From 791ba42ceb7c9ddc0820a2c4e1a7e5df74d9de78 Mon Sep 17 00:00:00 2001 From: Emma Date: Sat, 8 Feb 2025 01:16:09 -0700 Subject: [PATCH] basic state switching from loading to running to editing --- bundle.js | 329 +++++++++++++++++++++++++++++++-- deno.json | 3 +- deno.lock | 9 + dev.ts | 2 +- game.ts | 0 index.html | 12 ++ inputs.ts | 12 ++ lib/context.ts | 52 +++++- lib/input.ts | 2 + main.ts | 17 +- state/machine.ts | 55 ++++-- state/states.ts | 139 -------------- state/states/EditTrackState.ts | 70 +++++++ state/states/EditTrainState.ts | 25 +++ state/states/LoadState.ts | 51 +++++ state/states/PausedState.ts | 30 +++ state/states/RunningState.ts | 32 ++++ state/states/index.ts | 26 +++ test/contextBench.test.js | 16 +- track/system.ts | 63 ++++++- train/train.ts | 11 +- 21 files changed, 769 insertions(+), 187 deletions(-) create mode 100644 game.ts create mode 100644 inputs.ts delete mode 100644 state/states.ts create mode 100644 state/states/EditTrackState.ts create mode 100644 state/states/EditTrainState.ts create mode 100644 state/states/LoadState.ts create mode 100644 state/states/PausedState.ts create mode 100644 state/states/RunningState.ts create mode 100644 state/states/index.ts diff --git a/bundle.js b/bundle.js index 41a6c22..0a66da6 100644 --- a/bundle.js +++ b/bundle.js @@ -2,6 +2,7 @@ // lib/context.ts var contextStack = []; var defaultContext = {}; + var debug = JSON.parse(localStorage.getItem("debug") || "false"); function setDefaultContext(context) { Object.assign(defaultContext, context); } @@ -17,9 +18,58 @@ } } ); + function getContext() { + return ctx; + } function getContextItem(prop) { return ctx[prop]; } + function setContextItem(prop, value) { + Object.assign(contextStack[contextStack.length - 1] ?? defaultContext, { + [prop]: value + }); + } + if (debug) { + setInterval(() => { + let ctxEl = document.getElementById("context"); + if (!ctxEl) { + ctxEl = document.createElement("div"); + ctxEl.id = "context"; + document.body.append(ctxEl); + } + ctxEl.innerHTML = ""; + const div = document.createElement("div"); + const pre = document.createElement("pre"); + const h3 = document.createElement("h3"); + h3.textContent = "Default"; + div.append(h3); + pre.textContent = safeStringify(defaultContext); + div.append(pre); + ctxEl.append(div); + for (const [idx, ctx2] of contextStack.entries()) { + const div2 = document.createElement("div"); + const pre2 = document.createElement("pre"); + const h32 = document.createElement("h3"); + h32.textContent = "CTX " + idx; + div2.append(h32); + pre2.textContent = safeStringify(ctx2); + div2.append(pre2); + ctxEl.append(div2); + } + }, 1e3); + } + function safeStringify(obj) { + const seen = /* @__PURE__ */ new WeakSet(); + return JSON.stringify(obj, (key, value) => { + if (typeof value === "object" && value !== null) { + if (seen.has(value)) { + return "[Circular]"; + } + seen.add(value); + } + return value; + }, 2); + } // lib/input.ts var InputManager = class { @@ -71,7 +121,9 @@ this.mouseEvents.set(key, cb); } offKey(key) { + const events = this.keyEvents.get(key); this.keyEvents.delete(key); + return events; } offMouse(key) { this.mouseEvents.delete(key); @@ -1077,6 +1129,164 @@ }; } + // state/machine.ts + var StateMachine = class { + _states = /* @__PURE__ */ new Map(); + currentState; + update(dt, ctx2) { + this.currentState?.update(dt, ctx2); + } + get current() { + return this.currentState; + } + get states() { + return this._states; + } + addState(state2) { + this.states.set(state2.name, state2); + } + transitionTo(state2) { + if (!this.current) { + this.currentState = this._states.get(state2); + this.currentState.start(); + return; + } + if (this.current?.canTransitionTo(state2) && this._states.has(state2)) { + this.current.stop(); + this.currentState = this._states.get(state2); + this.current.start(); + } + } + }; + var State = class { + stateMachine; + constructor(stateMachine) { + this.stateMachine = stateMachine; + } + canTransitionTo(state2) { + return this.validTransitions.has(state2); + } + }; + + // state/states/EditTrainState.ts + var EditTrainState = class extends State { + name = 4 /* EDIT_TRAIN */; + validTransitions = /* @__PURE__ */ new Set([ + 1 /* RUNNING */, + 2 /* PAUSED */ + ]); + update(dt) { + throw new Error("Method not implemented."); + } + start() { + throw new Error("Method not implemented."); + } + stop() { + throw new Error("Method not implemented."); + } + }; + + // state/states/EditTrackState.ts + var EditTrackState = class extends State { + name = 3 /* EDIT_TRACK */; + validTransitions = /* @__PURE__ */ new Set([ + 1 /* RUNNING */, + 2 /* PAUSED */ + ]); + heldEvents = /* @__PURE__ */ new Map(); + update(dt) { + const inputManager2 = getContextItem("inputManager"); + const track = getContextItem("track"); + const firstSegment = track.firstSegment; + if (firstSegment) { + const firstPoint = firstSegment.points[0].copy(); + const { x, y } = inputManager2.getMouseLocation(); + firstSegment.points.forEach((p, i) => { + const relativePoint = Vector.sub(p, firstPoint); + p.set(x, y); + p.add(relativePoint); + }); + } + track.draw(true); + } + start() { + const inputManager2 = getContextItem("inputManager"); + this.heldEvents.set("e", inputManager2.offKey("e")); + this.heldEvents.set("Escape", inputManager2.offKey("Escape")); + inputManager2.onKey("e", () => { + const state2 = getContextItem("state"); + state2.transitionTo(1 /* RUNNING */); + }); + const track = getContextItem("track"); + setContextItem("trackCopy", track.copy()); + inputManager2.onKey("Escape", () => { + const trackCopy = getContextItem("trackCopy"); + setContextItem("track", trackCopy); + const state2 = getContextItem("state"); + state2.transitionTo(1 /* RUNNING */); + }); + } + stop() { + if (this.heldEvents.size > 0) { + for (const [key, cb] of this.heldEvents) { + if (cb) { + getContextItem("inputManager").onKey(key, cb); + } + this.heldEvents.delete(key); + } + } + } + }; + + // state/states/PausedState.ts + var PausedState = class extends State { + name = 2 /* PAUSED */; + validTransitions = /* @__PURE__ */ new Set([ + 0 /* LOAD */, + 1 /* RUNNING */, + 3 /* EDIT_TRACK */, + 4 /* EDIT_TRAIN */ + ]); + update(dt) { + throw new Error("Method not implemented."); + } + start() { + throw new Error("Method not implemented."); + } + stop() { + throw new Error("Method not implemented."); + } + }; + + // state/states/RunningState.ts + var RunningState = class extends State { + name = 1 /* RUNNING */; + validTransitions = /* @__PURE__ */ new Set([ + 2 /* PAUSED */, + 3 /* EDIT_TRACK */ + ]); + update(dt) { + const ctx2 = getContext(); + ctx2.track.draw(); + for (const train of ctx2.trains) { + train.draw(); + } + } + start() { + } + stop() { + } + }; + + // inputs.ts + function bootstrapInputs() { + const inputManager2 = getContextItem("inputManager"); + inputManager2.onKey("e", () => { + const state2 = getContextItem("state"); + state2.transitionTo(3 /* EDIT_TRACK */); + }); + } + // math/path.ts var PathSegment = class { points; @@ -1224,11 +1434,16 @@ doodler; constructor(segments) { this.doodler = getContextItem("doodler"); - this.segments = segments; + for (const segment of segments) { + this.segments.set(segment.id, segment); + } } - draw() { - for (const segment of this.segments) { - segment.draw(); + get firstSegment() { + return this.segments.values().next().value; + } + draw(showControls = false) { + for (const segment of this.segments.values()) { + segment.draw(showControls); } try { if (getContextItem("showEnds")) { @@ -1256,7 +1471,7 @@ } findEnds() { const ends = []; - for (const segment of this.segments) { + for (const segment of this.segments.values()) { const [a, b, c, d] = segment.points; { const tangent = Vector.sub(a, b).normalize(); @@ -1274,12 +1489,33 @@ serialize() { return this.segments.values().map((s) => s.serialize()).toArray(); } - static deserialize(data) { - const track2 = new _TrackSystem([]); - for (const segment of data) { - track2.segments.set(segment.id, TrackSegment.deserialize(segment)); + copy() { + const track = new _TrackSystem([]); + for (const segment of this.segments.values()) { + track.segments.set(segment.id, segment.copy()); } - return track2; + return track; + } + static deserialize(data) { + if (data.length === 0) return void 0; + const track = new _TrackSystem([]); + const neighborMap = /* @__PURE__ */ new Map(); + for (const segment of data) { + track.segments.set(segment.id, TrackSegment.deserialize(segment)); + } + for (const segment of track.segments.values()) { + segment.setTrack(track); + const neighbors = neighborMap.get(segment.id); + if (neighbors) { + segment.backNeighbours = neighbors[1].map( + (id) => track.segments.get(id) + ).filter((s) => s); + segment.frontNeighbours = neighbors[0].map( + (id) => track.segments.get(id) + ).filter((s) => s); + } + } + return track; } }; var TrackSegment = class _TrackSegment extends PathSegment { @@ -1296,7 +1532,7 @@ setTrack(t) { this.track = t; } - draw() { + draw(showControls = false) { this.doodler.drawBezier( this.points[0], this.points[1], @@ -1306,6 +1542,16 @@ strokeColor: "#ffffff50" } ); + if (showControls) { + this.doodler.drawCircle(this.points[1], 4, { + color: "red", + weight: 3 + }); + this.doodler.drawCircle(this.points[2], 4, { + color: "red", + weight: 3 + }); + } } serialize() { return { @@ -1315,6 +1561,12 @@ fNeighbors: this.frontNeighbours.map((n) => n.id) }; } + copy() { + return new _TrackSegment( + this.points.map((p) => p.copy()), + this.id + ); + } static deserialize(data) { return new _TrackSegment( data.p.map((p) => new Vector(p[0], p[1], p[2])), @@ -1328,7 +1580,7 @@ constructor(start) { start = start || new Vector(100, 100); super([ - start.copy(), + start, start.copy().add(25, 0), start.copy().add(75, 0), start.copy().add(100, 0) @@ -1336,6 +1588,52 @@ } }; + // state/states/LoadState.ts + var LoadState = class extends State { + name = 0 /* LOAD */; + validTransitions = /* @__PURE__ */ new Set([ + 1 /* RUNNING */ + ]); + update() { + } + start() { + const track = this.loadTrack() ?? new TrackSystem([new StraightTrack()]); + setContextItem("track", track); + const trains = this.loadTrains() ?? []; + setContextItem("trains", trains); + const resources2 = new ResourceManager(); + setContextItem("resources", resources2); + const inputManager2 = new InputManager(); + setContextItem("inputManager", inputManager2); + bootstrapInputs(); + this.stateMachine.transitionTo(1 /* RUNNING */); + } + stop() { + } + loadTrack() { + const track = TrackSystem.deserialize( + JSON.parse(localStorage.getItem("track") || "[]") + ); + return track; + } + loadTrains() { + const trains = JSON.parse(localStorage.getItem("trains") || "[]"); + return trains; + } + }; + + // state/states/index.ts + function bootstrapGameStateMachine() { + const stateMachine = new StateMachine(); + stateMachine.addState(new LoadState(stateMachine)); + stateMachine.addState(new RunningState(stateMachine)); + stateMachine.addState(new PausedState(stateMachine)); + stateMachine.addState(new EditTrackState(stateMachine)); + stateMachine.addState(new EditTrainState(stateMachine)); + stateMachine.transitionTo(0 /* LOAD */); + return stateMachine; + } + // main.ts var inputManager = new InputManager(); var resources = new ResourceManager(); @@ -1350,6 +1648,8 @@ debug: true, showEnds: true }); + var state = bootstrapGameStateMachine(); + setContextItem("state", state); doodler.init(); addButton({ text: "Hello World!", @@ -1365,8 +1665,7 @@ color: "white" } }); - var track = new TrackSystem([new StraightTrack()]); - doodler.createLayer(() => { - track.draw(); + doodler.createLayer((_, __, dTime) => { + state.update(dTime); }); })(); diff --git a/deno.json b/deno.json index f12d7db..f40273a 100644 --- a/deno.json +++ b/deno.json @@ -13,7 +13,6 @@ "dev": "deno run -RWEN --allow-run dev.ts dev" }, "imports": { - "@bearmetal/doodler": "jsr:@bearmetal/doodler@^0.0.3", - "@lib/": "./lib/" + "@bearmetal/doodler": "jsr:@bearmetal/doodler@^0.0.3" } } \ No newline at end of file diff --git a/deno.lock b/deno.lock index ebb8570..d922b2b 100644 --- a/deno.lock +++ b/deno.lock @@ -3,6 +3,7 @@ "specifiers": { "jsr:@bearmetal/doodler@^0.0.3": "0.0.3", "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", "jsr:@std/assert@^1.0.10": "1.0.10", "jsr:@std/bytes@^1.0.2": "1.0.2", @@ -32,6 +33,14 @@ "jsr:@std/path@^1.0.6" ] }, + "@luca/esbuild-deno-loader@0.11.1": { + "integrity": "dc020d16d75b591f679f6b9288b10f38bdb4f24345edb2f5732affa1d9885267", + "dependencies": [ + "jsr:@std/bytes", + "jsr:@std/encoding", + "jsr:@std/path@^1.0.6" + ] + }, "@std/assert@1.0.10": { "integrity": "59b5cbac5bd55459a19045d95cc7c2ff787b4f8527c0dd195078ff6f9481fbb3", "dependencies": [ diff --git a/dev.ts b/dev.ts index 4a1a29c..442abb8 100644 --- a/dev.ts +++ b/dev.ts @@ -1,7 +1,7 @@ /// import * as esbuild from "npm:esbuild"; -import { denoPlugins } from "jsr:@luca/esbuild-deno-loader"; +import { denoPlugins } from "jsr:@luca/esbuild-deno-loader@0.11.1"; import { serveDir } from "jsr:@std/http"; async function* crawl(dir: string): AsyncIterable { diff --git a/game.ts b/game.ts new file mode 100644 index 0000000..e69de29 diff --git a/index.html b/index.html index aaf5bf1..2a2d557 100644 --- a/index.html +++ b/index.html @@ -14,6 +14,18 @@ height: 100%; width: 100%; } + #context { + position: absolute; + top: 0; + left: 0; + background-color: rgba(0, 0, 0, 0.5); + color: white; + padding: 10px; + display: flex; + gap: 10px; + max-height: 50vh; + overflow-y: auto; + } diff --git a/inputs.ts b/inputs.ts new file mode 100644 index 0000000..8655e9e --- /dev/null +++ b/inputs.ts @@ -0,0 +1,12 @@ +import { getContextItem } from "./lib/context.ts"; +import { InputManager } from "./lib/input.ts"; +import { StateMachine } from "./state/machine.ts"; +import { States } from "./state/states/index.ts"; + +export function bootstrapInputs() { + const inputManager = getContextItem("inputManager"); + inputManager.onKey("e", () => { + const state = getContextItem>("state"); + state.transitionTo(States.EDIT_TRACK); + }); +} diff --git a/lib/context.ts b/lib/context.ts index 90862ce..c639b50 100644 --- a/lib/context.ts +++ b/lib/context.ts @@ -3,6 +3,8 @@ type ContextStore = Record; const contextStack: ContextStore[] = []; const defaultContext: ContextStore = {}; +const debug = JSON.parse(localStorage.getItem("debug") || "false"); + export function setDefaultContext(context: ContextStore) { Object.assign(defaultContext, context); } @@ -23,7 +25,7 @@ export const ctx = new Proxy( for (let i = contextStack.length - 1; i >= 0; i--) { if (prop in contextStack[i]) return contextStack[i][prop]; } - if (prop in defaultContext) return defaultContext[prop]; // ✅ Fallback to default + if (prop in defaultContext) return defaultContext[prop]; throw new Error(`Context variable '${prop}' is not defined.`); }, }, @@ -35,3 +37,51 @@ export function getContext() { export function getContextItem(prop: string): T { return ctx[prop] as T; } +export function setContextItem(prop: string, value: T) { + Object.assign(contextStack[contextStack.length - 1] ?? defaultContext, { + [prop]: value, + }); +} + +if (debug) { + setInterval(() => { + let ctxEl = document.getElementById("context"); + if (!ctxEl) { + ctxEl = document.createElement("div"); + ctxEl.id = "context"; + document.body.append(ctxEl); + } + ctxEl.innerHTML = ""; + const div = document.createElement("div"); + const pre = document.createElement("pre"); + const h3 = document.createElement("h3"); + h3.textContent = "Default"; + div.append(h3); + pre.textContent = safeStringify(defaultContext); + div.append(pre); + ctxEl.append(div); + for (const [idx, ctx] of contextStack.entries()) { + const div = document.createElement("div"); + const pre = document.createElement("pre"); + const h3 = document.createElement("h3"); + h3.textContent = "CTX " + idx; + div.append(h3); + pre.textContent = safeStringify(ctx); + div.append(pre); + ctxEl.append(div); + } + }, 1000); +} + +function safeStringify(obj: any) { + const seen = new WeakSet(); + return JSON.stringify(obj, (key, value) => { + if (typeof value === "object" && value !== null) { + if (seen.has(value)) { + return "[Circular]"; // Replace circular references + } + seen.add(value); + } + return value; + }, 2); +} diff --git a/lib/input.ts b/lib/input.ts index f2ee408..7928fee 100644 --- a/lib/input.ts +++ b/lib/input.ts @@ -53,7 +53,9 @@ export class InputManager { } offKey(key: string | number) { + const events = this.keyEvents.get(key); this.keyEvents.delete(key); + return events; } offMouse(key: string | number) { this.mouseEvents.delete(key); diff --git a/main.ts b/main.ts index 4303a50..5a15a8b 100644 --- a/main.ts +++ b/main.ts @@ -1,4 +1,8 @@ -import { setDefaultContext } from "./lib/context.ts"; +import { + getContext, + setContextItem, + setDefaultContext, +} from "./lib/context.ts"; import { InputManager } from "./lib/input.ts"; import { Doodler, Vector, ZoomableDoodler } from "@bearmetal/doodler"; @@ -6,6 +10,8 @@ import { ResourceManager } from "./lib/resources.ts"; import { addButton } from "./ui/button.ts"; import { TrackSystem } from "./track/system.ts"; import { StraightTrack } from "./track/shapes.ts"; +import { StateMachine } from "./state/machine.ts"; +import { bootstrapGameStateMachine } from "./state/states/index.ts"; const inputManager = new InputManager(); const resources = new ResourceManager(); @@ -22,6 +28,9 @@ setDefaultContext({ showEnds: true, }); +const state = bootstrapGameStateMachine(); +setContextItem("state", state); + doodler.init(); addButton({ text: "Hello World!", @@ -38,8 +47,6 @@ addButton({ }, }); -const track = new TrackSystem([new StraightTrack()]); - -doodler.createLayer(() => { - track.draw(); +doodler.createLayer((_, __, dTime) => { + state.update(dTime); }); diff --git a/state/machine.ts b/state/machine.ts index 2ac7110..1da69e8 100644 --- a/state/machine.ts +++ b/state/machine.ts @@ -1,13 +1,9 @@ -export class StateMachine { - private _states: Map = new Map(); - private currentState: State; +export class StateMachine { + private _states: Map> = new Map(); + private currentState?: State; - constructor(states: State[]) { - this.currentState = states[0]; - } - - update(dt: number) { - this.currentState.update(dt); + update(dt: number, ctx?: CanvasRenderingContext2D) { + this.currentState?.update(dt, ctx); } get current() { @@ -18,32 +14,37 @@ export class StateMachine { return this._states; } - addState(state: State) { + addState(state: State) { this.states.set(state.name, state); } - transitionTo(state: State) { - if (this.current.canTransitionTo(state)) { + transitionTo(state: T) { + if (!this.current) { + this.currentState = this._states.get(state)!; + this.currentState.start(); + return; + } + if (this.current?.canTransitionTo(state) && this._states.has(state)) { this.current.stop(); - this.currentState = state; + this.currentState = this._states.get(state)!; this.current.start(); } } } export abstract class State { - private stateMachine: StateMachine; + protected stateMachine: StateMachine; protected abstract validTransitions: Set; abstract readonly name: T; constructor( - stateMachine: StateMachine, + stateMachine: StateMachine, ) { this.stateMachine = stateMachine; } - abstract update(dt: number): void; + abstract update(dt: number, ctx?: CanvasRenderingContext2D): void; abstract start(): void; abstract stop(): void; @@ -51,3 +52,25 @@ export abstract class State { return this.validTransitions.has(state); } } + +export abstract class ExtensibleState extends State { + extensions: Map void> = new Map(); + registerExtension(name: string, cb: (...args: unknown[]) => void) { + this.extensions.set(name, cb); + } + + constructor(stateMachine: StateMachine) { + super(stateMachine); + const oldUpdate = this.update; + this.update = function (dt: number, ctx?: CanvasRenderingContext2D) { + oldUpdate.apply(this, [dt, ctx]); + this.runExtensions(dt, ctx); + }; + } + + runExtensions(...args: unknown[]) { + for (const [name, cb] of this.extensions) { + cb(...args); + } + } +} diff --git a/state/states.ts b/state/states.ts deleted file mode 100644 index 6043517..0000000 --- a/state/states.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { State } from "./machine.ts"; - -enum States { - LOAD, - RUNNING, - PAUSED, - EDIT_TRACK, - EDIT_TRAIN, -} - -export class LoadState extends State { - override name: States = States.LOAD; - override validTransitions: Set = new Set([ - States.RUNNING, - ]); - - override update(dt: number): void { - throw new Error("Method not implemented."); - // TODO - // Do nothing - } - override start(): void { - throw new Error("Method not implemented."); - // TODO - // load track into context - // Load trains into context - // Load resources into context - // Switch to running state - } - override stop(): void { - throw new Error("Method not implemented."); - // TODO - // Do nothing - } -} - -export class RunningState extends State { - override name: States = States.RUNNING; - override validTransitions: Set = new Set([ - States.PAUSED, - States.EDIT_TRACK, - ]); - override update(dt: number): void { - throw new Error("Method not implemented."); - // TODO - // Update trains - // Update world - // Handle input - // Draw (maybe via a layer system that syncs with doodler) - // Monitor world events - } - override start(): void { - throw new Error("Method not implemented."); - // TODO - // Do nothing - } - override stop(): void { - throw new Error("Method not implemented."); - // TODO - // Do nothing - } -} - -export class PausedState extends State { - override name: States = States.PAUSED; - override validTransitions: Set = new Set([ - States.LOAD, - States.RUNNING, - States.EDIT_TRACK, - States.EDIT_TRAIN, - ]); - override update(dt: number): void { - throw new Error("Method not implemented."); - // TODO - // Handle input - // Draw ui - } - override start(): void { - throw new Error("Method not implemented."); - // TODO - // Save tracks to cache - // Save trains to cache - // Save resources to cache - } - override stop(): void { - throw new Error("Method not implemented."); - // TODO - // Do nothing - } -} - -export class EditTrackState extends State { - override name: States = States.EDIT_TRACK; - override validTransitions: Set = new Set([ - States.RUNNING, - States.PAUSED, - ]); - override update(dt: number): void { - throw new Error("Method not implemented."); - // TODO - // Handle input - // Draw ui - // Draw track - // Draw track points - // Draw track tangents - } - override start(): void { - throw new Error("Method not implemented."); - // TODO - // Cache trains and save - // Stash track in context - } - override stop(): void { - throw new Error("Method not implemented."); - } -} - -export class EditTrainState extends State { - override name: States = States.EDIT_TRAIN; - override validTransitions: Set = new Set([ - States.RUNNING, - States.PAUSED, - ]); - - override update(dt: number): void { - throw new Error("Method not implemented."); - } - override start(): void { - throw new Error("Method not implemented."); - // TODO - // Cache trains - // Stash train in context - // Draw track - // Draw train (filtered by train ID) - } - override stop(): void { - throw new Error("Method not implemented."); - } -} diff --git a/state/states/EditTrackState.ts b/state/states/EditTrackState.ts new file mode 100644 index 0000000..9d7d2c5 --- /dev/null +++ b/state/states/EditTrackState.ts @@ -0,0 +1,70 @@ +import { Vector } from "@bearmetal/doodler"; +import { getContextItem, 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"; + +export class EditTrackState extends State { + override name: States = States.EDIT_TRACK; + override validTransitions: Set = new Set([ + States.RUNNING, + States.PAUSED, + ]); + + private heldEvents: Map void) | undefined> = + new Map(); + + override update(dt: number): void { + const inputManager = getContextItem("inputManager"); + const track = getContextItem("track"); + + const firstSegment = track.firstSegment; + if (firstSegment) { + const firstPoint = firstSegment.points[0].copy(); + const { x, y } = inputManager.getMouseLocation(); + firstSegment.points.forEach((p, i) => { + const relativePoint = Vector.sub(p, firstPoint); + p.set(x, y); + p.add(relativePoint); + }); + } + + track.draw(true); + // TODO + // Draw ui + // Draw track points + // Draw track tangents + } + override start(): void { + const inputManager = getContextItem("inputManager"); + this.heldEvents.set("e", inputManager.offKey("e")); + this.heldEvents.set("Escape", inputManager.offKey("Escape")); + inputManager.onKey("e", () => { + const state = getContextItem>("state"); + state.transitionTo(States.RUNNING); + }); + + const track = getContextItem("track"); + setContextItem("trackCopy", track.copy()); + inputManager.onKey("Escape", () => { + const trackCopy = getContextItem("trackCopy"); + setContextItem("track", trackCopy); + const state = getContextItem>("state"); + state.transitionTo(States.RUNNING); + }); + // TODO + // Cache trains and save + // Stash track in context + } + override stop(): void { + if (this.heldEvents.size > 0) { + for (const [key, cb] of this.heldEvents) { + if (cb) { + getContextItem("inputManager").onKey(key, cb); + } + this.heldEvents.delete(key); + } + } + } +} diff --git a/state/states/EditTrainState.ts b/state/states/EditTrainState.ts new file mode 100644 index 0000000..f8a58f6 --- /dev/null +++ b/state/states/EditTrainState.ts @@ -0,0 +1,25 @@ +import { State } from "../machine.ts"; +import { States } from "./index.ts"; + +export class EditTrainState extends State { + override name: States = States.EDIT_TRAIN; + override validTransitions: Set = new Set([ + States.RUNNING, + States.PAUSED, + ]); + + override update(dt: number): void { + throw new Error("Method not implemented."); + } + override start(): void { + throw new Error("Method not implemented."); + // TODO + // Cache trains + // Stash train in context + // Draw track + // Draw train (filtered by train ID) + } + override stop(): void { + throw new Error("Method not implemented."); + } +} diff --git a/state/states/LoadState.ts b/state/states/LoadState.ts new file mode 100644 index 0000000..1562482 --- /dev/null +++ b/state/states/LoadState.ts @@ -0,0 +1,51 @@ +import { bootstrapInputs } from "../../inputs.ts"; +import { setContextItem } from "../../lib/context.ts"; +import { InputManager } from "../../lib/input.ts"; +import { ResourceManager } from "../../lib/resources.ts"; +import { StraightTrack } from "../../track/shapes.ts"; +import { TrackSystem } from "../../track/system.ts"; +import { State } from "../machine.ts"; +import { States } from "./index.ts"; + +export class LoadState extends State { + override name: States = States.LOAD; + override validTransitions: Set = new Set([ + States.RUNNING, + ]); + + override update(): void { + // noop + } + override start(): void { + const track = this.loadTrack() ?? new TrackSystem([new StraightTrack()]); + setContextItem("track", track); + + const trains = this.loadTrains() ?? []; + setContextItem("trains", trains); + + const resources = new ResourceManager(); + setContextItem("resources", resources); + + const inputManager = new InputManager(); + setContextItem("inputManager", inputManager); + + bootstrapInputs(); + + this.stateMachine.transitionTo(States.RUNNING); + } + override stop(): void { + // noop + } + + private loadTrack() { + const track = TrackSystem.deserialize( + JSON.parse(localStorage.getItem("track") || "[]"), + ); + return track; + } + + private loadTrains() { + const trains = JSON.parse(localStorage.getItem("trains") || "[]"); + return trains; + } +} diff --git a/state/states/PausedState.ts b/state/states/PausedState.ts new file mode 100644 index 0000000..d4a40f1 --- /dev/null +++ b/state/states/PausedState.ts @@ -0,0 +1,30 @@ +import { State } from "../machine.ts"; +import { States } from "./index.ts"; + +export class PausedState extends State { + override name: States = States.PAUSED; + override validTransitions: Set = new Set([ + States.LOAD, + States.RUNNING, + States.EDIT_TRACK, + States.EDIT_TRAIN, + ]); + override update(dt: number): void { + throw new Error("Method not implemented."); + // TODO + // Handle input + // Draw ui + } + override start(): void { + throw new Error("Method not implemented."); + // TODO + // Save tracks to cache + // Save trains to cache + // Save resources to cache + } + override stop(): void { + throw new Error("Method not implemented."); + // TODO + // Do nothing + } +} diff --git a/state/states/RunningState.ts b/state/states/RunningState.ts new file mode 100644 index 0000000..b28afcf --- /dev/null +++ b/state/states/RunningState.ts @@ -0,0 +1,32 @@ +import { getContext } from "../../lib/context.ts"; +import { TrackSystem } from "../../track/system.ts"; +import { Train } from "../../train/train.ts"; +import { State } from "../machine.ts"; +import { States } from "./index.ts"; + +export class RunningState extends State { + override name: States = States.RUNNING; + override validTransitions: Set = new Set([ + States.PAUSED, + States.EDIT_TRACK, + ]); + override update(dt: number): void { + const ctx = getContext() as { trains: Train[]; track: TrackSystem }; + // TODO + // Update trains + // Update world + // Handle input + // Draw (maybe via a layer system that syncs with doodler) + ctx.track.draw(); + for (const train of ctx.trains) { + train.draw(); + } + // Monitor world events + } + override start(): void { + // noop + } + override stop(): void { + // noop + } +} diff --git a/state/states/index.ts b/state/states/index.ts new file mode 100644 index 0000000..9ea8cab --- /dev/null +++ b/state/states/index.ts @@ -0,0 +1,26 @@ +import { StateMachine } from "../machine.ts"; +import { Track } from "../../track.ts"; +import { EditTrainState } from "./EditTrainState.ts"; +import { EditTrackState } from "./EditTrackState.ts"; +import { PausedState } from "./PausedState.ts"; +import { RunningState } from "./RunningState.ts"; +import { LoadState } from "./LoadState.ts"; + +export enum States { + LOAD, + RUNNING, + PAUSED, + EDIT_TRACK, + EDIT_TRAIN, +} + +export function bootstrapGameStateMachine() { + const stateMachine = new StateMachine(); + stateMachine.addState(new LoadState(stateMachine)); + stateMachine.addState(new RunningState(stateMachine)); + stateMachine.addState(new PausedState(stateMachine)); + stateMachine.addState(new EditTrackState(stateMachine)); + stateMachine.addState(new EditTrainState(stateMachine)); + stateMachine.transitionTo(States.LOAD); + return stateMachine; +} diff --git a/test/contextBench.test.js b/test/contextBench.test.js index 9f78661..19bb431 100644 --- a/test/contextBench.test.js +++ b/test/contextBench.test.js @@ -1,10 +1,16 @@ import { getContextItem, + setContextItem, setDefaultContext, withContext, -} from "@lib/context.ts"; // adjust path as needed +} from "../lib/context.ts"; // adjust path as needed import { testPerformance } from "./bench.ts"; +/** + * Benchmarks the performance of setting and getting context items. + * All context transactions should run 10000 times within the 60 FPS frame time. + * getContextItem should run 100000 times within the 240 FPS frame time to ensure adequate performance. + */ Deno.test("Context Benchmark", () => { console.log("Context Benchmark - run within frame time"); testPerformance( @@ -32,4 +38,12 @@ Deno.test("Context Benchmark", () => { 100000, 240, ); + + testPerformance( + () => { + setContextItem("a", 1); + }, + 10000, + 60, + ); }); diff --git a/track/system.ts b/track/system.ts index 2007987..ee628c6 100644 --- a/track/system.ts +++ b/track/system.ts @@ -13,9 +13,13 @@ export class TrackSystem { } } - draw() { + get firstSegment() { + return this.segments.values().next().value; + } + + draw(showControls = false) { for (const segment of this.segments.values()) { - segment.draw(); + segment.draw(showControls); } try { @@ -65,15 +69,39 @@ export class TrackSystem { return this.segments.values().map((s) => s.serialize()).toArray(); } - static deserialize(data: any) { + copy() { const track = new TrackSystem([]); + for (const segment of this.segments.values()) { + track.segments.set(segment.id, segment.copy()); + } + return track; + } + + static deserialize(data: any) { + if (data.length === 0) return undefined; + const track = new TrackSystem([]); + const neighborMap = new Map(); for (const segment of data) { track.segments.set(segment.id, TrackSegment.deserialize(segment)); } + for (const segment of track.segments.values()) { + segment.setTrack(track); + const neighbors = neighborMap.get(segment.id); + if (neighbors) { + segment.backNeighbours = neighbors[1].map((id) => + track.segments.get(id) + ).filter((s) => s) as TrackSegment[]; + segment.frontNeighbours = neighbors[0].map((id) => + track.segments.get(id) + ).filter((s) => s) as TrackSegment[]; + } + } return track; } } +type VectorSet = [Vector, Vector, Vector, Vector]; + export class TrackSegment extends PathSegment { frontNeighbours: TrackSegment[] = []; backNeighbours: TrackSegment[] = []; @@ -84,7 +112,7 @@ export class TrackSegment extends PathSegment { id: string; - constructor(p: [Vector, Vector, Vector, Vector], id?: string) { + constructor(p: VectorSet, id?: string) { super(p); this.doodler = getContextItem("doodler"); this.id = id ?? crypto.randomUUID(); @@ -94,7 +122,7 @@ export class TrackSegment extends PathSegment { this.track = t; } - draw() { + draw(showControls = false) { this.doodler.drawBezier( this.points[0], this.points[1], @@ -104,6 +132,24 @@ export class TrackSegment extends PathSegment { strokeColor: "#ffffff50", }, ); + if (showControls) { + // this.doodler.drawCircle(this.points[0], 4, { + // color: "red", + // weight: 3, + // }); + this.doodler.drawCircle(this.points[1], 4, { + color: "red", + weight: 3, + }); + this.doodler.drawCircle(this.points[2], 4, { + color: "red", + weight: 3, + }); + // this.doodler.drawCircle(this.points[3], 4, { + // color: "red", + // weight: 3, + // }); + } } serialize() { @@ -115,6 +161,13 @@ export class TrackSegment extends PathSegment { }; } + copy() { + return new TrackSegment( + this.points.map((p) => p.copy()) as VectorSet, + this.id, + ); + } + static deserialize(data: any) { return new TrackSegment( data.p.map((p: [number, number, number]) => new Vector(p[0], p[1], p[2])), diff --git a/train/train.ts b/train/train.ts index cd09e9e..219f1e3 100644 --- a/train/train.ts +++ b/train/train.ts @@ -1,9 +1,9 @@ -import { drawLine } from "../drawing/line.ts"; import { ComplexPath, PathSegment } from "../math/path.ts"; -import { Vector } from "doodler"; import { Follower } from "../physics/follower.ts"; import { Mover } from "../physics/mover.ts"; import { Spline, Track } from "../track.ts"; +import { getContextItem } from "../lib/context.ts"; +import { Doodler, Vector } from "@bearmetal/doodler"; export class Train { nodes: Vector[] = []; @@ -83,6 +83,12 @@ export class Train { // } // } + draw() { + for (const car of this.cars) { + car.draw(); + } + } + real2Track(length: number) { return length / this.path.pointSpacing; } @@ -113,6 +119,7 @@ export class TrainCar { draw() { if (!this.points) return; + const doodler = getContextItem("doodler"); const [a, b] = this.points; const origin = Vector.add(Vector.sub(a, b).div(2), b); const angle = Vector.sub(b, a).heading();