All checks were successful
release / build-release (push) Successful in 1m36s
Ett tåg kan vara en buss (vehicle: bus): samma tidtabell, faser, bedömning i nätet och ombordstigning, men ett fordon på en väg i stället för vagnar på räls, hållplatsskylt, "1番" på tavlan, tuta vid inrullning och dörrsignal före avgång. Nätet har tre busslinjer från Narita. Nya sidobanor: 001b Flygbussterminalen (biljettbana med tre bussar) och 003b Harajuku (Yamanote åt rätt eller fel håll runt ringen). Overload-flaggan ger en rullande reklamticker och maximalt med affischer (Ueno, Tokyo). Romaji utan makroner (fonten saknar dem). Skärmdumparna som börjar på bankartan går via titelskärmen. Tester, docs och wiki uppdaterade. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
349 lines
13 KiB
Go
349 lines
13 KiB
Go
// 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
|
||
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, 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"}},
|
||
{"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)
|
||
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(strings.ReplaceAll(a, "$DEMO", demo), "$FRESH", fresh))
|
||
}
|
||
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
|
||
}
|