Compare commits

...

6 Commits

26 changed files with 2750 additions and 401 deletions

3
.gitignore vendored
View File

@ -1,2 +1,3 @@
bundle.js
dist/
dist/
temp.ts

63
GameLoop.ts Normal file
View File

@ -0,0 +1,63 @@
import { Doodler } from "@bearmetal/doodler";
import { StateMachine } from "./state/machine.ts";
import { getContextItem } from "./lib/context.ts";
export class GameLoop<T> {
lastTime: number;
running: boolean;
targetFps: number;
constructor(targetFps: number = 60) {
this.lastTime = performance.now();
this.running = false;
this.targetFps = targetFps;
}
async start(state: StateMachine<T>) {
if (this.running) return;
this.running = true;
this.lastTime = performance.now();
while (this.running) {
const currentTime = performance.now();
const deltaTime = (currentTime - this.lastTime) / 1000; // Convert to seconds
this.lastTime = currentTime;
try {
// Wait for state update to complete before continuing
await state.update(deltaTime);
} catch (error) {
console.error("Error in game loop:", error);
this.stop();
break;
}
// Use setTimeout to prevent immediate loop continuation
// and allow other tasks to run
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
stop() {
this.running = false;
}
}
// // Usage example:
// const gameState = {
// update: async (deltaTime) => {
// console.log(`Updating with delta time: ${deltaTime.toFixed(3)}s`);
// // Simulate some async work
// await new Promise(resolve => setTimeout(resolve, 16)); // ~60fps
// }
// };
// // Create and start the loop
// const loop = new GameLoop();
// loop.start(gameState);
// // Stop the loop after 5 seconds (example)
// setTimeout(() => {
// loop.stop();
// console.log('Loop stopped');
// }, 5000);

1353
bundle.js

File diff suppressed because it is too large Load Diff

View File

@ -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.5-b"
}
}

11
deno.lock generated
View File

@ -1,7 +1,7 @@
{
"version": "4",
"specifiers": {
"jsr:@bearmetal/doodler@^0.0.3": "0.0.3",
"jsr:@bearmetal/doodler@0.0.5-b": "0.0.5-b",
"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 +22,11 @@
"npm:esbuild@*": "0.24.2"
},
"jsr": {
"@bearmetal/doodler@0.0.3": {
"integrity": "42c04b672f4a6bc7ebd45ad936197a2e32856364b66a9a9fe2b81a4aa45c7a08"
"@bearmetal/doodler@0.0.4": {
"integrity": "b631083cff84994c513f70d1f09e6a9256edabcb224112c93a9ca6a87c88a389"
},
"@bearmetal/doodler@0.0.5-b": {
"integrity": "94f265ea21162f943291526800de7f3f6560634a4fe762a38cd73892685b6742"
},
"@luca/esbuild-deno-loader@0.11.0": {
"integrity": "c05a989aa7c4ee6992a27be5f15cfc5be12834cab7ff84cabb47313737c51a2c",
@ -229,7 +232,7 @@
},
"workspace": {
"dependencies": [
"jsr:@bearmetal/doodler@^0.0.3"
"jsr:@bearmetal/doodler@0.0.5-b"
]
}
}

View File

@ -26,6 +26,18 @@
max-height: 50vh;
overflow-y: auto;
}
#fps {
position: absolute;
top: 0;
right: 0;
background-color: rgba(0, 0, 0, 0.5);
color: white;
padding: 10px;
display: flex;
gap: 10px;
max-height: 50vh;
overflow-y: auto;
}
</style>
</head>
<body>

View File

@ -9,4 +9,9 @@ export function bootstrapInputs() {
const state = getContextItem<StateMachine<States>>("state");
state.transitionTo(States.EDIT_TRACK);
});
inputManager.onKey("Delete", () => {
if (inputManager.getKeyState("Control")) {
localStorage.removeItem("track");
}
});
}

View File

@ -37,6 +37,13 @@ export function getContext() {
export function getContextItem<T>(prop: string): T {
return ctx[prop] as T;
}
export function getContextItemOrDefault<T>(prop: string, defaultValue: T): T {
try {
return ctx[prop] as T;
} catch {
return defaultValue;
}
}
export function setContextItem<T>(prop: string, value: T) {
Object.assign(contextStack[contextStack.length - 1] ?? defaultContext, {
[prop]: value,

View File

@ -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<string | number, boolean> = new Map();
private mouseStates: Map<string | number, boolean> = new Map();
private keyStates: Map<string, boolean> = new Map();
private mouseStates: Map<string, boolean> = 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<string | number, () => void> = new Map();
private mouseEvents: Map<string | number, () => void> = new Map();
private keyEvents: Map<string, () => void> = new Map();
private mouseEvents: Map<string, () => 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));
}
}
}

67
main.ts
View File

