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+
upp. Hela speldesignen finns i [doc/spelbeskrivning.md](doc/spelbeskrivning.md).
**Status:** M9a klar (2026-10-24) – fem banor på en bankarta i
spelbrädesstil, med sidospår. Japanska skyltar och ordinlärning,
lämmeltåget, minikarta, tågkort, biljetter och minispel, svårighet med
tidsgräns, poäng och debuffar, tåg som rullar in till avgångsmelodin,
progression med XP, skillträd, items och sparfil. Och nu stationer i flera
våningar: Tokyo station börjar under mark och trappan tar dig upp till
perrongerna. Nästa är M9b: folk, reklam och ljudröra. Se
**Status:** M9b klar (2026-10-24) – sex banor på en bankarta i
spelbrädesstil, med sidospår och flera våningar. Japanska skyltar och
ordinlärning, lämmeltåget, minikarta, tågkort, biljetter och minispel,
svårighet med tidsgräns, poäng och debuffar, tåg som rullar in till
avgångsmelodin, progression med XP, skillträd, items och sparfil. Och nu
lever stationen: folk som går i vägen, filer där man ska hålla till vänster,
reklam på väggarna, sorl i bakgrunden, och tåg med flera vagnar där man
väljer vagn, låter folk kliva av först och aktar sig för kvinnovagnen.
Nästa är M9: navigeringslägena lista och skyltläge. Se
[doc/roadmap.md](doc/roadmap.md).
![Bankartan](doc/img/map.png)
@@ -56,6 +58,13 @@ tar dig upp och ner, och minikartan visar vilket plan du är på.
![Tokyo station, 1F](doc/img/floors-1f.png)
På svåra banor är stationen full av folk, väggarna av reklam och luften av
sorl. Tågen har flera vagnar: låt folk kliva av först, välj en vagn med lite
folk för extra poäng, och undvik den rosa kvinnovagnen om det finns en man i
sällskapet.
![Vagnval](doc/img/cars.png)
På de svåraste banorna har alla i sällskapet egna kort som måste ha saldo,
alla behöver biljett, och när biljetten är köpt ska sällskapet placeras i
vagnen efter sina önskemål: fönster eller gång, vänster eller höger sida,
@@ -176,7 +185,7 @@ Y = hjälp, X = ordlista, Back/Select = karta.
| `go run ./tools/build …` | bygger (se ovan) |
| `go run ./tools/genart` | genererar placeholder-grafiken `resources/sprites/tiles.png` och `player.png` |
| `go run ./tools/gensound` | genererar placeholder-ljuden `resources/sound/*.wav` (avgångsmelodi, dörrsignal) |
| `go run ./tools/shots` | tar dokumentationens skärmdumpar till `doc/img/` – **kör efter varje grafik-/UI-ändring och committa bilderna**. På Linux körs spelet på en osynlig skärm (headless Wayfire + Xwayland) så att inget fönster syns eller tar fokus; `METRO_HEADLESS=0` använder skärmen |
| `go run ./tools/shots [ord…]` | tar dokumentationens skärmdumpar till `doc/img/` – **kör efter varje grafik-/UI-ändring och committa bilderna**. Med argument körs bara skott vars bana eller skript innehåller något av orden (t.ex. `go run ./tools/shots 005 ride.png`). På Linux körs spelet på en osynlig skärm (headless Wayfire + Xwayland) så att inget fönster syns eller tar fokus; `METRO_HEADLESS=0` använder skärmen |
## Beroenden

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 |
| Release-binär | < 20 MB | 10,2 MB (Linux), 10,8 MB (Windows) |
| Testsviten | `go test ./...` grön på < 30 s | ~0,1 s |
| Banor | 100 % passerar det automatiska lösbarhetstestet | 5 av 5 |
| Banor | 100 % passerar det automatiska lösbarhetstestet | 6 av 6 |
| Windows-korsbygge från Linux | fungerar utan extra verktyg | ✓ (CGO_ENABLED=0) |
| Linux-binärens länkade bibliotek | bara libc | ✓ (GL/X11 laddas dynamiskt vid körning) |
| Bildfrekvens | 60 fps vid 4× skala på integrerad grafik | ej mätt |
@@ -126,6 +126,10 @@ att bygga på Linux som på Windows.
| `internal/minigame` | `TestCrowdLuckWidensZone` | Tur gör zonen bredare, otur smalare, klampat |
| `internal/level` | `TestFloorsStairsAndPaths`, `TestFloorsValidation` | **M9a:** flera våningar läses, trappor länkar, BFS-vägen byter våning bara via trappa, spärregeln och skyltar per våning; trappa utan motpart, olika storlekar, grid+floors och fel våning avvisas |
| `internal/sim` | `TestStairsMoveBetweenFloors` | Att kliva på trappan flyttar spelaren en våning, dimman lättar runt andra änden, perrongen hörs bara på egen våning, boten går hela vägen över våningar |
| `internal/level` | `TestCarsWomenOnlyAndCrowdFields` | **M9b:** vagnar, kvinnovagnens tidsfönster, crowd/clutter/noise 0–3, filrutor |
| `internal/minigame` | `TestCarPickAlightingAndChoice`, `TestCrowdLevelNarrowsZone`, `TestRideCrowdLetPass` | Folk av först (för tidigt räknas), seedad trängsel med minst en lugn vagn, val; trängsel smalnar zonen; resan: kortare tid per station, passagerare som vill förbi, blockerade räknas |
| `internal/sim` | `TestNPCsWanderStayOnFloorAndBlockPlayer`, `TestAlightingNPCsSpawnAndLeave` | Folk rör sig på golv, seedat, blockerar spelaren; avstigande spawnar när dörrarna öppnas och lämnar via utgången |
| `internal/sim` | `TestLanesSlowWrongSide`, `TestCarsWomenOnlyEarlyAndCrowd` | Fel sida i filen bromsar; kvinnovagn med man ger grova minus, för tidigt ombord räknas, resan ärver vagnens trängsel, lugn vagn ger bonus |
| `internal/archtest` | `TestOnlyPresentationPackagesImportEbitengine` | Beroenderegeln ovan |
### Planerade tester per milstolpe
@@ -140,7 +144,7 @@ att bygga på Linux som på Windows.
| ~~M6 Kort/biljetter/minispel~~ ✓ | Saldo, automat, biljetter, omskanning, batteri – finns, se tabellen ovan |
| ~~M7 Svårighet/poäng/debuffs~~ ✓ | Poäng, tidsgräns, debuffar, egna kort, sittplatsval – finns, se tabellen ovan |
| ~~M7b Tåg som kommer och går~~ ✓ | Faser, ombordstigning, indikatoravstånd – finns, se tabellen ovan; ljud och animation verifieras manuellt/med skärmdumpar |
| M9b Folk, reklam, ljudröra | NPC:er följer seedade mål utan att fastna; krock saktar ned; fel sida i gång bromsar; bot klarar bana med folk; fler NPC:er = längre tid; clutter/noise läses per bana |
| ~~M9b Folk, reklam, ljudröra, vagnar~~ ✓ | NPC:er, filer, röra, vagnval – finns, se tabellen ovan (bot med folk: testerna tömmer folket där det skulle störa, "fler NPC:er = längre tid" mäts manuellt) |
| ~~M8 Progression & sparfil~~ ✓ | Round-trip, skillkrav, upplåsningar, 33 %-regeln – finns, se tabellen ovan |
| ~~M9a Flera våningar~~ ✓ | Trappor, vägar över våningar, dimma per våning, gamla banor oförändrade – finns, se tabellen ovan |
| M9 Navigeringslägen | Listläge genererar rätt ordning av tåg; skyltläge: 1–4 giltiga vägar; fel-riktnings-förlust triggar vid rätt slutstation |

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 ✓
→ M4 lämmeltåget ✓ → M5 minikarta ✓ → M6 kort/biljetter/minispel ✓ → M7
svårighet/poäng/debuffar ✓ → M7b tåg som kommer och går ✓ → M8 progression/sparfiler ✓ → M9a flera våningar ✓ → M9b folk/reklam/ljudröra → M9 navigeringslägen →
svårighet/poäng/debuffar ✓ → M7b tåg som kommer och går ✓ → M8 progression/sparfiler ✓ → M9a flera våningar ✓ → M9b folk/reklam/ljudröra/vagnar ✓ → M9 navigeringslägen →
M10 story-innehåll → M11 oändligt läge/NG+ → M12 ljud/polish/release.
Innehåll per milstolpe: [roadmap.md › Milstolpar](roadmap.md#milstolpar).
Vad som ska vara testat per milstolpe: [mal-och-tester.md](mal-och-tester.md).

View File

@@ -100,7 +100,7 @@ skriptläge så att de alltid är pixelexakta och går att återskapa:
```bash
go run ./tools/genart # bara om placeholder-grafiken ska genereras om
go run ./tools/shots # bygger debug, kör spelet med -script, skriver doc/img/*.png
go run ./tools/shots [ord…] # bygger debug, kör spelet med -script, skriver doc/img/*.png (ord filtrerar på bana/skript)
```
**Regel:** när sprites, tileset eller UI ändras – kör `tools/shots` och
@@ -428,7 +428,7 @@ på **brasse-linux01-runnern** (label `linux-amd64`), aldrig på Pi5:n.
(`TestStairsMoveBetweenFloors`, `TestFloorsStairsAndPaths`), minikartan
visar rätt våning, och alla gamla banor fungerar oförändrat (en våning). ✓
### M9b – Folk, reklam och ljudröra (Björns tillägg 2026-10-24)
### M9b – Folk, reklam och ljudröra ✓ (klar 2026-10-24)
- **Mål:** stationen lever och överstimulerar – tre nya svårighetsvariabler.
- **Folk (NPC:er):** passagerare som går på och av tågen när dörrarna är
@@ -443,11 +443,44 @@ på **brasse-linux01-runnern** (label `linux-amd64`), aldrig på Pi5:n.
och blinkande skyltar på väggarna, lockskyltar som liknar riktiga.
- **Ljudröra** (`noise`-nivå): japanskt prat, utrop och reklam i bakgrunden
som konkurrerar med melodierna; på de svåraste nivåerna överlappar de.
- **Vagnar** (Björns tillägg 2026-10-24): tåg har `cars` vagnar; när tåget
står inne väljer man vagn på perrongen (kömarkeringar visar var man
ställer sig). **Folk kliver av först** – trycker man för tidigt blir det
avdrag. Varje vagn har en seedad **trängselnivå** (lite/mellan/mycket):
lite folk ger poängbonus, mycket folk gör trängselminispelet och resan
svårare (kortare tid per station, passagerare som måste **släppas förbi**
vid stationer där man inte ska av) och kan ge debuffen *svettig*.
**Kvinnovagn** (`womenOnlyCar`, ev. bara mellan vissa klockslag som står
på skyltarna) markeras rosa; har sällskapet en man och går på den blir det
**grova minus**. Sällskapets medlemmar får därför kön.
- **Teknik:** `sim.NPC` med enkla mål och BFS-vägar (ren Go, seedade),
dekorationslager i banformatet, `tools/genart` för folk/reklam,
`tools/gensound` för brusplaceholders; generatorn får bredd och sidor.
- **Klar när:** en bana med folk spelas av boten headless utan att fastna,
fler NPC:er ger mätbart längre tid, och röra/ljud styrs per bana.
`minigame.CarPick`, trängsel i `Crowd`/`Ride`, dekorationslager
(seedade affischer) och `clutter`/`noise`/`crowd` i banformatet,
`tools/genart` för folk/affischer/vagnmarkeringar, `tools/gensound` för
bakgrundsprat; generatorn får breda gångar med filer.
- **Så blev det:** `sim.NPC` (vandrare mellan kiosker/toaletter/skyltar/
perronger med BFS-vägar och seedade pauser; avstigande som kliver av när
dörrarna öppnas och går till utgången) tar plats i rutnätet – spelaren
stoppas ("Ursäkta…"), folket går igenom varandra så inget låser sig.
Filer `<`/`>` i breda gångar: fel sida ger ×0,7 fart. `clutter` ger
seedade affischer på väggar intill gångar (tre varianter, en liknar en
skylt); `noise` ger loopat bakgrundsprat (`chatter.wav`) som dämpar
melodierna. **Vagnval** (`minigame.CarPick`): folk kliver av i 1,5 s
(Enter för tidigt = avdrag), vagnarna visas med trängselfigurer och
kömarkeringar, kvinnovagn rosa med 女性専用; lugn vagn +50, full vagn ger
trängselminispel med smalare zon, kortare tid per station och
passagerare som ska släppas förbi (← ), samt 60 % risk för *svettig*.
Man i sällskapet på kvinnovagnen: −200. Sällskapet har kön (Anna/Sara
kvinnor, Ville/Omar män, spelaren okänd). **Bana 005 Ueno** (svårighet
4): breda gångar med filer, folk 2, röra 2, ljud 2, fyra vagnar där vagn
1 är kvinnovagn de första 2,5 minuterna.
- **Klar när:** NPC:er rör sig på golv, är seedade och blockerar spelaren
(`TestNPCsWanderStayOnFloorAndBlockPlayer`), avstigande kommer och går
(`TestAlightingNPCsSpawnAndLeave`), filer bromsar (`TestLanesSlowWrongSide`),
vagnval/kvinnovagn/trängsel/avdrag (`TestCarsWomenOnlyEarlyAndCrowd`,
`TestCarPickAlightingAndChoice`, `TestRideCrowdLetPass`). ✓
- **Kvar till senare:** NPC:er som väntar på och kliver på tåget, folk som
bildar riktiga köer vid markeringarna, utrop/reklam som ljud (M12).
### M9 – Navigeringslägen: lista och skyltläge

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
man kan gå mellan folk.
**Vagnar** (tillägg 2026-10-24):
- Vissa tåg har **flera vagnar**, och man väljer vilken man kliver på.
- Det kan finnas **kvinnovagnar** (女性専用車). Har man en man i sällskapet
och går på en sådan får man **grova minus**. Vagnen kan vara kvinnovagn
**bara under vissa tider**, som står på skyltarna, och den markeras **rosa**.
- Perrongen har **kömarkeringar** som visar var man ställer sig för att komma
på en vagn.
- Vagnarna har **olika mycket folk**. Tar man en vagn med **mindre folk** får
man **mer poäng**. Tar man en med **mycket folk** kan man få **debuffar** av
att bli svettig, och **minispelet blir svårare**: det blir svårare att kliva
av på rätt station, och man måste **släppa förbi andra passagerare** som ska
av på stationer där man själv inte ska av.
- Man **släpper av folk från tåget först** innan man får gå på.
**Flera våningar** (tillägg 2026-10-24):
- Banor och stationer kan ha **flera våningar**. Trappor (och rulltrappor/

View File

@@ -28,12 +28,16 @@ var tileOrder = []level.Tile{
level.Toilet,
level.Wall, // plats 9 = rälsen (laddas separat som Rails)
level.StairsUp, level.StairsDown,
level.LaneLeft, level.LaneRight,
level.Wall, level.Wall, level.Wall, // plats 13–15 = affischer (laddas separat som Posters)
level.Kiosk,
}
const (
trainIndex = 7
railsIndex = 9
posterIndex = 14
posterCount = 3
)
// Assets är allt som laddats från resources/.
@@ -43,6 +47,7 @@ type Assets struct {
Tiles map[level.Tile]*ebiten.Image
Train *ebiten.Image // tågvagn, ritas längs rälsen när tåget syns
Rails *ebiten.Image // rälsruta (horisontell), roteras för vertikala spår
Posters []*ebiten.Image // affischer för visuell röra (ritas över väggrutor)
Player [4][3]*ebiten.Image // [riktning: ner, upp, vänster, höger][bildruta: stilla, gå1, gå2]
Text map[string]string
Dict *lang.Dictionary
@@ -70,7 +75,7 @@ func LoadAssets() (*Assets, error) {
return nil, fmt.Errorf("tiles.png är %d px bred, behöver minst %d", w, need)
}
for i, t := range tileOrder {
if i == trainIndex || i == railsIndex {
if i == trainIndex || i == railsIndex || (i >= posterIndex && i < posterIndex+posterCount) {
continue
}
a.Tiles[t] = sub(i)
@@ -78,6 +83,9 @@ func LoadAssets() (*Assets, error) {
a.Tiles[level.WrongPlatform] = a.Tiles[level.Platform] // samma utseende; tåget avgör
a.Train = sub(trainIndex)
a.Rails = sub(railsIndex)
for i := 0; i < posterCount; i++ {
a.Posters = append(a.Posters, sub(posterIndex+i))
}
player, err := loadImage("sprites", "player.png")
if err != nil {

View File

@@ -17,6 +17,7 @@ import (
type Audio struct {
ctx *audio.Context
clips map[string][]byte // avkodad PCM (16-bit stereo LE) per klipp
loop *audio.Player // pågående bakgrundsloop (bakgrundsprat)
}
const sampleRate = 44100
@@ -25,6 +26,7 @@ const sampleRate = 44100
const (
SoundApproach = "melody-approach"
SoundDoors = "door-chime"
SoundChatter = "chatter"
)
// AudibleTiles är hur många rutor bort man hör perrongens ljud.
@@ -36,7 +38,7 @@ func newAudio(mute bool) (*Audio, error) {
return nil, nil
}
a := &Audio{ctx: audio.NewContext(sampleRate), clips: map[string][]byte{}}
for _, name := range []string{SoundApproach, SoundDoors} {
for _, name := range []string{SoundApproach, SoundDoors, SoundChatter} {
b, err := res.ReadFile("sound", name+".wav")
if err != nil {
return nil, err
@@ -68,5 +70,34 @@ func (a *Audio) Play(name string, volume float64) {
p.Play()
}
// Loop startar (eller byter) bakgrundsloopen med given volym; 0 stoppar.
func (a *Audio) Loop(name string, volume float64) {
if a == nil {
return
}
a.StopLoop()
pcm, ok := a.clips[name]
if !ok || volume <= 0 {
return
}
stream := audio.NewInfiniteLoop(bytes.NewReader(pcm), int64(len(pcm)))
p, err := a.ctx.NewPlayer(stream)
if err != nil {
return
}
p.SetVolume(math.Min(1, volume))
p.Play()
a.loop = p
}
// StopLoop stoppar bakgrundsloopen.
func (a *Audio) StopLoop() {
if a == nil || a.loop == nil {
return
}
a.loop.Close()
a.loop = nil
}
// volumeAt ger volym efter avstånd i rutor.
func volumeAt(dist float64) float64 { return math.Max(0, 1-dist/AudibleTiles) }

View File

@@ -164,8 +164,24 @@ func (g *Game) Save() {
}
}
// SetScene byter scen från och med nästa uppdatering.
func (g *Game) SetScene(s Scene) { g.next = s }
// SetScene byter scen från och med nästa uppdatering. Bakgrundsprat hör
// till banan och tystnar när man lämnar den.
func (g *Game) SetScene(s Scene) {
if _, inLevel := s.(*LevelScene); !inLevel {
if _, isResult := s.(*ResultScene); !isResult {
g.Audio.StopLoop()
}
}
g.next = s
}
// startChatter startar banans bakgrundsprat efter ljudröra-nivån.
func (g *Game) startChatter(noise int) {
g.Audio.Loop(SoundChatter, 0.2*float64(noise))
}
// noiseMask dämpar perrongens ljud när bakgrundspratet konkurrerar.
func noiseMask(noise int) float64 { return 1 - 0.2*float64(noise) }
// Update körs 60 ggr/s.
func (g *Game) Update() error {

View File

@@ -30,9 +30,61 @@ func (s *LevelScene) drawMinigame(g *Game, screen *ebiten.Image) {
s.drawCrowd(a, screen, m, panel)
case *minigame.Ride:
s.drawRide(a, screen, m, panel)
case *minigame.CarPick:
s.drawCarPick(a, screen, m, panel)
}
}
// drawCarPick: vagnarna i rad, rosa för kvinnovagn, figurer för trängsel,
// kömarkeringar under, och en fas där folk kliver av först.
func (s *LevelScene) drawCarPick(a *Assets, screen *ebiten.Image, m *minigame.CarPick, p image.Rectangle) {
x, y := p.Min.X+8, p.Min.Y+8
a.DrawBig(screen, "乗車", x, y, colKnown)
a.Draw(screen, a.T("mini.car.title"), x+36, y+2, colText)
y += 24
if m.Alighting > 0 {
a.Draw(screen, a.T("mini.car.alighting"), x, y, colWarn)
w := float32(p.Dx() - 16)
vector.DrawFilledRect(screen, float32(x), float32(y+LineH+2), w, 4, colBar, false)
vector.DrawFilledRect(screen, float32(x), float32(y+LineH+2), w*float32(1-m.Alighting/minigame.AlightSec), 4, colKnown, false)
} else {
a.Draw(screen, a.T("mini.car.pick"), x, y, colDim)
}
y += LineH + 12
carW := min(64, (p.Dx()-16)/max(1, len(m.Cars))-4)
for i, c := range m.Cars {
cx := x + i*(carW+4)
body := color.RGBA{230, 234, 240, 255}
if c.WomenOnly {
body = color.RGBA{255, 160, 205, 255}
}
vector.DrawFilledRect(screen, float32(cx), float32(y), float32(carW), 30, body, false)
vector.DrawFilledRect(screen, float32(cx), float32(y+30), float32(carW), 3, colBad, false)
if i == m.Cursor && m.Alighting <= 0 {
vector.DrawFilledRect(screen, float32(cx), float32(y-4), float32(carW), 2, colKnown, false)
}
a.Draw(screen, fmt.Sprintf("%d%s", c.Index, a.T("hud.car_suffix_ja")), cx+3, y+2, colPanel)
// figurer = trängsel
for k := 0; k <= c.Crowd; k++ {
px := cx + 4 + k*7
vector.DrawFilledRect(screen, float32(px), float32(y+16), 4, 10, color.RGBA{60, 70, 100, 255}, false)
vector.DrawFilledRect(screen, float32(px), float32(y+13), 4, 3, color.RGBA{240, 200, 160, 255}, false)
}
if c.WomenOnly {
a.Draw(screen, "女性専用", cx+2, y+34, color.RGBA{255, 150, 200, 255})
} else {
a.Draw(screen, a.T("car.crowd."+minigame.CrowdNames[c.Crowd]), cx+2, y+34, colDim)
}
// kömarkering under vagnen
vector.DrawFilledRect(screen, float32(cx+carW/2-6), float32(y+48), 12, 2, colWarn, false)
vector.DrawFilledRect(screen, float32(cx+carW/2-1), float32(y+50), 2, 4, colWarn, false)
}
if s.run.Party.HasMen() {
a.Draw(screen, a.T("mini.car.men"), x, p.Max.Y-28, colWarn)
}
a.Draw(screen, a.T("mini.car.hint"), x, p.Max.Y-16, colDim)
}
// jp ritar japansk text sedd genom spelarens kunskap (kända ord i gult).
func (s *LevelScene) jp(a *Assets, screen *ebiten.Image, text string, x, y int) {
rd := lang.Render(a.Dict, s.run.Know, text, UILang)
@@ -171,6 +223,11 @@ func (s *LevelScene) drawRide(a *Assets, screen *ebiten.Image, m *minigame.Ride,
if dest := lvl.Destination.Get("romaji"); dest != "" {
hint = strings.Replace(hint, "%s", dest, 1)
}
if m.NeedsLetPass() {
a.Draw(screen, a.T("mini.ride.let_pass"), x, p.Max.Y-30, colBad)
} else if m.Crowd > 0 {
a.Draw(screen, fmt.Sprintf("%s: %s", a.T("mini.ride.crowd"), a.T("car.crowd."+minigame.CrowdNames[m.Crowd])), x, p.Max.Y-30, colDim)
}
a.Draw(screen, hint, x, p.Max.Y-16, colGood)
}

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)
outcome LevelOutcome // progression från banan (fylls i när den avgjorts)
posters map[level.Point]int // affischer per väggruta (visuell röra)
// Tåg och effekter (M7b).
tracks []trackInfo
rails map[level.Point]bool
@@ -64,6 +66,8 @@ func NewLevelScene(g *Game, id string, seed rng.Seed) (*LevelScene, error) {
s.lastPhase = map[level.Tile]level.Phase{}
s.rng = rand.New(rand.NewPCG(1, 2))
s.buildTracks()
s.buildPosters()
g.startChatter(lvl.Noise)
for _, tr := range s.tracks { // ingen melodi för fasen banan råkar börja i
s.lastPhase[tr.tile], _ = s.run.PlatformPhase(tr.tile)
}
@@ -202,11 +206,17 @@ func (s *LevelScene) Draw(g *Game, screen *ebiten.Image) {
}
op.GeoM.Reset()
op.GeoM.Translate(ox+float64(x*TileSize), oy+float64(y*TileSize))
if v, ok := s.posters[p]; ok {
screen.DrawImage(g.Assets.Posters[v], &op)
continue
}
screen.DrawImage(g.Assets.Tiles[lvl.At(p)], &op)
}
}
s.drawTrains(g, screen, ox, oy)
s.drawQueueMarks(screen, ox, oy)
s.drawParticles(screen, ox, oy)
s.drawNPCs(g, screen, ox, oy)
// Följarna ritas bakifrån så att ledaren hamnar överst.
leader := s.run.Player
@@ -356,6 +366,11 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
}
lines = append(lines, a.wrapLine(plainLine(a.T(r.FlashKey), col), ScreenW-8)...)
}
if r.WrongLane {
lines = append(lines, plainLine(a.T("hud.wrong_lane"), colWarn))
} else if r.BumpedAt >= 0 && r.Time-r.BumpedAt < 1.0 {
lines = append(lines, plainLine(a.T("hud.bump"), colDim))
}
if debuffs := g.Profile.Debuffs; len(debuffs) > 0 && r.Time < 6 {
var parts []string
for _, d := range debuffs {

View File

@@ -82,8 +82,8 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) {
sc := run.Score
a.Draw(screen, fmt.Sprintf("%s %d", a.T("result.score"), sc.Total), 28, y, colText)
y += LineH
a.Draw(screen, fmt.Sprintf("%s %d %s %d %s %d %s %d %s %d", strings.ToLower(a.T("hud.time")), sc.Time, a.T("result.money"), sc.Money,
a.T("result.meters"), sc.Meters, a.T("result.map"), sc.Explore, a.T("result.penalty"), sc.Penalty), 28, y, colDim)
a.Draw(screen, fmt.Sprintf("%s %d %s %d %s %d %s %d %s %d %s %d", strings.ToLower(a.T("hud.time")), sc.Time, a.T("result.money"), sc.Money,
a.T("result.meters"), sc.Meters, a.T("result.map"), sc.Explore, a.T("result.car"), sc.Car, a.T("result.penalty"), sc.Penalty), 28, y, colDim)
y += LineH
msg := fmt.Sprintf("+%d %s", run.PointsEarned, a.T("result.lang_points"))
if len(run.LearnedDest) > 0 {

View File

@@ -40,6 +40,7 @@ func (s *LevelScene) buildTracks() {
for _, p := range s.run.Level.Find(t) {
tt, horizontal := s.run.Level.TrackTile(p)
s.tracks = append(s.tracks, trackInfo{tile: t, platform: p, track: tt, horizontal: horizontal})
out := level.Point{X: tt.X - p.X, Y: tt.Y - p.Y} // riktningen bort från perrongen
for k := -railsReach; k <= railsReach; k++ {
c := tt
if horizontal {
@@ -47,7 +48,9 @@ func (s *LevelScene) buildTracks() {
} else {
c.Y += k
}
if s.run.Level.At(c) == level.Wall && (c.X == 0 || c.Y == 0 || c.X == s.run.Level.Width()-1 || c.Y == s.run.Level.Height()-1) {
beyond := level.Point{X: c.X + out.X, Y: c.Y + out.Y, Z: c.Z}
// Räls på väggrutor längs spåret, så länge det är vägg (eller kartkant) bortom dem.
if s.run.Level.At(c) == level.Wall && s.run.Level.At(beyond) == level.Wall {
s.rails[c] = true
}
}
@@ -158,11 +161,12 @@ func (s *LevelScene) updateEffects(g *Game) {
}
s.lastPhase[tr.tile] = phase
dist := math.Hypot(float64(tr.platform.X*TileSize)-px, float64(tr.platform.Y*TileSize)-py) / TileSize
mask := noiseMask(r.Level.Noise)
switch phase {
case level.Approaching:
g.Audio.Play(SoundApproach, volumeAt(dist))
g.Audio.Play(SoundApproach, volumeAt(dist)*mask)
case level.Closing:
g.Audio.Play(SoundDoors, volumeAt(dist))
g.Audio.Play(SoundDoors, volumeAt(dist)*mask)
}
}
// Vind längs perrongen när tåget rullar in eller ut (och strax före).

View File

@@ -33,14 +33,19 @@ const (
Kiosk Tile = 'K' // kiosk/automat för mat och dryck
StairsUp Tile = 'U' // trappa upp: leder till 'D' på samma x,y en våning upp
StairsDown Tile = 'D' // trappa ner: leder till 'U' på samma x,y en våning ner
LaneLeft Tile = '<' // fil för gång uppåt/åt vänster (bredare gångar: håll till vänster)
LaneRight Tile = '>' // fil för gång neråt/åt höger
)
var knownTiles = map[Tile]bool{
Wall: true, Floor: true, Spawn: true, Platform: true, WrongPlatform: true,
Gate: true, TicketMachine: true, Sign: true, Toilet: true, Kiosk: true,
StairsUp: true, StairsDown: true,
StairsUp: true, StairsDown: true, LaneLeft: true, LaneRight: true,
}
// IsLane rapporterar om rutan är en markerad fil.
func (t Tile) IsLane() bool { return t == LaneLeft || t == LaneRight }
// IsStairs rapporterar om rutan är en trappa.
func (t Tile) IsStairs() bool { return t == StairsUp || t == StairsDown }
@@ -167,10 +172,31 @@ type Train struct {
Line Text `json:"line"` // linjens namn
Destination Text `json:"destination"` // slutstation
Correct bool `json:"correct"` // tar spelaren till rätt resmål
Crowded bool `json:"crowded"` // fullt tåg: minispel för att få plats
Crowded bool `json:"crowded"` // fullt tåg: minispel för att få plats (gäller alla vagnar)
Cars int `json:"cars"` // antal vagnar (0 = DefaultCars)
WomenOnlyCar int `json:"womenOnlyCar"` // 1-baserat vagnnummer för kvinnovagn, 0 = ingen
WomenOnlyFrom int `json:"womenOnlyFrom"` // kvinnovagn gäller från den här bantiden (s); 0 och To=0 = alltid
WomenOnlyTo int `json:"womenOnlyTo"` // …till den här bantiden (s)
Schedule Schedule `json:"schedule"`
}
// DefaultCars och gränser för vagnar.
const (
DefaultCars = 3
MaxCars = 8
)
// WomenOnlyActive rapporterar om kvinnovagnen gäller vid bantiden t.
func (tr *Train) WomenOnlyActive(t float64) bool {
if tr.WomenOnlyCar == 0 {
return false
}
if tr.WomenOnlyFrom == 0 && tr.WomenOnlyTo == 0 {
return true
}
return t >= float64(tr.WomenOnlyFrom) && t < float64(tr.WomenOnlyTo)
}
// SignText är texten på en skyltruta ('i').
type SignText struct {
X int `json:"x"`
@@ -200,6 +226,9 @@ type Level struct {
Parent string `json:"parent"` // sidobanans huvudbana – låses upp när den är klarad
Rewards Rewards `json:"rewards"`
Shop []string `json:"shop"` // items som kiosken säljer på banan (tom = seedat urval)
Crowd int `json:"crowd"` // folk på stationen 0–3 (NPC:er)
Clutter int `json:"clutter"` // visuell röra 0–3 (affischer, lockskyltar)
Noise int `json:"noise"` // ljudröra 0–3 (bakgrundsprat)
Name Text `json:"name"`
Difficulty int `json:"difficulty"` // 1 (lätt) … 5 (svår)
Destination Text `json:"destination"` // dit spelaren ska
@@ -402,6 +431,15 @@ func (l *Level) Validate() error {
if tr.Correct {
correct++
}
if tr.Cars == 0 {
tr.Cars = DefaultCars
}
if tr.Cars < 1 || tr.Cars > MaxCars {
return fmt.Errorf("level %s: tåg %d har %d vagnar (1–%d)", l.ID, i, tr.Cars, MaxCars)
}
if tr.WomenOnlyCar < 0 || tr.WomenOnlyCar > tr.Cars || tr.WomenOnlyFrom < 0 || tr.WomenOnlyTo < tr.WomenOnlyFrom {
return fmt.Errorf("level %s: tåg %d har ogiltig kvinnovagn (%d av %d, %d–%d s)", l.ID, i, tr.WomenOnlyCar, tr.Cars, tr.WomenOnlyFrom, tr.WomenOnlyTo)
}
sc := &tr.Schedule
if sc.IntervalSec == 0 {
sc.IntervalSec = DefaultIntervalSec
@@ -443,6 +481,11 @@ func (l *Level) Validate() error {
if l.MaxMissedTrains < 0 || l.TimeLimitSec < 0 {
return fmt.Errorf("level %s: negativ tidsgräns/maxantal missade tåg", l.ID)
}
for name, v := range map[string]int{"crowd": l.Crowd, "clutter": l.Clutter, "noise": l.Noise} {
if v < 0 || v > 3 {
return fmt.Errorf("level %s: %s måste vara 0–3", l.ID, name)
}
}
if l.Party > MaxParty {
return fmt.Errorf("level %s: sällskapet får vara högst %d", l.ID, MaxParty)
}

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
// NewCrowd seedar zonens läge och markörens fart. luck (1 = normalt) gör
// zonen bredare eller smalare, inom rimliga gränser.
func NewCrowd(seed rng.Seed, tries int, luck float64) *Crowd {
// zonen bredare eller smalare, och crowdLevel (0 lite, 1 mellan, 2 mycket
// folk i vagnen) smalare ju fler som trängs.
func NewCrowd(seed rng.Seed, tries int, luck float64, crowdLevel int) *Crowd {
r := newRand(seed, "crowd")
width := min(0.6, max(0.1, crowdZoneWidth*max(0.5, min(2, luck))))
level := 1.3 - 0.4*float64(max(0, min(2, crowdLevel)))
width := min(0.6, max(0.1, crowdZoneWidth*max(0.5, min(2, luck))*level))
lo := r.between(0.1, 0.9-width)
return &Crowd{dir: 1, Speed: r.between(0.9, 1.5), ZoneLo: lo, ZoneHi: lo + width, MaxTries: max(1, tries)}
}

View File

@@ -1,7 +1,6 @@
package minigame
import (
"math"
"strings"
"testing"
@@ -114,8 +113,8 @@ func TestTicketBoardSeededAndAffordable(t *testing.T) {
}
func TestCrowdZoneAndAttempts(t *testing.T) {
g := NewCrowd(rng.FromInt(7), 2, 1)
if g.ZoneLo < 0.1 || g.ZoneHi > 0.9 || math.Abs((g.ZoneHi-g.ZoneLo)-crowdZoneWidth) > 1e-9 {
g := NewCrowd(rng.FromInt(7), 2, 1, 1)
if w := g.ZoneHi - g.ZoneLo; g.ZoneLo < 0.1 || g.ZoneHi > 0.9 || w <= 0.1 || w > 0.6 {
t.Fatalf("zon %.2f–%.2f", g.ZoneLo, g.ZoneHi)
}
// Vänta tills markören är utanför zonen och tryck: ett bommat försök.
@@ -135,7 +134,7 @@ func TestCrowdZoneAndAttempts(t *testing.T) {
t.Fatal("träff i zonen ska lyckas")
}
// Två bom = tåget går.
f := NewCrowd(rng.FromInt(7), 2, 1)
f := NewCrowd(rng.FromInt(7), 2, 1, 1)
for n := 0; n < 2; n++ {
for f.InZone() {
f.Update(Input{}, frame)
@@ -146,7 +145,7 @@ func TestCrowdZoneAndAttempts(t *testing.T) {
t.Fatal("två bom ska misslyckas")
}
// Markören studsar och håller sig i 0..1.
b := NewCrowd(rng.FromInt(8), 1, 1)
b := NewCrowd(rng.FromInt(8), 1, 1, 1)
for i := 0; i < 600; i++ {
b.Update(Input{}, frame)
if b.Pos < 0 || b.Pos > 1 {
@@ -157,11 +156,11 @@ func TestCrowdZoneAndAttempts(t *testing.T) {
func TestRideStopsAndGettingOff(t *testing.T) {
fillers := []string{"東京", "上野", "新宿", "成田"}
g := NewRide(rng.FromInt(3), "渋谷", fillers, 4, true)
g := NewRide(rng.FromInt(3), "渋谷", fillers, 4, true, 0)
if len(g.Stops) != 4 || g.Dest < 1 || g.Stops[g.Dest] != "渋谷" {
t.Fatalf("stops=%v dest=%d", g.Stops, g.Dest)
}
h := NewRide(rng.FromInt(3), "渋谷", fillers, 4, true)
h := NewRide(rng.FromInt(3), "渋谷", fillers, 4, true, 0)
if strings.Join(g.Stops, ",") != strings.Join(h.Stops, ",") {
t.Fatal("samma seed = samma linje")
}
@@ -174,13 +173,13 @@ func TestRideStopsAndGettingOff(t *testing.T) {
t.Fatal("kliva av på resmålet ska lyckas")
}
// Kliva av för tidigt.
e := NewRide(rng.FromInt(3), "渋谷", fillers, 4, true)
e := NewRide(rng.FromInt(3), "渋谷", fillers, 4, true, 0)
e.Update(Input{Confirm: true}, frame)
if !e.Done() || e.Success() || e.GotOff != 0 {
t.Fatal("fel station = misslyckat")
}
// Åka förbi.
m := NewRide(rng.FromInt(3), "渋谷", fillers, 4, true)
m := NewRide(rng.FromInt(3), "渋谷", fillers, 4, true, 0)
for i := 0; i < 60*20 && !m.Done(); i++ {
m.Update(Input{}, frame)
}
@@ -188,7 +187,7 @@ func TestRideStopsAndGettingOff(t *testing.T) {
t.Fatalf("åka förbi: done=%v success=%v index=%d", m.Done(), m.Success(), m.Index)
}
// Fel tåg: resmålet finns inte på linjen, så det går aldrig att lyckas.
w := NewRide(rng.FromInt(3), "渋谷", fillers, 4, false)
w := NewRide(rng.FromInt(3), "渋谷", fillers, 4, false, 0)
if w.Dest != -1 || strings.Contains(strings.Join(w.Stops, ","), "渋谷") {
t.Fatalf("fel tåg: %v", w.Stops)
}
@@ -199,7 +198,7 @@ func TestRideStopsAndGettingOff(t *testing.T) {
}
func TestRideWithoutFillersStillHasDestination(t *testing.T) {
g := NewRide(rng.FromInt(1), "渋谷", nil, 3, true)
g := NewRide(rng.FromInt(1), "渋谷", nil, 3, true, 0)
if len(g.Stops) != 3 || g.Dest < 1 || g.Stops[g.Dest] != "渋谷" || g.Stops[0] != UnknownStop {
t.Fatalf("stops=%v dest=%d", g.Stops, g.Dest)
}
@@ -295,14 +294,118 @@ func TestSeatSelectionPreferences(t *testing.T) {
}
func TestCrowdLuckWidensZone(t *testing.T) {
normal := NewCrowd(rng.FromInt(3), 2, 1)
lucky := NewCrowd(rng.FromInt(3), 2, 1.5)
unlucky := NewCrowd(rng.FromInt(3), 2, 0.5)
normal := NewCrowd(rng.FromInt(3), 2, 1, 1)
lucky := NewCrowd(rng.FromInt(3), 2, 1.5, 1)
unlucky := NewCrowd(rng.FromInt(3), 2, 0.5, 1)
if lucky.ZoneHi-lucky.ZoneLo <= normal.ZoneHi-normal.ZoneLo || unlucky.ZoneHi-unlucky.ZoneLo >= normal.ZoneHi-normal.ZoneLo {
t.Fatal("tur ska ge bredare zon, otur smalare")
}
huge := NewCrowd(rng.FromInt(3), 2, 100)
huge := NewCrowd(rng.FromInt(3), 2, 100, 1)
if huge.ZoneHi-huge.ZoneLo > 0.6+1e-9 || huge.ZoneHi > 0.9+1e-9 {
t.Fatal("zonen klampas")
}
}
func TestCarPickAlightingAndChoice(t *testing.T) {
g := NewCarPick(rng.FromInt(5), 4, 1, AlightSec)
h := NewCarPick(rng.FromInt(5), 4, 1, AlightSec)
if len(g.Cars) != 4 || !g.Cars[0].WomenOnly || g.Cars[1].WomenOnly {
t.Fatalf("cars=%+v", g.Cars)
}
for i := range g.Cars {
if g.Cars[i] != h.Cars[i] {
t.Fatal("samma seed = samma trängsel")
}
if g.Cars[i].Crowd < 0 || g.Cars[i].Crowd > 2 {
t.Fatal("trängsel 0–2")
}
}
hasLow := false
for _, c := range g.Cars {
hasLow = hasLow || c.Crowd == 0
}
if !hasLow {
t.Fatal("minst en vagn med lite folk")
}
// För tidigt: folk kliver av först.
g.Update(Input{Confirm: true}, frame)
g.Update(Input{Confirm: true}, frame)
if g.Done() || g.Early != 2 {
t.Fatalf("early=%d done=%v", g.Early, g.Done())
}
for g.Alighting > 0 {
g.Update(Input{}, frame)
}
g.Update(Input{MoveX: 1}, frame)
before := g.Cursor
g.Update(Input{Confirm: true}, frame)
if !g.Done() || !g.Success() || g.Chosen == nil || g.Chosen.Index != before+1 {
t.Fatalf("val: chosen=%+v cursor=%d", g.Chosen, before)
}
c := NewCarPick(rng.FromInt(5), 1, 0, 0)
if len(c.Cars) != 1 || c.Cars[0].WomenOnly {
t.Fatal("ett tåg med en vagn")
}
c.Update(Input{Cancel: true}, frame)
if !c.Done() || c.Success() {
t.Fatal("Esc avbryter")
}
}
func TestCrowdLevelNarrowsZone(t *testing.T) {
w := func(level int) float64 {
g := NewCrowd(rng.FromInt(2), 2, 1, level)
return g.ZoneHi - g.ZoneLo
}
if !(w(0) > w(1) && w(1) > w(2)) {
t.Fatalf("zon: %.2f %.2f %.2f", w(0), w(1), w(2))
}
}
func TestRideCrowdLetPass(t *testing.T) {
fillers := []string{"東京", "上野", "新宿", "成田", "品川"}
g := NewRide(rng.FromInt(6), "渋谷", fillers, 5, true, 2)
if g.StopSec != RideStopSec(2) || RideStopSec(0) != 2.5 || RideStopSec(2) != 1.5 {
t.Fatal("tid per station")
}
wants := 0
for i, w := range g.WantsOut {
if w {
wants++
if i == g.Dest {
t.Fatal("ingen vill ut vid resmålet")
}
}
}
if wants == 0 {
t.Fatal("med mycket folk vill någon förbi")
}
// Släpp förbi vid första stationen som vill, blockera nästa.
released, blockedSeen := false, false
for i := 0; i < 60*60 && !g.Done(); i++ {
in := Input{}
if g.NeedsLetPass() && !released {
in.MoveX = -1
released = true
}
if g.Index == g.Dest {
in.Confirm = true
}
g.Update(in, frame)
if g.Blocked > 0 {
blockedSeen = true
}
}
if !g.Done() || !g.Success() {
t.Fatal("ska ändå komma fram")
}
if wants >= 2 && !blockedSeen {
t.Fatalf("minst en station ska ha blockerats: blocked=%d wants=%d", g.Blocked, wants)
}
calm := NewRide(rng.FromInt(6), "渋谷", fillers, 5, true, 0)
for _, w := range calm.WantsOut {
if w {
t.Fatal("utan trängsel vill ingen förbi")
}
}
}

View File

@@ -12,18 +12,31 @@ type Ride struct {
Timer float64 // sekunder kvar vid aktuell station
StopSec float64
GotOff int // index där spelaren klev av, -1 = inte än
// Trängsel: vid stationer där man inte ska av vill andra passagerare ut.
Crowd int // vagnens trängselnivå 0–2
WantsOut []bool // per station: någon vill förbi
LetPass bool // spelaren har släppt förbi vid aktuell station
Blocked int // stationer där ingen släpptes förbi (avdrag)
}
// Tid per station efter trängsel: 2,5 / 2,0 / 1,5 s.
const rideStopSec = 2.5
// RideStopSec är tiden per station för en trängselnivå.
func RideStopSec(crowd int) float64 { return rideStopSec - 0.5*float64(max(0, min(2, crowd))) }
// NewRide bygger linjen: fillers är andra stationsnamn (blandas), dest läggs
// på en seedad plats (aldrig först) om det är rätt tåg, annars utelämnas det.
func NewRide(seed rng.Seed, dest string, fillers []string, stops int, correctTrain bool) *Ride {
// crowd (0–2) ger kortare tid per station och passagerare som vill förbi.
func NewRide(seed rng.Seed, dest string, fillers []string, stops int, correctTrain bool, crowd int) *Ride {
r := newRand(seed, "ride")
f := append([]string(nil), fillers...)
r.shuffle(len(f), func(i, j int) { f[i], f[j] = f[j], f[i] })
stops = max(2, stops)
g := &Ride{StopSec: rideStopSec, Timer: rideStopSec, GotOff: -1, Dest: -1}
crowd = max(0, min(2, crowd))
sec := RideStopSec(crowd)
g := &Ride{StopSec: sec, Timer: sec, GotOff: -1, Dest: -1, Crowd: crowd}
if correctTrain {
g.Dest = 1 + r.intn(stops-1)
}
@@ -38,9 +51,19 @@ func NewRide(seed rng.Seed, dest string, fillers []string, stops int, correctTra
g.Stops = append(g.Stops, UnknownStop) // slut på namn: en namnlös station
}
}
g.WantsOut = make([]bool, len(g.Stops))
chance := [...]float64{0, 0.4, 0.7}[crowd]
for i := range g.Stops {
if i != g.Dest && r.float() < chance {
g.WantsOut[i] = true
}
}
return g
}
// NeedsLetPass rapporterar om någon vill förbi vid aktuell station just nu.
func (g *Ride) NeedsLetPass() bool { return g.WantsOut[g.Index] && !g.LetPass }
// UnknownStop används när det inte finns fler stationsnamn att ta av.
const UnknownStop = "・・・"
@@ -58,10 +81,17 @@ func (g *Ride) Update(in Input, dt float64) {
g.success = g.Index == g.Dest
return
}
if in.MoveX < 0 && g.NeedsLetPass() {
g.LetPass = true // kliv åt sidan
}
g.Timer -= dt
if g.Timer <= 0 {
if g.NeedsLetPass() {
g.Blocked++
}
g.Index++
g.Timer = g.StopSec
g.LetPass = false
if g.Index >= len(g.Stops) {
g.Index = len(g.Stops) - 1
g.done = true // åkte till slutstationen utan att kliva av

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.
type Member struct {
Name string
Gender string // "f", "m" eller "" (okänt) – kvinnovagnar bryr sig
Meters Meters
Urge Urge
UrgeAt float64 // när behovet uppstod
@@ -61,6 +62,9 @@ type Rates struct{ Thirst, Toilet float64 }
// PartyNames är namnen i den ordning de följer med.
var PartyNames = []string{"Du", "Anna", "Ville", "Sara", "Omar"}
// partyGenders hör ihop med PartyNames. Spelaren själv är okänd ("").
var partyGenders = []string{"", "f", "m", "f", "m"}
// Takter och trösklar. Mätarna går från 100 till 0 på ungefär 10/7/8 minuter.
const (
FoodPerSec = 100.0 / 600
@@ -94,7 +98,7 @@ func NewParty(n int, start level.Point, seed rng.Seed) *Party {
n = max(1, min(n, len(PartyNames)))
p := &Party{rand: seed.New(), Rates: Rates{1, 1}}
for i := 0; i < n; i++ {
p.Members = append(p.Members, &Member{Name: PartyNames[i], Meters: Meters{100, 100, 100}, Card: MemberCardYen})
p.Members = append(p.Members, &Member{Name: PartyNames[i], Gender: partyGenders[i], Meters: Meters{100, 100, 100}, Card: MemberCardYen})
}
p.trail = []level.Point{start}
p.nextEventAt = p.interval() * (0.5 + p.rand.Float64())
@@ -194,6 +198,16 @@ func (p *Party) SpeedFactor() float64 {
return f
}
// HasMen rapporterar om någon i sällskapet är man (kvinnovagnen).
func (p *Party) HasMen() bool {
for _, m := range p.Members {
if m.Gender == "m" {
return true
}
}
return false
}
// Names är medlemmarnas namn i ordning.
func (p *Party) Names() []string {
out := make([]string, len(p.Members))

View File

@@ -2,6 +2,7 @@ package sim
import (
"fmt"
"math/rand/v2"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
@@ -87,11 +88,39 @@ type Run struct {
Score ScoreBreakdown
NewDebuffs []Debuff // debuffar banan gav (appliceras på profilen efteråt)
opts RunOptions
// Vagnar, folk och filer (M9b).
CarChosen *minigame.Car // vagnen man klev på
EarlyBoard int // försök att kliva på innan folk klivit av
WomenOnlyViolation bool // en man i sällskapet på kvinnovagnen
Blocked int // passagerare som inte släpptes förbi på resan
NPCs []*NPC
npcRand *rand.Rand
platformPhase map[level.Tile]level.Phase
BumpedAt float64 // när spelaren senast gick in i någon (< 0 = aldrig)
WrongLane bool // går på fel sida i en fil just nu
}
// Poäng och avdrag för vagnar.
const (
ScoreCarBonus = 50 // lite folk i vagnen
PenaltyWomenOnly = 200 // grova minus
PenaltyEarlyBoard = 20 // per gång man inte lät folk kliva av först
PenaltyBlocked = 20 // per passagerare som inte släpptes förbi
SweatyChance = 0.6 // chans att bli svettig i en full vagn
WrongLaneFactor = 0.7 // fart på fel sida i en fil
)
// Fler flash-nycklar.
const (
FlashWomenOnly = "hud.women_only"
FlashEarly = "hud.early_board"
FlashBump = "hud.bump"
)
// ScoreBreakdown är poängen för en vunnen bana.
type ScoreBreakdown struct {
Time, Money, Meters, Explore, Penalty, Total int
Time, Money, Meters, Explore, Car, Penalty, Total int
}
// Poängformelns vikter.
@@ -129,6 +158,7 @@ const (
MiniTicket
MiniCrowd
MiniRide
MiniCarPick
)
// Förlustorsaker (textnycklar).
@@ -229,6 +259,13 @@ func NewRun(l *level.Level, o RunOptions) *Run {
Stats: CombinedStats(o.Debuffs),
spentStart: o.Wallet.Spent,
opts: o,
platformPhase: map[level.Tile]level.Phase{},
BumpedAt: -1,
}
r.npcRand = newNPCRand(o.Seed)
r.NPCs = spawnNPCs(l, l.Crowd*NPCPerCrowd, r.npcRand)
for _, t := range []level.Tile{level.Platform, level.WrongPlatform} {
r.platformPhase[t], _ = r.PlatformPhase(t)
}
if o.Extra != (Stats{}) {
for i := range r.Stats {
@@ -276,6 +313,12 @@ func (r *Run) CanEnter(p level.Point) bool {
r.DeniedAt = r.Time
return false
}
for _, n := range r.NPCs {
if n.Occupies(p) {
r.BumpedAt = r.Time
return false
}
}
return true
}
@@ -302,6 +345,19 @@ func (r *Run) canPassGate() bool {
return true
}
// PathCanEnter är CanEnter utan hänsyn till folk som råkar stå i vägen –
// för vägplanering (folk flyttar på sig; spärrar och väggar gör det inte).
func (r *Run) PathCanEnter(p level.Point) bool {
t := r.Level.At(p)
if !t.Passable() {
return false
}
if t == level.Gate && !r.inGate() && !r.canPassGate() {
return false
}
return true
}
// MembersWithoutFare listar följare vars egna kort inte räcker (HUD).
func (r *Run) MembersWithoutFare() []*Member {
if !r.Level.IndividualCards {
@@ -342,6 +398,17 @@ func (r *Run) Step(dt float64, want Dir, board bool) {
if r.Time < r.RescanUntil {
r.Player.Speed = 0 // står och skannar om
}
// Filer: på en markerad fil går man på rätt sida, annars möter man strömmen.
r.WrongLane = false
switch r.Level.At(r.Player.Tile) {
case level.LaneLeft:
r.WrongLane = want == Down || want == Right
case level.LaneRight:
r.WrongLane = want == Up || want == Left
}
if r.WrongLane {
r.Player.Speed *= WrongLaneFactor
}
r.Steps += r.Player.Step(dt, want, r.CanEnter)
if r.Player.Tile != before {
// Trappa: hamnar man på en trappruta flyttas man till andra änden
@@ -367,6 +434,7 @@ func (r *Run) Step(dt float64, want Dir, board bool) {
r.stepHelp(dt)
r.stepFacility(dt)
r.stepReading()
r.stepNPCs(dt)
r.checkLimits()
if r.Player.Tile != before && r.inGate() && !wasGate {
r.GatePasses++
@@ -464,20 +532,47 @@ func (r *Run) tryBoard(tr *level.Train) {
return
}
r.PendingTrain = tr
if tr.Crowded {
r.Mini, r.MiniKind = minigame.NewCrowd(r.miniSeed(), CrowdTries, r.Stats[StatLuck]), MiniCrowd
women := 0
if tr.WomenOnlyActive(r.Time) {
women = tr.WomenOnlyCar
}
r.Mini, r.MiniKind = minigame.NewCarPick(r.miniSeed(), tr.Cars, women, minigame.AlightSec), MiniCarPick
}
// carPicked fortsätter ombordstigningen efter vagnvalet.
func (r *Run) carPicked(c *minigame.Car, early int) {
tr := r.PendingTrain
r.CarChosen = c
r.EarlyBoard += early
if early > 0 {
r.flash(FlashEarly)
}
if c.WomenOnly && r.Party.HasMen() {
r.WomenOnlyViolation = true
r.flash(FlashWomenOnly)
}
if tr.Crowded || c.Crowd == 2 {
r.Mini, r.MiniKind = minigame.NewCrowd(r.miniSeed(), CrowdTries, r.Stats[StatLuck], c.Crowd), MiniCrowd
return
}
r.startRide()
}
// carCrowd är trängseln i den valda vagnen (0 om inget valts).
func (r *Run) carCrowd() int {
if r.CarChosen == nil {
return 0
}
return r.CarChosen.Crowd
}
func (r *Run) startRide() {
tr := r.PendingTrain
if r.Level.TicketRequired {
r.Wallet.Ticket, r.Wallet.TicketFare, r.Wallet.TicketCount = "", 0, 0 // biljetterna är förbrukade
}
dest := r.Level.Destination.Get("ja")
r.Mini, r.MiniKind = minigame.NewRide(r.miniSeed(), dest, r.stationPool(dest, 8), r.Level.RideStops, tr.Correct), MiniRide
r.Mini, r.MiniKind = minigame.NewRide(r.miniSeed(), dest, r.stationPool(dest, 8), r.Level.RideStops, tr.Correct, r.carCrowd()), MiniRide
}
// StepMini för ett pågående minispel framåt; klockan och sällskapet tickar
@@ -491,6 +586,7 @@ func (r *Run) StepMini(dt float64, in minigame.Input) {
r.Wallet.Drain(dt)
}
r.Party.Advance(dt, r.Time)
r.stepNPCs(dt)
r.Mini.Update(in, dt)
if !r.Mini.Done() {
r.checkLimits()
@@ -531,6 +627,12 @@ func (r *Run) StepMini(dt float64, in minigame.Input) {
}
r.flash(FlashGrumpy)
}
case *minigame.CarPick:
if g.Success() && g.Chosen != nil {
r.carPicked(g.Chosen, g.Early)
} else {
r.PendingTrain = nil // avbröt på perrongen
}
case *minigame.Crowd:
if g.Success() {
r.startRide()
@@ -542,6 +644,7 @@ func (r *Run) StepMini(dt float64, in minigame.Input) {
case *minigame.Ride:
tr := r.PendingTrain
r.PendingTrain = nil
r.Blocked += g.Blocked
switch {
case g.Success():
r.finish(tr, true, "")
@@ -628,8 +731,15 @@ func (r *Run) computeScore() {
sc.Money = max(0, ScoreMoneyMax-(r.Wallet.Spent-r.spentStart)/10)
sc.Meters = int(ScoreMetersMax * r.Party.AvgMeters())
sc.Explore = int(ScoreExploreMax * r.Fog.Explored(r.Level))
sc.Penalty = PenaltyMissed*r.MissedTrains + PenaltyRescan*r.Rescans + PenaltyGrumpy*r.Grumpy
sc.Total = max(0, sc.Time+sc.Money+sc.Meters+sc.Explore-sc.Penalty)
sc.Penalty = PenaltyMissed*r.MissedTrains + PenaltyRescan*r.Rescans + PenaltyGrumpy*r.Grumpy +
PenaltyEarlyBoard*r.EarlyBoard + PenaltyBlocked*r.Blocked
if r.WomenOnlyViolation {
sc.Penalty += PenaltyWomenOnly
}
if r.CarChosen != nil && r.CarChosen.Crowd == 0 {
sc.Car = ScoreCarBonus
}
sc.Total = max(0, sc.Time+sc.Money+sc.Meters+sc.Explore+sc.Car-sc.Penalty)
r.Score = sc
}
@@ -651,9 +761,38 @@ func (r *Run) earnedDebuffs() []Debuff {
if r.poisoned {
out = append(out, Debuff{FoodPoisoning, FoodPoisonLevels})
}
if r.CarChosen != nil && r.CarChosen.Crowd == 2 && r.Seed.Derive("sweaty").New().Float64() < SweatyChance/r.Stats[StatLuck] {
out = append(out, Debuff{Sweaty, 1})
}
return out
}
// stepNPCs flyttar folket, låter avstigande kliva av när dörrarna öppnas och
// tar bort dem som nått utgången.
func (r *Run) stepNPCs(dt float64) {
for _, t := range []level.Tile{level.Platform, level.WrongPlatform} {
ph, _ := r.PlatformPhase(t)
if r.platformPhase[t] != ph {
r.platformPhase[t] = ph
if tiles := r.Level.Find(t); ph == level.Dwelling && len(tiles) > 0 {
// Avstigande per tåg (inte per perrongruta), fördelade längs perrongen.
for i := 0; i < NPCAlightBase+r.Level.Crowd; i++ {
p := tiles[i%len(tiles)]
r.NPCs = append(r.NPCs, &NPC{Mover: NewMover(p, NPCSpeed), Kind: Exit, Target: r.Level.SpawnPoint(), Pause: 0.3 * float64(i/len(tiles))})
}
}
}
}
kept := r.NPCs[:0]
for _, n := range r.NPCs {
n.step(r.Level, dt, r.npcRand)
if !n.Done {
kept = append(kept, n)
}
}
r.NPCs = kept
}
// FacilityProgress är 0..1 för ett pågående toalettbesök.
func (r *Run) FacilityProgress() float64 {
if !r.Facility.Active || r.Facility.Total <= 0 {

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
// finns inget resmål, så resan går till slutstationen.
// finns inget resmål, så resan går till slutstationen. Släpper förbi folk.
func ride(t *testing.T, r *Run) {
t.Helper()
if r.MiniKind == MiniCarPick {
pickCar(t, r)
}
if r.MiniKind != MiniRide {
t.Fatalf("ingen tågresa pågår (kind=%v)", r.MiniKind)
}
for i := 0; i < 60*120 && r.Mini != nil; i++ {
g := r.Mini.(*minigame.Ride)
r.StepMini(frame, minigame.Input{Confirm: g.Index == g.Dest})
in := minigame.Input{Confirm: g.Index == g.Dest}
if g.NeedsLetPass() {
in.MoveX = -1
}
r.StepMini(frame, in)
}
if r.Mini != nil {
t.Fatal("resan tog aldrig slut")
@@ -137,7 +179,7 @@ func TestBotWinsLevel001(t *testing.T) {
l := loadLevel(t, "001-flygplatsen")
r := NewRun(l, RunOptions{Seed: rng.FromInt(1)})
goal := l.Find(level.Platform)[0]
path := l.Path(r.Player.Tile, goal, r.CanEnter)
path := l.Path(r.Player.Tile, goal, r.PathCanEnter)
if path == nil {
t.Fatal("ingen väg till perrongen")
}
@@ -152,8 +194,8 @@ func TestBotWinsLevel001(t *testing.T) {
t.Fatal("rätt perrong ska ha rätt tåg")
}
r.Step(frame, None, true)
if r.Outcome != Ongoing || r.MiniKind != MiniRide {
t.Fatalf("att kliva på ska starta tågresan: outcome=%v kind=%v", r.Outcome, r.MiniKind)
if r.Outcome != Ongoing || r.MiniKind != MiniCarPick {
t.Fatalf("att kliva på ska börja med vagnvalet: outcome=%v kind=%v", r.Outcome, r.MiniKind)
}
ride(t, r)
if r.Outcome != Won || r.Boarded != tr {
@@ -180,7 +222,7 @@ func TestBotLosesOnWrongTrain(t *testing.T) {
l := loadLevel(t, "001-flygplatsen")
r := NewRun(l, RunOptions{Seed: rng.FromInt(1)})
goal := l.Find(level.WrongPlatform)[0]
walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter))
walk(t, r, l.Path(r.Player.Tile, goal, r.PathCanEnter))
tr := waitForTrain(t, r)
r.Step(frame, None, true)
ride(t, r)
@@ -200,7 +242,7 @@ func TestGateNeedsCardBalance(t *testing.T) {
if r.DeniedAt < 0 {
t.Fatal("nekandet ska registreras för HUD:en")
}
if l.Path(r.Player.Tile, l.Find(level.Platform)[0], r.CanEnter) != nil {
if l.Path(r.Player.Tile, l.Find(level.Platform)[0], r.PathCanEnter) != nil {
t.Fatal("utan saldo ska perrongen vara onåbar")
}
r.Wallet.Card = l.FareYen
@@ -220,7 +262,7 @@ func TestBoardOnlyWhenTrainPresent(t *testing.T) {
l := loadLevel(t, "001-flygplatsen")
r := NewRun(l, RunOptions{Seed: rng.FromInt(1)})
goal := l.Find(level.Platform)[0]
walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter))
walk(t, r, l.Path(r.Player.Tile, goal, r.PathCanEnter))
// Spola fram till en tidpunkt då tåget är borta.
for {
_, at, _ := r.PlatformHere()
@@ -239,7 +281,7 @@ func TestSignHereOnlyWhenStill(t *testing.T) {
l := loadLevel(t, "001-flygplatsen")
r := NewRun(l, RunOptions{Seed: rng.FromInt(1)})
sign := l.Find(level.Sign)[0]
walk(t, r, l.Path(r.Player.Tile, sign, r.CanEnter))
walk(t, r, l.Path(r.Player.Tile, sign, r.PathCanEnter))
if sg := r.SignHere(); sg == nil || sg.Text.Get("sv") == "" {
t.Fatal("stilla på skyltrutan ska ge skylten")
}
@@ -256,7 +298,7 @@ func TestAskHelpRevealsSignAndTeachesWord(t *testing.T) {
know := lang.NewKnowledge()
r := NewRun(l, RunOptions{Dict: loadDict(t), Know: know, Seed: rng.FromInt(1)})
sign := l.Find(level.Sign)[1]
walk(t, r, l.Path(r.Player.Tile, sign, r.CanEnter))
walk(t, r, l.Path(r.Player.Tile, sign, r.PathCanEnter))
if !r.AskHelp() {
t.Fatal("ska kunna fråga om hjälp på en skylt")
@@ -296,7 +338,7 @@ func TestHelpIsCancelledByWalkingAway(t *testing.T) {
l := loadLevel(t, "001-flygplatsen")
r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(1)})
sign := l.Find(level.Sign)[1]
walk(t, r, l.Path(r.Player.Tile, sign, r.CanEnter))
walk(t, r, l.Path(r.Player.Tile, sign, r.PathCanEnter))
r.AskHelp()
r.Step(frame, Left, false)
if r.Help.Active {
@@ -344,7 +386,7 @@ func TestGoodResultTeachesDestination(t *testing.T) {
}{{"snabb", false, 3, true}, {"långsam", true, 1, false}} {
know := lang.NewKnowledge()
r := NewRun(l, RunOptions{Dict: loadDict(t), Know: know, Seed: rng.FromInt(2)})
walk(t, r, l.Path(r.Player.Tile, l.CorrectPlatform(), r.CanEnter))
walk(t, r, l.Path(r.Player.Tile, l.CorrectPlatform(), r.PathCanEnter))
waitForTrain(t, r)
if tc.slow {
r.Time = 10 * r.ParSec()
@@ -374,7 +416,7 @@ func TestReadingGivesSeededProgress(t *testing.T) {
play := func(seed uint64) *lang.Knowledge {
know := lang.NewKnowledge()
r := NewRun(l, RunOptions{Dict: loadDict(t), Know: know, Seed: rng.FromInt(seed)})
walk(t, r, l.Path(r.Player.Tile, sign, r.CanEnter))
walk(t, r, l.Path(r.Player.Tile, sign, r.PathCanEnter))
for i := 0; i < 15; i++ { // gå av och på skylten = nya besök
walk(t, r, []level.Point{sign, left})
walk(t, r, []level.Point{left, sign})
@@ -414,7 +456,7 @@ func TestToiletAndKioskInRun(t *testing.T) {
// Toalett: tar ToiletSecPerMember per person, avbryts om man går.
toilet := l.Find(level.Toilet)[0]
walk(t, r, l.Path(r.Player.Tile, toilet, r.CanEnter))
walk(t, r, l.Path(r.Player.Tile, toilet, r.PathCanEnter))
anna.Meters.Toilet = 10
anna.Urge = NeedToilet
r.Step(frame, None, true)
@@ -428,7 +470,7 @@ func TestToiletAndKioskInRun(t *testing.T) {
for r.Player.Moving() { // gå klart rutan innan vi vänder tillbaka
r.Step(frame, None, false)
}
walk(t, r, l.Path(r.Player.Tile, toilet, r.CanEnter))
walk(t, r, l.Path(r.Player.Tile, toilet, r.PathCanEnter))
r.Step(frame, None, true)
if !r.Facility.Active {
t.Fatal("besöket ska starta igen")
@@ -442,7 +484,7 @@ func TestToiletAndKioskInRun(t *testing.T) {
// Kiosk: direkt, kostar KioskPriceYen per person från kortet.
kiosk := l.Find(level.Kiosk)[0]
walk(t, r, l.Path(r.Player.Tile, kiosk, r.CanEnter))
walk(t, r, l.Path(r.Player.Tile, kiosk, r.PathCanEnter))
anna.Meters.Drink = 5
anna.Urge = Thirsty
before := r.Wallet.Card
@@ -471,9 +513,10 @@ func TestRideWrongOrMissedStopLoses(t *testing.T) {
reason string
}{{"fel station", true, LostWrongStop}, {"åker förbi", false, LostMissedStop}} {
r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(1)})
walk(t, r, l.Path(r.Player.Tile, l.CorrectPlatform(), r.CanEnter))
walk(t, r, l.Path(r.Player.Tile, l.CorrectPlatform(), r.PathCanEnter))
waitForTrain(t, r)
r.Step(frame, None, true)
pickCar(t, r)
g := r.Mini.(*minigame.Ride)
if g.Dest < 1 {
t.Fatalf("resmålet ska ligga på linjen men inte först: %d", g.Dest)
@@ -500,7 +543,7 @@ func TestTopUpFlowAtMachine(t *testing.T) {
t.Fatal("tomt kort stoppas")
}
machine := l.Find(level.TicketMachine)[0]
walk(t, r, l.Path(r.Player.Tile, machine, r.CanEnter))
walk(t, r, l.Path(r.Player.Tile, machine, r.PathCanEnter))
r.Step(frame, None, true)
g, ok := r.Mini.(*minigame.TopUp)
if !ok || r.MiniKind != MiniTopUp {
@@ -548,7 +591,7 @@ func TestTicketLevelFlow(t *testing.T) {
t.Fatal("utan biljett stoppar spärren även med kort")
}
machine := l.Find(level.TicketMachine)[0]
walk(t, r, l.Path(r.Player.Tile, machine, r.CanEnter))
walk(t, r, l.Path(r.Player.Tile, machine, r.PathCanEnter))
// Fel biljett först: tåget vägrar.
r.Step(frame, None, true)
@@ -563,7 +606,7 @@ func TestTicketLevelFlow(t *testing.T) {
t.Fatalf("biljett=%q cash=%d", r.Wallet.Ticket, r.Wallet.Cash)
}
goal := l.CorrectPlatform()
walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter))
walk(t, r, l.Path(r.Player.Tile, goal, r.PathCanEnter))
waitForTrain(t, r)
r.Step(frame, None, true)
if r.Mini != nil || r.FlashKey != FlashWrongTicket || r.Outcome != Ongoing {
@@ -571,7 +614,7 @@ func TestTicketLevelFlow(t *testing.T) {
}
// Rätt biljett.
walk(t, r, l.Path(r.Player.Tile, machine, r.CanEnter))
walk(t, r, l.Path(r.Player.Tile, machine, r.PathCanEnter))
r.Step(frame, None, true)
tk = r.Mini.(*minigame.Ticket)
for tk.Options[tk.Cursor].Dest != "渋谷" {
@@ -582,9 +625,17 @@ func TestTicketLevelFlow(t *testing.T) {
if r.Wallet.Ticket != "渋谷" {
t.Fatalf("biljett=%q", r.Wallet.Ticket)
}
walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter))
walk(t, r, l.Path(r.Player.Tile, goal, r.PathCanEnter))
waitForTrain(t, r)
r.Step(frame, None, true)
cp, ok := r.Mini.(*minigame.CarPick)
if !ok {
t.Fatalf("ombordstigning börjar med vagnval, fick %T", r.Mini)
}
for cp.Alighting > 0 {
r.StepMini(frame, minigame.Input{})
}
r.StepMini(frame, minigame.Input{Confirm: true})
cr, ok := r.Mini.(*minigame.Crowd)
if !ok {
t.Fatalf("fullt tåg ska ge trängselminispel, fick %T", r.Mini)
@@ -608,9 +659,14 @@ func TestCrowdFailureMissesTrain(t *testing.T) {
r.Party.nextEventAt = 1e9
r.Wallet.Ticket = "渋谷"
goal := l.CorrectPlatform()
walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter))
walk(t, r, l.Path(r.Player.Tile, goal, r.PathCanEnter))
waitForTrain(t, r)
r.Step(frame, None, true)
cp := r.Mini.(*minigame.CarPick)
for cp.Alighting > 0 {
r.StepMini(frame, minigame.Input{})
}
r.StepMini(frame, minigame.Input{Confirm: true})
cr := r.Mini.(*minigame.Crowd)
for n := 0; n < CrowdTries; n++ {
for cr.InZone() {
@@ -641,7 +697,7 @@ func TestPhoneSpeedRescanAndBattery(t *testing.T) {
r.Step(frame, None, false)
}
gate := l.Find(level.Gate)[0]
path := l.Path(r.Player.Tile, gate, r.CanEnter)
path := l.Path(r.Player.Tile, gate, r.PathCanEnter)
for i, p := range path { // stanna på den första spärrutan – där sker skanningen
if l.At(p) == level.Gate {
path = path[:i+1]
@@ -669,7 +725,7 @@ func TestPhoneSpeedRescanAndBattery(t *testing.T) {
r := NewRun(l, RunOptions{Seed: rng.FromInt(5)})
r.Party.nextEventAt = 1e9
r.Wallet.Phone = true
walk(t, r, l.Path(r.Player.Tile, l.Find(level.Gate)[0], r.CanEnter))
walk(t, r, l.Path(r.Player.Tile, l.Find(level.Gate)[0], r.PathCanEnter))
return r.Rescans
}
if run() != run() {
@@ -720,12 +776,12 @@ func TestScoreBreakdown(t *testing.T) {
l := loadLevel(t, "001-flygplatsen")
r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(2)})
r.Party.nextEventAt = 1e9
walk(t, r, l.Path(r.Player.Tile, l.CorrectPlatform(), r.CanEnter))
walk(t, r, l.Path(r.Player.Tile, l.CorrectPlatform(), r.PathCanEnter))
waitForTrain(t, r)
r.Step(frame, None, true)
ride(t, r)
sc := r.Score
if r.Outcome != Won || sc.Total <= 0 || sc.Total != sc.Time+sc.Money+sc.Meters+sc.Explore-sc.Penalty {
if r.Outcome != Won || sc.Total <= 0 || sc.Total != sc.Time+sc.Money+sc.Meters+sc.Explore+sc.Car-sc.Penalty {
t.Fatalf("score=%+v", sc)
}
if sc.Time != ScoreTimeMax || sc.Money != ScoreMoneyMax-l.FareYen/10 || sc.Penalty != 0 {
@@ -756,7 +812,7 @@ func TestDebuffsAffectRunAndAreEarned(t *testing.T) {
for r.Player.Moving() {
r.Step(frame, None, false)
}
walk(t, r, l.Path(r.Player.Tile, l.Find(level.Sign)[1], r.CanEnter))
walk(t, r, l.Path(r.Player.Tile, l.Find(level.Sign)[1], r.PathCanEnter))
r.AskHelp()
if want := lang.HelpSeconds(0) / 0.7; math.Abs(r.Help.Total-want) > 1e-9 {
t.Fatalf("hjälptid med dåligt fokus %.2f, vill ha %.2f", r.Help.Total, want)
@@ -794,7 +850,7 @@ func TestIndividualCardsAtGateAndMachine(t *testing.T) {
t.Fatal("utan saldo på Annas kort stoppar spärren alla")
}
// Fyll på Annas kort i automaten (personval först).
walk(t, r, l1.Path(r.Player.Tile, l1.Find(level.TicketMachine)[0], r.CanEnter))
walk(t, r, l1.Path(r.Player.Tile, l1.Find(level.TicketMachine)[0], r.PathCanEnter))
r.Step(frame, None, true)
g := r.Mini.(*minigame.TopUp)
if g.Stage != minigame.TopUpPickMember || len(g.Members) != 2 {
@@ -816,7 +872,7 @@ func TestIndividualCardsAtGateAndMachine(t *testing.T) {
}
// Spärren drar från allas kort.
before := r.Wallet.Spent
path := l1.Path(r.Player.Tile, gate, r.CanEnter)
path := l1.Path(r.Player.Tile, gate, r.PathCanEnter)
for i, p := range path {
if l1.At(p) == level.Gate {
path = path[:i+1]
@@ -836,7 +892,7 @@ func TestLevel003Flow(t *testing.T) {
r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(21)})
r.Party.nextEventAt = 1e9
machine := l.Find(level.TicketMachine)[0]
walk(t, r, l.Path(r.Player.Tile, machine, r.CanEnter))
walk(t, r, l.Path(r.Player.Tile, machine, r.PathCanEnter))
r.Step(frame, None, true)
tk := r.Mini.(*minigame.Ticket)
if len(tk.Members) != 4 || tk.Cash != StartCashYen/4 {
@@ -896,15 +952,10 @@ func TestLevel003Flow(t *testing.T) {
t.Fatal("missnöje ska flashas")
}
goal := l.CorrectPlatform()
walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter))
walk(t, r, l.Path(r.Player.Tile, goal, r.PathCanEnter))
waitForTrain(t, r)
r.Step(frame, None, true)
cr := r.Mini.(*minigame.Crowd)
for i := 0; i < 600 && !cr.InZone(); i++ {
r.StepMini(frame, minigame.Input{})
}
r.StepMini(frame, minigame.Input{Confirm: true})
ride(t, r)
ride(t, r) // vagnval, ev. trängsel, resa
if r.Outcome != Won || r.Wallet.TicketCount != 0 {
t.Fatalf("outcome=%v reason=%q tickets=%d", r.Outcome, r.LostReason, r.Wallet.TicketCount)
}
@@ -917,7 +968,7 @@ func TestNearTrackAndPhases(t *testing.T) {
if _, _, _, _, _, ok := r.NearTrack(2); ok {
t.Fatal("från starten är ingen perrong nära")
}
walk(t, r, l.Path(r.Player.Tile, l.CorrectPlatform(), r.CanEnter))
walk(t, r, l.Path(r.Player.Tile, l.CorrectPlatform(), r.PathCanEnter))
tr, p, phase, prog, dist, ok := r.NearTrack(2)
if !ok || dist != 0 || p != l.CorrectPlatform() || !tr.Correct || prog < 0 || prog > 1 {
t.Fatalf("ok=%v dist=%d p=%v", ok, dist, p)
@@ -987,7 +1038,7 @@ func TestRunOptionsFromProgression(t *testing.T) {
for r.Player.Moving() {
r.Step(frame, None, false)
}
path := l.Path(r.Player.Tile, gate, r.CanEnter)
path := l.Path(r.Player.Tile, gate, r.PathCanEnter)
for i, p := range path {
if l.At(p) == level.Gate {
path = path[:i+1]
@@ -1055,7 +1106,7 @@ func TestStairsMoveBetweenFloors(t *testing.T) {
// Hela vägen till perrongen med BFS över våningar. Man står i trappans
// nedre ände, så kliv av först – trappan tar en när man kliver PÅ den.
walk(t, r, []level.Point{r.Player.Tile, {X: 3, Y: 1, Z: 0}})
path := l.Path(r.Player.Tile, l.CorrectPlatform(), r.CanEnter)
path := l.Path(r.Player.Tile, l.CorrectPlatform(), r.PathCanEnter)
if path == nil {
t.Fatal("ingen väg")
}
@@ -1064,3 +1115,71 @@ func TestStairsMoveBetweenFloors(t *testing.T) {
t.Fatalf("boten nådde inte perrongen: %v", r.Player.Tile)
}
}
func TestCarsWomenOnlyEarlyAndCrowd(t *testing.T) {
l := loadLevel(t, "005-ueno")
tr := &l.Trains[0]
if tr.Cars != 4 || tr.WomenOnlyCar != 1 || !tr.WomenOnlyActive(10) || tr.WomenOnlyActive(200) {
t.Fatal("005: fyra vagnar, vagn 1 kvinnovagn de första 150 s")
}
r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(8)})
r.Party.nextEventAt = 1e9
r.NPCs = nil // folket ska inte stå i vägen för testboten
if !r.Party.HasMen() {
t.Fatal("tre i sällskapet: Du, Anna, Ville – Ville är man")
}
walk(t, r, l.Path(r.Player.Tile, l.CorrectPlatform(), r.PathCanEnter))
waitForTrain(t, r)
if r.Time >= 150 {
t.Skip("tåget kom efter rusningen i det här seedet")
}
r.Step(frame, None, true)
cp := r.Mini.(*minigame.CarPick)
if !cp.Cars[0].WomenOnly {
t.Fatal("vagn 1 ska vara kvinnovagn nu")
}
// För tidigt: folk kliver av först.
r.StepMini(frame, minigame.Input{Confirm: true})
for cp.Alighting > 0 {
r.StepMini(frame, minigame.Input{})
}
// Välj kvinnovagnen med en man i sällskapet: grova minus.
for cp.Cursor != 0 {
r.StepMini(frame, minigame.Input{MoveX: -1})
}
r.StepMini(frame, minigame.Input{Confirm: true})
if !r.WomenOnlyViolation || r.EarlyBoard != 1 || r.FlashKey != FlashWomenOnly || r.CarChosen == nil || r.CarChosen.Index != 1 {
t.Fatalf("violation=%v early=%d flash=%q car=%+v", r.WomenOnlyViolation, r.EarlyBoard, r.FlashKey, r.CarChosen)
}
if cr, ok := r.Mini.(*minigame.Crowd); ok { // full vagn -> trängsel
for i := 0; i < 600 && !cr.InZone(); i++ {
r.StepMini(frame, minigame.Input{})
}
r.StepMini(frame, minigame.Input{Confirm: true})
}
rd := r.Mini.(*minigame.Ride)
if rd.Crowd != r.CarChosen.Crowd || rd.StopSec != minigame.RideStopSec(rd.Crowd) {
t.Fatal("resan ska ärva vagnens trängsel")
}
ride(t, r)
if r.Outcome != Won {
t.Fatalf("outcome=%v reason=%q", r.Outcome, r.LostReason)
}
if r.Score.Penalty < PenaltyWomenOnly+PenaltyEarlyBoard {
t.Fatalf("avdrag=%d, vill ha minst %d", r.Score.Penalty, PenaltyWomenOnly+PenaltyEarlyBoard)
}
// Bonus för lugn vagn.
r2 := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(8)})
r2.Party.nextEventAt = 1e9
r2.NPCs = nil
walk(t, r2, l.Path(r2.Player.Tile, l.CorrectPlatform(), r2.PathCanEnter))
waitForTrain(t, r2)
r2.Step(frame, None, true)
ride(t, r2) // pickCar väljer lugnaste icke-kvinnovagnen
if r2.Outcome == Won && r2.CarChosen.Crowd == 0 && r2.Score.Car != ScoreCarBonus {
t.Fatalf("lugn vagn ska ge bonus: %+v", r2.Score)
}
if r2.WomenOnlyViolation || r2.Score.Penalty >= PenaltyWomenOnly {
t.Fatal("rätt vagn: inga grova minus")
}
}

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*).
- `door-chime.wav` – dörrsignal, spelas strax före avgång (fasen *closing*).
Båda är **placeholders** genererade av `go run ./tools/gensound`; riktiga
- `chatter.wav` – loopbart stationssorl (bakgrundsprat), spelas på banor med
`noise` > 0 och konkurrerar med melodierna.
Alla är **placeholders** genererade av `go run ./tools/gensound`; riktiga
inspelningar/kompositioner ersätter dem i M12 (behåll filnamnen).
Planerat: utrop, folkmassa, spärr-pip, automat-ljud, UI-klick, bakgrundsprat
och reklam som ljudröra (M9b).

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.party": "Party",
"map.resume": "⟳ resume (saved seed)",
"map.locked": "Locked – clear the previous level first.",
"map.locked": "Locked - clear the previous level first.",
"map.hint": "Arrows: level Enter: start L: words T: skills I: wardrobe P: phone Esc",
"skills.title": "Skills 技",
"skills.to_next": "XP to next",
"skills.already": "Already unlocked.",
"skills.unlocked": "Unlocked: %s",
"skills.need_points": "Costs %d skill points – level up by clearing levels.",
"skills.need_points": "Costs %d skill points - level up by clearing levels.",
"skills.need_req": "Requires an earlier skill in the branch.",
"skills.hint": "Arrows: select Enter: unlock Esc/T: back",
"items.title": "Wardrobe 持ち物",
"items.owned": "items",
"items.owned_short": "owned",
"items.wearing": "worn",
"items.none": "Nothing yet – buy at kiosks (B) or clear side trips.",
"items.none": "Nothing yet - buy at kiosks (B) or clear side trips.",
"items.worn": "You wear %s.",
"items.removed": "You took off %s.",
"items.total": "Total",
@@ -181,5 +181,21 @@
"effect.landmarks": "landmarks on map",
"effect.free_gates": "free gates",
"effect.reveal": "map: %s",
"effect.battery": "infinite battery"
"effect.battery": "infinite battery",
"mini.car.title": "Choose a car",
"mini.car.alighting": "People are getting off - wait…",
"mini.car.pick": "Pick a car with the arrows, board with Enter.",
"mini.car.men": "There is a man in the party - not the women-only car (pink)!",
"mini.car.hint": "←/→: car Enter: board Esc: cancel",
"car.crowd.low": "few people",
"car.crowd.mid": "some people",
"car.crowd.high": "packed",
"hud.car_suffix_ja": "号車",
"mini.ride.let_pass": "Someone wants to get off - press ← to step aside!",
"mini.ride.crowd": "Crowding",
"hud.women_only": "Women-only car with a man in the party - heavy penalty!",
"hud.early_board": "Let people off first!",
"hud.bump": "Sumimasen…",
"hud.wrong_lane": "Wrong side - keep to the side the lane shows",
"result.car": "car"
}

View File

@@ -588,5 +588,62 @@
"en": "down",
"sv": "ner",
"tier": 1
},
"上野駅": {
"kana": "うえのえき",
"romaji": "Ueno-eki",
"en": "Ueno Station",
"sv": "Ueno station",
"tier": 3
},
"京浜東北線": {
"kana": "けいひんとうほくせん",
"romaji": "Keihin-Tōhoku-sen",
"en": "Keihin-Tohoku Line",
"sv": "Keihin-Tohoku-linjen",
"tier": 3
},
"大宮": {
"kana": "おおみや",
"romaji": "Ōmiya",
"en": "Omiya",
"sv": "Omiya",
"tier": 3,
"kind": "station"
},
"女性専用車": {
"kana": "じょせいせんようしゃ",
"romaji": "josei sen'yō-sha",
"en": "Women-only car",
"sv": "Kvinnovagn",
"tier": 2
},
"号車": {
"kana": "ごうしゃ",
"romaji": "gōsha",
"en": "car no.",
"sv": "vagn nr",
"tier": 2
},
"ラッシュ": {
"kana": "ラッシュ",
"romaji": "rasshu",
"en": "rush hour",
"sv": "rusning",
"tier": 2
},
"左側": {
"kana": "ひだりがわ",
"romaji": "hidarigawa",
"en": "left side",
"sv": "vänster sida",
"tier": 2
},
"通行": {
"kana": "つうこう",
"romaji": "tsūkō",
"en": "passage / keep",
"sv": "gång / håll",
"tier": 3
}
}

View File

@@ -139,20 +139,20 @@
"map.side": "Sidospår",
"map.party": "Sällskap",
"map.resume": "⟳ fortsätt (sparad seed)",
"map.locked": "Låst – klara föregående bana först.",
"map.locked": "Låst - klara föregående bana först.",
"map.hint": "Pilar: bana Enter: start L: ord T: skills I: garderob P: tel Esc",
"skills.title": "Skills 技",
"skills.to_next": "XP till nästa",
"skills.already": "Redan upplåst.",
"skills.unlocked": "Upplåst: %s",
"skills.need_points": "Kostar %d skillpoäng – gå upp i nivå genom att klara banor.",
"skills.need_points": "Kostar %d skillpoäng - gå upp i nivå genom att klara banor.",
"skills.need_req": "Kräver en tidigare skill i grenen.",
"skills.hint": "Pilar: välj Enter: lås upp Esc/T: tillbaka",
"items.title": "Garderob 持ち物",
"items.owned": "items",
"items.owned_short": "ägs",
"items.wearing": "bärs",
"items.none": "Inget än – köp i kioskerna (B) eller klara sidospår.",
"items.none": "Inget än - köp i kioskerna (B) eller klara sidospår.",
"items.worn": "Du bär %s.",
"items.removed": "Du tog av %s.",
"items.total": "Totalt",
@@ -181,5 +181,21 @@
"effect.landmarks": "landmärken på kartan",
"effect.free_gates": "gratis spärrar",
"effect.reveal": "karta: %s",
"effect.battery": "oändligt batteri"
"effect.battery": "oändligt batteri",
"mini.car.title": "Välj vagn",
"mini.car.alighting": "Folk kliver av - vänta…",
"mini.car.pick": "Välj vagn med pilarna, kliv på med Enter.",
"mini.car.men": "Det finns en man i sällskapet - inte kvinnovagnen (rosa)!",
"mini.car.hint": "←/→: vagn Enter: kliv på Esc: avbryt",
"car.crowd.low": "lite folk",
"car.crowd.mid": "en del folk",
"car.crowd.high": "fullt",
"hud.car_suffix_ja": "号車",
"mini.ride.let_pass": "Någon vill förbi - tryck ← för att kliva åt sidan!",
"mini.ride.crowd": "Trängsel",
"hud.women_only": "Kvinnovagn med en man i sällskapet - grova minus!",
"hud.early_board": "Låt folk kliva av först!",
"hud.bump": "Ursäkta…",
"hud.wrong_lane": "Fel sida - håll till vänster/höger som filen visar",
"result.car": "vagn"
}

View File

@@ -4,7 +4,7 @@
//
// Skriver resources/sprites/tiles.png (en remsa 16×16-rutor i ordningen
// vägg, golv, start, perrong, spärr, automat, skylt, tågvagn, toalett, kiosk, räls,
// trappa upp, trappa ner) och
// trappa upp, trappa ner, fil vänster, fil höger, affisch ×3) och
// resources/sprites/player.png (3 bildrutor × 4 riktningar: ner, upp,
// vänster, höger; kolumn 0 = stilla, 1–2 = gå). Ritas med enkla rektanglar
// så att allt är deterministiskt och lätt att justera.
@@ -163,6 +163,44 @@ var tiles = []func(canvas){
c.rect(6, 13, 4, 1, boxDark)
c.rect(5, 12, 6, 1, boxDark)
},
func(c canvas) { // fil vänster: golv med vänsterpilar
floor(c)
for _, y := range []int{3, 11} {
c.rect(4, y, 8, 1, metalDark)
c.rect(3, y-1, 1, 3, metalDark)
}
},
func(c canvas) { // fil höger: golv med högerpilar
floor(c)
for _, y := range []int{3, 11} {
c.rect(4, y, 8, 1, metalDark)
c.rect(12, y-1, 1, 3, metalDark)
}
},
func(c canvas) { // affisch 1: färgglad reklam
c.rect(0, 0, 16, 16, wallMid)
c.rect(2, 1, 12, 14, rgb(250, 230, 120))
c.rect(3, 2, 10, 5, rgb(228, 59, 68))
c.rect(4, 8, 8, 1, boxDark)
c.rect(4, 10, 6, 1, boxDark)
c.rect(4, 12, 7, 1, boxDark)
},
func(c canvas) { // affisch 2: blå lockskylt som liknar en riktig skylt
c.rect(0, 0, 16, 16, wallMid)
c.rect(1, 2, 14, 12, signBoard)
c.rect(1, 2, 14, 1, white)
c.rect(3, 5, 10, 1, white)
c.rect(3, 8, 6, 1, white)
c.rect(3, 11, 8, 1, yellow)
},
func(c canvas) { // affisch 3: rosa med stort tecken
c.rect(0, 0, 16, 16, wallMid)
c.rect(2, 1, 12, 14, rgb(255, 170, 200))
c.rect(5, 4, 6, 6, white)
c.rect(7, 4, 2, 6, rgb(200, 60, 120))
c.rect(5, 6, 6, 2, rgb(200, 60, 120))
c.rect(4, 12, 8, 1, rgb(120, 30, 70))
},
func(c canvas) { // kiosk: randig markis och disk
floor(c)
c.rect(1, 1, 14, 3, awningRed)

View File

@@ -83,12 +83,39 @@ func writeWAV(path string, pcm []int16) error {
return binary.Write(f, binary.LittleEndian, pcm)
}
// chatter syntetiserar sex sekunder loopbart stationssorl: lågpassat brus
// med stavelseliknande amplitudmodulation från en deterministisk LCG.
func chatter(seconds float64) []int16 {
n := int(seconds * rate)
out := make([]int16, n)
var lcg uint32 = 12345
next := func() float64 { lcg = lcg*1664525 + 1013904223; return float64(lcg>>8)/float64(1<<24)*2 - 1 }
lp := 0.0
env, envTarget, envT := 0.3, 0.3, 0.0
for i := 0; i < n; i++ {
t := float64(i) / rate
// ny "stavelse" var 120–250 ms
if t >= envT {
envTarget = 0.1 + 0.6*math.Abs(next())
envT = t + 0.12 + 0.13*math.Abs(next())
}
env += (envTarget - env) * 0.002
lp += (next() - lp) * 0.08 // lågpass ≈ 600 Hz
v := lp * env * 0.6
// mjuk in-/utfasning för sömlös loop
fade := math.Min(1, math.Min(t, seconds-t)/0.2)
out[i] = int16(v * fade * 32767)
}
return out
}
func main() {
out := filepath.Join(repoRoot(), "resources", "sound")
must(os.MkdirAll(out, 0o755))
must(writeWAV(filepath.Join(out, "melody-approach.wav"), render(approachMelody, 0.01, 0.06, 0.3)))
must(writeWAV(filepath.Join(out, "door-chime.wav"), render(doorChime, 0.005, 0.04, 0.1)))
fmt.Println("skrev", filepath.Join(out, "melody-approach.wav"), "och door-chime.wav")
must(writeWAV(filepath.Join(out, "chatter.wav"), chatter(6)))
fmt.Println("skrev", filepath.Join(out, "melody-approach.wav"), ", door-chime.wav och chatter.wav")
}
func repoRoot() string {

View File

@@ -102,6 +102,14 @@ func buildShots(levelsDir string) error {
if err != nil {
return err
}
l5, err := level.LoadFile(filepath.Join(levelsDir, "005-ueno.json"))
if err != nil {
return err
}
toPlatform5, err := moves(l5, l5.SpawnPoint(), l5.CorrectPlatform())
if err != nil {
return err
}
// Skyltbilderna tas med några kända ord så att markeringen och den
// delvisa översättningen syns; hjälpen lär sedan in nästa ord.
known := []string{"-know", "改札,番線,方面", "-seed", "7"}
@@ -118,6 +126,7 @@ func buildShots(levelsDir string) error {
{"003-shibuya", toMachine3 + "; press enter; pick 池袋; press enter; wait 5; press enter; press right; wait 5; shot $OUT/seats.png; quit",
[]string{"-seed", "7", "-know", "池袋", "-debuffs", "sore_feet"}},
{"004-tokyo", "wait 10; shot $OUT/floors-b1.png; " + toStairs + "; wait 20; shot $OUT/floors-1f.png; quit", []string{"-seed", "7"}},
{"005-ueno", "wait 90; shot $OUT/crowd.png; " + toPlatform5 + "; waittrain; wait 5; press enter; wait 20; shot $OUT/cars-alight.png; wait 80; press right; wait 5; shot $OUT/cars.png; quit", []string{"-seed", "7"}},
}
return nil
}
@@ -190,6 +199,9 @@ func main() {
}
_ = demo
for _, s := range shots {
if len(os.Args) > 1 && !matchesAny(s, os.Args[1:]) {
continue // `go run ./tools/shots 005 ride` kör bara skott vars namn eller bana innehåller något av orden
}
args := []string{"-mute", "-nosave", "-script", strings.ReplaceAll(s.script, "$OUT", out)}
if s.level != "" {
args = append(args, "-level", s.level)
@@ -234,3 +246,13 @@ func fail(err error) {
fmt.Fprintln(os.Stderr, "shots:", err)
os.Exit(1)
}
// matchesAny rapporterar om skottets bana eller skript innehåller något av orden.
func matchesAny(s shot, words []string) bool {
for _, w := range words {
if strings.Contains(s.level, w) || strings.Contains(s.script, w) {
return true
}
}
return false
}