From bf71bfc3c7b9753068b75386305a1d682eecde18 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sun, 19 Jul 2026 10:55:18 -0600 Subject: [PATCH] feat(advance-wars): add unit progression system with mission-based unlocks Introduce a unit unlock system that progressively grants access to new unit types as the player advances through the campaign. Each mission specifies which units each army can build, starting with basic units and unlocking MD Tanks, Rockets, Anti-Air, Missiles, Lander, Cruiser, and eventually the full armory. - Add unit sprite sheet (advancewars-units.png) and artwork config - Implement unlock validation in production and build options - Add tutorial dialogue from Ethel explaining new mechanics and units - Balance AI difficulty by reducing skill levels and aggression - Adjust capture weights for more balanced AI behavior - Add introductory city capture mechanic in first mission - Update verification tool to support unlockedUnits --- data/advancewars-artwork.json | 2 +- data/advancewars-campaign.json | 88 ++++++++++++++++++----- src/games/advancewars/AdvanceWarsGame.js | 2 + src/games/advancewars/AdvanceWarsLogic.js | 5 ++ tools/verifyAdvanceWars.js | 4 +- 5 files changed, 81 insertions(+), 20 deletions(-) diff --git a/data/advancewars-artwork.json b/data/advancewars-artwork.json index cf82118..d20851a 100644 --- a/data/advancewars-artwork.json +++ b/data/advancewars-artwork.json @@ -14,7 +14,7 @@ }, "unitSheet": { "key": "advancewars-units", - "path": null, + "path": "assets/images/advancewars/advancewars-units.png", "frameWidth": 48, "frameHeight": 64 }, diff --git a/data/advancewars-campaign.json b/data/advancewars-campaign.json index 5da82e1..7bc6c38 100644 --- a/data/advancewars-campaign.json +++ b/data/advancewars-campaign.json @@ -19,7 +19,7 @@ }, "aiProfile": { "skill": 1, - "aggression": 0.4, + "aggression": 0.25, "captureWeight": 0.0 }, "briefing": [ @@ -41,7 +41,12 @@ { "speaker": "ethel", "mood": "idle", - "text": "Deary, just remember: tanks are strong, artillery shoots from afar, and infantry are brave little things. Destroy every enemy unit and the day is ours." + "text": "Oh, and dear — see that empty city in the middle of the field? Walk infantry onto it and choose Capture to claim it. It won't win the day, but every property we hold pays the bills." + }, + { + "speaker": "ethel", + "mood": "idle", + "text": "Now remember: tanks are strong, artillery shoots from afar, and infantry are brave little things. Destroy every enemy unit and the day is ours." } ], "victoryLine": { @@ -62,7 +67,7 @@ "w.q..rr..c.w", "....mrr.....", "..rrrrrrrr..", - "....w..m....", + "....wc.m....", ".rrrrrrrrrr.", ".c.......q..", "....mm......", @@ -163,8 +168,12 @@ "aiProfile": { "skill": 1, "aggression": 0.3, - "captureWeight": 0.6 + "captureWeight": 0.5 }, + "unlockedUnits": [ + ["infantry", "mech", "recon", "tank", "apc", "artillery"], + ["infantry", "mech", "recon", "tank", "apc", "artillery"] + ], "briefing": [ { "speaker": "ethel", @@ -290,8 +299,8 @@ "type": "rout" }, "aiProfile": { - "skill": 2, - "aggression": 0.5, + "skill": 1, + "aggression": 0.35, "captureWeight": 0.0 }, "briefing": [ @@ -453,10 +462,14 @@ "type": "hq" }, "aiProfile": { - "skill": 2, - "aggression": 0.4, - "captureWeight": 0.3 + "skill": 1, + "aggression": 0.35, + "captureWeight": 0.25 }, + "unlockedUnits": [ + ["infantry", "mech", "recon", "tank", "apc", "artillery", "mdtank"], + ["infantry", "mech", "recon", "tank", "apc", "artillery"] + ], "briefing": [ { "speaker": "jeff", @@ -477,6 +490,11 @@ "speaker": "jeff", "mood": "idle", "text": "Come on over whenever you're ready. I'll be here. Sipping lemonade. Aiming." + }, + { + "speaker": "ethel", + "mood": "idle", + "text": "One more thing, dear \u2014 headquarters is releasing the MD Tank to your motor pool. It costs more to build, but it hits harder and shrugs off return fire. Perfect for cracking a dug-in position like Jeff's." } ], "victoryLine": { @@ -656,10 +674,14 @@ "type": "rout" }, "aiProfile": { - "skill": 3, - "aggression": 0.5, - "captureWeight": 0.4 + "skill": 2, + "aggression": 0.4, + "captureWeight": 0.35 }, + "unlockedUnits": [ + ["infantry", "mech", "recon", "tank", "apc", "artillery", "mdtank", "rockets", "antiair"], + ["infantry", "mech", "recon", "tank", "apc", "artillery", "mdtank"] + ], "briefing": [ { "speaker": "brad", @@ -675,6 +697,11 @@ "speaker": "natasha", "mood": "idle", "text": "Then we finish this before the snow falls. All units \u2014 forward!" + }, + { + "speaker": "ethel", + "mood": "idle", + "text": "Before you head out: two new units are up for grabs. Rockets fire clear across the field without ever being fired back at, and the Anti-Air truck keeps our tanks safe from anything that flies. Build wisely." } ], "victoryLine": { @@ -804,10 +831,14 @@ "type": "rout" }, "aiProfile": { - "skill": 3, - "aggression": 0.45, + "skill": 2, + "aggression": 0.4, "captureWeight": 0.3 }, + "unlockedUnits": [ + ["infantry", "mech", "recon", "tank", "apc", "artillery", "mdtank", "rockets", "antiair", "missiles"], + ["infantry", "mech", "recon", "tank", "apc", "artillery", "mdtank", "rockets", "antiair"] + ], "briefing": [ { "speaker": "ethel", @@ -823,6 +854,11 @@ "speaker": "maurice", "mood": "idle", "text": "Can't see a thing out there... Bucky says use recon units. Bucky's smart. Big vision range on those." + }, + { + "speaker": "ethel", + "mood": "idle", + "text": "And dear, we've cleared the Missile Launcher for production. It hits just as far as rockets, but its crew keeps a much longer eye on things \u2014 perfect for punching through all this fog." } ], "victoryLine": { @@ -942,8 +978,12 @@ "aiProfile": { "skill": 2, "aggression": 0.4, - "captureWeight": 0.4 + "captureWeight": 0.35 }, + "unlockedUnits": [ + ["infantry", "mech", "recon", "tank", "apc", "artillery", "mdtank", "rockets", "antiair", "missiles", "lander", "cruiser"], + ["infantry", "mech", "recon", "tank", "apc", "artillery", "mdtank", "rockets", "antiair", "missiles"] + ], "briefing": [ { "speaker": "schooner", @@ -964,6 +1004,11 @@ "speaker": "ethel", "mood": "idle", "text": "Landers carry two land units across the sea and unload on those pale shoal tiles. Guard them \u2014 they can't fight back!" + }, + { + "speaker": "ethel", + "mood": "idle", + "text": "Oh \u2014 and both the Lander and the Cruiser are yours to build now, not just borrow. Keep that channel escorted and the beachhead is ours." } ], "victoryLine": { @@ -1145,8 +1190,8 @@ "type": "rout" }, "aiProfile": { - "skill": 3, - "aggression": 0.55, + "skill": 2, + "aggression": 0.45, "captureWeight": 0.0 }, "briefing": [ @@ -1285,6 +1330,10 @@ "aggression": 0.5, "captureWeight": 0.4 }, + "unlockedUnits": [ + ["infantry", "mech", "recon", "tank", "mdtank", "apc", "artillery", "rockets", "antiair", "missiles", "fighter", "bomber", "bcopter", "tcopter", "battleship", "cruiser", "lander", "sub"], + ["infantry", "mech", "recon", "tank", "apc", "artillery", "mdtank", "rockets", "antiair", "missiles", "lander", "cruiser"] + ], "briefing": [ { "speaker": "schooner", @@ -1300,6 +1349,11 @@ "speaker": "ethel", "mood": "idle", "text": "You can win on land or at sea, dear \u2014 but don't ignore either. A battleship parked off your coast ruins everyone's morning." + }, + { + "speaker": "ethel", + "mood": "idle", + "text": "Last thing, dear \u2014 the whole armory's open to you now. Battleships, submarines, even our air wing are standing by. Whatever the enemy throws at us, we can answer in kind." } ], "victoryLine": { diff --git a/src/games/advancewars/AdvanceWarsGame.js b/src/games/advancewars/AdvanceWarsGame.js index 8637747..15a4d28 100644 --- a/src/games/advancewars/AdvanceWarsGame.js +++ b/src/games/advancewars/AdvanceWarsGame.js @@ -138,6 +138,7 @@ export default class AdvanceWarsGame extends Phaser.Scene { enemyCos: [meta.cfg.enemyCo], fog: meta.cfg.fog, production: true, + unlockedUnits: undefined, startFunds: [3000, 3000], objective: { type: 'rout' }, dayLimit: 60, @@ -157,6 +158,7 @@ export default class AdvanceWarsGame extends Phaser.Scene { cos: [mission.playerCo, ...mission.enemyCos], fog: mission.fog, production: mission.production, + unlockedUnits: mission.unlockedUnits, startFunds: mission.startFunds, objective: mission.objective, dayLimit: mission.dayLimit, diff --git a/src/games/advancewars/AdvanceWarsLogic.js b/src/games/advancewars/AdvanceWarsLogic.js index de78bad..7dbec3c 100644 --- a/src/games/advancewars/AdvanceWarsLogic.js +++ b/src/games/advancewars/AdvanceWarsLogic.js @@ -57,6 +57,7 @@ export function createGame(rules, mapDef, opts = {}) { turn: 0, fog: !!opts.fog, production: opts.production !== false, + unlockedUnits: opts.unlockedUnits, rngState: (opts.seed ?? 1) >>> 0, w, h, terrain, @@ -685,6 +686,8 @@ export function applyAction(state, rules, action) { 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'); + const allowed = state.unlockedUnits?.[army]; + if (allowed && !allowed.includes(unitType)) return fail('unit not unlocked'); if (unitAt(state, x, y)) return fail('tile occupied'); const fx = coEffects(rules, state, army); const cost = Math.round(spec.cost * (fx.costMult ?? 1)); @@ -922,8 +925,10 @@ export function buildOptions(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); + const allowed = state.unlockedUnits?.[army]; return rules.units .filter((u) => (u.domain === 'air' ? 'air' : u.domain === 'sea' ? 'sea' : 'land') === t.builds) + .filter((u) => !allowed || allowed.includes(u.id)) .map((u) => ({ type: u.id, cost: Math.round(u.cost * (fx.costMult ?? 1)), diff --git a/tools/verifyAdvanceWars.js b/tools/verifyAdvanceWars.js index 19da54e..46cdea1 100644 --- a/tools/verifyAdvanceWars.js +++ b/tools/verifyAdvanceWars.js @@ -691,7 +691,7 @@ console.log('Campaign data'); try { state = Logic.createGame(rules, m.map, { cos: [m.playerCo, ...m.enemyCos], - fog: m.fog, production: m.production, + fog: m.fog, production: m.production, unlockedUnits: m.unlockedUnits, startFunds: m.startFunds, objective: m.objective, dayLimit: m.dayLimit, seed: 42, }); } catch (err) { @@ -796,7 +796,7 @@ console.log('AI'); 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, + fog: m.fog, production: m.production, unlockedUnits: m.unlockedUnits, startFunds: m.startFunds, objective: m.objective, dayLimit: m.dayLimit, seed: 500 + i, }); let guard = (m.dayLimit ?? 60) * (1 + m.enemyCos.length) + 8;