Improve battle audio and save state
This commit is contained in:
@@ -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.
BIN
src/assets/audio/sfx/footstep-walk.mp3
Normal file
BIN
src/assets/audio/sfx/footstep-walk.mp3
Normal file
Binary file not shown.
BIN
src/assets/audio/sfx/horse-gallop.mp3
Normal file
BIN
src/assets/audio/sfx/horse-gallop.mp3
Normal file
Binary file not shown.
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user