Add animated combat growth rewards

This commit is contained in:
2026-06-22 15:38:41 +09:00
parent ce8050c5cc
commit 305484a7c3
2 changed files with 424 additions and 37 deletions

View File

@@ -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;

View File

@@ -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<void>((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<void>((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<void>((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) {