From 305484a7c34abcb5ea8e990afa35ff634530d084 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Mon, 22 Jun 2026 15:38:41 +0900 Subject: [PATCH] Add animated combat growth rewards --- src/game/audio/SoundDirector.ts | 15 ++ src/game/scenes/BattleScene.ts | 446 +++++++++++++++++++++++++++++--- 2 files changed, 424 insertions(+), 37 deletions(-) diff --git a/src/game/audio/SoundDirector.ts b/src/game/audio/SoundDirector.ts index f0087bd..42fe4db 100644 --- a/src/game/audio/SoundDirector.ts +++ b/src/game/audio/SoundDirector.ts @@ -90,6 +90,21 @@ class SoundDirector { window.setTimeout(() => this.playTone(450, 0.08, 0.028, 'sine'), 70); } + playGrowthTick() { + if (this.playEffect('exp-gain', { volume: 0.18, rate: 1.12, stopAfterMs: 360 })) { + return; + } + + this.playTone(620, 0.05, 0.018, 'triangle'); + } + + playLevelUp() { + this.playEffect('exp-gain', { volume: 0.42, rate: 1.08, stopAfterMs: 820 }); + this.playTone(520, 0.08, 0.035, 'triangle'); + window.setTimeout(() => this.playTone(720, 0.09, 0.04, 'triangle'), 82); + window.setTimeout(() => this.playTone(980, 0.12, 0.034, 'sine'), 168); + } + playEffect(key: string, options: PlayEffectOptions = {}) { if (!this.started || this.muted) { return false; diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 7081cb5..948f61b 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -150,6 +150,38 @@ type CharacterGrowthResult = { leveled: boolean; }; +type BondGrowthResult = { + label: string; + amount: number; + previousLevel: number; + previousExp: number; + level: number; + exp: number; + next: number; + leveled: boolean; +}; + +type GrowthGaugeEntry = { + label: string; + owner: string; + amountLabel: string; + previousLevel: number; + previousExp: number; + previousNext: number; + level: number; + exp: number; + next: number; + leveled: boolean; + color: number; +}; + +type GrowthGaugeView = { + fill: Phaser.GameObjects.Rectangle; + valueText: Phaser.GameObjects.Text; + levelText: Phaser.GameObjects.Text; + eventText: Phaser.GameObjects.Text; +}; + type CombatPreview = { action: DamageCommand; usable?: BattleUsable; @@ -172,6 +204,7 @@ type CombatResult = CombatPreview & { attackerGrowth: EquipmentGrowthResult; defenderGrowth: EquipmentGrowthResult; bondExp: number; + bondGrowth: BondGrowthResult[]; counter?: CombatResult; }; @@ -2036,7 +2069,8 @@ export class BattleScene extends Phaser.Scene { const attackerGrowth = this.awardEquipmentExp(attacker, action === 'attack' ? 'weapon' : 'accessory', this.attackerEquipmentExpGain(attacker, action)); const armorGrowth = this.awardEquipmentExp(defender, 'armor', hit ? 6 : 3); const characterGrowth = this.awardCharacterExp(attacker, this.characterExpGain(preview, damage, hit, critical)); - const bondExp = attacker.faction === 'ally' && !isCounter ? this.recordBondAttackIntent(attacker, defender) : 0; + const bondGrowth = attacker.faction === 'ally' && !isCounter ? this.recordBondAttackIntent(attacker, defender) : []; + const bondExp = bondGrowth.reduce((total, growth) => total + growth.amount, 0); defender.hp = Math.max(0, defender.hp - damage); this.faceUnitToward(attacker, defender); @@ -2064,7 +2098,8 @@ export class BattleScene extends Phaser.Scene { characterGrowth, attackerGrowth, defenderGrowth: armorGrowth, - bondExp + bondExp, + bondGrowth }; } @@ -2793,8 +2828,8 @@ export class BattleScene extends Phaser.Scene { private async playCombatCutIn(result: CombatResult) { this.hideCombatCutIn(); - const panelWidth = 900; - const panelHeight = 430; + const panelWidth = 940; + const panelHeight = 560; const left = Math.floor((this.scale.width - panelWidth) / 2); const top = Math.floor((this.scale.height - panelHeight) / 2); const depth = 70; @@ -2889,6 +2924,9 @@ export class BattleScene extends Phaser.Scene { result.characterGrowth.leveled, 680 ); + if (result.characterGrowth.leveled) { + attackerStatus.nameText.setText(`${result.attacker.name} Lv ${result.characterGrowth.level}`); + } const resultLabel = result.hit ? `${result.critical ? '치명타! ' : ''}${result.damage} 피해` : '회피!'; const resultText = this.trackCombatObject(this.add.text(left + panelWidth / 2, top + 252, resultLabel, { @@ -2902,18 +2940,17 @@ export class BattleScene extends Phaser.Scene { resultText.setOrigin(0.5); resultText.setDepth(depth + 16); - const growthText = this.trackCombatObject(this.add.text(left + panelWidth / 2, top + 330, this.combatGrowthLines(result).join('\n'), { - fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '14px', - color: '#d4dce6', - fontStyle: '700', - align: 'center', - lineSpacing: 3 - })); - growthText.setOrigin(0.5, 0); - growthText.setDepth(depth + 16); + await this.playGrowthRewardPanel({ + x: left + 86, + y: top + 410, + width: panelWidth - 172, + title: '성장 결과', + entries: this.combatGrowthEntries(result), + celebrant: this.growthCelebrant(result), + depth: depth + 18 + }); - await this.delay(760); + await this.delay(360); this.hideCombatCutIn(); } @@ -2921,7 +2958,7 @@ export class BattleScene extends Phaser.Scene { this.hideCombatCutIn(); const panelWidth = 760; - const panelHeight = 330; + const panelHeight = 500; const left = Math.floor((this.scale.width - panelWidth) / 2); const top = Math.floor((this.scale.height - panelHeight) / 2); const depth = 70; @@ -2989,20 +3026,17 @@ export class BattleScene extends Phaser.Scene { resultText.setOrigin(0.5); resultText.setDepth(depth + 5); - const growth = this.trackCombatObject(this.add.text(left + panelWidth / 2, top + 292, [ - `장수 경험치 +${result.characterGrowth.amount} (${result.characterGrowth.exp}/${result.characterGrowth.next})`, - this.formatEquipmentGrowth(result.equipmentGrowth) - ].join(' '), { - fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '14px', - color: '#d4dce6', - fontStyle: '700', - align: 'center' - })); - growth.setOrigin(0.5); - growth.setDepth(depth + 5); + await this.playGrowthRewardPanel({ + x: left + 64, + y: top + 322, + width: panelWidth - 128, + title: '성장 결과', + entries: this.supportGrowthEntries(result), + celebrant: result.user, + depth: depth + 8 + }); - await this.delay(760); + await this.delay(360); this.hideCombatCutIn(); } @@ -3023,13 +3057,13 @@ export class BattleScene extends Phaser.Scene { unitIcon.setDisplaySize(64, 64); } - const name = this.trackCombatObject(this.add.text(x + 78, y + 14, `${unit.name} Lv ${unit.level}`, { + const nameText = this.trackCombatObject(this.add.text(x + 78, y + 14, `${unit.name} Lv ${growth?.previousLevel ?? unit.level}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '17px', color: '#f2e3bf', fontStyle: '700' })); - name.setDepth(85); + nameText.setDepth(85); const hpLabel = this.trackCombatObject(this.add.text(x + 78, y + 45, '병력', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', @@ -3077,7 +3111,283 @@ export class BattleScene extends Phaser.Scene { expText.setOrigin(1, 0); expText.setDepth(85); - return { hpFill, hpText, expFill, expText }; + return { hpFill, hpText, expFill, expText, nameText }; + } + + private combatGrowthEntries(result: CombatResult): GrowthGaugeEntry[] { + const entries: GrowthGaugeEntry[] = []; + + if (result.attacker.faction === 'ally') { + entries.push(this.characterGrowthEntry(result.attacker, result.characterGrowth)); + entries.push(this.equipmentGrowthEntry(result.attacker, result.attackerGrowth)); + } + if (result.defender.faction === 'ally') { + entries.push(this.equipmentGrowthEntry(result.defender, result.defenderGrowth)); + } + result.bondGrowth.forEach((growth) => entries.push(this.bondGrowthEntry(growth))); + + return entries; + } + + private supportGrowthEntries(result: SupportResult): GrowthGaugeEntry[] { + return [this.characterGrowthEntry(result.user, result.characterGrowth), this.equipmentGrowthEntry(result.user, result.equipmentGrowth)]; + } + + private characterGrowthEntry(unit: UnitData, growth: CharacterGrowthResult): GrowthGaugeEntry { + return { + label: '장수 경험치', + owner: unit.name, + amountLabel: `+${growth.amount}`, + previousLevel: growth.previousLevel, + previousExp: growth.previousExp, + previousNext: 100, + level: growth.level, + exp: growth.exp, + next: growth.next, + leveled: growth.leveled, + color: 0xd8b15f + }; + } + + private equipmentGrowthEntry(unit: UnitData, growth: EquipmentGrowthResult): GrowthGaugeEntry { + return { + label: `${equipmentSlotLabels[growth.slot]} ${growth.itemName}`, + owner: unit.name, + amountLabel: `+${growth.amount}`, + previousLevel: growth.previousLevel, + previousExp: growth.previousExp, + previousNext: equipmentExpToNext(growth.previousLevel), + level: growth.level, + exp: growth.exp, + next: growth.next, + leveled: growth.leveled, + color: growth.slot === 'weapon' ? 0xffc45f : growth.slot === 'armor' ? 0x77b7ff : 0xb7df7a + }; + } + + private bondGrowthEntry(growth: BondGrowthResult): GrowthGaugeEntry { + return { + label: '공명 경험치', + owner: growth.label, + amountLabel: `+${growth.amount}`, + previousLevel: growth.previousLevel, + previousExp: growth.previousExp, + previousNext: 100, + level: growth.level, + exp: growth.exp, + next: growth.next, + leveled: growth.leveled, + color: 0x83d6ff + }; + } + + private growthCelebrant(result: CombatResult) { + if (result.attacker.faction === 'ally') { + return result.attacker; + } + if (result.defender.faction === 'ally') { + return result.defender; + } + return undefined; + } + + private async playGrowthRewardPanel(config: { + x: number; + y: number; + width: number; + title: string; + entries: GrowthGaugeEntry[]; + celebrant?: UnitData; + depth: number; + }) { + const entries = config.entries.filter((entry) => entry.amountLabel !== '+0' || entry.leveled); + if (entries.length === 0) { + await this.delay(280); + return; + } + + const rowHeight = 25; + const height = 48 + entries.length * rowHeight; + const panel = this.trackCombatObject(this.add.rectangle(config.x, config.y, config.width, height, 0x0b121a, 0.96)); + panel.setOrigin(0); + panel.setDepth(config.depth); + panel.setStrokeStyle(2, palette.gold, 0.82); + + const title = this.trackCombatObject(this.add.text(config.x + 16, config.y + 12, config.title, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '16px', + color: '#f4dfad', + fontStyle: '700' + })); + title.setDepth(config.depth + 1); + + 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)); + + 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); + } + + for (let index = 0; index < entries.length; index += 1) { + await this.animateGrowthEntry(views[index], entries[index], 540 + index * 70); + if (entries[index].leveled) { + this.flashGrowthLevelUp(views[index], config.x, config.y, config.width, height, config.depth + 4); + } + } + + if (celebration) { + await this.playGrowthCelebration(celebration); + } + } + + private renderGrowthGaugeRow(entry: GrowthGaugeEntry, x: number, y: number, width: number, depth: number): GrowthGaugeView { + const label = this.trackCombatObject(this.add.text(x, y - 3, `${entry.owner} ${entry.label}`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '13px', + color: '#d4dce6', + fontStyle: '700' + })); + label.setDepth(depth); + + const amount = this.trackCombatObject(this.add.text(x + 214, y - 3, entry.amountLabel, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '13px', + color: '#ffdf7b', + fontStyle: '700' + })); + amount.setDepth(depth); + + const barX = x + 270; + const barWidth = Math.max(130, width - 440); + const track = this.trackCombatObject(this.add.rectangle(barX, y + 8, barWidth, 9, 0x05090e, 0.92)); + track.setOrigin(0, 0.5); + track.setDepth(depth); + const fill = this.trackCombatObject(this.add.rectangle(barX, y + 8, barWidth, 7, entry.color, 0.98)); + fill.setOrigin(0, 0.5); + fill.setDepth(depth + 1); + fill.setScale(entry.previousExp / entry.previousNext, 1); + + const valueText = this.trackCombatObject(this.add.text(barX + barWidth + 10, y - 3, `${entry.previousExp}/${entry.previousNext}`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '13px', + color: '#f0e4c8', + fontStyle: '700' + })); + valueText.setDepth(depth); + + const levelText = this.trackCombatObject(this.add.text(x + width - 6, y - 3, `Lv ${entry.previousLevel}`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '13px', + color: '#f4dfad', + fontStyle: '700' + })); + levelText.setOrigin(1, 0); + levelText.setDepth(depth); + + const eventText = this.trackCombatObject(this.add.text(barX + Math.floor(barWidth / 2), y - 12, '', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '15px', + color: '#fff2b8', + fontStyle: '700', + stroke: '#05070a', + strokeThickness: 3 + })); + eventText.setOrigin(0.5); + eventText.setDepth(depth + 3); + + return { fill, valueText, levelText, eventText }; + } + + private async animateGrowthEntry(view: GrowthGaugeView, entry: GrowthGaugeEntry, duration: number) { + if (entry.leveled) { + await this.animateGrowthGauge(view.fill, view.valueText, entry.previousExp / entry.previousNext, 1, entry.previousExp, entry.previousNext, entry.previousNext, false, duration); + view.levelText.setText(`Lv ${entry.level}`); + view.fill.setScale(0, 1); + view.valueText.setText(`0/${entry.next}`); + this.showGrowthEventText(view.eventText, `Lv ${entry.level} 달성!`); + 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))); + return; + } + + await this.animateGrowthGauge(view.fill, view.valueText, entry.previousExp / entry.previousNext, entry.exp / entry.next, entry.previousExp, entry.exp, entry.next, false, duration); + view.levelText.setText(`Lv ${entry.level}`); + } + + private showGrowthEventText(text: Phaser.GameObjects.Text, label: string) { + text.setText(label); + text.setAlpha(1); + text.setScale(0.9); + this.tweens.add({ + targets: text, + y: text.y - 12, + scale: 1.16, + alpha: 0, + duration: 780, + ease: 'Sine.easeOut' + }); + } + + private flashGrowthLevelUp(view: GrowthGaugeView, x: number, y: number, width: number, height: number, depth: number) { + const flash = this.trackCombatObject(this.add.rectangle(x + width / 2, y + height / 2, width - 10, height - 10, 0xffdf7b, 0.14)); + flash.setDepth(depth); + this.tweens.add({ + targets: flash, + alpha: 0, + scaleX: 1.03, + scaleY: 1.12, + duration: 520, + ease: 'Sine.easeOut', + onComplete: () => flash.destroy() + }); + this.tweens.add({ + targets: [view.levelText, view.valueText], + scale: 1.16, + duration: 130, + yoyo: true, + ease: 'Sine.easeOut' + }); + } + + private createGrowthCelebrationSprite(unit: UnitData, x: number, y: number, depth: number) { + const sprite = this.trackCombatObject( + this.add.sprite(x, y, this.unitActionTexture(unit), this.unitActionFrameIndex('south', this.celebrationPose(unit))) + ); + sprite.setDisplaySize(82, 82); + sprite.setDepth(depth); + return sprite; + } + + private celebrationPose(unit: UnitData): UnitActionPose { + if (unit.id === 'liu-bei') { + return 'strategy'; + } + if (unit.id === 'zhang-fei') { + return 'attack'; + } + return 'item'; + } + + private playGrowthCelebration(sprite: Phaser.GameObjects.Sprite) { + return new Promise((resolve) => { + this.tweens.add({ + targets: sprite, + y: sprite.y - 18, + scale: 1.16, + angle: 3, + duration: 160, + yoyo: true, + repeat: 2, + ease: 'Sine.easeInOut', + onComplete: () => { + sprite.setAngle(0); + resolve(); + } + }); + }); } private combatPortraitKey(unit: UnitData) { @@ -3244,8 +3554,56 @@ export class BattleScene extends Phaser.Scene { ) { target.setScale(Phaser.Math.Clamp(fromRatio, 0, 1), 1); text.setText(`${fromValue} / ${next}`); - if (toValue !== fromValue || leveled) { - soundDirector.playEffect('exp-gain', { volume: leveled ? 0.44 : 0.3, stopAfterMs: duration + 220 }); + + if (leveled) { + soundDirector.playGrowthTick(); + return new Promise((resolve) => { + const firstTracker = { ratio: Phaser.Math.Clamp(fromRatio, 0, 1), value: fromValue }; + this.tweens.add({ + targets: firstTracker, + ratio: 1, + value: next, + duration: Math.floor(duration * 0.62), + ease: 'Sine.easeInOut', + onUpdate: () => { + target.setScale(firstTracker.ratio, 1); + text.setText(`${Math.round(firstTracker.value)} / ${next}`); + }, + onComplete: () => { + soundDirector.playLevelUp(); + target.setScale(0, 1); + text.setText(`0 / ${next}`); + const secondTracker = { ratio: 0, value: 0 }; + this.tweens.add({ + targets: secondTracker, + ratio: Phaser.Math.Clamp(toRatio, 0, 1), + value: toValue, + duration: Math.floor(duration * 0.46), + ease: 'Sine.easeInOut', + onUpdate: () => { + target.setScale(secondTracker.ratio, 1); + text.setText(`${Math.round(secondTracker.value)} / ${next}`); + }, + onComplete: () => { + target.setScale(Phaser.Math.Clamp(toRatio, 0, 1), 1); + text.setText(`${toValue} / ${next}`); + this.tweens.add({ + targets: text, + scale: 1.18, + duration: 120, + yoyo: true, + ease: 'Sine.easeOut' + }); + resolve(); + } + }); + } + }); + }); + } + + if (toValue !== fromValue) { + soundDirector.playGrowthTick(); } return new Promise((resolve) => { @@ -3464,26 +3822,40 @@ export class BattleScene extends Phaser.Scene { const partners = this.attackIntents .filter((intent) => intent.targetId === target.id && intent.attackerId !== unit.id) .map((intent) => intent.attackerId); - let gained = 0; + const growthResults: BondGrowthResult[] = []; partners.forEach((partnerId) => { const bond = this.findBond(unit.id, partnerId); if (!bond) { return; } + const previousLevel = bond.level; + const previousExp = bond.exp; + let leveled = false; bond.battleExp += 4; bond.exp += 4; while (bond.exp >= 100) { bond.exp -= 100; bond.level = Math.min(100, bond.level + 1); + leveled = true; } - gained += 4; + const [first, second] = bond.unitIds.map((unitId) => this.unitName(unitId)); + growthResults.push({ + label: `${first}·${second}`, + amount: 4, + previousLevel, + previousExp, + level: bond.level, + exp: bond.exp, + next: 100, + leveled + }); }); this.attackIntents = this.attackIntents.filter((intent) => intent.attackerId !== unit.id); this.attackIntents.push({ attackerId: unit.id, targetId: target.id }); - return gained; + return growthResults; } private recordAttackIntent(unit: UnitData) {