feat: make guidance recoverable across camp and battle

This commit is contained in:
2026-07-28 05:57:42 +09:00
parent 495f98372c
commit 5f6490b2ae
7 changed files with 1300 additions and 44 deletions

View File

@@ -53,6 +53,41 @@ export type FirstBattleCamaraderieMemory = {
dialogue: FirstBattleCamaraderieDialogue;
};
export type FirstCampCamaraderieGuidedAction =
| {
kind: 'dialogue';
label: '전우 기억 되짚기';
targetId: typeof firstBattleCamaraderieDialogueId;
}
| {
kind: 'sortie';
label: '전우 조 편성하기' | '출진 준비';
targetId: null;
};
export type FirstBattleCamaraderieSortieGuard =
| {
applies: false;
shouldWarn: false;
confirmedByRepeat: false;
signature: null;
notice: null;
}
| {
applies: true;
shouldWarn: true;
confirmedByRepeat: false;
signature: string;
notice: string;
}
| {
applies: true;
shouldWarn: false;
confirmedByRepeat: true;
signature: string;
notice: null;
};
export type FirstBattleCamaraderieCampaignState = {
battleHistory?: Partial<Record<string, CampaignBattleSettlement | undefined>>;
completedCampDialogues?: readonly string[];
@@ -183,6 +218,90 @@ export function firstBattleCamaraderieBonusFor(options: {
: 0;
}
export function resolveFirstCampCamaraderieGuidedAction(options: {
memory?: FirstBattleCamaraderieMemory;
nextBattleId?: string;
selectedCoreBondId?: string;
}): FirstCampCamaraderieGuidedAction {
const { memory } = options;
if (memory && !memory.completed) {
return {
kind: 'dialogue',
label: '전우 기억 되짚기',
targetId: memory.dialogue.id
};
}
if (
memory?.completed &&
options.nextBattleId === memory.targetBattleId &&
options.selectedCoreBondId !== memory.bondId
) {
return {
kind: 'sortie',
label: '전우 조 편성하기',
targetId: null
};
}
return {
kind: 'sortie',
label: '출진 준비',
targetId: null
};
}
export function resolveFirstBattleCamaraderieSortieGuard(options: {
memory?: FirstBattleCamaraderieMemory;
nextBattleId?: string;
selectedCoreBondId?: string;
selectedUnitIds?: readonly string[];
previousSignature?: string;
}): FirstBattleCamaraderieSortieGuard {
const { memory } = options;
if (
!memory ||
options.nextBattleId !== memory.targetBattleId ||
(memory.completed && options.selectedCoreBondId === memory.bondId)
) {
return {
applies: false,
shouldWarn: false,
confirmedByRepeat: false,
signature: null,
notice: null
};
}
const selectedUnitsSignature = [...(options.selectedUnitIds ?? [])]
.sort((left, right) => left.localeCompare(right))
.join(',');
const signature = [
options.nextBattleId,
memory.completed ? 'remembered' : 'unreviewed',
options.selectedCoreBondId ?? 'none',
selectedUnitsSignature
].join(':');
if (options.previousSignature === signature) {
return {
applies: true,
shouldWarn: false,
confirmedByRepeat: true,
signature,
notice: null
};
}
const pairLabel = memory.unitNames.join('↔');
return {
applies: true,
shouldWarn: true,
confirmedByRepeat: false,
signature,
notice: memory.completed
? `전우 기억 미지정 · ${pairLabel}를 핵심 공명조로 지정해야 이번 추격에서 +${memory.bonusRate}%p가 적용됩니다. 보너스 없이 출진하려면 출진을 한 번 더 누르세요.`
: `전우 기억 미완료 · ${pairLabel}의 회고를 마쳐야 이번 추격에서 핵심 공명 +${memory.bonusRate}%p를 사용할 수 있습니다. 회고 없이 출진하려면 출진을 한 번 더 누르세요.`
};
}
function compareCooperationSnapshots(
left: CampaignSortieCooperationSnapshot,
right: CampaignSortieCooperationSnapshot

View File

@@ -1557,7 +1557,7 @@ type BattleSoundCaptionSnapshot = {
};
const battleSpeedOptions = ['normal', 'fast', 'very-fast'] as const;
type BattleSpeed = (typeof battleSpeedOptions)[number];
type MapMenuAction = 'endTurn' | 'threat' | 'save' | 'load' | 'roster' | 'bond' | 'situation' | 'speed' | 'presentation' | 'bgm' | 'close';
type MapMenuAction = 'endTurn' | 'threat' | 'tutorial' | 'save' | 'load' | 'roster' | 'bond' | 'situation' | 'speed' | 'presentation' | 'bgm' | 'close';
type EnemyAiBehavior = 'aggressive' | 'guard' | 'hold';
type BattleOutcome = 'victory' | 'defeat';
type SaveSlotMode = 'save' | 'load';
@@ -1975,9 +1975,18 @@ type MapMenuLayout = {
left: number;
top: number;
width: number;
height: number;
buttonHeight: number;
padding: number;
actions: MapMenuAction[];
guidanceBounds: HudBounds;
};
type MapMenuButtonView = {
action: MapMenuAction;
background: Phaser.GameObjects.Rectangle;
label: Phaser.GameObjects.Text;
available: boolean;
};
type BattleObjectiveResult = {
@@ -3726,6 +3735,7 @@ const factionLabels: Record<ActiveFaction, string> = {
const mapMenuLabels: Partial<Record<MapMenuAction, string>> = {
endTurn: '턴 종료',
threat: '위험 범위',
tutorial: '실전 안내',
roster: '부대 일람',
bond: '공명',
situation: '전황',
@@ -3885,6 +3895,13 @@ export class BattleScene extends Phaser.Scene {
private deploymentNotice = '추천 배치는 이미 적용되어 있습니다. 필요하면 장수를 선택해 시작 구역 안에서 위치를 바꾸세요.';
private mapMenuObjects: Array<Phaser.GameObjects.Rectangle | Phaser.GameObjects.Text> = [];
private mapMenuLayout?: MapMenuLayout;
private mapMenuButtons: MapMenuButtonView[] = [];
private mapMenuFocusedIndex = -1;
private mapMenuFocusMarker?: Phaser.GameObjects.Text;
private battleMenuButtonObjects: Phaser.GameObjects.GameObject[] = [];
private battleMenuButtonBounds?: HudBounds;
private battleMenuButtonBackground?: Phaser.GameObjects.Rectangle;
private battleMenuButtonText?: Phaser.GameObjects.Text;
private battleSpeed: BattleSpeed = 'normal';
private combatPresentationMode: CombatPresentationMode = 'important';
private combatPresentationCount = { full: 0, map: 0 };
@@ -4120,6 +4137,13 @@ export class BattleScene extends Phaser.Scene {
this.deploymentObjects = [];
this.mapMenuObjects = [];
this.mapMenuLayout = undefined;
this.mapMenuButtons = [];
this.mapMenuFocusedIndex = -1;
this.mapMenuFocusMarker = undefined;
this.battleMenuButtonObjects = [];
this.battleMenuButtonBounds = undefined;
this.battleMenuButtonBackground = undefined;
this.battleMenuButtonText = undefined;
this.tacticalInitiativePanelRoot = undefined;
this.tacticalInitiativePanelObjects = [];
this.tacticalInitiativePanelBounds = undefined;
@@ -4602,6 +4626,10 @@ export class BattleScene extends Phaser.Scene {
if (this.tacticalInitiativePanelObjects.length > 0) {
return;
}
if (this.mapMenuObjects.length > 0) {
this.activateMapMenuFocusedAction();
return;
}
if (this.commandMenuObjects.length > 0) {
this.activateCommandMenuFocusedAction();
return;
@@ -4717,6 +4745,12 @@ export class BattleScene extends Phaser.Scene {
}
private handleBattleKeyDown(event: KeyboardEvent) {
if (this.handleFirstBattleTutorialShortcut(event)) {
return;
}
if (this.handleMapMenuToggleKey(event)) {
return;
}
const repeatableNavigation =
event.code === 'ArrowLeft' ||
event.code === 'ArrowRight' ||
@@ -4739,6 +4773,9 @@ export class BattleScene extends Phaser.Scene {
if (this.saveSlotPanelObjects.length > 0) {
return;
}
if (this.handleMapMenuNavigationKey(event)) {
return;
}
if (this.handleTacticalInitiativeKey(event)) {
return;
}
@@ -6400,10 +6437,75 @@ export class BattleScene extends Phaser.Scene {
wordWrap: { width: panelWidth - this.battleUiLength(48), useAdvancedWrap: true }
});
this.renderBattleSpeedControl();
this.renderBattleMenuButton();
this.updateObjectiveTracker();
this.drawMiniMap();
}
private renderBattleMenuButton() {
this.battleMenuButtonObjects.forEach((object) => object.destroy());
this.battleMenuButtonObjects = [];
this.battleMenuButtonBounds = undefined;
this.battleMenuButtonBackground = undefined;
this.battleMenuButtonText = undefined;
const { panelX, panelY, panelWidth } = this.layout;
const speedWidth = this.battleUiLength(112);
const width = this.battleUiLength(82);
const height = this.battleUiLength(28);
const gap = this.battleUiLength(8);
const left = panelX + panelWidth - this.battleUiLength(24) - speedWidth - gap - width;
const top = panelY + this.battleUiLength(74);
const button = this.add.rectangle(left, top, width, height, 0x17232e, 0.96);
button.setName('battle-menu-button');
button.setOrigin(0);
button.setDepth(16);
button.setInteractive({ useHandCursor: true });
const text = this.add.text(left + width / 2, top + height / 2 - this.battleUiLength(1), '메뉴 M', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: this.battleUiFontSize(13),
color: '#f2e3bf',
fontStyle: '700'
});
text.setOrigin(0.5);
text.setDepth(17);
text.setInteractive({ useHandCursor: true });
const onSelect = () => {
this.suppressNextLeftClick = true;
this.toggleMapMenuFromHud();
};
button.on('pointerover', () => button.setFillStyle(0x243746, 0.98));
button.on('pointerout', () => this.updateBattleMenuButtonVisual());
button.on('pointerdown', onSelect);
text.on('pointerover', () => button.setFillStyle(0x243746, 0.98));
text.on('pointerout', () => this.updateBattleMenuButtonVisual());
text.on('pointerdown', onSelect);
this.battleMenuButtonObjects.push(button, text);
this.battleMenuButtonBounds = { x: left, y: top, width, height };
this.battleMenuButtonBackground = button;
this.battleMenuButtonText = text;
this.updateBattleMenuButtonVisual();
}
private updateBattleMenuButtonVisual() {
const button = this.battleMenuButtonBackground;
const text = this.battleMenuButtonText;
if (!button?.active || !text?.active) {
return;
}
const open = this.mapMenuObjects.length > 0;
button.setFillStyle(open ? 0x30475a : 0x17232e, 0.96);
button.setStrokeStyle(
this.battleUiLength(open ? 2 : 1),
open ? palette.gold : 0x5a7588,
open ? 0.96 : 0.86
);
text.setColor(open ? '#fff2b8' : '#f2e3bf');
}
private renderBattleSpeedControl() {
this.battleSpeedControlObjects.forEach((object) => object.destroy());
this.battleSpeedControlObjects = [];
@@ -19355,7 +19457,12 @@ export class BattleScene extends Phaser.Scene {
this.phase === 'idle' &&
this.activeFaction === 'ally' &&
this.turnNumber === 1 &&
this.battleEventObjects.length === 0
this.battleEventObjects.length === 0 &&
this.mapMenuObjects.length === 0 &&
this.saveSlotPanelObjects.length === 0 &&
this.turnPromptObjects.length === 0 &&
this.tacticalInitiativePanelObjects.length === 0 &&
this.tacticalCommandCutInObjects.length === 0
) {
this.startFirstBattleTutorial();
return;
@@ -19370,7 +19477,7 @@ export class BattleScene extends Phaser.Scene {
private startFirstBattleTutorial() {
const path = this.createFirstBattleTutorialPath();
if (!path) {
return;
return false;
}
this.firstBattleTutorialPath = path;
@@ -19382,6 +19489,7 @@ export class BattleScene extends Phaser.Scene {
this.ensureFirstBattleTutorialPathVisible();
this.syncFirstBattleTutorialKeyboardCursor();
this.renderFirstBattleTutorial();
return true;
}
private createFirstBattleTutorialPath(): FirstBattleTutorialPath | undefined {
@@ -19669,7 +19777,7 @@ export class BattleScene extends Phaser.Scene {
const feedback = this.trackFirstBattleTutorialObject(this.add.text(
left + 22,
top + 111,
this.firstBattleTutorialFeedback || '빛나는 표시를 따라 클릭/Enter로 조작하세요. · 우클릭/ESC 이전 단계',
this.firstBattleTutorialFeedback || '빛나는 표시를 따라 클릭/Enter로 조작하세요. · 우클릭/ESC 이전 · K 건너뛰기',
{
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '12px',
@@ -19690,7 +19798,7 @@ export class BattleScene extends Phaser.Scene {
skip.setDepth(depth + 2);
skip.setStrokeStyle(1, 0x7f8994, 0.82);
skip.setInteractive({ useHandCursor: true });
const skipText = this.trackFirstBattleTutorialObject(this.add.text(skipX + skipWidth / 2, skipY + skipHeight / 2, '건너뛰기', {
const skipText = this.trackFirstBattleTutorialObject(this.add.text(skipX + skipWidth / 2, skipY + skipHeight / 2, '건너뛰기 K', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '13px',
color: '#d4dce6',
@@ -19703,6 +19811,7 @@ export class BattleScene extends Phaser.Scene {
this.suppressNextLeftClick = true;
soundDirector.playSelect();
this.completeFirstBattleTutorial('skipped');
this.renderSituationPanel('실전 안내를 건너뛰었습니다.\n메뉴 M · 실전 안내에서 다시 시작할 수 있습니다.');
};
skip.on('pointerdown', skipTutorial);
skipText.on('pointerdown', skipTutorial);
@@ -19836,6 +19945,25 @@ export class BattleScene extends Phaser.Scene {
completeCampaignTutorial(firstBattleTutorialId);
}
private handleFirstBattleTutorialShortcut(event: KeyboardEvent) {
if (
!this.isFirstBattleTutorialActive() ||
event.code !== 'KeyK' ||
event.repeat ||
event.ctrlKey ||
event.altKey ||
event.metaKey
) {
return false;
}
event.preventDefault();
soundDirector.playSelect();
this.completeFirstBattleTutorial('skipped');
this.renderSituationPanel('실전 안내를 건너뛰었습니다.\n메뉴 M · 실전 안내에서 다시 시작할 수 있습니다.');
return true;
}
private acceptFirstBattleTutorialCommand(command: BattleCommand) {
if (!this.isFirstBattleTutorialActive()) {
return true;
@@ -21595,8 +21723,194 @@ export class BattleScene extends Phaser.Scene {
return this.isInBounds(x, y) ? { x, y } : undefined;
}
private mapMenuOpenBlockedReason() {
if (this.battleOutcome) {
return '전투가 끝난 뒤에는 지휘 메뉴를 열 수 없습니다.';
}
if (this.isFirstBattleTutorialActive()) {
return '실전 안내가 진행 중입니다. 빛나는 순서를 따르거나 K로 건너뛴 뒤 메뉴를 여세요.';
}
if (this.phase === 'deployment') {
return '배치를 확정한 뒤 지휘 메뉴를 열 수 있습니다.';
}
if (this.activeFaction !== 'ally') {
return '적군 행동 중에는 지휘 메뉴를 열 수 없습니다.';
}
if (this.phase !== 'idle') {
return '현재 명령을 마치거나 취소한 뒤 지휘 메뉴를 여세요.';
}
if (
this.battleEventObjects.length > 0 ||
this.tacticalInitiativePanelObjects.length > 0 ||
this.tacticalCommandCutInObjects.length > 0 ||
this.saveSlotPanelObjects.length > 0 ||
this.turnPromptObjects.length > 0
) {
return '열려 있는 안내나 확인 창을 먼저 닫아 주세요.';
}
return undefined;
}
private showMapMenuUnavailableFeedback(reason: string) {
if (this.isFirstBattleTutorialActive()) {
this.showFirstBattleTutorialFeedback(reason);
return;
}
if (this.phase === 'deployment') {
this.deploymentNotice = reason;
this.renderDeploymentPanel();
return;
}
this.renderSituationPanel(`메뉴를 열 수 없습니다.\n${reason}`);
}
private toggleMapMenuFromHud() {
if (this.mapMenuObjects.length > 0) {
soundDirector.playSelect();
this.hideMapMenu();
return;
}
const blockedReason = this.mapMenuOpenBlockedReason();
if (blockedReason) {
soundDirector.playSelect();
this.showMapMenuUnavailableFeedback(blockedReason);
return;
}
soundDirector.playSelect();
this.showMapMenu(
this.layout.gridX + this.layout.gridWidth - this.battleUiLength(28),
this.layout.gridY + this.layout.gridHeight / 2
);
}
private handleMapMenuToggleKey(event: KeyboardEvent) {
if (
event.code !== 'KeyM' ||
event.repeat ||
event.ctrlKey ||
event.altKey ||
event.metaKey
) {
return false;
}
event.preventDefault();
this.toggleMapMenuFromHud();
return true;
}
private handleMapMenuNavigationKey(event: KeyboardEvent) {
if (this.mapMenuObjects.length === 0) {
return false;
}
if (event.code === 'ArrowUp' || (event.code === 'Tab' && event.shiftKey)) {
event.preventDefault();
this.moveMapMenuFocus(-1);
return true;
}
if (event.code === 'ArrowDown' || event.code === 'Tab') {
event.preventDefault();
this.moveMapMenuFocus(1);
return true;
}
return true;
}
private moveMapMenuFocus(delta: -1 | 1) {
if (this.mapMenuButtons.length === 0) {
return;
}
const start = this.mapMenuFocusedIndex >= 0
? this.mapMenuFocusedIndex
: delta > 0
? -1
: 0;
for (let offset = 1; offset <= this.mapMenuButtons.length; offset += 1) {
const index = Phaser.Math.Wrap(
start + delta * offset,
0,
this.mapMenuButtons.length
);
if (this.mapMenuButtons[index]?.available) {
this.setMapMenuFocus(index);
return;
}
}
}
private setMapMenuFocus(index: number) {
const entry = this.mapMenuButtons[index];
if (!entry?.available) {
return false;
}
this.mapMenuFocusedIndex = index;
this.updateMapMenuButtonVisuals();
return true;
}
private focusFirstAvailableMapMenuItem() {
const index = this.mapMenuButtons.findIndex((entry) => entry.available);
if (index >= 0) {
this.setMapMenuFocus(index);
}
}
private updateMapMenuButtonVisuals() {
this.mapMenuButtons.forEach((entry, index) => {
const focused = index === this.mapMenuFocusedIndex && entry.available;
entry.background.setFillStyle(
entry.available
? focused
? 0x30475a
: 0x1a2630
: 0x121820,
entry.available ? 0.96 : 0.72
);
entry.background.setStrokeStyle(
focused ? 3 : 1,
focused
? 0xffec9d
: entry.action === 'endTurn'
? 0xd8b15f
: 0x5a7588,
entry.available ? (focused ? 1 : 0.68) : 0.32
);
entry.label.setColor(
entry.available
? focused
? '#fff4c7'
: '#f2e3bf'
: '#737b84'
);
});
const focused = this.mapMenuButtons[this.mapMenuFocusedIndex];
if (!this.mapMenuFocusMarker?.active || !focused?.available) {
this.mapMenuFocusMarker?.setVisible(false);
return;
}
const bounds = focused.background.getBounds();
this.mapMenuFocusMarker
.setPosition(bounds.x + 9, bounds.y + bounds.height / 2 - 1)
.setVisible(true);
}
private activateMapMenuFocusedAction() {
const entry = this.mapMenuButtons[this.mapMenuFocusedIndex];
if (!entry?.available) {
this.focusFirstAvailableMapMenuItem();
return;
}
this.runMapMenuAction(entry.action);
}
private showMapMenu(pointerX: number, pointerY: number) {
if (this.phase === 'deployment' || this.phase === 'command' || this.phase === 'targeting' || this.phase === 'animating') {
if (this.mapMenuOpenBlockedReason()) {
return;
}
@@ -21608,11 +21922,12 @@ export class BattleScene extends Phaser.Scene {
this.phase = 'idle';
this.refreshUnitLegibilityStyles();
const actions: MapMenuAction[] = ['endTurn', 'threat', 'save', 'load', 'roster', 'bond', 'situation', 'speed', 'presentation', 'bgm', 'close'];
const menuWidth = 148;
const actions: MapMenuAction[] = ['endTurn', 'threat', 'tutorial', 'save', 'load', 'roster', 'bond', 'situation', 'speed', 'presentation', 'bgm', 'close'];
const menuWidth = 204;
const buttonHeight = 34;
const padding = 8;
const menuHeight = padding * 2 + actions.length * buttonHeight;
const guidanceHeight = 38;
const menuHeight = padding * 2 + actions.length * buttonHeight + guidanceHeight;
const mapLeft = this.layout.mapX + 16;
const mapRight = this.layout.mapX + this.layout.mapWidth - 16;
const pointerGap = 12;
@@ -21621,9 +21936,25 @@ export class BattleScene extends Phaser.Scene {
: pointerX - menuWidth - pointerGap;
const left = Phaser.Math.Clamp(preferredLeft, mapLeft, mapRight - menuWidth);
const top = Phaser.Math.Clamp(pointerY - menuHeight / 2, this.layout.mapY + 16, this.layout.mapY + this.layout.mapHeight - menuHeight - 16);
this.mapMenuLayout = { left, top, width: menuWidth, buttonHeight, padding, actions };
const guidanceBounds = {
x: left + 8,
y: top + padding + actions.length * buttonHeight,
width: menuWidth - 16,
height: guidanceHeight
};
this.mapMenuLayout = {
left,
top,
width: menuWidth,
height: menuHeight,
buttonHeight,
padding,
actions,
guidanceBounds
};
const panel = this.add.rectangle(left, top, menuWidth, menuHeight, 0x101821, 0.97);
panel.setName('map-menu-panel');
panel.setOrigin(0);
panel.setDepth(20);
panel.setStrokeStyle(2, palette.gold, 0.86);
@@ -21634,6 +21965,7 @@ export class BattleScene extends Phaser.Scene {
const y = top + padding + index * buttonHeight;
const disabled = this.isMapMenuActionDisabled(action);
const bg = this.add.rectangle(left + 8, y, menuWidth - 16, buttonHeight - 4, disabled ? 0x121820 : 0x1a2630, disabled ? 0.72 : 0.94);
bg.setName(`map-menu-action-${action}`);
bg.setOrigin(0);
bg.setDepth(21);
bg.setStrokeStyle(1, action === 'endTurn' ? 0xd8b15f : 0x5a7588, disabled ? 0.32 : 0.68);
@@ -21648,27 +21980,68 @@ export class BattleScene extends Phaser.Scene {
text.setOrigin(0.5);
text.setDepth(22);
this.mapMenuObjects.push(text);
this.mapMenuButtons.push({
action,
background: bg,
label: text,
available: !disabled
});
if (!disabled) {
const focus = () => this.setMapMenuFocus(index);
const run = () => {
this.suppressNextLeftClick = true;
this.runMapMenuAction(action);
};
bg.setInteractive({ useHandCursor: true });
bg.on('pointerover', () => bg.setFillStyle(0x283947, 0.98));
bg.on('pointerout', () => bg.setFillStyle(0x1a2630, 0.94));
bg.on('pointerover', focus);
bg.on('pointerout', () => this.updateMapMenuButtonVisuals());
bg.on('pointerdown', run);
text.setInteractive({ useHandCursor: true });
text.on('pointerover', focus);
text.on('pointerout', () => this.updateMapMenuButtonVisuals());
text.on('pointerdown', run);
}
});
const guidance = this.add.text(
guidanceBounds.x + guidanceBounds.width / 2,
guidanceBounds.y + guidanceBounds.height / 2 - 1,
'M 닫기 · ↑↓/Tab 이동\nEnter 선택 · Esc 닫기',
{
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '12px',
color: '#aab8c4',
fontStyle: '700',
align: 'center'
}
);
guidance.setName('map-menu-keyboard-guidance');
guidance.setOrigin(0.5);
guidance.setDepth(22);
this.mapMenuObjects.push(guidance);
const focusMarker = this.add.text(0, 0, '▶', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '14px',
color: '#ffec9d',
fontStyle: '700'
});
focusMarker.setName('map-menu-focus-marker');
focusMarker.setOrigin(0, 0.5);
focusMarker.setDepth(23);
focusMarker.setVisible(false);
this.mapMenuObjects.push(focusMarker);
this.mapMenuFocusMarker = focusMarker;
this.focusFirstAvailableMapMenuItem();
this.updateBattleMenuButtonVisual();
}
private runMapMenuAction(action: MapMenuAction) {
this.hideMapMenu();
soundDirector.playSelect();
if (action === 'threat' || action === 'roster' || action === 'bond' || action === 'situation') {
if (action === 'threat' || action === 'tutorial' || action === 'roster' || action === 'bond' || action === 'situation') {
this.hideBattleEventBanner();
}
@@ -21680,6 +22053,10 @@ export class BattleScene extends Phaser.Scene {
this.activateSideQuickTab('threat', false);
return;
}
if (action === 'tutorial') {
this.startFirstBattleTutorialOnDemand();
return;
}
if (action === 'save') {
this.showSaveSlotPanel('save');
return;
@@ -21717,6 +22094,20 @@ export class BattleScene extends Phaser.Scene {
}
}
private startFirstBattleTutorialOnDemand() {
if (this.activeFaction !== 'ally' || this.phase !== 'idle') {
this.renderSituationPanel(
'실전 안내를 시작할 수 없습니다.\n아군 지휘 차례에 진행 중인 명령을 마친 뒤 다시 시도하세요.'
);
return;
}
if (!this.startFirstBattleTutorial()) {
this.renderSituationPanel(
'지금은 4단계 실전 안내 경로를 만들 수 없습니다.\n행동 가능한 아군이 적에게 접근할 수 있는 다음 아군 턴에 다시 시도하세요.'
);
}
}
private showManualTurnEndPrompt() {
const unactedAllies = this.actionableAllies();
if (unactedAllies.length === 0) {
@@ -21753,6 +22144,10 @@ export class BattleScene extends Phaser.Scene {
this.mapMenuObjects.forEach((object) => object.destroy());
this.mapMenuObjects = [];
this.mapMenuLayout = undefined;
this.mapMenuButtons = [];
this.mapMenuFocusedIndex = -1;
this.mapMenuFocusMarker = undefined;
this.updateBattleMenuButtonVisual();
}
private mapMenuActionAt(x: number, y: number) {
@@ -32017,6 +32412,81 @@ export class BattleScene extends Phaser.Scene {
};
}
private mapMenuKeyboardDebugState() {
const focused = this.mapMenuButtons[this.mapMenuFocusedIndex];
const guidance = this.mapMenuObjects.find(
(object): object is Phaser.GameObjects.Text =>
object instanceof Phaser.GameObjects.Text &&
object.active &&
object.visible &&
object.name === 'map-menu-keyboard-guidance'
);
const toBounds = (object?: Phaser.GameObjects.Rectangle | Phaser.GameObjects.Text) => {
const bounds = object?.active ? object.getBounds() : undefined;
return bounds
? { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }
: null;
};
const markerBounds =
this.mapMenuFocusMarker?.active && this.mapMenuFocusMarker.visible
? this.mapMenuFocusMarker.getBounds()
: undefined;
return {
visible: this.mapMenuObjects.length > 0 && Boolean(this.mapMenuLayout),
bounds: this.mapMenuLayout
? {
x: this.mapMenuLayout.left,
y: this.mapMenuLayout.top,
width: this.mapMenuLayout.width,
height: this.mapMenuLayout.height
}
: null,
focusedIndex: this.mapMenuFocusedIndex,
guidance: guidance
? {
text: guidance.text,
bounds: toBounds(guidance)
}
: null,
focusedItem: focused
? {
id: focused.action,
action: focused.action,
label: focused.label.text,
available: focused.available,
bounds: toBounds(focused.background),
lineWidth: focused.background.lineWidth
}
: null,
items: this.mapMenuButtons.map((entry, index) => ({
id: entry.action,
action: entry.action,
label: entry.label.text,
available: entry.available,
focused: index === this.mapMenuFocusedIndex,
bounds: toBounds(entry.background),
lineWidth: entry.background.lineWidth
})),
focusMarker: markerBounds
? {
visible: true,
text: this.mapMenuFocusMarker?.text ?? '',
bounds: {
x: markerBounds.x,
y: markerBounds.y,
width: markerBounds.width,
height: markerBounds.height
}
}
: {
visible: false,
text: this.mapMenuFocusMarker?.text ?? '',
bounds: null
}
};
}
private battleHudDebugState() {
const miniMap = this.miniMapLayout;
const selectedDot = this.selectedUnit ? this.miniMapUnitDots.get(this.selectedUnit.id) : undefined;
@@ -32066,6 +32536,8 @@ export class BattleScene extends Phaser.Scene {
const header = {
titleBounds: this.gameObjectBoundsDebug(this.battleTitleText),
turnBounds: this.gameObjectBoundsDebug(this.turnText),
menuBounds: this.battleMenuButtonBounds ? { ...this.battleMenuButtonBounds } : null,
menuLabel: this.battleMenuButtonText?.active ? this.battleMenuButtonText.text : null,
speedBounds: this.gameObjectCollectionBoundsDebug(this.battleSpeedControlObjects),
objectiveBounds: this.gameObjectBoundsDebug(this.objectiveTrackerText),
defeatBounds: this.gameObjectBoundsDebug(this.objectiveTrackerSubText),
@@ -32926,6 +33398,7 @@ export class BattleScene extends Phaser.Scene {
return {
id: firstBattleTutorialId,
eligibleBattle: battleScenario.id === 'first-battle-zhuo-commandery',
onDemandAvailable: !this.battleOutcome,
active: this.isFirstBattleTutorialActive(),
completed: hasCompletedCampaignTutorial(firstBattleTutorialId),
completion: this.firstBattleTutorialCompletion ?? null,
@@ -32934,6 +33407,8 @@ export class BattleScene extends Phaser.Scene {
totalSteps: 4,
targetPreviewLocked: this.firstBattleTutorialTargetPreviewLocked,
feedback: this.firstBattleTutorialFeedback,
skipShortcut: 'K',
skipAvailable: this.isFirstBattleTutorialActive(),
path: path
? {
unitId: path.unitId,
@@ -33115,6 +33590,7 @@ export class BattleScene extends Phaser.Scene {
commandMenuVisible: this.commandMenuObjects.length > 0,
commandMenuKeyboard: this.commandMenuKeyboardDebugState(),
mapMenuVisible: this.mapMenuObjects.length > 0,
mapMenuKeyboard: this.mapMenuKeyboardDebugState(),
saveSlotPanelVisible: this.saveSlotPanelObjects.length > 0,
saveSlotPanelMode: this.saveSlotPanelMode ?? null,
resultVisible: this.resultObjects.length > 0,

View File

@@ -31,6 +31,8 @@ import {
import {
firstBattleCamaraderieBonusFor,
firstBattleCamaraderieCoreRateBonus,
resolveFirstBattleCamaraderieSortieGuard,
resolveFirstCampCamaraderieGuidedAction,
resolveFirstBattleCamaraderieMemory,
type FirstBattleCamaraderieMemory
} from '../data/firstBattleCamaraderieMemory';
@@ -403,7 +405,7 @@ type FirstCampGuidedAction =
}
| {
kind: 'sortie';
label: '출진 준비';
label: '전우 조 편성하기' | '출진 준비';
targetId: null;
};
@@ -11506,6 +11508,7 @@ export class CampScene extends Phaser.Scene {
private thirdCampPreparationCardViews: ThirdCampPreparationCardView[] = [];
private thirdCampPreparationKeyboardIndex = 0;
private thirdCampIncompatibleSortieConfirmSignature?: string;
private firstBattleCamaraderieSortieConfirmSignature?: string;
private visitedTabs = new Set<CampTab>();
private selectedSortieUnitIds: string[] = [];
private sortieFormationAssignments: SortieFormationAssignments = {};
@@ -11561,6 +11564,7 @@ export class CampScene extends Phaser.Scene {
this.thirdCampPreparationBrowserOpen = false;
this.thirdCampPreparationKeyboardIndex = 0;
this.thirdCampIncompatibleSortieConfirmSignature = undefined;
this.firstBattleCamaraderieSortieConfirmSignature = undefined;
if (data?.completedAftermathBattleId) {
completeCampaignAftermath(data.completedAftermathBattleId);
}
@@ -13055,11 +13059,19 @@ export class CampScene extends Phaser.Scene {
targetId: followup.targetDialogueId
};
}
return {
kind: 'sortie',
label: '출진 준비',
targetId: null
};
const camaraderieMemory = this.firstBattleCamaraderieMemory();
const nextBattleId = this.currentSortieFlow().nextBattleId;
const sortieResonanceSelection =
this.campaign?.sortieResonanceSelection;
const selectedCoreBondId =
sortieResonanceSelection?.battleId === nextBattleId
? sortieResonanceSelection?.bondId
: undefined;
return resolveFirstCampCamaraderieGuidedAction({
memory: camaraderieMemory,
nextBattleId,
selectedCoreBondId
});
}
private runFirstCampGuidedAction() {
@@ -13069,12 +13081,14 @@ export class CampScene extends Phaser.Scene {
return;
}
if (action.kind === 'visit') {
this.clearCampNotice();
this.activeTab = 'visit';
this.selectedVisitId = action.targetId;
this.render();
this.startCampNavigation('CampVisitExplorationScene', { visitId: action.targetId });
return;
}
this.clearCampNotice();
this.activeTab = 'dialogue';
this.selectedDialogueId = action.targetId;
this.render();
@@ -13953,6 +13967,7 @@ export class CampScene extends Phaser.Scene {
}
private showRequestedSortiePrep() {
this.firstBattleCamaraderieSortieConfirmSignature = undefined;
if (this.openSortieImprovementOnCreate) {
this.prepareGuidedSortieImprovement();
}
@@ -18453,6 +18468,7 @@ export class CampScene extends Phaser.Scene {
this.persistSortieSelection();
const clearing = this.currentSortieResonanceBondId(scenario) === candidate.id;
this.campaign = setCampaignSortieResonanceSelection(scenario.id, clearing ? undefined : candidate.id);
this.firstBattleCamaraderieSortieConfirmSignature = undefined;
const guidedRecalculated = this.resetRecommendationAfterSortieStrategyChange();
const pairLabel = `${candidate.unitNames[0]}${candidate.unitNames[1]}`;
this.sortiePlanFeedback = clearing
@@ -21315,6 +21331,29 @@ export class CampScene extends Phaser.Scene {
return;
}
const sortieResonanceSelection =
this.campaign?.sortieResonanceSelection;
const selectedCamaraderieBondId =
sortieResonanceSelection?.battleId === flow.nextBattleId
? sortieResonanceSelection?.bondId
: undefined;
const camaraderieSortieGuard =
resolveFirstBattleCamaraderieSortieGuard({
memory: this.firstBattleCamaraderieMemory(),
nextBattleId: flow.nextBattleId,
selectedCoreBondId: selectedCamaraderieBondId,
selectedUnitIds: this.selectedSortieUnitIds,
previousSignature:
this.firstBattleCamaraderieSortieConfirmSignature
});
if (camaraderieSortieGuard.shouldWarn) {
this.firstBattleCamaraderieSortieConfirmSignature =
camaraderieSortieGuard.signature;
this.showCampNotice(camaraderieSortieGuard.notice);
this.showSortiePrep();
return;
}
const thirdCampSortie =
this.thirdCampPreparationSortieState();
if (
@@ -21580,6 +21619,7 @@ export class CampScene extends Phaser.Scene {
this.sortieCoreResonancePage = 0;
this.thirdCampPreparationBrowserOpen = false;
this.thirdCampIncompatibleSortieConfirmSignature = undefined;
this.firstBattleCamaraderieSortieConfirmSignature = undefined;
}
this.sortieComparisonPanelView = undefined;
this.sortiePursuitPanelView = undefined;