Add Xiapi final sortie chapter

This commit is contained in:
2026-06-23 03:43:53 +09:00
parent 450842bd28
commit 3cdbbe8d5a
12 changed files with 905 additions and 23 deletions

View File

@@ -32,12 +32,13 @@ Build a small complete tactical RPG loop that can grow into a longer Romance of
- 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
- Twelfth battle Xiapi outer siege, putting Liu Bei under Cao Cao's command against Lu Bu while preserving Liu Bei's independent motive and camp tension
- Tactical sortie preparation panel with battle-specific sortie limits, recommended officers with reasons, class role, named equipment, core stats, bond partner, next-map terrain suitability, deployment preview, formation roles, active bond count, and readiness advice for each deployable officer
- Flow verification script from title through the twelfth battle victory, recruit sortie selection, and camp save state
- Thirteenth battle Xiapi final clash, ending Lu Bu's arc and opening the tension that will lead toward Liu Bei breaking from Cao Cao
- Tactical sortie preparation panel with battle-specific sortie limits, recommended officers with reasons, class role, named equipment, core stats, bond partner, next-map terrain suitability, deployment preview, formation roles, active bond count, recruited-officer count, reserve roster summary, and readiness advice for each deployable officer
- Flow verification script from title through the thirteenth battle victory, recruit sortie selection, and camp save state
## Next Steps
1. Continue the Xiapi siege toward Lu Bu's fall, then build toward Liu Bei's break from Cao Cao
1. Build the Cao Cao break chapter, then move into Yuan Shao and Liu Biao refuge arcs
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

