diff --git a/README.md b/README.md index a354208..6702b76 100644 --- a/README.md +++ b/README.md @@ -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+ 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, 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 -progression: XP och nivåer, ett skillträd, items med buffar och genvägar som -köps i kioskernas butiker eller vinns på sidospår, och en sparfil som minns -allt inklusive seeden på den bana du höll på med. Nästa är M9a: stationer i -flera våningar. Se [doc/roadmap.md](doc/roadmap.md). +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 +[doc/roadmap.md](doc/roadmap.md). ![Bankartan](doc/img/map.png) @@ -51,6 +51,11 @@ visar vad du bär. ![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, 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, diff --git a/doc/img/floors-1f.png b/doc/img/floors-1f.png new file mode 100644 index 0000000..1f33a1a Binary files /dev/null and b/doc/img/floors-1f.png differ diff --git a/doc/img/floors-b1.png b/doc/img/floors-b1.png new file mode 100644 index 0000000..e3e8fc7 Binary files /dev/null and b/doc/img/floors-b1.png differ diff --git a/doc/img/map.png b/doc/img/map.png index dbb80bf..8d0a6a1 100644 Binary files a/doc/img/map.png and b/doc/img/map.png differ diff --git a/doc/img/words.png b/doc/img/words.png index 3889a13..5f02139 100644 Binary files a/doc/img/words.png and b/doc/img/words.png differ diff --git a/doc/mal-och-tester.md b/doc/mal-och-tester.md index c30209b..1068635 100644 --- a/doc/mal-och-tester.md +++ b/doc/mal-och-tester.md @@ -34,7 +34,7 @@ att bygga på Linux som på Windows. | Inkrementellt debug-bygge | < 10 s | 4,7 s | | Release-binär | < 20 MB | 10,2 MB (Linux), 10,8 MB (Windows) | | Testsviten | `go test ./...` grön på < 30 s | ~0,1 s | -| Banor | 100 % passerar det automatiska lösbarhetstestet | 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) | | 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 | @@ -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/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/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 | ### 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 | | 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 | -| 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 | | 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 | diff --git a/doc/plan.md b/doc/plan.md index ec6edaa..6eb7fbf 100644 --- a/doc/plan.md +++ b/doc/plan.md @@ -34,7 +34,7 @@ brasse-linux01-runnern, inte Pi5:n. M0 skelett ✓ → M1 rörelse/kamera ✓ → M2 tåg/spärrar/vinst ✓ → M3 japanska/skyltar ✓ → M4 lämmeltåget ✓ → M5 minikarta ✓ → M6 kort/biljetter/minispel ✓ → M7 -svårighet/poäng/debuffar ✓ → M7b tåg som kommer och går ✓ → M8 progression/sparfiler ✓ → M9a flera våningar → M9b folk/reklam/ljudröra → M9 navigeringslägen → +svårighet/poäng/debuffar ✓ → M7b tåg som kommer och går ✓ → M8 progression/sparfiler ✓ → M9a flera våningar ✓ → M9b folk/reklam/ljudröra → M9 navigeringslägen → M10 story-innehåll → M11 oändligt läge/NG+ → M12 ljud/polish/release. Innehåll per milstolpe: [roadmap.md › Milstolpar](roadmap.md#milstolpar). Vad som ska vara testat per milstolpe: [mal-och-tester.md](mal-och-tester.md). diff --git a/doc/roadmap.md b/doc/roadmap.md index cb3aaf2..2724ec0 100644 --- a/doc/roadmap.md +++ b/doc/roadmap.md @@ -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 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. -- Banformatet får **flera rutnät** (`floors`), ett per våning, med - **trappor** (och rulltrappor/hissar) som kopplar en ruta på en våning - till en ruta på en annan. Start kan ligga på en trappa upp eller ner: - under mark, ovan mark eller marknivå. -- Kamera, dimma, minikarta (en flik per våning eller stapel) och BFS-vägar - (`level.Path`, lösbarhetstestet, botarna) blir våningsmedvetna; skyltar - kan peka "↑ 2F / ↓ B1". -- **Teknik:** `level.Level.Floors []Grid` med `Stairs []Link`, `Point` - får `Z`; `sim.Mover` byter våning vid trappa; `tools/genart` får - trappruta; generatorn placerar trappor som flaskhalsar. -- **Klar när:** en bana med två våningar går att klara av boten headless, - minikartan visar rätt våning, och alla gamla banor fungerar oförändrat - (en våning). +- **Banformat:** `floors: [{name, grid}, …]` med lika stora rutnät; gamla + banor med `grid` läses som en våning ("1F"). Rutkoordinaten `Point` fick + `Z`. **Trappor** är rutor: `U` (upp) på en våning kräver `D` (ner) på + samma x,y en våning upp och tvärtom (valideras). Går man in på en trappa + hamnar man i andra änden i samma steg. Skyltar har `z`. +- **Logik:** `Neighbors` ger grannarna inkl. trappans andra ände, så + `Path`, lösbarhetstestet, spärregeln och `Unreachable` fungerar över + våningar. Dimman är per våning och avslöjar trappans andra ände. Man + ser/hör bara tågen på sin egen våning. +- **Spel:** bara spelarens våning ritas (följare på andra våningar syns + inte förrän de kommer upp), HUD och minikartan visar våningens namn, + trappor har egen grafik och färg på minikartan. +- **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) diff --git a/internal/game/assets.go b/internal/game/assets.go index c250981..164b388 100644 --- a/internal/game/assets.go +++ b/internal/game/assets.go @@ -27,6 +27,7 @@ var tileOrder = []level.Tile{ level.Wall, // plats 7 = tågvagnen (laddas separat som Train) level.Toilet, level.Wall, // plats 9 = rälsen (laddas separat som Rails) + level.StairsUp, level.StairsDown, level.Kiosk, } diff --git a/internal/game/minimap.go b/internal/game/minimap.go index 0257654..f4e8230 100644 --- a/internal/game/minimap.go +++ b/internal/game/minimap.go @@ -29,6 +29,8 @@ var minimapColors = map[level.Tile]color.RGBA{ level.Sign: {90, 140, 230, 255}, level.Toilet: {235, 240, 250, 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 @@ -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 }) + z := r.Player.Tile.Z + floorLabel := "" + if l.NumFloors() > 1 { + floorLabel = l.FloorName(z) + } w := max(mapW, 44) + 6 h := mapH + 6 + len(deps)*LineH + if floorLabel != "" { + h += LineH + } px, py := r.Player.WorldPos(TileSize) ox, oy := s.cam.Offset() 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) 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 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) { continue } - t := l.At(x, y) + t := l.At(p) c := minimapColors[t] if (t == level.Platform || t == level.WrongPlatform) && r.TrainVisible(t) { 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. for i := len(r.Party.Followers()); i >= 1; i-- { from, _ := r.Party.FollowerTiles(i) + if from.Z != z { + continue + } tint := memberTints[i%len(memberTints)] 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) diff --git a/internal/game/scene_level.go b/internal/game/scene_level.go index 9dda08d..04a9c5e 100644 --- a/internal/game/scene_level.go +++ b/internal/game/scene_level.go @@ -125,7 +125,7 @@ func (s *LevelScene) Update(g *Game) error { g.SetScene(NewMapScene(g)) 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) return nil } @@ -191,16 +191,18 @@ func (s *LevelScene) Draw(g *Game, screen *ebiten.Image) { y0 := max(0, int(s.cam.Y)/TileSize) x1 := min(lvl.Width(), int(s.cam.X+ScreenW)/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 for y := y0; y < y1; y++ { for x := x0; x < x1; x++ { - if s.rails[level.Point{X: x, Y: y}] { - s.drawRails(g, screen, level.Point{X: x, Y: y}, ox, oy) + p := level.Point{X: x, Y: y, Z: z} + if s.rails[p] { + s.drawRails(g, screen, p, ox, oy) continue } op.GeoM.Reset() 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) @@ -211,6 +213,9 @@ func (s *LevelScene) Draw(g *Game, screen *ebiten.Image) { followers := s.run.Party.Followers() for i := len(followers); i >= 1; 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 fy := float64(from.Y*TileSize) + float64((to.Y-from.Y)*TileSize)*leader.Progress facing := sim.Down @@ -296,6 +301,9 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) { dest += " " + r.Level.Destination.Get("ja") } 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) destX := 4 + a.Width(name) + 12 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, 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 { case phase == level.Closing: accent = colWarn @@ -461,7 +469,7 @@ func (s *LevelScene) infoLines(a *Assets) (lines []line, accent color.Color, bar return lines, accent, bar } - switch lvl.At(tile.X, tile.Y) { + switch lvl.At(tile) { case level.Toilet: if r.Facility.Active { return []line{plainLine(a.T("hud.toilet_busy"), colDim)}, colGood, true diff --git a/internal/game/trains.go b/internal/game/trains.go index 8856037..aad487d 100644 --- a/internal/game/trains.go +++ b/internal/game/trains.go @@ -47,7 +47,7 @@ func (s *LevelScene) buildTracks() { } else { 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 } } @@ -74,7 +74,7 @@ func (s *LevelScene) drawRails(g *Game, screen *ebiten.Image, c level.Point, ox, var op ebiten.DrawImageOptions vertical := false 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 } } @@ -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. func (s *LevelScene) drawTrains(g *Game, screen *ebiten.Image, ox, oy float64) { + z := s.run.Player.Tile.Z 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) // Strålkastare när tåget är på väg in: ett sken från det håll det kommer. if phase == level.Approaching { @@ -148,6 +152,10 @@ func (s *LevelScene) updateEffects(g *Game) { for _, tr := range s.tracks { phase, prog := r.PlatformPhase(tr.tile) 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 dist := math.Hypot(float64(tr.platform.X*TileSize)-px, float64(tr.platform.Y*TileSize)-py) / TileSize 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). 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 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) diff --git a/internal/level/level.go b/internal/level/level.go index 3420778..0fa2aea 100644 --- a/internal/level/level.go +++ b/internal/level/level.go @@ -31,13 +31,19 @@ const ( Sign Tile = 'i' // skylt Toilet Tile = 'W' // toalett (sällskapets toamätare) 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{ Wall: true, Floor: true, Spawn: true, Platform: true, WrongPlatform: 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). func (t Tile) Passable() bool { return t != Wall } @@ -169,9 +175,17 @@ type Train struct { type SignText struct { X int `json:"x"` Y int `json:"y"` + Z int `json:"z"` // våning (0 = första i floors) 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. type Rewards struct { 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"` Shop []string `json:"shop"` // items som kiosken säljer på banan (tom = seedat urval) Name Text `json:"name"` - Difficulty int `json:"difficulty"` // 1 (lätt) … 5 (svår) - Destination Text `json:"destination"` // dit spelaren ska - 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) - 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 - 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 - 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 - FareYen int `json:"fareYen"` // vad spärren drar från kortet (0 = standard) - Grid []string `json:"grid"` + Difficulty int `json:"difficulty"` // 1 (lätt) … 5 (svår) + Destination Text `json:"destination"` // dit spelaren ska + 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) + 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 + 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 + 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 + FareYen int `json:"fareYen"` // vad spärren drar från kortet (0 = standard) + 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"` Signs []SignText `json:"signs"` } -// Point är en rutkoordinat. -type Point struct{ X, Y int } +// Point är en rutkoordinat: x, y och våning z. +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. func Parse(data []byte) (*Level, error) { @@ -218,36 +240,77 @@ func Parse(data []byte) (*Level, error) { return &l, nil } -// Width och Height i rutor. +// Width och Height i rutor (alla våningar är lika stora). func (l *Level) Width() int { - if len(l.Grid) == 0 { + if len(l.Floors) == 0 || len(l.Floors[0].Grid) == 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. -func (l *Level) At(x, y int) Tile { - if y < 0 || y >= len(l.Grid) || x < 0 || x >= len(l.Grid[y]) { +// NumFloors är antalet våningar. +func (l *Level) NumFloors() int { return len(l.Floors) } + +// 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 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 { var pts []Point - for y, row := range l.Grid { - for x := 0; x < len(row); x++ { - if Tile(row[x]) == t { - pts = append(pts, Point{x, y}) + for z, f := range l.Floors { + for y, row := range f.Grid { + for x := 0; x < len(row); x++ { + if Tile(row[x]) == t { + pts = append(pts, Point{X: x, Y: y, Z: z}) + } } } } 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). func (l *Level) SpawnPoint() Point { return l.Find(Spawn)[0] } @@ -274,25 +337,47 @@ func (l *Level) Validate() error { if l.Order < 0 { return fmt.Errorf("level %s: negativ order", 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) + if len(l.Floors) == 0 { + if len(l.Grid) == 0 { + return fmt.Errorf("level %s: tomt rutnät", l.ID) } - for x := 0; x < len(row); x++ { - t := Tile(row[x]) - if !knownTiles[t] { - return fmt.Errorf("level %s: okänt tecken %q på (%d,%d)", l.ID, row[x], x, y) + l.Floors = []Storey{{Name: "1F", Grid: l.Grid}} + } else if len(l.Grid) > 0 { + return fmt.Errorf("level %s: ange antingen grid eller floors, inte båda", l.ID) + } + 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 { - case Spawn: - spawns++ - case Platform, WrongPlatform: - platforms++ + for x := 0; x < len(row); x++ { + t := Tile(row[x]) + if !knownTiles[t] { + return fmt.Errorf("level %s: okänt tecken %q på (%d,%d,%d)", l.ID, row[x], x, y, z) + } + 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) { 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) } 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. seen := map[Point]bool{} for i, sg := range l.Signs { - p := Point{sg.X, sg.Y} - if l.At(p.X, p.Y) != Sign { - return fmt.Errorf("level %s: skylt %d på (%d,%d) står inte på en skyltruta", l.ID, i, p.X, p.Y) + p := Point{X: sg.X, Y: sg.Y, Z: sg.Z} + if l.At(p) != Sign { + 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] { 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) { 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 @@ -387,7 +472,7 @@ func (l *Level) Validate() error { // SignAt returnerar skylten på en ruta, eller nil. func (l *Level) SignAt(p Point) *SignText { 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] } } @@ -413,8 +498,7 @@ func (l *Level) Path(from, to Point, canEnter func(Point) bool) []Point { } return path } - for _, d := range [...]Point{{1, 0}, {-1, 0}, {0, 1}, {0, -1}} { - n := Point{p.X + d.X, p.Y + d.Y} + for _, n := range l.Neighbors(p) { if _, ok := prev[n]; ok || !canEnter(n) { continue } @@ -434,9 +518,8 @@ func (l *Level) Reachable() map[Point]bool { for len(queue) > 0 { p := queue[0] queue = queue[1:] - for _, d := range [...]Point{{1, 0}, {-1, 0}, {0, 1}, {0, -1}} { - n := Point{p.X + d.X, p.Y + d.Y} - if seen[n] || !l.At(n.X, n.Y).Passable() { + for _, n := range l.Neighbors(p) { + if seen[n] || !l.At(n).Passable() { continue } seen[n] = true @@ -472,9 +555,8 @@ func (l *Level) UngatedPlatforms() []Point { for len(queue) > 0 { p := queue[0] queue = queue[1:] - for _, d := range [...]Point{{1, 0}, {-1, 0}, {0, 1}, {0, -1}} { - n := Point{p.X + d.X, p.Y + d.Y} - t := l.At(n.X, n.Y) + for _, n := range l.Neighbors(p) { + t := l.At(n) if seen[n] || !t.Passable() || t == Gate { continue } @@ -518,8 +600,8 @@ func (tr *Train) BoardText() string { // anger om rälsen (och tågets rörelse) går i sidled. 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}} { - n := Point{X: p.X + d.X, Y: p.Y + d.Y} - if l.At(n.X, n.Y) == Wall { + n := Point{X: p.X + d.X, Y: p.Y + d.Y, Z: p.Z} + if l.At(n) == Wall { 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). -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. func (l *Level) TrainAt(t Tile) *Train { diff --git a/internal/level/level_test.go b/internal/level/level_test.go index cc1261e..a80a7df 100644 --- a/internal/level/level_test.go +++ b/internal/level/level_test.go @@ -40,7 +40,7 @@ func TestParseAndSolve(t *testing.T) { if l.Width() != 7 || l.Height() != 5 { 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()) } if !l.Solvable() { @@ -56,14 +56,14 @@ func TestUnsolvableWhenWalledOff(t *testing.T) { if l.Solvable() { 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) } } func TestOutsideGridIsWall(t *testing.T) { 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") } } @@ -227,19 +227,19 @@ func TestSignAt(t *testing.T) { src := strings.Replace(strings.Replace(sample, `"#S..#P#"`, `"#Si.#P#"`, 1), `"trains"`, `"signs": [{"x": 2, "y": 1, "text": {"sv": "Hej"}}], "trains"`, 1) 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) } - if l.SignAt(Point{1, 1}) != nil { + if l.SignAt(Point{X: 1, Y: 1}) != nil { t.Fatal("ingen skylt på startrutan") } } func TestPath(t *testing.T) { 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) - 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) } 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]) } } - 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") } 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) { // Perrong utan spärr framför -> rapporteras. 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) } // 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 { 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()) } if got := l.Trains[0].BoardText(); got != "番線 A B方面" { t.Fatalf("BoardText = %q", got) } 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") } } @@ -378,12 +378,12 @@ func TestScheduleValidationForPhases(t *testing.T) { func TestTrackTile(t *testing.T) { 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 - if tile != (Point{5, 2}) && tile != (Point{6, 1}) && tile != (Point{5, 0}) { + tile, horizontal := l.TrackTile(Point{X: 5, Y: 1}) // ordningen är ner, höger, vänster, upp + if tile != (Point{X: 5, Y: 2}) && tile != (Point{X: 6, Y: 1}) && tile != (Point{X: 5, Y: 0}) { t.Fatalf("TrackTile = %v", tile) } // (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") } } @@ -430,3 +430,88 @@ func TestKindOrderParentAndLoadAll(t *testing.T) { 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) + } + } +} diff --git a/internal/sim/fog.go b/internal/sim/fog.go index a0a7588..0d96861 100644 --- a/internal/sim/fog.go +++ b/internal/sim/fog.go @@ -9,23 +9,24 @@ const FogRadius = 3 // 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. type Fog struct { - w, h int + w, h, floors int seen []bool Radius int count int 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 { - 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) { - 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 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. @@ -41,8 +42,9 @@ func (f *Fog) mark(p level.Point) { } } -// Reveal avslöjar allt inom Radius steg från from via passerbara rutor, -// samt väggarna som gränsar till dem (så att gångarnas form syns). +// Reveal avslöjar allt inom Radius steg från from via passerbara rutor på +// 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) { dist := map[level.Point]int{from: 0} queue := []level.Point{from} @@ -50,10 +52,16 @@ func (f *Fog) Reveal(l *level.Level, from level.Point) { p := queue[0] queue = queue[1:] 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 dy := -1; dy <= 1; dy++ { - n := level.Point{X: p.X + dx, Y: p.Y + dy} - if !l.At(n.X, n.Y).Passable() { + n := level.Point{X: p.X + dx, Y: p.Y + dy, Z: p.Z} + if !l.At(n).Passable() { f.mark(n) // gränsande vägg } } @@ -62,8 +70,8 @@ func (f *Fog) Reveal(l *level.Level, from level.Point) { continue } 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} - if _, ok := dist[n]; ok || !l.At(n.X, n.Y).Passable() { + n := level.Point{X: p.X + d.X, Y: p.Y + d.Y, Z: p.Z} + if _, ok := dist[n]; ok || !l.At(n).Passable() { continue } 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), // starten och spärrarna – det man orienterar sig efter. func Landmark(l *level.Level, p level.Point) bool { - switch l.At(p.X, p.Y) { - case level.Platform, level.WrongPlatform, level.Spawn, level.Gate: + switch l.At(p) { + case level.Platform, level.WrongPlatform, level.Spawn, level.Gate, level.StairsUp, level.StairsDown: return true } return false @@ -88,7 +96,7 @@ func (f *Fog) Visible(l *level.Level, p level.Point) bool { return true } if f.ExtraLandmarks { - switch l.At(p.X, p.Y) { + switch l.At(p) { case level.Toilet, level.Kiosk, level.TicketMachine: 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). func (f *Fog) RevealAll(l *level.Level) { - for y := 0; y < l.Height(); y++ { - for x := 0; x < l.Width(); x++ { - f.mark(level.Point{X: x, Y: y}) + for z := 0; z < l.NumFloors(); z++ { + for y := 0; y < l.Height(); 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. func (f *Fog) Explored(l *level.Level) float64 { total, seen := 0, 0 - for y := 0; y < l.Height(); y++ { - for x := 0; x < l.Width(); x++ { - if !l.At(x, y).Passable() { - continue - } - total++ - if f.Seen(level.Point{X: x, Y: y}) { - seen++ + 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).Passable() { + continue + } + total++ + if f.Seen(p) { + seen++ + } } } } diff --git a/internal/sim/mover.go b/internal/sim/mover.go index 48bb355..2105969 100644 --- a/internal/sim/mover.go +++ b/internal/sim/mover.go @@ -45,7 +45,7 @@ func (m *Mover) Step(dt float64, want Dir, canEnter func(level.Point) bool) int } m.Facing = want 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) { return arrived } diff --git a/internal/sim/run.go b/internal/sim/run.go index 5966e67..4470086 100644 --- a/internal/sim/run.go +++ b/internal/sim/run.go @@ -47,6 +47,8 @@ type Run struct { Progressed []string // ord som fick framsteg vid senaste läsningen, "ord n/3" ProgressedAt float64 // < 0 = aldrig + FloorChanges int // antal trappbyten (statistik) + // Resultat när banan vunnits. Stars int // 1–3 efter tid mot par 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 // gå mellan två spärrutor är gratis). func (r *Run) CanEnter(p level.Point) bool { - t := r.Level.At(p.X, p.Y) + t := r.Level.At(p) if !t.Passable() { return false } @@ -315,7 +317,7 @@ func (r *Run) MembersWithoutFare() []*Member { } 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 @@ -342,6 +344,18 @@ func (r *Run) Step(dt float64, want Dir, board bool) { } 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 + // (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.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() { - switch r.Level.At(r.Player.Tile.X, r.Player.Tile.Y) { + switch r.Level.At(r.Player.Tile) { case level.Toilet: r.useToilet() 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 // till avgång respektive ankomst. 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 { return nil, false, 0 } @@ -679,6 +693,9 @@ func (r *Run) NearTrack(maxDist int) (tr *level.Train, platform level.Point, pha best := maxDist + 1 for _, t := range []level.Tile{level.Platform, level.WrongPlatform} { 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)) if d < best { if cand := r.Level.TrainAt(t); cand != nil { diff --git a/internal/sim/run_test.go b/internal/sim/run_test.go index b0572c1..db5f838 100644 --- a/internal/sim/run_test.go +++ b/internal/sim/run_test.go @@ -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 // 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). +// 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) { t.Helper() - for _, next := range path[1:] { - d := dirTo(r.Player.Tile, next) - for i := 0; i < 600; i++ { - if r.Player.Tile == next && !r.Player.Moving() { - break - } - if r.Player.Target == next || r.Player.Tile == next { + for idx := 1; idx < len(path); idx++ { + next := path[idx] + cur := r.Player.Tile + if next.Z != cur.Z && next.X == cur.X && next.Y == cur.Y { + continue // våningsbytet skedde när vi klev på trappan + } + 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) } else { 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) } } @@ -203,7 +211,7 @@ func TestGateNeedsCardBalance(t *testing.T) { r.Player.Tile, r.Player.Target = gate, gate r.Wallet.Card = 0 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") } } @@ -635,7 +643,7 @@ func TestPhoneSpeedRescanAndBattery(t *testing.T) { gate := l.Find(level.Gate)[0] 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 - if l.At(p.X, p.Y) == level.Gate { + if l.At(p) == level.Gate { path = path[:i+1] break } @@ -810,7 +818,7 @@ func TestIndividualCardsAtGateAndMachine(t *testing.T) { before := r.Wallet.Spent path := l1.Path(r.Player.Tile, gate, r.CanEnter) for i, p := range path { - if l1.At(p.X, p.Y) == level.Gate { + if l1.At(p) == level.Gate { path = path[:i+1] break } @@ -981,7 +989,7 @@ func TestRunOptionsFromProgression(t *testing.T) { } path := l.Path(r.Player.Tile, gate, r.CanEnter) for i, p := range path { - if l.At(p.X, p.Y) == level.Gate { + if l.At(p) == level.Gate { path = path[:i+1] break } @@ -1001,3 +1009,58 @@ func TestRunOptionsFromProgression(t *testing.T) { 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) + } +} diff --git a/resources/data/levels/004-tokyo.json b/resources/data/levels/004-tokyo.json new file mode 100644 index 0000000..2d928b7 --- /dev/null +++ b/resources/data/levels/004-tokyo.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/resources/sprites/tiles.png b/resources/sprites/tiles.png index 9f95497..ddeb2f3 100644 Binary files a/resources/sprites/tiles.png and b/resources/sprites/tiles.png differ diff --git a/resources/text/jp-dictionary.json b/resources/text/jp-dictionary.json index f37c172..2db2fe4 100644 --- a/resources/text/jp-dictionary.json +++ b/resources/text/jp-dictionary.json @@ -539,5 +539,54 @@ "sv": "Kabukicho", "tier": 3, "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 } } diff --git a/tools/genart/main.go b/tools/genart/main.go index 290ea06..494b289 100644 --- a/tools/genart/main.go +++ b/tools/genart/main.go @@ -3,7 +3,8 @@ // go run ./tools/genart // // 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, // vänster, höger; kolumn 0 = stilla, 1–2 = gå). Ritas med enkla rektanglar // 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, 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 floor(c) c.rect(1, 1, 14, 3, awningRed) diff --git a/tools/shots/main.go b/tools/shots/main.go index 79656b2..349fdc9 100644 --- a/tools/shots/main.go +++ b/tools/shots/main.go @@ -94,6 +94,14 @@ func buildShots(levelsDir string) error { if err != nil { 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 // delvisa översättningen syns; hjälpen lär sedan in nästa ord. 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", "渋谷"}}, {"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"}}, } return nil } @@ -127,6 +136,9 @@ func moves(l *level.Level, from, to level.Point) (string, error) { } } 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]) if d != dir { flush()