Animate battle result growth settlement

This commit is contained in:
2026-06-22 19:55:04 +09:00
parent b2a4a576c5
commit f649240759

View File

@@ -226,6 +226,28 @@ type GrowthGaugeView = {
eventText: Phaser.GameObjects.Text;
};
type ResultGaugeAnimation = {
fill: Phaser.GameObjects.Rectangle;
valueText: Phaser.GameObjects.Text;
levelText: Phaser.GameObjects.Text;
eventText: Phaser.GameObjects.Text;
levelPrefix: string;
previousLevel: number;
previousExp: number;
previousNext: number;
level: number;
exp: number;
next: number;
leveled: boolean;
flashArea: {
x: number;
y: number;
width: number;
height: number;
depth: number;
};
};
type CombatPreview = {
action: DamageCommand;
usable?: BattleUsable;
@@ -551,6 +573,9 @@ export class BattleScene extends Phaser.Scene {
private turnPromptObjects: Phaser.GameObjects.GameObject[] = [];
private combatCutInObjects: Phaser.GameObjects.GameObject[] = [];
private resultObjects: Phaser.GameObjects.GameObject[] = [];
private resultGaugeAnimations: ResultGaugeAnimation[] = [];
private resultAnimationToken = 0;
private resultSettlementText?: Phaser.GameObjects.Text;
private saveSlotPanelObjects: Phaser.GameObjects.GameObject[] = [];
private unitViews = new Map<string, UnitView>();
private actedUnitIds = new Set<string>();
@@ -1940,6 +1965,9 @@ export class BattleScene extends Phaser.Scene {
private showBattleResult(outcome: BattleOutcome) {
this.hideBattleResult();
this.resultAnimationToken += 1;
this.resultGaugeAnimations = [];
const animationToken = this.resultAnimationToken;
const depth = 120;
const panelWidth = 1040;
@@ -1996,8 +2024,9 @@ export class BattleScene extends Phaser.Scene {
this.renderResultObjectivePanel(objectives, left + 44, top + 178, 456, depth + 2);
this.renderResultRewardPanel(outcome, objectives, left + 526, top + 178, 470, depth + 2);
this.renderResultBondStrip(left + 44, top + 318, panelWidth - 88, depth + 2);
const unitTitle = this.trackResultObject(this.add.text(left + 44, top + 332, '장수 성장', {
const unitTitle = this.trackResultObject(this.add.text(left + 44, top + 354, '장수 성장', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '21px',
color: '#f2e3bf',
@@ -2006,9 +2035,17 @@ export class BattleScene extends Phaser.Scene {
unitTitle.setDepth(depth + 2);
allies.forEach((unit, index) => {
this.renderResultUnitRow(unit, left + 44, top + 366 + index * 70, panelWidth - 88, depth + 2);
this.renderResultUnitRow(unit, left + 44, top + 386 + index * 70, panelWidth - 88, depth + 2);
});
this.resultSettlementText = this.trackResultObject(this.add.text(left + 44, top + 604, '성장 정산 대기', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '14px',
color: '#d8b15f',
fontStyle: '700'
}));
this.resultSettlementText.setDepth(depth + 2);
if (outcome === 'victory') {
this.addResultButton('군영으로', left + panelWidth - 422, top + 612, 132, () => {
soundDirector.playSelect();
@@ -2023,11 +2060,16 @@ export class BattleScene extends Phaser.Scene {
soundDirector.playSelect();
this.scene.start('TitleScene');
}, depth + 3);
void this.playResultSettlementAnimation(animationToken);
}
private hideBattleResult() {
this.resultAnimationToken += 1;
this.resultObjects.forEach((object) => object.destroy());
this.resultObjects = [];
this.resultGaugeAnimations = [];
this.resultSettlementText = undefined;
}
private resultObjectives(outcome: BattleOutcome): BattleObjectiveResult[] {
@@ -2233,6 +2275,106 @@ export class BattleScene extends Phaser.Scene {
});
}
private renderResultBondStrip(x: number, y: number, width: number, depth: number) {
const bonds = Array.from(this.bondStates.values());
const bg = this.trackResultObject(this.add.rectangle(x, y, width, 30, 0x101820, 0.9));
bg.setOrigin(0);
bg.setDepth(depth);
bg.setStrokeStyle(1, palette.gold, 0.46);
const title = this.trackResultObject(this.add.text(x + 12, y + 7, '공명 성장', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '13px',
color: '#f2e3bf',
fontStyle: '700'
}));
title.setDepth(depth + 1);
if (bonds.length === 0) {
const empty = this.trackResultObject(this.add.text(x + 110, y + 7, '공명 없음', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '13px',
color: '#9fb0bf'
}));
empty.setDepth(depth + 1);
return;
}
const startX = x + 104;
const columnWidth = Math.floor((width - 118) / bonds.length);
bonds.forEach((bond, index) => {
const initial = initialBattleBonds.find((candidate) => candidate.id === bond.id);
const previousLevel = initial?.level ?? bond.level;
const previousExp = initial?.exp ?? bond.exp;
const gained = initial ? Math.max(0, this.characterProgressScore(bond.level, bond.exp) - this.characterProgressScore(initial.level, initial.exp)) : bond.battleExp;
const colX = startX + index * columnWidth;
const [first, second] = bond.unitIds.map((unitId) => this.unitName(unitId));
const label = this.trackResultObject(this.add.text(colX, y + 6, `${first}·${second}`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '12px',
color: '#d4dce6',
fontStyle: '700'
}));
label.setDepth(depth + 1);
const amount = this.trackResultObject(this.add.text(colX + 86, y + 6, gained > 0 ? `+${gained}` : '+0', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '12px',
color: gained > 0 ? '#d8b15f' : '#6f7a84',
fontStyle: '700'
}));
amount.setDepth(depth + 1);
const fill = this.renderResultProgressGauge(colX + 122, y + 11, 82, 7, previousExp / 100, 0x83d6ff, depth + 1);
const valueText = this.trackResultObject(this.add.text(colX + 210, y + 4, `${previousExp}/100`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '11px',
color: '#f0e4c8',
fontStyle: '700'
}));
valueText.setOrigin(1, 0);
valueText.setDepth(depth + 1);
const levelText = this.trackResultObject(this.add.text(colX + columnWidth - 8, y + 6, `Lv ${previousLevel}`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '12px',
color: '#f4dfad',
fontStyle: '700'
}));
levelText.setOrigin(1, 0);
levelText.setDepth(depth + 1);
const eventText = this.trackResultObject(this.add.text(colX + 164, y + 2, '', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '12px',
color: '#fff2b8',
fontStyle: '700',
stroke: '#05070a',
strokeThickness: 3
}));
eventText.setOrigin(0.5);
eventText.setDepth(depth + 4);
eventText.setAlpha(0);
if (gained > 0 || bond.level > previousLevel) {
this.queueResultGaugeAnimation({
fill,
valueText,
levelText,
eventText,
levelPrefix: 'Lv ',
previousLevel,
previousExp,
previousNext: 100,
level: bond.level,
exp: bond.exp,
next: 100,
leveled: bond.level > previousLevel,
flashArea: { x: colX - 6, y: y + 3, width: Math.max(220, columnWidth - 12), height: 24, depth: depth + 3 }
});
}
});
}
private renderResultMetric(label: string, value: string, x: number, y: number, width: number, depth: number) {
const bg = this.trackResultObject(this.add.rectangle(x, y, width, 48, 0x16212d, 0.94));
bg.setOrigin(0);
@@ -2258,6 +2400,7 @@ export class BattleScene extends Phaser.Scene {
private renderResultUnitRow(unit: UnitData, x: number, y: number, width: number, depth: number) {
const alive = unit.hp > 0;
const initial = initialBattleUnits.find((candidate) => candidate.id === unit.id);
const bg = this.trackResultObject(this.add.rectangle(x, y, width, 68, 0x101820, alive ? 0.94 : 0.66));
bg.setOrigin(0);
bg.setDepth(depth);
@@ -2286,6 +2429,9 @@ export class BattleScene extends Phaser.Scene {
}));
hpText.setDepth(depth + 1);
const previousLevel = initial?.level ?? unit.level;
const previousExp = initial?.exp ?? unit.exp;
const characterGained = initial ? Math.max(0, this.characterProgressScore(unit.level, unit.exp) - this.characterProgressScore(initial.level, initial.exp)) : 0;
const expText = this.trackResultObject(this.add.text(x + 392, y + 9, this.characterResultText(unit), {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '14px',
@@ -2293,20 +2439,105 @@ export class BattleScene extends Phaser.Scene {
fontStyle: '700'
}));
expText.setDepth(depth + 1);
this.renderResultProgressGauge(x + 392, y + 36, 200, 8, unit.exp / 100, palette.gold, depth + 1);
const characterFill = this.renderResultProgressGauge(x + 392, y + 36, 158, 8, previousExp / 100, palette.gold, depth + 1);
const characterValueText = this.trackResultObject(this.add.text(x + 564, y + 28, `${previousExp} / 100`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '11px',
color: '#f0e4c8',
fontStyle: '700'
}));
characterValueText.setOrigin(1, 0);
characterValueText.setDepth(depth + 1);
const characterLevelText = this.trackResultObject(this.add.text(x + 570, y + 9, `Lv ${previousLevel}`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '12px',
color: '#f4dfad',
fontStyle: '700'
}));
characterLevelText.setDepth(depth + 1);
const characterEventText = this.trackResultObject(this.add.text(x + 476, y + 31, '', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '14px',
color: '#fff2b8',
fontStyle: '700',
stroke: '#05070a',
strokeThickness: 3
}));
characterEventText.setOrigin(0.5);
characterEventText.setDepth(depth + 4);
characterEventText.setAlpha(0);
if (characterGained > 0 || unit.level > previousLevel) {
this.queueResultGaugeAnimation({
fill: characterFill,
valueText: characterValueText,
levelText: characterLevelText,
eventText: characterEventText,
levelPrefix: 'Lv ',
previousLevel,
previousExp,
previousNext: 100,
level: unit.level,
exp: unit.exp,
next: 100,
leveled: unit.level > previousLevel,
flashArea: { x, y, width, height: 68, depth: depth + 3 }
});
}
equipmentSlots.forEach((slot, index) => {
const state = unit.equipment[slot];
const initialState = initial?.equipment[slot];
const previousEquipmentLevel = initialState?.level ?? state.level;
const previousEquipmentExp = initialState?.exp ?? state.exp;
const previousNext = equipmentExpToNext(previousEquipmentLevel);
const next = equipmentExpToNext(state.level);
const gained = initialState ? Math.max(0, this.equipmentProgressScore(state.level, state.exp) - this.equipmentProgressScore(initialState.level, initialState.exp)) : 0;
const color = slot === 'weapon' ? 0xffc45f : slot === 'armor' ? 0x77b7ff : 0xb7df7a;
const slotX = x + 632 + index * 96;
const label = this.trackResultObject(this.add.text(slotX, y + 9, equipmentSlotLabels[slot], {
const label = this.trackResultObject(this.add.text(slotX, y + 9, `${equipmentSlotLabels[slot]} Lv${previousEquipmentLevel}`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '11px',
color: alive ? '#9fb0bf' : '#777777',
fontStyle: '700'
}));
label.setDepth(depth + 1);
this.renderResultProgressGauge(slotX, y + 36, 76, 8, state.exp / equipmentExpToNext(state.level), color, depth + 1);
const valueText = this.trackResultObject(this.add.text(slotX + 38, y + 22, `${previousEquipmentExp}/${previousNext}`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '10px',
color: alive ? '#f0e4c8' : '#7e858d',
fontStyle: '700'
}));
valueText.setOrigin(0.5, 0);
valueText.setDepth(depth + 1);
const fill = this.renderResultProgressGauge(slotX, y + 38, 76, 7, previousEquipmentExp / previousNext, color, depth + 1);
const eventText = this.trackResultObject(this.add.text(slotX + 38, y + 31, '', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '12px',
color: '#fff2b8',
fontStyle: '700',
stroke: '#05070a',
strokeThickness: 3
}));
eventText.setOrigin(0.5);
eventText.setDepth(depth + 4);
eventText.setAlpha(0);
if (gained > 0 || state.level > previousEquipmentLevel) {
this.queueResultGaugeAnimation({
fill,
valueText,
levelText: label,
eventText,
levelPrefix: `${equipmentSlotLabels[slot]} Lv`,
previousLevel: previousEquipmentLevel,
previousExp: previousEquipmentExp,
previousNext,
level: state.level,
exp: state.exp,
next,
leveled: state.level > previousEquipmentLevel,
flashArea: { x: slotX - 6, y: y + 5, width: 88, height: 44, depth: depth + 3 }
});
}
});
const equipment = this.trackResultObject(this.add.text(x + 14, y + 48, this.equipmentResultText(unit), {
@@ -2324,10 +2555,11 @@ export class BattleScene extends Phaser.Scene {
track.setDepth(depth);
track.setStrokeStyle(1, 0x40515e, 0.5);
const fillWidth = Math.max(2, Math.floor((width - 2) * Phaser.Math.Clamp(ratio, 0, 1)));
const fill = this.trackResultObject(this.add.rectangle(x + 1, y + 1, fillWidth, Math.max(2, height - 2), color, 0.96));
const fill = this.trackResultObject(this.add.rectangle(x + 1, y + 1, Math.max(2, width - 2), Math.max(2, height - 2), color, 0.96));
fill.setOrigin(0);
fill.setDepth(depth + 1);
fill.setScale(Phaser.Math.Clamp(ratio, 0, 1), 1);
return fill;
}
private characterResultText(unit: UnitData) {
@@ -2396,6 +2628,94 @@ export class BattleScene extends Phaser.Scene {
return `공명 ${bond.level} 경험 +${gained}${levelText} 전투 +${bond.battleExp}`;
}
private queueResultGaugeAnimation(animation: ResultGaugeAnimation) {
this.resultGaugeAnimations.push(animation);
}
private async playResultSettlementAnimation(token: number) {
await this.delay(360);
if (token !== this.resultAnimationToken) {
return;
}
if (this.resultGaugeAnimations.length === 0) {
this.resultSettlementText?.setText('성장 변화 없음');
return;
}
for (let index = 0; index < this.resultGaugeAnimations.length; index += 1) {
if (token !== this.resultAnimationToken) {
return;
}
this.resultSettlementText?.setText(`성장 정산 ${index + 1} / ${this.resultGaugeAnimations.length}`);
await this.animateResultGauge(this.resultGaugeAnimations[index], 280);
await this.delay(44);
}
if (token === this.resultAnimationToken) {
this.resultSettlementText?.setText('성장 정산 완료');
}
}
private async animateResultGauge(animation: ResultGaugeAnimation, duration: number) {
if (animation.leveled) {
await this.animateGrowthGauge(
animation.fill,
animation.valueText,
animation.previousExp / animation.previousNext,
1,
animation.previousExp,
animation.previousNext,
animation.previousNext,
false,
duration
);
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.flashResultGauge(animation);
soundDirector.playLevelUp();
await this.delay(180);
await this.animateGrowthGauge(animation.fill, animation.valueText, 0, animation.exp / animation.next, 0, animation.exp, animation.next, false, duration);
return;
}
await this.animateGrowthGauge(
animation.fill,
animation.valueText,
animation.previousExp / animation.previousNext,
animation.exp / animation.next,
animation.previousExp,
animation.exp,
animation.next,
false,
duration
);
animation.levelText.setText(`${animation.levelPrefix}${animation.level}`);
}
private flashResultGauge(animation: ResultGaugeAnimation) {
const { x, y, width, height, depth } = animation.flashArea;
const flash = this.trackResultObject(this.add.rectangle(x + width / 2, y + height / 2, width, height, 0xffdf7b, 0.16));
flash.setDepth(depth);
this.tweens.add({
targets: flash,
alpha: 0,
scaleX: 1.04,
scaleY: 1.16,
duration: 520,
ease: 'Sine.easeOut'
});
this.tweens.add({
targets: [animation.levelText, animation.valueText],
scale: 1.14,
duration: 120,
yoyo: true,
ease: 'Sine.easeOut'
});
}
private addResultButton(label: string, x: number, y: number, width: number, action: () => void, depth: number) {
const bg = this.trackResultObject(this.add.rectangle(x, y, width, 34, 0x1a2630, 0.96));
bg.setDepth(depth);