Add hit critical and counter combat

This commit is contained in:
2026-06-22 14:07:04 +09:00
parent a54c64ce93
commit 6f1f7146cd

View File

@@ -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<UnitData, 'id' | 'level' | 'exp' | 'hp' | 'maxHp' | 'attack' | 'move' | 'x' | 'y'> & {
@@ -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<void>((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();