M1: rörelse, kollision, kamera, scener, tileset, skärmdumpar, CI
Some checks failed
release / build-release (push) Failing after 27s
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:
38
internal/sim/camera.go
Normal file
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
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
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
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
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user