diff --git a/docs/roadmap.md b/docs/roadmap.md index 24dcff4..cc9c2aa 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 soft stereo 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, cursor-adjacent post-move command popup, right-click move cancel, and grayscale acted-unit feedback +- First battle scene with a high-resolution battlefield background, large 12x12 tactical grid, high-detail pixel-style unit sprites, ally/enemy roster tabs, unit detail stat panels, 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/data/scenario.ts b/src/game/data/scenario.ts index 32b774b..dec8bbd 100644 --- a/src/game/data/scenario.ts +++ b/src/game/data/scenario.ts @@ -19,10 +19,19 @@ export type UnitData = { maxHp: number; attack: number; move: number; + stats: UnitStats; x: number; y: number; }; +export type UnitStats = { + might: number; + intelligence: number; + leadership: number; + agility: number; + luck: number; +}; + export type TerrainType = 'plain' | 'forest' | 'hill' | 'fort'; export type BattleMap = { @@ -138,11 +147,95 @@ export const firstBattleMap: BattleMap = { }; export const firstBattleUnits: UnitData[] = [ - { id: 'liu-bei', name: '유비', faction: 'ally', className: '의병장', hp: 32, maxHp: 32, attack: 9, move: 4, x: 1, y: 9 }, - { id: 'guan-yu', name: '관우', faction: 'ally', className: '보병', hp: 30, maxHp: 30, attack: 10, move: 3, x: 2, y: 10 }, - { id: 'zhang-fei', name: '장비', faction: 'ally', className: '창병', hp: 30, maxHp: 30, attack: 10, move: 3, x: 1, y: 10 }, - { id: 'rebel-a', name: '황건병', faction: 'enemy', className: '도적', hp: 20, maxHp: 20, attack: 6, move: 3, x: 8, y: 2 }, - { id: 'rebel-b', name: '황건병', faction: 'enemy', className: '도적', hp: 20, maxHp: 20, attack: 6, move: 3, x: 9, y: 3 }, - { id: 'rebel-c', name: '황건궁병', faction: 'enemy', className: '궁병', hp: 18, maxHp: 18, attack: 7, move: 3, x: 10, y: 2 }, - { id: 'rebel-leader', name: '두령 한석', faction: 'enemy', className: '두령', hp: 30, maxHp: 30, attack: 9, move: 3, x: 10, y: 4 } + { + id: 'liu-bei', + name: '유비', + faction: 'ally', + className: '의병장', + hp: 32, + maxHp: 32, + attack: 9, + move: 4, + stats: { might: 76, intelligence: 78, leadership: 84, agility: 70, luck: 88 }, + x: 1, + y: 9 + }, + { + id: 'guan-yu', + name: '관우', + faction: 'ally', + className: '보병', + hp: 30, + maxHp: 30, + attack: 10, + move: 3, + stats: { might: 96, intelligence: 74, leadership: 91, agility: 72, luck: 68 }, + x: 2, + y: 10 + }, + { + id: 'zhang-fei', + name: '장비', + faction: 'ally', + className: '창병', + hp: 30, + maxHp: 30, + attack: 10, + move: 3, + stats: { might: 95, intelligence: 42, leadership: 83, agility: 67, luck: 61 }, + x: 1, + y: 10 + }, + { + id: 'rebel-a', + name: '황건병', + faction: 'enemy', + className: '도적', + hp: 20, + maxHp: 20, + attack: 6, + move: 3, + stats: { might: 49, intelligence: 34, leadership: 38, agility: 45, luck: 40 }, + x: 8, + y: 2 + }, + { + id: 'rebel-b', + name: '황건병', + faction: 'enemy', + className: '도적', + hp: 20, + maxHp: 20, + attack: 6, + move: 3, + stats: { might: 47, intelligence: 32, leadership: 36, agility: 48, luck: 38 }, + x: 9, + y: 3 + }, + { + id: 'rebel-c', + name: '황건궁병', + faction: 'enemy', + className: '궁병', + hp: 18, + maxHp: 18, + attack: 7, + move: 3, + stats: { might: 52, intelligence: 40, leadership: 43, agility: 56, luck: 42 }, + x: 10, + y: 2 + }, + { + id: 'rebel-leader', + name: '두령 한석', + faction: 'enemy', + className: '두령', + hp: 30, + maxHp: 30, + attack: 9, + move: 3, + stats: { might: 64, intelligence: 46, leadership: 61, agility: 52, luck: 50 }, + x: 10, + y: 4 + } ]; diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 5d8a27f..9204bc6 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -1,6 +1,6 @@ import Phaser from 'phaser'; import { soundDirector } from '../audio/SoundDirector'; -import { firstBattleMap, firstBattleUnits, type TerrainType, type UnitData } from '../data/scenario'; +import { firstBattleMap, firstBattleUnits, type TerrainType, type UnitData, type UnitStats } from '../data/scenario'; import { palette } from '../ui/palette'; const terrainColor: Record = { @@ -42,6 +42,7 @@ type UnitView = { type BattlePhase = 'idle' | 'moving' | 'command'; type BattleCommand = 'attack' | 'strategy' | 'item' | 'wait'; +type RosterTab = 'ally' | 'enemy'; type CommandButton = { command: BattleCommand; @@ -64,13 +65,27 @@ const commandLabels: Record = { wait: '대기' }; +const rosterLabels: Record = { + ally: '아군', + enemy: '적군' +}; + +const statLabels: Array<{ key: keyof UnitStats; label: string }> = [ + { key: 'might', label: '무력' }, + { key: 'intelligence', label: '지력' }, + { key: 'leadership', label: '통솔력' }, + { key: 'agility', label: '민첩성' }, + { key: 'luck', label: '운' } +]; + 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 rosterTab: RosterTab = 'ally'; + private sidePanelObjects: Phaser.GameObjects.GameObject[] = []; private commandButtons: CommandButton[] = []; private commandMenuObjects: Array = []; private unitViews = new Map(); @@ -96,7 +111,7 @@ export class BattleScene extends Phaser.Scene { this.drawMap(); this.drawUnits(); this.drawSidePanel(); - this.setInfo('아군 차례입니다.\n행동할 장수를 선택한 뒤 파란 칸으로 이동하세요.'); + this.renderRosterPanel('ally', '행동할 장수를 선택하세요.'); } private createLayout(width: number, height: number): BattleLayout { @@ -214,25 +229,17 @@ export class BattleScene extends Phaser.Scene { fontSize: '22px', color: '#d8b15f' }); - this.infoText = this.add.text(panelX + 24, panelY + 144, '', { - fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '20px', - color: '#e8dfca', - wordWrap: { width: panelWidth - 48, useAdvancedWrap: true }, - lineSpacing: 8 - }); - - this.add.text(panelX + 24, panelY + panelHeight - 166, '승리 조건: 적 전멸', { + this.add.text(panelX + 24, panelY + panelHeight - 104, '승리 조건: 적 전멸', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '18px', color: '#9aa3ad' }); - this.add.text(panelX + 24, panelY + panelHeight - 126, '패배 조건: 유비 퇴각', { + this.add.text(panelX + 24, panelY + panelHeight - 72, '패배 조건: 유비 퇴각', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '18px', color: '#9aa3ad' }); - this.add.text(panelX + 24, panelY + panelHeight - 86, '이동 후 명령: 공격 / 책략 / 도구 / 대기', { + this.add.text(panelX + 24, panelY + panelHeight - 40, '이동 후 명령: 공격 / 책략 / 도구 / 대기', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '18px', color: '#9aa3ad' @@ -246,7 +253,12 @@ export class BattleScene extends Phaser.Scene { } if (unit.faction !== 'ally') { - this.setInfo(`${unit.name}: ${unit.className}\nHP ${unit.hp}/${unit.maxHp}`); + this.selectedUnit = undefined; + this.pendingMove = undefined; + this.phase = 'idle'; + this.clearMarkers(); + this.hideCommandMenu(); + this.renderUnitDetail(unit, '적 부대 정보입니다.'); return; } @@ -255,7 +267,7 @@ export class BattleScene extends Phaser.Scene { this.clearMarkers(); this.hideCommandMenu(); this.phase = 'idle'; - this.setInfo(`${unit.name}은 이미 행동을 마쳤습니다.\n흑백으로 표시된 장수는 이번 턴에 다시 움직일 수 없습니다.`); + this.renderUnitDetail(unit, '이미 행동을 마친 장수입니다.'); return; } @@ -265,7 +277,7 @@ export class BattleScene extends Phaser.Scene { this.clearMarkers(); this.hideCommandMenu(); this.showMoveRange(unit); - this.setInfo(`${unit.name} (${unit.className})\nHP ${unit.hp}/${unit.maxHp}\n공격 ${unit.attack} / 이동 ${unit.move}\n이동할 파란 칸을 선택하세요.`); + this.renderUnitDetail(unit, '이동할 파란 칸을 선택하세요.'); } private showMoveRange(unit: UnitData) { @@ -323,7 +335,7 @@ export class BattleScene extends Phaser.Scene { const targetY = this.tileCenterY(y); 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우클릭하면 이동을 취소합니다.`); + this.renderUnitDetail(unit, '공격, 책략, 도구, 대기 중 하나를 선택하세요.\n우클릭하면 이동을 취소합니다.'); } private canMoveTo(unit: UnitData, x: number, y: number) { @@ -470,7 +482,7 @@ export class BattleScene extends Phaser.Scene { soundDirector.playSelect(); const actionMessage = this.commandResultMessage(unit, command); - this.setInfo(`${actionMessage}\n\n${this.remainingAllyCount()}명의 아군이 아직 행동할 수 있습니다.`); + this.renderRosterPanel('ally', `${actionMessage}\n${this.remainingAllyCount()}명의 아군이 아직 행동할 수 있습니다.`); } private commandResultMessage(unit: UnitData, command: BattleCommand) { @@ -508,7 +520,10 @@ export class BattleScene extends Phaser.Scene { 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다시 이동할 파란 칸을 선택하세요.`); + this.renderUnitDetail( + unit, + `이동을 취소했습니다.\n${toX + 1}, ${toY + 1}에서 원래 위치 ${fromX + 1}, ${fromY + 1}로 되돌렸습니다.` + ); } private moveUnitView( @@ -555,7 +570,253 @@ export class BattleScene extends Phaser.Scene { view.label.setBackgroundColor('rgba(20,20,20,0.66)'); } + private renderRosterPanel(tab: RosterTab = this.rosterTab, message?: string) { + this.rosterTab = tab; + this.clearSidePanelContent(); + + const { panelX, panelY, panelWidth, panelHeight } = this.layout; + const left = panelX + 24; + const width = panelWidth - 48; + const top = panelY + 132; + const tabGap = 8; + const tabWidth = (width - tabGap) / 2; + + (['ally', 'enemy'] as RosterTab[]).forEach((targetTab, index) => { + const active = targetTab === tab; + const x = left + index * (tabWidth + tabGap); + const tabBg = this.trackSideObject(this.add.rectangle(x, top, tabWidth, 38, active ? 0x25384a : 0x141e29, active ? 0.98 : 0.86)); + tabBg.setOrigin(0); + tabBg.setStrokeStyle(1, active ? palette.gold : 0x53606c, active ? 0.92 : 0.62); + tabBg.setInteractive({ useHandCursor: true }); + tabBg.on('pointerdown', () => this.renderRosterPanel(targetTab)); + + const tabText = this.trackSideObject(this.add.text(x + tabWidth / 2, top + 19, rosterLabels[targetTab], { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '18px', + color: active ? '#f4dfad' : '#aeb7c2', + fontStyle: '700' + })); + tabText.setOrigin(0.5); + }); + + const units = firstBattleUnits.filter((unit) => unit.faction === tab); + const listTop = top + 54; + units.forEach((unit, index) => { + this.renderRosterRow(unit, left, listTop + index * 50, width); + }); + + if (message) { + const messageTop = Math.min(panelY + panelHeight - 254, listTop + units.length * 50 + 14); + this.renderPanelMessage(message, left, messageTop, width); + } + } + + private renderRosterRow(unit: UnitData, x: number, y: number, width: number) { + const acted = this.actedUnitIds.has(unit.id); + const active = this.selectedUnit?.id === unit.id; + const rowBg = this.trackSideObject(this.add.rectangle(x, y, width, 42, active ? 0x2f4050 : 0x101820, acted ? 0.54 : 0.9)); + rowBg.setOrigin(0); + rowBg.setStrokeStyle(1, active ? palette.gold : unit.faction === 'ally' ? palette.blue : 0xb86b55, active ? 0.88 : 0.52); + rowBg.setInteractive({ useHandCursor: true }); + rowBg.on('pointerdown', () => this.selectUnit(unit)); + + const nameColor = acted ? '#a8a8a8' : unit.faction === 'ally' ? '#e9f0f8' : '#ffe0d8'; + this.trackSideObject(this.add.text(x + 12, y + 7, unit.name, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: nameColor, + fontStyle: '700' + })); + this.trackSideObject(this.add.text(x + 88, y + 9, unit.className, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '14px', + color: acted ? '#8c8c8c' : '#9fb0bf' + })); + + const hpText = this.trackSideObject(this.add.text(x + width - 12, y + 7, `${unit.hp}/${unit.maxHp}`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '16px', + color: acted ? '#9a9a9a' : '#f0e4c8', + fontStyle: '700' + })); + hpText.setOrigin(1, 0); + + this.drawGauge(x + 88, y + 29, width - 100, 6, unit.hp / unit.maxHp, acted ? 0x9a9a9a : 0x59d18c); + + if (acted) { + const actedText = this.trackSideObject(this.add.text(x + 12, y + 27, '행동완료', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '12px', + color: '#bdbdbd' + })); + actedText.setAlpha(0.78); + } + } + + private renderUnitDetail(unit: UnitData, message?: string) { + this.clearSidePanelContent(); + + const { panelX, panelY, panelWidth } = this.layout; + const left = panelX + 24; + const width = panelWidth - 48; + const top = panelY + 124; + const acted = this.actedUnitIds.has(unit.id); + const factionColor = unit.faction === 'ally' ? palette.blue : 0xb86b55; + + const header = this.trackSideObject(this.add.rectangle(left, top, width, 60, 0x101820, 0.94)); + header.setOrigin(0); + header.setStrokeStyle(1, factionColor, 0.76); + + this.trackSideObject(this.add.text(left + 14, top + 9, rosterLabels[unit.faction], { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '14px', + color: unit.faction === 'ally' ? '#9fd0ff' : '#ffb2a4', + fontStyle: '700' + })); + this.trackSideObject(this.add.text(left + 14, top + 29, `${unit.name} ${unit.className}`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '22px', + color: acted ? '#bdbdbd' : '#f2e3bf', + fontStyle: '700' + })); + + if (this.phase !== 'command') { + const backBg = this.trackSideObject(this.add.rectangle(left + width - 66, top + 10, 52, 28, 0x1b2834, 0.9)); + backBg.setOrigin(0); + backBg.setStrokeStyle(1, palette.gold, 0.72); + backBg.setInteractive({ useHandCursor: true }); + backBg.on('pointerdown', () => this.returnToRosterPanel(unit.faction)); + + const backText = this.trackSideObject(this.add.text(left + width - 40, top + 24, '목록', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '14px', + color: '#f4dfad', + fontStyle: '700' + })); + backText.setOrigin(0.5); + } + + this.trackSideObject(this.add.text(left, top + 78, '병력', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#d4dce6', + fontStyle: '700' + })); + const hpValue = this.trackSideObject(this.add.text(left + width, top + 76, `${unit.hp} / ${unit.maxHp}`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '18px', + color: '#f0e4c8', + fontStyle: '700' + })); + hpValue.setOrigin(1, 0); + this.drawGauge(left + 76, top + 84, width - 160, 10, unit.hp / unit.maxHp, 0x59d18c); + + this.renderSmallValueBox(left, top + 112, '공격', `${unit.attack}`); + this.renderSmallValueBox(left + width / 2 + 6, top + 112, '이동', `${unit.move}`); + + const statTop = top + 164; + statLabels.forEach((stat, index) => { + this.renderStatRow(stat.label, unit.stats[stat.key], left, statTop + index * 32, width); + }); + + const positionText = `위치 ${unit.x + 1}, ${unit.y + 1}${acted ? ' / 행동완료' : ''}`; + this.trackSideObject(this.add.text(left, top + 328, positionText, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '15px', + color: acted ? '#bdbdbd' : '#9fb0bf' + })); + + if (message) { + this.renderPanelMessage(message, left, top + 352, width); + } + } + + private renderSmallValueBox(x: number, y: number, label: string, value: string) { + const boxWidth = (this.layout.panelWidth - 60) / 2; + const bg = this.trackSideObject(this.add.rectangle(x, y, boxWidth, 38, 0x16212d, 0.92)); + bg.setOrigin(0); + bg.setStrokeStyle(1, 0x53606c, 0.56); + this.trackSideObject(this.add.text(x + 12, y + 9, label, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '14px', + color: '#9fb0bf' + })); + const valueText = this.trackSideObject(this.add.text(x + boxWidth - 12, y + 7, value, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '18px', + color: '#f2e3bf', + fontStyle: '700' + })); + valueText.setOrigin(1, 0); + } + + private renderStatRow(label: string, value: number, x: number, y: number, width: number) { + this.trackSideObject(this.add.text(x, y, label, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '16px', + color: '#d4dce6', + fontStyle: '700' + })); + + this.drawGauge(x + 76, y + 8, width - 130, 9, value / 100, value >= 80 ? 0xd8b15f : 0x58aee0); + + const valueText = this.trackSideObject(this.add.text(x + width, y - 1, `${value}`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#f2e3bf', + fontStyle: '700' + })); + valueText.setOrigin(1, 0); + } + + private drawGauge(x: number, y: number, width: number, height: number, ratio: number, color: number) { + const track = this.trackSideObject(this.add.rectangle(x, y, width, height, 0x0a0f14, 0.82)); + track.setOrigin(0); + track.setStrokeStyle(1, 0x40515e, 0.58); + + const fillWidth = Math.max(2, Math.floor(width * Phaser.Math.Clamp(ratio, 0, 1))); + const fill = this.trackSideObject(this.add.rectangle(x + 1, y + 1, fillWidth - 2, Math.max(2, height - 2), color, 0.96)); + fill.setOrigin(0); + } + + private renderPanelMessage(text: string, x: number, y: number, width: number) { + const bg = this.trackSideObject(this.add.rectangle(x, y, width, 74, 0x101820, 0.72)); + bg.setOrigin(0); + bg.setStrokeStyle(1, 0x53606c, 0.42); + this.trackSideObject(this.add.text(x + 12, y + 10, text, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '15px', + color: '#d4dce6', + wordWrap: { width: width - 24, useAdvancedWrap: true }, + lineSpacing: 4 + })); + } + + private returnToRosterPanel(tab: RosterTab) { + this.selectedUnit = undefined; + this.pendingMove = undefined; + this.phase = 'idle'; + this.clearMarkers(); + this.hideCommandMenu(); + this.renderRosterPanel(tab); + } + + private clearSidePanelContent() { + this.sidePanelObjects.forEach((object) => object.destroy()); + this.sidePanelObjects = []; + } + + private trackSideObject(object: T) { + this.sidePanelObjects.push(object); + return object; + } + private setInfo(text: string) { - this.infoText?.setText(text); + if (this.selectedUnit) { + this.renderUnitDetail(this.selectedUnit, text); + return; + } + + this.renderRosterPanel(this.rosterTab, text); } }