diff --git a/README.md b/README.md index 6702b76..dbd050b 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,15 @@ 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:** M9a klar (2026-10-24) – fem banor på en bankarta i -spelbrädesstil, med sidospår. Japanska skyltar och ordinlärning, -lämmeltåget, minikarta, tågkort, biljetter och minispel, svårighet med -tidsgräns, poäng och debuffar, tåg som rullar in till avgångsmelodin, -progression med XP, skillträd, items och sparfil. Och nu stationer i flera -våningar: Tokyo station börjar under mark och trappan tar dig upp till -perrongerna. Nästa är M9b: folk, reklam och ljudröra. Se +**Status:** M9b klar (2026-10-24) – sex banor på en bankarta i +spelbrädesstil, med sidospår och flera våningar. Japanska skyltar och +ordinlärning, lämmeltåget, minikarta, tågkort, biljetter och minispel, +svårighet med tidsgräns, poäng och debuffar, tåg som rullar in till +avgångsmelodin, progression med XP, skillträd, items och sparfil. Och nu +lever stationen: folk som går i vägen, filer där man ska hålla till vänster, +reklam på väggarna, sorl i bakgrunden, och tåg med flera vagnar där man +väljer vagn, låter folk kliva av först och aktar sig för kvinnovagnen. +Nästa är M9: navigeringslägena lista och skyltläge. Se [doc/roadmap.md](doc/roadmap.md). ![Bankartan](doc/img/map.png) @@ -56,6 +58,13 @@ tar dig upp och ner, och minikartan visar vilket plan du är på. ![Tokyo station, 1F](doc/img/floors-1f.png) +På svåra banor är stationen full av folk, väggarna av reklam och luften av +sorl. Tågen har flera vagnar: låt folk kliva av först, välj en vagn med lite +folk för extra poäng, och undvik den rosa kvinnovagnen om det finns en man i +sällskapet. + +![Vagnval](doc/img/cars.png) + På de svåraste banorna har alla i sällskapet egna kort som måste ha saldo, alla behöver biljett, och när biljetten är köpt ska sällskapet placeras i vagnen efter sina önskemål: fönster eller gång, vänster eller höger sida, @@ -176,7 +185,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**. På Linux körs spelet på en osynlig skärm (headless Wayfire + Xwayland) så att inget fönster syns eller tar fokus; `METRO_HEADLESS=0` använder skärmen | +| `go run ./tools/shots [ord…]` | tar dokumentationens skärmdumpar till `doc/img/` – **kör efter varje grafik-/UI-ändring och committa bilderna**. Med argument körs bara skott vars bana eller skript innehåller något av orden (t.ex. `go run ./tools/shots 005 ride.png`). På Linux körs spelet på en osynlig skärm (headless Wayfire + Xwayland) så att inget fönster syns eller tar fokus; `METRO_HEADLESS=0` använder skärmen | ## Beroenden diff --git a/doc/img/approach.png b/doc/img/approach.png index 29e3815..0c72597 100644 Binary files a/doc/img/approach.png and b/doc/img/approach.png differ diff --git a/doc/img/cars-alight.png b/doc/img/cars-alight.png new file mode 100644 index 0000000..e73f7dc Binary files /dev/null and b/doc/img/cars-alight.png differ diff --git a/doc/img/cars.png b/doc/img/cars.png new file mode 100644 index 0000000..37f38d4 Binary files /dev/null and b/doc/img/cars.png differ diff --git a/doc/img/crowd.png b/doc/img/crowd.png new file mode 100644 index 0000000..8f4d2f0 Binary files /dev/null and b/doc/img/crowd.png differ diff --git a/doc/img/map.png b/doc/img/map.png index 8d0a6a1..cab1396 100644 Binary files a/doc/img/map.png and b/doc/img/map.png differ diff --git a/doc/img/platform.png b/doc/img/platform.png index 2577b5a..99b9e8c 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 6c89658..d8e3cbd 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 c0e1fa7..7073889 100644 Binary files a/doc/img/ride.png and b/doc/img/ride.png differ diff --git a/doc/img/shop.png b/doc/img/shop.png index f2f6798..87171a9 100644 Binary files a/doc/img/shop.png and b/doc/img/shop.png differ diff --git a/doc/img/sign-help.png b/doc/img/sign-help.png index ee9bd82..113c3de 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 f8365b9..c6ecf36 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 78aae42..f4218a7 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 eb8bb88..371059c 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 3b8e21e..18f0bfb 100644 Binary files a/doc/img/topup.png and b/doc/img/topup.png differ diff --git a/doc/img/words.png b/doc/img/words.png index 5f02139..655f36d 100644 Binary files a/doc/img/words.png and b/doc/img/words.png differ diff --git a/doc/mal-och-tester.md b/doc/mal-och-tester.md index 1068635..0b7e6c3 100644 --- a/doc/mal-och-tester.md +++ b/doc/mal-och-tester.md @@ -34,7 +34,7 @@ att bygga på Linux som på Windows. | Inkrementellt debug-bygge | < 10 s | 4,7 s | | Release-binär | < 20 MB | 10,2 MB (Linux), 10,8 MB (Windows) | | Testsviten | `go test ./...` grön på < 30 s | ~0,1 s | -| Banor | 100 % passerar det automatiska lösbarhetstestet | 5 av 5 | +| Banor | 100 % passerar det automatiska lösbarhetstestet | 6 av 6 | | Windows-korsbygge från Linux | fungerar utan extra verktyg | ✓ (CGO_ENABLED=0) | | Linux-binärens länkade bibliotek | bara libc | ✓ (GL/X11 laddas dynamiskt vid körning) | | Bildfrekvens | 60 fps vid 4× skala på integrerad grafik | ej mätt | @@ -126,6 +126,10 @@ att bygga på Linux som på Windows. | `internal/minigame` | `TestCrowdLuckWidensZone` | Tur gör zonen bredare, otur smalare, klampat | | `internal/level` | `TestFloorsStairsAndPaths`, `TestFloorsValidation` | **M9a:** flera våningar läses, trappor länkar, BFS-vägen byter våning bara via trappa, spärregeln och skyltar per våning; trappa utan motpart, olika storlekar, grid+floors och fel våning avvisas | | `internal/sim` | `TestStairsMoveBetweenFloors` | Att kliva på trappan flyttar spelaren en våning, dimman lättar runt andra änden, perrongen hörs bara på egen våning, boten går hela vägen över våningar | +| `internal/level` | `TestCarsWomenOnlyAndCrowdFields` | **M9b:** vagnar, kvinnovagnens tidsfönster, crowd/clutter/noise 0–3, filrutor | +| `internal/minigame` | `TestCarPickAlightingAndChoice`, `TestCrowdLevelNarrowsZone`, `TestRideCrowdLetPass` | Folk av först (för tidigt räknas), seedad trängsel med minst en lugn vagn, val; trängsel smalnar zonen; resan: kortare tid per station, passagerare som vill förbi, blockerade räknas | +| `internal/sim` | `TestNPCsWanderStayOnFloorAndBlockPlayer`, `TestAlightingNPCsSpawnAndLeave` | Folk rör sig på golv, seedat, blockerar spelaren; avstigande spawnar när dörrarna öppnas och lämnar via utgången | +| `internal/sim` | `TestLanesSlowWrongSide`, `TestCarsWomenOnlyEarlyAndCrowd` | Fel sida i filen bromsar; kvinnovagn med man ger grova minus, för tidigt ombord räknas, resan ärver vagnens trängsel, lugn vagn ger bonus | | `internal/archtest` | `TestOnlyPresentationPackagesImportEbitengine` | Beroenderegeln ovan | ### Planerade tester per milstolpe @@ -140,7 +144,7 @@ att bygga på Linux som på Windows. | ~~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~~ ✓ | 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 | +| ~~M9b Folk, reklam, ljudröra, vagnar~~ ✓ | NPC:er, filer, röra, vagnval – finns, se tabellen ovan (bot med folk: testerna tömmer folket där det skulle störa, "fler NPC:er = längre tid" mäts manuellt) | | ~~M8 Progression & sparfil~~ ✓ | Round-trip, skillkrav, upplåsningar, 33 %-regeln – finns, se tabellen ovan | | ~~M9a Flera våningar~~ ✓ | Trappor, vägar över våningar, dimma per våning, gamla banor oförändrade – finns, se tabellen ovan | | 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 6eb7fbf..8b616cc 100644 --- a/doc/plan.md +++ b/doc/plan.md @@ -34,7 +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 ✓ → M8 progression/sparfiler ✓ → M9a flera våningar ✓ → M9b folk/reklam/ljudröra → 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/vagnar ✓ → 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 2724ec0..7a14957 100644 --- a/doc/roadmap.md +++ b/doc/roadmap.md @@ -100,7 +100,7 @@ skriptläge så att de alltid är pixelexakta och går att återskapa: ```bash go run ./tools/genart # bara om placeholder-grafiken ska genereras om -go run ./tools/shots # bygger debug, kör spelet med -script, skriver doc/img/*.png +go run ./tools/shots [ord…] # bygger debug, kör spelet med -script, skriver doc/img/*.png (ord filtrerar på bana/skript) ``` **Regel:** när sprites, tileset eller UI ändras – kör `tools/shots` och @@ -428,7 +428,7 @@ på **brasse-linux01-runnern** (label `linux-amd64`), aldrig på Pi5:n. (`TestStairsMoveBetweenFloors`, `TestFloorsStairsAndPaths`), 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) +### M9b – Folk, reklam och ljudröra ✓ (klar 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 @@ -443,11 +443,44 @@ på **brasse-linux01-runnern** (label `linux-amd64`), aldrig på Pi5:n. 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. +- **Vagnar** (Björns tillägg 2026-10-24): tåg har `cars` vagnar; när tåget + står inne väljer man vagn på perrongen (kömarkeringar visar var man + ställer sig). **Folk kliver av först** – trycker man för tidigt blir det + avdrag. Varje vagn har en seedad **trängselnivå** (lite/mellan/mycket): + lite folk ger poängbonus, mycket folk gör trängselminispelet och resan + svårare (kortare tid per station, passagerare som måste **släppas förbi** + vid stationer där man inte ska av) och kan ge debuffen *svettig*. + **Kvinnovagn** (`womenOnlyCar`, ev. bara mellan vissa klockslag som står + på skyltarna) markeras rosa; har sällskapet en man och går på den blir det + **grova minus**. Sällskapets medlemmar får därför kön. - **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. + `minigame.CarPick`, trängsel i `Crowd`/`Ride`, dekorationslager + (seedade affischer) och `clutter`/`noise`/`crowd` i banformatet, + `tools/genart` för folk/affischer/vagnmarkeringar, `tools/gensound` för + bakgrundsprat; generatorn får breda gångar med filer. +- **Så blev det:** `sim.NPC` (vandrare mellan kiosker/toaletter/skyltar/ + perronger med BFS-vägar och seedade pauser; avstigande som kliver av när + dörrarna öppnas och går till utgången) tar plats i rutnätet – spelaren + stoppas ("Ursäkta…"), folket går igenom varandra så inget låser sig. + Filer `<`/`>` i breda gångar: fel sida ger ×0,7 fart. `clutter` ger + seedade affischer på väggar intill gångar (tre varianter, en liknar en + skylt); `noise` ger loopat bakgrundsprat (`chatter.wav`) som dämpar + melodierna. **Vagnval** (`minigame.CarPick`): folk kliver av i 1,5 s + (Enter för tidigt = avdrag), vagnarna visas med trängselfigurer och + kömarkeringar, kvinnovagn rosa med 女性専用; lugn vagn +50, full vagn ger + trängselminispel med smalare zon, kortare tid per station och + passagerare som ska släppas förbi (← ), samt 60 % risk för *svettig*. + Man i sällskapet på kvinnovagnen: −200. Sällskapet har kön (Anna/Sara + kvinnor, Ville/Omar män, spelaren okänd). **Bana 005 Ueno** (svårighet + 4): breda gångar med filer, folk 2, röra 2, ljud 2, fyra vagnar där vagn + 1 är kvinnovagn de första 2,5 minuterna. +- **Klar när:** NPC:er rör sig på golv, är seedade och blockerar spelaren + (`TestNPCsWanderStayOnFloorAndBlockPlayer`), avstigande kommer och går + (`TestAlightingNPCsSpawnAndLeave`), filer bromsar (`TestLanesSlowWrongSide`), + vagnval/kvinnovagn/trängsel/avdrag (`TestCarsWomenOnlyEarlyAndCrowd`, + `TestCarPickAlightingAndChoice`, `TestRideCrowdLetPass`). ✓ +- **Kvar till senare:** NPC:er som väntar på och kliver på tåget, folk som + bildar riktiga köer vid markeringarna, utrop/reklam som ljud (M12). ### M9 – Navigeringslägen: lista och skyltläge diff --git a/doc/spelbeskrivning.md b/doc/spelbeskrivning.md index cd01749..8503b7e 100644 --- a/doc/spelbeskrivning.md +++ b/doc/spelbeskrivning.md @@ -92,6 +92,21 @@ otydligt står tolkningen i [§15](#15-tolkningar-av-originalet). - **Bredare gångar**: vissa gångar och vägar på banorna är breda nog att man kan gå mellan folk. +**Vagnar** (tillägg 2026-10-24): + +- Vissa tåg har **flera vagnar**, och man väljer vilken man kliver på. +- Det kan finnas **kvinnovagnar** (女性専用車). Har man en man i sällskapet + och går på en sådan får man **grova minus**. Vagnen kan vara kvinnovagn + **bara under vissa tider**, som står på skyltarna, och den markeras **rosa**. +- Perrongen har **kömarkeringar** som visar var man ställer sig för att komma + på en vagn. +- Vagnarna har **olika mycket folk**. Tar man en vagn med **mindre folk** får + man **mer poäng**. Tar man en med **mycket folk** kan man få **debuffar** av + att bli svettig, och **minispelet blir svårare**: det blir svårare att kliva + av på rätt station, och man måste **släppa förbi andra passagerare** som ska + av på stationer där man själv inte ska av. +- Man **släpper av folk från tåget först** innan man får gå på. + **Flera våningar** (tillägg 2026-10-24): - Banor och stationer kan ha **flera våningar**. Trappor (och rulltrappor/ diff --git a/internal/game/assets.go b/internal/game/assets.go index 164b388..d0cbc20 100644 --- a/internal/game/assets.go +++ b/internal/game/assets.go @@ -28,12 +28,16 @@ var tileOrder = []level.Tile{ level.Toilet, level.Wall, // plats 9 = rälsen (laddas separat som Rails) level.StairsUp, level.StairsDown, + level.LaneLeft, level.LaneRight, + level.Wall, level.Wall, level.Wall, // plats 13–15 = affischer (laddas separat som Posters) level.Kiosk, } const ( - trainIndex = 7 - railsIndex = 9 + trainIndex = 7 + railsIndex = 9 + posterIndex = 14 + posterCount = 3 ) // Assets är allt som laddats från resources/. @@ -43,6 +47,7 @@ type Assets struct { Tiles map[level.Tile]*ebiten.Image 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 + Posters []*ebiten.Image // affischer för visuell röra (ritas över väggrutor) 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 @@ -70,7 +75,7 @@ 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 || i == railsIndex { + if i == trainIndex || i == railsIndex || (i >= posterIndex && i < posterIndex+posterCount) { continue } a.Tiles[t] = sub(i) @@ -78,6 +83,9 @@ func LoadAssets() (*Assets, error) { a.Tiles[level.WrongPlatform] = a.Tiles[level.Platform] // samma utseende; tåget avgör a.Train = sub(trainIndex) a.Rails = sub(railsIndex) + for i := 0; i < posterCount; i++ { + a.Posters = append(a.Posters, sub(posterIndex+i)) + } player, err := loadImage("sprites", "player.png") if err != nil { diff --git a/internal/game/audio.go b/internal/game/audio.go index bfd2fd5..e40e534 100644 --- a/internal/game/audio.go +++ b/internal/game/audio.go @@ -17,6 +17,7 @@ import ( type Audio struct { ctx *audio.Context clips map[string][]byte // avkodad PCM (16-bit stereo LE) per klipp + loop *audio.Player // pågående bakgrundsloop (bakgrundsprat) } const sampleRate = 44100 @@ -25,6 +26,7 @@ const sampleRate = 44100 const ( SoundApproach = "melody-approach" SoundDoors = "door-chime" + SoundChatter = "chatter" ) // AudibleTiles är hur många rutor bort man hör perrongens ljud. @@ -36,7 +38,7 @@ func newAudio(mute bool) (*Audio, error) { return nil, nil } a := &Audio{ctx: audio.NewContext(sampleRate), clips: map[string][]byte{}} - for _, name := range []string{SoundApproach, SoundDoors} { + for _, name := range []string{SoundApproach, SoundDoors, SoundChatter} { b, err := res.ReadFile("sound", name+".wav") if err != nil { return nil, err @@ -68,5 +70,34 @@ func (a *Audio) Play(name string, volume float64) { p.Play() } +// Loop startar (eller byter) bakgrundsloopen med given volym; 0 stoppar. +func (a *Audio) Loop(name string, volume float64) { + if a == nil { + return + } + a.StopLoop() + pcm, ok := a.clips[name] + if !ok || volume <= 0 { + return + } + stream := audio.NewInfiniteLoop(bytes.NewReader(pcm), int64(len(pcm))) + p, err := a.ctx.NewPlayer(stream) + if err != nil { + return + } + p.SetVolume(math.Min(1, volume)) + p.Play() + a.loop = p +} + +// StopLoop stoppar bakgrundsloopen. +func (a *Audio) StopLoop() { + if a == nil || a.loop == nil { + return + } + a.loop.Close() + a.loop = nil +} + // 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 29aeb73..f6c3df0 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -164,8 +164,24 @@ func (g *Game) Save() { } } -// SetScene byter scen från och med nästa uppdatering. -func (g *Game) SetScene(s Scene) { g.next = s } +// SetScene byter scen från och med nästa uppdatering. Bakgrundsprat hör +// till banan och tystnar när man lämnar den. +func (g *Game) SetScene(s Scene) { + if _, inLevel := s.(*LevelScene); !inLevel { + if _, isResult := s.(*ResultScene); !isResult { + g.Audio.StopLoop() + } + } + g.next = s +} + +// startChatter startar banans bakgrundsprat efter ljudröra-nivån. +func (g *Game) startChatter(noise int) { + g.Audio.Loop(SoundChatter, 0.2*float64(noise)) +} + +// noiseMask dämpar perrongens ljud när bakgrundspratet konkurrerar. +func noiseMask(noise int) float64 { return 1 - 0.2*float64(noise) } // Update körs 60 ggr/s. func (g *Game) Update() error { diff --git a/internal/game/minigame_view.go b/internal/game/minigame_view.go index 2b7ce8b..16e1157 100644 --- a/internal/game/minigame_view.go +++ b/internal/game/minigame_view.go @@ -30,9 +30,61 @@ func (s *LevelScene) drawMinigame(g *Game, screen *ebiten.Image) { s.drawCrowd(a, screen, m, panel) case *minigame.Ride: s.drawRide(a, screen, m, panel) + case *minigame.CarPick: + s.drawCarPick(a, screen, m, panel) } } +// drawCarPick: vagnarna i rad, rosa för kvinnovagn, figurer för trängsel, +// kömarkeringar under, och en fas där folk kliver av först. +func (s *LevelScene) drawCarPick(a *Assets, screen *ebiten.Image, m *minigame.CarPick, p image.Rectangle) { + x, y := p.Min.X+8, p.Min.Y+8 + a.DrawBig(screen, "乗車", x, y, colKnown) + a.Draw(screen, a.T("mini.car.title"), x+36, y+2, colText) + y += 24 + if m.Alighting > 0 { + a.Draw(screen, a.T("mini.car.alighting"), x, y, colWarn) + w := float32(p.Dx() - 16) + vector.DrawFilledRect(screen, float32(x), float32(y+LineH+2), w, 4, colBar, false) + vector.DrawFilledRect(screen, float32(x), float32(y+LineH+2), w*float32(1-m.Alighting/minigame.AlightSec), 4, colKnown, false) + } else { + a.Draw(screen, a.T("mini.car.pick"), x, y, colDim) + } + y += LineH + 12 + carW := min(64, (p.Dx()-16)/max(1, len(m.Cars))-4) + for i, c := range m.Cars { + cx := x + i*(carW+4) + body := color.RGBA{230, 234, 240, 255} + if c.WomenOnly { + body = color.RGBA{255, 160, 205, 255} + } + vector.DrawFilledRect(screen, float32(cx), float32(y), float32(carW), 30, body, false) + vector.DrawFilledRect(screen, float32(cx), float32(y+30), float32(carW), 3, colBad, false) + if i == m.Cursor && m.Alighting <= 0 { + vector.DrawFilledRect(screen, float32(cx), float32(y-4), float32(carW), 2, colKnown, false) + } + a.Draw(screen, fmt.Sprintf("%d%s", c.Index, a.T("hud.car_suffix_ja")), cx+3, y+2, colPanel) + // figurer = trängsel + for k := 0; k <= c.Crowd; k++ { + px := cx + 4 + k*7 + vector.DrawFilledRect(screen, float32(px), float32(y+16), 4, 10, color.RGBA{60, 70, 100, 255}, false) + vector.DrawFilledRect(screen, float32(px), float32(y+13), 4, 3, color.RGBA{240, 200, 160, 255}, false) + } + if c.WomenOnly { + a.Draw(screen, "女性専用", cx+2, y+34, color.RGBA{255, 150, 200, 255}) + } else { + a.Draw(screen, a.T("car.crowd."+minigame.CrowdNames[c.Crowd]), cx+2, y+34, colDim) + } + // kömarkering under vagnen + vector.DrawFilledRect(screen, float32(cx+carW/2-6), float32(y+48), 12, 2, colWarn, false) + vector.DrawFilledRect(screen, float32(cx+carW/2-1), float32(y+50), 2, 4, colWarn, false) + } + if s.run.Party.HasMen() { + a.Draw(screen, a.T("mini.car.men"), x, p.Max.Y-28, colWarn) + } + a.Draw(screen, a.T("mini.car.hint"), x, p.Max.Y-16, colDim) +} + // jp ritar japansk text sedd genom spelarens kunskap (kända ord i gult). func (s *LevelScene) jp(a *Assets, screen *ebiten.Image, text string, x, y int) { rd := lang.Render(a.Dict, s.run.Know, text, UILang) @@ -171,6 +223,11 @@ func (s *LevelScene) drawRide(a *Assets, screen *ebiten.Image, m *minigame.Ride, if dest := lvl.Destination.Get("romaji"); dest != "" { hint = strings.Replace(hint, "%s", dest, 1) } + if m.NeedsLetPass() { + a.Draw(screen, a.T("mini.ride.let_pass"), x, p.Max.Y-30, colBad) + } else if m.Crowd > 0 { + a.Draw(screen, fmt.Sprintf("%s: %s", a.T("mini.ride.crowd"), a.T("car.crowd."+minigame.CrowdNames[m.Crowd])), x, p.Max.Y-30, colDim) + } a.Draw(screen, hint, x, p.Max.Y-16, colGood) } diff --git a/internal/game/people.go b/internal/game/people.go new file mode 100644 index 0000000..4b2caef --- /dev/null +++ b/internal/game/people.go @@ -0,0 +1,98 @@ +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" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim" +) + +// Folk, affischer och kömarkeringar (M9b). + +// npcTints är gråtoner för passagerare tills de har egna sprites. +var npcTints = [][3]float32{{0.75, 0.75, 0.8}, {0.7, 0.8, 0.75}, {0.8, 0.72, 0.72}, {0.78, 0.78, 0.7}} + +// drawNPCs ritar passagerarna på spelarens våning. +func (s *LevelScene) drawNPCs(g *Game, screen *ebiten.Image, ox, oy float64) { + z := s.run.Player.Tile.Z + for i, n := range s.run.NPCs { + if n.Mover.Tile.Z != z { + continue + } + x, y := n.Mover.WorldPos(TileSize) + var op ebiten.DrawImageOptions + op.GeoM.Translate(ox+math.Round(x), oy+math.Round(y)) + t := npcTints[i%len(npcTints)] + op.ColorScale.Scale(t[0], t[1], t[2], 1) + screen.DrawImage(g.Assets.PlayerFrame(n.Mover.Facing, n.Mover.Moving(), s.animT+float64(i)*0.07), &op) + } +} + +// buildPosters väljer seedat vilka väggrutor som får affischer (visuell röra). +func (s *LevelScene) buildPosters() { + s.posters = map[level.Point]int{} + l := s.run.Level + if l.Clutter == 0 { + return + } + r := s.run.Seed.Derive("posters").New() + chance := 0.12 * float64(l.Clutter) + for z := 0; z < l.NumFloors(); z++ { + for y := 0; y < l.Height(); y++ { + for x := 0; x < l.Width(); x++ { + p := level.Point{X: x, Y: y, Z: z} + if l.At(p) != level.Wall { + continue + } + adjFloor := false + for _, n := range l.Neighbors(p) { + if n.Z == z && l.At(n).Passable() { + adjFloor = true + } + } + if adjFloor && r.Float64() < chance { + s.posters[p] = r.IntN(posterCount) + } + } + } + } +} + +// drawQueueMarks ritar kömarkeringar på perrongen (var man ställer sig för +// varje vagn) när tåget är på väg in eller står inne. +func (s *LevelScene) drawQueueMarks(screen *ebiten.Image, ox, oy float64) { + z := s.run.Player.Tile.Z + for _, tr := range s.tracks { + if tr.platform.Z != z { + continue + } + phase, _ := s.run.PlatformPhase(tr.tile) + if phase == level.Away || phase == level.Leaving { + continue + } + train := s.run.Level.TrainAt(tr.tile) + if train == nil { + continue + } + x := float32(ox + float64(tr.platform.X*TileSize)) + y := float32(oy + float64(tr.platform.Y*TileSize)) + cars := train.Cars + for k := 0; k < cars && k < 4; k++ { + col := color.RGBA{255, 255, 255, 200} + if train.WomenOnlyActive(s.run.Time) && k+1 == train.WomenOnlyCar { + col = color.RGBA{255, 150, 200, 230} // rosa: kvinnovagn + } + if tr.horizontal { + vector.DrawFilledRect(screen, x+float32(2+k*4), y+TileSize-6, 2, 3, col, false) + } else { + vector.DrawFilledRect(screen, x+TileSize-6, y+float32(2+k*4), 3, 2, col, false) + } + } + } +} + +var _ = sim.NPCSpeed diff --git a/internal/game/scene_level.go b/internal/game/scene_level.go index 04a9c5e..bae8a35 100644 --- a/internal/game/scene_level.go +++ b/internal/game/scene_level.go @@ -39,6 +39,8 @@ type LevelScene struct { shop *shop // öppen butik i kiosken (pausar banan) outcome LevelOutcome // progression från banan (fylls i när den avgjorts) + posters map[level.Point]int // affischer per väggruta (visuell röra) + // Tåg och effekter (M7b). tracks []trackInfo rails map[level.Point]bool @@ -64,6 +66,8 @@ func NewLevelScene(g *Game, id string, seed rng.Seed) (*LevelScene, error) { s.lastPhase = map[level.Tile]level.Phase{} s.rng = rand.New(rand.NewPCG(1, 2)) s.buildTracks() + s.buildPosters() + g.startChatter(lvl.Noise) 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) } @@ -202,11 +206,17 @@ func (s *LevelScene) Draw(g *Game, screen *ebiten.Image) { } op.GeoM.Reset() op.GeoM.Translate(ox+float64(x*TileSize), oy+float64(y*TileSize)) + if v, ok := s.posters[p]; ok { + screen.DrawImage(g.Assets.Posters[v], &op) + continue + } screen.DrawImage(g.Assets.Tiles[lvl.At(p)], &op) } } s.drawTrains(g, screen, ox, oy) + s.drawQueueMarks(screen, ox, oy) s.drawParticles(screen, ox, oy) + s.drawNPCs(g, screen, ox, oy) // Följarna ritas bakifrån så att ledaren hamnar överst. leader := s.run.Player @@ -356,6 +366,11 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) { } lines = append(lines, a.wrapLine(plainLine(a.T(r.FlashKey), col), ScreenW-8)...) } + if r.WrongLane { + lines = append(lines, plainLine(a.T("hud.wrong_lane"), colWarn)) + } else if r.BumpedAt >= 0 && r.Time-r.BumpedAt < 1.0 { + lines = append(lines, plainLine(a.T("hud.bump"), colDim)) + } if debuffs := g.Profile.Debuffs; len(debuffs) > 0 && r.Time < 6 { var parts []string for _, d := range debuffs { diff --git a/internal/game/scene_result.go b/internal/game/scene_result.go index 1efe3f0..9e420d1 100644 --- a/internal/game/scene_result.go +++ b/internal/game/scene_result.go @@ -82,8 +82,8 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) { sc := run.Score a.Draw(screen, fmt.Sprintf("%s %d", a.T("result.score"), sc.Total), 28, y, colText) y += LineH - a.Draw(screen, fmt.Sprintf("%s %d %s %d %s %d %s %d %s %d", strings.ToLower(a.T("hud.time")), sc.Time, a.T("result.money"), sc.Money, - a.T("result.meters"), sc.Meters, a.T("result.map"), sc.Explore, a.T("result.penalty"), sc.Penalty), 28, y, colDim) + a.Draw(screen, fmt.Sprintf("%s %d %s %d %s %d %s %d %s %d %s %d", strings.ToLower(a.T("hud.time")), sc.Time, a.T("result.money"), sc.Money, + a.T("result.meters"), sc.Meters, a.T("result.map"), sc.Explore, a.T("result.car"), sc.Car, a.T("result.penalty"), sc.Penalty), 28, y, colDim) y += LineH msg := fmt.Sprintf("+%d %s", run.PointsEarned, a.T("result.lang_points")) if len(run.LearnedDest) > 0 { diff --git a/internal/game/trains.go b/internal/game/trains.go index aad487d..e4f1318 100644 --- a/internal/game/trains.go +++ b/internal/game/trains.go @@ -40,6 +40,7 @@ func (s *LevelScene) buildTracks() { 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}) + out := level.Point{X: tt.X - p.X, Y: tt.Y - p.Y} // riktningen bort från perrongen for k := -railsReach; k <= railsReach; k++ { c := tt if horizontal { @@ -47,7 +48,9 @@ func (s *LevelScene) buildTracks() { } else { c.Y += k } - if s.run.Level.At(c) == level.Wall && (c.X == 0 || c.Y == 0 || c.X == s.run.Level.Width()-1 || c.Y == s.run.Level.Height()-1) { + beyond := level.Point{X: c.X + out.X, Y: c.Y + out.Y, Z: c.Z} + // Räls på väggrutor längs spåret, så länge det är vägg (eller kartkant) bortom dem. + if s.run.Level.At(c) == level.Wall && s.run.Level.At(beyond) == level.Wall { s.rails[c] = true } } @@ -158,11 +161,12 @@ func (s *LevelScene) updateEffects(g *Game) { } s.lastPhase[tr.tile] = phase dist := math.Hypot(float64(tr.platform.X*TileSize)-px, float64(tr.platform.Y*TileSize)-py) / TileSize + mask := noiseMask(r.Level.Noise) switch phase { case level.Approaching: - g.Audio.Play(SoundApproach, volumeAt(dist)) + g.Audio.Play(SoundApproach, volumeAt(dist)*mask) case level.Closing: - g.Audio.Play(SoundDoors, volumeAt(dist)) + g.Audio.Play(SoundDoors, volumeAt(dist)*mask) } } // Vind längs perrongen när tåget rullar in eller ut (och strax före). diff --git a/internal/level/level.go b/internal/level/level.go index 0fa2aea..fcb5999 100644 --- a/internal/level/level.go +++ b/internal/level/level.go @@ -33,14 +33,19 @@ const ( Kiosk Tile = 'K' // kiosk/automat för mat och dryck StairsUp Tile = 'U' // trappa upp: leder till 'D' på samma x,y en våning upp StairsDown Tile = 'D' // trappa ner: leder till 'U' på samma x,y en våning ner + LaneLeft Tile = '<' // fil för gång uppåt/åt vänster (bredare gångar: håll till vänster) + LaneRight Tile = '>' // fil för gång neråt/åt höger ) var knownTiles = map[Tile]bool{ Wall: true, Floor: true, Spawn: true, Platform: true, WrongPlatform: true, Gate: true, TicketMachine: true, Sign: true, Toilet: true, Kiosk: true, - StairsUp: true, StairsDown: true, + StairsUp: true, StairsDown: true, LaneLeft: true, LaneRight: true, } +// IsLane rapporterar om rutan är en markerad fil. +func (t Tile) IsLane() bool { return t == LaneLeft || t == LaneRight } + // IsStairs rapporterar om rutan är en trappa. func (t Tile) IsStairs() bool { return t == StairsUp || t == StairsDown } @@ -162,13 +167,34 @@ func (s Schedule) Present(t float64) bool { // 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 - Track string `json:"track"` // spårnummer som visas på skyltar/HUD, t.ex. "2" - Line Text `json:"line"` // linjens namn - Destination Text `json:"destination"` // slutstation - Correct bool `json:"correct"` // tar spelaren till rätt resmål - Crowded bool `json:"crowded"` // fullt tåg: minispel för att få plats - Schedule Schedule `json:"schedule"` + Platform string `json:"platform"` // vilket perrong-tecken (P/p) tåget står vid + Track string `json:"track"` // spårnummer som visas på skyltar/HUD, t.ex. "2" + Line Text `json:"line"` // linjens namn + Destination Text `json:"destination"` // slutstation + Correct bool `json:"correct"` // tar spelaren till rätt resmål + Crowded bool `json:"crowded"` // fullt tåg: minispel för att få plats (gäller alla vagnar) + Cars int `json:"cars"` // antal vagnar (0 = DefaultCars) + WomenOnlyCar int `json:"womenOnlyCar"` // 1-baserat vagnnummer för kvinnovagn, 0 = ingen + WomenOnlyFrom int `json:"womenOnlyFrom"` // kvinnovagn gäller från den här bantiden (s); 0 och To=0 = alltid + WomenOnlyTo int `json:"womenOnlyTo"` // …till den här bantiden (s) + Schedule Schedule `json:"schedule"` +} + +// DefaultCars och gränser för vagnar. +const ( + DefaultCars = 3 + MaxCars = 8 +) + +// WomenOnlyActive rapporterar om kvinnovagnen gäller vid bantiden t. +func (tr *Train) WomenOnlyActive(t float64) bool { + if tr.WomenOnlyCar == 0 { + return false + } + if tr.WomenOnlyFrom == 0 && tr.WomenOnlyTo == 0 { + return true + } + return t >= float64(tr.WomenOnlyFrom) && t < float64(tr.WomenOnlyTo) } // SignText är texten på en skyltruta ('i'). @@ -199,7 +225,10 @@ type Level struct { Order int `json:"order"` // huvudbanornas ordning på bankartan (1, 2, …); sidobanor ärver förälderns Parent string `json:"parent"` // sidobanans huvudbana – låses upp när den är klarad Rewards Rewards `json:"rewards"` - Shop []string `json:"shop"` // items som kiosken säljer på banan (tom = seedat urval) + Shop []string `json:"shop"` // items som kiosken säljer på banan (tom = seedat urval) + Crowd int `json:"crowd"` // folk på stationen 0–3 (NPC:er) + Clutter int `json:"clutter"` // visuell röra 0–3 (affischer, lockskyltar) + Noise int `json:"noise"` // ljudröra 0–3 (bakgrundsprat) Name Text `json:"name"` Difficulty int `json:"difficulty"` // 1 (lätt) … 5 (svår) Destination Text `json:"destination"` // dit spelaren ska @@ -402,6 +431,15 @@ func (l *Level) Validate() error { if tr.Correct { correct++ } + if tr.Cars == 0 { + tr.Cars = DefaultCars + } + if tr.Cars < 1 || tr.Cars > MaxCars { + return fmt.Errorf("level %s: tåg %d har %d vagnar (1–%d)", l.ID, i, tr.Cars, MaxCars) + } + if tr.WomenOnlyCar < 0 || tr.WomenOnlyCar > tr.Cars || tr.WomenOnlyFrom < 0 || tr.WomenOnlyTo < tr.WomenOnlyFrom { + return fmt.Errorf("level %s: tåg %d har ogiltig kvinnovagn (%d av %d, %d–%d s)", l.ID, i, tr.WomenOnlyCar, tr.Cars, tr.WomenOnlyFrom, tr.WomenOnlyTo) + } sc := &tr.Schedule if sc.IntervalSec == 0 { sc.IntervalSec = DefaultIntervalSec @@ -443,6 +481,11 @@ func (l *Level) Validate() error { if l.MaxMissedTrains < 0 || l.TimeLimitSec < 0 { return fmt.Errorf("level %s: negativ tidsgräns/maxantal missade tåg", l.ID) } + for name, v := range map[string]int{"crowd": l.Crowd, "clutter": l.Clutter, "noise": l.Noise} { + if v < 0 || v > 3 { + return fmt.Errorf("level %s: %s måste vara 0–3", l.ID, name) + } + } if l.Party > MaxParty { return fmt.Errorf("level %s: sällskapet får vara högst %d", l.ID, MaxParty) } diff --git a/internal/level/level_test.go b/internal/level/level_test.go index a80a7df..5202fe5 100644 --- a/internal/level/level_test.go +++ b/internal/level/level_test.go @@ -515,3 +515,39 @@ func TestFloorsValidation(t *testing.T) { } } } + +func TestCarsWomenOnlyAndCrowdFields(t *testing.T) { + l := mustParse(t, sample) + tr := l.Trains[0] + if tr.Cars != DefaultCars || tr.WomenOnlyCar != 0 || tr.WomenOnlyActive(10) || l.Crowd != 0 { + t.Fatal("standardvärden") + } + src := strings.Replace(sample, `"correct": true`, `"correct": true, "cars": 4, "womenOnlyCar": 1, "womenOnlyFrom": 30, "womenOnlyTo": 90`, 1) + src = strings.Replace(src, `"difficulty": 1`, `"difficulty": 1, "crowd": 2, "clutter": 3, "noise": 1`, 1) + l2 := mustParse(t, src) + tr2 := l2.Trains[0] + if tr2.Cars != 4 || tr2.WomenOnlyCar != 1 || tr2.WomenOnlyActive(10) || !tr2.WomenOnlyActive(30) || !tr2.WomenOnlyActive(89.9) || tr2.WomenOnlyActive(90) { + t.Fatal("kvinnovagnens tidsfönster") + } + always := mustParse(t, strings.Replace(sample, `"correct": true`, `"correct": true, "womenOnlyCar": 2`, 1)) + if !always.Trains[0].WomenOnlyActive(0) || !always.Trains[0].WomenOnlyActive(99999) { + t.Fatal("utan fönster gäller kvinnovagnen alltid") + } + if l2.Crowd != 2 || l2.Clutter != 3 || l2.Noise != 1 { + t.Fatal("crowd/clutter/noise") + } + for name, src := range map[string]string{ + "för många vagnar": strings.Replace(sample, `"correct": true`, `"correct": true, "cars": 9`, 1), + "kvinnovagn > vagnar": strings.Replace(sample, `"correct": true`, `"correct": true, "cars": 2, "womenOnlyCar": 3`, 1), + "fönster baklänges": strings.Replace(sample, `"correct": true`, `"correct": true, "womenOnlyCar": 1, "womenOnlyFrom": 50, "womenOnlyTo": 10`, 1), + "crowd 4": strings.Replace(sample, `"difficulty": 1`, `"difficulty": 1, "crowd": 4`, 1), + } { + if _, err := Parse([]byte(src)); err == nil { + t.Errorf("%s: förväntade fel", name) + } + } + lanes := mustParse(t, strings.Replace(sample, `"#S..#P#"`, `"#S<>#P#"`, 1)) + if lanes.FloorAt(0, 2, 1) != LaneLeft || !LaneRight.IsLane() || !LaneLeft.Passable() || Floor.IsLane() { + t.Fatal("filer") + } +} diff --git a/internal/minigame/carpick.go b/internal/minigame/carpick.go new file mode 100644 index 0000000..04cab1d --- /dev/null +++ b/internal/minigame/carpick.go @@ -0,0 +1,84 @@ +package minigame + +import "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng" + +// CarPick är vagnvalet på perrongen: folk kliver av först, sedan väljer man +// vagn. Varje vagn har en seedad trängselnivå; en vagn kan vara kvinnovagn. +type CarPick struct { + base + Cars []Car + Cursor int + Alighting float64 // sekunder kvar innan man får kliva på (folk kliver av) + Early int // för tidiga försök + Chosen *Car +} + +// Car är en vagn. +type Car struct { + Index int // 1-baserat vagnnummer + Crowd int // 0 lite folk, 1 mellan, 2 mycket + WomenOnly bool // kvinnovagn (just nu) +} + +// AlightSec är hur länge folk kliver av innan man får gå på. +const AlightSec = 1.5 + +// Trängselnivåernas namn (textnycklar "car.crowd."). +var CrowdNames = []string{"low", "mid", "high"} + +// NewCarPick seedar vagnarnas trängsel. womenOnly är 1-baserat vagnnummer +// (0 = ingen). Minst en vagn har lite folk när tåget har tre eller fler. +func NewCarPick(seed rng.Seed, cars int, womenOnly int, alightSec float64) *CarPick { + r := newRand(seed, "carpick") + g := &CarPick{Alighting: alightSec} + cars = max(1, cars) + for i := 1; i <= cars; i++ { + c := Car{Index: i, WomenOnly: i == womenOnly} + switch x := r.float(); { + case x < 0.35: + c.Crowd = 0 + case x < 0.75: + c.Crowd = 1 + default: + c.Crowd = 2 + } + g.Cars = append(g.Cars, c) + } + if cars >= 3 { + hasLow := false + for _, c := range g.Cars { + if c.Crowd == 0 { + hasLow = true + } + } + if !hasLow { + g.Cars[r.intn(cars)].Crowd = 0 + } + } + g.Cursor = cars / 2 + return g +} + +// Update låter folk kliva av, flyttar markören och väljer. +func (g *CarPick) Update(in Input, dt float64) { + if g.done { + return + } + if in.Cancel { + g.done = true + return + } + if g.Alighting > 0 { + g.Alighting -= dt + if in.Confirm { + g.Early++ // man ska låta folk kliva av först + } + return + } + g.Cursor = wrap(g.Cursor+in.MoveX, len(g.Cars)) + if in.Confirm { + c := g.Cars[g.Cursor] + g.Chosen = &c + g.done, g.success = true, true + } +} diff --git a/internal/minigame/crowd.go b/internal/minigame/crowd.go index e407f43..a813484 100644 --- a/internal/minigame/crowd.go +++ b/internal/minigame/crowd.go @@ -18,10 +18,12 @@ type Crowd struct { const crowdZoneWidth = 0.22 // NewCrowd seedar zonens läge och markörens fart. luck (1 = normalt) gör -// zonen bredare eller smalare, inom rimliga gränser. -func NewCrowd(seed rng.Seed, tries int, luck float64) *Crowd { +// zonen bredare eller smalare, och crowdLevel (0 lite, 1 mellan, 2 mycket +// folk i vagnen) smalare ju fler som trängs. +func NewCrowd(seed rng.Seed, tries int, luck float64, crowdLevel int) *Crowd { r := newRand(seed, "crowd") - width := min(0.6, max(0.1, crowdZoneWidth*max(0.5, min(2, luck)))) + level := 1.3 - 0.4*float64(max(0, min(2, crowdLevel))) + width := min(0.6, max(0.1, crowdZoneWidth*max(0.5, min(2, luck))*level)) lo := r.between(0.1, 0.9-width) return &Crowd{dir: 1, Speed: r.between(0.9, 1.5), ZoneLo: lo, ZoneHi: lo + width, MaxTries: max(1, tries)} } diff --git a/internal/minigame/minigame_test.go b/internal/minigame/minigame_test.go index c660266..50595dd 100644 --- a/internal/minigame/minigame_test.go +++ b/internal/minigame/minigame_test.go @@ -1,7 +1,6 @@ package minigame import ( - "math" "strings" "testing" @@ -114,8 +113,8 @@ func TestTicketBoardSeededAndAffordable(t *testing.T) { } func TestCrowdZoneAndAttempts(t *testing.T) { - g := NewCrowd(rng.FromInt(7), 2, 1) - if g.ZoneLo < 0.1 || g.ZoneHi > 0.9 || math.Abs((g.ZoneHi-g.ZoneLo)-crowdZoneWidth) > 1e-9 { + g := NewCrowd(rng.FromInt(7), 2, 1, 1) + if w := g.ZoneHi - g.ZoneLo; g.ZoneLo < 0.1 || g.ZoneHi > 0.9 || w <= 0.1 || w > 0.6 { t.Fatalf("zon %.2f–%.2f", g.ZoneLo, g.ZoneHi) } // Vänta tills markören är utanför zonen och tryck: ett bommat försök. @@ -135,7 +134,7 @@ func TestCrowdZoneAndAttempts(t *testing.T) { t.Fatal("träff i zonen ska lyckas") } // Två bom = tåget går. - f := NewCrowd(rng.FromInt(7), 2, 1) + f := NewCrowd(rng.FromInt(7), 2, 1, 1) for n := 0; n < 2; n++ { for f.InZone() { f.Update(Input{}, frame) @@ -146,7 +145,7 @@ func TestCrowdZoneAndAttempts(t *testing.T) { t.Fatal("två bom ska misslyckas") } // Markören studsar och håller sig i 0..1. - b := NewCrowd(rng.FromInt(8), 1, 1) + b := NewCrowd(rng.FromInt(8), 1, 1, 1) for i := 0; i < 600; i++ { b.Update(Input{}, frame) if b.Pos < 0 || b.Pos > 1 { @@ -157,11 +156,11 @@ func TestCrowdZoneAndAttempts(t *testing.T) { func TestRideStopsAndGettingOff(t *testing.T) { fillers := []string{"東京", "上野", "新宿", "成田"} - g := NewRide(rng.FromInt(3), "渋谷", fillers, 4, true) + g := NewRide(rng.FromInt(3), "渋谷", fillers, 4, true, 0) if len(g.Stops) != 4 || g.Dest < 1 || g.Stops[g.Dest] != "渋谷" { t.Fatalf("stops=%v dest=%d", g.Stops, g.Dest) } - h := NewRide(rng.FromInt(3), "渋谷", fillers, 4, true) + h := NewRide(rng.FromInt(3), "渋谷", fillers, 4, true, 0) if strings.Join(g.Stops, ",") != strings.Join(h.Stops, ",") { t.Fatal("samma seed = samma linje") } @@ -174,13 +173,13 @@ func TestRideStopsAndGettingOff(t *testing.T) { t.Fatal("kliva av på resmålet ska lyckas") } // Kliva av för tidigt. - e := NewRide(rng.FromInt(3), "渋谷", fillers, 4, true) + e := NewRide(rng.FromInt(3), "渋谷", fillers, 4, true, 0) e.Update(Input{Confirm: true}, frame) if !e.Done() || e.Success() || e.GotOff != 0 { t.Fatal("fel station = misslyckat") } // Åka förbi. - m := NewRide(rng.FromInt(3), "渋谷", fillers, 4, true) + m := NewRide(rng.FromInt(3), "渋谷", fillers, 4, true, 0) for i := 0; i < 60*20 && !m.Done(); i++ { m.Update(Input{}, frame) } @@ -188,7 +187,7 @@ func TestRideStopsAndGettingOff(t *testing.T) { t.Fatalf("åka förbi: done=%v success=%v index=%d", m.Done(), m.Success(), m.Index) } // Fel tåg: resmålet finns inte på linjen, så det går aldrig att lyckas. - w := NewRide(rng.FromInt(3), "渋谷", fillers, 4, false) + w := NewRide(rng.FromInt(3), "渋谷", fillers, 4, false, 0) if w.Dest != -1 || strings.Contains(strings.Join(w.Stops, ","), "渋谷") { t.Fatalf("fel tåg: %v", w.Stops) } @@ -199,7 +198,7 @@ func TestRideStopsAndGettingOff(t *testing.T) { } func TestRideWithoutFillersStillHasDestination(t *testing.T) { - g := NewRide(rng.FromInt(1), "渋谷", nil, 3, true) + g := NewRide(rng.FromInt(1), "渋谷", nil, 3, true, 0) if len(g.Stops) != 3 || g.Dest < 1 || g.Stops[g.Dest] != "渋谷" || g.Stops[0] != UnknownStop { t.Fatalf("stops=%v dest=%d", g.Stops, g.Dest) } @@ -295,14 +294,118 @@ func TestSeatSelectionPreferences(t *testing.T) { } func TestCrowdLuckWidensZone(t *testing.T) { - normal := NewCrowd(rng.FromInt(3), 2, 1) - lucky := NewCrowd(rng.FromInt(3), 2, 1.5) - unlucky := NewCrowd(rng.FromInt(3), 2, 0.5) + normal := NewCrowd(rng.FromInt(3), 2, 1, 1) + lucky := NewCrowd(rng.FromInt(3), 2, 1.5, 1) + unlucky := NewCrowd(rng.FromInt(3), 2, 0.5, 1) if lucky.ZoneHi-lucky.ZoneLo <= normal.ZoneHi-normal.ZoneLo || unlucky.ZoneHi-unlucky.ZoneLo >= normal.ZoneHi-normal.ZoneLo { t.Fatal("tur ska ge bredare zon, otur smalare") } - huge := NewCrowd(rng.FromInt(3), 2, 100) + huge := NewCrowd(rng.FromInt(3), 2, 100, 1) if huge.ZoneHi-huge.ZoneLo > 0.6+1e-9 || huge.ZoneHi > 0.9+1e-9 { t.Fatal("zonen klampas") } } + +func TestCarPickAlightingAndChoice(t *testing.T) { + g := NewCarPick(rng.FromInt(5), 4, 1, AlightSec) + h := NewCarPick(rng.FromInt(5), 4, 1, AlightSec) + if len(g.Cars) != 4 || !g.Cars[0].WomenOnly || g.Cars[1].WomenOnly { + t.Fatalf("cars=%+v", g.Cars) + } + for i := range g.Cars { + if g.Cars[i] != h.Cars[i] { + t.Fatal("samma seed = samma trängsel") + } + if g.Cars[i].Crowd < 0 || g.Cars[i].Crowd > 2 { + t.Fatal("trängsel 0–2") + } + } + hasLow := false + for _, c := range g.Cars { + hasLow = hasLow || c.Crowd == 0 + } + if !hasLow { + t.Fatal("minst en vagn med lite folk") + } + // För tidigt: folk kliver av först. + g.Update(Input{Confirm: true}, frame) + g.Update(Input{Confirm: true}, frame) + if g.Done() || g.Early != 2 { + t.Fatalf("early=%d done=%v", g.Early, g.Done()) + } + for g.Alighting > 0 { + g.Update(Input{}, frame) + } + g.Update(Input{MoveX: 1}, frame) + before := g.Cursor + g.Update(Input{Confirm: true}, frame) + if !g.Done() || !g.Success() || g.Chosen == nil || g.Chosen.Index != before+1 { + t.Fatalf("val: chosen=%+v cursor=%d", g.Chosen, before) + } + c := NewCarPick(rng.FromInt(5), 1, 0, 0) + if len(c.Cars) != 1 || c.Cars[0].WomenOnly { + t.Fatal("ett tåg med en vagn") + } + c.Update(Input{Cancel: true}, frame) + if !c.Done() || c.Success() { + t.Fatal("Esc avbryter") + } +} + +func TestCrowdLevelNarrowsZone(t *testing.T) { + w := func(level int) float64 { + g := NewCrowd(rng.FromInt(2), 2, 1, level) + return g.ZoneHi - g.ZoneLo + } + if !(w(0) > w(1) && w(1) > w(2)) { + t.Fatalf("zon: %.2f %.2f %.2f", w(0), w(1), w(2)) + } +} + +func TestRideCrowdLetPass(t *testing.T) { + fillers := []string{"東京", "上野", "新宿", "成田", "品川"} + g := NewRide(rng.FromInt(6), "渋谷", fillers, 5, true, 2) + if g.StopSec != RideStopSec(2) || RideStopSec(0) != 2.5 || RideStopSec(2) != 1.5 { + t.Fatal("tid per station") + } + wants := 0 + for i, w := range g.WantsOut { + if w { + wants++ + if i == g.Dest { + t.Fatal("ingen vill ut vid resmålet") + } + } + } + if wants == 0 { + t.Fatal("med mycket folk vill någon förbi") + } + // Släpp förbi vid första stationen som vill, blockera nästa. + released, blockedSeen := false, false + for i := 0; i < 60*60 && !g.Done(); i++ { + in := Input{} + if g.NeedsLetPass() && !released { + in.MoveX = -1 + released = true + } + if g.Index == g.Dest { + in.Confirm = true + } + g.Update(in, frame) + if g.Blocked > 0 { + blockedSeen = true + } + } + if !g.Done() || !g.Success() { + t.Fatal("ska ändå komma fram") + } + if wants >= 2 && !blockedSeen { + t.Fatalf("minst en station ska ha blockerats: blocked=%d wants=%d", g.Blocked, wants) + } + calm := NewRide(rng.FromInt(6), "渋谷", fillers, 5, true, 0) + for _, w := range calm.WantsOut { + if w { + t.Fatal("utan trängsel vill ingen förbi") + } + } +} diff --git a/internal/minigame/ride.go b/internal/minigame/ride.go index 6ccc209..07ed416 100644 --- a/internal/minigame/ride.go +++ b/internal/minigame/ride.go @@ -12,18 +12,31 @@ type Ride struct { Timer float64 // sekunder kvar vid aktuell station StopSec float64 GotOff int // index där spelaren klev av, -1 = inte än + + // Trängsel: vid stationer där man inte ska av vill andra passagerare ut. + Crowd int // vagnens trängselnivå 0–2 + WantsOut []bool // per station: någon vill förbi + LetPass bool // spelaren har släppt förbi vid aktuell station + Blocked int // stationer där ingen släpptes förbi (avdrag) } +// Tid per station efter trängsel: 2,5 / 2,0 / 1,5 s. const rideStopSec = 2.5 +// RideStopSec är tiden per station för en trängselnivå. +func RideStopSec(crowd int) float64 { return rideStopSec - 0.5*float64(max(0, min(2, crowd))) } + // NewRide bygger linjen: fillers är andra stationsnamn (blandas), dest läggs // på en seedad plats (aldrig först) om det är rätt tåg, annars utelämnas det. -func NewRide(seed rng.Seed, dest string, fillers []string, stops int, correctTrain bool) *Ride { +// crowd (0–2) ger kortare tid per station och passagerare som vill förbi. +func NewRide(seed rng.Seed, dest string, fillers []string, stops int, correctTrain bool, crowd int) *Ride { r := newRand(seed, "ride") f := append([]string(nil), fillers...) r.shuffle(len(f), func(i, j int) { f[i], f[j] = f[j], f[i] }) stops = max(2, stops) - g := &Ride{StopSec: rideStopSec, Timer: rideStopSec, GotOff: -1, Dest: -1} + crowd = max(0, min(2, crowd)) + sec := RideStopSec(crowd) + g := &Ride{StopSec: sec, Timer: sec, GotOff: -1, Dest: -1, Crowd: crowd} if correctTrain { g.Dest = 1 + r.intn(stops-1) } @@ -38,9 +51,19 @@ func NewRide(seed rng.Seed, dest string, fillers []string, stops int, correctTra g.Stops = append(g.Stops, UnknownStop) // slut på namn: en namnlös station } } + g.WantsOut = make([]bool, len(g.Stops)) + chance := [...]float64{0, 0.4, 0.7}[crowd] + for i := range g.Stops { + if i != g.Dest && r.float() < chance { + g.WantsOut[i] = true + } + } return g } +// NeedsLetPass rapporterar om någon vill förbi vid aktuell station just nu. +func (g *Ride) NeedsLetPass() bool { return g.WantsOut[g.Index] && !g.LetPass } + // UnknownStop används när det inte finns fler stationsnamn att ta av. const UnknownStop = "・・・" @@ -58,10 +81,17 @@ func (g *Ride) Update(in Input, dt float64) { g.success = g.Index == g.Dest return } + if in.MoveX < 0 && g.NeedsLetPass() { + g.LetPass = true // kliv åt sidan + } g.Timer -= dt if g.Timer <= 0 { + if g.NeedsLetPass() { + g.Blocked++ + } g.Index++ g.Timer = g.StopSec + g.LetPass = false if g.Index >= len(g.Stops) { g.Index = len(g.Stops) - 1 g.done = true // åkte till slutstationen utan att kliva av diff --git a/internal/sim/npc.go b/internal/sim/npc.go new file mode 100644 index 0000000..05819d4 --- /dev/null +++ b/internal/sim/npc.go @@ -0,0 +1,144 @@ +package sim + +import ( + "math/rand/v2" + + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng" +) + +// NPC är en passagerare som rör sig i stationen: vandrar mellan platser, +// eller har klivit av ett tåg och är på väg till utgången. NPC:er tar plats +// i rutnätet – spelaren kan inte gå in i dem – men går själva igenom +// varandra och spelaren, så att ingen kan låsa fast någon annan. +type NPC struct { + Mover *Mover + Target level.Point + Kind NPCKind + Pause float64 // sekunder kvar att stå still + path []level.Point + Done bool // på väg ut och framme: tas bort +} + +// NPCKind är vad passageraren håller på med. +type NPCKind int + +// Slag av passagerare. +const ( + Wander NPCKind = iota // går mellan kiosker, toaletter, skyltar och perronger + Exit // kom med tåget, går mot utgången (startrutan) +) + +// Folk: antal per trängselnivå och fart. +const ( + NPCPerCrowd = 2 // vandrare per crowd-steg vid banstart + NPCAlightBase = 1 // extra avstigande per tåg (+ crowd) + NPCSpeed = 3.5 // rutor/s + NPCPauseMinSec = 1.0 + NPCPauseMaxSec = 3.0 +) + +// npcTargets är rutor som folk vill till. +func npcTargets(l *level.Level) []level.Point { + var out []level.Point + for _, t := range []level.Tile{level.Kiosk, level.Toilet, level.Sign, level.TicketMachine, level.Platform, level.WrongPlatform, level.Spawn} { + out = append(out, l.Find(t)...) + } + return out +} + +// spawnNPCs skapar banans vandrare på slumpade golvrutor (seedat). +func spawnNPCs(l *level.Level, n int, r *rand.Rand) []*NPC { + var floorTiles []level.Point + for _, t := range []level.Tile{level.Floor, level.LaneLeft, level.LaneRight} { + floorTiles = append(floorTiles, l.Find(t)...) + } + targets := npcTargets(l) + var out []*NPC + for i := 0; i < n && len(floorTiles) > 0 && len(targets) > 0; i++ { + start := floorTiles[r.IntN(len(floorTiles))] + npc := &NPC{Mover: NewMover(start, NPCSpeed), Kind: Wander, Target: targets[r.IntN(len(targets))]} + out = append(out, npc) + } + return out +} + +// step för en NPC framåt: följer sin väg, pausar vid målet och väljer nytt. +func (n *NPC) step(l *level.Level, dt float64, r *rand.Rand) { + if n.Done { + return + } + if n.Pause > 0 { + n.Pause -= dt + return + } + if !n.Mover.Moving() && n.Mover.Tile == n.Target { + n.arrived(l, r) + return + } + if len(n.path) < 2 || n.path[0] != n.Mover.Tile { + n.path = l.Path(n.Mover.Tile, n.Target, l.CanEnter) + } + if len(n.path) < 2 { + n.arrived(l, r) // framme eller oåtkomligt mål: välj nytt + return + } + next := n.path[1] + if n.Mover.Tile == next || (next.Z != n.Mover.Tile.Z) { + n.path = n.path[1:] + return + } + want := Right + switch { + case next.X < n.Mover.Tile.X: + want = Left + case next.Y > n.Mover.Tile.Y: + want = Down + case next.Y < n.Mover.Tile.Y: + want = Up + } + if n.Mover.Moving() { + want = None + if n.Mover.Target == next { + want = n.Mover.Facing + } + } + before := n.Mover.Tile + // Bara nästa ruta på vägen får påbörjas: annars fortsätter resten av + // bildrutans rörelse rakt fram förbi hörn och mål, och NPC:n pendlar + // kring rutor den aldrig stannar exakt på. + onlyNext := func(p level.Point) bool { return p == next && l.CanEnter(p) } + n.Mover.Step(dt, want, onlyNext) + if n.Mover.Tile != before { + switch l.At(n.Mover.Tile) { // trappor flyttar även folk + case level.StairsUp: + n.Mover.Tile = n.Mover.Tile.Up() + n.Mover.Target = n.Mover.Tile + case level.StairsDown: + n.Mover.Tile = n.Mover.Tile.Down() + n.Mover.Target = n.Mover.Tile + } + n.path = nil + } +} + +func (n *NPC) arrived(l *level.Level, r *rand.Rand) { + if n.Kind == Exit { + n.Done = true + return + } + targets := npcTargets(l) + if len(targets) > 0 { + n.Target = targets[r.IntN(len(targets))] + } + n.Pause = NPCPauseMinSec + (NPCPauseMaxSec-NPCPauseMinSec)*r.Float64() + n.path = nil +} + +// Occupies rapporterar om NPC:n står på eller är på väg in i rutan. +func (n *NPC) Occupies(p level.Point) bool { + return !n.Done && (n.Mover.Tile == p || (n.Mover.Moving() && n.Mover.Target == p)) +} + +// newNPCRand ger slumpen för folket. +func newNPCRand(seed rng.Seed) *rand.Rand { return seed.Derive("npc").New() } diff --git a/internal/sim/npc_test.go b/internal/sim/npc_test.go new file mode 100644 index 0000000..0cf672f --- /dev/null +++ b/internal/sim/npc_test.go @@ -0,0 +1,115 @@ +package sim + +import ( + "testing" + + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng" +) + +func TestNPCsWanderStayOnFloorAndBlockPlayer(t *testing.T) { + l := loadLevel(t, "005-ueno") + if l.Crowd == 0 { + t.Fatal("005 ska ha folk") + } + r := NewRun(l, RunOptions{Seed: rng.FromInt(3)}) + if len(r.NPCs) != l.Crowd*NPCPerCrowd { + t.Fatalf("npcs=%d, vill ha %d", len(r.NPCs), l.Crowd*NPCPerCrowd) + } + moved := 0 + start := make([]level.Point, len(r.NPCs)) + for i, n := range r.NPCs { + start[i] = n.Mover.Tile + } + for i := 0; i < 60*20; i++ { + r.Step(frame, None, false) + for _, n := range r.NPCs { + if !l.At(n.Mover.Tile).Passable() { + t.Fatalf("NPC på vägg: %v", n.Mover.Tile) + } + } + } + for i := range start { // vandrarna ligger först; avstigande kan ha tillkommit efter dem + if r.NPCs[i].Mover.Tile != start[i] { + moved++ + } + } + if moved == 0 { + t.Fatal("ingen NPC rörde sig på 20 s") + } + // Spelaren kan inte gå in i en NPC. + npc := r.NPCs[0] + npc.Mover.Tile, npc.Mover.Target, npc.Pause = level.Point{X: 2, Y: 1}, level.Point{X: 2, Y: 1}, 1e9 + if r.CanEnter(level.Point{X: 2, Y: 1}) { + t.Fatal("rutan med en NPC är upptagen") + } + r.Step(frame, Right, false) + if r.Player.Moving() || r.BumpedAt < 0 { + t.Fatal("att gå in i någon stoppar en och noteras") + } + // Determinism. + a := NewRun(l, RunOptions{Seed: rng.FromInt(4)}) + b := NewRun(l, RunOptions{Seed: rng.FromInt(4)}) + for i := 0; i < 60*5; i++ { + a.Step(frame, None, false) + b.Step(frame, None, false) + } + for i := range a.NPCs { + if a.NPCs[i].Mover.Tile != b.NPCs[i].Mover.Tile { + t.Fatal("folket ska vara seedat") + } + } +} + +func TestAlightingNPCsSpawnAndLeave(t *testing.T) { + l := loadLevel(t, "005-ueno") + r := NewRun(l, RunOptions{Seed: rng.FromInt(5)}) + r.Party.nextEventAt = 1e9 + base := len(r.NPCs) + // Spola fram tills första tåget har öppnat dörrarna. + for i := 0; i < 60*60; i++ { + r.Step(frame, None, false) + if ph, _ := r.PlatformPhase(level.Platform); ph == level.Dwelling { + break + } + } + exits := 0 + for _, n := range r.NPCs { + if n.Kind == Exit { + exits++ + } + } + if exits != NPCAlightBase+l.Crowd { + t.Fatalf("avstigande=%d, vill ha %d", exits, NPCAlightBase+l.Crowd) + } + // De går mot utgången och försvinner. + for i := 0; i < 60*120 && len(r.NPCs) > base; i++ { + r.Step(frame, None, false) + } + if len(r.NPCs) != base { + t.Fatalf("avstigande ska ha lämnat stationen: %d kvar", len(r.NPCs)-base) + } +} + +func TestLanesSlowWrongSide(t *testing.T) { + src := `{"id":"lanes","name":{"sv":"x"},"difficulty":1,"destination":{"sv":"x"}, + "grid":["#######","#S<<= DefaultSpeed || !r.WrongLane { + t.Fatalf("fel sida ska bromsa: speed=%.2f wrong=%v", r.Player.Speed, r.WrongLane) + } + for r.Player.Moving() { + r.Step(frame, None, false) + } + r.Step(frame, Left, false) + if r.Player.Speed != DefaultSpeed || r.WrongLane { + t.Fatalf("rätt sida full fart: speed=%.2f wrong=%v", r.Player.Speed, r.WrongLane) + } +} diff --git a/internal/sim/party.go b/internal/sim/party.go index 11eb96b..29d5ebf 100644 --- a/internal/sim/party.go +++ b/internal/sim/party.go @@ -45,6 +45,7 @@ func (m Meters) Lowest() (Urge, float64) { // Member är en i sällskapet. Members[0] är spelaren själv. type Member struct { Name string + Gender string // "f", "m" eller "" (okänt) – kvinnovagnar bryr sig Meters Meters Urge Urge UrgeAt float64 // när behovet uppstod @@ -61,6 +62,9 @@ type Rates struct{ Thirst, Toilet float64 } // PartyNames är namnen i den ordning de följer med. var PartyNames = []string{"Du", "Anna", "Ville", "Sara", "Omar"} +// partyGenders hör ihop med PartyNames. Spelaren själv är okänd (""). +var partyGenders = []string{"", "f", "m", "f", "m"} + // Takter och trösklar. Mätarna går från 100 till 0 på ungefär 10/7/8 minuter. const ( FoodPerSec = 100.0 / 600 @@ -94,7 +98,7 @@ func NewParty(n int, start level.Point, seed rng.Seed) *Party { n = max(1, min(n, len(PartyNames))) p := &Party{rand: seed.New(), Rates: Rates{1, 1}} for i := 0; i < n; i++ { - p.Members = append(p.Members, &Member{Name: PartyNames[i], Meters: Meters{100, 100, 100}, Card: MemberCardYen}) + p.Members = append(p.Members, &Member{Name: PartyNames[i], Gender: partyGenders[i], Meters: Meters{100, 100, 100}, Card: MemberCardYen}) } p.trail = []level.Point{start} p.nextEventAt = p.interval() * (0.5 + p.rand.Float64()) @@ -194,6 +198,16 @@ func (p *Party) SpeedFactor() float64 { return f } +// HasMen rapporterar om någon i sällskapet är man (kvinnovagnen). +func (p *Party) HasMen() bool { + for _, m := range p.Members { + if m.Gender == "m" { + return true + } + } + return false +} + // Names är medlemmarnas namn i ordning. func (p *Party) Names() []string { out := make([]string, len(p.Members)) diff --git a/internal/sim/run.go b/internal/sim/run.go index 4470086..f3bd6e6 100644 --- a/internal/sim/run.go +++ b/internal/sim/run.go @@ -2,6 +2,7 @@ package sim import ( "fmt" + "math/rand/v2" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" @@ -87,11 +88,39 @@ type Run struct { Score ScoreBreakdown NewDebuffs []Debuff // debuffar banan gav (appliceras på profilen efteråt) opts RunOptions + + // Vagnar, folk och filer (M9b). + CarChosen *minigame.Car // vagnen man klev på + EarlyBoard int // försök att kliva på innan folk klivit av + WomenOnlyViolation bool // en man i sällskapet på kvinnovagnen + Blocked int // passagerare som inte släpptes förbi på resan + NPCs []*NPC + npcRand *rand.Rand + platformPhase map[level.Tile]level.Phase + BumpedAt float64 // när spelaren senast gick in i någon (< 0 = aldrig) + WrongLane bool // går på fel sida i en fil just nu } +// Poäng och avdrag för vagnar. +const ( + ScoreCarBonus = 50 // lite folk i vagnen + PenaltyWomenOnly = 200 // grova minus + PenaltyEarlyBoard = 20 // per gång man inte lät folk kliva av först + PenaltyBlocked = 20 // per passagerare som inte släpptes förbi + SweatyChance = 0.6 // chans att bli svettig i en full vagn + WrongLaneFactor = 0.7 // fart på fel sida i en fil +) + +// Fler flash-nycklar. +const ( + FlashWomenOnly = "hud.women_only" + FlashEarly = "hud.early_board" + FlashBump = "hud.bump" +) + // ScoreBreakdown är poängen för en vunnen bana. type ScoreBreakdown struct { - Time, Money, Meters, Explore, Penalty, Total int + Time, Money, Meters, Explore, Car, Penalty, Total int } // Poängformelns vikter. @@ -129,6 +158,7 @@ const ( MiniTicket MiniCrowd MiniRide + MiniCarPick ) // Förlustorsaker (textnycklar). @@ -209,26 +239,33 @@ func NewRun(l *level.Level, o RunOptions) *Run { o.Wallet = NewWallet() } r := &Run{ - Level: l, - Player: NewMover(l.SpawnPoint(), o.Speed), - Wallet: o.Wallet, - FlashAt: -1, - DeniedAt: -1, - Dict: o.Dict, - Know: o.Know, - Revealed: map[level.Point]bool{}, - LearnedAt: -1, - Seed: o.Seed, - Visits: map[level.Point]int{}, - lastTile: l.SpawnPoint(), - ProgressedAt: -1, - parSec: float64(l.ParSec), - Party: NewParty(l.Party, l.SpawnPoint(), o.Seed.Derive("party")), - baseSpeed: o.Speed, - NoticeAt: -1, - Stats: CombinedStats(o.Debuffs), - spentStart: o.Wallet.Spent, - opts: o, + Level: l, + Player: NewMover(l.SpawnPoint(), o.Speed), + Wallet: o.Wallet, + FlashAt: -1, + DeniedAt: -1, + Dict: o.Dict, + Know: o.Know, + Revealed: map[level.Point]bool{}, + LearnedAt: -1, + Seed: o.Seed, + Visits: map[level.Point]int{}, + lastTile: l.SpawnPoint(), + ProgressedAt: -1, + parSec: float64(l.ParSec), + Party: NewParty(l.Party, l.SpawnPoint(), o.Seed.Derive("party")), + baseSpeed: o.Speed, + NoticeAt: -1, + Stats: CombinedStats(o.Debuffs), + spentStart: o.Wallet.Spent, + opts: o, + platformPhase: map[level.Tile]level.Phase{}, + BumpedAt: -1, + } + r.npcRand = newNPCRand(o.Seed) + r.NPCs = spawnNPCs(l, l.Crowd*NPCPerCrowd, r.npcRand) + for _, t := range []level.Tile{level.Platform, level.WrongPlatform} { + r.platformPhase[t], _ = r.PlatformPhase(t) } if o.Extra != (Stats{}) { for i := range r.Stats { @@ -276,6 +313,12 @@ func (r *Run) CanEnter(p level.Point) bool { r.DeniedAt = r.Time return false } + for _, n := range r.NPCs { + if n.Occupies(p) { + r.BumpedAt = r.Time + return false + } + } return true } @@ -302,6 +345,19 @@ func (r *Run) canPassGate() bool { return true } +// PathCanEnter är CanEnter utan hänsyn till folk som råkar stå i vägen – +// för vägplanering (folk flyttar på sig; spärrar och väggar gör det inte). +func (r *Run) PathCanEnter(p level.Point) bool { + t := r.Level.At(p) + if !t.Passable() { + return false + } + if t == level.Gate && !r.inGate() && !r.canPassGate() { + return false + } + return true +} + // MembersWithoutFare listar följare vars egna kort inte räcker (HUD). func (r *Run) MembersWithoutFare() []*Member { if !r.Level.IndividualCards { @@ -342,6 +398,17 @@ func (r *Run) Step(dt float64, want Dir, board bool) { if r.Time < r.RescanUntil { r.Player.Speed = 0 // står och skannar om } + // Filer: på en markerad fil går man på rätt sida, annars möter man strömmen. + r.WrongLane = false + switch r.Level.At(r.Player.Tile) { + case level.LaneLeft: + r.WrongLane = want == Down || want == Right + case level.LaneRight: + r.WrongLane = want == Up || want == Left + } + if r.WrongLane { + r.Player.Speed *= WrongLaneFactor + } r.Steps += r.Player.Step(dt, want, r.CanEnter) if r.Player.Tile != before { // Trappa: hamnar man på en trappruta flyttas man till andra änden @@ -367,6 +434,7 @@ func (r *Run) Step(dt float64, want Dir, board bool) { r.stepHelp(dt) r.stepFacility(dt) r.stepReading() + r.stepNPCs(dt) r.checkLimits() if r.Player.Tile != before && r.inGate() && !wasGate { r.GatePasses++ @@ -464,20 +532,47 @@ func (r *Run) tryBoard(tr *level.Train) { return } r.PendingTrain = tr - if tr.Crowded { - r.Mini, r.MiniKind = minigame.NewCrowd(r.miniSeed(), CrowdTries, r.Stats[StatLuck]), MiniCrowd + women := 0 + if tr.WomenOnlyActive(r.Time) { + women = tr.WomenOnlyCar + } + r.Mini, r.MiniKind = minigame.NewCarPick(r.miniSeed(), tr.Cars, women, minigame.AlightSec), MiniCarPick +} + +// carPicked fortsätter ombordstigningen efter vagnvalet. +func (r *Run) carPicked(c *minigame.Car, early int) { + tr := r.PendingTrain + r.CarChosen = c + r.EarlyBoard += early + if early > 0 { + r.flash(FlashEarly) + } + if c.WomenOnly && r.Party.HasMen() { + r.WomenOnlyViolation = true + r.flash(FlashWomenOnly) + } + if tr.Crowded || c.Crowd == 2 { + r.Mini, r.MiniKind = minigame.NewCrowd(r.miniSeed(), CrowdTries, r.Stats[StatLuck], c.Crowd), MiniCrowd return } r.startRide() } +// carCrowd är trängseln i den valda vagnen (0 om inget valts). +func (r *Run) carCrowd() int { + if r.CarChosen == nil { + return 0 + } + return r.CarChosen.Crowd +} + func (r *Run) startRide() { tr := r.PendingTrain if r.Level.TicketRequired { r.Wallet.Ticket, r.Wallet.TicketFare, r.Wallet.TicketCount = "", 0, 0 // biljetterna är förbrukade } dest := r.Level.Destination.Get("ja") - r.Mini, r.MiniKind = minigame.NewRide(r.miniSeed(), dest, r.stationPool(dest, 8), r.Level.RideStops, tr.Correct), MiniRide + r.Mini, r.MiniKind = minigame.NewRide(r.miniSeed(), dest, r.stationPool(dest, 8), r.Level.RideStops, tr.Correct, r.carCrowd()), MiniRide } // StepMini för ett pågående minispel framåt; klockan och sällskapet tickar @@ -491,6 +586,7 @@ func (r *Run) StepMini(dt float64, in minigame.Input) { r.Wallet.Drain(dt) } r.Party.Advance(dt, r.Time) + r.stepNPCs(dt) r.Mini.Update(in, dt) if !r.Mini.Done() { r.checkLimits() @@ -531,6 +627,12 @@ func (r *Run) StepMini(dt float64, in minigame.Input) { } r.flash(FlashGrumpy) } + case *minigame.CarPick: + if g.Success() && g.Chosen != nil { + r.carPicked(g.Chosen, g.Early) + } else { + r.PendingTrain = nil // avbröt på perrongen + } case *minigame.Crowd: if g.Success() { r.startRide() @@ -542,6 +644,7 @@ func (r *Run) StepMini(dt float64, in minigame.Input) { case *minigame.Ride: tr := r.PendingTrain r.PendingTrain = nil + r.Blocked += g.Blocked switch { case g.Success(): r.finish(tr, true, "") @@ -628,8 +731,15 @@ func (r *Run) computeScore() { sc.Money = max(0, ScoreMoneyMax-(r.Wallet.Spent-r.spentStart)/10) sc.Meters = int(ScoreMetersMax * r.Party.AvgMeters()) sc.Explore = int(ScoreExploreMax * r.Fog.Explored(r.Level)) - sc.Penalty = PenaltyMissed*r.MissedTrains + PenaltyRescan*r.Rescans + PenaltyGrumpy*r.Grumpy - sc.Total = max(0, sc.Time+sc.Money+sc.Meters+sc.Explore-sc.Penalty) + sc.Penalty = PenaltyMissed*r.MissedTrains + PenaltyRescan*r.Rescans + PenaltyGrumpy*r.Grumpy + + PenaltyEarlyBoard*r.EarlyBoard + PenaltyBlocked*r.Blocked + if r.WomenOnlyViolation { + sc.Penalty += PenaltyWomenOnly + } + if r.CarChosen != nil && r.CarChosen.Crowd == 0 { + sc.Car = ScoreCarBonus + } + sc.Total = max(0, sc.Time+sc.Money+sc.Meters+sc.Explore+sc.Car-sc.Penalty) r.Score = sc } @@ -651,9 +761,38 @@ func (r *Run) earnedDebuffs() []Debuff { if r.poisoned { out = append(out, Debuff{FoodPoisoning, FoodPoisonLevels}) } + if r.CarChosen != nil && r.CarChosen.Crowd == 2 && r.Seed.Derive("sweaty").New().Float64() < SweatyChance/r.Stats[StatLuck] { + out = append(out, Debuff{Sweaty, 1}) + } return out } +// stepNPCs flyttar folket, låter avstigande kliva av när dörrarna öppnas och +// tar bort dem som nått utgången. +func (r *Run) stepNPCs(dt float64) { + for _, t := range []level.Tile{level.Platform, level.WrongPlatform} { + ph, _ := r.PlatformPhase(t) + if r.platformPhase[t] != ph { + r.platformPhase[t] = ph + if tiles := r.Level.Find(t); ph == level.Dwelling && len(tiles) > 0 { + // Avstigande per tåg (inte per perrongruta), fördelade längs perrongen. + for i := 0; i < NPCAlightBase+r.Level.Crowd; i++ { + p := tiles[i%len(tiles)] + r.NPCs = append(r.NPCs, &NPC{Mover: NewMover(p, NPCSpeed), Kind: Exit, Target: r.Level.SpawnPoint(), Pause: 0.3 * float64(i/len(tiles))}) + } + } + } + } + kept := r.NPCs[:0] + for _, n := range r.NPCs { + n.step(r.Level, dt, r.npcRand) + if !n.Done { + kept = append(kept, n) + } + } + r.NPCs = kept +} + // FacilityProgress är 0..1 för ett pågående toalettbesök. func (r *Run) FacilityProgress() float64 { if !r.Facility.Active || r.Facility.Total <= 0 { diff --git a/internal/sim/run_test.go b/internal/sim/run_test.go index db5f838..5f16f20 100644 --- a/internal/sim/run_test.go +++ b/internal/sim/run_test.go @@ -25,16 +25,58 @@ func fillSeats(r *Run, tk *minigame.Ticket) { } } +// pickCar väljer vagn: låter folk kliva av, väljer sedan den lugnaste vagnen +// som inte är kvinnovagn. Hanterar också ett ev. trängselminispel. +func pickCar(t *testing.T, r *Run) { + t.Helper() + g, ok := r.Mini.(*minigame.CarPick) + if !ok { + t.Fatalf("ingen vagnval pågår (kind=%v)", r.MiniKind) + } + for g.Alighting > 0 { + r.StepMini(frame, minigame.Input{}) + } + best := -1 + for i, c := range g.Cars { + if c.WomenOnly { + continue + } + if best < 0 || c.Crowd < g.Cars[best].Crowd { + best = i + } + } + if best < 0 { + best = 0 + } + for g.Cursor != best { + r.StepMini(frame, minigame.Input{MoveX: 1}) + } + r.StepMini(frame, minigame.Input{Confirm: true}) + if cr, ok := r.Mini.(*minigame.Crowd); ok { + for i := 0; i < 600 && !cr.InZone(); i++ { + r.StepMini(frame, minigame.Input{}) + } + r.StepMini(frame, minigame.Input{Confirm: true}) + } +} + // ride spelar tågresan: kliver av när tåget står vid resmålet. På fel tåg -// finns inget resmål, så resan går till slutstationen. +// finns inget resmål, så resan går till slutstationen. Släpper förbi folk. func ride(t *testing.T, r *Run) { t.Helper() + if r.MiniKind == MiniCarPick { + pickCar(t, r) + } if r.MiniKind != MiniRide { t.Fatalf("ingen tågresa pågår (kind=%v)", r.MiniKind) } for i := 0; i < 60*120 && r.Mini != nil; i++ { g := r.Mini.(*minigame.Ride) - r.StepMini(frame, minigame.Input{Confirm: g.Index == g.Dest}) + in := minigame.Input{Confirm: g.Index == g.Dest} + if g.NeedsLetPass() { + in.MoveX = -1 + } + r.StepMini(frame, in) } if r.Mini != nil { t.Fatal("resan tog aldrig slut") @@ -137,7 +179,7 @@ func TestBotWinsLevel001(t *testing.T) { l := loadLevel(t, "001-flygplatsen") r := NewRun(l, RunOptions{Seed: rng.FromInt(1)}) goal := l.Find(level.Platform)[0] - path := l.Path(r.Player.Tile, goal, r.CanEnter) + path := l.Path(r.Player.Tile, goal, r.PathCanEnter) if path == nil { t.Fatal("ingen väg till perrongen") } @@ -152,8 +194,8 @@ func TestBotWinsLevel001(t *testing.T) { t.Fatal("rätt perrong ska ha rätt tåg") } r.Step(frame, None, true) - if r.Outcome != Ongoing || r.MiniKind != MiniRide { - t.Fatalf("att kliva på ska starta tågresan: outcome=%v kind=%v", r.Outcome, r.MiniKind) + if r.Outcome != Ongoing || r.MiniKind != MiniCarPick { + t.Fatalf("att kliva på ska börja med vagnvalet: outcome=%v kind=%v", r.Outcome, r.MiniKind) } ride(t, r) if r.Outcome != Won || r.Boarded != tr { @@ -180,7 +222,7 @@ func TestBotLosesOnWrongTrain(t *testing.T) { l := loadLevel(t, "001-flygplatsen") r := NewRun(l, RunOptions{Seed: rng.FromInt(1)}) goal := l.Find(level.WrongPlatform)[0] - walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter)) + walk(t, r, l.Path(r.Player.Tile, goal, r.PathCanEnter)) tr := waitForTrain(t, r) r.Step(frame, None, true) ride(t, r) @@ -200,7 +242,7 @@ func TestGateNeedsCardBalance(t *testing.T) { if r.DeniedAt < 0 { t.Fatal("nekandet ska registreras för HUD:en") } - if l.Path(r.Player.Tile, l.Find(level.Platform)[0], r.CanEnter) != nil { + if l.Path(r.Player.Tile, l.Find(level.Platform)[0], r.PathCanEnter) != nil { t.Fatal("utan saldo ska perrongen vara onåbar") } r.Wallet.Card = l.FareYen @@ -220,7 +262,7 @@ func TestBoardOnlyWhenTrainPresent(t *testing.T) { l := loadLevel(t, "001-flygplatsen") r := NewRun(l, RunOptions{Seed: rng.FromInt(1)}) goal := l.Find(level.Platform)[0] - walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter)) + walk(t, r, l.Path(r.Player.Tile, goal, r.PathCanEnter)) // Spola fram till en tidpunkt då tåget är borta. for { _, at, _ := r.PlatformHere() @@ -239,7 +281,7 @@ func TestSignHereOnlyWhenStill(t *testing.T) { l := loadLevel(t, "001-flygplatsen") r := NewRun(l, RunOptions{Seed: rng.FromInt(1)}) sign := l.Find(level.Sign)[0] - walk(t, r, l.Path(r.Player.Tile, sign, r.CanEnter)) + walk(t, r, l.Path(r.Player.Tile, sign, r.PathCanEnter)) if sg := r.SignHere(); sg == nil || sg.Text.Get("sv") == "" { t.Fatal("stilla på skyltrutan ska ge skylten") } @@ -256,7 +298,7 @@ func TestAskHelpRevealsSignAndTeachesWord(t *testing.T) { know := lang.NewKnowledge() r := NewRun(l, RunOptions{Dict: loadDict(t), Know: know, Seed: rng.FromInt(1)}) sign := l.Find(level.Sign)[1] - walk(t, r, l.Path(r.Player.Tile, sign, r.CanEnter)) + walk(t, r, l.Path(r.Player.Tile, sign, r.PathCanEnter)) if !r.AskHelp() { t.Fatal("ska kunna fråga om hjälp på en skylt") @@ -296,7 +338,7 @@ func TestHelpIsCancelledByWalkingAway(t *testing.T) { l := loadLevel(t, "001-flygplatsen") r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(1)}) sign := l.Find(level.Sign)[1] - walk(t, r, l.Path(r.Player.Tile, sign, r.CanEnter)) + walk(t, r, l.Path(r.Player.Tile, sign, r.PathCanEnter)) r.AskHelp() r.Step(frame, Left, false) if r.Help.Active { @@ -344,7 +386,7 @@ func TestGoodResultTeachesDestination(t *testing.T) { }{{"snabb", false, 3, true}, {"långsam", true, 1, false}} { know := lang.NewKnowledge() r := NewRun(l, RunOptions{Dict: loadDict(t), Know: know, Seed: rng.FromInt(2)}) - walk(t, r, l.Path(r.Player.Tile, l.CorrectPlatform(), r.CanEnter)) + walk(t, r, l.Path(r.Player.Tile, l.CorrectPlatform(), r.PathCanEnter)) waitForTrain(t, r) if tc.slow { r.Time = 10 * r.ParSec() @@ -374,7 +416,7 @@ func TestReadingGivesSeededProgress(t *testing.T) { play := func(seed uint64) *lang.Knowledge { know := lang.NewKnowledge() r := NewRun(l, RunOptions{Dict: loadDict(t), Know: know, Seed: rng.FromInt(seed)}) - walk(t, r, l.Path(r.Player.Tile, sign, r.CanEnter)) + walk(t, r, l.Path(r.Player.Tile, sign, r.PathCanEnter)) for i := 0; i < 15; i++ { // gå av och på skylten = nya besök walk(t, r, []level.Point{sign, left}) walk(t, r, []level.Point{left, sign}) @@ -414,7 +456,7 @@ func TestToiletAndKioskInRun(t *testing.T) { // Toalett: tar ToiletSecPerMember per person, avbryts om man går. toilet := l.Find(level.Toilet)[0] - walk(t, r, l.Path(r.Player.Tile, toilet, r.CanEnter)) + walk(t, r, l.Path(r.Player.Tile, toilet, r.PathCanEnter)) anna.Meters.Toilet = 10 anna.Urge = NeedToilet r.Step(frame, None, true) @@ -428,7 +470,7 @@ func TestToiletAndKioskInRun(t *testing.T) { for r.Player.Moving() { // gå klart rutan innan vi vänder tillbaka r.Step(frame, None, false) } - walk(t, r, l.Path(r.Player.Tile, toilet, r.CanEnter)) + walk(t, r, l.Path(r.Player.Tile, toilet, r.PathCanEnter)) r.Step(frame, None, true) if !r.Facility.Active { t.Fatal("besöket ska starta igen") @@ -442,7 +484,7 @@ func TestToiletAndKioskInRun(t *testing.T) { // Kiosk: direkt, kostar KioskPriceYen per person från kortet. kiosk := l.Find(level.Kiosk)[0] - walk(t, r, l.Path(r.Player.Tile, kiosk, r.CanEnter)) + walk(t, r, l.Path(r.Player.Tile, kiosk, r.PathCanEnter)) anna.Meters.Drink = 5 anna.Urge = Thirsty before := r.Wallet.Card @@ -471,9 +513,10 @@ func TestRideWrongOrMissedStopLoses(t *testing.T) { reason string }{{"fel station", true, LostWrongStop}, {"åker förbi", false, LostMissedStop}} { r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(1)}) - walk(t, r, l.Path(r.Player.Tile, l.CorrectPlatform(), r.CanEnter)) + walk(t, r, l.Path(r.Player.Tile, l.CorrectPlatform(), r.PathCanEnter)) waitForTrain(t, r) r.Step(frame, None, true) + pickCar(t, r) g := r.Mini.(*minigame.Ride) if g.Dest < 1 { t.Fatalf("resmålet ska ligga på linjen men inte först: %d", g.Dest) @@ -500,7 +543,7 @@ func TestTopUpFlowAtMachine(t *testing.T) { t.Fatal("tomt kort stoppas") } machine := l.Find(level.TicketMachine)[0] - walk(t, r, l.Path(r.Player.Tile, machine, r.CanEnter)) + walk(t, r, l.Path(r.Player.Tile, machine, r.PathCanEnter)) r.Step(frame, None, true) g, ok := r.Mini.(*minigame.TopUp) if !ok || r.MiniKind != MiniTopUp { @@ -548,7 +591,7 @@ func TestTicketLevelFlow(t *testing.T) { t.Fatal("utan biljett stoppar spärren även med kort") } machine := l.Find(level.TicketMachine)[0] - walk(t, r, l.Path(r.Player.Tile, machine, r.CanEnter)) + walk(t, r, l.Path(r.Player.Tile, machine, r.PathCanEnter)) // Fel biljett först: tåget vägrar. r.Step(frame, None, true) @@ -563,7 +606,7 @@ func TestTicketLevelFlow(t *testing.T) { t.Fatalf("biljett=%q cash=%d", r.Wallet.Ticket, r.Wallet.Cash) } goal := l.CorrectPlatform() - walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter)) + walk(t, r, l.Path(r.Player.Tile, goal, r.PathCanEnter)) waitForTrain(t, r) r.Step(frame, None, true) if r.Mini != nil || r.FlashKey != FlashWrongTicket || r.Outcome != Ongoing { @@ -571,7 +614,7 @@ func TestTicketLevelFlow(t *testing.T) { } // Rätt biljett. - walk(t, r, l.Path(r.Player.Tile, machine, r.CanEnter)) + walk(t, r, l.Path(r.Player.Tile, machine, r.PathCanEnter)) r.Step(frame, None, true) tk = r.Mini.(*minigame.Ticket) for tk.Options[tk.Cursor].Dest != "渋谷" { @@ -582,9 +625,17 @@ func TestTicketLevelFlow(t *testing.T) { if r.Wallet.Ticket != "渋谷" { t.Fatalf("biljett=%q", r.Wallet.Ticket) } - walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter)) + walk(t, r, l.Path(r.Player.Tile, goal, r.PathCanEnter)) waitForTrain(t, r) r.Step(frame, None, true) + cp, ok := r.Mini.(*minigame.CarPick) + if !ok { + t.Fatalf("ombordstigning börjar med vagnval, fick %T", r.Mini) + } + for cp.Alighting > 0 { + r.StepMini(frame, minigame.Input{}) + } + r.StepMini(frame, minigame.Input{Confirm: true}) cr, ok := r.Mini.(*minigame.Crowd) if !ok { t.Fatalf("fullt tåg ska ge trängselminispel, fick %T", r.Mini) @@ -608,9 +659,14 @@ func TestCrowdFailureMissesTrain(t *testing.T) { r.Party.nextEventAt = 1e9 r.Wallet.Ticket = "渋谷" goal := l.CorrectPlatform() - walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter)) + walk(t, r, l.Path(r.Player.Tile, goal, r.PathCanEnter)) waitForTrain(t, r) r.Step(frame, None, true) + cp := r.Mini.(*minigame.CarPick) + for cp.Alighting > 0 { + r.StepMini(frame, minigame.Input{}) + } + r.StepMini(frame, minigame.Input{Confirm: true}) cr := r.Mini.(*minigame.Crowd) for n := 0; n < CrowdTries; n++ { for cr.InZone() { @@ -641,7 +697,7 @@ func TestPhoneSpeedRescanAndBattery(t *testing.T) { r.Step(frame, None, false) } gate := l.Find(level.Gate)[0] - path := l.Path(r.Player.Tile, gate, r.CanEnter) + path := l.Path(r.Player.Tile, gate, r.PathCanEnter) for i, p := range path { // stanna på den första spärrutan – där sker skanningen if l.At(p) == level.Gate { path = path[:i+1] @@ -669,7 +725,7 @@ func TestPhoneSpeedRescanAndBattery(t *testing.T) { r := NewRun(l, RunOptions{Seed: rng.FromInt(5)}) r.Party.nextEventAt = 1e9 r.Wallet.Phone = true - walk(t, r, l.Path(r.Player.Tile, l.Find(level.Gate)[0], r.CanEnter)) + walk(t, r, l.Path(r.Player.Tile, l.Find(level.Gate)[0], r.PathCanEnter)) return r.Rescans } if run() != run() { @@ -720,12 +776,12 @@ func TestScoreBreakdown(t *testing.T) { l := loadLevel(t, "001-flygplatsen") r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(2)}) r.Party.nextEventAt = 1e9 - walk(t, r, l.Path(r.Player.Tile, l.CorrectPlatform(), r.CanEnter)) + walk(t, r, l.Path(r.Player.Tile, l.CorrectPlatform(), r.PathCanEnter)) waitForTrain(t, r) r.Step(frame, None, true) ride(t, r) sc := r.Score - if r.Outcome != Won || sc.Total <= 0 || sc.Total != sc.Time+sc.Money+sc.Meters+sc.Explore-sc.Penalty { + if r.Outcome != Won || sc.Total <= 0 || sc.Total != sc.Time+sc.Money+sc.Meters+sc.Explore+sc.Car-sc.Penalty { t.Fatalf("score=%+v", sc) } if sc.Time != ScoreTimeMax || sc.Money != ScoreMoneyMax-l.FareYen/10 || sc.Penalty != 0 { @@ -756,7 +812,7 @@ func TestDebuffsAffectRunAndAreEarned(t *testing.T) { for r.Player.Moving() { r.Step(frame, None, false) } - walk(t, r, l.Path(r.Player.Tile, l.Find(level.Sign)[1], r.CanEnter)) + walk(t, r, l.Path(r.Player.Tile, l.Find(level.Sign)[1], r.PathCanEnter)) r.AskHelp() if want := lang.HelpSeconds(0) / 0.7; math.Abs(r.Help.Total-want) > 1e-9 { t.Fatalf("hjälptid med dåligt fokus %.2f, vill ha %.2f", r.Help.Total, want) @@ -794,7 +850,7 @@ func TestIndividualCardsAtGateAndMachine(t *testing.T) { t.Fatal("utan saldo på Annas kort stoppar spärren alla") } // Fyll på Annas kort i automaten (personval först). - walk(t, r, l1.Path(r.Player.Tile, l1.Find(level.TicketMachine)[0], r.CanEnter)) + walk(t, r, l1.Path(r.Player.Tile, l1.Find(level.TicketMachine)[0], r.PathCanEnter)) r.Step(frame, None, true) g := r.Mini.(*minigame.TopUp) if g.Stage != minigame.TopUpPickMember || len(g.Members) != 2 { @@ -816,7 +872,7 @@ func TestIndividualCardsAtGateAndMachine(t *testing.T) { } // Spärren drar från allas kort. before := r.Wallet.Spent - path := l1.Path(r.Player.Tile, gate, r.CanEnter) + path := l1.Path(r.Player.Tile, gate, r.PathCanEnter) for i, p := range path { if l1.At(p) == level.Gate { path = path[:i+1] @@ -836,7 +892,7 @@ func TestLevel003Flow(t *testing.T) { r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(21)}) r.Party.nextEventAt = 1e9 machine := l.Find(level.TicketMachine)[0] - walk(t, r, l.Path(r.Player.Tile, machine, r.CanEnter)) + walk(t, r, l.Path(r.Player.Tile, machine, r.PathCanEnter)) r.Step(frame, None, true) tk := r.Mini.(*minigame.Ticket) if len(tk.Members) != 4 || tk.Cash != StartCashYen/4 { @@ -896,15 +952,10 @@ func TestLevel003Flow(t *testing.T) { t.Fatal("missnöje ska flashas") } goal := l.CorrectPlatform() - walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter)) + walk(t, r, l.Path(r.Player.Tile, goal, r.PathCanEnter)) waitForTrain(t, r) r.Step(frame, None, true) - cr := r.Mini.(*minigame.Crowd) - for i := 0; i < 600 && !cr.InZone(); i++ { - r.StepMini(frame, minigame.Input{}) - } - r.StepMini(frame, minigame.Input{Confirm: true}) - ride(t, r) + ride(t, r) // vagnval, ev. trängsel, resa if r.Outcome != Won || r.Wallet.TicketCount != 0 { t.Fatalf("outcome=%v reason=%q tickets=%d", r.Outcome, r.LostReason, r.Wallet.TicketCount) } @@ -917,7 +968,7 @@ func TestNearTrackAndPhases(t *testing.T) { 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)) + walk(t, r, l.Path(r.Player.Tile, l.CorrectPlatform(), r.PathCanEnter)) 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) @@ -987,7 +1038,7 @@ func TestRunOptionsFromProgression(t *testing.T) { for r.Player.Moving() { r.Step(frame, None, false) } - path := l.Path(r.Player.Tile, gate, r.CanEnter) + path := l.Path(r.Player.Tile, gate, r.PathCanEnter) for i, p := range path { if l.At(p) == level.Gate { path = path[:i+1] @@ -1055,7 +1106,7 @@ func TestStairsMoveBetweenFloors(t *testing.T) { // Hela vägen till perrongen med BFS över våningar. Man står i trappans // nedre ände, så kliv av först – trappan tar en när man kliver PÅ den. walk(t, r, []level.Point{r.Player.Tile, {X: 3, Y: 1, Z: 0}}) - path := l.Path(r.Player.Tile, l.CorrectPlatform(), r.CanEnter) + path := l.Path(r.Player.Tile, l.CorrectPlatform(), r.PathCanEnter) if path == nil { t.Fatal("ingen väg") } @@ -1064,3 +1115,71 @@ func TestStairsMoveBetweenFloors(t *testing.T) { t.Fatalf("boten nådde inte perrongen: %v", r.Player.Tile) } } + +func TestCarsWomenOnlyEarlyAndCrowd(t *testing.T) { + l := loadLevel(t, "005-ueno") + tr := &l.Trains[0] + if tr.Cars != 4 || tr.WomenOnlyCar != 1 || !tr.WomenOnlyActive(10) || tr.WomenOnlyActive(200) { + t.Fatal("005: fyra vagnar, vagn 1 kvinnovagn de första 150 s") + } + r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(8)}) + r.Party.nextEventAt = 1e9 + r.NPCs = nil // folket ska inte stå i vägen för testboten + if !r.Party.HasMen() { + t.Fatal("tre i sällskapet: Du, Anna, Ville – Ville är man") + } + walk(t, r, l.Path(r.Player.Tile, l.CorrectPlatform(), r.PathCanEnter)) + waitForTrain(t, r) + if r.Time >= 150 { + t.Skip("tåget kom efter rusningen i det här seedet") + } + r.Step(frame, None, true) + cp := r.Mini.(*minigame.CarPick) + if !cp.Cars[0].WomenOnly { + t.Fatal("vagn 1 ska vara kvinnovagn nu") + } + // För tidigt: folk kliver av först. + r.StepMini(frame, minigame.Input{Confirm: true}) + for cp.Alighting > 0 { + r.StepMini(frame, minigame.Input{}) + } + // Välj kvinnovagnen med en man i sällskapet: grova minus. + for cp.Cursor != 0 { + r.StepMini(frame, minigame.Input{MoveX: -1}) + } + r.StepMini(frame, minigame.Input{Confirm: true}) + if !r.WomenOnlyViolation || r.EarlyBoard != 1 || r.FlashKey != FlashWomenOnly || r.CarChosen == nil || r.CarChosen.Index != 1 { + t.Fatalf("violation=%v early=%d flash=%q car=%+v", r.WomenOnlyViolation, r.EarlyBoard, r.FlashKey, r.CarChosen) + } + if cr, ok := r.Mini.(*minigame.Crowd); ok { // full vagn -> trängsel + for i := 0; i < 600 && !cr.InZone(); i++ { + r.StepMini(frame, minigame.Input{}) + } + r.StepMini(frame, minigame.Input{Confirm: true}) + } + rd := r.Mini.(*minigame.Ride) + if rd.Crowd != r.CarChosen.Crowd || rd.StopSec != minigame.RideStopSec(rd.Crowd) { + t.Fatal("resan ska ärva vagnens trängsel") + } + ride(t, r) + if r.Outcome != Won { + t.Fatalf("outcome=%v reason=%q", r.Outcome, r.LostReason) + } + if r.Score.Penalty < PenaltyWomenOnly+PenaltyEarlyBoard { + t.Fatalf("avdrag=%d, vill ha minst %d", r.Score.Penalty, PenaltyWomenOnly+PenaltyEarlyBoard) + } + // Bonus för lugn vagn. + r2 := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(8)}) + r2.Party.nextEventAt = 1e9 + r2.NPCs = nil + walk(t, r2, l.Path(r2.Player.Tile, l.CorrectPlatform(), r2.PathCanEnter)) + waitForTrain(t, r2) + r2.Step(frame, None, true) + ride(t, r2) // pickCar väljer lugnaste icke-kvinnovagnen + if r2.Outcome == Won && r2.CarChosen.Crowd == 0 && r2.Score.Car != ScoreCarBonus { + t.Fatalf("lugn vagn ska ge bonus: %+v", r2.Score) + } + if r2.WomenOnlyViolation || r2.Score.Penalty >= PenaltyWomenOnly { + t.Fatal("rätt vagn: inga grova minus") + } +} diff --git a/resources/data/levels/005-ueno.json b/resources/data/levels/005-ueno.json new file mode 100644 index 0000000..4b5186c --- /dev/null +++ b/resources/data/levels/005-ueno.json @@ -0,0 +1,134 @@ +{ + "clutter": 2, + "crowd": 2, + "destination": { + "en": "Akihabara", + "ja": "秋葉原", + "romaji": "Akihabara", + "sv": "Akihabara" + }, + "difficulty": 4, + "fareYen": 200, + "grid": [ + "#################################", + "#S.\u003c..\u003c..#..\u003c..\u003c..\u003c..\u003c..#..\u003c..###", + "#..\u003e..\u003e..#..\u003e..\u003e..\u003e..\u003e..#..\u003e..###", + "#######\u003c\u003e#\u003c\u003e#\u003c\u003e####\u003c\u003e#\u003c\u003e####\u003c\u003e###", + "#..\u003c..\u003c..#.W\u003c..\u003c..\u003c..#..\u003c..\u003c..###", + "#.i\u003e..\u003e..#..\u003e..\u003e..\u003e..#..\u003eK.\u003e..###", + "#\u003c\u003e#############\u003c\u003e#######\u003c\u003e#\u003c\u003e###", + "#..#..\u003c..\u003c..\u003c.i\u003c..\u003c..\u003c..#..\u003c..###", + "#..#..\u003e..\u003e..\u003e..\u003e..\u003e..\u003e..#.T\u003ei.###", + "#\u003c\u003e####\u003c\u003e##########\u003c\u003e#\u003c\u003e#\u003c\u003e######", + "#..\u003c..\u003c..\u003c..#..\u003c..\u003c..#..#..\u003c..###", + "#..\u003e..\u003e..\u003e..#..\u003e..\u003e..#..#..\u003e..###", + "####\u003c\u003e#\u003c\u003e#\u003c\u003e#\u003c\u003e####\u003c\u003e#######\u003c\u003e###", + "#..\u003c..#..#..#..\u003c..#..\u003c..\u003c..\u003c..###", + "#..\u003e..#..#..#..\u003e..#..\u003e..\u003e..\u003e..###", + "#\u003c\u003e#######\u003c\u003e####\u003c\u003e###############", + "#G.\u003c..\u003c..\u003c..#..\u003c..\u003c..\u003c..\u003c..\u003c.G###", + "#pG\u003e..\u003e..\u003e..#..\u003e..\u003e..\u003e..\u003e..\u003eGP###", + "#################################", + "#################################", + "#################################" + ], + "id": "005-ueno", + "kind": "main", + "maxMissedTrains": 2, + "name": { + "en": "Ueno Station", + "ja": "上野駅", + "sv": "Ueno station" + }, + "noise": 2, + "order": 5, + "party": 3, + "rideStops": 5, + "shop": [ + "suit", + "mask_tengu", + "yukata", + "onigiri_box", + "rail_pass" + ], + "signs": [ + { + "text": { + "en": "Ueno Station. Keep left in the corridors. Track 3 Yamanote Line for Akihabara: south-east. Track 9 Keihin-Tohoku Line for Omiya: south-west.", + "ja": "上野駅 左側 通行 3番線 山手線 秋葉原方面 → 南東 9番線 京浜東北線 大宮方面 → 南西", + "sv": "Ueno station. Håll till vänster i gångarna. Spår 3 Yamanote-linjen mot Akihabara: sydost. Spår 9 Keihin-Tohoku-linjen mot Omiya: sydväst." + }, + "x": 2, + "y": 5, + "z": 0 + }, + { + "text": { + "en": "\u003c- Track 9 Keihin-Tohoku Line (Omiya) Track 3 Yamanote Line (Akihabara) -\u003e", + "ja": "← 9番線 京浜東北線 大宮方面 3番線 山手線 秋葉原方面 →", + "sv": "\u003c- Spår 9 Keihin-Tohoku-linjen (Omiya) Spår 3 Yamanote-linjen (Akihabara) -\u003e" + }, + "x": 14, + "y": 7, + "z": 0 + }, + { + "text": { + "en": "Track 3: car 1 is women-only during rush hour (the first 2.5 minutes). Queue at the markings and let people off first.", + "ja": "3番線 1号車 女性専用車 ラッシュ 改札 → 南東口", + "sv": "Spår 3: vagn 1 är kvinnovagn under rusningen (de första 2,5 minuterna). Köa vid markeringarna och låt folk kliva av först." + }, + "x": 28, + "y": 8, + "z": 0 + } + ], + "timeLimitSec": 420, + "trains": [ + { + "cars": 4, + "correct": true, + "destination": { + "en": "Akihabara", + "ja": "秋葉原", + "romaji": "Akihabara" + }, + "line": { + "en": "Yamanote Line", + "ja": "山手線", + "romaji": "Yamanote-sen" + }, + "platform": "P", + "schedule": { + "dwellSec": 9, + "intervalSec": 26, + "offsetSec": 6 + }, + "track": "3", + "womenOnlyCar": 1, + "womenOnlyFrom": 0, + "womenOnlyTo": 150 + }, + { + "cars": 3, + "correct": false, + "destination": { + "en": "Omiya", + "ja": "大宮", + "romaji": "Omiya" + }, + "line": { + "en": "Keihin-Tohoku Line", + "ja": "京浜東北線", + "romaji": "Keihin-Tohoku-sen" + }, + "platform": "p", + "schedule": { + "dwellSec": 8, + "intervalSec": 30, + "offsetSec": 13 + }, + "track": "9" + } + ] +} \ No newline at end of file diff --git a/resources/sound/README.md b/resources/sound/README.md index 2233c77..be80a3f 100644 --- a/resources/sound/README.md +++ b/resources/sound/README.md @@ -7,7 +7,10 @@ OGG (musik, längre ljud). 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 +- `chatter.wav` – loopbart stationssorl (bakgrundsprat), spelas på banor med + `noise` > 0 och konkurrerar med melodierna. + +Alla är **placeholders** genererade av `go run ./tools/gensound`; riktiga inspelningar/kompositioner ersätter dem i M12 (behåll filnamnen). Planerat: utrop, folkmassa, spärr-pip, automat-ljud, UI-klick, bakgrundsprat och reklam som ljudröra (M9b). diff --git a/resources/sound/chatter.wav b/resources/sound/chatter.wav new file mode 100644 index 0000000..704a031 Binary files /dev/null and b/resources/sound/chatter.wav differ diff --git a/resources/sprites/tiles.png b/resources/sprites/tiles.png index ddeb2f3..f8b0c24 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 1b9bd4a..02c115e 100644 --- a/resources/text/en.json +++ b/resources/text/en.json @@ -139,20 +139,20 @@ "map.side": "Side trip", "map.party": "Party", "map.resume": "⟳ resume (saved seed)", - "map.locked": "Locked – clear the previous level first.", + "map.locked": "Locked - clear the previous level first.", "map.hint": "Arrows: level Enter: start L: words T: skills I: wardrobe P: phone Esc", "skills.title": "Skills 技", "skills.to_next": "XP to next", "skills.already": "Already unlocked.", "skills.unlocked": "Unlocked: %s", - "skills.need_points": "Costs %d skill points – level up by clearing levels.", + "skills.need_points": "Costs %d skill points - level up by clearing levels.", "skills.need_req": "Requires an earlier skill in the branch.", "skills.hint": "Arrows: select Enter: unlock Esc/T: back", "items.title": "Wardrobe 持ち物", "items.owned": "items", "items.owned_short": "owned", "items.wearing": "worn", - "items.none": "Nothing yet – buy at kiosks (B) or clear side trips.", + "items.none": "Nothing yet - buy at kiosks (B) or clear side trips.", "items.worn": "You wear %s.", "items.removed": "You took off %s.", "items.total": "Total", @@ -181,5 +181,21 @@ "effect.landmarks": "landmarks on map", "effect.free_gates": "free gates", "effect.reveal": "map: %s", - "effect.battery": "infinite battery" + "effect.battery": "infinite battery", + "mini.car.title": "Choose a car", + "mini.car.alighting": "People are getting off - wait…", + "mini.car.pick": "Pick a car with the arrows, board with Enter.", + "mini.car.men": "There is a man in the party - not the women-only car (pink)!", + "mini.car.hint": "←/→: car Enter: board Esc: cancel", + "car.crowd.low": "few people", + "car.crowd.mid": "some people", + "car.crowd.high": "packed", + "hud.car_suffix_ja": "号車", + "mini.ride.let_pass": "Someone wants to get off - press ← to step aside!", + "mini.ride.crowd": "Crowding", + "hud.women_only": "Women-only car with a man in the party - heavy penalty!", + "hud.early_board": "Let people off first!", + "hud.bump": "Sumimasen…", + "hud.wrong_lane": "Wrong side - keep to the side the lane shows", + "result.car": "car" } diff --git a/resources/text/jp-dictionary.json b/resources/text/jp-dictionary.json index 2db2fe4..1de6034 100644 --- a/resources/text/jp-dictionary.json +++ b/resources/text/jp-dictionary.json @@ -588,5 +588,62 @@ "en": "down", "sv": "ner", "tier": 1 + }, + "上野駅": { + "kana": "うえのえき", + "romaji": "Ueno-eki", + "en": "Ueno Station", + "sv": "Ueno station", + "tier": 3 + }, + "京浜東北線": { + "kana": "けいひんとうほくせん", + "romaji": "Keihin-Tōhoku-sen", + "en": "Keihin-Tohoku Line", + "sv": "Keihin-Tohoku-linjen", + "tier": 3 + }, + "大宮": { + "kana": "おおみや", + "romaji": "Ōmiya", + "en": "Omiya", + "sv": "Omiya", + "tier": 3, + "kind": "station" + }, + "女性専用車": { + "kana": "じょせいせんようしゃ", + "romaji": "josei sen'yō-sha", + "en": "Women-only car", + "sv": "Kvinnovagn", + "tier": 2 + }, + "号車": { + "kana": "ごうしゃ", + "romaji": "gōsha", + "en": "car no.", + "sv": "vagn nr", + "tier": 2 + }, + "ラッシュ": { + "kana": "ラッシュ", + "romaji": "rasshu", + "en": "rush hour", + "sv": "rusning", + "tier": 2 + }, + "左側": { + "kana": "ひだりがわ", + "romaji": "hidarigawa", + "en": "left side", + "sv": "vänster sida", + "tier": 2 + }, + "通行": { + "kana": "つうこう", + "romaji": "tsūkō", + "en": "passage / keep", + "sv": "gång / håll", + "tier": 3 } } diff --git a/resources/text/sv.json b/resources/text/sv.json index f4c7500..8ef8235 100644 --- a/resources/text/sv.json +++ b/resources/text/sv.json @@ -139,20 +139,20 @@ "map.side": "Sidospår", "map.party": "Sällskap", "map.resume": "⟳ fortsätt (sparad seed)", - "map.locked": "Låst – klara föregående bana först.", + "map.locked": "Låst - klara föregående bana först.", "map.hint": "Pilar: bana Enter: start L: ord T: skills I: garderob P: tel Esc", "skills.title": "Skills 技", "skills.to_next": "XP till nästa", "skills.already": "Redan upplåst.", "skills.unlocked": "Upplåst: %s", - "skills.need_points": "Kostar %d skillpoäng – gå upp i nivå genom att klara banor.", + "skills.need_points": "Kostar %d skillpoäng - gå upp i nivå genom att klara banor.", "skills.need_req": "Kräver en tidigare skill i grenen.", "skills.hint": "Pilar: välj Enter: lås upp Esc/T: tillbaka", "items.title": "Garderob 持ち物", "items.owned": "items", "items.owned_short": "ägs", "items.wearing": "bärs", - "items.none": "Inget än – köp i kioskerna (B) eller klara sidospår.", + "items.none": "Inget än - köp i kioskerna (B) eller klara sidospår.", "items.worn": "Du bär %s.", "items.removed": "Du tog av %s.", "items.total": "Totalt", @@ -181,5 +181,21 @@ "effect.landmarks": "landmärken på kartan", "effect.free_gates": "gratis spärrar", "effect.reveal": "karta: %s", - "effect.battery": "oändligt batteri" + "effect.battery": "oändligt batteri", + "mini.car.title": "Välj vagn", + "mini.car.alighting": "Folk kliver av - vänta…", + "mini.car.pick": "Välj vagn med pilarna, kliv på med Enter.", + "mini.car.men": "Det finns en man i sällskapet - inte kvinnovagnen (rosa)!", + "mini.car.hint": "←/→: vagn Enter: kliv på Esc: avbryt", + "car.crowd.low": "lite folk", + "car.crowd.mid": "en del folk", + "car.crowd.high": "fullt", + "hud.car_suffix_ja": "号車", + "mini.ride.let_pass": "Någon vill förbi - tryck ← för att kliva åt sidan!", + "mini.ride.crowd": "Trängsel", + "hud.women_only": "Kvinnovagn med en man i sällskapet - grova minus!", + "hud.early_board": "Låt folk kliva av först!", + "hud.bump": "Ursäkta…", + "hud.wrong_lane": "Fel sida - håll till vänster/höger som filen visar", + "result.car": "vagn" } diff --git a/tools/genart/main.go b/tools/genart/main.go index 494b289..07848d3 100644 --- a/tools/genart/main.go +++ b/tools/genart/main.go @@ -4,7 +4,7 @@ // // 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, räls, -// trappa upp, trappa ner) och +// trappa upp, trappa ner, fil vänster, fil höger, affisch ×3) 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. @@ -163,6 +163,44 @@ var tiles = []func(canvas){ c.rect(6, 13, 4, 1, boxDark) c.rect(5, 12, 6, 1, boxDark) }, + func(c canvas) { // fil vänster: golv med vänsterpilar + floor(c) + for _, y := range []int{3, 11} { + c.rect(4, y, 8, 1, metalDark) + c.rect(3, y-1, 1, 3, metalDark) + } + }, + func(c canvas) { // fil höger: golv med högerpilar + floor(c) + for _, y := range []int{3, 11} { + c.rect(4, y, 8, 1, metalDark) + c.rect(12, y-1, 1, 3, metalDark) + } + }, + func(c canvas) { // affisch 1: färgglad reklam + c.rect(0, 0, 16, 16, wallMid) + c.rect(2, 1, 12, 14, rgb(250, 230, 120)) + c.rect(3, 2, 10, 5, rgb(228, 59, 68)) + c.rect(4, 8, 8, 1, boxDark) + c.rect(4, 10, 6, 1, boxDark) + c.rect(4, 12, 7, 1, boxDark) + }, + func(c canvas) { // affisch 2: blå lockskylt som liknar en riktig skylt + c.rect(0, 0, 16, 16, wallMid) + c.rect(1, 2, 14, 12, signBoard) + c.rect(1, 2, 14, 1, white) + c.rect(3, 5, 10, 1, white) + c.rect(3, 8, 6, 1, white) + c.rect(3, 11, 8, 1, yellow) + }, + func(c canvas) { // affisch 3: rosa med stort tecken + c.rect(0, 0, 16, 16, wallMid) + c.rect(2, 1, 12, 14, rgb(255, 170, 200)) + c.rect(5, 4, 6, 6, white) + c.rect(7, 4, 2, 6, rgb(200, 60, 120)) + c.rect(5, 6, 6, 2, rgb(200, 60, 120)) + c.rect(4, 12, 8, 1, rgb(120, 30, 70)) + }, 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 index 6e4836a..afa2584 100644 --- a/tools/gensound/main.go +++ b/tools/gensound/main.go @@ -83,12 +83,39 @@ func writeWAV(path string, pcm []int16) error { return binary.Write(f, binary.LittleEndian, pcm) } +// chatter syntetiserar sex sekunder loopbart stationssorl: lågpassat brus +// med stavelseliknande amplitudmodulation från en deterministisk LCG. +func chatter(seconds float64) []int16 { + n := int(seconds * rate) + out := make([]int16, n) + var lcg uint32 = 12345 + next := func() float64 { lcg = lcg*1664525 + 1013904223; return float64(lcg>>8)/float64(1<<24)*2 - 1 } + lp := 0.0 + env, envTarget, envT := 0.3, 0.3, 0.0 + for i := 0; i < n; i++ { + t := float64(i) / rate + // ny "stavelse" var 120–250 ms + if t >= envT { + envTarget = 0.1 + 0.6*math.Abs(next()) + envT = t + 0.12 + 0.13*math.Abs(next()) + } + env += (envTarget - env) * 0.002 + lp += (next() - lp) * 0.08 // lågpass ≈ 600 Hz + v := lp * env * 0.6 + // mjuk in-/utfasning för sömlös loop + fade := math.Min(1, math.Min(t, seconds-t)/0.2) + out[i] = int16(v * fade * 32767) + } + return out +} + func main() { out := filepath.Join(repoRoot(), "resources", "sound") must(os.MkdirAll(out, 0o755)) must(writeWAV(filepath.Join(out, "melody-approach.wav"), render(approachMelody, 0.01, 0.06, 0.3))) must(writeWAV(filepath.Join(out, "door-chime.wav"), render(doorChime, 0.005, 0.04, 0.1))) - fmt.Println("skrev", filepath.Join(out, "melody-approach.wav"), "och door-chime.wav") + must(writeWAV(filepath.Join(out, "chatter.wav"), chatter(6))) + fmt.Println("skrev", filepath.Join(out, "melody-approach.wav"), ", door-chime.wav och chatter.wav") } func repoRoot() string { diff --git a/tools/shots/main.go b/tools/shots/main.go index 349fdc9..653b2ce 100644 --- a/tools/shots/main.go +++ b/tools/shots/main.go @@ -102,6 +102,14 @@ func buildShots(levelsDir string) error { if err != nil { return err } + l5, err := level.LoadFile(filepath.Join(levelsDir, "005-ueno.json")) + if err != nil { + return err + } + toPlatform5, err := moves(l5, l5.SpawnPoint(), l5.CorrectPlatform()) + if err != nil { + return err + } // Skyltbilderna tas med några kända ord så att markeringen och den // delvisa översättningen syns; hjälpen lär sedan in nästa ord. known := []string{"-know", "改札,番線,方面", "-seed", "7"} @@ -118,6 +126,7 @@ func buildShots(levelsDir string) error { {"003-shibuya", toMachine3 + "; press enter; pick 池袋; press enter; wait 5; press enter; press right; wait 5; shot $OUT/seats.png; quit", []string{"-seed", "7", "-know", "池袋", "-debuffs", "sore_feet"}}, {"004-tokyo", "wait 10; shot $OUT/floors-b1.png; " + toStairs + "; wait 20; shot $OUT/floors-1f.png; quit", []string{"-seed", "7"}}, + {"005-ueno", "wait 90; shot $OUT/crowd.png; " + toPlatform5 + "; waittrain; wait 5; press enter; wait 20; shot $OUT/cars-alight.png; wait 80; press right; wait 5; shot $OUT/cars.png; quit", []string{"-seed", "7"}}, } return nil } @@ -190,6 +199,9 @@ func main() { } _ = demo for _, s := range shots { + if len(os.Args) > 1 && !matchesAny(s, os.Args[1:]) { + continue // `go run ./tools/shots 005 ride` kör bara skott vars namn eller bana innehåller något av orden + } args := []string{"-mute", "-nosave", "-script", strings.ReplaceAll(s.script, "$OUT", out)} if s.level != "" { args = append(args, "-level", s.level) @@ -234,3 +246,13 @@ func fail(err error) { fmt.Fprintln(os.Stderr, "shots:", err) os.Exit(1) } + +// matchesAny rapporterar om skottets bana eller skript innehåller något av orden. +func matchesAny(s shot, words []string) bool { + for _, w := range words { + if strings.Contains(s.level, w) || strings.Contains(s.script, w) { + return true + } + } + return false +}