Add combat cut-in damage presentation
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user