Add Cao Cao refuge road chapter

This commit is contained in:
2026-06-23 02:21:23 +09:00
parent 6eacee48fc
commit 683357f6a7
11 changed files with 748 additions and 16 deletions

View File

@@ -30,12 +30,13 @@ Build a small complete tactical RPG loop that can grow into a longer Romance of
- 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
- Ninth battle Xuzhou gate night raid, opening Lu Bu's refuge and the internal instability that leads toward the loss of Xu Province
- Tenth battle Xuzhou breakout, covering Zhang Fei's mistake, the loss of Xu Province to Lu Bu, and the retreat toward Cao Cao's shadow
- Eleventh battle Xudu refuge road, opening Liu Bei's uneasy service under Cao Cao through a Yuan Shu remnant test battle
- Tactical sortie preparation panel with class role, named equipment, core stats, bond partner, and next-map terrain suitability for each deployable officer
- Flow verification script from title through the tenth battle victory, recruit sortie selection, and camp save state
- Flow verification script from title through the eleventh battle victory, recruit sortie selection, and camp save state
## Next Steps
1. Continue into Liu Bei's temporary service under Cao Cao and the tension of surviving in Xu/Hu Du politics
1. Continue into Liu Bei's tense service under Cao Cao, then build toward the break from Cao Cao
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

View File

