pick and place editing working, saving and loading working

This commit is contained in:
Emmaline Autumn 2025-02-09 02:54:17 -07:00
parent 8dc0af650f
commit 3d4596f8fb
13 changed files with 879 additions and 309 deletions

473
bundle.js
View File

@ -24,6 +24,13 @@
function getContextItem(prop) {
return ctx[prop];
}
function getContextItemOrDefault(prop, defaultValue) {
try {
return ctx[prop];
} catch {
return defaultValue;
}
}
function setContextItem(prop, value) {
Object.assign(contextStack[contextStack.length - 1] ?? defaultContext, {
[prop]: value
@ -71,12 +78,12 @@
}, 2);
}
// https://jsr.io/@bearmetal/doodler/0.0.3/geometry/constants.ts
// https://jsr.io/@bearmetal/doodler/0.0.4/geometry/constants.ts
var Constants = {
TWO_PI: Math.PI * 2
};
// https://jsr.io/@bearmetal/doodler/0.0.3/geometry/vector.ts
// https://jsr.io/@bearmetal/doodler/0.0.4/geometry/vector.ts
var Vector = class _Vector {
x;
y;
@ -361,7 +368,7 @@
}
};
// https://jsr.io/@bearmetal/doodler/0.0.3/FPSCounter.ts
// https://jsr.io/@bearmetal/doodler/0.0.4/FPSCounter.ts
var FPSCounter = class {
frameTimes = [];
maxSamples;
@ -389,7 +396,7 @@
}
};
// https://jsr.io/@bearmetal/doodler/0.0.3/canvas.ts
// https://jsr.io/@bearmetal/doodler/0.0.4/canvas.ts
var Doodler = class {
ctx;
_canvas;
@ -743,13 +750,13 @@
}
};
// https://jsr.io/@bearmetal/doodler/0.0.3/timing/EaseInOut.ts
// https://jsr.io/@bearmetal/doodler/0.0.4/timing/EaseInOut.ts
var easeInOut = (x) => x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;
// https://jsr.io/@bearmetal/doodler/0.0.3/timing/Map.ts
// https://jsr.io/@bearmetal/doodler/0.0.4/timing/Map.ts
var map = (value, x1, y1, x2, y2) => (value - x1) * (y2 - x2) / (y1 - x1) + x2;
// https://jsr.io/@bearmetal/doodler/0.0.3/zoomableCanvas.ts
// https://jsr.io/@bearmetal/doodler/0.0.4/zoomableCanvas.ts
var ZoomableDoodler = class extends Doodler {
scale = 1;
dragging = false;
@ -1014,6 +1021,16 @@
};
// lib/input.ts
function mouseButtonToString(button) {
switch (button) {
case 0:
return "left";
case 1:
return "middle";
case 2:
return "right";
}
}
var InputManager = class {
keyStates = /* @__PURE__ */ new Map();
mouseStates = /* @__PURE__ */ new Map();
@ -1030,11 +1047,15 @@
this.keyStates.set(e.key, false);
});
document.addEventListener("mousedown", (e) => {
this.mouseStates.set(e.button, true);
this.mouseEvents.get(e.button)?.call(e);
const button = mouseButtonToString(e.button);
if (!button) throw "Mouse button not found: " + e.button;
this.mouseStates.set(button, true);
this.mouseEvents.get(button)?.call(e);
});
document.addEventListener("mouseup", (e) => {
this.mouseStates.set(e.button, false);
const button = mouseButtonToString(e.button);
if (!button) throw "Mouse button not found: " + e.button;
this.mouseStates.set(button, false);
});
self.addEventListener("mousemove", (e) => {
this.mouseLocation = { x: e.clientX, y: e.clientY };
@ -1087,6 +1108,11 @@
offMouse(key) {
this.mouseEvents.delete(key);
}
onNumberKey(arg0) {
for (let i = 0; i < 10; i++) {
this.onKey(i.toString(), () => arg0(i));
}
}
};
// lib/resources.ts
@ -1125,27 +1151,6 @@
}
};
// ui/button.ts
function addButton(props) {
const doodler2 = getContextItem("doodler");
const { text, onClick, style } = props;
const { x, y } = props.at[1].copy().sub(props.at[0]);
const id = doodler2.addUIElement(
"rectangle",
props.at[0],
x,
y,
style
);
doodler2.registerClickable(props.at[0], props.at[1], onClick);
return {
id,
text,
onClick,
style
};
}
// state/machine.ts
var StateMachine = class {
_states = /* @__PURE__ */ new Map();
@ -1399,53 +1404,46 @@
segment.setTrack(this);
this.segments.set(segment.id, segment);
}
unregisterSegment(segment) {
this.segments.delete(segment.id);
for (const s of this.segments.values()) {
s.backNeighbours = s.backNeighbours.filter((n) => n !== segment);
s.frontNeighbours = s.frontNeighbours.filter((n) => n !== segment);
}
}
draw(showControls = false) {
for (const segment of this.segments.values()) {
segment.draw(showControls);
}
try {
if (getContextItem("showEnds")) {
const ends = this.findEnds();
for (const end of ends) {
this.doodler.fillCircle(end.pos, 2, {
color: "red"
// weight: 3,
});
if (getContextItem("debug")) {
this.doodler.line(
end.pos,
end.pos.copy().add(end.tangent.copy().mult(20)),
{
color: "blue"
// weight: 3,
}
);
}
}
}
} catch {
setDefaultContext({ showEnds: false });
}
}
ends = /* @__PURE__ */ new Map();
endArray = [];
findEnds() {
const ends = [];
for (const segment of this.segments.values()) {
const [a, b, c, d] = segment.points;
{
const tangent = Vector.sub(a, b).normalize();
const pos = a.copy();
ends.push({ pos, segment, tangent });
}
{
const tangent = Vector.sub(d, c).normalize();
const pos = d.copy();
ends.push({ pos, segment, tangent });
}
if (this.ends.has(segment)) continue;
const ends = [
{
pos: segment.points[0],
segment,
tangent: Vector.sub(segment.points[1], segment.points[0]).normalize(),
frontOrBack: "back"
},
{
pos: segment.points[3],
segment,
tangent: Vector.sub(segment.points[3], segment.points[2]).normalize(),
frontOrBack: "front"
}
];
this.ends.set(segment, ends);
this.endArray.push(...ends);
}
return ends;
return this.endArray;
}
serialize() {
return this.segments.values().map((s) => s.serialize()).toArray();
return JSON.stringify(
this.segments.values().map((s) => s.serialize()).toArray()
);
}
copy() {
const track = new _TrackSystem([]);
@ -1475,6 +1473,11 @@
}
return track;
}
translate(v) {
for (const segment of this.segments.values()) {
segment.translate(v);
}
}
};
var TrackSegment = class _TrackSegment extends PathSegment {
frontNeighbours = [];
@ -1501,13 +1504,17 @@
}
);
if (showControls) {
this.doodler.drawCircle(this.points[1], 4, {
color: "red",
weight: 3
this.doodler.fillCircle(this.points[0], 1, {
color: "red"
});
this.doodler.drawCircle(this.points[2], 4, {
color: "red",
weight: 3
this.doodler.fillCircle(this.points[1], 1, {
color: "red"
});
this.doodler.fillCircle(this.points[2], 1, {
color: "red"
});
this.doodler.fillCircle(this.points[3], 1, {
color: "red"
});
}
}
@ -1525,18 +1532,23 @@
this.id
);
}
propagate() {
const [_, __, p3, p4] = this.points;
const tangent = Vector.sub(p4, p3);
cleanCopy() {
return new _TrackSegment(
this.points.map((p) => p.copy())
);
}
propagateTranslation(v) {
for (const fNeighbour of this.frontNeighbours) {
fNeighbour.receivePropagation(tangent);
fNeighbour.receivePropagation(v);
}
for (const bNeighbour of this.backNeighbours) {
bNeighbour.receivePropagation(v);
}
}
lastHeading;
receivePropagation(tangent) {
const [p1, p2, p3, p4] = this.points;
this.rotate(tangent);
this.propagate();
receivePropagation(v) {
this.translate(v);
this.propagateTranslation(v);
}
// TODO: this duplicates receivePropagation, but for some reason it doesn't work when called from receivePropagation
rotate(angle) {
@ -1566,6 +1578,33 @@
data.id
);
}
setPositionByPoint(pos, point) {
if (!this.points.includes(point)) return;
point = point.copy();
this.points.forEach((p, i) => {
const relativePoint = Vector.sub(p, point);
p.set(pos);
p.add(relativePoint);
});
}
rotateAboutPoint(angle, point) {
if (!this.points.includes(point)) return;
point = point.copy();
this.points.forEach((p, i) => {
const relativePoint = Vector.sub(p, point);
relativePoint.rotate(angle);
p.set(Vector.add(point, relativePoint));
});
}
// resetRotation() {
// const angle = this.tangent(0).heading();
// this.rotateAboutPoint(-angle, this.points[0]);
// }
translate(v) {
this.points.forEach((p) => {
p.add(v.x, v.y);
});
}
};
// track/shapes.ts
@ -1580,6 +1619,38 @@
]);
}
};
var SBendLeft = class extends StraightTrack {
constructor(start) {
start = start || new Vector(100, 100);
super(start);
this.points[2].add(0, -25);
this.points[3].add(0, -25);
}
};
var SBendRight = class extends StraightTrack {
constructor(start) {
start = start || new Vector(100, 100);
super(start);
this.points[2].add(0, 25);
this.points[3].add(0, 25);
}
};
var BankLeft = class extends StraightTrack {
constructor(start) {
start = start || new Vector(100, 100);
super(start);
this.points[2].add(0, -25);
this.points[3].add(0, 25);
}
};
var BankRight = class extends StraightTrack {
constructor(start) {
start = start || new Vector(100, 100);
super(start);
this.points[2].add(0, 25);
this.points[3].add(0, -25);
}
};
// state/states/EditTrackState.ts
var EditTrackState = class extends State {
@ -1590,55 +1661,132 @@
]);
heldEvents = /* @__PURE__ */ new Map();
currentSegment;
selectedSegment;
ghostSegment;
ghostRotated = false;
closestEnd;
update(dt) {
const inputManager2 = getContextItem("inputManager");
const track = getContextItem("track");
const doodler2 = getContextItem("doodler");
const segment = this.currentSegment;
if (segment) {
segment.propagate();
if (this.selectedSegment) {
const segment = this.selectedSegment;
const firstPoint = segment.points[0].copy();
const mousePos = inputManager2.getMouseLocationV();
const p1 = segment.points[0];
const p2 = segment.points[1];
const p3 = segment.points[2];
const p4 = segment.points[3];
const prevp3 = p3.copy();
const dirToMouse = Vector.sub(mousePos, p2).normalize();
const angleToMouse = dirToMouse.heading();
const angleToP1 = Vector.sub(p2, p1).heading();
const p2DistToMouse = Vector.dist(p2, mousePos);
const p3DistToMouse = Vector.dist(p3, mousePos);
const distToP3 = Vector.dist(p2, p3);
const distToP4 = Vector.dist(prevp3, p4);
if (Math.abs(angleToMouse - angleToP1) < 0.6 && p2DistToMouse > distToP3 && p3DistToMouse > distToP4) {
{
const dirToNewP3 = dirToMouse.copy().rotate(
-(angleToMouse - angleToP1) / 2
);
dirToNewP3.setMag(distToP3);
p3.set(Vector.add(p2, dirToNewP3));
doodler2.line(p2, Vector.add(p2, dirToNewP3), { color: "blue" });
doodler2.line(p2, Vector.add(p2, dirToMouse.mult(100)), {
color: "red"
});
segment.points.forEach((p, i) => {
const relativePoint = Vector.sub(p, firstPoint);
p.set(mousePos);
p.add(relativePoint);
});
const ends = track.findEnds();
setContextItem("showEnds", true);
const nearbyEnds = ends.filter((end) => {
const dist = Vector.dist(end.pos, mousePos);
return dist < 20 && end.segment !== segment;
});
let closestEnd = nearbyEnds[0];
for (const end of nearbyEnds) {
if (end === closestEnd) continue;
const closestEndTangent = Vector.add(
closestEnd.tangent.copy().mult(20),
closestEnd.pos
);
const endTangent = Vector.add(
end.tangent.copy().rotate(Math.PI).mult(20),
end.pos
);
doodler2.drawCircle(closestEndTangent, 4, { color: "red", weight: 1 });
doodler2.drawCircle(endTangent, 4, { color: "blue", weight: 1 });
if (endTangent.dist(mousePos) < closestEndTangent.dist(mousePos) || end.pos.dist(mousePos) < closestEnd.pos.dist(mousePos)) {
closestEnd = end;
}
{
const dirToMouse2 = Vector.sub(mousePos, p3).normalize();
dirToMouse2.setMag(distToP4);
p4.set(Vector.add(p3, dirToMouse2));
doodler2.line(p3, Vector.add(p3, dirToMouse2), { color: "green" });
}
segment.clampLength();
}
doodler2.fillText(
segment.calculateApproxLength().toFixed(2),
p2.copy().add(10, 0),
100
);
if (closestEnd !== this.closestEnd) {
this.closestEnd = closestEnd;
this.ghostSegment = void 0;
this.ghostRotated = false;
}
if (closestEnd) {
doodler2.line(
closestEnd.pos,
Vector.add(closestEnd.pos, closestEnd.tangent.copy().mult(20)),
{ color: "green" }
);
}
if (this.closestEnd) {
if (!this.ghostSegment) {
this.ghostSegment = segment.copy();
this.ghostRotated = false;
}
switch (this.closestEnd.frontOrBack) {
case "front":
this.ghostSegment.setPositionByPoint(
this.closestEnd.pos,
this.ghostSegment.points[0]
);
!this.ghostRotated && this.ghostSegment.rotateAboutPoint(
this.closestEnd.tangent.heading(),
this.ghostSegment.points[0]
);
this.ghostRotated = true;
break;
case "back":
this.ghostSegment.setPositionByPoint(
this.closestEnd.pos,
this.ghostSegment.points[3]
);
!this.ghostRotated && this.ghostSegment.rotateAboutPoint(
this.closestEnd.tangent.heading(),
this.ghostSegment.points[3]
);
this.ghostRotated = true;
break;
}
} else if (!this.closestEnd || !closestEnd) {
this.ghostSegment = void 0;
this.ghostRotated = false;
}
this.selectedSegment?.draw();
if (this.ghostSegment) {
doodler2.drawWithAlpha(0.5, () => {
if (!this.ghostSegment) return;
this.ghostSegment.draw();
if (getContextItemOrDefault("debug", false)) {
const colors2 = getContextItem("colors");
for (const [i, point] of this.ghostSegment.points.entries() ?? []) {
doodler2.fillCircle(point, 4, { color: colors2[i + 3] });
}
}
});
}
}
const translation = new Vector(0, 0);
if (inputManager2.getKeyState("ArrowUp")) {
translation.y -= 1;
}
if (inputManager2.getKeyState("ArrowDown")) {
translation.y += 1;
}
if (inputManager2.getKeyState("ArrowLeft")) {
translation.x -= 1;
}
if (inputManager2.getKeyState("ArrowRight")) {
translation.x += 1;
}
if (translation.x !== 0 || translation.y !== 0) {
track.translate(translation);
}
track.draw(true);
}
start() {
setContextItem("trackSegments", [
void 0,
new StraightTrack(),
new SBendLeft(),
new SBendRight(),
new BankLeft(),
new BankRight()
]);
const inputManager2 = getContextItem("inputManager");
this.heldEvents.set("e", inputManager2.offKey("e"));
this.heldEvents.set("Escape", inputManager2.offKey("Escape"));
@ -1655,25 +1803,50 @@
const state2 = getContextItem("state");
state2.transitionTo(1 /* RUNNING */);
});
inputManager2.onKey("w", () => {
const track2 = getContextItem("track");
const segment = track2.lastSegment;
if (!segment) return;
const n = new StraightTrack(segment.points[3]);
const t = segment.tangent(1).heading();
n.rotate(t);
segment.frontNeighbours.push(n);
track2.registerSegment(n);
this.currentSegment = n;
inputManager2.onKey(" ", () => {
if (this.selectedSegment) {
this.selectedSegment = void 0;
} else {
this.selectedSegment = new StraightTrack();
}
});
inputManager2.onKey("1", () => {
this.currentSegment = track.firstSegment;
inputManager2.onMouse("left", () => {
const track2 = getContextItem("track");
if (this.ghostSegment && this.closestEnd) {
const segment = this.ghostSegment.cleanCopy();
switch (this.closestEnd.frontOrBack) {
case "front":
this.closestEnd.segment.frontNeighbours.push(segment);
segment.backNeighbours.push(this.closestEnd.segment);
break;
case "back":
this.closestEnd.segment.backNeighbours.push(segment);
segment.frontNeighbours.push(this.closestEnd.segment);
break;
}
track2.registerSegment(segment);
this.ghostSegment = void 0;
this.closestEnd = void 0;
} else if (this.selectedSegment) {
track2.registerSegment(this.selectedSegment);
this.selectedSegment = new StraightTrack();
} else {
this.selectedSegment = void 0;
}
});
inputManager2.onNumberKey((i) => {
console.log(i);
const segments = getContextItem("trackSegments");
this.selectedSegment = segments[i];
this.ghostRotated = false;
this.ghostSegment = void 0;
});
this.currentSegment = track.lastSegment;
}
stop() {
const inputManager2 = getContextItem("inputManager");
inputManager2.offKey("e");
inputManager2.offKey("w");
inputManager2.offKey("Escape");
if (this.heldEvents.size > 0) {
for (const [key, cb] of this.heldEvents) {
@ -1684,6 +1857,7 @@
}
}
setContextItem("trackCopy", void 0);
setContextItem("trackSegments", void 0);
}
};
@ -1790,31 +1964,36 @@
bg: "#302040"
});
doodler.scale = doodler.maxScale;
var colors = [
"red",
"orange",
"yellow",
"green",
"blue",
"indigo",
"purple",
"violet"
];
setDefaultContext({
inputManager,
doodler,
resources,
debug: true,
showEnds: true
showEnds: true,
colors
});
var state = bootstrapGameStateMachine();
setContextItem("state", state);
doodler.init();
addButton({
text: "Hello World!",
onClick: () => {
console.log("Hello World!");
},
at: [
new Vector(10, doodler.height - 50),
new Vector(110, doodler.height - 10)
],
style: {
fillColor: "blue",
color: "white"
}
});
doodler.createLayer((_, __, dTime) => {
state.update(dTime);
});
document.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
const track = getContextItem("track");
localStorage.setItem("track", track.serialize());
console.log("Saved track to local storage");
}
});
})();

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.4"
}
}

