diff --git a/docs/roadmap.md b/docs/roadmap.md index 9b04641..bc2c63b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -16,13 +16,13 @@ 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, and click-to-move unit movement +- 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 - Flow verification script from title to first battle ## Next Steps -1. Add selectable commands: attack, wait, and end turn -2. Implement attack range, damage, defeat, and victory checks +1. Implement attack range, damage, defeat, and victory checks +2. Add strategy and item inventories with usable battle effects 3. Add a simple enemy AI turn 4. Add battle result scene and local save/load diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index e7dbff9..688f3cb 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -40,13 +40,32 @@ type UnitView = { label: Phaser.GameObjects.Text; }; +type BattlePhase = 'idle' | 'moving' | 'command'; +type BattleCommand = 'attack' | 'strategy' | 'item' | 'wait'; + +type CommandButton = { + command: BattleCommand; + background: Phaser.GameObjects.Rectangle; + text: Phaser.GameObjects.Text; +}; + +const commandLabels: Record = { + attack: '공격', + strategy: '책략', + item: '도구', + wait: '대기' +}; + export class BattleScene extends Phaser.Scene { private layout!: BattleLayout; + private phase: BattlePhase = 'idle'; private selectedUnit?: UnitData; private infoText?: Phaser.GameObjects.Text; private turnText?: Phaser.GameObjects.Text; private markers: Phaser.GameObjects.Rectangle[] = []; + private commandButtons: CommandButton[] = []; private unitViews = new Map(); + private actedUnitIds = new Set(); constructor() { super('BattleScene'); @@ -61,7 +80,7 @@ export class BattleScene extends Phaser.Scene { this.drawMap(); this.drawUnits(); this.drawSidePanel(); - this.setInfo('아군 차례입니다.\n유비를 선택한 뒤 파란 칸을 눌러 이동하세요.'); + this.setInfo('아군 차례입니다.\n행동할 장수를 선택한 뒤 파란 칸으로 이동하세요.'); } private createLayout(width: number, height: number): BattleLayout { @@ -197,7 +216,7 @@ export class BattleScene extends Phaser.Scene { fontSize: '18px', color: '#9aa3ad' }); - this.add.text(panelX + 24, panelY + panelHeight - 86, '다음 단계: 공격 / 대기 / AI', { + this.add.text(panelX + 24, panelY + panelHeight - 86, '이동 후 명령: 공격 / 책략 / 도구 / 대기', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '18px', color: '#9aa3ad' @@ -205,13 +224,29 @@ export class BattleScene extends Phaser.Scene { } private selectUnit(unit: UnitData) { + if (this.phase === 'command') { + this.setInfo('먼저 현재 장수의 행동을 결정하세요.\n공격, 책략, 도구, 대기 중 하나를 선택해야 합니다.'); + return; + } + if (unit.faction !== 'ally') { this.setInfo(`${unit.name}: ${unit.className}\nHP ${unit.hp}/${unit.maxHp}`); return; } + if (this.actedUnitIds.has(unit.id)) { + this.selectedUnit = undefined; + this.clearMarkers(); + this.hideCommandMenu(); + this.phase = 'idle'; + this.setInfo(`${unit.name}은 이미 행동을 마쳤습니다.\n흑백으로 표시된 장수는 이번 턴에 다시 움직일 수 없습니다.`); + return; + } + + this.phase = 'moving'; this.selectedUnit = unit; this.clearMarkers(); + this.hideCommandMenu(); this.showMoveRange(unit); this.setInfo(`${unit.name} (${unit.className})\nHP ${unit.hp}/${unit.maxHp}\n공격 ${unit.attack} / 이동 ${unit.move}\n이동할 파란 칸을 선택하세요.`); } @@ -256,7 +291,7 @@ export class BattleScene extends Phaser.Scene { unit.x = x; unit.y = y; - this.selectedUnit = undefined; + this.phase = 'command'; this.clearMarkers(); soundDirector.playSelect(); @@ -276,7 +311,8 @@ export class BattleScene extends Phaser.Scene { duration: 260, ease: 'Sine.easeInOut' }); - this.setInfo(`${unit.name} 이동 완료\n위치: ${x + 1}, ${y + 1}\n다음 단계에서 공격/대기 명령을 붙일 예정입니다.`); + this.showCommandMenu(unit); + this.setInfo(`${unit.name} 이동 완료\n위치: ${x + 1}, ${y + 1}\n이제 공격, 책략, 도구, 대기 중 하나를 선택하세요.`); } private canMoveTo(unit: UnitData, x: number, y: number) { @@ -322,6 +358,98 @@ export class BattleScene extends Phaser.Scene { this.markers = []; } + private showCommandMenu(unit: UnitData) { + 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; + + 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); + background.setStrokeStyle(1, command === 'wait' ? palette.gold : palette.blue, 0.7); + background.setInteractive({ useHandCursor: true }); + background.setDepth(12); + + const text = this.add.text(centerX, centerY, commandLabels[command], { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '21px', + color: '#f2e3bf', + fontStyle: '700' + }); + text.setOrigin(0.5); + text.setDepth(13); + + const chooseCommand = () => this.completeUnitAction(unit, command); + background.on('pointerover', () => background.setFillStyle(0x283947, 0.98)); + background.on('pointerout', () => background.setFillStyle(0x1a2630, 0.94)); + background.on('pointerdown', chooseCommand); + text.setInteractive({ useHandCursor: true }); + text.on('pointerdown', chooseCommand); + + this.commandButtons.push({ command, background, text }); + }); + } + + private hideCommandMenu() { + this.commandButtons.forEach(({ background, text }) => { + background.destroy(); + text.destroy(); + }); + this.commandButtons = []; + } + + private completeUnitAction(unit: UnitData, command: BattleCommand) { + if (this.phase !== 'command' || this.selectedUnit?.id !== unit.id || this.actedUnitIds.has(unit.id)) { + return; + } + + this.actedUnitIds.add(unit.id); + this.phase = 'idle'; + this.selectedUnit = undefined; + this.hideCommandMenu(); + this.applyActedStyle(unit); + soundDirector.playSelect(); + + const actionMessage = this.commandResultMessage(unit, command); + this.setInfo(`${actionMessage}\n\n${this.remainingAllyCount()}명의 아군이 아직 행동할 수 있습니다.`); + } + + private commandResultMessage(unit: UnitData, command: BattleCommand) { + if (command === 'attack') { + return `${unit.name} 공격 명령 완료\n다음 구현에서 대상 선택과 피해 처리를 연결할 예정입니다.`; + } + if (command === 'strategy') { + return `${unit.name} 책략 명령 완료\n책략 목록과 MP는 다음 전투 확장 때 붙이겠습니다.`; + } + if (command === 'item') { + return `${unit.name} 도구 명령 완료\n도구 가방은 이후 회복 아이템과 함께 열겠습니다.`; + } + return `${unit.name} 대기 명령 완료`; + } + + private remainingAllyCount() { + return firstBattleUnits.filter((unit) => unit.faction === 'ally' && !this.actedUnitIds.has(unit.id)).length; + } + + private applyActedStyle(unit: UnitData) { + const view = this.unitViews.get(unit.id); + if (!view) { + return; + } + + view.sprite.postFX.addColorMatrix().grayscale(1); + view.sprite.setAlpha(0.46); + view.sprite.setTint(0xb7b7b7); + view.label.setAlpha(0.55); + view.label.setColor('#b9b9b9'); + view.label.setBackgroundColor('rgba(20,20,20,0.66)'); + } + private setInfo(text: string) { this.infoText?.setText(text); }