feat: make guidance recoverable across camp and battle
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user