// Advance Wars — faithful AW1-style turn-based tactics. Campaign + War Room. // The scene is a thin coordinator: all rules live in AdvanceWarsLogic (pure, // Node-testable), the CPU in AdvanceWarsAI, rendering in AdvanceWarsMapView, // HUD/menus in AdvanceWarsUI, full-screen flows in AdvanceWarsScreens. import * as Phaser from 'phaser'; import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js'; import { MusicPlayer } from '../../ui/MusicPlayer.js'; import { getGameSoundtrack } from '../../services/soundtrack.js'; import { api } from '../../services/api.js'; import { applyArcadeCRTOverlay } from '../../ui/ArcadeCRTOverlay.js'; import { compileRules } from './AdvanceWarsRules.js'; import * as Logic from './AdvanceWarsLogic.js'; import { runAITurn } from './AdvanceWarsAI.js'; import { AdvanceWarsMapView, ensureSheets, armyColorInt } from './AdvanceWarsMapView.js'; import { AdvanceWarsHUD, ActionMenu, DamagePreview, ProductionMenu, TileInfo, mkButton, mkText } from './AdvanceWarsUI.js'; import * as Screens from './AdvanceWarsScreens.js'; import { playBattleAnim } from './AdvanceWarsBattleAnim.js'; const SAVE_KEY = 'advancewars-save'; const ANIM_KEY = 'advancewars-battle-anims'; const OBJECTIVE_TEXT = { rout: 'Destroy all enemy units!', hq: 'Capture the enemy HQ (or rout them)!', capturecount: 'Capture properties!', survive: 'Survive!', }; export default class AdvanceWarsGame extends Phaser.Scene { constructor() { super('AdvanceWarsGame'); } init(data) { this.gameDef = data.game ?? { slug: 'advancewars', name: 'Advance Wars' }; this.rules = null; this.campaign = null; this.oppById = {}; this.screen = null; // current full-screen flow controller this.run = null; // active mission runtime this.levelsCompleted = 0; this.battleAnims = localStorage.getItem(ANIM_KEY) !== '0'; } async create() { try { const { tracks, volume } = getGameSoundtrack(this); if (tracks.length) this.music = new MusicPlayer(this, tracks, volume); else { const fallback = this.cache.json.get('music')?.tracks ?? []; if (fallback.length) this.music = new MusicPlayer(this, fallback); } } catch (_) { /* optional */ } this.input.mouse?.disableContextMenu(); this.rules = compileRules(this.cache.json.get('advancewars-rules')); this.campaign = this.cache.json.get('advancewars-campaign'); this.tex = ensureSheets(this, this.rules); this.crt = applyArcadeCRTOverlay(this, { accentTint: 0xff8c3a, scanlineTint: 0x69d2ff, scanlineAlpha: 0.35 }); this.events.once('shutdown', () => { this.crt.destroy(); this.teardownRun(); }); try { const data = await (await fetch('data/opponents.json')).json(); for (const o of data.opponents ?? []) this.oppById[o.id] = o; } catch (_) { /* portraits degrade to sprite/fallback */ } try { const res = await api.get('/puzzles/advancewars/progress'); this.levelsCompleted = res?.levelsCompleted ?? 0; } catch (_) { this.levelsCompleted = 0; } if (!this.scene.isActive()) return; this.showMainMenu(); } // ── screen routing ───────────────────────────────────────────────────────── swapScreen(builder) { this.screen?.destroy(); this.screen = builder(); } showMainMenu() { this.teardownRun(); this.swapScreen(() => Screens.mainMenu(this, { hasSave: !!localStorage.getItem(SAVE_KEY), onCampaign: () => this.showCampaign(), onWarRoom: () => this.showWarRoom(), onContinue: () => this.continueSave(), onLeave: () => this.scene.start('GameMenu'), })); } showCampaign() { this.swapScreen(() => Screens.campaignScreen(this, this.rules, this.campaign, this.levelsCompleted, this.oppById, { onPlay: (idx) => this.showBriefing(idx), onBack: () => this.showMainMenu(), })); } showBriefing(missionIdx) { const mission = this.campaign.missions[missionIdx]; this.swapScreen(() => Screens.briefingScreen(this, this.rules, mission, this.oppById, { onDone: () => this.startMission({ mode: 'campaign', missionIdx }), })); } showWarRoom() { this.swapScreen(() => Screens.warRoomScreen(this, this.rules, this.campaign, this.levelsCompleted, this.oppById, { onStart: (cfg) => this.startMission({ mode: 'warroom', cfg }), onBack: () => this.showMainMenu(), })); } continueSave() { try { const raw = JSON.parse(localStorage.getItem(SAVE_KEY)); const state = Logic.deserialize(raw.state); this.startMission(raw.meta, state); } catch (_) { localStorage.removeItem(SAVE_KEY); this.showMainMenu(); } } // ── mission runtime ──────────────────────────────────────────────────────── missionFor(meta) { if (meta.mode === 'campaign') return this.campaign.missions[meta.missionIdx]; // war room: mission map with custom COs/fog/skill const base = this.campaign.missions.find((m) => m.id === meta.cfg.missionId) ?? this.campaign.missions[meta.cfg.mapIdx ?? 0]; return { ...base, name: `War Room: ${base.name}`, playerCo: meta.cfg.playerCo, enemyCos: [meta.cfg.enemyCo], fog: meta.cfg.fog, production: true, startFunds: [3000, 3000], objective: { type: 'rout' }, dayLimit: 60, aiProfile: { skill: meta.cfg.skill, aggression: 0.55, captureWeight: 0.5 }, briefing: [], }; } startMission(meta, restoredState = null) { this.swapScreen(() => null); this.teardownRun(); if (meta.mode === 'warroom' && meta.cfg && !meta.cfg.missionId) { meta.cfg.missionId = this.campaign.missions[meta.cfg.mapIdx]?.id; } const mission = this.missionFor(meta); const state = restoredState ?? Logic.createGame(this.rules, mission.map, { cos: [mission.playerCo, ...mission.enemyCos], fog: mission.fog, production: mission.production, startFunds: mission.startFunds, objective: mission.objective, dayLimit: mission.dayLimit, seed: (Date.now() % 100000) + 1, }); const run = { meta, mission, state, mode: 'idle', // idle | selected | target | busy | over sel: null, busy: false, objs: [], }; this.run = run; run.bg = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x0b0e18, 1).setDepth(0); run.view = new AdvanceWarsMapView(this, this.rules, state, { areaX: 16, areaY: 84, areaW: GAME_WIDTH - 320, areaH: GAME_HEIGHT - 200, }); run.hud = new AdvanceWarsHUD(this, this.rules, { onEndTurn: () => this.onEndTurn(), onMenu: () => this.openPauseMenu(), onPower: () => this.onPower(), }); run.hud.uiKey = run.view.tex.ui; run.hud.buildStars(this.rules, state); run.hud.attachPortraits( this.oppById[this.rules.coById[state.armies[0].co].opponentId], this.oppById[this.rules.coById[state.armies[1].co].opponentId]); run.hud.setObjective(OBJECTIVE_TEXT[state.objective.type] ?? ''); run.menu = new ActionMenu(this); run.preview = new DamagePreview(this); run.prod = new ProductionMenu(this, this.rules, run.view.tex.units); run.tileInfo = new TileInfo(this, this.rules); this.bindBoardInput(); this.refreshAll(); this.saveGame(); } teardownRun() { const run = this.run; if (!run) return; this.input.off('pointermove', this.onPointerMove, this); this.input.off('pointerdown', this.onPointerDown, this); run.view?.destroy(); run.hud?.destroy(); run.menu?.close(); run.preview?.hide(); run.prod?.close(); run.tileInfo?.destroy(); run.bg?.destroy(); for (const o of run.objs) o?.destroy?.(); this.run = null; } refreshAll() { const run = this.run; if (!run) return; run.view.syncUnits(); run.view.refreshFog(0); run.hud.refresh(run.state, 0); } saveGame() { const run = this.run; if (!run || run.state.result) return; try { localStorage.setItem(SAVE_KEY, JSON.stringify({ meta: run.meta, state: Logic.serialize(run.state), })); } catch (_) { /* storage full */ } } // ── input ────────────────────────────────────────────────────────────────── bindBoardInput() { this.input.on('pointermove', this.onPointerMove, this); this.input.on('pointerdown', this.onPointerDown, this); } onPointerMove(p) { const run = this.run; if (!run || run.busy) return; const tile = run.view.tileAt(p.worldX, p.worldY); run.view.setCursor(tile); if (tile) { const u = Logic.unitAt(run.state, tile.x, tile.y); const spotted = u && Logic.visibleUnits(this.rules, run.state, 0).includes(u) ? u : null; run.tileInfo.show(run.state, tile.x, tile.y, spotted); } } onPointerDown(p, objects) { const run = this.run; if (!run || run.busy || run.state.result) return; if (objects?.length) return; // a UI object handled it if (run.menu.isOpen || run.preview.isOpen || run.prod.isOpen) { run.menu.close(); run.preview.hide(); this.cancelSelection(); return; } if (run.state.turn !== 0) return; const tile = run.view.tileAt(p.worldX, p.worldY); if (!tile) return; if (run.mode === 'target') { this.onTargetClick(tile); return; } if (run.mode === 'selected') { this.onDestinationClick(tile, p); return; } this.onIdleClick(tile); } onIdleClick(tile) { const run = this.run; const unit = Logic.unitAt(run.state, tile.x, tile.y); if (unit && unit.army === 0 && !unit.moved) { run.sel = { unit, reach: Logic.reachableTiles(this.rules, run.state, unit), }; run.mode = 'selected'; run.view.showMoveRange([...run.sel.reach.dist.keys()] .filter((k) => Logic.canStopAt(run.state, unit, k % run.state.w, Math.floor(k / run.state.w)))); // indirects: preview firing range from where they stand const spec = this.rules.unitById[unit.type]; if (spec.indirect) { const range = Logic.effectiveRange(this.rules, run.state, unit); const tiles = []; for (let y = 0; y < run.state.h; y++) { for (let x = 0; x < run.state.w; x++) { const d = Math.abs(x - unit.x) + Math.abs(y - unit.y); if (d >= range[0] && d <= range[1]) tiles.push({ x, y }); } } run.view.showAttackTiles(tiles); } return; } if (unit && unit.army === 0 && unit.moved) return; if (!unit) { // factory? const options = Logic.buildOptions(this.rules, run.state, tile.x, tile.y); if (options.length) { run.prod.open(options, run.state.armies[0].funds, (type) => this.doAction({ type: 'build', x: tile.x, y: tile.y, unitType: type }), () => {}); } } } onDestinationClick(tile, pointer) { const run = this.run; const { unit, reach } = run.sel; const key = Logic.tileKey(run.state, tile.x, tile.y); const occ = Logic.unitAt(run.state, tile.x, tile.y); // clicking the unit itself = act in place const inPlace = occ === unit; if (!inPlace && !reach.dist.has(key)) { this.cancelSelection(); return; } // friendly transport → load; friendly same-type → join if (occ && occ !== unit && occ.army === 0) { const tspec = this.rules.unitById[occ.type]; const canLoad = tspec.transport && occ.cargo.length < tspec.transport.cap && (tspec.transport.carries === 'landUnits' ? this.rules.unitById[unit.type].domain === 'land' : tspec.transport.carries.includes(unit.type)); const canJoin = occ.type === unit.type && occ.hp < 100; if (!canLoad && !canJoin) { this.cancelSelection(); return; } const adj = this.bestAdjacentStop(unit, reach, tile.x, tile.y); if (!adj) { this.cancelSelection(); return; } const path = adj.key === reach.start ? [] : Logic.pathFromReach(run.state, reach, adj.key); const opts = []; if (canLoad) opts.push({ label: 'LOAD', cb: () => this.doAction({ type: 'load', unitId: unit.id, path, x: tile.x, y: tile.y }) }); if (canJoin) opts.push({ label: 'JOIN', cb: () => this.doAction({ type: 'join', unitId: unit.id, path, x: tile.x, y: tile.y }) }); opts.push({ label: 'CANCEL', cb: () => this.cancelSelection() }); run.menu.open(pointer.worldX, pointer.worldY, opts); return; } if (!inPlace && !Logic.canStopAt(run.state, unit, tile.x, tile.y)) { this.cancelSelection(); return; } const path = inPlace ? [] : Logic.pathFromReach(run.state, reach, key); run.sel.pending = { x: tile.x, y: tile.y, path }; run.view.showPath(unit, path); this.openActionMenuAt(pointer.worldX, pointer.worldY); } bestAdjacentStop(unit, reach, tx, ty) { const run = this.run; let best = null; for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { const x = tx + dx, y = ty + dy; if (!Logic.inBounds(run.state, x, y)) continue; const k = Logic.tileKey(run.state, x, y); const isStart = k === reach.start; if (!isStart && !reach.dist.has(k)) continue; if (!isStart && !Logic.canStopAt(run.state, unit, x, y)) continue; const d = isStart ? 0 : reach.dist.get(k); if (!best || d < best.d) best = { key: k, d }; } return best; } openActionMenuAt(px, py) { const run = this.run; const { unit } = run.sel; const { x, y, path } = run.sel.pending; const spec = this.rules.unitById[unit.type]; const movedTiles = path.length > 0; const opts = []; const targets = spec.range ? Logic.attackTargetsFrom(this.rules, run.state, unit, x, y, { afterMove: movedTiles }) : []; if (targets.length) opts.push({ label: 'FIRE', cb: () => this.enterTargetMode(targets) }); const k = Logic.tileKey(run.state, x, y); const t = this.rules.terrains[run.state.terrain[k]]; if (spec.capture && t.property && run.state.owner[k] !== 0 && (run.state.owner[k] < 0 || Logic.hostile(run.state, run.state.owner[k], 0))) { opts.push({ label: 'CAPTURE', cb: () => this.doAction({ type: 'capture', unitId: unit.id, path }) }); } if (spec.supplies) { opts.push({ label: 'SUPPLY', cb: () => this.doAction({ type: 'supply', unitId: unit.id, path }) }); } if (spec.transport && unit.cargo.length) { const drops = this.autoDrops(unit, x, y); if (drops.length) { opts.push({ label: 'UNLOAD', cb: () => this.doAction({ type: 'unload', unitId: unit.id, path, drops }) }); } } if (spec.dive && !unit.dived) opts.push({ label: 'DIVE', cb: () => this.doAction({ type: 'dive', unitId: unit.id, path }) }); if (unit.dived) opts.push({ label: 'SURFACE', cb: () => this.doAction({ type: 'rise', unitId: unit.id, path }) }); opts.push({ label: 'WAIT', cb: () => this.doAction({ type: 'wait', unitId: unit.id, path }) }); opts.push({ label: 'CANCEL', cb: () => this.cancelSelection() }); run.menu.open(px, py, opts); } autoDrops(unit, x, y) { const run = this.run; const drops = []; const used = new Set(); for (let ci = unit.cargo.length - 1; ci >= 0; ci--) { const cargo = unit.cargo[ci]; for (const [dx, dy] of [[0, -1], [1, 0], [0, 1], [-1, 0]]) { const nx = x + dx, ny = y + dy; if (!Logic.inBounds(run.state, nx, ny) || used.has(`${nx},${ny}`)) continue; const t = Logic.terrainAt(this.rules, run.state, nx, ny); if (t.cost[this.rules.unitById[cargo.type].moveType] == null) continue; const occ = Logic.unitAt(run.state, nx, ny); if (occ && occ !== unit) continue; used.add(`${nx},${ny}`); drops.push({ cargoIndex: ci, x: nx, y: ny }); break; } } return drops; } enterTargetMode(targets) { const run = this.run; run.mode = 'target'; run.targets = targets; run.view.showAttackTiles(targets.map((t) => ({ x: t.x, y: t.y }))); } onTargetClick(tile) { const run = this.run; const target = run.targets?.find((t) => t.x === tile.x && t.y === tile.y); if (!target) { this.cancelSelection(); return; } const { unit } = run.sel; const { x, y, path } = run.sel.pending; const probe = { ...unit, x, y }; const deal = Logic.computeDamage(this.rules, run.state, probe, target, 0); let counter = null; const tspec = this.rules.unitById[target.type]; if (deal && !tspec.indirect && tspec.range && Math.abs(target.x - x) + Math.abs(target.y - y) === 1 && target.hp - deal.dmg > 0) { const cres = Logic.computeDamage(this.rules, run.state, { ...target, hp: Math.max(1, target.hp - deal.dmg) }, probe, 0); counter = cres ? Math.min(100, cres.dmg) : null; } run.preview.show(run.view.px(target.x), run.view.py(target.y), Math.min(100, deal?.dmg ?? 0), counter, () => this.doAction({ type: 'attack', unitId: unit.id, path, targetId: target.id }), () => { run.preview.hide(); this.cancelSelection(); }); } cancelSelection() { const run = this.run; if (!run) return; run.menu.close(); run.preview.hide(); run.view.clearOverlays(); run.sel = null; run.targets = null; run.mode = 'idle'; run.view.syncUnits(); } // ── executing actions ───────────────────────────────────────────────────── async doAction(action) { const run = this.run; if (!run || run.busy) return; run.menu.close(); run.preview.hide(); run.view.clearOverlays(); run.busy = true; const res = Logic.applyAction(run.state, this.rules, action); run.sel = null; run.mode = 'idle'; if (res.ok) { await this.replayEvents([{ action, events: res.events }], { animateOwn: true }); } run.busy = false; this.refreshAll(); if (run.state.result) { this.onMissionOver(); return; } } onEndTurn() { const run = this.run; if (!run || run.busy || run.state.turn !== 0 || run.state.result) return; this.cancelSelection(); this.runEnemyTurns(); } onPower() { if (!this.run || this.run.busy) return; this.doAction({ type: 'power' }); } async runEnemyTurns() { const run = this.run; run.busy = true; const endRes = Logic.applyAction(run.state, this.rules, { type: 'endTurn' }); await this.replayEvents([{ action: { type: 'endTurn' }, events: endRes.events }], {}); run.hud.refresh(run.state, 0); while (!run.state.result && run.state.turn !== 0 && this.run === run) { const army = run.state.turn; const profile = run.mission.aiProfile ?? { skill: 3, aggression: 0.5, captureWeight: 0.4 }; const log = runAITurn(this.rules, run.state, army, profile); await this.replayEvents(log, { enemy: true }); } if (this.run !== run) return; run.busy = false; this.refreshAll(); if (run.state.result) { this.onMissionOver(); return; } this.saveGame(); } // Replays engine events entry by entry. State is already final; views are // nudged per-event so the player can follow along. async replayEvents(log, { enemy = false } = {}) { const run = this.run; for (const entry of log) { if (this.run !== run) return; const events = entry.events ?? []; const moved = events.find((e) => e.type === 'moved'); if (moved && (!run.state.fog || this.pathPartlyVisible(moved))) { await new Promise((resolve) => run.view.animateMove(moved.unitId, moved.path, resolve)); } const battles = events.filter((e) => e.type === 'battle'); if (battles.length) { const anyVisible = !run.state.fog || battles.some((b) => Logic.computeVision(this.rules, run.state, 0).has(Logic.tileKey(run.state, b.defender.x, b.defender.y))); if (this.battleAnims && anyVisible) { await new Promise((resolve) => playBattleAnim(this, this.rules, run.view.tex.units, battles, resolve)); } this.crt.pulse(0.6, 260); } for (const e of events) { if (e.type === 'destroyed' || e.type === 'crashed') { run.view.boom(e.x, e.y); const v = run.view.unitViews.get(e.unitId); if (v) { v.c.destroy(); run.view.unitViews.delete(e.unitId); } } if (e.type === 'powerFired') { await new Promise((resolve) => Screens.powerCutIn(this, this.rules, this.oppById, run.state.armies[e.army].co, e.army, resolve)); run.hud.refresh(run.state, 0); } if (e.type === 'captured' || e.type === 'capturing') run.view.refreshProps(); if (e.type === 'meteor' || e.type === 'tsunami') this.crt.pulse(1.0, 500); if (e.type === 'dayStart' && e.army === 0) this.saveGame(); } run.view.syncUnits(); run.view.refreshFog(0); run.hud.refresh(run.state, 0); if (enemy && (moved || battles.length)) await this.wait(140); } } pathPartlyVisible(movedEvent) { const run = this.run; const vis = Logic.computeVision(this.rules, run.state, 0); const pts = [movedEvent.from, ...(movedEvent.path ?? []), movedEvent.to]; return pts.some((p) => vis.has(Logic.tileKey(run.state, p.x, p.y))); } wait(ms) { return new Promise((r) => this.time.delayedCall(ms, r)); } // ── pause / mission end ─────────────────────────────────────────────────── openPauseMenu() { const run = this.run; if (!run || run.busy) return; this.cancelSelection(); run.menu.open(GAME_WIDTH / 2 - 240, GAME_HEIGHT / 2 - 120, [ { label: 'RESUME', cb: () => {} }, { label: `ANIMS: ${this.battleAnims ? 'ON' : 'OFF'}`, cb: () => { this.battleAnims = !this.battleAnims; localStorage.setItem(ANIM_KEY, this.battleAnims ? '1' : '0'); }, }, { label: 'SAVE + MENU', cb: () => { this.saveGame(); this.showMainMenu(); } }, { label: 'SURRENDER', danger: true, cb: () => { run.state.result = { winner: 'enemy', reason: 'surrender' }; this.onMissionOver(); } }, ]); } onMissionOver() { const run = this.run; if (!run) return; const won = run.state.result.winner === 'player'; localStorage.removeItem(SAVE_KEY); const days = run.state.day; const limit = run.state.dayLimit || 60; const rank = days <= limit * 0.4 ? 'S' : days <= limit * 0.6 ? 'A' : days <= limit * 0.8 ? 'B' : 'C'; if (won && run.meta.mode === 'campaign') { const level = run.meta.missionIdx + 1; if (level === this.levelsCompleted + 1) this.levelsCompleted = level; api.post('/puzzles/advancewars/complete', { level }).catch(() => {}); } const meta = run.meta; const mission = run.mission; this.time.delayedCall(700, () => { this.teardownRun(); this.swapScreen(() => Screens.resultScreen(this, this.rules, this.oppById, { won, mission, days, rank, onContinue: () => { if (meta.mode === 'campaign' && meta.missionIdx + 1 < this.campaign.missions.length) this.showCampaign(); else this.showMainMenu(); }, onRetry: () => { if (meta.mode === 'campaign') this.showBriefing(meta.missionIdx); else this.startMission(meta); }, onMenu: () => this.showMainMenu(), })); }); } }