9
deno.lock generated
View File

@ -1,7 +1,8 @@
{
"version": "4",
"specifiers": {
"jsr:@bearmetal/doodler@^0.0.3": "0.0.3",
"jsr:@bearmetal/doodler@*": "0.0.4",
"jsr:@bearmetal/doodler@^0.0.4": "0.0.4",
"jsr:@luca/esbuild-deno-loader@*": "0.11.0",
"jsr:@luca/esbuild-deno-loader@0.11.1": "0.11.1",
"jsr:@std/assert@*": "1.0.10",
@ -22,8 +23,8 @@
"npm:esbuild@*": "0.24.2"
},
"jsr": {
"@bearmetal/doodler@0.0.3": {
"integrity": "42c04b672f4a6bc7ebd45ad936197a2e32856364b66a9a9fe2b81a4aa45c7a08"
"@bearmetal/doodler@0.0.4": {
"integrity": "b631083cff84994c513f70d1f09e6a9256edabcb224112c93a9ca6a87c88a389"
},
"@luca/esbuild-deno-loader@0.11.0": {
"integrity": "c05a989aa7c4ee6992a27be5f15cfc5be12834cab7ff84cabb47313737c51a2c",
@ -229,7 +230,7 @@
},
"workspace": {
"dependencies": [
"jsr:@bearmetal/doodler@^0.0.3"
"jsr:@bearmetal/doodler@^0.0.4"
]
}
}

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

36
main.ts
View File

@ -1,5 +1,6 @@
import {
getContext,
getContextItem,
setContextItem,
setDefaultContext,
} from "./lib/context.ts";
@ -22,33 +23,40 @@ const doodler = new ZoomableDoodler({
// doodler.minScale = 0.1;
(doodler as any).scale = doodler.maxScale;
const colors = [
"red",
"orange",
"yellow",
"green",
"blue",
"indigo",
"purple",
"violet",
];
setDefaultContext({
inputManager,
doodler,
resources,
debug: true,
showEnds: true,
colors,
});
const state = bootstrapGameStateMachine();
setContextItem("state", state);
doodler.init();
addButton({
text: "Hello World!",
onClick: () => {
console.log("Hello World!");
},
at: [
new Vector(10, doodler.height - 50),
new Vector(110, doodler.height - 10),
],
style: {
fillColor: "blue",
color: "white",
},
});
doodler.createLayer((_, __, dTime) => {
state.update(dTime);
});
document.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
const track = getContextItem<TrackSystem>("track");
localStorage.setItem("track", track.serialize());
console.log("Saved track to local storage");
}
});

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

@ -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,18 @@ 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;
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 +50,112 @@ 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;
}
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] });
}
}
});
}
}
// manipulate only end of segment while maintaining length
// const segment = track.lastSegment;
// if (segment) {
@ -50,56 +171,96 @@ 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);
@ -109,6 +270,15 @@ export class EditTrackState extends State<States> {
// Draw track tangents
}
override start(): void {
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,20 +297,62 @@ export class EditTrackState extends State<States> {
state.transitionTo(States.RUNNING);
});
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(" ", () => {
if (this.selectedSegment) {
this.selectedSegment = undefined;
} else {
this.selectedSegment = new StraightTrack();
}
});
inputManager.onKey("1", () => {
this.currentSegment = track.firstSegment;
inputManager.onMouse("left", () => {
const track = getContextItem<TrackSystem>("track");
if (this.ghostSegment && this.closestEnd) {
const segment = this.ghostSegment.cleanCopy();
switch (this.closestEnd.frontOrBack) {
case "front":
this.closestEnd.segment.frontNeighbours.push(segment);
segment.backNeighbours.push(this.closestEnd.segment);
break;
case "back":
this.closestEnd.segment.backNeighbours.push(segment);
segment.frontNeighbours.push(this.closestEnd.segment);
break;
}
track.registerSegment(segment);
this.ghostSegment = undefined;
this.closestEnd = undefined;
} else if (this.selectedSegment) {
track.registerSegment(this.selectedSegment);
this.selectedSegment = new StraightTrack();
} else {
this.selectedSegment = undefined;
}
});
// inputManager.onKey("w", () => {
// const track = getContextItem<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) => {
console.log(i);
const segments = getContextItem<TrackSegment[]>("trackSegments");
this.selectedSegment = segments[i];
this.ghostRotated = false;
this.ghostSegment = undefined;
});
this.currentSegment = track.lastSegment;
@ -151,6 +363,7 @@ export class EditTrackState extends State<States> {
override stop(): void {
const inputManager = getContextItem<InputManager>("inputManager");
inputManager.offKey("e");
inputManager.offKey("w");
inputManager.offKey("Escape");
if (this.heldEvents.size > 0) {
for (const [key, cb] of this.heldEvents) {
@ -161,5 +374,6 @@ export class EditTrackState extends State<States> {
}
}
setContextItem("trackCopy", undefined);
setContextItem("trackSegments", undefined);
}
}

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,37 @@ export class StraightTrack extends TrackSegment {
]);
}
}
export class SBendLeft extends StraightTrack {
constructor(start?: Vector) {
start = start || new Vector(100, 100);
super(start);
this.points[2].add(0, -25);
this.points[3].add(0, -25);
}
}
export class SBendRight extends StraightTrack {
constructor(start?: Vector) {
start = start || new Vector(100, 100);
super(start);
this.points[2].add(0, 25);
this.points[3].add(0, 25);
}
}
export class BankLeft extends StraightTrack {
constructor(start?: Vector) {
start = start || new Vector(100, 100);
super(start);
this.points[2].add(0, -25);
this.points[3].add(0, 25);
}
}
export class BankRight extends StraightTrack {
constructor(start?: Vector) {
start = start || new Vector(100, 100);
super(start);
this.points[2].add(0, 25);
this.points[3].add(0, -25);
}
}

