Add campaign progress timeline
This commit is contained in:
@@ -39,13 +39,14 @@ Build a small complete tactical RPG loop that can grow into a longer Romance of
|
||||
- Liu Biao camp visit events with one-time local choices, persisted completion, bond/gold/item rewards, and Wolong clue preparation before Zhuge Liang's recruitment arc
|
||||
- Seventeenth battle Wolong visit road, gated by Wolong clue visits, opening the Longzhong approach and recruiting Zhuge Liang into the roster after victory
|
||||
- Post-Wolong camp dialogue and visit events, including Zhuge Liang bond choices and Red Cliffs preparation clues
|
||||
- Camp progress timeline tab that summarizes Liu Bei's long campaign arc, completed battles, current chapter, latest battle, and next major chapter
|
||||
- Tactical sortie preparation panel with battle-specific sortie limits, recommended officers with reasons, class role, named equipment, core stats, bond partner, next-map terrain suitability, deployment preview, formation roles, active bond count, recruited-officer count, reserve roster summary, and readiness advice for each deployable officer
|
||||
- Flow verification script from title through the seventeenth battle victory, recruit sortie selection, Liu Biao visit rewards, Zhuge Liang recruitment, and camp save state
|
||||
- Flow verification script from title through the seventeenth battle victory, recruit sortie selection, Liu Biao visit rewards, Zhuge Liang recruitment, campaign timeline state, and camp save state
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Add a chapter/progress view so long campaign arcs are easier to read between battles
|
||||
2. Start the Red Cliffs preparation chapter with Zhuge Liang's strategic role visible in sortie and camp choices
|
||||
1. Start the Red Cliffs preparation chapter with Zhuge Liang's strategic role visible in sortie and camp choices
|
||||
2. Add a richer officer collection and reserve growth view so benched recruitable officers still feel valuable
|
||||
3. Apply treasure equipment effects to damage, defense, recovery, and command rules
|
||||
4. Add more recruitable officers as the campaign moves toward Red Cliffs, Jing Province, Yi Province, and Shu Han
|
||||
5. Keep expanding scenarios through the long campaign instead of treating the current slice as an ending
|
||||
|
||||
@@ -294,7 +294,7 @@ try {
|
||||
});
|
||||
campScene.render();
|
||||
});
|
||||
await page.mouse.click(956, 38);
|
||||
await page.mouse.click(888, 38);
|
||||
await page.waitForTimeout(160);
|
||||
await page.screenshot({ path: 'dist/verification-camp-supplies.png', fullPage: true });
|
||||
await page.mouse.click(1120, 245);
|
||||
@@ -321,7 +321,7 @@ try {
|
||||
throw new Error(`Expected merchant purchase to add bean and spend gold: ${JSON.stringify({ goldBeforeMerchant, campStateAfterMerchant })}`);
|
||||
}
|
||||
|
||||
await page.mouse.click(784, 38);
|
||||
await page.mouse.click(732, 38);
|
||||
await page.waitForTimeout(120);
|
||||
await page.mouse.click(974, 482);
|
||||
await page.waitForTimeout(260);
|
||||
@@ -1613,7 +1613,7 @@ try {
|
||||
throw new Error(`Expected sixteenth camp to expose Liu Biao refuge dialogue/visit sets and preserve Zhao Yun in roster: ${JSON.stringify(sixteenthCampState)}`);
|
||||
}
|
||||
|
||||
await page.mouse.click(870, 38);
|
||||
await page.mouse.click(810, 38);
|
||||
await page.waitForTimeout(160);
|
||||
const sixteenthVisitTabState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
||||
if (
|
||||
@@ -1730,6 +1730,20 @@ try {
|
||||
}
|
||||
await page.screenshot({ path: 'dist/verification-zhuge-recruit-camp.png', fullPage: true });
|
||||
|
||||
await page.mouse.click(966, 38);
|
||||
await page.waitForTimeout(180);
|
||||
const progressTabState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
||||
if (
|
||||
progressTabState?.activeTab !== 'progress' ||
|
||||
progressTabState.campaignProgress?.completedKnown !== progressTabState.campaignProgress?.totalKnown ||
|
||||
progressTabState.campaignProgress?.activeChapter?.title !== '적벽대전' ||
|
||||
progressTabState.campaignProgress?.latestBattleTitle !== '융중 방문로' ||
|
||||
progressTabState.campaignProgress?.nextBattleTitle !== '준비 중'
|
||||
) {
|
||||
throw new Error(`Expected progress tab to summarize completed known campaign and point toward Red Cliffs: ${JSON.stringify(progressTabState?.campaignProgress)}`);
|
||||
}
|
||||
await page.screenshot({ path: 'dist/verification-campaign-progress.png', fullPage: true });
|
||||
|
||||
await page.evaluate(() => window.__HEROS_GAME__?.scene.start('TitleScene'));
|
||||
await page.waitForFunction(() => {
|
||||
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
} from '../state/campaignState';
|
||||
import { palette } from '../ui/palette';
|
||||
|
||||
type CampTab = 'status' | 'dialogue' | 'visit' | 'supplies';
|
||||
type CampTab = 'status' | 'dialogue' | 'visit' | 'supplies' | 'progress';
|
||||
|
||||
type CampDialogue = {
|
||||
id: string;
|
||||
@@ -145,6 +145,15 @@ type SortiePlanSummary = {
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
type CampaignTimelineChapter = {
|
||||
id: string;
|
||||
title: string;
|
||||
period: string;
|
||||
description: string;
|
||||
battleIds: BattleScenarioId[];
|
||||
nextHints: string[];
|
||||
};
|
||||
|
||||
const portraitKeys: Record<PortraitKey, string> = {
|
||||
liuBei: 'portrait-liu-bei',
|
||||
guanYu: 'portrait-guan-yu',
|
||||
@@ -204,6 +213,95 @@ const merchantItems: MerchantItemDefinition[] = [
|
||||
}
|
||||
];
|
||||
|
||||
const campaignTimelineChapters: CampaignTimelineChapter[] = [
|
||||
{
|
||||
id: 'yellow-turban',
|
||||
title: '황건적 토벌',
|
||||
period: '의용군의 시작',
|
||||
description: '탁현에서 뜻을 세운 세 형제가 황건적을 토벌하며 이름과 명분을 얻는 초반 장입니다.',
|
||||
battleIds: [
|
||||
'first-battle-zhuo-commandery',
|
||||
'second-battle-yellow-turban-pursuit',
|
||||
'third-battle-guangzong-road',
|
||||
'fourth-battle-guangzong-camp'
|
||||
],
|
||||
nextHints: ['의용군 명성', '세 형제 공명', '황건 토벌 공적']
|
||||
},
|
||||
{
|
||||
id: 'anti-dong-gongsun',
|
||||
title: '반동탁과 공손찬 의탁',
|
||||
period: '난세의 큰 흐름',
|
||||
description: '반동탁 연합의 바깥에서 공손찬 진영의 신뢰를 얻고, 서주 원군 요청으로 이어지는 장입니다.',
|
||||
battleIds: ['fifth-battle-sishui-vanguard', 'sixth-battle-jieqiao-relief'],
|
||||
nextHints: ['공손찬 신뢰', '북방 전선', '서주 원군 요청']
|
||||
},
|
||||
{
|
||||
id: 'xuzhou-lubu',
|
||||
title: '서주 인수와 여포의 그림자',
|
||||
period: '얻고 잃는 땅',
|
||||
description: '도겸에게 서주를 맡고, 여포를 의탁시킨 뒤 내부 불안과 배신으로 서주를 잃는 장입니다.',
|
||||
battleIds: [
|
||||
'seventh-battle-xuzhou-rescue',
|
||||
'eighth-battle-xiaopei-supply-road',
|
||||
'ninth-battle-xuzhou-gate-night-raid',
|
||||
'tenth-battle-xuzhou-breakout'
|
||||
],
|
||||
nextHints: ['간옹·미축 합류', '서주 민심', '여포와의 긴장']
|
||||
},
|
||||
{
|
||||
id: 'cao-refuge-xiapi',
|
||||
title: '조조 의탁과 하비 결전',
|
||||
period: '의탁 속의 경계',
|
||||
description: '조조에게 몸을 의탁하지만 독자 명분을 잃지 않으며, 하비에서 여포의 운명을 마무리하는 장입니다.',
|
||||
battleIds: [
|
||||
'eleventh-battle-xudu-refuge-road',
|
||||
'twelfth-battle-xiapi-outer-siege',
|
||||
'thirteenth-battle-xiapi-final'
|
||||
],
|
||||
nextHints: ['조조군 감시', '하비 포위', '여포 격파']
|
||||
},
|
||||
{
|
||||
id: 'break-cao-yuan',
|
||||
title: '조조 이탈과 원소 의탁',
|
||||
period: '다시 북쪽으로',
|
||||
description: '조조의 장막을 벗어나 원소에게 향하지만, 객군의 한계와 다음 의탁의 필요를 확인하는 장입니다.',
|
||||
battleIds: ['fourteenth-battle-cao-break', 'fifteenth-battle-yuan-refuge-road'],
|
||||
nextHints: ['손건 합류', '원소 접선', '객군의 불안']
|
||||
},
|
||||
{
|
||||
id: 'liu-biao-wolong',
|
||||
title: '유표 의탁과 제갈량 영입',
|
||||
period: '와룡을 찾아',
|
||||
description: '형주에 머물며 조운이 재합류하고, 와룡의 단서를 좇아 제갈량을 얻는 전환점입니다.',
|
||||
battleIds: ['sixteenth-battle-liu-biao-refuge', 'seventeenth-battle-wolong-visit-road'],
|
||||
nextHints: ['조운 재합류', '와룡 단서', '천하삼분']
|
||||
},
|
||||
{
|
||||
id: 'red-cliffs',
|
||||
title: '적벽대전',
|
||||
period: '강동의 바람',
|
||||
description: '제갈량의 계책과 강동의 힘이 맞물려 조조의 대군을 막아서는 큰 전환 장입니다.',
|
||||
battleIds: [],
|
||||
nextHints: ['강동 사절', '화공 준비', '조조 남하']
|
||||
},
|
||||
{
|
||||
id: 'jing-yi',
|
||||
title: '형주·익주 확보',
|
||||
period: '근본을 세우다',
|
||||
description: '형주와 익주를 기반으로 삼아 유비군이 유랑군에서 국가의 뿌리로 바뀌는 장입니다.',
|
||||
battleIds: [],
|
||||
nextHints: ['형주 방위', '익주 진입', '장수 대거 합류']
|
||||
},
|
||||
{
|
||||
id: 'shu-han',
|
||||
title: '촉한 건국',
|
||||
period: '한실을 잇는 깃발',
|
||||
description: '여러 전장을 지나 촉한을 세우고 북벌을 바라보는 장기 목표입니다.',
|
||||
battleIds: [],
|
||||
nextHints: ['왕업 선언', '오호대장군', '북벌 준비']
|
||||
}
|
||||
];
|
||||
|
||||
const campBattleIds = {
|
||||
first: defaultBattleScenario.id,
|
||||
second: 'second-battle-yellow-turban-pursuit',
|
||||
@@ -2203,10 +2301,11 @@ export class CampScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private renderStaticButtons() {
|
||||
this.addTabButton('장수', 'status', 698, 38);
|
||||
this.addTabButton('대화', 'dialogue', 784, 38);
|
||||
this.addTabButton('방문', 'visit', 870, 38);
|
||||
this.addTabButton('정비', 'supplies', 956, 38);
|
||||
this.addTabButton('장수', 'status', 654, 38);
|
||||
this.addTabButton('대화', 'dialogue', 732, 38);
|
||||
this.addTabButton('방문', 'visit', 810, 38);
|
||||
this.addTabButton('정비', 'supplies', 888, 38);
|
||||
this.addTabButton('연표', 'progress', 966, 38);
|
||||
this.addCommandButton('저장', 1044, 38, 76, () => {
|
||||
soundDirector.playSelect();
|
||||
this.campaign = saveCampaignState(getCampaignState());
|
||||
@@ -2296,6 +2395,11 @@ export class CampScene extends Phaser.Scene {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.activeTab === 'progress') {
|
||||
this.renderProgressPanel();
|
||||
return;
|
||||
}
|
||||
|
||||
this.renderStatusPanel();
|
||||
}
|
||||
|
||||
@@ -2972,6 +3076,175 @@ export class CampScene extends Phaser.Scene {
|
||||
);
|
||||
}
|
||||
|
||||
private renderProgressPanel() {
|
||||
const x = 394;
|
||||
const y = 120;
|
||||
const width = 828;
|
||||
const height = 444;
|
||||
const progress = this.campaignTimelineProgress();
|
||||
const bg = this.track(this.add.rectangle(x, y, width, height, 0x101820, 0.9));
|
||||
bg.setOrigin(0);
|
||||
bg.setStrokeStyle(1, palette.gold, 0.56);
|
||||
|
||||
this.track(this.add.text(x + 24, y + 22, '군영 연표', this.textStyle(24, '#f2e3bf', true)));
|
||||
this.track(this.add.text(x + 24, y + 56, '유비군의 장기 서사와 현재 위치를 확인합니다.', this.textStyle(14, '#d4dce6')));
|
||||
|
||||
const statY = y + 82;
|
||||
this.renderProgressStatCard('완료 전투', `${progress.completedKnown}/${progress.totalKnown}`, x + 24, statY, 176);
|
||||
this.renderProgressStatCard('현재 장', progress.activeChapter.title, x + 212, statY, 198);
|
||||
this.renderProgressStatCard('최근 전투', progress.latestBattleTitle, x + 422, statY, 176);
|
||||
this.renderProgressStatCard('다음 전장', progress.nextBattleTitle, x + 610, statY, 190);
|
||||
|
||||
const completedIds = this.completedBattleIds();
|
||||
const listX = x + 24;
|
||||
const listY = y + 142;
|
||||
this.track(this.add.text(listX, listY, '큰 흐름', this.textStyle(17, '#f2e3bf', true)));
|
||||
campaignTimelineChapters.forEach((chapter, index) => {
|
||||
const status = this.timelineChapterStatus(chapter, index, progress.activeChapterIndex, completedIds);
|
||||
const rowY = listY + 30 + index * 31;
|
||||
const active = index === progress.activeChapterIndex;
|
||||
const complete = status === '완료';
|
||||
const row = this.track(this.add.rectangle(listX, rowY, 360, 27, active ? 0x25384a : complete ? 0x17231d : 0x151f2a, active ? 0.96 : 0.84));
|
||||
row.setOrigin(0);
|
||||
row.setStrokeStyle(1, active ? palette.gold : complete ? palette.green : palette.blue, active ? 0.7 : 0.34);
|
||||
const markerColor = active ? '#fff2b8' : complete ? '#a8ffd0' : '#9fb0bf';
|
||||
this.track(this.add.text(listX + 12, rowY + 5, status, this.textStyle(11, markerColor, true)));
|
||||
this.track(this.add.text(listX + 64, rowY + 4, chapter.title, this.textStyle(13, active ? '#f2e3bf' : '#d4dce6', active)));
|
||||
const countText = chapter.battleIds.length > 0
|
||||
? `${chapter.battleIds.filter((id) => completedIds.has(id)).length}/${chapter.battleIds.length}`
|
||||
: '예정';
|
||||
const count = this.track(this.add.text(listX + 342, rowY + 5, countText, this.textStyle(11, markerColor, true)));
|
||||
count.setOrigin(1, 0);
|
||||
});
|
||||
|
||||
this.renderProgressChapterDetail(progress.activeChapter, progress.activeChapterIndex, x + 410, listY, 390, 310, completedIds);
|
||||
}
|
||||
|
||||
private renderProgressStatCard(label: string, value: string, x: number, y: number, width: number) {
|
||||
const bg = this.track(this.add.rectangle(x, y, width, 44, 0x0d141c, 0.92));
|
||||
bg.setOrigin(0);
|
||||
bg.setStrokeStyle(1, palette.blue, 0.4);
|
||||
this.track(this.add.text(x + 12, y + 8, label, this.textStyle(11, '#9fb0bf', true)));
|
||||
const valueText = this.track(this.add.text(x + width - 12, y + 21, this.compactText(value, width > 190 ? 14 : 11), this.textStyle(14, '#f2e3bf', true)));
|
||||
valueText.setOrigin(1, 0);
|
||||
}
|
||||
|
||||
private renderProgressChapterDetail(
|
||||
chapter: CampaignTimelineChapter,
|
||||
chapterIndex: number,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
completedIds: Set<string>
|
||||
) {
|
||||
const bg = this.track(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92));
|
||||
bg.setOrigin(0);
|
||||
bg.setStrokeStyle(1, palette.gold, 0.48);
|
||||
const status = this.timelineChapterStatus(chapter, chapterIndex, this.campaignTimelineProgress().activeChapterIndex, completedIds);
|
||||
this.track(this.add.text(x + 18, y + 16, chapter.title, this.textStyle(21, '#f2e3bf', true)));
|
||||
const statusText = this.track(this.add.text(x + width - 18, y + 19, status, this.textStyle(13, this.timelineStatusColor(status), true)));
|
||||
statusText.setOrigin(1, 0);
|
||||
this.track(this.add.text(x + 18, y + 48, chapter.period, this.textStyle(13, '#d8b15f', true)));
|
||||
this.track(
|
||||
this.add.text(x + 18, y + 74, chapter.description, {
|
||||
...this.textStyle(13, '#d4dce6'),
|
||||
wordWrap: { width: width - 36, useAdvancedWrap: true },
|
||||
lineSpacing: 2
|
||||
})
|
||||
);
|
||||
|
||||
const completedCount = chapter.battleIds.filter((id) => completedIds.has(id)).length;
|
||||
const total = chapter.battleIds.length;
|
||||
const ratio = total > 0 ? completedCount / total : status === '다음' ? 0.08 : 0;
|
||||
this.track(this.add.text(x + 18, y + 136, total > 0 ? `전투 진행 ${completedCount}/${total}` : '아직 전투가 편성되지 않은 장입니다.', this.textStyle(13, '#f2e3bf', true)));
|
||||
this.drawBar(x + 18, y + 162, width - 36, 8, ratio, status === '완료' ? palette.green : palette.gold);
|
||||
|
||||
if (chapter.battleIds.length > 0) {
|
||||
chapter.battleIds.forEach((battleId, index) => {
|
||||
const battle = getBattleScenario(battleId);
|
||||
const complete = completedIds.has(battleId);
|
||||
const rowY = y + 190 + index * 28;
|
||||
const row = this.track(this.add.rectangle(x + 18, rowY, width - 36, 24, complete ? 0x17231d : 0x151f2a, 0.86));
|
||||
row.setOrigin(0);
|
||||
row.setStrokeStyle(1, complete ? palette.green : palette.blue, complete ? 0.48 : 0.32);
|
||||
this.track(this.add.text(x + 30, rowY + 4, complete ? '완료' : '대기', this.textStyle(10, complete ? '#a8ffd0' : '#9fb0bf', true)));
|
||||
this.track(this.add.text(x + 78, rowY + 3, battle.title, this.textStyle(12, complete ? '#d4dce6' : '#f2e3bf', !complete)));
|
||||
const objective = this.track(this.add.text(x + width - 30, rowY + 4, battle.victoryConditionLabel, this.textStyle(10, '#9fb0bf', true)));
|
||||
objective.setOrigin(1, 0);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
chapter.nextHints.forEach((hint, index) => {
|
||||
const rowY = y + 190 + index * 30;
|
||||
const dot = this.track(this.add.circle(x + 28, rowY + 11, 4, status === '다음' ? palette.gold : palette.blue, status === '다음' ? 0.92 : 0.48));
|
||||
dot.setStrokeStyle(1, 0x05070a, 0.6);
|
||||
this.track(this.add.text(x + 44, rowY + 3, hint, this.textStyle(13, status === '다음' ? '#f2e3bf' : '#9fb0bf', status === '다음')));
|
||||
});
|
||||
}
|
||||
|
||||
private campaignTimelineProgress() {
|
||||
const completedIds = this.completedBattleIds();
|
||||
const totalKnown = campaignTimelineChapters.reduce((sum, chapter) => sum + chapter.battleIds.length, 0);
|
||||
const completedKnown = campaignTimelineChapters.reduce(
|
||||
(sum, chapter) => sum + chapter.battleIds.filter((id) => completedIds.has(id)).length,
|
||||
0
|
||||
);
|
||||
const activeChapterIndex = this.activeTimelineChapterIndex(completedIds);
|
||||
const activeChapter = campaignTimelineChapters[activeChapterIndex];
|
||||
const latestBattleId = this.campaign?.latestBattleId ?? this.report?.battleId;
|
||||
const nextBattleId = getSortieFlow(this.campaign?.latestBattleId).nextBattleId;
|
||||
return {
|
||||
completedKnown,
|
||||
totalKnown,
|
||||
activeChapterIndex,
|
||||
activeChapter,
|
||||
latestBattleTitle: latestBattleId ? getBattleScenario(latestBattleId).title : '없음',
|
||||
nextBattleTitle: nextBattleId ? getBattleScenario(nextBattleId).title : '준비 중'
|
||||
};
|
||||
}
|
||||
|
||||
private activeTimelineChapterIndex(completedIds = this.completedBattleIds()) {
|
||||
const index = campaignTimelineChapters.findIndex((chapter) => {
|
||||
if (chapter.battleIds.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return chapter.battleIds.some((battleId) => !completedIds.has(battleId));
|
||||
});
|
||||
return index >= 0 ? index : campaignTimelineChapters.length - 1;
|
||||
}
|
||||
|
||||
private timelineChapterStatus(chapter: CampaignTimelineChapter, index: number, activeIndex: number, completedIds = this.completedBattleIds()) {
|
||||
if (index < activeIndex) {
|
||||
return '완료';
|
||||
}
|
||||
if (index > activeIndex) {
|
||||
return '예정';
|
||||
}
|
||||
if (chapter.battleIds.length === 0) {
|
||||
return '다음';
|
||||
}
|
||||
return '진행';
|
||||
}
|
||||
|
||||
private timelineStatusColor(status: string) {
|
||||
if (status === '완료') {
|
||||
return '#a8ffd0';
|
||||
}
|
||||
if (status === '다음') {
|
||||
return '#fff2b8';
|
||||
}
|
||||
if (status === '진행') {
|
||||
return '#d8b15f';
|
||||
}
|
||||
return '#9fb0bf';
|
||||
}
|
||||
|
||||
private completedBattleIds() {
|
||||
return new Set(Object.keys(this.campaign?.battleHistory ?? {}));
|
||||
}
|
||||
|
||||
private renderStatusPanel() {
|
||||
const unit = this.selectedUnit();
|
||||
if (!unit) {
|
||||
@@ -3827,6 +4100,7 @@ export class CampScene extends Phaser.Scene {
|
||||
};
|
||||
}),
|
||||
sortiePlan: this.sortiePlanSummary(),
|
||||
campaignProgress: this.campaignTimelineProgress(),
|
||||
campBattleId: this.currentCampBattleId(),
|
||||
campTitle: this.currentCampTitle(),
|
||||
availableDialogueIds: this.availableCampDialogues().map((dialogue) => dialogue.id),
|
||||
|
||||
Reference in New Issue
Block a user