From fb3a59aa59957a392bd4d73e9dd05c4d829e6d2f Mon Sep 17 00:00:00 2001
From: Dennis Postma <dennis@directonline.io>
Date: Thu, 6 Feb 2025 22:32:25 +0100
Subject: [PATCH] Cache audio

---
 public/assets/sounds/{hit.wav => attack.wav} | Bin
 src/App.vue                                  |   4 +-
 src/components/game/character/Character.vue  |  47 ++++---
 src/components/screens/Characters.vue        |   4 +-
 src/components/screens/Game.vue              |   4 +-
 src/composables/useGameComposable.ts         |  40 ------
 src/composables/useSoundComposable.ts        | 135 +++++++++++++++++++
 src/storage/storages.ts                      |   6 +
 8 files changed, 173 insertions(+), 67 deletions(-)
 rename public/assets/sounds/{hit.wav => attack.wav} (100%)
 delete mode 100644 src/composables/useGameComposable.ts
 create mode 100644 src/composables/useSoundComposable.ts

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<boolean>(true)
 const characters = ref<CharacterT[]>([])
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<string, HTMLAudioElement[]>()
+const audioCache = new Map<string, HTMLAudioElement>()
+const soundStorage = new SoundStorage()
+
+export function useSoundComposable() {
+  // Preload function to reduce initial playback delay
+  const preloadSound = async (soundUrl: string): Promise<HTMLAudioElement> => {
+    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<string> => {
+    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<any> {
     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