// 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. // demoSaveEN är samma demosparfil på engelska (för språkkontrollen). func demoSaveEN(path string) error { if err := demoSave(path); err != nil { return err } st, _, err := save.Load(path) if err != nil { return err } st.Settings.Lang = "en" return save.Store(path, st) } 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 st.Progress.StoryCleared = true // bankartan visar oändligt läge och New Game+ st.AddEndless(save.EndlessRecord{Score: 2140, Goals: 4}) 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, demoEN, fresh 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 } l1b, err := level.LoadFile(filepath.Join(levelsDir, "001b-flygbussen.json")) if err != nil { return err } toMachine1b, err := moves(l1b, l1b.SpawnPoint(), l1b.Find(level.TicketMachine)[0]) if err != nil { return err } toStop1b, err := moves(l1b, l1b.Find(level.TicketMachine)[0], l1b.CorrectPlatform()) 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 } toKiosk7, err := moves(l7, l7.SpawnPoint(), l7.Find(level.Kiosk)[0]) if err != nil { return err } // Från perrongen i Ueno tillbaka till första spärren, och riktningen ut // genom den (som spärren utan återvändo ska vägra). toGateBack, pushOut, err := backToGate(l5, l5.CorrectPlatform()) 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 5; press enter; 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 5; press enter; 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"}}, {"007-akihabara", toKiosk7 + "; urge Ville; press enter; wait 5; press right; wait 3; shot $OUT/payer.png; quit", []string{"-seed", "7"}}, {"", "wait 10; shot $OUT/endless.png; quit", []string{"-endless", "-seed", "7", "-mode", "list", "-save", "$DEMO"}}, {"", "wait 5; press enter; wait 10; shot $OUT/map-en.png; quit", []string{"-save", "$DEMOEN"}}, {"001-flygplatsen", toSign + "; wait 5; shot $OUT/sign-en.png; quit", []string{"-seed", "7", "-save", "$DEMOEN", "-know", "改札,番線,方面"}}, {"001b-flygbussen", toMachine1b + "; press enter; pick 新宿; press enter; wait 5; " + toStop1b + "; waittrain; wait 10; shot $OUT/bus.png; quit", []string{"-seed", "7", "-know", "新宿,方面"}}, {"", "wait 5; press enter; wait 10; press s; wait 5; press down; wait 3; shot $OUT/settings.png; quit", []string{"-save", "$DEMO"}}, {"", "wait 10; shot $OUT/lang.png; press enter; wait 30; shot $OUT/title.png; press down; press enter; wait 5; press right; wait 5; shot $OUT/tutorial.png; quit", []string{"-save", "$FRESH"}}, {"001-flygplatsen", "wait 40; shot $OUT/tips.png; " + toSign + "; wait 5; shot $OUT/tips-sign.png; press esc; wait 5; shot $OUT/pause.png; quit", []string{"-seed", "7", "-save", "$FRESH"}}, {"005-ueno", toPlatform5 + "; " + toGateBack + "; wait 5; push " + pushOut + " 30; wait 3; shot $OUT/no-exit.png; press enter; wait 150; shot $OUT/staff.png; quit", []string{"-seed", "7"}}, } return nil } // moves gör om kortaste vägen mellan två rutor till "move DIR N"-steg. // backToGate ger stegen från perrongen till första spärren på vägen ut och // riktningen vidare ut genom den. func backToGate(l *level.Level, from level.Point) (string, string, error) { path := l.PathOutside(from, l.SpawnPoint(), l.CanEnter) gi := 0 for gi < len(path) && l.At(path[gi]) != level.Gate { gi++ } if gi >= len(path)-1 { return "", "", fmt.Errorf("ingen spärr på vägen ut från %v i %s", from, l.ID) } m, err := moves(l, from, path[gi]) if err != nil { return "", "", err } g, o := path[gi], path[gi+1] dir := "right" switch { case o.X < g.X: dir = "left" case o.Y < g.Y: dir = "up" case o.Y > g.Y: dir = "down" } return m, dir, nil } func moves(l *level.Level, from, to level.Point) (string, error) { path := l.PathOutside(from, to, func(p level.Point) bool { // andra perronger och deras betalda områden är inga genvägar return l.CanEnter(p) && (!l.At(p).IsPlatform() || p == to) }) 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)) fresh = filepath.Join(root, "build", "fresh-save.json") // finns inte: första starten (språkval, titel, tips) os.Remove(fresh) demoEN = filepath.Join(root, "build", "demo-save-en.json") must(demoSaveEN(demoEN)) 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.NewReplacer("$DEMOEN", demoEN, "$DEMO", demo, "$FRESH", fresh).Replace(a)) } 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 }