Add campaign progress timeline

This commit is contained in:
2026-06-23 05:17:16 +09:00
parent 64af9c1f68
commit 8d958f6932
3 changed files with 300 additions and 11 deletions

View File

@@ -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),