feat: guide early stays and remember player choices

This commit is contained in:
2026-07-27 19:41:10 +09:00
parent eec4896002
commit c22f77b4e1
11 changed files with 2121 additions and 93 deletions

View File

@@ -0,0 +1,353 @@
import type { StoryPage } from './scenario';
import {
normalizeXuzhouStayBattlePayoffSnapshot,
resolveXuzhouStayBattlePayoff,
xuzhouStayBattlePayoffTargetBattleId,
xuzhouStayMiZhuUnitId,
xuzhouStayShareStorehouseDutyChoiceId,
xuzhouStayTrustMiZhuLedgerChoiceId,
type XuzhouStayBattlePayoffCampaignState,
type XuzhouStayBattlePayoffChoiceId,
type XuzhouStayBattlePayoffSnapshot
} from './xuzhouStayBattlePayoff';
export const xuzhouStayNarrativeIntroPageId =
'eighth-xuzhou-stay-memory-intro' as const;
export const xuzhouStayNarrativeAftermathPageId =
'eighth-xuzhou-stay-memory-aftermath' as const;
const xuzhouStayNarrativeIntroAnchorPageId =
'eighth-xiaopei-road';
const xuzhouStayNarrativeAftermathAnchorPageId =
'eighth-victory-supply-road';
export type XuzhouStayNarrativeStage = 'story' | 'aftermath';
export type XuzhouStayNarrativeMiZhuStatus =
| 'deployed'
| 'not-deployed'
| 'retreated';
export type XuzhouStayNarrativeSupportOutcome =
| 'used'
| 'unused'
| 'unavailable';
export type XuzhouStayNarrativeHistoricalUnit = {
unitId?: unknown;
hp?: unknown;
};
export type XuzhouStayNarrativeHistoricalReport = {
battleId?: unknown;
outcome?: unknown;
units?: readonly XuzhouStayNarrativeHistoricalUnit[];
xuzhouStayBattlePayoff?: unknown;
};
export type XuzhouStayNarrativeMemory = {
source: 'campaign' | 'historical-payoff';
stage: XuzhouStayNarrativeStage;
battleId: typeof xuzhouStayBattlePayoffTargetBattleId;
informationCompleted: boolean;
choiceId: XuzhouStayBattlePayoffChoiceId | null;
informationCounterplays: number | null;
miZhuStatus: XuzhouStayNarrativeMiZhuStatus | null;
supportOutcome: XuzhouStayNarrativeSupportOutcome | null;
bonusHealing: number;
bonusBuffTurns: number;
appliedPageIds: string[];
};
export type XuzhouStayNarrativeResolution = {
pages: StoryPage[];
memory?: XuzhouStayNarrativeMemory;
};
export type XuzhouStayNarrativeResolutionOptions = {
battleId?: unknown;
stage: XuzhouStayNarrativeStage;
campaign?: XuzhouStayBattlePayoffCampaignState | null;
historicalReport?: XuzhouStayNarrativeHistoricalReport | null;
};
export function resolveXuzhouStayNarrativeMemory(
sourcePages: readonly StoryPage[],
options: XuzhouStayNarrativeResolutionOptions
): XuzhouStayNarrativeResolution {
const pages = sourcePages.map((page) => ({ ...page }));
if (
options.battleId !==
xuzhouStayBattlePayoffTargetBattleId
) {
return { pages };
}
return options.stage === 'story'
? resolveIntroNarrative(pages, options.campaign)
: resolveAftermathNarrative(
pages,
options.historicalReport
);
}
function resolveIntroNarrative(
pages: StoryPage[],
campaign?: XuzhouStayBattlePayoffCampaignState | null
): XuzhouStayNarrativeResolution {
const payoff = resolveXuzhouStayBattlePayoff(
campaign,
xuzhouStayBattlePayoffTargetBattleId
);
if (!payoff) {
return { pages };
}
const page = introPage(
payoff.informationCompleted,
payoff.choiceId
);
if (
!insertAfter(
pages,
xuzhouStayNarrativeIntroAnchorPageId,
page
)
) {
return { pages };
}
return {
pages,
memory: {
source: 'campaign',
stage: 'story',
battleId: xuzhouStayBattlePayoffTargetBattleId,
informationCompleted: payoff.informationCompleted,
choiceId: payoff.choiceId ?? null,
informationCounterplays: null,
miZhuStatus: null,
supportOutcome: null,
bonusHealing: 0,
bonusBuffTurns: 0,
appliedPageIds: [xuzhouStayNarrativeIntroPageId]
}
};
}
function resolveAftermathNarrative(
pages: StoryPage[],
report?: XuzhouStayNarrativeHistoricalReport | null
): XuzhouStayNarrativeResolution {
if (
report?.battleId !==
xuzhouStayBattlePayoffTargetBattleId ||
report.outcome !== 'victory'
) {
return { pages };
}
const payoff = normalizeXuzhouStayBattlePayoffSnapshot(
report.xuzhouStayBattlePayoff,
{
expectedBattleId:
xuzhouStayBattlePayoffTargetBattleId
}
);
if (!payoff) {
return { pages };
}
const miZhuStatus = historicalMiZhuStatus(payoff, report);
const supportOutcome = historicalSupportOutcome(
payoff,
miZhuStatus
);
const page = aftermathPage(
payoff,
miZhuStatus,
supportOutcome
);
if (
!insertAfter(
pages,
xuzhouStayNarrativeAftermathAnchorPageId,
page
)
) {
return { pages };
}
return {
pages,
memory: {
source: 'historical-payoff',
stage: 'aftermath',
battleId: xuzhouStayBattlePayoffTargetBattleId,
informationCompleted: payoff.informationCompleted,
choiceId: payoff.choiceId ?? null,
informationCounterplays:
payoff.informationCounterplays,
miZhuStatus: payoff.choiceId ? miZhuStatus : null,
supportOutcome: payoff.choiceId
? supportOutcome
: null,
bonusHealing: payoff.bonusHealing,
bonusBuffTurns: payoff.bonusBuffTurns,
appliedPageIds: [
xuzhouStayNarrativeAftermathPageId
]
}
};
}
function introPage(
informationCompleted: boolean,
choiceId?: XuzhouStayBattlePayoffChoiceId
): StoryPage {
const informationLine = informationCompleted
? '서주에서 함께 살핀 장부 덕분에 적이 노릴 길목을 미리 짚을 수 있습니다.'
: '';
const choiceLine =
choiceId === xuzhouStayShareStorehouseDutyChoiceId
? '창고 당번을 함께 나누기로 한 뜻대로, 저도 전열에서 다친 이들을 먼저 돌보겠습니다.'
: choiceId ===
xuzhouStayTrustMiZhuLedgerChoiceId
? '장부를 제게 맡겨 주신 뜻대로, 전열이 흔들릴 때 병사들을 독려하겠습니다.'
: '장부에서 확인한 움직임을 출진 장수들에게 빠짐없이 전하겠습니다.';
return {
id: xuzhouStayNarrativeIntroPageId,
bgm: 'battle-prep',
chapter: '서주에서 세운 방침',
background: 'story-xuzhou',
speaker: '미축',
portrait: 'mi-zhu',
text: [informationLine, choiceLine]
.filter(Boolean)
.join(' ')
};
}
function aftermathPage(
payoff: XuzhouStayBattlePayoffSnapshot,
miZhuStatus: XuzhouStayNarrativeMiZhuStatus,
supportOutcome: XuzhouStayNarrativeSupportOutcome
): StoryPage {
const informationLine = payoff.informationCompleted
? payoff.informationCounterplays > 0
? `서주 장부에서 짚은 움직임 ${payoff.informationCounterplays}곳을 파훼해 보급로의 빈틈을 막았습니다.`
: '서주 장부를 전장에 가져왔지만, 짚어 둔 움직임을 직접 파훼할 기회는 없었습니다.'
: '';
const supportLine = payoff.choiceId
? supportResultLine(payoff, miZhuStatus, supportOutcome)
: '';
return {
id: xuzhouStayNarrativeAftermathPageId,
bgm: 'militia-theme',
chapter: '되살아난 서주의 약속',
background: 'story-xuzhou',
speaker: '미축',
portrait: 'mi-zhu',
text: [informationLine, supportLine]
.filter(Boolean)
.join(' ')
};
}
function supportResultLine(
payoff: XuzhouStayBattlePayoffSnapshot,
miZhuStatus: XuzhouStayNarrativeMiZhuStatus,
supportOutcome: XuzhouStayNarrativeSupportOutcome
) {
if (miZhuStatus === 'not-deployed') {
return '저는 이번 출전에 함께하지 못해, 서주에서 정한 방침을 전장에 보태지 못했습니다.';
}
if (
miZhuStatus === 'retreated' &&
supportOutcome !== 'used'
) {
return '제가 전장에서 물러나는 바람에, 서주에서 정한 방침을 끝까지 펼치지 못했습니다.';
}
if (
payoff.choiceId ===
xuzhouStayShareStorehouseDutyChoiceId
) {
if (supportOutcome === 'used') {
const retreatLine =
miZhuStatus === 'retreated'
? ' 비록 뒤에는 물러났지만,'
: '';
return `공동 구휼의 약속으로 응급 회복을 ${payoff.bonusHealing}만큼 더 보탰습니다.${retreatLine} 백성과 병사가 함께 창고를 지킨 뜻은 남았습니다.`;
}
return '공동 구휼의 방침을 쓸 기회는 없었지만, 다음 보급에도 백성과 병사의 몫을 함께 살피겠습니다.';
}
if (
payoff.choiceId ===
xuzhouStayTrustMiZhuLedgerChoiceId
) {
if (supportOutcome === 'used') {
const retreatLine =
miZhuStatus === 'retreated'
? ' 비록 뒤에는 물러났지만,'
: '';
return `맡겨 주신 장부로 격려의 기세를 ${payoff.bonusBuffTurns}턴 더 이어 냈습니다.${retreatLine} 믿고 맡긴 뜻에 보답할 수 있었습니다.`;
}
return '장부 위임의 방침을 쓸 기회는 없었지만, 다음에는 전열의 흔들림을 더 일찍 살피겠습니다.';
}
return '';
}
function historicalMiZhuStatus(
payoff: XuzhouStayBattlePayoffSnapshot,
report: XuzhouStayNarrativeHistoricalReport
): XuzhouStayNarrativeMiZhuStatus {
if (!payoff.miZhuDeployed) {
return 'not-deployed';
}
const unit = Array.isArray(report.units)
? report.units.find(
(candidate) =>
candidate?.unitId === xuzhouStayMiZhuUnitId
)
: undefined;
return unit && normalizedHp(unit.hp) <= 0
? 'retreated'
: 'deployed';
}
function historicalSupportOutcome(
payoff: XuzhouStayBattlePayoffSnapshot,
miZhuStatus: XuzhouStayNarrativeMiZhuStatus
): XuzhouStayNarrativeSupportOutcome {
if (miZhuStatus === 'not-deployed') {
return 'unavailable';
}
return payoff.supportUses > 0 ? 'used' : 'unused';
}
function normalizedHp(value: unknown) {
return typeof value === 'number' && Number.isFinite(value)
? Math.max(0, value)
: 1;
}
function insertAfter(
pages: StoryPage[],
anchorPageId: string,
page: StoryPage
) {
if (pages.some((candidate) => candidate.id === page.id)) {
return false;
}
const anchorIndex = pages.findIndex(
(candidate) => candidate.id === anchorPageId
);
if (
anchorIndex < 0 ||
anchorIndex >= pages.length - 1
) {
return false;
}
pages.splice(anchorIndex + 1, 0, page);
return true;
}

