Files
metro-puzzle-jp/internal/game/script.go
claude f04d6a88f9
All checks were successful
release / build-release (push) Successful in 2m7s
M10c: vem betalar i kiosken och spärrar utan återvändo
Björns tillägg 2026-10-24. Med egna kort och fler i sällskapet frågar
kiosken vem som betalar (minigame.Payer: kontant, spelarens kort eller en
följares kort; den som inte har råd nekas, Esc avbryter). Avgiften dras
nu när man passerar spärren in i det betalda området (level.InsideGates),
inte på spärrrutan. Banflaggan oneWayGates: incheckad släpps inte ut igen
förrän man åkt; Enter vid spärren kallar på personal (10 s) som låser upp
korten; ny inpassering kostar igen. På i 005, 007/007b och 008/008b.
Skriptkommandon urge och push. Docs, wiki och skärmdumpar uppdaterade.

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

278 lines
6.8 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.

package game
import (
"fmt"
"image"
"image/png"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/hajimehoshi/ebiten/v2"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
)
// Skriptläge (-script) för automatiserade skärmdumpar och smoke-tester.
// Stegen skiljs med ';':
//
// wait N vänta N bildrutor
// hold N håll Enter nere N bildrutor (sedelinmatning)
// waittrain vänta tills tåget vid perrongen spelaren står på är inne
// waitstop vänta tills tågresan står vid resmålet
// pick TEXT flytta markören i minispelet tills alternativet TEXT är valt
// waitphase NAME vänta tills närmaste perrongs tåg är i fasen NAME (approaching, dwelling …)
// press KEY tryck enter | esc | r | h | l | up | down en bildruta
// move DIR N gå N rutor åt up | down | left | right (stannar exakt där)
// shot PATH spara aktuell bild som PNG (intern upplösning × shotScale)
// quit avsluta spelet
//
// Exempel: -level 001-flygplatsen -script "move down 2; move right 2; shot doc/img/level.png; quit"
const shotScale = 3
// moveStallFrames: så många bildrutor utan ny ruta räknas som "vägg" och
// avslutar ett move-steg (annars skulle skriptet hänga för evigt).
const moveStallFrames = 120
type scriptStep struct {
cmd string
arg string
n int
dir sim.Dir
}
type script struct {
steps []scriptStep
i int
counter int
stall int
lastPos level.Point
havePos bool
}
// virtual är den simulerade inputen som input.go läser före riktig input.
var virtual struct {
held sim.Dir
pressed map[string]bool
holdEnter bool
}
func parseScript(src string) (*script, error) {
s := &script{}
for _, raw := range strings.Split(src, ";") {
f := strings.Fields(raw)
if len(f) == 0 {
continue
}
st := scriptStep{cmd: f[0]}
switch f[0] {
case "wait", "hold":
if len(f) != 2 {
return nil, fmt.Errorf("script: '%s N'", f[0])
}
st.n, _ = strconv.Atoi(f[1])
case "press":
if len(f) != 2 {
return nil, fmt.Errorf("script: 'press KEY'")
}
st.arg = f[1]
case "move":
if len(f) != 3 {
return nil, fmt.Errorf("script: 'move DIR N'")
}
st.dir = parseDir(f[1])
if st.dir == sim.None {
return nil, fmt.Errorf("script: okänd riktning %q", f[1])
}
st.n, _ = strconv.Atoi(f[2])
case "shot", "pick", "waitphase", "urge":
if len(f) != 2 {
return nil, fmt.Errorf("script: '%s TEXT'", f[0])
}
st.arg = f[1]
case "push": // håll en riktning N bildrutor oavsett om spelaren kommer någonstans (t.ex. mot en spärr)
if len(f) != 3 {
return nil, fmt.Errorf("script: 'push DIR N'")
}
st.dir = parseDir(f[1])
if st.dir == sim.None {
return nil, fmt.Errorf("script: okänd riktning %q", f[1])
}
st.n, _ = strconv.Atoi(f[2])
case "quit", "waittrain", "waitstop", "waitover":
default:
return nil, fmt.Errorf("script: okänt kommando %q", f[0])
}
s.steps = append(s.steps, st)
}
return s, nil
}
func parseDir(s string) sim.Dir {
switch s {
case "up":
return sim.Up
case "down":
return sim.Down
case "left":
return sim.Left
case "right":
return sim.Right
}
return sim.None
}
// playerTiler implementeras av scener som har en spelare i rutnätet.
type playerTiler interface {
PlayerTile() level.Point
PlayerMoving() bool
}
// trainWaiter implementeras av scener där man kan vänta på ett tåg.
type trainWaiter interface {
TrainHere() bool
RideAtDest() bool
LevelOver() bool
MiniPick(text string) bool
ForceUrge(name string) bool
TrackPhaseName() string
}
// tick körs i början av varje Update. frame är senast ritade bild.
// Returnerar ebiten.Termination när skriptet vill avsluta.
func (s *script) tick(g *Game, frame *ebiten.Image) error {
virtual.held = sim.None
virtual.pressed = map[string]bool{}
virtual.holdEnter = false
if s.i >= len(s.steps) {
return nil
}
st := &s.steps[s.i]
switch st.cmd {
case "wait":
s.counter++
if s.counter >= st.n {
s.advance()
}
case "hold":
virtual.holdEnter = true
s.counter++
if s.counter >= st.n {
s.advance()
}
case "press":
virtual.pressed[st.arg] = true
s.advance()
case "move":
pt, ok := g.scene.(playerTiler)
if !ok {
s.advance() // ingen spelare att flytta i den här scenen
return nil
}
cur := pt.PlayerTile()
if !s.havePos {
s.lastPos, s.havePos, s.stall = cur, true, 0
}
if cur != s.lastPos {
s.lastPos = cur
s.counter++
s.stall = 0
} else {
s.stall++
}
if s.counter >= st.n && !pt.PlayerMoving() {
s.advance()
return nil
}
if s.stall > moveStallFrames { // blockerad av vägg: ge upp steget
s.advance()
return nil
}
// Håll riktningen tills sista rutan har påbörjats; släpp sedan så att
// figuren stannar exakt där. Hålls tangenten kvar i ankomstbildrutan
// fortsätter figuren annars en ruta till när farten (t.ex. med
// sällskapets fartstraff) inte är bildruteexakt.
if s.counter < st.n-1 || (s.counter == st.n-1 && !pt.PlayerMoving()) {
virtual.held = st.dir
}
case "waittrain":
if tw, ok := g.scene.(trainWaiter); !ok || tw.TrainHere() {
s.advance()
}
case "waitstop":
if tw, ok := g.scene.(trainWaiter); !ok || tw.RideAtDest() {
s.advance()
}
case "waitover":
s.counter++
if tw, ok := g.scene.(trainWaiter); !ok || tw.LevelOver() || s.counter > 60*120 {
s.advance()
}
case "urge":
if tw, ok := g.scene.(trainWaiter); ok {
tw.ForceUrge(st.arg)
}
s.advance()
case "push":
virtual.held = st.dir
s.counter++
if s.counter >= st.n {
s.advance()
}
case "waitphase":
s.counter++
if tw, ok := g.scene.(trainWaiter); !ok || tw.TrackPhaseName() == st.arg || s.counter > 60*60 {
s.advance()
}
case "pick":
s.counter++
if tw, ok := g.scene.(trainWaiter); !ok || tw.MiniPick(st.arg) || s.counter > 60 {
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
}
s.advance()
case "quit":
return ebiten.Termination
}
return nil
}
func (s *script) advance() {
s.i++
s.counter = 0
s.stall = 0
s.havePos = false
}
func saveShot(frame *ebiten.Image, path string) error {
w, h := frame.Bounds().Dx(), frame.Bounds().Dy()
buf := make([]byte, 4*w*h)
frame.ReadPixels(buf)
out := image.NewNRGBA(image.Rect(0, 0, w*shotScale, h*shotScale))
for y := 0; y < h*shotScale; y++ {
for x := 0; x < w*shotScale; x++ {
src := 4 * ((y/shotScale)*w + x/shotScale)
dst := out.PixOffset(x, y)
copy(out.Pix[dst:dst+4], buf[src:src+4])
out.Pix[dst+3] = 255
}
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
fmt.Println("skärmdump:", path)
return png.Encode(f, out)
}