@@ -1257,6 +1257,93 @@ try {
throw new Error(`Expected twelfth camp to expose Xiapi siege dialogue set and preserve full roster: ${JSON.stringify(twelfthCampState)}`);
}
await page.mouse.click(1120, 38);
await page.waitForTimeout(160);
const twelfthCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
if (
!twelfthCampSortieState?.sortieVisible ||
!twelfthCampSortieState.sortiePlan?.objectiveLine?.includes('하비 결전') ||
twelfthCampSortieState.sortiePlan?.selectedRecruitedCount < 1 ||
!twelfthCampSortieState.sortiePlan?.recruitedLine?.includes('합류 무장')
) {
throw new Error(`Expected twelfth camp sortie prep to target Xiapi final battle with recruit selection summary: ${JSON.stringify(twelfthCampSortieState)}`);
}
assertSortieTacticalRoster(twelfthCampSortieState, ['liu-bei', 'guan-yu', 'zhang-fei', 'jian-yong', 'mi-zhu']);
if (!twelfthCampSortieState.sortieRoster.some((unit) => unit.id === 'jian-yong' && unit.recruited)) {
throw new Error(`Expected recruit officers to be marked in sortie roster: ${JSON.stringify(twelfthCampSortieState.sortieRoster)}`);
}
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-xiapi-final-story.png', fullPage: true });
for (let i = 0; i < 18; i += 1) {
const enteredThirteenthBattle = await page.evaluate(() => {
const state = window.__HEROS_DEBUG__?.battle();
return state?.scene === 'BattleScene' && state?.battleId === 'thirteenth-battle-xiapi-final';
});
if (enteredThirteenthBattle) {
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 === 'thirteenth-battle-xiapi-final' && state?.battleOutcome === null && state?.phase === 'idle';
});
await page.screenshot({ path: 'dist/verification-thirteenth-battle.png', fullPage: true });
const thirteenthBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
const thirteenthEnemies = thirteenthBattleState.units.filter((unit) => unit.faction === 'enemy');
const thirteenthAllies = thirteenthBattleState.units.filter((unit) => unit.faction === 'ally');
const thirteenthEnemyBehaviors = new Set(thirteenthEnemies.map((unit) => unit.ai));
if (
thirteenthBattleState.camera?.mapWidth !== 32 ||
thirteenthBattleState.camera?.mapHeight !== 24 ||
thirteenthBattleState.victoryConditionLabel !== '여포 격파' ||
thirteenthEnemies.length < 15 ||
!thirteenthEnemyBehaviors.has('aggressive') ||
!thirteenthEnemyBehaviors.has('guard') ||
!thirteenthEnemyBehaviors.has('hold') ||
!thirteenthEnemies.some((unit) => unit.id === 'xiapi-final-leader-lu-bu') ||
!thirteenthEnemies.some((unit) => unit.id === 'xiapi-final-strategist-chen-gong') ||
!thirteenthAllies.some((unit) => unit.id === 'liu-bei') ||
!thirteenthAllies.some((unit) => unit.id === 'guan-yu') ||
!thirteenthAllies.some((unit) => unit.id === 'zhang-fei') ||
!thirteenthAllies.some((unit) => unit.id === 'jian-yong') ||
!thirteenthAllies.some((unit) => unit.id === 'mi-zhu')
) {
throw new Error(`Expected thirteenth battle to use Xiapi final map, Lu Bu objective, mixed AI, and selected recruit sortie: ${JSON.stringify(thirteenthBattleState)}`);
}
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 thirteenthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
if (
thirteenthCampState?.campBattleId !== 'thirteenth-battle-xiapi-final' ||
thirteenthCampState.campTitle !== '하비 결전 후 군영' ||
thirteenthCampState.availableDialogueIds?.length !== 3 ||
!thirteenthCampState.availableDialogueIds.every((id) => id.endsWith('lu-bu-fall')) ||
!thirteenthCampState.campaign?.roster?.some((unit) => unit.id === 'guan-yu') ||
!thirteenthCampState.campaign?.roster?.some((unit) => unit.id === 'jian-yong') ||
!thirteenthCampState.campaign?.roster?.some((unit) => unit.id === 'mi-zhu')
) {
throw new Error(`Expected thirteenth camp to expose Lu Bu fall dialogue set and preserve full roster: ${JSON.stringify(thirteenthCampState)}`);
}
await page.evaluate(() => window.__HEROS_GAME__?.scene.start('TitleScene'));
await page.waitForFunction(() => {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
@@ -1268,7 +1355,7 @@ try {
return activeScenes.includes('CampScene');
});
console.log(`Verified title-to-twelfth-battle flow, recruit sortie selection, result states, and debug API at ${targetUrl}`);
console.log(`Verified title-to-thirteenth-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,95 @@
<svg xmlns="http://www.w3.org/2000/svg" width="3200" height="2400" viewBox="0 0 3200 2400">
<defs>
<linearGradient id="skyWash" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#8b8756"/>
<stop offset="0.48" stop-color="#b79a60"/>
<stop offset="1" stop-color="#77754f"/>
</linearGradient>
<radialGradient id="mudLight" cx="55%" cy="44%" r="62%">
<stop offset="0" stop-color="#d4bb7a"/>
<stop offset="0.62" stop-color="#a98d57"/>
<stop offset="1" stop-color="#716b45"/>
</radialGradient>
<linearGradient id="riverFlow" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#4e8394"/>
<stop offset="0.5" stop-color="#6ca8ad"/>
<stop offset="1" stop-color="#375e78"/>
</linearGradient>
<pattern id="grain" width="64" height="64" patternUnits="userSpaceOnUse">
<path d="M4 12h18M34 16h24M12 42h22M42 48h16" stroke="#2d2619" stroke-width="5" opacity=".18" fill="none"/>
<path d="M18 5l9 8M51 31l8 9M7 56l11-8" stroke="#e5cf8d" stroke-width="4" opacity=".16" fill="none"/>
</pattern>
</defs>
<rect width="3200" height="2400" fill="url(#skyWash)"/>
<rect width="3200" height="2400" fill="url(#mudLight)" opacity=".88"/>
<rect width="3200" height="2400" fill="url(#grain)" opacity=".65"/>
<rect x="1440" y="-80" width="270" height="2560" rx="120" fill="#5f98a6" opacity=".5"/>
<rect x="1888" y="30" width="238" height="2450" rx="105" fill="#5a90a2" opacity=".46"/>
<rect x="1538" y="-20" width="22" height="2120" rx="10" fill="#a8d5d0" opacity=".22"/>
<rect x="1998" y="90" width="18" height="1980" rx="9" fill="#a8d5d0" opacity=".2"/>
<g opacity=".42">
<ellipse cx="430" cy="310" rx="118" ry="72" fill="#718956"/>
<ellipse cx="650" cy="410" rx="156" ry="92" fill="#78905b"/>
<ellipse cx="918" cy="330" rx="132" ry="78" fill="#748a54"/>
<ellipse cx="420" cy="1715" rx="170" ry="92" fill="#708854"/>
<ellipse cx="760" cy="1788" rx="210" ry="105" fill="#778d58"/>
<ellipse cx="1045" cy="1848" rx="160" ry="86" fill="#718754"/>
<ellipse cx="2948" cy="2100" rx="185" ry="112" fill="#748b57"/>
</g>
<path d="M150 1938 L980 1938 L1430 1455 L2065 1455 L2450 1164 L2920 1164" stroke="#cbb27a" stroke-width="78" stroke-linecap="round" opacity=".92" fill="none"/>
<path d="M1020 1375 L1840 1375 L2430 895" stroke="#cbb27a" stroke-width="72" stroke-linecap="round" opacity=".88" fill="none"/>
<path d="M2450 735 L2450 1830" stroke="#cbb27a" stroke-width="68" stroke-linecap="round" opacity=".84" fill="none"/>
<path d="M150 1938 L980 1938 L1430 1455 L2065 1455 L2450 1164 L2920 1164" stroke="#6e4d2e" stroke-width="12" stroke-dasharray="46 48" opacity=".22" fill="none"/>
<path d="M1020 1375 L1840 1375 L2430 895" stroke="#6e4d2e" stroke-width="10" stroke-dasharray="44 46" opacity=".2" fill="none"/>
<g opacity=".58">
<rect x="2188" y="826" width="620" height="64" fill="#9e895f"/>
<rect x="2188" y="1438" width="620" height="64" fill="#927d58"/>
<rect x="2168" y="870" width="64" height="600" fill="#867250"/>
<rect x="2768" y="870" width="64" height="600" fill="#867250"/>
<rect x="2280" y="1018" width="390" height="300" rx="14" fill="#a58b58"/>
<rect x="2378" y="1112" width="196" height="126" fill="#756342" opacity=".5"/>
<path d="M2208 858 H2796" stroke="#d5bd81" stroke-width="10" opacity=".36" fill="none"/>
<path d="M2212 1470 H2792" stroke="#d5bd81" stroke-width="10" opacity=".3" fill="none"/>
<path d="M2242 892 V1458" stroke="#d5bd81" stroke-width="8" opacity=".25" fill="none"/>
<path d="M2796 892 V1458" stroke="#d5bd81" stroke-width="8" opacity=".25" fill="none"/>
</g>
<g opacity=".88">
<ellipse cx="1120" cy="1812" rx="165" ry="102" fill="#866b3e"/>
<rect x="1006" y="1740" width="230" height="130" fill="#a8874b"/>
<path d="M1006 1740 L1120 1662 L1236 1740Z" fill="#6b4a31"/>
<rect x="1078" y="1792" width="74" height="78" fill="#3b2b20"/>
<path d="M934 1905 C1040 1955 1235 1950 1315 1886" stroke="#d8c08a" stroke-width="18" opacity=".45" fill="none"/>
</g>
<g opacity=".78">
<circle cx="520" cy="420" r="72" fill="#41512d"/>
<circle cx="600" cy="465" r="90" fill="#536536"/>
<circle cx="710" cy="380" r="72" fill="#4f6034"/>
<circle cx="760" cy="535" r="95" fill="#44552f"/>
<circle cx="888" cy="430" r="78" fill="#526436"/>
<circle cx="528" cy="1270" r="84" fill="#4b5d33"/>
<circle cx="680" cy="1195" r="96" fill="#536a3b"/>
<circle cx="842" cy="1250" r="82" fill="#455830"/>
<circle cx="2780" cy="2055" r="88" fill="#4b5d33"/>
<circle cx="2912" cy="2130" r="104" fill="#536a3b"/>
</g>
<g opacity=".24">
<ellipse cx="930" cy="330" rx="190" ry="112" fill="#9b9366"/>
<ellipse cx="2920" cy="270" rx="230" ry="140" fill="#999165"/>
<ellipse cx="1160" cy="2190" rx="270" ry="150" fill="#9b9366"/>
</g>
<g opacity=".42">
<path d="M0 0H3200V2400H0Z" fill="none" stroke="#ead6a2" stroke-width="22"/>
<path d="M108 98H3092V2298H108Z" fill="none" stroke="#3a2c1b" stroke-width="12"/>
</g>
<rect width="3200" height="2400" fill="#101015" opacity=".035"/>
</svg>

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

@@ -67,6 +67,15 @@ export const itemCatalog: Record<string, ItemDefinition> = {
attackBonus: 5,
effects: ['연속 공격 판정 보정']
},
'sky-piercer-halberd': {
id: 'sky-piercer-halberd',
name: '방천화극',
slot: 'weapon',
rank: 'treasure',
description: '여포의 상징으로 두려움을 산 긴 화극. 한 번 휘두르면 전열이 크게 흔들린다.',
attackBonus: 6,
effects: ['단독으로 돌파할 때 피해 보너스']
},
'yellow-turban-saber': {
id: 'yellow-turban-saber',
name: '황건도',

View File

@@ -19,6 +19,10 @@ import {
tenthBattleMap,
tenthBattleUnits,
tenthBattleVictoryPages,
thirteenthBattleBonds,
thirteenthBattleMap,
thirteenthBattleUnits,
thirteenthBattleVictoryPages,
twelfthBattleBonds,
twelfthBattleMap,
twelfthBattleUnits,
@@ -65,7 +69,8 @@ export type BattleScenarioId =
| 'ninth-battle-xuzhou-gate-night-raid'
| 'tenth-battle-xuzhou-breakout'
| 'eleventh-battle-xudu-refuge-road'
| 'twelfth-battle-xiapi-outer-siege';
| 'twelfth-battle-xiapi-outer-siege'
| 'thirteenth-battle-xiapi-final';
export type BattleObjectiveKind = 'defeat-leader' | 'keep-unit-alive' | 'secure-terrain' | 'quick-victory';
@@ -740,6 +745,59 @@ export const twelfthBattleScenario: BattleScenarioDefinition = {
nextCampScene: 'CampScene'
};
export const thirteenthBattleScenario: BattleScenarioDefinition = {
id: 'thirteenth-battle-xiapi-final',
title: '하비 결전',
victoryConditionLabel: '여포 격파',
defeatConditionLabel: '유비 퇴각',
openingObjectiveLines: [
'여포가 하비 성 안에서 마지막 돌파를 준비합니다. 여포를 격파하면 서주를 뒤흔든 난전도 끝을 맞습니다.',
'진궁은 성루 궁병과 수문 수비를 이용해 접근로를 끊으려 합니다. 기병 돌파대와 성문 수비대를 나누어 상대하십시오.',
'성문 요새를 확보하고 22턴 안에 승리하면 조조 휘하에서도 유비군의 전공과 독자 보급 명분을 모두 남길 수 있습니다.'
],
map: thirteenthBattleMap,
units: thirteenthBattleUnits,
bonds: thirteenthBattleBonds,
mapTextureKey: 'battle-map-thirteenth',
leaderUnitId: 'xiapi-final-leader-lu-bu',
quickVictoryTurnLimit: 22,
baseVictoryGold: 1560,
objectives: [
{
id: 'leader',
kind: 'defeat-leader',
label: '여포 격파',
rewardGold: 980,
unitId: 'xiapi-final-leader-lu-bu'
},
{
id: 'liu-bei',
kind: 'keep-unit-alive',
label: '유비 생존',
rewardGold: 360,
unitId: 'liu-bei'
},
{
id: 'gate',
kind: 'secure-terrain',
label: '하비 성문 확보',
rewardGold: 560,
terrain: 'fort'
},
{
id: 'quick',
kind: 'quick-victory',
label: '22턴 이내 승리',
rewardGold: 420,
maxTurn: 22
}
],
defeatConditions: [{ kind: 'unit-defeated', unitId: 'liu-bei' }],
itemRewards: ['콩 5', '상처약 3', '탁주 2', '하비 결전 공적 +1'],
victoryPages: thirteenthBattleVictoryPages,
nextCampScene: 'CampScene'
};
export const defaultBattleScenarioId: BattleScenarioId = firstBattleScenario.id;
export const battleScenarios: Record<BattleScenarioId, BattleScenarioDefinition> = {
@@ -754,7 +812,8 @@ export const battleScenarios: Record<BattleScenarioId, BattleScenarioDefinition>
'ninth-battle-xuzhou-gate-night-raid': ninthBattleScenario,
'tenth-battle-xuzhou-breakout': tenthBattleScenario,
'eleventh-battle-xudu-refuge-road': eleventhBattleScenario,
'twelfth-battle-xiapi-outer-siege': twelfthBattleScenario
'twelfth-battle-xiapi-outer-siege': twelfthBattleScenario,
'thirteenth-battle-xiapi-final': thirteenthBattleScenario
};
export const defaultBattleScenario = battleScenarios[defaultBattleScenarioId];

View File

@@ -10,6 +10,7 @@ import {
seventhBattleScenario,
sixthBattleScenario,
tenthBattleScenario,
thirteenthBattleScenario,
twelfthBattleScenario,
thirdBattleScenario,
type BattleScenarioId
@@ -34,6 +35,8 @@ import {
sixthBattleVictoryPages,
tenthBattleIntroPages,
tenthBattleVictoryPages,
thirteenthBattleIntroPages,
thirteenthBattleVictoryPages,
twelfthBattleIntroPages,
twelfthBattleVictoryPages,
thirdBattleIntroPages,
@@ -166,12 +169,22 @@ const sortieFlows: Record<string, SortieFlow> = {
},
[twelfthBattleScenario.id]: {
afterBattleId: twelfthBattleScenario.id,
eyebrow: '다음 전장',
title: thirteenthBattleScenario.title,
description: '하비 외곽 포위망이 좁혀졌고, 여포의 마지막 운명도 가까워졌습니다. 성문과 수문을 뚫고 여포를 격파하면 조조 휘하에서 깊어지는 긴장이 다음 흐름으로 이어집니다.',
rewardHint: `예상 보상: ${thirteenthBattleScenario.title} 개방`,
nextBattleId: thirteenthBattleScenario.id,
campaignStep: 'thirteenth-battle',
pages: [...twelfthBattleVictoryPages, ...thirteenthBattleIntroPages]
},
[thirteenthBattleScenario.id]: {
afterBattleId: thirteenthBattleScenario.id,
eyebrow: '다음 장 준비',
title: '하비 포위의 끝',
description: '하비 외곽 포위망이 좁혀졌고, 여포의 마지막 운명도 가까워졌습니다. 다음 흐름은 여포 토벌의 결말과 조조 밑에서 깊어지장으로 이어집니다.',
rewardHint: '다음 장: 하비 결전과 조조 이탈 준비 중',
pages: twelfthBattleVictoryPages,
unavailableNotice: '하비 결전과 조조 이탈 장은 다음 작업에서 이어집니다. 군영에서 성장 출전 구성을 정비하십시오.'
title: '조조 이탈의 불씨',
description: '여포가 무너지고 하비 결전은 끝났지만, 조조의 칭찬과 경계는 동시에 깊어졌습니다. 다음 흐름은 조조 휘하를 벗어날 명분과 때를 준비하는 장으로 이어집니다.',
rewardHint: '다음 장: 조조 이탈과 원소 의탁 준비 중',
pages: thirteenthBattleVictoryPages,
unavailableNotice: '조조 이탈과 원소 의탁 장은 다음 작업에서 이어집니다. 군영에서 성장, 대화, 출전 구성을 정비하십시오.'
}
};

View File

@@ -813,6 +813,70 @@ export const twelfthBattleVictoryPages: StoryPage[] = [
}
];
export const thirteenthBattleIntroPages: StoryPage[] = [
{
id: 'thirteenth-cao-cao-final-order',
bgm: 'story-dark',
chapter: '하비 결전의 명',
background: 'story-liu-bei',
text: '하비 외곽이 무너지자 조조는 마지막 총공세를 명했다. 성 안의 여포는 아직 강했고, 진궁은 남은 성루와 수문을 엮어 마지막 반격을 준비하고 있었다.'
},
{
id: 'thirteenth-brothers-before-final',
bgm: 'militia-theme',
chapter: '여포를 앞두고',
background: 'story-three-heroes',
speaker: '관우',
portrait: 'guanYu',
text: '형님, 여포는 용맹하나 믿음이 얕습니다. 다만 그 칼끝이 어지러운 세상의 그림자라면, 오늘 싸움 뒤에 우리 길도 더 뚜렷해질 것입니다.'
},
{
id: 'thirteenth-liu-bei-resolve',
bgm: 'story-dark',
chapter: '조조의 그늘',
background: 'story-liu-bei',
speaker: '유비',
portrait: 'liuBei',
text: '조조의 명으로 싸우되, 백성의 원망을 덜고 난세의 사슬을 끊기 위해 싸운다. 여포의 끝을 보더라도 우리의 뜻까지 맡기지는 말자.'
},
{
id: 'thirteenth-xiapi-final-sortie',
bgm: 'battle-prep',
chapter: '하비 결전',
background: 'story-sortie',
speaker: '장비',
portrait: 'zhangFei',
text: '좋습니다, 형님. 이번엔 성문을 열고 여포의 깃발까지 밀고 들어가겠습니다. 누가 함께 나설지 정해 주십시오. 대기한 자도 다음 싸움을 위해 힘을 아껴야 합니다.'
}
];
export const thirteenthBattleVictoryPages: StoryPage[] = [
{
id: 'thirteenth-victory-lubu-falls',
bgm: 'militia-theme',
chapter: '무너진 여포',
background: 'story-militia',
text: '하비 성내의 마지막 저항이 꺾이고 여포의 깃발이 내려갔다. 뛰어난 무용도 믿음을 잃으면 성 하나를 지키지 못한다는 소문이 병사들 사이에 퍼졌다.'
},
{
id: 'thirteenth-cao-cao-weighs-liu-bei',
bgm: 'story-dark',
chapter: '칭찬과 경계',
background: 'story-liu-bei',
speaker: '간옹',
text: '조조는 형님의 말을 들은 뒤 오래 웃지 않았네. 여포의 운명은 끝났지만, 이제부터는 조조가 우리를 어디까지 품고 어디서부터 경계할지 살펴야 하네.'
},
{
id: 'thirteenth-road-to-cao-break',
bgm: 'story-dark',
chapter: '떠날 때를 헤아리다',
background: 'story-three-heroes',
speaker: '유비',
portrait: 'liuBei',
text: '오늘 싸움으로 빚도 공도 쌓였다. 그러나 의로 시작한 길을 남의 장부 속 숫자로 끝낼 수는 없다. 떠날 때가 오면 망설이지 않도록 마음을 단단히 하자.'
}
];
export const firstBattleMap: BattleMap = {
width: 20,
height: 18,
@@ -1030,6 +1094,12 @@ export const twelfthBattleMap: BattleMap = {
terrain: createTwelfthBattleTerrain()
};
export const thirteenthBattleMap: BattleMap = {
width: 32,
height: 24,
terrain: createThirteenthBattleTerrain()
};
export const firstBattleUnits: UnitData[] = [
{
id: 'liu-bei',
@@ -4181,6 +4251,335 @@ export const twelfthBattleUnits: UnitData[] = [
}
];
const thirteenthBattleAllyPositions: Record<string, { x: number; y: number }> = {
'liu-bei': { x: 3, y: 19 },
'guan-yu': { x: 4, y: 18 },
'zhang-fei': { x: 4, y: 20 },
'jian-yong': { x: 5, y: 19 },
'mi-zhu': { x: 3, y: 21 }
};
export const thirteenthBattleUnits: UnitData[] = [
...[...firstBattleUnits.filter((unit) => unit.faction === 'ally'), ...xuzhouRecruitUnits].map((unit) =>
placeScenarioUnit(unit, thirteenthBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })
),
{
id: 'xiapi-final-cavalry-a',
name: '여포군 돌격기병',
faction: 'enemy',
className: '여포군 기병',
classKey: 'cavalry',
level: 14,
exp: 84,
hp: 62,
maxHp: 62,
attack: 21,
move: 5,
stats: { might: 98, intelligence: 55, leadership: 76, agility: 99, luck: 62 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 60 },
armor: { itemId: 'rebel-vest', level: 2, exp: 26 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 15,
y: 15
},
{
id: 'xiapi-final-cavalry-b',
name: '여포군 돌격기병',
faction: 'enemy',
className: '여포군 기병',
classKey: 'cavalry',
level: 14,
exp: 84,
hp: 62,
maxHp: 62,
attack: 21,
move: 5,
stats: { might: 98, intelligence: 55, leadership: 76, agility: 99, luck: 62 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 60 },
armor: { itemId: 'rebel-vest', level: 2, exp: 26 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 20,
y: 7
},
{
id: 'xiapi-final-cavalry-c',
name: '여포군 돌격기병',
faction: 'enemy',
className: '여포군 기병',
classKey: 'cavalry',
level: 14,
exp: 86,
hp: 63,
maxHp: 63,
attack: 21,
move: 5,
stats: { might: 99, intelligence: 56, leadership: 77, agility: 100, luck: 63 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 62 },
armor: { itemId: 'rebel-vest', level: 2, exp: 26 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 25,
y: 17
},
{
id: 'xiapi-final-cavalry-d',
name: '여포군 친위기병',
faction: 'enemy',
className: '여포군 기병',
classKey: 'cavalry',
level: 15,
exp: 88,
hp: 66,
maxHp: 66,
attack: 22,
move: 5,
stats: { might: 101, intelligence: 58, leadership: 80, agility: 101, luck: 64 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 64 },
armor: { itemId: 'rebel-vest', level: 2, exp: 28 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 27,
y: 12
},
{
id: 'xiapi-final-gate-guard-a',
name: '하비 성문 수비병',
faction: 'enemy',
className: '여포군 보병',
classKey: 'yellowTurban',
level: 13,
exp: 82,
hp: 56,
maxHp: 56,
attack: 18,
move: 3,
stats: { might: 89, intelligence: 58, leadership: 75, agility: 68, luck: 59 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 56 },
armor: { itemId: 'lamellar-armor', level: 2, exp: 24 },
accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
},
x: 18,
y: 13
},
{
id: 'xiapi-final-gate-guard-b',
name: '하비 성문 수비병',
faction: 'enemy',
className: '여포군 보병',
classKey: 'yellowTurban',
level: 13,
exp: 82,
hp: 56,
maxHp: 56,
attack: 18,
move: 3,
stats: { might: 89, intelligence: 58, leadership: 75, agility: 68, luck: 59 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 56 },
armor: { itemId: 'lamellar-armor', level: 2, exp: 24 },
accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
},
x: 19,
y: 14
},
{
id: 'xiapi-final-gate-guard-c',
name: '하비 성문 수비병',
faction: 'enemy',
className: '여포군 보병',
classKey: 'yellowTurban',
level: 13,
exp: 84,
hp: 57,
maxHp: 57,
attack: 18,
move: 3,
stats: { might: 90, intelligence: 58, leadership: 76, agility: 69, luck: 59 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 58 },
armor: { itemId: 'lamellar-armor', level: 2, exp: 24 },
accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
},
x: 23,
y: 10
},
{
id: 'xiapi-final-gate-guard-d',
name: '하비 성문 수비병',
faction: 'enemy',
className: '여포군 보병',
classKey: 'yellowTurban',
level: 13,
exp: 84,
hp: 57,
maxHp: 57,
attack: 18,
move: 3,
stats: { might: 90, intelligence: 58, leadership: 76, agility: 69, luck: 59 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 58 },
armor: { itemId: 'lamellar-armor', level: 2, exp: 24 },
accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
},
x: 24,
y: 11
},
{
id: 'xiapi-final-archer-a',
name: '하비 성루 궁병',
faction: 'enemy',
className: '여포군 궁병',
classKey: 'archer',
level: 13,
exp: 82,
hp: 46,
maxHp: 46,
attack: 19,
move: 3,
stats: { might: 80, intelligence: 70, leadership: 68, agility: 82, luck: 60 },
equipment: {
weapon: { itemId: 'short-bow', level: 2, exp: 56 },
armor: { itemId: 'cloth-armor', level: 2, exp: 22 },
accessory: { itemId: 'wind-quiver', level: 1, exp: 0 }
},
x: 22,
y: 7
},
{
id: 'xiapi-final-archer-b',
name: '하비 성루 궁병',
faction: 'enemy',
className: '여포군 궁병',
classKey: 'archer',
level: 13,
exp: 84,
hp: 47,
maxHp: 47,
attack: 19,
move: 3,
stats: { might: 81, intelligence: 71, leadership: 69, agility: 83, luck: 60 },
equipment: {
weapon: { itemId: 'short-bow', level: 2, exp: 58 },
armor: { itemId: 'cloth-armor', level: 2, exp: 22 },
accessory: { itemId: 'wind-quiver', level: 1, exp: 0 }
},
x: 25,
y: 8
},
{
id: 'xiapi-final-archer-c',
name: '하비 성루 궁병',
faction: 'enemy',
className: '여포군 궁병',
classKey: 'archer',
level: 13,
exp: 84,
hp: 47,
maxHp: 47,
attack: 19,
move: 3,
stats: { might: 81, intelligence: 71, leadership: 69, agility: 83, luck: 60 },
equipment: {
weapon: { itemId: 'short-bow', level: 2, exp: 58 },
armor: { itemId: 'cloth-armor', level: 2, exp: 22 },
accessory: { itemId: 'wind-quiver', level: 1, exp: 0 }
},
x: 27,
y: 15
},
{
id: 'xiapi-final-raider-a',
name: '여포군 성내 기습병',
faction: 'enemy',
className: '여포군 기습병',
classKey: 'bandit',
level: 13,
exp: 82,
hp: 52,
maxHp: 52,
attack: 19,
move: 4,
stats: { might: 91, intelligence: 57, leadership: 67, agility: 85, luck: 61 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 56 },
armor: { itemId: 'rebel-vest', level: 2, exp: 24 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 11,
y: 10
},
{
id: 'xiapi-final-raider-b',
name: '여포군 성내 기습병',
faction: 'enemy',
className: '여포군 기습병',
classKey: 'bandit',
level: 13,
exp: 84,
hp: 53,
maxHp: 53,
attack: 19,
move: 4,
stats: { might: 92, intelligence: 57, leadership: 67, agility: 86, luck: 61 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 58 },
armor: { itemId: 'rebel-vest', level: 2, exp: 24 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 12,
y: 21
},
{
id: 'xiapi-final-strategist-chen-gong',
name: '진궁',
faction: 'enemy',
className: '여포군 책사',
classKey: 'strategist',
level: 14,
exp: 90,
hp: 56,
maxHp: 56,
attack: 19,
move: 3,
stats: { might: 64, intelligence: 98, leadership: 88, agility: 72, luck: 66 },
equipment: {
weapon: { itemId: 'short-bow', level: 2, exp: 60 },
armor: { itemId: 'cloth-armor', level: 2, exp: 24 },
accessory: { itemId: 'war-manual', level: 1, exp: 0 }
},
x: 25,
y: 11
},
{
id: 'xiapi-final-leader-lu-bu',
name: '여포',
faction: 'enemy',
className: '비장군',
classKey: 'rebelLeader',
level: 16,
exp: 96,
hp: 94,
maxHp: 94,
attack: 25,
move: 5,
stats: { might: 108, intelligence: 46, leadership: 88, agility: 102, luck: 65 },
equipment: {
weapon: { itemId: 'sky-piercer-halberd', level: 3, exp: 20 },
armor: { itemId: 'reinforced-lamellar', level: 2, exp: 32 },
accessory: { itemId: 'bravery-token', level: 1, exp: 0 }
},
x: 28,
y: 11
}
];
export const firstBattleBonds: BattleBond[] = [
{
id: 'liu-bei__guan-yu',
@@ -4238,6 +4637,7 @@ export const ninthBattleBonds: BattleBond[] = eighthBattleBonds.map(cloneBattleB
export const tenthBattleBonds: BattleBond[] = ninthBattleBonds.map(cloneBattleBondForScenario);
export const eleventhBattleBonds: BattleBond[] = tenthBattleBonds.map(cloneBattleBondForScenario);
export const twelfthBattleBonds: BattleBond[] = eleventhBattleBonds.map(cloneBattleBondForScenario);
export const thirteenthBattleBonds: BattleBond[] = twelfthBattleBonds.map(cloneBattleBondForScenario);
function createEighthBattleTerrain(): TerrainType[][] {
return Array.from({ length: 22 }, (_, y) =>
@@ -4432,6 +4832,62 @@ function createTwelfthBattleTerrain(): TerrainType[][] {
);
}
function createThirteenthBattleTerrain(): TerrainType[][] {
return Array.from({ length: 24 }, (_, y) =>
Array.from({ length: 32 }, (_, x): TerrainType => {
if (x <= 2 && y >= 18 && y <= 22) {
return 'camp';
}
if (
(x >= 20 && x <= 29 && y >= 8 && y <= 15) ||
(x >= 23 && x <= 28 && y >= 5 && y <= 7) ||
(x >= 25 && x <= 30 && y >= 16 && y <= 18)
) {
return 'fort';
}
if ((x >= 10 && x <= 12 && y >= 17 && y <= 19) || (x >= 17 && x <= 19 && y >= 13 && y <= 15)) {
return 'village';
}
if ((x === 14 || x === 15) && y >= 0 && y <= 22) {
return 'river';
}
if ((x === 18 || x === 19) && y >= 2 && y <= 20) {
return 'river';
}
if (
(y === 19 && x >= 2 && x <= 19) ||
(y === 14 && x >= 15 && x <= 24) ||
(y === 11 && x >= 19 && x <= 29) ||
(x === 24 && y >= 7 && y <= 18)
) {
return 'road';
}
if ((x >= 4 && x <= 16 && y === 23 - x) || (x >= 10 && x <= 22 && y === x - 3)) {
return 'road';
}
if (
(x <= 8 && y <= 8) ||
(x >= 4 && x <= 12 && y >= 9 && y <= 13) ||
(x >= 2 && x <= 9 && y >= 14 && y <= 17) ||
(x >= 28 && y >= 19 && y <= 22)
) {
return 'forest';
}
if (
(x >= 6 && x <= 10 && y >= 2 && y <= 5) ||
(x >= 27 && x <= 31 && y >= 1 && y <= 5) ||
(x >= 9 && x <= 13 && y >= 20)
) {
return 'hill';
}
if ((x >= 30 && y <= 8) || (x >= 30 && y >= 14) || (x <= 1 && y <= 4)) {
return 'cliff';
}
return 'plain';
})
);
}
function placeScenarioUnit(unit: UnitData, position: { x: number; y: number }): UnitData {
return {
...cloneUnitForScenario(unit),

View File

@@ -73,7 +73,22 @@ const unitTexture: Record<string, string> = {
'xiapi-camp-raider-a': 'unit-rebel',
'xiapi-camp-raider-b': 'unit-rebel',
'xiapi-strategist-chen-gong': 'unit-rebel-archer',
'xiapi-leader-zhang-liao': 'unit-rebel-leader'
'xiapi-leader-zhang-liao': 'unit-rebel-leader',
'xiapi-final-cavalry-a': 'unit-rebel-cavalry',
'xiapi-final-cavalry-b': 'unit-rebel-cavalry',
'xiapi-final-cavalry-c': 'unit-rebel-cavalry',
'xiapi-final-cavalry-d': 'unit-rebel-cavalry',
'xiapi-final-gate-guard-a': 'unit-rebel',
'xiapi-final-gate-guard-b': 'unit-rebel',
'xiapi-final-gate-guard-c': 'unit-rebel',
'xiapi-final-gate-guard-d': 'unit-rebel',
'xiapi-final-archer-a': 'unit-rebel-archer',
'xiapi-final-archer-b': 'unit-rebel-archer',
'xiapi-final-archer-c': 'unit-rebel-archer',
'xiapi-final-raider-a': 'unit-rebel',
'xiapi-final-raider-b': 'unit-rebel',
'xiapi-final-strategist-chen-gong': 'unit-rebel-archer',
'xiapi-final-leader-lu-bu': 'unit-rebel-leader'
};
const unitTextureByClass: Partial<Record<UnitClassKey, string>> = {
@@ -594,7 +609,22 @@ const enemyAiByUnitId: Record<string, EnemyAiBehavior> = {
'xiapi-camp-raider-a': 'aggressive',
'xiapi-camp-raider-b': 'aggressive',
'xiapi-strategist-chen-gong': 'hold',
'xiapi-leader-zhang-liao': 'guard'
'xiapi-leader-zhang-liao': 'guard',
'xiapi-final-cavalry-a': 'aggressive',
'xiapi-final-cavalry-b': 'aggressive',
'xiapi-final-cavalry-c': 'aggressive',
'xiapi-final-cavalry-d': 'aggressive',
'xiapi-final-gate-guard-a': 'guard',
'xiapi-final-gate-guard-b': 'guard',
'xiapi-final-gate-guard-c': 'guard',
'xiapi-final-gate-guard-d': 'guard',
'xiapi-final-archer-a': 'hold',
'xiapi-final-archer-b': 'hold',
'xiapi-final-archer-c': 'hold',
'xiapi-final-raider-a': 'aggressive',
'xiapi-final-raider-b': 'aggressive',
'xiapi-final-strategist-chen-gong': 'hold',
'xiapi-final-leader-lu-bu': 'guard'
};
const defaultEnemyAiByClass: Partial<Record<UnitClassKey, EnemyAiBehavior>> = {

View File

@@ -9,6 +9,7 @@ import secondBattleMapUrl from '../../assets/images/battle/second-battle-map.svg
import seventhBattleMapUrl from '../../assets/images/battle/seventh-battle-map.svg';
import sixthBattleMapUrl from '../../assets/images/battle/sixth-battle-map.svg';
import tenthBattleMapUrl from '../../assets/images/battle/tenth-battle-map.svg';
import thirteenthBattleMapUrl from '../../assets/images/battle/thirteenth-battle-map.svg';
import thirdBattleMapUrl from '../../assets/images/battle/third-battle-map.svg';
import twelfthBattleMapUrl from '../../assets/images/battle/twelfth-battle-map.svg';
import guanYuPortraitUrl from '../../assets/images/portraits/guan-yu.png';
@@ -82,6 +83,7 @@ export class BootScene extends Phaser.Scene {
this.load.image('battle-map-tenth', tenthBattleMapUrl);
this.load.image('battle-map-eleventh', eleventhBattleMapUrl);
this.load.image('battle-map-twelfth', twelfthBattleMapUrl);
this.load.image('battle-map-thirteenth', thirteenthBattleMapUrl);
this.load.image('portrait-liu-bei', liuBeiPortraitUrl);
this.load.image('portrait-guan-yu', guanYuPortraitUrl);
this.load.image('portrait-zhang-fei', zhangFeiPortraitUrl);
@@ -123,6 +125,7 @@ export class BootScene extends Phaser.Scene {
});
this.createItemIcon('item-green-dragon-glaive', (graphics) => this.drawPolearmIcon(graphics, 0x5fbf8f, 0xd8b15f));
this.createItemIcon('item-serpent-spear', (graphics) => this.drawSpearIcon(graphics, 0xe0e8ef, 0xb86b55));
this.createItemIcon('item-sky-piercer-halberd', (graphics) => this.drawPolearmIcon(graphics, 0xd8dfe6, 0xd95f4f));
this.createItemIcon('item-yellow-turban-saber', (graphics) => this.drawSaberIcon(graphics, 0xd8b15f, 0x9c6b2f));
this.createItemIcon('item-short-bow', (graphics) => this.drawBowIcon(graphics, 0xc58a4c, 0xe8dfca));
this.createItemIcon('item-leader-axe', (graphics) => this.drawAxeIcon(graphics, 0xd8dfe6, 0x8d5a3a));

View File

@@ -97,10 +97,15 @@ type SortiePlanSummary = {
terrainScore: number;
terrainGrade: string;
activeBondCount: number;
recruitedCount: number;
selectedRecruitedCount: number;
reserveCount: number;
recommendedSelectedCount: number;
recommendedTotal: number;
deploymentLine: string;
recommendationLine: string;
recruitedLine: string;
reserveLine: string;
objectiveLine: string;
formationSlots: SortieFormationSlot[];
warnings: string[];
@@ -177,10 +182,12 @@ const campBattleIds = {
ninth: 'ninth-battle-xuzhou-gate-night-raid',
tenth: 'tenth-battle-xuzhou-breakout',
eleventh: 'eleventh-battle-xudu-refuge-road',
twelfth: 'twelfth-battle-xiapi-outer-siege'
twelfth: 'twelfth-battle-xiapi-outer-siege',
thirteenth: 'thirteenth-battle-xiapi-final'
} as const;
const requiredSortieUnitIds = new Set(['liu-bei']);
const foundingSortieUnitIds = new Set(['liu-bei', 'guan-yu', 'zhang-fei']);
const maxSortieUnits = 6;
const sortieFormationSlotDefinitions: Omit<SortieFormationSlot, 'unitNames'>[] = [
{ role: 'front', label: '전열', description: '길목을 막고 반격을 받아냄' },
@@ -299,6 +306,16 @@ const sortieRulesByBattleId: Partial<Record<BattleScenarioId, SortieRuleDefiniti
{ unitId: 'mi-zhu', reason: '강가 포위전의 보급과 회복 운용에 유리합니다.' }
],
note: '하비 외곽전은 강줄기와 둑길이 전장을 나눕니다. 돌파와 보급, 조조군 보고까지 함께 고려하십시오.'
},
'thirteenth-battle-xiapi-final': {
maxUnits: 5,
recommended: [
{ unitId: 'liu-bei', reason: '여포의 운명을 두고 조조 앞에서 말해야 할 주장입니다.' },
{ unitId: 'guan-yu', reason: '성문 요새의 전열을 안정적으로 밀어낼 수 있습니다.' },
{ unitId: 'zhang-fei', reason: '여포군 기병의 돌파를 받아치고 반격하는 역할입니다.' },
{ unitId: 'jian-yong', reason: '여포 처분과 조조의 시선을 둘러싼 명분 조율에 필요합니다.' }
],
note: '하비 결전은 전열과 돌파가 강해야 하지만, 전후 조조와의 긴장을 생각하면 책사 한 명을 챙기는 편성이 좋습니다.'
}
};
@@ -1328,6 +1345,87 @@ const campDialogues: CampDialogue[] = [
rewardExp: 8
}
]
},
{
id: 'liu-guan-after-lu-bu-fall',
title: '무용과 믿음',
availableAfterBattleIds: [campBattleIds.thirteenth],
unitIds: ['liu-bei', 'guan-yu'],
bondId: 'liu-bei__guan-yu',
rewardExp: 24,
lines: [
'관우: 여포의 무용은 천하가 알았으나, 끝내 누구도 오래 믿게 하지 못했습니다.',
'유비: 힘만으로 길을 열 수는 있어도 사람 마음을 머물게 하지는 못하오.',
'관우: 그렇다면 형님의 길은 더 무거워질 것입니다. 저 또한 그 길을 지키겠습니다.'
],
choices: [
{
id: 'honor-trust-over-force',
label: '믿음의 무게를 말한다',
response: '관우는 여포의 말로를 힘의 패배가 아니라 믿음의 패배로 새기며 결의를 다졌다.',
rewardExp: 10
},
{
id: 'prepare-for-cao-watch',
label: '조조의 시선을 경계한다',
response: '관우는 칼을 거두는 때도 전투라 여기며 조조의 경계를 살피겠다고 답했다.',
rewardExp: 9
}
]
},
{
id: 'liu-jian-yong-after-lu-bu-fall',
title: '말 한마디의 무게',
availableAfterBattleIds: [campBattleIds.thirteenth],
unitIds: ['liu-bei', 'jian-yong'],
bondId: 'liu-bei__jian-yong',
rewardExp: 22,
lines: [
'간옹: 현덕, 여포가 사라졌으니 조조는 이제 우리를 더 오래 바라볼 걸세.',
'유비: 오늘 한 말이 내일의 의심이 될 수 있겠군.',
'간옹: 그러니 말은 짧게, 뜻은 길게 남기세. 떠날 명분은 오늘부터 쌓아야 하네.'
],
choices: [
{
id: 'keep-words-measured',
label: '말을 아끼게 한다',
response: '간옹은 조조 앞에서 드러낼 말과 속으로 남길 뜻을 분리해 두었다.',
rewardExp: 9
},
{
id: 'collect-leaving-cause',
label: '떠날 명분을 모은다',
response: '간옹은 조조 휘하에서 겪는 작은 어긋남도 훗날의 기록으로 남기기 시작했다.',
rewardExp: 10
}
]
},
{
id: 'liu-mi-after-lu-bu-fall',
title: '남겨 둘 군량',
availableAfterBattleIds: [campBattleIds.thirteenth],
unitIds: ['liu-bei', 'mi-zhu'],
bondId: 'liu-bei__mi-zhu',
rewardExp: 20,
lines: [
'미축: 하비의 보급품은 조조군 장부로 들어가겠지만, 우리 병사들이 떠날 때 쓸 몫도 필요합니다.',
'유비: 떠날 길을 생각하는 것이 불충으로 보일 수도 있소.',
'미축: 뜻을 지키려면 길도 있어야 합니다. 군량은 의리를 움직이게 하는 바퀴입니다.'
],
choices: [
{
id: 'set-aside-march-grain',
label: '행군 군량을 남긴다',
response: '미축은 장부 밖으로 새지 않는 선에서 유비군의 독자 행군 물자를 확보했다.',
rewardExp: 9
},
{
id: 'protect-xu-refugees',
label: '서주 피난민을 챙긴다',
response: '미축은 하비 뒤편의 피난민에게 갈 식량을 남겨 유비군의 명분을 조용히 지켰다.',
rewardExp: 8
}
]
}
];
@@ -1412,7 +1510,8 @@ export class CampScene extends Phaser.Scene {
battleId !== campBattleIds.ninth &&
battleId !== campBattleIds.tenth &&
battleId !== campBattleIds.eleventh &&
battleId !== campBattleIds.twelfth) ||
battleId !== campBattleIds.twelfth &&
battleId !== campBattleIds.thirteenth) ||
!this.campaign
) {
return;
@@ -1434,6 +1533,9 @@ export class CampScene extends Phaser.Scene {
private currentCampTitle() {
const battleId = this.currentCampBattleId();
if (battleId === campBattleIds.thirteenth) {
return '하비 결전 후 군영';
}
if (battleId === campBattleIds.twelfth) {
return '하비 포위 군영';
}
@@ -1682,6 +1784,7 @@ export class CampScene extends Phaser.Scene {
const rowY = y + 50 + index * rowGap;
const selected = this.isSortieSelected(unit.id);
const required = requiredSortieUnitIds.has(unit.id);
const recruited = !foundingSortieUnitIds.has(unit.id);
const recommendation = this.sortieRecommendation(unit.id);
const summary = this.sortieUnitTacticalSummary(unit);
const row = this.trackSortie(this.add.rectangle(x + 18, rowY - 5, width - 36, rowHeight, selected ? 0x172a22 : 0x151b24, selected ? 0.96 : 0.82));
@@ -1692,7 +1795,7 @@ export class CampScene extends Phaser.Scene {
row.on('pointerdown', () => this.toggleSortieUnit(unit.id));
const marker = selected ? '출전' : '대기';
const markerLabel = required ? '필수' : recommendation ? '추천' : marker;
const markerLabel = required ? '필수' : recommendation ? '추천' : recruited ? '합류' : marker;
this.trackSortie(this.add.text(x + 30, rowY, markerLabel, this.textStyle(12, required || recommendation ? '#ffdf7b' : selected ? '#a8ffd0' : '#9fb0bf', true))).setDepth(depth + 2);
this.trackSortie(this.add.text(x + 84, rowY, `${unit.name} Lv ${unit.level}`, this.textStyle(14, selected ? '#f2e3bf' : '#87919c', true))).setDepth(depth + 2);
this.trackSortie(this.add.text(x + 214, rowY, this.compactText(`${unit.className} · ${getUnitClass(unit.classKey).role}`, 19), this.textStyle(12, selected ? '#d4dce6' : '#77818c', true))).setDepth(depth + 2);
@@ -1724,17 +1827,18 @@ export class CampScene extends Phaser.Scene {
const summaryLines = [
plan.deploymentLine,
`${plan.recommendationLine} · 공명 ${plan.activeBondCount}`,
plan.recruitedLine,
plan.objectiveLine
];
summaryLines.forEach((line, index) => {
this.trackSortie(this.add.text(x + 18, y + 150 + index * 18, this.compactText(line, 31), this.textStyle(12, index === 0 ? '#f2e3bf' : '#d4dce6', index === 0))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 18, y + 146 + index * 16, this.compactText(line, 32), this.textStyle(12, index === 0 ? '#f2e3bf' : '#d4dce6', index === 0))).setDepth(depth + 1);
});
plan.formationSlots.forEach((slot, index) => {
const column = index % 2;
const row = Math.floor(index / 2);
const rowX = x + 18 + column * 176;
const rowY = y + 208 + row * 28;
const rowY = y + 218 + row * 28;
const names = slot.unitNames.length > 0 ? slot.unitNames.join(', ') : '미배치';
const color = slot.unitNames.length > 0 ? '#a8ffd0' : '#9fb0bf';
this.trackSortie(this.add.text(rowX, rowY, slot.label, this.textStyle(12, color, true))).setDepth(depth + 1);
@@ -1742,7 +1846,7 @@ export class CampScene extends Phaser.Scene {
this.trackSortie(this.add.text(rowX + 42, rowY + 13, this.compactText(slot.description, 14), this.textStyle(10, '#87919c'))).setDepth(depth + 1);
});
const warning = plan.warnings[0] ?? '전열, 돌파, 지원의 균형이 좋습니다.';
const warning = plan.warnings[0] ?? plan.reserveLine;
this.trackSortie(this.add.text(x + 18, y + height - 28, this.compactText(`조언: ${warning}`, 42), this.textStyle(12, plan.warnings.length > 0 ? '#ffdf7b' : '#a8ffd0', true))).setDepth(depth + 1);
}
@@ -1914,13 +2018,18 @@ export class CampScene extends Phaser.Scene {
}
private sortiePlanSummary(): SortiePlanSummary {
const allAllies = this.sortieAllies();
const selectedUnits = this.selectedSortieUnits();
const selectedIds = new Set(selectedUnits.map((unit) => unit.id));
const scenario = this.nextSortieScenario();
const rule = this.nextSortieRule(scenario);
const availableIds = new Set(this.sortieAllies().map((unit) => unit.id));
const availableIds = new Set(allAllies.map((unit) => unit.id));
const recommended = rule.recommended.filter((entry) => availableIds.has(entry.unitId));
const missingRecommended = recommended.filter((entry) => !selectedIds.has(entry.unitId));
const recruitedUnits = allAllies.filter((unit) => !foundingSortieUnitIds.has(unit.id));
const selectedRecruitedCount = recruitedUnits.filter((unit) => selectedIds.has(unit.id)).length;
const reserveUnits = allAllies.filter((unit) => !selectedIds.has(unit.id));
const reserveNames = reserveUnits.map((unit) => unit.name).join(', ');
const terrainScores = selectedUnits.map((unit) => this.sortieTerrainScore(unit, scenario));
const terrainScore = terrainScores.length
? Math.round(terrainScores.reduce((sum, score) => sum + score, 0) / terrainScores.length)
@@ -1970,10 +2079,18 @@ export class CampScene extends Phaser.Scene {
terrainScore,
terrainGrade,
activeBondCount,
recruitedCount: recruitedUnits.length,
selectedRecruitedCount,
reserveCount: reserveUnits.length,
recommendedSelectedCount,
recommendedTotal: recommended.length,
deploymentLine: `전열 ${roleCounts.front} · 돌파 ${roleCounts.flank} · 후원 ${roleCounts.support} · 예비 ${roleCounts.reserve}`,
recommendationLine: recommended.length > 0 ? `추천 ${recommendedSelectedCount}/${recommended.length}` : '추천 없음',
recruitedLine:
recruitedUnits.length > 0
? `합류 무장 ${selectedRecruitedCount}/${recruitedUnits.length} · 대기 ${reserveUnits.length}`
: '합류 무장 없음',
reserveLine: reserveUnits.length > 0 ? `대기 무장: ${reserveNames}` : '전원 출전 중입니다.',
objectiveLine: scenario ? `${scenario.title} · ${scenario.victoryConditionLabel}` : '다음 전투 정보 없음',
formationSlots,
warnings
@@ -2903,6 +3020,8 @@ export class CampScene extends Phaser.Scene {
id: unit.id,
name: unit.name,
selected: this.isSortieSelected(unit.id),
recruited: !foundingSortieUnitIds.has(unit.id),
required: requiredSortieUnitIds.has(unit.id),
className: unit.className,
classRole: getUnitClass(unit.classKey).role,
formationRole: this.sortieFormationRole(unit),

View File

@@ -11,6 +11,7 @@ import {
seventhBattleScenario,
sixthBattleScenario,
tenthBattleScenario,
thirteenthBattleScenario,
twelfthBattleScenario,
thirdBattleScenario
} from '../data/battles';
@@ -323,7 +324,8 @@ export class TitleScene extends Phaser.Scene {
campaign.step === 'ninth-camp' ||
campaign.step === 'tenth-camp' ||
campaign.step === 'eleventh-camp' ||
campaign.step === 'twelfth-camp'
campaign.step === 'twelfth-camp' ||
campaign.step === 'thirteenth-camp'
) {
this.scene.start('CampScene');
return;
@@ -389,6 +391,11 @@ export class TitleScene extends Phaser.Scene {
return;
}
if (campaign.step === 'thirteenth-battle') {
this.scene.start('BattleScene', { battleId: thirteenthBattleScenario.id });
return;
}
if (campaign.step === 'first-victory-story') {
this.scene.start('StoryScene', { pages: firstBattleVictoryPages, nextScene: 'TitleScene' });
return;

View File

@@ -63,7 +63,9 @@ export type CampaignStep =
| 'eleventh-battle'
| 'eleventh-camp'
| 'twelfth-battle'
| 'twelfth-camp';
| 'twelfth-camp'
| 'thirteenth-battle'
| 'thirteenth-camp';
export type CampaignUnitProgressSnapshot = {
unitId: string;
@@ -126,7 +128,8 @@ const campaignBattleSteps: Record<string, { victory: CampaignStep; retry: Campai
'ninth-battle-xuzhou-gate-night-raid': { victory: 'ninth-camp', retry: 'ninth-battle' },
'tenth-battle-xuzhou-breakout': { victory: 'tenth-camp', retry: 'tenth-battle' },
'eleventh-battle-xudu-refuge-road': { victory: 'eleventh-camp', retry: 'eleventh-battle' },
'twelfth-battle-xiapi-outer-siege': { victory: 'twelfth-camp', retry: 'twelfth-battle' }
'twelfth-battle-xiapi-outer-siege': { victory: 'twelfth-camp', retry: 'twelfth-battle' },
'thirteenth-battle-xiapi-final': { victory: 'thirteenth-camp', retry: 'thirteenth-battle' }
};
export type CampaignSaveSlotSummary = {