M0: skelett, byggkedja och dokumentation för JP Subway Pussel

- Go 1.27 + Ebitengine v2.10, intern upplösning 320x180 med heltalsskalning
- internal/res (resursuppslag), internal/rng (seedad slump), internal/level
  (banformat, validering, BFS-lösbarhet) med tester; internal/archtest
  kontrollerar att bara presentationslagret beror på Ebitengine
- tools/build: debug -> build/debug/, release -> build/release/<os>-<arch>/
  med resources/ bredvid binären; Windows korsbyggs utan C-verktyg
- VS Code tasks (build/test/release) och launch (dlv)
- Första banan 001-flygplatsen, sv/en-texter, skyltordbok, placeholder-sprite
- doc/: spelbeskrivning (renskriven), mål och tester, roadmap med teknikval
  (M0–M12), plan.md; README med bygg- och beroendeinformation

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 18:52:10 +02:00
commit c1f9546af6
32 changed files with 2165 additions and 0 deletions

135
internal/game/game.go Normal file
View File

@@ -0,0 +1,135 @@
// 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
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/jp-subway-pussel/internal/buildinfo"
"gitea.brasse-pc.eu/brasse/jp-subway-pussel/internal/level"
"gitea.brasse-pc.eu/brasse/jp-subway-pussel/internal/res"
)
// Intern upplösning. Allt ritas i denna storlek och skalas sedan upp med
// heltalsfaktor av Ebitengine så pixlarna förblir skarpa.
const (
ScreenW = 320
ScreenH = 180
TileSize = 8
)
// 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
}
// Options styr uppstarten.
type Options struct {
LevelID string // bana att visa, t.ex. "001-flygplatsen"
SmokeFrames int // >0 avslutar efter N uppdateringar
}
// New laddar resurser och bygger spelet.
func New(opt Options) (*Game, error) {
dir, err := res.Dir()
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.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
}
return g, nil
}
// 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
}
// Draw ritar aktuell bild.
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(
"JP Subway Pussel %s (%s) %.0f fps\nBana: %s\nResurser: %s\nESC avslutar",
buildinfo.Version, buildinfo.Mode, ebiten.ActualFPS(), name, g.resDir))
}
// 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
}