Enhance combat growth feedback

This commit is contained in:
2026-06-28 16:35:17 +09:00
parent 30cac6c7ed
commit 61259b9db7

View File

@@ -1147,6 +1147,11 @@ type EquipmentGrowthResult = {
leveled: boolean;
};
type LevelUpStatGain = {
label: string;
amount: number;
};
type CharacterGrowthResult = {
amount: number;
previousLevel: number;
@@ -1155,6 +1160,8 @@ type CharacterGrowthResult = {
exp: number;
next: number;
leveled: boolean;
levelUps: number;
statGains: LevelUpStatGain[];
};
type BondGrowthResult = {
@@ -1169,6 +1176,8 @@ type BondGrowthResult = {
};
type GrowthGaugeEntry = {
kind: 'character' | 'equipment' | 'bond';
slot?: EquipmentSlot;
label: string;
owner: string;
amountLabel: string;
@@ -1180,6 +1189,7 @@ type GrowthGaugeEntry = {
next: number;
leveled: boolean;
color: number;
statGains?: LevelUpStatGain[];
};
type GrowthGaugeView = {
@@ -1203,6 +1213,7 @@ type ResultGaugeAnimation = {
exp: number;
next: number;
leveled: boolean;
statGains?: LevelUpStatGain[];
flashArea: {
x: number;
y: number;
@@ -5007,6 +5018,7 @@ export class BattleScene extends Phaser.Scene {
exp: unit.exp,
next: 100,
leveled: unit.level > previousLevel,
statGains: this.characterLevelUpStatGains(unit.level - previousLevel),
flashArea: { x, y, width, height: 68, depth: depth + 3 }
});
}
@@ -5200,7 +5212,7 @@ export class BattleScene extends Phaser.Scene {
animation.levelText.setText(`${animation.levelPrefix}${animation.level}`);
animation.fill.setScale(0, 1);
animation.valueText.setText(`0 / ${animation.next}`);
this.showGrowthEventText(animation.eventText, `Lv ${animation.level} 달성!`);
this.showGrowthEventText(animation.eventText, this.levelUpEventLabel(animation));
this.flashResultGauge(animation);
soundDirector.playLevelUp();
await this.playResultLevelUpCelebration(animation);
@@ -5238,20 +5250,10 @@ export class BattleScene extends Phaser.Scene {
}
private async playResultLevelUpCelebration(animation: ResultGaugeAnimation) {
if (!animation.unit) {
await this.delay(180);
return;
}
const { x, y, width, height, depth } = animation.flashArea;
const spriteX = Math.min(x + width - 52, animation.valueText.x + 40);
const spriteY = y + height / 2 + 2;
this.createResultLevelUpBurst(spriteX, spriteY, depth + 4);
const sprite = this.trackResultObject(
this.add.sprite(spriteX, spriteY + 2, this.unitActionTexture(animation.unit), this.unitActionFrameIndex('south', this.celebrationPose(animation.unit)))
);
sprite.setDisplaySize(70, 70);
sprite.setDepth(depth + 6);
const banner = this.trackResultObject(this.add.text(spriteX, spriteY - 44, 'LEVEL UP', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '13px',
@@ -5262,7 +5264,81 @@ export class BattleScene extends Phaser.Scene {
}));
banner.setOrigin(0.5);
banner.setDepth(depth + 7);
await this.playUnitCelebrationFrames(sprite, animation.unit);
let sprite: Phaser.GameObjects.Sprite | undefined;
if (animation.unit) {
sprite = this.trackResultObject(
this.add.sprite(spriteX, spriteY + 2, this.unitActionTexture(animation.unit), this.unitActionFrameIndex('south', this.celebrationPose(animation.unit)))
);
sprite.setDisplaySize(70, 70);
sprite.setDepth(depth + 6);
}
await this.playResultLevelUpSpotlight(animation);
if (sprite && animation.unit) {
await this.playUnitCelebrationFrames(sprite, animation.unit);
}
}
private levelUpEventLabel(animation: ResultGaugeAnimation) {
const stats = this.statGainLine(animation.statGains);
return stats ? `Lv ${animation.level} 달성! ${stats}` : `Lv ${animation.level} 달성!`;
}
private async playResultLevelUpSpotlight(animation: ResultGaugeAnimation) {
const stats = this.statGainLine(animation.statGains);
const depth = animation.flashArea.depth + 12;
const container = this.trackResultObject(this.add.container(this.scale.width / 2, 146));
container.setDepth(depth);
container.setAlpha(0);
container.setScale(0.9);
const width = 480;
const bg = this.add.rectangle(0, 0, width, 96, 0x211624, 0.98);
bg.setStrokeStyle(3, palette.gold, 0.96);
const burst = this.add.star(-width / 2 + 56, -2, 8, 14, 42, 0xffdf7b, 0.78);
const title = this.add.text(-width / 2 + 110, -34, animation.unit ? `${animation.unit.name} LEVEL UP` : 'LEVEL UP', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '27px',
color: '#fff2b8',
fontStyle: '700',
stroke: '#2b1606',
strokeThickness: 5
});
const levelText = this.add.text(-width / 2 + 110, 0, `${animation.levelPrefix}${animation.previousLevel}${animation.levelPrefix}${animation.level}`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '17px',
color: '#f4dfad',
fontStyle: '700'
});
const statText = this.add.text(-width / 2 + 110, 26, stats ?? '숙련 효과 강화', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '16px',
color: stats ? '#a8ffd0' : '#d8b15f',
fontStyle: '700',
stroke: '#05070a',
strokeThickness: 3
});
container.add([bg, burst, title, levelText, statText]);
if (animation.unit) {
const sprite = this.add.sprite(-width / 2 + 56, 10, this.unitActionTexture(animation.unit), this.unitActionFrameIndex('south', this.celebrationPose(animation.unit)));
sprite.setDisplaySize(76, 76);
container.add(sprite);
}
this.tweens.add({ targets: burst, angle: 240, scale: 1.34, alpha: 0.16, duration: 760, ease: 'Sine.easeOut' });
this.tweens.add({ targets: container, alpha: 1, scale: 1.04, duration: 180, ease: 'Back.easeOut' });
await this.delay(760);
this.tweens.add({
targets: container,
alpha: 0,
y: container.y - 18,
duration: 240,
ease: 'Sine.easeIn',
onComplete: () => container.destroy()
});
await this.delay(260);
}
private createResultLevelUpBurst(x: number, y: number, depth: number) {
@@ -5748,7 +5824,7 @@ export class BattleScene extends Phaser.Scene {
const equipment = result.equipmentEffectLabels.length > 0 ? `\n보물 효과: ${result.equipmentEffectLabels.join(', ')}` : '';
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}${bondBonus}${equipment}\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}${equipment}\n${this.formatCharacterGrowth(result.characterGrowth)}\n${this.formatEquipmentGrowth(result.attackerGrowth)}\n${this.formatEquipmentGrowth(result.defenderGrowth)}${bond}${counter}`;
}
private formatCombatOutcome(result: CombatResult) {
@@ -5767,11 +5843,10 @@ export class BattleScene extends Phaser.Scene {
}
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.formatCharacterGrowth(result.characterGrowth),
this.formatEquipmentGrowth(result.attackerGrowth),
this.formatEquipmentGrowth(result.defenderGrowth)
];
@@ -5791,7 +5866,7 @@ export class BattleScene extends Phaser.Scene {
result.usable.effect === 'heal'
? `${result.target.name} 병력 +${result.healAmount} (${result.target.hp}/${result.target.maxHp})`
: `${result.target.name} 강화: 공격 +${result.buff?.attackBonus ?? 0} / 명중 +${result.buff?.hitBonus ?? 0} / 치명 +${result.buff?.criticalBonus ?? 0}`;
return `${result.user.name} ${result.usable.name}\n${effect}\n장수 경험치 +${result.characterGrowth.amount}\n${this.formatEquipmentGrowth(result.equipmentGrowth)}`;
return `${result.user.name} ${result.usable.name}\n${effect}\n${this.formatCharacterGrowth(result.characterGrowth)}\n${this.formatEquipmentGrowth(result.equipmentGrowth)}`;
}
private commandResultMessage(unit: UnitData, command: BattleCommand) {
@@ -7023,6 +7098,7 @@ export class BattleScene extends Phaser.Scene {
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}`);
const growthEntries = this.combatGrowthEntries(result);
if (result.bondDamageBonus > 0 && result.bondLabel) {
await this.showBondCombatEffect(result, left + panelWidth / 2, top + 106, attackerX, groundY - 70, depth + 15);
@@ -7035,6 +7111,7 @@ export class BattleScene extends Phaser.Scene {
} else {
this.showCombatMiss(defenderX, groundY - 70, depth + 14);
}
void this.playImmediateGrowthPopups(growthEntries, left + panelWidth / 2, top + 214, depth + 22);
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}`);
@@ -7071,7 +7148,7 @@ export class BattleScene extends Phaser.Scene {
y: top + 410,
width: panelWidth - 172,
title: '성장 결과',
entries: this.combatGrowthEntries(result),
entries: growthEntries,
celebrant: this.growthCelebrant(result),
depth: depth + 18
});
@@ -7132,6 +7209,7 @@ export class BattleScene extends Phaser.Scene {
const targetStatus = this.renderCombatStatusPanel(result.target, left + panelWidth / 2 - 196, top + 190, 392);
targetStatus.hpFill.setScale(result.previousTargetHp / result.target.maxHp, 1);
targetStatus.hpText.setText(`${result.previousTargetHp} / ${result.target.maxHp}`);
const growthEntries = this.supportGrowthEntries(result);
await this.delay(250);
await this.animateCombatGauge(targetStatus.hpFill, result.previousTargetHp / result.target.maxHp, result.target.hp / result.target.maxHp, 480);
@@ -7151,13 +7229,14 @@ export class BattleScene extends Phaser.Scene {
}));
resultText.setOrigin(0.5);
resultText.setDepth(depth + 5);
void this.playImmediateGrowthPopups(growthEntries, left + panelWidth / 2, top + 156, depth + 8);
await this.playGrowthRewardPanel({
x: left + 64,
y: top + 322,
width: panelWidth - 128,
title: '성장 결과',
entries: this.supportGrowthEntries(result),
entries: growthEntries,
celebrant: result.user,
depth: depth + 8
});
@@ -7261,6 +7340,7 @@ export class BattleScene extends Phaser.Scene {
private characterGrowthEntry(unit: UnitData, growth: CharacterGrowthResult): GrowthGaugeEntry {
return {
kind: 'character',
label: '장수 경험치',
owner: unit.name,
amountLabel: `+${growth.amount}`,
@@ -7271,12 +7351,15 @@ export class BattleScene extends Phaser.Scene {
exp: growth.exp,
next: growth.next,
leveled: growth.leveled,
color: 0xd8b15f
color: 0xd8b15f,
statGains: growth.statGains
};
}
private equipmentGrowthEntry(unit: UnitData, growth: EquipmentGrowthResult): GrowthGaugeEntry {
return {
kind: 'equipment',
slot: growth.slot,
label: `${equipmentSlotLabels[growth.slot]} ${growth.itemName}`,
owner: unit.name,
amountLabel: `+${growth.amount}`,
@@ -7293,6 +7376,7 @@ export class BattleScene extends Phaser.Scene {
private bondGrowthEntry(growth: BondGrowthResult): GrowthGaugeEntry {
return {
kind: 'bond',
label: '공명 경험치',
owner: growth.label,
amountLabel: `+${growth.amount}`,
@@ -7317,6 +7401,93 @@ export class BattleScene extends Phaser.Scene {
return undefined;
}
private playImmediateGrowthPopups(entries: GrowthGaugeEntry[], x: number, y: number, depth: number) {
const visibleEntries = entries.filter((entry) => entry.amountLabel !== '+0' || entry.leveled).slice(0, 5);
if (visibleEntries.length === 0) {
return Promise.resolve();
}
soundDirector.playGrowthTick();
visibleEntries.forEach((entry, index) => {
const label = this.growthPopupLabel(entry);
const statLine = entry.leveled ? this.statGainLine(entry.statGains) : undefined;
const width = Math.min(500, Math.max(230, label.length * 13 + (statLine ? 72 : 34)));
const rowY = y + index * 31;
const container = this.trackCombatObject(this.add.container(x, rowY));
container.setAlpha(0);
container.setScale(0.92);
container.setDepth(depth + index);
const bg = this.add.rectangle(0, 0, width, statLine ? 36 : 28, 0x101820, 0.94);
bg.setStrokeStyle(2, entry.leveled ? palette.gold : entry.color, entry.leveled ? 0.96 : 0.74);
const text = this.add.text(statLine ? -width / 2 + 18 : 0, statLine ? -12 : -9, label, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: entry.leveled ? '16px' : '15px',
color: entry.leveled ? '#fff2b8' : '#f4dfad',
fontStyle: '700',
stroke: '#05070a',
strokeThickness: 3
});
text.setOrigin(statLine ? 0 : 0.5, 0);
container.add([bg, text]);
if (statLine) {
const statText = this.add.text(-width / 2 + 18, 8, statLine, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '13px',
color: '#a8ffd0',
fontStyle: '700',
stroke: '#05070a',
strokeThickness: 3
});
container.add(statText);
}
this.tweens.add({
targets: container,
alpha: 1,
scale: entry.leveled ? 1.08 : 1,
y: rowY - 6,
delay: index * 82,
duration: 150,
ease: 'Back.easeOut'
});
this.tweens.add({
targets: container,
alpha: 0,
y: rowY - 34,
delay: 620 + index * 82,
duration: 420,
ease: 'Sine.easeIn',
onComplete: () => container.destroy()
});
});
return this.delay(visibleEntries.length * 82 + 720);
}
private growthPopupLabel(entry: GrowthGaugeEntry) {
if (entry.kind === 'character') {
return `${entry.owner} 경험치 ${entry.amountLabel}${entry.leveled ? ' LEVEL UP' : ''}`;
}
if (entry.kind === 'bond') {
return `${entry.owner} 공명 ${entry.amountLabel}${entry.leveled ? ' LEVEL UP' : ''}`;
}
const mastery =
entry.slot === 'weapon'
? '무기 숙련'
: entry.slot === 'armor'
? '방어구 숙련'
: '보조구 숙련';
return `${entry.owner} ${mastery} ${entry.amountLabel}${entry.leveled ? ' LEVEL UP' : ''}`;
}
private statGainLine(gains?: LevelUpStatGain[]) {
const visibleGains = gains?.filter((gain) => gain.amount > 0) ?? [];
return visibleGains.length > 0 ? visibleGains.map((gain) => `${gain.label} +${gain.amount}`).join(' ') : undefined;
}
private async playGrowthRewardPanel(config: {
x: number;
y: number;
@@ -7350,12 +7521,17 @@ export class BattleScene extends Phaser.Scene {
const levelEvent = entries.some((entry) => entry.leveled);
const rowWidth = config.width - (levelEvent && config.celebrant ? 126 : 36);
const views = entries.map((entry, index) => this.renderGrowthGaugeRow(entry, config.x + 18, config.y + 42 + index * rowHeight, rowWidth, config.depth + 1));
const spotlightEntry = entries.find((entry) => entry.kind === 'character' && entry.leveled) ?? entries.find((entry) => entry.leveled);
let celebration: Phaser.GameObjects.Sprite | undefined;
if (levelEvent && config.celebrant) {
celebration = this.createGrowthCelebrationSprite(config.celebrant, config.x + config.width - 46, config.y + 24, config.depth + 5);
}
const spotlight = spotlightEntry
? this.playCombatLevelUpSpotlight(spotlightEntry, config.celebrant, config.x + config.width / 2, config.y - 88, config.width, config.depth + 8)
: undefined;
for (let index = 0; index < entries.length; index += 1) {
await this.animateGrowthEntry(views[index], entries[index], 540 + index * 70);
if (entries[index].leveled) {
@@ -7366,6 +7542,69 @@ export class BattleScene extends Phaser.Scene {
if (celebration && config.celebrant) {
await this.playGrowthCelebration(celebration, config.celebrant);
}
if (spotlight) {
await spotlight;
}
}
private async playCombatLevelUpSpotlight(entry: GrowthGaugeEntry, celebrant: UnitData | undefined, x: number, y: number, width: number, depth: number) {
const panelWidth = Math.min(540, width - 34);
const panelHeight = 104;
const container = this.trackCombatObject(this.add.container(x, y));
container.setDepth(depth);
container.setAlpha(0);
container.setScale(0.9);
const bg = this.add.rectangle(0, 0, panelWidth, panelHeight, 0x211624, 0.97);
bg.setStrokeStyle(3, palette.gold, 0.96);
const glow = this.add.circle(-panelWidth / 2 + 58, 0, 42, 0xffdf7b, 0.12);
const title = this.add.text(celebrant ? -panelWidth / 2 + 112 : 0, -36, `${entry.owner} LEVEL UP`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '25px',
color: '#fff2b8',
fontStyle: '700',
stroke: '#2b1606',
strokeThickness: 5
});
title.setOrigin(celebrant ? 0 : 0.5, 0);
const levelText = this.add.text(celebrant ? -panelWidth / 2 + 112 : 0, -3, `Lv ${entry.previousLevel} → Lv ${entry.level}`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '18px',
color: '#f4dfad',
fontStyle: '700'
});
levelText.setOrigin(celebrant ? 0 : 0.5, 0);
const statText = this.add.text(celebrant ? -panelWidth / 2 + 112 : 0, 25, this.statGainLine(entry.statGains) ?? `${entry.label} 효과 강화`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '16px',
color: '#a8ffd0',
fontStyle: '700',
stroke: '#05070a',
strokeThickness: 3
});
statText.setOrigin(celebrant ? 0 : 0.5, 0);
container.add([bg, glow, title, levelText, statText]);
if (celebrant) {
const sprite = this.add.sprite(-panelWidth / 2 + 56, 6, this.unitActionTexture(celebrant), this.unitActionFrameIndex('south', this.celebrationPose(celebrant)));
sprite.setDisplaySize(82, 82);
const burst = this.add.star(-panelWidth / 2 + 56, -12, 8, 12, 34, 0xffdf7b, 0.72);
container.add([burst, sprite]);
this.tweens.add({ targets: burst, angle: 220, scale: 1.32, alpha: 0.18, duration: 760, ease: 'Sine.easeOut' });
}
this.tweens.add({ targets: container, alpha: 1, scale: 1.04, duration: 180, ease: 'Back.easeOut' });
await this.delay(740);
this.tweens.add({
targets: container,
alpha: 0,
y: y - 16,
duration: 240,
ease: 'Sine.easeIn',
onComplete: () => container.destroy()
});
await this.delay(260);
}
private renderGrowthGaugeRow(entry: GrowthGaugeEntry, x: number, y: number, width: number, depth: number): GrowthGaugeView {
@@ -7432,7 +7671,7 @@ export class BattleScene extends Phaser.Scene {
view.levelText.setText(`Lv ${entry.level}`);
view.fill.setScale(0, 1);
view.valueText.setText(`0/${entry.next}`);
this.showGrowthEventText(view.eventText, `Lv ${entry.level} 달성!`);
this.showGrowthEventText(view.eventText, this.growthLevelUpEventLabel(entry));
soundDirector.playLevelUp();
await this.delay(260);
await this.animateGrowthGauge(view.fill, view.valueText, 0, entry.exp / entry.next, 0, entry.exp, entry.next, false, Math.max(260, Math.floor(duration * 0.48)));
@@ -7443,6 +7682,11 @@ export class BattleScene extends Phaser.Scene {
view.levelText.setText(`Lv ${entry.level}`);
}
private growthLevelUpEventLabel(entry: GrowthGaugeEntry) {
const stats = this.statGainLine(entry.statGains);
return stats ? `Lv ${entry.level} 달성! ${stats}` : `Lv ${entry.level} 달성!`;
}
private showGrowthEventText(text: Phaser.GameObjects.Text, label: string) {
text.setText(label);
text.setAlpha(1);
@@ -8493,11 +8737,16 @@ export class BattleScene extends Phaser.Scene {
const previousExp = unit.exp;
let exp = unit.exp + amount;
let leveled = false;
let levelUps = 0;
while (unit.level < maxCharacterLevel && exp >= 100) {
exp -= 100;
unit.level += 1;
unit.maxHp += 2;
unit.hp = Math.min(unit.maxHp, unit.hp + 2);
unit.attack += 1;
leveled = true;
levelUps += 1;
}
unit.exp = unit.level >= maxCharacterLevel ? Math.min(exp, 100) : exp;
@@ -8509,15 +8758,34 @@ export class BattleScene extends Phaser.Scene {
level: unit.level,
exp: unit.exp,
next: 100,
leveled
leveled,
levelUps,
statGains: this.characterLevelUpStatGains(levelUps)
};
}
private characterLevelUpStatGains(levelUps: number): LevelUpStatGain[] {
if (levelUps <= 0) {
return [];
}
return [
{ label: '병력', amount: levelUps * 2 },
{ label: '공격', amount: levelUps }
];
}
private formatEquipmentGrowth(result: EquipmentGrowthResult) {
const levelUp = result.leveled ? ` / Lv ${result.level} 달성` : '';
return `${equipmentSlotLabels[result.slot]} ${result.itemName} 경험치 +${result.amount} (${result.exp}/${result.next})${levelUp}`;
}
private formatCharacterGrowth(result: CharacterGrowthResult) {
const levelUp = result.leveled ? ` / Lv ${result.level} 달성` : '';
const statGains = this.statGainLine(result.statGains);
return `장수 경험치 +${result.amount} (${result.exp}/${result.next})${levelUp}${statGains ? ` / ${statGains}` : ''}`;
}
private findNearestEnemy(unit: UnitData) {
const enemies = battleUnits.filter((candidate) => candidate.faction === 'enemy' && candidate.hp > 0);
return enemies.sort((a, b) => this.tileDistance(unit, a) - this.tileDistance(unit, b))[0];