All checks were successful
release / build-release (push) Successful in 1m35s
tools/gensound syntetiserar 21 ljud: fyra stationsmelodier i klockklang (en per station via melody-fältet eller banans id), ankomstjingel, ding-dong vid inrullning, dörrsignal vid avgång, spärrens pip och nekande, kiosk, toalett, lärt ord, bekräfta/varning, byte, personal, vinst, förlust, flygplan, sorl och menymusik. sfx.go spelar effekter utifrån vad som hänt i sim.Run sedan förra bildrutan. Ljudlagret har huvudvolym × effekter/musik och byter inte slinga i onödan. Inställningar (S på bankartan, sparas): volymer, språk sv/en som laddas om direkt, fönsterskala, fullskärm (F11 överallt) och tangentöversikt. Fönsterikon från genart. CHANGELOG.md. Tester för ljudfiler, melodival och inställningar. Docs, wiki och skärmdumpar uppdaterade. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
332 lines
12 KiB
Go
332 lines
12 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 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
|
||
}
|
||
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 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"}},
|
||
{"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 10; press s; wait 5; press down; wait 3; shot $OUT/settings.png; quit", []string{"-save", "$DEMO"}},
|
||
{"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))
|
||
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
|
||
}
|