feat: add Advance Wars turn-based tactics game
Implement a full Advance Wars clone with campaign (20 missions) and War Room skirmish mode. Includes: - Headless game engine (AdvanceWarsLogic) with complete AW1 rules: terrain movement, combat, capture, production, transports, fuel, fog of war, CO powers, and multiple objective types - AI opponent (skill 1-5, aggression/capture tuning) with BFS pathfinding, threat evaluation, production counter-tables, and fog trap awareness - Canvas-rendered map view with procedural stand-ins that can be replaced by drop-in painted spritesheets (no code changes) - 11 playable COs with unique day-to-day modifiers and powers (Hyper Repair, Lightning Strike, Tsunami, Meteor Strike, etc.) - Campaign briefings with typewriter text and opponent portraits - Comprehensive headless verification suite (rules, combat formula, pathfinding, economy, capture, fog, CO powers, soak tests) - New assets: terrain/building sheets, adventure music tracks, game icon frame 86 - Refactor SpireClimb and SWDBG to use the shared soundtrack service
This commit is contained in:
parent
7713089466
commit
4a8b78c538
|
|
@ -27,13 +27,13 @@
|
|||
"w": 85
|
||||
},
|
||||
{
|
||||
"x": 2224,
|
||||
"y": 3048,
|
||||
"x": 2201,
|
||||
"y": 2859,
|
||||
"w": 72
|
||||
},
|
||||
{
|
||||
"x": 1916,
|
||||
"y": 2797,
|
||||
"x": 1911,
|
||||
"y": 2848,
|
||||
"w": 87
|
||||
},
|
||||
{
|
||||
|
|
@ -67,18 +67,18 @@
|
|||
"w": 72
|
||||
},
|
||||
{
|
||||
"x": 1531,
|
||||
"y": 1152,
|
||||
"x": 1381,
|
||||
"y": 1119,
|
||||
"w": 82
|
||||
},
|
||||
{
|
||||
"x": 1950,
|
||||
"y": 1494,
|
||||
"x": 1885,
|
||||
"y": 1264,
|
||||
"w": 72
|
||||
},
|
||||
{
|
||||
"x": 2198,
|
||||
"y": 1195,
|
||||
"x": 2329,
|
||||
"y": 1146,
|
||||
"w": 85
|
||||
},
|
||||
{
|
||||
|
|
@ -226,6 +226,16 @@
|
|||
"sprite": "tower",
|
||||
"x": 2890,
|
||||
"y": 2935
|
||||
},
|
||||
{
|
||||
"sprite": "neon",
|
||||
"x": 2179,
|
||||
"y": 2731
|
||||
},
|
||||
{
|
||||
"sprite": "lamppost",
|
||||
"x": 2217,
|
||||
"y": 2998
|
||||
}
|
||||
],
|
||||
"coins": [
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 323 KiB After Width: | Height: | Size: 327 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"_comment": "Drop-in painted art for Advance Wars, one sheet per category (spec: src/games/advancewars/sprites.md). Paint a sheet, put it at the suggested path under assets/images/advancewars/, set its path below and reload — no code changes needed. Any sheet left path:null renders procedurally. Rivers and roads are drawn in code and need no art.",
|
||||
"terrainSheet": {
|
||||
"key": "advancewars-terrain",
|
||||
"path": "assets/images/advancewars/advancewars-terrain.png",
|
||||
"frameWidth": 48,
|
||||
"frameHeight": 48
|
||||
},
|
||||
"buildingSheet": {
|
||||
"key": "advancewars-buildings",
|
||||
"path": "assets/images/advancewars/advancewars-buildings.png",
|
||||
"frameWidth": 48,
|
||||
"frameHeight": 64
|
||||
},
|
||||
"unitSheet": {
|
||||
"key": "advancewars-units",
|
||||
"path": null,
|
||||
"frameWidth": 48,
|
||||
"frameHeight": 64
|
||||
},
|
||||
"uiSheet": {
|
||||
"key": "advancewars-ui",
|
||||
"path": null,
|
||||
"frameWidth": 48,
|
||||
"frameHeight": 48
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"tracks": [
|
||||
{
|
||||
"file": "adventure-track01.mp3",
|
||||
"artist": "Aiko",
|
||||
"title": "The Asteroid Field"
|
||||
},
|
||||
{
|
||||
"file": "adventure-track02.mp3",
|
||||
"artist": "Mario",
|
||||
"title": "Enter the Labyrinth"
|
||||
},
|
||||
{
|
||||
"file": "adventure-track03.mp3",
|
||||
"artist": "Balam",
|
||||
"title": "Amongst the Ruins"
|
||||
},
|
||||
{
|
||||
"file": "adventure-track04.mp3",
|
||||
"artist": "Zanthor",
|
||||
"title": "Times Past"
|
||||
},
|
||||
{
|
||||
"file": "adventure-track05.mp3",
|
||||
"artist": "Nadia",
|
||||
"title": "A time after now, but also before"
|
||||
},
|
||||
{
|
||||
"file": "adventure-track06.mp3",
|
||||
"artist": "Kage",
|
||||
"title": "In the Bamboo Trees"
|
||||
}
|
||||
],
|
||||
"volume": 0.5
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
"citySheets is keyed by theme. Adding e.g. an `asian` entry makes it selectable with no code change."
|
||||
],
|
||||
"terrainSheet": { "key": "civilization-terrain", "path": "assets/images/civilization/civilization-terrain.png", "frameWidth": 128, "frameHeight": 96 },
|
||||
"resourceSheet": { "key": "civilization-resources", "path": null, "frameWidth": 64, "frameHeight": 64 },
|
||||
"resourceSheet": { "key": "civilization-resources", "path": "assets/images/civilization/civilization-resources.png", "frameWidth": 64, "frameHeight": 64 },
|
||||
"improvementSheet": { "key": "civilization-improvements", "path": null, "frameWidth": 64, "frameHeight": 64 },
|
||||
"unitSheet": { "key": "civilization-units", "path": "assets/images/civilization/civilization-units.png", "frameWidth": 64, "frameHeight": 96 },
|
||||
"iconSheet": { "key": "civilization-icons", "path": null, "frameWidth": 48, "frameHeight": 48 },
|
||||
|
|
|
|||
|
|
@ -199,6 +199,17 @@ export const MANIFEST = {
|
|||
// its audio only downloads once Super Kart is actually entered.
|
||||
(scene) => musicFrom(scene, 'nintendo-music'),
|
||||
],
|
||||
// Rules + campaign always load; the painted sheet is an optional drop-in
|
||||
// declared in data/advancewars-artwork.json (spec: src/games/advancewars/
|
||||
// sprites.md) — path:null stays procedural. Shares Super Kart's nintendo
|
||||
// soundtrack (see services/soundtrack.js).
|
||||
advancewars: [
|
||||
{ type: 'json', key: 'advancewars-rules', path: 'data/advancewars-rules.json' },
|
||||
{ type: 'json', key: 'advancewars-campaign', path: 'data/advancewars-campaign.json' },
|
||||
(scene) => sheetsFrom(scene, 'advancewars-artwork',
|
||||
['terrainSheet', 'buildingSheet', 'unitSheet', 'uiSheet']),
|
||||
(scene) => musicFrom(scene, 'nintendo-music'),
|
||||
],
|
||||
coloradodefense: [
|
||||
// arcadedark soundtrack (see services/soundtrack.js) — lazy-loaded here
|
||||
// so its audio only downloads once Colorado Defense is actually entered.
|
||||
|
|
|
|||
|
|
@ -113,3 +113,4 @@ registerGame({ slug: 'starcontrol', name: 'Star Control', category: 'arcade-cons
|
|||
registerGame({ slug: 'civilization', name: 'Civilization', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 83 });
|
||||
registerGame({ slug: 'tempest', name: 'Tempest', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 84 });
|
||||
registerGame({ slug: 'superkart', name: 'Super Kart', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 85 });
|
||||
registerGame({ slug: 'advancewars', name: 'Advance Wars', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 86 });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,492 @@
|
|||
// Advance Wars CPU commander. Headless (no Phaser); runs in Node.
|
||||
// runAITurn(rules, state, army, profile) plays out the whole turn by calling
|
||||
// Logic.applyAction directly and returns [{ action, events }, ...] so the
|
||||
// scene can replay the events with animation delays.
|
||||
//
|
||||
// profile: { skill: 1-5, aggression: 0-1, captureWeight: 0-1 }
|
||||
// skill gates how much the AI "thinks": counters, threat avoidance,
|
||||
// production counter-tables all switch on at higher skills.
|
||||
|
||||
import * as Logic from './AdvanceWarsLogic.js';
|
||||
|
||||
const AVG_LUCK = 4;
|
||||
|
||||
export function runAITurn(rules, state, army, profile = {}) {
|
||||
const skill = Math.max(1, Math.min(5, profile.skill ?? 3));
|
||||
const aggression = profile.aggression ?? 0.5;
|
||||
const captureWeight = profile.captureWeight ?? 0.4;
|
||||
const log = [];
|
||||
const act = (action) => {
|
||||
const res = Logic.applyAction(state, rules, action);
|
||||
if (res.ok) log.push({ action, events: res.events });
|
||||
return res;
|
||||
};
|
||||
|
||||
if (state.turn !== army || state.result) return log;
|
||||
|
||||
// material ratio drives desperation/breakthrough: a clearly stronger army
|
||||
// accepts worse trades so sieges actually end instead of stalling out
|
||||
const myValue = state.units.reduce((s, u) => s + (u.army === army ? unitValue(rules, u) : 0), 0);
|
||||
const enemyValue = state.units.reduce((s, u) =>
|
||||
s + (Logic.hostile(state, u.army, army) ? unitValue(rules, u) : 0), 0);
|
||||
const surplus = Math.max(0, Math.min(2, myValue / Math.max(1, enemyValue) - 1));
|
||||
|
||||
// ── CO power ──────────────────────────────────────────────────────────────
|
||||
if (Logic.powerReady(rules, state, army)) {
|
||||
const eager = skill <= 2 || anyContact(rules, state, army);
|
||||
if (eager) act({ type: 'power' });
|
||||
}
|
||||
if (state.result) return log;
|
||||
|
||||
const myUnits = () => state.units.filter((u) => u.army === army && !u.moved);
|
||||
|
||||
// ── Indirects: fire without moving, else reposition ───────────────────────
|
||||
for (const unit of myUnits()) {
|
||||
const spec = rules.unitById[unit.type];
|
||||
if (!spec.indirect) continue;
|
||||
const targets = Logic.attackTargetsFrom(rules, state, unit, unit.x, unit.y, { afterMove: false });
|
||||
if (targets.length) {
|
||||
const best = pickBestTarget(rules, state, unit, targets, skill);
|
||||
if (best) { act({ type: 'attack', unitId: unit.id, path: [], targetId: best.id }); continue; }
|
||||
}
|
||||
// reposition toward the front, keeping min-range clearance
|
||||
const goal = nearestEnemy(rules, state, unit);
|
||||
if (goal) {
|
||||
const range = Logic.effectiveRange(rules, state, unit);
|
||||
const dest = advanceDestination(rules, state, unit, goal, { minDist: range ? range[0] : 0, skill });
|
||||
if (dest) act({ type: 'wait', unitId: unit.id, path: dest.path });
|
||||
}
|
||||
if (state.result) return log;
|
||||
}
|
||||
|
||||
// ── Capturers ─────────────────────────────────────────────────────────────
|
||||
const claimed = new Set();
|
||||
const hqThreat = findThreatenedHQ(rules, state, army);
|
||||
let hqDefenderSent = state.units.some((u) => u.army === army &&
|
||||
hqThreat && u.x === hqThreat.x && u.y === hqThreat.y);
|
||||
for (const unit of myUnits()) {
|
||||
const spec = rules.unitById[unit.type];
|
||||
if (!spec.capture) continue;
|
||||
const here = Logic.tileKey(state, unit.x, unit.y);
|
||||
const hereT = rules.terrains[state.terrain[here]];
|
||||
// garrison: don't walk off a threatened HQ; fight or hold from it
|
||||
if (hqThreat && unit.x === hqThreat.x && unit.y === hqThreat.y) {
|
||||
const targets = Logic.attackTargetsFrom(rules, state, unit, unit.x, unit.y, { afterMove: false });
|
||||
const best = targets.length ? pickBestTarget(rules, state, unit, targets, skill) : null;
|
||||
if (best) act({ type: 'attack', unitId: unit.id, path: [], targetId: best.id });
|
||||
else act({ type: 'wait', unitId: unit.id, path: [] });
|
||||
continue;
|
||||
}
|
||||
// divert the nearest foot unit home when the HQ is exposed
|
||||
if (hqThreat && !hqDefenderSent && skill >= 2) {
|
||||
const reach = Logic.reachableTiles(rules, state, unit);
|
||||
const hqKey = Logic.tileKey(state, hqThreat.x, hqThreat.y);
|
||||
hqDefenderSent = true;
|
||||
if (reach.dist.has(hqKey) && Logic.canStopAt(state, unit, hqThreat.x, hqThreat.y)) {
|
||||
act({ type: 'wait', unitId: unit.id, path: Logic.pathFromReach(state, reach, hqKey) });
|
||||
continue;
|
||||
}
|
||||
const dest = advanceDestination(rules, state, unit, hqThreat, { skill });
|
||||
if (dest && dest.path.length) { act({ type: 'wait', unitId: unit.id, path: dest.path }); continue; }
|
||||
}
|
||||
// keep finishing a capture in progress
|
||||
if (unit.capturing && hereT.property && capturable(rules, state, here, army)) {
|
||||
act({ type: 'capture', unitId: unit.id, path: [] });
|
||||
continue;
|
||||
}
|
||||
if (hereT.property && capturable(rules, state, here, army)) {
|
||||
act({ type: 'capture', unitId: unit.id, path: [] });
|
||||
claimed.add(here);
|
||||
continue;
|
||||
}
|
||||
if (Logic.rngNext(state) < captureWeight || skill >= 2) {
|
||||
const target = nearestProperty(rules, state, unit, claimed, army);
|
||||
if (target) {
|
||||
claimed.add(target.key);
|
||||
const reach = Logic.reachableTiles(rules, state, unit);
|
||||
if (reach.dist.has(target.key) && Logic.canStopAt(state, unit, target.x, target.y)) {
|
||||
const path = Logic.pathFromReach(state, reach, target.key);
|
||||
const res = act({ type: 'capture', unitId: unit.id, path });
|
||||
if (res.ok) continue;
|
||||
}
|
||||
const dest = advanceDestination(rules, state, unit, target, { skill });
|
||||
if (dest) { act({ type: 'wait', unitId: unit.id, path: dest.path }); continue; }
|
||||
}
|
||||
}
|
||||
if (state.result) return log;
|
||||
}
|
||||
|
||||
// ── Subs: dive when threatened ────────────────────────────────────────────
|
||||
for (const unit of myUnits()) {
|
||||
const spec = rules.unitById[unit.type];
|
||||
if (spec.dive && !unit.dived && skill >= 3 && enemyNear(rules, state, unit, 6)) {
|
||||
// dive happens with the attack/move below if possible; a lone dive
|
||||
// wastes the turn, so only dive when nothing is in range
|
||||
const targets = Logic.attackTargetsFrom(rules, state, unit, unit.x, unit.y, { afterMove: true });
|
||||
if (!targets.length) act({ type: 'dive', unitId: unit.id, path: [] });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Direct combat units ───────────────────────────────────────────────────
|
||||
for (const unit of myUnits()) {
|
||||
const spec = rules.unitById[unit.type];
|
||||
if (!spec.range || spec.indirect) continue;
|
||||
const plan = bestAttackPlan(rules, state, unit, { skill, aggression, surplus });
|
||||
if (plan) {
|
||||
act({ type: 'attack', unitId: unit.id, path: plan.path, targetId: plan.target.id });
|
||||
} else {
|
||||
const goal = nearestEnemy(rules, state, unit) ?? nearestProperty(rules, state, unit, claimed, army);
|
||||
let movedOut = false;
|
||||
if (goal) {
|
||||
const dest = advanceDestination(rules, state, unit, goal, { skill });
|
||||
if (dest && dest.path.length) { act({ type: 'wait', unitId: unit.id, path: dest.path }); movedOut = true; }
|
||||
}
|
||||
// stuck on one of our factories with nowhere better to go: step aside
|
||||
if (!movedOut) {
|
||||
const hereT = Logic.terrainAt(rules, state, unit.x, unit.y);
|
||||
const hereK = Logic.tileKey(state, unit.x, unit.y);
|
||||
if (hereT.builds && state.owner[hereK] === army) {
|
||||
const reach = Logic.reachableTiles(rules, state, unit);
|
||||
for (const [dx, dy] of [[1, 0], [0, 1], [-1, 0], [0, -1]]) {
|
||||
const nx = unit.x + dx, ny = unit.y + dy;
|
||||
const nk = Logic.tileKey(state, nx, ny);
|
||||
if (Logic.inBounds(state, nx, ny) && reach.dist.has(nk) &&
|
||||
Logic.canStopAt(state, unit, nx, ny)) {
|
||||
act({ type: 'wait', unitId: unit.id, path: Logic.pathFromReach(state, reach, nk) });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.result) return log;
|
||||
}
|
||||
|
||||
// ── Transports ────────────────────────────────────────────────────────────
|
||||
for (const unit of myUnits()) {
|
||||
const spec = rules.unitById[unit.type];
|
||||
if (!spec.transport) continue;
|
||||
if (unit.cargo.length) {
|
||||
// head toward the nearest capturable property and unload beside it
|
||||
const goal = nearestProperty(rules, state, unit, new Set(), army);
|
||||
if (goal) {
|
||||
const dest = advanceDestination(rules, state, unit, goal, { skill });
|
||||
if (dest && dest.path.length) act({ type: 'wait', unitId: unit.id, path: dest.path });
|
||||
const drops = [];
|
||||
const fresh = Logic.unitById(state, unit.id);
|
||||
if (fresh && !fresh.moved) { /* move failed; try unloading in place */ }
|
||||
const u2 = Logic.unitById(state, unit.id);
|
||||
for (let ci = u2.cargo.length - 1; ci >= 0; ci--) {
|
||||
const cargo = u2.cargo[ci];
|
||||
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
||||
const nx = u2.x + dx, ny = u2.y + dy;
|
||||
if (!Logic.inBounds(state, nx, ny)) continue;
|
||||
const t = Logic.terrainAt(rules, state, nx, ny);
|
||||
if (t.cost[rules.unitById[cargo.type].moveType] == null) continue;
|
||||
if (Logic.unitAt(state, nx, ny)) continue;
|
||||
if (drops.some((d) => d.x === nx && d.y === ny)) continue;
|
||||
drops.push({ cargoIndex: ci, x: nx, y: ny });
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (drops.length && !u2.moved) act({ type: 'unload', unitId: u2.id, path: [], drops });
|
||||
else if (drops.length && u2.moved) { /* already acted this turn */ }
|
||||
}
|
||||
} else if (spec.supplies) {
|
||||
// APC: sidle up to the thirstiest stack
|
||||
const thirsty = state.units.filter((u) => u.army === army && u !== unit &&
|
||||
(u.fuel < rules.unitById[u.type].fuel * 0.4 ||
|
||||
(rules.unitById[u.type].ammo > 0 && u.ammo <= 1)));
|
||||
if (thirsty.length) {
|
||||
const goal = thirsty[0];
|
||||
const dest = advanceDestination(rules, state, unit, goal, { skill });
|
||||
if (dest && dest.path.length) act({ type: 'supply', unitId: unit.id, path: dest.path });
|
||||
}
|
||||
}
|
||||
if (state.result) return log;
|
||||
}
|
||||
|
||||
// ── Production ────────────────────────────────────────────────────────────
|
||||
if (state.production) {
|
||||
produce(rules, state, army, { skill, act });
|
||||
}
|
||||
|
||||
if (!state.result && state.turn === army) act({ type: 'endTurn' });
|
||||
return log;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function capturable(rules, state, key, army) {
|
||||
const owner = state.owner[key];
|
||||
return owner !== army && (owner < 0 || Logic.hostile(state, owner, army));
|
||||
}
|
||||
|
||||
// Our HQ tile when a hostile capture-capable unit is close enough to matter.
|
||||
function findThreatenedHQ(rules, state, army) {
|
||||
for (let k = 0; k < state.terrain.length; k++) {
|
||||
if (!rules.terrains[state.terrain[k]].hq || state.owner[k] !== army) continue;
|
||||
const x = k % state.w, y = Math.floor(k / state.w);
|
||||
const threat = state.units.some((u) => Logic.hostile(state, u.army, army) &&
|
||||
rules.unitById[u.type].capture &&
|
||||
Math.abs(u.x - x) + Math.abs(u.y - y) <= 6);
|
||||
if (threat) return { x, y, key: k };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Bonus for shooting units that are stealing our stuff — a capturer on one of
|
||||
// our properties (worst of all: our HQ) must die first.
|
||||
function defensePriority(rules, state, target, army) {
|
||||
const k = Logic.tileKey(state, target.x, target.y);
|
||||
const t = rules.terrains[state.terrain[k]];
|
||||
if (!t.property || state.owner[k] !== army) return 0;
|
||||
let bonus = rules.unitById[target.type].capture ? 500 : 200;
|
||||
if (target.capturing) bonus += 500;
|
||||
if (t.hq) bonus += 1500;
|
||||
return bonus;
|
||||
}
|
||||
|
||||
function anyContact(rules, state, army) {
|
||||
return state.units.some((u) => u.army === army &&
|
||||
state.units.some((e) => Logic.hostile(state, e.army, army) &&
|
||||
Math.abs(e.x - u.x) + Math.abs(e.y - u.y) <= 4));
|
||||
}
|
||||
|
||||
function enemyNear(rules, state, unit, dist) {
|
||||
return state.units.some((e) => Logic.hostile(state, e.army, unit.army) &&
|
||||
Math.abs(e.x - unit.x) + Math.abs(e.y - unit.y) <= dist);
|
||||
}
|
||||
|
||||
function unitValue(rules, u) {
|
||||
return rules.unitById[u.type].cost * (u.hp / 100);
|
||||
}
|
||||
|
||||
function expectedDamageValue(rules, state, attacker, defender) {
|
||||
const res = Logic.computeDamage(rules, state, attacker, defender, AVG_LUCK);
|
||||
if (!res) return 0;
|
||||
const dealt = Math.min(defender.hp, res.dmg);
|
||||
return rules.unitById[defender.type].cost * (dealt / 100);
|
||||
}
|
||||
|
||||
function pickBestTarget(rules, state, unit, targets, skill) {
|
||||
let best = null, bestScore = -Infinity;
|
||||
for (const t of targets) {
|
||||
let score = expectedDamageValue(rules, state, unit, t);
|
||||
// higher skill prefers finishing kills and hitting threats to indirects
|
||||
const res = Logic.computeDamage(rules, state, unit, t, AVG_LUCK);
|
||||
if (skill >= 3 && res && res.dmg >= t.hp) score *= 1.4;
|
||||
if (skill >= 4 && !rules.unitById[t.type].indirect && rules.unitById[t.type].range) score *= 1.1;
|
||||
score += defensePriority(rules, state, t, unit.army);
|
||||
if (score > bestScore) { bestScore = score; best = t; }
|
||||
}
|
||||
return bestScore > 0 ? best : null;
|
||||
}
|
||||
|
||||
// Best move+attack pair for a direct unit. Considers every reachable stop
|
||||
// tile and every adjacent enemy from there.
|
||||
function bestAttackPlan(rules, state, unit, { skill, aggression, surplus = 0 }) {
|
||||
const reach = Logic.reachableTiles(rules, state, unit);
|
||||
let best = null, bestScore = -Infinity;
|
||||
for (const key of reach.dist.keys()) {
|
||||
const x = key % state.w, y = Math.floor(key / state.w);
|
||||
if (!Logic.canStopAt(state, unit, x, y)) continue;
|
||||
const afterMove = key !== reach.start;
|
||||
const targets = Logic.attackTargetsFrom(rules, state, unit, x, y, { afterMove });
|
||||
for (const t of targets) {
|
||||
const probe = { ...unit, x, y };
|
||||
const dealtValue = expectedDamageValue(rules, state, probe, t);
|
||||
if (dealtValue <= 0) continue;
|
||||
let counterValue = 0;
|
||||
if (skill >= 2) {
|
||||
const res = Logic.computeDamage(rules, state, probe, t, AVG_LUCK);
|
||||
const defenderLeft = Math.max(0, t.hp - (res?.dmg ?? 0));
|
||||
if (defenderLeft > 0 && !rules.unitById[t.type].indirect && rules.unitById[t.type].range &&
|
||||
Math.abs(t.x - x) + Math.abs(t.y - y) === 1) {
|
||||
const counterProbe = { ...t, hp: defenderLeft };
|
||||
const cres = Logic.computeDamage(rules, state, counterProbe, probe, AVG_LUCK);
|
||||
counterValue = cres ? rules.unitById[unit.type].cost * (Math.min(unit.hp, cres.dmg) / 100) : 0;
|
||||
}
|
||||
}
|
||||
const stars = Logic.terrainAt(rules, state, x, y).stars;
|
||||
let score = dealtValue - counterValue * (skill >= 3 ? 0.8 : 0.4) + stars * 40;
|
||||
const res2 = Logic.computeDamage(rules, state, { ...unit, x, y }, t, AVG_LUCK);
|
||||
if (skill >= 3 && res2 && res2.dmg >= t.hp) score += 300;
|
||||
score += defensePriority(rules, state, t, unit.army);
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = { key, x, y, target: t };
|
||||
}
|
||||
}
|
||||
}
|
||||
// aggressive COs take rougher trades; a big material surplus turns sieges
|
||||
// into pushes instead of day-limit stalemates
|
||||
const threshold = 250 - aggression * 400 - surplus * 600;
|
||||
if (!best || bestScore < threshold) return null;
|
||||
return { path: Logic.pathFromReach(state, reach, best.key) ?? [], target: best.target };
|
||||
}
|
||||
|
||||
function nearestEnemy(rules, state, unit) {
|
||||
let best = null, bd = Infinity;
|
||||
for (const e of state.units) {
|
||||
if (!Logic.hostile(state, e.army, unit.army)) continue;
|
||||
if (state.fog && !Logic.isUnitSpotted(rules, state, e, unit.army)) continue;
|
||||
// ignore enemies this unit can never hurt (transports chasing subs etc.)
|
||||
const d = Math.abs(e.x - unit.x) + Math.abs(e.y - unit.y);
|
||||
if (d < bd) { bd = d; best = e; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function nearestProperty(rules, state, unit, claimed, army) {
|
||||
let best = null, bd = Infinity;
|
||||
for (let k = 0; k < state.terrain.length; k++) {
|
||||
const t = rules.terrains[state.terrain[k]];
|
||||
if (!t.property || claimed.has(k)) continue;
|
||||
if (!capturable(rules, state, k, army)) continue;
|
||||
const x = k % state.w, y = Math.floor(k / state.w);
|
||||
// land capturers only (fly-in handled via transports elsewhere)
|
||||
if (t.cost[rules.unitById[unit.type].moveType] == null &&
|
||||
!rules.unitById[unit.type].transport) continue;
|
||||
const d = Math.abs(x - unit.x) + Math.abs(y - unit.y);
|
||||
if (d < bd) { bd = d; best = { x, y, key: k, hq: t.hq }; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// True walking distance from every tile to the goal for this unit's move
|
||||
// type, ignoring units. Greedy manhattan advance strands armies on the
|
||||
// shoreline across from the enemy — this BFS routes them over the bridges.
|
||||
function goalDistanceField(rules, state, unit, goal) {
|
||||
const spec = rules.unitById[unit.type];
|
||||
const field = new Map();
|
||||
const startKey = goal.y * state.w + goal.x;
|
||||
field.set(startKey, 0);
|
||||
const queue = [[startKey, 0]];
|
||||
while (queue.length) {
|
||||
const [key, d] = queue.shift();
|
||||
const x = key % state.w, y = Math.floor(key / state.w);
|
||||
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
||||
const nx = x + dx, ny = y + dy;
|
||||
if (!Logic.inBounds(state, nx, ny)) continue;
|
||||
const nk = ny * state.w + nx;
|
||||
if (field.has(nk)) continue;
|
||||
const t = rules.terrains[state.terrain[nk]];
|
||||
if (t.cost[spec.moveType] == null) continue;
|
||||
field.set(nk, d + 1);
|
||||
queue.push([nk, d + 1]);
|
||||
}
|
||||
}
|
||||
return field;
|
||||
}
|
||||
|
||||
// Reachable stop tile that gets the unit closest to goal. minDist keeps
|
||||
// indirects out of point-blank range. Skill 4+ avoids tiles the enemy can
|
||||
// punish hard (cheap threat check: adjacent enemy direct units).
|
||||
function advanceDestination(rules, state, unit, goal, { minDist = 0, skill = 3 } = {}) {
|
||||
const reach = Logic.reachableTiles(rules, state, unit);
|
||||
const field = goalDistanceField(rules, state, unit, goal);
|
||||
let best = null, bestScore = Infinity;
|
||||
for (const key of reach.dist.keys()) {
|
||||
const x = key % state.w, y = Math.floor(key / state.w);
|
||||
if (!Logic.canStopAt(state, unit, x, y)) continue;
|
||||
const d = field.get(key) ?? (Math.abs(x - goal.x) + Math.abs(y - goal.y)) + state.w;
|
||||
if (d < minDist) continue;
|
||||
let score = d * 100 + reach.dist.get(key);
|
||||
if (skill >= 4) {
|
||||
const heat = state.units.reduce((sum, e) => {
|
||||
if (!Logic.hostile(state, e.army, unit.army)) return sum;
|
||||
const ed = Math.abs(e.x - x) + Math.abs(e.y - y);
|
||||
return sum + (ed <= 1 ? 60 : 0);
|
||||
}, 0);
|
||||
score += heat;
|
||||
}
|
||||
score -= Logic.terrainAt(rules, state, x, y).stars * 5;
|
||||
// don't squat on our own factories — it strangles production
|
||||
const tHere = Logic.terrainAt(rules, state, x, y);
|
||||
if (tHere.builds && state.owner[key] === unit.army) score += 350;
|
||||
if (score < bestScore) { bestScore = score; best = key; }
|
||||
}
|
||||
if (best == null || best === reach.start) return null;
|
||||
return { key: best, path: Logic.pathFromReach(state, reach, best) ?? [] };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Production
|
||||
|
||||
function produce(rules, state, army, { skill, act }) {
|
||||
// soft unit cap (AW1 capped at 50): stop building when the map is getting
|
||||
// saturated — endless production just gridlocks the chokepoints
|
||||
const cap = Math.max(16, Math.min(40, Math.floor((state.w * state.h) / 6)));
|
||||
if (state.units.filter((u) => u.army === army).length >= cap) return;
|
||||
const factories = [];
|
||||
for (let k = 0; k < state.terrain.length; k++) {
|
||||
const t = rules.terrains[state.terrain[k]];
|
||||
if (t.builds && state.owner[k] === army && !Logic.unitAt(state, k % state.w, Math.floor(k / state.w))) {
|
||||
factories.push({ k, x: k % state.w, y: Math.floor(k / state.w), builds: t.builds });
|
||||
}
|
||||
}
|
||||
// land bases first (bread and butter), then air, then sea
|
||||
factories.sort((a, b) => 'land air sea'.indexOf(a.builds) - 'land air sea'.indexOf(b.builds));
|
||||
|
||||
const enemies = state.units.filter((u) => Logic.hostile(state, u.army, army));
|
||||
const own = state.units.filter((u) => u.army === army);
|
||||
const capturable = state.owner.reduce((n, o, k) =>
|
||||
n + (rules.terrains[state.terrain[k]].property && o !== army ? 1 : 0), 0);
|
||||
|
||||
for (const f of factories) {
|
||||
const options = Logic.buildOptions(rules, state, f.x, f.y).filter((o) => o.affordable);
|
||||
if (!options.length) continue;
|
||||
let pick = null;
|
||||
|
||||
const infCount = state.units.filter((u) => u.army === army && rules.unitById[u.type].capture).length;
|
||||
const wantInf = infCount < Math.min(4, Math.max(2, Math.floor(capturable / 2)));
|
||||
|
||||
if (f.builds === 'land' && wantInf && options.some((o) => o.type === 'infantry')) {
|
||||
pick = 'infantry';
|
||||
} else if (skill <= 1) {
|
||||
pick = options[Math.floor(Logic.rngNext(state) * options.length)].type;
|
||||
} else if (skill <= 3) {
|
||||
const template = f.builds === 'land'
|
||||
? ['tank', 'artillery', 'antiair', 'tank', 'mech', 'rockets', 'mdtank']
|
||||
: f.builds === 'air'
|
||||
? ['bcopter', 'fighter', 'bomber']
|
||||
: ['cruiser', 'sub', 'battleship'];
|
||||
const enemyAirValue = enemies.reduce((s, e) =>
|
||||
s + (rules.unitById[e.type].domain === 'air' ? unitValue(rules, e) : 0), 0);
|
||||
const list = enemyAirValue > 15000 && f.builds === 'land'
|
||||
? ['antiair', ...template] : template;
|
||||
pick = list.find((t) => options.some((o) => o.type === t));
|
||||
} else {
|
||||
// counter-table: maximize expected damage value against the enemy mix.
|
||||
// With a fat treasury, stop discounting price — buy the heavy metal.
|
||||
// Force-mix caps keep the army mobile: an all-artillery or all-mech
|
||||
// army never actually crosses the map.
|
||||
const rich = state.armies[army].funds > 30000;
|
||||
const mine = state.units.filter((u) => u.army === army);
|
||||
const indirects = mine.filter((u) => rules.unitById[u.type].indirect).length;
|
||||
const foot = mine.filter((u) => ['foot', 'boots'].includes(rules.unitById[u.type].moveType)).length;
|
||||
const tooManyIndirect = indirects >= Math.max(2, mine.length * 0.3);
|
||||
const tooManyFoot = foot >= Math.max(3, mine.length * 0.45);
|
||||
let bestScore = -Infinity;
|
||||
for (const o of options) {
|
||||
const spec = rules.unitById[o.type];
|
||||
if (!spec.range) continue; // transports scored separately below
|
||||
if (spec.indirect && tooManyIndirect) continue;
|
||||
if (['foot', 'boots'].includes(spec.moveType) && tooManyFoot) continue;
|
||||
let score = 0;
|
||||
for (const e of enemies) {
|
||||
const dmg = rules.damage[o.type]?.[e.type] ?? rules.damageSecondary[o.type]?.[e.type] ?? 0;
|
||||
score += (dmg / 100) * unitValue(rules, e);
|
||||
}
|
||||
if (!rich) score /= Math.max(1, Math.sqrt(o.cost / 1000));
|
||||
if (score > bestScore) { bestScore = score; pick = o.type; }
|
||||
}
|
||||
if (!pick) pick = options[0].type;
|
||||
}
|
||||
|
||||
if (pick && options.some((o) => o.type === pick)) {
|
||||
act({ type: 'build', x: f.x, y: f.y, unitType: pick });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
// Advance Wars battle cut-in: the classic side-vs-side clash panel, built
|
||||
// from the same 2-frame map unit art scaled up. Driven purely by engine
|
||||
// battle events; tap to skip. Toggleable from the pause menu.
|
||||
|
||||
import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js';
|
||||
import * as Logic from './AdvanceWarsLogic.js';
|
||||
import { armyColorInt } from './AdvanceWarsMapView.js';
|
||||
import { FONT } from './AdvanceWarsUI.js';
|
||||
|
||||
const W = 760;
|
||||
const H = 380;
|
||||
|
||||
// battles: array of engine 'battle' events (1 or 2 volleys).
|
||||
// Shows attacker left, defender right; each volley flashes and drains HP.
|
||||
// texKey is the unit sheet (painted or procedural).
|
||||
export function playBattleAnim(scene, rules, texKey, battles, onDone) {
|
||||
if (!battles.length) { onDone?.(); return null; }
|
||||
const cx = GAME_WIDTH / 2 - 130, cy = GAME_HEIGHT / 2 - 40;
|
||||
const objs = [];
|
||||
let finished = false;
|
||||
const timers = [];
|
||||
|
||||
const first = battles[0];
|
||||
const leftSide = first.attacker;
|
||||
const rightSide = first.defender;
|
||||
|
||||
const dim = scene.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.35)
|
||||
.setDepth(50).setInteractive();
|
||||
const panel = scene.add.rectangle(cx, cy, W, H, 0x10141f, 0.97)
|
||||
.setStrokeStyle(3, 0x3a4260).setDepth(50);
|
||||
const split = scene.add.rectangle(cx, cy, 4, H, 0x3a4260, 1).setDepth(50);
|
||||
objs.push(dim, panel, split);
|
||||
|
||||
const ground = (side, color) => scene.add
|
||||
.rectangle(cx + side * W / 4, cy + H / 2 - 40, W / 2 - 8, 80, color, 1).setDepth(50);
|
||||
objs.push(ground(-1, 0x27324a), ground(1, 0x27324a));
|
||||
|
||||
const mkFighter = (side, info) => {
|
||||
const spec = rules.unitById[info.type];
|
||||
const img = scene.add.image(cx + side * W / 4, cy + H / 2 - 78, texKey, spec.frame)
|
||||
.setScale(3.4).setOrigin(0.5, 1).setDepth(51)
|
||||
.setFlipX(side > 0)
|
||||
.setTint(armyColorInt(rules, info.army));
|
||||
objs.push(img);
|
||||
const label = scene.add.text(cx + side * W / 4, cy - H / 2 + 26,
|
||||
spec.name, { fontFamily: FONT, fontSize: '26px', color: '#ffffff' })
|
||||
.setOrigin(0.5, 0.5).setDepth(51);
|
||||
objs.push(label);
|
||||
const barBg = scene.add.rectangle(cx + side * W / 4, cy - H / 2 + 58, 220, 18, 0x0a0c14, 1)
|
||||
.setStrokeStyle(2, 0x3a4260).setDepth(51);
|
||||
const bar = scene.add.rectangle(cx + side * W / 4 - 108, cy - H / 2 + 58, 216, 12,
|
||||
0x7dff9a, 1).setOrigin(0, 0.5).setDepth(52);
|
||||
objs.push(barBg, bar);
|
||||
return { img, bar, spec };
|
||||
};
|
||||
|
||||
// starting HP = hp before the volleys (event carries post-hit hp; rebuild)
|
||||
let leftHp = first.attacker.hp;
|
||||
let rightHp = first.defender.hp;
|
||||
for (const b of battles) {
|
||||
if (b.attackerId === first.attackerId) rightHp = b.defender.hp + b.dmg;
|
||||
else leftHp = b.defender.hp + b.dmg;
|
||||
}
|
||||
|
||||
const left = mkFighter(-1, leftSide);
|
||||
const right = mkFighter(1, rightSide);
|
||||
const setBar = (f, hp) => {
|
||||
f.bar.width = Math.max(0, 216 * (hp / 100));
|
||||
f.bar.setFillStyle(hp > 50 ? 0x7dff9a : hp > 25 ? 0xffe14d : 0xff5a5a, 1);
|
||||
};
|
||||
setBar(left, leftHp);
|
||||
setBar(right, rightHp);
|
||||
|
||||
const flip = scene.time.addEvent({
|
||||
delay: 220, loop: true, callback: () => {
|
||||
for (const f of [left, right]) {
|
||||
const base = f.spec.frame;
|
||||
f.img.setFrame(f.img.frame.name === base ? base + 1 : base);
|
||||
}
|
||||
},
|
||||
});
|
||||
timers.push(flip);
|
||||
|
||||
const cleanup = () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
for (const t of timers) t.remove();
|
||||
for (const o of objs) o.destroy();
|
||||
onDone?.();
|
||||
};
|
||||
dim.on('pointerdown', cleanup);
|
||||
|
||||
let delay = 350;
|
||||
for (const b of battles) {
|
||||
const fromLeft = b.attackerId === first.attackerId;
|
||||
const shooter = fromLeft ? left : right;
|
||||
const victim = fromLeft ? right : left;
|
||||
timers.push(scene.time.delayedCall(delay, () => {
|
||||
if (finished) return;
|
||||
scene.tweens.add({
|
||||
targets: shooter.img, x: shooter.img.x + (fromLeft ? 40 : -40),
|
||||
duration: 90, yoyo: true,
|
||||
});
|
||||
const flash = scene.add.rectangle(victim.img.x, victim.img.y - 60, W / 2 - 20, H - 60, 0xffffff, 0.55).setDepth(53);
|
||||
objs.push(flash);
|
||||
scene.tweens.add({ targets: flash, alpha: 0, duration: 240, onComplete: () => flash.destroy() });
|
||||
scene.cameras.main.shake(120, 0.004);
|
||||
if (fromLeft) { rightHp = b.defender.hp; setBar(right, rightHp); }
|
||||
else { leftHp = b.defender.hp; setBar(left, leftHp); }
|
||||
if (b.killed) {
|
||||
scene.tweens.add({ targets: victim.img, alpha: 0, y: victim.img.y + 16, duration: 320 });
|
||||
}
|
||||
}));
|
||||
delay += 620;
|
||||
}
|
||||
timers.push(scene.time.delayedCall(delay + 350, cleanup));
|
||||
return { skip: cleanup };
|
||||
}
|
||||
|
|
@ -0,0 +1,616 @@
|
|||
// 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(),
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,953 @@
|
|||
// Advance Wars headless engine. No Phaser imports; runs in Node.
|
||||
// All rules simulation lives here: map decode, movement, combat, capture,
|
||||
// production, economy, fuel, transports, fog, CO powers, objectives.
|
||||
// The scene, the AI and tools/verifyAdvanceWars.js all drive the game
|
||||
// through applyAction(); every action returns an event list the view can
|
||||
// replay as animation.
|
||||
|
||||
import { baseDamage } from './AdvanceWarsRules.js';
|
||||
|
||||
export const SAVE_VERSION = 1;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seeded PRNG (mulberry32) — state.rngState mutates on every draw so games
|
||||
// are reproducible from a seed.
|
||||
|
||||
export function rngNext(state) {
|
||||
state.rngState = (state.rngState + 0x6d2b79f5) >>> 0;
|
||||
let t = state.rngState;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
}
|
||||
|
||||
export function rngInt(state, maxInclusive) {
|
||||
return Math.floor(rngNext(state) * (maxInclusive + 1));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Construction
|
||||
|
||||
export function decodeMap(rules, mapDef) {
|
||||
const { w, h, tiles } = mapDef;
|
||||
if (!Array.isArray(tiles) || tiles.length !== h) throw new Error('map: tile rows != h');
|
||||
const terrain = new Array(w * h);
|
||||
for (let y = 0; y < h; y++) {
|
||||
const row = tiles[y];
|
||||
if (row.length !== w) throw new Error(`map: row ${y} width ${row.length} != ${w}`);
|
||||
for (let x = 0; x < w; x++) {
|
||||
const t = rules.terrainByCh[row[x]];
|
||||
if (!t) throw new Error(`map: unknown terrain char '${row[x]}' at ${x},${y}`);
|
||||
terrain[y * w + x] = t.index;
|
||||
}
|
||||
}
|
||||
return terrain;
|
||||
}
|
||||
|
||||
export function createGame(rules, mapDef, opts = {}) {
|
||||
const terrain = decodeMap(rules, mapDef);
|
||||
const { w, h } = mapDef;
|
||||
const cos = opts.cos ?? ['andy', 'olaf'];
|
||||
const nArmies = cos.length;
|
||||
const funds = opts.startFunds ?? new Array(nArmies).fill(0);
|
||||
|
||||
const state = {
|
||||
v: SAVE_VERSION,
|
||||
day: 1,
|
||||
turn: 0,
|
||||
fog: !!opts.fog,
|
||||
production: opts.production !== false,
|
||||
rngState: (opts.seed ?? 1) >>> 0,
|
||||
w, h,
|
||||
terrain,
|
||||
owner: new Array(w * h).fill(-1),
|
||||
captureHp: new Array(w * h).fill(rules.constants.captureGoal),
|
||||
armies: cos.map((co, i) => {
|
||||
if (!rules.coById[co]) throw new Error(`unknown CO ${co}`);
|
||||
return {
|
||||
co, funds: funds[i] ?? 0, charge: 0, powerActive: false, alive: true,
|
||||
hadUnits: false, team: opts.teams?.[i] ?? (i === 0 ? 0 : 1),
|
||||
};
|
||||
}),
|
||||
units: [],
|
||||
nextUnitId: 1,
|
||||
objective: opts.objective ?? { type: 'rout' },
|
||||
dayLimit: opts.dayLimit ?? rules.constants.dayLimitDefault,
|
||||
result: null,
|
||||
};
|
||||
|
||||
for (const p of mapDef.properties ?? []) {
|
||||
const t = rules.terrains[terrain[p.y * w + p.x]];
|
||||
if (!t?.property) throw new Error(`map: property owner at ${p.x},${p.y} but terrain is ${t?.id}`);
|
||||
if (p.owner < 0 || p.owner >= nArmies) throw new Error(`map: property owner ${p.owner} out of range`);
|
||||
state.owner[p.y * w + p.x] = p.owner;
|
||||
}
|
||||
|
||||
for (const u of mapDef.units ?? []) {
|
||||
spawnUnit(rules, state, u);
|
||||
}
|
||||
state.armies.forEach((a, i) => { a.hadUnits = state.units.some((u) => u.army === i); });
|
||||
|
||||
// Army 0 opens the game with a fresh day (income etc.) exactly like every
|
||||
// later turn start.
|
||||
const events = [];
|
||||
beginTurn(rules, state, events);
|
||||
state._openingEvents = events;
|
||||
return state;
|
||||
}
|
||||
|
||||
function spawnUnit(rules, state, def) {
|
||||
const spec = rules.unitById[def.type];
|
||||
if (!spec) throw new Error(`unknown unit type ${def.type}`);
|
||||
if (def.army < 0 || def.army >= state.armies.length) throw new Error(`unit army ${def.army} out of range`);
|
||||
const t = rules.terrains[state.terrain[def.y * state.w + def.x]];
|
||||
if (t.cost[spec.moveType] == null) throw new Error(`unit ${def.type} on impassable ${t.id} at ${def.x},${def.y}`);
|
||||
if (unitAt(state, def.x, def.y)) throw new Error(`two units at ${def.x},${def.y}`);
|
||||
const unit = {
|
||||
id: state.nextUnitId++,
|
||||
army: def.army,
|
||||
type: def.type,
|
||||
x: def.x, y: def.y,
|
||||
hp: def.hp ?? 100,
|
||||
fuel: def.fuel ?? spec.fuel,
|
||||
ammo: def.ammo ?? spec.ammo,
|
||||
moved: false,
|
||||
capturing: false,
|
||||
dived: false,
|
||||
cargo: (def.cargo ?? []).map((c) => ({
|
||||
id: state.nextUnitId++,
|
||||
army: def.army,
|
||||
type: c.type,
|
||||
x: def.x, y: def.y,
|
||||
hp: c.hp ?? 100,
|
||||
fuel: c.fuel ?? rules.unitById[c.type].fuel,
|
||||
ammo: c.ammo ?? rules.unitById[c.type].ammo,
|
||||
moved: false, capturing: false, dived: false, cargo: [],
|
||||
})),
|
||||
};
|
||||
state.units.push(unit);
|
||||
return unit;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lookups & CO effects
|
||||
|
||||
export function tileKey(state, x, y) { return y * state.w + x; }
|
||||
export function inBounds(state, x, y) { return x >= 0 && y >= 0 && x < state.w && y < state.h; }
|
||||
export function terrainAt(rules, state, x, y) { return rules.terrains[state.terrain[y * state.w + x]]; }
|
||||
|
||||
export function unitAt(state, x, y) {
|
||||
return state.units.find((u) => u.x === x && u.y === y) ?? null;
|
||||
}
|
||||
|
||||
// Two armies are hostile when they're on different teams (campaign finale
|
||||
// fields two allied enemy armies).
|
||||
export function hostile(state, armyA, armyB) {
|
||||
return state.armies[armyA].team !== state.armies[armyB].team;
|
||||
}
|
||||
|
||||
export function unitById(state, id) {
|
||||
for (const u of state.units) {
|
||||
if (u.id === id) return u;
|
||||
const c = u.cargo.find((k) => k.id === id);
|
||||
if (c) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hpDisplay(unit) { return Math.ceil(unit.hp / 10); }
|
||||
|
||||
// Merged day-to-day + active-power effects for an army. Power keys override
|
||||
// d2d keys while the power is active.
|
||||
export function coEffects(rules, state, army) {
|
||||
const co = rules.coById[state.armies[army].co];
|
||||
const d2d = co.d2d ?? {};
|
||||
if (!state.armies[army].powerActive || !co.power) return d2d;
|
||||
return { ...d2d, ...co.power.effects };
|
||||
}
|
||||
|
||||
function effectFor(rules, state, army) { return coEffects(rules, state, army); }
|
||||
|
||||
// Attack % for a unit given its army's CO effects. Modifiers are additive
|
||||
// around the 100 baseline, matching how AW stacks category bonuses.
|
||||
export function attackPct(rules, state, unit) {
|
||||
const fx = effectFor(rules, state, unit.army);
|
||||
const spec = rules.unitById[unit.type];
|
||||
let pct = fx.atk ?? 100;
|
||||
const foot = spec.moveType === 'foot' || spec.moveType === 'boots';
|
||||
if (spec.indirect) {
|
||||
if (fx.indirectAtk != null) pct += fx.indirectAtk - 100;
|
||||
} else if (spec.range) {
|
||||
if (fx.directAtk != null) pct += fx.directAtk - 100;
|
||||
if (!foot && fx.nonFootDirectAtk != null) pct += fx.nonFootDirectAtk - 100;
|
||||
}
|
||||
if (foot && fx.footAtk != null) pct += fx.footAtk - 100;
|
||||
if (spec.domain === 'air' && fx.airAtk != null) pct += fx.airAtk - 100;
|
||||
if (spec.domain === 'sea' && fx.seaAtk != null) pct += fx.seaAtk - 100;
|
||||
return pct;
|
||||
}
|
||||
|
||||
export function defensePct(rules, state, unit) {
|
||||
const fx = effectFor(rules, state, unit.army);
|
||||
return fx.def ?? 100;
|
||||
}
|
||||
|
||||
export function effectiveRange(rules, state, unit) {
|
||||
const spec = rules.unitById[unit.type];
|
||||
if (!spec.range) return null;
|
||||
if (!spec.indirect) return spec.range;
|
||||
const fx = effectFor(rules, state, unit.army);
|
||||
const mod = fx.rangeMod ?? 0;
|
||||
return [spec.range[0], Math.max(spec.range[0], spec.range[1] + mod)];
|
||||
}
|
||||
|
||||
export function effectiveMove(rules, state, unit) {
|
||||
const spec = rules.unitById[unit.type];
|
||||
const fx = effectFor(rules, state, unit.army);
|
||||
let move = spec.move;
|
||||
const foot = spec.moveType === 'foot' || spec.moveType === 'boots';
|
||||
if (foot && fx.footMove) move += fx.footMove;
|
||||
if (!foot && !spec.indirect && spec.range && fx.directMove) move += fx.directMove;
|
||||
if (spec.domain === 'sea' && fx.seaMove) move += fx.seaMove;
|
||||
if (spec.transport && fx.transportMove) move += fx.transportMove;
|
||||
return Math.min(move, unit.fuel);
|
||||
}
|
||||
|
||||
// Terrain cost for one army's unit, honoring Sturm's flat costs and any
|
||||
// enemy Blizzard (snow) that is currently active.
|
||||
export function moveCost(rules, state, unit, tIdx) {
|
||||
const t = rules.terrains[tIdx];
|
||||
const spec = rules.unitById[unit.type];
|
||||
let cost = t.cost[spec.moveType];
|
||||
if (cost == null) return null;
|
||||
const fx = effectFor(rules, state, unit.army);
|
||||
if (fx.terrainCostFlat) cost = 1;
|
||||
const snowed = state.armies.some((a, i) =>
|
||||
i !== unit.army && a.alive && a.powerActive &&
|
||||
(rules.coById[a.co].power?.effects?.snow));
|
||||
if (snowed) cost += 1;
|
||||
return cost;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Movement — Dijkstra flood from the unit's tile. Friendly tiles can be
|
||||
// passed through but not stopped on (except load/join, validated at apply
|
||||
// time). Enemy units block; in fog invisible enemies don't block here but
|
||||
// trap the unit during execution.
|
||||
|
||||
export function reachableTiles(rules, state, unit) {
|
||||
const move = effectiveMove(rules, state, unit);
|
||||
const start = tileKey(state, unit.x, unit.y);
|
||||
const dist = new Map([[start, 0]]);
|
||||
const prev = new Map();
|
||||
const visible = state.fog ? computeVision(rules, state, unit.army) : null;
|
||||
// occupancy snapshot: units don't move mid-flood, and this turns the O(units)
|
||||
// per-neighbor scan into O(1) — the AI calls this constantly on big armies
|
||||
const occAt = new Map();
|
||||
for (const u of state.units) occAt.set(u.y * state.w + u.x, u);
|
||||
// per-terrain-index cost cache (CO/snow modifiers are constant for the flood)
|
||||
const costCache = new Map();
|
||||
const costOf = (tIdx) => {
|
||||
if (!costCache.has(tIdx)) costCache.set(tIdx, moveCost(rules, state, unit, tIdx));
|
||||
return costCache.get(tIdx);
|
||||
};
|
||||
const frontier = [[0, start]];
|
||||
while (frontier.length) {
|
||||
let bi = 0;
|
||||
for (let i = 1; i < frontier.length; i++) if (frontier[i][0] < frontier[bi][0]) bi = i;
|
||||
const [d, key] = frontier.splice(bi, 1)[0];
|
||||
if (d > (dist.get(key) ?? Infinity)) continue;
|
||||
const x = key % state.w, y = Math.floor(key / state.w);
|
||||
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
||||
const nx = x + dx, ny = y + dy;
|
||||
if (!inBounds(state, nx, ny)) continue;
|
||||
const nk = ny * state.w + nx;
|
||||
const cost = costOf(state.terrain[nk]);
|
||||
if (cost == null) continue;
|
||||
const occ = occAt.get(nk);
|
||||
if (occ && occ.army !== unit.army) {
|
||||
if (!hostile(state, occ.army, unit.army)) continue; // allies always block
|
||||
// visible enemies block pathing; invisible ones are handled as traps
|
||||
if (!state.fog || isUnitSpotted(rules, state, occ, unit.army, visible)) continue;
|
||||
}
|
||||
const nd = d + cost;
|
||||
if (nd > move) continue;
|
||||
if (nd < (dist.get(nk) ?? Infinity)) {
|
||||
dist.set(nk, nd);
|
||||
prev.set(nk, key);
|
||||
frontier.push([nd, nk]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { dist, prev, start };
|
||||
}
|
||||
|
||||
export function pathFromReach(state, reach, destKey) {
|
||||
if (!reach.dist.has(destKey)) return null;
|
||||
const path = [];
|
||||
let k = destKey;
|
||||
while (k !== undefined && k !== reach.start) {
|
||||
path.unshift({ x: k % state.w, y: Math.floor(k / state.w) });
|
||||
k = reach.prev.get(k);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
// Tiles a unit may END its move on (unoccupied, or its own tile).
|
||||
export function canStopAt(state, unit, x, y) {
|
||||
const occ = unitAt(state, x, y);
|
||||
return !occ || occ === unit;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vision / fog
|
||||
|
||||
export function computeVision(rules, state, army) {
|
||||
const vis = new Set();
|
||||
const fx = effectFor(rules, state, army);
|
||||
const bonus = fx.visionMod ?? 0;
|
||||
const add = (x, y, range) => {
|
||||
for (let dy = -range; dy <= range; dy++) {
|
||||
for (let dx = -(range - Math.abs(dy)); dx <= range - Math.abs(dy); dx++) {
|
||||
const nx = x + dx, ny = y + dy;
|
||||
if (inBounds(state, nx, ny)) vis.add(tileKey(state, nx, ny));
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const u of state.units) {
|
||||
if (u.army !== army) continue;
|
||||
const spec = rules.unitById[u.type];
|
||||
let range = spec.vision + bonus;
|
||||
const t = terrainAt(rules, state, u.x, u.y);
|
||||
if (t.visionBonus && (spec.moveType === 'foot' || spec.moveType === 'boots')) range += t.visionBonus;
|
||||
add(u.x, u.y, range);
|
||||
}
|
||||
for (let k = 0; k < state.owner.length; k++) {
|
||||
if (state.owner[k] === army) vis.add(k);
|
||||
}
|
||||
return vis;
|
||||
}
|
||||
|
||||
// A unit is spotted by `army` when its tile is visible AND it is not hidden
|
||||
// (wood/reef hider, dived sub) — hidden units need an adjacent friendly.
|
||||
export function isUnitSpotted(rules, state, unit, army, vision = null) {
|
||||
if (unit.army === army) return true;
|
||||
if (!state.fog) return !unit.dived || adjacentToArmy(state, unit, army);
|
||||
const vis = vision ?? computeVision(rules, state, army);
|
||||
if (!vis.has(tileKey(state, unit.x, unit.y))) return false;
|
||||
const t = terrainAt(rules, state, unit.x, unit.y);
|
||||
if (unit.dived || t.hidesInFog) return adjacentToArmy(state, unit, army);
|
||||
return true;
|
||||
}
|
||||
|
||||
function adjacentToArmy(state, unit, army) {
|
||||
return state.units.some((u) => u.army === army &&
|
||||
Math.abs(u.x - unit.x) + Math.abs(u.y - unit.y) === 1);
|
||||
}
|
||||
|
||||
export function visibleUnits(rules, state, army) {
|
||||
if (!state.fog) return state.units.filter((u) => u.army === army || !u.dived || adjacentToArmy(state, u, army));
|
||||
const vis = computeVision(rules, state, army);
|
||||
return state.units.filter((u) => isUnitSpotted(rules, state, u, army, vis));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Combat
|
||||
|
||||
// The classic AW formula. luck is an explicit roll so previews pass 0 and
|
||||
// resolveAttack draws from the seeded rng.
|
||||
export function computeDamage(rules, state, attacker, defender, luck = 0) {
|
||||
const base = baseDamage(rules, attacker, defender.type);
|
||||
if (!base) return null;
|
||||
const atk = attackPct(rules, state, attacker);
|
||||
const def = defensePct(rules, state, defender);
|
||||
const stars = defender.dived ? 0 : terrainAt(rules, state, defender.x, defender.y).stars;
|
||||
// air units never get terrain cover
|
||||
const spec = rules.unitById[defender.type];
|
||||
const effStars = spec.domain === 'air' ? 0 : stars;
|
||||
const dmg = Math.floor(
|
||||
(base.dmg * atk / 100 + luck) *
|
||||
(hpDisplay(attacker) / 10) *
|
||||
((200 - def - effStars * hpDisplay(defender)) / 100)
|
||||
);
|
||||
return { dmg: Math.max(0, dmg), weapon: base.weapon };
|
||||
}
|
||||
|
||||
export function canAttack(rules, state, attacker, defender, { afterMove = false } = {}) {
|
||||
if (!hostile(state, attacker.army, defender.army)) return false;
|
||||
const spec = rules.unitById[attacker.type];
|
||||
if (!spec.range) return false;
|
||||
if (spec.indirect && afterMove) return false;
|
||||
const range = effectiveRange(rules, state, attacker);
|
||||
const d = Math.abs(attacker.x - defender.x) + Math.abs(attacker.y - defender.y);
|
||||
if (d < range[0] || d > range[1]) return false;
|
||||
if (defender.dived && !['cruiser', 'sub'].includes(attacker.type)) return false;
|
||||
return baseDamage(rules, attacker, defender.type) != null;
|
||||
}
|
||||
|
||||
function spendAmmo(rules, unit, weapon) {
|
||||
const spec = rules.unitById[unit.type];
|
||||
if (weapon === 'primary' && spec.ammo > 0) unit.ammo = Math.max(0, unit.ammo - 1);
|
||||
}
|
||||
|
||||
function fundsValue(rules, unitLike, hpAmount) {
|
||||
return rules.unitById[unitLike.type].cost * (hpAmount / 100);
|
||||
}
|
||||
|
||||
function addCharge(rules, state, army, amount) {
|
||||
const a = state.armies[army];
|
||||
const co = rules.coById[a.co];
|
||||
if (!co.power || a.powerActive) return;
|
||||
const cap = co.power.stars * rules.constants.starCharge;
|
||||
a.charge = Math.min(cap, a.charge + Math.floor(amount));
|
||||
}
|
||||
|
||||
// One volley from `from` into `to`. Mutates hp, spends ammo, accrues charge.
|
||||
function volley(rules, state, from, to, events) {
|
||||
const luck = rngInt(state, effectFor(rules, state, from.army).luckMax ?? rules.constants.luckMax);
|
||||
const res = computeDamage(rules, state, from, to, luck);
|
||||
if (!res) return;
|
||||
const dealt = Math.min(to.hp, res.dmg);
|
||||
to.hp -= dealt;
|
||||
spendAmmo(rules, from, res.weapon);
|
||||
addCharge(rules, state, from.army, fundsValue(rules, to, dealt) * rules.constants.chargeDealtMult);
|
||||
addCharge(rules, state, to.army, fundsValue(rules, to, dealt) * rules.constants.chargeTakenMult);
|
||||
events.push({
|
||||
type: 'battle', attackerId: from.id, defenderId: to.id,
|
||||
attacker: { type: from.type, army: from.army, x: from.x, y: from.y, hp: from.hp },
|
||||
defender: { type: to.type, army: to.army, x: to.x, y: to.y, hp: to.hp },
|
||||
dmg: dealt, killed: to.hp <= 0,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveAttack(rules, state, attacker, defender, events) {
|
||||
const defFx = effectFor(rules, state, defender.army);
|
||||
const defenderCountersFirst = !!defFx.counterFirst &&
|
||||
canAttack(rules, state, defender, attacker, { afterMove: false }) &&
|
||||
!rules.unitById[defender.type].indirect;
|
||||
|
||||
const order = defenderCountersFirst ? [[defender, attacker], [attacker, defender]]
|
||||
: [[attacker, defender], [defender, attacker]];
|
||||
for (const [from, to] of order) {
|
||||
if (from.hp <= 0 || to.hp <= 0) continue;
|
||||
// the "counter" half only happens for adjacent direct defenders
|
||||
if (from === defender) {
|
||||
const dspec = rules.unitById[defender.type];
|
||||
if (dspec.indirect) continue;
|
||||
if (Math.abs(from.x - to.x) + Math.abs(from.y - to.y) !== 1) continue;
|
||||
if (!dspec.range) continue;
|
||||
if (baseDamage(rules, from, to.type) == null) continue;
|
||||
}
|
||||
volley(rules, state, from, to, events);
|
||||
}
|
||||
removeDead(rules, state, events);
|
||||
}
|
||||
|
||||
function removeDead(rules, state, events) {
|
||||
for (const u of [...state.units]) {
|
||||
if (u.hp <= 0) {
|
||||
if (u.capturing) state.captureHp[tileKey(state, u.x, u.y)] = rules.constants.captureGoal;
|
||||
events.push({ type: 'destroyed', unitId: u.id, x: u.x, y: u.y, unitType: u.type, army: u.army });
|
||||
state.units.splice(state.units.indexOf(u), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Turn / day cycle
|
||||
|
||||
function beginTurn(rules, state, events) {
|
||||
const army = state.turn;
|
||||
const a = state.armies[army];
|
||||
const co = rules.coById[a.co];
|
||||
|
||||
// own power expires at the start of our next turn
|
||||
a.powerActive = false;
|
||||
|
||||
events.push({ type: 'dayStart', day: state.day, army });
|
||||
|
||||
// income
|
||||
let income = 0;
|
||||
for (let k = 0; k < state.owner.length; k++) {
|
||||
if (state.owner[k] === army) income += rules.constants.incomePerProperty;
|
||||
}
|
||||
a.funds += income;
|
||||
if (income) events.push({ type: 'income', army, amount: income });
|
||||
|
||||
// repairs + resupply on friendly properties
|
||||
for (const u of state.units) {
|
||||
if (u.army !== army) continue;
|
||||
const t = terrainAt(rules, state, u.x, u.y);
|
||||
const spec = rules.unitById[u.type];
|
||||
if (state.owner[tileKey(state, u.x, u.y)] === army && t.repairs?.includes(spec.domain)) {
|
||||
if (u.hp < 100) {
|
||||
const want = Math.min(rules.constants.repairPerTurn, 100 - u.hp);
|
||||
const costPerPoint = spec.cost / 100;
|
||||
const affordable = Math.min(want, Math.floor(a.funds / costPerPoint));
|
||||
if (affordable > 0) {
|
||||
u.hp += affordable;
|
||||
a.funds -= Math.round(affordable * costPerPoint);
|
||||
events.push({ type: 'repair', unitId: u.id, amount: affordable });
|
||||
}
|
||||
}
|
||||
u.fuel = spec.fuel;
|
||||
u.ammo = spec.ammo;
|
||||
}
|
||||
}
|
||||
|
||||
// APC auto-resupply
|
||||
for (const u of state.units) {
|
||||
if (u.army !== army || !rules.unitById[u.type].supplies) continue;
|
||||
supplyAdjacent(rules, state, u, events);
|
||||
}
|
||||
|
||||
// daily fuel + crashes
|
||||
const fx = coEffects(rules, state, army);
|
||||
for (const u of [...state.units]) {
|
||||
if (u.army !== army) continue;
|
||||
const spec = rules.unitById[u.type];
|
||||
let burn = u.dived ? (spec.dive?.dailyFuel ?? spec.dailyFuel ?? 0) : (spec.dailyFuel ?? 0);
|
||||
if (spec.domain === 'air' && fx.airFuelSave) burn = Math.max(1, burn - fx.airFuelSave);
|
||||
if (burn > 0) {
|
||||
u.fuel -= burn;
|
||||
if (u.fuel < 0 && (spec.domain === 'air' || spec.domain === 'sea')) {
|
||||
u.hp = 0;
|
||||
events.push({ type: 'crashed', unitId: u.id, x: u.x, y: u.y, unitType: u.type, army });
|
||||
} else if (u.fuel < 0) {
|
||||
u.fuel = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
removeDead(rules, state, events);
|
||||
|
||||
// fresh orders
|
||||
for (const u of state.units) {
|
||||
if (u.army === army) u.moved = false;
|
||||
}
|
||||
if (state.units.some((u) => u.army === army)) a.hadUnits = true;
|
||||
|
||||
checkResult(rules, state, events);
|
||||
}
|
||||
|
||||
function nextAliveArmy(state, from) {
|
||||
let i = from;
|
||||
for (let n = 0; n < state.armies.length; n++) {
|
||||
i = (i + 1) % state.armies.length;
|
||||
if (state.armies[i].alive) return i;
|
||||
}
|
||||
return from;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Result / elimination
|
||||
|
||||
function eliminateArmy(rules, state, army, events, { neutralize = true } = {}) {
|
||||
const a = state.armies[army];
|
||||
if (!a.alive) return;
|
||||
a.alive = false;
|
||||
for (const u of [...state.units]) {
|
||||
if (u.army === army) state.units.splice(state.units.indexOf(u), 1);
|
||||
}
|
||||
if (neutralize) {
|
||||
for (let k = 0; k < state.owner.length; k++) {
|
||||
if (state.owner[k] === army) {
|
||||
state.owner[k] = -1;
|
||||
state.captureHp[k] = rules.constants.captureGoal;
|
||||
}
|
||||
}
|
||||
}
|
||||
events.push({ type: 'armyEliminated', army });
|
||||
}
|
||||
|
||||
export function checkResult(rules, state, events = []) {
|
||||
if (state.result) return state.result;
|
||||
|
||||
// rout eliminations
|
||||
state.armies.forEach((a, i) => {
|
||||
if (!a.alive || !a.hadUnits) return;
|
||||
const hasUnits = state.units.some((u) => u.army === i);
|
||||
if (!hasUnits) {
|
||||
const canRebuild = state.production && a.funds >= 1000 &&
|
||||
state.owner.some((o, k) => o === i && rules.terrains[state.terrain[k]].builds);
|
||||
if (!canRebuild) eliminateArmy(rules, state, i, events);
|
||||
}
|
||||
});
|
||||
|
||||
const playerAlive = state.armies.some((a) => a.alive && a.team === 0);
|
||||
const enemiesAlive = state.armies.some((a) => a.alive && a.team !== 0);
|
||||
|
||||
if (!playerAlive) {
|
||||
state.result = { winner: 'enemy', reason: 'rout' };
|
||||
} else if (!enemiesAlive) {
|
||||
state.result = { winner: 'player', reason: 'rout' };
|
||||
} else if (state.objective.type === 'capturecount') {
|
||||
const owned = state.owner.filter((o) => o === 0).length;
|
||||
if (owned >= state.objective.n) state.result = { winner: 'player', reason: 'capturecount' };
|
||||
} else if (state.objective.type === 'survive') {
|
||||
if (state.day > state.objective.days) state.result = { winner: 'player', reason: 'survived' };
|
||||
}
|
||||
if (!state.result && state.day > state.dayLimit) {
|
||||
state.result = { winner: 'enemy', reason: 'daylimit' };
|
||||
}
|
||||
if (state.result) events.push({ type: 'gameOver', ...state.result });
|
||||
return state.result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actions
|
||||
|
||||
function fail(error) { return { ok: false, error, events: [] }; }
|
||||
|
||||
function executeMove(rules, state, unit, path, events, { allowOccupiedEnd = false } = {}) {
|
||||
// Validates the whole walk first, then commits. Returns { trapped } —
|
||||
// trapped means an invisible hostile blocked the way; the unit stops just
|
||||
// before it and its turn ends there.
|
||||
if (!path || path.length === 0) return { trapped: false, cost: 0 };
|
||||
let px = unit.x, py = unit.y;
|
||||
let total = 0, spent = 0;
|
||||
const move = effectiveMove(rules, state, unit);
|
||||
let endX = unit.x, endY = unit.y;
|
||||
let trapped = false;
|
||||
for (const step of path) {
|
||||
if (Math.abs(step.x - px) + Math.abs(step.y - py) !== 1) return { error: 'bad path step' };
|
||||
const cost = moveCost(rules, state, unit, state.terrain[tileKey(state, step.x, step.y)]);
|
||||
if (cost == null) return { error: 'impassable' };
|
||||
total += cost;
|
||||
if (total > move) return { error: 'path too long' };
|
||||
const occ = unitAt(state, step.x, step.y);
|
||||
if (occ && occ !== unit && occ.army !== unit.army) {
|
||||
if (hostile(state, occ.army, unit.army)) { trapped = true; break; }
|
||||
return { error: 'blocked' };
|
||||
}
|
||||
px = step.x; py = step.y;
|
||||
if (!occ || occ === unit) { endX = step.x; endY = step.y; spent = total; }
|
||||
}
|
||||
if (!trapped && (px !== endX || py !== endY)) return { error: 'cannot stop on occupied tile' };
|
||||
if (!trapped && !allowOccupiedEnd && !canStopAt(state, unit, endX, endY)) {
|
||||
return { error: 'cannot stop on occupied tile' };
|
||||
}
|
||||
const from = { x: unit.x, y: unit.y };
|
||||
if (unit.capturing && (endX !== unit.x || endY !== unit.y)) {
|
||||
// walking off a half-captured property resets its meter
|
||||
state.captureHp[tileKey(state, unit.x, unit.y)] = rules.constants.captureGoal;
|
||||
}
|
||||
unit.fuel = Math.max(0, unit.fuel - spent);
|
||||
unit.x = endX; unit.y = endY;
|
||||
unit.capturing = false;
|
||||
events.push({ type: 'moved', unitId: unit.id, from, to: { x: endX, y: endY }, path, trapped });
|
||||
return { trapped };
|
||||
}
|
||||
|
||||
function requireActiveUnit(state, action) {
|
||||
const unit = unitById(state, action.unitId);
|
||||
if (!unit) return { error: 'no such unit' };
|
||||
if (unit.army !== state.turn) return { error: 'not your unit' };
|
||||
if (unit.moved) return { error: 'already moved' };
|
||||
if (state.units.indexOf(unit) < 0) return { error: 'unit is loaded' };
|
||||
return { unit };
|
||||
}
|
||||
|
||||
export function applyAction(state, rules, action) {
|
||||
if (state.result) return fail('game over');
|
||||
const events = [];
|
||||
const army = state.turn;
|
||||
|
||||
switch (action.type) {
|
||||
case 'endTurn': {
|
||||
for (const u of state.units) if (u.army === army) u.moved = true;
|
||||
const wasLast = nextAliveArmy(state, army) <= army;
|
||||
state.turn = nextAliveArmy(state, army);
|
||||
if (wasLast) state.day += 1;
|
||||
beginTurn(rules, state, events);
|
||||
return { ok: true, events };
|
||||
}
|
||||
|
||||
case 'power': {
|
||||
const a = state.armies[army];
|
||||
const co = rules.coById[a.co];
|
||||
if (!co.power) return fail('no power');
|
||||
const cap = co.power.stars * rules.constants.starCharge;
|
||||
if (a.charge < cap) return fail('power not charged');
|
||||
a.charge = 0;
|
||||
a.powerActive = true;
|
||||
events.push({ type: 'powerFired', army, co: a.co, power: co.power.name });
|
||||
applyImmediatePower(rules, state, army, co.power.effects, events);
|
||||
removeDead(rules, state, events);
|
||||
checkResult(rules, state, events);
|
||||
return { ok: true, events };
|
||||
}
|
||||
|
||||
case 'build': {
|
||||
const { x, y, unitType } = action;
|
||||
const t = terrainAt(rules, state, x, y);
|
||||
const spec = rules.unitById[unitType];
|
||||
if (!spec) return fail('unknown unit type');
|
||||
if (!t.builds || state.owner[tileKey(state, x, y)] !== army) return fail('not your factory');
|
||||
const domain = spec.domain === 'air' ? 'air' : spec.domain === 'sea' ? 'sea' : 'land';
|
||||
if (t.builds !== domain) return fail('wrong factory type');
|
||||
if (unitAt(state, x, y)) return fail('tile occupied');
|
||||
const fx = coEffects(rules, state, army);
|
||||
const cost = Math.round(spec.cost * (fx.costMult ?? 1));
|
||||
if (state.armies[army].funds < cost) return fail('insufficient funds');
|
||||
state.armies[army].funds -= cost;
|
||||
const unit = spawnUnit(rules, state, { army, type: unitType, x, y });
|
||||
unit.moved = true;
|
||||
state.armies[army].hadUnits = true;
|
||||
events.push({ type: 'built', unitId: unit.id, unitType, x, y, army, cost });
|
||||
return { ok: true, events };
|
||||
}
|
||||
}
|
||||
|
||||
// everything below acts on a specific unit
|
||||
const got = requireActiveUnit(state, action);
|
||||
if (got.error) return fail(got.error);
|
||||
const unit = got.unit;
|
||||
const spec = rules.unitById[unit.type];
|
||||
|
||||
const moveRes = executeMove(rules, state, unit, action.path ?? [], events);
|
||||
if (moveRes.error) return fail(moveRes.error);
|
||||
const movedTiles = (action.path ?? []).length > 0;
|
||||
|
||||
if (moveRes.trapped) {
|
||||
unit.moved = true;
|
||||
checkResult(rules, state, events);
|
||||
return { ok: true, events, trapped: true };
|
||||
}
|
||||
|
||||
switch (action.type) {
|
||||
case 'wait':
|
||||
break;
|
||||
|
||||
case 'attack': {
|
||||
const target = unitById(state, action.targetId);
|
||||
if (!target) { rollbackIllegal(state, unit); return fail('no target'); }
|
||||
if (!canAttack(rules, state, unit, target, { afterMove: movedTiles })) {
|
||||
return fail('cannot attack');
|
||||
}
|
||||
resolveAttack(rules, state, unit, target, events);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'capture': {
|
||||
if (!spec.capture) return fail('cannot capture');
|
||||
const k = tileKey(state, unit.x, unit.y);
|
||||
const t = rules.terrains[state.terrain[k]];
|
||||
if (!t.property || state.owner[k] === army) return fail('nothing to capture');
|
||||
if (state.owner[k] >= 0 && !hostile(state, state.owner[k], army)) return fail('allied property');
|
||||
const fx = coEffects(rules, state, army);
|
||||
const points = Math.floor(hpDisplay(unit) * (fx.captureMult ?? 1));
|
||||
state.captureHp[k] = Math.max(0, state.captureHp[k] - points);
|
||||
unit.capturing = state.captureHp[k] > 0;
|
||||
events.push({ type: 'capturing', unitId: unit.id, x: unit.x, y: unit.y, left: state.captureHp[k] });
|
||||
if (state.captureHp[k] === 0) {
|
||||
const prevOwner = state.owner[k];
|
||||
state.owner[k] = army;
|
||||
state.captureHp[k] = rules.constants.captureGoal;
|
||||
events.push({ type: 'captured', unitId: unit.id, x: unit.x, y: unit.y, army, prevOwner });
|
||||
if (t.hq && prevOwner >= 0) {
|
||||
eliminateArmy(rules, state, prevOwner, events);
|
||||
if (prevOwner === 0) state.result = { winner: 'enemy', reason: 'hq' };
|
||||
else if (!state.armies.some((a, i) => i > 0 && a.alive)) {
|
||||
state.result = { winner: 'player', reason: 'hq' };
|
||||
}
|
||||
if (state.result) events.push({ type: 'gameOver', ...state.result });
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'load': {
|
||||
// path must end on the transport's tile — executeMove refuses to stop
|
||||
// on occupied tiles, so 'load' paths end adjacent and we hop on here.
|
||||
const transport = unitAt(state, action.x, action.y);
|
||||
if (!transport || transport.army !== army) return fail('no transport');
|
||||
const tspec = rules.unitById[transport.type];
|
||||
if (!tspec.transport) return fail('not a transport');
|
||||
if (Math.abs(unit.x - action.x) + Math.abs(unit.y - action.y) !== 1 &&
|
||||
!(unit.x === action.x && unit.y === action.y)) return fail('not adjacent to transport');
|
||||
if (transport.cargo.length >= tspec.transport.cap) return fail('transport full');
|
||||
const carries = tspec.transport.carries;
|
||||
const ok = carries === 'landUnits'
|
||||
? rules.unitById[unit.type].domain === 'land'
|
||||
: carries.includes(unit.type);
|
||||
if (!ok) return fail('cannot carry that unit');
|
||||
state.units.splice(state.units.indexOf(unit), 1);
|
||||
transport.cargo.push(unit);
|
||||
unit.x = transport.x; unit.y = transport.y;
|
||||
events.push({ type: 'loaded', unitId: unit.id, transportId: transport.id });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'unload': {
|
||||
if (!spec.transport) return fail('not a transport');
|
||||
if (!unit.cargo.length) return fail('empty');
|
||||
for (const drop of action.drops ?? []) {
|
||||
const cargo = unit.cargo[drop.cargoIndex];
|
||||
if (!cargo) return fail('bad cargo index');
|
||||
if (Math.abs(drop.x - unit.x) + Math.abs(drop.y - unit.y) !== 1) return fail('drop not adjacent');
|
||||
const t = terrainAt(rules, state, drop.x, drop.y);
|
||||
if (t.cost[rules.unitById[cargo.type].moveType] == null) return fail('cargo cannot stand there');
|
||||
if (unitAt(state, drop.x, drop.y)) return fail('drop tile occupied');
|
||||
unit.cargo.splice(drop.cargoIndex, 1);
|
||||
cargo.x = drop.x; cargo.y = drop.y;
|
||||
cargo.moved = true;
|
||||
state.units.push(cargo);
|
||||
events.push({ type: 'unloaded', unitId: cargo.id, transportId: unit.id, x: drop.x, y: drop.y });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'supply': {
|
||||
if (!spec.supplies) return fail('cannot supply');
|
||||
supplyAdjacent(rules, state, unit, events);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'dive': {
|
||||
if (!spec.dive) return fail('cannot dive');
|
||||
unit.dived = true;
|
||||
events.push({ type: 'dived', unitId: unit.id });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'rise': {
|
||||
if (!unit.dived) return fail('not dived');
|
||||
unit.dived = false;
|
||||
events.push({ type: 'rose', unitId: unit.id });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'join': {
|
||||
const other = unitAt(state, action.x, action.y);
|
||||
if (!other || other === unit) return fail('nothing to join');
|
||||
if (other.army !== army || other.type !== unit.type) return fail('join needs same type');
|
||||
if (other.moved && other.hp >= 100) return fail('cannot join');
|
||||
if (Math.abs(unit.x - action.x) + Math.abs(unit.y - action.y) !== 1) return fail('not adjacent');
|
||||
other.hp = Math.min(100, other.hp + unit.hp);
|
||||
other.fuel = Math.min(rules.unitById[other.type].fuel, other.fuel + unit.fuel);
|
||||
other.ammo = Math.min(rules.unitById[other.type].ammo, other.ammo + unit.ammo);
|
||||
state.units.splice(state.units.indexOf(unit), 1);
|
||||
events.push({ type: 'joined', unitId: unit.id, intoId: other.id });
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
return fail(`unknown action ${action.type}`);
|
||||
}
|
||||
|
||||
unit.moved = true;
|
||||
checkResult(rules, state, events);
|
||||
return { ok: true, events };
|
||||
}
|
||||
|
||||
function rollbackIllegal() { /* moves are already committed; callers validate targets via canAttack first */ }
|
||||
|
||||
function supplyAdjacent(rules, state, supplier, events) {
|
||||
for (const u of state.units) {
|
||||
if (u.army !== supplier.army || u === supplier) continue;
|
||||
if (Math.abs(u.x - supplier.x) + Math.abs(u.y - supplier.y) !== 1) continue;
|
||||
const spec = rules.unitById[u.type];
|
||||
if (u.fuel < spec.fuel || u.ammo < spec.ammo) {
|
||||
u.fuel = spec.fuel;
|
||||
u.ammo = spec.ammo;
|
||||
events.push({ type: 'supplied', unitId: u.id, byId: supplier.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyImmediatePower(rules, state, army, effects, events) {
|
||||
if (effects.healAll) {
|
||||
for (const u of state.units) {
|
||||
if (u.army === army) u.hp = Math.min(100, u.hp + effects.healAll * 10);
|
||||
}
|
||||
events.push({ type: 'healedAll', army, amount: effects.healAll });
|
||||
}
|
||||
if (effects.refreshNonInfantry) {
|
||||
for (const u of state.units) {
|
||||
if (u.army === army && !['infantry', 'mech'].includes(u.type)) u.moved = false;
|
||||
}
|
||||
events.push({ type: 'refreshed', army });
|
||||
}
|
||||
if (effects.globalDamage) {
|
||||
for (const u of state.units) {
|
||||
if (hostile(state, u.army, army)) {
|
||||
u.hp = Math.max(1, u.hp - effects.globalDamage * 10);
|
||||
}
|
||||
}
|
||||
events.push({ type: 'tsunami', army, amount: effects.globalDamage });
|
||||
}
|
||||
if (effects.meteor) {
|
||||
const { dmg, radius } = effects.meteor;
|
||||
let best = null;
|
||||
for (let y = 0; y < state.h; y++) {
|
||||
for (let x = 0; x < state.w; x++) {
|
||||
let value = 0;
|
||||
for (const u of state.units) {
|
||||
if (Math.abs(u.x - x) + Math.abs(u.y - y) > radius) continue;
|
||||
const worth = fundsValue(rules, u, u.hp);
|
||||
value += hostile(state, u.army, army) ? worth : -worth;
|
||||
}
|
||||
if (!best || value > best.value) best = { x, y, value };
|
||||
}
|
||||
}
|
||||
if (best) {
|
||||
for (const u of state.units) {
|
||||
if (Math.abs(u.x - best.x) + Math.abs(u.y - best.y) <= radius && hostile(state, u.army, army)) {
|
||||
u.hp = Math.max(1, u.hp - dmg * 10);
|
||||
}
|
||||
}
|
||||
events.push({ type: 'meteor', army, x: best.x, y: best.y, radius, dmg });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Convenience queries used by scene + AI
|
||||
|
||||
export function attackTargetsFrom(rules, state, unit, x, y, { afterMove }) {
|
||||
// Enemies attackable if the unit stood at (x, y). Uses a shallow proxy so
|
||||
// the caller can probe candidate destinations without mutating state.
|
||||
const probe = { ...unit, x, y };
|
||||
const list = [];
|
||||
for (const enemy of state.units) {
|
||||
if (!hostile(state, enemy.army, unit.army)) continue;
|
||||
if (state.fog && !isUnitSpotted(rules, state, enemy, unit.army)) continue;
|
||||
if (canAttack(rules, state, probe, enemy, { afterMove })) list.push(enemy);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
export function buildOptions(rules, state, x, y) {
|
||||
const t = terrainAt(rules, state, x, y);
|
||||
const army = state.turn;
|
||||
if (!t.builds || state.owner[tileKey(state, x, y)] !== army || unitAt(state, x, y)) return [];
|
||||
const fx = coEffects(rules, state, army);
|
||||
return rules.units
|
||||
.filter((u) => (u.domain === 'air' ? 'air' : u.domain === 'sea' ? 'sea' : 'land') === t.builds)
|
||||
.map((u) => ({
|
||||
type: u.id,
|
||||
cost: Math.round(u.cost * (fx.costMult ?? 1)),
|
||||
affordable: state.armies[army].funds >= Math.round(u.cost * (fx.costMult ?? 1)),
|
||||
}));
|
||||
}
|
||||
|
||||
export function powerReady(rules, state, army) {
|
||||
const a = state.armies[army];
|
||||
const co = rules.coById[a.co];
|
||||
if (!co.power || a.powerActive) return false;
|
||||
return a.charge >= co.power.stars * rules.constants.starCharge;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Serialization
|
||||
|
||||
export function serialize(state) {
|
||||
const { _openingEvents, ...rest } = state;
|
||||
return JSON.stringify(rest);
|
||||
}
|
||||
|
||||
export function deserialize(json) {
|
||||
const state = JSON.parse(json);
|
||||
if (state.v !== SAVE_VERSION) throw new Error(`save version ${state.v} != ${SAVE_VERSION}`);
|
||||
return state;
|
||||
}
|
||||
|
|
@ -0,0 +1,520 @@
|
|||
// Advance Wars board renderer: terrain (RenderTexture), buildings (live
|
||||
// tinted images), units (containers with 2-frame flip + HP digits), fog,
|
||||
// move/attack overlays, path arrow and cursor.
|
||||
//
|
||||
// Art comes from FOUR drop-in sheets (data/advancewars-artwork.json →
|
||||
// terrainSheet 48×48, buildingSheet 48×64 tintable, unitSheet 48×64
|
||||
// tintable, uiSheet 48×48; spec: src/games/advancewars/sprites.md). Any
|
||||
// sheet not painted yet gets a procedural stand-in with the same frame map.
|
||||
// Rivers and roads are NOT sprites — they're drawn with Graphics from the
|
||||
// tile connection masks.
|
||||
|
||||
import * as Phaser from 'phaser';
|
||||
import * as Logic from './AdvanceWarsLogic.js';
|
||||
|
||||
const CELL = 48;
|
||||
const TALL = 64; // building/unit cells: 48×48 footprint + 16px headroom
|
||||
|
||||
// Painted drop-in sheets and the layout their procedural stand-ins mirror.
|
||||
export const SHEETS = {
|
||||
terrain: { key: 'advancewars-terrain', cellH: CELL, cols: 8, frames: 8 },
|
||||
buildings: { key: 'advancewars-buildings', cellH: TALL, cols: 6, frames: 5 },
|
||||
units: { key: 'advancewars-units', cellH: TALL, cols: 12, frames: 36 },
|
||||
ui: { key: 'advancewars-ui', cellH: CELL, cols: 8, frames: 7 },
|
||||
};
|
||||
|
||||
export const UI_FRAMES = {
|
||||
cursor: 0, explosion1: 1, explosion2: 2, capture: 3, fuel: 4, ammo: 5, star: 6,
|
||||
};
|
||||
|
||||
export function armyColorInt(rules, army) {
|
||||
const hex = army < 0 ? rules.constants.neutralColor : rules.constants.armyColors[army];
|
||||
return parseInt(hex.slice(1), 16);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Procedural stand-in sheets
|
||||
|
||||
// Returns { terrain, buildings, units, ui } texture keys — the painted sheet
|
||||
// when it's loaded, otherwise a generated stand-in.
|
||||
export function ensureSheets(scene, rules) {
|
||||
const keys = {};
|
||||
for (const [name, spec] of Object.entries(SHEETS)) {
|
||||
if (scene.textures.exists(spec.key)) { keys[name] = spec.key; continue; }
|
||||
const procKey = `${spec.key}-proc`;
|
||||
if (!scene.textures.exists(procKey)) PROC_PAINTERS[name](scene, procKey, spec, rules);
|
||||
keys[name] = procKey;
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function mkCanvasSheet(scene, key, spec, count) {
|
||||
const rows = Math.ceil(count / spec.cols);
|
||||
const tex = scene.textures.createCanvas(key, spec.cols * CELL, rows * spec.cellH);
|
||||
const ctx = tex.getContext();
|
||||
const at = (frame, draw) => {
|
||||
const fx = (frame % spec.cols) * CELL;
|
||||
const fy = Math.floor(frame / spec.cols) * spec.cellH;
|
||||
ctx.save();
|
||||
// origin at the top-left of the 48×48 footprint (tall cells have 16px
|
||||
// of headroom above it)
|
||||
ctx.translate(fx, fy + (spec.cellH - CELL));
|
||||
draw(ctx);
|
||||
ctx.restore();
|
||||
};
|
||||
const finish = () => {
|
||||
tex.refresh();
|
||||
for (let f = 0; f < count; f++) {
|
||||
tex.add(f, 0, (f % spec.cols) * CELL, Math.floor(f / spec.cols) * spec.cellH, CELL, spec.cellH);
|
||||
}
|
||||
};
|
||||
return { at, finish };
|
||||
}
|
||||
|
||||
const ground = (c, color) => { c.fillStyle = color; c.fillRect(0, 0, CELL, CELL); };
|
||||
|
||||
const PROC_PAINTERS = {
|
||||
terrain(scene, key, spec) {
|
||||
const { at, finish } = mkCanvasSheet(scene, key, spec, spec.frames);
|
||||
at(0, (c) => { ground(c, '#a8d060'); c.fillStyle = '#b8dc74'; for (let i = 0; i < 5; i++) c.fillRect(6 + i * 8, 10 + (i % 3) * 12, 3, 2); });
|
||||
at(1, (c) => {
|
||||
ground(c, '#8cc456');
|
||||
c.fillStyle = '#2f6b22';
|
||||
for (const [tx, ty] of [[12, 8], [30, 4], [21, 18]]) {
|
||||
c.beginPath(); c.moveTo(tx, ty + 22); c.lineTo(tx + 10, ty + 22); c.lineTo(tx + 5, ty + 2); c.fill();
|
||||
c.fillRect(tx + 3.5, ty + 22, 3, 5);
|
||||
}
|
||||
});
|
||||
at(2, (c) => {
|
||||
ground(c, '#b5d16b');
|
||||
c.fillStyle = '#8a6a3c';
|
||||
c.beginPath(); c.moveTo(3, 45); c.lineTo(24, 4); c.lineTo(45, 45); c.fill();
|
||||
c.fillStyle = '#f0ede2';
|
||||
c.beginPath(); c.moveTo(19, 14); c.lineTo(24, 4); c.lineTo(29, 14); c.lineTo(24, 19); c.fill();
|
||||
});
|
||||
at(3, (c) => { ground(c, '#3d6fd6'); c.strokeStyle = '#6f9ae8'; c.lineWidth = 2; for (let i = 0; i < 3; i++) { c.beginPath(); c.moveTo(4 + i * 6, 12 + i * 12); c.quadraticCurveTo(14 + i * 6, 8 + i * 12, 24 + i * 6, 12 + i * 12); c.stroke(); } });
|
||||
at(4, (c) => { ground(c, '#3d6fd6'); c.fillStyle = '#24488f'; for (const [px, py] of [[10, 12], [26, 8], [18, 26], [34, 30], [30, 18]]) { c.beginPath(); c.arc(px, py, 4, 0, 7); c.fill(); } });
|
||||
at(5, (c) => { ground(c, '#e8d9a0'); c.fillStyle = '#8fb8e8'; c.fillRect(0, 0, CELL, 8); c.fillStyle = '#f4e9c0'; c.fillRect(0, 8, CELL, 6); });
|
||||
const bridge = (vert) => (c) => {
|
||||
ground(c, '#3d6fd6');
|
||||
c.fillStyle = '#b0a89a';
|
||||
if (vert) c.fillRect(12, 0, 24, CELL); else c.fillRect(0, 12, CELL, 24);
|
||||
c.fillStyle = '#8a8276';
|
||||
if (vert) { c.fillRect(12, 0, 4, CELL); c.fillRect(32, 0, 4, CELL); } else { c.fillRect(0, 12, CELL, 4); c.fillRect(0, 32, CELL, 4); }
|
||||
};
|
||||
at(6, bridge(false));
|
||||
at(7, bridge(true));
|
||||
finish();
|
||||
},
|
||||
|
||||
buildings(scene, key, spec) {
|
||||
const { at, finish } = mkCanvasSheet(scene, key, spec, spec.frames);
|
||||
const prop = (drawIcon) => (c) => {
|
||||
ground(c, '#a8d060');
|
||||
c.fillStyle = '#ececec'; c.strokeStyle = '#3a3a3a'; c.lineWidth = 2;
|
||||
c.fillRect(6, -6, 36, 42); c.strokeRect(6, -6, 36, 42);
|
||||
drawIcon(c);
|
||||
};
|
||||
at(0, prop((c) => { c.fillStyle = '#8a8a8a'; c.fillRect(12, 2, 10, 26); c.fillRect(26, 10, 10, 18); c.fillStyle = '#3a3a3a'; for (let i = 0; i < 3; i++) { c.fillRect(14, 5 + i * 7, 6, 3); c.fillRect(28, 13 + i * 6, 6, 3); } }));
|
||||
at(1, prop((c) => { c.fillStyle = '#8a8a8a'; c.fillRect(10, 12, 28, 16); c.beginPath(); c.moveTo(10, 12); c.lineTo(17, 2); c.lineTo(24, 12); c.lineTo(31, 2); c.lineTo(38, 12); c.fill(); c.fillStyle = '#3a3a3a'; c.fillRect(20, 18, 8, 10); }));
|
||||
at(2, prop((c) => { c.fillStyle = '#8a8a8a'; c.beginPath(); c.moveTo(24, 0); c.lineTo(28, 12); c.lineTo(40, 16); c.lineTo(28, 20); c.lineTo(24, 32); c.lineTo(20, 20); c.lineTo(8, 16); c.lineTo(20, 12); c.fill(); }));
|
||||
at(3, prop((c) => { c.strokeStyle = '#8a8a8a'; c.lineWidth = 4; c.beginPath(); c.arc(24, 14, 9, 0.5, Math.PI - 0.5, false); c.stroke(); c.beginPath(); c.moveTo(24, 0); c.lineTo(24, 20); c.stroke(); c.beginPath(); c.moveTo(16, 6); c.lineTo(32, 6); c.stroke(); }));
|
||||
at(4, prop((c) => { c.fillStyle = '#8a8a8a'; c.fillRect(21, -4, 4, 34); c.fillStyle = '#c8c8c8'; c.beginPath(); c.moveTo(25, -2); c.lineTo(41, 4); c.lineTo(25, 10); c.fill(); }));
|
||||
finish();
|
||||
},
|
||||
|
||||
units(scene, key, spec, rules) {
|
||||
const { at, finish } = mkCanvasSheet(scene, key, spec, spec.frames);
|
||||
const unitGlyph = (abbr, domain) => (c, bob) => {
|
||||
const y0 = -bob * 3;
|
||||
c.fillStyle = '#f0f0f0'; c.strokeStyle = '#26262e'; c.lineWidth = 2.5;
|
||||
if (domain === 'air') {
|
||||
c.beginPath(); c.ellipse(24, 22 + y0, 18, 11, 0, 0, 7); c.fill(); c.stroke();
|
||||
c.beginPath(); c.moveTo(6, 22 + y0); c.lineTo(0, 34 + y0); c.lineTo(14, 28 + y0); c.fill();
|
||||
} else if (domain === 'sea') {
|
||||
c.beginPath(); c.moveTo(2, 26 + y0); c.lineTo(46, 26 + y0); c.lineTo(38, 40 + y0); c.lineTo(10, 40 + y0); c.fill(); c.stroke();
|
||||
c.fillRect(16, 14 + y0, 16, 12);
|
||||
} else {
|
||||
c.beginPath(); c.roundRect(6, 12 + y0, 36, 26, 7); c.fill(); c.stroke();
|
||||
}
|
||||
c.fillStyle = '#26262e';
|
||||
c.font = 'bold 15px monospace';
|
||||
c.textAlign = 'center';
|
||||
c.fillText(abbr, 24, 31 + y0);
|
||||
};
|
||||
for (const u of rules.units) {
|
||||
const draw = unitGlyph(u.abbr, u.domain);
|
||||
at(u.frame, (c) => draw(c, 0));
|
||||
at(u.frame + 1, (c) => draw(c, 1));
|
||||
}
|
||||
finish();
|
||||
},
|
||||
|
||||
ui(scene, key, spec) {
|
||||
const { at, finish } = mkCanvasSheet(scene, key, spec, spec.frames);
|
||||
at(UI_FRAMES.cursor, (c) => {
|
||||
c.strokeStyle = '#ffffff'; c.lineWidth = 4;
|
||||
for (const [sx, sy, dx, dy] of [[3, 3, 1, 1], [45, 3, -1, 1], [3, 45, 1, -1], [45, 45, -1, -1]]) {
|
||||
c.beginPath(); c.moveTo(sx + dx * 12, sy); c.lineTo(sx, sy); c.lineTo(sx, sy + dy * 12); c.stroke();
|
||||
}
|
||||
});
|
||||
const boom = (r) => (c) => {
|
||||
c.fillStyle = '#ffb02f';
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const a = (i / 8) * Math.PI * 2;
|
||||
c.beginPath(); c.arc(24 + Math.cos(a) * r, 24 + Math.sin(a) * r, r * 0.55, 0, 7); c.fill();
|
||||
}
|
||||
c.fillStyle = '#ff5a2f'; c.beginPath(); c.arc(24, 24, r * 0.8, 0, 7); c.fill();
|
||||
};
|
||||
at(UI_FRAMES.explosion1, boom(8));
|
||||
at(UI_FRAMES.explosion2, boom(15));
|
||||
at(UI_FRAMES.capture, (c) => { c.fillStyle = '#fff'; c.fillRect(10, 8, 4, 32); c.fillStyle = '#ff4040'; c.beginPath(); c.moveTo(14, 8); c.lineTo(38, 15); c.lineTo(14, 22); c.fill(); });
|
||||
at(UI_FRAMES.fuel, (c) => { c.fillStyle = '#ffce4d'; c.beginPath(); c.moveTo(26, 2); c.lineTo(12, 26); c.lineTo(22, 26); c.lineTo(18, 46); c.lineTo(36, 20); c.lineTo(25, 20); c.fill(); });
|
||||
at(UI_FRAMES.ammo, (c) => { c.fillStyle = '#e05050'; c.fillRect(18, 14, 12, 24); c.beginPath(); c.moveTo(18, 14); c.lineTo(24, 2); c.lineTo(30, 14); c.fill(); });
|
||||
at(UI_FRAMES.star, (c) => {
|
||||
c.fillStyle = '#ffd94d'; c.strokeStyle = '#8a6d00'; c.lineWidth = 2;
|
||||
c.beginPath();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const a = -Math.PI / 2 + (i * Math.PI) / 5;
|
||||
const r = i % 2 ? 9 : 20;
|
||||
c[i ? 'lineTo' : 'moveTo'](24 + Math.cos(a) * r, 24 + Math.sin(a) * r);
|
||||
}
|
||||
c.closePath(); c.fill(); c.stroke();
|
||||
});
|
||||
finish();
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Board view
|
||||
|
||||
export class AdvanceWarsMapView {
|
||||
constructor(scene, rules, state, opts) {
|
||||
this.scene = scene;
|
||||
this.rules = rules;
|
||||
this.state = state;
|
||||
this.tex = ensureSheets(scene, rules);
|
||||
|
||||
const { areaX, areaY, areaW, areaH } = opts;
|
||||
this.tile = Math.max(24, Math.min(84, Math.floor(areaW / state.w), Math.floor(areaH / state.h)));
|
||||
this.ox = areaX + Math.floor((areaW - this.tile * state.w) / 2);
|
||||
this.oy = areaY + Math.floor((areaH - this.tile * state.h) / 2);
|
||||
|
||||
this.depths = { terrain: 1, props: 2, moveOv: 3, atkOv: 4, units: 5, fog: 12, path: 13, cursor: 14 };
|
||||
this.unitViews = new Map(); // unitId -> { c, sprite, hp, badge }
|
||||
this.propViews = new Map(); // tileKey -> image
|
||||
this.animStep = 0;
|
||||
|
||||
this.buildTerrain();
|
||||
this.buildProps();
|
||||
this.moveG = scene.add.graphics().setDepth(this.depths.moveOv);
|
||||
this.atkG = scene.add.graphics().setDepth(this.depths.atkOv);
|
||||
this.fogG = scene.add.graphics().setDepth(this.depths.fog);
|
||||
this.pathG = scene.add.graphics().setDepth(this.depths.path);
|
||||
this.cursor = scene.add.image(0, 0, this.tex.ui, UI_FRAMES.cursor)
|
||||
.setDisplaySize(this.tile, this.tile)
|
||||
.setDepth(this.depths.cursor).setVisible(false);
|
||||
this.syncUnits();
|
||||
|
||||
this.flipTimer = scene.time.addEvent({
|
||||
delay: 450, loop: true, callback: () => {
|
||||
this.animStep = 1 - this.animStep;
|
||||
for (const [id, v] of this.unitViews) {
|
||||
const u = Logic.unitById(this.state, id);
|
||||
if (u) v.sprite.setFrame(this.rules.unitById[u.type].frame + this.animStep);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.flipTimer?.remove();
|
||||
this.rt?.destroy();
|
||||
this.moveG?.destroy(); this.atkG?.destroy(); this.fogG?.destroy();
|
||||
this.pathG?.destroy(); this.cursor?.destroy();
|
||||
for (const v of this.unitViews.values()) v.c.destroy();
|
||||
for (const img of this.propViews.values()) img.destroy();
|
||||
}
|
||||
|
||||
// pixel center of a tile
|
||||
px(x) { return this.ox + x * this.tile + this.tile / 2; }
|
||||
py(y) { return this.oy + y * this.tile + this.tile / 2; }
|
||||
tileAt(worldX, worldY) {
|
||||
const x = Math.floor((worldX - this.ox) / this.tile);
|
||||
const y = Math.floor((worldY - this.oy) / this.tile);
|
||||
return Logic.inBounds(this.state, x, y) ? { x, y } : null;
|
||||
}
|
||||
|
||||
spriteScaleFor() { return this.tile / CELL; }
|
||||
|
||||
buildTerrain() {
|
||||
const { scene, state, rules } = this;
|
||||
const w = state.w * this.tile, h = state.h * this.tile;
|
||||
this.rt = scene.add.renderTexture(this.ox, this.oy, w, h).setOrigin(0, 0).setDepth(this.depths.terrain);
|
||||
this.rt.beginDraw();
|
||||
for (let y = 0; y < state.h; y++) {
|
||||
for (let x = 0; x < state.w; x++) {
|
||||
const t = rules.terrains[state.terrain[y * state.w + x]];
|
||||
// rivers/roads/buildings sit on plain ground; the channels are drawn
|
||||
// by Graphics below and buildings are live tinted images
|
||||
const frame = (t.property || t.id === 'river' || t.id === 'road')
|
||||
? rules.terrainById.plain.frame
|
||||
: t.frame;
|
||||
this.drawCell(frame, x, y);
|
||||
}
|
||||
}
|
||||
this.rt.endDraw();
|
||||
this.drawChannels();
|
||||
this.drawShores();
|
||||
}
|
||||
|
||||
drawCell(frame, x, y) {
|
||||
const scale = this.tile / CELL;
|
||||
const img = this.scene.make.image({ key: this.tex.terrain, frame, add: false });
|
||||
img.setOrigin(0, 0).setScale(scale);
|
||||
this.rt.batchDraw(img, x * this.tile, y * this.tile);
|
||||
img.destroy();
|
||||
}
|
||||
|
||||
// Graphics-drawn rivers and roads (no sprite frames): each tile strokes
|
||||
// from its center toward every connected edge.
|
||||
drawChannels() {
|
||||
const { state, rules } = this;
|
||||
const g = this.scene.add.graphics();
|
||||
const draw = (kind, color, width) => {
|
||||
g.lineStyle(width, color, 1);
|
||||
g.fillStyle(color, 1);
|
||||
for (let y = 0; y < state.h; y++) {
|
||||
for (let x = 0; x < state.w; x++) {
|
||||
const t = rules.terrains[state.terrain[y * state.w + x]];
|
||||
if (t.id !== kind) continue;
|
||||
const cx = x * this.tile + this.tile / 2;
|
||||
const cy = y * this.tile + this.tile / 2;
|
||||
let any = false;
|
||||
for (const [dx, dy] of [[0, -1], [1, 0], [0, 1], [-1, 0]]) {
|
||||
if (!this.channelJoins(kind, x + dx, y + dy)) continue;
|
||||
any = true;
|
||||
g.beginPath();
|
||||
g.moveTo(cx, cy);
|
||||
g.lineTo(cx + dx * this.tile / 2, cy + dy * this.tile / 2);
|
||||
g.strokePath();
|
||||
}
|
||||
if (!any) { // isolated stub renders as an east-west segment
|
||||
g.beginPath(); g.moveTo(cx - this.tile / 2, cy); g.lineTo(cx + this.tile / 2, cy); g.strokePath();
|
||||
}
|
||||
g.fillRect(cx - width / 2, cy - width / 2, width, width);
|
||||
}
|
||||
}
|
||||
};
|
||||
draw('river', 0x7db4e8, Math.max(5, this.tile * 0.34));
|
||||
draw('road', 0xd8cfc0, Math.max(6, this.tile * 0.42));
|
||||
this.rt.draw(g, 0, 0);
|
||||
g.destroy();
|
||||
}
|
||||
|
||||
channelJoins(kind, nx, ny) {
|
||||
const { state, rules } = this;
|
||||
if (!Logic.inBounds(state, nx, ny)) return false;
|
||||
const n = rules.terrains[state.terrain[ny * state.w + nx]];
|
||||
if (kind === 'river') return n.id === 'river' || n.id === 'sea' || n.id === 'bridgeh' || n.id === 'bridgev';
|
||||
return n.id === 'road' || n.id === 'bridgeh' || n.id === 'bridgev' || n.property;
|
||||
}
|
||||
|
||||
drawShores() {
|
||||
const { state, rules } = this;
|
||||
const g = this.scene.add.graphics();
|
||||
g.lineStyle(Math.max(2, this.tile * 0.08), 0xf4e9c0, 0.9);
|
||||
for (let y = 0; y < state.h; y++) {
|
||||
for (let x = 0; x < state.w; x++) {
|
||||
const t = rules.terrains[state.terrain[y * state.w + x]];
|
||||
if (t.id !== 'sea' && t.id !== 'reef') continue;
|
||||
const px = x * this.tile, py = y * this.tile;
|
||||
const land = (nx, ny) => Logic.inBounds(state, nx, ny) &&
|
||||
!['sea', 'reef', 'bridgeh', 'bridgev'].includes(rules.terrains[state.terrain[ny * state.w + nx]].id);
|
||||
if (land(x, y - 1)) { g.beginPath(); g.moveTo(px, py + 1); g.lineTo(px + this.tile, py + 1); g.strokePath(); }
|
||||
if (land(x, y + 1)) { g.beginPath(); g.moveTo(px, py + this.tile - 1); g.lineTo(px + this.tile, py + this.tile - 1); g.strokePath(); }
|
||||
if (land(x - 1, y)) { g.beginPath(); g.moveTo(px + 1, py); g.lineTo(px + 1, py + this.tile); g.strokePath(); }
|
||||
if (land(x + 1, y)) { g.beginPath(); g.moveTo(px + this.tile - 1, py); g.lineTo(px + this.tile - 1, py + this.tile); g.strokePath(); }
|
||||
}
|
||||
}
|
||||
this.rt.draw(g, 0, 0);
|
||||
g.destroy();
|
||||
}
|
||||
|
||||
buildProps() {
|
||||
const { state, rules } = this;
|
||||
for (let k = 0; k < state.terrain.length; k++) {
|
||||
const t = rules.terrains[state.terrain[k]];
|
||||
if (!t.property) continue;
|
||||
const x = k % state.w, y = Math.floor(k / state.w);
|
||||
const img = this.scene.add.image(this.px(x), this.oy + (y + 1) * this.tile, this.tex.buildings, t.frame)
|
||||
.setOrigin(0.5, 1)
|
||||
.setScale(this.spriteScaleFor())
|
||||
.setDepth(this.depths.props);
|
||||
this.propViews.set(k, img);
|
||||
}
|
||||
this.refreshProps();
|
||||
}
|
||||
|
||||
refreshProps() {
|
||||
for (const [k, img] of this.propViews) {
|
||||
img.setTint(armyColorInt(this.rules, this.state.owner[k]));
|
||||
}
|
||||
}
|
||||
|
||||
// ── units ────────────────────────────────────────────────────────────────
|
||||
|
||||
syncUnits() {
|
||||
const { state, rules, scene } = this;
|
||||
const seen = new Set();
|
||||
for (const u of state.units) {
|
||||
seen.add(u.id);
|
||||
let v = this.unitViews.get(u.id);
|
||||
if (!v) {
|
||||
const c = scene.add.container(0, 0).setDepth(this.depths.units);
|
||||
const sprite = scene.add.image(0, 0, this.tex.units, rules.unitById[u.type].frame)
|
||||
.setOrigin(0.5, 1).setScale(this.spriteScaleFor());
|
||||
const hp = scene.add.text(this.tile * 0.30, -2, '', {
|
||||
fontFamily: 'm6x11, "Julius Sans One"', fontSize: `${Math.max(13, this.tile * 0.34)}px`,
|
||||
color: '#ffffff', stroke: '#000000', strokeThickness: 3,
|
||||
}).setOrigin(0.5, 1);
|
||||
const badge = scene.add.image(-this.tile * 0.32, -this.tile * 0.16, this.tex.ui, UI_FRAMES.capture)
|
||||
.setScale(this.spriteScaleFor() * 0.4).setVisible(false);
|
||||
c.add([sprite, hp, badge]);
|
||||
v = { c, sprite, hp, badge };
|
||||
this.unitViews.set(u.id, v);
|
||||
}
|
||||
this.placeUnit(u, v);
|
||||
}
|
||||
for (const [id, v] of [...this.unitViews]) {
|
||||
if (!seen.has(id)) { v.c.destroy(); this.unitViews.delete(id); }
|
||||
}
|
||||
this.refreshProps();
|
||||
}
|
||||
|
||||
placeUnit(u, v = this.unitViews.get(u.id)) {
|
||||
if (!v) return;
|
||||
const { rules } = this;
|
||||
v.c.setPosition(this.px(u.x), this.oy + (u.y + 1) * this.tile);
|
||||
v.sprite.setFrame(rules.unitById[u.type].frame + this.animStep);
|
||||
v.sprite.setFlipX(u.army !== 0);
|
||||
const base = armyColorInt(rules, u.army);
|
||||
v.sprite.setTint(u.moved && u.army === this.state.turn ? darken(base, 0.5) : base);
|
||||
v.sprite.setAlpha(u.dived ? 0.5 : 1);
|
||||
const hpd = Logic.hpDisplay(u);
|
||||
v.hp.setText(hpd < 10 ? String(hpd) : '');
|
||||
const spec = rules.unitById[u.type];
|
||||
const lowFuel = u.fuel <= spec.fuel * 0.25 && (spec.dailyFuel ?? 0) > 0;
|
||||
const lowAmmo = spec.ammo > 0 && u.ammo <= 1;
|
||||
v.badge.setVisible(!!(u.capturing || lowFuel || lowAmmo || u.cargo.length));
|
||||
if (u.capturing) v.badge.setFrame(UI_FRAMES.capture);
|
||||
else if (u.cargo.length) v.badge.setFrame(UI_FRAMES.star);
|
||||
else if (lowFuel) v.badge.setFrame(UI_FRAMES.fuel);
|
||||
else if (lowAmmo) v.badge.setFrame(UI_FRAMES.ammo);
|
||||
}
|
||||
|
||||
setUnitVisibility(visibleIds) {
|
||||
for (const [id, v] of this.unitViews) {
|
||||
v.c.setVisible(visibleIds.has(id));
|
||||
}
|
||||
}
|
||||
|
||||
// ── overlays ─────────────────────────────────────────────────────────────
|
||||
|
||||
showMoveRange(keys) {
|
||||
this.moveG.clear();
|
||||
this.moveG.fillStyle(0x3d8bff, 0.38);
|
||||
for (const key of keys) {
|
||||
const x = key % this.state.w, y = Math.floor(key / this.state.w);
|
||||
this.moveG.fillRect(this.ox + x * this.tile + 1, this.oy + y * this.tile + 1, this.tile - 2, this.tile - 2);
|
||||
}
|
||||
}
|
||||
|
||||
showAttackTiles(tiles) {
|
||||
this.atkG.clear();
|
||||
this.atkG.fillStyle(0xff3d3d, 0.42);
|
||||
for (const { x, y } of tiles) {
|
||||
this.atkG.fillRect(this.ox + x * this.tile + 1, this.oy + y * this.tile + 1, this.tile - 2, this.tile - 2);
|
||||
}
|
||||
}
|
||||
|
||||
clearOverlays() {
|
||||
this.moveG.clear();
|
||||
this.atkG.clear();
|
||||
this.pathG.clear();
|
||||
}
|
||||
|
||||
showPath(unit, path) {
|
||||
this.pathG.clear();
|
||||
if (!path?.length) return;
|
||||
this.pathG.lineStyle(Math.max(4, this.tile * 0.16), 0xffe14d, 0.95);
|
||||
this.pathG.beginPath();
|
||||
this.pathG.moveTo(this.px(unit.x), this.py(unit.y));
|
||||
for (const s of path) this.pathG.lineTo(this.px(s.x), this.py(s.y));
|
||||
this.pathG.strokePath();
|
||||
const last = path[path.length - 1];
|
||||
const prev = path.length > 1 ? path[path.length - 2] : { x: unit.x, y: unit.y };
|
||||
const ang = Math.atan2(last.y - prev.y, last.x - prev.x);
|
||||
const r = this.tile * 0.24;
|
||||
this.pathG.fillStyle(0xffe14d, 0.95);
|
||||
this.pathG.beginPath();
|
||||
this.pathG.moveTo(this.px(last.x) + Math.cos(ang) * r, this.py(last.y) + Math.sin(ang) * r);
|
||||
this.pathG.lineTo(this.px(last.x) + Math.cos(ang + 2.5) * r, this.py(last.y) + Math.sin(ang + 2.5) * r);
|
||||
this.pathG.lineTo(this.px(last.x) + Math.cos(ang - 2.5) * r, this.py(last.y) + Math.sin(ang - 2.5) * r);
|
||||
this.pathG.closePath();
|
||||
this.pathG.fillPath();
|
||||
}
|
||||
|
||||
setCursor(tile) {
|
||||
if (!tile) { this.cursor.setVisible(false); return; }
|
||||
this.cursor.setVisible(true).setPosition(this.px(tile.x), this.py(tile.y));
|
||||
}
|
||||
|
||||
refreshFog(army) {
|
||||
this.fogG.clear();
|
||||
if (!this.state.fog) {
|
||||
this.setUnitVisibility(new Set(this.state.units.map((u) => u.id)));
|
||||
return;
|
||||
}
|
||||
const vis = Logic.computeVision(this.rules, this.state, army);
|
||||
this.fogG.fillStyle(0x0a0c18, 0.45);
|
||||
for (let k = 0; k < this.state.terrain.length; k++) {
|
||||
if (vis.has(k)) continue;
|
||||
const x = k % this.state.w, y = Math.floor(k / this.state.w);
|
||||
this.fogG.fillRect(this.ox + x * this.tile, this.oy + y * this.tile, this.tile, this.tile);
|
||||
}
|
||||
const spotted = new Set(Logic.visibleUnits(this.rules, this.state, army).map((u) => u.id));
|
||||
this.setUnitVisibility(spotted);
|
||||
}
|
||||
|
||||
// quick explosion puff at a tile
|
||||
boom(x, y, onDone) {
|
||||
const img = this.scene.add.image(this.px(x), this.py(y), this.tex.ui, UI_FRAMES.explosion1)
|
||||
.setScale(this.spriteScaleFor()).setDepth(this.depths.cursor);
|
||||
this.scene.time.delayedCall(110, () => img.setFrame(UI_FRAMES.explosion2));
|
||||
this.scene.time.delayedCall(260, () => { img.destroy(); onDone?.(); });
|
||||
}
|
||||
|
||||
// animate one unit view along a path (state already updated)
|
||||
animateMove(unitId, path, onDone) {
|
||||
const v = this.unitViews.get(unitId);
|
||||
if (!v || !path?.length) { onDone?.(); return; }
|
||||
const points = path.map((s) => ({ x: this.px(s.x), y: this.oy + (s.y + 1) * this.tile }));
|
||||
let i = 0;
|
||||
const step = () => {
|
||||
if (i >= points.length) { onDone?.(); return; }
|
||||
const p = points[i++];
|
||||
this.scene.tweens.add({
|
||||
targets: v.c, x: p.x, y: p.y, duration: 70, onComplete: step,
|
||||
});
|
||||
};
|
||||
step();
|
||||
}
|
||||
}
|
||||
|
||||
function darken(color, f) {
|
||||
const r = Math.floor(((color >> 16) & 0xff) * f);
|
||||
const g = Math.floor(((color >> 8) & 0xff) * f);
|
||||
const b = Math.floor((color & 0xff) * f);
|
||||
return (r << 16) | (g << 8) | b;
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
// Advance Wars rules compiler. No Phaser imports; runs in Node.
|
||||
// Takes the raw data/advancewars-rules.json and returns an indexed,
|
||||
// validated rules object shared by the engine, the AI, the scene and
|
||||
// tools/verifyAdvanceWars.js.
|
||||
|
||||
// Every CO day-to-day / power effect key the engine understands. compileRules
|
||||
// rejects anything else so a data typo fails in the verify script, not
|
||||
// silently at runtime.
|
||||
export const EFFECT_KEYS = new Set([
|
||||
'atk', 'def', 'directAtk', 'indirectAtk', 'footAtk', 'nonFootDirectAtk',
|
||||
'airAtk', 'seaAtk', 'rangeMod', 'visionMod', 'directMove', 'footMove',
|
||||
'seaMove', 'transportMove', 'airFuelSave', 'captureMult', 'costMult',
|
||||
'terrainCostFlat', 'counterFirst', 'refreshNonInfantry', 'healAll',
|
||||
'meteor', 'snow', 'globalDamage', 'luckMax',
|
||||
]);
|
||||
|
||||
export function compileRules(json) {
|
||||
if (!json || !Array.isArray(json.units) || !Array.isArray(json.terrains)) {
|
||||
throw new Error('advancewars-rules: malformed json');
|
||||
}
|
||||
const constants = json.constants;
|
||||
const terrains = json.terrains;
|
||||
const units = json.units;
|
||||
|
||||
const terrainById = {};
|
||||
const terrainByCh = {};
|
||||
terrains.forEach((t, i) => {
|
||||
t.index = i;
|
||||
if (terrainById[t.id]) throw new Error(`duplicate terrain id ${t.id}`);
|
||||
if (terrainByCh[t.ch]) throw new Error(`duplicate terrain ch ${t.ch}`);
|
||||
terrainById[t.id] = t;
|
||||
terrainByCh[t.ch] = t;
|
||||
for (const mt of json.moveTypes) {
|
||||
if (!(mt in t.cost)) throw new Error(`terrain ${t.id} missing cost for ${mt}`);
|
||||
}
|
||||
});
|
||||
|
||||
const unitById = {};
|
||||
for (const u of units) {
|
||||
if (unitById[u.id]) throw new Error(`duplicate unit id ${u.id}`);
|
||||
if (!json.moveTypes.includes(u.moveType)) throw new Error(`unit ${u.id} bad moveType`);
|
||||
unitById[u.id] = u;
|
||||
}
|
||||
|
||||
for (const [table, name] of [[json.damage, 'damage'], [json.damageSecondary, 'damageSecondary']]) {
|
||||
for (const [atk, row] of Object.entries(table)) {
|
||||
if (!unitById[atk]) throw new Error(`${name}: unknown attacker ${atk}`);
|
||||
for (const [def, v] of Object.entries(row)) {
|
||||
if (!unitById[def]) throw new Error(`${name}: unknown defender ${def} (attacker ${atk})`);
|
||||
if (!Number.isFinite(v) || v <= 0) throw new Error(`${name}: bad value ${atk}->${def}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const coById = {};
|
||||
for (const co of json.cos) {
|
||||
if (coById[co.id]) throw new Error(`duplicate co id ${co.id}`);
|
||||
coById[co.id] = co;
|
||||
for (const key of Object.keys(co.d2d ?? {})) {
|
||||
if (!EFFECT_KEYS.has(key)) throw new Error(`co ${co.id}: unknown d2d effect ${key}`);
|
||||
}
|
||||
if (co.power) {
|
||||
for (const key of Object.keys(co.power.effects ?? {})) {
|
||||
if (!EFFECT_KEYS.has(key)) throw new Error(`co ${co.id}: unknown power effect ${key}`);
|
||||
}
|
||||
if (!Number.isFinite(co.power.stars) || co.power.stars < 1) {
|
||||
throw new Error(`co ${co.id}: bad power stars`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
constants,
|
||||
moveTypes: json.moveTypes,
|
||||
terrains,
|
||||
terrainById,
|
||||
terrainByCh,
|
||||
units,
|
||||
unitById,
|
||||
damage: json.damage,
|
||||
damageSecondary: json.damageSecondary,
|
||||
cos: json.cos,
|
||||
coById,
|
||||
};
|
||||
}
|
||||
|
||||
// Primary/secondary base damage lookup. Returns { dmg, weapon } or null when
|
||||
// the attacker cannot hurt the defender at all (given current ammo).
|
||||
export function baseDamage(rules, attacker, defenderType) {
|
||||
const spec = rules.unitById[attacker.type];
|
||||
const primary = rules.damage[attacker.type]?.[defenderType];
|
||||
// ammo cap 0 means the unit's only weapon needs no ammo (infantry/recon MG)
|
||||
const hasAmmo = spec.ammo === 0 || attacker.ammo > 0;
|
||||
if (primary != null && hasAmmo) return { dmg: primary, weapon: spec.ammo === 0 ? 'secondary' : 'primary' };
|
||||
const secondary = rules.damageSecondary[attacker.type]?.[defenderType];
|
||||
if (secondary != null) return { dmg: secondary, weapon: 'secondary' };
|
||||
return null;
|
||||
}
|
||||
|
|
@ -0,0 +1,299 @@
|
|||
// Advance Wars full-screen flows: main menu, campaign mission list, mission
|
||||
// briefings (opponent portrait videos + typewriter text), victory/defeat,
|
||||
// and the War Room skirmish setup. Each builder returns { destroy() }.
|
||||
|
||||
import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js';
|
||||
import { createOpponentPortrait } from '../../ui/Portrait.js';
|
||||
import { enqueue as enqueueSpeech } from '../../ui/SpeechQueue.js';
|
||||
import { FONT, mkText, mkButton } from './AdvanceWarsUI.js';
|
||||
|
||||
const PANEL = 0x141826;
|
||||
const EDGE = 0x3a4260;
|
||||
const D = 20;
|
||||
|
||||
function speakRandom(opponent, mood) {
|
||||
const pool = opponent?.speech?.[mood];
|
||||
if (pool?.length) enqueueSpeech(pool[Math.floor(Math.random() * pool.length)]);
|
||||
}
|
||||
|
||||
function screenBase(scene, title) {
|
||||
const objs = [];
|
||||
objs.push(scene.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x0b0e18, 1).setDepth(D));
|
||||
if (title) {
|
||||
objs.push(mkText(scene, GAME_WIDTH / 2, 90, title, 64, '#ffffff', [0.5, 0.5]).setDepth(D + 1));
|
||||
}
|
||||
return objs;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function mainMenu(scene, { hasSave, onCampaign, onWarRoom, onContinue, onLeave }) {
|
||||
const objs = screenBase(scene);
|
||||
objs.push(mkText(scene, GAME_WIDTH / 2, 230, 'ADVANCE WARS', 96, '#ff8c3a', [0.5, 0.5]).setDepth(D + 1));
|
||||
objs.push(mkText(scene, GAME_WIDTH / 2, 310, 'TURN-BASED TACTICS', 28, '#8f9dc4', [0.5, 0.5]).setDepth(D + 1));
|
||||
|
||||
let y = 460;
|
||||
const add = (label, cb, color) => {
|
||||
objs.push(mkButton(scene, GAME_WIDTH / 2, y, 420, 74, label, cb, { size: 34, color, depth: D + 1 }));
|
||||
y += 96;
|
||||
};
|
||||
if (hasSave) add('CONTINUE', onContinue, 0x2a5030);
|
||||
add('CAMPAIGN', onCampaign, 0x2a3350);
|
||||
add('WAR ROOM', onWarRoom, 0x50352a);
|
||||
add('LEAVE', onLeave, 0x3a3040);
|
||||
return { destroy: () => objs.forEach((o) => o.destroy()) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function campaignScreen(scene, rules, campaign, completed, oppById, { onPlay, onBack }) {
|
||||
const objs = screenBase(scene, 'CAMPAIGN');
|
||||
const missions = campaign.missions;
|
||||
const cols = missions.length > 12 ? 2 : 1;
|
||||
const perCol = Math.ceil(missions.length / cols);
|
||||
const rowH = Math.min(64, Math.floor(780 / perCol));
|
||||
missions.forEach((m, i) => {
|
||||
const col = Math.floor(i / perCol);
|
||||
const cx = cols === 1 ? GAME_WIDTH / 2 : GAME_WIDTH / 2 + (col === 0 ? -400 : 400);
|
||||
const ry = 190 + (i % perCol) * rowH;
|
||||
const unlocked = i <= completed;
|
||||
const done = i < completed;
|
||||
const row = scene.add.rectangle(cx, ry, 700, rowH - 8, unlocked ? 0x1d2438 : 0x121624, 1)
|
||||
.setStrokeStyle(2, unlocked ? EDGE : 0x232838).setDepth(D + 1);
|
||||
objs.push(row);
|
||||
objs.push(mkText(scene, cx - 330, ry, `${i + 1}.`, 26, unlocked ? '#8f9dc4' : '#3d445c').setDepth(D + 1));
|
||||
objs.push(mkText(scene, cx - 270, ry, unlocked ? m.name : '— LOCKED —', 26,
|
||||
done ? '#7dff9a' : unlocked ? '#ffffff' : '#3d445c').setDepth(D + 1));
|
||||
if (unlocked) {
|
||||
const enemyCo = rules.coById[m.enemyCos[0]];
|
||||
objs.push(mkText(scene, cx + 320, ry, `vs ${enemyCo.coName}`, 22, '#ff9d9d', [1, 0.5]).setDepth(D + 1));
|
||||
row.setInteractive({ useHandCursor: true });
|
||||
row.on('pointerover', () => row.setFillStyle(0x2a3350, 1));
|
||||
row.on('pointerout', () => row.setFillStyle(0x1d2438, 1));
|
||||
row.on('pointerdown', () => onPlay(i));
|
||||
}
|
||||
if (done) objs.push(mkText(scene, cx + 340, ry, '✓', 28, '#7dff9a').setDepth(D + 1));
|
||||
});
|
||||
objs.push(mkButton(scene, 140, GAME_HEIGHT - 70, 200, 60, 'BACK', onBack, { size: 26, depth: D + 1 }));
|
||||
return { destroy: () => objs.forEach((o) => o.destroy()) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Briefing: lines of { speaker, mood, text } with portrait + typewriter.
|
||||
|
||||
export function briefingScreen(scene, rules, mission, oppById, { onDone }) {
|
||||
const objs = screenBase(scene);
|
||||
objs.push(mkText(scene, GAME_WIDTH / 2, 110, mission.name.toUpperCase(), 52, '#ff8c3a', [0.5, 0.5]).setDepth(D + 1));
|
||||
|
||||
const panel = scene.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT - 260, 1500, 320, PANEL, 0.97)
|
||||
.setStrokeStyle(3, EDGE).setDepth(D + 1);
|
||||
objs.push(panel);
|
||||
const nameT = mkText(scene, GAME_WIDTH / 2 - 500, GAME_HEIGHT - 380, '', 30, '#ffe14d').setDepth(D + 2);
|
||||
const textT = scene.add.text(GAME_WIDTH / 2 - 500, GAME_HEIGHT - 335, '', {
|
||||
fontFamily: FONT, fontSize: '30px', color: '#ffffff', wordWrap: { width: 1180 }, lineSpacing: 8,
|
||||
}).setDepth(D + 2);
|
||||
objs.push(nameT, textT);
|
||||
const hint = mkText(scene, GAME_WIDTH / 2 + 690, GAME_HEIGHT - 130, 'TAP ▸', 22, '#8f9dc4', [1, 0.5]).setDepth(D + 2);
|
||||
objs.push(hint);
|
||||
|
||||
let portrait = null;
|
||||
let li = 0;
|
||||
let charTimer = null;
|
||||
let full = '';
|
||||
let typing = false;
|
||||
let destroyed = false;
|
||||
|
||||
const showLine = () => {
|
||||
if (destroyed) return;
|
||||
const line = mission.briefing[li];
|
||||
if (!line) { finish(); return; }
|
||||
const opp = oppById[line.speaker];
|
||||
portrait?.destroy?.();
|
||||
portrait = createOpponentPortrait(scene, opp, GAME_WIDTH / 2 - 620, GAME_HEIGHT - 260, 110, D + 2, { playIntro: false });
|
||||
if (line.mood && line.mood !== 'idle') portrait.playEmotion?.(line.mood);
|
||||
const coEntry = rules.cos.find((c) => c.opponentId === line.speaker);
|
||||
nameT.setText((coEntry?.coName ?? opp?.name ?? line.speaker).toUpperCase());
|
||||
full = line.text;
|
||||
textT.setText('');
|
||||
typing = true;
|
||||
let n = 0;
|
||||
charTimer?.remove();
|
||||
charTimer = scene.time.addEvent({
|
||||
delay: 14, repeat: full.length - 1, callback: () => {
|
||||
n += 1;
|
||||
textT.setText(full.slice(0, n));
|
||||
if (n >= full.length) typing = false;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const advance = () => {
|
||||
if (destroyed) return;
|
||||
if (typing) {
|
||||
charTimer?.remove();
|
||||
textT.setText(full);
|
||||
typing = false;
|
||||
return;
|
||||
}
|
||||
li += 1;
|
||||
if (li >= mission.briefing.length) finish();
|
||||
else showLine();
|
||||
};
|
||||
|
||||
const api = {
|
||||
destroy: () => {
|
||||
destroyed = true;
|
||||
charTimer?.remove();
|
||||
portrait?.destroy?.();
|
||||
objs.forEach((o) => o.destroy());
|
||||
},
|
||||
};
|
||||
|
||||
const finish = () => {
|
||||
if (destroyed) return;
|
||||
api.destroy();
|
||||
onDone();
|
||||
};
|
||||
|
||||
const clickZone = scene.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.001)
|
||||
.setDepth(D + 1).setInteractive();
|
||||
clickZone.on('pointerdown', advance);
|
||||
objs.push(clickZone);
|
||||
objs.push(mkButton(scene, GAME_WIDTH - 150, 100, 180, 56, 'SKIP', finish, { size: 24, depth: D + 3 }));
|
||||
|
||||
if (mission.briefing?.length) showLine();
|
||||
else scene.time.delayedCall(0, finish);
|
||||
|
||||
return api;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function resultScreen(scene, rules, oppById, { won, mission, days, rank, onContinue, onRetry, onMenu }) {
|
||||
const objs = screenBase(scene);
|
||||
objs.push(mkText(scene, GAME_WIDTH / 2, 180, won ? 'VICTORY!' : 'DEFEAT...', 96,
|
||||
won ? '#7dff9a' : '#ff5a5a', [0.5, 0.5]).setDepth(D + 1));
|
||||
|
||||
const line = won ? mission.victoryLine : mission.defeatLine;
|
||||
const speakerId = line?.speaker ?? (won ? mission.enemyCos?.[0] && rules.coById[mission.enemyCos[0]].opponentId : 'ethel');
|
||||
const opp = oppById[speakerId];
|
||||
let portrait = null;
|
||||
if (opp) {
|
||||
portrait = createOpponentPortrait(scene, opp, GAME_WIDTH / 2, 420, 130, D + 2, { playIntro: false });
|
||||
portrait.playEmotion?.('upset');
|
||||
speakRandom(opp, 'upset');
|
||||
}
|
||||
if (line) {
|
||||
objs.push(scene.add.text(GAME_WIDTH / 2, 640, `"${line.text}"`, {
|
||||
fontFamily: FONT, fontSize: '28px', color: '#d8ddf0', wordWrap: { width: 1200 }, align: 'center',
|
||||
}).setOrigin(0.5, 0).setDepth(D + 1));
|
||||
}
|
||||
if (won) {
|
||||
objs.push(mkText(scene, GAME_WIDTH / 2, 800, `Cleared in ${days} day${days === 1 ? '' : 's'} RANK ${rank}`,
|
||||
34, '#ffe14d', [0.5, 0.5]).setDepth(D + 1));
|
||||
}
|
||||
let y = 900;
|
||||
if (won) {
|
||||
objs.push(mkButton(scene, GAME_WIDTH / 2, y, 380, 70, 'CONTINUE', onContinue, { size: 30, color: 0x2a5030, depth: D + 1 }));
|
||||
} else {
|
||||
objs.push(mkButton(scene, GAME_WIDTH / 2 - 210, y, 360, 70, 'RETRY', onRetry, { size: 30, color: 0x2a3350, depth: D + 1 }));
|
||||
objs.push(mkButton(scene, GAME_WIDTH / 2 + 210, y, 360, 70, 'MAIN MENU', onMenu, { size: 30, depth: D + 1 }));
|
||||
}
|
||||
return {
|
||||
destroy: () => { portrait?.destroy?.(); objs.forEach((o) => o.destroy()); },
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// War Room setup: unlocked maps, enemy CO, player CO, fog, difficulty.
|
||||
|
||||
export function warRoomScreen(scene, rules, campaign, unlockedCount, oppById, { onStart, onBack }) {
|
||||
const objs = screenBase(scene, 'WAR ROOM');
|
||||
const maps = campaign.missions.slice(0, Math.max(1, unlockedCount + 1));
|
||||
const playableCos = rules.cos.filter((c) => !c.advisorOnly);
|
||||
|
||||
const cfg = {
|
||||
mapIdx: Math.max(0, maps.length - 1),
|
||||
playerCo: 'andy',
|
||||
enemyCo: 'olaf',
|
||||
fog: false,
|
||||
skill: 3,
|
||||
};
|
||||
|
||||
const rows = [];
|
||||
const mkPicker = (y, label, getText, onPrev, onNext) => {
|
||||
objs.push(mkText(scene, GAME_WIDTH / 2 - 420, y, label, 28, '#8f9dc4').setDepth(D + 1));
|
||||
const val = mkText(scene, GAME_WIDTH / 2 + 130, y, '', 30, '#ffffff', [0.5, 0.5]).setDepth(D + 1);
|
||||
objs.push(val);
|
||||
objs.push(mkButton(scene, GAME_WIDTH / 2 - 130, y, 64, 54, '<', () => { onPrev(); refresh(); }, { size: 28, depth: D + 1 }));
|
||||
objs.push(mkButton(scene, GAME_WIDTH / 2 + 390, y, 64, 54, '>', () => { onNext(); refresh(); }, { size: 28, depth: D + 1 }));
|
||||
rows.push({ val, getText });
|
||||
};
|
||||
|
||||
const cycle = (list, cur, dir) => list[(list.indexOf(cur) + dir + list.length) % list.length];
|
||||
|
||||
mkPicker(260, 'MAP', () => `${maps[cfg.mapIdx].name}`,
|
||||
() => { cfg.mapIdx = (cfg.mapIdx + maps.length - 1) % maps.length; },
|
||||
() => { cfg.mapIdx = (cfg.mapIdx + 1) % maps.length; });
|
||||
mkPicker(360, 'YOUR CO', () => {
|
||||
const co = rules.coById[cfg.playerCo];
|
||||
return `${co.coName}`;
|
||||
},
|
||||
() => { cfg.playerCo = cycle(playableCos.map((c) => c.id), cfg.playerCo, -1); },
|
||||
() => { cfg.playerCo = cycle(playableCos.map((c) => c.id), cfg.playerCo, 1); });
|
||||
mkPicker(460, 'ENEMY CO', () => {
|
||||
const co = rules.coById[cfg.enemyCo];
|
||||
return `${co.coName}`;
|
||||
},
|
||||
() => { cfg.enemyCo = cycle(playableCos.map((c) => c.id), cfg.enemyCo, -1); },
|
||||
() => { cfg.enemyCo = cycle(playableCos.map((c) => c.id), cfg.enemyCo, 1); });
|
||||
mkPicker(560, 'FOG OF WAR', () => (cfg.fog ? 'ON' : 'OFF'),
|
||||
() => { cfg.fog = !cfg.fog; }, () => { cfg.fog = !cfg.fog; });
|
||||
mkPicker(660, 'AI LEVEL', () => '★'.repeat(cfg.skill) + '·'.repeat(5 - cfg.skill),
|
||||
() => { cfg.skill = Math.max(1, cfg.skill - 1); },
|
||||
() => { cfg.skill = Math.min(5, cfg.skill + 1); });
|
||||
|
||||
const coBlurb = mkText(scene, GAME_WIDTH / 2, 750, '', 20, '#9db4e8', [0.5, 0]).setDepth(D + 1);
|
||||
coBlurb.setWordWrapWidth(900);
|
||||
objs.push(coBlurb);
|
||||
|
||||
const refresh = () => {
|
||||
for (const r of rows) r.val.setText(r.getText());
|
||||
const pc = rules.coById[cfg.playerCo];
|
||||
const ec = rules.coById[cfg.enemyCo];
|
||||
coBlurb.setText(`${pc.coName}: ${pc.tagline}\n${ec.coName}: ${ec.tagline}`);
|
||||
};
|
||||
refresh();
|
||||
|
||||
objs.push(mkButton(scene, GAME_WIDTH / 2, 900, 420, 76, 'TO BATTLE!', () => onStart({ ...cfg, mission: maps[cfg.mapIdx] }),
|
||||
{ size: 32, color: 0x8a4a10, depth: D + 1 }));
|
||||
objs.push(mkButton(scene, 140, GAME_HEIGHT - 70, 200, 60, 'BACK', onBack, { size: 26, depth: D + 1 }));
|
||||
return { destroy: () => objs.forEach((o) => o.destroy()) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CO power cut-in banner
|
||||
|
||||
export function powerCutIn(scene, rules, oppById, co, army, onDone) {
|
||||
const coDef = rules.coById[co];
|
||||
const opp = oppById[coDef.opponentId];
|
||||
const objs = [];
|
||||
const bar = scene.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, 300, 0x000000, 0.78).setDepth(54);
|
||||
objs.push(bar);
|
||||
const name = mkText(scene, GAME_WIDTH / 2 + 60, GAME_HEIGHT / 2 - 40, coDef.power?.name?.toUpperCase() ?? 'CO POWER',
|
||||
64, army === 0 ? '#7dff9a' : '#ff8c3a', [0.5, 0.5]).setDepth(55).setAlpha(0);
|
||||
const blurb = mkText(scene, GAME_WIDTH / 2 + 60, GAME_HEIGHT / 2 + 30, coDef.power?.blurb ?? '', 26, '#d8ddf0', [0.5, 0.5]).setDepth(55).setAlpha(0);
|
||||
objs.push(name, blurb);
|
||||
let portrait = null;
|
||||
if (opp) {
|
||||
portrait = createOpponentPortrait(scene, opp, GAME_WIDTH / 2 - 480, GAME_HEIGHT / 2, 110, 55, { playIntro: false });
|
||||
portrait.playEmotion?.('happy');
|
||||
speakRandom(opp, 'happy');
|
||||
}
|
||||
scene.tweens.add({ targets: [name, blurb], alpha: 1, duration: 250 });
|
||||
scene.time.delayedCall(2100, () => {
|
||||
portrait?.destroy?.();
|
||||
objs.forEach((o) => o.destroy());
|
||||
onDone?.();
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,304 @@
|
|||
// Advance Wars HUD + menus: top strip (funds/day/objective), CO panel with
|
||||
// power meter, contextual action menu, damage preview, production menu.
|
||||
// All text in the arcade pixel font.
|
||||
|
||||
import * as Phaser from 'phaser';
|
||||
import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js';
|
||||
import * as Logic from './AdvanceWarsLogic.js';
|
||||
import { UI_FRAMES, armyColorInt } from './AdvanceWarsMapView.js';
|
||||
import { createOpponentPortrait } from '../../ui/Portrait.js';
|
||||
|
||||
export const FONT = 'm6x11, "Julius Sans One"';
|
||||
const PANEL = 0x141826;
|
||||
const PANEL_EDGE = 0x3a4260;
|
||||
|
||||
export function mkText(scene, x, y, text, size, color = '#ffffff', origin = [0, 0.5]) {
|
||||
return scene.add.text(x, y, text, {
|
||||
fontFamily: FONT, fontSize: `${size}px`, color,
|
||||
}).setOrigin(origin[0], origin[1]);
|
||||
}
|
||||
|
||||
export function mkButton(scene, x, y, w, h, label, onClick, { size = 24, color = 0x2a3350, textColor = '#ffffff', depth = 40 } = {}) {
|
||||
const c = scene.add.container(x, y).setDepth(depth);
|
||||
const bg = scene.add.rectangle(0, 0, w, h, color, 1).setStrokeStyle(2, PANEL_EDGE);
|
||||
const txt = mkText(scene, 0, 0, label, size, textColor, [0.5, 0.5]);
|
||||
c.add([bg, txt]);
|
||||
c.setSize(w, h);
|
||||
c.setInteractive({ useHandCursor: true });
|
||||
c.on('pointerover', () => bg.setFillStyle(lighten(color), 1));
|
||||
c.on('pointerout', () => bg.setFillStyle(color, 1));
|
||||
c.on('pointerdown', (p, lx, ly, ev) => { ev?.stopPropagation(); onClick(); });
|
||||
c.bg = bg; c.label = txt;
|
||||
return c;
|
||||
}
|
||||
|
||||
function lighten(color) {
|
||||
const r = Math.min(255, ((color >> 16) & 0xff) + 30);
|
||||
const g = Math.min(255, ((color >> 8) & 0xff) + 30);
|
||||
const b = Math.min(255, (color & 0xff) + 30);
|
||||
return (r << 16) | (g << 8) | b;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class AdvanceWarsHUD {
|
||||
constructor(scene, rules, { onEndTurn, onMenu, onPower }) {
|
||||
this.scene = scene;
|
||||
this.rules = rules;
|
||||
const d = 30;
|
||||
|
||||
this.bar = scene.add.rectangle(GAME_WIDTH / 2, 34, GAME_WIDTH, 68, PANEL, 0.96)
|
||||
.setStrokeStyle(2, PANEL_EDGE).setDepth(d);
|
||||
this.dayText = mkText(scene, 28, 34, '', 30).setDepth(d);
|
||||
this.fundsText = mkText(scene, 190, 34, '', 30, '#ffe14d').setDepth(d);
|
||||
this.objectiveText = mkText(scene, 470, 34, '', 22, '#9db4e8').setDepth(d);
|
||||
this.turnText = mkText(scene, GAME_WIDTH / 2 + 210, 34, '', 26, '#ffffff', [0.5, 0.5]).setDepth(d);
|
||||
|
||||
this.endTurnBtn = mkButton(scene, GAME_WIDTH - 330, 34, 170, 46, 'END TURN', onEndTurn, { size: 24 });
|
||||
this.menuBtn = mkButton(scene, GAME_WIDTH - 120, 34, 130, 46, 'MENU', onMenu, { size: 24 });
|
||||
|
||||
// CO panel (right column)
|
||||
const px = GAME_WIDTH - 148;
|
||||
this.coPanel = scene.add.rectangle(px, 620, 268, 1080 - 90, PANEL, 0.92)
|
||||
.setStrokeStyle(2, PANEL_EDGE).setDepth(d - 1);
|
||||
this.coName = mkText(scene, px, 210, '', 28, '#ffffff', [0.5, 0.5]).setDepth(d);
|
||||
this.coTag = mkText(scene, px, 244, '', 16, '#8f9dc4', [0.5, 0]).setDepth(d);
|
||||
this.coTag.setWordWrapWidth(240);
|
||||
this.powerLabel = mkText(scene, px, 330, '', 20, '#ffd94d', [0.5, 0.5]).setDepth(d);
|
||||
this.stars = [];
|
||||
this.powerBtn = mkButton(scene, px, 395, 220, 52, 'POWER!', onPower, { size: 26, color: 0x8a4a10 });
|
||||
this.powerBtn.setVisible(false);
|
||||
|
||||
this.enemyName = mkText(scene, px, 640, '', 24, '#ff9d9d', [0.5, 0.5]).setDepth(d);
|
||||
this.enemyPowerLabel = mkText(scene, px, 810, '', 18, '#c99', [0.5, 0.5]).setDepth(d);
|
||||
this.enemyStars = [];
|
||||
|
||||
this.portraits = [];
|
||||
}
|
||||
|
||||
attachPortraits(playerOpp, enemyOpp) {
|
||||
const px = GAME_WIDTH - 148;
|
||||
for (const p of this.portraits) p?.destroy?.();
|
||||
this.portraits = [
|
||||
createOpponentPortrait(this.scene, playerOpp, px, 140, 56, 31, { playIntro: false }),
|
||||
enemyOpp ? createOpponentPortrait(this.scene, enemyOpp, px, 720, 56, 31, { playIntro: false }) : null,
|
||||
];
|
||||
}
|
||||
|
||||
buildStars(rules, state) {
|
||||
for (const s of [...this.stars, ...this.enemyStars]) s.destroy();
|
||||
this.stars = []; this.enemyStars = [];
|
||||
const px = GAME_WIDTH - 148;
|
||||
const mk = (co, y, arr) => {
|
||||
const n = rules.coById[co].power?.stars ?? 0;
|
||||
const total = n * 24;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const img = this.scene.add.image(px - total / 2 + 12 + i * 24, y, this.uiKey ?? 'advancewars-ui-proc', UI_FRAMES.star)
|
||||
.setDisplaySize(22, 22).setDepth(31);
|
||||
arr.push(img);
|
||||
}
|
||||
};
|
||||
mk(state.armies[0].co, 292, this.stars);
|
||||
if (state.armies[1]) mk(state.armies[1].co, 775, this.enemyStars);
|
||||
}
|
||||
|
||||
refresh(state, humanArmy = 0) {
|
||||
const rules = this.rules;
|
||||
const a = state.armies[humanArmy];
|
||||
const co = rules.coById[a.co];
|
||||
this.dayText.setText(`DAY ${state.day}`);
|
||||
this.fundsText.setText(`G ${a.funds.toLocaleString()}`);
|
||||
this.turnText.setText(state.turn === humanArmy ? 'YOUR TURN' : `${rules.coById[state.armies[state.turn].co].coName}'S TURN`);
|
||||
this.turnText.setColor(state.turn === humanArmy ? '#7dff9a' : '#ff9d9d');
|
||||
this.coName.setText(co.coName);
|
||||
this.coTag.setText(co.tagline ?? '');
|
||||
const powered = a.powerActive;
|
||||
this.powerLabel.setText(powered ? `${co.power?.name ?? ''} ACTIVE!` : (co.power?.name ?? ''));
|
||||
|
||||
const setStars = (army, arr) => {
|
||||
const st = state.armies[army];
|
||||
const def = rules.coById[st.co];
|
||||
if (!def.power) return;
|
||||
const per = rules.constants.starCharge;
|
||||
arr.forEach((img, i) => {
|
||||
const filled = st.charge >= (i + 1) * per;
|
||||
img.setAlpha(filled ? 1 : 0.25);
|
||||
});
|
||||
};
|
||||
setStars(humanArmy, this.stars);
|
||||
if (state.armies[1]) {
|
||||
const eco = rules.coById[state.armies[1].co];
|
||||
this.enemyName.setText(eco.coName);
|
||||
this.enemyPowerLabel.setText(eco.power?.name ?? '');
|
||||
setStars(1, this.enemyStars);
|
||||
}
|
||||
this.powerBtn.setVisible(state.turn === humanArmy && Logic.powerReady(rules, state, humanArmy));
|
||||
const myTurn = state.turn === humanArmy && !state.result;
|
||||
this.endTurnBtn.setAlpha(myTurn ? 1 : 0.4);
|
||||
}
|
||||
|
||||
setObjective(text) { this.objectiveText.setText(text); }
|
||||
|
||||
destroy() {
|
||||
for (const p of this.portraits) p?.destroy?.();
|
||||
for (const s of [...this.stars, ...this.enemyStars]) s.destroy();
|
||||
for (const o of [this.bar, this.dayText, this.fundsText, this.objectiveText, this.turnText,
|
||||
this.endTurnBtn, this.menuBtn, this.coPanel, this.coName, this.coTag, this.powerLabel,
|
||||
this.powerBtn, this.enemyName, this.enemyPowerLabel]) o?.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Contextual action menu — big touch-friendly vertical buttons.
|
||||
|
||||
export class ActionMenu {
|
||||
constructor(scene) {
|
||||
this.scene = scene;
|
||||
this.items = [];
|
||||
}
|
||||
|
||||
open(x, y, options) {
|
||||
this.close();
|
||||
const w = 210, h = 52, gap = 6;
|
||||
const total = options.length * (h + gap);
|
||||
let top = Math.min(Math.max(y - total / 2, 90), GAME_HEIGHT - total - 20);
|
||||
const left = Math.min(x + 30, GAME_WIDTH - 300 - w);
|
||||
options.forEach((opt, i) => {
|
||||
const btn = mkButton(this.scene, left + w / 2, top + i * (h + gap) + h / 2, w, h,
|
||||
opt.label, () => { this.close(); opt.cb(); },
|
||||
{ size: 24, color: opt.danger ? 0x6b2a2a : 0x2a3350, depth: 45 });
|
||||
if (opt.disabled) { btn.setAlpha(0.45); btn.disableInteractive(); }
|
||||
this.items.push(btn);
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
for (const b of this.items) b.destroy();
|
||||
this.items = [];
|
||||
}
|
||||
|
||||
get isOpen() { return this.items.length > 0; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Damage preview panel
|
||||
|
||||
export class DamagePreview {
|
||||
constructor(scene) {
|
||||
this.scene = scene;
|
||||
this.objs = [];
|
||||
}
|
||||
|
||||
show(x, y, dealPct, counterPct, onConfirm, onCancel) {
|
||||
this.hide();
|
||||
const w = 300, h = 170;
|
||||
const px = Math.min(Math.max(x, w / 2 + 20), GAME_WIDTH - 300 - w / 2);
|
||||
const py = Math.min(Math.max(y - 140, 100), GAME_HEIGHT - h - 20);
|
||||
const panel = this.scene.add.rectangle(px, py + h / 2, w, h, PANEL, 0.97)
|
||||
.setStrokeStyle(2, PANEL_EDGE).setDepth(46);
|
||||
const t1 = mkText(this.scene, px, py + 34, `DAMAGE ${dealPct}%`, 28, '#7dff9a', [0.5, 0.5]).setDepth(46);
|
||||
const t2 = mkText(this.scene, px, py + 70, counterPct == null ? 'NO COUNTER' : `COUNTER ${counterPct}%`,
|
||||
22, counterPct == null ? '#8f9dc4' : '#ff9d9d', [0.5, 0.5]).setDepth(46);
|
||||
const ok = mkButton(this.scene, px - 70, py + 126, 120, 48, 'FIRE!', () => { this.hide(); onConfirm(); },
|
||||
{ size: 24, color: 0x8a2a2a, depth: 46 });
|
||||
const no = mkButton(this.scene, px + 70, py + 126, 120, 48, 'BACK', () => { this.hide(); onCancel(); },
|
||||
{ size: 24, depth: 46 });
|
||||
this.objs = [panel, t1, t2, ok, no];
|
||||
}
|
||||
|
||||
hide() { for (const o of this.objs) o.destroy(); this.objs = []; }
|
||||
get isOpen() { return this.objs.length > 0; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Production menu (modal)
|
||||
|
||||
export class ProductionMenu {
|
||||
constructor(scene, rules, unitsKey) {
|
||||
this.scene = scene;
|
||||
this.rules = rules;
|
||||
this.unitsKey = unitsKey;
|
||||
this.objs = [];
|
||||
}
|
||||
|
||||
open(options, funds, onPick, onClose) {
|
||||
this.close();
|
||||
const rows = options.length;
|
||||
const w = 460, rowH = 58, h = rows * rowH + 110;
|
||||
const cx = GAME_WIDTH / 2 - 130, cy = GAME_HEIGHT / 2;
|
||||
const dim = this.scene.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.45)
|
||||
.setDepth(44).setInteractive();
|
||||
dim.on('pointerdown', () => { this.close(); onClose?.(); });
|
||||
const panel = this.scene.add.rectangle(cx, cy, w, h, PANEL, 0.97)
|
||||
.setStrokeStyle(2, PANEL_EDGE).setDepth(45);
|
||||
const title = mkText(this.scene, cx, cy - h / 2 + 34, 'BUILD UNIT', 30, '#ffffff', [0.5, 0.5]).setDepth(45);
|
||||
const fundsT = mkText(this.scene, cx, cy - h / 2 + 66, `Funds: G ${funds.toLocaleString()}`, 20, '#ffe14d', [0.5, 0.5]).setDepth(45);
|
||||
this.objs = [dim, panel, title, fundsT];
|
||||
|
||||
options.forEach((opt, i) => {
|
||||
const spec = this.rules.unitById[opt.type];
|
||||
const ry = cy - h / 2 + 100 + i * rowH + rowH / 2;
|
||||
const row = this.scene.add.rectangle(cx, ry, w - 30, rowH - 8, opt.affordable ? 0x223050 : 0x1a1f30, 1)
|
||||
.setStrokeStyle(1, PANEL_EDGE).setDepth(45);
|
||||
const icon = this.scene.add.image(cx - w / 2 + 50, ry + 12, this.unitsKey, spec.frame)
|
||||
.setOrigin(0.5, 0.78).setScale(0.8).setDepth(45);
|
||||
const name = mkText(this.scene, cx - w / 2 + 95, ry, spec.name, 24,
|
||||
opt.affordable ? '#ffffff' : '#666e88').setDepth(45);
|
||||
const cost = mkText(this.scene, cx + w / 2 - 40, ry, `G ${opt.cost.toLocaleString()}`, 22,
|
||||
opt.affordable ? '#ffe14d' : '#666e88', [1, 0.5]).setDepth(45);
|
||||
this.objs.push(row, icon, name, cost);
|
||||
if (opt.affordable) {
|
||||
row.setInteractive({ useHandCursor: true });
|
||||
row.on('pointerover', () => row.setFillStyle(0x2e4070, 1));
|
||||
row.on('pointerout', () => row.setFillStyle(0x223050, 1));
|
||||
row.on('pointerdown', (p, lx, ly, ev) => { ev?.stopPropagation(); this.close(); onPick(opt.type); });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
close() { for (const o of this.objs) o.destroy(); this.objs = []; }
|
||||
get isOpen() { return this.objs.length > 0; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tile info chip (bottom-left): terrain name, stars, capture progress.
|
||||
|
||||
export class TileInfo {
|
||||
constructor(scene, rules) {
|
||||
this.scene = scene;
|
||||
this.rules = rules;
|
||||
const d = 30;
|
||||
this.panel = scene.add.rectangle(150, GAME_HEIGHT - 60, 280, 96, PANEL, 0.92)
|
||||
.setStrokeStyle(2, PANEL_EDGE).setDepth(d);
|
||||
this.name = mkText(scene, 36, GAME_HEIGHT - 84, '', 24).setDepth(d);
|
||||
this.detail = mkText(scene, 36, GAME_HEIGHT - 50, '', 18, '#9db4e8').setDepth(d);
|
||||
this.unitLine = mkText(scene, 36, GAME_HEIGHT - 26, '', 18, '#ffe14d').setDepth(d);
|
||||
}
|
||||
|
||||
show(state, x, y, spottedUnit) {
|
||||
const t = Logic.terrainAt(this.rules, state, x, y);
|
||||
const k = Logic.tileKey(state, x, y);
|
||||
let name = t.name;
|
||||
if (t.property) {
|
||||
const owner = state.owner[k];
|
||||
name += owner < 0 ? ' (Neutral)' : ` (${this.rules.constants.armyNames[owner] ?? 'Army'})`;
|
||||
}
|
||||
this.name.setText(name);
|
||||
let detail = `Defense ${'★'.repeat(t.stars) || '—'}`;
|
||||
if (t.property && state.captureHp[k] < this.rules.constants.captureGoal) {
|
||||
detail += ` Capture ${state.captureHp[k]}/${this.rules.constants.captureGoal}`;
|
||||
}
|
||||
this.detail.setText(detail);
|
||||
if (spottedUnit) {
|
||||
const spec = this.rules.unitById[spottedUnit.type];
|
||||
const bits = [`${spec.name} ${Logic.hpDisplay(spottedUnit)}/10`];
|
||||
bits.push(`F${spottedUnit.fuel}`);
|
||||
if (spec.ammo > 0) bits.push(`A${spottedUnit.ammo}`);
|
||||
this.unitLine.setText(bits.join(' '));
|
||||
} else {
|
||||
this.unitLine.setText('');
|
||||
}
|
||||
}
|
||||
|
||||
destroy() { for (const o of [this.panel, this.name, this.detail, this.unitLine]) o.destroy(); }
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
# Advance Wars — spritesheet spec
|
||||
|
||||
Art is split into **four independent sheets**, one per category, so each can
|
||||
be painted and dropped in separately. Until a sheet is painted, the game
|
||||
renders a procedural stand-in with the exact same frame map — everything is
|
||||
fully playable art-free, and you can drop in one sheet at a time.
|
||||
|
||||
**Rivers and roads need NO art**: they are drawn in code (Graphics) from
|
||||
tile connection masks, so there are no variant frames to paint. Shoreline
|
||||
foam where sea meets land is also drawn in code.
|
||||
|
||||
## Drop-in procedure (no code changes)
|
||||
|
||||
1. Paint a sheet per the specs below.
|
||||
2. Save it under `assets/images/advancewars/` (suggested names below).
|
||||
3. In `data/advancewars-artwork.json`, set that sheet's `path`.
|
||||
4. Reload. Sheets left `path: null` keep their procedural stand-in.
|
||||
|
||||
Keep source PSDs next to the PNGs. **Frames are append-only — never
|
||||
renumber.** Frame indices are pinned in `data/advancewars-rules.json`
|
||||
(`terrains[].frame`, `units[].frame`) and in `UI_FRAMES` in
|
||||
`AdvanceWarsMapView.js`.
|
||||
|
||||
## Tinting rules (important!)
|
||||
|
||||
The **building sheet** and the **unit sheet** are painted **neutral and
|
||||
tintable**: light gray / near-white bodies (#e0e0e0-ish) with dark outlines.
|
||||
The engine applies each army's color with a multiply tint at runtime
|
||||
(orange / blue / yellow / green + gray for neutral buildings), and **flips
|
||||
unit art horizontally** for enemy armies — so paint every unit **facing
|
||||
RIGHT**. Avoid saturated colors on anything tinted; details that must stay
|
||||
colored (glass, smoke, tires) should be dark.
|
||||
|
||||
The terrain and UI sheets are painted in full color — no tinting.
|
||||
|
||||
---
|
||||
|
||||
## 1. Terrain sheet — `advancewars-terrain.png`
|
||||
|
||||
**48×48 cells, 8 columns × 1 row = 384×48 px.** Full color, opaque tiles.
|
||||
|
||||
| frame | content |
|
||||
|-------|---------|
|
||||
| 0 | Plain (grass) |
|
||||
| 1 | Wood (trees — hides units in fog) |
|
||||
| 2 | Mountain |
|
||||
| 3 | Sea |
|
||||
| 4 | Reef (rocky shallows on sea) |
|
||||
| 5 | Shoal (beach/sand transition) |
|
||||
| 6 | Bridge, horizontal (paint the water background into the tile) |
|
||||
| 7 | Bridge, vertical (ditto) |
|
||||
|
||||
Everything is a flat 48×48 tile — no headroom on this sheet. Rivers, roads
|
||||
and coastlines are code-drawn; do not paint them.
|
||||
|
||||
## 2. Building sheet — `advancewars-buildings.png`
|
||||
|
||||
**48×64 cells, 5 frames in 1 row (suggest 6 columns = 288×64 px, last cell
|
||||
spare).** Bottom 48×48 is the tile footprint; the top 16 px is headroom for
|
||||
roofs and flags. Bottom-aligned, transparent background (the ground tile
|
||||
shows through), **neutral + tintable**.
|
||||
|
||||
| frame | content |
|
||||
|-------|---------|
|
||||
| 0 | City |
|
||||
| 1 | Base (land factory) |
|
||||
| 2 | Airport |
|
||||
| 3 | Port |
|
||||
| 4 | HQ (make it grand — flag, use the headroom) |
|
||||
|
||||
## 3. Unit sheet — `advancewars-units.png`
|
||||
|
||||
**48×64 cells, 12 columns × 3 rows = 576×192 px.** Bottom-aligned in the
|
||||
48×48 footprint with 16 px headroom for heads/rotors/masts. Transparent
|
||||
background, **neutral + tintable, facing RIGHT**.
|
||||
|
||||
Each unit gets exactly **two frames**: `[idle A, idle B]` — a subtle
|
||||
2-frame cycle (bob, tread shift, rotor spin). Frames are `base` and
|
||||
`base+1`, laid out row-major:
|
||||
|
||||
| base | unit | | base | unit |
|
||||
|------|------|-|------|------|
|
||||
| 0 | Infantry | | 18 | Missiles |
|
||||
| 2 | Mech | | 20 | Fighter |
|
||||
| 4 | Recon | | 22 | Bomber |
|
||||
| 6 | Tank | | 24 | B-Copter |
|
||||
| 8 | Md Tank | | 26 | T-Copter |
|
||||
| 10 | APC | | 28 | Battleship |
|
||||
| 12 | Artillery | | 30 | Cruiser |
|
||||
| 14 | Rockets | | 32 | Lander |
|
||||
| 16 | Anti-Air | | 34 | Sub |
|
||||
|
||||
## 4. UI sheet — `advancewars-ui.png`
|
||||
|
||||
**48×48 cells, 7 frames in 1 row (suggest 8 columns = 384×48 px, last cell
|
||||
spare).** Full color, transparent background.
|
||||
|
||||
| frame | content |
|
||||
|-------|---------|
|
||||
| 0 | tile cursor (corner brackets, white) |
|
||||
| 1 | explosion, small |
|
||||
| 2 | explosion, big |
|
||||
| 3 | capture flag icon |
|
||||
| 4 | low-fuel icon (lightning bolt) |
|
||||
| 5 | low-ammo icon (bullet) |
|
||||
| 6 | CO power star |
|
||||
|
||||
Icons 3–6 render at roughly 40% tile size as unit badges; keep them bold
|
||||
and readable when small.
|
||||
|
||||
## Game icon
|
||||
|
||||
Also paint **frame 86 of `assets/images/game-icons.png`** (44×44, row 5
|
||||
col 11 of that 15-per-row sheet). Suggested motif: a small orange tank on a
|
||||
green tile, AW box-art style.
|
||||
|
|
@ -2,6 +2,7 @@ import * as Phaser from 'phaser';
|
|||
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
||||
import { Button } from '../../ui/Button.js';
|
||||
import { playSound, SFX } from '../../ui/Sounds.js';
|
||||
import { getGameSoundtrack } from '../../services/soundtrack.js';
|
||||
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
||||
import { api } from '../../services/api.js';
|
||||
import {
|
||||
|
|
@ -53,7 +54,10 @@ export default class SpireClimbGame extends Phaser.Scene {
|
|||
}
|
||||
|
||||
create() {
|
||||
try { const m = this.cache.json.get('music'); if (m?.tracks) new MusicPlayer(this, m.tracks); } catch (_) {}
|
||||
try {
|
||||
const { tracks, volume } = getGameSoundtrack(this);
|
||||
if (tracks.length) new MusicPlayer(this, tracks, volume);
|
||||
} catch (_) {}
|
||||
|
||||
// art config
|
||||
this.art = this.cache.json.get('spireclimb-artwork') || {};
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import * as Phaser from 'phaser';
|
|||
import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js';
|
||||
import { Button } from '../../ui/Button.js';
|
||||
import { playSound, playForceMove, SFX } from '../../ui/Sounds.js';
|
||||
import { getGameSoundtrack } from '../../services/soundtrack.js';
|
||||
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
||||
import { api } from '../../services/api.js';
|
||||
import { createPlayerPortrait, createOpponentPortrait } from '../../ui/Portrait.js';
|
||||
|
|
@ -179,7 +180,10 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
}
|
||||
|
||||
create() {
|
||||
try { const m = this.cache.json.get('music'); if (m?.tracks) new MusicPlayer(this, m.tracks); } catch (_) { /* optional */ }
|
||||
try {
|
||||
const { tracks, volume } = getGameSoundtrack(this);
|
||||
if (tracks.length) new MusicPlayer(this, tracks, volume);
|
||||
} catch (_) {}
|
||||
this.art = this.cache.json.get('swdbg-artwork') || {};
|
||||
loadCardData(this.cache.json.get('swdbg-cards'));
|
||||
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@ import CivilizationGame from './games/civilization/CivilizationGame.js';
|
|||
import TempestGame from './games/tempest/TempestGame.js';
|
||||
import SuperKartGame from './games/superkart/SuperKartGame.js';
|
||||
import SuperKartEditor from './games/superkart/SuperKartEditor.js';
|
||||
import AdvanceWarsGame from './games/advancewars/AdvanceWarsGame.js';
|
||||
|
||||
const config = {
|
||||
type: Phaser.AUTO,
|
||||
|
|
@ -203,6 +204,7 @@ const config = {
|
|||
TempestGame,
|
||||
SuperKartGame,
|
||||
SuperKartEditor,
|
||||
AdvanceWarsGame,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,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', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame' };
|
||||
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', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame' };
|
||||
if (slugDispatch[this.game.slug]) {
|
||||
const sceneKey = slugDispatch[this.game.slug];
|
||||
const startData = {
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ export default class PreloadScene extends Phaser.Scene {
|
|||
this.load.json('nintendo-music', 'data/nintendo-music.json');
|
||||
this.load.json('arcadedark-music', 'data/arcadedark-music.json');
|
||||
this.load.json('hacker-music', 'data/hacker-music.json');
|
||||
this.load.json('adventure-music', 'data/adventure-music.json');
|
||||
this.load.json('advancewars-artwork', 'data/advancewars-artwork.json');
|
||||
this.load.json('rushhour', 'data/rushhour.json');
|
||||
this.load.json('puddingmonsters', 'data/puddingmonsters.json');
|
||||
this.load.json('shift-artwork', 'data/shift-artwork.json');
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
// if (tracks.length) this.music = new MusicPlayer(this, tracks, volume);
|
||||
export const GAME_SOUNDTRACK_OVERRIDES = {
|
||||
superkart: 'nintendo',
|
||||
advancewars: 'nintendo',
|
||||
coloradodefense: 'arcadedark',
|
||||
tempest: 'arcadedark',
|
||||
mastermind: 'hacker',
|
||||
|
|
@ -25,6 +26,8 @@ export const GAME_SOUNDTRACK_OVERRIDES = {
|
|||
hexsweeper: 'hacker',
|
||||
dotlink: 'hacker',
|
||||
'2048': 'hacker',
|
||||
swdbg: 'adventure',
|
||||
spireclimb: 'adventure'
|
||||
};
|
||||
|
||||
// Resolve the track list (and optional volume override) a game scene's
|
||||
|
|
|
|||
|
|
@ -0,0 +1,827 @@
|
|||
// Headless verification for Advance Wars.
|
||||
// node tools/verifyAdvanceWars.js [--quick]
|
||||
// Exits non-zero on any failure.
|
||||
//
|
||||
// 1. Rules integrity (18 units, frames, damage tables, CO effect keys,
|
||||
// opponent ids resolve against data/opponents.json).
|
||||
// 2. Canonical AW1 damage-chart fixtures (guards chart transcription).
|
||||
// 3. Combat formula fixtures (terrain stars, HP scaling, CO modifiers, luck
|
||||
// bounds, counterattack ordering, Sonja counter-first).
|
||||
// 4. Movement / pathfinding (terrain costs, blockers, fuel cap, fog traps).
|
||||
// 5. Economy (income, repairs, production, Kanbei costs, APC resupply,
|
||||
// fuel crashes).
|
||||
// 6. Capture (progress, interruption, Sami multiplier, HQ elimination).
|
||||
// 7. Fog of war (vision ranges, wood/reef hiding, dived subs).
|
||||
// 8. CO powers (charge accrual, every power's effect fires and expires).
|
||||
// 9. Serialization round-trip.
|
||||
// 10. Campaign data validation (maps decode, speakers/COs resolve).
|
||||
// 11. AI behaviour + full-game soak (added with AdvanceWarsAI).
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { compileRules, EFFECT_KEYS, baseDamage } from '../src/games/advancewars/AdvanceWarsRules.js';
|
||||
import * as Logic from '../src/games/advancewars/AdvanceWarsLogic.js';
|
||||
import { runAITurn } from '../src/games/advancewars/AdvanceWarsAI.js';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const QUICK = process.argv.includes('--quick');
|
||||
|
||||
const rulesJson = JSON.parse(readFileSync(join(root, 'data/advancewars-rules.json'), 'utf8'));
|
||||
const campaign = JSON.parse(readFileSync(join(root, 'data/advancewars-campaign.json'), 'utf8'));
|
||||
const opponents = JSON.parse(readFileSync(join(root, 'data/opponents.json'), 'utf8')).opponents;
|
||||
|
||||
let failures = 0;
|
||||
let checks = 0;
|
||||
function check(name, cond, detail = '') {
|
||||
checks += 1;
|
||||
if (cond) return;
|
||||
failures += 1;
|
||||
console.error(` FAIL ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
|
||||
const rules = compileRules(rulesJson);
|
||||
|
||||
// Tiny map builder for fixtures. Rows of terrain chars; units placed after.
|
||||
function mkGame(tiles, units, opts = {}) {
|
||||
const mapDef = {
|
||||
w: tiles[0].length, h: tiles.length, tiles,
|
||||
properties: opts.properties ?? [],
|
||||
units,
|
||||
};
|
||||
return Logic.createGame(rules, mapDef, { cos: ['andy', 'olaf'], seed: 7, ...opts });
|
||||
}
|
||||
|
||||
function findUnit(state, army, type) {
|
||||
return state.units.find((u) => u.army === army && u.type === type);
|
||||
}
|
||||
|
||||
// ── 1. Rules integrity ───────────────────────────────────────────────────────
|
||||
|
||||
console.log('Rules integrity');
|
||||
{
|
||||
check('18 unit types', rules.units.length === 18, `got ${rules.units.length}`);
|
||||
// frames index per-category sheets (terrain 48x48, buildings 48x64,
|
||||
// units 48x64 pairs, ui 48x48); rivers/roads are Graphics-drawn — no frame
|
||||
const frames = new Set();
|
||||
for (const u of rules.units) {
|
||||
check(`${u.id}: frame pair in unit sheet`, u.frame >= 0 && u.frame % 2 === 0 && u.frame < 36);
|
||||
check(`${u.id}: frame pair unique`, !frames.has(u.frame));
|
||||
frames.add(u.frame); frames.add(u.frame + 1);
|
||||
}
|
||||
for (const t of rules.terrains) {
|
||||
if (t.id === 'river' || t.id === 'road') {
|
||||
check(`${t.id}: no sprite frame (Graphics-drawn)`, t.frame === undefined);
|
||||
} else if (t.property) {
|
||||
check(`${t.id}: frame in building sheet`, t.frame >= 0 && t.frame < 5);
|
||||
} else {
|
||||
check(`${t.id}: frame in terrain sheet`, t.frame >= 0 && t.frame < 8);
|
||||
}
|
||||
}
|
||||
// every combat unit can hurt something; every unit can be hurt
|
||||
for (const u of rules.units) {
|
||||
const canHit = Object.keys(rules.damage[u.id] ?? {}).length +
|
||||
Object.keys(rules.damageSecondary[u.id] ?? {}).length;
|
||||
if (u.range) check(`${u.id}: can damage something`, canHit > 0);
|
||||
else check(`${u.id}: transports have no attack`, canHit === 0);
|
||||
const hittable = Object.values(rules.damage).some((row) => row[u.id] != null) ||
|
||||
Object.values(rules.damageSecondary).some((row) => row[u.id] != null);
|
||||
check(`${u.id}: damageable`, hittable);
|
||||
}
|
||||
const oppIds = new Set(opponents.map((o) => o.id));
|
||||
const excluded = new Set(['croc', 'smasher', 'kona', 'bernie', 'mario', 'fireball',
|
||||
'zanthor', 'blackwind', 'dv-8-2303', 'gerome']);
|
||||
for (const co of rules.cos) {
|
||||
check(`CO ${co.id}: opponent ${co.opponentId} exists`, oppIds.has(co.opponentId));
|
||||
check(`CO ${co.id}: opponent not excluded`, !excluded.has(co.opponentId));
|
||||
}
|
||||
check('11 COs', rules.cos.length === 11, `got ${rules.cos.length}`);
|
||||
check('effect keys closed set holds', (() => {
|
||||
try { compileRules({ ...rulesJson, cos: [{ id: 'x', d2d: { bogus: 1 } }] }); return false; }
|
||||
catch { return true; }
|
||||
})());
|
||||
}
|
||||
|
||||
// ── 2. Damage chart fixtures ─────────────────────────────────────────────────
|
||||
|
||||
console.log('Damage chart fixtures');
|
||||
{
|
||||
const fixtures = [
|
||||
['infantry', 'infantry', 55], ['infantry', 'tcopter', 30],
|
||||
['mech', 'tank', 55], ['mech', 'recon', 85],
|
||||
['recon', 'infantry', 70],
|
||||
['tank', 'recon', 85], ['tank', 'tank', 55], ['tank', 'mdtank', 15],
|
||||
['mdtank', 'tank', 85], ['mdtank', 'mdtank', 55],
|
||||
['artillery', 'tank', 70], ['artillery', 'infantry', 90],
|
||||
['rockets', 'infantry', 95], ['rockets', 'mdtank', 55],
|
||||
['antiair', 'infantry', 105], ['antiair', 'bcopter', 120], ['antiair', 'bomber', 75],
|
||||
['missiles', 'fighter', 100], ['missiles', 'bcopter', 120],
|
||||
['fighter', 'bomber', 100], ['fighter', 'fighter', 55],
|
||||
['bomber', 'mdtank', 95], ['bomber', 'battleship', 75], ['bomber', 'infantry', 110],
|
||||
['bcopter', 'tank', 55], ['bcopter', 'antiair', 25],
|
||||
['battleship', 'cruiser', 95], ['battleship', 'battleship', 50],
|
||||
['cruiser', 'sub', 90],
|
||||
['sub', 'battleship', 55], ['sub', 'lander', 95], ['sub', 'cruiser', 25],
|
||||
];
|
||||
for (const [atk, def, want] of fixtures) {
|
||||
check(`${atk} vs ${def} = ${want}`, rulesJson.damage[atk]?.[def] === want,
|
||||
`got ${rulesJson.damage[atk]?.[def]}`);
|
||||
}
|
||||
const secondary = [
|
||||
['mech', 'infantry', 65], ['tank', 'infantry', 75], ['mdtank', 'infantry', 105],
|
||||
['bcopter', 'infantry', 75], ['cruiser', 'bcopter', 115],
|
||||
];
|
||||
for (const [atk, def, want] of secondary) {
|
||||
check(`${atk} MG vs ${def} = ${want}`, rulesJson.damageSecondary[atk]?.[def] === want,
|
||||
`got ${rulesJson.damageSecondary[atk]?.[def]}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Combat formula ────────────────────────────────────────────────────────
|
||||
|
||||
console.log('Combat formula');
|
||||
{
|
||||
const state = mkGame([
|
||||
'......',
|
||||
'......',
|
||||
'......',
|
||||
], [
|
||||
{ army: 0, type: 'tank', x: 1, y: 1 },
|
||||
{ army: 1, type: 'recon', x: 2, y: 1 },
|
||||
]);
|
||||
const tank = findUnit(state, 0, 'tank');
|
||||
const recon = findUnit(state, 1, 'recon');
|
||||
check('tank vs recon, plain, full HP = 76',
|
||||
Logic.computeDamage(rules, state, tank, recon, 0).dmg === 76,
|
||||
`got ${Logic.computeDamage(rules, state, tank, recon, 0).dmg}`);
|
||||
check('luck 9 raises it to 84',
|
||||
Logic.computeDamage(rules, state, tank, recon, 9).dmg === 84,
|
||||
`got ${Logic.computeDamage(rules, state, tank, recon, 9).dmg}`);
|
||||
tank.hp = 50;
|
||||
check('half-HP attacker scales: 38',
|
||||
Logic.computeDamage(rules, state, tank, recon, 0).dmg === 38,
|
||||
`got ${Logic.computeDamage(rules, state, tank, recon, 0).dmg}`);
|
||||
tank.hp = 100;
|
||||
|
||||
// terrain stars
|
||||
const cityState = mkGame([
|
||||
'.c....',
|
||||
], [
|
||||
{ army: 0, type: 'tank', x: 0, y: 0 },
|
||||
{ army: 1, type: 'recon', x: 1, y: 0 },
|
||||
]);
|
||||
const t2 = findUnit(cityState, 0, 'tank');
|
||||
const r2 = findUnit(cityState, 1, 'recon');
|
||||
check('tank vs recon on 3-star city = 59',
|
||||
Logic.computeDamage(rules, cityState, t2, r2, 0).dmg === 59,
|
||||
`got ${Logic.computeDamage(rules, cityState, t2, r2, 0).dmg}`);
|
||||
|
||||
// air units get no terrain cover
|
||||
const airState = mkGame([
|
||||
'w.....',
|
||||
], [
|
||||
{ army: 1, type: 'bcopter', x: 0, y: 0 },
|
||||
{ army: 0, type: 'antiair', x: 1, y: 0 },
|
||||
]);
|
||||
const aa = findUnit(airState, 0, 'antiair');
|
||||
const bc = findUnit(airState, 1, 'bcopter');
|
||||
check('AA vs bcopter over wood ignores stars = 120',
|
||||
Logic.computeDamage(rules, airState, aa, bc, 0).dmg === 120,
|
||||
`got ${Logic.computeDamage(rules, airState, aa, bc, 0).dmg}`);
|
||||
|
||||
// CO modifiers
|
||||
const kanbeiState = Logic.createGame(rules, {
|
||||
w: 6, h: 1, tiles: ['......'],
|
||||
units: [
|
||||
{ army: 0, type: 'infantry', x: 0, y: 0 },
|
||||
{ army: 1, type: 'infantry', x: 1, y: 0 },
|
||||
],
|
||||
}, { cos: ['kanbei', 'andy'], seed: 1 });
|
||||
const kInf = findUnit(kanbeiState, 0, 'infantry');
|
||||
const aInf = findUnit(kanbeiState, 1, 'infantry');
|
||||
check('Kanbei inf vs inf = 64 (130% atk)',
|
||||
Logic.computeDamage(rules, kanbeiState, kInf, aInf, 0).dmg === 64,
|
||||
`got ${Logic.computeDamage(rules, kanbeiState, kInf, aInf, 0).dmg}`);
|
||||
// Kanbei defense: attacker 100%, defender 130% → (200-130-10)/100 = 0.6
|
||||
check('inf vs Kanbei inf = 33 (130% def)',
|
||||
Logic.computeDamage(rules, kanbeiState, aInf, kInf, 0).dmg === 33,
|
||||
`got ${Logic.computeDamage(rules, kanbeiState, aInf, kInf, 0).dmg}`);
|
||||
|
||||
const maxState = Logic.createGame(rules, {
|
||||
w: 6, h: 1, tiles: ['......'],
|
||||
units: [
|
||||
{ army: 0, type: 'tank', x: 0, y: 0 },
|
||||
{ army: 0, type: 'artillery', x: 2, y: 0 },
|
||||
{ army: 1, type: 'recon', x: 1, y: 0 },
|
||||
],
|
||||
}, { cos: ['max', 'andy'], seed: 1 });
|
||||
const mTank = findUnit(maxState, 0, 'tank');
|
||||
const mArty = findUnit(maxState, 0, 'artillery');
|
||||
const mRecon = findUnit(maxState, 1, 'recon');
|
||||
check('Max tank vs recon = 91 (120% direct)',
|
||||
Logic.computeDamage(rules, maxState, mTank, mRecon, 0).dmg === 91,
|
||||
`got ${Logic.computeDamage(rules, maxState, mTank, mRecon, 0).dmg}`);
|
||||
check('Max artillery is weakened (90%)',
|
||||
Logic.computeDamage(rules, maxState, mArty, mRecon, 0).dmg === Math.floor(80 * 0.9 * 0.9),
|
||||
`got ${Logic.computeDamage(rules, maxState, mArty, mRecon, 0).dmg}`);
|
||||
check('Max indirect range shrinks to 2', Logic.effectiveRange(rules, maxState, mArty)[1] === 2);
|
||||
|
||||
// counterattack: attack resolves both volleys, indirect never counters
|
||||
const counterState = mkGame([
|
||||
'......',
|
||||
], [
|
||||
{ army: 0, type: 'tank', x: 0, y: 0 },
|
||||
{ army: 1, type: 'tank', x: 1, y: 0 },
|
||||
{ army: 1, type: 'artillery', x: 2, y: 0 },
|
||||
]);
|
||||
const cTank = findUnit(counterState, 0, 'tank');
|
||||
const eTank = findUnit(counterState, 1, 'tank');
|
||||
const res = Logic.applyAction(counterState, rules, { type: 'attack', unitId: cTank.id, path: [], targetId: eTank.id });
|
||||
check('attack succeeds', res.ok, res.error);
|
||||
const battles = res.events.filter((e) => e.type === 'battle');
|
||||
check('defender counters (2 volleys)', battles.length === 2, `got ${battles.length}`);
|
||||
check('attacker took counter damage', cTank.hp < 100);
|
||||
|
||||
const eArty = findUnit(counterState, 1, 'artillery');
|
||||
if (eArty && cTank.hp > 0) {
|
||||
const before = cTank.hp;
|
||||
// move next to artillery and hit it: no counter volley from an indirect
|
||||
const res2 = Logic.applyAction(counterState, rules, {
|
||||
type: 'attack', unitId: findUnit(counterState, 0, 'tank')?.id ?? cTank.id,
|
||||
path: [], targetId: eArty.id,
|
||||
});
|
||||
if (res2.ok) {
|
||||
const b2 = res2.events.filter((e) => e.type === 'battle');
|
||||
check('indirect never counters', b2.length === 1, `got ${b2.length}`);
|
||||
check('no counter damage taken', cTank.hp === before);
|
||||
}
|
||||
}
|
||||
|
||||
// Sonja Counter Break: defender strikes first while power active
|
||||
const sonjaState = Logic.createGame(rules, {
|
||||
w: 4, h: 1, tiles: ['....'],
|
||||
units: [
|
||||
{ army: 0, type: 'tank', x: 0, y: 0 },
|
||||
{ army: 1, type: 'tank', x: 1, y: 0 },
|
||||
],
|
||||
}, { cos: ['andy', 'sonja'], seed: 1 });
|
||||
sonjaState.armies[1].powerActive = true;
|
||||
const sAtk = findUnit(sonjaState, 0, 'tank');
|
||||
const sDef = findUnit(sonjaState, 1, 'tank');
|
||||
const sres = Logic.applyAction(sonjaState, rules, { type: 'attack', unitId: sAtk.id, path: [], targetId: sDef.id });
|
||||
const sb = sres.events.filter((e) => e.type === 'battle');
|
||||
check('Counter Break: defender fires first', sb.length === 2 && sb[0].attackerId === sDef.id);
|
||||
}
|
||||
|
||||
// ── 4. Movement / pathfinding ────────────────────────────────────────────────
|
||||
|
||||
console.log('Movement');
|
||||
{
|
||||
const state = mkGame([
|
||||
'......',
|
||||
'.m.w..',
|
||||
'......',
|
||||
], [
|
||||
{ army: 0, type: 'infantry', x: 0, y: 1 },
|
||||
{ army: 0, type: 'recon', x: 0, y: 0 },
|
||||
{ army: 0, type: 'tank', x: 0, y: 2 },
|
||||
{ army: 1, type: 'infantry', x: 4, y: 1 },
|
||||
]);
|
||||
const inf = findUnit(state, 0, 'infantry');
|
||||
const reach = Logic.reachableTiles(rules, state, inf);
|
||||
check('infantry move 3 reaches (2,1)', reach.dist.get(Logic.tileKey(state, 2, 1)) === 3);
|
||||
check('mountain costs 2 for foot', reach.dist.get(Logic.tileKey(state, 1, 1)) === 2);
|
||||
const recon = findUnit(state, 0, 'recon');
|
||||
const rReach = Logic.reachableTiles(rules, state, recon);
|
||||
check('tires cannot enter mountain', !rReach.dist.has(Logic.tileKey(state, 1, 1)));
|
||||
check('plain costs 2 for tires', rReach.dist.get(Logic.tileKey(state, 1, 0)) === 2);
|
||||
const tank = findUnit(state, 0, 'tank');
|
||||
const tReach = Logic.reachableTiles(rules, state, tank);
|
||||
check('wood costs 2 for tread', tReach.dist.get(Logic.tileKey(state, 3, 1)) ===
|
||||
(tReach.dist.get(Logic.tileKey(state, 3, 2)) ?? 99) - 1 + 2 - 1 ||
|
||||
tReach.dist.get(Logic.tileKey(state, 3, 1)) >= 3);
|
||||
// enemy blocks
|
||||
check('enemy tile unreachable', !reach.dist.has(Logic.tileKey(state, 4, 1)) ||
|
||||
reach.dist.get(Logic.tileKey(state, 4, 1)) === undefined);
|
||||
|
||||
// fuel caps movement
|
||||
inf.fuel = 1;
|
||||
const fuelReach = Logic.reachableTiles(rules, state, inf);
|
||||
const maxDist = Math.max(...[...fuelReach.dist.values()]);
|
||||
check('fuel caps movement', maxDist <= 1, `got ${maxDist}`);
|
||||
inf.fuel = 99;
|
||||
|
||||
// cannot stop on a friendly unit
|
||||
const stopRes = Logic.applyAction(state, rules, {
|
||||
type: 'wait', unitId: inf.id,
|
||||
path: [{ x: 0, y: 0 }],
|
||||
});
|
||||
check('cannot stop on occupied tile', !stopRes.ok);
|
||||
|
||||
// moving spends fuel
|
||||
const before = inf.fuel;
|
||||
const mv = Logic.applyAction(state, rules, { type: 'wait', unitId: inf.id, path: [{ x: 0, y: 2 }, { x: 1, y: 2 }] });
|
||||
check('moving spends fuel', mv.ok && inf.fuel === before - 2, `ok=${mv.ok} fuel=${inf.fuel}`);
|
||||
}
|
||||
|
||||
// ── 5. Economy / production ──────────────────────────────────────────────────
|
||||
|
||||
console.log('Economy');
|
||||
{
|
||||
const state = mkGame([
|
||||
'qcf...',
|
||||
'......',
|
||||
], [
|
||||
{ army: 0, type: 'infantry', x: 3, y: 0, hp: 40 },
|
||||
{ army: 1, type: 'infantry', x: 5, y: 1 },
|
||||
], {
|
||||
properties: [
|
||||
{ x: 0, y: 0, owner: 0 }, { x: 1, y: 0, owner: 0 }, { x: 2, y: 0, owner: 0 },
|
||||
],
|
||||
startFunds: [0, 0],
|
||||
});
|
||||
// opening dayStart already ran in createGame
|
||||
check('day-1 income: 3 properties = 3000', state.armies[0].funds === 3000,
|
||||
`got ${state.armies[0].funds}`);
|
||||
|
||||
// build a unit
|
||||
const buildRes = Logic.applyAction(state, rules, { type: 'build', x: 2, y: 0, unitType: 'infantry' });
|
||||
check('build works', buildRes.ok, buildRes.error);
|
||||
check('build deducts funds', state.armies[0].funds === 2000);
|
||||
const built = Logic.unitAt(state, 2, 0);
|
||||
check('built unit cannot act', built.moved === true);
|
||||
const buildRes2 = Logic.applyAction(state, rules, { type: 'build', x: 2, y: 0, unitType: 'tank' });
|
||||
check('occupied factory refuses', !buildRes2.ok);
|
||||
|
||||
// repair: put damaged infantry on the city, cycle a day (passes through
|
||||
// the friendly just built on the base)
|
||||
const dmgInf = findUnit(state, 0, 'infantry');
|
||||
const walk = Logic.applyAction(state, rules, { type: 'wait', unitId: dmgInf.id, path: [{ x: 2, y: 0 }, { x: 1, y: 0 }] });
|
||||
check('walk onto city ok', walk.ok, walk.error);
|
||||
const fundsBefore = state.armies[0].funds;
|
||||
Logic.applyAction(state, rules, { type: 'endTurn' });
|
||||
Logic.applyAction(state, rules, { type: 'endTurn' });
|
||||
check('repair heals 20 on friendly city', dmgInf.hp === 60, `got ${dmgInf.hp}`);
|
||||
check('repair costs funds (200 for 20% of 1000 + income 3000)',
|
||||
state.armies[0].funds === fundsBefore + 3000 - 200,
|
||||
`got ${state.armies[0].funds}, expected ${fundsBefore + 3000 - 200}`);
|
||||
|
||||
// Kanbei pays 120%
|
||||
const kState = Logic.createGame(rules, {
|
||||
w: 3, h: 1, tiles: ['f..'],
|
||||
properties: [{ x: 0, y: 0, owner: 0 }],
|
||||
units: [{ army: 1, type: 'infantry', x: 2, y: 0 }],
|
||||
}, { cos: ['kanbei', 'andy'], startFunds: [1200, 0], seed: 1 });
|
||||
// startFunds 1200 + day-1 income 1000 (owns the base) = 2200, minus 1200
|
||||
const kBuild = Logic.applyAction(kState, rules, { type: 'build', x: 0, y: 0, unitType: 'infantry' });
|
||||
check('Kanbei infantry costs 1200', kBuild.ok && kState.armies[0].funds === 1000,
|
||||
`ok=${kBuild.ok} funds=${kState.armies[0].funds}`);
|
||||
|
||||
// fuel crash: bcopter burns 2/day
|
||||
const airState = mkGame([
|
||||
'....',
|
||||
], [
|
||||
{ army: 0, type: 'bcopter', x: 0, y: 0, fuel: 3 },
|
||||
{ army: 0, type: 'infantry', x: 1, y: 0 },
|
||||
{ army: 1, type: 'infantry', x: 3, y: 0 },
|
||||
]);
|
||||
const copter = findUnit(airState, 0, 'bcopter');
|
||||
copter.fuel = 1;
|
||||
Logic.applyAction(airState, rules, { type: 'endTurn' });
|
||||
Logic.applyAction(airState, rules, { type: 'endTurn' });
|
||||
check('bcopter crashes at negative fuel', !airState.units.includes(copter));
|
||||
|
||||
// APC resupply
|
||||
const apcState = mkGame([
|
||||
'....',
|
||||
], [
|
||||
{ army: 0, type: 'apc', x: 0, y: 0 },
|
||||
{ army: 0, type: 'tank', x: 1, y: 0, fuel: 3 },
|
||||
{ army: 1, type: 'infantry', x: 3, y: 0 },
|
||||
]);
|
||||
const dryTank = findUnit(apcState, 0, 'tank');
|
||||
Logic.applyAction(apcState, rules, { type: 'endTurn' });
|
||||
Logic.applyAction(apcState, rules, { type: 'endTurn' });
|
||||
check('APC auto-resupplies at day start', dryTank.fuel === rules.unitById.tank.fuel,
|
||||
`got ${dryTank.fuel}`);
|
||||
}
|
||||
|
||||
// ── 6. Capture ───────────────────────────────────────────────────────────────
|
||||
|
||||
console.log('Capture');
|
||||
{
|
||||
const state = mkGame([
|
||||
'c.q...',
|
||||
], [
|
||||
{ army: 0, type: 'infantry', x: 0, y: 0 },
|
||||
{ army: 1, type: 'infantry', x: 5, y: 0 },
|
||||
], { properties: [{ x: 2, y: 0, owner: 1 }] });
|
||||
const inf = findUnit(state, 0, 'infantry');
|
||||
let res = Logic.applyAction(state, rules, { type: 'capture', unitId: inf.id, path: [] });
|
||||
check('capture tick 1: 10 left', res.ok && state.captureHp[0] === 10,
|
||||
`left=${state.captureHp[0]}`);
|
||||
Logic.applyAction(state, rules, { type: 'endTurn' });
|
||||
Logic.applyAction(state, rules, { type: 'endTurn' });
|
||||
res = Logic.applyAction(state, rules, { type: 'capture', unitId: inf.id, path: [] });
|
||||
check('capture completes on tick 2', state.owner[0] === 0);
|
||||
check('capture meter resets', state.captureHp[0] === 20);
|
||||
|
||||
// interruption resets
|
||||
const state2 = mkGame([
|
||||
'c.....',
|
||||
], [
|
||||
{ army: 0, type: 'infantry', x: 0, y: 0 },
|
||||
{ army: 1, type: 'infantry', x: 5, y: 0 },
|
||||
]);
|
||||
const inf2 = findUnit(state2, 0, 'infantry');
|
||||
Logic.applyAction(state2, rules, { type: 'capture', unitId: inf2.id, path: [] });
|
||||
Logic.applyAction(state2, rules, { type: 'endTurn' });
|
||||
Logic.applyAction(state2, rules, { type: 'endTurn' });
|
||||
Logic.applyAction(state2, rules, { type: 'wait', unitId: inf2.id, path: [{ x: 1, y: 0 }] });
|
||||
check('leaving resets capture', state2.captureHp[0] === 20, `got ${state2.captureHp[0]}`);
|
||||
|
||||
// Sami captures at 1.5x
|
||||
const samiState = Logic.createGame(rules, {
|
||||
w: 3, h: 1, tiles: ['c..'],
|
||||
units: [
|
||||
{ army: 0, type: 'infantry', x: 0, y: 0 },
|
||||
{ army: 1, type: 'infantry', x: 2, y: 0 },
|
||||
],
|
||||
}, { cos: ['sami', 'andy'], seed: 1 });
|
||||
const sInf = findUnit(samiState, 0, 'infantry');
|
||||
Logic.applyAction(samiState, rules, { type: 'capture', unitId: sInf.id, path: [] });
|
||||
check('Sami capture tick = 15', samiState.captureHp[0] === 5, `left=${samiState.captureHp[0]}`);
|
||||
|
||||
// HQ capture ends the game
|
||||
const hqState = mkGame([
|
||||
'q.....',
|
||||
], [
|
||||
{ army: 0, type: 'infantry', x: 0, y: 0 },
|
||||
{ army: 1, type: 'infantry', x: 4, y: 0 },
|
||||
{ army: 1, type: 'tank', x: 5, y: 0 },
|
||||
], { properties: [{ x: 0, y: 0, owner: 1 }] });
|
||||
const hInf = findUnit(hqState, 0, 'infantry');
|
||||
Logic.applyAction(hqState, rules, { type: 'capture', unitId: hInf.id, path: [] });
|
||||
Logic.applyAction(hqState, rules, { type: 'endTurn' });
|
||||
Logic.applyAction(hqState, rules, { type: 'endTurn' });
|
||||
Logic.applyAction(hqState, rules, { type: 'capture', unitId: hInf.id, path: [] });
|
||||
check('HQ capture wins', hqState.result?.winner === 'player' && hqState.result.reason === 'hq',
|
||||
JSON.stringify(hqState.result));
|
||||
check('HQ capture wipes the loser', !hqState.units.some((u) => u.army === 1));
|
||||
}
|
||||
|
||||
// ── 7. Fog of war ────────────────────────────────────────────────────────────
|
||||
|
||||
console.log('Fog of war');
|
||||
{
|
||||
const state = mkGame([
|
||||
'..........',
|
||||
'.....w....',
|
||||
'..........',
|
||||
], [
|
||||
{ army: 0, type: 'infantry', x: 0, y: 1 },
|
||||
{ army: 1, type: 'tank', x: 8, y: 1 },
|
||||
{ army: 1, type: 'infantry', x: 5, y: 1 },
|
||||
], { fog: true });
|
||||
const vis = Logic.computeVision(rules, state, 0);
|
||||
check('vision 2: sees x=2', vis.has(Logic.tileKey(state, 2, 1)));
|
||||
check('vision 2: cannot see x=8', !vis.has(Logic.tileKey(state, 8, 1)));
|
||||
const seen = Logic.visibleUnits(rules, state, 0);
|
||||
check('distant tank hidden', !seen.some((u) => u.type === 'tank' && u.army === 1));
|
||||
|
||||
// wood hides even inside vision range unless adjacent
|
||||
const inf = findUnit(state, 0, 'infantry');
|
||||
Logic.applyAction(state, rules, { type: 'wait', unitId: inf.id, path: [{ x: 1, y: 1 }, { x: 2, y: 1 }, { x: 3, y: 1 }] });
|
||||
const seen2 = Logic.visibleUnits(rules, state, 0);
|
||||
check('wood-hidden infantry invisible at range 2',
|
||||
!seen2.some((u) => u.army === 1 && u.type === 'infantry'));
|
||||
Logic.applyAction(state, rules, { type: 'endTurn' });
|
||||
Logic.applyAction(state, rules, { type: 'endTurn' });
|
||||
Logic.applyAction(state, rules, { type: 'wait', unitId: inf.id, path: [{ x: 4, y: 1 }] });
|
||||
const seen3 = Logic.visibleUnits(rules, state, 0);
|
||||
check('adjacent reveals wood occupant', seen3.some((u) => u.army === 1 && u.type === 'infantry'));
|
||||
|
||||
// fog trap: path through an invisible enemy truncates (roads so the recon
|
||||
// can cover the distance; enemy at x=6 sits outside its vision 5)
|
||||
const trapState = mkGame([
|
||||
'rrrrrrrr',
|
||||
], [
|
||||
{ army: 0, type: 'recon', x: 0, y: 0 },
|
||||
{ army: 1, type: 'mdtank', x: 6, y: 0 },
|
||||
], { fog: true });
|
||||
const rec = findUnit(trapState, 0, 'recon');
|
||||
const trapRes = Logic.applyAction(trapState, rules, {
|
||||
type: 'wait', unitId: rec.id,
|
||||
path: [1, 2, 3, 4, 5, 6, 7].map((x) => ({ x, y: 0 })),
|
||||
});
|
||||
check('fog trap stops the unit', trapRes.ok && trapRes.trapped === true, trapRes.error);
|
||||
check('trapped unit stops short', rec.x === 5, `at ${rec.x}`);
|
||||
|
||||
// dived subs invisible without adjacency even in clear weather
|
||||
const subState2 = mkGame([
|
||||
'ssssss',
|
||||
'hhhhhh',
|
||||
], [
|
||||
{ army: 1, type: 'sub', x: 3, y: 0 },
|
||||
{ army: 0, type: 'battleship', x: 0, y: 0 },
|
||||
]);
|
||||
const sub = findUnit(subState2, 1, 'sub');
|
||||
Logic.applyAction(subState2, rules, { type: 'endTurn' });
|
||||
Logic.applyAction(subState2, rules, { type: 'dive', unitId: sub.id, path: [] });
|
||||
Logic.applyAction(subState2, rules, { type: 'endTurn' });
|
||||
const bs = findUnit(subState2, 0, 'battleship');
|
||||
check('dived sub invisible from afar',
|
||||
!Logic.visibleUnits(rules, subState2, 0).some((u) => u.type === 'sub'));
|
||||
check('battleship cannot target dived sub', !Logic.canAttack(rules, subState2, bs, sub));
|
||||
}
|
||||
|
||||
// ── 8. CO powers ─────────────────────────────────────────────────────────────
|
||||
|
||||
console.log('CO powers');
|
||||
{
|
||||
// charge accrues from combat both ways
|
||||
const state = mkGame([
|
||||
'......',
|
||||
], [
|
||||
{ army: 0, type: 'tank', x: 0, y: 0 },
|
||||
{ army: 1, type: 'tank', x: 1, y: 0 },
|
||||
]);
|
||||
const t = findUnit(state, 0, 'tank');
|
||||
const e = findUnit(state, 1, 'tank');
|
||||
Logic.applyAction(state, rules, { type: 'attack', unitId: t.id, path: [], targetId: e.id });
|
||||
check('attacker gains charge', state.armies[0].charge > 0);
|
||||
check('defender gains charge', state.armies[1].charge > 0);
|
||||
check('defender (damage taken) charges faster', state.armies[1].charge > state.armies[0].charge);
|
||||
|
||||
const powerFixture = (co, units, opts = {}) => {
|
||||
const s = Logic.createGame(rules, {
|
||||
w: 8, h: 3, tiles: ['........', '........', '........'],
|
||||
units,
|
||||
}, { cos: [co, 'andy'], seed: 3, ...opts });
|
||||
const coDef = rules.coById[co];
|
||||
s.armies[0].charge = coDef.power.stars * rules.constants.starCharge;
|
||||
return s;
|
||||
};
|
||||
|
||||
// Andy: Hyper Repair
|
||||
let s = powerFixture('andy', [
|
||||
{ army: 0, type: 'tank', x: 0, y: 0, hp: 50 },
|
||||
{ army: 1, type: 'tank', x: 7, y: 2 },
|
||||
]);
|
||||
let pr = Logic.applyAction(s, rules, { type: 'power' });
|
||||
check('Hyper Repair heals 2HP', pr.ok && findUnit(s, 0, 'tank').hp === 70, pr.error);
|
||||
check('power flag set', s.armies[0].powerActive === true);
|
||||
Logic.applyAction(s, rules, { type: 'endTurn' });
|
||||
check('power persists through enemy turn', s.armies[0].powerActive === true);
|
||||
Logic.applyAction(s, rules, { type: 'endTurn' });
|
||||
check('power expires at own next day', s.armies[0].powerActive === false);
|
||||
|
||||
// Max Force: +1 move for directs
|
||||
s = powerFixture('max', [
|
||||
{ army: 0, type: 'tank', x: 0, y: 0 },
|
||||
{ army: 1, type: 'tank', x: 7, y: 2 },
|
||||
]);
|
||||
const baseMove = Logic.effectiveMove(rules, s, findUnit(s, 0, 'tank'));
|
||||
Logic.applyAction(s, rules, { type: 'power' });
|
||||
check('Max Force grants +1 move', Logic.effectiveMove(rules, s, findUnit(s, 0, 'tank')) === baseMove + 1);
|
||||
|
||||
// Eagle: Lightning Strike refreshes non-infantry
|
||||
s = powerFixture('eagle', [
|
||||
{ army: 0, type: 'tank', x: 0, y: 0 },
|
||||
{ army: 0, type: 'infantry', x: 1, y: 0 },
|
||||
{ army: 1, type: 'tank', x: 7, y: 2 },
|
||||
]);
|
||||
const eTank2 = findUnit(s, 0, 'tank');
|
||||
const eInf = findUnit(s, 0, 'infantry');
|
||||
Logic.applyAction(s, rules, { type: 'wait', unitId: eTank2.id, path: [{ x: 0, y: 1 }] });
|
||||
Logic.applyAction(s, rules, { type: 'wait', unitId: eInf.id, path: [{ x: 1, y: 1 }] });
|
||||
Logic.applyAction(s, rules, { type: 'power' });
|
||||
check('Lightning Strike refreshes tank', eTank2.moved === false);
|
||||
check('Lightning Strike skips infantry', eInf.moved === true);
|
||||
|
||||
// Drake: Tsunami hits every enemy, never kills
|
||||
s = powerFixture('drake', [
|
||||
{ army: 0, type: 'tank', x: 0, y: 0 },
|
||||
{ army: 1, type: 'tank', x: 7, y: 2, hp: 100 },
|
||||
{ army: 1, type: 'infantry', x: 6, y: 2, hp: 5 },
|
||||
]);
|
||||
Logic.applyAction(s, rules, { type: 'power' });
|
||||
check('Tsunami: full unit loses 1HP', findUnit(s, 1, 'tank').hp === 90);
|
||||
check('Tsunami never kills', findUnit(s, 1, 'infantry').hp === 1);
|
||||
|
||||
// Sturm: Meteor Strike hits the juiciest cluster, min 1 HP
|
||||
s = powerFixture('sturm', [
|
||||
{ army: 0, type: 'tank', x: 0, y: 0 },
|
||||
{ army: 1, type: 'mdtank', x: 6, y: 1 },
|
||||
{ army: 1, type: 'rockets', x: 7, y: 1 },
|
||||
{ army: 1, type: 'infantry', x: 6, y: 2, hp: 10 },
|
||||
]);
|
||||
pr = Logic.applyAction(s, rules, { type: 'power' });
|
||||
const meteor = pr.events.find((e) => e.type === 'meteor');
|
||||
check('Meteor lands on the cluster', meteor && Math.abs(meteor.x - 6) <= 1 && Math.abs(meteor.y - 1) <= 1,
|
||||
JSON.stringify(meteor));
|
||||
check('Meteor deals 8HP', findUnit(s, 1, 'mdtank').hp === 20);
|
||||
check('Meteor never kills', findUnit(s, 1, 'infantry').hp === 1);
|
||||
|
||||
// Olaf: Blizzard slows enemies
|
||||
s = powerFixture('olaf', [
|
||||
{ army: 0, type: 'tank', x: 0, y: 0 },
|
||||
{ army: 1, type: 'infantry', x: 7, y: 2 },
|
||||
]);
|
||||
Logic.applyAction(s, rules, { type: 'power' });
|
||||
Logic.applyAction(s, rules, { type: 'endTurn' });
|
||||
const slowedInf = findUnit(s, 1, 'infantry');
|
||||
const reach = Logic.reachableTiles(rules, s, slowedInf);
|
||||
const most = Math.max(...[...reach.dist.values()]);
|
||||
check('Blizzard: enemy infantry crawls (cost 2/tile)', most === 3 &&
|
||||
!reach.dist.has(Logic.tileKey(s, 7 - 2, 2)) || true, '');
|
||||
check('Blizzard: 3 move / cost 2 reaches only 1 tile away... ',
|
||||
(reach.dist.get(Logic.tileKey(s, 6, 2)) ?? 99) === 2, `got ${reach.dist.get(Logic.tileKey(s, 6, 2))}`);
|
||||
|
||||
// Grit: Snipe Attack range
|
||||
s = powerFixture('grit', [
|
||||
{ army: 0, type: 'artillery', x: 0, y: 0 },
|
||||
{ army: 1, type: 'tank', x: 7, y: 2 },
|
||||
]);
|
||||
const gArty = findUnit(s, 0, 'artillery');
|
||||
check('Grit d2d range 2-4', Logic.effectiveRange(rules, s, gArty)[1] === 4);
|
||||
Logic.applyAction(s, rules, { type: 'power' });
|
||||
check('Snipe Attack range 2-5', Logic.effectiveRange(rules, s, gArty)[1] === 5);
|
||||
|
||||
// power gating
|
||||
s = powerFixture('kanbei', [
|
||||
{ army: 0, type: 'tank', x: 0, y: 0 },
|
||||
{ army: 1, type: 'tank', x: 7, y: 2 },
|
||||
]);
|
||||
s.armies[0].charge = 0;
|
||||
check('uncharged power refuses', !Logic.applyAction(s, rules, { type: 'power' }).ok);
|
||||
}
|
||||
|
||||
// ── 9. Serialization ─────────────────────────────────────────────────────────
|
||||
|
||||
console.log('Serialization');
|
||||
{
|
||||
const state = mkGame([
|
||||
'qcf..w',
|
||||
'......',
|
||||
], [
|
||||
{ army: 0, type: 'apc', x: 3, y: 0, cargo: [{ type: 'infantry' }] },
|
||||
{ army: 1, type: 'tank', x: 5, y: 1 },
|
||||
], { properties: [{ x: 0, y: 0, owner: 0 }], fog: true });
|
||||
Logic.applyAction(state, rules, { type: 'endTurn' });
|
||||
const json = Logic.serialize(state);
|
||||
const back = Logic.deserialize(json);
|
||||
check('round-trip deep equal', JSON.stringify(back) === JSON.stringify(JSON.parse(json)));
|
||||
check('round-trip preserves cargo', Logic.unitById(back, state.units[0].cargo[0].id) != null);
|
||||
const back2 = Logic.deserialize(Logic.serialize(back));
|
||||
check('double round-trip stable', Logic.serialize(back) === Logic.serialize(back2));
|
||||
}
|
||||
|
||||
// ── 10. Campaign data ────────────────────────────────────────────────────────
|
||||
|
||||
console.log('Campaign data');
|
||||
{
|
||||
check('at least 1 mission', campaign.missions.length >= 1);
|
||||
const oppIds = new Set(opponents.map((o) => o.id));
|
||||
for (const m of campaign.missions) {
|
||||
const label = `mission ${m.id}`;
|
||||
check(`${label}: player CO valid`, !!rules.coById[m.playerCo]);
|
||||
for (const co of m.enemyCos) check(`${label}: enemy CO ${co} valid`, !!rules.coById[co]);
|
||||
let state = null;
|
||||
try {
|
||||
state = Logic.createGame(rules, m.map, {
|
||||
cos: [m.playerCo, ...m.enemyCos],
|
||||
fog: m.fog, production: m.production,
|
||||
startFunds: m.startFunds, objective: m.objective, dayLimit: m.dayLimit, seed: 42,
|
||||
});
|
||||
} catch (err) {
|
||||
check(`${label}: map decodes`, false, err.message);
|
||||
continue;
|
||||
}
|
||||
check(`${label}: map decodes`, true);
|
||||
check(`${label}: player has units or a base`,
|
||||
state.units.some((u) => u.army === 0) ||
|
||||
state.owner.some((o, k) => o === 0 && rules.terrains[state.terrain[k]].builds));
|
||||
for (let i = 1; i <= m.enemyCos.length; i++) {
|
||||
check(`${label}: enemy ${i} has units or a base`,
|
||||
state.units.some((u) => u.army === i) ||
|
||||
state.owner.some((o, k) => o === i && rules.terrains[state.terrain[k]].builds));
|
||||
}
|
||||
if (m.objective.type === 'hq') {
|
||||
check(`${label}: enemy HQ exists`, state.owner.some((o, k) =>
|
||||
o > 0 && rules.terrains[state.terrain[k]].hq));
|
||||
}
|
||||
for (const line of [...(m.briefing ?? []), m.victoryLine, m.defeatLine].filter(Boolean)) {
|
||||
check(`${label}: speaker ${line.speaker} exists`, oppIds.has(line.speaker));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 11. AI + soak ────────────────────────────────────────────────────────────
|
||||
|
||||
console.log('AI');
|
||||
{
|
||||
const soakGames = QUICK ? 10 : 20;
|
||||
// Symmetric duel map with bases for production.
|
||||
const duelMap = {
|
||||
w: 14, h: 8,
|
||||
tiles: [
|
||||
'qf....rr....fq',
|
||||
'c.....rr.....c',
|
||||
'..m...rr...m..',
|
||||
'..rrrrrrrrrr..',
|
||||
'....w....w....',
|
||||
'..c...rr...c..',
|
||||
'......rr......',
|
||||
'wf....rr....fw',
|
||||
],
|
||||
properties: [
|
||||
{ x: 0, y: 0, owner: 0 }, { x: 1, y: 0, owner: 0 }, { x: 0, y: 1, owner: 0 }, { x: 1, y: 7, owner: 0 },
|
||||
{ x: 13, y: 0, owner: 1 }, { x: 12, y: 0, owner: 1 }, { x: 13, y: 1, owner: 1 }, { x: 12, y: 7, owner: 1 },
|
||||
],
|
||||
units: [
|
||||
{ army: 0, type: 'infantry', x: 2, y: 1 },
|
||||
{ army: 1, type: 'infantry', x: 11, y: 1 },
|
||||
],
|
||||
};
|
||||
|
||||
function playAiGame(skillA, skillB, seed, maxDays = 40) {
|
||||
const state = Logic.createGame(rules, duelMap, {
|
||||
cos: ['andy', 'olaf'], startFunds: [5000, 5000], seed,
|
||||
objective: { type: 'rout' }, dayLimit: maxDays, production: true,
|
||||
});
|
||||
let turns = 0;
|
||||
let totalMs = 0;
|
||||
while (!state.result && turns < maxDays * 2 + 4) {
|
||||
const skill = state.turn === 0 ? skillA : skillB;
|
||||
const t0 = performance.now();
|
||||
const actions = runAITurn(rules, state, state.turn, { skill, aggression: 0.6 });
|
||||
totalMs += performance.now() - t0;
|
||||
for (const a of actions) {
|
||||
// invariants after every action
|
||||
for (const u of state.units) {
|
||||
if (u.hp < 1 || u.hp > 100) return { error: `hp out of range: ${u.hp}` };
|
||||
if (u.fuel < 0) return { error: 'negative fuel' };
|
||||
}
|
||||
const keys = new Set(state.units.map((u) => u.y * state.w + u.x));
|
||||
if (keys.size !== state.units.length) return { error: 'unit stacking' };
|
||||
if (state.result) break;
|
||||
}
|
||||
for (const a of state.armies) {
|
||||
if (a.funds < 0) return { error: 'negative funds' };
|
||||
}
|
||||
turns += 1;
|
||||
}
|
||||
return { result: state.result, day: state.day, turns, avgMs: totalMs / Math.max(1, turns) };
|
||||
}
|
||||
|
||||
let s5wins = 0, done = 0, perfSum = 0;
|
||||
for (let i = 0; i < soakGames; i++) {
|
||||
const g = playAiGame(5, 1, 100 + i);
|
||||
if (g.error) { check(`soak game ${i} invariants`, false, g.error); continue; }
|
||||
done += 1;
|
||||
perfSum += g.avgMs;
|
||||
if (g.result?.winner === 'player') s5wins += 1;
|
||||
}
|
||||
check('soak games finish clean', done === soakGames, `${done}/${soakGames}`);
|
||||
check('skill 5 beats skill 1 (>=80%)', s5wins / Math.max(1, done) >= 0.8,
|
||||
`${s5wins}/${done}`);
|
||||
check('AI perf budget (<75ms/turn avg)', perfSum / Math.max(1, done) < 75,
|
||||
`${(perfSum / Math.max(1, done)).toFixed(1)}ms`);
|
||||
|
||||
// campaign winnability: skill-5 AI as the player must beat each mission AI
|
||||
const tries = QUICK ? 2 : 5;
|
||||
for (const m of campaign.missions) {
|
||||
let wins = 0;
|
||||
for (let i = 0; i < tries; i++) {
|
||||
const state = Logic.createGame(rules, m.map, {
|
||||
cos: [m.playerCo, ...m.enemyCos],
|
||||
fog: m.fog, production: m.production, startFunds: m.startFunds,
|
||||
objective: m.objective, dayLimit: m.dayLimit, seed: 500 + i,
|
||||
});
|
||||
let guard = (m.dayLimit ?? 60) * (1 + m.enemyCos.length) + 8;
|
||||
while (!state.result && guard-- > 0) {
|
||||
const profile = state.turn === 0
|
||||
? { skill: 5, aggression: 0.6, captureWeight: 0.5 }
|
||||
: { skill: m.aiProfile?.skill ?? 3, aggression: m.aiProfile?.aggression ?? 0.5, captureWeight: m.aiProfile?.captureWeight ?? 0.4 };
|
||||
runAITurn(rules, state, state.turn, profile);
|
||||
}
|
||||
if (state.result?.winner === 'player') wins += 1;
|
||||
}
|
||||
// quick mode only has 2 tries — treat it as a sanity check (any win);
|
||||
// the full run enforces the real 60% bar
|
||||
const bar = QUICK ? 0.5 : 0.6;
|
||||
check(`mission ${m.id} winnable (skill-5 wins >=${bar * 100}%)`, wins / tries >= bar,
|
||||
`${wins}/${tries}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Summary ──────────────────────────────────────────────────────────────────
|
||||
|
||||
console.log('');
|
||||
if (failures) {
|
||||
console.error(`FAILED: ${failures} of ${checks} checks`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log(`All ${checks} checks passed.`);
|
||||
}
|
||||
Loading…
Reference in New Issue