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>
417 lines
12 KiB
Go
417 lines
12 KiB
Go
// Package level definierar banformatet och ren spellogik kring det
|
||
// (validering, framkomlighet). Paketet importerar inte Ebitengine och
|
||
// kan därför testas utan fönster.
|
||
//
|
||
// Banor lagras som JSON i resources/data/levels/. Rutnätet är en lista av
|
||
// lika långa strängar där varje tecken är en ruta (se Tile-konstanterna).
|
||
package level
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"math"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
)
|
||
|
||
// Tile är ett tecken i rutnätet.
|
||
type Tile byte
|
||
|
||
// Tecknen som får förekomma i ett rutnät.
|
||
const (
|
||
Wall Tile = '#' // vägg, ej passerbar
|
||
Floor Tile = '.' // golv
|
||
Spawn Tile = 'S' // startpunkt (exakt en)
|
||
Platform Tile = 'P' // perrong med ett tåg (målet om tåget är rätt)
|
||
WrongPlatform Tile = 'p' // perrong med fel tåg
|
||
Gate Tile = 'G' // spärr (kräver kort/biljett)
|
||
TicketMachine Tile = 'T' // biljett-/påfyllningsautomat
|
||
Sign Tile = 'i' // skylt
|
||
)
|
||
|
||
var knownTiles = map[Tile]bool{
|
||
Wall: true, Floor: true, Spawn: true, Platform: true, WrongPlatform: true,
|
||
Gate: true, TicketMachine: true, Sign: true,
|
||
}
|
||
|
||
// Passable rapporterar om en ruta går att gå på (utan hänsyn till spärrar).
|
||
func (t Tile) Passable() bool { return t != Wall }
|
||
|
||
// Text är en flerspråkig sträng, t.ex. {"sv": "Flygplatsen", "en": "The Airport", "ja": "空港"}.
|
||
type Text map[string]string
|
||
|
||
// Get hämtar texten på ett språk med fallback till engelska, sedan vilket som helst.
|
||
func (t Text) Get(lang string) string {
|
||
if s, ok := t[lang]; ok {
|
||
return s
|
||
}
|
||
if s, ok := t["en"]; ok {
|
||
return s
|
||
}
|
||
for _, s := range t {
|
||
return s
|
||
}
|
||
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
|
||
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
|
||
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.
|
||
type Point struct{ X, Y int }
|
||
|
||
// Parse avkodar och validerar en bana.
|
||
func Parse(data []byte) (*Level, error) {
|
||
var l Level
|
||
if err := json.Unmarshal(data, &l); err != nil {
|
||
return nil, fmt.Errorf("level: ogiltig JSON: %w", err)
|
||
}
|
||
if err := l.Validate(); err != nil {
|
||
return nil, err
|
||
}
|
||
return &l, nil
|
||
}
|
||
|
||
// Width och Height i rutor.
|
||
func (l *Level) Width() int {
|
||
if len(l.Grid) == 0 {
|
||
return 0
|
||
}
|
||
return len(l.Grid[0])
|
||
}
|
||
func (l *Level) Height() int { return len(l.Grid) }
|
||
|
||
// At returnerar rutan på (x, y); utanför kartan räknas som vägg.
|
||
func (l *Level) At(x, y int) Tile {
|
||
if y < 0 || y >= len(l.Grid) || x < 0 || x >= len(l.Grid[y]) {
|
||
return Wall
|
||
}
|
||
return Tile(l.Grid[y][x])
|
||
}
|
||
|
||
// Find returnerar alla rutor av en viss typ.
|
||
func (l *Level) Find(t Tile) []Point {
|
||
var pts []Point
|
||
for y, row := range l.Grid {
|
||
for x := 0; x < len(row); x++ {
|
||
if Tile(row[x]) == t {
|
||
pts = append(pts, Point{x, y})
|
||
}
|
||
}
|
||
}
|
||
return pts
|
||
}
|
||
|
||
// SpawnPoint returnerar startrutan (valideringen garanterar att den finns).
|
||
func (l *Level) SpawnPoint() Point { return l.Find(Spawn)[0] }
|
||
|
||
// Validate kontrollerar att banan är rimlig: rektangulär, bara kända tecken,
|
||
// exakt en start, minst en perrong, och att varje tåg står vid ett
|
||
// perrong-tecken som finns i rutnätet.
|
||
func (l *Level) Validate() error {
|
||
if l.ID == "" {
|
||
return fmt.Errorf("level: saknar id")
|
||
}
|
||
if len(l.Grid) == 0 {
|
||
return fmt.Errorf("level %s: tomt rutnät", l.ID)
|
||
}
|
||
w := len(l.Grid[0])
|
||
spawns, platforms := 0, 0
|
||
for y, row := range l.Grid {
|
||
if len(row) != w {
|
||
return fmt.Errorf("level %s: rad %d har längd %d, förväntade %d", l.ID, y, len(row), w)
|
||
}
|
||
for x := 0; x < len(row); x++ {
|
||
t := Tile(row[x])
|
||
if !knownTiles[t] {
|
||
return fmt.Errorf("level %s: okänt tecken %q på (%d,%d)", l.ID, row[x], x, y)
|
||
}
|
||
switch t {
|
||
case Spawn:
|
||
spawns++
|
||
case Platform, WrongPlatform:
|
||
platforms++
|
||
}
|
||
}
|
||
}
|
||
if spawns != 1 {
|
||
return fmt.Errorf("level %s: förväntade exakt en start (S), hittade %d", l.ID, spawns)
|
||
}
|
||
if platforms == 0 {
|
||
return fmt.Errorf("level %s: saknar perronger (P/p)", l.ID)
|
||
}
|
||
if len(l.Trains) == 0 {
|
||
return fmt.Errorf("level %s: saknar tåg", l.ID)
|
||
}
|
||
correct := 0
|
||
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)
|
||
}
|
||
if !strings.Contains(strings.Join(l.Grid, ""), tr.Platform) {
|
||
return fmt.Errorf("level %s: tåg %d står vid perrong %q som inte finns i rutnätet", l.ID, i, tr.Platform)
|
||
}
|
||
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
|
||
}
|
||
|
||
// Reachable returnerar alla rutor som nås från start via passerbara rutor
|
||
// (4-riktningars BFS). Spärrar räknas som passerbara.
|
||
func (l *Level) Reachable() map[Point]bool {
|
||
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}
|
||
if seen[n] || !l.At(n.X, n.Y).Passable() {
|
||
continue
|
||
}
|
||
seen[n] = true
|
||
queue = append(queue, n)
|
||
}
|
||
}
|
||
return seen
|
||
}
|
||
|
||
// Solvable rapporterar om minst en perrong med ett rätt tåg kan nås från start.
|
||
func (l *Level) Solvable() bool {
|
||
reach := l.Reachable()
|
||
for _, tr := range l.Trains {
|
||
if !tr.Correct {
|
||
continue
|
||
}
|
||
for _, p := range l.Find(Tile(tr.Platform[0])) {
|
||
if reach[p] {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
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 {
|
||
reach := l.Reachable()
|
||
var out []Point
|
||
for _, t := range []Tile{Platform, WrongPlatform, TicketMachine, Sign, Gate} {
|
||
for _, p := range l.Find(t) {
|
||
if !reach[p] {
|
||
out = append(out, p)
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// CanEnter rapporterar om rutan går att gå in på (för sim.Mover).
|
||
func (l *Level) CanEnter(p Point) bool { return l.At(p.X, p.Y).Passable() }
|
||
|
||
// TrainAt returnerar tåget vid en perrongruta, eller nil.
|
||
func (l *Level) TrainAt(t Tile) *Train {
|
||
for i := range l.Trains {
|
||
if Tile(l.Trains[i].Platform[0]) == t {
|
||
return &l.Trains[i]
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// LoadFile läser och validerar en bana från disk.
|
||
func LoadFile(path string) (*Level, error) {
|
||
b, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("level: %w", err)
|
||
}
|
||
return Parse(b)
|
||
}
|
||
|
||
// List returnerar id:n (filnamn utan .json) för alla banor i en mapp, sorterade.
|
||
func List(dir string) ([]string, error) {
|
||
files, err := filepath.Glob(filepath.Join(dir, "*.json"))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
ids := make([]string, 0, len(files))
|
||
for _, f := range files {
|
||
ids = append(ids, strings.TrimSuffix(filepath.Base(f), ".json"))
|
||
}
|
||
sort.Strings(ids)
|
||
return ids, nil
|
||
}
|