diff --git a/public/assets/sounds/hit.wav b/public/assets/sounds/attack.wav similarity index 100% rename from public/assets/sounds/hit.wav rename to public/assets/sounds/attack.wav diff --git a/src/App.vue b/src/App.vue index 5ab00d8..333dda0 100644 --- a/src/App.vue +++ b/src/App.vue @@ -16,7 +16,7 @@ import MapEditor from '@/components/screens/MapEditor.vue' import BackgroundImageLoader from '@/components/utilities/BackgroundImageLoader.vue' import Debug from '@/components/utilities/Debug.vue' import Notifications from '@/components/utilities/Notifications.vue' -import { useGameComposable } from '@/composables/useGameComposable' +import { useSoundComposable } from '@/composables/useSoundComposable' import { useMapEditorComposable } from '@/composables/useMapEditorComposable' import { useGameStore } from '@/stores/gameStore' import { computed, watch } from 'vue' @@ -24,7 +24,7 @@ import { computed, watch } from 'vue' const gameStore = useGameStore() const mapEditor = useMapEditorComposable() -const { playSound } = useGameComposable() +const { playSound } = useSoundComposable() const currentScreen = computed(() => { if (!gameStore.game.isLoaded) return Loading diff --git a/src/components/game/character/Character.vue b/src/components/game/character/Character.vue index d8281fe..29961c0 100644 --- a/src/components/game/character/Character.vue +++ b/src/components/game/character/Character.vue @@ -13,7 +13,7 @@ import CharacterHair from '@/components/game/character/partials/CharacterHair.vu import ChatBubble from '@/components/game/character/partials/ChatBubble.vue' import HealthBar from '@/components/game/character/partials/HealthBar.vue' import { useCharacterSpriteComposable } from '@/composables/useCharacterSpriteComposable' -import { useGameComposable } from '@/composables/useGameComposable' +import { useSoundComposable } from '@/composables/useSoundComposable' import { useGameStore } from '@/stores/gameStore' import { useMapStore } from '@/stores/mapStore' import { Container, Sprite, useScene } from 'phavuer' @@ -29,7 +29,7 @@ const mapStore = useMapStore() const scene = useScene() const { characterContainer, characterSprite, currentPositionX, currentPositionY, isometricDepth, isFlippedX, updatePosition, playAnimation, calcDirection, updateSprite, initializeSprite, cleanup } = useCharacterSpriteComposable(scene, props.tileMap, props.mapCharacter) -const { playSound, stopSound } = useGameComposable() +const { playSound, stopSound } = useSoundComposable() const handlePositionUpdate = (newValues: any, oldValues: any) => { if (!newValues) return @@ -43,6 +43,30 @@ const handlePositionUpdate = (newValues: any, oldValues: any) => { } } +watch( + () => props.mapCharacter.isMoving, + (newValue) => { + if (newValue) { + playSound('/assets/sounds/walk.wav', false, true) + } else { + stopSound('/assets/sounds/walk.wav') + } + } +) + +watch( + () => props.mapCharacter.isAttacking, + (newValue) => { + if (newValue) { + playAnimation('attack') + playSound('/assets/sounds/attack.wav', false, true) + } else { + stopSound('/assets/sounds/attack.wav') + } + mapStore.updateCharacterProperty(props.mapCharacter.character.id, 'isAttacking', false) + } +) + watch( () => ({ positionX: props.mapCharacter.character.positionX, @@ -53,25 +77,6 @@ watch( }), (oldValues, newValues) => { handlePositionUpdate(oldValues, newValues) - if (props.mapCharacter.isAttacking) { - // Play attack animation - playAnimation('attack') - // Play hit sound - playSound('/assets/sounds/hit.wav') - // Disable attack immediately after playing the animation - mapStore.updateCharacterProperty(props.mapCharacter.character.id, 'isAttacking', false) - } - } -) - -watch( - () => props.mapCharacter.isMoving, - (newValue) => { - if (newValue) { - playSound('/assets/sounds/walk.wav', true, true) - } else { - stopSound('/assets/sounds/walk.wav') - } } ) diff --git a/src/components/screens/Characters.vue b/src/components/screens/Characters.vue index 9dbc3dd..a7cf94c 100644 --- a/src/components/screens/Characters.vue +++ b/src/components/screens/Characters.vue @@ -124,12 +124,12 @@ import config from '@/application/config' import { type CharacterHair, type Character as CharacterT, type Map } from '@/application/types' import Modal from '@/components/utilities/Modal.vue' -import { useGameComposable } from '@/composables/useGameComposable' +import { useSoundComposable } from '@/composables/useSoundComposable' import { CharacterHairStorage } from '@/storage/storages' import { useGameStore } from '@/stores/gameStore' import { onBeforeUnmount, onMounted, ref, watch } from 'vue' -const { playSound } = useGameComposable() +const { playSound } = useSoundComposable() const gameStore = useGameStore() const isLoading = ref(true) const characters = ref([]) diff --git a/src/components/screens/Game.vue b/src/components/screens/Game.vue index 0c989a7..f64560d 100644 --- a/src/components/screens/Game.vue +++ b/src/components/screens/Game.vue @@ -27,13 +27,13 @@ import Hotkeys from '@/components/game/gui/Hotkeys.vue' import Hud from '@/components/game/gui/Hud.vue' import Menu from '@/components/game/gui/Menu.vue' import Map from '@/components/game/map/Map.vue' -import { useGameComposable } from '@/composables/useGameComposable' +import { useSoundComposable } from '@/composables/useSoundComposable' import { useGameStore } from '@/stores/gameStore' import { Game, Scene } from 'phavuer' import { onMounted } from 'vue' const gameStore = useGameStore() -const { playSound, stopSound } = useGameComposable() +const { playSound, stopSound } = useSoundComposable() const gameConfig = { name: config.name, diff --git a/src/composables/useGameComposable.ts b/src/composables/useGameComposable.ts deleted file mode 100644 index 837a224..0000000 --- a/src/composables/useGameComposable.ts +++ /dev/null @@ -1,40 +0,0 @@ -const activeSounds: { [key: string]: HTMLAudioElement } = {} - -export function useGameComposable() { - const playSound = (sound: string, loop: boolean = false, ignoreIfPlaying: boolean = false) => { - // If sound is already playing and we want to ignore - if (ignoreIfPlaying && activeSounds[sound] && !activeSounds[sound].paused) { - return - } - - // Stop previous instance of this sound if it exists - if (activeSounds[sound]) { - activeSounds[sound].pause() - activeSounds[sound].currentTime = 0 - } - - const audio = new Audio(sound) - audio.loop = loop - - // Store reference to track playing state - activeSounds[sound] = audio - - audio.addEventListener('ended', () => { - delete activeSounds[sound] - }) - - audio.play().catch(console.error) - } - - const stopSound = (sound: string) => { - if (!activeSounds[sound]) return - activeSounds[sound].pause() - activeSounds[sound].currentTime = 0 - delete activeSounds[sound] - } - - return { - playSound, - stopSound - } -} diff --git a/src/composables/useSoundComposable.ts b/src/composables/useSoundComposable.ts new file mode 100644 index 0000000..55af069 --- /dev/null +++ b/src/composables/useSoundComposable.ts @@ -0,0 +1,135 @@ +import { SoundStorage } from '@/storage/storages' + +// Use a WeakMap for better garbage collection +const activeSounds = new Map() +const audioCache = new Map() +const soundStorage = new SoundStorage() + +export function useSoundComposable() { + // Preload function to reduce initial playback delay + const preloadSound = async (soundUrl: string): Promise => { + if (audioCache.has(soundUrl)) { + return audioCache.get(soundUrl)! + } + + let audio: HTMLAudioElement + const cachedSound = await soundStorage.get(soundUrl) + + if (cachedSound) { + audio = new Audio(`data:audio/mpeg;base64,${cachedSound.base64}`) + } else { + try { + const base64 = await soundToBase64(soundUrl) + await soundStorage.add({ + id: soundUrl, + name: soundUrl.split('/').pop() || soundUrl, + base64 + }) + audio = new Audio(`data:audio/mpeg;base64,${base64}`) + } catch (error) { + console.error(`Failed to load and cache sound ${soundUrl}:`, error) + audio = new Audio(soundUrl) + } + } + + // Preload the audio + audio.load() + audioCache.set(soundUrl, audio) + return audio + } + + const soundToBase64 = async (url: string): Promise => { + try { + const response = await fetch(url) + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`) + const blob = await response.blob() + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onloadend = () => resolve((reader.result as string).split(',')[1]) + reader.onerror = reject + reader.readAsDataURL(blob) + }) + } catch (error) { + console.error('Error converting sound to base64:', error) + throw error + } + } + + const playSound = async (soundUrl: string, loop: boolean = false, ignoreIfPlaying: boolean = false) => { + try { + // Check if sound is already playing + const playingSounds = activeSounds.get(soundUrl) || [] + if (ignoreIfPlaying && playingSounds.some(audio => !audio.paused)) { + return + } + + // Stop previous instances if they exist + stopSound(soundUrl) + + const audio = await preloadSound(soundUrl) + const playingAudio = audio.cloneNode() as HTMLAudioElement + playingAudio.loop = loop + + // Initialize or get the array of active sounds for this URL + if (!activeSounds.has(soundUrl)) { + activeSounds.set(soundUrl, []) + } + activeSounds.get(soundUrl)!.push(playingAudio) + + playingAudio.addEventListener('ended', () => { + const sounds = activeSounds.get(soundUrl) + if (sounds) { + const index = sounds.indexOf(playingAudio) + if (index > -1) { + sounds.splice(index, 1) + } + if (sounds.length === 0) { + activeSounds.delete(soundUrl) + } + } + }, { once: true }) + + await playingAudio.play() + } catch (error) { + console.error(`Failed to play sound ${soundUrl}:`, error) + } + } + + const stopSound = (soundUrl: string) => { + const sounds = activeSounds.get(soundUrl) + if (!sounds) return + + sounds.forEach(audio => { + audio.pause() + audio.currentTime = 0 + }) + activeSounds.delete(soundUrl) + } + + const stopAllSounds = () => { + activeSounds.forEach((sounds, url) => { + stopSound(url) + }) + activeSounds.clear() + } + + const clearSoundCache = async () => { + stopAllSounds() + audioCache.clear() + await soundStorage.reset() + } + + + // New method to preload multiple sounds + const preloadSounds = async (soundUrls: string[]) => { + return Promise.all(soundUrls.map(preloadSound)) + } + + return { + playSound, + stopSound, + stopAllSounds, + clearSoundCache, + preloadSounds + } +} \ No newline at end of file diff --git a/src/storage/storages.ts b/src/storage/storages.ts index 396f6ac..c9be338 100644 --- a/src/storage/storages.ts +++ b/src/storage/storages.ts @@ -46,3 +46,9 @@ export class CharacterHairStorage extends BaseStorage { return characterType?.sprite } } + +export class SoundStorage extends BaseStorage<{ id: string; name: string; base64: string }> { + constructor() { + super('sounds', 'id, name, createdAt, updatedAt') + } +} \ No newline at end of file