Skärmdumpar på osynlig skärm och deterministiska bildrutor
All checks were successful
release / build-release (push) Successful in 1m42s
- tools/shots/headless.go: startar en egen headless Wayfire (wlroots headless-backend + Xwayland) och kör spelet där, så att inga fönster syns eller tar fokus från det man gör på datorn; snäll avstängning och städning av sockets; METRO_HEADLESS=0 använder skärmen - skriptläget ritar scenen synkront vid shot så att bilden alltid speglar exakt den uppdateringen oavsett FPS (två körningar ger identiska PNG:er) - README/roadmap: hur den osynliga skärmen fungerar och används manuellt Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@@ -171,7 +171,7 @@ Y = hjälp, X = ordlista, Back/Select = karta.
|
||||
| `go run ./tools/build …` | bygger (se ovan) |
|
||||
| `go run ./tools/genart` | genererar placeholder-grafiken `resources/sprites/tiles.png` och `player.png` |
|
||||
| `go run ./tools/gensound` | genererar placeholder-ljuden `resources/sound/*.wav` (avgångsmelodi, dörrsignal) |
|
||||
| `go run ./tools/shots` | tar dokumentationens skärmdumpar till `doc/img/` – **kör efter varje grafik-/UI-ändring och committa bilderna** |
|
||||
| `go run ./tools/shots` | tar dokumentationens skärmdumpar till `doc/img/` – **kör efter varje grafik-/UI-ändring och committa bilderna**. På Linux körs spelet på en osynlig skärm (headless Wayfire + Xwayland) så att inget fönster syns eller tar fokus; `METRO_HEADLESS=0` använder skärmen |
|
||||
|
||||
## Beroenden
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 14 KiB |
BIN
doc/img/map.png
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
BIN
doc/img/ride.png
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
@@ -107,6 +107,15 @@ go run ./tools/shots # bygger debug, kör spelet med -script, skriver doc/img
|
||||
committa `doc/img/` i samma commit, så visar README och wiki alltid aktuell
|
||||
grafik. Nya vyer läggs till i listan i `tools/shots/main.go`.
|
||||
|
||||
**Osynlig skärm:** på Linux startar `tools/shots` en egen headless
|
||||
wlroots-kompositor (Wayfire med `WLR_BACKENDS=headless` och Xwayland) och
|
||||
kör spelet där, så att skärmdumparna varken syns eller tar fokus från det
|
||||
man själv gör på datorn. Bilderna är pixelidentiska med riktiga skärmen
|
||||
(mjukvarurendering, nearest-filter). `METRO_HEADLESS=0` stänger av det;
|
||||
saknas `wayfire` används skärmen. Samma knep för manuella körningar:
|
||||
`DISPLAY=:N WAYLAND_DISPLAY=wayland-N ./metro-puzzle-jp …` mot en startad
|
||||
headless-instans.
|
||||
|
||||
### CI
|
||||
|
||||
`.gitea/workflows/release.yaml` kör `go vet` + `go test`, bygger
|
||||
|
||||
@@ -206,6 +206,7 @@ func (s *script) tick(g *Game, frame *ebiten.Image) error {
|
||||
s.advance()
|
||||
}
|
||||
case "shot":
|
||||
g.scene.Draw(g, frame) // rita nu, så att bilden speglar exakt denna uppdatering oavsett FPS
|
||||
if err := saveShot(frame, st.arg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
128
tools/shots/headless.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Headless skärm för skärmdumparna (Linux): en egen wlroots-kompositor
|
||||
// (Wayfire) med headless-backend och Xwayland, så att spelfönstren varken
|
||||
// syns eller tar fokus från det Björn gör på datorn. Finns inte wayfire,
|
||||
// eller sätts METRO_HEADLESS=0, används den vanliga skärmen.
|
||||
|
||||
type headless struct {
|
||||
cmd *exec.Cmd
|
||||
wayland string // t.ex. wayland-1
|
||||
x11 string // t.ex. :1
|
||||
}
|
||||
|
||||
var (
|
||||
reSocket = regexp.MustCompile(`Using socket name (wayland-\d+)`)
|
||||
reXwayland = regexp.MustCompile(`Starting Xwayland on (:\d+)`)
|
||||
)
|
||||
|
||||
// startHeadless startar kompositorn och väntar på socket + Xwayland.
|
||||
// Returnerar nil (utan fel) när headless inte är tillgängligt.
|
||||
func startHeadless(buildDir string) (*headless, error) {
|
||||
if runtime.GOOS != "linux" || os.Getenv("METRO_HEADLESS") == "0" {
|
||||
return nil, nil
|
||||
}
|
||||
bin, err := exec.LookPath("wayfire")
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
ini := filepath.Join(buildDir, "wayfire-headless.ini")
|
||||
if err := os.WriteFile(ini, []byte("[core]\nplugins = \nxwayland = true\npreferred_decoration_mode = server\nvwidth = 1\nvheight = 1\n"), 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cmd := exec.Command(bin, "-c", ini)
|
||||
cmd.Env = append(os.Environ(), "WLR_BACKENDS=headless", "WLR_LIBINPUT_NO_DEVICES=1", "WLR_RENDERER=pixman")
|
||||
logf, err := os.Create(filepath.Join(buildDir, "wayfire-headless.log"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pr, pw, err := os.Pipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cmd.Stdout, cmd.Stderr = pw, pw
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pw.Close()
|
||||
h := &headless{cmd: cmd}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
sc := bufio.NewScanner(pr)
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
fmt.Fprintln(logf, line)
|
||||
if m := reSocket.FindStringSubmatch(line); m != nil {
|
||||
h.wayland = m[1]
|
||||
}
|
||||
if m := reXwayland.FindStringSubmatch(line); m != nil {
|
||||
h.x11 = m[1]
|
||||
}
|
||||
if h.wayland != "" && h.x11 != "" {
|
||||
select {
|
||||
case <-done:
|
||||
default:
|
||||
close(done)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(10 * time.Second):
|
||||
h.stop()
|
||||
return nil, fmt.Errorf("headless-kompositorn startade inte inom 10 s (se %s)", logf.Name())
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond) // låt Xwayland bli redo
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// env ger miljön som pekar spelet mot den osynliga skärmen.
|
||||
func (h *headless) env() []string {
|
||||
var out []string
|
||||
for _, e := range os.Environ() {
|
||||
if strings.HasPrefix(e, "DISPLAY=") || strings.HasPrefix(e, "WAYLAND_DISPLAY=") {
|
||||
continue
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return append(out, "DISPLAY="+h.x11, "WAYLAND_DISPLAY="+h.wayland)
|
||||
}
|
||||
|
||||
// stop stänger kompositorn snällt (SIGTERM, så att den tar bort sina
|
||||
// sockets) och städar det som ändå blir kvar.
|
||||
func (h *headless) stop() {
|
||||
if h == nil || h.cmd == nil || h.cmd.Process == nil {
|
||||
return
|
||||
}
|
||||
_ = h.cmd.Process.Signal(syscall.SIGTERM)
|
||||
done := make(chan struct{})
|
||||
go func() { _, _ = h.cmd.Process.Wait(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
_ = h.cmd.Process.Kill()
|
||||
<-done
|
||||
}
|
||||
if rt := os.Getenv("XDG_RUNTIME_DIR"); rt != "" && h.wayland != "" {
|
||||
os.Remove(filepath.Join(rt, h.wayland))
|
||||
os.Remove(filepath.Join(rt, h.wayland+".lock"))
|
||||
}
|
||||
if h.x11 != "" {
|
||||
os.Remove(filepath.Join("/tmp/.X11-unix", "X"+strings.TrimPrefix(h.x11, ":")))
|
||||
}
|
||||
h.cmd = nil
|
||||
}
|
||||
@@ -6,6 +6,10 @@
|
||||
// PNG:er till doc/img/. Vägarna genom banorna räknas ut med BFS från
|
||||
// bandatan. Kör om detta när grafik, UI eller banor ändrats, och committa
|
||||
// bilderna – README och wikin pekar på dem.
|
||||
//
|
||||
// På Linux körs spelet på en osynlig skärm (headless Wayfire + Xwayland,
|
||||
// se headless.go) så att inget fönster syns eller tar fokus. METRO_HEADLESS=0
|
||||
// stänger av det.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -164,6 +168,14 @@ func main() {
|
||||
if runtime.GOOS == "windows" {
|
||||
exe += ".exe"
|
||||
}
|
||||
hl, err := startHeadless(filepath.Join(root, "build"))
|
||||
must(err)
|
||||
if hl != nil {
|
||||
fmt.Printf("» osynlig skärm: %s / %s (inget syns på skrivbordet)\n", hl.wayland, hl.x11)
|
||||
defer hl.stop()
|
||||
} else {
|
||||
fmt.Println("» ingen headless-kompositor (wayfire saknas eller METRO_HEADLESS=0): använder skärmen")
|
||||
}
|
||||
_ = demo
|
||||
for _, s := range shots {
|
||||
args := []string{"-mute", "-nosave", "-script", strings.ReplaceAll(s.script, "$OUT", out)}
|
||||
@@ -175,7 +187,11 @@ func main() {
|
||||
}
|
||||
run := exec.Command(exe, args...)
|
||||
run.Dir, run.Stdout, run.Stderr = filepath.Dir(exe), os.Stdout, os.Stderr
|
||||
if hl != nil {
|
||||
run.Env = hl.env()
|
||||
}
|
||||
if err := run.Run(); err != nil {
|
||||
hl.stop()
|
||||
fail(fmt.Errorf("%s: %w", s.script, err))
|
||||
}
|
||||
}
|
||||
|
||||