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

97
internal/game/assets.go Normal file
View File

@@ -0,0 +1,97 @@
package game
import (
"fmt"
"image"
"github.com/hajimehoshi/ebiten/v2"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
)
// Ordningen på rutorna i resources/sprites/tiles.png (måste matcha tools/genart).
var tileOrder = []level.Tile{
level.Wall, level.Floor, level.Spawn, level.Platform, level.Gate, level.TicketMachine, level.Sign,
}
// Assets är allt som laddats från resources/.
type Assets struct {
ResDir string
LevelsDir string
Tiles map[level.Tile]*ebiten.Image
Player [4][3]*ebiten.Image // [riktning: ner, upp, vänster, höger][bildruta: stilla, gå1, gå2]
Text map[string]string
}
// LoadAssets läser grafik och texter.
func LoadAssets() (*Assets, error) {
dir, err := res.Dir()
if err != nil {
return nil, err
}
a := &Assets{ResDir: dir, Tiles: map[level.Tile]*ebiten.Image{}}
a.LevelsDir, _ = res.Path("data", "levels")
tiles, err := loadImage("sprites", "tiles.png")
if err != nil {
return nil, err
}
if w := tiles.Bounds().Dx(); w < TileSize*len(tileOrder) {
return nil, fmt.Errorf("tiles.png är %d px bred, behöver minst %d", w, TileSize*len(tileOrder))
}
for i, t := range tileOrder {
a.Tiles[t] = tiles.SubImage(image.Rect(i*TileSize, 0, (i+1)*TileSize, TileSize)).(*ebiten.Image)
}
a.Tiles[level.WrongPlatform] = a.Tiles[level.Platform] // samma utseende; tåget avgör
player, err := loadImage("sprites", "player.png")
if err != nil {
return nil, err
}
for row := 0; row < 4; row++ {
for col := 0; col < 3; col++ {
a.Player[row][col] = player.SubImage(image.Rect(col*TileSize, row*TileSize, (col+1)*TileSize, (row+1)*TileSize)).(*ebiten.Image)
}
}
if err := res.LoadJSON(&a.Text, "text", "sv.json"); err != nil {
return nil, err
}
return a, nil
}
// T slår upp en UI-text; saknas nyckeln visas nyckeln själv så att det syns.
func (a *Assets) T(key string) string {
if s, ok := a.Text[key]; ok {
return s
}
return key
}
// PlayerFrame returnerar rätt bildruta för riktning och gångfas.
func (a *Assets) PlayerFrame(facing sim.Dir, moving bool, animT float64) *ebiten.Image {
row := 0
switch facing {
case sim.Up:
row = 1
case sim.Left:
row = 2
case sim.Right:
row = 3
}
col := 0
if moving {
col = 1 + int(animT/0.12)%2
}
return a.Player[row][col]
}
func loadImage(parts ...string) (*ebiten.Image, error) {
img, err := res.LoadImage(parts...)
if err != nil {
return nil, err
}
return ebiten.NewImageFromImage(img), nil
}

View File

@@ -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 }

99
internal/game/input.go Normal file
View File

