Add combat cut-in damage presentation

This commit is contained in:
2026-06-22 12:08:57 +09:00
parent 2aefcd6f23
commit 381b2cf14c
2 changed files with 487 additions and 46 deletions

View File

@@ -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,

View File

@@ -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<BattleCommand, 'wait'>;
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<Record<UnitClassKey, number>> = {
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<Phaser.GameObjects.Rectangle | Phaser.GameObjects.Text> = [];
private mapMenuObjects: Array<Phaser.GameObjects.Rectangle | Phaser.GameObjects.Text> = [];
private turnPromptObjects: Phaser.GameObjects.GameObject[] = [];
private combatCutInObjects: Phaser.GameObjects.GameObject[] = [];
private unitViews = new Map<string, UnitView>();
private actedUnitIds = new Set<string>();
private bondStates = new Map<string, BondState>();
@@ -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<void>((resolve) => {
this.tweens.add({
targets: target,
scaleX: Phaser.Math.Clamp(toRatio, 0, 1),
duration,
ease: 'Sine.easeInOut',
onComplete: () => resolve()
});
});
}
private trackCombatObject<T extends Phaser.GameObjects.GameObject>(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<void>((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,