diff --git a/docs/roadmap.md b/docs/roadmap.md index bc2c63b..3d1c67e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -16,7 +16,7 @@ Build a small complete tactical RPG loop: - Cinematic title scene with an original Taoyuan Oath background, subtle motion, menu UI, and first-input audio - Cinematic prologue pages with high-resolution story backgrounds, character portraits, non-black scene transitions, and scenario data - File-based original orchestral BGM loops for title, prologue, oath, militia, and battle-prep scenes -- First battle scene with a high-resolution battlefield background, large 12x12 tactical grid, high-detail pixel-style unit sprites, movement range preview, click-to-move unit movement, post-move command selection, and grayscale acted-unit feedback +- First battle scene with a high-resolution battlefield background, large 12x12 tactical grid, high-detail pixel-style unit sprites, movement range preview, click-to-move unit movement, cursor-adjacent post-move command popup, right-click move cancel, and grayscale acted-unit feedback - Flow verification script from title to first battle ## Next Steps diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 688f3cb..5d8a27f 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -49,6 +49,14 @@ type CommandButton = { text: Phaser.GameObjects.Text; }; +type PendingMove = { + unit: UnitData; + fromX: number; + fromY: number; + toX: number; + toY: number; +}; + const commandLabels: Record = { attack: '공격', strategy: '책략', @@ -64,8 +72,10 @@ export class BattleScene extends Phaser.Scene { private turnText?: Phaser.GameObjects.Text; private markers: Phaser.GameObjects.Rectangle[] = []; private commandButtons: CommandButton[] = []; + private commandMenuObjects: Array = []; private unitViews = new Map(); private actedUnitIds = new Set(); + private pendingMove?: PendingMove; constructor() { super('BattleScene'); @@ -75,6 +85,12 @@ export class BattleScene extends Phaser.Scene { const { width, height } = this.scale; this.layout = this.createLayout(width, height); soundDirector.playMusic('battle-prep'); + this.input.mouse?.disableContextMenu(); + this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => { + if (pointer.rightButtonDown()) { + this.cancelPendingMove(); + } + }); this.add.rectangle(0, 0, width, height, 0x080b0d).setOrigin(0); this.drawMap(); @@ -245,6 +261,7 @@ export class BattleScene extends Phaser.Scene { this.phase = 'moving'; this.selectedUnit = unit; + this.pendingMove = undefined; this.clearMarkers(); this.hideCommandMenu(); this.showMoveRange(unit); @@ -272,13 +289,17 @@ export class BattleScene extends Phaser.Scene { marker.setInteractive({ useHandCursor: true }); marker.on('pointerover', () => marker.setFillStyle(palette.blue, 0.36)); marker.on('pointerout', () => marker.setFillStyle(palette.blue, 0.24)); - marker.on('pointerdown', () => this.moveSelectedUnit(x, y)); + marker.on('pointerdown', (pointer: Phaser.Input.Pointer) => { + if (pointer.leftButtonDown()) { + this.moveSelectedUnit(x, y, pointer); + } + }); this.markers.push(marker); } } } - private moveSelectedUnit(x: number, y: number) { + private moveSelectedUnit(x: number, y: number, pointer?: Phaser.Input.Pointer) { if (!this.selectedUnit || !this.canMoveTo(this.selectedUnit, x, y)) { return; } @@ -289,30 +310,20 @@ export class BattleScene extends Phaser.Scene { return; } + const fromX = unit.x; + const fromY = unit.y; unit.x = x; unit.y = y; + this.pendingMove = { unit, fromX, fromY, toX: x, toY: y }; this.phase = 'command'; this.clearMarkers(); soundDirector.playSelect(); const targetX = this.tileCenterX(x); const targetY = this.tileCenterY(y); - this.tweens.add({ - targets: view.sprite, - x: targetX, - y: targetY, - duration: 260, - ease: 'Sine.easeInOut' - }); - this.tweens.add({ - targets: view.label, - x: targetX, - y: targetY + this.layout.tileSize * 0.36, - duration: 260, - ease: 'Sine.easeInOut' - }); - this.showCommandMenu(unit); - this.setInfo(`${unit.name} 이동 완료\n위치: ${x + 1}, ${y + 1}\n이제 공격, 책략, 도구, 대기 중 하나를 선택하세요.`); + this.moveUnitView(unit, x, y, view); + this.showCommandMenu(unit, pointer?.x ?? targetX, pointer?.y ?? targetY); + this.setInfo(`${unit.name} 이동 완료\n위치: ${x + 1}, ${y + 1}\n공격, 책략, 도구, 대기 중 하나를 선택하세요.\n우클릭하면 이동을 취소합니다.`); } private canMoveTo(unit: UnitData, x: number, y: number) { @@ -358,33 +369,61 @@ export class BattleScene extends Phaser.Scene { this.markers = []; } - private showCommandMenu(unit: UnitData) { + private showCommandMenu(unit: UnitData, pointerX: number, pointerY: number) { this.hideCommandMenu(); - const { panelX, panelY, panelWidth } = this.layout; const commands: BattleCommand[] = ['attack', 'strategy', 'item', 'wait']; - const buttonWidth = panelWidth - 48; - const buttonHeight = 44; - const startY = panelY + 354; + const menuWidth = 136; + const titleHeight = 31; + const buttonHeight = 34; + const gap = 5; + const padding = 8; + const menuHeight = padding * 2 + titleHeight + commands.length * buttonHeight + (commands.length - 1) * gap; + const { left, top } = this.commandMenuPosition(pointerX, pointerY, menuWidth, menuHeight); + + const panel = this.add.rectangle(left, top, menuWidth, menuHeight, 0x101821, 0.97); + panel.setOrigin(0); + panel.setStrokeStyle(2, palette.gold, 0.76); + panel.setDepth(14); + this.commandMenuObjects.push(panel); + + const title = this.add.text(left + menuWidth / 2, top + padding + 2, '명령', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '16px', + color: '#d8b15f', + fontStyle: '700' + }); + title.setOrigin(0.5, 0); + title.setDepth(15); + this.commandMenuObjects.push(title); commands.forEach((command, index) => { - const centerX = panelX + 24 + buttonWidth / 2; - const centerY = startY + index * 56; - const background = this.add.rectangle(centerX, centerY, buttonWidth, buttonHeight, 0x1a2630, 0.94); + const buttonTop = top + padding + titleHeight + index * (buttonHeight + gap); + const centerX = left + menuWidth / 2; + const centerY = buttonTop + buttonHeight / 2; + const background = this.add.rectangle(centerX, centerY, menuWidth - padding * 2, buttonHeight, 0x1a2630, 0.94); background.setStrokeStyle(1, command === 'wait' ? palette.gold : palette.blue, 0.7); background.setInteractive({ useHandCursor: true }); - background.setDepth(12); + background.setDepth(15); const text = this.add.text(centerX, centerY, commandLabels[command], { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '21px', + fontSize: '18px', color: '#f2e3bf', fontStyle: '700' }); text.setOrigin(0.5); - text.setDepth(13); + text.setDepth(16); - const chooseCommand = () => this.completeUnitAction(unit, command); + const chooseCommand = (choicePointer: Phaser.Input.Pointer) => { + if (choicePointer.rightButtonDown()) { + this.cancelPendingMove(); + return; + } + if (choicePointer.leftButtonDown()) { + this.completeUnitAction(unit, command); + } + }; background.on('pointerover', () => background.setFillStyle(0x283947, 0.98)); background.on('pointerout', () => background.setFillStyle(0x1a2630, 0.94)); background.on('pointerdown', chooseCommand); @@ -395,7 +434,21 @@ export class BattleScene extends Phaser.Scene { }); } + private commandMenuPosition(pointerX: number, pointerY: number, width: number, height: number) { + const margin = 14; + const offset = 18; + const preferredLeft = pointerX + offset + width > this.scale.width - margin ? pointerX - offset - width : pointerX + offset; + const preferredTop = pointerY + height > this.scale.height - margin ? this.scale.height - margin - height : pointerY; + + return { + left: Phaser.Math.Clamp(preferredLeft, margin, this.scale.width - margin - width), + top: Phaser.Math.Clamp(preferredTop, margin, this.scale.height - margin - height) + }; + } + private hideCommandMenu() { + this.commandMenuObjects.forEach((object) => object.destroy()); + this.commandMenuObjects = []; this.commandButtons.forEach(({ background, text }) => { background.destroy(); text.destroy(); @@ -411,6 +464,7 @@ export class BattleScene extends Phaser.Scene { this.actedUnitIds.add(unit.id); this.phase = 'idle'; this.selectedUnit = undefined; + this.pendingMove = undefined; this.hideCommandMenu(); this.applyActedStyle(unit); soundDirector.playSelect(); @@ -436,6 +490,57 @@ export class BattleScene extends Phaser.Scene { return firstBattleUnits.filter((unit) => unit.faction === 'ally' && !this.actedUnitIds.has(unit.id)).length; } + private cancelPendingMove() { + if (this.phase !== 'command' || !this.pendingMove) { + return; + } + + const { unit, fromX, fromY, toX, toY } = this.pendingMove; + const view = this.unitViews.get(unit.id); + unit.x = fromX; + unit.y = fromY; + this.phase = 'moving'; + this.selectedUnit = unit; + this.pendingMove = undefined; + this.hideCommandMenu(); + soundDirector.playSelect(); + + this.moveUnitView(unit, fromX, fromY, view, 180); + this.clearMarkers(); + this.showMoveRange(unit); + this.setInfo(`${unit.name} 이동 취소\n${toX + 1}, ${toY + 1}에서 원래 위치 ${fromX + 1}, ${fromY + 1}로 되돌렸습니다.\n다시 이동할 파란 칸을 선택하세요.`); + } + + private moveUnitView( + unit: UnitData, + x: number, + y: number, + view = this.unitViews.get(unit.id), + duration = 260 + ) { + if (!view) { + return; + } + + const targetX = this.tileCenterX(x); + const targetY = this.tileCenterY(y); + this.tweens.killTweensOf([view.sprite, view.label]); + this.tweens.add({ + targets: view.sprite, + x: targetX, + y: targetY, + duration, + ease: 'Sine.easeInOut' + }); + this.tweens.add({ + targets: view.label, + x: targetX, + y: targetY + this.layout.tileSize * 0.36, + duration, + ease: 'Sine.easeInOut' + }); + } + private applyActedStyle(unit: UnitData) { const view = this.unitViews.get(unit.id); if (!view) {