From 6509d2a682c431d25225beed009a8a8ef19c1660 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Mon, 22 Jun 2026 14:30:12 +0900 Subject: [PATCH] Add tactics items and growth feedback --- src/game/scenes/BattleScene.ts | 766 +++++++++++++++++++++++++++++++-- 1 file changed, 727 insertions(+), 39 deletions(-) diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 4b05fcb..062380b 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -62,6 +62,9 @@ type UnitDirection = 'south' | 'east' | 'north' | 'west'; type BattlePhase = 'idle' | 'moving' | 'command' | 'targeting' | 'animating' | 'resolved'; type BattleCommand = 'attack' | 'strategy' | 'item' | 'wait'; type DamageCommand = Exclude; +type UsableCommand = Extract; +type UsableEffect = 'damage' | 'heal' | 'focus'; +type UsableTarget = 'enemy' | 'ally' | 'self'; type RosterTab = 'ally' | 'enemy'; type ActiveFaction = 'ally' | 'enemy'; type MapMenuAction = 'endTurn' | 'save' | 'load' | 'roster' | 'bond' | 'situation' | 'bgm' | 'close'; @@ -115,6 +118,7 @@ type CharacterGrowthResult = { type CombatPreview = { action: DamageCommand; + usable?: BattleUsable; attacker: UnitData; defender: UnitData; damage: number; @@ -137,6 +141,42 @@ type CombatResult = CombatPreview & { counter?: CombatResult; }; +type BattleUsable = { + id: string; + command: UsableCommand; + name: string; + target: UsableTarget; + effect: UsableEffect; + range: number; + power: number; + accuracyBonus?: number; + criticalBonus?: number; + attackBonus?: number; + hitBonus?: number; + duration?: number; + description: string; +}; + +type SupportResult = { + usable: BattleUsable; + user: UnitData; + target: UnitData; + previousTargetHp: number; + healAmount: number; + buff?: BattleBuffState; + characterGrowth: CharacterGrowthResult; + equipmentGrowth: EquipmentGrowthResult; +}; + +type BattleBuffState = { + unitId: string; + label: string; + turns: number; + attackBonus: number; + hitBonus: number; + criticalBonus: number; +}; + type SavedUnitState = Pick & { equipment: UnitData['equipment']; direction?: UnitDirection; @@ -153,6 +193,8 @@ type BattleSaveState = { battleLog: string[]; units: SavedUnitState[]; bonds: BondState[]; + itemStocks?: Record>; + battleBuffs?: BattleBuffState[]; }; const maxEquipmentLevel = 9; @@ -161,6 +203,103 @@ const battleSaveStorageKey = 'heros-web:first-battle-state'; const initialFirstBattleUnits = firstBattleUnits.map(cloneUnitData); const initialFirstBattleBonds = firstBattleBonds.map(cloneBattleBond); +const usableCatalog: Record = { + aid: { + id: 'aid', + command: 'strategy', + name: '응급', + target: 'ally', + effect: 'heal', + range: 2, + power: 12, + description: '가까운 아군의 병력을 조금 회복한다.' + }, + encourage: { + id: 'encourage', + command: 'strategy', + name: '격려', + target: 'ally', + effect: 'focus', + range: 2, + power: 0, + attackBonus: 2, + hitBonus: 8, + criticalBonus: 6, + duration: 2, + description: '아군의 공격, 명중, 치명 보정을 잠시 올린다.' + }, + fireTactic: { + id: 'fireTactic', + command: 'strategy', + name: '소화', + target: 'enemy', + effect: 'damage', + range: 2, + power: 12, + accuracyBonus: 6, + criticalBonus: 0, + description: '적 한 부대에 책략 피해를 준다.' + }, + roar: { + id: 'roar', + command: 'strategy', + name: '고함', + target: 'enemy', + effect: 'damage', + range: 1, + power: 9, + accuracyBonus: 8, + criticalBonus: 4, + description: '가까운 적을 위압해 피해를 준다.' + }, + bean: { + id: 'bean', + command: 'item', + name: '콩', + target: 'ally', + effect: 'heal', + range: 1, + power: 10, + description: '아군 한 부대의 병력을 소량 회복한다.' + }, + salve: { + id: 'salve', + command: 'item', + name: '상처약', + target: 'ally', + effect: 'heal', + range: 1, + power: 18, + description: '아군 한 부대의 병력을 크게 회복한다.' + }, + wine: { + id: 'wine', + command: 'item', + name: '술', + target: 'self', + effect: 'focus', + range: 0, + power: 0, + attackBonus: 3, + hitBonus: 6, + criticalBonus: 10, + duration: 2, + description: '사용자의 공격과 치명 보정을 잠시 올린다.' + } +}; + +const unitStrategyIds: Record = { + 'liu-bei': ['aid', 'encourage'], + 'guan-yu': ['fireTactic'], + 'zhang-fei': ['roar'] +}; + +const initialItemStocks: Record> = { + 'liu-bei': { bean: 2, salve: 1 }, + 'guan-yu': { bean: 1 }, + 'zhang-fei': { bean: 1, wine: 1 } +}; + const attackRangeByClass: Partial> = { archer: 2 }; @@ -247,6 +386,7 @@ export class BattleScene extends Phaser.Scene { private activeFaction: ActiveFaction = 'ally'; private selectedUnit?: UnitData; private targetingAction?: DamageCommand; + private selectedUsable?: BattleUsable; private turnText?: Phaser.GameObjects.Text; private markers: Phaser.GameObjects.Rectangle[] = []; private rosterTab: RosterTab = 'ally'; @@ -262,6 +402,8 @@ export class BattleScene extends Phaser.Scene { private unitViews = new Map(); private actedUnitIds = new Set(); private bondStates = new Map(); + private itemStocks = new Map>(); + private battleBuffs = new Map(); private attackIntents: AttackIntent[] = []; private battleLog: string[] = []; private enemyHomeTiles = new Map(); @@ -278,6 +420,8 @@ export class BattleScene extends Phaser.Scene { const { width, height } = this.scale; this.layout = this.createLayout(width, height); this.bondStates = this.createBondStates(); + this.itemStocks = this.createItemStocks(); + this.battleBuffs.clear(); this.enemyHomeTiles = this.createEnemyHomeTiles(); this.battleLog = []; this.actedUnitIds.clear(); @@ -285,6 +429,7 @@ export class BattleScene extends Phaser.Scene { this.selectedUnit = undefined; this.pendingMove = undefined; this.targetingAction = undefined; + this.selectedUsable = undefined; this.battleOutcome = undefined; this.phase = 'idle'; soundDirector.playMusic('battle-prep'); @@ -463,8 +608,19 @@ export class BattleScene extends Phaser.Scene { } if (this.phase === 'targeting' && this.selectedUnit) { - if (unit.faction !== this.selectedUnit.faction && unit.hp > 0 && this.targetingAction) { - void this.tryResolveDamageTarget(this.selectedUnit, unit, this.targetingAction); + if (unit.hp > 0 && this.targetingAction) { + if (this.selectedUsable && this.selectedUsable.effect !== 'damage') { + void this.tryResolveSupportTarget(this.selectedUnit, unit, this.selectedUsable); + return; + } + if (unit.faction !== this.selectedUnit.faction) { + void this.tryResolveDamageTarget(this.selectedUnit, unit, this.targetingAction, this.selectedUsable); + return; + } + } + + if (this.selectedUsable) { + this.renderUnitDetail(this.selectedUnit, `${this.selectedUsable.name} 대상으로 삼을 부대를 선택하세요. 우클릭하면 명령 선택으로 돌아갑니다.`); return; } @@ -697,7 +853,7 @@ export class BattleScene extends Phaser.Scene { } if (choicePointer.leftButtonDown()) { if (command === 'strategy' || command === 'item') { - this.showUnavailableCommandAlert(command); + this.showUsableMenu(unit, command, choicePointer.x, choicePointer.y); return; } @@ -741,6 +897,121 @@ export class BattleScene extends Phaser.Scene { this.commandButtons = []; } + private showUsableMenu(unit: UnitData, command: UsableCommand, pointerX: number, pointerY: number) { + const usables = this.availableUsables(unit, command); + if (usables.length === 0) { + this.showUnavailableCommandAlert(command); + return; + } + + this.hideCommandMenu(); + + const menuWidth = 238; + const titleHeight = 34; + const rowHeight = 52; + const padding = 9; + const menuHeight = padding * 2 + titleHeight + usables.length * rowHeight; + const { left, top } = this.commandMenuPosition(pointerX, pointerY, menuWidth, menuHeight); + + const panel = this.add.rectangle(left, top, menuWidth, menuHeight, 0x101821, 0.98); + panel.setOrigin(0); + panel.setStrokeStyle(2, command === 'strategy' ? palette.blue : palette.gold, 0.78); + panel.setDepth(14); + this.commandMenuObjects.push(panel); + + const title = this.add.text(left + menuWidth / 2, top + padding + 2, commandLabels[command], { + 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); + + usables.forEach((usable, index) => { + const rowTop = top + padding + titleHeight + index * rowHeight; + const bg = this.add.rectangle(left + 8, rowTop, menuWidth - 16, rowHeight - 6, 0x1a2630, 0.94); + bg.setOrigin(0); + bg.setDepth(15); + bg.setStrokeStyle(1, command === 'strategy' ? palette.blue : palette.gold, 0.66); + bg.setInteractive({ useHandCursor: true }); + this.commandMenuObjects.push(bg); + + const stock = command === 'item' ? ` x${this.itemStock(unit.id, usable.id)}` : ''; + const name = this.add.text(left + 18, rowTop + 7, `${usable.name}${stock}`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '16px', + color: '#f2e3bf', + fontStyle: '700' + }); + name.setDepth(16); + this.commandMenuObjects.push(name); + + const desc = this.add.text(left + 18, rowTop + 28, usable.description, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '12px', + color: '#aeb7c2', + wordWrap: { width: menuWidth - 36, useAdvancedWrap: true } + }); + desc.setDepth(16); + this.commandMenuObjects.push(desc); + + const choose = (choicePointer: Phaser.Input.Pointer) => { + if (choicePointer.rightButtonDown()) { + this.showCommandMenu(unit, pointerX, pointerY); + return; + } + if (choicePointer.leftButtonDown()) { + this.beginUsableTargeting(unit, usable); + } + }; + bg.on('pointerover', () => { + bg.setFillStyle(0x283947, 0.98); + this.renderUnitDetail(unit, `${usable.name}\n${usable.description}`); + }); + bg.on('pointerout', () => bg.setFillStyle(0x1a2630, 0.94)); + bg.on('pointerdown', choose); + name.setInteractive({ useHandCursor: true }); + name.on('pointerdown', choose); + desc.setInteractive({ useHandCursor: true }); + desc.on('pointerdown', choose); + }); + } + + private availableUsables(unit: UnitData, command: UsableCommand) { + if (command === 'strategy') { + return (unitStrategyIds[unit.id] ?? []) + .map((id) => usableCatalog[id]) + .filter((usable): usable is BattleUsable => Boolean(usable)); + } + + const stocks = this.itemStocks.get(unit.id); + if (!stocks) { + return []; + } + + return Array.from(stocks.entries()) + .filter(([, count]) => count > 0) + .map(([id]) => usableCatalog[id]) + .filter((usable): usable is BattleUsable => Boolean(usable)); + } + + private itemStock(unitId: string, itemId: string) { + return this.itemStocks.get(unitId)?.get(itemId) ?? 0; + } + + private consumeItem(unitId: string, itemId: string) { + const stocks = this.itemStocks.get(unitId); + const current = stocks?.get(itemId) ?? 0; + if (!stocks || current <= 0) { + return false; + } + + stocks.set(itemId, current - 1); + return true; + } + private showUnavailableCommandAlert(command: Extract) { const message = command === 'strategy' ? '사용할 수 있는 책략이 없습니다.' : '사용할 수 있는 도구가 없습니다.'; this.showBattleAlert(message); @@ -815,12 +1086,12 @@ export class BattleScene extends Phaser.Scene { this.finishUnitAction(unit, this.commandResultMessage(unit, command)); } - private beginDamageTargeting(unit: UnitData, action: DamageCommand) { + private beginDamageTargeting(unit: UnitData, action: DamageCommand, usable?: BattleUsable) { if (this.phase !== 'command' || this.selectedUnit?.id !== unit.id || this.actedUnitIds.has(unit.id)) { return; } - const targets = this.damageableTargets(unit, action); + const targets = this.damageableTargets(unit, action, usable); if (targets.length === 0) { this.renderUnitDetail(unit, `현재 위치에서 ${commandLabels[action]} 가능한 적이 없습니다. 다른 명령을 선택하거나 우클릭으로 이동을 취소하세요.`); return; @@ -828,16 +1099,45 @@ export class BattleScene extends Phaser.Scene { this.phase = 'targeting'; this.targetingAction = action; + this.selectedUsable = usable; this.hideCommandMenu(); this.clearMarkers(); - this.renderUnitDetail(unit, `붉은 칸의 적을 선택하면 ${commandLabels[action]}합니다. 우클릭하면 명령 선택으로 돌아갑니다.`); - this.showDamageTargets(unit, action, targets); + this.renderUnitDetail(unit, `붉은 칸의 적을 선택하면 ${usable?.name ?? commandLabels[action]}합니다. 우클릭하면 명령 선택으로 돌아갑니다.`); + this.showDamageTargets(unit, action, targets, usable); soundDirector.playSelect(); } - private showDamageTargets(attacker: UnitData, action: DamageCommand, targets = this.damageableTargets(attacker, action)) { + private beginUsableTargeting(unit: UnitData, usable: BattleUsable) { + if (usable.command === 'item' && this.itemStock(unit.id, usable.id) <= 0) { + this.showUnavailableCommandAlert('item'); + return; + } + + if (usable.effect === 'damage') { + this.beginDamageTargeting(unit, usable.command, usable); + return; + } + + const targets = this.supportTargets(unit, usable); + if (targets.length === 0) { + const reason = usable.effect === 'heal' ? '회복할 수 있는 아군이 없습니다.' : '대상으로 삼을 수 있는 아군이 없습니다.'; + this.renderUnitDetail(unit, `${usable.name}\n${reason}`); + return; + } + + this.phase = 'targeting'; + this.targetingAction = usable.command; + this.selectedUsable = usable; + this.hideCommandMenu(); + this.clearMarkers(); + this.renderUnitDetail(unit, `푸른 칸의 아군을 선택하면 ${usable.name}을 사용합니다. 우클릭하면 명령 선택으로 돌아갑니다.`); + this.showSupportTargets(unit, usable, targets); + soundDirector.playSelect(); + } + + private showDamageTargets(attacker: UnitData, action: DamageCommand, targets = this.damageableTargets(attacker, action), usable?: BattleUsable) { targets.forEach((target) => { - const preview = this.combatPreview(attacker, target, action); + const preview = this.combatPreview(attacker, target, action, usable); const marker = this.add.rectangle( this.layout.gridX + target.x * this.layout.tileSize, this.layout.gridY + target.y * this.layout.tileSize, @@ -857,25 +1157,58 @@ export class BattleScene extends Phaser.Scene { marker.on('pointerout', () => marker.setFillStyle(0xc93b30, 0.3)); marker.on('pointerdown', (pointer: Phaser.Input.Pointer) => { if (pointer.leftButtonDown()) { - void this.tryResolveDamageTarget(attacker, target, action); + void this.tryResolveDamageTarget(attacker, target, action, usable); } }); this.markers.push(marker); }); } - private async tryResolveDamageTarget(attacker: UnitData, target: UnitData, action: DamageCommand) { + private showSupportTargets(user: UnitData, usable: BattleUsable, targets = this.supportTargets(user, usable)) { + targets.forEach((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, + usable.effect === 'heal' ? 0x45b875 : 0x4f86d9, + 0.3 + ); + marker.setOrigin(0); + marker.setStrokeStyle(2, usable.effect === 'heal' ? 0xb6ffd2 : 0xd9e8ff, 0.9); + marker.setDepth(9); + marker.setInteractive({ useHandCursor: true }); + marker.on('pointerover', () => { + marker.setFillStyle(usable.effect === 'heal' ? 0x45b875 : 0x4f86d9, 0.44); + this.renderSupportPreview(user, target, usable); + }); + marker.on('pointerout', () => marker.setFillStyle(usable.effect === 'heal' ? 0x45b875 : 0x4f86d9, 0.3)); + marker.on('pointerdown', (pointer: Phaser.Input.Pointer) => { + if (pointer.leftButtonDown()) { + void this.tryResolveSupportTarget(user, target, usable); + } + }); + this.markers.push(marker); + }); + } + + private async tryResolveDamageTarget(attacker: UnitData, target: UnitData, action: DamageCommand, usable?: BattleUsable) { if (this.phase !== 'targeting' || this.selectedUnit?.id !== attacker.id) { return; } - if (!this.canUseDamageCommand(attacker, target, action)) { + if (!this.canUseDamageCommand(attacker, target, action, usable)) { this.renderUnitDetail(attacker, '사거리 밖의 적입니다. 붉은 칸으로 표시된 적을 선택하세요.'); return; } + if (usable?.command === 'item' && !this.consumeItem(attacker.id, usable.id)) { + this.showUnavailableCommandAlert('item'); + return; + } + this.phase = 'animating'; - const result = this.resolveCombatAction(attacker, target, action); + const result = this.resolveCombatAction(attacker, target, action, false, usable); this.clearMarkers(); await this.playCombatCutIn(result); result.counter = this.resolveCounterAttack(result); @@ -886,6 +1219,28 @@ export class BattleScene extends Phaser.Scene { this.finishUnitAction(attacker, this.formatCombatResult(result)); } + private async tryResolveSupportTarget(user: UnitData, target: UnitData, usable: BattleUsable) { + if (this.phase !== 'targeting' || this.selectedUnit?.id !== user.id) { + return; + } + + if (!this.canUseSupportCommand(user, target, usable)) { + this.renderUnitDetail(user, '사거리 밖의 대상입니다. 푸른 칸으로 표시된 대상을 선택하세요.'); + return; + } + + if (usable.command === 'item' && !this.consumeItem(user.id, usable.id)) { + this.showUnavailableCommandAlert('item'); + return; + } + + this.phase = 'animating'; + const result = this.resolveSupportAction(user, target, usable); + this.clearMarkers(); + await this.playSupportCutIn(result); + this.finishUnitAction(user, this.formatSupportResult(result)); + } + private renderAttackPreview(preview: CombatPreview) { const counter = preview.counterAvailable ? ' / 반격 가능' : ''; this.renderUnitDetail( @@ -894,12 +1249,26 @@ export class BattleScene extends Phaser.Scene { ); } + private renderSupportPreview(user: UnitData, target: UnitData, usable: BattleUsable) { + if (usable.effect === 'heal') { + const amount = this.supportHealAmount(user, target, usable); + this.renderUnitDetail(target, `${user.name} ${usable.name} 예측\n병력 +${amount} / ${target.hp} -> ${Math.min(target.maxHp, target.hp + amount)}`); + return; + } + + this.renderUnitDetail( + target, + `${user.name} ${usable.name} 예측\n공격 +${usable.attackBonus ?? 0} / 명중 +${usable.hitBonus ?? 0} / 치명 +${usable.criticalBonus ?? 0}\n${usable.duration ?? 1}턴 동안 유지` + ); + } + private finishUnitAction(unit: UnitData, message: string) { this.actedUnitIds.add(unit.id); this.phase = 'idle'; this.selectedUnit = undefined; this.pendingMove = undefined; this.targetingAction = undefined; + this.selectedUsable = undefined; this.hideCommandMenu(); this.hideTurnEndPrompt(); this.applyActedStyle(unit); @@ -952,6 +1321,7 @@ export class BattleScene extends Phaser.Scene { this.selectedUnit = undefined; this.pendingMove = undefined; this.targetingAction = undefined; + this.selectedUsable = undefined; this.clearMarkers(); this.hideCommandMenu(); this.hideMapMenu(); @@ -1209,44 +1579,75 @@ export class BattleScene extends Phaser.Scene { return this.damageableTargets(attacker, 'attack'); } - private damageableTargets(attacker: UnitData, action: DamageCommand) { - return firstBattleUnits.filter((target) => this.canUseDamageCommand(attacker, target, action)); + private damageableTargets(attacker: UnitData, action: DamageCommand, usable?: BattleUsable) { + return firstBattleUnits.filter((target) => this.canUseDamageCommand(attacker, target, action, usable)); } private canAttack(attacker: UnitData, target: UnitData) { return this.canUseDamageCommand(attacker, target, 'attack'); } - private canUseDamageCommand(attacker: UnitData, target: UnitData, action: DamageCommand) { + private canUseDamageCommand(attacker: UnitData, target: UnitData, action: DamageCommand, usable?: BattleUsable) { if (attacker.faction === target.faction || target.hp <= 0) { return false; } - return this.tileDistance(attacker, target) <= this.actionRange(attacker, action); + return this.tileDistance(attacker, target) <= this.actionRange(attacker, action, usable); } private attackRange(unit: UnitData) { return attackRangeByClass[unit.classKey] ?? 1; } - private actionRange(unit: UnitData, action: DamageCommand) { + private actionRange(unit: UnitData, action: DamageCommand, usable?: BattleUsable) { + if (usable) { + return usable.range; + } if (action === 'attack') { return this.attackRange(unit); } return unit.classKey === 'archer' ? 3 : 2; } - private combatPreview(attacker: UnitData, defender: UnitData, action: DamageCommand): CombatPreview { + private supportTargets(user: UnitData, usable: BattleUsable) { + return firstBattleUnits.filter((target) => this.canUseSupportCommand(user, target, usable)); + } + + private canUseSupportCommand(user: UnitData, target: UnitData, usable: BattleUsable) { + if (target.hp <= 0) { + return false; + } + if (usable.target === 'self' && target.id !== user.id) { + return false; + } + if (usable.target === 'ally' && target.faction !== user.faction) { + return false; + } + if (usable.target === 'enemy' && target.faction === user.faction) { + return false; + } + if (usable.effect === 'heal' && target.hp >= target.maxHp) { + return false; + } + return this.tileDistance(user, target) <= usable.range; + } + + private combatPreview(attacker: UnitData, defender: UnitData, action: DamageCommand, usable?: BattleUsable): CombatPreview { const terrain = firstBattleMap.terrain[defender.y][defender.x]; const terrainRule = getTerrainRule(terrain); - const attackPower = this.actionPower(attacker, action); + const attackPower = this.actionPower(attacker, action, usable); const defensePower = this.actionDefense(defender, action, terrainRule.defenseBonus); 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 defenderTerrainRating = getUnitClass(defender.classKey).terrainRatings[terrain] ?? 100; const terrainHitPenalty = Math.floor(terrainRule.defenseBonus / 2) + Math.max(0, Math.floor((defenderTerrainRating - 100) / 4)); const hitRate = Phaser.Math.Clamp( - 88 + Math.floor((attacker.stats.agility - defender.stats.agility) / 3) + Math.floor((attacker.stats.luck - defender.stats.luck) / 8) - terrainHitPenalty, + 88 + + Math.floor((attacker.stats.agility - defender.stats.agility) / 3) + + Math.floor((attacker.stats.luck - defender.stats.luck) / 8) - + terrainHitPenalty + + (usable?.accuracyBonus ?? 0) + + this.hitBuff(attacker), 45, 98 ); @@ -1254,13 +1655,16 @@ export class BattleScene extends Phaser.Scene { 5 + Math.floor((attacker.stats.luck - defender.stats.luck) / 7) + Math.floor((attacker.stats.might - defender.stats.leadership) / 12) + - (action === 'attack' && getItem(attacker.equipment.weapon.itemId).rank === 'treasure' ? 2 : 0), + (action === 'attack' && getItem(attacker.equipment.weapon.itemId).rank === 'treasure' ? 2 : 0) + + (usable?.criticalBonus ?? 0) + + this.criticalBuff(attacker), 2, 28 ); return { action, + usable, attacker, defender, damage, @@ -1275,8 +1679,8 @@ export class BattleScene extends Phaser.Scene { return this.resolveCombatAction(attacker, defender, 'attack'); } - private resolveCombatAction(attacker: UnitData, defender: UnitData, action: DamageCommand, isCounter = false): CombatResult { - const preview = this.combatPreview(attacker, defender, action); + private resolveCombatAction(attacker: UnitData, defender: UnitData, action: DamageCommand, isCounter = false, usable?: BattleUsable): CombatResult { + const preview = this.combatPreview(attacker, defender, action, usable); const previousDefenderHp = defender.hp; const hit = this.rollPercent(preview.hitRate); const critical = hit && this.rollPercent(preview.criticalRate); @@ -1343,14 +1747,63 @@ export class BattleScene extends Phaser.Scene { return Phaser.Math.Between(1, 100) <= Phaser.Math.Clamp(rate, 0, 100); } - private actionPower(unit: UnitData, action: DamageCommand) { + private resolveSupportAction(user: UnitData, target: UnitData, usable: BattleUsable): SupportResult { + const previousTargetHp = target.hp; + const healAmount = usable.effect === 'heal' ? this.supportHealAmount(user, target, usable) : 0; + let buff: BattleBuffState | undefined; + + if (healAmount > 0) { + target.hp = Math.min(target.maxHp, target.hp + healAmount); + this.syncUnitMotion(target); + this.flashSupport(target, `+${healAmount}`, '#a8ffd0'); + } + + if (usable.effect === 'focus') { + buff = { + unitId: target.id, + label: usable.name, + turns: usable.duration ?? 1, + attackBonus: usable.attackBonus ?? 0, + hitBonus: usable.hitBonus ?? 0, + criticalBonus: usable.criticalBonus ?? 0 + }; + this.battleBuffs.set(target.id, buff); + this.flashSupport(target, usable.name, '#ffdf7b'); + } + + const equipmentGrowth = this.awardEquipmentExp(user, 'accessory', usable.command === 'strategy' ? 7 : 5); + const characterGrowth = this.awardCharacterExp(user, usable.effect === 'heal' ? 8 : 6); + + return { + usable, + user, + target, + previousTargetHp, + healAmount, + buff, + characterGrowth, + equipmentGrowth + }; + } + + private supportHealAmount(user: UnitData, target: UnitData, usable: BattleUsable) { + const bonus = usable.command === 'strategy' ? Math.floor(user.stats.intelligence / 12) : Math.floor(user.stats.luck / 14); + return Math.min(target.maxHp - target.hp, Math.max(1, usable.power + bonus)); + } + + private actionPower(unit: UnitData, action: DamageCommand, usable?: BattleUsable) { + if (usable?.effect === 'damage') { + const statBonus = usable.command === 'strategy' ? Math.floor(unit.stats.intelligence / 5) : Math.floor(unit.stats.luck / 8); + const equipmentBonus = usable.command === 'strategy' ? this.equipmentStrategyBonus(unit) : Math.floor(this.equipmentAttackBonus(unit) / 2); + return usable.power + statBonus + equipmentBonus + this.attackBuff(unit); + } if (action === 'strategy') { - return 10 + Math.floor(unit.stats.intelligence / 5) + this.equipmentStrategyBonus(unit); + return 10 + Math.floor(unit.stats.intelligence / 5) + this.equipmentStrategyBonus(unit) + this.attackBuff(unit); } if (action === 'item') { - return 9 + Math.floor(unit.stats.luck / 8) + Math.floor(this.equipmentAttackBonus(unit) / 2); + return 9 + Math.floor(unit.stats.luck / 8) + Math.floor(this.equipmentAttackBonus(unit) / 2) + this.attackBuff(unit); } - return unit.attack + this.equipmentAttackBonus(unit) + Math.floor(unit.stats.might / 12); + return unit.attack + this.equipmentAttackBonus(unit) + Math.floor(unit.stats.might / 12) + this.attackBuff(unit); } private actionDefense(unit: UnitData, action: DamageCommand, terrainDefenseBonus: number) { @@ -1387,7 +1840,7 @@ export class BattleScene extends Phaser.Scene { 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}` : ''; const counter = result.counter ? `\n${this.formatCounterLine(result.counter)}` : ''; - return `${result.attacker.name} ${commandLabels[result.action]}\n${outcome}${defeated}\n장수 경험치 +${result.characterGrowth.amount}\n${this.formatEquipmentGrowth(result.attackerGrowth)}\n${this.formatEquipmentGrowth(result.defenderGrowth)}${bond}${counter}`; + return `${result.attacker.name} ${result.usable?.name ?? commandLabels[result.action]}\n${outcome}${defeated}\n장수 경험치 +${result.characterGrowth.amount}\n${this.formatEquipmentGrowth(result.attackerGrowth)}\n${this.formatEquipmentGrowth(result.defenderGrowth)}${bond}${counter}`; } private formatCombatOutcome(result: CombatResult) { @@ -1422,6 +1875,14 @@ export class BattleScene extends Phaser.Scene { return lines; } + private formatSupportResult(result: SupportResult) { + const effect = + result.usable.effect === 'heal' + ? `${result.target.name} 병력 +${result.healAmount} (${result.target.hp}/${result.target.maxHp})` + : `${result.target.name} 강화: 공격 +${result.buff?.attackBonus ?? 0} / 명중 +${result.buff?.hitBonus ?? 0} / 치명 +${result.buff?.criticalBonus ?? 0}`; + return `${result.user.name} ${result.usable.name}\n${effect}\n장수 경험치 +${result.characterGrowth.amount}\n${this.formatEquipmentGrowth(result.equipmentGrowth)}`; + } + private commandResultMessage(unit: UnitData, command: BattleCommand) { if (command === 'attack') { return this.recordAttackIntent(unit); @@ -1451,6 +1912,15 @@ export class BattleScene extends Phaser.Scene { ); } + private createItemStocks() { + return new Map( + Object.entries(initialItemStocks).map(([unitId, stocks]) => [ + unitId, + new Map(Object.entries(stocks)) + ]) + ); + } + private createEnemyHomeTiles() { return new Map( firstBattleUnits @@ -1501,6 +1971,7 @@ export class BattleScene extends Phaser.Scene { this.selectedUnit = undefined; this.pendingMove = undefined; this.targetingAction = undefined; + this.selectedUsable = undefined; this.clearMarkers(); this.hideCommandMenu(); this.hideTurnEndPrompt(); @@ -1697,7 +2168,9 @@ export class BattleScene extends Phaser.Scene { bonds: Array.from(this.bondStates.values()).map((bond) => ({ ...bond, unitIds: [...bond.unitIds] as [string, string] - })) + })), + itemStocks: this.serializeItemStocks(), + battleBuffs: Array.from(this.battleBuffs.values()).map((buff) => ({ ...buff })) }; } @@ -1729,6 +2202,8 @@ export class BattleScene extends Phaser.Scene { } ]) ); + this.itemStocks = this.deserializeItemStocks(state.itemStocks); + this.battleBuffs = new Map((state.battleBuffs ?? []).map((buff) => [buff.unitId, { ...buff }])); state.units.forEach((savedUnit) => { const unit = firstBattleUnits.find((candidate) => candidate.id === savedUnit.id); @@ -1772,6 +2247,25 @@ export class BattleScene extends Phaser.Scene { return JSON.parse(JSON.stringify(equipment)) as UnitData['equipment']; } + private serializeItemStocks() { + return Object.fromEntries( + Array.from(this.itemStocks.entries()).map(([unitId, stocks]) => [unitId, Object.fromEntries(stocks.entries())]) + ); + } + + private deserializeItemStocks(savedStocks?: Record>) { + if (!savedStocks) { + return this.createItemStocks(); + } + + return new Map( + Object.entries(savedStocks).map(([unitId, stocks]) => [ + unitId, + new Map(Object.entries(stocks).map(([itemId, count]) => [itemId, Math.max(0, count)])) + ]) + ); + } + private formatSavedAt(savedAt: string) { return new Date(savedAt).toLocaleString('ko-KR'); } @@ -1974,7 +2468,7 @@ export class BattleScene extends Phaser.Scene { stage.fillStyle(0x23472c, 1); stage.fillRect(left + 18, top + 82, panelWidth - 36, 18); - const titleText = result.isCounter ? '반격' : commandLabels[result.action]; + const titleText = result.isCounter ? '반격' : result.usable?.name ?? commandLabels[result.action]; const title = this.trackCombatObject(this.add.text(left + panelWidth / 2, top + 24, titleText, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '24px', @@ -2018,17 +2512,17 @@ export class BattleScene extends Phaser.Scene { await this.animateCombatGauge(defenderStatus.hpFill, result.previousDefenderHp / result.defender.maxHp, result.defender.hp / result.defender.maxHp, 420); defenderStatus.hpText.setText(`${result.defender.hp} / ${result.defender.maxHp}`); - if (result.characterGrowth.amount > 0) { - soundDirector.playEffect('exp-gain', { volume: result.characterGrowth.leveled ? 0.44 : 0.28, stopAfterMs: 1000 }); - } - - await this.animateCombatGauge( + await this.animateGrowthGauge( attackerStatus.expFill, + attackerStatus.expText, result.characterGrowth.previousExp / result.characterGrowth.next, result.characterGrowth.exp / result.characterGrowth.next, - 420 + result.characterGrowth.previousExp, + result.characterGrowth.exp, + result.characterGrowth.next, + result.characterGrowth.leveled, + 680 ); - attackerStatus.expText.setText(`${result.characterGrowth.exp} / ${result.characterGrowth.next}`); const resultLabel = result.hit ? `${result.critical ? '치명타! ' : ''}${result.damage} 피해` : '회피!'; const resultText = this.trackCombatObject(this.add.text(left + panelWidth / 2, top + 252, resultLabel, { @@ -2057,6 +2551,92 @@ export class BattleScene extends Phaser.Scene { this.hideCombatCutIn(); } + private async playSupportCutIn(result: SupportResult) { + this.hideCombatCutIn(); + + const panelWidth = 760; + const panelHeight = 330; + const left = Math.floor((this.scale.width - panelWidth) / 2); + const top = Math.floor((this.scale.height - panelHeight) / 2); + const depth = 70; + + const dim = this.trackCombatObject(this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x020406, 0.58)); + dim.setOrigin(0); + dim.setDepth(depth); + + const panel = this.trackCombatObject(this.add.rectangle(left, top, panelWidth, panelHeight, 0x101821, 0.98)); + panel.setOrigin(0); + panel.setDepth(depth + 1); + panel.setStrokeStyle(3, result.usable.command === 'strategy' ? palette.blue : palette.gold, 0.9); + + const title = this.trackCombatObject(this.add.text(left + panelWidth / 2, top + 24, result.usable.name, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '26px', + color: '#f4dfad', + fontStyle: '700', + stroke: '#05070a', + strokeThickness: 4 + })); + title.setOrigin(0.5, 0); + title.setDepth(depth + 2); + + const userSprite = this.trackCombatObject(this.add.sprite(left + 160, top + 146, unitTexture[result.user.id] ?? 'unit-rebel', this.unitFrameIndex('east'))); + userSprite.setDepth(depth + 3); + userSprite.setDisplaySize(112, 112); + + const targetSprite = this.trackCombatObject(this.add.sprite(left + panelWidth - 160, top + 146, unitTexture[result.target.id] ?? 'unit-rebel', this.unitFrameIndex('west'))); + targetSprite.setDepth(depth + 3); + targetSprite.setDisplaySize(112, 112); + + const effectColor = result.usable.effect === 'heal' ? 0x59d18c : 0xd8b15f; + const effect = this.trackCombatObject(this.add.circle(left + panelWidth / 2, top + 145, 30, effectColor, 0.76)); + effect.setDepth(depth + 4); + this.tweens.add({ targets: effect, scale: 1.45, alpha: 0.18, duration: 520, yoyo: true }); + soundDirector.playEffect(result.usable.command === 'strategy' ? 'strategy-cast' : 'exp-gain', { + volume: result.usable.command === 'strategy' ? 0.38 : 0.28, + stopAfterMs: 900 + }); + + const targetStatus = this.renderCombatStatusPanel(result.target, left + panelWidth / 2 - 196, top + 190, 392); + targetStatus.hpFill.setScale(result.previousTargetHp / result.target.maxHp, 1); + targetStatus.hpText.setText(`${result.previousTargetHp} / ${result.target.maxHp}`); + + await this.delay(250); + await this.animateCombatGauge(targetStatus.hpFill, result.previousTargetHp / result.target.maxHp, result.target.hp / result.target.maxHp, 480); + targetStatus.hpText.setText(`${result.target.hp} / ${result.target.maxHp}`); + + const resultLine = + result.usable.effect === 'heal' + ? `${result.target.name} 병력 +${result.healAmount}` + : `${result.target.name} ${result.usable.name}: 공격 +${result.buff?.attackBonus ?? 0} / 명중 +${result.buff?.hitBonus ?? 0} / 치명 +${result.buff?.criticalBonus ?? 0}`; + const resultText = this.trackCombatObject(this.add.text(left + panelWidth / 2, top + 106, resultLine, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '19px', + color: result.usable.effect === 'heal' ? '#a8ffd0' : '#ffdf7b', + fontStyle: '700', + stroke: '#05070a', + strokeThickness: 4 + })); + resultText.setOrigin(0.5); + resultText.setDepth(depth + 5); + + const growth = this.trackCombatObject(this.add.text(left + panelWidth / 2, top + 292, [ + `장수 경험치 +${result.characterGrowth.amount} (${result.characterGrowth.exp}/${result.characterGrowth.next})`, + this.formatEquipmentGrowth(result.equipmentGrowth) + ].join(' '), { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '14px', + color: '#d4dce6', + fontStyle: '700', + align: 'center' + })); + growth.setOrigin(0.5); + growth.setDepth(depth + 5); + + await this.delay(760); + this.hideCombatCutIn(); + } + private renderCombatStatusPanel(unit: UnitData, x: number, y: number, width: number, growth?: CharacterGrowthResult) { const panel = this.trackCombatObject(this.add.rectangle(x, y, width, 116, 0x101820, 0.96)); panel.setOrigin(0); @@ -2275,6 +2855,53 @@ export class BattleScene extends Phaser.Scene { }); } + private animateGrowthGauge( + target: Phaser.GameObjects.Rectangle, + text: Phaser.GameObjects.Text, + fromRatio: number, + toRatio: number, + fromValue: number, + toValue: number, + next: number, + leveled: boolean, + duration: number + ) { + target.setScale(Phaser.Math.Clamp(fromRatio, 0, 1), 1); + text.setText(`${fromValue} / ${next}`); + if (toValue !== fromValue || leveled) { + soundDirector.playEffect('exp-gain', { volume: leveled ? 0.44 : 0.3, stopAfterMs: duration + 220 }); + } + + return new Promise((resolve) => { + const tracker = { ratio: Phaser.Math.Clamp(fromRatio, 0, 1), value: fromValue }; + this.tweens.add({ + targets: tracker, + ratio: Phaser.Math.Clamp(toRatio, 0, 1), + value: toValue, + duration, + ease: 'Sine.easeInOut', + onUpdate: () => { + target.setScale(tracker.ratio, 1); + text.setText(`${Math.round(tracker.value)} / ${next}`); + }, + onComplete: () => { + target.setScale(Phaser.Math.Clamp(toRatio, 0, 1), 1); + text.setText(`${toValue} / ${next}`); + if (leveled) { + this.tweens.add({ + targets: text, + scale: 1.18, + duration: 120, + yoyo: true, + ease: 'Sine.easeOut' + }); + } + resolve(); + } + }); + }); + } + private trackCombatObject(object: T) { this.combatCutInObjects.push(object); return object; @@ -2341,6 +2968,7 @@ export class BattleScene extends Phaser.Scene { } this.turnNumber += 1; + this.tickBattleBuffs(); this.activeFaction = 'ally'; this.actedUnitIds.clear(); this.attackIntents = []; @@ -2555,6 +3183,27 @@ export class BattleScene extends Phaser.Scene { }; } + private attackBuff(unit: UnitData) { + return this.battleBuffs.get(unit.id)?.attackBonus ?? 0; + } + + private hitBuff(unit: UnitData) { + return this.battleBuffs.get(unit.id)?.hitBonus ?? 0; + } + + private criticalBuff(unit: UnitData) { + return this.battleBuffs.get(unit.id)?.criticalBonus ?? 0; + } + + private tickBattleBuffs() { + this.battleBuffs.forEach((buff, unitId) => { + buff.turns -= 1; + if (buff.turns <= 0) { + this.battleBuffs.delete(unitId); + } + }); + } + private cancelAttackTargeting() { if (this.phase !== 'targeting' || !this.selectedUnit) { return false; @@ -2564,10 +3213,11 @@ export class BattleScene extends Phaser.Scene { const view = this.unitViews.get(unit.id); this.phase = 'command'; this.targetingAction = undefined; + this.selectedUsable = undefined; this.clearMarkers(); soundDirector.playSelect(); this.showCommandMenu(unit, view?.sprite.x ?? this.tileCenterX(unit.x), view?.sprite.y ?? this.tileCenterY(unit.y)); - this.renderUnitDetail(unit, '공격 선택을 취소했습니다. 명령을 다시 선택하세요.'); + this.renderUnitDetail(unit, '대상 선택을 취소했습니다. 명령을 다시 선택하세요.'); return true; } @@ -2805,6 +3455,41 @@ export class BattleScene extends Phaser.Scene { }); } + private flashSupport(unit: UnitData, label: string, color: string) { + 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, label, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '20px', + color, + stroke: '#071623', + strokeThickness: 4, + fontStyle: '700' + }); + popup.setOrigin(0.5); + popup.setDepth(30); + + this.tweens.add({ + targets: view.sprite, + scale: view.sprite.scale * 1.08, + duration: 120, + yoyo: true, + ease: 'Sine.easeOut' + }); + + this.tweens.add({ + targets: popup, + y: popup.y - 24, + alpha: 0, + duration: 560, + ease: 'Sine.easeOut', + onComplete: () => popup.destroy() + }); + } + private showTurnEndPrompt(message?: string) { this.hideTurnEndPrompt(); @@ -3422,6 +4107,9 @@ export class BattleScene extends Phaser.Scene { actedUnitIds: Array.from(this.actedUnitIds), attackIntents: this.attackIntents.map((intent) => ({ ...intent })), battleLog: [...this.battleLog], + selectedUsable: this.selectedUsable?.id ?? null, + itemStocks: this.serializeItemStocks(), + battleBuffs: Array.from(this.battleBuffs.values()).map((buff) => ({ ...buff })), units: firstBattleUnits.map((unit) => ({ id: unit.id, name: unit.name,