@@ -0,0 +1,99 @@
package game
import (
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/inpututil"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
)
const stickDeadzone = 0.5
// dirInput returnerar hållen riktning från tangentbord (pilar/WASD) eller
// gamepad (d-pad / vänster spak). None om inget hålls.
func dirInput() sim.Dir {
if virtual.held != sim.None {
return virtual.held
}
switch {
case ebiten.IsKeyPressed(ebiten.KeyArrowUp) || ebiten.IsKeyPressed(ebiten.KeyW):
return sim.Up
case ebiten.IsKeyPressed(ebiten.KeyArrowDown) || ebiten.IsKeyPressed(ebiten.KeyS):
return sim.Down
case ebiten.IsKeyPressed(ebiten.KeyArrowLeft) || ebiten.IsKeyPressed(ebiten.KeyA):
return sim.Left
case ebiten.IsKeyPressed(ebiten.KeyArrowRight) || ebiten.IsKeyPressed(ebiten.KeyD):
return sim.Right
}
for _, id := range ebiten.AppendGamepadIDs(nil) {
if !ebiten.IsStandardGamepadLayoutAvailable(id) {
continue
}
switch {
case ebiten.IsStandardGamepadButtonPressed(id, ebiten.StandardGamepadButtonLeftTop):
return sim.Up
case ebiten.IsStandardGamepadButtonPressed(id, ebiten.StandardGamepadButtonLeftBottom):
return sim.Down
case ebiten.IsStandardGamepadButtonPressed(id, ebiten.StandardGamepadButtonLeftLeft):
return sim.Left
case ebiten.IsStandardGamepadButtonPressed(id, ebiten.StandardGamepadButtonLeftRight):
return sim.Right
}
x := ebiten.StandardGamepadAxisValue(id, ebiten.StandardGamepadAxisLeftStickHorizontal)
y := ebiten.StandardGamepadAxisValue(id, ebiten.StandardGamepadAxisLeftStickVertical)
switch {
case y < -stickDeadzone:
return sim.Up
case y > stickDeadzone:
return sim.Down
case x < -stickDeadzone:
return sim.Left
case x > stickDeadzone:
return sim.Right
}
}
return sim.None
}
// confirmJustPressed: Enter/mellanslag eller gamepadens nedre knapp (A/kryss).
func confirmJustPressed() bool {
if virtual.pressed["enter"] {
return true
}
if inpututil.IsKeyJustPressed(ebiten.KeyEnter) || inpututil.IsKeyJustPressed(ebiten.KeySpace) {
return true
}
return gamepadJustPressed(ebiten.StandardGamepadButtonRightBottom)
}
// backJustPressed: Escape eller gamepadens högra knapp (B/cirkel).
func backJustPressed() bool {
return virtual.pressed["esc"] || inpututil.IsKeyJustPressed(ebiten.KeyEscape) || gamepadJustPressed(ebiten.StandardGamepadButtonRightRight)
}
// menuMove returnerar -1/+1 när användaren just tryckte upp/ner i en meny.
func menuMove() int {
switch {
case virtual.pressed["up"]:
return -1
case virtual.pressed["down"]:
return 1
case inpututil.IsKeyJustPressed(ebiten.KeyArrowUp) || inpututil.IsKeyJustPressed(ebiten.KeyW) || gamepadJustPressed(ebiten.StandardGamepadButtonLeftTop):
return -1
case inpututil.IsKeyJustPressed(ebiten.KeyArrowDown) || inpututil.IsKeyJustPressed(ebiten.KeyS) || gamepadJustPressed(ebiten.StandardGamepadButtonLeftBottom):
return 1
}
return 0
}
func gamepadJustPressed(b ebiten.StandardGamepadButton) bool {
for _, id := range ebiten.AppendGamepadIDs(nil) {
if ebiten.IsStandardGamepadLayoutAvailable(id) && inpututil.IsStandardGamepadButtonJustPressed(id, b) {
return true
}
}
return false
}
// inpututil_R: R = spela om banan.
func inpututil_R() bool { return virtual.pressed["r"] || inpututil.IsKeyJustPressed(ebiten.KeyR) }

View File

