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:
2026-10-23 18:52:10 +02:00
commit c1f9546af6
32 changed files with 2165 additions and 0 deletions

228
internal/level/level.go Normal file
View 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
}

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