All checks were successful
release / build-release (push) Successful in 1m35s
tools/gensound syntetiserar 21 ljud: fyra stationsmelodier i klockklang (en per station via melody-fältet eller banans id), ankomstjingel, ding-dong vid inrullning, dörrsignal vid avgång, spärrens pip och nekande, kiosk, toalett, lärt ord, bekräfta/varning, byte, personal, vinst, förlust, flygplan, sorl och menymusik. sfx.go spelar effekter utifrån vad som hänt i sim.Run sedan förra bildrutan. Ljudlagret har huvudvolym × effekter/musik och byter inte slinga i onödan. Inställningar (S på bankartan, sparas): volymer, språk sv/en som laddas om direkt, fönsterskala, fullskärm (F11 överallt) och tangentöversikt. Fönsterikon från genart. CHANGELOG.md. Tester för ljudfiler, melodival och inställningar. Docs, wiki och skärmdumpar uppdaterade. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
179 lines
4.9 KiB
Go
179 lines
4.9 KiB
Go
package game
|
||
|
||
import (
|
||
"bytes"
|
||
"fmt"
|
||
"hash/fnv"
|
||
"io"
|
||
"math"
|
||
|
||
"github.com/hajimehoshi/ebiten/v2/audio"
|
||
"github.com/hajimehoshi/ebiten/v2/audio/wav"
|
||
|
||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
|
||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res"
|
||
)
|
||
|
||
// Audio spelar effekter och en bakgrundsslinga, med volymer i tre lager:
|
||
// huvudvolym × effekter respektive musik/sorl. nil-säkert: utan ljud
|
||
// (−mute eller fel) är alla anrop no-ops.
|
||
type Audio struct {
|
||
ctx *audio.Context
|
||
clips map[string][]byte // avkodad PCM (16-bit stereo LE) per klipp
|
||
loop *audio.Player // pågående bakgrundsslinga
|
||
loopName string
|
||
loopBase float64 // slingans egen volym innan inställningarna
|
||
|
||
master, effects, music float64 // 0..1 från inställningarna
|
||
}
|
||
|
||
const sampleRate = 44100
|
||
|
||
// Ljudklipp (filnamn utan .wav i resources/sound).
|
||
const (
|
||
SoundApproach = "melody-approach" // ankomstjingel: tåget är på väg in
|
||
SoundAnnounce = "announce" // ding-dong när tåget rullar in
|
||
SoundDoors = "door-chime" // dörrarna stängs
|
||
SoundChatter = "chatter" // stationssorl (slinga)
|
||
SoundMusicMap = "music-map" // bankartans musik (slinga)
|
||
SoundGateBeep = "gate-beep"
|
||
SoundGateDeny = "gate-deny"
|
||
SoundKiosk = "kiosk"
|
||
SoundToilet = "toilet"
|
||
SoundLearn = "learn"
|
||
SoundConfirm = "confirm"
|
||
SoundCancel = "cancel"
|
||
SoundTransfer = "transfer"
|
||
SoundWin = "win"
|
||
SoundLose = "lose"
|
||
SoundStaff = "staff"
|
||
SoundPlane = "plane"
|
||
)
|
||
|
||
// StationMelodies är avgångsmelodierna; varje station har sin (level.Melody
|
||
// eller en vald efter banans id).
|
||
var StationMelodies = []string{"melody-a", "melody-b", "melody-c", "melody-d"}
|
||
|
||
// AllSounds är alla klipp som måste finnas i resources/sound.
|
||
func AllSounds() []string {
|
||
return append([]string{SoundApproach, SoundAnnounce, SoundDoors, SoundChatter, SoundMusicMap, SoundGateBeep, SoundGateDeny,
|
||
SoundKiosk, SoundToilet, SoundLearn, SoundConfirm, SoundCancel, SoundTransfer, SoundWin, SoundLose, SoundStaff, SoundPlane},
|
||
StationMelodies...)
|
||
}
|
||
|
||
// MelodyFor är stationens avgångsmelodi: banans melody-fält, annars en som
|
||
// väljs deterministiskt av banans id.
|
||
func MelodyFor(l *level.Level) string {
|
||
if l.Melody != "" {
|
||
return "melody-" + l.Melody
|
||
}
|
||
h := fnv.New32a()
|
||
h.Write([]byte(l.Station + l.ID))
|
||
return StationMelodies[int(h.Sum32()%uint32(len(StationMelodies)))]
|
||
}
|
||
|
||
// AudibleTiles är hur många rutor bort man hör perrongens ljud.
|
||
const AudibleTiles = 12.0
|
||
|
||
// newAudio laddar klippen. Returnerar nil om ljudet är avstängt.
|
||
func newAudio(mute bool) (*Audio, error) {
|
||
if mute {
|
||
return nil, nil
|
||
}
|
||
a := &Audio{ctx: audio.NewContext(sampleRate), clips: map[string][]byte{}, master: 1, effects: 1, music: 1}
|
||
for _, name := range AllSounds() {
|
||
pcm, err := decodeSound(name)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
a.clips[name] = pcm
|
||
}
|
||
return a, nil
|
||
}
|
||
|
||
// decodeSound läser och avkodar ett klipp till PCM.
|
||
func decodeSound(name string) ([]byte, error) {
|
||
b, err := res.ReadFile("sound", name+".wav")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
stream, err := wav.DecodeWithoutResampling(bytes.NewReader(b))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("ljud %s: %w", name, err)
|
||
}
|
||
return io.ReadAll(stream)
|
||
}
|
||
|
||
// SetVolumes sätter lagren (0..1) och justerar en pågående slinga.
|
||
func (a *Audio) SetVolumes(master, effects, music float64) {
|
||
if a == nil {
|
||
return
|
||
}
|
||
a.master, a.effects, a.music = clamp01(master), clamp01(effects), clamp01(music)
|
||
if a.loop != nil {
|
||
a.loop.SetVolume(clamp01(a.loopBase * a.music * a.master))
|
||
}
|
||
}
|
||
|
||
func clamp01(v float64) float64 { return math.Max(0, math.Min(1, v)) }
|
||
|
||
// Play spelar ett klipp med volym 0..1 (gånger effekt- och huvudvolym).
|
||
func (a *Audio) Play(name string, volume float64) {
|
||
if a == nil || volume <= 0 {
|
||
return
|
||
}
|
||
pcm, ok := a.clips[name]
|
||
if !ok {
|
||
return
|
||
}
|
||
v := clamp01(volume * a.effects * a.master)
|
||
if v <= 0 {
|
||
return
|
||
}
|
||
p := a.ctx.NewPlayerFromBytes(pcm)
|
||
p.SetVolume(v)
|
||
p.Play()
|
||
}
|
||
|
||
// Loop startar (eller byter) bakgrundsslingan med given volym; 0 stoppar.
|
||
// Samma klipp som redan spelas fortsätter bara med ny volym.
|
||
func (a *Audio) Loop(name string, volume float64) {
|
||
if a == nil {
|
||
return
|
||
}
|
||
if volume <= 0 {
|
||
a.StopLoop()
|
||
return
|
||
}
|
||
if a.loop != nil && a.loopName == name {
|
||
a.loopBase = volume
|
||
a.loop.SetVolume(clamp01(volume * a.music * a.master))
|
||
return
|
||
}
|
||
a.StopLoop()
|
||
pcm, ok := a.clips[name]
|
||
if !ok {
|
||
return
|
||
}
|
||
stream := audio.NewInfiniteLoop(bytes.NewReader(pcm), int64(len(pcm)))
|
||
p, err := a.ctx.NewPlayer(stream)
|
||
if err != nil {
|
||
return
|
||
}
|
||
p.SetVolume(clamp01(volume * a.music * a.master))
|
||
p.Play()
|
||
a.loop, a.loopName, a.loopBase = p, name, volume
|
||
}
|
||
|
||
// StopLoop stoppar bakgrundsslingan.
|
||
func (a *Audio) StopLoop() {
|
||
if a == nil || a.loop == nil {
|
||
return
|
||
}
|
||
a.loop.Close()
|
||
a.loop, a.loopName = nil, ""
|
||
}
|
||
|
||
// volumeAt ger volym efter avstånd i rutor.
|
||
func volumeAt(dist float64) float64 { return math.Max(0, 1-dist/AudibleTiles) }
|