// 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" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/nav" ) // 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 (vilket tåg som är rätt avgör tåget, inte tecknet) WrongPlatform Tile = 'p' // ytterligare perrong (namnet är historiskt: i de första banorna stod fel tåg här) Platform2 Tile = 'Q' // fler perronger för banor med flera giltiga vägar och fällor Platform3 Tile = 'q' Platform4 Tile = 'R' Platform5 Tile = 'r' Gate Tile = 'G' // spärr (kräver kort/biljett) TicketMachine Tile = 'T' // biljett-/påfyllningsautomat Sign Tile = 'i' // skylt Toilet Tile = 'W' // toalett (sällskapets toamätare) Kiosk Tile = 'K' // kiosk/automat för mat och dryck StairsUp Tile = 'U' // trappa upp: leder till 'D' på samma x,y en våning upp StairsDown Tile = 'D' // trappa ner: leder till 'U' på samma x,y en våning ner LaneLeft Tile = '<' // fil för gång uppåt/åt vänster (bredare gångar: håll till vänster) LaneRight Tile = '>' // fil för gång neråt/åt höger ) // PlatformTiles är alla perrongtecken, i den ordning tåg brukar listas. var PlatformTiles = []Tile{Platform, WrongPlatform, Platform2, Platform3, Platform4, Platform5} var knownTiles = map[Tile]bool{ Wall: true, Floor: true, Spawn: true, Platform: true, WrongPlatform: true, Platform2: true, Platform3: true, Platform4: true, Platform5: true, Gate: true, TicketMachine: true, Sign: true, Toilet: true, Kiosk: true, StairsUp: true, StairsDown: true, LaneLeft: true, LaneRight: true, } // IsPlatform rapporterar om rutan är en perrong (något av PlatformTiles). func (t Tile) IsPlatform() bool { for _, p := range PlatformTiles { if t == p { return true } } return false } // IsLane rapporterar om rutan är en markerad fil. func (t Tile) IsLane() bool { return t == LaneLeft || t == LaneRight } // IsStairs rapporterar om rutan är en trappa. func (t Tile) IsStairs() bool { return t == StairsUp || t == StairsDown } // 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 MaxParty = 5 DefaultRideStops = 3 ) // 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 } // Phase är var i cykeln ett tåg befinner sig. type Phase int // Tågfaser i ordning över en cykel. const ( Away Phase = iota // borta Approaching // på väg in: melodi, lampor, vind (ApproachSec före ankomst) Arriving // rullar in och bromsar (ArriveSec), dörrarna stängda Dwelling // står inne med öppna dörrar Closing // strax avgång: dörrsignal (sista WarnSec av uppehållet) Leaving // rullar ut (LeaveSec efter avgång) ) func (p Phase) String() string { return [...]string{"away", "approaching", "arriving", "dwelling", "closing", "leaving"}[p] } // Fasernas längder i sekunder. const ( ApproachSec = 5.0 ArriveSec = 1.5 WarnSec = 3.0 LeaveSec = 1.5 MinDwellSec = ArriveSec + WarnSec + 1 // minst en sekund öppna dörrar utan signal ) // Phase returnerar fasen vid tiden t och hur långt in i fasen man är (0..1). func (s Schedule) Phase(t float64) (Phase, float64) { var phi float64 // tid sedan senaste ankomst, kan vara negativ före första if t < s.OffsetSec { phi = t - s.OffsetSec if -phi <= ApproachSec { return Approaching, 1 + phi/ApproachSec } return Away, 0 } else { phi = math.Mod(t-s.OffsetSec, s.IntervalSec) } switch { case phi < ArriveSec: return Arriving, phi / ArriveSec case phi < s.DwellSec-WarnSec: return Dwelling, (phi - ArriveSec) / max(0.001, s.DwellSec-WarnSec-ArriveSec) case phi < s.DwellSec: return Closing, (phi - (s.DwellSec - WarnSec)) / WarnSec case phi < s.DwellSec+LeaveSec: return Leaving, (phi - s.DwellSec) / LeaveSec case phi >= s.IntervalSec-ApproachSec: return Approaching, (phi - (s.IntervalSec - ApproachSec)) / ApproachSec default: return Away, (phi - s.DwellSec - LeaveSec) / max(0.001, s.IntervalSec-ApproachSec-s.DwellSec-LeaveSec) } } // Boardable rapporterar om dörrarna är öppna så att man kan kliva på. func (s Schedule) Boardable(t float64) bool { p, _ := s.Phase(t) return p == Dwelling || p == Closing } // Present rapporterar om tåget syns vid perrongen (rullar in, står, rullar ut). func (s Schedule) Present(t float64) bool { p, _ := s.Phase(t) return p == Arriving || p == Dwelling || p == Closing || p == Leaving } // 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 (räknas ut från nätet när banan har station/to) LineID string `json:"lineId"` // linjen i linjenätet (resources/data/network.json) Terminus string `json:"terminus"` // en station i färdriktningen (nätets id) – avgör riktningen Crowded bool `json:"crowded"` // fullt tåg: minispel för att få plats (gäller alla vagnar) Cars int `json:"cars"` // antal vagnar (0 = DefaultCars) WomenOnlyCar int `json:"womenOnlyCar"` // 1-baserat vagnnummer för kvinnovagn, 0 = ingen WomenOnlyFrom int `json:"womenOnlyFrom"` // kvinnovagn gäller från den här bantiden (s); 0 och To=0 = alltid WomenOnlyTo int `json:"womenOnlyTo"` // …till den här bantiden (s) Schedule Schedule `json:"schedule"` Verdict nav.Verdict `json:"-"` // nätets bedömning (Unjudged när banan inte är kopplad) } // DefaultCars och gränser för vagnar. const ( DefaultCars = 3 MaxCars = 8 ) // WomenOnlyActive rapporterar om kvinnovagnen gäller vid bantiden t. func (tr *Train) WomenOnlyActive(t float64) bool { if tr.WomenOnlyCar == 0 { return false } if tr.WomenOnlyFrom == 0 && tr.WomenOnlyTo == 0 { return true } return t >= float64(tr.WomenOnlyFrom) && t < float64(tr.WomenOnlyTo) } // SignText är texten på en skyltruta ('i'). type SignText struct { X int `json:"x"` Y int `json:"y"` Z int `json:"z"` // våning (0 = första i floors) Text Text `json:"text"` } // Storey är en våning: ett rutnät med namn ("B1", "1F", "2F"). type Storey struct { Name string `json:"name"` Grid []string `json:"grid"` Floors []Storey `json:"floors,omitempty"` // flervåningsbana; alla våningar lika stora } // Rewards är vad en klarad bana ger utöver poäng. type Rewards struct { Items []string `json:"items"` // item-id:n (t.ex. skins) som delas ut första gången SkillPoints int `json:"skillPoints"` // extra skillpoäng första gången } // Level är en bana. type Level struct { ID string `json:"id"` Kind string `json:"kind"` // "main" (standard) eller "side" Order int `json:"order"` // huvudbanornas ordning på bankartan (1, 2, …); sidobanor ärver förälderns Parent string `json:"parent"` // sidobanans huvudbana – låses upp när den är klarad Next string `json:"next"` // nästa delbana (kind "leg") i en resa med byten: när man klivit av här fortsätter banan där Rewards Rewards `json:"rewards"` Shop []string `json:"shop"` // items som kiosken säljer på banan (tom = seedat urval) Crowd int `json:"crowd"` // folk på stationen 0–3 (NPC:er) Clutter int `json:"clutter"` // visuell röra 0–3 (affischer, lockskyltar) Noise int `json:"noise"` // ljudröra 0–3 (bakgrundsprat) Name Text `json:"name"` Difficulty int `json:"difficulty"` // 1 (lätt) … 5 (svår) Destination Text `json:"destination"` // dit spelaren ska Station string `json:"station"` // stationen i linjenätet (nätets id); tomt = banan är inte kopplad till nätet To string `json:"to"` // resmålets station i nätet TimeLimitSec int `json:"timeLimitSec"` // 0 = ingen tidsgräns ParSec int `json:"parSec"` // "bra tid" för tre stjärnor (0 = räknas ut från kortaste vägen) Party int `json:"party"` // antal i sällskapet inkl. spelaren (0 = 1) TicketRequired bool `json:"ticketRequired"` // spärren kräver pappersbiljett (köps i automaten) i stället för kort RideStops int `json:"rideStops"` // antal stationer på tågresan (0 = DefaultRideStops) MaxMissedTrains int `json:"maxMissedTrains"` // >0: fler missade tåg än så förlorar banan IndividualCards bool `json:"individualCards"` // alla i sällskapet har egna kort (och biljetter) som spärren kräver SeatSelection bool `json:"seatSelection"` // biljettköpet fortsätter med sittplatsval efter sällskapets önskemål FareYen int `json:"fareYen"` // vad spärren drar från kortet (0 = standard) Grid []string `json:"grid,omitempty"` // envåningsbana (kompatibilitet) – blir Floors[0] Floors []Storey `json:"floors,omitempty"` // flervåningsbana; alla våningar lika stora Trains []Train `json:"trains"` Signs []SignText `json:"signs"` Net *nav.Network `json:"-"` // linjenätet banan är kopplad till (nil om inte) } // Point är en rutkoordinat: x, y och våning z. type Point struct{ X, Y, Z int } // Up/Down ger rutan rakt ovanför/under (via trappa). func (p Point) Up() Point { return Point{X: p.X, Y: p.Y, Z: p.Z + 1} } func (p Point) Down() Point { return Point{X: p.X, Y: p.Y, Z: p.Z - 1} } // dirs är de fyra grannriktningarna på samma våning. var dirs = [...]Point{{X: 1}, {X: -1}, {Y: 1}, {Y: -1}} // 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 (alla våningar är lika stora). func (l *Level) Width() int { if len(l.Floors) == 0 || len(l.Floors[0].Grid) == 0 { return 0 } return len(l.Floors[0].Grid[0]) } func (l *Level) Height() int { if len(l.Floors) == 0 { return 0 } return len(l.Floors[0].Grid) } // NumFloors är antalet våningar. func (l *Level) NumFloors() int { return len(l.Floors) } // FloorName är våningens namn ("1F" om det saknas). func (l *Level) FloorName(z int) string { if z < 0 || z >= len(l.Floors) || l.Floors[z].Name == "" { return "1F" } return l.Floors[z].Name } // At returnerar rutan på p; utanför kartan (eller våningarna) räknas som vägg. func (l *Level) At(p Point) Tile { return l.FloorAt(p.Z, p.X, p.Y) } // FloorAt returnerar rutan (x, y) på våning z; utanför kartan räknas som vägg. func (l *Level) FloorAt(z, x, y int) Tile { if z < 0 || z >= len(l.Floors) { return Wall } g := l.Floors[z].Grid if y < 0 || y >= len(g) || x < 0 || x >= len(g[y]) { return Wall } return Tile(g[y][x]) } // Find returnerar alla rutor av en viss typ på alla våningar. func (l *Level) Find(t Tile) []Point { var pts []Point for z, f := range l.Floors { for y, row := range f.Grid { for x := 0; x < len(row); x++ { if Tile(row[x]) == t { pts = append(pts, Point{X: x, Y: y, Z: z}) } } } } return pts } // Neighbors är rutorna man kan gå till från p: de fyra grannarna på samma // våning samt trappans andra ände. Passerbarhet avgörs av anroparen. func (l *Level) Neighbors(p Point) []Point { out := make([]Point, 0, 5) for _, d := range dirs { out = append(out, Point{X: p.X + d.X, Y: p.Y + d.Y, Z: p.Z}) } switch l.At(p) { case StairsUp: out = append(out, p.Up()) case StairsDown: out = append(out, p.Down()) } return out } // 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") } switch l.Kind { case "": l.Kind = "main" case "main", "side", "leg": default: return fmt.Errorf("level %s: kind måste vara main, side eller leg", l.ID) } if l.Kind == "side" && l.Parent == "" { return fmt.Errorf("level %s: sidobana utan parent", l.ID) } if l.Kind == "main" && l.Parent != "" { return fmt.Errorf("level %s: huvudbana med parent", l.ID) } if l.Order < 0 { return fmt.Errorf("level %s: negativ order", l.ID) } if len(l.Floors) == 0 { if len(l.Grid) == 0 { return fmt.Errorf("level %s: tomt rutnät", l.ID) } l.Floors = []Storey{{Name: "1F", Grid: l.Grid}} } else if len(l.Grid) > 0 { return fmt.Errorf("level %s: ange antingen grid eller floors, inte båda", l.ID) } l.Grid = l.Floors[0].Grid // kompatibilitet: Grid = första våningen if len(l.Floors[0].Grid) == 0 { return fmt.Errorf("level %s: tom första våning", l.ID) } w, h := len(l.Floors[0].Grid[0]), len(l.Floors[0].Grid) spawns, platforms := 0, 0 for z, f := range l.Floors { if len(f.Grid) != h { return fmt.Errorf("level %s: våning %d har %d rader, förväntade %d", l.ID, z, len(f.Grid), h) } for y, row := range f.Grid { if len(row) != w { return fmt.Errorf("level %s: våning %d rad %d har längd %d, förväntade %d", l.ID, z, 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,%d)", l.ID, row[x], x, y, z) } if t.IsPlatform() { platforms++ } switch t { case Spawn: spawns++ case StairsUp: if l.FloorAt(z+1, x, y) != StairsDown { return fmt.Errorf("level %s: trappa upp på (%d,%d,%d) saknar trappa ner ovanför", l.ID, x, y, z) } case StairsDown: if l.FloorAt(z-1, x, y) != StairsUp { return fmt.Errorf("level %s: trappa ner på (%d,%d,%d) saknar trappa upp under", l.ID, x, y, z) } } } } } 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/Q/q/R/r)", 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]).IsPlatform() { return fmt.Errorf("level %s: tåg %d har ogiltig perrong %q", l.ID, i, tr.Platform) } if len(l.Find(Tile(tr.Platform[0]))) == 0 { 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++ } if tr.Cars == 0 { tr.Cars = DefaultCars } if tr.Cars < 1 || tr.Cars > MaxCars { return fmt.Errorf("level %s: tåg %d har %d vagnar (1–%d)", l.ID, i, tr.Cars, MaxCars) } if tr.WomenOnlyCar < 0 || tr.WomenOnlyCar > tr.Cars || tr.WomenOnlyFrom < 0 || tr.WomenOnlyTo < tr.WomenOnlyFrom { return fmt.Errorf("level %s: tåg %d har ogiltig kvinnovagn (%d av %d, %d–%d s)", l.ID, i, tr.WomenOnlyCar, tr.Cars, tr.WomenOnlyFrom, tr.WomenOnlyTo) } 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 sc.DwellSec < MinDwellSec || sc.IntervalSec-sc.DwellSec < LeaveSec+ApproachSec+1 { return fmt.Errorf("level %s: tåg %d: uppehållet måste vara ≥ %.1f s och tiden borta ≥ %.1f s för tågfaserna", l.ID, i, MinDwellSec, LeaveSec+ApproachSec+1) } } if correct == 0 && !l.Networked() { 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 || l.ParSec < 0 || l.Party < 0 { return fmt.Errorf("level %s: negativ avgift/partid/sällskap", l.ID) } if l.Party == 0 { l.Party = 1 } if l.RideStops == 0 { l.RideStops = DefaultRideStops } if l.RideStops < 2 || l.RideStops > 8 { return fmt.Errorf("level %s: rideStops måste vara 2–8", l.ID) } if l.TicketRequired && len(l.Find(TicketMachine)) == 0 { return fmt.Errorf("level %s: biljettkrav utan biljettautomat (T)", l.ID) } if l.SeatSelection && !l.TicketRequired { return fmt.Errorf("level %s: sittplatsval kräver biljettbana", l.ID) } if l.MaxMissedTrains < 0 || l.TimeLimitSec < 0 { return fmt.Errorf("level %s: negativ tidsgräns/maxantal missade tåg", l.ID) } for name, v := range map[string]int{"crowd": l.Crowd, "clutter": l.Clutter, "noise": l.Noise} { if v < 0 || v > 3 { return fmt.Errorf("level %s: %s måste vara 0–3", l.ID, name) } } if l.Party > MaxParty { return fmt.Errorf("level %s: sällskapet får vara högst %d", l.ID, MaxParty) } // 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{X: sg.X, Y: sg.Y, Z: sg.Z} if l.At(p) != Sign { return fmt.Errorf("level %s: skylt %d på (%d,%d,%d) står inte på en skyltruta", l.ID, i, p.X, p.Y, p.Z) } 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,%d) saknar text i \"signs\"", l.ID, p.X, p.Y, p.Z) } } 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 && l.Signs[i].Z == p.Z { 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 _, n := range l.Neighbors(p) { 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 _, n := range l.Neighbors(p) { if seen[n] || !l.At(n).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 _, n := range l.Neighbors(p) { t := l.At(n) 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 append(append([]Tile(nil), PlatformTiles...), TicketMachine, Sign, Gate, Toilet, Kiosk) { for _, p := range l.Find(t) { if !reach[p] { out = append(out, p) } } } return out } // BoardText är avgångstavlans text på japanska, t.ex. "2番線 成田エクスプレス 新宿方面". func (tr *Train) BoardText() string { return tr.Track + "番線 " + tr.Line.Get("ja") + " " + tr.Destination.Get("ja") + "方面" } // TrackTile är rutan "bortom perrongkanten" där rälsen går: första väggruta // nedanför, till höger, till vänster eller ovanför perrongen. horizontal // anger om rälsen (och tågets rörelse) går i sidled. func (l *Level) TrackTile(p Point) (tile Point, horizontal bool) { for _, d := range [...]Point{{X: 0, Y: 1}, {X: 1, Y: 0}, {X: -1, Y: 0}, {X: 0, Y: -1}} { n := Point{X: p.X + d.X, Y: p.Y + d.Y, Z: p.Z} if l.At(n) == Wall { return n, d.Y != 0 } } return p, true } // CorrectPlatform returnerar en perrongruta med rätt tåg (valideringen // garanterar att det finns minst ett rätt tåg vid en befintlig perrong). func (l *Level) CorrectPlatform() Point { for _, tr := range l.Trains { if tr.Correct { if pts := l.Find(Tile(tr.Platform[0])); len(pts) > 0 { return pts[0] } } } return l.SpawnPoint() } // 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).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. Är banan kopplad till // linjenätet läses nätet från NetworkPath bredvid banmappen. func LoadFile(path string) (*Level, error) { b, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("level: %w", err) } l, err := Parse(b) if err != nil { return nil, err } if !l.Networked() { return l, nil } net, err := nav.LoadFile(NetworkPath(filepath.Dir(path))) if err != nil { return nil, fmt.Errorf("level %s: %w", l.ID, err) } if err := l.Annotate(net); err != nil { return nil, err } return l, nil } // NetworkPath är linjenätets fil för en banmapp: ../network.json. func NetworkPath(levelsDir string) string { return filepath.Join(levelsDir, "..", "network.json") } // Networked rapporterar om banan pekar på linjenätet (station/to satta). func (l *Level) Networked() bool { return l.Station != "" || l.To != "" } // Annotate kopplar banan till linjenätet: varje tåg bedöms av nätet utifrån // lineId och terminus, och correct räknas ut därifrån i stället för att // skrivas i banfilen. Skyltläget tillåter 1–nav.MaxRoutes rätta tåg. func (l *Level) Annotate(net *nav.Network) error { if !l.Networked() { return nil } if net == nil { return fmt.Errorf("level %s: pekar på linjenätet men inget nät finns", l.ID) } if net.Station(l.Station) == nil || net.Station(l.To) == nil { return fmt.Errorf("level %s: station %q eller resmål %q finns inte i nätet", l.ID, l.Station, l.To) } if want := net.StationName(l.To, "ja"); l.Destination.Get("ja") != want { return fmt.Errorf("level %s: destination %q matchar inte nätets %q", l.ID, l.Destination.Get("ja"), want) } l.Net = net correct := 0 for i := range l.Trains { tr := &l.Trains[i] ln := net.Line(tr.LineID) if ln == nil { return fmt.Errorf("level %s: tåg %d har okänd linje %q", l.ID, i, tr.LineID) } if tr.Line.Get("ja") != ln.Name["ja"] { return fmt.Errorf("level %s: tåg %d heter %q men linjen %s heter %q", l.ID, i, tr.Line.Get("ja"), tr.LineID, ln.Name["ja"]) } v := net.Judge(tr.LineID, l.Station, tr.Terminus, l.To) if v == nav.Unjudged { return fmt.Errorf("level %s: tåg %d (%s mot %q) går inte att bedöma i nätet", l.ID, i, tr.LineID, tr.Terminus) } tr.Verdict = v tr.Correct = v == nav.Direct if tr.Correct { correct++ } } if correct < 1 || correct > nav.MaxRoutes { return fmt.Errorf("level %s: %d rätta tåg, vill ha 1–%d", l.ID, correct, nav.MaxRoutes) } return nil } // IsSide rapporterar om banan är en sidobana. func (l *Level) IsSide() bool { return l.Kind == "side" } // IsLeg rapporterar om banan är en delbana i en resa med byten (nås bara // via en annan banas next, visas inte på bankartan). func (l *Level) IsLeg() bool { return l.Kind == "leg" } // ChainOf ger resan som börjar med banan id: banan själv följd av dess // delbanor i ordning. nil om id är okänt. func ChainOf(all []*Level, id string) []*Level { byID := map[string]*Level{} for _, l := range all { byID[l.ID] = l } var out []*Level for l := byID[id]; l != nil && len(out) <= len(all); l = byID[l.Next] { out = append(out, l) if l.Next == "" { break } } return out } // FinalOf är resans sista delbana (banan själv utan byten) – dess // destination är resans resmål. func FinalOf(all []*Level, l *Level) *Level { if c := ChainOf(all, l.ID); len(c) > 0 { return c[len(c)-1] } return l } // ValidateChains kontrollerar resorna med byten: next finns och är en // delbana, varje delbana används exakt en gång, inga cykler, och på // nätkopplade banor börjar nästa delbana där den förra slutade. func ValidateChains(all []*Level) error { byID := map[string]*Level{} for _, l := range all { byID[l.ID] = l } refs := map[string]int{} for _, l := range all { if l.Next == "" { continue } n := byID[l.Next] if n == nil { return fmt.Errorf("level %s: next %q finns inte", l.ID, l.Next) } if !n.IsLeg() { return fmt.Errorf("level %s: next %q måste ha kind leg", l.ID, l.Next) } refs[n.ID]++ if l.Networked() && n.Networked() && n.Station != l.To { return fmt.Errorf("level %s: bytet hänger inte ihop – slutar i %q men %s börjar i %q", l.ID, l.To, n.ID, n.Station) } } for _, l := range all { if l.IsLeg() && refs[l.ID] != 1 { return fmt.Errorf("level %s: delbanan refereras av %d banor, ska vara exakt 1", l.ID, refs[l.ID]) } } for _, l := range all { if l.IsLeg() { continue } seen := map[string]bool{} for c := l; c != nil && c.Next != ""; c = byID[c.Next] { if seen[c.ID] { return fmt.Errorf("level %s: resan går i cirkel", l.ID) } seen[c.ID] = true } } return nil } // LoadAll läser alla banor i en mapp, sorterade på id, och kopplar dem som // pekar på linjenätet till ../network.json (läses en gång). func LoadAll(dir string) ([]*Level, error) { ids, err := List(dir) if err != nil { return nil, err } var net *nav.Network if _, statErr := os.Stat(NetworkPath(dir)); statErr == nil { if net, err = nav.LoadFile(NetworkPath(dir)); err != nil { return nil, err } } var out []*Level for _, id := range ids { b, err := os.ReadFile(filepath.Join(dir, id+".json")) if err != nil { return nil, fmt.Errorf("level: %w", err) } l, err := Parse(b) if err != nil { return nil, err } if err := l.Annotate(net); err != nil { return nil, err } out = append(out, l) } if err := ValidateChains(out); err != nil { return nil, err } return out, nil } // 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 }