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