@ -1,5 +1,6 @@
import {
getContext,
getContextItem,
setContextItem,
setDefaultContext,
} from "./lib/context.ts";
@ -10,8 +11,9 @@ 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 { State, StateMachine } from "./state/machine.ts";
import { bootstrapGameStateMachine } from "./state/states/index.ts";
import { GameLoop } from "./GameLoop.ts";
const inputManager = new InputManager();
const resources = new ResourceManager();
@ -19,8 +21,21 @@ const doodler = new ZoomableDoodler({
fillScreen: true,
bg: "#302040",
});
(doodler as any as { ctx: CanvasRenderingContext2D }).ctx
.imageSmoothingEnabled = false;
// doodler.minScale = 0.1;
(doodler as any).scale = doodler.maxScale;
// (doodler as any).scale = doodler.maxScale;
const colors = [
"red",
"orange",
"yellow",
"green",
"blue",
"indigo",
"purple",
"violet",
];
setDefaultContext({
inputManager,
@ -28,27 +43,43 @@ setDefaultContext({
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<TrackSystem>("track");
localStorage.setItem("track", track.serialize());
console.log("Saved track to local storage");
}
});
doodler.createLayer((_, __, dTime) => {
state.update(dTime);
});
setInterval(() => {
const doodler = getContextItem<Doodler>("doodler");
const frameRate = doodler.fps;
if (frameRate < 0.5) return;
let fpsEl = document.getElementById("fps");
if (!fpsEl) {
fpsEl = document.createElement("div");
fpsEl.id = "fps";
document.body.appendChild(fpsEl);
}
const fPerc = frameRate / 60;
if (fPerc < 0.6) {
state.optimizePerformance(fPerc);
}
fpsEl.textContent = frameRate.toFixed(1) + " fps";
}, 1000);
const gameLoop = new GameLoop();
gameLoop.start(state);

3
math/clamp.ts Normal file
View File

@ -0,0 +1,3 @@
export function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}

View File

@ -2,10 +2,12 @@ import { Vector } from "@bearmetal/doodler";
export class ComplexPath {
points: Vector[] = [];
segments: PathSegment[] = [];
radius = 50;
ctx?: CanvasRenderingContext2D;
evenPoints: Vector[] = [];
constructor(points?: Vector[]) {
points && (this.points = points);
@ -35,15 +37,66 @@ export class ComplexPath {
}
ctx.restore();
}
followEvenPoints(t: number) {
if (t < 0) t += this.evenPoints.length;
const i = Math.floor(t);
const a = this.evenPoints[i];
const b = this.evenPoints[(i + 1) % this.evenPoints.length];
return Vector.lerp(a, b, t % 1);
}
calculateEvenlySpacedPoints(spacing: number, resolution = 1) {
// this.pointSpacing = 1;
// return this.segments.flatMap(s => s.calculateEvenlySpacedPoints(spacing, resolution));
const points: Vector[] = [];
points.push(this.segments[0].points[0]);
let prev = points[0];
let distSinceLastEvenPoint = 0;
for (const seg of this.segments) {
let t = 0;
const div = Math.ceil(seg.length * resolution * 10);
while (t < 1) {
t += 1 / div;
const point = seg.getPointAtT(t);
distSinceLastEvenPoint += prev.dist(point);
if (distSinceLastEvenPoint >= spacing) {
const overshoot = distSinceLastEvenPoint - spacing;
const evenPoint = Vector.add(
point,
Vector.sub(point, prev).normalize().mult(overshoot),
);
distSinceLastEvenPoint = overshoot;
points.push(evenPoint);
prev = evenPoint;
}
prev = point;
}
}
this.evenPoints = points;
return points;
}
}
export class PathSegment {
id: string;
points: [Vector, Vector, Vector, Vector];
length: number;
startingLength: number;
next?: PathSegment;
prev?: PathSegment;
constructor(points: [Vector, Vector, Vector, Vector]) {
this.id = crypto.randomUUID();
this.points = points;
this.length = this.calculateApproxLength(100);
this.startingLength = Math.round(this.length);
@ -254,4 +307,6 @@ export class PathSegment {
this.points[3].set(points[curveLength]);
}
}
draw(): void {}
}

View File

@ -1,7 +1,7 @@
import { lerp } from "./math/lerp.ts";
import { ComplexPath, PathSegment } from "./math/path.ts";
import { Mover } from "./physics/mover.ts";
import { Train, TrainCar } from "./train/train.ts";
import { Train, TrainCar } from "./train/train.old.ts";
import { generateSquareTrack, IControlNode, loadFromJson } from "./track.ts";
import { drawLine } from "./drawing/line.ts";
import { initializeDoodler, Vector } from "doodler";

View File

