diff --git a/README.md b/README.md index 19e6384..5bce7bc 100644 --- a/README.md +++ b/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). ![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. 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) 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 | diff --git a/cmd/metro-puzzle-jp/main.go b/cmd/metro-puzzle-jp/main.go index c146010..fdabec7 100644 --- a/cmd/metro-puzzle-jp/main.go +++ b/cmd/metro-puzzle-jp/main.go @@ -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) } diff --git a/doc/img/approach.png b/doc/img/approach.png new file mode 100644 index 0000000..29e3815 Binary files /dev/null and b/doc/img/approach.png differ diff --git a/doc/img/level-001.png b/doc/img/level-001.png index 75b532a..a7c4529 100644 Binary files a/doc/img/level-001.png and b/doc/img/level-001.png differ diff --git a/doc/img/level-002.png b/doc/img/level-002.png index 8560597..b119809 100644 Binary files a/doc/img/level-002.png and b/doc/img/level-002.png differ diff --git a/doc/img/menu.png b/doc/img/menu.png index 2c2c0ef..7ec5b57 100644 Binary files a/doc/img/menu.png and b/doc/img/menu.png differ diff --git a/doc/img/platform.png b/doc/img/platform.png index 487f539..2577b5a 100644 Binary files a/doc/img/platform.png and b/doc/img/platform.png differ diff --git a/doc/img/result.png b/doc/img/result.png index 99a071b..9bb8d95 100644 Binary files a/doc/img/result.png and b/doc/img/result.png differ diff --git a/doc/img/ride.png b/doc/img/ride.png index 030eeea..3d14fc3 100644 Binary files a/doc/img/ride.png and b/doc/img/ride.png differ diff --git a/doc/img/seats.png b/doc/img/seats.png index fc9e8d4..c5a8577 100644 Binary files a/doc/img/seats.png and b/doc/img/seats.png differ diff --git a/doc/img/sign-help.png b/doc/img/sign-help.png index 2c0baa3..ee9bd82 100644 Binary files a/doc/img/sign-help.png and b/doc/img/sign-help.png differ diff --git a/doc/img/sign-learned.png b/doc/img/sign-learned.png index cb87dd5..f8365b9 100644 Binary files a/doc/img/sign-learned.png and b/doc/img/sign-learned.png differ diff --git a/doc/img/sign.png b/doc/img/sign.png index 21a02c6..78aae42 100644 Binary files a/doc/img/sign.png and b/doc/img/sign.png differ diff --git a/doc/img/ticket.png b/doc/img/ticket.png index 740b08d..435d60e 100644 Binary files a/doc/img/ticket.png and b/doc/img/ticket.png differ diff --git a/doc/img/topup.png b/doc/img/topup.png index 70ac398..3b8e21e 100644 Binary files a/doc/img/topup.png and b/doc/img/topup.png differ diff --git a/doc/mal-och-tester.md b/doc/mal-och-tester.md index 78cc964..d10321a 100644 --- a/doc/mal-och-tester.md +++ b/doc/mal-och-tester.md @@ -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 | diff --git a/doc/plan.md b/doc/plan.md index 2d3b68b..540b59d 100644 --- a/doc/plan.md +++ b/doc/plan.md @@ -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). diff --git a/doc/roadmap.md b/doc/roadmap.md index 9146dc9..7c067fa 100644 --- a/doc/roadmap.md +++ b/doc/roadmap.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. diff --git a/doc/spelbeskrivning.md b/doc/spelbeskrivning.md index d668b09..cd01749 100644 --- a/doc/spelbeskrivning.md +++ b/doc/spelbeskrivning.md @@ -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 diff --git a/go.mod b/go.mod index c151aa4..b60e654 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index a511953..4a02c36 100644 --- a/go.sum +++ b/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= diff --git a/internal/game/assets.go b/internal/game/assets.go index ebaeb5c..c250981 100644 --- a/internal/game/assets.go +++ b/internal/game/assets.go @@ -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 { diff --git a/internal/game/audio.go b/internal/game/audio.go new file mode 100644 index 0000000..bfd2fd5 --- /dev/null +++ b/internal/game/audio.go @@ -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) } diff --git a/internal/game/game.go b/internal/game/game.go index 9686e1e..042cc9a 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -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 { diff --git a/internal/game/scene_level.go b/internal/game/scene_level.go index 0e2b0cb..bb2f06e 100644 --- a/internal/game/scene_level.go +++ b/internal/game/scene_level.go @@ -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 diff --git a/internal/game/script.go b/internal/game/script.go index 832d413..9d69eef 100644 --- a/internal/game/script.go +++ b/internal/game/script.go @@ -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 { diff --git a/internal/game/trains.go b/internal/game/trains.go new file mode 100644 index 0000000..8856037 --- /dev/null +++ b/internal/game/trains.go @@ -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) + } +} diff --git a/internal/level/level.go b/internal/level/level.go index f062110..5a925c3 100644 --- a/internal/level/level.go +++ b/internal/level/level.go @@ -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 { diff --git a/internal/level/level_test.go b/internal/level/level_test.go index 398c5d6..436c92c 100644 --- a/internal/level/level_test.go +++ b/internal/level/level_test.go @@ -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") + } +} diff --git a/internal/sim/camera.go b/internal/sim/camera.go index 8c28d30..ecbd0b6 100644 --- a/internal/sim/camera.go +++ b/internal/sim/camera.go @@ -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. diff --git a/internal/sim/camera_test.go b/internal/sim/camera_test.go index fa61837..e48bd0e 100644 --- a/internal/sim/camera_test.go +++ b/internal/sim/camera_test.go @@ -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) + } +} diff --git a/internal/sim/run.go b/internal/sim/run.go index 1829ce9..1ed302f 100644 --- a/internal/sim/run.go +++ b/internal/sim/run.go @@ -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 diff --git a/internal/sim/run_test.go b/internal/sim/run_test.go index b099cad..ba1f2da 100644 --- a/internal/sim/run_test.go +++ b/internal/sim/run_test.go @@ -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") + } +} diff --git a/resources/sound/README.md b/resources/sound/README.md index d506d33..2233c77 100644 --- a/resources/sound/README.md +++ b/resources/sound/README.md @@ -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). diff --git a/resources/sound/door-chime.wav b/resources/sound/door-chime.wav new file mode 100644 index 0000000..35c4736 Binary files /dev/null and b/resources/sound/door-chime.wav differ diff --git a/resources/sound/melody-approach.wav b/resources/sound/melody-approach.wav new file mode 100644 index 0000000..45c86b8 Binary files /dev/null and b/resources/sound/melody-approach.wav differ diff --git a/resources/sprites/tiles.png b/resources/sprites/tiles.png index d0fa12f..9f95497 100644 Binary files a/resources/sprites/tiles.png and b/resources/sprites/tiles.png differ diff --git a/resources/text/en.json b/resources/text/en.json index cf3011b..1518813 100644 --- a/resources/text/en.json +++ b/resources/text/en.json @@ -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!" } diff --git a/resources/text/sv.json b/resources/text/sv.json index c9f8f08..e3d406a 100644 --- a/resources/text/sv.json +++ b/resources/text/sv.json @@ -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!" } diff --git a/tools/genart/main.go b/tools/genart/main.go index fdae450..290ea06 100644 --- a/tools/genart/main.go +++ b/tools/genart/main.go @@ -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) diff --git a/tools/gensound/main.go b/tools/gensound/main.go new file mode 100644 index 0000000..6e4836a --- /dev/null +++ b/tools/gensound/main.go @@ -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) + } +} diff --git a/tools/shots/main.go b/tools/shots/main.go index 20c33ef..70effdb 100644 --- a/tools/shots/main.go +++ b/tools/shots/main.go @@ -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) }