Sound engine and sound environments (#652)

* Working sound engine and sound environments

* Clean up sounds.txt loader

* Make global volume settings apply properly

Als shuffle some stuff around

* Reset sound engine on game unload
This commit is contained in:
Ziemas 2020-07-30 22:17:26 +02:00 committed by GitHub
parent 78ecc3557e
commit 29ea71489d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 374 additions and 106 deletions

View File

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

View File

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

View File

@ -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)
}

View File

@ -4,4 +4,6 @@ package d2interface
type SoundEffect interface {
Play()
Stop()
IsPlaying() bool
SetVolume(volume float64)
}

View File

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

View File

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

View File

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

View File

@ -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()

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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() {

View File

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

View File

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