@ -1,3 +1,7 @@
import { getContext } from "../lib/context.ts";
import { TrackSystem } from "../track/system.ts";
import { Train } from "../train.old.ts";
export class StateMachine<T> {
private _states: Map<T, State<T>> = new Map();
private currentState?: State<T>;
@ -6,6 +10,13 @@ export class StateMachine<T> {
this.currentState?.update(dt, ctx);
}
optimizePerformance(percent: number) {
const ctx = getContext() as { trains: Train[]; track: TrackSystem };
if (percent < 0.5) {
ctx.track.optimize(percent);
}
}
get current() {
return this.currentState;
}

View File

@ -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<States> {
override name: States = States.EDIT_TRACK;
@ -14,14 +25,20 @@ export class EditTrackState extends State<States> {
States.PAUSED,
]);
private heldEvents: Map<string | number, (() => void) | undefined> =
new Map();
private heldEvents: Map<string, (() => void) | undefined> = new Map();
private currentSegment?: TrackSegment;
private selectedSegment?: TrackSegment;
private ghostSegment?: TrackSegment;
private ghostRotated = false;
private closestEnd?: End;
layers: number[] = [];
override update(dt: number): void {
const inputManager = getContextItem<InputManager>("inputManager");
const track = getContextItem<TrackSystem>("track");
const doodler = getContextItem<Doodler>("doodler");
// For moving a segment, i.e. the currently active one
// const segment = track.lastSegment;
@ -35,6 +52,96 @@ export class EditTrackState extends State<States> {
// });
// }
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;
}
}
// manipulate only end of segment while maintaining length
// const segment = track.lastSegment;
// if (segment) {
@ -50,65 +157,135 @@ export class EditTrackState extends State<States> {
// // doodler.fillText(curveLength.toFixed(2), p3.copy().add(10, 0), 100);
// }
const doodler = getContextItem<Doodler>("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);
// TODO
// Draw ui
// Draw track points
// Draw track tangents
}
override start(): void {
const doodler = getContextItem<Doodler>("doodler");
this.layers.push(
doodler.createLayer(() => {
this.selectedSegment?.draw();
if (this.ghostSegment) {
doodler.drawWithAlpha(0.5, () => {
if (!this.ghostSegment) return;
this.ghostSegment.draw();
if (getContextItemOrDefault("debug", false)) {
const colors = getContextItem<string[]>("colors");
for (
const [i, point] of this.ghostSegment.points.entries() ?? []
) {
doodler.fillCircle(point, 4, { color: colors[i + 3] });
}
}
});
}
track.draw(true);
}),
);
setContextItem("trackSegments", [
undefined,
new StraightTrack(),
new SBendLeft(),
new SBendRight(),
new BankLeft(),
new BankRight(),
]);
const inputManager = getContextItem<InputManager>("inputManager");
this.heldEvents.set("e", inputManager.offKey("e"));
this.heldEvents.set("Escape", inputManager.offKey("Escape"));
@ -127,31 +304,103 @@ export class EditTrackState extends State<States> {
state.transitionTo(States.RUNNING);
});
inputManager.onKey("w", () => {
inputManager.onKey(" ", () => {
if (this.selectedSegment) {
this.selectedSegment = undefined;
} else {
this.selectedSegment = new StraightTrack();
}
});
inputManager.onMouse("left", () => {
const track = getContextItem<TrackSystem>("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;
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.cleanCopy());
// this.selectedSegment = new StraightTrack();
} else {
this.selectedSegment = undefined;
}
});
inputManager.onKey("1", () => {
this.currentSegment = track.firstSegment;
// inputManager.onKey("w", () => {
// const track = getContextItem<TrackSystem>("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) => {
const segments = getContextItem<TrackSegment[]>("trackSegments");
this.selectedSegment = segments[i];
this.ghostRotated = false;
this.ghostSegment = undefined;
});
this.currentSegment = track.lastSegment;
// this.currentSegment = track.lastSegment;
inputManager.onKey("z", () => {
if (inputManager.getKeyState("Control")) {
const segment = track.lastSegment;
if (!segment) return;
this.redoBuffer.push(segment);
if (this.redoBuffer.length > 100) {
this.redoBuffer.shift();
}
track.unregisterSegment(segment);
}
});
inputManager.onKey("y", () => {
if (inputManager.getKeyState("Control")) {
const segment = this.redoBuffer.pop();
if (!segment) return;
track.registerSegment(segment);
}
});
// TODO
// Cache trains and save
// const trackCount = 2000;
// for (let i = 0; i < trackCount; i++) {
// const seg = new StraightTrack();
// track.registerSegment(seg);
// }
}
redoBuffer: TrackSegment[] = [];
override stop(): void {
for (const layer of this.layers) {
getContextItem<Doodler>("doodler").deleteLayer(layer);
}
const inputManager = getContextItem<InputManager>("inputManager");
inputManager.offKey("e");
inputManager.offKey("w");
inputManager.offKey("Escape");
inputManager.offMouse("left");
if (this.heldEvents.size > 0) {
for (const [key, cb] of this.heldEvents) {
if (cb) {
@ -161,5 +410,6 @@ export class EditTrackState extends State<States> {
}
}
setContextItem("trackCopy", undefined);
setContextItem("trackSegments", undefined);
}
}

View File

@ -1,5 +1,6 @@
import { Doodler, Vector } from "@bearmetal/doodler";
import { bootstrapInputs } from "../../inputs.ts";
import { setContextItem } from "../../lib/context.ts";
import { getContextItem, setContextItem } from "../../lib/context.ts";
import { InputManager } from "../../lib/input.ts";
import { ResourceManager } from "../../lib/resources.ts";
import { StraightTrack } from "../../track/shapes.ts";
@ -13,6 +14,8 @@ export class LoadState extends State<States> {
States.RUNNING,
]);
layers: number[] = [];
override update(): void {
// noop
}
@ -31,7 +34,20 @@ export class LoadState extends State<States> {
bootstrapInputs();
this.stateMachine.transitionTo(States.RUNNING);
resources.set("engine-sprites", new Image());
resources.get<HTMLImageElement>("engine-sprites")!.src =
"/sprites/EngineSprites.png";
resources.ready().then(() => {
this.stateMachine.transitionTo(States.RUNNING);
});
const doodler = getContextItem<Doodler>("doodler");
this.layers.push(doodler.createLayer((_, __, dTime) => {
doodler.clearRect(new Vector(0, 0), doodler.width, doodler.height);
doodler.fillRect(new Vector(0, 0), doodler.width, doodler.height, {
color: "#302040",
});
}));
}
override stop(): void {
// noop

View File

@ -1,5 +1,10 @@
import { getContext } from "../../lib/context.ts";
import { Doodler } from "@bearmetal/doodler";
import { getContext, getContextItem } from "../../lib/context.ts";
import { InputManager } from "../../lib/input.ts";
import { TrackSystem } from "../../track/system.ts";
import { Tender } from "../../train/cars.ts";
import { RedEngine } from "../../train/engines.ts";
import { DotFollower } from "../../train/newTrain.ts";
import { Train } from "../../train/train.ts";
import { State } from "../machine.ts";
import { States } from "./index.ts";
@ -10,23 +15,74 @@ export class RunningState extends State<States> {
States.PAUSED,
States.EDIT_TRACK,
]);
layers: number[] = [];
override update(dt: number): void {
const ctx = getContext() as { trains: Train[]; track: TrackSystem };
// const ctx = getContext() as { trains: DotFollower[]; 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
for (const train of ctx.trains) {
train.move(dt);
}
}
override start(): void {
const doodler = getContextItem<Doodler>("doodler");
this.layers.push(
doodler.createLayer(() => {
const track = getContextItem<TrackSystem>("track");
track.draw();
}),
doodler.createLayer(() => {
const trains = getContextItem<Train[]>("trains");
for (const train of trains) {
// if (input.getKeyState("ArrowUp")) {
// train.acceleration.x += 10;
// }
train.draw();
}
}),
);
// noop
const inputManager = getContextItem<InputManager>("inputManager");
const track = getContextItem<TrackSystem>("track");
const ctx = getContext() as { trains: Train[] };
// const ctx = getContext() as { trains: DotFollower[] };
inputManager.onKey(" ", () => {
// const path = track.path;
// const follower = new DotFollower(path, path.points[0].copy());
// ctx.trains.push(follower);
const train = new Train(track.path, [new RedEngine(), new Tender()]);
ctx.trains.push(train);
});
// const trainCount = 2000;
// for (let i = 0; i < trainCount; i++) {
// const train = new Train(track.path, [new RedEngine(), new Tender()]);
// ctx.trains.push(train);
// }
inputManager.onKey("ArrowUp", () => {
const trains = getContextItem<Train[]>("trains");
for (const train of trains) {
train.speed += 1;
}
});
inputManager.onKey("ArrowDown", () => {
const trains = getContextItem<Train[]>("trains");
for (const train of trains) {
train.speed -= 1;
}
});
}
override stop(): void {
// noop
for (const layer of this.layers) {
getContextItem<Doodler>("doodler").deleteLayer(layer);
}
}
}

View File

@ -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,
);
});

