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>
17
README.md
@@ -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+
|
||||
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
|
||||
dimma, tågkort, biljetter och minispel, och nu svårigheten: tidsgräns, max
|
||||
antal missade tåg, en poängformel, debuffar som följer med mellan banor, och
|
||||
inställningarna egna kort per person och sittplatsval med sällskapets
|
||||
önskemål. Nästa är M7b: tågen ska synas och kännas när de kommer och går. Se
|
||||
dimma, tågkort, biljetter och minispel, svårighet med tidsgräns, poäng,
|
||||
debuffar, egna kort och sittplatsval. Och nu lever tågen: de rullar in på
|
||||
rälsen till avgångsmelodin, står med öppna dörrar, dörrsignalen ljuder och
|
||||
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).
|
||||
|
||||

|
||||
@@ -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.
|
||||
Klarar du banan under par får du stjärnor, språkpoäng och resmålets namn.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
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 -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 -mute # inget ljud
|
||||
metro-puzzle-jp -script "move right 4; shot bild.png; quit" # skriptläge, se internal/game/script.go
|
||||
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/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** |
|
||||
|
||||
## Beroenden
|
||||
@@ -159,7 +164,7 @@ Y = hjälp, X = ordlista, Back/Select = karta.
|
||||
| | Linux | Windows |
|
||||
|---|---|---|
|
||||
| **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 |
|
||||
| **Font** | PixelMplus 10/12 px (M+ FONT LICENSE), ligger i `resources/text/fonts/` | samma |
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ func main() {
|
||||
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)")
|
||||
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")
|
||||
flag.Parse()
|
||||
|
||||
@@ -33,7 +34,7 @@ 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)})
|
||||
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 {
|
||||
log.Fatalf("kunde inte starta: %v", err)
|
||||
}
|
||||
|
||||
BIN
doc/img/approach.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
BIN
doc/img/menu.png
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
BIN
doc/img/ride.png
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 24 KiB |
BIN
doc/img/sign.png
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
@@ -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` | `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/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 |
|
||||
|
||||
### 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 |
|
||||
| ~~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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
@@ -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 ✓
|
||||
→ 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,
|
||||
animation) → M8 progression/sparfiler → M9a flera våningar → M9 navigeringslägen →
|
||||
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 →
|
||||
M10 story-innehåll → M11 oändligt läge/NG+ → M12 ljud/polish/release.
|
||||
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).
|
||||
|
||||
@@ -328,24 +328,32 @@ på **brasse-linux01-runnern** (label `linux-amd64`), aldrig på Pi5:n.
|
||||
`TestIndividualCardsAtGateAndMachine`, `TestLevel003Flow`,
|
||||
`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,
|
||||
särskilt när missade tåg kostar (M7).
|
||||
- **Ankomstindikator nära rälsen:** några sekunder före ankomst (från
|
||||
tidtabellen) tänds lampor vid perrongkanten och strålkastare syns i
|
||||
tunneln/vid kartkanten, bara för den som står nära rälsen.
|
||||
- **Vind:** när tåget kör in eller avgår blåser det längs perrongen –
|
||||
partiklar (damm, papper) och fladdrande kläder/hår på alla som står vid
|
||||
kanten. Kan senare påverka items (hattar som blåser av, M8).
|
||||
- **In- och utkörningsanimation:** tågvagnarna rullar in från kartkanten,
|
||||
bromsar, står (dörrar öppna) och accelererar ut – samma för bussar. Ersätter
|
||||
dagens "dyker upp/försvinner".
|
||||
- **Teknik:** ren logik i `sim` (`TrainPhase(t)`: approaching/arriving/
|
||||
dwelling/departing/away med fas 0–1 ur `level.Schedule`), partiklar och
|
||||
tweening i `internal/game`. Ljudet (M12) hakar på samma faser.
|
||||
- **Klar när:** faserna beräknas rätt ur tidtabellen (testat), indikatorn
|
||||
syns bara nära rälsen, tåget rullar in/ut i stället för att blinka till.
|
||||
- **Mål:** tågen ska synas, höras och kännas – man ska förstå när de kommer
|
||||
och går, särskilt när missade tåg kostar (M7).
|
||||
- **Tågfaser** ur tidtabellen (`level.Schedule.Phase`): på väg in
|
||||
(approaching, 5 s före), kör in (arriving, 1,5 s), står inne (dwelling,
|
||||
dörrar öppna), strax avgång (closing, sista 3 s, dörrsignal), kör ut
|
||||
(leaving, 1,5 s), borta. Man kan bara kliva på när dörrarna är öppna.
|
||||
- **Räls** ritas längs perrongkanten (egen ruta i `tools/genart`); tågen
|
||||
rullar in längs rälsen i tre vagnar, står, och accelererar ut – inte
|
||||
"dyker upp/försvinner". Vägrutor för bussar kommer med bussarna (M10).
|
||||
- **Ankomstindikator nära rälsen:** står man inom två rutor från perrongen
|
||||
när tåget är på väg in blinkar lampor vid kanten, strålkastare syns från
|
||||
det håll tåget kommer och inforutan säger "tåg på väg in".
|
||||
- **Vind:** dammpartiklar längs perrongen när tåget kör in och ut.
|
||||
- **Ljud:** ljudsystemet (`ebiten/v2/audio`) införs här med två placeholder-
|
||||
jinglar genererade av `tools/gensound` (pentatonisk avgångsmelodi när
|
||||
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
|
||||
|
||||
@@ -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
|
||||
(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
|
||||
|
||||
- **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
|
||||
|
||||
- **Mål:** hela resan från flygplats till flygplats.
|
||||
- Alla huvudbanor och sidobanor; banor med flera stopp och byten; buss;
|
||||
stationer i flera våningar (M9a) och svårighetsinställningarna från M7
|
||||
i stigande kombinationer.
|
||||
- Alla huvudbanor och sidobanor; banor med flera stopp och byten; buss (med
|
||||
vägrutor och hållplatser); stationer i flera våningar (M9a), folk och röra
|
||||
(M9b) och svårighetsinställningarna från M7 i stigande kombinationer.
|
||||
- Slutanimation (karaktärerna flyger iväg, items och poäng visas) och
|
||||
eftertexter.
|
||||
- Ö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
|
||||
|
||||
- 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).
|
||||
- Windows-test på riktig maskin, ikon i exe:n, eventuell Gitea-CI med
|
||||
releaser enligt playbooken.
|
||||
|
||||
@@ -77,6 +77,20 @@ otydligt står tolkningen i [§15](#15-tolkningar-av-originalet).
|
||||
vid perrongkanten, strålkastare i tunneln.
|
||||
- **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.
|
||||
- **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):
|
||||
|
||||
@@ -157,6 +171,12 @@ otydligt står tolkningen i [§15](#15-tolkningar-av-originalet).
|
||||
och banan misslyckas.
|
||||
- **Extra svårigheter:** överstimulerande UI och banor byggda för att förvirra
|
||||
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å
|
||||
skyltar, kort-/biljettkrav, tidsgräns, antal byten, UI-störningar, antal
|
||||
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.
|
||||
- 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
|
||||
|
||||
|
||||
2
go.mod
@@ -7,8 +7,10 @@ require github.com/hajimehoshi/ebiten/v2 v2.10.5
|
||||
require (
|
||||
github.com/ebitengine/gomobile v0.0.0-20260820040257-d11f821a26a6 // 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/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
|
||||
golang.org/x/image v0.45.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
|
||||
4
go.sum
@@ -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/hideconsole v1.0.0 h1:5J4U0kXF+pv/DhiXt5/lTz0eO5ogJ1iXb8Yj1yReDqE=
|
||||
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/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
|
||||
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/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/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/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
|
||||
|
||||
@@ -25,17 +25,23 @@ const LineH = 12
|
||||
var tileOrder = []level.Tile{
|
||||
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.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/.
|
||||
type Assets struct {
|
||||
ResDir string
|
||||
LevelsDir string
|
||||
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]
|
||||
Text map[string]string
|
||||
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)
|
||||
}
|
||||
for i, t := range tileOrder {
|
||||
if i == trainIndex {
|
||||
if i == trainIndex || i == railsIndex {
|
||||
continue
|
||||
}
|
||||
a.Tiles[t] = sub(i)
|
||||
}
|
||||
a.Tiles[level.WrongPlatform] = a.Tiles[level.Platform] // samma utseende; tåget avgör
|
||||
a.Train = sub(trainIndex)
|
||||
a.Rails = sub(railsIndex)
|
||||
|
||||
player, err := loadImage("sprites", "player.png")
|
||||
if err != nil {
|
||||
|
||||
72
internal/game/audio.go
Normal 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) }
|
||||
@@ -33,6 +33,7 @@ type Game struct {
|
||||
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
|
||||
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)
|
||||
scene Scene
|
||||
next Scene
|
||||
@@ -51,6 +52,7 @@ type Options struct {
|
||||
Seed uint64 // >0: fast seed för banorna (för tester/skärmdumpar)
|
||||
Phone bool // kortet i telefonen från start
|
||||
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
|
||||
}
|
||||
|
||||
@@ -66,6 +68,9 @@ func New(opt Options) (*Game, error) {
|
||||
}
|
||||
g.Know.Points = opt.Points
|
||||
g.fixedSeed = opt.Seed
|
||||
if g.Audio, err = newAudio(opt.Mute); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.Wallet = sim.NewWallet()
|
||||
g.Profile = &sim.Profile{}
|
||||
for _, d := range opt.Debuffs {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"image"
|
||||
"image/color"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@@ -34,6 +35,13 @@ type LevelScene struct {
|
||||
cam *sim.Camera
|
||||
animT float64
|
||||
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,
|
||||
@@ -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}),
|
||||
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()
|
||||
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).
|
||||
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 {
|
||||
s.updateEffects(g)
|
||||
if s.run.Mini != nil {
|
||||
// Minispel: all input går dit; Esc avbryter minispelet, inte banan.
|
||||
mx, my := miniMoveXY()
|
||||
@@ -146,18 +172,6 @@ func (s *LevelScene) MiniPick(text string) bool {
|
||||
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) {
|
||||
screen.Fill(color.RGBA{24, 20, 37, 255})
|
||||
lvl := s.run.Level
|
||||
@@ -172,23 +186,17 @@ func (s *LevelScene) Draw(g *Game, screen *ebiten.Image) {
|
||||
var op ebiten.DrawImageOptions
|
||||
for y := y0; y < y1; y++ {
|
||||
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.Translate(ox+float64(x*TileSize), oy+float64(y*TileSize))
|
||||
screen.DrawImage(g.Assets.Tiles[lvl.At(x, y)], &op)
|
||||
}
|
||||
}
|
||||
// Tåg som står inne.
|
||||
for _, t := range []level.Tile{level.Platform, level.WrongPlatform} {
|
||||
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)
|
||||
}
|
||||
}
|
||||
s.drawTrains(g, screen, ox, oy)
|
||||
s.drawParticles(screen, ox, oy)
|
||||
|
||||
// Följarna ritas bakifrån så att ledaren hamnar överst.
|
||||
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))
|
||||
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.drawIndicator(screen, ox, oy)
|
||||
|
||||
s.drawHUD(g, screen)
|
||||
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,
|
||||
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
|
||||
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
|
||||
lines = append(lines, plainLine(fmt.Sprintf("%s %.0f s", a.T("hud.next_train_in"), math.Ceil(secs)), colWarn))
|
||||
}
|
||||
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 {
|
||||
know := r.Know
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
// 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
|
||||
// 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
|
||||
// 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)
|
||||
@@ -87,7 +88,7 @@ func parseScript(src string) (*script, error) {
|
||||
return nil, fmt.Errorf("script: okänd riktning %q", f[1])
|
||||
}
|
||||
st.n, _ = strconv.Atoi(f[2])
|
||||
case "shot", "pick":
|
||||
case "shot", "pick", "waitphase":
|
||||
if len(f) != 2 {
|
||||
return nil, fmt.Errorf("script: '%s TEXT'", f[0])
|
||||
}
|
||||
@@ -126,6 +127,7 @@ type trainWaiter interface {
|
||||
TrainHere() bool
|
||||
RideAtDest() bool
|
||||
MiniPick(text string) bool
|
||||
TrackPhaseName() string
|
||||
}
|
||||
|
||||
// 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() {
|
||||
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":
|
||||
s.counter++
|
||||
if tw, ok := g.scene.(trainWaiter); !ok || tw.MiniPick(st.arg) || s.counter > 60 {
|
||||
|
||||
196
internal/game/trains.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,72 @@ func (s Schedule) Status(t float64) (atPlatform bool, seconds float64) {
|
||||
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.
|
||||
type Train struct {
|
||||
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 {
|
||||
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 {
|
||||
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") + "方面"
|
||||
}
|
||||
|
||||
// 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
|
||||
// garanterar att det finns minst ett rätt tåg vid en befintlig perrong).
|
||||
func (l *Level) CorrectPlatform() Point {
|
||||
|
||||
@@ -321,3 +321,66 @@ func TestDifficultySettings(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package sim
|
||||
type Camera struct {
|
||||
ViewW, ViewH 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
|
||||
}
|
||||
|
||||
@@ -15,10 +16,11 @@ func NewCamera(viewW, viewH, worldW, worldH float64) *Camera {
|
||||
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) {
|
||||
c.X = clampAxis(cx-c.ViewW/2, c.ViewW, c.WorldW)
|
||||
c.Y = clampAxis(cy-c.ViewH/2, c.ViewH, c.WorldH)
|
||||
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.Margin, c.ViewH, c.WorldH+2*c.Margin) - c.Margin
|
||||
}
|
||||
|
||||
// Offset returnerar översättningen världs- -> skärmkoordinater.
|
||||
|
||||
@@ -39,3 +39,22 @@ func TestCameraCentersSmallWorld(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -634,8 +634,45 @@ func (r *Run) PlatformHere() (tr *level.Train, atPlatform bool, seconds float64)
|
||||
if tr == nil {
|
||||
return nil, false, 0
|
||||
}
|
||||
atPlatform, seconds = tr.Schedule.Status(r.Time)
|
||||
return tr, atPlatform, seconds
|
||||
_, seconds = tr.Schedule.Status(r.Time)
|
||||
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.
|
||||
@@ -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 {
|
||||
tr := r.Level.TrainAt(t)
|
||||
if tr == nil {
|
||||
return false
|
||||
}
|
||||
at, _ := tr.Schedule.Status(r.Time)
|
||||
return at
|
||||
return tr != nil && tr.Schedule.Present(r.Time)
|
||||
}
|
||||
|
||||
// AskHelp börjar fråga en förbipasserande om vad skylten betyder. Kräver att
|
||||
|
||||
@@ -901,3 +901,56 @@ func TestLevel003Flow(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
# 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
|
||||
stationer, spärr-pip, automat-ljud, folkmassa, UI-klick.
|
||||
- `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*).
|
||||
|
||||
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).
|
||||
|
||||
BIN
resources/sound/door-chime.wav
Normal file
BIN
resources/sound/melody-approach.wav
Normal file
|
Before Width: | Height: | Size: 879 B After Width: | Height: | Size: 950 B |
@@ -127,5 +127,10 @@
|
||||
"result.penalty": "penalty",
|
||||
"result.lang_points": "language points",
|
||||
"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!"
|
||||
}
|
||||
|
||||
@@ -127,5 +127,10 @@
|
||||
"result.penalty": "avdrag",
|
||||
"result.lang_points": "språkpoäng",
|
||||
"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!"
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// go run ./tools/genart
|
||||
//
|
||||
// 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,
|
||||
// vänster, höger; kolumn 0 = stilla, 1–2 = gå). Ritas med enkla rektanglar
|
||||
// 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(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
|
||||
floor(c)
|
||||
c.rect(1, 1, 14, 3, awningRed)
|
||||
|
||||
113
tools/gensound/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
{"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 +
|
||||
"; 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"}},
|
||||
{"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",
|
||||
@@ -135,7 +135,7 @@ func main() {
|
||||
exe += ".exe"
|
||||
}
|
||||
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 != "" {
|
||||
args = append(args, "-level", s.level)
|
||||
}
|
||||
|
||||