diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs
index b358a64..df99cfa 100644
--- a/scripts/verify-flow.mjs
+++ b/scripts/verify-flow.mjs
@@ -280,6 +280,74 @@ try {
throw new Error(`Expected sortie preparation overlay to close: ${JSON.stringify(campStateAfterSortieClose)}`);
}
+ await page.mouse.click(1120, 38);
+ await page.waitForTimeout(120);
+ await page.mouse.click(938, 596);
+ await page.waitForFunction(() => {
+ const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
+ return activeScenes.includes('StoryScene');
+ });
+
+ for (let i = 0; i < 8; i += 1) {
+ const isSecondBattleActive = await page.evaluate(() => {
+ const state = window.__HEROS_DEBUG__?.battle();
+ return state?.scene === 'BattleScene' && state?.battleId === 'second-battle-yellow-turban-pursuit';
+ });
+
+ if (isSecondBattleActive) {
+ break;
+ }
+
+ await page.keyboard.press('Space');
+ await page.waitForTimeout(220);
+ }
+
+ await page.waitForFunction(() => {
+ const state = window.__HEROS_DEBUG__?.battle();
+ return state?.scene === 'BattleScene' && state?.battleId === 'second-battle-yellow-turban-pursuit' && state?.battleOutcome === null && state?.phase === 'idle';
+ });
+ await page.screenshot({ path: 'dist/verification-second-battle.png', fullPage: true });
+
+ const secondBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
+ const secondEnemies = secondBattleState.units.filter((unit) => unit.faction === 'enemy');
+ const secondEnemyBehaviors = new Set(secondEnemies.map((unit) => unit.ai));
+ if (
+ secondBattleState.camera?.mapWidth !== 24 ||
+ secondBattleState.camera?.mapHeight !== 20 ||
+ secondEnemies.length < 10 ||
+ !secondEnemyBehaviors.has('aggressive') ||
+ !secondEnemyBehaviors.has('guard') ||
+ !secondEnemyBehaviors.has('hold')
+ ) {
+ throw new Error(`Expected expanded second battle map and mixed enemy AI: ${JSON.stringify(secondBattleState)}`);
+ }
+
+ await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory'));
+ await page.waitForFunction(() => {
+ const state = window.__HEROS_DEBUG__?.battle();
+ return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true;
+ });
+
+ await page.mouse.click(738, 642);
+ await page.waitForFunction(() => {
+ const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
+ return activeScenes.includes('CampScene');
+ });
+
+ const campaignSaveAfterSecondBattle = await page.evaluate(() => {
+ const raw = window.localStorage.getItem('heros-web:campaign-state');
+ return raw ? JSON.parse(raw) : undefined;
+ });
+ if (
+ !campaignSaveAfterSecondBattle ||
+ campaignSaveAfterSecondBattle.step !== 'second-camp' ||
+ campaignSaveAfterSecondBattle.latestBattleId !== 'second-battle-yellow-turban-pursuit' ||
+ !campaignSaveAfterSecondBattle.battleHistory?.['second-battle-yellow-turban-pursuit'] ||
+ !campaignSaveAfterSecondBattle.completedCampDialogues?.length
+ ) {
+ throw new Error(`Expected campaign save to persist second battle victory: ${JSON.stringify(campaignSaveAfterSecondBattle)}`);
+ }
+
await page.evaluate(() => window.__HEROS_GAME__?.scene.start('TitleScene'));
await page.waitForFunction(() => {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
@@ -291,25 +359,7 @@ try {
return activeScenes.includes('CampScene');
});
- await page.evaluate(() => window.__HEROS_DEBUG__?.goToBattle());
- await page.waitForFunction(() => {
- const state = window.__HEROS_DEBUG__?.battle();
- return state?.scene === 'BattleScene' && state?.battleOutcome === null && state?.phase === 'idle';
- });
-
- await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('defeat'));
- await page.waitForFunction(() => {
- const state = window.__HEROS_DEBUG__?.battle();
- return state?.battleOutcome === 'defeat' && state?.phase === 'resolved' && state?.resultVisible === true;
- });
-
- const defeatState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
- const liuBei = defeatState.units.find((unit) => unit.id === 'liu-bei');
- if (!liuBei || liuBei.hp > 0) {
- throw new Error(`Expected Liu Bei to be defeated after forced defeat: ${JSON.stringify(liuBei)}`);
- }
-
- console.log(`Verified title-to-battle flow, result states, and debug API at ${targetUrl}`);
+ console.log(`Verified title-to-second-battle flow, result states, and debug API at ${targetUrl}`);
} finally {
await browser?.close();
if (serverProcess && !serverProcess.killed) {
diff --git a/src/assets/images/battle/second-battle-map.svg b/src/assets/images/battle/second-battle-map.svg
new file mode 100644
index 0000000..5e9399b
--- /dev/null
+++ b/src/assets/images/battle/second-battle-map.svg
@@ -0,0 +1,98 @@
+
diff --git a/src/game/data/battles.ts b/src/game/data/battles.ts
index bb9c982..9ea837c 100644
--- a/src/game/data/battles.ts
+++ b/src/game/data/battles.ts
@@ -3,13 +3,17 @@ import {
firstBattleMap,
firstBattleUnits,
firstBattleVictoryPages,
+ secondBattleBonds,
+ secondBattleMap,
+ secondBattleUnits,
+ secondBattleVictoryPages,
type BattleBond,
type BattleMap,
type StoryPage,
type UnitData
} from './scenario';
-export type BattleScenarioId = 'first-battle-zhuo-commandery';
+export type BattleScenarioId = 'first-battle-zhuo-commandery' | 'second-battle-yellow-turban-pursuit';
export type BattleObjectiveKind = 'defeat-leader' | 'keep-unit-alive' | 'secure-terrain' | 'quick-victory';
@@ -101,10 +105,64 @@ export const firstBattleScenario: BattleScenarioDefinition = {
nextCampScene: 'CampScene'
};
+export const secondBattleScenario: BattleScenarioDefinition = {
+ id: 'second-battle-yellow-turban-pursuit',
+ title: '황건 잔당 추격',
+ victoryConditionLabel: '두령 한석 격파',
+ defeatConditionLabel: '유비 퇴각',
+ openingObjectiveLines: [
+ '황건 잔당이 북쪽 마을로 달아났습니다. 두령 한석을 격파하면 적의 퇴로가 무너집니다.',
+ '마을 주변에 적 위협이 남아 있으면 보상이 줄어듭니다. 길목의 습격조를 먼저 정리하십시오.',
+ '12턴 이내에 승리하면 의용군의 추격전 명성이 오릅니다.'
+ ],
+ map: secondBattleMap,
+ units: secondBattleUnits,
+ bonds: secondBattleBonds,
+ mapTextureKey: 'battle-map-second',
+ leaderUnitId: 'pursuit-leader-han-seok',
+ quickVictoryTurnLimit: 12,
+ baseVictoryGold: 420,
+ objectives: [
+ {
+ id: 'leader',
+ kind: 'defeat-leader',
+ label: '두령 한석 격파',
+ rewardGold: 260,
+ unitId: 'pursuit-leader-han-seok'
+ },
+ {
+ id: 'liu-bei',
+ kind: 'keep-unit-alive',
+ label: '유비 생존',
+ rewardGold: 120,
+ unitId: 'liu-bei'
+ },
+ {
+ id: 'village',
+ kind: 'secure-terrain',
+ label: '북쪽 마을 확보',
+ rewardGold: 180,
+ terrain: 'village'
+ },
+ {
+ id: 'quick',
+ kind: 'quick-victory',
+ label: '12턴 이내 승리',
+ rewardGold: 150,
+ maxTurn: 12
+ }
+ ],
+ defeatConditions: [{ kind: 'unit-defeated', unitId: 'liu-bei' }],
+ itemRewards: ['콩 2', '탁주 1', '의용군 명성 +2'],
+ victoryPages: secondBattleVictoryPages,
+ nextCampScene: 'CampScene'
+};
+
export const defaultBattleScenarioId: BattleScenarioId = firstBattleScenario.id;
export const battleScenarios: Record = {
- [firstBattleScenario.id]: firstBattleScenario
+ 'first-battle-zhuo-commandery': firstBattleScenario,
+ 'second-battle-yellow-turban-pursuit': secondBattleScenario
};
export const defaultBattleScenario = battleScenarios[defaultBattleScenarioId];
diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts
index 52db0a7..4c23b25 100644
--- a/src/game/data/scenario.ts
+++ b/src/game/data/scenario.ts
@@ -185,6 +185,69 @@ export const firstBattleVictoryPages: StoryPage[] = [
}
];
+export const secondBattleIntroPages: StoryPage[] = [
+ {
+ id: 'second-pursuit-rumor',
+ bgm: 'militia-theme',
+ chapter: '잔당의 그림자',
+ background: 'story-militia',
+ text: '첫 승리의 기쁨이 채 가시기도 전에, 황건 잔당이 북쪽 길목의 마을을 습격하고 있다는 급보가 도착했다.'
+ },
+ {
+ id: 'second-liu-bei-command',
+ bgm: 'battle-prep',
+ chapter: '추격 명령',
+ background: 'story-liu-bei',
+ speaker: '유비',
+ portrait: 'liuBei',
+ text: '백성이 다시 칼끝에 몰렸다면 우리가 쉬어 갈 곳은 없다. 길을 따라 추격하되, 마을을 먼저 지켜야 한다.'
+ },
+ {
+ id: 'second-guan-yu-vow',
+ bgm: 'battle-prep',
+ chapter: '좁은 길목',
+ background: 'story-three-heroes',
+ speaker: '관우',
+ portrait: 'guanYu',
+ text: '적은 숲과 강을 끼고 물러설 것입니다. 제가 앞길을 열겠습니다. 형님께서는 백성을 살피십시오.'
+ },
+ {
+ id: 'second-zhang-fei-charge',
+ bgm: 'battle-prep',
+ chapter: '의용군 출진',
+ background: 'story-sortie',
+ speaker: '장비',
+ portrait: 'zhangFei',
+ text: '좋다! 숨어 있는 놈들도 모조리 끌어내 주마. 이번에는 도망칠 틈도 주지 않겠다!'
+ }
+];
+
+export const secondBattleVictoryPages: StoryPage[] = [
+ {
+ id: 'second-victory-village',
+ bgm: 'militia-theme',
+ chapter: '마을의 안도',
+ background: 'story-militia',
+ text: '잔당의 불길은 꺼지고, 피난하던 백성들이 하나둘 마을로 돌아왔다. 의용군의 이름은 이제 길목의 장터마다 오르내렸다.'
+ },
+ {
+ id: 'second-victory-liu-bei',
+ bgm: 'militia-theme',
+ chapter: '다음 책임',
+ background: 'story-liu-bei',
+ speaker: '유비',
+ portrait: 'liuBei',
+ text: '우리가 이긴 것은 힘만으로 된 일이 아니다. 백성을 지키겠다는 뜻을 잊지 않는다면, 더 큰 전장도 견딜 수 있을 것이다.'
+ },
+ {
+ id: 'second-victory-next',
+ bgm: 'battle-prep',
+ chapter: '넓어지는 전장',
+ background: 'story-sortie',
+ text: '탁현 인근의 소란은 잦아들었지만, 황건의 난은 아직 천하 곳곳에서 타오르고 있었다. 세 형제의 길은 이제 더 멀리 이어진다.'
+ }
+];
+
export const firstBattleMap: BattleMap = {
width: 20,
height: 18,
@@ -210,6 +273,33 @@ export const firstBattleMap: BattleMap = {
]
};
+export const secondBattleMap: BattleMap = {
+ width: 24,
+ height: 20,
+ terrain: [
+ ['forest', 'forest', 'forest', 'hill', 'plain', 'plain', 'road', 'plain', 'forest', 'hill', 'plain', 'river', 'river', 'plain', 'plain', 'forest', 'forest', 'plain', 'hill', 'hill', 'plain', 'plain', 'forest', 'forest'],
+ ['forest', 'hill', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'hill', 'river', 'river', 'plain', 'forest', 'forest', 'plain', 'plain', 'hill', 'fort', 'fort', 'plain', 'plain', 'forest'],
+ ['plain', 'plain', 'plain', 'forest', 'plain', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'river', 'river', 'road', 'road', 'plain', 'plain', 'plain', 'plain', 'fort', 'plain', 'plain', 'plain', 'forest'],
+ ['plain', 'plain', 'forest', 'forest', 'plain', 'road', 'plain', 'forest', 'forest', 'plain', 'plain', 'river', 'river', 'road', 'plain', 'plain', 'hill', 'hill', 'plain', 'plain', 'plain', 'forest', 'plain', 'plain'],
+ ['plain', 'forest', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'hill', 'river', 'river', 'road', 'plain', 'plain', 'hill', 'cliff', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain'],
+ ['plain', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain', 'river', 'river', 'road', 'plain', 'plain', 'hill', 'plain', 'village', 'plain', 'forest', 'forest', 'plain'],
+ ['plain', 'plain', 'forest', 'plain', 'road', 'plain', 'plain', 'hill', 'hill', 'plain', 'plain', 'plain', 'river', 'river', 'road', 'road', 'plain', 'plain', 'plain', 'village', 'plain', 'plain', 'forest', 'plain'],
+ ['plain', 'forest', 'forest', 'plain', 'road', 'plain', 'plain', 'plain', 'hill', 'plain', 'road', 'road', 'plain', 'river', 'river', 'road', 'plain', 'plain', 'plain', 'plain', 'plain', 'plain', 'hill', 'plain'],
+ ['plain', 'forest', 'plain', 'road', 'road', 'plain', 'hill', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'hill', 'hill', 'plain'],
+ ['plain', 'plain', 'plain', 'road', 'plain', 'plain', 'hill', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'plain', 'hill', 'plain'],
+ ['hill', 'plain', 'plain', 'road', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'village', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'plain', 'plain'],
+ ['hill', 'hill', 'plain', 'road', 'plain', 'forest', 'plain', 'road', 'plain', 'plain', 'village', 'village', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'plain'],
+ ['cliff', 'hill', 'plain', 'road', 'road', 'plain', 'plain', 'road', 'plain', 'plain', 'plain', 'village', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'plain'],
+ ['cliff', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'road', 'plain', 'plain', 'plain', 'plain', 'forest', 'plain', 'river', 'river', 'plain', 'plain', 'plain', 'plain', 'forest', 'plain', 'plain'],
+ ['plain', 'plain', 'camp', 'plain', 'plain', 'road', 'road', 'plain', 'road', 'plain', 'forest', 'plain', 'plain', 'forest', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain'],
+ ['plain', 'camp', 'camp', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'plain', 'forest', 'plain', 'hill'],
+ ['plain', 'camp', 'plain', 'plain', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'plain', 'hill', 'hill'],
+ ['plain', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'plain', 'hill'],
+ ['plain', 'plain', 'road', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'plain'],
+ ['plain', 'road', 'road', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'plain']
+ ]
+};
+
export const firstBattleUnits: UnitData[] = [
{
id: 'liu-bei',
@@ -507,6 +597,249 @@ export const firstBattleUnits: UnitData[] = [
}
];
+const secondBattleAllyPositions: Record = {
+ 'liu-bei': { x: 2, y: 15 },
+ 'guan-yu': { x: 3, y: 15 },
+ 'zhang-fei': { x: 2, y: 16 }
+};
+
+export const secondBattleUnits: UnitData[] = [
+ ...firstBattleUnits
+ .filter((unit) => unit.faction === 'ally')
+ .map((unit) => placeScenarioUnit(unit, secondBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })),
+ {
+ id: 'pursuit-raider-a',
+ name: '황건 추격병',
+ faction: 'enemy',
+ className: '황건병',
+ classKey: 'yellowTurban',
+ level: 2,
+ exp: 12,
+ hp: 23,
+ maxHp: 23,
+ attack: 7,
+ move: 3,
+ stats: { might: 55, intelligence: 34, leadership: 43, agility: 48, luck: 42 },
+ equipment: {
+ weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 12 },
+ armor: { itemId: 'rebel-vest', level: 1, exp: 6 },
+ accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
+ },
+ x: 7,
+ y: 12
+ },
+ {
+ id: 'pursuit-raider-b',
+ name: '황건 습격병',
+ faction: 'enemy',
+ className: '황건병',
+ classKey: 'yellowTurban',
+ level: 2,
+ exp: 12,
+ hp: 23,
+ maxHp: 23,
+ attack: 7,
+ move: 3,
+ stats: { might: 54, intelligence: 35, leadership: 42, agility: 47, luck: 41 },
+ equipment: {
+ weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 12 },
+ armor: { itemId: 'rebel-vest', level: 1, exp: 6 },
+ accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
+ },
+ x: 10,
+ y: 11
+ },
+ {
+ id: 'pursuit-bandit-a',
+ name: '산길 복병',
+ faction: 'enemy',
+ className: '산적',
+ classKey: 'bandit',
+ level: 2,
+ exp: 14,
+ hp: 24,
+ maxHp: 24,
+ attack: 8,
+ move: 3,
+ stats: { might: 58, intelligence: 32, leadership: 40, agility: 53, luck: 44 },
+ equipment: {
+ weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 13 },
+ armor: { itemId: 'rebel-vest', level: 1, exp: 7 },
+ accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
+ },
+ x: 6,
+ y: 8
+ },
+ {
+ id: 'pursuit-archer-a',
+ name: '황건 궁병',
+ faction: 'enemy',
+ className: '궁병',
+ classKey: 'archer',
+ level: 2,
+ exp: 16,
+ hp: 20,
+ maxHp: 20,
+ attack: 8,
+ move: 3,
+ stats: { might: 54, intelligence: 43, leadership: 45, agility: 58, luck: 45 },
+ equipment: {
+ weapon: { itemId: 'short-bow', level: 1, exp: 16 },
+ armor: { itemId: 'cloth-armor', level: 1, exp: 7 },
+ accessory: { itemId: 'wind-quiver', level: 1, exp: 0 }
+ },
+ x: 12,
+ y: 10
+ },
+ {
+ id: 'pursuit-cavalry-a',
+ name: '황건 기병',
+ faction: 'enemy',
+ className: '경기병',
+ classKey: 'cavalry',
+ level: 3,
+ exp: 18,
+ hp: 26,
+ maxHp: 26,
+ attack: 9,
+ move: 5,
+ stats: { might: 62, intelligence: 35, leadership: 48, agility: 65, luck: 46 },
+ equipment: {
+ weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 18 },
+ armor: { itemId: 'rebel-vest', level: 1, exp: 8 },
+ accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
+ },
+ x: 15,
+ y: 7
+ },
+ {
+ id: 'pursuit-raider-c',
+ name: '마을 습격병',
+ faction: 'enemy',
+ className: '황건병',
+ classKey: 'yellowTurban',
+ level: 2,
+ exp: 13,
+ hp: 24,
+ maxHp: 24,
+ attack: 7,
+ move: 3,
+ stats: { might: 56, intelligence: 34, leadership: 43, agility: 49, luck: 42 },
+ equipment: {
+ weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 13 },
+ armor: { itemId: 'rebel-vest', level: 1, exp: 7 },
+ accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
+ },
+ x: 17,
+ y: 6
+ },
+ {
+ id: 'pursuit-archer-b',
+ name: '황건 궁병',
+ faction: 'enemy',
+ className: '궁병',
+ classKey: 'archer',
+ level: 3,
+ exp: 18,
+ hp: 21,
+ maxHp: 21,
+ attack: 8,
+ move: 3,
+ stats: { might: 55, intelligence: 45, leadership: 46, agility: 59, luck: 47 },
+ equipment: {
+ weapon: { itemId: 'short-bow', level: 1, exp: 18 },
+ armor: { itemId: 'cloth-armor', level: 1, exp: 8 },
+ accessory: { itemId: 'wind-quiver', level: 1, exp: 0 }
+ },
+ x: 20,
+ y: 5
+ },
+ {
+ id: 'pursuit-cavalry-b',
+ name: '황건 기병',
+ faction: 'enemy',
+ className: '경기병',
+ classKey: 'cavalry',
+ level: 3,
+ exp: 19,
+ hp: 27,
+ maxHp: 27,
+ attack: 9,
+ move: 5,
+ stats: { might: 63, intelligence: 35, leadership: 49, agility: 66, luck: 47 },
+ equipment: {
+ weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 19 },
+ armor: { itemId: 'rebel-vest', level: 1, exp: 8 },
+ accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
+ },
+ x: 18,
+ y: 3
+ },
+ {
+ id: 'pursuit-guard-a',
+ name: '한석 친위병',
+ faction: 'enemy',
+ className: '황건병',
+ classKey: 'yellowTurban',
+ level: 3,
+ exp: 16,
+ hp: 25,
+ maxHp: 25,
+ attack: 8,
+ move: 3,
+ stats: { might: 58, intelligence: 36, leadership: 48, agility: 50, luck: 44 },
+ equipment: {
+ weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 16 },
+ armor: { itemId: 'rebel-vest', level: 1, exp: 8 },
+ accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
+ },
+ x: 19,
+ y: 2
+ },
+ {
+ id: 'pursuit-guard-b',
+ name: '한석 친위병',
+ faction: 'enemy',
+ className: '황건병',
+ classKey: 'yellowTurban',
+ level: 3,
+ exp: 16,
+ hp: 25,
+ maxHp: 25,
+ attack: 8,
+ move: 3,
+ stats: { might: 58, intelligence: 36, leadership: 48, agility: 50, luck: 44 },
+ equipment: {
+ weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 16 },
+ armor: { itemId: 'rebel-vest', level: 1, exp: 8 },
+ accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
+ },
+ x: 21,
+ y: 2
+ },
+ {
+ id: 'pursuit-leader-han-seok',
+ name: '두령 한석',
+ faction: 'enemy',
+ className: '두령',
+ classKey: 'rebelLeader',
+ level: 4,
+ exp: 24,
+ hp: 38,
+ maxHp: 38,
+ attack: 11,
+ move: 3,
+ stats: { might: 68, intelligence: 48, leadership: 64, agility: 54, luck: 52 },
+ equipment: {
+ weapon: { itemId: 'leader-axe', level: 1, exp: 24 },
+ armor: { itemId: 'lamellar-armor', level: 1, exp: 14 },
+ accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
+ },
+ x: 20,
+ y: 1
+ }
+];
+
export const firstBattleBonds: BattleBond[] = [
{
id: 'liu-bei__guan-yu',
@@ -533,3 +866,24 @@ export const firstBattleBonds: BattleBond[] = [
description: '둘이 같은 전선을 압박하면 연계 공격의 기초 확률이 오른다.'
}
];
+
+export const secondBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario);
+
+function placeScenarioUnit(unit: UnitData, position: { x: number; y: number }): UnitData {
+ return {
+ ...cloneUnitForScenario(unit),
+ x: position.x,
+ y: position.y
+ };
+}
+
+function cloneUnitForScenario(unit: UnitData): UnitData {
+ return JSON.parse(JSON.stringify(unit)) as UnitData;
+}
+
+function cloneBattleBondForScenario(bond: BattleBond): BattleBond {
+ return {
+ ...bond,
+ unitIds: [...bond.unitIds] as [string, string]
+ };
+}
diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts
index c1f3998..7c883b8 100644
--- a/src/game/scenes/BattleScene.ts
+++ b/src/game/scenes/BattleScene.ts
@@ -18,6 +18,7 @@ import {
markCampaignStep,
saveCampaignState,
setFirstBattleReport,
+ type CampaignState,
type CampaignStep
} from '../state/campaignState';
import { palette } from '../ui/palette';
@@ -36,7 +37,18 @@ const unitTexture: Record = {
'rebel-cavalry-a': 'unit-rebel-cavalry',
'rebel-cavalry-b': 'unit-rebel-cavalry',
'rebel-archer-b': 'unit-rebel-archer',
- 'rebel-leader': 'unit-rebel-leader'
+ 'rebel-leader': 'unit-rebel-leader',
+ 'pursuit-raider-a': 'unit-rebel',
+ 'pursuit-raider-b': 'unit-rebel',
+ 'pursuit-bandit-a': 'unit-rebel',
+ 'pursuit-archer-a': 'unit-rebel-archer',
+ 'pursuit-cavalry-a': 'unit-rebel-cavalry',
+ 'pursuit-raider-c': 'unit-rebel',
+ 'pursuit-archer-b': 'unit-rebel-archer',
+ 'pursuit-cavalry-b': 'unit-rebel-cavalry',
+ 'pursuit-guard-a': 'unit-rebel',
+ 'pursuit-guard-b': 'unit-rebel',
+ 'pursuit-leader-han-seok': 'unit-rebel-leader'
};
const unitFrameRows: Record = {
@@ -459,7 +471,18 @@ const enemyAiByUnitId: Record = {
'rebel-cavalry-a': 'aggressive',
'rebel-cavalry-b': 'aggressive',
'rebel-archer-b': 'hold',
- 'rebel-leader': 'guard'
+ 'rebel-leader': 'guard',
+ 'pursuit-raider-a': 'aggressive',
+ 'pursuit-raider-b': 'guard',
+ 'pursuit-bandit-a': 'aggressive',
+ 'pursuit-archer-a': 'hold',
+ 'pursuit-cavalry-a': 'aggressive',
+ 'pursuit-raider-c': 'aggressive',
+ 'pursuit-archer-b': 'hold',
+ 'pursuit-cavalry-b': 'aggressive',
+ 'pursuit-guard-a': 'guard',
+ 'pursuit-guard-b': 'guard',
+ 'pursuit-leader-han-seok': 'guard'
};
const defaultEnemyAiByClass: Partial> = {
@@ -611,8 +634,8 @@ export class BattleScene extends Phaser.Scene {
}
create() {
- this.resetBattleData();
const campaign = getCampaignState();
+ this.resetBattleData(campaign);
if (campaign.step === 'new' || campaign.step === 'prologue' || campaign.step === 'first-battle') {
markCampaignStep('first-battle');
}
@@ -662,9 +685,42 @@ export class BattleScene extends Phaser.Scene {
this.renderRosterPanel('ally', '행동할 장수를 선택하세요.');
}
- private resetBattleData() {
- battleUnits.splice(0, battleUnits.length, ...initialBattleUnits.map(cloneUnitData));
- battleBonds.splice(0, battleBonds.length, ...initialBattleBonds.map(cloneBattleBond));
+ private resetBattleData(campaign?: CampaignState) {
+ battleUnits.splice(0, battleUnits.length, ...initialBattleUnits.map((unit) => this.applyCampaignUnitProgress(unit, campaign)));
+ battleBonds.splice(0, battleBonds.length, ...initialBattleBonds.map((bond) => this.applyCampaignBondProgress(bond, campaign)));
+ initialBattleUnits = battleUnits.map(cloneUnitData);
+ initialBattleBonds = battleBonds.map(cloneBattleBond);
+ }
+
+ private applyCampaignUnitProgress(unit: UnitData, campaign?: CampaignState) {
+ const cloned = cloneUnitData(unit);
+ const progress = campaign?.roster.find((candidate) => candidate.id === unit.id);
+ if (!progress || unit.faction !== 'ally') {
+ return cloned;
+ }
+
+ return {
+ ...cloned,
+ level: progress.level,
+ exp: progress.exp,
+ hp: Math.max(1, Math.min(progress.hp, progress.maxHp)),
+ maxHp: progress.maxHp,
+ equipment: JSON.parse(JSON.stringify(progress.equipment)) as UnitData['equipment']
+ };
+ }
+
+ private applyCampaignBondProgress(bond: BattleBond, campaign?: CampaignState) {
+ const cloned = cloneBattleBond(bond);
+ const progress = campaign?.bonds.find((candidate) => candidate.id === bond.id);
+ if (!progress) {
+ return cloned;
+ }
+
+ return {
+ ...cloned,
+ level: progress.level,
+ exp: progress.exp
+ };
}
private createLayout(width: number, height: number): BattleLayout {
@@ -2054,7 +2110,7 @@ export class BattleScene extends Phaser.Scene {
}
this.addResultButton('다시 하기', left + panelWidth - 276, top + 612, 124, () => {
soundDirector.playSelect();
- this.scene.restart();
+ this.scene.restart({ battleId: battleScenario.id });
}, depth + 3);
this.addResultButton('타이틀로', left + panelWidth - 140, top + 612, 124, () => {
soundDirector.playSelect();
@@ -3149,7 +3205,7 @@ export class BattleScene extends Phaser.Scene {
}
private showOpeningBattleEvent() {
- this.triggerBattleEvent('opening', '첫 전투 목표', battleScenario.openingObjectiveLines);
+ this.triggerBattleEvent('opening', '전투 목표', battleScenario.openingObjectiveLines);
}
private triggerBattleEvent(key: string, title: string, lines: string[]) {
@@ -6463,8 +6519,8 @@ export class BattleScene extends Phaser.Scene {
x: unit.x,
y: unit.y,
acted: this.actedUnitIds.has(unit.id),
- animating: this.unitViews.get(unit.id)?.sprite.anims.isPlaying ?? false,
- animationKey: this.unitViews.get(unit.id)?.sprite.anims.currentAnim?.key ?? null
+ animating: this.unitViews.get(unit.id)?.sprite.anims?.isPlaying ?? false,
+ animationKey: this.unitViews.get(unit.id)?.sprite.anims?.currentAnim?.key ?? null
})),
bonds: Array.from(this.bondStates.values()).map((bond) => ({
id: bond.id,
diff --git a/src/game/scenes/BootScene.ts b/src/game/scenes/BootScene.ts
index 083d798..e992b61 100644
--- a/src/game/scenes/BootScene.ts
+++ b/src/game/scenes/BootScene.ts
@@ -1,5 +1,6 @@
import Phaser from 'phaser';
import firstBattleMapUrl from '../../assets/images/battle/first-battle-map.png';
+import secondBattleMapUrl from '../../assets/images/battle/second-battle-map.svg';
import guanYuPortraitUrl from '../../assets/images/portraits/guan-yu.png';
import liuBeiPortraitUrl from '../../assets/images/portraits/liu-bei.png';
import zhangFeiPortraitUrl from '../../assets/images/portraits/zhang-fei.png';
@@ -60,6 +61,7 @@ export class BootScene extends Phaser.Scene {
this.load.image('story-militia', storyMilitiaUrl);
this.load.image('story-sortie', storySortieUrl);
this.load.image('battle-map-first', firstBattleMapUrl);
+ this.load.image('battle-map-second', secondBattleMapUrl);
this.load.image('portrait-liu-bei', liuBeiPortraitUrl);
this.load.image('portrait-guan-yu', guanYuPortraitUrl);
this.load.image('portrait-zhang-fei', zhangFeiPortraitUrl);
diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts
index 226d033..b6e6c3e 100644
--- a/src/game/scenes/CampScene.ts
+++ b/src/game/scenes/CampScene.ts
@@ -2,8 +2,8 @@ import Phaser from 'phaser';
import { soundDirector } from '../audio/SoundDirector';
import { equipmentExpToNext, equipmentSlotLabels, equipmentSlots, getItem, type EquipmentSlot } from '../data/battleItems';
import { getUnitClass } from '../data/battleRules';
-import { defaultBattleScenario } from '../data/battles';
-import { firstBattleBonds, firstBattleUnits, firstBattleVictoryPages, type PortraitKey, type UnitData } from '../data/scenario';
+import { defaultBattleScenario, secondBattleScenario } from '../data/battles';
+import { firstBattleBonds, firstBattleUnits, secondBattleIntroPages, type PortraitKey, type UnitData } from '../data/scenario';
import {
applyCampBondExp,
getCampaignState,
@@ -335,10 +335,11 @@ export class CampScene extends Phaser.Scene {
bg.setDepth(depth);
bg.setStrokeStyle(1, palette.blue, 0.48);
- this.trackSortie(this.add.text(x + 18, y + 14, '다음 전장', this.textStyle(18, '#f2e3bf', true))).setDepth(depth + 1);
- this.trackSortie(this.add.text(x + 18, y + 48, '황건 잔당 추격', this.textStyle(23, '#d8b15f', true))).setDepth(depth + 1);
+ const briefing = this.nextSortieBriefing();
+ this.trackSortie(this.add.text(x + 18, y + 14, briefing.eyebrow, this.textStyle(18, '#f2e3bf', true))).setDepth(depth + 1);
+ this.trackSortie(this.add.text(x + 18, y + 48, briefing.title, this.textStyle(23, '#d8b15f', true))).setDepth(depth + 1);
this.trackSortie(
- this.add.text(x + 18, y + 84, '탁현을 물러난 황건 잔당이 인근 마을을 위협하고 있습니다. 의용군은 첫 승리의 기세를 몰아 추격에 나섭니다.', {
+ this.add.text(x + 18, y + 84, briefing.description, {
...this.textStyle(14, '#d4dce6'),
wordWrap: { width: width - 36, useAdvancedWrap: true },
lineSpacing: 4
@@ -397,10 +398,27 @@ export class CampScene extends Phaser.Scene {
bg.setStrokeStyle(1, palette.gold, 0.4);
const completedDialogues = this.completedCampDialogues().length;
const inventory = this.inventoryLabels().join(', ');
- this.trackSortie(this.add.text(x + 16, y + 10, `예상 보상: 군자금, 소모품, 의용군 명성`, this.textStyle(13, '#f2e3bf', true))).setDepth(depth + 1);
+ const reward = this.campaign?.latestBattleId === secondBattleScenario.id ? '예상 보상: 다음 장 개방' : '예상 보상: 군자금, 소모품, 의용군 명성';
+ this.trackSortie(this.add.text(x + 16, y + 10, reward, this.textStyle(13, '#f2e3bf', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 16, y + 30, `현재 준비: 대화 ${completedDialogues}/${campDialogues.length} · 보유 ${inventory || '없음'}`, this.textStyle(12, '#d4dce6'))).setDepth(depth + 1);
}
+ private nextSortieBriefing() {
+ if (this.campaign?.latestBattleId === secondBattleScenario.id) {
+ return {
+ eyebrow: '다음 이야기',
+ title: '넓어지는 전장',
+ description: '황건 잔당 추격을 마친 의용군은 탁현을 넘어 더 큰 난세의 흐름을 마주합니다. 다음 장으로 향하기 전 전투 결과를 정리합니다.'
+ };
+ }
+
+ return {
+ eyebrow: '다음 전장',
+ title: secondBattleScenario.title,
+ description: '탁현을 물러난 황건 잔당이 인근 마을을 위협하고 있습니다. 의용군은 첫 승리의 기세를 몰아 추격에 나섭니다.'
+ };
+ }
+
private sortieChecklist(): SortieChecklistItem[] {
const units = this.currentUnits().filter((unit) => unit.faction === 'ally');
const liuBei = units.find((unit) => unit.id === 'liu-bei');
@@ -453,8 +471,17 @@ export class CampScene extends Phaser.Scene {
}
private startVictoryStory() {
- markCampaignStep('first-victory-story');
- this.scene.start('StoryScene', { pages: firstBattleVictoryPages, nextScene: 'TitleScene' });
+ if (this.campaign?.latestBattleId === secondBattleScenario.id) {
+ this.scene.start('StoryScene', { pages: secondBattleScenario.victoryPages, nextScene: 'TitleScene' });
+ return;
+ }
+
+ markCampaignStep('second-battle');
+ this.scene.start('StoryScene', {
+ pages: secondBattleIntroPages,
+ nextScene: 'BattleScene',
+ nextSceneData: { battleId: secondBattleScenario.id }
+ });
}
private hideSortiePrep() {
diff --git a/src/game/scenes/StoryScene.ts b/src/game/scenes/StoryScene.ts
index 1facd44..2643d01 100644
--- a/src/game/scenes/StoryScene.ts
+++ b/src/game/scenes/StoryScene.ts
@@ -14,12 +14,14 @@ type AlphaObject = Phaser.GameObjects.Image | Phaser.GameObjects.Rectangle | Pha
type StorySceneData = {
pages?: StoryPage[];
nextScene?: string;
+ nextSceneData?: Record;
};
export class StoryScene extends Phaser.Scene {
private pageIndex = 0;
private pages: StoryPage[] = prologuePages;
private nextScene = 'BattleScene';
+ private nextSceneData?: Record;
private transitioning = false;
private background?: Phaser.GameObjects.Image;
private chapterText?: Phaser.GameObjects.Text;
@@ -37,6 +39,7 @@ export class StoryScene extends Phaser.Scene {
init(data?: StorySceneData) {
this.pages = data?.pages?.length ? data.pages : prologuePages;
this.nextScene = data?.nextScene ?? 'BattleScene';
+ this.nextSceneData = data?.nextSceneData;
this.pageIndex = 0;
this.transitioning = false;
}
@@ -223,7 +226,7 @@ export class StoryScene extends Phaser.Scene {
}
if (this.pageIndex >= this.pages.length - 1) {
- this.scene.start(this.nextScene);
+ this.scene.start(this.nextScene, this.nextSceneData);
return;
}
diff --git a/src/game/scenes/TitleScene.ts b/src/game/scenes/TitleScene.ts
index da2cf75..5799b45 100644
--- a/src/game/scenes/TitleScene.ts
+++ b/src/game/scenes/TitleScene.ts
@@ -1,6 +1,7 @@
import Phaser from 'phaser';
import { soundDirector } from '../audio/SoundDirector';
import { firstBattleVictoryPages } from '../data/scenario';
+import { secondBattleScenario } from '../data/battles';
import { hasCampaignSave, loadCampaignState, startNewCampaign } from '../state/campaignState';
import { palette } from '../ui/palette';
@@ -298,7 +299,7 @@ export class TitleScene extends Phaser.Scene {
soundDirector.playSelect();
const campaign = loadCampaignState();
- if (campaign.step === 'first-camp') {
+ if (campaign.step === 'first-camp' || campaign.step === 'second-camp') {
this.scene.start('CampScene');
return;
}
@@ -308,6 +309,11 @@ export class TitleScene extends Phaser.Scene {
return;
}
+ if (campaign.step === 'second-battle') {
+ this.scene.start('BattleScene', { battleId: secondBattleScenario.id });
+ return;
+ }
+
if (campaign.step === 'first-victory-story') {
this.scene.start('StoryScene', { pages: firstBattleVictoryPages, nextScene: 'TitleScene' });
return;
diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts
index 3c71a45..e2f1428 100644
--- a/src/game/state/campaignState.ts
+++ b/src/game/state/campaignState.ts
@@ -36,7 +36,7 @@ export type FirstBattleReport = {
createdAt: string;
};
-export type CampaignStep = 'new' | 'prologue' | 'first-battle' | 'first-camp' | 'first-victory-story';
+export type CampaignStep = 'new' | 'prologue' | 'first-battle' | 'first-camp' | 'first-victory-story' | 'second-battle' | 'second-camp';
export type CampaignUnitProgressSnapshot = {
unitId: string;
@@ -186,13 +186,18 @@ export function setFirstBattleReport(report: FirstBattleReport) {
const reportClone = cloneReport(report);
const battleId = reportClone.battleId;
const previousSettlement = state.battleHistory[battleId];
+ const completedCampDialogues =
+ battleId === 'first-battle-zhuo-commandery' ? [...reportClone.completedCampDialogues] : [...state.completedCampDialogues];
+ reportClone.completedCampDialogues = completedCampDialogues;
state.firstBattleReport = reportClone;
- state.step = reportClone.outcome === 'victory' ? 'first-camp' : 'first-battle';
+ const victoryStep: CampaignStep = battleId === 'second-battle-yellow-turban-pursuit' ? 'second-camp' : 'first-camp';
+ const retryStep: CampaignStep = battleId === 'second-battle-yellow-turban-pursuit' ? 'second-battle' : 'first-battle';
+ state.step = reportClone.outcome === 'victory' ? victoryStep : retryStep;
state.gold = Math.max(0, state.gold - (previousSettlement?.rewardGold ?? 0) + reportClone.rewardGold);
state.roster = reportClone.units.filter((unit) => unit.faction === 'ally').map(cloneUnit);
state.bonds = reportClone.bonds.map(cloneBondSnapshot);
- state.completedCampDialogues = [...reportClone.completedCampDialogues];
+ state.completedCampDialogues = completedCampDialogues;
state.inventory = applyRewardDelta(state.inventory, previousSettlement?.itemRewards ?? [], -1);
state.inventory = applyRewardDelta(state.inventory, reportClone.itemRewards, 1);
state.battleHistory[battleId] = createBattleSettlement(reportClone);