diff --git a/docs/roadmap.md b/docs/roadmap.md
index 7f33087..47745fb 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -27,14 +27,15 @@ Build a small complete tactical RPG loop that can grow into a longer Romance of
- Seventh battle Xu Province rescue route against Cao Cao's vanguard, with Tao Qian entrusting Xu Province as the next endpoint
- Sortie preparation selection in camp so Liu Bei is required and optional allied officers can be deployed or benched before battle
- Xu Province camp recruitment for Jian Yong and Mi Zhu, including new strategist/quartermaster roles, bond dialogue, and selectable sortie participation
-- Flow verification script from title through the seventh battle victory, sortie selection, and camp save state
+- Eighth battle Xiaopei supply-road defense, where the expanded Xu Province roster can be selected into battle and Lu Bu's refuge arc is foreshadowed
+- Flow verification script from title through the eighth battle victory, recruit sortie selection, and camp save state
## Next Steps
-1. Add the Tao Qian handoff story and first Xu Province governance camp
-2. Use the expanded Xu Province roster in the next Lu Bu pressure arc so sortie selection has direct battle consequences
-3. Turn story-only endpoint bridges into a more explicit chapter selection/progress view
-4. Apply treasure equipment effects to damage, defense, recovery, and command rules
+1. Continue into the Lu Bu refuge and Xu Province loss chapter
+2. Add a chapter/progress view so long campaign arcs are easier to read between battles
+3. Apply treasure equipment effects to damage, defense, recovery, and command rules
+4. Add more recruitable officers as the campaign moves toward Cao Cao, Yuan Shao, Liu Biao, and Zhuge Liang
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 260239c..619ede5 100644
--- a/scripts/verify-flow.mjs
+++ b/scripts/verify-flow.mjs
@@ -869,22 +869,67 @@ try {
});
await page.screenshot({ path: 'dist/verification-xuzhou-entrust-story.png', fullPage: true });
- for (let i = 0; i < 8; i += 1) {
- const returnedToCamp = await page.evaluate(() => {
- const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
- return activeScenes.includes('CampScene');
+ for (let i = 0; i < 10; i += 1) {
+ const enteredEighthBattle = await page.evaluate(() => {
+ const state = window.__HEROS_DEBUG__?.battle();
+ return state?.scene === 'BattleScene' && state?.battleId === 'eighth-battle-xiaopei-supply-road';
});
- if (returnedToCamp) {
+ if (enteredEighthBattle) {
break;
}
await page.keyboard.press('Space');
await page.waitForTimeout(320);
}
+ await page.waitForFunction(() => {
+ const state = window.__HEROS_DEBUG__?.battle();
+ return state?.scene === 'BattleScene' && state?.battleId === 'eighth-battle-xiaopei-supply-road' && state?.battleOutcome === null && state?.phase === 'idle';
+ });
+ await page.screenshot({ path: 'dist/verification-eighth-battle.png', fullPage: true });
+
+ const eighthBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
+ const eighthEnemies = eighthBattleState.units.filter((unit) => unit.faction === 'enemy');
+ const eighthAllies = eighthBattleState.units.filter((unit) => unit.faction === 'ally');
+ const eighthEnemyBehaviors = new Set(eighthEnemies.map((unit) => unit.ai));
+ if (
+ eighthBattleState.camera?.mapWidth !== 26 ||
+ eighthBattleState.camera?.mapHeight !== 22 ||
+ eighthBattleState.victoryConditionLabel !== '고순 격파' ||
+ eighthEnemies.length < 11 ||
+ !eighthEnemyBehaviors.has('aggressive') ||
+ !eighthEnemyBehaviors.has('guard') ||
+ !eighthEnemyBehaviors.has('hold') ||
+ eighthAllies.some((unit) => unit.id === 'guan-yu') ||
+ !eighthAllies.some((unit) => unit.id === 'liu-bei') ||
+ !eighthAllies.some((unit) => unit.id === 'zhang-fei') ||
+ !eighthAllies.some((unit) => unit.id === 'jian-yong')
+ ) {
+ throw new Error(`Expected eighth battle to use expanded map, mixed AI, and selected recruit sortie: ${JSON.stringify(eighthBattleState)}`);
+ }
+
+ 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 eighthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
+ if (
+ eighthCampState?.campBattleId !== 'eighth-battle-xiaopei-supply-road' ||
+ eighthCampState.campTitle !== '소패 방위 군영' ||
+ eighthCampState.availableDialogueIds?.length !== 3 ||
+ !eighthCampState.availableDialogueIds.every((id) => id.endsWith('xiaopei')) ||
+ !eighthCampState.campaign?.roster?.some((unit) => unit.id === 'jian-yong') ||
+ !eighthCampState.campaign?.roster?.some((unit) => unit.id === 'mi-zhu')
+ ) {
+ throw new Error(`Expected eighth camp to expose Xiaopei dialogue set and preserve recruits: ${JSON.stringify(eighthCampState)}`);
+ }
+
await page.evaluate(() => window.__HEROS_GAME__?.scene.start('TitleScene'));
await page.waitForFunction(() => {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
@@ -896,7 +941,7 @@ try {
return activeScenes.includes('CampScene');
});
- console.log(`Verified title-to-seventh-battle flow, sortie selection, result states, and debug API at ${targetUrl}`);
+ console.log(`Verified title-to-eighth-battle flow, recruit sortie selection, result states, and debug API at ${targetUrl}`);
} finally {
await browser?.close();
if (serverProcess && !serverProcess.killed) {
diff --git a/src/assets/images/battle/eighth-battle-map.svg b/src/assets/images/battle/eighth-battle-map.svg
new file mode 100644
index 0000000..8bed22d
--- /dev/null
+++ b/src/assets/images/battle/eighth-battle-map.svg
@@ -0,0 +1,96 @@
+
diff --git a/src/game/data/battles.ts b/src/game/data/battles.ts
index 4e1f9c1..5f13a56 100644
--- a/src/game/data/battles.ts
+++ b/src/game/data/battles.ts
@@ -1,5 +1,9 @@
import {
firstBattleBonds,
+ eighthBattleBonds,
+ eighthBattleMap,
+ eighthBattleUnits,
+ eighthBattleVictoryPages,
firstBattleMap,
firstBattleUnits,
firstBattleVictoryPages,
@@ -40,7 +44,8 @@ export type BattleScenarioId =
| 'fourth-battle-guangzong-camp'
| 'fifth-battle-sishui-vanguard'
| 'sixth-battle-jieqiao-relief'
- | 'seventh-battle-xuzhou-rescue';
+ | 'seventh-battle-xuzhou-rescue'
+ | 'eighth-battle-xiaopei-supply-road';
export type BattleObjectiveKind = 'defeat-leader' | 'keep-unit-alive' | 'secure-terrain' | 'quick-victory';
@@ -450,6 +455,59 @@ export const seventhBattleScenario: BattleScenarioDefinition = {
nextCampScene: 'CampScene'
};
+export const eighthBattleScenario: BattleScenarioDefinition = {
+ id: 'eighth-battle-xiaopei-supply-road',
+ title: '소패 보급로 방위전',
+ victoryConditionLabel: '고순 격파',
+ defeatConditionLabel: '유비 퇴각',
+ openingObjectiveLines: [
+ '여포군의 척후와 습격병이 소패 보급로를 흔들고 있습니다. 고순을 격파하면 서주의 첫 방위선을 지킬 수 있습니다.',
+ '기병은 길을 타고 빠르게 파고들고, 궁병은 강 건너와 언덕에서 사거리를 잡습니다. 마을과 창고 지형을 이용해 버티십시오.',
+ '18턴 이내 승리하면 서주 보급망과 새 합류 무장의 명성이 크게 오릅니다.'
+ ],
+ map: eighthBattleMap,
+ units: eighthBattleUnits,
+ bonds: eighthBattleBonds,
+ mapTextureKey: 'battle-map-eighth',
+ leaderUnitId: 'xiaopei-leader-gao-shun',
+ quickVictoryTurnLimit: 18,
+ baseVictoryGold: 1080,
+ objectives: [
+ {
+ id: 'leader',
+ kind: 'defeat-leader',
+ label: '고순 격파',
+ rewardGold: 640,
+ unitId: 'xiaopei-leader-gao-shun'
+ },
+ {
+ id: 'liu-bei',
+ kind: 'keep-unit-alive',
+ label: '유비 생존',
+ rewardGold: 250,
+ unitId: 'liu-bei'
+ },
+ {
+ id: 'village',
+ kind: 'secure-terrain',
+ label: '소패 보급촌 확보',
+ rewardGold: 360,
+ terrain: 'village'
+ },
+ {
+ id: 'quick',
+ kind: 'quick-victory',
+ label: '18턴 이내 승리',
+ rewardGold: 300,
+ maxTurn: 18
+ }
+ ],
+ defeatConditions: [{ kind: 'unit-defeated', unitId: 'liu-bei' }],
+ itemRewards: ['콩 3', '상처약 2', '탁주 1', '서주 보급망 +1'],
+ victoryPages: eighthBattleVictoryPages,
+ nextCampScene: 'CampScene'
+};
+
export const defaultBattleScenarioId: BattleScenarioId = firstBattleScenario.id;
export const battleScenarios: Record = {
@@ -459,7 +517,8 @@ export const battleScenarios: Record
'fourth-battle-guangzong-camp': fourthBattleScenario,
'fifth-battle-sishui-vanguard': fifthBattleScenario,
'sixth-battle-jieqiao-relief': sixthBattleScenario,
- 'seventh-battle-xuzhou-rescue': seventhBattleScenario
+ 'seventh-battle-xuzhou-rescue': seventhBattleScenario,
+ 'eighth-battle-xiaopei-supply-road': eighthBattleScenario
};
export const defaultBattleScenario = battleScenarios[defaultBattleScenarioId];
diff --git a/src/game/data/campaignFlow.ts b/src/game/data/campaignFlow.ts
index 5288e95..9ec2056 100644
--- a/src/game/data/campaignFlow.ts
+++ b/src/game/data/campaignFlow.ts
@@ -1,6 +1,7 @@
import type { CampaignStep } from '../state/campaignState';
import {
defaultBattleScenario,
+ eighthBattleScenario,
fifthBattleScenario,
fourthBattleScenario,
secondBattleScenario,
@@ -10,6 +11,8 @@ import {
type BattleScenarioId
} from './battles';
import {
+ eighthBattleIntroPages,
+ eighthBattleVictoryPages,
fifthBattleIntroPages,
fifthBattleVictoryPages,
firstBattleVictoryPages,
@@ -101,12 +104,22 @@ const sortieFlows: Record = {
},
[seventhBattleScenario.id]: {
afterBattleId: seventhBattleScenario.id,
+ eyebrow: '다음 전장',
+ title: eighthBattleScenario.title,
+ description: '도겸에게 서주를 맡은 뒤 첫 과제는 소패 보급로를 지키는 일입니다. 여포군의 그림자가 드리우기 전에 새로 합류한 장수들과 방위선을 세워야 합니다.',
+ rewardHint: `예상 보상: ${eighthBattleScenario.title} 개방`,
+ nextBattleId: eighthBattleScenario.id,
+ campaignStep: 'eighth-battle',
+ pages: [...seventhBattleVictoryPages, ...eighthBattleIntroPages]
+ },
+ [eighthBattleScenario.id]: {
+ afterBattleId: eighthBattleScenario.id,
eyebrow: '다음 장 준비',
- title: '도겸의 서주',
- description: '서주 구원전 뒤 도겸은 유비에게 서주의 장래를 맡아 달라 청합니다. 이제 서주 인수와 여포의 그림자가 다음 줄기를 만듭니다.',
- rewardHint: '다음 장: 도겸에게 서주 인수 준비 중',
- pages: seventhBattleVictoryPages,
- unavailableNotice: '도겸의 서주 인수 흐름은 다음 장으로 이어집니다. 군영에서 성장 상태와 출전 구성을 정비하세요.'
+ title: '여포의 의탁',
+ description: '소패 보급로를 지켜냈지만 여포가 몸을 의탁하겠다는 전갈이 도착합니다. 받아들이는 선택은 곧 서주 상실의 갈등으로 이어질 것입니다.',
+ rewardHint: '다음 장: 여포 의탁과 서주 내부 불안 준비 중',
+ pages: eighthBattleVictoryPages,
+ unavailableNotice: '여포 의탁 이후의 서주 상실 흐름은 다음 작업에서 이어집니다. 군영에서 성장과 출전 구성을 정비하십시오.'
}
};
diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts
index 9fbeed8..073a2de 100644
--- a/src/game/data/scenario.ts
+++ b/src/game/data/scenario.ts
@@ -540,6 +540,60 @@ export const seventhBattleVictoryPages: StoryPage[] = [
}
];
+export const eighthBattleIntroPages: StoryPage[] = [
+ {
+ id: 'eighth-xuzhou-handover',
+ bgm: 'militia-theme',
+ chapter: '서주의 새 군영',
+ background: 'story-militia',
+ text: '도겸은 병든 몸을 일으켜 서주의 장부와 군량 창고를 유비에게 넘겼다. 간옹과 미축은 백성의 청원과 보급로를 정리하며 새 군영의 기초를 세웠다.'
+ },
+ {
+ id: 'eighth-lu-bu-shadow',
+ bgm: 'battle-prep',
+ chapter: '여포의 그림자',
+ background: 'story-liu-bei',
+ speaker: '유비',
+ portrait: 'liuBei',
+ text: '여포가 떠돌고 있다는 소식은 가볍지 않다. 그 이름 하나만으로도 여러 군세가 흔들린다. 그러나 지금은 서주의 길과 백성을 먼저 지켜야 한다.'
+ },
+ {
+ id: 'eighth-xiaopei-road',
+ bgm: 'battle-prep',
+ chapter: '소패 보급로',
+ background: 'story-sortie',
+ speaker: '미축',
+ text: '소패로 이어지는 창고 길목이 습격받고 있습니다. 군량이 끊기면 서주를 안정시킬 수 없습니다. 보급로와 마을을 동시에 지켜야 합니다.'
+ },
+ {
+ id: 'eighth-sortie-choice',
+ bgm: 'battle-prep',
+ chapter: '첫 방위전',
+ background: 'story-three-heroes',
+ speaker: '간옹',
+ text: '현덕, 이제 함께 갈 장수를 고르는 일도 전술이 되었네. 칼이 필요한 곳과 말이 필요한 곳, 그리고 말보다 장부가 필요한 곳을 구분해야 하네.'
+ }
+];
+
+export const eighthBattleVictoryPages: StoryPage[] = [
+ {
+ id: 'eighth-victory-supply-road',
+ bgm: 'militia-theme',
+ chapter: '지켜낸 보급로',
+ background: 'story-militia',
+ text: '소패의 보급로가 다시 열리자 서주의 창고와 마을은 겨우 숨을 돌렸다. 새로 합류한 장수들은 각자의 방식으로 유비군의 빈틈을 메웠다.'
+ },
+ {
+ id: 'eighth-lu-bu-arrival',
+ bgm: 'battle-prep',
+ chapter: '뜻밖의 손님',
+ background: 'story-liu-bei',
+ speaker: '유비',
+ portrait: 'liuBei',
+ text: '전투가 끝난 뒤, 여포가 몸을 의탁할 곳을 구한다는 전갈이 왔다. 그를 받아들이는 일은 의로울 수 있으나, 서주에 또 다른 불씨를 들이는 일이기도 했다.'
+ }
+];
+
export const firstBattleMap: BattleMap = {
width: 20,
height: 18,
@@ -727,6 +781,12 @@ export const seventhBattleMap: BattleMap = {
]
};
+export const eighthBattleMap: BattleMap = {
+ width: 26,
+ height: 22,
+ terrain: createEighthBattleTerrain()
+};
+
export const firstBattleUnits: UnitData[] = [
{
id: 'liu-bei',
@@ -2611,6 +2671,251 @@ export const xuzhouRecruitUnits: UnitData[] = [
}
];
+const eighthBattleAllyPositions: Record = {
+ 'liu-bei': { x: 2, y: 17 },
+ 'guan-yu': { x: 3, y: 17 },
+ 'zhang-fei': { x: 2, y: 18 },
+ 'jian-yong': { x: 3, y: 18 },
+ 'mi-zhu': { x: 1, y: 18 }
+};
+
+export const eighthBattleUnits: UnitData[] = [
+ ...[...firstBattleUnits.filter((unit) => unit.faction === 'ally'), ...xuzhouRecruitUnits].map((unit) =>
+ placeScenarioUnit(unit, eighthBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })
+ ),
+ {
+ id: 'xiaopei-raider-a',
+ name: '여포군 척후',
+ faction: 'enemy',
+ className: '기동 척후',
+ classKey: 'cavalry',
+ level: 8,
+ exp: 52,
+ hp: 43,
+ maxHp: 43,
+ attack: 15,
+ move: 5,
+ stats: { might: 82, intelligence: 50, leadership: 62, agility: 86, luck: 55 },
+ equipment: {
+ weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 28 },
+ armor: { itemId: 'rebel-vest', level: 2, exp: 14 },
+ accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
+ },
+ x: 17,
+ y: 9
+ },
+ {
+ id: 'xiaopei-raider-b',
+ name: '여포군 척후',
+ faction: 'enemy',
+ className: '기동 척후',
+ classKey: 'cavalry',
+ level: 8,
+ exp: 54,
+ hp: 44,
+ maxHp: 44,
+ attack: 15,
+ move: 5,
+ stats: { might: 83, intelligence: 50, leadership: 62, agility: 86, luck: 55 },
+ equipment: {
+ weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 30 },
+ armor: { itemId: 'rebel-vest', level: 2, exp: 14 },
+ accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
+ },
+ x: 19,
+ y: 10
+ },
+ {
+ id: 'xiaopei-infantry-a',
+ name: '소패 침투병',
+ faction: 'enemy',
+ className: '여포군 보병',
+ classKey: 'yellowTurban',
+ level: 8,
+ exp: 50,
+ hp: 42,
+ maxHp: 42,
+ attack: 13,
+ move: 3,
+ stats: { might: 78, intelligence: 50, leadership: 66, agility: 61, luck: 53 },
+ equipment: {
+ weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 26 },
+ armor: { itemId: 'lamellar-armor', level: 2, exp: 12 },
+ accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
+ },
+ x: 11,
+ y: 12
+ },
+ {
+ id: 'xiaopei-infantry-b',
+ name: '소패 침투병',
+ faction: 'enemy',
+ className: '여포군 보병',
+ classKey: 'yellowTurban',
+ level: 8,
+ exp: 52,
+ hp: 43,
+ maxHp: 43,
+ attack: 13,
+ move: 3,
+ stats: { might: 79, intelligence: 50, leadership: 67, agility: 62, luck: 53 },
+ equipment: {
+ weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 28 },
+ armor: { itemId: 'lamellar-armor', level: 2, exp: 12 },
+ accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
+ },
+ x: 12,
+ y: 14
+ },
+ {
+ id: 'xiaopei-archer-a',
+ name: '소패 궁병',
+ faction: 'enemy',
+ className: '여포군 궁병',
+ classKey: 'archer',
+ level: 8,
+ exp: 52,
+ hp: 34,
+ maxHp: 34,
+ attack: 14,
+ move: 3,
+ stats: { might: 70, intelligence: 62, leadership: 60, agility: 71, luck: 54 },
+ equipment: {
+ weapon: { itemId: 'short-bow', level: 2, exp: 28 },
+ armor: { itemId: 'cloth-armor', level: 2, exp: 12 },
+ accessory: { itemId: 'wind-quiver', level: 1, exp: 0 }
+ },
+ x: 18,
+ y: 7
+ },
+ {
+ id: 'xiaopei-archer-b',
+ name: '소패 궁병',
+ faction: 'enemy',
+ className: '여포군 궁병',
+ classKey: 'archer',
+ level: 9,
+ exp: 54,
+ hp: 35,
+ maxHp: 35,
+ attack: 15,
+ move: 3,
+ stats: { might: 72, intelligence: 63, leadership: 61, agility: 72, luck: 55 },
+ equipment: {
+ weapon: { itemId: 'short-bow', level: 2, exp: 30 },
+ armor: { itemId: 'cloth-armor', level: 2, exp: 12 },
+ accessory: { itemId: 'wind-quiver', level: 1, exp: 0 }
+ },
+ x: 21,
+ y: 14
+ },
+ {
+ id: 'xiaopei-guard-a',
+ name: '창고 습격병',
+ faction: 'enemy',
+ className: '여포군 보병',
+ classKey: 'bandit',
+ level: 8,
+ exp: 50,
+ hp: 38,
+ maxHp: 38,
+ attack: 14,
+ move: 4,
+ stats: { might: 79, intelligence: 48, leadership: 58, agility: 72, luck: 52 },
+ equipment: {
+ weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 26 },
+ armor: { itemId: 'rebel-vest', level: 2, exp: 12 },
+ accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
+ },
+ x: 8,
+ y: 11
+ },
+ {
+ id: 'xiaopei-guard-b',
+ name: '창고 습격병',
+ faction: 'enemy',
+ className: '여포군 보병',
+ classKey: 'bandit',
+ level: 8,
+ exp: 52,
+ hp: 39,
+ maxHp: 39,
+ attack: 14,
+ move: 4,
+ stats: { might: 80, intelligence: 48, leadership: 58, agility: 73, luck: 52 },
+ equipment: {
+ weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 28 },
+ armor: { itemId: 'rebel-vest', level: 2, exp: 12 },
+ accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
+ },
+ x: 9,
+ y: 14
+ },
+ {
+ id: 'xiaopei-cavalry-a',
+ name: '여포군 기병',
+ faction: 'enemy',
+ className: '여포군 기병',
+ classKey: 'cavalry',
+ level: 9,
+ exp: 56,
+ hp: 46,
+ maxHp: 46,
+ attack: 16,
+ move: 5,
+ stats: { might: 86, intelligence: 50, leadership: 66, agility: 88, luck: 56 },
+ equipment: {
+ weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 32 },
+ armor: { itemId: 'rebel-vest', level: 2, exp: 16 },
+ accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
+ },
+ x: 23,
+ y: 6
+ },
+ {
+ id: 'xiaopei-cavalry-b',
+ name: '여포군 기병',
+ faction: 'enemy',
+ className: '여포군 기병',
+ classKey: 'cavalry',
+ level: 9,
+ exp: 58,
+ hp: 47,
+ maxHp: 47,
+ attack: 16,
+ move: 5,
+ stats: { might: 87, intelligence: 50, leadership: 66, agility: 89, luck: 56 },
+ equipment: {
+ weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 34 },
+ armor: { itemId: 'rebel-vest', level: 2, exp: 16 },
+ accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
+ },
+ x: 24,
+ y: 15
+ },
+ {
+ id: 'xiaopei-leader-gao-shun',
+ name: '고순',
+ faction: 'enemy',
+ className: '여포군 선봉장',
+ classKey: 'rebelLeader',
+ level: 10,
+ exp: 62,
+ hp: 62,
+ maxHp: 62,
+ attack: 17,
+ move: 4,
+ stats: { might: 88, intelligence: 66, leadership: 86, agility: 68, luck: 58 },
+ equipment: {
+ weapon: { itemId: 'leader-axe', level: 2, exp: 34 },
+ armor: { itemId: 'lamellar-armor', level: 2, exp: 18 },
+ accessory: { itemId: 'war-manual', level: 1, exp: 0 }
+ },
+ x: 22,
+ y: 3
+ }
+];
+
export const firstBattleBonds: BattleBond[] = [
{
id: 'liu-bei__guan-yu',
@@ -2663,6 +2968,39 @@ export const fourthBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleB
export const fifthBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario);
export const sixthBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario);
export const seventhBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario);
+export const eighthBattleBonds: BattleBond[] = [...firstBattleBonds, ...xuzhouRecruitBonds].map(cloneBattleBondForScenario);
+
+function createEighthBattleTerrain(): TerrainType[][] {
+ return Array.from({ length: 22 }, (_, y) =>
+ Array.from({ length: 26 }, (_, x): TerrainType => {
+ if (x <= 1 && y >= 16 && y <= 20) {
+ return 'camp';
+ }
+ if ((x === 2 && y >= 15 && y <= 20) || (x === 3 && y >= 18 && y <= 20)) {
+ return 'camp';
+ }
+ if ((x >= 20 && x <= 23 && y >= 2 && y <= 5) || (x >= 21 && x <= 24 && y >= 13 && y <= 16)) {
+ return 'fort';
+ }
+ if ((x >= 17 && x <= 18 && y >= 7 && y <= 9) || (x >= 8 && x <= 9 && y >= 12 && y <= 14)) {
+ return 'village';
+ }
+ if ((x === 14 || x === 15) && y <= 18 && y >= 0) {
+ return 'river';
+ }
+ if (x === y - 12 || x === y - 13 || (y >= 7 && y <= 10 && x >= 2 && x <= 18) || (x >= 4 && x <= 22 && y === 10)) {
+ return 'road';
+ }
+ if ((x <= 6 && y <= 4) || (x >= 5 && x <= 10 && y >= 3 && y <= 7) || (x >= 2 && x <= 6 && y >= 8 && y <= 12) || (x >= 18 && x <= 25 && y >= 17)) {
+ return 'forest';
+ }
+ if ((x >= 18 && y <= 2) || (x >= 22 && y >= 7 && y <= 12) || (x >= 10 && x <= 13 && y >= 15 && y <= 20) || (x <= 4 && y >= 4 && y <= 7)) {
+ return 'hill';
+ }
+ return 'plain';
+ })
+ );
+}
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 6047deb..0b3ce39 100644
--- a/src/game/scenes/BattleScene.ts
+++ b/src/game/scenes/BattleScene.ts
@@ -524,7 +524,18 @@ const enemyAiByUnitId: Record = {
'xuzhou-cavalry-c': 'aggressive',
'xuzhou-guard-b': 'guard',
'xuzhou-archer-c': 'hold',
- 'xuzhou-leader-xiahou-dun': 'guard'
+ 'xuzhou-leader-xiahou-dun': 'guard',
+ 'xiaopei-raider-a': 'aggressive',
+ 'xiaopei-raider-b': 'aggressive',
+ 'xiaopei-infantry-a': 'guard',
+ 'xiaopei-infantry-b': 'guard',
+ 'xiaopei-archer-a': 'hold',
+ 'xiaopei-archer-b': 'hold',
+ 'xiaopei-guard-a': 'aggressive',
+ 'xiaopei-guard-b': 'aggressive',
+ 'xiaopei-cavalry-a': 'aggressive',
+ 'xiaopei-cavalry-b': 'aggressive',
+ 'xiaopei-leader-gao-shun': 'guard'
};
const defaultEnemyAiByClass: Partial> = {
diff --git a/src/game/scenes/BootScene.ts b/src/game/scenes/BootScene.ts
index b71d198..805f063 100644
--- a/src/game/scenes/BootScene.ts
+++ b/src/game/scenes/BootScene.ts
@@ -1,4 +1,5 @@
import Phaser from 'phaser';
+import eighthBattleMapUrl from '../../assets/images/battle/eighth-battle-map.svg';
import fifthBattleMapUrl from '../../assets/images/battle/fifth-battle-map.svg';
import firstBattleMapUrl from '../../assets/images/battle/first-battle-map.png';
import fourthBattleMapUrl from '../../assets/images/battle/fourth-battle-map.svg';
@@ -72,6 +73,7 @@ export class BootScene extends Phaser.Scene {
this.load.image('battle-map-fifth', fifthBattleMapUrl);
this.load.image('battle-map-sixth', sixthBattleMapUrl);
this.load.image('battle-map-seventh', seventhBattleMapUrl);
+ this.load.image('battle-map-eighth', eighthBattleMapUrl);
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 48de9fa..172097b 100644
--- a/src/game/scenes/CampScene.ts
+++ b/src/game/scenes/CampScene.ts
@@ -130,7 +130,8 @@ const campBattleIds = {
fourth: 'fourth-battle-guangzong-camp',
fifth: 'fifth-battle-sishui-vanguard',
sixth: 'sixth-battle-jieqiao-relief',
- seventh: 'seventh-battle-xuzhou-rescue'
+ seventh: 'seventh-battle-xuzhou-rescue',
+ eighth: 'eighth-battle-xiaopei-supply-road'
} as const;
const requiredSortieUnitIds = new Set(['liu-bei']);
@@ -757,6 +758,87 @@ const campDialogues: CampDialogue[] = [
rewardExp: 7
}
]
+ },
+ {
+ id: 'liu-mi-zhu-after-xiaopei',
+ title: '지켜낸 보급로',
+ availableAfterBattleIds: [campBattleIds.eighth],
+ unitIds: ['liu-bei', 'mi-zhu'],
+ bondId: 'liu-bei__mi-zhu',
+ rewardExp: 20,
+ lines: [
+ '미축: 소패 보급로는 지켰지만, 서주는 아직 안정되었다고 보기 어렵습니다. 창고를 지켜도 사람의 마음이 흩어지면 다시 흔들립니다.',
+ '유비: 군량은 칼보다 조용하지만, 백성을 살리는 힘이 있소. 그 일을 계속 맡아 주시오.',
+ '미축: 맡겨 주신다면 상단과 호족을 설득해 서주의 숨통을 조금 더 넓히겠습니다.'
+ ],
+ choices: [
+ {
+ id: 'trust-supply-network',
+ label: '보급망을 믿고 맡긴다',
+ response: '미축은 유비가 실무를 믿고 맡기자 서주의 창고와 상단을 더 단단히 묶겠다고 답했다.',
+ rewardExp: 8
+ },
+ {
+ id: 'share-grain-openly',
+ label: '군량 배분을 투명하게 한다',
+ response: '유비가 백성 앞에서 군량 배분을 숨기지 않겠다고 하자 미축의 표정이 한결 밝아졌다.',
+ rewardExp: 9
+ }
+ ]
+ },
+ {
+ id: 'liu-jian-yong-after-xiaopei',
+ title: '여포를 맞을 것인가',
+ availableAfterBattleIds: [campBattleIds.eighth],
+ unitIds: ['liu-bei', 'jian-yong'],
+ bondId: 'liu-bei__jian-yong',
+ rewardExp: 20,
+ lines: [
+ '간옹: 여포를 받아들이면 의롭다는 말은 들을 걸세. 하지만 사람들은 그 뒤에 칼집이 몇 개인지도 세어 볼 거야.',
+ '유비: 곤궁한 이를 외면할 수는 없소. 그러나 서주 백성의 근심도 가볍게 여길 수 없소.',
+ '간옹: 그래서 선택에는 늘 값을 치르는 법이지. 적어도 그 값을 알고 받아들이게.'
+ ],
+ choices: [
+ {
+ id: 'accept-with-guard',
+ label: '경계를 세우고 맞는다',
+ response: '간옹은 유비가 의와 경계를 함께 세우겠다고 하자, 그나마 현실적인 대답이라고 웃었다.',
+ rewardExp: 9
+ },
+ {
+ id: 'ask-people-first',
+ label: '백성의 불안을 먼저 살핀다',
+ response: '간옹은 서주 사람들의 두려움을 먼저 살피겠다는 말에 고개를 끄덕였다.',
+ rewardExp: 8
+ }
+ ]
+ },
+ {
+ id: 'guan-zhang-after-xiaopei',
+ title: '성문을 지키는 두 장수',
+ availableAfterBattleIds: [campBattleIds.eighth],
+ unitIds: ['guan-yu', 'zhang-fei'],
+ bondId: 'guan-yu__zhang-fei',
+ rewardExp: 20,
+ lines: [
+ '관우: 여포가 온다면 군율이 먼저 흔들릴 것이다. 성문과 술자리를 모두 조심해야 한다.',
+ '장비: 왜 나를 보며 말하시오? 하지만 알겠소. 이번에는 먼저 소리치기보다 성문부터 붙잡겠소.',
+ '관우: 형님의 뜻을 지키려면 우리부터 흔들리지 않아야 한다.'
+ ],
+ choices: [
+ {
+ id: 'guard-gates',
+ label: '성문 경계를 강화한다',
+ response: '관우와 장비는 성문 근무와 순찰을 나누어 맡기로 했다.',
+ rewardExp: 8
+ },
+ {
+ id: 'restrain-temper',
+ label: '장비의 성급함을 다잡는다',
+ response: '장비는 툴툴대면서도 이번만큼은 술보다 군율을 먼저 보겠다고 약속했다.',
+ rewardExp: 9
+ }
+ ]
}
];
@@ -834,7 +916,8 @@ export class CampScene extends Phaser.Scene {
}
private ensureCurrentCampRecruitment() {
- if (this.currentCampBattleId() !== campBattleIds.seventh || !this.campaign) {
+ const battleId = this.currentCampBattleId();
+ if ((battleId !== campBattleIds.seventh && battleId !== campBattleIds.eighth) || !this.campaign) {
return;
}
@@ -854,6 +937,9 @@ export class CampScene extends Phaser.Scene {
private currentCampTitle() {
const battleId = this.currentCampBattleId();
+ if (battleId === campBattleIds.eighth) {
+ return '소패 방위 군영';
+ }
if (battleId === campBattleIds.seventh) {
return '서주 인수 준비 군영';
}
diff --git a/src/game/scenes/TitleScene.ts b/src/game/scenes/TitleScene.ts
index 8078c75..5481720 100644
--- a/src/game/scenes/TitleScene.ts
+++ b/src/game/scenes/TitleScene.ts
@@ -1,7 +1,15 @@
import Phaser from 'phaser';
import { soundDirector } from '../audio/SoundDirector';
import { firstBattleVictoryPages } from '../data/scenario';
-import { fifthBattleScenario, fourthBattleScenario, secondBattleScenario, seventhBattleScenario, sixthBattleScenario, thirdBattleScenario } from '../data/battles';
+import {
+ eighthBattleScenario,
+ fifthBattleScenario,
+ fourthBattleScenario,
+ secondBattleScenario,
+ seventhBattleScenario,
+ sixthBattleScenario,
+ thirdBattleScenario
+} from '../data/battles';
import { hasCampaignSave, loadCampaignState, startNewCampaign } from '../state/campaignState';
import { palette } from '../ui/palette';
@@ -306,7 +314,8 @@ export class TitleScene extends Phaser.Scene {
campaign.step === 'fourth-camp' ||
campaign.step === 'fifth-camp' ||
campaign.step === 'sixth-camp' ||
- campaign.step === 'seventh-camp'
+ campaign.step === 'seventh-camp' ||
+ campaign.step === 'eighth-camp'
) {
this.scene.start('CampScene');
return;
@@ -347,6 +356,11 @@ export class TitleScene extends Phaser.Scene {
return;
}
+ if (campaign.step === 'eighth-battle') {
+ this.scene.start('BattleScene', { battleId: eighthBattleScenario.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 5d159bc..2418238 100644
--- a/src/game/state/campaignState.ts
+++ b/src/game/state/campaignState.ts
@@ -53,7 +53,9 @@ export type CampaignStep =
| 'sixth-battle'
| 'sixth-camp'
| 'seventh-battle'
- | 'seventh-camp';
+ | 'seventh-camp'
+ | 'eighth-battle'
+ | 'eighth-camp';
export type CampaignUnitProgressSnapshot = {
unitId: string;
@@ -111,7 +113,8 @@ const campaignBattleSteps: Record