M12: ljud, inställningar och release v0.1.0
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>
This commit is contained in:
2026-10-25 08:31:38 +01:00
parent 36a45563ec
commit 86f8130735
47 changed files with 800 additions and 107 deletions

View File

@@ -3,32 +3,75 @@ 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 korta effekter. nil-säkert: utan ljud (−mute eller fel) är
// alla anrop no-ops.
// 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 bakgrundsloop (bakgrundsprat)
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"
SoundDoors = "door-chime"
SoundChatter = "chatter"
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
@@ -37,17 +80,9 @@ func newAudio(mute bool) (*Audio, error) {
if mute {
return nil, nil
}
a := &Audio{ctx: audio.NewContext(sampleRate), clips: map[string][]byte{}}
for _, name := range []string{SoundApproach, SoundDoors, SoundChatter} {
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)
}
pcm, err := io.ReadAll(stream)
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
}
@@ -56,7 +91,33 @@ func newAudio(mute bool) (*Audio, error) {
return a, nil
}
// Play spelar ett klipp med volym 0..1.
// 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
@@ -65,19 +126,33 @@ func (a *Audio) Play(name string, volume float64) {
if !ok {
return
}
v := clamp01(volume * a.effects * a.master)
if v <= 0 {
return
}
p := a.ctx.NewPlayerFromBytes(pcm)
p.SetVolume(math.Min(1, volume))
p.SetVolume(v)
p.Play()
}
// Loop startar (eller byter) bakgrundsloopen med given volym; 0 stoppar.
// 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 || volume <= 0 {
if !ok {
return
}
stream := audio.NewInfiniteLoop(bytes.NewReader(pcm), int64(len(pcm)))
@@ -85,18 +160,18 @@ func (a *Audio) Loop(name string, volume float64) {
if err != nil {
return
}
p.SetVolume(math.Min(1, volume))
p.SetVolume(clamp01(volume * a.music * a.master))
p.Play()
a.loop = p
a.loop, a.loopName, a.loopBase = p, name, volume
}
// StopLoop stoppar bakgrundsloopen.
// StopLoop stoppar bakgrundsslingan.
func (a *Audio) StopLoop() {
if a == nil || a.loop == nil {
return
}
a.loop.Close()
a.loop = nil
a.loop, a.loopName = nil, ""
}
// volumeAt ger volym efter avstånd i rutor.