M2: tåg med tidtabell, spärrar med kort, skyltar, vinst/förlust
All checks were successful
release / build-release (push) Successful in 1m35s
All checks were successful
release / build-release (push) Successful in 1m35s
- level: Schedule (ankomst/intervall/uppehåll) med Status(t), spårnummer, avgift, skylttexter per i-ruta, Path (BFS) och UngatedPlatforms – regeln att perronger bara nås via spärrar testas för alla banor - sim.Run: klocka, tågkort (saldo dras vid spärr, tomt kort stoppas), ombordstigning med Enter när tåget är inne, vinst/förlust; headless bot-test klarar bana 001 och förlorar på fel tåg - game: perronginfo (avgår om / nästa tåg om), skyltruta, nekad spärr, tågvagn som ritas när tåget är inne, inforutan flyttas upp när spelaren står nära nederkanten; resultat med spärrpassager och saldo - bana 001 ombyggd så spärrarna är flaskhalsar; bana 002 omgenererad med spärrar runt perrongerna och skylttexter - tools/shots räknar ut vägar med BFS; skriptkommandot waittrain - docs/README/wiki uppdaterade med nya bilder Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ type Assets struct {
|
||||
ResDir string
|
||||
LevelsDir string
|
||||
Tiles map[level.Tile]*ebiten.Image
|
||||
Train *ebiten.Image // tågvagn, ritas vid perrongen när tåget är inne
|
||||
Player [4][3]*ebiten.Image // [riktning: ner, upp, vänster, höger][bildruta: stilla, gå1, gå2]
|
||||
Text map[string]string
|
||||
}
|
||||
@@ -38,13 +39,17 @@ func LoadAssets() (*Assets, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if w := tiles.Bounds().Dx(); w < TileSize*len(tileOrder) {
|
||||
return nil, fmt.Errorf("tiles.png är %d px bred, behöver minst %d", w, TileSize*len(tileOrder))
|
||||
sub := func(i int) *ebiten.Image {
|
||||
return tiles.SubImage(image.Rect(i*TileSize, 0, (i+1)*TileSize, TileSize)).(*ebiten.Image)
|
||||
}
|
||||
if w, need := tiles.Bounds().Dx(), TileSize*(len(tileOrder)+1); w < need {
|
||||
return nil, fmt.Errorf("tiles.png är %d px bred, behöver minst %d", w, need)
|
||||
}
|
||||
for i, t := range tileOrder {
|
||||
a.Tiles[t] = tiles.SubImage(image.Rect(i*TileSize, 0, (i+1)*TileSize, TileSize)).(*ebiten.Image)
|
||||
a.Tiles[t] = sub(i)
|
||||
}
|
||||
a.Tiles[level.WrongPlatform] = a.Tiles[level.Platform] // samma utseende; tåget avgör
|
||||
a.Train = sub(len(tileOrder))
|
||||
|
||||
player, err := loadImage("sprites", "player.png")
|
||||
if err != nil {
|
||||
|
||||
@@ -17,15 +17,16 @@ import (
|
||||
// Spelarens grundfart i rutor per sekund (blir en stat i M7).
|
||||
const playerSpeed = 6.0
|
||||
|
||||
// LevelScene är själva spelandet av en bana.
|
||||
// Så länge "spärren nekade" visas efter försöket.
|
||||
const deniedShowSec = 2.0
|
||||
|
||||
// LevelScene är själva spelandet av en bana. All spellogik ligger i sim.Run;
|
||||
// scenen översätter input och ritar.
|
||||
type LevelScene struct {
|
||||
id string
|
||||
lvl *level.Level
|
||||
player *sim.Mover
|
||||
cam *sim.Camera
|
||||
animT float64
|
||||
elapsed float64
|
||||
steps int
|
||||
id string
|
||||
run *sim.Run
|
||||
cam *sim.Camera
|
||||
animT float64
|
||||
}
|
||||
|
||||
// NewLevelScene laddar banan och ställer spelaren på startrutan.
|
||||
@@ -35,20 +36,25 @@ func NewLevelScene(a *Assets, id string) (*LevelScene, error) {
|
||||
return nil, err
|
||||
}
|
||||
s := &LevelScene{
|
||||
id: id,
|
||||
lvl: lvl,
|
||||
player: sim.NewMover(lvl.SpawnPoint(), playerSpeed),
|
||||
cam: sim.NewCamera(ScreenW, ScreenH, float64(lvl.Width()*TileSize), float64(lvl.Height()*TileSize)),
|
||||
id: id,
|
||||
run: sim.NewRun(lvl, playerSpeed),
|
||||
cam: sim.NewCamera(ScreenW, ScreenH, float64(lvl.Width()*TileSize), float64(lvl.Height()*TileSize)),
|
||||
}
|
||||
s.followPlayer()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// PlayerTile returnerar rutan spelaren står på/lämnar (används av skriptläget).
|
||||
func (s *LevelScene) PlayerTile() level.Point { return s.player.Tile }
|
||||
func (s *LevelScene) PlayerTile() level.Point { return s.run.Player.Tile }
|
||||
|
||||
// TrainHere rapporterar om spelaren står på en perrong vars tåg är inne (skriptläget).
|
||||
func (s *LevelScene) TrainHere() bool {
|
||||
tr, at, _ := s.run.PlatformHere()
|
||||
return tr != nil && at
|
||||
}
|
||||
|
||||
func (s *LevelScene) followPlayer() {
|
||||
x, y := s.player.WorldPos(TileSize)
|
||||
x, y := s.run.Player.WorldPos(TileSize)
|
||||
s.cam.Follow(x+TileSize/2, y+TileSize/2)
|
||||
}
|
||||
|
||||
@@ -61,50 +67,116 @@ func (s *LevelScene) Update(g *Game) error {
|
||||
g.SetScene(m)
|
||||
return nil
|
||||
}
|
||||
s.elapsed += dt
|
||||
s.steps += s.player.Step(dt, dirInput(), s.lvl.CanEnter)
|
||||
if s.player.Moving() {
|
||||
s.run.Step(dt, dirInput(), confirmJustPressed())
|
||||
if s.run.Player.Moving() {
|
||||
s.animT += dt
|
||||
} else {
|
||||
s.animT = 0
|
||||
// Står vi på en perrong är banan slut (M2 ger tåg, tidtabell och spärrar).
|
||||
if t := s.lvl.At(s.player.Tile.X, s.player.Tile.Y); t == level.Platform || t == level.WrongPlatform {
|
||||
g.SetScene(NewResultScene(s, s.lvl.TrainAt(t)))
|
||||
}
|
||||
}
|
||||
s.followPlayer()
|
||||
if s.run.Outcome != sim.Ongoing {
|
||||
g.SetScene(NewResultScene(s))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// trainTile är rutan "bortom perrongkanten" där tågvagnen ritas: första
|
||||
// väggruta nedanför, till höger, till vänster eller ovanför perrongen.
|
||||
func (s *LevelScene) trainTile(p level.Point) level.Point {
|
||||
for _, d := range [...]level.Point{{X: 0, Y: 1}, {X: 1, Y: 0}, {X: -1, Y: 0}, {X: 0, Y: -1}} {
|
||||
n := level.Point{X: p.X + d.X, Y: p.Y + d.Y}
|
||||
if s.run.Level.At(n.X, n.Y) == level.Wall {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (s *LevelScene) Draw(g *Game, screen *ebiten.Image) {
|
||||
screen.Fill(color.RGBA{24, 20, 37, 255})
|
||||
lvl := s.run.Level
|
||||
ox, oy := s.cam.Offset()
|
||||
ox, oy = math.Round(ox), math.Round(oy)
|
||||
|
||||
// Rita bara rutor som syns.
|
||||
x0 := max(0, int(s.cam.X)/TileSize)
|
||||
y0 := max(0, int(s.cam.Y)/TileSize)
|
||||
x1 := min(s.lvl.Width(), int(s.cam.X+ScreenW)/TileSize+2)
|
||||
y1 := min(s.lvl.Height(), int(s.cam.Y+ScreenH)/TileSize+2)
|
||||
x1 := min(lvl.Width(), int(s.cam.X+ScreenW)/TileSize+2)
|
||||
y1 := min(lvl.Height(), int(s.cam.Y+ScreenH)/TileSize+2)
|
||||
var op ebiten.DrawImageOptions
|
||||
for y := y0; y < y1; y++ {
|
||||
for x := x0; x < x1; x++ {
|
||||
op.GeoM.Reset()
|
||||
op.GeoM.Translate(ox+float64(x*TileSize), oy+float64(y*TileSize))
|
||||
screen.DrawImage(g.Assets.Tiles[s.lvl.At(x, y)], &op)
|
||||
screen.DrawImage(g.Assets.Tiles[lvl.At(x, y)], &op)
|
||||
}
|
||||
}
|
||||
// Tåg som står inne.
|
||||
for _, t := range []level.Tile{level.Platform, level.WrongPlatform} {
|
||||
if !s.run.TrainVisible(t) {
|
||||
continue
|
||||
}
|
||||
for _, p := range lvl.Find(t) {
|
||||
tt := s.trainTile(p)
|
||||
op.GeoM.Reset()
|
||||
op.GeoM.Translate(ox+float64(tt.X*TileSize), oy+float64(tt.Y*TileSize))
|
||||
screen.DrawImage(g.Assets.Train, &op)
|
||||
}
|
||||
}
|
||||
|
||||
px, py := s.player.WorldPos(TileSize)
|
||||
px, py := s.run.Player.WorldPos(TileSize)
|
||||
op.GeoM.Reset()
|
||||
op.GeoM.Translate(ox+math.Round(px), oy+math.Round(py))
|
||||
screen.DrawImage(g.Assets.PlayerFrame(s.player.Facing, s.player.Moving(), s.animT), &op)
|
||||
screen.DrawImage(g.Assets.PlayerFrame(s.run.Player.Facing, s.run.Player.Moving(), s.animT), &op)
|
||||
|
||||
// HUD
|
||||
s.drawHUD(g, screen)
|
||||
}
|
||||
|
||||
func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
|
||||
r := s.run
|
||||
a := g.Assets
|
||||
vector.DrawFilledRect(screen, 0, 0, ScreenW, 24, color.RGBA{24, 20, 37, 200}, false)
|
||||
// TODO(M3): visa japanska när en font med kana/kanji finns – debugfonten saknar dem.
|
||||
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s %s: %s", s.lvl.Name.Get("sv"), g.Assets.T("hud.destination"),
|
||||
s.lvl.Destination.Get("romaji")), 4, 1)
|
||||
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s %3.0fs %s %d %s", g.Assets.T("hud.time"), s.elapsed,
|
||||
g.Assets.T("hud.steps"), s.steps, g.Assets.T("hud.controls")), 4, 12)
|
||||
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s %s: %s", r.Level.Name.Get("sv"), a.T("hud.destination"),
|
||||
r.Level.Destination.Get("romaji")), 4, 1)
|
||||
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s %3.0fs %s %d %s %d¥ %s", a.T("hud.time"), r.Time,
|
||||
a.T("hud.steps"), r.Steps, a.T("hud.card"), r.Card.Balance, a.T("hud.controls")), 4, 12)
|
||||
|
||||
// Inforutan: perronginfo, skylt eller nekad spärr. Visas inte när banan är avgjord.
|
||||
if r.Outcome != sim.Ongoing {
|
||||
return
|
||||
}
|
||||
var lines []string
|
||||
accent := color.RGBA{58, 68, 102, 255}
|
||||
if tr, at, secs := r.PlatformHere(); tr != nil {
|
||||
lines = append(lines, fmt.Sprintf("%s %s %s -> %s", a.T("hud.track"), tr.Track, tr.Line.Get("romaji"), tr.Destination.Get("romaji")))
|
||||
if at {
|
||||
accent = color.RGBA{99, 199, 77, 255}
|
||||
lines = append(lines, fmt.Sprintf("%s %.0f s %s", a.T("hud.departs_in"), math.Ceil(secs), a.T("hud.board")))
|
||||
} else {
|
||||
accent = color.RGBA{254, 174, 52, 255}
|
||||
lines = append(lines, fmt.Sprintf("%s %.0f s", a.T("hud.next_train_in"), math.Ceil(secs)))
|
||||
}
|
||||
} else if sg := r.SignHere(); sg != nil {
|
||||
lines = wrap(a.T("hud.sign")+": "+sg.Text.Get("sv"), textCols)
|
||||
} else if r.DeniedAt >= 0 && r.Time-r.DeniedAt < deniedShowSec {
|
||||
accent = color.RGBA{228, 59, 68, 255}
|
||||
lines = []string{a.T("hud.gate_denied")}
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return
|
||||
}
|
||||
h := float32(8 + 12*len(lines))
|
||||
// Rutan ligger i nederkant, men flyttas upp under HUD:en om spelaren
|
||||
// (eller tåget under hen) annars skulle hamna bakom den.
|
||||
_, py := r.Player.WorldPos(TileSize)
|
||||
y := float32(ScreenH) - h
|
||||
if float32(py-s.cam.Y)+2*TileSize > y {
|
||||
y = 24
|
||||
}
|
||||
vector.DrawFilledRect(screen, 0, y, ScreenW, h, color.RGBA{24, 20, 37, 230}, false)
|
||||
vector.DrawFilledRect(screen, 0, y, ScreenW, 2, accent, false)
|
||||
for i, l := range lines {
|
||||
ebitenutil.DebugPrintAt(screen, l, 4, int(y)+4+12*i)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,21 +8,16 @@ import (
|
||||
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
|
||||
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
|
||||
)
|
||||
|
||||
// ResultScene visas när spelaren nått en perrong.
|
||||
// ResultScene visas när banan är avgjord (rätt eller fel tåg).
|
||||
type ResultScene struct {
|
||||
from *LevelScene
|
||||
train *level.Train
|
||||
from *LevelScene
|
||||
}
|
||||
|
||||
// NewResultScene bygger resultatet för banan som just spelades.
|
||||
func NewResultScene(from *LevelScene, train *level.Train) *ResultScene {
|
||||
return &ResultScene{from: from, train: train}
|
||||
}
|
||||
|
||||
func (r *ResultScene) correct() bool { return r.train != nil && r.train.Correct }
|
||||
func NewResultScene(from *LevelScene) *ResultScene { return &ResultScene{from: from} }
|
||||
|
||||
func (r *ResultScene) Update(g *Game) error {
|
||||
switch {
|
||||
@@ -44,22 +39,33 @@ func (r *ResultScene) Update(g *Game) error {
|
||||
|
||||
func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) {
|
||||
r.from.Draw(g, screen) // banan i bakgrunden
|
||||
bg := color.RGBA{24, 20, 37, 230}
|
||||
vector.DrawFilledRect(screen, 20, 40, ScreenW-40, 100, bg, false)
|
||||
accent := color.RGBA{228, 59, 68, 255}
|
||||
headline := g.Assets.T("level.wrong_train")
|
||||
if r.correct() {
|
||||
accent = color.RGBA{99, 199, 77, 255}
|
||||
headline = g.Assets.T("level.cleared")
|
||||
}
|
||||
vector.DrawFilledRect(screen, 20, 40, ScreenW-40, 3, accent, false)
|
||||
run := r.from.run
|
||||
a := g.Assets
|
||||
vector.DrawFilledRect(screen, 20, 36, ScreenW-40, 112, color.RGBA{24, 20, 37, 235}, false)
|
||||
|
||||
ebitenutil.DebugPrintAt(screen, headline, 28, 50)
|
||||
if r.train != nil {
|
||||
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s -> %s", r.train.Line.Get("romaji"),
|
||||
r.train.Destination.Get("romaji")), 28, 66) // TODO(M3): japanska när fonten finns
|
||||
accent, headline := color.RGBA{228, 59, 68, 255}, a.T("level.wrong_train")
|
||||
if run.Outcome == sim.Won {
|
||||
accent, headline = color.RGBA{99, 199, 77, 255}, a.T("level.cleared")
|
||||
}
|
||||
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s %.1fs %s %d", g.Assets.T("hud.time"), r.from.elapsed,
|
||||
g.Assets.T("hud.steps"), r.from.steps), 28, 86)
|
||||
ebitenutil.DebugPrintAt(screen, g.Assets.T("result.hint"), 28, 118)
|
||||
vector.DrawFilledRect(screen, 20, 36, ScreenW-40, 3, accent, false)
|
||||
ebitenutil.DebugPrintAt(screen, headline, 28, 46)
|
||||
|
||||
y := 62
|
||||
if tr := run.Boarded; tr != nil {
|
||||
// TODO(M3): japanska när fonten finns
|
||||
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s %s %s -> %s", a.T("hud.track"), tr.Track,
|
||||
tr.Line.Get("romaji"), tr.Destination.Get("romaji")), 28, y)
|
||||
y += 12
|
||||
if run.Outcome == sim.Lost {
|
||||
for _, l := range wrap(fmt.Sprintf(a.T("result.wrong_detail"), tr.Destination.Get("romaji"),
|
||||
run.Level.Destination.Get("romaji")), 44) {
|
||||
ebitenutil.DebugPrintAt(screen, l, 28, y)
|
||||
y += 12
|
||||
}
|
||||
}
|
||||
}
|
||||
y += 6
|
||||
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s %.1fs %s %d %s %d %s %d¥", a.T("hud.time"), run.Time,
|
||||
a.T("hud.steps"), run.Steps, a.T("result.gates"), run.GatePasses, a.T("result.card"), run.Card.Balance), 28, y)
|
||||
ebitenutil.DebugPrintAt(screen, a.T("result.hint"), 28, 128)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
// Stegen skiljs med ';':
|
||||
//
|
||||
// wait N vänta N bildrutor
|
||||
// waittrain vänta tills tåget vid perrongen spelaren står på är inne
|
||||
// press KEY tryck enter | esc | r | up | down en bildruta
|
||||
// move DIR N gå N rutor åt up | down | left | right (släpper när rutorna är avklarade)
|
||||
// shot PATH spara aktuell bild som PNG (intern upplösning × shotScale)
|
||||
@@ -83,7 +84,7 @@ func parseScript(src string) (*script, error) {
|
||||
return nil, fmt.Errorf("script: 'shot PATH'")
|
||||
}
|
||||
st.arg = f[1]
|
||||
case "quit":
|
||||
case "quit", "waittrain":
|
||||
default:
|
||||
return nil, fmt.Errorf("script: okänt kommando %q", f[0])
|
||||
}
|
||||
@@ -109,6 +110,9 @@ func parseDir(s string) sim.Dir {
|
||||
// playerTiler implementeras av scener som har en spelare i rutnätet.
|
||||
type playerTiler interface{ PlayerTile() level.Point }
|
||||
|
||||
// trainWaiter implementeras av scener där man kan vänta på ett tåg.
|
||||
type trainWaiter interface{ TrainHere() 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 {
|
||||
@@ -151,6 +155,10 @@ func (s *script) tick(g *Game, frame *ebiten.Image) error {
|
||||
return err
|
||||
}
|
||||
s.advance()
|
||||
case "waittrain":
|
||||
if tw, ok := g.scene.(trainWaiter); !ok || tw.TrainHere() {
|
||||
s.advance()
|
||||
}
|
||||
case "quit":
|
||||
return ebiten.Termination
|
||||
}
|
||||
|
||||
27
internal/game/text.go
Normal file
27
internal/game/text.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package game
|
||||
|
||||
import "strings"
|
||||
|
||||
// Debugfonten är 6 px bred per tecken; 320 px ger drygt 50 tecken.
|
||||
const textCols = 50
|
||||
|
||||
// wrap radbryter en text på ordgränser till högst width tecken per rad.
|
||||
func wrap(s string, width int) []string {
|
||||
var lines []string
|
||||
cur := ""
|
||||
for _, w := range strings.Fields(s) {
|
||||
switch {
|
||||
case cur == "":
|
||||
cur = w
|
||||
case len([]rune(cur))+1+len([]rune(w)) > width:
|
||||
lines = append(lines, cur)
|
||||
cur = w
|
||||
default:
|
||||
cur += " " + w
|
||||
}
|
||||
}
|
||||
if cur != "" {
|
||||
lines = append(lines, cur)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
@@ -9,6 +9,7 @@ package level
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -55,23 +56,62 @@ func (t Text) Get(lang string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Schedule är ett tågs tidtabell i sekunder från banstart: första ankomst
|
||||
// vid OffsetSec, därefter var IntervalSec; tåget står DwellSec vid perrongen.
|
||||
type Schedule struct {
|
||||
OffsetSec float64 `json:"offsetSec"`
|
||||
IntervalSec float64 `json:"intervalSec"`
|
||||
DwellSec float64 `json:"dwellSec"`
|
||||
}
|
||||
|
||||
// Standardvärden som fylls i av Validate när fältet är 0.
|
||||
const (
|
||||
DefaultIntervalSec = 30.0
|
||||
DefaultDwellSec = 10.0
|
||||
DefaultFareYen = 200
|
||||
)
|
||||
|
||||
// Status returnerar om tåget står vid perrongen vid tiden t, och hur många
|
||||
// sekunder det är kvar – till avgång om det står där, annars till ankomst.
|
||||
func (s Schedule) Status(t float64) (atPlatform bool, seconds float64) {
|
||||
if t < s.OffsetSec {
|
||||
return false, s.OffsetSec - t
|
||||
}
|
||||
phase := math.Mod(t-s.OffsetSec, s.IntervalSec)
|
||||
if phase < s.DwellSec {
|
||||
return true, s.DwellSec - phase
|
||||
}
|
||||
return false, s.IntervalSec - phase
|
||||
}
|
||||
|
||||
// Train är ett tåg (eller en buss) som avgår från en perrong i banan.
|
||||
type Train struct {
|
||||
Platform string `json:"platform"` // vilket perrong-tecken (P/p) tåget står vid
|
||||
Line Text `json:"line"` // linjens namn
|
||||
Destination Text `json:"destination"` // slutstation
|
||||
Correct bool `json:"correct"` // tar spelaren till rätt resmål
|
||||
Platform string `json:"platform"` // vilket perrong-tecken (P/p) tåget står vid
|
||||
Track string `json:"track"` // spårnummer som visas på skyltar/HUD, t.ex. "2"
|
||||
Line Text `json:"line"` // linjens namn
|
||||
Destination Text `json:"destination"` // slutstation
|
||||
Correct bool `json:"correct"` // tar spelaren till rätt resmål
|
||||
Schedule Schedule `json:"schedule"`
|
||||
}
|
||||
|
||||
// SignText är texten på en skyltruta ('i').
|
||||
type SignText struct {
|
||||
X int `json:"x"`
|
||||
Y int `json:"y"`
|
||||
Text Text `json:"text"`
|
||||
}
|
||||
|
||||
// Level är en bana.
|
||||
type Level struct {
|
||||
ID string `json:"id"`
|
||||
Name Text `json:"name"`
|
||||
Difficulty int `json:"difficulty"` // 1 (lätt) … 5 (svår)
|
||||
Destination Text `json:"destination"` // dit spelaren ska
|
||||
TimeLimitSec int `json:"timeLimitSec"` // 0 = ingen tidsgräns
|
||||
Grid []string `json:"grid"`
|
||||
Trains []Train `json:"trains"`
|
||||
ID string `json:"id"`
|
||||
Name Text `json:"name"`
|
||||
Difficulty int `json:"difficulty"` // 1 (lätt) … 5 (svår)
|
||||
Destination Text `json:"destination"` // dit spelaren ska
|
||||
TimeLimitSec int `json:"timeLimitSec"` // 0 = ingen tidsgräns
|
||||
FareYen int `json:"fareYen"` // vad spärren drar från kortet (0 = standard)
|
||||
Grid []string `json:"grid"`
|
||||
Trains []Train `json:"trains"`
|
||||
Signs []SignText `json:"signs"`
|
||||
}
|
||||
|
||||
// Point är en rutkoordinat.
|
||||
@@ -161,7 +201,8 @@ func (l *Level) Validate() error {
|
||||
return fmt.Errorf("level %s: saknar tåg", l.ID)
|
||||
}
|
||||
correct := 0
|
||||
for i, tr := range l.Trains {
|
||||
for i := range l.Trains {
|
||||
tr := &l.Trains[i]
|
||||
if len(tr.Platform) != 1 || (Tile(tr.Platform[0]) != Platform && Tile(tr.Platform[0]) != WrongPlatform) {
|
||||
return fmt.Errorf("level %s: tåg %d har ogiltig perrong %q", l.ID, i, tr.Platform)
|
||||
}
|
||||
@@ -171,10 +212,87 @@ func (l *Level) Validate() error {
|
||||
if tr.Correct {
|
||||
correct++
|
||||
}
|
||||
sc := &tr.Schedule
|
||||
if sc.IntervalSec == 0 {
|
||||
sc.IntervalSec = DefaultIntervalSec
|
||||
}
|
||||
if sc.DwellSec == 0 {
|
||||
sc.DwellSec = DefaultDwellSec
|
||||
}
|
||||
if sc.OffsetSec < 0 || sc.IntervalSec <= 0 || sc.DwellSec <= 0 || sc.DwellSec >= sc.IntervalSec {
|
||||
return fmt.Errorf("level %s: tåg %d har orimlig tidtabell %+v (kräver offset ≥ 0, 0 < dwell < interval)", l.ID, i, *sc)
|
||||
}
|
||||
}
|
||||
if correct == 0 {
|
||||
return fmt.Errorf("level %s: inget tåg är markerat som rätt", l.ID)
|
||||
}
|
||||
if l.FareYen == 0 {
|
||||
l.FareYen = DefaultFareYen
|
||||
}
|
||||
if l.FareYen < 0 {
|
||||
return fmt.Errorf("level %s: negativ avgift", l.ID)
|
||||
}
|
||||
// Skyltar: varje post måste stå på en 'i'-ruta och varje 'i'-ruta ha text.
|
||||
seen := map[Point]bool{}
|
||||
for i, sg := range l.Signs {
|
||||
p := Point{sg.X, sg.Y}
|
||||
if l.At(p.X, p.Y) != Sign {
|
||||
return fmt.Errorf("level %s: skylt %d på (%d,%d) står inte på en skyltruta", l.ID, i, p.X, p.Y)
|
||||
}
|
||||
if seen[p] {
|
||||
return fmt.Errorf("level %s: två skyltar på (%d,%d)", l.ID, p.X, p.Y)
|
||||
}
|
||||
if len(sg.Text) == 0 {
|
||||
return fmt.Errorf("level %s: skylt på (%d,%d) saknar text", l.ID, p.X, p.Y)
|
||||
}
|
||||
seen[p] = true
|
||||
}
|
||||
for _, p := range l.Find(Sign) {
|
||||
if !seen[p] {
|
||||
return fmt.Errorf("level %s: skyltrutan (%d,%d) saknar text i \"signs\"", l.ID, p.X, p.Y)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SignAt returnerar skylten på en ruta, eller nil.
|
||||
func (l *Level) SignAt(p Point) *SignText {
|
||||
for i := range l.Signs {
|
||||
if l.Signs[i].X == p.X && l.Signs[i].Y == p.Y {
|
||||
return &l.Signs[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Path returnerar kortaste vägen från from till to (båda inkluderade) via
|
||||
// rutor som canEnter godkänner, eller nil om ingen väg finns.
|
||||
func (l *Level) Path(from, to Point, canEnter func(Point) bool) []Point {
|
||||
prev := map[Point]Point{from: from}
|
||||
queue := []Point{from}
|
||||
for len(queue) > 0 {
|
||||
p := queue[0]
|
||||
queue = queue[1:]
|
||||
if p == to {
|
||||
var path []Point
|
||||
for ; p != from; p = prev[p] {
|
||||
path = append(path, p)
|
||||
}
|
||||
path = append(path, from)
|
||||
for i, j := 0, len(path)-1; i < j; i, j = i+1, j-1 {
|
||||
path[i], path[j] = path[j], path[i]
|
||||
}
|
||||
return path
|
||||
}
|
||||
for _, d := range [...]Point{{1, 0}, {-1, 0}, {0, 1}, {0, -1}} {
|
||||
n := Point{p.X + d.X, p.Y + d.Y}
|
||||
if _, ok := prev[n]; ok || !canEnter(n) {
|
||||
continue
|
||||
}
|
||||
prev[n] = p
|
||||
queue = append(queue, n)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -215,6 +333,37 @@ func (l *Level) Solvable() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// UngatedPlatforms listar perronger som går att nå från start utan att
|
||||
// passera någon spärr. Speldesignen kräver att alla perronger ligger
|
||||
// innanför spärrar, så listan ska vara tom för skeppade banor.
|
||||
func (l *Level) UngatedPlatforms() []Point {
|
||||
start := l.SpawnPoint()
|
||||
seen := map[Point]bool{start: true}
|
||||
queue := []Point{start}
|
||||
for len(queue) > 0 {
|
||||
p := queue[0]
|
||||
queue = queue[1:]
|
||||
for _, d := range [...]Point{{1, 0}, {-1, 0}, {0, 1}, {0, -1}} {
|
||||
n := Point{p.X + d.X, p.Y + d.Y}
|
||||
t := l.At(n.X, n.Y)
|
||||
if seen[n] || !t.Passable() || t == Gate {
|
||||
continue
|
||||
}
|
||||
seen[n] = true
|
||||
queue = append(queue, n)
|
||||
}
|
||||
}
|
||||
var out []Point
|
||||
for _, t := range []Tile{Platform, WrongPlatform} {
|
||||
for _, p := range l.Find(t) {
|
||||
if seen[p] {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Unreachable listar interaktiva rutor (perronger, automater, skyltar) som
|
||||
// inte går att nå – praktiskt som varning när man bygger banor.
|
||||
func (l *Level) Unreachable() []Point {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package level
|
||||
|
||||
import (
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -69,15 +70,20 @@ func TestOutsideGridIsWall(t *testing.T) {
|
||||
|
||||
func TestValidationErrors(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"ojämn rad": strings.Replace(sample, `"#.#.#.#"`, `"#.#.#.##"`, 1),
|
||||
"okänt tecken": strings.Replace(sample, `"#.#.#.#"`, `"#.#.#X#"`, 1),
|
||||
"två starter": strings.Replace(sample, `"#S..#P#"`, `"#S.S#P#"`, 1),
|
||||
"ingen start": strings.Replace(sample, `"#S..#P#"`, `"#...#P#"`, 1),
|
||||
"ingen perrong": strings.Replace(sample, `"#S..#P#"`, `"#S..#.#"`, 1),
|
||||
"inget rätt tåg": strings.Replace(sample, `"correct": true`, `"correct": false`, 1),
|
||||
"tåg vid fel perrong": strings.Replace(sample, `"platform": "P"`, `"platform": "p"`, 1),
|
||||
"saknar id": strings.Replace(sample, `"id": "test"`, `"id": ""`, 1),
|
||||
"trasig json": "{",
|
||||
"ojämn rad": strings.Replace(sample, `"#.#.#.#"`, `"#.#.#.##"`, 1),
|
||||
"okänt tecken": strings.Replace(sample, `"#.#.#.#"`, `"#.#.#X#"`, 1),
|
||||
"två starter": strings.Replace(sample, `"#S..#P#"`, `"#S.S#P#"`, 1),
|
||||
"ingen start": strings.Replace(sample, `"#S..#P#"`, `"#...#P#"`, 1),
|
||||
"ingen perrong": strings.Replace(sample, `"#S..#P#"`, `"#S..#.#"`, 1),
|
||||
"inget rätt tåg": strings.Replace(sample, `"correct": true`, `"correct": false`, 1),
|
||||
"tåg vid fel perrong": strings.Replace(sample, `"platform": "P"`, `"platform": "p"`, 1),
|
||||
"dwell >= interval": strings.Replace(sample, `"correct": true`, `"correct": true, "schedule": {"intervalSec": 10, "dwellSec": 10}`, 1),
|
||||
"negativ offset": strings.Replace(sample, `"correct": true`, `"correct": true, "schedule": {"offsetSec": -1}`, 1),
|
||||
"skylt utanför i-ruta": strings.Replace(sample, `"trains"`, `"signs": [{"x": 1, "y": 1, "text": {"sv": "x"}}], "trains"`, 1),
|
||||
"i-ruta utan skylt": strings.Replace(sample, `"#S..#P#"`, `"#Si.#P#"`, 1),
|
||||
"skylt utan text": strings.Replace(strings.Replace(sample, `"#S..#P#"`, `"#Si.#P#"`, 1), `"trains"`, `"signs": [{"x": 2, "y": 1, "text": {}}], "trains"`, 1),
|
||||
"saknar id": strings.Replace(sample, `"id": "test"`, `"id": ""`, 1),
|
||||
"trasig json": "{",
|
||||
}
|
||||
for name, src := range cases {
|
||||
if _, err := Parse([]byte(src)); err == nil {
|
||||
@@ -133,6 +139,19 @@ func TestAllShippedLevels(t *testing.T) {
|
||||
if u := l.Unreachable(); len(u) > 0 {
|
||||
t.Errorf("onåbara interaktiva rutor: %v", u)
|
||||
}
|
||||
if u := l.UngatedPlatforms(); len(u) > 0 {
|
||||
t.Errorf("perronger som nås utan att passera en spärr: %v – spärrarna måste vara flaskhalsar", u)
|
||||
}
|
||||
for i, tr := range l.Trains {
|
||||
if tr.Track == "" {
|
||||
t.Errorf("tåg %d saknar spårnummer", i)
|
||||
}
|
||||
}
|
||||
for _, sg := range l.Signs {
|
||||
if sg.Text["sv"] == "" || sg.Text["en"] == "" {
|
||||
t.Errorf("skylt (%d,%d) saknar sv/en-text", sg.X, sg.Y)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -169,3 +188,80 @@ func TestListAndLoadFile(t *testing.T) {
|
||||
t.Fatal("saknad fil ska ge fel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleStatus(t *testing.T) {
|
||||
s := Schedule{OffsetSec: 5, IntervalSec: 30, DwellSec: 10}
|
||||
cases := []struct {
|
||||
t float64
|
||||
at bool
|
||||
secs float64
|
||||
}{
|
||||
{0, false, 5}, {5, true, 10}, {14.9, true, 0.1}, {15, false, 20}, {34.9, false, 0.1}, {35, true, 10}, {70, true, 5}, {80, false, 15},
|
||||
}
|
||||
for _, c := range cases {
|
||||
at, secs := s.Status(c.t)
|
||||
if at != c.at || math.Abs(secs-c.secs) > 1e-9 {
|
||||
t.Errorf("t=%v: at=%v secs=%.2f, vill ha at=%v secs=%.2f", c.t, at, secs, c.at, c.secs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleAndFareDefaults(t *testing.T) {
|
||||
l := mustParse(t, sample)
|
||||
sc := l.Trains[0].Schedule
|
||||
if sc.IntervalSec != DefaultIntervalSec || sc.DwellSec != DefaultDwellSec || sc.OffsetSec != 0 {
|
||||
t.Fatalf("standardtidtabell saknas: %+v", sc)
|
||||
}
|
||||
if l.FareYen != DefaultFareYen {
|
||||
t.Fatalf("standardavgift saknas: %d", l.FareYen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignAt(t *testing.T) {
|
||||
src := strings.Replace(strings.Replace(sample, `"#S..#P#"`, `"#Si.#P#"`, 1),
|
||||
`"trains"`, `"signs": [{"x": 2, "y": 1, "text": {"sv": "Hej"}}], "trains"`, 1)
|
||||
l := mustParse(t, src)
|
||||
if sg := l.SignAt(Point{2, 1}); sg == nil || sg.Text.Get("sv") != "Hej" {
|
||||
t.Fatalf("SignAt = %v", sg)
|
||||
}
|
||||
if l.SignAt(Point{1, 1}) != nil {
|
||||
t.Fatal("ingen skylt på startrutan")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPath(t *testing.T) {
|
||||
l := mustParse(t, sample)
|
||||
path := l.Path(l.SpawnPoint(), Point{5, 1}, l.CanEnter)
|
||||
// S(1,1) -> (1,2) -> (1,3) -> (2,3) -> (3,3) -> (4,3) -> (5,3) -> (5,2) -> (5,1)
|
||||
if len(path) != 9 || path[0] != (Point{1, 1}) || path[len(path)-1] != (Point{5, 1}) {
|
||||
t.Fatalf("path = %v", path)
|
||||
}
|
||||
for i := 1; i < len(path); i++ {
|
||||
dx, dy := path[i].X-path[i-1].X, path[i].Y-path[i-1].Y
|
||||
if dx*dx+dy*dy != 1 {
|
||||
t.Fatalf("steg %d är inte en grannruta: %v -> %v", i, path[i-1], path[i])
|
||||
}
|
||||
}
|
||||
if l.Path(l.SpawnPoint(), Point{0, 0}, l.CanEnter) != nil {
|
||||
t.Fatal("väg in i en vägg ska vara nil")
|
||||
}
|
||||
if p := l.Path(l.SpawnPoint(), l.SpawnPoint(), l.CanEnter); len(p) != 1 {
|
||||
t.Fatalf("väg till sig själv = %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUngatedPlatforms(t *testing.T) {
|
||||
// Perrong utan spärr framför -> rapporteras.
|
||||
l := mustParse(t, sample)
|
||||
if u := l.UngatedPlatforms(); len(u) != 1 || u[0] != (Point{5, 1}) {
|
||||
t.Fatalf("UngatedPlatforms = %v, vill ha [(5,1)]", u)
|
||||
}
|
||||
// Spärr i enda passagen -> inget rapporteras, men banan är fortfarande lösbar.
|
||||
l = mustParse(t, strings.Replace(sample, `"#.#...#"`, `"#.#.G.#"`, 1))
|
||||
if u := l.UngatedPlatforms(); len(u) != 0 {
|
||||
t.Fatalf("UngatedPlatforms = %v, vill ha inga", u)
|
||||
}
|
||||
if !l.Solvable() {
|
||||
t.Fatal("banan ska vara lösbar med spärren passerbar")
|
||||
}
|
||||
}
|
||||
|
||||
138
internal/sim/run.go
Normal file
138
internal/sim/run.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package sim
|
||||
|
||||
import "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
|
||||
|
||||
// Outcome är hur en banomgång står sig.
|
||||
type Outcome int
|
||||
|
||||
// Utfall.
|
||||
const (
|
||||
Ongoing Outcome = iota
|
||||
Won // klev på rätt tåg
|
||||
Lost // klev på fel tåg
|
||||
)
|
||||
|
||||
// Card är tågkortet (IC-kortet). Saldo i yen.
|
||||
type Card struct{ Balance int }
|
||||
|
||||
// CanPay rapporterar om saldot räcker.
|
||||
func (c *Card) CanPay(yen int) bool { return c.Balance >= yen }
|
||||
|
||||
// Pay drar beloppet om det går.
|
||||
func (c *Card) Pay(yen int) bool {
|
||||
if !c.CanPay(yen) {
|
||||
return false
|
||||
}
|
||||
c.Balance -= yen
|
||||
return true
|
||||
}
|
||||
|
||||
// StartBalanceYen är kortets saldo vid banstart tills M6 ger riktig ekonomi.
|
||||
const StartBalanceYen = 2000
|
||||
|
||||
// Run är tillståndet för en pågående bana: spelaren, klockan, kortet och
|
||||
// utfallet. Ren logik – LevelScene ritar den, testerna kör den headless.
|
||||
type Run struct {
|
||||
Level *level.Level
|
||||
Player *Mover
|
||||
Card Card
|
||||
Time float64 // sekunder sedan banstart (styr tidtabellerna)
|
||||
Steps int
|
||||
GatePasses int
|
||||
DeniedAt float64 // när spärren senast nekade (för HUD); < 0 = aldrig
|
||||
Outcome Outcome
|
||||
Boarded *level.Train
|
||||
}
|
||||
|
||||
// NewRun startar banan med spelaren på startrutan.
|
||||
func NewRun(l *level.Level, speed float64) *Run {
|
||||
return &Run{
|
||||
Level: l,
|
||||
Player: NewMover(l.SpawnPoint(), speed),
|
||||
Card: Card{Balance: StartBalanceYen},
|
||||
DeniedAt: -1,
|
||||
}
|
||||
}
|
||||
|
||||
// CanEnter avgör om spelaren får gå in på en ruta: inte vägg, och en spärr
|
||||
// kräver att kortet kan betala avgiften (att gå mellan två spärrutor är gratis).
|
||||
func (r *Run) CanEnter(p level.Point) bool {
|
||||
t := r.Level.At(p.X, p.Y)
|
||||
if !t.Passable() {
|
||||
return false
|
||||
}
|
||||
if t == level.Gate && !r.inGate() && !r.Card.CanPay(r.Level.FareYen) {
|
||||
r.DeniedAt = r.Time
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *Run) inGate() bool {
|
||||
return r.Level.At(r.Player.Tile.X, r.Player.Tile.Y) == level.Gate
|
||||
}
|
||||
|
||||
// Step för banan framåt dt sekunder. want är hållen riktning, board=true
|
||||
// betyder att spelaren försöker kliva på tåget där hen står.
|
||||
func (r *Run) Step(dt float64, want Dir, board bool) {
|
||||
if r.Outcome != Ongoing {
|
||||
return
|
||||
}
|
||||
r.Time += dt
|
||||
before := r.Player.Tile
|
||||
wasGate := r.inGate()
|
||||
r.Steps += r.Player.Step(dt, want, r.CanEnter)
|
||||
if r.Player.Tile != before && r.inGate() && !wasGate {
|
||||
r.Card.Pay(r.Level.FareYen)
|
||||
r.GatePasses++
|
||||
}
|
||||
if board && !r.Player.Moving() {
|
||||
if tr, at, _ := r.PlatformHere(); tr != nil && at {
|
||||
r.Board(tr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PlatformHere returnerar tåget vid perrongen spelaren står på (nil om
|
||||
// spelaren inte står på en perrong), om det är inne, och sekunder kvar
|
||||
// till avgång respektive ankomst.
|
||||
func (r *Run) PlatformHere() (tr *level.Train, atPlatform bool, seconds float64) {
|
||||
t := r.Level.At(r.Player.Tile.X, r.Player.Tile.Y)
|
||||
if t != level.Platform && t != level.WrongPlatform {
|
||||
return nil, false, 0
|
||||
}
|
||||
tr = r.Level.TrainAt(t)
|
||||
if tr == nil {
|
||||
return nil, false, 0
|
||||
}
|
||||
atPlatform, seconds = tr.Schedule.Status(r.Time)
|
||||
return tr, atPlatform, seconds
|
||||
}
|
||||
|
||||
// SignHere returnerar skylten spelaren står stilla på, annars nil.
|
||||
func (r *Run) SignHere() *level.SignText {
|
||||
if r.Player.Moving() {
|
||||
return nil
|
||||
}
|
||||
return r.Level.SignAt(r.Player.Tile)
|
||||
}
|
||||
|
||||
// Board kliver på ett tåg och avgör banan.
|
||||
func (r *Run) Board(tr *level.Train) {
|
||||
r.Boarded = tr
|
||||
if tr.Correct {
|
||||
r.Outcome = Won
|
||||
} else {
|
||||
r.Outcome = Lost
|
||||
}
|
||||
}
|
||||
|
||||
// TrainVisible rapporterar om tåget för en perrongruta står inne just nu.
|
||||
func (r *Run) TrainVisible(t level.Tile) bool {
|
||||
tr := r.Level.TrainAt(t)
|
||||
if tr == nil {
|
||||
return false
|
||||
}
|
||||
at, _ := tr.Schedule.Status(r.Time)
|
||||
return at
|
||||
}
|
||||
182
internal/sim/run_test.go
Normal file
182
internal/sim/run_test.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package sim
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res"
|
||||
)
|
||||
|
||||
const frame = 1.0 / 60
|
||||
|
||||
func loadLevel(t *testing.T, id string) *level.Level {
|
||||
t.Helper()
|
||||
dir, err := res.Path("data", "levels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l, err := level.LoadFile(filepath.Join(dir, id+".json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func dirTo(from, to level.Point) Dir {
|
||||
switch {
|
||||
case to.X > from.X:
|
||||
return Right
|
||||
case to.X < from.X:
|
||||
return Left
|
||||
case to.Y > from.Y:
|
||||
return Down
|
||||
default:
|
||||
return Up
|
||||
}
|
||||
}
|
||||
|
||||
// walk låter en bot följa en väg ruta för ruta, en bildruta i taget.
|
||||
func walk(t *testing.T, r *Run, path []level.Point) {
|
||||
t.Helper()
|
||||
for _, next := range path[1:] {
|
||||
d := dirTo(r.Player.Tile, next)
|
||||
for i := 0; i < 600; i++ {
|
||||
if r.Player.Tile == next && !r.Player.Moving() {
|
||||
break
|
||||
}
|
||||
r.Step(frame, d, false)
|
||||
}
|
||||
if r.Player.Tile != next {
|
||||
t.Fatalf("boten fastnade på %v på väg mot %v", r.Player.Tile, next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// waitForTrain står still tills tåget vid perrongen är inne.
|
||||
func waitForTrain(t *testing.T, r *Run) *level.Train {
|
||||
t.Helper()
|
||||
for i := 0; i < 60*120; i++ {
|
||||
tr, at, _ := r.PlatformHere()
|
||||
if tr == nil {
|
||||
t.Fatalf("står inte på en perrong: %v", r.Player.Tile)
|
||||
}
|
||||
if at {
|
||||
return tr
|
||||
}
|
||||
r.Step(frame, None, false)
|
||||
}
|
||||
t.Fatal("tåget kom aldrig")
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestBotWinsLevel001 är simuleringstestet för M2: en bot går kortaste vägen
|
||||
// från start till rätt perrong i bana 001, passerar spärren, väntar in tåget
|
||||
// och kliver på – helt utan fönster.
|
||||
func TestBotWinsLevel001(t *testing.T) {
|
||||
l := loadLevel(t, "001-flygplatsen")
|
||||
r := NewRun(l, 6)
|
||||
goal := l.Find(level.Platform)[0]
|
||||
path := l.Path(r.Player.Tile, goal, r.CanEnter)
|
||||
if path == nil {
|
||||
t.Fatal("ingen väg till perrongen")
|
||||
}
|
||||
walk(t, r, path)
|
||||
|
||||
if r.Outcome != Ongoing {
|
||||
t.Fatal("banan ska inte vara avgjord innan man kliver på")
|
||||
}
|
||||
r.Step(frame, None, true) // Enter medan tåget kanske inte är inne får inte avgöra fel
|
||||
tr := waitForTrain(t, r)
|
||||
if !tr.Correct {
|
||||
t.Fatal("rätt perrong ska ha rätt tåg")
|
||||
}
|
||||
r.Step(frame, None, true)
|
||||
if r.Outcome != Won || r.Boarded != tr {
|
||||
t.Fatalf("outcome=%v boarded=%v", r.Outcome, r.Boarded)
|
||||
}
|
||||
if r.GatePasses != 1 || r.Card.Balance != StartBalanceYen-l.FareYen {
|
||||
t.Fatalf("spärrpassager=%d saldo=%d (vill ha 1 och %d)", r.GatePasses, r.Card.Balance, StartBalanceYen-l.FareYen)
|
||||
}
|
||||
if r.Steps != len(path)-1 {
|
||||
t.Fatalf("steg=%d, vägen är %d rutor", r.Steps, len(path)-1)
|
||||
}
|
||||
// Efter utfall händer inget mer.
|
||||
before := r.Time
|
||||
r.Step(frame, Right, true)
|
||||
if r.Time != before {
|
||||
t.Fatal("Step ska vara en no-op när banan är avgjord")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotLosesOnWrongTrain(t *testing.T) {
|
||||
l := loadLevel(t, "001-flygplatsen")
|
||||
r := NewRun(l, 6)
|
||||
goal := l.Find(level.WrongPlatform)[0]
|
||||
walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter))
|
||||
tr := waitForTrain(t, r)
|
||||
r.Step(frame, None, true)
|
||||
if r.Outcome != Lost || r.Boarded != tr || tr.Correct {
|
||||
t.Fatalf("outcome=%v boarded=%+v", r.Outcome, r.Boarded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGateNeedsCardBalance(t *testing.T) {
|
||||
l := loadLevel(t, "001-flygplatsen")
|
||||
r := NewRun(l, 6)
|
||||
gate := l.Find(level.Gate)[0]
|
||||
r.Card.Balance = 0
|
||||
if r.CanEnter(gate) {
|
||||
t.Fatal("spärren ska stoppa ett tomt kort")
|
||||
}
|
||||
if r.DeniedAt < 0 {
|
||||
t.Fatal("nekandet ska registreras för HUD:en")
|
||||
}
|
||||
if l.Path(r.Player.Tile, l.Find(level.Platform)[0], r.CanEnter) != nil {
|
||||
t.Fatal("utan saldo ska perrongen vara onåbar")
|
||||
}
|
||||
r.Card.Balance = l.FareYen
|
||||
if !r.CanEnter(gate) {
|
||||
t.Fatal("med exakt avgiften ska spärren släppa igenom")
|
||||
}
|
||||
// Mellan två spärrutor är det gratis: ställ spelaren i spärren.
|
||||
r.Player.Tile, r.Player.Target = gate, gate
|
||||
r.Card.Balance = 0
|
||||
other := level.Point{X: gate.X + 1, Y: gate.Y}
|
||||
if l.At(other.X, other.Y) == level.Gate && !r.CanEnter(other) {
|
||||
t.Fatal("att gå mellan två spärrutor ska inte kosta")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoardOnlyWhenTrainPresent(t *testing.T) {
|
||||
l := loadLevel(t, "001-flygplatsen")
|
||||
r := NewRun(l, 6)
|
||||
goal := l.Find(level.Platform)[0]
|
||||
walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter))
|
||||
// Spola fram till en tidpunkt då tåget är borta.
|
||||
for {
|
||||
_, at, _ := r.PlatformHere()
|
||||
if !at {
|
||||
break
|
||||
}
|
||||
r.Step(frame, None, false)
|
||||
}
|
||||
r.Step(frame, None, true)
|
||||
if r.Outcome != Ongoing {
|
||||
t.Fatal("man ska inte kunna kliva på ett tåg som inte är inne")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignHereOnlyWhenStill(t *testing.T) {
|
||||
l := loadLevel(t, "001-flygplatsen")
|
||||
r := NewRun(l, 6)
|
||||
sign := l.Find(level.Sign)[0]
|
||||
walk(t, r, l.Path(r.Player.Tile, sign, r.CanEnter))
|
||||
if sg := r.SignHere(); sg == nil || sg.Text.Get("sv") == "" {
|
||||
t.Fatal("stilla på skyltrutan ska ge skylten")
|
||||
}
|
||||
r.Step(frame, Left, false)
|
||||
if r.Player.Moving() && r.SignHere() != nil {
|
||||
t.Fatal("i rörelse visas ingen skylt")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user