diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 7a90cb1..3eaece8 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -8,7 +8,7 @@ import { type UnitData, type UnitStats } from '../data/scenario'; -import { getTerrainRule, getUnitClass, type TerrainType } from '../data/battleRules'; +import { getTerrainRule, getUnitClass, type TerrainType, type UnitClassKey } from '../data/battleRules'; import { equipmentExpToNext, equipmentSlotLabels, @@ -59,11 +59,12 @@ type UnitView = { type UnitDirection = 'south' | 'east' | 'north' | 'west'; -type BattlePhase = 'idle' | 'moving' | 'command'; +type BattlePhase = 'idle' | 'moving' | 'command' | 'targeting'; type BattleCommand = 'attack' | 'strategy' | 'item' | 'wait'; type RosterTab = 'ally' | 'enemy'; type ActiveFaction = 'ally' | 'enemy'; type MapMenuAction = 'endTurn' | 'roster' | 'bond' | 'situation' | 'bgm' | 'close'; +type EnemyAiBehavior = 'aggressive' | 'guard' | 'hold'; type CommandButton = { command: BattleCommand; @@ -98,8 +99,43 @@ type EquipmentGrowthResult = { leveled: boolean; }; +type CombatPreview = { + attacker: UnitData; + defender: UnitData; + damage: number; + hitRate: number; + terrainLabel: string; +}; + +type CombatResult = CombatPreview & { + defeated: boolean; + weaponGrowth: EquipmentGrowthResult; + armorGrowth: EquipmentGrowthResult; + bondExp: number; +}; + const maxEquipmentLevel = 9; +const attackRangeByClass: Partial> = { + archer: 2 +}; + +const enemyAiByUnitId: Record = { + 'rebel-a': 'aggressive', + 'rebel-b': 'guard', + 'rebel-c': 'hold', + 'rebel-leader': 'guard' +}; + +const defaultEnemyAiByClass: Partial> = { + archer: 'hold', + cavalry: 'aggressive', + yellowTurban: 'guard', + rebelLeader: 'guard' +}; + +const guardDetectionRange = 5; + const commandLabels: Record = { attack: '공격', strategy: '책략', @@ -147,10 +183,13 @@ export class BattleScene extends Phaser.Scene { private commandButtons: CommandButton[] = []; private commandMenuObjects: Array = []; private mapMenuObjects: Array = []; + private turnPromptObjects: Phaser.GameObjects.GameObject[] = []; private unitViews = new Map(); private actedUnitIds = new Set(); private bondStates = new Map(); private attackIntents: AttackIntent[] = []; + private battleLog: string[] = []; + private enemyHomeTiles = new Map(); private pendingMove?: PendingMove; private debugOverlay?: Phaser.GameObjects.Text; @@ -162,6 +201,8 @@ export class BattleScene extends Phaser.Scene { const { width, height } = this.scale; this.layout = this.createLayout(width, height); this.bondStates = this.createBondStates(); + this.enemyHomeTiles = this.createEnemyHomeTiles(); + this.battleLog = []; soundDirector.playMusic('battle-prep'); this.input.mouse?.disableContextMenu(); this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => { @@ -317,12 +358,28 @@ export class BattleScene extends Phaser.Scene { private selectUnit(unit: UnitData) { this.hideMapMenu(); + this.hideTurnEndPrompt(); - if (this.phase === 'command') { + if (this.phase === 'targeting' && this.selectedUnit) { + if (unit.faction !== this.selectedUnit.faction && unit.hp > 0) { + this.tryResolveAttackTarget(this.selectedUnit, unit); + return; + } + + this.renderUnitDetail(this.selectedUnit, '공격할 적 부대를 선택하세요. 우클릭하면 명령 선택으로 돌아갑니다.'); + return; + } + + if (this.phase === 'command' || this.phase === 'targeting') { this.setInfo('먼저 현재 장수의 행동을 결정하세요.\n공격, 책략, 도구, 대기 중 하나를 선택해야 합니다.'); return; } + if (unit.hp <= 0) { + this.renderRosterPanel(unit.faction, '이미 퇴각한 부대입니다.'); + return; + } + if (unit.faction !== 'ally') { this.selectedUnit = undefined; this.pendingMove = undefined; @@ -469,7 +526,7 @@ export class BattleScene extends Phaser.Scene { } private isOccupied(x: number, y: number, exceptUnitId?: string) { - return firstBattleUnits.some((unit) => unit.id !== exceptUnitId && unit.x === x && unit.y === y); + return firstBattleUnits.some((unit) => unit.id !== exceptUnitId && unit.hp > 0 && unit.x === x && unit.y === y); } private tileCenterX(x: number) { @@ -537,6 +594,11 @@ export class BattleScene extends Phaser.Scene { return; } if (choicePointer.leftButtonDown()) { + if (command === 'attack') { + this.beginAttackTargeting(unit); + return; + } + this.completeUnitAction(unit, command); } }; @@ -577,6 +639,9 @@ export class BattleScene extends Phaser.Scene { return; } + this.finishUnitAction(unit, this.commandResultMessage(unit, command)); + return; + this.actedUnitIds.add(unit.id); this.phase = 'idle'; this.selectedUnit = undefined; @@ -591,6 +656,164 @@ export class BattleScene extends Phaser.Scene { this.renderRosterPanel('ally', `${actionMessage}\n${turnHint}`); } + private beginAttackTargeting(unit: UnitData) { + if (this.phase !== 'command' || this.selectedUnit?.id !== unit.id || this.actedUnitIds.has(unit.id)) { + return; + } + + const targets = this.attackableTargets(unit); + if (targets.length === 0) { + this.renderUnitDetail(unit, '현재 위치에서 공격 가능한 적이 없습니다. 다른 명령을 선택하거나 우클릭으로 이동을 취소하세요.'); + return; + } + + this.phase = 'targeting'; + this.hideCommandMenu(); + this.clearMarkers(); + this.renderUnitDetail(unit, '붉은 칸의 적을 선택하면 공격합니다. 우클릭하면 명령 선택으로 돌아갑니다.'); + this.showAttackTargets(unit, targets); + soundDirector.playSelect(); + } + + private showAttackTargets(attacker: UnitData, targets = this.attackableTargets(attacker)) { + targets.forEach((target) => { + const preview = this.combatPreview(attacker, target); + const marker = this.add.rectangle( + this.layout.gridX + target.x * this.layout.tileSize, + this.layout.gridY + target.y * this.layout.tileSize, + this.layout.tileSize, + this.layout.tileSize, + 0xc93b30, + 0.3 + ); + marker.setOrigin(0); + marker.setStrokeStyle(2, 0xffd27a, 0.9); + marker.setDepth(9); + marker.setInteractive({ useHandCursor: true }); + marker.on('pointerover', () => { + marker.setFillStyle(0xd9503f, 0.44); + this.renderAttackPreview(preview); + }); + marker.on('pointerout', () => marker.setFillStyle(0xc93b30, 0.3)); + marker.on('pointerdown', (pointer: Phaser.Input.Pointer) => { + if (pointer.leftButtonDown()) { + this.tryResolveAttackTarget(attacker, target); + } + }); + this.markers.push(marker); + }); + } + + private tryResolveAttackTarget(attacker: UnitData, target: UnitData) { + if (this.phase !== 'targeting' || this.selectedUnit?.id !== attacker.id) { + return; + } + + if (!this.canAttack(attacker, target)) { + this.renderUnitDetail(attacker, '사거리 밖의 적입니다. 붉은 칸으로 표시된 적을 선택하세요.'); + return; + } + + const result = this.resolveAttack(attacker, target); + this.clearMarkers(); + this.finishUnitAction(attacker, this.formatCombatResult(result)); + } + + private renderAttackPreview(preview: CombatPreview) { + this.renderUnitDetail( + preview.defender, + `${preview.attacker.name} 공격 예측\n피해 ${preview.damage} / 명중 ${preview.hitRate}% / 지형 ${preview.terrainLabel}` + ); + } + + private finishUnitAction(unit: UnitData, message: string) { + this.actedUnitIds.add(unit.id); + this.phase = 'idle'; + this.selectedUnit = undefined; + this.pendingMove = undefined; + this.hideCommandMenu(); + this.hideTurnEndPrompt(); + this.applyActedStyle(unit); + soundDirector.playSelect(); + + const remaining = this.remainingAllyCount(); + const turnHint = + remaining > 0 + ? `${remaining}명의 아군이 아직 행동할 수 있습니다.` + : '모든 아군이 행동했습니다. 턴 종료 여부를 선택하세요.'; + this.pushBattleLog(message); + this.renderRosterPanel('ally', `${message}\n${turnHint}`); + + if (remaining === 0) { + this.showTurnEndPrompt(message); + } + } + + private attackableTargets(attacker: UnitData) { + return firstBattleUnits.filter((target) => this.canAttack(attacker, target)); + } + + private canAttack(attacker: UnitData, target: UnitData) { + if (attacker.faction === target.faction || target.hp <= 0) { + return false; + } + + return this.tileDistance(attacker, target) <= this.attackRange(attacker); + } + + private attackRange(unit: UnitData) { + return attackRangeByClass[unit.classKey] ?? 1; + } + + private combatPreview(attacker: UnitData, defender: UnitData): CombatPreview { + const terrain = firstBattleMap.terrain[defender.y][defender.x]; + const terrainRule = getTerrainRule(terrain); + const attackPower = attacker.attack + this.equipmentAttackBonus(attacker) + Math.floor(attacker.stats.might / 12); + const defensePower = this.equipmentDefenseBonus(defender) + Math.floor(defender.stats.leadership / 18) + Math.floor(terrainRule.defenseBonus / 3); + const bondBonus = attacker.faction === 'ally' ? this.strongestBondBonus(attacker.id).damageBonus : 0; + const damage = Phaser.Math.Clamp(Math.round((attackPower - defensePower) * (1 + bondBonus / 100)), 3, defender.maxHp); + const hitRate = Phaser.Math.Clamp(84 + Math.floor((attacker.stats.agility - defender.stats.agility) / 4), 65, 98); + + return { + attacker, + defender, + damage, + hitRate, + terrainLabel: terrainRule.label + }; + } + + private resolveAttack(attacker: UnitData, defender: UnitData): CombatResult { + const preview = this.combatPreview(attacker, defender); + const weaponGrowth = this.awardEquipmentExp(attacker, 'weapon', this.weaponExpGain(attacker)); + const armorGrowth = this.awardEquipmentExp(defender, 'armor', 6); + const bondExp = attacker.faction === 'ally' ? this.recordBondAttackIntent(attacker, defender) : 0; + + defender.hp = Math.max(0, defender.hp - preview.damage); + this.faceUnitToward(attacker, defender); + this.flashDamage(defender, preview.damage); + + if (defender.hp <= 0) { + this.applyDefeatedStyle(defender); + } else { + this.syncUnitMotion(defender); + } + + return { + ...preview, + defeated: defender.hp <= 0, + weaponGrowth, + armorGrowth, + bondExp + }; + } + + private formatCombatResult(result: CombatResult) { + const defeated = result.defeated ? `\n${result.defender.name} 퇴각!` : `\n${result.defender.name} HP ${result.defender.hp}/${result.defender.maxHp}`; + const bond = result.bondExp > 0 ? `\n공명 경험치 +${result.bondExp}` : ''; + return `${result.attacker.name} 공격\n${result.defender.name}에게 ${result.damage} 피해${defeated}\n${this.formatEquipmentGrowth(result.weaponGrowth)}\n${this.formatEquipmentGrowth(result.armorGrowth)}${bond}`; + } + private commandResultMessage(unit: UnitData, command: BattleCommand) { if (command === 'attack') { return this.recordAttackIntent(unit); @@ -605,7 +828,7 @@ export class BattleScene extends Phaser.Scene { } private remainingAllyCount() { - return firstBattleUnits.filter((unit) => unit.faction === 'ally' && !this.actedUnitIds.has(unit.id)).length; + return firstBattleUnits.filter((unit) => unit.faction === 'ally' && unit.hp > 0 && !this.actedUnitIds.has(unit.id)).length; } private createBondStates() { @@ -620,7 +843,19 @@ export class BattleScene extends Phaser.Scene { ); } + private createEnemyHomeTiles() { + return new Map( + firstBattleUnits + .filter((unit) => unit.faction === 'enemy') + .map((unit) => [unit.id, { x: unit.x, y: unit.y }] as const) + ); + } + private handleRightClick(pointer: Phaser.Input.Pointer) { + if (this.cancelAttackTargeting()) { + return; + } + if (this.cancelPendingMove()) { return; } @@ -655,11 +890,12 @@ export class BattleScene extends Phaser.Scene { } private showMapMenu(pointerX: number, pointerY: number) { - if (this.phase === 'command') { + if (this.phase === 'command' || this.phase === 'targeting') { return; } this.hideMapMenu(); + this.hideTurnEndPrompt(); this.clearMarkers(); this.selectedUnit = undefined; this.pendingMove = undefined; @@ -746,7 +982,7 @@ export class BattleScene extends Phaser.Scene { this.renderSituationPanel('이미 적군 행동 중입니다.'); return; } - if (this.phase === 'command') { + if (this.phase === 'command' || this.phase === 'targeting') { this.setInfo('현재 장수의 행동을 먼저 결정하세요.'); return; } @@ -756,6 +992,13 @@ export class BattleScene extends Phaser.Scene { this.phase = 'idle'; this.clearMarkers(); this.hideCommandMenu(); + this.hideTurnEndPrompt(); + + this.activeFaction = 'enemy'; + this.updateTurnText(); + this.renderSituationPanel('아군 턴을 종료했습니다.\n적군이 행동을 시작합니다.'); + void this.runEnemyTurn(); + return; this.activeFaction = 'enemy'; this.updateTurnText(); @@ -765,6 +1008,159 @@ export class BattleScene extends Phaser.Scene { this.time.delayedCall(700, () => this.beginNextAllyTurn()); } + private async runEnemyTurn() { + const enemies = firstBattleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp > 0); + const messages: string[] = []; + + for (const enemy of enemies) { + const message = await this.executeEnemyAction(enemy); + if (message) { + messages.push(message); + this.pushBattleLog(message); + this.renderSituationPanel(messages.slice(-3).join('\n')); + } + await this.delay(260); + } + + this.time.delayedCall(520, () => this.beginNextAllyTurn()); + } + + private async executeEnemyAction(enemy: UnitData) { + const behavior = this.enemyBehavior(enemy); + let target = this.chooseEnemyTarget(enemy, behavior); + + if (!target) { + return `${enemy.name}: 대기`; + } + + if (!this.canAttack(enemy, target) && behavior !== 'hold') { + const destination = this.chooseApproachTile(enemy, target); + if (destination && (destination.x !== enemy.x || destination.y !== enemy.y)) { + enemy.x = destination.x; + enemy.y = destination.y; + await this.moveUnitViewAsync(enemy, destination.x, destination.y, this.unitViews.get(enemy.id), 260); + } + } + + target = this.chooseTargetInRange(enemy) ?? target; + if (this.canAttack(enemy, target)) { + const result = this.resolveAttack(enemy, target); + return this.formatEnemyCombatResult(result, behavior); + } + + return `${enemy.name}: ${this.enemyBehaviorLabel(behavior)} 태세로 접근`; + } + + private chooseEnemyTarget(enemy: UnitData, behavior: EnemyAiBehavior) { + if (behavior === 'hold') { + return this.chooseTargetInRange(enemy); + } + + const nearest = this.nearestAliveUnit(enemy, 'ally'); + if (!nearest) { + return undefined; + } + + if (behavior === 'guard') { + const home = this.enemyHomeTiles.get(enemy.id) ?? { x: enemy.x, y: enemy.y }; + const distanceFromHome = Math.abs(nearest.x - home.x) + Math.abs(nearest.y - home.y); + if (distanceFromHome > guardDetectionRange && !this.canAttack(enemy, nearest)) { + return undefined; + } + } + + return nearest; + } + + private chooseTargetInRange(enemy: UnitData) { + return firstBattleUnits + .filter((unit) => unit.faction === 'ally' && unit.hp > 0 && this.canAttack(enemy, unit)) + .sort((a, b) => a.hp - b.hp || this.tileDistance(enemy, a) - this.tileDistance(enemy, b))[0]; + } + + private chooseApproachTile(enemy: UnitData, target: UnitData) { + const candidates = [{ x: enemy.x, y: enemy.y, cost: 0 }, ...this.reachableTiles(enemy)]; + return candidates + .filter((tile) => !this.isOccupied(tile.x, tile.y, enemy.id)) + .sort((a, b) => { + const aDistance = Math.abs(a.x - target.x) + Math.abs(a.y - target.y); + const bDistance = Math.abs(b.x - target.x) + Math.abs(b.y - target.y); + const aTerrain = getTerrainRule(firstBattleMap.terrain[a.y][a.x]).defenseBonus; + const bTerrain = getTerrainRule(firstBattleMap.terrain[b.y][b.x]).defenseBonus; + return aDistance - bDistance || bTerrain - aTerrain || a.cost - b.cost; + })[0]; + } + + private nearestAliveUnit(unit: UnitData, faction: UnitData['faction']) { + return firstBattleUnits + .filter((candidate) => candidate.faction === faction && candidate.hp > 0) + .sort((a, b) => this.tileDistance(unit, a) - this.tileDistance(unit, b))[0]; + } + + private enemyBehavior(enemy: UnitData): EnemyAiBehavior { + return enemyAiByUnitId[enemy.id] ?? defaultEnemyAiByClass[enemy.classKey] ?? 'guard'; + } + + private enemyBehaviorLabel(behavior: EnemyAiBehavior) { + if (behavior === 'aggressive') { + return '추격'; + } + if (behavior === 'hold') { + return '고수'; + } + return '경계'; + } + + private formatEnemyCombatResult(result: CombatResult, behavior: EnemyAiBehavior) { + const defeated = result.defeated ? ` / ${result.defender.name} 퇴각` : ` / ${result.defender.name} HP ${result.defender.hp}/${result.defender.maxHp}`; + return `${result.attacker.name} ${this.enemyBehaviorLabel(behavior)} 공격: ${result.defender.name}에게 ${result.damage} 피해${defeated}`; + } + + private delay(ms: number) { + return new Promise((resolve) => { + this.time.delayedCall(ms, () => resolve()); + }); + } + + private moveUnitViewAsync( + unit: UnitData, + x: number, + y: number, + view = this.unitViews.get(unit.id), + duration = 260 + ) { + return new Promise((resolve) => { + if (!view) { + resolve(); + return; + } + + const targetX = this.tileCenterX(x); + const targetY = this.tileCenterY(y); + const direction = this.directionFromDelta(targetX - view.sprite.x, targetY - view.sprite.y); + this.tweens.killTweensOf([view.sprite, view.label]); + this.playUnitWalk(view, direction); + this.tweens.add({ + targets: view.sprite, + x: targetX, + y: targetY, + duration, + ease: 'Sine.easeInOut', + onComplete: () => { + this.syncUnitMotion(unit, view, direction); + resolve(); + } + }); + this.tweens.add({ + targets: view.label, + x: targetX, + y: targetY + this.layout.tileSize * 0.52, + duration, + ease: 'Sine.easeInOut' + }); + }); + } + private beginNextAllyTurn() { this.turnNumber += 1; this.activeFaction = 'ally'; @@ -782,6 +1178,10 @@ export class BattleScene extends Phaser.Scene { private resetActedStyles() { this.unitViews.forEach((view, unitId) => { const unit = firstBattleUnits.find((candidate) => candidate.id === unitId); + if (unit && unit.hp <= 0) { + this.applyDefeatedStyle(unit); + return; + } view.sprite.postFX.clear(); view.sprite.clearTint(); view.sprite.setAlpha(1); @@ -796,6 +1196,32 @@ export class BattleScene extends Phaser.Scene { }); } + private recordBondAttackIntent(unit: UnitData, target: UnitData) { + 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 }); + + return gained; + } + private recordAttackIntent(unit: UnitData) { const target = this.findNearestEnemy(unit); const weaponGrowth = this.awardEquipmentExp(unit, 'weapon', this.weaponExpGain(unit)); @@ -922,6 +1348,21 @@ export class BattleScene extends Phaser.Scene { }; } + private cancelAttackTargeting() { + if (this.phase !== 'targeting' || !this.selectedUnit) { + return false; + } + + const unit = this.selectedUnit; + const view = this.unitViews.get(unit.id); + this.phase = 'command'; + this.clearMarkers(); + soundDirector.playSelect(); + this.showCommandMenu(unit, view?.sprite.x ?? this.tileCenterX(unit.x), view?.sprite.y ?? this.tileCenterY(unit.y)); + this.renderUnitDetail(unit, '공격 선택을 취소했습니다. 명령을 다시 선택하세요.'); + return true; + } + private cancelPendingMove() { if (this.phase !== 'command' || !this.pendingMove) { return false; @@ -1038,6 +1479,142 @@ export class BattleScene extends Phaser.Scene { view.label.setBackgroundColor(''); } + private applyDefeatedStyle(unit: UnitData) { + const view = this.unitViews.get(unit.id); + if (!view) { + return; + } + + view.sprite.stop(); + view.sprite.disableInteractive(); + view.sprite.postFX.clear(); + view.sprite.postFX.addColorMatrix().grayscale(1); + view.sprite.setAlpha(0.22); + view.sprite.setTint(0x4f4f4f); + view.label.setAlpha(0.3); + view.label.setColor('#8a8a8a'); + } + + private faceUnitToward(unit: UnitData, target: UnitData) { + const view = this.unitViews.get(unit.id); + if (!view) { + return; + } + + const direction = this.directionFromDelta(target.x - unit.x, target.y - unit.y); + this.syncUnitMotion(unit, view, direction); + } + + private flashDamage(unit: UnitData, damage: number) { + const view = this.unitViews.get(unit.id); + if (!view) { + return; + } + + const popup = this.add.text(view.sprite.x, view.sprite.y - this.layout.tileSize * 0.5, `-${damage}`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '20px', + color: '#ffdf7b', + stroke: '#220909', + strokeThickness: 4, + fontStyle: '700' + }); + popup.setOrigin(0.5); + popup.setDepth(30); + + view.sprite.setTintFill(0xffe0a3); + this.time.delayedCall(120, () => { + if (unit.hp <= 0) { + this.applyDefeatedStyle(unit); + } else { + view.sprite.clearTint(); + this.syncUnitMotion(unit, view); + } + }); + + this.tweens.add({ + targets: popup, + y: popup.y - 28, + alpha: 0, + duration: 520, + ease: 'Sine.easeOut', + onComplete: () => popup.destroy() + }); + } + + private showTurnEndPrompt(message?: string) { + this.hideTurnEndPrompt(); + + const width = 360; + const height = 156; + const left = this.layout.mapX + this.layout.mapWidth / 2 - width / 2; + const top = this.layout.mapY + this.layout.mapHeight / 2 - height / 2; + const panel = this.add.rectangle(left, top, width, height, 0x101821, 0.96); + panel.setOrigin(0); + panel.setDepth(40); + panel.setStrokeStyle(2, palette.gold, 0.9); + this.turnPromptObjects.push(panel); + + const title = this.add.text(left + width / 2, top + 26, '모든 아군의 행동이 종료되었습니다.', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '20px', + color: '#f2e3bf', + fontStyle: '700' + }); + title.setOrigin(0.5); + title.setDepth(41); + this.turnPromptObjects.push(title); + + const body = this.add.text(left + width / 2, top + 58, '턴을 종료하시겠습니까?', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#d4dce6' + }); + body.setOrigin(0.5); + body.setDepth(41); + this.turnPromptObjects.push(body); + + const buttons = [ + { label: '턴 종료', x: left + 106, action: () => this.endAllyTurn() }, + { label: '전장 확인', x: left + 254, action: () => this.hideTurnEndPrompt() } + ]; + + buttons.forEach(({ label, x, action }) => { + const bg = this.add.rectangle(x, top + 112, 116, 36, 0x1a2630, 0.95); + bg.setDepth(41); + bg.setStrokeStyle(1, label === '턴 종료' ? palette.gold : palette.blue, 0.72); + bg.setInteractive({ useHandCursor: true }); + bg.on('pointerover', () => bg.setFillStyle(0x283947, 0.98)); + bg.on('pointerout', () => bg.setFillStyle(0x1a2630, 0.95)); + bg.on('pointerdown', action); + this.turnPromptObjects.push(bg); + + const text = this.add.text(x, top + 112, label, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '16px', + color: '#f2e3bf', + fontStyle: '700' + }); + text.setOrigin(0.5); + text.setDepth(42); + text.setInteractive({ useHandCursor: true }); + text.on('pointerdown', action); + this.turnPromptObjects.push(text); + }); + + void message; + } + + private hideTurnEndPrompt() { + this.turnPromptObjects.forEach((object) => object.destroy()); + this.turnPromptObjects = []; + } + + private pushBattleLog(message: string) { + const firstLine = message.split('\n')[0]; + this.battleLog = [firstLine, ...this.battleLog].slice(0, 8); + } + private renderBondPanel(message?: string) { this.clearSidePanelContent(); const { panelX, panelY, panelWidth } = this.layout; @@ -1406,6 +1983,14 @@ export class BattleScene extends Phaser.Scene { }, 0); } + private equipmentDefenseBonus(unit: UnitData) { + return equipmentSlots.reduce((total, slot) => { + const item = getItem(unit.equipment[slot].itemId); + const levelBonus = slot === 'armor' ? unit.equipment[slot].level - 1 : 0; + return total + (item.defenseBonus ?? 0) + levelBonus; + }, 0); + } + 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)); @@ -1511,11 +2096,14 @@ export class BattleScene extends Phaser.Scene { : null, actedUnitIds: Array.from(this.actedUnitIds), attackIntents: this.attackIntents.map((intent) => ({ ...intent })), + battleLog: [...this.battleLog], units: firstBattleUnits.map((unit) => ({ id: unit.id, name: unit.name, faction: unit.faction, classKey: unit.classKey, + ai: unit.faction === 'enemy' ? this.enemyBehavior(unit) : null, + attackRange: this.attackRange(unit), hp: unit.hp, maxHp: unit.maxHp, x: unit.x,