All checks were successful
release / build-release (push) Successful in 1m42s
- level.Schedule.Phase: away/approaching/arriving/dwelling/closing/leaving ur tidtabellen med progress; Boardable (öppna dörrar) och Present; validering av minsta uppehåll/borta-tid; TrackTile för rälsen - sim.Run: ombordstigning bara med öppna dörrar, TrainVisible via Present, PlatformPhase och NearTrack (närmaste perrong inom två rutor) - game/trains.go: rälsrutor längs kantraden (roterade för vertikala spår), tre vagnar som bromsar in och accelererar ut längs rälsen, strålkastare under inrullningen, blinkande lampor vid kanten för den som står nära, dammpartiklar längs perrongen; kameran får en rutas marginal så att kanträlsen syns - game/audio.go: ebiten/v2/audio med avgångsmelodi (approaching) och dörrsignal (closing), volym efter avstånd, -mute; tools/gensound syntetiserar placeholder-WAV:ar - HUD: fasberoende perrongtext och "tåg på väg in" nära rälsen; skriptkommandot waitphase; skärmdump av ankomsten - plan (Björn 2026-10-24): räls/väg-grafik och melodierna i M7b; folk/NPC:er, vänster-/högertrafik, bredare gångar, reklam- och ljudröra som nytt steg M9b – i spelbeskrivning, roadmap, testplan - docs/README/wiki uppdaterade Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
250 lines
6.0 KiB
Go
250 lines
6.0 KiB
Go
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":
|
||
if len(f) != 2 {
|
||
return nil, fmt.Errorf("script: '%s TEXT'", f[0])
|
||
}
|
||
st.arg = f[1]
|
||
case "quit", "waittrain", "waitstop":
|
||
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
|
||
MiniPick(text 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 "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":
|
||
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)
|
||
}
|