diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts index 9592f47..6ce848e 100644 --- a/src/game/data/scenario.ts +++ b/src/game/data/scenario.ts @@ -19,6 +19,8 @@ export type UnitData = { faction: 'ally' | 'enemy'; className: string; classKey: UnitClassKey; + level: number; + exp: number; hp: number; maxHp: number; attack: number; @@ -165,6 +167,8 @@ export const firstBattleUnits: UnitData[] = [ faction: 'ally', className: '군웅', classKey: 'lord', + level: 3, + exp: 18, hp: 32, maxHp: 32, attack: 9, @@ -184,6 +188,8 @@ export const firstBattleUnits: UnitData[] = [ faction: 'ally', className: '의용 보병', classKey: 'infantry', + level: 3, + exp: 26, hp: 30, maxHp: 30, attack: 10, @@ -203,6 +209,8 @@ export const firstBattleUnits: UnitData[] = [ faction: 'ally', className: '창병', classKey: 'spearman', + level: 3, + exp: 20, hp: 30, maxHp: 30, attack: 10, @@ -222,6 +230,8 @@ export const firstBattleUnits: UnitData[] = [ faction: 'enemy', className: '황건병', classKey: 'yellowTurban', + level: 1, + exp: 6, hp: 20, maxHp: 20, attack: 6, @@ -241,6 +251,8 @@ export const firstBattleUnits: UnitData[] = [ faction: 'enemy', className: '황건병', classKey: 'yellowTurban', + level: 1, + exp: 4, hp: 20, maxHp: 20, attack: 6, @@ -260,6 +272,8 @@ export const firstBattleUnits: UnitData[] = [ faction: 'enemy', className: '궁병', classKey: 'archer', + level: 1, + exp: 10, hp: 18, maxHp: 18, attack: 7, @@ -279,6 +293,8 @@ export const firstBattleUnits: UnitData[] = [ faction: 'enemy', className: '두령', classKey: 'rebelLeader', + level: 2, + exp: 18, hp: 30, maxHp: 30, attack: 9, diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 714c803..dbad807 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -59,8 +59,9 @@ type UnitView = { type UnitDirection = 'south' | 'east' | 'north' | 'west'; -type BattlePhase = 'idle' | 'moving' | 'command' | 'targeting'; +type BattlePhase = 'idle' | 'moving' | 'command' | 'targeting' | 'animating'; type BattleCommand = 'attack' | 'strategy' | 'item' | 'wait'; +type DamageCommand = Exclude; type RosterTab = 'ally' | 'enemy'; type ActiveFaction = 'ally' | 'enemy'; type MapMenuAction = 'endTurn' | 'roster' | 'bond' | 'situation' | 'bgm' | 'close'; @@ -93,6 +94,18 @@ type EquipmentGrowthResult = { slot: EquipmentSlot; itemName: string; amount: number; + previousLevel: number; + previousExp: number; + level: number; + exp: number; + next: number; + leveled: boolean; +}; + +type CharacterGrowthResult = { + amount: number; + previousLevel: number; + previousExp: number; level: number; exp: number; next: number; @@ -100,6 +113,7 @@ type EquipmentGrowthResult = { }; type CombatPreview = { + action: DamageCommand; attacker: UnitData; defender: UnitData; damage: number; @@ -108,13 +122,16 @@ type CombatPreview = { }; type CombatResult = CombatPreview & { + previousDefenderHp: number; defeated: boolean; - weaponGrowth: EquipmentGrowthResult; - armorGrowth: EquipmentGrowthResult; + characterGrowth: CharacterGrowthResult; + attackerGrowth: EquipmentGrowthResult; + defenderGrowth: EquipmentGrowthResult; bondExp: number; }; const maxEquipmentLevel = 9; +const maxCharacterLevel = 99; const attackRangeByClass: Partial> = { archer: 2 @@ -176,6 +193,7 @@ export class BattleScene extends Phaser.Scene { private turnNumber = 1; private activeFaction: ActiveFaction = 'ally'; private selectedUnit?: UnitData; + private targetingAction?: DamageCommand; private turnText?: Phaser.GameObjects.Text; private markers: Phaser.GameObjects.Rectangle[] = []; private rosterTab: RosterTab = 'ally'; @@ -184,6 +202,7 @@ export class BattleScene extends Phaser.Scene { private commandMenuObjects: Array = []; private mapMenuObjects: Array = []; private turnPromptObjects: Phaser.GameObjects.GameObject[] = []; + private combatCutInObjects: Phaser.GameObjects.GameObject[] = []; private unitViews = new Map(); private actedUnitIds = new Set(); private bondStates = new Map(); @@ -360,13 +379,17 @@ export class BattleScene extends Phaser.Scene { this.hideMapMenu(); this.hideTurnEndPrompt(); + if (this.phase === 'animating') { + return; + } + if (this.phase === 'targeting' && this.selectedUnit) { - if (unit.faction !== this.selectedUnit.faction && unit.hp > 0) { - this.tryResolveAttackTarget(this.selectedUnit, unit); + if (unit.faction !== this.selectedUnit.faction && unit.hp > 0 && this.targetingAction) { + void this.tryResolveDamageTarget(this.selectedUnit, unit, this.targetingAction); return; } - this.renderUnitDetail(this.selectedUnit, '공격할 적 부대를 선택하세요. 우클릭하면 명령 선택으로 돌아갑니다.'); + this.renderUnitDetail(this.selectedUnit, '대상으로 삼을 적 부대를 선택하세요. 우클릭하면 명령 선택으로 돌아갑니다.'); return; } @@ -594,8 +617,8 @@ export class BattleScene extends Phaser.Scene { return; } if (choicePointer.leftButtonDown()) { - if (command === 'attack') { - this.beginAttackTargeting(unit); + if (command !== 'wait') { + this.beginDamageTargeting(unit, command); return; } @@ -640,44 +663,31 @@ export class BattleScene extends Phaser.Scene { } this.finishUnitAction(unit, this.commandResultMessage(unit, command)); - return; - - this.actedUnitIds.add(unit.id); - this.phase = 'idle'; - this.selectedUnit = undefined; - this.pendingMove = undefined; - this.hideCommandMenu(); - this.applyActedStyle(unit); - soundDirector.playSelect(); - - const actionMessage = this.commandResultMessage(unit, command); - const remaining = this.remainingAllyCount(); - const turnHint = remaining > 0 ? `${remaining}명의 아군이 아직 행동할 수 있습니다.` : '모든 아군이 행동했습니다. 빈 맵 우클릭 메뉴에서 턴 종료를 선택하세요.'; - this.renderRosterPanel('ally', `${actionMessage}\n${turnHint}`); } - private beginAttackTargeting(unit: UnitData) { + private beginDamageTargeting(unit: UnitData, action: DamageCommand) { if (this.phase !== 'command' || this.selectedUnit?.id !== unit.id || this.actedUnitIds.has(unit.id)) { return; } - const targets = this.attackableTargets(unit); + const targets = this.damageableTargets(unit, action); if (targets.length === 0) { - this.renderUnitDetail(unit, '현재 위치에서 공격 가능한 적이 없습니다. 다른 명령을 선택하거나 우클릭으로 이동을 취소하세요.'); + this.renderUnitDetail(unit, `현재 위치에서 ${commandLabels[action]} 가능한 적이 없습니다. 다른 명령을 선택하거나 우클릭으로 이동을 취소하세요.`); return; } this.phase = 'targeting'; + this.targetingAction = action; this.hideCommandMenu(); this.clearMarkers(); - this.renderUnitDetail(unit, '붉은 칸의 적을 선택하면 공격합니다. 우클릭하면 명령 선택으로 돌아갑니다.'); - this.showAttackTargets(unit, targets); + this.renderUnitDetail(unit, `붉은 칸의 적을 선택하면 ${commandLabels[action]}합니다. 우클릭하면 명령 선택으로 돌아갑니다.`); + this.showDamageTargets(unit, action, targets); soundDirector.playSelect(); } - private showAttackTargets(attacker: UnitData, targets = this.attackableTargets(attacker)) { + private showDamageTargets(attacker: UnitData, action: DamageCommand, targets = this.damageableTargets(attacker, action)) { targets.forEach((target) => { - const preview = this.combatPreview(attacker, target); + const preview = this.combatPreview(attacker, target, action); const marker = this.add.rectangle( this.layout.gridX + target.x * this.layout.tileSize, this.layout.gridY + target.y * this.layout.tileSize, @@ -697,32 +707,34 @@ export class BattleScene extends Phaser.Scene { marker.on('pointerout', () => marker.setFillStyle(0xc93b30, 0.3)); marker.on('pointerdown', (pointer: Phaser.Input.Pointer) => { if (pointer.leftButtonDown()) { - this.tryResolveAttackTarget(attacker, target); + void this.tryResolveDamageTarget(attacker, target, action); } }); this.markers.push(marker); }); } - private tryResolveAttackTarget(attacker: UnitData, target: UnitData) { + private async tryResolveDamageTarget(attacker: UnitData, target: UnitData, action: DamageCommand) { if (this.phase !== 'targeting' || this.selectedUnit?.id !== attacker.id) { return; } - if (!this.canAttack(attacker, target)) { + if (!this.canUseDamageCommand(attacker, target, action)) { this.renderUnitDetail(attacker, '사거리 밖의 적입니다. 붉은 칸으로 표시된 적을 선택하세요.'); return; } - const result = this.resolveAttack(attacker, target); + this.phase = 'animating'; + const result = this.resolveCombatAction(attacker, target, action); this.clearMarkers(); + await this.playCombatCutIn(result); this.finishUnitAction(attacker, this.formatCombatResult(result)); } private renderAttackPreview(preview: CombatPreview) { this.renderUnitDetail( preview.defender, - `${preview.attacker.name} 공격 예측\n피해 ${preview.damage} / 명중 ${preview.hitRate}% / 지형 ${preview.terrainLabel}` + `${preview.attacker.name} ${commandLabels[preview.action]} 예측\n피해 ${preview.damage} / 명중 ${preview.hitRate}% / 지형 ${preview.terrainLabel}` ); } @@ -731,6 +743,7 @@ export class BattleScene extends Phaser.Scene { this.phase = 'idle'; this.selectedUnit = undefined; this.pendingMove = undefined; + this.targetingAction = undefined; this.hideCommandMenu(); this.hideTurnEndPrompt(); this.applyActedStyle(unit); @@ -750,31 +763,47 @@ export class BattleScene extends Phaser.Scene { } private attackableTargets(attacker: UnitData) { - return firstBattleUnits.filter((target) => this.canAttack(attacker, target)); + return this.damageableTargets(attacker, 'attack'); + } + + private damageableTargets(attacker: UnitData, action: DamageCommand) { + return firstBattleUnits.filter((target) => this.canUseDamageCommand(attacker, target, action)); } private canAttack(attacker: UnitData, target: UnitData) { + return this.canUseDamageCommand(attacker, target, 'attack'); + } + + private canUseDamageCommand(attacker: UnitData, target: UnitData, action: DamageCommand) { if (attacker.faction === target.faction || target.hp <= 0) { return false; } - return this.tileDistance(attacker, target) <= this.attackRange(attacker); + return this.tileDistance(attacker, target) <= this.actionRange(attacker, action); } private attackRange(unit: UnitData) { return attackRangeByClass[unit.classKey] ?? 1; } - private combatPreview(attacker: UnitData, defender: UnitData): CombatPreview { + private actionRange(unit: UnitData, action: DamageCommand) { + if (action === 'attack') { + return this.attackRange(unit); + } + return unit.classKey === 'archer' ? 3 : 2; + } + + private combatPreview(attacker: UnitData, defender: UnitData, action: DamageCommand): 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 attackPower = this.actionPower(attacker, action); + 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 hitRate = Phaser.Math.Clamp(84 + Math.floor((attacker.stats.agility - defender.stats.agility) / 4), 65, 98); return { + action, attacker, defender, damage, @@ -784,9 +813,15 @@ export class BattleScene extends Phaser.Scene { } private resolveAttack(attacker: UnitData, defender: UnitData): CombatResult { - const preview = this.combatPreview(attacker, defender); - const weaponGrowth = this.awardEquipmentExp(attacker, 'weapon', this.weaponExpGain(attacker)); + return this.resolveCombatAction(attacker, defender, 'attack'); + } + + private resolveCombatAction(attacker: UnitData, defender: UnitData, action: DamageCommand): CombatResult { + const preview = this.combatPreview(attacker, defender, action); + const previousDefenderHp = defender.hp; + const attackerGrowth = this.awardEquipmentExp(attacker, action === 'attack' ? 'weapon' : 'accessory', this.attackerEquipmentExpGain(attacker, action)); const armorGrowth = this.awardEquipmentExp(defender, 'armor', 6); + const characterGrowth = this.awardCharacterExp(attacker, this.characterExpGain(preview)); const bondExp = attacker.faction === 'ally' ? this.recordBondAttackIntent(attacker, defender) : 0; defender.hp = Math.max(0, defender.hp - preview.damage); @@ -801,17 +836,53 @@ export class BattleScene extends Phaser.Scene { return { ...preview, + previousDefenderHp, defeated: defender.hp <= 0, - weaponGrowth, - armorGrowth, + characterGrowth, + attackerGrowth, + defenderGrowth: armorGrowth, bondExp }; } + private actionPower(unit: UnitData, action: DamageCommand) { + if (action === 'strategy') { + return 10 + Math.floor(unit.stats.intelligence / 5) + this.equipmentStrategyBonus(unit); + } + if (action === 'item') { + return 9 + Math.floor(unit.stats.luck / 8) + Math.floor(this.equipmentAttackBonus(unit) / 2); + } + return unit.attack + this.equipmentAttackBonus(unit) + Math.floor(unit.stats.might / 12); + } + + private actionDefense(unit: UnitData, action: DamageCommand, terrainDefenseBonus: number) { + if (action === 'strategy') { + return Math.floor(unit.stats.intelligence / 10) + Math.floor(terrainDefenseBonus / 4); + } + if (action === 'item') { + return Math.floor(unit.stats.leadership / 22) + Math.floor(terrainDefenseBonus / 5); + } + return this.equipmentDefenseBonus(unit) + Math.floor(unit.stats.leadership / 18) + Math.floor(terrainDefenseBonus / 3); + } + + private attackerEquipmentExpGain(unit: UnitData, action: DamageCommand) { + if (action === 'attack') { + return this.weaponExpGain(unit); + } + return 5 + (getItem(unit.equipment.accessory.itemId).rank === 'treasure' ? 2 : 0); + } + + private characterExpGain(result: CombatPreview) { + const levelGap = result.defender.level - result.attacker.level; + const actionBonus = result.action === 'attack' ? 10 : 8; + const defeatBonus = result.damage >= result.defender.hp ? 12 : 0; + return Phaser.Math.Clamp(actionBonus + levelGap * 2 + defeatBonus, 4, 28); + } + 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}`; + return `${result.attacker.name} ${commandLabels[result.action]}\n${result.defender.name}에게 ${result.damage} 피해${defeated}\n장수 경험치 +${result.characterGrowth.amount}\n${this.formatEquipmentGrowth(result.attackerGrowth)}\n${this.formatEquipmentGrowth(result.defenderGrowth)}${bond}`; } private commandResultMessage(unit: UnitData, command: BattleCommand) { @@ -852,6 +923,10 @@ export class BattleScene extends Phaser.Scene { } private handleRightClick(pointer: Phaser.Input.Pointer) { + if (this.phase === 'animating') { + return; + } + if (this.cancelAttackTargeting()) { return; } @@ -890,7 +965,7 @@ export class BattleScene extends Phaser.Scene { } private showMapMenu(pointerX: number, pointerY: number) { - if (this.phase === 'command' || this.phase === 'targeting') { + if (this.phase === 'command' || this.phase === 'targeting' || this.phase === 'animating') { return; } @@ -982,7 +1057,7 @@ export class BattleScene extends Phaser.Scene { this.renderSituationPanel('이미 적군 행동 중입니다.'); return; } - if (this.phase === 'command' || this.phase === 'targeting') { + if (this.phase === 'command' || this.phase === 'targeting' || this.phase === 'animating') { this.setInfo('현재 장수의 행동을 먼저 결정하세요.'); return; } @@ -1045,6 +1120,7 @@ export class BattleScene extends Phaser.Scene { target = this.chooseTargetInRange(enemy) ?? target; if (this.canAttack(enemy, target)) { const result = this.resolveAttack(enemy, target); + await this.playCombatCutIn(result); return this.formatEnemyCombatResult(result, behavior); } @@ -1116,6 +1192,315 @@ export class BattleScene extends Phaser.Scene { return `${result.attacker.name} ${this.enemyBehaviorLabel(behavior)} 공격: ${result.defender.name}에게 ${result.damage} 피해${defeated}`; } + private async playCombatCutIn(result: CombatResult) { + this.hideCombatCutIn(); + + const panelWidth = 900; + const panelHeight = 430; + const left = Math.floor((this.scale.width - panelWidth) / 2); + const top = Math.floor((this.scale.height - panelHeight) / 2); + const depth = 70; + const attackerX = left + 190; + const defenderX = left + panelWidth - 190; + const groundY = top + 246; + + const dim = this.trackCombatObject(this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x020406, 0.62)); + 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, palette.gold, 0.9); + + const stage = this.trackCombatObject(this.add.graphics()); + stage.setDepth(depth + 2); + stage.fillStyle(0x8ec9e6, 1); + stage.fillRect(left + 18, top + 18, panelWidth - 36, 64); + stage.fillStyle(0x3f6f44, 1); + stage.fillRect(left + 18, top + 82, panelWidth - 36, 186); + stage.fillStyle(0x6f8c43, 0.92); + stage.fillRect(left + 18, top + 158, panelWidth - 36, 110); + stage.lineStyle(1, 0x2f4f2f, 0.45); + for (let x = left + 40; x < left + panelWidth - 40; x += 42) { + stage.beginPath(); + stage.moveTo(x, top + 163); + stage.lineTo(x - 16, top + 264); + stage.strokePath(); + } + stage.fillStyle(0x23472c, 1); + stage.fillRect(left + 18, top + 82, panelWidth - 36, 18); + + const title = this.trackCombatObject(this.add.text(left + panelWidth / 2, top + 24, commandLabels[result.action], { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '24px', + color: '#f4dfad', + fontStyle: '700', + stroke: '#05070a', + strokeThickness: 4 + })); + title.setOrigin(0.5, 0); + title.setDepth(depth + 8); + + const attackerDirection = result.attacker.faction === 'ally' ? 'east' : 'west'; + const defenderDirection = result.attacker.faction === 'ally' ? 'west' : 'east'; + const attackerSprite = this.trackCombatObject( + this.add.sprite(attackerX, groundY, unitTexture[result.attacker.id] ?? 'unit-rebel', this.unitFrameIndex(attackerDirection)) + ); + attackerSprite.setDepth(depth + 6); + attackerSprite.setDisplaySize(126, 126); + + const defenderSprite = this.trackCombatObject( + this.add.sprite(defenderX, groundY, unitTexture[result.defender.id] ?? 'unit-rebel', this.unitFrameIndex(defenderDirection)) + ); + defenderSprite.setDepth(depth + 6); + defenderSprite.setDisplaySize(126, 126); + + const attackerStatus = this.renderCombatStatusPanel(result.attacker, left + 26, top + 286, 392, result.characterGrowth); + const defenderStatus = this.renderCombatStatusPanel(result.defender, left + panelWidth - 418, top + 286, 392); + defenderStatus.hpFill.setScale(result.previousDefenderHp / result.defender.maxHp, 1); + defenderStatus.hpText.setText(`${result.previousDefenderHp} / ${result.defender.maxHp}`); + attackerStatus.expFill.setScale(result.characterGrowth.previousExp / result.characterGrowth.next, 1); + attackerStatus.expText.setText(`${result.characterGrowth.previousExp} / ${result.characterGrowth.next}`); + + await this.delay(180); + await this.playCombatActionMotion(result, attackerSprite, defenderSprite, attackerX, defenderX, groundY, depth + 10); + this.showCombatImpact(result, defenderX, groundY - 46, depth + 14); + + 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}`); + + await this.animateCombatGauge( + attackerStatus.expFill, + result.characterGrowth.previousExp / result.characterGrowth.next, + result.characterGrowth.exp / result.characterGrowth.next, + 420 + ); + attackerStatus.expText.setText(`${result.characterGrowth.exp} / ${result.characterGrowth.next}`); + + const resultText = this.trackCombatObject(this.add.text(left + panelWidth / 2, top + 252, `${result.damage} 피해`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '22px', + color: '#ffdf7b', + fontStyle: '700', + stroke: '#2b0909', + strokeThickness: 4 + })); + resultText.setOrigin(0.5); + resultText.setDepth(depth + 16); + + const expText = this.trackCombatObject(this.add.text(left + panelWidth / 2, top + 386, `장수 경험치 +${result.characterGrowth.amount}${result.characterGrowth.leveled ? ` / Lv ${result.characterGrowth.level}` : ''}`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#d4dce6', + fontStyle: '700' + })); + expText.setOrigin(0.5); + expText.setDepth(depth + 16); + + 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); + panel.setDepth(84); + panel.setStrokeStyle(2, unit.faction === 'ally' ? palette.blue : 0xb86b55, 0.78); + + const textureKey = this.combatPortraitKey(unit); + if (textureKey && this.textures.exists(textureKey)) { + const portrait = this.trackCombatObject(this.add.image(x + 38, y + 58, textureKey)); + portrait.setDepth(85); + portrait.setDisplaySize(58, 80); + } else { + const unitIcon = this.trackCombatObject(this.add.sprite(x + 38, y + 58, unitTexture[unit.id] ?? 'unit-rebel', this.unitFrameIndex('south'))); + unitIcon.setDepth(85); + unitIcon.setDisplaySize(64, 64); + } + + const name = this.trackCombatObject(this.add.text(x + 78, y + 14, `${unit.name} Lv ${unit.level}`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#f2e3bf', + fontStyle: '700' + })); + name.setDepth(85); + + const hpLabel = this.trackCombatObject(this.add.text(x + 78, y + 45, '병력', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '13px', + color: '#9fb0bf', + fontStyle: '700' + })); + hpLabel.setDepth(85); + const hpTrack = this.trackCombatObject(this.add.rectangle(x + 126, y + 54, width - 210, 9, 0x0a0f14, 0.9)); + hpTrack.setOrigin(0, 0.5); + hpTrack.setDepth(85); + const hpFill = this.trackCombatObject(this.add.rectangle(x + 126, y + 54, width - 210, 7, 0x59d18c, 0.96)); + hpFill.setOrigin(0, 0.5); + hpFill.setDepth(86); + hpFill.setScale(unit.hp / unit.maxHp, 1); + const hpText = this.trackCombatObject(this.add.text(x + width - 18, y + 43, `${unit.hp} / ${unit.maxHp}`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '14px', + color: '#f0e4c8', + fontStyle: '700' + })); + hpText.setOrigin(1, 0); + hpText.setDepth(85); + + const expLabel = this.trackCombatObject(this.add.text(x + 78, y + 75, '경험', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '13px', + color: '#9fb0bf', + fontStyle: '700' + })); + expLabel.setDepth(85); + const expTrack = this.trackCombatObject(this.add.rectangle(x + 126, y + 84, width - 210, 9, 0x0a0f14, 0.9)); + expTrack.setOrigin(0, 0.5); + expTrack.setDepth(85); + const expFill = this.trackCombatObject(this.add.rectangle(x + 126, y + 84, width - 210, 7, 0xd8b15f, 0.96)); + expFill.setOrigin(0, 0.5); + expFill.setDepth(86); + expFill.setScale((growth?.previousExp ?? unit.exp) / 100, 1); + const expText = this.trackCombatObject(this.add.text(x + width - 18, y + 73, `${growth?.previousExp ?? unit.exp} / 100`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '14px', + color: '#f0e4c8', + fontStyle: '700' + })); + expText.setOrigin(1, 0); + expText.setDepth(85); + + return { hpFill, hpText, expFill, expText }; + } + + private combatPortraitKey(unit: UnitData) { + if (unit.id === 'liu-bei') { + return 'portrait-liu-bei'; + } + if (unit.id === 'guan-yu') { + return 'portrait-guan-yu'; + } + if (unit.id === 'zhang-fei') { + return 'portrait-zhang-fei'; + } + return undefined; + } + + private async playCombatActionMotion( + result: CombatResult, + attackerSprite: Phaser.GameObjects.Sprite, + defenderSprite: Phaser.GameObjects.Sprite, + attackerX: number, + defenderX: number, + groundY: number, + depth: number + ) { + const direction = result.attacker.faction === 'ally' ? 1 : -1; + if (result.action === 'attack') { + this.tweens.add({ + targets: attackerSprite, + x: attackerX + direction * 84, + duration: 170, + yoyo: true, + ease: 'Sine.easeInOut' + }); + await this.delay(180); + this.tweens.add({ + targets: defenderSprite, + x: defenderX + direction * 12, + duration: 70, + yoyo: true + }); + await this.delay(230); + return; + } + + const projectile = + result.action === 'item' + ? this.createCombatCart(attackerX + direction * 78, groundY - 10, depth) + : this.createStrategyEffect(attackerX + direction * 78, groundY - 48, depth); + projectile.setDepth(depth); + this.combatCutInObjects.push(projectile); + this.tweens.add({ + targets: projectile, + x: defenderX - direction * 72, + duration: 430, + ease: 'Cubic.easeIn' + }); + await this.delay(450); + projectile.destroy(); + } + + private createCombatCart(x: number, y: number, depth: number) { + const container = this.add.container(x, y); + container.setDepth(depth); + const body = this.add.rectangle(0, 0, 58, 28, 0x7c5432, 1); + body.setStrokeStyle(2, 0x2a160c, 1); + const cover = this.add.rectangle(0, -9, 46, 10, 0xa57a45, 1); + const leftWheel = this.add.circle(-18, 15, 7, 0x1b1511, 1); + const rightWheel = this.add.circle(18, 15, 7, 0x1b1511, 1); + const blade = this.add.triangle(35, -4, 0, -10, 22, 0, 0, 10, 0xd8dfe6, 1); + blade.setStrokeStyle(1, 0x4d5960, 1); + container.add([body, cover, leftWheel, rightWheel, blade]); + return container; + } + + private createStrategyEffect(x: number, y: number, depth: number) { + const container = this.add.container(x, y); + container.setDepth(depth); + const core = this.add.circle(0, 0, 14, 0x6cc5ff, 0.9); + const ring = this.add.circle(0, 0, 24); + ring.setStrokeStyle(3, 0xd8b15f, 0.9); + const spark = this.add.star(0, 0, 5, 7, 18, 0xf7e6a1, 0.9); + container.add([ring, core, spark]); + this.tweens.add({ targets: ring, angle: 180, duration: 430 }); + this.tweens.add({ targets: spark, angle: -240, duration: 430 }); + return container; + } + + private showCombatImpact(result: CombatResult, x: number, y: number, depth: number) { + const impact = this.trackCombatObject(this.add.star(x, y, 8, 12, 42, result.action === 'strategy' ? 0x6cc5ff : 0xffdf7b, 0.92)); + impact.setDepth(depth); + this.tweens.add({ + targets: impact, + scale: 1.45, + alpha: 0, + duration: 320, + ease: 'Sine.easeOut', + onComplete: () => impact.destroy() + }); + } + + private animateCombatGauge(target: Phaser.GameObjects.Rectangle, fromRatio: number, toRatio: number, duration: number) { + target.setScale(Phaser.Math.Clamp(fromRatio, 0, 1), 1); + return new Promise((resolve) => { + this.tweens.add({ + targets: target, + scaleX: Phaser.Math.Clamp(toRatio, 0, 1), + duration, + ease: 'Sine.easeInOut', + onComplete: () => resolve() + }); + }); + } + + private trackCombatObject(object: T) { + this.combatCutInObjects.push(object); + return object; + } + + private hideCombatCutIn() { + this.combatCutInObjects.forEach((object) => { + if (object.active) { + object.destroy(); + } + }); + this.combatCutInObjects = []; + } + private delay(ms: number) { return new Promise((resolve) => { this.time.delayedCall(ms, () => resolve()); @@ -1292,6 +1677,8 @@ export class BattleScene extends Phaser.Scene { private awardEquipmentExp(unit: UnitData, slot: EquipmentSlot, amount: number): EquipmentGrowthResult { const state = unit.equipment[slot]; const item = getItem(state.itemId); + const previousLevel = state.level; + const previousExp = state.exp; let exp = state.exp + amount; let leveled = false; @@ -1308,6 +1695,8 @@ export class BattleScene extends Phaser.Scene { slot, itemName: item.name, amount, + previousLevel, + previousExp, level: state.level, exp: state.exp, next, @@ -1315,6 +1704,31 @@ export class BattleScene extends Phaser.Scene { }; } + private awardCharacterExp(unit: UnitData, amount: number): CharacterGrowthResult { + const previousLevel = unit.level; + const previousExp = unit.exp; + let exp = unit.exp + amount; + let leveled = false; + + while (unit.level < maxCharacterLevel && exp >= 100) { + exp -= 100; + unit.level += 1; + leveled = true; + } + + unit.exp = unit.level >= maxCharacterLevel ? Math.min(exp, 100) : exp; + + return { + amount, + previousLevel, + previousExp, + level: unit.level, + exp: unit.exp, + next: 100, + leveled + }; + } + private formatEquipmentGrowth(result: EquipmentGrowthResult) { const levelUp = result.leveled ? ` / Lv ${result.level} 달성` : ''; return `${equipmentSlotLabels[result.slot]} ${result.itemName} 경험치 +${result.amount} (${result.exp}/${result.next})${levelUp}`; @@ -1356,6 +1770,7 @@ export class BattleScene extends Phaser.Scene { const unit = this.selectedUnit; const view = this.unitViews.get(unit.id); this.phase = 'command'; + this.targetingAction = undefined; this.clearMarkers(); soundDirector.playSelect(); this.showCommandMenu(unit, view?.sprite.x ?? this.tileCenterX(unit.x), view?.sprite.y ?? this.tileCenterY(unit.y)); @@ -2000,6 +2415,14 @@ export class BattleScene extends Phaser.Scene { }, 0); } + private equipmentStrategyBonus(unit: UnitData) { + return equipmentSlots.reduce((total, slot) => { + const item = getItem(unit.equipment[slot].itemId); + const levelBonus = slot === 'accessory' ? unit.equipment[slot].level - 1 : 0; + return total + (item.strategyBonus ?? 0) + levelBonus; + }, 0); + } + private equipmentDefenseBonus(unit: UnitData) { return equipmentSlots.reduce((total, slot) => { const item = getItem(unit.equipment[slot].itemId); @@ -2121,6 +2544,8 @@ export class BattleScene extends Phaser.Scene { classKey: unit.classKey, ai: unit.faction === 'enemy' ? this.enemyBehavior(unit) : null, attackRange: this.attackRange(unit), + level: unit.level, + exp: unit.exp, hp: unit.hp, maxHp: unit.maxHp, x: unit.x,