M2: tåg med tidtabell, spärrar med kort, skyltar, vinst/förlust
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:
2026-10-23 19:36:45 +02:00
parent 3c00c7f356
commit a5a660b32f
26 changed files with 1031 additions and 135 deletions

View File

@@ -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)
}
}