M12: ljud, inställningar och release v0.1.0
All checks were successful
release / build-release (push) Successful in 1m35s
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:
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
"github.com/hajimehoshi/ebiten/v2/text/v2"
|
||||
@@ -15,8 +16,8 @@ import (
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
|
||||
)
|
||||
|
||||
// UILang är UI-språket (sv/en) – blir en inställning i M12.
|
||||
const UILang = "sv"
|
||||
// UILang är UI-språket (sv/en), från inställningarna (Assets.LoadLang).
|
||||
var UILang = "sv"
|
||||
|
||||
// Radhöjd för 10-pixelsfonten.
|
||||
const LineH = 12
|
||||
@@ -49,6 +50,7 @@ type Assets struct {
|
||||
Rails *ebiten.Image // rälsruta (horisontell), roteras för vertikala spår
|
||||
Posters []*ebiten.Image // affischer för visuell röra (ritas över väggrutor)
|
||||
Plane *ebiten.Image // flygplanet i slutscenen
|
||||
Icon image.Image // fönsterikonen (nil om den saknas)
|
||||
Player [4][3]*ebiten.Image // [riktning: ner, upp, vänster, höger][bildruta: stilla, gå1, gå2]
|
||||
Text map[string]string
|
||||
Dict *lang.Dictionary
|
||||
@@ -103,9 +105,12 @@ func LoadAssets() (*Assets, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := res.LoadJSON(&a.Text, UILang+".json"); err != nil {
|
||||
if err := res.LoadJSON(&a.Text, "text", UILang+".json"); err != nil {
|
||||
return nil, err
|
||||
if err := a.LoadLang(UILang); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if b, err := res.ReadFile("sprites", "icon.png"); err == nil {
|
||||
if img, err := png.Decode(bytes.NewReader(b)); err == nil {
|
||||
a.Icon = img
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +164,19 @@ func (a *Assets) drawFace(dst *ebiten.Image, face *text.GoTextFace, s string, x,
|
||||
// Width är textens bredd i pixlar med 10-pixelsfonten.
|
||||
func (a *Assets) Width(s string) int { return int(text.Advance(s, a.Font)) }
|
||||
|
||||
// LoadLang läser UI-texterna för ett språk och gör det till UILang.
|
||||
func (a *Assets) LoadLang(lang string) error {
|
||||
texts := map[string]string{}
|
||||
if err := res.LoadJSON(&texts, lang+".json"); err != nil {
|
||||
if err := res.LoadJSON(&texts, "text", lang+".json"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
a.Text = texts
|
||||
UILang = lang
|
||||
return nil
|
||||
}
|
||||
|
||||
// T slår upp en UI-text; saknas nyckeln visas nyckeln själv så att det syns.
|
||||
func (a *Assets) T(key string) string {
|
||||
if s, ok := a.Text[key]; ok {
|
||||
|
||||
@@ -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.
|
||||
|
||||
46
internal/game/audio_test.go
Normal file
46
internal/game/audio_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
|
||||
)
|
||||
|
||||
// TestAllSoundsDecode: alla ljud som spelet refererar finns och går att
|
||||
// avkoda (utan ljudkontext eller fönster).
|
||||
func TestAllSoundsDecode(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for _, name := range AllSounds() {
|
||||
if seen[name] {
|
||||
t.Errorf("%s listas två gånger", name)
|
||||
}
|
||||
seen[name] = true
|
||||
pcm, err := decodeSound(name)
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", name, err)
|
||||
continue
|
||||
}
|
||||
if len(pcm) < sampleRate/20*4 { // minst 50 ms stereo 16-bit
|
||||
t.Errorf("%s: för kort (%d byte)", name, len(pcm))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMelodyForIsStableAndKnown(t *testing.T) {
|
||||
known := map[string]bool{}
|
||||
for _, m := range StationMelodies {
|
||||
known[m] = true
|
||||
}
|
||||
a := &level.Level{ID: "005-ueno", Station: "ueno"}
|
||||
if m := MelodyFor(a); !known[m] || m != MelodyFor(a) {
|
||||
t.Fatalf("MelodyFor = %q", m)
|
||||
}
|
||||
if m := MelodyFor(&level.Level{ID: "x", Melody: "c"}); m != "melody-c" {
|
||||
t.Fatalf("melody-fältet ska vinna: %q", m)
|
||||
}
|
||||
var nilAudio *Audio
|
||||
nilAudio.Play(SoundWin, 1) // nil-säkert
|
||||
nilAudio.Loop(SoundMusicMap, 1)
|
||||
nilAudio.SetVolumes(1, 1, 1)
|
||||
nilAudio.StopLoop()
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
"github.com/hajimehoshi/ebiten/v2/inpututil"
|
||||
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/endless"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang"
|
||||
@@ -52,6 +53,7 @@ type Game struct {
|
||||
Endless *EndlessRun // pågående omgång i oändligt läge (nil = ingen)
|
||||
|
||||
savePath string // "" = spara inte
|
||||
flagScale int // fönsterskala från flaggan (när inställningarna säger 0)
|
||||
fixedSeed uint64 // >0: alla banor startar med denna seed (tester/skärmdumpar)
|
||||
scene Scene
|
||||
next Scene
|
||||
@@ -76,6 +78,7 @@ type Options struct {
|
||||
NoSave bool // läs sparfilen men skriv aldrig
|
||||
Ending bool // öppna slutscenen direkt (tester/skärmdumpar)
|
||||
Endless bool // starta oändligt läge direkt (tester/skärmdumpar)
|
||||
Scale int // fönsterskala från flaggan (när inställningarna säger 0)
|
||||
Mode string // navigeringsläge från start: "list" eller "signs" (tomt = sparfilens)
|
||||
}
|
||||
|
||||
@@ -121,6 +124,8 @@ func New(opt Options) (*Game, error) {
|
||||
if opt.Mode != "" {
|
||||
g.Mode = nav.ParseMode(opt.Mode)
|
||||
}
|
||||
g.flagScale = max(1, opt.Scale)
|
||||
g.applySettings()
|
||||
if !opt.NoSave {
|
||||
g.savePath = path
|
||||
}
|
||||
@@ -278,10 +283,12 @@ func (g *Game) Save() {
|
||||
// SetScene byter scen från och med nästa uppdatering. Bakgrundsprat hör
|
||||
// till banan och tystnar när man lämnar den.
|
||||
func (g *Game) SetScene(s Scene) {
|
||||
if _, inLevel := s.(*LevelScene); !inLevel {
|
||||
if _, isResult := s.(*ResultScene); !isResult {
|
||||
g.Audio.StopLoop()
|
||||
}
|
||||
switch s.(type) {
|
||||
case *LevelScene, *ResultScene: // banans sorl fortsätter
|
||||
case *MapScene, *SettingsScene, *EndingScene, *WordsScene, *SkillsScene, *ItemsScene:
|
||||
g.Audio.Loop(SoundMusicMap, 0.6) // menymusiken (fortsätter om den redan spelas)
|
||||
default:
|
||||
g.Audio.StopLoop()
|
||||
}
|
||||
g.next = s
|
||||
}
|
||||
@@ -305,6 +312,11 @@ func (g *Game) Update() error {
|
||||
if g.next != nil {
|
||||
g.scene, g.next = g.next, nil
|
||||
}
|
||||
if inpututil.IsKeyJustPressed(ebiten.KeyF11) { // fullskärm av/på överallt
|
||||
g.State.Settings.Fullscreen = !g.State.Settings.Fullscreen
|
||||
g.applySettings()
|
||||
g.Save()
|
||||
}
|
||||
if g.script != nil {
|
||||
if err := g.script.tick(g, g.frame); err != nil {
|
||||
return err
|
||||
@@ -349,6 +361,25 @@ func (g *Game) runOptions(id string, seed rng.Seed) sim.RunOptions {
|
||||
}
|
||||
}
|
||||
|
||||
// applySettings för ut inställningarna: volymer, språk, fönster.
|
||||
func (g *Game) applySettings() {
|
||||
st := g.State.Settings
|
||||
g.Audio.SetVolumes(float64(st.Master)/10, float64(st.Effects)/10, float64(st.Music)/10)
|
||||
if st.Lang != UILang {
|
||||
if err := g.Assets.LoadLang(st.Lang); err != nil {
|
||||
log.Printf("språk %s: %v", st.Lang, err)
|
||||
}
|
||||
}
|
||||
scale := st.Scale
|
||||
if scale <= 0 {
|
||||
scale = g.flagScale
|
||||
}
|
||||
if scale > 0 {
|
||||
ebiten.SetWindowSize(ScreenW*scale, ScreenH*scale)
|
||||
}
|
||||
ebiten.SetFullscreen(st.Fullscreen)
|
||||
}
|
||||
|
||||
// toggleMode växlar navigeringsläge och sparar.
|
||||
func (g *Game) toggleMode() {
|
||||
if g.Mode == nav.List {
|
||||
|
||||
@@ -126,6 +126,11 @@ func shopJustPressed() bool { return virtual.pressed["b"] || inpututil.IsKeyJust
|
||||
// phoneJustPressed: P = kortet i telefonen av/på (meny).
|
||||
func phoneJustPressed() bool { return virtual.pressed["p"] || inpututil.IsKeyJustPressed(ebiten.KeyP) }
|
||||
|
||||
// settingsJustPressed: S öppnar inställningarna från bankartan.
|
||||
func settingsJustPressed() bool {
|
||||
return virtual.pressed["s"] || inpututil.IsKeyJustPressed(ebiten.KeyS)
|
||||
}
|
||||
|
||||
// endlessJustPressed: O startar oändligt läge (när storyn är klar).
|
||||
func endlessJustPressed() bool {
|
||||
return virtual.pressed["o"] || inpututil.IsKeyJustPressed(ebiten.KeyO)
|
||||
|
||||
@@ -29,6 +29,7 @@ const (
|
||||
|
||||
// NewEndingScene startar hemresan.
|
||||
func NewEndingScene(g *Game) *EndingScene {
|
||||
g.Audio.Play(SoundPlane, 0.8)
|
||||
return &EndingScene{lines: strings.Split(g.Assets.T("ending.credits"), "\n")}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ type LevelScene struct {
|
||||
level *level.Level // aktuell delbana (byts när resan fortsätter på nästa station)
|
||||
run *sim.Run
|
||||
endless bool // målet är ett lottat mål i oändligt läge (inte storyn)
|
||||
sfx sfxState
|
||||
cam *sim.Camera
|
||||
animT float64
|
||||
hideMap bool // M döljer/visar minikartan
|
||||
@@ -183,6 +184,7 @@ func (s *LevelScene) Update(g *Game) error {
|
||||
return nil
|
||||
}
|
||||
s.updateEffects(g)
|
||||
s.updateSfx(g)
|
||||
if s.run.Mini != nil {
|
||||
// Minispel: all input går dit; Esc avbryter minispelet, inte banan.
|
||||
mx, my := miniMoveXY()
|
||||
|
||||
@@ -106,6 +106,9 @@ func (m *MapScene) Update(g *Game) error {
|
||||
g.Save()
|
||||
case modeJustPressed():
|
||||
g.toggleMode()
|
||||
case settingsJustPressed():
|
||||
g.SetScene(NewSettingsScene(m))
|
||||
return nil
|
||||
case endlessJustPressed() && g.Progress.StoryCleared:
|
||||
s, err := g.startEndless(g.seed())
|
||||
if err != nil {
|
||||
|
||||
117
internal/game/scene_settings.go
Normal file
117
internal/game/scene_settings.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/save"
|
||||
)
|
||||
|
||||
// SettingsScene är inställningarna: volymer, språk, fönsterskala och
|
||||
// fullskärm. Pilar väljer rad och ändrar, Enter växlar, Esc/S går tillbaka.
|
||||
// Allt sparas direkt.
|
||||
type SettingsScene struct {
|
||||
back Scene
|
||||
sel int
|
||||
}
|
||||
|
||||
const settingsRows = 6
|
||||
|
||||
// NewSettingsScene öppnar inställningarna.
|
||||
func NewSettingsScene(back Scene) *SettingsScene { return &SettingsScene{back: back} }
|
||||
|
||||
func (s *SettingsScene) Update(g *Game) error {
|
||||
if backJustPressed() || settingsJustPressed() {
|
||||
g.SetScene(s.back)
|
||||
return nil
|
||||
}
|
||||
s.sel = (s.sel + menuMove() + settingsRows) % settingsRows
|
||||
dx, _ := miniMoveXY()
|
||||
toggle := confirmJustPressed()
|
||||
if dx == 0 && !toggle {
|
||||
return nil
|
||||
}
|
||||
st := g.State.Settings
|
||||
step := func(v *int, lo, hi int) {
|
||||
*v = max(lo, min(hi, *v+dx))
|
||||
}
|
||||
switch s.sel {
|
||||
case 0:
|
||||
step(&st.Master, 0, 10)
|
||||
case 1:
|
||||
step(&st.Effects, 0, 10)
|
||||
case 2:
|
||||
step(&st.Music, 0, 10)
|
||||
case 3:
|
||||
if st.Lang == "sv" {
|
||||
st.Lang = "en"
|
||||
} else {
|
||||
st.Lang = "sv"
|
||||
}
|
||||
case 4:
|
||||
step(&st.Scale, 0, 6)
|
||||
case 5:
|
||||
st.Fullscreen = !st.Fullscreen
|
||||
}
|
||||
g.applySettings()
|
||||
g.Save()
|
||||
g.Audio.Play(SoundConfirm, 0.6)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SettingsScene) Draw(g *Game, screen *ebiten.Image) {
|
||||
a := g.Assets
|
||||
st := g.State.Settings
|
||||
screen.Fill(color.RGBA{24, 20, 37, 255})
|
||||
vector.DrawFilledRect(screen, 0, 0, ScreenW, 22, color.RGBA{38, 43, 68, 255}, false)
|
||||
a.DrawBig(screen, a.T("settings.title"), 8, 5, colText)
|
||||
scale := a.T("settings.scale_auto")
|
||||
if st.Scale > 0 {
|
||||
scale = fmt.Sprintf("%d× (%d×%d)", st.Scale, ScreenW*st.Scale, ScreenH*st.Scale)
|
||||
}
|
||||
fs := a.T("menu.off")
|
||||
if st.Fullscreen {
|
||||
fs = a.T("menu.on")
|
||||
}
|
||||
rows := [settingsRows][2]string{
|
||||
{a.T("settings.master"), ""},
|
||||
{a.T("settings.effects"), ""},
|
||||
{a.T("settings.music"), ""},
|
||||
{a.T("settings.lang"), a.T("lang." + st.Lang)},
|
||||
{a.T("settings.scale"), scale},
|
||||
{a.T("settings.fullscreen"), fs},
|
||||
}
|
||||
levels := [3]int{st.Master, st.Effects, st.Music}
|
||||
y := 30
|
||||
for i, r := range rows {
|
||||
col := colText
|
||||
if i == s.sel {
|
||||
vector.DrawFilledRect(screen, 6, float32(y-2), ScreenW-12, LineH+1, colBar, false)
|
||||
col = colKnown
|
||||
}
|
||||
a.Draw(screen, r[0], 10, y, col)
|
||||
if i < 3 { // volymstapel i tio segment
|
||||
for k := 0; k < 10; k++ {
|
||||
c := color.RGBA{70, 80, 110, 255}
|
||||
if k < levels[i] {
|
||||
c = color.RGBA{120, 200, 120, 255}
|
||||
}
|
||||
vector.DrawFilledRect(screen, float32(130+k*9), float32(y+2), 7, 7, c, false)
|
||||
}
|
||||
a.Draw(screen, fmt.Sprintf("%d", levels[i]), 224, y, col)
|
||||
} else {
|
||||
a.Draw(screen, r[1], 130, y, col)
|
||||
}
|
||||
y += LineH + 1
|
||||
}
|
||||
y += 4
|
||||
for _, l := range a.wrapLine(plainLine(a.T("settings.keys"), colDim), ScreenW-20) {
|
||||
a.drawLines(screen, []line{l}, 10, y)
|
||||
y += LineH
|
||||
}
|
||||
a.Draw(screen, a.T("settings.hint"), 8, ScreenH-12, colDim)
|
||||
_ = save.DefaultSettings // (inställningarnas standardvärden ligger i save)
|
||||
}
|
||||
66
internal/game/sfx.go
Normal file
66
internal/game/sfx.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package game
|
||||
|
||||
import "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
|
||||
|
||||
// sfxState är vad ljudlagret senast såg av omgången; skillnader mot nu ger
|
||||
// effekter (spärr, kiosk, toalett, lärt ord, byte, personal, vinst/förlust).
|
||||
type sfxState struct {
|
||||
init bool
|
||||
gatePasses int
|
||||
served int
|
||||
staffCalls int
|
||||
deniedAt float64
|
||||
noExitAt float64
|
||||
learnedAt float64
|
||||
transferAt float64
|
||||
flashAt float64
|
||||
facility bool
|
||||
mini bool
|
||||
outcome sim.Outcome
|
||||
}
|
||||
|
||||
// updateSfx spelar effekter för det som hänt sedan förra bildrutan.
|
||||
func (s *LevelScene) updateSfx(g *Game) {
|
||||
r := s.run
|
||||
x := &s.sfx
|
||||
if x.init {
|
||||
switch {
|
||||
case r.GatePasses > x.gatePasses:
|
||||
g.Audio.Play(SoundGateBeep, 0.7)
|
||||
case r.DeniedAt >= 0 && r.DeniedAt != x.deniedAt, r.NoExitAt >= 0 && r.NoExitAt != x.noExitAt:
|
||||
g.Audio.Play(SoundGateDeny, 0.7)
|
||||
}
|
||||
if r.Served > x.served {
|
||||
if x.facility && !r.Facility.Active {
|
||||
g.Audio.Play(SoundToilet, 0.6)
|
||||
} else {
|
||||
g.Audio.Play(SoundKiosk, 0.7)
|
||||
}
|
||||
}
|
||||
if r.LearnedAt >= 0 && r.LearnedAt != x.learnedAt {
|
||||
g.Audio.Play(SoundLearn, 0.8)
|
||||
}
|
||||
if r.TransferAt >= 0 && r.TransferAt != x.transferAt {
|
||||
g.Audio.Play(SoundTransfer, 0.8)
|
||||
}
|
||||
if r.StaffCalls > x.staffCalls {
|
||||
g.Audio.Play(SoundStaff, 0.7)
|
||||
}
|
||||
if r.Mini != nil && !x.mini {
|
||||
g.Audio.Play(SoundConfirm, 0.5)
|
||||
}
|
||||
if r.FlashAt >= 0 && r.FlashAt != x.flashAt && r.FlashKey != sim.FlashTransfer && r.FlashKey != sim.FlashStaff {
|
||||
g.Audio.Play(SoundCancel, 0.6)
|
||||
}
|
||||
if r.Outcome != x.outcome {
|
||||
switch r.Outcome {
|
||||
case sim.Won:
|
||||
g.Audio.Play(SoundWin, 0.9)
|
||||
case sim.Lost:
|
||||
g.Audio.Play(SoundLose, 0.9)
|
||||
}
|
||||
}
|
||||
}
|
||||
*x = sfxState{init: true, gatePasses: r.GatePasses, served: r.Served, staffCalls: r.StaffCalls, deniedAt: r.DeniedAt, noExitAt: r.NoExitAt,
|
||||
learnedAt: r.LearnedAt, transferAt: r.TransferAt, flashAt: r.FlashAt, facility: r.Facility.Active, mini: r.Mini != nil, outcome: r.Outcome}
|
||||
}
|
||||
@@ -163,9 +163,13 @@ func (s *LevelScene) updateEffects(g *Game) {
|
||||
dist := math.Hypot(float64(tr.platform.X*TileSize)-px, float64(tr.platform.Y*TileSize)-py) / TileSize
|
||||
mask := noiseMask(r.Level.Noise)
|
||||
switch phase {
|
||||
case level.Approaching:
|
||||
case level.Approaching: // ankomstjingeln: tåget är på väg
|
||||
g.Audio.Play(SoundApproach, volumeAt(dist)*mask)
|
||||
case level.Closing:
|
||||
case level.Arriving: // ding-dong när det rullar in
|
||||
g.Audio.Play(SoundAnnounce, volumeAt(dist)*mask)
|
||||
case level.Closing: // stationens avgångsmelodi innan dörrarna stängs
|
||||
g.Audio.Play(MelodyFor(r.Level), volumeAt(dist)*mask)
|
||||
case level.Leaving: // dörrsignalen när det går
|
||||
g.Audio.Play(SoundDoors, volumeAt(dist)*mask)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user