M9b: folk, filer, reklam, sorl och vagnval
All checks were successful
release / build-release (push) Successful in 1m33s
Folk på stationen (NPC:er som vandrar, kliver av tåg och går mot utgången, blockerar spelaren men inte varandra), vänster-/högerfiler som bromsar på fel sida, bredare gångar, reklamaffischer (clutter), japanskt sorl i loop (noise), vagnval med kvinnovagn (stort avdrag med man i sällskapet, tidsstyrd), kömarkeringar på perrongen, trängsel per vagn (bonus för tom vagn, svettig-debuff och svårare resa i full vagn, släpp förbi passagerare) och att folk kliver av innan man får gå på. Ny bana 005 Ueno. Skärmdumpsverktyget kan filtrera på bana/skript. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
25
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+
|
flygplatsen till avresan. Efter storyn låses ett oändligt läge och New Game+
|
||||||
upp. Hela speldesignen finns i [doc/spelbeskrivning.md](doc/spelbeskrivning.md).
|
upp. Hela speldesignen finns i [doc/spelbeskrivning.md](doc/spelbeskrivning.md).
|
||||||
|
|
||||||
**Status:** M9a klar (2026-10-24) – fem banor på en bankarta i
|
**Status:** M9b klar (2026-10-24) – sex banor på en bankarta i
|
||||||
spelbrädesstil, med sidospår. Japanska skyltar och ordinlärning,
|
spelbrädesstil, med sidospår och flera våningar. Japanska skyltar och
|
||||||
lämmeltåget, minikarta, tågkort, biljetter och minispel, svårighet med
|
ordinlärning, lämmeltåget, minikarta, tågkort, biljetter och minispel,
|
||||||
tidsgräns, poäng och debuffar, tåg som rullar in till avgångsmelodin,
|
svårighet med tidsgräns, poäng och debuffar, tåg som rullar in till
|
||||||
progression med XP, skillträd, items och sparfil. Och nu stationer i flera
|
avgångsmelodin, progression med XP, skillträd, items och sparfil. Och nu
|
||||||
våningar: Tokyo station börjar under mark och trappan tar dig upp till
|
lever stationen: folk som går i vägen, filer där man ska hålla till vänster,
|
||||||
perrongerna. Nästa är M9b: folk, reklam och ljudröra. Se
|
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).
|
[doc/roadmap.md](doc/roadmap.md).
|
||||||
|
|
||||||

|

