M7b: tågfaser, räls, in-/utkörning, vind, ankomstindikator och melodier
All checks were successful
release / build-release (push) Successful in 1m42s

- level.Schedule.Phase: away/approaching/arriving/dwelling/closing/leaving
  ur tidtabellen med progress; Boardable (öppna dörrar) och Present;
  validering av minsta uppehåll/borta-tid; TrackTile för rälsen
- sim.Run: ombordstigning bara med öppna dörrar, TrainVisible via Present,
  PlatformPhase och NearTrack (närmaste perrong inom två rutor)
- game/trains.go: rälsrutor längs kantraden (roterade för vertikala spår),
  tre vagnar som bromsar in och accelererar ut längs rälsen, strålkastare
  under inrullningen, blinkande lampor vid kanten för den som står nära,
  dammpartiklar längs perrongen; kameran får en rutas marginal så att
  kanträlsen syns
- game/audio.go: ebiten/v2/audio med avgångsmelodi (approaching) och
  dörrsignal (closing), volym efter avstånd, -mute; tools/gensound
  syntetiserar placeholder-WAV:ar
- HUD: fasberoende perrongtext och "tåg på väg in" nära rälsen;
  skriptkommandot waitphase; skärmdump av ankomsten
- plan (Björn 2026-10-24): räls/väg-grafik och melodierna i M7b; folk/NPC:er,
  vänster-/högertrafik, bredare gångar, reklam- och ljudröra som nytt steg
  M9b – i spelbeskrivning, roadmap, testplan
- docs/README/wiki uppdaterade

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 11:03:12 +02:00
parent b2995d4ca9
commit d466333f01
42 changed files with 855 additions and 82 deletions

View File

