Add Sishui vanguard campaign battle

This commit is contained in:
2026-06-22 23:57:49 +09:00
parent 421b95011e
commit fe93b70a56
9 changed files with 594 additions and 15 deletions

View File

@@ -21,11 +21,12 @@ Build a small complete tactical RPG loop that can grow into a longer Romance of
- Second battle expansion with a larger scrollable map, mixed enemy AI behaviors, minimap navigation, camp progression, and save persistence
- Third battle expansion path toward Guangzong with a new battlefield, story bridge, campaign step persistence, and title continue support
- Fourth battle Guangzong main camp confrontation with Zhang Jue, expanded enemy formation, campaign persistence, and anti-Dong Zhuo setup
- Flow verification script from title through the fourth battle victory and camp save state
- Fifth battle Sishui Gate vanguard path starting the anti-Dong Zhuo chapter, with campaign persistence and title continue support
- Flow verification script from title through the fifth battle victory and camp save state
## Next Steps
1. Start the anti-Dong Zhuo chapter with a story bridge and first coalition-era battle
1. Build the Gongsun Zan camp chapter after the Sishui Gate vanguard battle
2. Broaden camp events so bonds, supplies, and equipment choices matter between battles
3. Apply treasure equipment effects to damage, defense, recovery, and command rules
4. Add more distinctive victory/defeat branches and optional objectives

View File