@@ -0,0 +1,110 @@
package game
import (
"fmt"
"image/color"
"math"
"path/filepath"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
"github.com/hajimehoshi/ebiten/v2/vector"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
)
// Spelarens grundfart i rutor per sekund (blir en stat i M7).
const playerSpeed = 6.0
// LevelScene är själva spelandet av en bana.
type LevelScene struct {
id string
lvl *level.Level
player *sim.Mover
cam *sim.Camera
animT float64
elapsed float64
steps int
}
// NewLevelScene laddar banan och ställer spelaren på startrutan.
func NewLevelScene(a *Assets, id string) (*LevelScene, error) {
lvl, err := level.LoadFile(filepath.Join(a.LevelsDir, id+".json"))
if err != nil {
return nil, err
}
s := &LevelScene{
id: id,
lvl: lvl,
player: sim.NewMover(lvl.SpawnPoint(), playerSpeed),
cam: sim.NewCamera(ScreenW, ScreenH, float64(lvl.Width()*TileSize), float64(lvl.Height()*TileSize)),
}
s.followPlayer()
return s, nil
}
// PlayerTile returnerar rutan spelaren står på/lämnar (används av skriptläget).
func (s *LevelScene) PlayerTile() level.Point { return s.player.Tile }
func (s *LevelScene) followPlayer() {
x, y := s.player.WorldPos(TileSize)
s.cam.Follow(x+TileSize/2, y+TileSize/2)
}
func (s *LevelScene) Update(g *Game) error {
if backJustPressed() {
m, err := NewMenuScene(g.Assets)
if err != nil {
return err
}
g.SetScene(m)
return nil
}
s.elapsed += dt
s.steps += s.player.Step(dt, dirInput(), s.lvl.CanEnter)
if s.player.Moving() {
s.animT += dt
} else {
s.animT = 0
// Står vi på en perrong är banan slut (M2 ger tåg, tidtabell och spärrar).
if t := s.lvl.At(s.player.Tile.X, s.player.Tile.Y); t == level.Platform || t == level.WrongPlatform {
g.SetScene(NewResultScene(s, s.lvl.TrainAt(t)))
}
}
s.followPlayer()
return nil
}
func (s *LevelScene) Draw(g *Game, screen *ebiten.Image) {
screen.Fill(color.RGBA{24, 20, 37, 255})
ox, oy := s.cam.Offset()
ox, oy = math.Round(ox), math.Round(oy)
// Rita bara rutor som syns.
x0 := max(0, int(s.cam.X)/TileSize)
y0 := max(0, int(s.cam.Y)/TileSize)
x1 := min(s.lvl.Width(), int(s.cam.X+ScreenW)/TileSize+2)
y1 := min(s.lvl.Height(), int(s.cam.Y+ScreenH)/TileSize+2)
var op ebiten.DrawImageOptions
for y := y0; y < y1; y++ {
for x := x0; x < x1; x++ {
op.GeoM.Reset()
op.GeoM.Translate(ox+float64(x*TileSize), oy+float64(y*TileSize))
screen.DrawImage(g.Assets.Tiles[s.lvl.At(x, y)], &op)
}
}
px, py := s.player.WorldPos(TileSize)
op.GeoM.Reset()
op.GeoM.Translate(ox+math.Round(px), oy+math.Round(py))
screen.DrawImage(g.Assets.PlayerFrame(s.player.Facing, s.player.Moving(), s.animT), &op)
// HUD
vector.DrawFilledRect(screen, 0, 0, ScreenW, 24, color.RGBA{24, 20, 37, 200}, false)
// TODO(M3): visa japanska när en font med kana/kanji finns – debugfonten saknar dem.
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s %s: %s", s.lvl.Name.Get("sv"), g.Assets.T("hud.destination"),
s.lvl.Destination.Get("romaji")), 4, 1)
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s %3.0fs %s %d %s", g.Assets.T("hud.time"), s.elapsed,
g.Assets.T("hud.steps"), s.steps, g.Assets.T("hud.controls")), 4, 12)
}

View File

