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

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

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

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

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