@@ -10,12 +10,13 @@ Storyn följer några polare på semester i Japan, från landningen på
flygplatsen till avresan. Efter storyn låses ett oändligt läge och New Game+ flygplatsen till avresan. Efter storyn låses ett oändligt läge och New Game+
upp. Hela speldesignen finns i [doc/spelbeskrivning.md](doc/spelbeskrivning.md). upp. Hela speldesignen finns i [doc/spelbeskrivning.md](doc/spelbeskrivning.md).
**Status:** M7 klar (2026-10-24) – tre banor med stigande svårighet. **Status:** M7b klar (2026-10-24) – tre banor med stigande svårighet.
Japanska skyltar och ordinlärning, lämmeltåget med sina behov, minikarta med Japanska skyltar och ordinlärning, lämmeltåget med sina behov, minikarta med
dimma, tågkort, biljetter och minispel, och nu svårigheten: tidsgräns, max dimma, tågkort, biljetter och minispel, svårighet med tidsgräns, poäng,
antal missade tåg, en poängformel, debuffar som följer med mellan banor, och debuffar, egna kort och sittplatsval. Och nu lever tågen: de rullar in på
inställningarna egna kort per person och sittplatsval med sällskapets rälsen till avgångsmelodin, står med öppna dörrar, dörrsignalen ljuder och
önskemål. Nästa är M7b: tågen ska synas och kännas när de kommer och går. Se de accelererar ut igen, med vind längs perrongen och lampor som varnar den
som står nära rälsen. Nästa är M8: progression, items och sparfiler. Se
[doc/roadmap.md](doc/roadmap.md). [doc/roadmap.md](doc/roadmap.md).
![Skylt på japanska med delvis översättning](doc/img/sign.png) ![Skylt på japanska med delvis översättning](doc/img/sign.png)
@@ -30,6 +31,8 @@ för att kliva på. Rätt tåg tar dig till resmålet, fel tåg avslutar banan.
På tåget passerar stationerna en i taget; kliv av med Enter vid ditt resmål. På tåget passerar stationerna en i taget; kliv av med Enter vid ditt resmål.
Klarar du banan under par får du stjärnor, språkpoäng och resmålets namn. Klarar du banan under par får du stjärnor, språkpoäng och resmålets namn.
![Tåget på väg in](doc/img/approach.png)
![Perrongen – tåget är inne](doc/img/platform.png) ![Perrongen – tåget är inne](doc/img/platform.png)
Spärren drar från tågkortet. Tar saldot slut fyller du på vid automaten, Spärren drar från tågkortet. Tar saldot slut fyller du på vid automaten,
@@ -132,6 +135,7 @@ metro-puzzle-jp -know 改札,番線 -points 3 # förkunskap och språkpoäng (
metro-puzzle-jp -seed 7 # fast seed för händelser, läsning och minispel (tester/skärmdumpar) metro-puzzle-jp -seed 7 # fast seed för händelser, läsning och minispel (tester/skärmdumpar)
metro-puzzle-jp -phone -card 0 # kortet i telefonen; kortets startsaldo (tester/skärmdumpar) metro-puzzle-jp -phone -card 0 # kortet i telefonen; kortets startsaldo (tester/skärmdumpar)
metro-puzzle-jp -debuffs sore_feet,tired # debuffar från start (tester/skärmdumpar) metro-puzzle-jp -debuffs sore_feet,tired # debuffar från start (tester/skärmdumpar)
metro-puzzle-jp -mute # inget ljud
metro-puzzle-jp -script "move right 4; shot bild.png; quit" # skriptläge, se internal/game/script.go metro-puzzle-jp -script "move right 4; shot bild.png; quit" # skriptläge, se internal/game/script.go
metro-puzzle-jp -version metro-puzzle-jp -version
``` ```
@@ -152,6 +156,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` och `player.png` | | `go run ./tools/genart` | genererar placeholder-grafiken `resources/sprites/tiles.png` och `player.png` |
| `go run ./tools/gensound` | genererar placeholder-ljuden `resources/sound/*.wav` (avgångsmelodi, dörrsignal) |
| `go run ./tools/shots` | tar dokumentationens skärmdumpar till `doc/img/` – **kör efter varje grafik-/UI-ändring och committa bilderna** | | `go run ./tools/shots` | tar dokumentationens skärmdumpar till `doc/img/` – **kör efter varje grafik-/UI-ändring och committa bilderna** |
## Beroenden ## Beroenden
@@ -159,7 +164,7 @@ Y = hjälp, X = ordlista, Back/Select = karta.
| | Linux | Windows | | | Linux | Windows |
|---|---|---| |---|---|---|
| **Bygga** | Go ≥ 1.27, gcc, pkgconf, mesa (GL-headers), libx11/libxrandr/libxcursor/libxinerama/libxi/libxxf86vm, alsa-lib | Go ≥ 1.27 | | **Bygga** | Go ≥ 1.27, gcc, pkgconf, mesa (GL-headers), libx11/libxrandr/libxcursor/libxinerama/libxi/libxxf86vm, alsa-lib | Go ≥ 1.27 |
| **Köra** | libc, samt ett skrivbord med OpenGL (mesa) och X11 eller Wayland (XWayland), ALSA/PipeWire för ljud – laddas dynamiskt vid start | Windows 10+ med DirectX 11 eller OpenGL | | **Köra** | libc, samt ett skrivbord med OpenGL (mesa) och X11 eller Wayland (XWayland), ALSA/PipeWire för ljud – laddas dynamiskt vid start (`-mute` stänger av ljudet) | Windows 10+ med DirectX 11 eller OpenGL |
| **Go-moduler** | `github.com/hajimehoshi/ebiten/v2` (spelbibliotek, inkl. `text/v2` för TTF-rendering) + indirekta beroenden (`purego`, `gomobile`, `go-text/typesetting`, `x/image`, `x/sys`, `x/sync`) | samma | | **Go-moduler** | `github.com/hajimehoshi/ebiten/v2` (spelbibliotek, inkl. `text/v2` för TTF-rendering) + indirekta beroenden (`purego`, `gomobile`, `go-text/typesetting`, `x/image`, `x/sys`, `x/sync`) | samma |
| **Font** | PixelMplus 10/12 px (M+ FONT LICENSE), ligger i `resources/text/fonts/` | samma | | **Font** | PixelMplus 10/12 px (M+ FONT LICENSE), ligger i `resources/text/fonts/` | samma |

View File

@@ -25,6 +25,7 @@ func main() {
phone := flag.Bool("phone", false, "ha tågkortet i telefonen från start") phone := flag.Bool("phone", false, "ha tågkortet i telefonen från start")
card := flag.Int("card", -1, "kortets startsaldo i yen, -1 = standard (tester/skärmdumpar)") card := flag.Int("card", -1, "kortets startsaldo i yen, -1 = standard (tester/skärmdumpar)")
debuffs := flag.String("debuffs", "", "debuffar från start, kommaseparerade, t.ex. sore_feet,tired (tester/skärmdumpar)") debuffs := flag.String("debuffs", "", "debuffar från start, kommaseparerade, t.ex. sore_feet,tired (tester/skärmdumpar)")
mute := flag.Bool("mute", false, "inget ljud")
version := flag.Bool("version", false, "skriv ut version och avsluta") version := flag.Bool("version", false, "skriv ut version och avsluta")
flag.Parse() flag.Parse()
@@ -33,7 +34,7 @@ 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)}) 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})
if err != nil { if err != nil {
log.Fatalf("kunde inte starta: %v", err) log.Fatalf("kunde inte starta: %v", err)
} }

BIN
doc/img/approach.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -116,6 +116,8 @@ att bygga på Linux som på Windows.
| `internal/sim` | `TestDebuffsAffectRunAndAreEarned` | Skoskav/matförgiftning/hunger sänker fart, snabbar mätare och förlänger hjälptiden; många steg, lång tid och tom matmätare ger debuffar | | `internal/sim` | `TestDebuffsAffectRunAndAreEarned` | Skoskav/matförgiftning/hunger sänker fart, snabbar mätare och förlänger hjälptiden; många steg, lång tid och tom matmätare ger debuffar |
| `internal/sim` | `TestIndividualCardsAtGateAndMachine`, `TestLevel003Flow` | Egna kort: spärren kräver allas saldo, automaten frågar vems kort, spärren drar från alla; **hela bana 003 headless** med biljetter åt alla, sittplatser, trängsel och resa | | `internal/sim` | `TestIndividualCardsAtGateAndMachine`, `TestLevel003Flow` | Egna kort: spärren kräver allas saldo, automaten frågar vems kort, spärren drar från alla; **hela bana 003 headless** med biljetter åt alla, sittplatser, trängsel och resa |
| `internal/minigame` | `TestTopUpPicksMemberWithIndividualCards`, `TestSeatSelectionPreferences` | Personval och 2×2-rutnät; seedade önskemål, upptagna platser, missnöje, fönster/gång/sida/bredvid, Esc avbryter köpet | | `internal/minigame` | `TestTopUpPicksMemberWithIndividualCards`, `TestSeatSelectionPreferences` | Personval och 2×2-rutnät; seedade önskemål, upptagna platser, missnöje, fönster/gång/sida/bredvid, Esc avbryter köpet |
| `internal/level` | `TestSchedulePhases`, `TestScheduleValidationForPhases`, `TestTrackTile` | **M7b:** faserna away→approaching→arriving→dwelling→closing→leaving ur tidtabellen, ombordstigning bara med öppna dörrar, Status och Phase konsekventa; för korta uppehåll/borta-tider avvisas; rälsrutan |
| `internal/sim` | `TestNearTrackAndPhases` | Närmaste perrong inom två rutor, inte tre; under inrullningen syns tåget men man kan inte kliva på; med öppna dörrar kan man |
| `internal/archtest` | `TestOnlyPresentationPackagesImportEbitengine` | Beroenderegeln ovan | | `internal/archtest` | `TestOnlyPresentationPackagesImportEbitengine` | Beroenderegeln ovan |
### Planerade tester per milstolpe ### Planerade tester per milstolpe
@@ -129,7 +131,8 @@ att bygga på Linux som på Windows.
| ~~M5 Minikarta~~ ✓ | Upptäckta rutor, nollställning per bana, avgångar från start – finns, se tabellen ovan | | ~~M5 Minikarta~~ ✓ | Upptäckta rutor, nollställning per bana, avgångar från start – finns, se tabellen ovan |
| ~~M6 Kort/biljetter/minispel~~ ✓ | Saldo, automat, biljetter, omskanning, batteri – finns, se tabellen ovan | | ~~M6 Kort/biljetter/minispel~~ ✓ | Saldo, automat, biljetter, omskanning, batteri – finns, se tabellen ovan |
| ~~M7 Svårighet/poäng/debuffs~~ ✓ | Poäng, tidsgräns, debuffar, egna kort, sittplatsval – finns, se tabellen ovan | | ~~M7 Svårighet/poäng/debuffs~~ ✓ | Poäng, tidsgräns, debuffar, egna kort, sittplatsval – finns, se tabellen ovan |
| M7b Tåg som kommer och går | `TrainPhase(t)` ger approaching/arriving/dwelling/departing/away med rätt fas ur tidtabellen; indikatorn aktiv bara inom N rutor från rälsen; vindzonen = rutor längs perrongkanten | | ~~M7b Tåg som kommer och går~~ ✓ | Faser, ombordstigning, indikatoravstånd – finns, se tabellen ovan; ljud och animation verifieras manuellt/med skärmdumpar |
| M9b Folk, reklam, ljudröra | NPC:er följer seedade mål utan att fastna; krock saktar ned; fel sida i gång bromsar; bot klarar bana med folk; fler NPC:er = längre tid; clutter/noise läses per bana |
| M8 Progression & sparfil | Sparfil round-trip (skriv → läs ger samma tillstånd inkl. seed); skill tree-krav; upplåsningar efter rätt bana; "< 33 %"-regeln kontrolleras mot itemlistan | | M8 Progression & sparfil | Sparfil round-trip (skriv → läs ger samma tillstånd inkl. seed); skill tree-krav; upplåsningar efter rätt bana; "< 33 %"-regeln kontrolleras mot itemlistan |
| M9a Flera våningar | Trappa byter våning för Mover och följare; `Path` över våningar; lösbarhetstest med trappor; dimma/minikarta per våning; gamla banor = en våning | | M9a Flera våningar | Trappa byter våning för Mover och följare; `Path` över våningar; lösbarhetstest med trappor; dimma/minikarta per våning; gamla banor = en våning |
| M9 Navigeringslägen | Listläge genererar rätt ordning av tåg; skyltläge: 1–4 giltiga vägar; fel-riktnings-förlust triggar vid rätt slutstation | | M9 Navigeringslägen | Listläge genererar rätt ordning av tåg; skyltläge: 1–4 giltiga vägar; fel-riktnings-förlust triggar vid rätt slutstation |

View File

@@ -34,8 +34,7 @@ brasse-linux01-runnern, inte Pi5:n.
M0 skelett ✓ → M1 rörelse/kamera ✓ → M2 tåg/spärrar/vinst ✓ → M3 japanska/skyltar ✓ M0 skelett ✓ → M1 rörelse/kamera ✓ → M2 tåg/spärrar/vinst ✓ → M3 japanska/skyltar ✓
→ M4 lämmeltåget ✓ → M5 minikarta ✓ → M6 kort/biljetter/minispel ✓ → M7 → M4 lämmeltåget ✓ → M5 minikarta ✓ → M6 kort/biljetter/minispel ✓ → M7
svårighet/poäng/debuffar ✓ → M7b tåg som kommer och går (vind, indikator, svårighet/poäng/debuffar ✓ → M7b tåg som kommer och går ✓ → M8 progression/sparfiler → M9a flera våningar → M9b folk/reklam/ljudröra → M9 navigeringslägen →
animation) → M8 progression/sparfiler → M9a flera våningar → M9 navigeringslägen →
M10 story-innehåll → M11 oändligt läge/NG+ → M12 ljud/polish/release. M10 story-innehåll → M11 oändligt läge/NG+ → M12 ljud/polish/release.
Innehåll per milstolpe: [roadmap.md › Milstolpar](roadmap.md#milstolpar). Innehåll per milstolpe: [roadmap.md › Milstolpar](roadmap.md#milstolpar).
Vad som ska vara testat per milstolpe: [mal-och-tester.md](mal-och-tester.md). Vad som ska vara testat per milstolpe: [mal-och-tester.md](mal-och-tester.md).

View File

@@ -328,24 +328,32 @@ på **brasse-linux01-runnern** (label `linux-amd64`), aldrig på Pi5:n.
`TestIndividualCardsAtGateAndMachine`, `TestLevel003Flow`, `TestIndividualCardsAtGateAndMachine`, `TestLevel003Flow`,
`TestSeatSelectionPreferences`); bana 003 använder dem. ✓ `TestSeatSelectionPreferences`); bana 003 använder dem. ✓
### M7b – Tåg som kommer och går (Björns tillägg 2026-10-24) ### M7b – Tåg som kommer och går ✓ (klar 2026-10-24)
- **Mål:** tågen ska kännas – man ska se och förstå när de kommer och går, - **Mål:** tågen ska synas, höras och kännas – man ska förstå när de kommer
särskilt när missade tåg kostar (M7). och går, särskilt när missade tåg kostar (M7).
- **Ankomstindikator nära rälsen:** några sekunder före ankomst (från - **Tågfaser** ur tidtabellen (`level.Schedule.Phase`): på väg in
tidtabellen) tänds lampor vid perrongkanten och strålkastare syns i (approaching, 5 s före), kör in (arriving, 1,5 s), står inne (dwelling,
tunneln/vid kartkanten, bara för den som står nära rälsen. dörrar öppna), strax avgång (closing, sista 3 s, dörrsignal), kör ut
- **Vind:** när tåget kör in eller avgår blåser det längs perrongen – (leaving, 1,5 s), borta. Man kan bara kliva på när dörrarna är öppna.
partiklar (damm, papper) och fladdrande kläder/hår på alla som står vid - **Räls** ritas längs perrongkanten (egen ruta i `tools/genart`); tågen
kanten. Kan senare påverka items (hattar som blåser av, M8). rullar in längs rälsen i tre vagnar, står, och accelererar ut – inte
- **In- och utkörningsanimation:** tågvagnarna rullar in från kartkanten, "dyker upp/försvinner". Vägrutor för bussar kommer med bussarna (M10).
bromsar, står (dörrar öppna) och accelererar ut – samma för bussar. Ersätter - **Ankomstindikator nära rälsen:** står man inom två rutor från perrongen
dagens "dyker upp/försvinner". när tåget är på väg in blinkar lampor vid kanten, strålkastare syns från
- **Teknik:** ren logik i `sim` (`TrainPhase(t)`: approaching/arriving/ det håll tåget kommer och inforutan säger "tåg på väg in".
dwelling/departing/away med fas 0–1 ur `level.Schedule`), partiklar och - **Vind:** dammpartiklar längs perrongen när tåget kör in och ut.
tweening i `internal/game`. Ljudet (M12) hakar på samma faser. - **Ljud:** ljudsystemet (`ebiten/v2/audio`) införs här med två placeholder-
- **Klar när:** faserna beräknas rätt ur tidtabellen (testat), indikatorn jinglar genererade av `tools/gensound` (pentatonisk avgångsmelodi när
syns bara nära rälsen, tåget rullar in/ut i stället för att blinka till. tåget är på väg in, dörrsignal strax före avgång), med volym efter avstånd
till perrongen och `-mute`. Riktiga inspelningar/kompositioner i M12.
- **Teknik:** faser i `level` (ren Go, testade), `sim.Run.NearTrack`,
partiklar/tweening/ljud i `internal/game`, skriptkommandot `waitphase`.
- **Klar när:** faserna beräknas rätt ur tidtabellen (testat:
`TestSchedulePhases`, `TestNearTrackAndPhases`), indikatorn syns bara
nära rälsen, tåget rullar in/ut och melodierna spelas i rätt fas. ✓
- **Konsekvens för banorna:** uppehållet måste vara minst 5,5 s och tiden
borta minst 7,5 s (valideras), så att faserna får plats.
### M8 – Progression, items och sparfiler ### M8 – Progression, items och sparfiler
@@ -377,6 +385,27 @@ på **brasse-linux01-runnern** (label `linux-amd64`), aldrig på Pi5:n.
minikartan visar rätt våning, och alla gamla banor fungerar oförändrat minikartan visar rätt våning, och alla gamla banor fungerar oförändrat
(en våning). (en våning).
### M9b – Folk, reklam och ljudröra (Björns tillägg 2026-10-24)
- **Mål:** stationen lever och överstimulerar – tre nya svårighetsvariabler.
- **Folk (NPC:er):** passagerare som går på och av tågen när dörrarna är
öppna, rör sig mellan kiosker, toaletter och utgångar, och in och ut från
stationen. De tar plats i rutnätet: man måste runda dem, och att gå in i
någon saktar ned (`crowd`-nivå per bana styr hur många).
- **Vänster-/högertrafik:** gångar och trappor kan markeras med en sida
(pilar i golvet); går man på fel sida möter man strömmen och bromsas.
- **Bredare gångar:** generatorn kan göra gångar två rutor breda där det
ska gå att passera folk; följarna går då i bredd när det får plats.
- **Visuell röra** (`clutter`-nivå): dekorationslager med reklam, affischer
och blinkande skyltar på väggarna, lockskyltar som liknar riktiga.
- **Ljudröra** (`noise`-nivå): japanskt prat, utrop och reklam i bakgrunden
som konkurrerar med melodierna; på de svåraste nivåerna överlappar de.
- **Teknik:** `sim.NPC` med enkla mål och BFS-vägar (ren Go, seedade),
dekorationslager i banformatet, `tools/genart` för folk/reklam,
`tools/gensound` för brusplaceholders; generatorn får bredd och sidor.
- **Klar när:** en bana med folk spelas av boten headless utan att fastna,
fler NPC:er ger mätbart längre tid, och röra/ljud styrs per bana.
### M9 – Navigeringslägen: lista och skyltläge ### M9 – Navigeringslägen: lista och skyltläge
- **Mål:** de två sätten att spela story. - **Mål:** de två sätten att spela story.
@@ -389,9 +418,9 @@ på **brasse-linux01-runnern** (label `linux-amd64`), aldrig på Pi5:n.
### M10 – Story-innehåll och flerbytesbanor ### M10 – Story-innehåll och flerbytesbanor
- **Mål:** hela resan från flygplats till flygplats. - **Mål:** hela resan från flygplats till flygplats.
- Alla huvudbanor och sidobanor; banor med flera stopp och byten; buss; - Alla huvudbanor och sidobanor; banor med flera stopp och byten; buss (med
stationer i flera våningar (M9a) och svårighetsinställningarna från M7 vägrutor och hållplatser); stationer i flera våningar (M9a), folk och röra
i stigande kombinationer. (M9b) och svårighetsinställningarna från M7 i stigande kombinationer.
- Slutanimation (karaktärerna flyger iväg, items och poäng visas) och - Slutanimation (karaktärerna flyger iväg, items och poäng visas) och
eftertexter. eftertexter.
- Överstimulerande UI/banor som extra svårighet. - Överstimulerande UI/banor som extra svårighet.
@@ -405,7 +434,8 @@ på **brasse-linux01-runnern** (label `linux-amd64`), aldrig på Pi5:n.
### M12 – Ljud, polish och release ### M12 – Ljud, polish och release
- Avgångsmelodier/ankomstjinglar, effekter, musik. - 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). - 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.

View File

@@ -77,6 +77,20 @@ otydligt står tolkningen i [§15](#15-tolkningar-av-originalet).
vid perrongkanten, strålkastare i tunneln. vid perrongkanten, strålkastare i tunneln.
- **Tåg och bussar har animationer** när de kör in till och ut från sina - **Tåg och bussar har animationer** när de kör in till och ut från sina
stationer och hållplatser, i stället för att bara dyka upp och försvinna. stationer och hållplatser, i stället för att bara dyka upp och försvinna.
- **Räls och väg har egen grafik** så att man ser var tågen och bussarna går.
- **De ikoniska melodierna** (発車メロディー) hörs när ett tåg är på väg in
och när det strax ska avgå, innan dörrarna stängs.
**Stationen lever** (tillägg 2026-10-24):
- **Folk** – NPC:er som går på och av tåg och bussar, rör sig mellan
butiker och in och ut från stationen. De är i vägen: man måste undvika
dem och de kan sakta ned en.
- **Vänster- eller högertrafik**: på vissa ställen, i trappor och gångar,
ska man gå på vänster eller höger sida. Går man fel sida krockar man med
strömmen.
- **Bredare gångar**: vissa gångar och vägar på banorna är breda nog att
man kan gå mellan folk.
**Flera våningar** (tillägg 2026-10-24): **Flera våningar** (tillägg 2026-10-24):
@@ -157,6 +171,12 @@ otydligt står tolkningen i [§15](#15-tolkningar-av-originalet).
och banan misslyckas. och banan misslyckas.
- **Extra svårigheter:** överstimulerande UI och banor byggda för att förvirra - **Extra svårigheter:** överstimulerande UI och banor byggda för att förvirra
spelaren räknas som egna, extra svårigheter. spelaren räknas som egna, extra svårigheter.
- **Visuell röra** (tillägg 2026-10-24): banor med mer visuellt brus –
reklam, affischer, blinkande skyltar – gör det svårare att hitta vart man
ska. En egen svårighetsvariabel.
- **Ljudröra** (tillägg 2026-10-24): japanskt prat, utrop och reklam som
ljudeffekter är också en svårighet; de konkurrerar med det man behöver
höra (melodierna, utropen om spåret).
- Variabler som styr svårighet: antal i sällskapet, andel engelska på - Variabler som styr svårighet: antal i sällskapet, andel engelska på
skyltar, kort-/biljettkrav, tidsgräns, antal byten, UI-störningar, antal skyltar, kort-/biljettkrav, tidsgräns, antal byten, UI-störningar, antal
våningar, egna kort, sittplatsönskemål. våningar, egna kort, sittplatsönskemål.
@@ -212,7 +232,9 @@ från skills i skill tree, och från unika skills man får av sidouppdrag.
- Pixel art. - Pixel art.
- Ljud som påminner om japanska stationer: **avgångsmelodier** och - Ljud som påminner om japanska stationer: **avgångsmelodier** och
**ankomstjinglar** som spelas när tåg kommer och går. **ankomstjinglar** som spelas när tåg kommer och går – melodin när tåget
är på väg in, dörrsignalen strax före avgång.
- Bakgrundsljud: japanskt prat, utrop och reklam (se §9, ljudröra).
## 14. Slump och sparande ## 14. Slump och sparande

2
go.mod
View File

@@ -7,8 +7,10 @@ require github.com/hajimehoshi/ebiten/v2 v2.10.5
require ( require (
github.com/ebitengine/gomobile v0.0.0-20260820040257-d11f821a26a6 // indirect github.com/ebitengine/gomobile v0.0.0-20260820040257-d11f821a26a6 // indirect
github.com/ebitengine/hideconsole v1.0.0 // indirect github.com/ebitengine/hideconsole v1.0.0 // indirect
github.com/ebitengine/oto/v3 v3.5.0 // indirect
github.com/ebitengine/purego v0.11.0 // indirect github.com/ebitengine/purego v0.11.0 // indirect
github.com/go-text/typesetting v0.3.5 // indirect github.com/go-text/typesetting v0.3.5 // indirect
github.com/jfreymuth/pulse v0.1.3 // indirect
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
golang.org/x/image v0.45.0 // indirect golang.org/x/image v0.45.0 // indirect
golang.org/x/sync v0.22.0 // indirect golang.org/x/sync v0.22.0 // indirect

4
go.sum
View File

@@ -2,6 +2,8 @@ github.com/ebitengine/gomobile v0.0.0-20260820040257-d11f821a26a6 h1:Tnc3Ytzxhgs
github.com/ebitengine/gomobile v0.0.0-20260820040257-d11f821a26a6/go.mod h1:gwnFEwdzWZpNehgwkeK4756Ez58f58bXz6bgEAq+xqk= github.com/ebitengine/gomobile v0.0.0-20260820040257-d11f821a26a6/go.mod h1:gwnFEwdzWZpNehgwkeK4756Ez58f58bXz6bgEAq+xqk=
github.com/ebitengine/hideconsole v1.0.0 h1:5J4U0kXF+pv/DhiXt5/lTz0eO5ogJ1iXb8Yj1yReDqE= github.com/ebitengine/hideconsole v1.0.0 h1:5J4U0kXF+pv/DhiXt5/lTz0eO5ogJ1iXb8Yj1yReDqE=
github.com/ebitengine/hideconsole v1.0.0/go.mod h1:hTTBTvVYWKBuxPr7peweneWdkUwEuHuB3C1R/ielR1A= github.com/ebitengine/hideconsole v1.0.0/go.mod h1:hTTBTvVYWKBuxPr7peweneWdkUwEuHuB3C1R/ielR1A=
github.com/ebitengine/oto/v3 v3.5.0 h1:8ZDhKP3Hhslyb9NL3g4HWvhRQqwI+hKOHAXAqD6GPXs=
github.com/ebitengine/oto/v3 v3.5.0/go.mod h1:Elkm7yzTRns3w2efvibzVOoQ65YOwmec9a76dCiK10o=
github.com/ebitengine/purego v0.11.0 h1:jhp/D+Nyv7UUW8HAcmcjt2N2rYrYi9m3SL21k0Ua/NI= github.com/ebitengine/purego v0.11.0 h1:jhp/D+Nyv7UUW8HAcmcjt2N2rYrYi9m3SL21k0Ua/NI=
github.com/ebitengine/purego v0.11.0/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ= github.com/ebitengine/purego v0.11.0/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
github.com/go-text/typesetting v0.3.5 h1:XZPUooClHY0Vf/rFyUyuPRNEkawARaFzLMQcXLSEyPk= github.com/go-text/typesetting v0.3.5 h1:XZPUooClHY0Vf/rFyUyuPRNEkawARaFzLMQcXLSEyPk=
@@ -12,6 +14,8 @@ github.com/hajimehoshi/bitmapfont/v4 v4.2.0 h1:bpeN2ph7aHSuX7yoWFkLPo4ado9K+E+Dq
github.com/hajimehoshi/bitmapfont/v4 v4.2.0/go.mod h1:AIeoQek1nxUmDczXNtFEYWTQ4ArINijutPD729q/NeE= github.com/hajimehoshi/bitmapfont/v4 v4.2.0/go.mod h1:AIeoQek1nxUmDczXNtFEYWTQ4ArINijutPD729q/NeE=
github.com/hajimehoshi/ebiten/v2 v2.10.5 h1:D6XuOFhzgkvfqeA6Xwwvcz54uj6HHPNC9Y+0pE6Qfs4= github.com/hajimehoshi/ebiten/v2 v2.10.5 h1:D6XuOFhzgkvfqeA6Xwwvcz54uj6HHPNC9Y+0pE6Qfs4=
github.com/hajimehoshi/ebiten/v2 v2.10.5/go.mod h1:47QNgyS/y2ZRkjVUvlGLx8a+F7MSjcn8/GsjcCZ9Rc8= github.com/hajimehoshi/ebiten/v2 v2.10.5/go.mod h1:47QNgyS/y2ZRkjVUvlGLx8a+F7MSjcn8/GsjcCZ9Rc8=
github.com/jfreymuth/pulse v0.1.3 h1:bc5TdxiB8E+2INnFjFWWgyfgXtz2IyNNNCX+Wt/ZD14=
github.com/jfreymuth/pulse v0.1.3/go.mod h1:cpYspI6YljhkUf1WLXLLDmeaaPFc3CnGLjDZf9dZ4no=
github.com/pierrec/lz4/v4 v4.1.29 h1:CDQY6qZOLI4DW0Nx6R1vRrifrCeQHnNXkMb0hZWXFjg= github.com/pierrec/lz4/v4 v4.1.29 h1:CDQY6qZOLI4DW0Nx6R1vRrifrCeQHnNXkMb0hZWXFjg=
github.com/pierrec/lz4/v4 v4.1.29/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pierrec/lz4/v4 v4.1.29/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=

View File

@@ -25,17 +25,23 @@ const LineH = 12
var tileOrder = []level.Tile{ var tileOrder = []level.Tile{
level.Wall, level.Floor, level.Spawn, level.Platform, level.Gate, level.TicketMachine, level.Sign, level.Wall, level.Floor, level.Spawn, level.Platform, level.Gate, level.TicketMachine, level.Sign,
level.Wall, // plats 7 = tågvagnen (laddas separat som Train) level.Wall, // plats 7 = tågvagnen (laddas separat som Train)
level.Toilet, level.Kiosk, level.Toilet,
level.Wall, // plats 9 = rälsen (laddas separat som Rails)
level.Kiosk,
} }
const trainIndex = 7 const (
trainIndex = 7
railsIndex = 9
)
// Assets är allt som laddats från resources/. // Assets är allt som laddats från resources/.
type Assets struct { type Assets struct {
ResDir string ResDir string
LevelsDir string LevelsDir string
Tiles map[level.Tile]*ebiten.Image Tiles map[level.Tile]*ebiten.Image
Train *ebiten.Image // tågvagn, ritas vid perrongen när tåget är inne Train *ebiten.Image // tågvagn, ritas längs rälsen när tåget syns
Rails *ebiten.Image // rälsruta (horisontell), roteras för vertikala spår
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
@@ -63,13 +69,14 @@ func LoadAssets() (*Assets, error) {
return nil, fmt.Errorf("tiles.png är %d px bred, behöver minst %d", w, need) return nil, fmt.Errorf("tiles.png är %d px bred, behöver minst %d", w, need)
} }
for i, t := range tileOrder { for i, t := range tileOrder {
if i == trainIndex { if i == trainIndex || i == railsIndex {
continue continue
} }
a.Tiles[t] = sub(i) a.Tiles[t] = sub(i)
} }
a.Tiles[level.WrongPlatform] = a.Tiles[level.Platform] // samma utseende; tåget avgör a.Tiles[level.WrongPlatform] = a.Tiles[level.Platform] // samma utseende; tåget avgör
a.Train = sub(trainIndex) a.Train = sub(trainIndex)
a.Rails = sub(railsIndex)
player, err := loadImage("sprites", "player.png") player, err := loadImage("sprites", "player.png")
if err != nil { if err != nil {

72
internal/game/audio.go Normal file
View File

@@ -0,0 +1,72 @@
package game
import (
"bytes"
"fmt"
"io"
"math"
"github.com/hajimehoshi/ebiten/v2/audio"
"github.com/hajimehoshi/ebiten/v2/audio/wav"
"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.
type Audio struct {
ctx *audio.Context
clips map[string][]byte // avkodad PCM (16-bit stereo LE) per klipp
}
const sampleRate = 44100
// Ljudklipp (filnamn utan .wav i resources/sound).
const (
SoundApproach = "melody-approach"
SoundDoors = "door-chime"
)
// AudibleTiles är hur många rutor bort man hör perrongens ljud.
const AudibleTiles = 12.0
// newAudio laddar klippen. Returnerar nil om ljudet är avstängt.
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} {
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)
if err != nil {
return nil, err
}
a.clips[name] = pcm
}
return a, nil
}
// Play spelar ett klipp med volym 0..1.
func (a *Audio) Play(name string, volume float64) {
if a == nil || volume <= 0 {
return
}
pcm, ok := a.clips[name]
if !ok {
return
}
p := a.ctx.NewPlayerFromBytes(pcm)
p.SetVolume(math.Min(1, volume))
p.Play()
}
// volumeAt ger volym efter avstånd i rutor.
func volumeAt(dist float64) float64 { return math.Max(0, 1-dist/AudibleTiles) }

View File

@@ -33,6 +33,7 @@ type Game struct {
Know *lang.Knowledge // spelarens japanska – följer med mellan banor (sparas i M8) Know *lang.Knowledge // spelarens japanska – följer med mellan banor (sparas i M8)
Wallet *sim.Wallet // pengar, kort, telefon, biljett – följer med mellan banor Wallet *sim.Wallet // pengar, kort, telefon, biljett – följer med mellan banor
Profile *sim.Profile // aktiva debuffar – följer med mellan banor Profile *sim.Profile // aktiva debuffar – följer med mellan banor
Audio *Audio // nil = tyst
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
@@ -51,6 +52,7 @@ type Options struct {
Seed uint64 // >0: fast seed för banorna (för tester/skärmdumpar) Seed uint64 // >0: fast seed för banorna (för tester/skärmdumpar)
Phone bool // kortet i telefonen från start Phone bool // kortet i telefonen från start
Debuffs []string // debuffar från start (för tester/skärmdumpar) Debuffs []string // debuffar från start (för tester/skärmdumpar)
Mute bool // inget ljud
CardYen int // >=0: kortets startsaldo (för tester/skärmdumpar), -1 = standard CardYen int // >=0: kortets startsaldo (för tester/skärmdumpar), -1 = standard
} }
@@ -66,6 +68,9 @@ func New(opt Options) (*Game, error) {
} }
g.Know.Points = opt.Points g.Know.Points = opt.Points
g.fixedSeed = opt.Seed g.fixedSeed = opt.Seed
if g.Audio, err = newAudio(opt.Mute); err != nil {
return nil, err
}
g.Wallet = sim.NewWallet() g.Wallet = sim.NewWallet()
g.Profile = &sim.Profile{} g.Profile = &sim.Profile{}
for _, d := range opt.Debuffs { for _, d := range opt.Debuffs {

View File

@@ -5,6 +5,7 @@ import (
"image" "image"
"image/color" "image/color"
"math" "math"
"math/rand/v2"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -34,6 +35,13 @@ type LevelScene struct {
cam *sim.Camera cam *sim.Camera
animT float64 animT float64
hideMap bool // M döljer/visar minikartan hideMap bool // M döljer/visar minikartan
// Tåg och effekter (M7b).
tracks []trackInfo
rails map[level.Point]bool
lastPhase map[level.Tile]level.Phase
particles []particle
rng *rand.Rand // bara för partiklar (ingen spellogik)
} }
// NewLevelScene laddar banan och ställer spelaren på startrutan. Kunskap, // NewLevelScene laddar banan och ställer spelaren på startrutan. Kunskap,
@@ -48,10 +56,27 @@ func NewLevelScene(a *Assets, id string, g *Game, seed rng.Seed) (*LevelScene, e
run: sim.NewRun(lvl, sim.RunOptions{Dict: a.Dict, Know: g.Know, Wallet: g.Wallet, Debuffs: g.Profile.Debuffs, Seed: seed}), run: sim.NewRun(lvl, sim.RunOptions{Dict: a.Dict, Know: g.Know, Wallet: g.Wallet, Debuffs: g.Profile.Debuffs, Seed: seed}),
cam: sim.NewCamera(ScreenW, ScreenH, float64(lvl.Width()*TileSize), float64(lvl.Height()*TileSize)), cam: sim.NewCamera(ScreenW, ScreenH, float64(lvl.Width()*TileSize), float64(lvl.Height()*TileSize)),
} }
s.cam.Margin = TileSize // rälsen ligger i kantraden: visa en ruta utanför kartan
s.lastPhase = map[level.Tile]level.Phase{}
s.rng = rand.New(rand.NewPCG(1, 2))
s.buildTracks()
for _, tr := range s.tracks { // ingen melodi för fasen banan råkar börja i
s.lastPhase[tr.tile], _ = s.run.PlatformPhase(tr.tile)
}
s.followPlayer() s.followPlayer()
return s, nil return s, nil
} }
// TrackPhaseName är fasen för närmaste perrong inom indikatoravståndet
// (skriptläget), "" om ingen är nära.
func (s *LevelScene) TrackPhaseName() string {
_, _, phase, _, _, ok := s.run.NearTrack(indicatorDist)
if !ok {
return ""
}
return phase.String()
}
// PlayerTile returnerar rutan spelaren står på/lämnar (används av skriptläget). // PlayerTile returnerar rutan spelaren står på/lämnar (används av skriptläget).
func (s *LevelScene) PlayerTile() level.Point { return s.run.Player.Tile } func (s *LevelScene) PlayerTile() level.Point { return s.run.Player.Tile }
@@ -77,6 +102,7 @@ func (s *LevelScene) followPlayer() {
} }
func (s *LevelScene) Update(g *Game) error { func (s *LevelScene) Update(g *Game) error {
s.updateEffects(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()
@@ -146,18 +172,6 @@ func (s *LevelScene) MiniPick(text string) bool {
return true return true
} }
// trainTile är rutan "bortom perrongkanten" där tågvagnen ritas: första
// väggruta nedanför, till höger, till vänster eller ovanför perrongen.
func (s *LevelScene) trainTile(p level.Point) level.Point {
for _, d := range [...]level.Point{{X: 0, Y: 1}, {X: 1, Y: 0}, {X: -1, Y: 0}, {X: 0, Y: -1}} {
n := level.Point{X: p.X + d.X, Y: p.Y + d.Y}
if s.run.Level.At(n.X, n.Y) == level.Wall {
return n
}
}
return p
}
func (s *LevelScene) Draw(g *Game, screen *ebiten.Image) { func (s *LevelScene) Draw(g *Game, screen *ebiten.Image) {
screen.Fill(color.RGBA{24, 20, 37, 255}) screen.Fill(color.RGBA{24, 20, 37, 255})
lvl := s.run.Level lvl := s.run.Level
@@ -172,23 +186,17 @@ func (s *LevelScene) Draw(g *Game, screen *ebiten.Image) {
var op ebiten.DrawImageOptions var op ebiten.DrawImageOptions
for y := y0; y < y1; y++ { for y := y0; y < y1; y++ {
for x := x0; x < x1; x++ { for x := x0; x < x1; x++ {
if s.rails[level.Point{X: x, Y: y}] {
s.drawRails(g, screen, level.Point{X: x, Y: y}, ox, oy)
continue
}
op.GeoM.Reset() op.GeoM.Reset()
op.GeoM.Translate(ox+float64(x*TileSize), oy+float64(y*TileSize)) op.GeoM.Translate(ox+float64(x*TileSize), oy+float64(y*TileSize))
screen.DrawImage(g.Assets.Tiles[lvl.At(x, y)], &op) screen.DrawImage(g.Assets.Tiles[lvl.At(x, y)], &op)
} }
} }
// Tåg som står inne. s.drawTrains(g, screen, ox, oy)
for _, t := range []level.Tile{level.Platform, level.WrongPlatform} { s.drawParticles(screen, ox, oy)
if !s.run.TrainVisible(t) {
continue
}
for _, p := range lvl.Find(t) {
tt := s.trainTile(p)
op.GeoM.Reset()
op.GeoM.Translate(ox+float64(tt.X*TileSize), oy+float64(tt.Y*TileSize))
screen.DrawImage(g.Assets.Train, &op)
}
}
// Följarna ritas bakifrån så att ledaren hamnar överst. // Följarna ritas bakifrån så att ledaren hamnar överst.
leader := s.run.Player leader := s.run.Player
@@ -221,6 +229,7 @@ func (s *LevelScene) Draw(g *Game, screen *ebiten.Image) {
op.GeoM.Translate(ox+math.Round(px), oy+math.Round(py)) op.GeoM.Translate(ox+math.Round(px), oy+math.Round(py))
screen.DrawImage(g.Assets.PlayerFrame(leader.Facing, leader.Moving(), s.animT), &op) screen.DrawImage(g.Assets.PlayerFrame(leader.Facing, leader.Moving(), s.animT), &op)
s.drawUrge(g, screen, s.run.Party.Members[0], int(ox+math.Round(px)), int(oy+math.Round(py))) s.drawUrge(g, screen, s.run.Party.Members[0], int(ox+math.Round(px)), int(oy+math.Round(py)))
s.drawIndicator(screen, ox, oy)
s.drawHUD(g, screen) s.drawHUD(g, screen)
if s.run.Mini != nil { if s.run.Mini != nil {
@@ -387,15 +396,32 @@ func (s *LevelScene) infoLines(a *Assets) (lines []line, accent color.Color, bar
lines = append(lines, a.wrapLine(plainLine(fmt.Sprintf("%s: Track %s %s for %s", a.T("hud.english"), tr.Track, lines = append(lines, a.wrapLine(plainLine(fmt.Sprintf("%s: Track %s %s for %s", a.T("hud.english"), tr.Track,
tr.Line.Get("en"), tr.Destination.Get("en")), colEnglish), maxW)...) tr.Line.Get("en"), tr.Destination.Get("en")), colEnglish), maxW)...)
} }
if at { phase, _ := r.PlatformPhase(lvl.At(tile.X, tile.Y))
switch {
case phase == level.Closing:
accent = colWarn
lines = append(lines, plainLine(fmt.Sprintf("%s %.0f s", a.T("hud.closing"), math.Ceil(secs)), colWarn))
case at:
accent = colGood accent = colGood
lines = append(lines, plainLine(fmt.Sprintf("%s %.0f s %s", a.T("hud.departs_in"), math.Ceil(secs), a.T("hud.board")), colGood)) lines = append(lines, plainLine(fmt.Sprintf("%s %.0f s %s", a.T("hud.departs_in"), math.Ceil(secs), a.T("hud.board")), colGood))
} else { case phase == level.Arriving:
accent = colWarn
lines = append(lines, plainLine(a.T("hud.arriving"), colWarn))
case phase == level.Approaching:
accent = colWarn
lines = append(lines, plainLine(fmt.Sprintf(a.T("hud.approaching"), math.Ceil(secs)), colWarn))
case phase == level.Leaving:
lines = append(lines, plainLine(fmt.Sprintf("%s %s %.0f s", a.T("hud.leaving"), a.T("hud.next_train_in"), math.Ceil(secs)), colDim))
default:
accent = colWarn accent = colWarn
lines = append(lines, plainLine(fmt.Sprintf("%s %.0f s", a.T("hud.next_train_in"), math.Ceil(secs)), colWarn)) lines = append(lines, plainLine(fmt.Sprintf("%s %.0f s", a.T("hud.next_train_in"), math.Ceil(secs)), colWarn))
} }
return lines, accent, false return lines, accent, false
} }
// Nära rälsen (men inte på perrongen): man ser och hör tåget komma.
if _, _, phase, _, dist, ok := r.NearTrack(indicatorDist); ok && dist > 0 && phase == level.Approaching && r.SignHere() == nil {
return []line{plainLine(a.T("hud.train_near"), colWarn)}, colWarn, false
}
if sg := r.SignHere(); sg != nil { if sg := r.SignHere(); sg != nil {
know := r.Know know := r.Know

View File

@@ -23,6 +23,7 @@ import (
// waittrain vänta tills tåget vid perrongen spelaren står på är inne // waittrain vänta tills tåget vid perrongen spelaren står på är inne
// waitstop vänta tills tågresan står vid resmålet // waitstop vänta tills tågresan står vid resmålet
// pick TEXT flytta markören i minispelet tills alternativet TEXT är valt // pick TEXT flytta markören i minispelet tills alternativet TEXT är valt
// waitphase NAME vänta tills närmaste perrongs tåg är i fasen NAME (approaching, dwelling …)
// press KEY tryck enter | esc | r | h | l | up | down en bildruta // press KEY tryck enter | esc | r | h | l | up | down en bildruta
// move DIR N gå N rutor åt up | down | left | right (stannar exakt där) // move DIR N gå N rutor åt up | down | left | right (stannar exakt där)
// shot PATH spara aktuell bild som PNG (intern upplösning × shotScale) // shot PATH spara aktuell bild som PNG (intern upplösning × shotScale)
@@ -87,7 +88,7 @@ func parseScript(src string) (*script, error) {
return nil, fmt.Errorf("script: okänd riktning %q", f[1]) return nil, fmt.Errorf("script: okänd riktning %q", f[1])
} }
st.n, _ = strconv.Atoi(f[2]) st.n, _ = strconv.Atoi(f[2])
case "shot", "pick": case "shot", "pick", "waitphase":
if len(f) != 2 { if len(f) != 2 {
return nil, fmt.Errorf("script: '%s TEXT'", f[0]) return nil, fmt.Errorf("script: '%s TEXT'", f[0])
} }
@@ -126,6 +127,7 @@ type trainWaiter interface {
TrainHere() bool TrainHere() bool
RideAtDest() bool RideAtDest() bool
MiniPick(text string) bool MiniPick(text string) bool
TrackPhaseName() string
} }
// tick körs i början av varje Update. frame är senast ritade bild. // tick körs i början av varje Update. frame är senast ritade bild.
@@ -193,6 +195,11 @@ func (s *script) tick(g *Game, frame *ebiten.Image) error {
if tw, ok := g.scene.(trainWaiter); !ok || tw.RideAtDest() { if tw, ok := g.scene.(trainWaiter); !ok || tw.RideAtDest() {
s.advance() s.advance()
} }
case "waitphase":
s.counter++
if tw, ok := g.scene.(trainWaiter); !ok || tw.TrackPhaseName() == st.arg || s.counter > 60*60 {
s.advance()
}
case "pick": case "pick":
s.counter++ s.counter++
if tw, ok := g.scene.(trainWaiter); !ok || tw.MiniPick(st.arg) || s.counter > 60 { if tw, ok := g.scene.(trainWaiter); !ok || tw.MiniPick(st.arg) || s.counter > 60 {

196
internal/game/trains.go Normal file
View File

@@ -0,0 +1,196 @@
package game
import (
"image/color"
"math"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
)
// Tågens ankomst och avgång (M7b): räls längs perrongkanten, vagnar som
// rullar in och ut, vind, ankomstindikator och melodier.
const (
trainCars = 3 // vagnar per tåg
trainRunTiles = 6.0 // hur långt bort tåget startar/försvinner
railsReach = 8 // räls ritas så här många rutor åt varje håll
maxParticles = 40
indicatorDist = 2 // rutor från perrongen där man ser/hör tåget komma
)
type particle struct {
x, y, vx, vy, life float64
}
// trackInfo är det som behövs för att rita ett tågs räls och rörelse.
type trackInfo struct {
tile level.Tile // P eller p
platform level.Point // perrongrutan
track level.Point // rälsrutan bortom kanten
horizontal bool // rälsens riktning
}
func (s *LevelScene) buildTracks() {
s.tracks = nil
s.rails = map[level.Point]bool{}
for _, t := range []level.Tile{level.Platform, level.WrongPlatform} {
for _, p := range s.run.Level.Find(t) {
tt, horizontal := s.run.Level.TrackTile(p)
s.tracks = append(s.tracks, trackInfo{tile: t, platform: p, track: tt, horizontal: horizontal})
for k := -railsReach; k <= railsReach; k++ {
c := tt
if horizontal {
c.X += k
} else {
c.Y += k
}
if s.run.Level.At(c.X, c.Y) == level.Wall && (c.X == 0 || c.Y == 0 || c.X == s.run.Level.Width()-1 || c.Y == s.run.Level.Height()-1) {
s.rails[c] = true
}
}
}
}
}
// trainOffset är tågets förskjutning i rutor längs rälsen i en fas:
// det kommer in från minus-sidan och lämnar åt plus-sidan.
func trainOffset(phase level.Phase, prog float64) (float64, bool) {
switch phase {
case level.Arriving:
return -trainRunTiles * (1 - prog) * (1 - prog), true // bromsar in
case level.Dwelling, level.Closing:
return 0, true
case level.Leaving:
return trainRunTiles * prog * prog, true // accelererar ut
}
return 0, false
}
// drawRails ritar rälsrutan (roterad för vertikala spår).
func (s *LevelScene) drawRails(g *Game, screen *ebiten.Image, c level.Point, ox, oy float64) {
var op ebiten.DrawImageOptions
vertical := false
for _, tr := range s.tracks {
if !tr.horizontal && (c.X == tr.track.X) {
vertical = true
}
}
if vertical {
op.GeoM.Translate(-TileSize/2, -TileSize/2)
op.GeoM.Rotate(math.Pi / 2)
op.GeoM.Translate(TileSize/2, TileSize/2)
}
op.GeoM.Translate(ox+float64(c.X*TileSize), oy+float64(c.Y*TileSize))
screen.DrawImage(g.Assets.Rails, &op)
}
// drawTrains ritar tågen i rätt läge för sin fas.
func (s *LevelScene) drawTrains(g *Game, screen *ebiten.Image, ox, oy float64) {
for _, tr := range s.tracks {
phase, prog := s.run.PlatformPhase(tr.tile)
// Strålkastare när tåget är på väg in: ett sken från det håll det kommer.
if phase == level.Approaching {
s.drawHeadlight(screen, tr, prog, ox, oy)
}
off, show := trainOffset(phase, prog)
if !show {
continue
}
for k := 0; k < trainCars; k++ {
d := off + float64(k-trainCars/2)
var op ebiten.DrawImageOptions
x, y := float64(tr.track.X*TileSize), float64(tr.track.Y*TileSize)
if tr.horizontal {
x += d * TileSize
} else {
y += d * TileSize
op.GeoM.Translate(-TileSize/2, -TileSize/2)
op.GeoM.Rotate(math.Pi / 2)
op.GeoM.Translate(TileSize/2, TileSize/2)
}
op.GeoM.Translate(ox+math.Round(x), oy+math.Round(y))
screen.DrawImage(g.Assets.Train, &op)
}
}
}
func (s *LevelScene) drawHeadlight(screen *ebiten.Image, tr trackInfo, prog float64, ox, oy float64) {
a := 0.25 + 0.55*prog // Ebitengine vill ha premultiplicerad alfa
c := color.RGBA{uint8(255 * a), uint8(240 * a), uint8(180 * a), uint8(255 * a)}
x, y := float64(tr.track.X*TileSize), float64(tr.track.Y*TileSize)
length := float32(TileSize * (1 + 4*prog))
if tr.horizontal {
vector.DrawFilledRect(screen, float32(ox+x)-length, float32(oy+y)+4, length, 8, c, false)
} else {
vector.DrawFilledRect(screen, float32(ox+x)+4, float32(oy+y)-length, 8, length, c, false)
}
}
// drawIndicator: blinkande lampor vid perrongkanten när tåget är på väg in
// och spelaren är nära rälsen.
func (s *LevelScene) drawIndicator(screen *ebiten.Image, ox, oy float64) {
_, platform, phase, _, _, ok := s.run.NearTrack(indicatorDist)
if !ok || phase != level.Approaching || math.Mod(s.run.Time, 0.5) > 0.25 {
return
}
x, y := float32(ox+float64(platform.X*TileSize)), float32(oy+float64(platform.Y*TileSize))
vector.DrawFilledRect(screen, x+2, y+TileSize-3, 3, 2, colWarn, false)
vector.DrawFilledRect(screen, x+TileSize-5, y+TileSize-3, 3, 2, colWarn, false)
}
// updateEffects sköter fasbyten (ljud), vind och partiklar.
func (s *LevelScene) updateEffects(g *Game) {
r := s.run
px, py := r.Player.WorldPos(TileSize)
for _, tr := range s.tracks {
phase, prog := r.PlatformPhase(tr.tile)
if last, seen := s.lastPhase[tr.tile]; !seen || last != phase {
s.lastPhase[tr.tile] = phase
dist := math.Hypot(float64(tr.platform.X*TileSize)-px, float64(tr.platform.Y*TileSize)-py) / TileSize
switch phase {
case level.Approaching:
g.Audio.Play(SoundApproach, volumeAt(dist))
case level.Closing:
g.Audio.Play(SoundDoors, volumeAt(dist))
}
}
// Vind längs perrongen när tåget rullar in eller ut (och strax före).
windy := phase == level.Arriving || phase == level.Leaving || (phase == level.Approaching && prog > 0.7)
if windy && len(s.particles) < maxParticles {
dir := 1.0
if phase == level.Arriving {
dir = 1.0 // luften trycks framför tåget: samma håll som tåget rör sig (in från minus-sidan)
}
spread := float64(s.rng.IntN(5*TileSize)) - 2*TileSize
x := float64(tr.platform.X*TileSize) + spread
y := float64(tr.platform.Y*TileSize) + float64(s.rng.IntN(TileSize))
vx, vy := dir*(60+float64(s.rng.IntN(60))), float64(s.rng.IntN(10))-5
if !tr.horizontal {
x, y = float64(tr.platform.X*TileSize)+float64(s.rng.IntN(TileSize)), float64(tr.platform.Y*TileSize)+spread
vx, vy = vy, vx
}
s.particles = append(s.particles, particle{x, y, vx, vy, 0.4 + 0.3*s.rng.Float64()})
}
}
kept := s.particles[:0]
for _, p := range s.particles {
p.life -= dt
if p.life <= 0 {
continue
}
p.x += p.vx * dt
p.y += p.vy * dt
kept = append(kept, p)
}
s.particles = kept
}
func (s *LevelScene) drawParticles(screen *ebiten.Image, ox, oy float64) {
for _, p := range s.particles {
a := uint8(200 * math.Min(1, p.life*3))
vector.DrawFilledRect(screen, float32(ox+p.x), float32(oy+p.y), 2, 1, color.RGBA{a, a, a, a}, false)
}
}

View File

@@ -88,6 +88,72 @@ func (s Schedule) Status(t float64) (atPlatform bool, seconds float64) {
return false, s.IntervalSec - phase return false, s.IntervalSec - phase
} }
// Phase är var i cykeln ett tåg befinner sig.
type Phase int
// Tågfaser i ordning över en cykel.
const (
Away Phase = iota // borta
Approaching // på väg in: melodi, lampor, vind (ApproachSec före ankomst)
Arriving // rullar in och bromsar (ArriveSec), dörrarna stängda
Dwelling // står inne med öppna dörrar
Closing // strax avgång: dörrsignal (sista WarnSec av uppehållet)
Leaving // rullar ut (LeaveSec efter avgång)
)
func (p Phase) String() string {
return [...]string{"away", "approaching", "arriving", "dwelling", "closing", "leaving"}[p]
}
// Fasernas längder i sekunder.
const (
ApproachSec = 5.0
ArriveSec = 1.5
WarnSec = 3.0
LeaveSec = 1.5
MinDwellSec = ArriveSec + WarnSec + 1 // minst en sekund öppna dörrar utan signal
)
// Phase returnerar fasen vid tiden t och hur långt in i fasen man är (0..1).
func (s Schedule) Phase(t float64) (Phase, float64) {
var phi float64 // tid sedan senaste ankomst, kan vara negativ före första
if t < s.OffsetSec {
phi = t - s.OffsetSec
if -phi <= ApproachSec {
return Approaching, 1 + phi/ApproachSec
}
return Away, 0
} else {
phi = math.Mod(t-s.OffsetSec, s.IntervalSec)
}
switch {
case phi < ArriveSec:
return Arriving, phi / ArriveSec
case phi < s.DwellSec-WarnSec:
return Dwelling, (phi - ArriveSec) / max(0.001, s.DwellSec-WarnSec-ArriveSec)
case phi < s.DwellSec:
return Closing, (phi - (s.DwellSec - WarnSec)) / WarnSec
case phi < s.DwellSec+LeaveSec:
return Leaving, (phi - s.DwellSec) / LeaveSec
case phi >= s.IntervalSec-ApproachSec:
return Approaching, (phi - (s.IntervalSec - ApproachSec)) / ApproachSec
default:
return Away, (phi - s.DwellSec - LeaveSec) / max(0.001, s.IntervalSec-ApproachSec-s.DwellSec-LeaveSec)
}
}
// Boardable rapporterar om dörrarna är öppna så att man kan kliva på.
func (s Schedule) Boardable(t float64) bool {
p, _ := s.Phase(t)
return p == Dwelling || p == Closing
}
// Present rapporterar om tåget syns vid perrongen (rullar in, står, rullar ut).
func (s Schedule) Present(t float64) bool {
p, _ := s.Phase(t)
return p == Arriving || p == Dwelling || p == Closing || p == Leaving
}
// Train är ett tåg (eller en buss) som avgår från en perrong i banan. // Train är ett tåg (eller en buss) som avgår från en perrong i banan.
type Train struct { type Train struct {
Platform string `json:"platform"` // vilket perrong-tecken (P/p) tåget står vid Platform string `json:"platform"` // vilket perrong-tecken (P/p) tåget står vid
@@ -234,6 +300,9 @@ func (l *Level) Validate() error {
if sc.OffsetSec < 0 || sc.IntervalSec <= 0 || sc.DwellSec <= 0 || sc.DwellSec >= sc.IntervalSec { if sc.OffsetSec < 0 || sc.IntervalSec <= 0 || sc.DwellSec <= 0 || sc.DwellSec >= sc.IntervalSec {
return fmt.Errorf("level %s: tåg %d har orimlig tidtabell %+v (kräver offset ≥ 0, 0 < dwell < interval)", l.ID, i, *sc) return fmt.Errorf("level %s: tåg %d har orimlig tidtabell %+v (kräver offset ≥ 0, 0 < dwell < interval)", l.ID, i, *sc)
} }
if sc.DwellSec < MinDwellSec || sc.IntervalSec-sc.DwellSec < LeaveSec+ApproachSec+1 {
return fmt.Errorf("level %s: tåg %d: uppehållet måste vara ≥ %.1f s och tiden borta ≥ %.1f s för tågfaserna", l.ID, i, MinDwellSec, LeaveSec+ApproachSec+1)
}
} }
if correct == 0 { if correct == 0 {
return fmt.Errorf("level %s: inget tåg är markerat som rätt", l.ID) return fmt.Errorf("level %s: inget tåg är markerat som rätt", l.ID)
@@ -417,6 +486,19 @@ func (tr *Train) BoardText() string {
return tr.Track + "番線 " + tr.Line.Get("ja") + " " + tr.Destination.Get("ja") + "方面" return tr.Track + "番線 " + tr.Line.Get("ja") + " " + tr.Destination.Get("ja") + "方面"
} }
// TrackTile är rutan "bortom perrongkanten" där rälsen går: första väggruta
// nedanför, till höger, till vänster eller ovanför perrongen. horizontal
// anger om rälsen (och tågets rörelse) går i sidled.
func (l *Level) TrackTile(p Point) (tile Point, horizontal bool) {
for _, d := range [...]Point{{X: 0, Y: 1}, {X: 1, Y: 0}, {X: -1, Y: 0}, {X: 0, Y: -1}} {
n := Point{X: p.X + d.X, Y: p.Y + d.Y}
if l.At(n.X, n.Y) == Wall {
return n, d.Y != 0
}
}
return p, true
}
// CorrectPlatform returnerar en perrongruta med rätt tåg (valideringen // CorrectPlatform returnerar en perrongruta med rätt tåg (valideringen
// garanterar att det finns minst ett rätt tåg vid en befintlig perrong). // garanterar att det finns minst ett rätt tåg vid en befintlig perrong).
func (l *Level) CorrectPlatform() Point { func (l *Level) CorrectPlatform() Point {

View File

@@ -321,3 +321,66 @@ func TestDifficultySettings(t *testing.T) {
t.Fatal("inställningarna ska läsas") t.Fatal("inställningarna ska läsas")
} }
} }
func TestSchedulePhases(t *testing.T) {
s := Schedule{OffsetSec: 10, IntervalSec: 30, DwellSec: 8}
cases := []struct {
t float64
phase Phase
}{
{0, Away}, {4.9, Away}, {5.1, Approaching}, {9.9, Approaching},
{10, Arriving}, {11.4, Arriving}, {11.6, Dwelling}, {14.9, Dwelling},
{15.1, Closing}, {17.9, Closing}, {18.1, Leaving}, {19.4, Leaving},
{19.6, Away}, {34.9, Away}, {35.1, Approaching}, {40.5, Arriving}, {42, Dwelling},
}
for _, c := range cases {
p, prog := s.Phase(c.t)
if p != c.phase {
t.Errorf("t=%.1f: fas %s, vill ha %s", c.t, p, c.phase)
}
if prog < 0 || prog > 1.0001 {
t.Errorf("t=%.1f: progress %.2f utanför 0..1", c.t, prog)
}
}
if !s.Boardable(13) || s.Boardable(10.5) || s.Boardable(18.5) || !s.Boardable(16) {
t.Fatal("ombordstigning bara med öppna dörrar (dwelling/closing)")
}
if !s.Present(10.5) || !s.Present(19) || s.Present(19.6) || s.Present(9) {
t.Fatal("tåget syns från inrullning till utrullning")
}
// Progress växer monotont inom en fas.
p1, a := s.Phase(5.5)
p2, b := s.Phase(9.5)
if p1 != Approaching || p2 != Approaching || b <= a {
t.Fatalf("approaching-progress %.2f -> %.2f", a, b)
}
// Status och Phase är konsekventa: "vid perrongen" = arriving/dwelling/closing.
for tt := 0.0; tt < 60; tt += 0.25 {
at, _ := s.Status(tt)
p, _ := s.Phase(tt)
if at != (p == Arriving || p == Dwelling || p == Closing) {
t.Fatalf("t=%.2f: Status=%v men fas=%s", tt, at, p)
}
}
}
func TestScheduleValidationForPhases(t *testing.T) {
if _, err := Parse([]byte(strings.Replace(sample, `"correct": true`, `"correct": true, "schedule": {"intervalSec": 30, "dwellSec": 4}`, 1))); err == nil {
t.Fatal("för kort uppehåll för faserna ska ge fel")
}
if _, err := Parse([]byte(strings.Replace(sample, `"correct": true`, `"correct": true, "schedule": {"intervalSec": 12, "dwellSec": 6}`, 1))); err == nil {
t.Fatal("för kort tid borta ska ge fel")
}
}
func TestTrackTile(t *testing.T) {
l := mustParse(t, sample)
tile, horizontal := l.TrackTile(Point{5, 1}) // P uppe till höger: vägg ovanför? nej, (5,0) är vägg -> men ordningen är ner, höger, vänster, upp
if tile != (Point{5, 2}) && tile != (Point{6, 1}) && tile != (Point{5, 0}) {
t.Fatalf("TrackTile = %v", tile)
}
// (5,2) är golv, (6,1) är vägg -> höger: vertikal räls
if tile == (Point{6, 1}) && horizontal {
t.Fatal("räls till höger om perrongen är vertikal")
}
}

View File

@@ -5,6 +5,7 @@ package sim
type Camera struct { type Camera struct {
ViewW, ViewH float64 ViewW, ViewH float64
WorldW, WorldH float64 WorldW, WorldH float64
Margin float64 // så många pixlar utanför världen får vyn visa (räls vid kanten)
X, Y float64 // vyns övre vänstra hörn i världskoordinater X, Y float64 // vyns övre vänstra hörn i världskoordinater
} }
@@ -15,10 +16,11 @@ func NewCamera(viewW, viewH, worldW, worldH float64) *Camera {
return c return c
} }
// Follow centrerar vyn på (cx, cy) och klampar mot världens kanter. // Follow centrerar vyn på (cx, cy) och klampar mot världens kanter (plus
// Margin).
func (c *Camera) Follow(cx, cy float64) { func (c *Camera) Follow(cx, cy float64) {
c.X = clampAxis(cx-c.ViewW/2, c.ViewW, c.WorldW) c.X = clampAxis(cx-c.ViewW/2+c.Margin, c.ViewW, c.WorldW+2*c.Margin) - c.Margin
c.Y = clampAxis(cy-c.ViewH/2, c.ViewH, c.WorldH) c.Y = clampAxis(cy-c.ViewH/2+c.Margin, c.ViewH, c.WorldH+2*c.Margin) - c.Margin
} }
// Offset returnerar översättningen världs- -> skärmkoordinater. // Offset returnerar översättningen världs- -> skärmkoordinater.

View File

@@ -39,3 +39,22 @@ func TestCameraCentersSmallWorld(t *testing.T) {
t.Fatalf("X,Y=%v,%v", c.X, c.Y) t.Fatalf("X,Y=%v,%v", c.X, c.Y)
} }
} }
func TestCameraMarginShowsBeyondEdges(t *testing.T) {
c := NewCamera(320, 180, 320, 192)
c.Margin = 16
c.Follow(160, 1000)
if c.Y != 192-180+16 || c.X != 0 {
t.Fatalf("X,Y=%v,%v – vyn ska nå en ruta nedanför kartan", c.X, c.Y)
}
c.Follow(160, -1000)
if c.Y != -16 {
t.Fatalf("Y=%v – och en ruta ovanför", c.Y)
}
// Utan marginal som förut.
d := NewCamera(320, 180, 320, 192)
d.Follow(160, 1000)
if d.Y != 12 {
t.Fatalf("Y=%v", d.Y)
}
}

View File

@@ -634,8 +634,45 @@ func (r *Run) PlatformHere() (tr *level.Train, atPlatform bool, seconds float64)
if tr == nil { if tr == nil {
return nil, false, 0 return nil, false, 0
} }
atPlatform, seconds = tr.Schedule.Status(r.Time) _, seconds = tr.Schedule.Status(r.Time)
return tr, atPlatform, seconds return tr, tr.Schedule.Boardable(r.Time), seconds
}
// PlatformPhase är tågfasen för en perrongruta (Away om inget tåg).
func (r *Run) PlatformPhase(t level.Tile) (level.Phase, float64) {
tr := r.Level.TrainAt(t)
if tr == nil {
return level.Away, 0
}
return tr.Schedule.Phase(r.Time)
}
// NearTrack hittar närmaste perrong inom maxDist rutor (schackavstånd) från
// spelaren – det är där man ser och hör tåget komma.
func (r *Run) NearTrack(maxDist int) (tr *level.Train, platform level.Point, phase level.Phase, progress float64, dist int, ok bool) {
best := maxDist + 1
for _, t := range []level.Tile{level.Platform, level.WrongPlatform} {
for _, p := range r.Level.Find(t) {
d := max(abs(p.X-r.Player.Tile.X), abs(p.Y-r.Player.Tile.Y))
if d < best {
if cand := r.Level.TrainAt(t); cand != nil {
best, tr, platform = d, cand, p
}
}
}
}
if tr == nil {
return nil, level.Point{}, level.Away, 0, 0, false
}
phase, progress = tr.Schedule.Phase(r.Time)
return tr, platform, phase, progress, best, true
}
func abs(a int) int {
if a < 0 {
return -a
}
return a
} }
// SignHere returnerar skylten spelaren står stilla på, annars nil. // SignHere returnerar skylten spelaren står stilla på, annars nil.
@@ -709,14 +746,11 @@ func (r *Run) stepReading() {
} }
} }
// TrainVisible rapporterar om tåget för en perrongruta står inne just nu. // TrainVisible rapporterar om tåget för en perrongruta syns just nu
// (rullar in, står inne eller rullar ut).
func (r *Run) TrainVisible(t level.Tile) bool { func (r *Run) TrainVisible(t level.Tile) bool {
tr := r.Level.TrainAt(t) tr := r.Level.TrainAt(t)
if tr == nil { return tr != nil && tr.Schedule.Present(r.Time)
return false
}
at, _ := tr.Schedule.Status(r.Time)
return at
} }
// AskHelp börjar fråga en förbipasserande om vad skylten betyder. Kräver att // AskHelp börjar fråga en förbipasserande om vad skylten betyder. Kräver att

View File

@@ -901,3 +901,56 @@ func TestLevel003Flow(t *testing.T) {
t.Fatalf("outcome=%v reason=%q tickets=%d", r.Outcome, r.LostReason, r.Wallet.TicketCount) t.Fatalf("outcome=%v reason=%q tickets=%d", r.Outcome, r.LostReason, r.Wallet.TicketCount)
} }
} }
func TestNearTrackAndPhases(t *testing.T) {
l := loadLevel(t, "001-flygplatsen")
r := NewRun(l, RunOptions{Seed: rng.FromInt(1)})
r.Party.nextEventAt = 1e9
if _, _, _, _, _, ok := r.NearTrack(2); ok {
t.Fatal("från starten är ingen perrong nära")
}
walk(t, r, l.Path(r.Player.Tile, l.CorrectPlatform(), r.CanEnter))
tr, p, phase, prog, dist, ok := r.NearTrack(2)
if !ok || dist != 0 || p != l.CorrectPlatform() || !tr.Correct || prog < 0 || prog > 1 {
t.Fatalf("ok=%v dist=%d p=%v", ok, dist, p)
}
if want, _ := tr.Schedule.Phase(r.Time); want != phase {
t.Fatal("fasen ska komma från tidtabellen")
}
// Ombordstigning bara med öppna dörrar: under inrullningen vägras man.
for {
ph, _ := r.PlatformPhase(level.Platform)
if ph == level.Arriving {
break
}
r.Step(frame, None, false)
}
if _, at, _ := r.PlatformHere(); at || !r.TrainVisible(level.Platform) {
t.Fatal("under inrullningen syns tåget men dörrarna är stängda")
}
r.Step(frame, None, true)
if r.Mini != nil {
t.Fatal("man kan inte kliva på ett tåg som rullar in")
}
for {
ph, _ := r.PlatformPhase(level.Platform)
if ph == level.Dwelling {
break
}
r.Step(frame, None, false)
}
if _, at, _ := r.PlatformHere(); !at {
t.Fatal("med öppna dörrar kan man kliva på")
}
// Står man två rutor bort ser man tåget komma; tre rutor bort inte.
r2 := NewRun(l, RunOptions{Seed: rng.FromInt(1)})
goal := l.CorrectPlatform()
r2.Player.Tile, r2.Player.Target = level.Point{X: goal.X, Y: goal.Y - 2}, level.Point{X: goal.X, Y: goal.Y - 2}
if _, _, _, _, d, ok := r2.NearTrack(2); !ok || d != 2 {
t.Fatalf("två rutor bort: ok=%v d=%d", ok, d)
}
r2.Player.Tile, r2.Player.Target = level.Point{X: goal.X - 3, Y: goal.Y}, level.Point{X: goal.X - 3, Y: goal.Y}
if _, _, _, _, _, ok := r2.NearTrack(2); ok {
t.Fatal("tre rutor bort är utom räckhåll")
}
}

View File

@@ -1,6 +1,13 @@
# resources/sound – ljud # resources/sound – ljud
Effekter och musik. Format: OGG (musik, längre ljud) och WAV (korta effekter). Effekter och musik. Format: WAV (korta effekter, 16-bit mono 44,1 kHz) och
OGG (musik, längre ljud).
Planerat innehåll: avgångsmelodier och ankomstjinglar i stil med japanska - `melody-approach.wav` – avgångsmelodi i japansk stationsstil, spelas när
stationer, spärr-pip, automat-ljud, folkmassa, UI-klick. 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*).
Båda är **placeholders** genererade av `go run ./tools/gensound`; riktiga
inspelningar/kompositioner ersätter dem i M12 (behåll filnamnen).
Planerat: utrop, folkmassa, spärr-pip, automat-ljud, UI-klick, bakgrundsprat
och reklam som ljudröra (M9b).

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 879 B

After

Width:  |  Height:  |  Size: 950 B

View File

@@ -127,5 +127,10 @@
"result.penalty": "penalty", "result.penalty": "penalty",
"result.lang_points": "language points", "result.lang_points": "language points",
"result.missed_short": "Missed", "result.missed_short": "Missed",
"result.map": "map" "result.map": "map",
"hud.approaching": "Train arriving in %.0f s - melody playing",
"hud.arriving": "The train is pulling in…",
"hud.closing": "Doors closing - board now!",
"hud.leaving": "The train is leaving…",
"hud.train_near": "Train approaching!"
} }

View File

@@ -127,5 +127,10 @@
"result.penalty": "avdrag", "result.penalty": "avdrag",
"result.lang_points": "språkpoäng", "result.lang_points": "språkpoäng",
"result.missed_short": "Missade", "result.missed_short": "Missade",
"result.map": "karta" "result.map": "karta",
"hud.approaching": "Tåget kör in om %.0f s - melodin spelar",
"hud.arriving": "Tåget rullar in…",
"hud.closing": "Dörrarna stängs - kliv på nu!",
"hud.leaving": "Tåget går…",
"hud.train_near": "Tåg på väg in!"
} }

View File

@@ -3,7 +3,7 @@
// go run ./tools/genart // go run ./tools/genart
// //
// Skriver resources/sprites/tiles.png (en remsa 16×16-rutor i ordningen // Skriver resources/sprites/tiles.png (en remsa 16×16-rutor i ordningen
// vägg, golv, start, perrong, spärr, automat, skylt, tågvagn, toalett, kiosk) och // vägg, golv, start, perrong, spärr, automat, skylt, tågvagn, toalett, kiosk, räls) och
// resources/sprites/player.png (3 bildrutor × 4 riktningar: ner, upp, // resources/sprites/player.png (3 bildrutor × 4 riktningar: ner, upp,
// vänster, höger; kolumn 0 = stilla, 1–2 = gå). Ritas med enkla rektanglar // vänster, höger; kolumn 0 = stilla, 1–2 = gå). Ritas med enkla rektanglar
// så att allt är deterministiskt och lätt att justera. // så att allt är deterministiskt och lätt att justera.
@@ -131,6 +131,17 @@ var tiles = []func(canvas){
c.rect(7, 4, 2, 3, white) // figur c.rect(7, 4, 2, 3, white) // figur
c.rect(10, 9, 1, 1, boxDark) c.rect(10, 9, 1, 1, boxDark)
}, },
func(c canvas) { // räls (horisontell; roteras för vertikala spår): slipers och två stålskenor
c.rect(0, 0, 16, 16, wallDark)
c.rect(0, 0, 16, 1, wallMid)
for x := 1; x < 16; x += 4 {
c.rect(x, 3, 2, 10, boxDark) // slipers
}
c.rect(0, 5, 16, 1, metal)
c.rect(0, 6, 16, 1, metalDark)
c.rect(0, 10, 16, 1, metal)
c.rect(0, 11, 16, 1, metalDark)
},
func(c canvas) { // kiosk: randig markis och disk func(c canvas) { // kiosk: randig markis och disk
floor(c) floor(c)
c.rect(1, 1, 14, 3, awningRed) c.rect(1, 1, 14, 3, awningRed)

113
tools/gensound/main.go Normal file
View File

@@ -0,0 +1,113 @@
// Genererar placeholder-ljud 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.
package main
import (
"encoding/binary"
"fmt"
"math"
"os"
"path/filepath"
)
const rate = 44100
type note struct {
freq float64 // Hz, 0 = paus
dur float64 // sekunder
}
// Pentatonisk melodi (C-dur-pentatonik), två takter.
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},
{0, 0.12},
{659.25, 0.18}, {783.99, 0.18}, {880.00, 0.18}, {1046.50, 0.54},
}
// 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},
}
func render(notes []note, attack, release float64, overtone float64) []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 := 1.0
if t < attack {
env = t / attack
}
if rem := n.dur - t; rem < release {
env *= rem / release
}
v := math.Sin(2*math.Pi*n.freq*t) + overtone*math.Sin(2*math.Pi*2*n.freq*t)
v = v / (1 + overtone) * env * 0.5
out = append(out, int16(v*32767))
}
}
return out
}
func writeWAV(path string, pcm []int16) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
dataLen := uint32(len(pcm) * 2)
hdr := []any{
[]byte("RIFF"), uint32(36 + dataLen), []byte("WAVE"),
[]byte("fmt "), uint32(16), uint16(1), uint16(1), uint32(rate), uint32(rate * 2), uint16(2), uint16(16),
[]byte("data"), dataLen,
}
for _, h := range hdr {
if err := binary.Write(f, binary.LittleEndian, h); err != nil {
return err
}
}
return binary.Write(f, binary.LittleEndian, pcm)
}
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)))
fmt.Println("skrev", filepath.Join(out, "melody-approach.wav"), "och door-chime.wav")
}
func repoRoot() string {
dir, _ := os.Getwd()
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
must(fmt.Errorf("hittade ingen go.mod"))
}
dir = parent
}
}
func must(err error) {
if err != nil {
fmt.Fprintln(os.Stderr, "gensound:", err)
os.Exit(1)
}
}

View File

@@ -73,7 +73,7 @@ func buildShots(levelsDir string) error {
{"001-flygplatsen", "move right 4; move down 2; wait 5; shot $OUT/level-001.png; quit", nil}, {"001-flygplatsen", "move right 4; move down 2; wait 5; shot $OUT/level-001.png; quit", nil},
{"002-shinjuku", "move down 6; move right 4; wait 5; shot $OUT/level-002.png; quit", nil}, {"002-shinjuku", "move down 6; move right 4; wait 5; shot $OUT/level-002.png; quit", nil},
{"001-flygplatsen", toSign + "; wait 5; shot $OUT/sign.png; press h; wait 200; shot $OUT/sign-help.png; wait 200; shot $OUT/sign-learned.png; " + toGoal + {"001-flygplatsen", toSign + "; wait 5; shot $OUT/sign.png; press h; wait 200; shot $OUT/sign-help.png; wait 200; shot $OUT/sign-learned.png; " + toGoal +
"; waittrain; wait 30; shot $OUT/platform.png; press enter; wait 40; shot $OUT/ride.png; waitstop; press enter; wait 5; shot $OUT/result.png; quit", known}, "; waitphase approaching; wait 200; shot $OUT/approach.png; waittrain; wait 30; shot $OUT/platform.png; press enter; wait 40; shot $OUT/ride.png; waitstop; press enter; wait 5; shot $OUT/result.png; quit", known},
{"001-flygplatsen", toMachine + "; press enter; wait 5; press right; wait 5; shot $OUT/topup.png; quit", []string{"-seed", "7", "-card", "0"}}, {"001-flygplatsen", toMachine + "; press enter; wait 5; press right; wait 5; shot $OUT/topup.png; quit", []string{"-seed", "7", "-card", "0"}},
{"002-shinjuku", toMachine2 + "; press enter; wait 5; shot $OUT/ticket.png; quit", []string{"-seed", "7", "-know", "渋谷"}}, {"002-shinjuku", toMachine2 + "; press enter; wait 5; shot $OUT/ticket.png; quit", []string{"-seed", "7", "-know", "渋谷"}},
{"003-shibuya", toMachine3 + "; press enter; pick 池袋; press enter; wait 5; press enter; press right; wait 5; shot $OUT/seats.png; quit", {"003-shibuya", toMachine3 + "; press enter; pick 池袋; press enter; wait 5; press enter; press right; wait 5; shot $OUT/seats.png; quit",
@@ -135,7 +135,7 @@ func main() {
exe += ".exe" exe += ".exe"
} }
for _, s := range shots { for _, s := range shots {
args := []string{"-script", strings.ReplaceAll(s.script, "$OUT", out)} args := []string{"-mute", "-script", strings.ReplaceAll(s.script, "$OUT", out)}
if s.level != "" { if s.level != "" {
args = append(args, "-level", s.level) args = append(args, "-level", s.level)
} }