From 61259b9db7af8f14d69d2412b720221236117995 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sun, 28 Jun 2026 16:35:17 +0900 Subject: [PATCH] Enhance combat growth feedback --- src/game/scenes/BattleScene.ts | 310 ++++++++++++++++++++++++++++++--- 1 file changed, 289 insertions(+), 21 deletions(-) diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 2a0006e..3b0d12a 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -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];