diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 308781e..c1f3998 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -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(); private actedUnitIds = new Set(); @@ -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);