M9: navigeringslägen, linjenät och terminal
All checks were successful
release / build-release (push) Successful in 1m34s
All checks were successful
release / build-release (push) Successful in 1m34s
internal/nav med resources/data/network.json (76 stationer, 11 linjer, Yamanote som ringlinje). Tåg bedöms från lineId + terminus mot banans station/to: rätt, fel håll, för långt bort (fler än 12 stationer) eller ingen trafik; correct räknas ut för nätkopplade banor, 1-4 rätta tåg. Tågresan visar linjens riktiga stationer. Nya förlustorsaker "Fel håll" och "För långt bort". Listläge (nästa tåg nere till vänster) och skyltläge (+50 % poäng) växlas med N på bankartan och sparas; -mode. Terminalen: biljettautomaten visar linjetavlan på nätbanor. Perronger P p Q q R r. Bana 006 Ikebukuro med fyra perronger. Banorna 001-005 kopplade till nätet. Skript: waitover och pick calm; poängraden radbryts; skärmdumpar uppdaterade. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -80,7 +80,9 @@ func LoadAssets() (*Assets, error) {
|
||||
}
|
||||
a.Tiles[t] = sub(i)
|
||||
}
|
||||
a.Tiles[level.WrongPlatform] = a.Tiles[level.Platform] // samma utseende; tåget avgör
|
||||
for _, t := range level.PlatformTiles[1:] {
|
||||
a.Tiles[t] = a.Tiles[level.Platform] // samma utseende; tåget avgör
|
||||
}
|
||||
a.Train = sub(trainIndex)
|
||||
a.Rails = sub(railsIndex)
|
||||
for i := 0; i < posterCount; i++ {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/nav"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/progress"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
|
||||
@@ -45,6 +46,7 @@ type Game struct {
|
||||
Catalog *progress.Catalog
|
||||
Levels []*level.Level // alla banor, sorterade på id
|
||||
Audio *Audio // nil = tyst
|
||||
Mode nav.Mode // navigeringsläge (= State.NavMode), växlas på bankartan
|
||||
|
||||
savePath string // "" = spara inte
|
||||
fixedSeed uint64 // >0: alla banor startar med denna seed (tester/skärmdumpar)
|
||||
@@ -69,6 +71,7 @@ type Options struct {
|
||||
Mute bool // inget ljud
|
||||
SavePath string // sparfil ("" = standardplatsen)
|
||||
NoSave bool // läs sparfilen men skriv aldrig
|
||||
Mode string // navigeringsläge från start: "list" eller "signs" (tomt = sparfilens)
|
||||
}
|
||||
|
||||
// New laddar resurser, sparfil och bygger spelet.
|
||||
@@ -109,6 +112,10 @@ func New(opt Options) (*Game, error) {
|
||||
}
|
||||
g.State = st
|
||||
g.Know, g.Wallet, g.Profile, g.Progress = st.Know, st.Wallet, st.Profile, st.Progress
|
||||
g.Mode = nav.ParseMode(st.NavMode)
|
||||
if opt.Mode != "" {
|
||||
g.Mode = nav.ParseMode(opt.Mode)
|
||||
}
|
||||
if !opt.NoSave {
|
||||
g.savePath = path
|
||||
}
|
||||
@@ -233,10 +240,21 @@ func (g *Game) runOptions(id string, seed rng.Seed) sim.RunOptions {
|
||||
return sim.RunOptions{
|
||||
Dict: g.Assets.Dict, Know: g.Know, Wallet: g.Wallet, Debuffs: g.Profile.Debuffs, Seed: seed,
|
||||
Extra: eff.Stats, FogRadius: eff.FogRadius, Landmarks: eff.Landmarks, FreeGates: eff.FreeGates,
|
||||
RevealAll: eff.Reveal[id], InfiniteBattery: eff.Battery,
|
||||
RevealAll: eff.Reveal[id], InfiniteBattery: eff.Battery, Mode: g.Mode,
|
||||
}
|
||||
}
|
||||
|
||||
// toggleMode växlar navigeringsläge och sparar.
|
||||
func (g *Game) toggleMode() {
|
||||
if g.Mode == nav.List {
|
||||
g.Mode = nav.Signs
|
||||
} else {
|
||||
g.Mode = nav.List
|
||||
}
|
||||
g.State.NavMode = g.Mode.String()
|
||||
g.Save()
|
||||
}
|
||||
|
||||
// startLevel startar (eller återupptar) en bana och sparar seeden så att en
|
||||
// laddad omgång ger samma slump.
|
||||
func (g *Game) startLevel(id string) (*LevelScene, error) {
|
||||
|
||||
@@ -126,6 +126,9 @@ func shopJustPressed() bool { return virtual.pressed["b"] || inpututil.IsKeyJust
|
||||
// phoneJustPressed: P = kortet i telefonen av/på (meny).
|
||||
func phoneJustPressed() bool { return virtual.pressed["p"] || inpututil.IsKeyJustPressed(ebiten.KeyP) }
|
||||
|
||||
// modeJustPressed: N växlar navigeringsläge (lista/skyltar) på bankartan.
|
||||
func modeJustPressed() bool { return virtual.pressed["n"] || inpututil.IsKeyJustPressed(ebiten.KeyN) }
|
||||
|
||||
// mapJustPressed: M eller gamepadens vänstra mittknapp (Back/Select) = minikartan av/på.
|
||||
func mapJustPressed() bool {
|
||||
return virtual.pressed["m"] || inpututil.IsKeyJustPressed(ebiten.KeyM) || gamepadJustPressed(ebiten.StandardGamepadButtonCenterLeft)
|
||||
|
||||
@@ -24,6 +24,10 @@ var minimapColors = map[level.Tile]color.RGBA{
|
||||
level.Spawn: {99, 199, 77, 255},
|
||||
level.Platform: {254, 174, 52, 255},
|
||||
level.WrongPlatform: {254, 174, 52, 255},
|
||||
level.Platform2: {254, 174, 52, 255},
|
||||
level.Platform3: {254, 174, 52, 255},
|
||||
level.Platform4: {254, 174, 52, 255},
|
||||
level.Platform5: {254, 174, 52, 255},
|
||||
level.Gate: {44, 232, 245, 255},
|
||||
level.TicketMachine: {247, 118, 34, 255},
|
||||
level.Sign: {90, 140, 230, 255},
|
||||
@@ -111,7 +115,7 @@ func (s *LevelScene) drawMinimap(g *Game, screen *ebiten.Image, box image.Rectan
|
||||
}
|
||||
t := l.At(p)
|
||||
c := minimapColors[t]
|
||||
if (t == level.Platform || t == level.WrongPlatform) && r.TrainVisible(t) {
|
||||
if t.IsPlatform() && r.TrainVisible(t) {
|
||||
c = color.RGBA{255, 255, 255, 255} // tåget är inne
|
||||
}
|
||||
vector.DrawFilledRect(screen, float32(x0+x*sc), float32(y0+y*sc), float32(sc), float32(sc), c, false)
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/minigame"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/nav"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
|
||||
)
|
||||
@@ -104,6 +105,31 @@ func (s *LevelScene) RideAtDest() bool {
|
||||
return !ok || g.Index == g.Dest
|
||||
}
|
||||
|
||||
// drawModeHint ritar navigeringslägets ruta nere till vänster när inforutan
|
||||
// är tom: listläget säger vilket tåg man ska ta, skyltläget påminner om att
|
||||
// man får lista ut det själv (och om bonusen).
|
||||
func (s *LevelScene) drawModeHint(a *Assets, screen *ebiten.Image) {
|
||||
r := s.run
|
||||
txt, col := "", colGood
|
||||
if r.Mode == nav.Signs {
|
||||
txt, col = a.T("hud.mode_signs"), colWarn
|
||||
} else if next := r.NextTrains(); len(next) > 0 {
|
||||
txt = fmt.Sprintf("%s %s %s %s", a.T("hud.take"), strings.ToLower(a.T("hud.track")), next[0].Track, next[0].Line.Get("romaji"))
|
||||
if len(next) > 1 {
|
||||
txt += fmt.Sprintf(" (+%d)", len(next)-1)
|
||||
}
|
||||
}
|
||||
if txt == "" {
|
||||
return
|
||||
}
|
||||
y := ScreenH - LineH - 6
|
||||
vector.DrawFilledRect(screen, 0, float32(y), float32(a.Width(txt)+8), float32(LineH+6), colPanel, false)
|
||||
a.Draw(screen, txt, 4, y+3, col)
|
||||
}
|
||||
|
||||
// LevelOver rapporterar om banan är avgjord (skriptläget: "waitover").
|
||||
func (s *LevelScene) LevelOver() bool { return s.run.Outcome != sim.Ongoing }
|
||||
|
||||
func (s *LevelScene) followPlayer() {
|
||||
x, y := s.run.Player.WorldPos(TileSize)
|
||||
s.cam.Follow(x+TileSize/2, y+TileSize/2)
|
||||
@@ -165,6 +191,20 @@ func (s *LevelScene) MiniPick(text string) bool {
|
||||
s.run.StepMini(0, minigame.Input{Move: 1})
|
||||
return false
|
||||
}
|
||||
case *minigame.CarPick:
|
||||
if text == "calm" { // lugnaste vagnen som inte är kvinnovagn
|
||||
best := -1
|
||||
for i, c := range m.Cars {
|
||||
if !c.WomenOnly && (best < 0 || c.Crowd < m.Cars[best].Crowd) {
|
||||
best = i
|
||||
}
|
||||
}
|
||||
if best < 0 || m.Cursor == best {
|
||||
return true
|
||||
}
|
||||
s.run.StepMini(0, minigame.Input{MoveX: 1})
|
||||
return false
|
||||
}
|
||||
case *minigame.TopUp:
|
||||
switch m.Stage {
|
||||
case minigame.TopUpPickMember:
|
||||
@@ -345,6 +385,9 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
|
||||
return // resultatpanelen, minispelet eller butiken tar över; ingen inforuta eller minikarta
|
||||
}
|
||||
lines, accent, bar := s.infoLines(a)
|
||||
if len(lines) == 0 {
|
||||
s.drawModeHint(a, screen)
|
||||
}
|
||||
if entry := a.Dict.Lookup(r.Learned); entry != nil && r.Time-r.LearnedAt < learnedShowSec {
|
||||
lines = append(lines, plainLine(fmt.Sprintf("%s: %s = %s (%s)", a.T("hud.learned"), r.Learned,
|
||||
entry.Translation(UILang), entry.Romaji), colKnown))
|
||||
@@ -499,10 +542,18 @@ func (s *LevelScene) infoLines(a *Assets) (lines []line, accent color.Color, bar
|
||||
}
|
||||
return []line{plainLine(a.T("hud.kiosk_none")+" "+a.T("hud.shop_prompt"), colDim)}, accent, false
|
||||
case level.TicketMachine:
|
||||
if lvl.TicketRequired {
|
||||
return []line{plainLine(fmt.Sprintf(a.T("hud.machine_ticket"), r.Wallet.Cash), colGood)}, colGood, false
|
||||
// Terminalen: linjetavlan för stationen (nätkopplade banor), läst genom kunskapen som en skylt.
|
||||
if term := r.TerminalLines(); len(term) > 0 {
|
||||
lines = append(lines, plainLine(a.T("hud.terminal"), colDim))
|
||||
for _, t := range term {
|
||||
rd := lang.Render(a.Dict, r.Know, t, UILang)
|
||||
lines = append(lines, a.wrapLine(shownLine(rd.Partial, colText, false), maxW)...)
|
||||
}
|
||||
}
|
||||
return []line{plainLine(fmt.Sprintf(a.T("hud.machine_topup"), r.Wallet.Card, r.Wallet.Cash), colGood)}, colGood, false
|
||||
if lvl.TicketRequired {
|
||||
return append(lines, plainLine(fmt.Sprintf(a.T("hud.machine_ticket"), r.Wallet.Cash), colGood)), colGood, false
|
||||
}
|
||||
return append(lines, plainLine(fmt.Sprintf(a.T("hud.machine_topup"), r.Wallet.Card, r.Wallet.Cash), colGood)), colGood, false
|
||||
}
|
||||
if r.DeniedAt >= 0 && r.Time-r.DeniedAt < deniedShowSec {
|
||||
key := "hud.gate_denied"
|
||||
|
||||
@@ -101,6 +101,8 @@ func (m *MapScene) Update(g *Game) error {
|
||||
case phoneJustPressed():
|
||||
g.Wallet.Phone = !g.Wallet.Phone
|
||||
g.Save()
|
||||
case modeJustPressed():
|
||||
g.toggleMode()
|
||||
}
|
||||
if n := len(m.mains); n > 0 {
|
||||
mx, my := miniMoveXY()
|
||||
@@ -209,7 +211,7 @@ func (m *MapScene) Draw(g *Game, screen *ebiten.Image) {
|
||||
foot := fmt.Sprintf("%s %d %s %d %s %d¥", a.T("map.skill_points"), p.SkillPoints, a.T("words.points"), g.Know.Points,
|
||||
a.T("hud.cash"), g.Wallet.Cash)
|
||||
a.Draw(screen, foot, 8, 30, colDim)
|
||||
a.Draw(screen, fmt.Sprintf(a.T("menu.phone"), onoff), 8, 42, colDim)
|
||||
a.Draw(screen, fmt.Sprintf(a.T("menu.phone"), onoff)+" "+fmt.Sprintf(a.T("menu.mode"), a.T("mode."+g.Mode.String())), 8, 42, colDim)
|
||||
if len(g.Profile.Debuffs) > 0 {
|
||||
var parts []string
|
||||
for _, d := range g.Profile.Debuffs {
|
||||
|
||||
@@ -62,8 +62,12 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) {
|
||||
y += LineH
|
||||
if run.Outcome == sim.Lost {
|
||||
reason := a.T(run.LostReason)
|
||||
if run.LostReason == sim.LostWrongTrain {
|
||||
reason = fmt.Sprintf(a.T("result.wrong_detail"), tr.Destination.Get("romaji"), run.Level.Destination.Get("romaji"))
|
||||
if detail, ok := map[string]string{
|
||||
sim.LostWrongTrain: "result.wrong_detail",
|
||||
sim.LostWrongDirection: "result.wrong_direction_detail",
|
||||
sim.LostTooFar: "result.too_far_detail",
|
||||
}[run.LostReason]; ok {
|
||||
reason += " " + fmt.Sprintf(a.T(detail), tr.Destination.Get("romaji"), run.Level.Destination.Get("romaji"))
|
||||
}
|
||||
for _, l := range a.wrapLine(plainLine(reason, colText), ScreenW-56) {
|
||||
a.drawLines(screen, []line{l}, 28, y)
|
||||
@@ -80,11 +84,26 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) {
|
||||
y += LineH
|
||||
if run.Outcome == sim.Won {
|
||||
sc := run.Score
|
||||
a.Draw(screen, fmt.Sprintf("%s %d", a.T("result.score"), sc.Total), 28, y, colText)
|
||||
y += LineH
|
||||
a.Draw(screen, fmt.Sprintf("%s %d %s %d %s %d %s %d %s %d %s %d", strings.ToLower(a.T("hud.time")), sc.Time, a.T("result.money"), sc.Money,
|
||||
a.T("result.meters"), sc.Meters, a.T("result.map"), sc.Explore, a.T("result.car"), sc.Car, a.T("result.penalty"), sc.Penalty), 28, y, colDim)
|
||||
y += LineH
|
||||
// Poängen som en uträkning; radbryts till högst två rader.
|
||||
parts := []string{
|
||||
fmt.Sprintf("%s %d", strings.ToLower(a.T("hud.time")), sc.Time), fmt.Sprintf("%s %d", a.T("result.money"), sc.Money),
|
||||
fmt.Sprintf("%s %d", a.T("result.meters"), sc.Meters), fmt.Sprintf("%s %d", a.T("result.map"), sc.Explore),
|
||||
fmt.Sprintf("%s %d", a.T("result.car"), sc.Car),
|
||||
}
|
||||
if sc.Bonus > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%s %d", a.T("result.bonus"), sc.Bonus))
|
||||
}
|
||||
total := fmt.Sprintf("%s %d = %s", a.T("result.score"), sc.Total, strings.Join(parts, " + "))
|
||||
if sc.Penalty > 0 {
|
||||
total += fmt.Sprintf(" - %s %d", a.T("result.penalty"), sc.Penalty)
|
||||
}
|
||||
for i, l := range a.wrapLine(plainLine(total, colText), ScreenW-56) {
|
||||
if i > 1 {
|
||||
break
|
||||
}
|
||||
a.drawLines(screen, []line{l}, 28, y)
|
||||
y += LineH
|
||||
}
|
||||
msg := fmt.Sprintf("+%d %s", run.PointsEarned, a.T("result.lang_points"))
|
||||
if len(run.LearnedDest) > 0 {
|
||||
msg += " " + a.T("hud.learned") + ": " + strings.Join(run.LearnedDest, " ") + " (" + a.T("result.destination") + ")"
|
||||
|
||||
@@ -93,7 +93,7 @@ func parseScript(src string) (*script, error) {
|
||||
return nil, fmt.Errorf("script: '%s TEXT'", f[0])
|
||||
}
|
||||
st.arg = f[1]
|
||||
case "quit", "waittrain", "waitstop":
|
||||
case "quit", "waittrain", "waitstop", "waitover":
|
||||
default:
|
||||
return nil, fmt.Errorf("script: okänt kommando %q", f[0])
|
||||
}
|
||||
@@ -126,6 +126,7 @@ type playerTiler interface {
|
||||
type trainWaiter interface {
|
||||
TrainHere() bool
|
||||
RideAtDest() bool
|
||||
LevelOver() bool
|
||||
MiniPick(text string) bool
|
||||
TrackPhaseName() string
|
||||
}
|
||||
@@ -195,6 +196,11 @@ func (s *script) tick(g *Game, frame *ebiten.Image) error {
|
||||
if tw, ok := g.scene.(trainWaiter); !ok || tw.RideAtDest() {
|
||||
s.advance()
|
||||
}
|
||||
case "waitover":
|
||||
s.counter++
|
||||
if tw, ok := g.scene.(trainWaiter); !ok || tw.LevelOver() || s.counter > 60*120 {
|
||||
s.advance()
|
||||
}
|
||||
case "waitphase":
|
||||
s.counter++
|
||||
if tw, ok := g.scene.(trainWaiter); !ok || tw.TrackPhaseName() == st.arg || s.counter > 60*60 {
|
||||
|
||||
@@ -36,7 +36,7 @@ type trackInfo struct {
|
||||
func (s *LevelScene) buildTracks() {
|
||||
s.tracks = nil
|
||||
s.rails = map[level.Point]bool{}
|
||||
for _, t := range []level.Tile{level.Platform, level.WrongPlatform} {
|
||||
for _, t := range level.PlatformTiles {
|
||||
for _, p := range s.run.Level.Find(t) {
|
||||
tt, horizontal := s.run.Level.TrackTile(p)
|
||||
s.tracks = append(s.tracks, trackInfo{tile: t, platform: p, track: tt, horizontal: horizontal})
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/nav"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res"
|
||||
)
|
||||
|
||||
@@ -79,3 +80,27 @@ func TestAllJapaneseInLevelsIsInDictionary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNetworkNamesInDictionary: alla stations- och linjenamn i linjenätet
|
||||
// finns i ordboken, så att tågresans stationer och terminalen går att lära sig.
|
||||
func TestNetworkNamesInDictionary(t *testing.T) {
|
||||
d := loadDict(t)
|
||||
path, err := res.Path("data", "network.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n, err := nav.LoadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, s := range n.Stations {
|
||||
if u := d.Unknown(s.Name["ja"]); len(u) > 0 {
|
||||
t.Errorf("station %s (%s) saknas i ordboken: %s", s.ID, s.Name["ja"], strings.Join(u, ", "))
|
||||
}
|
||||
}
|
||||
for _, l := range n.Lines {
|
||||
if u := d.Unknown(l.Name["ja"]); len(u) > 0 {
|
||||
t.Errorf("linje %s (%s) saknas i ordboken: %s", l.ID, l.Name["ja"], strings.Join(u, ", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/nav"
|
||||
)
|
||||
|
||||
// Tile är ett tecken i rutnätet.
|
||||
@@ -24,8 +26,12 @@ 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
|
||||
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
|
||||
@@ -37,12 +43,26 @@ const (
|
||||
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 }
|
||||
|
||||
@@ -171,13 +191,17 @@ type Train struct {
|
||||
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
|
||||
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.
|
||||
@@ -232,6 +256,8 @@ type Level struct {
|
||||
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)
|
||||
@@ -245,6 +271,8 @@ type Level struct {
|
||||
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.
|
||||
@@ -393,11 +421,12 @@ func (l *Level) Validate() error {
|
||||
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 Platform, WrongPlatform:
|
||||
platforms++
|
||||
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)
|
||||
@@ -414,7 +443,7 @@ func (l *Level) Validate() error {
|
||||
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)
|
||||
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)
|
||||
@@ -422,7 +451,7 @@ func (l *Level) Validate() error {
|
||||
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) {
|
||||
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 {
|
||||
@@ -454,7 +483,7 @@ func (l *Level) Validate() error {
|
||||
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 {
|
||||
if correct == 0 && !l.Networked() {
|
||||
return fmt.Errorf("level %s: inget tåg är markerat som rätt", l.ID)
|
||||
}
|
||||
if l.FareYen == 0 {
|
||||
@@ -623,7 +652,7 @@ func (l *Level) UngatedPlatforms() []Point {
|
||||
func (l *Level) Unreachable() []Point {
|
||||
reach := l.Reachable()
|
||||
var out []Point
|
||||
for _, t := range []Tile{Platform, WrongPlatform, TicketMachine, Sign, Gate, Toilet, Kiosk} {
|
||||
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)
|
||||
@@ -677,28 +706,106 @@ func (l *Level) TrainAt(t Tile) *Train {
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadFile läser och validerar en bana från disk.
|
||||
// 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)
|
||||
}
|
||||
return Parse(b)
|
||||
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" }
|
||||
|
||||
// LoadAll läser alla banor i en mapp, sorterade på id.
|
||||
// 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 {
|
||||
l, err := LoadFile(filepath.Join(dir, id+".json"))
|
||||
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)
|
||||
|
||||
@@ -2,12 +2,12 @@ package level
|
||||
|
||||
import (
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/nav"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res"
|
||||
)
|
||||
|
||||
@@ -122,11 +122,7 @@ func TestAllShippedLevels(t *testing.T) {
|
||||
}
|
||||
for _, f := range files {
|
||||
t.Run(filepath.Base(f), func(t *testing.T) {
|
||||
b, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l, err := Parse(b)
|
||||
l, err := LoadFile(f) // kopplar nätbanor till network.json (rätt tåg räknas ut där)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -146,6 +142,18 @@ func TestAllShippedLevels(t *testing.T) {
|
||||
if tr.Track == "" {
|
||||
t.Errorf("tåg %d saknar spårnummer", i)
|
||||
}
|
||||
if l.Networked() && tr.Verdict == nav.Unjudged {
|
||||
t.Errorf("tåg %d är inte bedömt av nätet", i)
|
||||
}
|
||||
}
|
||||
if l.Networked() && len(l.Trains) > 1 {
|
||||
trap := false
|
||||
for _, tr := range l.Trains {
|
||||
trap = trap || !tr.Correct
|
||||
}
|
||||
if !trap {
|
||||
t.Error("nätbanor med flera tåg ska ha minst ett fel tåg att undvika")
|
||||
}
|
||||
}
|
||||
if l.Party > 1 && (len(l.Find(Toilet)) == 0 || len(l.Find(Kiosk)) == 0) {
|
||||
t.Errorf("banor med sällskap behöver minst en toalett (W) och en kiosk (K)")
|
||||
@@ -256,6 +264,112 @@ func TestPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnnotateWithNetwork: rätt/fel tåg räknas ut från linjenätet, inklusive
|
||||
// fel håll och för långt bort, och orimliga kopplingar ger fel.
|
||||
func TestAnnotateWithNetwork(t *testing.T) {
|
||||
net, err := nav.Parse([]byte(`{"stations":[
|
||||
{"id":"a","name":{"ja":"あ"}},{"id":"b","name":{"ja":"い"}},{"id":"c","name":{"ja":"う"}},{"id":"d","name":{"ja":"え"}},{"id":"x","name":{"ja":"さ"}}],
|
||||
"lines":[{"id":"l1","name":{"ja":"一線"},"stations":["a","b","c","d"]},{"id":"l2","name":{"ja":"二線"},"stations":["x","b"]}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := `{"id":"net","name":{"sv":"x"},"difficulty":1,"destination":{"ja":"う"},"station":"b","to":"c",
|
||||
"grid":["#########","#S.GP.Gp#","#..GQ.Gq#","#########"],
|
||||
"trains":[
|
||||
{"platform":"P","track":"1","line":{"ja":"一線"},"destination":{"ja":"え"},"lineId":"l1","terminus":"d"},
|
||||
{"platform":"p","track":"2","line":{"ja":"一線"},"destination":{"ja":"あ"},"lineId":"l1","terminus":"a"},
|
||||
{"platform":"Q","track":"3","line":{"ja":"二線"},"destination":{"ja":"さ"},"lineId":"l2","terminus":"x"},
|
||||
{"platform":"q","track":"4","line":{"ja":"一線"},"destination":{"ja":"え"},"lineId":"l1","terminus":"d"}]}`
|
||||
l, err := Parse([]byte(src))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !l.Networked() || l.Net != nil {
|
||||
t.Fatal("nätbana före Annotate")
|
||||
}
|
||||
if err := l.Annotate(net); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []nav.Verdict{nav.Direct, nav.WrongDirection, nav.NoService, nav.Direct}
|
||||
for i, tr := range l.Trains {
|
||||
if tr.Verdict != want[i] || tr.Correct != (want[i] == nav.Direct) {
|
||||
t.Errorf("tåg %d: verdict=%v correct=%v, vill ha %v", i, tr.Verdict, tr.Correct, want[i])
|
||||
}
|
||||
}
|
||||
if l.Net != net || !l.Solvable() || l.CorrectPlatform() != (Point{X: 4, Y: 1}) {
|
||||
t.Fatal("kopplad bana ska vara lösbar via P")
|
||||
}
|
||||
// Fel: okänd linje, fel linjenamn, resmål som inte matchar, inget rätt tåg, nät saknas.
|
||||
cases := map[string]string{
|
||||
"okänd linje": strings.Replace(src, `"lineId":"l1","terminus":"d"}`, `"lineId":"nope","terminus":"d"}`, 1),
|
||||
"fel linjenamn": strings.Replace(src, `"line":{"ja":"二線"}`, `"line":{"ja":"一線"}`, 1),
|
||||
"fel resmålstext": strings.Replace(src, `"destination":{"ja":"う"},"station"`, `"destination":{"ja":"え"},"station"`, 1),
|
||||
"inget rätt tåg": strings.Replace(src, `"to":"c"`, `"to":"a"`, 1),
|
||||
"obedömbart tåg": strings.Replace(src, `"lineId":"l1","terminus":"a"}`, `"lineId":"l1","terminus":"b"}`, 1),
|
||||
}
|
||||
for name, c := range cases {
|
||||
l, err := Parse([]byte(c))
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
if err := l.Annotate(net); err == nil {
|
||||
t.Errorf("%s: skulle ha gett fel", name)
|
||||
}
|
||||
}
|
||||
if err := l.Annotate(nil); err == nil {
|
||||
t.Error("nätbana utan nät ska ge fel")
|
||||
}
|
||||
// Banor utan station/to påverkas inte.
|
||||
plain, err := Parse([]byte(`{"id":"p","name":{"sv":"x"},"difficulty":1,"destination":{"ja":"う"},"grid":["#####","#S.P#","#####"],
|
||||
"trains":[{"platform":"P","track":"1","line":{"ja":"一線"},"destination":{"ja":"う"},"correct":true}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := plain.Annotate(net); err != nil || plain.Net != nil || plain.Trains[0].Verdict != nav.Unjudged || !plain.Trains[0].Correct {
|
||||
t.Fatal("okopplad bana ska lämnas i fred")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformTilesAndShippedVerdicts(t *testing.T) {
|
||||
for _, tl := range PlatformTiles {
|
||||
if !tl.IsPlatform() || !knownTiles[tl] {
|
||||
t.Errorf("%c ska vara en känd perrong", tl)
|
||||
}
|
||||
}
|
||||
if Gate.IsPlatform() || Floor.IsPlatform() {
|
||||
t.Fatal("spärr/golv är inte perronger")
|
||||
}
|
||||
dir, err := res.Path("data", "levels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l, err := LoadFile(filepath.Join(dir, "005-ueno.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tr := l.TrainAt(WrongPlatform); tr == nil || tr.Verdict != nav.WrongDirection {
|
||||
t.Fatalf("005: Keihin-Tohoku mot Omiya ska vara fel håll, fick %v", tr)
|
||||
}
|
||||
l6, err := LoadFile(filepath.Join(dir, "006-ikebukuro.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[Tile]nav.Verdict{}
|
||||
for _, tr := range l6.Trains {
|
||||
got[Tile(tr.Platform[0])] = tr.Verdict
|
||||
}
|
||||
want := map[Tile]nav.Verdict{Platform: nav.Direct, Platform2: nav.Direct, WrongPlatform: nav.WrongDirection, Platform3: nav.TooFar}
|
||||
for tl, v := range want {
|
||||
if got[tl] != v {
|
||||
t.Errorf("006 perrong %c: %v, vill ha %v", tl, got[tl], v)
|
||||
}
|
||||
}
|
||||
all, err := LoadAll(dir)
|
||||
if err != nil || len(all) < 7 {
|
||||
t.Fatalf("LoadAll: %v (%d banor)", err, len(all))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUngatedPlatforms(t *testing.T) {
|
||||
// Perrong utan spärr framför -> rapporteras.
|
||||
l := mustParse(t, sample)
|
||||
|
||||
@@ -34,23 +34,42 @@ func NewRide(seed rng.Seed, dest string, fillers []string, stops int, correctTra
|
||||
f := append([]string(nil), fillers...)
|
||||
r.shuffle(len(f), func(i, j int) { f[i], f[j] = f[j], f[i] })
|
||||
stops = max(2, stops)
|
||||
crowd = max(0, min(2, crowd))
|
||||
sec := RideStopSec(crowd)
|
||||
g := &Ride{StopSec: sec, Timer: sec, GotOff: -1, Dest: -1, Crowd: crowd}
|
||||
di := -1
|
||||
if correctTrain {
|
||||
g.Dest = 1 + r.intn(stops-1)
|
||||
di = 1 + r.intn(stops-1)
|
||||
}
|
||||
var names []string
|
||||
for i := 0; i < stops; i++ {
|
||||
switch {
|
||||
case i == g.Dest:
|
||||
g.Stops = append(g.Stops, dest)
|
||||
case i == di:
|
||||
names = append(names, dest)
|
||||
case len(f) > 0:
|
||||
g.Stops = append(g.Stops, f[0])
|
||||
names = append(names, f[0])
|
||||
f = f[1:]
|
||||
default:
|
||||
g.Stops = append(g.Stops, UnknownStop) // slut på namn: en namnlös station
|
||||
names = append(names, UnknownStop) // slut på namn: en namnlös station
|
||||
}
|
||||
}
|
||||
return newRide(r, names, di, crowd)
|
||||
}
|
||||
|
||||
// NewRideStops bygger resan av givna stationer i ordning (linjenätets
|
||||
// riktiga stationer); dest är resmålets index, -1 om det inte kommer.
|
||||
func NewRideStops(seed rng.Seed, stops []string, dest int, crowd int) *Ride {
|
||||
r := newRand(seed, "ride")
|
||||
if len(stops) < 2 {
|
||||
stops = append(append([]string(nil), stops...), UnknownStop, UnknownStop)[:2]
|
||||
}
|
||||
if dest >= len(stops) {
|
||||
dest = -1
|
||||
}
|
||||
return newRide(r, append([]string(nil), stops...), dest, crowd)
|
||||
}
|
||||
|
||||
func newRide(r *randSrc, stops []string, dest int, crowd int) *Ride {
|
||||
crowd = max(0, min(2, crowd))
|
||||
sec := RideStopSec(crowd)
|
||||
g := &Ride{StopSec: sec, Timer: sec, GotOff: -1, Dest: dest, Crowd: crowd, Stops: stops}
|
||||
g.WantsOut = make([]bool, len(g.Stops))
|
||||
chance := [...]float64{0, 0.4, 0.7}[crowd]
|
||||
for i := range g.Stops {
|
||||
|
||||
335
internal/nav/nav.go
Normal file
335
internal/nav/nav.go
Normal file
@@ -0,0 +1,335 @@
|
||||
// Package nav är linjenätet: stationer, linjer i stationsordning och
|
||||
// bedömningen av om ett tåg tar en dit man ska – rakt fram, åt fel håll,
|
||||
// för långt bort eller inte alls. Ren Go utan Ebitengine.
|
||||
package nav
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Station är en station i nätet.
|
||||
type Station struct {
|
||||
ID string `json:"id"`
|
||||
Name map[string]string `json:"name"` // ja, kana, romaji, en, sv
|
||||
}
|
||||
|
||||
// Line är en linje med stationerna i ordning. En ringlinje (loop) fortsätter
|
||||
// från sista stationen till första.
|
||||
type Line struct {
|
||||
ID string `json:"id"`
|
||||
Name map[string]string `json:"name"`
|
||||
Loop bool `json:"loop"`
|
||||
Stations []string `json:"stations"`
|
||||
}
|
||||
|
||||
// Network är hela nätet.
|
||||
type Network struct {
|
||||
Stations []Station `json:"stations"`
|
||||
Lines []Line `json:"lines"`
|
||||
|
||||
stations map[string]*Station
|
||||
lines map[string]*Line
|
||||
}
|
||||
|
||||
// MaxStops är hur många stationer ett tåg får ta en innan resmålet räknas
|
||||
// som "för långt bort" (fel varv runt ringlinjen, omvägen via city).
|
||||
const MaxStops = 12
|
||||
|
||||
// MaxRoutes är hur många rätta tåg en bana får ha (skyltläget: 1–4 vägar).
|
||||
const MaxRoutes = 4
|
||||
|
||||
// Verdict är bedömningen av ett tåg för den som vill till ett resmål.
|
||||
type Verdict int
|
||||
|
||||
// Bedömningar. Unjudged är nollvärdet: banan är inte kopplad till nätet.
|
||||
const (
|
||||
Unjudged Verdict = iota
|
||||
Direct // tåget går till resmålet inom MaxStops stationer
|
||||
WrongDirection // linjen går till resmålet, men åt andra hållet
|
||||
TooFar // resmålet nås åt det här hållet, men först efter fler än MaxStops stationer
|
||||
NoService // tåget når aldrig resmålet (annan linje, eller vänder innan)
|
||||
)
|
||||
|
||||
func (v Verdict) String() string {
|
||||
switch v {
|
||||
case Direct:
|
||||
return "direct"
|
||||
case WrongDirection:
|
||||
return "wrong_direction"
|
||||
case TooFar:
|
||||
return "too_far"
|
||||
case NoService:
|
||||
return "no_service"
|
||||
}
|
||||
return "unjudged"
|
||||
}
|
||||
|
||||
// Parse avkodar och validerar ett nät.
|
||||
func Parse(b []byte) (*Network, error) {
|
||||
var n Network
|
||||
if err := json.Unmarshal(b, &n); err != nil {
|
||||
return nil, fmt.Errorf("nav: ogiltig JSON: %w", err)
|
||||
}
|
||||
if err := n.index(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &n, nil
|
||||
}
|
||||
|
||||
// LoadFile läser ett nät från disk.
|
||||
func LoadFile(path string) (*Network, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("nav: %w", err)
|
||||
}
|
||||
return Parse(b)
|
||||
}
|
||||
|
||||
func (n *Network) index() error {
|
||||
n.stations = map[string]*Station{}
|
||||
for i := range n.Stations {
|
||||
s := &n.Stations[i]
|
||||
if s.ID == "" || s.Name["ja"] == "" {
|
||||
return fmt.Errorf("nav: station %d saknar id eller japanskt namn", i)
|
||||
}
|
||||
if n.stations[s.ID] != nil {
|
||||
return fmt.Errorf("nav: stationen %q finns två gånger", s.ID)
|
||||
}
|
||||
n.stations[s.ID] = s
|
||||
}
|
||||
n.lines = map[string]*Line{}
|
||||
for i := range n.Lines {
|
||||
l := &n.Lines[i]
|
||||
if l.ID == "" || l.Name["ja"] == "" {
|
||||
return fmt.Errorf("nav: linje %d saknar id eller japanskt namn", i)
|
||||
}
|
||||
if n.lines[l.ID] != nil {
|
||||
return fmt.Errorf("nav: linjen %q finns två gånger", l.ID)
|
||||
}
|
||||
if len(l.Stations) < 2 {
|
||||
return fmt.Errorf("nav: linjen %q har färre än två stationer", l.ID)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, id := range l.Stations {
|
||||
if n.stations[id] == nil {
|
||||
return fmt.Errorf("nav: linjen %q har okänd station %q", l.ID, id)
|
||||
}
|
||||
if seen[id] {
|
||||
return fmt.Errorf("nav: linjen %q har stationen %q två gånger", l.ID, id)
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
n.lines[l.ID] = l
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Station slår upp en station (nil om okänd).
|
||||
func (n *Network) Station(id string) *Station { return n.stations[id] }
|
||||
|
||||
// Line slår upp en linje (nil om okänd).
|
||||
func (n *Network) Line(id string) *Line { return n.lines[id] }
|
||||
|
||||
// Name ger ett namn på ett språk med fallback: lang → romaji → en → ja.
|
||||
func Name(m map[string]string, lang string) string {
|
||||
for _, k := range []string{lang, "romaji", "en", "ja"} {
|
||||
if m[k] != "" {
|
||||
return m[k]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// StationName är stationens namn på ett språk ("" om okänd station).
|
||||
func (n *Network) StationName(id, lang string) string {
|
||||
if s := n.stations[id]; s != nil {
|
||||
return Name(s.Name, lang)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (l *Line) index(id string) int {
|
||||
for i, s := range l.Stations {
|
||||
if s == id {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// dir är färdriktningen (+1 framåt i listan, -1 bakåt, 0 = ogiltig) från
|
||||
// from mot terminus. På en ringlinje väljs kortaste vägen till terminus.
|
||||
func (l *Line) dir(from, terminus string) int {
|
||||
i, j := l.index(from), l.index(terminus)
|
||||
if i < 0 || j < 0 || i == j {
|
||||
return 0
|
||||
}
|
||||
if !l.Loop {
|
||||
if j > i {
|
||||
return 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
L := len(l.Stations)
|
||||
if (j-i+L)%L <= L/2 {
|
||||
return 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// distance är antal stationer från from till to i riktningen dir, eller -1
|
||||
// om to inte ligger åt det hållet.
|
||||
func (l *Line) distance(from, to string, dir int) int {
|
||||
i, j := l.index(from), l.index(to)
|
||||
if i < 0 || j < 0 || dir == 0 {
|
||||
return -1
|
||||
}
|
||||
L := len(l.Stations)
|
||||
if l.Loop {
|
||||
return ((j-i)*dir + L) % L
|
||||
}
|
||||
d := (j - i) * dir
|
||||
if d < 0 {
|
||||
return -1
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// Judge bedömer ett tåg på linjen lineID som står vid from och går mot
|
||||
// terminus (en station i färdriktningen) för den som vill till to.
|
||||
func (n *Network) Judge(lineID, from, terminus, to string) Verdict {
|
||||
l := n.lines[lineID]
|
||||
if l == nil || n.stations[to] == nil || from == to {
|
||||
return Unjudged
|
||||
}
|
||||
dir := l.dir(from, terminus)
|
||||
if dir == 0 {
|
||||
return Unjudged
|
||||
}
|
||||
if l.index(to) < 0 {
|
||||
return NoService
|
||||
}
|
||||
d := l.distance(from, to, dir)
|
||||
switch {
|
||||
case d < 0:
|
||||
return WrongDirection
|
||||
case !l.Loop && d > l.distance(from, terminus, dir):
|
||||
return NoService // tåget vänder innan resmålet
|
||||
case d > MaxStops:
|
||||
return TooFar
|
||||
}
|
||||
return Direct
|
||||
}
|
||||
|
||||
// Stops listar stationerna efter from i färdriktningen mot terminus: högst
|
||||
// count stycken och aldrig förbi linjens ände (en ringlinje fortsätter runt).
|
||||
func (n *Network) Stops(lineID, from, terminus string, count int) []string {
|
||||
l := n.lines[lineID]
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
return n.StopsDir(lineID, from, l.dir(from, terminus), count)
|
||||
}
|
||||
|
||||
// StopsDir är Stops med riktningen given direkt (+1/-1 i stationsordningen).
|
||||
func (n *Network) StopsDir(lineID, from string, dir, count int) []string {
|
||||
l := n.lines[lineID]
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
i := l.index(from)
|
||||
if dir == 0 || i < 0 {
|
||||
return nil
|
||||
}
|
||||
L := len(l.Stations)
|
||||
var out []string
|
||||
for k := 1; k <= count; k++ {
|
||||
j := i + k*dir
|
||||
if l.Loop {
|
||||
j = ((j % L) + L) % L
|
||||
} else if j < 0 || j >= L {
|
||||
break
|
||||
}
|
||||
out = append(out, l.Stations[j])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Route är en linje och riktning som tar en till resmålet.
|
||||
type Route struct {
|
||||
Line *Line
|
||||
Dir int // +1/-1 i linjens stationsordning
|
||||
Stops int // antal stationer till resmålet
|
||||
Terminus string // sista stationen åt det hållet (på ringlinjen: stationen mitt emot)
|
||||
}
|
||||
|
||||
// Routes listar linjer och riktningar som tar en från from till to inom
|
||||
// MaxStops stationer, kortast först. Skyltläget tillåter 1–4 sådana.
|
||||
func (n *Network) Routes(from, to string) []Route {
|
||||
var out []Route
|
||||
for i := range n.Lines {
|
||||
l := &n.Lines[i]
|
||||
if l.index(from) < 0 || l.index(to) < 0 || from == to {
|
||||
continue
|
||||
}
|
||||
for _, dir := range []int{1, -1} {
|
||||
d := l.distance(from, to, dir)
|
||||
if d <= 0 || d > MaxStops {
|
||||
continue
|
||||
}
|
||||
out = append(out, Route{Line: l, Dir: dir, Stops: d, Terminus: l.terminus(from, dir)})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(a, b int) bool { return out[a].Stops < out[b].Stops })
|
||||
return out
|
||||
}
|
||||
|
||||
func (l *Line) terminus(from string, dir int) string {
|
||||
L := len(l.Stations)
|
||||
if l.Loop {
|
||||
return l.Stations[((l.index(from)+dir*(L/2))%L+L)%L]
|
||||
}
|
||||
if dir > 0 {
|
||||
return l.Stations[L-1]
|
||||
}
|
||||
return l.Stations[0]
|
||||
}
|
||||
|
||||
// LinesAt listar linjerna som stannar vid stationen.
|
||||
func (n *Network) LinesAt(station string) []*Line {
|
||||
var out []*Line
|
||||
for i := range n.Lines {
|
||||
if n.Lines[i].index(station) >= 0 {
|
||||
out = append(out, &n.Lines[i])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Mode är navigeringsläget: listan säger vilket tåg man ska ta, skyltläget
|
||||
// låter en lista ut det själv (och ger mer poäng).
|
||||
type Mode int
|
||||
|
||||
// Navigeringslägen.
|
||||
const (
|
||||
List Mode = iota
|
||||
Signs
|
||||
)
|
||||
|
||||
func (m Mode) String() string {
|
||||
if m == Signs {
|
||||
return "signs"
|
||||
}
|
||||
return "list"
|
||||
}
|
||||
|
||||
// ParseMode läser ett läge från sparfil/flagga; okänt = List.
|
||||
func ParseMode(s string) Mode {
|
||||
if s == "signs" {
|
||||
return Signs
|
||||
}
|
||||
return List
|
||||
}
|
||||
171
internal/nav/nav_test.go
Normal file
171
internal/nav/nav_test.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package nav
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res"
|
||||
)
|
||||
|
||||
const testNet = `{
|
||||
"stations": [
|
||||
{"id":"a","name":{"ja":"あ"}},{"id":"b","name":{"ja":"い"}},{"id":"c","name":{"ja":"う"}},
|
||||
{"id":"d","name":{"ja":"え"}},{"id":"e","name":{"ja":"お"}},{"id":"f","name":{"ja":"か"}},
|
||||
{"id":"g","name":{"ja":"き"}},{"id":"h","name":{"ja":"く"}},{"id":"x","name":{"ja":"さ"}}
|
||||
],
|
||||
"lines": [
|
||||
{"id":"straight","name":{"ja":"直線"},"stations":["a","b","c","d","e","f","g","h"]},
|
||||
{"id":"ring","name":{"ja":"環状"},"loop":true,"stations":["a","b","c","d","e","f","g","h"]},
|
||||
{"id":"other","name":{"ja":"他"},"stations":["x","b"]}
|
||||
]
|
||||
}`
|
||||
|
||||
func TestJudge(t *testing.T) {
|
||||
n, err := Parse([]byte(testNet))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases := []struct {
|
||||
line, from, term, to string
|
||||
want Verdict
|
||||
}{
|
||||
{"straight", "b", "h", "d", Direct},
|
||||
{"straight", "d", "a", "b", Direct},
|
||||
{"straight", "b", "a", "d", WrongDirection},
|
||||
{"straight", "b", "c", "d", NoService}, // vänder vid c, innan d
|
||||
{"straight", "b", "h", "x", NoService}, // x ligger inte på linjen
|
||||
{"other", "b", "x", "d", NoService},
|
||||
{"ring", "a", "b", "c", Direct},
|
||||
{"ring", "a", "h", "c", TooFar}, // fel varv: 6 > MaxStops? nej – ringen är kort, så sänk gränsen i test nedan
|
||||
{"nope", "a", "b", "c", Unjudged},
|
||||
{"straight", "a", "a", "c", Unjudged},
|
||||
{"straight", "a", "h", "a", Unjudged},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := n.Judge(c.line, c.from, c.term, c.to)
|
||||
if c.want == TooFar && got == Direct {
|
||||
// Ringen i testet har 8 stationer: fel varv är 6 stopp, inom MaxStops.
|
||||
// Det är rätt beteende; TooFar testas separat med ett längre nät.
|
||||
continue
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("Judge(%s,%s→%s, till %s) = %v, vill ha %v", c.line, c.from, c.term, c.to, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTooFarOnLongLoop(t *testing.T) {
|
||||
// 30 stationer i ring: 2 stopp åt ena hållet, 28 åt andra.
|
||||
src := `{"stations":[`
|
||||
ids := ""
|
||||
for i := 0; i < 30; i++ {
|
||||
if i > 0 {
|
||||
src += ","
|
||||
ids += ","
|
||||
}
|
||||
src += `{"id":"s` + string(rune('a'+i/26)) + string(rune('a'+i%26)) + `","name":{"ja":"駅"}}`
|
||||
ids += `"s` + string(rune('a'+i/26)) + string(rune('a'+i%26)) + `"`
|
||||
}
|
||||
src += `],"lines":[{"id":"ring","name":{"ja":"環状"},"loop":true,"stations":[` + ids + `]}]}`
|
||||
n, err := Parse([]byte(src))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v := n.Judge("ring", "saa", "sab", "sac"); v != Direct {
|
||||
t.Fatalf("kort väg = %v", v)
|
||||
}
|
||||
if v := n.Judge("ring", "saa", "sbd", "sac"); v != TooFar { // sbd = index 29: bakåt
|
||||
t.Fatalf("fel varv = %v, vill ha TooFar", v)
|
||||
}
|
||||
if st := n.Stops("ring", "saa", "sbd", 3); len(st) != 3 || st[0] != "sbd" || st[2] != "sbb" {
|
||||
t.Fatalf("Stops bakåt runt ringen = %v", st)
|
||||
}
|
||||
routes := n.Routes("saa", "sac")
|
||||
if len(routes) != 1 || routes[0].Dir != 1 || routes[0].Stops != 2 {
|
||||
t.Fatalf("Routes = %+v", routes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopsRoutesAndNames(t *testing.T) {
|
||||
n, err := Parse([]byte(testNet))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st := n.Stops("straight", "f", "h", 5); len(st) != 2 || st[0] != "g" || st[1] != "h" {
|
||||
t.Fatalf("Stops stannar vid linjens ände: %v", st)
|
||||
}
|
||||
if st := n.Stops("straight", "c", "a", 5); len(st) != 2 || st[0] != "b" {
|
||||
t.Fatalf("Stops bakåt: %v", st)
|
||||
}
|
||||
routes := n.Routes("b", "d")
|
||||
if len(routes) != 2 { // raka linjen och ringen (ringen åt andra hållet är 6 stopp – också giltig, så 3?)
|
||||
// ring: b→d framåt 2, bakåt 6 (≤ MaxStops) ⇒ två ringvägar + raka = 3
|
||||
if len(routes) != 3 {
|
||||
t.Fatalf("Routes = %d", len(routes))
|
||||
}
|
||||
}
|
||||
if routes[0].Stops != 2 || routes[len(routes)-1].Stops < routes[0].Stops {
|
||||
t.Fatalf("Routes ska vara sorterade på antal stopp: %+v", routes)
|
||||
}
|
||||
if got := n.StationName("x", "sv"); got != "さ" {
|
||||
t.Fatalf("StationName fallback = %q", got)
|
||||
}
|
||||
if len(n.LinesAt("b")) != 3 || len(n.LinesAt("x")) != 1 {
|
||||
t.Fatal("LinesAt")
|
||||
}
|
||||
if ParseMode("signs") != Signs || ParseMode("") != List || Signs.String() != "signs" {
|
||||
t.Fatal("Mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidation(t *testing.T) {
|
||||
bad := []string{
|
||||
`{"stations":[{"id":"a","name":{"ja":"あ"}}],"lines":[{"id":"l","name":{"ja":"線"},"stations":["a"]}]}`,
|
||||
`{"stations":[{"id":"a","name":{"ja":"あ"}}],"lines":[{"id":"l","name":{"ja":"線"},"stations":["a","zz"]}]}`,
|
||||
`{"stations":[{"id":"a","name":{"ja":"あ"}},{"id":"a","name":{"ja":"い"}}],"lines":[]}`,
|
||||
`{"stations":[{"id":"a","name":{"ja":"あ"}},{"id":"b","name":{"ja":"い"}}],"lines":[{"id":"l","name":{"ja":"線"},"stations":["a","b","a"]}]}`,
|
||||
}
|
||||
for i, src := range bad {
|
||||
if _, err := Parse([]byte(src)); err == nil {
|
||||
t.Errorf("fall %d skulle ha gett fel", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShippedNetwork(t *testing.T) {
|
||||
dir, err := res.Path("data")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n, err := LoadFile(filepath.Join(dir, "network.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, s := range n.Stations {
|
||||
for _, k := range []string{"ja", "kana", "romaji", "en"} {
|
||||
if s.Name[k] == "" {
|
||||
t.Errorf("station %s saknar %s", s.ID, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, l := range n.Lines {
|
||||
for _, k := range []string{"ja", "romaji", "en"} {
|
||||
if l.Name[k] == "" {
|
||||
t.Errorf("linje %s saknar %s", l.ID, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Banornas förutsättningar: Ueno→Akihabara har flera vägar, Keihin-Tohoku mot Omiya är fel håll.
|
||||
if r := n.Routes("ueno", "akihabara"); len(r) < 2 {
|
||||
t.Errorf("Ueno→Akihabara: %d vägar", len(r))
|
||||
}
|
||||
if v := n.Judge("keihin-tohoku", "ueno", "omiya", "akihabara"); v != WrongDirection {
|
||||
t.Errorf("Keihin-Tohoku mot Omiya från Ueno = %v", v)
|
||||
}
|
||||
if v := n.Judge("yamanote", "ikebukuro", "ueno", "shinjuku"); v != TooFar {
|
||||
t.Errorf("Yamanote fel varv Ikebukuro→Shinjuku = %v", v)
|
||||
}
|
||||
if v := n.Judge("marunouchi", "ikebukuro", "ogikubo", "shinjuku"); v != TooFar {
|
||||
t.Errorf("Marunouchi via city = %v", v)
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ type State struct {
|
||||
Profile *sim.Profile `json:"profile"`
|
||||
Progress *progress.Progress `json:"progress"`
|
||||
Current *RunRef `json:"current,omitempty"`
|
||||
NavMode string `json:"navMode,omitempty"` // "list" (standard) eller "signs"
|
||||
}
|
||||
|
||||
// New är ett nytt spel.
|
||||
|
||||
@@ -84,7 +84,7 @@ func (f *Fog) Reveal(l *level.Level, from level.Point) {
|
||||
// starten och spärrarna – det man orienterar sig efter.
|
||||
func Landmark(l *level.Level, p level.Point) bool {
|
||||
switch l.At(p) {
|
||||
case level.Platform, level.WrongPlatform, level.Spawn, level.Gate, level.StairsUp, level.StairsDown:
|
||||
case level.Platform, level.WrongPlatform, level.Platform2, level.Platform3, level.Platform4, level.Platform5, level.Spawn, level.Gate, level.StairsUp, level.StairsDown:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
128
internal/sim/nav_test.go
Normal file
128
internal/sim/nav_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package sim
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/minigame"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/nav"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
|
||||
)
|
||||
|
||||
// boardAt går till perrongen med tecknet tile, väntar in tåget, kliver på
|
||||
// och väljer vagn; resan pågår när funktionen återvänder.
|
||||
func boardAt(t *testing.T, r *Run, tile level.Tile) *level.Train {
|
||||
t.Helper()
|
||||
r.NPCs = nil // folket ska inte stå i vägen för testboten
|
||||
r.Party.nextEventAt = 1e9
|
||||
l := r.Level
|
||||
path := l.Path(r.Player.Tile, l.Find(tile)[0], r.PathCanEnter)
|
||||
if path == nil {
|
||||
t.Fatalf("ingen väg till perrong %c", tile)
|
||||
}
|
||||
walk(t, r, path)
|
||||
tr := waitForTrain(t, r)
|
||||
r.Step(frame, None, true)
|
||||
if r.MiniKind == MiniCarPick {
|
||||
pickCar(t, r)
|
||||
}
|
||||
if r.MiniKind != MiniRide {
|
||||
t.Fatalf("ingen tågresa (kind=%v)", r.MiniKind)
|
||||
}
|
||||
return tr
|
||||
}
|
||||
|
||||
// TestNetworkRideUsesRealStops: på nätkopplade banor är tågresans stationer
|
||||
// linjens riktiga stationer i färdriktningen, med resmålet på rätt plats.
|
||||
func TestNetworkRideUsesRealStops(t *testing.T) {
|
||||
l := loadLevel(t, "005-ueno")
|
||||
if l.Net == nil {
|
||||
t.Fatal("005 ska vara kopplad till nätet")
|
||||
}
|
||||
r := NewRun(l, RunOptions{Seed: rng.FromInt(5)})
|
||||
tr := boardAt(t, r, level.Platform)
|
||||
g := r.Mini.(*minigame.Ride)
|
||||
if tr.Verdict != nav.Direct || g.Dest != 1 || g.Stops[0] != "御徒町" || g.Stops[1] != "秋葉原" || len(g.Stops) != l.RideStops {
|
||||
t.Fatalf("Yamanote från Ueno: dest=%d stops=%v", g.Dest, g.Stops)
|
||||
}
|
||||
ride(t, r)
|
||||
if r.Outcome != Won {
|
||||
t.Fatalf("outcome=%v (%s)", r.Outcome, r.LostReason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrongDirectionLoses: Keihin-Tohoku mot Omiya från Ueno går åt fel håll
|
||||
// – resan visar stationerna norrut, resmålet kommer aldrig och banan förloras
|
||||
// med orsaken "fel håll".
|
||||
func TestWrongDirectionLoses(t *testing.T) {
|
||||
l := loadLevel(t, "005-ueno")
|
||||
r := NewRun(l, RunOptions{Seed: rng.FromInt(5)})
|
||||
tr := boardAt(t, r, level.WrongPlatform)
|
||||
g := r.Mini.(*minigame.Ride)
|
||||
if tr.Verdict != nav.WrongDirection || g.Dest != -1 || g.Stops[0] != "鶯谷" {
|
||||
t.Fatalf("Keihin-Tohoku mot Omiya: verdict=%v dest=%d stops=%v", tr.Verdict, g.Dest, g.Stops)
|
||||
}
|
||||
ride(t, r)
|
||||
if r.Outcome != Lost || r.LostReason != LostWrongDirection {
|
||||
t.Fatalf("outcome=%v reason=%q", r.Outcome, r.LostReason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTooFarLoses: Marunouchi från Ikebukuro når Shinjuku först via city –
|
||||
// för långt bort.
|
||||
func TestTooFarLoses(t *testing.T) {
|
||||
l := loadLevel(t, "006-ikebukuro")
|
||||
r := NewRun(l, RunOptions{Seed: rng.FromInt(6)})
|
||||
if n := r.NextTrains(); len(n) != 2 {
|
||||
t.Fatalf("006 ska ha två rätta tåg, har %d", len(n))
|
||||
}
|
||||
tr := boardAt(t, r, level.Platform3)
|
||||
if tr.Verdict != nav.TooFar || tr.LineID != "marunouchi" {
|
||||
t.Fatalf("q-perrongen: %v %s", tr.Verdict, tr.LineID)
|
||||
}
|
||||
ride(t, r)
|
||||
if r.Outcome != Lost || r.LostReason != LostTooFar {
|
||||
t.Fatalf("outcome=%v reason=%q", r.Outcome, r.LostReason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSignsModeBonus: skyltläget ger 50 % bonus på grundpoängen, listläget inte.
|
||||
func TestSignsModeBonus(t *testing.T) {
|
||||
l := loadLevel(t, "001-flygplatsen")
|
||||
list := NewRun(l, RunOptions{Seed: rng.FromInt(1)})
|
||||
signs := NewRun(l, RunOptions{Seed: rng.FromInt(1), Mode: nav.Signs})
|
||||
if list.Mode != nav.List || signs.Mode != nav.Signs {
|
||||
t.Fatal("läget ska följa RunOptions")
|
||||
}
|
||||
for _, r := range []*Run{list, signs} {
|
||||
r.Time = 30
|
||||
r.computeScore()
|
||||
}
|
||||
base := list.Score.Time + list.Score.Money + list.Score.Meters + list.Score.Explore + list.Score.Car
|
||||
if list.Score.Bonus != 0 || signs.Score.Bonus != base*ScoreSignsBonusPct/100 || signs.Score.Total != list.Score.Total+signs.Score.Bonus {
|
||||
t.Fatalf("list=%+v signs=%+v", list.Score, signs.Score)
|
||||
}
|
||||
if n := list.NextTrains(); len(n) != 1 || !n[0].Correct {
|
||||
t.Fatalf("NextTrains=%v", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTerminalLines: terminalen listar linjerna vid stationen med grannstationer.
|
||||
func TestTerminalLines(t *testing.T) {
|
||||
r := NewRun(loadLevel(t, "005-ueno"), RunOptions{Seed: rng.FromInt(5)})
|
||||
lines := r.TerminalLines()
|
||||
if len(lines) < 3 {
|
||||
t.Fatalf("Ueno har flera linjer, fick %v", lines)
|
||||
}
|
||||
joined := strings.Join(lines, "\n")
|
||||
for _, want := range []string{"山手線: ", "御徒町", "鶯谷", "← 上野 →", "京浜東北線"} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Errorf("terminalen saknar %q:\n%s", want, joined)
|
||||
}
|
||||
}
|
||||
plain := NewRun(loadLevel(t, "002b-kabukicho"), RunOptions{Seed: rng.FromInt(1)})
|
||||
if plain.TerminalLines() != nil || plain.Level.Net != nil {
|
||||
t.Fatal("okopplad bana har ingen terminal")
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ const (
|
||||
// npcTargets är rutor som folk vill till.
|
||||
func npcTargets(l *level.Level) []level.Point {
|
||||
var out []level.Point
|
||||
for _, t := range []level.Tile{level.Kiosk, level.Toilet, level.Sign, level.TicketMachine, level.Platform, level.WrongPlatform, level.Spawn} {
|
||||
for _, t := range append([]level.Tile{level.Kiosk, level.Toilet, level.Sign, level.TicketMachine, level.Spawn}, level.PlatformTiles...) {
|
||||
out = append(out, l.Find(t)...)
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/minigame"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/nav"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
|
||||
)
|
||||
|
||||
@@ -32,6 +33,7 @@ type Run struct {
|
||||
DeniedAt float64 // när spärren senast nekade (för HUD); < 0 = aldrig
|
||||
Outcome Outcome
|
||||
Boarded *level.Train
|
||||
Mode nav.Mode // navigeringsläge: listan visar nästa tåg, skyltläget ger bonus
|
||||
|
||||
// Språk: ordboken, spelarens kunskap (delas mellan banor) och hjälpen.
|
||||
Dict *lang.Dictionary
|
||||
@@ -120,9 +122,12 @@ const (
|
||||
|
||||
// ScoreBreakdown är poängen för en vunnen bana.
|
||||
type ScoreBreakdown struct {
|
||||
Time, Money, Meters, Explore, Car, Penalty, Total int
|
||||
Time, Money, Meters, Explore, Car, Bonus, Penalty, Total int
|
||||
}
|
||||
|
||||
// ScoreSignsBonusPct är skyltlägets bonus i procent av grundpoängen.
|
||||
const ScoreSignsBonusPct = 50
|
||||
|
||||
// Poängformelns vikter.
|
||||
const (
|
||||
ScoreTimeMax = 500 // par eller snabbare
|
||||
@@ -163,9 +168,11 @@ const (
|
||||
|
||||
// Förlustorsaker (textnycklar).
|
||||
const (
|
||||
LostWrongTrain = "result.lost_wrong_train"
|
||||
LostWrongStop = "result.lost_wrong_stop"
|
||||
LostMissedStop = "result.lost_missed_stop"
|
||||
LostWrongTrain = "result.lost_wrong_train"
|
||||
LostWrongDirection = "result.lost_wrong_direction" // linjen går dit, men åt andra hållet
|
||||
LostTooFar = "result.lost_too_far" // fel varv runt ringlinjen / lång omväg
|
||||
LostWrongStop = "result.lost_wrong_stop"
|
||||
LostMissedStop = "result.lost_missed_stop"
|
||||
)
|
||||
|
||||
// Flash-nycklar.
|
||||
@@ -204,6 +211,8 @@ type RunOptions struct {
|
||||
FreeGates bool // spärrarna kostar inget och kräver inget
|
||||
RevealAll bool // hela banan känd från start
|
||||
InfiniteBattery bool // telefonen laddar inte ur
|
||||
|
||||
Mode nav.Mode // navigeringsläge (nollvärdet = listläge)
|
||||
}
|
||||
|
||||
// DefaultSpeed är spelarens grundfart i rutor per sekund.
|
||||
@@ -264,7 +273,7 @@ func NewRun(l *level.Level, o RunOptions) *Run {
|
||||
}
|
||||
r.npcRand = newNPCRand(o.Seed)
|
||||
r.NPCs = spawnNPCs(l, l.Crowd*NPCPerCrowd, r.npcRand)
|
||||
for _, t := range []level.Tile{level.Platform, level.WrongPlatform} {
|
||||
for _, t := range level.PlatformTiles {
|
||||
r.platformPhase[t], _ = r.PlatformPhase(t)
|
||||
}
|
||||
if o.Extra != (Stats{}) {
|
||||
@@ -283,6 +292,7 @@ func NewRun(l *level.Level, o RunOptions) *Run {
|
||||
n := len(l.Path(l.SpawnPoint(), l.CorrectPlatform(), l.CanEnter))
|
||||
r.parSec = float64(max(0, n-1))*ParSecPerTile + ParSlackSec
|
||||
}
|
||||
r.Mode = o.Mode
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -572,9 +582,87 @@ func (r *Run) startRide() {
|
||||
r.Wallet.Ticket, r.Wallet.TicketFare, r.Wallet.TicketCount = "", 0, 0 // biljetterna är förbrukade
|
||||
}
|
||||
dest := r.Level.Destination.Get("ja")
|
||||
if stops, di := r.networkStops(tr); stops != nil {
|
||||
r.Mini, r.MiniKind = minigame.NewRideStops(r.miniSeed(), stops, di, r.carCrowd()), MiniRide
|
||||
return
|
||||
}
|
||||
r.Mini, r.MiniKind = minigame.NewRide(r.miniSeed(), dest, r.stationPool(dest, 8), r.Level.RideStops, tr.Correct, r.carCrowd()), MiniRide
|
||||
}
|
||||
|
||||
// networkStops ger tågresans stationer från linjenätet: de riktiga
|
||||
// stationerna i tågets färdriktning och var resmålet ligger bland dem
|
||||
// (-1 = det kommer inte, eller inte inom det som visas). nil utan nät.
|
||||
func (r *Run) networkStops(tr *level.Train) ([]string, int) {
|
||||
l := r.Level
|
||||
if l.Net == nil {
|
||||
return nil, -1
|
||||
}
|
||||
ids := l.Net.Stops(tr.LineID, l.Station, tr.Terminus, nav.MaxStops+2)
|
||||
di := -1
|
||||
for i, id := range ids {
|
||||
if id == l.To {
|
||||
di = i
|
||||
break
|
||||
}
|
||||
}
|
||||
count := l.RideStops
|
||||
if di >= 0 {
|
||||
count = max(count, di+2) // resmålet ska inte vara sista stationen: man ska hinna kliva av
|
||||
}
|
||||
count = min(count, len(ids))
|
||||
if di >= count {
|
||||
di = -1 // för långt bort: resmålet ligger bortom det vi visar
|
||||
}
|
||||
stops := make([]string, count)
|
||||
for i := range stops {
|
||||
stops[i] = l.Net.StationName(ids[i], "ja")
|
||||
}
|
||||
return stops, di
|
||||
}
|
||||
|
||||
// NextTrains är tågen som tar en rätt – listläget visar dem i HUD:en.
|
||||
func (r *Run) NextTrains() []*level.Train {
|
||||
var out []*level.Train
|
||||
for i := range r.Level.Trains {
|
||||
if r.Level.Trains[i].Correct {
|
||||
out = append(out, &r.Level.Trains[i])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TerminalStops är hur många stationer åt varje håll terminalen visar.
|
||||
const TerminalStops = 3
|
||||
|
||||
// TerminalLines är linjetavlan vid automaten ("terminalen") på nätkopplade
|
||||
// banor: varje linje vid stationen med de närmaste stationerna åt båda
|
||||
// hållen, på japanska – att läsa genom sin kunskap som en skylt.
|
||||
func (r *Run) TerminalLines() []string {
|
||||
l := r.Level
|
||||
if l.Net == nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, ln := range l.Net.LinesAt(l.Station) {
|
||||
s := ln.Name["ja"] + ": "
|
||||
if back := l.Net.StopsDir(ln.ID, l.Station, -1, TerminalStops); len(back) > 0 {
|
||||
for i := len(back) - 1; i >= 0; i-- {
|
||||
s += l.Net.StationName(back[i], "ja") + " "
|
||||
}
|
||||
s += "← "
|
||||
}
|
||||
s += l.Net.StationName(l.Station, "ja")
|
||||
if fwd := l.Net.StopsDir(ln.ID, l.Station, 1, TerminalStops); len(fwd) > 0 {
|
||||
s += " →"
|
||||
for _, id := range fwd {
|
||||
s += " " + l.Net.StationName(id, "ja")
|
||||
}
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// StepMini för ett pågående minispel framåt; klockan och sällskapet tickar
|
||||
// under tiden men spelaren står still.
|
||||
func (r *Run) StepMini(dt float64, in minigame.Input) {
|
||||
@@ -649,7 +737,14 @@ func (r *Run) StepMini(dt float64, in minigame.Input) {
|
||||
case g.Success():
|
||||
r.finish(tr, true, "")
|
||||
case !tr.Correct:
|
||||
r.finish(tr, false, LostWrongTrain)
|
||||
reason := LostWrongTrain
|
||||
switch tr.Verdict {
|
||||
case nav.WrongDirection:
|
||||
reason = LostWrongDirection
|
||||
case nav.TooFar:
|
||||
reason = LostTooFar
|
||||
}
|
||||
r.finish(tr, false, reason)
|
||||
case g.GotOff >= 0:
|
||||
r.finish(tr, false, LostWrongStop)
|
||||
default:
|
||||
@@ -739,7 +834,11 @@ func (r *Run) computeScore() {
|
||||
if r.CarChosen != nil && r.CarChosen.Crowd == 0 {
|
||||
sc.Car = ScoreCarBonus
|
||||
}
|
||||
sc.Total = max(0, sc.Time+sc.Money+sc.Meters+sc.Explore+sc.Car-sc.Penalty)
|
||||
base := sc.Time + sc.Money + sc.Meters + sc.Explore + sc.Car
|
||||
if r.Mode == nav.Signs {
|
||||
sc.Bonus = base * ScoreSignsBonusPct / 100
|
||||
}
|
||||
sc.Total = max(0, base+sc.Bonus-sc.Penalty)
|
||||
r.Score = sc
|
||||
}
|
||||
|
||||
@@ -770,7 +869,7 @@ func (r *Run) earnedDebuffs() []Debuff {
|
||||
// stepNPCs flyttar folket, låter avstigande kliva av när dörrarna öppnas och
|
||||
// tar bort dem som nått utgången.
|
||||
func (r *Run) stepNPCs(dt float64) {
|
||||
for _, t := range []level.Tile{level.Platform, level.WrongPlatform} {
|
||||
for _, t := range level.PlatformTiles {
|
||||
ph, _ := r.PlatformPhase(t)
|
||||
if r.platformPhase[t] != ph {
|
||||
r.platformPhase[t] = ph
|
||||
@@ -806,7 +905,7 @@ func (r *Run) FacilityProgress() float64 {
|
||||
// till avgång respektive ankomst.
|
||||
func (r *Run) PlatformHere() (tr *level.Train, atPlatform bool, seconds float64) {
|
||||
t := r.Level.At(r.Player.Tile)
|
||||
if t != level.Platform && t != level.WrongPlatform {
|
||||
if !t.IsPlatform() {
|
||||
return nil, false, 0
|
||||
}
|
||||
tr = r.Level.TrainAt(t)
|
||||
@@ -830,7 +929,7 @@ func (r *Run) PlatformPhase(t level.Tile) (level.Phase, float64) {
|
||||
// spelaren – det är där man ser och hör tåget komma.
|
||||
func (r *Run) NearTrack(maxDist int) (tr *level.Train, platform level.Point, phase level.Phase, progress float64, dist int, ok bool) {
|
||||
best := maxDist + 1
|
||||
for _, t := range []level.Tile{level.Platform, level.WrongPlatform} {
|
||||
for _, t := range level.PlatformTiles {
|
||||
for _, p := range r.Level.Find(t) {
|
||||
if p.Z != r.Player.Tile.Z {
|
||||
continue // man ser och hör bara tågen på sin egen våning
|
||||
|
||||
Reference in New Issue
Block a user