diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index bde17db..4b05fcb 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -119,16 +119,22 @@ type CombatPreview = { defender: UnitData; damage: number; hitRate: number; + criticalRate: number; + counterAvailable: boolean; terrainLabel: string; }; type CombatResult = CombatPreview & { previousDefenderHp: number; + hit: boolean; + critical: boolean; + isCounter: boolean; defeated: boolean; characterGrowth: CharacterGrowthResult; attackerGrowth: EquipmentGrowthResult; defenderGrowth: EquipmentGrowthResult; bondExp: number; + counter?: CombatResult; }; type SavedUnitState = Pick & { @@ -872,13 +878,19 @@ export class BattleScene extends Phaser.Scene { const result = this.resolveCombatAction(attacker, target, action); this.clearMarkers(); await this.playCombatCutIn(result); + result.counter = this.resolveCounterAttack(result); + if (result.counter) { + await this.delay(180); + await this.playCombatCutIn(result.counter); + } this.finishUnitAction(attacker, this.formatCombatResult(result)); } private renderAttackPreview(preview: CombatPreview) { + const counter = preview.counterAvailable ? ' / 반격 가능' : ''; this.renderUnitDetail( preview.defender, - `${preview.attacker.name} ${commandLabels[preview.action]} 예측\n피해 ${preview.damage} / 명중 ${preview.hitRate}% / 지형 ${preview.terrainLabel}` + `${preview.attacker.name} ${commandLabels[preview.action]} 예측\n피해 ${preview.damage} / 명중 ${preview.hitRate}% / 치명 ${preview.criticalRate}%\n지형 ${preview.terrainLabel}${counter}` ); } @@ -1231,7 +1243,21 @@ export class BattleScene extends Phaser.Scene { 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); + 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, + 45, + 98 + ); + const criticalRate = Phaser.Math.Clamp( + 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), + 2, + 28 + ); return { action, @@ -1239,6 +1265,8 @@ export class BattleScene extends Phaser.Scene { defender, damage, hitRate, + criticalRate, + counterAvailable: this.canCounterAttack(defender, attacker, action), terrainLabel: terrainRule.label }; } @@ -1247,17 +1275,24 @@ export class BattleScene extends Phaser.Scene { return this.resolveCombatAction(attacker, defender, 'attack'); } - private resolveCombatAction(attacker: UnitData, defender: UnitData, action: DamageCommand): CombatResult { + private resolveCombatAction(attacker: UnitData, defender: UnitData, action: DamageCommand, isCounter = false): CombatResult { const preview = this.combatPreview(attacker, defender, action); const previousDefenderHp = defender.hp; + const hit = this.rollPercent(preview.hitRate); + const critical = hit && this.rollPercent(preview.criticalRate); + const damage = hit ? Phaser.Math.Clamp(Math.round(preview.damage * (critical ? 1.5 : 1)), 1, defender.hp) : 0; 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; + const armorGrowth = this.awardEquipmentExp(defender, 'armor', hit ? 6 : 3); + const characterGrowth = this.awardCharacterExp(attacker, this.characterExpGain(preview, damage, hit, critical)); + const bondExp = attacker.faction === 'ally' && !isCounter ? this.recordBondAttackIntent(attacker, defender) : 0; - defender.hp = Math.max(0, defender.hp - preview.damage); + defender.hp = Math.max(0, defender.hp - damage); this.faceUnitToward(attacker, defender); - this.flashDamage(defender, preview.damage); + if (hit) { + this.flashDamage(defender, damage, critical); + } else { + this.flashMiss(defender); + } if (defender.hp <= 0) { this.applyDefeatedStyle(defender); @@ -1267,7 +1302,11 @@ export class BattleScene extends Phaser.Scene { return { ...preview, + damage, previousDefenderHp, + hit, + critical, + isCounter, defeated: defender.hp <= 0, characterGrowth, attackerGrowth, @@ -1276,6 +1315,34 @@ export class BattleScene extends Phaser.Scene { }; } + private resolveCounterAttack(result: CombatResult) { + if (result.defender.hp <= 0) { + return undefined; + } + if (!this.canCounterAttack(result.defender, result.attacker, result.action)) { + return undefined; + } + + return this.resolveCombatAction(result.defender, result.attacker, 'attack', true); + } + + private canCounterAttack(defender: UnitData, attacker: UnitData, incomingAction: DamageCommand) { + if (incomingAction !== 'attack' || defender.hp <= 0 || attacker.hp <= 0) { + return false; + } + + const distance = this.tileDistance(defender, attacker); + if (defender.classKey === 'archer' && distance <= 1) { + return false; + } + + return this.canAttack(defender, attacker); + } + + private rollPercent(rate: number) { + return Phaser.Math.Between(1, 100) <= Phaser.Math.Clamp(rate, 0, 100); + } + private actionPower(unit: UnitData, action: DamageCommand) { if (action === 'strategy') { return 10 + Math.floor(unit.stats.intelligence / 5) + this.equipmentStrategyBonus(unit); @@ -1303,22 +1370,46 @@ export class BattleScene extends Phaser.Scene { return 5 + (getItem(unit.equipment.accessory.itemId).rank === 'treasure' ? 2 : 0); } - private characterExpGain(result: CombatPreview) { + private characterExpGain(result: CombatPreview, damage: number, hit: boolean, critical: boolean) { + if (!hit) { + return 2; + } + 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); + const defeatBonus = damage >= result.defender.hp ? 12 : 0; + const criticalBonus = critical ? 2 : 0; + return Phaser.Math.Clamp(actionBonus + levelGap * 2 + defeatBonus + criticalBonus, 4, 30); } private formatCombatResult(result: CombatResult) { + const outcome = this.formatCombatOutcome(result); 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} ${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}`; + 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}`; + } + + private formatCombatOutcome(result: CombatResult) { + if (!result.hit) { + return `${result.defender.name}이 공격을 회피했습니다.`; + } + + const critical = result.critical ? '치명타! ' : ''; + return `${critical}${result.defender.name}에게 ${result.damage} 피해`; + } + + private formatCounterLine(result: CombatResult) { + const outcome = result.hit ? `${result.critical ? '치명타로 ' : ''}${result.damage} 피해` : '회피됨'; + const defeated = result.defeated ? ` / ${result.defender.name} 퇴각` : ` / ${result.defender.name} HP ${result.defender.hp}/${result.defender.maxHp}`; + return `반격: ${result.attacker.name} -> ${result.defender.name} ${outcome}${defeated}`; } private combatGrowthLines(result: CombatResult) { const characterLevel = result.characterGrowth.leveled ? ` / Lv ${result.characterGrowth.level}` : ''; + const verdict = result.hit ? `${result.critical ? '치명타 / ' : ''}명중 ${result.hitRate}%` : `회피됨 / 명중 ${result.hitRate}%`; const lines = [ + verdict, `장수 경험치 +${result.characterGrowth.amount} (${result.characterGrowth.exp}/${result.characterGrowth.next})${characterLevel}`, this.formatEquipmentGrowth(result.attackerGrowth), this.formatEquipmentGrowth(result.defenderGrowth) @@ -1766,6 +1857,11 @@ export class BattleScene extends Phaser.Scene { if (this.canAttack(enemy, target)) { const result = this.resolveAttack(enemy, target); await this.playCombatCutIn(result); + result.counter = this.resolveCounterAttack(result); + if (result.counter) { + await this.delay(180); + await this.playCombatCutIn(result.counter); + } return this.formatEnemyCombatResult(result, behavior); } @@ -1833,8 +1929,10 @@ export class BattleScene extends Phaser.Scene { } private formatEnemyCombatResult(result: CombatResult, behavior: EnemyAiBehavior) { + const outcome = result.hit ? `${result.critical ? '치명타 ' : ''}${result.damage} 피해` : '회피됨'; 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}`; + const counter = result.counter ? ` / ${this.formatCounterLine(result.counter)}` : ''; + return `${result.attacker.name} ${this.enemyBehaviorLabel(behavior)} 공격: ${result.defender.name} ${outcome}${defeated}${counter}`; } private async playCombatCutIn(result: CombatResult) { @@ -1876,7 +1974,8 @@ export class BattleScene extends Phaser.Scene { 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], { + const titleText = result.isCounter ? '반격' : 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', color: '#f4dfad', @@ -1910,7 +2009,11 @@ export class BattleScene extends Phaser.Scene { await this.delay(180); await this.playCombatActionMotion(result, attackerSprite, defenderSprite, attackerX, defenderX, groundY, depth + 10); - this.showCombatImpact(result, defenderX, groundY - 46, depth + 14); + if (result.hit) { + this.showCombatImpact(result, defenderX, groundY - 46, depth + 14); + } else { + this.showCombatMiss(defenderX, groundY - 70, 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}`); @@ -1927,10 +2030,11 @@ export class BattleScene extends Phaser.Scene { ); attackerStatus.expText.setText(`${result.characterGrowth.exp} / ${result.characterGrowth.next}`); - const resultText = this.trackCombatObject(this.add.text(left + panelWidth / 2, top + 252, `${result.damage} 피해`, { + const resultLabel = result.hit ? `${result.critical ? '치명타! ' : ''}${result.damage} 피해` : '회피!'; + const resultText = this.trackCombatObject(this.add.text(left + panelWidth / 2, top + 252, resultLabel, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '22px', - color: '#ffdf7b', + color: result.hit ? (result.critical ? '#ff8f5f' : '#ffdf7b') : '#d9f1ff', fontStyle: '700', stroke: '#2b0909', strokeThickness: 4 @@ -1938,23 +2042,13 @@ export class BattleScene extends Phaser.Scene { 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}` : ''}`, { + const growthText = this.trackCombatObject(this.add.text(left + panelWidth / 2, top + 330, this.combatGrowthLines(result).join('\n'), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '17px', - color: '#d4dce6', - fontStyle: '700' - })); - expText.setOrigin(0.5); - expText.setDepth(depth + 16); - expText.setVisible(false); - - const growthText = this.trackCombatObject(this.add.text(left + panelWidth / 2, top + 348, this.combatGrowthLines(result).join('\n'), { - fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '15px', + fontSize: '14px', color: '#d4dce6', fontStyle: '700', align: 'center', - lineSpacing: 4 + lineSpacing: 3 })); growthText.setOrigin(0.5, 0); growthText.setDepth(depth + 16); @@ -2061,7 +2155,7 @@ export class BattleScene extends Phaser.Scene { ) { const direction = result.attacker.faction === 'ally' ? 1 : -1; if (result.action === 'attack') { - soundDirector.playEffect('sword-slash', { volume: 0.52, stopAfterMs: 700 }); + soundDirector.playEffect('sword-slash', { volume: result.hit ? 0.52 : 0.38, stopAfterMs: 700 }); this.tweens.add({ targets: attackerSprite, x: attackerX + direction * 84, @@ -2072,8 +2166,9 @@ export class BattleScene extends Phaser.Scene { await this.delay(180); this.tweens.add({ targets: defenderSprite, - x: defenderX + direction * 12, - duration: 70, + x: defenderX + direction * (result.hit ? 12 : 28), + y: result.hit ? defenderSprite.y : defenderSprite.y - 10, + duration: result.hit ? 70 : 120, yoyo: true }); await this.delay(230); @@ -2093,7 +2188,8 @@ export class BattleScene extends Phaser.Scene { this.combatCutInObjects.push(projectile); this.tweens.add({ targets: projectile, - x: defenderX - direction * 72, + x: defenderX - direction * (result.hit ? 72 : 12), + y: result.hit ? projectile.y : projectile.y - 24, duration: 430, ease: 'Cubic.easeIn' }); @@ -2129,13 +2225,15 @@ export class BattleScene extends Phaser.Scene { } private showCombatImpact(result: CombatResult, x: number, y: number, depth: number) { - soundDirector.playEffect('combat-impact', { volume: result.action === 'strategy' ? 0.3 : 0.48, stopAfterMs: 650 }); + soundDirector.playEffect('combat-impact', { volume: result.critical ? 0.62 : result.action === 'strategy' ? 0.3 : 0.48, stopAfterMs: 650 }); - const impact = this.trackCombatObject(this.add.star(x, y, 8, 12, 42, result.action === 'strategy' ? 0x6cc5ff : 0xffdf7b, 0.92)); + const impact = this.trackCombatObject( + this.add.star(x, y, 8, 12, result.critical ? 54 : 42, result.critical ? 0xff8f5f : result.action === 'strategy' ? 0x6cc5ff : 0xffdf7b, 0.92) + ); impact.setDepth(depth); this.tweens.add({ targets: impact, - scale: 1.45, + scale: result.critical ? 1.7 : 1.45, alpha: 0, duration: 320, ease: 'Sine.easeOut', @@ -2143,6 +2241,27 @@ export class BattleScene extends Phaser.Scene { }); } + private showCombatMiss(x: number, y: number, depth: number) { + const miss = this.trackCombatObject(this.add.text(x, y, '회피', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '24px', + color: '#d9f1ff', + fontStyle: '700', + stroke: '#071623', + strokeThickness: 4 + })); + miss.setOrigin(0.5); + miss.setDepth(depth); + this.tweens.add({ + targets: miss, + y: y - 20, + alpha: 0, + duration: 460, + ease: 'Sine.easeOut', + onComplete: () => miss.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) => { @@ -2574,6 +2693,10 @@ export class BattleScene extends Phaser.Scene { if (!view) { return; } + if (unit.hp <= 0) { + this.applyDefeatedStyle(unit); + return; + } view.sprite.postFX.clear(); view.sprite.clearTint(); @@ -2610,16 +2733,16 @@ export class BattleScene extends Phaser.Scene { this.syncUnitMotion(unit, view, direction); } - private flashDamage(unit: UnitData, damage: number) { + private flashDamage(unit: UnitData, damage: number, critical = false) { 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}`, { + const popup = this.add.text(view.sprite.x, view.sprite.y - this.layout.tileSize * 0.5, critical ? `치명 -${damage}` : `-${damage}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '20px', - color: '#ffdf7b', + fontSize: critical ? '22px' : '20px', + color: critical ? '#ff8f5f' : '#ffdf7b', stroke: '#220909', strokeThickness: 4, fontStyle: '700' @@ -2647,6 +2770,41 @@ export class BattleScene extends Phaser.Scene { }); } + private flashMiss(unit: UnitData) { + 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, '회피', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '20px', + color: '#d9f1ff', + stroke: '#071623', + strokeThickness: 4, + fontStyle: '700' + }); + popup.setOrigin(0.5); + popup.setDepth(30); + + this.tweens.add({ + targets: [view.sprite, view.label], + x: `+=${unit.faction === 'ally' ? -8 : 8}`, + duration: 90, + yoyo: true, + ease: 'Sine.easeOut' + }); + + this.tweens.add({ + targets: popup, + y: popup.y - 24, + alpha: 0, + duration: 500, + ease: 'Sine.easeOut', + onComplete: () => popup.destroy() + }); + } + private showTurnEndPrompt(message?: string) { this.hideTurnEndPrompt();