diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml new file mode 100644 index 0000000..c13d5a0 --- /dev/null +++ b/.gitea/workflows/release.yaml @@ -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" diff --git a/README.md b/README.md index 605f43f..6760975 100644 --- a/README.md +++ b/README.md @@ -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+ 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. +**Status:** M1 klar (2026-10-23) – man kan gå runt i labyrinterna med kamera +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 @@ -80,12 +94,27 @@ arbetskatalogen (så `go run` och F5 fungerar), och `JP_RESOURCES=/sökväg` ### 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 -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 ``` +### 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 | | Linux | Windows | @@ -108,12 +137,22 @@ vid körning, så den fungerar på alla vanliga distributioner. ## Struktur i korthet ``` -cmd/metro-puzzle-jp/ programstart -internal/game/ Ebitengine-loop och rendering (enda paketet som får känna till Ebitengine) +cmd/metro-puzzle-jp/ programstart +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/res/ hittar och läser resources/ internal/rng/ seedad slump -tools/build/ byggverktyget +tools/ build/ genart/ shots/ 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. diff --git a/cmd/metro-puzzle-jp/main.go b/cmd/metro-puzzle-jp/main.go index 6b513a5..48653f9 100644 --- a/cmd/metro-puzzle-jp/main.go +++ b/cmd/metro-puzzle-jp/main.go @@ -14,9 +14,10 @@ import ( ) 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)") 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") flag.Parse() @@ -25,7 +26,7 @@ func main() { 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 { log.Fatalf("kunde inte starta: %v", err) } diff --git a/doc/img/level-001.png b/doc/img/level-001.png new file mode 100644 index 0000000..7071982 Binary files /dev/null and b/doc/img/level-001.png differ diff --git a/doc/img/level-002.png b/doc/img/level-002.png new file mode 100644 index 0000000..55063cd Binary files /dev/null and b/doc/img/level-002.png differ diff --git a/doc/img/menu.png b/doc/img/menu.png new file mode 100644 index 0000000..c8483a7 Binary files /dev/null and b/doc/img/menu.png differ diff --git a/doc/img/result.png b/doc/img/result.png new file mode 100644 index 0000000..f933cf9 Binary files /dev/null and b/doc/img/result.png differ diff --git a/doc/mal-och-tester.md b/doc/mal-och-tester.md index 8861c22..b79eca6 100644 --- a/doc/mal-och-tester.md +++ b/doc/mal-och-tester.md @@ -34,7 +34,7 @@ att bygga på Linux som på Windows. | 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 | +| Banor | 100 % passerar det automatiska lösbarhetstestet | 2 av 2 | | 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 | @@ -65,7 +65,7 @@ att bygga på Linux som på Windows. | 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 | `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 | ### 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` | `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` | `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 | ### 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 | +| ~~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 | | 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 | diff --git a/doc/plan.md b/doc/plan.md index c73e4ff..b259372 100644 --- a/doc/plan.md +++ b/doc/plan.md @@ -24,15 +24,15 @@ mål för spelet, men Gitea-CI kan byggas på Pi5-runnern senare. ## Leverans -Mönster A enligt playbooken (nedladdningsbar binär), när projektet ska -delas: `build/release/-/` är redan formen som en release-zip ska -ha. Repot är **lokalt** nu och synkas till Gitea senare (Björn skapar repot -och lägger till `claude` som medlem, därefter `git remote add origin -gitea-claude:brasse/metro-puzzle-jp.git`). +Mönster A enligt playbooken (nedladdningsbar binär). Repot ligger på Gitea +(`brasse/metro-puzzle-jp`, sedan 2026-10-23) och CI bygger release-zippar +för Linux och Windows till den rullande releasen `latest` vid varje push +till master; en tagg `vX.Y.Z` ger en versionerad release. Byggs på +brasse-linux01-runnern, inte Pi5:n. ## 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 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. diff --git a/doc/roadmap.md b/doc/roadmap.md index 45d4712..9eb479a 100644 --- a/doc/roadmap.md +++ b/doc/roadmap.md @@ -58,16 +58,20 @@ metro-puzzle-jp/ │ ├── 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 +│ ├── sim/ rutbaserad rörelse, kollision, kamera (ren Go) +│ ├── game/ Ebitengine-loop, scener, input, rendering, skriptläge ← får använda Ebitengine │ ├── archtest/ beroenderegler -│ └── … kommande: sim/, lang/, nav/, score/, save/, minigame/, ui/, audio/ +│ └── … kommande: lang/, nav/, score/, save/, minigame/, ui/, audio/ ├── 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 │ ├── sprites/ bilder, tilesets, UI │ ├── sound/ OGG/WAV │ ├── text/ sv.json, en.json, jp-dictionary.json, fonts/ │ └── 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 └── build/ (gitignorerad) debug/ och release/-/ ``` @@ -85,6 +89,27 @@ metro-puzzle-jp/ 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`. +### 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 ### 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 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. -- 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. +- Rutbaserad rörelse i 4 riktningar (pilar/WASD + gamepad d-pad/spak), mjukt + interpolerad, riktningsbyte vid rutgränser; kollision mot väggar; kamera + som följer spelaren och klampas till kartan (centrerar små kartor). +- Scen-system: meny (banlista) → bana → resultat (rätt/fel tåg, R = spela om). +- Tileset (`tiles.png`) och spelarsprite med gå-animation i fyra riktningar + (`player.png`), genererade av `tools/genart` tills riktig pixel art finns. +- Större testbana `002-shinjuku` (33×21 rutor) som bevisar kamerascrollen. +- 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 diff --git a/internal/game/assets.go b/internal/game/assets.go new file mode 100644 index 0000000..6cf7b64 --- /dev/null +++ b/internal/game/assets.go @@ -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 +} diff --git a/internal/game/game.go b/internal/game/game.go index bbe9c67..13c6d93 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -1,19 +1,11 @@ -// 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 innehåller Ebitengine-spelloopen och scenerna. Detta är det +// enda paketet (tillsammans med cmd/) som får känna till Ebitengine; all +// spellogik ligger i paket som går att testa utan fönster (internal/sim, +// internal/level …). 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/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 @@ -21,115 +13,87 @@ import ( const ( ScreenW = 320 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. 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 + Assets *Assets + scene Scene + next Scene + smokeLeft int // >0: avsluta automatiskt efter så många uppdateringar + script *script + frame *ebiten.Image // allt ritas hit och blittas sedan till skärmen (gör skärmdumpar möjliga) } // Options styr uppstarten. type Options struct { - LevelID string // bana att visa, t.ex. "001-flygplatsen" - SmokeFrames int // >0 avslutar efter N uppdateringar + LevelID string // hoppa direkt in i en bana (tom = meny) + 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. func New(opt Options) (*Game, error) { - dir, err := res.Dir() + a, err := LoadAssets() 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 := &Game{Assets: a, smokeLeft: opt.SmokeFrames, frame: ebiten.NewImage(ScreenW, ScreenH)} + if opt.Script != "" { + if g.script, err = parseScript(opt.Script); 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 + if opt.LevelID != "" { + s, err := NewLevelScene(a, opt.LevelID) + if err != nil { + return nil, err + } + g.scene = s + } else { + g.scene, err = NewMenuScene(a) + if err != nil { + return nil, err + } } 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. 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 + 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) { - 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( - "Metro Puzzle JP %s (%s) %.0f fps\nBana: %s\nResurser: %s\nESC avslutar", - buildinfo.Version, buildinfo.Mode, ebiten.ActualFPS(), name, g.resDir)) + g.scene.Draw(g, g.frame) + screen.DrawImage(g.frame, nil) } // 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 -} +func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) { return ScreenW, ScreenH } diff --git a/internal/game/input.go b/internal/game/input.go new file mode 100644 index 0000000..257e92e --- /dev/null +++ b/internal/game/input.go @@ -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) } diff --git a/internal/game/scene_level.go b/internal/game/scene_level.go new file mode 100644 index 0000000..11725ab --- /dev/null +++ b/internal/game/scene_level.go @@ -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) +} diff --git a/internal/game/scene_menu.go b/internal/game/scene_menu.go new file mode 100644 index 0000000..7308a6b --- /dev/null +++ b/internal/game/scene_menu.go @@ -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) +} diff --git a/internal/game/scene_result.go b/internal/game/scene_result.go new file mode 100644 index 0000000..69724c3 --- /dev/null +++ b/internal/game/scene_result.go @@ -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) +} diff --git a/internal/game/script.go b/internal/game/script.go new file mode 100644 index 0000000..99af1c7 --- /dev/null +++ b/internal/game/script.go @@ -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) +} diff --git a/internal/level/level.go b/internal/level/level.go index 8b1a417..f4fbb07 100644 --- a/internal/level/level.go +++ b/internal/level/level.go @@ -9,6 +9,9 @@ package level import ( "encoding/json" "fmt" + "os" + "path/filepath" + "sort" "strings" ) @@ -226,3 +229,39 @@ func (l *Level) Unreachable() []Point { } 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 +} diff --git a/internal/level/level_test.go b/internal/level/level_test.go index 3c4fbf6..2013450 100644 --- a/internal/level/level_test.go +++ b/internal/level/level_test.go @@ -3,6 +3,7 @@ package level import ( "os" "path/filepath" + "sort" "strings" "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") + } +} diff --git a/internal/res/res_test.go b/internal/res/res_test.go index f8c8301..922941e 100644 --- a/internal/res/res_test.go +++ b/internal/res/res_test.go @@ -70,7 +70,7 @@ func TestDir_RealRepo(t *testing.T) { if m["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) } } diff --git a/internal/sim/camera.go b/internal/sim/camera.go new file mode 100644 index 0000000..8c28d30 --- /dev/null +++ b/internal/sim/camera.go @@ -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 +} diff --git a/internal/sim/camera_test.go b/internal/sim/camera_test.go new file mode 100644 index 0000000..fa61837 --- /dev/null +++ b/internal/sim/camera_test.go @@ -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) + } +} diff --git a/internal/sim/dir.go b/internal/sim/dir.go new file mode 100644 index 0000000..1c11d5c --- /dev/null +++ b/internal/sim/dir.go @@ -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] +} diff --git a/internal/sim/mover.go b/internal/sim/mover.go new file mode 100644 index 0000000..48bb355 --- /dev/null +++ b/internal/sim/mover.go @@ -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 +} diff --git a/internal/sim/mover_test.go b/internal/sim/mover_test.go new file mode 100644 index 0000000..d295d44 --- /dev/null +++ b/internal/sim/mover_test.go @@ -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) + } + } +} diff --git a/resources/data/levels/002-shinjuku.json b/resources/data/levels/002-shinjuku.json new file mode 100644 index 0000000..a24a240 --- /dev/null +++ b/resources/data/levels/002-shinjuku.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/resources/sprites/README.md b/resources/sprites/README.md index 100fbe2..6d20d6d 100644 --- a/resources/sprites/README.md +++ b/resources/sprites/README.md @@ -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 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). diff --git a/resources/sprites/placeholder.png b/resources/sprites/placeholder.png deleted file mode 100644 index a848cfc..0000000 Binary files a/resources/sprites/placeholder.png and /dev/null differ diff --git a/resources/sprites/player.png b/resources/sprites/player.png new file mode 100644 index 0000000..b69ce5a Binary files /dev/null and b/resources/sprites/player.png differ diff --git a/resources/sprites/tiles.png b/resources/sprites/tiles.png new file mode 100644 index 0000000..c2df2e3 Binary files /dev/null and b/resources/sprites/tiles.png differ diff --git a/resources/text/en.json b/resources/text/en.json index b6aa18d..9617310 100644 --- a/resources/text/en.json +++ b/resources/text/en.json @@ -13,5 +13,12 @@ "meter.toilet": "Toilet", "meter.battery": "Battery", "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" } diff --git a/resources/text/sv.json b/resources/text/sv.json index 73fb557..cec5466 100644 --- a/resources/text/sv.json +++ b/resources/text/sv.json @@ -13,5 +13,12 @@ "meter.toilet": "Toa", "meter.battery": "Batteri", "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" } diff --git a/tools/genart/main.go b/tools/genart/main.go new file mode 100644 index 0000000..35d9e4f --- /dev/null +++ b/tools/genart/main.go @@ -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) +} diff --git a/tools/shots/main.go b/tools/shots/main.go new file mode 100644 index 0000000..3009f9a --- /dev/null +++ b/tools/shots/main.go @@ -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) +}