@@ -0,0 +1,83 @@
package game
import (
"fmt"
"image/color"
"path/filepath"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
"github.com/hajimehoshi/ebiten/v2/vector"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/buildinfo"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
)
type menuEntry struct {
id string
name string
dest string
}
// MenuScene listar banorna och låter spelaren välja.
type MenuScene struct {
entries []menuEntry
sel int
err error
}
// NewMenuScene läser in banlistan.
func NewMenuScene(a *Assets) (*MenuScene, error) {
ids, err := level.List(a.LevelsDir)
if err != nil {
return nil, err
}
m := &MenuScene{}
for _, id := range ids {
l, err := level.LoadFile(filepath.Join(a.LevelsDir, id+".json"))
if err != nil {
m.err = err
continue
}
m.entries = append(m.entries, menuEntry{id: id, name: l.Name.Get("sv"), dest: l.Destination.Get("romaji")}) // TODO(M3): japanska när fonten finns
}
return m, nil
}
func (m *MenuScene) Update(g *Game) error {
if backJustPressed() {
return ebiten.Termination
}
if n := len(m.entries); n > 0 {
m.sel = (m.sel + menuMove() + n) % n
if confirmJustPressed() {
s, err := NewLevelScene(g.Assets, m.entries[m.sel].id)
if err != nil {
m.err = err
return nil
}
g.SetScene(s)
}
}
return nil
}
func (m *MenuScene) Draw(g *Game, screen *ebiten.Image) {
screen.Fill(color.RGBA{24, 20, 37, 255})
vector.DrawFilledRect(screen, 0, 0, ScreenW, 22, color.RGBA{38, 43, 68, 255}, false)
ebitenutil.DebugPrintAt(screen, g.Assets.T("game.title"), 8, 4)
ebitenutil.DebugPrintAt(screen, g.Assets.T("menu.choose_level"), 8, 30)
for i, e := range m.entries {
y := 46 + i*14
if i == m.sel {
vector.DrawFilledRect(screen, 6, float32(y-2), ScreenW-12, 13, color.RGBA{58, 68, 102, 255}, false)
}
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s -> %s", e.name, e.dest), 12, y)
}
if m.err != nil {
ebitenutil.DebugPrintAt(screen, "FEL: "+m.err.Error(), 8, ScreenH-40)
}
ebitenutil.DebugPrintAt(screen, g.Assets.T("menu.hint"), 8, ScreenH-26)
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s (%s)", buildinfo.Version, buildinfo.Mode), 8, ScreenH-13)
}

View File

@@ -0,0 +1,65 @@
package game
import (
"fmt"
"image/color"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
"github.com/hajimehoshi/ebiten/v2/vector"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
)
// ResultScene visas när spelaren nått en perrong.
type ResultScene struct {
from *LevelScene
train *level.Train
}
// NewResultScene bygger resultatet för banan som just spelades.
func NewResultScene(from *LevelScene, train *level.Train) *ResultScene {
return &ResultScene{from: from, train: train}
}
func (r *ResultScene) correct() bool { return r.train != nil && r.train.Correct }
func (r *ResultScene) Update(g *Game) error {
switch {
case inpututil_R():
s, err := NewLevelScene(g.Assets, r.from.id)
if err != nil {
return err
}
g.SetScene(s)
case confirmJustPressed() || backJustPressed():
m, err := NewMenuScene(g.Assets)
if err != nil {
return err
}
g.SetScene(m)
}
return nil
}
func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) {
r.from.Draw(g, screen) // banan i bakgrunden
bg := color.RGBA{24, 20, 37, 230}
vector.DrawFilledRect(screen, 20, 40, ScreenW-40, 100, bg, false)
accent := color.RGBA{228, 59, 68, 255}
headline := g.Assets.T("level.wrong_train")
if r.correct() {
accent = color.RGBA{99, 199, 77, 255}
headline = g.Assets.T("level.cleared")
}
vector.DrawFilledRect(screen, 20, 40, ScreenW-40, 3, accent, false)
ebitenutil.DebugPrintAt(screen, headline, 28, 50)
if r.train != nil {
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s -> %s", r.train.Line.Get("romaji"),
r.train.Destination.Get("romaji")), 28, 66) // TODO(M3): japanska när fonten finns
}
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s %.1fs %s %d", g.Assets.T("hud.time"), r.from.elapsed,
g.Assets.T("hud.steps"), r.from.steps), 28, 86)
ebitenutil.DebugPrintAt(screen, g.Assets.T("result.hint"), 28, 118)
}

189
internal/game/script.go Normal file
View File

