M12: ljud, inställningar och release v0.1.0
All checks were successful
release / build-release (push) Successful in 1m35s
All checks were successful
release / build-release (push) Successful in 1m35s
tools/gensound syntetiserar 21 ljud: fyra stationsmelodier i klockklang (en per station via melody-fältet eller banans id), ankomstjingel, ding-dong vid inrullning, dörrsignal vid avgång, spärrens pip och nekande, kiosk, toalett, lärt ord, bekräfta/varning, byte, personal, vinst, förlust, flygplan, sorl och menymusik. sfx.go spelar effekter utifrån vad som hänt i sim.Run sedan förra bildrutan. Ljudlagret har huvudvolym × effekter/musik och byter inte slinga i onödan. Inställningar (S på bankartan, sparas): volymer, språk sv/en som laddas om direkt, fönsterskala, fullskärm (F11 överallt) och tangentöversikt. Fönsterikon från genart. CHANGELOG.md. Tester för ljudfiler, melodival och inställningar. Docs, wiki och skärmdumpar uppdaterade. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
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å
|
||||
bankartan: lottade resmål på storyns stationer tills man förlorar, egen
|
||||
poänglista) och **New Game+** (G två gånger: om från början med skills kvar
|
||||
och ett större sällskap). Nästa är M12 (ljud, polish, release) och M10b
|
||||
(bussar). Se [doc/roadmap.md](doc/roadmap.md).
|
||||
och ett större sällskap). Version 0.1.0 har ljud (stationsmelodier, effekter,
|
||||
menymusik), inställningar (S: volymer, språk sv/en, skala, fullskärm) och
|
||||
fönsterikon. Kvar i roadmapen är M10b (bussar). Se
|
||||
[doc/roadmap.md](doc/roadmap.md) och [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||

|
||||
|
||||
@@ -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,
|
||||
alla behöver biljett, och när biljetten är köpt ska sällskapet placeras i
|
||||
vagnen efter sina önskemål: fönster eller gång, vänster eller höger sida,
|
||||
@@ -201,8 +205,9 @@ Enter bekräftar (håll Enter för att mata in sedlar). H frågar om hjälp vid
|
||||
skylt, L öppnar ordlistan, T skillträdet, I garderoben, B butiken vid en kiosk, M
|
||||
döljer/visar minikartan, P på kartan lägger kortet i telefonen, N på kartan
|
||||
växlar navigeringsläge (lista/skyltar), O på kartan startar oändligt läge
|
||||
och G (två gånger) New Game+ när storyn är klar, Esc går tillbaka, R spelar
|
||||
om. Gamepad
|
||||
och G (två gånger) New Game+ när storyn är klar, S på kartan öppnar
|
||||
inställningarna (volymer, språk, skala, fullskärm), F11 växlar fullskärm
|
||||
överallt, Esc går tillbaka, R spelar om. Gamepad
|
||||
med standardlayout fungerar: d-pad/vänster spak, A = välj, B = tillbaka,
|
||||
Y = hjälp, X = ordlista, Back/Select = karta.
|
||||
|
||||
@@ -213,7 +218,7 @@ Y = hjälp, X = ordlista, Back/Select = karta.
|
||||
| `go run ./tools/build …` | bygger (se ovan) |
|
||||
| `go run ./tools/genart` | genererar placeholder-grafiken `resources/sprites/tiles.png`, `player.png` och `plane.png` |
|
||||
| `go run ./tools/levelcheck [ord…]` | ritar varje bana (per våning) som PNG i `build/levelcheck/` och rapporterar lösbarhet, onåbara rutor, perronger utanför spärrar, tågens bedömning i linjenätet och resornas byten; avslutar med fel om något är trasigt |
|
||||
| `go run ./tools/gensound` | genererar placeholder-ljuden `resources/sound/*.wav` (avgångsmelodi, dörrsignal) |
|
||||
| `go run ./tools/gensound` | genererar alla ljud i `resources/sound/*.wav` (stationsmelodier, jinglar, effekter, sorl, menymusik), se `resources/sound/README.md` |
|
||||
| `go run ./tools/shots [ord…]` | tar dokumentationens skärmdumpar till `doc/img/` – **kör efter varje grafik-/UI-ändring och committa bilderna**. Med argument körs bara skott vars bana eller skript innehåller något av orden (t.ex. `go run ./tools/shots 005 ride.png`). På Linux körs spelet på en osynlig skärm (headless Wayfire + Xwayland) så att inget fönster syns eller tar fokus; `METRO_HEADLESS=0` använder skärmen |
|
||||
|
||||
## Beroenden
|
||||
|
||||
@@ -4,6 +4,7 @@ package main
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"image"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -39,13 +40,15 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
g, err := game.New(game.Options{LevelID: *levelID, SmokeFrames: *smoke, Script: *script, KnownWords: splitWords(*know), Points: *points, Seed: *seed, Phone: *phone, CardYen: *card, Debuffs: splitWords(*debuffs), Mute: *mute, SavePath: *savePath, NoSave: *noSave, Mode: *mode, Ending: *ending, Endless: *endlessFlag})
|
||||
g, err := game.New(game.Options{LevelID: *levelID, SmokeFrames: *smoke, Script: *script, KnownWords: splitWords(*know), Points: *points, Seed: *seed, Phone: *phone, CardYen: *card, Debuffs: splitWords(*debuffs), Mute: *mute, SavePath: *savePath, NoSave: *noSave, Mode: *mode, Ending: *ending, Endless: *endlessFlag, Scale: *scale})
|
||||
if err != nil {
|
||||
log.Fatalf("kunde inte starta: %v", err)
|
||||
}
|
||||
|
||||
ebiten.SetWindowSize(game.ScreenW**scale, game.ScreenH**scale)
|
||||
ebiten.SetWindowTitle("Metro Puzzle JP")
|
||||
if icon := g.Assets.Icon; icon != nil {
|
||||
ebiten.SetWindowIcon([]image.Image{icon})
|
||||
}
|
||||
ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled)
|
||||
ebiten.SetVsyncEnabled(true)
|
||||
|
||||
|
||||
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/progress` | `TestNewGamePlusKeepsSkillsResetsItems` | **M11:** NG+ behåller skills, XP, skillpoäng och klarad story; nollställer items, utrustning och banresultat |
|
||||
| `internal/save` | `TestEndlessRecords` | **M11:** poänglistan sorterad, kapad till fem, "ny bästa" bara när toppen slås |
|
||||
| `internal/game` | `TestAllSoundsDecode`, `TestMelodyForIsStableAndKnown` | **M12:** alla ljud i `AllSounds` finns, avkodas och är minst 50 ms; melodin per bana är stabil och känd; ljudlagret är nil-säkert |
|
||||
| `internal/save` | `TestSettingsDefaultsAndNormalize` | **M12:** standardinställningar, klampning av volymer/skala, okänt språk blir sv, saknade inställningar fylls i |
|
||||
| `internal/level` | `TestToiletSides` | **M10d:** 005 och 008b har toaletter bara innanför, 007b på båda sidor, 007 och 008 bara utanför; innanför bara på banor med spärrar utan återvändo |
|
||||
| `internal/sim` | `TestJourneyWithTransfer`, `TestJourneyWrongTrainOnSecondLeg`, `TestChainLevelsLoad` | **M10:** bytet fortsätter omgången på nästa station med klocka, plånbok, sällskap och par-summa; vinst först på sista delbanan; fel håll på bytesstationen förlorar; delbanor laddas enskilt |
|
||||
| `internal/minigame` | `TestCarPickAlightingAndChoice`, `TestCrowdLevelNarrowsZone`, `TestRideCrowdLetPass` | Folk av först (för tidigt räknas), seedad trängsel med minst en lugn vagn, val; trängsel smalnar zonen; resan: kortare tid per station, passagerare som vill förbi, blockerade räknas |
|
||||
@@ -167,7 +169,7 @@ att bygga på Linux som på Windows.
|
||||
| ~~M10d Toaletter innanför/utanför~~ ✓ | Toaletterna ligger på rätt sida av spärrarna per bana – finns, se tabellen ovan |
|
||||
| M10b Bussar och vägar | Bussar följer tidtabell och vägrutor; hållplatser bedöms som perronger; överstimulerande UI-reglaget påverkar bara presentationen |
|
||||
| ~~M11 Oändligt läge & NG+~~ ✓ | Målsekvens deterministisk per seed, giltiga mål med fälla, NG+ behåller skills men nollställer items, poänglista – finns, se tabellen ovan |
|
||||
| M12 Ljud & polish | Alla ljudfiler som refereras finns och går att avkoda; Windows-binär startar (manuellt) |
|
||||
| ~~M12 Ljud & polish~~ ✓ | Alla ljudfiler som refereras finns och går att avkoda, stationsmelodi per bana, inställningar normaliseras – finns, se tabellen ovan; Windows-binär startar (manuellt, kvar) |
|
||||
|
||||
### Acceptanskriterier för "klar"
|
||||
|
||||
@@ -188,7 +190,8 @@ En milstolpe är klar när:
|
||||
- [ ] Tomt kort tvingar till automaten; minispelet är begripligt första gången.
|
||||
- [ ] Minikartan fylls i när man går runt och nollställs vid ny bana.
|
||||
- [ ] Spara mitt i en bana, ladda, och få samma minispelsutfall.
|
||||
- [ ] Ljud spelas vid ankomst och avgång; inget knaster vid loop.
|
||||
- [ ] Ljud spelas vid ankomst (jingel + ding-dong) och avgång (stationsmelodi + dörrsignal); inget knaster när sorlet och menymusiken loopar.
|
||||
- [ ] Inställningarna (S) ändrar volym, språk, skala och fullskärm direkt och överlever omstart.
|
||||
- [ ] Samma sparfil fungerar på Linux och Windows.
|
||||
|
||||
### Kommandon
|
||||
|
||||
@@ -673,13 +673,37 @@ på **brasse-linux01-runnern** (label `linux-amd64`), aldrig på Pi5:n.
|
||||
- **Kvar till senare:** oändligt läge med byten (resor över två stationer),
|
||||
stationer som genereras vid körning (generatorn in i repot).
|
||||
|
||||
### M12 – Ljud, polish och release
|
||||
### M12 – Ljud, polish och release ✓ (klar 2026-10-25)
|
||||
|
||||
- Riktiga avgångsmelodier/ankomstjinglar (ersätter placeholders från M7b),
|
||||
utrop, effekter, musik; mixning och volyminställningar.
|
||||
- Inställningar (ljudnivåer, språk sv/en, skala, tangenter).
|
||||
- Windows-test på riktig maskin, ikon i exe:n, eventuell Gitea-CI med
|
||||
releaser enligt playbooken.
|
||||
- **Så blev det:** `tools/gensound` syntetiserar 21 ljud (egna
|
||||
kompositioner, deterministiska): fyra **stationsmelodier** i
|
||||
klockklang (`melody-a`–`d`, banans `melody`-fält eller vald efter id),
|
||||
ankomstjingel, ding-dong vid inrullning, dörrsignal vid avgång,
|
||||
spärrens pip/nekande, kiosk, toalett, lärt ord, bekräfta/varning, byte,
|
||||
personal, vinst, förlust, flygplan, sorl och en menymusikslinga.
|
||||
`internal/game/sfx.go` spelar effekterna utifrån vad som hänt i `sim.Run`
|
||||
sedan förra bildrutan (räknare och tidsstämplar), tågfaserna i
|
||||
`trains.go`. **Ljudlagret** har tre volymer (huvud × effekter resp.
|
||||
musik/sorl) och byter inte slinga i onödan (menymusiken fortsätter
|
||||
mellan karta, inställningar och listor). **Inställningar** (S på
|
||||
bankartan, sparas i sparfilen): volymer, språk sv/en (texterna laddas om
|
||||
direkt), fönsterskala 1–6 (0 = flaggan `-scale`), fullskärm (även F11
|
||||
överallt), plus en tangentöversikt. **Fönsterikon** (`icon.png` från
|
||||
genart) sätts vid start. **Release:** `CHANGELOG.md` och taggen
|
||||
`v0.1.0` → versionerad release på Gitea (CI:n bygger redan Linux och
|
||||
Windows). Windows-testet på riktig maskin och ikon inbakad i exe:n
|
||||
(.syso) är kvar att göra manuellt.
|
||||
- **Klar när:** alla ljud finns och avkodas (`game.TestAllSoundsDecode`),
|
||||
stationsmelodin är stabil per bana (`TestMelodyForIsStableAndKnown`),
|
||||
inställningar får standardvärden och normaliseras
|
||||
(`save.TestSettingsDefaultsAndNormalize`), `melody`-fältet valideras. ✓
|
||||
- **Kvar till senare:** ikon i exe:n (.syso), omdefinierbara tangenter,
|
||||
riktiga inspelningar, Windows-test på riktig maskin (Björn).
|
||||
|
||||
## Prioritering
|
||||
|
||||
|
||||
@@ -283,6 +283,7 @@ från skills i skill tree, och från unika skills man får av sidouppdrag.
|
||||
| "flera stopp och byten" | En resa är en kedja av banfiler (`next` → delbana med `kind: "leg"`). Samma omgång fortsätter på nästa station med klocka, plånbok och sällskap; resmålet i HUD:en visas "via" bytesstationen. Vilken bytesstation som gäller bestämmer banan – spelaren väljer tåg, inte rutt | Fri ruttplanering (välj själv var man byter) är en möjlig utbyggnad |
|
||||
| "slutar när man ska tillbaka till flygplatsen" | Sista huvudbanan (008) går Tokyo → Nippori → Narita flygplats med Keisei. När den klaras första gången visas hemresan: planet lyfter, resan summeras, eftertexter | Slutet går att se igen via `-ending` |
|
||||
| Buss | Uppskjuten till M10b (vägrutor, hållplatser, egen sprite) | |
|
||||
| "De ikoniska melodierna" | Egna syntetiserade stationsmelodier i klockklang (fyra stycken, en per station) – riktiga JR-melodier är upphovsrättsskyddade och spelas inte in | Byt filerna i `resources/sound/` om egna inspelningar finns |
|
||||
| "Oändligt läge … målen är slumpade" | Målen är storyns stationer med lottade resmål (samma labyrint, samma tåg, nytt resmål avgör vilket tåg som är rätt), seedat; sällskapet växer och tidsgränsen krymper per mål; omgången slutar vid första förlust och hamnar i en egen poänglista | Slumpade labyrinter vid körning kan komma senare |
|
||||
| "New Game+ … behåll alla dina skills" | Skills, XP och språkkunskap behålls; items, stjärnor, pengar och debuffar nollställs; sällskapet är en person större per NG+-varv | |
|
||||
| Repo-namnet | Arbetsnamnet var "jp-subway-pussel"; Björn döpte Gitea-repot till **metro-puzzle-jp** (2026-10-23), så repo, modul, binär och titel heter så | Spelets visningsnamn "Metro Puzzle JP" kan ändras fritt i `resources/text/*.json` |
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
"github.com/hajimehoshi/ebiten/v2/text/v2"
|
||||
@@ -15,8 +16,8 @@ import (
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
|
||||
)
|
||||
|
||||
// UILang är UI-språket (sv/en) – blir en inställning i M12.
|
||||
const UILang = "sv"
|
||||
// UILang är UI-språket (sv/en), från inställningarna (Assets.LoadLang).
|
||||
var UILang = "sv"
|
||||
|
||||
// Radhöjd för 10-pixelsfonten.
|
||||
const LineH = 12
|
||||
@@ -49,6 +50,7 @@ type Assets struct {
|
||||
Rails *ebiten.Image // rälsruta (horisontell), roteras för vertikala spår
|
||||
Posters []*ebiten.Image // affischer för visuell röra (ritas över väggrutor)
|
||||
Plane *ebiten.Image // flygplanet i slutscenen
|
||||
Icon image.Image // fönsterikonen (nil om den saknas)
|
||||
Player [4][3]*ebiten.Image // [riktning: ner, upp, vänster, höger][bildruta: stilla, gå1, gå2]
|
||||
Text map[string]string
|
||||
Dict *lang.Dictionary
|
||||
@@ -103,10 +105,13 @@ func LoadAssets() (*Assets, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := res.LoadJSON(&a.Text, UILang+".json"); err != nil {
|
||||
if err := res.LoadJSON(&a.Text, "text", UILang+".json"); err != nil {
|
||||
if err := a.LoadLang(UILang); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if b, err := res.ReadFile("sprites", "icon.png"); err == nil {
|
||||
if img, err := png.Decode(bytes.NewReader(b)); err == nil {
|
||||
a.Icon = img
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
func (a *Assets) Width(s string) int { return int(text.Advance(s, a.Font)) }
|
||||
|
||||
// LoadLang läser UI-texterna för ett språk och gör det till UILang.
|
||||
func (a *Assets) LoadLang(lang string) error {
|
||||
texts := map[string]string{}
|
||||
if err := res.LoadJSON(&texts, lang+".json"); err != nil {
|
||||
if err := res.LoadJSON(&texts, "text", lang+".json"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
a.Text = texts
|
||||
UILang = lang
|
||||
return nil
|
||||
}
|
||||
|
||||
// T slår upp en UI-text; saknas nyckeln visas nyckeln själv så att det syns.
|
||||
func (a *Assets) T(key string) string {
|
||||
if s, ok := a.Text[key]; ok {
|
||||
|
||||
@@ -3,32 +3,75 @@ package game
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"io"
|
||||
"math"
|
||||
|
||||
"github.com/hajimehoshi/ebiten/v2/audio"
|
||||
"github.com/hajimehoshi/ebiten/v2/audio/wav"
|
||||
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res"
|
||||
)
|
||||
|
||||
// Audio spelar korta effekter. nil-säkert: utan ljud (−mute eller fel) är
|
||||
// alla anrop no-ops.
|
||||
// Audio spelar effekter och en bakgrundsslinga, med volymer i tre lager:
|
||||
// huvudvolym × effekter respektive musik/sorl. nil-säkert: utan ljud
|
||||
// (−mute eller fel) är alla anrop no-ops.
|
||||
type Audio struct {
|
||||
ctx *audio.Context
|
||||
clips map[string][]byte // avkodad PCM (16-bit stereo LE) per klipp
|
||||
loop *audio.Player // pågående bakgrundsloop (bakgrundsprat)
|
||||
loop *audio.Player // pågående bakgrundsslinga
|
||||
loopName string
|
||||
loopBase float64 // slingans egen volym innan inställningarna
|
||||
|
||||
master, effects, music float64 // 0..1 från inställningarna
|
||||
}
|
||||
|
||||
const sampleRate = 44100
|
||||
|
||||
// Ljudklipp (filnamn utan .wav i resources/sound).
|
||||
const (
|
||||
SoundApproach = "melody-approach"
|
||||
SoundDoors = "door-chime"
|
||||
SoundChatter = "chatter"
|
||||
SoundApproach = "melody-approach" // ankomstjingel: tåget är på väg in
|
||||
SoundAnnounce = "announce" // ding-dong när tåget rullar in
|
||||
SoundDoors = "door-chime" // dörrarna stängs
|
||||
SoundChatter = "chatter" // stationssorl (slinga)
|
||||
SoundMusicMap = "music-map" // bankartans musik (slinga)
|
||||
SoundGateBeep = "gate-beep"
|
||||
SoundGateDeny = "gate-deny"
|
||||
SoundKiosk = "kiosk"
|
||||
SoundToilet = "toilet"
|
||||
SoundLearn = "learn"
|
||||
SoundConfirm = "confirm"
|
||||
SoundCancel = "cancel"
|
||||
SoundTransfer = "transfer"
|
||||
SoundWin = "win"
|
||||
SoundLose = "lose"
|
||||
SoundStaff = "staff"
|
||||
SoundPlane = "plane"
|
||||
)
|
||||
|
||||
// StationMelodies är avgångsmelodierna; varje station har sin (level.Melody
|
||||
// eller en vald efter banans id).
|
||||
var StationMelodies = []string{"melody-a", "melody-b", "melody-c", "melody-d"}
|
||||
|
||||
// AllSounds är alla klipp som måste finnas i resources/sound.
|
||||
func AllSounds() []string {
|
||||
return append([]string{SoundApproach, SoundAnnounce, SoundDoors, SoundChatter, SoundMusicMap, SoundGateBeep, SoundGateDeny,
|
||||
SoundKiosk, SoundToilet, SoundLearn, SoundConfirm, SoundCancel, SoundTransfer, SoundWin, SoundLose, SoundStaff, SoundPlane},
|
||||
StationMelodies...)
|
||||
}
|
||||
|
||||
// MelodyFor är stationens avgångsmelodi: banans melody-fält, annars en som
|
||||
// väljs deterministiskt av banans id.
|
||||
func MelodyFor(l *level.Level) string {
|
||||
if l.Melody != "" {
|
||||
return "melody-" + l.Melody
|
||||
}
|
||||
h := fnv.New32a()
|
||||
h.Write([]byte(l.Station + l.ID))
|
||||
return StationMelodies[int(h.Sum32()%uint32(len(StationMelodies)))]
|
||||
}
|
||||
|
||||
// AudibleTiles är hur många rutor bort man hör perrongens ljud.
|
||||
const AudibleTiles = 12.0
|
||||
|
||||
@@ -37,17 +80,9 @@ func newAudio(mute bool) (*Audio, error) {
|
||||
if mute {
|
||||
return nil, nil
|
||||
}
|
||||
a := &Audio{ctx: audio.NewContext(sampleRate), clips: map[string][]byte{}}
|
||||
for _, name := range []string{SoundApproach, SoundDoors, SoundChatter} {
|
||||
b, err := res.ReadFile("sound", name+".wav")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stream, err := wav.DecodeWithoutResampling(bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ljud %s: %w", name, err)
|
||||
}
|
||||
pcm, err := io.ReadAll(stream)
|
||||
a := &Audio{ctx: audio.NewContext(sampleRate), clips: map[string][]byte{}, master: 1, effects: 1, music: 1}
|
||||
for _, name := range AllSounds() {
|
||||
pcm, err := decodeSound(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -56,7 +91,33 @@ func newAudio(mute bool) (*Audio, error) {
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// Play spelar ett klipp med volym 0..1.
|
||||
// decodeSound läser och avkodar ett klipp till PCM.
|
||||
func decodeSound(name string) ([]byte, error) {
|
||||
b, err := res.ReadFile("sound", name+".wav")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stream, err := wav.DecodeWithoutResampling(bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ljud %s: %w", name, err)
|
||||
}
|
||||
return io.ReadAll(stream)
|
||||
}
|
||||
|
||||
// SetVolumes sätter lagren (0..1) och justerar en pågående slinga.
|
||||
func (a *Audio) SetVolumes(master, effects, music float64) {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
a.master, a.effects, a.music = clamp01(master), clamp01(effects), clamp01(music)
|
||||
if a.loop != nil {
|
||||
a.loop.SetVolume(clamp01(a.loopBase * a.music * a.master))
|
||||
}
|
||||
}
|
||||
|
||||
func clamp01(v float64) float64 { return math.Max(0, math.Min(1, v)) }
|
||||
|
||||
// Play spelar ett klipp med volym 0..1 (gånger effekt- och huvudvolym).
|
||||
func (a *Audio) Play(name string, volume float64) {
|
||||
if a == nil || volume <= 0 {
|
||||
return
|
||||
@@ -65,19 +126,33 @@ func (a *Audio) Play(name string, volume float64) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
v := clamp01(volume * a.effects * a.master)
|
||||
if v <= 0 {
|
||||
return
|
||||
}
|
||||
p := a.ctx.NewPlayerFromBytes(pcm)
|
||||
p.SetVolume(math.Min(1, volume))
|
||||
p.SetVolume(v)
|
||||
p.Play()
|
||||
}
|
||||
|
||||
// Loop startar (eller byter) bakgrundsloopen med given volym; 0 stoppar.
|
||||
// Loop startar (eller byter) bakgrundsslingan med given volym; 0 stoppar.
|
||||
// Samma klipp som redan spelas fortsätter bara med ny volym.
|
||||
func (a *Audio) Loop(name string, volume float64) {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
if volume <= 0 {
|
||||
a.StopLoop()
|
||||
return
|
||||
}
|
||||
if a.loop != nil && a.loopName == name {
|
||||
a.loopBase = volume
|
||||
a.loop.SetVolume(clamp01(volume * a.music * a.master))
|
||||
return
|
||||
}
|
||||
a.StopLoop()
|
||||
pcm, ok := a.clips[name]
|
||||
if !ok || volume <= 0 {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
stream := audio.NewInfiniteLoop(bytes.NewReader(pcm), int64(len(pcm)))
|
||||
@@ -85,18 +160,18 @@ func (a *Audio) Loop(name string, volume float64) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
p.SetVolume(math.Min(1, volume))
|
||||
p.SetVolume(clamp01(volume * a.music * a.master))
|
||||
p.Play()
|
||||
a.loop = p
|
||||
a.loop, a.loopName, a.loopBase = p, name, volume
|
||||
}
|
||||
|
||||
// StopLoop stoppar bakgrundsloopen.
|
||||
// StopLoop stoppar bakgrundsslingan.
|
||||
func (a *Audio) StopLoop() {
|
||||
if a == nil || a.loop == nil {
|
||||
return
|
||||
}
|
||||
a.loop.Close()
|
||||
a.loop = nil
|
||||
a.loop, a.loopName = nil, ""
|
||||
}
|
||||
|
||||
// volumeAt ger volym efter avstånd i rutor.
|
||||
|
||||
46
internal/game/audio_test.go
Normal file
46
internal/game/audio_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
|
||||
)
|
||||
|
||||
// TestAllSoundsDecode: alla ljud som spelet refererar finns och går att
|
||||
// avkoda (utan ljudkontext eller fönster).
|
||||
func TestAllSoundsDecode(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for _, name := range AllSounds() {
|
||||
if seen[name] {
|
||||
t.Errorf("%s listas två gånger", name)
|
||||
}
|
||||
seen[name] = true
|
||||
pcm, err := decodeSound(name)
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", name, err)
|
||||
continue
|
||||
}
|
||||
if len(pcm) < sampleRate/20*4 { // minst 50 ms stereo 16-bit
|
||||
t.Errorf("%s: för kort (%d byte)", name, len(pcm))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMelodyForIsStableAndKnown(t *testing.T) {
|
||||
known := map[string]bool{}
|
||||
for _, m := range StationMelodies {
|
||||
known[m] = true
|
||||
}
|
||||
a := &level.Level{ID: "005-ueno", Station: "ueno"}
|
||||
if m := MelodyFor(a); !known[m] || m != MelodyFor(a) {
|
||||
t.Fatalf("MelodyFor = %q", m)
|
||||
}
|
||||
if m := MelodyFor(&level.Level{ID: "x", Melody: "c"}); m != "melody-c" {
|
||||
t.Fatalf("melody-fältet ska vinna: %q", m)
|
||||
}
|
||||
var nilAudio *Audio
|
||||
nilAudio.Play(SoundWin, 1) // nil-säkert
|
||||
nilAudio.Loop(SoundMusicMap, 1)
|
||||
nilAudio.SetVolumes(1, 1, 1)
|
||||
nilAudio.StopLoop()
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
"github.com/hajimehoshi/ebiten/v2/inpututil"
|
||||
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/endless"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang"
|
||||
@@ -52,6 +53,7 @@ type Game struct {
|
||||
Endless *EndlessRun // pågående omgång i oändligt läge (nil = ingen)
|
||||
|
||||
savePath string // "" = spara inte
|
||||
flagScale int // fönsterskala från flaggan (när inställningarna säger 0)
|
||||
fixedSeed uint64 // >0: alla banor startar med denna seed (tester/skärmdumpar)
|
||||
scene Scene
|
||||
next Scene
|
||||
@@ -76,6 +78,7 @@ type Options struct {
|
||||
NoSave bool // läs sparfilen men skriv aldrig
|
||||
Ending bool // öppna slutscenen direkt (tester/skärmdumpar)
|
||||
Endless bool // starta oändligt läge direkt (tester/skärmdumpar)
|
||||
Scale int // fönsterskala från flaggan (när inställningarna säger 0)
|
||||
Mode string // navigeringsläge från start: "list" eller "signs" (tomt = sparfilens)
|
||||
}
|
||||
|
||||
@@ -121,6 +124,8 @@ func New(opt Options) (*Game, error) {
|
||||
if opt.Mode != "" {
|
||||
g.Mode = nav.ParseMode(opt.Mode)
|
||||
}
|
||||
g.flagScale = max(1, opt.Scale)
|
||||
g.applySettings()
|
||||
if !opt.NoSave {
|
||||
g.savePath = path
|
||||
}
|
||||
@@ -278,11 +283,13 @@ func (g *Game) Save() {
|
||||
// SetScene byter scen från och med nästa uppdatering. Bakgrundsprat hör
|
||||
// till banan och tystnar när man lämnar den.
|
||||
func (g *Game) SetScene(s Scene) {
|
||||
if _, inLevel := s.(*LevelScene); !inLevel {
|
||||
if _, isResult := s.(*ResultScene); !isResult {
|
||||
switch s.(type) {
|
||||
case *LevelScene, *ResultScene: // banans sorl fortsätter
|
||||
case *MapScene, *SettingsScene, *EndingScene, *WordsScene, *SkillsScene, *ItemsScene:
|
||||
g.Audio.Loop(SoundMusicMap, 0.6) // menymusiken (fortsätter om den redan spelas)
|
||||
default:
|
||||
g.Audio.StopLoop()
|
||||
}
|
||||
}
|
||||
g.next = s
|
||||
}
|
||||
|
||||
@@ -305,6 +312,11 @@ func (g *Game) Update() error {
|
||||
if g.next != nil {
|
||||
g.scene, g.next = g.next, nil
|
||||
}
|
||||
if inpututil.IsKeyJustPressed(ebiten.KeyF11) { // fullskärm av/på överallt
|
||||
g.State.Settings.Fullscreen = !g.State.Settings.Fullscreen
|
||||
g.applySettings()
|
||||
g.Save()
|
||||
}
|
||||
if g.script != nil {
|
||||
if err := g.script.tick(g, g.frame); err != nil {
|
||||
return err
|
||||
@@ -349,6 +361,25 @@ func (g *Game) runOptions(id string, seed rng.Seed) sim.RunOptions {
|
||||
}
|
||||
}
|
||||
|
||||
// applySettings för ut inställningarna: volymer, språk, fönster.
|
||||
func (g *Game) applySettings() {
|
||||
st := g.State.Settings
|
||||
g.Audio.SetVolumes(float64(st.Master)/10, float64(st.Effects)/10, float64(st.Music)/10)
|
||||
if st.Lang != UILang {
|
||||
if err := g.Assets.LoadLang(st.Lang); err != nil {
|
||||
log.Printf("språk %s: %v", st.Lang, err)
|
||||
}
|
||||
}
|
||||
scale := st.Scale
|
||||
if scale <= 0 {
|
||||
scale = g.flagScale
|
||||
}
|
||||
if scale > 0 {
|
||||
ebiten.SetWindowSize(ScreenW*scale, ScreenH*scale)
|
||||
}
|
||||
ebiten.SetFullscreen(st.Fullscreen)
|
||||
}
|
||||
|
||||
// toggleMode växlar navigeringsläge och sparar.
|
||||
func (g *Game) toggleMode() {
|
||||
if g.Mode == nav.List {
|
||||
|
||||
@@ -126,6 +126,11 @@ func shopJustPressed() bool { return virtual.pressed["b"] || inpututil.IsKeyJust
|
||||
// phoneJustPressed: P = kortet i telefonen av/på (meny).
|
||||
func phoneJustPressed() bool { return virtual.pressed["p"] || inpututil.IsKeyJustPressed(ebiten.KeyP) }
|
||||
|
||||
// settingsJustPressed: S öppnar inställningarna från bankartan.
|
||||
func settingsJustPressed() bool {
|
||||
return virtual.pressed["s"] || inpututil.IsKeyJustPressed(ebiten.KeyS)
|
||||
}
|
||||
|
||||
// endlessJustPressed: O startar oändligt läge (när storyn är klar).
|
||||
func endlessJustPressed() bool {
|
||||
return virtual.pressed["o"] || inpututil.IsKeyJustPressed(ebiten.KeyO)
|
||||
|
||||
@@ -29,6 +29,7 @@ const (
|
||||
|
||||
// NewEndingScene startar hemresan.
|
||||
func NewEndingScene(g *Game) *EndingScene {
|
||||
g.Audio.Play(SoundPlane, 0.8)
|
||||
return &EndingScene{lines: strings.Split(g.Assets.T("ending.credits"), "\n")}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ type LevelScene struct {
|
||||
level *level.Level // aktuell delbana (byts när resan fortsätter på nästa station)
|
||||
run *sim.Run
|
||||
endless bool // målet är ett lottat mål i oändligt läge (inte storyn)
|
||||
sfx sfxState
|
||||
cam *sim.Camera
|
||||
animT float64
|
||||
hideMap bool // M döljer/visar minikartan
|
||||
@@ -183,6 +184,7 @@ func (s *LevelScene) Update(g *Game) error {
|
||||
return nil
|
||||
}
|
||||
s.updateEffects(g)
|
||||
s.updateSfx(g)
|
||||
if s.run.Mini != nil {
|
||||
// Minispel: all input går dit; Esc avbryter minispelet, inte banan.
|
||||
mx, my := miniMoveXY()
|
||||
|
||||
@@ -106,6 +106,9 @@ func (m *MapScene) Update(g *Game) error {
|
||||
g.Save()
|
||||
case modeJustPressed():
|
||||
g.toggleMode()
|
||||
case settingsJustPressed():
|
||||
g.SetScene(NewSettingsScene(m))
|
||||
return nil
|
||||
case endlessJustPressed() && g.Progress.StoryCleared:
|
||||
s, err := g.startEndless(g.seed())
|
||||
if err != nil {
|
||||
|
||||
117
internal/game/scene_settings.go
Normal file
117
internal/game/scene_settings.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/save"
|
||||
)
|
||||
|
||||
// SettingsScene är inställningarna: volymer, språk, fönsterskala och
|
||||
// fullskärm. Pilar väljer rad och ändrar, Enter växlar, Esc/S går tillbaka.
|
||||
// Allt sparas direkt.
|
||||
type SettingsScene struct {
|
||||
back Scene
|
||||
sel int
|
||||
}
|
||||
|
||||
const settingsRows = 6
|
||||
|
||||
// NewSettingsScene öppnar inställningarna.
|
||||
func NewSettingsScene(back Scene) *SettingsScene { return &SettingsScene{back: back} }
|
||||
|
||||
func (s *SettingsScene) Update(g *Game) error {
|
||||
if backJustPressed() || settingsJustPressed() {
|
||||
g.SetScene(s.back)
|
||||
return nil
|
||||
}
|
||||
s.sel = (s.sel + menuMove() + settingsRows) % settingsRows
|
||||
dx, _ := miniMoveXY()
|
||||
toggle := confirmJustPressed()
|
||||
if dx == 0 && !toggle {
|
||||
return nil
|
||||
}
|
||||
st := g.State.Settings
|
||||
step := func(v *int, lo, hi int) {
|
||||
*v = max(lo, min(hi, *v+dx))
|
||||
}
|
||||
switch s.sel {
|
||||
case 0:
|
||||
step(&st.Master, 0, 10)
|
||||
case 1:
|
||||
step(&st.Effects, 0, 10)
|
||||
case 2:
|
||||
step(&st.Music, 0, 10)
|
||||
case 3:
|
||||
if st.Lang == "sv" {
|
||||
st.Lang = "en"
|
||||
} else {
|
||||
st.Lang = "sv"
|
||||
}
|
||||
case 4:
|
||||
step(&st.Scale, 0, 6)
|
||||
case 5:
|
||||
st.Fullscreen = !st.Fullscreen
|
||||
}
|
||||
g.applySettings()
|
||||
g.Save()
|
||||
g.Audio.Play(SoundConfirm, 0.6)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SettingsScene) Draw(g *Game, screen *ebiten.Image) {
|
||||
a := g.Assets
|
||||
st := g.State.Settings
|
||||
screen.Fill(color.RGBA{24, 20, 37, 255})
|
||||
vector.DrawFilledRect(screen, 0, 0, ScreenW, 22, color.RGBA{38, 43, 68, 255}, false)
|
||||
a.DrawBig(screen, a.T("settings.title"), 8, 5, colText)
|
||||
scale := a.T("settings.scale_auto")
|
||||
if st.Scale > 0 {
|
||||
scale = fmt.Sprintf("%d× (%d×%d)", st.Scale, ScreenW*st.Scale, ScreenH*st.Scale)
|
||||
}
|
||||
fs := a.T("menu.off")
|
||||
if st.Fullscreen {
|
||||
fs = a.T("menu.on")
|
||||
}
|
||||
rows := [settingsRows][2]string{
|
||||
{a.T("settings.master"), ""},
|
||||
{a.T("settings.effects"), ""},
|
||||
{a.T("settings.music"), ""},
|
||||
{a.T("settings.lang"), a.T("lang." + st.Lang)},
|
||||
{a.T("settings.scale"), scale},
|
||||
{a.T("settings.fullscreen"), fs},
|
||||
}
|
||||
levels := [3]int{st.Master, st.Effects, st.Music}
|
||||
y := 30
|
||||
for i, r := range rows {
|
||||
col := colText
|
||||
if i == s.sel {
|
||||
vector.DrawFilledRect(screen, 6, float32(y-2), ScreenW-12, LineH+1, colBar, false)
|
||||
col = colKnown
|
||||
}
|
||||
a.Draw(screen, r[0], 10, y, col)
|
||||
if i < 3 { // volymstapel i tio segment
|
||||
for k := 0; k < 10; k++ {
|
||||
c := color.RGBA{70, 80, 110, 255}
|
||||
if k < levels[i] {
|
||||
c = color.RGBA{120, 200, 120, 255}
|
||||
}
|
||||
vector.DrawFilledRect(screen, float32(130+k*9), float32(y+2), 7, 7, c, false)
|
||||
}
|
||||
a.Draw(screen, fmt.Sprintf("%d", levels[i]), 224, y, col)
|
||||
} else {
|
||||
a.Draw(screen, r[1], 130, y, col)
|
||||
}
|
||||
y += LineH + 1
|
||||
}
|
||||
y += 4
|
||||
for _, l := range a.wrapLine(plainLine(a.T("settings.keys"), colDim), ScreenW-20) {
|
||||
a.drawLines(screen, []line{l}, 10, y)
|
||||
y += LineH
|
||||
}
|
||||
a.Draw(screen, a.T("settings.hint"), 8, ScreenH-12, colDim)
|
||||
_ = save.DefaultSettings // (inställningarnas standardvärden ligger i save)
|
||||
}
|
||||
66
internal/game/sfx.go
Normal file
66
internal/game/sfx.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package game
|
||||
|
||||
import "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
|
||||
|
||||
// sfxState är vad ljudlagret senast såg av omgången; skillnader mot nu ger
|
||||
// effekter (spärr, kiosk, toalett, lärt ord, byte, personal, vinst/förlust).
|
||||
type sfxState struct {
|
||||
init bool
|
||||
gatePasses int
|
||||
served int
|
||||
staffCalls int
|
||||
deniedAt float64
|
||||
noExitAt float64
|
||||
learnedAt float64
|
||||
transferAt float64
|
||||
flashAt float64
|
||||
facility bool
|
||||
mini bool
|
||||
outcome sim.Outcome
|
||||
}
|
||||
|
||||
// updateSfx spelar effekter för det som hänt sedan förra bildrutan.
|
||||
func (s *LevelScene) updateSfx(g *Game) {
|
||||
r := s.run
|
||||
x := &s.sfx
|
||||
if x.init {
|
||||
switch {
|
||||
case r.GatePasses > x.gatePasses:
|
||||
g.Audio.Play(SoundGateBeep, 0.7)
|
||||
case r.DeniedAt >= 0 && r.DeniedAt != x.deniedAt, r.NoExitAt >= 0 && r.NoExitAt != x.noExitAt:
|
||||
g.Audio.Play(SoundGateDeny, 0.7)
|
||||
}
|
||||
if r.Served > x.served {
|
||||
if x.facility && !r.Facility.Active {
|
||||
g.Audio.Play(SoundToilet, 0.6)
|
||||
} else {
|
||||
g.Audio.Play(SoundKiosk, 0.7)
|
||||
}
|
||||
}
|
||||
if r.LearnedAt >= 0 && r.LearnedAt != x.learnedAt {
|
||||
g.Audio.Play(SoundLearn, 0.8)
|
||||
}
|
||||
if r.TransferAt >= 0 && r.TransferAt != x.transferAt {
|
||||
g.Audio.Play(SoundTransfer, 0.8)
|
||||
}
|
||||
if r.StaffCalls > x.staffCalls {
|
||||
g.Audio.Play(SoundStaff, 0.7)
|
||||
}
|
||||
if r.Mini != nil && !x.mini {
|
||||
g.Audio.Play(SoundConfirm, 0.5)
|
||||
}
|
||||
if r.FlashAt >= 0 && r.FlashAt != x.flashAt && r.FlashKey != sim.FlashTransfer && r.FlashKey != sim.FlashStaff {
|
||||
g.Audio.Play(SoundCancel, 0.6)
|
||||
}
|
||||
if r.Outcome != x.outcome {
|
||||
switch r.Outcome {
|
||||
case sim.Won:
|
||||
g.Audio.Play(SoundWin, 0.9)
|
||||
case sim.Lost:
|
||||
g.Audio.Play(SoundLose, 0.9)
|
||||
}
|
||||
}
|
||||
}
|
||||
*x = sfxState{init: true, gatePasses: r.GatePasses, served: r.Served, staffCalls: r.StaffCalls, deniedAt: r.DeniedAt, noExitAt: r.NoExitAt,
|
||||
learnedAt: r.LearnedAt, transferAt: r.TransferAt, flashAt: r.FlashAt, facility: r.Facility.Active, mini: r.Mini != nil, outcome: r.Outcome}
|
||||
}
|
||||
@@ -163,9 +163,13 @@ func (s *LevelScene) updateEffects(g *Game) {
|
||||
dist := math.Hypot(float64(tr.platform.X*TileSize)-px, float64(tr.platform.Y*TileSize)-py) / TileSize
|
||||
mask := noiseMask(r.Level.Noise)
|
||||
switch phase {
|
||||
case level.Approaching:
|
||||
case level.Approaching: // ankomstjingeln: tåget är på väg
|
||||
g.Audio.Play(SoundApproach, volumeAt(dist)*mask)
|
||||
case level.Closing:
|
||||
case level.Arriving: // ding-dong när det rullar in
|
||||
g.Audio.Play(SoundAnnounce, volumeAt(dist)*mask)
|
||||
case level.Closing: // stationens avgångsmelodi innan dörrarna stängs
|
||||
g.Audio.Play(MelodyFor(r.Level), volumeAt(dist)*mask)
|
||||
case level.Leaving: // dörrsignalen när det går
|
||||
g.Audio.Play(SoundDoors, volumeAt(dist)*mask)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,6 +268,7 @@ type Level struct {
|
||||
IndividualCards bool `json:"individualCards"` // alla i sällskapet har egna kort (och biljetter) som spärren kräver
|
||||
SeatSelection bool `json:"seatSelection"` // biljettköpet fortsätter med sittplatsval efter sällskapets önskemål
|
||||
OneWayGates bool `json:"oneWayGates"` // spärren släpper inte ut en igen förrän man åkt; personal vid spärren låser upp (tar tid)
|
||||
Melody string `json:"melody"` // stationens avgångsmelodi: "a".."d" (tomt = vald efter id)
|
||||
FareYen int `json:"fareYen"` // vad spärren drar från kortet (0 = standard)
|
||||
Grid []string `json:"grid,omitempty"` // envåningsbana (kompatibilitet) – blir Floors[0]
|
||||
Floors []Storey `json:"floors,omitempty"` // flervåningsbana; alla våningar lika stora
|
||||
@@ -396,6 +397,9 @@ func (l *Level) Validate() error {
|
||||
if l.Order < 0 {
|
||||
return fmt.Errorf("level %s: negativ order", l.ID)
|
||||
}
|
||||
if l.Melody != "" && (len(l.Melody) != 1 || l.Melody[0] < 'a' || l.Melody[0] > 'd') {
|
||||
return fmt.Errorf("level %s: melody måste vara a–d", l.ID)
|
||||
}
|
||||
if len(l.Floors) == 0 {
|
||||
if len(l.Grid) == 0 {
|
||||
return fmt.Errorf("level %s: tomt rutnät", l.ID)
|
||||
|
||||
@@ -42,6 +42,32 @@ type State struct {
|
||||
Current *RunRef `json:"current,omitempty"`
|
||||
NavMode string `json:"navMode,omitempty"` // "list" (standard) eller "signs"
|
||||
Endless []EndlessRecord `json:"endless,omitempty"` // oändligt läge: bästa omgångarna, högst poäng först
|
||||
Settings *Settings `json:"settings,omitempty"`
|
||||
}
|
||||
|
||||
// Settings är spelarens inställningar (M12).
|
||||
type Settings struct {
|
||||
Master int `json:"master"` // 0–10
|
||||
Effects int `json:"effects"` // 0–10
|
||||
Music int `json:"music"` // 0–10 (musik och sorl)
|
||||
Lang string `json:"lang"` // "sv" eller "en"
|
||||
Scale int `json:"scale"` // fönsterskala 1–6, 0 = flaggan -scale
|
||||
Fullscreen bool `json:"fullscreen"`
|
||||
}
|
||||
|
||||
// DefaultSettings är standardinställningarna.
|
||||
func DefaultSettings() *Settings { return &Settings{Master: 8, Effects: 8, Music: 6, Lang: "sv"} }
|
||||
|
||||
// normalize ser till att inställningarna är rimliga (saknade = standard).
|
||||
func (s *Settings) normalize() {
|
||||
clamp := func(v *int) { *v = max(0, min(10, *v)) }
|
||||
clamp(&s.Master)
|
||||
clamp(&s.Effects)
|
||||
clamp(&s.Music)
|
||||
if s.Lang != "sv" && s.Lang != "en" {
|
||||
s.Lang = "sv"
|
||||
}
|
||||
s.Scale = max(0, min(6, s.Scale))
|
||||
}
|
||||
|
||||
// EndlessRecord är en avslutad omgång i oändligt läge.
|
||||
@@ -77,7 +103,7 @@ func (s *State) BestEndless() *EndlessRecord {
|
||||
|
||||
// New är ett nytt spel.
|
||||
func New() *State {
|
||||
return &State{Version: Version, Know: lang.NewKnowledge(), Wallet: sim.NewWallet(), Profile: &sim.Profile{}, Progress: progress.New()}
|
||||
return &State{Version: Version, Know: lang.NewKnowledge(), Wallet: sim.NewWallet(), Profile: &sim.Profile{}, Progress: progress.New(), Settings: DefaultSettings()}
|
||||
}
|
||||
|
||||
// DefaultPath är sparfilens plats: ~/.config/metro-puzzle-jp/save.json på
|
||||
@@ -116,6 +142,10 @@ func migrate(s *State) error {
|
||||
}
|
||||
// Version 0 (saknat fält) och 1 har samma form; här läggs framtida steg.
|
||||
s.Version = Version
|
||||
if s.Settings == nil {
|
||||
s.Settings = DefaultSettings()
|
||||
}
|
||||
s.Settings.normalize()
|
||||
if s.Know == nil {
|
||||
s.Know = lang.NewKnowledge()
|
||||
}
|
||||
|
||||
@@ -92,3 +92,27 @@ func TestEndlessRecords(t *testing.T) {
|
||||
t.Fatalf("listan: %+v", s.Endless)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsDefaultsAndNormalize(t *testing.T) {
|
||||
s := New()
|
||||
if s.Settings == nil || s.Settings.Master != 8 || s.Settings.Lang != "sv" {
|
||||
t.Fatalf("standardinställningar: %+v", s.Settings)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "save.json")
|
||||
if err := os.WriteFile(path, []byte(`{"version":1,"settings":{"master":99,"music":-3,"lang":"xx","scale":12}}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, _, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Settings.Master != 10 || st.Settings.Music != 0 || st.Settings.Lang != "sv" || st.Settings.Scale != 6 {
|
||||
t.Fatalf("normaliserade: %+v", st.Settings)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(`{"version":1}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st, _, err = Load(path); err != nil || st.Settings == nil || st.Settings.Effects != 8 {
|
||||
t.Fatalf("saknade inställningar ska bli standard: %+v %v", st.Settings, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
# resources/sound – ljud
|
||||
|
||||
Effekter och musik. Format: WAV (korta effekter, 16-bit mono 44,1 kHz) och
|
||||
OGG (musik, längre ljud).
|
||||
Alla ljud är syntetiserade av `go run ./tools/gensound` (16-bit mono
|
||||
44,1 kHz WAV, deterministiska, egna kompositioner – inga inspelade JR-melodier).
|
||||
Byt gärna ut dem mot riktiga inspelningar, men **behåll filnamnen**; listan
|
||||
i `internal/game/audio.go` (`AllSounds`) testas så att alla finns och går
|
||||
att avkoda.
|
||||
|
||||
- `melody-approach.wav` – avgångsmelodi i japansk stationsstil, spelas när
|
||||
ett tåg är på väg in till perrongen (fasen *approaching*).
|
||||
- `door-chime.wav` – dörrsignal, spelas strax före avgång (fasen *closing*).
|
||||
| Fil | När |
|
||||
|-----|-----|
|
||||
| `melody-approach.wav` | ankomstjingel när ett tåg är på väg in (*approaching*) |
|
||||
| `announce.wav` | ding-dong när tåget rullar in (*arriving*) |
|
||||
| `melody-a.wav` … `melody-d.wav` | stationens avgångsmelodi innan dörrarna stängs (*closing*); banans `melody`-fält eller vald efter id |
|
||||
| `door-chime.wav` | dörrsignal när tåget går (*leaving*) |
|
||||
| `chatter.wav` | loopbart stationssorl på banor med `noise` > 0 |
|
||||
| `music-map.wav` | bankartans/menyernas musikslinga |
|
||||
| `gate-beep.wav`, `gate-deny.wav` | spärren släpper igenom / nekar |
|
||||
| `kiosk.wav`, `toilet.wav` | köp i kiosken, toalettbesök klart |
|
||||
| `learn.wav` | nytt japanskt ord |
|
||||
| `confirm.wav`, `cancel.wav` | minispel öppnas / varning (fel vagn, för tidigt …) |
|
||||
| `transfer.wav`, `staff.wav` | byte till nästa station, personalen har låst upp |
|
||||
| `win.wav`, `lose.wav` | banan klarad / förlorad |
|
||||
| `plane.wav` | hemresan |
|
||||
|
||||
- `chatter.wav` – loopbart stationssorl (bakgrundsprat), spelas på banor med
|
||||
`noise` > 0 och konkurrerar med melodierna.
|
||||
|
||||
Alla är **placeholders** genererade av `go run ./tools/gensound`; riktiga
|
||||
inspelningar/kompositioner ersätter dem i M12 (behåll filnamnen).
|
||||
Planerat: utrop, folkmassa, spärr-pip, automat-ljud, UI-klick, bakgrundsprat
|
||||
och reklam som ljudröra (M9b).
|
||||
Volymerna styrs av inställningarna (S på bankartan): huvudvolym × effekter
|
||||
respektive musik/sorl.
|
||||
|
||||
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.resume": "⟳ resume (saved seed)",
|
||||
"map.locked": "Locked - clear the previous level first.",
|
||||
"map.hint": "Enter start N mode L words T skills I items P phone Esc",
|
||||
"map.hint": "Enter start N mode L words T skills I items P phone S set.",
|
||||
"skills.title": "Skills 技",
|
||||
"skills.to_next": "XP to next",
|
||||
"skills.already": "Already unlocked.",
|
||||
@@ -244,5 +244,17 @@
|
||||
"result.endless_lost": "Endless mode over after %d goals: %d points (best %d).",
|
||||
"result.endless_best": "New best!",
|
||||
"result.hint_endless": "Enter: next goal L: words Esc: end the run",
|
||||
"result.hint_endless_over": "Enter/Esc: level map L: words"
|
||||
"result.hint_endless_over": "Enter/Esc: level map L: words",
|
||||
"settings.title": "Settings",
|
||||
"settings.master": "Master volume",
|
||||
"settings.effects": "Effects",
|
||||
"settings.music": "Music and chatter",
|
||||
"settings.lang": "Language",
|
||||
"settings.scale": "Window scale",
|
||||
"settings.scale_auto": "from -scale (default 4x)",
|
||||
"settings.fullscreen": "Fullscreen (F11)",
|
||||
"settings.hint": "Arrows: pick row / change Enter: toggle Esc: back",
|
||||
"settings.keys": "Arrows/WASD walk, Enter select, Esc back, H help, L words, T skills, I wardrobe, B shop, M map, N mode, O endless, G NG+, S settings, F11 fullscreen. Gamepad: A select, B back, Y help, X words, Back map.",
|
||||
"lang.sv": "Svenska",
|
||||
"lang.en": "English"
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
"map.party": "Sällskap",
|
||||
"map.resume": "⟳ fortsätt (sparad seed)",
|
||||
"map.locked": "Låst - klara föregående bana först.",
|
||||
"map.hint": "Enter start N läge L ord T skills I items P tel Esc",
|
||||
"map.hint": "Enter start N läge L ord T skills I items P tel S inst.",
|
||||
"skills.title": "Skills 技",
|
||||
"skills.to_next": "XP till nästa",
|
||||
"skills.already": "Redan upplåst.",
|
||||
@@ -244,5 +244,17 @@
|
||||
"result.endless_lost": "Oändligt läge slut efter %d mål: %d poäng (bästa %d).",
|
||||
"result.endless_best": "Ny bästa!",
|
||||
"result.hint_endless": "Enter: nästa mål L: ordlista Esc: avsluta omgången",
|
||||
"result.hint_endless_over": "Enter/Esc: bankartan L: ordlista"
|
||||
"result.hint_endless_over": "Enter/Esc: bankartan L: ordlista",
|
||||
"settings.title": "Inställningar",
|
||||
"settings.master": "Huvudvolym",
|
||||
"settings.effects": "Effekter",
|
||||
"settings.music": "Musik och sorl",
|
||||
"settings.lang": "Språk",
|
||||
"settings.scale": "Fönsterskala",
|
||||
"settings.scale_auto": "från -scale (standard 4×)",
|
||||
"settings.fullscreen": "Fullskärm (F11)",
|
||||
"settings.hint": "Pilar: välj rad / ändra Enter: växla Esc: tillbaka",
|
||||
"settings.keys": "Pilar/WASD gå, Enter välj, Esc tillbaka, H hjälp, L ord, T skills, I garderob, B butik, M karta, N läge, O oändligt, G NG+, S inställningar, F11 fullskärm. Gamepad: A välj, B tillbaka, Y hjälp, X ord, Back karta.",
|
||||
"lang.sv": "Svenska",
|
||||
"lang.en": "English"
|
||||
}
|
||||
|
||||
@@ -291,6 +291,22 @@ func main() {
|
||||
planeImg := image.NewNRGBA(image.Rect(0, 0, 32, 12))
|
||||
plane(canvas{planeImg, 0, 0})
|
||||
save(filepath.Join(out, "plane.png"), planeImg)
|
||||
|
||||
iconImg := image.NewNRGBA(image.Rect(0, 0, 32, 32))
|
||||
icon(canvas{iconImg, 0, 0})
|
||||
save(filepath.Join(out, "icon.png"), iconImg)
|
||||
}
|
||||
|
||||
// icon är fönsterikonen (32×32): ett tågfront på mörk platta.
|
||||
func icon(c canvas) {
|
||||
c.rect(0, 0, 32, 32, rgb(38, 43, 68))
|
||||
c.rect(6, 4, 20, 24, rgb(236, 240, 245)) // front
|
||||
c.rect(8, 7, 16, 8, rgb(90, 140, 220)) // vindruta
|
||||
c.rect(6, 17, 20, 3, rgb(214, 60, 60)) // rand
|
||||
c.rect(9, 22, 4, 3, rgb(255, 230, 120)) // strålkastare
|
||||
c.rect(19, 22, 4, 3, rgb(255, 230, 120)) // strålkastare
|
||||
c.rect(4, 28, 24, 2, rgb(150, 160, 175)) // underrede
|
||||
c.rect(13, 1, 6, 3, rgb(150, 160, 175)) // strömavtagare
|
||||
}
|
||||
|
||||
// plane är flygplanet i slutscenen (32×12, nosen åt höger).
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
// Genererar placeholder-ljud tills riktiga inspelningar finns:
|
||||
// Genererar spelets ljud – syntetiserade, deterministiska och fria att
|
||||
// använda, tills riktiga inspelningar finns:
|
||||
//
|
||||
// go run ./tools/gensound
|
||||
//
|
||||
// Skriver resources/sound/melody-approach.wav (en kort pentatonisk
|
||||
// avgångsmelodi i japansk stationsstil, spelas när tåget är på väg in) och
|
||||
// resources/sound/door-chime.wav (dörrsignal strax före avgång). 16-bit
|
||||
// mono 44,1 kHz, syntetiserade med enkla vågformer så att allt är
|
||||
// deterministiskt.
|
||||
// Skriver 16-bit mono 44,1 kHz WAV till resources/sound/: fyra
|
||||
// stationsmelodier (avgångsmelodier i japansk stationsstil, en per
|
||||
// station), ankomstjingel, utropssignal, dörrsignal, spärrens pip och
|
||||
// nekande, kiosk, toalett, lärt ord, bekräfta/avbryt, byte, vinst, förlust,
|
||||
// personal, flygplan, bakgrundssorl och kartans musikslinga.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -24,20 +25,54 @@ type note struct {
|
||||
dur float64 // sekunder
|
||||
}
|
||||
|
||||
// Pentatonisk melodi (C-dur-pentatonik), två takter.
|
||||
// Toner (Hz).
|
||||
const (
|
||||
c5, d5, e5, f5, g5, a5, b5 = 523.25, 587.33, 659.25, 698.46, 783.99, 880.00, 987.77
|
||||
c6, d6, e6, g6, a6 = 1046.50, 1174.66, 1318.51, 1567.98, 1760.00
|
||||
g4, a4, b4, e4, f4 = 392.00, 440.00, 493.88, 329.63, 349.23
|
||||
)
|
||||
|
||||
// Ankomstjingel (C-dur-pentatonik), spelas när tåget är på väg in.
|
||||
var approachMelody = []note{
|
||||
{523.25, 0.18}, {587.33, 0.18}, {659.25, 0.18}, {783.99, 0.36},
|
||||
{880.00, 0.18}, {783.99, 0.18}, {659.25, 0.18}, {523.25, 0.36},
|
||||
{c5, 0.18}, {d5, 0.18}, {e5, 0.18}, {g5, 0.36},
|
||||
{a5, 0.18}, {g5, 0.18}, {e5, 0.18}, {c5, 0.36},
|
||||
{0, 0.12},
|
||||
{659.25, 0.18}, {783.99, 0.18}, {880.00, 0.18}, {1046.50, 0.54},
|
||||
{e5, 0.18}, {g5, 0.18}, {a5, 0.18}, {c6, 0.54},
|
||||
}
|
||||
|
||||
// Stationsmelodier: fyra olika avgångsmelodier (発車メロディ-stil, egna
|
||||
// kompositioner), ca 2,5 s, spelas innan dörrarna stängs.
|
||||
var stationMelodies = map[string][]note{
|
||||
"a": {{g5, 0.15}, {e5, 0.15}, {g5, 0.15}, {c6, 0.3}, {b5, 0.15}, {g5, 0.15}, {a5, 0.3}, {0, 0.1}, {g5, 0.15}, {e5, 0.15}, {d5, 0.15}, {c5, 0.6}},
|
||||
"b": {{e5, 0.2}, {g5, 0.2}, {a5, 0.2}, {g5, 0.2}, {e5, 0.4}, {0, 0.1}, {d5, 0.2}, {e5, 0.2}, {g5, 0.2}, {e5, 0.2}, {d5, 0.2}, {c5, 0.6}},
|
||||
"c": {{c6, 0.12}, {b5, 0.12}, {a5, 0.12}, {g5, 0.24}, {a5, 0.12}, {g5, 0.12}, {e5, 0.24}, {0, 0.08}, {g5, 0.12}, {a5, 0.12}, {c6, 0.12}, {d6, 0.24}, {c6, 0.6}},
|
||||
"d": {{a5, 0.18}, {c6, 0.18}, {a5, 0.18}, {g5, 0.36}, {e5, 0.18}, {g5, 0.18}, {a5, 0.36}, {0, 0.1}, {c6, 0.18}, {a5, 0.18}, {g5, 0.18}, {e5, 0.18}, {g5, 0.6}},
|
||||
}
|
||||
|
||||
// Dörrsignal: två toner växelvis.
|
||||
var doorChime = []note{
|
||||
{1318.51, 0.14}, {1046.50, 0.14}, {1318.51, 0.14}, {1046.50, 0.14},
|
||||
{1318.51, 0.14}, {1046.50, 0.14}, {1318.51, 0.14}, {1046.50, 0.28},
|
||||
{e6, 0.14}, {c6, 0.14}, {e6, 0.14}, {c6, 0.14},
|
||||
{e6, 0.14}, {c6, 0.14}, {e6, 0.14}, {c6, 0.28},
|
||||
}
|
||||
|
||||
// Utropssignal ("ding-dong") före utrop.
|
||||
var announce = []note{{e6, 0.25}, {c6, 0.45}}
|
||||
|
||||
// Korta effekter.
|
||||
var (
|
||||
learnJingle = []note{{c6, 0.09}, {e6, 0.09}, {g6, 0.22}}
|
||||
confirmBlip = []note{{a5, 0.05}, {c6, 0.07}}
|
||||
cancelBlip = []note{{e5, 0.06}, {c5, 0.08}}
|
||||
transferJingle = []note{{g5, 0.12}, {c6, 0.12}, {e6, 0.12}, {g6, 0.3}}
|
||||
winFanfare = []note{{c5, 0.14}, {e5, 0.14}, {g5, 0.14}, {c6, 0.4}, {0, 0.06}, {g5, 0.12}, {c6, 0.5}}
|
||||
loseSting = []note{{e5, 0.22}, {d5, 0.22}, {c5, 0.5}}
|
||||
kioskCoin = []note{{b5, 0.05}, {e6, 0.05}, {0, 0.04}, {g6, 0.12}}
|
||||
staffTone = []note{{a4, 0.18}, {0, 0.08}, {a4, 0.18}, {0, 0.12}, {e5, 0.3}}
|
||||
gateBeep = []note{{a6, 0.09}}
|
||||
gateDeny = []note{{e4, 0.12}, {0, 0.04}, {e4, 0.12}, {0, 0.04}, {e4, 0.14}}
|
||||
)
|
||||
|
||||
// render syntetiserar toner med sinus + överton, attack/release-envelope.
|
||||
func render(notes []note, attack, release float64, overtone float64) []int16 {
|
||||
var out []int16
|
||||
for _, n := range notes {
|
||||
@@ -63,6 +98,133 @@ func render(notes []note, attack, release float64, overtone float64) []int16 {
|
||||
return out
|
||||
}
|
||||
|
||||
// bell syntetiserar klockliknande toner: grundton + två övertoner som dör
|
||||
// ut snabbare, exponentiell avklingning – stationsmelodiernas klang.
|
||||
func bell(notes []note) []int16 {
|
||||
var out []int16
|
||||
for _, n := range notes {
|
||||
samples := int(n.dur * rate)
|
||||
for i := 0; i < samples; i++ {
|
||||
if n.freq == 0 {
|
||||
out = append(out, 0)
|
||||
continue
|
||||
}
|
||||
t := float64(i) / rate
|
||||
env := math.Exp(-3.5 * t / n.dur)
|
||||
if t < 0.004 {
|
||||
env *= t / 0.004
|
||||
}
|
||||
if rem := n.dur - t; rem < 0.02 {
|
||||
env *= rem / 0.02
|
||||
}
|
||||
v := math.Sin(2*math.Pi*n.freq*t) +
|
||||
0.45*math.Exp(-6*t/n.dur)*math.Sin(2*math.Pi*2*n.freq*t) +
|
||||
0.2*math.Exp(-9*t/n.dur)*math.Sin(2*math.Pi*3*n.freq*t)
|
||||
out = append(out, int16(v/1.65*env*0.55*32767))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// square syntetiserar en råare, fyrkantsliknande ton (spärrens nekande).
|
||||
func square(notes []note) []int16 {
|
||||
var out []int16
|
||||
for _, n := range notes {
|
||||
samples := int(n.dur * rate)
|
||||
for i := 0; i < samples; i++ {
|
||||
if n.freq == 0 {
|
||||
out = append(out, 0)
|
||||
continue
|
||||
}
|
||||
t := float64(i) / rate
|
||||
v := 0.0
|
||||
for h := 1.0; h <= 7; h += 2 { // udda övertoner
|
||||
v += math.Sin(2*math.Pi*n.freq*h*t) / h
|
||||
}
|
||||
env := 1.0
|
||||
if rem := n.dur - t; rem < 0.02 {
|
||||
env = rem / 0.02
|
||||
}
|
||||
out = append(out, int16(v/1.6*env*0.35*32767))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// lcg är en deterministisk brusgenerator.
|
||||
type lcg uint32
|
||||
|
||||
func (l *lcg) next() float64 {
|
||||
*l = *l*1664525 + 1013904223
|
||||
return float64(*l>>8)/float64(1<<24)*2 - 1
|
||||
}
|
||||
|
||||
// noiseSweep är filtrerat brus med envelope och filtersvep: toalettspolning
|
||||
// (fallande) och flygplan (stigande och fallande).
|
||||
func noiseSweep(seconds, lpFrom, lpTo float64, env func(t float64) float64) []int16 {
|
||||
n := int(seconds * rate)
|
||||
out := make([]int16, n)
|
||||
var r lcg = 777
|
||||
lp := 0.0
|
||||
for i := 0; i < n; i++ {
|
||||
t := float64(i) / rate
|
||||
f := t / seconds
|
||||
k := lpFrom + (lpTo-lpFrom)*f // filterkoefficient (0..1): högre = ljusare
|
||||
lp += (r.next() - lp) * k
|
||||
out[i] = int16(lp * env(t) * 0.7 * 32767)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// chatter syntetiserar sex sekunder loopbart stationssorl: lågpassat brus
|
||||
// med stavelseliknande amplitudmodulation från en deterministisk LCG.
|
||||
func chatter(seconds float64) []int16 {
|
||||
n := int(seconds * rate)
|
||||
out := make([]int16, n)
|
||||
var r lcg = 12345
|
||||
lp := 0.0
|
||||
env, envTarget, envT := 0.3, 0.3, 0.0
|
||||
for i := 0; i < n; i++ {
|
||||
t := float64(i) / rate
|
||||
if t >= envT { // ny "stavelse" var 120–250 ms
|
||||
envTarget = 0.1 + 0.6*math.Abs(r.next())
|
||||
envT = t + 0.12 + 0.13*math.Abs(r.next())
|
||||
}
|
||||
env += (envTarget - env) * 0.002
|
||||
lp += (r.next() - lp) * 0.08 // lågpass ≈ 600 Hz
|
||||
v := lp * env * 0.6
|
||||
fade := math.Min(1, math.Min(t, seconds-t)/0.2) // sömlös loop
|
||||
out[i] = int16(v * fade * 32767)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mapMusic är kartans musikslinga: ett långsamt arpeggio i C-dur-pentatonik
|
||||
// över två ackord, mjuka toner, loopbar (8 s).
|
||||
func mapMusic() []int16 {
|
||||
pattern := []note{
|
||||
{c5, 0.5}, {g5, 0.5}, {e5, 0.5}, {a5, 0.5}, {g5, 0.5}, {e5, 0.5}, {d5, 0.5}, {e5, 0.5},
|
||||
{a4, 0.5}, {e5, 0.5}, {c5, 0.5}, {g5, 0.5}, {e5, 0.5}, {c5, 0.5}, {d5, 0.5}, {g4, 0.5},
|
||||
}
|
||||
var out []int16
|
||||
for _, n := range pattern {
|
||||
samples := int(n.dur * rate)
|
||||
for i := 0; i < samples; i++ {
|
||||
t := float64(i) / rate
|
||||
env := math.Exp(-2.2*t/n.dur) * math.Min(1, t/0.01)
|
||||
v := math.Sin(2*math.Pi*n.freq*t) + 0.3*math.Sin(2*math.Pi*2*n.freq*t) + 0.15*math.Sin(2*math.Pi*n.freq/2*t)
|
||||
out = append(out, int16(v/1.45*env*0.3*32767))
|
||||
}
|
||||
}
|
||||
// låt tonerna ringa in i varandra: mjuk in-/utfasning i kanterna för loopen
|
||||
for i := 0; i < 400; i++ {
|
||||
f := float64(i) / 400
|
||||
out[i] = int16(float64(out[i]) * f)
|
||||
out[len(out)-1-i] = int16(float64(out[len(out)-1-i]) * f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func writeWAV(path string, pcm []int16) error {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
@@ -83,39 +245,35 @@ func writeWAV(path string, pcm []int16) error {
|
||||
return binary.Write(f, binary.LittleEndian, pcm)
|
||||
}
|
||||
|
||||
// chatter syntetiserar sex sekunder loopbart stationssorl: lågpassat brus
|
||||
// med stavelseliknande amplitudmodulation från en deterministisk LCG.
|
||||
func chatter(seconds float64) []int16 {
|
||||
n := int(seconds * rate)
|
||||
out := make([]int16, n)
|
||||
var lcg uint32 = 12345
|
||||
next := func() float64 { lcg = lcg*1664525 + 1013904223; return float64(lcg>>8)/float64(1<<24)*2 - 1 }
|
||||
lp := 0.0
|
||||
env, envTarget, envT := 0.3, 0.3, 0.0
|
||||
for i := 0; i < n; i++ {
|
||||
t := float64(i) / rate
|
||||
// ny "stavelse" var 120–250 ms
|
||||
if t >= envT {
|
||||
envTarget = 0.1 + 0.6*math.Abs(next())
|
||||
envT = t + 0.12 + 0.13*math.Abs(next())
|
||||
}
|
||||
env += (envTarget - env) * 0.002
|
||||
lp += (next() - lp) * 0.08 // lågpass ≈ 600 Hz
|
||||
v := lp * env * 0.6
|
||||
// mjuk in-/utfasning för sömlös loop
|
||||
fade := math.Min(1, math.Min(t, seconds-t)/0.2)
|
||||
out[i] = int16(v * fade * 32767)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func main() {
|
||||
out := filepath.Join(repoRoot(), "resources", "sound")
|
||||
must(os.MkdirAll(out, 0o755))
|
||||
must(writeWAV(filepath.Join(out, "melody-approach.wav"), render(approachMelody, 0.01, 0.06, 0.3)))
|
||||
must(writeWAV(filepath.Join(out, "door-chime.wav"), render(doorChime, 0.005, 0.04, 0.1)))
|
||||
must(writeWAV(filepath.Join(out, "chatter.wav"), chatter(6)))
|
||||
fmt.Println("skrev", filepath.Join(out, "melody-approach.wav"), ", door-chime.wav och chatter.wav")
|
||||
clips := map[string][]int16{
|
||||
"melody-approach": render(approachMelody, 0.01, 0.06, 0.3),
|
||||
"door-chime": render(doorChime, 0.005, 0.04, 0.1),
|
||||
"announce": bell(announce),
|
||||
"gate-beep": render(gateBeep, 0.003, 0.02, 0.0),
|
||||
"gate-deny": square(gateDeny),
|
||||
"kiosk": render(kioskCoin, 0.003, 0.03, 0.5),
|
||||
"toilet": noiseSweep(1.1, 0.35, 0.05, func(t float64) float64 { return math.Min(1, t/0.05) * math.Max(0, 1-t/1.1) }),
|
||||
"learn": bell(learnJingle),
|
||||
"confirm": render(confirmBlip, 0.003, 0.03, 0.2),
|
||||
"cancel": render(cancelBlip, 0.003, 0.03, 0.2),
|
||||
"transfer": bell(transferJingle),
|
||||
"win": render(winFanfare, 0.005, 0.05, 0.4),
|
||||
"lose": render(loseSting, 0.01, 0.08, 0.2),
|
||||
"staff": square(staffTone),
|
||||
"plane": noiseSweep(3.0, 0.03, 0.4, func(t float64) float64 { return math.Sin(math.Pi * t / 3.0) }),
|
||||
"chatter": chatter(6),
|
||||
"music-map": mapMusic(),
|
||||
}
|
||||
for id, m := range stationMelodies {
|
||||
clips["melody-"+id] = bell(m)
|
||||
}
|
||||
for name, pcm := range clips {
|
||||
must(writeWAV(filepath.Join(out, name+".wav"), pcm))
|
||||
}
|
||||
fmt.Printf("skrev %d ljud till %s\n", len(clips), out)
|
||||
}
|
||||
|
||||
func repoRoot() string {
|
||||
|
||||
@@ -168,6 +168,7 @@ func buildShots(levelsDir string) error {
|
||||
{"", "wait 150; shot $OUT/ending.png; press enter; wait 5; shot $OUT/ending-summary.png; press enter; wait 250; shot $OUT/credits.png; quit", []string{"-ending", "-save", "$DEMO"}},
|
||||
{"007-akihabara", toKiosk7 + "; urge Ville; press enter; wait 5; press right; wait 3; shot $OUT/payer.png; quit", []string{"-seed", "7"}},
|
||||
{"", "wait 10; shot $OUT/endless.png; quit", []string{"-endless", "-seed", "7", "-mode", "list", "-save", "$DEMO"}},
|
||||
{"", "wait 10; press s; wait 5; press down; wait 3; shot $OUT/settings.png; quit", []string{"-save", "$DEMO"}},
|
||||
{"005-ueno", toPlatform5 + "; " + toGateBack + "; wait 5; push " + pushOut + " 30; wait 3; shot $OUT/no-exit.png; press enter; wait 150; shot $OUT/staff.png; quit", []string{"-seed", "7"}},
|
||||
}
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user