Files
metro-puzzle-jp/tools/shots/main.go
claude 4ae6a1d24c
All checks were successful
release / build-release (push) Successful in 1m37s
M10: byten, hemresan och levelcheck
Resor med byten: en bana kan ha next som pekar på en delbana (kind leg,
syns inte på bankartan). När man klivit av rätt tåg fortsätter samma
omgång på nästa station med klocka, plånbok, sällskap och räknare;
par-tiden adderas, dimma och folk är nya, en Byte!-banner visas och
HUD:en visar resmålet via bytesstationen. Tidsgräns och missade tåg
gäller hela resan. level.ValidateChains kräver sammanhängande kedjor.
Nya resor: 007 Akihabara -> Ueno -> Asakusa och finalen 008 Tokyo (två
våningar) -> Nippori -> Narita flygplats. Slutscen när sista huvudbanan
klaras första gången: planet lyfter, resan summeras, eftertexter;
Progress.StoryCleared sparas; flaggan -ending. tools/levelcheck ritar
banorna och rapporterar problem. HUD-nivån kortas när mätarna tar plats;
poängraden radbryts. Skärmdumpar och docs uppdaterade.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-24 13:37:48 +02:00

286 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Genererar dokumentationens skärmdumpar deterministiskt:
//
// go run ./tools/shots
//
// Bygger debug-binären, kör den med -script för varje bild och skriver
// 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 (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/save"
)
// demoSave skriver en sparfil med lite progression så att kartan, skills
// och garderoben har något att visa. Används bara av skärmdumparna.
func demoSave(path string) error {
st := save.New()
st.Know.Learn("改札")
st.Know.Learn("出口")
st.Know.Points = 2
st.Progress.RecordLevel("001-flygplatsen", true, 3, 941)
st.Progress.RecordLevel("002-shinjuku", true, 2, 760)
st.Progress.AddXP(600)
st.Progress.Items["skin_salaryman"] = true
st.Progress.Items["geta"] = true
st.Progress.Items["water_bottle"] = true
st.Progress.Equipped["body"] = "skin_salaryman"
st.Progress.Equipped["feet"] = "geta"
st.Progress.Skills["focus1"] = true
return save.Store(path, st)
}
type shot struct {
level string
script string // $OUT ersätts med doc/img-mappen
args []string // extra flaggor till spelet
}
// shots byggs i main eftersom vägarna räknas ut från bandatan (BFS), så att
// bilderna överlever ändringar i banorna.
var shots []shot
var demo string
func buildShots(levelsDir string) error {
l, err := level.LoadFile(filepath.Join(levelsDir, "001-flygplatsen.json"))
if err != nil {
return err
}
sign := l.Find(level.Sign)[1] // skylten vid spärrarna
goal := l.Find(level.Platform)[0]
toSign, err := moves(l, l.SpawnPoint(), sign)
if err != nil {
return err
}
toGoal, err := moves(l, sign, goal)
if err != nil {
return err
}
toMachine, err := moves(l, l.SpawnPoint(), l.Find(level.TicketMachine)[0])
if err != nil {
return err
}
toKiosk, err := moves(l, l.SpawnPoint(), l.Find(level.Kiosk)[0])
if err != nil {
return err
}
l2, err := level.LoadFile(filepath.Join(levelsDir, "002-shinjuku.json"))
if err != nil {
return err
}
toMachine2, err := moves(l2, l2.SpawnPoint(), l2.Find(level.TicketMachine)[0])
if err != nil {
return err
}
l3, err := level.LoadFile(filepath.Join(levelsDir, "003-shibuya.json"))
if err != nil {
return err
}
toMachine3, err := moves(l3, l3.SpawnPoint(), l3.Find(level.TicketMachine)[0])
if err != nil {
return err
}
l4, err := level.LoadFile(filepath.Join(levelsDir, "004-tokyo.json"))
if err != nil {
return err
}
toStairs, err := moves(l4, l4.SpawnPoint(), l4.Find(level.StairsUp)[0])
if err != nil {
return err
}
l5, err := level.LoadFile(filepath.Join(levelsDir, "005-ueno.json"))
if err != nil {
return err
}
toPlatform5, err := moves(l5, l5.SpawnPoint(), l5.CorrectPlatform())
if err != nil {
return err
}
l6, err := level.LoadFile(filepath.Join(levelsDir, "006-ikebukuro.json"))
if err != nil {
return err
}
toMachine6, err := moves(l6, l6.SpawnPoint(), l6.Find(level.TicketMachine)[0])
if err != nil {
return err
}
toTrap6, err := moves(l6, l6.SpawnPoint(), l6.Find(level.Platform3)[0]) // Marunouchi: för långt bort
if err != nil {
return err
}
l7, err := level.LoadFile(filepath.Join(levelsDir, "007-akihabara.json"))
if err != nil {
return err
}
toPlatform7, err := moves(l7, l7.SpawnPoint(), l7.Find(level.Platform)[0]) // Hibiya mot Kita-Senju: byte i Ueno
if err != nil {
return err
}
// Skyltbilderna tas med några kända ord så att markeringen och den
// delvisa översättningen syns; hjälpen lär sedan in nästa ord.
known := []string{"-know", "改札,番線,方面", "-seed", "7"}
shots = []shot{
{"", "wait 10; shot $OUT/map.png; press l; wait 5; press down; press down; press down; wait 5; shot $OUT/words.png; press l; wait 3; press t; wait 5; press down; wait 3; shot $OUT/skills.png; press t; wait 3; press i; wait 5; shot $OUT/items.png; quit",
[]string{"-know", "改札,出口,トイレ", "-points", "3", "-save", "$DEMO"}},
{"001-flygplatsen", toKiosk + "; press b; wait 5; press down; wait 3; shot $OUT/shop.png; quit", []string{"-seed", "7", "-save", "$DEMO"}},
{"001-flygplatsen", "move right 4; move down 2; wait 5; shot $OUT/level-001.png; quit", nil},
{"002-shinjuku", "move down 6; move right 4; wait 5; shot $OUT/level-002.png; quit", nil},
{"001-flygplatsen", toSign + "; wait 5; shot $OUT/sign.png; press h; wait 200; shot $OUT/sign-help.png; wait 200; shot $OUT/sign-learned.png; " + toGoal +
"; waitphase approaching; wait 200; shot $OUT/approach.png; waittrain; wait 30; shot $OUT/platform.png; press enter; wait 100; pick calm; press enter; wait 40; shot $OUT/ride.png; waitstop; press enter; wait 5; shot $OUT/result.png; quit", known},
{"001-flygplatsen", toMachine + "; press enter; wait 5; press right; wait 5; shot $OUT/topup.png; quit", []string{"-seed", "7", "-card", "0"}},
{"002-shinjuku", toMachine2 + "; press enter; wait 5; shot $OUT/ticket.png; quit", []string{"-seed", "7", "-know", "渋谷"}},
{"003-shibuya", toMachine3 + "; press enter; pick 池袋; press enter; wait 5; press enter; press right; wait 5; shot $OUT/seats.png; quit",
[]string{"-seed", "7", "-know", "池袋", "-debuffs", "sore_feet"}},
{"004-tokyo", "wait 10; shot $OUT/floors-b1.png; " + toStairs + "; wait 20; shot $OUT/floors-1f.png; quit", []string{"-seed", "7"}},
{"005-ueno", "wait 90; shot $OUT/crowd.png; " + toPlatform5 + "; waittrain; wait 5; press enter; wait 20; shot $OUT/cars-alight.png; wait 80; press right; wait 5; shot $OUT/cars.png; quit", []string{"-seed", "7"}},
{"006-ikebukuro", "move right 2; wait 5; shot $OUT/nav-list.png; quit", []string{"-seed", "7", "-mode", "list"}},
{"006-ikebukuro", toMachine6 + "; wait 5; shot $OUT/terminal.png; " + "quit", []string{"-seed", "7", "-mode", "signs", "-know", "山手線,番線,方面,新宿,池袋,埼京線"}},
{"006-ikebukuro", toTrap6 + "; waittrain; wait 5; press enter; wait 100; pick calm; press enter; wait 60; shot $OUT/ride-real.png; waitover; wait 5; shot $OUT/too-far.png; quit", []string{"-seed", "7", "-mode", "signs"}},
{"", "wait 10; press n; wait 5; shot $OUT/map-signs.png; quit", []string{"-save", "$DEMO"}},
{"007-akihabara", toPlatform7 + "; waittrain; wait 5; press enter; wait 100; pick calm; press enter; waitstop; press enter; wait 40; shot $OUT/transfer.png; quit",
[]string{"-seed", "7", "-mode", "list", "-know", "上野,番線,方面,乗り換え"}},
{"", "wait 150; shot $OUT/ending.png; press enter; wait 5; shot $OUT/ending-summary.png; press enter; wait 250; shot $OUT/credits.png; quit", []string{"-ending", "-save", "$DEMO"}},
}
return nil
}
// moves gör om kortaste vägen mellan två rutor till "move DIR N"-steg.
func moves(l *level.Level, from, to level.Point) (string, error) {
path := l.Path(from, to, l.CanEnter)
if path == nil {
return "", fmt.Errorf("ingen väg %v -> %v i %s", from, to, l.ID)
}
var parts []string
dir, n := "", 0
flush := func() {
if n > 0 {
parts = append(parts, fmt.Sprintf("move %s %d", dir, n))
}
}
for i := 1; i < len(path); i++ {
if path[i].Z != path[i-1].Z {
continue // trappan flyttar en till andra våningen i samma steg
}
d := dirName(path[i-1], path[i])
if d != dir {
flush()
dir, n = d, 0
}
n++
}
flush()
return strings.Join(parts, "; "), nil
}
func dirName(a, b level.Point) string {
switch {
case b.X > a.X:
return "right"
case b.X < a.X:
return "left"
case b.Y > a.Y:
return "down"
default:
return "up"
}
}
func main() {
root := repoRoot()
out := filepath.Join(root, "doc", "img")
must(os.MkdirAll(out, 0o755))
demo = filepath.Join(root, "build", "demo-save.json")
must(os.MkdirAll(filepath.Dir(demo), 0o755))
must(demoSave(demo))
must(buildShots(filepath.Join(root, "resources", "data", "levels")))
build := exec.Command("go", "run", "./tools/build", "-mode", "debug")
build.Dir, build.Stdout, build.Stderr = root, os.Stdout, os.Stderr
must(build.Run())
exe := filepath.Join(root, "build", "debug", "metro-puzzle-jp")
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 {
if len(os.Args) > 1 && !matchesAny(s, os.Args[1:]) {
continue // `go run ./tools/shots 005 ride` kör bara skott vars namn eller bana innehåller något av orden
}
args := []string{"-mute", "-nosave", "-script", strings.ReplaceAll(s.script, "$OUT", out)}
if s.level != "" {
args = append(args, "-level", s.level)
}
for _, a := range s.args {
args = append(args, strings.ReplaceAll(a, "$DEMO", demo))
}
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))
}
}
fmt.Println("klart:", out)
}
func repoRoot() string {
dir, _ := os.Getwd()
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
fail(fmt.Errorf("hittade ingen go.mod"))
}
dir = parent
}
}
func must(err error) {
if err != nil {
fail(err)
}
}
func fail(err error) {
fmt.Fprintln(os.Stderr, "shots:", err)
os.Exit(1)
}
// matchesAny rapporterar om skottets bana eller skript innehåller något av orden.
func matchesAny(s shot, words []string) bool {
for _, w := range words {
if strings.Contains(s.level, w) || strings.Contains(s.script, w) {
return true
}
}
return false
}