diff --git a/d2common/d2data/d2datadict/soundenviron.go b/d2common/d2data/d2datadict/soundenviron.go index e33c60a9..6aba7070 100644 --- a/d2common/d2data/d2datadict/soundenviron.go +++ b/d2common/d2data/d2datadict/soundenviron.go @@ -36,11 +36,11 @@ type SoundEnvironRecord struct { // SoundEnvirons contains the SoundEnviron records //nolint:gochecknoglobals // Currently global by design, only written once -var SoundEnvirons map[string]*SoundEnvironRecord +var SoundEnvirons map[int]*SoundEnvironRecord // LoadSoundEnvirons loads SoundEnvirons from the supplied file func LoadSoundEnvirons(file []byte) { - SoundEnvirons = make(map[string]*SoundEnvironRecord) + SoundEnvirons = make(map[int]*SoundEnvironRecord) d := d2common.LoadDataDictionary(file) for d.Next() { @@ -70,7 +70,7 @@ func LoadSoundEnvirons(file []byte) { EAXRoomRoll: d.Number("EAX Room Roll"), EAXAirAbsorb: d.Number("EAX Air Absorb"), } - SoundEnvirons[record.Handle] = record + SoundEnvirons[record.Index] = record } if d.Err != nil { diff --git a/d2common/d2data/d2datadict/sounds.go b/d2common/d2data/d2datadict/sounds.go index aa05b62d..1519cddd 100644 --- a/d2common/d2data/d2datadict/sounds.go +++ b/d2common/d2data/d2datadict/sounds.go @@ -2,7 +2,6 @@ package d2datadict import ( "log" - "strings" "github.com/OpenDiablo2/OpenDiablo2/d2common" ) @@ -12,69 +11,30 @@ type SoundEntry struct { Handle string Index int FileName string - Volume byte - GroupSize uint8 + Volume int + GroupSize int Loop bool - FadeIn uint8 - FadeOut uint8 - DeferInst uint8 - StopInst uint8 - Duration uint8 - Compound int8 - Reverb bool - Falloff uint8 - Cache uint8 + FadeIn int + FadeOut int + DeferInst bool + StopInst bool + Duration int + Compound int + Reverb int + Falloff int + Cache bool AsyncOnly bool - Priority uint8 - Stream uint8 - Stereo uint8 - Tracking uint8 - Solo uint8 - MusicVol uint8 + Priority int + Stream bool + Stereo bool + Tracking bool + Solo bool + MusicVol bool Block1 int Block2 int Block3 int } -// CreateSoundEntry creates a sound entry based on a sound row on sounds.txt -func createSoundEntry(soundLine string) SoundEntry { - props := strings.Split(soundLine, "\t") - i := -1 - inc := func() int { - i++ - return i - } - result := SoundEntry{ - Handle: props[inc()], - Index: d2common.StringToInt(props[inc()]), - FileName: props[inc()], - Volume: d2common.StringToUint8(props[inc()]), - GroupSize: d2common.StringToUint8(props[inc()]), - Loop: d2common.StringToUint8(props[inc()]) == 1, - FadeIn: d2common.StringToUint8(props[inc()]), - FadeOut: d2common.StringToUint8(props[inc()]), - DeferInst: d2common.StringToUint8(props[inc()]), - StopInst: d2common.StringToUint8(props[inc()]), - Duration: d2common.StringToUint8(props[inc()]), - Compound: d2common.StringToInt8(props[inc()]), - Reverb: d2common.StringToUint8(props[inc()]) == 1, - Falloff: d2common.StringToUint8(props[inc()]), - Cache: d2common.StringToUint8(props[inc()]), - AsyncOnly: d2common.StringToUint8(props[inc()]) == 1, - Priority: d2common.StringToUint8(props[inc()]), - Stream: d2common.StringToUint8(props[inc()]), - Stereo: d2common.StringToUint8(props[inc()]), - Tracking: d2common.StringToUint8(props[inc()]), - Solo: d2common.StringToUint8(props[inc()]), - MusicVol: d2common.StringToUint8(props[inc()]), - Block1: d2common.StringToInt(props[inc()]), - Block2: d2common.StringToInt(props[inc()]), - Block3: d2common.StringToInt(props[inc()]), - } - - return result -} - // Sounds stores all of the SoundEntries //nolint:gochecknoglobals // Currently global by design, only written once var Sounds map[string]SoundEntry @@ -82,31 +42,53 @@ var Sounds map[string]SoundEntry // LoadSounds loads SoundEntries from sounds.txt func LoadSounds(file []byte) { Sounds = make(map[string]SoundEntry) - soundData := strings.Split(string(file), "\r\n")[1:] - for _, line := range soundData { - if line == "" { - continue + d := d2common.LoadDataDictionary(file) + for d.Next() { + entry := SoundEntry{ + Handle: d.String("Sound"), + Index: d.Number("Index"), + FileName: d.String("FileName"), + Volume: d.Number("Volume"), + GroupSize: d.Number("Group Size"), + Loop: d.Bool("Loop"), + FadeIn: d.Number("Fade In"), + FadeOut: d.Number("Fade Out"), + DeferInst: d.Bool("Defer Inst"), + StopInst: d.Bool("Stop Inst"), + Duration: d.Number("Duration"), + Compound: d.Number("Compound"), + Reverb: d.Number("Reverb"), + Falloff: d.Number("Falloff"), + Cache: d.Bool("Cache"), + AsyncOnly: d.Bool("Async Only"), + Priority: d.Number("Priority"), + Stream: d.Bool("Stream"), + Stereo: d.Bool("Stereo"), + Tracking: d.Bool("Tracking"), + Solo: d.Bool("Solo"), + MusicVol: d.Bool("Music Vol"), + Block1: d.Number("Block 1"), + Block2: d.Number("Block 2"), + Block3: d.Number("Block 3"), } + Sounds[entry.Handle] = entry + } - soundEntry := createSoundEntry(line) - soundEntry.FileName = "/data/global/sfx/" + strings.ReplaceAll(soundEntry.FileName, `\`, "/") - Sounds[soundEntry.Handle] = soundEntry - - //nolint:gocritic // Debug util code - /* - // Use the following code to write out the values - f, err := os.OpenFile(`C:\Users\lunat\Desktop\D2\sounds.txt`, - os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - log.Println(err) - } - defer f.Close() - if _, err := f.WriteString("\n[" + soundEntry.Handle + "] " + soundEntry.FileName); err != nil { - log.Println(err) - } - */ - } //nolint:wsl // Debug util code + if d.Err != nil { + panic(d.Err) + } log.Printf("Loaded %d sound definitions", len(Sounds)) } + +// SelectSoundByIndex selects a sound by its ID +func SelectSoundByIndex(index int) *SoundEntry { + for _, el := range Sounds { + if el.Index == index { + return &el + } + } + + return nil +} diff --git a/d2common/d2interface/audio_provider.go b/d2common/d2interface/audio_provider.go index 5400aa8e..ce8a6ccb 100644 --- a/d2common/d2interface/audio_provider.go +++ b/d2common/d2interface/audio_provider.go @@ -4,6 +4,6 @@ package d2interface // by the asset manager, and set the game engine's volume levels type AudioProvider interface { PlayBGM(song string) - LoadSoundEffect(sfx string) (SoundEffect, error) + LoadSound(sfx string, loop bool, bgm bool) (SoundEffect, error) SetVolumes(bgmVolume, sfxVolume float64) } diff --git a/d2common/d2interface/sound_effect.go b/d2common/d2interface/sound_effect.go index feecf8fe..945b0765 100644 --- a/d2common/d2interface/sound_effect.go +++ b/d2common/d2interface/sound_effect.go @@ -4,4 +4,6 @@ package d2interface type SoundEffect interface { Play() Stop() + IsPlaying() bool + SetVolume(volume float64) } diff --git a/d2common/d2resource/resource_paths.go b/d2common/d2resource/resource_paths.go index 0e6ecc21..28521cdd 100644 --- a/d2common/d2resource/resource_paths.go +++ b/d2common/d2resource/resource_paths.go @@ -290,12 +290,12 @@ const ( SFXButtonClick = "cursor_button_click" SFXAmazonDeselect = "cursor_amazon_deselect" SFXAmazonSelect = "cursor_amazon_select" - SFXAssassinDeselect = "/data/global/sfx/Cursor/intro/assassin deselect.wav" - SFXAssassinSelect = "/data/global/sfx/Cursor/intro/assassin select.wav" + SFXAssassinDeselect = "Cursor/intro/assassin deselect.wav" + SFXAssassinSelect = "Cursor/intro/assassin select.wav" SFXBarbarianDeselect = "cursor_barbarian_deselect" SFXBarbarianSelect = "cursor_barbarian_select" - SFXDruidDeselect = "/data/global/sfx/Cursor/intro/druid deselect.wav" - SFXDruidSelect = "/data/global/sfx/Cursor/intro/druid select.wav" + SFXDruidDeselect = "Cursor/intro/druid deselect.wav" + SFXDruidSelect = "Cursor/intro/druid select.wav" SFXNecromancerDeselect = "cursor_necromancer_deselect" SFXNecromancerSelect = "cursor_necromancer_select" SFXPaladinDeselect = "cursor_paladin_deselect" diff --git a/d2common/data_dictionary.go b/d2common/data_dictionary.go index 7336b3df..c6a8445b 100644 --- a/d2common/data_dictionary.go +++ b/d2common/data_dictionary.go @@ -85,7 +85,7 @@ func (d *DataDictionary) List(field string) []string { func (d *DataDictionary) Bool(field string) bool { n := d.Number(field) if n > 1 { - log.Panic("Bool on non-bool field") + log.Panic("Bool on non-bool field ", field) } return n == 1 diff --git a/d2core/d2audio/ebiten/ebiten_audio_provider.go b/d2core/d2audio/ebiten/ebiten_audio_provider.go index 1351b581..4c7afe70 100644 --- a/d2core/d2audio/ebiten/ebiten_audio_provider.go +++ b/d2core/d2audio/ebiten/ebiten_audio_provider.go @@ -97,8 +97,12 @@ func (eap *AudioProvider) PlayBGM(song string) { } // LoadSoundEffect loads a sound affect so that it canb e played -func (eap *AudioProvider) LoadSoundEffect(sfx string) (d2interface.SoundEffect, error) { - result := CreateSoundEffect(sfx, eap.audioContext, eap.sfxVolume) // TODO: Split +func (eap *AudioProvider) LoadSound(sfx string, loop bool, bgm bool) (d2interface.SoundEffect, error) { + volume := eap.sfxVolume + if bgm { + volume = eap.bgmVolume + } + result := CreateSoundEffect(sfx, eap.audioContext, volume, loop) // TODO: Split return result, nil } diff --git a/d2core/d2audio/ebiten/ebiten_sound_effect.go b/d2core/d2audio/ebiten/ebiten_sound_effect.go index a43dec5d..057e23c5 100644 --- a/d2core/d2audio/ebiten/ebiten_sound_effect.go +++ b/d2core/d2audio/ebiten/ebiten_sound_effect.go @@ -12,40 +12,53 @@ import ( // SoundEffect represents an ebiten implementation of a sound effect type SoundEffect struct { - player *audio.Player + player *audio.Player + volumeScale float64 } // CreateSoundEffect creates a new instance of ebiten's sound effect implementation. -func CreateSoundEffect(sfx string, context *audio.Context, volume float64) *SoundEffect { +func CreateSoundEffect(sfx string, context *audio.Context, volume float64, loop bool) *SoundEffect { result := &SoundEffect{} - var soundFile string + soundFile := "/data/global/sfx/" if _, exists := d2datadict.Sounds[sfx]; exists { soundEntry := d2datadict.Sounds[sfx] - soundFile = soundEntry.FileName + soundFile += soundEntry.FileName } else { - soundFile = sfx + soundFile += sfx } - audioData, err := d2asset.LoadFile(soundFile) + audioData, err := d2asset.LoadFileStream(soundFile) + + if err != nil { + audioData, err = d2asset.LoadFileStream("/data/global/music/" + sfx) + } if err != nil { panic(err) } - d, err := wav.Decode(context, audio.BytesReadSeekCloser(audioData)) + d, err := wav.Decode(context, audioData) if err != nil { log.Fatal(err) } - player, err := audio.NewPlayer(context, d) + var player *audio.Player + + if loop { + s := audio.NewInfiniteLoop(d, d.Length()) + player, err = audio.NewPlayer(context, s) + } else { + player, err = audio.NewPlayer(context, d) + } if err != nil { log.Fatal(err) } + result.volumeScale = volume player.SetVolume(volume) result.player = player @@ -53,6 +66,14 @@ func CreateSoundEffect(sfx string, context *audio.Context, volume float64) *Soun return result } +func (v *SoundEffect) SetVolume(volume float64) { + v.player.SetVolume(volume * v.volumeScale) +} + +func (v *SoundEffect) IsPlaying() bool { + return v.player.IsPlaying() +} + // Play plays the sound effect func (v *SoundEffect) Play() { err := v.player.Rewind() diff --git a/d2core/d2audio/sound_engine.go b/d2core/d2audio/sound_engine.go new file mode 100644 index 00000000..35cdb579 --- /dev/null +++ b/d2core/d2audio/sound_engine.go @@ -0,0 +1,195 @@ +package d2audio + +import ( + "log" + "math/rand" + + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2data/d2datadict" + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2interface" +) + +type envState int + +const ( + envAttack = 0 + envSustain = 1 + envRelease = 2 + envStopped = 3 +) + +const volMax float64 = 255 +const originalFPS float64 = 25 + +// A Sound that can be started and stopped +type Sound struct { + effect d2interface.SoundEffect + entry *d2datadict.SoundEntry + volume float64 + vTarget float64 + vRate float64 + state envState + // panning float64 // lets forget about this for now +} + +func (s *Sound) update(elapsed float64) { + // attack + if s.state == envAttack { + s.volume += s.vRate * elapsed + if s.volume > s.vTarget { + s.volume = s.vTarget + s.state = envSustain + } + + s.effect.SetVolume(s.volume) + } + + // release + if s.state == envRelease { + s.volume -= s.vRate * elapsed + if s.volume < 0 { + s.effect.Stop() + s.volume = 0 + s.state = envStopped + } + + s.effect.SetVolume(s.volume) + } +} + +// Play the sound +func (s *Sound) Play() { + log.Println("starting sound", s.entry.Handle) + s.effect.Play() + + if s.entry.FadeIn != 0 { + s.effect.SetVolume(0) + s.volume = 0 + s.state = envAttack + s.vTarget = float64(s.entry.Volume) / volMax + s.vRate = (s.vTarget / (float64(s.entry.FadeIn) / originalFPS)) + } else { + s.volume = float64(s.entry.Volume) / volMax + s.effect.SetVolume(s.volume) + s.state = envSustain + } +} + +// Stop the sound, only required for looping sounds +func (s *Sound) Stop() { + if s.entry.FadeOut != 0 { + s.state = envRelease + s.vTarget = 0 + s.vRate = (s.volume / (float64(s.entry.FadeOut) / originalFPS)) + } else { + s.state = envStopped + s.volume = 0 + s.effect.SetVolume(s.volume) + s.effect.Stop() + } +} + +// SoundEngine provides functions for playing sounds +type SoundEngine struct { + provider d2interface.AudioProvider + timer float64 + accTime float64 + sounds map[*Sound]struct{} +} + +// NewSoundEngine creates a new sound engine +func NewSoundEngine(provider d2interface.AudioProvider, term d2interface.Terminal) *SoundEngine { + r := SoundEngine{ + provider: provider, + sounds: map[*Sound]struct{}{}, + timer: 1, + } + + _ = term.BindAction("playsoundid", "plays the sound for a given id", func(id int) { + r.PlaySoundID(id) + }) + + _ = term.BindAction("playsound", "plays the sound for a given handle string", func(handle string) { + r.PlaySoundHandle(handle) + }) + + _ = term.BindAction("activesounds", "list currently active sounds", func() { + for s := range r.sounds { + log.Println(s) + } + }) + + _ = term.BindAction("killsounds", "kill active sounds", func() { + for s := range r.sounds { + s.Stop() + } + }) + + return &r +} + +// Advance updates sound engine state, triggering events and envelopes +func (s *SoundEngine) Advance(elapsed float64) { + s.timer -= elapsed + s.accTime += elapsed + + if s.timer < 0 { + for sound := range s.sounds { + sound.update(s.accTime) + + // Clean up finished non-looping effects + if !sound.effect.IsPlaying() { + delete(s.sounds, sound) + } + + // Clean up stopped looping effects + if sound.state == envStopped { + delete(s.sounds, sound) + } + } + + s.timer = 0.2 + s.accTime = 0 + } +} + +// Reset stop all sounds and reset state +func (s *SoundEngine) Reset() { + for snd := range s.sounds { + snd.effect.Stop() + delete(s.sounds, snd) + } +} + +// PlaySoundID plays a sound by sounds.txt index, returning the sound here is kinda ugly +// now we could have a situation where someone holds onto the sound after the sound engine is done with it +// someone needs to be in charge of deciding when to stopping looping sounds though... +func (s *SoundEngine) PlaySoundID(id int) *Sound { + if id == 0 { + return nil + } + + entry := d2datadict.SelectSoundByIndex(id) + + if entry.GroupSize > 0 { + entry = d2datadict.SelectSoundByIndex(entry.Index + rand.Intn(entry.GroupSize)) + } + + effect, _ := s.provider.LoadSound(entry.FileName, entry.Loop, entry.MusicVol) + + snd := Sound{ + entry: entry, + effect: effect, + } + + s.sounds[&snd] = struct{}{} + + snd.Play() + + return &snd +} + +// PlaySoundHandle plays a sound by sounds.txt handle +func (s *SoundEngine) PlaySoundHandle(handle string) *Sound { + sound := d2datadict.Sounds[handle].Index + return s.PlaySoundID(sound) +} diff --git a/d2core/d2audio/sound_environment.go b/d2core/d2audio/sound_environment.go new file mode 100644 index 00000000..85ee2235 --- /dev/null +++ b/d2core/d2audio/sound_environment.go @@ -0,0 +1,55 @@ +package d2audio + +import ( + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2data/d2datadict" +) + +type SoundEnvironment struct { + environment *d2datadict.SoundEnvironRecord + engine *SoundEngine + bgm *Sound + ambiance *Sound + eventTimer float64 +} + +func NewSoundEnvironment(soundEngine *SoundEngine) SoundEnvironment { + r := SoundEnvironment{ + // Start with env NONE + environment: d2datadict.SoundEnvirons[0], + engine: soundEngine, + } + + return r +} + +func (s *SoundEnvironment) SetEnv(environment int) { + if s.environment.Index != environment { + newEnv := d2datadict.SoundEnvirons[environment] + + if s.environment.Song != newEnv.Song { + if s.bgm != nil { + s.bgm.Stop() + } + + s.bgm = s.engine.PlaySoundID(newEnv.Song) + } + + if s.environment.DayAmbience != newEnv.DayAmbience { + if s.ambiance != nil { + s.ambiance.Stop() + } + + s.ambiance = s.engine.PlaySoundID(newEnv.DayAmbience) + } + + s.environment = newEnv + } +} + +func (s *SoundEnvironment) Advance(elapsed float64) { + s.eventTimer -= elapsed + if s.eventTimer < 0 { + s.eventTimer = float64(s.environment.EventDelay) / 25 + s.engine.PlaySoundID(s.environment.DayEvent) + } +} diff --git a/d2core/d2ui/d2ui.go b/d2core/d2ui/d2ui.go index 0e034516..066c5e7c 100644 --- a/d2core/d2ui/d2ui.go +++ b/d2core/d2ui/d2ui.go @@ -33,7 +33,7 @@ type UI struct { var singleton UI func Initialize(inputManager d2interface.InputManager, audioProvider d2interface.AudioProvider) { - sfx, err := audioProvider.LoadSoundEffect(d2resource.SFXButtonClick) + sfx, err := audioProvider.LoadSound(d2resource.SFXButtonClick, false, false) if err != nil { log.Fatalf("failed to initialize ui: %v", err) } diff --git a/d2game/d2gamescreen/escape_menu.go b/d2game/d2gamescreen/escape_menu.go index 65c2f75e..2e780f61 100644 --- a/d2game/d2gamescreen/escape_menu.go +++ b/d2game/d2gamescreen/escape_menu.go @@ -319,7 +319,7 @@ func (m *EscapeMenu) addEnumLabel(l *layout, optID optionID, text string, values } func (m *EscapeMenu) onLoad() { - m.selectSound, _ = m.audioProvider.LoadSoundEffect(d2resource.SFXCursorSelect) + m.selectSound, _ = m.audioProvider.LoadSound(d2resource.SFXCursorSelect, false, false) } func (m *EscapeMenu) onEscKey() { diff --git a/d2game/d2gamescreen/game.go b/d2game/d2gamescreen/game.go index a9c8daeb..a95a0ec4 100644 --- a/d2game/d2gamescreen/game.go +++ b/d2game/d2gamescreen/game.go @@ -2,14 +2,13 @@ package d2gamescreen import ( "fmt" + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2math/d2vector" "image/color" - "github.com/OpenDiablo2/OpenDiablo2/d2common/d2math/d2vector" - - "github.com/OpenDiablo2/OpenDiablo2/d2common" "github.com/OpenDiablo2/OpenDiablo2/d2common/d2data/d2datadict" "github.com/OpenDiablo2/OpenDiablo2/d2common/d2enum" "github.com/OpenDiablo2/OpenDiablo2/d2common/d2interface" + "github.com/OpenDiablo2/OpenDiablo2/d2core/d2audio" "github.com/OpenDiablo2/OpenDiablo2/d2core/d2map/d2mapentity" "github.com/OpenDiablo2/OpenDiablo2/d2core/d2map/d2maprenderer" "github.com/OpenDiablo2/OpenDiablo2/d2core/d2screen" @@ -36,6 +35,8 @@ type Game struct { lastRegionType d2enum.RegionIdType ticksSinceLevelCheck float64 escapeMenu *EscapeMenu + soundEngine *d2audio.SoundEngine + soundEnv d2audio.SoundEnvironment renderer d2interface.Renderer inputManager d2interface.InputManager @@ -75,7 +76,10 @@ func CreateGame( audioProvider: audioProvider, renderer: renderer, terminal: term, + soundEngine: d2audio.NewSoundEngine(audioProvider, term), } + result.soundEnv = d2audio.NewSoundEnvironment(result.soundEngine) + result.escapeMenu.onLoad() if err := inputManager.BindHandler(result.escapeMenu); err != nil { @@ -125,6 +129,8 @@ func (v *Game) OnUnload() error { return err } + v.soundEngine.Reset() + return nil } @@ -152,6 +158,8 @@ func (v *Game) Render(screen d2interface.Surface) error { // Advance runs the update logic on the Gameplay screen func (v *Game) Advance(elapsed float64) error { + v.soundEngine.Advance(elapsed) + if (v.escapeMenu != nil && !v.escapeMenu.isOpen) || len(v.gameClient.Players) != 1 { v.gameClient.MapEngine.Advance(elapsed) // TODO: Hack } @@ -170,8 +178,7 @@ func (v *Game) Advance(elapsed float64) error { tile := v.gameClient.MapEngine.TileAt(int(tilePosition.X()), int(tilePosition.Y())) if tile != nil { - musicInfo := d2common.GetMusicDef(tile.RegionType) - v.audioProvider.PlayBGM(musicInfo.MusicFile) + v.soundEnv.SetEnv(d2datadict.LevelDetails[int(tile.RegionType)].SoundEnvironmentID) // skip showing zone change text the first time we enter the world if v.lastRegionType != d2enum.RegionNone && v.lastRegionType != tile.RegionType { @@ -203,6 +210,8 @@ func (v *Game) Advance(elapsed float64) error { v.mapRenderer.SetCameraTarget(&position) } + v.soundEnv.Advance(elapsed) + return nil } diff --git a/d2game/d2gamescreen/select_hero_class.go b/d2game/d2gamescreen/select_hero_class.go index 67a866cc..35e94192 100644 --- a/d2game/d2gamescreen/select_hero_class.go +++ b/d2game/d2gamescreen/select_hero_class.go @@ -755,6 +755,6 @@ func loadSprite(animationPath string, position image.Point, playLength int, play } func (v *SelectHeroClass) loadSoundEffect(sfx string) d2interface.SoundEffect { - result, _ := v.audioProvider.LoadSoundEffect(sfx) + result, _ := v.audioProvider.LoadSound(sfx, false, false) return result }