diff --git a/docs/roadmap.md b/docs/roadmap.md
index 8690cad..c6f5057 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -2,13 +2,14 @@
## Initial Goal
-Build a small complete tactical RPG loop:
+Build a small complete tactical RPG loop that can grow into a longer Romance of the Three Kingdoms campaign:
1. Title screen
2. Prologue story
3. First battle briefing
4. First battle
5. Battle result and save point
+6. Repeatable story -> camp -> battle expansion path
## Current Slice
@@ -17,15 +18,17 @@ Build a small complete tactical RPG loop:
- Cinematic prologue pages with high-resolution story backgrounds, character portraits, non-black scene transitions, and scenario data
- File-based original soft stereo orchestral BGM loops for title, prologue, oath, militia, and battle-prep scenes
- First battle scene with a high-resolution battlefield background, large 12x12 tactical grid, high-detail pixel-style unit sprites, class and terrain affinity rules, ally/enemy roster tabs, unit detail stat panels, weapon/armor/accessory equipment growth, terrain-cost movement range preview, click-to-move unit movement, cursor-adjacent post-move command popup, right-click move cancel, right-click tactical menu, turn ending/reset, resonance bond tracking, and grayscale acted-unit feedback
-- Flow verification script from title to first battle
+- Second battle expansion with a larger scrollable map, mixed enemy AI behaviors, minimap navigation, camp progression, and save persistence
+- Third battle expansion path toward Guangzong with a new battlefield, story bridge, campaign step persistence, and title continue support
+- Flow verification script from title through the third battle victory and camp save state
## Next Steps
-1. Implement attack range, damage, defeat, victory checks, and real resonance assist attacks
-2. Apply treasure equipment effects to damage, defense, recovery, and command rules
-3. Add strategy and item inventories with usable battle effects
-4. Add a simple enemy AI turn
-5. Add battle result scene and local save/load
+1. Add the next Guangzong main battle and named Yellow Turban commanders
+2. Broaden camp events so bonds, supplies, and equipment choices matter between battles
+3. Apply treasure equipment effects to damage, defense, recovery, and command rules
+4. Add more distinctive victory/defeat branches and optional objectives
+5. Keep expanding scenarios through the long campaign instead of treating the current slice as an ending
## Content Direction
diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs
index 49aaac6..4b6872e 100644
--- a/scripts/verify-flow.mjs
+++ b/scripts/verify-flow.mjs
@@ -418,6 +418,78 @@ try {
throw new Error(`Expected campaign save to persist second battle victory: ${JSON.stringify(campaignSaveAfterSecondBattle)}`);
}
+ await page.mouse.click(1120, 38);
+ await page.waitForTimeout(160);
+ const secondCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
+ if (!secondCampSortieState?.sortieVisible) {
+ throw new Error(`Expected second camp sortie preparation overlay: ${JSON.stringify(secondCampSortieState)}`);
+ }
+ await page.mouse.click(938, 596);
+ await page.waitForFunction(() => {
+ const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
+ return activeScenes.includes('StoryScene');
+ });
+
+ for (let i = 0; i < 14; i += 1) {
+ const isThirdBattleActive = await page.evaluate(() => {
+ const state = window.__HEROS_DEBUG__?.battle();
+ return state?.scene === 'BattleScene' && state?.battleId === 'third-battle-guangzong-road';
+ });
+
+ if (isThirdBattleActive) {
+ 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 === 'third-battle-guangzong-road' && state?.battleOutcome === null && state?.phase === 'idle';
+ });
+ await page.screenshot({ path: 'dist/verification-third-battle.png', fullPage: true });
+
+ const thirdBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
+ const thirdEnemies = thirdBattleState.units.filter((unit) => unit.faction === 'enemy');
+ const thirdEnemyBehaviors = new Set(thirdEnemies.map((unit) => unit.ai));
+ if (
+ thirdBattleState.camera?.mapWidth !== 24 ||
+ thirdBattleState.camera?.mapHeight !== 20 ||
+ thirdBattleState.victoryConditionLabel !== '전령 마원 격파' ||
+ thirdEnemies.length < 10 ||
+ !thirdEnemyBehaviors.has('aggressive') ||
+ !thirdEnemyBehaviors.has('guard') ||
+ !thirdEnemyBehaviors.has('hold')
+ ) {
+ throw new Error(`Expected third battle map, objective, and mixed enemy AI: ${JSON.stringify(thirdBattleState)}`);
+ }
+
+ 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 campaignSaveAfterThirdBattle = await page.evaluate(() => {
+ const raw = window.localStorage.getItem('heros-web:campaign-state');
+ return raw ? JSON.parse(raw) : undefined;
+ });
+ if (
+ !campaignSaveAfterThirdBattle ||
+ campaignSaveAfterThirdBattle.step !== 'third-camp' ||
+ campaignSaveAfterThirdBattle.latestBattleId !== 'third-battle-guangzong-road' ||
+ !campaignSaveAfterThirdBattle.battleHistory?.['third-battle-guangzong-road']
+ ) {
+ throw new Error(`Expected campaign save to persist third battle victory: ${JSON.stringify(campaignSaveAfterThirdBattle)}`);
+ }
+
await page.evaluate(() => window.__HEROS_GAME__?.scene.start('TitleScene'));
await page.waitForFunction(() => {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
@@ -429,7 +501,7 @@ try {
return activeScenes.includes('CampScene');
});
- console.log(`Verified title-to-second-battle flow, result states, and debug API at ${targetUrl}`);
+ console.log(`Verified title-to-third-battle flow, result states, and debug API at ${targetUrl}`);
} finally {
await browser?.close();
if (serverProcess && !serverProcess.killed) {
diff --git a/src/assets/images/battle/third-battle-map.svg b/src/assets/images/battle/third-battle-map.svg
new file mode 100644
index 0000000..98d3633
--- /dev/null
+++ b/src/assets/images/battle/third-battle-map.svg
@@ -0,0 +1,81 @@
+
diff --git a/src/game/data/battles.ts b/src/game/data/battles.ts
index 9ea837c..a25a72e 100644
--- a/src/game/data/battles.ts
+++ b/src/game/data/battles.ts
@@ -7,13 +7,17 @@ import {
secondBattleMap,
secondBattleUnits,
secondBattleVictoryPages,
+ thirdBattleBonds,
+ thirdBattleMap,
+ thirdBattleUnits,
+ thirdBattleVictoryPages,
type BattleBond,
type BattleMap,
type StoryPage,
type UnitData
} from './scenario';
-export type BattleScenarioId = 'first-battle-zhuo-commandery' | 'second-battle-yellow-turban-pursuit';
+export type BattleScenarioId = 'first-battle-zhuo-commandery' | 'second-battle-yellow-turban-pursuit' | 'third-battle-guangzong-road';
export type BattleObjectiveKind = 'defeat-leader' | 'keep-unit-alive' | 'secure-terrain' | 'quick-victory';
@@ -158,11 +162,65 @@ export const secondBattleScenario: BattleScenarioDefinition = {
nextCampScene: 'CampScene'
};
+export const thirdBattleScenario: BattleScenarioDefinition = {
+ id: 'third-battle-guangzong-road',
+ title: '광종 구원로',
+ victoryConditionLabel: '전령 마원 격파',
+ defeatConditionLabel: '유비 퇴각',
+ openingObjectiveLines: [
+ '황건 전령 마원이 광종 본대로 향하고 있습니다. 마원을 격파하면 적의 연락망이 끊어집니다.',
+ '강가 길목과 요새 주변의 궁병을 방치하면 전선이 오래 묶입니다. 지형 보정을 살펴 진군하십시오.',
+ '14턴 이내에 승리하면 광종 구원로 확보 명성이 오릅니다.'
+ ],
+ map: thirdBattleMap,
+ units: thirdBattleUnits,
+ bonds: thirdBattleBonds,
+ mapTextureKey: 'battle-map-third',
+ leaderUnitId: 'guangzong-leader-ma-yuan',
+ quickVictoryTurnLimit: 14,
+ baseVictoryGold: 520,
+ objectives: [
+ {
+ id: 'leader',
+ kind: 'defeat-leader',
+ label: '전령 마원 격파',
+ rewardGold: 320,
+ unitId: 'guangzong-leader-ma-yuan'
+ },
+ {
+ id: 'liu-bei',
+ kind: 'keep-unit-alive',
+ label: '유비 생존',
+ rewardGold: 140,
+ unitId: 'liu-bei'
+ },
+ {
+ id: 'fort',
+ kind: 'secure-terrain',
+ label: '강가 요새 확보',
+ rewardGold: 220,
+ terrain: 'fort'
+ },
+ {
+ id: 'quick',
+ kind: 'quick-victory',
+ label: '14턴 이내 승리',
+ rewardGold: 180,
+ maxTurn: 14
+ }
+ ],
+ defeatConditions: [{ kind: 'unit-defeated', unitId: 'liu-bei' }],
+ itemRewards: ['콩 2', '상처약 1', '의용군 명성 +3'],
+ victoryPages: thirdBattleVictoryPages,
+ nextCampScene: 'CampScene'
+};
+
export const defaultBattleScenarioId: BattleScenarioId = firstBattleScenario.id;
export const battleScenarios: Record = {
'first-battle-zhuo-commandery': firstBattleScenario,
- 'second-battle-yellow-turban-pursuit': secondBattleScenario
+ 'second-battle-yellow-turban-pursuit': secondBattleScenario,
+ 'third-battle-guangzong-road': thirdBattleScenario
};
export const defaultBattleScenario = battleScenarios[defaultBattleScenarioId];
diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts
index 4c23b25..1d9325e 100644
--- a/src/game/data/scenario.ts
+++ b/src/game/data/scenario.ts
@@ -248,6 +248,69 @@ export const secondBattleVictoryPages: StoryPage[] = [
}
];
+export const thirdBattleIntroPages: StoryPage[] = [
+ {
+ id: 'third-guangzong-report',
+ bgm: 'militia-theme',
+ chapter: '광종의 급보',
+ background: 'story-militia',
+ text: '탁현 북쪽의 소란을 잠재우자, 장터에는 더 큰 소식이 밀려왔다. 광종 방면에서 황건 본대가 관군을 압박하고 있다는 전갈이었다.'
+ },
+ {
+ id: 'third-liu-bei-decision',
+ bgm: 'battle-prep',
+ chapter: '넓어지는 길',
+ background: 'story-liu-bei',
+ speaker: '유비',
+ portrait: 'liuBei',
+ text: '작은 마을만 지키고 물러선다면 난세는 끝나지 않는다. 광종으로 향하는 길을 열어, 더 많은 백성이 버틸 시간을 만들어야 한다.'
+ },
+ {
+ id: 'third-guan-yu-bridge',
+ bgm: 'battle-prep',
+ chapter: '강가의 길목',
+ background: 'story-three-heroes',
+ speaker: '관우',
+ portrait: 'guanYu',
+ text: '적 전령이 강가 요새를 거쳐 본대로 향한다면, 그 길을 끊어야 합니다. 좁은 다리와 숲길을 먼저 장악하겠습니다.'
+ },
+ {
+ id: 'third-zhang-fei-vanguard',
+ bgm: 'battle-prep',
+ chapter: '전초전',
+ background: 'story-sortie',
+ speaker: '장비',
+ portrait: 'zhangFei',
+ text: '전령이든 두령이든 내 앞길에 서면 다 똑같소! 형님, 명만 내리시오. 길목을 시원하게 열어 버리겠소!'
+ }
+];
+
+export const thirdBattleVictoryPages: StoryPage[] = [
+ {
+ id: 'third-victory-road',
+ bgm: 'militia-theme',
+ chapter: '열린 길목',
+ background: 'story-sortie',
+ text: '황건 전령의 깃발이 꺾이자 광종으로 향하는 길목에 잠시 숨통이 트였다. 흩어진 백성들은 의용군의 이름을 기억하기 시작했다.'
+ },
+ {
+ id: 'third-victory-liu-bei',
+ bgm: 'militia-theme',
+ chapter: '큰 싸움의 그림자',
+ background: 'story-liu-bei',
+ speaker: '유비',
+ portrait: 'liuBei',
+ text: '우리가 막은 것은 작은 전갈 하나일 뿐이다. 그러나 작은 길 하나가 열리면, 다음 전장에서도 백성을 구할 길이 생긴다.'
+ },
+ {
+ id: 'third-victory-next',
+ bgm: 'battle-prep',
+ chapter: '광종으로',
+ background: 'story-militia',
+ text: '세 형제는 병사를 추스르고 광종 방면으로 눈을 돌렸다. 황건의 큰 물결은 아직 꺾이지 않았다.'
+ }
+];
+
export const firstBattleMap: BattleMap = {
width: 20,
height: 18,
@@ -300,6 +363,33 @@ export const secondBattleMap: BattleMap = {
]
};
+export const thirdBattleMap: BattleMap = {
+ width: 24,
+ height: 20,
+ terrain: [
+ ['forest', 'forest', 'hill', 'plain', 'plain', 'road', 'plain', 'forest', 'hill', 'plain', 'river', 'river', 'plain', 'forest', 'forest', 'hill', 'hill', 'plain', 'fort', 'fort', 'plain', 'forest', 'forest', 'hill'],
+ ['forest', 'hill', 'plain', 'plain', 'road', 'road', 'plain', 'forest', 'plain', 'plain', 'river', 'river', 'road', 'plain', 'forest', 'plain', 'hill', 'plain', 'fort', 'plain', 'plain', 'plain', 'forest', 'forest'],
+ ['hill', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'plain', 'road', 'road', 'road', 'road', 'plain', 'plain', 'plain', 'plain', 'hill', 'plain', 'plain', 'forest', 'plain', 'plain', 'forest'],
+ ['plain', 'plain', 'forest', 'road', 'plain', 'plain', 'forest', 'plain', 'hill', 'road', 'river', 'river', 'road', 'road', 'plain', 'hill', 'hill', 'plain', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain'],
+ ['plain', 'forest', 'forest', 'road', 'plain', 'hill', 'plain', 'plain', 'plain', 'road', 'river', 'river', 'plain', 'road', 'plain', 'plain', 'hill', 'cliff', 'plain', 'plain', 'plain', 'forest', 'plain', 'plain'],
+ ['plain', 'plain', 'forest', 'road', 'road', 'plain', 'plain', 'hill', 'plain', 'road', 'plain', 'river', 'river', 'road', 'plain', 'village', 'plain', 'hill', 'plain', 'plain', 'plain', 'forest', 'forest', 'plain'],
+ ['plain', 'plain', 'plain', 'plain', 'road', 'plain', 'hill', 'hill', 'plain', 'road', 'plain', 'river', 'river', 'road', 'plain', 'village', 'plain', 'plain', 'plain', 'plain', 'hill', 'plain', 'forest', 'plain'],
+ ['plain', 'forest', 'plain', 'plain', 'road', 'plain', 'plain', 'hill', 'plain', 'road', 'road', 'plain', 'river', 'river', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'hill', 'hill', 'plain', 'plain'],
+ ['forest', 'forest', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'hill', 'plain', 'road', 'plain', 'plain', 'river', 'river', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'hill', 'plain', 'plain'],
+ ['forest', 'plain', 'plain', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'river', 'river', 'plain', 'plain', 'forest', 'plain', 'hill', 'plain', 'plain', 'plain', 'plain'],
+ ['plain', 'plain', 'road', 'road', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'river', 'river', 'plain', 'plain', 'plain', 'hill', 'hill', 'plain', 'plain', 'plain'],
+ ['plain', 'road', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'village', 'plain', 'plain', 'plain', 'road', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'plain', 'hill', 'hill', 'plain', 'plain'],
+ ['hill', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'village', 'plain', 'forest', 'plain', 'road', 'road', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'plain', 'plain', 'plain', 'plain'],
+ ['hill', 'road', 'plain', 'forest', 'forest', 'plain', 'plain', 'hill', 'plain', 'plain', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'forest', 'plain', 'plain'],
+ ['plain', 'road', 'plain', 'plain', 'plain', 'plain', 'hill', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'forest', 'plain'],
+ ['camp', 'road', 'road', 'plain', 'plain', 'hill', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'hill'],
+ ['camp', 'camp', 'road', 'plain', 'forest', 'plain', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'river', 'river', 'plain', 'hill'],
+ ['plain', 'camp', 'road', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'hill', 'plain', 'hill', 'plain', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'river', 'river', 'plain'],
+ ['plain', 'plain', 'camp', 'road', 'plain', 'forest', 'forest', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'river', 'plain'],
+ ['plain', 'plain', 'plain', 'road', 'road', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain']
+ ]
+};
+
export const firstBattleUnits: UnitData[] = [
{
id: 'liu-bei',
@@ -840,6 +930,270 @@ export const secondBattleUnits: UnitData[] = [
}
];
+const thirdBattleAllyPositions: Record = {
+ 'liu-bei': { x: 2, y: 17 },
+ 'guan-yu': { x: 3, y: 17 },
+ 'zhang-fei': { x: 2, y: 18 }
+};
+
+export const thirdBattleUnits: UnitData[] = [
+ ...firstBattleUnits
+ .filter((unit) => unit.faction === 'ally')
+ .map((unit) => placeScenarioUnit(unit, thirdBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })),
+ {
+ id: 'guangzong-scout-a',
+ name: '황건 척후병',
+ faction: 'enemy',
+ className: '황건병',
+ classKey: 'yellowTurban',
+ level: 3,
+ exp: 18,
+ hp: 25,
+ maxHp: 25,
+ attack: 8,
+ move: 3,
+ stats: { might: 58, intelligence: 36, leadership: 45, agility: 52, luck: 43 },
+ equipment: {
+ weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 18 },
+ armor: { itemId: 'rebel-vest', level: 1, exp: 8 },
+ accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
+ },
+ x: 6,
+ y: 15
+ },
+ {
+ id: 'guangzong-bandit-a',
+ name: '숲길 복병',
+ faction: 'enemy',
+ className: '도적',
+ classKey: 'bandit',
+ level: 3,
+ exp: 20,
+ hp: 26,
+ maxHp: 26,
+ attack: 9,
+ move: 3,
+ stats: { might: 62, intelligence: 34, leadership: 42, agility: 58, luck: 46 },
+ equipment: {
+ weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 18 },
+ armor: { itemId: 'rebel-vest', level: 1, exp: 8 },
+ accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
+ },
+ x: 8,
+ y: 13
+ },
+ {
+ id: 'guangzong-archer-a',
+ name: '강가 궁병',
+ faction: 'enemy',
+ className: '궁병',
+ classKey: 'archer',
+ level: 3,
+ exp: 20,
+ hp: 22,
+ maxHp: 22,
+ attack: 9,
+ move: 3,
+ stats: { might: 57, intelligence: 45, leadership: 47, agility: 60, luck: 48 },
+ equipment: {
+ weapon: { itemId: 'short-bow', level: 1, exp: 20 },
+ armor: { itemId: 'cloth-armor', level: 1, exp: 8 },
+ accessory: { itemId: 'wind-quiver', level: 1, exp: 0 }
+ },
+ x: 11,
+ y: 14
+ },
+ {
+ id: 'guangzong-cavalry-a',
+ name: '황건 기병',
+ faction: 'enemy',
+ className: '경기병',
+ classKey: 'cavalry',
+ level: 4,
+ exp: 22,
+ hp: 29,
+ maxHp: 29,
+ attack: 10,
+ move: 5,
+ stats: { might: 66, intelligence: 36, leadership: 50, agility: 68, luck: 48 },
+ equipment: {
+ weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 22 },
+ armor: { itemId: 'rebel-vest', level: 1, exp: 9 },
+ accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
+ },
+ x: 12,
+ y: 11
+ },
+ {
+ id: 'guangzong-raider-a',
+ name: '황건 장정',
+ faction: 'enemy',
+ className: '황건병',
+ classKey: 'yellowTurban',
+ level: 3,
+ exp: 18,
+ hp: 26,
+ maxHp: 26,
+ attack: 8,
+ move: 3,
+ stats: { might: 59, intelligence: 35, leadership: 46, agility: 51, luck: 44 },
+ equipment: {
+ weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 18 },
+ armor: { itemId: 'rebel-vest', level: 1, exp: 8 },
+ accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
+ },
+ x: 10,
+ y: 9
+ },
+ {
+ id: 'guangzong-raider-b',
+ name: '황건 장정',
+ faction: 'enemy',
+ className: '황건병',
+ classKey: 'yellowTurban',
+ level: 3,
+ exp: 18,
+ hp: 26,
+ maxHp: 26,
+ attack: 8,
+ move: 3,
+ stats: { might: 59, intelligence: 35, leadership: 46, agility: 51, luck: 44 },
+ equipment: {
+ weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 18 },
+ armor: { itemId: 'rebel-vest', level: 1, exp: 8 },
+ accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
+ },
+ x: 14,
+ y: 10
+ },
+ {
+ id: 'guangzong-archer-b',
+ name: '요새 궁병',
+ faction: 'enemy',
+ className: '궁병',
+ classKey: 'archer',
+ level: 4,
+ exp: 24,
+ hp: 23,
+ maxHp: 23,
+ attack: 9,
+ move: 3,
+ stats: { might: 58, intelligence: 47, leadership: 48, agility: 61, luck: 49 },
+ equipment: {
+ weapon: { itemId: 'short-bow', level: 1, exp: 24 },
+ armor: { itemId: 'cloth-armor', level: 1, exp: 9 },
+ accessory: { itemId: 'wind-quiver', level: 1, exp: 0 }
+ },
+ x: 15,
+ y: 7
+ },
+ {
+ id: 'guangzong-cavalry-b',
+ name: '전령 기병',
+ faction: 'enemy',
+ className: '경기병',
+ classKey: 'cavalry',
+ level: 4,
+ exp: 25,
+ hp: 30,
+ maxHp: 30,
+ attack: 10,
+ move: 5,
+ stats: { might: 67, intelligence: 38, leadership: 51, agility: 70, luck: 50 },
+ equipment: {
+ weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 25 },
+ armor: { itemId: 'rebel-vest', level: 1, exp: 10 },
+ accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
+ },
+ x: 17,
+ y: 6
+ },
+ {
+ id: 'guangzong-guard-a',
+ name: '요새 수비병',
+ faction: 'enemy',
+ className: '황건병',
+ classKey: 'yellowTurban',
+ level: 4,
+ exp: 22,
+ hp: 28,
+ maxHp: 28,
+ attack: 9,
+ move: 3,
+ stats: { might: 62, intelligence: 37, leadership: 50, agility: 52, luck: 46 },
+ equipment: {
+ weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 22 },
+ armor: { itemId: 'rebel-vest', level: 1, exp: 10 },
+ accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
+ },
+ x: 18,
+ y: 4
+ },
+ {
+ id: 'guangzong-guard-b',
+ name: '요새 수비병',
+ faction: 'enemy',
+ className: '황건병',
+ classKey: 'yellowTurban',
+ level: 4,
+ exp: 22,
+ hp: 28,
+ maxHp: 28,
+ attack: 9,
+ move: 3,
+ stats: { might: 62, intelligence: 37, leadership: 50, agility: 52, luck: 46 },
+ equipment: {
+ weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 22 },
+ armor: { itemId: 'rebel-vest', level: 1, exp: 10 },
+ accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
+ },
+ x: 20,
+ y: 4
+ },
+ {
+ id: 'guangzong-fort-archer',
+ name: '망루 궁병',
+ faction: 'enemy',
+ className: '궁병',
+ classKey: 'archer',
+ level: 4,
+ exp: 25,
+ hp: 24,
+ maxHp: 24,
+ attack: 10,
+ move: 3,
+ stats: { might: 60, intelligence: 49, leadership: 49, agility: 61, luck: 50 },
+ equipment: {
+ weapon: { itemId: 'short-bow', level: 1, exp: 25 },
+ armor: { itemId: 'cloth-armor', level: 1, exp: 10 },
+ accessory: { itemId: 'wind-quiver', level: 1, exp: 0 }
+ },
+ x: 19,
+ y: 2
+ },
+ {
+ id: 'guangzong-leader-ma-yuan',
+ name: '전령 마원',
+ faction: 'enemy',
+ className: '두령',
+ classKey: 'rebelLeader',
+ level: 5,
+ exp: 30,
+ hp: 42,
+ maxHp: 42,
+ attack: 12,
+ move: 3,
+ stats: { might: 72, intelligence: 50, leadership: 68, agility: 56, luck: 54 },
+ equipment: {
+ weapon: { itemId: 'leader-axe', level: 2, exp: 8 },
+ armor: { itemId: 'lamellar-armor', level: 1, exp: 18 },
+ accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
+ },
+ x: 20,
+ y: 2
+ }
+];
+
export const firstBattleBonds: BattleBond[] = [
{
id: 'liu-bei__guan-yu',
@@ -868,6 +1222,7 @@ export const firstBattleBonds: BattleBond[] = [
];
export const secondBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario);
+export const thirdBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario);
function placeScenarioUnit(unit: UnitData, position: { x: number; y: number }): UnitData {
return {
diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts
index 4c42961..df72327 100644
--- a/src/game/scenes/BattleScene.ts
+++ b/src/game/scenes/BattleScene.ts
@@ -51,6 +51,17 @@ const unitTexture: Record = {
'pursuit-leader-han-seok': 'unit-rebel-leader'
};
+const unitTextureByClass: Partial> = {
+ lord: 'unit-liu-bei',
+ infantry: 'unit-guan-yu',
+ spearman: 'unit-zhang-fei',
+ archer: 'unit-rebel-archer',
+ cavalry: 'unit-rebel-cavalry',
+ bandit: 'unit-rebel',
+ yellowTurban: 'unit-rebel',
+ rebelLeader: 'unit-rebel-leader'
+};
+
const unitFrameRows: Record = {
south: 0,
east: 1,
@@ -806,7 +817,7 @@ export class BattleScene extends Phaser.Scene {
private drawUnits() {
battleUnits.forEach((unit) => {
const sizeRatio = unit.faction === 'ally' ? 1.16 : 1.1;
- const textureBase = unitTexture[unit.id] ?? 'unit-rebel';
+ const textureBase = unitTexture[unit.id] ?? unitTextureByClass[unit.classKey] ?? 'unit-rebel';
const sprite = this.add.sprite(this.tileCenterX(unit.x), this.tileCenterY(unit.y), textureBase, this.unitFrameIndex('south'));
sprite.setName(`unit-${unit.id}`);
sprite.setDisplaySize(this.layout.tileSize * sizeRatio, this.layout.tileSize * sizeRatio);
diff --git a/src/game/scenes/BootScene.ts b/src/game/scenes/BootScene.ts
index e992b61..6bd2c8d 100644
--- a/src/game/scenes/BootScene.ts
+++ b/src/game/scenes/BootScene.ts
@@ -1,6 +1,7 @@
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 thirdBattleMapUrl from '../../assets/images/battle/third-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';
@@ -62,6 +63,7 @@ export class BootScene extends Phaser.Scene {
this.load.image('story-sortie', storySortieUrl);
this.load.image('battle-map-first', firstBattleMapUrl);
this.load.image('battle-map-second', secondBattleMapUrl);
+ this.load.image('battle-map-third', thirdBattleMapUrl);
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 b6e6c3e..39f6c77 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, secondBattleScenario } from '../data/battles';
-import { firstBattleBonds, firstBattleUnits, secondBattleIntroPages, type PortraitKey, type UnitData } from '../data/scenario';
+import { defaultBattleScenario, secondBattleScenario, thirdBattleScenario } from '../data/battles';
+import { firstBattleBonds, firstBattleUnits, secondBattleIntroPages, thirdBattleIntroPages, type PortraitKey, type UnitData } from '../data/scenario';
import {
applyCampBondExp,
getCampaignState,
@@ -398,17 +398,30 @@ export class CampScene extends Phaser.Scene {
bg.setStrokeStyle(1, palette.gold, 0.4);
const completedDialogues = this.completedCampDialogues().length;
const inventory = this.inventoryLabels().join(', ');
- const reward = this.campaign?.latestBattleId === secondBattleScenario.id ? '예상 보상: 다음 장 개방' : '예상 보상: 군자금, 소모품, 의용군 명성';
+ const reward =
+ this.campaign?.latestBattleId === thirdBattleScenario.id
+ ? '다음 장: 광종 본전 준비 중'
+ : this.campaign?.latestBattleId === secondBattleScenario.id
+ ? `예상 보상: ${thirdBattleScenario.title} 개방`
+ : '예상 보상: 군자금, 소모품, 의용군 명성';
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 === thirdBattleScenario.id) {
+ return {
+ eyebrow: '다음 장 준비',
+ title: '광종 본전',
+ description: '광종 구원로는 열렸지만 황건 본대는 아직 건재합니다. 다음 작업에서 광종 본전과 더 큰 전장 목표를 이어 붙일 수 있습니다.'
+ };
+ }
+
if (this.campaign?.latestBattleId === secondBattleScenario.id) {
return {
- eyebrow: '다음 이야기',
- title: '넓어지는 전장',
- description: '황건 잔당 추격을 마친 의용군은 탁현을 넘어 더 큰 난세의 흐름을 마주합니다. 다음 장으로 향하기 전 전투 결과를 정리합니다.'
+ eyebrow: '다음 전장',
+ title: thirdBattleScenario.title,
+ description: '탁현을 넘어 광종으로 향하는 길목에서 황건 전령이 관군의 움직임을 본대로 전하려 합니다. 강가 요새와 좁은 길을 돌파해야 합니다.'
};
}
@@ -471,8 +484,19 @@ export class CampScene extends Phaser.Scene {
}
private startVictoryStory() {
+ if (this.campaign?.latestBattleId === thirdBattleScenario.id) {
+ this.hideSortiePrep();
+ this.showCampNotice('광종 본전은 다음 장으로 이어집니다. 군영에서 성장 상태를 정비하세요.');
+ return;
+ }
+
if (this.campaign?.latestBattleId === secondBattleScenario.id) {
- this.scene.start('StoryScene', { pages: secondBattleScenario.victoryPages, nextScene: 'TitleScene' });
+ markCampaignStep('third-battle');
+ this.scene.start('StoryScene', {
+ pages: [...secondBattleScenario.victoryPages, ...thirdBattleIntroPages],
+ nextScene: 'BattleScene',
+ nextSceneData: { battleId: thirdBattleScenario.id }
+ });
return;
}
diff --git a/src/game/scenes/TitleScene.ts b/src/game/scenes/TitleScene.ts
index 5799b45..af2c1c6 100644
--- a/src/game/scenes/TitleScene.ts
+++ b/src/game/scenes/TitleScene.ts
@@ -1,7 +1,7 @@
import Phaser from 'phaser';
import { soundDirector } from '../audio/SoundDirector';
import { firstBattleVictoryPages } from '../data/scenario';
-import { secondBattleScenario } from '../data/battles';
+import { secondBattleScenario, thirdBattleScenario } from '../data/battles';
import { hasCampaignSave, loadCampaignState, startNewCampaign } from '../state/campaignState';
import { palette } from '../ui/palette';
@@ -299,7 +299,7 @@ export class TitleScene extends Phaser.Scene {
soundDirector.playSelect();
const campaign = loadCampaignState();
- if (campaign.step === 'first-camp' || campaign.step === 'second-camp') {
+ if (campaign.step === 'first-camp' || campaign.step === 'second-camp' || campaign.step === 'third-camp') {
this.scene.start('CampScene');
return;
}
@@ -314,6 +314,11 @@ export class TitleScene extends Phaser.Scene {
return;
}
+ if (campaign.step === 'third-battle') {
+ this.scene.start('BattleScene', { battleId: thirdBattleScenario.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 e2f1428..4acecf0 100644
--- a/src/game/state/campaignState.ts
+++ b/src/game/state/campaignState.ts
@@ -36,7 +36,16 @@ export type FirstBattleReport = {
createdAt: string;
};
-export type CampaignStep = 'new' | 'prologue' | 'first-battle' | 'first-camp' | 'first-victory-story' | 'second-battle' | 'second-camp';
+export type CampaignStep =
+ | 'new'
+ | 'prologue'
+ | 'first-battle'
+ | 'first-camp'
+ | 'first-victory-story'
+ | 'second-battle'
+ | 'second-camp'
+ | 'third-battle'
+ | 'third-camp';
export type CampaignUnitProgressSnapshot = {
unitId: string;
@@ -86,6 +95,12 @@ export type CampaignState = {
export const campaignStorageKey = 'heros-web:campaign-state';
export const campaignSaveSlotCount = 3;
+const campaignBattleSteps: Record = {
+ 'first-battle-zhuo-commandery': { victory: 'first-camp', retry: 'first-battle' },
+ 'second-battle-yellow-turban-pursuit': { victory: 'second-camp', retry: 'second-battle' },
+ 'third-battle-guangzong-road': { victory: 'third-camp', retry: 'third-battle' }
+};
+
export type CampaignSaveSlotSummary = {
slot: number;
exists: boolean;
@@ -191,8 +206,9 @@ export function setFirstBattleReport(report: FirstBattleReport) {
reportClone.completedCampDialogues = completedCampDialogues;
state.firstBattleReport = reportClone;
- 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';
+ const stepRule = campaignBattleSteps[battleId] ?? campaignBattleSteps['first-battle-zhuo-commandery'];
+ const victoryStep: CampaignStep = stepRule.victory;
+ const retryStep: CampaignStep = stepRule.retry;
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);