Add second battle pursuit chapter

This commit is contained in:
2026-06-22 23:00:10 +09:00
parent ab5870eaa8
commit 3de598511c
10 changed files with 703 additions and 44 deletions

View File

@@ -280,6 +280,74 @@ try {
throw new Error(`Expected sortie preparation overlay to close: ${JSON.stringify(campStateAfterSortieClose)}`); throw new Error(`Expected sortie preparation overlay to close: ${JSON.stringify(campStateAfterSortieClose)}`);
} }
await page.mouse.click(1120, 38);
await page.waitForTimeout(120);
await page.mouse.click(938, 596);
await page.waitForFunction(() => {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
return activeScenes.includes('StoryScene');
});
for (let i = 0; i < 8; i += 1) {
const isSecondBattleActive = await page.evaluate(() => {
const state = window.__HEROS_DEBUG__?.battle();
return state?.scene === 'BattleScene' && state?.battleId === 'second-battle-yellow-turban-pursuit';
});
if (isSecondBattleActive) {
break;
}
await page.keyboard.press('Space');
await page.waitForTimeout(220);
}
await page.waitForFunction(() => {
const state = window.__HEROS_DEBUG__?.battle();
return state?.scene === 'BattleScene' && state?.battleId === 'second-battle-yellow-turban-pursuit' && state?.battleOutcome === null && state?.phase === 'idle';
});
await page.screenshot({ path: 'dist/verification-second-battle.png', fullPage: true });
const secondBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
const secondEnemies = secondBattleState.units.filter((unit) => unit.faction === 'enemy');
const secondEnemyBehaviors = new Set(secondEnemies.map((unit) => unit.ai));
if (
secondBattleState.camera?.mapWidth !== 24 ||
secondBattleState.camera?.mapHeight !== 20 ||
secondEnemies.length < 10 ||
!secondEnemyBehaviors.has('aggressive') ||
!secondEnemyBehaviors.has('guard') ||
!secondEnemyBehaviors.has('hold')
) {
throw new Error(`Expected expanded second battle map and mixed enemy AI: ${JSON.stringify(secondBattleState)}`);
}
await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory'));
await page.waitForFunction(() => {
const state = window.__HEROS_DEBUG__?.battle();
return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true;
});
await page.mouse.click(738, 642);
await page.waitForFunction(() => {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
return activeScenes.includes('CampScene');
});
const campaignSaveAfterSecondBattle = await page.evaluate(() => {
const raw = window.localStorage.getItem('heros-web:campaign-state');
return raw ? JSON.parse(raw) : undefined;
});
if (
!campaignSaveAfterSecondBattle ||
campaignSaveAfterSecondBattle.step !== 'second-camp' ||
campaignSaveAfterSecondBattle.latestBattleId !== 'second-battle-yellow-turban-pursuit' ||
!campaignSaveAfterSecondBattle.battleHistory?.['second-battle-yellow-turban-pursuit'] ||
!campaignSaveAfterSecondBattle.completedCampDialogues?.length
) {
throw new Error(`Expected campaign save to persist second battle victory: ${JSON.stringify(campaignSaveAfterSecondBattle)}`);
}
await page.evaluate(() => window.__HEROS_GAME__?.scene.start('TitleScene')); await page.evaluate(() => window.__HEROS_GAME__?.scene.start('TitleScene'));
await page.waitForFunction(() => { await page.waitForFunction(() => {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
@@ -291,25 +359,7 @@ try {
return activeScenes.includes('CampScene'); return activeScenes.includes('CampScene');
}); });
await page.evaluate(() => window.__HEROS_DEBUG__?.goToBattle()); console.log(`Verified title-to-second-battle flow, result states, and debug API at ${targetUrl}`);
await page.waitForFunction(() => {
const state = window.__HEROS_DEBUG__?.battle();
return state?.scene === 'BattleScene' && state?.battleOutcome === null && state?.phase === 'idle';
});
await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('defeat'));
await page.waitForFunction(() => {
const state = window.__HEROS_DEBUG__?.battle();
return state?.battleOutcome === 'defeat' && state?.phase === 'resolved' && state?.resultVisible === true;
});
const defeatState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
const liuBei = defeatState.units.find((unit) => unit.id === 'liu-bei');
if (!liuBei || liuBei.hp > 0) {
throw new Error(`Expected Liu Bei to be defeated after forced defeat: ${JSON.stringify(liuBei)}`);
}
console.log(`Verified title-to-battle flow, result states, and debug API at ${targetUrl}`);
} finally { } finally {
await browser?.close(); await browser?.close();
if (serverProcess && !serverProcess.killed) { if (serverProcess && !serverProcess.killed) {

View File

@@ -0,0 +1,98 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2400 2000" width="2400" height="2000">
<defs>
<filter id="paintedNoise">
<feTurbulence type="fractalNoise" baseFrequency="0.018" numOctaves="4" seed="23" />
<feColorMatrix type="saturate" values="0.42" />
<feBlend mode="multiply" in2="SourceGraphic" />
</filter>
<pattern id="grassGrain" width="48" height="48" patternUnits="userSpaceOnUse">
<rect width="48" height="48" fill="#65784e" />
<path d="M8 31l8-14M29 42l5-17M39 18l5-9M17 9l10 4" stroke="#9cab67" stroke-width="3" opacity="0.24" />
<path d="M4 14h9M22 26h13M32 5h8" stroke="#344f32" stroke-width="2" opacity="0.3" />
</pattern>
<pattern id="earthGrain" width="54" height="54" patternUnits="userSpaceOnUse">
<rect width="54" height="54" fill="#98754b" />
<path d="M5 12c17-9 30 8 44-2M1 37c18-11 35 12 50-1" stroke="#c49a5d" stroke-width="4" opacity="0.26" />
<path d="M13 48l6-11M32 21l10-8M42 44l7-3" stroke="#5d4a33" stroke-width="3" opacity="0.32" />
</pattern>
<pattern id="forestLeaf" width="90" height="76" patternUnits="userSpaceOnUse">
<rect width="90" height="76" fill="#334b2d" />
<circle cx="20" cy="24" r="19" fill="#263b25" />
<circle cx="44" cy="21" r="23" fill="#49683a" />
<circle cx="67" cy="31" r="20" fill="#2c4329" />
<circle cx="37" cy="51" r="19" fill="#5d7b44" />
<path d="M28 19c12 12 31 11 45-4M9 42c18 10 44 9 61-6" stroke="#152619" stroke-width="5" opacity="0.35" />
</pattern>
<linearGradient id="riverGradient" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#386f8c" />
<stop offset="0.55" stop-color="#24526d" />
<stop offset="1" stop-color="#173a52" />
</linearGradient>
<linearGradient id="cliffGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#665d48" />
<stop offset="1" stop-color="#302c24" />
</linearGradient>
</defs>
<rect width="2400" height="2000" fill="url(#grassGrain)" />
<g filter="url(#paintedNoise)">
<path d="M-90 1180C250 1060 440 1190 710 1090C930 1010 1030 810 1270 760C1530 707 1710 850 1960 760C2150 692 2268 520 2490 536V2090H-90Z" fill="#4d693c" opacity="0.72" />
<path d="M-60 470C270 420 470 510 720 455C1000 394 1160 210 1460 255C1765 301 1950 440 2450 300V-70H-60Z" fill="#778056" opacity="0.65" />
<path d="M-120 1560C170 1430 360 1370 640 1220C860 1102 1050 915 1225 790C1440 636 1652 594 1905 466C2105 365 2266 245 2520 150" fill="none" stroke="#a17a47" stroke-width="220" stroke-linecap="round" stroke-linejoin="round" opacity="0.98" />
<path d="M-120 1560C170 1430 360 1370 640 1220C860 1102 1050 915 1225 790C1440 636 1652 594 1905 466C2105 365 2266 245 2520 150" fill="none" stroke="url(#earthGrain)" stroke-width="154" stroke-linecap="round" stroke-linejoin="round" opacity="0.95" />
<path d="M300 2050C390 1770 610 1530 860 1320C1110 1110 1165 960 1200 750C1240 515 1350 285 1540-90" fill="none" stroke="#9a7447" stroke-width="178" stroke-linecap="round" opacity="0.95" />
<path d="M300 2050C390 1770 610 1530 860 1320C1110 1110 1165 960 1200 750C1240 515 1350 285 1540-90" fill="none" stroke="url(#earthGrain)" stroke-width="126" stroke-linecap="round" opacity="0.9" />
<path d="M1420-80C1330 210 1295 435 1370 630C1450 840 1640 965 1688 1180C1745 1435 1600 1670 1490 2100" fill="none" stroke="#143347" stroke-width="250" stroke-linecap="round" opacity="0.95" />
<path d="M1420-80C1330 210 1295 435 1370 630C1450 840 1640 965 1688 1180C1745 1435 1600 1670 1490 2100" fill="none" stroke="url(#riverGradient)" stroke-width="180" stroke-linecap="round" opacity="0.98" />
<path d="M1355 80c70 66 62 157 22 240M1450 735c115 94 170 194 166 318M1630 1365c35 154 6 288-80 405" fill="none" stroke="#8ac5d5" stroke-width="14" opacity="0.38" />
<path d="M80 60h525c80 80 115 168 104 266c-206 45-421 26-676-38Z" fill="url(#forestLeaf)" opacity="0.95" />
<path d="M760 170h325c118 85 160 186 124 302c-210 55-360 15-518-92Z" fill="url(#forestLeaf)" opacity="0.82" />
<path d="M90 670c280-110 520-65 695 93c-55 185-258 300-618 314c-132-116-165-254-77-407Z" fill="url(#forestLeaf)" opacity="0.9" />
<path d="M1770 90c190-82 380-68 586 36c42 160-18 305-155 430c-235 16-404-86-520-252Z" fill="url(#forestLeaf)" opacity="0.9" />
<path d="M1810 1010c240-90 420-72 600 22v420c-235 68-450 30-635-110c-32-126-24-236 35-332Z" fill="url(#forestLeaf)" opacity="0.88" />
<path d="M620 1560c290-120 545-84 735 70c-30 220-220 334-570 350c-145-94-200-226-165-420Z" fill="url(#forestLeaf)" opacity="0.82" />
<path d="M0 1630c212-116 370-125 545-74c20 164-46 303-198 418H0Z" fill="url(#cliffGradient)" opacity="0.9" />
<path d="M1920 1510c175-35 350 13 520 142v348h-520c-110-138-110-304 0-490Z" fill="url(#cliffGradient)" opacity="0.86" />
<path d="M1620 520c170-86 330-79 480 18c-5 130-83 234-236 312c-168-35-260-143-244-330Z" fill="url(#cliffGradient)" opacity="0.7" />
<g transform="translate(840 1120)">
<rect x="0" y="0" width="220" height="160" rx="12" fill="#5d4630" />
<rect x="26" y="42" width="58" height="78" fill="#8d6740" />
<rect x="112" y="30" width="70" height="92" fill="#8d6740" />
<path d="M14 42l43-42l43 42M100 30l50-38l54 38" fill="#c58b32" stroke="#34251b" stroke-width="9" />
<rect x="130" y="70" width="22" height="52" fill="#2d2219" />
</g>
<g transform="translate(1780 645)">
<rect x="0" y="0" width="260" height="160" rx="14" fill="#67503a" />
<rect x="32" y="46" width="70" height="80" fill="#98704a" />
<rect x="132" y="30" width="82" height="96" fill="#98704a" />
<path d="M14 46l54-42l54 42M118 30l58-42l66 42" fill="#c8923e" stroke="#3a2a1d" stroke-width="9" />
<path d="M0 144h260" stroke="#2e231a" stroke-width="10" />
</g>
<g transform="translate(300 1360)">
<rect x="0" y="0" width="240" height="170" rx="12" fill="#534030" />
<rect x="34" y="44" width="72" height="92" fill="#8a6848" />
<rect x="132" y="50" width="62" height="84" fill="#8a6848" />
<path d="M18 44l53-40l54 40M122 50l42-34l48 34" fill="#be8431" stroke="#302215" stroke-width="9" />
</g>
<g transform="translate(1940 300)">
<rect x="0" y="0" width="220" height="220" fill="#4a4034" />
<rect x="28" y="28" width="164" height="164" fill="#6e604c" />
<rect x="74" y="74" width="72" height="118" fill="#32251c" />
<path d="M-14 0h248M-14 220h248M0-14v248M220-14v248" stroke="#221913" stroke-width="24" />
<path d="M28 28h164v164H28Z" fill="none" stroke="#b49354" stroke-width="10" opacity="0.7" />
</g>
<g opacity="0.38" stroke="#21180f" stroke-width="4">
<path d="M130 1660l115 46l-80 86M2040 1590l140 60l-95 116M1680 640l130 65l-82 92" fill="none" />
<path d="M456 1210c82-40 140-34 210 10M1060 910c110-72 214-80 334-22M2100 730c92-24 176-8 244 46" fill="none" opacity="0.45" />
</g>
</g>
<rect width="2400" height="2000" fill="#080a0c" opacity="0.08" />
</svg>

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

@@ -3,13 +3,17 @@ import {
firstBattleMap, firstBattleMap,
firstBattleUnits, firstBattleUnits,
firstBattleVictoryPages, firstBattleVictoryPages,
secondBattleBonds,
secondBattleMap,
secondBattleUnits,
secondBattleVictoryPages,
type BattleBond, type BattleBond,
type BattleMap, type BattleMap,
type StoryPage, type StoryPage,
type UnitData type UnitData
} from './scenario'; } from './scenario';
export type BattleScenarioId = 'first-battle-zhuo-commandery'; export type BattleScenarioId = 'first-battle-zhuo-commandery' | 'second-battle-yellow-turban-pursuit';
export type BattleObjectiveKind = 'defeat-leader' | 'keep-unit-alive' | 'secure-terrain' | 'quick-victory'; export type BattleObjectiveKind = 'defeat-leader' | 'keep-unit-alive' | 'secure-terrain' | 'quick-victory';
@@ -101,10 +105,64 @@ export const firstBattleScenario: BattleScenarioDefinition = {
nextCampScene: 'CampScene' nextCampScene: 'CampScene'
}; };
export const secondBattleScenario: BattleScenarioDefinition = {
id: 'second-battle-yellow-turban-pursuit',
title: '황건 잔당 추격',
victoryConditionLabel: '두령 한석 격파',
defeatConditionLabel: '유비 퇴각',
openingObjectiveLines: [
'황건 잔당이 북쪽 마을로 달아났습니다. 두령 한석을 격파하면 적의 퇴로가 무너집니다.',
'마을 주변에 적 위협이 남아 있으면 보상이 줄어듭니다. 길목의 습격조를 먼저 정리하십시오.',
'12턴 이내에 승리하면 의용군의 추격전 명성이 오릅니다.'
],
map: secondBattleMap,
units: secondBattleUnits,
bonds: secondBattleBonds,
mapTextureKey: 'battle-map-second',
leaderUnitId: 'pursuit-leader-han-seok',
quickVictoryTurnLimit: 12,
baseVictoryGold: 420,
objectives: [
{
id: 'leader',
kind: 'defeat-leader',
label: '두령 한석 격파',
rewardGold: 260,
unitId: 'pursuit-leader-han-seok'
},
{
id: 'liu-bei',
kind: 'keep-unit-alive',
label: '유비 생존',
rewardGold: 120,
unitId: 'liu-bei'
},
{
id: 'village',
kind: 'secure-terrain',
label: '북쪽 마을 확보',
rewardGold: 180,
terrain: 'village'
},
{
id: 'quick',
kind: 'quick-victory',
label: '12턴 이내 승리',
rewardGold: 150,
maxTurn: 12
}
],
defeatConditions: [{ kind: 'unit-defeated', unitId: 'liu-bei' }],
itemRewards: ['콩 2', '탁주 1', '의용군 명성 +2'],
victoryPages: secondBattleVictoryPages,
nextCampScene: 'CampScene'
};
export const defaultBattleScenarioId: BattleScenarioId = firstBattleScenario.id; export const defaultBattleScenarioId: BattleScenarioId = firstBattleScenario.id;
export const battleScenarios: Record<BattleScenarioId, BattleScenarioDefinition> = { export const battleScenarios: Record<BattleScenarioId, BattleScenarioDefinition> = {
[firstBattleScenario.id]: firstBattleScenario 'first-battle-zhuo-commandery': firstBattleScenario,
'second-battle-yellow-turban-pursuit': secondBattleScenario
}; };
export const defaultBattleScenario = battleScenarios[defaultBattleScenarioId]; export const defaultBattleScenario = battleScenarios[defaultBattleScenarioId];

View File

@@ -185,6 +185,69 @@ export const firstBattleVictoryPages: StoryPage[] = [
} }
]; ];
export const secondBattleIntroPages: StoryPage[] = [
{
id: 'second-pursuit-rumor',
bgm: 'militia-theme',
chapter: '잔당의 그림자',
background: 'story-militia',
text: '첫 승리의 기쁨이 채 가시기도 전에, 황건 잔당이 북쪽 길목의 마을을 습격하고 있다는 급보가 도착했다.'
},
{
id: 'second-liu-bei-command',
bgm: 'battle-prep',
chapter: '추격 명령',
background: 'story-liu-bei',
speaker: '유비',
portrait: 'liuBei',
text: '백성이 다시 칼끝에 몰렸다면 우리가 쉬어 갈 곳은 없다. 길을 따라 추격하되, 마을을 먼저 지켜야 한다.'
},
{
id: 'second-guan-yu-vow',
bgm: 'battle-prep',
chapter: '좁은 길목',
background: 'story-three-heroes',
speaker: '관우',
portrait: 'guanYu',
text: '적은 숲과 강을 끼고 물러설 것입니다. 제가 앞길을 열겠습니다. 형님께서는 백성을 살피십시오.'
},
{
id: 'second-zhang-fei-charge',
bgm: 'battle-prep',
chapter: '의용군 출진',
background: 'story-sortie',
speaker: '장비',
portrait: 'zhangFei',
text: '좋다! 숨어 있는 놈들도 모조리 끌어내 주마. 이번에는 도망칠 틈도 주지 않겠다!'
}
];
export const secondBattleVictoryPages: StoryPage[] = [
{
id: 'second-victory-village',
bgm: 'militia-theme',
chapter: '마을의 안도',
background: 'story-militia',
text: '잔당의 불길은 꺼지고, 피난하던 백성들이 하나둘 마을로 돌아왔다. 의용군의 이름은 이제 길목의 장터마다 오르내렸다.'
},
{
id: 'second-victory-liu-bei',
bgm: 'militia-theme',
chapter: '다음 책임',
background: 'story-liu-bei',
speaker: '유비',
portrait: 'liuBei',
text: '우리가 이긴 것은 힘만으로 된 일이 아니다. 백성을 지키겠다는 뜻을 잊지 않는다면, 더 큰 전장도 견딜 수 있을 것이다.'
},
{
id: 'second-victory-next',
bgm: 'battle-prep',
chapter: '넓어지는 전장',
background: 'story-sortie',
text: '탁현 인근의 소란은 잦아들었지만, 황건의 난은 아직 천하 곳곳에서 타오르고 있었다. 세 형제의 길은 이제 더 멀리 이어진다.'
}
];
export const firstBattleMap: BattleMap = { export const firstBattleMap: BattleMap = {
width: 20, width: 20,
height: 18, height: 18,
@@ -210,6 +273,33 @@ export const firstBattleMap: BattleMap = {
] ]
}; };
export const secondBattleMap: BattleMap = {
width: 24,
height: 20,
terrain: [
['forest', 'forest', 'forest', 'hill', 'plain', 'plain', 'road', 'plain', 'forest', 'hill', 'plain', 'river', 'river', 'plain', 'plain', 'forest', 'forest', 'plain', 'hill', 'hill', 'plain', 'plain', 'forest', 'forest'],
['forest', 'hill', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'hill', 'river', 'river', 'plain', 'forest', 'forest', 'plain', 'plain', 'hill', 'fort', 'fort', 'plain', 'plain', 'forest'],
['plain', 'plain', 'plain', 'forest', 'plain', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'river', 'river', 'road', 'road', 'plain', 'plain', 'plain', 'plain', 'fort', 'plain', 'plain', 'plain', 'forest'],
['plain', 'plain', 'forest', 'forest', 'plain', 'road', 'plain', 'forest', 'forest', 'plain', 'plain', 'river', 'river', 'road', 'plain', 'plain', 'hill', 'hill', 'plain', 'plain', 'plain', 'forest', 'plain', 'plain'],
['plain', 'forest', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'hill', 'river', 'river', 'road', 'plain', 'plain', 'hill', 'cliff', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain'],
['plain', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain', 'river', 'river', 'road', 'plain', 'plain', 'hill', 'plain', 'village', 'plain', 'forest', 'forest', 'plain'],
['plain', 'plain', 'forest', 'plain', 'road', 'plain', 'plain', 'hill', 'hill', 'plain', 'plain', 'plain', 'river', 'river', 'road', 'road', 'plain', 'plain', 'plain', 'village', 'plain', 'plain', 'forest', 'plain'],
['plain', 'forest', 'forest', 'plain', 'road', 'plain', 'plain', 'plain', 'hill', 'plain', 'road', 'road', 'plain', 'river', 'river', 'road', 'plain', 'plain', 'plain', 'plain', 'plain', 'plain', 'hill', 'plain'],
['plain', 'forest', 'plain', 'road', 'road', 'plain', 'hill', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'hill', 'hill', 'plain'],
['plain', 'plain', 'plain', 'road', 'plain', 'plain', 'hill', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'plain', 'hill', 'plain'],
['hill', 'plain', 'plain', 'road', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'village', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'plain', 'plain'],
['hill', 'hill', 'plain', 'road', 'plain', 'forest', 'plain', 'road', 'plain', 'plain', 'village', 'village', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'plain'],
['cliff', 'hill', 'plain', 'road', 'road', 'plain', 'plain', 'road', 'plain', 'plain', 'plain', 'village', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'plain'],
['cliff', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'road', 'plain', 'plain', 'plain', 'plain', 'forest', 'plain', 'river', 'river', 'plain', 'plain', 'plain', 'plain', 'forest', 'plain', 'plain'],
['plain', 'plain', 'camp', 'plain', 'plain', 'road', 'road', 'plain', 'road', 'plain', 'forest', 'plain', 'plain', 'forest', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain'],
['plain', 'camp', 'camp', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'plain', 'forest', 'plain', 'hill'],
['plain', 'camp', 'plain', 'plain', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'plain', 'hill', 'hill'],
['plain', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'plain', 'hill'],
['plain', 'plain', 'road', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'plain'],
['plain', 'road', 'road', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'plain']
]
};
export const firstBattleUnits: UnitData[] = [ export const firstBattleUnits: UnitData[] = [
{ {
id: 'liu-bei', id: 'liu-bei',
@@ -507,6 +597,249 @@ export const firstBattleUnits: UnitData[] = [
} }
]; ];
const secondBattleAllyPositions: Record<string, { x: number; y: number }> = {
'liu-bei': { x: 2, y: 15 },
'guan-yu': { x: 3, y: 15 },
'zhang-fei': { x: 2, y: 16 }
};
export const secondBattleUnits: UnitData[] = [
...firstBattleUnits
.filter((unit) => unit.faction === 'ally')
.map((unit) => placeScenarioUnit(unit, secondBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })),
{
id: 'pursuit-raider-a',
name: '황건 추격병',
faction: 'enemy',
className: '황건병',
classKey: 'yellowTurban',
level: 2,
exp: 12,
hp: 23,
maxHp: 23,
attack: 7,
move: 3,
stats: { might: 55, intelligence: 34, leadership: 43, agility: 48, luck: 42 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 12 },
armor: { itemId: 'rebel-vest', level: 1, exp: 6 },
accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
},
x: 7,
y: 12
},
{
id: 'pursuit-raider-b',
name: '황건 습격병',
faction: 'enemy',
className: '황건병',
classKey: 'yellowTurban',
level: 2,
exp: 12,
hp: 23,
maxHp: 23,
attack: 7,
move: 3,
stats: { might: 54, intelligence: 35, leadership: 42, agility: 47, luck: 41 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 12 },
armor: { itemId: 'rebel-vest', level: 1, exp: 6 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 10,
y: 11
},
{
id: 'pursuit-bandit-a',
name: '산길 복병',
faction: 'enemy',
className: '산적',
classKey: 'bandit',
level: 2,
exp: 14,
hp: 24,
maxHp: 24,
attack: 8,
move: 3,
stats: { might: 58, intelligence: 32, leadership: 40, agility: 53, luck: 44 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 13 },
armor: { itemId: 'rebel-vest', level: 1, exp: 7 },
accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
},
x: 6,
y: 8
},
{
id: 'pursuit-archer-a',
name: '황건 궁병',
faction: 'enemy',
className: '궁병',
classKey: 'archer',
level: 2,
exp: 16,
hp: 20,
maxHp: 20,
attack: 8,
move: 3,
stats: { might: 54, intelligence: 43, leadership: 45, agility: 58, luck: 45 },
equipment: {
weapon: { itemId: 'short-bow', level: 1, exp: 16 },
armor: { itemId: 'cloth-armor', level: 1, exp: 7 },
accessory: { itemId: 'wind-quiver', level: 1, exp: 0 }
},
x: 12,
y: 10
},
{
id: 'pursuit-cavalry-a',
name: '황건 기병',
faction: 'enemy',
className: '경기병',
classKey: 'cavalry',
level: 3,
exp: 18,
hp: 26,
maxHp: 26,
attack: 9,
move: 5,
stats: { might: 62, intelligence: 35, leadership: 48, agility: 65, luck: 46 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 18 },
armor: { itemId: 'rebel-vest', level: 1, exp: 8 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 15,
y: 7
},
{
id: 'pursuit-raider-c',
name: '마을 습격병',
faction: 'enemy',
className: '황건병',
classKey: 'yellowTurban',
level: 2,
exp: 13,
hp: 24,
maxHp: 24,
attack: 7,
move: 3,
stats: { might: 56, intelligence: 34, leadership: 43, agility: 49, luck: 42 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 13 },
armor: { itemId: 'rebel-vest', level: 1, exp: 7 },
accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
},
x: 17,
y: 6
},
{
id: 'pursuit-archer-b',
name: '황건 궁병',
faction: 'enemy',
className: '궁병',
classKey: 'archer',
level: 3,
exp: 18,
hp: 21,
maxHp: 21,
attack: 8,
move: 3,
stats: { might: 55, intelligence: 45, leadership: 46, agility: 59, luck: 47 },
equipment: {
weapon: { itemId: 'short-bow', level: 1, exp: 18 },
armor: { itemId: 'cloth-armor', level: 1, exp: 8 },
accessory: { itemId: 'wind-quiver', level: 1, exp: 0 }
},
x: 20,
y: 5
},
{
id: 'pursuit-cavalry-b',
name: '황건 기병',
faction: 'enemy',
className: '경기병',
classKey: 'cavalry',
level: 3,
exp: 19,
hp: 27,
maxHp: 27,
attack: 9,
move: 5,
stats: { might: 63, intelligence: 35, leadership: 49, agility: 66, luck: 47 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 19 },
armor: { itemId: 'rebel-vest', level: 1, exp: 8 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 18,
y: 3
},
{
id: 'pursuit-guard-a',
name: '한석 친위병',
faction: 'enemy',
className: '황건병',
classKey: 'yellowTurban',
level: 3,
exp: 16,
hp: 25,
maxHp: 25,
attack: 8,
move: 3,
stats: { might: 58, intelligence: 36, leadership: 48, agility: 50, luck: 44 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 16 },
armor: { itemId: 'rebel-vest', level: 1, exp: 8 },
accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
},
x: 19,
y: 2
},
{
id: 'pursuit-guard-b',
name: '한석 친위병',
faction: 'enemy',
className: '황건병',
classKey: 'yellowTurban',
level: 3,
exp: 16,
hp: 25,
maxHp: 25,
attack: 8,
move: 3,
stats: { might: 58, intelligence: 36, leadership: 48, agility: 50, luck: 44 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 16 },
armor: { itemId: 'rebel-vest', level: 1, exp: 8 },
accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
},
x: 21,
y: 2
},
{
id: 'pursuit-leader-han-seok',
name: '두령 한석',
faction: 'enemy',
className: '두령',
classKey: 'rebelLeader',
level: 4,
exp: 24,
hp: 38,
maxHp: 38,
attack: 11,
move: 3,
stats: { might: 68, intelligence: 48, leadership: 64, agility: 54, luck: 52 },
equipment: {
weapon: { itemId: 'leader-axe', level: 1, exp: 24 },
armor: { itemId: 'lamellar-armor', level: 1, exp: 14 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 20,
y: 1
}
];
export const firstBattleBonds: BattleBond[] = [ export const firstBattleBonds: BattleBond[] = [
{ {
id: 'liu-bei__guan-yu', id: 'liu-bei__guan-yu',
@@ -533,3 +866,24 @@ export const firstBattleBonds: BattleBond[] = [
description: '둘이 같은 전선을 압박하면 연계 공격의 기초 확률이 오른다.' description: '둘이 같은 전선을 압박하면 연계 공격의 기초 확률이 오른다.'
} }
]; ];
export const secondBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario);
function placeScenarioUnit(unit: UnitData, position: { x: number; y: number }): UnitData {
return {
...cloneUnitForScenario(unit),
x: position.x,
y: position.y
};
}
function cloneUnitForScenario(unit: UnitData): UnitData {
return JSON.parse(JSON.stringify(unit)) as UnitData;
}
function cloneBattleBondForScenario(bond: BattleBond): BattleBond {
return {
...bond,
unitIds: [...bond.unitIds] as [string, string]
};
}

View File

@@ -18,6 +18,7 @@ import {
markCampaignStep, markCampaignStep,
saveCampaignState, saveCampaignState,
setFirstBattleReport, setFirstBattleReport,
type CampaignState,
type CampaignStep type CampaignStep
} from '../state/campaignState'; } from '../state/campaignState';
import { palette } from '../ui/palette'; import { palette } from '../ui/palette';
@@ -36,7 +37,18 @@ const unitTexture: Record<string, string> = {
'rebel-cavalry-a': 'unit-rebel-cavalry', 'rebel-cavalry-a': 'unit-rebel-cavalry',
'rebel-cavalry-b': 'unit-rebel-cavalry', 'rebel-cavalry-b': 'unit-rebel-cavalry',
'rebel-archer-b': 'unit-rebel-archer', 'rebel-archer-b': 'unit-rebel-archer',
'rebel-leader': 'unit-rebel-leader' 'rebel-leader': 'unit-rebel-leader',
'pursuit-raider-a': 'unit-rebel',
'pursuit-raider-b': 'unit-rebel',
'pursuit-bandit-a': 'unit-rebel',
'pursuit-archer-a': 'unit-rebel-archer',
'pursuit-cavalry-a': 'unit-rebel-cavalry',
'pursuit-raider-c': 'unit-rebel',
'pursuit-archer-b': 'unit-rebel-archer',
'pursuit-cavalry-b': 'unit-rebel-cavalry',
'pursuit-guard-a': 'unit-rebel',
'pursuit-guard-b': 'unit-rebel',
'pursuit-leader-han-seok': 'unit-rebel-leader'
}; };
const unitFrameRows: Record<UnitDirection, number> = { const unitFrameRows: Record<UnitDirection, number> = {
@@ -459,7 +471,18 @@ const enemyAiByUnitId: Record<string, EnemyAiBehavior> = {
'rebel-cavalry-a': 'aggressive', 'rebel-cavalry-a': 'aggressive',
'rebel-cavalry-b': 'aggressive', 'rebel-cavalry-b': 'aggressive',
'rebel-archer-b': 'hold', 'rebel-archer-b': 'hold',
'rebel-leader': 'guard' 'rebel-leader': 'guard',
'pursuit-raider-a': 'aggressive',
'pursuit-raider-b': 'guard',
'pursuit-bandit-a': 'aggressive',
'pursuit-archer-a': 'hold',
'pursuit-cavalry-a': 'aggressive',
'pursuit-raider-c': 'aggressive',
'pursuit-archer-b': 'hold',
'pursuit-cavalry-b': 'aggressive',
'pursuit-guard-a': 'guard',
'pursuit-guard-b': 'guard',
'pursuit-leader-han-seok': 'guard'
}; };
const defaultEnemyAiByClass: Partial<Record<UnitClassKey, EnemyAiBehavior>> = { const defaultEnemyAiByClass: Partial<Record<UnitClassKey, EnemyAiBehavior>> = {
@@ -611,8 +634,8 @@ export class BattleScene extends Phaser.Scene {
} }
create() { create() {
this.resetBattleData();
const campaign = getCampaignState(); const campaign = getCampaignState();
this.resetBattleData(campaign);
if (campaign.step === 'new' || campaign.step === 'prologue' || campaign.step === 'first-battle') { if (campaign.step === 'new' || campaign.step === 'prologue' || campaign.step === 'first-battle') {
markCampaignStep('first-battle'); markCampaignStep('first-battle');
} }
@@ -662,9 +685,42 @@ export class BattleScene extends Phaser.Scene {
this.renderRosterPanel('ally', '행동할 장수를 선택하세요.'); this.renderRosterPanel('ally', '행동할 장수를 선택하세요.');
} }
private resetBattleData() { private resetBattleData(campaign?: CampaignState) {
battleUnits.splice(0, battleUnits.length, ...initialBattleUnits.map(cloneUnitData)); battleUnits.splice(0, battleUnits.length, ...initialBattleUnits.map((unit) => this.applyCampaignUnitProgress(unit, campaign)));
battleBonds.splice(0, battleBonds.length, ...initialBattleBonds.map(cloneBattleBond)); battleBonds.splice(0, battleBonds.length, ...initialBattleBonds.map((bond) => this.applyCampaignBondProgress(bond, campaign)));
initialBattleUnits = battleUnits.map(cloneUnitData);
initialBattleBonds = battleBonds.map(cloneBattleBond);
}
private applyCampaignUnitProgress(unit: UnitData, campaign?: CampaignState) {
const cloned = cloneUnitData(unit);
const progress = campaign?.roster.find((candidate) => candidate.id === unit.id);
if (!progress || unit.faction !== 'ally') {
return cloned;
}
return {
...cloned,
level: progress.level,
exp: progress.exp,
hp: Math.max(1, Math.min(progress.hp, progress.maxHp)),
maxHp: progress.maxHp,
equipment: JSON.parse(JSON.stringify(progress.equipment)) as UnitData['equipment']
};
}
private applyCampaignBondProgress(bond: BattleBond, campaign?: CampaignState) {
const cloned = cloneBattleBond(bond);
const progress = campaign?.bonds.find((candidate) => candidate.id === bond.id);
if (!progress) {
return cloned;
}
return {
...cloned,
level: progress.level,
exp: progress.exp
};
} }
private createLayout(width: number, height: number): BattleLayout { private createLayout(width: number, height: number): BattleLayout {
@@ -2054,7 +2110,7 @@ export class BattleScene extends Phaser.Scene {
} }
this.addResultButton('다시 하기', left + panelWidth - 276, top + 612, 124, () => { this.addResultButton('다시 하기', left + panelWidth - 276, top + 612, 124, () => {
soundDirector.playSelect(); soundDirector.playSelect();
this.scene.restart(); this.scene.restart({ battleId: battleScenario.id });
}, depth + 3); }, depth + 3);
this.addResultButton('타이틀로', left + panelWidth - 140, top + 612, 124, () => { this.addResultButton('타이틀로', left + panelWidth - 140, top + 612, 124, () => {
soundDirector.playSelect(); soundDirector.playSelect();
@@ -3149,7 +3205,7 @@ export class BattleScene extends Phaser.Scene {
} }
private showOpeningBattleEvent() { private showOpeningBattleEvent() {
this.triggerBattleEvent('opening', '전투 목표', battleScenario.openingObjectiveLines); this.triggerBattleEvent('opening', '전투 목표', battleScenario.openingObjectiveLines);
} }
private triggerBattleEvent(key: string, title: string, lines: string[]) { private triggerBattleEvent(key: string, title: string, lines: string[]) {
@@ -6463,8 +6519,8 @@ export class BattleScene extends Phaser.Scene {
x: unit.x, x: unit.x,
y: unit.y, y: unit.y,
acted: this.actedUnitIds.has(unit.id), acted: this.actedUnitIds.has(unit.id),
animating: this.unitViews.get(unit.id)?.sprite.anims.isPlaying ?? false, animating: this.unitViews.get(unit.id)?.sprite.anims?.isPlaying ?? false,
animationKey: this.unitViews.get(unit.id)?.sprite.anims.currentAnim?.key ?? null animationKey: this.unitViews.get(unit.id)?.sprite.anims?.currentAnim?.key ?? null
})), })),
bonds: Array.from(this.bondStates.values()).map((bond) => ({ bonds: Array.from(this.bondStates.values()).map((bond) => ({
id: bond.id, id: bond.id,

View File

@@ -1,5 +1,6 @@
import Phaser from 'phaser'; import Phaser from 'phaser';
import firstBattleMapUrl from '../../assets/images/battle/first-battle-map.png'; import firstBattleMapUrl from '../../assets/images/battle/first-battle-map.png';
import secondBattleMapUrl from '../../assets/images/battle/second-battle-map.svg';
import guanYuPortraitUrl from '../../assets/images/portraits/guan-yu.png'; import guanYuPortraitUrl from '../../assets/images/portraits/guan-yu.png';
import liuBeiPortraitUrl from '../../assets/images/portraits/liu-bei.png'; import liuBeiPortraitUrl from '../../assets/images/portraits/liu-bei.png';
import zhangFeiPortraitUrl from '../../assets/images/portraits/zhang-fei.png'; import zhangFeiPortraitUrl from '../../assets/images/portraits/zhang-fei.png';
@@ -60,6 +61,7 @@ export class BootScene extends Phaser.Scene {
this.load.image('story-militia', storyMilitiaUrl); this.load.image('story-militia', storyMilitiaUrl);
this.load.image('story-sortie', storySortieUrl); this.load.image('story-sortie', storySortieUrl);
this.load.image('battle-map-first', firstBattleMapUrl); this.load.image('battle-map-first', firstBattleMapUrl);
this.load.image('battle-map-second', secondBattleMapUrl);
this.load.image('portrait-liu-bei', liuBeiPortraitUrl); this.load.image('portrait-liu-bei', liuBeiPortraitUrl);
this.load.image('portrait-guan-yu', guanYuPortraitUrl); this.load.image('portrait-guan-yu', guanYuPortraitUrl);
this.load.image('portrait-zhang-fei', zhangFeiPortraitUrl); this.load.image('portrait-zhang-fei', zhangFeiPortraitUrl);

View File

@@ -2,8 +2,8 @@ import Phaser from 'phaser';
import { soundDirector } from '../audio/SoundDirector'; import { soundDirector } from '../audio/SoundDirector';
import { equipmentExpToNext, equipmentSlotLabels, equipmentSlots, getItem, type EquipmentSlot } from '../data/battleItems'; import { equipmentExpToNext, equipmentSlotLabels, equipmentSlots, getItem, type EquipmentSlot } from '../data/battleItems';
import { getUnitClass } from '../data/battleRules'; import { getUnitClass } from '../data/battleRules';
import { defaultBattleScenario } from '../data/battles'; import { defaultBattleScenario, secondBattleScenario } from '../data/battles';
import { firstBattleBonds, firstBattleUnits, firstBattleVictoryPages, type PortraitKey, type UnitData } from '../data/scenario'; import { firstBattleBonds, firstBattleUnits, secondBattleIntroPages, type PortraitKey, type UnitData } from '../data/scenario';
import { import {
applyCampBondExp, applyCampBondExp,
getCampaignState, getCampaignState,
@@ -335,10 +335,11 @@ export class CampScene extends Phaser.Scene {
bg.setDepth(depth); bg.setDepth(depth);
bg.setStrokeStyle(1, palette.blue, 0.48); bg.setStrokeStyle(1, palette.blue, 0.48);
this.trackSortie(this.add.text(x + 18, y + 14, '다음 전장', this.textStyle(18, '#f2e3bf', true))).setDepth(depth + 1); const briefing = this.nextSortieBriefing();
this.trackSortie(this.add.text(x + 18, y + 48, '황건 잔당 추격', this.textStyle(23, '#d8b15f', true))).setDepth(depth + 1); this.trackSortie(this.add.text(x + 18, y + 14, briefing.eyebrow, this.textStyle(18, '#f2e3bf', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 18, y + 48, briefing.title, this.textStyle(23, '#d8b15f', true))).setDepth(depth + 1);
this.trackSortie( this.trackSortie(
this.add.text(x + 18, y + 84, '탁현을 물러난 황건 잔당이 인근 마을을 위협하고 있습니다. 의용군은 첫 승리의 기세를 몰아 추격에 나섭니다.', { this.add.text(x + 18, y + 84, briefing.description, {
...this.textStyle(14, '#d4dce6'), ...this.textStyle(14, '#d4dce6'),
wordWrap: { width: width - 36, useAdvancedWrap: true }, wordWrap: { width: width - 36, useAdvancedWrap: true },
lineSpacing: 4 lineSpacing: 4
@@ -397,10 +398,27 @@ export class CampScene extends Phaser.Scene {
bg.setStrokeStyle(1, palette.gold, 0.4); bg.setStrokeStyle(1, palette.gold, 0.4);
const completedDialogues = this.completedCampDialogues().length; const completedDialogues = this.completedCampDialogues().length;
const inventory = this.inventoryLabels().join(', '); const inventory = this.inventoryLabels().join(', ');
this.trackSortie(this.add.text(x + 16, y + 10, `예상 보상: 군자금, 소모품, 의용군 명성`, this.textStyle(13, '#f2e3bf', true))).setDepth(depth + 1); const reward = this.campaign?.latestBattleId === secondBattleScenario.id ? '예상 보상: 다음 장 개방' : '예상 보상: 군자금, 소모품, 의용군 명성';
this.trackSortie(this.add.text(x + 16, y + 10, reward, this.textStyle(13, '#f2e3bf', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 16, y + 30, `현재 준비: 대화 ${completedDialogues}/${campDialogues.length} · 보유 ${inventory || '없음'}`, this.textStyle(12, '#d4dce6'))).setDepth(depth + 1); this.trackSortie(this.add.text(x + 16, y + 30, `현재 준비: 대화 ${completedDialogues}/${campDialogues.length} · 보유 ${inventory || '없음'}`, this.textStyle(12, '#d4dce6'))).setDepth(depth + 1);
} }
private nextSortieBriefing() {
if (this.campaign?.latestBattleId === secondBattleScenario.id) {
return {
eyebrow: '다음 이야기',
title: '넓어지는 전장',
description: '황건 잔당 추격을 마친 의용군은 탁현을 넘어 더 큰 난세의 흐름을 마주합니다. 다음 장으로 향하기 전 전투 결과를 정리합니다.'
};
}
return {
eyebrow: '다음 전장',
title: secondBattleScenario.title,
description: '탁현을 물러난 황건 잔당이 인근 마을을 위협하고 있습니다. 의용군은 첫 승리의 기세를 몰아 추격에 나섭니다.'
};
}
private sortieChecklist(): SortieChecklistItem[] { private sortieChecklist(): SortieChecklistItem[] {
const units = this.currentUnits().filter((unit) => unit.faction === 'ally'); const units = this.currentUnits().filter((unit) => unit.faction === 'ally');
const liuBei = units.find((unit) => unit.id === 'liu-bei'); const liuBei = units.find((unit) => unit.id === 'liu-bei');
@@ -453,8 +471,17 @@ export class CampScene extends Phaser.Scene {
} }
private startVictoryStory() { private startVictoryStory() {
markCampaignStep('first-victory-story'); if (this.campaign?.latestBattleId === secondBattleScenario.id) {
this.scene.start('StoryScene', { pages: firstBattleVictoryPages, nextScene: 'TitleScene' }); this.scene.start('StoryScene', { pages: secondBattleScenario.victoryPages, nextScene: 'TitleScene' });
return;
}
markCampaignStep('second-battle');
this.scene.start('StoryScene', {
pages: secondBattleIntroPages,
nextScene: 'BattleScene',
nextSceneData: { battleId: secondBattleScenario.id }
});
} }
private hideSortiePrep() { private hideSortiePrep() {

View File

@@ -14,12 +14,14 @@ type AlphaObject = Phaser.GameObjects.Image | Phaser.GameObjects.Rectangle | Pha
type StorySceneData = { type StorySceneData = {
pages?: StoryPage[]; pages?: StoryPage[];
nextScene?: string; nextScene?: string;
nextSceneData?: Record<string, unknown>;
}; };
export class StoryScene extends Phaser.Scene { export class StoryScene extends Phaser.Scene {
private pageIndex = 0; private pageIndex = 0;
private pages: StoryPage[] = prologuePages; private pages: StoryPage[] = prologuePages;
private nextScene = 'BattleScene'; private nextScene = 'BattleScene';
private nextSceneData?: Record<string, unknown>;
private transitioning = false; private transitioning = false;
private background?: Phaser.GameObjects.Image; private background?: Phaser.GameObjects.Image;
private chapterText?: Phaser.GameObjects.Text; private chapterText?: Phaser.GameObjects.Text;
@@ -37,6 +39,7 @@ export class StoryScene extends Phaser.Scene {
init(data?: StorySceneData) { init(data?: StorySceneData) {
this.pages = data?.pages?.length ? data.pages : prologuePages; this.pages = data?.pages?.length ? data.pages : prologuePages;
this.nextScene = data?.nextScene ?? 'BattleScene'; this.nextScene = data?.nextScene ?? 'BattleScene';
this.nextSceneData = data?.nextSceneData;
this.pageIndex = 0; this.pageIndex = 0;
this.transitioning = false; this.transitioning = false;
} }
@@ -223,7 +226,7 @@ export class StoryScene extends Phaser.Scene {
} }
if (this.pageIndex >= this.pages.length - 1) { if (this.pageIndex >= this.pages.length - 1) {
this.scene.start(this.nextScene); this.scene.start(this.nextScene, this.nextSceneData);
return; return;
} }

View File

@@ -1,6 +1,7 @@
import Phaser from 'phaser'; import Phaser from 'phaser';
import { soundDirector } from '../audio/SoundDirector'; import { soundDirector } from '../audio/SoundDirector';
import { firstBattleVictoryPages } from '../data/scenario'; import { firstBattleVictoryPages } from '../data/scenario';
import { secondBattleScenario } from '../data/battles';
import { hasCampaignSave, loadCampaignState, startNewCampaign } from '../state/campaignState'; import { hasCampaignSave, loadCampaignState, startNewCampaign } from '../state/campaignState';
import { palette } from '../ui/palette'; import { palette } from '../ui/palette';
@@ -298,7 +299,7 @@ export class TitleScene extends Phaser.Scene {
soundDirector.playSelect(); soundDirector.playSelect();
const campaign = loadCampaignState(); const campaign = loadCampaignState();
if (campaign.step === 'first-camp') { if (campaign.step === 'first-camp' || campaign.step === 'second-camp') {
this.scene.start('CampScene'); this.scene.start('CampScene');
return; return;
} }
@@ -308,6 +309,11 @@ export class TitleScene extends Phaser.Scene {
return; return;
} }
if (campaign.step === 'second-battle') {
this.scene.start('BattleScene', { battleId: secondBattleScenario.id });
return;
}
if (campaign.step === 'first-victory-story') { if (campaign.step === 'first-victory-story') {
this.scene.start('StoryScene', { pages: firstBattleVictoryPages, nextScene: 'TitleScene' }); this.scene.start('StoryScene', { pages: firstBattleVictoryPages, nextScene: 'TitleScene' });
return; return;

View File

@@ -36,7 +36,7 @@ export type FirstBattleReport = {
createdAt: string; createdAt: string;
}; };
export type CampaignStep = 'new' | 'prologue' | 'first-battle' | 'first-camp' | 'first-victory-story'; export type CampaignStep = 'new' | 'prologue' | 'first-battle' | 'first-camp' | 'first-victory-story' | 'second-battle' | 'second-camp';
export type CampaignUnitProgressSnapshot = { export type CampaignUnitProgressSnapshot = {
unitId: string; unitId: string;
@@ -186,13 +186,18 @@ export function setFirstBattleReport(report: FirstBattleReport) {
const reportClone = cloneReport(report); const reportClone = cloneReport(report);
const battleId = reportClone.battleId; const battleId = reportClone.battleId;
const previousSettlement = state.battleHistory[battleId]; const previousSettlement = state.battleHistory[battleId];
const completedCampDialogues =
battleId === 'first-battle-zhuo-commandery' ? [...reportClone.completedCampDialogues] : [...state.completedCampDialogues];
reportClone.completedCampDialogues = completedCampDialogues;
state.firstBattleReport = reportClone; state.firstBattleReport = reportClone;
state.step = reportClone.outcome === 'victory' ? 'first-camp' : 'first-battle'; const victoryStep: CampaignStep = battleId === 'second-battle-yellow-turban-pursuit' ? 'second-camp' : 'first-camp';
const retryStep: CampaignStep = battleId === 'second-battle-yellow-turban-pursuit' ? 'second-battle' : 'first-battle';
state.step = reportClone.outcome === 'victory' ? victoryStep : retryStep;
state.gold = Math.max(0, state.gold - (previousSettlement?.rewardGold ?? 0) + reportClone.rewardGold); state.gold = Math.max(0, state.gold - (previousSettlement?.rewardGold ?? 0) + reportClone.rewardGold);
state.roster = reportClone.units.filter((unit) => unit.faction === 'ally').map(cloneUnit); state.roster = reportClone.units.filter((unit) => unit.faction === 'ally').map(cloneUnit);
state.bonds = reportClone.bonds.map(cloneBondSnapshot); state.bonds = reportClone.bonds.map(cloneBondSnapshot);
state.completedCampDialogues = [...reportClone.completedCampDialogues]; state.completedCampDialogues = completedCampDialogues;
state.inventory = applyRewardDelta(state.inventory, previousSettlement?.itemRewards ?? [], -1); state.inventory = applyRewardDelta(state.inventory, previousSettlement?.itemRewards ?? [], -1);
state.inventory = applyRewardDelta(state.inventory, reportClone.itemRewards, 1); state.inventory = applyRewardDelta(state.inventory, reportClone.itemRewards, 1);
state.battleHistory[battleId] = createBattleSettlement(reportClone); state.battleHistory[battleId] = createBattleSettlement(reportClone);