From 4ae7dba291c014ab8053d2e299ebeef38a2da547 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 23 Oct 2026 19:16:53 +0200 Subject: [PATCH] =?UTF-8?q?M1:=20r=C3=B6relse,=20kollision,=20kamera,=20sc?= =?UTF-8?q?ener,=20tileset,=20sk=C3=A4rmdumpar,=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitea/workflows/release.yaml | 121 +++++++++++++ README.md | 53 +++++- cmd/metro-puzzle-jp/main.go | 5 +- doc/img/level-001.png | Bin 0 -> 16935 bytes doc/img/level-002.png | Bin 0 -> 16769 bytes doc/img/menu.png | Bin 0 -> 14569 bytes doc/img/result.png | Bin 0 -> 22149 bytes doc/mal-och-tester.md | 11 +- doc/plan.md | 12 +- doc/roadmap.md | 55 ++++-- internal/game/assets.go | 97 +++++++++++ internal/game/game.go | 144 ++++++---------- internal/game/input.go | 99 +++++++++++ internal/game/scene_level.go | 110 ++++++++++++ internal/game/scene_menu.go | 83 +++++++++ internal/game/scene_result.go | 65 +++++++ internal/game/script.go | 189 +++++++++++++++++++++ internal/level/level.go | 39 +++++ internal/level/level_test.go | 34 ++++ internal/res/res_test.go | 2 +- internal/sim/camera.go | 38 +++++ internal/sim/camera_test.go | 41 +++++ internal/sim/dir.go | 49 ++++++ internal/sim/mover.go | 83 +++++++++ internal/sim/mover_test.go | 136 +++++++++++++++ resources/data/levels/002-shinjuku.json | 69 ++++++++ resources/sprites/README.md | 2 +- resources/sprites/placeholder.png | Bin 243 -> 0 bytes resources/sprites/player.png | Bin 0 -> 591 bytes resources/sprites/tiles.png | Bin 0 -> 599 bytes resources/text/en.json | 9 +- resources/text/sv.json | 9 +- tools/genart/main.go | 217 ++++++++++++++++++++++++ tools/shots/main.go | 82 +++++++++ 34 files changed, 1730 insertions(+), 124 deletions(-) create mode 100644 .gitea/workflows/release.yaml create mode 100644 doc/img/level-001.png create mode 100644 doc/img/level-002.png create mode 100644 doc/img/menu.png create mode 100644 doc/img/result.png create mode 100644 internal/game/assets.go create mode 100644 internal/game/input.go create mode 100644 internal/game/scene_level.go create mode 100644 internal/game/scene_menu.go create mode 100644 internal/game/scene_result.go create mode 100644 internal/game/script.go create mode 100644 internal/sim/camera.go create mode 100644 internal/sim/camera_test.go create mode 100644 internal/sim/dir.go create mode 100644 internal/sim/mover.go create mode 100644 internal/sim/mover_test.go create mode 100644 resources/data/levels/002-shinjuku.json delete mode 100644 resources/sprites/placeholder.png create mode 100644 resources/sprites/player.png create mode 100644 resources/sprites/tiles.png create mode 100644 tools/genart/main.go create mode 100644 tools/shots/main.go 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 0000000000000000000000000000000000000000..7071982c12f66cabf578bc87c6273908329aa16c GIT binary patch literal 16935 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 literal 0 HcmV?d00001 diff --git a/doc/img/level-002.png b/doc/img/level-002.png new file mode 100644 index 0000000000000000000000000000000000000000..55063cd7d96f35bfc1c1f29800e46c64f29f2996 GIT binary patch literal 16769 zcmdsf2{_c>{=cP=Y*`|Wh-}FgWr-O{*^{ytlk7>dWuLKTH%YcEqio+wBwMm&YZRG@ z5HVsfWEspb#+dO}-+S-V;@j7~|L=Y7{oUt%9-ld9<~g5p-tX6WpVxVPK987lXSG?G z`I%{GXjpZ1w9eDe(7&Xip#w0{ZMIZ_&K;qlIjN(ob<)HyYc{*qh})R^mH7Y<{Tr`> z5FJHhD`}1W7x!~*SaI#QbIP-D))cvSnx4}NLx`CLfishi;$q9@*LA9+&*T|96;Dq;K5C}bGV0<~Tq+K>k6+8W0JkX>w{uHJ zzFti7=8`6w=Lgc_T`0u1wWWUE<;9tnFIT)Ru(POvbSZUD1cxTm!3b1iWaujmyIgq` zpEHXh13!Jj*D5^=q2Pls03WxzJ3+I|?fxq>cZqp&Xjal2sdx(Kk3og_=)BgflVjVG{;uDgUw|@t!cC`y|uB zyHm@VPThutg{BMBntwRT-v{l*_3iZkF#CNy34U3bUg zKb8fWIjPZROGzDs;bx z=O5$yd$hdGRcq|4SJ>EzoZ`S^%l$Ee0yrnPjp_Rn1wQ+vJi&Ftnoo?nt~m7gjc)*?v3c^8sR0HMZ<7zuCFDiOg6so{#9A)jTF-czufQ zT}NU)$G~ZmCH{h5)S9yNo!FDVaA--62G`c{%By0E8Xmlu4mKQin4aZHz1Fq&YVcb~ zR;sAXXIDcrgArE`7vop4S;iC~_VUU`Nws$6#DI7uRIa&8 z4r!$Eq4tx2itKMuS;zz4$Us!rGdChmJ@tCPt(*N86WmR}VSlq6;+qQ+ro z*&-#>q25b3yH>m^ZO*c0U;WVi@Or7ct`fd!?M*4|m3GhwYG|jG@SBZK+UBotkcujGnzML%~-UK35^D-9M7!?1&2(kN7 zK9T@t6aUsBgz~fuT$}g6K9e}%@Qe$}+$^T6Mox70%<2*mk1Xz+O@(&lb|CD-ZZ$>h z8&I&LW6s6R#Q4ncMv5363u01P1xfaDU;jb6RsnC z;sjIT=;%8tl&=fe^#a9|%SE;>vCGJ?R?6ob%i|?Kk*C*nW)e&(%8=a)-*7AZNbFbu z=5zCgewr1E2E6DYBGMihS2i-FPQGJaeoil0=kTKb!r;9U`FoZ^zjZLWvK(+$L;2+{ zYG6WN(I+tSvnzhzuolP2PrsGDXb#Eg1VSYGgN4K0ji^W5oCuTSr(NS$qfP`)Jag%_ z-J=fjTOo}&`eM_%`wETlMrn|q`daeyOUJFb4krTEQ~l)OX`65gN`o!_1`n6C2x^X> z&5$o2n0>qvH7$iMh{Iq&FAfxFt;nS58!v|_G)~Bm(d^JZ@=`ocn*TxpKJ*gp6^e5B znOhZ&HVy0by}~zh?>@fm|7MU^_05@LnUNDEoWSyGCVvNk9a;X`l8hoBoEx5ar#S-s z@m2BsTD0d(-DkBy>e6XE&h{&59XSKwfHzGH9)=5h8;6~4s(X7TeAaHr*+{cspRv5P z9KTGI!dq?WBYt*5`vGT`t!#>!KH%BnoMX*A;RU1J{4lxZQq^>6BRHi)!RjKokq|e% zT&a)=v9rsoSl*{#3HKIg{$zJtsZq);JBdyj{f_BHP`r+iNpO=ohu~?MOZMWRBn>!P zY6>6`q8!qOJjKC(rE-Tdbo%ox(eOv@i@Pe?5Ub5}+3e$wViO&%8- z6G!&7D!wx33(1v7Kd4oU?DT0_K9|22?*9qTKU1T?jvos14@)yJojkK}=k-%G(v_Fk zBFFNz1nsfY*vicLP|0zbJ+!{7nkylfC+YoRJdP4x&rb483(>WWr7+~}6+jtRzSn?q z)0;kh&fG$?SGIx1dZ(_^C1{p+ck^^D(fY-xba%q#P7SlkSK*9CRUtw<`lPSCf#*>ZX z)inKlx-sN3P4@ixIGIDW7DDknu6vonL?tMnYz?JNck;J>?zTpch10Wyf21f!3Q5SC zh^FP;=hC5T{e&-WxpB zczLoMgMoN8^D8u<4Eg69$BK#ELKgXVJ&0hd|kY8X1Z}K z{KAr%Y*rw(A)Hk?hO3?D>5Q#LHykqL4ke{y8IfSl1jXw0te=N zQ`VV6&QIRrz4v-nHcd(>=7>!v*7}$kT~I46|K~TerI#V9k%Mnbn4*yP|r~I)eu-W-(5`xAa-$|J~ZrV|T4;Ukv5&O}X1XR~q4*pTMT<`38I*K2xf@ z)rYh!9+^BCvFEjbl zvq0kA<8K!{I#}UM{7JoI!@dWAOb(e*Few?6^N-U|3my$YYlc(R{;R7*^ou7ojX8~e zeKK7&162)BIhKwQ&%h0#QH|!%2>M-&oNR{xI?A_i{JKFvS9yN8aim%9w0zwKwB3Py z3lkkU&8iA5Ei6hF2~E0jizE)wC3Dua zv%f}?y)qEtvdmHQHe+%!St{QK1l!}VW%uvgWkI?y~ExwzN)P~@pe!0 zYm5}nF%fWZ3H)wNQJotA8_kf{M_uA6@VGa;IKH~-t+?Ct^yI{FFu=X|FmB$NDS|uq zUK3}Nc(ONce)zVu=LawqCy8vq=YxkG6cnnljaQpkTUbMlZ9k|CQm{%D!8v5swIW7HNYI78J8q8lR8p`(RJ( zIcGUolA@?@^*PB>OYc1Dwm-2axq2h!R&HE-L`BSC*iHuiUzjSGfuC_JdvS|ieRZZT zn~dPalR!jRKR+svRGNN)E^OHH$+T99uOPa?t6&Vg@;MDB3yMRK!PY_CZHs~v#=CI! zWRC*91ofa|19)_0Bn)X?$XOY4B>y#<_x`m4$*u#sB|SR3R3E=}uIOh50-M@HB;7pA z?`rqTEL2=NLiY)FIIh(<_K|R$^fTY{D`_L}XN6i#i|?w73FM|3sf}Rkm{z`|@p=G> zLMb|U(FE0glagNt6~pj*Z+4v)OnI~jKr2$>O}R&~z1>u;cWgbCvx_4b(_yQm{cb~T(h!;V@W zP(Q7>lb-b#R$s}Y$hMVvvoZ#79F8Q{2g(oimWLA`f?nRgcSNI66PywKwKXogQEFmU-mw z*>ObZYup1w1g07XY6DTuk0jVIA|JHMm?AWod8$#(Cb7a{4>&YUEI3$cGZn9h?Ag8F z?NCBWIPFOlo(d4BY zlLPyh86(4+Pmbo^IHxQD;7&X%0U%k~qQ_2S{d#3>IO_KE^hBrFy$){%x9#`VI&J&z zbD4II%X2^^2eaTrjERn2*1i`D)~d^ik6~}N zSdYaXkb=9g+<)ptom@X_d!4(_T#+jgLcNKW(FRU>^qC)QNk7vOdvaQ1<`?Gsf}S;( zRxyMDMZ>Q_?@aTxl0%b=3xr^@6>|#DZt$D=Sli^7`lt^Dss{VHQB>ANSu>Z>QDm17 z#Vt)~E`Dz6Wz~uTYZk~?)J=F=GnODK^@O!V+6l1?Ja^slYxM2q*hscvQY;VYIR*6 zo@ot+tpp?@77c>c`po@Y?fZakS(O)$#4L&{b^9H>T0R1As_radV+>ckYgB|+ zS+$pC&8#H8J89Z)1cI3NF3;{RO$S-$fIP&!#o^OtdcI4>pR4XJo7#Z4b*nGP>eL>6 z-ncN+Zb=FAe1Nlj4lXZQcJ#Das+l8=2kz#cM?S45mymiENnY;M@eEW`&=Tb;WR*HY zK$}`;!8@F?NDAf46Mg`rOpN8sTYKxF-sRJO2ke&f`v;uam=RgriHhg+5==ohA^!|eu>?vY_0o{6n zIs8oXv*@HU}x^d$ns(eqR5~DWLrr$7O=&b4)ku}_6-`;g-e8^ z*d9rl3wYIjl2sFA7V)(>wNjK}c>AuNkAy10T~QIInh%cNzM-Rh2*Anq$JN0@0CRSj zk;ib`(D2ABhc}%#_NeD+YlWXE7=`%^+E@7U_4O}9HmarYCGW$*%g~@-b@$!PDs+`; zen?K+f(+p^je5Nra^(6t5t)W7bst0DYjm&-4)|!~DMMAuflpw+&UkQ6T&1gg5%0GV zzf4W~)0zHlXnBUsv_Iv{KN%L00R^5Nlz-H$!4We0N+cpAi=g@B_C_6Jr$K;8~-Yut5_c=Inc0@5sk4WL9B9hKIVQ3T+CWJ?ox8_*2Y=|9|C-C% z5EH>AIMZ(3u6Nzgu=k0VV7Sa9N2Je2f20r9UI1GZ1R-cc3F4)ozOu6&s%W@KJ!spT z+aXDS{8T}oLyYZ(`Yv)%ac-h%ml0`w3PznWvVq>cDv3jup#`_GDgVA{d2%OqL?Md! ze2uGpmE^Ap<6D$pUFs3vi4%m&7lHOuYH|wG%i#$WWzYm31E~Q^6Fa_S!=)8WuuLOz z+oK+r0MJ#?NNemy{qq+>BjZ$02Y%gctQrIcUOE;W=w0e=Id8LeVIFokjXbeADu1HB zWQsW&yF@nF?nbSh*nDtvIKd24vNz`h+fE8Ge|j1UI2rMf?n3_SW14#Gt{E*%59!7Z zYDTzdrmy!%EU9NorcTTix0|p3C)deewLaJ2A?R6lgttv=)@M}#UU_Vxt~L=r4pAc^ z$jMOXkP#IUFYj2g7~BPgi~e$p`zFJri;aFrJy?jx5c;e5#PA9@H9<GF z{V~w_vy%#MgXiCB^e+baH?UzzoJos!{GBGzk2jkvKZaJ(yD<*3RR}nrK;6up)A+q1 zpyULKhV}bGVb_3Xfrnb3#|~XTE?+DR`{>5<7AcFzNC@^B`BT#2OY70m20XaU6>J%K zYTIU)&x{zRoQi+k-n;C;QafHip$cO?D`UN^pN-7jE3K=CwyNM2!bu)?*=G=I)8!B? ztwKFFXrqbp_U+Zf8#Ld#Y&)L`;~_R`pi0nLZwhguqo-V1X`=cFBaD>MzfS0E8i6g_ zQ3#fxd8`Evn`}+iKFN2BPWe)k$lb7HR8yO4v>p4$cP-pm5#6VlB9E{w8!`De6E7Xr zVLFI%UsZwO--C=QaB15lpoahsoNOTs&i@l-YIr(GcZS1D;8q2Te6@#xJ`vAI#8gx8 zkUDr4(Jo%TQf!c@zb#_EnY9v+?m{%E6I>{&9d=jy23C>cjioCtwKCGs42PUdpTyw4 z3|tRcp%xxOw1dwD*9qdpv6K%8;7Xa%li&xzjo&1`UmpY{lD7>Z?;9GRfoi+EZ1Gii&Tuyb{q1a&^YCEV^&V{v6kX7#%AObrB!b53aLgdODl&8`k?eb z5#(>6N@WizktvkFrIK_11fJYD6CVW! z#ro{=@(L8_D3--zeFU)2sOO*dL0wfL6A7*7`oreoea*^lMSUC(JyAnOo%4G?`_Eju?m<*xtYF z;&$+&%!i9dt~-?lu%?FP7U;axe0sbf1e$l-F_@Wr<*p-G)X(ufKMln>64MGic7QR9`wOtB_2<1-X)8a(J zvl<%{Jpyf@wfiu7kFLJe|C7_e*#r$K6mtdLlkYCW_cdTwIFhP{8rN?3l7RjI9LJv> zE3S*8{+I!?Jh>sKl)k@{b&|m=yryYLXJ|;{=OaphAf4eK&KKbrX-6Hij(*pT6Klua*gqr2@IPbLj?(*VPXoClNz9k(djOFE(k7db_MV4FvD6^vtu3VeM zd-LbvCxWtpR#TG^=y!Pj;a)#DhoK097`EJ>H#ofaz zh_93)QIr>@d^PtNir<;Fg29UZNob#j>lW219KSOc+aFqD%`U`xDn`oOVISg67Au3t z`z?F<{vIx@+hHev`ELlEA@c^{R~{t>Z#Hyw2g!snp=X6^>S#pS(XQZ;v+cIcxF-TLzAoG)oPeB1 zp9Pq|#;@YpLpOYD7Y7e#oE2+3QtFOdycb=eHV%3vZDZnNF@n4?{mercitpUyi2fger!8+y+}*bZ+>dR{@tO1@u68~if(1L`yd3%y zjRDcNG_i&4uRS*u_|ET)XHU{|h;NVIm*@Ye(4w=1dGT^18AE*zf}%H7L%1G!+xn~B z)|}vfCThQ)NE-c`3Q)QMdUn$3C`JHR48`&YUNo5W?V?z-gowzT0dp2eJ zG)rPMn%(Yj=af2-{cUYF-d)nJGx!xHb{q(L#_VjU&IQX-P8}vik`lAT{sj}t(vdr!5&mcWMpLsZ@MhUC#GT&g^`@( zb}F|hf-$9x8y6s2S!HA^Q;CYynS~+;712P~>PH}D=5+oLlQ@1@Wd4nz4 zr<9iep_J0= zSsAC&HsEU=pQy0rHBaW7`{I+$0ErID5}nqG*tQaZd?6++Tbi6mZH^{WIqgNN6Q8qa zbX0W2z>c8eGyb04=!W%ILvsn*;d|sxTkKp}@+5Px~sXH{3vJoG45DNu2{1{ofI+p>R zP+uEg!6hp9{5%Y|s9s7?-}duUgYC;QKSAL?@(hxfc}lAGX8fQA zix#cPEkG0B`F$qO7mpd}2?|vj{nj0|=Q(q zTiZ`tuL3agusLnhg;g#Q?ms^!u{86=sUlyUdS)KB#nI^Z;}QWh{0)$Q_Yi?T1TqN3 zDvvk?Jbs7`-yppiIptMVYYo8KUzJ{-#iM*1dXbHI;YOIsWPIlr93_3lkq8k1@S=7D zc-XdWiSU0xnEmgF<6i(C!dM6zkXWQ#DqNbT|2OY?B_b!s&0Ig%q;kSvTcbB+M3cx+ z3Fh3Oc1|EI@p4{=2jhIJI`b7LkuOhD>8>0b+QH>1W~h^L1%2arjYoOie!N4&9n!N+ z6~=x;=lhN4ANx>1!LFa2s6_MPab`{N=zg#t>&WUQnf@CYviOwFG>YBEA+q+?Sx^)u zW%$$i`UNct8ijc{?^Je^>^6J`%&rc*smGdzw&Lpvqs}Ur5!h8C=E*ctLhBKgeG zQA|ud`M3F;gx)EAv^Wy?^e_yLq;m_c(zuu_joRzHyJF|yUsAQ)JJmw+H0%d?TzAWq z5kL&A*7#b#AW#-5mg|(obw5a-8VD_m>%aF;sIr?Xe;J#+bt8Onz`Q4elx+mrOUZuW z(mkQ%1h{oowdnch9Km25WC%u9<}vrVc$Zn{u!^VGyy(AILz9}Ym$;mGEx2GbGcWF zK$SpEbi+GCg8B+vbPl8f?Wt=FRC-7BgQODH(+T5hcz@jK&HA-$jT50a>E?C}?yBH& z7Dt`U{k@_7J=afU14xTe1M)sL)AbzaCsz**g-xv zNx8G#htYZGnim&Vx%*8!-_H45V#}ze5~H$a-iiVDA4Rm>CuhJmDNH9{&*}$gHyO5E z2J9;uT1zLqUs>PwvCUX)l;)?I{79RA_`%cwZGAxS^hS^zp-~nL2e&$TQQvqjJ#-5` zlenn>&5L`0R^SAxU2rjMS+W`^QbZy@oFi>pbIAKwPLe;zee0HCn%pnB>27bRL}Ffo zJ`=5!?Mh=|ND9ZLh+dPi%jtomM*&iCh}%j!%Igh{KZyF@OZ@--n!07KJFfp-qfzjK zF8>23kzP3z*O(3S_bSGMNQ}Q4lKACqx)IPkg7O?*M~+!5UQ_FZfW!dXH(jp8IrJJ4 z-W$B&F4Iefb`7l5L$eR#VSSp^^XKLuSr`CzJR3t%fNVS$Bw`>-^9U7;*CPtVKzaM- zDfWLy=BsDO1#P}O7`vpw7cy$yz9+R}_rq#S*ljyMdiOC&dNWO?FKow0HQs24a+*no=3n7rJPnn6WWU_A@8wH%$b@`F3HONb78;>hS6f2x9TN z`aGOK!31R)y(T?}4uM38+ZT3x{)P7rASZHh^fZn;^P~4@yU1+*YXutJ(`U6xG%tt! EKdu7tPXGV_ literal 0 HcmV?d00001 diff --git a/doc/img/menu.png b/doc/img/menu.png new file mode 100644 index 0000000000000000000000000000000000000000..c8483a7f9dd7d94796166fa13d6f4aa6d5c5494c GIT binary patch literal 14569 zcmeHOXH?Tk*QX;v=|v!dN|z2Ip$OSUL_`pzhbABpKx#y~0jWw`KzfT5K}teXIxHw6 zy%VGdY0^8=U-a4HYQQhgp7njs=Kjx_%zT)8=Qnre-nnzN3pRw<4j5jAo zC*Pb{{UEP3JZfc|vBJLsM<#HS^Rxqs@N&Ue3qzV|?@Gx=YbHNj!36)I+p=VNLkHL1 z;XYg=lOy#ZXTSco0NAv^DV>ui7x;1&yt|FDK!~<8LQfSDLGW|Edcjt|SGWDe+N)od zElsT4KRDQ0oY-7nT-9z%klUH#9N;>D?QQO^&N>g;)>lsO$^>n*S0K8_(i7G_&b6Po zh!+fIJ)*eB>GjM`1;hoUAp;Qmde{DhL42;lxPUHg5|?U<11*$1BUJ}mb!@lV1?qO< zmcG-YBn9*@9sSm?kbfdh3@B8!6_<6r#iw?+as zwArj*9^B2|6}jwUzRQgq|MiZWgWSZgjkFE=uxq`tsky!~VE-3XFy__>;`%c5LDY9x>#4onTj zQ$mnj^x|m$0V=MvUed`A8173|OPzr)EZDMSSePQ zXq)8cd)5_h?vi~+Lv(4sVmG~ozs4!)6xt<7A*ZU{9RL7WvbnzewecMu&uu>S(w4}i z1P~GD{;<{gwjj9QK+G!1Vvm^M(wlE7R&%csVvfU~o7Q@thYLW=% zU0O4E+h#Dm)azTby2efr_lGa`g=;n^iwU5jz&)$w7a~JJeKw>9cSpdC>%~n1*o@Y5mk@JNZ57ZFKf2)WQO=n{e7H)1H3}ocR^%+wbyTv8FE)z-gPTA51dTipQd&X8^ zmOCUqXX#FP#`1xMVEf67_$^=&Y(<+``*|QX;$*=DJb-Nq0-0^l#ha4xS@|v7tLyn{ zT_(0JhC*^TvRGbBMY~mSCT9eYb8l}>?Npia?#?g{-fmK$mp(-l=VgXqfMP4A6^~-M zcLkL4j(Nz^t((m8{h67!r!NLbs2kYaU=+1lcb_0i?&XLO#x{mO2IhaN=!8EOlJ})vR;L zJ+Uz}^SPegjh2`(AvA;9E9?+{X73?Q1o4r@LaE6o3ndwBJD5)gat zB>R)*feXh>f-f2~lejFENG2*v?9M4YDah7~e4@U+^Pw|7=ZuuElKU+qBhQiMMMOuJZ7+@Y@z{@FBf=8R@6-OIVZB`_1Pm1w4e4PmLzb5-7i=OC|}z#O*|Go zZ_964$A960@apSM9UJ!oO%Zjb2}drLg5EMf{K3^+K^>*#c(=l?H}L?NwBsc8X3vxU z6Koq&TiJV&@!n;3FCTZNCT4AC17f54FuT|MohK8oks^EJs^Ig|wx*7`{i8@3Q|m6n z1GtHQZJeRqDxVm&S%;iX{7#ZpE_G~owZyT9xjkVo4$N6B1VZ?{cTaj?C6VXI)v_j` zUdYjV$8<8cVq%^dV>VkA+Fzan5A0p=Evu=`q%1 z`~Ha{Vr;bbka!KgIP$=KyCT_RYkO;R5u-xcLGjD3hs);x65owlMZm2@G+=9VWrgZQ5M_)xhoqxoosB2kj*xhOA)%^i#&JL6j>4C-obugr}47 zGfFr7#vWM|P#hDju=PJ$A@eiR8+0#A1ox}!KY0=9<1N&yT^ZRzdwlpqKmnlux`vgK zBJ5swj^_>+dC;4(m&E=8dysEGv{!CzC``OLzR{Cf>UNLs`ujY~;yfQsZm4e$gogImoBK_T`{z&z~)}BVF_C8k$brQ z+pv*3P8U^FkepJyny7h)y4Z%LLtIBQXDZT&?AAVJ2wskUbf%MM!U7TzQ3&;8olxrW z340VA6vn`jF}zZC&4n(j;dv_m;@d`aIdnKU=ulACU_Ej1^@y;n@iaH(;AV=Wfy>}H z0=SG~yE{1p6gfj85w zCy1)$_|FzU%59>!SOWN`#8yFV}8NSYGc%PwSCj| zv-$)dF)eDC+khhbs>BaNSVE;T z!`{A<+HlUWRMHnL4m;;8{am~rsVBemJaXKmqnm0f)gFI^C_37?O)XcR`Damnhb*0{ z-NmEL&h*rg9EF5=ZnS1)z7T$`r@_PijNjQIvtKW7xNQZZ6r`8OFc>;$shPNIp!OmT z;}~VGQK`(Q3S)+kcZh~MX{258FcdZ4q6cnScPE5NHgh`mbZfOn*pA)dJ9_YjO20?t z{}QBMyMn(rH8OH&>^vqqf+%ONIo;_W7#~*;<|s|d5|6c=WD#q~Y8IP)KKbww+m!mM zLNEts233QF=9xf9HeDiQIL9&^Hq*BfHa?MkxbkW^%!^CLlhlf?RtASj-gR)udZX@8 zn5C06UM4SBnJHAD6WpDZee_QIfE9Z*R!kklOc(3bd!RMs-u0PQSGLTCQtxVB;)#!J zv3%76_;xi+b7DU{{)8!kVBUr?Kx+lNKhwQrBk1?~V!}R^KVkZ=V!+>?uD-qucp&iS z#mHI#(RSdF#OcG>Ao%R0?GS5Fd>zjJYVh>OAD0B#H2#k-<^QgP?>(gv^&w|Y9xjKo z6syGxly8(wjt>z2NI}^jEJpnIi}N1Oi^Gfyr0ycxqKs}ONxR3r?1X%f;>{LhOp$or38mCWlXhe zl+yV1>o+LJk7kTb%l+~1PNyA)&3~wzksr~-R|bF&n8@e?>ofc@OZ6q?l`Ldv*XglP zZ2^-Np(%H>=FAcO2!Ylxe|mcQRI(?_gzt67Md~u&j*VHh==q%AzwMEh5HXDMt}=AN zHu_>g<7$)U zOY(ZusRko5EcVjt^h9FRheAC2NVgTISLV)f9!?|1+siW|F0dJ+VI$)p>QKY#pz}B| zFbLz%ufI%xb}DUK&#G=Cc*6=BS&dwYT8xfiSx!yd8mWmFURU}yZAR=|`|rYhB^@-? z*Q9w;HQ$#~mweV5HpdibTQJUa^~2bW<@S`;Ex$U*c<5u*I`@qhsboHL(DTvxsi+9s zYU7suNpkg98|EdZ2{%2^j{2K5!|%rXb6*Lb9+D*<0&iB=g)|y?5}s>N9!uC!i161w zu;)J+hngCp-f(wngJ&(JyYJlsBYwg{$yX3h?Z6n%T3laz05J_27f=O+_}pNB+SlYh znzeMHvmWM~CfpldUXEss=8g0$u=s!kc-)g7*605D*W0P=XbL;k%O4y|m8sW}Q9NCC zOnUe?L^FL+q zqcNzso0bbmccr?n@80w@uU#uWPsNBwE4^Fxf|Q{~g_YGPb35_yO1LW!Zogk;CY-JE z=IP0I&Okm5hn6%ABhyhTaxN#r3Zwu96bnrs?jW+*0LS%xe-a>HB6%YEgi{t${Nc8~ z-)1`hF3}amM%udzDw zX{#?I43Lms>JglQO`>fLa{;HFD5wW_Ag$(ORA2LJCls78@ST$f_tT(HxaxYZSU^ue z;*_e!L<1}K_?r6MHa+>1n%>`CT$4y_p};B{BSG2GUFnENKaYpY-C(*L0VT^Ghme;azDq zQ>7az7~FeAtWFQO$P1j~R|5puajbESjYzJ#bzBxjA)v)qo-d;yo`VP7`3$b110zmF&N@2?|4 zN$QH*MBesG5cLtJ0Wr@pXa3^l>T5>43mQUXrp;@+fJK_cCb>jSh)s*2vxU41|K%`r ziXY&`xM@l7;5yssVzblFD8pTis?@nzOR{V0^vE;>5s1x=Fry-C_Z*4wC&9vF{*@l(SyfuqQXD(N@GX z+@O_b#3M4{Bi!uj9v~U>Alwx6iA5K>8E_ZTglpa%>`MX4&O6`5c2?6 z;!#^IP^v_#MDlC*&L5dEQYq?nA1g7JfZ;m_`$k>Y8+?wSquz~Qnf>i8W(FAdIW z%OsJ3Gn>byH3WmJsiD~WSMfEez5tY|B2TMh^-qGm&MtT(yR}_9zJJI@lecVU`tki* zD)Xei+$r(G5&O2IZWBMh7+I6So)x#D21;=ZuXslg0JL{s5!XFyCVg(EIf;A~MzTpA z1^Sp=zWd@`UOmJ9_TK6|_3=<$!k)sXCyU|A@jg#0Nw48UuX=|1Y$*5rzIWPl86s_Z z-Fory;_`Kxfi-pL28_#%@ij@+t&aAX(qWeuE0_Erlp5}d?)T)>c~x&{2R)K6rKd(p zXdEvE)sa#J@LIDOz?j9~j9;RZtSuklKjyCF039G**0*u5Su9hhqhr|Fc zE5|9rpWo>?pOVzLvy zw{Pz2%AVC7P}rMb(%OGrTr#v!BTG6kuEv~mNCI7#biRf+E`w`_JCwP*&E~UBJQwJxOZ{v-&9k z-O5Fp2~C#nHfio-_3k3xUOM&(snEc1ji5#@B$^Y#YvmztwKV%rDwB@gt?ks5ipr@= zmYWFPli?dF*)p-Y>smF0vMimha4@>y{X6D{s)^k}EX=~oiGI&|G@}?yfHCLKE z2xHDDip+EvkIZyuPhBaDn7_;PuJO##yV0kmm#mCnZ>NHi6cIr1_`IUPivOvuUB%m* zuis3DA5>+V;yPW=PY1=TG9=COUU*eZ6urM9CvereMbDKVlF~TpUex2z5o_1|kdmaJ zEoMt0B5;RW@rm`5_jlUPL}ljIuxI(Sjk;WPeeAobnaq&$l1`A6Lc6lK=n! literal 0 HcmV?d00001 diff --git a/doc/img/result.png b/doc/img/result.png new file mode 100644 index 0000000000000000000000000000000000000000..f933cf9c5e177bd4a92812ebc0ad30cd249e6b63 GIT binary patch literal 22149 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 MsxqbbAHDd00P}X!J^%m! literal 0 HcmV?d00001 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 a848cfc595e910e8f74788c7b7a2374f4605fa53..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 243 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`yF6VSLn`JZB_3d$7qR@NZ9C*dW*v;a$C^li*9+u`~X$HA5 z4ZezIhCJdW=kGCXt6aEV*@uJmNd7(kW!}NH5{5Ahmwk8~!nz-^PB^)8SAIea55pwq zZL)_MYU+-vwuf#!*S$VsZ?S8Ww6pECGha^Ml=r#6i|eFA)~y|r&4u|27csU?{`dbs m|MuRA6DJBzmtcm11cv8RoE|-3KeiF*3kFYDKbLh*2~7b0t6XFN diff --git a/resources/sprites/player.png b/resources/sprites/player.png new file mode 100644 index 0000000000000000000000000000000000000000..b69ce5a577101ec2f1469655d11a5ee0b5ff9b34 GIT binary patch literal 591 zcmV-V0Q@f6H@|-#``ADH2N;O~2X z*xJ^f{5<&}?JPGBHgAWIdZ*+D+;u>c_cxaP{Ndyt0Kt z8gU(fDy?k}2TuaB|9*W@|N7UlIU~lnKFdAgYM~HCmR0uO06^waY|e-=uKU`fz7N#* z0o(fk^?JLXtYD~Acl5;0Hx!>wbTH^+NdC15H=oYKqxc{CI7IMI2bT_Y5JX}@!GCp7YAAwh7l)#P zE&-Pk6bUMbph6ZA9v&!xBa7f91SPrqCU=+j;yce={`vV0{k->n={tV>(%(_z%(*)V z!tDMTY70|{i9~{{7FEkirPExEM7}{EU~)^xsK{?TqdHu_aFUAiSMF2s@WBRELMD4k zb%lzX_Z|ZpHiAAbIa{e+^$#QmIy`WqU zHk+}l02Y7U;zoc|&8U=aQzbMjJ|qQ=Aat5^cnaFN`$G1nszaqE{@w(;IPoe3cq0 zA= 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) +}