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