feat: add spoiler-safe campaign objective journal
This commit is contained in:
@@ -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) => ({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
1072
src/game/state/campaignObjectiveJournal.ts
Normal file
1072
src/game/state/campaignObjectiveJournal.ts
Normal file
File diff suppressed because it is too large
Load Diff
942
src/game/ui/CampaignObjectiveJournalOverlay.ts
Normal file
942
src/game/ui/CampaignObjectiveJournalOverlay.ts
Normal 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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user