M5: minikarta med dimma, riktmärken och avgångslista
All checks were successful
release / build-release (push) Successful in 1m40s

- sim.Fog: avslöjar tre gångsteg från spelaren plus gränsande väggar (ser
  längs gången, inte genom väggen); riktmärken (perronger, start, spärrar)
  syns alltid; Explored ger utforskad andel; nollställs per omgång
- game/minimap: 1–3 px per ruta i det hörn som varken täcker inforutan
  eller spelaren, perrong vit när tåget är inne, spelaren blinkar,
  följarna i sina toner, avgångslista per spår (● inne / ○ kommer);
  M döljer/visar
- resultatskärmen visar utforskad andel och antal försörjda; layout justerad
- docs/README/wiki uppdaterade

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 09:28:01 +02:00
parent daa2089eda
commit 3f56020777
21 changed files with 419 additions and 39 deletions

View File

@@ -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+ 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:** 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 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, språkpoäng i ordlistan. Lämmeltåget följer efter dig med sina behov, och
törstiga och måste på toa, oftare ju fler ni är, och du löser det vid kiosker minikartan visar bara avgångar och riktmärken tills du utforskat stationen.
och toaletter. Nästa är M5: minikartan. Se [doc/roadmap.md](doc/roadmap.md). 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) ![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, 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 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) ![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 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 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 ordlistan, M döljer/visar minikartan, Esc går tillbaka, R spelar om. Gamepad
fungerar: d-pad/vänster spak, A = välj, B = tillbaka, Y = hjälp, X = ordlista. med standardlayout fungerar: d-pad/vänster spak, A = välj, B = tillbaka,
Y = hjälp, X = ordlista, Back/Select = karta.
### Verktyg ### Verktyg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -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` | `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` | `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` | `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 | | `internal/archtest` | `TestOnlyPresentationPackagesImportEbitengine` | Beroenderegeln ovan |
### Planerade tester per milstolpe ### 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 | | ~~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 | | ~~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 | | ~~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 | | 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 | | 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 | | 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 |

View File

@@ -33,7 +33,7 @@ brasse-linux01-runnern, inte Pi5:n.
## Milstolpar ## Milstolpar
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 → M8 progression/sparfiler → M9 navigeringslägen → 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. 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).

View File

