1197 lines
42 KiB
JavaScript
1197 lines
42 KiB
JavaScript
// Mini Motorways — pure simulation logic. No Phaser imports; runs in Node for
|
|
// headless verification (server/scripts/verifyMiniMotorways.js) and in the
|
|
// browser scene. All distances are in grid cells, all times in milliseconds.
|
|
|
|
export const WORLD_W = 40;
|
|
export const WORLD_H = 24;
|
|
|
|
export const TERRAIN = { LAND: 0, WATER: 1, TREE: 2 };
|
|
|
|
export const COLOR_NAMES = ['red', 'blue', 'yellow', 'green', 'purple', 'orange'];
|
|
export const COLOR_HEX = {
|
|
red: 0xe4574c, blue: 0x4a90d9, yellow: 0xf0b429,
|
|
green: 0x55b86a, purple: 0x9b6dd6, orange: 0xee8a3c,
|
|
};
|
|
|
|
export const TUNE = {
|
|
WEEK_MS: 55000,
|
|
SUBSTEP_MS: 50,
|
|
CAR_CAP: 60,
|
|
HOUSE_CAP: 30,
|
|
BUILDING_CAP: 12,
|
|
CAR_SPEED: 3.0, // cells / second
|
|
HEADWAY: 0.65, // minimum gap behind the car ahead, in cells
|
|
DWELL_MS: 1000,
|
|
COOLDOWN_MS: 2000,
|
|
PIN_MS_BASE: 12500, // pin interval = max(MIN, BASE * DECAY^week)
|
|
PIN_MS_DECAY: 0.96,
|
|
PIN_MS_MIN: 3500,
|
|
PIN_GRACE_MS: 15000, // new buildings wait this long before pin #1
|
|
PIN_CAP: 12,
|
|
OVERFLOW_PINS: 8,
|
|
OVERFLOW_MS: 35000, // full ring → game over
|
|
OVERFLOW_DRAIN: 2, // ring drains at 2x fill rate while pins < 8
|
|
DELIVERY_RELIEF: 0.06, // each delivery knocks the ring down a touch
|
|
HOUSE_MS_BASE: 9000,
|
|
HOUSE_MS_DECAY: 0.95,
|
|
HOUSE_MS_MIN: 5000,
|
|
BUILDING_MS_BASE: 85000,
|
|
BUILDING_MS_DECAY: 0.93,
|
|
BUILDING_MS_MIN: 50000,
|
|
START_ROADS: 30,
|
|
WEEK_ROADS: 12,
|
|
UPGRADE_ROADS: 10,
|
|
MOTORWAY_COST: 2.5, // A* cost of the portal edge
|
|
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
|
|
LIGHT_PHASE_MS: 4000,
|
|
ROUNDABOUT_CAP: 2,
|
|
DISPATCH_MS: 500,
|
|
SECOND_CAR_WEEK: 2, // houses gain a second car from this week on
|
|
COLOR_UNLOCK_WEEKS: [0, 0, 2, 4, 7, 10],
|
|
GROWTH: [
|
|
{ week: 0, w: 20, h: 12 },
|
|
{ week: 3, w: 26, h: 16 },
|
|
{ week: 6, w: 32, h: 20 },
|
|
{ week: 9, w: 40, h: 24 },
|
|
],
|
|
};
|
|
|
|
// Six cities: palette + terrain generator parameters + personality.
|
|
export const CITIES = [
|
|
{
|
|
name: 'Marlow',
|
|
blurb: 'A gentle market town split by one slow river.',
|
|
palette: { land: 0xf4efe6, landAlt: 0xeae3d4, water: 0x8fcde4, accent: 0xf0b429, night: 0x8e9cc8, road: 0xffffff, roadEdge: 0xd8d0c0 },
|
|
gen: { rivers: 1, riverWidth: 1, lakes: 0, lakeSize: 0, treeDensity: 0.02 },
|
|
colorOrder: ['red', 'blue', 'yellow', 'green', 'purple', 'orange'],
|
|
upgradeWeights: { bridge: 1, motorway: 1, light: 1, roundabout: 1, roads: 1.5 },
|
|
},
|
|
{
|
|
name: 'Sandpoint',
|
|
blurb: 'Lake country — build around the water, not through it.',
|
|
palette: { land: 0xf2e8d5, landAlt: 0xe8dcc2, water: 0x5fb8b0, accent: 0xee8a3c, night: 0x96a0c4, road: 0xfffdf7, roadEdge: 0xd9cdb2 },
|
|
gen: { rivers: 0, riverWidth: 1, lakes: 3, lakeSize: 13, treeDensity: 0.015 },
|
|
colorOrder: ['orange', 'blue', 'green', 'red', 'yellow', 'purple'],
|
|
upgradeWeights: { bridge: 1.6, motorway: 1.2, light: 1, roundabout: 1, roads: 1.4 },
|
|
},
|
|
{
|
|
name: 'Twin Forks',
|
|
blurb: 'Two rivers braid through town. Bring bridges.',
|
|
palette: { land: 0xe9eef2, landAlt: 0xdde4ea, water: 0x7fb5d9, accent: 0x5a8fd6, night: 0x8893be, road: 0xffffff, roadEdge: 0xc9d2dc },
|
|
gen: { rivers: 2, riverWidth: 1, lakes: 0, lakeSize: 0, treeDensity: 0.02 },
|
|
colorOrder: ['blue', 'yellow', 'red', 'purple', 'orange', 'green'],
|
|
upgradeWeights: { bridge: 3, motorway: 1, light: 1, roundabout: 1, roads: 1.3 },
|
|
},
|
|
{
|
|
name: 'Cedar Falls',
|
|
blurb: 'Forest roads wind between the pines.',
|
|
palette: { land: 0xe8f0dd, landAlt: 0xdbe7cb, water: 0x74c4cf, accent: 0x4f9e58, night: 0x84a08e, road: 0xfdfff8, roadEdge: 0xc6d4b4 },
|
|
gen: { rivers: 1, riverWidth: 1, lakes: 1, lakeSize: 8, treeDensity: 0.06 },
|
|
colorOrder: ['green', 'red', 'purple', 'blue', 'orange', 'yellow'],
|
|
upgradeWeights: { bridge: 1.4, motorway: 1, light: 1, roundabout: 1.4, roads: 1.5 },
|
|
},
|
|
{
|
|
name: 'Saltmere',
|
|
blurb: 'A wide cold estuary cuts the city in half.',
|
|
palette: { land: 0xe8e8ec, landAlt: 0xdcdce2, water: 0x6f9fc8, accent: 0x8a77c9, night: 0x7d88b4, road: 0xffffff, roadEdge: 0xc8c8d2 },
|
|
gen: { rivers: 1, riverWidth: 3, lakes: 0, lakeSize: 0, treeDensity: 0.01 },
|
|
colorOrder: ['purple', 'yellow', 'blue', 'orange', 'green', 'red'],
|
|
upgradeWeights: { bridge: 2.5, motorway: 1.2, light: 1, roundabout: 1, roads: 1.3 },
|
|
},
|
|
{
|
|
name: 'Solano',
|
|
blurb: 'Dry, sprawling, and fast. Motorway territory.',
|
|
palette: { land: 0xf6e7d3, landAlt: 0xeedbc0, water: 0x66b2c4, accent: 0xd95f43, night: 0xa78f9e, road: 0xfffaf2, roadEdge: 0xe0cdaf },
|
|
gen: { rivers: 0, riverWidth: 1, lakes: 1, lakeSize: 6, treeDensity: 0.025 },
|
|
colorOrder: ['red', 'orange', 'purple', 'green', 'blue', 'yellow'],
|
|
upgradeWeights: { bridge: 0.6, motorway: 2.5, light: 1.2, roundabout: 1, roads: 1.4 },
|
|
},
|
|
];
|
|
|
|
export function mulberry32(seed) {
|
|
let a = seed >>> 0;
|
|
return () => {
|
|
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
};
|
|
}
|
|
|
|
export const keyOf = (x, y) => y * WORLD_W + x;
|
|
export const xOf = (k) => k % WORLD_W;
|
|
export const yOf = (k) => Math.floor(k / WORLD_W);
|
|
const inBounds = (x, y) => x >= 0 && x < WORLD_W && y >= 0 && y < WORLD_H;
|
|
|
|
const SQRT2 = Math.SQRT2;
|
|
const DIRS = [
|
|
[1, 0], [-1, 0], [0, 1], [0, -1],
|
|
[1, 1], [1, -1], [-1, 1], [-1, -1],
|
|
];
|
|
|
|
export function octile(ax, ay, bx, by) {
|
|
const dx = Math.abs(ax - bx);
|
|
const dy = Math.abs(ay - by);
|
|
return Math.max(dx, dy) + (SQRT2 - 1) * Math.min(dx, dy);
|
|
}
|
|
|
|
function centeredRect(w, h) {
|
|
const x0 = Math.floor((WORLD_W - w) / 2);
|
|
const y0 = Math.floor((WORLD_H - h) / 2);
|
|
return { x0, y0, x1: x0 + w - 1, y1: y0 + h - 1, w, h };
|
|
}
|
|
|
|
const START_RECT = centeredRect(TUNE.GROWTH[0].w, TUNE.GROWTH[0].h);
|
|
|
|
// ── Terrain generation ─────────────────────────────────────────────────────────
|
|
|
|
function carveRiver(terrain, rng, width) {
|
|
// Biased random walk from one edge to the opposite one.
|
|
const vertical = rng() < 0.5;
|
|
let x; let y;
|
|
if (vertical) { x = 6 + Math.floor(rng() * (WORLD_W - 12)); y = 0; }
|
|
else { x = 0; y = 4 + Math.floor(rng() * (WORLD_H - 8)); }
|
|
|
|
while (inBounds(x, y)) {
|
|
for (let o = 0; o < width; o++) {
|
|
const wx = vertical ? x + o : x;
|
|
const wy = vertical ? y : y + o;
|
|
if (inBounds(wx, wy)) terrain[keyOf(wx, wy)] = TERRAIN.WATER;
|
|
}
|
|
if (vertical) {
|
|
y += 1;
|
|
if (rng() < 0.42) x += rng() < 0.5 ? -1 : 1;
|
|
x = Math.max(1, Math.min(WORLD_W - 1 - width, x));
|
|
} else {
|
|
x += 1;
|
|
if (rng() < 0.42) y += rng() < 0.5 ? -1 : 1;
|
|
y = Math.max(1, Math.min(WORLD_H - 1 - width, y));
|
|
}
|
|
}
|
|
}
|
|
|
|
function carveLake(terrain, rng, size) {
|
|
const cx = 4 + Math.floor(rng() * (WORLD_W - 8));
|
|
const cy = 3 + Math.floor(rng() * (WORLD_H - 6));
|
|
const blob = [keyOf(cx, cy)];
|
|
const inBlob = new Set(blob);
|
|
terrain[blob[0]] = TERRAIN.WATER;
|
|
while (blob.length < size) {
|
|
const from = blob[Math.floor(rng() * blob.length)];
|
|
const [dx, dy] = DIRS[Math.floor(rng() * 4)];
|
|
const nx = xOf(from) + dx; const ny = yOf(from) + dy;
|
|
if (!inBounds(nx, ny)) continue;
|
|
const k = keyOf(nx, ny);
|
|
if (inBlob.has(k)) continue;
|
|
inBlob.add(k); blob.push(k);
|
|
terrain[k] = TERRAIN.WATER;
|
|
}
|
|
}
|
|
|
|
function waterRunLengths(terrain, x, y) {
|
|
let h = 1; let v = 1;
|
|
for (let i = x - 1; i >= 0 && terrain[keyOf(i, y)] === TERRAIN.WATER; i--) h++;
|
|
for (let i = x + 1; i < WORLD_W && terrain[keyOf(i, y)] === TERRAIN.WATER; i++) h++;
|
|
for (let j = y - 1; j >= 0 && terrain[keyOf(x, j)] === TERRAIN.WATER; j--) v++;
|
|
for (let j = y + 1; j < WORLD_H && terrain[keyOf(x, j)] === TERRAIN.WATER; j++) v++;
|
|
return { h, v };
|
|
}
|
|
|
|
function startRectOk(terrain) {
|
|
let water = 0;
|
|
for (let y = START_RECT.y0; y <= START_RECT.y1; y++) {
|
|
for (let x = START_RECT.x0; x <= START_RECT.x1; x++) {
|
|
if (terrain[keyOf(x, y)] !== TERRAIN.WATER) continue;
|
|
water++;
|
|
// Every water cell crossing the start zone must be bridgeable (≤3 wide)
|
|
// along at least one axis.
|
|
const { h, v } = waterRunLengths(terrain, x, y);
|
|
if (h > 3 && v > 3) return false;
|
|
}
|
|
}
|
|
return water <= START_RECT.w * START_RECT.h * 0.2;
|
|
}
|
|
|
|
function forceFixStartRect(terrain) {
|
|
const cells = [];
|
|
for (let y = START_RECT.y0; y <= START_RECT.y1; y++) {
|
|
for (let x = START_RECT.x0; x <= START_RECT.x1; x++) {
|
|
if (terrain[keyOf(x, y)] === TERRAIN.WATER) cells.push([x, y]);
|
|
}
|
|
}
|
|
for (const [x, y] of cells) {
|
|
const { h, v } = waterRunLengths(terrain, x, y);
|
|
if (h > 3 && v > 3) terrain[keyOf(x, y)] = TERRAIN.LAND;
|
|
}
|
|
const cap = Math.floor(START_RECT.w * START_RECT.h * 0.2);
|
|
const cx = WORLD_W / 2; const cy = WORLD_H / 2;
|
|
let water = cells.filter(([x, y]) => terrain[keyOf(x, y)] === TERRAIN.WATER);
|
|
water.sort((a, b) => octile(a[0], a[1], cx, cy) - octile(b[0], b[1], cx, cy));
|
|
while (water.length > cap) {
|
|
const [x, y] = water.shift();
|
|
terrain[keyOf(x, y)] = TERRAIN.LAND;
|
|
}
|
|
}
|
|
|
|
function genTerrainOnce(city, rng) {
|
|
const terrain = new Uint8Array(WORLD_W * WORLD_H);
|
|
for (let i = 0; i < city.gen.rivers; i++) carveRiver(terrain, rng, city.gen.riverWidth);
|
|
for (let i = 0; i < city.gen.lakes; i++) carveLake(terrain, rng, city.gen.lakeSize);
|
|
for (let k = 0; k < terrain.length; k++) {
|
|
if (terrain[k] === TERRAIN.LAND && rng() < city.gen.treeDensity) terrain[k] = TERRAIN.TREE;
|
|
}
|
|
// Keep a small clearing at the very centre so the first structures always fit.
|
|
for (let y = 10; y <= 13; y++) {
|
|
for (let x = 17; x <= 22; x++) {
|
|
if (terrain[keyOf(x, y)] === TERRAIN.TREE) terrain[keyOf(x, y)] = TERRAIN.LAND;
|
|
}
|
|
}
|
|
return terrain;
|
|
}
|
|
|
|
export function generateCity(cityIndex, seed) {
|
|
const city = CITIES[cityIndex];
|
|
for (let attempt = 0; attempt < 10; attempt++) {
|
|
const rng = mulberry32((seed + attempt * 1000003) >>> 0);
|
|
const terrain = genTerrainOnce(city, rng);
|
|
if (startRectOk(terrain)) return { terrain };
|
|
}
|
|
const terrain = genTerrainOnce(city, mulberry32(seed >>> 0));
|
|
forceFixStartRect(terrain);
|
|
return { terrain };
|
|
}
|
|
|
|
// ── Simulation ─────────────────────────────────────────────────────────────────
|
|
|
|
export class Sim {
|
|
constructor(cityIndex, seed) {
|
|
this.cityIndex = cityIndex;
|
|
this.city = CITIES[cityIndex];
|
|
this.rng = mulberry32(seed >>> 0);
|
|
this.terrain = generateCity(cityIndex, seed).terrain;
|
|
|
|
this.roads = new Set();
|
|
this.bridgeCells = new Set();
|
|
this.bridges = []; // { id, cells: [k...] }
|
|
this.items = new Map(); // k -> { type: 'light' | 'roundabout' }
|
|
this.motorways = []; // { id, a, b }
|
|
this.portals = new Map(); // k -> twin portal k
|
|
|
|
this.houses = []; // { id, color, k, carIds: [] }
|
|
this.buildings = []; // { id, color, k, cells, pins, reserved, ring, pinT, graceT, overflowing }
|
|
this.cars = [];
|
|
this.nextId = 1;
|
|
|
|
this.stock = { roads: TUNE.START_ROADS, bridges: 0, motorways: 0, lights: 0, roundabouts: 0 };
|
|
this.score = 0;
|
|
this.week = 0;
|
|
this.weekT = 0;
|
|
this.time = 0;
|
|
this.paused = false;
|
|
this.gameOver = false;
|
|
this.gameOverInfo = null;
|
|
this.upgradeChoices = null;
|
|
|
|
this.netVersion = 0;
|
|
this.connCache = new Map();
|
|
this.cellOcc = new Map(); // k -> count of active cars on the cell
|
|
this.dispatchT = 0;
|
|
this.events = [];
|
|
|
|
this.growthIdx = 0;
|
|
this.activeRect = centeredRect(TUNE.GROWTH[0].w, TUNE.GROWTH[0].h);
|
|
this.colorsUnlocked = 0;
|
|
|
|
// Cities with water start with one bridge in hand so an early river spawn
|
|
// can't be an unwinnable death sentence.
|
|
if (this.terrain.includes(TERRAIN.WATER)) this.stock.bridges = 1;
|
|
|
|
this.startComponent = this.computeStartComponent();
|
|
this.houseT = this.houseInterval();
|
|
this.buildingT = this.buildingInterval();
|
|
|
|
// Week 0 opens with two colours, each seeded with a destination and homes.
|
|
this.unlockColors(2);
|
|
}
|
|
|
|
// ── Small helpers ────────────────────────────────────────────────────────────
|
|
|
|
emit(type, data = {}) { this.events.push({ type, ...data }); }
|
|
|
|
houseInterval() { return Math.max(TUNE.HOUSE_MS_MIN, TUNE.HOUSE_MS_BASE * TUNE.HOUSE_MS_DECAY ** this.week); }
|
|
buildingInterval() { return Math.max(TUNE.BUILDING_MS_MIN, TUNE.BUILDING_MS_BASE * TUNE.BUILDING_MS_DECAY ** this.week); }
|
|
pinInterval() { return Math.max(TUNE.PIN_MS_MIN, TUNE.PIN_MS_BASE * TUNE.PIN_MS_DECAY ** this.week); }
|
|
carsPerHouse() { return this.week >= TUNE.SECOND_CAR_WEEK ? 2 : 1; }
|
|
lightPhase() { return Math.floor(this.time / TUNE.LIGHT_PHASE_MS) % 2; }
|
|
|
|
computeStartComponent() {
|
|
// Flood fill over non-water from the centre: early spawns stay on the
|
|
// starting landmass so the player is never forced to bridge in week 0.
|
|
const seen = new Set();
|
|
let seed = null;
|
|
for (let r = 0; r < 10 && seed === null; r++) {
|
|
for (let dy = -r; dy <= r && seed === null; dy++) {
|
|
for (let dx = -r; dx <= r; dx++) {
|
|
const x = 20 + dx; const y = 12 + dy;
|
|
if (inBounds(x, y) && this.terrain[keyOf(x, y)] !== TERRAIN.WATER) { seed = keyOf(x, y); break; }
|
|
}
|
|
}
|
|
}
|
|
if (seed === null) return seen;
|
|
const stack = [seed];
|
|
seen.add(seed);
|
|
while (stack.length) {
|
|
const k = stack.pop();
|
|
const x = xOf(k); const y = yOf(k);
|
|
for (let d = 0; d < 4; d++) {
|
|
const nx = x + DIRS[d][0]; const ny = y + DIRS[d][1];
|
|
if (!inBounds(nx, ny)) continue;
|
|
const nk = keyOf(nx, ny);
|
|
if (seen.has(nk) || this.terrain[nk] === TERRAIN.WATER) continue;
|
|
seen.add(nk);
|
|
stack.push(nk);
|
|
}
|
|
}
|
|
return seen;
|
|
}
|
|
|
|
occupiedAt(k) {
|
|
for (const h of this.houses) if (h.k === k) return { type: 'house', ref: h };
|
|
for (const b of this.buildings) if (b.cells.includes(k)) return { type: 'building', ref: b };
|
|
return null;
|
|
}
|
|
|
|
buildOccupiedSet() {
|
|
const s = new Set();
|
|
for (const h of this.houses) s.add(h.k);
|
|
for (const b of this.buildings) for (const c of b.cells) s.add(c);
|
|
return s;
|
|
}
|
|
|
|
// ── Road network ─────────────────────────────────────────────────────────────
|
|
|
|
touchNetwork() { this.netVersion++; this.connCache.clear(); }
|
|
|
|
roadNeighbors(k) {
|
|
const x = xOf(k); const y = yOf(k);
|
|
const out = [];
|
|
for (const [dx, dy] of DIRS) {
|
|
const nx = x + dx; const ny = y + dy;
|
|
if (!inBounds(nx, ny)) continue;
|
|
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.
|
|
const cornerA = keyOf(x + dx, y);
|
|
const cornerB = keyOf(x, y + dy);
|
|
if (this.roads.has(cornerA) && this.roads.has(cornerB)) continue;
|
|
}
|
|
out.push({ k: nk, cost: dx !== 0 && dy !== 0 ? SQRT2 : 1 });
|
|
}
|
|
const twin = this.portals.get(k);
|
|
if (twin !== undefined) out.push({ k: twin, cost: TUNE.MOTORWAY_COST, motorway: true });
|
|
return out;
|
|
}
|
|
|
|
connCount(k) {
|
|
let n = this.connCache.get(k);
|
|
if (n === undefined) {
|
|
n = this.roadNeighbors(k).length;
|
|
this.connCache.set(k, n);
|
|
}
|
|
return n;
|
|
}
|
|
|
|
canPlaceRoad(k) {
|
|
return this.stock.roads > 0
|
|
&& this.terrain[k] === TERRAIN.LAND
|
|
&& !this.roads.has(k)
|
|
&& !this.occupiedAt(k);
|
|
}
|
|
|
|
placeRoad(k) {
|
|
if (!this.canPlaceRoad(k)) return false;
|
|
this.stock.roads--;
|
|
this.roads.add(k);
|
|
this.touchNetwork();
|
|
return true;
|
|
}
|
|
|
|
eraseRoad(k) {
|
|
if (!this.roads.has(k)) return false;
|
|
if (this.portals.has(k)) return this.eraseMotorwayAt(k);
|
|
if (this.bridgeCells.has(k)) return this.eraseBridgeAt(k);
|
|
this.roads.delete(k);
|
|
const item = this.items.get(k);
|
|
if (item) {
|
|
this.items.delete(k);
|
|
if (item.type === 'light') this.stock.lights++;
|
|
else this.stock.roundabouts++;
|
|
}
|
|
this.stock.roads++;
|
|
this.touchNetwork();
|
|
this.flagReroutes([k]);
|
|
return true;
|
|
}
|
|
|
|
flagReroutes(removed) {
|
|
const gone = new Set(removed);
|
|
for (const car of this.cars) {
|
|
if (car.state !== 'toPickup' && car.state !== 'toHome') continue;
|
|
const from = Math.floor(car.pos);
|
|
for (let i = from; i < car.path.length; i++) {
|
|
if (gone.has(car.path[i])) { car.needsReroute = true; break; }
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Bridges / motorways / intersection items ─────────────────────────────────
|
|
|
|
// cells must be a straight orthogonal run of 1-3 water cells whose two
|
|
// extension cells (just beyond each end) are dry land.
|
|
canPlaceBridge(cells) {
|
|
if (this.stock.bridges < 1) return false;
|
|
if (!cells || cells.length < 1 || cells.length > 3) return false;
|
|
const xs = cells.map(xOf); const ys = cells.map(yOf);
|
|
const horiz = ys.every((y) => y === ys[0]);
|
|
const vert = xs.every((x) => x === xs[0]);
|
|
if (!horiz && !vert) return false;
|
|
const sorted = [...cells].sort((a, b) => a - b);
|
|
for (let i = 1; i < sorted.length; i++) {
|
|
const stepOk = horiz ? sorted[i] === sorted[i - 1] + 1 : sorted[i] === sorted[i - 1] + WORLD_W;
|
|
if (!stepOk) return false;
|
|
}
|
|
for (const k of cells) {
|
|
if (this.terrain[k] !== TERRAIN.WATER || this.roads.has(k)) return false;
|
|
}
|
|
const step = horiz ? 1 : WORLD_W;
|
|
const before = sorted[0] - step;
|
|
const after = sorted[sorted.length - 1] + step;
|
|
const dry = (k) => {
|
|
const x = xOf(k); const y = yOf(k);
|
|
return inBounds(x, y) && this.terrain[k] !== TERRAIN.WATER;
|
|
};
|
|
// Guard against wrap-around on horizontal runs at the map edge.
|
|
if (horiz && (xOf(sorted[0]) === 0 || xOf(sorted[sorted.length - 1]) === WORLD_W - 1)) return false;
|
|
if (!horiz && (yOf(sorted[0]) === 0 || yOf(sorted[sorted.length - 1]) === WORLD_H - 1)) return false;
|
|
return dry(before) && dry(after);
|
|
}
|
|
|
|
placeBridge(cells) {
|
|
if (!this.canPlaceBridge(cells)) return false;
|
|
this.stock.bridges--;
|
|
const bridge = { id: this.nextId++, cells: [...cells] };
|
|
this.bridges.push(bridge);
|
|
for (const k of cells) {
|
|
this.roads.add(k);
|
|
this.bridgeCells.add(k);
|
|
}
|
|
this.touchNetwork();
|
|
return true;
|
|
}
|
|
|
|
eraseBridgeAt(k) {
|
|
const idx = this.bridges.findIndex((b) => b.cells.includes(k));
|
|
if (idx < 0) return false;
|
|
const bridge = this.bridges[idx];
|
|
this.bridges.splice(idx, 1);
|
|
for (const c of bridge.cells) {
|
|
this.roads.delete(c);
|
|
this.bridgeCells.delete(c);
|
|
}
|
|
this.stock.bridges++;
|
|
this.touchNetwork();
|
|
this.flagReroutes(bridge.cells);
|
|
return true;
|
|
}
|
|
|
|
// Motorway portals sit on previously-empty land next to existing road.
|
|
canPlacePortal(k) {
|
|
if (this.terrain[k] !== TERRAIN.LAND || this.roads.has(k) || this.occupiedAt(k)) return false;
|
|
const x = xOf(k); const y = yOf(k);
|
|
for (const [dx, dy] of DIRS) {
|
|
const nx = x + dx; const ny = y + dy;
|
|
if (inBounds(nx, ny) && this.roads.has(keyOf(nx, ny))) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
canPlaceMotorway(a, b) {
|
|
return this.stock.motorways > 0
|
|
&& a !== b
|
|
&& this.canPlacePortal(a) && this.canPlacePortal(b)
|
|
&& octile(xOf(a), yOf(a), xOf(b), yOf(b)) >= TUNE.MOTORWAY_MIN_DIST;
|
|
}
|
|
|
|
placeMotorway(a, b) {
|
|
if (!this.canPlaceMotorway(a, b)) return false;
|
|
this.stock.motorways--;
|
|
this.motorways.push({ id: this.nextId++, a, b });
|
|
this.roads.add(a); this.roads.add(b);
|
|
this.portals.set(a, b); this.portals.set(b, a);
|
|
this.touchNetwork();
|
|
return true;
|
|
}
|
|
|
|
eraseMotorwayAt(k) {
|
|
const idx = this.motorways.findIndex((m) => m.a === k || m.b === k);
|
|
if (idx < 0) return false;
|
|
const m = this.motorways[idx];
|
|
this.motorways.splice(idx, 1);
|
|
this.portals.delete(m.a); this.portals.delete(m.b);
|
|
this.roads.delete(m.a); this.roads.delete(m.b);
|
|
this.stock.motorways++;
|
|
this.touchNetwork();
|
|
this.flagReroutes([m.a, m.b]);
|
|
return true;
|
|
}
|
|
|
|
canPlaceItem(k, type) {
|
|
const stockOk = type === 'light' ? this.stock.lights > 0 : this.stock.roundabouts > 0;
|
|
return stockOk && this.roads.has(k) && !this.items.has(k) && !this.portals.has(k)
|
|
&& this.connCount(k) >= 3;
|
|
}
|
|
|
|
placeItem(k, type) {
|
|
if (!this.canPlaceItem(k, type)) return false;
|
|
if (type === 'light') this.stock.lights--; else this.stock.roundabouts--;
|
|
this.items.set(k, { type });
|
|
return true;
|
|
}
|
|
|
|
eraseItem(k) {
|
|
const item = this.items.get(k);
|
|
if (!item) return false;
|
|
this.items.delete(k);
|
|
if (item.type === 'light') this.stock.lights++; else this.stock.roundabouts++;
|
|
return true;
|
|
}
|
|
|
|
// ── Pathfinding ──────────────────────────────────────────────────────────────
|
|
|
|
roadCellsAround(cells) {
|
|
const out = [];
|
|
const seen = new Set();
|
|
for (const k of Array.isArray(cells) ? cells : [cells]) {
|
|
const x = xOf(k); const y = yOf(k);
|
|
for (const [dx, dy] of DIRS) {
|
|
const nx = x + dx; const ny = y + dy;
|
|
if (!inBounds(nx, ny)) continue;
|
|
const nk = keyOf(nx, ny);
|
|
if (this.roads.has(nk) && !seen.has(nk)) { seen.add(nk); out.push(nk); }
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Multi-source / multi-goal A* over road cells. Returns [k...] or null.
|
|
findPath(starts, goals) {
|
|
if (!starts.length || !goals.length) return null;
|
|
const goalSet = new Set(goals);
|
|
const goalPts = goals.map((g) => [xOf(g), yOf(g)]);
|
|
// Octile distance is inadmissible once motorway teleports exist (a portal
|
|
// can cover 30 cells for cost 2.5), so fall back to Dijkstra then.
|
|
const h = this.portals.size > 0 ? () => 0 : (k) => {
|
|
const x = xOf(k); const y = yOf(k);
|
|
let best = Infinity;
|
|
for (const [gx, gy] of goalPts) {
|
|
const d = octile(x, y, gx, gy);
|
|
if (d < best) best = d;
|
|
}
|
|
return best;
|
|
};
|
|
|
|
const gScore = new Map();
|
|
const cameFrom = new Map();
|
|
const open = []; // binary min-heap of [f, k]
|
|
const push = (f, k) => {
|
|
open.push([f, k]);
|
|
let i = open.length - 1;
|
|
while (i > 0) {
|
|
const p = (i - 1) >> 1;
|
|
if (open[p][0] <= open[i][0]) break;
|
|
[open[p], open[i]] = [open[i], open[p]]; i = p;
|
|
}
|
|
};
|
|
const pop = () => {
|
|
const top = open[0];
|
|
const last = open.pop();
|
|
if (open.length) {
|
|
open[0] = last;
|
|
let i = 0;
|
|
for (;;) {
|
|
const l = 2 * i + 1; const r = l + 1;
|
|
let m = i;
|
|
if (l < open.length && open[l][0] < open[m][0]) m = l;
|
|
if (r < open.length && open[r][0] < open[m][0]) m = r;
|
|
if (m === i) break;
|
|
[open[m], open[i]] = [open[i], open[m]]; i = m;
|
|
}
|
|
}
|
|
return top;
|
|
};
|
|
|
|
for (const s of starts) {
|
|
if (!this.roads.has(s)) continue;
|
|
gScore.set(s, 0);
|
|
push(h(s), s);
|
|
}
|
|
|
|
while (open.length) {
|
|
const [, k] = pop();
|
|
if (goalSet.has(k)) {
|
|
const path = [k];
|
|
let cur = k;
|
|
while (cameFrom.has(cur)) { cur = cameFrom.get(cur); path.push(cur); }
|
|
return path.reverse();
|
|
}
|
|
const gk = gScore.get(k);
|
|
for (const nb of this.roadNeighbors(k)) {
|
|
const occ = this.cellOcc.get(nb.k) || 0;
|
|
const tentative = gk + nb.cost + TUNE.CONGESTION_COST * occ;
|
|
if (tentative < (gScore.get(nb.k) ?? Infinity)) {
|
|
gScore.set(nb.k, tentative);
|
|
cameFrom.set(nb.k, k);
|
|
push(tentative + h(nb.k), nb.k);
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Full trip path for a car leaving home: [houseCell, road..., roadByBuilding].
|
|
findTripPath(house, building) {
|
|
const starts = this.roadCellsAround(house.k);
|
|
const goals = this.roadCellsAround(building.cells);
|
|
const path = this.findPath(starts, goals);
|
|
return path ? [house.k, ...path] : null;
|
|
}
|
|
|
|
findHomePath(fromRoadCell, house) {
|
|
if (!this.roads.has(fromRoadCell)) return null;
|
|
const goals = this.roadCellsAround(house.k);
|
|
const path = this.findPath([fromRoadCell], goals);
|
|
return path ? [...path, house.k] : null;
|
|
}
|
|
|
|
// ── Spawning ─────────────────────────────────────────────────────────────────
|
|
|
|
randomFreeCell(needs2x2) {
|
|
const r = this.activeRect;
|
|
const occupied = this.buildOccupiedSet();
|
|
const fits = (x, y) => {
|
|
if (!inBounds(x, y)) return false;
|
|
const k = keyOf(x, y);
|
|
if (this.terrain[k] !== TERRAIN.LAND || this.roads.has(k) || occupied.has(k)) return false;
|
|
if (this.week < 2 && !this.startComponent.has(k)) return false;
|
|
// One cell of clearance from other structures so driveways stay open.
|
|
for (const [dx, dy] of DIRS) {
|
|
const nx = x + dx; const ny = y + dy;
|
|
if (inBounds(nx, ny) && occupied.has(keyOf(nx, ny))) return false;
|
|
}
|
|
return true;
|
|
};
|
|
for (let attempt = 0; attempt < 90; attempt++) {
|
|
const x = r.x0 + Math.floor(this.rng() * (r.w - (needs2x2 ? 1 : 0)));
|
|
const y = r.y0 + Math.floor(this.rng() * (r.h - (needs2x2 ? 1 : 0)));
|
|
if (needs2x2) {
|
|
if (fits(x, y) && fits(x + 1, y) && fits(x, y + 1) && fits(x + 1, y + 1)) return keyOf(x, y);
|
|
} else if (fits(x, y)) {
|
|
return keyOf(x, y);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
spawnHouse(color, force = false) {
|
|
if (!force && this.houses.length >= TUNE.HOUSE_CAP) return null;
|
|
const k = this.randomFreeCell(false);
|
|
if (k === null) return null;
|
|
const house = { id: this.nextId++, color, k, carIds: [] };
|
|
this.houses.push(house);
|
|
this.emit('houseSpawn', { id: house.id, k, color });
|
|
return house;
|
|
}
|
|
|
|
spawnBuilding(color) {
|
|
if (this.buildings.length >= TUNE.BUILDING_CAP) return null;
|
|
const k = this.randomFreeCell(true);
|
|
if (k === null) return null;
|
|
const cells = [k, k + 1, k + WORLD_W, k + WORLD_W + 1];
|
|
const building = {
|
|
id: this.nextId++, color, k, cells,
|
|
pins: 0, reserved: 0, ring: 0, overflowing: false,
|
|
pinT: this.pinInterval(), graceT: TUNE.PIN_GRACE_MS,
|
|
};
|
|
this.buildings.push(building);
|
|
this.emit('buildingSpawn', { id: building.id, k, color });
|
|
return building;
|
|
}
|
|
|
|
unlockColors(target) {
|
|
while (this.colorsUnlocked < Math.min(target, this.city.colorOrder.length)) {
|
|
const color = this.city.colorOrder[this.colorsUnlocked];
|
|
this.colorsUnlocked++;
|
|
this.spawnBuilding(color);
|
|
// Bypass the house cap: a fresh colour must never start supply-starved.
|
|
this.spawnHouse(color, true);
|
|
this.spawnHouse(color, true);
|
|
this.emit('colorUnlock', { color });
|
|
}
|
|
}
|
|
|
|
pickSpawnColor(forBuilding) {
|
|
const unlocked = this.city.colorOrder.slice(0, this.colorsUnlocked);
|
|
const stats = unlocked.map((color) => ({
|
|
color,
|
|
houses: this.houses.filter((h) => h.color === color).length,
|
|
buildings: this.buildings.filter((b) => b.color === color).length,
|
|
}));
|
|
if (forBuilding) {
|
|
// Destinations follow supply: favour colours with spare houses per stop.
|
|
stats.sort((a, b) => (b.houses / (b.buildings + 1)) - (a.houses / (a.buildings + 1)));
|
|
} else {
|
|
// Houses go where demand is under-served.
|
|
stats.sort((a, b) => (a.houses / Math.max(1, a.buildings)) - (b.houses / Math.max(1, b.buildings)));
|
|
}
|
|
const top = stats.slice(0, 2);
|
|
return top[Math.floor(this.rng() * top.length)].color;
|
|
}
|
|
|
|
// ── Weekly cycle ─────────────────────────────────────────────────────────────
|
|
|
|
activeRectHasWater() {
|
|
const r = this.activeRect;
|
|
for (let y = r.y0; y <= r.y1; y++) {
|
|
for (let x = r.x0; x <= r.x1; x++) {
|
|
if (this.terrain[keyOf(x, y)] === TERRAIN.WATER) return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
pickUpgradeChoices() {
|
|
const weights = { ...this.city.upgradeWeights };
|
|
if (!this.activeRectHasWater()) delete weights.bridge;
|
|
const choices = [];
|
|
for (let pick = 0; pick < 2; pick++) {
|
|
const entries = Object.entries(weights).filter(([key]) => !choices.includes(key));
|
|
let total = 0;
|
|
for (const [, w] of entries) total += w;
|
|
let roll = this.rng() * total;
|
|
for (const [key, w] of entries) {
|
|
roll -= w;
|
|
if (roll <= 0) { choices.push(key); break; }
|
|
}
|
|
if (choices.length < pick + 1) choices.push(entries[entries.length - 1][0]);
|
|
}
|
|
return choices;
|
|
}
|
|
|
|
chooseUpgrade(index) {
|
|
if (!this.upgradeChoices) return;
|
|
const pick = this.upgradeChoices[index] ?? this.upgradeChoices[0];
|
|
if (pick === 'bridge') this.stock.bridges++;
|
|
else if (pick === 'motorway') this.stock.motorways++;
|
|
else if (pick === 'light') this.stock.lights++;
|
|
else if (pick === 'roundabout') this.stock.roundabouts++;
|
|
else this.stock.roads += TUNE.UPGRADE_ROADS;
|
|
this.upgradeChoices = null;
|
|
this.paused = false;
|
|
}
|
|
|
|
rollWeek() {
|
|
this.week++;
|
|
this.stock.roads += TUNE.WEEK_ROADS;
|
|
|
|
const growth = TUNE.GROWTH.findIndex((g) => g.week === this.week);
|
|
if (growth > this.growthIdx) {
|
|
this.growthIdx = growth;
|
|
this.activeRect = centeredRect(TUNE.GROWTH[growth].w, TUNE.GROWTH[growth].h);
|
|
this.emit('growth', { rect: { ...this.activeRect }, idx: growth });
|
|
}
|
|
|
|
const unlockTarget = TUNE.COLOR_UNLOCK_WEEKS.filter((w) => w <= this.week).length;
|
|
this.unlockColors(unlockTarget);
|
|
|
|
this.upgradeChoices = this.pickUpgradeChoices();
|
|
this.paused = true;
|
|
this.emit('weekEnd', { week: this.week, choices: [...this.upgradeChoices] });
|
|
}
|
|
|
|
// ── Cars ─────────────────────────────────────────────────────────────────────
|
|
|
|
setCarOcc(car, k) {
|
|
if (car.occK === k) return;
|
|
if (car.occK !== null && car.occK !== undefined) {
|
|
const n = (this.cellOcc.get(car.occK) || 1) - 1;
|
|
if (n <= 0) this.cellOcc.delete(car.occK); else this.cellOcc.set(car.occK, n);
|
|
}
|
|
car.occK = k;
|
|
if (k !== null) this.cellOcc.set(k, (this.cellOcc.get(k) || 0) + 1);
|
|
}
|
|
|
|
syncCarXY(car) {
|
|
if (car.mw) {
|
|
const a = car.path[car.mw.fromIdx]; const b = car.path[car.mw.fromIdx + 1];
|
|
car.x = xOf(a) + (xOf(b) - xOf(a)) * car.mw.t;
|
|
car.y = yOf(a) + (yOf(b) - yOf(a)) * car.mw.t;
|
|
return;
|
|
}
|
|
const i = Math.min(Math.floor(car.pos), car.path.length - 1);
|
|
const j = Math.min(i + 1, car.path.length - 1);
|
|
const f = car.pos - i;
|
|
const a = car.path[i]; const b = car.path[j];
|
|
car.x = xOf(a) + (xOf(b) - xOf(a)) * f;
|
|
car.y = yOf(a) + (yOf(b) - yOf(a)) * f;
|
|
if (a !== b) car.heading = Math.atan2(yOf(b) - yOf(a), xOf(b) - xOf(a));
|
|
}
|
|
|
|
createCar(house) {
|
|
const car = {
|
|
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,
|
|
};
|
|
this.cars.push(car);
|
|
house.carIds.push(car.id);
|
|
return car;
|
|
}
|
|
|
|
houseById(id) { return this.houses.find((h) => h.id === id); }
|
|
buildingById(id) { return this.buildings.find((b) => b.id === id); }
|
|
carById(id) { return this.cars.find((c) => c.id === id); }
|
|
|
|
dispatch() {
|
|
const wanting = this.buildings
|
|
.filter((b) => b.pins - b.reserved > 0)
|
|
.sort((a, b) => (b.ring - a.ring) || (b.pins - a.pins));
|
|
for (const building of wanting) {
|
|
const bx = xOf(building.k); const by = yOf(building.k);
|
|
const homes = this.houses
|
|
.filter((h) => h.color === building.color)
|
|
.sort((a, b) => octile(xOf(a.k), yOf(a.k), bx, by) - octile(xOf(b.k), yOf(b.k), bx, by));
|
|
let assignments = 0;
|
|
let failures = 0;
|
|
for (const house of homes) {
|
|
if (assignments >= 3 || failures >= 8) break;
|
|
if (building.pins - building.reserved <= 0) break;
|
|
let car = house.carIds.map((id) => this.carById(id)).find((c) => c && c.state === 'idle');
|
|
if (!car && house.carIds.length < this.carsPerHouse() && this.cars.length < TUNE.CAR_CAP) {
|
|
car = this.createCar(house);
|
|
}
|
|
if (!car) continue;
|
|
const path = this.findTripPath(house, building);
|
|
if (!path) { failures++; continue; }
|
|
car.state = 'toPickup';
|
|
car.path = path;
|
|
car.pos = 0;
|
|
car.targetId = building.id;
|
|
car.needsReroute = false;
|
|
car.mw = null;
|
|
building.reserved++;
|
|
assignments++;
|
|
this.setCarOcc(car, path[0]);
|
|
this.syncCarXY(car);
|
|
}
|
|
}
|
|
}
|
|
|
|
releaseReservation(car) {
|
|
if (car.targetId === null) return;
|
|
const b = this.buildingById(car.targetId);
|
|
if (b && b.reserved > 0) b.reserved--;
|
|
car.targetId = null;
|
|
}
|
|
|
|
parkAtHome(car) {
|
|
const house = this.houseById(car.houseId);
|
|
car.state = 'cooldown';
|
|
car.cooldownT = TUNE.COOLDOWN_MS;
|
|
car.path = null;
|
|
car.pos = 0;
|
|
car.mw = null;
|
|
car.needsReroute = false;
|
|
if (house) { car.x = xOf(house.k); car.y = yOf(house.k); }
|
|
this.setCarOcc(car, null);
|
|
}
|
|
|
|
goHome(car) {
|
|
const house = this.houseById(car.houseId);
|
|
const here = car.path[car.path.length - 1];
|
|
const path = house && this.roads.has(here) ? this.findHomePath(here, house) : null;
|
|
if (!path) {
|
|
this.emit('stranded', { carId: car.id, x: car.x, y: car.y });
|
|
this.parkAtHome(car);
|
|
return;
|
|
}
|
|
car.state = 'toHome';
|
|
car.path = path;
|
|
car.pos = 0;
|
|
car.mw = null;
|
|
car.needsReroute = false;
|
|
this.setCarOcc(car, path[0]);
|
|
this.syncCarXY(car);
|
|
}
|
|
|
|
reroute(car) {
|
|
car.needsReroute = false;
|
|
const idx = Math.min(Math.round(car.pos), car.path.length - 1);
|
|
let anchor = car.path[idx];
|
|
if (!this.roads.has(anchor)) {
|
|
const back = car.path.slice(0, idx).reverse().find((k) => this.roads.has(k));
|
|
if (back === undefined) {
|
|
if (car.state === 'toPickup') this.releaseReservation(car);
|
|
this.emit('stranded', { carId: car.id, x: car.x, y: car.y });
|
|
this.parkAtHome(car);
|
|
return;
|
|
}
|
|
anchor = back;
|
|
}
|
|
let path = null;
|
|
if (car.state === 'toPickup') {
|
|
const building = this.buildingById(car.targetId);
|
|
if (building) {
|
|
const goals = this.roadCellsAround(building.cells);
|
|
const tail = this.findPath([anchor], goals);
|
|
if (tail) path = tail;
|
|
}
|
|
if (!path) {
|
|
this.releaseReservation(car);
|
|
const house = this.houseById(car.houseId);
|
|
const home = house ? this.findHomePath(anchor, house) : null;
|
|
if (home) { car.state = 'toHome'; path = home; }
|
|
}
|
|
} else {
|
|
const house = this.houseById(car.houseId);
|
|
if (house) path = this.findHomePath(anchor, house);
|
|
}
|
|
if (!path) {
|
|
this.emit('stranded', { carId: car.id, x: car.x, y: car.y });
|
|
this.parkAtHome(car);
|
|
return;
|
|
}
|
|
car.path = path;
|
|
car.pos = 0;
|
|
car.mw = null;
|
|
this.setCarOcc(car, path[0]);
|
|
this.syncCarXY(car);
|
|
}
|
|
|
|
canEnterCell(car, k, fromK) {
|
|
if (this.connCount(k) < 3) return true;
|
|
const item = this.items.get(k);
|
|
const occ = (this.cellOcc.get(k) || 0);
|
|
if (item?.type === 'roundabout') return occ < TUNE.ROUNDABOUT_CAP;
|
|
if (item?.type === 'light') {
|
|
const dx = xOf(k) - xOf(fromK);
|
|
const dy = yOf(k) - yOf(fromK);
|
|
if (dx !== 0 && dy !== 0) return true; // diagonal approaches filter in
|
|
const axis = dy === 0 ? 0 : 1;
|
|
return axis === this.lightPhase();
|
|
}
|
|
return occ === 0;
|
|
}
|
|
|
|
headwayLimit(car, want) {
|
|
let allowed = want;
|
|
const hx = Math.cos(car.heading); const hy = Math.sin(car.heading);
|
|
for (const other of this.cars) {
|
|
if (other === car || other.mw) continue;
|
|
if (other.state !== 'toPickup' && other.state !== 'toHome' && other.state !== 'dwell') continue;
|
|
const ddx = other.x - car.x; const ddy = other.y - car.y;
|
|
const d2 = ddx * ddx + ddy * ddy;
|
|
if (d2 > 4) continue;
|
|
const front = ddx * hx + ddy * hy;
|
|
if (front <= 0.05) continue;
|
|
const lat = Math.abs(-ddx * hy + ddy * hx);
|
|
if (lat > 0.45) continue;
|
|
if (other.state !== 'dwell') {
|
|
// Opposite-direction cars pass through each other — this is what keeps
|
|
// a single road usable both ways without lane simulation.
|
|
const dot = hx * Math.cos(other.heading) + hy * Math.sin(other.heading);
|
|
if (dot < 0.3) continue;
|
|
}
|
|
allowed = Math.min(allowed, front - TUNE.HEADWAY);
|
|
}
|
|
return Math.max(0, allowed);
|
|
}
|
|
|
|
moveCar(car, dtMs) {
|
|
if (car.mw) {
|
|
car.mw.t += dtMs / TUNE.MOTORWAY_MS;
|
|
if (car.mw.t >= 1) {
|
|
car.pos = car.mw.fromIdx + 1;
|
|
car.mw = null;
|
|
this.setCarOcc(car, car.path[Math.round(car.pos)]);
|
|
}
|
|
this.syncCarXY(car);
|
|
return;
|
|
}
|
|
|
|
const want = TUNE.CAR_SPEED * (dtMs / 1000);
|
|
let allowed = this.headwayLimit(car, want);
|
|
if (allowed <= 0.0001) return;
|
|
|
|
const curIdx = Math.round(car.pos);
|
|
const boundary = curIdx + 0.5;
|
|
let target = car.pos + allowed;
|
|
|
|
if (car.pos < boundary && target >= boundary && curIdx + 1 < car.path.length) {
|
|
const fromK = car.path[curIdx];
|
|
const nextK = car.path[curIdx + 1];
|
|
const isPortalJump = this.portals.get(fromK) === nextK
|
|
&& octile(xOf(fromK), yOf(fromK), xOf(nextK), yOf(nextK)) > SQRT2 + 0.01;
|
|
if (isPortalJump) {
|
|
// Snap to the portal mouth, then fly.
|
|
car.pos = curIdx;
|
|
car.mw = { fromIdx: curIdx, t: 0 };
|
|
this.setCarOcc(car, null);
|
|
this.syncCarXY(car);
|
|
return;
|
|
}
|
|
if (!this.canEnterCell(car, nextK, fromK)) target = boundary - 0.01;
|
|
}
|
|
|
|
car.pos = Math.min(target, car.path.length - 1);
|
|
const occIdx = Math.round(car.pos);
|
|
this.setCarOcc(car, car.path[Math.min(occIdx, car.path.length - 1)]);
|
|
this.syncCarXY(car);
|
|
|
|
if (car.pos >= car.path.length - 1 - 0.0001) {
|
|
if (car.state === 'toPickup') {
|
|
car.state = 'dwell';
|
|
car.dwellT = TUNE.DWELL_MS;
|
|
} else if (car.state === 'toHome') {
|
|
this.parkAtHome(car);
|
|
car.state = 'cooldown';
|
|
}
|
|
}
|
|
}
|
|
|
|
updateCar(car, dtMs) {
|
|
switch (car.state) {
|
|
case 'idle':
|
|
return;
|
|
case 'cooldown':
|
|
car.cooldownT -= dtMs;
|
|
if (car.cooldownT <= 0) car.state = 'idle';
|
|
return;
|
|
case 'dwell': {
|
|
car.dwellT -= dtMs;
|
|
if (car.dwellT > 0) return;
|
|
const building = this.buildingById(car.targetId);
|
|
if (building) {
|
|
if (building.pins > 0) building.pins--;
|
|
if (building.reserved > 0) building.reserved--;
|
|
building.ring = Math.max(0, building.ring - TUNE.DELIVERY_RELIEF);
|
|
this.score++;
|
|
this.emit('delivered', {
|
|
carId: car.id, buildingId: building.id, color: car.color,
|
|
x: car.x, y: car.y, score: this.score,
|
|
});
|
|
}
|
|
car.targetId = null;
|
|
this.goHome(car);
|
|
return;
|
|
}
|
|
case 'toPickup':
|
|
case 'toHome':
|
|
if (car.needsReroute) this.reroute(car);
|
|
if (car.state === 'toPickup' || car.state === 'toHome') this.moveCar(car, dtMs);
|
|
}
|
|
}
|
|
|
|
// ── Pins / overflow ──────────────────────────────────────────────────────────
|
|
|
|
updateBuilding(building, dtMs) {
|
|
if (building.graceT > 0) {
|
|
building.graceT -= dtMs;
|
|
} else {
|
|
building.pinT -= dtMs;
|
|
if (building.pinT <= 0) {
|
|
building.pinT += this.pinInterval();
|
|
if (building.pins < TUNE.PIN_CAP) {
|
|
building.pins++;
|
|
this.emit('pinAdded', { buildingId: building.id, pins: building.pins });
|
|
}
|
|
}
|
|
}
|
|
|
|
if (building.pins >= TUNE.OVERFLOW_PINS) {
|
|
if (!building.overflowing) {
|
|
building.overflowing = true;
|
|
this.emit('overflowStart', { buildingId: building.id });
|
|
}
|
|
building.ring += dtMs / TUNE.OVERFLOW_MS;
|
|
if (building.ring >= 1) {
|
|
this.gameOver = true;
|
|
this.gameOverInfo = { buildingId: building.id, k: building.k, week: this.week, score: this.score };
|
|
this.emit('gameOver', { ...this.gameOverInfo });
|
|
}
|
|
} else if (building.overflowing) {
|
|
building.ring -= (dtMs / TUNE.OVERFLOW_MS) * TUNE.OVERFLOW_DRAIN;
|
|
if (building.ring <= 0) {
|
|
building.ring = 0;
|
|
building.overflowing = false;
|
|
this.emit('overflowEnd', { buildingId: building.id });
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Main step ────────────────────────────────────────────────────────────────
|
|
|
|
step(dtMs) {
|
|
this.events = [];
|
|
if (this.paused || this.gameOver) return this.events;
|
|
|
|
let remaining = dtMs;
|
|
while (remaining > 0 && !this.paused && !this.gameOver) {
|
|
const dt = Math.min(TUNE.SUBSTEP_MS, remaining);
|
|
remaining -= dt;
|
|
this.time += dt;
|
|
|
|
// Weekly clock.
|
|
this.weekT += dt;
|
|
if (this.weekT >= TUNE.WEEK_MS) {
|
|
this.weekT -= TUNE.WEEK_MS;
|
|
this.rollWeek(); // pauses the sim for the upgrade choice
|
|
}
|
|
|
|
// Spawning.
|
|
this.houseT -= dt;
|
|
if (this.houseT <= 0) {
|
|
this.houseT = this.houseInterval();
|
|
this.spawnHouse(this.pickSpawnColor(false));
|
|
}
|
|
this.buildingT -= dt;
|
|
if (this.buildingT <= 0) {
|
|
this.buildingT = this.buildingInterval();
|
|
this.spawnBuilding(this.pickSpawnColor(true));
|
|
}
|
|
|
|
// Demand.
|
|
for (const building of this.buildings) {
|
|
this.updateBuilding(building, dt);
|
|
if (this.gameOver) return this.events;
|
|
}
|
|
|
|
// Dispatch.
|
|
this.dispatchT -= dt;
|
|
if (this.dispatchT <= 0) {
|
|
this.dispatchT = TUNE.DISPATCH_MS;
|
|
this.dispatch();
|
|
}
|
|
|
|
// Movement.
|
|
for (const car of this.cars) this.updateCar(car, dt);
|
|
}
|
|
return this.events;
|
|
}
|
|
}
|