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

20
.gitignore vendored Normal file
View File

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

3
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["golang.go"]
}

43
.vscode/launch.json vendored Normal file
View File

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

7
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"go.testFlags": ["-count=1"],
"go.lintOnSave": "package",
"go.vetOnSave": "package",
"files.exclude": { "build/": true },
"[go]": { "editor.formatOnSave": true }
}

81
.vscode/tasks.json vendored Normal file
View File

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

119
README.md Normal file
View File

@@ -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 <remote>/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 <https://go.dev/dl/>. 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
```

View File

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

130
doc/mal-och-tester.md Normal file
View File

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

64
doc/plan.md Normal file
View File

@@ -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/<os>-<arch>/` ä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.)

233
doc/roadmap.md Normal file
View File

@@ -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/<os>-<arch>/
```
### 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.

189
doc/spelbeskrivning.md Normal file
View File

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

13
go.mod Normal file
View File

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

14
go.sum Normal file
View File

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

View File

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

View File

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

135
internal/game/game.go Normal file
View File

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

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

135
internal/res/res.go Normal file
View File

@@ -0,0 +1,135 @@
// Package res hittar och läser resurser (sprites, ljud, text, speldata).
//
// Resursmappen ligger normalt bredvid binären (`<exe-mapp>/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")

76
internal/res/res_test.go Normal file
View File

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

48
internal/rng/rng.go Normal file
View File

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

38
internal/rng/rng_test.go Normal file
View File

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

22
resources/data/README.md Normal file
View File

@@ -0,0 +1,22 @@
# resources/data – speldata
Banor, items, skills och annan data som spelet läser vid start. Allt är JSON.
- `levels/<id>.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.

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 B

8
resources/text/README.md Normal file
View File

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

17
resources/text/en.json Normal file
View File

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

View File

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

17
resources/text/sv.json Normal file
View File

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

205
tools/build/main.go Normal file
View File

@@ -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/<binär> + resources/
// release -> build/release/<os>-<arch>/<binär> + 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)
}