M0: skelett, byggkedja och dokumentation för JP Subway Pussel
- Go 1.27 + Ebitengine v2.10, intern upplösning 320x180 med heltalsskalning - internal/res (resursuppslag), internal/rng (seedad slump), internal/level (banformat, validering, BFS-lösbarhet) med tester; internal/archtest kontrollerar att bara presentationslagret beror på Ebitengine - tools/build: debug -> build/debug/, release -> build/release/<os>-<arch>/ med resources/ bredvid binären; Windows korsbyggs utan C-verktyg - VS Code tasks (build/test/release) och launch (dlv) - Första banan 001-flygplatsen, sv/en-texter, skyltordbok, placeholder-sprite - doc/: spelbeskrivning (renskriven), mål och tester, roadmap med teknikval (M0–M12), plan.md; README med bygg- och beroendeinformation Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
228
internal/level/level.go
Normal file
228
internal/level/level.go
Normal file
@@ -0,0 +1,228 @@
|
||||
// Package level definierar banformatet och ren spellogik kring det
|
||||
// (validering, framkomlighet). Paketet importerar inte Ebitengine och
|
||||
// kan därför testas utan fönster.
|
||||
//
|
||||
// Banor lagras som JSON i resources/data/levels/. Rutnätet är en lista av
|
||||
// lika långa strängar där varje tecken är en ruta (se Tile-konstanterna).
|
||||
package level
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Tile är ett tecken i rutnätet.
|
||||
type Tile byte
|
||||
|
||||
// Tecknen som får förekomma i ett rutnät.
|
||||
const (
|
||||
Wall Tile = '#' // vägg, ej passerbar
|
||||
Floor Tile = '.' // golv
|
||||
Spawn Tile = 'S' // startpunkt (exakt en)
|
||||
Platform Tile = 'P' // perrong med ett tåg (målet om tåget är rätt)
|
||||
WrongPlatform Tile = 'p' // perrong med fel tåg
|
||||
Gate Tile = 'G' // spärr (kräver kort/biljett)
|
||||
TicketMachine Tile = 'T' // biljett-/påfyllningsautomat
|
||||
Sign Tile = 'i' // skylt
|
||||
)
|
||||
|
||||
var knownTiles = map[Tile]bool{
|
||||
Wall: true, Floor: true, Spawn: true, Platform: true, WrongPlatform: true,
|
||||
Gate: true, TicketMachine: true, Sign: true,
|
||||
}
|
||||
|
||||
// Passable rapporterar om en ruta går att gå på (utan hänsyn till spärrar).
|
||||
func (t Tile) Passable() bool { return t != Wall }
|
||||
|
||||
// Text är en flerspråkig sträng, t.ex. {"sv": "Flygplatsen", "en": "The Airport", "ja": "空港"}.
|
||||
type Text map[string]string
|
||||
|
||||
// Get hämtar texten på ett språk med fallback till engelska, sedan vilket som helst.
|
||||
func (t Text) Get(lang string) string {
|
||||
if s, ok := t[lang]; ok {
|
||||
return s
|
||||
}
|
||||
if s, ok := t["en"]; ok {
|
||||
return s
|
||||
}
|
||||
for _, s := range t {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Train är ett tåg (eller en buss) som avgår från en perrong i banan.
|
||||
type Train struct {
|
||||
Platform string `json:"platform"` // vilket perrong-tecken (P/p) tåget står vid
|
||||
Line Text `json:"line"` // linjens namn
|
||||
Destination Text `json:"destination"` // slutstation
|
||||
Correct bool `json:"correct"` // tar spelaren till rätt resmål
|
||||
}
|
||||
|
||||
// Level är en bana.
|
||||
type Level struct {
|
||||
ID string `json:"id"`
|
||||
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
|
||||
Grid []string `json:"grid"`
|
||||
Trains []Train `json:"trains"`
|
||||
}
|
||||
|
||||
// Point är en rutkoordinat.
|
||||
type Point struct{ X, Y int }
|
||||
|
||||
// Parse avkodar och validerar en bana.
|
||||
func Parse(data []byte) (*Level, error) {
|
||||
var l Level
|
||||
if err := json.Unmarshal(data, &l); err != nil {
|
||||
return nil, fmt.Errorf("level: ogiltig JSON: %w", err)
|
||||
}
|
||||
if err := l.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
// Width och Height i rutor.
|
||||
func (l *Level) Width() int {
|
||||
if len(l.Grid) == 0 {
|
||||
return 0
|
||||
}
|
||||
return len(l.Grid[0])
|
||||
}
|
||||
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]) {
|
||||
return Wall
|
||||
}
|
||||
return Tile(l.Grid[y][x])
|
||||
}
|
||||
|
||||
// Find returnerar alla rutor av en viss typ.
|
||||
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})
|
||||
}
|
||||
}
|
||||
}
|
||||
return pts
|
||||
}
|
||||
|
||||
// SpawnPoint returnerar startrutan (valideringen garanterar att den finns).
|
||||
func (l *Level) SpawnPoint() Point { return l.Find(Spawn)[0] }
|
||||
|
||||
// Validate kontrollerar att banan är rimlig: rektangulär, bara kända tecken,
|
||||
// exakt en start, minst en perrong, och att varje tåg står vid ett
|
||||
// perrong-tecken som finns i rutnätet.
|
||||
func (l *Level) Validate() error {
|
||||
if l.ID == "" {
|
||||
return fmt.Errorf("level: saknar id")
|
||||
}
|
||||
if len(l.Grid) == 0 {
|
||||
return fmt.Errorf("level %s: tomt rutnät", l.ID)
|
||||
}
|
||||
w := len(l.Grid[0])
|
||||
spawns, platforms := 0, 0
|
||||
for y, row := range l.Grid {
|
||||
if len(row) != w {
|
||||
return fmt.Errorf("level %s: rad %d har längd %d, förväntade %d", l.ID, y, len(row), w)
|
||||
}
|
||||
for x := 0; x < len(row); x++ {
|
||||
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)
|
||||
}
|
||||
switch t {
|
||||
case Spawn:
|
||||
spawns++
|
||||
case Platform, WrongPlatform:
|
||||
platforms++
|
||||
}
|
||||
}
|
||||
}
|
||||
if spawns != 1 {
|
||||
return fmt.Errorf("level %s: förväntade exakt en start (S), hittade %d", l.ID, spawns)
|
||||
}
|
||||
if platforms == 0 {
|
||||
return fmt.Errorf("level %s: saknar perronger (P/p)", l.ID)
|
||||
}
|
||||
if len(l.Trains) == 0 {
|
||||
return fmt.Errorf("level %s: saknar tåg", l.ID)
|
||||
}
|
||||
correct := 0
|
||||
for i, tr := range l.Trains {
|
||||
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) {
|
||||
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 {
|
||||
correct++
|
||||
}
|
||||
}
|
||||
if correct == 0 {
|
||||
return fmt.Errorf("level %s: inget tåg är markerat som rätt", l.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reachable returnerar alla rutor som nås från start via passerbara rutor
|
||||
// (4-riktningars BFS). Spärrar räknas som passerbara.
|
||||
func (l *Level) Reachable() map[Point]bool {
|
||||
start := l.SpawnPoint()
|
||||
seen := map[Point]bool{start: true}
|
||||
queue := []Point{start}
|
||||
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() {
|
||||
continue
|
||||
}
|
||||
seen[n] = true
|
||||
queue = append(queue, n)
|
||||
}
|
||||
}
|
||||
return seen
|
||||
}
|
||||
|
||||
// Solvable rapporterar om minst en perrong med ett rätt tåg kan nås från start.
|
||||
func (l *Level) Solvable() bool {
|
||||
reach := l.Reachable()
|
||||
for _, tr := range l.Trains {
|
||||
if !tr.Correct {
|
||||
continue
|
||||
}
|
||||
for _, p := range l.Find(Tile(tr.Platform[0])) {
|
||||
if reach[p] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Unreachable listar interaktiva rutor (perronger, automater, skyltar) som
|
||||
// inte går att nå – praktiskt som varning när man bygger banor.
|
||||
func (l *Level) Unreachable() []Point {
|
||||
reach := l.Reachable()
|
||||
var out []Point
|
||||
for _, t := range []Tile{Platform, WrongPlatform, TicketMachine, Sign, Gate} {
|
||||
for _, p := range l.Find(t) {
|
||||
if !reach[p] {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
137
internal/level/level_test.go
Normal file
137
internal/level/level_test.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package level
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.brasse-pc.eu/brasse/jp-subway-pussel/internal/res"
|
||||
)
|
||||
|
||||
const sample = `{
|
||||
"id": "test",
|
||||
"name": {"sv": "Test"},
|
||||
"difficulty": 1,
|
||||
"destination": {"sv": "Mål"},
|
||||
"grid": [
|
||||
"#######",
|
||||
"#S..#P#",
|
||||
"#.#.#.#",
|
||||
"#.#...#",
|
||||
"#######"
|
||||
],
|
||||
"trains": [{"platform": "P", "line": {"en": "A"}, "destination": {"en": "B"}, "correct": true}]
|
||||
}`
|
||||
|
||||
func mustParse(t *testing.T, s string) *Level {
|
||||
t.Helper()
|
||||
l, err := Parse([]byte(s))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func TestParseAndSolve(t *testing.T) {
|
||||
l := mustParse(t, sample)
|
||||
if l.Width() != 7 || l.Height() != 5 {
|
||||
t.Fatalf("storlek %dx%d", l.Width(), l.Height())
|
||||
}
|
||||
if l.SpawnPoint() != (Point{1, 1}) {
|
||||
t.Fatalf("spawn %v", l.SpawnPoint())
|
||||
}
|
||||
if !l.Solvable() {
|
||||
t.Fatal("banan ska vara lösbar")
|
||||
}
|
||||
if u := l.Unreachable(); len(u) != 0 {
|
||||
t.Fatalf("oväntat onåbara rutor: %v", u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsolvableWhenWalledOff(t *testing.T) {
|
||||
l := mustParse(t, strings.Replace(sample, `"#.#...#"`, `"#.#.#.#"`, 1))
|
||||
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}) {
|
||||
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() {
|
||||
t.Fatal("utanför kartan ska räknas som vägg")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationErrors(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"ojämn rad": strings.Replace(sample, `"#.#.#.#"`, `"#.#.#.##"`, 1),
|
||||
"okänt tecken": strings.Replace(sample, `"#.#.#.#"`, `"#.#.#X#"`, 1),
|
||||
"två starter": strings.Replace(sample, `"#S..#P#"`, `"#S.S#P#"`, 1),
|
||||
"ingen start": strings.Replace(sample, `"#S..#P#"`, `"#...#P#"`, 1),
|
||||
"ingen perrong": strings.Replace(sample, `"#S..#P#"`, `"#S..#.#"`, 1),
|
||||
"inget rätt tåg": strings.Replace(sample, `"correct": true`, `"correct": false`, 1),
|
||||
"tåg vid fel perrong": strings.Replace(sample, `"platform": "P"`, `"platform": "p"`, 1),
|
||||
"saknar id": strings.Replace(sample, `"id": "test"`, `"id": ""`, 1),
|
||||
"trasig json": "{",
|
||||
}
|
||||
for name, src := range cases {
|
||||
if _, err := Parse([]byte(src)); err == nil {
|
||||
t.Errorf("%s: förväntade fel", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextFallback(t *testing.T) {
|
||||
tx := Text{"en": "Exit", "ja": "出口"}
|
||||
if tx.Get("ja") != "出口" || tx.Get("sv") != "Exit" {
|
||||
t.Fatal("fel fallback")
|
||||
}
|
||||
if (Text{"ja": "x"}).Get("sv") != "x" {
|
||||
t.Fatal("fallback till godtyckligt språk saknas")
|
||||
}
|
||||
if (Text{}).Get("sv") != "" {
|
||||
t.Fatal("tom text ska ge tom sträng")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllShippedLevels är ett datadrivet test: varje bana under
|
||||
// resources/data/levels måste parsa, validera och vara lösbar.
|
||||
// Lägger man till en bana som inte går att klara faller bygget här.
|
||||
func TestAllShippedLevels(t *testing.T) {
|
||||
dir, err := res.Path("data", "levels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
files, err := filepath.Glob(filepath.Join(dir, "*.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
t.Fatalf("inga banor i %s", dir)
|
||||
}
|
||||
for _, f := range files {
|
||||
t.Run(filepath.Base(f), func(t *testing.T) {
|
||||
b, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l, err := Parse(b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := strings.TrimSuffix(filepath.Base(f), ".json"); l.ID != want {
|
||||
t.Errorf("id %q matchar inte filnamnet %q", l.ID, want)
|
||||
}
|
||||
if !l.Solvable() {
|
||||
t.Error("banan går inte att klara från start")
|
||||
}
|
||||
if u := l.Unreachable(); len(u) > 0 {
|
||||
t.Errorf("onåbara interaktiva rutor: %v", u)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user