diff --git a/docs/audio-sources.md b/docs/audio-sources.md index 445eb63..d3049a0 100644 --- a/docs/audio-sources.md +++ b/docs/audio-sources.md @@ -12,7 +12,7 @@ License reference: https://pixabay.com/service/license-summary/ | `src/assets/audio/bgm/story-dark.mp3` | Chinese, Asia, China music | Reklamistrz | https://pixabay.com/music/meditationspiritual-ancient-chinese-dali-448882/ | | `src/assets/audio/bgm/oath-theme.mp3` | Chinese ancient style, Ancient style, World music | ET11LX | https://pixabay.com/music/world-chinese-ancient-style-music-love-et%E5%8D%81%E4%B8%80lx-%E5%8F%A4%E9%A3%8E-%E6%8A%92%E6%83%85-%E8%AF%86%E4%B8%B0%E5%AF%8C%E9%85%8D%E5%99%A8%E6%8E%A8%E9%AB%98%E6%BD%AE%E7%89%88-247345/ | | `src/assets/audio/bgm/militia-theme.mp3` | Orchestra, Heroic, Warrior music | NR-Music | https://pixabay.com/music/adventure-short-heroic-orchestral-loop-541095/ | -| `src/assets/audio/bgm/battle-prep.mp3` | Epic, Battle, Knight music | DeusLower | https://pixabay.com/music/mystery-epic-battle-orchestra-music-241006/ | +| `src/assets/audio/bgm/battle-prep.mp3` | Epic orchestral, War, Orchestral music | Sonican | https://pixabay.com/music/main-title-epic-for-war-orchestral-loop-364200/ | ## Sound Effects @@ -24,3 +24,5 @@ License reference: https://pixabay.com/service/license-summary/ | `src/assets/audio/sfx/strategy-cast.mp3` | Designed, Magic spell, Projectile sound effect | RescopicSound | https://pixabay.com/sound-effects/film-special-effects-elemental-magic-spell-impact-outgoing-228342/ | | `src/assets/audio/sfx/cart-roll.mp3` | Cart, Wheel, Cobblestones sound effect | Fronbondi_Skegs | https://pixabay.com/sound-effects/film-special-effects-foley-heavily-laden-cart-moving-over-cobblestones-sound-effect-246657/ | | `src/assets/audio/sfx/exp-gain.mp3` | Level up, Level up sound, Game level up sound effect | Universfield | https://pixabay.com/sound-effects/film-special-effects-level-up-06-370051/ | +| `src/assets/audio/sfx/footstep-walk.mp3` | Walking, Grass, Footsteps sound effect | vgraham1 (Freesound) | https://pixabay.com/sound-effects/film-special-effects-walking-through-grass-80308/ | +| `src/assets/audio/sfx/horse-gallop.mp3` | Horse, Galloping, Horse sound sound effect | DRAGON-STUDIO | https://pixabay.com/sound-effects/nature-horse-gallop-sfx-339736/ | diff --git a/src/assets/audio/bgm/battle-prep.mp3 b/src/assets/audio/bgm/battle-prep.mp3 index 8fd306d..864392e 100644 Binary files a/src/assets/audio/bgm/battle-prep.mp3 and b/src/assets/audio/bgm/battle-prep.mp3 differ diff --git a/src/assets/audio/sfx/footstep-walk.mp3 b/src/assets/audio/sfx/footstep-walk.mp3 new file mode 100644 index 0000000..3a552a2 Binary files /dev/null and b/src/assets/audio/sfx/footstep-walk.mp3 differ diff --git a/src/assets/audio/sfx/horse-gallop.mp3 b/src/assets/audio/sfx/horse-gallop.mp3 new file mode 100644 index 0000000..47f02bf Binary files /dev/null and b/src/assets/audio/sfx/horse-gallop.mp3 differ diff --git a/src/game/audio/audioAssets.ts b/src/game/audio/audioAssets.ts index f87a9ca..b9bf9bc 100644 --- a/src/game/audio/audioAssets.ts +++ b/src/game/audio/audioAssets.ts @@ -6,6 +6,8 @@ import titleThemeUrl from '../../assets/audio/bgm/title-theme.mp3'; import cartRollUrl from '../../assets/audio/sfx/cart-roll.mp3'; 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 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'; @@ -24,7 +26,9 @@ export const effectTracks = { 'combat-impact': combatImpactUrl, 'strategy-cast': strategyCastUrl, 'cart-roll': cartRollUrl, - 'exp-gain': expGainUrl + 'exp-gain': expGainUrl, + 'footstep-walk': footstepWalkUrl, + 'horse-gallop': horseGallopUrl } as const; export type MusicTrackKey = keyof typeof musicTracks; diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 42c4579..e8ca916 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -64,7 +64,7 @@ type BattleCommand = 'attack' | 'strategy' | 'item' | 'wait'; type DamageCommand = Exclude; type RosterTab = 'ally' | 'enemy'; type ActiveFaction = 'ally' | 'enemy'; -type MapMenuAction = 'endTurn' | 'roster' | 'bond' | 'situation' | 'bgm' | 'close'; +type MapMenuAction = 'endTurn' | 'save' | 'load' | 'roster' | 'bond' | 'situation' | 'bgm' | 'close'; type EnemyAiBehavior = 'aggressive' | 'guard' | 'hold'; type CommandButton = { @@ -130,8 +130,27 @@ type CombatResult = CombatPreview & { bondExp: number; }; +type SavedUnitState = Pick & { + equipment: UnitData['equipment']; + direction?: UnitDirection; +}; + +type BattleSaveState = { + version: 1; + savedAt: string; + turnNumber: number; + activeFaction: ActiveFaction; + rosterTab: RosterTab; + actedUnitIds: string[]; + attackIntents: AttackIntent[]; + battleLog: string[]; + units: SavedUnitState[]; + bonds: BondState[]; +}; + const maxEquipmentLevel = 9; const maxCharacterLevel = 99; +const battleSaveStorageKey = 'heros-web:first-battle-state'; const attackRangeByClass: Partial> = { archer: 2 @@ -170,7 +189,7 @@ const factionLabels: Record = { enemy: '적군' }; -const mapMenuLabels: Record = { +const mapMenuLabels: Partial> = { endTurn: '턴 종료', roster: '부대 일람', bond: '공명', @@ -885,6 +904,21 @@ export class BattleScene extends Phaser.Scene { return `${result.attacker.name} ${commandLabels[result.action]}\n${result.defender.name}에게 ${result.damage} 피해${defeated}\n장수 경험치 +${result.characterGrowth.amount}\n${this.formatEquipmentGrowth(result.attackerGrowth)}\n${this.formatEquipmentGrowth(result.defenderGrowth)}${bond}`; } + private combatGrowthLines(result: CombatResult) { + const characterLevel = result.characterGrowth.leveled ? ` / Lv ${result.characterGrowth.level}` : ''; + const lines = [ + `장수 경험치 +${result.characterGrowth.amount} (${result.characterGrowth.exp}/${result.characterGrowth.next})${characterLevel}`, + this.formatEquipmentGrowth(result.attackerGrowth), + this.formatEquipmentGrowth(result.defenderGrowth) + ]; + + if (result.bondExp > 0) { + lines.push(`공명 경험치 +${result.bondExp}`); + } + + return lines; + } + private commandResultMessage(unit: UnitData, command: BattleCommand) { if (command === 'attack') { return this.recordAttackIntent(unit); @@ -976,7 +1010,7 @@ export class BattleScene extends Phaser.Scene { this.pendingMove = undefined; this.phase = 'idle'; - const actions: MapMenuAction[] = ['endTurn', 'roster', 'bond', 'situation', 'bgm', 'close']; + const actions: MapMenuAction[] = ['endTurn', 'save', 'load', 'roster', 'bond', 'situation', 'bgm', 'close']; const menuWidth = 148; const buttonHeight = 34; const padding = 8; @@ -992,14 +1026,17 @@ export class BattleScene extends Phaser.Scene { actions.forEach((action, index) => { const y = top + padding + index * buttonHeight; - const disabled = action === 'endTurn' && this.activeFaction !== 'ally'; + const disabled = + (action === 'endTurn' && this.activeFaction !== 'ally') || + ((action === 'save' || action === 'load') && this.activeFaction !== 'ally') || + (action === 'load' && !this.hasBattleSave()); const bg = this.add.rectangle(left + 8, y, menuWidth - 16, buttonHeight - 4, disabled ? 0x121820 : 0x1a2630, disabled ? 0.72 : 0.94); bg.setOrigin(0); bg.setDepth(21); bg.setStrokeStyle(1, action === 'endTurn' ? 0xd8b15f : 0x5a7588, disabled ? 0.32 : 0.68); this.mapMenuObjects.push(bg); - const text = this.add.text(left + menuWidth / 2, y + buttonHeight / 2 - 2, mapMenuLabels[action], { + const text = this.add.text(left + menuWidth / 2, y + buttonHeight / 2 - 2, this.mapMenuLabel(action), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '17px', color: disabled ? '#737b84' : '#f2e3bf', @@ -1029,6 +1066,14 @@ export class BattleScene extends Phaser.Scene { this.endAllyTurn(); return; } + if (action === 'save') { + this.saveBattleState(); + return; + } + if (action === 'load') { + this.loadBattleState(); + return; + } if (action === 'roster') { this.renderRosterPanel('ally', '부대 일람입니다.'); return; @@ -1052,6 +1097,152 @@ export class BattleScene extends Phaser.Scene { this.mapMenuObjects = []; } + private mapMenuLabel(action: MapMenuAction) { + if (action === 'save') { + return '저장'; + } + if (action === 'load') { + return '불러오기'; + } + return mapMenuLabels[action] ?? action; + } + + private hasBattleSave() { + return Boolean(window.localStorage.getItem(battleSaveStorageKey)); + } + + private saveBattleState() { + try { + const state = this.createBattleSaveState(); + window.localStorage.setItem(battleSaveStorageKey, JSON.stringify(state)); + this.renderSituationPanel(`전투 상태를 저장했습니다.\n${this.formatSavedAt(state.savedAt)}`); + } catch { + this.renderSituationPanel('전투 상태를 저장하지 못했습니다.'); + } + } + + private loadBattleState() { + const raw = window.localStorage.getItem(battleSaveStorageKey); + if (!raw) { + this.renderSituationPanel('불러올 저장 데이터가 없습니다.'); + return; + } + + try { + const state = JSON.parse(raw) as BattleSaveState; + if (!state || state.version !== 1 || !Array.isArray(state.units)) { + throw new Error('Invalid battle save'); + } + this.applyBattleSaveState(state); + this.renderSituationPanel(`저장된 전투를 불러왔습니다.\n${this.formatSavedAt(state.savedAt)}`); + } catch { + this.renderSituationPanel('저장 데이터를 불러오지 못했습니다.'); + } + } + + private createBattleSaveState(): BattleSaveState { + return { + version: 1, + savedAt: new Date().toISOString(), + turnNumber: this.turnNumber, + activeFaction: this.activeFaction, + rosterTab: this.rosterTab, + actedUnitIds: Array.from(this.actedUnitIds), + attackIntents: this.attackIntents.map((intent) => ({ ...intent })), + battleLog: [...this.battleLog], + units: firstBattleUnits.map((unit) => ({ + id: unit.id, + level: unit.level, + exp: unit.exp, + hp: unit.hp, + maxHp: unit.maxHp, + attack: unit.attack, + move: unit.move, + x: unit.x, + y: unit.y, + direction: this.unitViews.get(unit.id)?.direction, + equipment: this.cloneEquipment(unit.equipment) + })), + bonds: Array.from(this.bondStates.values()).map((bond) => ({ + ...bond, + unitIds: [...bond.unitIds] as [string, string] + })) + }; + } + + private applyBattleSaveState(state: BattleSaveState) { + this.clearMarkers(); + this.hideCommandMenu(); + this.hideTurnEndPrompt(); + this.hideCombatCutIn(); + this.selectedUnit = undefined; + this.pendingMove = undefined; + this.targetingAction = undefined; + this.phase = 'idle'; + + this.turnNumber = Math.max(1, state.turnNumber || 1); + this.activeFaction = state.activeFaction === 'enemy' ? 'enemy' : 'ally'; + this.rosterTab = state.rosterTab === 'enemy' ? 'enemy' : 'ally'; + this.actedUnitIds = new Set(state.actedUnitIds ?? []); + this.attackIntents = (state.attackIntents ?? []).map((intent) => ({ ...intent })); + this.battleLog = [...(state.battleLog ?? [])]; + this.bondStates = new Map( + (state.bonds ?? []).map((bond) => [ + bond.id, + { + ...bond, + unitIds: [...bond.unitIds] as [string, string], + battleExp: bond.battleExp ?? 0 + } + ]) + ); + + state.units.forEach((savedUnit) => { + const unit = firstBattleUnits.find((candidate) => candidate.id === savedUnit.id); + if (!unit) { + return; + } + + unit.level = savedUnit.level; + unit.exp = savedUnit.exp; + unit.hp = Phaser.Math.Clamp(savedUnit.hp, 0, savedUnit.maxHp); + unit.maxHp = savedUnit.maxHp; + unit.attack = savedUnit.attack; + unit.move = savedUnit.move; + unit.x = Phaser.Math.Clamp(savedUnit.x, 0, firstBattleMap.width - 1); + unit.y = Phaser.Math.Clamp(savedUnit.y, 0, firstBattleMap.height - 1); + unit.equipment = this.cloneEquipment(savedUnit.equipment); + this.restoreUnitView(unit, savedUnit.direction); + }); + + this.resetActedStyles(); + this.updateTurnText(); + this.renderRosterPanel(this.rosterTab, '저장된 전투 상태입니다.'); + } + + private restoreUnitView(unit: UnitData, direction: UnitDirection = 'south') { + const view = this.unitViews.get(unit.id); + if (!view) { + return; + } + + this.tweens.killTweensOf([view.sprite, view.label]); + view.direction = direction; + view.sprite.setPosition(this.tileCenterX(unit.x), this.tileCenterY(unit.y)); + view.label.setPosition(view.sprite.x, view.sprite.y + this.layout.tileSize * 0.52); + if (unit.hp > 0) { + view.sprite.setInteractive({ useHandCursor: true }); + } + } + + private cloneEquipment(equipment: UnitData['equipment']) { + return JSON.parse(JSON.stringify(equipment)) as UnitData['equipment']; + } + + private formatSavedAt(savedAt: string) { + return new Date(savedAt).toLocaleString('ko-KR'); + } + private endAllyTurn() { if (this.activeFaction !== 'ally') { this.renderSituationPanel('이미 적군 행동 중입니다.'); @@ -1242,8 +1433,8 @@ export class BattleScene extends Phaser.Scene { title.setOrigin(0.5, 0); title.setDepth(depth + 8); - const attackerDirection = result.attacker.faction === 'ally' ? 'east' : 'west'; - const defenderDirection = result.attacker.faction === 'ally' ? 'west' : 'east'; + const attackerDirection = 'east'; + const defenderDirection = 'west'; const attackerSprite = this.trackCombatObject( this.add.sprite(attackerX, groundY, unitTexture[result.attacker.id] ?? 'unit-rebel', this.unitFrameIndex(attackerDirection)) ); @@ -1301,6 +1492,18 @@ export class BattleScene extends Phaser.Scene { })); expText.setOrigin(0.5); expText.setDepth(depth + 16); + expText.setVisible(false); + + const growthText = this.trackCombatObject(this.add.text(left + panelWidth / 2, top + 348, this.combatGrowthLines(result).join('\n'), { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '15px', + color: '#d4dce6', + fontStyle: '700', + align: 'center', + lineSpacing: 4 + })); + growthText.setOrigin(0.5, 0); + growthText.setDepth(depth + 16); await this.delay(760); this.hideCombatCutIn(); @@ -1536,6 +1739,7 @@ export class BattleScene extends Phaser.Scene { const targetY = this.tileCenterY(y); const direction = this.directionFromDelta(targetX - view.sprite.x, targetY - view.sprite.y); this.tweens.killTweensOf([view.sprite, view.label]); + this.playMovementSound(unit, duration); this.playUnitWalk(view, direction); this.tweens.add({ targets: view.sprite, @@ -1830,6 +2034,7 @@ export class BattleScene extends Phaser.Scene { const targetY = this.tileCenterY(y); const direction = this.directionFromDelta(targetX - view.sprite.x, targetY - view.sprite.y); this.tweens.killTweensOf([view.sprite, view.label]); + this.playMovementSound(unit, duration); this.playUnitWalk(view, direction); this.tweens.add({ targets: view.sprite, @@ -1848,6 +2053,16 @@ export class BattleScene extends Phaser.Scene { }); } + private playMovementSound(unit: UnitData, duration: number) { + const mountedClasses: UnitClassKey[] = ['cavalry']; + const isMounted = mountedClasses.includes(unit.classKey); + soundDirector.playEffect(isMounted ? 'horse-gallop' : 'footstep-walk', { + volume: isMounted ? 0.34 : 0.26, + rate: isMounted ? 1.08 : 1, + stopAfterMs: Math.max(280, duration + 160) + }); + } + private directionFromDelta(deltaX: number, deltaY: number): UnitDirection { if (Math.abs(deltaX) > Math.abs(deltaY)) { return deltaX >= 0 ? 'east' : 'west';