M12: ljud, inställningar och release v0.1.0
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:
22
CHANGELOG.md
Normal file
22
CHANGELOG.md
Normal 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`.
|
||||||
15
README.md
15
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å
|
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).
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -87,6 +89,8 @@ labyrintgolv, samma klocka och samma hungriga sällskap.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
BIN
doc/img/map.png
BIN
doc/img/map.png
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
BIN
doc/img/settings.png
Normal file
BIN
doc/img/settings.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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` |
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
46
internal/game/audio_test.go
Normal file
46
internal/game/audio_test.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAllSoundsDecode: alla ljud som spelet refererar finns och går att
|
||||||
|
// avkoda (utan ljudkontext eller fönster).
|
||||||
|
func TestAllSoundsDecode(t *testing.T) {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, name := range AllSounds() {
|
||||||
|
if seen[name] {
|
||||||
|
t.Errorf("%s listas två gånger", name)
|
||||||
|
}
|
||||||
|
seen[name] = true
|
||||||
|
pcm, err := decodeSound(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("%s: %v", name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(pcm) < sampleRate/20*4 { // minst 50 ms stereo 16-bit
|
||||||
|
t.Errorf("%s: för kort (%d byte)", name, len(pcm))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMelodyForIsStableAndKnown(t *testing.T) {
|
||||||
|
known := map[string]bool{}
|
||||||
|
for _, m := range StationMelodies {
|
||||||
|
known[m] = true
|
||||||
|
}
|
||||||
|
a := &level.Level{ID: "005-ueno", Station: "ueno"}
|
||||||
|
if m := MelodyFor(a); !known[m] || m != MelodyFor(a) {
|
||||||
|
t.Fatalf("MelodyFor = %q", m)
|
||||||
|
}
|
||||||
|
if m := MelodyFor(&level.Level{ID: "x", Melody: "c"}); m != "melody-c" {
|
||||||
|
t.Fatalf("melody-fältet ska vinna: %q", m)
|
||||||
|
}
|
||||||
|
var nilAudio *Audio
|
||||||
|
nilAudio.Play(SoundWin, 1) // nil-säkert
|
||||||
|
nilAudio.Loop(SoundMusicMap, 1)
|
||||||
|
nilAudio.SetVolumes(1, 1, 1)
|
||||||
|
nilAudio.StopLoop()
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"time"
|
"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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
117
internal/game/scene_settings.go
Normal file
117
internal/game/scene_settings.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"image/color"
|
||||||
|
|
||||||
|
"github.com/hajimehoshi/ebiten/v2"
|
||||||
|
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||||
|
|
||||||
|
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/save"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SettingsScene är inställningarna: volymer, språk, fönsterskala och
|
||||||
|
// fullskärm. Pilar väljer rad och ändrar, Enter växlar, Esc/S går tillbaka.
|
||||||
|
// Allt sparas direkt.
|
||||||
|
type SettingsScene struct {
|
||||||
|
back Scene
|
||||||
|
sel int
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsRows = 6
|
||||||
|
|
||||||
|
// NewSettingsScene öppnar inställningarna.
|
||||||
|
func NewSettingsScene(back Scene) *SettingsScene { return &SettingsScene{back: back} }
|
||||||
|
|
||||||
|
func (s *SettingsScene) Update(g *Game) error {
|
||||||
|
if backJustPressed() || settingsJustPressed() {
|
||||||
|
g.SetScene(s.back)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.sel = (s.sel + menuMove() + settingsRows) % settingsRows
|
||||||
|
dx, _ := miniMoveXY()
|
||||||
|
toggle := confirmJustPressed()
|
||||||
|
if dx == 0 && !toggle {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
st := g.State.Settings
|
||||||
|
step := func(v *int, lo, hi int) {
|
||||||
|
*v = max(lo, min(hi, *v+dx))
|
||||||
|
}
|
||||||
|
switch s.sel {
|
||||||
|
case 0:
|
||||||
|
step(&st.Master, 0, 10)
|
||||||
|
case 1:
|
||||||
|
step(&st.Effects, 0, 10)
|
||||||
|
case 2:
|
||||||
|
step(&st.Music, 0, 10)
|
||||||
|
case 3:
|
||||||
|
if st.Lang == "sv" {
|
||||||
|
st.Lang = "en"
|
||||||
|
} else {
|
||||||
|
st.Lang = "sv"
|
||||||
|
}
|
||||||
|
case 4:
|
||||||
|
step(&st.Scale, 0, 6)
|
||||||
|
case 5:
|
||||||
|
st.Fullscreen = !st.Fullscreen
|
||||||
|
}
|
||||||
|
g.applySettings()
|
||||||
|
g.Save()
|
||||||
|
g.Audio.Play(SoundConfirm, 0.6)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingsScene) Draw(g *Game, screen *ebiten.Image) {
|
||||||
|
a := g.Assets
|
||||||
|
st := g.State.Settings
|
||||||
|
screen.Fill(color.RGBA{24, 20, 37, 255})
|
||||||
|
vector.DrawFilledRect(screen, 0, 0, ScreenW, 22, color.RGBA{38, 43, 68, 255}, false)
|
||||||
|
a.DrawBig(screen, a.T("settings.title"), 8, 5, colText)
|
||||||
|
scale := a.T("settings.scale_auto")
|
||||||
|
if st.Scale > 0 {
|
||||||
|
scale = fmt.Sprintf("%d× (%d×%d)", st.Scale, ScreenW*st.Scale, ScreenH*st.Scale)
|
||||||
|
}
|
||||||
|
fs := a.T("menu.off")
|
||||||
|
if st.Fullscreen {
|
||||||
|
fs = a.T("menu.on")
|
||||||
|
}
|
||||||
|
rows := [settingsRows][2]string{
|
||||||
|
{a.T("settings.master"), ""},
|
||||||
|
{a.T("settings.effects"), ""},
|
||||||
|
{a.T("settings.music"), ""},
|
||||||
|
{a.T("settings.lang"), a.T("lang." + st.Lang)},
|
||||||
|
{a.T("settings.scale"), scale},
|
||||||
|
{a.T("settings.fullscreen"), fs},
|
||||||
|
}
|
||||||
|
levels := [3]int{st.Master, st.Effects, st.Music}
|
||||||
|
y := 30
|
||||||
|
for i, r := range rows {
|
||||||
|
col := colText
|
||||||
|
if i == s.sel {
|
||||||
|
vector.DrawFilledRect(screen, 6, float32(y-2), ScreenW-12, LineH+1, colBar, false)
|
||||||
|
col = colKnown
|
||||||
|
}
|
||||||
|
a.Draw(screen, r[0], 10, y, col)
|
||||||
|
if i < 3 { // volymstapel i tio segment
|
||||||
|
for k := 0; k < 10; k++ {
|
||||||
|
c := color.RGBA{70, 80, 110, 255}
|
||||||
|
if k < levels[i] {
|
||||||
|
c = color.RGBA{120, 200, 120, 255}
|
||||||
|
}
|
||||||
|
vector.DrawFilledRect(screen, float32(130+k*9), float32(y+2), 7, 7, c, false)
|
||||||
|
}
|
||||||
|
a.Draw(screen, fmt.Sprintf("%d", levels[i]), 224, y, col)
|
||||||
|
} else {
|
||||||
|
a.Draw(screen, r[1], 130, y, col)
|
||||||
|
}
|
||||||
|
y += LineH + 1
|
||||||
|
}
|
||||||
|
y += 4
|
||||||
|
for _, l := range a.wrapLine(plainLine(a.T("settings.keys"), colDim), ScreenW-20) {
|
||||||
|
a.drawLines(screen, []line{l}, 10, y)
|
||||||
|
y += LineH
|
||||||
|
}
|
||||||
|
a.Draw(screen, a.T("settings.hint"), 8, ScreenH-12, colDim)
|
||||||
|
_ = save.DefaultSettings // (inställningarnas standardvärden ligger i save)
|
||||||
|
}
|
||||||
66
internal/game/sfx.go
Normal file
66
internal/game/sfx.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
|
||||||
|
|
||||||
|
// sfxState är vad ljudlagret senast såg av omgången; skillnader mot nu ger
|
||||||
|
// effekter (spärr, kiosk, toalett, lärt ord, byte, personal, vinst/förlust).
|
||||||
|
type sfxState struct {
|
||||||
|
init bool
|
||||||
|
gatePasses int
|
||||||
|
served int
|
||||||
|
staffCalls int
|
||||||
|
deniedAt float64
|
||||||
|
noExitAt float64
|
||||||
|
learnedAt float64
|
||||||
|
transferAt float64
|
||||||
|
flashAt float64
|
||||||
|
facility bool
|
||||||
|
mini bool
|
||||||
|
outcome sim.Outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateSfx spelar effekter för det som hänt sedan förra bildrutan.
|
||||||
|
func (s *LevelScene) updateSfx(g *Game) {
|
||||||
|
r := s.run
|
||||||
|
x := &s.sfx
|
||||||
|
if x.init {
|
||||||
|
switch {
|
||||||
|
case r.GatePasses > x.gatePasses:
|
||||||
|
g.Audio.Play(SoundGateBeep, 0.7)
|
||||||
|
case r.DeniedAt >= 0 && r.DeniedAt != x.deniedAt, r.NoExitAt >= 0 && r.NoExitAt != x.noExitAt:
|
||||||
|
g.Audio.Play(SoundGateDeny, 0.7)
|
||||||
|
}
|
||||||
|
if r.Served > x.served {
|
||||||
|
if x.facility && !r.Facility.Active {
|
||||||
|
g.Audio.Play(SoundToilet, 0.6)
|
||||||
|
} else {
|
||||||
|
g.Audio.Play(SoundKiosk, 0.7)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.LearnedAt >= 0 && r.LearnedAt != x.learnedAt {
|
||||||
|
g.Audio.Play(SoundLearn, 0.8)
|
||||||
|
}
|
||||||
|
if r.TransferAt >= 0 && r.TransferAt != x.transferAt {
|
||||||
|
g.Audio.Play(SoundTransfer, 0.8)
|
||||||
|
}
|
||||||
|
if r.StaffCalls > x.staffCalls {
|
||||||
|
g.Audio.Play(SoundStaff, 0.7)
|
||||||
|
}
|
||||||
|
if r.Mini != nil && !x.mini {
|
||||||
|
g.Audio.Play(SoundConfirm, 0.5)
|
||||||
|
}
|
||||||
|
if r.FlashAt >= 0 && r.FlashAt != x.flashAt && r.FlashKey != sim.FlashTransfer && r.FlashKey != sim.FlashStaff {
|
||||||
|
g.Audio.Play(SoundCancel, 0.6)
|
||||||
|
}
|
||||||
|
if r.Outcome != x.outcome {
|
||||||
|
switch r.Outcome {
|
||||||
|
case sim.Won:
|
||||||
|
g.Audio.Play(SoundWin, 0.9)
|
||||||
|
case sim.Lost:
|
||||||
|
g.Audio.Play(SoundLose, 0.9)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*x = sfxState{init: true, gatePasses: r.GatePasses, served: r.Served, staffCalls: r.StaffCalls, deniedAt: r.DeniedAt, noExitAt: r.NoExitAt,
|
||||||
|
learnedAt: r.LearnedAt, transferAt: r.TransferAt, flashAt: r.FlashAt, facility: r.Facility.Active, mini: r.Mini != nil, outcome: r.Outcome}
|
||||||
|
}
|
||||||
@@ -163,9 +163,13 @@ func (s *LevelScene) updateEffects(g *Game) {
|
|||||||
dist := math.Hypot(float64(tr.platform.X*TileSize)-px, float64(tr.platform.Y*TileSize)-py) / TileSize
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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).
|
|
||||||
|
|||||||
BIN
resources/sound/announce.wav
Normal file
BIN
resources/sound/announce.wav
Normal file
Binary file not shown.
BIN
resources/sound/cancel.wav
Normal file
BIN
resources/sound/cancel.wav
Normal file
Binary file not shown.
BIN
resources/sound/confirm.wav
Normal file
BIN
resources/sound/confirm.wav
Normal file
Binary file not shown.
BIN
resources/sound/gate-beep.wav
Normal file
BIN
resources/sound/gate-beep.wav
Normal file
Binary file not shown.
BIN
resources/sound/gate-deny.wav
Normal file
BIN
resources/sound/gate-deny.wav
Normal file
Binary file not shown.
BIN
resources/sound/kiosk.wav
Normal file
BIN
resources/sound/kiosk.wav
Normal file
Binary file not shown.
BIN
resources/sound/learn.wav
Normal file
BIN
resources/sound/learn.wav
Normal file
Binary file not shown.
BIN
resources/sound/lose.wav
Normal file
BIN
resources/sound/lose.wav
Normal file
Binary file not shown.
BIN
resources/sound/melody-a.wav
Normal file
BIN
resources/sound/melody-a.wav
Normal file
Binary file not shown.
BIN
resources/sound/melody-b.wav
Normal file
BIN
resources/sound/melody-b.wav
Normal file
Binary file not shown.
BIN
resources/sound/melody-c.wav
Normal file
BIN
resources/sound/melody-c.wav
Normal file
Binary file not shown.
BIN
resources/sound/melody-d.wav
Normal file
BIN
resources/sound/melody-d.wav
Normal file
Binary file not shown.
BIN
resources/sound/music-map.wav
Normal file
BIN
resources/sound/music-map.wav
Normal file
Binary file not shown.
BIN
resources/sound/plane.wav
Normal file
BIN
resources/sound/plane.wav
Normal file
Binary file not shown.
BIN
resources/sound/staff.wav
Normal file
BIN
resources/sound/staff.wav
Normal file
Binary file not shown.
BIN
resources/sound/toilet.wav
Normal file
BIN
resources/sound/toilet.wav
Normal file
Binary file not shown.
BIN
resources/sound/transfer.wav
Normal file
BIN
resources/sound/transfer.wav
Normal file
Binary file not shown.
BIN
resources/sound/win.wav
Normal file
BIN
resources/sound/win.wav
Normal file
Binary file not shown.
BIN
resources/sprites/icon.png
Normal file
BIN
resources/sprites/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 221 B |
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user