diff --git a/README.md b/README.md index 3579290..d144ae2 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,12 @@ 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:** M4 klar (2026-10-23) – skyltarna är på japanska och du lär dig +**Status:** M5 klar (2026-10-24) – skyltarna är på japanska och du lär dig ord genom att läsa, fråga om hjälp, klara banor med bra tid eller lägga -språkpoäng i ordlistan. Lämmeltåget följer efter dig: polarna blir hungriga, -törstiga och måste på toa, oftare ju fler ni är, och du löser det vid kiosker -och toaletter. Nästa är M5: minikartan. Se [doc/roadmap.md](doc/roadmap.md). +språkpoäng i ordlistan. Lämmeltåget följer efter dig med sina behov, och +minikartan visar bara avgångar och riktmärken tills du utforskat stationen. +Nästa är M6: tågkort, biljetter, telefon och minispel. Se +[doc/roadmap.md](doc/roadmap.md). ![Skylt på japanska med delvis översättning](doc/img/sign.png) @@ -31,7 +32,9 @@ Klarar du banan under par får du stjärnor, språkpoäng och resmålets namn. Ditt sällskap följer efter i en rad. Mätarna uppe till höger visar mat, dryck och toa för var och en; faller de får någon ett behov som saktar ned -er alla tills ni hittar en kiosk eller en toalett. +er alla tills ni hittar en kiosk eller en toalett. Minikartan i hörnet visar +från början bara perrongerna, starten och spärrarna plus en avgångslista; +resten fylls i allt eftersom ni går. ![Shinjuku station med sällskapet](doc/img/level-002.png) @@ -119,8 +122,9 @@ metro-puzzle-jp -version Pilar eller WASD går. Enter väljer, kliver på tåget, använder toaletten eller handlar i kiosken. H frågar om hjälp vid en skylt, L öppnar -ordlistan, Esc går tillbaka, R spelar om. Gamepad med standardlayout -fungerar: d-pad/vänster spak, A = välj, B = tillbaka, Y = hjälp, X = ordlista. +ordlistan, M döljer/visar minikartan, Esc går tillbaka, R spelar om. Gamepad +med standardlayout fungerar: d-pad/vänster spak, A = välj, B = tillbaka, +Y = hjälp, X = ordlista, Back/Select = karta. ### Verktyg diff --git a/doc/img/level-001.png b/doc/img/level-001.png index b20a8ad..ba95970 100644 Binary files a/doc/img/level-001.png and b/doc/img/level-001.png differ diff --git a/doc/img/level-002.png b/doc/img/level-002.png index af2c800..884b333 100644 Binary files a/doc/img/level-002.png and b/doc/img/level-002.png differ diff --git a/doc/img/menu.png b/doc/img/menu.png index 3a116ac..da9541c 100644 Binary files a/doc/img/menu.png and b/doc/img/menu.png differ diff --git a/doc/img/platform.png b/doc/img/platform.png index 592b0b7..5b38c4d 100644 Binary files a/doc/img/platform.png and b/doc/img/platform.png differ diff --git a/doc/img/result.png b/doc/img/result.png index db3a1f5..5ad2513 100644 Binary files a/doc/img/result.png and b/doc/img/result.png differ diff --git a/doc/img/sign-help.png b/doc/img/sign-help.png index f569a40..4b2c4b3 100644 Binary files a/doc/img/sign-help.png and b/doc/img/sign-help.png differ diff --git a/doc/img/sign-learned.png b/doc/img/sign-learned.png index 6ad0243..baa6590 100644 Binary files a/doc/img/sign-learned.png and b/doc/img/sign-learned.png differ diff --git a/doc/img/sign.png b/doc/img/sign.png index 7cac225..c8af9ba 100644 Binary files a/doc/img/sign.png and b/doc/img/sign.png differ diff --git a/doc/mal-och-tester.md b/doc/mal-och-tester.md index f72b226..66c97ba 100644 --- a/doc/mal-och-tester.md +++ b/doc/mal-och-tester.md @@ -106,6 +106,7 @@ att bygga på Linux som på Windows. | `internal/sim` | `TestMoreMembersMoreEvents`, `TestEventsAreSeeded` | **M4:** fyra i sällskapet ger minst 3× så många händelser som en; samma seed = samma logg | | `internal/sim` | `TestUrgesSlowAndFacilitiesRestore`, `TestFollowersTrailLeader`, `TestPartySizeIsClamped` | Fartstraff, toalett/kiosk återställer och kostar, distraktion går över, följarna står på ledarens spår | | `internal/sim` | `TestToiletAndKioskInRun`, `TestPartySlowsLeader` | Enter på W startar ett besök som tar tid och avbryts om man går; K drar från kortet; sällskapet saktar ledaren | +| `internal/sim` | `TestFogRevealsAlongCorridorsNotThroughWalls`, `TestLandmarksAlwaysVisible`, `TestFogGrowsWithMovementAndResetsPerRun` | **M5:** dimman lättar längs gångar men inte genom väggar; perronger/start/spärrar syns alltid; dimman växer när man går och nollställs per omgång | | `internal/archtest` | `TestOnlyPresentationPackagesImportEbitengine` | Beroenderegeln ovan | ### Planerade tester per milstolpe @@ -116,7 +117,7 @@ att bygga på Linux som på Windows. | ~~M2 Stationen~~ ✓ | Tidtabell, spärr med/utan saldo, vinst/förlust, bot klarar bana 001 headless – finns, se tabellen ovan | | ~~M3 Japanska & skyltar~~ ✓ | Delvis översättning, engelska-andel, ordbokens fullständighet och täckning, hjälptid – finns, se tabellen ovan | | ~~M4 Lämmeltåget~~ ✓ | Mätare, händelsefrekvens efter gruppstorlek, seedade händelser – finns, se tabellen ovan | -| M5 Minikarta | Upptäckta rutor markeras, återställs per bana; avgångar syns från start | +| ~~M5 Minikarta~~ ✓ | Upptäckta rutor, nollställning per bana, avgångar från start – finns, se tabellen ovan | | M6 Kort/biljetter/minispel | Saldo dras i spärr; tomt kort → automatkrav; biljettminispel: rätt/fel biljett; telefon-skanning misslyckas med given procent per seed; batteri tar bara slut över tröskeltid | | M7 Svårighet/poäng/debuffs | Poängformeln (tid, pengar, mätare, läge); tidsgräns och maxantal missade tåg; varje debuff ändrar rätt stat; temporära debuffar räknar ned 1–4 banor | | M8 Progression & sparfil | Sparfil round-trip (skriv → läs ger samma tillstånd inkl. seed); skill tree-krav; upplåsningar efter rätt bana; "< 33 %"-regeln kontrolleras mot itemlistan | diff --git a/doc/plan.md b/doc/plan.md index ac28c83..19007a2 100644 --- a/doc/plan.md +++ b/doc/plan.md @@ -33,7 +33,7 @@ brasse-linux01-runnern, inte Pi5:n. ## Milstolpar 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 → M8 progression/sparfiler → 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). diff --git a/doc/roadmap.md b/doc/roadmap.md index 44054ac..01a8118 100644 --- a/doc/roadmap.md +++ b/doc/roadmap.md @@ -58,7 +58,7 @@ metro-puzzle-jp/ │ ├── res/ hittar och läser resources/ (ren Go) │ ├── rng/ seedad slump (ren Go) │ ├── level/ banformat, validering, BFS (ren Go) -│ ├── sim/ rutbaserad rörelse, kollision, kamera, banomgång (Run: klocka, kort, spärrar, tåg, hjälp) – ren Go +│ ├── sim/ rutbaserad rörelse, kollision, kamera, banomgång (Run), sällskap (Party), dimma (Fog) – ren Go │ ├── lang/ ordbok, tokenisering, kunskap, delvis översättning, engelska-andel, hjälptid – ren Go │ ├── game/ Ebitengine-loop, scener, input, rendering, skriptläge ← får använda Ebitengine │ ├── archtest/ beroenderegler @@ -233,12 +233,26 @@ på **brasse-linux01-runnern** (label `linux-amd64`), aldrig på Pi5:n. - **Skjutet till M7:** mätarnas värde vid målgång påverkar poängen; debuffar vid tomma mätare. -### M5 – Minikarta och utforskning +### M5 – Minikarta och utforskning ✓ (klar 2026-10-24) - **Mål:** orientering utan att avslöja labyrinten. -- Minikarta som visar avgångar och riktmärken från start och fylls i när - man utforskar. Nollställs per bana och omgång. -- **Teknik:** `internal/sim/fog` (ren Go), rendering i `internal/game`. +- **Dimma** (`sim.Fog`): när spelaren når en ruta avslöjas allt inom tre + *steg* via passerbara rutor plus de väggar som gränsar till dem – man ser + längs gången, inte genom väggen. Nollställs per omgång (ny `Run`). +- **Riktmärken** syns alltid: perrongerna (avgångarna), starten och + spärrarna. Allt annat (kiosker, toaletter, skyltar, automater, gångar) + ritas först när det setts. +- **Minikartan** ritas i ett hörn som varken täcker inforutan eller + spelaren, 1–3 px per ruta beroende på banans storlek. Under kartan står + en avgångslista per spår: `2 ●6s` = tåget inne, avgår om 6 s; + `1 ○14s` = kommer om 14 s. Perrong med tåg inne lyser vit. Spelaren + blinkar vit, följarna i sina färgtoner. **M** döljer/visar kartan. +- Resultatskärmen visar **utforskad andel** av banan (används av poängen i + M7) samt antal försörjda i sällskapet. +- **Teknik:** `sim.Fog` (ren Go, 3 tester), `internal/game/minimap.go`. +- **Klar när:** upptäckta rutor markeras och nollställs per bana, avgångar + syns från start – `TestFogRevealsAlongCorridorsNotThroughWalls`, + `TestLandmarksAlwaysVisible`, `TestFogGrowsWithMovementAndResetsPerRun`. ✓ ### M6 – Kort, biljetter, telefon och minispel diff --git a/internal/game/input.go b/internal/game/input.go index 86412e6..5f11924 100644 --- a/internal/game/input.go +++ b/internal/game/input.go @@ -81,6 +81,11 @@ func wordsJustPressed() bool { return virtual.pressed["l"] || inpututil.IsKeyJustPressed(ebiten.KeyL) || gamepadJustPressed(ebiten.StandardGamepadButtonLeftRight) } +// mapJustPressed: M eller gamepadens vänstra mittknapp (Back/Select) = minikartan av/på. +func mapJustPressed() bool { + return virtual.pressed["m"] || inpututil.IsKeyJustPressed(ebiten.KeyM) || gamepadJustPressed(ebiten.StandardGamepadButtonCenterLeft) +} + // menuMove returnerar -1/+1 när användaren just tryckte upp/ner i en meny. func menuMove() int { switch { diff --git a/internal/game/minimap.go b/internal/game/minimap.go new file mode 100644 index 0000000..0257654 --- /dev/null +++ b/internal/game/minimap.go @@ -0,0 +1,126 @@ +package game + +import ( + "fmt" + "image" + "image/color" + "math" + "sort" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/vector" + + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim" +) + +// Minikartan: bara det spelaren sett plus riktmärkena (perronger, start, +// spärrar), och en liten avgångslista per spår. Den lägger sig i det hörn +// som varken täcker inforutan eller spelaren. + +var minimapColors = map[level.Tile]color.RGBA{ + level.Wall: {58, 68, 102, 255}, + level.Floor: {139, 155, 180, 255}, + level.Spawn: {99, 199, 77, 255}, + level.Platform: {254, 174, 52, 255}, + level.WrongPlatform: {254, 174, 52, 255}, + level.Gate: {44, 232, 245, 255}, + level.TicketMachine: {247, 118, 34, 255}, + level.Sign: {90, 140, 230, 255}, + level.Toilet: {235, 240, 250, 255}, + level.Kiosk: {228, 59, 68, 255}, +} + +// minimapScale väljer pixlar per ruta så att kartan ryms i ungefär en +// tredjedel av skärmen. +func minimapScale(l *level.Level) int { + sc := min(3, (ScreenW/3)/max(1, l.Width()), (ScreenH/3)/max(1, l.Height())) + return max(1, sc) +} + +// minimapRect väljer hörn: nedre höger som standard, annars det första hörnet +// som inte krockar med inforutan (full bredd vid box) eller spelaren. +func minimapRect(w, h int, box image.Rectangle, hasBox bool, player image.Rectangle) image.Rectangle { + const m = 4 + candidates := []image.Rectangle{ + image.Rect(ScreenW-m-w, ScreenH-m-h, ScreenW-m, ScreenH-m), // nedre höger + image.Rect(m, ScreenH-m-h, m+w, ScreenH-m), // nedre vänster + image.Rect(ScreenW-m-w, 26, ScreenW-m, 26+h), // övre höger (under HUD:en) + image.Rect(m, 26, m+w, 26+h), // övre vänster + } + for _, c := range candidates { + if hasBox && c.Overlaps(box) { + continue + } + if c.Overlaps(player.Inset(-8)) { + continue + } + return c + } + return candidates[0] +} + +func (s *LevelScene) drawMinimap(g *Game, screen *ebiten.Image, box image.Rectangle, hasBox bool) { + a := g.Assets + r := s.run + l := r.Level + sc := minimapScale(l) + mapW, mapH := l.Width()*sc, l.Height()*sc + + // Avgångslistan: ett spår per rad. + type dep struct { + track string + at bool + secs float64 + } + var deps []dep + for _, tr := range l.Trains { + at, secs := tr.Schedule.Status(r.Time) + deps = append(deps, dep{tr.Track, at, secs}) + } + sort.Slice(deps, func(i, j int) bool { return deps[i].track < deps[j].track }) + + w := max(mapW, 44) + 6 + h := mapH + 6 + len(deps)*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) + rc := minimapRect(w, h, box, hasBox, playerRect) + + 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 + for y := 0; y < l.Height(); y++ { + for x := 0; x < l.Width(); x++ { + p := level.Point{X: x, Y: y} + if !r.Fog.Visible(l, p) { + continue + } + t := l.At(x, y) + c := minimapColors[t] + if (t == level.Platform || t == level.WrongPlatform) && r.TrainVisible(t) { + c = color.RGBA{255, 255, 255, 255} // tåget är inne + } + vector.DrawFilledRect(screen, float32(x0+x*sc), float32(y0+y*sc), float32(sc), float32(sc), c, false) + } + } + // Sällskapet: följarna i sina toner, spelaren blinkande vit. + for i := len(r.Party.Followers()); i >= 1; i-- { + from, _ := r.Party.FollowerTiles(i) + 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) + } + if math.Mod(r.Time, 0.6) < 0.4 { + pt := r.Player.Tile + vector.DrawFilledRect(screen, float32(x0+pt.X*sc), float32(y0+pt.Y*sc), float32(sc), float32(sc), colText, false) + } + // Avgångar: "2 ●6s" = inne, avgår om 6 s; "1 ○14s" = kommer om 14 s. + for i, d := range deps { + mark, col := "○", colDim + if d.at { + mark, col = "●", colGood + } + a.Draw(screen, fmt.Sprintf("%s %s%.0fs", d.track, mark, math.Ceil(d.secs)), x0, y0+mapH+2+i*LineH, col) + } + _ = sim.FogRadius +} diff --git a/internal/game/scene_level.go b/internal/game/scene_level.go index 7500093..3de34b1 100644 --- a/internal/game/scene_level.go +++ b/internal/game/scene_level.go @@ -2,6 +2,7 @@ package game import ( "fmt" + "image" "image/color" "math" "path/filepath" @@ -26,10 +27,11 @@ const ( // LevelScene är själva spelandet av en bana. All spellogik ligger i sim.Run; // scenen översätter input och ritar. type LevelScene struct { - id string - run *sim.Run - cam *sim.Camera - animT float64 + id string + run *sim.Run + cam *sim.Camera + animT float64 + hideMap bool // M döljer/visar minikartan } // NewLevelScene laddar banan och ställer spelaren på startrutan. know är @@ -77,6 +79,9 @@ func (s *LevelScene) Update(g *Game) error { if helpJustPressed() { s.run.AskHelp() } + if mapJustPressed() { + s.hideMap = !s.hideMap + } s.run.Step(dt, dirInput(), confirmJustPressed()) if s.run.Player.Moving() { s.animT += dt @@ -219,7 +224,7 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) { s.drawMeters(g, screen) if r.Outcome != sim.Ongoing { - return + return // resultatpanelen tar över; ingen inforuta eller minikarta } lines, accent, bar := s.infoLines(a) if entry := a.Dict.Lookup(r.Learned); entry != nil && r.Time-r.LearnedAt < learnedShowSec { @@ -236,20 +241,30 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) { } lines = append(lines, a.wrapLine(plainLine(strings.Join(parts, " "), colWarn), ScreenW-8)...) } - if len(lines) == 0 { + + // Inforutans läge: nederkant, men flyttas upp under HUD:en om spelaren + // (eller tåget under hen) annars skulle hamna bakom den. + var box image.Rectangle + hasBox := len(lines) > 0 + if hasBox { + h := 8 + LineH*len(lines) + if bar { + h += 8 + } + _, py := r.Player.WorldPos(TileSize) + y := ScreenH - h + if int(py-s.cam.Y)+2*TileSize > y { + y = 24 + } + box = image.Rect(0, y, ScreenW, y+h) + } + if !s.hideMap { + s.drawMinimap(g, screen, box, hasBox) + } + if !hasBox { return } - h := 8 + LineH*len(lines) - if bar { - h += 8 - } - // Rutan ligger i nederkant, men flyttas upp under HUD:en om spelaren - // (eller tåget under hen) annars skulle hamna bakom den. - _, py := r.Player.WorldPos(TileSize) - y := ScreenH - h - if int(py-s.cam.Y)+2*TileSize > y { - y = 24 - } + y, h := box.Min.Y, box.Dy() vector.DrawFilledRect(screen, 0, float32(y), ScreenW, float32(h), colPanel, false) vector.DrawFilledRect(screen, 0, float32(y), ScreenW, 2, accent, false) a.drawLines(screen, lines, 4, y+4) diff --git a/internal/game/scene_result.go b/internal/game/scene_result.go index 481e734..ac60c71 100644 --- a/internal/game/scene_result.go +++ b/internal/game/scene_result.go @@ -42,7 +42,7 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) { r.from.Draw(g, screen) // banan i bakgrunden run := r.from.run a := g.Assets - vector.DrawFilledRect(screen, 20, 36, ScreenW-40, 112, colPanel, false) + vector.DrawFilledRect(screen, 20, 36, ScreenW-40, 120, colPanel, false) accent, headline := colBad, a.T("level.wrong_train") if run.Outcome == sim.Won { @@ -71,8 +71,11 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) { } } y += 4 - a.Draw(screen, fmt.Sprintf("%s %.1fs (%s %.0fs) %s %d %s %d %s %d¥", a.T("hud.time"), run.Time, a.T("result.par"), run.ParSec(), - a.T("hud.steps"), run.Steps, a.T("result.gates"), run.GatePasses, a.T("result.card"), run.Card.Balance), 28, y, colText) + a.Draw(screen, fmt.Sprintf("%s %.1fs (%s %.0fs) %s %d %s %.0f %%", a.T("hud.time"), run.Time, a.T("result.par"), run.ParSec(), + a.T("hud.steps"), run.Steps, a.T("result.explored"), 100*run.Fog.Explored(run.Level)), 28, y, colText) + y += LineH + a.Draw(screen, fmt.Sprintf("%s %d %s %d¥ %s %d", a.T("result.gates"), run.GatePasses, a.T("result.card"), run.Card.Balance, + a.T("result.served"), run.Served), 28, y, colText) y += LineH if run.Outcome == sim.Won { msg := fmt.Sprintf("+%d %s", run.PointsEarned, a.T("words.points")) @@ -84,5 +87,5 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) { } a.Draw(screen, fmt.Sprintf("%s Lv%d %d %s %s %d (%d %s)", a.T("hud.japanese"), run.Know.Level(), run.Know.Count(), a.T("hud.words"), a.T("words.points"), run.Know.Points, run.Know.Helps, a.T("result.helps")), 28, y, colDim) - a.Draw(screen, a.T("result.hint"), 28, 130, colDim) + a.Draw(screen, a.T("result.hint"), 28, 141, colDim) } diff --git a/internal/sim/fog.go b/internal/sim/fog.go new file mode 100644 index 0000000..fdb7fe4 --- /dev/null +++ b/internal/sim/fog.go @@ -0,0 +1,110 @@ +package sim + +import "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" + +// FogRadius är hur många steg från spelaren som avslöjas (gångavstånd, så +// väggar skymmer – man ser längs gången, inte genom väggen). +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 + seen []bool + Radius int + count int +} + +// NewFog skapar en tom dimma för banan. +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} +} + +func (f *Fog) idx(p level.Point) (int, bool) { + if p.X < 0 || p.Y < 0 || p.X >= f.w || p.Y >= f.h { + return 0, false + } + return p.Y*f.w + p.X, true +} + +// Seen rapporterar om rutan har setts. +func (f *Fog) Seen(p level.Point) bool { + i, ok := f.idx(p) + return ok && f.seen[i] +} + +func (f *Fog) mark(p level.Point) { + if i, ok := f.idx(p); ok && !f.seen[i] { + f.seen[i] = true + f.count++ + } +} + +// 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). +func (f *Fog) Reveal(l *level.Level, from level.Point) { + dist := map[level.Point]int{from: 0} + queue := []level.Point{from} + for len(queue) > 0 { + p := queue[0] + queue = queue[1:] + f.mark(p) + 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() { + f.mark(n) // gränsande vägg + } + } + } + if dist[p] >= f.Radius { + 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() { + continue + } + dist[n] = dist[p] + 1 + queue = append(queue, n) + } + } +} + +// 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: + return true + } + return false +} + +// Visible rapporterar om rutan ska ritas på minikartan. +func (f *Fog) Visible(l *level.Level, p level.Point) bool { + return f.Seen(p) || Landmark(l, p) +} + +// Count är antalet sedda rutor (inkl. väggar). +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++ + } + } + } + if total == 0 { + return 0 + } + return float64(seen) / float64(total) +} diff --git a/internal/sim/fog_test.go b/internal/sim/fog_test.go new file mode 100644 index 0000000..ff9f15a --- /dev/null +++ b/internal/sim/fog_test.go @@ -0,0 +1,92 @@ +package sim + +import ( + "testing" + + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng" +) + +const fogSample = `{ + "id": "fog", "name": {"sv": "Fog"}, "difficulty": 1, "destination": {"sv": "x"}, + "grid": [ + "#########", + "#S......#", + "#.#####.#", + "#.#...#.#", + "#.#.#.#.#", + "#.#.#P#.#", + "#.#.###.#", + "#.......#", + "#########" + ], + "trains": [{"platform": "P", "track": "1", "line": {"en": "A"}, "destination": {"en": "B"}, "correct": true}] +}` + +func TestFogRevealsAlongCorridorsNotThroughWalls(t *testing.T) { + l, err := level.Parse([]byte(fogSample)) + if err != nil { + t.Fatal(err) + } + f := NewFog(l, 3) + f.Reveal(l, pt(1, 1)) + for _, p := range []level.Point{pt(1, 1), pt(4, 1), pt(1, 4), pt(2, 1)} { + if !f.Seen(p) { + t.Errorf("%v inom 3 steg ska vara sedd", p) + } + } + // (2,2) är vägg bredvid gången -> sedd; (3,3) är golv men 2 steg bort fågelvägen + // och mycket längre gångvägen -> inte sedd. + if !f.Seen(pt(2, 2)) { + t.Error("väggen vid gången ska synas") + } + if f.Seen(pt(3, 3)) || f.Seen(pt(5, 1)) || f.Seen(pt(1, 5)) { + t.Errorf("för långt bort ska inte vara sett: (3,3)=%v (5,1)=%v (1,5)=%v", f.Seen(pt(3, 3)), f.Seen(pt(5, 1)), f.Seen(pt(1, 5))) + } + if f.Seen(pt(-1, 0)) || f.Seen(pt(99, 99)) { + t.Error("utanför kartan är aldrig sett") + } +} + +func TestLandmarksAlwaysVisible(t *testing.T) { + l, _ := level.Parse([]byte(fogSample)) + f := NewFog(l, FogRadius) + if f.Count() != 0 { + t.Fatal("tom dimma från start") + } + if !f.Visible(l, pt(5, 5)) || !f.Visible(l, pt(1, 1)) { + t.Fatal("perrong och start ska alltid synas") + } + if f.Visible(l, pt(7, 7)) { + t.Fatal("vanligt golv långt bort ska vara dolt") + } + if Landmark(l, pt(7, 7)) || !Landmark(l, pt(5, 5)) { + t.Fatal("Landmark") + } +} + +func TestFogGrowsWithMovementAndResetsPerRun(t *testing.T) { + l := loadLevel(t, "001-flygplatsen") + r := NewRun(l, RunOptions{Seed: rng.FromInt(1)}) + start := r.Fog.Count() + if start == 0 || !r.Fog.Seen(l.SpawnPoint()) { + t.Fatal("starten ska vara avslöjad direkt") + } + e0 := r.Fog.Explored(l) + walk(t, r, l.Path(r.Player.Tile, l.Find(level.Sign)[1], r.CanEnter)) + if r.Fog.Count() <= start || r.Fog.Explored(l) <= e0 { + t.Fatalf("dimman ska lätta när man går: %d -> %d", start, r.Fog.Count()) + } + if e := r.Fog.Explored(l); e <= 0 || e > 1 { + t.Fatalf("Explored=%.2f", e) + } + fresh := NewRun(l, RunOptions{Seed: rng.FromInt(1)}) + if fresh.Fog.Count() != start { + t.Fatal("ny omgång = ny dimma") + } + // Gates är riktmärken men ändå inte "sedda" förrän man är där. + gate := l.Find(level.Gate)[0] + if fresh.Fog.Seen(gate) || !fresh.Fog.Visible(l, gate) { + t.Fatal("spärren syns som riktmärke utan att vara sedd") + } +} diff --git a/internal/sim/run.go b/internal/sim/run.go index 4075c28..0f9bec2 100644 --- a/internal/sim/run.go +++ b/internal/sim/run.go @@ -70,6 +70,9 @@ type Run struct { LearnedDest []string // resmålets ord som lärdes in tack vare bra resultat parSec float64 + // Minikartans dimma: vad spelaren sett i den här omgången. + Fog *Fog + // Sällskapet (lämmeltåget) och pågående toalettbesök. Party *Party Facility FacilityState @@ -142,7 +145,9 @@ func NewRun(l *level.Level, o RunOptions) *Run { Party: NewParty(l.Party, l.SpawnPoint(), o.Seed.Derive("party")), baseSpeed: o.Speed, NoticeAt: -1, + Fog: NewFog(l, FogRadius), } + r.Fog.Reveal(l, l.SpawnPoint()) if r.parSec <= 0 { n := len(l.Path(l.SpawnPoint(), l.CorrectPlatform(), l.CanEnter)) r.parSec = float64(max(0, n-1))*ParSecPerTile + ParSlackSec @@ -196,6 +201,7 @@ func (r *Run) Step(dt float64, want Dir, board bool) { r.Steps += r.Player.Step(dt, want, r.CanEnter) if r.Player.Tile != before { r.Party.OnLeaderArrived(r.Player.Tile) + r.Fog.Reveal(r.Level, r.Player.Tile) } logged := len(r.Party.Log) r.Party.Advance(dt, r.Time) diff --git a/resources/text/en.json b/resources/text/en.json index 238a199..82673f7 100644 --- a/resources/text/en.json +++ b/resources/text/en.json @@ -18,7 +18,7 @@ "menu.hint": "Arrows/WASD: select Enter: start L: word list Esc: quit", "hud.time": "Time", "hud.steps": "Steps", - "hud.controls": "Esc: menu", + "hud.controls": "Esc: menu M: map", "level.wrong_train": "Wrong train!", "result.hint": "R: replay L: word list Enter/Esc: menu", "hud.card": "Card", @@ -58,5 +58,7 @@ "hud.toilet_busy": "In the toilet…", "hud.toilet_none": "Nobody needs the toilet right now.", "hud.kiosk_prompt": "Enter: buy food/drink for %d person(s), %d¥", - "hud.kiosk_none": "Nobody is hungry or thirsty right now." + "hud.kiosk_none": "Nobody is hungry or thirsty right now.", + "result.explored": "Explored", + "result.served": "served" } diff --git a/resources/text/sv.json b/resources/text/sv.json index 6041f55..710d592 100644 --- a/resources/text/sv.json +++ b/resources/text/sv.json @@ -18,7 +18,7 @@ "menu.hint": "Pilar/WASD: välj Enter: starta L: ordlista Esc: avsluta", "hud.time": "Tid", "hud.steps": "Steg", - "hud.controls": "Esc: meny", + "hud.controls": "Esc: meny M: karta", "level.wrong_train": "Fel tåg!", "result.hint": "R: spela om L: ordlista Enter/Esc: meny", "hud.card": "Kort", @@ -58,5 +58,7 @@ "hud.toilet_busy": "På toaletten…", "hud.toilet_none": "Ingen behöver toa just nu.", "hud.kiosk_prompt": "Enter: köp mat/dryck åt %d person(er), %d¥", - "hud.kiosk_none": "Ingen är hungrig eller törstig just nu." + "hud.kiosk_none": "Ingen är hungrig eller törstig just nu.", + "result.explored": "Utforskat", + "result.served": "försörjda" }