Add third campaign battle path

This commit is contained in:
2026-06-22 23:22:51 +09:00
parent e06b8dab58
commit 68e1b10cb4
10 changed files with 650 additions and 23 deletions

View File

@@ -2,13 +2,14 @@
## Initial Goal
Build a small complete tactical RPG loop:
Build a small complete tactical RPG loop that can grow into a longer Romance of the Three Kingdoms campaign:
1. Title screen
2. Prologue story
3. First battle briefing
4. First battle
5. Battle result and save point
6. Repeatable story -> camp -> battle expansion path
## Current Slice
@@ -17,15 +18,17 @@ Build a small complete tactical RPG loop:
- Cinematic prologue pages with high-resolution story backgrounds, character portraits, non-black scene transitions, and scenario data
- File-based original soft stereo orchestral BGM loops for title, prologue, oath, militia, and battle-prep scenes
- First battle scene with a high-resolution battlefield background, large 12x12 tactical grid, high-detail pixel-style unit sprites, class and terrain affinity rules, ally/enemy roster tabs, unit detail stat panels, weapon/armor/accessory equipment growth, terrain-cost movement range preview, click-to-move unit movement, cursor-adjacent post-move command popup, right-click move cancel, right-click tactical menu, turn ending/reset, resonance bond tracking, and grayscale acted-unit feedback
- Flow verification script from title to first battle
- 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
- Flow verification script from title through the third battle victory and camp save state
## Next Steps
1. Implement attack range, damage, defeat, victory checks, and real resonance assist attacks
2. Apply treasure equipment effects to damage, defense, recovery, and command rules
3. Add strategy and item inventories with usable battle effects
4. Add a simple enemy AI turn
5. Add battle result scene and local save/load
1. Add the next Guangzong main battle and named Yellow Turban commanders
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
5. Keep expanding scenarios through the long campaign instead of treating the current slice as an ending
## Content Direction

View File