View File

@ -1,4 +1,4 @@
import { Doodler, Vector } from "@bearmetal/doodler";
import { Doodler, Point, Vector } from "@bearmetal/doodler";
import { PathSegment } from "../math/path.ts";
import { getContextItem, setDefaultContext } from "../lib/context.ts";
@ -25,57 +25,74 @@ export class TrackSystem {
segment.setTrack(this);
this.segments.set(segment.id, segment);
}
unregisterSegment(segment: TrackSegment) {
this.segments.delete(segment.id);
for (const s of this.segments.values()) {
s.backNeighbours = s.backNeighbours.filter((n) => n !== segment);
s.frontNeighbours = s.frontNeighbours.filter((n) => n !== segment);
}
}
draw(showControls = false) {
for (const segment of this.segments.values()) {
segment.draw(showControls);
}
try {
if (getContextItem<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() {
@ -107,6 +124,12 @@ export class TrackSystem {
}
return track;
}
translate(v: Vector) {
for (const segment of this.segments.values()) {
segment.translate(v);
}
}
}
type VectorSet = [Vector, Vector, Vector, Vector];
@ -142,22 +165,18 @@ export class TrackSegment extends PathSegment {
},
);
if (showControls) {
// this.doodler.drawCircle(this.points[0], 4, {
// color: "red",
// weight: 3,
// });
this.doodler.drawCircle(this.points[1], 4, {
this.doodler.fillCircle(this.points[0], 1, {
color: "red",
weight: 3,
});
this.doodler.drawCircle(this.points[2], 4, {
this.doodler.fillCircle(this.points[1], 1, {
color: "red",
});
this.doodler.fillCircle(this.points[2], 1, {
color: "red",
});
this.doodler.fillCircle(this.points[3], 1, {
color: "red",
weight: 3,
});
// this.doodler.drawCircle(this.points[3], 4, {
// color: "red",
// weight: 3,
// });
}
}
@ -177,30 +196,26 @@ export class TrackSegment extends PathSegment {
);
}
propagate() {
const [_, __, p3, p4] = this.points;
const tangent = Vector.sub(p4, p3);
cleanCopy() {
return new TrackSegment(
this.points.map((p) => p.copy()) as VectorSet,
);
}
propagateTranslation(v: Vector) {
for (const fNeighbour of this.frontNeighbours) {
fNeighbour.receivePropagation(tangent);
fNeighbour.receivePropagation(v);
}
for (const bNeighbour of this.backNeighbours) {
bNeighbour.receivePropagation(v);
}
}
lastHeading?: number;
receivePropagation(tangent: Vector) {
const [p1, p2, p3, p4] = this.points;
// const angle = tangent.heading() - (this.lastHeading ?? 0);
// this.lastHeading = tangent.heading();
// const newP2 = Vector.add(p1, tangent);
// const p2ToP3 = Vector.sub(p3, p2);
// p2ToP3.rotate(angle);
// p3.set(Vector.add(newP2, p2ToP3));
// const p2Top4 = Vector.sub(p4, p2);
// p2Top4.rotate(angle);
// p4.set(Vector.add(newP2, p2Top4));
// p2.set(newP2);
this.rotate(tangent);
this.propagate();
receivePropagation(v: Vector) {
this.translate(v);
this.propagateTranslation(v);
}
// TODO: this duplicates receivePropagation, but for some reason it doesn't work when called from receivePropagation
@ -231,4 +246,35 @@ export class TrackSegment extends PathSegment {
data.id,
);
}
setPositionByPoint(pos: Vector, point: Vector) {
if (!this.points.includes(point)) return;
point = point.copy();
this.points.forEach((p, i) => {
const relativePoint = Vector.sub(p, point);
p.set(pos);
p.add(relativePoint);
});
}
rotateAboutPoint(angle: number, point: Vector) {
if (!this.points.includes(point)) return;
point = point.copy();
this.points.forEach((p, i) => {
const relativePoint = Vector.sub(p, point);
relativePoint.rotate(angle);
p.set(Vector.add(point, relativePoint));
});
}
// resetRotation() {
// const angle = this.tangent(0).heading();
// this.rotateAboutPoint(-angle, this.points[0]);
// }
translate(v: Point) {
this.points.forEach((p) => {
p.add(v.x, v.y);
});
}
}

11
types.ts Normal file
View File

@ -0,0 +1,11 @@
import { Vector } from "@bearmetal/doodler";
import { TrackSegment } from "./track/system.ts";
declare global {
type End = {
pos: Vector;
segment: TrackSegment;
tangent: Vector;
frontOrBack: "front" | "back";
};
}