From 44b837676c2d82cf84858af88c934cdd846767ea Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Fri, 19 Jun 2026 17:05:47 -0600 Subject: [PATCH] feat: implement click-and-draw road routing in Mini Motorways - Replace drag-to-draw mode with click-and-drag path drawing - Add A* pathfinding for drawing roads between points - Support drawing roads along passable terrain (land without obstacles) - Add visual preview of planned path with cost-based color coding - Update UI hints to reflect new interaction model - Cancel drawing with ESC without changing tool state --- .../games/minimotorways/MiniMotorwaysGame.js | 168 +++++++++++++++++- 1 file changed, 160 insertions(+), 8 deletions(-) diff --git a/public/src/games/minimotorways/MiniMotorwaysGame.js b/public/src/games/minimotorways/MiniMotorwaysGame.js index a1724ab..437a65e 100644 --- a/public/src/games/minimotorways/MiniMotorwaysGame.js +++ b/public/src/games/minimotorways/MiniMotorwaysGame.js @@ -68,8 +68,11 @@ export default class MiniMotorwaysGame extends Phaser.Scene { this.carSprites = new Map(); this.carRender = new Map(); this.shimmerCells = []; - this.dragMode = null; // 'draw' | 'erase' | null + this.dragMode = null; // 'erase' | null this.dragCell = null; + this.drawStart = null; + this.drawPath = []; + this.lastPathCell = null; } create() { @@ -658,7 +661,7 @@ export default class MiniMotorwaysGame extends Phaser.Scene { } this.hintText = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT - 16, - 'Drag to draw roads · Right-drag to erase · Esc cancels a tool', { + 'Click & drag to route roads · Right-drag to erase · Esc cancels tool', { fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.textDarkHex, }).setOrigin(0.5).setAlpha(0.55); this.uiRoot.add(this.hintText); @@ -765,6 +768,9 @@ export default class MiniMotorwaysGame extends Phaser.Scene { setTool(tool) { this.tool = tool; + this.drawStart = null; + this.drawPath = []; + this.lastPathCell = null; this.ghostG.clear(); for (const rec of Object.values(this.chips)) { rec.active = (tool === 'eraser' && rec.key === 'eraser') @@ -806,11 +812,20 @@ export default class MiniMotorwaysGame extends Phaser.Scene { // ── Input ───────────────────────────────────────────────────────────────────── wireInput() { - this.input.keyboard.on('keydown-ESC', () => this.setTool(null)); + this.input.keyboard.on('keydown-ESC', () => { + if (this.drawStart !== null) { + this.drawStart = null; + this.drawPath = []; + this.lastPathCell = null; + this.ghostG.clear(); + return; + } + this.setTool(null); + }); this.input.on('pointerdown', (pointer, over) => { if (this.view !== 'play' || this.overlayUp || !this.sim || this.sim.paused || this.sim.gameOver) return; - if (over && over.length) return; // a HUD element took it + if (over && over.length) return; const cell = this.worldCell(pointer); if (cell === null) return; @@ -830,9 +845,13 @@ export default class MiniMotorwaysGame extends Phaser.Scene { } if (this.tool) { this.useToolAt(cell); return; } - this.dragMode = 'draw'; - this.dragCell = cell; - this.tryPaint(cell); + const sim = this.sim; + const startable = sim.roads.has(cell) + || (sim.terrain[cell] === TERRAIN.LAND && !sim.occupiedAt(cell)); + if (!startable) return; + this.drawStart = cell; + this.drawPath = [cell]; + this.lastPathCell = cell; }); this.input.on('pointermove', (pointer) => { @@ -841,10 +860,22 @@ export default class MiniMotorwaysGame extends Phaser.Scene { if (this.dragMode && pointer.isDown && cell !== null) { this.stepDragTo(cell); } + if (this.drawStart !== null && pointer.isDown && cell !== null && cell !== this.lastPathCell) { + this.lastPathCell = cell; + const path = this.findDrawPath(this.drawStart, cell); + this.drawPath = path ?? [this.drawStart]; + } this.updateGhost(cell); }); - const endDrag = () => { this.dragMode = null; this.dragCell = null; }; + const endDrag = () => { + if (this.drawStart !== null) this.buildDrawPath(); + this.dragMode = null; + this.dragCell = null; + this.drawStart = null; + this.drawPath = []; + this.lastPathCell = null; + }; this.input.on('pointerup', endDrag); this.input.on('pointerupoutside', endDrag); @@ -852,6 +883,112 @@ export default class MiniMotorwaysGame extends Phaser.Scene { this.input.keyboard.on('keydown-TWO', () => this.pickUpgradeKey(1)); } + findDrawPath(from, to) { + const sim = this.sim; + if (from === to) return [from]; + + const isPassable = (k) => sim.roads.has(k) + || (sim.terrain[k] === TERRAIN.LAND && !sim.occupiedAt(k)); + + // State = cell * 9 + dirIndex (0-7 = one of DIRS, 8 = "no prior direction") + // Primary cost: new road tiles placed (integer). + // Tiebreaker: tiny penalty per direction change so straight/diagonal runs win. + const ND = 9; + const TURN_COST = 0.001; // << 1, so never overrides tile-count optimality + const stateCount = WORLD_W * WORLD_H * ND; + const dist = new Float64Array(stateCount).fill(Infinity); + const par = new Int32Array(stateCount).fill(-1); + + const startState = from * ND + 8; + dist[startState] = 0; + + const settled = new Uint8Array(stateCount); + const heap = [startState]; + + const swap = (a, b) => { const t = heap[a]; heap[a] = heap[b]; heap[b] = t; }; + const siftUp = (i) => { + while (i > 0) { + const p = (i - 1) >> 1; + if (dist[heap[p]] <= dist[heap[i]]) break; + swap(p, i); i = p; + } + }; + const siftDown = (i) => { + const len = heap.length; + for (;;) { + let s = i; + const l = 2 * i + 1; const r = l + 1; + if (l < len && dist[heap[l]] < dist[heap[s]]) s = l; + if (r < len && dist[heap[r]] < dist[heap[s]]) s = r; + if (s === i) break; + swap(s, i); i = s; + } + }; + const heapPop = () => { + const top = heap[0]; + const last = heap.pop(); + // Only replace root if heap still has elements — avoids the single-element + // infinite loop where heap.pop() empties the array then heap[0]=last refills it. + if (heap.length > 0) { heap[0] = last; siftDown(0); } + return top; + }; + + while (heap.length > 0) { + const state = heapPop(); + if (settled[state]) continue; + settled[state] = 1; + + const cur = (state / ND) | 0; + const curDir = state % ND; + const cd = dist[state]; + + const cx = xOf(cur); const cy = yOf(cur); + for (let di = 0; di < 8; di++) { + const [dx, dy] = DIRS[di]; + const nx = cx + dx; const ny = cy + dy; + if (nx < 0 || nx >= WORLD_W || ny < 0 || ny >= WORLD_H) continue; + const nk = keyOf(nx, ny); + if (!isPassable(nk)) continue; + const cellCost = sim.roads.has(nk) ? 0 : 1; + const turnCost = (curDir !== 8 && di !== curDir) ? TURN_COST : 0; + const nd = cd + cellCost + turnCost; + const nState = nk * ND + di; + if (nd < dist[nState]) { + dist[nState] = nd; + par[nState] = state; + heap.push(nState); + siftUp(heap.length - 1); + } + } + } + + // Find the cheapest arrival direction at the destination. + let bestState = -1; let bestDist = Infinity; + for (let di = 0; di < ND; di++) { + const s = to * ND + di; + if (dist[s] < bestDist) { bestDist = dist[s]; bestState = s; } + } + if (bestState === -1 || bestDist === Infinity) return null; + + const path = []; + let s = bestState; + while (s !== -1) { path.unshift((s / ND) | 0); s = par[s]; } + return path; + } + + buildDrawPath() { + if (!this.drawPath || this.drawPath.length === 0) return; + let painted = false; + for (const cell of this.drawPath) { + if (this.sim.roads.has(cell)) continue; + if (this.sim.canPlaceRoad(cell)) { + this.sim.placeRoad(cell); + painted = true; + } + } + if (painted) playSound(this, SFX.PIECE_CLICK); + } + worldCell(pointer) { const p = pointer.positionToCamera(this.cameras.main); const x = Math.floor(p.x / CELL); const y = Math.floor(p.y / CELL); @@ -944,6 +1081,21 @@ export default class MiniMotorwaysGame extends Phaser.Scene { const t = this.tool; if (!t) { + if (this.drawStart !== null && this.drawPath.length > 0) { + let budget = sim.stock.roads; + for (const k of this.drawPath) { + const isNew = !sim.roads.has(k); + if (isNew) budget--; + const affordable = !isNew || budget >= 0; + const px = xOf(k) * CELL; const py = yOf(k) * CELL; + g.fillStyle(affordable ? 0x57d977 : 0xe35050, isNew ? 0.48 : 0.2); + g.fillRoundedRect(px + 4, py + 4, CELL - 8, CELL - 8, 12); + } + // Highlight the start cell + g.fillStyle(0xffffff, 0.55); + g.fillCircle(cellCx(this.drawStart), cellCy(this.drawStart), CELL * 0.22); + return; + } const ok = sim.canPlaceRoad(cell) || sim.roads.has(cell); g.fillStyle(ok ? 0xffffff : 0xe35050, ok ? 0.22 : 0.2); g.fillRoundedRect(x + 4, y + 4, CELL - 8, CELL - 8, 12);