All checks were successful
release / build-release (push) Successful in 1m37s
- sim/stats.go: Stats (fart, törst, fokus, toalett, tur), debuffar som multiplikatorer, Profile som följer med mellan banor och räknar ned - sim.Run: tidsgräns och maxantal missade tåg förlorar banan; poängformel (tid mot par, pengar, sällskapets mätare, utforskat, avdrag); debuffar tjänas av många steg, lång tid, tom matmätare och seedad otur vid kiosken; stats påverkar fart, mätartakter, läsning och hjälptid - egna kort (individualCards): Member.Card, spärren kräver allas saldo och drar från alla, automaten frågar vems kort, biljetter köps åt alla - sittplatsval (seatSelection): Ticket får ett sittplatssteg med seedade önskemål (fönster/gång/sida/bredvid); missnöjda ger mätar- och poängavdrag - level: maxMissedTrains, individualCards, seatSelection; bana 003 Shibuya (svårighet 3, fyra i sällskapet) och 002 med tidsgräns och sittplatser; ordboken utökad; generatorn är spec-driven - game: tid kvar/missade i HUD, debuffar i meny/HUD/resultat, poäng och förlustorsaker på resultatet, vyer för personval och vagn, skript-pick, flaggan -debuffs; HUD och resultat komprimerade för fyra i sällskapet - plan (Björn 2026-10-24): flera våningar med trappor som M9a, egna kort och sittplatsval som svårighetsinställningar i M7 – i spelbeskrivning, roadmap, testplan - docs/README/wiki med sittplatsbilden Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
243 lines
5.7 KiB
Go
243 lines
5.7 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
|
||
// 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":
|
||
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
|
||
}
|
||
|
||
// 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 "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)
|
||
}
|