View File

@ -12,3 +12,79 @@ export class StraightTrack extends TrackSegment {
]);
}
}
export class SBendLeft extends TrackSegment {
constructor(start?: Vector) {
start = start || new Vector(100, 100);
super([
start,
start.copy().add(60, 0),
start.copy().add(90, -25),
start.copy().add(150, -25),
]);
}
}
export class SBendRight extends TrackSegment {
constructor(start?: Vector) {
start = start || new Vector(100, 100);
super([
start,
start.copy().add(60, 0),
start.copy().add(90, 25),
start.copy().add(150, 25),
]);
}
}
export class BankLeft extends TrackSegment {
constructor(start?: Vector) {
start = start || new Vector(100, 100);
const p1 = start.copy();
const p2 = start.copy();
const p3 = start.copy();
const p4 = start.copy();
const scale = 33;
p2.add(new Vector(1, 0).mult(scale));
p3.set(p2);
const dirToP3 = Vector.fromAngle(-Math.PI / 12).mult(scale);
p3.add(dirToP3);
p4.set(p3);
const dirToP4 = Vector.fromAngle(-Math.PI / 6).mult(scale);
p4.add(dirToP4);
super([
p1,
p2,
p3,
p4,
]);
}
}
export class BankRight extends TrackSegment {
constructor(start?: Vector) {
start = start || new Vector(100, 100);
const p1 = start.copy();
const p2 = start.copy();
const p3 = start.copy();
const p4 = start.copy();
const scale = 33;
p2.add(new Vector(1, 0).mult(scale));
p3.set(p2);
const dirToP3 = Vector.fromAngle(Math.PI / 12).mult(scale);
p3.add(dirToP3);
p4.set(p3);
const dirToP4 = Vector.fromAngle(Math.PI / 6).mult(scale);
p4.add(dirToP4);
super([
p1,
p2,
p3,
p4,
]);
}
}

View File

