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:
58
internal/archtest/arch_test.go
Normal file
58
internal/archtest/arch_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Package archtest innehåller arkitekturtester: regler om vilka paket som
|
||||
// får bero på vad. Körs som vanliga tester med `go test ./...`.
|
||||
package archtest
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Paket (relativt modulroten) som får importera Ebitengine. All annan kod
|
||||
// under internal/ ska gå att testa utan fönster.
|
||||
var mayUseEbitengine = map[string]bool{
|
||||
"internal/game": true,
|
||||
"internal/ui": true,
|
||||
"internal/audio": true,
|
||||
}
|
||||
|
||||
const ebitengine = "github.com/hajimehoshi/ebiten/"
|
||||
|
||||
func TestOnlyPresentationPackagesImportEbitengine(t *testing.T) {
|
||||
root := goList(t, "-m", "-f", "{{.Dir}}")
|
||||
module := goList(t, "-m", "-f", "{{.Path}}")
|
||||
|
||||
out := goListIn(t, root, "-f", "{{.ImportPath}}|{{join .Deps \",\"}}", "./...")
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
pkg, deps, ok := strings.Cut(line, "|")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rel := strings.TrimPrefix(strings.TrimPrefix(pkg, module), "/")
|
||||
if strings.HasPrefix(rel, "cmd/") || mayUseEbitengine[rel] {
|
||||
continue
|
||||
}
|
||||
for _, d := range strings.Split(deps, ",") {
|
||||
if strings.HasPrefix(d, ebitengine) {
|
||||
t.Errorf("%s får inte bero på Ebitengine (via %s) – flytta logiken till ett rent paket eller lägg till paketet i mayUseEbitengine", rel, d)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func goList(t *testing.T, args ...string) string {
|
||||
t.Helper()
|
||||
return goListIn(t, "", args...)
|
||||
}
|
||||
|
||||
func goListIn(t *testing.T, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("go", append([]string{"list"}, args...)...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("go list %v: %v", args, err)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
12
internal/buildinfo/buildinfo.go
Normal file
12
internal/buildinfo/buildinfo.go
Normal file
@@ -0,0 +1,12 @@
|
||||
// Package buildinfo innehåller värden som sätts av byggverktyget via -ldflags -X.
|
||||
// Lämnas de orörda (t.ex. vid `go run` eller debugging via dlv) gäller standardvärdena.
|
||||
package buildinfo
|
||||
|
||||
// Version är git-beskrivningen (t.ex. v0.1.0 eller v0.1.0-3-gabcdef-dirty) eller "dev".
|
||||
var Version = "dev"
|
||||
|
||||
// Mode är "debug" eller "release".
|
||||
var Mode = "debug"
|
||||
|
||||
// IsRelease rapporterar om binären byggdes i release-läge.
|
||||
func IsRelease() bool { return Mode == "release" }
|
||||
135
internal/game/game.go
Normal file
135
internal/game/game.go
Normal 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
|
||||
}
|
||||
228
internal/level/level.go
Normal file
228
internal/level/level.go
Normal file
@@ -0,0 +1,228 @@
|
||||
// Package level definierar banformatet och ren spellogik kring det
|
||||
// (validering, framkomlighet). Paketet importerar inte Ebitengine och
|
||||
// kan därför testas utan fönster.
|
||||
//
|
||||
// Banor lagras som JSON i resources/data/levels/. Rutnätet är en lista av
|
||||
// lika långa strängar där varje tecken är en ruta (se Tile-konstanterna).
|
||||
package level
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Tile är ett tecken i rutnätet.
|
||||
type Tile byte
|
||||
|
||||
// Tecknen som får förekomma i ett rutnät.
|
||||
const (
|
||||
Wall Tile = '#' // vägg, ej passerbar
|
||||
Floor Tile = '.' // golv
|
||||
Spawn Tile = 'S' // startpunkt (exakt en)
|
||||
Platform Tile = 'P' // perrong med ett tåg (målet om tåget är rätt)
|
||||
WrongPlatform Tile = 'p' // perrong med fel tåg
|
||||
Gate Tile = 'G' // spärr (kräver kort/biljett)
|
||||
TicketMachine Tile = 'T' // biljett-/påfyllningsautomat
|
||||
Sign Tile = 'i' // skylt
|
||||
)
|
||||
|
||||
var knownTiles = map[Tile]bool{
|
||||
Wall: true, Floor: true, Spawn: true, Platform: true, WrongPlatform: true,
|
||||
Gate: true, TicketMachine: true, Sign: true,
|
||||
}
|
||||
|
||||
// Passable rapporterar om en ruta går att gå på (utan hänsyn till spärrar).
|
||||
func (t Tile) Passable() bool { return t != Wall }
|
||||
|
||||
// Text är en flerspråkig sträng, t.ex. {"sv": "Flygplatsen", "en": "The Airport", "ja": "空港"}.
|
||||
type Text map[string]string
|
||||
|
||||
// Get hämtar texten på ett språk med fallback till engelska, sedan vilket som helst.
|
||||
func (t Text) Get(lang string) string {
|
||||
if s, ok := t[lang]; ok {
|
||||
return s
|
||||
}
|
||||
if s, ok := t["en"]; ok {
|
||||
return s
|
||||
}
|
||||
for _, s := range t {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Train är ett tåg (eller en buss) som avgår från en perrong i banan.
|
||||
type Train struct {
|
||||
Platform string `json:"platform"` // vilket perrong-tecken (P/p) tåget står vid
|
||||
Line Text `json:"line"` // linjens namn
|
||||
Destination Text `json:"destination"` // slutstation
|
||||
Correct bool `json:"correct"` // tar spelaren till rätt resmål
|
||||
}
|
||||
|
||||
// Level är en bana.
|
||||
type Level struct {
|
||||
ID string `json:"id"`
|
||||
Name Text `json:"name"`
|
||||
Difficulty int `json:"difficulty"` // 1 (lätt) … 5 (svår)
|
||||
Destination Text `json:"destination"` // dit spelaren ska
|
||||
TimeLimitSec int `json:"timeLimitSec"` // 0 = ingen tidsgräns
|
||||
Grid []string `json:"grid"`
|
||||
Trains []Train `json:"trains"`
|
||||
}
|
||||
|
||||
// Point är en rutkoordinat.
|
||||
type Point struct{ X, Y int }
|
||||
|
||||
// Parse avkodar och validerar en bana.
|
||||
func Parse(data []byte) (*Level, error) {
|
||||
var l Level
|
||||
if err := json.Unmarshal(data, &l); err != nil {
|
||||
return nil, fmt.Errorf("level: ogiltig JSON: %w", err)
|
||||
}
|
||||
if err := l.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
// Width och Height i rutor.
|
||||
func (l *Level) Width() int {
|
||||
if len(l.Grid) == 0 {
|
||||
return 0
|
||||
}
|
||||
return len(l.Grid[0])
|
||||
}
|
||||
func (l *Level) Height() int { return len(l.Grid) }
|
||||
|
||||
// At returnerar rutan på (x, y); utanför kartan räknas som vägg.
|
||||
func (l *Level) At(x, y int) Tile {
|
||||
if y < 0 || y >= len(l.Grid) || x < 0 || x >= len(l.Grid[y]) {
|
||||
return Wall
|
||||
}
|
||||
return Tile(l.Grid[y][x])
|
||||
}
|
||||
|
||||
// Find returnerar alla rutor av en viss typ.
|
||||
func (l *Level) Find(t Tile) []Point {
|
||||
var pts []Point
|
||||
for y, row := range l.Grid {
|
||||
for x := 0; x < len(row); x++ {
|
||||
if Tile(row[x]) == t {
|
||||
pts = append(pts, Point{x, y})
|
||||
}
|
||||
}
|
||||
}
|
||||
return pts
|
||||
}
|
||||
|
||||
// SpawnPoint returnerar startrutan (valideringen garanterar att den finns).
|
||||
func (l *Level) SpawnPoint() Point { return l.Find(Spawn)[0] }
|
||||
|
||||
// Validate kontrollerar att banan är rimlig: rektangulär, bara kända tecken,
|
||||
// exakt en start, minst en perrong, och att varje tåg står vid ett
|
||||
// perrong-tecken som finns i rutnätet.
|
||||
func (l *Level) Validate() error {
|
||||
if l.ID == "" {
|
||||
return fmt.Errorf("level: saknar id")
|
||||
}
|
||||
if len(l.Grid) == 0 {
|
||||
return fmt.Errorf("level %s: tomt rutnät", l.ID)
|
||||
}
|
||||
w := len(l.Grid[0])
|
||||
spawns, platforms := 0, 0
|
||||
for y, row := range l.Grid {
|
||||
if len(row) != w {
|
||||
return fmt.Errorf("level %s: rad %d har längd %d, förväntade %d", l.ID, y, len(row), w)
|
||||
}
|
||||
for x := 0; x < len(row); x++ {
|
||||
t := Tile(row[x])
|
||||
if !knownTiles[t] {
|
||||
return fmt.Errorf("level %s: okänt tecken %q på (%d,%d)", l.ID, row[x], x, y)
|
||||
}
|
||||
switch t {
|
||||
case Spawn:
|
||||
spawns++
|
||||
case Platform, WrongPlatform:
|
||||
platforms++
|
||||
}
|
||||
}
|
||||
}
|
||||
if spawns != 1 {
|
||||
return fmt.Errorf("level %s: förväntade exakt en start (S), hittade %d", l.ID, spawns)
|
||||
}
|
||||
if platforms == 0 {
|
||||
return fmt.Errorf("level %s: saknar perronger (P/p)", l.ID)
|
||||
}
|
||||
if len(l.Trains) == 0 {
|
||||
return fmt.Errorf("level %s: saknar tåg", l.ID)
|
||||
}
|
||||
correct := 0
|
||||
for i, tr := range l.Trains {
|
||||
if len(tr.Platform) != 1 || (Tile(tr.Platform[0]) != Platform && Tile(tr.Platform[0]) != WrongPlatform) {
|
||||
return fmt.Errorf("level %s: tåg %d har ogiltig perrong %q", l.ID, i, tr.Platform)
|
||||
}
|
||||
if !strings.Contains(strings.Join(l.Grid, ""), tr.Platform) {
|
||||
return fmt.Errorf("level %s: tåg %d står vid perrong %q som inte finns i rutnätet", l.ID, i, tr.Platform)
|
||||
}
|
||||
if tr.Correct {
|
||||
correct++
|
||||
}
|
||||
}
|
||||
if correct == 0 {
|
||||
return fmt.Errorf("level %s: inget tåg är markerat som rätt", l.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reachable returnerar alla rutor som nås från start via passerbara rutor
|
||||
// (4-riktningars BFS). Spärrar räknas som passerbara.
|
||||
func (l *Level) Reachable() map[Point]bool {
|
||||
start := l.SpawnPoint()
|
||||
seen := map[Point]bool{start: true}
|
||||
queue := []Point{start}
|
||||
for len(queue) > 0 {
|
||||
p := queue[0]
|
||||
queue = queue[1:]
|
||||
for _, d := range [...]Point{{1, 0}, {-1, 0}, {0, 1}, {0, -1}} {
|
||||
n := Point{p.X + d.X, p.Y + d.Y}
|
||||
if seen[n] || !l.At(n.X, n.Y).Passable() {
|
||||
continue
|
||||
}
|
||||
seen[n] = true
|
||||
queue = append(queue, n)
|
||||
}
|
||||
}
|
||||
return seen
|
||||
}
|
||||
|
||||
// Solvable rapporterar om minst en perrong med ett rätt tåg kan nås från start.
|
||||
func (l *Level) Solvable() bool {
|
||||
reach := l.Reachable()
|
||||
for _, tr := range l.Trains {
|
||||
if !tr.Correct {
|
||||
continue
|
||||
}
|
||||
for _, p := range l.Find(Tile(tr.Platform[0])) {
|
||||
if reach[p] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Unreachable listar interaktiva rutor (perronger, automater, skyltar) som
|
||||
// inte går att nå – praktiskt som varning när man bygger banor.
|
||||
func (l *Level) Unreachable() []Point {
|
||||
reach := l.Reachable()
|
||||
var out []Point
|
||||
for _, t := range []Tile{Platform, WrongPlatform, TicketMachine, Sign, Gate} {
|
||||
for _, p := range l.Find(t) {
|
||||
if !reach[p] {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
137
internal/level/level_test.go
Normal file
137
internal/level/level_test.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package level
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.brasse-pc.eu/brasse/jp-subway-pussel/internal/res"
|
||||
)
|
||||
|
||||
const sample = `{
|
||||
"id": "test",
|
||||
"name": {"sv": "Test"},
|
||||
"difficulty": 1,
|
||||
"destination": {"sv": "Mål"},
|
||||
"grid": [
|
||||
"#######",
|
||||
"#S..#P#",
|
||||
"#.#.#.#",
|
||||
"#.#...#",
|
||||
"#######"
|
||||
],
|
||||
"trains": [{"platform": "P", "line": {"en": "A"}, "destination": {"en": "B"}, "correct": true}]
|
||||
}`
|
||||
|
||||
func mustParse(t *testing.T, s string) *Level {
|
||||
t.Helper()
|
||||
l, err := Parse([]byte(s))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func TestParseAndSolve(t *testing.T) {
|
||||
l := mustParse(t, sample)
|
||||
if l.Width() != 7 || l.Height() != 5 {
|
||||
t.Fatalf("storlek %dx%d", l.Width(), l.Height())
|
||||
}
|
||||
if l.SpawnPoint() != (Point{1, 1}) {
|
||||
t.Fatalf("spawn %v", l.SpawnPoint())
|
||||
}
|
||||
if !l.Solvable() {
|
||||
t.Fatal("banan ska vara lösbar")
|
||||
}
|
||||
if u := l.Unreachable(); len(u) != 0 {
|
||||
t.Fatalf("oväntat onåbara rutor: %v", u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsolvableWhenWalledOff(t *testing.T) {
|
||||
l := mustParse(t, strings.Replace(sample, `"#.#...#"`, `"#.#.#.#"`, 1))
|
||||
if l.Solvable() {
|
||||
t.Fatal("banan ska INTE vara lösbar när perrongen är inmurad")
|
||||
}
|
||||
if u := l.Unreachable(); len(u) != 1 || u[0] != (Point{5, 1}) {
|
||||
t.Fatalf("Unreachable = %v, vill ha [(5,1)]", u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutsideGridIsWall(t *testing.T) {
|
||||
l := mustParse(t, sample)
|
||||
if l.At(-1, 0).Passable() || l.At(0, 99).Passable() {
|
||||
t.Fatal("utanför kartan ska räknas som vägg")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationErrors(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"ojämn rad": strings.Replace(sample, `"#.#.#.#"`, `"#.#.#.##"`, 1),
|
||||
"okänt tecken": strings.Replace(sample, `"#.#.#.#"`, `"#.#.#X#"`, 1),
|
||||
"två starter": strings.Replace(sample, `"#S..#P#"`, `"#S.S#P#"`, 1),
|
||||
"ingen start": strings.Replace(sample, `"#S..#P#"`, `"#...#P#"`, 1),
|
||||
"ingen perrong": strings.Replace(sample, `"#S..#P#"`, `"#S..#.#"`, 1),
|
||||
"inget rätt tåg": strings.Replace(sample, `"correct": true`, `"correct": false`, 1),
|
||||
"tåg vid fel perrong": strings.Replace(sample, `"platform": "P"`, `"platform": "p"`, 1),
|
||||
"saknar id": strings.Replace(sample, `"id": "test"`, `"id": ""`, 1),
|
||||
"trasig json": "{",
|
||||
}
|
||||
for name, src := range cases {
|
||||
if _, err := Parse([]byte(src)); err == nil {
|
||||
t.Errorf("%s: förväntade fel", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextFallback(t *testing.T) {
|
||||
tx := Text{"en": "Exit", "ja": "出口"}
|
||||
if tx.Get("ja") != "出口" || tx.Get("sv") != "Exit" {
|
||||
t.Fatal("fel fallback")
|
||||
}
|
||||
if (Text{"ja": "x"}).Get("sv") != "x" {
|
||||
t.Fatal("fallback till godtyckligt språk saknas")
|
||||
}
|
||||
if (Text{}).Get("sv") != "" {
|
||||
t.Fatal("tom text ska ge tom sträng")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllShippedLevels är ett datadrivet test: varje bana under
|
||||
// resources/data/levels måste parsa, validera och vara lösbar.
|
||||
// Lägger man till en bana som inte går att klara faller bygget här.
|
||||
func TestAllShippedLevels(t *testing.T) {
|
||||
dir, err := res.Path("data", "levels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
files, err := filepath.Glob(filepath.Join(dir, "*.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
t.Fatalf("inga banor i %s", dir)
|
||||
}
|
||||
for _, f := range files {
|
||||
t.Run(filepath.Base(f), func(t *testing.T) {
|
||||
b, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l, err := Parse(b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := strings.TrimSuffix(filepath.Base(f), ".json"); l.ID != want {
|
||||
t.Errorf("id %q matchar inte filnamnet %q", l.ID, want)
|
||||
}
|
||||
if !l.Solvable() {
|
||||
t.Error("banan går inte att klara från start")
|
||||
}
|
||||
if u := l.Unreachable(); len(u) > 0 {
|
||||
t.Errorf("onåbara interaktiva rutor: %v", u)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
135
internal/res/res.go
Normal file
135
internal/res/res.go
Normal file
@@ -0,0 +1,135 @@
|
||||
// Package res hittar och läser resurser (sprites, ljud, text, speldata).
|
||||
//
|
||||
// Resursmappen ligger normalt bredvid binären (`<exe-mapp>/resources`). Vid
|
||||
// utveckling (`go run`, dlv) ligger binären i en temp-mapp, så vi faller
|
||||
// tillbaka på arbetskatalogen och letar uppåt efter repots rot (go.mod).
|
||||
// Miljövariabeln JP_RESOURCES vinner alltid.
|
||||
package res
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/png" // registrerar PNG-avkodaren för image.Decode
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// EnvVar är miljövariabeln som pekar ut resursmappen explicit.
|
||||
const EnvVar = "JP_RESOURCES"
|
||||
|
||||
// DirName är namnet på resursmappen bredvid binären / i repots rot.
|
||||
const DirName = "resources"
|
||||
|
||||
var (
|
||||
dirOnce sync.Once
|
||||
dirPath string
|
||||
dirErr error
|
||||
)
|
||||
|
||||
// Dir returnerar den absoluta sökvägen till resursmappen. Resultatet cachas.
|
||||
func Dir() (string, error) {
|
||||
dirOnce.Do(func() {
|
||||
exe, _ := os.Executable()
|
||||
cwd, _ := os.Getwd()
|
||||
dirPath, dirErr = Locate(os.Getenv(EnvVar), filepath.Dir(exe), cwd, dirExists)
|
||||
})
|
||||
return dirPath, dirErr
|
||||
}
|
||||
|
||||
// Locate är den rena uppslagslogiken bakom Dir, bruten ut för att kunna testas.
|
||||
// Ordning: env-variabel, mappen bredvid binären, arbetskatalogen och dess
|
||||
// föräldrar (stannar vid första som innehåller en resursmapp).
|
||||
func Locate(env, exeDir, cwd string, exists func(string) bool) (string, error) {
|
||||
if env != "" {
|
||||
if exists(env) {
|
||||
return filepath.Clean(env), nil
|
||||
}
|
||||
return "", fmt.Errorf("res: %s=%q pekar inte på en mapp", EnvVar, env)
|
||||
}
|
||||
var tried []string
|
||||
if exeDir != "" {
|
||||
c := filepath.Join(exeDir, DirName)
|
||||
if exists(c) {
|
||||
return c, nil
|
||||
}
|
||||
tried = append(tried, c)
|
||||
}
|
||||
for dir := cwd; dir != ""; {
|
||||
c := filepath.Join(dir, DirName)
|
||||
if exists(c) {
|
||||
return c, nil
|
||||
}
|
||||
tried = append(tried, c)
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
break
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
return "", fmt.Errorf("res: hittade ingen %q-mapp (sökte: %v); sätt %s", DirName, tried, EnvVar)
|
||||
}
|
||||
|
||||
func dirExists(p string) bool {
|
||||
st, err := os.Stat(p)
|
||||
return err == nil && st.IsDir()
|
||||
}
|
||||
|
||||
// Path bygger en sökväg inuti resursmappen, t.ex. Path("sprites", "player.png").
|
||||
func Path(parts ...string) (string, error) {
|
||||
d, err := Dir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(append([]string{d}, parts...)...), nil
|
||||
}
|
||||
|
||||
// ReadFile läser en fil relativt resursmappen.
|
||||
func ReadFile(parts ...string) ([]byte, error) {
|
||||
p, err := Path(parts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("res: %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// LoadJSON läser och avkodar en JSON-fil relativt resursmappen.
|
||||
func LoadJSON(v any, parts ...string) error {
|
||||
b, err := ReadFile(parts...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := json.Unmarshal(b, v); err != nil {
|
||||
return fmt.Errorf("res: %s: %w", filepath.Join(parts...), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadImage läser en PNG relativt resursmappen och returnerar en image.Image.
|
||||
// (Konvertering till *ebiten.Image görs av anroparen så att detta paket
|
||||
// förblir fritt från Ebitengine och kan testas utan fönster.)
|
||||
func LoadImage(parts ...string) (image.Image, error) {
|
||||
p, err := Path(parts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := os.Open(p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("res: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
img, _, err := image.Decode(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("res: %s: %w", p, err)
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// ErrNotFound kan användas av anropare för att skilja "saknas" från andra fel.
|
||||
var ErrNotFound = errors.New("res: resursen saknas")
|
||||
76
internal/res/res_test.go
Normal file
76
internal/res/res_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package res
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func existsIn(paths ...string) func(string) bool {
|
||||
set := map[string]bool{}
|
||||
for _, p := range paths {
|
||||
set[filepath.Clean(p)] = true
|
||||
}
|
||||
return func(p string) bool { return set[filepath.Clean(p)] }
|
||||
}
|
||||
|
||||
func TestLocate_EnvWins(t *testing.T) {
|
||||
got, err := Locate("/opt/custom", "/bin", "/work", existsIn("/opt/custom", "/bin/resources"))
|
||||
if err != nil || got != "/opt/custom" {
|
||||
t.Fatalf("got %q, %v; want /opt/custom", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocate_EnvMissingIsError(t *testing.T) {
|
||||
_, err := Locate("/nope", "/bin", "/work", existsIn("/bin/resources"))
|
||||
if err == nil || !strings.Contains(err.Error(), EnvVar) {
|
||||
t.Fatalf("expected error mentioning %s, got %v", EnvVar, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocate_ExeDirBeforeCwd(t *testing.T) {
|
||||
got, err := Locate("", "/app", "/work", existsIn("/app/resources", "/work/resources"))
|
||||
if err != nil || got != filepath.Join("/app", DirName) {
|
||||
t.Fatalf("got %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocate_WalksUpFromCwd(t *testing.T) {
|
||||
// Som vid `go run`/dlv: binären i en temp-mapp, cwd djupt inne i repot.
|
||||
got, err := Locate("", "/tmp/go-build123", "/repo/internal/level", existsIn("/repo/resources"))
|
||||
if err != nil || got != filepath.Join("/repo", DirName) {
|
||||
t.Fatalf("got %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocate_NothingFound(t *testing.T) {
|
||||
_, err := Locate("", "/a", "/b/c", existsIn())
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDir_RealRepo kör mot det riktiga repot: testet körs med cwd = paketmappen,
|
||||
// så uppsökningen måste hitta <repo>/resources.
|
||||
func TestDir_RealRepo(t *testing.T) {
|
||||
d, err := Dir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if filepath.Base(d) != DirName {
|
||||
t.Fatalf("unexpected dir %q", d)
|
||||
}
|
||||
if _, err := ReadFile("text", "sv.json"); err != nil {
|
||||
t.Fatalf("resources/text/sv.json ska finnas: %v", err)
|
||||
}
|
||||
var m map[string]string
|
||||
if err := LoadJSON(&m, "text", "sv.json"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m["game.title"] == "" {
|
||||
t.Fatal("sv.json saknar game.title")
|
||||
}
|
||||
if _, err := LoadImage("sprites", "placeholder.png"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
48
internal/rng/rng.go
Normal file
48
internal/rng/rng.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// Package rng ger seedade slumptalsgeneratorer.
|
||||
//
|
||||
// Allt som ska vara reproducerbart (minispel, oändligt läge, event-timing)
|
||||
// måste ta sin slump härifrån med en känd seed, aldrig från den globala
|
||||
// generatorn. Seeden sparas i sparfilen så att en laddad omgång ger samma
|
||||
// utfall som den avbrutna.
|
||||
package rng
|
||||
|
||||
import (
|
||||
crand "crypto/rand"
|
||||
"encoding/binary"
|
||||
"math/rand/v2"
|
||||
)
|
||||
|
||||
// Seed är en 64-bitars seed. Två värden för att kunna avleda del-seeds
|
||||
// (t.ex. per bana eller per minispel) utan att de påverkar varandra.
|
||||
type Seed struct {
|
||||
A, B uint64
|
||||
}
|
||||
|
||||
// NewSeed drar en färsk slumpmässig seed från operativsystemet.
|
||||
func NewSeed() Seed {
|
||||
var b [16]byte
|
||||
if _, err := crand.Read(b[:]); err != nil {
|
||||
panic("rng: kunde inte läsa slump från OS: " + err.Error())
|
||||
}
|
||||
return Seed{binary.LittleEndian.Uint64(b[:8]), binary.LittleEndian.Uint64(b[8:])}
|
||||
}
|
||||
|
||||
// FromInt skapar en seed från ett enkelt heltal (praktiskt i tester och för
|
||||
// seeds som spelaren skriver in manuellt).
|
||||
func FromInt(n uint64) Seed { return Seed{A: n, B: n ^ 0x9E3779B97F4A7C15} }
|
||||
|
||||
// Derive avleder en deterministisk del-seed för en namngiven kontext, så att
|
||||
// t.ex. biljettautomatens minispel på bana 3 alltid får samma slump oavsett
|
||||
// vad spelaren gjort innan.
|
||||
func (s Seed) Derive(context string) Seed {
|
||||
h := rand.New(rand.NewPCG(s.A, s.B))
|
||||
a, b := h.Uint64(), h.Uint64()
|
||||
for _, c := range context {
|
||||
a ^= uint64(c) * 0x100000001B3
|
||||
b = b*31 + uint64(c)
|
||||
}
|
||||
return Seed{A: a, B: b}
|
||||
}
|
||||
|
||||
// New returnerar en generator för seeden. Samma seed ger alltid samma sekvens.
|
||||
func (s Seed) New() *rand.Rand { return rand.New(rand.NewPCG(s.A, s.B)) }
|
||||
38
internal/rng/rng_test.go
Normal file
38
internal/rng/rng_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package rng
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDeterministic(t *testing.T) {
|
||||
a := FromInt(42).New()
|
||||
b := FromInt(42).New()
|
||||
for i := 0; i < 1000; i++ {
|
||||
if x, y := a.Uint64(), b.Uint64(); x != y {
|
||||
t.Fatalf("sekvensen skiljer sig vid %d: %d != %d", i, x, y)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDifferentSeedsDiffer(t *testing.T) {
|
||||
if FromInt(1).New().Uint64() == FromInt(2).New().Uint64() {
|
||||
t.Fatal("olika seeds gav samma första värde")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveIsStableAndContextual(t *testing.T) {
|
||||
s := FromInt(7)
|
||||
if s.Derive("ticket") != s.Derive("ticket") {
|
||||
t.Fatal("Derive är inte deterministisk")
|
||||
}
|
||||
if s.Derive("ticket") == s.Derive("toilet") {
|
||||
t.Fatal("olika kontexter gav samma del-seed")
|
||||
}
|
||||
if s.Derive("ticket") == FromInt(8).Derive("ticket") {
|
||||
t.Fatal("olika rot-seeds gav samma del-seed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSeedIsRandom(t *testing.T) {
|
||||
if NewSeed() == NewSeed() {
|
||||
t.Fatal("två NewSeed() gav samma värde")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user