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:
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user