feat: deepen audiovisual story immersion

This commit is contained in:
2026-07-23 10:57:07 +09:00
parent a9e401aedf
commit 8cf0886d7d
38 changed files with 2037 additions and 144 deletions

View File

@@ -981,15 +981,24 @@ const campaignTimelineChapters: CampaignTimelineChapter[] = [
nextHints: ['형주 방위', '익주 진입', '성도 항복']
},
{
id: 'shu-han',
id: 'hanzhong-shu-han',
title: '촉한 건국',
period: '한실을 잇는 깃발',
description: '여러 전장을 지나 촉한을 세우고 북벌을 바라보는 장기 목표입니다.',
period: '한중에서 나라로',
description: '가맹관에서 한중 결전까지 전선을 밀어 올리고, 유랑군의 깃발을 촉한의 국호로 세우는 장입니다.',
battleIds: [
'thirty-fourth-battle-jiameng-pass',
'thirty-fifth-battle-yangping-scout',
'thirty-sixth-battle-dingjun-vanguard',
'thirty-seventh-battle-hanzhong-decisive',
'thirty-seventh-battle-hanzhong-decisive'
],
nextHints: ['가맹관 돌파', '정군산 결전', '한중왕 선언', '촉한 건국']
},
{
id: 'jingzhou-yiling',
title: '형주 붕괴와 이릉',
period: '잃은 땅과 남은 뜻',
description: '번성의 공세에서 형주 후방의 붕괴, 맥성의 고립과 이릉의 화공까지 이어지는 비극의 장입니다.',
battleIds: [
'thirty-eighth-battle-jing-defense',
'thirty-ninth-battle-fan-castle-vanguard',
'fortieth-battle-han-river-flood',
@@ -998,7 +1007,16 @@ const campaignTimelineChapters: CampaignTimelineChapter[] = [
'forty-third-battle-gongan-collapse',
'forty-fourth-battle-maicheng-isolation',
'forty-fifth-battle-yiling-vanguard',
'forty-sixth-battle-yiling-fire',
'forty-sixth-battle-yiling-fire'
],
nextHints: ['번성 포위', '형주 후방 위기', '맥성 고립', '이릉 화공', '백제성의 유언']
},
{
id: 'nanzhong',
title: '남중 평정',
period: '칠종칠금',
description: '힘으로 꺾는 데서 멈추지 않고, 맹획의 마음과 남중의 신뢰를 얻어 북벌의 후방을 다지는 장입니다.',
battleIds: [
'forty-seventh-battle-nanzhong-stabilization',
'forty-eighth-battle-meng-huo-main-force',
'forty-ninth-battle-meng-huo-second-capture',
@@ -1008,7 +1026,7 @@ const campaignTimelineChapters: CampaignTimelineChapter[] = [
'fifty-third-battle-meng-huo-sixth-capture',
'fifty-fourth-battle-meng-huo-final-capture'
],
nextHints: ['왕업 선언', '번성 포위', '형주 위기', '이릉 화공', '남중 안정', '맹획 회유', '칠종칠금']
nextHints: ['남중 안정', '맹획 회유', '칠종칠금', '북벌 후방 확보']
},
{
id: 'northern-campaign',
@@ -21974,22 +21992,26 @@ export class CampScene extends Phaser.Scene {
const completedIds = this.completedBattleIds();
const listX = x + 24;
const listY = y + 142;
const compactChapterList = campaignTimelineChapters.length > 9;
const chapterRowGap = compactChapterList ? 22 : 31;
const chapterRowHeight = compactChapterList ? 20 : 27;
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 rowY = listY + 30 + index * chapterRowGap;
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));
const row = this.track(this.add.rectangle(listX, rowY, 360, chapterRowHeight, 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 textY = rowY + (compactChapterList ? 2 : 5);
this.track(this.add.text(listX + 12, textY, status, this.textStyle(compactChapterList ? 10 : 11, markerColor, true)));
this.track(this.add.text(listX + 64, rowY + (compactChapterList ? 1 : 4), chapter.title, this.textStyle(compactChapterList ? 11 : 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)));
const count = this.track(this.add.text(listX + 342, textY, countText, this.textStyle(compactChapterList ? 10 : 11, markerColor, true)));
count.setOrigin(1, 0);
});
@@ -22037,7 +22059,29 @@ export class CampScene extends Phaser.Scene {
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 visibleBattleLimit = 4;
const firstPendingBattleIndex = chapter.battleIds.findIndex((battleId) => !completedIds.has(battleId));
const focusBattleIndex = firstPendingBattleIndex >= 0 ? firstPendingBattleIndex : chapter.battleIds.length - 1;
const visibleBattleStart = Phaser.Math.Clamp(
focusBattleIndex - 1,
0,
Math.max(0, chapter.battleIds.length - visibleBattleLimit)
);
const visibleBattleIds = chapter.battleIds.slice(visibleBattleStart, visibleBattleStart + visibleBattleLimit);
if (chapter.battleIds.length > visibleBattleIds.length) {
const range = this.track(this.add.text(
x + width - 18,
y + 174,
`전장 ${visibleBattleStart + 1}${visibleBattleStart + visibleBattleIds.length} 표시`,
this.textStyle(10, '#9fb0bf', true)
));
range.setOrigin(1, 0);
}
const battleTitleX = x + 78;
const battleTitleWidth = 116;
const objectiveX = battleTitleX + battleTitleWidth + 10;
const objectiveWidth = x + width - 30 - objectiveX;
visibleBattleIds.forEach((battleId, index) => {
const battle = getBattleScenario(battleId);
const complete = completedIds.has(battleId);
const rowY = y + 190 + index * 28;
@@ -22045,9 +22089,27 @@ export class CampScene extends Phaser.Scene {
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);
const battleTitle = this.track(this.add.text(
battleTitleX,
rowY + 3,
this.compactText(battle.title, 10),
{
...this.textStyle(12, complete ? '#d4dce6' : '#f2e3bf', !complete),
fixedWidth: battleTitleWidth
}
));
battleTitle.setData('timelineField', 'battle-title');
const objective = this.track(this.add.text(
objectiveX,
rowY + 4,
this.compactText(battle.victoryConditionLabel, 15),
{
...this.textStyle(10, '#9fb0bf', true),
align: 'right',
fixedWidth: objectiveWidth
}
));
objective.setData('timelineField', 'victory-condition');
});
return;
}
@@ -22310,10 +22372,23 @@ export class CampScene extends Phaser.Scene {
);
if (completed) {
const done = this.track(this.add.rectangle(x + width / 2, y + height - 34, 220, 34, 0x17231d, 0.96));
done.setStrokeStyle(1, palette.green, 0.76);
const text = this.track(this.add.text(x + width / 2, y + height - 34, '대화 완료', this.textStyle(15, '#a8ffd0', true)));
text.setOrigin(0.5);
const chosen = this.recordedDialogueChoice(dialogue);
if (chosen) {
const memory = this.track(this.add.rectangle(x + 18, y + height - 94, width - 36, 76, 0x17231d, 0.96));
memory.setOrigin(0);
memory.setStrokeStyle(1, palette.green, 0.76);
this.track(this.add.text(x + 32, y + height - 84, `나의 결정 · ${chosen.label}`, this.textStyle(13, '#a8ffd0', true)));
this.track(this.add.text(x + 32, y + height - 58, `남은 말 · ${this.compactText(chosen.response, 54)}`, {
...this.textStyle(12, '#d4dce6'),
wordWrap: { width: width - 64, useAdvancedWrap: true },
lineSpacing: 2
}));
} else {
const done = this.track(this.add.rectangle(x + width / 2, y + height - 34, 250, 34, 0x17231d, 0.96));
done.setStrokeStyle(1, palette.green, 0.76);
const text = this.track(this.add.text(x + width / 2, y + height - 34, '대화 완료 · 과거 선택 기록 없음', this.textStyle(13, '#a8ffd0', true)));
text.setOrigin(0.5);
}
return;
}
@@ -22418,10 +22493,23 @@ export class CampScene extends Phaser.Scene {
);
if (completed) {
const done = this.track(this.add.rectangle(x + width / 2, y + height - 34, 220, 34, 0x17231d, 0.96));
done.setStrokeStyle(1, palette.green, 0.76);
const text = this.track(this.add.text(x + width / 2, y + height - 34, '방문 완료', this.textStyle(15, '#a8ffd0', true)));
text.setOrigin(0.5);
const chosen = this.recordedVisitChoice(visit);
if (chosen) {
const memory = this.track(this.add.rectangle(x + 18, y + height - 94, width - 36, 76, 0x17231d, 0.96));
memory.setOrigin(0);
memory.setStrokeStyle(1, palette.green, 0.76);
this.track(this.add.text(x + 32, y + height - 84, `나의 결정 · ${chosen.label}`, this.textStyle(13, '#a8ffd0', true)));
this.track(this.add.text(x + 32, y + height - 58, `남은 말 · ${this.compactText(chosen.response, 54)}`, {
...this.textStyle(12, '#d4dce6'),
wordWrap: { width: width - 64, useAdvancedWrap: true },
lineSpacing: 2
}));
} else {
const done = this.track(this.add.rectangle(x + width / 2, y + height - 34, 250, 34, 0x17231d, 0.96));
done.setStrokeStyle(1, palette.green, 0.76);
const text = this.track(this.add.text(x + width / 2, y + height - 34, '방문 완료 · 과거 선택 기록 없음', this.textStyle(13, '#a8ffd0', true)));
text.setOrigin(0.5);
}
return;
}
@@ -23063,7 +23151,7 @@ export class CampScene extends Phaser.Scene {
bondExp: choice.bondExp,
gold: choice.gold,
itemRewards: choice.itemRewards
});
}, choice.id);
if (updated) {
this.campaign = updated;
@@ -23095,7 +23183,7 @@ export class CampScene extends Phaser.Scene {
}
const rewardExp = dialogue.rewardExp + choice.rewardExp;
const updated = applyCampBondExp(dialogue.id, dialogue.bondId, rewardExp);
const updated = applyCampBondExp(dialogue.id, dialogue.bondId, rewardExp, choice.id);
if (updated) {
this.report = updated;
this.campaign = getCampaignState();
@@ -23501,6 +23589,16 @@ export class CampScene extends Phaser.Scene {
return this.report?.completedCampVisits ?? [];
}
private recordedDialogueChoice(dialogue: CampDialogue) {
const choiceId = this.campaign?.campDialogueChoiceIds[dialogue.id];
return choiceId ? dialogue.choices.find((choice) => choice.id === choiceId) : undefined;
}
private recordedVisitChoice(visit: CampVisitDefinition) {
const choiceId = this.campaign?.campVisitChoiceIds[visit.id];
return choiceId ? visit.choices.find((choice) => choice.id === choiceId) : undefined;
}
private inventoryLabels() {
const inventory = this.campaign?.inventory ?? {};
const entries = Object.entries(inventory).filter(([, amount]) => amount > 0);