@@ -58,7 +58,7 @@ metro-puzzle-jp/
│ ├── res/ hittar och läser resources/ (ren Go) │ ├── res/ hittar och läser resources/ (ren Go)
│ ├── rng/ seedad slump (ren Go) │ ├── rng/ seedad slump (ren Go)
│ ├── level/ banformat, validering, BFS (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 │ ├── 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 │ ├── game/ Ebitengine-loop, scener, input, rendering, skriptläge ← får använda Ebitengine
│ ├── archtest/ beroenderegler │ ├── 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 - **Skjutet till M7:** mätarnas värde vid målgång påverkar poängen; debuffar
vid tomma mätare. vid tomma mätare.
### M5 – Minikarta och utforskning ### M5 – Minikarta och utforskning ✓ (klar 2026-10-24)
- **Mål:** orientering utan att avslöja labyrinten. - **Mål:** orientering utan att avslöja labyrinten.
- Minikarta som visar avgångar och riktmärken från start och fylls i när - **Dimma** (`sim.Fog`): när spelaren når en ruta avslöjas allt inom tre
man utforskar. Nollställs per bana och omgång. *steg* via passerbara rutor plus de väggar som gränsar till dem – man ser
- **Teknik:** `internal/sim/fog` (ren Go), rendering i `internal/game`. 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 ### M6 – Kort, biljetter, telefon och minispel

View File

@@ -81,6 +81,11 @@ func wordsJustPressed() bool {
return virtual.pressed["l"] || inpututil.IsKeyJustPressed(ebiten.KeyL) || gamepadJustPressed(ebiten.StandardGamepadButtonLeftRight) 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. // menuMove returnerar -1/+1 när användaren just tryckte upp/ner i en meny.
func menuMove() int { func menuMove() int {
switch { switch {

126
internal/game/minimap.go Normal file
View File

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

View File

@@ -2,6 +2,7 @@ package game
import ( import (
"fmt" "fmt"
"image"
"image/color" "image/color"
"math" "math"
"path/filepath" "path/filepath"
@@ -26,10 +27,11 @@ const (
// LevelScene är själva spelandet av en bana. All spellogik ligger i sim.Run; // LevelScene är själva spelandet av en bana. All spellogik ligger i sim.Run;
// scenen översätter input och ritar. // scenen översätter input och ritar.
type LevelScene struct { type LevelScene struct {
id string id string
run *sim.Run run *sim.Run
cam *sim.Camera cam *sim.Camera
animT float64 animT float64
hideMap bool // M döljer/visar minikartan
} }
// NewLevelScene laddar banan och ställer spelaren på startrutan. know är // NewLevelScene laddar banan och ställer spelaren på startrutan. know är
@@ -77,6 +79,9 @@ func (s *LevelScene) Update(g *Game) error {
if helpJustPressed() { if helpJustPressed() {
s.run.AskHelp() s.run.AskHelp()
} }
if mapJustPressed() {
s.hideMap = !s.hideMap
}
s.run.Step(dt, dirInput(), confirmJustPressed()) s.run.Step(dt, dirInput(), confirmJustPressed())
if s.run.Player.Moving() { if s.run.Player.Moving() {
s.animT += dt s.animT += dt
@@ -219,7 +224,7 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
s.drawMeters(g, screen) s.drawMeters(g, screen)
if r.Outcome != sim.Ongoing { if r.Outcome != sim.Ongoing {
return return // resultatpanelen tar över; ingen inforuta eller minikarta
} }
lines, accent, bar := s.infoLines(a) lines, accent, bar := s.infoLines(a)
if entry := a.Dict.Lookup(r.Learned); entry != nil && r.Time-r.LearnedAt < learnedShowSec { 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)...) 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 return
} }
h := 8 + LineH*len(lines) y, h := box.Min.Y, box.Dy()
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
}
vector.DrawFilledRect(screen, 0, float32(y), ScreenW, float32(h), colPanel, false) vector.DrawFilledRect(screen, 0, float32(y), ScreenW, float32(h), colPanel, false)
vector.DrawFilledRect(screen, 0, float32(y), ScreenW, 2, accent, false) vector.DrawFilledRect(screen, 0, float32(y), ScreenW, 2, accent, false)
a.drawLines(screen, lines, 4, y+4) a.drawLines(screen, lines, 4, y+4)

View File

@@ -42,7 +42,7 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) {
r.from.Draw(g, screen) // banan i bakgrunden r.from.Draw(g, screen) // banan i bakgrunden
run := r.from.run run := r.from.run
a := g.Assets 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") accent, headline := colBad, a.T("level.wrong_train")
if run.Outcome == sim.Won { if run.Outcome == sim.Won {
@@ -71,8 +71,11 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) {
} }
} }
y += 4 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.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.gates"), run.GatePasses, a.T("result.card"), run.Card.Balance), 28, y, colText) 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 y += LineH
if run.Outcome == sim.Won { if run.Outcome == sim.Won {
msg := fmt.Sprintf("+%d %s", run.PointsEarned, a.T("words.points")) 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.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.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)
} }

110
internal/sim/fog.go Normal file
View File

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

92
internal/sim/fog_test.go Normal file
View File

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

View File

@@ -70,6 +70,9 @@ type Run struct {
LearnedDest []string // resmålets ord som lärdes in tack vare bra resultat LearnedDest []string // resmålets ord som lärdes in tack vare bra resultat
parSec float64 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. // Sällskapet (lämmeltåget) och pågående toalettbesök.
Party *Party Party *Party
Facility FacilityState Facility FacilityState
@@ -142,7 +145,9 @@ func NewRun(l *level.Level, o RunOptions) *Run {
Party: NewParty(l.Party, l.SpawnPoint(), o.Seed.Derive("party")), Party: NewParty(l.Party, l.SpawnPoint(), o.Seed.Derive("party")),
baseSpeed: o.Speed, baseSpeed: o.Speed,
NoticeAt: -1, NoticeAt: -1,
Fog: NewFog(l, FogRadius),
} }
r.Fog.Reveal(l, l.SpawnPoint())
if r.parSec <= 0 { if r.parSec <= 0 {
n := len(l.Path(l.SpawnPoint(), l.CorrectPlatform(), l.CanEnter)) n := len(l.Path(l.SpawnPoint(), l.CorrectPlatform(), l.CanEnter))
r.parSec = float64(max(0, n-1))*ParSecPerTile + ParSlackSec 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) r.Steps += r.Player.Step(dt, want, r.CanEnter)
if r.Player.Tile != before { if r.Player.Tile != before {
r.Party.OnLeaderArrived(r.Player.Tile) r.Party.OnLeaderArrived(r.Player.Tile)
r.Fog.Reveal(r.Level, r.Player.Tile)
} }
logged := len(r.Party.Log) logged := len(r.Party.Log)
r.Party.Advance(dt, r.Time) r.Party.Advance(dt, r.Time)

View File

@@ -18,7 +18,7 @@
"menu.hint": "Arrows/WASD: select Enter: start L: word list Esc: quit", "menu.hint": "Arrows/WASD: select Enter: start L: word list Esc: quit",
"hud.time": "Time", "hud.time": "Time",
"hud.steps": "Steps", "hud.steps": "Steps",
"hud.controls": "Esc: menu", "hud.controls": "Esc: menu M: map",
"level.wrong_train": "Wrong train!", "level.wrong_train": "Wrong train!",
"result.hint": "R: replay L: word list Enter/Esc: menu", "result.hint": "R: replay L: word list Enter/Esc: menu",
"hud.card": "Card", "hud.card": "Card",
@@ -58,5 +58,7 @@
"hud.toilet_busy": "In the toilet…", "hud.toilet_busy": "In the toilet…",
"hud.toilet_none": "Nobody needs the toilet right now.", "hud.toilet_none": "Nobody needs the toilet right now.",
"hud.kiosk_prompt": "Enter: buy food/drink for %d person(s), %d¥", "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"
} }

View File

@@ -18,7 +18,7 @@
"menu.hint": "Pilar/WASD: välj Enter: starta L: ordlista Esc: avsluta", "menu.hint": "Pilar/WASD: välj Enter: starta L: ordlista Esc: avsluta",
"hud.time": "Tid", "hud.time": "Tid",
"hud.steps": "Steg", "hud.steps": "Steg",
"hud.controls": "Esc: meny", "hud.controls": "Esc: meny M: karta",
"level.wrong_train": "Fel tåg!", "level.wrong_train": "Fel tåg!",
"result.hint": "R: spela om L: ordlista Enter/Esc: meny", "result.hint": "R: spela om L: ordlista Enter/Esc: meny",
"hud.card": "Kort", "hud.card": "Kort",
@@ -58,5 +58,7 @@
"hud.toilet_busy": "På toaletten…", "hud.toilet_busy": "På toaletten…",
"hud.toilet_none": "Ingen behöver toa just nu.", "hud.toilet_none": "Ingen behöver toa just nu.",
"hud.kiosk_prompt": "Enter: köp mat/dryck åt %d person(er), %d¥", "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"
} }