Add second battle pursuit chapter
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
98
src/assets/images/battle/second-battle-map.svg
Normal file
98
src/assets/images/battle/second-battle-map.svg
Normal 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 |
@@ -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];
|
||||||
|
|||||||
@@ -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]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user