M1: rörelse, kollision, kamera, scener, tileset, skärmdumpar, CI
Some checks failed
release / build-release (push) Failing after 27s

- internal/sim: rutbaserad Mover (4 riktningar, riktningsbyte vid rutgräns,
  bildrute-exakt ankomst) och Camera (följer, klampar, centrerar små kartor),
  med tester
- internal/game: scener meny -> bana -> resultat, tangentbord + gamepad,
  tileset och spelaranimation, HUD, skriptläge (-script) för deterministiska
  skärmdumpar och tester
- tools/genart genererar placeholder-grafik (tiles.png, player.png);
  tools/shots tar doc/img/*.png som README och wiki visar
- bana 002-shinjuku (33x21) för kamerascroll; level.List/LoadFile/TrainAt
- .gitea/workflows/release.yaml: vet + test + linux/windows-zippar till
  rullande latest på brasse-linux01-runnern (linux-amd64)
- docs: roadmap M1 klar, tester, plan, README med bilder och styrning

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 19:16:53 +02:00
parent ae50d444a4
commit 4ae7dba291
34 changed files with 1730 additions and 124 deletions

View File

@@ -0,0 +1,121 @@
name: release
# Kör tester och bygger release-zippar för linux-amd64 och windows-amd64.
# Varje push till master uppdaterar den rullande releasen "latest"; en tagg
# vX.Y.Z ger dessutom en versionerad release.
#
# Körs ALLTID på brasse-linux01-runnern (label linux-amd64): Linux-bygget
# behöver cgo + X11/GL-headers för amd64, vilket Pi5:n (arm64) inte kan ge
# utan korskompilator – och Pi5:n ska inte belastas med spelbyggen.
# Är datorn av köas jobbet tills runnern är tillbaka (ofarligt: pushar görs
# ändå därifrån). Första steget vägrar köra på arm64 som skydd om labels ändras.
on:
push:
branches: [master]
tags: ["v*"]
workflow_dispatch:
inputs:
tag:
description: "Release-tag (lämna tom för 'latest')"
required: false
jobs:
build-release:
runs-on: linux-amd64
container:
image: golang:1.27-bookworm
steps:
- name: Vägra bygga på arm64/Pi5
run: |
if [ "$(uname -m)" != "x86_64" ]; then
echo "::error::Fel runner ($(uname -m)). Spelbygget ska köras på brasse-linux01 (label linux-amd64)."
exit 1
fi
echo "Runner OK: $(uname -m) på $(hostname)"
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # taggar behövs för versionsstämpeln (git describe)
- name: Byggberoenden (X11/GL/ALSA-headers för Ebitengine på Linux)
run: |
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
gcc libc6-dev pkg-config libgl1-mesa-dev libx11-dev libxrandr-dev libxcursor-dev \
libxinerama-dev libxi-dev libxxf86vm-dev libasound2-dev zip jq curl git >/dev/null
git config --global --add safe.directory '*'
go version
- name: Cache Go-moduler
uses: actions/cache@v4
with:
path: /go/pkg/mod
key: gomod-${{ hashFiles('go.sum') }}
restore-keys: gomod-
- name: Bestäm release-tag
id: t
run: |
case "$GITHUB_REF" in
refs/tags/*) T="$GITHUB_REF_NAME" ;;
*) T="${{ inputs.tag }}"; [ -z "$T" ] && T="latest" ;;
esac
echo "tag=$T" >> "$GITHUB_OUTPUT"
echo "bygger release: $T"
- name: Vet + test
run: |
go vet ./...
go test ./...
- name: Bygg release (linux-amd64 + windows-amd64)
run: |
go run ./tools/build -mode release -os linux -arch amd64
go run ./tools/build -mode release -os windows -arch amd64
mkdir -p out
(cd build/release && zip -qr ../../out/metro-puzzle-jp-linux-amd64.zip linux-amd64)
(cd build/release && zip -qr ../../out/metro-puzzle-jp-windows-amd64.zip windows-amd64)
(cd out && sha256sum *.zip > checksums.txt && ls -la && cat checksums.txt)
- name: Skapa/uppdatera Gitea-release + ladda upp assets
env:
REPO: ${{ github.repository }}
TAG: ${{ steps.t.outputs.tag }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# gitea-d finns bara på Pi5:ns dockernät; härifrån används publika adressen
API=http://gitea-d:3000/api/v1
curl -sf -m 5 "$API/version" >/dev/null 2>&1 || API=https://gitea.brasse-pc.eu/api/v1
echo "API: $API"
auth() { curl -s -H "Authorization: token $TOKEN" "$@"; }
VER=$(./build/release/linux-amd64/metro-puzzle-jp -version 2>/dev/null | awk '{print $2}' || echo "$TAG")
BODY="Metro Puzzle JP $TAG ($VER, commit ${GITHUB_SHA:0:10}).
Packa upp zippen för din plattform och kör binären i mappen – resources/ måste ligga bredvid.
- Linux: kräver ett skrivbord med OpenGL (mesa) och X11/XWayland. Ljud via ALSA/PipeWire.
- Windows 10+: inga extra beroenden."
if [ "$TAG" = "latest" ]; then
# rullande release: ta bort den gamla så att taggen följer master
rid=$(auth "$API/repos/$REPO/releases/tags/$TAG" | jq -r '.id // empty')
[ -n "$rid" ] && auth -X DELETE "$API/repos/$REPO/releases/$rid" -o /dev/null
auth -X DELETE "$API/repos/$REPO/tags/$TAG" -o /dev/null || true
JSON=$(jq -n --arg t "$TAG" --arg c "$GITHUB_SHA" --arg n "latest ($VER)" --arg b "$BODY" \
'{tag_name:$t, target_commitish:$c, name:$n, body:$b, prerelease:true}')
else
JSON=$(jq -n --arg t "$TAG" --arg b "$BODY" '{tag_name:$t, name:$t, body:$b}')
fi
RESP=$(auth -X POST "$API/repos/$REPO/releases" -H 'Content-Type: application/json' -d "$JSON")
rid=$(echo "$RESP" | jq -r '.id // empty')
[ -z "$rid" ] && rid=$(auth "$API/repos/$REPO/releases/tags/$TAG" | jq -r '.id // empty')
[ -n "$rid" ] && [ "$rid" != "null" ] || { echo "kunde inte skapa release: $RESP"; exit 1; }
echo "release id: $rid"
for f in metro-puzzle-jp-linux-amd64.zip metro-puzzle-jp-windows-amd64.zip checksums.txt; do
aid=$(auth "$API/repos/$REPO/releases/$rid/assets" | jq -r ".[] | select(.name==\"$f\") | .id")
if [ -n "$aid" ] && [ "$aid" != "null" ]; then
auth -X DELETE "$API/repos/$REPO/releases/$rid/assets/$aid" -o /dev/null
fi
echo "laddar upp $f ..."
auth -X POST "$API/repos/$REPO/releases/$rid/assets?name=$f" -F "attachment=@out/$f" -o /dev/null -w " -> HTTP %{http_code}\n"
done
echo "klart: https://gitea.brasse-pc.eu/$REPO/releases/tag/$TAG"

View File

@@ -10,8 +10,22 @@ 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+ 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). upp. Hela speldesignen finns i [doc/spelbeskrivning.md](doc/spelbeskrivning.md).
**Status:** M0 – skelett och byggkedja klara (2026-10-23). Se **Status:** M1 klar (2026-10-23) – man kan gå runt i labyrinterna med kamera
[doc/roadmap.md](doc/roadmap.md) för vad som byggs härnäst. som följer, välja bana i menyn och nå perrongen. Nästa är M2: tåg med
tidtabell, spärrar och första riktigt spelbara banan. Se
[doc/roadmap.md](doc/roadmap.md).
![Bana 001 – Flygplatsen](doc/img/level-001.png)
Så fungerar det: du startar på den gröna dörrmattan, letar dig genom
stationen förbi skyltar, automater och spärrar, och ställer dig på en
perrong (gul ledlinje). Rätt perrong tar dig till resmålet, fel perrong ger
fel tåg.
![Resultat – rätt tåg](doc/img/result.png)
Fler bilder och en genomgång finns i
[wikin](https://gitea.brasse-pc.eu/brasse/metro-puzzle-jp/wiki).
## Bygga ## Bygga
@@ -80,12 +94,27 @@ arbetskatalogen (så `go run` och F5 fungerar), och `JP_RESOURCES=/sökväg`
### Flaggor ### Flaggor
``` ```
metro-puzzle-jp -level 001-flygplatsen # starta en viss bana metro-puzzle-jp # meny
metro-puzzle-jp -level 001-flygplatsen # hoppa direkt in i en bana
metro-puzzle-jp -scale 3 # fönsterstorlek (320x180 × skala) metro-puzzle-jp -scale 3 # fönsterstorlek (320x180 × skala)
metro-puzzle-jp -smoke 90 # öppna fönster, avsluta efter 90 bildrutor (smoke-test) metro-puzzle-jp -smoke 90 # öppna fönster, avsluta efter 90 bildrutor (smoke-test)
metro-puzzle-jp -script "move right 4; shot bild.png; quit" # skriptläge, se internal/game/script.go
metro-puzzle-jp -version metro-puzzle-jp -version
``` ```
### Styrning
Pilar eller WASD går, Enter väljer, Esc går tillbaka, R spelar om. Gamepad
med standardlayout fungerar: d-pad/vänster spak, A = välj, B = tillbaka.
### Verktyg
| Kommando | Gör |
|----------|-----|
| `go run ./tools/build …` | bygger (se ovan) |
| `go run ./tools/genart` | genererar placeholder-grafiken `resources/sprites/tiles.png` och `player.png` |
| `go run ./tools/shots` | tar dokumentationens skärmdumpar till `doc/img/` – **kör efter varje grafik-/UI-ändring och committa bilderna** |
## Beroenden ## Beroenden
| | Linux | Windows | | | Linux | Windows |
@@ -109,11 +138,21 @@ vid körning, så den fungerar på alla vanliga distributioner.
``` ```
cmd/metro-puzzle-jp/ programstart cmd/metro-puzzle-jp/ programstart
internal/game/ Ebitengine-loop och rendering (enda paketet som får känna till Ebitengine) internal/game/ Ebitengine: scener, input, rendering, skriptläge (enda paketet som får känna till Ebitengine)
internal/sim/ rutbaserad rörelse, kollision, kamera (ren Go)
internal/level/ banformat, validering, framkomlighet internal/level/ banformat, validering, framkomlighet
internal/res/ hittar och läser resources/ internal/res/ hittar och läser resources/
internal/rng/ seedad slump internal/rng/ seedad slump
tools/build/ byggverktyget tools/ build/ genart/ shots/
resources/ sprites/ sound/ text/ data/ – kopieras bredvid binären resources/ sprites/ sound/ text/ data/ – kopieras bredvid binären
doc/ dokumentation doc/ dokumentation, doc/img/ = skärmdumpar
.gitea/workflows/ CI: test + release-zippar (linux/windows)
``` ```
## Release
Varje push till master bygger zippar för Linux och Windows på Gitea och
lägger dem på releasen
[latest](https://gitea.brasse-pc.eu/brasse/metro-puzzle-jp/releases/tag/latest).
En tagg `vX.Y.Z` ger en versionerad release. Packa upp och kör binären;
`resources/` måste ligga bredvid.

View File

@@ -14,9 +14,10 @@ import (
) )
func main() { func main() {
levelID := flag.String("level", "001-flygplatsen", "bana att starta (id = filnamn utan .json)") levelID := flag.String("level", "", "hoppa direkt in i en bana (id = filnamn utan .json); tom = meny")
scale := flag.Int("scale", 4, "fönsterskala (intern upplösning 320x180 × skala)") scale := flag.Int("scale", 4, "fönsterskala (intern upplösning 320x180 × skala)")
smoke := flag.Int("smoke", 0, "avsluta automatiskt efter N bildrutor (smoke-test)") smoke := flag.Int("smoke", 0, "avsluta automatiskt efter N bildrutor (smoke-test)")
script := flag.String("script", "", "skriptade steg för skärmdumpar/tester, t.ex. \"wait 10; shot menu.png; quit\" (se internal/game/script.go)")
version := flag.Bool("version", false, "skriv ut version och avsluta") version := flag.Bool("version", false, "skriv ut version och avsluta")
flag.Parse() flag.Parse()
@@ -25,7 +26,7 @@ func main() {
return return
} }
g, err := game.New(game.Options{LevelID: *levelID, SmokeFrames: *smoke}) g, err := game.New(game.Options{LevelID: *levelID, SmokeFrames: *smoke, Script: *script})
if err != nil { if err != nil {
log.Fatalf("kunde inte starta: %v", err) log.Fatalf("kunde inte starta: %v", err)
} }

BIN
doc/img/level-001.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
doc/img/level-002.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
doc/img/menu.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
doc/img/result.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -34,7 +34,7 @@ att bygga på Linux som på Windows.
| Inkrementellt debug-bygge | < 10 s | 4,7 s | | Inkrementellt debug-bygge | < 10 s | 4,7 s |
| Release-binär | < 20 MB | 10,2 MB (Linux), 10,8 MB (Windows) | | Release-binär | < 20 MB | 10,2 MB (Linux), 10,8 MB (Windows) |
| Testsviten | `go test ./...` grön på < 30 s | ~0,1 s | | Testsviten | `go test ./...` grön på < 30 s | ~0,1 s |
| Banor | 100 % passerar det automatiska lösbarhetstestet | 1 av 1 | | Banor | 100 % passerar det automatiska lösbarhetstestet | 2 av 2 |
| Windows-korsbygge från Linux | fungerar utan extra verktyg | ✓ (CGO_ENABLED=0) | | 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) | | 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 | | Bildfrekvens | 60 fps vid 4× skala på integrerad grafik | ej mätt |
@@ -65,7 +65,7 @@ att bygga på Linux som på Windows.
| 2. Data | Allt under `resources/` | Datadrivna tester som läser filerna | `go test ./...` | | 2. Data | Allt under `resources/` | Datadrivna tester som läser filerna | `go test ./...` |
| 3. Arkitektur | Beroenderegler | `internal/archtest` | `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 ./...` | | 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 | `metro-puzzle-jp -smoke 90` | manuellt / med skärm | | 5. Smoke | Binären öppnar fönster och avslutar | `metro-puzzle-jp -smoke 90`, eller `tools/shots` som dessutom spelar igenom bana 001 i skriptläge | manuellt / med skärm |
| 6. Speltest | Känsla, svårighet, buggar | Checklistan nedan | före varje release | | 6. Speltest | Känsla, svårighet, buggar | Checklistan nedan | före varje release |
### Tester som finns i dag ### Tester som finns i dag
@@ -78,13 +78,18 @@ att bygga på Linux som på Windows.
| `internal/level` | `TestParseAndSolve`, `TestUnsolvableWhenWalledOff` | BFS hittar/hittar inte väg till rätt perrong | | `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` | `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/level` | `TestAllShippedLevels` | Varje bana i `resources/data/levels` parsar, har id = filnamn, är lösbar och har inga onåbara interaktiva rutor |
| `internal/level` | `TestListAndLoadFile` | Banlistan är sorterad, laddning från disk, `TrainAt` hittar rätt/fel tåg |
| `internal/sim` | `TestMoveFourDirections`, `TestBlockedByWallTurnsButStays` | Rörelse åt alla håll; vägg stoppar men vänder figuren |
| `internal/sim` | `TestContinuousMovementKeepsPace`, `TestStopsAtTileBoundaryWithoutInput`, `TestDirectionChangesOnlyAtTileBoundary` | Jämn fart med hållen tangent, stopp vid rutgräns, riktningsbyte bara vid rutgräns |
| `internal/sim` | `TestWorldPosInterpolates`, `TestFrameExactArrival`, `TestHugeDtIsBounded` | Pixelposition mellan rutor; exakt 10 bildrutor per ruta vid 6 rutor/s; inga oändliga loopar |
| `internal/sim` | `TestCamera*` | Kameran centrerar, klampas mot kanter och centrerar små världar |
| `internal/archtest` | `TestOnlyPresentationPackagesImportEbitengine` | Beroenderegeln ovan | | `internal/archtest` | `TestOnlyPresentationPackagesImportEbitengine` | Beroenderegeln ovan |
### Planerade tester per milstolpe ### Planerade tester per milstolpe
| Milstolpe (se roadmap) | Tester som ska finnas när den är klar | | 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 | | ~~M1 Rörelse & kamera~~ ✓ | Kollision mot vägg, rörelse i 4 riktningar, kamera klampas till kartan – finns, se tabellen ovan |
| 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 | | 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å) | | 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 | | M4 Lämmeltåget | Mätare sjunker med rätt takt; event-frekvens ökar med gruppstorlek; event väljs deterministiskt per seed |

View File

@@ -24,15 +24,15 @@ mål för spelet, men Gitea-CI kan byggas på Pi5-runnern senare.
## Leverans ## Leverans
Mönster A enligt playbooken (nedladdningsbar binär), när projektet ska Mönster A enligt playbooken (nedladdningsbar binär). Repot ligger på Gitea
delas: `build/release/<os>-<arch>/` är redan formen som en release-zip ska (`brasse/metro-puzzle-jp`, sedan 2026-10-23) och CI bygger release-zippar
ha. Repot är **lokalt** nu och synkas till Gitea senare (Björn skapar repot för Linux och Windows till den rullande releasen `latest` vid varje push
och lägger till `claude` som medlem, därefter `git remote add origin till master; en tagg `vX.Y.Z` ger en versionerad release. Byggs på
gitea-claude:brasse/metro-puzzle-jp.git`). brasse-linux01-runnern, inte Pi5:n.
## Milstolpar ## Milstolpar
M0 skelett ✓ → M1 rörelse → M2 första spelbara banan → M3 japanska/skyltar M0 skelett ✓ → M1 rörelse/kamera ✓ → M2 första spelbara banan → M3 japanska/skyltar
→ M4 lämmeltåget → M5 minikarta → M6 kort/biljetter/minispel → M7 → M4 lämmeltåget → M5 minikarta → M6 kort/biljetter/minispel → M7
svårighet/poäng/debuffar → M8 progression/sparfiler → M9 navigeringslägen → 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. M10 story-innehåll → M11 oändligt läge/NG+ → M12 ljud/polish/release.

View File

@@ -58,16 +58,20 @@ metro-puzzle-jp/
│ ├── res/ hittar och läser resources/ (ren Go) │ ├── res/ hittar och läser resources/ (ren Go)
│ ├── rng/ seedad slump (ren Go) │ ├── rng/ seedad slump (ren Go)
│ ├── level/ banformat, validering, BFS (ren Go) │ ├── level/ banformat, validering, BFS (ren Go)
│ ├── game/ Ebitengine-loop, scener, rendering ← får använda Ebitengine │ ├── sim/ rutbaserad rörelse, kollision, kamera (ren Go)
│ ├── game/ Ebitengine-loop, scener, input, rendering, skriptläge ← får använda Ebitengine
│ ├── archtest/ beroenderegler │ ├── archtest/ beroenderegler
│ └── … kommande: sim/, lang/, nav/, score/, save/, minigame/, ui/, audio/ │ └── … kommande: lang/, nav/, score/, save/, minigame/, ui/, audio/
├── tools/build/ byggverktyget ├── tools/build/ byggverktyget
├── tools/genart/ genererar placeholder-grafik (tiles.png, player.png)
├── tools/shots/ tar dokumentationens skärmdumpar till doc/img/
├── resources/ kopieras bredvid binären vid bygge ├── resources/ kopieras bredvid binären vid bygge
│ ├── sprites/ bilder, tilesets, UI │ ├── sprites/ bilder, tilesets, UI
│ ├── sound/ OGG/WAV │ ├── sound/ OGG/WAV
│ ├── text/ sv.json, en.json, jp-dictionary.json, fonts/ │ ├── text/ sv.json, en.json, jp-dictionary.json, fonts/
│ └── data/ levels/, items, skills (JSON) │ └── data/ levels/, items, skills (JSON)
├── doc/ denna dokumentation ├── doc/ denna dokumentation (doc/img/ = skärmdumpar från tools/shots)
├── .gitea/workflows/ CI: test + release-zippar för linux/windows på brasse-linux01-runnern
├── .vscode/ tasks.json, launch.json ├── .vscode/ tasks.json, launch.json
└── build/ (gitignorerad) debug/ och release/<os>-<arch>/ └── build/ (gitignorerad) debug/ och release/<os>-<arch>/
``` ```
@@ -85,6 +89,27 @@ metro-puzzle-jp/
Versionen i binären kommer från `git describe --tags --always --dirty`, Versionen i binären kommer från `git describe --tags --always --dirty`,
så en tagg `v0.1.0` ger `metro-puzzle-jp v0.1.0` i `-version`. så en tagg `v0.1.0` ger `metro-puzzle-jp v0.1.0` i `-version`.
### Skärmdumpar till dokumentationen
README och wikin visar bilder från `doc/img/`. De tas av spelet självt i
skriptläge så att de alltid är pixelexakta och går att återskapa:
```bash
go run ./tools/genart # bara om placeholder-grafiken ska genereras om
go run ./tools/shots # bygger debug, kör spelet med -script, skriver doc/img/*.png
```
**Regel:** när sprites, tileset eller UI ändras – kör `tools/shots` och
committa `doc/img/` i samma commit, så visar README och wiki alltid aktuell
grafik. Nya vyer läggs till i listan i `tools/shots/main.go`.
### CI
`.gitea/workflows/release.yaml` kör `go vet` + `go test`, bygger
`linux-amd64` och `windows-amd64` och lägger zipparna på den rullande
releasen `latest` (en tagg `vX.Y.Z` ger en versionerad release). Jobbet körs
på **brasse-linux01-runnern** (label `linux-amd64`), aldrig på Pi5:n.
## Milstolpar ## Milstolpar
### M0 – Skelett och byggkedja ✓ (klar 2026-10-23) ### M0 – Skelett och byggkedja ✓ (klar 2026-10-23)
@@ -97,17 +122,23 @@ så en tagg `v0.1.0` ger `metro-puzzle-jp v0.1.0` i `-version`.
- Verifierat: debug-bygge, Linux-release, Windows-korsbygge, smoke-test - Verifierat: debug-bygge, Linux-release, Windows-korsbygge, smoke-test
med fönster, alla tester gröna. med fönster, alla tester gröna.
### M1 – Rörelse, kollision och kamera ### M1 – Rörelse, kollision och kamera ✓ (klar 2026-10-23)
- **Mål:** gå runt i en labyrint som är större än skärmen. - **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 - Rutbaserad rörelse i 4 riktningar (pilar/WASD + gamepad d-pad/spak), mjukt
väggar, kamera som följer och klampas till kartan. interpolerad, riktningsbyte vid rutgränser; kollision mot väggar; kamera
- Scen-system (meny → bana → resultat) som en enkel tillståndsmaskin. som följer spelaren och klampas till kartan (centrerar små kartor).
- Tileset i stället för färgade rutor; sprite-animation (gå/stå). - Scen-system: meny (banlista) → bana → resultat (rätt/fel tåg, R = spela om).
- **Teknik:** `internal/game/scene`, `internal/sim` (rörelse/kollision som - Tileset (`tiles.png`) och spelarsprite med gå-animation i fyra riktningar
ren logik), Ebitengine `inpututil`. (`player.png`), genererade av `tools/genart` tills riktig pixel art finns.
- **Klar när:** man kan gå från S till P i bana 001, kameran följer, testerna - Större testbana `002-shinjuku` (33×21 rutor) som bevisar kamerascrollen.
i mål-dokumentet för M1 är gröna. - Skriptläge (`-script`) och `tools/shots` som tar dokumentationens
skärmdumpar deterministiskt.
- **Teknik:** `internal/sim` (Mover, Camera – ren Go med tester),
`internal/game` (scener, input, assets), Ebitengine `inpututil`/`vector`.
- **Avsteg:** resultatskärmen visar redan rätt/fel tåg eftersom perrongen är
det naturliga slutet på en bana; tidtabell, spärrar och väntan på tåg
kommer i M2 som planerat.
### M2 – Stationen: tåg, spärrar och vinst/förlust → första spelbara banan ### M2 – Stationen: tåg, spärrar och vinst/förlust → första spelbara banan

97
internal/game/assets.go Normal file
View File

@@ -0,0 +1,97 @@
package game
import (
"fmt"
"image"
"github.com/hajimehoshi/ebiten/v2"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
)
// Ordningen på rutorna i resources/sprites/tiles.png (måste matcha tools/genart).
var tileOrder = []level.Tile{
level.Wall, level.Floor, level.Spawn, level.Platform, level.Gate, level.TicketMachine, level.Sign,
}
// Assets är allt som laddats från resources/.
type Assets struct {
ResDir string
LevelsDir string
Tiles map[level.Tile]*ebiten.Image
Player [4][3]*ebiten.Image // [riktning: ner, upp, vänster, höger][bildruta: stilla, gå1, gå2]
Text map[string]string
}
// LoadAssets läser grafik och texter.
func LoadAssets() (*Assets, error) {
dir, err := res.Dir()
if err != nil {
return nil, err
}
a := &Assets{ResDir: dir, Tiles: map[level.Tile]*ebiten.Image{}}
a.LevelsDir, _ = res.Path("data", "levels")
tiles, err := loadImage("sprites", "tiles.png")
if err != nil {
return nil, err
}
if w := tiles.Bounds().Dx(); w < TileSize*len(tileOrder) {
return nil, fmt.Errorf("tiles.png är %d px bred, behöver minst %d", w, TileSize*len(tileOrder))
}
for i, t := range tileOrder {
a.Tiles[t] = tiles.SubImage(image.Rect(i*TileSize, 0, (i+1)*TileSize, TileSize)).(*ebiten.Image)
}
a.Tiles[level.WrongPlatform] = a.Tiles[level.Platform] // samma utseende; tåget avgör
player, err := loadImage("sprites", "player.png")
if err != nil {
return nil, err
}
for row := 0; row < 4; row++ {
for col := 0; col < 3; col++ {
a.Player[row][col] = player.SubImage(image.Rect(col*TileSize, row*TileSize, (col+1)*TileSize, (row+1)*TileSize)).(*ebiten.Image)
}
}
if err := res.LoadJSON(&a.Text, "text", "sv.json"); err != nil {
return nil, err
}
return a, nil
}
// T slår upp en UI-text; saknas nyckeln visas nyckeln själv så att det syns.
func (a *Assets) T(key string) string {
if s, ok := a.Text[key]; ok {
return s
}
return key
}
// PlayerFrame returnerar rätt bildruta för riktning och gångfas.
func (a *Assets) PlayerFrame(facing sim.Dir, moving bool, animT float64) *ebiten.Image {
row := 0
switch facing {
case sim.Up:
row = 1
case sim.Left:
row = 2
case sim.Right:
row = 3
}
col := 0
if moving {
col = 1 + int(animT/0.12)%2
}
return a.Player[row][col]
}
func loadImage(parts ...string) (*ebiten.Image, error) {
img, err := res.LoadImage(parts...)
if err != nil {
return nil, err
}
return ebiten.NewImageFromImage(img), nil
}

View File

@@ -1,19 +1,11 @@
// Package game innehåller Ebitengine-spelloopen. Detta är det enda paketet // Package game innehåller Ebitengine-spelloopen och scenerna. Detta är det
// (tillsammans med cmd/) som får känna till Ebitengine; all spellogik ska // enda paketet (tillsammans med cmd/) som får känna till Ebitengine; all
// ligga i paket som går att testa utan fönster. // spellogik ligger i paket som går att testa utan fönster (internal/sim,
// internal/level …).
package game package game
import ( import (
"fmt"
"image/color"
"github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
"github.com/hajimehoshi/ebiten/v2/inpututil"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/buildinfo"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res"
) )
// Intern upplösning. Allt ritas i denna storlek och skalas sedan upp med // Intern upplösning. Allt ritas i denna storlek och skalas sedan upp med
@@ -21,115 +13,87 @@ import (
const ( const (
ScreenW = 320 ScreenW = 320
ScreenH = 180 ScreenH = 180
TileSize = 8 TileSize = 16
dt = 1.0 / 60 // Ebitengine kör Update med fast 60 TPS
) )
// Scene är ett spelläge: meny, bana, resultat …
type Scene interface {
Update(g *Game) error
Draw(g *Game, screen *ebiten.Image)
}
// Game är Ebitengine-tillståndet för hela programmet. // Game är Ebitengine-tillståndet för hela programmet.
type Game struct { type Game struct {
lvl *level.Level Assets *Assets
player *ebiten.Image scene Scene
tiles map[level.Tile]*ebiten.Image next Scene
resDir string smokeLeft int // >0: avsluta automatiskt efter så många uppdateringar
smokeLeft int // >0: avsluta automatiskt efter så många uppdateringar (för smoke-test) script *script
frames int frame *ebiten.Image // allt ritas hit och blittas sedan till skärmen (gör skärmdumpar möjliga)
levelError error
} }
// Options styr uppstarten. // Options styr uppstarten.
type Options struct { type Options struct {
LevelID string // bana att visa, t.ex. "001-flygplatsen" LevelID string // hoppa direkt in i en bana (tom = meny)
SmokeFrames int // >0 avslutar efter N uppdateringar SmokeFrames int // >0 avslutar efter N uppdateringar (smoke-test)
Script string // skriptade steg för skärmdumpar/tester, se script.go
} }
// New laddar resurser och bygger spelet. // New laddar resurser och bygger spelet.
func New(opt Options) (*Game, error) { func New(opt Options) (*Game, error) {
dir, err := res.Dir() a, err := LoadAssets()
if err != nil { if err != nil {
return nil, err return nil, err
} }
g := &Game{resDir: dir, smokeLeft: opt.SmokeFrames} g := &Game{Assets: a, smokeLeft: opt.SmokeFrames, frame: ebiten.NewImage(ScreenW, ScreenH)}
if opt.Script != "" {
img, err := res.LoadImage("sprites", "placeholder.png") if g.script, err = parseScript(opt.Script); err != nil {
return nil, err
}
}
if opt.LevelID != "" {
s, err := NewLevelScene(a, opt.LevelID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
g.player = ebiten.NewImageFromImage(img) g.scene = s
} else {
var raw []byte g.scene, err = NewMenuScene(a)
if raw, err = res.ReadFile("data", "levels", opt.LevelID+".json"); err == nil {
g.lvl, err = level.Parse(raw)
}
if err != nil { if err != nil {
g.levelError = err return nil, 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 return g, nil
} }
// SetScene byter scen från och med nästa uppdatering.
func (g *Game) SetScene(s Scene) { g.next = s }
// Update körs 60 ggr/s. // Update körs 60 ggr/s.
func (g *Game) Update() error { func (g *Game) Update() error {
g.frames++
if inpututil.IsKeyJustPressed(ebiten.KeyEscape) {
return ebiten.Termination
}
if g.smokeLeft > 0 { if g.smokeLeft > 0 {
g.smokeLeft-- g.smokeLeft--
if g.smokeLeft == 0 { if g.smokeLeft == 0 {
return ebiten.Termination return ebiten.Termination
} }
} }
return nil if g.next != nil {
g.scene, g.next = g.next, nil
}
if g.script != nil {
if err := g.script.tick(g, g.frame); err != nil {
return err
}
}
return g.scene.Update(g)
} }
// Draw ritar aktuell bild. // Draw ritar aktuell scen till den interna bilden och sedan till skärmen.
func (g *Game) Draw(screen *ebiten.Image) { func (g *Game) Draw(screen *ebiten.Image) {
screen.Fill(color.RGBA{24, 20, 37, 255}) g.scene.Draw(g, g.frame)
screen.DrawImage(g.frame, nil)
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(
"Metro Puzzle JP %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. // Layout talar om för Ebitengine vilken intern upplösning vi ritar i.
func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) { func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) { return ScreenW, ScreenH }
return ScreenW, ScreenH
}

99
internal/game/input.go Normal file
View File

@@ -0,0 +1,99 @@
package game
import (
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/inpututil"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
)
const stickDeadzone = 0.5
// dirInput returnerar hållen riktning från tangentbord (pilar/WASD) eller
// gamepad (d-pad / vänster spak). None om inget hålls.
func dirInput() sim.Dir {
if virtual.held != sim.None {
return virtual.held
}
switch {
case ebiten.IsKeyPressed(ebiten.KeyArrowUp) || ebiten.IsKeyPressed(ebiten.KeyW):
return sim.Up
case ebiten.IsKeyPressed(ebiten.KeyArrowDown) || ebiten.IsKeyPressed(ebiten.KeyS):
return sim.Down
case ebiten.IsKeyPressed(ebiten.KeyArrowLeft) || ebiten.IsKeyPressed(ebiten.KeyA):
return sim.Left
case ebiten.IsKeyPressed(ebiten.KeyArrowRight) || ebiten.IsKeyPressed(ebiten.KeyD):
return sim.Right
}
for _, id := range ebiten.AppendGamepadIDs(nil) {
if !ebiten.IsStandardGamepadLayoutAvailable(id) {
continue
}
switch {
case ebiten.IsStandardGamepadButtonPressed(id, ebiten.StandardGamepadButtonLeftTop):
return sim.Up
case ebiten.IsStandardGamepadButtonPressed(id, ebiten.StandardGamepadButtonLeftBottom):
return sim.Down
case ebiten.IsStandardGamepadButtonPressed(id, ebiten.StandardGamepadButtonLeftLeft):
return sim.Left
case ebiten.IsStandardGamepadButtonPressed(id, ebiten.StandardGamepadButtonLeftRight):
return sim.Right
}
x := ebiten.StandardGamepadAxisValue(id, ebiten.StandardGamepadAxisLeftStickHorizontal)
y := ebiten.StandardGamepadAxisValue(id, ebiten.StandardGamepadAxisLeftStickVertical)
switch {
case y < -stickDeadzone:
return sim.Up
case y > stickDeadzone:
return sim.Down
case x < -stickDeadzone:
return sim.Left
case x > stickDeadzone:
return sim.Right
}
}
return sim.None
}
// confirmJustPressed: Enter/mellanslag eller gamepadens nedre knapp (A/kryss).
func confirmJustPressed() bool {
if virtual.pressed["enter"] {
return true
}
if inpututil.IsKeyJustPressed(ebiten.KeyEnter) || inpututil.IsKeyJustPressed(ebiten.KeySpace) {
return true
}
return gamepadJustPressed(ebiten.StandardGamepadButtonRightBottom)
}
// backJustPressed: Escape eller gamepadens högra knapp (B/cirkel).
func backJustPressed() bool {
return virtual.pressed["esc"] || inpututil.IsKeyJustPressed(ebiten.KeyEscape) || gamepadJustPressed(ebiten.StandardGamepadButtonRightRight)
}
// menuMove returnerar -1/+1 när användaren just tryckte upp/ner i en meny.
func menuMove() int {
switch {
case virtual.pressed["up"]:
return -1
case virtual.pressed["down"]:
return 1
case inpututil.IsKeyJustPressed(ebiten.KeyArrowUp) || inpututil.IsKeyJustPressed(ebiten.KeyW) || gamepadJustPressed(ebiten.StandardGamepadButtonLeftTop):
return -1
case inpututil.IsKeyJustPressed(ebiten.KeyArrowDown) || inpututil.IsKeyJustPressed(ebiten.KeyS) || gamepadJustPressed(ebiten.StandardGamepadButtonLeftBottom):
return 1
}
return 0
}
func gamepadJustPressed(b ebiten.StandardGamepadButton) bool {
for _, id := range ebiten.AppendGamepadIDs(nil) {
if ebiten.IsStandardGamepadLayoutAvailable(id) && inpututil.IsStandardGamepadButtonJustPressed(id, b) {
return true
}
}
return false
}
// inpututil_R: R = spela om banan.
func inpututil_R() bool { return virtual.pressed["r"] || inpututil.IsKeyJustPressed(ebiten.KeyR) }

View File

@@ -0,0 +1,110 @@
package game
import (
"fmt"
"image/color"
"math"
"path/filepath"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
"github.com/hajimehoshi/ebiten/v2/vector"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
)
// Spelarens grundfart i rutor per sekund (blir en stat i M7).
const playerSpeed = 6.0
// LevelScene är själva spelandet av en bana.
type LevelScene struct {
id string
lvl *level.Level
player *sim.Mover
cam *sim.Camera
animT float64
elapsed float64
steps int
}
// NewLevelScene laddar banan och ställer spelaren på startrutan.
func NewLevelScene(a *Assets, id string) (*LevelScene, error) {
lvl, err := level.LoadFile(filepath.Join(a.LevelsDir, id+".json"))
if err != nil {
return nil, err
}
s := &LevelScene{
id: id,
lvl: lvl,
player: sim.NewMover(lvl.SpawnPoint(), playerSpeed),
cam: sim.NewCamera(ScreenW, ScreenH, float64(lvl.Width()*TileSize), float64(lvl.Height()*TileSize)),
}
s.followPlayer()
return s, nil
}
// PlayerTile returnerar rutan spelaren står på/lämnar (används av skriptläget).
func (s *LevelScene) PlayerTile() level.Point { return s.player.Tile }
func (s *LevelScene) followPlayer() {
x, y := s.player.WorldPos(TileSize)
s.cam.Follow(x+TileSize/2, y+TileSize/2)
}
func (s *LevelScene) Update(g *Game) error {
if backJustPressed() {
m, err := NewMenuScene(g.Assets)
if err != nil {
return err
}
g.SetScene(m)
return nil
}
s.elapsed += dt
s.steps += s.player.Step(dt, dirInput(), s.lvl.CanEnter)
if s.player.Moving() {
s.animT += dt
} else {
s.animT = 0
// Står vi på en perrong är banan slut (M2 ger tåg, tidtabell och spärrar).
if t := s.lvl.At(s.player.Tile.X, s.player.Tile.Y); t == level.Platform || t == level.WrongPlatform {
g.SetScene(NewResultScene(s, s.lvl.TrainAt(t)))
}
}
s.followPlayer()
return nil
}
func (s *LevelScene) Draw(g *Game, screen *ebiten.Image) {
screen.Fill(color.RGBA{24, 20, 37, 255})
ox, oy := s.cam.Offset()
ox, oy = math.Round(ox), math.Round(oy)
// Rita bara rutor som syns.
x0 := max(0, int(s.cam.X)/TileSize)
y0 := max(0, int(s.cam.Y)/TileSize)
x1 := min(s.lvl.Width(), int(s.cam.X+ScreenW)/TileSize+2)
y1 := min(s.lvl.Height(), int(s.cam.Y+ScreenH)/TileSize+2)
var op ebiten.DrawImageOptions
for y := y0; y < y1; y++ {
for x := x0; x < x1; x++ {
op.GeoM.Reset()
op.GeoM.Translate(ox+float64(x*TileSize), oy+float64(y*TileSize))
screen.DrawImage(g.Assets.Tiles[s.lvl.At(x, y)], &op)
}
}
px, py := s.player.WorldPos(TileSize)
op.GeoM.Reset()
op.GeoM.Translate(ox+math.Round(px), oy+math.Round(py))
screen.DrawImage(g.Assets.PlayerFrame(s.player.Facing, s.player.Moving(), s.animT), &op)
// HUD
vector.DrawFilledRect(screen, 0, 0, ScreenW, 24, color.RGBA{24, 20, 37, 200}, false)
// TODO(M3): visa japanska när en font med kana/kanji finns – debugfonten saknar dem.
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s %s: %s", s.lvl.Name.Get("sv"), g.Assets.T("hud.destination"),
s.lvl.Destination.Get("romaji")), 4, 1)
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s %3.0fs %s %d %s", g.Assets.T("hud.time"), s.elapsed,
g.Assets.T("hud.steps"), s.steps, g.Assets.T("hud.controls")), 4, 12)
}

View File

@@ -0,0 +1,83 @@
package game
import (
"fmt"
"image/color"
"path/filepath"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
"github.com/hajimehoshi/ebiten/v2/vector"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/buildinfo"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
)
type menuEntry struct {
id string
name string
dest string
}
// MenuScene listar banorna och låter spelaren välja.
type MenuScene struct {
entries []menuEntry
sel int
err error
}
// NewMenuScene läser in banlistan.
func NewMenuScene(a *Assets) (*MenuScene, error) {
ids, err := level.List(a.LevelsDir)
if err != nil {
return nil, err
}
m := &MenuScene{}
for _, id := range ids {
l, err := level.LoadFile(filepath.Join(a.LevelsDir, id+".json"))
if err != nil {
m.err = err
continue
}
m.entries = append(m.entries, menuEntry{id: id, name: l.Name.Get("sv"), dest: l.Destination.Get("romaji")}) // TODO(M3): japanska när fonten finns
}
return m, nil
}
func (m *MenuScene) Update(g *Game) error {
if backJustPressed() {
return ebiten.Termination
}
if n := len(m.entries); n > 0 {
m.sel = (m.sel + menuMove() + n) % n
if confirmJustPressed() {
s, err := NewLevelScene(g.Assets, m.entries[m.sel].id)
if err != nil {
m.err = err
return nil
}
g.SetScene(s)
}
}
return nil
}
func (m *MenuScene) Draw(g *Game, screen *ebiten.Image) {
screen.Fill(color.RGBA{24, 20, 37, 255})
vector.DrawFilledRect(screen, 0, 0, ScreenW, 22, color.RGBA{38, 43, 68, 255}, false)
ebitenutil.DebugPrintAt(screen, g.Assets.T("game.title"), 8, 4)
ebitenutil.DebugPrintAt(screen, g.Assets.T("menu.choose_level"), 8, 30)
for i, e := range m.entries {
y := 46 + i*14
if i == m.sel {
vector.DrawFilledRect(screen, 6, float32(y-2), ScreenW-12, 13, color.RGBA{58, 68, 102, 255}, false)
}
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s -> %s", e.name, e.dest), 12, y)
}
if m.err != nil {
ebitenutil.DebugPrintAt(screen, "FEL: "+m.err.Error(), 8, ScreenH-40)
}
ebitenutil.DebugPrintAt(screen, g.Assets.T("menu.hint"), 8, ScreenH-26)
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s (%s)", buildinfo.Version, buildinfo.Mode), 8, ScreenH-13)
}

View File

@@ -0,0 +1,65 @@
package game
import (
"fmt"
"image/color"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
"github.com/hajimehoshi/ebiten/v2/vector"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
)
// ResultScene visas när spelaren nått en perrong.
type ResultScene struct {
from *LevelScene
train *level.Train
}
// NewResultScene bygger resultatet för banan som just spelades.
func NewResultScene(from *LevelScene, train *level.Train) *ResultScene {
return &ResultScene{from: from, train: train}
}
func (r *ResultScene) correct() bool { return r.train != nil && r.train.Correct }
func (r *ResultScene) Update(g *Game) error {
switch {
case inpututil_R():
s, err := NewLevelScene(g.Assets, r.from.id)
if err != nil {
return err
}
g.SetScene(s)
case confirmJustPressed() || backJustPressed():
m, err := NewMenuScene(g.Assets)
if err != nil {
return err
}
g.SetScene(m)
}
return nil
}
func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) {
r.from.Draw(g, screen) // banan i bakgrunden
bg := color.RGBA{24, 20, 37, 230}
vector.DrawFilledRect(screen, 20, 40, ScreenW-40, 100, bg, false)
accent := color.RGBA{228, 59, 68, 255}
headline := g.Assets.T("level.wrong_train")
if r.correct() {
accent = color.RGBA{99, 199, 77, 255}
headline = g.Assets.T("level.cleared")
}
vector.DrawFilledRect(screen, 20, 40, ScreenW-40, 3, accent, false)
ebitenutil.DebugPrintAt(screen, headline, 28, 50)
if r.train != nil {
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s -> %s", r.train.Line.Get("romaji"),
r.train.Destination.Get("romaji")), 28, 66) // TODO(M3): japanska när fonten finns
}
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s %.1fs %s %d", g.Assets.T("hud.time"), r.from.elapsed,
g.Assets.T("hud.steps"), r.from.steps), 28, 86)
ebitenutil.DebugPrintAt(screen, g.Assets.T("result.hint"), 28, 118)
}

189
internal/game/script.go Normal file
View File

@@ -0,0 +1,189 @@
package game
import (
"fmt"
"image"
"image/png"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/hajimehoshi/ebiten/v2"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
)
// Skriptläge (-script) för automatiserade skärmdumpar och smoke-tester.
// Stegen skiljs med ';':
//
// wait N vänta N bildrutor
// press KEY tryck enter | esc | r | up | down en bildruta
// move DIR N gå N rutor åt up | down | left | right (släpper när rutorna är avklarade)
// shot PATH spara aktuell bild som PNG (intern upplösning × shotScale)
// quit avsluta spelet
//
// Exempel: -level 001-flygplatsen -script "move down 2; move right 2; shot doc/img/level.png; quit"
const shotScale = 3
type scriptStep struct {
cmd string
arg string
n int
dir sim.Dir
done bool
}
type script struct {
steps []scriptStep
i int
counter int
lastPos level.Point
havePos bool
}
// virtual är den simulerade inputen som input.go läser före riktig input.
var virtual struct {
held sim.Dir
pressed map[string]bool
}
func parseScript(src string) (*script, error) {
s := &script{}
for _, raw := range strings.Split(src, ";") {
f := strings.Fields(raw)
if len(f) == 0 {
continue
}
st := scriptStep{cmd: f[0]}
switch f[0] {
case "wait":
if len(f) != 2 {
return nil, fmt.Errorf("script: 'wait N'")
}
st.n, _ = strconv.Atoi(f[1])
case "press":
if len(f) != 2 {
return nil, fmt.Errorf("script: 'press KEY'")
}
st.arg = f[1]
case "move":
if len(f) != 3 {
return nil, fmt.Errorf("script: 'move DIR N'")
}
st.dir = parseDir(f[1])
if st.dir == sim.None {
return nil, fmt.Errorf("script: okänd riktning %q", f[1])
}
st.n, _ = strconv.Atoi(f[2])
case "shot":
if len(f) != 2 {
return nil, fmt.Errorf("script: 'shot PATH'")
}
st.arg = f[1]
case "quit":
default:
return nil, fmt.Errorf("script: okänt kommando %q", f[0])
}
s.steps = append(s.steps, st)
}
return s, nil
}
func parseDir(s string) sim.Dir {
switch s {
case "up":
return sim.Up
case "down":
return sim.Down
case "left":
return sim.Left
case "right":
return sim.Right
}
return sim.None
}
// playerTiler implementeras av scener som har en spelare i rutnätet.
type playerTiler interface{ PlayerTile() level.Point }
// tick körs i början av varje Update. frame är senast ritade bild.
// Returnerar ebiten.Termination när skriptet vill avsluta.
func (s *script) tick(g *Game, frame *ebiten.Image) error {
virtual.held = sim.None
virtual.pressed = map[string]bool{}
if s.i >= len(s.steps) {
return nil
}
st := &s.steps[s.i]
switch st.cmd {
case "wait":
s.counter++
if s.counter >= st.n {
s.advance()
}
case "press":
virtual.pressed[st.arg] = true
s.advance()
case "move":
pt, ok := g.scene.(playerTiler)
if !ok {
s.advance() // ingen spelare att flytta i den här scenen
return nil
}
cur := pt.PlayerTile()
if !s.havePos {
s.lastPos, s.havePos = cur, true
}
if cur != s.lastPos {
s.lastPos = cur
s.counter++
}
if s.counter >= st.n {
s.advance()
return nil
}
virtual.held = st.dir
case "shot":
if err := saveShot(frame, st.arg); err != nil {
return err
}
s.advance()
case "quit":
return ebiten.Termination
}
return nil
}
func (s *script) advance() {
s.i++
s.counter = 0
s.havePos = false
}
func saveShot(frame *ebiten.Image, path string) error {
w, h := frame.Bounds().Dx(), frame.Bounds().Dy()
buf := make([]byte, 4*w*h)
frame.ReadPixels(buf)
out := image.NewNRGBA(image.Rect(0, 0, w*shotScale, h*shotScale))
for y := 0; y < h*shotScale; y++ {
for x := 0; x < w*shotScale; x++ {
src := 4 * ((y/shotScale)*w + x/shotScale)
dst := out.PixOffset(x, y)
copy(out.Pix[dst:dst+4], buf[src:src+4])
out.Pix[dst+3] = 255
}
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
fmt.Println("skärmdump:", path)
return png.Encode(f, out)
}

View File

@@ -9,6 +9,9 @@ package level
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os"
"path/filepath"
"sort"
"strings" "strings"
) )
@@ -226,3 +229,39 @@ func (l *Level) Unreachable() []Point {
} }
return out return out
} }
// CanEnter rapporterar om rutan går att gå in på (för sim.Mover).
func (l *Level) CanEnter(p Point) bool { return l.At(p.X, p.Y).Passable() }
// TrainAt returnerar tåget vid en perrongruta, eller nil.
func (l *Level) TrainAt(t Tile) *Train {
for i := range l.Trains {
if Tile(l.Trains[i].Platform[0]) == t {
return &l.Trains[i]
}
}
return nil
}
// LoadFile läser och validerar en bana från disk.
func LoadFile(path string) (*Level, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("level: %w", err)
}
return Parse(b)
}
// List returnerar id:n (filnamn utan .json) för alla banor i en mapp, sorterade.
func List(dir string) ([]string, error) {
files, err := filepath.Glob(filepath.Join(dir, "*.json"))
if err != nil {
return nil, err
}
ids := make([]string, 0, len(files))
for _, f := range files {
ids = append(ids, strings.TrimSuffix(filepath.Base(f), ".json"))
}
sort.Strings(ids)
return ids, nil
}

View File

@@ -3,6 +3,7 @@ package level
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"testing" "testing"
@@ -135,3 +136,36 @@ func TestAllShippedLevels(t *testing.T) {
}) })
} }
} }
func TestListAndLoadFile(t *testing.T) {
dir, err := res.Path("data", "levels")
if err != nil {
t.Fatal(err)
}
ids, err := List(dir)
if err != nil || len(ids) == 0 {
t.Fatalf("ids=%v err=%v", ids, err)
}
if !sort.StringsAreSorted(ids) {
t.Fatalf("inte sorterade: %v", ids)
}
if ids[0] != "001-flygplatsen" {
t.Fatalf("första banan ska vara 001-flygplatsen, fick %q", ids[0])
}
l, err := LoadFile(filepath.Join(dir, ids[0]+".json"))
if err != nil {
t.Fatal(err)
}
if tr := l.TrainAt(Platform); tr == nil || !tr.Correct {
t.Fatal("TrainAt(P) ska ge det rätta tåget")
}
if tr := l.TrainAt(WrongPlatform); tr == nil || tr.Correct {
t.Fatal("TrainAt(p) ska ge ett fel tåg")
}
if l.TrainAt(Wall) != nil {
t.Fatal("TrainAt(#) ska vara nil")
}
if _, err := LoadFile(filepath.Join(dir, "finns-inte.json")); err == nil {
t.Fatal("saknad fil ska ge fel")
}
}

View File

@@ -70,7 +70,7 @@ func TestDir_RealRepo(t *testing.T) {
if m["game.title"] == "" { if m["game.title"] == "" {
t.Fatal("sv.json saknar game.title") t.Fatal("sv.json saknar game.title")
} }
if _, err := LoadImage("sprites", "placeholder.png"); err != nil { if _, err := LoadImage("sprites", "player.png"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
} }

38
internal/sim/camera.go Normal file
View File

@@ -0,0 +1,38 @@
package sim
// Camera är en vy över världen som följer en punkt men aldrig visar utanför
// kartan. Är världen mindre än vyn centreras den i stället.
type Camera struct {
ViewW, ViewH float64
WorldW, WorldH float64
X, Y float64 // vyns övre vänstra hörn i världskoordinater
}
// NewCamera skapar en kamera för en värld av given storlek (pixlar).
func NewCamera(viewW, viewH, worldW, worldH float64) *Camera {
c := &Camera{ViewW: viewW, ViewH: viewH, WorldW: worldW, WorldH: worldH}
c.Follow(worldW/2, worldH/2)
return c
}
// Follow centrerar vyn på (cx, cy) och klampar mot världens kanter.
func (c *Camera) Follow(cx, cy float64) {
c.X = clampAxis(cx-c.ViewW/2, c.ViewW, c.WorldW)
c.Y = clampAxis(cy-c.ViewH/2, c.ViewH, c.WorldH)
}
// Offset returnerar översättningen världs- -> skärmkoordinater.
func (c *Camera) Offset() (ox, oy float64) { return -c.X, -c.Y }
func clampAxis(pos, view, world float64) float64 {
if world <= view {
return -(view - world) / 2 // centrera en liten värld
}
if pos < 0 {
return 0
}
if max := world - view; pos > max {
return max
}
return pos
}

View File

@@ -0,0 +1,41 @@
package sim
import "testing"
func TestCameraCentersOnTarget(t *testing.T) {
c := NewCamera(320, 180, 1000, 1000)
c.Follow(500, 500)
if c.X != 340 || c.Y != 410 {
t.Fatalf("X,Y=%v,%v", c.X, c.Y)
}
ox, oy := c.Offset()
if ox != -340 || oy != -410 {
t.Fatalf("offset=%v,%v", ox, oy)
}
}
func TestCameraClampsToWorldEdges(t *testing.T) {
c := NewCamera(320, 180, 1000, 600)
c.Follow(10, 10)
if c.X != 0 || c.Y != 0 {
t.Fatalf("övre vänstra: X,Y=%v,%v", c.X, c.Y)
}
c.Follow(5000, 5000)
if c.X != 1000-320 || c.Y != 600-180 {
t.Fatalf("nedre högra: X,Y=%v,%v", c.X, c.Y)
}
}
func TestCameraCentersSmallWorld(t *testing.T) {
c := NewCamera(320, 180, 160, 96)
c.Follow(0, 0) // spelar ingen roll var man är
if c.X != -80 || c.Y != -42 {
t.Fatalf("X,Y=%v,%v, vill ha -80,-42", c.X, c.Y)
}
// Blandat: bred värld, låg värld -> klampa i x, centrera i y.
c = NewCamera(320, 180, 640, 96)
c.Follow(700, 0)
if c.X != 320 || c.Y != -42 {
t.Fatalf("X,Y=%v,%v", c.X, c.Y)
}
}

49
internal/sim/dir.go Normal file
View File

@@ -0,0 +1,49 @@
// Package sim innehåller ren spellogik som inte känner till Ebitengine:
// rutbaserad rörelse, kollision och kamera. Allt här går att testa utan fönster.
package sim
// Dir är en rörelseriktning i rutnätet.
type Dir uint8
// Riktningar. None betyder "ingen input".
const (
None Dir = iota
Up
Down
Left
Right
)
// Delta returnerar förflyttningen i rutor för riktningen.
func (d Dir) Delta() (dx, dy int) {
switch d {
case Up:
return 0, -1
case Down:
return 0, 1
case Left:
return -1, 0
case Right:
return 1, 0
}
return 0, 0
}
// Opposite returnerar motsatt riktning.
func (d Dir) Opposite() Dir {
switch d {
case Up:
return Down
case Down:
return Up
case Left:
return Right
case Right:
return Left
}
return None
}
func (d Dir) String() string {
return [...]string{"none", "up", "down", "left", "right"}[d]
}

83
internal/sim/mover.go Normal file
View File

@@ -0,0 +1,83 @@
package sim
import "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
// Mover är något som rör sig ruta för ruta i labyrinten (spelaren, senare
// följarna). Rörelsen är mjukt interpolerad mellan rutor men beslut om
// riktning fattas bara vid rutgränser, som i klassiska labyrintspel.
type Mover struct {
Tile level.Point // rutan vi står på / lämnar
Target level.Point // rutan vi är på väg till (== Tile när vi står stilla)
Progress float64 // 0..1 längs Tile -> Target
Facing Dir // senaste riktning (för sprite), aldrig None
Speed float64 // rutor per sekund
}
// NewMover skapar en stillastående Mover på en ruta.
func NewMover(start level.Point, speed float64) *Mover {
return &Mover{Tile: start, Target: start, Facing: Down, Speed: speed}
}
// Moving rapporterar om vi är mellan två rutor.
func (m *Mover) Moving() bool { return m.Target != m.Tile }
// maxTilesPerStep skyddar mot oändliga loopar vid absurt stora dt.
const maxTilesPerStep = 16
// Step för rörelsen framåt dt sekunder med önskad riktning want (None = ingen
// input). canEnter avgör om en ruta får beträdas. Returnerar antalet rutor
// som nåddes under steget (normalt 0 eller 1).
//
// Regler: står vi stilla och får input startar vi mot grannrutan om den är
// passerbar, annars vänder vi oss bara åt det hållet. Är vi på väg fortsätter
// vi till rutgränsen; väl där fortsätter vi direkt i önskad riktning om den är
// fri (så att hållen tangent ger jämn fart), annars stannar vi.
func (m *Mover) Step(dt float64, want Dir, canEnter func(level.Point) bool) int {
arrived := 0
remaining := m.Speed * dt // i rutor
for i := 0; i < maxTilesPerStep; i++ {
if remaining <= eps {
return arrived
}
if !m.Moving() {
if want == None {
return arrived
}
m.Facing = want
dx, dy := want.Delta()
next := level.Point{X: m.Tile.X + dx, Y: m.Tile.Y + dy}
if !canEnter(next) {
return arrived
}
m.Target = next
m.Progress = 0
}
need := 1 - m.Progress
if remaining+eps < need {
m.Progress += remaining
return arrived
}
// Framme vid rutan. eps gör att 10 × 0.1 räknas som exakt 1 ruta trots
// flyttalsavrundning, så att rörelsen blir deterministisk per bildruta.
remaining = max(0, remaining-need)
m.Tile = m.Target
m.Progress = 0
arrived++
}
return arrived
}
// eps är toleransen för flyttalsavrundning i rutor.
const eps = 1e-9
// WorldPos returnerar den interpolerade positionen i pixlar (övre vänstra
// hörnet av figuren) givet rutstorleken.
func (m *Mover) WorldPos(tileSize float64) (x, y float64) {
x = float64(m.Tile.X) * tileSize
y = float64(m.Tile.Y) * tileSize
if m.Moving() {
x += float64(m.Target.X-m.Tile.X) * tileSize * m.Progress
y += float64(m.Target.Y-m.Tile.Y) * tileSize * m.Progress
}
return x, y
}

136
internal/sim/mover_test.go Normal file
View File

@@ -0,0 +1,136 @@
package sim
import (
"math"
"testing"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
)
func pt(x, y int) level.Point { return level.Point{X: x, Y: y} }
// open: 5x5 med vägg runt om, allt annat fritt.
func open(p level.Point) bool { return p.X >= 1 && p.X <= 5 && p.Y >= 1 && p.Y <= 5 }
func TestMoveFourDirections(t *testing.T) {
for _, tc := range []struct {
dir Dir
want level.Point
}{
{Up, pt(3, 2)}, {Down, pt(3, 4)}, {Left, pt(2, 3)}, {Right, pt(4, 3)},
} {
m := NewMover(pt(3, 3), 4) // 4 rutor/s -> 0.25 s per ruta
if n := m.Step(0.25, tc.dir, open); n != 1 {
t.Errorf("%s: arrived=%d, vill ha 1", tc.dir, n)
}
if m.Tile != tc.want || m.Moving() {
t.Errorf("%s: hamnade på %v (moving=%v), vill ha %v", tc.dir, m.Tile, m.Moving(), tc.want)
}
if m.Facing != tc.dir {
t.Errorf("%s: facing=%s", tc.dir, m.Facing)
}
}
}
func TestBlockedByWallTurnsButStays(t *testing.T) {
m := NewMover(pt(1, 1), 4)
if n := m.Step(1, Left, open); n != 0 {
t.Fatalf("gick genom väggen: %d", n)
}
if m.Moving() || m.Tile != (pt(1, 1)) {
t.Fatal("ska stå kvar")
}
if m.Facing != Left {
t.Fatalf("ska vända sig mot väggen, facing=%s", m.Facing)
}
}
func TestContinuousMovementKeepsPace(t *testing.T) {
m := NewMover(pt(1, 1), 4)
if n := m.Step(0.5, Right, open); n != 2 {
t.Fatalf("arrived=%d, vill ha 2", n)
}
if m.Tile != (pt(3, 1)) {
t.Fatalf("tile=%v", m.Tile)
}
// Liten överskjutning ska bli start på nästa ruta, inte tappas bort.
m = NewMover(pt(1, 1), 4)
m.Step(0.3, Right, open)
if m.Tile != (pt(2, 1)) || !m.Moving() || math.Abs(m.Progress-0.2) > 1e-9 {
t.Fatalf("tile=%v moving=%v progress=%.3f", m.Tile, m.Moving(), m.Progress)
}
}
func TestStopsAtTileBoundaryWithoutInput(t *testing.T) {
m := NewMover(pt(1, 1), 4)
m.Step(0.125, Right, open) // halvvägs
if !m.Moving() {
t.Fatal("ska vara på väg")
}
m.Step(1, None, open) // släpp tangenten: avsluta rutan, stanna sedan
if m.Moving() || m.Tile != (pt(2, 1)) {
t.Fatalf("tile=%v moving=%v", m.Tile, m.Moving())
}
}
func TestDirectionChangesOnlyAtTileBoundary(t *testing.T) {
m := NewMover(pt(1, 1), 4)
m.Step(0.125, Right, open) // halvvägs åt höger
m.Step(0.125, Down, open) // vill ner: ska först slutföra höger
if m.Tile != (pt(2, 1)) || m.Moving() {
t.Fatalf("efter 0.25 s: tile=%v moving=%v", m.Tile, m.Moving())
}
m.Step(0.25, Down, open)
if m.Tile != (pt(2, 2)) {
t.Fatalf("tile=%v", m.Tile)
}
}
func TestWorldPosInterpolates(t *testing.T) {
m := NewMover(pt(1, 1), 4)
m.Step(0.125, Right, open) // progress 0.5
x, y := m.WorldPos(16)
if x != 24 || y != 16 {
t.Fatalf("pos=(%v,%v), vill ha (24,16)", x, y)
}
m.Step(0.125, Right, open)
if x, _ := m.WorldPos(16); x != 32 {
t.Fatalf("x=%v", x)
}
}
func TestHugeDtIsBounded(t *testing.T) {
m := NewMover(pt(0, 0), 1000)
if n := m.Step(1000, Right, func(level.Point) bool { return true }); n != maxTilesPerStep {
t.Fatalf("arrived=%d, vill ha %d", n, maxTilesPerStep)
}
}
func TestDirHelpers(t *testing.T) {
for _, d := range []Dir{Up, Down, Left, Right} {
if d.Opposite().Opposite() != d {
t.Errorf("%s: dubbel motsats", d)
}
dx, dy := d.Delta()
ox, oy := d.Opposite().Delta()
if dx != -ox || dy != -oy {
t.Errorf("%s: delta/motsats stämmer inte", d)
}
}
if dx, dy := None.Delta(); dx != 0 || dy != 0 || None.Opposite() != None {
t.Error("None ska vara neutral")
}
}
// TestFrameExactArrival: 6 rutor/s vid 60 TPS = exakt 10 bildrutor per ruta,
// trots att 0.1 inte går att representera exakt. Viktigt för skriptläget.
func TestFrameExactArrival(t *testing.T) {
m := NewMover(pt(1, 1), 6)
arrived := 0
for frame := 1; frame <= 40; frame++ {
arrived += m.Step(1.0/60, Right, func(level.Point) bool { return true })
if frame%10 == 0 && (arrived != frame/10 || m.Moving()) {
t.Fatalf("bildruta %d: arrived=%d moving=%v progress=%.17f", frame, arrived, m.Moving(), m.Progress)
}
}
}

View File

@@ -0,0 +1,69 @@
{
"destination": {
"en": "Shibuya",
"ja": "渋谷",
"romaji": "Shibuya",
"sv": "Shibuya"
},
"difficulty": 2,
"grid": [
"#################################",
"#S#..i..#.#...........#.........#",
"#.#.#.#.#.#.#######.#.#########.#",
"#.#.#.#.#.#.#.....#.#...........#",
"#.###.#.#.#.#####.#.###########.#",
"#.#...#.#.........#.#.......#...#",
"#.#.###.###########.#.#.###.#.###",
"#.....#.......#...#.#.#.........#",
"#####.#######.#.#.#.#.#########.#",
"#.....#.....#...#...#...#.....#.#",
"#.#####.###.###########.#.###.#.#",
"#.......#...#.....#...#.#...#.#.#",
"#.#######.#.#.#.###.#.#.###.#.#.#",
"#.#.......#.#.#...#.#.#.....#.#.#",
"#.#.###.###.#.###.#.#.#######.#.#",
"#...#T#.#..i#.#.#...#.....#...#.#",
"#####.#.#.#.#.#.#####.#####.###.#",
"#.....#.#.#.#..i....#.......#G..#",
"#.#.#.#.#.#########.#########.#.#",
"#p..#...#.....................#P#",
"#################################"
],
"id": "002-shinjuku",
"name": {
"en": "Shinjuku Station",
"ja": "新宿駅",
"sv": "Shinjuku station"
},
"timeLimitSec": 0,
"trains": [
{
"correct": true,
"destination": {
"en": "Shibuya",
"ja": "渋谷",
"romaji": "Shibuya"
},
"line": {
"en": "Yamanote Line",
"ja": "山手線",
"romaji": "Yamanote-sen"
},
"platform": "P"
},
{
"correct": false,
"destination": {
"en": "Tokyo",
"ja": "東京",
"romaji": "Tōkyō"
},
"line": {
"en": "Chūō Line",
"ja": "中央線",
"romaji": "Chūō-sen"
},
"platform": "p"
}
]
}

View File

@@ -4,4 +4,4 @@ Pixel art: karaktärer, tilesets, UI, ikoner. PNG med alfa.
- Rita i den interna upplösningen (320×180, rutor om 8×8 px). Ingen - Rita i den interna upplösningen (320×180, rutor om 8×8 px). Ingen
uppskalning i filerna – spelet skalar med heltalsfaktor. uppskalning i filerna – spelet skalar med heltalsfaktor.
- `placeholder.png` är en 16×16-testfigur som används tills riktig grafik finns. - `tiles.png` och `player.png` är placeholder-grafik som genereras av `go run ./tools/genart`. Ersätt dem med riktig pixel art i samma layout (tiles: remsa om 16×16 i ordningen vägg, golv, start, perrong, spärr, automat, skylt; player: 3 bildrutor × 4 riktningar ner/upp/vänster/höger).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 B

BIN
resources/sprites/tiles.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 599 B

View File

@@ -13,5 +13,12 @@
"meter.toilet": "Toilet", "meter.toilet": "Toilet",
"meter.battery": "Battery", "meter.battery": "Battery",
"level.failed": "You missed too many trains…", "level.failed": "You missed too many trains…",
"level.cleared": "Arrived!" "level.cleared": "Arrived!",
"menu.choose_level": "Choose a level:",
"menu.hint": "Arrows/WASD: select Enter: start Esc: quit",
"hud.time": "Time",
"hud.steps": "Steps",
"hud.controls": "Esc: menu",
"level.wrong_train": "Wrong train!",
"result.hint": "R: replay Enter/Esc: menu"
} }

View File

@@ -13,5 +13,12 @@
"meter.toilet": "Toa", "meter.toilet": "Toa",
"meter.battery": "Batteri", "meter.battery": "Batteri",
"level.failed": "Du missade för många tåg…", "level.failed": "Du missade för många tåg…",
"level.cleared": "Framme!" "level.cleared": "Framme!",
"menu.choose_level": "Välj bana:",
"menu.hint": "Pilar/WASD: välj Enter: starta Esc: avsluta",
"hud.time": "Tid",
"hud.steps": "Steg",
"hud.controls": "Esc: meny",
"level.wrong_train": "Fel tåg!",
"result.hint": "R: spela om Enter/Esc: meny"
} }

217
tools/genart/main.go Normal file
View File

@@ -0,0 +1,217 @@
// Genererar placeholder-grafik tills riktig pixel art finns:
//
// go run ./tools/genart
//
// Skriver resources/sprites/tiles.png (en remsa 16×16-rutor i ordningen
// vägg, golv, start, perrong, spärr, automat, skylt) och
// resources/sprites/player.png (3 bildrutor × 4 riktningar: ner, upp,
// vänster, höger; kolumn 0 = stilla, 1–2 = gå). Ritas med enkla rektanglar
// så att allt är deterministiskt och lätt att justera.
package main
import (
"fmt"
"image"
"image/color"
"image/png"
"os"
"path/filepath"
)
const T = 16
func rgb(r, g, b uint8) color.RGBA { return color.RGBA{r, g, b, 255} }
var (
wallDark, wallMid, wallLight = rgb(38, 43, 68), rgb(58, 68, 102), rgb(90, 105, 136)
floorA, floorB, grout = rgb(139, 155, 180), rgb(128, 143, 168), rgb(108, 121, 146)
yellow, yellowDark = rgb(254, 174, 52), rgb(200, 130, 30)
metal, metalDark = rgb(190, 198, 208), rgb(120, 130, 145)
screenC, boxDark = rgb(44, 232, 245), rgb(50, 60, 80)
signBoard, white = rgb(24, 60, 120), rgb(255, 255, 255)
green, greenDark = rgb(99, 199, 77), rgb(60, 140, 50)
skin, hair, capC, capDark = rgb(240, 200, 160), rgb(60, 40, 30), rgb(228, 59, 68), rgb(170, 40, 50)
jacket, jacketDark = rgb(44, 232, 245), rgb(30, 170, 185)
pants, shoe, eye = rgb(38, 43, 68), rgb(255, 255, 255), rgb(38, 43, 68)
)
type canvas struct {
img *image.NRGBA
ox, oy int
}
func (c canvas) rect(x, y, w, h int, col color.RGBA) {
for yy := y; yy < y+h; yy++ {
for xx := x; xx < x+w; xx++ {
c.img.SetNRGBA(c.ox+xx, c.oy+yy, color.NRGBA{col.R, col.G, col.B, col.A})
}
}
}
func floor(c canvas) {
c.rect(0, 0, 7, 7, floorA)
c.rect(8, 0, 7, 7, floorB)
c.rect(0, 8, 7, 7, floorB)
c.rect(8, 8, 7, 7, floorA)
c.rect(7, 0, 1, 16, grout)
c.rect(15, 0, 1, 16, grout)
c.rect(0, 7, 16, 1, grout)
c.rect(0, 15, 16, 1, grout)
}
var tiles = []func(canvas){
func(c canvas) { // vägg
c.rect(0, 0, 16, 16, wallMid)
c.rect(0, 0, 16, 2, wallLight)
c.rect(0, 14, 16, 2, wallDark)
c.rect(0, 7, 16, 1, wallDark)
c.rect(7, 2, 1, 5, wallDark)
c.rect(3, 8, 1, 6, wallDark)
c.rect(11, 8, 1, 6, wallDark)
},
floor, // golv
func(c canvas) { // start: golv + dörrmatta
floor(c)
c.rect(3, 10, 10, 4, green)
c.rect(3, 10, 10, 1, greenDark)
c.rect(3, 13, 10, 1, greenDark)
},
func(c canvas) { // perrong: golv + gul ledlinje + kant
floor(c)
c.rect(0, 11, 16, 3, yellow)
for x := 1; x < 16; x += 4 {
c.rect(x, 12, 2, 1, yellowDark)
}
c.rect(0, 15, 16, 1, wallDark)
},
func(c canvas) { // spärr: två stolpar med grön lampa
floor(c)
for _, x := range []int{1, 11} {
c.rect(x, 1, 4, 13, metal)
c.rect(x, 1, 4, 1, white)
c.rect(x, 13, 4, 1, metalDark)
c.rect(x+1, 3, 2, 2, green)
}
},
func(c canvas) { // automat
floor(c)
c.rect(3, 1, 10, 13, boxDark)
c.rect(4, 2, 8, 5, screenC)
c.rect(5, 9, 6, 1, metal)
c.rect(5, 11, 2, 1, yellow)
c.rect(8, 11, 2, 1, green)
},
func(c canvas) { // skylt
floor(c)
c.rect(7, 7, 2, 9, boxDark)
c.rect(2, 1, 12, 7, signBoard)
c.rect(2, 1, 12, 1, white)
c.rect(3, 3, 10, 1, white)
c.rect(3, 5, 6, 1, white)
},
}
// player ritar en figur som tittar åt facing (0 ner, 1 upp, 2 vänster, 3 höger)
// i bildruta frame (0 stilla, 1–2 gå).
func player(c canvas, facing, frame int) {
// ben + skor
ly, ry := 11, 11
lx, rx := 5, 9
switch frame {
case 1:
ly = 10
case 2:
ry = 10
}
if facing >= 2 { // sidovy: benen närmare varandra, växelvis fram
lx, rx = 6, 8
if frame == 1 {
lx, rx = 5, 8
} else if frame == 2 {
lx, rx = 6, 9
}
}
c.rect(lx, ly, 2, 14-ly, pants)
c.rect(rx, ry, 2, 14-ry, pants)
c.rect(lx, 14, 2, 1, shoe)
c.rect(rx, 14, 2, 1, shoe)
// kropp + armar
c.rect(4, 6, 8, 5, jacket)
c.rect(4, 6, 8, 1, jacketDark)
c.rect(3, 7, 1, 3, jacket)
c.rect(12, 7, 1, 3, jacket)
// huvud
switch facing {
case 0: // ner
c.rect(4, 1, 8, 5, skin)
c.rect(3, 0, 10, 2, capC)
c.rect(3, 2, 10, 1, capDark)
c.rect(6, 4, 1, 1, eye)
c.rect(9, 4, 1, 1, eye)
case 1: // upp: bakhuvud
c.rect(4, 1, 8, 5, hair)
c.rect(3, 0, 10, 2, capC)
c.rect(3, 2, 10, 1, capDark)
case 2: // vänster
c.rect(4, 1, 8, 5, skin)
c.rect(9, 1, 3, 5, hair)
c.rect(3, 0, 10, 2, capC)
c.rect(1, 2, 8, 1, capDark)
c.rect(5, 4, 1, 1, eye)
case 3: // höger
c.rect(4, 1, 8, 5, skin)
c.rect(4, 1, 3, 5, hair)
c.rect(3, 0, 10, 2, capC)
c.rect(7, 2, 8, 1, capDark)
c.rect(10, 4, 1, 1, eye)
}
}
func main() {
out := filepath.Join(repoRoot(), "resources", "sprites")
tilesImg := image.NewNRGBA(image.Rect(0, 0, T*len(tiles), T))
for i, draw := range tiles {
draw(canvas{tilesImg, i * T, 0})
}
save(filepath.Join(out, "tiles.png"), tilesImg)
playerImg := image.NewNRGBA(image.Rect(0, 0, T*3, T*4))
for facing := 0; facing < 4; facing++ {
for frame := 0; frame < 3; frame++ {
player(canvas{playerImg, frame * T, facing * T}, facing, frame)
}
}
save(filepath.Join(out, "player.png"), playerImg)
}
func save(path string, img image.Image) {
f, err := os.Create(path)
if err != nil {
fail(err)
}
defer f.Close()
if err := png.Encode(f, img); err != nil {
fail(err)
}
fmt.Println("skrev", path)
}
func repoRoot() string {
dir, _ := os.Getwd()
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
fail(fmt.Errorf("hittade ingen go.mod"))
}
dir = parent
}
}
func fail(err error) {
fmt.Fprintln(os.Stderr, "genart:", err)
os.Exit(1)
}

82
tools/shots/main.go Normal file
View File

@@ -0,0 +1,82 @@
// Genererar dokumentationens skärmdumpar deterministiskt:
//
// go run ./tools/shots
//
// Bygger debug-binären, kör den med -script för varje bild och skriver
// PNG:er till doc/img/. Kör om detta när grafik eller UI ändrats, och
// committa bilderna – README och wikin pekar på dem.
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
)
type shot struct {
file string
level string
script string
}
var shots = []shot{
{"menu.png", "", "wait 10; shot %s; quit"},
{"level-001.png", "001-flygplatsen", "move right 4; move down 2; wait 5; shot %s; quit"},
{"level-002.png", "002-shinjuku", "move down 6; move right 4; wait 5; shot %s; quit"},
{"result.png", "001-flygplatsen",
"move down 2; move right 2; move down 2; move left 2; move down 4; move right 17; move down 1; move left 1; wait 15; shot %s; quit"},
}
func main() {
root := repoRoot()
out := filepath.Join(root, "doc", "img")
must(os.MkdirAll(out, 0o755))
build := exec.Command("go", "run", "./tools/build", "-mode", "debug")
build.Dir, build.Stdout, build.Stderr = root, os.Stdout, os.Stderr
must(build.Run())
exe := filepath.Join(root, "build", "debug", "metro-puzzle-jp")
if runtime.GOOS == "windows" {
exe += ".exe"
}
for _, s := range shots {
args := []string{"-script", fmt.Sprintf(s.script, filepath.Join(out, s.file))}
if s.level != "" {
args = append(args, "-level", s.level)
}
run := exec.Command(exe, args...)
run.Dir, run.Stdout, run.Stderr = filepath.Dir(exe), os.Stdout, os.Stderr
if err := run.Run(); err != nil {
fail(fmt.Errorf("%s: %w", s.file, err))
}
}
fmt.Println("klart:", out)
}
func repoRoot() string {
dir, _ := os.Getwd()
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
fail(fmt.Errorf("hittade ingen go.mod"))
}
dir = parent
}
}
func must(err error) {
if err != nil {
fail(err)
}
}
func fail(err error) {
fmt.Fprintln(os.Stderr, "shots:", err)
os.Exit(1)
}