From 6db887e2dadbbd5883b0d56a49c76b75e65dd272 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Mon, 22 Jun 2026 02:01:16 +0900 Subject: [PATCH] Add turn menu and resonance bonds --- docs/roadmap.md | 4 +- src/game/data/scenario.ts | 36 +++ src/game/scenes/BattleScene.ts | 424 ++++++++++++++++++++++++++++++++- 3 files changed, 457 insertions(+), 7 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index cc9c2aa..6ff315e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -16,12 +16,12 @@ 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, 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 +- 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, right-click tactical menu, turn ending/reset, resonance bond tracking, and grayscale acted-unit feedback - Flow verification script from title to first battle ## Next Steps -1. Implement attack range, damage, defeat, and victory checks +1. Implement attack range, damage, defeat, victory checks, and real resonance assist attacks 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/data/scenario.ts b/src/game/data/scenario.ts index dec8bbd..6333641 100644 --- a/src/game/data/scenario.ts +++ b/src/game/data/scenario.ts @@ -32,6 +32,15 @@ export type UnitStats = { luck: number; }; +export type BattleBond = { + id: string; + unitIds: [string, string]; + title: string; + level: number; + exp: number; + description: string; +}; + export type TerrainType = 'plain' | 'forest' | 'hill' | 'fort'; export type BattleMap = { @@ -239,3 +248,30 @@ export const firstBattleUnits: UnitData[] = [ y: 4 } ]; + +export const firstBattleBonds: BattleBond[] = [ + { + id: 'liu-bei__guan-yu', + unitIds: ['liu-bei', 'guan-yu'], + title: '도원결의', + level: 72, + exp: 20, + description: '같은 적을 노릴 때 공격 피해 보너스와 연계 공격 확률이 오른다.' + }, + { + id: 'liu-bei__zhang-fei', + unitIds: ['liu-bei', 'zhang-fei'], + title: '의형제', + level: 68, + exp: 12, + description: '공격 목표가 겹치면 공명 경험치가 빠르게 쌓인다.' + }, + { + id: 'guan-yu__zhang-fei', + unitIds: ['guan-yu', 'zhang-fei'], + title: '맹장 공명', + level: 64, + exp: 8, + description: '둘이 같은 전선을 압박하면 연계 공격의 기초 확률이 오른다.' + } +]; diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 9204bc6..b5399ee 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -1,6 +1,14 @@ import Phaser from 'phaser'; import { soundDirector } from '../audio/SoundDirector'; -import { firstBattleMap, firstBattleUnits, type TerrainType, type UnitData, type UnitStats } from '../data/scenario'; +import { + firstBattleBonds, + firstBattleMap, + firstBattleUnits, + type BattleBond, + type TerrainType, + type UnitData, + type UnitStats +} from '../data/scenario'; import { palette } from '../ui/palette'; const terrainColor: Record = { @@ -43,6 +51,8 @@ type UnitView = { type BattlePhase = 'idle' | 'moving' | 'command'; type BattleCommand = 'attack' | 'strategy' | 'item' | 'wait'; type RosterTab = 'ally' | 'enemy'; +type ActiveFaction = 'ally' | 'enemy'; +type MapMenuAction = 'endTurn' | 'roster' | 'bond' | 'situation' | 'bgm' | 'close'; type CommandButton = { command: BattleCommand; @@ -58,6 +68,15 @@ type PendingMove = { toY: number; }; +type AttackIntent = { + attackerId: string; + targetId: string; +}; + +type BondState = BattleBond & { + battleExp: number; +}; + const commandLabels: Record = { attack: '공격', strategy: '책략', @@ -70,6 +89,20 @@ const rosterLabels: Record = { enemy: '적군' }; +const factionLabels: Record = { + ally: '아군', + enemy: '적군' +}; + +const mapMenuLabels: Record = { + endTurn: '턴 종료', + roster: '부대 일람', + bond: '공명', + situation: '전황', + bgm: 'BGM', + close: '닫기' +}; + const statLabels: Array<{ key: keyof UnitStats; label: string }> = [ { key: 'might', label: '무력' }, { key: 'intelligence', label: '지력' }, @@ -81,6 +114,8 @@ const statLabels: Array<{ key: keyof UnitStats; label: string }> = [ export class BattleScene extends Phaser.Scene { private layout!: BattleLayout; private phase: BattlePhase = 'idle'; + private turnNumber = 1; + private activeFaction: ActiveFaction = 'ally'; private selectedUnit?: UnitData; private turnText?: Phaser.GameObjects.Text; private markers: Phaser.GameObjects.Rectangle[] = []; @@ -88,8 +123,11 @@ export class BattleScene extends Phaser.Scene { private sidePanelObjects: Phaser.GameObjects.GameObject[] = []; private commandButtons: CommandButton[] = []; private commandMenuObjects: Array = []; + private mapMenuObjects: Array = []; private unitViews = new Map(); private actedUnitIds = new Set(); + private bondStates = new Map(); + private attackIntents: AttackIntent[] = []; private pendingMove?: PendingMove; constructor() { @@ -99,11 +137,12 @@ export class BattleScene extends Phaser.Scene { create() { const { width, height } = this.scale; this.layout = this.createLayout(width, height); + this.bondStates = this.createBondStates(); soundDirector.playMusic('battle-prep'); this.input.mouse?.disableContextMenu(); this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => { if (pointer.rightButtonDown()) { - this.cancelPendingMove(); + this.handleRightClick(pointer); } }); @@ -111,6 +150,7 @@ export class BattleScene extends Phaser.Scene { this.drawMap(); this.drawUnits(); this.drawSidePanel(); + this.updateTurnText(); this.renderRosterPanel('ally', '행동할 장수를 선택하세요.'); } @@ -247,6 +287,8 @@ export class BattleScene extends Phaser.Scene { } private selectUnit(unit: UnitData) { + this.hideMapMenu(); + if (this.phase === 'command') { this.setInfo('먼저 현재 장수의 행동을 결정하세요.\n공격, 책략, 도구, 대기 중 하나를 선택해야 합니다.'); return; @@ -262,6 +304,11 @@ export class BattleScene extends Phaser.Scene { return; } + if (this.activeFaction !== 'ally') { + this.renderUnitDetail(unit, '적군 행동 중에는 장수를 움직일 수 없습니다.'); + return; + } + if (this.actedUnitIds.has(unit.id)) { this.selectedUnit = undefined; this.clearMarkers(); @@ -482,12 +529,14 @@ export class BattleScene extends Phaser.Scene { soundDirector.playSelect(); const actionMessage = this.commandResultMessage(unit, command); - this.renderRosterPanel('ally', `${actionMessage}\n${this.remainingAllyCount()}명의 아군이 아직 행동할 수 있습니다.`); + const remaining = this.remainingAllyCount(); + const turnHint = remaining > 0 ? `${remaining}명의 아군이 아직 행동할 수 있습니다.` : '모든 아군이 행동했습니다. 빈 맵 우클릭 메뉴에서 턴 종료를 선택하세요.'; + this.renderRosterPanel('ally', `${actionMessage}\n${turnHint}`); } private commandResultMessage(unit: UnitData, command: BattleCommand) { if (command === 'attack') { - return `${unit.name} 공격 명령 완료\n다음 구현에서 대상 선택과 피해 처리를 연결할 예정입니다.`; + return this.recordAttackIntent(unit); } if (command === 'strategy') { return `${unit.name} 책략 명령 완료\n책략 목록과 MP는 다음 전투 확장 때 붙이겠습니다.`; @@ -502,9 +551,253 @@ export class BattleScene extends Phaser.Scene { return firstBattleUnits.filter((unit) => unit.faction === 'ally' && !this.actedUnitIds.has(unit.id)).length; } + private createBondStates() { + return new Map( + firstBattleBonds.map((bond) => [ + bond.id, + { + ...bond, + battleExp: 0 + } + ]) + ); + } + + private handleRightClick(pointer: Phaser.Input.Pointer) { + if (this.cancelPendingMove()) { + return; + } + + if (this.isPointerOnEmptyMapTile(pointer)) { + this.showMapMenu(pointer.x, pointer.y); + return; + } + + this.hideMapMenu(); + } + + private isPointerOnEmptyMapTile(pointer: Phaser.Input.Pointer) { + const tile = this.pointerToTile(pointer); + if (!tile) { + return false; + } + + return !this.isOccupied(tile.x, tile.y); + } + + private pointerToTile(pointer: Phaser.Input.Pointer) { + const { gridX, gridY, gridSize, tileSize } = this.layout; + if (pointer.x < gridX || pointer.y < gridY || pointer.x >= gridX + gridSize || pointer.y >= gridY + gridSize) { + return undefined; + } + + return { + x: Math.floor((pointer.x - gridX) / tileSize), + y: Math.floor((pointer.y - gridY) / tileSize) + }; + } + + private showMapMenu(pointerX: number, pointerY: number) { + if (this.phase === 'command') { + return; + } + + this.hideMapMenu(); + this.clearMarkers(); + this.selectedUnit = undefined; + this.pendingMove = undefined; + this.phase = 'idle'; + + const actions: MapMenuAction[] = ['endTurn', 'roster', 'bond', 'situation', 'bgm', 'close']; + const menuWidth = 148; + const buttonHeight = 34; + const padding = 8; + const menuHeight = padding * 2 + actions.length * buttonHeight; + const left = this.layout.mapX + 84; + const top = Phaser.Math.Clamp(pointerY - menuHeight / 2, this.layout.mapY + 16, this.layout.mapY + this.layout.mapHeight - menuHeight - 16); + + const panel = this.add.rectangle(left, top, menuWidth, menuHeight, 0x101821, 0.97); + panel.setOrigin(0); + panel.setDepth(20); + panel.setStrokeStyle(2, palette.gold, 0.86); + this.mapMenuObjects.push(panel); + + actions.forEach((action, index) => { + const y = top + padding + index * buttonHeight; + const disabled = action === 'endTurn' && this.activeFaction !== 'ally'; + const bg = this.add.rectangle(left + 8, y, menuWidth - 16, buttonHeight - 4, disabled ? 0x121820 : 0x1a2630, disabled ? 0.72 : 0.94); + bg.setOrigin(0); + bg.setDepth(21); + bg.setStrokeStyle(1, action === 'endTurn' ? 0xd8b15f : 0x5a7588, disabled ? 0.32 : 0.68); + this.mapMenuObjects.push(bg); + + const text = this.add.text(left + menuWidth / 2, y + buttonHeight / 2 - 2, mapMenuLabels[action], { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: disabled ? '#737b84' : '#f2e3bf', + fontStyle: '700' + }); + text.setOrigin(0.5); + text.setDepth(22); + this.mapMenuObjects.push(text); + + if (!disabled) { + const run = () => 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('pointerdown', run); + text.setInteractive({ useHandCursor: true }); + text.on('pointerdown', run); + } + }); + } + + private runMapMenuAction(action: MapMenuAction) { + this.hideMapMenu(); + soundDirector.playSelect(); + + if (action === 'endTurn') { + this.endAllyTurn(); + return; + } + if (action === 'roster') { + this.renderRosterPanel('ally', '부대 일람입니다.'); + return; + } + if (action === 'bond') { + this.renderBondPanel(); + return; + } + if (action === 'situation') { + this.renderSituationPanel(); + return; + } + if (action === 'bgm') { + soundDirector.setMuted(!soundDirector.isMuted()); + this.renderSituationPanel(`BGM을 ${soundDirector.isMuted() ? '껐습니다' : '켰습니다'}.`); + } + } + + private hideMapMenu() { + this.mapMenuObjects.forEach((object) => object.destroy()); + this.mapMenuObjects = []; + } + + private endAllyTurn() { + if (this.activeFaction !== 'ally') { + this.renderSituationPanel('이미 적군 행동 중입니다.'); + return; + } + if (this.phase === 'command') { + this.setInfo('현재 장수의 행동을 먼저 결정하세요.'); + return; + } + + this.selectedUnit = undefined; + this.pendingMove = undefined; + this.phase = 'idle'; + this.clearMarkers(); + this.hideCommandMenu(); + + this.activeFaction = 'enemy'; + this.updateTurnText(); + this.renderSituationPanel('아군 턴을 종료했습니다.\n황건적은 아직 간단 AI 구현 전이라 정비만 하고 넘어갑니다.'); + + this.time.delayedCall(700, () => this.beginNextAllyTurn()); + } + + private beginNextAllyTurn() { + this.turnNumber += 1; + this.activeFaction = 'ally'; + this.actedUnitIds.clear(); + this.attackIntents = []; + this.resetActedStyles(); + this.updateTurnText(); + this.renderRosterPanel('ally', `${this.turnNumber}턴 아군 차례입니다.\n행동할 장수를 선택하세요.`); + } + + private updateTurnText() { + this.turnText?.setText(`${this.turnNumber}턴 / ${factionLabels[this.activeFaction]}`); + } + + private resetActedStyles() { + this.unitViews.forEach((view, unitId) => { + const unit = firstBattleUnits.find((candidate) => candidate.id === unitId); + view.sprite.postFX.clear(); + view.sprite.clearTint(); + view.sprite.setAlpha(1); + view.label.setAlpha(1); + view.label.setColor(unit?.faction === 'ally' ? '#e7edf7' : '#ffebe7'); + view.label.setBackgroundColor('rgba(8,10,14,0.78)'); + }); + } + + private recordAttackIntent(unit: UnitData) { + const target = this.findNearestEnemy(unit); + if (!target) { + return `${unit.name} 공격 명령 완료\n공격 가능한 적이 없습니다.`; + } + + const partners = this.attackIntents + .filter((intent) => intent.targetId === target.id && intent.attackerId !== unit.id) + .map((intent) => intent.attackerId); + let gained = 0; + + partners.forEach((partnerId) => { + const bond = this.findBond(unit.id, partnerId); + if (!bond) { + return; + } + bond.battleExp += 4; + bond.exp += 4; + while (bond.exp >= 100) { + bond.exp -= 100; + bond.level = Math.min(100, bond.level + 1); + } + gained += 4; + }); + + this.attackIntents = this.attackIntents.filter((intent) => intent.attackerId !== unit.id); + this.attackIntents.push({ attackerId: unit.id, targetId: target.id }); + + const bonus = this.strongestBondBonus(unit.id); + const chain = bonus.chainRate > 0 ? `\n공명 보너스: 피해 +${bonus.damageBonus}% / 연계 ${bonus.chainRate}%` : ''; + const exp = gained > 0 ? `\n같은 목표를 노린 공명 경험치 +${gained}` : '\n같은 목표를 노린 장수가 생기면 공명 경험치가 쌓입니다.'; + return `${unit.name} 공격 명령 완료\n예상 목표: ${target.name}${chain}${exp}`; + } + + private findNearestEnemy(unit: UnitData) { + const enemies = firstBattleUnits.filter((candidate) => candidate.faction === 'enemy' && candidate.hp > 0); + return enemies.sort((a, b) => this.tileDistance(unit, a) - this.tileDistance(unit, b))[0]; + } + + private tileDistance(a: UnitData, b: UnitData) { + return Math.abs(a.x - b.x) + Math.abs(a.y - b.y); + } + + private findBond(unitId: string, partnerId: string) { + return Array.from(this.bondStates.values()).find((bond) => { + return bond.unitIds.includes(unitId) && bond.unitIds.includes(partnerId); + }); + } + + private strongestBondBonus(unitId: string) { + const related = Array.from(this.bondStates.values()).filter((bond) => bond.unitIds.includes(unitId)); + const strongest = related.sort((a, b) => b.level - a.level)[0]; + if (!strongest) { + return { damageBonus: 0, chainRate: 0 }; + } + + return { + damageBonus: Math.floor(strongest.level / 20) * 3, + chainRate: strongest.level >= 70 ? 18 : strongest.level >= 50 ? 8 : 0 + }; + } + private cancelPendingMove() { if (this.phase !== 'command' || !this.pendingMove) { - return; + return false; } const { unit, fromX, fromY, toX, toY } = this.pendingMove; @@ -524,6 +817,7 @@ export class BattleScene extends Phaser.Scene { unit, `이동을 취소했습니다.\n${toX + 1}, ${toY + 1}에서 원래 위치 ${fromX + 1}, ${fromY + 1}로 되돌렸습니다.` ); + return true; } private moveUnitView( @@ -570,6 +864,126 @@ export class BattleScene extends Phaser.Scene { view.label.setBackgroundColor('rgba(20,20,20,0.66)'); } + private renderBondPanel(message?: string) { + this.clearSidePanelContent(); + const { panelX, panelY, panelWidth } = this.layout; + const left = panelX + 24; + const width = panelWidth - 48; + const top = panelY + 132; + + this.trackSideObject(this.add.text(left, top, '공명 관계', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '22px', + color: '#f2e3bf', + fontStyle: '700' + })); + this.trackSideObject(this.add.text(left, top + 34, '같은 적을 노리거나 전투 사이 이벤트로 상승합니다.', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '14px', + color: '#9fb0bf' + })); + + Array.from(this.bondStates.values()).forEach((bond, index) => { + const y = top + 70 + index * 104; + const [first, second] = bond.unitIds.map((unitId) => this.unitName(unitId)); + const bg = this.trackSideObject(this.add.rectangle(left, y, width, 92, 0x101820, 0.9)); + bg.setOrigin(0); + bg.setStrokeStyle(1, bond.level >= 70 ? palette.gold : palette.blue, 0.66); + + this.trackSideObject(this.add.text(left + 12, y + 8, `${first} · ${second}`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#e8dfca', + fontStyle: '700' + })); + const levelText = this.trackSideObject(this.add.text(left + width - 12, y + 8, `${bond.level}`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '18px', + color: '#f2e3bf', + fontStyle: '700' + })); + levelText.setOrigin(1, 0); + + this.trackSideObject(this.add.text(left + 12, y + 33, bond.title, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '14px', + color: '#d8b15f' + })); + this.drawGauge(left + 86, y + 39, width - 148, 8, bond.exp / 100, bond.level >= 70 ? 0xd8b15f : 0x58aee0); + + const bonus = this.bondBonus(bond); + this.trackSideObject(this.add.text(left + 12, y + 59, `피해 +${bonus.damageBonus}% 연계 ${bonus.chainRate}% 전투 +${bond.battleExp}`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '14px', + color: '#d4dce6' + })); + }); + + this.renderPanelMessage(message ?? '공격 명령에서 같은 예상 목표를 노리면 공명 경험치가 쌓입니다.', left, top + 396, width); + } + + private renderSituationPanel(message?: string) { + this.clearSidePanelContent(); + const { panelX, panelY, panelWidth } = this.layout; + const left = panelX + 24; + const width = panelWidth - 48; + const top = panelY + 132; + const actedCount = firstBattleUnits.filter((unit) => unit.faction === 'ally' && this.actedUnitIds.has(unit.id)).length; + const allyCount = firstBattleUnits.filter((unit) => unit.faction === 'ally').length; + const enemyCount = firstBattleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp > 0).length; + const bestBond = Array.from(this.bondStates.values()).sort((a, b) => b.level - a.level)[0]; + + this.trackSideObject(this.add.text(left, top, '전황', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '24px', + color: '#f2e3bf', + fontStyle: '700' + })); + + this.renderSituationLine('현재 턴', `${this.turnNumber}턴 / ${factionLabels[this.activeFaction]}`, left, top + 48, width); + this.renderSituationLine('아군 행동', `${actedCount} / ${allyCount}`, left, top + 92, width); + this.renderSituationLine('남은 적', `${enemyCount}`, left, top + 136, width); + this.renderSituationLine('BGM', soundDirector.isMuted() ? '꺼짐' : '켜짐', left, top + 180, width); + this.renderSituationLine( + '최고 공명', + bestBond ? `${this.unitName(bestBond.unitIds[0])}·${this.unitName(bestBond.unitIds[1])} ${bestBond.level}` : '-', + left, + top + 224, + width + ); + + this.renderPanelMessage(message ?? '빈 맵에서 우클릭하면 전술 메뉴를 다시 열 수 있습니다.', left, top + 286, width); + } + + private renderSituationLine(label: string, value: string, x: number, y: number, width: number) { + const bg = this.trackSideObject(this.add.rectangle(x, y, width, 34, 0x101820, 0.86)); + bg.setOrigin(0); + bg.setStrokeStyle(1, 0x53606c, 0.46); + this.trackSideObject(this.add.text(x + 12, y + 8, label, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '15px', + color: '#9fb0bf' + })); + const valueText = this.trackSideObject(this.add.text(x + width - 12, y + 7, value, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '16px', + color: '#f2e3bf', + fontStyle: '700' + })); + valueText.setOrigin(1, 0); + } + + private bondBonus(bond: BondState) { + return { + damageBonus: Math.floor(bond.level / 20) * 3, + chainRate: bond.level >= 70 ? 18 : bond.level >= 50 ? 8 : 0 + }; + } + + private unitName(unitId: string) { + return firstBattleUnits.find((unit) => unit.id === unitId)?.name ?? unitId; + } + private renderRosterPanel(tab: RosterTab = this.rosterTab, message?: string) { this.rosterTab = tab; this.clearSidePanelContent();