@@ -418,6 +418,78 @@ try {
throw new Error(`Expected campaign save to persist second battle victory: ${JSON.stringify(campaignSaveAfterSecondBattle)}`);
}
await page.mouse.click(1120, 38);
await page.waitForTimeout(160);
const secondCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
if (!secondCampSortieState?.sortieVisible) {
throw new Error(`Expected second camp sortie preparation overlay: ${JSON.stringify(secondCampSortieState)}`);
}
await page.mouse.click(938, 596);
await page.waitForFunction(() => {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
return activeScenes.includes('StoryScene');
});
for (let i = 0; i < 14; i += 1) {
const isThirdBattleActive = await page.evaluate(() => {
const state = window.__HEROS_DEBUG__?.battle();
return state?.scene === 'BattleScene' && state?.battleId === 'third-battle-guangzong-road';
});
if (isThirdBattleActive) {
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 === 'third-battle-guangzong-road' && state?.battleOutcome === null && state?.phase === 'idle';
});
await page.screenshot({ path: 'dist/verification-third-battle.png', fullPage: true });
const thirdBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
const thirdEnemies = thirdBattleState.units.filter((unit) => unit.faction === 'enemy');
const thirdEnemyBehaviors = new Set(thirdEnemies.map((unit) => unit.ai));
if (
thirdBattleState.camera?.mapWidth !== 24 ||
thirdBattleState.camera?.mapHeight !== 20 ||
thirdBattleState.victoryConditionLabel !== '전령 마원 격파' ||
thirdEnemies.length < 10 ||
!thirdEnemyBehaviors.has('aggressive') ||
!thirdEnemyBehaviors.has('guard') ||
!thirdEnemyBehaviors.has('hold')
) {
throw new Error(`Expected third battle map, objective, and mixed enemy AI: ${JSON.stringify(thirdBattleState)}`);
}
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 campaignSaveAfterThirdBattle = await page.evaluate(() => {
const raw = window.localStorage.getItem('heros-web:campaign-state');
return raw ? JSON.parse(raw) : undefined;
});
if (
!campaignSaveAfterThirdBattle ||
campaignSaveAfterThirdBattle.step !== 'third-camp' ||
campaignSaveAfterThirdBattle.latestBattleId !== 'third-battle-guangzong-road' ||
!campaignSaveAfterThirdBattle.battleHistory?.['third-battle-guangzong-road']
) {
throw new Error(`Expected campaign save to persist third battle victory: ${JSON.stringify(campaignSaveAfterThirdBattle)}`);
}
await page.evaluate(() => window.__HEROS_GAME__?.scene.start('TitleScene'));
await page.waitForFunction(() => {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
@@ -429,7 +501,7 @@ try {
return activeScenes.includes('CampScene');
});
console.log(`Verified title-to-second-battle flow, result states, and debug API at ${targetUrl}`);
console.log(`Verified title-to-third-battle flow, result states, and debug API at ${targetUrl}`);
} finally {
await browser?.close();
if (serverProcess && !serverProcess.killed) {

View File

@@ -0,0 +1,81 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2400 2000" width="2400" height="2000">
<defs>
<filter id="paintTexture">
<feTurbulence type="fractalNoise" baseFrequency="0.016" numOctaves="4" seed="41" />
<feColorMatrix type="saturate" values="0.48" />
<feBlend mode="multiply" in2="SourceGraphic" />
</filter>
<pattern id="field" width="64" height="64" patternUnits="userSpaceOnUse">
<rect width="64" height="64" fill="#64754c" />
<path d="M8 19h18M34 42h21M42 13l12 8M16 53l15-11" stroke="#a5af68" stroke-width="3" opacity="0.26" />
<path d="M4 41l12-16M28 12l7-10M50 58l8-18" stroke="#344b31" stroke-width="2" opacity="0.28" />
</pattern>
<pattern id="earth" width="72" height="72" patternUnits="userSpaceOnUse">
<rect width="72" height="72" fill="#9a7449" />
<path d="M4 16c22-11 42 8 62-3M0 49c24-13 48 11 68-2" stroke="#c99c5d" stroke-width="5" opacity="0.25" />
<path d="M17 62l9-14M42 29l15-10M58 58l7-5" stroke="#5d4932" stroke-width="3" opacity="0.33" />
</pattern>
<pattern id="leaf" width="96" height="82" patternUnits="userSpaceOnUse">
<rect width="96" height="82" fill="#31472c" />
<circle cx="22" cy="27" r="22" fill="#263b24" />
<circle cx="48" cy="23" r="25" fill="#4b6839" />
<circle cx="72" cy="34" r="22" fill="#2b4128" />
<circle cx="41" cy="57" r="21" fill="#5b7a43" />
<path d="M14 45c20 12 50 11 72-5M29 22c15 13 36 12 51-4" 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="#3b7894" />
<stop offset="0.55" stop-color="#24536e" />
<stop offset="1" stop-color="#183b52" />
</linearGradient>
<linearGradient id="cliff" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#71664d" />
<stop offset="1" stop-color="#2f2b24" />
</linearGradient>
</defs>
<rect width="2400" height="2000" fill="url(#field)" />
<g filter="url(#paintTexture)">
<path d="M-80 1560C230 1410 455 1372 710 1210C928 1072 1044 886 1212 736C1412 556 1690 505 1890 340C2050 208 2175 70 2480-18" fill="none" stroke="#a17a47" stroke-width="210" stroke-linecap="round" stroke-linejoin="round" opacity="0.98" />
<path d="M-80 1560C230 1410 455 1372 710 1210C928 1072 1044 886 1212 736C1412 556 1690 505 1890 340C2050 208 2175 70 2480-18" fill="none" stroke="url(#earth)" stroke-width="148" stroke-linecap="round" stroke-linejoin="round" opacity="0.94" />
<path d="M180 2060C360 1750 555 1502 828 1290C1052 1117 1170 940 1192 720C1212 520 1130 310 1225-90" fill="none" stroke="#9c7448" stroke-width="175" stroke-linecap="round" opacity="0.94" />
<path d="M180 2060C360 1750 555 1502 828 1290C1052 1117 1170 940 1192 720C1212 520 1130 310 1225-90" fill="none" stroke="url(#earth)" stroke-width="124" stroke-linecap="round" opacity="0.9" />
<path d="M1010-90C1020 180 990 410 1068 610C1160 848 1370 1000 1455 1215C1560 1484 1500 1710 1400 2100" fill="none" stroke="#15394f" stroke-width="250" stroke-linecap="round" opacity="0.94" />
<path d="M1010-90C1020 180 990 410 1068 610C1160 848 1370 1000 1455 1215C1560 1484 1500 1710 1400 2100" fill="none" stroke="url(#river)" stroke-width="176" stroke-linecap="round" opacity="0.98" />
<path d="M970 120c80 82 78 184 28 288M1182 760c120 82 184 190 190 325M1450 1405c42 156 10 292-83 420" fill="none" stroke="#91d0dc" stroke-width="14" opacity="0.34" />
<path d="M-40 0h520c100 76 142 175 126 294C410 350 207 328-42 276Z" fill="url(#leaf)" opacity="0.93" />
<path d="M650 110h325c132 80 180 188 140 315C922 480 744 430 602 316Z" fill="url(#leaf)" opacity="0.86" />
<path d="M80 610c275-110 506-72 690 88c-44 190-244 318-594 340C34 928 0 772 80 610Z" fill="url(#leaf)" opacity="0.9" />
<path d="M1515 45c240-82 468-42 708 100c34 164-38 310-200 434c-255 20-420-78-548-258Z" fill="url(#leaf)" opacity="0.88" />
<path d="M1715 1010c240-78 445-54 640 70v410c-250 54-468 6-650-150c-28-128-24-232 10-330Z" fill="url(#leaf)" opacity="0.86" />
<path d="M560 1580c286-126 555-96 760 72c-40 210-236 332-590 350c-142-94-200-236-170-422Z" fill="url(#leaf)" opacity="0.82" />
<path d="M1650 405c190-95 370-70 542 60c-18 154-120 278-302 370c-182-44-270-180-240-430Z" fill="url(#cliff)" opacity="0.7" />
<path d="M0 1645c230-120 400-128 574-62c18 166-56 305-224 417H0Z" fill="url(#cliff)" opacity="0.88" />
<path d="M1900 1490c182-36 360 18 520 150v360h-540c-108-146-102-315 20-510Z" fill="url(#cliff)" opacity="0.84" />
<g transform="translate(1500 540)">
<rect x="0" y="0" width="240" height="160" rx="14" fill="#5f4934" />
<rect x="30" y="46" width="68" height="82" fill="#98704a" />
<rect x="128" y="32" width="74" height="96" fill="#98704a" />
<path d="M14 46l52-42l54 42M116 32l52-40l64 40" fill="#c8923e" stroke="#332317" stroke-width="9" />
<path d="M0 144h240" stroke="#2d2218" stroke-width="10" />
</g>
<g transform="translate(1760 150)">
<rect x="0" y="0" width="310" height="210" rx="16" fill="#514538" />
<rect x="28" y="54" width="250" height="126" fill="#725c42" />
<path d="M18 54l70-56l70 56M148 54l70-56l70 56" fill="#8f6d3e" stroke="#2b2118" stroke-width="10" />
<path d="M64 178v-56h38v56M198 178v-70h46v70" fill="#211915" />
<path d="M-4 188h318" stroke="#1e1814" stroke-width="12" />
</g>
<g transform="translate(820 1180)">
<rect x="0" y="0" width="230" height="170" rx="13" fill="#584432" />
<rect x="32" y="48" width="70" height="88" fill="#8a6848" />
<rect x="130" y="52" width="64" height="84" fill="#8a6848" />
<path d="M16 48l52-42l56 42M120 52l42-34l50 34" fill="#be8431" stroke="#302215" stroke-width="9" />
</g>
</g>
<rect width="2400" height="2000" fill="#0b0d12" opacity="0.08" />
</svg>

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

@@ -7,13 +7,17 @@ import {
secondBattleMap,
secondBattleUnits,
secondBattleVictoryPages,
thirdBattleBonds,
thirdBattleMap,
thirdBattleUnits,
thirdBattleVictoryPages,
type BattleBond,
type BattleMap,
type StoryPage,
type UnitData
} from './scenario';
export type BattleScenarioId = 'first-battle-zhuo-commandery' | 'second-battle-yellow-turban-pursuit';
export type BattleScenarioId = 'first-battle-zhuo-commandery' | 'second-battle-yellow-turban-pursuit' | 'third-battle-guangzong-road';
export type BattleObjectiveKind = 'defeat-leader' | 'keep-unit-alive' | 'secure-terrain' | 'quick-victory';
@@ -158,11 +162,65 @@ export const secondBattleScenario: BattleScenarioDefinition = {
nextCampScene: 'CampScene'
};
export const thirdBattleScenario: BattleScenarioDefinition = {
id: 'third-battle-guangzong-road',
title: '광종 구원로',
victoryConditionLabel: '전령 마원 격파',
defeatConditionLabel: '유비 퇴각',
openingObjectiveLines: [
'황건 전령 마원이 광종 본대로 향하고 있습니다. 마원을 격파하면 적의 연락망이 끊어집니다.',
'강가 길목과 요새 주변의 궁병을 방치하면 전선이 오래 묶입니다. 지형 보정을 살펴 진군하십시오.',
'14턴 이내에 승리하면 광종 구원로 확보 명성이 오릅니다.'
],
map: thirdBattleMap,
units: thirdBattleUnits,
bonds: thirdBattleBonds,
mapTextureKey: 'battle-map-third',
leaderUnitId: 'guangzong-leader-ma-yuan',
quickVictoryTurnLimit: 14,
baseVictoryGold: 520,
objectives: [
{
id: 'leader',
kind: 'defeat-leader',
label: '전령 마원 격파',
rewardGold: 320,
unitId: 'guangzong-leader-ma-yuan'
},
{
id: 'liu-bei',
kind: 'keep-unit-alive',
label: '유비 생존',
rewardGold: 140,
unitId: 'liu-bei'
},
{
id: 'fort',
kind: 'secure-terrain',
label: '강가 요새 확보',
rewardGold: 220,
terrain: 'fort'
},
{
id: 'quick',
kind: 'quick-victory',
label: '14턴 이내 승리',
rewardGold: 180,
maxTurn: 14
}
],
defeatConditions: [{ kind: 'unit-defeated', unitId: 'liu-bei' }],
itemRewards: ['콩 2', '상처약 1', '의용군 명성 +3'],
victoryPages: thirdBattleVictoryPages,
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
'second-battle-yellow-turban-pursuit': secondBattleScenario,
'third-battle-guangzong-road': thirdBattleScenario
};
export const defaultBattleScenario = battleScenarios[defaultBattleScenarioId];

View File

@@ -248,6 +248,69 @@ export const secondBattleVictoryPages: StoryPage[] = [
}
];
export const thirdBattleIntroPages: StoryPage[] = [
{
id: 'third-guangzong-report',
bgm: 'militia-theme',
chapter: '광종의 급보',
background: 'story-militia',
text: '탁현 북쪽의 소란을 잠재우자, 장터에는 더 큰 소식이 밀려왔다. 광종 방면에서 황건 본대가 관군을 압박하고 있다는 전갈이었다.'
},
{
id: 'third-liu-bei-decision',
bgm: 'battle-prep',
chapter: '넓어지는 길',
background: 'story-liu-bei',
speaker: '유비',
portrait: 'liuBei',
text: '작은 마을만 지키고 물러선다면 난세는 끝나지 않는다. 광종으로 향하는 길을 열어, 더 많은 백성이 버틸 시간을 만들어야 한다.'
},
{
id: 'third-guan-yu-bridge',
bgm: 'battle-prep',
chapter: '강가의 길목',
background: 'story-three-heroes',
speaker: '관우',
portrait: 'guanYu',
text: '적 전령이 강가 요새를 거쳐 본대로 향한다면, 그 길을 끊어야 합니다. 좁은 다리와 숲길을 먼저 장악하겠습니다.'
},
{
id: 'third-zhang-fei-vanguard',
bgm: 'battle-prep',
chapter: '전초전',
background: 'story-sortie',
speaker: '장비',
portrait: 'zhangFei',
text: '전령이든 두령이든 내 앞길에 서면 다 똑같소! 형님, 명만 내리시오. 길목을 시원하게 열어 버리겠소!'
}
];
export const thirdBattleVictoryPages: StoryPage[] = [
{
id: 'third-victory-road',
bgm: 'militia-theme',
chapter: '열린 길목',
background: 'story-sortie',
text: '황건 전령의 깃발이 꺾이자 광종으로 향하는 길목에 잠시 숨통이 트였다. 흩어진 백성들은 의용군의 이름을 기억하기 시작했다.'
},
{
id: 'third-victory-liu-bei',
bgm: 'militia-theme',
chapter: '큰 싸움의 그림자',
background: 'story-liu-bei',
speaker: '유비',
portrait: 'liuBei',
text: '우리가 막은 것은 작은 전갈 하나일 뿐이다. 그러나 작은 길 하나가 열리면, 다음 전장에서도 백성을 구할 길이 생긴다.'
},
{
id: 'third-victory-next',
bgm: 'battle-prep',
chapter: '광종으로',
background: 'story-militia',
text: '세 형제는 병사를 추스르고 광종 방면으로 눈을 돌렸다. 황건의 큰 물결은 아직 꺾이지 않았다.'
}
];
export const firstBattleMap: BattleMap = {
width: 20,
height: 18,
@@ -300,6 +363,33 @@ export const secondBattleMap: BattleMap = {
]
};
export const thirdBattleMap: BattleMap = {
width: 24,
height: 20,
terrain: [
['forest', 'forest', 'hill', 'plain', 'plain', 'road', 'plain', 'forest', 'hill', 'plain', 'river', 'river', 'plain', 'forest', 'forest', 'hill', 'hill', 'plain', 'fort', 'fort', 'plain', 'forest', 'forest', 'hill'],
['forest', 'hill', 'plain', 'plain', 'road', 'road', 'plain', 'forest', 'plain', 'plain', 'river', 'river', 'road', 'plain', 'forest', 'plain', 'hill', 'plain', 'fort', 'plain', 'plain', 'plain', 'forest', 'forest'],
['hill', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'plain', 'road', 'road', 'road', 'road', 'plain', 'plain', 'plain', 'plain', 'hill', 'plain', 'plain', 'forest', 'plain', 'plain', 'forest'],
['plain', 'plain', 'forest', 'road', 'plain', 'plain', 'forest', 'plain', 'hill', 'road', 'river', 'river', 'road', 'road', 'plain', 'hill', 'hill', 'plain', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain'],
['plain', 'forest', 'forest', 'road', 'plain', 'hill', 'plain', 'plain', 'plain', 'road', 'river', 'river', 'plain', 'road', 'plain', 'plain', 'hill', 'cliff', 'plain', 'plain', 'plain', 'forest', 'plain', 'plain'],
['plain', 'plain', 'forest', 'road', 'road', 'plain', 'plain', 'hill', 'plain', 'road', 'plain', 'river', 'river', 'road', 'plain', 'village', 'plain', 'hill', 'plain', 'plain', 'plain', 'forest', 'forest', 'plain'],
['plain', 'plain', 'plain', 'plain', 'road', 'plain', 'hill', 'hill', 'plain', 'road', 'plain', 'river', 'river', 'road', 'plain', 'village', 'plain', 'plain', 'plain', 'plain', 'hill', 'plain', 'forest', 'plain'],
['plain', 'forest', 'plain', 'plain', 'road', 'plain', 'plain', 'hill', 'plain', 'road', 'road', 'plain', 'river', 'river', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'hill', 'hill', 'plain', 'plain'],
['forest', 'forest', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'hill', 'plain', 'road', 'plain', 'plain', 'river', 'river', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'hill', 'plain', 'plain'],
['forest', 'plain', 'plain', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'river', 'river', 'plain', 'plain', 'forest', 'plain', 'hill', 'plain', 'plain', 'plain', 'plain'],
['plain', 'plain', 'road', 'road', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'river', 'river', 'plain', 'plain', 'plain', 'hill', 'hill', 'plain', 'plain', 'plain'],
['plain', 'road', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'village', 'plain', 'plain', 'plain', 'road', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'plain', 'hill', 'hill', 'plain', 'plain'],
['hill', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'village', 'plain', 'forest', 'plain', 'road', 'road', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'plain', 'plain', 'plain', 'plain'],
['hill', 'road', 'plain', 'forest', 'forest', 'plain', 'plain', 'hill', 'plain', 'plain', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'forest', 'plain', 'plain'],
['plain', 'road', 'plain', 'plain', 'plain', 'plain', 'hill', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'forest', 'plain'],
['camp', 'road', 'road', 'plain', 'plain', 'hill', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'hill'],
['camp', 'camp', 'road', 'plain', 'forest', 'plain', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'river', 'river', 'plain', 'hill'],
['plain', 'camp', 'road', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'hill', 'plain', 'hill', 'plain', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'river', 'river', 'plain'],
['plain', 'plain', 'camp', 'road', 'plain', 'forest', 'forest', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'river', 'plain'],
['plain', 'plain', 'plain', 'road', 'road', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain']
]
};
export const firstBattleUnits: UnitData[] = [
{
id: 'liu-bei',
@@ -840,6 +930,270 @@ export const secondBattleUnits: UnitData[] = [
}
];
const thirdBattleAllyPositions: Record<string, { x: number; y: number }> = {
'liu-bei': { x: 2, y: 17 },
'guan-yu': { x: 3, y: 17 },
'zhang-fei': { x: 2, y: 18 }
};
export const thirdBattleUnits: UnitData[] = [
...firstBattleUnits
.filter((unit) => unit.faction === 'ally')
.map((unit) => placeScenarioUnit(unit, thirdBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })),
{
id: 'guangzong-scout-a',
name: '황건 척후병',
faction: 'enemy',
className: '황건병',
classKey: 'yellowTurban',
level: 3,
exp: 18,
hp: 25,
maxHp: 25,
attack: 8,
move: 3,
stats: { might: 58, intelligence: 36, leadership: 45, agility: 52, luck: 43 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 18 },
armor: { itemId: 'rebel-vest', level: 1, exp: 8 },
accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
},
x: 6,
y: 15
},
{
id: 'guangzong-bandit-a',
name: '숲길 복병',
faction: 'enemy',
className: '도적',
classKey: 'bandit',
level: 3,
exp: 20,
hp: 26,
maxHp: 26,
attack: 9,
move: 3,
stats: { might: 62, intelligence: 34, leadership: 42, agility: 58, luck: 46 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 18 },
armor: { itemId: 'rebel-vest', level: 1, exp: 8 },
accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
},
x: 8,
y: 13
},
{
id: 'guangzong-archer-a',
name: '강가 궁병',
faction: 'enemy',
className: '궁병',
classKey: 'archer',
level: 3,
exp: 20,
hp: 22,
maxHp: 22,
attack: 9,
move: 3,
stats: { might: 57, intelligence: 45, leadership: 47, agility: 60, luck: 48 },
equipment: {
weapon: { itemId: 'short-bow', level: 1, exp: 20 },
armor: { itemId: 'cloth-armor', level: 1, exp: 8 },
accessory: { itemId: 'wind-quiver', level: 1, exp: 0 }
},
x: 11,
y: 14
},
{
id: 'guangzong-cavalry-a',
name: '황건 기병',
faction: 'enemy',
className: '경기병',
classKey: 'cavalry',
level: 4,
exp: 22,
hp: 29,
maxHp: 29,
attack: 10,
move: 5,
stats: { might: 66, intelligence: 36, leadership: 50, agility: 68, luck: 48 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 22 },
armor: { itemId: 'rebel-vest', level: 1, exp: 9 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 12,
y: 11
},
{
id: 'guangzong-raider-a',
name: '황건 장정',
faction: 'enemy',
className: '황건병',
classKey: 'yellowTurban',
level: 3,
exp: 18,
hp: 26,
maxHp: 26,
attack: 8,
move: 3,
stats: { might: 59, intelligence: 35, leadership: 46, agility: 51, luck: 44 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 18 },
armor: { itemId: 'rebel-vest', level: 1, exp: 8 },
accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
},
x: 10,
y: 9
},
{
id: 'guangzong-raider-b',
name: '황건 장정',
faction: 'enemy',
className: '황건병',
classKey: 'yellowTurban',
level: 3,
exp: 18,
hp: 26,
maxHp: 26,
attack: 8,
move: 3,
stats: { might: 59, intelligence: 35, leadership: 46, agility: 51, luck: 44 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 18 },
armor: { itemId: 'rebel-vest', level: 1, exp: 8 },
accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
},
x: 14,
y: 10
},
{
id: 'guangzong-archer-b',
name: '요새 궁병',
faction: 'enemy',
className: '궁병',
classKey: 'archer',
level: 4,
exp: 24,
hp: 23,
maxHp: 23,
attack: 9,
move: 3,
stats: { might: 58, intelligence: 47, leadership: 48, agility: 61, luck: 49 },
equipment: {
weapon: { itemId: 'short-bow', level: 1, exp: 24 },
armor: { itemId: 'cloth-armor', level: 1, exp: 9 },
accessory: { itemId: 'wind-quiver', level: 1, exp: 0 }
},
x: 15,
y: 7
},
{
id: 'guangzong-cavalry-b',
name: '전령 기병',
faction: 'enemy',
className: '경기병',
classKey: 'cavalry',
level: 4,
exp: 25,
hp: 30,
maxHp: 30,
attack: 10,
move: 5,
stats: { might: 67, intelligence: 38, leadership: 51, agility: 70, luck: 50 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 25 },
armor: { itemId: 'rebel-vest', level: 1, exp: 10 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 17,
y: 6
},
{
id: 'guangzong-guard-a',
name: '요새 수비병',
faction: 'enemy',
className: '황건병',
classKey: 'yellowTurban',
level: 4,
exp: 22,
hp: 28,
maxHp: 28,
attack: 9,
move: 3,
stats: { might: 62, intelligence: 37, leadership: 50, agility: 52, luck: 46 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 22 },
armor: { itemId: 'rebel-vest', level: 1, exp: 10 },
accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
},
x: 18,
y: 4
},
{
id: 'guangzong-guard-b',
name: '요새 수비병',
faction: 'enemy',
className: '황건병',
classKey: 'yellowTurban',
level: 4,
exp: 22,
hp: 28,
maxHp: 28,
attack: 9,
move: 3,
stats: { might: 62, intelligence: 37, leadership: 50, agility: 52, luck: 46 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 22 },
armor: { itemId: 'rebel-vest', level: 1, exp: 10 },
accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
},
x: 20,
y: 4
},
{
id: 'guangzong-fort-archer',
name: '망루 궁병',
faction: 'enemy',
className: '궁병',
classKey: 'archer',
level: 4,
exp: 25,
hp: 24,
maxHp: 24,
attack: 10,
move: 3,
stats: { might: 60, intelligence: 49, leadership: 49, agility: 61, luck: 50 },
equipment: {
weapon: { itemId: 'short-bow', level: 1, exp: 25 },
armor: { itemId: 'cloth-armor', level: 1, exp: 10 },
accessory: { itemId: 'wind-quiver', level: 1, exp: 0 }
},
x: 19,
y: 2
},
{
id: 'guangzong-leader-ma-yuan',
name: '전령 마원',
faction: 'enemy',
className: '두령',
classKey: 'rebelLeader',
level: 5,
exp: 30,
hp: 42,
maxHp: 42,
attack: 12,
move: 3,
stats: { might: 72, intelligence: 50, leadership: 68, agility: 56, luck: 54 },
equipment: {
weapon: { itemId: 'leader-axe', level: 2, exp: 8 },
armor: { itemId: 'lamellar-armor', level: 1, exp: 18 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 20,
y: 2
}
];
export const firstBattleBonds: BattleBond[] = [
{
id: 'liu-bei__guan-yu',
@@ -868,6 +1222,7 @@ export const firstBattleBonds: BattleBond[] = [
];
export const secondBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario);
export const thirdBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario);
function placeScenarioUnit(unit: UnitData, position: { x: number; y: number }): UnitData {
return {

View File

@@ -51,6 +51,17 @@ const unitTexture: Record<string, string> = {
'pursuit-leader-han-seok': 'unit-rebel-leader'
};
const unitTextureByClass: Partial<Record<UnitClassKey, string>> = {
lord: 'unit-liu-bei',
infantry: 'unit-guan-yu',
spearman: 'unit-zhang-fei',
archer: 'unit-rebel-archer',
cavalry: 'unit-rebel-cavalry',
bandit: 'unit-rebel',
yellowTurban: 'unit-rebel',
rebelLeader: 'unit-rebel-leader'
};
const unitFrameRows: Record<UnitDirection, number> = {
south: 0,
east: 1,
@@ -806,7 +817,7 @@ export class BattleScene extends Phaser.Scene {
private drawUnits() {
battleUnits.forEach((unit) => {
const sizeRatio = unit.faction === 'ally' ? 1.16 : 1.1;
const textureBase = unitTexture[unit.id] ?? 'unit-rebel';
const textureBase = unitTexture[unit.id] ?? unitTextureByClass[unit.classKey] ?? 'unit-rebel';
const sprite = this.add.sprite(this.tileCenterX(unit.x), this.tileCenterY(unit.y), textureBase, this.unitFrameIndex('south'));
sprite.setName(`unit-${unit.id}`);
sprite.setDisplaySize(this.layout.tileSize * sizeRatio, this.layout.tileSize * sizeRatio);

View File

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

View File

@@ -2,8 +2,8 @@ import Phaser from 'phaser';
import { soundDirector } from '../audio/SoundDirector';
import { equipmentExpToNext, equipmentSlotLabels, equipmentSlots, getItem, type EquipmentSlot } from '../data/battleItems';
import { getUnitClass } from '../data/battleRules';
import { defaultBattleScenario, secondBattleScenario } from '../data/battles';
import { firstBattleBonds, firstBattleUnits, secondBattleIntroPages, type PortraitKey, type UnitData } from '../data/scenario';
import { defaultBattleScenario, secondBattleScenario, thirdBattleScenario } from '../data/battles';
import { firstBattleBonds, firstBattleUnits, secondBattleIntroPages, thirdBattleIntroPages, type PortraitKey, type UnitData } from '../data/scenario';
import {
applyCampBondExp,
getCampaignState,
@@ -398,17 +398,30 @@ export class CampScene extends Phaser.Scene {
bg.setStrokeStyle(1, palette.gold, 0.4);
const completedDialogues = this.completedCampDialogues().length;
const inventory = this.inventoryLabels().join(', ');
const reward = this.campaign?.latestBattleId === secondBattleScenario.id ? '예상 보상: 다음 장 개방' : '예상 보상: 군자금, 소모품, 의용군 명성';
const reward =
this.campaign?.latestBattleId === thirdBattleScenario.id
? '다음 장: 광종 본전 준비 중'
: this.campaign?.latestBattleId === secondBattleScenario.id
? `예상 보상: ${thirdBattleScenario.title} 개방`
: '예상 보상: 군자금, 소모품, 의용군 명성';
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);
}
private nextSortieBriefing() {
if (this.campaign?.latestBattleId === thirdBattleScenario.id) {
return {
eyebrow: '다음 장 준비',
title: '광종 본전',
description: '광종 구원로는 열렸지만 황건 본대는 아직 건재합니다. 다음 작업에서 광종 본전과 더 큰 전장 목표를 이어 붙일 수 있습니다.'
};
}
if (this.campaign?.latestBattleId === secondBattleScenario.id) {
return {
eyebrow: '다음 이야기',
title: '넓어지는 전장',
description: '황건 잔당 추격을 마친 의용군은 탁현을 넘어 더 큰 난세의 흐름을 마주합니다. 다음 장으로 향하기 전 전투 결과를 정리합니다.'
eyebrow: '다음 전장',
title: thirdBattleScenario.title,
description: '탁현을 넘어 광종으로 향하는 길목에서 황건 전령이 관군의 움직임을 본대로 전하려 합니다. 강가 요새와 좁은 길을 돌파해야 합니다.'
};
}
@@ -471,8 +484,19 @@ export class CampScene extends Phaser.Scene {
}
private startVictoryStory() {
if (this.campaign?.latestBattleId === thirdBattleScenario.id) {
this.hideSortiePrep();
this.showCampNotice('광종 본전은 다음 장으로 이어집니다. 군영에서 성장 상태를 정비하세요.');
return;
}
if (this.campaign?.latestBattleId === secondBattleScenario.id) {
this.scene.start('StoryScene', { pages: secondBattleScenario.victoryPages, nextScene: 'TitleScene' });
markCampaignStep('third-battle');
this.scene.start('StoryScene', {
pages: [...secondBattleScenario.victoryPages, ...thirdBattleIntroPages],
nextScene: 'BattleScene',
nextSceneData: { battleId: thirdBattleScenario.id }
});
return;
}

View File

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

View File

@@ -36,7 +36,16 @@ export type FirstBattleReport = {
createdAt: string;
};
export type CampaignStep = 'new' | 'prologue' | 'first-battle' | 'first-camp' | 'first-victory-story' | 'second-battle' | 'second-camp';
export type CampaignStep =
| 'new'
| 'prologue'
| 'first-battle'
| 'first-camp'
| 'first-victory-story'
| 'second-battle'
| 'second-camp'
| 'third-battle'
| 'third-camp';
export type CampaignUnitProgressSnapshot = {
unitId: string;
@@ -86,6 +95,12 @@ export type CampaignState = {
export const campaignStorageKey = 'heros-web:campaign-state';
export const campaignSaveSlotCount = 3;
const campaignBattleSteps: Record<string, { victory: CampaignStep; retry: CampaignStep }> = {
'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' }
};
export type CampaignSaveSlotSummary = {
slot: number;
exists: boolean;
@@ -191,8 +206,9 @@ export function setFirstBattleReport(report: FirstBattleReport) {
reportClone.completedCampDialogues = completedCampDialogues;
state.firstBattleReport = reportClone;
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';
const stepRule = campaignBattleSteps[battleId] ?? campaignBattleSteps['first-battle-zhuo-commandery'];
const victoryStep: CampaignStep = stepRule.victory;
const retryStep: CampaignStep = stepRule.retry;
state.step = reportClone.outcome === 'victory' ? victoryStep : retryStep;
state.gold = Math.max(0, state.gold - (previousSettlement?.rewardGold ?? 0) + reportClone.rewardGold);
state.roster = reportClone.units.filter((unit) => unit.faction === 'ally').map(cloneUnit);