@@ -0,0 +1,189 @@
package game
import (
"fmt"
"image"
"image/png"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/hajimehoshi/ebiten/v2"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
)
// Skriptläge (-script) för automatiserade skärmdumpar och smoke-tester.
// Stegen skiljs med ';':
//
// wait N vänta N bildrutor
// press KEY tryck enter | esc | r | up | down en bildruta
// move DIR N gå N rutor åt up | down | left | right (släpper när rutorna är avklarade)
// shot PATH spara aktuell bild som PNG (intern upplösning × shotScale)
// quit avsluta spelet
//
// Exempel: -level 001-flygplatsen -script "move down 2; move right 2; shot doc/img/level.png; quit"
const shotScale = 3
type scriptStep struct {
cmd string
arg string
n int
dir sim.Dir
done bool
}
type script struct {
steps []scriptStep
i int
counter int
lastPos level.Point
havePos bool
}
// virtual är den simulerade inputen som input.go läser före riktig input.
var virtual struct {
held sim.Dir
pressed map[string]bool
}
func parseScript(src string) (*script, error) {
s := &script{}
for _, raw := range strings.Split(src, ";") {
f := strings.Fields(raw)
if len(f) == 0 {
continue
}
st := scriptStep{cmd: f[0]}
switch f[0] {
case "wait":
if len(f) != 2 {
return nil, fmt.Errorf("script: 'wait N'")
}
st.n, _ = strconv.Atoi(f[1])
case "press":
if len(f) != 2 {
return nil, fmt.Errorf("script: 'press KEY'")
}
st.arg = f[1]
case "move":
if len(f) != 3 {
return nil, fmt.Errorf("script: 'move DIR N'")
}
st.dir = parseDir(f[1])
if st.dir == sim.None {
return nil, fmt.Errorf("script: okänd riktning %q", f[1])
}
st.n, _ = strconv.Atoi(f[2])
case "shot":
if len(f) != 2 {
return nil, fmt.Errorf("script: 'shot PATH'")
}
st.arg = f[1]
case "quit":
default:
return nil, fmt.Errorf("script: okänt kommando %q", f[0])
}
s.steps = append(s.steps, st)
}
return s, nil
}
func parseDir(s string) sim.Dir {
switch s {
case "up":
return sim.Up
case "down":
return sim.Down
case "left":
return sim.Left
case "right":
return sim.Right
}
return sim.None
}
// playerTiler implementeras av scener som har en spelare i rutnätet.
type playerTiler interface{ PlayerTile() level.Point }
// tick körs i början av varje Update. frame är senast ritade bild.
// Returnerar ebiten.Termination när skriptet vill avsluta.
func (s *script) tick(g *Game, frame *ebiten.Image) error {
virtual.held = sim.None
virtual.pressed = map[string]bool{}
if s.i >= len(s.steps) {
return nil
}
st := &s.steps[s.i]
switch st.cmd {
case "wait":
s.counter++
if s.counter >= st.n {
s.advance()
}
case "press":
virtual.pressed[st.arg] = true
s.advance()
case "move":
pt, ok := g.scene.(playerTiler)
if !ok {
s.advance() // ingen spelare att flytta i den här scenen
return nil
}
cur := pt.PlayerTile()
if !s.havePos {
s.lastPos, s.havePos = cur, true
}
if cur != s.lastPos {
s.lastPos = cur
s.counter++
}
if s.counter >= st.n {
s.advance()
return nil
}
virtual.held = st.dir
case "shot":
if err := saveShot(frame, st.arg); err != nil {
return err
}
s.advance()
case "quit":
return ebiten.Termination
}
return nil
}
func (s *script) advance() {
s.i++
s.counter = 0
s.havePos = false
}
func saveShot(frame *ebiten.Image, path string) error {
w, h := frame.Bounds().Dx(), frame.Bounds().Dy()
buf := make([]byte, 4*w*h)
frame.ReadPixels(buf)
out := image.NewNRGBA(image.Rect(0, 0, w*shotScale, h*shotScale))
for y := 0; y < h*shotScale; y++ {
for x := 0; x < w*shotScale; x++ {
src := 4 * ((y/shotScale)*w + x/shotScale)
dst := out.PixOffset(x, y)
copy(out.Pix[dst:dst+4], buf[src:src+4])
out.Pix[dst+3] = 255
}
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
fmt.Println("skärmdump:", path)
return png.Encode(f, out)
}

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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)
}
}

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

View 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
View 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
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
}

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)
}
}
}