From f1c991bd3ef4c96e0a502abc6f1ae1950d0343c1 Mon Sep 17 00:00:00 2001 From: Emma Date: Tue, 7 Feb 2023 08:36:58 -0700 Subject: [PATCH] Working version of train following spline path --- .vscode/settings.json | 25 ++ bundle.js | 859 ++++++++++++++++++++++++++++++++++++++++++ canvas/canvas.ts | 18 + deno.jsonc | 17 + drawing/circle.ts | 16 + drawing/index.ts | 1 + drawing/line.ts | 6 + index.html | 12 + main.ts | 207 ++++++++++ math/constants.ts | 3 + math/lerp.ts | 8 + math/path.ts | 172 +++++++++ math/vector.ts | 273 ++++++++++++++ physics/follower.ts | 158 ++++++++ physics/mover.ts | 112 ++++++ track.ts | 152 ++++++++ train.ts | 311 +++++++++++++++ 17 files changed, 2350 insertions(+) create mode 100644 .vscode/settings.json create mode 100644 bundle.js create mode 100644 canvas/canvas.ts create mode 100644 deno.jsonc create mode 100644 drawing/circle.ts create mode 100644 drawing/index.ts create mode 100644 drawing/line.ts create mode 100644 index.html create mode 100644 main.ts create mode 100644 math/constants.ts create mode 100644 math/lerp.ts create mode 100644 math/path.ts create mode 100644 math/vector.ts create mode 100644 physics/follower.ts create mode 100644 physics/mover.ts create mode 100644 track.ts create mode 100644 train.ts diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..25c1934 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,25 @@ +{ + "deno.enable": true, + "deno.unstable": true, + "deno.config": "./deno.jsonc", + "workbench.colorCustomizations": { + "activityBar.activeBackground": "#520088", + "activityBar.background": "#520088", + "activityBar.foreground": "#e7e7e7", + "activityBar.inactiveForeground": "#e7e7e799", + "activityBarBadge.background": "#9f6000", + "activityBarBadge.foreground": "#e7e7e7", + "commandCenter.border": "#e7e7e799", + "sash.hoverBorder": "#520088", + "statusBar.background": "#330055", + "statusBar.foreground": "#e7e7e7", + "statusBarItem.hoverBackground": "#520088", + "statusBarItem.remoteBackground": "#330055", + "statusBarItem.remoteForeground": "#e7e7e7", + "titleBar.activeBackground": "#330055", + "titleBar.activeForeground": "#e7e7e7", + "titleBar.inactiveBackground": "#33005599", + "titleBar.inactiveForeground": "#e7e7e799" + }, + "peacock.remoteColor": "330055" +} \ No newline at end of file diff --git a/bundle.js b/bundle.js new file mode 100644 index 0000000..8eaab4e --- /dev/null +++ b/bundle.js @@ -0,0 +1,859 @@ +// deno-fmt-ignore-file +// deno-lint-ignore-file +// This code was bundled using `deno bundle` and it's not recommended to edit it manually + +const Constants = { + TWO_PI: Math.PI * 2 +}; +const map = (value, x1, y1, x2, y2)=>(value - x1) * (y2 - x2) / (y1 - x1) + x2; +class Vector { + x; + y; + z; + constructor(x = 0, y = 0, z = 0){ + this.x = x; + this.y = y; + this.z = z; + } + set(v, y, z) { + if (arguments.length === 1 && typeof v !== "number") { + this.set(v.x || v[0] || 0, v.y || v[1] || 0, v.z || v[2] || 0); + } else { + this.x = v; + this.y = y || 0; + this.z = z || 0; + } + } + get() { + return new Vector(this.x, this.y, this.z); + } + mag() { + const x = this.x, y = this.y, z = this.z; + return Math.sqrt(x * x + y * y + z * z); + } + magSq() { + const x = this.x, y = this.y, z = this.z; + return x * x + y * y + z * z; + } + setMag(v_or_len, len) { + if (len === undefined) { + len = v_or_len; + this.normalize(); + this.mult(len); + } else { + const v = v_or_len; + v.normalize(); + v.mult(len); + return v; + } + } + add(v, y, z) { + if (arguments.length === 1 && typeof v !== 'number') { + this.x += v.x; + this.y += v.y; + this.z += v.z; + } else if (arguments.length === 2) { + this.x += v; + this.y += y ?? 0; + } else { + this.x += v; + this.y += y ?? 0; + this.z += z ?? 0; + } + } + sub(v, y, z) { + if (arguments.length === 1 && typeof v !== 'number') { + this.x -= v.x; + this.y -= v.y; + this.z -= v.z; + } else if (arguments.length === 2) { + this.x -= v; + this.y -= y ?? 0; + } else { + this.x -= v; + this.y -= y ?? 0; + this.z -= z ?? 0; + } + } + mult(v) { + if (typeof v === 'number') { + this.x *= v; + this.y *= v; + this.z *= v; + } else { + this.x *= v.x; + this.y *= v.y; + this.z *= v.z; + } + return this; + } + div(v) { + if (typeof v === 'number') { + this.x /= v; + this.y /= v; + this.z /= v; + } else { + this.x /= v.x; + this.y /= v.y; + this.z /= v.z; + } + } + rotate(angle) { + const prev_x = this.x; + const c = Math.cos(angle); + const s = Math.sin(angle); + this.x = c * this.x - s * this.y; + this.y = s * prev_x + c * this.y; + } + dist(v) { + const dx = this.x - v.x, dy = this.y - v.y, dz = this.z - v.z; + return Math.sqrt(dx * dx + dy * dy + dz * dz); + } + dot(v, y, z) { + if (arguments.length === 1 && typeof v !== 'number') { + return this.x * v.x + this.y * v.y + this.z * v.z; + } + return this.x * v + this.y * y + this.z * z; + } + cross(v) { + const x = this.x, y = this.y, z = this.z; + return new Vector(y * v.z - v.y * z, z * v.x - v.z * x, x * v.y - v.x * y); + } + lerp(v_or_x, amt_or_y, z, amt) { + const lerp_val = (start, stop, amt)=>{ + return start + (stop - start) * amt; + }; + let x, y; + if (arguments.length === 2 && typeof v_or_x !== 'number') { + amt = amt_or_y; + x = v_or_x.x; + y = v_or_x.y; + z = v_or_x.z; + } else { + x = v_or_x; + y = amt_or_y; + } + this.x = lerp_val(this.x, x, amt); + this.y = lerp_val(this.y, y, amt); + this.z = lerp_val(this.z, z, amt); + } + normalize() { + const m = this.mag(); + if (m > 0) { + this.div(m); + } + return this; + } + limit(high) { + if (this.mag() > high) { + this.normalize(); + this.mult(high); + } + } + heading() { + return -Math.atan2(-this.y, this.x); + } + heading2D() { + return this.heading(); + } + toString() { + return "[" + this.x + ", " + this.y + ", " + this.z + "]"; + } + array() { + return [ + this.x, + this.y, + this.z + ]; + } + copy() { + return new Vector(this.x, this.y, this.z); + } + drawDot(ctx) { + ctx.beginPath(); + ctx.arc(this.x, this.y, 2, 0, Constants.TWO_PI); + ctx.fill(); + } + static fromAngle(angle, v) { + if (v === undefined || v === null) { + v = new Vector(); + } + v.x = Math.cos(angle); + v.y = Math.sin(angle); + return v; + } + static random2D(v) { + return Vector.fromAngle(Math.random() * (Math.PI * 2), v); + } + static random3D(v) { + const angle = Math.random() * Constants.TWO_PI; + const vz = Math.random() * 2 - 1; + const mult = Math.sqrt(1 - vz * vz); + const vx = mult * Math.cos(angle); + const vy = mult * Math.sin(angle); + if (v === undefined || v === null) { + v = new Vector(vx, vy, vz); + } else { + v.set(vx, vy, vz); + } + return v; + } + static dist(v1, v2) { + return v1.dist(v2); + } + static dot(v1, v2) { + return v1.dot(v2); + } + static cross(v1, v2) { + return v1.cross(v2); + } + static add(v1, v2) { + return new Vector(v1.x + v2.x, v1.y + v2.y, v1.z + v2.z); + } + static sub(v1, v2) { + return new Vector(v1.x - v2.x, v1.y - v2.y, v1.z - v2.z); + } + static angleBetween(v1, v2) { + return Math.acos(v1.dot(v2) / Math.sqrt(v1.magSq() * v2.magSq())); + } + static lerp(v1, v2, amt) { + const retval = new Vector(v1.x, v1.y, v1.z); + retval.lerp(v2, amt); + return retval; + } + static vectorProjection(v1, v2) { + v2 = v2.copy(); + v2.normalize(); + const sp = v1.dot(v2); + v2.mult(sp); + return v2; + } + static hypot2(a, b) { + return Vector.dot(Vector.sub(a, b), Vector.sub(a, b)); + } +} +class ComplexPath { + points = []; + radius = 50; + ctx; + constructor(points){ + points && (this.points = points); + } + setContext(ctx) { + this.ctx = ctx; + } + draw() { + if (!this.ctx || !this.points.length) return; + const ctx = this.ctx; + ctx.save(); + ctx.lineWidth = 2; + ctx.strokeStyle = 'white'; + ctx.setLineDash([ + 21, + 6 + ]); + let last = this.points[this.points.length - 1]; + for (const point of this.points){ + ctx.beginPath(); + ctx.moveTo(last.x, last.y); + ctx.lineTo(point.x, point.y); + ctx.stroke(); + last = point; + } + ctx.restore(); + } +} +class PathSegment { + points; + ctx; + constructor(points){ + this.points = points; + } + setContext(ctx) { + this.ctx = ctx; + } + draw() { + if (!this.ctx) return; + const ctx = this.ctx; + ctx.save(); + ctx.beginPath(); + ctx.moveTo(this.points[0].x, this.points[0].y); + ctx.bezierCurveTo(this.points[1].x, this.points[1].y, this.points[2].x, this.points[2].y, this.points[3].x, this.points[3].y); + ctx.strokeStyle = '#ffffff50'; + ctx.lineWidth = 2; + ctx.stroke(); + ctx.restore(); + } + getPointAtT(t) { + const [a, b, c, d] = this.points; + const res = a.copy(); + res.add(Vector.add(a.copy().mult(-3), b.copy().mult(3)).mult(t)); + res.add(Vector.add(Vector.add(a.copy().mult(3), b.copy().mult(-6)), c.copy().mult(3)).mult(Math.pow(t, 2))); + res.add(Vector.add(Vector.add(a.copy().mult(-1), b.copy().mult(3)), Vector.add(c.copy().mult(-3), d.copy())).mult(Math.pow(t, 3))); + return res; + } + getClosestPoint(v) { + const resolution = 1 / 25; + let closest = this.points[0]; + let closestDistance = this.points[0].dist(v); + let closestT = 0; + for(let i = 0; i < 25; i++){ + const point = this.getPointAtT(i * resolution); + const distance = v.dist(point); + if (distance < closestDistance) { + closest = point; + closestDistance = distance; + closestT = i * resolution; + } + } + return [ + closest, + closestDistance, + closestT + ]; + } + getPointsWithinRadius(v, r) { + const points = []; + const resolution = 1 / 25; + for(let i = 0; i < 25; i++){ + const point = this.getPointAtT(i * resolution); + const distance = v.dist(point); + if (distance < r) { + points.push([ + i * resolution, + this + ]); + } + } + return points; + } + tangent(t) { + const [a, b, c, d] = this.points; + const res = Vector.sub(b, a).mult(3 * Math.pow(1 - t, 2)); + res.add(Vector.add(Vector.sub(c, b).mult(6 * (1 - t) * t), Vector.sub(d, c).mult(3 * Math.pow(t, 2)))); + return res; + } + doesIntersectCircle(x, y, r) { + const v = new Vector(x, y); + const resolution = 1 / 25; + let distance = Infinity; + let t; + for(let i = 0; i < 25; i++){ + if (i !== 25 - 1) { + const a = this.getPointAtT(i * resolution); + const b = this.getPointAtT((i + 1) * resolution); + const ac = Vector.sub(v, a); + const ab = Vector.sub(b, a); + const d = Vector.add(Vector.vectorProjection(ac, ab), a); + const ad = Vector.sub(d, a); + const k = Math.abs(ab.x) > Math.abs(ab.y) ? ad.x / ab.x : ad.y / ab.y; + let dist; + if (k <= 0.0) { + dist = Vector.hypot2(v, a); + } else if (k >= 1.0) { + dist = Vector.hypot2(v, b); + } + dist = Vector.hypot2(v, d); + if (dist < distance) { + distance = dist; + t = i * resolution; + } + } + } + if (distance < r) return t; + return false; + } +} +class Mover { + position; + velocity; + acceleration; + maxSpeed; + maxForce; + _trailingPoint; + _leadingPoint; + get trailingPoint() { + const desired = this.velocity.copy(); + desired.normalize(); + desired.mult(-this._trailingPoint); + return Vector.add(this.position, desired); + } + get leadingPoint() { + const desired = this.velocity.copy(); + desired.normalize(); + desired.mult(this._leadingPoint); + return Vector.add(this.position, desired); + } + ctx; + boundingBox; + constructor(posOrRandom, vel, acc){ + if (typeof posOrRandom === 'boolean' && posOrRandom) { + this.position = Vector.random2D(new Vector()); + this.velocity = Vector.random2D(new Vector()); + this.acceleration = new Vector(); + } else { + this.position = posOrRandom || new Vector(); + this.velocity = vel || new Vector(); + this.acceleration = acc || new Vector(); + } + this.boundingBox = { + size: new Vector(20, 10), + pos: new Vector(this.position.x - 10, this.position.y - 5) + }; + this.maxSpeed = 3; + this.maxForce = .3; + this._trailingPoint = 0; + this._leadingPoint = 0; + this.init(); + } + init() {} + move() { + this.velocity.limit(this.maxSpeed); + this.acceleration.limit(this.maxForce); + this.velocity.add(this.acceleration); + this.position.add(this.velocity); + this.edges(); + this.draw(); + } + edges() { + if (!this.ctx) return; + if (this.position.x > this.ctx.canvas.width) this.position.x = 0; + if (this.position.y > this.ctx.canvas.height) this.position.y = 0; + if (this.position.x < 0) this.position.x = this.ctx.canvas.width; + if (this.position.y < 0) this.position.y = this.ctx.canvas.height; + } + draw() { + if (!this.ctx) return; + this.ctx.fillStyle = 'white'; + this.ctx.save(); + this.ctx.translate(this.position.x, this.position.y); + this.ctx.rotate(this.velocity.heading() || 0); + this.ctx.translate(-this.position.x, -this.position.y); + this.ctx.translate(-(this.boundingBox.size.x / 2), -(this.boundingBox.size.y / 2)); + this.ctx.fillRect(this.position.x, this.position.y, this.boundingBox.size.x, this.boundingBox.size.y); + this.ctx.restore(); + } + setContext(ctx) { + this.ctx = ctx; + } + applyForce(force) { + this.acceleration.add(force); + } + static edges(point, width, height) { + if (point.x > width) point.x = 0; + if (point.y > height) point.y = 0; + if (point.x < 0) point.x = width; + if (point.y < 0) point.y = height; + } +} +class Follower extends Mover { + debug = true; + follow(toFollow) { + if (toFollow instanceof ComplexPath) { + const predict = this.velocity.copy(); + predict.normalize(); + predict.mult(25); + const predictpos = Vector.add(this.position, predict); + if (this.ctx) Mover.edges(predict, this.ctx.canvas.width, this.ctx.canvas.height); + let normal = null; + let target = null; + let worldRecord = 1000000; + for(let i = 0; i < toFollow.points.length; i++){ + let a = toFollow.points[i]; + let b = toFollow.points[(i + 1) % toFollow.points.length]; + let normalPoint = getNormalPoint(predictpos, a, b); + let dir = Vector.sub(b, a); + if (normalPoint.x < Math.min(a.x, b.x) || normalPoint.x > Math.max(a.x, b.x) || normalPoint.y < Math.min(a.y, b.y) || normalPoint.y > Math.max(a.y, b.y)) { + normalPoint = b.copy(); + a = toFollow.points[(i + 1) % toFollow.points.length]; + b = toFollow.points[(i + 2) % toFollow.points.length]; + dir = Vector.sub(b, a); + } + const d = Vector.dist(predictpos, normalPoint); + if (d < worldRecord) { + worldRecord = d; + normal = normalPoint; + dir.normalize(); + dir.mult(25); + target = normal.copy(); + target.add(dir); + } + if (worldRecord > toFollow.radius) { + return this.seek(target); + } + } + if (this.debug && this.ctx) { + this.ctx.strokeStyle = 'red'; + this.ctx.fillStyle = 'pink'; + this.ctx.beginPath(); + this.ctx.moveTo(this.position.x, this.position.y); + this.ctx.lineTo(predictpos.x, predictpos.y); + this.ctx.stroke(); + this.ctx.beginPath(); + this.ctx.arc(predictpos.x, predictpos.y, 4, 0, Constants.TWO_PI); + this.ctx.fill(); + this.ctx.stroke(); + this.ctx.beginPath(); + this.ctx.arc(normal.x, normal.y, 4, 0, Constants.TWO_PI); + this.ctx.fill(); + this.ctx.stroke(); + this.ctx.beginPath(); + this.ctx.moveTo(predictpos.x, predictpos.y); + this.ctx.lineTo(target.x, target.y); + this.ctx.stroke(); + this.ctx.beginPath(); + this.ctx.arc(target.x, target.y, 8, 0, Constants.TWO_PI); + this.ctx.fill(); + this.ctx.stroke(); + } + } + } + seek(target, strength = 1) { + const desired = Vector.sub(target, this.position); + desired.normalize(); + desired.mult(this.maxSpeed); + const steer = Vector.sub(desired, this.velocity); + steer.limit(this.maxForce); + this.applyForce(steer.mult(strength)); + } + link(target) { + this.position = target.trailingPoint; + this.seek(target.trailingPoint); + } + arrive(target) { + const desired = Vector.sub(target, this.position); + const d = desired.mag(); + let speed = this.maxSpeed; + if (d < 10) { + speed = map(d, 0, 100, 0, this.maxSpeed); + } + desired.setMag(speed); + const steer = Vector.sub(desired, this.velocity); + steer.limit(this.maxForce); + this.applyForce(steer); + } +} +function getNormalPoint(p, a, b) { + const ap = Vector.sub(p, a); + const ab = Vector.sub(b, a); + ab.normalize(); + ab.mult(ap.dot(ab)); + const normalPoint = Vector.add(a, ab); + return normalPoint; +} +class Train extends Follower { + nodes; + currentTrack; + speed; + follower; + followers; + constructor(track, length){ + super(track.points[0].copy()); + this.maxSpeed = 2; + this.speed = 1; + this.currentTrack = track; + this.velocity = this.currentTrack.tangent(0).normalize().mult(this.maxSpeed); + this.addCar(length); + this.maxForce = .2; + } + init() { + this.boundingBox.size.set(30, 10); + this._trailingPoint = 30; + } + move() { + this.follow(); + super.move(); + this.follower?.move(); + } + follow() { + const [_, t] = this.currentTrack.followTrack(this); + this.velocity = this.currentTrack.tangent(t); + this.velocity.normalize().mult(this.speed || this.maxSpeed); + } + setContext(ctx) { + super.setContext(ctx); + this.follower?.setContext(ctx); + } + addCar(length) { + console.log(length); + if (length) this.follower = new TrainCar(this.currentTrack, length - 1); + this.follower?.setTarget(this); + this.follower?.position.set(this.trailingPoint); + this._trailingPoint -= 2; + } +} +class TrainCar extends Train { + target; + setTarget(t) { + this.target = t; + } + init() { + this.boundingBox.size.set(20, 10); + this._trailingPoint = 25; + this.maxSpeed = this.maxSpeed * 2; + this.maxForce = this.maxForce * 2; + } + move() { + if (this.target) { + if (this.position.dist(this.target.position) > this.target.position.dist(this.target.trailingPoint)) { + this.arrive(this.currentTrack.getNearestPoint(this.target.trailingPoint)); + this.speed = this.target.speed; + super.move(); + } else { + this.draw(); + } + } + } + edges() {} +} +class Track extends PathSegment { + editable = false; + next; + prev; + id; + constructor(points, next, prev){ + super(points); + this.id = crypto.randomUUID(); + this.next = next || this; + this.prev = prev || this; + } + followTrack(train) { + const predict = train.velocity.copy(); + predict.normalize(); + predict.mult(1); + const predictpos = Vector.add(train.position, predict); + let [closest, closestDistance, closestT] = this.getClosestPoint(predictpos); + let mostValid = this; + if (this.next !== this) { + const [point, distance, t] = this.next.getClosestPoint(predictpos); + if (distance < closestDistance) { + closest = point; + closestDistance = distance; + mostValid = this.next; + closestT = t; + } + } + if (this.prev !== this) { + const [point1, distance1, t1] = this.next.getClosestPoint(predictpos); + if (distance1 < closestDistance) { + closest = point1; + closestDistance = distance1; + mostValid = this.next; + closestT = t1; + } + } + train.currentTrack = mostValid; + train.arrive(closest); + return [ + closest, + closestT + ]; + } + getNearestPoint(p) { + let [closest, closestDistance, closestT] = this.getClosestPoint(p); + let mostValid = this; + if (this.next !== this) { + const [point, distance, t] = this.next.getClosestPoint(p); + if (distance < closestDistance) { + closest = point; + closestDistance = distance; + mostValid = this.next; + t; + } + } + if (this.prev !== this) { + const [point1, distance1, t1] = this.next.getClosestPoint(p); + if (distance1 < closestDistance) { + closest = point1; + closestDistance = distance1; + mostValid = this.next; + t1; + } + } + return closest; + } + getAllPointsInRange(v, r) { + const points = this.getPointsWithinRadius(v, r).concat(this.next.getPointsWithinRadius(v, r), this.prev.getPointsWithinRadius(v, r)); + return points; + } + draw() { + super.draw(); + if (this.ctx && this.editable) for (const e of this.points){ + this.ctx.fillStyle = 'blue'; + e.drawDot(this.ctx); + } + } +} +class Spline { + segments = []; + ctx; + constructor(segs){ + this.segments = segs; + } + setContext(ctx) { + this.ctx = ctx; + for (const segment of this.segments){ + segment.setContext(ctx); + } + } + draw() { + for (const segment of this.segments){ + segment.draw(); + } + } +} +const generateSquareTrack = ()=>{ + const first = new Track([ + new Vector(20, 40), + new Vector(20, 100), + new Vector(20, 300), + new Vector(20, 360) + ]); + const second = new Track([ + first.points[3], + new Vector(20, 370), + new Vector(30, 380), + new Vector(40, 380) + ]); + const third = new Track([ + second.points[3], + new Vector(100, 380), + new Vector(300, 380), + new Vector(360, 380) + ]); + const fourth = new Track([ + third.points[3], + new Vector(370, 380), + new Vector(380, 370), + new Vector(380, 360) + ]); + const fifth = new Track([ + fourth.points[3], + new Vector(380, 300), + new Vector(380, 100), + new Vector(380, 40) + ]); + const sixth = new Track([ + fifth.points[3], + new Vector(380, 30), + new Vector(370, 20), + new Vector(360, 20) + ]); + const seventh = new Track([ + sixth.points[3], + new Vector(300, 20), + new Vector(100, 20), + new Vector(40, 20) + ]); + const eighth = new Track([ + seventh.points[3], + new Vector(30, 20), + new Vector(20, 30), + first.points[0] + ]); + const tracks = [ + first, + second, + third, + fourth, + fifth, + sixth, + seventh, + eighth + ]; + for (const [i, track] of tracks.entries()){ + track.next = tracks[(i + 1) % tracks.length]; + track.prev = tracks.at(i - 1); + } + return new Spline([ + first, + second, + third, + fourth, + fifth, + sixth, + seventh, + eighth + ]); +}; +const drawLine = (ctx, x1, y1, x2, y2)=>{ + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + ctx.stroke(); +}; +const hello = ()=>{ + console.log('HELLO WORLD'); +}; +hello(); +const canvas = document.createElement('canvas'); +canvas.height = 400; +canvas.width = 400; +document.body.append(canvas); +const ctx = canvas.getContext('2d'); +const clear = ()=>{ + ctx.fillStyle = 'black'; + ctx.fillRect(0, 0, canvas.width, canvas.height); +}; +setInterval(()=>{ + draw(); +}, 1000 / 60); +const path = generateSquareTrack(); +path.setContext(ctx); +let t = 0; +let currentSeg = 0; +const trains = Array(1).fill(null).map((_, i)=>new Train(path.segments[i % path.segments.length], 5)); +for (const train of trains){ + train.setContext(ctx); +} +function draw() { + clear(); + path.draw(); + for (const train of trains){ + train.move(); + } + ctx.strokeStyle = 'red'; + ctx.lineWidth = 4; + const seg = path.segments[currentSeg]; + const start = seg.getPointAtT(t); + const tan = seg.tangent(t).normalize().mult(25); + drawLine(ctx, start.x, start.y, start.x + tan.x, start.y + tan.y); + t += .01; + if (t > 1) { + t -= 1; + currentSeg = (currentSeg + 1) % path.segments.length; + } +} +document.addEventListener('keyup', (e)=>{ + if (e.key === 'd') { + console.log(trains); + } + if (e.key === 'ArrowUp') { + for (const train of trains){ + train.speed += .1; + } + } + if (e.key === 'ArrowDown') { + for (const train1 of trains){ + train1.speed -= .1; + } + } + if (e.key === 'e') { + for (const t of path.segments){ + t.editable = !t.editable; + } + } +}); +document.addEventListener('keydown', (e)=>{ + if (e.ctrlKey && e.key === 's') { + e.preventDefault(); + path.segments.forEach((s)=>{ + s.next = s.next.id; + s.prev = s.prev.id; + delete s.ctx; + }); + delete path.ctx; + const json = JSON.stringify(path); + localStorage.setItem('railPath', json); + } +}); diff --git a/canvas/canvas.ts b/canvas/canvas.ts new file mode 100644 index 0000000..4a0303b --- /dev/null +++ b/canvas/canvas.ts @@ -0,0 +1,18 @@ + +type ClickEvent = { + mouseX: number; + mouseY: number; +} +type ClickEventHandler = (e: ClickEvent) => void; + +export class Canvas { + clickables: ClickEventHandler[] = []; + + constructor(); + constructor(width: number, height: number); + constructor(width?: number, height?: number) { + const canvas = document.createElement('canvas'); + canvas.width = width || 400; + canvas.height = height || 400; + } +} \ No newline at end of file diff --git a/deno.jsonc b/deno.jsonc new file mode 100644 index 0000000..3770318 --- /dev/null +++ b/deno.jsonc @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "lib": [ + // "deno.window" + "DOM", + "ES2021", + "ESNext" + ] + }, + "tasks": { + "dev": "deno bundle --watch main.ts bundle.js" + }, + "imports": { + "drawing": "./drawing/index.ts", + "doodler": "https://git.cyborggrizzly.com/emma/doodler/raw/branch/main/mod.ts" + } +} \ No newline at end of file diff --git a/drawing/circle.ts b/drawing/circle.ts new file mode 100644 index 0000000..1245aea --- /dev/null +++ b/drawing/circle.ts @@ -0,0 +1,16 @@ +import { Constants } from "../math/constants.ts"; +import { Vector } from "../math/vector.ts"; + +const circle = (ctx: CanvasRenderingContext2D, center: Vector, radius: number) => { + ctx.beginPath(); + ctx.arc(center.x, center.y, radius, 0, Constants.TWO_PI); +} + +export const drawCircle = (ctx: CanvasRenderingContext2D, center: Vector, radius: number) => { + circle(ctx, center, radius); + ctx.stroke(); +} +export const fillCircle = (ctx: CanvasRenderingContext2D, center: Vector, radius: number) => { + circle(ctx, center, radius); + ctx.fill(); +} \ No newline at end of file diff --git a/drawing/index.ts b/drawing/index.ts new file mode 100644 index 0000000..ff4277a --- /dev/null +++ b/drawing/index.ts @@ -0,0 +1 @@ +export { drawCircle, fillCircle } from './circle.ts' \ No newline at end of file diff --git a/drawing/line.ts b/drawing/line.ts new file mode 100644 index 0000000..50b431c --- /dev/null +++ b/drawing/line.ts @@ -0,0 +1,6 @@ +export const drawLine = (ctx: CanvasRenderingContext2D, x1:number, y1:number, x2:number, y2: number) => { + ctx.beginPath(); + ctx.moveTo(x1,y1); + ctx.lineTo(x2,y2); + ctx.stroke(); +} \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..5d86e0c --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + + TRAINS! + + + + + \ No newline at end of file diff --git a/main.ts b/main.ts new file mode 100644 index 0000000..a00b623 --- /dev/null +++ b/main.ts @@ -0,0 +1,207 @@ +import { lerp } from "./math/lerp.ts"; +import { ComplexPath, PathSegment } from "./math/path.ts"; +import { Vector } from "./math/vector.ts"; +import { Mover } from "./physics/mover.ts"; +import { Train } from "./train.ts"; +import { fillCircle, drawCircle } from 'drawing'; +import { generateSquareTrack } from "./track.ts"; +import { drawLine } from "./drawing/line.ts"; +import { hello } from 'doodler'; + +hello(); + +const canvas = document.createElement('canvas'); +canvas.height = 400; +canvas.width = 400; +document.body.append(canvas); +const ctx = canvas.getContext('2d')!; + +// for (const mover of trains) { +// mover.setContext(ctx); +// mover.velocity.add(Vector.random2D()) +// } + +const clear = () => { + ctx.fillStyle = 'black'; + ctx.fillRect(0, 0, canvas.width, canvas.height) +} + +const fps = 60; + +setInterval(() => { + // for (const train of trains) { + // train.move(); + // } + draw(); +}, 1000 / fps); + + +// const path = new PathSegment([new Vector(20, 20), new Vector(200, 100), new Vector(200, 300), new Vector(20, 380)]); +const path = generateSquareTrack(); +path.setContext(ctx); +// const train = new Train(path.segments[0], 4); +// train.setContext(ctx); +// train.velocity.x = -1; +// train.velocity.y = 1; + +const controls = { + ArrowUp: false, + ArrowRight: false, + ArrowDown: false, + ArrowLeft: false, +} + +let t = 0; +let currentSeg = 0; + +const trainCount = 1; +const trains = Array(trainCount).fill(null).map((_, i) => new Train(path.segments[i % path.segments.length], 5)); +for (const train of trains) { + train.setContext(ctx); + // train.maxSpeed = Math.random() * 5 + 1 +} + +function draw() { + clear(); + path.draw(); + + // for (const control in controls) { + // if (controls.hasOwnProperty(control)) { + // const isActive = controls[control as keyof typeof controls]; + // if (isActive) { + // const force = getSteeringForce(train, control); + // train.applyForce(force); + // } + // } + + // if (Object.values(controls).every(c => !c)) { + // train.acceleration.set(0, 0) + // } + // } + + // train.follow(path) + for (const train of trains) { + train.move(); + } + // ctx.strokeStyle = 'orange'; + + ctx.strokeStyle = 'red'; + ctx.lineWidth = 4; + const seg = path.segments[currentSeg]; + const start = seg.getPointAtT(t); + const tan = seg.tangent(t).normalize().mult(25); + drawLine(ctx, start.x, start.y, start.x + tan.x, start.y + tan.y); + + t += .01; + if (t > 1) { + t -= 1; + currentSeg = (currentSeg + 1) % path.segments.length; + } +} + +// let wKeydown =false + +// document.addEventListener('keydown', e => { +// if (e.key === 'w' && !wKeydown) { +// wKeydown = true; +// for (const train of trains) { +// train.acceleration.add(.1, 0); +// } +// } +// }); +// document.addEventListener('keyup', e => { +// if (e.key === 'w') { +// wKeydown = false; +// for (const train of trains) { +// train.acceleration.sub(.1, 0); +// } +// } +// }); +// let sKeydown = false; +// document.addEventListener('keydown', e => { +// if (e.key === 's' && !sKeydown) { +// sKeydown = true; +// for (const train of trains) { +// train.acceleration.sub(.1, 0); +// } +// } +// }); +// document.addEventListener('keyup', e => { +// if (e.key === 's') { +// sKeydown = false; +// for (const train of trains) { +// train.acceleration.add(.1, 0); +// } +// } +// }); + +document.addEventListener('keyup', e => { + if (e.key === 'd') { + console.log(trains) + } + + if (e.key === 'ArrowUp') { + for (const train of trains) { + train.speed += .1; + } + } + if (e.key === 'ArrowDown') { + for (const train of trains) { + train.speed -= .1; + } + } + + if (e.key === 'e') { + for (const t of path.segments) { + t.editable = !t.editable; + } + } +}) + +// document.addEventListener('keydown', e => { +// const valid = ["ArrowUp", +// "ArrowRight", +// "ArrowDown", +// "ArrowLeft",] +// if (valid.includes(e.key)) +// controls[e.key as keyof typeof controls] = true; +// }) +// document.addEventListener('keyup', e => { +// const valid = ["ArrowUp", +// "ArrowRight", +// "ArrowDown", +// "ArrowLeft",] +// if (valid.includes(e.key)) +// controls[e.key as keyof typeof controls] = false; +// }) + +// function getSteeringForce(mover: Mover, dir: string) { +// const dirs = { +// ArrowUp: 0, +// ArrowRight: .1 * Math.PI, +// ArrowDown: Math.PI, +// ArrowLeft: -.1 * Math.PI, +// } + +// const target = mover.velocity.copy(); +// target.normalize(); +// target.mult(10); +// target.rotate(dirs[dir as keyof typeof dirs]); +// const force = Vector.sub(target, mover.velocity); +// force.limit(.1) +// return force; +// } + +document.addEventListener('keydown', e => { + if (e.ctrlKey && e.key === 's') { + e.preventDefault(); + path.segments.forEach((s: any) => { + s.next = s.next.id + s.prev = s.prev.id + delete s.ctx + }) + delete path.ctx; + const json = JSON.stringify(path); + localStorage.setItem('railPath', json); + } +}) \ No newline at end of file diff --git a/math/constants.ts b/math/constants.ts new file mode 100644 index 0000000..f619913 --- /dev/null +++ b/math/constants.ts @@ -0,0 +1,3 @@ +export const Constants = { + TWO_PI: Math.PI * 2 +} \ No newline at end of file diff --git a/math/lerp.ts b/math/lerp.ts new file mode 100644 index 0000000..f6acaeb --- /dev/null +++ b/math/lerp.ts @@ -0,0 +1,8 @@ +import { Vector } from "./vector.ts"; + +export const lerp = (a: number, b: number, t: number) => { + return (a*t) + (b*(1-t)); +} + +export const map = (value: number, x1: number, y1: number, x2: number, y2: number) => +(value - x1) * (y2 - x2) / (y1 - x1) + x2; \ No newline at end of file diff --git a/math/path.ts b/math/path.ts new file mode 100644 index 0000000..16ae52c --- /dev/null +++ b/math/path.ts @@ -0,0 +1,172 @@ +import { Vector } from "./vector.ts"; + +export class ComplexPath { + + points: Vector[] = []; + + radius = 50; + + ctx?: CanvasRenderingContext2D; + + constructor(points?: Vector[]) { + points && (this.points = points); + } + + setContext(ctx: CanvasRenderingContext2D) { + this.ctx = ctx; + } + + draw() { + if (!this.ctx || !this.points.length) return; + const ctx = this.ctx; + + ctx.save(); + ctx.lineWidth = 2; + ctx.strokeStyle = 'white'; + ctx.setLineDash([21, 6]) + + let last = this.points[this.points.length - 1] + + for (const point of this.points) { + ctx.beginPath(); + ctx.moveTo(last.x, last.y); + ctx.lineTo(point.x, point.y); + ctx.stroke(); + last = point; + } + ctx.restore(); + } +} + +export class PathSegment { + points: [Vector, Vector, Vector, Vector] + ctx?: CanvasRenderingContext2D; + + constructor(points: [Vector, Vector, Vector, Vector]) { + this.points = points; + } + + setContext(ctx: CanvasRenderingContext2D) { + this.ctx = ctx; + } + + draw() { + if (!this.ctx) return; + const ctx = this.ctx; + + ctx.save(); + ctx.beginPath(); + ctx.moveTo(this.points[0].x, this.points[0].y); + + ctx.bezierCurveTo( + this.points[1].x, + this.points[1].y, + this.points[2].x, + this.points[2].y, + this.points[3].x, + this.points[3].y, + ); + + ctx.strokeStyle = '#ffffff50'; + ctx.lineWidth = 2; + ctx.stroke(); + ctx.restore(); + } + + getPointAtT(t: number) { + const [a, b, c, d] = this.points; + const res = a.copy(); + + res.add(Vector.add(a.copy().mult(-3), b.copy().mult(3)).mult(t)) + res.add(Vector.add(Vector.add(a.copy().mult(3), b.copy().mult(-6)), c.copy().mult(3)).mult(Math.pow(t, 2))); + res.add(Vector.add(Vector.add(a.copy().mult(-1), b.copy().mult(3)), Vector.add(c.copy().mult(-3), d.copy())).mult(Math.pow(t, 3))); + + return res; + } + + getClosestPoint(v: Vector): [Vector, number, number] { + const samples = 25; + const resolution = 1 / samples; + let closest = this.points[0]; + let closestDistance = this.points[0].dist(v); + let closestT = 0; + + for (let i = 0; i < samples; i++) { + const point = this.getPointAtT(i * resolution); + const distance = v.dist(point); + if (distance < closestDistance) { + closest = point; + closestDistance = distance; + closestT = i * resolution; + } + } + + return [closest, closestDistance, closestT]; + } + + getPointsWithinRadius(v: Vector, r: number) { + const points: [number, PathSegment][] = []; + const samples = 25; + const resolution = 1 / samples; + + for (let i = 0; i < samples; i++) { + const point = this.getPointAtT(i * resolution); + const distance = v.dist(point); + if (distance < r) { + points.push([i * resolution,this]); + } + } + return points + } + + tangent(t: number) { + // dP(t) / dt = -3(1-t)^2 * P0 + 3(1-t)^2 * P1 - 6t(1-t) * P1 - 3t^2 * P2 + 6t(1-t) * P2 + 3t^2 * P3 + const [a, b, c, d] = this.points; + + const res = Vector.sub(b, a).mult(3 * Math.pow(1 - t, 2)); + res.add(Vector.add(Vector.sub(c, b).mult(6 * (1 - t) * t), Vector.sub(d, c).mult(3 * Math.pow(t, 2)))); + + return res; + } + + doesIntersectCircle(x: number, y: number, r: number) { + const v = new Vector(x, y); + const samples = 25; + const resolution = 1 / samples; + + let distance = Infinity; + let t; + + for (let i = 0; i < samples; i++) { + if (i !== samples - 1) { + const a = this.getPointAtT(i * resolution); + const b = this.getPointAtT((i + 1) * resolution); + const ac = Vector.sub(v, a); + const ab = Vector.sub(b, a); + + const d = Vector.add(Vector.vectorProjection(ac, ab), a); + const ad = Vector.sub(d, a); + + const k = Math.abs(ab.x) > Math.abs(ab.y) ? ad.x / ab.x : ad.y / ab.y; + + let dist; + if (k <= 0.0) { + dist = Vector.hypot2(v, a) + } else if (k >= 1.0) { + dist = Vector.hypot2(v, b) + } + + dist = Vector.hypot2(v, d) + + if (dist < distance) { + distance = dist; + t = i * resolution; + } + } + } + + if (distance < r) return t; + + return false; + } +} diff --git a/math/vector.ts b/math/vector.ts new file mode 100644 index 0000000..f18c8af --- /dev/null +++ b/math/vector.ts @@ -0,0 +1,273 @@ +import { Constants } from "./constants.ts"; + +export class Vector { + x: number; + y: number; + z: number; + + constructor(x = 0, y = 0, z = 0) { + this.x = x; + this.y = y; + this.z = z; + } + + set(x: number, y: number, z?: number): void; + set(v: Vector): void; + set(v: [number, number, number]): void; + set(v: Vector | [number, number, number] | number, y?: number, z?: number) { + if (arguments.length === 1 && typeof v !== "number") { + this.set((v as Vector).x || (v as Array)[0] || 0, + (v as Vector).y || (v as Array)[1] || 0, + (v as Vector).z || (v as Array)[2] || 0); + } else { + this.x = v as number; + this.y = y || 0; + this.z = z || 0; + } + } + get() { + return new Vector(this.x, this.y, this.z); + } + mag() { + const x = this.x, + y = this.y, + z = this.z; + return Math.sqrt(x * x + y * y + z * z); + } + magSq() { + const x = this.x, + y = this.y, + z = this.z; + return (x * x + y * y + z * z); + } + setMag(len: number): void; + setMag(v: Vector, len: number): Vector + setMag(v_or_len: Vector | number, len?: number) { + if (len === undefined) { + len = v_or_len as number; + this.normalize(); + this.mult(len); + } else { + const v = v_or_len as Vector; + v.normalize(); + v.mult(len); + return v; + } + } + add(x: number, y: number, z: number): void; + add(x: number, y: number): void; + add(v: Vector): void; + add(v: Vector | number, y?: number, z?: number) { + if (arguments.length === 1 && typeof v !== 'number') { + this.x += v.x; + this.y += v.y; + this.z += v.z; + } else if (arguments.length === 2) { + // 2D Vector + this.x += v as number; + this.y += y ?? 0; + } else { + this.x += v as number; + this.y += y ?? 0; + this.z += z ?? 0; + } + } + sub(x: number, y: number, z: number): void; + sub(x: number, y: number): void; + sub(v: Vector): void; + sub(v: Vector | number, y?: number, z?: number) { + if (arguments.length === 1 && typeof v !== 'number') { + this.x -= v.x; + this.y -= v.y; + this.z -= v.z; + } else if (arguments.length === 2) { + // 2D Vector + this.x -= v as number; + this.y -= y ?? 0; + } else { + this.x -= v as number; + this.y -= y ?? 0; + this.z -= z ?? 0; + } + } + mult(v: number | Vector) { + if (typeof v === 'number') { + this.x *= v; + this.y *= v; + this.z *= v; + } else { + this.x *= v.x; + this.y *= v.y; + this.z *= v.z; + } + return this; + } + div(v: number | Vector) { + if (typeof v === 'number') { + this.x /= v; + this.y /= v; + this.z /= v; + } else { + this.x /= v.x; + this.y /= v.y; + this.z /= v.z; + } + } + rotate(angle: number) { + const prev_x = this.x; + const c = Math.cos(angle); + const s = Math.sin(angle); + this.x = c * this.x - s * this.y; + this.y = s * prev_x + c * this.y; + } + dist(v: Vector) { + const dx = this.x - v.x, + dy = this.y - v.y, + dz = this.z - v.z; + return Math.sqrt(dx * dx + dy * dy + dz * dz); + } + dot(x: number, y: number, z: number): number; + dot(v: Vector): number; + dot(v: Vector | number, y?: number, z?: number) { + if (arguments.length === 1 && typeof v !== 'number') { + return (this.x * v.x + this.y * v.y + this.z * v.z); + } + return (this.x * (v as number) + this.y * y! + this.z * z!); + } + cross(v: Vector) { + const x = this.x, + y = this.y, + z = this.z; + return new Vector(y * v.z - v.y * z, + z * v.x - v.z * x, + x * v.y - v.x * y); + } + lerp(x: number, y: number, z: number): void; + lerp(v: Vector, amt: number): void; + lerp(v_or_x: Vector | number, amt_or_y: number, z?: number, amt?: number) { + const lerp_val = (start: number, stop: number, amt: number) => { + return start + (stop - start) * amt; + }; + let x, y: number; + if (arguments.length === 2 && typeof v_or_x !== 'number') { + // given vector and amt + amt = amt_or_y; + x = v_or_x.x; + y = v_or_x.y; + z = v_or_x.z; + } else { + // given x, y, z and amt + x = v_or_x as number; + y = amt_or_y; + } + this.x = lerp_val(this.x, x, amt!); + this.y = lerp_val(this.y, y, amt!); + this.z = lerp_val(this.z, z!, amt!); + } + normalize() { + const m = this.mag(); + if (m > 0) { + this.div(m); + } + return this; + } + limit(high: number) { + if (this.mag() > high) { + this.normalize(); + this.mult(high); + } + } + heading() { + return (-Math.atan2(-this.y, this.x)); + } + heading2D() { + return this.heading(); + } + toString() { + return "[" + this.x + ", " + this.y + ", " + this.z + "]"; + } + array() { + return [this.x, this.y, this.z]; + } + + copy() { + return new Vector(this.x, this.y, this.z); + } + + drawDot(ctx: CanvasRenderingContext2D) { + // ctx.fillStyle = 'red' + ctx.beginPath(); + ctx.arc(this.x, this.y, 2, 0, Constants.TWO_PI); + ctx.fill(); + } + + static fromAngle(angle: number, v?: Vector) { + if (v === undefined || v === null) { + v = new Vector(); + } + v.x = Math.cos(angle); + v.y = Math.sin(angle); + return v; + } + + static random2D(v?: Vector) { + return Vector.fromAngle(Math.random() * (Math.PI * 2), v); + } + + static random3D(v: Vector) { + const angle = Math.random() * Constants.TWO_PI; + const vz = Math.random() * 2 - 1; + const mult = Math.sqrt(1 - vz * vz); + const vx = mult * Math.cos(angle); + const vy = mult * Math.sin(angle); + if (v === undefined || v === null) { + v = new Vector(vx, vy, vz); + } else { + v.set(vx, vy, vz); + } + return v; + } + + static dist(v1: Vector, v2: Vector) { + return v1.dist(v2); + } + + static dot(v1: Vector, v2: Vector) { + return v1.dot(v2); + } + + static cross(v1: Vector, v2: Vector) { + return v1.cross(v2); + } + + static add(v1: Vector, v2: Vector) { + return new Vector(v1.x + v2.x, v1.y + v2.y, v1.z + v2.z); + } + + static sub(v1: Vector, v2: Vector) { + return new Vector(v1.x - v2.x, v1.y - v2.y, v1.z - v2.z); + } + + static angleBetween(v1: Vector, v2: Vector) { + return Math.acos(v1.dot(v2) / Math.sqrt(v1.magSq() * v2.magSq())); + } + + static lerp(v1: Vector, v2: Vector, amt: number) { + // non-static lerp mutates object, but this version returns a new vector + const retval = new Vector(v1.x, v1.y, v1.z); + retval.lerp(v2, amt); + return retval; + } + + static vectorProjection(v1: Vector, v2: Vector) { + v2 = v2.copy(); + v2.normalize(); + const sp = v1.dot(v2); + v2.mult(sp); + return v2; + } + + static hypot2(a: Vector, b: Vector) { + return Vector.dot(Vector.sub(a,b), Vector.sub(a,b)) + } +} diff --git a/physics/follower.ts b/physics/follower.ts new file mode 100644 index 0000000..1c7744a --- /dev/null +++ b/physics/follower.ts @@ -0,0 +1,158 @@ +import { Constants } from "../math/constants.ts"; +import { map } from "../math/lerp.ts"; +import { ComplexPath, PathSegment } from "../math/path.ts"; +import { Vector } from "../math/vector.ts"; +import { Mover } from "./mover.ts"; + +export class + Follower extends Mover { + debug = true; + + follow(toFollow: ComplexPath | PathSegment) { + if (toFollow instanceof ComplexPath) { + const predict = this.velocity.copy(); + predict.normalize(); + predict.mult(25); + const predictpos = Vector.add(this.position, predict) + + if (this.ctx) + Mover.edges(predict, this.ctx.canvas.width, this.ctx.canvas.height) + let normal = null; + let target = null; + let worldRecord = 1000000; + + for (let i = 0; i < toFollow.points.length; i++) { + // Look at a line segment + let a = toFollow.points[i]; + let b = toFollow.points[(i + 1) % toFollow.points.length]; // Note Path has to wraparound + + // Get the normal point to that line + let normalPoint = getNormalPoint(predictpos, a, b); + + // Check if normal is on line segment + let dir = Vector.sub(b, a); + // If it's not within the line segment, consider the normal to just be the end of the line segment (point b) + //if (da + db > line.mag()+1) { + if ( + normalPoint.x < Math.min(a.x, b.x) || + normalPoint.x > Math.max(a.x, b.x) || + normalPoint.y < Math.min(a.y, b.y) || + normalPoint.y > Math.max(a.y, b.y) + ) { + normalPoint = b.copy(); + // If we're at the end we really want the next line segment for looking ahead + a = toFollow.points[(i + 1) % toFollow.points.length]; + b = toFollow.points[(i + 2) % toFollow.points.length]; // Path wraps around + dir = Vector.sub(b, a); + } + + // How far away are we from the path? + const d = Vector.dist(predictpos, normalPoint); + // Did we beat the worldRecord and find the closest line segment? + if (d < worldRecord) { + worldRecord = d; + normal = normalPoint; + + // Look at the direction of the line segment so we can seek a little bit ahead of the normal + dir.normalize(); + // This is an oversimplification + // Should be based on distance to path & velocity + dir.mult(25); + target = normal.copy(); + target.add(dir); + } + + if (worldRecord > toFollow.radius) { + return this.seek(target!); + } + } + + if (this.debug && this.ctx) { + // Draw predicted future position + this.ctx.strokeStyle = 'red'; + this.ctx.fillStyle = 'pink'; + this.ctx.beginPath(); + this.ctx.moveTo(this.position.x, this.position.y) + this.ctx.lineTo(predictpos.x, predictpos.y); + this.ctx.stroke(); + + this.ctx.beginPath(); + this.ctx.arc(predictpos.x, predictpos.y, 4, 0, Constants.TWO_PI); + this.ctx.fill(); + this.ctx.stroke(); + + // Draw normal position + this.ctx.beginPath(); + this.ctx.arc(normal!.x, normal!.y, 4, 0, Constants.TWO_PI); + this.ctx.fill(); + this.ctx.stroke(); + + // Draw actual target (red if steering towards it) + this.ctx.beginPath(); + this.ctx.moveTo(predictpos.x, predictpos.y) + this.ctx.lineTo(target!.x, target!.y); + this.ctx.stroke(); + + // if (worldRecord > toFollow.radius) fill(255, 0, 0); + // noStroke(); + + this.ctx.beginPath(); + this.ctx.arc(target!.x, target!.y, 8, 0, Constants.TWO_PI); + this.ctx.fill(); + this.ctx.stroke(); + } + } + } + + seek(target: Vector, strength: number = 1) { + const desired = Vector.sub(target, this.position); + desired.normalize(); + desired.mult(this.maxSpeed); + + const steer = Vector.sub(desired, this.velocity); + steer.limit(this.maxForce); + + this.applyForce(steer.mult(strength)); + } + + link(target: Mover) { + // const desired = target.velocity.copy(); + // desired.normalize(); + // desired.mult(-distance); + + // const predicted = Vector.add(target.position, desired); + this.position = target.trailingPoint; + + // const lastVel = this.velocity.copy(); + this.seek(target.trailingPoint); + // this.velocity = target.velocity; + } + + arrive(target: Vector) { + // const predicted = Vector.add(this.position, this.velocity.copy().normalize().mult(25)); + const desired = Vector.sub(target, this.position); + const d = desired.mag(); + let speed = this.maxSpeed; + if (d < 10) { + speed = map(d, 0, 100, 0, this.maxSpeed); + } + desired.setMag(speed); + + const steer = Vector.sub(desired, this.velocity); + steer.limit(this.maxForce); + + this.applyForce(steer); + } +} + +function getNormalPoint(p: Vector, a: Vector, b: Vector) { + // Vector from a to p + const ap = Vector.sub(p, a); + // Vector from a to b + const ab = Vector.sub(b, a); + ab.normalize(); // Normalize the line + // Project vector "diff" onto line by using the dot product + ab.mult(ap.dot(ab)); + const normalPoint = Vector.add(a, ab); + return normalPoint; +} diff --git a/physics/mover.ts b/physics/mover.ts new file mode 100644 index 0000000..2152973 --- /dev/null +++ b/physics/mover.ts @@ -0,0 +1,112 @@ +import { Vector } from "../math/vector.ts"; + +export class Mover { + position: Vector; + velocity: Vector; + acceleration: Vector; + maxSpeed: number; + maxForce: number; + _trailingPoint: number; + protected _leadingPoint: number; + + get trailingPoint() { + const desired = this.velocity.copy(); + desired.normalize(); + desired.mult(-this._trailingPoint); + + return Vector.add(this.position, desired); + } + get leadingPoint() { + const desired = this.velocity.copy(); + desired.normalize(); + desired.mult(this._leadingPoint); + + return Vector.add(this.position, desired); + } + + ctx?: CanvasRenderingContext2D; + + boundingBox: { + pos: Vector; + size: Vector; + } + + constructor(); + constructor(random: boolean); + constructor(pos?: Vector, vel?: Vector, acc?: Vector); + constructor(posOrRandom?: Vector | boolean, vel?: Vector, acc?: Vector) { + if (typeof posOrRandom === 'boolean' && posOrRandom) { + this.position = Vector.random2D(new Vector()); + this.velocity = Vector.random2D(new Vector()); + this.acceleration = new Vector() + } else { + this.position = posOrRandom || new Vector(); + this.velocity = vel || new Vector(); + this.acceleration = acc || new Vector() + } + this.boundingBox = { + size: new Vector(20, 10), + pos: new Vector(this.position.x - 10, this.position.y - 5) + } + + this.maxSpeed = 3; + this.maxForce = .3; + + this._trailingPoint = 0; + this._leadingPoint = 0; + + this.init(); + } + + init() { + // + } + + move() { + this.velocity.limit(this.maxSpeed); + this.acceleration.limit(this.maxForce); + this.velocity.add(this.acceleration); + this.position.add(this.velocity); + this.edges(); + this.draw(); + } + + edges() { + if (!this.ctx) return; + + if (this.position.x > this.ctx.canvas.width) this.position.x = 0; + if (this.position.y > this.ctx.canvas.height) this.position.y = 0; + if (this.position.x < 0) this.position.x = this.ctx.canvas.width; + if (this.position.y < 0) this.position.y = this.ctx.canvas.height; + } + + draw() { + if (!this.ctx) return; + + this.ctx.fillStyle = 'white' + this.ctx.save(); + this.ctx.translate(this.position.x, this.position.y); + this.ctx.rotate(this.velocity.heading() || 0); + this.ctx.translate(-this.position.x, -this.position.y); + // this.ctx.rotate(Math.PI) + // this.ctx.rotate(.5); + this.ctx.translate(-(this.boundingBox.size.x / 2), -(this.boundingBox.size.y / 2)); + this.ctx.fillRect(this.position.x, this.position.y, this.boundingBox.size.x, this.boundingBox.size.y); + this.ctx.restore(); + } + + setContext(ctx: CanvasRenderingContext2D) { + this.ctx = ctx; + } + + applyForce(force: Vector) { + this.acceleration.add(force); + } + + static edges(point: Vector, width: number, height: number) { + if (point.x > width) point.x = 0; + if (point.y > height) point.y = 0; + if (point.x < 0) point.x = width; + if (point.y < 0) point.y = height; + } +} \ No newline at end of file diff --git a/track.ts b/track.ts new file mode 100644 index 0000000..415d5ec --- /dev/null +++ b/track.ts @@ -0,0 +1,152 @@ +import { PathSegment } from "./math/path.ts"; +import { Vector } from "./math/vector.ts"; +import { Train } from "./train.ts"; + +export class Track extends PathSegment { + + editable = false; + + next: Track; + prev: Track; + + id: string; + + constructor(points: [Vector, Vector, Vector, Vector], next?: Track, prev?: Track) { + super(points); + this.id = crypto.randomUUID(); + this.next = next || this; + this.prev = prev || this; + } + + followTrack(train: Train): [Vector, number] { + const predict = train.velocity.copy(); + predict.normalize(); + predict.mult(1); + const predictpos = Vector.add(train.position, predict) + + // const leading = train.leadingPoint; + // let closest = this.points[0]; + // let closestDistance = this.getClosestPoint(leading); + let [closest, closestDistance, closestT] = this.getClosestPoint(predictpos); + // deno-lint-ignore no-this-alias + let mostValid: Track = this; + + if (this.next !== this) { + const [point, distance, t] = this.next.getClosestPoint(predictpos); + if (distance < closestDistance) { + closest = point; + closestDistance = distance; + mostValid = this.next; + closestT = t; + } + } + if (this.prev !== this) { + const [point, distance, t] = this.next.getClosestPoint(predictpos); + if (distance < closestDistance) { + closest = point; + closestDistance = distance; + mostValid = this.next; + closestT = t; + } + } + + train.currentTrack = mostValid; + train.arrive(closest); + // if (predictpos.dist(closest) > 2) train.arrive(closest); + return [closest, closestT]; + } + + getNearestPoint(p: Vector) { + let [closest, closestDistance, closestT] = this.getClosestPoint(p); + // deno-lint-ignore no-this-alias + let mostValid: Track = this; + + if (this.next !== this) { + const [point, distance, t] = this.next.getClosestPoint(p); + if (distance < closestDistance) { + closest = point; + closestDistance = distance; + mostValid = this.next; + closestT = t; + } + } + if (this.prev !== this) { + const [point, distance, t] = this.next.getClosestPoint(p); + if (distance < closestDistance) { + closest = point; + closestDistance = distance; + mostValid = this.next; + closestT = t; + } + } + + return closest; + } + + getAllPointsInRange(v: Vector, r: number) { + const points: [number, PathSegment][] = this.getPointsWithinRadius(v, r).concat(this.next.getPointsWithinRadius(v, r), this.prev.getPointsWithinRadius(v, r)) + + return points; + } + + draw(): void { + super.draw(); + if (this.ctx && this.editable) + for (const e of this.points) { + this.ctx.fillStyle = 'blue'; + e.drawDot(this.ctx); + } + } +} + +export class Spline { + segments: T[] = []; + ctx?: CanvasRenderingContext2D; + constructor(segs: T[]) { + this.segments = segs; + } + + setContext(ctx: CanvasRenderingContext2D) { + this.ctx = ctx; + for (const segment of this.segments) { + segment.setContext(ctx); + } + } + + draw() { + for (const segment of this.segments) { + segment.draw(); + } + } +} + +export const generateSquareTrack = () => { + const first = new Track([new Vector(20, 40), new Vector(20, 100), new Vector(20, 300), new Vector(20, 360)]); + + const second = new Track([first.points[3], new Vector(20, 370), new Vector(30, 380), new Vector(40, 380)]); + + const third = new Track([second.points[3], new Vector(100, 380), new Vector(300, 380), new Vector(360, 380)]); + + const fourth = new Track([third.points[3], new Vector(370, 380), new Vector(380, 370), new Vector(380, 360)]); + + const fifth = new Track([fourth.points[3], new Vector(380, 300), new Vector(380, 100), new Vector(380, 40)]); + + const sixth = new Track([fifth.points[3], new Vector(380, 30), new Vector(370, 20), new Vector(360, 20)]); + + const seventh = new Track([sixth.points[3], new Vector(300, 20), new Vector(100, 20), new Vector(40, 20)]); + + const eighth = new Track([seventh.points[3], new Vector(30, 20), new Vector(20, 30), first.points[0]]); + + const tracks = [first, second, third, fourth, fifth, sixth, seventh, eighth]; + for (const [i, track] of tracks.entries()) { + track.next = tracks[(i + 1) % tracks.length]; + track.prev = tracks.at(i - 1)!; + } + // first.next = second; + // first.prev = eighth; + // second.next = third; + // second.prev = first; + // third. + + return new Spline([first, second, third, fourth, fifth, sixth, seventh, eighth]); +} diff --git a/train.ts b/train.ts new file mode 100644 index 0000000..284f17d --- /dev/null +++ b/train.ts @@ -0,0 +1,311 @@ +import { drawLine } from "./drawing/line.ts"; +import { ComplexPath, PathSegment } from "./math/path.ts"; +import { Vector } from "./math/vector.ts"; +import { Follower } from "./physics/follower.ts"; +import { Mover } from "./physics/mover.ts"; +import { Track } from "./track.ts"; + +export class Train extends Follower { + nodes?: Vector[]; + + currentTrack: Track; + + speed: number; + + follower?: TrainCar; + + followers?: TrainCar[]; + + constructor(track: Track, length: number) { + super(track.points[0].copy()); + this.maxSpeed = 2; + this.speed = 1; + this.currentTrack = track; + this.velocity = this.currentTrack.tangent(0).normalize().mult(this.maxSpeed); + + this.addCar(length); + + this.maxForce = .2; + } + + init(): void { + this.boundingBox.size.set(30, 10); + this._trailingPoint = 30; + } + + move(): void { + this.follow(); + + super.move(); + this.follower?.move() + // this.draw(); + } + + follow(): void { + const [_, t] = this.currentTrack.followTrack(this); + + // this.position = this.currentTrack.getPointAtT(t); + this.velocity = this.currentTrack.tangent(t); + this.velocity.normalize().mult(this.speed || this.maxSpeed); + // if (nearest.dist(this.position) > 10) + // this.seek(nearest); + } + + // draw(): void { + // if (!this.ctx) return; + // const ctx = this.ctx; + // // const [a, b] = this.nodes; + + // ctx.strokeStyle = 'blue' + // ctx.lineWidth = 10; + // // drawLine(ctx, a.x, a.y, b.x, b.y); + // super.draw() + // } + + setContext(ctx: CanvasRenderingContext2D): void { + super.setContext(ctx); + this.follower?.setContext(ctx); + } + + addCar(length: number,) { + console.log(length); + if (length) + this.follower = new TrainCar(this.currentTrack, length - 1); + this.follower?.setTarget(this); + this.follower?.position.set(this.trailingPoint); + this._trailingPoint -= 2; + } +} + +class TrainCar extends Train { + // constructor(n: [Vector, Vector], track: Track) { + // super(track); + // this.nodes = n; + // } + target?: Train; + setTarget(t: Train) { + this.target = t; + } + + init(): void { + this.boundingBox.size.set(20, 10) + this._trailingPoint = 25; + this.maxSpeed = this.maxSpeed * 2; + this.maxForce = this.maxForce * 2; + // this.speed = 0; + } + + // follow(): void { + // if (!this.target) return; + + // const points = this.currentTrack.getAllPointsInRange(this.target.position, this.target._trailingPoint); + // let closest = this.target.position; + // let closestTan = this.target.velocity; + // for (const [t, path] of points) { + // const point = path.getPointAtT(t); + // if (point.dist(this.target.trailingPoint) < this.target.trailingPoint.dist(closest)) { + // closest = point; + // closestTan = path.tangent(t); + // } + // } + + // // this.position.set(closest); + // this.seek(closest); + // this.velocity.set(closestTan.normalize().mult(this.target.speed)); + // } + + move(): void { + // if (!this.target) return; + + // const r = 30; + // const points = this.currentTrack.getAllPointsInRange(this.target.position, this.target._trailingPoint); + // let closest = this.target.position; + // let closestTan = this.target.velocity; + // for (const [t, path] of points) { + // const point = path.getPointAtT(t); + // if (point.dist(this.target.trailingPoint) < this.target.trailingPoint.dist(closest)) { + // closest = point; + // closestTan = path.tangent(t); + // } + // } + + // // this.position.set(closest); + // // this.seek(closest); + // this.velocity.set(closestTan.normalize().mult(this.target.speed)); + // super.move(); + // if (this.target && this.position.dist(this.target.trailingPoint) < 2) { + // this.velocity.setMag(0); + // } else if (this.target) { + // this.velocity.setMag(this.target.velocity.mag()); + // } + + // if (this.target) { + // this.position.set(this.target.trailingPoint); + // this.speed = this.target.speed; + // } + // const [pos,t] = this.currentTrack.followTrack(this); + + // this.position = pos.copy() + // if (this.target) { + // const points = this.currentTrack.getPointWithinRadius(this.target.position, 30); + + // let closest = this.target.position; + // for (const [i,point] of points.entries()) { + // if (typeof point !== "number") break; + + // const tracks = [this.currentTrack, this.currentTrack.next, this.currentTrack.prev]; + + // const a = tracks[i].getPointAtT(point); + + // if (a.dist(this.target.trailingPoint) < closest.dist(this.target.trailingPoint)) { + // closest = a; + // } + // } + + // this.position = closest; + // } + // this.draw(); + if (this.target) { + if (this.position.dist(this.target.position) > this.target.position.dist(this.target.trailingPoint)) { + + // this.velocity = this.currentTrack.tangent(t); + // this.velocity.normalize().mult(this.speed); + + this.arrive(this.currentTrack.getNearestPoint(this.target.trailingPoint)); + // if (this.position.dist()) + // this.move() + this.speed = this.target.speed; + super.move(); + } else { + this.draw() + } + } + // this.draw() + // this.follower?.move() + } + + // draw(): void { + // if (!this.ctx) return; + // super.draw() + // this.ctx.fillStyle = 'red'; + // this.position.drawDot(this.ctx); + // this.ctx.fillStyle = 'green'; + // this.target?.trailingPoint.drawDot(this.ctx); + // } + + edges(): void { + + } +} + +// export class Train extends Follower { + +// currentSegment: Track; +// cars: TrainCar[] = []; + +// id: string; +// constructor(path: Track); +// constructor(x: number, y: number, segment: Track); +// constructor(x: number | Track, y?: number, segment?: Track) { + +// super(x instanceof Track ? x.points[0].copy() : new Vector(x, y)) + +// if (x instanceof Track) { +// this.currentSegment = x; +// } else if (segment) { +// this.currentSegment = segment; +// } else { +// throw new Error('Path not provided for train construction') +// } +// // super(new Vector(Math.floor(Math.random() * 200),Math.floor(Math.random() * 200)), Vector.random2D()); +// this.id = crypto.randomUUID() +// this.boundingBox.size.set(40, 10) + +// this.maxSpeed = 3; +// this.maxForce = .3; + +// this.addCar(); + +// this._trailingPoint = 40; +// this._leadingPoint = 15; +// } + +// move(): void { +// for (const car of this.cars) { +// car.move(); +// } +// this.follow(this.currentSegment) +// super.move(); +// } + +// draw(): void { +// if (!this.ctx) return; +// // this.ctx.save(); +// this.ctx.fillStyle = 'white'; +// this.ctx.strokeStyle = 'red'; +// super.draw(); +// // this.ctx.restore(); +// } + +// addCar() { +// const last = this.cars[this.cars.length - 1]; +// this.cars.push(new TrainCar(this, (last || this).velocity.copy().normalize().mult(-30), last)); +// } + +// setContext(ctx: CanvasRenderingContext2D): void { +// super.setContext(ctx); +// for (const car of this.cars) { +// car.setContext(ctx); +// } +// } + +// follow(toFollow: Track): void { +// // const predict = this.velocity.copy(); +// // predict.normalize(); +// // predict.mult(25); +// // const predictpos = Vector.add(this.position, predict) + +// const nearest = toFollow.getMostValidTrack(this); + +// this.seek(nearest); +// } +// } + +// export class TrainCar extends Follower { +// train?: Train; +// prevCar?: Mover; +// constructor(train: Train, pos: Vector, prevCar: Mover) { +// super(pos); +// this.train = train; + +// this.boundingBox.size.set(20, 15); + +// this.prevCar = prevCar || train; + +// this.maxSpeed = 2; +// this.maxForce = .3; + +// this._trailingPoint = 25; +// this._leadingPoint = 25; + +// } + +// move(): void { +// if (this.train && this.prevCar) { +// this.link(this.prevCar); +// // super.move(); +// this.edges(); +// this.ctx && (this.ctx.fillStyle = 'orange') +// this.draw(); +// } +// else super.move(); +// } + +// edges(): void { +// if (!this.ctx || !this.train) return; +// if (this.train.position.x > this.ctx.canvas.width) this.position.x -= this.ctx.canvas.width; +// if (this.train.position.y > this.ctx.canvas.height) this.position.y -= this.ctx.canvas.height; +// if (this.train.position.x < 0) this.position.x += this.ctx.canvas.width; +// if (this.train.position.y < 0) this.position.y += this.ctx.canvas.height; +// } +// } \ No newline at end of file