feat: complete keyboard flows and stabilize camp focus

This commit is contained in:
2026-07-27 20:52:57 +09:00
parent c22f77b4e1
commit 43e5533515
10 changed files with 1938 additions and 232 deletions

View File

@@ -1728,6 +1728,24 @@ type CommandButton = {
command: BattleCommand;
background: Phaser.GameObjects.Rectangle;
text: Phaser.GameObjects.Text;
available: boolean;
accent: number;
};
type UsableMenuButton = {
usable: BattleUsable;
background: Phaser.GameObjects.Rectangle;
text: Phaser.GameObjects.Text;
available: boolean;
accent: number;
};
type KeyboardCommandMenuEntry = {
id: string;
label: string;
available: boolean;
accent: number;
background: Phaser.GameObjects.Rectangle;
};
type PendingMove = {
@@ -3839,7 +3857,14 @@ export class BattleScene extends Phaser.Scene {
private sidePanelTransitionActive = false;
private sidePanelTransitionLast?: SidePanelTransitionSnapshot;
private commandButtons: CommandButton[] = [];
private usableMenuButtons: UsableMenuButton[] = [];
private commandMenuObjects: Phaser.GameObjects.GameObject[] = [];
private commandMenuMode?: 'command' | 'usable';
private commandMenuUnitId?: string;
private commandMenuParentCommand?: UsableCommand;
private commandMenuAnchor?: { x: number; y: number };
private commandMenuFocusedIndex = -1;
private commandMenuFocusMarker?: Phaser.GameObjects.Text;
private deploymentObjects: Phaser.GameObjects.GameObject[] = [];
private deploymentSelectedUnitId?: string;
private deploymentNotice = '추천 배치는 이미 적용되어 있습니다. 필요하면 장수를 선택해 시작 구역 안에서 위치를 바꾸세요.';
@@ -4069,7 +4094,14 @@ export class BattleScene extends Phaser.Scene {
this.terrainPanelLayout = undefined;
this.sidePanelObjects = [];
this.commandButtons = [];
this.usableMenuButtons = [];
this.commandMenuObjects = [];
this.commandMenuMode = undefined;
this.commandMenuUnitId = undefined;
this.commandMenuParentCommand = undefined;
this.commandMenuAnchor = undefined;
this.commandMenuFocusedIndex = -1;
this.commandMenuFocusMarker = undefined;
this.deploymentObjects = [];
this.mapMenuObjects = [];
this.mapMenuLayout = undefined;
@@ -4351,6 +4383,10 @@ export class BattleScene extends Phaser.Scene {
if (this.tacticalInitiativePanelObjects.length > 0) {
return;
}
if (this.commandMenuObjects.length > 0) {
this.activateCommandMenuFocusedAction();
return;
}
if (this.turnPromptObjects.length > 0) {
this.activateTurnPromptFocusedAction();
return;
@@ -4404,6 +4440,15 @@ export class BattleScene extends Phaser.Scene {
return;
}
if (this.commandMenuObjects.length > 0) {
if (this.returnFromUsableMenu()) {
return;
}
if (this.cancelPendingMove()) {
return;
}
}
if (this.cancelDeploymentSelection()) {
soundDirector.playSelect();
return;
@@ -4452,6 +4497,9 @@ export class BattleScene extends Phaser.Scene {
if (this.handleTacticalInitiativeKey(event)) {
return;
}
if (this.handleCommandMenuNavigationKey(event)) {
return;
}
if (this.handleBattleKeyboardNavigation(event)) {
return;
}
@@ -8392,6 +8440,183 @@ export class BattleScene extends Phaser.Scene {
this.lastPointerFeedbackKey = undefined;
}
private activeCommandMenuEntries(): KeyboardCommandMenuEntry[] {
if (this.commandMenuMode === 'command') {
return this.commandButtons
.filter(({ background }) => background.active)
.map(({ command, background, available, accent }) => ({
id: command,
label: commandLabels[command],
available,
accent,
background
}));
}
if (this.commandMenuMode === 'usable') {
return this.usableMenuButtons
.filter(({ background }) => background.active)
.map(({ usable, background, available, accent }) => ({
id: usable.id,
label: usable.name,
available,
accent,
background
}));
}
return [];
}
private setCommandMenuFocus(index: number) {
const entries = this.activeCommandMenuEntries();
const entry = entries[index];
if (!entry?.available) {
return false;
}
this.commandMenuFocusedIndex = index;
entries.forEach((candidate, candidateIndex) => {
candidate.background.setStrokeStyle(
candidateIndex === index ? 3 : 1,
candidateIndex === index ? 0xffffff : candidate.accent,
candidateIndex === index ? 1 : candidate.available ? 0.74 : 0.46
);
});
if (!this.commandMenuFocusMarker?.active) {
const marker = this.add.text(0, 0, '▶', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '18px',
color: '#fff8df',
fontStyle: '700',
stroke: '#05080b',
strokeThickness: 4
});
marker.setName('command-keyboard-focus-marker');
marker.setOrigin(0, 0.5);
marker.setDepth(19);
this.commandMenuFocusMarker = marker;
this.commandMenuObjects.push(marker);
}
const bounds = entry.background.getBounds();
this.commandMenuFocusMarker
.setPosition(bounds.x + 8, bounds.centerY)
.setVisible(true);
return true;
}
private focusFirstAvailableCommandMenuItem() {
const entries = this.activeCommandMenuEntries();
const firstAvailableIndex = entries.findIndex(({ available }) => available);
this.commandMenuFocusedIndex = -1;
this.commandMenuFocusMarker?.setVisible(false);
if (firstAvailableIndex >= 0) {
this.setCommandMenuFocus(firstAvailableIndex);
}
}
private moveCommandMenuFocus(step: -1 | 1) {
const entries = this.activeCommandMenuEntries();
if (entries.length === 0) {
return false;
}
let index = this.commandMenuFocusedIndex;
for (let attempt = 0; attempt < entries.length; attempt += 1) {
index = (index + step + entries.length) % entries.length;
if (entries[index]?.available) {
const changed = index !== this.commandMenuFocusedIndex;
this.setCommandMenuFocus(index);
if (changed) {
soundDirector.playHover();
}
return true;
}
}
return false;
}
private handleCommandMenuNavigationKey(event: KeyboardEvent) {
if (this.commandMenuObjects.length === 0 || !this.commandMenuMode) {
return false;
}
const step =
event.code === 'ArrowUp'
? -1
: event.code === 'ArrowDown'
? 1
: event.code === 'Tab'
? event.shiftKey
? -1
: 1
: undefined;
if (step !== undefined) {
event.preventDefault();
this.moveCommandMenuFocus(step);
}
return true;
}
private activateCommandMenuFocusedAction() {
const entries = this.activeCommandMenuEntries();
const entry = entries[this.commandMenuFocusedIndex];
const unit = battleUnits.find(
(candidate) =>
candidate.id === this.commandMenuUnitId &&
candidate.id === this.selectedUnit?.id &&
candidate.hp > 0
);
if (!entry?.available || !unit) {
return false;
}
const bounds = entry.background.getBounds();
if (this.commandMenuMode === 'command') {
const button = this.commandButtons[this.commandMenuFocusedIndex];
if (!button || button.command !== entry.id) {
return false;
}
return this.activateCommandMenuChoice(unit, button.command, bounds.centerX, bounds.centerY);
}
const button = this.usableMenuButtons[this.commandMenuFocusedIndex];
if (!button || button.usable.id !== entry.id) {
return false;
}
return this.activateUsableMenuChoice(unit, button.usable);
}
private returnFromUsableMenu() {
if (this.commandMenuMode !== 'usable') {
return false;
}
const unit = battleUnits.find(
(candidate) =>
candidate.id === this.commandMenuUnitId &&
candidate.id === this.selectedUnit?.id &&
candidate.hp > 0
);
const parentCommand = this.commandMenuParentCommand;
const anchor = this.commandMenuAnchor;
if (!unit || !parentCommand || !anchor) {
return false;
}
soundDirector.playSelect();
this.showCommandMenu(unit, anchor.x, anchor.y);
const parentIndex = this.commandButtons.findIndex(({ command, available }) => command === parentCommand && available);
if (parentIndex >= 0) {
this.setCommandMenuFocus(parentIndex);
}
this.renderUnitDetail(
unit,
`${commandLabels[parentCommand]} 선택을 취소했습니다.\n키보드: ↑↓/Tab 이동 · Enter 선택 · Esc 이동 취소`
);
return true;
}
private handleBattleKeyboardNavigation(event: KeyboardEvent) {
if (
(this.phase !== 'moving' && this.phase !== 'targeting') ||
@@ -8763,7 +8988,10 @@ export class BattleScene extends Phaser.Scene {
}
this.showCommandMenu(unit, commandX, commandY);
this.renderUnitDetail(unit, '명령 선택 · 우클릭은 이동 취소');
this.renderUnitDetail(
unit,
'명령 선택 · 우클릭은 이동 취소\n키보드: ↑↓/Tab 이동 · Enter 선택 · Esc 이동 취소'
);
this.hideTurnEndPrompt();
if (this.firstBattleTutorialStep === 'move') {
this.suppressNextLeftClick = true;
@@ -10951,6 +11179,9 @@ export class BattleScene extends Phaser.Scene {
private showCommandMenu(unit: UnitData, pointerX: number, pointerY: number) {
this.hideCommandMenu();
this.commandMenuMode = 'command';
this.commandMenuUnitId = unit.id;
this.commandMenuAnchor = { x: pointerX, y: pointerY };
const commands: BattleCommand[] = ['attack', 'strategy', 'item', 'wait'];
const menuWidth = 330;
@@ -11064,27 +11295,13 @@ export class BattleScene extends Phaser.Scene {
}
if (choicePointer.leftButtonDown()) {
this.suppressNextLeftClick = true;
if (!this.acceptFirstBattleTutorialCommand(command)) {
return;
}
this.hideTurnEndPrompt();
if (command === 'strategy' || command === 'item') {
this.showUsableMenu(unit, command, choicePointer.x, choicePointer.y);
return;
}
if (command !== 'wait') {
this.beginDamageTargeting(unit, command);
if (command === 'attack' && this.phase === 'targeting' && this.firstBattleTutorialStep === 'attack-command') {
this.setFirstBattleTutorialStep('target-preview');
}
return;
}
this.completeUnitAction(unit, command);
this.activateCommandMenuChoice(unit, command, choicePointer.x, choicePointer.y);
}
};
const showDetail = () => {
if (info.available) {
this.setCommandMenuFocus(index);
}
background.setFillStyle(info.available ? 0x283947 : 0x27303a, 0.98);
this.renderUnitDetail(unit, `${commandLabels[command]}\n${info.detail}`);
};
@@ -11097,8 +11314,47 @@ export class BattleScene extends Phaser.Scene {
text.on('pointerout', clearHover);
text.on('pointerdown', chooseCommand);
this.commandButtons.push({ command, background, text });
this.commandButtons.push({
command,
background,
text,
available: info.available,
accent
});
});
this.focusFirstAvailableCommandMenuItem();
this.renderUnitDetail(
unit,
'명령 선택\n키보드: ↑↓/Tab 이동 · Enter 선택 · Esc 이동 취소'
);
}
private activateCommandMenuChoice(
unit: UnitData,
command: BattleCommand,
anchorX: number,
anchorY: number
) {
if (!this.acceptFirstBattleTutorialCommand(command)) {
return false;
}
this.hideTurnEndPrompt();
if (command === 'strategy' || command === 'item') {
this.showUsableMenu(unit, command, anchorX, anchorY);
soundDirector.playSelect();
return true;
}
if (command !== 'wait') {
this.beginDamageTargeting(unit, command);
if (command === 'attack' && this.phase === 'targeting' && this.firstBattleTutorialStep === 'attack-command') {
this.setFirstBattleTutorialStep('target-preview');
}
return this.phase === 'targeting';
}
this.completeUnitAction(unit, command);
return true;
}
private commandMenuPosition(pointerX: number, pointerY: number, width: number, height: number) {
@@ -11477,6 +11733,13 @@ export class BattleScene extends Phaser.Scene {
text.destroy();
});
this.commandButtons = [];
this.usableMenuButtons = [];
this.commandMenuMode = undefined;
this.commandMenuUnitId = undefined;
this.commandMenuParentCommand = undefined;
this.commandMenuAnchor = undefined;
this.commandMenuFocusedIndex = -1;
this.commandMenuFocusMarker = undefined;
}
private showUsableMenu(unit: UnitData, command: UsableCommand, pointerX: number, pointerY: number) {
@@ -11487,6 +11750,10 @@ export class BattleScene extends Phaser.Scene {
}
this.hideCommandMenu();
this.commandMenuMode = 'usable';
this.commandMenuUnitId = unit.id;
this.commandMenuParentCommand = command;
this.commandMenuAnchor = { x: pointerX, y: pointerY };
const forecasts = usables.map((usable) => ({ usable, forecast: this.usableMenuForecast(unit, usable) }));
const readyCount = forecasts.filter(({ forecast }) => forecast.available).length;
@@ -11645,7 +11912,7 @@ export class BattleScene extends Phaser.Scene {
const choose = (choicePointer: Phaser.Input.Pointer) => {
if (choicePointer.rightButtonDown()) {
this.showCommandMenu(unit, pointerX, pointerY);
this.returnFromUsableMenu();
return;
}
if (choicePointer.leftButtonDown()) {
@@ -11654,10 +11921,13 @@ export class BattleScene extends Phaser.Scene {
showDetail();
return;
}
this.beginUsableTargeting(unit, usable);
this.activateUsableMenuChoice(unit, usable);
}
};
const showDetail = () => {
if (forecast.available) {
this.setCommandMenuFocus(index);
}
bg.setFillStyle(forecast.available ? 0x283947 : 0x27303a, 0.98);
this.renderUnitDetail(unit, this.usableDetailText(unit, usable));
};
@@ -11673,7 +11943,29 @@ export class BattleScene extends Phaser.Scene {
name.on('pointerover', showDetail);
name.on('pointerout', clearHover);
name.on('pointerdown', choose);
this.usableMenuButtons.push({
usable,
background: bg,
text: name,
available: forecast.available,
accent
});
});
this.focusFirstAvailableCommandMenuItem();
this.renderUnitDetail(
unit,
`${commandLabels[command]} 선택\n키보드: ↑↓/Tab 이동 · Enter 선택 · Esc 명령으로`
);
}
private activateUsableMenuChoice(unit: UnitData, usable: BattleUsable) {
const forecast = this.usableMenuForecast(unit, usable);
if (!forecast.available) {
this.renderUnitDetail(unit, this.usableDetailText(unit, usable));
return false;
}
this.beginUsableTargeting(unit, usable);
return this.phase === 'targeting';
}
private availableUsables(unit: UnitData, command: UsableCommand) {
@@ -26473,7 +26765,10 @@ export class BattleScene extends Phaser.Scene {
this.clearMarkers();
soundDirector.playSelect();
this.showCommandMenu(unit, view?.sprite.x ?? this.tileCenterX(unit.x), view?.sprite.y ?? this.tileCenterY(unit.y));
this.renderUnitDetail(unit, '대상 선택을 취소했습니다.\n공격, 책략, 도구, 대기 중 하나를 다시 선택하세요.\n우클릭하면 이동 취소로 돌아갑니다.');
this.renderUnitDetail(
unit,
'대상 선택을 취소했습니다.\n공격, 책략, 도구, 대기 중 하나를 다시 선택하세요.\n키보드: ↑↓/Tab 이동 · Enter 선택 · Esc 이동 취소'
);
if (this.firstBattleTutorialStep === 'target-preview') {
this.setFirstBattleTutorialStep('attack-command', '대상 선택을 취소해 공격 명령 단계로 돌아왔습니다.');
}
@@ -30783,6 +31078,80 @@ export class BattleScene extends Phaser.Scene {
};
}
private commandMenuKeyboardDebugState() {
const entries = this.activeCommandMenuEntries();
const focused = entries[this.commandMenuFocusedIndex];
const guidance = this.sidePanelObjects.find(
(object): object is Phaser.GameObjects.Text =>
object instanceof Phaser.GameObjects.Text &&
object.active &&
object.visible &&
object.text.includes('↑↓/Tab')
);
const toBounds = (background?: Phaser.GameObjects.Rectangle) => {
const bounds = background?.active ? background.getBounds() : undefined;
return bounds
? { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }
: null;
};
const markerBounds =
this.commandMenuFocusMarker?.active && this.commandMenuFocusMarker.visible
? this.commandMenuFocusMarker.getBounds()
: undefined;
return {
visible: this.commandMenuObjects.length > 0 && Boolean(this.commandMenuMode),
mode: this.commandMenuMode ?? null,
unitId: this.commandMenuUnitId ?? null,
parentCommand: this.commandMenuParentCommand ?? null,
focusedIndex: this.commandMenuFocusedIndex,
guidance: guidance
? {
text: guidance.text,
bounds: {
x: guidance.getBounds().x,
y: guidance.getBounds().y,
width: guidance.getBounds().width,
height: guidance.getBounds().height
}
}
: null,
focusedItem: focused
? {
id: focused.id,
label: focused.label,
available: focused.available,
bounds: toBounds(focused.background),
lineWidth: focused.background.lineWidth
}
: null,
items: entries.map((entry, index) => ({
id: entry.id,
label: entry.label,
available: entry.available,
focused: index === this.commandMenuFocusedIndex,
bounds: toBounds(entry.background),
lineWidth: entry.background.lineWidth
})),
focusMarker: markerBounds
? {
visible: true,
text: this.commandMenuFocusMarker?.text ?? '',
bounds: {
x: markerBounds.x,
y: markerBounds.y,
width: markerBounds.width,
height: markerBounds.height
}
}
: {
visible: false,
text: this.commandMenuFocusMarker?.text ?? '',
bounds: null
}
};
}
private battleHudDebugState() {
const miniMap = this.miniMapLayout;
const selectedDot = this.selectedUnit ? this.miniMapUnitDots.get(this.selectedUnit.id) : undefined;
@@ -31868,6 +32237,7 @@ export class BattleScene extends Phaser.Scene {
this.mapBackground?.active && this.mapBackground.texture.key === battleScenario.mapTextureKey
),
commandMenuVisible: this.commandMenuObjects.length > 0,
commandMenuKeyboard: this.commandMenuKeyboardDebugState(),
mapMenuVisible: this.mapMenuObjects.length > 0,
saveSlotPanelVisible: this.saveSlotPanelObjects.length > 0,
saveSlotPanelMode: this.saveSlotPanelMode ?? null,

View File

@@ -261,6 +261,7 @@ import {
} from '../state/thirdCampPreparationActions';
import { clearCampaignBattleSavesForSlot } from '../state/battleSaveKeys';
import { releaseTextureSource } from '../render/loaderLifecycle';
import { isVisualMotionReduced } from '../settings/visualMotion';
import { palette } from '../ui/palette';
import { ensureLazyScene, type LazySceneKey } from './lazyScenes';
@@ -11461,6 +11462,7 @@ export class CampScene extends Phaser.Scene {
private campSkinTransitionRevision = 0;
private campSkinTransitionCompletedRevision = 0;
private campSkinTransitionActive = false;
private visualMotionReduced = false;
private ownedCampTextureKeys = new Set<string>();
private campSkinTransitionLast?: {
kind: 'backdrop-fade';
@@ -11593,6 +11595,7 @@ export class CampScene extends Phaser.Scene {
private retrySortieBattleId?: BattleScenarioId;
private skipIntroStoryForSortie = false;
private navigationPending = false;
private campNoticeRemovalTimer?: Phaser.Time.TimerEvent;
constructor() {
super('CampScene');
@@ -11615,6 +11618,10 @@ export class CampScene extends Phaser.Scene {
create() {
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.releaseOwnedCampTextures());
this.navigationPending = false;
this.visualMotionReduced = isVisualMotionReduced();
this.activeTab = 'status';
this.selectedCityLocation = 'information';
this.campNoticeRemovalTimer = undefined;
this.contentObjects = [];
this.campRosterPage = 0;
this.campRosterLayout = undefined;
@@ -11755,8 +11762,6 @@ export class CampScene extends Phaser.Scene {
: !dialogueComplete
? 'dialogue'
: 'market';
} else if (this.activeTab === 'city') {
this.activeTab = 'status';
}
const pendingDirectExplorationVisit = this.availableCampVisits().find(
(visit) =>
@@ -11766,6 +11771,8 @@ export class CampScene extends Phaser.Scene {
if (!cityStay && pendingDirectExplorationVisit) {
this.selectedVisitId = pendingDirectExplorationVisit.id;
this.activeTab = 'visit';
} else if (!cityStay && pendingPriorityReturnDialogueId) {
this.activeTab = 'dialogue';
}
this.initializeCityEquipmentContext();
soundDirector.playSoundscape({
@@ -11865,9 +11872,9 @@ export class CampScene extends Phaser.Scene {
fontStyle: '700'
})).setOrigin(0.5).setDepth(-17);
const transitionDurationMs = 420;
const transitionDurationMs = this.visualMotionReduced ? 0 : 420;
const transitionRevision = ++this.campSkinTransitionRevision;
this.campSkinTransitionActive = true;
this.campSkinTransitionActive = !this.visualMotionReduced;
this.campSkinTransitionLast = {
kind: 'backdrop-fade',
durationMs: transitionDurationMs,
@@ -11875,19 +11882,24 @@ export class CampScene extends Phaser.Scene {
fromAlpha: 0,
toAlpha: skin.backdropAlpha
};
this.tweens.add({
targets: backdrop,
alpha: skin.backdropAlpha,
duration: transitionDurationMs,
ease: 'Sine.easeOut',
onComplete: () => {
if (this.campSkinBackdrop !== backdrop) {
return;
if (this.visualMotionReduced) {
backdrop.setAlpha(skin.backdropAlpha);
this.campSkinTransitionCompletedRevision = transitionRevision;
} else {
this.tweens.add({
targets: backdrop,
alpha: skin.backdropAlpha,
duration: transitionDurationMs,
ease: 'Sine.easeOut',
onComplete: () => {
if (this.campSkinBackdrop !== backdrop) {
return;
}
this.campSkinTransitionCompletedRevision = transitionRevision;
this.campSkinTransitionActive = false;
}
this.campSkinTransitionCompletedRevision = transitionRevision;
this.campSkinTransitionActive = false;
}
});
});
}
this.renderStaticButtons();
this.render();
@@ -25162,21 +25174,30 @@ export class CampScene extends Phaser.Scene {
box.setDepth(depth);
const noticeObjects: Phaser.GameObjects.GameObject[] = [box, text];
this.dialogueObjects = noticeObjects;
this.tweens.add({
targets: noticeObjects,
alpha: 0,
delay: this.campNoticeHoldDuration(message),
duration: 320,
onComplete: () => {
noticeObjects.forEach((object) => object.active && object.destroy());
if (this.dialogueObjects === noticeObjects) {
this.dialogueObjects = [];
}
const removeNotice = () => {
noticeObjects.forEach((object) => object.active && object.destroy());
if (this.dialogueObjects === noticeObjects) {
this.dialogueObjects = [];
}
});
this.campNoticeRemovalTimer = undefined;
};
const holdDuration = this.campNoticeHoldDuration(message);
if (this.visualMotionReduced) {
this.campNoticeRemovalTimer = this.time.delayedCall(holdDuration, removeNotice);
} else {
this.tweens.add({
targets: noticeObjects,
alpha: 0,
delay: holdDuration,
duration: 320,
onComplete: removeNotice
});
}
}
private clearCampNotice() {
this.campNoticeRemovalTimer?.remove(false);
this.campNoticeRemovalTimer = undefined;
this.tweens.killTweensOf(this.dialogueObjects);
this.dialogueObjects.forEach((object) => object.active && object.destroy());
this.dialogueObjects = [];
@@ -26228,6 +26249,7 @@ export class CampScene extends Phaser.Scene {
: undefined;
return {
scene: this.scene.key,
visualMotionReduced: this.visualMotionReduced,
victoryRewardAcknowledgement: {
visible: this.victoryRewardObjects.length > 0,
battleId: this.pendingVictoryRewardReport?.battleId ?? null,

View File

@@ -14,6 +14,7 @@ import {
updateAudioPreference
} from '../settings/audioPreferences';
import {
isVisualMotionReduced,
loadVisualMotionMode,
nextVisualMotionMode,
saveVisualMotionMode,
@@ -43,11 +44,31 @@ const fhdUiScale = 1.5;
const ui = (value: number) => value * fhdUiScale;
const uiPx = (value: number) => `${Math.round(ui(value))}px`;
type TitleFocusScope = 'main' | 'settings' | 'save-slots' | 'new-game-confirm';
type TitleFocusItem = {
id: string;
enabled: boolean;
marker: Phaser.GameObjects.Text;
activate: () => void;
render: (focused: boolean) => void;
bounds: () => Phaser.Geom.Rectangle;
};
export class TitleScene extends Phaser.Scene {
private focusedButton?: Phaser.GameObjects.Text;
private settingsPanel?: Phaser.GameObjects.Container;
private newGameConfirmPanel?: Phaser.GameObjects.Container;
private saveSlotPanel?: Phaser.GameObjects.Container;
private readonly focusItems = new Map<TitleFocusScope, TitleFocusItem[]>();
private focusScope: TitleFocusScope = 'main';
private focusedItemId?: string;
private titleBackground?: Phaser.GameObjects.Image;
private titleBackgroundCoverScale = 1;
private titleMist?: Phaser.GameObjects.Ellipse;
private titlePetals: Phaser.GameObjects.Image[] = [];
private titleMotionTweens: Phaser.Tweens.Tween[] = [];
private visualMotionReduced = false;
private handledKeyboardEvents = new WeakSet<KeyboardEvent>();
private navigating = false;
constructor() {
@@ -55,10 +76,18 @@ export class TitleScene extends Phaser.Scene {
}
create() {
this.focusedButton = undefined;
this.settingsPanel = undefined;
this.newGameConfirmPanel = undefined;
this.saveSlotPanel = undefined;
this.focusItems.clear();
this.focusScope = 'main';
this.focusedItemId = undefined;
this.titleBackground = undefined;
this.titleMist = undefined;
this.titlePetals = [];
this.titleMotionTweens = [];
this.visualMotionReduced = isVisualMotionReduced();
this.handledKeyboardEvents = new WeakSet<KeyboardEvent>();
this.navigating = false;
const debugBattleId = this.debugBattleId();
@@ -90,6 +119,7 @@ export class TitleScene extends Phaser.Scene {
const { width, height } = this.scale;
this.drawBackground(width, height);
this.drawAtmosphere(width, height);
this.applyTitleMotionPreference(this.visualMotionReduced);
this.drawTitleMark(width, height);
this.drawMenu(width, height);
@@ -102,9 +132,11 @@ export class TitleScene extends Phaser.Scene {
this.input.once('pointerdown', unlockAudio);
this.input.keyboard?.once('keydown', unlockAudio);
this.input.keyboard?.on('keydown-ENTER', (event: KeyboardEvent) => this.handleEnterKey(event));
this.input.keyboard?.on('keydown', (event: KeyboardEvent) => this.handleSaveSlotKey(event));
this.input.keyboard?.on('keydown-ESC', () => this.handleEscapeKey());
this.input.keyboard?.on('keydown', this.handleKeyboardInput, this);
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
this.input.keyboard?.off('keydown', this.handleKeyboardInput, this);
this.stopTitleMotionTweens();
});
}
private shouldOpenDebugSortiePrep() {
@@ -173,18 +205,8 @@ export class TitleScene extends Phaser.Scene {
const texture = this.textures.get('title-taoyuan').getSourceImage();
const coverScale = Math.max(width / texture.width, height / texture.height);
background.setScale(coverScale * 1.08);
background.setPosition(width / 2 - ui(18), height / 2 + ui(4));
this.tweens.add({
targets: background,
x: width / 2 + ui(18),
y: height / 2 - ui(6),
scale: coverScale * 1.12,
duration: 18000,
ease: 'Sine.easeInOut',
yoyo: true,
repeat: -1
});
this.titleBackground = background;
this.titleBackgroundCoverScale = coverScale;
this.add.rectangle(0, 0, width, height, 0x080a0e, 0.2).setOrigin(0);
this.add.image(0, 0, this.createShadeTexture(width, height)).setOrigin(0);
@@ -224,15 +246,7 @@ export class TitleScene extends Phaser.Scene {
private drawAtmosphere(width: number, height: number) {
const mist = this.add.ellipse(width / 2, height * 0.78, width * 1.25, ui(190), 0xd8b15f, 0.04);
this.tweens.add({
targets: mist,
alpha: 0.11,
x: width / 2 + ui(22),
duration: 8000,
ease: 'Sine.easeInOut',
yoyo: true,
repeat: -1
});
this.titleMist = mist;
for (let index = 0; index < 34; index += 1) {
const petal = this.add.image(
@@ -244,8 +258,63 @@ export class TitleScene extends Phaser.Scene {
petal.setScale(scale * fhdUiScale);
petal.setAlpha(Phaser.Math.FloatBetween(0.22, 0.62));
petal.setRotation(Phaser.Math.FloatBetween(-1.2, 1.2));
this.titlePetals.push(petal);
}
}
this.tweens.add({
private applyTitleMotionPreference(reduced: boolean) {
this.visualMotionReduced = reduced;
this.stopTitleMotionTweens();
const { width, height } = this.scale;
this.titleBackground
?.setPosition(width / 2, height / 2)
.setScale(this.titleBackgroundCoverScale * 1.1);
this.titleMist?.setPosition(width / 2, height * 0.78).setAlpha(reduced ? 0.07 : 0.04);
this.titlePetals.forEach((petal, index) => {
const x = ((index * 211 + 97) % Math.max(1, width + ui(120))) - ui(60);
const y = ((index * 137 + 43) % Math.max(1, height + ui(100))) - ui(80);
petal
.setVisible(true)
.setPosition(x, y)
.setRotation(-0.9 + (index % 9) * 0.21);
});
if (reduced) {
return;
}
if (this.titleBackground) {
this.titleBackground
.setPosition(width / 2 - ui(18), height / 2 + ui(4))
.setScale(this.titleBackgroundCoverScale * 1.08);
this.trackTitleMotionTween({
targets: this.titleBackground,
x: width / 2 + ui(18),
y: height / 2 - ui(6),
scale: this.titleBackgroundCoverScale * 1.12,
duration: 18000,
ease: 'Sine.easeInOut',
yoyo: true,
repeat: -1
});
}
if (this.titleMist) {
this.trackTitleMotionTween({
targets: this.titleMist,
alpha: 0.11,
x: width / 2 + ui(22),
duration: 8000,
ease: 'Sine.easeInOut',
yoyo: true,
repeat: -1
});
}
this.titlePetals.forEach((petal) => {
this.trackTitleMotionTween({
targets: petal,
x: petal.x + Phaser.Math.Between(ui(80), ui(220)),
y: height + Phaser.Math.Between(ui(40), ui(180)),
@@ -258,7 +327,18 @@ export class TitleScene extends Phaser.Scene {
petal.setPosition(Phaser.Math.Between(-ui(120), width), Phaser.Math.Between(-ui(180), -ui(40)));
}
});
}
});
}
private trackTitleMotionTween(config: Phaser.Types.Tweens.TweenBuilderConfig) {
const tween = this.tweens.add(config);
this.titleMotionTweens.push(tween);
return tween;
}
private stopTitleMotionTweens() {
this.titleMotionTweens.forEach((tween) => tween.stop());
this.titleMotionTweens = [];
}
private drawTitleMark(width: number, height: number) {
@@ -303,13 +383,22 @@ export class TitleScene extends Phaser.Scene {
this.add.rectangle(menuX, menuY - plateHeight / 2, ui(186), ui(2), palette.gold, 0.7);
this.add.rectangle(menuX, menuY + plateHeight / 2, ui(186), ui(2), palette.gold, 0.36);
this.createMenuButton(menuX, menuY - ui(70), '새 게임', true, () => this.requestStartGame());
this.createMenuButton(menuX, menuY, isEndingComplete ? '엔딩 보기' : '이어하기', canContinue, () => this.requestContinueGame());
this.createMenuButton(menuX, menuY + ui(70), '설정', true, () => this.openSettingsPanel(menuX, menuY));
this.createMenuButton('new-game', menuX, menuY - ui(70), '새 게임', true, () => this.requestStartGame());
this.createMenuButton(
'continue',
menuX,
menuY,
isEndingComplete ? '엔딩 보기' : '이어하기',
canContinue,
() => this.requestContinueGame()
);
this.createMenuButton('settings', menuX, menuY + ui(70), '설정', true, () => this.openSettingsPanel(menuX, menuY));
if (campaign) {
this.drawCampaignSaveBadge(menuX, menuY + ui(132), campaign, isEndingComplete);
}
this.setFocusScope('main', canContinue ? 'continue' : 'new-game');
}
private drawCampaignSaveBadge(
@@ -404,6 +493,7 @@ export class TitleScene extends Phaser.Scene {
}
private createMenuButton(
id: string,
x: number,
y: number,
label: string,
@@ -421,41 +511,49 @@ export class TitleScene extends Phaser.Scene {
text.setOrigin(0.5);
text.setShadow(0, ui(3), '#080a0e', ui(8), true, true);
if (!enabled) {
return text;
}
const marker = this.add.text(x - ui(116), y, '▶', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(17),
color: '#f6e6bd',
fontStyle: '700'
});
marker.setOrigin(0.5).setVisible(false);
const hitArea = this.add.zone(x, y, ui(216), ui(58));
hitArea.setOrigin(0.5);
hitArea.setInteractive({ useHandCursor: true });
hitArea.on('pointerover', () => {
this.focusButton(text);
soundDirector.playHover();
});
hitArea.on('pointerout', () => this.unfocusButton(text));
hitArea.on('pointerdown', () => {
soundDirector.start();
soundDirector.resume();
onSelect();
const render = (focused: boolean) => {
marker.setVisible(enabled && focused);
text.setColor(!enabled ? '#7d8189' : focused ? '#111821' : '#f1e3c2');
text.setBackgroundColor(enabled && focused ? '#d8b15f' : 'rgba(0,0,0,0)');
};
this.registerFocusItem('main', {
id,
enabled,
marker,
activate: onSelect,
render,
bounds: () => hitArea.getBounds()
});
render(false);
if (enabled) {
hitArea.setInteractive({ useHandCursor: true });
hitArea.on('pointerover', () => this.focusItem('main', id, true));
hitArea.on('pointerdown', () => {
if (this.activeModalScope()) {
return;
}
this.focusItem('main', id);
soundDirector.start();
soundDirector.resume();
onSelect();
});
}
return text;
}
private focusButton(text: Phaser.GameObjects.Text) {
this.focusedButton = text;
text.setColor('#111821');
text.setBackgroundColor('#d8b15f');
}
private unfocusButton(text: Phaser.GameObjects.Text) {
if (this.focusedButton === text) {
this.focusedButton = undefined;
}
text.setColor('#f1e3c2');
text.setBackgroundColor('rgba(0,0,0,0)');
}
private openSettingsPanel(x: number, y: number) {
if (this.settingsPanel) {
this.closeSettingsPanel();
@@ -463,8 +561,9 @@ export class TitleScene extends Phaser.Scene {
}
soundDirector.playSelect();
this.closeNewGameConfirmPanel();
this.closeSaveSlotPanel();
this.closeNewGameConfirmPanel(false);
this.closeSaveSlotPanel(false);
this.clearFocusScope('settings');
const panel = this.add.container(x, y + ui(126));
const backdrop = this.add.rectangle(0, 0, ui(320), ui(468), 0x0b1118, 1);
@@ -480,48 +579,98 @@ export class TitleScene extends Phaser.Scene {
});
title.setOrigin(0.5);
const soundText = this.createSettingsButton(0, -ui(126), this.soundLabel());
soundText.on('pointerdown', () => {
const preferences = loadAudioPreferences();
const muted = !soundDirector.isMuted();
soundDirector.setMuted(muted);
saveAudioPreferences({ ...preferences, muted });
soundText.setText(this.soundLabel());
soundDirector.playSelect();
});
this.createSettingsButton(
panel,
'settings',
'sound',
0,
-ui(126),
this.soundLabel(),
(text) => {
const preferences = loadAudioPreferences();
const muted = !soundDirector.isMuted();
soundDirector.setMuted(muted);
saveAudioPreferences({ ...preferences, muted });
text.setText(this.soundLabel());
soundDirector.playSelect();
}
);
const musicText = this.createSettingsButton(0, -ui(84), this.audioCategoryLabel('music'));
musicText.on('pointerdown', () => this.cycleAudioLevel('music', musicText));
this.createSettingsButton(
panel,
'settings',
'music',
0,
-ui(84),
this.audioCategoryLabel('music'),
(text) => this.cycleAudioLevel('music', text)
);
const effectsText = this.createSettingsButton(0, -ui(42), this.audioCategoryLabel('effects'));
effectsText.on('pointerdown', () => this.cycleAudioLevel('effects', effectsText));
this.createSettingsButton(
panel,
'settings',
'effects',
0,
-ui(42),
this.audioCategoryLabel('effects'),
(text) => this.cycleAudioLevel('effects', text)
);
const ambienceText = this.createSettingsButton(0, 0, this.audioCategoryLabel('ambience'));
ambienceText.on('pointerdown', () => this.cycleAudioLevel('ambience', ambienceText));
this.createSettingsButton(
panel,
'settings',
'ambience',
0,
0,
this.audioCategoryLabel('ambience'),
(text) => this.cycleAudioLevel('ambience', text)
);
const presentationText = this.createSettingsButton(0, ui(42), this.combatPresentationLabel());
presentationText.on('pointerdown', () => {
const nextMode = nextCombatPresentationMode(loadCombatPresentationMode());
saveCombatPresentationMode(nextMode);
presentationText.setText(this.combatPresentationLabel());
soundDirector.playSelect();
});
this.createSettingsButton(
panel,
'settings',
'combat-presentation',
0,
ui(42),
this.combatPresentationLabel(),
(text) => {
const nextMode = nextCombatPresentationMode(loadCombatPresentationMode());
saveCombatPresentationMode(nextMode);
text.setText(this.combatPresentationLabel());
soundDirector.playSelect();
}
);
const motionText = this.createSettingsButton(0, ui(84), this.visualMotionLabel());
motionText.on('pointerdown', () => {
const nextMode = nextVisualMotionMode(loadVisualMotionMode());
saveVisualMotionMode(nextMode);
motionText.setText(this.visualMotionLabel());
soundDirector.playSelect();
});
this.createSettingsButton(
panel,
'settings',
'visual-motion',
0,
ui(84),
this.visualMotionLabel(),
(text) => {
const nextMode = nextVisualMotionMode(loadVisualMotionMode());
saveVisualMotionMode(nextMode);
this.applyTitleMotionPreference(nextMode === 'reduced');
text.setText(this.visualMotionLabel());
soundDirector.playSelect();
}
);
const close = this.createSettingsButton(0, ui(132), '닫기');
close.on('pointerdown', () => {
soundDirector.playSelect();
this.closeSettingsPanel();
});
this.createSettingsButton(
panel,
'settings',
'close',
0,
ui(132),
'닫기',
() => {
soundDirector.playSelect();
this.closeSettingsPanel();
}
);
const hint = this.add.text(0, ui(178), '항목을 눌러 단계 변경 · Esc 닫기', {
const hint = this.add.text(0, ui(178), '↑↓/Tab 이동 · Enter/Space 변경\nEsc 닫기', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(11),
color: '#9fb0bf',
@@ -531,24 +680,70 @@ export class TitleScene extends Phaser.Scene {
});
hint.setOrigin(0.5);
panel.add([backdrop, title, soundText, musicText, effectsText, ambienceText, presentationText, motionText, close, hint]);
panel.addAt([backdrop, title, hint], 0);
panel.setAlpha(0);
this.settingsPanel = panel;
this.tweens.add({ targets: panel, alpha: 1, y: y + ui(116), duration: 160, ease: 'Sine.easeOut' });
this.tweens.add({
targets: panel,
alpha: 1,
y: y + ui(116),
duration: this.visualMotionReduced ? 0 : 160,
ease: 'Sine.easeOut'
});
this.setFocusScope('settings', 'sound');
}
private createSettingsButton(x: number, y: number, label: string) {
private createSettingsButton(
panel: Phaser.GameObjects.Container,
scope: Exclude<TitleFocusScope, 'main'>,
id: string,
x: number,
y: number,
label: string,
onSelect: (text: Phaser.GameObjects.Text) => void,
baseColor = '#d8b15f'
) {
const text = this.add.text(x, y, label, {
fontSize: uiPx(20),
color: '#d8b15f',
color: baseColor,
fixedWidth: ui(210),
align: 'center',
padding: { top: ui(8), bottom: ui(8) }
});
text.setOrigin(0.5);
text.setInteractive({ useHandCursor: true });
text.on('pointerover', () => text.setColor('#f1e3c2'));
text.on('pointerout', () => text.setColor('#d8b15f'));
const marker = this.add.text(x - ui(124), y, '▶', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(14),
color: '#f6e6bd',
fontStyle: '700'
});
marker.setOrigin(0.5).setVisible(false);
const render = (focused: boolean) => {
marker.setVisible(focused);
text.setColor(focused ? '#111821' : baseColor);
text.setBackgroundColor(focused ? '#d8b15f' : 'rgba(0,0,0,0)');
};
panel.add([marker, text]);
this.registerFocusItem(scope, {
id,
enabled: true,
marker,
activate: () => onSelect(text),
render,
bounds: () => text.getBounds()
});
render(false);
text.on('pointerover', () => this.focusItem(scope, id, true));
text.on('pointerdown', () => {
this.focusItem(scope, id);
soundDirector.start();
soundDirector.resume();
onSelect(text);
});
return text;
}
@@ -584,9 +779,13 @@ export class TitleScene extends Phaser.Scene {
return `화면 움직임: ${visualMotionModeLabel(loadVisualMotionMode())}`;
}
private closeSettingsPanel() {
private closeSettingsPanel(restoreFocus = true) {
this.clearFocusScope('settings');
this.settingsPanel?.destroy();
this.settingsPanel = undefined;
if (restoreFocus) {
this.setFocusScope('main', 'settings');
}
}
private requestContinueGame() {
@@ -614,12 +813,14 @@ export class TitleScene extends Phaser.Scene {
}
soundDirector.playSelect();
this.closeSettingsPanel();
this.closeNewGameConfirmPanel();
this.closeSettingsPanel(false);
this.closeNewGameConfirmPanel(false);
this.clearFocusScope('save-slots');
const panel = this.add.container(x, y + ui(252));
const backdrop = this.add.rectangle(0, 0, ui(318), ui(280), 0x0e151d, 0.95);
backdrop.setStrokeStyle(ui(1), palette.gold, 0.58);
backdrop.setInteractive();
const title = this.add.text(0, -ui(116), '이어갈 기록 선택 (1~3)', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
@@ -632,13 +833,20 @@ export class TitleScene extends Phaser.Scene {
title.setOrigin(0.5);
const rows = listCampaignSaveSlots().map((slot, index) => this.createSaveSlotRow(slot, -ui(82) + index * ui(58)));
const close = this.createSettingsButton(0, ui(96), '닫기');
close.on('pointerdown', () => {
soundDirector.playSelect();
this.closeSaveSlotPanel();
});
this.createSettingsButton(
panel,
'save-slots',
'close',
0,
ui(96),
'닫기',
() => {
soundDirector.playSelect();
this.closeSaveSlotPanel();
}
);
const hint = this.add.text(0, ui(126), '숫자키 1~3 선택 · Esc 닫기', {
const hint = this.add.text(0, ui(120), '↑↓/Tab 이동 · Enter/Space 선택\n숫자키 1~3 · Esc 닫기', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(11),
color: '#9fb0bf',
@@ -648,10 +856,18 @@ export class TitleScene extends Phaser.Scene {
});
hint.setOrigin(0.5);
panel.add([backdrop, title, ...rows, close, hint]);
panel.addAt([backdrop, title, ...rows, hint], 0);
panel.setAlpha(0);
this.saveSlotPanel = panel;
this.tweens.add({ targets: panel, alpha: 1, y: y + ui(242), duration: 160, ease: 'Sine.easeOut' });
this.tweens.add({
targets: panel,
alpha: 1,
y: y + ui(242),
duration: this.visualMotionReduced ? 0 : 160,
ease: 'Sine.easeOut'
});
const activeSlot = loadCampaignState().activeSaveSlot;
this.setFocusScope('save-slots', `slot-${activeSlot}`);
}
private createSaveSlotRow(slot: CampaignSaveSlotSummary, y: number) {
@@ -701,20 +917,41 @@ export class TitleScene extends Phaser.Scene {
});
keyText.setOrigin(0.5);
row.add([background, title, detail, meta, keycap, keyText]);
const marker = this.add.text(-ui(143), 0, '▶', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(12),
color: '#f6e6bd',
fontStyle: '700'
});
marker.setOrigin(0.5).setVisible(false);
row.add([background, title, detail, meta, keycap, keyText, marker]);
const render = (focused: boolean) => {
marker.setVisible(enabled && focused);
background.setFillStyle(enabled && focused ? 0xd8b15f : enabled ? 0x1b2530 : 0x121820, enabled && focused ? 0.22 : enabled ? 0.9 : 0.56);
background.setStrokeStyle(
ui(focused ? 2 : 1),
enabled ? palette.gold : 0x56616d,
enabled && focused ? 0.9 : enabled ? 0.42 : 0.2
);
};
this.registerFocusItem('save-slots', {
id: `slot-${slot.slot}`,
enabled,
marker,
activate: () => this.activateSaveSlot(slot.slot),
render,
bounds: () => background.getBounds()
});
render(false);
if (enabled) {
background.setInteractive({ useHandCursor: true });
background.on('pointerover', () => {
background.setFillStyle(0xd8b15f, 0.22);
background.setStrokeStyle(ui(1), palette.gold, 0.72);
soundDirector.playHover();
});
background.on('pointerout', () => {
background.setFillStyle(0x1b2530, 0.9);
background.setStrokeStyle(ui(1), palette.gold, 0.42);
});
background.on('pointerover', () => this.focusItem('save-slots', `slot-${slot.slot}`, true));
background.on('pointerdown', () => {
this.focusItem('save-slots', `slot-${slot.slot}`);
this.activateSaveSlot(slot.slot);
});
}
@@ -722,54 +959,156 @@ export class TitleScene extends Phaser.Scene {
return row;
}
private handleSaveSlotKey(event: KeyboardEvent) {
if (!this.saveSlotPanel) {
return;
}
const slot = Number(event.key);
if (!Number.isInteger(slot)) {
return;
}
event.preventDefault();
this.activateSaveSlot(slot);
}
private activateSaveSlot(slot: number) {
if (!listCampaignSaveSlots().some((summary) => summary.slot === slot && summary.exists)) {
return;
}
this.closeSaveSlotPanel();
this.closeSaveSlotPanel(false);
this.continueGame(slot);
}
private closeSaveSlotPanel() {
private closeSaveSlotPanel(restoreFocus = true) {
this.clearFocusScope('save-slots');
this.saveSlotPanel?.destroy();
this.saveSlotPanel = undefined;
if (restoreFocus) {
this.setFocusScope('main', 'continue');
}
}
private handleEnterKey(event?: KeyboardEvent) {
if (event?.repeat || this.navigating) {
private registerFocusItem(scope: TitleFocusScope, item: TitleFocusItem) {
const items = this.focusItems.get(scope) ?? [];
const duplicateIndex = items.findIndex((candidate) => candidate.id === item.id);
if (duplicateIndex >= 0) {
items[duplicateIndex] = item;
} else {
items.push(item);
}
this.focusItems.set(scope, items);
}
private setFocusScope(scope: TitleFocusScope, preferredId?: string) {
this.currentFocusItem()?.render(false);
this.focusScope = scope;
const enabledItems = (this.focusItems.get(scope) ?? []).filter((item) => item.enabled);
const nextItem = enabledItems.find((item) => item.id === preferredId) ?? enabledItems[0];
this.focusedItemId = nextItem?.id;
nextItem?.render(true);
}
private clearFocusScope(scope: TitleFocusScope) {
const items = this.focusItems.get(scope) ?? [];
items.forEach((item) => item.render(false));
this.focusItems.delete(scope);
if (this.focusScope === scope) {
this.focusedItemId = undefined;
}
}
private focusItem(scope: TitleFocusScope, id: string, playHover = false) {
const modalScope = this.activeModalScope();
if (modalScope && scope !== modalScope) {
return;
}
if (this.saveSlotPanel) {
const nextItem = (this.focusItems.get(scope) ?? []).find((item) => item.id === id && item.enabled);
if (!nextItem) {
return;
}
if (this.newGameConfirmPanel) {
this.closeNewGameConfirmPanel();
this.startGame();
return;
const changed = this.focusScope !== scope || this.focusedItemId !== id;
this.currentFocusItem()?.render(false);
this.focusScope = scope;
this.focusedItemId = id;
nextItem.render(true);
if (changed && playHover) {
soundDirector.playHover();
}
}
private activeModalScope(): Exclude<TitleFocusScope, 'main'> | undefined {
if (this.settingsPanel) {
return 'settings';
}
if (this.saveSlotPanel) {
return 'save-slots';
}
if (this.newGameConfirmPanel) {
return 'new-game-confirm';
}
return undefined;
}
private moveFocus(direction: 1 | -1) {
const enabledItems = (this.focusItems.get(this.focusScope) ?? []).filter((item) => item.enabled);
if (enabledItems.length === 0) {
return;
}
this.activateDefaultMenuAction();
const currentIndex = enabledItems.findIndex((item) => item.id === this.focusedItemId);
const nextIndex = currentIndex < 0
? 0
: (currentIndex + direction + enabledItems.length) % enabledItems.length;
this.focusItem(this.focusScope, enabledItems[nextIndex].id, true);
}
private currentFocusItem() {
return (this.focusItems.get(this.focusScope) ?? []).find((item) => item.id === this.focusedItemId);
}
private activateFocusedItem() {
const item = this.currentFocusItem();
if (!item?.enabled) {
return;
}
soundDirector.start();
soundDirector.resume();
item.activate();
}
private handleKeyboardInput(event: KeyboardEvent) {
if (this.handledKeyboardEvents.has(event)) {
return;
}
this.handledKeyboardEvents.add(event);
if (event.repeat || this.navigating) {
return;
}
if (event.key === 'Escape') {
event.preventDefault();
this.handleEscapeKey();
return;
}
if (this.saveSlotPanel && /^[1-3]$/.test(event.key)) {
event.preventDefault();
const slot = Number(event.key);
this.focusItem('save-slots', `slot-${slot}`);
this.activateSaveSlot(slot);
return;
}
if (event.key === 'ArrowUp' || (event.key === 'Tab' && event.shiftKey)) {
event.preventDefault();
this.moveFocus(-1);
return;
}
if (event.key === 'ArrowDown' || event.key === 'Tab') {
event.preventDefault();
this.moveFocus(1);
return;
}
if (event.key === 'Enter' || event.key === ' ' || event.code === 'Space') {
event.preventDefault();
this.activateFocusedItem();
}
}
private handleEscapeKey() {
@@ -811,12 +1150,14 @@ export class TitleScene extends Phaser.Scene {
}
soundDirector.playSelect();
this.closeSettingsPanel();
this.closeSaveSlotPanel();
this.closeSettingsPanel(false);
this.closeSaveSlotPanel(false);
this.clearFocusScope('new-game-confirm');
const panel = this.add.container(x, y + ui(240));
const backdrop = this.add.rectangle(0, 0, ui(286), ui(198), 0x0e151d, 0.94);
backdrop.setStrokeStyle(ui(1), palette.gold, 0.58);
backdrop.setInteractive();
const title = this.add.text(0, -ui(72), '새로 시작', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
@@ -838,7 +1179,7 @@ export class TitleScene extends Phaser.Scene {
});
message.setOrigin(0.5);
const hint = this.add.text(0, ui(13), 'Enter 새 게임 · Esc 취소', {
const hint = this.add.text(0, ui(13), '↑↓ 선택 · Enter/Space 결정 · Esc 취소', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(11),
color: '#9fb0bf',
@@ -848,37 +1189,100 @@ export class TitleScene extends Phaser.Scene {
});
hint.setOrigin(0.5);
const confirm = this.createSettingsButton(0, ui(48), '새 게임 시작');
confirm.setColor('#f1e3c2');
confirm.on('pointerdown', () => {
this.closeNewGameConfirmPanel();
this.startGame();
});
this.createSettingsButton(
panel,
'new-game-confirm',
'confirm',
0,
ui(48),
'새 게임 시작',
() => {
this.closeNewGameConfirmPanel(false);
this.startGame();
},
'#f1e3c2'
);
const cancel = this.createSettingsButton(0, ui(88), '취소');
cancel.on('pointerdown', () => {
soundDirector.playSelect();
this.closeNewGameConfirmPanel();
});
this.createSettingsButton(
panel,
'new-game-confirm',
'cancel',
0,
ui(88),
'취소',
() => {
soundDirector.playSelect();
this.closeNewGameConfirmPanel();
}
);
panel.add([backdrop, title, message, hint, confirm, cancel]);
panel.addAt([backdrop, title, message, hint], 0);
panel.setAlpha(0);
this.newGameConfirmPanel = panel;
this.tweens.add({ targets: panel, alpha: 1, y: y + ui(230), duration: 160, ease: 'Sine.easeOut' });
this.tweens.add({
targets: panel,
alpha: 1,
y: y + ui(230),
duration: this.visualMotionReduced ? 0 : 160,
ease: 'Sine.easeOut'
});
this.setFocusScope('new-game-confirm', 'confirm');
}
private closeNewGameConfirmPanel() {
private closeNewGameConfirmPanel(restoreFocus = true) {
this.clearFocusScope('new-game-confirm');
this.newGameConfirmPanel?.destroy();
this.newGameConfirmPanel = undefined;
if (restoreFocus) {
this.setFocusScope('main', 'new-game');
}
}
private activateDefaultMenuAction() {
if (hasCampaignSave()) {
this.requestContinueGame();
return;
}
getDebugState() {
const items = (this.focusItems.get(this.focusScope) ?? []).map((item) => {
const bounds = item.bounds();
return {
id: item.id,
enabled: item.enabled,
focused: item.id === this.focusedItemId,
focusIndicatorVisible: item.marker.visible,
bounds: {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height
}
};
});
this.startGame();
return {
scene: 'TitleScene',
navigating: this.navigating,
focus: {
scope: this.focusScope,
itemId: this.focusedItemId,
items
},
panels: {
settings: Boolean(this.settingsPanel),
saveSlots: Boolean(this.saveSlotPanel),
newGameConfirm: Boolean(this.newGameConfirmPanel)
},
motion: {
mode: loadVisualMotionMode(),
reduced: this.visualMotionReduced,
activeInfiniteTweenCount: this.titleMotionTweens.length,
background: this.titleBackground
? {
x: this.titleBackground.x,
y: this.titleBackground.y,
scale: this.titleBackground.scale
}
: undefined,
mistAlpha: this.titleMist?.alpha,
petalCount: this.titlePetals.length
}
};
}
private startGame() {

View File

@@ -21,6 +21,10 @@ type DebugBattleScene = Phaser.Scene & {
debugForceBattleOutcome?: (outcome: 'victory' | 'defeat') => void;
};
type DebugTitleScene = Phaser.Scene & {
getDebugState?: () => unknown;
};
type DebugCampScene = Phaser.Scene & {
getDebugState?: () => unknown;
};
@@ -55,6 +59,7 @@ type HerosDebugApi = {
game: Phaser.Game;
activeScenes: () => string[];
audio: () => ReturnType<typeof soundDirector.getDebugState>;
title: () => unknown;
battle: () => unknown;
camp: () => unknown;
village: () => unknown;
@@ -115,6 +120,7 @@ if (debugEnabled) {
function createDebugApi(game: Phaser.Game): HerosDebugApi {
const scene = (key: string) => game.scene.getScene(key);
const titleScene = () => scene('TitleScene') as DebugTitleScene | undefined;
const battleScene = () => scene('BattleScene') as DebugBattleScene | undefined;
const campScene = () => scene('CampScene') as DebugCampScene | undefined;
const villageScene = () => scene('PrologueVillageScene') as DebugVillageScene | undefined;
@@ -127,6 +133,9 @@ function createDebugApi(game: Phaser.Game): HerosDebugApi {
game,
activeScenes: () => game.scene.getScenes(true).map((activeScene) => activeScene.scene.key),
audio: () => soundDirector.getDebugState(),
title: () => game.scene.isActive('TitleScene')
? titleScene()?.getDebugState?.() ?? { active: false, reason: 'TitleScene debug state is unavailable.' }
: { active: false, reason: 'TitleScene is not active yet.' },
battle: () => game.scene.isActive('BattleScene')
? battleScene()?.getDebugState?.() ?? { active: false, reason: 'BattleScene debug state is unavailable.' }
: { active: false, reason: 'BattleScene is not active yet.' },