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();