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

@@ -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 {

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

View File

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

View File

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