diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3ed4d6a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,22 @@ +# Ändringslogg + +## v0.1.0 – 2026-10-25 + +Första versionerade releasen: hela storyn från Narita till Narita. + +- Åtta huvudbanor och ett sidospår, två resor med byten, flera våningar, + folk, reklam, sorl, vagnar med kvinnovagn och trängsel. +- Linjenät med riktiga stationer: tågen bedöms efter var de går; listläge + och skyltläge; terminalen vid automaten. +- Japanska skyltar med ordinlärning, ordlista och språkpoäng. +- Sällskapet (lämmeltåget) med mat, dryck och toa; vem som betalar i + kiosken; spärrar utan återvändo med personalanrop; toaletter innanför och + utanför spärrarna. +- Tågkort, biljetter, telefon, minispel (påfyllning, biljett med + sittplatser, trängsel, tågresa, vagnval). +- Progression: XP, skillträd, items och butik, sparfil. +- Hemresan (slutscen), oändligt läge med poänglista, New Game+. +- Ljud: stationsmelodier, effekter och menymusik; inställningar för volym, + språk (sv/en), fönsterskala och fullskärm; fönsterikon. +- Verktyg: `tools/build`, `tools/shots` (headless skärmdumpar), + `tools/levelcheck`, `tools/genart`, `tools/gensound`. diff --git a/README.md b/README.md index 3e87186..3cc141b 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,10 @@ stationerna släpper spärren inte ut en som checkat in: man får **kalla på personal** med Enter vid spärren. Efter storyn finns **oändligt läge** (O på bankartan: lottade resmål på storyns stationer tills man förlorar, egen poänglista) och **New Game+** (G två gånger: om från början med skills kvar -och ett större sällskap). Nästa är M12 (ljud, polish, release) och M10b -(bussar). Se [doc/roadmap.md](doc/roadmap.md). +och ett större sällskap). Version 0.1.0 har ljud (stationsmelodier, effekter, +menymusik), inställningar (S: volymer, språk sv/en, skala, fullskärm) och +fönsterikon. Kvar i roadmapen är M10b (bussar). Se +[doc/roadmap.md](doc/roadmap.md) och [CHANGELOG.md](CHANGELOG.md). ![Bankartan](doc/img/map.png) @@ -87,6 +89,8 @@ labyrintgolv, samma klocka och samma hungriga sällskap. ![Oändligt läge](doc/img/endless.png) +![Inställningar](doc/img/settings.png) + På de svåraste banorna har alla i sällskapet egna kort som måste ha saldo, alla behöver biljett, och när biljetten är köpt ska sällskapet placeras i vagnen efter sina önskemål: fönster eller gång, vänster eller höger sida, @@ -201,8 +205,9 @@ Enter bekräftar (håll Enter för att mata in sedlar). H frågar om hjälp vid skylt, L öppnar ordlistan, T skillträdet, I garderoben, B butiken vid en kiosk, M döljer/visar minikartan, P på kartan lägger kortet i telefonen, N på kartan växlar navigeringsläge (lista/skyltar), O på kartan startar oändligt läge -och G (två gånger) New Game+ när storyn är klar, Esc går tillbaka, R spelar -om. Gamepad +och G (två gånger) New Game+ när storyn är klar, S på kartan öppnar +inställningarna (volymer, språk, skala, fullskärm), F11 växlar fullskärm +överallt, Esc går tillbaka, R spelar om. Gamepad med standardlayout fungerar: d-pad/vänster spak, A = välj, B = tillbaka, Y = hjälp, X = ordlista, Back/Select = karta. @@ -213,7 +218,7 @@ Y = hjälp, X = ordlista, Back/Select = karta. | `go run ./tools/build …` | bygger (se ovan) | | `go run ./tools/genart` | genererar placeholder-grafiken `resources/sprites/tiles.png`, `player.png` och `plane.png` | | `go run ./tools/levelcheck [ord…]` | ritar varje bana (per våning) som PNG i `build/levelcheck/` och rapporterar lösbarhet, onåbara rutor, perronger utanför spärrar, tågens bedömning i linjenätet och resornas byten; avslutar med fel om något är trasigt | -| `go run ./tools/gensound` | genererar placeholder-ljuden `resources/sound/*.wav` (avgångsmelodi, dörrsignal) | +| `go run ./tools/gensound` | genererar alla ljud i `resources/sound/*.wav` (stationsmelodier, jinglar, effekter, sorl, menymusik), se `resources/sound/README.md` | | `go run ./tools/shots [ord…]` | tar dokumentationens skärmdumpar till `doc/img/` – **kör efter varje grafik-/UI-ändring och committa bilderna**. Med argument körs bara skott vars bana eller skript innehåller något av orden (t.ex. `go run ./tools/shots 005 ride.png`). På Linux körs spelet på en osynlig skärm (headless Wayfire + Xwayland) så att inget fönster syns eller tar fokus; `METRO_HEADLESS=0` använder skärmen | ## Beroenden diff --git a/cmd/metro-puzzle-jp/main.go b/cmd/metro-puzzle-jp/main.go index 64c3b1e..14e9a68 100644 --- a/cmd/metro-puzzle-jp/main.go +++ b/cmd/metro-puzzle-jp/main.go @@ -4,6 +4,7 @@ package main import ( "flag" "fmt" + "image" "log" "os" "strings" @@ -39,13 +40,15 @@ func main() { return } - g, err := game.New(game.Options{LevelID: *levelID, SmokeFrames: *smoke, Script: *script, KnownWords: splitWords(*know), Points: *points, Seed: *seed, Phone: *phone, CardYen: *card, Debuffs: splitWords(*debuffs), Mute: *mute, SavePath: *savePath, NoSave: *noSave, Mode: *mode, Ending: *ending, Endless: *endlessFlag}) + g, err := game.New(game.Options{LevelID: *levelID, SmokeFrames: *smoke, Script: *script, KnownWords: splitWords(*know), Points: *points, Seed: *seed, Phone: *phone, CardYen: *card, Debuffs: splitWords(*debuffs), Mute: *mute, SavePath: *savePath, NoSave: *noSave, Mode: *mode, Ending: *ending, Endless: *endlessFlag, Scale: *scale}) if err != nil { log.Fatalf("kunde inte starta: %v", err) } - ebiten.SetWindowSize(game.ScreenW**scale, game.ScreenH**scale) ebiten.SetWindowTitle("Metro Puzzle JP") + if icon := g.Assets.Icon; icon != nil { + ebiten.SetWindowIcon([]image.Image{icon}) + } ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled) ebiten.SetVsyncEnabled(true) diff --git a/doc/img/map.png b/doc/img/map.png index 20e30ba..3a6e5fb 100644 Binary files a/doc/img/map.png and b/doc/img/map.png differ diff --git a/doc/img/settings.png b/doc/img/settings.png new file mode 100644 index 0000000..707d74f Binary files /dev/null and b/doc/img/settings.png differ diff --git a/doc/mal-och-tester.md b/doc/mal-och-tester.md index e77f774..625a7a7 100644 --- a/doc/mal-och-tester.md +++ b/doc/mal-och-tester.md @@ -139,6 +139,8 @@ att bygga på Linux som på Windows. | `internal/endless` | `TestPlanIsDeterministicAndValid`, `TestDestinationsNeedTrapAndRoute` | **M11:** samma seed → samma följd, annan seed → annan; varje mål har 1–4 rätta tåg och en fälla, nytt resmål, inga byten, aldrig samma station två gånger i rad; sällskapet växer och tidsgränsen krymper | | `internal/progress` | `TestNewGamePlusKeepsSkillsResetsItems` | **M11:** NG+ behåller skills, XP, skillpoäng och klarad story; nollställer items, utrustning och banresultat | | `internal/save` | `TestEndlessRecords` | **M11:** poänglistan sorterad, kapad till fem, "ny bästa" bara när toppen slås | +| `internal/game` | `TestAllSoundsDecode`, `TestMelodyForIsStableAndKnown` | **M12:** alla ljud i `AllSounds` finns, avkodas och är minst 50 ms; melodin per bana är stabil och känd; ljudlagret är nil-säkert | +| `internal/save` | `TestSettingsDefaultsAndNormalize` | **M12:** standardinställningar, klampning av volymer/skala, okänt språk blir sv, saknade inställningar fylls i | | `internal/level` | `TestToiletSides` | **M10d:** 005 och 008b har toaletter bara innanför, 007b på båda sidor, 007 och 008 bara utanför; innanför bara på banor med spärrar utan återvändo | | `internal/sim` | `TestJourneyWithTransfer`, `TestJourneyWrongTrainOnSecondLeg`, `TestChainLevelsLoad` | **M10:** bytet fortsätter omgången på nästa station med klocka, plånbok, sällskap och par-summa; vinst först på sista delbanan; fel håll på bytesstationen förlorar; delbanor laddas enskilt | | `internal/minigame` | `TestCarPickAlightingAndChoice`, `TestCrowdLevelNarrowsZone`, `TestRideCrowdLetPass` | Folk av först (för tidigt räknas), seedad trängsel med minst en lugn vagn, val; trängsel smalnar zonen; resan: kortare tid per station, passagerare som vill förbi, blockerade räknas | @@ -167,7 +169,7 @@ att bygga på Linux som på Windows. | ~~M10d Toaletter innanför/utanför~~ ✓ | Toaletterna ligger på rätt sida av spärrarna per bana – finns, se tabellen ovan | | M10b Bussar och vägar | Bussar följer tidtabell och vägrutor; hållplatser bedöms som perronger; överstimulerande UI-reglaget påverkar bara presentationen | | ~~M11 Oändligt läge & NG+~~ ✓ | Målsekvens deterministisk per seed, giltiga mål med fälla, NG+ behåller skills men nollställer items, poänglista – finns, se tabellen ovan | -| M12 Ljud & polish | Alla ljudfiler som refereras finns och går att avkoda; Windows-binär startar (manuellt) | +| ~~M12 Ljud & polish~~ ✓ | Alla ljudfiler som refereras finns och går att avkoda, stationsmelodi per bana, inställningar normaliseras – finns, se tabellen ovan; Windows-binär startar (manuellt, kvar) | ### Acceptanskriterier för "klar" @@ -188,7 +190,8 @@ En milstolpe är klar när: - [ ] Tomt kort tvingar till automaten; minispelet är begripligt första gången. - [ ] Minikartan fylls i när man går runt och nollställs vid ny bana. - [ ] Spara mitt i en bana, ladda, och få samma minispelsutfall. -- [ ] Ljud spelas vid ankomst och avgång; inget knaster vid loop. +- [ ] Ljud spelas vid ankomst (jingel + ding-dong) och avgång (stationsmelodi + dörrsignal); inget knaster när sorlet och menymusiken loopar. +- [ ] Inställningarna (S) ändrar volym, språk, skala och fullskärm direkt och överlever omstart. - [ ] Samma sparfil fungerar på Linux och Windows. ### Kommandon diff --git a/doc/roadmap.md b/doc/roadmap.md index 35e4500..846aa92 100644 --- a/doc/roadmap.md +++ b/doc/roadmap.md @@ -673,13 +673,37 @@ på **brasse-linux01-runnern** (label `linux-amd64`), aldrig på Pi5:n. - **Kvar till senare:** oändligt läge med byten (resor över två stationer), stationer som genereras vid körning (generatorn in i repot). -### M12 – Ljud, polish och release +### M12 – Ljud, polish och release ✓ (klar 2026-10-25) - Riktiga avgångsmelodier/ankomstjinglar (ersätter placeholders från M7b), utrop, effekter, musik; mixning och volyminställningar. - Inställningar (ljudnivåer, språk sv/en, skala, tangenter). - Windows-test på riktig maskin, ikon i exe:n, eventuell Gitea-CI med releaser enligt playbooken. +- **Så blev det:** `tools/gensound` syntetiserar 21 ljud (egna + kompositioner, deterministiska): fyra **stationsmelodier** i + klockklang (`melody-a`–`d`, banans `melody`-fält eller vald efter id), + ankomstjingel, ding-dong vid inrullning, dörrsignal vid avgång, + spärrens pip/nekande, kiosk, toalett, lärt ord, bekräfta/varning, byte, + personal, vinst, förlust, flygplan, sorl och en menymusikslinga. + `internal/game/sfx.go` spelar effekterna utifrån vad som hänt i `sim.Run` + sedan förra bildrutan (räknare och tidsstämplar), tågfaserna i + `trains.go`. **Ljudlagret** har tre volymer (huvud × effekter resp. + musik/sorl) och byter inte slinga i onödan (menymusiken fortsätter + mellan karta, inställningar och listor). **Inställningar** (S på + bankartan, sparas i sparfilen): volymer, språk sv/en (texterna laddas om + direkt), fönsterskala 1–6 (0 = flaggan `-scale`), fullskärm (även F11 + överallt), plus en tangentöversikt. **Fönsterikon** (`icon.png` från + genart) sätts vid start. **Release:** `CHANGELOG.md` och taggen + `v0.1.0` → versionerad release på Gitea (CI:n bygger redan Linux och + Windows). Windows-testet på riktig maskin och ikon inbakad i exe:n + (.syso) är kvar att göra manuellt. +- **Klar när:** alla ljud finns och avkodas (`game.TestAllSoundsDecode`), + stationsmelodin är stabil per bana (`TestMelodyForIsStableAndKnown`), + inställningar får standardvärden och normaliseras + (`save.TestSettingsDefaultsAndNormalize`), `melody`-fältet valideras. ✓ +- **Kvar till senare:** ikon i exe:n (.syso), omdefinierbara tangenter, + riktiga inspelningar, Windows-test på riktig maskin (Björn). ## Prioritering diff --git a/doc/spelbeskrivning.md b/doc/spelbeskrivning.md index 4f637b1..2ae16de 100644 --- a/doc/spelbeskrivning.md +++ b/doc/spelbeskrivning.md @@ -283,6 +283,7 @@ från skills i skill tree, och från unika skills man får av sidouppdrag. | "flera stopp och byten" | En resa är en kedja av banfiler (`next` → delbana med `kind: "leg"`). Samma omgång fortsätter på nästa station med klocka, plånbok och sällskap; resmålet i HUD:en visas "via" bytesstationen. Vilken bytesstation som gäller bestämmer banan – spelaren väljer tåg, inte rutt | Fri ruttplanering (välj själv var man byter) är en möjlig utbyggnad | | "slutar när man ska tillbaka till flygplatsen" | Sista huvudbanan (008) går Tokyo → Nippori → Narita flygplats med Keisei. När den klaras första gången visas hemresan: planet lyfter, resan summeras, eftertexter | Slutet går att se igen via `-ending` | | Buss | Uppskjuten till M10b (vägrutor, hållplatser, egen sprite) | | +| "De ikoniska melodierna" | Egna syntetiserade stationsmelodier i klockklang (fyra stycken, en per station) – riktiga JR-melodier är upphovsrättsskyddade och spelas inte in | Byt filerna i `resources/sound/` om egna inspelningar finns | | "Oändligt läge … målen är slumpade" | Målen är storyns stationer med lottade resmål (samma labyrint, samma tåg, nytt resmål avgör vilket tåg som är rätt), seedat; sällskapet växer och tidsgränsen krymper per mål; omgången slutar vid första förlust och hamnar i en egen poänglista | Slumpade labyrinter vid körning kan komma senare | | "New Game+ … behåll alla dina skills" | Skills, XP och språkkunskap behålls; items, stjärnor, pengar och debuffar nollställs; sällskapet är en person större per NG+-varv | | | Repo-namnet | Arbetsnamnet var "jp-subway-pussel"; Björn döpte Gitea-repot till **metro-puzzle-jp** (2026-10-23), så repo, modul, binär och titel heter så | Spelets visningsnamn "Metro Puzzle JP" kan ändras fritt i `resources/text/*.json` | diff --git a/internal/game/assets.go b/internal/game/assets.go index 7a6a6bc..cbe7d46 100644 --- a/internal/game/assets.go +++ b/internal/game/assets.go @@ -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 { diff --git a/internal/game/audio.go b/internal/game/audio.go index e40e534..70eb38f 100644 --- a/internal/game/audio.go +++ b/internal/game/audio.go @@ -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. diff --git a/internal/game/audio_test.go b/internal/game/audio_test.go new file mode 100644 index 0000000..8e31ad4 --- /dev/null +++ b/internal/game/audio_test.go @@ -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() +} diff --git a/internal/game/game.go b/internal/game/game.go index c70bcaf..530ce7f 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -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 { diff --git a/internal/game/input.go b/internal/game/input.go index 96c4a73..4b561dd 100644 --- a/internal/game/input.go +++ b/internal/game/input.go @@ -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) diff --git a/internal/game/scene_ending.go b/internal/game/scene_ending.go index 1a895b2..5ba5fde 100644 --- a/internal/game/scene_ending.go +++ b/internal/game/scene_ending.go @@ -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")} } diff --git a/internal/game/scene_level.go b/internal/game/scene_level.go index b27adba..7952c2f 100644 --- a/internal/game/scene_level.go +++ b/internal/game/scene_level.go @@ -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() diff --git a/internal/game/scene_map.go b/internal/game/scene_map.go index c456ca7..5ed23a0 100644 --- a/internal/game/scene_map.go +++ b/internal/game/scene_map.go @@ -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 { diff --git a/internal/game/scene_settings.go b/internal/game/scene_settings.go new file mode 100644 index 0000000..0f66f07 --- /dev/null +++ b/internal/game/scene_settings.go @@ -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) +} diff --git a/internal/game/sfx.go b/internal/game/sfx.go new file mode 100644 index 0000000..382e3d1 --- /dev/null +++ b/internal/game/sfx.go @@ -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} +} diff --git a/internal/game/trains.go b/internal/game/trains.go index efcde97..44ea0f7 100644 --- a/internal/game/trains.go +++ b/internal/game/trains.go @@ -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) } } diff --git a/internal/level/level.go b/internal/level/level.go index 289f741..8e46936 100644 --- a/internal/level/level.go +++ b/internal/level/level.go @@ -268,6 +268,7 @@ type Level struct { IndividualCards bool `json:"individualCards"` // alla i sällskapet har egna kort (och biljetter) som spärren kräver SeatSelection bool `json:"seatSelection"` // biljettköpet fortsätter med sittplatsval efter sällskapets önskemål OneWayGates bool `json:"oneWayGates"` // spärren släpper inte ut en igen förrän man åkt; personal vid spärren låser upp (tar tid) + Melody string `json:"melody"` // stationens avgångsmelodi: "a".."d" (tomt = vald efter id) FareYen int `json:"fareYen"` // vad spärren drar från kortet (0 = standard) Grid []string `json:"grid,omitempty"` // envåningsbana (kompatibilitet) – blir Floors[0] Floors []Storey `json:"floors,omitempty"` // flervåningsbana; alla våningar lika stora @@ -396,6 +397,9 @@ func (l *Level) Validate() error { if l.Order < 0 { return fmt.Errorf("level %s: negativ order", l.ID) } + if l.Melody != "" && (len(l.Melody) != 1 || l.Melody[0] < 'a' || l.Melody[0] > 'd') { + return fmt.Errorf("level %s: melody måste vara a–d", l.ID) + } if len(l.Floors) == 0 { if len(l.Grid) == 0 { return fmt.Errorf("level %s: tomt rutnät", l.ID) diff --git a/internal/save/save.go b/internal/save/save.go index a66fc44..6fbdfea 100644 --- a/internal/save/save.go +++ b/internal/save/save.go @@ -42,6 +42,32 @@ type State struct { Current *RunRef `json:"current,omitempty"` NavMode string `json:"navMode,omitempty"` // "list" (standard) eller "signs" Endless []EndlessRecord `json:"endless,omitempty"` // oändligt läge: bästa omgångarna, högst poäng först + Settings *Settings `json:"settings,omitempty"` +} + +// Settings är spelarens inställningar (M12). +type Settings struct { + Master int `json:"master"` // 0–10 + Effects int `json:"effects"` // 0–10 + Music int `json:"music"` // 0–10 (musik och sorl) + Lang string `json:"lang"` // "sv" eller "en" + Scale int `json:"scale"` // fönsterskala 1–6, 0 = flaggan -scale + Fullscreen bool `json:"fullscreen"` +} + +// DefaultSettings är standardinställningarna. +func DefaultSettings() *Settings { return &Settings{Master: 8, Effects: 8, Music: 6, Lang: "sv"} } + +// normalize ser till att inställningarna är rimliga (saknade = standard). +func (s *Settings) normalize() { + clamp := func(v *int) { *v = max(0, min(10, *v)) } + clamp(&s.Master) + clamp(&s.Effects) + clamp(&s.Music) + if s.Lang != "sv" && s.Lang != "en" { + s.Lang = "sv" + } + s.Scale = max(0, min(6, s.Scale)) } // EndlessRecord är en avslutad omgång i oändligt läge. @@ -77,7 +103,7 @@ func (s *State) BestEndless() *EndlessRecord { // New är ett nytt spel. func New() *State { - return &State{Version: Version, Know: lang.NewKnowledge(), Wallet: sim.NewWallet(), Profile: &sim.Profile{}, Progress: progress.New()} + return &State{Version: Version, Know: lang.NewKnowledge(), Wallet: sim.NewWallet(), Profile: &sim.Profile{}, Progress: progress.New(), Settings: DefaultSettings()} } // DefaultPath är sparfilens plats: ~/.config/metro-puzzle-jp/save.json på @@ -116,6 +142,10 @@ func migrate(s *State) error { } // Version 0 (saknat fält) och 1 har samma form; här läggs framtida steg. s.Version = Version + if s.Settings == nil { + s.Settings = DefaultSettings() + } + s.Settings.normalize() if s.Know == nil { s.Know = lang.NewKnowledge() } diff --git a/internal/save/save_test.go b/internal/save/save_test.go index 297e31e..7e0a4f1 100644 --- a/internal/save/save_test.go +++ b/internal/save/save_test.go @@ -92,3 +92,27 @@ func TestEndlessRecords(t *testing.T) { t.Fatalf("listan: %+v", s.Endless) } } + +func TestSettingsDefaultsAndNormalize(t *testing.T) { + s := New() + if s.Settings == nil || s.Settings.Master != 8 || s.Settings.Lang != "sv" { + t.Fatalf("standardinställningar: %+v", s.Settings) + } + path := filepath.Join(t.TempDir(), "save.json") + if err := os.WriteFile(path, []byte(`{"version":1,"settings":{"master":99,"music":-3,"lang":"xx","scale":12}}`), 0o644); err != nil { + t.Fatal(err) + } + st, _, err := Load(path) + if err != nil { + t.Fatal(err) + } + if st.Settings.Master != 10 || st.Settings.Music != 0 || st.Settings.Lang != "sv" || st.Settings.Scale != 6 { + t.Fatalf("normaliserade: %+v", st.Settings) + } + if err := os.WriteFile(path, []byte(`{"version":1}`), 0o644); err != nil { + t.Fatal(err) + } + if st, _, err = Load(path); err != nil || st.Settings == nil || st.Settings.Effects != 8 { + t.Fatalf("saknade inställningar ska bli standard: %+v %v", st.Settings, err) + } +} diff --git a/resources/sound/README.md b/resources/sound/README.md index be80a3f..9091a36 100644 --- a/resources/sound/README.md +++ b/resources/sound/README.md @@ -1,16 +1,26 @@ # resources/sound – ljud -Effekter och musik. Format: WAV (korta effekter, 16-bit mono 44,1 kHz) och -OGG (musik, längre ljud). +Alla ljud är syntetiserade av `go run ./tools/gensound` (16-bit mono +44,1 kHz WAV, deterministiska, egna kompositioner – inga inspelade JR-melodier). +Byt gärna ut dem mot riktiga inspelningar, men **behåll filnamnen**; listan +i `internal/game/audio.go` (`AllSounds`) testas så att alla finns och går +att avkoda. -- `melody-approach.wav` – avgångsmelodi i japansk stationsstil, spelas när - ett tåg är på väg in till perrongen (fasen *approaching*). -- `door-chime.wav` – dörrsignal, spelas strax före avgång (fasen *closing*). +| Fil | När | +|-----|-----| +| `melody-approach.wav` | ankomstjingel när ett tåg är på väg in (*approaching*) | +| `announce.wav` | ding-dong när tåget rullar in (*arriving*) | +| `melody-a.wav` … `melody-d.wav` | stationens avgångsmelodi innan dörrarna stängs (*closing*); banans `melody`-fält eller vald efter id | +| `door-chime.wav` | dörrsignal när tåget går (*leaving*) | +| `chatter.wav` | loopbart stationssorl på banor med `noise` > 0 | +| `music-map.wav` | bankartans/menyernas musikslinga | +| `gate-beep.wav`, `gate-deny.wav` | spärren släpper igenom / nekar | +| `kiosk.wav`, `toilet.wav` | köp i kiosken, toalettbesök klart | +| `learn.wav` | nytt japanskt ord | +| `confirm.wav`, `cancel.wav` | minispel öppnas / varning (fel vagn, för tidigt …) | +| `transfer.wav`, `staff.wav` | byte till nästa station, personalen har låst upp | +| `win.wav`, `lose.wav` | banan klarad / förlorad | +| `plane.wav` | hemresan | -- `chatter.wav` – loopbart stationssorl (bakgrundsprat), spelas på banor med - `noise` > 0 och konkurrerar med melodierna. - -Alla är **placeholders** genererade av `go run ./tools/gensound`; riktiga -inspelningar/kompositioner ersätter dem i M12 (behåll filnamnen). -Planerat: utrop, folkmassa, spärr-pip, automat-ljud, UI-klick, bakgrundsprat -och reklam som ljudröra (M9b). +Volymerna styrs av inställningarna (S på bankartan): huvudvolym × effekter +respektive musik/sorl. diff --git a/resources/sound/announce.wav b/resources/sound/announce.wav new file mode 100644 index 0000000..ca7b910 Binary files /dev/null and b/resources/sound/announce.wav differ diff --git a/resources/sound/cancel.wav b/resources/sound/cancel.wav new file mode 100644 index 0000000..43da336 Binary files /dev/null and b/resources/sound/cancel.wav differ diff --git a/resources/sound/confirm.wav b/resources/sound/confirm.wav new file mode 100644 index 0000000..2a13b9d Binary files /dev/null and b/resources/sound/confirm.wav differ diff --git a/resources/sound/gate-beep.wav b/resources/sound/gate-beep.wav new file mode 100644 index 0000000..1f0ce89 Binary files /dev/null and b/resources/sound/gate-beep.wav differ diff --git a/resources/sound/gate-deny.wav b/resources/sound/gate-deny.wav new file mode 100644 index 0000000..955694f Binary files /dev/null and b/resources/sound/gate-deny.wav differ diff --git a/resources/sound/kiosk.wav b/resources/sound/kiosk.wav new file mode 100644 index 0000000..69ae474 Binary files /dev/null and b/resources/sound/kiosk.wav differ diff --git a/resources/sound/learn.wav b/resources/sound/learn.wav new file mode 100644 index 0000000..33e73e0 Binary files /dev/null and b/resources/sound/learn.wav differ diff --git a/resources/sound/lose.wav b/resources/sound/lose.wav new file mode 100644 index 0000000..beed407 Binary files /dev/null and b/resources/sound/lose.wav differ diff --git a/resources/sound/melody-a.wav b/resources/sound/melody-a.wav new file mode 100644 index 0000000..5a7ffd2 Binary files /dev/null and b/resources/sound/melody-a.wav differ diff --git a/resources/sound/melody-b.wav b/resources/sound/melody-b.wav new file mode 100644 index 0000000..8848e8f Binary files /dev/null and b/resources/sound/melody-b.wav differ diff --git a/resources/sound/melody-c.wav b/resources/sound/melody-c.wav new file mode 100644 index 0000000..2bd2e05 Binary files /dev/null and b/resources/sound/melody-c.wav differ diff --git a/resources/sound/melody-d.wav b/resources/sound/melody-d.wav new file mode 100644 index 0000000..8d844df Binary files /dev/null and b/resources/sound/melody-d.wav differ diff --git a/resources/sound/music-map.wav b/resources/sound/music-map.wav new file mode 100644 index 0000000..710a4b5 Binary files /dev/null and b/resources/sound/music-map.wav differ diff --git a/resources/sound/plane.wav b/resources/sound/plane.wav new file mode 100644 index 0000000..770af66 Binary files /dev/null and b/resources/sound/plane.wav differ diff --git a/resources/sound/staff.wav b/resources/sound/staff.wav new file mode 100644 index 0000000..db8649e Binary files /dev/null and b/resources/sound/staff.wav differ diff --git a/resources/sound/toilet.wav b/resources/sound/toilet.wav new file mode 100644 index 0000000..d853255 Binary files /dev/null and b/resources/sound/toilet.wav differ diff --git a/resources/sound/transfer.wav b/resources/sound/transfer.wav new file mode 100644 index 0000000..3772894 Binary files /dev/null and b/resources/sound/transfer.wav differ diff --git a/resources/sound/win.wav b/resources/sound/win.wav new file mode 100644 index 0000000..333afbc Binary files /dev/null and b/resources/sound/win.wav differ diff --git a/resources/sprites/icon.png b/resources/sprites/icon.png new file mode 100644 index 0000000..9687f26 Binary files /dev/null and b/resources/sprites/icon.png differ diff --git a/resources/text/en.json b/resources/text/en.json index f2419cb..8265d4f 100644 --- a/resources/text/en.json +++ b/resources/text/en.json @@ -140,7 +140,7 @@ "map.party": "Party", "map.resume": "⟳ resume (saved seed)", "map.locked": "Locked - clear the previous level first.", - "map.hint": "Enter start N mode L words T skills I items P phone Esc", + "map.hint": "Enter start N mode L words T skills I items P phone S set.", "skills.title": "Skills 技", "skills.to_next": "XP to next", "skills.already": "Already unlocked.", @@ -244,5 +244,17 @@ "result.endless_lost": "Endless mode over after %d goals: %d points (best %d).", "result.endless_best": "New best!", "result.hint_endless": "Enter: next goal L: words Esc: end the run", - "result.hint_endless_over": "Enter/Esc: level map L: words" + "result.hint_endless_over": "Enter/Esc: level map L: words", + "settings.title": "Settings", + "settings.master": "Master volume", + "settings.effects": "Effects", + "settings.music": "Music and chatter", + "settings.lang": "Language", + "settings.scale": "Window scale", + "settings.scale_auto": "from -scale (default 4x)", + "settings.fullscreen": "Fullscreen (F11)", + "settings.hint": "Arrows: pick row / change Enter: toggle Esc: back", + "settings.keys": "Arrows/WASD walk, Enter select, Esc back, H help, L words, T skills, I wardrobe, B shop, M map, N mode, O endless, G NG+, S settings, F11 fullscreen. Gamepad: A select, B back, Y help, X words, Back map.", + "lang.sv": "Svenska", + "lang.en": "English" } diff --git a/resources/text/sv.json b/resources/text/sv.json index 312ac2e..51eea77 100644 --- a/resources/text/sv.json +++ b/resources/text/sv.json @@ -140,7 +140,7 @@ "map.party": "Sällskap", "map.resume": "⟳ fortsätt (sparad seed)", "map.locked": "Låst - klara föregående bana först.", - "map.hint": "Enter start N läge L ord T skills I items P tel Esc", + "map.hint": "Enter start N läge L ord T skills I items P tel S inst.", "skills.title": "Skills 技", "skills.to_next": "XP till nästa", "skills.already": "Redan upplåst.", @@ -244,5 +244,17 @@ "result.endless_lost": "Oändligt läge slut efter %d mål: %d poäng (bästa %d).", "result.endless_best": "Ny bästa!", "result.hint_endless": "Enter: nästa mål L: ordlista Esc: avsluta omgången", - "result.hint_endless_over": "Enter/Esc: bankartan L: ordlista" + "result.hint_endless_over": "Enter/Esc: bankartan L: ordlista", + "settings.title": "Inställningar", + "settings.master": "Huvudvolym", + "settings.effects": "Effekter", + "settings.music": "Musik och sorl", + "settings.lang": "Språk", + "settings.scale": "Fönsterskala", + "settings.scale_auto": "från -scale (standard 4×)", + "settings.fullscreen": "Fullskärm (F11)", + "settings.hint": "Pilar: välj rad / ändra Enter: växla Esc: tillbaka", + "settings.keys": "Pilar/WASD gå, Enter välj, Esc tillbaka, H hjälp, L ord, T skills, I garderob, B butik, M karta, N läge, O oändligt, G NG+, S inställningar, F11 fullskärm. Gamepad: A välj, B tillbaka, Y hjälp, X ord, Back karta.", + "lang.sv": "Svenska", + "lang.en": "English" } diff --git a/tools/genart/main.go b/tools/genart/main.go index 4706e83..6892186 100644 --- a/tools/genart/main.go +++ b/tools/genart/main.go @@ -291,6 +291,22 @@ func main() { planeImg := image.NewNRGBA(image.Rect(0, 0, 32, 12)) plane(canvas{planeImg, 0, 0}) save(filepath.Join(out, "plane.png"), planeImg) + + iconImg := image.NewNRGBA(image.Rect(0, 0, 32, 32)) + icon(canvas{iconImg, 0, 0}) + save(filepath.Join(out, "icon.png"), iconImg) +} + +// icon är fönsterikonen (32×32): ett tågfront på mörk platta. +func icon(c canvas) { + c.rect(0, 0, 32, 32, rgb(38, 43, 68)) + c.rect(6, 4, 20, 24, rgb(236, 240, 245)) // front + c.rect(8, 7, 16, 8, rgb(90, 140, 220)) // vindruta + c.rect(6, 17, 20, 3, rgb(214, 60, 60)) // rand + c.rect(9, 22, 4, 3, rgb(255, 230, 120)) // strålkastare + c.rect(19, 22, 4, 3, rgb(255, 230, 120)) // strålkastare + c.rect(4, 28, 24, 2, rgb(150, 160, 175)) // underrede + c.rect(13, 1, 6, 3, rgb(150, 160, 175)) // strömavtagare } // plane är flygplanet i slutscenen (32×12, nosen åt höger). diff --git a/tools/gensound/main.go b/tools/gensound/main.go index afa2584..5579e93 100644 --- a/tools/gensound/main.go +++ b/tools/gensound/main.go @@ -1,12 +1,13 @@ -// Genererar placeholder-ljud tills riktiga inspelningar finns: +// Genererar spelets ljud – syntetiserade, deterministiska och fria att +// använda, tills riktiga inspelningar finns: // // go run ./tools/gensound // -// Skriver resources/sound/melody-approach.wav (en kort pentatonisk -// avgångsmelodi i japansk stationsstil, spelas när tåget är på väg in) och -// resources/sound/door-chime.wav (dörrsignal strax före avgång). 16-bit -// mono 44,1 kHz, syntetiserade med enkla vågformer så att allt är -// deterministiskt. +// Skriver 16-bit mono 44,1 kHz WAV till resources/sound/: fyra +// stationsmelodier (avgångsmelodier i japansk stationsstil, en per +// station), ankomstjingel, utropssignal, dörrsignal, spärrens pip och +// nekande, kiosk, toalett, lärt ord, bekräfta/avbryt, byte, vinst, förlust, +// personal, flygplan, bakgrundssorl och kartans musikslinga. package main import ( @@ -24,20 +25,54 @@ type note struct { dur float64 // sekunder } -// Pentatonisk melodi (C-dur-pentatonik), två takter. +// Toner (Hz). +const ( + c5, d5, e5, f5, g5, a5, b5 = 523.25, 587.33, 659.25, 698.46, 783.99, 880.00, 987.77 + c6, d6, e6, g6, a6 = 1046.50, 1174.66, 1318.51, 1567.98, 1760.00 + g4, a4, b4, e4, f4 = 392.00, 440.00, 493.88, 329.63, 349.23 +) + +// Ankomstjingel (C-dur-pentatonik), spelas när tåget är på väg in. var approachMelody = []note{ - {523.25, 0.18}, {587.33, 0.18}, {659.25, 0.18}, {783.99, 0.36}, - {880.00, 0.18}, {783.99, 0.18}, {659.25, 0.18}, {523.25, 0.36}, + {c5, 0.18}, {d5, 0.18}, {e5, 0.18}, {g5, 0.36}, + {a5, 0.18}, {g5, 0.18}, {e5, 0.18}, {c5, 0.36}, {0, 0.12}, - {659.25, 0.18}, {783.99, 0.18}, {880.00, 0.18}, {1046.50, 0.54}, + {e5, 0.18}, {g5, 0.18}, {a5, 0.18}, {c6, 0.54}, +} + +// Stationsmelodier: fyra olika avgångsmelodier (発車メロディ-stil, egna +// kompositioner), ca 2,5 s, spelas innan dörrarna stängs. +var stationMelodies = map[string][]note{ + "a": {{g5, 0.15}, {e5, 0.15}, {g5, 0.15}, {c6, 0.3}, {b5, 0.15}, {g5, 0.15}, {a5, 0.3}, {0, 0.1}, {g5, 0.15}, {e5, 0.15}, {d5, 0.15}, {c5, 0.6}}, + "b": {{e5, 0.2}, {g5, 0.2}, {a5, 0.2}, {g5, 0.2}, {e5, 0.4}, {0, 0.1}, {d5, 0.2}, {e5, 0.2}, {g5, 0.2}, {e5, 0.2}, {d5, 0.2}, {c5, 0.6}}, + "c": {{c6, 0.12}, {b5, 0.12}, {a5, 0.12}, {g5, 0.24}, {a5, 0.12}, {g5, 0.12}, {e5, 0.24}, {0, 0.08}, {g5, 0.12}, {a5, 0.12}, {c6, 0.12}, {d6, 0.24}, {c6, 0.6}}, + "d": {{a5, 0.18}, {c6, 0.18}, {a5, 0.18}, {g5, 0.36}, {e5, 0.18}, {g5, 0.18}, {a5, 0.36}, {0, 0.1}, {c6, 0.18}, {a5, 0.18}, {g5, 0.18}, {e5, 0.18}, {g5, 0.6}}, } // Dörrsignal: två toner växelvis. var doorChime = []note{ - {1318.51, 0.14}, {1046.50, 0.14}, {1318.51, 0.14}, {1046.50, 0.14}, - {1318.51, 0.14}, {1046.50, 0.14}, {1318.51, 0.14}, {1046.50, 0.28}, + {e6, 0.14}, {c6, 0.14}, {e6, 0.14}, {c6, 0.14}, + {e6, 0.14}, {c6, 0.14}, {e6, 0.14}, {c6, 0.28}, } +// Utropssignal ("ding-dong") före utrop. +var announce = []note{{e6, 0.25}, {c6, 0.45}} + +// Korta effekter. +var ( + learnJingle = []note{{c6, 0.09}, {e6, 0.09}, {g6, 0.22}} + confirmBlip = []note{{a5, 0.05}, {c6, 0.07}} + cancelBlip = []note{{e5, 0.06}, {c5, 0.08}} + transferJingle = []note{{g5, 0.12}, {c6, 0.12}, {e6, 0.12}, {g6, 0.3}} + winFanfare = []note{{c5, 0.14}, {e5, 0.14}, {g5, 0.14}, {c6, 0.4}, {0, 0.06}, {g5, 0.12}, {c6, 0.5}} + loseSting = []note{{e5, 0.22}, {d5, 0.22}, {c5, 0.5}} + kioskCoin = []note{{b5, 0.05}, {e6, 0.05}, {0, 0.04}, {g6, 0.12}} + staffTone = []note{{a4, 0.18}, {0, 0.08}, {a4, 0.18}, {0, 0.12}, {e5, 0.3}} + gateBeep = []note{{a6, 0.09}} + gateDeny = []note{{e4, 0.12}, {0, 0.04}, {e4, 0.12}, {0, 0.04}, {e4, 0.14}} +) + +// render syntetiserar toner med sinus + överton, attack/release-envelope. func render(notes []note, attack, release float64, overtone float64) []int16 { var out []int16 for _, n := range notes { @@ -63,6 +98,133 @@ func render(notes []note, attack, release float64, overtone float64) []int16 { return out } +// bell syntetiserar klockliknande toner: grundton + två övertoner som dör +// ut snabbare, exponentiell avklingning – stationsmelodiernas klang. +func bell(notes []note) []int16 { + var out []int16 + for _, n := range notes { + samples := int(n.dur * rate) + for i := 0; i < samples; i++ { + if n.freq == 0 { + out = append(out, 0) + continue + } + t := float64(i) / rate + env := math.Exp(-3.5 * t / n.dur) + if t < 0.004 { + env *= t / 0.004 + } + if rem := n.dur - t; rem < 0.02 { + env *= rem / 0.02 + } + v := math.Sin(2*math.Pi*n.freq*t) + + 0.45*math.Exp(-6*t/n.dur)*math.Sin(2*math.Pi*2*n.freq*t) + + 0.2*math.Exp(-9*t/n.dur)*math.Sin(2*math.Pi*3*n.freq*t) + out = append(out, int16(v/1.65*env*0.55*32767)) + } + } + return out +} + +// square syntetiserar en råare, fyrkantsliknande ton (spärrens nekande). +func square(notes []note) []int16 { + var out []int16 + for _, n := range notes { + samples := int(n.dur * rate) + for i := 0; i < samples; i++ { + if n.freq == 0 { + out = append(out, 0) + continue + } + t := float64(i) / rate + v := 0.0 + for h := 1.0; h <= 7; h += 2 { // udda övertoner + v += math.Sin(2*math.Pi*n.freq*h*t) / h + } + env := 1.0 + if rem := n.dur - t; rem < 0.02 { + env = rem / 0.02 + } + out = append(out, int16(v/1.6*env*0.35*32767)) + } + } + return out +} + +// lcg är en deterministisk brusgenerator. +type lcg uint32 + +func (l *lcg) next() float64 { + *l = *l*1664525 + 1013904223 + return float64(*l>>8)/float64(1<<24)*2 - 1 +} + +// noiseSweep är filtrerat brus med envelope och filtersvep: toalettspolning +// (fallande) och flygplan (stigande och fallande). +func noiseSweep(seconds, lpFrom, lpTo float64, env func(t float64) float64) []int16 { + n := int(seconds * rate) + out := make([]int16, n) + var r lcg = 777 + lp := 0.0 + for i := 0; i < n; i++ { + t := float64(i) / rate + f := t / seconds + k := lpFrom + (lpTo-lpFrom)*f // filterkoefficient (0..1): högre = ljusare + lp += (r.next() - lp) * k + out[i] = int16(lp * env(t) * 0.7 * 32767) + } + return out +} + +// chatter syntetiserar sex sekunder loopbart stationssorl: lågpassat brus +// med stavelseliknande amplitudmodulation från en deterministisk LCG. +func chatter(seconds float64) []int16 { + n := int(seconds * rate) + out := make([]int16, n) + var r lcg = 12345 + lp := 0.0 + env, envTarget, envT := 0.3, 0.3, 0.0 + for i := 0; i < n; i++ { + t := float64(i) / rate + if t >= envT { // ny "stavelse" var 120–250 ms + envTarget = 0.1 + 0.6*math.Abs(r.next()) + envT = t + 0.12 + 0.13*math.Abs(r.next()) + } + env += (envTarget - env) * 0.002 + lp += (r.next() - lp) * 0.08 // lågpass ≈ 600 Hz + v := lp * env * 0.6 + fade := math.Min(1, math.Min(t, seconds-t)/0.2) // sömlös loop + out[i] = int16(v * fade * 32767) + } + return out +} + +// mapMusic är kartans musikslinga: ett långsamt arpeggio i C-dur-pentatonik +// över två ackord, mjuka toner, loopbar (8 s). +func mapMusic() []int16 { + pattern := []note{ + {c5, 0.5}, {g5, 0.5}, {e5, 0.5}, {a5, 0.5}, {g5, 0.5}, {e5, 0.5}, {d5, 0.5}, {e5, 0.5}, + {a4, 0.5}, {e5, 0.5}, {c5, 0.5}, {g5, 0.5}, {e5, 0.5}, {c5, 0.5}, {d5, 0.5}, {g4, 0.5}, + } + var out []int16 + for _, n := range pattern { + samples := int(n.dur * rate) + for i := 0; i < samples; i++ { + t := float64(i) / rate + env := math.Exp(-2.2*t/n.dur) * math.Min(1, t/0.01) + v := math.Sin(2*math.Pi*n.freq*t) + 0.3*math.Sin(2*math.Pi*2*n.freq*t) + 0.15*math.Sin(2*math.Pi*n.freq/2*t) + out = append(out, int16(v/1.45*env*0.3*32767)) + } + } + // låt tonerna ringa in i varandra: mjuk in-/utfasning i kanterna för loopen + for i := 0; i < 400; i++ { + f := float64(i) / 400 + out[i] = int16(float64(out[i]) * f) + out[len(out)-1-i] = int16(float64(out[len(out)-1-i]) * f) + } + return out +} + func writeWAV(path string, pcm []int16) error { f, err := os.Create(path) if err != nil { @@ -83,39 +245,35 @@ func writeWAV(path string, pcm []int16) error { return binary.Write(f, binary.LittleEndian, pcm) } -// chatter syntetiserar sex sekunder loopbart stationssorl: lågpassat brus -// med stavelseliknande amplitudmodulation från en deterministisk LCG. -func chatter(seconds float64) []int16 { - n := int(seconds * rate) - out := make([]int16, n) - var lcg uint32 = 12345 - next := func() float64 { lcg = lcg*1664525 + 1013904223; return float64(lcg>>8)/float64(1<<24)*2 - 1 } - lp := 0.0 - env, envTarget, envT := 0.3, 0.3, 0.0 - for i := 0; i < n; i++ { - t := float64(i) / rate - // ny "stavelse" var 120–250 ms - if t >= envT { - envTarget = 0.1 + 0.6*math.Abs(next()) - envT = t + 0.12 + 0.13*math.Abs(next()) - } - env += (envTarget - env) * 0.002 - lp += (next() - lp) * 0.08 // lågpass ≈ 600 Hz - v := lp * env * 0.6 - // mjuk in-/utfasning för sömlös loop - fade := math.Min(1, math.Min(t, seconds-t)/0.2) - out[i] = int16(v * fade * 32767) - } - return out -} - func main() { out := filepath.Join(repoRoot(), "resources", "sound") must(os.MkdirAll(out, 0o755)) - must(writeWAV(filepath.Join(out, "melody-approach.wav"), render(approachMelody, 0.01, 0.06, 0.3))) - must(writeWAV(filepath.Join(out, "door-chime.wav"), render(doorChime, 0.005, 0.04, 0.1))) - must(writeWAV(filepath.Join(out, "chatter.wav"), chatter(6))) - fmt.Println("skrev", filepath.Join(out, "melody-approach.wav"), ", door-chime.wav och chatter.wav") + clips := map[string][]int16{ + "melody-approach": render(approachMelody, 0.01, 0.06, 0.3), + "door-chime": render(doorChime, 0.005, 0.04, 0.1), + "announce": bell(announce), + "gate-beep": render(gateBeep, 0.003, 0.02, 0.0), + "gate-deny": square(gateDeny), + "kiosk": render(kioskCoin, 0.003, 0.03, 0.5), + "toilet": noiseSweep(1.1, 0.35, 0.05, func(t float64) float64 { return math.Min(1, t/0.05) * math.Max(0, 1-t/1.1) }), + "learn": bell(learnJingle), + "confirm": render(confirmBlip, 0.003, 0.03, 0.2), + "cancel": render(cancelBlip, 0.003, 0.03, 0.2), + "transfer": bell(transferJingle), + "win": render(winFanfare, 0.005, 0.05, 0.4), + "lose": render(loseSting, 0.01, 0.08, 0.2), + "staff": square(staffTone), + "plane": noiseSweep(3.0, 0.03, 0.4, func(t float64) float64 { return math.Sin(math.Pi * t / 3.0) }), + "chatter": chatter(6), + "music-map": mapMusic(), + } + for id, m := range stationMelodies { + clips["melody-"+id] = bell(m) + } + for name, pcm := range clips { + must(writeWAV(filepath.Join(out, name+".wav"), pcm)) + } + fmt.Printf("skrev %d ljud till %s\n", len(clips), out) } func repoRoot() string { diff --git a/tools/shots/main.go b/tools/shots/main.go index 6cc2a55..c594764 100644 --- a/tools/shots/main.go +++ b/tools/shots/main.go @@ -168,6 +168,7 @@ func buildShots(levelsDir string) error { {"", "wait 150; shot $OUT/ending.png; press enter; wait 5; shot $OUT/ending-summary.png; press enter; wait 250; shot $OUT/credits.png; quit", []string{"-ending", "-save", "$DEMO"}}, {"007-akihabara", toKiosk7 + "; urge Ville; press enter; wait 5; press right; wait 3; shot $OUT/payer.png; quit", []string{"-seed", "7"}}, {"", "wait 10; shot $OUT/endless.png; quit", []string{"-endless", "-seed", "7", "-mode", "list", "-save", "$DEMO"}}, + {"", "wait 10; press s; wait 5; press down; wait 3; shot $OUT/settings.png; quit", []string{"-save", "$DEMO"}}, {"005-ueno", toPlatform5 + "; " + toGateBack + "; wait 5; push " + pushOut + " 30; wait 3; shot $OUT/no-exit.png; press enter; wait 150; shot $OUT/staff.png; quit", []string{"-seed", "7"}}, } return nil