Align battle map art and bond effects

This commit is contained in:
2026-06-22 15:58:20 +09:00
parent 305484a7c3
commit b7f683eb9f
4 changed files with 420 additions and 14 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -24,15 +24,15 @@ export type UnitClassDefinition = {
};
export const terrainRules: Record<TerrainType, TerrainRule> = {
plain: { label: '평지', color: 0x6d8056, alpha: 0.04, moveCost: 1, defenseBonus: 0 },
road: { label: '길', color: 0xb08b55, alpha: 0.1, moveCost: 1, defenseBonus: 0 },
forest: { label: '숲', color: 0x355f42, alpha: 0.17, moveCost: 2, defenseBonus: 10 },
hill: { label: '언덕', color: 0x82724e, alpha: 0.15, moveCost: 2, defenseBonus: 8 },
village: { label: '마을', color: 0x8d6f4b, alpha: 0.2, moveCost: 1, defenseBonus: 12, recoveryHp: 8, moraleBonus: 3 },
fort: { label: '요새', color: 0x735c46, alpha: 0.22, moveCost: 1, defenseBonus: 18, recoveryHp: 10, moraleBonus: 4 },
camp: { label: '진영', color: 0x4f6a73, alpha: 0.17, moveCost: 1, defenseBonus: 15, recoveryHp: 6, moraleBonus: 4 },
river: { label: '강', color: 0x2c6687, alpha: 0.24, moveCost: 99, defenseBonus: -4, passable: false },
cliff: { label: '절벽', color: 0x39342e, alpha: 0.28, moveCost: 99, defenseBonus: 20, passable: false }
plain: { label: '평지', color: 0x6d8056, alpha: 0.01, moveCost: 1, defenseBonus: 0 },
road: { label: '길', color: 0xb08b55, alpha: 0.015, moveCost: 1, defenseBonus: 0 },
forest: { label: '숲', color: 0x355f42, alpha: 0.025, moveCost: 2, defenseBonus: 10 },
hill: { label: '언덕', color: 0x82724e, alpha: 0.025, moveCost: 2, defenseBonus: 8 },
village: { label: '마을', color: 0x8d6f4b, alpha: 0.03, moveCost: 1, defenseBonus: 12, recoveryHp: 8, moraleBonus: 3 },
fort: { label: '요새', color: 0x735c46, alpha: 0.035, moveCost: 1, defenseBonus: 18, recoveryHp: 10, moraleBonus: 4 },
camp: { label: '진영', color: 0x4f6a73, alpha: 0.03, moveCost: 1, defenseBonus: 15, recoveryHp: 6, moraleBonus: 4 },
river: { label: '강', color: 0x2c6687, alpha: 0.035, moveCost: 99, defenseBonus: -4, passable: false },
cliff: { label: '절벽', color: 0x39342e, alpha: 0.045, moveCost: 99, defenseBonus: 20, passable: false }
};
export const unitClasses: Record<UnitClassKey, UnitClassDefinition> = {

View File

@@ -188,6 +188,8 @@ type CombatPreview = {
attacker: UnitData;
defender: UnitData;
damage: number;
bondDamageBonus: number;
bondLabel?: string;
hitRate: number;
criticalRate: number;
counterAvailable: boolean;
@@ -1595,9 +1597,10 @@ export class BattleScene extends Phaser.Scene {
private renderAttackPreview(preview: CombatPreview) {
const counter = preview.counterAvailable ? ' / 반격 가능' : '';
const bond = preview.bondDamageBonus > 0 && preview.bondLabel ? `\n공명 ${preview.bondLabel} 피해 +${preview.bondDamageBonus}%` : '';
this.renderUnitDetail(
preview.defender,
`${preview.attacker.name} ${commandLabels[preview.action]} 예측\n피해 ${preview.damage} / 명중 ${preview.hitRate}% / 치명 ${preview.criticalRate}%\n지형 ${preview.terrainLabel}${counter}`
`${preview.attacker.name} ${commandLabels[preview.action]} 예측\n피해 ${preview.damage} / 명중 ${preview.hitRate}% / 치명 ${preview.criticalRate}%\n지형 ${preview.terrainLabel}${counter}${bond}`
);
}
@@ -2018,8 +2021,8 @@ export class BattleScene extends Phaser.Scene {
const terrainRule = getTerrainRule(terrain);
const attackPower = this.actionPower(attacker, action, usable);
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 bondBonus = attacker.faction === 'ally' ? this.strongestBondBonus(attacker.id) : { damageBonus: 0, chainRate: 0 };
const damage = Phaser.Math.Clamp(Math.round((attackPower - defensePower) * (1 + bondBonus.damageBonus / 100)), 3, defender.maxHp);
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(
@@ -2049,6 +2052,8 @@ export class BattleScene extends Phaser.Scene {
attacker,
defender,
damage,
bondDamageBonus: bondBonus.damageBonus,
bondLabel: bondBonus.label,
hitRate,
criticalRate,
counterAvailable: this.canCounterAttack(defender, attacker, action),
@@ -2223,9 +2228,10 @@ export class BattleScene extends Phaser.Scene {
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 bondBonus = result.bondDamageBonus > 0 && result.bondLabel ? `\n공명 효과 ${result.bondLabel}: 피해 +${result.bondDamageBonus}%` : '';
const bond = result.bondExp > 0 ? `\n공명 경험치 +${result.bondExp}` : '';
const counter = result.counter ? `\n${this.formatCounterLine(result.counter)}` : '';
return `${result.attacker.name} ${result.usable?.name ?? commandLabels[result.action]}\n${outcome}${defeated}\n장수 경험치 +${result.characterGrowth.amount}\n${this.formatEquipmentGrowth(result.attackerGrowth)}\n${this.formatEquipmentGrowth(result.defenderGrowth)}${bond}${counter}`;
return `${result.attacker.name} ${result.usable?.name ?? commandLabels[result.action]}\n${outcome}${defeated}${bondBonus}\n장수 경험치 +${result.characterGrowth.amount}\n${this.formatEquipmentGrowth(result.attackerGrowth)}\n${this.formatEquipmentGrowth(result.defenderGrowth)}${bond}${counter}`;
}
private formatCombatOutcome(result: CombatResult) {
@@ -2902,6 +2908,10 @@ export class BattleScene extends Phaser.Scene {
attackerStatus.expFill.setScale(result.characterGrowth.previousExp / result.characterGrowth.next, 1);
attackerStatus.expText.setText(`${result.characterGrowth.previousExp} / ${result.characterGrowth.next}`);
if (result.bondDamageBonus > 0 && result.bondLabel) {
await this.showBondCombatEffect(result, left + panelWidth / 2, top + 106, attackerX, groundY - 70, depth + 15);
}
await this.delay(180);
await this.playCombatActionMotion(result, attackerSprite, defenderSprite, attackerX, defenderX, groundY, depth + 10, defenderDirection);
if (result.hit) {
@@ -3490,6 +3500,44 @@ export class BattleScene extends Phaser.Scene {
return container;
}
private showBondCombatEffect(result: CombatResult, textX: number, textY: number, auraX: number, auraY: number, depth: number) {
if (!result.bondLabel || result.bondDamageBonus <= 0) {
return Promise.resolve();
}
soundDirector.playEffect('strategy-cast', { volume: 0.22, rate: 1.08, stopAfterMs: 720 });
const aura = this.trackCombatObject(this.add.circle(auraX, auraY, 42));
aura.setStrokeStyle(4, 0xffdf7b, 0.88);
aura.setDepth(depth);
const inner = this.trackCombatObject(this.add.circle(auraX, auraY, 24, 0xffdf7b, 0.18));
inner.setDepth(depth - 1);
const spark = this.trackCombatObject(this.add.star(auraX, auraY, 6, 8, 24, 0xfff0b8, 0.86));
spark.setDepth(depth + 1);
const text = this.trackCombatObject(this.add.text(textX, textY, `공명 효과 ${result.bondLabel}\n피해 +${result.bondDamageBonus}%`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '18px',
color: '#fff2b8',
fontStyle: '700',
align: 'center',
stroke: '#2b1606',
strokeThickness: 4,
lineSpacing: 2
}));
text.setOrigin(0.5);
text.setDepth(depth + 2);
this.tweens.add({ targets: aura, scale: 1.7, alpha: 0, duration: 560, ease: 'Sine.easeOut' });
this.tweens.add({ targets: inner, scale: 1.35, alpha: 0.04, duration: 560, ease: 'Sine.easeOut' });
this.tweens.add({ targets: spark, angle: 180, scale: 1.22, duration: 560, ease: 'Sine.easeInOut' });
this.tweens.add({ targets: text, y: textY - 8, scale: 1.05, duration: 180, yoyo: true, ease: 'Sine.easeOut' });
return this.delay(580);
}
private showCombatImpact(result: CombatResult, x: number, y: number, depth: number) {
soundDirector.playEffect('combat-impact', { volume: result.critical ? 0.62 : result.action === 'strategy' ? 0.3 : 0.48, stopAfterMs: 650 });
@@ -4006,10 +4054,12 @@ export class BattleScene extends Phaser.Scene {
if (!strongest) {
return { damageBonus: 0, chainRate: 0 };
}
const [first, second] = strongest.unitIds.map((id) => this.unitName(id));
return {
damageBonus: Math.floor(strongest.level / 20) * 3,
chainRate: strongest.level >= 70 ? 18 : strongest.level >= 50 ? 8 : 0
chainRate: strongest.level >= 70 ? 18 : strongest.level >= 50 ? 8 : 0,
label: `${strongest.title}: ${first}·${second}`
};
}