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>
121
.gitea/workflows/release.yaml
Normal 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"
|
||||||
51
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+
|
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).
|
||||||
|
|
||||||
|

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

|
||||||
|
|
||||||
|
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.
|
||||||
|
|||||||
@@ -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
|
After Width: | Height: | Size: 16 KiB |
BIN
doc/img/level-002.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
doc/img/menu.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
doc/img/result.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
@@ -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 |
|
||||||
|
|||||||
12
doc/plan.md
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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) }
|
||||||
110
internal/game/scene_level.go
Normal 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)
|
||||||
|
}
|
||||||
83
internal/game/scene_menu.go
Normal 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)
|
||||||
|
}
|
||||||
65
internal/game/scene_result.go
Normal 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
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
41
internal/sim/camera_test.go
Normal 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
@@ -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
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
69
resources/data/levels/002-shinjuku.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 243 B |
BIN
resources/sprites/player.png
Normal file
|
After Width: | Height: | Size: 591 B |
BIN
resources/sprites/tiles.png
Normal file
|
After Width: | Height: | Size: 599 B |
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
}
|
||||||