@@ -1102,6 +1102,83 @@ try {
throw new Error(`Expected tenth camp to expose Xuzhou loss dialogue set and preserve full roster: ${JSON.stringify(tenthCampState)}`);
}
await page.mouse.click(1120, 38);
await page.waitForTimeout(160);
const tenthCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
if (!tenthCampSortieState?.sortieVisible) {
throw new Error(`Expected tenth camp sortie prep overlay: ${JSON.stringify(tenthCampSortieState)}`);
}
assertSortieTacticalRoster(tenthCampSortieState, ['liu-bei', 'guan-yu', 'zhang-fei', 'jian-yong', 'mi-zhu']);
await page.mouse.click(1068, 646);
await page.waitForFunction(() => {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
return activeScenes.includes('StoryScene');
});
await page.screenshot({ path: 'dist/verification-cao-cao-refuge-story.png', fullPage: true });
for (let i = 0; i < 16; i += 1) {
const enteredEleventhBattle = await page.evaluate(() => {
const state = window.__HEROS_DEBUG__?.battle();
return state?.scene === 'BattleScene' && state?.battleId === 'eleventh-battle-xudu-refuge-road';
});
if (enteredEleventhBattle) {
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 === 'eleventh-battle-xudu-refuge-road' && state?.battleOutcome === null && state?.phase === 'idle';
});
await page.screenshot({ path: 'dist/verification-eleventh-battle.png', fullPage: true });
const eleventhBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
const eleventhEnemies = eleventhBattleState.units.filter((unit) => unit.faction === 'enemy');
const eleventhAllies = eleventhBattleState.units.filter((unit) => unit.faction === 'ally');
const eleventhEnemyBehaviors = new Set(eleventhEnemies.map((unit) => unit.ai));
if (
eleventhBattleState.camera?.mapWidth !== 28 ||
eleventhBattleState.camera?.mapHeight !== 22 ||
eleventhBattleState.victoryConditionLabel !== '기령 격파' ||
eleventhEnemies.length < 12 ||
!eleventhEnemyBehaviors.has('aggressive') ||
!eleventhEnemyBehaviors.has('guard') ||
!eleventhEnemyBehaviors.has('hold') ||
!eleventhAllies.some((unit) => unit.id === 'liu-bei') ||
!eleventhAllies.some((unit) => unit.id === 'guan-yu') ||
!eleventhAllies.some((unit) => unit.id === 'zhang-fei') ||
!eleventhAllies.some((unit) => unit.id === 'jian-yong') ||
!eleventhAllies.some((unit) => unit.id === 'mi-zhu')
) {
throw new Error(`Expected eleventh battle to use Xudu road map, mixed AI, and selected Cao Cao refuge sortie: ${JSON.stringify(eleventhBattleState)}`);
}
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 eleventhCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
if (
eleventhCampState?.campBattleId !== 'eleventh-battle-xudu-refuge-road' ||
eleventhCampState.campTitle !== '허도 의탁 군영' ||
eleventhCampState.availableDialogueIds?.length !== 3 ||
!eleventhCampState.availableDialogueIds.every((id) => id.endsWith('cao-cao-refuge')) ||
!eleventhCampState.campaign?.roster?.some((unit) => unit.id === 'guan-yu') ||
!eleventhCampState.campaign?.roster?.some((unit) => unit.id === 'jian-yong') ||
!eleventhCampState.campaign?.roster?.some((unit) => unit.id === 'mi-zhu')
) {
throw new Error(`Expected eleventh camp to expose Cao Cao refuge dialogue set and preserve full roster: ${JSON.stringify(eleventhCampState)}`);
}
await page.evaluate(() => window.__HEROS_GAME__?.scene.start('TitleScene'));
await page.waitForFunction(() => {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
@@ -1113,7 +1190,7 @@ try {
return activeScenes.includes('CampScene');
});
console.log(`Verified title-to-tenth-battle flow, recruit sortie selection, result states, and debug API at ${targetUrl}`);
console.log(`Verified title-to-eleventh-battle flow, recruit sortie selection, result states, and debug API at ${targetUrl}`);
} finally {
await browser?.close();
if (serverProcess && !serverProcess.killed) {

View File

@@ -0,0 +1,97 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2800 2200" width="2800" height="2200">
<defs>
<filter id="marchGrain">
<feTurbulence type="fractalNoise" baseFrequency="0.019" numOctaves="4" seed="1111" />
<feColorMatrix type="saturate" values="0.62" />
<feBlend mode="multiply" in2="SourceGraphic" />
</filter>
<pattern id="field" width="100" height="100" patternUnits="userSpaceOnUse">
<rect width="100" height="100" fill="#596a48" />
<path d="M12 28h24M44 66h34M64 18l18 12M20 84l22-16" stroke="#a8ba74" stroke-width="3" opacity="0.18" />
<path d="M12 56l14-22M50 38l20-18M76 80l10-24" stroke="#2a3d23" stroke-width="2" opacity="0.34" />
</pattern>
<pattern id="road" width="112" height="112" patternUnits="userSpaceOnUse">
<rect width="112" height="112" fill="#866b43" />
<path d="M0 30c42-18 78 14 112-8M0 78c44-20 76 12 112-5" stroke="#c0a068" stroke-width="8" opacity="0.25" />
<path d="M18 88l22-28M60 46l26-18M86 86l12-10" stroke="#3a2d20" stroke-width="3" opacity="0.36" />
</pattern>
<pattern id="grove" width="132" height="112" patternUnits="userSpaceOnUse">
<rect width="132" height="112" fill="#203826" />
<circle cx="24" cy="48" r="30" fill="#142818" />
<circle cx="64" cy="34" r="38" fill="#41633f" />
<circle cx="104" cy="54" r="31" fill="#1f3822" />
<circle cx="54" cy="84" r="28" fill="#527046" />
<path d="M12 66c36 18 78 16 110-10M36 26c24 20 58 18 76-8" stroke="#0b190e" stroke-width="5" opacity="0.36" />
</pattern>
<pattern id="ridge" width="132" height="100" patternUnits="userSpaceOnUse">
<rect width="132" height="100" fill="#706747" />
<path d="M-12 86c48-62 92-82 134-64c38 16 58 46 76 82" fill="#494832" opacity="0.72" />
<path d="M18 78c38-42 74-52 110-32" fill="none" stroke="#9a925e" stroke-width="10" opacity="0.28" />
</pattern>
<linearGradient id="river" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#54859a" />
<stop offset="0.52" stop-color="#275a72" />
<stop offset="1" stop-color="#123247" />
</linearGradient>
<linearGradient id="campWall" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#8b7358" />
<stop offset="1" stop-color="#3f3022" />
</linearGradient>
<radialGradient id="watchFire" cx="0.5" cy="0.42" r="0.6">
<stop offset="0" stop-color="#ffe7a0" />
<stop offset="0.48" stop-color="#d77a32" />
<stop offset="1" stop-color="#4b1e14" stop-opacity="0" />
</radialGradient>
</defs>
<rect width="2800" height="2200" fill="#121b20" />
<rect width="2800" height="2200" fill="url(#field)" opacity="0.95" />
<g filter="url(#marchGrain)">
<path d="M-90 1500C330 1450 750 1480 1120 1496C1510 1514 1970 1490 2910 1450" fill="none" stroke="#94754a" stroke-width="260" stroke-linecap="round" opacity="0.9" />
<path d="M-90 1500C330 1450 750 1480 1120 1496C1510 1514 1970 1490 2910 1450" fill="none" stroke="url(#road)" stroke-width="154" stroke-linecap="round" opacity="0.93" />
<path d="M790 2180C810 1820 804 1440 800 1100C796 800 792 520 806 170" fill="none" stroke="#94754a" stroke-width="190" stroke-linecap="round" opacity="0.85" />
<path d="M790 2180C810 1820 804 1440 800 1100C796 800 792 520 806 170" fill="none" stroke="url(#road)" stroke-width="108" stroke-linecap="round" opacity="0.84" />
<path d="M2260 1830C2262 1440 2250 1080 2266 720C2274 540 2290 360 2268 110" fill="none" stroke="#94754a" stroke-width="176" stroke-linecap="round" opacity="0.86" />
<path d="M2260 1830C2262 1440 2250 1080 2266 720C2274 540 2290 360 2268 110" fill="none" stroke="url(#road)" stroke-width="98" stroke-linecap="round" opacity="0.84" />
<path d="M1620 2220c84-300 78-570-12-808c-80-210-64-424 48-642c88-172 102-362 44-574" fill="none" stroke="#123248" stroke-width="286" stroke-linecap="round" opacity="0.95" />
<path d="M1620 2220c84-300 78-570-12-808c-80-210-64-424 48-642c88-172 102-362 44-574" fill="none" stroke="url(#river)" stroke-width="182" stroke-linecap="round" opacity="0.98" />
<path d="M0 20c278-76 548-32 810 130c54 178-80 324-398 438C178 536 34 414-14 246Z" fill="url(#grove)" opacity="0.84" />
<path d="M270 760c316-120 612-70 888 154c-118 238-410 368-872 386C136 1130 128 946 270 760Z" fill="url(#grove)" opacity="0.82" />
<path d="M870 1840c228-84 450-52 660 96c-44 164-200 260-464 288c-144-72-210-196-196-384Z" fill="url(#grove)" opacity="0.78" />
<path d="M2140 40c208-56 392-22 552 96v300c-190 48-366 10-526-112c-66-92-74-188-26-284Z" fill="url(#ridge)" opacity="0.82" />
<path d="M2440 1660c184-54 346-22 484 96v430c-198 58-370 24-516-102c-46-130-36-272 32-424Z" fill="url(#ridge)" opacity="0.82" />
<g transform="translate(2100 760)">
<rect x="-70" y="-58" width="650" height="610" rx="18" fill="#1a130e" opacity="0.54" />
<rect x="0" y="0" width="520" height="500" rx="12" fill="url(#campWall)" stroke="#24190f" stroke-width="18" />
<path d="M0 104h520M0 214h520M0 324h520M0 430h520" stroke="#2e241b" stroke-width="12" opacity="0.72" />
<path d="M110 0v500M260 0v500M410 0v500" stroke="#65513b" stroke-width="10" opacity="0.48" />
<rect x="160" y="210" width="200" height="126" fill="#1f1713" stroke="#0e0907" stroke-width="11" />
<path d="M160 210l100-66l100 66" fill="#764b24" stroke="#21160e" stroke-width="11" />
<path d="M224 336v-76h72v76" fill="#0d0907" />
<circle cx="78" cy="140" r="70" fill="url(#watchFire)" opacity="0.68" />
<circle cx="450" cy="360" r="72" fill="url(#watchFire)" opacity="0.64" />
</g>
<g transform="translate(820 1000)">
<rect x="0" y="0" width="260" height="140" rx="10" fill="#604a35" stroke="#251d16" stroke-width="10" />
<path d="M-20 18L130-74l152 92Z" fill="#9d642e" stroke="#2b1f14" stroke-width="10" />
<path d="M78 140v-62h60v62" fill="#201713" />
</g>
<g transform="translate(1425 1540)">
<rect x="0" y="0" width="246" height="132" rx="10" fill="#604a35" stroke="#251d16" stroke-width="10" />
<path d="M-18 14L123-68l142 82Z" fill="#8f5b2b" stroke="#2b1f14" stroke-width="10" />
<path d="M76 132v-58h58v58" fill="#201713" />
</g>
<path d="M2000 900c220 44 470 30 754-40" fill="none" stroke="#15100d" stroke-width="18" opacity="0.3" />
<path d="M2020 1270c220 48 478 34 776-42" fill="none" stroke="#15100d" stroke-width="18" opacity="0.28" />
<path d="M180 1500c300 58 622 58 970 0" fill="none" stroke="#d8b15f" stroke-width="10" opacity="0.15" />
</g>
<rect width="2800" height="2200" fill="#07090c" opacity="0.12" />
<path d="M2180 802c-120 170-92 360 84 570" fill="none" stroke="#ffe0a0" stroke-width="8" opacity="0.08" />
<path d="M188 1488c260-20 520-14 780 18" fill="none" stroke="#fff0bd" stroke-width="8" opacity="0.08" />
</svg>

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@@ -4,6 +4,10 @@ import {
eighthBattleMap,
eighthBattleUnits,
eighthBattleVictoryPages,
eleventhBattleBonds,
eleventhBattleMap,
eleventhBattleUnits,
eleventhBattleVictoryPages,
firstBattleMap,
firstBattleUnits,
firstBattleVictoryPages,
@@ -55,7 +59,8 @@ export type BattleScenarioId =
| 'seventh-battle-xuzhou-rescue'
| 'eighth-battle-xiaopei-supply-road'
| 'ninth-battle-xuzhou-gate-night-raid'
| 'tenth-battle-xuzhou-breakout';
| 'tenth-battle-xuzhou-breakout'
| 'eleventh-battle-xudu-refuge-road';
export type BattleObjectiveKind = 'defeat-leader' | 'keep-unit-alive' | 'secure-terrain' | 'quick-victory';
@@ -624,6 +629,59 @@ export const tenthBattleScenario: BattleScenarioDefinition = {
nextCampScene: 'CampScene'
};
export const eleventhBattleScenario: BattleScenarioDefinition = {
id: 'eleventh-battle-xudu-refuge-road',
title: '허도 입성로',
victoryConditionLabel: '기령 격파',
defeatConditionLabel: '유비 퇴각',
openingObjectiveLines: [
'조조는 유비군을 받아들이는 대신 허도 입성로를 어지럽히는 원술 잔군을 치라는 명을 내립니다. 기령을 격파하면 길이 열립니다.',
'원술군 기병은 길을 따라 돌파하고, 약탈병은 숲과 마을을 끼고 보급을 노립니다. 조조군의 감시 속에서 군율을 증명해야 합니다.',
'마을을 확보하고 18턴 안에 승리하면 조조 의탁 이후에도 병력과 민심을 더 많이 보존합니다.'
],
map: eleventhBattleMap,
units: eleventhBattleUnits,
bonds: eleventhBattleBonds,
mapTextureKey: 'battle-map-eleventh',
leaderUnitId: 'xudu-road-leader-ji-ling',
quickVictoryTurnLimit: 18,
baseVictoryGold: 1320,
objectives: [
{
id: 'leader',
kind: 'defeat-leader',
label: '기령 격파',
rewardGold: 820,
unitId: 'xudu-road-leader-ji-ling'
},
{
id: 'liu-bei',
kind: 'keep-unit-alive',
label: '유비 생존',
rewardGold: 320,
unitId: 'liu-bei'
},
{
id: 'village',
kind: 'secure-terrain',
label: '허도 길목 마을 확보',
rewardGold: 460,
terrain: 'village'
},
{
id: 'quick',
kind: 'quick-victory',
label: '18턴 이내 승리',
rewardGold: 360,
maxTurn: 18
}
],
defeatConditions: [{ kind: 'unit-defeated', unitId: 'liu-bei' }],
itemRewards: ['콩 4', '상처약 3', '탁주 1', '조조군 군문 통행 +1'],
victoryPages: eleventhBattleVictoryPages,
nextCampScene: 'CampScene'
};
export const defaultBattleScenarioId: BattleScenarioId = firstBattleScenario.id;
export const battleScenarios: Record<BattleScenarioId, BattleScenarioDefinition> = {
@@ -636,7 +694,8 @@ export const battleScenarios: Record<BattleScenarioId, BattleScenarioDefinition>
'seventh-battle-xuzhou-rescue': seventhBattleScenario,
'eighth-battle-xiaopei-supply-road': eighthBattleScenario,
'ninth-battle-xuzhou-gate-night-raid': ninthBattleScenario,
'tenth-battle-xuzhou-breakout': tenthBattleScenario
'tenth-battle-xuzhou-breakout': tenthBattleScenario,
'eleventh-battle-xudu-refuge-road': eleventhBattleScenario
};
export const defaultBattleScenario = battleScenarios[defaultBattleScenarioId];

View File

@@ -2,6 +2,7 @@ import type { CampaignStep } from '../state/campaignState';
import {
defaultBattleScenario,
eighthBattleScenario,
eleventhBattleScenario,
fifthBattleScenario,
fourthBattleScenario,
ninthBattleScenario,
@@ -15,6 +16,8 @@ import {
import {
eighthBattleIntroPages,
eighthBattleVictoryPages,
eleventhBattleIntroPages,
eleventhBattleVictoryPages,
fifthBattleIntroPages,
fifthBattleVictoryPages,
firstBattleVictoryPages,
@@ -140,12 +143,22 @@ const sortieFlows: Record<string, SortieFlow> = {
},
[tenthBattleScenario.id]: {
afterBattleId: tenthBattleScenario.id,
eyebrow: '다음 전장',
title: eleventhBattleScenario.title,
description: '조조는 유비군을 받아들이되 허도 입성로의 원술 잔군을 치게 합니다. 의탁은 시작되지만, 동시에 유비군의 군율과 힘을 재는 시험입니다.',
rewardHint: `예상 보상: ${eleventhBattleScenario.title} 개방`,
nextBattleId: eleventhBattleScenario.id,
campaignStep: 'eleventh-battle',
pages: [...tenthBattleVictoryPages, ...eleventhBattleIntroPages]
},
[eleventhBattleScenario.id]: {
afterBattleId: eleventhBattleScenario.id,
eyebrow: '다음 장 준비',
title: '조조 의탁의 길',
description: '서주를 잃은 유비군은 살아남은 병력과 백성을 추슬러 허도로 향할 길을 찾습니다. 다음 흐름은 조조 의탁과 그 안의 긴장으로 이어집니다.',
rewardHint: '다음 장: 조조 의탁 준비 중',
pages: tenthBattleVictoryPages,
unavailableNotice: '조조 의탁 장은 다음 작업에서 이어집니다. 군영에서 성장과 출전 구성을 정비하십시오.'
title: '조조의 그늘 아래',
description: '허도 입성로를 열어 조조의 장막에 들어섰지만, 유비군은 보호와 감시를 동시에 받게 됩니다. 다음 흐름은 조조 밑에서 기회를 기다리는 장으로 이어집니다.',
rewardHint: '다음 장: 조조 휘하의 긴장 준비 중',
pages: eleventhBattleVictoryPages,
unavailableNotice: '조조 휘하 장은 다음 작업에서 이어집니다. 군영에서 성장과 출전 구성을 정비하십시오.'
}
};

View File

@@ -704,6 +704,61 @@ export const tenthBattleVictoryPages: StoryPage[] = [
}
];
export const eleventhBattleIntroPages: StoryPage[] = [
{
id: 'eleventh-arrive-cao-cao-camp',
bgm: 'story-dark',
chapter: '조조의 장막',
background: 'story-liu-bei',
text: '서주를 잃은 유비군은 허도로 향했다. 조조는 웃으며 맞이했지만, 그 웃음 뒤에는 사람을 재는 날카로운 눈빛이 숨어 있었다.'
},
{
id: 'eleventh-cao-cao-test',
bgm: 'militia-theme',
chapter: '의탁의 대가',
background: 'story-militia',
speaker: '간옹',
text: '현덕, 조조는 우리에게 머물 곳을 주겠지만 공짜로 주지는 않을 걸세. 허도 입성로에 남은 원술 잔군을 치라는 명은 환대이자 시험이지.'
},
{
id: 'eleventh-brothers-under-watch',
bgm: 'story-dark',
chapter: '감시 속의 출진',
background: 'story-three-heroes',
speaker: '관우',
portrait: 'guanYu',
text: '형님, 조조의 깃발 아래 싸우더라도 우리의 칼끝은 백성을 향하지 않습니다. 명을 따르되 뜻은 잃지 않겠습니다.'
},
{
id: 'eleventh-xudu-road-sortie',
bgm: 'battle-prep',
chapter: '허도 입성로',
background: 'story-sortie',
speaker: '유비',
portrait: 'liuBei',
text: '지금은 몸을 낮추어야 할 때다. 그러나 낮춘 몸 안에서도 마음까지 굽힐 수는 없다. 길을 열고, 살아남아 다시 일어서자.'
}
];
export const eleventhBattleVictoryPages: StoryPage[] = [
{
id: 'eleventh-victory-road-cleared',
bgm: 'militia-theme',
chapter: '열린 허도 길',
background: 'story-militia',
text: '원술 잔군은 물러났고 허도로 향하는 길이 열렸다. 조조군의 감시병들은 유비군의 전열과 군율을 조용히 기록했다.'
},
{
id: 'eleventh-cao-cao-accepts',
bgm: 'story-dark',
chapter: '웃음 속의 칼',
background: 'story-liu-bei',
speaker: '유비',
portrait: 'liuBei',
text: '조조의 장막은 비바람을 피하게 해 주지만, 그 안의 공기는 오래 머물수록 무겁다. 나는 살아남기 위해 들어가되, 나올 날을 잊지 않겠다.'
}
];
export const firstBattleMap: BattleMap = {
width: 20,
height: 18,
@@ -909,6 +964,12 @@ export const tenthBattleMap: BattleMap = {
terrain: createTenthBattleTerrain()
};
export const eleventhBattleMap: BattleMap = {
width: 28,
height: 22,
terrain: createEleventhBattleTerrain()
};
export const firstBattleUnits: UnitData[] = [
{
id: 'liu-bei',
@@ -3507,6 +3568,272 @@ export const tenthBattleUnits: UnitData[] = [
}
];
const eleventhBattleAllyPositions: Record<string, { x: number; y: number }> = {
'liu-bei': { x: 3, y: 15 },
'guan-yu': { x: 4, y: 14 },
'zhang-fei': { x: 4, y: 16 },
'jian-yong': { x: 5, y: 17 },
'mi-zhu': { x: 3, y: 18 }
};
export const eleventhBattleUnits: UnitData[] = [
...[...firstBattleUnits.filter((unit) => unit.faction === 'ally'), ...xuzhouRecruitUnits].map((unit) =>
placeScenarioUnit(unit, eleventhBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })
),
{
id: 'xudu-road-raider-a',
name: '원술군 잔병',
faction: 'enemy',
className: '원술군 보병',
classKey: 'yellowTurban',
level: 11,
exp: 68,
hp: 50,
maxHp: 50,
attack: 16,
move: 3,
stats: { might: 84, intelligence: 54, leadership: 70, agility: 64, luck: 56 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 42 },
armor: { itemId: 'lamellar-armor', level: 2, exp: 20 },
accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
},
x: 11,
y: 12
},
{
id: 'xudu-road-raider-b',
name: '원술군 잔병',
faction: 'enemy',
className: '원술군 보병',
classKey: 'yellowTurban',
level: 11,
exp: 70,
hp: 51,
maxHp: 51,
attack: 16,
move: 3,
stats: { might: 85, intelligence: 55, leadership: 71, agility: 65, luck: 56 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 44 },
armor: { itemId: 'lamellar-armor', level: 2, exp: 20 },
accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
},
x: 13,
y: 16
},
{
id: 'xudu-road-raider-c',
name: '원술군 잔병',
faction: 'enemy',
className: '원술군 보병',
classKey: 'yellowTurban',
level: 11,
exp: 70,
hp: 51,
maxHp: 51,
attack: 16,
move: 3,
stats: { might: 85, intelligence: 55, leadership: 71, agility: 65, luck: 56 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 44 },
armor: { itemId: 'lamellar-armor', level: 2, exp: 20 },
accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
},
x: 17,
y: 11
},
{
id: 'xudu-road-bandit-a',
name: '군량 약탈병',
faction: 'enemy',
className: '원술군 약탈병',
classKey: 'bandit',
level: 11,
exp: 68,
hp: 45,
maxHp: 45,
attack: 17,
move: 4,
stats: { might: 86, intelligence: 53, leadership: 62, agility: 78, luck: 56 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 42 },
armor: { itemId: 'rebel-vest', level: 2, exp: 20 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 8,
y: 8
},
{
id: 'xudu-road-bandit-b',
name: '군량 약탈병',
faction: 'enemy',
className: '원술군 약탈병',
classKey: 'bandit',
level: 11,
exp: 70,
hp: 46,
maxHp: 46,
attack: 17,
move: 4,
stats: { might: 87, intelligence: 53, leadership: 63, agility: 79, luck: 56 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 44 },
armor: { itemId: 'rebel-vest', level: 2, exp: 20 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 9,
y: 18
},
{
id: 'xudu-road-archer-a',
name: '원술군 궁병',
faction: 'enemy',
className: '원술군 궁병',
classKey: 'archer',
level: 11,
exp: 68,
hp: 40,
maxHp: 40,
attack: 17,
move: 3,
stats: { might: 76, intelligence: 66, leadership: 64, agility: 76, luck: 57 },
equipment: {
weapon: { itemId: 'short-bow', level: 2, exp: 42 },
armor: { itemId: 'cloth-armor', level: 2, exp: 18 },
accessory: { itemId: 'wind-quiver', level: 1, exp: 0 }
},
x: 16,
y: 6
},
{
id: 'xudu-road-archer-b',
name: '원술군 궁병',
faction: 'enemy',
className: '원술군 궁병',
classKey: 'archer',
level: 11,
exp: 70,
hp: 41,
maxHp: 41,
attack: 17,
move: 3,
stats: { might: 77, intelligence: 67, leadership: 65, agility: 77, luck: 57 },
equipment: {
weapon: { itemId: 'short-bow', level: 2, exp: 44 },
armor: { itemId: 'cloth-armor', level: 2, exp: 18 },
accessory: { itemId: 'wind-quiver', level: 1, exp: 0 }
},
x: 20,
y: 15
},
{
id: 'xudu-road-cavalry-a',
name: '원술군 기병',
faction: 'enemy',
className: '원술군 기병',
classKey: 'cavalry',
level: 12,
exp: 72,
hp: 55,
maxHp: 55,
attack: 19,
move: 5,
stats: { might: 93, intelligence: 54, leadership: 72, agility: 94, luck: 59 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 48 },
armor: { itemId: 'rebel-vest', level: 2, exp: 22 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 21,
y: 8
},
{
id: 'xudu-road-cavalry-b',
name: '원술군 기병',
faction: 'enemy',
className: '원술군 기병',
classKey: 'cavalry',
level: 12,
exp: 74,
hp: 56,
maxHp: 56,
attack: 19,
move: 5,
stats: { might: 94, intelligence: 54, leadership: 72, agility: 95, luck: 59 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 50 },
armor: { itemId: 'rebel-vest', level: 2, exp: 22 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 22,
y: 17
},
{
id: 'xudu-road-guard-a',
name: '원술군 친위병',
faction: 'enemy',
className: '원술군 창병',
classKey: 'spearman',
level: 12,
exp: 72,
hp: 54,
maxHp: 54,
attack: 18,
move: 3,
stats: { might: 90, intelligence: 56, leadership: 76, agility: 68, luck: 58 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 48 },
armor: { itemId: 'lamellar-armor', level: 2, exp: 22 },
accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
},
x: 23,
y: 11
},
{
id: 'xudu-road-guard-b',
name: '원술군 친위병',
faction: 'enemy',
className: '원술군 창병',
classKey: 'spearman',
level: 12,
exp: 74,
hp: 55,
maxHp: 55,
attack: 18,
move: 3,
stats: { might: 91, intelligence: 56, leadership: 76, agility: 69, luck: 58 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 50 },
armor: { itemId: 'lamellar-armor', level: 2, exp: 22 },
accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
},
x: 24,
y: 13
},
{
id: 'xudu-road-leader-ji-ling',
name: '기령',
faction: 'enemy',
className: '원술군 대장',
classKey: 'rebelLeader',
level: 13,
exp: 80,
hp: 74,
maxHp: 74,
attack: 20,
move: 4,
stats: { might: 94, intelligence: 64, leadership: 88, agility: 74, luck: 60 },
equipment: {
weapon: { itemId: 'leader-axe', level: 2, exp: 52 },
armor: { itemId: 'reinforced-lamellar', level: 2, exp: 24 },
accessory: { itemId: 'war-manual', level: 1, exp: 0 }
},
x: 24,
y: 10
}
];
export const firstBattleBonds: BattleBond[] = [
{
id: 'liu-bei__guan-yu',
@@ -3562,6 +3889,7 @@ export const seventhBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattle
export const eighthBattleBonds: BattleBond[] = [...firstBattleBonds, ...xuzhouRecruitBonds].map(cloneBattleBondForScenario);
export const ninthBattleBonds: BattleBond[] = eighthBattleBonds.map(cloneBattleBondForScenario);
export const tenthBattleBonds: BattleBond[] = ninthBattleBonds.map(cloneBattleBondForScenario);
export const eleventhBattleBonds: BattleBond[] = tenthBattleBonds.map(cloneBattleBondForScenario);
function createEighthBattleTerrain(): TerrainType[][] {
return Array.from({ length: 22 }, (_, y) =>
@@ -3671,6 +3999,53 @@ function createTenthBattleTerrain(): TerrainType[][] {
);
}
function createEleventhBattleTerrain(): TerrainType[][] {
return Array.from({ length: 22 }, (_, y) =>
Array.from({ length: 28 }, (_, x): TerrainType => {
if ((x <= 4 && y >= 14 && y <= 20) || (x >= 5 && x <= 7 && y >= 16 && y <= 20)) {
return 'camp';
}
if ((x >= 22 && x <= 26 && y >= 8 && y <= 13) || (x >= 19 && x <= 21 && y >= 5 && y <= 8)) {
return 'fort';
}
if ((x >= 8 && x <= 10 && y >= 10 && y <= 12) || (x >= 14 && x <= 16 && y >= 15 && y <= 17)) {
return 'village';
}
if ((x === 17 || x === 18) && y >= 0 && y <= 19) {
return 'river';
}
if (
(y === 15 && x >= 2 && x <= 25) ||
(x === 8 && y >= 6 && y <= 20) ||
(x === 22 && y >= 4 && y <= 17) ||
(x + y === 28 && x >= 9 && x <= 21)
) {
return 'road';
}
if (
(x <= 5 && y <= 5) ||
(x >= 6 && x <= 13 && y >= 2 && y <= 7) ||
(x >= 3 && x <= 8 && y >= 8 && y <= 13) ||
(x >= 9 && x <= 14 && y >= 18 && y <= 21)
) {
return 'forest';
}
if (
(x >= 21 && y <= 3) ||
(x >= 24 && y >= 16) ||
(x >= 11 && x <= 14 && y >= 11 && y <= 14) ||
(x <= 4 && y >= 6 && y <= 10)
) {
return 'hill';
}
if ((x >= 25 && y >= 4 && y <= 7) || (x >= 19 && x <= 21 && y <= 2)) {
return 'cliff';
}
return 'plain';
})
);
}
function placeScenarioUnit(unit: UnitData, position: { x: number; y: number }): UnitData {
return {
...cloneUnitForScenario(unit),

View File

@@ -556,7 +556,19 @@ const enemyAiByUnitId: Record<string, EnemyAiBehavior> = {
'xuzhou-escape-archer-b': 'hold',
'xuzhou-escape-raider-a': 'aggressive',
'xuzhou-escape-raider-b': 'aggressive',
'xuzhou-escape-leader-song-xian': 'guard'
'xuzhou-escape-leader-song-xian': 'guard',
'xudu-road-raider-a': 'guard',
'xudu-road-raider-b': 'guard',
'xudu-road-raider-c': 'guard',
'xudu-road-bandit-a': 'aggressive',
'xudu-road-bandit-b': 'aggressive',
'xudu-road-archer-a': 'hold',
'xudu-road-archer-b': 'hold',
'xudu-road-cavalry-a': 'aggressive',
'xudu-road-cavalry-b': 'aggressive',
'xudu-road-guard-a': 'guard',
'xudu-road-guard-b': 'guard',
'xudu-road-leader-ji-ling': 'guard'
};
const defaultEnemyAiByClass: Partial<Record<UnitClassKey, EnemyAiBehavior>> = {

View File

@@ -1,5 +1,6 @@
import Phaser from 'phaser';
import eighthBattleMapUrl from '../../assets/images/battle/eighth-battle-map.svg';
import eleventhBattleMapUrl from '../../assets/images/battle/eleventh-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';
@@ -78,6 +79,7 @@ export class BootScene extends Phaser.Scene {
this.load.image('battle-map-eighth', eighthBattleMapUrl);
this.load.image('battle-map-ninth', ninthBattleMapUrl);
this.load.image('battle-map-tenth', tenthBattleMapUrl);
this.load.image('battle-map-eleventh', eleventhBattleMapUrl);
this.load.image('portrait-liu-bei', liuBeiPortraitUrl);
this.load.image('portrait-guan-yu', guanYuPortraitUrl);
this.load.image('portrait-zhang-fei', zhangFeiPortraitUrl);

View File

@@ -140,7 +140,8 @@ const campBattleIds = {
seventh: 'seventh-battle-xuzhou-rescue',
eighth: 'eighth-battle-xiaopei-supply-road',
ninth: 'ninth-battle-xuzhou-gate-night-raid',
tenth: 'tenth-battle-xuzhou-breakout'
tenth: 'tenth-battle-xuzhou-breakout',
eleventh: 'eleventh-battle-xudu-refuge-road'
} as const;
const requiredSortieUnitIds = new Set(['liu-bei']);
@@ -1010,6 +1011,87 @@ const campDialogues: CampDialogue[] = [
rewardExp: 8
}
]
},
{
id: 'liu-guan-after-cao-cao-refuge',
title: '남의 깃발 아래',
availableAfterBattleIds: [campBattleIds.eleventh],
unitIds: ['liu-bei', 'guan-yu'],
bondId: 'liu-bei__guan-yu',
rewardExp: 22,
lines: [
'관우: 조조의 깃발 아래 서 있으니 병사들의 눈빛이 조심스러워졌습니다.',
'유비: 운장, 깃발은 잠시 빌릴 수 있어도 뜻은 빌릴 수 없다. 우리가 지켜야 할 선을 잊지 맙시다.',
'관우: 형님의 뜻이 흔들리지 않는다면, 남의 진영에서도 우리의 칼은 길을 잃지 않을 것입니다.'
],
choices: [
{
id: 'keep-oath-visible',
label: '도원의 뜻을 분명히 한다',
response: '관우는 병사들에게 조조의 명을 따르되 유비군의 군율을 잃지 말라고 전했다.',
rewardExp: 10
},
{
id: 'watch-cao-cao-camp',
label: '조조 진영의 움직임을 살핀다',
response: '관우는 지나친 경계가 오히려 의심을 부른다며 조용한 감시를 맡았다.',
rewardExp: 9
}
]
},
{
id: 'liu-jian-yong-after-cao-cao-refuge',
title: '웃음 뒤의 문장',
availableAfterBattleIds: [campBattleIds.eleventh],
unitIds: ['liu-bei', 'jian-yong'],
bondId: 'liu-bei__jian-yong',
rewardExp: 20,
lines: [
'간옹: 조조는 웃으며 맞았지만, 그 웃음은 사람을 묶는 줄이기도 하네.',
'유비: 그 줄을 끊으면 목이 조이고, 그대로 두면 발이 묶일 것이오.',
'간옹: 그래서 말과 문서를 조심해야지. 지금은 충심을 보이되, 떠날 길의 이름을 잊지 않는 때라네.'
],
choices: [
{
id: 'write-carefully',
label: '문서를 조심스럽게 정리한다',
response: '간옹은 조조에게 보일 문서와 유비군 내부 문서를 따로 정리했다.',
rewardExp: 9
},
{
id: 'hear-rumors',
label: '허도 소문을 모은다',
response: '간옹은 말 많은 술자리와 시장길에서 조조 진영의 분위기를 살피기로 했다.',
rewardExp: 8
}
]
},
{
id: 'liu-mi-after-cao-cao-refuge',
title: '빌린 창고',
availableAfterBattleIds: [campBattleIds.eleventh],
unitIds: ['liu-bei', 'mi-zhu'],
bondId: 'liu-bei__mi-zhu',
rewardExp: 18,
lines: [
'미축: 조조군의 창고를 빌리더라도 우리 병사의 식량 장부는 따로 남겨 두겠습니다.',
'유비: 군량 하나에도 마음이 묶이는 법이오. 빌린 것은 빌린 것이라 분명히 해야 하오.',
'미축: 그렇다면 보급은 받되, 유비군이 스스로 버틸 양도 함께 모아 두겠습니다.'
],
choices: [
{
id: 'separate-ledgers',
label: '장부를 분리한다',
response: '미축은 조조군 보급과 유비군 예비 군량을 따로 기록해 훗날의 발목을 줄였다.',
rewardExp: 8
},
{
id: 'save-refugee-grain',
label: '피난민 군량을 남긴다',
response: '미축은 병사 몫을 줄이지 않으면서도 피난민에게 갈 곡식을 별도로 숨겨 두었다.',
rewardExp: 8
}
]
}
];
@@ -1092,7 +1174,8 @@ export class CampScene extends Phaser.Scene {
(battleId !== campBattleIds.seventh &&
battleId !== campBattleIds.eighth &&
battleId !== campBattleIds.ninth &&
battleId !== campBattleIds.tenth) ||
battleId !== campBattleIds.tenth &&
battleId !== campBattleIds.eleventh) ||
!this.campaign
) {
return;
@@ -1114,6 +1197,9 @@ export class CampScene extends Phaser.Scene {
private currentCampTitle() {
const battleId = this.currentCampBattleId();
if (battleId === campBattleIds.eleventh) {
return '허도 의탁 군영';
}
if (battleId === campBattleIds.tenth) {
return '서주 상실 후 군영';
}

View File

@@ -3,6 +3,7 @@ import { soundDirector } from '../audio/SoundDirector';
import { firstBattleVictoryPages } from '../data/scenario';
import {
eighthBattleScenario,
eleventhBattleScenario,
fifthBattleScenario,
fourthBattleScenario,
ninthBattleScenario,
@@ -319,7 +320,8 @@ export class TitleScene extends Phaser.Scene {
campaign.step === 'seventh-camp' ||
campaign.step === 'eighth-camp' ||
campaign.step === 'ninth-camp' ||
campaign.step === 'tenth-camp'
campaign.step === 'tenth-camp' ||
campaign.step === 'eleventh-camp'
) {
this.scene.start('CampScene');
return;
@@ -375,6 +377,11 @@ export class TitleScene extends Phaser.Scene {
return;
}
if (campaign.step === 'eleventh-battle') {
this.scene.start('BattleScene', { battleId: eleventhBattleScenario.id });
return;
}
if (campaign.step === 'first-victory-story') {
this.scene.start('StoryScene', { pages: firstBattleVictoryPages, nextScene: 'TitleScene' });
return;

View File

@@ -59,7 +59,9 @@ export type CampaignStep =
| 'ninth-battle'
| 'ninth-camp'
| 'tenth-battle'
| 'tenth-camp';
| 'tenth-camp'
| 'eleventh-battle'
| 'eleventh-camp';
export type CampaignUnitProgressSnapshot = {
unitId: string;
@@ -120,7 +122,8 @@ const campaignBattleSteps: Record<string, { victory: CampaignStep; retry: Campai
'seventh-battle-xuzhou-rescue': { victory: 'seventh-camp', retry: 'seventh-battle' },
'eighth-battle-xiaopei-supply-road': { victory: 'eighth-camp', retry: 'eighth-battle' },
'ninth-battle-xuzhou-gate-night-raid': { victory: 'ninth-camp', retry: 'ninth-battle' },
'tenth-battle-xuzhou-breakout': { victory: 'tenth-camp', retry: 'tenth-battle' }
'tenth-battle-xuzhou-breakout': { victory: 'tenth-camp', retry: 'tenth-battle' },
'eleventh-battle-xudu-refuge-road': { victory: 'eleventh-camp', retry: 'eleventh-battle' }
};
export type CampaignSaveSlotSummary = {