Add Xiaopei supply road battle

This commit is contained in:
2026-06-23 01:26:45 +09:00
parent 6b863882aa
commit 1c501057e3
11 changed files with 693 additions and 25 deletions

View File

@@ -27,14 +27,15 @@ Build a small complete tactical RPG loop that can grow into a longer Romance of
- Seventh battle Xu Province rescue route against Cao Cao's vanguard, with Tao Qian entrusting Xu Province as the next endpoint
- Sortie preparation selection in camp so Liu Bei is required and optional allied officers can be deployed or benched before battle
- Xu Province camp recruitment for Jian Yong and Mi Zhu, including new strategist/quartermaster roles, bond dialogue, and selectable sortie participation
- Flow verification script from title through the seventh battle victory, sortie selection, and camp save state
- Eighth battle Xiaopei supply-road defense, where the expanded Xu Province roster can be selected into battle and Lu Bu's refuge arc is foreshadowed
- Flow verification script from title through the eighth battle victory, recruit sortie selection, and camp save state
## Next Steps
1. Add the Tao Qian handoff story and first Xu Province governance camp
2. Use the expanded Xu Province roster in the next Lu Bu pressure arc so sortie selection has direct battle consequences
3. Turn story-only endpoint bridges into a more explicit chapter selection/progress view
4. Apply treasure equipment effects to damage, defense, recovery, and command rules
1. Continue into the Lu Bu refuge and Xu Province loss chapter
2. Add a chapter/progress view so long campaign arcs are easier to read between battles
3. Apply treasure equipment effects to damage, defense, recovery, and command rules
4. Add more recruitable officers as the campaign moves toward Cao Cao, Yuan Shao, Liu Biao, and Zhuge Liang
5. Keep expanding scenarios through the long campaign instead of treating the current slice as an ending
## Content Direction

View File

