diff --git a/public/assets/images/game-icons.png b/public/assets/images/game-icons.png index 5bfb0d4..4dee5d4 100644 Binary files a/public/assets/images/game-icons.png and b/public/assets/images/game-icons.png differ diff --git a/public/assets/images/game-icons.psd b/public/assets/images/game-icons.psd index 5fc754e..23fe1c3 100644 Binary files a/public/assets/images/game-icons.psd and b/public/assets/images/game-icons.psd differ diff --git a/public/src/games/bejeweled/BejeweledGame.js b/public/src/games/bejeweled/BejeweledGame.js new file mode 100644 index 0000000..9f676f6 --- /dev/null +++ b/public/src/games/bejeweled/BejeweledGame.js @@ -0,0 +1,1167 @@ +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 { + COLS, ROWS, SPECIAL, BLITZ_SECONDS, GEM_COLORS, + newGame, applyMove, lastHurrah, findMove, shuffleBoard, +} from './BejeweledLogic.js'; + +// ── Layout ────────────────────────────────────────────────────────────────── +const CELL = 96; +const BOARD_W = COLS * CELL; // 768 +const BOARD_X = GAME_WIDTH / 2 - BOARD_W / 2; // left edge +const BOARD_Y = 196; // top edge +const TIMER_Y = 142; + +// Jewel palette: base body, bright facet, dark rim, and a text-friendly hex. +const GEMS = { + red: { base: 0xe23b4e, hi: 0xffaab4, lo: 0x7d1322, hex: '#ff6d7e' }, + orange: { base: 0xf08c1e, hi: 0xffd9a0, lo: 0x8a4d06, hex: '#ffb259' }, + yellow: { base: 0xf2cf1d, hi: 0xfff7bb, lo: 0x8f7a08, hex: '#ffe75e' }, + green: { base: 0x2ecc71, hi: 0xaef7cf, lo: 0x126b3b, hex: '#5fe89d' }, + blue: { base: 0x2e9bf0, hi: 0xaadcff, lo: 0x0e4e83, hex: '#6cc1ff' }, + purple: { base: 0xa64ce8, hi: 0xe2bbff, lo: 0x551a80, hex: '#c98aff' }, + white: { base: 0xdde4f2, hi: 0xffffff, lo: 0x7d87a0, hex: '#ffffff' }, +}; + +const COMBO_WORDS = [null, null, 'Good!', 'Excellent!', 'Awesome!', 'Spectacular!', 'Extraordinary!']; +const COMBO_COLORS = ['', '', '#5fe89d', '#6cc1ff', '#c98aff', '#ffb259', '#ffe75e']; + +const D = { bg: -5, panel: 0, gems: 5, fx: 14, hud: 30, banner: 42, overlay: 60, overlayUI: 62 }; +const BEST_KEY = 'bejeweled-best'; + +function mixColor(a, b, t) { + const ar = (a >> 16) & 255, ag = (a >> 8) & 255, ab = a & 255; + const br = (b >> 16) & 255, bg = (b >> 8) & 255, bb = b & 255; + const r = Math.round(ar + (br - ar) * t); + const g = Math.round(ag + (bg - ag) * t); + const bl = Math.round(ab + (bb - ab) * t); + return (r << 16) | (g << 8) | bl; +} + +// Each colour gets its own silhouette so gems read at a glance. +function unitShape(color) { + const poly = (n, rotDeg) => { + const pts = []; + for (let i = 0; i < n; i++) { + const a = (Math.PI / 180) * (rotDeg + (360 / n) * i); + pts.push({ x: Math.cos(a), y: Math.sin(a) }); + } + return pts; + }; + switch (color) { + case 'red': return poly(4, 45); + case 'orange': return poly(5, -90); + case 'yellow': return [{ x: 0, y: -1.06 }, { x: 0.72, y: 0 }, { x: 0, y: 1.06 }, { x: -0.72, y: 0 }]; + case 'green': return poly(6, 0); + case 'purple': return poly(3, -90); + case 'white': return poly(8, 22.5); + default: return null; // blue → circle + } +} + +export default class BejeweledGame extends Phaser.Scene { + constructor() { super('BejeweledGame'); } + + init(data) { + this.gameDef = data.game ?? { slug: 'bejeweled', name: 'Bejeweled Blitz' }; + this.view = 'menu'; + this.state = null; + this.grid = []; // sprite containers, [r][c] + this.busy = false; + this.timeUp = false; + this.hurrahStarted = false; + this.score = 0; + this.displayScore = 0; + this.selected = null; + this.dragFrom = null; + this.lastAction = 0; + this.maxCascade = 0; + this.peakMultiplier = 1; + } + + create() { + try { + const music = this.cache.json.get('music'); + if (music?.tracks) new MusicPlayer(this, music.tracks); + } catch (_) { /* optional */ } + + this.createTextures(); + this.buildBackground(); + this.layer = this.add.container(0, 0); + + this.input.on('pointerdown', this.onPointerDown, this); + this.input.on('pointermove', this.onPointerMove, this); + this.input.on('pointerup', () => { this.dragFrom = null; }); + + this.showMenu(); + } + + // ── Procedural textures ─────────────────────────────────────────────────── + + createTextures() { + if (!this.textures.exists('bj-bg')) { + const tex = this.textures.createCanvas('bj-bg', 16, 540); + const ctx = tex.getContext(); + const grad = ctx.createLinearGradient(0, 0, 0, 540); + grad.addColorStop(0, '#1c1038'); + grad.addColorStop(0.38, '#241349'); + grad.addColorStop(0.72, '#101437'); + grad.addColorStop(1, '#06070f'); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, 16, 540); + tex.refresh(); + } + + if (!this.textures.exists('bj-beam')) { + const tex = this.textures.createCanvas('bj-beam', 256, 64); + const ctx = tex.getContext(); + const gx = ctx.createLinearGradient(0, 0, 256, 0); + gx.addColorStop(0, 'rgba(255,255,255,0)'); + gx.addColorStop(0.18, 'rgba(255,255,255,0.85)'); + gx.addColorStop(0.5, 'rgba(255,255,255,1)'); + gx.addColorStop(0.82, 'rgba(255,255,255,0.85)'); + gx.addColorStop(1, 'rgba(255,255,255,0)'); + ctx.fillStyle = gx; + ctx.fillRect(0, 0, 256, 64); + ctx.globalCompositeOperation = 'destination-in'; + const gy = ctx.createLinearGradient(0, 0, 0, 64); + gy.addColorStop(0, 'rgba(255,255,255,0)'); + gy.addColorStop(0.5, 'rgba(255,255,255,1)'); + gy.addColorStop(1, 'rgba(255,255,255,0)'); + ctx.fillStyle = gy; + ctx.fillRect(0, 0, 256, 64); + tex.refresh(); + } + + if (this.textures.exists('bj-gem-red')) return; + + // Soft radial glow (additive-blended everywhere for halos and flashes). + let g = this.add.graphics(); + for (let i = 16; i >= 1; i--) { + const t = i / 16; + g.fillStyle(0xffffff, 0.022 + 0.085 * (1 - t)); + g.fillCircle(64, 64, 64 * t); + } + g.generateTexture('bj-glow', 128, 128); + g.destroy(); + + g = this.add.graphics(); + g.fillStyle(0xffffff, 1); + g.fillCircle(4, 4, 4); + g.generateTexture('bj-dot', 8, 8); + g.destroy(); + + g = this.add.graphics(); + g.fillStyle(0xffffff, 1); + g.fillPoints([ + { x: 16, y: 0 }, { x: 19, y: 13 }, { x: 32, y: 16 }, { x: 19, y: 19 }, + { x: 16, y: 32 }, { x: 13, y: 19 }, { x: 0, y: 16 }, { x: 13, y: 13 }, + ], true); + g.generateTexture('bj-spark', 32, 32); + g.destroy(); + + g = this.add.graphics(); + g.lineStyle(5, 0xffffff, 1); + g.strokeCircle(32, 32, 27); + g.generateTexture('bj-ring', 64, 64); + g.destroy(); + + // 8-spike starburst for Star gems. + g = this.add.graphics(); + const burst = []; + for (let i = 0; i < 16; i++) { + const a = (Math.PI / 8) * i - Math.PI / 2; + const r = i % 2 === 0 ? 30 : 11; + burst.push({ x: 32 + Math.cos(a) * r, y: 32 + Math.sin(a) * r }); + } + g.fillStyle(0xffffff, 1); + g.fillPoints(burst, true); + g.generateTexture('bj-burst', 64, 64); + g.destroy(); + + g = this.add.graphics(); + g.fillStyle(0xffffff, 0.55); + g.fillRoundedRect(0, 0, 64, 16, 8); + g.generateTexture('bj-sheen', 64, 16); + g.destroy(); + + // Faceted gems, one silhouette per colour. + const S = 108, CC = S / 2, R = 46; + const at = (pts, r, ox, oy) => pts.map((p) => ({ x: CC + ox + p.x * r, y: CC + oy + p.y * r })); + for (const color of GEM_COLORS) { + const def = GEMS[color]; + const midC = mixColor(def.base, def.hi, 0.35); + const coreC = mixColor(def.base, def.hi, 0.72); + const rimC = mixColor(def.lo, 0x000000, 0.35); + const pts = unitShape(color); + g = this.add.graphics(); + if (pts) { + g.fillStyle(def.lo, 1); g.fillPoints(at(pts, R, 0, 1), true); + g.fillStyle(def.base, 1); g.fillPoints(at(pts, R - 5, 0, -1), true); + g.fillStyle(midC, 1); g.fillPoints(at(pts, (R - 5) * 0.66, -3, -5), true); + g.fillStyle(coreC, 1); g.fillPoints(at(pts, (R - 5) * 0.36, -5, -8), true); + g.lineStyle(2.5, rimC, 0.9); g.strokePoints(at(pts, R, 0, 1), true, true); + } else { + g.fillStyle(def.lo, 1); g.fillCircle(CC, CC + 1, R); + g.fillStyle(def.base, 1); g.fillCircle(CC, CC - 1, R - 5); + g.fillStyle(midC, 1); g.fillCircle(CC - 3, CC - 5, (R - 5) * 0.66); + g.fillStyle(coreC, 1); g.fillCircle(CC - 5, CC - 8, (R - 5) * 0.36); + g.lineStyle(2.5, rimC, 0.9); g.strokeCircle(CC, CC + 1, R); + } + g.fillStyle(0xffffff, 0.35); + g.fillEllipse(CC - 12, CC - 19, 26, 13); + g.fillStyle(0xffffff, 0.9); + g.fillCircle(CC - 17, CC - 21, 3.5); + g.generateTexture(`bj-gem-${color}`, S, S); + g.destroy(); + } + + // Hypercube: an iridescent orb. + g = this.add.graphics(); + const wheel = ['red', 'orange', 'yellow', 'green', 'blue', 'purple', 'white']; + wheel.forEach((c, i) => { + const a0 = (Math.PI * 2 / wheel.length) * i - Math.PI / 2; + const a1 = a0 + Math.PI * 2 / wheel.length; + g.fillStyle(GEMS[c].base, 0.95); + g.slice(CC, CC, R, a0, a1, false); + g.fillPath(); + }); + g.fillStyle(0x16102e, 0.92); + g.fillCircle(CC, CC, R * 0.62); + g.fillStyle(0xffffff, 0.95); + g.fillCircle(CC, CC, R * 0.24); + g.lineStyle(3, 0xffffff, 0.8); + g.strokeCircle(CC, CC, R); + g.fillStyle(0xffffff, 0.3); + g.fillEllipse(CC - 12, CC - 19, 26, 13); + g.generateTexture('bj-hyper', S, S); + g.destroy(); + } + + // ── Cosmic backdrop ─────────────────────────────────────────────────────── + + buildBackground() { + this.add.image(0, 0, 'bj-bg').setOrigin(0).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(D.bg); + + const nebulas = [ + { x: 420, y: 280, tint: 0x6633ff, alpha: 0.17, scale: 9 }, + { x: 1520, y: 760, tint: 0x2255ff, alpha: 0.15, scale: 10 }, + { x: 1080, y: 170, tint: 0xff3aa0, alpha: 0.10, scale: 7 }, + { x: 250, y: 900, tint: 0x00c2a8, alpha: 0.08, scale: 6 }, + ]; + for (const n of nebulas) { + const img = this.add.image(n.x, n.y, 'bj-glow') + .setScale(n.scale).setTint(n.tint).setAlpha(n.alpha) + .setBlendMode(Phaser.BlendModes.ADD).setDepth(D.bg); + this.tweens.add({ + targets: img, + x: n.x + Phaser.Math.Between(-70, 70), + y: n.y + Phaser.Math.Between(-50, 50), + scale: n.scale * 1.12, + duration: Phaser.Math.Between(11000, 17000), + yoyo: true, repeat: -1, ease: 'Sine.easeInOut', + }); + } + + for (let i = 0; i < 90; i++) { + const star = this.add.image( + Phaser.Math.Between(0, GAME_WIDTH), Phaser.Math.Between(0, GAME_HEIGHT), + i % 9 === 0 ? 'bj-spark' : 'bj-dot', + ).setScale(Phaser.Math.FloatBetween(0.15, 0.55)) + .setAlpha(Phaser.Math.FloatBetween(0.15, 0.8)) + .setDepth(D.bg).setBlendMode(Phaser.BlendModes.ADD); + this.tweens.add({ + targets: star, alpha: 0.05, + duration: Phaser.Math.Between(700, 2600), + delay: Phaser.Math.Between(0, 2000), + yoyo: true, repeat: -1, ease: 'Sine.easeInOut', + }); + } + } + + clearLayer() { + this.stopTimer(); + for (const row of this.grid) { + for (const sprite of row ?? []) { + if (!sprite) continue; + this.tweens.killTweensOf(sprite); + sprite.each((child) => this.tweens.killTweensOf(child)); + } + } + this.tweens.killTweensOf(this.layer.list); + this.layer.removeAll(true); + if (this.boardMask) { this.boardMask.destroy(); this.boardMask = null; } + this.grid = []; + this.selected = null; + this.selRing = null; + this.scoreText = null; + this.multText = null; + this.timerFill = null; + this.timerText = null; + } + + // ── Menu ────────────────────────────────────────────────────────────────── + + showMenu() { + this.view = 'menu'; + this.clearLayer(); + const cx = GAME_WIDTH / 2; + + const halo = this.add.image(cx, 200, 'bj-glow').setScale(8, 3.2) + .setTint(0xd4a017).setAlpha(0.35).setBlendMode(Phaser.BlendModes.ADD); + const title = this.add.text(cx, 168, 'BEJEWELED', { + fontFamily: 'Righteous', fontSize: '116px', color: '#ffffff', + }).setOrigin(0.5); + title.setTint(0xfff3c0, 0xffe14d, 0xd4a017, 0xb8741a); + const blitz = this.add.text(cx, 282, 'B L I T Z', { + fontFamily: 'Righteous', fontSize: '64px', color: '#6cc1ff', + }).setOrigin(0.5); + blitz.setTint(0xaadcff, 0xaadcff, 0x2e9bf0, 0x6633ff); + this.layer.add([halo, title, blitz]); + this.tweens.add({ targets: halo, alpha: 0.2, scaleX: 8.6, duration: 2400, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); + + // A row of slowly bobbing gems under the title. + GEM_COLORS.forEach((color, i) => { + const x = cx - 330 + i * 110; + const gem = this.add.image(x, 420, `bj-gem-${color}`).setScale(0.95); + const glow = this.add.image(x, 420, 'bj-glow').setScale(1.1) + .setTint(GEMS[color].base).setAlpha(0.5).setBlendMode(Phaser.BlendModes.ADD); + this.layer.add([glow, gem]); + this.tweens.add({ + targets: [gem, glow], y: 404, duration: 1500, delay: i * 160, + yoyo: true, repeat: -1, ease: 'Sine.easeInOut', + }); + }); + + const sub = this.add.text(cx, 532, '60 seconds. Match gems. Chase the cascade.', { + fontFamily: '"Julius Sans One"', fontSize: '30px', color: COLORS.textHex, + }).setOrigin(0.5); + const rules = this.add.text(cx, 588, + 'Match 4 → Flame Gem • L or T shape → Star Gem • Match 5 → Hypercube', { + fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex, + }).setOrigin(0.5); + this.layer.add([sub, rules]); + + const best = Number(localStorage.getItem(BEST_KEY) ?? 0); + if (best > 0) { + const bestText = this.add.text(cx, 648, `Best score: ${best.toLocaleString()}`, { + fontFamily: 'Righteous', fontSize: '30px', color: COLORS.goldHex, + }).setOrigin(0.5); + this.layer.add(bestText); + } + + const play = new Button(this, cx, 770, 'Play', () => this.startGame(), + { width: 340, height: 76, fontSize: 32 }); + const back = new Button(this, cx, 880, 'Back', () => this.scene.start('GameMenu'), + { variant: 'ghost', width: 240, height: 60, fontSize: 24 }); + this.layer.add([play, back]); + } + + // ── Game setup ──────────────────────────────────────────────────────────── + + startGame() { + this.view = 'play'; + this.clearLayer(); + this.state = newGame(); + this.score = 0; + this.displayScore = 0; + this.timeLeft = BLITZ_SECONDS; + this.timeUp = false; + this.hurrahStarted = false; + this.busy = true; // until the intro drop settles + this.maxCascade = 0; + this.peakMultiplier = 1; + this.lastAction = this.time.now; + + this.drawBoardPanel(); + this.buildHud(); + + this.gemLayer = this.add.container(0, 0).setDepth(D.gems); + this.layer.add(this.gemLayer); + this.boardMask = this.make.graphics({ add: false }); + this.boardMask.fillRect(BOARD_X - 4, BOARD_Y - 4, BOARD_W + 8, BOARD_W + 8); + this.gemLayer.setMask(this.boardMask.createGeometryMask()); + + this.buildGems(true); + this.time.delayedCall(950, () => { + this.busy = false; + this.lastAction = this.time.now; + this.startTimer(); + }); + } + + cellXY(c, r) { + return { x: BOARD_X + c * CELL + CELL / 2, y: BOARD_Y + r * CELL + CELL / 2 }; + } + + drawBoardPanel() { + const p = this.add.graphics().setDepth(D.panel); + // Outer aura. + for (let i = 4; i >= 1; i--) { + p.lineStyle(i * 5, 0x7b5cff, 0.05 * (5 - i)); + p.strokeRoundedRect(BOARD_X - 16, BOARD_Y - 16, BOARD_W + 32, BOARD_W + 32, 26); + } + p.fillStyle(0x0a0c22, 0.78); + p.fillRoundedRect(BOARD_X - 14, BOARD_Y - 14, BOARD_W + 28, BOARD_W + 28, 24); + p.lineStyle(2, 0x9d8bff, 0.85); + p.strokeRoundedRect(BOARD_X - 14, BOARD_Y - 14, BOARD_W + 28, BOARD_W + 28, 24); + // Checkered cells. + for (let r = 0; r < ROWS; r++) { + for (let c = 0; c < COLS; c++) { + p.fillStyle(0xffffff, (c + r) % 2 === 0 ? 0.045 : 0.085); + p.fillRoundedRect(BOARD_X + c * CELL + 2, BOARD_Y + r * CELL + 2, CELL - 4, CELL - 4, 10); + } + } + this.layer.add(p); + } + + buildHud() { + const leftX = 250; + + const panel = this.add.graphics().setDepth(D.hud); + panel.fillStyle(0x0a0c22, 0.72); + panel.fillRoundedRect(leftX - 190, 196, 380, 470, 22); + panel.lineStyle(2, 0x9d8bff, 0.6); + panel.strokeRoundedRect(leftX - 190, 196, 380, 470, 22); + this.layer.add(panel); + + const mk = (y, txt, opts) => { + const t = this.add.text(leftX, y, txt, opts).setOrigin(0.5).setDepth(D.hud); + this.layer.add(t); + return t; + }; + + mk(150, 'BEJEWELED BLITZ', { fontFamily: 'Righteous', fontSize: '34px', color: COLORS.goldHex }); + + mk(250, 'SCORE', { fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.mutedHex }); + this.scoreText = mk(310, '0', { fontFamily: 'Righteous', fontSize: '62px', color: COLORS.goldHex }); + + mk(400, 'MULTIPLIER', { fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.mutedHex }); + this.multText = mk(452, '×1', { fontFamily: 'Righteous', fontSize: '46px', color: '#6cc1ff' }).setAlpha(0.45); + + mk(540, 'BEST', { fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.mutedHex }); + const best = Number(localStorage.getItem(BEST_KEY) ?? 0); + mk(588, best.toLocaleString(), { fontFamily: 'Righteous', fontSize: '36px', color: COLORS.textHex }); + + const hint = new Button(this, leftX, 730, 'Hint', () => this.showHint(), + { width: 240, height: 56, fontSize: 22, variant: 'ghost' }); + const restart = new Button(this, leftX, 806, 'New Game', () => { if (!this.busy) this.startGame(); }, + { width: 240, height: 56, fontSize: 22 }); + const menu = new Button(this, leftX, 882, 'Menu', () => { if (!this.busy) this.showMenu(); }, + { width: 240, height: 56, fontSize: 22, variant: 'ghost' }); + [hint, restart, menu].forEach((b) => { b.setDepth(D.hud); this.layer.add(b); }); + + this.buildLegend(); + this.buildTimerBar(); + } + + buildLegend() { + const x = GAME_WIDTH - 250; + const panel = this.add.graphics().setDepth(D.hud); + panel.fillStyle(0x0a0c22, 0.72); + panel.fillRoundedRect(x - 190, 196, 380, 470, 22); + panel.lineStyle(2, 0x9d8bff, 0.6); + panel.strokeRoundedRect(x - 190, 196, 380, 470, 22); + this.layer.add(panel); + + const title = this.add.text(x, 236, 'SPECIAL GEMS', { + fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.mutedHex, + }).setOrigin(0.5).setDepth(D.hud); + this.layer.add(title); + + const rows = [ + { tex: 'bj-gem-red', glow: 0xff7a1a, name: 'Flame Gem', desc: 'Match 4 — blasts a 3×3 area', burst: false }, + { tex: 'bj-gem-blue', glow: 0xffffff, name: 'Star Gem', desc: 'L or T match — clears row + column', burst: true }, + { tex: 'bj-hyper', glow: 0xc98aff, name: 'Hypercube', desc: 'Match 5 — swap to zap a whole colour', burst: false }, + { tex: 'bj-gem-green', glow: 0xffd24a, name: 'Multiplier', desc: 'Drops in big cascades — boosts scoring', mult: true }, + ]; + rows.forEach((rowDef, i) => { + const y = 312 + i * 92; + const glow = this.add.image(x - 130, y, 'bj-glow').setScale(0.85) + .setTint(rowDef.glow).setAlpha(0.7).setBlendMode(Phaser.BlendModes.ADD).setDepth(D.hud); + const icon = this.add.image(x - 130, y, rowDef.tex).setScale(0.62).setDepth(D.hud); + const name = this.add.text(x - 80, y - 18, rowDef.name, { + fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex, + }).setOrigin(0, 0.5).setDepth(D.hud); + const desc = this.add.text(x - 80, y + 12, rowDef.desc, { + fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.mutedHex, + wordWrap: { width: 250 }, + }).setOrigin(0, 0.5).setDepth(D.hud); + this.layer.add([glow, icon, name, desc]); + if (rowDef.burst) { + const b = this.add.image(x - 130, y, 'bj-burst').setScale(0.8).setAlpha(0.95) + .setBlendMode(Phaser.BlendModes.ADD).setDepth(D.hud); + this.layer.add(b); + } + if (rowDef.mult) { + const badge = this.add.text(x - 130, y, '×', { + fontFamily: 'Righteous', fontSize: '30px', color: '#ffd24a', stroke: '#101226', strokeThickness: 5, + }).setOrigin(0.5).setDepth(D.hud); + this.layer.add(badge); + } + }); + } + + buildTimerBar() { + const bg = this.add.graphics().setDepth(D.hud); + bg.fillStyle(0x0a0c22, 0.85); + bg.fillRoundedRect(BOARD_X, TIMER_Y - 16, BOARD_W, 32, 16); + bg.lineStyle(2, 0x9d8bff, 0.6); + bg.strokeRoundedRect(BOARD_X, TIMER_Y - 16, BOARD_W, 32, 16); + this.layer.add(bg); + + this.timerFill = this.add.graphics().setDepth(D.hud); + this.layer.add(this.timerFill); + this.timerText = this.add.text(BOARD_X + BOARD_W + 24, TIMER_Y, '60', { + fontFamily: 'Righteous', fontSize: '38px', color: COLORS.textHex, + }).setOrigin(0, 0.5).setDepth(D.hud); + this.layer.add(this.timerText); + this.redrawTimer(); + } + + redrawTimer() { + if (!this.timerFill) return; + const t = Math.max(0, this.timeLeft) / BLITZ_SECONDS; + const color = t > 0.5 + ? mixColor(0xffd24a, 0x3ddc84, (t - 0.5) * 2) + : mixColor(0xff4d5e, 0xffd24a, t * 2); + this.timerFill.clear(); + const w = Math.max(0, (BOARD_W - 8) * t); + if (w > 16) { + this.timerFill.fillStyle(color, 1); + this.timerFill.fillRoundedRect(BOARD_X + 4, TIMER_Y - 12, w, 24, 12); + } + this.timerText.setText(String(Math.ceil(Math.max(0, this.timeLeft)))); + this.timerText.setColor(this.timeLeft <= 10 ? '#ff4d5e' : COLORS.textHex); + } + + startTimer() { + this.stopTimer(); + this.timerEvent = this.time.addEvent({ + delay: 100, loop: true, + callback: () => { + if (this.timeUp) return; + this.timeLeft -= 0.1; + if (this.timeLeft <= 0) { + this.timeLeft = 0; + this.timeUp = true; + this.stopTimer(); + this.redrawTimer(); + if (!this.busy) this.beginLastHurrah(); + return; + } + this.redrawTimer(); + }, + }); + } + + stopTimer() { + if (this.timerEvent) { this.timerEvent.remove(false); this.timerEvent = null; } + } + + // ── Gem sprites ─────────────────────────────────────────────────────────── + + makeGem(cell, c, r) { + const { x, y } = this.cellXY(c, r); + const cont = this.add.container(x, y); + cont._color = cell.color; + cont._special = cell.special; + + if (cell.special === SPECIAL.HYPER) { + const glow = this.add.image(0, 0, 'bj-glow').setScale(1.3) + .setTint(0xc98aff).setAlpha(0.85).setBlendMode(Phaser.BlendModes.ADD); + const orb = this.add.image(0, 0, 'bj-hyper').setScale(CELL / 108 * 0.94); + const sheen = this.add.image(0, -10, 'bj-sheen').setAlpha(0.8).setAngle(-30); + cont.add([glow, orb, sheen]); + this.tweens.add({ targets: glow, scale: 1.6, alpha: 0.45, duration: 700, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); + this.tweens.add({ targets: sheen, angle: 330, duration: 2600, repeat: -1 }); + } else { + if (cell.special === SPECIAL.FLAME) { + const glow = this.add.image(0, 0, 'bj-glow').setScale(1.15) + .setTint(0xff7a1a).setAlpha(0.9).setBlendMode(Phaser.BlendModes.ADD); + const core = this.add.image(0, 0, 'bj-glow').setScale(0.55) + .setTint(0xffd24a).setAlpha(0.9).setBlendMode(Phaser.BlendModes.ADD); + cont.add([glow, core]); + this.tweens.add({ targets: glow, scale: 1.45, alpha: 0.55, duration: 420, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); + this.tweens.add({ targets: core, scale: 0.8, duration: 300, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); + } else if (cell.special === SPECIAL.STAR) { + const glow = this.add.image(0, 0, 'bj-glow').setScale(1.2) + .setAlpha(0.9).setBlendMode(Phaser.BlendModes.ADD); + cont.add(glow); + this.tweens.add({ targets: glow, scale: 1.55, alpha: 0.5, duration: 520, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); + } + const img = this.add.image(0, 0, `bj-gem-${cell.color}`).setScale(CELL / 108 * 0.94); + cont.add(img); + if (cell.special === SPECIAL.STAR) { + const star = this.add.image(0, 0, 'bj-burst').setScale(1.05) + .setAlpha(0.95).setBlendMode(Phaser.BlendModes.ADD); + cont.add(star); + this.tweens.add({ targets: star, scale: 1.25, duration: 520, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); + } + if (cell.special === SPECIAL.MULT) { + const badge = this.add.circle(26, 26, 17, 0x101226, 0.94).setStrokeStyle(2.5, 0xffd24a, 1); + const sym = this.add.text(26, 26, '×', { + fontFamily: 'Righteous', fontSize: '27px', color: '#ffd24a', + }).setOrigin(0.5, 0.56); + cont.add([badge, sym]); + this.tweens.add({ targets: [badge, sym], scale: 1.18, duration: 480, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); + } + } + this.gemLayer.add(cont); + return cont; + } + + destroyGem(cont) { + if (!cont) return; + this.tweens.killTweensOf(cont); + cont.each((child) => this.tweens.killTweensOf(child)); + cont.destroy(); + } + + buildGems(intro = false) { + this.grid = []; + for (let r = 0; r < ROWS; r++) { + this.grid[r] = []; + for (let c = 0; c < COLS; c++) { + const sprite = this.makeGem(this.state.board[r][c], c, r); + this.grid[r][c] = sprite; + if (intro) { + const finalY = sprite.y; + sprite.y = finalY - (ROWS + 2) * CELL; + this.tweens.add({ + targets: sprite, y: finalY, + delay: c * 45 + r * 22, + duration: 430, ease: 'Bounce.easeOut', + }); + } + } + } + } + + // Safety net: after every move make sprites agree with the board exactly. + resyncSprites() { + for (let r = 0; r < ROWS; r++) { + for (let c = 0; c < COLS; c++) { + const cell = this.state.board[r][c]; + const sprite = this.grid[r][c]; + const { x, y } = this.cellXY(c, r); + if (sprite && sprite._color === cell.color && sprite._special === cell.special) { + sprite.setPosition(x, y); + continue; + } + this.destroyGem(sprite); + this.grid[r][c] = this.makeGem(cell, c, r); + } + } + } + + // ── Input ───────────────────────────────────────────────────────────────── + + cellAt(x, y) { + const c = Math.floor((x - BOARD_X) / CELL); + const r = Math.floor((y - BOARD_Y) / CELL); + if (c < 0 || c >= COLS || r < 0 || r >= ROWS) return null; + return { c, r }; + } + + onPointerDown(pointer) { + if (this.view !== 'play' || this.busy || this.timeUp) return; + this.lastAction = this.time.now; + const cell = this.cellAt(pointer.x, pointer.y); + if (!cell) { this.clearSelection(); return; } + + if (this.selected) { + const d = Math.abs(this.selected.c - cell.c) + Math.abs(this.selected.r - cell.r); + if (d === 1) { + const from = this.selected; + this.clearSelection(); + this.attemptSwap(from, cell); + return; + } + if (d === 0) { this.clearSelection(); return; } + } + this.select(cell); + this.dragFrom = cell; + this.dragStart = { x: pointer.x, y: pointer.y }; + } + + onPointerMove(pointer) { + if (!this.dragFrom || !pointer.isDown || this.busy || this.timeUp || this.view !== 'play') return; + const dx = pointer.x - this.dragStart.x; + const dy = pointer.y - this.dragStart.y; + if (Math.max(Math.abs(dx), Math.abs(dy)) < 32) return; + const from = this.dragFrom; + this.dragFrom = null; + const to = Math.abs(dx) > Math.abs(dy) + ? { c: from.c + Math.sign(dx), r: from.r } + : { c: from.c, r: from.r + Math.sign(dy) }; + if (to.c < 0 || to.c >= COLS || to.r < 0 || to.r >= ROWS) return; + this.clearSelection(); + this.attemptSwap(from, to); + } + + select(cell) { + this.clearSelection(); + this.selected = cell; + const { x, y } = this.cellXY(cell.c, cell.r); + this.selRing = this.add.image(x, y, 'bj-ring').setScale(CELL / 64 * 0.92) + .setTint(0xffffff).setDepth(D.fx).setBlendMode(Phaser.BlendModes.ADD); + this.tweens.add({ + targets: this.selRing, scale: CELL / 64 * 1.02, alpha: 0.6, + duration: 420, yoyo: true, repeat: -1, ease: 'Sine.easeInOut', + }); + playSound(this, SFX.PIECE_CLICK); + } + + clearSelection() { + this.selected = null; + if (this.selRing) { this.tweens.killTweensOf(this.selRing); this.selRing.destroy(); this.selRing = null; } + } + + // ── Moves ───────────────────────────────────────────────────────────────── + + attemptSwap(a, b) { + if (this.busy || this.timeUp || this.view !== 'play') return; + const board = this.state.board; + const isHyperSwap = board[a.r][a.c]?.special === SPECIAL.HYPER + || board[b.r][b.c]?.special === SPECIAL.HYPER; + + const phases = applyMove(this.state, a, b); + this.lastAction = this.time.now; + if (!phases) { this.invalidSwap(a, b); return; } + + this.busy = true; + playSound(this, SFX.SCIFI_WOOSH); + const sa = this.grid[a.r][a.c]; + const sb = this.grid[b.r][b.c]; + const pa = this.cellXY(a.c, a.r); + const pb = this.cellXY(b.c, b.r); + + if (isHyperSwap) { + // The hypercube fires in place: pull the gems together, then detonate. + this.tweens.add({ targets: sa, x: pa.x + (pb.x - pa.x) * 0.3, y: pa.y + (pb.y - pa.y) * 0.3, duration: 110, yoyo: true }); + this.tweens.add({ + targets: sb, x: pb.x + (pa.x - pb.x) * 0.3, y: pb.y + (pa.y - pb.y) * 0.3, duration: 110, yoyo: true, + onComplete: () => this.runPhases(phases, () => this.afterMove()), + }); + } else { + this.grid[a.r][a.c] = sb; + this.grid[b.r][b.c] = sa; + this.tweens.add({ targets: sa, x: pb.x, y: pb.y, duration: 170, ease: 'Quad.easeInOut' }); + this.tweens.add({ + targets: sb, x: pa.x, y: pa.y, duration: 170, ease: 'Quad.easeInOut', + onComplete: () => this.runPhases(phases, () => this.afterMove()), + }); + } + } + + invalidSwap(a, b) { + this.busy = true; + playSound(this, SFX.MASTERMIND_DENIED); + const sa = this.grid[a.r][a.c]; + const sb = this.grid[b.r][b.c]; + const pa = this.cellXY(a.c, a.r); + const pb = this.cellXY(b.c, b.r); + this.tweens.add({ targets: sa, x: pb.x, y: pb.y, duration: 130, yoyo: true, ease: 'Quad.easeInOut' }); + this.tweens.add({ + targets: sb, x: pa.x, y: pa.y, duration: 130, yoyo: true, ease: 'Quad.easeInOut', + onComplete: () => { this.busy = false; }, + }); + } + + afterMove() { + this.resyncSprites(); + this.busy = false; + this.lastAction = this.time.now; + if (this.timeUp) { this.beginLastHurrah(); return; } + if (this.state.noMoves) this.doReshuffle(); + } + + doReshuffle() { + this.busy = true; + this.showBanner('NO MORE MOVES', '#ff6d7e', 'reshuffling the gems…'); + for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) { + const s = this.grid[r][c]; + this.tweens.add({ + targets: s, alpha: 0, scale: 0.3, delay: (c + r) * 18, duration: 240, ease: 'Quad.easeIn', + onComplete: () => this.destroyGem(s), + }); + } + this.time.delayedCall(620, () => { + shuffleBoard(this.state); + this.buildGems(true); + this.time.delayedCall(900, () => { + this.busy = false; + if (this.timeUp) this.beginLastHurrah(); + }); + }); + } + + // ── Phase animation ─────────────────────────────────────────────────────── + + runPhases(phases, done) { + const step = (i) => { + if (i >= phases.length) { done(); return; } + this.animatePhase(phases[i], () => step(i + 1)); + }; + step(0); + } + + animatePhase(phase, done) { + this.maxCascade = Math.max(this.maxCascade, phase.cascade); + this.peakMultiplier = Math.max(this.peakMultiplier, phase.multiplier); + + // Special-gem fireworks, staggered so chains read as chains. + phase.events.forEach((e, i) => { + this.time.delayedCall(i * 90, () => this.playEvent(e)); + }); + + // Clear matched gems with a burst. + if (phase.cleared.length) playSound(this, SFX.MASTERMIND_MATCH); + const sparse = phase.cleared.length > 14; + phase.cleared.forEach((cell, i) => { + const sprite = this.grid[cell.r][cell.c]; + this.grid[cell.r][cell.c] = null; + if (!sprite) return; + this.tweens.add({ + targets: sprite, scale: 0, alpha: 0, duration: 180, ease: 'Back.easeIn', + onComplete: () => this.destroyGem(sprite), + }); + if (!sparse || i % 2 === 0) this.gemBurst(sprite.x, sprite.y, cell.color); + }); + + // Score & combo callout at the centroid of the clear. + if (phase.points > 0 && phase.cleared.length) { + let mx = 0, my = 0; + for (const cell of phase.cleared) { const p = this.cellXY(cell.c, cell.r); mx += p.x; my += p.y; } + mx /= phase.cleared.length; my /= phase.cleared.length; + this.addScore(phase.points); + this.time.delayedCall(70, () => this.scorePopup(mx, my, phase.points, phase.cascade)); + if (phase.cascade >= 2) this.time.delayedCall(120, () => this.comboCallout(phase.cascade)); + } + + // Newly earned specials flash into existence. + phase.spawns.forEach((s) => { + this.time.delayedCall(190, () => { + const old = this.grid[s.r][s.c]; + this.destroyGem(old); + const sprite = this.makeGem({ color: s.color, special: s.special }, s.c, s.r); + this.grid[s.r][s.c] = sprite; + sprite.setScale(1.7).setAlpha(0); + this.tweens.add({ targets: sprite, scale: 1, alpha: 1, duration: 260, ease: 'Back.easeOut' }); + const flash = this.add.image(sprite.x, sprite.y, 'bj-glow').setScale(0.6) + .setDepth(D.fx).setBlendMode(Phaser.BlendModes.ADD); + this.tweens.add({ + targets: flash, scale: 2.2, alpha: 0, duration: 380, + onComplete: () => flash.destroy(), + }); + }); + }); + + // Gravity: surviving gems slide down, fresh ones rain in from above. + const FALL_AT = 250; + let maxFall = 0; + this.time.delayedCall(FALL_AT, () => { + for (const f of phase.falls) { + const sprite = this.grid[f.fromR][f.c]; + this.grid[f.toR][f.c] = sprite; + this.grid[f.fromR][f.c] = null; + if (!sprite) continue; + const { y } = this.cellXY(f.c, f.toR); + this.tweens.add({ + targets: sprite, y, + duration: 110 + 58 * (f.toR - f.fromR), ease: 'Bounce.easeOut', + }); + } + for (const f of phase.refills) { + const sprite = this.makeGem({ color: f.color, special: f.special }, f.c, f.fromR); + this.grid[f.r][f.c] = sprite; + const { y } = this.cellXY(f.c, f.r); + this.tweens.add({ + targets: sprite, y, + duration: 110 + 58 * (f.r - f.fromR), ease: 'Bounce.easeOut', + }); + } + }); + for (const f of phase.falls) maxFall = Math.max(maxFall, 110 + 58 * (f.toR - f.fromR)); + for (const f of phase.refills) maxFall = Math.max(maxFall, 110 + 58 * (f.r - f.fromR)); + + this.time.delayedCall(FALL_AT + maxFall + 70, done); + } + + playEvent(e) { + if (e.type === 'mult') { + playSound(this, SFX.COINS); + this.multText.setText(`×${e.multiplier}`).setAlpha(1); + this.tweens.add({ targets: this.multText, scale: 1.5, duration: 160, yoyo: true, ease: 'Quad.easeOut' }); + this.showBanner(`MULTIPLIER ×${e.multiplier}!`, '#6cc1ff'); + return; + } + const { x, y } = this.cellXY(e.c, e.r); + if (e.type === 'flame') { + playSound(this, SFX.SCIFI_EXPLODE); + this.cameras.main.shake(110, 0.0045); + const flash = this.add.image(x, y, 'bj-glow').setScale(1).setTint(0xff7a1a) + .setDepth(D.fx).setBlendMode(Phaser.BlendModes.ADD); + this.tweens.add({ targets: flash, scale: 4.5, alpha: 0, duration: 420, onComplete: () => flash.destroy() }); + const ring = this.add.image(x, y, 'bj-ring').setScale(0.6).setTint(0xffd24a) + .setDepth(D.fx).setBlendMode(Phaser.BlendModes.ADD); + this.tweens.add({ targets: ring, scale: 4, alpha: 0, duration: 380, onComplete: () => ring.destroy() }); + const em = this.add.particles(x, y, 'bj-dot', { + speed: { min: 130, max: 420 }, lifespan: 600, quantity: 26, + scale: { start: 1.2, end: 0 }, alpha: { start: 1, end: 0 }, + tint: [0xff7a1a, 0xffd24a, 0xff4d5e, 0xffffff], + blendMode: 'ADD', + }).setDepth(D.fx); + this.time.delayedCall(60, () => em.stop()); + this.time.delayedCall(800, () => em.destroy()); + } else if (e.type === 'star') { + playSound(this, SFX.SCIFI_LAUNCH); + this.cameras.main.shake(90, 0.003); + const h = this.add.image(BOARD_X + BOARD_W / 2, y, 'bj-beam') + .setDepth(D.fx).setBlendMode(Phaser.BlendModes.ADD).setDisplaySize(BOARD_W + 60, 110); + const v = this.add.image(x, BOARD_Y + BOARD_W / 2, 'bj-beam').setAngle(90) + .setDepth(D.fx).setBlendMode(Phaser.BlendModes.ADD).setDisplaySize(BOARD_W + 60, 110); + this.tweens.add({ targets: [h, v], alpha: 0, duration: 360, ease: 'Quad.easeIn', + onComplete: () => { h.destroy(); v.destroy(); } }); + const em = this.add.particles(x, y, 'bj-spark', { + speed: { min: 80, max: 260 }, lifespan: 500, quantity: 14, + scale: { start: 0.8, end: 0 }, alpha: { start: 1, end: 0 }, + tint: 0xffffff, blendMode: 'ADD', + }).setDepth(D.fx); + this.time.delayedCall(60, () => em.stop()); + this.time.delayedCall(700, () => em.destroy()); + } else if (e.type === 'hyper') { + playSound(this, SFX.SCIFI_REVEAL); + this.cameras.main.shake(170, 0.006); + const tint = e.color ? GEMS[e.color].base : 0xffffff; + const flash = this.add.image(x, y, 'bj-glow').setScale(1.4).setTint(tint) + .setDepth(D.fx).setBlendMode(Phaser.BlendModes.ADD); + this.tweens.add({ targets: flash, scale: 7, alpha: 0, duration: 520, onComplete: () => flash.destroy() }); + // Lightning to each zapped gem. + const bolts = this.add.graphics().setDepth(D.fx).setBlendMode(Phaser.BlendModes.ADD); + const targets = (e.cells ?? []).slice(0, 20); + for (const [c, r] of targets) { + const p = this.cellXY(c, r); + bolts.lineStyle(3, tint, 0.9); + bolts.beginPath(); + bolts.moveTo(x, y); + const segs = 3; + for (let i = 1; i <= segs; i++) { + const t = i / segs; + const jx = (i < segs) ? Phaser.Math.Between(-22, 22) : 0; + const jy = (i < segs) ? Phaser.Math.Between(-22, 22) : 0; + bolts.lineTo(x + (p.x - x) * t + jx, y + (p.y - y) * t + jy); + } + bolts.strokePath(); + } + this.tweens.add({ targets: bolts, alpha: 0, duration: 300, onComplete: () => bolts.destroy() }); + const em = this.add.particles(x, y, 'bj-spark', { + speed: { min: 140, max: 480 }, lifespan: 700, quantity: 30, + scale: { start: 1, end: 0 }, alpha: { start: 1, end: 0 }, + tint: [tint, 0xffffff], blendMode: 'ADD', + }).setDepth(D.fx); + this.time.delayedCall(80, () => em.stop()); + this.time.delayedCall(900, () => em.destroy()); + } + } + + gemBurst(x, y, color) { + const tint = GEMS[color]?.base ?? 0xffffff; + const em = this.add.particles(x, y, 'bj-dot', { + speed: { min: 60, max: 200 }, lifespan: 420, quantity: 8, + scale: { start: 0.9, end: 0 }, alpha: { start: 1, end: 0 }, + tint: [tint, mixColor(tint, 0xffffff, 0.6)], blendMode: 'ADD', + }).setDepth(D.fx); + this.time.delayedCall(40, () => em.stop()); + this.time.delayedCall(520, () => em.destroy()); + } + + addScore(points) { + this.score += points; + if (this.scoreTween) this.scoreTween.stop(); + const from = this.displayScore; + const counter = { v: from }; + this.scoreTween = this.tweens.add({ + targets: counter, v: this.score, duration: 320, ease: 'Quad.easeOut', + onUpdate: () => { + this.displayScore = Math.round(counter.v); + if (this.scoreText) this.scoreText.setText(this.displayScore.toLocaleString()); + }, + }); + } + + scorePopup(x, y, points, cascade) { + const size = Math.min(30 + cascade * 7, 64); + const t = this.add.text(x, y, `+${points.toLocaleString()}`, { + fontFamily: 'Righteous', fontSize: `${size}px`, color: COLORS.goldHex, + stroke: '#100c04', strokeThickness: 6, + }).setOrigin(0.5).setDepth(D.banner); + this.tweens.add({ + targets: t, y: y - 70, alpha: 0, duration: 800, ease: 'Quad.easeOut', + onComplete: () => t.destroy(), + }); + } + + comboCallout(cascade) { + const idx = Math.min(cascade, COMBO_WORDS.length - 1); + const word = cascade >= 7 ? 'UNBELIEVABLE!' : COMBO_WORDS[idx]; + const color = cascade >= 7 ? '#ffe75e' : COMBO_COLORS[idx]; + if (!word) return; + const t = this.add.text(GAME_WIDTH / 2, BOARD_Y + 240, word, { + fontFamily: 'Righteous', fontSize: `${46 + cascade * 6}px`, color, + stroke: '#0a0c22', strokeThickness: 8, + }).setOrigin(0.5).setDepth(D.banner).setScale(0.3).setAlpha(0); + this.tweens.add({ targets: t, scale: 1, alpha: 1, duration: 200, ease: 'Back.easeOut' }); + this.tweens.add({ + targets: t, alpha: 0, y: t.y - 40, delay: 600, duration: 320, + onComplete: () => t.destroy(), + }); + } + + showBanner(text, color, subText) { + const cx = GAME_WIDTH / 2; + const cy = BOARD_Y + BOARD_W / 2 - 40; + const t = this.add.text(cx, cy, text, { + fontFamily: 'Righteous', fontSize: '64px', color, + stroke: '#0a0c22', strokeThickness: 10, + }).setOrigin(0.5).setDepth(D.banner).setScale(0.3).setAlpha(0); + this.tweens.add({ targets: t, scale: 1, alpha: 1, duration: 220, ease: 'Back.easeOut' }); + this.tweens.add({ targets: t, alpha: 0, delay: 950, duration: 300, onComplete: () => t.destroy() }); + if (subText) { + const s = this.add.text(cx, cy + 56, subText, { + fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.textHex, + stroke: '#0a0c22', strokeThickness: 6, + }).setOrigin(0.5).setDepth(D.banner).setAlpha(0); + this.tweens.add({ targets: s, alpha: 1, duration: 220 }); + this.tweens.add({ targets: s, alpha: 0, delay: 950, duration: 300, onComplete: () => s.destroy() }); + } + } + + // ── Hint ────────────────────────────────────────────────────────────────── + + showHint() { + if (this.view !== 'play' || this.busy || this.timeUp) return; + const mv = findMove(this.state.board); + if (!mv) return; + this.lastAction = this.time.now; + for (const cell of [mv.a, mv.b]) { + const sprite = this.grid[cell.r][cell.c]; + if (!sprite) continue; + this.tweens.add({ targets: sprite, scale: 1.18, duration: 180, yoyo: true, repeat: 2, ease: 'Sine.easeInOut' }); + const { x, y } = this.cellXY(cell.c, cell.r); + const glow = this.add.image(x, y, 'bj-glow').setScale(1.4).setAlpha(0.8) + .setDepth(D.fx).setBlendMode(Phaser.BlendModes.ADD); + this.tweens.add({ targets: glow, alpha: 0, scale: 1.9, duration: 900, onComplete: () => glow.destroy() }); + } + } + + update(time) { + if (this.view !== 'play') return; + // Low-clock pulse. + if (this.timerFill && this.timeLeft <= 10 && this.timeLeft > 0) { + this.timerFill.setAlpha(0.65 + 0.35 * Math.sin(time / 90)); + } + // Gentle automatic hint when the player stalls. + if (!this.busy && !this.timeUp && time - this.lastAction > 7000) { + this.lastAction = time; + this.showHint(); + } + } + + // ── Endgame ─────────────────────────────────────────────────────────────── + + beginLastHurrah() { + if (this.hurrahStarted) return; + this.hurrahStarted = true; + this.busy = true; + this.clearSelection(); + + const phases = lastHurrah(this.state); + if (!phases.length) { this.time.delayedCall(700, () => this.gameOver()); this.showBanner("TIME'S UP!", '#ff6d7e'); return; } + + this.showBanner('LAST HURRAH!', '#ffe75e', 'every special gem detonates'); + this.time.delayedCall(1000, () => { + this.runPhases(phases, () => this.time.delayedCall(300, () => this.gameOver())); + }); + } + + gameOver() { + this.view = 'over'; + playSound(this, SFX.VICTORY_SHORT); + + const prevBest = Number(localStorage.getItem(BEST_KEY) ?? 0); + const newBest = this.score > prevBest; + if (newBest) localStorage.setItem(BEST_KEY, String(this.score)); + + api.post('/history/single-player', { + slug: 'bejeweled', score: this.score, opponentScores: [], result: 'win', + }).catch(() => { /* best effort */ }); + + const cx = GAME_WIDTH / 2; + const cy = GAME_HEIGHT / 2; + const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x05060f, 0.7) + .setDepth(D.overlay).setInteractive(); + this.layer.add(dim); + + const panel = this.add.graphics().setDepth(D.overlay); + panel.fillStyle(0x0a0c22, 0.97); + panel.fillRoundedRect(cx - 390, cy - 270, 780, 540, 26); + panel.lineStyle(3, newBest ? 0xffd24a : 0x9d8bff, 1); + panel.strokeRoundedRect(cx - 390, cy - 270, 780, 540, 26); + this.layer.add(panel); + + const title = this.add.text(cx, cy - 198, "TIME'S UP!", { + fontFamily: 'Righteous', fontSize: '64px', color: '#ff6d7e', + }).setOrigin(0.5).setDepth(D.overlayUI); + const scoreLabel = this.add.text(cx, cy - 116, 'FINAL SCORE', { + fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.mutedHex, + }).setOrigin(0.5).setDepth(D.overlayUI); + const scoreText = this.add.text(cx, cy - 48, '0', { + fontFamily: 'Righteous', fontSize: '84px', color: COLORS.goldHex, + }).setOrigin(0.5).setDepth(D.overlayUI); + this.layer.add([title, scoreLabel, scoreText]); + + const counter = { v: 0 }; + this.tweens.add({ + targets: counter, v: this.score, duration: 1100, ease: 'Cubic.easeOut', + onUpdate: () => scoreText.setText(Math.round(counter.v).toLocaleString()), + }); + + const stats = this.add.text(cx, cy + 38, + `Biggest cascade ×${this.maxCascade} • Top multiplier ×${this.peakMultiplier}`, { + fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex, + }).setOrigin(0.5).setDepth(D.overlayUI); + this.layer.add(stats); + + if (newBest) { + const nb = this.add.text(cx, cy + 92, '★ NEW BEST SCORE ★', { + fontFamily: 'Righteous', fontSize: '36px', color: '#ffd24a', + }).setOrigin(0.5).setDepth(D.overlayUI); + this.layer.add(nb); + this.tweens.add({ targets: nb, scale: 1.1, duration: 480, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); + const em = this.add.particles(cx, cy - 270, 'bj-dot', { + x: { min: -360, max: 360 }, speedY: { min: 120, max: 260 }, speedX: { min: -40, max: 40 }, + lifespan: 2400, quantity: 2, frequency: 70, scale: { start: 0.8, end: 0.2 }, + alpha: { start: 1, end: 0 }, + tint: Object.values(GEMS).map((gem) => gem.base), blendMode: 'ADD', + }).setDepth(D.overlayUI); + this.time.delayedCall(3600, () => em.destroy()); + } else if (prevBest > 0) { + const bb = this.add.text(cx, cy + 92, `Best: ${prevBest.toLocaleString()}`, { + fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.mutedHex, + }).setOrigin(0.5).setDepth(D.overlayUI); + this.layer.add(bb); + } + + const again = new Button(this, cx - 170, cy + 188, 'Play Again', () => this.startGame(), + { width: 290, height: 64, fontSize: 26 }).setDepth(D.overlayUI); + const menu = new Button(this, cx + 170, cy + 188, 'Menu', () => this.showMenu(), + { width: 290, height: 64, fontSize: 26, variant: 'ghost' }).setDepth(D.overlayUI); + this.layer.add([again, menu]); + } +} diff --git a/public/src/games/bejeweled/BejeweledLogic.js b/public/src/games/bejeweled/BejeweledLogic.js new file mode 100644 index 0000000..2aed562 --- /dev/null +++ b/public/src/games/bejeweled/BejeweledLogic.js @@ -0,0 +1,424 @@ +// Bejeweled Blitz — pure game logic (no Phaser). +// +// Board is ROWS×COLS of gems { color, special }. Swapping two adjacent gems is +// legal when it creates a run of 3+ (or involves a Hypercube). Matches resolve +// in cascading phases; each phase reports exactly what happened so the scene +// can animate it: cleared gems, special-gem spawns, detonation events, falls +// and refills, plus the points earned. +// +// Specials: +// FLAME — from a 4-in-a-row; detonates a 3×3 blast when cleared. +// STAR — from an L/T intersection; clears its full row and column. +// HYPER — from 5+ in a row; swap with any gem to clear that colour +// (detonated by a blast, it zaps a random colour instead). +// MULT — multiplier gem dropped during deep cascades; clearing it raises +// the global score multiplier (×2 … ×8). + +export const COLS = 8; +export const ROWS = 8; +export const BLITZ_SECONDS = 60; +export const MAX_MULTIPLIER = 8; + +export const GEM_COLORS = ['red', 'orange', 'yellow', 'green', 'blue', 'purple', 'white']; + +export const SPECIAL = { + NONE: 'none', + FLAME: 'flame', + STAR: 'star', + HYPER: 'hyper', + MULT: 'mult', +}; + +const key = (c, r) => r * COLS + c; +const unkey = (k) => ({ c: k % COLS, r: (k / COLS) | 0 }); +const inBounds = (c, r) => c >= 0 && c < COLS && r >= 0 && r < ROWS; + +function randomGem(rng) { + return { color: GEM_COLORS[(rng() * GEM_COLORS.length) | 0], special: SPECIAL.NONE }; +} + +// ── Board construction ────────────────────────────────────────────────────── + +export function randomBoard(rng = Math.random) { + for (let attempt = 0; attempt < 100; attempt++) { + const board = []; + for (let r = 0; r < ROWS; r++) { + board[r] = []; + for (let c = 0; c < COLS; c++) { + let gem; + do { gem = randomGem(rng); } while ( + (c >= 2 && board[r][c - 1].color === gem.color && board[r][c - 2].color === gem.color) || + (r >= 2 && board[r - 1][c].color === gem.color && board[r - 2][c].color === gem.color) + ); + board[r][c] = gem; + } + } + if (findMove(board)) return board; + } + throw new Error('Could not generate a board with a legal move.'); +} + +export function newGame(rng = Math.random) { + return { board: randomBoard(rng), multiplier: 1, noMoves: false }; +} + +// ── Runs & match groups ───────────────────────────────────────────────────── + +export function findRuns(board) { + const runs = []; + for (let r = 0; r < ROWS; r++) { + let c = 0; + while (c < COLS) { + const color = board[r][c]?.color; + if (!color) { c++; continue; } + let end = c + 1; + while (end < COLS && board[r][end]?.color === color) end++; + if (end - c >= 3) { + const cells = []; + for (let i = c; i < end; i++) cells.push([i, r]); + runs.push({ color, horizontal: true, cells }); + } + c = end; + } + } + for (let c = 0; c < COLS; c++) { + let r = 0; + while (r < ROWS) { + const color = board[r][c]?.color; + if (!color) { r++; continue; } + let end = r + 1; + while (end < ROWS && board[end][c]?.color === color) end++; + if (end - r >= 3) { + const cells = []; + for (let i = r; i < end; i++) cells.push([c, i]); + runs.push({ color, horizontal: false, cells }); + } + r = end; + } + } + return runs; +} + +// Union runs that share a cell into match groups (an L/T counts as one group). +function groupRuns(runs) { + const parent = runs.map((_, i) => i); + const find = (i) => (parent[i] === i ? i : (parent[i] = find(parent[i]))); + const union = (a, b) => { parent[find(a)] = find(b); }; + + const byCell = new Map(); + runs.forEach((run, i) => run.cells.forEach(([c, r]) => { + const k = key(c, r); + if (byCell.has(k)) union(i, byCell.get(k)); + else byCell.set(k, i); + })); + + const groups = new Map(); + runs.forEach((run, i) => { + const root = find(i); + if (!groups.has(root)) groups.set(root, { color: run.color, runs: [], cells: new Set() }); + const g = groups.get(root); + g.runs.push(run); + run.cells.forEach(([c, r]) => g.cells.add(key(c, r))); + }); + return [...groups.values()]; +} + +// Where a freshly-earned special gem materialises: the swapped cell if it is +// part of the group, else the runs' shared cell, else the longest run's middle. +function pickSpawnKey(group, swapKeys) { + for (const k of swapKeys) if (group.cells.has(k)) return k; + if (group.runs.length >= 2) { + const seen = new Set(); + for (const run of group.runs) { + for (const [c, r] of run.cells) { + const k = key(c, r); + if (seen.has(k)) return k; + seen.add(k); + } + } + } + const longest = group.runs.reduce((a, b) => (b.cells.length > a.cells.length ? b : a)); + const [c, r] = longest.cells[(longest.cells.length / 2) | 0]; + return key(c, r); +} + +// ── Detonation chains ─────────────────────────────────────────────────────── + +// Expand a seed set of cleared cells through special-gem chain reactions. +// hyperOverrides maps a hyper gem's key to the colour it must zap (set by a +// hyper swap); hypers consumed by blasts zap a random colour on the board. +function expandClears(board, seedKeys, rng, hyperOverrides = new Map()) { + const keys = new Set(); + const events = []; + const queue = []; + + const add = (c, r) => { + if (!inBounds(c, r)) return; + const k = key(c, r); + if (keys.has(k) || !board[r][c]) return; + keys.add(k); + const sp = board[r][c].special; + if (sp === SPECIAL.FLAME || sp === SPECIAL.STAR || sp === SPECIAL.HYPER) queue.push(k); + }; + + for (const k of seedKeys) { const { c, r } = unkey(k); add(c, r); } + + while (queue.length) { + const k = queue.shift(); + const { c, r } = unkey(k); + const sp = board[r][c].special; + if (sp === SPECIAL.FLAME) { + events.push({ type: 'flame', c, r }); + for (let dr = -1; dr <= 1; dr++) for (let dc = -1; dc <= 1; dc++) add(c + dc, r + dr); + } else if (sp === SPECIAL.STAR) { + events.push({ type: 'star', c, r }); + for (let i = 0; i < COLS; i++) add(i, r); + for (let i = 0; i < ROWS; i++) add(c, i); + } else if (sp === SPECIAL.HYPER) { + let color = hyperOverrides.get(k) ?? null; + if (!color) { + const present = new Set(); + for (let rr = 0; rr < ROWS; rr++) for (let cc = 0; cc < COLS; cc++) { + if (board[rr][cc]?.color && !keys.has(key(cc, rr))) present.add(board[rr][cc].color); + } + const pool = [...present]; + color = pool.length ? pool[(rng() * pool.length) | 0] : null; + } + const cells = []; + if (color) { + for (let rr = 0; rr < ROWS; rr++) for (let cc = 0; cc < COLS; cc++) { + if (board[rr][cc]?.color === color) { cells.push([cc, rr]); add(cc, rr); } + } + } + events.push({ type: 'hyper', c, r, color, cells }); + } + } + return { keys, events }; +} + +// ── Gravity & refill ──────────────────────────────────────────────────────── + +function collapse(board, rng) { + const falls = []; + const refills = []; + for (let c = 0; c < COLS; c++) { + let write = ROWS - 1; + for (let r = ROWS - 1; r >= 0; r--) { + if (!board[r][c]) continue; + if (write !== r) { + board[write][c] = board[r][c]; + board[r][c] = null; + falls.push({ c, fromR: r, toR: write }); + } + write--; + } + const newCount = write + 1; + for (let r = write; r >= 0; r--) { + const gem = randomGem(rng); + board[r][c] = gem; + refills.push({ c, r, color: gem.color, special: gem.special, fromR: r - newCount }); + } + } + return { falls, refills }; +} + +// ── Cascade driver ────────────────────────────────────────────────────────── + +const GEM_POINTS = 30; +const EVENT_BONUS = { flame: 100, star: 200, hyper: 400 }; + +// Resolve one or more cascade phases. opts.preClear seeds phase 1 directly +// (hyper swaps, Last Hurrah); afterwards phases come from runs on the board. +function runCascades(state, rng, opts = {}) { + const board = state.board; + const phases = []; + let swapKeys = opts.swapKeys ?? []; + let preClear = opts.preClear ?? null; + let multDropped = false; + let cascade = 0; + + while (cascade < 30) { + cascade++; + let seedKeys; + let spawns = []; + let runBonus = 0; + let hyperOverrides = new Map(); + + if (preClear) { + seedKeys = preClear.keys; + runBonus = preClear.bonus ?? 0; + hyperOverrides = preClear.hyperOverrides ?? new Map(); + preClear = null; + } else { + const runs = findRuns(board); + if (!runs.length) break; + seedKeys = new Set(); + for (const group of groupRuns(runs)) { + group.cells.forEach((k) => seedKeys.add(k)); + const maxLen = Math.max(...group.runs.map((r) => r.cells.length)); + let special = null; + if (maxLen >= 5) special = SPECIAL.HYPER; + else if (group.runs.length >= 2) special = SPECIAL.STAR; + else if (maxLen === 4) special = SPECIAL.FLAME; + if (special) { + const k = pickSpawnKey(group, swapKeys); + spawns.push({ k, color: special === SPECIAL.HYPER ? null : group.color, special }); + } + runBonus += (maxLen - 3) * 100 + (group.runs.length >= 2 ? 150 : 0); + } + } + + const { keys, events } = expandClears(board, seedKeys, rng, hyperOverrides); + const spawnKeys = new Set(spawns.map((s) => s.k)); + + // Multiplier gems consumed this phase raise the global multiplier. + let eventBonus = 0; + const cleared = []; + for (const k of keys) { + const { c, r } = unkey(k); + const gem = board[r][c]; + if (gem.special === SPECIAL.MULT && state.multiplier < MAX_MULTIPLIER) { + state.multiplier++; + events.push({ type: 'mult', c, r, multiplier: state.multiplier }); + } + if (!spawnKeys.has(k)) cleared.push({ c, r, color: gem.color, special: gem.special }); + } + for (const e of events) eventBonus += EVENT_BONUS[e.type] ?? 0; + + const points = Math.round((keys.size * GEM_POINTS + runBonus + eventBonus) * cascade * state.multiplier); + + for (const k of keys) { const { c, r } = unkey(k); board[r][c] = null; } + const placedSpawns = []; + for (const s of spawns) { + const { c, r } = unkey(s.k); + board[r][c] = { color: s.color, special: s.special }; + placedSpawns.push({ c, r, color: s.color, special: s.special }); + } + + const { falls, refills } = collapse(board, rng); + + // Deep cascades can drop a multiplier gem into the refill. + if (opts.allowMult !== false && !multDropped && cascade >= 2 + && state.multiplier < MAX_MULTIPLIER && refills.length && rng() < 0.6) { + const pick = refills[(rng() * refills.length) | 0]; + pick.special = SPECIAL.MULT; + board[pick.r][pick.c].special = SPECIAL.MULT; + multDropped = true; + } + + phases.push({ + cascade, points, multiplier: state.multiplier, + cleared, spawns: placedSpawns, events, falls, refills, + }); + swapKeys = []; + } + + state.noMoves = !findMove(board); + return phases; +} + +// ── Moves ─────────────────────────────────────────────────────────────────── + +// Attempt a swap of adjacent cells a/b ({c, r}). Returns an array of phases, +// or null if the swap is illegal (board left untouched). +export function applyMove(state, a, b, rng = Math.random) { + const board = state.board; + if (!inBounds(a.c, a.r) || !inBounds(b.c, b.r)) return null; + if (Math.abs(a.c - b.c) + Math.abs(a.r - b.r) !== 1) return null; + const A = board[a.r][a.c]; + const B = board[b.r][b.c]; + if (!A || !B) return null; + + if (A.special === SPECIAL.HYPER || B.special === SPECIAL.HYPER) { + const keys = new Set(); + const hyperOverrides = new Map(); + let bonus; + if (A.special === SPECIAL.HYPER && B.special === SPECIAL.HYPER) { + // Double hypercube: the whole board goes up. + for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) keys.add(key(c, r)); + hyperOverrides.set(key(a.c, a.r), null); + hyperOverrides.set(key(b.c, b.r), null); + bonus = 2000; + } else { + const hyper = A.special === SPECIAL.HYPER ? a : b; + const other = A.special === SPECIAL.HYPER ? B : A; + keys.add(key(hyper.c, hyper.r)); + hyperOverrides.set(key(hyper.c, hyper.r), other.color); + bonus = 500; + } + return runCascades(state, rng, { preClear: { keys, bonus, hyperOverrides } }); + } + + board[a.r][a.c] = B; + board[b.r][b.c] = A; + if (!findRuns(board).length) { + board[a.r][a.c] = A; + board[b.r][b.c] = B; + return null; + } + return runCascades(state, rng, { swapKeys: [key(b.c, b.r), key(a.c, a.r)] }); +} + +// When the clock runs out every special left on the board detonates, +// repeatedly, until none remain. +export function lastHurrah(state, rng = Math.random) { + const board = state.board; + const phases = []; + for (let round = 0; round < 12; round++) { + const keys = new Set(); + for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) { + if (board[r][c] && board[r][c].special !== SPECIAL.NONE) keys.add(key(c, r)); + } + if (!keys.size) break; + phases.push(...runCascades(state, rng, { preClear: { keys }, allowMult: false })); + } + return phases; +} + +// ── Move search / shuffle ─────────────────────────────────────────────────── + +export function findMove(board) { + for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) { + if (board[r][c]?.special === SPECIAL.HYPER) { + const b = c + 1 < COLS ? { c: c + 1, r } : { c: c - 1, r }; + return { a: { c, r }, b }; + } + } + for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) { + for (const [dc, dr] of [[1, 0], [0, 1]]) { + const c2 = c + dc, r2 = r + dr; + if (!inBounds(c2, r2)) continue; + const A = board[r][c], B = board[r2][c2]; + if (!A || !B || A.color === B.color) continue; + board[r][c] = B; board[r2][c2] = A; + const hit = findRuns(board).length > 0; + board[r][c] = A; board[r2][c2] = B; + if (hit) return { a: { c, r }, b: { c: c2, r: r2 } }; + } + } + return null; +} + +// Rearrange the existing gems into a fresh layout with no instant matches and +// at least one legal move. Falls back to a brand-new board if that fails. +export function shuffleBoard(state, rng = Math.random) { + const gems = []; + for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) gems.push(state.board[r][c]); + + for (let attempt = 0; attempt < 300; attempt++) { + for (let i = gems.length - 1; i > 0; i--) { + const j = (rng() * (i + 1)) | 0; + [gems[i], gems[j]] = [gems[j], gems[i]]; + } + const board = []; + for (let r = 0; r < ROWS; r++) board[r] = gems.slice(r * COLS, (r + 1) * COLS); + if (!findRuns(board).length && findMove(board)) { + state.board = board; + state.noMoves = false; + return board; + } + } + state.board = randomBoard(rng); + state.noMoves = false; + return state.board; +} diff --git a/public/src/games/minimotorways/MiniMotorwaysGame.js b/public/src/games/minimotorways/MiniMotorwaysGame.js new file mode 100644 index 0000000..a701ed7 --- /dev/null +++ b/public/src/games/minimotorways/MiniMotorwaysGame.js @@ -0,0 +1,1251 @@ +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; + +// World-space depths. +const D = { + land: 0, waterFx: 1, roads: 2, 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]); + } + } + + // 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); + + 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); + + // 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); + 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; + rec.c.setPosition(x, y); + 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]); + } +} diff --git a/public/src/games/minimotorways/MiniMotorwaysLogic.js b/public/src/games/minimotorways/MiniMotorwaysLogic.js new file mode 100644 index 0000000..a8fdeb0 --- /dev/null +++ b/public/src/games/minimotorways/MiniMotorwaysLogic.js @@ -0,0 +1,1196 @@ +// 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; + } +} diff --git a/public/src/main.js b/public/src/main.js index e623f67..42e4264 100644 --- a/public/src/main.js +++ b/public/src/main.js @@ -70,6 +70,8 @@ import MahjongMatchGame from './games/mahjongmatch/MahjongMatchGame.js'; import MahjongGame from './games/mahjong/MahjongGame.js'; import JewelQuestGame from './games/jewelquest/JewelQuestGame.js'; import ZumaGame from './games/zuma/ZumaGame.js'; +import BejeweledGame from './games/bejeweled/BejeweledGame.js'; +import MiniMotorwaysGame from './games/minimotorways/MiniMotorwaysGame.js'; const config = { type: Phaser.AUTO, @@ -153,6 +155,8 @@ const config = { MahjongGame, JewelQuestGame, ZumaGame, + BejeweledGame, + MiniMotorwaysGame, ], }; diff --git a/public/src/scenes/GameRoomScene.js b/public/src/scenes/GameRoomScene.js index 5276e38..a313425 100644 --- a/public/src/scenes/GameRoomScene.js +++ b/public/src/scenes/GameRoomScene.js @@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene { } create() { - const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame' }; + const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame' }; if (slugDispatch[this.game.slug]) { this.scene.start(slugDispatch[this.game.slug], { game: this.game, diff --git a/server/games/registry.js b/server/games/registry.js index 7de12d0..7693290 100644 --- a/server/games/registry.js +++ b/server/games/registry.js @@ -85,3 +85,5 @@ registerGame({ slug: 'mahjongmatch', name: 'Mahjong Match', category: ' registerGame({ slug: 'mahjong', name: 'Mahjong', category: 'tabletop', minPlayers: 4, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, hasTutorial: true, iconFrame: 58 }); registerGame({ slug: 'jewelquest', name: 'Jewel Quest', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 59 }); registerGame({ slug: 'zuma', name: 'Zuma', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 60 }); +registerGame({ slug: 'bejeweled', name: 'Bejeweled Blitz', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 61 }); +registerGame({ slug: 'minimotorways', name: 'Mini Motorways', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 62 }); diff --git a/server/scripts/verifyBejeweled.js b/server/scripts/verifyBejeweled.js new file mode 100644 index 0000000..bf583c6 --- /dev/null +++ b/server/scripts/verifyBejeweled.js @@ -0,0 +1,281 @@ +// Headless verification for Bejeweled Blitz. +// node server/scripts/verifyBejeweled.js +// Exits non-zero on any failure. +// +// 1. Fixture tests: special-gem creation and detonation on hand-built boards. +// 2. Monte-carlo self-play: thousands of random moves with invariants checked +// after every resolution (board full, no resting matches, phases coherent). +// 3. Last Hurrah and shuffle sanity. + +import { + COLS, ROWS, SPECIAL, GEM_COLORS, + newGame, applyMove, lastHurrah, findMove, findRuns, shuffleBoard, randomBoard, +} from '../../public/src/games/bejeweled/BejeweledLogic.js'; + +let failures = 0; +function check(name, cond, detail = '') { + if (cond) { console.log(` ok ${name}`); return; } + failures++; + console.error(` FAIL ${name}${detail ? ` — ${detail}` : ''}`); +} + +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; + }; +} + +// Build a board from 8 strings of 8 colour initials (r/o/y/g/b/p/w). +// Uppercase suffix markers are handled by the caller via overrides. +const INITIAL = { r: 'red', o: 'orange', y: 'yellow', g: 'green', b: 'blue', p: 'purple', w: 'white' }; +function boardFromStrings(rows, overrides = {}) { + const board = []; + for (let r = 0; r < ROWS; r++) { + board[r] = []; + for (let c = 0; c < COLS; c++) { + const color = INITIAL[rows[r][c]]; + board[r][c] = { color, special: SPECIAL.NONE }; + } + } + for (const [k, v] of Object.entries(overrides)) { + const [c, r] = k.split(',').map(Number); + board[r][c] = { ...board[r][c], ...v }; + } + return board; +} + +function boardFull(board) { + for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) { + const cell = board[r][c]; + if (!cell) return false; + if (cell.special !== SPECIAL.HYPER && !GEM_COLORS.includes(cell.color)) return false; + } + return true; +} + +console.log('Fixture: 3-match clears, board refills'); +{ + // Swapping (0,1)↔(0,0)? Build a guaranteed vertical 3-match: column 0 has + // red at rows 1,2 and a red arrives at row 0 via swap from (1,0). + const rows = [ + 'rgybgypg', + 'grybyopw', + 'gboprwyb', + 'ywgwobry', + 'obrygwpo', + 'wpogrbwy', + 'rygbpoyr', + 'bowyrgbw', + ]; + const board = boardFromStrings(rows); + const state = { board, multiplier: 1, noMoves: false }; + // (1,0) is 'g'; swap with (0,0)='r'? col0 rows1,2 are g,g → moving g to (0,0) makes col0 g,g,g. + const phases = applyMove(state, { c: 1, r: 0 }, { c: 0, r: 0 }, mulberry32(7)); + check('legal swap returns phases', Array.isArray(phases) && phases.length >= 1); + check('phase 1 cleared 3+ gems', phases && phases[0].cleared.length >= 3); + check('phase points positive', phases && phases[0].points > 0); + check('board still full after resolution', boardFull(state.board)); + check('no resting matches', findRuns(state.board).length === 0); +} + +console.log('Fixture: illegal swap rejected, board untouched'); +{ + const state = newGame(mulberry32(3)); + const snapshot = JSON.stringify(state.board); + // Find a swap that yields no match. + let rejected = false; + outer: + for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS - 1; c++) { + const res = applyMove(state, { c, r }, { c: c + 1, r }, mulberry32(4)); + if (res === null) { rejected = true; break outer; } + // applyMove mutated the board — restore for the next probe. + state.board = JSON.parse(snapshot); + } + check('some non-matching swap was rejected', rejected); + check('rejected swap left board unchanged', JSON.stringify(state.board) === snapshot); + check('non-adjacent swap rejected', applyMove(state, { c: 0, r: 0 }, { c: 2, r: 0 }) === null); +} + +console.log('Fixture: match 4 spawns a Flame gem'); +{ + const rows = [ + 'gybgypgo', + 'rrwryopw', + 'gboprwyb', + 'ywgwobry', + 'obrygwpo', + 'wpogrbwy', + 'rygbpoyr', + 'bowyrgbw', + ]; + // Row 1: r r w r — swapping (2,1)'w' with (2,0)'b'? need the 'w' replaced by r. + // Instead swap (2,1)↔(2,2): (2,2)='o'… simpler: put r at (2,0) and swap down. + const board = boardFromStrings(rows, { '2,0': { color: 'red' } }); + const state = { board, multiplier: 1, noMoves: false }; + const phases = applyMove(state, { c: 2, r: 0 }, { c: 2, r: 1 }, mulberry32(9)); + check('4-match resolved', phases !== null); + const spawns = phases ? phases.flatMap((p) => p.spawns) : []; + check('flame gem spawned', spawns.some((s) => s.special === SPECIAL.FLAME && s.color === 'red'), + JSON.stringify(spawns)); +} + +console.log('Fixture: match 5 spawns a Hypercube; hyper swap zaps a colour'); +{ + const rows = [ + 'gybgypgo', + 'rrwrropw', + 'gboprwyb', + 'ywgwobry', + 'obrygwpo', + 'wpogrbwy', + 'rygbpoyr', + 'bowyrgbw', + ]; + // Row 1 becomes r r r r r after dropping a red into (2,1) from (2,0). + const board = boardFromStrings(rows, { '2,0': { color: 'red' } }); + const state = { board, multiplier: 1, noMoves: false }; + const phases = applyMove(state, { c: 2, r: 0 }, { c: 2, r: 1 }, mulberry32(11)); + const spawns = phases ? phases.flatMap((p) => p.spawns) : []; + const hyperSpawn = spawns.find((s) => s.special === SPECIAL.HYPER); + check('hypercube spawned from 5-match', !!hyperSpawn, JSON.stringify(spawns)); + + // Now swap the hypercube with a neighbour and confirm a colour sweep. + if (hyperSpawn) { + // The hyper may have fallen; find it. + let pos = null; + for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) { + if (state.board[r][c]?.special === SPECIAL.HYPER) pos = { c, r }; + } + check('hypercube present on board', !!pos); + if (pos) { + const nb = pos.c > 0 ? { c: pos.c - 1, r: pos.r } : { c: pos.c + 1, r: pos.r }; + const target = state.board[nb.r][nb.c].color; + const before = JSON.stringify(state.board); + const hp = applyMove(state, pos, nb, mulberry32(13)); + check('hyper swap always legal', hp !== null); + if (hp) { + const ev = hp.flatMap((p) => p.events).find((e) => e.type === 'hyper'); + check('hyper event fired with swapped colour', !!ev && ev.color === target, + `event=${JSON.stringify(ev)} target=${target}`); + check('board full after hyper sweep', boardFull(state.board)); + } else { + state.board = JSON.parse(before); + } + } + } +} + +console.log('Fixture: L-match spawns a Star; star detonation clears row+col'); +{ + const rows = [ + 'rwbgypgo', + 'rgwbropw', + 'gboprwyb', + 'ywgwobry', + 'obrygwpo', + 'wpogrbwy', + 'rygbpoyr', + 'bowyrgbw', + ]; + // Column 0: r r g … and row 0: r w b — swap (1,0)'g' row0? Build L: + // put red at (1,1) wait — simpler: col0 rows0,1 red; row2 'g b o' — make + // row 2 start r ? Use overrides: row2 col1,col2 red → swapping (0,2)'g' + // with (1,2)? Instead: cells (0,0),(0,1) red vertical; (1,2),(2,2) red + // horizontal… Build explicitly: + const board = boardFromStrings(rows, { + '0,0': { color: 'red' }, '0,1': { color: 'red' }, // col 0, rows 0,1 + '1,2': { color: 'red' }, '2,2': { color: 'red' }, // row 2, cols 1,2 + '0,3': { color: 'green' }, // below the L corner + '0,2': { color: 'blue' }, '1,3': { color: 'yellow' }, + }); + // Swap (1,3)? The corner (0,2) needs red: swap (0,2)'blue' with… place red at (1,2)? taken. + // Give (0,3) red and swap it up into (0,2): col0 r,r,[r] + row2 [r],r,r → L of 5. + board[3][0] = { color: 'red', special: SPECIAL.NONE }; + board[2][0] = { color: 'blue', special: SPECIAL.NONE }; + const state = { board, multiplier: 1, noMoves: false }; + const phases = applyMove(state, { c: 0, r: 3 }, { c: 0, r: 2 }, mulberry32(17)); + check('L-swap resolved', phases !== null); + const spawns = phases ? phases.flatMap((p) => p.spawns) : []; + check('star gem spawned from L', spawns.some((s) => s.special === SPECIAL.STAR), + JSON.stringify(spawns)); +} + +console.log('Monte-carlo self-play'); +{ + const rng = mulberry32(42); + let totalMoves = 0; + let totalPhases = 0; + let specialsSeen = 0; + let multsSeen = 0; + let maxCascade = 0; + let invariantsOk = true; + let pointsOk = true; + + for (let game = 0; game < 60; game++) { + const state = newGame(rng); + for (let move = 0; move < 80; move++) { + const mv = findMove(state.board); + if (!mv) { shuffleBoard(state, rng); continue; } + const phases = applyMove(state, mv.a, mv.b, rng); + if (!phases) { invariantsOk = false; console.error(' findMove suggested an illegal move', mv); break; } + totalMoves++; + totalPhases += phases.length; + for (const p of phases) { + maxCascade = Math.max(maxCascade, p.cascade); + if (p.points <= 0) pointsOk = false; + specialsSeen += p.spawns.length; + multsSeen += p.events.filter((e) => e.type === 'mult').length; + // falls/refills coherence: every refill lands on a distinct cell. + const seen = new Set(); + for (const f of p.refills) { + const k = `${f.c},${f.r}`; + if (seen.has(k)) { invariantsOk = false; console.error(' duplicate refill cell', k); } + seen.add(k); + } + } + if (!boardFull(state.board)) { invariantsOk = false; console.error(' board has holes after move'); break; } + if (findRuns(state.board).length) { invariantsOk = false; console.error(' resting matches after move'); break; } + if (state.multiplier < 1 || state.multiplier > 8) { invariantsOk = false; console.error(' multiplier out of range'); break; } + } + if (!invariantsOk) break; + } + check('played 4000+ moves', totalMoves >= 4000, `moves=${totalMoves}`); + check('all invariants held', invariantsOk); + check('all phases scored points', pointsOk); + check('cascades occurred', totalPhases > totalMoves, `phases=${totalPhases}`); + check('special gems spawned', specialsSeen > 0, `specials=${specialsSeen}`); + check('multiplier gems appeared', multsSeen > 0, `mults=${multsSeen}`); + console.log(` info moves=${totalMoves} phases=${totalPhases} specials=${specialsSeen} mults=${multsSeen} maxCascade=${maxCascade}`); +} + +console.log('Last Hurrah & shuffle'); +{ + const rng = mulberry32(99); + const state = newGame(rng); + // Seed some specials by hand. + state.board[7][0].special = SPECIAL.FLAME; + state.board[7][3].special = SPECIAL.STAR; + state.board[7][6] = { color: null, special: SPECIAL.HYPER }; + state.board[6][2].special = SPECIAL.MULT; + const phases = lastHurrah(state, rng); + check('last hurrah produced phases', phases.length >= 1); + check('last hurrah detonated events', phases.flatMap((p) => p.events).length >= 3); + let specialsLeft = 0; + for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) { + if (state.board[r][c].special !== SPECIAL.NONE) specialsLeft++; + } + check('no specials remain after last hurrah', specialsLeft === 0, `left=${specialsLeft}`); + check('board full after last hurrah', boardFull(state.board)); + + const s2 = { board: randomBoard(rng), multiplier: 1, noMoves: false }; + shuffleBoard(s2, rng); + check('shuffle leaves no resting matches', findRuns(s2.board).length === 0); + check('shuffle leaves a legal move', !!findMove(s2.board)); +} + +console.log(failures ? `\n${failures} FAILURE(S)` : '\nAll checks passed.'); +process.exit(failures ? 1 : 0); diff --git a/server/scripts/verifyMiniMotorways.js b/server/scripts/verifyMiniMotorways.js new file mode 100644 index 0000000..356cf63 --- /dev/null +++ b/server/scripts/verifyMiniMotorways.js @@ -0,0 +1,380 @@ +// Headless verification for Mini Motorways. +// node server/scripts/verifyMiniMotorways.js +// Exits non-zero on any failure. +// +// 1. City generation invariants for all six cities. +// 2. Road adjacency fixtures (diagonal crossing rule, costs). +// 3. Pathfinder fixtures (straight runs, bridges, motorway shortcuts). +// 4. Overflow with no roads ends the game. +// 5. Monte-carlo bot: 15 simulated weeks per city with invariant checks. + +import { + WORLD_W, WORLD_H, TERRAIN, TUNE, CITIES, COLOR_NAMES, + Sim, generateCity, keyOf, xOf, yOf, octile, +} from '../../public/src/games/minimotorways/MiniMotorwaysLogic.js'; + +let failures = 0; +function check(name, cond, detail = '') { + if (cond) { console.log(` ok ${name}`); return; } + failures++; + console.error(` FAIL ${name}${detail ? ` — ${detail}` : ''}`); +} + +// A sim stripped to bare terrain for hand-built network fixtures. +function fixtureSim() { + const sim = new Sim(0, 12345); + sim.terrain.fill(TERRAIN.LAND); + sim.houses = []; + sim.buildings = []; + sim.cars = []; + sim.roads.clear(); + sim.bridgeCells.clear(); + sim.bridges = []; + sim.items.clear(); + sim.motorways = []; + sim.portals.clear(); + sim.cellOcc.clear(); + sim.touchNetwork(); + return sim; +} + +// ── 1. City generation ───────────────────────────────────────────────────────── + +console.log('City generation'); +{ + const r = { x0: (WORLD_W - 20) / 2, y0: (WORLD_H - 12) / 2, w: 20, h: 12 }; + for (let ci = 0; ci < CITIES.length; ci++) { + for (const seed of [1, 777, 424242]) { + const { terrain } = generateCity(ci, seed); + let bad = terrain.length !== WORLD_W * WORLD_H; + let water = 0; let startWater = 0; + for (let i = 0; i < terrain.length; i++) { + if (terrain[i] > 2) bad = true; + if (terrain[i] === TERRAIN.WATER) { + water++; + const x = xOf(i); const y = yOf(i); + if (x >= r.x0 && x < r.x0 + r.w && y >= r.y0 && y < r.y0 + r.h) startWater++; + } + } + check(`${CITIES[ci].name} seed ${seed}: terrain valid`, !bad); + check(`${CITIES[ci].name} seed ${seed}: start rect ≥80% land`, + startWater <= r.w * r.h * 0.2, `water=${startWater}`); + const expectsWater = CITIES[ci].gen.rivers > 0 || CITIES[ci].gen.lakes > 0; + if (expectsWater) check(`${CITIES[ci].name} seed ${seed}: has water`, water > 0); + } + } +} + +// ── 2. Adjacency ─────────────────────────────────────────────────────────────── + +console.log('Adjacency / crossing rule'); +{ + const sim = fixtureSim(); + // 2x2 all-road square: diagonals must be suppressed, orthogonals intact. + const square = [keyOf(5, 5), keyOf(6, 5), keyOf(5, 6), keyOf(6, 6)]; + for (const k of square) sim.roads.add(k); + sim.touchNetwork(); + let diagonals = 0; let orthogonals = 0; + for (const k of square) { + for (const nb of sim.roadNeighbors(k)) { + if (Math.abs(nb.cost - Math.SQRT2) < 0.001) diagonals++; + else orthogonals++; + } + } + check('2x2 square has zero diagonal edges', diagonals === 0, `diag=${diagonals}`); + check('2x2 square fully orthogonally connected', orthogonals === 8, `orth=${orthogonals}`); + + // Diagonal staircase keeps its diagonal edges at cost √2. + const sim2 = fixtureSim(); + const stairs = [keyOf(10, 10), keyOf(11, 11), keyOf(12, 12)]; + for (const k of stairs) sim2.roads.add(k); + sim2.touchNetwork(); + const mid = sim2.roadNeighbors(keyOf(11, 11)); + check('staircase middle has two diagonal edges', mid.length === 2); + check('diagonal edge costs √2', mid.every((nb) => Math.abs(nb.cost - Math.SQRT2) < 0.001)); + + // Symmetry: every edge runs both ways. + const sim3 = fixtureSim(); + for (let i = 0; i < 60; i++) { + sim3.roads.add(keyOf(2 + Math.floor(Math.random() * 20), 2 + Math.floor(Math.random() * 15))); + } + sim3.touchNetwork(); + let asym = 0; + for (const k of sim3.roads) { + for (const nb of sim3.roadNeighbors(k)) { + if (!sim3.roadNeighbors(nb.k).some((b) => b.k === k)) asym++; + } + } + check('random network adjacency is symmetric', asym === 0, `asym=${asym}`); +} + +// ── 3. Pathfinding ───────────────────────────────────────────────────────────── + +console.log('Pathfinding'); +{ + const sim = fixtureSim(); + for (let x = 1; x <= 10; x++) sim.roads.add(keyOf(x, 1)); + sim.touchNetwork(); + const path = sim.findPath([keyOf(1, 1)], [keyOf(10, 1)]); + check('straight 10-cell road: path found', !!path); + check('straight path has 10 cells', path && path.length === 10, `len=${path?.length}`); + + // Bridge across a 2-wide channel. + const sim2 = fixtureSim(); + for (let y = 0; y < WORLD_H; y++) { + sim2.terrain[keyOf(12, y)] = TERRAIN.WATER; + sim2.terrain[keyOf(13, y)] = TERRAIN.WATER; + } + for (let x = 8; x <= 11; x++) sim2.roads.add(keyOf(x, 5)); + for (let x = 14; x <= 17; x++) sim2.roads.add(keyOf(x, 5)); + sim2.touchNetwork(); + check('channel blocks path', sim2.findPath([keyOf(8, 5)], [keyOf(17, 5)]) === null); + sim2.stock.bridges = 1; + const span = [keyOf(12, 5), keyOf(13, 5)]; + check('bridge placement allowed', sim2.canPlaceBridge(span)); + sim2.placeBridge(span); + const over = sim2.findPath([keyOf(8, 5)], [keyOf(17, 5)]); + check('bridge connects the banks', !!over && over.length === 10, `len=${over?.length}`); + check('bridge cells marked', sim2.bridgeCells.has(span[0]) && sim2.bridgeCells.has(span[1])); + sim2.eraseBridgeAt(span[0]); + check('erasing bridge disconnects again', sim2.findPath([keyOf(8, 5)], [keyOf(17, 5)]) === null); + check('bridge stock refunded', sim2.stock.bridges === 1); + + // Motorway shortcut. + const sim3 = fixtureSim(); + for (let x = 1; x <= 30; x++) sim3.roads.add(keyOf(x, 1)); + sim3.touchNetwork(); + sim3.stock.motorways = 1; + const a = keyOf(1, 2); const b = keyOf(30, 2); + check('motorway placement allowed', sim3.canPlaceMotorway(a, b)); + sim3.placeMotorway(a, b); + const quick = sim3.findPath([keyOf(1, 1)], [keyOf(30, 1)]); + check('motorway path found', !!quick); + check('path takes the portal', quick && quick.includes(a) && quick.includes(b)); + check('portal route is short', quick && quick.length <= 6, `len=${quick?.length}`); + sim3.eraseMotorwayAt(a); + const slow = sim3.findPath([keyOf(1, 1)], [keyOf(30, 1)]); + check('after erase, path is the long way', !!slow && slow.length === 30, `len=${slow?.length}`); + check('motorway stock refunded', sim3.stock.motorways === 1); + + // Intersection items need ≥3 connections. + const sim4 = fixtureSim(); + sim4.roads.add(keyOf(5, 5)); sim4.roads.add(keyOf(4, 5)); sim4.roads.add(keyOf(6, 5)); + sim4.touchNetwork(); + sim4.stock.lights = 1; + check('light rejected on straight road', !sim4.canPlaceItem(keyOf(5, 5), 'light')); + sim4.roads.add(keyOf(5, 4)); + sim4.touchNetwork(); + check('light allowed on T-junction', sim4.canPlaceItem(keyOf(5, 5), 'light')); +} + +// ── 4. Overflow ends the game ────────────────────────────────────────────────── + +console.log('Overflow → game over'); +{ + const sim = new Sim(0, 99); + let gameOverEvent = null; + let guard = 0; + while (!sim.gameOver && guard++ < 10000) { + const events = sim.step(100); + for (const e of events) { + if (e.type === 'weekEnd') sim.chooseUpgrade(0); + if (e.type === 'gameOver') gameOverEvent = e; + } + } + check('roadless city overflows to game over', sim.gameOver); + check('game over event emitted with culprit', !!gameOverEvent && gameOverEvent.buildingId > 0); + check('overflow timing plausible', sim.time > 60000 && sim.time < 400000, `t=${sim.time}`); + check('score stayed at zero', sim.score === 0); +} + +// ── 5. Monte-carlo bot ───────────────────────────────────────────────────────── + +console.log('Monte-carlo bot, 15 weeks per city'); + +// Weighted search over buildable cells (4-connected) between two structures, +// then pave the path. Water is allowed at a steep cost; runs of water ≤3 cells +// become bridges, longer runs abort the connection. +function botConnect(sim, fromCells, toCells) { + const from = Array.isArray(fromCells) ? fromCells : [fromCells]; + const to = new Set(Array.isArray(toCells) ? toCells : [toCells]); + const structs = new Set([...from, ...to]); + const occ = sim.buildOccupiedSet(); + const cellCost = (k) => { + if (structs.has(k)) return 1; + if (sim.terrain[k] === TERRAIN.WATER) return sim.roads.has(k) ? 1 : 6; + if (sim.terrain[k] === TERRAIN.LAND && !occ.has(k)) return 1; + return Infinity; + }; + + const dist = new Map(); + const prev = new Map(); + const open = [...from.map((k) => [0, k])]; + for (const k of from) dist.set(k, 0); + let hit = null; + while (open.length && hit === null) { + open.sort((a, b) => a[0] - b[0]); + const [d, k] = open.shift(); + if (d > (dist.get(k) ?? Infinity)) continue; + if (to.has(k)) { hit = k; break; } + const x = xOf(k); const y = yOf(k); + for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { + const nx = x + dx; const ny = y + dy; + if (nx < 0 || nx >= WORLD_W || ny < 0 || ny >= WORLD_H) continue; + const nk = keyOf(nx, ny); + const c = cellCost(nk); + if (c === Infinity) continue; + const nd = d + c; + if (nd < (dist.get(nk) ?? Infinity)) { + dist.set(nk, nd); + prev.set(nk, k); + open.push([nd, nk]); + } + } + } + if (hit === null) return false; + + const path = []; + for (let k = hit; k !== undefined; k = prev.get(k)) path.push(k); + path.reverse(); + + // Validate water runs first: each must be ≤3 cells and straight. + const runs = []; + let run = []; + for (const k of path) { + if (sim.terrain[k] === TERRAIN.WATER && !sim.roads.has(k)) { run.push(k); continue; } + if (run.length) { runs.push(run); run = []; } + } + if (run.length) runs.push(run); + for (const r of runs) { + if (r.length > 3) return false; + const straight = r.every((k) => xOf(k) === xOf(r[0])) || r.every((k) => yOf(k) === yOf(r[0])); + if (!straight) return false; + } + + for (const r of runs) { + if (sim.stock.bridges < 1 || !sim.placeBridge(r)) return false; + } + for (const k of path) { + if (!structs.has(k) && !sim.roads.has(k) && sim.terrain[k] !== TERRAIN.WATER) sim.placeRoad(k); + } + return true; +} + +function botHandleSpawn(sim, e) { + if (e.type === 'houseSpawn') { + const targets = sim.buildings.filter((b) => b.color === e.color); + targets.sort((p, q) => octile(xOf(p.k), yOf(p.k), xOf(e.k), yOf(e.k)) + - octile(xOf(q.k), yOf(q.k), xOf(e.k), yOf(e.k))); + for (const t of targets.slice(0, 4)) { + if (botConnect(sim, e.k, t.cells)) return; + } + } else if (e.type === 'buildingSpawn') { + const building = sim.buildings.find((b) => b.id === e.id); + const homes = sim.houses.filter((h) => h.color === e.color && h.k !== e.k); + homes.sort((p, q) => octile(xOf(p.k), yOf(p.k), xOf(e.k), yOf(e.k)) + - octile(xOf(q.k), yOf(q.k), xOf(e.k), yOf(e.k))); + for (const h of homes.slice(0, 4)) { + if (botConnect(sim, h.k, building.cells)) return; + } + } +} + +// Late repairs: any building accumulating pins with nothing en route gets a +// fresh connection attempt to its nearest same-colour houses. +function botRescue(sim) { + for (const b of sim.buildings) { + if (b.pins < 3 || (b.reserved > 0 && b.pins < 5)) continue; + const homes = sim.houses.filter((h) => h.color === b.color); + homes.sort((p, q) => octile(xOf(p.k), yOf(p.k), xOf(b.k), yOf(b.k)) + - octile(xOf(q.k), yOf(q.k), xOf(b.k), yOf(b.k))); + for (const h of homes.slice(0, 3)) { + if (botConnect(sim, h.k, b.cells)) break; + } + } +} + +function checkInvariants(sim, label) { + for (const car of sim.cars) { + if (Number.isNaN(car.x) || Number.isNaN(car.y) || Number.isNaN(car.pos)) { + return `${label}: NaN in car ${car.id} (${car.state})`; + } + } + if (sim.cars.length > TUNE.CAR_CAP) return `${label}: car cap exceeded (${sim.cars.length})`; + for (const b of sim.buildings) { + if (b.reserved > b.pins) return `${label}: reserved ${b.reserved} > pins ${b.pins} at building ${b.id}`; + if (b.ring < 0 || b.ring > 1.2) return `${label}: ring out of range ${b.ring}`; + } + for (const k of sim.roads) { + for (const nb of sim.roadNeighbors(k)) { + if (!sim.roadNeighbors(nb.k).some((x) => x.k === k)) { + return `${label}: asymmetric edge ${k}→${nb.k}`; + } + } + } + return null; +} + +{ + const targetWeeks = 15; + for (let ci = 0; ci < CITIES.length; ci++) { + const sim = new Sim(ci, 1000 + ci); + sim.stock.roads = 99999; + sim.stock.bridges += 50; + + // Wire up the structures spawned during construction. + for (const h of sim.houses) { + const targets = sim.buildings.filter((b) => b.color === h.color); + if (targets.length) botConnect(sim, h.k, targets[0].cells); + } + + let lastScore = 0; + let scoreRegressed = false; + let invariantError = null; + let steps = 0; + let stranded = 0; + + while (sim.week < targetWeeks && !sim.gameOver && steps < 30000) { + const events = sim.step(100); + steps++; + for (const e of events) { + if (e.type === 'weekEnd') sim.chooseUpgrade(0); + else if (e.type === 'houseSpawn' || e.type === 'buildingSpawn' || e.type === 'colorUnlock') { + if (e.type !== 'colorUnlock') botHandleSpawn(sim, e); + } else if (e.type === 'stranded') stranded++; + } + if (sim.score < lastScore) scoreRegressed = true; + lastScore = sim.score; + + // Exercise erase + reroute: pull a road cell out from under traffic, + // then put it back two ticks later. + if (steps % 600 === 300 && sim.roads.size > 10) { + const plain = [...sim.roads].filter((k) => !sim.bridgeCells.has(k) && !sim.portals.has(k)); + if (plain.length) { + const victim = plain[Math.floor(Math.random() * plain.length)]; + sim.eraseRoad(victim); + sim.step(100); steps++; + sim.placeRoad(victim); + } + } + + if (steps % 100 === 0) botRescue(sim); + if (steps % 50 === 0 && !invariantError) { + invariantError = checkInvariants(sim, `${CITIES[ci].name} step ${steps}`); + } + } + + const name = CITIES[ci].name; + check(`${name}: no invariant violations`, !invariantError, invariantError ?? ''); + check(`${name}: score is positive`, sim.score > 0, `score=${sim.score}`); + check(`${name}: score never regressed`, !scoreRegressed); + check(`${name}: survived or died legitimately`, + sim.week >= targetWeeks || sim.gameOver, `week=${sim.week} steps=${steps}`); + console.log(` ${name}: weeks=${sim.week} score=${sim.score} cars=${sim.cars.length} ` + + `houses=${sim.houses.length} buildings=${sim.buildings.length} roads=${sim.roads.size} ` + + `stranded=${stranded} gameOver=${sim.gameOver}`); + } +} + +console.log(failures ? `\n${failures} FAILURE(S)` : '\nAll checks passed.'); +process.exit(failures ? 1 : 0);