View File

@@ -1673,6 +1673,33 @@ type TurnEndPromptOptions = {
secondaryLabel?: string;
primaryAction?: () => void;
secondaryAction?: () => void;
initialFocus?: TurnPromptActionId;
cancelActionId?: TurnPromptActionId;
primaryMeaning?: TurnPromptActionMeaning;
secondaryMeaning?: TurnPromptActionMeaning;
primaryDangerous?: boolean;
secondaryDangerous?: boolean;
};
type TurnPromptActionId = 'primary' | 'secondary';
type TurnPromptActionMeaning =
| 'end-turn'
| 'review-battlefield'
| 'continue-command'
| 'abandon-unacted-and-end'
| 'overwrite-save'
| 'load-save'
| 'cancel';
type TurnPromptButtonView = {
id: TurnPromptActionId;
label: string;
meaning: TurnPromptActionMeaning;
dangerous: boolean;
background: Phaser.GameObjects.Rectangle;
text: Phaser.GameObjects.Text;
hint: Phaser.GameObjects.Text;
};
type DeploymentSlot = {
@@ -3857,6 +3884,11 @@ export class BattleScene extends Phaser.Scene {
private turnPromptMode?: TurnPromptMode;
private turnPromptPrimaryAction?: () => void;
private turnPromptSecondaryAction?: () => void;
private turnPromptTitle?: string;
private turnPromptBody?: string;
private turnPromptFocusedAction?: TurnPromptActionId;
private turnPromptCancelActionId?: TurnPromptActionId;
private turnPromptButtonViews: TurnPromptButtonView[] = [];
private mapResultPopupObjects: Phaser.GameObjects.Text[] = [];
private mapResultPopupPeakCount = 0;
private mapResultPopupLast?: {
@@ -4051,6 +4083,11 @@ export class BattleScene extends Phaser.Scene {
this.turnPromptMode = undefined;
this.turnPromptPrimaryAction = undefined;
this.turnPromptSecondaryAction = undefined;
this.turnPromptTitle = undefined;
this.turnPromptBody = undefined;
this.turnPromptFocusedAction = undefined;
this.turnPromptCancelActionId = undefined;
this.turnPromptButtonViews = [];
this.turnText = undefined;
this.battleTitleText = undefined;
this.objectiveTrackerText = undefined;
@@ -4315,7 +4352,7 @@ export class BattleScene extends Phaser.Scene {
return;
}
if (this.turnPromptObjects.length > 0) {
this.activateTurnPromptPrimaryAction();
this.activateTurnPromptFocusedAction();
return;
}
@@ -4351,7 +4388,7 @@ export class BattleScene extends Phaser.Scene {
}
if (this.turnPromptObjects.length > 0) {
this.activateTurnPromptSecondaryAction();
this.activateTurnPromptCancelAction();
return;
}
@@ -4403,6 +4440,9 @@ export class BattleScene extends Phaser.Scene {
if (this.battleEventObjects.length > 0) {
return;
}
if (this.handleTurnPromptNavigationKey(event)) {
return;
}
if (this.handleSaveSlotPanelKey(event)) {
return;
}
@@ -10444,6 +10484,13 @@ export class BattleScene extends Phaser.Scene {
if (!this.enemyIntentForecastEnabled()) {
return '아군 턴을 종료하시겠습니까?';
}
return `${this.enemyIntentTurnEndSummary()}\n이대로 턴을 종료할까요?`;
}
private enemyIntentTurnEndSummary() {
if (!this.enemyIntentForecastEnabled()) {
return '적 예상 정보 없음';
}
const groups = new Map<string, { target: UnitData; immediate: number; approach: number }>();
const plans = this.enemyIntentForecasts.size > 0
? Array.from(this.enemyIntentForecasts.values())
@@ -10460,13 +10507,16 @@ export class BattleScene extends Phaser.Scene {
}
groups.set(plan.target.id, group);
});
const summaries = Array.from(groups.values())
.sort((left, right) => right.immediate - left.immediate || right.approach - left.approach)
.slice(0, 2)
.map((group) => `${group.target.name}${group.immediate}·추${group.approach}`);
const sortedGroups = Array.from(groups.values())
.sort((left, right) => right.immediate - left.immediate || right.approach - left.approach);
const visibleGroups = sortedGroups.slice(0, 2);
const omittedCount = Math.max(0, sortedGroups.length - visibleGroups.length);
const summaries = visibleGroups.map(
(group) => `${group.target.name}${group.immediate}·추${group.approach}`
);
return summaries.length > 0
? `적 예상 · ${summaries.join(' · ')}\n이대로 턴을 종료할까요?`
: '적 예상 · 즉시 공격 없음\n이대로 턴을 종료할까요?';
? `적 예상 · ${summaries.join(' · ')}${omittedCount > 0 ? ` · 외 ${omittedCount}` : ''}`
: '적 예상 · 즉시 공격 없음';
}
private enemyIntentTargetCounts(unitId: string) {
@@ -20495,12 +20545,7 @@ export class BattleScene extends Phaser.Scene {
}
if (action === 'endTurn') {
this.showTurnEndPrompt(undefined, {
title: '턴 종료 확인',
body: this.enemyIntentTurnEndBody(),
primaryLabel: '턴 종료',
secondaryLabel: '계속 지휘'
});
this.showManualTurnEndPrompt();
return;
}
if (action === 'threat') {
@@ -20544,6 +20589,38 @@ export class BattleScene extends Phaser.Scene {
}
}
private showManualTurnEndPrompt() {
const unactedAllies = this.actionableAllies();
if (unactedAllies.length === 0) {
this.showTurnEndPrompt(undefined, {
title: '턴 종료 확인',
body: this.enemyIntentTurnEndBody(),
primaryLabel: '턴 종료',
secondaryLabel: '계속 지휘',
primaryMeaning: 'end-turn',
secondaryMeaning: 'continue-command'
});
return;
}
const visibleNames = unactedAllies.slice(0, 4).map((unit) => unit.name);
const omittedNames = Math.max(0, unactedAllies.length - visibleNames.length);
const unactedNames = `${visibleNames.join(' · ')}${omittedNames > 0 ? `${omittedNames}` : ''}`;
this.showTurnEndPrompt(undefined, {
title: `미행동 ${unactedAllies.length}명 · 턴 종료 확인`,
body: `미행동 장수 · ${unactedNames}\n${this.enemyIntentTurnEndSummary()}\n행동을 포기하고 턴을 종료할까요?`,
primaryLabel: '계속 지휘',
secondaryLabel: '미행동 포기 후 종료',
primaryAction: () => this.hideTurnEndPrompt(),
secondaryAction: () => this.endAllyTurn(),
initialFocus: 'primary',
cancelActionId: 'primary',
primaryMeaning: 'continue-command',
secondaryMeaning: 'abandon-unacted-and-end',
secondaryDangerous: true
});
}
private hideMapMenu() {
this.mapMenuObjects.forEach((object) => object.destroy());
this.mapMenuObjects = [];
@@ -27760,6 +27837,32 @@ export class BattleScene extends Phaser.Scene {
private showTurnEndPrompt(message?: string, options: TurnEndPromptOptions = {}) {
this.hideTurnEndPrompt();
this.turnPromptMode = options.mode ?? 'turn-end';
const promptTitle = options.title ?? '모든 아군의 행동이 종료되었습니다.';
const promptBody = options.body ?? this.enemyIntentTurnEndBody();
const primaryLabel = options.primaryLabel ?? '턴 종료';
const secondaryLabel = options.secondaryLabel ?? '전장 확인';
const primaryAction = options.primaryAction ?? (() => this.endAllyTurn());
const secondaryAction = options.secondaryAction ?? (() => this.hideTurnEndPrompt());
const primaryMeaning = options.primaryMeaning ?? (
this.turnPromptMode === 'save-overwrite-confirm'
? 'overwrite-save'
: this.turnPromptMode === 'load-confirm'
? 'load-save'
: 'end-turn'
);
const secondaryMeaning = options.secondaryMeaning ?? (
this.turnPromptMode === 'turn-end' ? 'review-battlefield' : 'cancel'
);
this.turnPromptTitle = promptTitle;
this.turnPromptBody = promptBody;
this.turnPromptPrimaryAction = primaryAction;
this.turnPromptSecondaryAction = secondaryAction;
this.turnPromptFocusedAction = options.initialFocus ?? 'primary';
this.turnPromptCancelActionId = options.cancelActionId ?? 'secondary';
const expandedPrompt = promptBody.split('\n').length >= 3;
const promptHeight = expandedPrompt ? 202 : 170;
const buttonY = promptHeight - 46;
const hintY = promptHeight - 19;
const shade = this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x03070b, 0.46);
shade.setOrigin(0);
@@ -27777,7 +27880,7 @@ export class BattleScene extends Phaser.Scene {
this.turnPromptObjects.push(shade);
const width = this.battleUiLength(388);
const height = this.battleUiLength(162);
const height = this.battleUiLength(promptHeight);
const left = this.layout.mapX + this.layout.mapWidth / 2 - width / 2;
const top = this.layout.mapY + this.layout.mapHeight / 2 - height / 2;
const panel = this.add.rectangle(left, top, width, height, 0x101821, 0.96);
@@ -27786,7 +27889,7 @@ export class BattleScene extends Phaser.Scene {
panel.setStrokeStyle(this.battleUiLength(2), palette.gold, 0.9);
this.turnPromptObjects.push(panel);
const title = this.add.text(left + width / 2, top + this.battleUiLength(26), options.title ?? '모든 아군의 행동이 종료되었습니다.', {
const title = this.add.text(left + width / 2, top + this.battleUiLength(26), promptTitle, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: this.battleUiFontSize(20),
color: '#f2e3bf',
@@ -27796,79 +27899,136 @@ export class BattleScene extends Phaser.Scene {
title.setDepth(41);
this.turnPromptObjects.push(title);
const body = this.add.text(left + width / 2, top + this.battleUiLength(58), options.body ?? this.enemyIntentTurnEndBody(), {
const body = this.add.text(left + width / 2, top + this.battleUiLength(52), promptBody, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: this.battleUiFontSize(17),
color: '#d4dce6',
align: 'center',
wordWrap: { width: width - this.battleUiLength(44), useAdvancedWrap: true }
});
body.setOrigin(0.5);
body.setOrigin(0.5, 0);
body.setDepth(41);
this.turnPromptObjects.push(body);
const primaryLabel = options.primaryLabel ?? '턴 종료';
const secondaryLabel = options.secondaryLabel ?? '전장 확인';
const primaryAction = options.primaryAction ?? (() => this.endAllyTurn());
const secondaryAction = options.secondaryAction ?? (() => this.hideTurnEndPrompt());
this.turnPromptPrimaryAction = primaryAction;
this.turnPromptSecondaryAction = secondaryAction;
const buttonWidth = this.battleUiLength(136);
const buttons = [
{ label: primaryLabel, keyHint: 'Enter', x: left + this.battleUiLength(119), primary: true },
{ label: secondaryLabel, keyHint: 'Esc', x: left + this.battleUiLength(269), primary: false }
{
id: 'primary' as const,
label: primaryLabel,
meaning: primaryMeaning,
dangerous: options.primaryDangerous ?? false,
x: left + this.battleUiLength(119)
},
{
id: 'secondary' as const,
label: secondaryLabel,
meaning: secondaryMeaning,
dangerous: options.secondaryDangerous ?? false,
x: left + this.battleUiLength(269)
}
];
buttons.forEach(({ label, keyHint, x, primary }) => {
const bg = this.add.rectangle(x, top + this.battleUiLength(116), buttonWidth, this.battleUiLength(36), 0x1a2630, 0.95);
buttons.forEach(({ id, label, meaning, dangerous, x }) => {
const bg = this.add.rectangle(x, top + this.battleUiLength(buttonY), buttonWidth, this.battleUiLength(36), 0x1a2630, 0.95);
bg.setDepth(41);
bg.setStrokeStyle(this.battleUiLength(1), primary ? palette.gold : palette.blue, 0.72);
bg.setInteractive({ useHandCursor: true });
bg.on('pointerover', () => bg.setFillStyle(0x283947, 0.98));
bg.on('pointerout', () => bg.setFillStyle(0x1a2630, 0.95));
bg.on('pointerdown', () => (primary ? this.activateTurnPromptPrimaryAction(true) : this.activateTurnPromptSecondaryAction(true)));
bg.on('pointerover', () => this.focusTurnPromptAction(id));
bg.on('pointerdown', () => this.activateTurnPromptAction(id, true));
this.turnPromptObjects.push(bg);
const text = this.add.text(x, top + this.battleUiLength(116), label, {
const text = this.add.text(x, top + this.battleUiLength(buttonY), label, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: this.battleUiFontSize(16),
fontSize: this.battleUiFontSize(label.length > 9 ? 13 : 16),
color: '#f2e3bf',
fontStyle: '700'
});
text.setOrigin(0.5);
text.setDepth(42);
text.setInteractive({ useHandCursor: true });
text.on('pointerdown', () => (primary ? this.activateTurnPromptPrimaryAction(true) : this.activateTurnPromptSecondaryAction(true)));
text.on('pointerover', () => this.focusTurnPromptAction(id));
text.on('pointerdown', () => this.activateTurnPromptAction(id, true));
this.turnPromptObjects.push(text);
const hint = this.add.text(x, top + this.battleUiLength(143), keyHint, {
const hint = this.add.text(x, top + this.battleUiLength(hintY), '', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: this.battleUiFontSize(10),
color: primary ? '#cdbb8a' : '#91a7bd',
color: '#91a7bd',
fontStyle: '700'
});
hint.setOrigin(0.5);
hint.setDepth(42);
this.turnPromptObjects.push(hint);
this.turnPromptButtonViews.push({ id, label, meaning, dangerous, background: bg, text, hint });
});
this.renderTurnPromptFocus();
void message;
}
private activateTurnPromptPrimaryAction(fromPointer = false) {
if (!this.turnPromptPrimaryAction) {
return;
private handleTurnPromptNavigationKey(event: KeyboardEvent) {
if (this.turnPromptObjects.length === 0) {
return false;
}
const requestedFocus =
event.code === 'ArrowLeft'
? 'primary'
: event.code === 'ArrowRight'
? 'secondary'
: event.code === 'Tab'
? this.turnPromptFocusedAction === 'primary' && !event.shiftKey
? 'secondary'
: 'primary'
: undefined;
if (!requestedFocus) {
return false;
}
if (fromPointer) {
this.suppressNextLeftClick = true;
}
event.preventDefault();
this.focusTurnPromptAction(requestedFocus);
soundDirector.playSelect();
this.turnPromptPrimaryAction();
return true;
}
private activateTurnPromptSecondaryAction(fromPointer = false) {
const action = this.turnPromptSecondaryAction ?? (() => this.hideTurnEndPrompt());
private focusTurnPromptAction(actionId: TurnPromptActionId) {
if (this.turnPromptObjects.length === 0 || this.turnPromptFocusedAction === actionId) {
return;
}
this.turnPromptFocusedAction = actionId;
this.renderTurnPromptFocus();
}
private renderTurnPromptFocus() {
this.turnPromptButtonViews.forEach((view) => {
const focused = this.turnPromptFocusedAction === view.id;
const cancel = this.turnPromptCancelActionId === view.id;
const tone = view.dangerous ? 0xd46a54 : view.id === 'primary' ? palette.gold : palette.blue;
view.background.setFillStyle(
focused ? (view.dangerous ? 0x4a2320 : 0x283947) : 0x1a2630,
focused ? 0.99 : 0.95
);
view.background.setStrokeStyle(this.battleUiLength(focused ? 2 : 1), tone, focused ? 1 : 0.72);
view.text.setColor(focused ? '#fff8df' : '#d4dce6');
view.hint
.setText(focused ? (cancel ? 'Enter · Esc' : 'Enter') : cancel ? 'Esc' : '←/→ · Tab')
.setColor(view.dangerous ? '#ffb6a6' : cancel ? '#cdbb8a' : '#91a7bd');
});
}
private activateTurnPromptFocusedAction() {
this.activateTurnPromptAction(this.turnPromptFocusedAction ?? 'primary');
}
private activateTurnPromptCancelAction() {
this.activateTurnPromptAction(this.turnPromptCancelActionId ?? 'secondary');
}
private activateTurnPromptAction(actionId: TurnPromptActionId, fromPointer = false) {
const action = actionId === 'primary'
? this.turnPromptPrimaryAction
: this.turnPromptSecondaryAction;
if (!action) {
return;
}
if (fromPointer) {
this.suppressNextLeftClick = true;
}
@@ -27876,12 +28036,25 @@ export class BattleScene extends Phaser.Scene {
action();
}
private activateTurnPromptPrimaryAction(fromPointer = false) {
this.activateTurnPromptAction('primary', fromPointer);
}
private activateTurnPromptSecondaryAction(fromPointer = false) {
this.activateTurnPromptAction('secondary', fromPointer);
}
private hideTurnEndPrompt() {
this.turnPromptObjects.forEach((object) => object.destroy());
this.turnPromptObjects = [];
this.turnPromptMode = undefined;
this.turnPromptPrimaryAction = undefined;
this.turnPromptSecondaryAction = undefined;
this.turnPromptTitle = undefined;
this.turnPromptBody = undefined;
this.turnPromptFocusedAction = undefined;
this.turnPromptCancelActionId = undefined;
this.turnPromptButtonViews = [];
}
private pushBattleLog(message: string) {
@@ -31821,6 +31994,23 @@ export class BattleScene extends Phaser.Scene {
: null,
turnPromptVisible: this.turnPromptObjects.length > 0,
turnPromptMode: this.turnPromptMode ?? null,
turnPrompt: this.turnPromptObjects.length > 0
? {
mode: this.turnPromptMode ?? null,
title: this.turnPromptTitle ?? '',
body: this.turnPromptBody ?? '',
focusedAction: this.turnPromptFocusedAction ?? null,
cancelAction: this.turnPromptCancelActionId ?? null,
buttons: this.turnPromptButtonViews.map((view) => ({
id: view.id,
label: view.label,
meaning: view.meaning,
dangerous: view.dangerous,
focused: this.turnPromptFocusedAction === view.id,
escapeCancels: this.turnPromptCancelActionId === view.id
}))
}
: null,
mapResultFeedback: {
activeCount: this.mapResultPopupObjects.filter((object) => object.active).length,
peakCount: this.mapResultPopupPeakCount,

View File

@@ -344,7 +344,7 @@ type CampTabButtonView = {
type VictoryRewardCardId = CampaignVictoryRewardCategoryId;
type VictoryRewardActionId = 'equipment' | 'supplies' | 'sortie' | 'close';
type VictoryRewardActionId = 'equipment' | 'supplies' | 'guided' | 'sortie' | 'close';
type VictoryRewardIcon =
| { kind: 'mark'; mark: string }
@@ -368,6 +368,7 @@ type VictoryRewardActionView = {
id: VictoryRewardActionId;
label: string;
button: Phaser.GameObjects.Rectangle;
primary: boolean;
};
type CampActionButtonView = {
@@ -377,6 +378,23 @@ type CampActionButtonView = {
hovered: boolean;
};
type FirstCampGuidedAction =
| {
kind: 'visit';
label: '북문 정찰막 둘러보기';
targetId: typeof firstPursuitScoutVisitId;
}
| {
kind: 'dialogue';
label: string;
targetId: string;
}
| {
kind: 'sortie';
label: '출진 준비';
targetId: null;
};
type EquipmentSwapRequest = {
unitId: string;
slot: EquipmentSlot;
@@ -11517,6 +11535,9 @@ export class CampScene extends Phaser.Scene {
private reportFormationHistoryUndoState?: ReportFormationHistoryUndoState;
private tabButtons: CampTabButtonView[] = [];
private sortieCommandButton?: Phaser.GameObjects.Rectangle;
private sortieCommandLabel?: Phaser.GameObjects.Text;
private sortieBypassButton?: Phaser.GameObjects.Rectangle;
private sortieBypassLabel?: Phaser.GameObjects.Text;
private sortiePrimaryActionButton?: CampActionButtonView;
private sortieNextActionGuideBackground?: Phaser.GameObjects.Rectangle;
private sortieNextActionGuideText?: Phaser.GameObjects.Text;
@@ -11672,6 +11693,10 @@ export class CampScene extends Phaser.Scene {
this.reportFormationHistoryView = undefined;
this.reportFormationHistoryUndoState = undefined;
this.tabButtons = [];
this.sortieCommandButton = undefined;
this.sortieCommandLabel = undefined;
this.sortieBypassButton = undefined;
this.sortieBypassLabel = undefined;
this.sortiePrimaryActionButton = undefined;
this.sortieNextActionGuideBackground = undefined;
this.sortieNextActionGuideText = undefined;
@@ -13025,6 +13050,85 @@ export class CampScene extends Phaser.Scene {
);
}
private firstCampGuidedAction(): FirstCampGuidedAction | undefined {
if (
!this.isFirstSortiePrepSlice() ||
this.currentCampBattleId() !== campBattleIds.first
) {
return undefined;
}
if (!this.completedCampVisits().includes(firstPursuitScoutVisitId)) {
return {
kind: 'visit',
label: '북문 정찰막 둘러보기',
targetId: firstPursuitScoutVisitId
};
}
const followup = this.firstBattleCampFollowup();
if (
followup &&
!this.completedCampDialogues().includes(followup.targetDialogueId)
) {
return {
kind: 'dialogue',
label: `${followup.mentorName}와 핵심 회고`,
targetId: followup.targetDialogueId
};
}
return {
kind: 'sortie',
label: '출진 준비',
targetId: null
};
}
private runFirstCampGuidedAction() {
const action = this.firstCampGuidedAction();
if (!action || action.kind === 'sortie') {
this.showRequestedSortiePrep();
return;
}
if (action.kind === 'visit') {
this.activeTab = 'visit';
this.selectedVisitId = action.targetId;
this.render();
this.startCampNavigation('CampVisitExplorationScene', { visitId: action.targetId });
return;
}
this.activeTab = 'dialogue';
this.selectedDialogueId = action.targetId;
this.render();
}
private campPrimaryActionLabel(flow = this.currentSortieFlow()) {
if (this.isFinalEpilogueFlow(flow) && this.campaign?.step === flow.campaignStep) {
return '엔딩 보기';
}
const guidedAction = this.firstCampGuidedAction();
return guidedAction?.kind === 'visit'
? '정찰막 둘러보기'
: guidedAction?.label ?? '출진 준비';
}
private refreshCampPrimaryActionPresentation() {
const label = this.campPrimaryActionLabel();
const guidedAction = this.firstCampGuidedAction();
const bypassVisible = Boolean(guidedAction && guidedAction.kind !== 'sortie');
this.sortieCommandLabel
?.setText(label)
.setFontSize(
bypassVisible
? Array.from(label).length > 7 ? 12 : 14
: Array.from(label).length > 8 ? 13 : 16
);
[this.sortieBypassButton, this.sortieBypassLabel].forEach((object) => {
object?.setVisible(bypassVisible);
if (object?.input) {
object.input.enabled = bypassVisible;
}
});
}
private hasPendingFirstBattleCamaraderieMemory() {
const dialogue = this.firstBattleCamaraderieDialogue();
return Boolean(
@@ -13102,16 +13206,36 @@ export class CampScene extends Phaser.Scene {
soundDirector.playSelect();
this.showCampSaveSlotPanel();
});
const flow = this.currentSortieFlow();
const storyButtonLabel = this.isFinalEpilogueFlow(flow) && this.campaign?.step === flow.campaignStep ? '엔딩 보기' : '출진 준비';
this.sortieCommandButton = this.addCommandButton(storyButtonLabel, 1152, 38, 142, () => {
const guidedAction = this.firstCampGuidedAction();
const splitSortieCommand = Boolean(guidedAction && guidedAction.kind !== 'sortie');
const sortieCommand = this.addCommandButton(
this.campPrimaryActionLabel(),
splitSortieCommand ? 1132 : 1152,
38,
splitSortieCommand ? 116 : 142,
() => {
soundDirector.playSelect();
const flow = this.currentSortieFlow();
if (this.isFinalEpilogueFlow(flow)) {
this.startVictoryStory();
return;
}
this.showSortiePrep();
}, true);
this.runFirstCampGuidedAction();
},
true
);
this.sortieCommandButton = sortieCommand.background;
this.sortieCommandLabel = sortieCommand.label;
if (splitSortieCommand) {
const sortieBypass = this.addCommandButton('바로 출진', 1230, 38, 72, () => {
soundDirector.playSelect();
this.showRequestedSortiePrep();
});
sortieBypass.label.setFontSize(13);
this.sortieBypassButton = sortieBypass.background;
this.sortieBypassLabel = sortieBypass.label;
}
this.refreshCampPrimaryActionPresentation();
}
private showCampSaveSlotPanel() {
@@ -13472,6 +13596,9 @@ export class CampScene extends Phaser.Scene {
const panelY = 90;
const panelWidth = 884;
const panelHeight = 546;
const firstCampGuidedAction = report.battleId === campBattleIds.first
? this.firstCampGuidedAction()
: undefined;
const shade = this.trackVictoryReward(
this.add.rectangle(0, 0, campLegacyCanvasWidth, campLegacyCanvasHeight, 0x020406, 0.78)
@@ -13683,15 +13810,24 @@ export class CampScene extends Phaser.Scene {
);
noteText.setDepth(depth + 2);
const actionDefinitions: Array<{ id: VictoryRewardActionId; label: string; primary?: boolean }> = [
{ id: 'equipment', label: '장비 확인' },
{ id: 'supplies', label: '보급 확인' },
{ id: 'sortie', label: '출진 준비', primary: true },
{ id: 'close', label: '닫기' }
];
const buttonWidth = 158;
const actionDefinitions: Array<{ id: VictoryRewardActionId; label: string; primary?: boolean }> =
firstCampGuidedAction
? [
{ id: 'equipment', label: '장비 확인' },
{ id: 'supplies', label: '보급 확인' },
{ id: 'guided', label: firstCampGuidedAction.label, primary: true },
{ id: 'sortie', label: '바로 출진' },
{ id: 'close', label: '닫기' }
]
: [
{ id: 'equipment', label: '장비 확인' },
{ id: 'supplies', label: '보급 확인' },
{ id: 'sortie', label: '출진 준비', primary: true },
{ id: 'close', label: '닫기' }
];
const buttonWidth = firstCampGuidedAction ? 156 : 158;
const buttonHeight = 48;
const actionGap = 14;
const actionGap = firstCampGuidedAction ? 8 : 14;
const totalActionWidth = actionDefinitions.length * buttonWidth + (actionDefinitions.length - 1) * actionGap;
const actionStartX = panelX + Math.floor((panelWidth - totalActionWidth) / 2);
actionDefinitions.forEach((definition, index) => {
@@ -13708,7 +13844,16 @@ export class CampScene extends Phaser.Scene {
button.setInteractive({ useHandCursor: true });
const label = this.trackVictoryReward(
this.add.text(buttonX + buttonWidth / 2, buttonY + buttonHeight / 2, definition.label, this.textStyle(14, definition.primary ? '#fff2b8' : '#f2e3bf', true))
this.add.text(
buttonX + buttonWidth / 2,
buttonY + buttonHeight / 2,
definition.label,
this.textStyle(
definition.id === 'guided' && Array.from(definition.label).length > 8 ? 12 : 14,
definition.primary ? '#fff2b8' : '#f2e3bf',
true
)
)
);
label.setOrigin(0.5);
label.setDepth(depth + 3);
@@ -13723,7 +13868,12 @@ export class CampScene extends Phaser.Scene {
target.on('pointerout', () => button.setFillStyle(restingFill, 0.99));
target.on('pointerdown', run);
});
this.victoryRewardActions.push({ id: definition.id, label: definition.label, button });
this.victoryRewardActions.push({
id: definition.id,
label: definition.label,
button,
primary: Boolean(definition.primary)
});
});
soundDirector.playRewardRevealCue();
}
@@ -13759,6 +13909,10 @@ export class CampScene extends Phaser.Scene {
this.render();
return;
}
if (action === 'guided') {
this.runFirstCampGuidedAction();
return;
}
if (action === 'sortie' || (action === 'close' && this.openSortiePrepOnCreate)) {
this.showRequestedSortiePrep();
}
@@ -14110,7 +14264,7 @@ export class CampScene extends Phaser.Scene {
target.on('pointerout', () => setHovered(false));
target.on('pointerdown', action);
});
return bg;
return { background: bg, label: text };
}
private render() {
@@ -14124,6 +14278,7 @@ export class CampScene extends Phaser.Scene {
this.acknowledgePendingVictoryRewardCategories(['supplies']);
}
this.visitedTabs.add(this.activeTab);
this.refreshCampPrimaryActionPresentation();
this.updateTabButtons();
this.renderUnitColumn();
this.renderReportPanel();
@@ -26042,6 +26197,7 @@ export class CampScene extends Phaser.Scene {
: false;
const cityEquipmentSortieStatus = this.cityEquipmentSortieStatus();
const firstBattleCampFollowup = this.firstBattleCampFollowup();
const firstCampGuidedAction = this.firstCampGuidedAction();
const firstBattleCamaraderieMemory = this.firstBattleCamaraderieMemory();
const firstBattleCamaraderieDialogue = this.firstBattleCamaraderieDialogue();
const thirdBattlePriorityReturn = this.thirdBattlePriorityReturnDialogue();
@@ -26090,6 +26246,7 @@ export class CampScene extends Phaser.Scene {
actions: this.victoryRewardActions.map((action) => ({
id: action.id,
label: action.label,
primary: action.primary,
bounds: this.sortieObjectBoundsDebug(action.button),
interactive: Boolean(action.button.input?.enabled)
}))
@@ -26148,8 +26305,28 @@ export class CampScene extends Phaser.Scene {
lineWidth: button.bg.lineWidth
})),
sortieCommand: {
label: this.sortieCommandLabel?.text ?? null,
guidedKind: firstCampGuidedAction?.kind ?? null,
targetId: firstCampGuidedAction?.targetId ?? null,
bounds: this.sortieObjectBoundsDebug(this.sortieCommandButton),
interactive: Boolean(this.sortieCommandButton?.input?.enabled)
interactive: Boolean(
this.sortieCommandButton?.input?.enabled &&
this.sortieCommandLabel?.input?.enabled
),
bypass:
this.sortieBypassButton?.visible &&
this.sortieBypassLabel?.visible
? {
label: this.sortieBypassLabel.text,
bounds: this.sortieObjectBoundsDebug(
this.sortieBypassButton
),
interactive: Boolean(
this.sortieBypassButton.input?.enabled &&
this.sortieBypassLabel.input?.enabled
)
}
: null
},
firstVictoryFollowup: firstBattleCampFollowup
? {

View File

@@ -37,6 +37,10 @@ import {
resolveFirstBattleVictoryNarrative,
type FirstBattleVictoryNarrativeMemory
} from '../data/firstBattleNarrativeMemory';
import {
resolveXuzhouStayNarrativeMemory,
type XuzhouStayNarrativeMemory
} from '../data/xuzhouStayNarrativeMemory';
import {
ensureUnitAnimations,
loadUnitBaseSheets,
@@ -53,7 +57,11 @@ import {
} from '../data/storyCutscenes';
import { prologueOpeningPages } from '../data/prologueVillage';
import { type PortraitKey, type StoryPage } from '../data/scenario';
import { campaignVictoryRewardPresentation, getFirstBattleReport } from '../state/campaignState';
import {
campaignVictoryRewardPresentation,
getCampaignState,
getFirstBattleReport
} from '../state/campaignState';
import { releaseTextureSource } from '../render/loaderLifecycle';
import { isVisualMotionReduced } from '../settings/visualMotion';
import { palette } from '../ui/palette';
@@ -222,6 +230,7 @@ export class StoryScene extends Phaser.Scene {
private ownedStoryTextureKeys = new Set<string>();
private rewardDisplay: StoryRewardDisplayModel = this.emptyRewardDisplay();
private firstBattleNarrativeMemory?: FirstBattleVictoryNarrativeMemory;
private xuzhouStayNarrativeMemory?: XuzhouStayNarrativeMemory;
private visualMotionReduced = false;
private storyEnvironmentProfile?: StoryEnvironmentProfile;
private storyEnvironmentBackgroundKey?: string;
@@ -248,8 +257,22 @@ export class StoryScene extends Phaser.Scene {
)
? resolveFirstBattleVictoryNarrative(sourcePages, getFirstBattleReport())
: undefined;
this.pages = firstBattleAftermath?.pages ?? sourcePages;
const campaign = getCampaignState();
const xuzhouStayNarrative = resolveXuzhouStayNarrativeMemory(
firstBattleAftermath?.pages ?? sourcePages,
{
battleId: this.presentationBattleId,
stage: this.presentationStage,
campaign,
historicalReport: this.presentationBattleId
? campaign.battleHistory[this.presentationBattleId]
: undefined
}
);
this.pages = xuzhouStayNarrative.pages;
this.firstBattleNarrativeMemory = firstBattleAftermath?.memory;
this.xuzhouStayNarrativeMemory =
xuzhouStayNarrative.memory;
this.selectedStoryBackgroundKeys = storyBackgroundKeysForPages(this.pages);
this.presentationWash = undefined;
this.pageIndex = 0;
@@ -349,6 +372,21 @@ export class StoryScene extends Phaser.Scene {
)
}
: null,
xuzhouStayNarrativeMemory:
this.xuzhouStayNarrativeMemory
? {
...this.xuzhouStayNarrativeMemory,
appliedPageIds: [
...this.xuzhouStayNarrativeMemory.appliedPageIds
],
currentPageApplied: Boolean(
page &&
this.xuzhouStayNarrativeMemory.appliedPageIds.includes(
page.id
)
)
}
: null,
environment: this.storyEnvironmentDebugState(),
presentation: this.storyPresentationDebugState(),
assetStreaming: {