feat: add spoiler-safe campaign objective journal

This commit is contained in:
2026-07-28 11:05:57 +09:00
parent a67cfbfbe3
commit 91607cfd4c
16 changed files with 5116 additions and 220 deletions

View File

@@ -259,6 +259,10 @@ import {
type CampaignVictoryRewardReport,
type FirstBattleReport
} from '../state/campaignState';
import {
resolveCampaignObjectiveJournal,
type CampaignObjectiveJournalEntry
} from '../state/campaignObjectiveJournal';
import {
chooseCityStayResonance,
collectCityStayInformation,
@@ -356,6 +360,14 @@ type CampTabButtonView = {
hovered: boolean;
};
type CampObjectiveJournalCardView = {
kind: 'current' | 'completed' | 'next-clue';
background: Phaser.GameObjects.Rectangle;
status: Phaser.GameObjects.Text;
title: Phaser.GameObjects.Text;
detail: Phaser.GameObjects.Text;
};
type VictoryRewardCardId = CampaignVictoryRewardCategoryId;
type VictoryRewardActionId = 'equipment' | 'supplies' | 'sortie' | 'close';
@@ -11437,6 +11449,11 @@ export class CampScene extends Phaser.Scene {
private campRosterPage = 0;
private campRosterLayout?: CampRosterLayout;
private activeTab: CampTab = 'status';
private campaignJournalReturnTab: Exclude<CampTab, 'progress'> = 'status';
private campaignJournalPanel?: Phaser.GameObjects.Rectangle;
private campaignJournalCardViews: CampObjectiveJournalCardView[] = [];
private campaignJournalVisibleText: string[] = [];
private campaignJournalVisibleChapterTitles: string[] = [];
private selectedCityLocation: CityStayLocationId = 'information';
private cityPanelBackground?: Phaser.GameObjects.Rectangle;
private cityExploreButton?: Phaser.GameObjects.Rectangle;
@@ -11593,6 +11610,11 @@ export class CampScene extends Phaser.Scene {
this.navigationPending = false;
this.visualMotionReduced = isVisualMotionReduced();
this.activeTab = 'status';
this.campaignJournalReturnTab = 'status';
this.campaignJournalPanel = undefined;
this.campaignJournalCardViews = [];
this.campaignJournalVisibleText = [];
this.campaignJournalVisibleChapterTitles = [];
this.selectedCityLocation = 'information';
this.campNoticeRemovalTimer = undefined;
this.contentObjects = [];
@@ -11770,7 +11792,10 @@ export class CampScene extends Phaser.Scene {
this.input.keyboard?.on('keydown-ESC', () => this.handleEscapeKey());
this.input.keyboard?.on('keydown-ENTER', (event: KeyboardEvent) => this.handleSortieEnterKey(event));
this.input.keyboard?.on('keydown', (event: KeyboardEvent) => {
if (!this.handleThirdCampPreparationKey(event)) {
if (
!this.handleCampaignJournalKey(event) &&
!this.handleThirdCampPreparationKey(event)
) {
this.handleSaveSlotKey(event);
}
});
@@ -13236,14 +13261,14 @@ export class CampScene extends Phaser.Scene {
this.addTabButton('방문', 'visit', 758, 38, 60, 14);
this.addTabButton('보급', 'supplies', 822, 38, 60, 14);
this.addTabButton('장비', 'equipment', 886, 38, 60, 14);
this.addTabButton('연표', 'progress', 950, 38, 60, 14);
this.addTabButton('기록 J', 'progress', 950, 38, 60, 13);
} else {
this.addTabButton('장수', 'status', 576, 38);
this.addTabButton('대화', 'dialogue', 650, 38);
this.addTabButton('방문', 'visit', 724, 38);
this.addTabButton('보급', 'supplies', 798, 38);
this.addTabButton('장비', 'equipment', 872, 38);
this.addTabButton('연표', 'progress', 946, 38);
this.addTabButton('기록 J', 'progress', 946, 38, 76, 14);
}
this.addCommandButton('저장', 1030, 38, 76, () => {
soundDirector.playSelect();
@@ -14118,6 +14143,38 @@ export class CampScene extends Phaser.Scene {
return { key, ready: true };
}
private handleCampaignJournalKey(event: KeyboardEvent) {
if (event.key.toLowerCase() !== 'j') {
return false;
}
if (event.repeat) {
return true;
}
event.preventDefault();
if (
this.navigationPending ||
this.victoryRewardObjects.length > 0 ||
this.equipmentSwapConfirmObjects.length > 0 ||
this.saveSlotObjects.length > 0 ||
this.saveSlotConfirmObjects.length > 0 ||
this.reportFormationReviewVisible ||
this.reportFormationHistoryVisible ||
this.sortieObjects.length > 0
) {
return true;
}
soundDirector.playSelect();
if (this.activeTab === 'progress') {
this.activeTab = this.campaignJournalReturnTab;
} else {
this.clearCampNotice();
this.campaignJournalReturnTab = this.activeTab;
this.activeTab = 'progress';
}
this.render();
return true;
}
private handleEscapeKey() {
if (this.navigationPending) {
return;
@@ -14175,6 +14232,13 @@ export class CampScene extends Phaser.Scene {
if (this.sortieObjects.length > 0) {
soundDirector.playSelect();
this.hideSortiePrep();
return;
}
if (this.activeTab === 'progress') {
soundDirector.playSelect();
this.activeTab = this.campaignJournalReturnTab;
this.render();
}
}
@@ -14308,6 +14372,10 @@ export class CampScene extends Phaser.Scene {
const selectTab = () => {
soundDirector.playSelect();
if (tab === 'progress' && this.activeTab !== 'progress') {
this.clearCampNotice();
this.campaignJournalReturnTab = this.activeTab;
}
this.activeTab = tab;
this.render();
};
@@ -23541,46 +23609,235 @@ export class CampScene extends Phaser.Scene {
const width = 828;
const height = 444;
const progress = this.campaignTimelineProgress();
const journal = resolveCampaignObjectiveJournal(
this.campaign ?? getCampaignState()
);
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.campaignJournalPanel = bg;
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 title = this.track(this.add.text(x + 24, y + 18, '진군 기록', this.textStyle(24, '#f2e3bf', true)));
const subtitle = this.track(this.add.text(
x + 164,
y + 26,
'J · 지금 할 일과 이어질 실마리를 언제든 다시 확인',
this.textStyle(12, '#d4dce6', true)
));
const progressLabel = this.track(this.add.text(
x + width - 24,
y + 24,
`전장 ${progress.completedKnown}/${progress.totalKnown}`,
this.textStyle(13, '#fff2b8', true)
));
progressLabel.setOrigin(1, 0);
const ratio = progress.totalKnown > 0
? progress.completedKnown / progress.totalKnown
: 0;
this.drawBar(x + 24, y + 58, width - 48, 8, ratio, palette.gold);
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);
this.campaignJournalVisibleText = [
title.text,
subtitle.text,
progressLabel.text
];
this.campaignJournalCardViews = [];
this.renderCampaignJournalCard(
'current',
journal.current,
x + 24,
y + 82,
width - 48,
92
);
this.renderCampaignJournalCard(
'completed',
journal.recentCompleted,
x + 24,
y + 186,
384,
104
);
this.renderCampaignJournalCard(
'next-clue',
journal.nextClue,
x + 420,
y + 186,
384,
104
);
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 * chapterRowGap;
const active = index === progress.activeChapterIndex;
const complete = status === '완료';
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';
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, textY, countText, this.textStyle(compactChapterList ? 10 : 11, markerColor, true)));
count.setOrigin(1, 0);
});
const journeyX = x + 24;
const journeyY = y + 304;
const journeyWidth = width - 48;
const journeyHeight = 116;
const journeyBackground = this.track(
this.add.rectangle(
journeyX,
journeyY,
journeyWidth,
journeyHeight,
0x0d141c,
0.92
)
);
journeyBackground.setOrigin(0);
journeyBackground.setStrokeStyle(1, palette.blue, 0.42);
const journeyTitle = this.track(this.add.text(
journeyX + 16,
journeyY + 12,
'공개된 큰 흐름',
this.textStyle(13, '#f2e3bf', true)
));
const hiddenHint = this.track(this.add.text(
journeyX + journeyWidth - 16,
journeyY + 13,
'앞으로의 장은 도달할 때 공개됩니다.',
this.textStyle(11, '#9fb0bf', true)
));
hiddenHint.setOrigin(1, 0);
this.renderProgressChapterDetail(progress.activeChapter, progress.activeChapterIndex, x + 410, listY, 390, 310, completedIds);
const revealedChapters = campaignTimelineChapters
.slice(0, progress.activeChapterIndex + 1)
.slice(-3);
this.campaignJournalVisibleChapterTitles = revealedChapters.map(
(chapter) => chapter.title
);
this.campaignJournalVisibleText.push(
journeyTitle.text,
hiddenHint.text,
...this.campaignJournalVisibleChapterTitles
);
revealedChapters.forEach((chapter, index) => {
const absoluteIndex = campaignTimelineChapters.indexOf(chapter);
const active = absoluteIndex === progress.activeChapterIndex;
const complete = absoluteIndex < progress.activeChapterIndex;
const rowY = journeyY + 40 + index * 24;
const row = this.track(
this.add.rectangle(
journeyX + 16,
rowY,
journeyWidth - 32,
20,
active ? 0x25384a : 0x17231d,
0.9
)
);
row.setOrigin(0);
row.setStrokeStyle(
1,
active ? palette.gold : palette.green,
active ? 0.68 : 0.38
);
const status = active ? '◆ 현재' : complete ? '✓ 완료' : '• 공개';
this.track(this.add.text(
journeyX + 28,
rowY + 2,
status,
this.textStyle(10, active ? '#fff2b8' : '#a8ffd0', true)
));
this.track(this.add.text(
journeyX + 90,
rowY + 1,
chapter.title,
this.textStyle(11, '#d4dce6', active)
));
const chapterProgress = chapter.battleIds.length > 0
? `${chapter.battleIds.filter((id) => completedIds.has(id)).length}/${chapter.battleIds.length}`
: active
? '진행'
: '완료';
const count = this.track(this.add.text(
journeyX + journeyWidth - 28,
rowY + 2,
chapterProgress,
this.textStyle(10, active ? '#fff2b8' : '#a8ffd0', true)
));
count.setOrigin(1, 0);
this.campaignJournalVisibleText.push(status, chapterProgress);
});
}
private renderCampaignJournalCard(
kind: CampObjectiveJournalCardView['kind'],
entry: CampaignObjectiveJournalEntry,
x: number,
y: number,
width: number,
height: number
) {
const cardStyle = kind === 'current'
? {
fill: 0x25384a,
stroke: palette.gold,
status: '◆ 진행 · 지금 할 일',
statusColor: '#fff2b8'
}
: kind === 'completed'
? {
fill: 0x17231d,
stroke: palette.green,
status: '✓ 완료 · 마친 일',
statusColor: '#a8ffd0'
}
: {
fill: 0x1b2430,
stroke: palette.blue,
status: '? 실마리 · 다음 단서',
statusColor: '#b9d8f2'
};
const background = this.track(
this.add.rectangle(x, y, width, height, cardStyle.fill, 0.94)
);
background.setOrigin(0);
background.setStrokeStyle(
kind === 'current' ? 2 : 1,
cardStyle.stroke,
kind === 'current' ? 0.82 : 0.56
);
const status = this.track(this.add.text(
x + 16,
y + 10,
cardStyle.status,
this.textStyle(12, cardStyle.statusColor, true)
));
const locationText = entry.location ? ` · ${entry.location}` : '';
const title = this.track(this.add.text(
x + 16,
y + 32,
this.compactText(
`${entry.title}${locationText}`,
kind === 'current' ? 42 : 21
),
this.textStyle(kind === 'current' ? 17 : 15, '#f2e3bf', true)
));
const detail = this.track(this.add.text(
x + 16,
y + (kind === 'current' ? 58 : 59),
this.compactText(entry.detail, kind === 'current' ? 108 : 46),
{
...this.textStyle(12, '#d4dce6'),
fixedWidth: width - 32,
wordWrap: {
width: width - 32,
useAdvancedWrap: true
}
}
));
this.campaignJournalCardViews.push({
kind,
background,
status,
title,
detail
});
this.campaignJournalVisibleText.push(
cardStyle.status,
title.text,
detail.text
);
}
private renderProgressStatCard(label: string, value: string, x: number, y: number, width: number) {
@@ -25701,9 +25958,17 @@ export class CampScene extends Phaser.Scene {
private showCampNotice(message: string) {
this.clearCampNotice();
const depth = this.sortieObjects.length > 0 ? 72 : 30;
const journalOpen = this.activeTab === 'progress';
const noticeX = journalOpen ? 202 : campLegacyCanvasWidth / 2;
const noticeY = journalOpen ? 116 : 104;
const noticeMinimumWidth = journalOpen ? 300 : 420;
const noticeMaximumWidth = journalOpen ? 340 : 760;
const longestLineLength = Math.max(...message.split('\n').map((line) => line.length));
const noticeWidth = Math.min(760, Math.max(420, longestLineLength * 13));
const text = this.scaleLegacyCampUi(this.add.text(campLegacyCanvasWidth / 2, 104, message, {
const noticeWidth = Math.min(
noticeMaximumWidth,
Math.max(noticeMinimumWidth, longestLineLength * 13)
);
const text = this.scaleLegacyCampUi(this.add.text(noticeX, noticeY, message, {
...this.textStyle(14, '#f2e3bf', true),
align: 'center',
wordWrap: { width: noticeWidth - 44, useAdvancedWrap: true }
@@ -25712,7 +25977,9 @@ export class CampScene extends Phaser.Scene {
text.setDepth(depth + 1);
text.setLineSpacing(5);
const noticeHeight = Math.max(56, text.height + 28);
const box = this.scaleLegacyCampUi(this.add.rectangle(campLegacyCanvasWidth / 2, 104, noticeWidth, noticeHeight, 0x101820, 0.96));
const box = this.scaleLegacyCampUi(
this.add.rectangle(noticeX, noticeY, noticeWidth, noticeHeight, 0x101820, 0.96)
);
box.setStrokeStyle(2, palette.gold, 0.84);
box.setDepth(depth);
const noticeObjects: Phaser.GameObjects.GameObject[] = [box, text];
@@ -26601,6 +26868,10 @@ export class CampScene extends Phaser.Scene {
private clearContent() {
this.contentObjects.forEach((object) => object.destroy());
this.contentObjects = [];
this.campaignJournalPanel = undefined;
this.campaignJournalCardViews = [];
this.campaignJournalVisibleText = [];
this.campaignJournalVisibleChapterTitles = [];
this.campDialogueRowButtons = {};
this.campDialogueChoiceButtons = {};
this.campDialogueBodyText = undefined;
@@ -26675,6 +26946,41 @@ export class CampScene extends Phaser.Scene {
};
}
private campaignObjectiveJournalDebug() {
const trigger = this.tabButtons.find(
(button) => button.tab === 'progress'
);
const visible = this.activeTab === 'progress';
return {
visible,
shortcut: 'J',
triggerBounds: this.sortieObjectBoundsDebug(trigger?.bg),
buttonBounds: this.sortieObjectBoundsDebug(trigger?.bg),
panelBounds: visible
? this.sortieObjectBoundsDebug(this.campaignJournalPanel)
: null,
closeBounds: null,
objectCount: visible
? 1 + this.campaignJournalCardViews.length
: 0,
cards: visible
? this.campaignJournalCardViews.map((card) => ({
kind: card.kind === 'next-clue' ? 'nextClue' : card.kind,
label: card.status.text,
title: card.title.text,
body: card.detail.text,
bounds: this.sortieObjectBoundsDebug(card.background),
titleBounds: this.sortieTextBoundsDebug(card.title),
bodyBounds: this.sortieTextBoundsDebug(card.detail)
}))
: [],
visibleText: visible ? [...this.campaignJournalVisibleText] : [],
revealedChapterTitles: visible
? [...this.campaignJournalVisibleChapterTitles]
: []
};
}
getDebugState() {
const sortieChecklist = this.sortieChecklist();
const sortieScenario = this.nextSortieScenario();
@@ -28158,6 +28464,12 @@ export class CampScene extends Phaser.Scene {
rosterCollection: this.rosterCollectionSummary(),
reserveTrainingAwards: this.latestReserveTrainingAwards(),
campaignProgress: this.campaignTimelineProgress(),
campaignObjectiveJournal: {
snapshot: resolveCampaignObjectiveJournal(
this.campaign ?? getCampaignState()
),
view: this.campaignObjectiveJournalDebug()
},
campBattleId: this.currentCampBattleId(),
campTitle: this.currentCampTitle(),
campSoundscape: this.campSoundscape
@@ -28234,6 +28546,14 @@ export class CampScene extends Phaser.Scene {
sortieItemAssignments: this.campaign.sortieItemAssignments,
sortieResonanceSelection: this.campaign.sortieResonanceSelection ?? null,
activeSaveSlot: this.campaign.activeSaveSlot,
acknowledgedVictoryRewardCategories: Object.fromEntries(
Object.entries(
this.campaign.acknowledgedVictoryRewardCategories
).map(([battleId, categories]) => [
battleId,
[...(categories ?? [])]
])
),
reserveTrainingAssignments: this.campaign.reserveTrainingAssignments,
reserveTrainingFocus: this.campaign.reserveTrainingFocus,
roster: this.campaign.roster.map((unit) => ({

View File

@@ -50,6 +50,7 @@ import { ExplorationInputController } from '../input/ExplorationInputController'
import { releaseTextureSource } from '../render/loaderLifecycle';
import { isVisualMotionReduced } from '../settings/visualMotion';
import { getCampaignState } from '../state/campaignState';
import { resolveCampaignObjectiveJournal } from '../state/campaignObjectiveJournal';
import { completeFirstPursuitScoutVisit } from '../state/firstPursuitScoutActions';
import {
completeSecondBattleReliefExploration,
@@ -62,6 +63,7 @@ import {
recordThirdCampExplorationActivity,
thirdCampExplorationProgress
} from '../state/thirdCampExplorationActions';
import { CampaignObjectiveJournalOverlay } from '../ui/CampaignObjectiveJournalOverlay';
import { fitDialogueFacePortrait } from '../ui/dialoguePortrait';
import { startGameScene } from './lazyScenes';
@@ -351,6 +353,7 @@ export class CampVisitExplorationScene extends Phaser.Scene {
private lastNotice = '';
private ownedPresentationTextureKeys = new Set<string>();
private visualMotionReduced = false;
private campaignObjectiveJournal?: CampaignObjectiveJournalOverlay;
constructor() {
super('CampVisitExplorationScene');
@@ -400,6 +403,8 @@ export class CampVisitExplorationScene extends Phaser.Scene {
this.explorationInput?.destroy();
this.explorationInput = undefined;
this.dialogueState = undefined;
this.campaignObjectiveJournal?.destroy();
this.campaignObjectiveJournal = undefined;
this.closeChoicePanel();
releaseExplorationCharacterTextureKeys(
this,
@@ -445,6 +450,7 @@ export class CampVisitExplorationScene extends Phaser.Scene {
this.createDialoguePanel();
this.ready = true;
this.inputReadyAt = this.time.now + inputCarryoverGuardMs;
this.createCampaignObjectiveJournal();
this.refreshObjectiveHud();
this.refreshActorMarkers();
this.refreshInteractionPrompt();
@@ -493,6 +499,11 @@ export class CampVisitExplorationScene extends Phaser.Scene {
this.explorationInput?.discardInteractionRequest();
return;
}
if (this.campaignObjectiveJournal?.isOpen()) {
this.stopPlayerMovement();
this.explorationInput?.discardInteractionRequest();
return;
}
if (this.choicePanel) {
this.stopPlayerMovement();
this.explorationInput?.discardInteractionRequest();
@@ -560,6 +571,10 @@ export class CampVisitExplorationScene extends Phaser.Scene {
ready: this.ready,
viewport: { width: this.scale.width, height: this.scale.height },
campaignStep: campaign.step,
campaignObjectiveJournal: {
snapshot: resolveCampaignObjectiveJournal(campaign),
view: this.campaignObjectiveJournal?.getDebugState() ?? null
},
background: {
key: this.backgroundImage?.texture.key ?? this.currentBackgroundKey(),
ready: this.backgroundImage?.active === true,
@@ -1119,6 +1134,7 @@ export class CampVisitExplorationScene extends Phaser.Scene {
this.actorViews.clear();
this.blockers = [];
this.dialogueState = undefined;
this.campaignObjectiveJournal = undefined;
this.choicePanel = undefined;
this.choicePanelBounds = undefined;
this.choiceViews = [];
@@ -2291,7 +2307,16 @@ export class CampVisitExplorationScene extends Phaser.Scene {
.bindKey(
Phaser.Input.Keyboard.KeyCodes.TWO,
() => this.chooseChoiceByIndex(1)
)
.bindKey(
Phaser.Input.Keyboard.KeyCodes.J,
() => this.campaignObjectiveJournal?.toggle()
);
this.input.keyboard?.on('keydown-ESC', () => {
if (this.campaignObjectiveJournal?.isOpen()) {
this.campaignObjectiveJournal.close();
}
});
this.input.on(
Phaser.Input.Events.POINTER_DOWN,
@@ -2299,6 +2324,7 @@ export class CampVisitExplorationScene extends Phaser.Scene {
if (
!this.ready ||
this.navigationPending ||
this.campaignObjectiveJournal?.isOpen() ||
this.time.now < this.inputReadyAt
) {
return;
@@ -2334,6 +2360,10 @@ export class CampVisitExplorationScene extends Phaser.Scene {
) {
return;
}
if (this.campaignObjectiveJournal?.isOpen()) {
this.campaignObjectiveJournal.close();
return;
}
if (this.choicePanel) {
this.closeChoicePanel();
return;
@@ -2351,6 +2381,16 @@ export class CampVisitExplorationScene extends Phaser.Scene {
);
}
private createCampaignObjectiveJournal() {
this.campaignObjectiveJournal = new CampaignObjectiveJournalOverlay(this, {
snapshotProvider: () =>
resolveCampaignObjectiveJournal(getCampaignState()),
canOpen: () => this.ready && !this.navigationPending,
buttonX: 1215,
buttonY: 21
});
}
private updateMovement(time: number, delta: number) {
if (!this.player) {
return;
@@ -3466,6 +3506,7 @@ export class CampVisitExplorationScene extends Phaser.Scene {
if (
!this.choicePanel ||
this.navigationPending ||
this.campaignObjectiveJournal?.isOpen() ||
this.time.now < this.choiceReadyAt
) {
return;
@@ -3477,6 +3518,13 @@ export class CampVisitExplorationScene extends Phaser.Scene {
}
private chooseVisitChoice(choice: ChoiceOption) {
if (
!this.choicePanel ||
this.navigationPending ||
this.campaignObjectiveJournal?.isOpen()
) {
return;
}
if (this.isThirdCampVisit()) {
if ('rewardExp' in choice) {
this.chooseThirdCampCompanion(choice);

View File

@@ -53,6 +53,8 @@ import {
setActiveCityStayId,
type CampaignState
} from '../state/campaignState';
import { resolveCampaignObjectiveJournal } from '../state/campaignObjectiveJournal';
import { CampaignObjectiveJournalOverlay } from '../ui/CampaignObjectiveJournalOverlay';
import { fitDialogueFacePortrait } from '../ui/dialoguePortrait';
import { startGameScene } from './lazyScenes';
@@ -209,6 +211,7 @@ export class CityStayScene extends Phaser.Scene {
private lastNotice = '';
private visualMotionReduced = false;
private ownedPresentationTextureKeys = new Set<string>();
private campaignObjectiveJournal?: CampaignObjectiveJournalOverlay;
constructor() {
super('CityStayScene');
@@ -267,6 +270,7 @@ export class CityStayScene extends Phaser.Scene {
this.createDialoguePanel();
this.ready = true;
this.inputReadyAt = this.time.now + inputCarryoverGuardMs;
this.createCampaignObjectiveJournal();
this.refreshProgressHud();
this.refreshActorMarkers();
this.refreshInteractionPrompt();
@@ -285,6 +289,8 @@ export class CityStayScene extends Phaser.Scene {
this.explorationInput?.destroy();
this.explorationInput = undefined;
this.dialogueState = undefined;
this.campaignObjectiveJournal?.destroy();
this.campaignObjectiveJournal = undefined;
releaseExplorationCharacterTextureKeys(
this,
this.characterTextureKeys()
@@ -299,6 +305,12 @@ export class CityStayScene extends Phaser.Scene {
return;
}
if (this.campaignObjectiveJournal?.isOpen()) {
this.stopPlayerMovement();
this.explorationInput?.discardInteractionRequest();
return;
}
if (this.shopPanel || this.choicePanel) {
this.stopPlayerMovement();
this.explorationInput?.discardInteractionRequest();
@@ -339,6 +351,10 @@ export class CityStayScene extends Phaser.Scene {
ready: this.ready,
cityStayId: this.cityStay.id,
cityName: this.cityStay.city.name,
campaignObjectiveJournal: {
snapshot: resolveCampaignObjectiveJournal(campaign),
view: this.campaignObjectiveJournal?.getDebugState() ?? null
},
visualMotionReduced: this.visualMotionReduced,
viewport: { width: this.scale.width, height: this.scale.height },
activeCityStayId: campaign.activeCityStayId ?? null,
@@ -581,6 +597,7 @@ export class CityStayScene extends Phaser.Scene {
this.dialoguePortraitFrame = undefined;
this.dialoguePortrait = undefined;
this.dialogueState = undefined;
this.campaignObjectiveJournal = undefined;
this.shopPanel = undefined;
this.shopOfferViews = [];
this.shopNotice = '';
@@ -1298,10 +1315,24 @@ export class CityStayScene extends Phaser.Scene {
.bindKey(
Phaser.Input.Keyboard.KeyCodes.TWO,
() => this.chooseDialogueByIndex(1)
)
.bindKey(
Phaser.Input.Keyboard.KeyCodes.J,
() => this.campaignObjectiveJournal?.toggle()
);
this.input.keyboard?.on('keydown-ESC', () => {
if (this.campaignObjectiveJournal?.isOpen()) {
this.campaignObjectiveJournal.close();
}
});
this.input.on(Phaser.Input.Events.POINTER_DOWN, (pointer: Phaser.Input.Pointer) => {
if (!this.ready || this.navigationPending || this.time.now < this.inputReadyAt) {
if (
!this.ready ||
this.navigationPending ||
this.campaignObjectiveJournal?.isOpen() ||
this.time.now < this.inputReadyAt
) {
return;
}
if (this.shopPanel || this.choicePanel) {
@@ -1332,6 +1363,10 @@ export class CityStayScene extends Phaser.Scene {
if (!this.ready || this.navigationPending || this.time.now < this.inputReadyAt) {
return;
}
if (this.campaignObjectiveJournal?.isOpen()) {
this.campaignObjectiveJournal.close();
return;
}
if (this.shopPanel) {
this.closeShop();
return;
@@ -2135,7 +2170,11 @@ export class CityStayScene extends Phaser.Scene {
}
private chooseDialogueByIndex(index: number) {
if (!this.choicePanel || this.navigationPending) {
if (
!this.choicePanel ||
this.navigationPending ||
this.campaignObjectiveJournal?.isOpen()
) {
return;
}
const choice = this.cityStay.dialogue.choices[index];
@@ -2145,6 +2184,13 @@ export class CityStayScene extends Phaser.Scene {
}
private chooseDialogue(choice: CityResonanceDialogueChoice) {
if (
!this.choicePanel ||
this.navigationPending ||
this.campaignObjectiveJournal?.isOpen()
) {
return;
}
const result = chooseCityStayResonance(this.cityStay.id, choice.id);
if (!result.ok) {
this.closeChoicePanel();
@@ -2905,7 +2951,22 @@ export class CityStayScene extends Phaser.Scene {
}
private modalOpen() {
return Boolean(this.dialogueState || this.shopPanel || this.choicePanel);
return Boolean(
this.campaignObjectiveJournal?.isOpen() ||
this.dialogueState ||
this.shopPanel ||
this.choicePanel
);
}
private createCampaignObjectiveJournal() {
this.campaignObjectiveJournal = new CampaignObjectiveJournalOverlay(this, {
snapshotProvider: () =>
resolveCampaignObjectiveJournal(getCampaignState()),
canOpen: () => this.ready && !this.navigationPending,
buttonX: 1215,
buttonY: 21
});
}
private releasePresentationTextures() {

View File

@@ -41,8 +41,10 @@ import {
setCampaignSortieOrderSelection,
type CampaignTutorialId
} from '../state/campaignState';
import { resolveCampaignObjectiveJournal } from '../state/campaignObjectiveJournal';
import { releaseTextureSource } from '../render/loaderLifecycle';
import { isVisualMotionReduced } from '../settings/visualMotion';
import { CampaignObjectiveJournalOverlay } from '../ui/CampaignObjectiveJournalOverlay';
import { fitDialogueFacePortrait } from '../ui/dialoguePortrait';
import { palette } from '../ui/palette';
import { startGameScene } from './lazyScenes';
@@ -228,6 +230,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
private firstVisit = false;
private visualMotionReduced = false;
private ownedPresentationTextureKeys = new Set<string>();
private campaignObjectiveJournal?: CampaignObjectiveJournalOverlay;
constructor() {
super('PrologueMilitiaCampScene');
@@ -276,6 +279,8 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
this.navigationPending = false;
this.clearNavigationTarget(true);
this.dialogueState = undefined;
this.campaignObjectiveJournal?.destroy();
this.campaignObjectiveJournal = undefined;
this.closeCommandChoice();
this.stopNpcMovement();
releaseExplorationCharacterTextureKeys(this, characterTextureKeys);
@@ -290,6 +295,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
this.loadingOverlay = undefined;
this.ready = true;
this.inputReadyAt = this.time.now + inputCarryoverGuardMs;
this.createCampaignObjectiveJournal();
this.refreshObjectiveHud();
this.refreshNpcMarkers();
this.refreshInteractionPrompt();
@@ -313,6 +319,11 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
this.interactionQueued = false;
return;
}
if (this.campaignObjectiveJournal?.isOpen()) {
this.stopPlayerMovement();
this.interactionQueued = false;
return;
}
if (this.commandChoicePanel) {
this.stopPlayerMovement();
this.interactionQueued = false;
@@ -349,6 +360,10 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
scene: this.scene.key,
locationId: 'zhuo-volunteer-camp',
ready: this.ready,
campaignObjectiveJournal: {
snapshot: resolveCampaignObjectiveJournal(campaign),
view: this.campaignObjectiveJournal?.getDebugState() ?? null
},
visualMotionReduced: this.visualMotionReduced,
viewport: { width: this.scale.width, height: this.scale.height },
campaignStep: campaign.step,
@@ -622,6 +637,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
this.dialoguePortraitFrame = undefined;
this.dialoguePortrait = undefined;
this.dialogueState = undefined;
this.campaignObjectiveJournal = undefined;
this.commandChoicePanel = undefined;
this.commandChoicePanelBounds = undefined;
this.commandChoiceViews = [];
@@ -1399,7 +1415,15 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ENTER)
.on('down', () => this.confirmFirstCommand());
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ESC)
.on('down', () => this.cancelFirstCommandChoice());
.on('down', () => {
if (this.campaignObjectiveJournal?.isOpen()) {
this.campaignObjectiveJournal.close();
return;
}
this.cancelFirstCommandChoice();
});
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.J)
.on('down', () => this.campaignObjectiveJournal?.toggle());
}
this.input.on(Phaser.Input.Events.POINTER_DOWN, (pointer: Phaser.Input.Pointer) => {
@@ -1407,6 +1431,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
!this.ready ||
this.navigationPending ||
this.npcMovementPending ||
this.campaignObjectiveJournal?.isOpen() ||
this.time.now < this.inputReadyAt
) {
return;
@@ -1432,6 +1457,16 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
});
}
private createCampaignObjectiveJournal() {
this.campaignObjectiveJournal = new CampaignObjectiveJournalOverlay(this, {
snapshotProvider: () =>
resolveCampaignObjectiveJournal(getCampaignState()),
canOpen: () => this.ready && !this.navigationPending,
buttonX: 1215,
buttonY: 21
});
}
private updateMovement(time: number, delta: number) {
if (!this.player) {
return;
@@ -2395,6 +2430,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
if (
!this.commandChoicePanel ||
this.navigationPending ||
this.campaignObjectiveJournal?.isOpen() ||
(!bypassInputGuard && this.time.now < this.commandChoiceReadyAt)
) {
return false;
@@ -2434,6 +2470,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
!this.commandChoicePanel ||
!this.pendingCommandOrderId ||
this.navigationPending ||
this.campaignObjectiveJournal?.isOpen() ||
(!bypassInputGuard && this.time.now < this.commandChoiceReadyAt)
) {
return false;
@@ -2461,6 +2498,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
if (
!this.commandChoicePanel ||
this.navigationPending ||
this.campaignObjectiveJournal?.isOpen() ||
this.time.now < this.commandChoiceReadyAt
) {
return false;

View File

@@ -35,8 +35,10 @@ import {
prologueVillageCampaignTutorialIds,
type CampaignTutorialId
} from '../state/campaignState';
import { resolveCampaignObjectiveJournal } from '../state/campaignObjectiveJournal';
import { releaseTextureSource } from '../render/loaderLifecycle';
import { isVisualMotionReduced } from '../settings/visualMotion';
import { CampaignObjectiveJournalOverlay } from '../ui/CampaignObjectiveJournalOverlay';
import { fitDialogueFacePortrait } from '../ui/dialoguePortrait';
import { palette } from '../ui/palette';
import { startGameScene } from './lazyScenes';
@@ -247,6 +249,7 @@ export class PrologueVillageScene extends Phaser.Scene {
private firstVisit = false;
private visualMotionReduced = false;
private ownedPresentationTextureKeys = new Set<string>();
private campaignObjectiveJournal?: CampaignObjectiveJournalOverlay;
constructor() {
super('PrologueVillageScene');
@@ -297,6 +300,8 @@ export class PrologueVillageScene extends Phaser.Scene {
this.clearNavigationTarget(true);
this.queuedMovementIntent = undefined;
this.dialogueState = undefined;
this.campaignObjectiveJournal?.destroy();
this.campaignObjectiveJournal = undefined;
this.stopAllNpcMovement();
releaseExplorationCharacterTextureKeys(this, characterTextureKeys);
this.releasePresentationTextures();
@@ -310,6 +315,7 @@ export class PrologueVillageScene extends Phaser.Scene {
this.loadingOverlay = undefined;
this.ready = true;
this.inputReadyAt = this.time.now + inputCarryoverGuardMs;
this.createCampaignObjectiveJournal();
this.refreshObjectiveHud();
this.refreshNpcMarkers();
this.refreshInteractionPrompt();
@@ -331,6 +337,12 @@ export class PrologueVillageScene extends Phaser.Scene {
return;
}
if (this.campaignObjectiveJournal?.isOpen()) {
this.interactionQueued = false;
this.stopPlayerMovement();
return;
}
if (this.oathGatherPending) {
this.interactionQueued = false;
this.stopPlayerMovement();
@@ -377,6 +389,10 @@ export class PrologueVillageScene extends Phaser.Scene {
visualMotionReduced: this.visualMotionReduced,
viewport: { width: this.scale.width, height: this.scale.height },
campaignStep: campaign.step,
campaignObjectiveJournal: {
snapshot: resolveCampaignObjectiveJournal(campaign),
view: this.campaignObjectiveJournal?.getDebugState() ?? null
},
background: {
key: this.backgroundImage?.texture.key ?? villageBackgroundKey,
ready: this.backgroundImage?.active === true,
@@ -577,6 +593,7 @@ export class PrologueVillageScene extends Phaser.Scene {
this.dialoguePortraitFrame = undefined;
this.dialoguePortrait = undefined;
this.dialogueState = undefined;
this.campaignObjectiveJournal = undefined;
this.moveTarget = undefined;
this.moveWaypoints = [];
this.moveTargetNpcId = undefined;
@@ -1187,6 +1204,14 @@ export class PrologueVillageScene extends Phaser.Scene {
this.interactionQueued = true;
});
});
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.J)
.on('down', () => this.campaignObjectiveJournal?.toggle());
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ESC)
.on('down', () => {
if (this.campaignObjectiveJournal?.isOpen()) {
this.campaignObjectiveJournal.close();
}
});
keyboard.addCapture([
Phaser.Input.Keyboard.KeyCodes.UP,
Phaser.Input.Keyboard.KeyCodes.DOWN,
@@ -1201,6 +1226,7 @@ export class PrologueVillageScene extends Phaser.Scene {
!this.ready ||
this.navigationPending ||
this.oathGatherPending ||
this.campaignObjectiveJournal?.isOpen() ||
this.time.now < this.inputReadyAt
) {
return;
@@ -1228,6 +1254,16 @@ export class PrologueVillageScene extends Phaser.Scene {
});
}
private createCampaignObjectiveJournal() {
this.campaignObjectiveJournal = new CampaignObjectiveJournalOverlay(this, {
snapshotProvider: () =>
resolveCampaignObjectiveJournal(getCampaignState()),
canOpen: () => this.ready && !this.navigationPending,
buttonX: 1215,
buttonY: 21
});
}
private updateMovement(time: number, delta: number) {
if (!this.player) {
return;

View File

@@ -57,8 +57,10 @@ import {
import { prologueOpeningPages } from '../data/prologueVillage';
import { type PortraitKey, type StoryPage } from '../data/scenario';
import { getCampaignState, getFirstBattleReport } from '../state/campaignState';
import { resolveCampaignObjectiveJournal } from '../state/campaignObjectiveJournal';
import { releaseTextureSource } from '../render/loaderLifecycle';
import { isVisualMotionReduced } from '../settings/visualMotion';
import { CampaignObjectiveJournalOverlay } from '../ui/CampaignObjectiveJournalOverlay';
import { fitDialogueFacePortrait } from '../ui/dialoguePortrait';
import { palette } from '../ui/palette';
import { ensureLazyScene, startGameScene } from './lazyScenes';
@@ -236,6 +238,7 @@ export class StoryScene extends Phaser.Scene {
private storyEnvironmentHaze: StoryEnvironmentHazeView[] = [];
private storyEnvironmentRandomState = 1;
private storyEnvironmentRevision = 0;
private campaignObjectiveJournal?: CampaignObjectiveJournalOverlay;
constructor() {
super('StoryScene');
@@ -290,6 +293,7 @@ export class StoryScene extends Phaser.Scene {
this.storyEnvironmentHaze = [];
this.storyEnvironmentRandomState = 1;
this.storyEnvironmentRevision = 0;
this.campaignObjectiveJournal = undefined;
}
getDebugState() {
@@ -309,6 +313,10 @@ export class StoryScene extends Phaser.Scene {
return {
scene: this.scene.key,
ready: this.sys.isActive() && this.loadingText === undefined && this.background?.active === true,
campaignObjectiveJournal: {
snapshot: resolveCampaignObjectiveJournal(getCampaignState()),
view: this.campaignObjectiveJournal?.getDebugState() ?? null
},
pageIndex: this.pageIndex,
totalPages: this.pages.length,
currentPageId: page?.id,
@@ -426,6 +434,8 @@ export class StoryScene extends Phaser.Scene {
this.activeStoryLoadErrorHandler = undefined;
}
this.pageLoadingText = undefined;
this.campaignObjectiveJournal?.destroy();
this.campaignObjectiveJournal = undefined;
this.clearStoryEnvironment();
this.releaseOwnedStoryTextures();
});
@@ -445,10 +455,33 @@ export class StoryScene extends Phaser.Scene {
this.drawSceneShade(width, height);
this.drawDialogPanel(width, height);
this.showPage(0, true);
this.campaignObjectiveJournal = new CampaignObjectiveJournalOverlay(
this,
{
snapshotProvider: () =>
resolveCampaignObjectiveJournal(getCampaignState()),
canOpen: () => !this.transitioning && !this.loadingText,
buttonX: 1215,
buttonY: 21
}
);
this.input.on('pointerdown', () => this.advance());
this.input.keyboard?.on('keydown-SPACE', () => this.advance());
this.input.keyboard?.on('keydown-ENTER', () => this.advance());
this.input.keyboard?.on(
'keydown-J',
(event: KeyboardEvent) => {
if (!event.repeat) {
this.campaignObjectiveJournal?.toggle();
}
}
);
this.input.keyboard?.on('keydown-ESC', () => {
if (this.campaignObjectiveJournal?.isOpen()) {
this.campaignObjectiveJournal.close();
}
});
}, true);
}
@@ -2082,7 +2115,7 @@ export class StoryScene extends Phaser.Scene {
}
private advance() {
if (this.transitioning) {
if (this.transitioning || this.campaignObjectiveJournal?.isOpen()) {
return;
}

View File

@@ -41,6 +41,7 @@ import {
startNewCampaign,
type CampaignSaveSlotSummary
} from '../state/campaignState';
import { resolveCampaignObjectiveJournal } from '../state/campaignObjectiveJournal';
import { palette } from '../ui/palette';
import { startLazyScene, type LazySceneKey } from './lazyScenes';
@@ -460,8 +461,8 @@ export class TitleScene extends Phaser.Scene {
return this.compactSaveDetail(`${Object.keys(campaign.battleHistory).length}전 완료 · ${campaign.roster.length}명 합류`);
}
const progress = summarizeCampaignProgress(campaign);
return this.compactSaveDetail(`${progress.meta} · ${progress.title}`);
const journal = resolveCampaignObjectiveJournal(campaign);
return this.compactSaveDetail(`${journal.current.title}`);
}
private compactSaveDetail(label: string) {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,942 @@
import type Phaser from 'phaser';
const viewportWidth = 1920;
const viewportHeight = 1080;
const overlayDepth = 5100;
const buttonDepth = 5001;
const panelWidth = 1120;
const panelHeight = 680;
const panelX = (viewportWidth - panelWidth) / 2;
const panelY = (viewportHeight - panelHeight) / 2;
const buttonWidth = 240;
const buttonHeight = 50;
const cardX = panelX + 44;
const cardWidth = panelWidth - 88;
const cardHeight = 140;
const fontFamily = '"Malgun Gothic", "Noto Sans KR", sans-serif';
type PointerEventData = {
stopPropagation: () => void;
};
export type CampaignObjectiveJournalStatus =
| 'current'
| 'completed'
| 'clue'
| 'locked'
| 'warning'
| 'neutral'
| (string & {});
export interface CampaignObjectiveJournalEntry {
status: CampaignObjectiveJournalStatus;
statusLabel: string;
title: string;
detail: string;
location?: string;
}
export interface CampaignObjectiveJournalProgress {
label?: string;
title?: string;
detail?: string;
statusLabel?: string;
current?: number;
completed?: number;
total?: number;
[key: string]: unknown;
}
export interface CampaignObjectiveJournalResumeContext {
title?: string;
summary?: string;
lines?: readonly string[];
detail?: string;
previousEvent?: string;
currentReason?: string;
[key: string]: unknown;
}
export interface CampaignObjectiveJournalSnapshot {
contextKind: string;
current: CampaignObjectiveJournalEntry | null;
recentCompleted: CampaignObjectiveJournalEntry | null;
nextClue: CampaignObjectiveJournalEntry | null;
progress?: CampaignObjectiveJournalProgress | string | number | null;
resumeContext?:
| CampaignObjectiveJournalResumeContext
| string
| null;
}
export interface CampaignObjectiveJournalOverlayOptions {
snapshotProvider: () => CampaignObjectiveJournalSnapshot;
canOpen?: boolean | (() => boolean);
buttonX?: number;
buttonY?: number;
}
export interface CampaignObjectiveJournalBounds {
x: number;
y: number;
width: number;
height: number;
right: number;
bottom: number;
}
export type CampaignObjectiveJournalCardKind =
| 'current'
| 'completed'
| 'nextClue';
export interface CampaignObjectiveJournalCardDebugState {
kind: CampaignObjectiveJournalCardKind;
label: string;
title: string;
body: string;
bounds: CampaignObjectiveJournalBounds;
titleBounds: CampaignObjectiveJournalBounds;
bodyBounds: CampaignObjectiveJournalBounds;
}
export interface CampaignObjectiveJournalDebugState {
visible: boolean;
shortcut: 'J';
objectCount: number;
buttonBounds: CampaignObjectiveJournalBounds;
panelBounds: CampaignObjectiveJournalBounds | null;
closeBounds: CampaignObjectiveJournalBounds | null;
cards: CampaignObjectiveJournalCardDebugState[];
visibleText: string[];
}
type CardPresentation = {
kind: CampaignObjectiveJournalCardKind;
label: string;
entry: CampaignObjectiveJournalEntry;
fill: number;
stroke: number;
labelColor: string;
};
type CardView = {
kind: CampaignObjectiveJournalCardKind;
label: string;
title: string;
body: string;
background: Phaser.GameObjects.Rectangle;
titleText: Phaser.GameObjects.Text;
bodyText: Phaser.GameObjects.Text;
};
const fallbackEntries: Record<
CampaignObjectiveJournalCardKind,
CampaignObjectiveJournalEntry
> = {
current: {
status: 'neutral',
statusLabel: '확인 중',
title: '현재 목표를 확인하는 중입니다',
detail: '장면을 이어 진행하면 즉시 할 일이 갱신됩니다.'
},
completed: {
status: 'neutral',
statusLabel: '기록 없음',
title: '아직 마친 기록이 없습니다',
detail: '첫 목표를 완료하면 가장 최근 기록이 이곳에 남습니다.'
},
nextClue: {
status: 'locked',
statusLabel: '미공개',
title: '다음 실마리는 아직 드러나지 않았습니다',
detail: '현재 목표를 진행하면 공개된 정보만 바탕으로 갱신됩니다.'
}
};
/**
* 1920×1080 데스크톱 장면에서 공통으로 사용하는 캠페인 목표 기록 오버레이입니다.
*
* 키 입력은 의도적으로 등록하지 않습니다. 소유 장면이 J/Esc 입력을 받아
* toggle/open/close를 호출해야 장면별 키 충돌과 모달 우선순위를 제어할 수 있습니다.
*/
export class CampaignObjectiveJournalOverlay {
private readonly scene: Phaser.Scene;
private readonly options: CampaignObjectiveJournalOverlayOptions;
private readonly buttonX: number;
private readonly buttonY: number;
private readonly buttonRoot: Phaser.GameObjects.Container;
private readonly buttonBackground: Phaser.GameObjects.Rectangle;
private readonly buttonLabel: Phaser.GameObjects.Text;
private overlayRoot?: Phaser.GameObjects.Container;
private panelBackground?: Phaser.GameObjects.Rectangle;
private closeBackground?: Phaser.GameObjects.Rectangle;
private overlayTexts: Phaser.GameObjects.Text[] = [];
private cardViews: CardView[] = [];
private destroyed = false;
private readonly handleSceneEnd = () => {
this.destroy();
};
constructor(
scene: Phaser.Scene,
options: CampaignObjectiveJournalOverlayOptions
) {
this.scene = scene;
this.options = options;
this.buttonX = options.buttonX ?? 1325;
this.buttonY = options.buttonY ?? 44;
this.buttonRoot = scene.add
.container(0, 0)
.setDepth(buttonDepth)
.setScrollFactor(0);
const buttonShadow = scene.add
.rectangle(
this.buttonX + 4,
this.buttonY + 5,
buttonWidth,
buttonHeight,
0x05080d,
0.62
)
.setOrigin(0);
this.buttonRoot.add(buttonShadow);
this.buttonBackground = scene.add
.rectangle(
this.buttonX,
this.buttonY,
buttonWidth,
buttonHeight,
0x162331,
0.97
)
.setOrigin(0)
.setStrokeStyle(2, 0xd8b15f, 0.92)
.setInteractive({ useHandCursor: true });
this.buttonRoot.add(this.buttonBackground);
const buttonAccent = scene.add
.rectangle(this.buttonX, this.buttonY, 7, buttonHeight, 0xd8b15f, 1)
.setOrigin(0);
this.buttonRoot.add(buttonAccent);
this.buttonLabel = scene.add
.text(
this.buttonX + buttonWidth / 2,
this.buttonY + buttonHeight / 2,
'J 진군 기록',
{
fontFamily,
fontSize: '21px',
color: '#f4e7c7',
fontStyle: 'bold',
align: 'center'
}
)
.setOrigin(0.5);
this.buttonRoot.add(this.buttonLabel);
this.buttonBackground.on('pointerover', () => {
const enabled = this.canOpen();
this.buttonBackground.setFillStyle(
enabled ? 0x24384b : 0x1a2028,
enabled ? 1 : 0.84
);
this.buttonLabel.setColor(enabled ? '#fff2cf' : '#8f969f');
});
this.buttonBackground.on('pointerout', () => {
this.refreshButtonPresentation();
});
this.buttonBackground.on(
'pointerdown',
(
_pointer: Phaser.Input.Pointer,
_localX: number,
_localY: number,
event: PointerEventData
) => {
event.stopPropagation();
if (this.canOpen()) {
this.toggle();
}
}
);
this.refreshButtonPresentation();
scene.events.once('shutdown', this.handleSceneEnd);
scene.events.once('destroy', this.handleSceneEnd);
}
toggle(): boolean {
if (this.isOpen()) {
this.close();
return false;
}
return this.open();
}
open(): boolean {
if (this.destroyed || this.isOpen() || !this.canOpen()) {
return this.isOpen();
}
const snapshot = this.options.snapshotProvider();
this.createOverlay(snapshot);
return true;
}
close(): boolean {
if (!this.overlayRoot) {
return false;
}
this.overlayRoot.destroy(true);
this.overlayRoot = undefined;
this.panelBackground = undefined;
this.closeBackground = undefined;
this.overlayTexts = [];
this.cardViews = [];
return true;
}
isOpen(): boolean {
return Boolean(
this.overlayRoot?.active &&
this.overlayRoot.visible &&
!this.destroyed
);
}
getDebugState(): CampaignObjectiveJournalDebugState {
const visible = this.isOpen();
const buttonBounds = this.buttonBackground.active
? snapshotBounds(this.buttonBackground.getBounds())
: boundsFromValues(
this.buttonX,
this.buttonY,
buttonWidth,
buttonHeight
);
const visibleText = [
this.buttonLabel,
...this.overlayTexts
]
.filter((text) => text.active && text.visible)
.map((text) => text.text);
return {
visible,
shortcut: 'J',
objectCount: visible
? 1 + this.cardViews.length
: 0,
buttonBounds,
panelBounds:
visible && this.panelBackground
? snapshotBounds(this.panelBackground.getBounds())
: null,
closeBounds:
visible && this.closeBackground
? snapshotBounds(this.closeBackground.getBounds())
: null,
cards: visible
? this.cardViews.map((card) => ({
kind: card.kind,
label: card.label,
title: card.title,
body: card.body,
bounds: snapshotBounds(card.background.getBounds()),
titleBounds: snapshotBounds(card.titleText.getBounds()),
bodyBounds: snapshotBounds(card.bodyText.getBounds())
}))
: [],
visibleText
};
}
destroy() {
if (this.destroyed) {
return;
}
this.destroyed = true;
this.scene.events.off('shutdown', this.handleSceneEnd);
this.scene.events.off('destroy', this.handleSceneEnd);
this.close();
this.buttonRoot.destroy(true);
}
private canOpen() {
const predicate = this.options.canOpen;
return typeof predicate === 'function'
? predicate()
: predicate ?? true;
}
private refreshButtonPresentation() {
if (!this.buttonBackground.active || !this.buttonLabel.active) {
return;
}
const enabled = this.canOpen();
this.buttonBackground.setFillStyle(
enabled ? 0x162331 : 0x1a2028,
enabled ? 0.97 : 0.78
);
this.buttonLabel.setColor(enabled ? '#f4e7c7' : '#8f969f');
this.buttonRoot.setAlpha(enabled ? 1 : 0.78);
}
private createOverlay(snapshot: CampaignObjectiveJournalSnapshot) {
const root = this.scene.add
.container(0, 0)
.setDepth(overlayDepth)
.setScrollFactor(0);
this.overlayRoot = root;
const blocker = this.scene.add
.rectangle(
0,
0,
viewportWidth,
viewportHeight,
0x03070c,
0.76
)
.setOrigin(0)
.setInteractive();
blocker.on(
'pointerdown',
(
_pointer: Phaser.Input.Pointer,
_localX: number,
_localY: number,
event: PointerEventData
) => event.stopPropagation()
);
blocker.on(
'wheel',
(
_pointer: Phaser.Input.Pointer,
_deltaX: number,
_deltaY: number,
_deltaZ: number,
event: PointerEventData
) => event.stopPropagation()
);
root.add(blocker);
const panelShadow = this.scene.add
.rectangle(
panelX + 12,
panelY + 16,
panelWidth,
panelHeight,
0x000000,
0.48
)
.setOrigin(0);
root.add(panelShadow);
const panel = this.scene.add
.rectangle(
panelX,
panelY,
panelWidth,
panelHeight,
0x111923,
0.995
)
.setOrigin(0)
.setStrokeStyle(3, 0xd8b15f, 0.92)
.setInteractive();
panel.on(
'pointerdown',
(
_pointer: Phaser.Input.Pointer,
_localX: number,
_localY: number,
event: PointerEventData
) => event.stopPropagation()
);
root.add(panel);
this.panelBackground = panel;
const topAccent = this.scene.add
.rectangle(panelX, panelY, panelWidth, 8, 0xd8b15f, 0.96)
.setOrigin(0);
root.add(topAccent);
this.addOverlayText(
root,
panelX + 44,
panelY + 28,
'진군 기록',
{
fontSize: '30px',
color: '#f4e7c7',
fontStyle: 'bold'
}
);
this.addOverlayText(
root,
panelX + 230,
panelY + 38,
`진행 맥락 · ${formatContextKind(snapshot.contextKind)}`,
{
fontSize: '18px',
color: '#aebccc'
}
);
const close = this.scene.add
.rectangle(
panelX + panelWidth - 140,
panelY + 24,
96,
44,
0x273442,
0.98
)
.setOrigin(0)
.setStrokeStyle(2, 0x8192a5, 0.82)
.setInteractive({ useHandCursor: true });
close.on('pointerover', () => close.setFillStyle(0x3a4a5b, 1));
close.on('pointerout', () => close.setFillStyle(0x273442, 0.98));
close.on(
'pointerdown',
(
_pointer: Phaser.Input.Pointer,
_localX: number,
_localY: number,
event: PointerEventData
) => {
event.stopPropagation();
this.close();
}
);
root.add(close);
this.closeBackground = close;
const closeLabel = this.addOverlayText(
root,
panelX + panelWidth - 92,
panelY + 46,
'닫기',
{
fontSize: '18px',
color: '#edf2f6',
fontStyle: 'bold',
align: 'center'
}
).setOrigin(0.5);
const progressText = formatProgress(snapshot.progress);
this.addOverlayText(
root,
panelX + 44,
panelY + 82,
progressText,
{
fontSize: '20px',
color: '#e4c77f',
fontStyle: 'bold'
}
);
const resumeText = formatResumeContext(snapshot.resumeContext);
if (resumeText) {
this.addOverlayText(
root,
panelX + 44,
panelY + 114,
`재개 맥락 · ${compactText(resumeText, 112)}`,
{
fontSize: '18px',
color: '#bcc7d2',
wordWrap: { width: panelWidth - 88, useAdvancedWrap: true },
maxLines: 2
}
);
}
const divider = this.scene.add
.rectangle(
panelX + 44,
panelY + 158,
panelWidth - 88,
2,
0x536276,
0.7
)
.setOrigin(0);
root.add(divider);
const cards: CardPresentation[] = [
{
kind: 'current',
label: '◆ 지금 할 일',
entry: snapshot.current ?? fallbackEntries.current,
fill: 0x1b2936,
stroke: 0xd8b15f,
labelColor: '#f0cf82'
},
{
kind: 'completed',
label: '✓ 마친 일',
entry:
snapshot.recentCompleted ?? fallbackEntries.completed,
fill: 0x182a28,
stroke: 0x70a978,
labelColor: '#9dd0a3'
},
{
kind: 'nextClue',
label: '? 다음 실마리',
entry: snapshot.nextClue ?? fallbackEntries.nextClue,
fill: 0x192532,
stroke: 0x6f92be,
labelColor: '#a9c6e9'
}
];
cards.forEach((card, index) => {
this.createCard(root, card, panelY + 174 + index * 154);
});
this.addOverlayText(
root,
panelX + 44,
panelY + panelHeight - 30,
'공개된 진행 정보만 기록됩니다 · 장면에서 J 또는 Esc로 닫기',
{
fontSize: '18px',
color: '#8f9cab'
}
).setOrigin(0, 1);
// The label is intentionally tracked even though it is non-interactive:
// browser QA can assert that the visible close affordance matches its hit box.
void closeLabel;
}
private createCard(
root: Phaser.GameObjects.Container,
card: CardPresentation,
y: number
) {
const background = this.scene.add
.rectangle(
cardX,
y,
cardWidth,
cardHeight,
card.fill,
0.98
)
.setOrigin(0)
.setStrokeStyle(2, card.stroke, 0.9);
root.add(background);
const accent = this.scene.add
.rectangle(cardX, y, 7, cardHeight, card.stroke, 1)
.setOrigin(0);
root.add(accent);
this.addOverlayText(
root,
cardX + 24,
y + 14,
card.label,
{
fontSize: '20px',
color: card.labelColor,
fontStyle: 'bold'
}
);
const locationSuffix = cleanText(card.entry.location);
const statusLabel =
cleanText(card.entry.statusLabel) ||
fallbackEntries[card.kind].statusLabel;
this.addOverlayText(
root,
cardX + cardWidth - 304,
y + 14,
compactText(
locationSuffix
? `${statusLabel} · ${locationSuffix}`
: statusLabel,
30
),
{
fontSize: '18px',
color: statusColor(card.entry.status, card.labelColor),
fontStyle: 'bold',
fixedWidth: 278,
align: 'right'
}
);
const title = compactText(
cleanText(card.entry.title) ||
fallbackEntries[card.kind].title,
52
);
const body = compactText(
cleanText(card.entry.detail) ||
fallbackEntries[card.kind].detail,
112
);
const titleText = this.addOverlayText(
root,
cardX + 24,
y + 48,
title,
{
fontSize: '24px',
color: '#f1eadb',
fontStyle: 'bold',
fixedWidth: cardWidth - 48,
maxLines: 1
}
);
const bodyText = this.addOverlayText(
root,
cardX + 24,
y + 84,
body,
{
fontSize: '18px',
color: '#c4ced7',
fixedWidth: cardWidth - 48,
wordWrap: {
width: cardWidth - 48,
useAdvancedWrap: true
},
maxLines: 2,
lineSpacing: 4
}
);
this.cardViews.push({
kind: card.kind,
label: card.label,
title,
body,
background,
titleText,
bodyText
});
}
private addOverlayText(
root: Phaser.GameObjects.Container,
x: number,
y: number,
value: string,
style: Phaser.Types.GameObjects.Text.TextStyle
) {
const text = this.scene.add.text(x, y, value, {
fontFamily,
...style
});
root.add(text);
this.overlayTexts.push(text);
return text;
}
}
function formatContextKind(contextKind: string) {
const normalized = cleanText(contextKind);
const labels: Record<string, string> = {
battle: '전장',
camp: '군영',
story: '이야기',
cityStay: '성 체류',
'city-stay': '성 체류',
'not-started': '여정 시작 전',
'village-exploration': '의용군 마을',
'militia-camp-exploration': '의용군 막사',
'battle-preparation': '출진 준비',
aftermath: '승리 후 이야기',
'interlude-exploration': '전투 사이 탐색',
prologueVillage: '의용군 마을',
'prologue-village': '의용군 마을',
prologueMilitiaCamp: '의용군 막사',
'prologue-militia-camp': '의용군 막사',
campVisit: '전투 사이 탐색',
'camp-visit': '전투 사이 탐색',
title: '저장 기록',
ending: '대단원'
};
return labels[normalized] ?? (normalized || '캠페인');
}
function formatProgress(
progress:
| CampaignObjectiveJournalProgress
| string
| number
| null
| undefined
) {
if (typeof progress === 'string') {
return `전장 진행 · ${compactText(progress, 72)}`;
}
if (typeof progress === 'number' && Number.isFinite(progress)) {
return `전장 진행 · ${progress}`;
}
if (!progress || typeof progress !== 'object') {
return '전장 진행 · 기록 확인 중';
}
const label =
firstText(
progress.label,
progress.title,
progress.statusLabel
) ?? '전장 진행';
const current = firstFiniteNumber(
progress.current,
progress.completed,
progress.completedBattles,
progress.battle
);
const total = firstFiniteNumber(
progress.total,
progress.max,
progress.totalBattles
);
const detail = firstText(progress.detail);
if (current !== undefined && total !== undefined) {
return `${compactText(label, 44)} · ${current} / ${total}`;
}
if (current !== undefined) {
return `${compactText(label, 54)} · ${current}`;
}
if (detail) {
return `${compactText(label, 34)} · ${compactText(detail, 52)}`;
}
return compactText(label, 72);
}
function formatResumeContext(
context:
| CampaignObjectiveJournalResumeContext
| string
| null
| undefined
) {
if (typeof context === 'string') {
return cleanText(context);
}
if (!context || typeof context !== 'object') {
return '';
}
const parts = [
firstText(context.title),
firstText(context.summary),
firstText(context.detail),
firstText(context.previousEvent)
? `지난 기록 ${firstText(context.previousEvent)}`
: undefined,
firstText(context.currentReason)
? `현재 ${firstText(context.currentReason)}`
: undefined
].filter((part): part is string => Boolean(part));
return Array.from(new Set(parts)).join(' · ');
}
function statusColor(
status: CampaignObjectiveJournalStatus,
fallbackColor: string
) {
switch (status) {
case 'completed':
return '#a8d6ad';
case 'warning':
return '#f0b27f';
case 'locked':
return '#a7b0bb';
case 'current':
return '#f0cf82';
case 'clue':
return '#adc9e8';
default:
return fallbackColor;
}
}
function firstText(...values: unknown[]) {
for (const value of values) {
const text = cleanText(value);
if (text) {
return text;
}
}
return undefined;
}
function firstFiniteNumber(...values: unknown[]) {
for (const value of values) {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
}
return undefined;
}
function cleanText(value: unknown) {
return typeof value === 'string'
? value.replace(/\s+/g, ' ').trim()
: '';
}
function compactText(value: string, limit: number) {
const text = cleanText(value);
if (text.length <= limit) {
return text;
}
return `${text.slice(0, Math.max(1, limit - 1)).trimEnd()}`;
}
function snapshotBounds(
bounds: Phaser.Geom.Rectangle
): CampaignObjectiveJournalBounds {
return boundsFromValues(
bounds.x,
bounds.y,
bounds.width,
bounds.height
);
}
function boundsFromValues(
x: number,
y: number,
width: number,
height: number
): CampaignObjectiveJournalBounds {
return {
x,
y,
width,
height,
right: x + width,
bottom: y + height
};
}