|
||||||
@@ -56,6 +58,13 @@ tar dig upp och ner, och minikartan visar vilket plan du är på.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
På de svåraste banorna har alla i sällskapet egna kort som måste ha saldo,
|
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
|
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,
|
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/build …` | bygger (se ovan) |
|
||||||
| `go run ./tools/genart` | genererar placeholder-grafiken `resources/sprites/tiles.png` och `player.png` |
|
| `go run ./tools/genart` | genererar placeholder-grafiken `resources/sprites/tiles.png` och `player.png` |
|
||||||
| `go run ./tools/gensound` | genererar placeholder-ljuden `resources/sound/*.wav` (avgångsmelodi, dörrsignal) |
|
| `go run ./tools/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
|
## Beroenden
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
BIN
doc/img/cars-alight.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
doc/img/cars.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
doc/img/crowd.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
doc/img/map.png
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 21 KiB |
BIN
doc/img/ride.png
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 21 KiB |
BIN
doc/img/shop.png
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 25 KiB |
BIN
doc/img/sign.png
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
@@ -34,7 +34,7 @@ att bygga på Linux som på Windows.
|
|||||||
| Inkrementellt debug-bygge | < 10 s | 4,7 s |
|
| Inkrementellt debug-bygge | < 10 s | 4,7 s |
|
||||||
| Release-binär | < 20 MB | 10,2 MB (Linux), 10,8 MB (Windows) |
|
| Release-binär | < 20 MB | 10,2 MB (Linux), 10,8 MB (Windows) |
|
||||||
| Testsviten | `go test ./...` grön på < 30 s | ~0,1 s |
|
| 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) |
|
| 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) |
|
| 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 |
|
| 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/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/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/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 |
|
| `internal/archtest` | `TestOnlyPresentationPackagesImportEbitengine` | Beroenderegeln ovan |
|
||||||
|
|
||||||
### Planerade tester per milstolpe
|
### 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 |
|
| ~~M6 Kort/biljetter/minispel~~ ✓ | Saldo, automat, biljetter, omskanning, batteri – finns, se tabellen ovan |
|
||||||
| ~~M7 Svårighet/poäng/debuffs~~ ✓ | Poäng, tidsgräns, debuffar, egna kort, sittplatsval – finns, se tabellen ovan |
|
| ~~M7 Svårighet/poäng/debuffs~~ ✓ | Poäng, tidsgräns, debuffar, egna kort, sittplatsval – finns, se tabellen ovan |
|
||||||
| ~~M7b Tåg som kommer och går~~ ✓ | Faser, ombordstigning, indikatoravstånd – finns, se tabellen ovan; ljud och animation verifieras manuellt/med skärmdumpar |
|
| ~~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 |
|
| ~~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 |
|
| ~~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 |
|
| M9 Navigeringslägen | Listläge genererar rätt ordning av tåg; skyltläge: 1–4 giltiga vägar; fel-riktnings-förlust triggar vid rätt slutstation |
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ brasse-linux01-runnern, inte Pi5:n.
|
|||||||
|
|
||||||
M0 skelett ✓ → M1 rörelse/kamera ✓ → M2 tåg/spärrar/vinst ✓ → M3 japanska/skyltar ✓
|
M0 skelett ✓ → M1 rörelse/kamera ✓ → M2 tåg/spärrar/vinst ✓ → M3 japanska/skyltar ✓
|
||||||
→ M4 lämmeltåget ✓ → M5 minikarta ✓ → M6 kort/biljetter/minispel ✓ → M7
|
→ M4 lämmeltåget ✓ → M5 minikarta ✓ → M6 kort/biljetter/minispel ✓ → M7
|
||||||
svårighet/poäng/debuffar ✓ → M7b tåg som kommer och går ✓ → 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.
|
M10 story-innehåll → M11 oändligt läge/NG+ → M12 ljud/polish/release.
|
||||||
Innehåll per milstolpe: [roadmap.md › Milstolpar](roadmap.md#milstolpar).
|
Innehåll per milstolpe: [roadmap.md › Milstolpar](roadmap.md#milstolpar).
|
||||||
Vad som ska vara testat per milstolpe: [mal-och-tester.md](mal-och-tester.md).
|
Vad som ska vara testat per milstolpe: [mal-och-tester.md](mal-och-tester.md).
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ skriptläge så att de alltid är pixelexakta och går att återskapa:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
go run ./tools/genart # bara om placeholder-grafiken ska genereras om
|
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
|
**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
|
(`TestStairsMoveBetweenFloors`, `TestFloorsStairsAndPaths`), minikartan
|
||||||
visar rätt våning, och alla gamla banor fungerar oförändrat (en våning). ✓
|
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.
|
- **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
|
- **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.
|
och blinkande skyltar på väggarna, lockskyltar som liknar riktiga.
|
||||||
- **Ljudröra** (`noise`-nivå): japanskt prat, utrop och reklam i bakgrunden
|
- **Ljudröra** (`noise`-nivå): japanskt prat, utrop och reklam i bakgrunden
|
||||||
som konkurrerar med melodierna; på de svåraste nivåerna överlappar de.
|
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),
|
- **Teknik:** `sim.NPC` med enkla mål och BFS-vägar (ren Go, seedade),
|
||||||
dekorationslager i banformatet, `tools/genart` för folk/reklam,
|
`minigame.CarPick`, trängsel i `Crowd`/`Ride`, dekorationslager
|
||||||
`tools/gensound` för brusplaceholders; generatorn får bredd och sidor.
|
(seedade affischer) och `clutter`/`noise`/`crowd` i banformatet,
|
||||||
- **Klar när:** en bana med folk spelas av boten headless utan att fastna,
|
`tools/genart` för folk/affischer/vagnmarkeringar, `tools/gensound` för
|
||||||
fler NPC:er ger mätbart längre tid, och röra/ljud styrs per bana.
|
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
|
### M9 – Navigeringslägen: lista och skyltläge
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
- **Bredare gångar**: vissa gångar och vägar på banorna är breda nog att
|
||||||
man kan gå mellan folk.
|
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):
|
**Flera våningar** (tillägg 2026-10-24):
|
||||||
|
|
||||||
- Banor och stationer kan ha **flera våningar**. Trappor (och rulltrappor/
|
- Banor och stationer kan ha **flera våningar**. Trappor (och rulltrappor/
|
||||||
|
|||||||
@@ -28,12 +28,16 @@ var tileOrder = []level.Tile{
|
|||||||
level.Toilet,
|
level.Toilet,
|
||||||
level.Wall, // plats 9 = rälsen (laddas separat som Rails)
|
level.Wall, // plats 9 = rälsen (laddas separat som Rails)
|
||||||
level.StairsUp, level.StairsDown,
|
level.StairsUp, level.StairsDown,
|
||||||
|
level.LaneLeft, level.LaneRight,
|
||||||
|
level.Wall, level.Wall, level.Wall, // plats 13–15 = affischer (laddas separat som Posters)
|
||||||
level.Kiosk,
|
level.Kiosk,
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
trainIndex = 7
|
trainIndex = 7
|
||||||
railsIndex = 9
|
railsIndex = 9
|
||||||
|
posterIndex = 14
|
||||||
|
posterCount = 3
|
||||||
)
|
)
|
||||||
|
|
||||||
// Assets är allt som laddats från resources/.
|
// Assets är allt som laddats från resources/.
|
||||||
@@ -43,6 +47,7 @@ type Assets struct {
|
|||||||
Tiles map[level.Tile]*ebiten.Image
|
Tiles map[level.Tile]*ebiten.Image
|
||||||
Train *ebiten.Image // tågvagn, ritas längs rälsen när tåget syns
|
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
|
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]
|
Player [4][3]*ebiten.Image // [riktning: ner, upp, vänster, höger][bildruta: stilla, gå1, gå2]
|
||||||
Text map[string]string
|
Text map[string]string
|
||||||
Dict *lang.Dictionary
|
Dict *lang.Dictionary
|
||||||
@@ -70,7 +75,7 @@ func LoadAssets() (*Assets, error) {
|
|||||||
return nil, fmt.Errorf("tiles.png är %d px bred, behöver minst %d", w, need)
|
return nil, fmt.Errorf("tiles.png är %d px bred, behöver minst %d", w, need)
|
||||||
}
|
}
|
||||||
for i, t := range tileOrder {
|
for i, t := range tileOrder {
|
||||||
if i == trainIndex || i == railsIndex {
|
if i == trainIndex || i == railsIndex || (i >= posterIndex && i < posterIndex+posterCount) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
a.Tiles[t] = sub(i)
|
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.Tiles[level.WrongPlatform] = a.Tiles[level.Platform] // samma utseende; tåget avgör
|
||||||
a.Train = sub(trainIndex)
|
a.Train = sub(trainIndex)
|
||||||
a.Rails = sub(railsIndex)
|
a.Rails = sub(railsIndex)
|
||||||
|
for i := 0; i < posterCount; i++ {
|
||||||
|
a.Posters = append(a.Posters, sub(posterIndex+i))
|
||||||
|
}
|
||||||
|
|
||||||
player, err := loadImage("sprites", "player.png")
|
player, err := loadImage("sprites", "player.png")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
type Audio struct {
|
type Audio struct {
|
||||||
ctx *audio.Context
|
ctx *audio.Context
|
||||||
clips map[string][]byte // avkodad PCM (16-bit stereo LE) per klipp
|
clips map[string][]byte // avkodad PCM (16-bit stereo LE) per klipp
|
||||||
|
loop *audio.Player // pågående bakgrundsloop (bakgrundsprat)
|
||||||
}
|
}
|
||||||
|
|
||||||
const sampleRate = 44100
|
const sampleRate = 44100
|
||||||
@@ -25,6 +26,7 @@ const sampleRate = 44100
|
|||||||
const (
|
const (
|
||||||
SoundApproach = "melody-approach"
|
SoundApproach = "melody-approach"
|
||||||
SoundDoors = "door-chime"
|
SoundDoors = "door-chime"
|
||||||
|
SoundChatter = "chatter"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AudibleTiles är hur många rutor bort man hör perrongens ljud.
|
// 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
|
return nil, nil
|
||||||
}
|
}
|
||||||
a := &Audio{ctx: audio.NewContext(sampleRate), clips: map[string][]byte{}}
|
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")
|
b, err := res.ReadFile("sound", name+".wav")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -68,5 +70,34 @@ func (a *Audio) Play(name string, volume float64) {
|
|||||||
p.Play()
|
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.
|
// volumeAt ger volym efter avstånd i rutor.
|
||||||
func volumeAt(dist float64) float64 { return math.Max(0, 1-dist/AudibleTiles) }
|
func volumeAt(dist float64) float64 { return math.Max(0, 1-dist/AudibleTiles) }
|
||||||
|
|||||||
@@ -164,8 +164,24 @@ func (g *Game) Save() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetScene byter scen från och med nästa uppdatering.
|
// SetScene byter scen från och med nästa uppdatering. Bakgrundsprat hör
|
||||||
func (g *Game) SetScene(s Scene) { g.next = s }
|
// 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.
|
// Update körs 60 ggr/s.
|
||||||
func (g *Game) Update() error {
|
func (g *Game) Update() error {
|
||||||
|
|||||||
@@ -30,9 +30,61 @@ func (s *LevelScene) drawMinigame(g *Game, screen *ebiten.Image) {
|
|||||||
s.drawCrowd(a, screen, m, panel)
|
s.drawCrowd(a, screen, m, panel)
|
||||||
case *minigame.Ride:
|
case *minigame.Ride:
|
||||||
s.drawRide(a, screen, m, panel)
|
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).
|
// 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) {
|
func (s *LevelScene) jp(a *Assets, screen *ebiten.Image, text string, x, y int) {
|
||||||
rd := lang.Render(a.Dict, s.run.Know, text, UILang)
|
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 != "" {
|
if dest := lvl.Destination.Get("romaji"); dest != "" {
|
||||||
hint = strings.Replace(hint, "%s", dest, 1)
|
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)
|
a.Draw(screen, hint, x, p.Max.Y-16, colGood)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
98
internal/game/people.go
Normal file
@@ -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
|
||||||
@@ -39,6 +39,8 @@ type LevelScene struct {
|
|||||||
shop *shop // öppen butik i kiosken (pausar banan)
|
shop *shop // öppen butik i kiosken (pausar banan)
|
||||||
outcome LevelOutcome // progression från banan (fylls i när den avgjorts)
|
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).
|
// Tåg och effekter (M7b).
|
||||||
tracks []trackInfo
|
tracks []trackInfo
|
||||||
rails map[level.Point]bool
|
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.lastPhase = map[level.Tile]level.Phase{}
|
||||||
s.rng = rand.New(rand.NewPCG(1, 2))
|
s.rng = rand.New(rand.NewPCG(1, 2))
|
||||||
s.buildTracks()
|
s.buildTracks()
|
||||||
|
s.buildPosters()
|
||||||
|
g.startChatter(lvl.Noise)
|
||||||
for _, tr := range s.tracks { // ingen melodi för fasen banan råkar börja i
|
for _, tr := range s.tracks { // ingen melodi för fasen banan råkar börja i
|
||||||
s.lastPhase[tr.tile], _ = s.run.PlatformPhase(tr.tile)
|
s.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.Reset()
|
||||||
op.GeoM.Translate(ox+float64(x*TileSize), oy+float64(y*TileSize))
|
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)
|
screen.DrawImage(g.Assets.Tiles[lvl.At(p)], &op)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.drawTrains(g, screen, ox, oy)
|
s.drawTrains(g, screen, ox, oy)
|
||||||
|
s.drawQueueMarks(screen, ox, oy)
|
||||||
s.drawParticles(screen, ox, oy)
|
s.drawParticles(screen, ox, oy)
|
||||||
|
s.drawNPCs(g, screen, ox, oy)
|
||||||
|
|
||||||
// Följarna ritas bakifrån så att ledaren hamnar överst.
|
// Följarna ritas bakifrån så att ledaren hamnar överst.
|
||||||
leader := s.run.Player
|
leader := s.run.Player
|
||||||
@@ -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)...)
|
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 {
|
if debuffs := g.Profile.Debuffs; len(debuffs) > 0 && r.Time < 6 {
|
||||||
var parts []string
|
var parts []string
|
||||||
for _, d := range debuffs {
|
for _, d := range debuffs {
|
||||||
|
|||||||
@@ -82,8 +82,8 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) {
|
|||||||
sc := run.Score
|
sc := run.Score
|
||||||
a.Draw(screen, fmt.Sprintf("%s %d", a.T("result.score"), sc.Total), 28, y, colText)
|
a.Draw(screen, fmt.Sprintf("%s %d", a.T("result.score"), sc.Total), 28, y, colText)
|
||||||
y += LineH
|
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.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.penalty"), sc.Penalty), 28, y, colDim)
|
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
|
y += LineH
|
||||||
msg := fmt.Sprintf("+%d %s", run.PointsEarned, a.T("result.lang_points"))
|
msg := fmt.Sprintf("+%d %s", run.PointsEarned, a.T("result.lang_points"))
|
||||||
if len(run.LearnedDest) > 0 {
|
if len(run.LearnedDest) > 0 {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ func (s *LevelScene) buildTracks() {
|
|||||||
for _, p := range s.run.Level.Find(t) {
|
for _, p := range s.run.Level.Find(t) {
|
||||||
tt, horizontal := s.run.Level.TrackTile(p)
|
tt, horizontal := s.run.Level.TrackTile(p)
|
||||||
s.tracks = append(s.tracks, trackInfo{tile: t, platform: p, track: tt, horizontal: horizontal})
|
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++ {
|
for k := -railsReach; k <= railsReach; k++ {
|
||||||
c := tt
|
c := tt
|
||||||
if horizontal {
|
if horizontal {
|
||||||
@@ -47,7 +48,9 @@ func (s *LevelScene) buildTracks() {
|
|||||||
} else {
|
} else {
|
||||||
c.Y += k
|
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
|
s.rails[c] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -158,11 +161,12 @@ func (s *LevelScene) updateEffects(g *Game) {
|
|||||||
}
|
}
|
||||||
s.lastPhase[tr.tile] = phase
|
s.lastPhase[tr.tile] = phase
|
||||||
dist := math.Hypot(float64(tr.platform.X*TileSize)-px, float64(tr.platform.Y*TileSize)-py) / TileSize
|
dist := math.Hypot(float64(tr.platform.X*TileSize)-px, float64(tr.platform.Y*TileSize)-py) / TileSize
|
||||||
|
mask := noiseMask(r.Level.Noise)
|
||||||
switch phase {
|
switch phase {
|
||||||
case level.Approaching:
|
case level.Approaching:
|
||||||
g.Audio.Play(SoundApproach, volumeAt(dist))
|
g.Audio.Play(SoundApproach, volumeAt(dist)*mask)
|
||||||
case level.Closing:
|
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).
|
// Vind längs perrongen när tåget rullar in eller ut (och strax före).
|
||||||
|
|||||||
@@ -33,14 +33,19 @@ const (
|
|||||||
Kiosk Tile = 'K' // kiosk/automat för mat och dryck
|
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
|
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
|
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{
|
var knownTiles = map[Tile]bool{
|
||||||
Wall: true, Floor: true, Spawn: true, Platform: true, WrongPlatform: true,
|
Wall: true, Floor: true, Spawn: true, Platform: true, WrongPlatform: true,
|
||||||
Gate: true, TicketMachine: true, Sign: true, Toilet: true, Kiosk: 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.
|
// IsStairs rapporterar om rutan är en trappa.
|
||||||
func (t Tile) IsStairs() bool { return t == StairsUp || t == StairsDown }
|
func (t Tile) IsStairs() bool { return t == StairsUp || t == StairsDown }
|
||||||
|
|
||||||
@@ -167,10 +172,31 @@ type Train struct {
|
|||||||
Line Text `json:"line"` // linjens namn
|
Line Text `json:"line"` // linjens namn
|
||||||
Destination Text `json:"destination"` // slutstation
|
Destination Text `json:"destination"` // slutstation
|
||||||
Correct bool `json:"correct"` // tar spelaren till rätt resmål
|
Correct bool `json:"correct"` // tar spelaren till rätt resmål
|
||||||
Crowded bool `json:"crowded"` // fullt tåg: minispel för att få plats
|
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"`
|
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').
|
// SignText är texten på en skyltruta ('i').
|
||||||
type SignText struct {
|
type SignText struct {
|
||||||
X int `json:"x"`
|
X int `json:"x"`
|
||||||
@@ -200,6 +226,9 @@ type Level struct {
|
|||||||
Parent string `json:"parent"` // sidobanans huvudbana – låses upp när den är klarad
|
Parent string `json:"parent"` // sidobanans huvudbana – låses upp när den är klarad
|
||||||
Rewards Rewards `json:"rewards"`
|
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"`
|
Name Text `json:"name"`
|
||||||
Difficulty int `json:"difficulty"` // 1 (lätt) … 5 (svår)
|
Difficulty int `json:"difficulty"` // 1 (lätt) … 5 (svår)
|
||||||
Destination Text `json:"destination"` // dit spelaren ska
|
Destination Text `json:"destination"` // dit spelaren ska
|
||||||
@@ -402,6 +431,15 @@ func (l *Level) Validate() error {
|
|||||||
if tr.Correct {
|
if tr.Correct {
|
||||||
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
|
sc := &tr.Schedule
|
||||||
if sc.IntervalSec == 0 {
|
if sc.IntervalSec == 0 {
|
||||||
sc.IntervalSec = DefaultIntervalSec
|
sc.IntervalSec = DefaultIntervalSec
|
||||||
@@ -443,6 +481,11 @@ func (l *Level) Validate() error {
|
|||||||
if l.MaxMissedTrains < 0 || l.TimeLimitSec < 0 {
|
if l.MaxMissedTrains < 0 || l.TimeLimitSec < 0 {
|
||||||
return fmt.Errorf("level %s: negativ tidsgräns/maxantal missade tåg", l.ID)
|
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 {
|
if l.Party > MaxParty {
|
||||||
return fmt.Errorf("level %s: sällskapet får vara högst %d", l.ID, MaxParty)
|
return fmt.Errorf("level %s: sällskapet får vara högst %d", l.ID, MaxParty)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
84
internal/minigame/carpick.go
Normal file
@@ -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.<n>").
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,10 +18,12 @@ type Crowd struct {
|
|||||||
const crowdZoneWidth = 0.22
|
const crowdZoneWidth = 0.22
|
||||||
|
|
||||||
// NewCrowd seedar zonens läge och markörens fart. luck (1 = normalt) gör
|
// NewCrowd seedar zonens läge och markörens fart. luck (1 = normalt) gör
|
||||||
// zonen bredare eller smalare, inom rimliga gränser.
|
// zonen bredare eller smalare, och crowdLevel (0 lite, 1 mellan, 2 mycket
|
||||||
func NewCrowd(seed rng.Seed, tries int, luck float64) *Crowd {
|
// 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")
|
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)
|
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)}
|
return &Crowd{dir: 1, Speed: r.between(0.9, 1.5), ZoneLo: lo, ZoneHi: lo + width, MaxTries: max(1, tries)}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package minigame
|
package minigame
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"math"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -114,8 +113,8 @@ func TestTicketBoardSeededAndAffordable(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCrowdZoneAndAttempts(t *testing.T) {
|
func TestCrowdZoneAndAttempts(t *testing.T) {
|
||||||
g := NewCrowd(rng.FromInt(7), 2, 1)
|
g := NewCrowd(rng.FromInt(7), 2, 1, 1)
|
||||||
if g.ZoneLo < 0.1 || g.ZoneHi > 0.9 || math.Abs((g.ZoneHi-g.ZoneLo)-crowdZoneWidth) > 1e-9 {
|
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)
|
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.
|
// 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")
|
t.Fatal("träff i zonen ska lyckas")
|
||||||
}
|
}
|
||||||
// Två bom = tåget går.
|
// 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 n := 0; n < 2; n++ {
|
||||||
for f.InZone() {
|
for f.InZone() {
|
||||||
f.Update(Input{}, frame)
|
f.Update(Input{}, frame)
|
||||||
@@ -146,7 +145,7 @@ func TestCrowdZoneAndAttempts(t *testing.T) {
|
|||||||
t.Fatal("två bom ska misslyckas")
|
t.Fatal("två bom ska misslyckas")
|
||||||
}
|
}
|
||||||
// Markören studsar och håller sig i 0..1.
|
// 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++ {
|
for i := 0; i < 600; i++ {
|
||||||
b.Update(Input{}, frame)
|
b.Update(Input{}, frame)
|
||||||
if b.Pos < 0 || b.Pos > 1 {
|
if b.Pos < 0 || b.Pos > 1 {
|
||||||
@@ -157,11 +156,11 @@ func TestCrowdZoneAndAttempts(t *testing.T) {
|
|||||||
|
|
||||||
func TestRideStopsAndGettingOff(t *testing.T) {
|
func TestRideStopsAndGettingOff(t *testing.T) {
|
||||||
fillers := []string{"東京", "上野", "新宿", "成田"}
|
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] != "渋谷" {
|
if len(g.Stops) != 4 || g.Dest < 1 || g.Stops[g.Dest] != "渋谷" {
|
||||||
t.Fatalf("stops=%v dest=%d", 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, ",") {
|
if strings.Join(g.Stops, ",") != strings.Join(h.Stops, ",") {
|
||||||
t.Fatal("samma seed = samma linje")
|
t.Fatal("samma seed = samma linje")
|
||||||
}
|
}
|
||||||
@@ -174,13 +173,13 @@ func TestRideStopsAndGettingOff(t *testing.T) {
|
|||||||
t.Fatal("kliva av på resmålet ska lyckas")
|
t.Fatal("kliva av på resmålet ska lyckas")
|
||||||
}
|
}
|
||||||
// Kliva av för tidigt.
|
// 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)
|
e.Update(Input{Confirm: true}, frame)
|
||||||
if !e.Done() || e.Success() || e.GotOff != 0 {
|
if !e.Done() || e.Success() || e.GotOff != 0 {
|
||||||
t.Fatal("fel station = misslyckat")
|
t.Fatal("fel station = misslyckat")
|
||||||
}
|
}
|
||||||
// Åka förbi.
|
// Å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++ {
|
for i := 0; i < 60*20 && !m.Done(); i++ {
|
||||||
m.Update(Input{}, frame)
|
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)
|
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.
|
// 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, ","), "渋谷") {
|
if w.Dest != -1 || strings.Contains(strings.Join(w.Stops, ","), "渋谷") {
|
||||||
t.Fatalf("fel tåg: %v", w.Stops)
|
t.Fatalf("fel tåg: %v", w.Stops)
|
||||||
}
|
}
|
||||||
@@ -199,7 +198,7 @@ func TestRideStopsAndGettingOff(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRideWithoutFillersStillHasDestination(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 {
|
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)
|
t.Fatalf("stops=%v dest=%d", g.Stops, g.Dest)
|
||||||
}
|
}
|
||||||
@@ -295,14 +294,118 @@ func TestSeatSelectionPreferences(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCrowdLuckWidensZone(t *testing.T) {
|
func TestCrowdLuckWidensZone(t *testing.T) {
|
||||||
normal := NewCrowd(rng.FromInt(3), 2, 1)
|
normal := NewCrowd(rng.FromInt(3), 2, 1, 1)
|
||||||
lucky := NewCrowd(rng.FromInt(3), 2, 1.5)
|
lucky := NewCrowd(rng.FromInt(3), 2, 1.5, 1)
|
||||||
unlucky := NewCrowd(rng.FromInt(3), 2, 0.5)
|
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 {
|
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")
|
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 {
|
if huge.ZoneHi-huge.ZoneLo > 0.6+1e-9 || huge.ZoneHi > 0.9+1e-9 {
|
||||||
t.Fatal("zonen klampas")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,18 +12,31 @@ type Ride struct {
|
|||||||
Timer float64 // sekunder kvar vid aktuell station
|
Timer float64 // sekunder kvar vid aktuell station
|
||||||
StopSec float64
|
StopSec float64
|
||||||
GotOff int // index där spelaren klev av, -1 = inte än
|
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
|
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
|
// 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.
|
// 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")
|
r := newRand(seed, "ride")
|
||||||
f := append([]string(nil), fillers...)
|
f := append([]string(nil), fillers...)
|
||||||
r.shuffle(len(f), func(i, j int) { f[i], f[j] = f[j], f[i] })
|
r.shuffle(len(f), func(i, j int) { f[i], f[j] = f[j], f[i] })
|
||||||
stops = max(2, stops)
|
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 {
|
if correctTrain {
|
||||||
g.Dest = 1 + r.intn(stops-1)
|
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.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
|
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.
|
// UnknownStop används när det inte finns fler stationsnamn att ta av.
|
||||||
const UnknownStop = "・・・"
|
const UnknownStop = "・・・"
|
||||||
|
|
||||||
@@ -58,10 +81,17 @@ func (g *Ride) Update(in Input, dt float64) {
|
|||||||
g.success = g.Index == g.Dest
|
g.success = g.Index == g.Dest
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if in.MoveX < 0 && g.NeedsLetPass() {
|
||||||
|
g.LetPass = true // kliv åt sidan
|
||||||
|
}
|
||||||
g.Timer -= dt
|
g.Timer -= dt
|
||||||
if g.Timer <= 0 {
|
if g.Timer <= 0 {
|
||||||
|
if g.NeedsLetPass() {
|
||||||
|
g.Blocked++
|
||||||
|
}
|
||||||
g.Index++
|
g.Index++
|
||||||
g.Timer = g.StopSec
|
g.Timer = g.StopSec
|
||||||
|
g.LetPass = false
|
||||||
if g.Index >= len(g.Stops) {
|
if g.Index >= len(g.Stops) {
|
||||||
g.Index = len(g.Stops) - 1
|
g.Index = len(g.Stops) - 1
|
||||||
g.done = true // åkte till slutstationen utan att kliva av
|
g.done = true // åkte till slutstationen utan att kliva av
|
||||||
|
|||||||
144
internal/sim/npc.go
Normal file
@@ -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() }
|
||||||
115
internal/sim/npc_test.go
Normal file
@@ -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<<<P#","#######"],
|
||||||
|
"trains":[{"platform":"P","track":"1","line":{"en":"A"},"destination":{"en":"B"},"correct":true}]}`
|
||||||
|
l, err := level.Parse([]byte(src))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r := NewRun(l, RunOptions{Seed: rng.FromInt(1)})
|
||||||
|
r.Player.Tile, r.Player.Target = level.Point{X: 2, Y: 1}, level.Point{X: 2, Y: 1}
|
||||||
|
r.Step(frame, Right, false) // '<' är vänsterfil: höger är fel håll
|
||||||
|
if r.Player.Speed >= 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ func (m Meters) Lowest() (Urge, float64) {
|
|||||||
// Member är en i sällskapet. Members[0] är spelaren själv.
|
// Member är en i sällskapet. Members[0] är spelaren själv.
|
||||||
type Member struct {
|
type Member struct {
|
||||||
Name string
|
Name string
|
||||||
|
Gender string // "f", "m" eller "" (okänt) – kvinnovagnar bryr sig
|
||||||
Meters Meters
|
Meters Meters
|
||||||
Urge Urge
|
Urge Urge
|
||||||
UrgeAt float64 // när behovet uppstod
|
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.
|
// PartyNames är namnen i den ordning de följer med.
|
||||||
var PartyNames = []string{"Du", "Anna", "Ville", "Sara", "Omar"}
|
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.
|
// Takter och trösklar. Mätarna går från 100 till 0 på ungefär 10/7/8 minuter.
|
||||||
const (
|
const (
|
||||||
FoodPerSec = 100.0 / 600
|
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)))
|
n = max(1, min(n, len(PartyNames)))
|
||||||
p := &Party{rand: seed.New(), Rates: Rates{1, 1}}
|
p := &Party{rand: seed.New(), Rates: Rates{1, 1}}
|
||||||
for i := 0; i < n; i++ {
|
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.trail = []level.Point{start}
|
||||||
p.nextEventAt = p.interval() * (0.5 + p.rand.Float64())
|
p.nextEventAt = p.interval() * (0.5 + p.rand.Float64())
|
||||||
@@ -194,6 +198,16 @@ func (p *Party) SpeedFactor() float64 {
|
|||||||
return f
|
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.
|
// Names är medlemmarnas namn i ordning.
|
||||||
func (p *Party) Names() []string {
|
func (p *Party) Names() []string {
|
||||||
out := make([]string, len(p.Members))
|
out := make([]string, len(p.Members))
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package sim
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/rand/v2"
|
||||||
|
|
||||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang"
|
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang"
|
||||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
|
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
|
||||||
@@ -87,11 +88,39 @@ type Run struct {
|
|||||||
Score ScoreBreakdown
|
Score ScoreBreakdown
|
||||||
NewDebuffs []Debuff // debuffar banan gav (appliceras på profilen efteråt)
|
NewDebuffs []Debuff // debuffar banan gav (appliceras på profilen efteråt)
|
||||||
opts RunOptions
|
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.
|
// ScoreBreakdown är poängen för en vunnen bana.
|
||||||
type ScoreBreakdown struct {
|
type ScoreBreakdown struct {
|
||||||
Time, Money, Meters, Explore, Penalty, Total int
|
Time, Money, Meters, Explore, Car, Penalty, Total int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poängformelns vikter.
|
// Poängformelns vikter.
|
||||||
@@ -129,6 +158,7 @@ const (
|
|||||||
MiniTicket
|
MiniTicket
|
||||||
MiniCrowd
|
MiniCrowd
|
||||||
MiniRide
|
MiniRide
|
||||||
|
MiniCarPick
|
||||||
)
|
)
|
||||||
|
|
||||||
// Förlustorsaker (textnycklar).
|
// Förlustorsaker (textnycklar).
|
||||||
@@ -229,6 +259,13 @@ func NewRun(l *level.Level, o RunOptions) *Run {
|
|||||||
Stats: CombinedStats(o.Debuffs),
|
Stats: CombinedStats(o.Debuffs),
|
||||||
spentStart: o.Wallet.Spent,
|
spentStart: o.Wallet.Spent,
|
||||||
opts: o,
|
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{}) {
|
if o.Extra != (Stats{}) {
|
||||||
for i := range r.Stats {
|
for i := range r.Stats {
|
||||||
@@ -276,6 +313,12 @@ func (r *Run) CanEnter(p level.Point) bool {
|
|||||||
r.DeniedAt = r.Time
|
r.DeniedAt = r.Time
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
for _, n := range r.NPCs {
|
||||||
|
if n.Occupies(p) {
|
||||||
|
r.BumpedAt = r.Time
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,6 +345,19 @@ func (r *Run) canPassGate() bool {
|
|||||||
return true
|
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).
|
// MembersWithoutFare listar följare vars egna kort inte räcker (HUD).
|
||||||
func (r *Run) MembersWithoutFare() []*Member {
|
func (r *Run) MembersWithoutFare() []*Member {
|
||||||
if !r.Level.IndividualCards {
|
if !r.Level.IndividualCards {
|
||||||
@@ -342,6 +398,17 @@ func (r *Run) Step(dt float64, want Dir, board bool) {
|
|||||||
if r.Time < r.RescanUntil {
|
if r.Time < r.RescanUntil {
|
||||||
r.Player.Speed = 0 // står och skannar om
|
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)
|
r.Steps += r.Player.Step(dt, want, r.CanEnter)
|
||||||
if r.Player.Tile != before {
|
if r.Player.Tile != before {
|
||||||
// Trappa: hamnar man på en trappruta flyttas man till andra änden
|
// 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.stepHelp(dt)
|
||||||
r.stepFacility(dt)
|
r.stepFacility(dt)
|
||||||
r.stepReading()
|
r.stepReading()
|
||||||
|
r.stepNPCs(dt)
|
||||||
r.checkLimits()
|
r.checkLimits()
|
||||||
if r.Player.Tile != before && r.inGate() && !wasGate {
|
if r.Player.Tile != before && r.inGate() && !wasGate {
|
||||||
r.GatePasses++
|
r.GatePasses++
|
||||||
@@ -464,20 +532,47 @@ func (r *Run) tryBoard(tr *level.Train) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
r.PendingTrain = tr
|
r.PendingTrain = tr
|
||||||
if tr.Crowded {
|
women := 0
|
||||||
r.Mini, r.MiniKind = minigame.NewCrowd(r.miniSeed(), CrowdTries, r.Stats[StatLuck]), MiniCrowd
|
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
|
return
|
||||||
}
|
}
|
||||||
r.startRide()
|
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() {
|
func (r *Run) startRide() {
|
||||||
tr := r.PendingTrain
|
tr := r.PendingTrain
|
||||||
if r.Level.TicketRequired {
|
if r.Level.TicketRequired {
|
||||||
r.Wallet.Ticket, r.Wallet.TicketFare, r.Wallet.TicketCount = "", 0, 0 // biljetterna är förbrukade
|
r.Wallet.Ticket, r.Wallet.TicketFare, r.Wallet.TicketCount = "", 0, 0 // biljetterna är förbrukade
|
||||||
}
|
}
|
||||||
dest := r.Level.Destination.Get("ja")
|
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
|
// 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.Wallet.Drain(dt)
|
||||||
}
|
}
|
||||||
r.Party.Advance(dt, r.Time)
|
r.Party.Advance(dt, r.Time)
|
||||||
|
r.stepNPCs(dt)
|
||||||
r.Mini.Update(in, dt)
|
r.Mini.Update(in, dt)
|
||||||
if !r.Mini.Done() {
|
if !r.Mini.Done() {
|
||||||
r.checkLimits()
|
r.checkLimits()
|
||||||
@@ -531,6 +627,12 @@ func (r *Run) StepMini(dt float64, in minigame.Input) {
|
|||||||
}
|
}
|
||||||
r.flash(FlashGrumpy)
|
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:
|
case *minigame.Crowd:
|
||||||
if g.Success() {
|
if g.Success() {
|
||||||
r.startRide()
|
r.startRide()
|
||||||
@@ -542,6 +644,7 @@ func (r *Run) StepMini(dt float64, in minigame.Input) {
|
|||||||
case *minigame.Ride:
|
case *minigame.Ride:
|
||||||
tr := r.PendingTrain
|
tr := r.PendingTrain
|
||||||
r.PendingTrain = nil
|
r.PendingTrain = nil
|
||||||
|
r.Blocked += g.Blocked
|
||||||
switch {
|
switch {
|
||||||
case g.Success():
|
case g.Success():
|
||||||
r.finish(tr, true, "")
|
r.finish(tr, true, "")
|
||||||
@@ -628,8 +731,15 @@ func (r *Run) computeScore() {
|
|||||||
sc.Money = max(0, ScoreMoneyMax-(r.Wallet.Spent-r.spentStart)/10)
|
sc.Money = max(0, ScoreMoneyMax-(r.Wallet.Spent-r.spentStart)/10)
|
||||||
sc.Meters = int(ScoreMetersMax * r.Party.AvgMeters())
|
sc.Meters = int(ScoreMetersMax * r.Party.AvgMeters())
|
||||||
sc.Explore = int(ScoreExploreMax * r.Fog.Explored(r.Level))
|
sc.Explore = int(ScoreExploreMax * r.Fog.Explored(r.Level))
|
||||||
sc.Penalty = PenaltyMissed*r.MissedTrains + PenaltyRescan*r.Rescans + PenaltyGrumpy*r.Grumpy
|
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)
|
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
|
r.Score = sc
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -651,9 +761,38 @@ func (r *Run) earnedDebuffs() []Debuff {
|
|||||||
if r.poisoned {
|
if r.poisoned {
|
||||||
out = append(out, Debuff{FoodPoisoning, FoodPoisonLevels})
|
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
|
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.
|
// FacilityProgress är 0..1 för ett pågående toalettbesök.
|
||||||
func (r *Run) FacilityProgress() float64 {
|
func (r *Run) FacilityProgress() float64 {
|
||||||
if !r.Facility.Active || r.Facility.Total <= 0 {
|
if !r.Facility.Active || r.Facility.Total <= 0 {
|
||||||
|
|||||||
@@ -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
|
// 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) {
|
func ride(t *testing.T, r *Run) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
if r.MiniKind == MiniCarPick {
|
||||||
|
pickCar(t, r)
|
||||||
|
}
|
||||||
if r.MiniKind != MiniRide {
|
if r.MiniKind != MiniRide {
|
||||||
t.Fatalf("ingen tågresa pågår (kind=%v)", r.MiniKind)
|
t.Fatalf("ingen tågresa pågår (kind=%v)", r.MiniKind)
|
||||||
}
|
}
|
||||||
for i := 0; i < 60*120 && r.Mini != nil; i++ {
|
for i := 0; i < 60*120 && r.Mini != nil; i++ {
|
||||||
g := r.Mini.(*minigame.Ride)
|
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 {
|
if r.Mini != nil {
|
||||||
t.Fatal("resan tog aldrig slut")
|
t.Fatal("resan tog aldrig slut")
|
||||||
@@ -137,7 +179,7 @@ func TestBotWinsLevel001(t *testing.T) {
|
|||||||
l := loadLevel(t, "001-flygplatsen")
|
l := loadLevel(t, "001-flygplatsen")
|
||||||
r := NewRun(l, RunOptions{Seed: rng.FromInt(1)})
|
r := NewRun(l, RunOptions{Seed: rng.FromInt(1)})
|
||||||
goal := l.Find(level.Platform)[0]
|
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 {
|
if path == nil {
|
||||||
t.Fatal("ingen väg till perrongen")
|
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")
|
t.Fatal("rätt perrong ska ha rätt tåg")
|
||||||
}
|
}
|
||||||
r.Step(frame, None, true)
|
r.Step(frame, None, true)
|
||||||
if r.Outcome != Ongoing || r.MiniKind != MiniRide {
|
if r.Outcome != Ongoing || r.MiniKind != MiniCarPick {
|
||||||
t.Fatalf("att kliva på ska starta tågresan: outcome=%v kind=%v", r.Outcome, r.MiniKind)
|
t.Fatalf("att kliva på ska börja med vagnvalet: outcome=%v kind=%v", r.Outcome, r.MiniKind)
|
||||||
}
|
}
|
||||||
ride(t, r)
|
ride(t, r)
|
||||||
if r.Outcome != Won || r.Boarded != tr {
|
if r.Outcome != Won || r.Boarded != tr {
|
||||||
@@ -180,7 +222,7 @@ func TestBotLosesOnWrongTrain(t *testing.T) {
|
|||||||
l := loadLevel(t, "001-flygplatsen")
|
l := loadLevel(t, "001-flygplatsen")
|
||||||
r := NewRun(l, RunOptions{Seed: rng.FromInt(1)})
|
r := NewRun(l, RunOptions{Seed: rng.FromInt(1)})
|
||||||
goal := l.Find(level.WrongPlatform)[0]
|
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)
|
tr := waitForTrain(t, r)
|
||||||
r.Step(frame, None, true)
|
r.Step(frame, None, true)
|
||||||
ride(t, r)
|
ride(t, r)
|
||||||
@@ -200,7 +242,7 @@ func TestGateNeedsCardBalance(t *testing.T) {
|
|||||||
if r.DeniedAt < 0 {
|
if r.DeniedAt < 0 {
|
||||||
t.Fatal("nekandet ska registreras för HUD:en")
|
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")
|
t.Fatal("utan saldo ska perrongen vara onåbar")
|
||||||
}
|
}
|
||||||
r.Wallet.Card = l.FareYen
|
r.Wallet.Card = l.FareYen
|
||||||
@@ -220,7 +262,7 @@ func TestBoardOnlyWhenTrainPresent(t *testing.T) {
|
|||||||
l := loadLevel(t, "001-flygplatsen")
|
l := loadLevel(t, "001-flygplatsen")
|
||||||
r := NewRun(l, RunOptions{Seed: rng.FromInt(1)})
|
r := NewRun(l, RunOptions{Seed: rng.FromInt(1)})
|
||||||
goal := l.Find(level.Platform)[0]
|
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.
|
// Spola fram till en tidpunkt då tåget är borta.
|
||||||
for {
|
for {
|
||||||
_, at, _ := r.PlatformHere()
|
_, at, _ := r.PlatformHere()
|
||||||
@@ -239,7 +281,7 @@ func TestSignHereOnlyWhenStill(t *testing.T) {
|
|||||||
l := loadLevel(t, "001-flygplatsen")
|
l := loadLevel(t, "001-flygplatsen")
|
||||||
r := NewRun(l, RunOptions{Seed: rng.FromInt(1)})
|
r := NewRun(l, RunOptions{Seed: rng.FromInt(1)})
|
||||||
sign := l.Find(level.Sign)[0]
|
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") == "" {
|
if sg := r.SignHere(); sg == nil || sg.Text.Get("sv") == "" {
|
||||||
t.Fatal("stilla på skyltrutan ska ge skylten")
|
t.Fatal("stilla på skyltrutan ska ge skylten")
|
||||||
}
|
}
|
||||||
@@ -256,7 +298,7 @@ func TestAskHelpRevealsSignAndTeachesWord(t *testing.T) {
|
|||||||
know := lang.NewKnowledge()
|
know := lang.NewKnowledge()
|
||||||
r := NewRun(l, RunOptions{Dict: loadDict(t), Know: know, Seed: rng.FromInt(1)})
|
r := NewRun(l, RunOptions{Dict: loadDict(t), Know: know, Seed: rng.FromInt(1)})
|
||||||
sign := l.Find(level.Sign)[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() {
|
if !r.AskHelp() {
|
||||||
t.Fatal("ska kunna fråga om hjälp på en skylt")
|
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")
|
l := loadLevel(t, "001-flygplatsen")
|
||||||
r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(1)})
|
r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(1)})
|
||||||
sign := l.Find(level.Sign)[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.AskHelp()
|
||||||
r.Step(frame, Left, false)
|
r.Step(frame, Left, false)
|
||||||
if r.Help.Active {
|
if r.Help.Active {
|
||||||
@@ -344,7 +386,7 @@ func TestGoodResultTeachesDestination(t *testing.T) {
|
|||||||
}{{"snabb", false, 3, true}, {"långsam", true, 1, false}} {
|
}{{"snabb", false, 3, true}, {"långsam", true, 1, false}} {
|
||||||
know := lang.NewKnowledge()
|
know := lang.NewKnowledge()
|
||||||
r := NewRun(l, RunOptions{Dict: loadDict(t), Know: know, Seed: rng.FromInt(2)})
|
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)
|
waitForTrain(t, r)
|
||||||
if tc.slow {
|
if tc.slow {
|
||||||
r.Time = 10 * r.ParSec()
|
r.Time = 10 * r.ParSec()
|
||||||
@@ -374,7 +416,7 @@ func TestReadingGivesSeededProgress(t *testing.T) {
|
|||||||
play := func(seed uint64) *lang.Knowledge {
|
play := func(seed uint64) *lang.Knowledge {
|
||||||
know := lang.NewKnowledge()
|
know := lang.NewKnowledge()
|
||||||
r := NewRun(l, RunOptions{Dict: loadDict(t), Know: know, Seed: rng.FromInt(seed)})
|
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
|
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{sign, left})
|
||||||
walk(t, r, []level.Point{left, sign})
|
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.
|
// Toalett: tar ToiletSecPerMember per person, avbryts om man går.
|
||||||
toilet := l.Find(level.Toilet)[0]
|
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.Meters.Toilet = 10
|
||||||
anna.Urge = NeedToilet
|
anna.Urge = NeedToilet
|
||||||
r.Step(frame, None, true)
|
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
|
for r.Player.Moving() { // gå klart rutan innan vi vänder tillbaka
|
||||||
r.Step(frame, None, false)
|
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)
|
r.Step(frame, None, true)
|
||||||
if !r.Facility.Active {
|
if !r.Facility.Active {
|
||||||
t.Fatal("besöket ska starta igen")
|
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: direkt, kostar KioskPriceYen per person från kortet.
|
||||||
kiosk := l.Find(level.Kiosk)[0]
|
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.Meters.Drink = 5
|
||||||
anna.Urge = Thirsty
|
anna.Urge = Thirsty
|
||||||
before := r.Wallet.Card
|
before := r.Wallet.Card
|
||||||
@@ -471,9 +513,10 @@ func TestRideWrongOrMissedStopLoses(t *testing.T) {
|
|||||||
reason string
|
reason string
|
||||||
}{{"fel station", true, LostWrongStop}, {"åker förbi", false, LostMissedStop}} {
|
}{{"fel station", true, LostWrongStop}, {"åker förbi", false, LostMissedStop}} {
|
||||||
r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(1)})
|
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)
|
waitForTrain(t, r)
|
||||||
r.Step(frame, None, true)
|
r.Step(frame, None, true)
|
||||||
|
pickCar(t, r)
|
||||||
g := r.Mini.(*minigame.Ride)
|
g := r.Mini.(*minigame.Ride)
|
||||||
if g.Dest < 1 {
|
if g.Dest < 1 {
|
||||||
t.Fatalf("resmålet ska ligga på linjen men inte först: %d", g.Dest)
|
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")
|
t.Fatal("tomt kort stoppas")
|
||||||
}
|
}
|
||||||
machine := l.Find(level.TicketMachine)[0]
|
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)
|
r.Step(frame, None, true)
|
||||||
g, ok := r.Mini.(*minigame.TopUp)
|
g, ok := r.Mini.(*minigame.TopUp)
|
||||||
if !ok || r.MiniKind != MiniTopUp {
|
if !ok || r.MiniKind != MiniTopUp {
|
||||||
@@ -548,7 +591,7 @@ func TestTicketLevelFlow(t *testing.T) {
|
|||||||
t.Fatal("utan biljett stoppar spärren även med kort")
|
t.Fatal("utan biljett stoppar spärren även med kort")
|
||||||
}
|
}
|
||||||
machine := l.Find(level.TicketMachine)[0]
|
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.
|
// Fel biljett först: tåget vägrar.
|
||||||
r.Step(frame, None, true)
|
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)
|
t.Fatalf("biljett=%q cash=%d", r.Wallet.Ticket, r.Wallet.Cash)
|
||||||
}
|
}
|
||||||
goal := l.CorrectPlatform()
|
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)
|
waitForTrain(t, r)
|
||||||
r.Step(frame, None, true)
|
r.Step(frame, None, true)
|
||||||
if r.Mini != nil || r.FlashKey != FlashWrongTicket || r.Outcome != Ongoing {
|
if r.Mini != nil || r.FlashKey != FlashWrongTicket || r.Outcome != Ongoing {
|
||||||
@@ -571,7 +614,7 @@ func TestTicketLevelFlow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Rätt biljett.
|
// 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)
|
r.Step(frame, None, true)
|
||||||
tk = r.Mini.(*minigame.Ticket)
|
tk = r.Mini.(*minigame.Ticket)
|
||||||
for tk.Options[tk.Cursor].Dest != "渋谷" {
|
for tk.Options[tk.Cursor].Dest != "渋谷" {
|
||||||
@@ -582,9 +625,17 @@ func TestTicketLevelFlow(t *testing.T) {
|
|||||||
if r.Wallet.Ticket != "渋谷" {
|
if r.Wallet.Ticket != "渋谷" {
|
||||||
t.Fatalf("biljett=%q", 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)
|
waitForTrain(t, r)
|
||||||
r.Step(frame, None, true)
|
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)
|
cr, ok := r.Mini.(*minigame.Crowd)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("fullt tåg ska ge trängselminispel, fick %T", r.Mini)
|
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.Party.nextEventAt = 1e9
|
||||||
r.Wallet.Ticket = "渋谷"
|
r.Wallet.Ticket = "渋谷"
|
||||||
goal := l.CorrectPlatform()
|
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)
|
waitForTrain(t, r)
|
||||||
r.Step(frame, None, true)
|
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)
|
cr := r.Mini.(*minigame.Crowd)
|
||||||
for n := 0; n < CrowdTries; n++ {
|
for n := 0; n < CrowdTries; n++ {
|
||||||
for cr.InZone() {
|
for cr.InZone() {
|
||||||
@@ -641,7 +697,7 @@ func TestPhoneSpeedRescanAndBattery(t *testing.T) {
|
|||||||
r.Step(frame, None, false)
|
r.Step(frame, None, false)
|
||||||
}
|
}
|
||||||
gate := l.Find(level.Gate)[0]
|
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
|
for i, p := range path { // stanna på den första spärrutan – där sker skanningen
|
||||||
if l.At(p) == level.Gate {
|
if l.At(p) == level.Gate {
|
||||||
path = path[:i+1]
|
path = path[:i+1]
|
||||||
@@ -669,7 +725,7 @@ func TestPhoneSpeedRescanAndBattery(t *testing.T) {
|
|||||||
r := NewRun(l, RunOptions{Seed: rng.FromInt(5)})
|
r := NewRun(l, RunOptions{Seed: rng.FromInt(5)})
|
||||||
r.Party.nextEventAt = 1e9
|
r.Party.nextEventAt = 1e9
|
||||||
r.Wallet.Phone = true
|
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
|
return r.Rescans
|
||||||
}
|
}
|
||||||
if run() != run() {
|
if run() != run() {
|
||||||
@@ -720,12 +776,12 @@ func TestScoreBreakdown(t *testing.T) {
|
|||||||
l := loadLevel(t, "001-flygplatsen")
|
l := loadLevel(t, "001-flygplatsen")
|
||||||
r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(2)})
|
r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(2)})
|
||||||
r.Party.nextEventAt = 1e9
|
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)
|
waitForTrain(t, r)
|
||||||
r.Step(frame, None, true)
|
r.Step(frame, None, true)
|
||||||
ride(t, r)
|
ride(t, r)
|
||||||
sc := r.Score
|
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)
|
t.Fatalf("score=%+v", sc)
|
||||||
}
|
}
|
||||||
if sc.Time != ScoreTimeMax || sc.Money != ScoreMoneyMax-l.FareYen/10 || sc.Penalty != 0 {
|
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() {
|
for r.Player.Moving() {
|
||||||
r.Step(frame, None, false)
|
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()
|
r.AskHelp()
|
||||||
if want := lang.HelpSeconds(0) / 0.7; math.Abs(r.Help.Total-want) > 1e-9 {
|
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)
|
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")
|
t.Fatal("utan saldo på Annas kort stoppar spärren alla")
|
||||||
}
|
}
|
||||||
// Fyll på Annas kort i automaten (personval först).
|
// 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)
|
r.Step(frame, None, true)
|
||||||
g := r.Mini.(*minigame.TopUp)
|
g := r.Mini.(*minigame.TopUp)
|
||||||
if g.Stage != minigame.TopUpPickMember || len(g.Members) != 2 {
|
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.
|
// Spärren drar från allas kort.
|
||||||
before := r.Wallet.Spent
|
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 {
|
for i, p := range path {
|
||||||
if l1.At(p) == level.Gate {
|
if l1.At(p) == level.Gate {
|
||||||
path = path[:i+1]
|
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 := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(21)})
|
||||||
r.Party.nextEventAt = 1e9
|
r.Party.nextEventAt = 1e9
|
||||||
machine := l.Find(level.TicketMachine)[0]
|
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)
|
r.Step(frame, None, true)
|
||||||
tk := r.Mini.(*minigame.Ticket)
|
tk := r.Mini.(*minigame.Ticket)
|
||||||
if len(tk.Members) != 4 || tk.Cash != StartCashYen/4 {
|
if len(tk.Members) != 4 || tk.Cash != StartCashYen/4 {
|
||||||
@@ -896,15 +952,10 @@ func TestLevel003Flow(t *testing.T) {
|
|||||||
t.Fatal("missnöje ska flashas")
|
t.Fatal("missnöje ska flashas")
|
||||||
}
|
}
|
||||||
goal := l.CorrectPlatform()
|
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)
|
waitForTrain(t, r)
|
||||||
r.Step(frame, None, true)
|
r.Step(frame, None, true)
|
||||||
cr := r.Mini.(*minigame.Crowd)
|
ride(t, r) // vagnval, ev. trängsel, resa
|
||||||
for i := 0; i < 600 && !cr.InZone(); i++ {
|
|
||||||
r.StepMini(frame, minigame.Input{})
|
|
||||||
}
|
|
||||||
r.StepMini(frame, minigame.Input{Confirm: true})
|
|
||||||
ride(t, r)
|
|
||||||
if r.Outcome != Won || r.Wallet.TicketCount != 0 {
|
if r.Outcome != Won || r.Wallet.TicketCount != 0 {
|
||||||
t.Fatalf("outcome=%v reason=%q tickets=%d", r.Outcome, r.LostReason, r.Wallet.TicketCount)
|
t.Fatalf("outcome=%v reason=%q tickets=%d", r.Outcome, r.LostReason, r.Wallet.TicketCount)
|
||||||
}
|
}
|
||||||
@@ -917,7 +968,7 @@ func TestNearTrackAndPhases(t *testing.T) {
|
|||||||
if _, _, _, _, _, ok := r.NearTrack(2); ok {
|
if _, _, _, _, _, ok := r.NearTrack(2); ok {
|
||||||
t.Fatal("från starten är ingen perrong nära")
|
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)
|
tr, p, phase, prog, dist, ok := r.NearTrack(2)
|
||||||
if !ok || dist != 0 || p != l.CorrectPlatform() || !tr.Correct || prog < 0 || prog > 1 {
|
if !ok || dist != 0 || p != l.CorrectPlatform() || !tr.Correct || prog < 0 || prog > 1 {
|
||||||
t.Fatalf("ok=%v dist=%d p=%v", ok, dist, p)
|
t.Fatalf("ok=%v dist=%d p=%v", ok, dist, p)
|
||||||
@@ -987,7 +1038,7 @@ func TestRunOptionsFromProgression(t *testing.T) {
|
|||||||
for r.Player.Moving() {
|
for r.Player.Moving() {
|
||||||
r.Step(frame, None, false)
|
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 {
|
for i, p := range path {
|
||||||
if l.At(p) == level.Gate {
|
if l.At(p) == level.Gate {
|
||||||
path = path[:i+1]
|
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
|
// 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.
|
// 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}})
|
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 {
|
if path == nil {
|
||||||
t.Fatal("ingen väg")
|
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)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
134
resources/data/levels/005-ueno.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -7,7 +7,10 @@ OGG (musik, längre ljud).
|
|||||||
ett tåg är på väg in till perrongen (fasen *approaching*).
|
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*).
|
- `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).
|
inspelningar/kompositioner ersätter dem i M12 (behåll filnamnen).
|
||||||
Planerat: utrop, folkmassa, spärr-pip, automat-ljud, UI-klick, bakgrundsprat
|
Planerat: utrop, folkmassa, spärr-pip, automat-ljud, UI-klick, bakgrundsprat
|
||||||
och reklam som ljudröra (M9b).
|
och reklam som ljudröra (M9b).
|
||||||
|
|||||||
BIN
resources/sound/chatter.wav
Normal file
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.4 KiB |
@@ -139,20 +139,20 @@
|
|||||||
"map.side": "Side trip",
|
"map.side": "Side trip",
|
||||||
"map.party": "Party",
|
"map.party": "Party",
|
||||||
"map.resume": "⟳ resume (saved seed)",
|
"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",
|
"map.hint": "Arrows: level Enter: start L: words T: skills I: wardrobe P: phone Esc",
|
||||||
"skills.title": "Skills 技",
|
"skills.title": "Skills 技",
|
||||||
"skills.to_next": "XP to next",
|
"skills.to_next": "XP to next",
|
||||||
"skills.already": "Already unlocked.",
|
"skills.already": "Already unlocked.",
|
||||||
"skills.unlocked": "Unlocked: %s",
|
"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.need_req": "Requires an earlier skill in the branch.",
|
||||||
"skills.hint": "Arrows: select Enter: unlock Esc/T: back",
|
"skills.hint": "Arrows: select Enter: unlock Esc/T: back",
|
||||||
"items.title": "Wardrobe 持ち物",
|
"items.title": "Wardrobe 持ち物",
|
||||||
"items.owned": "items",
|
"items.owned": "items",
|
||||||
"items.owned_short": "owned",
|
"items.owned_short": "owned",
|
||||||
"items.wearing": "worn",
|
"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.worn": "You wear %s.",
|
||||||
"items.removed": "You took off %s.",
|
"items.removed": "You took off %s.",
|
||||||
"items.total": "Total",
|
"items.total": "Total",
|
||||||
@@ -181,5 +181,21 @@
|
|||||||
"effect.landmarks": "landmarks on map",
|
"effect.landmarks": "landmarks on map",
|
||||||
"effect.free_gates": "free gates",
|
"effect.free_gates": "free gates",
|
||||||
"effect.reveal": "map: %s",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -588,5 +588,62 @@
|
|||||||
"en": "down",
|
"en": "down",
|
||||||
"sv": "ner",
|
"sv": "ner",
|
||||||
"tier": 1
|
"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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,20 +139,20 @@
|
|||||||
"map.side": "Sidospår",
|
"map.side": "Sidospår",
|
||||||
"map.party": "Sällskap",
|
"map.party": "Sällskap",
|
||||||
"map.resume": "⟳ fortsätt (sparad seed)",
|
"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",
|
"map.hint": "Pilar: bana Enter: start L: ord T: skills I: garderob P: tel Esc",
|
||||||
"skills.title": "Skills 技",
|
"skills.title": "Skills 技",
|
||||||
"skills.to_next": "XP till nästa",
|
"skills.to_next": "XP till nästa",
|
||||||
"skills.already": "Redan upplåst.",
|
"skills.already": "Redan upplåst.",
|
||||||
"skills.unlocked": "Upplåst: %s",
|
"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.need_req": "Kräver en tidigare skill i grenen.",
|
||||||
"skills.hint": "Pilar: välj Enter: lås upp Esc/T: tillbaka",
|
"skills.hint": "Pilar: välj Enter: lås upp Esc/T: tillbaka",
|
||||||
"items.title": "Garderob 持ち物",
|
"items.title": "Garderob 持ち物",
|
||||||
"items.owned": "items",
|
"items.owned": "items",
|
||||||
"items.owned_short": "ägs",
|
"items.owned_short": "ägs",
|
||||||
"items.wearing": "bärs",
|
"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.worn": "Du bär %s.",
|
||||||
"items.removed": "Du tog av %s.",
|
"items.removed": "Du tog av %s.",
|
||||||
"items.total": "Totalt",
|
"items.total": "Totalt",
|
||||||
@@ -181,5 +181,21 @@
|
|||||||
"effect.landmarks": "landmärken på kartan",
|
"effect.landmarks": "landmärken på kartan",
|
||||||
"effect.free_gates": "gratis spärrar",
|
"effect.free_gates": "gratis spärrar",
|
||||||
"effect.reveal": "karta: %s",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
//
|
//
|
||||||
// Skriver resources/sprites/tiles.png (en remsa 16×16-rutor i ordningen
|
// Skriver resources/sprites/tiles.png (en remsa 16×16-rutor i ordningen
|
||||||
// vägg, golv, start, perrong, spärr, automat, skylt, tågvagn, toalett, kiosk, räls,
|
// 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,
|
// resources/sprites/player.png (3 bildrutor × 4 riktningar: ner, upp,
|
||||||
// vänster, höger; kolumn 0 = stilla, 1–2 = gå). Ritas med enkla rektanglar
|
// vänster, höger; kolumn 0 = stilla, 1–2 = gå). Ritas med enkla rektanglar
|
||||||
// så att allt är deterministiskt och lätt att justera.
|
// så att allt är deterministiskt och lätt att justera.
|
||||||
@@ -163,6 +163,44 @@ var tiles = []func(canvas){
|
|||||||
c.rect(6, 13, 4, 1, boxDark)
|
c.rect(6, 13, 4, 1, boxDark)
|
||||||
c.rect(5, 12, 6, 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
|
func(c canvas) { // kiosk: randig markis och disk
|
||||||
floor(c)
|
floor(c)
|
||||||
c.rect(1, 1, 14, 3, awningRed)
|
c.rect(1, 1, 14, 3, awningRed)
|
||||||
|
|||||||
@@ -83,12 +83,39 @@ func writeWAV(path string, pcm []int16) error {
|
|||||||
return binary.Write(f, binary.LittleEndian, pcm)
|
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() {
|
func main() {
|
||||||
out := filepath.Join(repoRoot(), "resources", "sound")
|
out := filepath.Join(repoRoot(), "resources", "sound")
|
||||||
must(os.MkdirAll(out, 0o755))
|
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, "melody-approach.wav"), render(approachMelody, 0.01, 0.06, 0.3)))
|
||||||
must(writeWAV(filepath.Join(out, "door-chime.wav"), render(doorChime, 0.005, 0.04, 0.1)))
|
must(writeWAV(filepath.Join(out, "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 {
|
func repoRoot() string {
|
||||||
|
|||||||
@@ -102,6 +102,14 @@ func buildShots(levelsDir string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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
|
// 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.
|
// delvisa översättningen syns; hjälpen lär sedan in nästa ord.
|
||||||
known := []string{"-know", "改札,番線,方面", "-seed", "7"}
|
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",
|
{"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"}},
|
[]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"}},
|
{"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
|
return nil
|
||||||
}
|
}
|
||||||
@@ -190,6 +199,9 @@ func main() {
|
|||||||
}
|
}
|
||||||
_ = demo
|
_ = demo
|
||||||
for _, s := range shots {
|
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)}
|
args := []string{"-mute", "-nosave", "-script", strings.ReplaceAll(s.script, "$OUT", out)}
|
||||||
if s.level != "" {
|
if s.level != "" {
|
||||||
args = append(args, "-level", s.level)
|
args = append(args, "-level", s.level)
|
||||||
@@ -234,3 +246,13 @@ func fail(err error) {
|
|||||||
fmt.Fprintln(os.Stderr, "shots:", err)
|
fmt.Fprintln(os.Stderr, "shots:", err)
|
||||||
os.Exit(1)
|
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
|
||||||
|
}
|
||||||
|
|||||||