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

83
internal/sim/mover.go Normal file
View 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
}