@@ -869,22 +869,67 @@ try {
});
await page.screenshot({ path: 'dist/verification-xuzhou-entrust-story.png', fullPage: true });
for (let i = 0; i < 8; i += 1) {
const returnedToCamp = await page.evaluate(() => {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
return activeScenes.includes('CampScene');
for (let i = 0; i < 10; i += 1) {
const enteredEighthBattle = await page.evaluate(() => {
const state = window.__HEROS_DEBUG__?.battle();
return state?.scene === 'BattleScene' && state?.battleId === 'eighth-battle-xiaopei-supply-road';
});
if (returnedToCamp) {
if (enteredEighthBattle) {
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 === 'eighth-battle-xiaopei-supply-road' && state?.battleOutcome === null && state?.phase === 'idle';
});
await page.screenshot({ path: 'dist/verification-eighth-battle.png', fullPage: true });
const eighthBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
const eighthEnemies = eighthBattleState.units.filter((unit) => unit.faction === 'enemy');
const eighthAllies = eighthBattleState.units.filter((unit) => unit.faction === 'ally');
const eighthEnemyBehaviors = new Set(eighthEnemies.map((unit) => unit.ai));
if (
eighthBattleState.camera?.mapWidth !== 26 ||
eighthBattleState.camera?.mapHeight !== 22 ||
eighthBattleState.victoryConditionLabel !== '고순 격파' ||
eighthEnemies.length < 11 ||
!eighthEnemyBehaviors.has('aggressive') ||
!eighthEnemyBehaviors.has('guard') ||
!eighthEnemyBehaviors.has('hold') ||
eighthAllies.some((unit) => unit.id === 'guan-yu') ||
!eighthAllies.some((unit) => unit.id === 'liu-bei') ||
!eighthAllies.some((unit) => unit.id === 'zhang-fei') ||
!eighthAllies.some((unit) => unit.id === 'jian-yong')
) {
throw new Error(`Expected eighth battle to use expanded map, mixed AI, and selected recruit sortie: ${JSON.stringify(eighthBattleState)}`);
}
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 eighthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
if (
eighthCampState?.campBattleId !== 'eighth-battle-xiaopei-supply-road' ||
eighthCampState.campTitle !== '소패 방위 군영' ||
eighthCampState.availableDialogueIds?.length !== 3 ||
!eighthCampState.availableDialogueIds.every((id) => id.endsWith('xiaopei')) ||
!eighthCampState.campaign?.roster?.some((unit) => unit.id === 'jian-yong') ||
!eighthCampState.campaign?.roster?.some((unit) => unit.id === 'mi-zhu')
) {
throw new Error(`Expected eighth camp to expose Xiaopei dialogue set and preserve recruits: ${JSON.stringify(eighthCampState)}`);
}
await page.evaluate(() => window.__HEROS_GAME__?.scene.start('TitleScene'));
await page.waitForFunction(() => {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
@@ -896,7 +941,7 @@ try {
return activeScenes.includes('CampScene');
});
console.log(`Verified title-to-seventh-battle flow, sortie selection, result states, and debug API at ${targetUrl}`);
console.log(`Verified title-to-eighth-battle flow, recruit sortie selection, result states, and debug API at ${targetUrl}`);
} finally {
await browser?.close();
if (serverProcess && !serverProcess.killed) {

View File

@@ -0,0 +1,96 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2600 2200" width="2600" height="2200">
<defs>
<filter id="grain">
<feTurbulence type="fractalNoise" baseFrequency="0.02" numOctaves="4" seed="821" />
<feColorMatrix type="saturate" values="0.62" />
<feBlend mode="multiply" in2="SourceGraphic" />
</filter>
<pattern id="grass" width="96" height="96" patternUnits="userSpaceOnUse">
<rect width="96" height="96" fill="#61764b" />
<path d="M8 28h28M48 58h30M24 76l20-14M66 18l16 12" stroke="#b7c077" stroke-width="3" opacity="0.24" />
<path d="M12 62l14-24M40 34l13-18M70 80l9-25" stroke="#2f4b2d" stroke-width="2" opacity="0.36" />
<circle cx="20" cy="44" r="3" fill="#7f9258" opacity="0.42" />
<circle cx="68" cy="39" r="4" fill="#3f5f35" opacity="0.32" />
</pattern>
<pattern id="earth" width="104" height="104" patternUnits="userSpaceOnUse">
<rect width="104" height="104" fill="#9b7749" />
<path d="M0 26c38-18 70 12 104-8M0 70c42-18 72 14 104-4" stroke="#c9a069" stroke-width="7" opacity="0.25" />
<path d="M20 86l18-24M62 42l24-16M78 82l14-9" stroke="#57422c" stroke-width="3" opacity="0.34" />
</pattern>
<pattern id="forest" width="126" height="110" patternUnits="userSpaceOnUse">
<rect width="126" height="110" fill="#31482e" />
<circle cx="26" cy="42" r="30" fill="#203823" />
<circle cx="62" cy="32" r="35" fill="#587448" />
<circle cx="96" cy="50" r="31" fill="#294328" />
<circle cx="52" cy="82" r="27" fill="#69844f" />
<path d="M14 62c34 18 74 16 104-8M36 26c22 18 54 16 72-6" stroke="#142615" stroke-width="5" opacity="0.36" />
</pattern>
<pattern id="hill" width="132" height="98" patternUnits="userSpaceOnUse">
<rect width="132" height="98" fill="#77704b" />
<path d="M-10 84c48-62 92-82 134-64c36 15 56 44 74 80" fill="#55543a" opacity="0.72" />
<path d="M18 76c36-40 72-50 108-32" fill="none" stroke="#aa9d66" stroke-width="10" opacity="0.3" />
<path d="M40 36l30 20M82 22l22 22" stroke="#393828" stroke-width="4" opacity="0.34" />
</pattern>
<linearGradient id="river" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#61a4bd" />
<stop offset="0.48" stop-color="#2c6687" />
<stop offset="1" stop-color="#17384f" />
</linearGradient>
<linearGradient id="wall" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#907a5f" />
<stop offset="1" stop-color="#493728" />
</linearGradient>
<linearGradient id="roof" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#d79a41" />
<stop offset="1" stop-color="#7a4d1d" />
</linearGradient>
</defs>
<rect width="2600" height="2200" fill="url(#grass)" />
<g filter="url(#grain)">
<path d="M-80 2020C210 1740 386 1542 604 1322C836 1086 1056 1018 1260 1014C1508 1008 1740 888 2040 708C2268 570 2440 484 2680 438" fill="none" stroke="#a77e4d" stroke-width="272" stroke-linecap="round" opacity="0.96" />
<path d="M-80 2020C210 1740 386 1542 604 1322C836 1086 1056 1018 1260 1014C1508 1008 1740 888 2040 708C2268 570 2440 484 2680 438" fill="none" stroke="url(#earth)" stroke-width="178" stroke-linecap="round" opacity="0.96" />
<path d="M300 2220C350 1830 304 1600 430 1382C560 1158 860 1110 1160 1028C1446 950 1788 764 2180 458" fill="none" stroke="#a77e4d" stroke-width="190" stroke-linecap="round" opacity="0.9" />
<path d="M300 2220C350 1830 304 1600 430 1382C560 1158 860 1110 1160 1028C1446 950 1788 764 2180 458" fill="none" stroke="url(#earth)" stroke-width="114" stroke-linecap="round" opacity="0.86" />
<path d="M1460 -80c-88 250-58 420 72 552c158 160 194 350 106 568c-76 190-52 354 72 492c128 142 170 326 88 594" fill="none" stroke="#173c52" stroke-width="280" stroke-linecap="round" opacity="0.96" />
<path d="M1460 -80c-88 250-58 420 72 552c158 160 194 350 106 568c-76 190-52 354 72 492c128 142 170 326 88 594" fill="none" stroke="url(#river)" stroke-width="184" stroke-linecap="round" opacity="0.98" />
<path d="M1508 146c36 132 48 250-16 384M1638 782c88 108 102 238 34 386M1808 1560c58 108 68 216 24 332" fill="none" stroke="#c0eef5" stroke-width="14" opacity="0.34" />
<path d="M-40 40c260-76 518-42 760 106c40 146-54 282-276 408c-238-22-402-104-492-250Z" fill="url(#forest)" opacity="0.84" />
<path d="M520 320c236-98 474-70 710 78c14 152-88 268-306 346c-246-32-382-170-404-424Z" fill="url(#forest)" opacity="0.82" />
<path d="M18 762c314-118 610-64 872 156c-70 226-330 350-774 366C-16 1118-42 950 18 762Z" fill="url(#forest)" opacity="0.84" />
<path d="M1800 1710c260-96 520-66 760 88v360c-298 76-572 18-820-176c-50-112-30-202 60-272Z" fill="url(#forest)" opacity="0.82" />
<path d="M910 1540c236-84 432-58 590 72c-42 220-190 366-448 438c-148-76-206-246-142-510Z" fill="url(#hill)" opacity="0.84" />
<path d="M2240 760c150-38 286-12 410 82v464c-170 64-322 36-454-82c-58-160-42-314 44-464Z" fill="url(#hill)" opacity="0.86" />
<g transform="translate(2020 230)">
<rect x="-46" y="-26" width="520" height="318" rx="20" fill="#241d16" opacity="0.55" />
<rect x="0" y="0" width="450" height="260" rx="16" fill="url(#wall)" />
<rect x="42" y="50" width="366" height="170" fill="#765f43" stroke="#241d15" stroke-width="12" />
<path d="M42 50h366M42 220h366M42 50v170M408 50v170" stroke="#35291e" stroke-width="10" />
<rect x="82" y="104" width="74" height="84" fill="#98704a" />
<rect x="198" y="78" width="88" height="110" fill="#9e7650" />
<rect x="330" y="104" width="58" height="84" fill="#8c6846" />
<path d="M62 104l58-48l62 48M184 78l60-52l70 52M318 104l42-38l54 38" fill="url(#roof)" stroke="#302316" stroke-width="9" />
<path d="M220 188v-60h46v60" fill="#1f1712" />
</g>
<g transform="translate(760 1220)">
<rect x="0" y="0" width="230" height="130" rx="10" fill="#6d563b" stroke="#2a2119" stroke-width="10" />
<path d="M-16 12L116-74l132 86Z" fill="url(#roof)" stroke="#342313" stroke-width="10" />
<path d="M42 130v-58h46v58M140 130v-70h46v70" fill="#2a2119" opacity="0.8" />
</g>
<g transform="translate(1710 770)">
<rect x="0" y="0" width="220" height="126" rx="10" fill="#6d563b" stroke="#2a2119" stroke-width="10" />
<path d="M-14 14L110-62l128 76Z" fill="url(#roof)" stroke="#342313" stroke-width="10" />
<path d="M78 126v-56h52v56" fill="#2a2119" opacity="0.8" />
</g>
<g transform="translate(60 1630)">
<rect x="0" y="0" width="315" height="230" rx="16" fill="#4f5c53" stroke="#222820" stroke-width="14" />
<path d="M20 56h270M20 116h270M20 176h270M74 20v190M154 20v190M234 20v190" stroke="#263124" stroke-width="9" opacity="0.68" />
<rect x="72" y="-34" width="168" height="76" fill="url(#roof)" stroke="#2c2115" stroke-width="10" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@@ -1,5 +1,9 @@
import {
firstBattleBonds,
eighthBattleBonds,
eighthBattleMap,
eighthBattleUnits,
eighthBattleVictoryPages,
firstBattleMap,
firstBattleUnits,
firstBattleVictoryPages,
@@ -40,7 +44,8 @@ export type BattleScenarioId =
| 'fourth-battle-guangzong-camp'
| 'fifth-battle-sishui-vanguard'
| 'sixth-battle-jieqiao-relief'
| 'seventh-battle-xuzhou-rescue';
| 'seventh-battle-xuzhou-rescue'
| 'eighth-battle-xiaopei-supply-road';
export type BattleObjectiveKind = 'defeat-leader' | 'keep-unit-alive' | 'secure-terrain' | 'quick-victory';
@@ -450,6 +455,59 @@ export const seventhBattleScenario: BattleScenarioDefinition = {
nextCampScene: 'CampScene'
};
export const eighthBattleScenario: BattleScenarioDefinition = {
id: 'eighth-battle-xiaopei-supply-road',
title: '소패 보급로 방위전',
victoryConditionLabel: '고순 격파',
defeatConditionLabel: '유비 퇴각',
openingObjectiveLines: [
'여포군의 척후와 습격병이 소패 보급로를 흔들고 있습니다. 고순을 격파하면 서주의 첫 방위선을 지킬 수 있습니다.',
'기병은 길을 타고 빠르게 파고들고, 궁병은 강 건너와 언덕에서 사거리를 잡습니다. 마을과 창고 지형을 이용해 버티십시오.',
'18턴 이내 승리하면 서주 보급망과 새 합류 무장의 명성이 크게 오릅니다.'
],
map: eighthBattleMap,
units: eighthBattleUnits,
bonds: eighthBattleBonds,
mapTextureKey: 'battle-map-eighth',
leaderUnitId: 'xiaopei-leader-gao-shun',
quickVictoryTurnLimit: 18,
baseVictoryGold: 1080,
objectives: [
{
id: 'leader',
kind: 'defeat-leader',
label: '고순 격파',
rewardGold: 640,
unitId: 'xiaopei-leader-gao-shun'
},
{
id: 'liu-bei',
kind: 'keep-unit-alive',
label: '유비 생존',
rewardGold: 250,
unitId: 'liu-bei'
},
{
id: 'village',
kind: 'secure-terrain',
label: '소패 보급촌 확보',
rewardGold: 360,
terrain: 'village'
},
{
id: 'quick',
kind: 'quick-victory',
label: '18턴 이내 승리',
rewardGold: 300,
maxTurn: 18
}
],
defeatConditions: [{ kind: 'unit-defeated', unitId: 'liu-bei' }],
itemRewards: ['콩 3', '상처약 2', '탁주 1', '서주 보급망 +1'],
victoryPages: eighthBattleVictoryPages,
nextCampScene: 'CampScene'
};
export const defaultBattleScenarioId: BattleScenarioId = firstBattleScenario.id;
export const battleScenarios: Record<BattleScenarioId, BattleScenarioDefinition> = {
@@ -459,7 +517,8 @@ export const battleScenarios: Record<BattleScenarioId, BattleScenarioDefinition>
'fourth-battle-guangzong-camp': fourthBattleScenario,
'fifth-battle-sishui-vanguard': fifthBattleScenario,
'sixth-battle-jieqiao-relief': sixthBattleScenario,
'seventh-battle-xuzhou-rescue': seventhBattleScenario
'seventh-battle-xuzhou-rescue': seventhBattleScenario,
'eighth-battle-xiaopei-supply-road': eighthBattleScenario
};
export const defaultBattleScenario = battleScenarios[defaultBattleScenarioId];

View File

@@ -1,6 +1,7 @@
import type { CampaignStep } from '../state/campaignState';
import {
defaultBattleScenario,
eighthBattleScenario,
fifthBattleScenario,
fourthBattleScenario,
secondBattleScenario,
@@ -10,6 +11,8 @@ import {
type BattleScenarioId
} from './battles';
import {
eighthBattleIntroPages,
eighthBattleVictoryPages,
fifthBattleIntroPages,
fifthBattleVictoryPages,
firstBattleVictoryPages,
@@ -101,12 +104,22 @@ const sortieFlows: Record<string, SortieFlow> = {
},
[seventhBattleScenario.id]: {
afterBattleId: seventhBattleScenario.id,
eyebrow: '다음 전장',
title: eighthBattleScenario.title,
description: '도겸에게 서주를 맡은 뒤 첫 과제는 소패 보급로를 지키는 일입니다. 여포군의 그림자가 드리우기 전에 새로 합류한 장수들과 방위선을 세워야 합니다.',
rewardHint: `예상 보상: ${eighthBattleScenario.title} 개방`,
nextBattleId: eighthBattleScenario.id,
campaignStep: 'eighth-battle',
pages: [...seventhBattleVictoryPages, ...eighthBattleIntroPages]
},
[eighthBattleScenario.id]: {
afterBattleId: eighthBattleScenario.id,
eyebrow: '다음 장 준비',
title: '도겸의 서주',
description: '서주 구원전 뒤 도겸은 유비에게 서주의 장래를 맡아 달라 청합니다. 이제 서주 인수와 여포의 그림자가 다음 줄기를 만듭니다.',
rewardHint: '다음 장: 도겸에게 서주 인수 준비 중',
pages: seventhBattleVictoryPages,
unavailableNotice: '도겸의 서주 인수 흐름은 다음 장으로 이어집니다. 군영에서 성장 상태와 출전 구성을 정비하세요.'
title: '여포의 의탁',
description: '소패 보급로를 지켜냈지만 여포가 몸을 의탁하겠다는 전갈이 도착합니다. 받아들이는 선택은 곧 서주 상실의 갈등으로 이어질 것입니다.',
rewardHint: '다음 장: 여포 의탁과 서주 내부 불안 준비 중',
pages: eighthBattleVictoryPages,
unavailableNotice: '여포 의탁 이후의 서주 상실 흐름은 다음 작업에서 이어집니다. 군영에서 성장 출전 구성을 정비하십시오.'
}
};

View File

@@ -540,6 +540,60 @@ export const seventhBattleVictoryPages: StoryPage[] = [
}
];
export const eighthBattleIntroPages: StoryPage[] = [
{
id: 'eighth-xuzhou-handover',
bgm: 'militia-theme',
chapter: '서주의 새 군영',
background: 'story-militia',
text: '도겸은 병든 몸을 일으켜 서주의 장부와 군량 창고를 유비에게 넘겼다. 간옹과 미축은 백성의 청원과 보급로를 정리하며 새 군영의 기초를 세웠다.'
},
{
id: 'eighth-lu-bu-shadow',
bgm: 'battle-prep',
chapter: '여포의 그림자',
background: 'story-liu-bei',
speaker: '유비',
portrait: 'liuBei',
text: '여포가 떠돌고 있다는 소식은 가볍지 않다. 그 이름 하나만으로도 여러 군세가 흔들린다. 그러나 지금은 서주의 길과 백성을 먼저 지켜야 한다.'
},
{
id: 'eighth-xiaopei-road',
bgm: 'battle-prep',
chapter: '소패 보급로',
background: 'story-sortie',
speaker: '미축',
text: '소패로 이어지는 창고 길목이 습격받고 있습니다. 군량이 끊기면 서주를 안정시킬 수 없습니다. 보급로와 마을을 동시에 지켜야 합니다.'
},
{
id: 'eighth-sortie-choice',
bgm: 'battle-prep',
chapter: '첫 방위전',
background: 'story-three-heroes',
speaker: '간옹',
text: '현덕, 이제 함께 갈 장수를 고르는 일도 전술이 되었네. 칼이 필요한 곳과 말이 필요한 곳, 그리고 말보다 장부가 필요한 곳을 구분해야 하네.'
}
];
export const eighthBattleVictoryPages: StoryPage[] = [
{
id: 'eighth-victory-supply-road',
bgm: 'militia-theme',
chapter: '지켜낸 보급로',
background: 'story-militia',
text: '소패의 보급로가 다시 열리자 서주의 창고와 마을은 겨우 숨을 돌렸다. 새로 합류한 장수들은 각자의 방식으로 유비군의 빈틈을 메웠다.'
},
{
id: 'eighth-lu-bu-arrival',
bgm: 'battle-prep',
chapter: '뜻밖의 손님',
background: 'story-liu-bei',
speaker: '유비',
portrait: 'liuBei',
text: '전투가 끝난 뒤, 여포가 몸을 의탁할 곳을 구한다는 전갈이 왔다. 그를 받아들이는 일은 의로울 수 있으나, 서주에 또 다른 불씨를 들이는 일이기도 했다.'
}
];
export const firstBattleMap: BattleMap = {
width: 20,
height: 18,
@@ -727,6 +781,12 @@ export const seventhBattleMap: BattleMap = {
]
};
export const eighthBattleMap: BattleMap = {
width: 26,
height: 22,
terrain: createEighthBattleTerrain()
};
export const firstBattleUnits: UnitData[] = [
{
id: 'liu-bei',
@@ -2611,6 +2671,251 @@ export const xuzhouRecruitUnits: UnitData[] = [
}
];
const eighthBattleAllyPositions: Record<string, { x: number; y: number }> = {
'liu-bei': { x: 2, y: 17 },
'guan-yu': { x: 3, y: 17 },
'zhang-fei': { x: 2, y: 18 },
'jian-yong': { x: 3, y: 18 },
'mi-zhu': { x: 1, y: 18 }
};
export const eighthBattleUnits: UnitData[] = [
...[...firstBattleUnits.filter((unit) => unit.faction === 'ally'), ...xuzhouRecruitUnits].map((unit) =>
placeScenarioUnit(unit, eighthBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })
),
{
id: 'xiaopei-raider-a',
name: '여포군 척후',
faction: 'enemy',
className: '기동 척후',
classKey: 'cavalry',
level: 8,
exp: 52,
hp: 43,
maxHp: 43,
attack: 15,
move: 5,
stats: { might: 82, intelligence: 50, leadership: 62, agility: 86, luck: 55 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 28 },
armor: { itemId: 'rebel-vest', level: 2, exp: 14 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 17,
y: 9
},
{
id: 'xiaopei-raider-b',
name: '여포군 척후',
faction: 'enemy',
className: '기동 척후',
classKey: 'cavalry',
level: 8,
exp: 54,
hp: 44,
maxHp: 44,
attack: 15,
move: 5,
stats: { might: 83, intelligence: 50, leadership: 62, agility: 86, luck: 55 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 30 },
armor: { itemId: 'rebel-vest', level: 2, exp: 14 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 19,
y: 10
},
{
id: 'xiaopei-infantry-a',
name: '소패 침투병',
faction: 'enemy',
className: '여포군 보병',
classKey: 'yellowTurban',
level: 8,
exp: 50,
hp: 42,
maxHp: 42,
attack: 13,
move: 3,
stats: { might: 78, intelligence: 50, leadership: 66, agility: 61, luck: 53 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 26 },
armor: { itemId: 'lamellar-armor', level: 2, exp: 12 },
accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
},
x: 11,
y: 12
},
{
id: 'xiaopei-infantry-b',
name: '소패 침투병',
faction: 'enemy',
className: '여포군 보병',
classKey: 'yellowTurban',
level: 8,
exp: 52,
hp: 43,
maxHp: 43,
attack: 13,
move: 3,
stats: { might: 79, intelligence: 50, leadership: 67, agility: 62, luck: 53 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 28 },
armor: { itemId: 'lamellar-armor', level: 2, exp: 12 },
accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
},
x: 12,
y: 14
},
{
id: 'xiaopei-archer-a',
name: '소패 궁병',
faction: 'enemy',
className: '여포군 궁병',
classKey: 'archer',
level: 8,
exp: 52,
hp: 34,
maxHp: 34,
attack: 14,
move: 3,
stats: { might: 70, intelligence: 62, leadership: 60, agility: 71, luck: 54 },
equipment: {
weapon: { itemId: 'short-bow', level: 2, exp: 28 },
armor: { itemId: 'cloth-armor', level: 2, exp: 12 },
accessory: { itemId: 'wind-quiver', level: 1, exp: 0 }
},
x: 18,
y: 7
},
{
id: 'xiaopei-archer-b',
name: '소패 궁병',
faction: 'enemy',
className: '여포군 궁병',
classKey: 'archer',
level: 9,
exp: 54,
hp: 35,
maxHp: 35,
attack: 15,
move: 3,
stats: { might: 72, intelligence: 63, leadership: 61, agility: 72, luck: 55 },
equipment: {
weapon: { itemId: 'short-bow', level: 2, exp: 30 },
armor: { itemId: 'cloth-armor', level: 2, exp: 12 },
accessory: { itemId: 'wind-quiver', level: 1, exp: 0 }
},
x: 21,
y: 14
},
{
id: 'xiaopei-guard-a',
name: '창고 습격병',
faction: 'enemy',
className: '여포군 보병',
classKey: 'bandit',
level: 8,
exp: 50,
hp: 38,
maxHp: 38,
attack: 14,
move: 4,
stats: { might: 79, intelligence: 48, leadership: 58, agility: 72, luck: 52 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 26 },
armor: { itemId: 'rebel-vest', level: 2, exp: 12 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 8,
y: 11
},
{
id: 'xiaopei-guard-b',
name: '창고 습격병',
faction: 'enemy',
className: '여포군 보병',
classKey: 'bandit',
level: 8,
exp: 52,
hp: 39,
maxHp: 39,
attack: 14,
move: 4,
stats: { might: 80, intelligence: 48, leadership: 58, agility: 73, luck: 52 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 28 },
armor: { itemId: 'rebel-vest', level: 2, exp: 12 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 9,
y: 14
},
{
id: 'xiaopei-cavalry-a',
name: '여포군 기병',
faction: 'enemy',
className: '여포군 기병',
classKey: 'cavalry',
level: 9,
exp: 56,
hp: 46,
maxHp: 46,
attack: 16,
move: 5,
stats: { might: 86, intelligence: 50, leadership: 66, agility: 88, luck: 56 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 32 },
armor: { itemId: 'rebel-vest', level: 2, exp: 16 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 23,
y: 6
},
{
id: 'xiaopei-cavalry-b',
name: '여포군 기병',
faction: 'enemy',
className: '여포군 기병',
classKey: 'cavalry',
level: 9,
exp: 58,
hp: 47,
maxHp: 47,
attack: 16,
move: 5,
stats: { might: 87, intelligence: 50, leadership: 66, agility: 89, luck: 56 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 34 },
armor: { itemId: 'rebel-vest', level: 2, exp: 16 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 24,
y: 15
},
{
id: 'xiaopei-leader-gao-shun',
name: '고순',
faction: 'enemy',
className: '여포군 선봉장',
classKey: 'rebelLeader',
level: 10,
exp: 62,
hp: 62,
maxHp: 62,
attack: 17,
move: 4,
stats: { might: 88, intelligence: 66, leadership: 86, agility: 68, luck: 58 },
equipment: {
weapon: { itemId: 'leader-axe', level: 2, exp: 34 },
armor: { itemId: 'lamellar-armor', level: 2, exp: 18 },
accessory: { itemId: 'war-manual', level: 1, exp: 0 }
},
x: 22,
y: 3
}
];
export const firstBattleBonds: BattleBond[] = [
{
id: 'liu-bei__guan-yu',
@@ -2663,6 +2968,39 @@ export const fourthBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleB
export const fifthBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario);
export const sixthBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario);
export const seventhBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario);
export const eighthBattleBonds: BattleBond[] = [...firstBattleBonds, ...xuzhouRecruitBonds].map(cloneBattleBondForScenario);
function createEighthBattleTerrain(): TerrainType[][] {
return Array.from({ length: 22 }, (_, y) =>
Array.from({ length: 26 }, (_, x): TerrainType => {
if (x <= 1 && y >= 16 && y <= 20) {
return 'camp';
}
if ((x === 2 && y >= 15 && y <= 20) || (x === 3 && y >= 18 && y <= 20)) {
return 'camp';
}
if ((x >= 20 && x <= 23 && y >= 2 && y <= 5) || (x >= 21 && x <= 24 && y >= 13 && y <= 16)) {
return 'fort';
}
if ((x >= 17 && x <= 18 && y >= 7 && y <= 9) || (x >= 8 && x <= 9 && y >= 12 && y <= 14)) {
return 'village';
}
if ((x === 14 || x === 15) && y <= 18 && y >= 0) {
return 'river';
}
if (x === y - 12 || x === y - 13 || (y >= 7 && y <= 10 && x >= 2 && x <= 18) || (x >= 4 && x <= 22 && y === 10)) {
return 'road';
}
if ((x <= 6 && y <= 4) || (x >= 5 && x <= 10 && y >= 3 && y <= 7) || (x >= 2 && x <= 6 && y >= 8 && y <= 12) || (x >= 18 && x <= 25 && y >= 17)) {
return 'forest';
}
if ((x >= 18 && y <= 2) || (x >= 22 && y >= 7 && y <= 12) || (x >= 10 && x <= 13 && y >= 15 && y <= 20) || (x <= 4 && y >= 4 && y <= 7)) {
return 'hill';
}
return 'plain';
})
);
}
function placeScenarioUnit(unit: UnitData, position: { x: number; y: number }): UnitData {
return {

View File

@@ -524,7 +524,18 @@ const enemyAiByUnitId: Record<string, EnemyAiBehavior> = {
'xuzhou-cavalry-c': 'aggressive',
'xuzhou-guard-b': 'guard',
'xuzhou-archer-c': 'hold',
'xuzhou-leader-xiahou-dun': 'guard'
'xuzhou-leader-xiahou-dun': 'guard',
'xiaopei-raider-a': 'aggressive',
'xiaopei-raider-b': 'aggressive',
'xiaopei-infantry-a': 'guard',
'xiaopei-infantry-b': 'guard',
'xiaopei-archer-a': 'hold',
'xiaopei-archer-b': 'hold',
'xiaopei-guard-a': 'aggressive',
'xiaopei-guard-b': 'aggressive',
'xiaopei-cavalry-a': 'aggressive',
'xiaopei-cavalry-b': 'aggressive',
'xiaopei-leader-gao-shun': 'guard'
};
const defaultEnemyAiByClass: Partial<Record<UnitClassKey, EnemyAiBehavior>> = {

View File

@@ -1,4 +1,5 @@
import Phaser from 'phaser';
import eighthBattleMapUrl from '../../assets/images/battle/eighth-battle-map.svg';
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';
@@ -72,6 +73,7 @@ export class BootScene extends Phaser.Scene {
this.load.image('battle-map-fifth', fifthBattleMapUrl);
this.load.image('battle-map-sixth', sixthBattleMapUrl);
this.load.image('battle-map-seventh', seventhBattleMapUrl);
this.load.image('battle-map-eighth', eighthBattleMapUrl);
this.load.image('portrait-liu-bei', liuBeiPortraitUrl);
this.load.image('portrait-guan-yu', guanYuPortraitUrl);
this.load.image('portrait-zhang-fei', zhangFeiPortraitUrl);

View File

@@ -130,7 +130,8 @@ const campBattleIds = {
fourth: 'fourth-battle-guangzong-camp',
fifth: 'fifth-battle-sishui-vanguard',
sixth: 'sixth-battle-jieqiao-relief',
seventh: 'seventh-battle-xuzhou-rescue'
seventh: 'seventh-battle-xuzhou-rescue',
eighth: 'eighth-battle-xiaopei-supply-road'
} as const;
const requiredSortieUnitIds = new Set(['liu-bei']);
@@ -757,6 +758,87 @@ const campDialogues: CampDialogue[] = [
rewardExp: 7
}
]
},
{
id: 'liu-mi-zhu-after-xiaopei',
title: '지켜낸 보급로',
availableAfterBattleIds: [campBattleIds.eighth],
unitIds: ['liu-bei', 'mi-zhu'],
bondId: 'liu-bei__mi-zhu',
rewardExp: 20,
lines: [
'미축: 소패 보급로는 지켰지만, 서주는 아직 안정되었다고 보기 어렵습니다. 창고를 지켜도 사람의 마음이 흩어지면 다시 흔들립니다.',
'유비: 군량은 칼보다 조용하지만, 백성을 살리는 힘이 있소. 그 일을 계속 맡아 주시오.',
'미축: 맡겨 주신다면 상단과 호족을 설득해 서주의 숨통을 조금 더 넓히겠습니다.'
],
choices: [
{
id: 'trust-supply-network',
label: '보급망을 믿고 맡긴다',
response: '미축은 유비가 실무를 믿고 맡기자 서주의 창고와 상단을 더 단단히 묶겠다고 답했다.',
rewardExp: 8
},
{
id: 'share-grain-openly',
label: '군량 배분을 투명하게 한다',
response: '유비가 백성 앞에서 군량 배분을 숨기지 않겠다고 하자 미축의 표정이 한결 밝아졌다.',
rewardExp: 9
}
]
},
{
id: 'liu-jian-yong-after-xiaopei',
title: '여포를 맞을 것인가',
availableAfterBattleIds: [campBattleIds.eighth],
unitIds: ['liu-bei', 'jian-yong'],
bondId: 'liu-bei__jian-yong',
rewardExp: 20,
lines: [
'간옹: 여포를 받아들이면 의롭다는 말은 들을 걸세. 하지만 사람들은 그 뒤에 칼집이 몇 개인지도 세어 볼 거야.',
'유비: 곤궁한 이를 외면할 수는 없소. 그러나 서주 백성의 근심도 가볍게 여길 수 없소.',
'간옹: 그래서 선택에는 늘 값을 치르는 법이지. 적어도 그 값을 알고 받아들이게.'
],
choices: [
{
id: 'accept-with-guard',
label: '경계를 세우고 맞는다',
response: '간옹은 유비가 의와 경계를 함께 세우겠다고 하자, 그나마 현실적인 대답이라고 웃었다.',
rewardExp: 9
},
{
id: 'ask-people-first',
label: '백성의 불안을 먼저 살핀다',
response: '간옹은 서주 사람들의 두려움을 먼저 살피겠다는 말에 고개를 끄덕였다.',
rewardExp: 8
}
]
},
{
id: 'guan-zhang-after-xiaopei',
title: '성문을 지키는 두 장수',
availableAfterBattleIds: [campBattleIds.eighth],
unitIds: ['guan-yu', 'zhang-fei'],
bondId: 'guan-yu__zhang-fei',
rewardExp: 20,
lines: [
'관우: 여포가 온다면 군율이 먼저 흔들릴 것이다. 성문과 술자리를 모두 조심해야 한다.',
'장비: 왜 나를 보며 말하시오? 하지만 알겠소. 이번에는 먼저 소리치기보다 성문부터 붙잡겠소.',
'관우: 형님의 뜻을 지키려면 우리부터 흔들리지 않아야 한다.'
],
choices: [
{
id: 'guard-gates',
label: '성문 경계를 강화한다',
response: '관우와 장비는 성문 근무와 순찰을 나누어 맡기로 했다.',
rewardExp: 8
},
{
id: 'restrain-temper',
label: '장비의 성급함을 다잡는다',
response: '장비는 툴툴대면서도 이번만큼은 술보다 군율을 먼저 보겠다고 약속했다.',
rewardExp: 9
}
]
}
];
@@ -834,7 +916,8 @@ export class CampScene extends Phaser.Scene {
}
private ensureCurrentCampRecruitment() {
if (this.currentCampBattleId() !== campBattleIds.seventh || !this.campaign) {
const battleId = this.currentCampBattleId();
if ((battleId !== campBattleIds.seventh && battleId !== campBattleIds.eighth) || !this.campaign) {
return;
}
@@ -854,6 +937,9 @@ export class CampScene extends Phaser.Scene {
private currentCampTitle() {
const battleId = this.currentCampBattleId();
if (battleId === campBattleIds.eighth) {
return '소패 방위 군영';
}
if (battleId === campBattleIds.seventh) {
return '서주 인수 준비 군영';
}

View File

@@ -1,7 +1,15 @@
import Phaser from 'phaser';
import { soundDirector } from '../audio/SoundDirector';
import { firstBattleVictoryPages } from '../data/scenario';
import { fifthBattleScenario, fourthBattleScenario, secondBattleScenario, seventhBattleScenario, sixthBattleScenario, thirdBattleScenario } from '../data/battles';
import {
eighthBattleScenario,
fifthBattleScenario,
fourthBattleScenario,
secondBattleScenario,
seventhBattleScenario,
sixthBattleScenario,
thirdBattleScenario
} from '../data/battles';
import { hasCampaignSave, loadCampaignState, startNewCampaign } from '../state/campaignState';
import { palette } from '../ui/palette';
@@ -306,7 +314,8 @@ export class TitleScene extends Phaser.Scene {
campaign.step === 'fourth-camp' ||
campaign.step === 'fifth-camp' ||
campaign.step === 'sixth-camp' ||
campaign.step === 'seventh-camp'
campaign.step === 'seventh-camp' ||
campaign.step === 'eighth-camp'
) {
this.scene.start('CampScene');
return;
@@ -347,6 +356,11 @@ export class TitleScene extends Phaser.Scene {
return;
}
if (campaign.step === 'eighth-battle') {
this.scene.start('BattleScene', { battleId: eighthBattleScenario.id });
return;
}
if (campaign.step === 'first-victory-story') {
this.scene.start('StoryScene', { pages: firstBattleVictoryPages, nextScene: 'TitleScene' });
return;

View File

@@ -53,7 +53,9 @@ export type CampaignStep =
| 'sixth-battle'
| 'sixth-camp'
| 'seventh-battle'
| 'seventh-camp';
| 'seventh-camp'
| 'eighth-battle'
| 'eighth-camp';
export type CampaignUnitProgressSnapshot = {
unitId: string;
@@ -111,7 +113,8 @@ const campaignBattleSteps: Record<string, { victory: CampaignStep; retry: Campai
'fourth-battle-guangzong-camp': { victory: 'fourth-camp', retry: 'fourth-battle' },
'fifth-battle-sishui-vanguard': { victory: 'fifth-camp', retry: 'fifth-battle' },
'sixth-battle-jieqiao-relief': { victory: 'sixth-camp', retry: 'sixth-battle' },
'seventh-battle-xuzhou-rescue': { victory: 'seventh-camp', retry: 'seventh-battle' }
'seventh-battle-xuzhou-rescue': { victory: 'seventh-camp', retry: 'seventh-battle' },
'eighth-battle-xiaopei-supply-road': { victory: 'eighth-camp', retry: 'eighth-battle' }
};
export type CampaignSaveSlotSummary = {