From c1f9546af6abd6e90a881654c719c7493798122d Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 23 Oct 2026 18:52:10 +0200 Subject: [PATCH] =?UTF-8?q?M0:=20skelett,=20byggkedja=20och=20dokumentatio?= =?UTF-8?q?n=20f=C3=B6r=20JP=20Subway=20Pussel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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/-/ 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 --- .gitignore | 20 ++ .vscode/extensions.json | 3 + .vscode/launch.json | 43 ++++ .vscode/settings.json | 7 + .vscode/tasks.json | 81 +++++++ README.md | 119 +++++++++++ cmd/jp-subway-pussel/main.go | 42 ++++ doc/mal-och-tester.md | 130 ++++++++++++ doc/plan.md | 64 ++++++ doc/roadmap.md | 233 +++++++++++++++++++++ doc/spelbeskrivning.md | 189 +++++++++++++++++ go.mod | 13 ++ go.sum | 14 ++ internal/archtest/arch_test.go | 58 +++++ internal/buildinfo/buildinfo.go | 12 ++ internal/game/game.go | 135 ++++++++++++ internal/level/level.go | 228 ++++++++++++++++++++ internal/level/level_test.go | 137 ++++++++++++ internal/res/res.go | 135 ++++++++++++ internal/res/res_test.go | 76 +++++++ internal/rng/rng.go | 48 +++++ internal/rng/rng_test.go | 38 ++++ resources/data/README.md | 22 ++ resources/data/levels/001-flygplatsen.json | 35 ++++ resources/sound/README.md | 6 + resources/sprites/README.md | 7 + resources/sprites/placeholder.png | Bin 0 -> 243 bytes resources/text/README.md | 8 + resources/text/en.json | 17 ++ resources/text/jp-dictionary.json | 23 ++ resources/text/sv.json | 17 ++ tools/build/main.go | 205 ++++++++++++++++++ 32 files changed, 2165 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/extensions.json create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 .vscode/tasks.json create mode 100644 README.md create mode 100644 cmd/jp-subway-pussel/main.go create mode 100644 doc/mal-och-tester.md create mode 100644 doc/plan.md create mode 100644 doc/roadmap.md create mode 100644 doc/spelbeskrivning.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/archtest/arch_test.go create mode 100644 internal/buildinfo/buildinfo.go create mode 100644 internal/game/game.go create mode 100644 internal/level/level.go create mode 100644 internal/level/level_test.go create mode 100644 internal/res/res.go create mode 100644 internal/res/res_test.go create mode 100644 internal/rng/rng.go create mode 100644 internal/rng/rng_test.go create mode 100644 resources/data/README.md create mode 100644 resources/data/levels/001-flygplatsen.json create mode 100644 resources/sound/README.md create mode 100644 resources/sprites/README.md create mode 100644 resources/sprites/placeholder.png create mode 100644 resources/text/README.md create mode 100644 resources/text/en.json create mode 100644 resources/text/jp-dictionary.json create mode 100644 resources/text/sv.json create mode 100644 tools/build/main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eee170b --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Byggutdata (förankrat till roten så att tools/build/ inte träffas) +/build/ + +# Go +*.exe +*.test +*.out +*.prof +/vendor/ + +# Sparfiler / lokala inställningar som råkar hamna i repot vid test +/saves/ +*.save.json + +# OS / editor +.DS_Store +Thumbs.db +*~ +*.swp +.idea/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..12a59cb --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["golang.go"] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..fbe884a --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,43 @@ +{ + // F5 = "Debug (dlv)". Kräver VS Code-tillägget golang.go, som installerar + // delve (dlv) åt dig första gången (eller: go install github.com/go-delve/delve/cmd/dlv@latest). + "version": "0.2.0", + "configurations": [ + { + "name": "Debug (dlv)", + "type": "go", + "request": "launch", + "mode": "debug", + "program": "${workspaceFolder}/cmd/jp-subway-pussel", + "cwd": "${workspaceFolder}", + "args": ["-level", "001-flygplatsen"], + "env": {} + }, + { + "name": "Debug: build/debug-binären", + "type": "go", + "request": "launch", + "mode": "exec", + "preLaunchTask": "build", + "program": "${workspaceFolder}/build/debug/jp-subway-pussel", + "windows": { "program": "${workspaceFolder}/build/debug/jp-subway-pussel.exe" }, + "cwd": "${workspaceFolder}/build/debug" + }, + { + "name": "Kör release (linux-amd64)", + "type": "go", + "request": "launch", + "mode": "exec", + "preLaunchTask": "build: release (linux-amd64)", + "program": "${workspaceFolder}/build/release/linux-amd64/jp-subway-pussel", + "cwd": "${workspaceFolder}/build/release/linux-amd64" + }, + { + "name": "Debug: tester i aktuell fil", + "type": "go", + "request": "launch", + "mode": "test", + "program": "${fileDirname}" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..53444d0 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "go.testFlags": ["-count=1"], + "go.lintOnSave": "package", + "go.vetOnSave": "package", + "files.exclude": { "build/": true }, + "[go]": { "editor.formatOnSave": true } +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..b75623e --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,81 @@ +{ + // Byggtasks. Allt går via `go run ./tools/build` så att samma kommandon + // fungerar på Linux och Windows utan make/bash. + // Ctrl+Shift+B = "build" (debug). Terminal > Run Task… för övriga. + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "detail": "Debug-bygge → build/debug/ (standard, Ctrl+Shift+B)", + "type": "process", + "command": "go", + "args": ["run", "./tools/build", "-mode", "debug"], + "group": { "kind": "build", "isDefault": true }, + "problemMatcher": ["$go"], + "presentation": { "reveal": "silent", "clear": true } + }, + { + "label": "build: release (linux-amd64)", + "detail": "Release-bygge → build/release/linux-amd64/", + "type": "process", + "command": "go", + "args": ["run", "./tools/build", "-mode", "release", "-os", "linux", "-arch", "amd64"], + "group": "build", + "problemMatcher": ["$go"], + "presentation": { "clear": true } + }, + { + "label": "build: release (windows-amd64)", + "detail": "Release-bygge (korsbygge, ren Go) → build/release/windows-amd64/", + "type": "process", + "command": "go", + "args": ["run", "./tools/build", "-mode", "release", "-os", "windows", "-arch", "amd64"], + "group": "build", + "problemMatcher": ["$go"], + "presentation": { "clear": true } + }, + { + "label": "build: release (alla plattformar)", + "detail": "Linux + Windows release-byggen", + "dependsOn": ["build: release (linux-amd64)", "build: release (windows-amd64)"], + "dependsOrder": "sequence", + "group": "build", + "problemMatcher": [] + }, + { + "label": "run: debug", + "detail": "Bygg debug och starta spelet (utan debugger)", + "type": "process", + "command": "go", + "args": ["run", "./tools/build", "-mode", "debug", "-run"], + "problemMatcher": ["$go"], + "presentation": { "clear": true } + }, + { + "label": "test", + "detail": "go test ./...", + "type": "process", + "command": "go", + "args": ["test", "./..."], + "group": { "kind": "test", "isDefault": true }, + "problemMatcher": ["$go"], + "presentation": { "clear": true } + }, + { + "label": "test: verbose med race-detektor", + "type": "process", + "command": "go", + "args": ["test", "-race", "-v", "./..."], + "group": "test", + "problemMatcher": ["$go"], + "presentation": { "clear": true } + }, + { + "label": "vet", + "type": "process", + "command": "go", + "args": ["vet", "./..."], + "problemMatcher": ["$go"] + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..b10ea91 --- /dev/null +++ b/README.md @@ -0,0 +1,119 @@ +# JP Subway Pussel + +Pixel art-pusselspel: leda ett sällskap – *lämmeltåget* – genom japanska +tågstationer byggda som labyrinter, hitta rätt tåg och komma fram till rätt +resmål. Nästan alla skyltar är på japanska; ju mer du lär dig, desto mer kan +du läsa. Sällskapet behöver mat, dryck och toapauser, tågkortet måste ha +saldo, telefonen batteri, och på svåra banor tickar klockan. + +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:** M0 – skelett och byggkedja klara (2026-10-23). Se +[doc/roadmap.md](doc/roadmap.md) för vad som byggs härnäst. + +## Bygga + +Enda kravet är **Go ≥ 1.27**. Linux behöver dessutom en C-kompilator och +X11/GL-headers för fönsterhanteringen (Ebitengine/GLFW). Windows-bygget är +ren Go och kan korsbyggas från Linux. + +### Linux + +```bash +# Arch / Garuda +sudo pacman -S --needed go gcc pkgconf mesa libx11 libxrandr libxcursor libxinerama libxi libxxf86vm alsa-lib + +# Debian / Ubuntu +sudo apt install golang gcc libc6-dev pkg-config libgl1-mesa-dev libx11-dev libxrandr-dev libxcursor-dev libxinerama-dev libxi-dev libxxf86vm-dev libasound2-dev +``` + +```bash +git clone /jp-subway-pussel.git && cd jp-subway-pussel +go run ./tools/build # debug → build/debug/ +go run ./tools/build -mode release -os linux # release → build/release/linux-amd64/ +go run ./tools/build -mode release -os windows # korsbygge → build/release/windows-amd64/ +./build/debug/jp-subway-pussel # kör +go test ./... # tester +``` + +### Windows (bygga på Windows) + +Installera Go från . Ingen C-kompilator behövs. + +```powershell +go run ./tools/build -mode release # → build\release\windows-amd64\jp-subway-pussel.exe +``` + +### VS Code + +Öppna mappen med tillägget **Go** (`golang.go`, föreslås automatiskt). + +| Vad | Hur | +|-----|-----| +| Bygga debug | `Ctrl+Shift+B` (tasken `build`) | +| Debugga | `F5` – "Debug (dlv)". Tillägget erbjuder att installera delve första gången. | +| Bygga release | Terminal › Run Task › `build: release (linux-amd64)` / `(windows-amd64)` / `(alla plattformar)` | +| Köra tester | Terminal › Run Task › `test`, eller testknapparna i editorn | + +### Resultatet av ett bygge + +``` +build/ +├── debug/ debug-bygge för den här datorn +│ ├── jp-subway-pussel +│ └── resources/ +└── release/ + ├── linux-amd64/ + │ ├── jp-subway-pussel en strippad binär + │ └── resources/ sprites/ sound/ text/ data/ + └── windows-amd64/ + ├── jp-subway-pussel.exe + └── resources/ +``` + +Spelet letar efter `resources/` bredvid binären, annars uppåt från +arbetskatalogen (så `go run` och F5 fungerar), och `JP_RESOURCES=/sökväg` +överstyr allt. Att distribuera spelet = zippa en plattformsmapp. + +### Flaggor + +``` +jp-subway-pussel -level 001-flygplatsen # starta en viss bana +jp-subway-pussel -scale 3 # fönsterstorlek (320x180 × skala) +jp-subway-pussel -smoke 90 # öppna fönster, avsluta efter 90 bildrutor (smoke-test) +jp-subway-pussel -version +``` + +## Beroenden + +| | Linux | Windows | +|---|---|---| +| **Bygga** | Go ≥ 1.27, gcc, pkgconf, mesa (GL-headers), libx11/libxrandr/libxcursor/libxinerama/libxi/libxxf86vm, alsa-lib | Go ≥ 1.27 | +| **Köra** | libc, samt ett skrivbord med OpenGL (mesa) och X11 eller Wayland (XWayland), ALSA/PipeWire för ljud – laddas dynamiskt vid start | Windows 10+ med DirectX 11 eller OpenGL | +| **Go-moduler** | `github.com/hajimehoshi/ebiten/v2` (spelbibliotek) + dess indirekta beroenden (`purego`, `gomobile`, `x/sys`, `x/sync`, `x/image`) | samma | + +Release-binären för Linux är dynamiskt länkad bara mot libc; GL/X11 laddas +vid körning, så den fungerar på alla vanliga distributioner. + +## Dokumentation + +- [doc/plan.md](doc/plan.md) – navet: mål, stack, milstolpar, öppna frågor +- [doc/spelbeskrivning.md](doc/spelbeskrivning.md) – hela speldesignen +- [doc/roadmap.md](doc/roadmap.md) – teknikval, katalogstruktur, milstolpar i ordning +- [doc/mal-och-tester.md](doc/mal-och-tester.md) – projektmål, teststrategi, checklistor +- `resources/*/README.md` – vad som ska ligga i respektive resursmapp + +## Struktur i korthet + +``` +cmd/jp-subway-pussel/ programstart +internal/game/ Ebitengine-loop och rendering (enda paketet som får känna till Ebitengine) +internal/level/ banformat, validering, framkomlighet +internal/res/ hittar och läser resources/ +internal/rng/ seedad slump +tools/build/ byggverktyget +resources/ sprites/ sound/ text/ data/ – kopieras bredvid binären +doc/ dokumentation +``` diff --git a/cmd/jp-subway-pussel/main.go b/cmd/jp-subway-pussel/main.go new file mode 100644 index 0000000..5bef366 --- /dev/null +++ b/cmd/jp-subway-pussel/main.go @@ -0,0 +1,42 @@ +// jp-subway-pussel – pixel art-pussel om att hitta rätt tåg på japanska stationer. +package main + +import ( + "flag" + "fmt" + "log" + "os" + + "github.com/hajimehoshi/ebiten/v2" + + "gitea.brasse-pc.eu/brasse/jp-subway-pussel/internal/buildinfo" + "gitea.brasse-pc.eu/brasse/jp-subway-pussel/internal/game" +) + +func main() { + levelID := flag.String("level", "001-flygplatsen", "bana att starta (id = filnamn utan .json)") + scale := flag.Int("scale", 4, "fönsterskala (intern upplösning 320x180 × skala)") + smoke := flag.Int("smoke", 0, "avsluta automatiskt efter N bildrutor (smoke-test)") + version := flag.Bool("version", false, "skriv ut version och avsluta") + flag.Parse() + + if *version { + fmt.Printf("jp-subway-pussel %s (%s)\n", buildinfo.Version, buildinfo.Mode) + return + } + + g, err := game.New(game.Options{LevelID: *levelID, SmokeFrames: *smoke}) + if err != nil { + log.Fatalf("kunde inte starta: %v", err) + } + + ebiten.SetWindowSize(game.ScreenW**scale, game.ScreenH**scale) + ebiten.SetWindowTitle("JP Subway Pussel") + ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled) + ebiten.SetVsyncEnabled(true) + + if err := ebiten.RunGame(g); err != nil { + log.Println(err) + os.Exit(1) + } +} diff --git a/doc/mal-och-tester.md b/doc/mal-och-tester.md new file mode 100644 index 0000000..99d9ccc --- /dev/null +++ b/doc/mal-och-tester.md @@ -0,0 +1,130 @@ +# Mål och tester + +## Projektmål + +### Vision + +Ett pixel art-pusselspel där känslan av att vara vilse på en japansk +station är själva spelet – och där man faktiskt blir bättre på att läsa +skyltarna ju mer man spelar. Spelet ska vara en enda binär som är lika lätt +att bygga på Linux som på Windows. + +### Konkreta mål + +1. **Spelbar story** från flygplatsen till flygplatsen, med stigande + svårighet, flerbytesbanor, sidobanor, slutanimation och eftertexter. +2. **En binär + en `resources/`-mapp** per plattform. Inga installers, inga + runtime-beroenden utöver vad ett vanligt skrivbord redan har. +3. **Få byggverktyg.** Go är det enda kravet (Linux behöver dessutom en + C-kompilator och X11/GL-headers för fönsterhanteringen). Allt byggs med + `go run ./tools/build`. +4. **Debug och release i VS Code** med F5 respektive Ctrl+Shift+B, utan + extra skript. +5. **All spellogik testbar utan fönster.** Bara presentationslagret känner + till Ebitengine. +6. **Deterministiskt.** Samma seed ger samma minispel och samma mål i + oändligt läge. +7. **Windows utan omskrivning.** `-os windows` ska vara allt som krävs för + att få en fungerande Windows-binär från Linux. + +### Mätbara mål + +| Mål | Mått | Status 2026-10-23 | +|-----|------|-------------------| +| 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 | 1 av 1 | +| 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 | +| Uppstart | < 2 s till första bildruta | ej mätt | + +## Teststrategi + +### Principer + +- **Ren logik → enhetstester.** Paket under `internal/` som inte är + presentation får inte importera Ebitengine. Det kontrolleras av + `internal/archtest`, som läser beroendegrafen med `go list` och faller om + något annat paket än `internal/game`, `internal/ui`, `internal/audio` + eller `cmd/…` drar in Ebitengine. +- **Data testas som data.** Varje bana, språkfil och ordbok som ligger i + `resources/` valideras av tester, så att trasigt innehåll stoppar bygget + i stället för att krascha spelet. +- **Slump går alltid via `internal/rng`** med en känd seed. Då går + minispel och event-timing att testa deterministiskt. +- **Inga tester kräver display.** Smoke-testet av själva fönstret körs + manuellt (eller på en maskin med skärm) med `-smoke`. + +### Testnivåer + +| Nivå | Vad | Hur | Körs | +|------|-----|-----|------| +| 1. Enhet | Funktioner i ett paket | `*_test.go` bredvid koden | `go test ./...` | +| 2. Data | Allt under `resources/` | Datadrivna tester som läser filerna | `go test ./...` | +| 3. Arkitektur | Beroenderegler | `internal/archtest` | `go test ./...` | +| 4. Simulering | En bot spelar en bana headless | Deterministisk körning av spelloopen utan rendering (kommande) | `go test ./...` | +| 5. Smoke | Binären öppnar fönster och avslutar | `jp-subway-pussel -smoke 90` | manuellt / med skärm | +| 6. Speltest | Känsla, svårighet, buggar | Checklistan nedan | före varje release | + +### Tester som finns i dag + +| Paket | Test | Verifierar | +|-------|------|------------| +| `internal/res` | `TestLocate_*` | Uppslagsordningen env → bredvid binären → uppåt från cwd | +| `internal/res` | `TestDir_RealRepo` | Att repots `resources/` hittas och att sv.json + placeholder.png går att läsa | +| `internal/rng` | `TestDeterministic`, `TestDeriveIsStableAndContextual` | Samma seed → samma sekvens; del-seeds per kontext | +| `internal/level` | `TestParseAndSolve`, `TestUnsolvableWhenWalledOff` | BFS hittar/hittar inte väg till rätt perrong | +| `internal/level` | `TestValidationErrors` | Ojämna rader, okända tecken, fel antal starter, tåg vid saknad perrong … | +| `internal/level` | `TestAllShippedLevels` | Varje bana i `resources/data/levels` parsar, har id = filnamn, är lösbar och har inga onåbara interaktiva rutor | +| `internal/archtest` | `TestOnlyPresentationPackagesImportEbitengine` | Beroenderegeln ovan | + +### Planerade tester per milstolpe + +| Milstolpe (se roadmap) | Tester som ska finnas när den är klar | +|------------------------|----------------------------------------| +| M1 Rörelse & kamera | Kollision mot vägg, rörelse i 4 riktningar, kamera klampas till kartan | +| M2 Stationen | Tidtabell: nästa avgång beräknas rätt; spärr släpper igenom med giltigt kort/biljett och stoppar annars; vinst när rätt tåg tas, förlust vid fel tåg; simuleringstest: bot går kortaste vägen och klarar bana 001 | +| M3 Japanska & skyltar | Delvis översättning ger exakt de ord spelaren kan; andel engelska följer svårighetstabellen; ordboken: alla nycklar har kana/romaji/en/sv; alla skyltord i banorna finns i ordboken; hjälptid = f(japanska-nivå) | +| M4 Lämmeltåget | Mätare sjunker med rätt takt; event-frekvens ökar med gruppstorlek; event väljs deterministiskt per seed | +| M5 Minikarta | Upptäckta rutor markeras, återställs per bana; avgångar syns från start | +| 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 | +| 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 | +| M12 Ljud & polish | Alla ljudfiler som refereras finns och går att avkoda; Windows-binär startar (manuellt) | + +### Acceptanskriterier för "klar" + +En milstolpe är klar när: + +1. Alla tester i raden ovan finns och är gröna. +2. `go vet ./...` och `go test -race ./...` är gröna. +3. Debug- och release-bygget går igenom för Linux och Windows. +4. Roadmapen är uppdaterad med datum och eventuella avsteg. + +### Manuell testchecklista (speltest) + +- [ ] Spelet startar till meny på under 2 s; ESC/avsluta fungerar. +- [ ] Fönstret kan skalas; pixlarna är skarpa (heltalsskalning). +- [ ] Bana 001 går att klara i både listläge och skyltläge. +- [ ] Fel tåg ger tydlig förlust med förklaring. +- [ ] Mätarna syns, sjunker och går att fylla på. +- [ ] Tomt kort tvingar till automaten; minispelet är begripligt första gången. +- [ ] Minikartan fylls i när man går runt och nollställs vid ny bana. +- [ ] Spara mitt i en bana, ladda, och få samma minispelsutfall. +- [ ] Ljud spelas vid ankomst och avgång; inget knaster vid loop. +- [ ] Samma sparfil fungerar på Linux och Windows. + +### Kommandon + +```bash +go test ./... # hela sviten +go test -race -v ./... # utförligt med race-detektor +go test ./internal/level/ -run TestAllShippedLevels -v # bara banorna +go vet ./... +go run ./tools/build -mode debug -run -- -smoke 90 # bygg + smoke-test med fönster +``` diff --git a/doc/plan.md b/doc/plan.md new file mode 100644 index 0000000..68b3023 --- /dev/null +++ b/doc/plan.md @@ -0,0 +1,64 @@ +# jp-subway-pussel – plan + +Navet för projektets dokumentation. Detaljerna ligger i de länkade filerna. + +## Mål + +Ett pixel art-pusselspel där ett sällskap ska hitta rätt tåg genom japanska +stationer byggda som labyrinter, med skyltar på japanska som spelaren lär sig +läsa. Spelet ska vara en enda binär med en resursmapp bredvid, lika lätt att +bygga på Linux som på Windows, utan andra byggverktyg än Go. +Hela spelidén: [spelbeskrivning.md](spelbeskrivning.md). + +## Stack + +Go 1.27 + Ebitengine v2.10, JSON-data, standardbibliotekets tester, +byggverktyg i Go (`go run ./tools/build`), VS Code med `golang.go`. +Motivering och alternativ som valts bort: [roadmap.md › Teknikval](roadmap.md#teknikval). + +## Målplattformar + +Linux x64 först. Windows x64 byggs från dag 1 (korsbygge utan C-verktyg) +men speltestas på riktig Windows först i M12. Linux arm64 (Pi5) är inte ett +mål för spelet, men Gitea-CI kan byggas på Pi5-runnern senare. + +## Leverans + +Mönster A enligt playbooken (nedladdningsbar binär), när projektet ska +delas: `build/release/-/` är redan formen som en release-zip ska +ha. Repot är **lokalt** nu och synkas till Gitea senare (Björn skapar repot +och lägger till `claude` som medlem, därefter `git remote add origin +gitea-claude:brasse/jp-subway-pussel.git`). + +## Milstolpar + +M0 skelett ✓ → M1 rörelse → M2 första spelbara banan → M3 japanska/skyltar +→ 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). +Vad som ska vara testat per milstolpe: [mal-och-tester.md](mal-och-tester.md). + +## Idéer till expansioner (ej beslutade) + +- **Baneditor i spelet** eller ett `tools/levelcheck` som ritar banor som + PNG och rapporterar onåbara rutor – blir troligen nödvändigt i M10. +- **Daglig utmaning:** oändligt läge med dagens datum som seed och en lokal + topplista. +- **Lärande-läge:** en ordlista i menyn över tecken man mött, med + repetition – går hand i hand med skyltsystemet. +- **Lokalt samarbete:** en andra spelare styr en av polarna (delad skärm på + samma tangentbord/gamepad). +- **Steam Deck:** Linux-binären bör fungera som den är; gamepad-stöd från M1 + gör det gratis. +- **Modbara banor:** eftersom banor är JSON i `resources/data/levels` kan + spelare lägga till egna – kräver bara att banväljaren läser mappen. + +## Öppna frågor + +- Repo-namn: behålla `pussel` (svenska) eller byta till `puzzle`? +- "Game box-liknande" banval: tolkat som spelbräde/världskarta – stämmer det? +- Font: PixelMplus (fler kanji, 10/12 px) eller Misaki (8 px, tajtare)? + Avgörs i M3 när skyltar renderas på riktigt. +- Ska Oändligt läge dela sparfil med story eller ha egen? +- Hur många huvudbanor siktar vi på i storyn? (Påverkar M10:s omfattning.) diff --git a/doc/roadmap.md b/doc/roadmap.md new file mode 100644 index 0000000..e954f3c --- /dev/null +++ b/doc/roadmap.md @@ -0,0 +1,233 @@ +# Roadmap och teknikval + +Ordningen nedan är den ordning saker ska byggas i. Varje milstolpe är ett +körbart, testat steg som går att visa upp. Teknikvalen står först eftersom +de styr hur milstolparna byggs. + +## Teknikval + +| Område | Val | Varför | +|--------|-----|--------| +| Språk | **Go 1.27** | En enda installation ger kompilator, pakethanterare, testramverk och formatterare. Snabba byggen. Björn använder redan Go i andra projekt. | +| Spelbibliotek | **Ebitengine v2.10** (`github.com/hajimehoshi/ebiten/v2`) | Byggt för 2D-pixelspel: intern upplösning + heltalsskalning, nearest-neighbour-sampling, inbyggt ljud (OGG/WAV/MP3), tangentbord/gamepad/mus, textrendering med TTF. **Ren Go på Windows** → korsbygge från Linux utan C-verktyg. | +| Tester | Standardbiblioteket `testing` | Inget att installera. `go test ./...` kör allt. | +| Speldata | **JSON** i `resources/data/` | Läsbart, diffbart i git, `encoding/json` i standardbiblioteket. Byt till TOML bara om kommentarer blir ett verkligt behov. | +| Bygg | **`go run ./tools/build`** | Ett Go-program i stället för make/bash/PowerShell. Samma kommando på alla plattformar. Stämplar version och läge, kopierar resurser. | +| IDE | **VS Code + tillägget `golang.go`** | `.vscode/tasks.json` (bygg/test) och `.vscode/launch.json` (F5 med delve). Tillägget installerar delve åt dig. | +| Font | **PixelMplus** (10/12 px) eller **Misaki** (8 px) via `ebiten/v2/text/v2` | Pixelfonter med kana **och kanji**. Fri licens. Krävs för att skyltar ska kunna visas på riktigt. Beslut i M3. | +| Ljud | `ebiten/v2/audio` med OGG Vorbis (musik, jinglar) och WAV (korta effekter) | Ingår i Ebitengine, inga externa bibliotek. | +| Slump | `math/rand/v2` (PCG) genom `internal/rng` | Seedbart, deterministiskt, del-seeds per kontext. | +| Sparfiler | JSON i `os.UserConfigDir()/jp-subway-pussel/` | Rätt plats på både Linux (`~/.config`) och Windows (`%AppData%`). | +| Versionshantering | Git, lokalt nu, Gitea senare | Enligt playbooken; CI-release när projektet är redo att delas. | + +### Varför inte … + +- **Godot:** utmärkt för pixel art, men bygger inte "i VS Code" på samma + sätt, kräver editor + exportmallar, och C#-spåret drar in .NET. Hade + passat om kravet varit "snabbast till prototyp", inte "få byggverktyg". +- **Rust + macroquad/Bevy:** bra, men Windows-korsbygge kräver MinGW eller + MSVC, och Bevy har långa byggtider. Rust-toolchain är en till sak att hålla + i gång på två OS. +- **C++ + raylib/SDL:** CMake + kompilator per plattform, pakethantering + för hand. Precis det "många byggverktyg"-problem vi vill undvika. +- **Love2D (Lua):** inget byggsteg alls, men distribution som en binär + kräver att man "fuserar" ihop filer, och det saknas ett naturligt + testramverk. + +### Regler för Windows-portabilitet (gäller från dag 1) + +1. Bygg sökvägar med `filepath.Join`, aldrig med `/` i strängar. +2. Inga Linux-specifika syscalls eller `/proc`-läsningar. Behövs OS-specifik + kod: lägg den i `*_linux.go` / `*_windows.go`. +3. Sparfiler och konfiguration via `os.UserConfigDir()`, aldrig `~` i + strängar. +4. Resurser hittas relativt binären (`internal/res`), aldrig relativt + arbetskatalogen i release. +5. Textfiler skrivs med `\n`; läsning tål `\r\n`. +6. Varje milstolpe avslutas med `go run ./tools/build -mode release -os windows` + så att Windows-bygget aldrig hinner gå sönder obemärkt. +7. Inget `cgo` i vår egen kod. + +### Katalogstruktur + +``` +jp-subway-pussel/ +├── cmd/jp-subway-pussel/ main.go – flaggor, fönster, startar spelet +├── internal/ +│ ├── buildinfo/ version + läge (sätts av byggverktyget) +│ ├── res/ hittar och läser resources/ (ren Go) +│ ├── rng/ seedad slump (ren Go) +│ ├── level/ banformat, validering, BFS (ren Go) +│ ├── game/ Ebitengine-loop, scener, rendering ← får använda Ebitengine +│ ├── archtest/ beroenderegler +│ └── … kommande: sim/, lang/, nav/, score/, save/, minigame/, ui/, audio/ +├── tools/build/ byggverktyget +├── resources/ kopieras bredvid binären vid bygge +│ ├── sprites/ bilder, tilesets, UI +│ ├── sound/ OGG/WAV +│ ├── text/ sv.json, en.json, jp-dictionary.json, fonts/ +│ └── data/ levels/, items, skills (JSON) +├── doc/ denna dokumentation +├── .vscode/ tasks.json, launch.json +└── build/ (gitignorerad) debug/ och release/-/ +``` + +### Byggflöde + +| Vad | Kommando | Resultat | +|-----|----------|----------| +| Debug (standard, Ctrl+Shift+B) | `go run ./tools/build` | `build/debug/jp-subway-pussel` + `resources/`; inga optimeringar, full debuginfo | +| Debug med debugger (F5) | VS Code "Debug (dlv)" | Kompilerar och startar under delve med cwd = repots rot | +| Release Linux | `go run ./tools/build -mode release -os linux -arch amd64` | `build/release/linux-amd64/` – strippad, `-trimpath` | +| Release Windows | `go run ./tools/build -mode release -os windows -arch amd64` | `build/release/windows-amd64/jp-subway-pussel.exe` – GUI-subsystem, inget konsolfönster | +| Tester | `go test ./...` | | + +Versionen i binären kommer från `git describe --tags --always --dirty`, +så en tagg `v0.1.0` ger `jp-subway-pussel v0.1.0` i `-version`. + +## Milstolpar + +### M0 – Skelett och byggkedja ✓ (klar 2026-10-23) + +- Repo, Go-modul, Ebitengine-fönster 320×180 med heltalsskalning. +- `internal/res`, `internal/rng`, `internal/level` med tester. +- Byggverktyg, VS Code-tasks och launch-konfigurationer. +- Första banan (`001-flygplatsen`) renderad som färgade rutor med + placeholder-sprite. +- Verifierat: debug-bygge, Linux-release, Windows-korsbygge, smoke-test + med fönster, alla tester gröna. + +### M1 – Rörelse, kollision och kamera + +- **Mål:** gå runt i en labyrint som är större än skärmen. +- Spelare som rör sig i 4 riktningar (tangentbord + gamepad), kollision mot + väggar, kamera som följer och klampas till kartan. +- Scen-system (meny → bana → resultat) som en enkel tillståndsmaskin. +- Tileset i stället för färgade rutor; sprite-animation (gå/stå). +- **Teknik:** `internal/game/scene`, `internal/sim` (rörelse/kollision som + ren logik), Ebitengine `inpututil`. +- **Klar när:** man kan gå från S till P i bana 001, kameran följer, testerna + i mål-dokumentet för M1 är gröna. + +### M2 – Stationen: tåg, spärrar och vinst/förlust → första spelbara banan + +- **Mål:** bana 001 går att klara och misslyckas. +- Tåg med tidtabell som står vid perrongen en stund och avgår; nästa avgång + visas vid perrongen. +- Spärrar som kräver kort (kortet finns och har alltid saldo i M2). +- Vinst när rätt tåg tas; förlust när fel tåg tas. Resultatskärm. +- Interaktion med skyltar (visar text, än så länge på svenska/engelska). +- **Teknik:** `internal/sim/schedule`, `internal/level` utökas med + tidtabell + skyltinnehåll. +- **Klar när:** en simuleringstest låter en bot klara bana 001 headless; + manuellt spel från start till mål fungerar. + +### M3 – Japanska och skyltsystemet + +- **Mål:** spelets identitet – skyltar på japanska som man lär sig läsa. +- Pixelfont med kana + kanji (beslut PixelMplus/Misaki, licens in i repot). +- Kunskapsmodell: vilka tecken/ord spelaren kan. Skyltar renderas med + delvis översättning, egen färg för det som är översatt, interaktion för + att se översättning. +- Andel engelska på skyltar styrs av banans svårighet. +- "Fråga en japan": NPC-interaktion med väntetid beroende på japanska-nivå. +- Hintar i UI/bana skalar med japanska-nivå. +- **Teknik:** `internal/lang` (ordbok, kunskap, översättningslogik – ren + Go), `text/v2` i `internal/game`. +- **Klar när:** samma skylt visar olika mycket beroende på kunskap, och det + är testat; bana 001 spelas med japanska skyltar. + +### M4 – Lämmeltåget och mätarna + +- **Mål:** sällskapet följer med och ställer till det. +- Följare som går i rad efter spelaren. +- Mätare mat/dryck/toa per karaktär; händelser (toabesök, köpa dricka, + distraktion) vars frekvens skalar med gruppstorlek. +- Toaletter, kiosker och automater som rutor i banan. +- **Teknik:** `internal/sim/party`, `internal/sim/events` (seedade via + `internal/rng`). +- **Klar när:** fler i gruppen ger mätbart fler händelser i test; händelser + går att lösa i spelet. + +### M5 – Minikarta och utforskning + +- **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`. + +### M6 – Kort, biljetter, telefon och minispel + +- **Mål:** resurshantering och de seedade minispelen. +- Kortsaldo som dras i spärren; tomt kort → hitta automat → påfyllningsminispel. +- Separata biljetter på svårare banor → biljettköpsminispel (rätt biljett). +- Kort i telefonen: speed-buff + procentchans för omskanning. Batteri som + tar slut på långa banor → tvingar till biljett. +- Minispel på tåget: få plats, komma av, gå av på rätt station. +- **Teknik:** `internal/minigame/*` med gemensamt gränssnitt + (`Start(seed) / Update / Result`), all slump via del-seeds. +- **Klar när:** varje minispel är deterministiskt för en seed (test) och + spelbart. + +### M7 – Svårighet, tid, poäng och debuffar + +- **Mål:** banor som kan misslyckas och poäng att jaga. +- Tidsgräns och maxantal missade tåg på svåra banor; lätta banor obegränsade. +- Poängformel: tid, pengar/påfyllningar, mätarnas status, navigeringsläge. +- Stats (speed, törst, fokus, toalett, tur) och debuffar (onda fötter, + trötthet, hunger/irritation, matförgiftning); temporära debuffar 1–4 banor. +- Svårighetsprofil per bana i JSON. +- **Teknik:** `internal/score`, `internal/sim/stats`. + +### M8 – Progression, items och sparfiler + +- **Mål:** något att låsa upp och en anledning att fortsätta. +- XP/level-ups, skill tree, items (kläder, masker, hattar) med buffar. +- Bankarta i spelbrädesstil; sidobanor som ger skins/powers/items; items + som låser upp genvägar i huvudbanor. +- Sparfil med framsteg, items, skills, debuffar och seed. Autospar vid + banstart. +- **Teknik:** `internal/save` (JSON, versionsfält för migrering), + `resources/data/items.json`, `skills.json`. +- **Klar när:** sparfil round-trip-test grön; "< 33 % per omgång" går att + räkna ut från datan. + +### M9 – Navigeringslägen: lista och skyltläge + +- **Mål:** de två sätten att spela story. +- Listläge: nästa tåg visas. +- Skyltläge: 1–4 giltiga vägar, mer poäng, förlust om man åker i fel + riktning / för långt bort. +- **Teknik:** `internal/nav` (graf över stationer/linjer, giltiga vägar, + riktningskontroll – ren Go). + +### M10 – Story-innehåll och flerbytesbanor + +- **Mål:** hela resan från flygplats till flygplats. +- Alla huvudbanor och sidobanor; banor med flera stopp och byten; buss. +- Slutanimation (karaktärerna flyger iväg, items och poäng visas) och + eftertexter. +- Överstimulerande UI/banor som extra svårighet. +- **Teknik:** baneditor-stöd behövs troligen här: ett `tools/levelcheck` + som ritar banan som bild och rapporterar onåbara rutor. + +### M11 – Oändligt läge och New Game+ + +- Slumpade mål från seed, poäng per mål, egen poänglista. NG+ behåller skills. +- Låses upp när storyn är klar. + +### M12 – Ljud, polish och release + +- Avgångsmelodier/ankomstjinglar, effekter, musik. +- Inställningar (ljudnivåer, språk sv/en, skala, tangenter). +- Windows-test på riktig maskin, ikon i exe:n, eventuell Gitea-CI med + releaser enligt playbooken. + +## Prioritering + +- **Måste för ett spelbart spel:** M1–M3, M6 (åtminstone kort/spärr), M7. +- **Gör spelet till *det här* spelet:** M3, M4, M9. +- **Kan skjutas utan att något går sönder:** M5, M11, delar av M8 (skill + tree), överstimulerande UI i M10. +- Windows-bygget körs vid varje milstolpe men testas på riktig Windows + först i M12. diff --git a/doc/spelbeskrivning.md b/doc/spelbeskrivning.md new file mode 100644 index 0000000..1e07b96 --- /dev/null +++ b/doc/spelbeskrivning.md @@ -0,0 +1,189 @@ +# JP Subway Pussel – spelbeskrivning + +Renskriven och omsorterad version av den ursprungliga idébeskrivningen +(2026-10-23). Allt innehåll från originalet finns med; där originalet var +otydligt står tolkningen i [§15](#15-tolkningar-av-originalet). + +## 1. Koncept i korthet + +- 2D-pusselspel i pixel art. +- Du leder ett sällskap – *lämmeltåget* – genom japanska tågstationer som + är byggda som labyrinter. +- Målet på varje bana: **hitta rätt tåg och anländ till rätt resmål.** +- Nästan alla skyltar är på japanska. Hur mycket du förstår beror på vad du + har lärt dig. +- Svårigheten kommer från sällskapets behov (mat, dryck, toa), tidsgränser, + tågkort och biljetter, samt stationer och UI som är byggda för att förvirra. + +## 2. Story + +- Några polare är på semester i Japan. +- Spelet **börjar på flygplatsen** när man landar och **slutar när man ska + tillbaka till flygplatsen**. +- Under resan upplever man Japan och köper saker: kläder, masker, hattar + m.m. Dessa kan tas på och ger olika buffar allt eftersom man spelar. +- Slutet är en animation där karaktärerna flyger iväg. Alla items och poäng + från omgången visas, sedan rullar eftertexterna. +- När storyn är klar låses **Oändligt läge** och **New Game+** upp. + +## 3. Spellägen + +### 3.1 Story-läge + +- Banorna är statiska (handgjorda). +- Minispelen är slumpade utifrån en **seed** som genereras när banan startas + och sparas när man sparar spelet, så en laddad omgång ger samma utfall. +- Två navigeringslägen att välja mellan: + + | Läge | Så funkar det | Poäng | + |------|---------------|-------| + | **Listläge** | Du får en lista över vilket tåg du ska ta härnäst för att komma rätt. | Normal | + | **Skyltläge** | Ingen lista. Du använder skyltar och terminaler för att lista ut vilka tåg som tar dig rätt. 1–4 olika vägar kan vara korrekta. Tar du ett tåg/buss vars slutstation ligger i fel riktning i förhållande till starten, eller hamnar för långt bort, förlorar du. | Högre | + +### 3.2 Oändligt läge + +- Låses upp när storyn är klar. Separat spelläge med egen poänglista. +- Du får en slutstation. När du är framme får du dina poäng och sedan nästa + mål. Målen är slumpade. + +### 3.3 New Game+ + +- Spela om storyn och behåll alla dina skills. + +## 4. En bana – kärnloopen + +1. Du startar på en station (eller kliver av ett tåg). +2. Stationen är en labyrint med skyltar. Du navigerar med hjälp av symboler, + skyltar, terminaler och det du lärt dig (se §6). +3. Du passerar spärrar (kräver kort eller biljett, se §8), hittar rätt + perrong och kliver på rätt tåg innan det går. +4. Banan är klar när du **anländer till rätt resmål**. + +- De första och enklaste banorna är **ett enda tåg eller en buss**. Längre + fram består en bana av **flera stopp och byten**. +- På vissa tåg och bussar förekommer minispel: att **få plats** på ett fullt + fordon, att **kunna ta sig av**, och att **veta vilken station** man ska + gå av på. +- Tåg avgår enligt tidtabell. Missar du ett tåg får du vänta på nästa, och på + svåra banor kostar det tid du inte har (se §9). + +## 5. Sällskapet – lämmeltåget + +- Dina polare följer efter dig i en rad – därav *lämmeltåget*. +- Varje karaktär har mätare för **mat**, **dryck** och **toa**. +- Folk måste gå på toa, köpa dricka och kan bli distraherade. +- **Ju fler i lämmeltåget, desto svårare** blir det och desto oftare händer + specialhändelser (dricka, mat, toa m.m.). + +## 6. Japanska, skyltar och hjälp + +- Nästan alla skyltar i banorna är på japanska. +- Hur mycket **engelska** som finns på skyltarna beror på banans svårighet: + lätt bana = mer engelska, svår bana = nästan bara japanska. +- Allt som översätts åt dig baserat på dina skills eller din japanska-nivå + visas i **en annan färg**, eller måste **interageras med** för att visa + översättningen. +- Översättningen kan vara **delvis**: bara de tecken eller ord du har lärt + dig översätts, resten står kvar på japanska. +- Du kan **fråga en japan om hjälp**. Det tar tid, och tiden beror på hur bra + din japanska är. +- Ju bättre din japanska är, desto **fler hintar** kan synas i UI och i banan. +- Huvudsyftet med stationslabyrinten är att navigera med symboler och + skyltar, med stöd av dina skills. + +## 7. Minikarta + +- Visar bara **avgångar** och några **riktmärken** som man kan förhålla sig + till. +- När du upptäcker delar av stationen **fylls minikartan i** – för just den + banan under just den spelomgången. + +## 8. Tågkort, biljetter och telefon + +- Du har ett **tågkort** (IC-kort) med saldo. Att hålla koll på kortet är en + egen svårighetsgrad. +- Är kortet tomt måste du **hitta en automat** och klara ett **minispel** för + att fylla på. +- På svårare banor måste du ibland köpa en **separat biljett**. Då kör du ett + separat minispel för att köpa **rätt** biljett, annars kommer du inte på + tåget i slutet av banan. +- Har du kortet **i telefonen** får du en **speed-buff**, men med en liten + procentchans att behöva **skanna flera gånger** i spärren eller vid + påfyllning. +- Telefonen har **batteri**. Dör det måste du köpa biljett i stället. Detta + ska nästan bara kunna hända på **långa banor**. + +## 9. Svårighet och tid + +- **Lätta banor:** ingen tidsgräns, försök hur länge du vill. +- **Svåra banor:** tidsgräns. Gör du för många fel missar du för många tåg + och banan misslyckas. +- **Extra svårigheter:** överstimulerande UI och banor byggda för att förvirra + spelaren räknas som egna, extra svårigheter. +- Variabler som styr svårighet: antal i sällskapet, andel engelska på + skyltar, kort-/biljettkrav, tidsgräns, antal byten, UI-störningar. + +## 10. Poäng + +Per bana baseras poängen på: + +- **Tid.** +- **Minst pengar spenderade** och färst kortpåfyllningar. +- Hur bra **alla karaktärers mat-, dryck- och toamätare** står vid målgång. +- Skyltläge (§3.1) ger mer poäng än listläge. + +## 11. Stats, buffar och debuffar + +**Stats** som påverkas: speed, törst, fokus, toalett, tur. + +**Debuffar** man kan drabbas av under spelet: + +- onda fötter +- trötthet +- hunger / irritation +- matförgiftning (kortare toamätare) + +**Temporära debuffar** från sidouppdrag (varar 1–4 banor), beroende på hur +bra man gjorde ifrån sig: + +- träningsvärk +- skoskav (köpt dåliga skor) +- köpt något tungt eller stort +- proppmätt +- svettig och varm + +**Buffar** kommer från kläder, masker, hattar och items som köps längs vägen, +från skills i skill tree, och från unika skills man får av sidouppdrag. + +## 12. Progression och upplåsningar + +- **Level-ups** och ett **skill tree**. +- **Skins** låses upp efter banor. +- **Bankarta** i spelbrädes-/världskartestil där man väljer bana. +- **Sidobanor** ger speciella skins, powers och items. Vissa items låser upp + **genvägar** i andra huvudbanor. +- **Många items och skills** – man ska knappt få tag på 33 % av allt under + en omgång. +- Sidouppdrag kan ge unika skills men också temporära debuffar (§11). + +## 13. Ljud och grafik + +- Pixel art. +- Ljud som påminner om japanska stationer: **avgångsmelodier** och + **ankomstjinglar** som spelas när tåg kommer och går. + +## 14. Slump och sparande + +- Story-banor är statiska. Minispel är slumpade utifrån banans seed. +- Seeden genereras när banan startas och sparas med spelet. +- Oändligt läge drar slumpade mål. +- Sparfilen innehåller framsteg, items, skills, aktiva debuffar och seed. + +## 15. Tolkningar av originalet + +| Originalet | Tolkning här | Kommentar | +|------------|--------------|-----------| +| "Lämel tåg" | *Lämmeltåg*: sällskapet som följer efter dig i rad | Styr §5 | +| "level maps game box liknande styl av level väljande" | Bankarta i spelbrädes-/världskartestil | Kan ändras om något annat avsågs | +| "bilder och eventuella språk och tester" (resursmappen) | bilder, språk och *texter* | Se resurslayouten i roadmap | +| Repo-namnet "jp-subway-pussel" | Behållet; *pussel* är korrekt svenska | Byt till `-puzzle` om det ska vara helengelskt | diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1b8cbc4 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module gitea.brasse-pc.eu/brasse/jp-subway-pussel + +go 1.27 + +require github.com/hajimehoshi/ebiten/v2 v2.10.5 + +require ( + github.com/ebitengine/gomobile v0.0.0-20260820040257-d11f821a26a6 // indirect + github.com/ebitengine/hideconsole v1.0.0 // indirect + github.com/ebitengine/purego v0.11.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..25511ba --- /dev/null +++ b/go.sum @@ -0,0 +1,14 @@ +github.com/ebitengine/gomobile v0.0.0-20260820040257-d11f821a26a6 h1:Tnc3YtzxhgsvNdNrER9wWkGJbyjOwyUuzjUY5rZK72k= +github.com/ebitengine/gomobile v0.0.0-20260820040257-d11f821a26a6/go.mod h1:gwnFEwdzWZpNehgwkeK4756Ez58f58bXz6bgEAq+xqk= +github.com/ebitengine/hideconsole v1.0.0 h1:5J4U0kXF+pv/DhiXt5/lTz0eO5ogJ1iXb8Yj1yReDqE= +github.com/ebitengine/hideconsole v1.0.0/go.mod h1:hTTBTvVYWKBuxPr7peweneWdkUwEuHuB3C1R/ielR1A= +github.com/ebitengine/purego v0.11.0 h1:jhp/D+Nyv7UUW8HAcmcjt2N2rYrYi9m3SL21k0Ua/NI= +github.com/ebitengine/purego v0.11.0/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ= +github.com/hajimehoshi/ebiten/v2 v2.10.5 h1:D6XuOFhzgkvfqeA6Xwwvcz54uj6HHPNC9Y+0pE6Qfs4= +github.com/hajimehoshi/ebiten/v2 v2.10.5/go.mod h1:47QNgyS/y2ZRkjVUvlGLx8a+F7MSjcn8/GsjcCZ9Rc8= +golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0= +golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/internal/archtest/arch_test.go b/internal/archtest/arch_test.go new file mode 100644 index 0000000..94b5e20 --- /dev/null +++ b/internal/archtest/arch_test.go @@ -0,0 +1,58 @@ +// Package archtest innehåller arkitekturtester: regler om vilka paket som +// får bero på vad. Körs som vanliga tester med `go test ./...`. +package archtest + +import ( + "os/exec" + "strings" + "testing" +) + +// Paket (relativt modulroten) som får importera Ebitengine. All annan kod +// under internal/ ska gå att testa utan fönster. +var mayUseEbitengine = map[string]bool{ + "internal/game": true, + "internal/ui": true, + "internal/audio": true, +} + +const ebitengine = "github.com/hajimehoshi/ebiten/" + +func TestOnlyPresentationPackagesImportEbitengine(t *testing.T) { + root := goList(t, "-m", "-f", "{{.Dir}}") + module := goList(t, "-m", "-f", "{{.Path}}") + + out := goListIn(t, root, "-f", "{{.ImportPath}}|{{join .Deps \",\"}}", "./...") + for _, line := range strings.Split(out, "\n") { + pkg, deps, ok := strings.Cut(line, "|") + if !ok { + continue + } + rel := strings.TrimPrefix(strings.TrimPrefix(pkg, module), "/") + if strings.HasPrefix(rel, "cmd/") || mayUseEbitengine[rel] { + continue + } + for _, d := range strings.Split(deps, ",") { + if strings.HasPrefix(d, ebitengine) { + t.Errorf("%s får inte bero på Ebitengine (via %s) – flytta logiken till ett rent paket eller lägg till paketet i mayUseEbitengine", rel, d) + break + } + } + } +} + +func goList(t *testing.T, args ...string) string { + t.Helper() + return goListIn(t, "", args...) +} + +func goListIn(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("go", append([]string{"list"}, args...)...) + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + t.Fatalf("go list %v: %v", args, err) + } + return strings.TrimSpace(string(out)) +} diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go new file mode 100644 index 0000000..481a2c7 --- /dev/null +++ b/internal/buildinfo/buildinfo.go @@ -0,0 +1,12 @@ +// Package buildinfo innehåller värden som sätts av byggverktyget via -ldflags -X. +// Lämnas de orörda (t.ex. vid `go run` eller debugging via dlv) gäller standardvärdena. +package buildinfo + +// Version är git-beskrivningen (t.ex. v0.1.0 eller v0.1.0-3-gabcdef-dirty) eller "dev". +var Version = "dev" + +// Mode är "debug" eller "release". +var Mode = "debug" + +// IsRelease rapporterar om binären byggdes i release-läge. +func IsRelease() bool { return Mode == "release" } diff --git a/internal/game/game.go b/internal/game/game.go new file mode 100644 index 0000000..ea61084 --- /dev/null +++ b/internal/game/game.go @@ -0,0 +1,135 @@ +// Package game innehåller Ebitengine-spelloopen. Detta är det enda paketet +// (tillsammans med cmd/) som får känna till Ebitengine; all spellogik ska +// ligga i paket som går att testa utan fönster. +package game + +import ( + "fmt" + "image/color" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/ebitenutil" + "github.com/hajimehoshi/ebiten/v2/inpututil" + + "gitea.brasse-pc.eu/brasse/jp-subway-pussel/internal/buildinfo" + "gitea.brasse-pc.eu/brasse/jp-subway-pussel/internal/level" + "gitea.brasse-pc.eu/brasse/jp-subway-pussel/internal/res" +) + +// Intern upplösning. Allt ritas i denna storlek och skalas sedan upp med +// heltalsfaktor av Ebitengine så pixlarna förblir skarpa. +const ( + ScreenW = 320 + ScreenH = 180 + TileSize = 8 +) + +// Game är Ebitengine-tillståndet för hela programmet. +type Game struct { + lvl *level.Level + player *ebiten.Image + tiles map[level.Tile]*ebiten.Image + resDir string + smokeLeft int // >0: avsluta automatiskt efter så många uppdateringar (för smoke-test) + frames int + levelError error +} + +// Options styr uppstarten. +type Options struct { + LevelID string // bana att visa, t.ex. "001-flygplatsen" + SmokeFrames int // >0 avslutar efter N uppdateringar +} + +// New laddar resurser och bygger spelet. +func New(opt Options) (*Game, error) { + dir, err := res.Dir() + if err != nil { + return nil, err + } + g := &Game{resDir: dir, smokeLeft: opt.SmokeFrames} + + img, err := res.LoadImage("sprites", "placeholder.png") + if err != nil { + return nil, err + } + g.player = ebiten.NewImageFromImage(img) + + var raw []byte + if raw, err = res.ReadFile("data", "levels", opt.LevelID+".json"); err == nil { + g.lvl, err = level.Parse(raw) + } + if err != nil { + g.levelError = err + } + + g.tiles = map[level.Tile]*ebiten.Image{} + for t, c := range map[level.Tile]color.RGBA{ + level.Wall: {38, 43, 68, 255}, + level.Floor: {139, 155, 180, 255}, + level.Spawn: {99, 199, 77, 255}, + level.Platform: {254, 174, 52, 255}, + level.WrongPlatform: {228, 59, 68, 255}, + level.Gate: {44, 232, 245, 255}, + level.TicketMachine: {247, 118, 34, 255}, + level.Sign: {255, 255, 255, 255}, + } { + im := ebiten.NewImage(TileSize, TileSize) + im.Fill(c) + g.tiles[t] = im + } + return g, nil +} + +// Update körs 60 ggr/s. +func (g *Game) Update() error { + g.frames++ + if inpututil.IsKeyJustPressed(ebiten.KeyEscape) { + return ebiten.Termination + } + if g.smokeLeft > 0 { + g.smokeLeft-- + if g.smokeLeft == 0 { + return ebiten.Termination + } + } + return nil +} + +// Draw ritar aktuell bild. +func (g *Game) Draw(screen *ebiten.Image) { + screen.Fill(color.RGBA{24, 20, 37, 255}) + + if g.lvl != nil { + // Centrera banan på skärmen (riktiga banor får kamera senare). + ox := (ScreenW - g.lvl.Width()*TileSize) / 2 + oy := (ScreenH - g.lvl.Height()*TileSize) / 2 + var op ebiten.DrawImageOptions + for y := 0; y < g.lvl.Height(); y++ { + for x := 0; x < g.lvl.Width(); x++ { + op.GeoM.Reset() + op.GeoM.Translate(float64(ox+x*TileSize), float64(oy+y*TileSize)) + screen.DrawImage(g.tiles[g.lvl.At(x, y)], &op) + } + } + s := g.lvl.SpawnPoint() + op.GeoM.Reset() + op.GeoM.Translate(float64(ox+s.X*TileSize)-4, float64(oy+s.Y*TileSize)-8) + screen.DrawImage(g.player, &op) + } + + name := "(ingen bana)" + if g.lvl != nil { + name = g.lvl.Name.Get("sv") + " -> " + g.lvl.Destination.Get("ja") + } else if g.levelError != nil { + name = "FEL: " + g.levelError.Error() + } + ebitenutil.DebugPrint(screen, fmt.Sprintf( + "JP Subway Pussel %s (%s) %.0f fps\nBana: %s\nResurser: %s\nESC avslutar", + buildinfo.Version, buildinfo.Mode, ebiten.ActualFPS(), name, g.resDir)) +} + +// Layout talar om för Ebitengine vilken intern upplösning vi ritar i. +func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) { + return ScreenW, ScreenH +} diff --git a/internal/level/level.go b/internal/level/level.go new file mode 100644 index 0000000..8b1a417 --- /dev/null +++ b/internal/level/level.go @@ -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 +} diff --git a/internal/level/level_test.go b/internal/level/level_test.go new file mode 100644 index 0000000..93c3553 --- /dev/null +++ b/internal/level/level_test.go @@ -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) + } + }) + } +} diff --git a/internal/res/res.go b/internal/res/res.go new file mode 100644 index 0000000..45d7aeb --- /dev/null +++ b/internal/res/res.go @@ -0,0 +1,135 @@ +// Package res hittar och läser resurser (sprites, ljud, text, speldata). +// +// Resursmappen ligger normalt bredvid binären (`/resources`). Vid +// utveckling (`go run`, dlv) ligger binären i en temp-mapp, så vi faller +// tillbaka på arbetskatalogen och letar uppåt efter repots rot (go.mod). +// Miljövariabeln JP_RESOURCES vinner alltid. +package res + +import ( + "encoding/json" + "errors" + "fmt" + "image" + _ "image/png" // registrerar PNG-avkodaren för image.Decode + "os" + "path/filepath" + "sync" +) + +// EnvVar är miljövariabeln som pekar ut resursmappen explicit. +const EnvVar = "JP_RESOURCES" + +// DirName är namnet på resursmappen bredvid binären / i repots rot. +const DirName = "resources" + +var ( + dirOnce sync.Once + dirPath string + dirErr error +) + +// Dir returnerar den absoluta sökvägen till resursmappen. Resultatet cachas. +func Dir() (string, error) { + dirOnce.Do(func() { + exe, _ := os.Executable() + cwd, _ := os.Getwd() + dirPath, dirErr = Locate(os.Getenv(EnvVar), filepath.Dir(exe), cwd, dirExists) + }) + return dirPath, dirErr +} + +// Locate är den rena uppslagslogiken bakom Dir, bruten ut för att kunna testas. +// Ordning: env-variabel, mappen bredvid binären, arbetskatalogen och dess +// föräldrar (stannar vid första som innehåller en resursmapp). +func Locate(env, exeDir, cwd string, exists func(string) bool) (string, error) { + if env != "" { + if exists(env) { + return filepath.Clean(env), nil + } + return "", fmt.Errorf("res: %s=%q pekar inte på en mapp", EnvVar, env) + } + var tried []string + if exeDir != "" { + c := filepath.Join(exeDir, DirName) + if exists(c) { + return c, nil + } + tried = append(tried, c) + } + for dir := cwd; dir != ""; { + c := filepath.Join(dir, DirName) + if exists(c) { + return c, nil + } + tried = append(tried, c) + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return "", fmt.Errorf("res: hittade ingen %q-mapp (sökte: %v); sätt %s", DirName, tried, EnvVar) +} + +func dirExists(p string) bool { + st, err := os.Stat(p) + return err == nil && st.IsDir() +} + +// Path bygger en sökväg inuti resursmappen, t.ex. Path("sprites", "player.png"). +func Path(parts ...string) (string, error) { + d, err := Dir() + if err != nil { + return "", err + } + return filepath.Join(append([]string{d}, parts...)...), nil +} + +// ReadFile läser en fil relativt resursmappen. +func ReadFile(parts ...string) ([]byte, error) { + p, err := Path(parts...) + if err != nil { + return nil, err + } + b, err := os.ReadFile(p) + if err != nil { + return nil, fmt.Errorf("res: %w", err) + } + return b, nil +} + +// LoadJSON läser och avkodar en JSON-fil relativt resursmappen. +func LoadJSON(v any, parts ...string) error { + b, err := ReadFile(parts...) + if err != nil { + return err + } + if err := json.Unmarshal(b, v); err != nil { + return fmt.Errorf("res: %s: %w", filepath.Join(parts...), err) + } + return nil +} + +// LoadImage läser en PNG relativt resursmappen och returnerar en image.Image. +// (Konvertering till *ebiten.Image görs av anroparen så att detta paket +// förblir fritt från Ebitengine och kan testas utan fönster.) +func LoadImage(parts ...string) (image.Image, error) { + p, err := Path(parts...) + if err != nil { + return nil, err + } + f, err := os.Open(p) + if err != nil { + return nil, fmt.Errorf("res: %w", err) + } + defer f.Close() + img, _, err := image.Decode(f) + if err != nil { + return nil, fmt.Errorf("res: %s: %w", p, err) + } + return img, nil +} + +// ErrNotFound kan användas av anropare för att skilja "saknas" från andra fel. +var ErrNotFound = errors.New("res: resursen saknas") diff --git a/internal/res/res_test.go b/internal/res/res_test.go new file mode 100644 index 0000000..f8c8301 --- /dev/null +++ b/internal/res/res_test.go @@ -0,0 +1,76 @@ +package res + +import ( + "path/filepath" + "strings" + "testing" +) + +func existsIn(paths ...string) func(string) bool { + set := map[string]bool{} + for _, p := range paths { + set[filepath.Clean(p)] = true + } + return func(p string) bool { return set[filepath.Clean(p)] } +} + +func TestLocate_EnvWins(t *testing.T) { + got, err := Locate("/opt/custom", "/bin", "/work", existsIn("/opt/custom", "/bin/resources")) + if err != nil || got != "/opt/custom" { + t.Fatalf("got %q, %v; want /opt/custom", got, err) + } +} + +func TestLocate_EnvMissingIsError(t *testing.T) { + _, err := Locate("/nope", "/bin", "/work", existsIn("/bin/resources")) + if err == nil || !strings.Contains(err.Error(), EnvVar) { + t.Fatalf("expected error mentioning %s, got %v", EnvVar, err) + } +} + +func TestLocate_ExeDirBeforeCwd(t *testing.T) { + got, err := Locate("", "/app", "/work", existsIn("/app/resources", "/work/resources")) + if err != nil || got != filepath.Join("/app", DirName) { + t.Fatalf("got %q, %v", got, err) + } +} + +func TestLocate_WalksUpFromCwd(t *testing.T) { + // Som vid `go run`/dlv: binären i en temp-mapp, cwd djupt inne i repot. + got, err := Locate("", "/tmp/go-build123", "/repo/internal/level", existsIn("/repo/resources")) + if err != nil || got != filepath.Join("/repo", DirName) { + t.Fatalf("got %q, %v", got, err) + } +} + +func TestLocate_NothingFound(t *testing.T) { + _, err := Locate("", "/a", "/b/c", existsIn()) + if err == nil { + t.Fatal("expected error") + } +} + +// TestDir_RealRepo kör mot det riktiga repot: testet körs med cwd = paketmappen, +// så uppsökningen måste hitta /resources. +func TestDir_RealRepo(t *testing.T) { + d, err := Dir() + if err != nil { + t.Fatal(err) + } + if filepath.Base(d) != DirName { + t.Fatalf("unexpected dir %q", d) + } + if _, err := ReadFile("text", "sv.json"); err != nil { + t.Fatalf("resources/text/sv.json ska finnas: %v", err) + } + var m map[string]string + if err := LoadJSON(&m, "text", "sv.json"); err != nil { + t.Fatal(err) + } + if m["game.title"] == "" { + t.Fatal("sv.json saknar game.title") + } + if _, err := LoadImage("sprites", "placeholder.png"); err != nil { + t.Fatal(err) + } +} diff --git a/internal/rng/rng.go b/internal/rng/rng.go new file mode 100644 index 0000000..41aaf4f --- /dev/null +++ b/internal/rng/rng.go @@ -0,0 +1,48 @@ +// Package rng ger seedade slumptalsgeneratorer. +// +// Allt som ska vara reproducerbart (minispel, oändligt läge, event-timing) +// måste ta sin slump härifrån med en känd seed, aldrig från den globala +// generatorn. Seeden sparas i sparfilen så att en laddad omgång ger samma +// utfall som den avbrutna. +package rng + +import ( + crand "crypto/rand" + "encoding/binary" + "math/rand/v2" +) + +// Seed är en 64-bitars seed. Två värden för att kunna avleda del-seeds +// (t.ex. per bana eller per minispel) utan att de påverkar varandra. +type Seed struct { + A, B uint64 +} + +// NewSeed drar en färsk slumpmässig seed från operativsystemet. +func NewSeed() Seed { + var b [16]byte + if _, err := crand.Read(b[:]); err != nil { + panic("rng: kunde inte läsa slump från OS: " + err.Error()) + } + return Seed{binary.LittleEndian.Uint64(b[:8]), binary.LittleEndian.Uint64(b[8:])} +} + +// FromInt skapar en seed från ett enkelt heltal (praktiskt i tester och för +// seeds som spelaren skriver in manuellt). +func FromInt(n uint64) Seed { return Seed{A: n, B: n ^ 0x9E3779B97F4A7C15} } + +// Derive avleder en deterministisk del-seed för en namngiven kontext, så att +// t.ex. biljettautomatens minispel på bana 3 alltid får samma slump oavsett +// vad spelaren gjort innan. +func (s Seed) Derive(context string) Seed { + h := rand.New(rand.NewPCG(s.A, s.B)) + a, b := h.Uint64(), h.Uint64() + for _, c := range context { + a ^= uint64(c) * 0x100000001B3 + b = b*31 + uint64(c) + } + return Seed{A: a, B: b} +} + +// New returnerar en generator för seeden. Samma seed ger alltid samma sekvens. +func (s Seed) New() *rand.Rand { return rand.New(rand.NewPCG(s.A, s.B)) } diff --git a/internal/rng/rng_test.go b/internal/rng/rng_test.go new file mode 100644 index 0000000..cb2d807 --- /dev/null +++ b/internal/rng/rng_test.go @@ -0,0 +1,38 @@ +package rng + +import "testing" + +func TestDeterministic(t *testing.T) { + a := FromInt(42).New() + b := FromInt(42).New() + for i := 0; i < 1000; i++ { + if x, y := a.Uint64(), b.Uint64(); x != y { + t.Fatalf("sekvensen skiljer sig vid %d: %d != %d", i, x, y) + } + } +} + +func TestDifferentSeedsDiffer(t *testing.T) { + if FromInt(1).New().Uint64() == FromInt(2).New().Uint64() { + t.Fatal("olika seeds gav samma första värde") + } +} + +func TestDeriveIsStableAndContextual(t *testing.T) { + s := FromInt(7) + if s.Derive("ticket") != s.Derive("ticket") { + t.Fatal("Derive är inte deterministisk") + } + if s.Derive("ticket") == s.Derive("toilet") { + t.Fatal("olika kontexter gav samma del-seed") + } + if s.Derive("ticket") == FromInt(8).Derive("ticket") { + t.Fatal("olika rot-seeds gav samma del-seed") + } +} + +func TestNewSeedIsRandom(t *testing.T) { + if NewSeed() == NewSeed() { + t.Fatal("två NewSeed() gav samma värde") + } +} diff --git a/resources/data/README.md b/resources/data/README.md new file mode 100644 index 0000000..ee62f2e --- /dev/null +++ b/resources/data/README.md @@ -0,0 +1,22 @@ +# resources/data – speldata + +Banor, items, skills och annan data som spelet läser vid start. Allt är JSON. + +- `levels/.json` – en bana per fil. `id` i filen **måste** vara lika med + filnamnet utan `.json`. Testet `internal/level.TestAllShippedLevels` kör + igenom alla banor här och faller om någon inte parsar eller inte går att klara. + +## Rutnätets tecken + +| Tecken | Betydelse | +|--------|-----------| +| `#` | vägg | +| `.` | golv | +| `S` | start (exakt en) | +| `P` | perrong med tåg | +| `p` | perrong med fel tåg | +| `G` | spärr (kräver kort/biljett) | +| `T` | biljett-/påfyllningsautomat | +| `i` | skylt | + +Varje rad måste vara lika lång. Se `internal/level/level.go` för formatet. diff --git a/resources/data/levels/001-flygplatsen.json b/resources/data/levels/001-flygplatsen.json new file mode 100644 index 0000000..ba78b00 --- /dev/null +++ b/resources/data/levels/001-flygplatsen.json @@ -0,0 +1,35 @@ +{ + "id": "001-flygplatsen", + "name": { "sv": "Flygplatsen", "en": "The Airport", "ja": "空港" }, + "difficulty": 1, + "destination": { "ja": "新宿", "romaji": "Shinjuku", "sv": "Shinjuku", "en": "Shinjuku" }, + "timeLimitSec": 0, + "grid": [ + "####################", + "#S.....#.....i.....#", + "#.###.##.###.#.###.#", + "#...#....#...#...#.#", + "###.######.#####.#.#", + "#...#....#.....#.#.#", + "#.###.##.#####.#.#.#", + "#.....#T.......#...#", + "#.#####.#########.##", + "#....GG......i..GG.#", + "#..p.....##......P.#", + "####################" + ], + "trains": [ + { + "platform": "P", + "line": { "ja": "成田エクスプレス", "romaji": "Narita Ekusupuresu", "en": "Narita Express" }, + "destination": { "ja": "新宿", "romaji": "Shinjuku", "en": "Shinjuku" }, + "correct": true + }, + { + "platform": "p", + "line": { "ja": "京成本線", "romaji": "Keisei Honsen", "en": "Keisei Main Line" }, + "destination": { "ja": "京成上野", "romaji": "Keisei-Ueno", "en": "Keisei-Ueno" }, + "correct": false + } + ] +} diff --git a/resources/sound/README.md b/resources/sound/README.md new file mode 100644 index 0000000..d506d33 --- /dev/null +++ b/resources/sound/README.md @@ -0,0 +1,6 @@ +# resources/sound – ljud + +Effekter och musik. Format: OGG (musik, längre ljud) och WAV (korta effekter). + +Planerat innehåll: avgångsmelodier och ankomstjinglar i stil med japanska +stationer, spärr-pip, automat-ljud, folkmassa, UI-klick. diff --git a/resources/sprites/README.md b/resources/sprites/README.md new file mode 100644 index 0000000..100fbe2 --- /dev/null +++ b/resources/sprites/README.md @@ -0,0 +1,7 @@ +# resources/sprites – bilder + +Pixel art: karaktärer, tilesets, UI, ikoner. PNG med alfa. + +- Rita i den interna upplösningen (320×180, rutor om 8×8 px). Ingen + uppskalning i filerna – spelet skalar med heltalsfaktor. +- `placeholder.png` är en 16×16-testfigur som används tills riktig grafik finns. diff --git a/resources/sprites/placeholder.png b/resources/sprites/placeholder.png new file mode 100644 index 0000000000000000000000000000000000000000..a848cfc595e910e8f74788c7b7a2374f4605fa53 GIT binary patch literal 243 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`yF6VSLn`JZB_3d$7qR@NZ9C*dW*v;a$C^li*9+u`~X$HA5 z4ZezIhCJdW=kGCXt6aEV*@uJmNd7(kW!}NH5{5Ahmwk8~!nz-^PB^)8SAIea55pwq zZL)_MYU+-vwuf#!*S$VsZ?S8Ww6pECGha^Ml=r#6i|eFA)~y|r&4u|27csU?{`dbs m|MuRA6DJBzmtcm11cv8RoE|-3KeiF*3kFYDKbLh*2~7b0t6XFN literal 0 HcmV?d00001 diff --git a/resources/text/README.md b/resources/text/README.md new file mode 100644 index 0000000..b2b1df6 --- /dev/null +++ b/resources/text/README.md @@ -0,0 +1,8 @@ +# resources/text – text och språk + +- `sv.json`, `en.json` – UI-strängar per språk (platt nyckel → text). +- `jp-dictionary.json` – ordboken över japanska skyltord: tecken, läsning + (romaji) och översättning. Används av skyltsystemet för att visa hel/delvis + översättning baserat på vad spelaren lärt sig. +- `fonts/` – (kommer) pixelfont med stöd för kana + kanji, t.ex. PixelMplus + eller Misaki. Licensfil ska ligga bredvid fonten. diff --git a/resources/text/en.json b/resources/text/en.json new file mode 100644 index 0000000..e4b81b4 --- /dev/null +++ b/resources/text/en.json @@ -0,0 +1,17 @@ +{ + "game.title": "JP Subway Puzzle", + "menu.start": "Start", + "menu.continue": "Continue", + "menu.endless": "Endless mode", + "menu.settings": "Settings", + "menu.quit": "Quit", + "hud.destination": "Destination", + "hud.next_train": "Next train", + "hud.card_balance": "Card balance", + "meter.food": "Food", + "meter.drink": "Drink", + "meter.toilet": "Toilet", + "meter.battery": "Battery", + "level.failed": "You missed too many trains…", + "level.cleared": "Arrived!" +} diff --git a/resources/text/jp-dictionary.json b/resources/text/jp-dictionary.json new file mode 100644 index 0000000..ba99b70 --- /dev/null +++ b/resources/text/jp-dictionary.json @@ -0,0 +1,23 @@ +{ + "_comment": "Skyltord. Nyckel = japanska. kana = läsning i hiragana/katakana, romaji = latinsk läsning. tier = hur tidigt ordet brukar läras (1 = allra vanligast).", + "出口": { "kana": "でぐち", "romaji": "deguchi", "en": "Exit", "sv": "Utgång", "tier": 1 }, + "入口": { "kana": "いりぐち", "romaji": "iriguchi", "en": "Entrance", "sv": "Ingång", "tier": 1 }, + "改札": { "kana": "かいさつ", "romaji": "kaisatsu", "en": "Ticket gate", "sv": "Spärr", "tier": 1 }, + "のりば": { "kana": "のりば", "romaji": "noriba", "en": "Platform", "sv": "Perrong", "tier": 1 }, + "番線": { "kana": "ばんせん", "romaji": "bansen", "en": "Track no.", "sv": "Spår nr", "tier": 1 }, + "切符": { "kana": "きっぷ", "romaji": "kippu", "en": "Ticket", "sv": "Biljett", "tier": 1 }, + "トイレ": { "kana": "トイレ", "romaji": "toire", "en": "Toilet", "sv": "Toalett", "tier": 1 }, + "方面": { "kana": "ほうめん", "romaji": "hōmen", "en": "Direction / for", "sv": "Riktning / mot", "tier": 2 }, + "乗換": { "kana": "のりかえ", "romaji": "norikae", "en": "Transfer", "sv": "Byte", "tier": 2 }, + "快速": { "kana": "かいそく", "romaji": "kaisoku", "en": "Rapid", "sv": "Snabbtåg", "tier": 2 }, + "各駅停車": { "kana": "かくえきていしゃ", "romaji": "kakueki teisha", "en": "Local (all stops)", "sv": "Lokaltåg (alla stopp)", "tier": 3 }, + "特急": { "kana": "とっきゅう", "romaji": "tokkyū", "en": "Limited express", "sv": "Expresståg", "tier": 3 }, + "空港": { "kana": "くうこう", "romaji": "kūkō", "en": "Airport", "sv": "Flygplats", "tier": 2 }, + "東口": { "kana": "ひがしぐち", "romaji": "higashiguchi", "en": "East exit", "sv": "Östra utgången", "tier": 2 }, + "西口": { "kana": "にしぐち", "romaji": "nishiguchi", "en": "West exit", "sv": "Västra utgången", "tier": 2 }, + "南口": { "kana": "みなみぐち", "romaji": "minamiguchi", "en": "South exit", "sv": "Södra utgången", "tier": 2 }, + "北口": { "kana": "きたぐち", "romaji": "kitaguchi", "en": "North exit", "sv": "Norra utgången", "tier": 2 }, + "終点": { "kana": "しゅうてん", "romaji": "shūten", "en": "Terminus", "sv": "Slutstation", "tier": 3 }, + "自動券売機": { "kana": "じどうけんばいき", "romaji": "jidō kenbaiki", "en": "Ticket machine", "sv": "Biljettautomat", "tier": 3 }, + "チャージ": { "kana": "チャージ", "romaji": "chāji", "en": "Top up (IC card)", "sv": "Fyll på (kort)", "tier": 2 } +} diff --git a/resources/text/sv.json b/resources/text/sv.json new file mode 100644 index 0000000..2e33b1b --- /dev/null +++ b/resources/text/sv.json @@ -0,0 +1,17 @@ +{ + "game.title": "JP Subway Pussel", + "menu.start": "Starta", + "menu.continue": "Fortsätt", + "menu.endless": "Oändligt läge", + "menu.settings": "Inställningar", + "menu.quit": "Avsluta", + "hud.destination": "Resmål", + "hud.next_train": "Nästa tåg", + "hud.card_balance": "Kortsaldo", + "meter.food": "Mat", + "meter.drink": "Dryck", + "meter.toilet": "Toa", + "meter.battery": "Batteri", + "level.failed": "Du missade för många tåg…", + "level.cleared": "Framme!" +} diff --git a/tools/build/main.go b/tools/build/main.go new file mode 100644 index 0000000..39fb09d --- /dev/null +++ b/tools/build/main.go @@ -0,0 +1,205 @@ +// Byggverktyg: `go run ./tools/build [flaggor]` +// +// Skrivet i Go så att samma kommando fungerar på Linux och Windows utan +// make, bash eller andra extra verktyg. Bygger binären med rätt flaggor för +// debug/release, stämplar version + läge via -ldflags och kopierar +// resources/ bredvid binären. +// +// debug -> build/debug/ + resources/ +// release -> build/release/-/ + resources/ +package main + +import ( + "flag" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" +) + +const ( + appName = "jp-subway-pussel" + mainPkg = "./cmd/" + appName +) + +func main() { + mode := flag.String("mode", "debug", "debug | release") + goos := flag.String("os", runtime.GOOS, "målplattform: linux | windows") + goarch := flag.String("arch", runtime.GOARCH, "arkitektur: amd64 | arm64") + run := flag.Bool("run", false, "starta binären efter bygget (kräver att os/arch matchar den här datorn)") + flag.Parse() + + if *mode != "debug" && *mode != "release" { + fatal("-mode måste vara debug eller release") + } + switch *goos { + case "linux", "windows": + case "darwin": + fatal("macOS kräver cgo och måste byggas på en Mac (`go build` fungerar där, men inte korsbygge härifrån)") + default: + fatal("stöd saknas för -os " + *goos) + } + if *goos == "linux" && (*goos != runtime.GOOS || *goarch != runtime.GOARCH) { + fatal("korsbygge till linux/" + *goarch + " kräver en C-korskompilator (cgo); bygg på målmaskinen i stället") + } + + root := repoRoot() + module := modulePath(root) + version := gitVersion(root) + + outDir := filepath.Join(root, "build", "debug") + if *mode == "release" { + outDir = filepath.Join(root, "build", "release", *goos+"-"+*goarch) + } + exe := appName + if *goos == "windows" { + exe += ".exe" + } + exePath := filepath.Join(outDir, exe) + must(os.MkdirAll(outDir, 0o755)) + + ldflags := []string{ + "-X", module + "/internal/buildinfo.Version=" + version, + "-X", module + "/internal/buildinfo.Mode=" + *mode, + } + args := []string{"build", "-o", exePath} + if *mode == "release" { + ldflags = append(ldflags, "-s", "-w") // strippa symboler/debuginfo + if *goos == "windows" { + ldflags = append(ldflags, "-H", "windowsgui") // inget konsolfönster + } + args = append(args, "-trimpath") + } else { + args = append(args, "-gcflags=all=-N -l") // ingen optimering/inlining -> bättre debugging + } + args = append(args, "-ldflags", strings.Join(ldflags, " "), mainPkg) + + cmd := exec.Command("go", args...) + cmd.Dir = root + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + cmd.Env = append(os.Environ(), "GOOS="+*goos, "GOARCH="+*goarch) + if *goos == "windows" { + // Ebitengine på Windows är ren Go -> korsbygge från Linux utan C-verktyg. + cmd.Env = append(cmd.Env, "CGO_ENABLED=0") + } + start := time.Now() + fmt.Printf("» go %s\n", strings.Join(args, " ")) + must(cmd.Run()) + + // Kopiera resources/ bredvid binären (ersätt gammal kopia). + dst := filepath.Join(outDir, "resources") + must(os.RemoveAll(dst)) + n, err := copyTree(filepath.Join(root, "resources"), dst) + must(err) + + st, _ := os.Stat(exePath) + fmt.Printf("✓ %s-bygge klart på %s\n binär: %s (%.1f MB, %s)\n resurser: %s (%d filer)\n", + *mode, time.Since(start).Round(time.Millisecond), rel(root, exePath), float64(st.Size())/1e6, version, rel(root, dst), n) + + if *run { + if *goos != runtime.GOOS || *goarch != runtime.GOARCH { + fatal("kan inte köra en " + *goos + "/" + *goarch + "-binär här") + } + r := exec.Command(exePath, flag.Args()...) + r.Dir = outDir + r.Stdout, r.Stderr, r.Stdin = os.Stdout, os.Stderr, os.Stdin + if err := r.Run(); err != nil { + fatal(err.Error()) + } + } +} + +// repoRoot letar uppåt från arbetskatalogen efter go.mod. +func repoRoot() string { + dir, err := os.Getwd() + must(err) + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + fatal("hittade ingen go.mod ovanför arbetskatalogen – kör från repot") + } + dir = parent + } +} + +func modulePath(root string) string { + b, err := os.ReadFile(filepath.Join(root, "go.mod")) + must(err) + for _, line := range strings.Split(string(b), "\n") { + if strings.HasPrefix(line, "module ") { + return strings.TrimSpace(strings.TrimPrefix(line, "module ")) + } + } + fatal("go.mod saknar module-rad") + return "" +} + +func gitVersion(root string) string { + cmd := exec.Command("git", "describe", "--tags", "--always", "--dirty") + cmd.Dir = root + out, err := cmd.Output() + if err != nil { + return "dev" + } + return strings.TrimSpace(string(out)) +} + +// copyTree kopierar en mapp rekursivt. README.md-filer i resursmappen är +// dokumentation för utvecklare och hoppas över. +func copyTree(src, dst string) (int, error) { + n := 0 + err := filepath.WalkDir(src, func(p string, d os.DirEntry, err error) error { + if err != nil { + return err + } + relp, _ := filepath.Rel(src, p) + target := filepath.Join(dst, relp) + if d.IsDir() { + return os.MkdirAll(target, 0o755) + } + if strings.EqualFold(d.Name(), "README.md") { + return nil + } + in, err := os.Open(p) + if err != nil { + return err + } + defer in.Close() + out, err := os.Create(target) + if err != nil { + return err + } + defer out.Close() + if _, err := io.Copy(out, in); err != nil { + return err + } + n++ + return nil + }) + return n, err +} + +func rel(root, p string) string { + if r, err := filepath.Rel(root, p); err == nil { + return r + } + return p +} + +func must(err error) { + if err != nil { + fatal(err.Error()) + } +} + +func fatal(msg string) { + fmt.Fprintln(os.Stderr, "build: "+msg) + os.Exit(1) +}