Files
metro-puzzle-jp/internal/game/audio.go
claude 86f8130735
All checks were successful
release / build-release (push) Successful in 1m35s
M12: ljud, inställningar och release v0.1.0
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>
2026-10-25 08:31:38 +01:00

179 lines
4.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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