From 41818f7a4a24d714841a64f91b0d29ce340e674b Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Fri, 19 Jun 2026 20:38:56 -0600 Subject: [PATCH] refactor(minimotorways): improve roundabout visuals and traffic flow - Redesign roundabout rendering with concentric rings (outer border, road surface, inner border, central island) for clearer visual distinction - Increase ROUNDABOUT_CAP from 2 to 4 to allow more cars simultaneously - Add stuck detection (stuckMs) with FORCE_ADVANCE_MS (6s) timeout to prevent cars from remaining permanently blocked - Implement "don't block the box" logic: cars won't enter bare junctions if their exit cell is occupied, reducing gridlock --- .../games/minimotorways/MiniMotorwaysGame.js | 17 ++++++-- .../games/minimotorways/MiniMotorwaysLogic.js | 40 ++++++++++++++++--- 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/public/src/games/minimotorways/MiniMotorwaysGame.js b/public/src/games/minimotorways/MiniMotorwaysGame.js index 450809d..12234b6 100644 --- a/public/src/games/minimotorways/MiniMotorwaysGame.js +++ b/public/src/games/minimotorways/MiniMotorwaysGame.js @@ -511,10 +511,19 @@ export default class MiniMotorwaysGame extends Phaser.Scene { for (const [k, item] of sim.items) { const cx = cellCx(k); const cy = cellCy(k); if (item.type === 'roundabout') { - g.fillStyle(this.city.palette.land, 1); - g.fillCircle(cx, cy, CELL * 0.16); - g.lineStyle(5, darken(this.city.palette.roadEdge, 0.7), 1); - g.strokeCircle(cx, cy, CELL * 0.16); + const pal = this.city.palette; + // Outer ring border + g.fillStyle(darken(pal.roadEdge, 0.88), 1); + g.fillCircle(cx, cy, CELL * 0.34); + // Ring road surface (covers road cap circles drawn beneath) + g.fillStyle(pal.road, 1); + g.fillCircle(cx, cy, CELL * 0.31); + // Inner ring border + g.fillStyle(darken(pal.roadEdge, 0.82), 1); + g.fillCircle(cx, cy, CELL * 0.18); + // Central island + g.fillStyle(pal.land, 1); + g.fillCircle(cx, cy, CELL * 0.14); } else { g.fillStyle(0x2f2f38, 1); g.fillRoundedRect(cx - 9, cy - 14, 18, 28, 5); diff --git a/public/src/games/minimotorways/MiniMotorwaysLogic.js b/public/src/games/minimotorways/MiniMotorwaysLogic.js index 184314c..3f907ec 100644 --- a/public/src/games/minimotorways/MiniMotorwaysLogic.js +++ b/public/src/games/minimotorways/MiniMotorwaysLogic.js @@ -45,8 +45,9 @@ export const TUNE = { MOTORWAY_MS: 1200, // real traversal time, ignores all traffic MOTORWAY_MIN_DIST: 4, // portals must be at least this far apart CONGESTION_COST: 0.4, // A* edge penalty per car on the target cell + FORCE_ADVANCE_MS: 6000, // after this long stuck, car overrides canEnterCell LIGHT_PHASE_MS: 4000, - ROUNDABOUT_CAP: 2, + ROUNDABOUT_CAP: 4, DISPATCH_MS: 500, SECOND_CAR_WEEK: 2, // houses gain a second car from this week on COLOR_UNLOCK_WEEKS: [0, 0, 3, 5, 8, 11], @@ -859,7 +860,7 @@ export class Sim { id: this.nextId++, color: house.color, houseId: house.id, state: 'idle', path: null, pos: 0, x: xOf(house.k), y: yOf(house.k), heading: 0, dwellT: 0, cooldownT: 0, targetId: null, - needsReroute: false, mw: null, occK: null, + needsReroute: false, mw: null, occK: null, stuckMs: 0, }; this.cars.push(car); house.carIds.push(car.id); @@ -894,6 +895,7 @@ export class Sim { car.state = 'toPickup'; car.path = path; car.pos = 0; + car.stuckMs = 0; car.targetId = building.id; car.needsReroute = false; car.mw = null; @@ -918,6 +920,7 @@ export class Sim { car.cooldownT = TUNE.COOLDOWN_MS; car.path = null; car.pos = 0; + car.stuckMs = 0; car.mw = null; car.needsReroute = false; if (house) { car.x = xOf(house.k); car.y = yOf(house.k); } @@ -936,6 +939,7 @@ export class Sim { car.state = 'toHome'; car.path = path; car.pos = 0; + car.stuckMs = 0; car.mw = null; car.needsReroute = false; this.setCarOcc(car, path[0]); @@ -981,6 +985,7 @@ export class Sim { } car.path = path; car.pos = 0; + car.stuckMs = 0; car.mw = null; this.setCarOcc(car, path[0]); this.syncCarXY(car); @@ -998,7 +1003,18 @@ export class Sim { const axis = dy === 0 ? 0 : 1; return axis === this.lightPhase(); } - return occ === 0; + // Bare junction: only enter if empty, and don't block the box — + // hold outside if our exit is itself a bare occupied junction. + if (occ > 0) return false; + if (car.path && car.path.length > 0) { + const curIdx = Math.round(car.pos); + if (curIdx + 2 < car.path.length) { + const exitK = car.path[curIdx + 2]; + if (this.connCount(exitK) >= 3 && !this.items.has(exitK) + && (this.cellOcc.get(exitK) || 0) > 0) return false; + } + } + return true; } headwayLimit(car, want) { @@ -1031,13 +1047,18 @@ export class Sim { this.setCarOcc(car, car.path[Math.round(car.pos)]); } this.syncCarXY(car); + car.stuckMs = 0; return; } const want = TUNE.CAR_SPEED * (dtMs / 1000); let allowed = this.headwayLimit(car, want); - if (allowed <= 0.0001) return; + if (allowed <= 0.0001) { + car.stuckMs = (car.stuckMs ?? 0) + dtMs; + return; + } + const prevPos = car.pos; const curIdx = Math.round(car.pos); const boundary = curIdx + 0.5; let target = car.pos + allowed; @@ -1053,9 +1074,11 @@ export class Sim { car.mw = { fromIdx: curIdx, t: 0 }; this.setCarOcc(car, null); this.syncCarXY(car); + car.stuckMs = 0; return; } - if (!this.canEnterCell(car, nextK, fromK)) target = boundary - 0.01; + const forceThrough = (car.stuckMs ?? 0) >= TUNE.FORCE_ADVANCE_MS; + if (!forceThrough && !this.canEnterCell(car, nextK, fromK)) target = boundary - 0.01; } car.pos = Math.min(target, car.path.length - 1); @@ -1063,10 +1086,17 @@ export class Sim { this.setCarOcc(car, car.path[Math.min(occIdx, car.path.length - 1)]); this.syncCarXY(car); + if (car.pos > prevPos + 0.0001) { + car.stuckMs = 0; + } else { + car.stuckMs = (car.stuckMs ?? 0) + dtMs; + } + if (car.pos >= car.path.length - 1 - 0.0001) { if (car.state === 'toPickup') { car.state = 'dwell'; car.dwellT = TUNE.DWELL_MS; + car.stuckMs = 0; } else if (car.state === 'toHome') { this.parkAtHome(car); car.state = 'cooldown';