(() => { // lib/context.ts var contextStack = []; var defaultContext = {}; var debug = JSON.parse(localStorage.getItem("debug") || "false"); function setDefaultContext(context) { Object.assign(defaultContext, context); } var ctx = new Proxy( {}, { get(_, prop) { for (let i = contextStack.length - 1; i >= 0; i--) { if (prop in contextStack[i]) return contextStack[i][prop]; } if (prop in defaultContext) return defaultContext[prop]; throw new Error(`Context variable '${prop}' is not defined.`); } } ); function getContext() { return ctx; } 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 }); } if (debug) { setInterval(() => { let ctxEl = document.getElementById("context"); if (!ctxEl) { ctxEl = document.createElement("div"); ctxEl.id = "context"; document.body.append(ctxEl); } ctxEl.innerHTML = ""; const div = document.createElement("div"); const pre = document.createElement("pre"); const h3 = document.createElement("h3"); h3.textContent = "Default"; div.append(h3); pre.textContent = safeStringify(defaultContext); div.append(pre); ctxEl.append(div); for (const [idx, ctx2] of contextStack.entries()) { const div2 = document.createElement("div"); const pre2 = document.createElement("pre"); const h32 = document.createElement("h3"); h32.textContent = "CTX " + idx; div2.append(h32); pre2.textContent = safeStringify(ctx2); div2.append(pre2); ctxEl.append(div2); } }, 1e3); } function safeStringify(obj) { const seen = /* @__PURE__ */ new WeakSet(); return JSON.stringify(obj, (key, value) => { if (typeof value === "object" && value !== null) { if (seen.has(value)) { return "[Circular]"; } seen.add(value); } return value; }, 2); } // https://jsr.io/@bearmetal/doodler/0.0.5-b/geometry/constants.ts var Constants = { TWO_PI: Math.PI * 2 }; // https://jsr.io/@bearmetal/doodler/0.0.5-b/geometry/vector.ts var Vector = class _Vector { x; y; z; doodler; constructor(x = 0, y = 0, z = 0) { if (typeof x === "number") { this.x = x; this.y = y; this.z = z; } else { this.x = x.x; this.y = x.y || y; this.z = x.z || z; } } initializeDoodler(doodler2) { this.doodler = doodler2; } set(v, y, z) { if (arguments.length === 1 && typeof v !== "number") { this.set( v.x || v[0] || 0, v.y || v[1] || 0, v.z || v[2] || 0 ); } else { this.x = v; this.y = y || 0; this.z = z || 0; } } get() { return new _Vector(this.x, this.y, this.z); } mag() { const x = this.x, y = this.y, z = this.z; return Math.sqrt(x * x + y * y + z * z); } magSq() { const x = this.x, y = this.y, z = this.z; return x * x + y * y + z * z; } setMag(v_or_len, len) { if (len === void 0) { len = v_or_len; this.normalize(); this.mult(len); } else { const v = v_or_len; v.normalize(); v.mult(len); return v; } } add(v, y, z) { if (arguments.length === 1 && typeof v !== "number") { this.x += v.x; this.y += v.y; this.z += v.z; } else if (arguments.length === 2) { this.x += v; this.y += y ?? 0; } else { this.x += v; this.y += y ?? 0; this.z += z ?? 0; } return this; } sub(v, y, z) { if (arguments.length === 1 && typeof v !== "number") { this.x -= v.x; this.y -= v.y; this.z -= v.z || 0; } else if (arguments.length === 2) { this.x -= v; this.y -= y ?? 0; } else { this.x -= v; this.y -= y ?? 0; this.z -= z ?? 0; } return this; } mult(v) { if (typeof v === "number") { this.x *= v; this.y *= v; this.z *= v; } else { this.x *= v.x; this.y *= v.y; this.z *= v.z; } return this; } div(v) { if (typeof v === "number") { this.x /= v; this.y /= v; this.z /= v; } else { this.x /= v.x; this.y /= v.y; this.z /= v.z; } return this; } rotate(angle) { const prev_x = this.x; const c = Math.cos(angle); const s = Math.sin(angle); this.x = c * this.x - s * this.y; this.y = s * prev_x + c * this.y; return this; } dist(other) { return Math.sqrt( Math.pow(this.x - other.x, 2) + Math.pow(this.y - other.y, 2) + Math.pow(this.z - other.z, 2) ); } dot(v, y, z) { if (arguments.length === 1 && typeof v !== "number") { return this.x * v.x + this.y * v.y + this.z * v.z; } return this.x * v + this.y * y + this.z * z; } cross(v) { const x = this.x, y = this.y, z = this.z; return new _Vector(y * v.z - v.y * z, z * v.x - v.z * x, x * v.y - v.x * y); } lerp(v_or_x, amt_or_y, z, amt) { const lerp_val = (start, stop, amt2) => { return start + (stop - start) * amt2; }; let x, y; if (arguments.length === 2 && typeof v_or_x !== "number") { amt = amt_or_y; x = v_or_x.x; y = v_or_x.y; z = v_or_x.z; } else { x = v_or_x; y = amt_or_y; } this.x = lerp_val(this.x, x, amt); this.y = lerp_val(this.y, y, amt); this.z = lerp_val(this.z, z, amt); return this; } normalize() { const m = this.mag(); if (m > 0) { this.div(m); } return this; } limit(high) { if (this.mag() > high) { this.normalize(); this.mult(high); } return this; } heading() { return -Math.atan2(-this.y, this.x); } heading2D() { return this.heading(); } toString() { return "[" + this.x.toFixed(2) + ", " + this.y.toFixed(2) + ", " + this.z.toFixed(2) + "]"; } array() { return [this.x, this.y, this.z]; } copy() { return new _Vector(this.x, this.y, this.z); } drawDot(color) { if (!this.doodler) return; this.doodler.dot(this, { weight: 2, color: color || "red" }); } draw(origin) { if (!this.doodler) return; const startPoint = origin ? new _Vector(origin) : new _Vector(); this.doodler.line( startPoint, startPoint.copy().add(this.copy().normalize().mult(100)) ); } normal(v) { if (!v) return new _Vector(-this.y, this.x); const dx = v.x - this.x; const dy = v.y - this.y; return new _Vector(-dy, dx); } static fromAngle(angle, v) { if (v === void 0 || v === null) { v = new _Vector(); } v.x = Math.cos(angle); v.y = Math.sin(angle); return v; } static random2D(v) { return _Vector.fromAngle(Math.random() * (Math.PI * 2), v); } static random3D(v) { const angle = Math.random() * Constants.TWO_PI; const vz = Math.random() * 2 - 1; const mult = Math.sqrt(1 - vz * vz); const vx = mult * Math.cos(angle); const vy = mult * Math.sin(angle); if (v === void 0 || v === null) { v = new _Vector(vx, vy, vz); } else { v.set(vx, vy, vz); } return v; } static dist(v1, v2) { return v1.dist(v2); } static dot(v1, v2) { return v1.dot(v2); } static cross(v1, v2) { return v1.cross(v2); } static add(v1, v2) { return new _Vector(v1.x + v2.x, v1.y + v2.y, v1.z + v2.z); } static sub(v1, v2) { return new _Vector(v1.x - v2.x, v1.y - v2.y, v1.z - v2.z); } static angleBetween(v1, v2) { return Math.acos(v1.dot(v2) / Math.sqrt(v1.magSq() * v2.magSq())); } static lerp(v1, v2, amt) { const val = new _Vector(v1.x, v1.y, v1.z); val.lerp(v2, amt); return val; } static vectorProjection(v1, v2) { v2 = v2.copy(); v2.normalize(); const sp = v1.dot(v2); v2.mult(sp); return v2; } static vectorProjectionAndDot(v1, v2) { v2 = v2.copy(); v2.normalize(); const sp = v1.dot(v2); v2.mult(sp); return [v2, sp]; } static hypot2(a, b) { return _Vector.dot(_Vector.sub(a, b), _Vector.sub(a, b)); } }; var OriginVector = class _OriginVector extends Vector { origin; get halfwayPoint() { return { x: this.mag() / 2 * Math.sin(this.heading()) + this.origin.x, y: this.mag() / 2 * Math.cos(this.heading()) + this.origin.y }; } constructor(origin, p) { super(p.x, p.y, p.z); this.origin = origin; } static from(origin, p) { const v = { x: p.x - origin.x, y: p.y - origin.y }; return new _OriginVector(origin, v); } }; // https://jsr.io/@bearmetal/doodler/0.0.5-b/FPSCounter.ts var FPSCounter = class { frameTimes = []; maxSamples; totalFrameTime = 0; constructor(maxSamples = 100) { this.maxSamples = maxSamples; } update(deltaTime) { const frameTime = deltaTime; this.frameTimes.push(frameTime); this.totalFrameTime += frameTime; if (this.frameTimes.length > this.maxSamples) { const removed = this.frameTimes.shift(); this.totalFrameTime -= removed; } } getFPS() { if (this.frameTimes.length === 0) { return 0; } return this.frameTimes.length / this.totalFrameTime; } getFPSf() { return this.getFPS().toFixed(0); } }; // https://jsr.io/@bearmetal/doodler/0.0.5-b/canvas.ts var Doodler = class { ctx; _canvas; layers = []; bg; framerate; get width() { return this.ctx.canvas.width; } get height() { return this.ctx.canvas.height; } draggables = []; clickables = []; dragTarget; constructor({ width, height, fillScreen, canvas, bg, framerate }, postInit) { if (!canvas) { canvas = document.createElement("canvas"); document.body.append(canvas); } this.bg = bg || "white"; this.framerate = framerate; canvas.width = fillScreen ? document.body.clientWidth : width; canvas.height = fillScreen ? document.body.clientHeight : height; if (fillScreen) { const resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { this._canvas.width = entry.target.clientWidth; this._canvas.height = entry.target.clientHeight; } }); resizeObserver.observe(document.body); } this._canvas = canvas; const ctx2 = canvas.getContext("2d"); if (!ctx2) throw "Unable to initialize Doodler: Canvas context not found"; this.ctx = ctx2; postInit?.(this.ctx); } init() { this._canvas.addEventListener("mousedown", (e) => this.onClick(e)); this._canvas.addEventListener("mouseup", (e) => this.offClick(e)); this._canvas.addEventListener("mousemove", (e) => this.onDrag(e)); this.startDrawLoop(); } timer; lastFrameAt = performance.now(); startDrawLoop() { this.lastFrameAt = Date.now(); if (this.framerate) { this.timer = setInterval( () => this.draw(Date.now()), 1e3 / this.framerate ); } else { const cb = (t) => { this.draw(t); requestAnimationFrame(cb); }; requestAnimationFrame(cb); } } fpsCounter = new FPSCounter(); get fps() { return this.fpsCounter.getFPS(); } draw(time) { const frameTime = (time - this.lastFrameAt) / 1e3; this.fpsCounter.update(frameTime); this.ctx.clearRect(0, 0, this.width, this.height); this.ctx.fillStyle = this.bg; this.ctx.fillRect(0, 0, this.width, this.height); if (frameTime > 0) { for (const [i, l] of (this.layers || []).entries()) { l(this.ctx, i, frameTime); this.drawDeferred(); } this.drawUI(); } this.lastFrameAt = time; } // Layer management createLayer(layer) { this.layers.push(layer); return this.layers.length - 1; } deleteLayer(layer) { if (typeof layer === "number") { this.layers = this.layers.filter((_, i) => i !== layer); } else { this.layers = this.layers.filter((l) => l !== layer); } } moveLayer(layer, index) { let temp = this.layers.filter((l) => l !== layer); temp = [...temp.slice(0, index), layer, ...temp.slice(index)]; this.layers = temp; } // Drawing line(start, end, style) { this.setStyle(style); this.ctx.beginPath(); this.ctx.moveTo(start.x, start.y); this.ctx.lineTo(end.x, end.y); this.ctx.stroke(); } drawLine(points, style) { this.setStyle(style); this.ctx.beginPath(); this.ctx.moveTo(points[0].x, points[0].y); for (const p of points.slice(1)) { this.ctx.lineTo(p.x, p.y); } this.ctx.stroke(); } dot(at, style) { this.setStyle({ ...style, weight: 1 }); this.ctx.beginPath(); this.ctx.arc(at.x, at.y, style?.weight || 1, 0, Constants.TWO_PI); this.ctx.fill(); } drawCircle(at, radius, style) { this.setStyle(style); this.ctx.beginPath(); this.ctx.arc(at.x, at.y, radius, 0, Constants.TWO_PI); this.ctx.stroke(); } fillCircle(at, radius, style) { this.setStyle(style); this.ctx.beginPath(); this.ctx.arc(at.x, at.y, radius, 0, Constants.TWO_PI); this.ctx.fill(); } drawRect(at, width, height, style) { this.setStyle(style); this.ctx.strokeRect(at.x, at.y, width, height); } fillRect(at, width, height, style) { this.setStyle(style); this.ctx.fillRect(at.x, at.y, width, height); } drawSquare(at, size, style) { this.drawRect(at, size, size, style); } fillSquare(at, size, style) { this.fillRect(at, size, size, style); } drawCenteredRect(at, width, height, style) { this.ctx.save(); this.ctx.translate(-width / 2, -height / 2); this.drawRect(at, width, height, style); this.ctx.restore(); } fillCenteredRect(at, width, height, style) { this.ctx.save(); this.ctx.translate(-width / 2, -height / 2); this.fillRect(at, width, height, style); this.ctx.restore(); } drawCenteredSquare(at, size, style) { this.drawCenteredRect(at, size, size, style); } fillCenteredSquare(at, size, style) { this.fillCenteredRect(at, size, size, style); } drawBezier(a, b, c, d, style) { this.setStyle(style); this.ctx.beginPath(); this.ctx.moveTo(a.x, a.y); this.ctx.bezierCurveTo(b.x, b.y, c.x, c.y, d.x, d.y); this.ctx.stroke(); } drawRotated(origin, angle, cb) { this.ctx.save(); this.ctx.translate(origin.x, origin.y); this.ctx.rotate(angle); this.ctx.translate(-origin.x, -origin.y); cb(); this.ctx.restore(); } drawScaled(scale, cb) { this.ctx.save(); this.ctx.transform(scale, 0, 0, scale, 0, 0); cb(); this.ctx.restore(); } drawWithAlpha(alpha, cb) { this.ctx.save(); this.ctx.globalAlpha = Math.min(Math.max(alpha, 0), 1); cb(); this.ctx.restore(); } drawImage(img, at, w, h) { w && h ? this.ctx.drawImage(img, at.x, at.y, w, h) : this.ctx.drawImage(img, at.x, at.y); } drawImageWithOutline(img, at, w, h, style) { this.ctx.save(); const s = (typeof w === "number" || !w ? style?.weight : w.weight) || 1; this.ctx.shadowColor = (typeof w === "number" || !w ? style?.color || style?.fillColor : w.color || w.strokeColor) || "red"; this.ctx.shadowBlur = 0; for (let x = -s; x <= s; x++) { for (let y = -s; y <= s; y++) { this.ctx.shadowOffsetX = x; this.ctx.shadowOffsetY = y; typeof w === "number" && h ? this.ctx.drawImage(img, at.x, at.y, w, h) : this.ctx.drawImage(img, at.x, at.y); } } this.ctx.restore(); } drawSprite(img, spritePos, sWidth, sHeight, at, width, height) { this.ctx.drawImage( img, spritePos.x, spritePos.y, sWidth, sHeight, at.x, at.y, width, height ); } deferredDrawings = []; deferDrawing(cb) { this.deferredDrawings.push(cb); } drawDeferred() { while (this.deferredDrawings.length) { this.deferredDrawings.pop()?.(); } } setStyle(style) { const ctx2 = this.ctx; ctx2.fillStyle = style?.color || style?.fillColor || "black"; ctx2.strokeStyle = style?.color || style?.strokeColor || "black"; ctx2.lineWidth = style?.weight || 1; ctx2.textAlign = style?.textAlign || ctx2.textAlign; ctx2.textBaseline = style?.textBaseline || ctx2.textBaseline; } fillText(text, pos, maxWidth, style) { this.setStyle(style); this.ctx.fillText(text, pos.x, pos.y, maxWidth); } strokeText(text, pos, maxWidth, style) { this.setStyle(style); this.ctx.strokeText(text, pos.x, pos.y, maxWidth); } clearRect(at, width, height) { this.ctx.clearRect(at.x, at.y, width, height); } // Interaction mouseX = 0; mouseY = 0; registerDraggable(point, radius, style) { if (this.draggables.find((d) => d.point === point)) return; const id = this.addUIElement("circle", point, radius, { fillColor: "#5533ff50", strokeColor: "#5533ff50" }); this.draggables.push({ point, radius, style, id }); } unregisterDraggable(point) { for (const d of this.draggables) { if (d.point === point) { this.removeUIElement(d.id); } } this.draggables = this.draggables.filter((d) => d.point !== point); } registerClickable(p1, p2, cb) { const top = Math.min(p1.y, p2.y); const left = Math.min(p1.x, p2.x); const bottom = Math.max(p1.y, p2.y); const right = Math.max(p1.x, p2.x); this.clickables.push({ onClick: cb, checkBound: (p) => p.y >= top && p.x >= left && p.y <= bottom && p.x <= right }); } unregisterClickable(cb) { this.clickables = this.clickables.filter((c) => c.onClick !== cb); } addDragEvents({ onDragEnd, onDragStart, onDrag, point }) { const d = this.draggables.find((d2) => d2.point === point); if (d) { d.onDragEnd = onDragEnd; d.onDragStart = onDragStart; d.onDrag = onDrag; } } onClick(_e) { const mouse = new Vector(this.mouseX, this.mouseY); for (const d of this.draggables) { if (d.point.dist(mouse) <= d.radius) { d.beingDragged = true; d.onDragStart?.call(null); this.dragTarget = d; } else d.beingDragged = false; } for (const c of this.clickables) { if (c.checkBound(mouse)) { c.onClick(); } } } offClick(_e) { for (const d of this.draggables) { d.beingDragged = false; d.onDragEnd?.call(null); } this.dragTarget = void 0; } onDrag(e) { const _rect = this._canvas.getBoundingClientRect(); this.mouseX = e.offsetX; this.mouseY = e.offsetY; for (const d of this.draggables.filter((d2) => d2.beingDragged)) { d.point.add(e.movementX, e.movementY); d.onDrag && d.onDrag({ x: e.movementX, y: e.movementY }); } } // UI Layer uiElements = /* @__PURE__ */ new Map(); uiDrawing = { rectangle: (...args) => { !args[3].noFill && this.fillRect(args[0], args[1], args[2], args[3]); !args[3].noStroke && this.drawRect(args[0], args[1], args[2], args[3]); }, square: (...args) => { !args[2].noFill && this.fillSquare(args[0], args[1], args[2]); !args[2].noStroke && this.drawSquare(args[0], args[1], args[2]); }, circle: (...args) => { !args[2].noFill && this.fillCircle(args[0], args[1], args[2]); !args[2].noStroke && this.drawCircle(args[0], args[1], args[2]); } }; drawUI() { for (const [shape, ...args] of this.uiElements.values()) { this.uiDrawing[shape].apply(null, args); } } addUIElement(shape, ...args) { const id = crypto.randomUUID(); for (const arg of args) { delete arg.color; } this.uiElements.set(id, [shape, ...args]); return id; } removeUIElement(id) { this.uiElements.delete(id); } }; // https://jsr.io/@bearmetal/doodler/0.0.5-b/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.5-b/timing/Map.ts var map = (value, x1, y1, x2, y2) => (value - x1) * (y2 - x2) / (y1 - x1) + x2; // https://jsr.io/@bearmetal/doodler/0.0.5-b/zoomableCanvas.ts var ZoomableDoodler = class extends Doodler { scale = 1; dragging = false; origin = { x: 0, y: 0 }; mouse = { x: 0, y: 0 }; previousTouchLength; touchTimer; hasDoubleTapped = false; zooming = false; scaleAround = { x: 0, y: 0 }; maxScale = 4; minScale = 1; constructor(options, postInit) { super(options, postInit); this._canvas.addEventListener("wheel", (e) => { this.scaleAtMouse(e.deltaY < 0 ? 1.1 : 0.9); if (this.scale === 1) { this.origin.x = 0; this.origin.y = 0; } }); this._canvas.addEventListener("dblclick", (e) => { e.preventDefault(); this.scale = 1; this.origin.x = 0; this.origin.y = 0; this.ctx.setTransform(1, 0, 0, 1, 0, 0); }); this._canvas.addEventListener("mousedown", (e) => { e.preventDefault(); this.dragging = true; }); this._canvas.addEventListener("mouseup", (e) => { e.preventDefault(); this.dragging = false; }); this._canvas.addEventListener("mouseleave", (_e) => { this.dragging = false; }); this._canvas.addEventListener("mousemove", (e) => { const prev = this.mouse; this.mouse = { x: e.offsetX, y: e.offsetY }; if (this.dragging && !this.dragTarget) this.drag(prev); }); this._canvas.addEventListener("touchstart", (e) => { e.preventDefault(); if (e.touches.length === 1) { const t1 = e.touches.item(0); if (t1) { this.mouse = this.getTouchOffset({ x: t1.clientX, y: t1.clientY }); } } else { clearTimeout(this.touchTimer); } }); this._canvas.addEventListener("touchend", (e) => { if (e.touches.length !== 2) { this.previousTouchLength = void 0; } switch (e.touches.length) { case 1: break; case 0: if (!this.zooming) { this.events.get("touchend")?.map((cb) => cb(e)); } break; } this.dragging = e.touches.length === 1; clearTimeout(this.touchTimer); }); this._canvas.addEventListener("touchmove", (e) => { e.preventDefault(); if (e.touches.length === 2) { const t1 = e.touches.item(0); const t2 = e.touches.item(1); if (t1 && t2) { const vect = OriginVector.from( this.getTouchOffset({ x: t1.clientX, y: t1.clientY }), { x: t2.clientX, y: t2.clientY } ); if (this.previousTouchLength) { const diff = this.previousTouchLength - vect.mag(); this.scaleAt(vect.halfwayPoint, diff < 0 ? 1.01 : 0.99); this.scaleAround = { ...vect.halfwayPoint }; } this.previousTouchLength = vect.mag(); } } if (e.touches.length === 1) { this.dragging === true; const t1 = e.touches.item(0); if (t1) { const prev = this.mouse; this.mouse = this.getTouchOffset({ x: t1.clientX, y: t1.clientY }); this.drag(prev); } } }); this._canvas.addEventListener("touchstart", (e) => { if (e.touches.length !== 1) return false; if (!this.hasDoubleTapped) { this.hasDoubleTapped = true; setTimeout(() => this.hasDoubleTapped = false, 300); return false; } if (this.scale > 1) { this.frameCounter = map(this.scale, this.maxScale, 1, 0, 59); this.zoomDirection = -1; } else { this.frameCounter = 0; this.zoomDirection = 1; } if (this.zoomDirection > 0) { this.scaleAround = { ...this.mouse }; } this.events.get("doubletap")?.map((cb) => cb(e)); }); } worldToScreen(x, y) { x = x * this.scale + this.origin.x; y = y * this.scale + this.origin.y; return { x, y }; } screenToWorld(x, y) { x = (x - this.origin.x) / this.scale; y = (y - this.origin.y) / this.scale; return { x, y }; } scaleAtMouse(scaleBy) { if (this.scale === this.maxScale && scaleBy > 1) return; this.scaleAt({ x: this.mouse.x, y: this.mouse.y }, scaleBy); } scaleAt(p, scaleBy) { this.scale = Math.min( Math.max(this.scale * scaleBy, this.minScale), this.maxScale ); this.origin.x = p.x - (p.x - this.origin.x) * scaleBy; this.origin.y = p.y - (p.y - this.origin.y) * scaleBy; this.constrainOrigin(); } moveOrigin(motion) { if (this.scale > 1) { this.origin.x += motion.x; this.origin.y += motion.y; this.constrainOrigin(); } } drag(prev) { if (this.scale > 1) { const xOffset = this.mouse.x - prev.x; const yOffset = this.mouse.y - prev.y; this.origin.x += xOffset; this.origin.y += yOffset; this.constrainOrigin(); } } constrainOrigin() { this.origin.x = Math.min( Math.max( this.origin.x, -this._canvas.width * this.scale + this._canvas.width ), 0 ); this.origin.y = Math.min( Math.max( this.origin.y, -this._canvas.height * this.scale + this._canvas.height ), 0 ); } draw(time) { this.ctx.setTransform( this.scale, 0, 0, this.scale, this.origin.x, this.origin.y ); this.animateZoom(); this.ctx.fillStyle = this.bg; this.ctx.fillRect(0, 0, this.width / this.scale, this.height / this.scale); super.draw(time); } getTouchOffset(p) { const { x, y } = this._canvas.getBoundingClientRect(); const offsetX = p.x - x; const offsetY = p.y - y; return { x: offsetX, y: offsetY }; } onDrag(e) { const d = { ...e, movementX: e.movementX / this.scale, movementY: e.movementY / this.scale }; super.onDrag(d); const { x, y } = this.screenToWorld(e.offsetX, e.offsetY); this.mouseX = x; this.mouseY = y; } zoomDirection = -1; frameCounter = 60; animateZoom() { if (this.frameCounter < 60) { const frame = easeInOut(map(this.frameCounter, 0, 59, 0, 1)); switch (this.zoomDirection) { case 1: { this.scale = map(frame, 0, 1, 1, this.maxScale); } break; case -1: { this.scale = map(frame, 0, 1, this.maxScale, 1); } break; } this.origin.x = this.scaleAround.x - this.scaleAround.x * this.scale; this.origin.y = this.scaleAround.y - this.scaleAround.y * this.scale; this.constrainOrigin(); this.frameCounter++; } } events = /* @__PURE__ */ new Map(); registerEvent(eventName, cb) { let events = this.events.get(eventName); if (!events) events = this.events.set(eventName, []).get(eventName); events.push(cb); } }; // 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(); mouseLocation = { x: 0, y: 0 }; mouseDelta = { x: 0, y: 0 }; keyEvents = /* @__PURE__ */ new Map(); mouseEvents = /* @__PURE__ */ new Map(); constructor() { document.addEventListener("keydown", (e) => { this.keyStates.set(e.key, true); this.keyEvents.get(e.key)?.call(e); }); document.addEventListener("keyup", (e) => { this.keyStates.set(e.key, false); }); document.addEventListener("mousedown", (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) => { 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 }; this.mouseDelta = { x: e.movementX, y: e.movementY }; }); } getKeyState(key) { return this.keyStates.get(key); } getMouseState(key) { return this.mouseStates.get(key); } getMouseLocation() { if (getContextItem("doodler") instanceof ZoomableDoodler) { return getContextItem("doodler").screenToWorld( this.mouseLocation.x, this.mouseLocation.y ); } return this.mouseLocation; } getMouseLocationV() { if (getContextItem("doodler") instanceof ZoomableDoodler) { return new Vector( getContextItem("doodler").screenToWorld( this.mouseLocation.x, this.mouseLocation.y ) ); } return new Vector(this.mouseLocation); } getMouseDelta() { return this.mouseDelta; } onKey(key, cb) { this.keyEvents.set(key, cb); } onMouse(key, cb) { this.mouseEvents.set(key, cb); } offKey(key) { const events = this.keyEvents.get(key); this.keyEvents.delete(key); return events; } offMouse(key) { this.mouseEvents.delete(key); } onNumberKey(arg0) { for (let i = 0; i < 10; i++) { this.onKey(i.toString(), () => arg0(i)); } } }; // lib/resources.ts var ResourceManager = class { resources = /* @__PURE__ */ new Map(); statuses = /* @__PURE__ */ new Map(); get(name) { if (!this.resources.has(name)) { throw new Error(`Resource ${name} not found`); } return this.resources.get(name); } set(name, value) { if (typeof value.addEventListener === "function") { this.statuses.set( name, new Promise((resolve) => { const onload = () => { this.resources.set(name, value); resolve(true); value.removeEventListener("load", onload); }; value.addEventListener("load", onload); }) ); } else { console.warn("Resource added was not a loadable resource"); } this.resources.set(name, value); } delete(name) { this.resources.delete(name); } ready() { return Promise.all(Array.from(this.statuses.values())); } }; // state/machine.ts var StateMachine = class { _states = /* @__PURE__ */ new Map(); currentState; update(dt, ctx2) { this.currentState?.update(dt, ctx2); } optimizePerformance(percent) { const ctx2 = getContext(); if (percent < 0.5) { ctx2.track.optimize(percent); } } get current() { return this.currentState; } get states() { return this._states; } addState(state2) { this.states.set(state2.name, state2); } transitionTo(state2) { if (!this.current) { this.currentState = this._states.get(state2); this.currentState.start(); return; } if (this.current?.canTransitionTo(state2) && this._states.has(state2)) { this.current.stop(); this.currentState = this._states.get(state2); this.current.start(); } } }; var State = class { stateMachine; constructor(stateMachine) { this.stateMachine = stateMachine; } canTransitionTo(state2) { return this.validTransitions.has(state2); } }; // state/states/EditTrainState.ts var EditTrainState = class extends State { name = 4 /* EDIT_TRAIN */; validTransitions = /* @__PURE__ */ new Set([ 1 /* RUNNING */, 2 /* PAUSED */ ]); update(dt) { throw new Error("Method not implemented."); } start() { throw new Error("Method not implemented."); } stop() { throw new Error("Method not implemented."); } }; // math/path.ts var PathSegment = class { id; points; length; startingLength; next; prev; constructor(points) { this.id = crypto.randomUUID(); this.points = points; this.length = this.calculateApproxLength(100); this.startingLength = Math.round(this.length); } getPointAtT(t) { const [a, b, c, d] = this.points; const res = a.copy(); res.add(Vector.add(a.copy().mult(-3), b.copy().mult(3)).mult(t)); res.add( Vector.add( Vector.add(a.copy().mult(3), b.copy().mult(-6)), c.copy().mult(3) ).mult(Math.pow(t, 2)) ); res.add( Vector.add( Vector.add(a.copy().mult(-1), b.copy().mult(3)), Vector.add(c.copy().mult(-3), d.copy()) ).mult(Math.pow(t, 3)) ); return res; } getClosestPoint(v) { const samples = 25; const resolution = 1 / samples; let closest = this.points[0]; let closestDistance = this.points[0].dist(v); let closestT = 0; for (let i = 0; i < samples; i++) { const point = this.getPointAtT(i * resolution); const distance = v.dist(point); if (distance < closestDistance) { closest = point; closestDistance = distance; closestT = i * resolution; } } return [closest, closestDistance, closestT]; } getPointsWithinRadius(v, r) { const points = []; const samples = 25; const resolution = 1 / samples; for (let i = 0; i < samples; i++) { const point = this.getPointAtT(i * resolution); const distance = v.dist(point); if (distance < r) { points.push([i * resolution, this]); } } return points; } tangent(t) { const [a, b, c, d] = this.points; const res = Vector.sub(b, a).mult(3 * Math.pow(1 - t, 2)); res.add( Vector.add( Vector.sub(c, b).mult(6 * (1 - t) * t), Vector.sub(d, c).mult(3 * Math.pow(t, 2)) ) ); return res; } doesIntersectCircle(x, y, r) { const v = new Vector(x, y); const samples = 25; const resolution = 1 / samples; let distance = Infinity; let t; for (let i = 0; i < samples; i++) { if (i !== samples - 1) { const a = this.getPointAtT(i * resolution); const b = this.getPointAtT((i + 1) * resolution); const ac = Vector.sub(v, a); const ab = Vector.sub(b, a); const d = Vector.add(Vector.vectorProjection(ac, ab), a); const ad = Vector.sub(d, a); const k = Math.abs(ab.x) > Math.abs(ab.y) ? ad.x / ab.x : ad.y / ab.y; let dist; if (k <= 0) { dist = Vector.hypot2(v, a); } else if (k >= 1) { dist = Vector.hypot2(v, b); } dist = Vector.hypot2(v, d); if (dist < distance) { distance = dist; t = i * resolution; } } } if (distance < r) return t; return false; } calculateApproxLength(resolution = 25) { const stepSize = 1 / resolution; const points = []; for (let i = 0; i <= resolution; i++) { const current = stepSize * i; points.push(this.getPointAtT(current)); } this.length = points.reduce((acc, cur) => { const prev = acc.prev; acc.prev = cur; if (!prev) return acc; acc.length += cur.dist(prev); return acc; }, { prev: void 0, length: 0 }).length; return this.length; } calculateEvenlySpacedPoints(spacing, resolution = 1, targetLength) { const points = []; points.push(this.points[0]); let prev = points[0]; let distSinceLastEvenPoint = 0; let t = 0; const div = Math.ceil(this.length * resolution * 10); while (t < 1) { t += 1 / div; const point = this.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; } if (targetLength && points.length < targetLength) { while (points.length < targetLength) { t += 1 / div; const point = this.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; } } return points; } calculateSubdividedPoints(numberOfPoints) { const points = []; for (let i = 0; i < numberOfPoints; i++) { const point = this.getPointAtT(i / numberOfPoints); points.push(point); } return points; } clampLength() { const curveLength = this.startingLength; const points = this.calculateEvenlySpacedPoints(1, 1, curveLength + 1); if (points.length >= curveLength) { this.points[3].set(points[curveLength]); } } draw() { } }; // math/clamp.ts function clamp(value, min, max) { return Math.min(Math.max(value, min), max); } // track/system.ts var TrackSystem = class _TrackSystem { segments = /* @__PURE__ */ new Map(); doodler; constructor(segments) { this.doodler = getContextItem("doodler"); for (const segment of segments) { this.segments.set(segment.id, segment); } } get firstSegment() { return this.segments.values().next().value; } get lastSegment() { return this.segments.values().toArray().pop(); } optimize(percent) { console.log("Optimizing track", percent * 100 / 4); for (const segment of this.segments.values()) { segment.recalculateRailPoints(Math.round(percent * 100 / 4)); } } registerSegment(segment) { 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 [i, segment] of this.segments.entries()) { segment.draw(showControls); } } ends = /* @__PURE__ */ new Map(); endArray = []; findEnds() { for (const segment of this.segments.values()) { 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 this.endArray; } serialize() { return JSON.stringify( this.segments.values().map((s) => s.serialize()).toArray() ); } copy() { const track = new _TrackSystem([]); for (const segment of this.segments.values()) { track.segments.set(segment.id, segment.copy()); } return track; } static deserialize(data) { if (data.length === 0) return void 0; const track = new _TrackSystem([]); const neighborMap = /* @__PURE__ */ new Map(); for (const segment of data) { track.segments.set(segment.id, TrackSegment.deserialize(segment)); neighborMap.set(segment.id, [segment.fNeighbors, segment.bNeighbors]); } for (const segment of track.segments.values()) { segment.setTrack(track); const neighbors = neighborMap.get(segment.id); if (neighbors) { segment.backNeighbours = neighbors[1].map( (id) => track.segments.get(id) ).filter((s) => s); segment.frontNeighbours = neighbors[0].map( (id) => track.segments.get(id) ).filter((s) => s); } } return track; } translate(v) { for (const segment of this.segments.values()) { segment.translate(v); } } _path; 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, /* @__PURE__ */ 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(rightOnlyPath); } *findRightPath(start, seen, flags) { 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, seen, flags) { 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); } }; var TrackSegment = class _TrackSegment extends PathSegment { frontNeighbours = []; backNeighbours = []; track; doodler; normalPoints = []; antiNormalPoints = []; constructor(p, id) { super(p); this.doodler = getContextItem("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) { this.track = t; } draw(showControls = false) { 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.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.deferDrawing( () => { this.doodler.drawLine(this.normalPoints, { color: "grey", weight: 1.5 }); this.doodler.drawLine(this.antiNormalPoints, { color: "grey", weight: 1.5 }); } ); } serialize() { return { p: this.points.map((p) => p.array()), id: this.id, bNeighbors: this.backNeighbours.map((n) => n.id), fNeighbors: this.frontNeighbours.map((n) => n.id) }; } copy() { return new _TrackSegment( this.points.map((p) => p.copy()), this.id ); } cleanCopy() { return new _TrackSegment( this.points.map((p) => p.copy()) ); } propagateTranslation(v) { for (const fNeighbour of this.frontNeighbours) { fNeighbour.receivePropagation(v); } for (const bNeighbour of this.backNeighbours) { bNeighbour.receivePropagation(v); } } lastHeading; 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) { const [p1, p2, p3, p4] = this.points; let newP2; if (angle instanceof Vector) { const tan = angle; angle = tan.heading() - (this.lastHeading ?? 0); this.lastHeading = tan.heading(); newP2 = Vector.add(p1, tan); } else { const p1ToP2 = Vector.sub(p2, p1); p1ToP2.rotate(angle); newP2 = Vector.add(p1, p1ToP2); } 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); } static deserialize(data) { return new _TrackSegment( data.p.map((p) => new Vector(p[0], p[1], p[2])), 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); }); } }; var Spline = class { segments = []; ctx; evenPoints; pointSpacing; get points() { return Array.from(new Set(this.segments.flatMap((s) => s.points))); } nodes; looped = false; constructor(segs) { 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 = []; } // setContext(ctx: CanvasRenderingContext2D) { // this.ctx = ctx; // for (const segment of this.segments) { // segment.setContext(ctx); // } // } draw() { for (const segment of this.segments) { const doodler2 = getContextItem("doodler"); doodler2.drawWithAlpha(0.5, () => { doodler2.drawBezier(...segment.points, { color: "red" }); }); } } calculateEvenlySpacedPoints(spacing, resolution = 1) { this.pointSpacing = 1; const points = []; 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) { if (this.looped) { if (t < 0) t += this.evenPoints.length; const i2 = Math.floor(t) % this.evenPoints.length; const a2 = this.evenPoints[i2]; const b2 = this.evenPoints[(i2 + 1) % this.evenPoints.length]; return Vector.lerp(a2, b2, 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) { const node = this.nodes.find((n) => n.anchor === p); node && (node.tangent = !node.tangent); } toggleNodeMirrored(p) { const node = this.nodes.find((n) => n.anchor === p); node && (node.mirrored = !node.mirrored); } handleNodeEdit(p, movement) { 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); } } } }; // track/shapes.ts var StraightTrack = class extends TrackSegment { constructor(start) { start = start || new Vector(100, 100); super([ start, start.copy().add(25, 0), start.copy().add(75, 0), start.copy().add(100, 0) ]); } }; var SBendLeft = class extends TrackSegment { constructor(start) { start = start || new Vector(100, 100); super([ start, start.copy().add(60, 0), start.copy().add(90, -25), start.copy().add(150, -25) ]); } }; var SBendRight = class extends TrackSegment { constructor(start) { start = start || new Vector(100, 100); super([ start, start.copy().add(60, 0), start.copy().add(90, 25), start.copy().add(150, 25) ]); } }; var BankLeft = class extends TrackSegment { constructor(start) { 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 ]); } }; var BankRight = class extends TrackSegment { constructor(start) { 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 ]); } }; // state/states/EditTrackState.ts var EditTrackState = class extends State { name = 3 /* EDIT_TRACK */; validTransitions = /* @__PURE__ */ new Set([ 1 /* RUNNING */, 2 /* PAUSED */ ]); heldEvents = /* @__PURE__ */ new Map(); currentSegment; selectedSegment; ghostSegment; ghostRotated = false; closestEnd; layers = []; update(dt) { const inputManager2 = getContextItem("inputManager"); const track = getContextItem("track"); const doodler2 = getContextItem("doodler"); if (this.selectedSegment) { const segment = this.selectedSegment; const firstPoint = segment.points[0].copy(); const mousePos = inputManager2.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 ); 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; } } 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; } } 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); } } start() { const doodler2 = getContextItem("doodler"); this.layers.push( doodler2.createLayer(() => { 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] }); } } }); } track.draw(true); }) ); 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")); inputManager2.onKey("e", () => { const state2 = getContextItem("state"); state2.transitionTo(1 /* RUNNING */); }); const track = getContextItem("track"); setContextItem("trackCopy", track.copy()); inputManager2.onKey("Escape", () => { const trackCopy = getContextItem("trackCopy"); setContextItem("track", trackCopy); setContextItem("trackCopy", void 0); const state2 = getContextItem("state"); state2.transitionTo(1 /* RUNNING */); }); inputManager2.onKey(" ", () => { if (this.selectedSegment) { this.selectedSegment = void 0; } else { this.selectedSegment = new StraightTrack(); } }); 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.cleanCopy()); } else { this.selectedSegment = void 0; } }); inputManager2.onNumberKey((i) => { const segments = getContextItem("trackSegments"); this.selectedSegment = segments[i]; this.ghostRotated = false; this.ghostSegment = void 0; }); inputManager2.onKey("z", () => { if (inputManager2.getKeyState("Control")) { const segment = track.lastSegment; if (!segment) return; this.redoBuffer.push(segment); if (this.redoBuffer.length > 100) { this.redoBuffer.shift(); } track.unregisterSegment(segment); } }); inputManager2.onKey("y", () => { if (inputManager2.getKeyState("Control")) { const segment = this.redoBuffer.pop(); if (!segment) return; track.registerSegment(segment); } }); } redoBuffer = []; stop() { for (const layer of this.layers) { getContextItem("doodler").deleteLayer(layer); } const inputManager2 = getContextItem("inputManager"); inputManager2.offKey("e"); inputManager2.offKey("w"); inputManager2.offKey("Escape"); inputManager2.offMouse("left"); if (this.heldEvents.size > 0) { for (const [key, cb] of this.heldEvents) { if (cb) { getContextItem("inputManager").onKey(key, cb); } this.heldEvents.delete(key); } } setContextItem("trackCopy", void 0); setContextItem("trackSegments", void 0); } }; // state/states/PausedState.ts var PausedState = class extends State { name = 2 /* PAUSED */; validTransitions = /* @__PURE__ */ new Set([ 0 /* LOAD */, 1 /* RUNNING */, 3 /* EDIT_TRACK */, 4 /* EDIT_TRAIN */ ]); update(dt) { throw new Error("Method not implemented."); } start() { throw new Error("Method not implemented."); } stop() { throw new Error("Method not implemented."); } }; // https://jsr.io/@bearmetal/doodler/0.0.4/geometry/constants.ts var Constants2 = { TWO_PI: Math.PI * 2 }; // https://jsr.io/@bearmetal/doodler/0.0.4/geometry/vector.ts var Vector2 = class _Vector { x; y; z; doodler; constructor(x = 0, y = 0, z = 0) { if (typeof x === "number") { this.x = x; this.y = y; this.z = z; } else { this.x = x.x; this.y = x.y || y; this.z = x.z || z; } } initializeDoodler(doodler2) { this.doodler = doodler2; } set(v, y, z) { if (arguments.length === 1 && typeof v !== "number") { this.set( v.x || v[0] || 0, v.y || v[1] || 0, v.z || v[2] || 0 ); } else { this.x = v; this.y = y || 0; this.z = z || 0; } } get() { return new _Vector(this.x, this.y, this.z); } mag() { const x = this.x, y = this.y, z = this.z; return Math.sqrt(x * x + y * y + z * z); } magSq() { const x = this.x, y = this.y, z = this.z; return x * x + y * y + z * z; } setMag(v_or_len, len) { if (len === void 0) { len = v_or_len; this.normalize(); this.mult(len); } else { const v = v_or_len; v.normalize(); v.mult(len); return v; } } add(v, y, z) { if (arguments.length === 1 && typeof v !== "number") { this.x += v.x; this.y += v.y; this.z += v.z; } else if (arguments.length === 2) { this.x += v; this.y += y ?? 0; } else { this.x += v; this.y += y ?? 0; this.z += z ?? 0; } return this; } sub(v, y, z) { if (arguments.length === 1 && typeof v !== "number") { this.x -= v.x; this.y -= v.y; this.z -= v.z || 0; } else if (arguments.length === 2) { this.x -= v; this.y -= y ?? 0; } else { this.x -= v; this.y -= y ?? 0; this.z -= z ?? 0; } return this; } mult(v) { if (typeof v === "number") { this.x *= v; this.y *= v; this.z *= v; } else { this.x *= v.x; this.y *= v.y; this.z *= v.z; } return this; } div(v) { if (typeof v === "number") { this.x /= v; this.y /= v; this.z /= v; } else { this.x /= v.x; this.y /= v.y; this.z /= v.z; } return this; } rotate(angle) { const prev_x = this.x; const c = Math.cos(angle); const s = Math.sin(angle); this.x = c * this.x - s * this.y; this.y = s * prev_x + c * this.y; return this; } dist(other) { return Math.sqrt( Math.pow(this.x - other.x, 2) + Math.pow(this.y - other.y, 2) + Math.pow(this.z - other.z, 2) ); } dot(v, y, z) { if (arguments.length === 1 && typeof v !== "number") { return this.x * v.x + this.y * v.y + this.z * v.z; } return this.x * v + this.y * y + this.z * z; } cross(v) { const x = this.x, y = this.y, z = this.z; return new _Vector(y * v.z - v.y * z, z * v.x - v.z * x, x * v.y - v.x * y); } lerp(v_or_x, amt_or_y, z, amt) { const lerp_val = (start, stop, amt2) => { return start + (stop - start) * amt2; }; let x, y; if (arguments.length === 2 && typeof v_or_x !== "number") { amt = amt_or_y; x = v_or_x.x; y = v_or_x.y; z = v_or_x.z; } else { x = v_or_x; y = amt_or_y; } this.x = lerp_val(this.x, x, amt); this.y = lerp_val(this.y, y, amt); this.z = lerp_val(this.z, z, amt); return this; } normalize() { const m = this.mag(); if (m > 0) { this.div(m); } return this; } limit(high) { if (this.mag() > high) { this.normalize(); this.mult(high); } return this; } heading() { return -Math.atan2(-this.y, this.x); } heading2D() { return this.heading(); } toString() { return "[" + this.x.toFixed(2) + ", " + this.y.toFixed(2) + ", " + this.z.toFixed(2) + "]"; } array() { return [this.x, this.y, this.z]; } copy() { return new _Vector(this.x, this.y, this.z); } drawDot(color) { if (!this.doodler) return; this.doodler.dot(this, { weight: 2, color: color || "red" }); } draw(origin) { if (!this.doodler) return; const startPoint = origin ? new _Vector(origin) : new _Vector(); this.doodler.line( startPoint, startPoint.copy().add(this.copy().normalize().mult(100)) ); } normal(v) { if (!v) return new _Vector(-this.y, this.x); const dx = v.x - this.x; const dy = v.y - this.y; return new _Vector(-dy, dx); } static fromAngle(angle, v) { if (v === void 0 || v === null) { v = new _Vector(); } v.x = Math.cos(angle); v.y = Math.sin(angle); return v; } static random2D(v) { return _Vector.fromAngle(Math.random() * (Math.PI * 2), v); } static random3D(v) { const angle = Math.random() * Constants2.TWO_PI; const vz = Math.random() * 2 - 1; const mult = Math.sqrt(1 - vz * vz); const vx = mult * Math.cos(angle); const vy = mult * Math.sin(angle); if (v === void 0 || v === null) { v = new _Vector(vx, vy, vz); } else { v.set(vx, vy, vz); } return v; } static dist(v1, v2) { return v1.dist(v2); } static dot(v1, v2) { return v1.dot(v2); } static cross(v1, v2) { return v1.cross(v2); } static add(v1, v2) { return new _Vector(v1.x + v2.x, v1.y + v2.y, v1.z + v2.z); } static sub(v1, v2) { return new _Vector(v1.x - v2.x, v1.y - v2.y, v1.z - v2.z); } static angleBetween(v1, v2) { return Math.acos(v1.dot(v2) / Math.sqrt(v1.magSq() * v2.magSq())); } static lerp(v1, v2, amt) { const val = new _Vector(v1.x, v1.y, v1.z); val.lerp(v2, amt); return val; } static vectorProjection(v1, v2) { v2 = v2.copy(); v2.normalize(); const sp = v1.dot(v2); v2.mult(sp); return v2; } static vectorProjectionAndDot(v1, v2) { v2 = v2.copy(); v2.normalize(); const sp = v1.dot(v2); v2.mult(sp); return [v2, sp]; } static hypot2(a, b) { return _Vector.dot(_Vector.sub(a, b), _Vector.sub(a, b)); } }; // train/train.ts var Train = class { nodes = []; cars = []; path; t; engineLength = 40; spacing = 30; speed = 10; constructor(track, cars) { this.path = track; this.t = 0; const resources2 = getContextItem("resources"); this.cars = cars; 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) { this.t = this.t + this.speed * dTime * 10; let currentOffset = 0; for (const car of this.cars) { if (!car.points) return; const [a, b] = car.points; a.set(this.path.followEvenPoints(this.t - currentOffset)); currentOffset += car.length; b.set(this.path.followEvenPoints(this.t - currentOffset)); currentOffset += this.spacing; } } // draw() { // const doodler = getContextItem("doodler"); // this.path.draw(); // for (const [i, node] of this.nodes.entries()) { // // 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); // // to.setMag(40); // // doodler.line(next.point, Vector.add(to, next.point)) // // } // } // } draw() { for (const car of this.cars) { car.draw(); } } real2Track(length) { return length / this.path.pointSpacing; } }; var TrainCar = class { img; imgWidth; imgHeight; sprite; points; length; constructor(length, img, w, h, sprite) { this.img = img; this.sprite = sprite; this.imgWidth = w; this.imgHeight = h; this.length = length; } draw() { if (!this.points) return; const doodler2 = getContextItem("doodler"); const [a, b] = this.points; const origin = Vector.add(Vector.sub(a, b).div(2), b); const angle = Vector.sub(b, a).heading(); doodler2.drawCircle(origin, 4, { color: "blue" }); doodler2.drawRotated(origin, angle, () => { this.sprite ? doodler2.drawSprite( this.img, this.sprite.at, this.sprite.width, this.sprite.height, origin.copy().sub(this.imgWidth / 2, this.imgHeight / 2), this.imgWidth, this.imgHeight ) : doodler2.drawImage( this.img, origin.copy().sub(this.imgWidth / 2, this.imgHeight / 2) ); }); } }; // train/cars.ts var Tender = class extends TrainCar { constructor() { const resources2 = getContextItem("resources"); super(25, resources2.get("engine-sprites"), 40, 20, { at: new Vector2(80, 0), width: 40, height: 20 }); } }; // train/engines.ts var RedEngine = class extends TrainCar { constructor() { const resources2 = getContextItem("resources"); super(55, resources2.get("engine-sprites"), 80, 20, { at: new Vector(0, 60), width: 80, height: 20 }); } }; // state/states/RunningState.ts var RunningState = class extends State { name = 1 /* RUNNING */; validTransitions = /* @__PURE__ */ new Set([ 2 /* PAUSED */, 3 /* EDIT_TRACK */ ]); layers = []; update(dt) { const ctx2 = getContext(); for (const train of ctx2.trains) { train.move(dt); } } start() { const doodler2 = getContextItem("doodler"); this.layers.push( doodler2.createLayer(() => { const track2 = getContextItem("track"); track2.draw(); }), doodler2.createLayer(() => { const trains = getContextItem("trains"); for (const train of trains) { train.draw(); } }) ); const inputManager2 = getContextItem("inputManager"); const track = getContextItem("track"); const ctx2 = getContext(); inputManager2.onKey(" ", () => { const train = new Train(track.path, [new RedEngine(), new Tender()]); ctx2.trains.push(train); }); inputManager2.onKey("ArrowUp", () => { const trains = getContextItem("trains"); for (const train of trains) { train.speed += 1; } }); inputManager2.onKey("ArrowDown", () => { const trains = getContextItem("trains"); for (const train of trains) { train.speed -= 1; } }); } stop() { for (const layer of this.layers) { getContextItem("doodler").deleteLayer(layer); } } }; // inputs.ts function bootstrapInputs() { const inputManager2 = getContextItem("inputManager"); inputManager2.onKey("e", () => { const state2 = getContextItem("state"); state2.transitionTo(3 /* EDIT_TRACK */); }); inputManager2.onKey("Delete", () => { if (inputManager2.getKeyState("Control")) { localStorage.removeItem("track"); } }); } // state/states/LoadState.ts var LoadState = class extends State { name = 0 /* LOAD */; validTransitions = /* @__PURE__ */ new Set([ 1 /* RUNNING */ ]); layers = []; update() { } start() { const track = this.loadTrack() ?? new TrackSystem([new StraightTrack()]); setContextItem("track", track); const trains = this.loadTrains() ?? []; setContextItem("trains", trains); const resources2 = new ResourceManager(); setContextItem("resources", resources2); const inputManager2 = new InputManager(); setContextItem("inputManager", inputManager2); bootstrapInputs(); resources2.set("engine-sprites", new Image()); resources2.get("engine-sprites").src = "/sprites/EngineSprites.png"; resources2.ready().then(() => { this.stateMachine.transitionTo(1 /* RUNNING */); }); const doodler2 = getContextItem("doodler"); this.layers.push(doodler2.createLayer((_, __, dTime) => { doodler2.clearRect(new Vector(0, 0), doodler2.width, doodler2.height); doodler2.fillRect(new Vector(0, 0), doodler2.width, doodler2.height, { color: "#302040" }); })); } stop() { } loadTrack() { const track = TrackSystem.deserialize( JSON.parse(localStorage.getItem("track") || "[]") ); return track; } loadTrains() { const trains = JSON.parse(localStorage.getItem("trains") || "[]"); return trains; } }; // state/states/index.ts function bootstrapGameStateMachine() { const stateMachine = new StateMachine(); stateMachine.addState(new LoadState(stateMachine)); stateMachine.addState(new RunningState(stateMachine)); stateMachine.addState(new PausedState(stateMachine)); stateMachine.addState(new EditTrackState(stateMachine)); stateMachine.addState(new EditTrainState(stateMachine)); stateMachine.transitionTo(0 /* LOAD */); return stateMachine; } // GameLoop.ts var GameLoop = class { lastTime; running; targetFps; constructor(targetFps = 60) { this.lastTime = performance.now(); this.running = false; this.targetFps = targetFps; } async start(state2) { if (this.running) return; this.running = true; this.lastTime = performance.now(); while (this.running) { const currentTime = performance.now(); const deltaTime = (currentTime - this.lastTime) / 1e3; this.lastTime = currentTime; try { await state2.update(deltaTime); } catch (error) { console.error("Error in game loop:", error); this.stop(); break; } await new Promise((resolve) => setTimeout(resolve, 0)); } } stop() { this.running = false; } }; // main.ts var inputManager = new InputManager(); var resources = new ResourceManager(); var doodler = new ZoomableDoodler({ fillScreen: true, bg: "#302040" }); doodler.ctx.imageSmoothingEnabled = false; var colors = [ "red", "orange", "yellow", "green", "blue", "indigo", "purple", "violet" ]; setDefaultContext({ inputManager, doodler, resources, debug: true, showEnds: true, colors }); var state = bootstrapGameStateMachine(); setContextItem("state", state); doodler.init(); 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"); } }); setInterval(() => { const doodler2 = getContextItem("doodler"); const frameRate = doodler2.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"; }, 1e3); var gameLoop = new GameLoop(); gameLoop.start(state); })();