From 3d4596f8fb9fc7aa1190a1bac2af3083ee6dfded Mon Sep 17 00:00:00 2001 From: Emma Date: Sun, 9 Feb 2025 02:54:17 -0700 Subject: [PATCH] pick and place editing working, saving and loading working --- bundle.js | 473 ++++++++++++------ deno.json | 2 +- deno.lock | 9 +- lib/context.ts | 7 + lib/input.ts | 58 ++- main.ts | 36 +- math/clamp.ts | 3 + state/states/EditTrackState.ts | 338 ++++++++++--- ...textBench.test.js => contextBench.test.ts} | 0 test/trackSystemBench.test.ts | 35 ++ track/shapes.ts | 34 ++ track/system.ts | 182 ++++--- types.ts | 11 + 13 files changed, 879 insertions(+), 309 deletions(-) create mode 100644 math/clamp.ts rename test/{contextBench.test.js => contextBench.test.ts} (100%) create mode 100644 test/trackSystemBench.test.ts create mode 100644 types.ts diff --git a/bundle.js b/bundle.js index 1573d30..ed4c670 100644 --- a/bundle.js +++ b/bundle.js @@ -24,6 +24,13 @@ function getContextItem(prop) { return ctx[prop]; } + function getContextItemOrDefault(prop, defaultValue) { + try { + return ctx[prop]; + } catch { + return defaultValue; + } + } function setContextItem(prop, value) { Object.assign(contextStack[contextStack.length - 1] ?? defaultContext, { [prop]: value @@ -71,12 +78,12 @@ }, 2); } - // https://jsr.io/@bearmetal/doodler/0.0.3/geometry/constants.ts + // https://jsr.io/@bearmetal/doodler/0.0.4/geometry/constants.ts var Constants = { TWO_PI: Math.PI * 2 }; - // https://jsr.io/@bearmetal/doodler/0.0.3/geometry/vector.ts + // https://jsr.io/@bearmetal/doodler/0.0.4/geometry/vector.ts var Vector = class _Vector { x; y; @@ -361,7 +368,7 @@ } }; - // https://jsr.io/@bearmetal/doodler/0.0.3/FPSCounter.ts + // https://jsr.io/@bearmetal/doodler/0.0.4/FPSCounter.ts var FPSCounter = class { frameTimes = []; maxSamples; @@ -389,7 +396,7 @@ } }; - // https://jsr.io/@bearmetal/doodler/0.0.3/canvas.ts + // https://jsr.io/@bearmetal/doodler/0.0.4/canvas.ts var Doodler = class { ctx; _canvas; @@ -743,13 +750,13 @@ } }; - // https://jsr.io/@bearmetal/doodler/0.0.3/timing/EaseInOut.ts + // https://jsr.io/@bearmetal/doodler/0.0.4/timing/EaseInOut.ts var easeInOut = (x) => x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2; - // https://jsr.io/@bearmetal/doodler/0.0.3/timing/Map.ts + // https://jsr.io/@bearmetal/doodler/0.0.4/timing/Map.ts var map = (value, x1, y1, x2, y2) => (value - x1) * (y2 - x2) / (y1 - x1) + x2; - // https://jsr.io/@bearmetal/doodler/0.0.3/zoomableCanvas.ts + // https://jsr.io/@bearmetal/doodler/0.0.4/zoomableCanvas.ts var ZoomableDoodler = class extends Doodler { scale = 1; dragging = false; @@ -1014,6 +1021,16 @@ }; // lib/input.ts + function mouseButtonToString(button) { + switch (button) { + case 0: + return "left"; + case 1: + return "middle"; + case 2: + return "right"; + } + } var InputManager = class { keyStates = /* @__PURE__ */ new Map(); mouseStates = /* @__PURE__ */ new Map(); @@ -1030,11 +1047,15 @@ this.keyStates.set(e.key, false); }); document.addEventListener("mousedown", (e) => { - this.mouseStates.set(e.button, true); - this.mouseEvents.get(e.button)?.call(e); + const button = mouseButtonToString(e.button); + if (!button) throw "Mouse button not found: " + e.button; + this.mouseStates.set(button, true); + this.mouseEvents.get(button)?.call(e); }); document.addEventListener("mouseup", (e) => { - this.mouseStates.set(e.button, false); + const button = mouseButtonToString(e.button); + if (!button) throw "Mouse button not found: " + e.button; + this.mouseStates.set(button, false); }); self.addEventListener("mousemove", (e) => { this.mouseLocation = { x: e.clientX, y: e.clientY }; @@ -1087,6 +1108,11 @@ offMouse(key) { this.mouseEvents.delete(key); } + onNumberKey(arg0) { + for (let i = 0; i < 10; i++) { + this.onKey(i.toString(), () => arg0(i)); + } + } }; // lib/resources.ts @@ -1125,27 +1151,6 @@ } }; - // ui/button.ts - function addButton(props) { - const doodler2 = getContextItem("doodler"); - const { text, onClick, style } = props; - const { x, y } = props.at[1].copy().sub(props.at[0]); - const id = doodler2.addUIElement( - "rectangle", - props.at[0], - x, - y, - style - ); - doodler2.registerClickable(props.at[0], props.at[1], onClick); - return { - id, - text, - onClick, - style - }; - } - // state/machine.ts var StateMachine = class { _states = /* @__PURE__ */ new Map(); @@ -1399,53 +1404,46 @@ segment.setTrack(this); this.segments.set(segment.id, segment); } + unregisterSegment(segment) { + this.segments.delete(segment.id); + for (const s of this.segments.values()) { + s.backNeighbours = s.backNeighbours.filter((n) => n !== segment); + s.frontNeighbours = s.frontNeighbours.filter((n) => n !== segment); + } + } draw(showControls = false) { for (const segment of this.segments.values()) { segment.draw(showControls); } - try { - if (getContextItem("showEnds")) { - const ends = this.findEnds(); - for (const end of ends) { - this.doodler.fillCircle(end.pos, 2, { - color: "red" - // weight: 3, - }); - if (getContextItem("debug")) { - this.doodler.line( - end.pos, - end.pos.copy().add(end.tangent.copy().mult(20)), - { - color: "blue" - // weight: 3, - } - ); - } - } - } - } catch { - setDefaultContext({ showEnds: false }); - } } + ends = /* @__PURE__ */ new Map(); + endArray = []; findEnds() { - const ends = []; for (const segment of this.segments.values()) { - const [a, b, c, d] = segment.points; - { - const tangent = Vector.sub(a, b).normalize(); - const pos = a.copy(); - ends.push({ pos, segment, tangent }); - } - { - const tangent = Vector.sub(d, c).normalize(); - const pos = d.copy(); - ends.push({ pos, segment, tangent }); - } + if (this.ends.has(segment)) continue; + const ends = [ + { + pos: segment.points[0], + segment, + tangent: Vector.sub(segment.points[1], segment.points[0]).normalize(), + frontOrBack: "back" + }, + { + pos: segment.points[3], + segment, + tangent: Vector.sub(segment.points[3], segment.points[2]).normalize(), + frontOrBack: "front" + } + ]; + this.ends.set(segment, ends); + this.endArray.push(...ends); } - return ends; + return this.endArray; } serialize() { - return this.segments.values().map((s) => s.serialize()).toArray(); + return JSON.stringify( + this.segments.values().map((s) => s.serialize()).toArray() + ); } copy() { const track = new _TrackSystem([]); @@ -1475,6 +1473,11 @@ } return track; } + translate(v) { + for (const segment of this.segments.values()) { + segment.translate(v); + } + } }; var TrackSegment = class _TrackSegment extends PathSegment { frontNeighbours = []; @@ -1501,13 +1504,17 @@ } ); if (showControls) { - this.doodler.drawCircle(this.points[1], 4, { - color: "red", - weight: 3 + this.doodler.fillCircle(this.points[0], 1, { + color: "red" }); - this.doodler.drawCircle(this.points[2], 4, { - color: "red", - weight: 3 + this.doodler.fillCircle(this.points[1], 1, { + color: "red" + }); + this.doodler.fillCircle(this.points[2], 1, { + color: "red" + }); + this.doodler.fillCircle(this.points[3], 1, { + color: "red" }); } } @@ -1525,18 +1532,23 @@ this.id ); } - propagate() { - const [_, __, p3, p4] = this.points; - const tangent = Vector.sub(p4, p3); + cleanCopy() { + return new _TrackSegment( + this.points.map((p) => p.copy()) + ); + } + propagateTranslation(v) { for (const fNeighbour of this.frontNeighbours) { - fNeighbour.receivePropagation(tangent); + fNeighbour.receivePropagation(v); + } + for (const bNeighbour of this.backNeighbours) { + bNeighbour.receivePropagation(v); } } lastHeading; - receivePropagation(tangent) { - const [p1, p2, p3, p4] = this.points; - this.rotate(tangent); - this.propagate(); + receivePropagation(v) { + this.translate(v); + this.propagateTranslation(v); } // TODO: this duplicates receivePropagation, but for some reason it doesn't work when called from receivePropagation rotate(angle) { @@ -1566,6 +1578,33 @@ data.id ); } + setPositionByPoint(pos, point) { + if (!this.points.includes(point)) return; + point = point.copy(); + this.points.forEach((p, i) => { + const relativePoint = Vector.sub(p, point); + p.set(pos); + p.add(relativePoint); + }); + } + rotateAboutPoint(angle, point) { + if (!this.points.includes(point)) return; + point = point.copy(); + this.points.forEach((p, i) => { + const relativePoint = Vector.sub(p, point); + relativePoint.rotate(angle); + p.set(Vector.add(point, relativePoint)); + }); + } + // resetRotation() { + // const angle = this.tangent(0).heading(); + // this.rotateAboutPoint(-angle, this.points[0]); + // } + translate(v) { + this.points.forEach((p) => { + p.add(v.x, v.y); + }); + } }; // track/shapes.ts @@ -1580,6 +1619,38 @@ ]); } }; + var SBendLeft = class extends StraightTrack { + constructor(start) { + start = start || new Vector(100, 100); + super(start); + this.points[2].add(0, -25); + this.points[3].add(0, -25); + } + }; + var SBendRight = class extends StraightTrack { + constructor(start) { + start = start || new Vector(100, 100); + super(start); + this.points[2].add(0, 25); + this.points[3].add(0, 25); + } + }; + var BankLeft = class extends StraightTrack { + constructor(start) { + start = start || new Vector(100, 100); + super(start); + this.points[2].add(0, -25); + this.points[3].add(0, 25); + } + }; + var BankRight = class extends StraightTrack { + constructor(start) { + start = start || new Vector(100, 100); + super(start); + this.points[2].add(0, 25); + this.points[3].add(0, -25); + } + }; // state/states/EditTrackState.ts var EditTrackState = class extends State { @@ -1590,55 +1661,132 @@ ]); heldEvents = /* @__PURE__ */ new Map(); currentSegment; + selectedSegment; + ghostSegment; + ghostRotated = false; + closestEnd; update(dt) { const inputManager2 = getContextItem("inputManager"); const track = getContextItem("track"); const doodler2 = getContextItem("doodler"); - const segment = this.currentSegment; - if (segment) { - segment.propagate(); + if (this.selectedSegment) { + const segment = this.selectedSegment; + const firstPoint = segment.points[0].copy(); const mousePos = inputManager2.getMouseLocationV(); - const p1 = segment.points[0]; - const p2 = segment.points[1]; - const p3 = segment.points[2]; - const p4 = segment.points[3]; - const prevp3 = p3.copy(); - const dirToMouse = Vector.sub(mousePos, p2).normalize(); - const angleToMouse = dirToMouse.heading(); - const angleToP1 = Vector.sub(p2, p1).heading(); - const p2DistToMouse = Vector.dist(p2, mousePos); - const p3DistToMouse = Vector.dist(p3, mousePos); - const distToP3 = Vector.dist(p2, p3); - const distToP4 = Vector.dist(prevp3, p4); - if (Math.abs(angleToMouse - angleToP1) < 0.6 && p2DistToMouse > distToP3 && p3DistToMouse > distToP4) { - { - const dirToNewP3 = dirToMouse.copy().rotate( - -(angleToMouse - angleToP1) / 2 - ); - dirToNewP3.setMag(distToP3); - p3.set(Vector.add(p2, dirToNewP3)); - doodler2.line(p2, Vector.add(p2, dirToNewP3), { color: "blue" }); - doodler2.line(p2, Vector.add(p2, dirToMouse.mult(100)), { - color: "red" - }); + segment.points.forEach((p, i) => { + const relativePoint = Vector.sub(p, firstPoint); + p.set(mousePos); + p.add(relativePoint); + }); + const ends = track.findEnds(); + setContextItem("showEnds", true); + const nearbyEnds = ends.filter((end) => { + const dist = Vector.dist(end.pos, mousePos); + return dist < 20 && end.segment !== segment; + }); + let closestEnd = nearbyEnds[0]; + for (const end of nearbyEnds) { + if (end === closestEnd) continue; + const closestEndTangent = Vector.add( + closestEnd.tangent.copy().mult(20), + closestEnd.pos + ); + const endTangent = Vector.add( + end.tangent.copy().rotate(Math.PI).mult(20), + end.pos + ); + doodler2.drawCircle(closestEndTangent, 4, { color: "red", weight: 1 }); + doodler2.drawCircle(endTangent, 4, { color: "blue", weight: 1 }); + if (endTangent.dist(mousePos) < closestEndTangent.dist(mousePos) || end.pos.dist(mousePos) < closestEnd.pos.dist(mousePos)) { + closestEnd = end; } - { - const dirToMouse2 = Vector.sub(mousePos, p3).normalize(); - dirToMouse2.setMag(distToP4); - p4.set(Vector.add(p3, dirToMouse2)); - doodler2.line(p3, Vector.add(p3, dirToMouse2), { color: "green" }); - } - segment.clampLength(); } - doodler2.fillText( - segment.calculateApproxLength().toFixed(2), - p2.copy().add(10, 0), - 100 - ); + if (closestEnd !== this.closestEnd) { + this.closestEnd = closestEnd; + this.ghostSegment = void 0; + this.ghostRotated = false; + } + if (closestEnd) { + doodler2.line( + closestEnd.pos, + Vector.add(closestEnd.pos, closestEnd.tangent.copy().mult(20)), + { color: "green" } + ); + } + if (this.closestEnd) { + if (!this.ghostSegment) { + this.ghostSegment = segment.copy(); + this.ghostRotated = false; + } + switch (this.closestEnd.frontOrBack) { + case "front": + this.ghostSegment.setPositionByPoint( + this.closestEnd.pos, + this.ghostSegment.points[0] + ); + !this.ghostRotated && this.ghostSegment.rotateAboutPoint( + this.closestEnd.tangent.heading(), + this.ghostSegment.points[0] + ); + this.ghostRotated = true; + break; + case "back": + this.ghostSegment.setPositionByPoint( + this.closestEnd.pos, + this.ghostSegment.points[3] + ); + !this.ghostRotated && this.ghostSegment.rotateAboutPoint( + this.closestEnd.tangent.heading(), + this.ghostSegment.points[3] + ); + this.ghostRotated = true; + break; + } + } else if (!this.closestEnd || !closestEnd) { + this.ghostSegment = void 0; + this.ghostRotated = false; + } + this.selectedSegment?.draw(); + if (this.ghostSegment) { + doodler2.drawWithAlpha(0.5, () => { + if (!this.ghostSegment) return; + this.ghostSegment.draw(); + if (getContextItemOrDefault("debug", false)) { + const colors2 = getContextItem("colors"); + for (const [i, point] of this.ghostSegment.points.entries() ?? []) { + doodler2.fillCircle(point, 4, { color: colors2[i + 3] }); + } + } + }); + } + } + const translation = new Vector(0, 0); + if (inputManager2.getKeyState("ArrowUp")) { + translation.y -= 1; + } + if (inputManager2.getKeyState("ArrowDown")) { + translation.y += 1; + } + if (inputManager2.getKeyState("ArrowLeft")) { + translation.x -= 1; + } + if (inputManager2.getKeyState("ArrowRight")) { + translation.x += 1; + } + if (translation.x !== 0 || translation.y !== 0) { + track.translate(translation); } track.draw(true); } start() { + setContextItem("trackSegments", [ + void 0, + new StraightTrack(), + new SBendLeft(), + new SBendRight(), + new BankLeft(), + new BankRight() + ]); const inputManager2 = getContextItem("inputManager"); this.heldEvents.set("e", inputManager2.offKey("e")); this.heldEvents.set("Escape", inputManager2.offKey("Escape")); @@ -1655,25 +1803,50 @@ const state2 = getContextItem("state"); state2.transitionTo(1 /* RUNNING */); }); - inputManager2.onKey("w", () => { - const track2 = getContextItem("track"); - const segment = track2.lastSegment; - if (!segment) return; - const n = new StraightTrack(segment.points[3]); - const t = segment.tangent(1).heading(); - n.rotate(t); - segment.frontNeighbours.push(n); - track2.registerSegment(n); - this.currentSegment = n; + inputManager2.onKey(" ", () => { + if (this.selectedSegment) { + this.selectedSegment = void 0; + } else { + this.selectedSegment = new StraightTrack(); + } }); - inputManager2.onKey("1", () => { - this.currentSegment = track.firstSegment; + inputManager2.onMouse("left", () => { + const track2 = getContextItem("track"); + if (this.ghostSegment && this.closestEnd) { + const segment = this.ghostSegment.cleanCopy(); + switch (this.closestEnd.frontOrBack) { + case "front": + this.closestEnd.segment.frontNeighbours.push(segment); + segment.backNeighbours.push(this.closestEnd.segment); + break; + case "back": + this.closestEnd.segment.backNeighbours.push(segment); + segment.frontNeighbours.push(this.closestEnd.segment); + break; + } + track2.registerSegment(segment); + this.ghostSegment = void 0; + this.closestEnd = void 0; + } else if (this.selectedSegment) { + track2.registerSegment(this.selectedSegment); + this.selectedSegment = new StraightTrack(); + } else { + this.selectedSegment = void 0; + } + }); + inputManager2.onNumberKey((i) => { + console.log(i); + const segments = getContextItem("trackSegments"); + this.selectedSegment = segments[i]; + this.ghostRotated = false; + this.ghostSegment = void 0; }); this.currentSegment = track.lastSegment; } stop() { const inputManager2 = getContextItem("inputManager"); inputManager2.offKey("e"); + inputManager2.offKey("w"); inputManager2.offKey("Escape"); if (this.heldEvents.size > 0) { for (const [key, cb] of this.heldEvents) { @@ -1684,6 +1857,7 @@ } } setContextItem("trackCopy", void 0); + setContextItem("trackSegments", void 0); } }; @@ -1790,31 +1964,36 @@ bg: "#302040" }); doodler.scale = doodler.maxScale; + var colors = [ + "red", + "orange", + "yellow", + "green", + "blue", + "indigo", + "purple", + "violet" + ]; setDefaultContext({ inputManager, doodler, resources, debug: true, - showEnds: true + showEnds: true, + colors }); var state = bootstrapGameStateMachine(); setContextItem("state", state); doodler.init(); - addButton({ - text: "Hello World!", - onClick: () => { - console.log("Hello World!"); - }, - at: [ - new Vector(10, doodler.height - 50), - new Vector(110, doodler.height - 10) - ], - style: { - fillColor: "blue", - color: "white" - } - }); doodler.createLayer((_, __, dTime) => { state.update(dTime); }); + document.addEventListener("keydown", (e) => { + if ((e.ctrlKey || e.metaKey) && e.key === "s") { + e.preventDefault(); + const track = getContextItem("track"); + localStorage.setItem("track", track.serialize()); + console.log("Saved track to local storage"); + } + }); })(); diff --git a/deno.json b/deno.json index f40273a..7b7dc6d 100644 --- a/deno.json +++ b/deno.json @@ -13,6 +13,6 @@ "dev": "deno run -RWEN --allow-run dev.ts dev" }, "imports": { - "@bearmetal/doodler": "jsr:@bearmetal/doodler@^0.0.3" + "@bearmetal/doodler": "jsr:@bearmetal/doodler@^0.0.4" } } \ No newline at end of file diff --git a/deno.lock b/deno.lock index d922b2b..56d8002 100644 --- a/deno.lock +++ b/deno.lock @@ -1,7 +1,8 @@ { "version": "4", "specifiers": { - "jsr:@bearmetal/doodler@^0.0.3": "0.0.3", + "jsr:@bearmetal/doodler@*": "0.0.4", + "jsr:@bearmetal/doodler@^0.0.4": "0.0.4", "jsr:@luca/esbuild-deno-loader@*": "0.11.0", "jsr:@luca/esbuild-deno-loader@0.11.1": "0.11.1", "jsr:@std/assert@*": "1.0.10", @@ -22,8 +23,8 @@ "npm:esbuild@*": "0.24.2" }, "jsr": { - "@bearmetal/doodler@0.0.3": { - "integrity": "42c04b672f4a6bc7ebd45ad936197a2e32856364b66a9a9fe2b81a4aa45c7a08" + "@bearmetal/doodler@0.0.4": { + "integrity": "b631083cff84994c513f70d1f09e6a9256edabcb224112c93a9ca6a87c88a389" }, "@luca/esbuild-deno-loader@0.11.0": { "integrity": "c05a989aa7c4ee6992a27be5f15cfc5be12834cab7ff84cabb47313737c51a2c", @@ -229,7 +230,7 @@ }, "workspace": { "dependencies": [ - "jsr:@bearmetal/doodler@^0.0.3" + "jsr:@bearmetal/doodler@^0.0.4" ] } } diff --git a/lib/context.ts b/lib/context.ts index c639b50..9758aae 100644 --- a/lib/context.ts +++ b/lib/context.ts @@ -37,6 +37,13 @@ export function getContext() { export function getContextItem(prop: string): T { return ctx[prop] as T; } +export function getContextItemOrDefault(prop: string, defaultValue: T): T { + try { + return ctx[prop] as T; + } catch { + return defaultValue; + } +} export function setContextItem(prop: string, value: T) { Object.assign(contextStack[contextStack.length - 1] ?? defaultContext, { [prop]: value, diff --git a/lib/input.ts b/lib/input.ts index a4b5f72..dd9098d 100644 --- a/lib/input.ts +++ b/lib/input.ts @@ -1,14 +1,36 @@ import { Vector, ZoomableDoodler } from "@bearmetal/doodler"; import { getContextItem } from "./context.ts"; +function mouseButtonToString(button: number) { + switch (button) { + case 0: + return "left"; + case 1: + return "middle"; + case 2: + return "right"; + } +} + +function mouseButtonToNumber(button: string) { + switch (button) { + case "left": + return 0; + case "middle": + return 1; + case "right": + return 2; + } +} + export class InputManager { - private keyStates: Map = new Map(); - private mouseStates: Map = new Map(); + private keyStates: Map = new Map(); + private mouseStates: Map = new Map(); private mouseLocation: { x: number; y: number } = { x: 0, y: 0 }; private mouseDelta: { x: number; y: number } = { x: 0, y: 0 }; - private keyEvents: Map void> = new Map(); - private mouseEvents: Map void> = new Map(); + private keyEvents: Map void> = new Map(); + private mouseEvents: Map void> = new Map(); constructor() { document.addEventListener("keydown", (e) => { @@ -19,11 +41,15 @@ export class InputManager { this.keyStates.set(e.key, false); }); document.addEventListener("mousedown", (e) => { - this.mouseStates.set(e.button, true); - this.mouseEvents.get(e.button)?.call(e); + const button = mouseButtonToString(e.button); + if (!button) throw "Mouse button not found: " + e.button; + this.mouseStates.set(button, true); + this.mouseEvents.get(button)?.call(e); }); document.addEventListener("mouseup", (e) => { - this.mouseStates.set(e.button, false); + const button = mouseButtonToString(e.button); + if (!button) throw "Mouse button not found: " + e.button; + this.mouseStates.set(button, false); }); self.addEventListener("mousemove", (e) => { @@ -35,10 +61,10 @@ export class InputManager { }); } - getKeyState(key: string | number) { + getKeyState(key: string) { return this.keyStates.get(key); } - getMouseState(key: string | number) { + getMouseState(key: string) { return this.mouseStates.get(key); } getMouseLocation() { @@ -65,19 +91,25 @@ export class InputManager { return this.mouseDelta; } - onKey(key: string | number, cb: () => void) { + onKey(key: string, cb: () => void) { this.keyEvents.set(key, cb); } - onMouse(key: string | number, cb: () => void) { + onMouse(key: string, cb: () => void) { this.mouseEvents.set(key, cb); } - offKey(key: string | number) { + offKey(key: string) { const events = this.keyEvents.get(key); this.keyEvents.delete(key); return events; } - offMouse(key: string | number) { + offMouse(key: string) { this.mouseEvents.delete(key); } + + onNumberKey(arg0: (arg: number) => void) { + for (let i = 0; i < 10; i++) { + this.onKey(i.toString(), () => arg0(i)); + } + } } diff --git a/main.ts b/main.ts index 12f62b3..1035d27 100644 --- a/main.ts +++ b/main.ts @@ -1,5 +1,6 @@ import { getContext, + getContextItem, setContextItem, setDefaultContext, } from "./lib/context.ts"; @@ -22,33 +23,40 @@ const doodler = new ZoomableDoodler({ // doodler.minScale = 0.1; (doodler as any).scale = doodler.maxScale; +const colors = [ + "red", + "orange", + "yellow", + "green", + "blue", + "indigo", + "purple", + "violet", +]; + setDefaultContext({ inputManager, doodler, resources, debug: true, showEnds: true, + colors, }); const state = bootstrapGameStateMachine(); setContextItem("state", state); doodler.init(); -addButton({ - text: "Hello World!", - onClick: () => { - console.log("Hello World!"); - }, - at: [ - new Vector(10, doodler.height - 50), - new Vector(110, doodler.height - 10), - ], - style: { - fillColor: "blue", - color: "white", - }, -}); doodler.createLayer((_, __, dTime) => { state.update(dTime); }); + +document.addEventListener("keydown", (e) => { + if ((e.ctrlKey || e.metaKey) && e.key === "s") { + e.preventDefault(); + const track = getContextItem("track"); + localStorage.setItem("track", track.serialize()); + console.log("Saved track to local storage"); + } +}); diff --git a/math/clamp.ts b/math/clamp.ts new file mode 100644 index 0000000..44f88f3 --- /dev/null +++ b/math/clamp.ts @@ -0,0 +1,3 @@ +export function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} diff --git a/state/states/EditTrackState.ts b/state/states/EditTrackState.ts index 4fa8de3..c70ee89 100644 --- a/state/states/EditTrackState.ts +++ b/state/states/EditTrackState.ts @@ -1,11 +1,22 @@ import { Doodler, Vector } from "@bearmetal/doodler"; -import { getContextItem, setContextItem } from "../../lib/context.ts"; +import { + getContextItem, + getContextItemOrDefault, + setContextItem, +} from "../../lib/context.ts"; import { InputManager } from "../../lib/input.ts"; import { TrackSystem } from "../../track/system.ts"; import { State, StateMachine } from "../machine.ts"; import { States } from "./index.ts"; -import { StraightTrack } from "../../track/shapes.ts"; +import { + BankLeft, + BankRight, + SBendLeft, + SBendRight, + StraightTrack, +} from "../../track/shapes.ts"; import { TrackSegment } from "../../track/system.ts"; +import { clamp } from "../../math/clamp.ts"; export class EditTrackState extends State { override name: States = States.EDIT_TRACK; @@ -14,14 +25,18 @@ export class EditTrackState extends State { States.PAUSED, ]); - private heldEvents: Map void) | undefined> = - new Map(); + private heldEvents: Map void) | undefined> = new Map(); private currentSegment?: TrackSegment; + private selectedSegment?: TrackSegment; + private ghostSegment?: TrackSegment; + private ghostRotated = false; + private closestEnd?: End; override update(dt: number): void { const inputManager = getContextItem("inputManager"); const track = getContextItem("track"); + const doodler = getContextItem("doodler"); // For moving a segment, i.e. the currently active one // const segment = track.lastSegment; @@ -35,6 +50,112 @@ export class EditTrackState extends State { // }); // } + if (this.selectedSegment) { + const segment = this.selectedSegment; + const firstPoint = segment.points[0].copy(); + const mousePos = inputManager.getMouseLocationV(); + segment.points.forEach((p, i) => { + const relativePoint = Vector.sub(p, firstPoint); + p.set(mousePos); + p.add(relativePoint); + }); + + const ends = track.findEnds(); + setContextItem("showEnds", true); + const nearbyEnds = ends.filter((end) => { + const dist = Vector.dist(end.pos, mousePos); + return dist < 20 && end.segment !== segment; + }); + let closestEnd = nearbyEnds[0]; + for (const end of nearbyEnds) { + if (end === closestEnd) continue; + const closestEndTangent = Vector.add( + closestEnd.tangent.copy().mult(20), + closestEnd.pos, + ); + const endTangent = Vector.add( + end.tangent.copy().rotate(Math.PI).mult(20), + end.pos, + ); + doodler.drawCircle(closestEndTangent, 4, { color: "red", weight: 1 }); + doodler.drawCircle(endTangent, 4, { color: "blue", weight: 1 }); + if ( + endTangent.dist(mousePos) < closestEndTangent.dist(mousePos) || + end.pos.dist(mousePos) < closestEnd.pos.dist(mousePos) + ) { + closestEnd = end; + } + } + if (closestEnd !== this.closestEnd) { + this.closestEnd = closestEnd; + this.ghostSegment = undefined; + this.ghostRotated = false; + } + if (closestEnd) { + // doodler.drawCircle(closestEnd.pos, 4, { color: "green", weight: 1 }); + doodler.line( + closestEnd.pos, + Vector.add(closestEnd.pos, closestEnd.tangent.copy().mult(20)), + { color: "green" }, + ); + } + if ( + this.closestEnd + ) { + if (!this.ghostSegment) { + this.ghostSegment = segment.copy(); + this.ghostRotated = false; + } + switch (this.closestEnd.frontOrBack) { + case "front": + this.ghostSegment.setPositionByPoint( + this.closestEnd.pos, + this.ghostSegment.points[0], + ); + // this.ghostSegment.points[0] = this.closestEnd.pos; + !this.ghostRotated && this.ghostSegment.rotateAboutPoint( + this.closestEnd.tangent.heading(), + this.ghostSegment.points[0], + ); + this.ghostRotated = true; + break; + case "back": + this.ghostSegment.setPositionByPoint( + this.closestEnd.pos, + this.ghostSegment.points[3], + ); + // this.ghostSegment.points[3] = this.closestEnd.pos; + !this.ghostRotated && this.ghostSegment.rotateAboutPoint( + this.closestEnd.tangent.heading(), + this.ghostSegment.points[3], + ); + this.ghostRotated = true; + break; + } + // } else if (closestEnd) { + // this.closestEnd = closestEnd; + } else if (!this.closestEnd || !closestEnd) { + this.ghostSegment = undefined; + this.ghostRotated = false; + } + + this.selectedSegment?.draw(); + if (this.ghostSegment) { + doodler.drawWithAlpha(0.5, () => { + if (!this.ghostSegment) return; + this.ghostSegment.draw(); + if (getContextItemOrDefault("debug", false)) { + const colors = getContextItem("colors"); + for ( + const [i, point] of this.ghostSegment.points.entries() ?? [] + ) { + doodler.fillCircle(point, 4, { color: colors[i + 3] }); + } + } + }); + } + } + // manipulate only end of segment while maintaining length // const segment = track.lastSegment; // if (segment) { @@ -50,56 +171,96 @@ export class EditTrackState extends State { // // doodler.fillText(curveLength.toFixed(2), p3.copy().add(10, 0), 100); // } - const doodler = getContextItem("doodler"); // Adjust angles until tangent points to mouse - const segment = this.currentSegment; - if (segment) { - segment.propagate(); + // const segment = this.currentSegment; + // if (segment) { + // segment.propagate(); - const mousePos = inputManager.getMouseLocationV(); - const p1 = segment.points[0]; - const p2 = segment.points[1]; - const p3 = segment.points[2]; - const p4 = segment.points[3]; + // const mousePos = inputManager.getMouseLocationV(); + // const p1 = segment.points[0]; + // const p2 = segment.points[1]; + // const p3 = segment.points[2]; + // const p4 = segment.points[3]; - const prevp3 = p3.copy(); - const dirToMouse = Vector.sub(mousePos, p2).normalize(); - const angleToMouse = dirToMouse.heading(); - const angleToP1 = Vector.sub(p2, p1).heading(); - const p2DistToMouse = Vector.dist(p2, mousePos); - const p3DistToMouse = Vector.dist(p3, mousePos); - const distToP3 = Vector.dist(p2, p3); - const distToP4 = Vector.dist(prevp3, p4); - if ( - Math.abs(angleToMouse - angleToP1) < .6 && - p2DistToMouse > distToP3 && - p3DistToMouse > distToP4 - ) { - { - const dirToNewP3 = dirToMouse.copy().rotate( - -(angleToMouse - angleToP1) / 2, - ); - dirToNewP3.setMag(distToP3); - p3.set(Vector.add(p2, dirToNewP3)); - doodler.line(p2, Vector.add(p2, dirToNewP3), { color: "blue" }); - doodler.line(p2, Vector.add(p2, dirToMouse.mult(100)), { - color: "red", - }); - } - { - const dirToMouse = Vector.sub(mousePos, p3).normalize(); - dirToMouse.setMag(distToP4); - p4.set(Vector.add(p3, dirToMouse)); - doodler.line(p3, Vector.add(p3, dirToMouse), { color: "green" }); - } - segment.clampLength(); - } - doodler.fillText( - segment.calculateApproxLength().toFixed(2), - p2.copy().add(10, 0), - 100, - ); + // const prevp3 = p3.copy(); + // const dirToMouse = Vector.sub(mousePos, p2).normalize(); + // const angleToMouse = dirToMouse.heading(); + // const dirToP1 = Vector.sub(p2, p1).normalize(); + // const angleToP1 = dirToP1.heading(); + // const p2DistToMouse = Vector.dist(p2, mousePos); + // const p3DistToMouse = Vector.dist(p3, mousePos); + // const distToP3 = Vector.dist(p2, p3); + // const distToP4 = Vector.dist(prevp3, p4); + + // const goodangle = clamp( + // angleToMouse - angleToP1, + // angleToP1 - .6, + // angleToP1 + .6, + // ); + // if ( + // // Math.abs(goodangle) < .6 && + // p2DistToMouse > distToP3 && + // p3DistToMouse > distToP4 + // ) { + // { + // const dirToNewP3 = dirToP1.copy().rotate( + // goodangle / 2, + // ); + // dirToNewP3.setMag(distToP3); + // p3.set(Vector.add(p2, dirToNewP3)); + // doodler.line(p2, Vector.add(p2, dirToNewP3), { color: "blue" }); + // doodler.line( + // p2, + // Vector.add(p2, dirToNewP3), + // { + // color: "red", + // }, + // ); + // } + // { + // const dirToMouse = Vector.sub(mousePos, p3).normalize(); + // const dirToP3 = Vector.sub(p3, p2).normalize(); + // const angleToP3 = dirToP3.heading(); + // const goodangle = clamp( + // dirToMouse.heading() - angleToP3, + // angleToP3 - .6, + // angleToP3 + .6, + // ); + + // const dirToNewP4 = dirToP3.copy().rotate( + // goodangle / 2, + // ); + // dirToNewP4.setMag(distToP4); + // p4.set(Vector.add(p3, dirToNewP4)); + // doodler.line(p3, Vector.add(p3, dirToNewP4), { color: "green" }); + // } + // segment.clampLength(); + // } + // // doodler.fillText( + // // segment.calculateApproxLength().toFixed(2), + // // p2.copy().add(10, 0), + // // 100, + // // ); + // } + + const translation = new Vector(0, 0); + + if (inputManager.getKeyState("ArrowUp")) { + translation.y -= 1; + } + if (inputManager.getKeyState("ArrowDown")) { + translation.y += 1; + } + if (inputManager.getKeyState("ArrowLeft")) { + translation.x -= 1; + } + if (inputManager.getKeyState("ArrowRight")) { + translation.x += 1; + } + + if (translation.x !== 0 || translation.y !== 0) { + track.translate(translation); } track.draw(true); @@ -109,6 +270,15 @@ export class EditTrackState extends State { // Draw track tangents } override start(): void { + setContextItem("trackSegments", [ + undefined, + new StraightTrack(), + new SBendLeft(), + new SBendRight(), + new BankLeft(), + new BankRight(), + ]); + const inputManager = getContextItem("inputManager"); this.heldEvents.set("e", inputManager.offKey("e")); this.heldEvents.set("Escape", inputManager.offKey("Escape")); @@ -127,20 +297,62 @@ export class EditTrackState extends State { state.transitionTo(States.RUNNING); }); - inputManager.onKey("w", () => { - const track = getContextItem("track"); - const segment = track.lastSegment; - if (!segment) return; - const n = new StraightTrack(segment.points[3]); - const t = segment.tangent(1).heading(); - n.rotate(t); - segment.frontNeighbours.push(n); - track.registerSegment(n); - this.currentSegment = n; + inputManager.onKey(" ", () => { + if (this.selectedSegment) { + this.selectedSegment = undefined; + } else { + this.selectedSegment = new StraightTrack(); + } }); - inputManager.onKey("1", () => { - this.currentSegment = track.firstSegment; + inputManager.onMouse("left", () => { + const track = getContextItem("track"); + if (this.ghostSegment && this.closestEnd) { + const segment = this.ghostSegment.cleanCopy(); + + switch (this.closestEnd.frontOrBack) { + case "front": + this.closestEnd.segment.frontNeighbours.push(segment); + segment.backNeighbours.push(this.closestEnd.segment); + break; + case "back": + this.closestEnd.segment.backNeighbours.push(segment); + segment.frontNeighbours.push(this.closestEnd.segment); + break; + } + track.registerSegment(segment); + this.ghostSegment = undefined; + this.closestEnd = undefined; + } else if (this.selectedSegment) { + track.registerSegment(this.selectedSegment); + this.selectedSegment = new StraightTrack(); + } else { + this.selectedSegment = undefined; + } + }); + + // inputManager.onKey("w", () => { + // const track = getContextItem("track"); + // const segment = track.lastSegment; + // if (!segment) return; + // const n = new StraightTrack(segment.points[3]); + // const t = segment.tangent(1).heading(); + // n.rotate(t); + // segment.frontNeighbours.push(n); + // track.registerSegment(n); + // this.currentSegment = n; + // }); + + // inputManager.onKey("1", () => { + // this.currentSegment = track.firstSegment; + // }); + + inputManager.onNumberKey((i) => { + console.log(i); + const segments = getContextItem("trackSegments"); + this.selectedSegment = segments[i]; + this.ghostRotated = false; + this.ghostSegment = undefined; }); this.currentSegment = track.lastSegment; @@ -151,6 +363,7 @@ export class EditTrackState extends State { override stop(): void { const inputManager = getContextItem("inputManager"); inputManager.offKey("e"); + inputManager.offKey("w"); inputManager.offKey("Escape"); if (this.heldEvents.size > 0) { for (const [key, cb] of this.heldEvents) { @@ -161,5 +374,6 @@ export class EditTrackState extends State { } } setContextItem("trackCopy", undefined); + setContextItem("trackSegments", undefined); } } diff --git a/test/contextBench.test.js b/test/contextBench.test.ts similarity index 100% rename from test/contextBench.test.js rename to test/contextBench.test.ts diff --git a/test/trackSystemBench.test.ts b/test/trackSystemBench.test.ts new file mode 100644 index 0000000..725618c --- /dev/null +++ b/test/trackSystemBench.test.ts @@ -0,0 +1,35 @@ +import { assert } from "jsr:@std/assert"; +import { describe, it } from "jsr:@std/testing/bdd"; +import { TrackSystem } from "../track/system.ts"; +import { StraightTrack } from "../track/shapes.ts"; +import { testPerformance } from "./bench.ts"; +import { setDefaultContext } from "../lib/context.ts"; + +/** + * Tests if a function can run a given number of iterations within a target frame time. + * @param fn The function to test. + * @param iterations Number of times to run the function per frame. + * @param fps Target frames per second. + */ +Deno.test("Track System Benchmark", () => { + console.log("Track System Benchmark - run within frame time"); + const mockDoodler = { + fillCircle: () => {}, + line: () => {}, + }; + setDefaultContext({ + doodler: mockDoodler, + }); + const mockTrack = new TrackSystem([]); + for (let i = 0; i < 100; i++) { + mockTrack.registerSegment(new StraightTrack()); + } + + testPerformance( + () => { + mockTrack.findEnds(); + }, + 10000, + 60, + ); +}); diff --git a/track/shapes.ts b/track/shapes.ts index 207c22f..7714c21 100644 --- a/track/shapes.ts +++ b/track/shapes.ts @@ -12,3 +12,37 @@ export class StraightTrack extends TrackSegment { ]); } } + +export class SBendLeft extends StraightTrack { + constructor(start?: Vector) { + start = start || new Vector(100, 100); + super(start); + this.points[2].add(0, -25); + this.points[3].add(0, -25); + } +} +export class SBendRight extends StraightTrack { + constructor(start?: Vector) { + start = start || new Vector(100, 100); + super(start); + this.points[2].add(0, 25); + this.points[3].add(0, 25); + } +} + +export class BankLeft extends StraightTrack { + constructor(start?: Vector) { + start = start || new Vector(100, 100); + super(start); + this.points[2].add(0, -25); + this.points[3].add(0, 25); + } +} +export class BankRight extends StraightTrack { + constructor(start?: Vector) { + start = start || new Vector(100, 100); + super(start); + this.points[2].add(0, 25); + this.points[3].add(0, -25); + } +} diff --git a/track/system.ts b/track/system.ts index 1d3611c..4901a5f 100644 --- a/track/system.ts +++ b/track/system.ts @@ -1,4 +1,4 @@ -import { Doodler, Vector } from "@bearmetal/doodler"; +import { Doodler, Point, Vector } from "@bearmetal/doodler"; import { PathSegment } from "../math/path.ts"; import { getContextItem, setDefaultContext } from "../lib/context.ts"; @@ -25,57 +25,74 @@ export class TrackSystem { segment.setTrack(this); this.segments.set(segment.id, segment); } + unregisterSegment(segment: TrackSegment) { + this.segments.delete(segment.id); + for (const s of this.segments.values()) { + s.backNeighbours = s.backNeighbours.filter((n) => n !== segment); + s.frontNeighbours = s.frontNeighbours.filter((n) => n !== segment); + } + } draw(showControls = false) { for (const segment of this.segments.values()) { segment.draw(showControls); } - try { - if (getContextItem("showEnds")) { - const ends = this.findEnds(); - for (const end of ends) { - this.doodler.fillCircle(end.pos, 2, { - color: "red", - // weight: 3, - }); - if (getContextItem("debug")) { - this.doodler.line( - end.pos, - end.pos.copy().add(end.tangent.copy().mult(20)), - { - color: "blue", - // weight: 3, - }, - ); - } - } - } - } catch { - setDefaultContext({ showEnds: false }); - } + // try { + // if (getContextItem("showEnds")) { + // const ends = this.findEnds(); + // for (const end of ends) { + // this.doodler.fillCircle(end.pos, 2, { + // color: "red", + // // weight: 3, + // }); + // if (getContextItem("debug")) { + // this.doodler.line( + // end.pos, + // end.pos.copy().add(end.tangent.copy().mult(20)), + // { + // color: "blue", + // // weight: 3, + // }, + // ); + // } + // } + // } + // } catch { + // setDefaultContext({ showEnds: false }); + // } } + ends: Map = new Map(); + endArray: End[] = []; + findEnds() { - const ends: { pos: Vector; segment: TrackSegment; tangent: Vector }[] = []; for (const segment of this.segments.values()) { - const [a, b, c, d] = segment.points; - { - const tangent = Vector.sub(a, b).normalize(); - const pos = a.copy(); - ends.push({ pos, segment, tangent }); - } - { - const tangent = Vector.sub(d, c).normalize(); - const pos = d.copy(); - ends.push({ pos, segment, tangent }); - } + if (this.ends.has(segment)) continue; + const ends: [End, End] = [ + { + pos: segment.points[0], + segment, + tangent: Vector.sub(segment.points[1], segment.points[0]).normalize(), + frontOrBack: "back", + }, + { + pos: segment.points[3], + segment, + tangent: Vector.sub(segment.points[3], segment.points[2]).normalize(), + frontOrBack: "front", + }, + ]; + this.ends.set(segment, ends); + this.endArray.push(...ends); } - return ends; + return this.endArray; } serialize() { - return this.segments.values().map((s) => s.serialize()).toArray(); + return JSON.stringify( + this.segments.values().map((s) => s.serialize()).toArray(), + ); } copy() { @@ -107,6 +124,12 @@ export class TrackSystem { } return track; } + + translate(v: Vector) { + for (const segment of this.segments.values()) { + segment.translate(v); + } + } } type VectorSet = [Vector, Vector, Vector, Vector]; @@ -142,22 +165,18 @@ export class TrackSegment extends PathSegment { }, ); if (showControls) { - // this.doodler.drawCircle(this.points[0], 4, { - // color: "red", - // weight: 3, - // }); - this.doodler.drawCircle(this.points[1], 4, { + this.doodler.fillCircle(this.points[0], 1, { color: "red", - weight: 3, }); - this.doodler.drawCircle(this.points[2], 4, { + this.doodler.fillCircle(this.points[1], 1, { + color: "red", + }); + this.doodler.fillCircle(this.points[2], 1, { + color: "red", + }); + this.doodler.fillCircle(this.points[3], 1, { color: "red", - weight: 3, }); - // this.doodler.drawCircle(this.points[3], 4, { - // color: "red", - // weight: 3, - // }); } } @@ -177,30 +196,26 @@ export class TrackSegment extends PathSegment { ); } - propagate() { - const [_, __, p3, p4] = this.points; - const tangent = Vector.sub(p4, p3); + cleanCopy() { + return new TrackSegment( + this.points.map((p) => p.copy()) as VectorSet, + ); + } + + propagateTranslation(v: Vector) { for (const fNeighbour of this.frontNeighbours) { - fNeighbour.receivePropagation(tangent); + fNeighbour.receivePropagation(v); + } + for (const bNeighbour of this.backNeighbours) { + bNeighbour.receivePropagation(v); } } lastHeading?: number; - receivePropagation(tangent: Vector) { - const [p1, p2, p3, p4] = this.points; - // const angle = tangent.heading() - (this.lastHeading ?? 0); - // this.lastHeading = tangent.heading(); - // const newP2 = Vector.add(p1, tangent); - // const p2ToP3 = Vector.sub(p3, p2); - // p2ToP3.rotate(angle); - // p3.set(Vector.add(newP2, p2ToP3)); - // const p2Top4 = Vector.sub(p4, p2); - // p2Top4.rotate(angle); - // p4.set(Vector.add(newP2, p2Top4)); - // p2.set(newP2); - this.rotate(tangent); - this.propagate(); + receivePropagation(v: Vector) { + this.translate(v); + this.propagateTranslation(v); } // TODO: this duplicates receivePropagation, but for some reason it doesn't work when called from receivePropagation @@ -231,4 +246,35 @@ export class TrackSegment extends PathSegment { data.id, ); } + + setPositionByPoint(pos: Vector, point: Vector) { + if (!this.points.includes(point)) return; + point = point.copy(); + this.points.forEach((p, i) => { + const relativePoint = Vector.sub(p, point); + p.set(pos); + p.add(relativePoint); + }); + } + + rotateAboutPoint(angle: number, point: Vector) { + if (!this.points.includes(point)) return; + point = point.copy(); + this.points.forEach((p, i) => { + const relativePoint = Vector.sub(p, point); + relativePoint.rotate(angle); + p.set(Vector.add(point, relativePoint)); + }); + } + + // resetRotation() { + // const angle = this.tangent(0).heading(); + // this.rotateAboutPoint(-angle, this.points[0]); + // } + + translate(v: Point) { + this.points.forEach((p) => { + p.add(v.x, v.y); + }); + } } diff --git a/types.ts b/types.ts new file mode 100644 index 0000000..9fed813 --- /dev/null +++ b/types.ts @@ -0,0 +1,11 @@ +import { Vector } from "@bearmetal/doodler"; +import { TrackSegment } from "./track/system.ts"; + +declare global { + type End = { + pos: Vector; + segment: TrackSegment; + tangent: Vector; + frontOrBack: "front" | "back"; + }; +}