fertig-classic-games/src/games/advancewars/AdvanceWarsGame.js

617 lines
23 KiB
JavaScript

// 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(),
}));
});
}
}