M9a: stationer i flera våningar med trappor
All checks were successful
release / build-release (push) Successful in 1m34s

- level: Point har Z; banor kan ha floors (lika stora rutnät med namn),
  gamla grid-banor läses som en våning; trappor U/D på samma x,y en våning
  upp/ner valideras parvis; Neighbors ger grannar över våningar så att
  Path, lösbarhet, spärregel och Unreachable fungerar; skyltar har z
- sim: att kliva på en trappa flyttar spelaren till andra änden
  (FloorChanges); dimman är per våning och avslöjar trappans andra ände;
  tåg hörs/syns bara på egen våning
- game: bara spelarens våning ritas (följare på andra våningar döljs),
  HUD och minikarta visar våningens namn, trappgrafik i genart och färg på
  minikartan
- bana 004 Tokyo (svårighet 3, tre i sällskapet): start under mark på B1,
  trappa upp till 1F med perronger; generatorn gör våningar med trappor
  som flaskhalsar; ordboken utökad
- testbotar och tools/shots förstår trappor; två våningsbilder
- docs/README/wiki uppdaterade

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 11:54:07 +02:00
parent ef6cfaccc8
commit 8fca14e42d
23 changed files with 704 additions and 152 deletions

View File

@@ -10,14 +10,14 @@ Storyn följer några polare på semester i Japan, från landningen på
flygplatsen till avresan. Efter storyn låses ett oändligt läge och New Game+ flygplatsen till avresan. Efter storyn låses ett oändligt läge och New Game+
upp. Hela speldesignen finns i [doc/spelbeskrivning.md](doc/spelbeskrivning.md). upp. Hela speldesignen finns i [doc/spelbeskrivning.md](doc/spelbeskrivning.md).
**Status:** M8 klar (2026-10-24) – fyra banor på en bankarta i **Status:** M9a klar (2026-10-24) – fem banor på en bankarta i
spelbrädesstil, med sidospår. Japanska skyltar och ordinlärning, spelbrädesstil, med sidospår. Japanska skyltar och ordinlärning,
lämmeltåget, minikarta, tågkort, biljetter och minispel, svårighet med 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. Och nu tidsgräns, poäng och debuffar, tåg som rullar in till avgångsmelodin,
progression: XP och nivåer, ett skillträd, items med buffar och genvägar som progression med XP, skillträd, items och sparfil. Och nu stationer i flera
köps i kioskernas butiker eller vinns på sidospår, och en sparfil som minns våningar: Tokyo station börjar under mark och trappan tar dig upp till
allt inklusive seeden på den bana du höll på med. Nästa är M9a: stationer i perrongerna. Nästa är M9b: folk, reklam och ljudröra. Se
flera våningar. Se [doc/roadmap.md](doc/roadmap.md). [doc/roadmap.md](doc/roadmap.md).
![Bankartan](doc/img/map.png) ![Bankartan](doc/img/map.png)
@@ -51,6 +51,11 @@ visar vad du bär.
![Skillträdet](doc/img/skills.png) ![Skillträdet](doc/img/skills.png)
Stationer kan ha flera våningar. Du ser bara den våning du står på; trappor
tar dig upp och ner, och minikartan visar vilket plan du är på.
![Tokyo station, 1F](doc/img/floors-1f.png)
På de svåraste banorna har alla i sällskapet egna kort som måste ha saldo, På de svåraste banorna har alla i sällskapet egna kort som måste ha saldo,
alla behöver biljett, och när biljetten är köpt ska sällskapet placeras i alla behöver biljett, och när biljetten är köpt ska sällskapet placeras i
vagnen efter sina önskemål: fönster eller gång, vänster eller höger sida, vagnen efter sina önskemål: fönster eller gång, vänster eller höger sida,

BIN
doc/img/floors-1f.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
doc/img/floors-b1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 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: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -34,7 +34,7 @@ att bygga på Linux som på Windows.
| Inkrementellt debug-bygge | < 10 s | 4,7 s | | Inkrementellt debug-bygge | < 10 s | 4,7 s |
| Release-binär | < 20 MB | 10,2 MB (Linux), 10,8 MB (Windows) | | Release-binär | < 20 MB | 10,2 MB (Linux), 10,8 MB (Windows) |
| Testsviten | `go test ./...` grön på < 30 s | ~0,1 s | | Testsviten | `go test ./...` grön på < 30 s | ~0,1 s |
| Banor | 100 % passerar det automatiska lösbarhetstestet | 4 av 4 | | Banor | 100 % passerar det automatiska lösbarhetstestet | 5 av 5 |
| Windows-korsbygge från Linux | fungerar utan extra verktyg | ✓ (CGO_ENABLED=0) | | Windows-korsbygge från Linux | fungerar utan extra verktyg | ✓ (CGO_ENABLED=0) |
| Linux-binärens länkade bibliotek | bara libc | ✓ (GL/X11 laddas dynamiskt vid körning) | | Linux-binärens länkade bibliotek | bara libc | ✓ (GL/X11 laddas dynamiskt vid körning) |
| Bildfrekvens | 60 fps vid 4× skala på integrerad grafik | ej mätt | | Bildfrekvens | 60 fps vid 4× skala på integrerad grafik | ej mätt |
@@ -124,6 +124,8 @@ att bygga på Linux som på Windows.
| `internal/save` | `TestRoundTrip`, `TestCorruptAndFutureVersions`, `TestDefaultPath` | Skriv → läs ger samma tillstånd inkl. seed; trasig/nyare fil ger fel; version 0 migreras | | `internal/save` | `TestRoundTrip`, `TestCorruptAndFutureVersions`, `TestDefaultPath` | Skriv → läs ger samma tillstånd inkl. seed; trasig/nyare fil ger fel; version 0 migreras |
| `internal/sim` | `TestRunOptionsFromProgression` | Extra stats, sikt, landmärken, gratis spärrar, hela kartan, powerbank | | `internal/sim` | `TestRunOptionsFromProgression` | Extra stats, sikt, landmärken, gratis spärrar, hela kartan, powerbank |
| `internal/minigame` | `TestCrowdLuckWidensZone` | Tur gör zonen bredare, otur smalare, klampat | | `internal/minigame` | `TestCrowdLuckWidensZone` | Tur gör zonen bredare, otur smalare, klampat |
| `internal/level` | `TestFloorsStairsAndPaths`, `TestFloorsValidation` | **M9a:** flera våningar läses, trappor länkar, BFS-vägen byter våning bara via trappa, spärregeln och skyltar per våning; trappa utan motpart, olika storlekar, grid+floors och fel våning avvisas |
| `internal/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/archtest` | `TestOnlyPresentationPackagesImportEbitengine` | Beroenderegeln ovan | | `internal/archtest` | `TestOnlyPresentationPackagesImportEbitengine` | Beroenderegeln ovan |
### Planerade tester per milstolpe ### Planerade tester per milstolpe
@@ -140,7 +142,7 @@ att bygga på Linux som på Windows.
| ~~M7b Tåg som kommer och går~~ ✓ | Faser, ombordstigning, indikatoravstånd – finns, se tabellen ovan; ljud och animation verifieras manuellt/med skärmdumpar | | ~~M7b Tåg som kommer och går~~ ✓ | Faser, ombordstigning, indikatoravstånd – finns, se tabellen ovan; ljud och animation verifieras manuellt/med skärmdumpar |
| M9b Folk, reklam, ljudröra | NPC:er följer seedade mål utan att fastna; krock saktar ned; fel sida i gång bromsar; bot klarar bana med folk; fler NPC:er = längre tid; clutter/noise läses per bana | | M9b Folk, reklam, ljudröra | NPC:er följer seedade mål utan att fastna; krock saktar ned; fel sida i gång bromsar; bot klarar bana med folk; fler NPC:er = längre tid; clutter/noise läses per bana |
| ~~M8 Progression & sparfil~~ ✓ | Round-trip, skillkrav, upplåsningar, 33 %-regeln – finns, se tabellen ovan | | ~~M8 Progression & sparfil~~ ✓ | Round-trip, skillkrav, upplåsningar, 33 %-regeln – finns, se tabellen ovan |
| M9a Flera våningar | Trappa byter våning för Mover och följare; `Path` över våningar; lösbarhetstest med trappor; dimma/minikarta per våning; gamla banor = en våning | | ~~M9a Flera våningar~~ ✓ | Trappor, vägar över våningar, dimma per våning, gamla banor oförändrade – finns, se tabellen ovan |
| M9 Navigeringslägen | Listläge genererar rätt ordning av tåg; skyltläge: 1–4 giltiga vägar; fel-riktnings-förlust triggar vid rätt slutstation | | M9 Navigeringslägen | Listläge genererar rätt ordning av tåg; skyltläge: 1–4 giltiga vägar; fel-riktnings-förlust triggar vid rätt slutstation |
| M10 Story-innehåll | Alla banor lösbara (redan automatiskt); flerbytesbanor har sammanhängande byten; sista banan slutar på flygplatsen | | M10 Story-innehåll | Alla banor lösbara (redan automatiskt); flerbytesbanor har sammanhängande byten; sista banan slutar på flygplatsen |
| M11 Oändligt läge & NG+ | Målsekvens deterministisk per seed; NG+ behåller skills men nollställer items | | M11 Oändligt läge & NG+ | Målsekvens deterministisk per seed; NG+ behåller skills men nollställer items |

View File

