Improve battle audio and save state

This commit is contained in:
2026-06-22 12:51:20 +09:00
parent 41d70c7b7c
commit 0753549471
6 changed files with 230 additions and 9 deletions

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

@@ -64,7 +64,7 @@ type BattleCommand = 'attack' | 'strategy' | 'item' | 'wait';
type DamageCommand = Exclude<BattleCommand, 'wait'>;
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<UnitData, 'id' | 'level' | 'exp' | 'hp' | 'maxHp' | 'attack' | 'move' | 'x' | 'y'> & {
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<Record<UnitClassKey, number>> = {
archer: 2
@@ -170,7 +189,7 @@ const factionLabels: Record<ActiveFaction, string> = {
enemy: '적군'
};
const mapMenuLabels: Record<MapMenuAction, string> = {
const mapMenuLabels: Partial<Record<MapMenuAction, string>> = {
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';