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>
This commit is contained in:
2026-10-24 12:33:44 +02:00
parent 8fca14e42d
commit de61568e44
49 changed files with 1568 additions and 136 deletions

View File

@@ -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).
![Bankartan](doc/img/map.png) ![Bankartan](doc/img/map.png)
@@ -56,6 +58,13 @@ tar dig upp och ner, och minikartan visar vilket plan du är på.
![Tokyo station, 1F](doc/img/floors-1f.png) ![Tokyo station, 1F](doc/img/floors-1f.png)
På svåra banor är stationen full av folk, väggarna av reklam och luften av
sorl. Tågen har flera vagnar: låt folk kliva av först, välj en vagn med lite
folk för extra poäng, och undvik den rosa kvinnovagnen om det finns en man i
sällskapet.
![Vagnval](doc/img/cars.png)
På de svåraste banorna har alla i sällskapet egna kort som måste ha saldo, 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

BIN
doc/img/cars-alight.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
doc/img/cars.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
doc/img/crowd.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

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

View File

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

View File

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

View File

@@ -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/

View File

@@ -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 {

View File

@@ -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) }

View File

@@ -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 {

View File

@@ -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
View 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

View File

@@ -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 {

View File

@@ -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 {

View File

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

View File

@@ -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 }
@@ -162,13 +167,34 @@ func (s Schedule) Present(t float64) bool {
// Train är ett tåg (eller en buss) som avgår från en perrong i banan. // Train är ett tåg (eller en buss) som avgår från en perrong i banan.
type Train struct { type Train struct {
Platform string `json:"platform"` // vilket perrong-tecken (P/p) tåget står vid Platform string `json:"platform"` // vilket perrong-tecken (P/p) tåget står vid
Track string `json:"track"` // spårnummer som visas på skyltar/HUD, t.ex. "2" Track string `json:"track"` // spårnummer som visas på skyltar/HUD, t.ex. "2"
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)
Schedule Schedule `json:"schedule"` Cars int `json:"cars"` // antal vagnar (0 = DefaultCars)
WomenOnlyCar int `json:"womenOnlyCar"` // 1-baserat vagnnummer för kvinnovagn, 0 = ingen
WomenOnlyFrom int `json:"womenOnlyFrom"` // kvinnovagn gäller från den här bantiden (s); 0 och To=0 = alltid
WomenOnlyTo int `json:"womenOnlyTo"` // …till den här bantiden (s)
Schedule Schedule `json:"schedule"`
}
// DefaultCars och gränser för vagnar.
const (
DefaultCars = 3
MaxCars = 8
)
// WomenOnlyActive rapporterar om kvinnovagnen gäller vid bantiden t.
func (tr *Train) WomenOnlyActive(t float64) bool {
if tr.WomenOnlyCar == 0 {
return false
}
if tr.WomenOnlyFrom == 0 && tr.WomenOnlyTo == 0 {
return true
}
return t >= float64(tr.WomenOnlyFrom) && t < float64(tr.WomenOnlyTo)
} }
// SignText är texten på en skyltruta ('i'). // SignText är texten på en skyltruta ('i').
@@ -199,7 +225,10 @@ type Level struct {
Order int `json:"order"` // huvudbanornas ordning på bankartan (1, 2, …); sidobanor ärver förälderns Order int `json:"order"` // huvudbanornas ordning på bankartan (1, 2, …); sidobanor ärver förälderns
Parent string `json:"parent"` // sidobanans huvudbana – låses upp när den är klarad 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)
} }

View File

@@ -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")
}
}

View 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
}
}

View File

@@ -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)}
} }

View File

@@ -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")
}
}
}

View File

@@ -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
View 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
View 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)
}
}

View File

@@ -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))

View File

@@ -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).
@@ -209,26 +239,33 @@ func NewRun(l *level.Level, o RunOptions) *Run {
o.Wallet = NewWallet() o.Wallet = NewWallet()
} }
r := &Run{ r := &Run{
Level: l, Level: l,
Player: NewMover(l.SpawnPoint(), o.Speed), Player: NewMover(l.SpawnPoint(), o.Speed),
Wallet: o.Wallet, Wallet: o.Wallet,
FlashAt: -1, FlashAt: -1,
DeniedAt: -1, DeniedAt: -1,
Dict: o.Dict, Dict: o.Dict,
Know: o.Know, Know: o.Know,
Revealed: map[level.Point]bool{}, Revealed: map[level.Point]bool{},
LearnedAt: -1, LearnedAt: -1,
Seed: o.Seed, Seed: o.Seed,
Visits: map[level.Point]int{}, Visits: map[level.Point]int{},
lastTile: l.SpawnPoint(), lastTile: l.SpawnPoint(),
ProgressedAt: -1, ProgressedAt: -1,
parSec: float64(l.ParSec), parSec: float64(l.ParSec),
Party: NewParty(l.Party, l.SpawnPoint(), o.Seed.Derive("party")), Party: NewParty(l.Party, l.SpawnPoint(), o.Seed.Derive("party")),
baseSpeed: o.Speed, baseSpeed: o.Speed,
NoticeAt: -1, NoticeAt: -1,
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 {

View File

@@ -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")
}
}

View 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"
}
]
}

View File

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

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -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"
} }

View File

@@ -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
} }
} }

View File

@@ -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"
} }

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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
}