fix: improve road pathfinding junction quality in Mini Motorways

- Add stop-at-road mode to A*: snap to nearest road cell on approach
  instead of routing along the road to the cursor, producing clean T-
  and L-junctions without diagonal shortcuts from unexpected angles
- Tighten diagonal suppression rule: block diagonals when EITHER
  orthogonal intermediary is a road (previously required BOTH),
  preventing spurious diagonal shortcuts at T-junctions that would
  cause perpendicular approaches to fan out to three connections
- Refactor A* path reconstruction into shared helper and add early
  termination to avoid post-search state scanning
This commit is contained in:
Brian Fertig 2026-06-19 18:06:43 -06:00
parent 44b837676c
commit fc7f5f83ca
2 changed files with 34 additions and 26 deletions

View File

@ -890,21 +890,27 @@ export default class MiniMotorwaysGame extends Phaser.Scene {
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.
// State = cell * 9 + dirIndex (0-7 = DIRS entry, 8 = "no prior direction").
// Primary cost: new road tiles. Tiebreaker: tiny turn penalty so straight
// and diagonal runs beat zigzags when tile counts are equal.
const ND = 9;
const TURN_COST = 0.001; // << 1, so never overrides tile-count optimality
const TURN_COST = 0.001;
const stateCount = WORLD_W * WORLD_H * ND;
const dist = new Float64Array(stateCount).fill(Infinity);
const par = new Int32Array(stateCount).fill(-1);
const settled = new Uint8Array(stateCount);
// When the cursor is on an existing road and we're not starting from one,
// treat ANY road cell as the goal instead of routing along the road to the
// cursor. The nearest road cell on a straight/diagonal approach always wins
// (0 turns), so you get a clean T- or L-junction rather than a diagonal
// shortcut that sneaks up to the road from an unexpected angle.
const stopAtRoad = sim.roads.has(to) && !sim.roads.has(from);
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) {
@ -927,11 +933,15 @@ export default class MiniMotorwaysGame extends Phaser.Scene {
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;
};
const reconstruct = (state) => {
const path = [];
let s = state;
while (s !== -1) { path.unshift((s / ND) | 0); s = par[s]; }
return path;
};
while (heap.length > 0) {
const state = heapPop();
@ -939,10 +949,16 @@ export default class MiniMotorwaysGame extends Phaser.Scene {
settled[state] = 1;
const cur = (state / ND) | 0;
const curDir = state % ND;
const cd = dist[state];
// Terminate: reached specific target, or (in road-stop mode) any road cell.
if (cur !== from && (cur === to || (stopAtRoad && sim.roads.has(cur)))) {
return reconstruct(state);
}
const cd = dist[state];
const curDir = state % ND;
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;
@ -962,18 +978,7 @@ export default class MiniMotorwaysGame extends Phaser.Scene {
}
}
// 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;
return null; // target unreachable
}
buildDrawPath() {

View File

@ -383,12 +383,15 @@ export class Sim {
const nk = keyOf(nx, ny);
if (!this.roads.has(nk)) continue;
if (dx !== 0 && dy !== 0) {
// Crossing rule: if both shared corners carry road, the two diagonals
// of this 2x2 block would cross — and the corners already connect the
// cells orthogonally — so the diagonal edge is suppressed.
// Suppress diagonal if either orthogonal intermediary is a road: either
// the X-crossing case (both corners are roads, two diagonals would
// cross) or the T-junction case (one corner is road — traffic already
// flows through that cell, so the diagonal shortcut is spurious and
// would make a perpendicular approach fan out to three connections
// instead of one clean junction).
const cornerA = keyOf(x + dx, y);
const cornerB = keyOf(x, y + dy);
if (this.roads.has(cornerA) && this.roads.has(cornerB)) continue;
if (this.roads.has(cornerA) || this.roads.has(cornerB)) continue;
}
out.push({ k: nk, cost: dx !== 0 && dy !== 0 ? SQRT2 : 1 });
}