1308 lines
48 KiB
JavaScript
1308 lines
48 KiB
JavaScript
import * as Phaser from 'phaser';
|
|
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
|
import { Button } from '../../ui/Button.js';
|
|
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
|
import { playSound, SFX } from '../../ui/Sounds.js';
|
|
import { api } from '../../services/api.js';
|
|
import {
|
|
WORLD_W, WORLD_H, TERRAIN, TUNE, CITIES, COLOR_HEX,
|
|
Sim, generateCity, keyOf, xOf, yOf,
|
|
} from './MiniMotorwaysLogic.js';
|
|
|
|
const CELL = 64;
|
|
const WORLD_PX_W = WORLD_W * CELL;
|
|
const WORLD_PX_H = WORLD_H * CELL;
|
|
|
|
const DIRS = [
|
|
[1, 0], [-1, 0], [0, 1], [0, -1],
|
|
[1, 1], [1, -1], [-1, 1], [-1, -1],
|
|
];
|
|
const inBounds = (x, y) => x >= 0 && x < WORLD_W && y >= 0 && y < WORLD_H;
|
|
|
|
// World-space depths.
|
|
const D = {
|
|
land: 0, waterFx: 1, roads: 1.5, items: 3, ghost: 3.5,
|
|
structures: 4, cars: 5, motorway: 6, pins: 7, fx: 8, night: 9,
|
|
};
|
|
|
|
const BEST_KEY = (i) => `minimotorways-best-${i}`;
|
|
|
|
const UPGRADE_DEFS = {
|
|
roads: { name: 'More Roads', desc: `+${TUNE.UPGRADE_ROADS} extra road tiles\non top of your weekly batch.` },
|
|
bridge: { name: 'Bridge', desc: 'Span up to 3 tiles of water\nand join two shores.' },
|
|
motorway: { name: 'Motorway', desc: 'An express link between two\nramps — traffic flies over town.' },
|
|
light: { name: 'Traffic Light', desc: 'Meters a busy intersection,\nalternating the right of way.' },
|
|
roundabout: { name: 'Roundabout', desc: 'Keeps a junction flowing —\nno more all-stop pile-ups.' },
|
|
};
|
|
|
|
function mixColor(a, b, t) {
|
|
const ar = (a >> 16) & 255; const ag = (a >> 8) & 255; const ab = a & 255;
|
|
const br = (b >> 16) & 255; const bg = (b >> 8) & 255; const bb = b & 255;
|
|
return ((ar + (br - ar) * t) << 16) | (((ag + (bg - ag) * t) | 0) << 8) | ((ab + (bb - ab) * t) | 0);
|
|
}
|
|
function darken(c, f) {
|
|
const r = Math.round(((c >> 16) & 255) * f);
|
|
const g = Math.round(((c >> 8) & 255) * f);
|
|
const b = Math.round((c & 255) * f);
|
|
return (r << 16) | (g << 8) | b;
|
|
}
|
|
const hexStr = (c) => `#${c.toString(16).padStart(6, '0')}`;
|
|
const cellCx = (k) => (xOf(k) + 0.5) * CELL;
|
|
const cellCy = (k) => (yOf(k) + 0.5) * CELL;
|
|
|
|
export default class MiniMotorwaysGame extends Phaser.Scene {
|
|
constructor() { super('MiniMotorwaysGame'); }
|
|
|
|
init(data) {
|
|
this.gameDef = data.game ?? { slug: 'minimotorways', name: 'Mini Motorways' };
|
|
this.autoCity = data.autoCity ?? null;
|
|
this.view = 'select';
|
|
this.sim = null;
|
|
this.tool = null;
|
|
this.overlayUp = false;
|
|
this.drawnNetVersion = -1;
|
|
this.lastTickAt = 0;
|
|
this.lastPaintSound = 0;
|
|
this.houseSprites = new Map();
|
|
this.buildingSprites = new Map();
|
|
this.carSprites = new Map();
|
|
this.carRender = new Map();
|
|
this.shimmerCells = [];
|
|
this.dragMode = null; // 'draw' | 'erase' | null
|
|
this.dragCell = null;
|
|
}
|
|
|
|
create() {
|
|
try {
|
|
const music = this.cache.json.get('music');
|
|
if (music?.tracks) this.music = new MusicPlayer(this, music.tracks);
|
|
} catch (_) { /* optional */ }
|
|
if (this.input.mouse) this.input.mouse.disableContextMenu();
|
|
|
|
if (this.autoCity !== null) this.startGame(this.autoCity);
|
|
else this.showCitySelect();
|
|
}
|
|
|
|
// ── City select ───────────────────────────────────────────────────────────────
|
|
|
|
showCitySelect() {
|
|
this.view = 'select';
|
|
const cx = GAME_WIDTH / 2;
|
|
this.add.rectangle(cx, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, COLORS.bg);
|
|
|
|
this.add.text(cx, 96, 'MINI MOTORWAYS', {
|
|
fontFamily: 'Righteous', fontSize: '72px', color: COLORS.goldHex,
|
|
}).setOrigin(0.5);
|
|
this.add.text(cx, 162, 'Draw the roads. Keep the city moving. One overwhelmed destination ends it all.', {
|
|
fontFamily: '"Julius Sans One"', fontSize: '25px', color: COLORS.mutedHex,
|
|
}).setOrigin(0.5);
|
|
|
|
const CARD_W = 440; const CARD_H = 332; const GAP_X = 48; const GAP_Y = 40;
|
|
const left = cx - (3 * CARD_W + 2 * GAP_X) / 2 + CARD_W / 2;
|
|
const top = 318;
|
|
|
|
CITIES.forEach((city, i) => {
|
|
const x = left + (i % 3) * (CARD_W + GAP_X);
|
|
const y = top + Math.floor(i / 3) * (CARD_H + GAP_Y);
|
|
|
|
const card = this.add.rectangle(x, y, CARD_W, CARD_H, 0x171411)
|
|
.setStrokeStyle(3, city.palette.accent, 0.85);
|
|
|
|
// Miniature of the real map this city generates.
|
|
this.drawCityPreview(i, x, y - 50, 360, 216);
|
|
|
|
this.add.text(x - 170, y + 92, city.name, {
|
|
fontFamily: 'Righteous', fontSize: '34px', color: COLORS.textHex,
|
|
}).setOrigin(0, 0.5);
|
|
|
|
const best = Number(localStorage.getItem(BEST_KEY(i)) ?? 0);
|
|
this.add.text(x + 170, y + 92, best > 0 ? `BEST ${best}` : 'NEW', {
|
|
fontFamily: 'Righteous', fontSize: '26px', color: hexStr(city.palette.accent),
|
|
}).setOrigin(1, 0.5);
|
|
|
|
this.add.text(x - 170, y + 130, city.blurb, {
|
|
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
|
|
}).setOrigin(0, 0.5);
|
|
|
|
card.setInteractive({ useHandCursor: true });
|
|
card.on('pointerover', () => card.setStrokeStyle(5, city.palette.accent, 1));
|
|
card.on('pointerout', () => card.setStrokeStyle(3, city.palette.accent, 0.85));
|
|
card.on('pointerup', () => this.startGame(i));
|
|
});
|
|
|
|
const back = new Button(this, cx, GAME_HEIGHT - 56, 'Back', () => this.scene.start('GameMenu'),
|
|
{ variant: 'ghost', width: 220, height: 56, fontSize: 24 });
|
|
this.add.existing(back);
|
|
}
|
|
|
|
drawCityPreview(cityIndex, cx, cy, w, h) {
|
|
const city = CITIES[cityIndex];
|
|
const { terrain } = generateCity(cityIndex, 7000 + cityIndex * 131);
|
|
const g = this.add.graphics();
|
|
const sx = w / WORLD_W; const sy = h / WORLD_H;
|
|
const ox = cx - w / 2; const oy = cy - h / 2;
|
|
g.fillStyle(city.palette.land, 1);
|
|
g.fillRoundedRect(ox, oy, w, h, 10);
|
|
for (let y = 0; y < WORLD_H; y++) {
|
|
for (let x = 0; x < WORLD_W; x++) {
|
|
const t = terrain[keyOf(x, y)];
|
|
if (t === TERRAIN.WATER) {
|
|
g.fillStyle(city.palette.water, 1);
|
|
g.fillRect(ox + x * sx, oy + y * sy, sx + 0.8, sy + 0.8);
|
|
} else if (t === TERRAIN.TREE) {
|
|
g.fillStyle(darken(city.palette.landAlt, 0.78), 1);
|
|
g.fillRect(ox + x * sx + sx * 0.25, oy + y * sy + sy * 0.25, sx * 0.5, sy * 0.5);
|
|
}
|
|
}
|
|
}
|
|
// A few hint houses in the city's first colours.
|
|
const rng = (n) => ((Math.sin(cityIndex * 999 + n * 71.7) + 1) / 2);
|
|
for (let i = 0; i < 6; i++) {
|
|
const color = COLOR_HEX[city.colorOrder[i % 2]];
|
|
let px; let py; let guard = 0;
|
|
do {
|
|
px = Math.floor(rng(i * 3 + guard) * WORLD_W);
|
|
py = Math.floor(rng(i * 3 + 1 + guard) * WORLD_H);
|
|
guard++;
|
|
} while (terrain[keyOf(px, py)] !== TERRAIN.LAND && guard < 20);
|
|
g.fillStyle(color, 1);
|
|
g.fillRect(ox + px * sx, oy + py * sy, sx * 1.4, sy * 1.4);
|
|
}
|
|
}
|
|
|
|
// ── Boot a city ───────────────────────────────────────────────────────────────
|
|
|
|
startGame(cityIndex) {
|
|
// Destroy (not just remove) the select screen — removed-but-alive objects
|
|
// keep their input hit areas and would still swallow clicks during play.
|
|
// The music player's buttons live outside the screen flow and must survive.
|
|
const keep = new Set(this.music?._objs ?? []);
|
|
for (const child of [...this.children.list]) {
|
|
if (!keep.has(child)) child.destroy();
|
|
}
|
|
this.view = 'play';
|
|
this.cityIndex = cityIndex;
|
|
this.city = CITIES[cityIndex];
|
|
this.sim = new Sim(cityIndex, (Math.random() * 1e9) | 0);
|
|
this.overlayUp = false;
|
|
this.tool = null;
|
|
this.drawnNetVersion = -1;
|
|
|
|
this.makeTextures();
|
|
this.buildWorld();
|
|
this.buildHud();
|
|
this.wireInput();
|
|
|
|
// Seed sprites for the structures spawned in the Sim constructor.
|
|
for (const h of this.sim.houses) this.ensureHouseSprite(h, false);
|
|
for (const b of this.sim.buildings) this.ensureBuildingSprite(b, false);
|
|
|
|
this.lastTickAt = this.time.now;
|
|
this.simTimer = this.time.addEvent({
|
|
delay: 100, loop: true, callback: () => this.simTick(),
|
|
});
|
|
}
|
|
|
|
fitZoom(rect) {
|
|
return Math.min(GAME_WIDTH / (rect.w * CELL), GAME_HEIGHT / (rect.h * CELL)) * 0.92;
|
|
}
|
|
|
|
buildWorld() {
|
|
this.worldRoot = this.add.container(0, 0);
|
|
this.uiRoot = this.add.container(0, 0);
|
|
|
|
const cam = this.cameras.main;
|
|
cam.setBackgroundColor(darken(this.city.palette.landAlt, 0.9));
|
|
cam.setBounds(-CELL * 2, -CELL * 2, WORLD_PX_W + CELL * 4, WORLD_PX_H + CELL * 4);
|
|
cam.centerOn(WORLD_PX_W / 2, WORLD_PX_H / 2);
|
|
cam.setZoom(this.fitZoom(this.sim.activeRect));
|
|
|
|
this.uiCam = this.cameras.add(0, 0, GAME_WIDTH, GAME_HEIGHT);
|
|
this.uiCam.ignore(this.worldRoot);
|
|
cam.ignore(this.uiRoot);
|
|
// Music controls are screen-space UI: only the UI camera should draw them.
|
|
if (this.music?._objs?.length) cam.ignore(this.music._objs);
|
|
|
|
this.terrainRT = this.add.renderTexture(0, 0, WORLD_PX_W, WORLD_PX_H)
|
|
.setOrigin(0, 0).setDepth(D.land);
|
|
this.worldRoot.add(this.terrainRT);
|
|
this.drawTerrainRT();
|
|
|
|
this.shimmerG = this.add.graphics().setDepth(D.waterFx);
|
|
this.roadsG = this.add.graphics().setDepth(D.roads);
|
|
this.itemsG = this.add.graphics().setDepth(D.items);
|
|
this.motorwayG = this.add.graphics().setDepth(D.motorway);
|
|
this.ghostG = this.add.graphics().setDepth(D.ghost);
|
|
this.worldRoot.add([this.shimmerG, this.roadsG, this.itemsG, this.motorwayG, this.ghostG]);
|
|
|
|
this.nightRect = this.add.rectangle(WORLD_PX_W / 2, WORLD_PX_H / 2, WORLD_PX_W + CELL * 8, WORLD_PX_H + CELL * 8, 0xffffff)
|
|
.setDepth(D.night).setBlendMode(Phaser.BlendModes.MULTIPLY);
|
|
this.worldRoot.add(this.nightRect);
|
|
}
|
|
|
|
// ── Procedural textures ───────────────────────────────────────────────────────
|
|
|
|
makeTextures() {
|
|
for (const [name, color] of Object.entries(COLOR_HEX)) {
|
|
const houseKey = `mm-house-${name}`;
|
|
if (!this.textures.exists(houseKey)) {
|
|
const g = this.add.graphics();
|
|
g.fillStyle(darken(color, 0.72), 1);
|
|
g.fillRoundedRect(2, 2, 44, 44, 10);
|
|
g.fillStyle(color, 1);
|
|
g.fillRoundedRect(2, 2, 44, 40, 10);
|
|
g.fillStyle(darken(color, 0.82), 1);
|
|
g.fillRoundedRect(2, 2, 44, 14, { tl: 10, tr: 10, bl: 0, br: 0 });
|
|
g.fillStyle(0xffffff, 0.92);
|
|
g.fillRoundedRect(19, 28, 10, 14, 3);
|
|
g.generateTexture(houseKey, 48, 48);
|
|
g.destroy();
|
|
}
|
|
const bldgKey = `mm-bldg-${name}`;
|
|
if (!this.textures.exists(bldgKey)) {
|
|
const g = this.add.graphics();
|
|
g.fillStyle(darken(color, 0.68), 1);
|
|
g.fillRoundedRect(4, 8, 104, 100, 14);
|
|
g.fillStyle(color, 1);
|
|
g.fillRoundedRect(4, 4, 104, 100, 14);
|
|
g.fillStyle(darken(color, 0.85), 1);
|
|
g.fillRoundedRect(12, 12, 88, 30, 8);
|
|
g.fillStyle(0xffffff, 0.85);
|
|
for (let wx = 0; wx < 3; wx++) {
|
|
g.fillRoundedRect(22 + wx * 26, 56, 16, 16, 4);
|
|
}
|
|
g.fillStyle(0xffffff, 0.95);
|
|
g.fillRoundedRect(46, 80, 20, 24, 4);
|
|
g.generateTexture(bldgKey, 112, 112);
|
|
g.destroy();
|
|
}
|
|
const carKey = `mm-car-${name}`;
|
|
if (!this.textures.exists(carKey)) {
|
|
const g = this.add.graphics();
|
|
g.fillStyle(darken(color, 0.6), 1);
|
|
g.fillRoundedRect(1, 3, 32, 18, 7);
|
|
g.fillStyle(color, 1);
|
|
g.fillRoundedRect(1, 1, 32, 18, 7);
|
|
g.fillStyle(0xffffff, 0.55);
|
|
g.fillRoundedRect(19, 4, 8, 12, 3);
|
|
g.generateTexture(carKey, 34, 22);
|
|
g.destroy();
|
|
}
|
|
}
|
|
|
|
if (!this.textures.exists('mm-pin')) {
|
|
const g = this.add.graphics();
|
|
g.fillStyle(0xffffff, 1);
|
|
g.fillCircle(7, 7, 7);
|
|
g.generateTexture('mm-pin', 14, 14);
|
|
g.destroy();
|
|
}
|
|
if (!this.textures.exists('mm-dot')) {
|
|
const g = this.add.graphics();
|
|
g.fillStyle(0xffffff, 1);
|
|
g.fillRect(0, 0, 8, 8);
|
|
g.generateTexture('mm-dot', 8, 8);
|
|
g.destroy();
|
|
}
|
|
if (!this.textures.exists('mm-glow')) {
|
|
const g = this.add.graphics();
|
|
for (let i = 14; i >= 1; i--) {
|
|
const t = i / 14;
|
|
g.fillStyle(0xffffff, 0.02 + 0.07 * (1 - t));
|
|
g.fillCircle(56, 56, 56 * t);
|
|
}
|
|
g.generateTexture('mm-glow', 112, 112);
|
|
g.destroy();
|
|
}
|
|
}
|
|
|
|
// ── Terrain / roads / items rendering ─────────────────────────────────────────
|
|
|
|
drawTerrainRT() {
|
|
const pal = this.city.palette;
|
|
const sim = this.sim;
|
|
const g = this.add.graphics();
|
|
|
|
g.fillStyle(darken(pal.land, 0.94), 1);
|
|
g.fillRect(0, 0, WORLD_PX_W, WORLD_PX_H);
|
|
const r = sim.activeRect;
|
|
g.fillStyle(pal.land, 1);
|
|
g.fillRoundedRect(r.x0 * CELL, r.y0 * CELL, r.w * CELL, r.h * CELL, 26);
|
|
|
|
// Soft alternating "field" blocks for texture, barely visible.
|
|
g.fillStyle(pal.landAlt, 0.35);
|
|
for (let y = 0; y < WORLD_H; y += 2) {
|
|
for (let x = (y / 2) % 2 === 0 ? 0 : 2; x < WORLD_W; x += 4) {
|
|
g.fillRoundedRect(x * CELL + 6, y * CELL + 6, CELL * 2 - 12, CELL * 2 - 12, 14);
|
|
}
|
|
}
|
|
|
|
// Water as merged rounded blobs.
|
|
this.shimmerCells = [];
|
|
g.fillStyle(darken(pal.water, 0.86), 1);
|
|
for (let k = 0; k < sim.terrain.length; k++) {
|
|
if (sim.terrain[k] !== TERRAIN.WATER) continue;
|
|
g.fillRoundedRect(xOf(k) * CELL - 7, yOf(k) * CELL - 5, CELL + 14, CELL + 14, 20);
|
|
}
|
|
g.fillStyle(pal.water, 1);
|
|
let wi = 0;
|
|
for (let k = 0; k < sim.terrain.length; k++) {
|
|
if (sim.terrain[k] !== TERRAIN.WATER) continue;
|
|
g.fillRoundedRect(xOf(k) * CELL - 6, yOf(k) * CELL - 6, CELL + 12, CELL + 12, 20);
|
|
if (wi++ % 5 === 0 && this.shimmerCells.length < 70) this.shimmerCells.push(k);
|
|
}
|
|
|
|
// Trees.
|
|
for (let k = 0; k < sim.terrain.length; k++) {
|
|
if (sim.terrain[k] !== TERRAIN.TREE) continue;
|
|
const cx = cellCx(k); const cy = cellCy(k);
|
|
g.fillStyle(0x9a7b58, 1);
|
|
g.fillRoundedRect(cx - 3, cy + 6, 6, 12, 2);
|
|
g.fillStyle(darken(0x7fae6e, 0.92), 1);
|
|
g.fillCircle(cx - 8, cy + 2, 10);
|
|
g.fillCircle(cx + 8, cy + 2, 10);
|
|
g.fillStyle(0x8cba7b, 1);
|
|
g.fillCircle(cx, cy - 6, 12);
|
|
}
|
|
|
|
this.terrainRT.clear();
|
|
this.terrainRT.draw(g, 0, 0);
|
|
g.destroy();
|
|
}
|
|
|
|
redrawRoads() {
|
|
const sim = this.sim;
|
|
const pal = this.city.palette;
|
|
const g = this.roadsG;
|
|
g.clear();
|
|
this.drawnNetVersion = sim.netVersion;
|
|
|
|
const edges = [];
|
|
for (const k of sim.roads) {
|
|
for (const nb of sim.roadNeighbors(k)) {
|
|
if (nb.motorway || nb.k < k) continue;
|
|
edges.push([k, nb.k]);
|
|
}
|
|
}
|
|
|
|
// Extend roads into adjacent house/building cells so the road is visible
|
|
// underneath the structure. Collect struct cells adjacent to roads, then
|
|
// add virtual edges and circle targets.
|
|
const structRoadCells = new Set();
|
|
for (const h of sim.houses) {
|
|
const x = xOf(h.k); const y = yOf(h.k);
|
|
for (const [dx, dy] of DIRS) {
|
|
if (dx === 0 && dy === 0) continue;
|
|
const nx = x + dx; const ny = y + dy;
|
|
if (inBounds(nx, ny) && sim.roads.has(keyOf(nx, ny))) {
|
|
// Diagonal crossing rule
|
|
if (dx !== 0 && dy !== 0) {
|
|
if (sim.roads.has(keyOf(x + dx, y)) && sim.roads.has(keyOf(x, y + dy))) continue;
|
|
}
|
|
const rk = keyOf(nx, ny);
|
|
if (!edges.find(e => (e[0] === rk && e[1] === h.k) || (e[0] === h.k && e[1] === rk))) {
|
|
edges.push([rk, h.k]);
|
|
}
|
|
structRoadCells.add(h.k);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
for (const b of sim.buildings) {
|
|
for (const c of b.cells) {
|
|
if (structRoadCells.has(c)) continue;
|
|
const x = xOf(c); const y = yOf(c);
|
|
for (const [dx, dy] of DIRS) {
|
|
if (dx === 0 && dy === 0) continue;
|
|
const nx = x + dx; const ny = y + dy;
|
|
if (inBounds(nx, ny) && sim.roads.has(keyOf(nx, ny))) {
|
|
if (dx !== 0 && dy !== 0) {
|
|
if (sim.roads.has(keyOf(x + dx, y)) && sim.roads.has(keyOf(x, y + dy))) continue;
|
|
}
|
|
const rk = keyOf(nx, ny);
|
|
if (!edges.find(e => (e[0] === rk && e[1] === c) || (e[0] === c && e[1] === rk))) {
|
|
edges.push([rk, c]);
|
|
}
|
|
structRoadCells.add(c);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Bridge plinths sit under everything.
|
|
for (const k of sim.bridgeCells) {
|
|
g.fillStyle(darken(pal.roadEdge, 0.62), 1);
|
|
g.fillRoundedRect(xOf(k) * CELL + 2, yOf(k) * CELL + 2, CELL - 4, CELL - 4, 14);
|
|
}
|
|
|
|
// Pass 1: outline (wide), pass 2: fill (narrow). Round cap circles at each
|
|
// cell centre make every 8-way junction read as one smooth ribbon.
|
|
g.lineStyle(CELL * 0.62, pal.roadEdge, 1);
|
|
for (const [a, b] of edges) g.lineBetween(cellCx(a), cellCy(a), cellCx(b), cellCy(b));
|
|
g.fillStyle(pal.roadEdge, 1);
|
|
for (const k of sim.roads) g.fillCircle(cellCx(k), cellCy(k), CELL * 0.31);
|
|
for (const k of structRoadCells) g.fillCircle(cellCx(k), cellCy(k), CELL * 0.31);
|
|
|
|
g.lineStyle(CELL * 0.5, pal.road, 1);
|
|
for (const [a, b] of edges) g.lineBetween(cellCx(a), cellCy(a), cellCx(b), cellCy(b));
|
|
g.fillStyle(pal.road, 1);
|
|
for (const k of sim.roads) g.fillCircle(cellCx(k), cellCy(k), CELL * 0.25);
|
|
for (const k of structRoadCells) g.fillCircle(cellCx(k), cellCy(k), CELL * 0.25);
|
|
|
|
// Bridge side rails over the deck.
|
|
for (const k of sim.bridgeCells) {
|
|
const horiz = sim.roads.has(k + 1) || sim.roads.has(k - 1);
|
|
g.lineStyle(4, darken(pal.roadEdge, 0.55), 1);
|
|
const x = xOf(k) * CELL; const y = yOf(k) * CELL;
|
|
if (horiz) {
|
|
g.lineBetween(x, y + 8, x + CELL, y + 8);
|
|
g.lineBetween(x, y + CELL - 8, x + CELL, y + CELL - 8);
|
|
} else {
|
|
g.lineBetween(x + 8, y, x + 8, y + CELL);
|
|
g.lineBetween(x + CELL - 8, y, x + CELL - 8, y + CELL);
|
|
}
|
|
}
|
|
|
|
this.redrawMotorways();
|
|
}
|
|
|
|
redrawMotorways() {
|
|
const g = this.motorwayG;
|
|
const sim = this.sim;
|
|
g.clear();
|
|
const ASPHALT = 0x4d4d59;
|
|
for (const m of sim.motorways) {
|
|
const ax = cellCx(m.a); const ay = cellCy(m.a);
|
|
const bx = cellCx(m.b); const by = cellCy(m.b);
|
|
// Drop shadow gives the elevated feel.
|
|
g.lineStyle(CELL * 0.55, 0x000000, 0.14);
|
|
g.lineBetween(ax + 7, ay + 10, bx + 7, by + 10);
|
|
g.lineStyle(CELL * 0.55, ASPHALT, 1);
|
|
g.lineBetween(ax, ay, bx, by);
|
|
// Dashed centre line.
|
|
const dx = bx - ax; const dy = by - ay;
|
|
const len = Math.hypot(dx, dy);
|
|
const steps = Math.floor(len / 34);
|
|
g.lineStyle(4, 0xf4e26b, 0.9);
|
|
for (let i = 0; i < steps; i++) {
|
|
const t0 = (i + 0.18) / steps; const t1 = (i + 0.55) / steps;
|
|
g.lineBetween(ax + dx * t0, ay + dy * t0, ax + dx * t1, ay + dy * t1);
|
|
}
|
|
// Ramps.
|
|
for (const k of [m.a, m.b]) {
|
|
g.fillStyle(darken(ASPHALT, 0.8), 1);
|
|
g.fillRoundedRect(xOf(k) * CELL + 4, yOf(k) * CELL + 4, CELL - 8, CELL - 8, 12);
|
|
g.fillStyle(0xf4e26b, 0.9);
|
|
g.fillTriangle(cellCx(k) - 8, cellCy(k) + 7, cellCx(k) + 8, cellCy(k) + 7, cellCx(k), cellCy(k) - 9);
|
|
}
|
|
}
|
|
}
|
|
|
|
redrawItems() {
|
|
const g = this.itemsG;
|
|
const sim = this.sim;
|
|
g.clear();
|
|
if (sim.items.size === 0) return;
|
|
const phase = sim.lightPhase();
|
|
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);
|
|
} else {
|
|
g.fillStyle(0x2f2f38, 1);
|
|
g.fillRoundedRect(cx - 9, cy - 14, 18, 28, 5);
|
|
const hOn = phase === 0;
|
|
g.fillStyle(hOn ? 0x57d977 : 0xe35050, 1);
|
|
g.fillCircle(cx, cy - 6, 4.5);
|
|
g.fillStyle(hOn ? 0xe35050 : 0x57d977, 1);
|
|
g.fillCircle(cx, cy + 6, 4.5);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Entity sprites ────────────────────────────────────────────────────────────
|
|
|
|
ensureHouseSprite(house, pop = true) {
|
|
if (this.houseSprites.has(house.id)) return;
|
|
const c = this.add.container(cellCx(house.k), cellCy(house.k)).setDepth(D.structures);
|
|
const img = this.add.image(0, 0, `mm-house-${house.color}`);
|
|
c.add(img);
|
|
this.worldRoot.add(c);
|
|
this.houseSprites.set(house.id, c);
|
|
if (pop) {
|
|
c.setScale(0);
|
|
this.tweens.add({ targets: c, scale: 1, duration: 420, ease: 'Back.easeOut' });
|
|
}
|
|
}
|
|
|
|
ensureBuildingSprite(building, pop = true) {
|
|
if (this.buildingSprites.has(building.id)) return;
|
|
const cx = xOf(building.k) * CELL + CELL;
|
|
const cy = yOf(building.k) * CELL + CELL;
|
|
const c = this.add.container(cx, cy).setDepth(D.structures);
|
|
const img = this.add.image(0, 0, `mm-bldg-${building.color}`);
|
|
c.add(img);
|
|
this.worldRoot.add(c);
|
|
|
|
const pinsC = this.add.container(cx, cy).setDepth(D.pins);
|
|
this.worldRoot.add(pinsC);
|
|
const ringG = this.add.graphics().setDepth(D.pins);
|
|
this.worldRoot.add(ringG);
|
|
|
|
this.buildingSprites.set(building.id, {
|
|
root: c, img, pinsC, ringG, lastPins: -1, pulse: null,
|
|
});
|
|
if (pop) {
|
|
c.setScale(0);
|
|
this.tweens.add({ targets: c, scale: 1, duration: 480, ease: 'Back.easeOut' });
|
|
}
|
|
}
|
|
|
|
syncBuilding(building) {
|
|
const rec = this.buildingSprites.get(building.id);
|
|
if (!rec) return;
|
|
|
|
if (rec.lastPins !== building.pins) {
|
|
rec.lastPins = building.pins;
|
|
rec.pinsC.removeAll(true);
|
|
for (let i = 0; i < building.pins; i++) {
|
|
const row = Math.floor(i / 6); const col = i % 6;
|
|
const dot = this.add.image(-45 + col * 18, -86 - row * 18, 'mm-pin')
|
|
.setTint(COLOR_HEX[building.color]).setScale(i === building.pins - 1 ? 0 : 1);
|
|
rec.pinsC.add(dot);
|
|
if (i === building.pins - 1) {
|
|
this.tweens.add({ targets: dot, scale: 1, duration: 240, ease: 'Back.easeOut' });
|
|
}
|
|
}
|
|
}
|
|
|
|
rec.ringG.clear();
|
|
if (building.ring > 0.001) {
|
|
const cx = xOf(building.k) * CELL + CELL;
|
|
const cy = yOf(building.k) * CELL + CELL;
|
|
rec.ringG.lineStyle(7, 0xe3504f, 0.28);
|
|
rec.ringG.strokeCircle(cx, cy, 84);
|
|
rec.ringG.lineStyle(7, 0xe3504f, 0.95);
|
|
rec.ringG.beginPath();
|
|
rec.ringG.arc(cx, cy, 84, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * Math.min(1, building.ring));
|
|
rec.ringG.strokePath();
|
|
}
|
|
|
|
if (building.overflowing && !rec.pulse) {
|
|
rec.pulse = this.tweens.add({
|
|
targets: rec.root, scale: 1.07, duration: 320, yoyo: true, repeat: -1, ease: 'Sine.easeInOut',
|
|
});
|
|
} else if (!building.overflowing && rec.pulse) {
|
|
rec.pulse.stop();
|
|
rec.pulse = null;
|
|
rec.root.setScale(1);
|
|
}
|
|
}
|
|
|
|
ensureCarSprite(car) {
|
|
let rec = this.carSprites.get(car.id);
|
|
if (!rec) {
|
|
const c = this.add.container(0, 0).setDepth(D.cars).setScale(0.7);
|
|
const img = this.add.image(0, 0, `mm-car-${car.color}`);
|
|
const pin = this.add.image(0, -12, 'mm-pin').setTint(COLOR_HEX[car.color]).setScale(0.8).setVisible(false);
|
|
c.add([img, pin]);
|
|
this.worldRoot.add(c);
|
|
rec = { c, img, pin, rot: 0 };
|
|
this.carSprites.set(car.id, rec);
|
|
}
|
|
return rec;
|
|
}
|
|
|
|
// ── HUD ───────────────────────────────────────────────────────────────────────
|
|
|
|
buildHud() {
|
|
const pal = this.city.palette;
|
|
|
|
this.scoreText = this.add.text(44, 36, '0', {
|
|
fontFamily: 'Righteous', fontSize: '54px', color: COLORS.textDarkHex,
|
|
}).setOrigin(0, 0.5).setShadow(0, 2, '#ffffff', 6);
|
|
this.bestText = this.add.text(46, 82, '', {
|
|
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.textDarkHex,
|
|
}).setOrigin(0, 0.5).setAlpha(0.65);
|
|
this.uiRoot.add([this.scoreText, this.bestText]);
|
|
|
|
this.weekArcG = this.add.graphics();
|
|
this.weekText = this.add.text(GAME_WIDTH / 2, 62, '1', {
|
|
fontFamily: 'Righteous', fontSize: '30px', color: COLORS.textDarkHex,
|
|
}).setOrigin(0.5);
|
|
this.uiRoot.add([this.weekArcG, this.weekText]);
|
|
|
|
// Below the music player controls in the top-right corner.
|
|
const cityLabel = this.add.text(GAME_WIDTH - 44, 122, this.city.name.toUpperCase(), {
|
|
fontFamily: 'Righteous', fontSize: '34px', color: hexStr(darken(pal.accent, 0.8)),
|
|
}).setOrigin(1, 0.5);
|
|
this.uiRoot.add(cityLabel);
|
|
|
|
// Inventory chips.
|
|
this.chips = {};
|
|
const defs = [
|
|
['roads', 'Roads'], ['bridge', 'Bridge'], ['motorway', 'Motorway'],
|
|
['light', 'Light'], ['roundabout', 'Rndabout'], ['eraser', 'Erase'],
|
|
];
|
|
const CHIP = 92; const GAPC = 18;
|
|
const total = defs.length * CHIP + (defs.length - 1) * GAPC;
|
|
let x = GAME_WIDTH / 2 - total / 2 + CHIP / 2;
|
|
const y = GAME_HEIGHT - 78;
|
|
for (const [key, label] of defs) {
|
|
this.chips[key] = this.makeChip(x, y, key, label);
|
|
x += CHIP + GAPC;
|
|
}
|
|
|
|
this.hintText = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT - 16,
|
|
'Drag to draw roads · Right-drag to erase · Esc cancels a tool', {
|
|
fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.textDarkHex,
|
|
}).setOrigin(0.5).setAlpha(0.55);
|
|
this.uiRoot.add(this.hintText);
|
|
|
|
const quit = new Button(this, GAME_WIDTH - 110, GAME_HEIGHT - 56, 'Cities',
|
|
() => this.scene.restart({ game: this.gameDef }),
|
|
{ variant: 'ghost', width: 160, height: 50, fontSize: 20 });
|
|
this.uiRoot.add(quit);
|
|
|
|
this.syncHud();
|
|
}
|
|
|
|
makeChip(x, y, key, label) {
|
|
const c = this.add.container(x, y);
|
|
const bg = this.add.graphics();
|
|
const icon = this.add.graphics();
|
|
this.drawChipIcon(icon, key);
|
|
const count = this.add.text(30, 28, '', {
|
|
fontFamily: 'Righteous', fontSize: '22px', color: '#ffffff',
|
|
}).setOrigin(0.5);
|
|
const lbl = this.add.text(0, 56, label, {
|
|
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.textDarkHex,
|
|
}).setOrigin(0.5).setAlpha(0.8);
|
|
c.add([bg, icon, count, lbl]);
|
|
this.uiRoot.add(c);
|
|
|
|
const rec = { c, bg, icon, count, key, active: false, enabled: true };
|
|
this.paintChip(rec);
|
|
|
|
c.setSize(92, 92);
|
|
c.setInteractive({ useHandCursor: true, hitArea: new Phaser.Geom.Rectangle(0, 0, 92, 92), hitAreaCallback: Phaser.Geom.Rectangle.Contains });
|
|
c.on('pointerover', () => { rec.hover = true; this.paintChip(rec); });
|
|
c.on('pointerout', () => { rec.hover = false; this.paintChip(rec); });
|
|
c.on('pointerdown', () => this.onChip(key));
|
|
return rec;
|
|
}
|
|
|
|
drawChipIcon(g, key) {
|
|
g.clear();
|
|
const PAVE = 0xf5f2ea;
|
|
if (key === 'roads') {
|
|
g.lineStyle(10, PAVE, 1);
|
|
g.lineBetween(-26, 10, -2, 10);
|
|
g.lineBetween(-2, 10, 18, -10);
|
|
g.fillStyle(PAVE, 1);
|
|
g.fillCircle(-26, 10, 5); g.fillCircle(-2, 10, 5); g.fillCircle(18, -10, 5);
|
|
} else if (key === 'bridge') {
|
|
g.lineStyle(5, 0x9fd2e8, 1);
|
|
g.lineBetween(-28, 14, -14, 14); g.lineBetween(-6, 14, 8, 14); g.lineBetween(16, 14, 28, 14);
|
|
g.lineStyle(9, PAVE, 1);
|
|
g.beginPath(); g.arc(0, 26, 34, Math.PI * 1.22, Math.PI * 1.78); g.strokePath();
|
|
} else if (key === 'motorway') {
|
|
g.lineStyle(12, 0x4d4d59, 1);
|
|
g.lineBetween(-26, 12, 26, -12);
|
|
g.lineStyle(3, 0xf4e26b, 1);
|
|
g.lineBetween(-18, 8, -6, 2); g.lineBetween(2, -2, 14, -8);
|
|
} else if (key === 'light') {
|
|
g.fillStyle(0x2f2f38, 1);
|
|
g.fillRoundedRect(-10, -22, 20, 38, 6);
|
|
g.fillStyle(0x57d977, 1); g.fillCircle(0, -12, 6);
|
|
g.fillStyle(0xe35050, 1); g.fillCircle(0, 6, 6);
|
|
} else if (key === 'roundabout') {
|
|
g.lineStyle(10, PAVE, 1);
|
|
g.strokeCircle(0, -2, 16);
|
|
} else if (key === 'eraser') {
|
|
g.fillStyle(0xe8a0a0, 1);
|
|
g.fillRoundedRect(-16, -16, 30, 22, 6);
|
|
g.fillStyle(0xc97e7e, 1);
|
|
g.fillRoundedRect(-16, -2, 30, 8, { tl: 0, tr: 0, bl: 6, br: 6 });
|
|
}
|
|
}
|
|
|
|
paintChip(rec) {
|
|
const { bg } = rec;
|
|
bg.clear();
|
|
const active = rec.active;
|
|
const base = active ? this.city.palette.accent : 0xffffff;
|
|
bg.fillStyle(base, active ? 0.95 : 0.82);
|
|
bg.fillRoundedRect(-46, -46, 92, 92, 18);
|
|
bg.lineStyle(3, rec.hover || active ? darken(this.city.palette.accent, 0.85) : 0x999188, active ? 1 : 0.6);
|
|
bg.strokeRoundedRect(-46, -46, 92, 92, 18);
|
|
rec.c.setAlpha(rec.enabled ? 1 : 0.38);
|
|
// Count badge backdrop.
|
|
bg.fillStyle(0x33302c, 0.92);
|
|
bg.fillCircle(30, 28, 15);
|
|
}
|
|
|
|
onChip(key) {
|
|
if (this.overlayUp || !this.sim || this.sim.gameOver) return;
|
|
if (key === 'roads') return;
|
|
if (key === 'eraser') {
|
|
this.setTool(this.tool === 'eraser' ? null : 'eraser');
|
|
return;
|
|
}
|
|
const stock = {
|
|
bridge: this.sim.stock.bridges, motorway: this.sim.stock.motorways,
|
|
light: this.sim.stock.lights, roundabout: this.sim.stock.roundabouts,
|
|
}[key];
|
|
if (stock < 1) return;
|
|
if (this.tool && this.tool.type === key) { this.setTool(null); return; }
|
|
this.setTool(key === 'motorway' ? { type: 'motorway', stage: 0, a: null } : { type: key });
|
|
playSound(this, SFX.PIECE_CLICK);
|
|
}
|
|
|
|
setTool(tool) {
|
|
this.tool = tool;
|
|
this.ghostG.clear();
|
|
for (const rec of Object.values(this.chips)) {
|
|
rec.active = (tool === 'eraser' && rec.key === 'eraser')
|
|
|| (tool && tool.type === rec.key);
|
|
this.paintChip(rec);
|
|
}
|
|
}
|
|
|
|
syncHud() {
|
|
const sim = this.sim;
|
|
if (!sim) return;
|
|
this.scoreText.setText(String(sim.score));
|
|
const best = Number(localStorage.getItem(BEST_KEY(this.cityIndex)) ?? 0);
|
|
this.bestText.setText(best > 0 ? `BEST ${Math.max(best, sim.score)}` : '');
|
|
this.weekText.setText(String(sim.week + 1));
|
|
|
|
const g = this.weekArcG;
|
|
g.clear();
|
|
const cx = GAME_WIDTH / 2; const cy = 62;
|
|
g.lineStyle(7, 0x000000, 0.12);
|
|
g.strokeCircle(cx, cy, 34);
|
|
g.lineStyle(7, darken(this.city.palette.accent, 0.85), 0.95);
|
|
g.beginPath();
|
|
g.arc(cx, cy, 34, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * (sim.weekT / TUNE.WEEK_MS));
|
|
g.strokePath();
|
|
|
|
const counts = {
|
|
roads: sim.stock.roads, bridge: sim.stock.bridges, motorway: sim.stock.motorways,
|
|
light: sim.stock.lights, roundabout: sim.stock.roundabouts, eraser: null,
|
|
};
|
|
for (const [key, rec] of Object.entries(this.chips)) {
|
|
const n = counts[key];
|
|
rec.count.setText(n === null ? '' : String(n));
|
|
const enabled = key === 'eraser' || key === 'roads' ? true : n > 0;
|
|
if (enabled !== rec.enabled) { rec.enabled = enabled; this.paintChip(rec); }
|
|
}
|
|
}
|
|
|
|
// ── Input ─────────────────────────────────────────────────────────────────────
|
|
|
|
wireInput() {
|
|
this.input.keyboard.on('keydown-ESC', () => 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
|
|
const cell = this.worldCell(pointer);
|
|
if (cell === null) return;
|
|
|
|
if (pointer.rightButtonDown()) {
|
|
if (this.tool) { this.setTool(null); return; }
|
|
this.dragMode = 'erase';
|
|
this.dragCell = cell;
|
|
this.eraseAt(cell);
|
|
return;
|
|
}
|
|
|
|
if (this.tool === 'eraser') {
|
|
this.dragMode = 'erase';
|
|
this.dragCell = cell;
|
|
this.eraseAt(cell);
|
|
return;
|
|
}
|
|
if (this.tool) { this.useToolAt(cell); return; }
|
|
|
|
this.dragMode = 'draw';
|
|
this.dragCell = cell;
|
|
this.tryPaint(cell);
|
|
});
|
|
|
|
this.input.on('pointermove', (pointer) => {
|
|
if (this.view !== 'play' || !this.sim) return;
|
|
const cell = this.worldCell(pointer);
|
|
if (this.dragMode && pointer.isDown && cell !== null) {
|
|
this.stepDragTo(cell);
|
|
}
|
|
this.updateGhost(cell);
|
|
});
|
|
|
|
const endDrag = () => { this.dragMode = null; this.dragCell = null; };
|
|
this.input.on('pointerup', endDrag);
|
|
this.input.on('pointerupoutside', endDrag);
|
|
|
|
this.input.keyboard.on('keydown-ONE', () => this.pickUpgradeKey(0));
|
|
this.input.keyboard.on('keydown-TWO', () => this.pickUpgradeKey(1));
|
|
}
|
|
|
|
worldCell(pointer) {
|
|
const p = pointer.positionToCamera(this.cameras.main);
|
|
const x = Math.floor(p.x / CELL); const y = Math.floor(p.y / CELL);
|
|
if (x < 0 || x >= WORLD_W || y < 0 || y >= WORLD_H) return null;
|
|
return keyOf(x, y);
|
|
}
|
|
|
|
stepDragTo(target) {
|
|
let guard = 0;
|
|
while (this.dragCell !== target && guard++ < 80) {
|
|
const cx = xOf(this.dragCell); const cy = yOf(this.dragCell);
|
|
const dx = Math.sign(xOf(target) - cx);
|
|
const dy = Math.sign(yOf(target) - cy);
|
|
const next = keyOf(cx + dx, cy + dy);
|
|
this.dragCell = next;
|
|
if (this.dragMode === 'draw') this.tryPaint(next);
|
|
else this.eraseAt(next);
|
|
}
|
|
}
|
|
|
|
tryPaint(cell) {
|
|
if (this.sim.canPlaceRoad(cell)) {
|
|
this.sim.placeRoad(cell);
|
|
if (this.time.now - this.lastPaintSound > 60) {
|
|
this.lastPaintSound = this.time.now;
|
|
playSound(this, SFX.PIECE_CLICK);
|
|
}
|
|
}
|
|
}
|
|
|
|
eraseAt(cell) {
|
|
if (this.sim.eraseRoad(cell)) {
|
|
if (this.time.now - this.lastPaintSound > 60) {
|
|
this.lastPaintSound = this.time.now;
|
|
playSound(this, SFX.CARD_PLACE);
|
|
}
|
|
}
|
|
}
|
|
|
|
useToolAt(cell) {
|
|
const sim = this.sim;
|
|
const t = this.tool;
|
|
if (t.type === 'bridge') {
|
|
const span = this.bridgeSpanAt(cell);
|
|
if (span && sim.placeBridge(span)) {
|
|
playSound(this, SFX.CARD_PLACE);
|
|
this.setTool(null);
|
|
}
|
|
} else if (t.type === 'motorway') {
|
|
if (t.stage === 0) {
|
|
if (sim.canPlacePortal(cell)) {
|
|
t.a = cell; t.stage = 1;
|
|
playSound(this, SFX.PIECE_CLICK);
|
|
}
|
|
} else if (sim.canPlaceMotorway(t.a, cell)) {
|
|
sim.placeMotorway(t.a, cell);
|
|
playSound(this, SFX.CARD_PLACE);
|
|
this.setTool(null);
|
|
}
|
|
} else if (t.type === 'light' || t.type === 'roundabout') {
|
|
if (sim.placeItem(cell, t.type)) {
|
|
playSound(this, SFX.CARD_PLACE);
|
|
this.setTool(null);
|
|
}
|
|
}
|
|
}
|
|
|
|
bridgeSpanAt(cell) {
|
|
const sim = this.sim;
|
|
if (sim.terrain[cell] !== TERRAIN.WATER) return null;
|
|
const tryAxis = (step) => {
|
|
const run = [cell];
|
|
for (let k = cell - step; sim.terrain[k] === TERRAIN.WATER && run.length < 5; k -= step) run.unshift(k);
|
|
for (let k = cell + step; sim.terrain[k] === TERRAIN.WATER && run.length < 5; k += step) run.push(k);
|
|
return sim.canPlaceBridge(run) ? run : null;
|
|
};
|
|
const h = tryAxis(1);
|
|
const v = tryAxis(WORLD_W);
|
|
if (h && v) return h.length <= v.length ? h : v;
|
|
return h ?? v;
|
|
}
|
|
|
|
updateGhost(cell) {
|
|
const g = this.ghostG;
|
|
g.clear();
|
|
if (this.view !== 'play' || this.overlayUp || cell === null || !this.sim
|
|
|| this.sim.paused || this.sim.gameOver) return;
|
|
const sim = this.sim;
|
|
const x = xOf(cell) * CELL; const y = yOf(cell) * CELL;
|
|
const t = this.tool;
|
|
|
|
if (!t) {
|
|
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);
|
|
return;
|
|
}
|
|
if (t === 'eraser') {
|
|
g.fillStyle(0xe35050, sim.roads.has(cell) ? 0.34 : 0.15);
|
|
g.fillRoundedRect(x + 4, y + 4, CELL - 8, CELL - 8, 12);
|
|
return;
|
|
}
|
|
if (t.type === 'bridge') {
|
|
const span = this.bridgeSpanAt(cell);
|
|
if (span) {
|
|
g.fillStyle(0x57d977, 0.4);
|
|
for (const k of span) g.fillRoundedRect(xOf(k) * CELL + 4, yOf(k) * CELL + 4, CELL - 8, CELL - 8, 12);
|
|
} else {
|
|
g.fillStyle(0xe35050, 0.3);
|
|
g.fillRoundedRect(x + 4, y + 4, CELL - 8, CELL - 8, 12);
|
|
}
|
|
return;
|
|
}
|
|
if (t.type === 'motorway') {
|
|
const valid = t.stage === 0 ? sim.canPlacePortal(cell) : sim.canPlaceMotorway(t.a, cell);
|
|
if (t.stage === 1) {
|
|
g.lineStyle(CELL * 0.4, valid ? 0x4d4d59 : 0xe35050, 0.55);
|
|
g.lineBetween(cellCx(t.a), cellCy(t.a), cellCx(cell), cellCy(cell));
|
|
g.fillStyle(0x4d4d59, 0.85);
|
|
g.fillRoundedRect(xOf(t.a) * CELL + 6, yOf(t.a) * CELL + 6, CELL - 12, CELL - 12, 10);
|
|
}
|
|
g.fillStyle(valid ? 0x57d977 : 0xe35050, 0.4);
|
|
g.fillRoundedRect(x + 4, y + 4, CELL - 8, CELL - 8, 12);
|
|
return;
|
|
}
|
|
// light / roundabout: highlight all eligible junctions, plus the hover cell.
|
|
g.fillStyle(0xffffff, 0.18);
|
|
for (const k of sim.roads) {
|
|
if (!sim.items.has(k) && !sim.portals.has(k) && sim.connCount(k) >= 3) {
|
|
g.fillCircle(cellCx(k), cellCy(k), CELL * 0.2);
|
|
}
|
|
}
|
|
const ok = sim.canPlaceItem(cell, t.type);
|
|
g.fillStyle(ok ? 0x57d977 : 0xe35050, 0.4);
|
|
g.fillRoundedRect(x + 4, y + 4, CELL - 8, CELL - 8, 12);
|
|
}
|
|
|
|
// ── Sim tick / events ─────────────────────────────────────────────────────────
|
|
|
|
simTick() {
|
|
if (this.view !== 'play' || !this.sim || this.sim.gameOver) return;
|
|
|
|
for (const [id, rec] of this.carRender) {
|
|
rec.px = rec.cx; rec.py = rec.cy;
|
|
}
|
|
const events = this.sim.step(100);
|
|
this.lastTickAt = this.time.now;
|
|
|
|
for (const car of this.sim.cars) {
|
|
let rec = this.carRender.get(car.id);
|
|
const wx = (car.x + 0.5) * CELL; const wy = (car.y + 0.5) * CELL;
|
|
if (!rec) {
|
|
rec = { px: wx, py: wy, cx: wx, cy: wy };
|
|
this.carRender.set(car.id, rec);
|
|
}
|
|
rec.cx = wx; rec.cy = wy;
|
|
}
|
|
|
|
for (const e of events) this.handleEvent(e);
|
|
|
|
for (const b of this.sim.buildings) this.syncBuilding(b);
|
|
this.redrawItems();
|
|
this.syncHud();
|
|
}
|
|
|
|
handleEvent(e) {
|
|
switch (e.type) {
|
|
case 'houseSpawn':
|
|
this.ensureHouseSprite(this.sim.houseById(e.id));
|
|
break;
|
|
case 'buildingSpawn':
|
|
this.ensureBuildingSprite(this.sim.buildingById(e.id));
|
|
break;
|
|
case 'delivered': {
|
|
playSound(this, SFX.COINS);
|
|
this.confetti((e.x + 0.5) * CELL, (e.y + 0.5) * CELL, COLOR_HEX[e.color]);
|
|
this.tweens.add({ targets: this.scoreText, scale: 1.18, duration: 110, yoyo: true });
|
|
break;
|
|
}
|
|
case 'overflowStart':
|
|
playSound(this, SFX.SCIFI_RISER);
|
|
break;
|
|
case 'growth': {
|
|
this.drawTerrainRT();
|
|
this.tweens.add({
|
|
targets: this.cameras.main, zoom: this.fitZoom(e.rect),
|
|
duration: 1800, ease: 'Sine.easeInOut',
|
|
});
|
|
break;
|
|
}
|
|
case 'weekEnd':
|
|
this.showWeekModal(e.week, e.choices);
|
|
break;
|
|
case 'gameOver':
|
|
this.onGameOver(e);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
confetti(x, y, tint) {
|
|
for (let i = 0; i < 8; i++) {
|
|
const p = this.add.image(x, y, 'mm-dot')
|
|
.setTint(i % 3 === 0 ? 0xffffff : tint)
|
|
.setDepth(D.fx)
|
|
.setScale(0.6 + Math.random() * 0.8)
|
|
.setAngle(Math.random() * 90);
|
|
this.worldRoot.add(p);
|
|
this.tweens.add({
|
|
targets: p,
|
|
x: x + (Math.random() - 0.5) * 110,
|
|
y: y - 30 - Math.random() * 30 + Math.random() * 110,
|
|
angle: p.angle + (Math.random() - 0.5) * 260,
|
|
alpha: 0,
|
|
duration: 520 + Math.random() * 240,
|
|
ease: 'Quad.easeIn',
|
|
onComplete: () => p.destroy(),
|
|
});
|
|
}
|
|
}
|
|
|
|
// ── Frame update ──────────────────────────────────────────────────────────────
|
|
|
|
update(time) {
|
|
if (this.view !== 'play' || !this.sim) return;
|
|
const sim = this.sim;
|
|
|
|
if (sim.netVersion !== this.drawnNetVersion) this.redrawRoads();
|
|
|
|
// Cars: lerp between the last two sim positions.
|
|
const alpha = Math.max(0, Math.min(1, (time - this.lastTickAt) / 100));
|
|
const active = new Set();
|
|
for (const car of sim.cars) {
|
|
const moving = car.state === 'toPickup' || car.state === 'toHome' || car.state === 'dwell';
|
|
if (!moving) continue;
|
|
active.add(car.id);
|
|
const rec = this.ensureCarSprite(car);
|
|
const rr = this.carRender.get(car.id);
|
|
if (!rr) continue;
|
|
const x = rr.px + (rr.cx - rr.px) * alpha;
|
|
const y = rr.py + (rr.cy - rr.py) * alpha + Math.sin(time * 0.011 + car.id) * 0.8;
|
|
// Offset to the right side of the road (perpendicular to heading, right side)
|
|
const rightOffset = CELL * 0.12;
|
|
const rightAngle = rec.rot - Math.PI / 2;
|
|
rec.c.setPosition(x + rightOffset * Math.cos(rightAngle), y + rightOffset * Math.sin(rightAngle));
|
|
rec.c.setVisible(true);
|
|
rec.c.setDepth(car.mw ? D.motorway + 0.5 : D.cars);
|
|
let target = car.heading;
|
|
let delta = target - rec.rot;
|
|
while (delta > Math.PI) delta -= Math.PI * 2;
|
|
while (delta < -Math.PI) delta += Math.PI * 2;
|
|
rec.rot += delta * 0.22;
|
|
rec.c.setRotation(rec.rot);
|
|
rec.pin.setVisible(car.state === 'toHome');
|
|
rec.pin.setRotation(-rec.rot);
|
|
}
|
|
for (const [id, rec] of this.carSprites) {
|
|
if (!active.has(id)) rec.c.setVisible(false);
|
|
}
|
|
|
|
this.updateNight();
|
|
this.frameN = (this.frameN ?? 0) + 1;
|
|
if (this.frameN % 3 === 0) this.updateShimmer(time);
|
|
}
|
|
|
|
updateNight() {
|
|
const sim = this.sim;
|
|
const p = sim.weekT / TUNE.WEEK_MS;
|
|
const keys = [
|
|
[0.0, 0xffffff], [0.45, 0xfff6e6], [0.62, 0xffd9b0], [0.72, 0xc9b4d8],
|
|
[0.80, this.city.palette.night], [0.90, this.city.palette.night],
|
|
[0.96, 0xffe9d4], [1.0, 0xffffff],
|
|
];
|
|
let c = 0xffffff;
|
|
for (let i = 0; i < keys.length - 1; i++) {
|
|
if (p >= keys[i][0] && p <= keys[i + 1][0]) {
|
|
const t = (p - keys[i][0]) / (keys[i + 1][0] - keys[i][0]);
|
|
c = mixColor(keys[i][1], keys[i + 1][1], t);
|
|
break;
|
|
}
|
|
}
|
|
this.nightRect.fillColor = c;
|
|
}
|
|
|
|
updateShimmer(time) {
|
|
const g = this.shimmerG;
|
|
g.clear();
|
|
const pal = this.city.palette;
|
|
for (let i = 0; i < this.shimmerCells.length; i++) {
|
|
const k = this.shimmerCells[i];
|
|
const ph = time * 0.0014 + i * 1.71;
|
|
const a = 0.05 + 0.05 * Math.sin(ph);
|
|
g.lineStyle(4, mixColor(pal.water, 0xffffff, 0.55), a);
|
|
const cx = cellCx(k) + Math.sin(ph * 0.7) * 8;
|
|
const cy = cellCy(k) + Math.cos(ph * 0.4) * 6;
|
|
g.lineBetween(cx - 12, cy, cx + 12, cy);
|
|
}
|
|
}
|
|
|
|
// ── Weekly upgrade modal ──────────────────────────────────────────────────────
|
|
|
|
showWeekModal(week, choices) {
|
|
this.overlayUp = true;
|
|
this.weekChoices = choices;
|
|
this.setTool(null);
|
|
playSound(this, SFX.CARD_SHOW);
|
|
|
|
const cx = GAME_WIDTH / 2; const cy = GAME_HEIGHT / 2;
|
|
const root = this.add.container(0, 0);
|
|
this.uiRoot.add(root);
|
|
this.weekModal = root;
|
|
|
|
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.45).setInteractive();
|
|
root.add(dim);
|
|
|
|
const panel = this.add.graphics();
|
|
panel.fillStyle(COLORS.panel, 0.97);
|
|
panel.fillRoundedRect(cx - 470, cy - 270, 940, 540, 24);
|
|
panel.lineStyle(3, this.city.palette.accent, 1);
|
|
panel.strokeRoundedRect(cx - 470, cy - 270, 940, 540, 24);
|
|
root.add(panel);
|
|
|
|
root.add(this.add.text(cx, cy - 212, `WEEK ${week} COMPLETE`, {
|
|
fontFamily: 'Righteous', fontSize: '46px', color: COLORS.goldHex,
|
|
}).setOrigin(0.5));
|
|
root.add(this.add.text(cx, cy - 158, `+${TUNE.WEEK_ROADS} road tiles delivered. Choose one bonus:`, {
|
|
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.mutedHex,
|
|
}).setOrigin(0.5));
|
|
|
|
choices.forEach((key, i) => {
|
|
const x = cx + (i === 0 ? -200 : 200);
|
|
const y = cy + 40;
|
|
const card = this.add.rectangle(x, y, 340, 320, 0x262017)
|
|
.setStrokeStyle(3, this.city.palette.accent, 0.7);
|
|
const icon = this.add.graphics({ x, y: y - 80 });
|
|
this.drawChipIcon(icon, key);
|
|
icon.setScale(1.7);
|
|
const name = this.add.text(x, y + 10, UPGRADE_DEFS[key].name, {
|
|
fontFamily: 'Righteous', fontSize: '32px', color: COLORS.textHex,
|
|
}).setOrigin(0.5);
|
|
const desc = this.add.text(x, y + 78, UPGRADE_DEFS[key].desc, {
|
|
fontFamily: '"Julius Sans One"', fontSize: '19px', color: COLORS.mutedHex, align: 'center',
|
|
}).setOrigin(0.5);
|
|
const num = this.add.text(x - 150, y - 140, `${i + 1}`, {
|
|
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.mutedHex,
|
|
}).setOrigin(0.5);
|
|
root.add([card, icon, name, desc, num]);
|
|
|
|
card.setInteractive({ useHandCursor: true });
|
|
card.on('pointerover', () => card.setStrokeStyle(5, this.city.palette.accent, 1));
|
|
card.on('pointerout', () => card.setStrokeStyle(3, this.city.palette.accent, 0.7));
|
|
card.on('pointerup', () => this.pickUpgrade(i));
|
|
});
|
|
}
|
|
|
|
pickUpgradeKey(i) {
|
|
if (this.weekModal && this.weekChoices && i < this.weekChoices.length) this.pickUpgrade(i);
|
|
}
|
|
|
|
pickUpgrade(i) {
|
|
if (!this.weekModal) return;
|
|
playSound(this, SFX.PURCHASE);
|
|
this.sim.chooseUpgrade(i);
|
|
this.weekModal.destroy();
|
|
this.weekModal = null;
|
|
this.weekChoices = null;
|
|
this.overlayUp = false;
|
|
this.lastTickAt = this.time.now;
|
|
this.syncHud();
|
|
}
|
|
|
|
// ── Game over ─────────────────────────────────────────────────────────────────
|
|
|
|
onGameOver(e) {
|
|
this.setTool(null);
|
|
this.ghostG.clear();
|
|
playSound(this, SFX.SCIFI_EXPLODE);
|
|
|
|
// Linger on the culprit, then show the report card.
|
|
const rec = this.buildingSprites.get(e.buildingId);
|
|
if (rec) {
|
|
this.tweens.add({ targets: rec.root, scale: 1.25, duration: 300, yoyo: true, repeat: 2 });
|
|
}
|
|
this.time.delayedCall(1100, () => this.showGameOver(e));
|
|
}
|
|
|
|
showGameOver(e) {
|
|
this.overlayUp = true;
|
|
const sim = this.sim;
|
|
const building = sim.buildingById(e.buildingId);
|
|
|
|
const prevBest = Number(localStorage.getItem(BEST_KEY(this.cityIndex)) ?? 0);
|
|
const newBest = sim.score > prevBest;
|
|
if (newBest) localStorage.setItem(BEST_KEY(this.cityIndex), String(sim.score));
|
|
|
|
api.post('/history/single-player', {
|
|
slug: 'minimotorways', score: sim.score, opponentScores: [], result: 'win',
|
|
}).catch(() => { /* best effort */ });
|
|
|
|
const cx = GAME_WIDTH / 2; const cy = GAME_HEIGHT / 2;
|
|
const root = this.add.container(0, 0);
|
|
this.uiRoot.add(root);
|
|
|
|
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62).setInteractive();
|
|
root.add(dim);
|
|
|
|
const panel = this.add.graphics();
|
|
panel.fillStyle(COLORS.panel, 0.98);
|
|
panel.fillRoundedRect(cx - 380, cy - 250, 760, 500, 22);
|
|
panel.lineStyle(3, COLORS.danger, 1);
|
|
panel.strokeRoundedRect(cx - 380, cy - 250, 760, 500, 22);
|
|
root.add(panel);
|
|
|
|
root.add(this.add.text(cx, cy - 182, 'GRIDLOCK!', {
|
|
fontFamily: 'Righteous', fontSize: '64px', color: COLORS.dangerHex,
|
|
}).setOrigin(0.5));
|
|
|
|
const colorName = building ? building.color : 'a';
|
|
root.add(this.add.text(cx, cy - 112,
|
|
`Your ${colorName} destination was overwhelmed in week ${sim.week + 1}.`, {
|
|
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.mutedHex,
|
|
}).setOrigin(0.5));
|
|
|
|
const scoreText = this.add.text(cx, cy - 18, '0', {
|
|
fontFamily: 'Righteous', fontSize: '96px', color: COLORS.goldHex,
|
|
}).setOrigin(0.5);
|
|
root.add(scoreText);
|
|
const counter = { v: 0 };
|
|
this.tweens.add({
|
|
targets: counter, v: sim.score, duration: 1000, ease: 'Cubic.easeOut',
|
|
onUpdate: () => scoreText.setText(String(Math.round(counter.v))),
|
|
});
|
|
root.add(this.add.text(cx, cy + 52, 'TRIPS COMPLETED', {
|
|
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex,
|
|
}).setOrigin(0.5));
|
|
|
|
root.add(this.add.text(cx, cy + 102,
|
|
newBest ? '★ NEW BEST FOR ' + this.city.name.toUpperCase() + ' ★'
|
|
: (prevBest > 0 ? `Best for ${this.city.name}: ${prevBest}` : ''), {
|
|
fontFamily: 'Righteous', fontSize: '26px',
|
|
color: newBest ? hexStr(this.city.palette.accent) : COLORS.mutedHex,
|
|
}).setOrigin(0.5));
|
|
|
|
const again = new Button(this, cx - 170, cy + 180, 'Play Again',
|
|
() => this.scene.restart({ game: this.gameDef, autoCity: this.cityIndex }),
|
|
{ width: 280, height: 62, fontSize: 26 });
|
|
const cities = new Button(this, cx + 170, cy + 180, 'Cities',
|
|
() => this.scene.restart({ game: this.gameDef }),
|
|
{ width: 280, height: 62, fontSize: 26, variant: 'ghost' });
|
|
root.add([again, cities]);
|
|
}
|
|
}
|