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:
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal 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
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["golang.go"]
|
||||
}
|
||||
43
.vscode/launch.json
vendored
Normal file
43
.vscode/launch.json
vendored
Normal 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
7
.vscode/settings.json
vendored
Normal 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
81
.vscode/tasks.json
vendored
Normal 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
119
README.md
Normal 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
|
||||
```
|
||||
42
cmd/jp-subway-pussel/main.go
Normal file
42
cmd/jp-subway-pussel/main.go
Normal 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
130
doc/mal-och-tester.md
Normal 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
64
doc/plan.md
Normal 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
233
doc/roadmap.md
Normal 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
189
doc/spelbeskrivning.md
Normal 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
13
go.mod
Normal 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
14
go.sum
Normal 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=
|
||||
58
internal/archtest/arch_test.go
Normal file
58
internal/archtest/arch_test.go
Normal 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))
|
||||
}
|
||||
12
internal/buildinfo/buildinfo.go
Normal file
12
internal/buildinfo/buildinfo.go
Normal 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
135
internal/game/game.go
Normal 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
228
internal/level/level.go
Normal file
@@ -0,0 +1,228 @@
|
||||
// Package level definierar banformatet och ren spellogik kring det
|
||||
// (validering, framkomlighet). Paketet importerar inte Ebitengine och
|
||||
// kan därför testas utan fönster.
|
||||
//
|
||||
// Banor lagras som JSON i resources/data/levels/. Rutnätet är en lista av
|
||||
// lika långa strängar där varje tecken är en ruta (se Tile-konstanterna).
|
||||
package level
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Tile är ett tecken i rutnätet.
|
||||
type Tile byte
|
||||
|
||||
// Tecknen som får förekomma i ett rutnät.
|
||||
const (
|
||||
Wall Tile = '#' // vägg, ej passerbar
|
||||
Floor Tile = '.' // golv
|
||||
Spawn Tile = 'S' // startpunkt (exakt en)
|
||||
Platform Tile = 'P' // perrong med ett tåg (målet om tåget är rätt)
|
||||
WrongPlatform Tile = 'p' // perrong med fel tåg
|
||||
Gate Tile = 'G' // spärr (kräver kort/biljett)
|
||||
TicketMachine Tile = 'T' // biljett-/påfyllningsautomat
|
||||
Sign Tile = 'i' // skylt
|
||||
)
|
||||
|
||||
var knownTiles = map[Tile]bool{
|
||||
Wall: true, Floor: true, Spawn: true, Platform: true, WrongPlatform: true,
|
||||
Gate: true, TicketMachine: true, Sign: true,
|
||||
}
|
||||
|
||||
// Passable rapporterar om en ruta går att gå på (utan hänsyn till spärrar).
|
||||
func (t Tile) Passable() bool { return t != Wall }
|
||||
|
||||
// Text är en flerspråkig sträng, t.ex. {"sv": "Flygplatsen", "en": "The Airport", "ja": "空港"}.
|
||||
type Text map[string]string
|
||||
|
||||
// Get hämtar texten på ett språk med fallback till engelska, sedan vilket som helst.
|
||||
func (t Text) Get(lang string) string {
|
||||
if s, ok := t[lang]; ok {
|
||||
return s
|
||||
}
|
||||
if s, ok := t["en"]; ok {
|
||||
return s
|
||||
}
|
||||
for _, s := range t {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Train är ett tåg (eller en buss) som avgår från en perrong i banan.
|
||||
type Train struct {
|
||||
Platform string `json:"platform"` // vilket perrong-tecken (P/p) tåget står vid
|
||||
Line Text `json:"line"` // linjens namn
|
||||
Destination Text `json:"destination"` // slutstation
|
||||
Correct bool `json:"correct"` // tar spelaren till rätt resmål
|
||||
}
|
||||
|
||||
// Level är en bana.
|
||||
type Level struct {
|
||||
ID string `json:"id"`
|
||||
Name Text `json:"name"`
|
||||
Difficulty int `json:"difficulty"` // 1 (lätt) … 5 (svår)
|
||||
Destination Text `json:"destination"` // dit spelaren ska
|
||||
TimeLimitSec int `json:"timeLimitSec"` // 0 = ingen tidsgräns
|
||||
Grid []string `json:"grid"`
|
||||
Trains []Train `json:"trains"`
|
||||
}
|
||||
|
||||
// Point är en rutkoordinat.
|
||||
type Point struct{ X, Y int }
|
||||
|
||||
// Parse avkodar och validerar en bana.
|
||||
func Parse(data []byte) (*Level, error) {
|
||||
var l Level
|
||||
if err := json.Unmarshal(data, &l); err != nil {
|
||||
return nil, fmt.Errorf("level: ogiltig JSON: %w", err)
|
||||
}
|
||||
if err := l.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
// Width och Height i rutor.
|
||||
func (l *Level) Width() int {
|
||||
if len(l.Grid) == 0 {
|
||||
return 0
|
||||
}
|
||||
return len(l.Grid[0])
|
||||
}
|
||||
func (l *Level) Height() int { return len(l.Grid) }
|
||||
|
||||
// At returnerar rutan på (x, y); utanför kartan räknas som vägg.
|
||||
func (l *Level) At(x, y int) Tile {
|
||||
if y < 0 || y >= len(l.Grid) || x < 0 || x >= len(l.Grid[y]) {
|
||||
return Wall
|
||||
}
|
||||
return Tile(l.Grid[y][x])
|
||||
}
|
||||
|
||||
// Find returnerar alla rutor av en viss typ.
|
||||
func (l *Level) Find(t Tile) []Point {
|
||||
var pts []Point
|
||||
for y, row := range l.Grid {
|
||||
for x := 0; x < len(row); x++ {
|
||||
if Tile(row[x]) == t {
|
||||
pts = append(pts, Point{x, y})
|
||||
}
|
||||
}
|
||||
}
|
||||
return pts
|
||||
}
|
||||
|
||||
// SpawnPoint returnerar startrutan (valideringen garanterar att den finns).
|
||||
func (l *Level) SpawnPoint() Point { return l.Find(Spawn)[0] }
|
||||
|
||||
// Validate kontrollerar att banan är rimlig: rektangulär, bara kända tecken,
|
||||
// exakt en start, minst en perrong, och att varje tåg står vid ett
|
||||
// perrong-tecken som finns i rutnätet.
|
||||
func (l *Level) Validate() error {
|
||||
if l.ID == "" {
|
||||
return fmt.Errorf("level: saknar id")
|
||||
}
|
||||
if len(l.Grid) == 0 {
|
||||
return fmt.Errorf("level %s: tomt rutnät", l.ID)
|
||||
}
|
||||
w := len(l.Grid[0])
|
||||
spawns, platforms := 0, 0
|
||||
for y, row := range l.Grid {
|
||||
if len(row) != w {
|
||||
return fmt.Errorf("level %s: rad %d har längd %d, förväntade %d", l.ID, y, len(row), w)
|
||||
}
|
||||
for x := 0; x < len(row); x++ {
|
||||
t := Tile(row[x])
|
||||
if !knownTiles[t] {
|
||||
return fmt.Errorf("level %s: okänt tecken %q på (%d,%d)", l.ID, row[x], x, y)
|
||||
}
|
||||
switch t {
|
||||
case Spawn:
|
||||
spawns++
|
||||
case Platform, WrongPlatform:
|
||||
platforms++
|
||||
}
|
||||
}
|
||||
}
|
||||
if spawns != 1 {
|
||||
return fmt.Errorf("level %s: förväntade exakt en start (S), hittade %d", l.ID, spawns)
|
||||
}
|
||||
if platforms == 0 {
|
||||
return fmt.Errorf("level %s: saknar perronger (P/p)", l.ID)
|
||||
}
|
||||
if len(l.Trains) == 0 {
|
||||
return fmt.Errorf("level %s: saknar tåg", l.ID)
|
||||
}
|
||||
correct := 0
|
||||
for i, tr := range l.Trains {
|
||||
if len(tr.Platform) != 1 || (Tile(tr.Platform[0]) != Platform && Tile(tr.Platform[0]) != WrongPlatform) {
|
||||
return fmt.Errorf("level %s: tåg %d har ogiltig perrong %q", l.ID, i, tr.Platform)
|
||||
}
|
||||
if !strings.Contains(strings.Join(l.Grid, ""), tr.Platform) {
|
||||
return fmt.Errorf("level %s: tåg %d står vid perrong %q som inte finns i rutnätet", l.ID, i, tr.Platform)
|
||||
}
|
||||
if tr.Correct {
|
||||
correct++
|
||||
}
|
||||
}
|
||||
if correct == 0 {
|
||||
return fmt.Errorf("level %s: inget tåg är markerat som rätt", l.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reachable returnerar alla rutor som nås från start via passerbara rutor
|
||||
// (4-riktningars BFS). Spärrar räknas som passerbara.
|
||||
func (l *Level) Reachable() map[Point]bool {
|
||||
start := l.SpawnPoint()
|
||||
seen := map[Point]bool{start: true}
|
||||
queue := []Point{start}
|
||||
for len(queue) > 0 {
|
||||
p := queue[0]
|
||||
queue = queue[1:]
|
||||
for _, d := range [...]Point{{1, 0}, {-1, 0}, {0, 1}, {0, -1}} {
|
||||
n := Point{p.X + d.X, p.Y + d.Y}
|
||||
if seen[n] || !l.At(n.X, n.Y).Passable() {
|
||||
continue
|
||||
}
|
||||
seen[n] = true
|
||||
queue = append(queue, n)
|
||||
}
|
||||
}
|
||||
return seen
|
||||
}
|
||||
|
||||
// Solvable rapporterar om minst en perrong med ett rätt tåg kan nås från start.
|
||||
func (l *Level) Solvable() bool {
|
||||
reach := l.Reachable()
|
||||
for _, tr := range l.Trains {
|
||||
if !tr.Correct {
|
||||
continue
|
||||
}
|
||||
for _, p := range l.Find(Tile(tr.Platform[0])) {
|
||||
if reach[p] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Unreachable listar interaktiva rutor (perronger, automater, skyltar) som
|
||||
// inte går att nå – praktiskt som varning när man bygger banor.
|
||||
func (l *Level) Unreachable() []Point {
|
||||
reach := l.Reachable()
|
||||
var out []Point
|
||||
for _, t := range []Tile{Platform, WrongPlatform, TicketMachine, Sign, Gate} {
|
||||
for _, p := range l.Find(t) {
|
||||
if !reach[p] {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
137
internal/level/level_test.go
Normal file
137
internal/level/level_test.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package level
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.brasse-pc.eu/brasse/jp-subway-pussel/internal/res"
|
||||
)
|
||||
|
||||
const sample = `{
|
||||
"id": "test",
|
||||
"name": {"sv": "Test"},
|
||||
"difficulty": 1,
|
||||
"destination": {"sv": "Mål"},
|
||||
"grid": [
|
||||
"#######",
|
||||
"#S..#P#",
|
||||
"#.#.#.#",
|
||||
"#.#...#",
|
||||
"#######"
|
||||
],
|
||||
"trains": [{"platform": "P", "line": {"en": "A"}, "destination": {"en": "B"}, "correct": true}]
|
||||
}`
|
||||
|
||||
func mustParse(t *testing.T, s string) *Level {
|
||||
t.Helper()
|
||||
l, err := Parse([]byte(s))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func TestParseAndSolve(t *testing.T) {
|
||||
l := mustParse(t, sample)
|
||||
if l.Width() != 7 || l.Height() != 5 {
|
||||
t.Fatalf("storlek %dx%d", l.Width(), l.Height())
|
||||
}
|
||||
if l.SpawnPoint() != (Point{1, 1}) {
|
||||
t.Fatalf("spawn %v", l.SpawnPoint())
|
||||
}
|
||||
if !l.Solvable() {
|
||||
t.Fatal("banan ska vara lösbar")
|
||||
}
|
||||
if u := l.Unreachable(); len(u) != 0 {
|
||||
t.Fatalf("oväntat onåbara rutor: %v", u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsolvableWhenWalledOff(t *testing.T) {
|
||||
l := mustParse(t, strings.Replace(sample, `"#.#...#"`, `"#.#.#.#"`, 1))
|
||||
if l.Solvable() {
|
||||
t.Fatal("banan ska INTE vara lösbar när perrongen är inmurad")
|
||||
}
|
||||
if u := l.Unreachable(); len(u) != 1 || u[0] != (Point{5, 1}) {
|
||||
t.Fatalf("Unreachable = %v, vill ha [(5,1)]", u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutsideGridIsWall(t *testing.T) {
|
||||
l := mustParse(t, sample)
|
||||
if l.At(-1, 0).Passable() || l.At(0, 99).Passable() {
|
||||
t.Fatal("utanför kartan ska räknas som vägg")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationErrors(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"ojämn rad": strings.Replace(sample, `"#.#.#.#"`, `"#.#.#.##"`, 1),
|
||||
"okänt tecken": strings.Replace(sample, `"#.#.#.#"`, `"#.#.#X#"`, 1),
|
||||
"två starter": strings.Replace(sample, `"#S..#P#"`, `"#S.S#P#"`, 1),
|
||||
"ingen start": strings.Replace(sample, `"#S..#P#"`, `"#...#P#"`, 1),
|
||||
"ingen perrong": strings.Replace(sample, `"#S..#P#"`, `"#S..#.#"`, 1),
|
||||
"inget rätt tåg": strings.Replace(sample, `"correct": true`, `"correct": false`, 1),
|
||||
"tåg vid fel perrong": strings.Replace(sample, `"platform": "P"`, `"platform": "p"`, 1),
|
||||
"saknar id": strings.Replace(sample, `"id": "test"`, `"id": ""`, 1),
|
||||
"trasig json": "{",
|
||||
}
|
||||
for name, src := range cases {
|
||||
if _, err := Parse([]byte(src)); err == nil {
|
||||
t.Errorf("%s: förväntade fel", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextFallback(t *testing.T) {
|
||||
tx := Text{"en": "Exit", "ja": "出口"}
|
||||
if tx.Get("ja") != "出口" || tx.Get("sv") != "Exit" {
|
||||
t.Fatal("fel fallback")
|
||||
}
|
||||
if (Text{"ja": "x"}).Get("sv") != "x" {
|
||||
t.Fatal("fallback till godtyckligt språk saknas")
|
||||
}
|
||||
if (Text{}).Get("sv") != "" {
|
||||
t.Fatal("tom text ska ge tom sträng")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllShippedLevels är ett datadrivet test: varje bana under
|
||||
// resources/data/levels måste parsa, validera och vara lösbar.
|
||||
// Lägger man till en bana som inte går att klara faller bygget här.
|
||||
func TestAllShippedLevels(t *testing.T) {
|
||||
dir, err := res.Path("data", "levels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
files, err := filepath.Glob(filepath.Join(dir, "*.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
t.Fatalf("inga banor i %s", dir)
|
||||
}
|
||||
for _, f := range files {
|
||||
t.Run(filepath.Base(f), func(t *testing.T) {
|
||||
b, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l, err := Parse(b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := strings.TrimSuffix(filepath.Base(f), ".json"); l.ID != want {
|
||||
t.Errorf("id %q matchar inte filnamnet %q", l.ID, want)
|
||||
}
|
||||
if !l.Solvable() {
|
||||
t.Error("banan går inte att klara från start")
|
||||
}
|
||||
if u := l.Unreachable(); len(u) > 0 {
|
||||
t.Errorf("onåbara interaktiva rutor: %v", u)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
135
internal/res/res.go
Normal file
135
internal/res/res.go
Normal 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
76
internal/res/res_test.go
Normal 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
48
internal/rng/rng.go
Normal 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
38
internal/rng/rng_test.go
Normal 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
22
resources/data/README.md
Normal 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.
|
||||
35
resources/data/levels/001-flygplatsen.json
Normal file
35
resources/data/levels/001-flygplatsen.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
6
resources/sound/README.md
Normal file
6
resources/sound/README.md
Normal 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.
|
||||
7
resources/sprites/README.md
Normal file
7
resources/sprites/README.md
Normal 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.
|
||||
BIN
resources/sprites/placeholder.png
Normal file
BIN
resources/sprites/placeholder.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 243 B |
8
resources/text/README.md
Normal file
8
resources/text/README.md
Normal 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
17
resources/text/en.json
Normal 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!"
|
||||
}
|
||||
23
resources/text/jp-dictionary.json
Normal file
23
resources/text/jp-dictionary.json
Normal 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
17
resources/text/sv.json
Normal 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
205
tools/build/main.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user