@@ -34,7 +34,7 @@ brasse-linux01-runnern, inte Pi5:n.
M0 skelett ✓ → M1 rörelse/kamera ✓ → M2 tåg/spärrar/vinst ✓ → M3 japanska/skyltar ✓ M0 skelett ✓ → M1 rörelse/kamera ✓ → M2 tåg/spärrar/vinst ✓ → M3 japanska/skyltar ✓
→ M4 lämmeltåget ✓ → M5 minikarta ✓ → M6 kort/biljetter/minispel ✓ → M7 → M4 lämmeltåget ✓ → M5 minikarta ✓ → M6 kort/biljetter/minispel ✓ → M7
svårighet/poäng/debuffar ✓ → M7b tåg som kommer och går ✓ → M8 progression/sparfiler ✓ → M9a flera våningar → M9b folk/reklam/ljudröra → M9 navigeringslägen → svårighet/poäng/debuffar ✓ → M7b tåg som kommer och går ✓ → M8 progression/sparfiler ✓ → M9a flera våningar ✓ → M9b folk/reklam/ljudröra → M9 navigeringslägen →
M10 story-innehåll → M11 oändligt läge/NG+ → M12 ljud/polish/release. M10 story-innehåll → M11 oändligt läge/NG+ → M12 ljud/polish/release.
Innehåll per milstolpe: [roadmap.md › Milstolpar](roadmap.md#milstolpar). Innehåll per milstolpe: [roadmap.md › Milstolpar](roadmap.md#milstolpar).
Vad som ska vara testat per milstolpe: [mal-och-tester.md](mal-och-tester.md). Vad som ska vara testat per milstolpe: [mal-och-tester.md](mal-och-tester.md).

View File

@@ -401,22 +401,32 @@ på **brasse-linux01-runnern** (label `linux-amd64`), aldrig på Pi5:n.
- **Klar när:** sparfil round-trip-test grön; "< 33 %" kontrolleras mot - **Klar när:** sparfil round-trip-test grön; "< 33 %" kontrolleras mot
itemlistan; skillkrav, upplåsningar och belöningar testade. ✓ itemlistan; skillkrav, upplåsningar och belöningar testade. ✓
### M9a – Flera våningar (Björns tillägg 2026-10-24) ### M9a – Flera våningar ✓ (klar 2026-10-24)
- **Mål:** stationer i flera plan – ännu en svårighetsvariabel. - **Mål:** stationer i flera plan – ännu en svårighetsvariabel.
- Banformatet får **flera rutnät** (`floors`), ett per våning, med - **Banformat:** `floors: [{name, grid}, …]` med lika stora rutnät; gamla
**trappor** (och rulltrappor/hissar) som kopplar en ruta på en våning banor med `grid` läses som en våning ("1F"). Rutkoordinaten `Point` fick
till en ruta på en annan. Start kan ligga på en trappa upp eller ner: `Z`. **Trappor** är rutor: `U` (upp) på en våning kräver `D` (ner) på
under mark, ovan mark eller marknivå. samma x,y en våning upp och tvärtom (valideras). Går man in på en trappa
- Kamera, dimma, minikarta (en flik per våning eller stapel) och BFS-vägar hamnar man i andra änden i samma steg. Skyltar har `z`.
(`level.Path`, lösbarhetstestet, botarna) blir våningsmedvetna; skyltar - **Logik:** `Neighbors` ger grannarna inkl. trappans andra ände, så
kan peka "↑ 2F / ↓ B1". `Path`, lösbarhetstestet, spärregeln och `Unreachable` fungerar över
- **Teknik:** `level.Level.Floors []Grid` med `Stairs []Link`, `Point` våningar. Dimman är per våning och avslöjar trappans andra ände. Man
får `Z`; `sim.Mover` byter våning vid trappa; `tools/genart` får ser/hör bara tågen på sin egen våning.
trappruta; generatorn placerar trappor som flaskhalsar. - **Spel:** bara spelarens våning ritas (följare på andra våningar syns
- **Klar när:** en bana med två våningar går att klara av boten headless, inte förrän de kommer upp), HUD och minikartan visar våningens namn,
minikartan visar rätt våning, och alla gamla banor fungerar oförändrat trappor har egen grafik och färg på minikartan.
(en våning). - **Bana 004 Tokyo** (svårighet 3, tre i sällskapet): start under mark på
B1 (Marunouchi-linjen), trappan upp till 1F där perronger, spärrar,
automat, kiosk och toalett finns. Generatorn gör våningar: labyrint per
våning, trappor på gemensamma öppna rutor nära mitten, start på en våning
och perronger på en annan.
- **Teknik:** `level` (Storey, Point.Z, Neighbors, FloorAt), `sim.Run`
(trappbyte, FloorChanges), `sim.Fog` 3D, spelet ritar per våning;
testbotarna och `tools/shots` förstår trappor.
- **Klar när:** en bana med två våningar går att klara av boten headless
(`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 (Björns tillägg 2026-10-24)

View File

@@ -27,6 +27,7 @@ var tileOrder = []level.Tile{
level.Wall, // plats 7 = tågvagnen (laddas separat som Train) level.Wall, // plats 7 = tågvagnen (laddas separat som Train)
level.Toilet, level.Toilet,
level.Wall, // plats 9 = rälsen (laddas separat som Rails) level.Wall, // plats 9 = rälsen (laddas separat som Rails)
level.StairsUp, level.StairsDown,
level.Kiosk, level.Kiosk,
} }

View File

@@ -29,6 +29,8 @@ var minimapColors = map[level.Tile]color.RGBA{
level.Sign: {90, 140, 230, 255}, level.Sign: {90, 140, 230, 255},
level.Toilet: {235, 240, 250, 255}, level.Toilet: {235, 240, 250, 255},
level.Kiosk: {228, 59, 68, 255}, level.Kiosk: {228, 59, 68, 255},
level.StairsUp: {200, 150, 255, 255},
level.StairsDown: {150, 100, 220, 255},
} }
// minimapScale väljer pixlar per ruta så att kartan ryms i ungefär en // minimapScale väljer pixlar per ruta så att kartan ryms i ungefär en
@@ -80,8 +82,16 @@ func (s *LevelScene) drawMinimap(g *Game, screen *ebiten.Image, box image.Rectan
} }
sort.Slice(deps, func(i, j int) bool { return deps[i].track < deps[j].track }) sort.Slice(deps, func(i, j int) bool { return deps[i].track < deps[j].track })
z := r.Player.Tile.Z
floorLabel := ""
if l.NumFloors() > 1 {
floorLabel = l.FloorName(z)
}
w := max(mapW, 44) + 6 w := max(mapW, 44) + 6
h := mapH + 6 + len(deps)*LineH h := mapH + 6 + len(deps)*LineH
if floorLabel != "" {
h += LineH
}
px, py := r.Player.WorldPos(TileSize) px, py := r.Player.WorldPos(TileSize)
ox, oy := s.cam.Offset() ox, oy := s.cam.Offset()
playerRect := image.Rect(int(px+ox), int(py+oy), int(px+ox)+TileSize, int(py+oy)+TileSize) playerRect := image.Rect(int(px+ox), int(py+oy), int(px+ox)+TileSize, int(py+oy)+TileSize)
@@ -89,13 +99,17 @@ func (s *LevelScene) drawMinimap(g *Game, screen *ebiten.Image, box image.Rectan
vector.DrawFilledRect(screen, float32(rc.Min.X), float32(rc.Min.Y), float32(w), float32(h), colPanel, false) vector.DrawFilledRect(screen, float32(rc.Min.X), float32(rc.Min.Y), float32(w), float32(h), colPanel, false)
x0, y0 := rc.Min.X+3, rc.Min.Y+3 x0, y0 := rc.Min.X+3, rc.Min.Y+3
if floorLabel != "" {
a.Draw(screen, floorLabel, x0, y0, colKnown)
y0 += LineH
}
for y := 0; y < l.Height(); y++ { for y := 0; y < l.Height(); y++ {
for x := 0; x < l.Width(); x++ { for x := 0; x < l.Width(); x++ {
p := level.Point{X: x, Y: y} p := level.Point{X: x, Y: y, Z: z}
if !r.Fog.Visible(l, p) { if !r.Fog.Visible(l, p) {
continue continue
} }
t := l.At(x, y) t := l.At(p)
c := minimapColors[t] c := minimapColors[t]
if (t == level.Platform || t == level.WrongPlatform) && r.TrainVisible(t) { if (t == level.Platform || t == level.WrongPlatform) && r.TrainVisible(t) {
c = color.RGBA{255, 255, 255, 255} // tåget är inne c = color.RGBA{255, 255, 255, 255} // tåget är inne
@@ -106,6 +120,9 @@ func (s *LevelScene) drawMinimap(g *Game, screen *ebiten.Image, box image.Rectan
// Sällskapet: följarna i sina toner, spelaren blinkande vit. // Sällskapet: följarna i sina toner, spelaren blinkande vit.
for i := len(r.Party.Followers()); i >= 1; i-- { for i := len(r.Party.Followers()); i >= 1; i-- {
from, _ := r.Party.FollowerTiles(i) from, _ := r.Party.FollowerTiles(i)
if from.Z != z {
continue
}
tint := memberTints[i%len(memberTints)] tint := memberTints[i%len(memberTints)]
vector.DrawFilledRect(screen, float32(x0+from.X*sc), float32(y0+from.Y*sc), float32(sc), float32(sc), vector.DrawFilledRect(screen, float32(x0+from.X*sc), float32(y0+from.Y*sc), float32(sc), float32(sc),
color.RGBA{uint8(255 * tint[0]), uint8(255 * tint[1]), uint8(255 * tint[2]), 255}, false) color.RGBA{uint8(255 * tint[0]), uint8(255 * tint[1]), uint8(255 * tint[2]), 255}, false)

View File

@@ -125,7 +125,7 @@ func (s *LevelScene) Update(g *Game) error {
g.SetScene(NewMapScene(g)) g.SetScene(NewMapScene(g))
return nil return nil
} }
if shopJustPressed() && !s.run.Player.Moving() && s.run.Level.At(s.run.Player.Tile.X, s.run.Player.Tile.Y) == level.Kiosk { if shopJustPressed() && !s.run.Player.Moving() && s.run.Level.At(s.run.Player.Tile) == level.Kiosk {
s.openShop(g) s.openShop(g)
return nil return nil
} }
@@ -191,16 +191,18 @@ func (s *LevelScene) Draw(g *Game, screen *ebiten.Image) {
y0 := max(0, int(s.cam.Y)/TileSize) y0 := max(0, int(s.cam.Y)/TileSize)
x1 := min(lvl.Width(), int(s.cam.X+ScreenW)/TileSize+2) x1 := min(lvl.Width(), int(s.cam.X+ScreenW)/TileSize+2)
y1 := min(lvl.Height(), int(s.cam.Y+ScreenH)/TileSize+2) y1 := min(lvl.Height(), int(s.cam.Y+ScreenH)/TileSize+2)
z := s.run.Player.Tile.Z // bara spelarens våning ritas
var op ebiten.DrawImageOptions var op ebiten.DrawImageOptions
for y := y0; y < y1; y++ { for y := y0; y < y1; y++ {
for x := x0; x < x1; x++ { for x := x0; x < x1; x++ {
if s.rails[level.Point{X: x, Y: y}] { p := level.Point{X: x, Y: y, Z: z}
s.drawRails(g, screen, level.Point{X: x, Y: y}, ox, oy) if s.rails[p] {
s.drawRails(g, screen, p, ox, oy)
continue continue
} }
op.GeoM.Reset() op.GeoM.Reset()
op.GeoM.Translate(ox+float64(x*TileSize), oy+float64(y*TileSize)) op.GeoM.Translate(ox+float64(x*TileSize), oy+float64(y*TileSize))
screen.DrawImage(g.Assets.Tiles[lvl.At(x, y)], &op) screen.DrawImage(g.Assets.Tiles[lvl.At(p)], &op)
} }
} }
s.drawTrains(g, screen, ox, oy) s.drawTrains(g, screen, ox, oy)
@@ -211,6 +213,9 @@ func (s *LevelScene) Draw(g *Game, screen *ebiten.Image) {
followers := s.run.Party.Followers() followers := s.run.Party.Followers()
for i := len(followers); i >= 1; i-- { for i := len(followers); i >= 1; i-- {
from, to := s.run.Party.FollowerTiles(i) from, to := s.run.Party.FollowerTiles(i)
if from.Z != z && to.Z != z {
continue // på en annan våning
}
fx := float64(from.X*TileSize) + float64((to.X-from.X)*TileSize)*leader.Progress fx := float64(from.X*TileSize) + float64((to.X-from.X)*TileSize)*leader.Progress
fy := float64(from.Y*TileSize) + float64((to.Y-from.Y)*TileSize)*leader.Progress fy := float64(from.Y*TileSize) + float64((to.Y-from.Y)*TileSize)*leader.Progress
facing := sim.Down facing := sim.Down
@@ -296,6 +301,9 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
dest += " " + r.Level.Destination.Get("ja") dest += " " + r.Level.Destination.Get("ja")
} }
name := r.Level.Name.Get(UILang) name := r.Level.Name.Get(UILang)
if r.Level.NumFloors() > 1 {
name += " " + r.Level.FloorName(r.Player.Tile.Z)
}
a.Draw(screen, name, 4, 0, colText) a.Draw(screen, name, 4, 0, colText)
destX := 4 + a.Width(name) + 12 destX := 4 + a.Width(name) + 12
a.Draw(screen, a.T("hud.destination")+": "+dest, destX, 0, colKnown) a.Draw(screen, a.T("hud.destination")+": "+dest, destX, 0, colKnown)
@@ -411,7 +419,7 @@ func (s *LevelScene) infoLines(a *Assets) (lines []line, accent color.Color, bar
lines = append(lines, a.wrapLine(plainLine(fmt.Sprintf("%s: Track %s %s for %s", a.T("hud.english"), tr.Track, lines = append(lines, a.wrapLine(plainLine(fmt.Sprintf("%s: Track %s %s for %s", a.T("hud.english"), tr.Track,
tr.Line.Get("en"), tr.Destination.Get("en")), colEnglish), maxW)...) tr.Line.Get("en"), tr.Destination.Get("en")), colEnglish), maxW)...)
} }
phase, _ := r.PlatformPhase(lvl.At(tile.X, tile.Y)) phase, _ := r.PlatformPhase(lvl.At(tile))
switch { switch {
case phase == level.Closing: case phase == level.Closing:
accent = colWarn accent = colWarn
@@ -461,7 +469,7 @@ func (s *LevelScene) infoLines(a *Assets) (lines []line, accent color.Color, bar
return lines, accent, bar return lines, accent, bar
} }
switch lvl.At(tile.X, tile.Y) { switch lvl.At(tile) {
case level.Toilet: case level.Toilet:
if r.Facility.Active { if r.Facility.Active {
return []line{plainLine(a.T("hud.toilet_busy"), colDim)}, colGood, true return []line{plainLine(a.T("hud.toilet_busy"), colDim)}, colGood, true

View File

@@ -47,7 +47,7 @@ func (s *LevelScene) buildTracks() {
} else { } else {
c.Y += k c.Y += k
} }
if s.run.Level.At(c.X, c.Y) == level.Wall && (c.X == 0 || c.Y == 0 || c.X == s.run.Level.Width()-1 || c.Y == s.run.Level.Height()-1) { 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) {
s.rails[c] = true s.rails[c] = true
} }
} }
@@ -74,7 +74,7 @@ func (s *LevelScene) drawRails(g *Game, screen *ebiten.Image, c level.Point, ox,
var op ebiten.DrawImageOptions var op ebiten.DrawImageOptions
vertical := false vertical := false
for _, tr := range s.tracks { for _, tr := range s.tracks {
if !tr.horizontal && (c.X == tr.track.X) { if !tr.horizontal && c.X == tr.track.X && c.Z == tr.track.Z {
vertical = true vertical = true
} }
} }
@@ -89,7 +89,11 @@ func (s *LevelScene) drawRails(g *Game, screen *ebiten.Image, c level.Point, ox,
// drawTrains ritar tågen i rätt läge för sin fas. // drawTrains ritar tågen i rätt läge för sin fas.
func (s *LevelScene) drawTrains(g *Game, screen *ebiten.Image, ox, oy float64) { func (s *LevelScene) drawTrains(g *Game, screen *ebiten.Image, ox, oy float64) {
z := s.run.Player.Tile.Z
for _, tr := range s.tracks { for _, tr := range s.tracks {
if tr.platform.Z != z {
continue // tågen på andra våningar syns inte härifrån
}
phase, prog := s.run.PlatformPhase(tr.tile) phase, prog := s.run.PlatformPhase(tr.tile)
// Strålkastare när tåget är på väg in: ett sken från det håll det kommer. // Strålkastare när tåget är på väg in: ett sken från det håll det kommer.
if phase == level.Approaching { if phase == level.Approaching {
@@ -148,6 +152,10 @@ func (s *LevelScene) updateEffects(g *Game) {
for _, tr := range s.tracks { for _, tr := range s.tracks {
phase, prog := r.PlatformPhase(tr.tile) phase, prog := r.PlatformPhase(tr.tile)
if last, seen := s.lastPhase[tr.tile]; !seen || last != phase { if last, seen := s.lastPhase[tr.tile]; !seen || last != phase {
if tr.platform.Z != r.Player.Tile.Z {
s.lastPhase[tr.tile] = phase
continue // hörs inte från en annan våning
}
s.lastPhase[tr.tile] = phase s.lastPhase[tr.tile] = phase
dist := math.Hypot(float64(tr.platform.X*TileSize)-px, float64(tr.platform.Y*TileSize)-py) / TileSize dist := math.Hypot(float64(tr.platform.X*TileSize)-px, float64(tr.platform.Y*TileSize)-py) / TileSize
switch phase { switch phase {
@@ -159,7 +167,7 @@ func (s *LevelScene) updateEffects(g *Game) {
} }
// Vind längs perrongen när tåget rullar in eller ut (och strax före). // Vind längs perrongen när tåget rullar in eller ut (och strax före).
windy := phase == level.Arriving || phase == level.Leaving || (phase == level.Approaching && prog > 0.7) windy := phase == level.Arriving || phase == level.Leaving || (phase == level.Approaching && prog > 0.7)
if windy && len(s.particles) < maxParticles { if windy && tr.platform.Z == r.Player.Tile.Z && len(s.particles) < maxParticles {
dir := 1.0 dir := 1.0
if phase == level.Arriving { if phase == level.Arriving {
dir = 1.0 // luften trycks framför tåget: samma håll som tåget rör sig (in från minus-sidan) dir = 1.0 // luften trycks framför tåget: samma håll som tåget rör sig (in från minus-sidan)

View File

@@ -31,13 +31,19 @@ const (
Sign Tile = 'i' // skylt Sign Tile = 'i' // skylt
Toilet Tile = 'W' // toalett (sällskapets toamätare) Toilet Tile = 'W' // toalett (sällskapets toamätare)
Kiosk Tile = 'K' // kiosk/automat för mat och dryck Kiosk Tile = 'K' // kiosk/automat för mat och dryck
StairsUp Tile = 'U' // trappa upp: leder till 'D' på samma x,y en våning upp
StairsDown Tile = 'D' // trappa ner: leder till 'U' på samma x,y en våning ner
) )
var knownTiles = map[Tile]bool{ var knownTiles = map[Tile]bool{
Wall: true, Floor: true, Spawn: true, Platform: true, WrongPlatform: true, Wall: true, Floor: true, Spawn: true, Platform: true, WrongPlatform: true,
Gate: true, TicketMachine: true, Sign: true, Toilet: true, Kiosk: true, Gate: true, TicketMachine: true, Sign: true, Toilet: true, Kiosk: true,
StairsUp: true, StairsDown: true,
} }
// IsStairs rapporterar om rutan är en trappa.
func (t Tile) IsStairs() bool { return t == StairsUp || t == StairsDown }
// Passable rapporterar om en ruta går att gå på (utan hänsyn till spärrar). // Passable rapporterar om en ruta går att gå på (utan hänsyn till spärrar).
func (t Tile) Passable() bool { return t != Wall } func (t Tile) Passable() bool { return t != Wall }
@@ -169,9 +175,17 @@ type Train struct {
type SignText struct { type SignText struct {
X int `json:"x"` X int `json:"x"`
Y int `json:"y"` Y int `json:"y"`
Z int `json:"z"` // våning (0 = första i floors)
Text Text `json:"text"` Text Text `json:"text"`
} }
// Storey är en våning: ett rutnät med namn ("B1", "1F", "2F").
type Storey struct {
Name string `json:"name"`
Grid []string `json:"grid"`
Floors []Storey `json:"floors,omitempty"` // flervåningsbana; alla våningar lika stora
}
// Rewards är vad en klarad bana ger utöver poäng. // Rewards är vad en klarad bana ger utöver poäng.
type Rewards struct { type Rewards struct {
Items []string `json:"items"` // item-id:n (t.ex. skins) som delas ut första gången Items []string `json:"items"` // item-id:n (t.ex. skins) som delas ut första gången
@@ -187,24 +201,32 @@ type Level struct {
Rewards Rewards `json:"rewards"` Rewards Rewards `json:"rewards"`
Shop []string `json:"shop"` // items som kiosken säljer på banan (tom = seedat urval) Shop []string `json:"shop"` // items som kiosken säljer på banan (tom = seedat urval)
Name Text `json:"name"` Name Text `json:"name"`
Difficulty int `json:"difficulty"` // 1 (lätt) … 5 (svår) Difficulty int `json:"difficulty"` // 1 (lätt) … 5 (svår)
Destination Text `json:"destination"` // dit spelaren ska Destination Text `json:"destination"` // dit spelaren ska
TimeLimitSec int `json:"timeLimitSec"` // 0 = ingen tidsgräns TimeLimitSec int `json:"timeLimitSec"` // 0 = ingen tidsgräns
ParSec int `json:"parSec"` // "bra tid" för tre stjärnor (0 = räknas ut från kortaste vägen) ParSec int `json:"parSec"` // "bra tid" för tre stjärnor (0 = räknas ut från kortaste vägen)
Party int `json:"party"` // antal i sällskapet inkl. spelaren (0 = 1) Party int `json:"party"` // antal i sällskapet inkl. spelaren (0 = 1)
TicketRequired bool `json:"ticketRequired"` // spärren kräver pappersbiljett (köps i automaten) i stället för kort TicketRequired bool `json:"ticketRequired"` // spärren kräver pappersbiljett (köps i automaten) i stället för kort
RideStops int `json:"rideStops"` // antal stationer på tågresan (0 = DefaultRideStops) RideStops int `json:"rideStops"` // antal stationer på tågresan (0 = DefaultRideStops)
MaxMissedTrains int `json:"maxMissedTrains"` // >0: fler missade tåg än så förlorar banan MaxMissedTrains int `json:"maxMissedTrains"` // >0: fler missade tåg än så förlorar banan
IndividualCards bool `json:"individualCards"` // alla i sällskapet har egna kort (och biljetter) som spärren kräver IndividualCards bool `json:"individualCards"` // alla i sällskapet har egna kort (och biljetter) som spärren kräver
SeatSelection bool `json:"seatSelection"` // biljettköpet fortsätter med sittplatsval efter sällskapets önskemål SeatSelection bool `json:"seatSelection"` // biljettköpet fortsätter med sittplatsval efter sällskapets önskemål
FareYen int `json:"fareYen"` // vad spärren drar från kortet (0 = standard) FareYen int `json:"fareYen"` // vad spärren drar från kortet (0 = standard)
Grid []string `json:"grid"` Grid []string `json:"grid,omitempty"` // envåningsbana (kompatibilitet) – blir Floors[0]
Floors []Storey `json:"floors,omitempty"` // flervåningsbana; alla våningar lika stora
Trains []Train `json:"trains"` Trains []Train `json:"trains"`
Signs []SignText `json:"signs"` Signs []SignText `json:"signs"`
} }
// Point är en rutkoordinat. // Point är en rutkoordinat: x, y och våning z.
type Point struct{ X, Y int } type Point struct{ X, Y, Z int }
// Up/Down ger rutan rakt ovanför/under (via trappa).
func (p Point) Up() Point { return Point{X: p.X, Y: p.Y, Z: p.Z + 1} }
func (p Point) Down() Point { return Point{X: p.X, Y: p.Y, Z: p.Z - 1} }
// dirs är de fyra grannriktningarna på samma våning.
var dirs = [...]Point{{X: 1}, {X: -1}, {Y: 1}, {Y: -1}}
// Parse avkodar och validerar en bana. // Parse avkodar och validerar en bana.
func Parse(data []byte) (*Level, error) { func Parse(data []byte) (*Level, error) {
@@ -218,36 +240,77 @@ func Parse(data []byte) (*Level, error) {
return &l, nil return &l, nil
} }
// Width och Height i rutor. // Width och Height i rutor (alla våningar är lika stora).
func (l *Level) Width() int { func (l *Level) Width() int {
if len(l.Grid) == 0 { if len(l.Floors) == 0 || len(l.Floors[0].Grid) == 0 {
return 0 return 0
} }
return len(l.Grid[0]) return len(l.Floors[0].Grid[0])
}
func (l *Level) Height() int {
if len(l.Floors) == 0 {
return 0
}
return len(l.Floors[0].Grid)
} }
func (l *Level) Height() int { return len(l.Grid) }
// At returnerar rutan på (x, y); utanför kartan räknas som vägg. // NumFloors är antalet våningar.
func (l *Level) At(x, y int) Tile { func (l *Level) NumFloors() int { return len(l.Floors) }
if y < 0 || y >= len(l.Grid) || x < 0 || x >= len(l.Grid[y]) {
// FloorName är våningens namn ("1F" om det saknas).
func (l *Level) FloorName(z int) string {
if z < 0 || z >= len(l.Floors) || l.Floors[z].Name == "" {
return "1F"
}
return l.Floors[z].Name
}
// At returnerar rutan på p; utanför kartan (eller våningarna) räknas som vägg.
func (l *Level) At(p Point) Tile { return l.FloorAt(p.Z, p.X, p.Y) }
// FloorAt returnerar rutan (x, y) på våning z; utanför kartan räknas som vägg.
func (l *Level) FloorAt(z, x, y int) Tile {
if z < 0 || z >= len(l.Floors) {
return Wall return Wall
} }
return Tile(l.Grid[y][x]) g := l.Floors[z].Grid
if y < 0 || y >= len(g) || x < 0 || x >= len(g[y]) {
return Wall
}
return Tile(g[y][x])
} }
// Find returnerar alla rutor av en viss typ. // Find returnerar alla rutor av en viss typ på alla våningar.
func (l *Level) Find(t Tile) []Point { func (l *Level) Find(t Tile) []Point {
var pts []Point var pts []Point
for y, row := range l.Grid { for z, f := range l.Floors {
for x := 0; x < len(row); x++ { for y, row := range f.Grid {
if Tile(row[x]) == t { for x := 0; x < len(row); x++ {
pts = append(pts, Point{x, y}) if Tile(row[x]) == t {
pts = append(pts, Point{X: x, Y: y, Z: z})
}
} }
} }
} }
return pts return pts
} }
// Neighbors är rutorna man kan gå till från p: de fyra grannarna på samma
// våning samt trappans andra ände. Passerbarhet avgörs av anroparen.
func (l *Level) Neighbors(p Point) []Point {
out := make([]Point, 0, 5)
for _, d := range dirs {
out = append(out, Point{X: p.X + d.X, Y: p.Y + d.Y, Z: p.Z})
}
switch l.At(p) {
case StairsUp:
out = append(out, p.Up())
case StairsDown:
out = append(out, p.Down())
}
return out
}
// SpawnPoint returnerar startrutan (valideringen garanterar att den finns). // SpawnPoint returnerar startrutan (valideringen garanterar att den finns).
func (l *Level) SpawnPoint() Point { return l.Find(Spawn)[0] } func (l *Level) SpawnPoint() Point { return l.Find(Spawn)[0] }
@@ -274,25 +337,47 @@ func (l *Level) Validate() error {
if l.Order < 0 { if l.Order < 0 {
return fmt.Errorf("level %s: negativ order", l.ID) return fmt.Errorf("level %s: negativ order", l.ID)
} }
if len(l.Grid) == 0 { if len(l.Floors) == 0 {
return fmt.Errorf("level %s: tomt rutnät", l.ID) if len(l.Grid) == 0 {
} return fmt.Errorf("level %s: tomt rutnät", l.ID)
w := len(l.Grid[0])
spawns, platforms := 0, 0
for y, row := range l.Grid {
if len(row) != w {
return fmt.Errorf("level %s: rad %d har längd %d, förväntade %d", l.ID, y, len(row), w)
} }
for x := 0; x < len(row); x++ { l.Floors = []Storey{{Name: "1F", Grid: l.Grid}}
t := Tile(row[x]) } else if len(l.Grid) > 0 {
if !knownTiles[t] { return fmt.Errorf("level %s: ange antingen grid eller floors, inte båda", l.ID)
return fmt.Errorf("level %s: okänt tecken %q på (%d,%d)", l.ID, row[x], x, y) }
l.Grid = l.Floors[0].Grid // kompatibilitet: Grid = första våningen
if len(l.Floors[0].Grid) == 0 {
return fmt.Errorf("level %s: tom första våning", l.ID)
}
w, h := len(l.Floors[0].Grid[0]), len(l.Floors[0].Grid)
spawns, platforms := 0, 0
for z, f := range l.Floors {
if len(f.Grid) != h {
return fmt.Errorf("level %s: våning %d har %d rader, förväntade %d", l.ID, z, len(f.Grid), h)
}
for y, row := range f.Grid {
if len(row) != w {
return fmt.Errorf("level %s: våning %d rad %d har längd %d, förväntade %d", l.ID, z, y, len(row), w)
} }
switch t { for x := 0; x < len(row); x++ {
case Spawn: t := Tile(row[x])
spawns++ if !knownTiles[t] {
case Platform, WrongPlatform: return fmt.Errorf("level %s: okänt tecken %q på (%d,%d,%d)", l.ID, row[x], x, y, z)
platforms++ }
switch t {
case Spawn:
spawns++
case Platform, WrongPlatform:
platforms++
case StairsUp:
if l.FloorAt(z+1, x, y) != StairsDown {
return fmt.Errorf("level %s: trappa upp på (%d,%d,%d) saknar trappa ner ovanför", l.ID, x, y, z)
}
case StairsDown:
if l.FloorAt(z-1, x, y) != StairsUp {
return fmt.Errorf("level %s: trappa ner på (%d,%d,%d) saknar trappa upp under", l.ID, x, y, z)
}
}
} }
} }
} }
@@ -311,7 +396,7 @@ func (l *Level) Validate() error {
if len(tr.Platform) != 1 || (Tile(tr.Platform[0]) != Platform && Tile(tr.Platform[0]) != WrongPlatform) { if len(tr.Platform) != 1 || (Tile(tr.Platform[0]) != Platform && Tile(tr.Platform[0]) != WrongPlatform) {
return fmt.Errorf("level %s: tåg %d har ogiltig perrong %q", l.ID, i, tr.Platform) return fmt.Errorf("level %s: tåg %d har ogiltig perrong %q", l.ID, i, tr.Platform)
} }
if !strings.Contains(strings.Join(l.Grid, ""), tr.Platform) { if len(l.Find(Tile(tr.Platform[0]))) == 0 {
return fmt.Errorf("level %s: tåg %d står vid perrong %q som inte finns i rutnätet", l.ID, i, tr.Platform) return fmt.Errorf("level %s: tåg %d står vid perrong %q som inte finns i rutnätet", l.ID, i, tr.Platform)
} }
if tr.Correct { if tr.Correct {
@@ -364,9 +449,9 @@ func (l *Level) Validate() error {
// Skyltar: varje post måste stå på en 'i'-ruta och varje 'i'-ruta ha text. // Skyltar: varje post måste stå på en 'i'-ruta och varje 'i'-ruta ha text.
seen := map[Point]bool{} seen := map[Point]bool{}
for i, sg := range l.Signs { for i, sg := range l.Signs {
p := Point{sg.X, sg.Y} p := Point{X: sg.X, Y: sg.Y, Z: sg.Z}
if l.At(p.X, p.Y) != Sign { if l.At(p) != Sign {
return fmt.Errorf("level %s: skylt %d på (%d,%d) står inte på en skyltruta", l.ID, i, p.X, p.Y) return fmt.Errorf("level %s: skylt %d på (%d,%d,%d) står inte på en skyltruta", l.ID, i, p.X, p.Y, p.Z)
} }
if seen[p] { if seen[p] {
return fmt.Errorf("level %s: två skyltar på (%d,%d)", l.ID, p.X, p.Y) return fmt.Errorf("level %s: två skyltar på (%d,%d)", l.ID, p.X, p.Y)
@@ -378,7 +463,7 @@ func (l *Level) Validate() error {
} }
for _, p := range l.Find(Sign) { for _, p := range l.Find(Sign) {
if !seen[p] { if !seen[p] {
return fmt.Errorf("level %s: skyltrutan (%d,%d) saknar text i \"signs\"", l.ID, p.X, p.Y) return fmt.Errorf("level %s: skyltrutan (%d,%d,%d) saknar text i \"signs\"", l.ID, p.X, p.Y, p.Z)
} }
} }
return nil return nil
@@ -387,7 +472,7 @@ func (l *Level) Validate() error {
// SignAt returnerar skylten på en ruta, eller nil. // SignAt returnerar skylten på en ruta, eller nil.
func (l *Level) SignAt(p Point) *SignText { func (l *Level) SignAt(p Point) *SignText {
for i := range l.Signs { for i := range l.Signs {
if l.Signs[i].X == p.X && l.Signs[i].Y == p.Y { if l.Signs[i].X == p.X && l.Signs[i].Y == p.Y && l.Signs[i].Z == p.Z {
return &l.Signs[i] return &l.Signs[i]
} }
} }
@@ -413,8 +498,7 @@ func (l *Level) Path(from, to Point, canEnter func(Point) bool) []Point {
} }
return path return path
} }
for _, d := range [...]Point{{1, 0}, {-1, 0}, {0, 1}, {0, -1}} { for _, n := range l.Neighbors(p) {
n := Point{p.X + d.X, p.Y + d.Y}
if _, ok := prev[n]; ok || !canEnter(n) { if _, ok := prev[n]; ok || !canEnter(n) {
continue continue
} }
@@ -434,9 +518,8 @@ func (l *Level) Reachable() map[Point]bool {
for len(queue) > 0 { for len(queue) > 0 {
p := queue[0] p := queue[0]
queue = queue[1:] queue = queue[1:]
for _, d := range [...]Point{{1, 0}, {-1, 0}, {0, 1}, {0, -1}} { for _, n := range l.Neighbors(p) {
n := Point{p.X + d.X, p.Y + d.Y} if seen[n] || !l.At(n).Passable() {
if seen[n] || !l.At(n.X, n.Y).Passable() {
continue continue
} }
seen[n] = true seen[n] = true
@@ -472,9 +555,8 @@ func (l *Level) UngatedPlatforms() []Point {
for len(queue) > 0 { for len(queue) > 0 {
p := queue[0] p := queue[0]
queue = queue[1:] queue = queue[1:]
for _, d := range [...]Point{{1, 0}, {-1, 0}, {0, 1}, {0, -1}} { for _, n := range l.Neighbors(p) {
n := Point{p.X + d.X, p.Y + d.Y} t := l.At(n)
t := l.At(n.X, n.Y)
if seen[n] || !t.Passable() || t == Gate { if seen[n] || !t.Passable() || t == Gate {
continue continue
} }
@@ -518,8 +600,8 @@ func (tr *Train) BoardText() string {
// anger om rälsen (och tågets rörelse) går i sidled. // anger om rälsen (och tågets rörelse) går i sidled.
func (l *Level) TrackTile(p Point) (tile Point, horizontal bool) { func (l *Level) TrackTile(p Point) (tile Point, horizontal bool) {
for _, d := range [...]Point{{X: 0, Y: 1}, {X: 1, Y: 0}, {X: -1, Y: 0}, {X: 0, Y: -1}} { for _, d := range [...]Point{{X: 0, Y: 1}, {X: 1, Y: 0}, {X: -1, Y: 0}, {X: 0, Y: -1}} {
n := Point{X: p.X + d.X, Y: p.Y + d.Y} n := Point{X: p.X + d.X, Y: p.Y + d.Y, Z: p.Z}
if l.At(n.X, n.Y) == Wall { if l.At(n) == Wall {
return n, d.Y != 0 return n, d.Y != 0
} }
} }
@@ -540,7 +622,7 @@ func (l *Level) CorrectPlatform() Point {
} }
// CanEnter rapporterar om rutan går att gå in på (för sim.Mover). // CanEnter rapporterar om rutan går att gå in på (för sim.Mover).
func (l *Level) CanEnter(p Point) bool { return l.At(p.X, p.Y).Passable() } func (l *Level) CanEnter(p Point) bool { return l.At(p).Passable() }
// TrainAt returnerar tåget vid en perrongruta, eller nil. // TrainAt returnerar tåget vid en perrongruta, eller nil.
func (l *Level) TrainAt(t Tile) *Train { func (l *Level) TrainAt(t Tile) *Train {

View File

@@ -40,7 +40,7 @@ func TestParseAndSolve(t *testing.T) {
if l.Width() != 7 || l.Height() != 5 { if l.Width() != 7 || l.Height() != 5 {
t.Fatalf("storlek %dx%d", l.Width(), l.Height()) t.Fatalf("storlek %dx%d", l.Width(), l.Height())
} }
if l.SpawnPoint() != (Point{1, 1}) { if l.SpawnPoint() != (Point{X: 1, Y: 1}) {
t.Fatalf("spawn %v", l.SpawnPoint()) t.Fatalf("spawn %v", l.SpawnPoint())
} }
if !l.Solvable() { if !l.Solvable() {
@@ -56,14 +56,14 @@ func TestUnsolvableWhenWalledOff(t *testing.T) {
if l.Solvable() { if l.Solvable() {
t.Fatal("banan ska INTE vara lösbar när perrongen är inmurad") t.Fatal("banan ska INTE vara lösbar när perrongen är inmurad")
} }
if u := l.Unreachable(); len(u) != 1 || u[0] != (Point{5, 1}) { if u := l.Unreachable(); len(u) != 1 || u[0] != (Point{X: 5, Y: 1}) {
t.Fatalf("Unreachable = %v, vill ha [(5,1)]", u) t.Fatalf("Unreachable = %v, vill ha [(5,1)]", u)
} }
} }
func TestOutsideGridIsWall(t *testing.T) { func TestOutsideGridIsWall(t *testing.T) {
l := mustParse(t, sample) l := mustParse(t, sample)
if l.At(-1, 0).Passable() || l.At(0, 99).Passable() { if l.FloorAt(0, -1, 0).Passable() || l.FloorAt(0, 0, 99).Passable() || l.At(Point{Z: 5}).Passable() {
t.Fatal("utanför kartan ska räknas som vägg") t.Fatal("utanför kartan ska räknas som vägg")
} }
} }
@@ -227,19 +227,19 @@ func TestSignAt(t *testing.T) {
src := strings.Replace(strings.Replace(sample, `"#S..#P#"`, `"#Si.#P#"`, 1), src := strings.Replace(strings.Replace(sample, `"#S..#P#"`, `"#Si.#P#"`, 1),
`"trains"`, `"signs": [{"x": 2, "y": 1, "text": {"sv": "Hej"}}], "trains"`, 1) `"trains"`, `"signs": [{"x": 2, "y": 1, "text": {"sv": "Hej"}}], "trains"`, 1)
l := mustParse(t, src) l := mustParse(t, src)
if sg := l.SignAt(Point{2, 1}); sg == nil || sg.Text.Get("sv") != "Hej" { if sg := l.SignAt(Point{X: 2, Y: 1}); sg == nil || sg.Text.Get("sv") != "Hej" {
t.Fatalf("SignAt = %v", sg) t.Fatalf("SignAt = %v", sg)
} }
if l.SignAt(Point{1, 1}) != nil { if l.SignAt(Point{X: 1, Y: 1}) != nil {
t.Fatal("ingen skylt på startrutan") t.Fatal("ingen skylt på startrutan")
} }
} }
func TestPath(t *testing.T) { func TestPath(t *testing.T) {
l := mustParse(t, sample) l := mustParse(t, sample)
path := l.Path(l.SpawnPoint(), Point{5, 1}, l.CanEnter) path := l.Path(l.SpawnPoint(), Point{X: 5, Y: 1}, l.CanEnter)
// S(1,1) -> (1,2) -> (1,3) -> (2,3) -> (3,3) -> (4,3) -> (5,3) -> (5,2) -> (5,1) // S(1,1) -> (1,2) -> (1,3) -> (2,3) -> (3,3) -> (4,3) -> (5,3) -> (5,2) -> (5,1)
if len(path) != 9 || path[0] != (Point{1, 1}) || path[len(path)-1] != (Point{5, 1}) { if len(path) != 9 || path[0] != (Point{X: 1, Y: 1}) || path[len(path)-1] != (Point{X: 5, Y: 1}) {
t.Fatalf("path = %v", path) t.Fatalf("path = %v", path)
} }
for i := 1; i < len(path); i++ { for i := 1; i < len(path); i++ {
@@ -248,7 +248,7 @@ func TestPath(t *testing.T) {
t.Fatalf("steg %d är inte en grannruta: %v -> %v", i, path[i-1], path[i]) t.Fatalf("steg %d är inte en grannruta: %v -> %v", i, path[i-1], path[i])
} }
} }
if l.Path(l.SpawnPoint(), Point{0, 0}, l.CanEnter) != nil { if l.Path(l.SpawnPoint(), Point{X: 0, Y: 0}, l.CanEnter) != nil {
t.Fatal("väg in i en vägg ska vara nil") t.Fatal("väg in i en vägg ska vara nil")
} }
if p := l.Path(l.SpawnPoint(), l.SpawnPoint(), l.CanEnter); len(p) != 1 { if p := l.Path(l.SpawnPoint(), l.SpawnPoint(), l.CanEnter); len(p) != 1 {
@@ -259,7 +259,7 @@ func TestPath(t *testing.T) {
func TestUngatedPlatforms(t *testing.T) { func TestUngatedPlatforms(t *testing.T) {
// Perrong utan spärr framför -> rapporteras. // Perrong utan spärr framför -> rapporteras.
l := mustParse(t, sample) l := mustParse(t, sample)
if u := l.UngatedPlatforms(); len(u) != 1 || u[0] != (Point{5, 1}) { if u := l.UngatedPlatforms(); len(u) != 1 || u[0] != (Point{X: 5, Y: 1}) {
t.Fatalf("UngatedPlatforms = %v, vill ha [(5,1)]", u) t.Fatalf("UngatedPlatforms = %v, vill ha [(5,1)]", u)
} }
// Spärr i enda passagen -> inget rapporteras, men banan är fortfarande lösbar. // Spärr i enda passagen -> inget rapporteras, men banan är fortfarande lösbar.
@@ -280,14 +280,14 @@ func TestPartyAndParDefaults(t *testing.T) {
if _, err := Parse([]byte(strings.Replace(sample, `"difficulty": 1`, `"difficulty": 1, "party": 9`, 1))); err == nil { if _, err := Parse([]byte(strings.Replace(sample, `"difficulty": 1`, `"difficulty": 1, "party": 9`, 1))); err == nil {
t.Fatal("för stort sällskap ska ge fel") t.Fatal("för stort sällskap ska ge fel")
} }
if l.CorrectPlatform() != (Point{5, 1}) { if l.CorrectPlatform() != (Point{X: 5, Y: 1}) {
t.Fatalf("CorrectPlatform = %v", l.CorrectPlatform()) t.Fatalf("CorrectPlatform = %v", l.CorrectPlatform())
} }
if got := l.Trains[0].BoardText(); got != "番線 A B方面" { if got := l.Trains[0].BoardText(); got != "番線 A B方面" {
t.Fatalf("BoardText = %q", got) t.Fatalf("BoardText = %q", got)
} }
l2 := mustParse(t, strings.Replace(sample, `"#S..#P#"`, `"#SW.KP#"`, 1)) l2 := mustParse(t, strings.Replace(sample, `"#S..#P#"`, `"#SW.KP#"`, 1))
if l2.At(2, 1) != Toilet || l2.At(4, 1) != Kiosk || !l2.Solvable() { if l2.FloorAt(0, 2, 1) != Toilet || l2.FloorAt(0, 4, 1) != Kiosk || !l2.Solvable() {
t.Fatal("W och K ska vara passerbara rutor") t.Fatal("W och K ska vara passerbara rutor")
} }
} }
@@ -378,12 +378,12 @@ func TestScheduleValidationForPhases(t *testing.T) {
func TestTrackTile(t *testing.T) { func TestTrackTile(t *testing.T) {
l := mustParse(t, sample) l := mustParse(t, sample)
tile, horizontal := l.TrackTile(Point{5, 1}) // P uppe till höger: vägg ovanför? nej, (5,0) är vägg -> men ordningen är ner, höger, vänster, upp tile, horizontal := l.TrackTile(Point{X: 5, Y: 1}) // ordningen är ner, höger, vänster, upp
if tile != (Point{5, 2}) && tile != (Point{6, 1}) && tile != (Point{5, 0}) { if tile != (Point{X: 5, Y: 2}) && tile != (Point{X: 6, Y: 1}) && tile != (Point{X: 5, Y: 0}) {
t.Fatalf("TrackTile = %v", tile) t.Fatalf("TrackTile = %v", tile)
} }
// (5,2) är golv, (6,1) är vägg -> höger: vertikal räls // (5,2) är golv, (6,1) är vägg -> höger: vertikal räls
if tile == (Point{6, 1}) && horizontal { if tile == (Point{X: 6, Y: 1}) && horizontal {
t.Fatal("räls till höger om perrongen är vertikal") t.Fatal("räls till höger om perrongen är vertikal")
} }
} }
@@ -430,3 +430,88 @@ func TestKindOrderParentAndLoadAll(t *testing.T) {
t.Fatal("det ska finnas minst en sidobana") t.Fatal("det ska finnas minst en sidobana")
} }
} }
const floorsSample = `{
"id": "two-floors", "name": {"sv": "Två våningar"}, "difficulty": 2, "destination": {"sv": "x"},
"floors": [
{"name": "B1", "grid": [
"#######",
"#S..U.#",
"#.###.#",
"#.....#",
"#######"
]},
{"name": "1F", "grid": [
"#######",
"#...D.#",
"#.#####",
"#G..i.#",
"#P#####"
]}
],
"signs": [{"x": 4, "y": 3, "z": 1, "text": {"sv": "Upp", "en": "Up"}}],
"trains": [{"platform": "P", "track": "1", "line": {"en": "A"}, "destination": {"en": "B"}, "correct": true}]
}`
func TestFloorsStairsAndPaths(t *testing.T) {
l := mustParse(t, floorsSample)
if l.NumFloors() != 2 || l.FloorName(0) != "B1" || l.FloorName(1) != "1F" || l.FloorName(9) != "1F" {
t.Fatalf("våningar: %d %s %s", l.NumFloors(), l.FloorName(0), l.FloorName(1))
}
if l.Width() != 7 || l.Height() != 5 || len(l.Grid) != 5 {
t.Fatal("storlek/kompatibilitet")
}
if l.SpawnPoint() != (Point{X: 1, Y: 1, Z: 0}) || l.CorrectPlatform() != (Point{X: 1, Y: 4, Z: 1}) {
t.Fatalf("spawn=%v platform=%v", l.SpawnPoint(), l.CorrectPlatform())
}
if l.At(Point{X: 4, Y: 1}) != StairsUp || l.At(Point{X: 4, Y: 1, Z: 1}) != StairsDown || !StairsUp.IsStairs() || !StairsUp.Passable() {
t.Fatal("trappor")
}
up := Point{X: 4, Y: 1}
n := l.Neighbors(up)
if len(n) != 5 || n[4] != up.Up() {
t.Fatalf("grannar vid trappa: %v", n)
}
if !l.Solvable() {
t.Fatal("banan ska vara lösbar via trappan")
}
path := l.Path(l.SpawnPoint(), l.CorrectPlatform(), l.CanEnter)
if path == nil || path[0].Z != 0 || path[len(path)-1].Z != 1 {
t.Fatalf("vägen ska byta våning: %v", path)
}
climbed := false
for i := 1; i < len(path); i++ {
if path[i].Z != path[i-1].Z {
climbed = true
if path[i].X != path[i-1].X || path[i].Y != path[i-1].Y || !l.At(path[i-1]).IsStairs() {
t.Fatalf("våningsbyte utan trappa: %v -> %v", path[i-1], path[i])
}
}
}
if !climbed {
t.Fatal("vägen använde ingen trappa")
}
if u := l.UngatedPlatforms(); len(u) != 0 {
t.Fatalf("perrongen ligger innanför spärren: %v", u)
}
if sg := l.SignAt(Point{X: 4, Y: 3, Z: 1}); sg == nil || l.SignAt(Point{X: 4, Y: 3}) != nil {
t.Fatal("skylten hör till våning 1")
}
if u := l.Unreachable(); len(u) != 0 {
t.Fatalf("onåbart: %v", u)
}
}
func TestFloorsValidation(t *testing.T) {
bad := map[string]string{
"trappa utan motpart": strings.Replace(floorsSample, `"#...D.#"`, `"#.....#"`, 1),
"olika storlek": strings.Replace(floorsSample, `"#P#####"`, `"#P######"`, 1),
"både grid och floors": strings.Replace(floorsSample, `"floors"`, `"grid": ["###","#S#","###"], "floors"`, 1),
"skylt på fel våning": strings.Replace(floorsSample, `"z": 1`, `"z": 0`, 1),
}
for name, src := range bad {
if _, err := Parse([]byte(src)); err == nil {
t.Errorf("%s: förväntade fel", name)
}
}
}

View File

@@ -9,23 +9,24 @@ const FogRadius = 3
// Fog håller reda på vilka rutor spelaren har sett i den här omgången. // Fog håller reda på vilka rutor spelaren har sett i den här omgången.
// Minikartan ritar bara sedda rutor plus riktmärken som alltid syns. // Minikartan ritar bara sedda rutor plus riktmärken som alltid syns.
type Fog struct { type Fog struct {
w, h int w, h, floors int
seen []bool seen []bool
Radius int Radius int
count int count int
ExtraLandmarks bool // toaletter, kiosker och automater syns alltid (skillen Lokalkännedom) ExtraLandmarks bool // toaletter, kiosker och automater syns alltid (skillen Lokalkännedom)
} }
// NewFog skapar en tom dimma för banan. // NewFog skapar en tom dimma för banan (alla våningar).
func NewFog(l *level.Level, radius int) *Fog { func NewFog(l *level.Level, radius int) *Fog {
return &Fog{w: l.Width(), h: l.Height(), seen: make([]bool, l.Width()*l.Height()), Radius: radius} n := max(1, l.NumFloors())
return &Fog{w: l.Width(), h: l.Height(), floors: n, seen: make([]bool, l.Width()*l.Height()*n), Radius: radius}
} }
func (f *Fog) idx(p level.Point) (int, bool) { func (f *Fog) idx(p level.Point) (int, bool) {
if p.X < 0 || p.Y < 0 || p.X >= f.w || p.Y >= f.h { if p.X < 0 || p.Y < 0 || p.Z < 0 || p.X >= f.w || p.Y >= f.h || p.Z >= f.floors {
return 0, false return 0, false
} }
return p.Y*f.w + p.X, true return (p.Z*f.h+p.Y)*f.w + p.X, true
} }
// Seen rapporterar om rutan har setts. // Seen rapporterar om rutan har setts.
@@ -41,8 +42,9 @@ func (f *Fog) mark(p level.Point) {
} }
} }
// Reveal avslöjar allt inom Radius steg från from via passerbara rutor, // Reveal avslöjar allt inom Radius steg från from via passerbara rutor på
// samt väggarna som gränsar till dem (så att gångarnas form syns). // samma våning, samt väggarna som gränsar till dem (så att gångarnas form
// syns). Trappans andra ände avslöjas också.
func (f *Fog) Reveal(l *level.Level, from level.Point) { func (f *Fog) Reveal(l *level.Level, from level.Point) {
dist := map[level.Point]int{from: 0} dist := map[level.Point]int{from: 0}
queue := []level.Point{from} queue := []level.Point{from}
@@ -50,10 +52,16 @@ func (f *Fog) Reveal(l *level.Level, from level.Point) {
p := queue[0] p := queue[0]
queue = queue[1:] queue = queue[1:]
f.mark(p) f.mark(p)
switch l.At(p) {
case level.StairsUp:
f.mark(p.Up())
case level.StairsDown:
f.mark(p.Down())
}
for dx := -1; dx <= 1; dx++ { for dx := -1; dx <= 1; dx++ {
for dy := -1; dy <= 1; dy++ { for dy := -1; dy <= 1; dy++ {
n := level.Point{X: p.X + dx, Y: p.Y + dy} n := level.Point{X: p.X + dx, Y: p.Y + dy, Z: p.Z}
if !l.At(n.X, n.Y).Passable() { if !l.At(n).Passable() {
f.mark(n) // gränsande vägg f.mark(n) // gränsande vägg
} }
} }
@@ -62,8 +70,8 @@ func (f *Fog) Reveal(l *level.Level, from level.Point) {
continue continue
} }
for _, d := range [...]level.Point{{X: 1}, {X: -1}, {Y: 1}, {Y: -1}} { for _, d := range [...]level.Point{{X: 1}, {X: -1}, {Y: 1}, {Y: -1}} {
n := level.Point{X: p.X + d.X, Y: p.Y + d.Y} n := level.Point{X: p.X + d.X, Y: p.Y + d.Y, Z: p.Z}
if _, ok := dist[n]; ok || !l.At(n.X, n.Y).Passable() { if _, ok := dist[n]; ok || !l.At(n).Passable() {
continue continue
} }
dist[n] = dist[p] + 1 dist[n] = dist[p] + 1
@@ -75,8 +83,8 @@ func (f *Fog) Reveal(l *level.Level, from level.Point) {
// Landmark är rutor som alltid syns på minikartan: perrongerna (avgångarna), // Landmark är rutor som alltid syns på minikartan: perrongerna (avgångarna),
// starten och spärrarna – det man orienterar sig efter. // starten och spärrarna – det man orienterar sig efter.
func Landmark(l *level.Level, p level.Point) bool { func Landmark(l *level.Level, p level.Point) bool {
switch l.At(p.X, p.Y) { switch l.At(p) {
case level.Platform, level.WrongPlatform, level.Spawn, level.Gate: case level.Platform, level.WrongPlatform, level.Spawn, level.Gate, level.StairsUp, level.StairsDown:
return true return true
} }
return false return false
@@ -88,7 +96,7 @@ func (f *Fog) Visible(l *level.Level, p level.Point) bool {
return true return true
} }
if f.ExtraLandmarks { if f.ExtraLandmarks {
switch l.At(p.X, p.Y) { switch l.At(p) {
case level.Toilet, level.Kiosk, level.TicketMachine: case level.Toilet, level.Kiosk, level.TicketMachine:
return true return true
} }
@@ -98,9 +106,11 @@ func (f *Fog) Visible(l *level.Level, p level.Point) bool {
// RevealAll avslöjar hela banan (kartan köpt som item). // RevealAll avslöjar hela banan (kartan köpt som item).
func (f *Fog) RevealAll(l *level.Level) { func (f *Fog) RevealAll(l *level.Level) {
for y := 0; y < l.Height(); y++ { for z := 0; z < l.NumFloors(); z++ {
for x := 0; x < l.Width(); x++ { for y := 0; y < l.Height(); y++ {
f.mark(level.Point{X: x, Y: y}) for x := 0; x < l.Width(); x++ {
f.mark(level.Point{X: x, Y: y, Z: z})
}
} }
} }
} }
@@ -111,14 +121,17 @@ func (f *Fog) Count() int { return f.count }
// Explored är andelen passerbara rutor som setts, 0..1. // Explored är andelen passerbara rutor som setts, 0..1.
func (f *Fog) Explored(l *level.Level) float64 { func (f *Fog) Explored(l *level.Level) float64 {
total, seen := 0, 0 total, seen := 0, 0
for y := 0; y < l.Height(); y++ { for z := 0; z < l.NumFloors(); z++ {
for x := 0; x < l.Width(); x++ { for y := 0; y < l.Height(); y++ {
if !l.At(x, y).Passable() { for x := 0; x < l.Width(); x++ {
continue p := level.Point{X: x, Y: y, Z: z}
} if !l.At(p).Passable() {
total++ continue
if f.Seen(level.Point{X: x, Y: y}) { }
seen++ total++
if f.Seen(p) {
seen++
}
} }
} }
} }

View File

@@ -45,7 +45,7 @@ func (m *Mover) Step(dt float64, want Dir, canEnter func(level.Point) bool) int
} }
m.Facing = want m.Facing = want
dx, dy := want.Delta() dx, dy := want.Delta()
next := level.Point{X: m.Tile.X + dx, Y: m.Tile.Y + dy} next := level.Point{X: m.Tile.X + dx, Y: m.Tile.Y + dy, Z: m.Tile.Z}
if !canEnter(next) { if !canEnter(next) {
return arrived return arrived
} }

View File

@@ -47,6 +47,8 @@ type Run struct {
Progressed []string // ord som fick framsteg vid senaste läsningen, "ord n/3" Progressed []string // ord som fick framsteg vid senaste läsningen, "ord n/3"
ProgressedAt float64 // < 0 = aldrig ProgressedAt float64 // < 0 = aldrig
FloorChanges int // antal trappbyten (statistik)
// Resultat när banan vunnits. // Resultat när banan vunnits.
Stars int // 1–3 efter tid mot par Stars int // 1–3 efter tid mot par
PointsEarned int // språkpoäng PointsEarned int // språkpoäng
@@ -266,7 +268,7 @@ func (r *Run) StarsFor(t float64) int {
// kräver biljett (på biljettbanor) eller ett användbart kort med saldo (att // kräver biljett (på biljettbanor) eller ett användbart kort med saldo (att
// gå mellan två spärrutor är gratis). // gå mellan två spärrutor är gratis).
func (r *Run) CanEnter(p level.Point) bool { func (r *Run) CanEnter(p level.Point) bool {
t := r.Level.At(p.X, p.Y) t := r.Level.At(p)
if !t.Passable() { if !t.Passable() {
return false return false
} }
@@ -315,7 +317,7 @@ func (r *Run) MembersWithoutFare() []*Member {
} }
func (r *Run) inGate() bool { func (r *Run) inGate() bool {
return r.Level.At(r.Player.Tile.X, r.Player.Tile.Y) == level.Gate return r.Level.At(r.Player.Tile) == level.Gate
} }
// Step för banan framåt dt sekunder. want är hållen riktning, board=true // Step för banan framåt dt sekunder. want är hållen riktning, board=true
@@ -342,6 +344,18 @@ func (r *Run) Step(dt float64, want Dir, board bool) {
} }
r.Steps += r.Player.Step(dt, want, r.CanEnter) r.Steps += r.Player.Step(dt, want, r.CanEnter)
if r.Player.Tile != before { if r.Player.Tile != before {
// Trappa: hamnar man på en trappruta flyttas man till andra änden
// (samma x, y, en våning upp eller ner) och fortsätter därifrån.
switch r.Level.At(r.Player.Tile) {
case level.StairsUp:
r.Player.Tile = r.Player.Tile.Up()
r.Player.Target = r.Player.Tile
r.FloorChanges++
case level.StairsDown:
r.Player.Tile = r.Player.Tile.Down()
r.Player.Target = r.Player.Tile
r.FloorChanges++
}
r.Party.OnLeaderArrived(r.Player.Tile) r.Party.OnLeaderArrived(r.Player.Tile)
r.Fog.Reveal(r.Level, r.Player.Tile) r.Fog.Reveal(r.Level, r.Player.Tile)
} }
@@ -372,7 +386,7 @@ func (r *Run) Step(dt float64, want Dir, board bool) {
} }
} }
if board && !r.Player.Moving() { if board && !r.Player.Moving() {
switch r.Level.At(r.Player.Tile.X, r.Player.Tile.Y) { switch r.Level.At(r.Player.Tile) {
case level.Toilet: case level.Toilet:
r.useToilet() r.useToilet()
case level.Kiosk: case level.Kiosk:
@@ -652,7 +666,7 @@ func (r *Run) FacilityProgress() float64 {
// spelaren inte står på en perrong), om det är inne, och sekunder kvar // spelaren inte står på en perrong), om det är inne, och sekunder kvar
// till avgång respektive ankomst. // till avgång respektive ankomst.
func (r *Run) PlatformHere() (tr *level.Train, atPlatform bool, seconds float64) { func (r *Run) PlatformHere() (tr *level.Train, atPlatform bool, seconds float64) {
t := r.Level.At(r.Player.Tile.X, r.Player.Tile.Y) t := r.Level.At(r.Player.Tile)
if t != level.Platform && t != level.WrongPlatform { if t != level.Platform && t != level.WrongPlatform {
return nil, false, 0 return nil, false, 0
} }
@@ -679,6 +693,9 @@ func (r *Run) NearTrack(maxDist int) (tr *level.Train, platform level.Point, pha
best := maxDist + 1 best := maxDist + 1
for _, t := range []level.Tile{level.Platform, level.WrongPlatform} { for _, t := range []level.Tile{level.Platform, level.WrongPlatform} {
for _, p := range r.Level.Find(t) { for _, p := range r.Level.Find(t) {
if p.Z != r.Player.Tile.Z {
continue // man ser och hör bara tågen på sin egen våning
}
d := max(abs(p.X-r.Player.Tile.X), abs(p.Y-r.Player.Tile.Y)) d := max(abs(p.X-r.Player.Tile.X), abs(p.Y-r.Player.Tile.Y))
if d < best { if d < best {
if cand := r.Level.TrainAt(t); cand != nil { if cand := r.Level.TrainAt(t); cand != nil {

View File

@@ -85,21 +85,29 @@ func dirTo(from, to level.Point) Dir {
// walk låter en bot följa en väg ruta för ruta, en bildruta i taget. Tangenten // walk låter en bot följa en väg ruta för ruta, en bildruta i taget. Tangenten
// släpps så fort steget mot nästa ruta påbörjats, så att boten stannar exakt // släpps så fort steget mot nästa ruta påbörjats, så att boten stannar exakt
// där även när farten inte är bildruteexakt (telefon- eller sällskapsfaktor). // där även när farten inte är bildruteexakt (telefon- eller sällskapsfaktor).
// Trappor: att kliva på en trappruta flyttar en till andra änden i samma
// steg, så vägens nästa punkt (motparten) är redan nådd.
func walk(t *testing.T, r *Run, path []level.Point) { func walk(t *testing.T, r *Run, path []level.Point) {
t.Helper() t.Helper()
for _, next := range path[1:] { for idx := 1; idx < len(path); idx++ {
d := dirTo(r.Player.Tile, next) next := path[idx]
for i := 0; i < 600; i++ { cur := r.Player.Tile
if r.Player.Tile == next && !r.Player.Moving() { if next.Z != cur.Z && next.X == cur.X && next.Y == cur.Y {
break continue // våningsbytet skedde när vi klev på trappan
} }
if r.Player.Target == next || r.Player.Tile == next { d := dirTo(cur, next)
arrived := func() bool {
p := r.Player.Tile
return !r.Player.Moving() && (p == next || (r.Level.At(next).IsStairs() && p.X == next.X && p.Y == next.Y))
}
for i := 0; i < 600 && !arrived(); i++ {
if r.Player.Target == next || r.Player.Tile == next || (r.Player.Tile.X == next.X && r.Player.Tile.Y == next.Y) {
r.Step(frame, None, false) r.Step(frame, None, false)
} else { } else {
r.Step(frame, d, false) r.Step(frame, d, false)
} }
} }
if r.Player.Tile != next || r.Player.Moving() { if !arrived() {
t.Fatalf("boten fastnade på %v på väg mot %v", r.Player.Tile, next) t.Fatalf("boten fastnade på %v på väg mot %v", r.Player.Tile, next)
} }
} }
@@ -203,7 +211,7 @@ func TestGateNeedsCardBalance(t *testing.T) {
r.Player.Tile, r.Player.Target = gate, gate r.Player.Tile, r.Player.Target = gate, gate
r.Wallet.Card = 0 r.Wallet.Card = 0
other := level.Point{X: gate.X + 1, Y: gate.Y} other := level.Point{X: gate.X + 1, Y: gate.Y}
if l.At(other.X, other.Y) == level.Gate && !r.CanEnter(other) { if l.At(other) == level.Gate && !r.CanEnter(other) {
t.Fatal("att gå mellan två spärrutor ska inte kosta") t.Fatal("att gå mellan två spärrutor ska inte kosta")
} }
} }
@@ -635,7 +643,7 @@ func TestPhoneSpeedRescanAndBattery(t *testing.T) {
gate := l.Find(level.Gate)[0] gate := l.Find(level.Gate)[0]
path := l.Path(r.Player.Tile, gate, r.CanEnter) path := l.Path(r.Player.Tile, gate, r.CanEnter)
for i, p := range path { // stanna på den första spärrutan – där sker skanningen for i, p := range path { // stanna på den första spärrutan – där sker skanningen
if l.At(p.X, p.Y) == level.Gate { if l.At(p) == level.Gate {
path = path[:i+1] path = path[:i+1]
break break
} }
@@ -810,7 +818,7 @@ func TestIndividualCardsAtGateAndMachine(t *testing.T) {
before := r.Wallet.Spent before := r.Wallet.Spent
path := l1.Path(r.Player.Tile, gate, r.CanEnter) path := l1.Path(r.Player.Tile, gate, r.CanEnter)
for i, p := range path { for i, p := range path {
if l1.At(p.X, p.Y) == level.Gate { if l1.At(p) == level.Gate {
path = path[:i+1] path = path[:i+1]
break break
} }
@@ -981,7 +989,7 @@ func TestRunOptionsFromProgression(t *testing.T) {
} }
path := l.Path(r.Player.Tile, gate, r.CanEnter) path := l.Path(r.Player.Tile, gate, r.CanEnter)
for i, p := range path { for i, p := range path {
if l.At(p.X, p.Y) == level.Gate { if l.At(p) == level.Gate {
path = path[:i+1] path = path[:i+1]
break break
} }
@@ -1001,3 +1009,58 @@ func TestRunOptionsFromProgression(t *testing.T) {
t.Fatal("landmärken från skillen") t.Fatal("landmärken från skillen")
} }
} }
const stairsLevel = `{
"id": "stairs", "name": {"sv": "Trappor"}, "difficulty": 1, "destination": {"sv": "x", "ja": "新宿"},
"floors": [
{"name": "B1", "grid": ["#######", "#S..U.#", "#######"]},
{"name": "1F", "grid": ["#######", "#.P.D.#", "#######"]}
],
"trains": [{"platform": "P", "track": "1", "line": {"en": "A"}, "destination": {"en": "B", "ja": "新宿"}, "correct": true}]
}`
func TestStairsMoveBetweenFloors(t *testing.T) {
l, err := level.Parse([]byte(stairsLevel))
if err != nil {
t.Fatal(err)
}
r := NewRun(l, RunOptions{Seed: rng.FromInt(1)})
if r.Player.Tile.Z != 0 || r.Fog.Seen(level.Point{X: 2, Y: 1, Z: 1}) {
t.Fatal("startar på B1; 1F är inte sedd än (utom trappans ände)")
}
walk(t, r, []level.Point{pt(1, 1), pt(2, 1), pt(3, 1)})
if !r.Fog.Seen(level.Point{X: 4, Y: 1, Z: 1}) {
t.Fatal("nära trappan avslöjas dess andra ände")
}
// Gå in på trappan: hamnar på 1F i trappan ner.
for i := 0; i < 120 && r.Player.Tile.Z == 0; i++ {
r.Step(frame, Right, false)
}
if r.Player.Tile != (level.Point{X: 4, Y: 1, Z: 1}) || r.Player.Moving() || r.FloorChanges != 1 {
t.Fatalf("efter trappan: %v moving=%v changes=%d", r.Player.Tile, r.Player.Moving(), r.FloorChanges)
}
if _, _, _, _, _, ok := r.NearTrack(2); !ok {
t.Fatal("på 1F är perrongen nära")
}
// Dimman på 1F är nu avslöjad runt trappan.
if !r.Fog.Seen(level.Point{X: 2, Y: 1, Z: 1}) {
t.Fatal("dimman på 1F ska lätta runt trappan")
}
// Tillbaka ner: kliv av trappan och gå på den igen.
walk(t, r, []level.Point{r.Player.Tile, {X: 3, Y: 1, Z: 1}})
walk(t, r, []level.Point{{X: 3, Y: 1, Z: 1}, {X: 4, Y: 1, Z: 1}})
if r.Player.Tile != (level.Point{X: 4, Y: 1, Z: 0}) || r.FloorChanges != 2 {
t.Fatalf("tillbaka till B1: %v changes=%d", r.Player.Tile, r.FloorChanges)
}
// 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)
if path == nil {
t.Fatal("ingen väg")
}
walk(t, r, path)
if r.Player.Tile != l.CorrectPlatform() {
t.Fatalf("boten nådde inte perrongen: %v", r.Player.Tile)
}
}

View File

@@ -0,0 +1,159 @@
{
"destination": {
"en": "Ueno",
"ja": "上野",
"romaji": "Ueno",
"sv": "Ueno"
},
"difficulty": 3,
"fareYen": 200,
"floors": [
{
"grid": [
"#################################",
"#S..#.......#.............#.....#",
"#.#.#.#####.###.#########.#####.#",
"#.#.#.#...#...#.#.....#...#...#.#",
"#.#.#.#.#.###.#.#.###.#.###.#.#.#",
"#.#...#.#...#.#.....#.#.....#...#",
"#.#####.#####.#######.#.#########",
"#.......#...#.#.....#.#.#.......#",
"#.#.#####.#.#.#.###.#.#.#.#####.#",
"#.#.#...#.#.#.#.#...#.#...#...#.#",
"#.#.#.#.#.#.#.#.#.###.###.#.#.#.#",
"#...#.#...#.#...#...#...#.#.#.#.#",
"###.#.#####.#######.###.#.#.#.#.#",
"#...#.....#.......#...#...#.#...#",
"#.#.#####.#.#########.#####.###.#",
"#.#.#...#.#.........#...#...#.#.#",
"#.###.#.#.#######.#.###.#.###.#.#",
"#.#...#...#.....#.#.......#...#.#",
"#.#.#########.#.#.#########.###.#",
"#.....U.......#.#...............#",
"#################################"
],
"name": "B1"
},
{
"grid": [
"#################################",
"#.......#.............#...#.....#",
"#.#####.#.#########.#.#.#.#.#.#.#",
"#..i..#.#.....#...#.#.#.#.#.#.#.#",
"###.#.#.#######.#.#.#.#.#.###.###",
"#...#.#.......#.#...#.#.#...#...#",
"#.###.#######.#.#####.#.#.#.###.#",
"#.........#T#.#.#K..#...#.#...#.#",
"#.#######.#.#.#.###.#####.###.#.#",
"#...#...#...#.#...#.........#..i#",
"#.#.#.#.###.#.###.#.###########.#",
"#W#...#...#.#.#...#.......#...#.#",
"#########.#.#.#.###.#####.#.#.#.#",
"#........i#.#...#.#.#...#...#...#",
"#.#######.#######.#.#.#.#########",
"#...#.....#.........#.#...#.....#",
"###.#.#.###.###########.#.#.###.#",
"#...#.#...#.....#.......#.#...#.#",
"#G###.###.#####.#.#.#####.###.#G#",
"#pG...D.#...........#.........#P#",
"#################################"
],
"name": "1F"
}
],
"id": "004-tokyo",
"kind": "main",
"maxMissedTrains": 2,
"name": {
"en": "Tokyo Station",
"ja": "東京駅",
"sv": "Tokyo station"
},
"order": 4,
"party": 3,
"rideStops": 4,
"shop": [
"beanie",
"mask_fox",
"hiking_boots",
"omamori",
"conductor_cap"
],
"signs": [
{
"text": {
"en": "Tokyo Station, floor 1F. You arrived on the Marunouchi Line underground (B1). Track 4 Yamanote Line for Ueno: south-east. Track 7 Tokaido Line for Shinagawa: south-west.",
"ja": "東京駅 1階 地下 丸ノ内線 ↓ 4番線 山手線 上野方面 → 南東 7番線 東海道線 品川方面 → 南西",
"sv": "Tokyo station, våning 1F. Du kom med Marunouchi-linjen under mark (B1). Spår 4 Yamanote-linjen mot Ueno: sydost. Spår 7 Tokaido-linjen mot Shinagawa: sydväst."
},
"x": 9,
"y": 13,
"z": 1
},
{
"text": {
"en": "\u003c- Track 7 Tokaido Line (Shinagawa) Track 4 Yamanote Line (Ueno) -\u003e",
"ja": "← 7番線 東海道線 品川方面 4番線 山手線 上野方面 →",
"sv": "\u003c- Spår 7 Tokaido-linjen (Shinagawa) Spår 4 Yamanote-linjen (Ueno) -\u003e"
},
"x": 3,
"y": 3,
"z": 1
},
{
"text": {
"en": "Track 4 Yamanote Line for Ueno: the gate in the south-east corner. The stairs down lead back to B1.",
"ja": "4番線 山手線 上野方面 → 南東口 改札 ↓ 地下",
"sv": "Spår 4 Yamanote-linjen mot Ueno: spärren i sydöstra hörnet. Trappan ner leder tillbaka till B1."
},
"x": 31,
"y": 9,
"z": 1
}
],
"ticketRequired": false,
"timeLimitSec": 420,
"trains": [
{
"correct": true,
"crowded": true,
"destination": {
"en": "Ueno",
"ja": "上野",
"romaji": "Ueno"
},
"line": {
"en": "Yamanote Line",
"ja": "山手線",
"romaji": "Yamanote-sen"
},
"platform": "P",
"schedule": {
"dwellSec": 8,
"intervalSec": 24,
"offsetSec": 5
},
"track": "4"
},
{
"correct": false,
"destination": {
"en": "Shinagawa",
"ja": "品川",
"romaji": "Shinagawa"
},
"line": {
"en": "Tokaido Line",
"ja": "東海道線",
"romaji": "Tokaido-sen"
},
"platform": "p",
"schedule": {
"dwellSec": 8,
"intervalSec": 28,
"offsetSec": 11
},
"track": "7"
}
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 950 B

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -539,5 +539,54 @@
"sv": "Kabukicho", "sv": "Kabukicho",
"tier": 3, "tier": 3,
"kind": "station" "kind": "station"
},
"東京駅": {
"kana": "とうきょうえき",
"romaji": "Tōkyō-eki",
"en": "Tokyo Station",
"sv": "Tokyo station",
"tier": 3
},
"東海道線": {
"kana": "とうかいどうせん",
"romaji": "Tōkaidō-sen",
"en": "Tokaido Line",
"sv": "Tokaido-linjen",
"tier": 3
},
"丸ノ内線": {
"kana": "まるのうちせん",
"romaji": "Marunouchi-sen",
"en": "Marunouchi Line",
"sv": "Marunouchi-linjen",
"tier": 3
},
"階": {
"kana": "かい",
"romaji": "kai",
"en": "floor",
"sv": "våning",
"tier": 2
},
"地下": {
"kana": "ちか",
"romaji": "chika",
"en": "underground",
"sv": "under mark",
"tier": 2
},
"上": {
"kana": "うえ",
"romaji": "ue",
"en": "up",
"sv": "upp",
"tier": 1
},
"下": {
"kana": "した",
"romaji": "shita",
"en": "down",
"sv": "ner",
"tier": 1
} }
} }

View File

@@ -3,7 +3,8 @@
// go run ./tools/genart // go run ./tools/genart
// //
// Skriver resources/sprites/tiles.png (en remsa 16×16-rutor i ordningen // Skriver resources/sprites/tiles.png (en remsa 16×16-rutor i ordningen
// vägg, golv, start, perrong, spärr, automat, skylt, tågvagn, toalett, kiosk, räls) och // vägg, golv, start, perrong, spärr, automat, skylt, tågvagn, toalett, kiosk, räls,
// trappa upp, trappa ner) och
// resources/sprites/player.png (3 bildrutor × 4 riktningar: ner, upp, // resources/sprites/player.png (3 bildrutor × 4 riktningar: ner, upp,
// vänster, höger; kolumn 0 = stilla, 1–2 = gå). Ritas med enkla rektanglar // vänster, höger; kolumn 0 = stilla, 1–2 = gå). Ritas med enkla rektanglar
// så att allt är deterministiskt och lätt att justera. // så att allt är deterministiskt och lätt att justera.
@@ -142,6 +143,26 @@ var tiles = []func(canvas){
c.rect(0, 10, 16, 1, metal) c.rect(0, 10, 16, 1, metal)
c.rect(0, 11, 16, 1, metalDark) c.rect(0, 11, 16, 1, metalDark)
}, },
func(c canvas) { // trappa upp: steg som blir ljusare uppåt + pil
floor(c)
for i := 0; i < 5; i++ {
shade := uint8(90 + i*25)
c.rect(2, 13-i*3, 12, 3, color.RGBA{shade, shade + 10, shade + 30, 255})
}
c.rect(7, 1, 2, 4, white) // pil upp
c.rect(6, 2, 4, 1, white)
c.rect(5, 3, 6, 1, white)
},
func(c canvas) { // trappa ner: steg som blir mörkare neråt + pil
floor(c)
for i := 0; i < 5; i++ {
shade := uint8(190 - i*25)
c.rect(2, 1+i*3, 12, 3, color.RGBA{shade, shade + 10, shade + 30, 255})
}
c.rect(7, 11, 2, 4, boxDark) // pil ner
c.rect(6, 13, 4, 1, boxDark)
c.rect(5, 12, 6, 1, boxDark)
},
func(c canvas) { // kiosk: randig markis och disk func(c canvas) { // kiosk: randig markis och disk
floor(c) floor(c)
c.rect(1, 1, 14, 3, awningRed) c.rect(1, 1, 14, 3, awningRed)

View File

@@ -94,6 +94,14 @@ func buildShots(levelsDir string) error {
if err != nil { if err != nil {
return err return err
} }
l4, err := level.LoadFile(filepath.Join(levelsDir, "004-tokyo.json"))
if err != nil {
return err
}
toStairs, err := moves(l4, l4.SpawnPoint(), l4.Find(level.StairsUp)[0])
if err != nil {
return err
}
// Skyltbilderna tas med några kända ord så att markeringen och den // Skyltbilderna tas med några kända ord så att markeringen och den
// delvisa översättningen syns; hjälpen lär sedan in nästa ord. // delvisa översättningen syns; hjälpen lär sedan in nästa ord.
known := []string{"-know", "改札,番線,方面", "-seed", "7"} known := []string{"-know", "改札,番線,方面", "-seed", "7"}
@@ -109,6 +117,7 @@ func buildShots(levelsDir string) error {
{"002-shinjuku", toMachine2 + "; press enter; wait 5; shot $OUT/ticket.png; quit", []string{"-seed", "7", "-know", "渋谷"}}, {"002-shinjuku", toMachine2 + "; press enter; wait 5; shot $OUT/ticket.png; quit", []string{"-seed", "7", "-know", "渋谷"}},
{"003-shibuya", toMachine3 + "; press enter; pick 池袋; press enter; wait 5; press enter; press right; wait 5; shot $OUT/seats.png; quit", {"003-shibuya", toMachine3 + "; press enter; pick 池袋; press enter; wait 5; press enter; press right; wait 5; shot $OUT/seats.png; quit",
[]string{"-seed", "7", "-know", "池袋", "-debuffs", "sore_feet"}}, []string{"-seed", "7", "-know", "池袋", "-debuffs", "sore_feet"}},
{"004-tokyo", "wait 10; shot $OUT/floors-b1.png; " + toStairs + "; wait 20; shot $OUT/floors-1f.png; quit", []string{"-seed", "7"}},
} }
return nil return nil
} }
@@ -127,6 +136,9 @@ func moves(l *level.Level, from, to level.Point) (string, error) {
} }
} }
for i := 1; i < len(path); i++ { for i := 1; i < len(path); i++ {
if path[i].Z != path[i-1].Z {
continue // trappan flyttar en till andra våningen i samma steg
}
d := dirName(path[i-1], path[i]) d := dirName(path[i-1], path[i])
if d != dir { if d != dir {
flush() flush()