diff --git a/package.json b/package.json index 55c3ed7..9ff2cf8 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "debug": "vite --host 0.0.0.0 --debug", "build": "tsc && vite build", "generate:audio": "node scripts/generate-bgm.mjs", + "generate:sfx": "node scripts/generate-sfx.mjs", "preview": "vite preview --host 0.0.0.0", "verify:flow": "node scripts/verify-flow.mjs" }, diff --git a/scripts/generate-sfx.mjs b/scripts/generate-sfx.mjs new file mode 100644 index 0000000..9c5bce8 --- /dev/null +++ b/scripts/generate-sfx.mjs @@ -0,0 +1,78 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +const sampleRate = 44100; +const outputPath = join('src', 'assets', 'audio', 'sfx', 'level-up.wav'); + +function envelope(time, start, duration) { + const local = time - start; + if (local < 0 || local > duration) { + return 0; + } + const attack = 0.018; + const release = 0.22; + if (local < attack) { + return local / attack; + } + if (local > duration - release) { + return Math.max(0, (duration - local) / release); + } + return 1; +} + +function tone(time, frequency, start, duration, gain, wave = 'sine') { + const amp = envelope(time, start, duration) * gain; + if (amp <= 0) { + return 0; + } + const phase = Math.PI * 2 * frequency * (time - start); + if (wave === 'triangle') { + return amp * (2 / Math.PI) * Math.asin(Math.sin(phase)); + } + return amp * Math.sin(phase); +} + +function sampleAt(time) { + const notes = [ + { start: 0, frequency: 523.25, gain: 0.22, duration: 0.52 }, + { start: 0.09, frequency: 659.25, gain: 0.2, duration: 0.56 }, + { start: 0.18, frequency: 783.99, gain: 0.18, duration: 0.62 }, + { start: 0.31, frequency: 1046.5, gain: 0.16, duration: 0.7 } + ]; + + const chord = notes.reduce((sum, note) => sum + tone(time, note.frequency, note.start, note.duration, note.gain), 0); + const shimmer = + tone(time, 1318.51, 0.42, 0.36, 0.055, 'triangle') + + tone(time, 1567.98, 0.52, 0.28, 0.042, 'triangle'); + return Math.max(-0.92, Math.min(0.92, chord + shimmer)); +} + +function wavBuffer(samples) { + const bytesPerSample = 2; + const dataSize = samples.length * bytesPerSample; + const buffer = Buffer.alloc(44 + dataSize); + buffer.write('RIFF', 0); + buffer.writeUInt32LE(36 + dataSize, 4); + buffer.write('WAVE', 8); + buffer.write('fmt ', 12); + buffer.writeUInt32LE(16, 16); + buffer.writeUInt16LE(1, 20); + buffer.writeUInt16LE(1, 22); + buffer.writeUInt32LE(sampleRate, 24); + buffer.writeUInt32LE(sampleRate * bytesPerSample, 28); + buffer.writeUInt16LE(bytesPerSample, 32); + buffer.writeUInt16LE(16, 34); + buffer.write('data', 36); + buffer.writeUInt32LE(dataSize, 40); + + samples.forEach((sample, index) => { + buffer.writeInt16LE(Math.round(sample * 32767), 44 + index * bytesPerSample); + }); + return buffer; +} + +await mkdir(dirname(outputPath), { recursive: true }); +const duration = 1.12; +const samples = Array.from({ length: Math.floor(sampleRate * duration) }, (_, index) => sampleAt(index / sampleRate)); +await writeFile(outputPath, wavBuffer(samples)); +console.log(`Generated ${outputPath}`); diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index f2acfc9..e84689a 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -101,7 +101,15 @@ try { 'unit-rebel-actions', 'unit-rebel-archer-actions', 'unit-rebel-cavalry-actions', - 'unit-rebel-leader-actions' + 'unit-rebel-leader-actions', + 'unit-shu-cavalry', + 'unit-shu-cavalry-actions', + 'unit-wei-infantry', + 'unit-wei-infantry-actions', + 'unit-wu-archer', + 'unit-wu-archer-actions', + 'unit-nanman-officer', + 'unit-nanman-officer-actions' ].every((key) => textures?.exists(key)); }); if (!actionTexturesLoaded) { diff --git a/src/assets/audio/sfx/level-up.wav b/src/assets/audio/sfx/level-up.wav new file mode 100644 index 0000000..a64fc66 Binary files /dev/null and b/src/assets/audio/sfx/level-up.wav differ diff --git a/src/game/audio/SoundDirector.ts b/src/game/audio/SoundDirector.ts index 42fe4db..2102c41 100644 --- a/src/game/audio/SoundDirector.ts +++ b/src/game/audio/SoundDirector.ts @@ -99,6 +99,10 @@ class SoundDirector { } playLevelUp() { + if (this.playEffect('level-up', { volume: 0.36, stopAfterMs: 1200 })) { + return; + } + 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); diff --git a/src/game/audio/audioAssets.ts b/src/game/audio/audioAssets.ts index b9bf9bc..aa78094 100644 --- a/src/game/audio/audioAssets.ts +++ b/src/game/audio/audioAssets.ts @@ -8,6 +8,7 @@ import combatImpactUrl from '../../assets/audio/sfx/combat-impact.mp3'; import expGainUrl from '../../assets/audio/sfx/exp-gain.mp3'; import footstepWalkUrl from '../../assets/audio/sfx/footstep-walk.mp3'; import horseGallopUrl from '../../assets/audio/sfx/horse-gallop.mp3'; +import levelUpUrl from '../../assets/audio/sfx/level-up.wav'; import strategyCastUrl from '../../assets/audio/sfx/strategy-cast.mp3'; import swordSlashUrl from '../../assets/audio/sfx/sword-slash.mp3'; import uiSelectUrl from '../../assets/audio/sfx/ui-select.mp3'; @@ -27,6 +28,7 @@ export const effectTracks = { 'strategy-cast': strategyCastUrl, 'cart-roll': cartRollUrl, 'exp-gain': expGainUrl, + 'level-up': levelUpUrl, 'footstep-walk': footstepWalkUrl, 'horse-gallop': horseGallopUrl } as const; diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index ef8624b..a08edac 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -929,6 +929,23 @@ const unitTextureByClass: Partial> = { rebelLeader: 'unit-rebel-leader' }; +const coreAllyTextureIds = new Set(['liu-bei', 'guan-yu', 'zhang-fei']); + +const earlyRebelTexturePrefixes = ['rebel-', 'pursuit-']; + +const wuTexturePrefixes = [ + 'jing-defense-wu-', + 'jing-rear-', + 'jing-collapse-', + 'maicheng-wu-', + 'yiling-' +]; + +const nanmanTexturePrefixes = [ + 'nanzhong-', + 'menghuo-' +]; + const unitFrameRows: Record = { south: 0, east: 1, @@ -1128,6 +1145,7 @@ type ResultGaugeAnimation = { valueText: Phaser.GameObjects.Text; levelText: Phaser.GameObjects.Text; eventText: Phaser.GameObjects.Text; + unit?: UnitData; levelPrefix: string; previousLevel: number; previousExp: number; @@ -4797,6 +4815,7 @@ export class BattleScene extends Phaser.Scene { valueText: characterValueText, levelText: characterLevelText, eventText: characterEventText, + unit, levelPrefix: 'Lv ', previousLevel, previousExp, @@ -5001,7 +5020,7 @@ export class BattleScene extends Phaser.Scene { this.showGrowthEventText(animation.eventText, `Lv ${animation.level} 달성!`); this.flashResultGauge(animation); soundDirector.playLevelUp(); - await this.delay(180); + await this.playResultLevelUpCelebration(animation); await this.animateGrowthGauge(animation.fill, animation.valueText, 0, animation.exp / animation.next, 0, animation.exp, animation.next, false, duration); return; } @@ -5027,17 +5046,61 @@ export class BattleScene extends Phaser.Scene { this.tweens.add({ targets: flash, alpha: 0, - scaleX: 1.04, - scaleY: 1.16, + x: flash.x + 10, duration: 520, ease: 'Sine.easeOut' }); - this.tweens.add({ - targets: [animation.levelText, animation.valueText], - scale: 1.14, - duration: 120, - yoyo: true, - ease: 'Sine.easeOut' + this.flashTextColor(animation.levelText, '#fff2b8'); + this.flashTextColor(animation.valueText, '#fff2b8'); + } + + 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 - 46, animation.valueText.x + 34); + const spriteY = y + height / 2 + 4; + const sprite = this.trackResultObject( + this.add.sprite(spriteX, spriteY, this.unitActionTexture(animation.unit), this.unitActionFrameIndex('south', 'attack')) + ); + sprite.setDisplaySize(58, 58); + sprite.setDepth(depth + 4); + await this.playUnitCelebrationFrames(sprite); + } + + private flashTextColor(text: Phaser.GameObjects.Text, color: string, duration = 360) { + const previousColor = typeof text.style.color === 'string' ? text.style.color : '#f0e4c8'; + text.setColor(color); + this.time.delayedCall(duration, () => { + if (text.active) { + text.setColor(previousColor); + } + }); + } + + private playUnitCelebrationFrames(sprite: Phaser.GameObjects.Sprite) { + const baseY = sprite.y; + const poses: UnitActionPose[] = ['attack', 'item', 'strategy', 'attack', 'item']; + return new Promise((resolve) => { + poses.forEach((pose, index) => { + this.time.delayedCall(index * 120, () => { + if (!sprite.active) { + return; + } + sprite.setFrame(this.unitActionFrameIndex('south', pose)); + sprite.setY(baseY + (index % 2 === 0 ? -5 : 3)); + }); + }); + this.time.delayedCall(poses.length * 120 + 80, () => { + if (sprite.active) { + sprite.setY(baseY); + sprite.setFrame(this.unitActionFrameIndex('south', 'item')); + } + resolve(); + }); }); } @@ -7061,11 +7124,10 @@ export class BattleScene extends Phaser.Scene { private showGrowthEventText(text: Phaser.GameObjects.Text, label: string) { text.setText(label); text.setAlpha(1); - text.setScale(0.9); + text.setScale(1); this.tweens.add({ targets: text, y: text.y - 12, - scale: 1.16, alpha: 0, duration: 780, ease: 'Sine.easeOut' @@ -7078,19 +7140,13 @@ export class BattleScene extends Phaser.Scene { this.tweens.add({ targets: flash, alpha: 0, - scaleX: 1.03, - scaleY: 1.12, + x: flash.x + 10, 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' - }); + this.flashTextColor(view.levelText, '#fff2b8'); + this.flashTextColor(view.valueText, '#fff2b8'); } private createGrowthCelebrationSprite(unit: UnitData, x: number, y: number, depth: number) { @@ -7113,22 +7169,7 @@ export class BattleScene extends Phaser.Scene { } 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(); - } - }); - }); + return this.playUnitCelebrationFrames(sprite); } private combatPortraitKey(unit: UnitData) { @@ -7429,13 +7470,7 @@ export class BattleScene extends Phaser.Scene { 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' - }); + this.flashTextColor(text, '#fff2b8'); resolve(); } }); @@ -7464,13 +7499,7 @@ export class BattleScene extends Phaser.Scene { target.setScale(Phaser.Math.Clamp(toRatio, 0, 1), 1); text.setText(`${toValue} / ${next}`); if (leveled) { - this.tweens.add({ - targets: text, - scale: 1.18, - duration: 120, - yoyo: true, - ease: 'Sine.easeOut' - }); + this.flashTextColor(text, '#fff2b8'); } resolve(); } @@ -8094,6 +8123,11 @@ export class BattleScene extends Phaser.Scene { } private unitTextureKey(unit: UnitData) { + const factionTexture = this.unitFactionTextureKey(unit); + if (factionTexture) { + return factionTexture; + } + if ( unit.id.startsWith('northern-first-cavalry') || unit.id.startsWith('northern-second-cavalry') || @@ -8124,6 +8158,72 @@ export class BattleScene extends Phaser.Scene { return unitTexture[unit.id] ?? unitTextureByClass[unit.classKey] ?? 'unit-rebel'; } + private unitFactionTextureKey(unit: UnitData) { + if (unit.faction === 'ally') { + if (coreAllyTextureIds.has(unit.id)) { + return undefined; + } + return this.unitTextureForClass('shu', unit.classKey); + } + + if (earlyRebelTexturePrefixes.some((prefix) => unit.id.startsWith(prefix))) { + return undefined; + } + if (nanmanTexturePrefixes.some((prefix) => unit.id.startsWith(prefix))) { + return this.unitTextureForClass('nanman', unit.classKey); + } + if (wuTexturePrefixes.some((prefix) => unit.id.startsWith(prefix))) { + return this.unitTextureForClass('wu', unit.classKey); + } + + return this.unitTextureForClass('wei', unit.classKey); + } + + private unitTextureForClass(style: 'shu' | 'wei' | 'wu' | 'nanman', classKey: UnitClassKey) { + if (style === 'shu') { + if (classKey === 'spearman') { + return 'unit-shu-spearman'; + } + if (classKey === 'archer') { + return 'unit-shu-archer'; + } + if (classKey === 'cavalry') { + return 'unit-shu-cavalry'; + } + if (classKey === 'strategist' || classKey === 'quartermaster') { + return 'unit-shu-strategist'; + } + if (classKey === 'rebelLeader') { + return 'unit-shu-officer'; + } + return 'unit-shu-infantry'; + } + + if (style === 'nanman') { + if (classKey === 'archer' || classKey === 'strategist' || classKey === 'quartermaster') { + return 'unit-nanman-shaman'; + } + if (classKey === 'rebelLeader' || classKey === 'cavalry') { + return 'unit-nanman-officer'; + } + return 'unit-nanman-infantry'; + } + + if (classKey === 'archer') { + return `unit-${style}-archer`; + } + if (classKey === 'cavalry') { + return `unit-${style}-cavalry`; + } + if (classKey === 'strategist' || classKey === 'quartermaster') { + return `unit-${style}-strategist`; + } + if (classKey === 'rebelLeader') { + return `unit-${style}-officer`; + } + return `unit-${style}-infantry`; + } + private unitActionTexture(unit: UnitData) { const base = this.unitTextureKey(unit); const actionKey = `${base}-actions`; @@ -9115,6 +9215,8 @@ export class BattleScene extends Phaser.Scene { name: unit.name, faction: unit.faction, classKey: unit.classKey, + textureBase: this.unitViews.get(unit.id)?.textureBase ?? this.unitTextureKey(unit), + actionTexture: this.unitActionTexture(unit), ai: unit.faction === 'enemy' ? this.enemyBehavior(unit) : null, attackRange: this.attackRange(unit), level: unit.level, diff --git a/src/game/scenes/BootScene.ts b/src/game/scenes/BootScene.ts index da52494..6fb90bd 100644 --- a/src/game/scenes/BootScene.ts +++ b/src/game/scenes/BootScene.ts @@ -48,7 +48,7 @@ const unitSheetRows: Record = { west: 3 }; -const unitSheets = [ +const sourceUnitSheets = [ { key: 'unit-liu-bei', url: liuBeiUnitSheetUrl, actionKey: 'unit-liu-bei-actions', actionUrl: liuBeiActionSheetUrl }, { key: 'unit-guan-yu', url: guanYuUnitSheetUrl, actionKey: 'unit-guan-yu-actions', actionUrl: guanYuActionSheetUrl }, { key: 'unit-zhang-fei', url: zhangFeiUnitSheetUrl, actionKey: 'unit-zhang-fei-actions', actionUrl: zhangFeiActionSheetUrl }, @@ -58,6 +58,30 @@ const unitSheets = [ { key: 'unit-rebel-leader', url: rebelLeaderUnitSheetUrl, actionKey: 'unit-rebel-leader-actions', actionUrl: rebelLeaderActionSheetUrl } ]; +const generatedUnitSheets = [ + { key: 'unit-shu-infantry', sourceKey: 'unit-guan-yu', actionKey: 'unit-shu-infantry-actions', sourceActionKey: 'unit-guan-yu-actions', tint: 0x2f7fba, alpha: 0.32 }, + { key: 'unit-shu-spearman', sourceKey: 'unit-zhang-fei', actionKey: 'unit-shu-spearman-actions', sourceActionKey: 'unit-zhang-fei-actions', tint: 0x4aa878, alpha: 0.3 }, + { key: 'unit-shu-archer', sourceKey: 'unit-rebel-archer', actionKey: 'unit-shu-archer-actions', sourceActionKey: 'unit-rebel-archer-actions', tint: 0x4b9bd5, alpha: 0.36 }, + { key: 'unit-shu-cavalry', sourceKey: 'unit-rebel-cavalry', actionKey: 'unit-shu-cavalry-actions', sourceActionKey: 'unit-rebel-cavalry-actions', tint: 0x488fc4, alpha: 0.36 }, + { key: 'unit-shu-strategist', sourceKey: 'unit-liu-bei', actionKey: 'unit-shu-strategist-actions', sourceActionKey: 'unit-liu-bei-actions', tint: 0x75b7d8, alpha: 0.28 }, + { key: 'unit-shu-officer', sourceKey: 'unit-guan-yu', actionKey: 'unit-shu-officer-actions', sourceActionKey: 'unit-guan-yu-actions', tint: 0x3f91c8, alpha: 0.36 }, + { key: 'unit-wei-infantry', sourceKey: 'unit-rebel', actionKey: 'unit-wei-infantry-actions', sourceActionKey: 'unit-rebel-actions', tint: 0x8b3030, alpha: 0.42 }, + { key: 'unit-wei-archer', sourceKey: 'unit-rebel-archer', actionKey: 'unit-wei-archer-actions', sourceActionKey: 'unit-rebel-archer-actions', tint: 0x9d3c3c, alpha: 0.4 }, + { key: 'unit-wei-cavalry', sourceKey: 'unit-rebel-cavalry', actionKey: 'unit-wei-cavalry-actions', sourceActionKey: 'unit-rebel-cavalry-actions', tint: 0x7a3540, alpha: 0.44 }, + { key: 'unit-wei-strategist', sourceKey: 'unit-rebel-archer', actionKey: 'unit-wei-strategist-actions', sourceActionKey: 'unit-rebel-archer-actions', tint: 0x7f5a8e, alpha: 0.38 }, + { key: 'unit-wei-officer', sourceKey: 'unit-rebel-leader', actionKey: 'unit-wei-officer-actions', sourceActionKey: 'unit-rebel-leader-actions', tint: 0x8f3940, alpha: 0.44 }, + { key: 'unit-wu-infantry', sourceKey: 'unit-rebel', actionKey: 'unit-wu-infantry-actions', sourceActionKey: 'unit-rebel-actions', tint: 0x0b8b86, alpha: 0.42 }, + { key: 'unit-wu-archer', sourceKey: 'unit-rebel-archer', actionKey: 'unit-wu-archer-actions', sourceActionKey: 'unit-rebel-archer-actions', tint: 0x169b91, alpha: 0.4 }, + { key: 'unit-wu-cavalry', sourceKey: 'unit-rebel-cavalry', actionKey: 'unit-wu-cavalry-actions', sourceActionKey: 'unit-rebel-cavalry-actions', tint: 0x087b86, alpha: 0.44 }, + { key: 'unit-wu-strategist', sourceKey: 'unit-rebel-archer', actionKey: 'unit-wu-strategist-actions', sourceActionKey: 'unit-rebel-archer-actions', tint: 0x3aa57e, alpha: 0.38 }, + { key: 'unit-wu-officer', sourceKey: 'unit-rebel-leader', actionKey: 'unit-wu-officer-actions', sourceActionKey: 'unit-rebel-leader-actions', tint: 0x168f83, alpha: 0.44 }, + { key: 'unit-nanman-infantry', sourceKey: 'unit-rebel', actionKey: 'unit-nanman-infantry-actions', sourceActionKey: 'unit-rebel-actions', tint: 0x8b6c2f, alpha: 0.42 }, + { key: 'unit-nanman-shaman', sourceKey: 'unit-rebel-archer', actionKey: 'unit-nanman-shaman-actions', sourceActionKey: 'unit-rebel-archer-actions', tint: 0x6e8a3a, alpha: 0.42 }, + { key: 'unit-nanman-officer', sourceKey: 'unit-rebel-leader', actionKey: 'unit-nanman-officer-actions', sourceActionKey: 'unit-rebel-leader-actions', tint: 0x9a6a2e, alpha: 0.46 } +]; + +const animatedUnitSheets = [...sourceUnitSheets, ...generatedUnitSheets].map(({ key }) => ({ key })); + export class BootScene extends Phaser.Scene { constructor() { super('BootScene'); @@ -90,7 +114,7 @@ export class BootScene extends Phaser.Scene { this.load.image(key, url); }); - unitSheets.forEach(({ key, url, actionKey, actionUrl }) => { + sourceUnitSheets.forEach(({ key, url, actionKey, actionUrl }) => { this.load.spritesheet(key, url, { frameWidth: unitSheetFrameSize, frameHeight: unitSheetFrameSize @@ -105,6 +129,7 @@ export class BootScene extends Phaser.Scene { create() { this.createPetalTexture(); this.createEquipmentIconTextures(); + this.createGeneratedUnitSheets(); this.createUnitAnimations(); this.scene.start('TitleScene'); } @@ -369,8 +394,42 @@ export class BootScene extends Phaser.Scene { graphics.strokePath(); } + private createGeneratedUnitSheets() { + generatedUnitSheets.forEach((sheet) => { + this.createTintedUnitSheet(sheet.key, sheet.sourceKey, sheet.tint, sheet.alpha); + this.createTintedUnitSheet(sheet.actionKey, sheet.sourceActionKey, sheet.tint, sheet.alpha); + }); + } + + private createTintedUnitSheet(targetKey: string, sourceKey: string, tint: number, alpha: number) { + if (this.textures.exists(targetKey) || !this.textures.exists(sourceKey)) { + return; + } + + const sourceImage = this.textures.get(sourceKey).getSourceImage() as CanvasImageSource & { width: number; height: number }; + const canvas = document.createElement('canvas'); + canvas.width = sourceImage.width; + canvas.height = sourceImage.height; + const context = canvas.getContext('2d'); + if (!context) { + return; + } + + context.drawImage(sourceImage, 0, 0); + context.globalCompositeOperation = 'source-atop'; + context.globalAlpha = alpha; + context.fillStyle = `#${tint.toString(16).padStart(6, '0')}`; + context.fillRect(0, 0, canvas.width, canvas.height); + context.globalCompositeOperation = 'source-over'; + context.globalAlpha = 1; + this.textures.addSpriteSheet(targetKey, canvas as unknown as HTMLImageElement, { + frameWidth: unitSheetFrameSize, + frameHeight: unitSheetFrameSize + }); + } + private createUnitAnimations() { - unitSheets.forEach(({ key }) => { + animatedUnitSheets.forEach(({ key }) => { (['south', 'east', 'north', 'west'] as UnitDirection[]).forEach((direction) => { const frames = Array.from({ length: unitWalkFrameCount }, (_, frameIndex) => ({ key,