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

22
CHANGELOG.md Normal file
View File

@@ -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`.

View File

@@ -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å 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 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 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 och ett större sällskap). Version 0.1.0 har ljud (stationsmelodier, effekter,
(bussar). Se [doc/roadmap.md](doc/roadmap.md). 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) ![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) ![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, 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 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, 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 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 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 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 och G (två gånger) New Game+ när storyn är klar, S på kartan öppnar
om. Gamepad 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, med standardlayout fungerar: d-pad/vänster spak, A = välj, B = tillbaka,
Y = hjälp, X = ordlista, Back/Select = karta. 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/build …` | bygger (se ovan) |
| `go run ./tools/genart` | genererar placeholder-grafiken `resources/sprites/tiles.png`, `player.png` och `plane.png` | | `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/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 | | `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 ## Beroenden

View File

@@ -4,6 +4,7 @@ package main
import ( import (
"flag" "flag"
"fmt" "fmt"
"image"
"log" "log"
"os" "os"
"strings" "strings"
@@ -39,13 +40,15 @@ func main() {
return 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 { if err != nil {
log.Fatalf("kunde inte starta: %v", err) log.Fatalf("kunde inte starta: %v", err)
} }
ebiten.SetWindowSize(game.ScreenW**scale, game.ScreenH**scale)
ebiten.SetWindowTitle("Metro Puzzle JP") ebiten.SetWindowTitle("Metro Puzzle JP")
if icon := g.Assets.Icon; icon != nil {
ebiten.SetWindowIcon([]image.Image{icon})
}
ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled) ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled)
ebiten.SetVsyncEnabled(true) ebiten.SetVsyncEnabled(true)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

BIN
doc/img/settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -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/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/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/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/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/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 | | `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 | | ~~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 | | 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 | | ~~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" ### 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. - [ ] 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. - [ ] 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. - [ ] 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. - [ ] Samma sparfil fungerar på Linux och Windows.
### Kommandon ### Kommandon

View File

@@ -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), - **Kvar till senare:** oändligt läge med byten (resor över två stationer),
stationer som genereras vid körning (generatorn in i repot). 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), - Riktiga avgångsmelodier/ankomstjinglar (ersätter placeholders från M7b),
utrop, effekter, musik; mixning och volyminställningar. utrop, effekter, musik; mixning och volyminställningar.
- Inställningar (ljudnivåer, språk sv/en, skala, tangenter). - Inställningar (ljudnivåer, språk sv/en, skala, tangenter).
- Windows-test på riktig maskin, ikon i exe:n, eventuell Gitea-CI med - Windows-test på riktig maskin, ikon i exe:n, eventuell Gitea-CI med
releaser enligt playbooken. 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 ## Prioritering

View File

@@ -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 | | "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` | | "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) | | | 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 | | "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 | | | "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` | | 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` |

View File

@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"image" "image"
"image/color" "image/color"
"image/png"
"github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/text/v2" "github.com/hajimehoshi/ebiten/v2/text/v2"
@@ -15,8 +16,8 @@ import (
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
) )
// UILang är UI-språket (sv/en) – blir en inställning i M12. // UILang är UI-språket (sv/en), från inställningarna (Assets.LoadLang).
const UILang = "sv" var UILang = "sv"
// Radhöjd för 10-pixelsfonten. // Radhöjd för 10-pixelsfonten.
const LineH = 12 const LineH = 12
@@ -49,6 +50,7 @@ type Assets struct {
Rails *ebiten.Image // rälsruta (horisontell), roteras för vertikala spår 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) Posters []*ebiten.Image // affischer för visuell röra (ritas över väggrutor)
Plane *ebiten.Image // flygplanet i slutscenen 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] Player [4][3]*ebiten.Image // [riktning: ner, upp, vänster, höger][bildruta: stilla, gå1, gå2]
Text map[string]string Text map[string]string
Dict *lang.Dictionary Dict *lang.Dictionary
@@ -103,10 +105,13 @@ func LoadAssets() (*Assets, error) {
} }
} }
if err := res.LoadJSON(&a.Text, UILang+".json"); err != nil { if err := a.LoadLang(UILang); err != nil {
if err := res.LoadJSON(&a.Text, "text", UILang+".json"); err != nil {
return nil, err 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
}
} }
dict, err := res.ReadFile("text", "jp-dictionary.json") dict, err := res.ReadFile("text", "jp-dictionary.json")
@@ -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. // Width är textens bredd i pixlar med 10-pixelsfonten.
func (a *Assets) Width(s string) int { return int(text.Advance(s, a.Font)) } 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. // T slår upp en UI-text; saknas nyckeln visas nyckeln själv så att det syns.
func (a *Assets) T(key string) string { func (a *Assets) T(key string) string {
if s, ok := a.Text[key]; ok { if s, ok := a.Text[key]; ok {

View File

@@ -3,32 +3,75 @@ package game
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"hash/fnv"
"io" "io"
"math" "math"
"github.com/hajimehoshi/ebiten/v2/audio" "github.com/hajimehoshi/ebiten/v2/audio"
"github.com/hajimehoshi/ebiten/v2/audio/wav" "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" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res"
) )
// Audio spelar korta effekter. nil-säkert: utan ljud (−mute eller fel) är // Audio spelar effekter och en bakgrundsslinga, med volymer i tre lager:
// alla anrop no-ops. // huvudvolym × effekter respektive musik/sorl. nil-säkert: utan ljud
// (−mute eller fel) är alla anrop no-ops.
type Audio struct { type Audio struct {
ctx *audio.Context ctx *audio.Context
clips map[string][]byte // avkodad PCM (16-bit stereo LE) per klipp clips map[string][]byte // avkodad PCM (16-bit stereo LE) per klipp
loop *audio.Player // pågående bakgrundsloop (bakgrundsprat) 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 const sampleRate = 44100
// Ljudklipp (filnamn utan .wav i resources/sound). // Ljudklipp (filnamn utan .wav i resources/sound).
const ( const (
SoundApproach = "melody-approach" SoundApproach = "melody-approach" // ankomstjingel: tåget är på väg in
SoundDoors = "door-chime" SoundAnnounce = "announce" // ding-dong när tåget rullar in
SoundChatter = "chatter" 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. // AudibleTiles är hur många rutor bort man hör perrongens ljud.
const AudibleTiles = 12.0 const AudibleTiles = 12.0
@@ -37,17 +80,9 @@ func newAudio(mute bool) (*Audio, error) {
if mute { if mute {
return nil, nil return nil, nil
} }
a := &Audio{ctx: audio.NewContext(sampleRate), clips: map[string][]byte{}} a := &Audio{ctx: audio.NewContext(sampleRate), clips: map[string][]byte{}, master: 1, effects: 1, music: 1}
for _, name := range []string{SoundApproach, SoundDoors, SoundChatter} { for _, name := range AllSounds() {
b, err := res.ReadFile("sound", name+".wav") pcm, err := decodeSound(name)
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)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -56,7 +91,33 @@ func newAudio(mute bool) (*Audio, error) {
return a, nil 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) { func (a *Audio) Play(name string, volume float64) {
if a == nil || volume <= 0 { if a == nil || volume <= 0 {
return return
@@ -65,19 +126,33 @@ func (a *Audio) Play(name string, volume float64) {
if !ok { if !ok {
return return
} }
v := clamp01(volume * a.effects * a.master)
if v <= 0 {
return
}
p := a.ctx.NewPlayerFromBytes(pcm) p := a.ctx.NewPlayerFromBytes(pcm)
p.SetVolume(math.Min(1, volume)) p.SetVolume(v)
p.Play() 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) { func (a *Audio) Loop(name string, volume float64) {
if a == nil { if a == nil {
return 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() a.StopLoop()
pcm, ok := a.clips[name] pcm, ok := a.clips[name]
if !ok || volume <= 0 { if !ok {
return return
} }
stream := audio.NewInfiniteLoop(bytes.NewReader(pcm), int64(len(pcm))) stream := audio.NewInfiniteLoop(bytes.NewReader(pcm), int64(len(pcm)))
@@ -85,18 +160,18 @@ func (a *Audio) Loop(name string, volume float64) {
if err != nil { if err != nil {
return return
} }
p.SetVolume(math.Min(1, volume)) p.SetVolume(clamp01(volume * a.music * a.master))
p.Play() p.Play()
a.loop = p a.loop, a.loopName, a.loopBase = p, name, volume
} }
// StopLoop stoppar bakgrundsloopen. // StopLoop stoppar bakgrundsslingan.
func (a *Audio) StopLoop() { func (a *Audio) StopLoop() {
if a == nil || a.loop == nil { if a == nil || a.loop == nil {
return return
} }
a.loop.Close() a.loop.Close()
a.loop = nil a.loop, a.loopName = nil, ""
} }
// volumeAt ger volym efter avstånd i rutor. // volumeAt ger volym efter avstånd i rutor.

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

View File

@@ -10,6 +10,7 @@ import (
"time" "time"
"github.com/hajimehoshi/ebiten/v2" "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/endless"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang" "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) Endless *EndlessRun // pågående omgång i oändligt läge (nil = ingen)
savePath string // "" = spara inte 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) fixedSeed uint64 // >0: alla banor startar med denna seed (tester/skärmdumpar)
scene Scene scene Scene
next Scene next Scene
@@ -76,6 +78,7 @@ type Options struct {
NoSave bool // läs sparfilen men skriv aldrig NoSave bool // läs sparfilen men skriv aldrig
Ending bool // öppna slutscenen direkt (tester/skärmdumpar) Ending bool // öppna slutscenen direkt (tester/skärmdumpar)
Endless bool // starta oändligt läge 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) 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 != "" { if opt.Mode != "" {
g.Mode = nav.ParseMode(opt.Mode) g.Mode = nav.ParseMode(opt.Mode)
} }
g.flagScale = max(1, opt.Scale)
g.applySettings()
if !opt.NoSave { if !opt.NoSave {
g.savePath = path g.savePath = path
} }
@@ -278,11 +283,13 @@ func (g *Game) Save() {
// SetScene byter scen från och med nästa uppdatering. Bakgrundsprat hör // SetScene byter scen från och med nästa uppdatering. Bakgrundsprat hör
// till banan och tystnar när man lämnar den. // till banan och tystnar när man lämnar den.
func (g *Game) SetScene(s Scene) { func (g *Game) SetScene(s Scene) {
if _, inLevel := s.(*LevelScene); !inLevel { switch s.(type) {
if _, isResult := s.(*ResultScene); !isResult { 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.Audio.StopLoop()
} }
}
g.next = s g.next = s
} }
@@ -305,6 +312,11 @@ func (g *Game) Update() error {
if g.next != nil { if g.next != nil {
g.scene, g.next = 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 g.script != nil {
if err := g.script.tick(g, g.frame); err != nil { if err := g.script.tick(g, g.frame); err != nil {
return err 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. // toggleMode växlar navigeringsläge och sparar.
func (g *Game) toggleMode() { func (g *Game) toggleMode() {
if g.Mode == nav.List { if g.Mode == nav.List {

View File

@@ -126,6 +126,11 @@ func shopJustPressed() bool { return virtual.pressed["b"] || inpututil.IsKeyJust
// phoneJustPressed: P = kortet i telefonen av/på (meny). // phoneJustPressed: P = kortet i telefonen av/på (meny).
func phoneJustPressed() bool { return virtual.pressed["p"] || inpututil.IsKeyJustPressed(ebiten.KeyP) } 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). // endlessJustPressed: O startar oändligt läge (när storyn är klar).
func endlessJustPressed() bool { func endlessJustPressed() bool {
return virtual.pressed["o"] || inpututil.IsKeyJustPressed(ebiten.KeyO) return virtual.pressed["o"] || inpututil.IsKeyJustPressed(ebiten.KeyO)

View File

@@ -29,6 +29,7 @@ const (
// NewEndingScene startar hemresan. // NewEndingScene startar hemresan.
func NewEndingScene(g *Game) *EndingScene { func NewEndingScene(g *Game) *EndingScene {
g.Audio.Play(SoundPlane, 0.8)
return &EndingScene{lines: strings.Split(g.Assets.T("ending.credits"), "\n")} return &EndingScene{lines: strings.Split(g.Assets.T("ending.credits"), "\n")}
} }

View File

@@ -42,6 +42,7 @@ type LevelScene struct {
level *level.Level // aktuell delbana (byts när resan fortsätter på nästa station) level *level.Level // aktuell delbana (byts när resan fortsätter på nästa station)
run *sim.Run run *sim.Run
endless bool // målet är ett lottat mål i oändligt läge (inte storyn) endless bool // målet är ett lottat mål i oändligt läge (inte storyn)
sfx sfxState
cam *sim.Camera cam *sim.Camera
animT float64 animT float64
hideMap bool // M döljer/visar minikartan hideMap bool // M döljer/visar minikartan
@@ -183,6 +184,7 @@ func (s *LevelScene) Update(g *Game) error {
return nil return nil
} }
s.updateEffects(g) s.updateEffects(g)
s.updateSfx(g)
if s.run.Mini != nil { if s.run.Mini != nil {
// Minispel: all input går dit; Esc avbryter minispelet, inte banan. // Minispel: all input går dit; Esc avbryter minispelet, inte banan.
mx, my := miniMoveXY() mx, my := miniMoveXY()

View File

@@ -106,6 +106,9 @@ func (m *MapScene) Update(g *Game) error {
g.Save() g.Save()
case modeJustPressed(): case modeJustPressed():
g.toggleMode() g.toggleMode()
case settingsJustPressed():
g.SetScene(NewSettingsScene(m))
return nil
case endlessJustPressed() && g.Progress.StoryCleared: case endlessJustPressed() && g.Progress.StoryCleared:
s, err := g.startEndless(g.seed()) s, err := g.startEndless(g.seed())
if err != nil { if err != nil {

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

View File

@@ -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 dist := math.Hypot(float64(tr.platform.X*TileSize)-px, float64(tr.platform.Y*TileSize)-py) / TileSize
mask := noiseMask(r.Level.Noise) mask := noiseMask(r.Level.Noise)
switch phase { switch phase {
case level.Approaching: case level.Approaching: // ankomstjingeln: tåget är på väg
g.Audio.Play(SoundApproach, volumeAt(dist)*mask) 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) g.Audio.Play(SoundDoors, volumeAt(dist)*mask)
} }
} }

View File

@@ -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 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 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) 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) FareYen int `json:"fareYen"` // vad spärren drar från kortet (0 = standard)
Grid []string `json:"grid,omitempty"` // envåningsbana (kompatibilitet) – blir Floors[0] Grid []string `json:"grid,omitempty"` // envåningsbana (kompatibilitet) – blir Floors[0]
Floors []Storey `json:"floors,omitempty"` // flervåningsbana; alla våningar lika stora 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 { if l.Order < 0 {
return fmt.Errorf("level %s: negativ order", l.ID) 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.Floors) == 0 {
if len(l.Grid) == 0 { if len(l.Grid) == 0 {
return fmt.Errorf("level %s: tomt rutnät", l.ID) return fmt.Errorf("level %s: tomt rutnät", l.ID)

View File

@@ -42,6 +42,32 @@ type State struct {
Current *RunRef `json:"current,omitempty"` Current *RunRef `json:"current,omitempty"`
NavMode string `json:"navMode,omitempty"` // "list" (standard) eller "signs" 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 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. // 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. // New är ett nytt spel.
func New() *State { 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å // 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. // Version 0 (saknat fält) och 1 har samma form; här läggs framtida steg.
s.Version = Version s.Version = Version
if s.Settings == nil {
s.Settings = DefaultSettings()
}
s.Settings.normalize()
if s.Know == nil { if s.Know == nil {
s.Know = lang.NewKnowledge() s.Know = lang.NewKnowledge()
} }

View File

@@ -92,3 +92,27 @@ func TestEndlessRecords(t *testing.T) {
t.Fatalf("listan: %+v", s.Endless) 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)
}
}

View File

@@ -1,16 +1,26 @@
# resources/sound – ljud # resources/sound – ljud
Effekter och musik. Format: WAV (korta effekter, 16-bit mono 44,1 kHz) och Alla ljud är syntetiserade av `go run ./tools/gensound` (16-bit mono
OGG (musik, längre ljud). 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 | Fil | 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*). | `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 Volymerna styrs av inställningarna (S på bankartan): huvudvolym × effekter
`noise` > 0 och konkurrerar med melodierna. respektive musik/sorl.
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).

Binary file not shown.

BIN
resources/sound/cancel.wav Normal file

Binary file not shown.

BIN
resources/sound/confirm.wav Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
resources/sound/kiosk.wav Normal file

Binary file not shown.

BIN
resources/sound/learn.wav Normal file

Binary file not shown.

BIN
resources/sound/lose.wav Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
resources/sound/plane.wav Normal file

Binary file not shown.

BIN
resources/sound/staff.wav Normal file

Binary file not shown.

BIN
resources/sound/toilet.wav Normal file

Binary file not shown.

Binary file not shown.

BIN
resources/sound/win.wav Normal file

Binary file not shown.

BIN
resources/sprites/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 B

View File

@@ -140,7 +140,7 @@
"map.party": "Party", "map.party": "Party",
"map.resume": "⟳ resume (saved seed)", "map.resume": "⟳ resume (saved seed)",
"map.locked": "Locked - clear the previous level first.", "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.title": "Skills 技",
"skills.to_next": "XP to next", "skills.to_next": "XP to next",
"skills.already": "Already unlocked.", "skills.already": "Already unlocked.",
@@ -244,5 +244,17 @@
"result.endless_lost": "Endless mode over after %d goals: %d points (best %d).", "result.endless_lost": "Endless mode over after %d goals: %d points (best %d).",
"result.endless_best": "New best!", "result.endless_best": "New best!",
"result.hint_endless": "Enter: next goal L: words Esc: end the run", "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"
} }

View File

@@ -140,7 +140,7 @@
"map.party": "Sällskap", "map.party": "Sällskap",
"map.resume": "⟳ fortsätt (sparad seed)", "map.resume": "⟳ fortsätt (sparad seed)",
"map.locked": "Låst - klara föregående bana först.", "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.title": "Skills 技",
"skills.to_next": "XP till nästa", "skills.to_next": "XP till nästa",
"skills.already": "Redan upplåst.", "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_lost": "Oändligt läge slut efter %d mål: %d poäng (bästa %d).",
"result.endless_best": "Ny bästa!", "result.endless_best": "Ny bästa!",
"result.hint_endless": "Enter: nästa mål L: ordlista Esc: avsluta omgången", "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"
} }

View File

@@ -291,6 +291,22 @@ func main() {
planeImg := image.NewNRGBA(image.Rect(0, 0, 32, 12)) planeImg := image.NewNRGBA(image.Rect(0, 0, 32, 12))
plane(canvas{planeImg, 0, 0}) plane(canvas{planeImg, 0, 0})
save(filepath.Join(out, "plane.png"), planeImg) 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). // plane är flygplanet i slutscenen (32×12, nosen åt höger).

View File

@@ -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 // go run ./tools/gensound
// //
// Skriver resources/sound/melody-approach.wav (en kort pentatonisk // Skriver 16-bit mono 44,1 kHz WAV till resources/sound/: fyra
// avgångsmelodi i japansk stationsstil, spelas när tåget är på väg in) och // stationsmelodier (avgångsmelodier i japansk stationsstil, en per
// resources/sound/door-chime.wav (dörrsignal strax före avgång). 16-bit // station), ankomstjingel, utropssignal, dörrsignal, spärrens pip och
// mono 44,1 kHz, syntetiserade med enkla vågformer så att allt är // nekande, kiosk, toalett, lärt ord, bekräfta/avbryt, byte, vinst, förlust,
// deterministiskt. // personal, flygplan, bakgrundssorl och kartans musikslinga.
package main package main
import ( import (
@@ -24,20 +25,54 @@ type note struct {
dur float64 // sekunder 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{ var approachMelody = []note{
{523.25, 0.18}, {587.33, 0.18}, {659.25, 0.18}, {783.99, 0.36}, {c5, 0.18}, {d5, 0.18}, {e5, 0.18}, {g5, 0.36},
{880.00, 0.18}, {783.99, 0.18}, {659.25, 0.18}, {523.25, 0.36}, {a5, 0.18}, {g5, 0.18}, {e5, 0.18}, {c5, 0.36},
{0, 0.12}, {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. // Dörrsignal: två toner växelvis.
var doorChime = []note{ var doorChime = []note{
{1318.51, 0.14}, {1046.50, 0.14}, {1318.51, 0.14}, {1046.50, 0.14}, {e6, 0.14}, {c6, 0.14}, {e6, 0.14}, {c6, 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.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 { func render(notes []note, attack, release float64, overtone float64) []int16 {
var out []int16 var out []int16
for _, n := range notes { for _, n := range notes {
@@ -63,6 +98,133 @@ func render(notes []note, attack, release float64, overtone float64) []int16 {
return out 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 { func writeWAV(path string, pcm []int16) error {
f, err := os.Create(path) f, err := os.Create(path)
if err != nil { if err != nil {
@@ -83,39 +245,35 @@ func writeWAV(path string, pcm []int16) error {
return binary.Write(f, binary.LittleEndian, pcm) 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() { func main() {
out := filepath.Join(repoRoot(), "resources", "sound") out := filepath.Join(repoRoot(), "resources", "sound")
must(os.MkdirAll(out, 0o755)) must(os.MkdirAll(out, 0o755))
must(writeWAV(filepath.Join(out, "melody-approach.wav"), render(approachMelody, 0.01, 0.06, 0.3))) clips := map[string][]int16{
must(writeWAV(filepath.Join(out, "door-chime.wav"), render(doorChime, 0.005, 0.04, 0.1))) "melody-approach": render(approachMelody, 0.01, 0.06, 0.3),
must(writeWAV(filepath.Join(out, "chatter.wav"), chatter(6))) "door-chime": render(doorChime, 0.005, 0.04, 0.1),
fmt.Println("skrev", filepath.Join(out, "melody-approach.wav"), ", door-chime.wav och chatter.wav") "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 { func repoRoot() string {

View File

@@ -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"}}, {"", "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"}}, {"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; 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"}}, {"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 return nil