@ -1,6 +1,7 @@
import { Doodler, Vector } from "@bearmetal/doodler";
import { PathSegment } from "../math/path.ts";
import { Doodler, Point, Vector } from "@bearmetal/doodler";
import { ComplexPath, PathSegment } from "../math/path.ts";
import { getContextItem, setDefaultContext } from "../lib/context.ts";
import { clamp } from "../math/clamp.ts";
export class TrackSystem {
private segments: Map<string, TrackSegment> = new Map();
@ -21,61 +22,85 @@ export class TrackSystem {
return this.segments.values().toArray().pop();
}
optimize(percent: number) {
console.log("Optimizing track", percent * 100 / 4);
for (const segment of this.segments.values()) {
segment.recalculateRailPoints(Math.round(percent * 100 / 4));
}
}
registerSegment(segment: TrackSegment) {
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()) {
for (const [i, segment] of this.segments.entries()) {
segment.draw(showControls);
}
try {
if (getContextItem<boolean>("showEnds")) {
const ends = this.findEnds();
for (const end of ends) {
this.doodler.fillCircle(end.pos, 2, {
color: "red",
// weight: 3,
});
if (getContextItem<boolean>("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<boolean>("showEnds")) {
// const ends = this.findEnds();
// for (const end of ends) {
// this.doodler.fillCircle(end.pos, 2, {
// color: "red",
// // weight: 3,
// });
// if (getContextItem<boolean>("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<TrackSegment, [End, End]> = 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() {
@ -86,13 +111,16 @@ export class TrackSystem {
return track;
}
static deserialize(data: any) {
static deserialize(data: SerializedTrackSegment[]) {
if (data.length === 0) return undefined;
const track = new TrackSystem([]);
const neighborMap = new Map<string, [string[], string[]]>();
for (const segment of data) {
track.segments.set(segment.id, TrackSegment.deserialize(segment));
neighborMap.set(segment.id, [segment.fNeighbors, segment.bNeighbors]);
}
for (const segment of track.segments.values()) {
segment.setTrack(track);
const neighbors = neighborMap.get(segment.id);
@ -105,8 +133,126 @@ export class TrackSystem {
).filter((s) => s) as TrackSegment[];
}
}
return track;
}
translate(v: Vector) {
for (const segment of this.segments.values()) {
segment.translate(v);
}
}
private _path?: Spline<TrackSegment>;
get path() {
if (!this._path) {
this._path = this.generatePath();
}
return this._path;
}
generatePath() {
if (!this.firstSegment) throw new Error("No first segment");
const flags = { looping: true };
const rightOnlyPath = [
this.firstSegment.copy(),
...this.findRightPath(
this.firstSegment,
new Set([this.firstSegment.id]),
flags,
),
];
rightOnlyPath.forEach((s, i, arr) => {
if (i === 0) return;
const prev = arr[i - 1];
s.points[0] = prev.points[3];
s.prev = prev;
prev.next = s;
});
if (flags.looping) {
const first = rightOnlyPath[0];
const last = rightOnlyPath[rightOnlyPath.length - 1];
first.points[0] = last.points[3];
last.points[3] = first.points[0];
first.prev = last;
last.next = first;
}
return new Spline<TrackSegment>(rightOnlyPath);
}
*findRightPath(
start: TrackSegment,
seen: Set<string>,
flags: { looping: boolean },
): Generator<TrackSegment> {
if (start.frontNeighbours.length === 0) {
return;
}
let rightMost = start.frontNeighbours[0];
for (const segment of start.frontNeighbours) {
if (segment.id === rightMost.id) continue;
const rotatedSegment = segment.copy();
rotatedSegment.rotateAboutPoint(
rotatedSegment.tangent(0).heading(),
rotatedSegment.points[0],
);
const rotatedRightMost = rightMost.copy();
rotatedRightMost.rotateAboutPoint(
rotatedRightMost.tangent(0).heading(),
rotatedRightMost.points[0],
);
if (rotatedSegment.points[3].y > rotatedRightMost.points[3].y) {
rightMost = segment;
}
}
if (seen.has(rightMost.id)) {
if (seen.values().next().value === rightMost.id) {
flags.looping = true;
}
return;
}
seen.add(rightMost.id);
yield rightMost.copy();
yield* this.findRightPath(rightMost, seen, flags);
}
*findLeftPath(
start: TrackSegment,
seen: Set<string>,
flags: { looping: boolean },
): Generator<TrackSegment> {
if (start.frontNeighbours.length === 0) {
return;
}
let leftMost = start.frontNeighbours[0];
for (const segment of start.frontNeighbours) {
if (segment.id === leftMost.id) continue;
const rotatedSegment = segment.copy();
rotatedSegment.rotateAboutPoint(
rotatedSegment.tangent(0).heading(),
rotatedSegment.points[0],
);
const rotatedLeftMost = leftMost.copy();
rotatedLeftMost.rotateAboutPoint(
rotatedLeftMost.tangent(0).heading(),
rotatedLeftMost.points[0],
);
if (rotatedSegment.points[3].y < rotatedLeftMost.points[3].y) {
leftMost = segment;
}
}
if (seen.has(leftMost.id)) {
if (seen.values().next().value === leftMost.id) {
flags.looping = true;
}
return;
}
seen.add(leftMost.id);
yield leftMost.copy();
yield* this.findLeftPath(leftMost, seen, flags);
}
}
type VectorSet = [Vector, Vector, Vector, Vector];
@ -118,50 +264,109 @@ export class TrackSegment extends PathSegment {
track?: TrackSystem;
doodler: Doodler;
id: string;
normalPoints: Vector[] = [];
antiNormalPoints: Vector[] = [];
constructor(p: VectorSet, id?: string) {
super(p);
this.doodler = getContextItem<Doodler>("doodler");
this.id = id ?? crypto.randomUUID();
this.recalculateRailPoints();
}
recalculateRailPoints(resolution = 100) {
this.normalPoints = [];
this.antiNormalPoints = [];
for (let i = 0; i <= resolution; i++) {
const t = i / resolution;
const normal = this.tangent(t).rotate(Math.PI / 2);
normal.setMag(6);
const p = this.getPointAtT(t);
this.normalPoints.push(p.copy().add(normal));
this.antiNormalPoints.push(p.copy().add(normal.rotate(Math.PI)));
}
}
setTrack(t: TrackSystem) {
this.track = t;
}
draw(showControls = false) {
this.doodler.drawBezier(
this.points[0],
this.points[1],
this.points[2],
this.points[3],
{
strokeColor: "#ffffff50",
override draw(showControls = false) {
// if (showControls) {
// this.doodler.drawBezier(
// this.points[0],
// this.points[1],
// this.points[2],
// this.points[3],
// {
// strokeColor: "#ffffff50",
// },
// );
// }
if (showControls) {
this.doodler.deferDrawing(() => {
this.doodler.fillCircle(this.points[0], 1, {
color: "red",
});
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",
});
});
}
const ties = Math.ceil(this.length / 10);
for (let i = 0; i < ties; i++) {
const t = i / ties;
const p = this.getPointAtT(t);
// this.doodler.drawCircle(p, 2, {
// color: "red",
// weight: 3,
// });
this.doodler.drawRotated(p, this.tangent(t).heading(), () => {
this.doodler.line(p, p.copy().add(0, 10), {
color: "#291b17",
weight: 4,
});
this.doodler.line(p, p.copy().add(0, -10), {
color: "#291b17",
weight: 4,
});
// this.doodler.line(p.copy().add(-6, 5), p.copy().add(6, 5), {
// color: "grey",
// weight: 1,
// });
// this.doodler.line(p.copy().add(-6, -5), p.copy().add(6, -5), {
// color: "grey",
// weight: 1,
// });
});
}
this.doodler.deferDrawing(
() => {
this.doodler.drawLine(this.normalPoints, {
color: "grey",
weight: 1.5,
});
this.doodler.drawLine(this.antiNormalPoints, {
color: "grey",
weight: 1.5,
});
},
);
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,
// });
}
// this.doodler.drawCircle(p, 2, {
// color: "red",
// weight: 3,
// });
}
serialize() {
serialize(): SerializedTrackSegment {
return {
p: this.points.map((p) => p.array()),
id: this.id,
@ -177,30 +382,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 +432,188 @@ 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);
});
}
}
export class Spline<T extends PathSegment = PathSegment> {
segments: T[] = [];
ctx?: CanvasRenderingContext2D;
evenPoints: Vector[];
pointSpacing: number;
get points() {
return Array.from(new Set(this.segments.flatMap((s) => s.points)));
}
nodes: IControlNode[];
looped = false;
constructor(segs: T[]) {
this.segments = segs;
if (this.segments.at(-1)?.next === this.segments[0]) {
this.looped = true;
}
this.pointSpacing = 1;
this.evenPoints = this.calculateEvenlySpacedPoints(1);
this.nodes = [];
// for (let i = 0; i < this.points.length; i += 3) {
// const node: IControlNode = {
// anchor: this.points[i],
// controls: [
// this.points.at(i - 1)!,
// this.points[(i + 1) % this.points.length],
// ],
// mirrored: false,
// tangent: true,
// };
// this.nodes.push(node);
// }
}
// setContext(ctx: CanvasRenderingContext2D) {
// this.ctx = ctx;
// for (const segment of this.segments) {
// segment.setContext(ctx);
// }
// }
draw() {
for (const segment of this.segments) {
// segment.draw();
const doodler = getContextItem<Doodler>("doodler");
doodler.drawWithAlpha(0.5, () => {
doodler.drawBezier(...segment.points, { color: "red" });
});
}
}
calculateEvenlySpacedPoints(spacing: number, resolution = 1) {
this.pointSpacing = 1;
// return this.segments.flatMap(s => s.calculateEvenlySpacedPoints(spacing, resolution));
const points: Vector[] = [];
points.push(this.segments[0].points[0]);
let prev = points[0];
let distSinceLastEvenPoint = 0;
for (const seg of this.segments) {
let t = 0;
const div = Math.ceil(seg.length * resolution * 10);
while (t < 1) {
t += 1 / div;
const point = seg.getPointAtT(t);
distSinceLastEvenPoint += prev.dist(point);
if (distSinceLastEvenPoint >= spacing) {
const overshoot = distSinceLastEvenPoint - spacing;
const evenPoint = Vector.add(
point,
Vector.sub(point, prev).normalize().mult(overshoot),
);
distSinceLastEvenPoint = overshoot;
points.push(evenPoint);
prev = evenPoint;
}
prev = point;
}
}
this.evenPoints = points;
return points;
}
followEvenPoints(t: number) {
if (this.looped) {
if (t < 0) t += this.evenPoints.length;
const i = Math.floor(t) % this.evenPoints.length;
const a = this.evenPoints[i];
const b = this.evenPoints[(i + 1) % this.evenPoints.length];
return Vector.lerp(a, b, t % 1);
}
t = clamp(t, 0, this.evenPoints.length - 1);
const i = clamp(Math.floor(t), 0, this.evenPoints.length - 1);
const a = this.evenPoints[clamp(i, 0, this.evenPoints.length - 1)];
const b = this
.evenPoints[
clamp((i + 1) % this.evenPoints.length, 0, this.evenPoints.length - 1)
];
return Vector.lerp(a, b, t % 1);
}
calculateApproxLength() {
for (const s of this.segments) {
s.calculateApproxLength();
}
}
toggleNodeTangent(p: Vector) {
const node = this.nodes.find((n) => n.anchor === p);
node && (node.tangent = !node.tangent);
}
toggleNodeMirrored(p: Vector) {
const node = this.nodes.find((n) => n.anchor === p);
node && (node.mirrored = !node.mirrored);
}
handleNodeEdit(p: Vector, movement: { x: number; y: number }) {
const node = this.nodes.find((n) =>
n.anchor === p || n.controls.includes(p)
);
if (!node || !(node.mirrored || node.tangent)) return;
if (node.anchor !== p) {
if (node.mirrored || node.tangent) {
const mover = node.controls.find((e) => e !== p)!;
const v = Vector.sub(node.anchor, p);
if (!node.mirrored) v.setMag(Vector.sub(node.anchor, mover).mag());
mover.set(Vector.add(v, node.anchor));
}
} else {
for (const control of node.controls) {
control.add(movement.x, movement.y);
}
}
}
}
export interface IControlNode {
anchor: Vector;
controls: [Vector, Vector];
tangent: boolean;
mirrored: boolean;
}

55
train/cars.ts Normal file
View File

@ -0,0 +1,55 @@
import { Vector } from "https://jsr.io/@bearmetal/doodler/0.0.4/geometry/vector.ts";
import { TrainCar } from "./train.ts";
import { ResourceManager } from "../lib/resources.ts";
import { getContextItem } from "../lib/context.ts";
export class Tender extends TrainCar {
constructor() {
const resources = getContextItem<ResourceManager>("resources");
super(25, resources.get<HTMLImageElement>("engine-sprites")!, 40, 20, {
at: new Vector(80, 0),
width: 40,
height: 20,
});
}
}
export class Tank extends TrainCar {
constructor() {
const resources = getContextItem<ResourceManager>("resources");
super(50, resources.get<HTMLImageElement>("engine-sprites")!, 70, 20, {
at: new Vector(80, 20),
width: 70,
height: 20,
});
}
}
export class YellowDumpCar extends TrainCar {
constructor() {
const resources = getContextItem<ResourceManager>("resources");
super(50, resources.get<HTMLImageElement>("engine-sprites")!, 70, 20, {
at: new Vector(80, 40),
width: 70,
height: 20,
});
}
}
export class GrayDumpCar extends TrainCar {
constructor() {
const resources = getContextItem<ResourceManager>("resources");
super(50, resources.get<HTMLImageElement>("engine-sprites")!, 70, 20, {
at: new Vector(80, 60),
width: 70,
height: 20,
});
}
}
export class NullCar extends TrainCar {
constructor() {
const resources = getContextItem<ResourceManager>("resources");
super(50, resources.get<HTMLImageElement>("engine-sprites")!, 70, 20, {
at: new Vector(80, 80),
width: 70,
height: 20,
});
}
}

55
train/engines.ts Normal file
View File

@ -0,0 +1,55 @@
import { Vector } from "@bearmetal/doodler";
import { TrainCar } from "./train.ts";
import { getContextItem } from "../lib/context.ts";
import { ResourceManager } from "../lib/resources.ts";
export class RedEngine extends TrainCar {
constructor() {
const resources = getContextItem<ResourceManager>("resources");
super(55, resources.get<HTMLImageElement>("engine-sprites")!, 80, 20, {
at: new Vector(0, 60),
width: 80,
height: 20,
});
}
}
export class PurpleEngine extends TrainCar {
constructor() {
const resources = getContextItem<ResourceManager>("resources");
super(55, resources.get<HTMLImageElement>("engine-sprites")!, 80, 20, {
at: new Vector(0, 60),
width: 80,
height: 20,
});
}
}
export class GreenEngine extends TrainCar {
constructor() {
const resources = getContextItem<ResourceManager>("resources");
super(55, resources.get<HTMLImageElement>("engine-sprites")!, 80, 20, {
at: new Vector(0, 40),
width: 80,
height: 20,
});
}
}
export class GrayEngine extends TrainCar {
constructor() {
const resources = getContextItem<ResourceManager>("resources");
super(55, resources.get<HTMLImageElement>("engine-sprites")!, 80, 20, {
at: new Vector(0, 20),
width: 80,
height: 20,
});
}
}
export class BlueEngine extends TrainCar {
constructor() {
const resources = getContextItem<ResourceManager>("resources");
super(55, resources.get<HTMLImageElement>("engine-sprites")!, 80, 20, {
at: new Vector(0, 0),
width: 80,
height: 20,
});
}
}

147
train/newTrain.ts Normal file
View File

@ -0,0 +1,147 @@
import { Doodler, Vector } from "@bearmetal/doodler";
import { getContextItem } from "../lib/context.ts";
import { Spline, TrackSegment } from "../track/system.ts";
export class DotFollower {
position: Vector;
velocity: Vector;
acceleration: Vector;
maxSpeed: number;
maxForce: number;
_trailingPoint: number;
protected _leadingPoint: number;
path: Spline<TrackSegment>;
get trailingPoint() {
const desired = this.velocity.copy();
desired.normalize();
desired.mult(-this._trailingPoint);
return Vector.add(this.position, desired);
}
constructor(path: Spline<TrackSegment>, pos: Vector) {
this.path = path;
this.position = pos;
this.velocity = new Vector();
this.acceleration = new Vector();
this.maxSpeed = 3;
this.maxForce = 0.3;
this._trailingPoint = 0;
this._leadingPoint = 0;
this.init();
}
init() {
}
move(dt: number) {
dt *= 10;
const force = calculatePathForce(this, this.path.points);
this.applyForce(force.mult(dt));
this.velocity.limit(this.maxSpeed);
this.acceleration.limit(this.maxForce);
this.velocity.add(this.acceleration.copy().mult(dt));
this.position.add(this.velocity.copy().mult(dt));
this.edges();
}
edges() {
const doodler = getContextItem<Doodler>("doodler");
if (this.position.x > doodler.width) this.position.x = 0;
if (this.position.y > doodler.height) this.position.y = 0;
if (this.position.x < 0) this.position.x = doodler.width;
if (this.position.y < 0) this.position.y = doodler.height;
}
draw() {
const doodler = getContextItem<Doodler>("doodler");
doodler.drawRotated(this.position, this.velocity.heading() || 0, () => {
doodler.fillCenteredRect(this.position, 20, 20, { fillColor: "white" });
});
for (const point of this.path.points) {
doodler.drawCircle(point, 4, { color: "red", weight: 3 });
}
}
applyForce(force: Vector) {
this.velocity.add(force);
}
static edges(point: Vector, width: number, height: number) {
if (point.x > width) point.x = 0;
if (point.y > height) point.y = 0;
if (point.x < 0) point.x = width;
if (point.y < 0) point.y = height;
}
}
function closestPointOnLineSegment(p: Vector, a: Vector, b: Vector): Vector {
// Vector AB
// const AB = { x: b.x - a.x, y: b.y - a.y };
const AB = Vector.sub(b, a);
// Vector AP
// const AP = { x: p.x - a.x, y: p.y - a.y };
const AP = Vector.sub(p, a);
// Dot product of AP and AB
// const AB_AB = AB.x * AB.x + AB.y * AB.y;
const AB_AB = Vector.dot(AB, AB);
// const AP_AB = AP.x * AB.x + AP.y * AB.y;
const AP_AB = Vector.dot(AP, AB);
// Project AP onto AB
const t = AP_AB / AB_AB;
// Clamp t to the range [0, 1] to restrict to the segment
const tClamped = Math.max(0, Math.min(1, t));
// Closest point on the segment
return new Vector({ x: a.x + AB.x * tClamped, y: a.y + AB.y * tClamped });
}
function calculatePathForce(f: DotFollower, path: Vector[]) {
let closestPoint: Vector = path[0];
let minDistance = Infinity;
// Loop through each segment to find the closest point on the path
for (let i = 0; i < path.length - 1; i++) {
const segmentStart = path[i];
const segmentEnd = path[i + 1];
// Find the closest point on the segment
const closest = closestPointOnLineSegment(
f.position,
segmentStart,
segmentEnd,
);
// Calculate the distance from the follower to the closest point
// const distance = Math.sqrt(
// Math.pow(follower.position.x - closest.x, 2) +
// Math.pow(follower.position.y - closest.y, 2),
// );
const distance = Vector.dist(f.position, closest);
// Track the closest point
if (distance < minDistance) {
minDistance = distance;
closestPoint = closest;
}
}
// Calculate the force to apply toward the closest point
// const force = {
// x: closestPoint.x - f.position.x,
// y: closestPoint.y - f.position.y,
// };
const force = Vector.sub(closestPoint, f.position);
// Normalize the force and apply a magnitude (this will depend on your desired strength)
const magnitude = 100; // Adjust this based on your needs
force.setMag(magnitude);
return force;
}

View File

@ -1,62 +1,71 @@
import { ComplexPath, PathSegment } from "../math/path.ts";
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";
import { Spline, TrackSegment } from "../track/system.ts";
import { ResourceManager } from "../lib/resources.ts";
export class Train {
nodes: Vector[] = [];
cars: TrainCar[] = [];
path: Spline<Track>;
path: Spline<TrackSegment>;
t: number;
engineLength = 40;
spacing = 30;
speed = 0;
speed = 10;
constructor(track: Spline<Track>, cars: TrainCar[] = []) {
constructor(track: Spline<TrackSegment>, cars: TrainCar[]) {
this.path = track;
this.t = 0;
this.nodes.push(this.path.followEvenPoints(this.t));
this.nodes.push(this.path.followEvenPoints(this.t - this.real2Track(40)));
const engineSprites = document.getElementById(
"engine-sprites",
)! as HTMLImageElement;
this.cars.push(
new TrainCar(
55,
engineSprites,
80,
20,
{ at: new Vector(0, 60), width: 80, height: 20 },
),
new TrainCar(
25,
engineSprites,
40,
20,
{ at: new Vector(80, 0), width: 40, height: 20 },
),
);
this.cars[0].points = this.nodes.map((n) => n) as [Vector, Vector];
this.cars[1].points = this.nodes.map((n) => n) as [Vector, Vector];
let currentOffset = 40;
for (const car of cars) {
currentOffset += this.spacing;
const a = this.path.followEvenPoints(this.t - currentOffset);
currentOffset += car.length;
const b = this.path.followEvenPoints(this.t - currentOffset);
car.points = [a, b];
this.cars.push(car);
const resources = getContextItem<ResourceManager>("resources");
this.cars = cars;
// this.cars.push(
// new TrainCar(
// 55,
// engineSprites,
// 80,
// 20,
// { at: new Vector(0, 60), width: 80, height: 20 },
// ),
// new TrainCar(
// 25,
// engineSprites,
// 40,
// 20,
// { at: new Vector(80, 0), width: 40, height: 20 },
// ),
// );
let currentOffset = 0;
try {
for (const car of this.cars) {
currentOffset += this.spacing;
const a = this.path.followEvenPoints(this.t - currentOffset);
currentOffset += car.length;
const b = this.path.followEvenPoints(this.t - currentOffset);
car.points = [a, b];
this.nodes.push(a, b);
}
} catch {
currentOffset = 0;
for (const car of this.cars.toReversed()) {
currentOffset += this.spacing;
const a = this.path.followEvenPoints(this.t - currentOffset);
currentOffset += car.length;
const b = this.path.followEvenPoints(this.t - currentOffset);
car.points = [a, b];
this.nodes.push(a, b);
}
}
}
move(dTime: number) {
this.t = (this.t + this.speed * dTime * 10) % this.path.evenPoints.length;
this.t = this.t + this.speed * dTime * 10;
// % this.path.evenPoints.length; // This should probably be on the track system
// console.log(this.t);
let currentOffset = 0;
for (const car of this.cars) {
@ -66,14 +75,17 @@ export class Train {
currentOffset += car.length;
b.set(this.path.followEvenPoints(this.t - currentOffset));
currentOffset += this.spacing;
car.draw();
// car.draw();
}
// this.draw();
}
// draw() {
// const doodler = getContextItem<Doodler>("doodler");
// this.path.draw();
// for (const [i, node] of this.nodes.entries()) {
// doodler.drawCircle(node.point, 10, { color: 'purple', weight: 3 })
// // doodler.drawCircle(node, 10, { color: "purple", weight: 3 });
// doodler.fillCircle(node, 2, { color: "purple" });
// // const next = this.nodes[i + 1];
// // if (next) {
// // const to = Vector.sub(node.point, next.point);

18
types.ts Normal file
View File

@ -0,0 +1,18 @@
import { Vector } from "@bearmetal/doodler";
import { TrackSegment } from "./track/system.ts";
declare global {
type End = {
pos: Vector;
segment: TrackSegment;
tangent: Vector;
frontOrBack: "front" | "back";
};
type SerializedTrackSegment = {
p: [number, number, number][];
id: string;
bNeighbors: string[];
fNeighbors: string[];
};
}