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
This commit is contained in:
Brian Fertig 2026-06-19 17:05:47 -06:00
parent e608c5b205
commit 44b837676c
1 changed files with 160 additions and 8 deletions

View File

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