All checks were successful
release / build-release (push) Successful in 1m36s
Ett tåg kan vara en buss (vehicle: bus): samma tidtabell, faser, bedömning i nätet och ombordstigning, men ett fordon på en väg i stället för vagnar på räls, hållplatsskylt, "1番" på tavlan, tuta vid inrullning och dörrsignal före avgång. Nätet har tre busslinjer från Narita. Nya sidobanor: 001b Flygbussterminalen (biljettbana med tre bussar) och 003b Harajuku (Yamanote åt rätt eller fel håll runt ringen). Overload-flaggan ger en rullande reklamticker och maximalt med affischer (Ueno, Tokyo). Romaji utan makroner (fonten saknar dem). Skärmdumparna som börjar på bankartan går via titelskärmen. Tester, docs och wiki uppdaterade. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
180 lines
4.9 KiB
Go
180 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"
|
||
SoundBusHorn = "bus-horn"
|
||
)
|
||
|
||
// 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, SoundBusHorn},
|
||
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) }
|