@@ -574,6 +574,78 @@ try {
throw new Error(`Expected campaign save to persist fourth battle victory: ${JSON.stringify(campaignSaveAfterFourthBattle)}`);
}
await page.mouse.click(1120, 38);
await page.waitForTimeout(160);
const fourthCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
if (!fourthCampSortieState?.sortieVisible) {
throw new Error(`Expected fourth camp sortie preparation overlay: ${JSON.stringify(fourthCampSortieState)}`);
}
await page.mouse.click(938, 596);
await page.waitForFunction(() => {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
return activeScenes.includes('StoryScene');
});
for (let i = 0; i < 26; i += 1) {
const isFifthBattleActive = await page.evaluate(() => {
const state = window.__HEROS_DEBUG__?.battle();
return state?.scene === 'BattleScene' && state?.battleId === 'fifth-battle-sishui-vanguard';
});
if (isFifthBattleActive) {
break;
}
await page.keyboard.press('Space');
await page.waitForTimeout(320);
}
await page.waitForFunction(() => {
const state = window.__HEROS_DEBUG__?.battle();
return state?.scene === 'BattleScene' && state?.battleId === 'fifth-battle-sishui-vanguard' && state?.battleOutcome === null && state?.phase === 'idle';
});
await page.screenshot({ path: 'dist/verification-fifth-battle.png', fullPage: true });
const fifthBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
const fifthEnemies = fifthBattleState.units.filter((unit) => unit.faction === 'enemy');
const fifthEnemyBehaviors = new Set(fifthEnemies.map((unit) => unit.ai));
if (
fifthBattleState.camera?.mapWidth !== 24 ||
fifthBattleState.camera?.mapHeight !== 20 ||
fifthBattleState.victoryConditionLabel !== '호진 격파' ||
fifthEnemies.length < 10 ||
!fifthEnemyBehaviors.has('aggressive') ||
!fifthEnemyBehaviors.has('guard') ||
!fifthEnemyBehaviors.has('hold')
) {
throw new Error(`Expected fifth battle map, objective, and mixed enemy AI: ${JSON.stringify(fifthBattleState)}`);
}
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 campaignSaveAfterFifthBattle = await page.evaluate(() => {
const raw = window.localStorage.getItem('heros-web:campaign-state');
return raw ? JSON.parse(raw) : undefined;
});
if (
!campaignSaveAfterFifthBattle ||
campaignSaveAfterFifthBattle.step !== 'fifth-camp' ||
campaignSaveAfterFifthBattle.latestBattleId !== 'fifth-battle-sishui-vanguard' ||
!campaignSaveAfterFifthBattle.battleHistory?.['fifth-battle-sishui-vanguard']
) {
throw new Error(`Expected campaign save to persist fifth battle victory: ${JSON.stringify(campaignSaveAfterFifthBattle)}`);
}
await page.evaluate(() => window.__HEROS_GAME__?.scene.start('TitleScene'));
await page.waitForFunction(() => {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
@@ -585,7 +657,7 @@ try {
return activeScenes.includes('CampScene');
});
console.log(`Verified title-to-fourth-battle flow, result states, and debug API at ${targetUrl}`);
console.log(`Verified title-to-fifth-battle flow, result states, and debug API at ${targetUrl}`);
} finally {
await browser?.close();
if (serverProcess && !serverProcess.killed) {

View File

@@ -0,0 +1,87 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2400 2000" width="2400" height="2000">
<defs>
<filter id="grain">
<feTurbulence type="fractalNoise" baseFrequency="0.017" numOctaves="4" seed="85" />
<feColorMatrix type="saturate" values="0.55" />
<feBlend mode="multiply" in2="SourceGraphic" />
</filter>
<pattern id="grass" width="72" height="72" patternUnits="userSpaceOnUse">
<rect width="72" height="72" fill="#61784c" />
<path d="M10 20h20M41 48h18M20 62l14-10M52 17l12 9" stroke="#a3b46b" stroke-width="3" opacity="0.26" />
<path d="M8 52l11-18M35 24l10-14M58 66l7-20" stroke="#334e30" stroke-width="2" opacity="0.34" />
</pattern>
<pattern id="earth" width="82" height="82" patternUnits="userSpaceOnUse">
<rect width="82" height="82" fill="#94704a" />
<path d="M4 18c28-14 50 9 76-5M0 55c30-16 56 12 80-4" stroke="#c69a5f" stroke-width="6" opacity="0.25" />
<path d="M18 70l13-18M48 34l18-13M64 68l10-7" stroke="#57452f" stroke-width="3" opacity="0.32" />
</pattern>
<pattern id="forest" width="104" height="92" patternUnits="userSpaceOnUse">
<rect width="104" height="92" fill="#30472d" />
<circle cx="20" cy="32" r="25" fill="#223923" />
<circle cx="50" cy="24" r="29" fill="#526f3e" />
<circle cx="78" cy="40" r="26" fill="#2b4329" />
<circle cx="44" cy="68" r="23" fill="#668548" />
<path d="M12 50c24 15 58 14 84-5M32 22c18 16 44 14 60-5" stroke="#142516" stroke-width="5" opacity="0.36" />
</pattern>
<linearGradient id="river" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#467f9a" />
<stop offset="0.54" stop-color="#23536f" />
<stop offset="1" stop-color="#193b52" />
</linearGradient>
<linearGradient id="wall" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#80705a" />
<stop offset="1" stop-color="#44372a" />
</linearGradient>
<linearGradient id="roof" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#c88b38" />
<stop offset="1" stop-color="#7a4f1f" />
</linearGradient>
</defs>
<rect width="2400" height="2000" fill="url(#grass)" />
<g filter="url(#grain)">
<path d="M-120 1710C230 1438 416 1288 640 1072C826 892 990 760 1164 646C1374 508 1568 380 1870 300C2060 250 2246 150 2520-30" fill="none" stroke="#a98555" stroke-width="250" stroke-linecap="round" opacity="0.98" />
<path d="M-120 1710C230 1438 416 1288 640 1072C826 892 990 760 1164 646C1374 508 1568 380 1870 300C2060 250 2246 150 2520-30" fill="none" stroke="url(#earth)" stroke-width="170" stroke-linecap="round" opacity="0.92" />
<path d="M-70 1040C212 945 444 920 666 782C880 650 1036 506 1280 454C1508 405 1668 312 1788 128" fill="none" stroke="#a17a4c" stroke-width="184" stroke-linecap="round" opacity="0.9" />
<path d="M-70 1040C212 945 444 920 666 782C880 650 1036 506 1280 454C1508 405 1668 312 1788 128" fill="none" stroke="url(#earth)" stroke-width="116" stroke-linecap="round" opacity="0.86" />
<path d="M1286 430c-62 224 24 368 168 514c146 146 192 292 136 486c-44 152-18 302 112 458" fill="none" stroke="#173b52" stroke-width="252" stroke-linecap="round" opacity="0.96" />
<path d="M1286 430c-62 224 24 368 168 514c146 146 192 292 136 486c-44 152-18 302 112 458" fill="none" stroke="url(#river)" stroke-width="170" stroke-linecap="round" opacity="0.98" />
<path d="M1325 546c62 86 70 176 26 272M1480 1048c88 100 105 220 52 360M1642 1630c48 84 64 166 46 250" fill="none" stroke="#a9dbe4" stroke-width="13" opacity="0.34" />
<path d="M-40 70c228-80 454-52 674 80c42 142-24 266-198 372c-192-16-356-80-492-194Z" fill="url(#forest)" opacity="0.9" />
<path d="M562 238c238-104 466-78 684 84c16 142-72 248-264 316c-232-18-372-132-420-400Z" fill="url(#forest)" opacity="0.82" />
<path d="M40 618c300-118 568-70 806 146c-80 222-324 342-730 360C-10 980-34 812 40 618Z" fill="url(#forest)" opacity="0.86" />
<path d="M1762 470c252-92 454-60 650 94v392c-240 78-468 20-684-172c-36-122-24-226 34-314Z" fill="url(#forest)" opacity="0.84" />
<path d="M640 1538c320-126 602-82 846 118c-54 220-284 336-690 344c-158-106-210-250-156-462Z" fill="url(#forest)" opacity="0.84" />
<g transform="translate(1338 58)">
<rect x="-34" y="-12" width="560" height="324" rx="18" fill="#2f281e" opacity="0.6" />
<rect x="0" y="0" width="492" height="278" rx="16" fill="url(#wall)" />
<rect x="40" y="50" width="412" height="184" fill="#756044" stroke="#251d15" stroke-width="12" />
<path d="M40 50h412M40 234h412M40 50v184M452 50v184" stroke="#35291e" stroke-width="10" />
<rect x="76" y="100" width="82" height="96" fill="#98724a" />
<rect x="204" y="72" width="100" height="124" fill="#9d7650" />
<rect x="346" y="98" width="68" height="98" fill="#8c6946" />
<path d="M58 100l60-48l62 48M190 72l64-52l76 52M330 98l50-42l58 42" fill="url(#roof)" stroke="#302316" stroke-width="9" />
<path d="M229 196v-66h50v66" fill="#1f1712" />
<path d="M18 278h456" stroke="#c0a369" stroke-width="8" opacity="0.42" />
</g>
<g transform="translate(1760 520)">
<rect x="0" y="0" width="220" height="158" rx="12" fill="#5f4b36" />
<rect x="34" y="46" width="66" height="82" fill="#98714d" />
<rect x="124" y="36" width="70" height="92" fill="#96704a" />
<path d="M18 46l50-40l54 40M112 36l48-38l58 38" fill="url(#roof)" stroke="#322317" stroke-width="9" />
</g>
<g transform="translate(80 1418)">
<path d="M0 88c84-70 182-92 294-66c80 74 92 162 36 264c-116 24-222 0-318-74c-34-48-38-90-12-124Z" fill="#4d5d5f" opacity="0.48" />
<rect x="74" y="104" width="178" height="136" rx="10" fill="#604b36" />
<rect x="112" y="148" width="52" height="70" fill="#98724a" />
<rect x="184" y="136" width="44" height="82" fill="#96704a" />
<path d="M92 148l46-36l48 36M174 136l34-28l42 28" fill="url(#roof)" stroke="#2f2218" stroke-width="8" />
</g>
</g>
<rect width="2400" height="2000" fill="#0b0d12" opacity="0.07" />
</svg>

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

@@ -3,6 +3,10 @@ import {
firstBattleMap,
firstBattleUnits,
firstBattleVictoryPages,
fifthBattleBonds,
fifthBattleMap,
fifthBattleUnits,
fifthBattleVictoryPages,
secondBattleBonds,
secondBattleMap,
secondBattleUnits,
@@ -25,7 +29,8 @@ export type BattleScenarioId =
| 'first-battle-zhuo-commandery'
| 'second-battle-yellow-turban-pursuit'
| 'third-battle-guangzong-road'
| 'fourth-battle-guangzong-camp';
| 'fourth-battle-guangzong-camp'
| 'fifth-battle-sishui-vanguard';
export type BattleObjectiveKind = 'defeat-leader' | 'keep-unit-alive' | 'secure-terrain' | 'quick-victory';
@@ -276,13 +281,67 @@ export const fourthBattleScenario: BattleScenarioDefinition = {
nextCampScene: 'CampScene'
};
export const fifthBattleScenario: BattleScenarioDefinition = {
id: 'fifth-battle-sishui-vanguard',
title: '사수관 전초전',
victoryConditionLabel: '호진 격파',
defeatConditionLabel: '유비 퇴각',
openingObjectiveLines: [
'반동탁 연합으로 향하는 길목을 동탁군 장수 호진이 막고 있습니다. 호진을 격파하면 사수관 앞 전초 진지가 흔들립니다.',
'중앙 길은 빠르지만 궁병과 기병의 반격을 받기 쉽습니다. 숲길 척후를 처리하고 요새 주변을 차근히 압박하십시오.',
'14턴 이내에 승리하면 공손찬 진영에서 세 형제의 무공이 더 크게 알려집니다.'
],
map: fifthBattleMap,
units: fifthBattleUnits,
bonds: fifthBattleBonds,
mapTextureKey: 'battle-map-fifth',
leaderUnitId: 'sishui-leader-hu-zhen',
quickVictoryTurnLimit: 14,
baseVictoryGold: 760,
objectives: [
{
id: 'leader',
kind: 'defeat-leader',
label: '호진 격파',
rewardGold: 460,
unitId: 'sishui-leader-hu-zhen'
},
{
id: 'liu-bei',
kind: 'keep-unit-alive',
label: '유비 생존',
rewardGold: 190,
unitId: 'liu-bei'
},
{
id: 'fort',
kind: 'secure-terrain',
label: '사수관 전초 요새 확보',
rewardGold: 280,
terrain: 'fort'
},
{
id: 'quick',
kind: 'quick-victory',
label: '14턴 이내 승리',
rewardGold: 230,
maxTurn: 14
}
],
defeatConditions: [{ kind: 'unit-defeated', unitId: 'liu-bei' }],
itemRewards: ['콩 2', '상처약 1', '탁주 1', '반동탁 공적 +1'],
victoryPages: fifthBattleVictoryPages,
nextCampScene: 'CampScene'
};
export const defaultBattleScenarioId: BattleScenarioId = firstBattleScenario.id;
export const battleScenarios: Record<BattleScenarioId, BattleScenarioDefinition> = {
'first-battle-zhuo-commandery': firstBattleScenario,
'second-battle-yellow-turban-pursuit': secondBattleScenario,
'third-battle-guangzong-road': thirdBattleScenario,
'fourth-battle-guangzong-camp': fourthBattleScenario
'fourth-battle-guangzong-camp': fourthBattleScenario,
'fifth-battle-sishui-vanguard': fifthBattleScenario
};
export const defaultBattleScenario = battleScenarios[defaultBattleScenarioId];

View File

@@ -1,6 +1,15 @@
import type { CampaignStep } from '../state/campaignState';
import { defaultBattleScenario, fourthBattleScenario, secondBattleScenario, thirdBattleScenario, type BattleScenarioId } from './battles';
import {
defaultBattleScenario,
fifthBattleScenario,
fourthBattleScenario,
secondBattleScenario,
thirdBattleScenario,
type BattleScenarioId
} from './battles';
import {
fifthBattleIntroPages,
fifthBattleVictoryPages,
firstBattleVictoryPages,
fourthBattleIntroPages,
fourthBattleVictoryPages,
@@ -56,12 +65,22 @@ const sortieFlows: Record<string, SortieFlow> = {
},
[fourthBattleScenario.id]: {
afterBattleId: fourthBattleScenario.id,
eyebrow: '다음 전장',
title: fifthBattleScenario.title,
description: '황건적 토벌로 이름을 알린 세 형제는 반동탁 연합의 흐름에 몸을 싣습니다. 공손찬의 진영으로 향하는 길목에서 사수관 전초 병력을 상대해야 합니다.',
rewardHint: `예상 보상: ${fifthBattleScenario.title} 개방`,
nextBattleId: fifthBattleScenario.id,
campaignStep: 'fifth-battle',
pages: [...fourthBattleVictoryPages, ...fifthBattleIntroPages]
},
[fifthBattleScenario.id]: {
afterBattleId: fifthBattleScenario.id,
eyebrow: '다음 장 준비',
title: '반동탁의 흐름',
description: '황건적 토벌로 이름을 알린 세 형제 앞에 조정의 새로운 혼란이 다가옵니다. 다음 줄기는 반동탁 흐름으로 이어집니다.',
rewardHint: '다음 장: 반동탁 흐름 준비 중',
pages: [],
unavailableNotice: '반동탁 흐름은 다음 장으로 이어집니다. 군영에서 성장 상태를 정비하세요.'
title: '공손찬 의탁',
description: '사수관 앞 전초전을 넘긴 유비군은 공손찬 진영에서 연합군의 큰 흐름을 살피게 됩니다. 다음 줄기는 공손찬 의탁과 그 뒤의 서주로 이어집니다.',
rewardHint: '다음 장: 공손찬 의탁 준비 중',
pages: fifthBattleVictoryPages,
unavailableNotice: '공손찬 의탁 흐름은 다음 장으로 이어집니다. 군영에서 성장 상태를 정비하세요.'
}
};

View File

@@ -374,6 +374,60 @@ export const fourthBattleVictoryPages: StoryPage[] = [
}
];
export const fifthBattleIntroPages: StoryPage[] = [
{
id: 'fifth-coalition-rumor',
bgm: 'militia-theme',
chapter: '반동탁의 격문',
background: 'story-militia',
text: '황건의 불길이 잦아들자 조정에는 또 다른 폭풍이 일었다. 동탁이 낙양을 장악했다는 소식과 함께, 각지의 제후들이 반동탁의 격문에 응하기 시작했다.'
},
{
id: 'fifth-liu-bei-choice',
bgm: 'battle-prep',
chapter: '새로운 대의',
background: 'story-liu-bei',
speaker: '유비',
portrait: 'liuBei',
text: '백성을 괴롭히는 난을 막았다 하나, 조정이 어지러우면 천하는 다시 피를 흘린다. 우리는 더 큰 흐름 속으로 들어가야 한다.'
},
{
id: 'fifth-gongsun-zan',
bgm: 'battle-prep',
chapter: '공손찬의 진영',
background: 'story-three-heroes',
speaker: '관우',
portrait: 'guanYu',
text: '공손찬의 군영에 합류하면 연합군의 움직임을 가까이서 볼 수 있습니다. 그러나 동탁군도 사수관 앞길을 막고 있습니다.'
},
{
id: 'fifth-sishui-sortie',
bgm: 'battle-prep',
chapter: '사수관 전초전',
background: 'story-sortie',
speaker: '장비',
portrait: 'zhangFei',
text: '동탁군이든 황건적이든 백성을 짓밟는 놈들은 똑같소. 사수관 앞길부터 뚫어 봅시다!'
}
];
export const fifthBattleVictoryPages: StoryPage[] = [
{
id: 'fifth-victory-sishui',
bgm: 'militia-theme',
chapter: '사수관의 문턱',
background: 'story-sortie',
text: '동탁군의 전초 부대가 물러서자 연합군 진영에 세 형제의 이름이 다시 한 번 전해졌다.'
},
{
id: 'fifth-victory-next',
bgm: 'battle-prep',
chapter: '공손찬 의탁',
background: 'story-militia',
text: '사수관 앞 전초전은 끝났지만, 반동탁의 싸움은 이제 막 시작되었다. 유비는 공손찬의 군영에서 다음 기회를 기다린다.'
}
];
export const firstBattleMap: BattleMap = {
width: 20,
height: 18,
@@ -480,6 +534,33 @@ export const fourthBattleMap: BattleMap = {
]
};
export const fifthBattleMap: BattleMap = {
width: 24,
height: 20,
terrain: [
['hill', 'hill', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'hill', 'hill', 'forest', 'plain', 'plain', 'fort', 'fort', 'plain', 'hill', 'hill', 'forest', 'forest', 'plain', 'plain', 'hill', 'hill'],
['hill', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain', 'road', 'fort', 'fort', 'road', 'plain', 'hill', 'forest', 'plain', 'plain', 'plain', 'plain', 'hill'],
['plain', 'plain', 'forest', 'plain', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'hill', 'plain', 'road', 'road', 'road', 'road', 'plain', 'plain', 'plain', 'forest', 'plain', 'hill', 'plain', 'plain'],
['plain', 'forest', 'forest', 'plain', 'road', 'plain', 'hill', 'forest', 'plain', 'plain', 'hill', 'plain', 'plain', 'road', 'plain', 'road', 'plain', 'hill', 'plain', 'forest', 'plain', 'plain', 'plain', 'plain'],
['plain', 'plain', 'forest', 'road', 'road', 'plain', 'hill', 'plain', 'plain', 'forest', 'plain', 'plain', 'river', 'river', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'forest', 'plain', 'plain', 'hill'],
['plain', 'plain', 'plain', 'road', 'plain', 'plain', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'road', 'plain', 'village', 'plain', 'forest', 'forest', 'plain', 'hill'],
['forest', 'plain', 'plain', 'road', 'plain', 'hill', 'hill', 'plain', 'forest', 'plain', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'road', 'plain', 'village', 'plain', 'plain', 'forest', 'plain', 'plain'],
['forest', 'plain', 'road', 'road', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain', 'forest', 'plain', 'plain', 'river', 'river', 'plain', 'road', 'road', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain'],
['plain', 'plain', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'hill', 'plain', 'forest', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'road', 'plain', 'plain', 'hill', 'plain', 'forest', 'plain'],
['plain', 'road', 'road', 'plain', 'forest', 'forest', 'plain', 'plain', 'hill', 'plain', 'plain', 'road', 'plain', 'plain', 'plain', 'river', 'river', 'road', 'road', 'plain', 'plain', 'plain', 'forest', 'plain'],
['road', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'river', 'river', 'road', 'plain', 'plain', 'hill', 'plain', 'plain'],
['plain', 'road', 'plain', 'forest', 'plain', 'plain', 'hill', 'plain', 'plain', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'hill', 'plain', 'plain'],
['plain', 'road', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'hill', 'plain', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'plain', 'plain'],
['plain', 'road', 'plain', 'plain', 'plain', 'hill', 'plain', 'forest', 'plain', 'plain', 'plain', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'hill', 'plain'],
['camp', 'road', 'road', 'plain', 'plain', 'hill', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'hill', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'plain'],
['camp', 'camp', 'road', 'plain', 'forest', 'plain', 'plain', 'plain', 'forest', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'river', 'river', 'plain'],
['plain', 'camp', 'road', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'hill', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'river', 'plain'],
['plain', 'plain', 'camp', 'road', 'plain', 'forest', 'plain', 'plain', 'plain', 'hill', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'plain'],
['plain', 'plain', 'plain', 'road', 'road', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'hill', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain'],
['plain', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'hill', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain']
]
};
export const firstBattleUnits: UnitData[] = [
{
id: 'liu-bei',
@@ -1548,6 +1629,249 @@ export const fourthBattleUnits: UnitData[] = [
}
];
const fifthBattleAllyPositions: Record<string, { x: number; y: number }> = {
'liu-bei': { x: 2, y: 16 },
'guan-yu': { x: 3, y: 16 },
'zhang-fei': { x: 2, y: 17 }
};
export const fifthBattleUnits: UnitData[] = [
...firstBattleUnits
.filter((unit) => unit.faction === 'ally')
.map((unit) => placeScenarioUnit(unit, fifthBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })),
{
id: 'sishui-vanguard-a',
name: '서량 선봉',
faction: 'enemy',
className: '동탁 보병',
classKey: 'yellowTurban',
level: 5,
exp: 30,
hp: 32,
maxHp: 32,
attack: 10,
move: 3,
stats: { might: 68, intelligence: 38, leadership: 52, agility: 55, luck: 44 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 4 },
armor: { itemId: 'rebel-vest', level: 1, exp: 16 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 6,
y: 14
},
{
id: 'sishui-vanguard-b',
name: '서량 선봉',
faction: 'enemy',
className: '동탁 보병',
classKey: 'yellowTurban',
level: 5,
exp: 30,
hp: 32,
maxHp: 32,
attack: 10,
move: 3,
stats: { might: 68, intelligence: 37, leadership: 52, agility: 54, luck: 44 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 4 },
armor: { itemId: 'rebel-vest', level: 1, exp: 16 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 8,
y: 12
},
{
id: 'sishui-raider-a',
name: '산길 척후',
faction: 'enemy',
className: '척후병',
classKey: 'bandit',
level: 5,
exp: 32,
hp: 30,
maxHp: 30,
attack: 11,
move: 4,
stats: { might: 70, intelligence: 42, leadership: 48, agility: 66, luck: 52 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 8 },
armor: { itemId: 'rebel-vest', level: 1, exp: 16 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 5,
y: 9
},
{
id: 'sishui-archer-a',
name: '사수관 궁병',
faction: 'enemy',
className: '서량 궁병',
classKey: 'archer',
level: 5,
exp: 32,
hp: 27,
maxHp: 27,
attack: 11,
move: 3,
stats: { might: 63, intelligence: 50, leadership: 51, agility: 64, luck: 49 },
equipment: {
weapon: { itemId: 'short-bow', level: 2, exp: 8 },
armor: { itemId: 'cloth-armor', level: 1, exp: 16 },
accessory: { itemId: 'wind-quiver', level: 1, exp: 0 }
},
x: 10,
y: 10
},
{
id: 'sishui-cavalry-a',
name: '서량 기병',
faction: 'enemy',
className: '서량 경기병',
classKey: 'cavalry',
level: 6,
exp: 34,
hp: 36,
maxHp: 36,
attack: 12,
move: 5,
stats: { might: 74, intelligence: 38, leadership: 54, agility: 76, luck: 50 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 10 },
armor: { itemId: 'rebel-vest', level: 2, exp: 2 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 12,
y: 9
},
{
id: 'sishui-guard-a',
name: '관문 수비병',
faction: 'enemy',
className: '동탁 보병',
classKey: 'yellowTurban',
level: 6,
exp: 34,
hp: 35,
maxHp: 35,
attack: 11,
move: 3,
stats: { might: 72, intelligence: 40, leadership: 56, agility: 56, luck: 48 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 12 },
armor: { itemId: 'rebel-vest', level: 2, exp: 2 },
accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
},
x: 14,
y: 7
},
{
id: 'sishui-archer-b',
name: '관문 궁병',
faction: 'enemy',
className: '서량 궁병',
classKey: 'archer',
level: 6,
exp: 36,
hp: 28,
maxHp: 28,
attack: 12,
move: 3,
stats: { might: 65, intelligence: 52, leadership: 53, agility: 65, luck: 50 },
equipment: {
weapon: { itemId: 'short-bow', level: 2, exp: 12 },
armor: { itemId: 'cloth-armor', level: 1, exp: 18 },
accessory: { itemId: 'wind-quiver', level: 1, exp: 0 }
},
x: 16,
y: 6
},
{
id: 'sishui-cavalry-b',
name: '서량 기병',
faction: 'enemy',
className: '서량 경기병',
classKey: 'cavalry',
level: 6,
exp: 36,
hp: 37,
maxHp: 37,
attack: 12,
move: 5,
stats: { might: 75, intelligence: 39, leadership: 55, agility: 77, luck: 51 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 12 },
armor: { itemId: 'rebel-vest', level: 2, exp: 4 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 18,
y: 10
},
{
id: 'sishui-guard-b',
name: '관문 수비병',
faction: 'enemy',
className: '동탁 보병',
classKey: 'yellowTurban',
level: 6,
exp: 36,
hp: 36,
maxHp: 36,
attack: 11,
move: 3,
stats: { might: 73, intelligence: 40, leadership: 57, agility: 56, luck: 49 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 14 },
armor: { itemId: 'rebel-vest', level: 2, exp: 4 },
accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
},
x: 16,
y: 3
},
{
id: 'sishui-gate-guard',
name: '관문 친위병',
faction: 'enemy',
className: '동탁 보병',
classKey: 'yellowTurban',
level: 6,
exp: 38,
hp: 38,
maxHp: 38,
attack: 12,
move: 3,
stats: { might: 76, intelligence: 42, leadership: 60, agility: 57, luck: 50 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 18 },
armor: { itemId: 'lamellar-armor', level: 2, exp: 8 },
accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
},
x: 14,
y: 1
},
{
id: 'sishui-leader-hu-zhen',
name: '호진',
faction: 'enemy',
className: '동탁 장수',
classKey: 'rebelLeader',
level: 7,
exp: 44,
hp: 50,
maxHp: 50,
attack: 14,
move: 3,
stats: { might: 80, intelligence: 55, leadership: 72, agility: 60, luck: 54 },
equipment: {
weapon: { itemId: 'leader-axe', level: 2, exp: 20 },
armor: { itemId: 'lamellar-armor', level: 2, exp: 10 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 15,
y: 1
}
];
export const firstBattleBonds: BattleBond[] = [
{
id: 'liu-bei__guan-yu',
@@ -1578,6 +1902,7 @@ export const firstBattleBonds: BattleBond[] = [
export const secondBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario);
export const thirdBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario);
export const fourthBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario);
export const fifthBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario);
function placeScenarioUnit(unit: UnitData, position: { x: number; y: number }): UnitData {
return {

View File

@@ -1,4 +1,5 @@
import Phaser from 'phaser';
import fifthBattleMapUrl from '../../assets/images/battle/fifth-battle-map.svg';
import firstBattleMapUrl from '../../assets/images/battle/first-battle-map.png';
import fourthBattleMapUrl from '../../assets/images/battle/fourth-battle-map.svg';
import secondBattleMapUrl from '../../assets/images/battle/second-battle-map.svg';
@@ -66,6 +67,7 @@ export class BootScene extends Phaser.Scene {
this.load.image('battle-map-second', secondBattleMapUrl);
this.load.image('battle-map-third', thirdBattleMapUrl);
this.load.image('battle-map-fourth', fourthBattleMapUrl);
this.load.image('battle-map-fifth', fifthBattleMapUrl);
this.load.image('portrait-liu-bei', liuBeiPortraitUrl);
this.load.image('portrait-guan-yu', guanYuPortraitUrl);
this.load.image('portrait-zhang-fei', zhangFeiPortraitUrl);

View File

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

View File

@@ -47,7 +47,9 @@ export type CampaignStep =
| 'third-battle'
| 'third-camp'
| 'fourth-battle'
| 'fourth-camp';
| 'fourth-camp'
| 'fifth-battle'
| 'fifth-camp';
export type CampaignUnitProgressSnapshot = {
unitId: string;
@@ -101,7 +103,8 @@ const campaignBattleSteps: Record<string, { victory: CampaignStep; retry: Campai
'first-battle-zhuo-commandery': { victory: 'first-camp', retry: 'first-battle' },
'second-battle-yellow-turban-pursuit': { victory: 'second-camp', retry: 'second-battle' },
'third-battle-guangzong-road': { victory: 'third-camp', retry: 'third-battle' },
'fourth-battle-guangzong-camp': { victory: 'fourth-camp', retry: 'fourth-battle' }
'fourth-battle-guangzong-camp': { victory: 'fourth-camp', retry: 'fourth-battle' },
'fifth-battle-sishui-vanguard': { victory: 'fifth-camp', retry: 'fifth-battle' }
};
export type CampaignSaveSlotSummary = {