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:
parent
e608c5b205
commit
44b837676c
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue