Files
metro-puzzle-jp/internal/sim/run.go
claude 4ae6a1d24c
All checks were successful
release / build-release (push) Successful in 1m37s
M10: byten, hemresan och levelcheck
Resor med byten: en bana kan ha next som pekar på en delbana (kind leg,
syns inte på bankartan). När man klivit av rätt tåg fortsätter samma
omgång på nästa station med klocka, plånbok, sällskap och räknare;
par-tiden adderas, dimma och folk är nya, en Byte!-banner visas och
HUD:en visar resmålet via bytesstationen. Tidsgräns och missade tåg
gäller hela resan. level.ValidateChains kräver sammanhängande kedjor.
Nya resor: 007 Akihabara -> Ueno -> Asakusa och finalen 008 Tokyo (två
våningar) -> Nippori -> Narita flygplats. Slutscen när sista huvudbanan
klaras första gången: planet lyfter, resan summeras, eftertexter;
Progress.StoryCleared sparas; flaggan -ending. tools/levelcheck ritar
banorna och rapporterar problem. HUD-nivån kortas när mätarna tar plats;
poängraden radbryts. Skärmdumpar och docs uppdaterade.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-24 13:37:48 +02:00

1184 lines
34 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package sim
import (
"fmt"
"math/rand/v2"
"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"
)
// Outcome är hur en banomgång står sig.
type Outcome int
// Utfall.
const (
Ongoing Outcome = iota
Won // klev på rätt tåg
Lost // klev på fel tåg
)
// Run är tillståndet för en pågående bana: spelaren, klockan, kortet och
// utfallet. Ren logik – LevelScene ritar den, testerna kör den headless.
type Run struct {
Level *level.Level
Player *Mover
Wallet *Wallet // pengar, kort, telefon, biljett – delas mellan banor
Time float64 // sekunder sedan banstart (styr tidtabellerna)
Steps int
GatePasses int
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
// Resor med byten: Level är aktuell delbana; tiden, plånboken och
// sällskapet följer med mellan delbanorna.
LegIndex int // 0 = första delbanan
TransferAt float64 // när senaste bytet skedde (< 0 = inget)
Rides []*level.Train // tågen man åkt med hittills
calmCars int // resor i en lugn vagn (bonus)
crowdedRide bool // någon resa i en full vagn (svettig-risk)
exploreSum float64 // utforskad andel i avklarade delbanor
legsDone int
timeLimit int // resans tidsgräns (första delbanans), 0 = ingen
maxMissed int // resans gräns för missade tåg (första delbanans)
// Språk: ordboken, spelarens kunskap (delas mellan banor) och hjälpen.
Dict *lang.Dictionary
Know *lang.Knowledge
Help HelpState
Revealed map[level.Point]bool // skyltar som översatts helt via hjälp
Learned string // senast inlärda ord (för HUD-notis)
LearnedAt float64 // när det lärdes in; < 0 = aldrig
// Slump och inlärning genom läsning.
Seed rng.Seed // all slump i omgången härleds härifrån
Visits map[level.Point]int // hur många gånger spelaren stannat på en skylt/perrong
lastTile level.Point
Progressed []string // ord som fick framsteg vid senaste läsningen, "ord n/3"
ProgressedAt float64 // < 0 = aldrig
FloorChanges int // antal trappbyten (statistik)
// Resultat när banan vunnits.
Stars int // 1–3 efter tid mot par
PointsEarned int // språkpoäng
LearnedDest []string // resmålets ord som lärdes in tack vare bra resultat
parSec float64
// Minikartans dimma: vad spelaren sett i den här omgången.
Fog *Fog
// Sällskapet (lämmeltåget) och pågående toalettbesök.
Party *Party
Facility FacilityState
baseSpeed float64
Served int // personer som fått mat/dryck/toa (statistik)
Notice string // senaste meddelande från sällskapet (HUD)
NoticeAt float64 // < 0 = aldrig
// Minispel, biljetter och telefon.
Mini minigame.Game // pågående minispel (nil = inget); rörelsen pausar
MiniKind MiniKind
miniCount int
PendingTrain *level.Train // tåget vi håller på att kliva på
MissedTrains int
TopUps int
Rescans int
RescanUntil float64 // telefonen måste skannas igen: spelaren står still tills dess
LostReason string // varför banan förlorades (nyckel för UI)
FlashKey string // kort meddelande till HUD (textnyckel)
FlashAt float64 // < 0 = aldrig
// Svårighet, poäng och debuffar (M7).
Stats Stats // multiplikatorer från profilens debuffar
Grumpy int // missnöjda med sittplatsen
poisoned bool // otur vid kiosken: matförgiftning efter banan
spentStart int // plånbokens Spent vid banstart
Score ScoreBreakdown
NewDebuffs []Debuff // debuffar banan gav (appliceras på profilen efteråt)
opts RunOptions
// Vagnar, folk och filer (M9b).
CarChosen *minigame.Car // vagnen man klev på
EarlyBoard int // försök att kliva på innan folk klivit av
WomenOnlyViolation bool // en man i sällskapet på kvinnovagnen
Blocked int // passagerare som inte släpptes förbi på resan
NPCs []*NPC
npcRand *rand.Rand
platformPhase map[level.Tile]level.Phase
BumpedAt float64 // när spelaren senast gick in i någon (< 0 = aldrig)
WrongLane bool // går på fel sida i en fil just nu
}
// Poäng och avdrag för vagnar.
const (
ScoreCarBonus = 50 // lite folk i vagnen
PenaltyWomenOnly = 200 // grova minus
PenaltyEarlyBoard = 20 // per gång man inte lät folk kliva av först
PenaltyBlocked = 20 // per passagerare som inte släpptes förbi
SweatyChance = 0.6 // chans att bli svettig i en full vagn
WrongLaneFactor = 0.7 // fart på fel sida i en fil
)
// Fler flash-nycklar.
const (
FlashWomenOnly = "hud.women_only"
FlashTransfer = "hud.transfer_done"
FlashEarly = "hud.early_board"
FlashBump = "hud.bump"
)
// ScoreBreakdown är poängen för en vunnen bana.
type ScoreBreakdown struct {
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
ScoreMoneyMax = 300 // minus 1 per 10 yen spenderade på banan
ScoreMetersMax = 200 // sällskapets mätare vid målgång
ScoreExploreMax = 100 // utforskad andel
PenaltyMissed = 50
PenaltyRescan = 10
PenaltyGrumpy = 30
GrumpyFoodPenalty = 25.0
)
// Fler förlustorsaker.
const (
LostTimeUp = "result.lost_time_up"
LostMissedTrains = "result.lost_missed_trains"
)
// Fler flash-nycklar.
const (
FlashGrumpy = "hud.grumpy"
FlashPoisoned = "hud.poisoned"
FlashNoCards = "hud.need_all_cards"
)
// MiniKind är vilket minispel som pågår.
type MiniKind int
// Minispel.
const (
MiniNone MiniKind = iota
MiniTopUp
MiniTicket
MiniCrowd
MiniRide
MiniCarPick
)
// Förlustorsaker (textnycklar).
const (
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.
const (
FlashWrongTicket = "hud.wrong_ticket"
FlashMissedTrain = "hud.missed_train"
FlashRescan = "hud.rescan"
FlashNoTicket = "hud.need_ticket"
)
// CrowdTries är antalet försök att få plats på ett fullt tåg.
const CrowdTries = 2
// FacilityState är ett pågående toalettbesök (tar tid, avbryts om man går).
type FacilityState struct {
Active bool
At level.Point
Remaining float64
Total float64
Count int
}
// RunOptions styr en banomgång.
type RunOptions struct {
Speed float64 // rutor per sekund (0 = DefaultSpeed)
Dict *lang.Dictionary // nil = tom ordbok
Know *lang.Knowledge // nil = ny kunskap
Wallet *Wallet // nil = startkassa
Debuffs []Debuff // aktiva debuffar från profilen
Seed rng.Seed // omgångens seed (sparas med spelet)
// Från skills och items (progression):
Extra Stats // multipliceras in i statsen (nollvärde = inga)
FogRadius int // extra siktsteg
Landmarks bool // toaletter/kiosker/automater alltid på minikartan
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)
Chain []*level.Level // resans fortsättning: delbanorna efter den första (byten), i ordning
}
// DefaultSpeed är spelarens grundfart i rutor per sekund.
const DefaultSpeed = 6.0
// Par: utan parSec i bandatan räknas "bra tid" som kortaste vägen i rutor ×
// ParSecPerTile + ParSlackSec. Tre stjärnor under par, två under 1,5 × par.
const (
ParSecPerTile = 0.5
ParSlackSec = 20.0
)
// HelpState är en pågående "fråga någon om hjälp".
type HelpState struct {
Active bool
At level.Point // skyltrutan hjälpen gäller
Remaining float64
Total float64
}
// NewRun startar banan med spelaren på startrutan.
func NewRun(l *level.Level, o RunOptions) *Run {
if o.Dict == nil {
o.Dict = lang.NewDictionary()
}
if o.Know == nil {
o.Know = lang.NewKnowledge()
}
if o.Speed <= 0 {
o.Speed = DefaultSpeed
}
if o.Wallet == nil {
o.Wallet = NewWallet()
}
r := &Run{
Level: l,
Player: NewMover(l.SpawnPoint(), o.Speed),
Wallet: o.Wallet,
FlashAt: -1,
DeniedAt: -1,
Dict: o.Dict,
Know: o.Know,
Revealed: map[level.Point]bool{},
LearnedAt: -1,
Seed: o.Seed,
Visits: map[level.Point]int{},
lastTile: l.SpawnPoint(),
ProgressedAt: -1,
parSec: parFor(l),
timeLimit: l.TimeLimitSec,
maxMissed: l.MaxMissedTrains,
TransferAt: -1,
Party: NewParty(l.Party, l.SpawnPoint(), o.Seed.Derive("party")),
baseSpeed: o.Speed,
NoticeAt: -1,
Stats: CombinedStats(o.Debuffs),
spentStart: o.Wallet.Spent,
opts: o,
platformPhase: map[level.Tile]level.Phase{},
BumpedAt: -1,
}
r.npcRand = newNPCRand(o.Seed)
r.NPCs = spawnNPCs(l, l.Crowd*NPCPerCrowd, r.npcRand)
for _, t := range level.PlatformTiles {
r.platformPhase[t], _ = r.PlatformPhase(t)
}
if o.Extra != (Stats{}) {
for i := range r.Stats {
r.Stats[i] *= o.Extra[i]
}
}
r.Fog = NewFog(l, FogRadius+o.FogRadius)
r.Fog.ExtraLandmarks = o.Landmarks
r.Party.Rates = Rates{Thirst: r.Stats[StatThirst], Toilet: r.Stats[StatToilet]}
if o.RevealAll {
r.Fog.RevealAll(l)
}
r.Fog.Reveal(l, l.SpawnPoint())
r.Mode = o.Mode
return r
}
// parFor är "bra tid" för en delbana: parSec i bandatan, annars kortaste
// vägen i rutor × ParSecPerTile + ParSlackSec.
func parFor(l *level.Level) float64 {
if l.ParSec > 0 {
return float64(l.ParSec)
}
n := len(l.Path(l.SpawnPoint(), l.CorrectPlatform(), l.CanEnter))
return float64(max(0, n-1))*ParSecPerTile + ParSlackSec
}
// Legs är antalet delbanor i resan, LegNo den aktuella (1-baserad).
func (r *Run) Legs() int { return 1 + len(r.opts.Chain) }
func (r *Run) LegNo() int { return r.LegIndex + 1 }
// IsLastLeg rapporterar om det inte blir fler byten.
func (r *Run) IsLastLeg() bool { return r.LegIndex >= len(r.opts.Chain) }
// FinalDestination är resans slutmål (sista delbanans destination).
func (r *Run) FinalDestination() level.Text {
if n := len(r.opts.Chain); n > 0 {
return r.opts.Chain[n-1].Destination
}
return r.Level.Destination
}
// TimeLimit och MaxMissed är resans gränser (0 = ingen).
func (r *Run) TimeLimit() int { return r.timeLimit }
func (r *Run) MaxMissed() int { return r.maxMissed }
// nextLeg är nästa delbana, eller nil på den sista.
func (r *Run) nextLeg() *level.Level {
if r.IsLastLeg() {
return nil
}
return r.opts.Chain[r.LegIndex]
}
// startLeg byter till nästa delbana: ny station, spelaren och sällskapet på
// dess start, ny dimma och nytt folk – men klockan, plånboken, mätarna och
// räknarna fortsätter. Par-tiden adderas.
func (r *Run) startLeg(l *level.Level) {
r.exploreSum += r.Fog.Explored(r.Level)
r.legsDone++
r.LegIndex++
r.Level = l
spawn := l.SpawnPoint()
r.Player = NewMover(spawn, r.baseSpeed)
r.Party.Reset(spawn)
r.Fog = NewFog(l, FogRadius+r.opts.FogRadius)
r.Fog.ExtraLandmarks = r.opts.Landmarks
if r.opts.RevealAll {
r.Fog.RevealAll(l)
}
r.Fog.Reveal(l, spawn)
r.NPCs = spawnNPCs(l, l.Crowd*NPCPerCrowd, r.npcRand)
r.platformPhase = map[level.Tile]level.Phase{}
for _, t := range level.PlatformTiles {
r.platformPhase[t], _ = r.PlatformPhase(t)
}
r.Revealed = map[level.Point]bool{}
r.Visits = map[level.Point]int{}
r.lastTile = spawn
r.Mini, r.MiniKind, r.PendingTrain, r.CarChosen = nil, MiniNone, nil, nil
r.Help, r.Facility = HelpState{}, FacilityState{}
r.DeniedAt, r.BumpedAt, r.WrongLane = -1, -1, false
r.parSec += parFor(l)
r.TransferAt = r.Time
r.flash(FlashTransfer)
}
// rideDone bokför en avslutad tågresa (vagnbonus, svettig-risk).
func (r *Run) rideDone(tr *level.Train) {
r.Rides = append(r.Rides, tr)
if r.CarChosen != nil && r.CarChosen.Crowd == 0 {
r.calmCars++
}
if r.CarChosen != nil && r.CarChosen.Crowd == 2 {
r.crowdedRide = true
}
}
// ParSec är tiden för tre stjärnor.
func (r *Run) ParSec() float64 { return r.parSec }
// StarsFor ger 3 stjärnor under par, 2 under 1,5 × par, annars 1.
func (r *Run) StarsFor(t float64) int {
switch {
case t <= r.parSec:
return 3
case t <= 1.5*r.parSec:
return 2
default:
return 1
}
}
// CanEnter avgör om spelaren får gå in på en ruta: inte vägg, och en spärr
// kräver biljett (på biljettbanor) eller ett användbart kort med saldo (att
// gå mellan två spärrutor är gratis).
func (r *Run) CanEnter(p level.Point) bool {
t := r.Level.At(p)
if !t.Passable() {
return false
}
if t == level.Gate && !r.inGate() && !r.canPassGate() {
r.DeniedAt = r.Time
return false
}
for _, n := range r.NPCs {
if n.Occupies(p) {
r.BumpedAt = r.Time
return false
}
}
return true
}
func (r *Run) canPassGate() bool {
if r.opts.FreeGates {
return true // JR Pass: genväg genom alla spärrar
}
if r.Level.TicketRequired {
if r.Wallet.Ticket == "" {
return false
}
return !r.Level.IndividualCards || r.Wallet.TicketCount >= r.Party.Size()
}
if !r.Wallet.CardUsable() || r.Wallet.Card < r.Level.FareYen {
return false
}
if r.Level.IndividualCards {
for _, m := range r.Party.Followers() {
if m.Card < r.Level.FareYen {
return false
}
}
}
return true
}
// PathCanEnter är CanEnter utan hänsyn till folk som råkar stå i vägen –
// för vägplanering (folk flyttar på sig; spärrar och väggar gör det inte).
func (r *Run) PathCanEnter(p level.Point) bool {
t := r.Level.At(p)
if !t.Passable() {
return false
}
if t == level.Gate && !r.inGate() && !r.canPassGate() {
return false
}
return true
}
// MembersWithoutFare listar följare vars egna kort inte räcker (HUD).
func (r *Run) MembersWithoutFare() []*Member {
if !r.Level.IndividualCards {
return nil
}
var out []*Member
for _, m := range r.Party.Followers() {
if m.Card < r.Level.FareYen {
out = append(out, m)
}
}
return out
}
func (r *Run) inGate() bool {
return r.Level.At(r.Player.Tile) == level.Gate
}
// Step för banan framåt dt sekunder. want är hållen riktning, board=true
// betyder att spelaren försöker kliva på tåget där hen står.
func (r *Run) Step(dt float64, want Dir, board bool) {
if r.Outcome != Ongoing {
return
}
if r.Mini != nil {
return // minispel pågår: använd StepMini
}
r.Time += dt
if !r.opts.InfiniteBattery {
r.Wallet.Drain(dt)
}
before := r.Player.Tile
wasGate := r.inGate()
r.Player.Speed = r.baseSpeed * r.Party.SpeedFactor() * r.Stats[StatSpeed]
if r.Wallet.Phone && r.Wallet.Battery > 0 {
r.Player.Speed *= PhoneSpeedFactor
}
if r.Time < r.RescanUntil {
r.Player.Speed = 0 // står och skannar om
}
// Filer: på en markerad fil går man på rätt sida, annars möter man strömmen.
r.WrongLane = false
switch r.Level.At(r.Player.Tile) {
case level.LaneLeft:
r.WrongLane = want == Down || want == Right
case level.LaneRight:
r.WrongLane = want == Up || want == Left
}
if r.WrongLane {
r.Player.Speed *= WrongLaneFactor
}
r.Steps += r.Player.Step(dt, want, r.CanEnter)
if r.Player.Tile != before {
// Trappa: hamnar man på en trappruta flyttas man till andra änden
// (samma x, y, en våning upp eller ner) och fortsätter därifrån.
switch r.Level.At(r.Player.Tile) {
case level.StairsUp:
r.Player.Tile = r.Player.Tile.Up()
r.Player.Target = r.Player.Tile
r.FloorChanges++
case level.StairsDown:
r.Player.Tile = r.Player.Tile.Down()
r.Player.Target = r.Player.Tile
r.FloorChanges++
}
r.Party.OnLeaderArrived(r.Player.Tile)
r.Fog.Reveal(r.Level, r.Player.Tile)
}
logged := len(r.Party.Log)
r.Party.Advance(dt, r.Time)
if len(r.Party.Log) > logged {
r.Notice, r.NoticeAt = r.Party.Log[len(r.Party.Log)-1], r.Time
}
r.stepHelp(dt)
r.stepFacility(dt)
r.stepReading()
r.stepNPCs(dt)
r.checkLimits()
if r.Player.Tile != before && r.inGate() && !wasGate {
r.GatePasses++
if !r.Level.TicketRequired && !r.opts.FreeGates {
r.Wallet.PayCard(r.Level.FareYen)
if r.Level.IndividualCards {
for _, m := range r.Party.Followers() {
m.Card -= r.Level.FareYen
r.Wallet.Spent += r.Level.FareYen
}
}
if r.Wallet.Phone && r.Seed.Derive(fmt.Sprintf("rescan:%d", r.GatePasses)).New().Float64() < PhoneRescanChance/r.Stats[StatLuck] {
r.Rescans++
r.RescanUntil = r.Time + RescanSec
r.flash(FlashRescan)
}
}
}
if board && !r.Player.Moving() {
switch r.Level.At(r.Player.Tile) {
case level.Toilet:
r.useToilet()
case level.Kiosk:
r.buyAtKiosk()
case level.TicketMachine:
r.startMachine()
default:
if tr, at, _ := r.PlatformHere(); tr != nil && at {
r.tryBoard(tr)
}
}
}
}
func (r *Run) flash(key string) { r.FlashKey, r.FlashAt = key, r.Time }
// Flashing rapporterar om ett kort meddelande ska visas just nu.
func (r *Run) Flashing(within float64) bool { return r.FlashAt >= 0 && r.Time-r.FlashAt < within }
func (r *Run) miniSeed() rng.Seed {
r.miniCount++
return r.Seed.Derive(fmt.Sprintf("mini:%d", r.miniCount))
}
// stationPool ger andra stationsnamn ur ordboken (seedat urval) till
// taxetavlan och tågresan.
func (r *Run) stationPool(exclude string, n int) []string {
var pool []string
for _, w := range r.Dict.OfKind("station") {
if w != exclude {
pool = append(pool, w)
}
}
rr := r.Seed.Derive("pool:" + exclude).New()
rr.Shuffle(len(pool), func(i, j int) { pool[i], pool[j] = pool[j], pool[i] })
if len(pool) > n {
pool = pool[:n]
}
return pool
}
// startMachine öppnar automaten: biljettköp på biljettbanor, annars påfyllning.
func (r *Run) startMachine() {
if r.Mini != nil {
return
}
if r.Level.TicketRequired {
dest := r.Level.Destination.Get("ja")
var seats []string
if r.Level.SeatSelection {
seats = r.Party.Names()
}
cash := r.Wallet.Cash
if r.Level.IndividualCards {
cash /= r.Party.Size() // alla behöver en biljett: visa vad som går att köpa per person
}
r.Mini, r.MiniKind = minigame.NewTicket(r.miniSeed(), dest, r.stationPool(dest, 4), cash, seats), MiniTicket
return
}
var members []string
if r.Level.IndividualCards {
members = r.Party.Names()
}
r.Mini, r.MiniKind = minigame.NewTopUp(r.miniSeed(), r.Wallet.Cash, members), MiniTopUp
}
// tryBoard försöker kliva på: biljetten måste gälla tåget, fulla tåg kräver
// trängselminispelet, sedan börjar resan.
func (r *Run) tryBoard(tr *level.Train) {
if r.Mini != nil {
return
}
if r.Level.TicketRequired && r.Wallet.Ticket != tr.Destination.Get("ja") {
r.flash(FlashWrongTicket)
return
}
r.PendingTrain = tr
women := 0
if tr.WomenOnlyActive(r.Time) {
women = tr.WomenOnlyCar
}
r.Mini, r.MiniKind = minigame.NewCarPick(r.miniSeed(), tr.Cars, women, minigame.AlightSec), MiniCarPick
}
// carPicked fortsätter ombordstigningen efter vagnvalet.
func (r *Run) carPicked(c *minigame.Car, early int) {
tr := r.PendingTrain
r.CarChosen = c
r.EarlyBoard += early
if early > 0 {
r.flash(FlashEarly)
}
if c.WomenOnly && r.Party.HasMen() {
r.WomenOnlyViolation = true
r.flash(FlashWomenOnly)
}
if tr.Crowded || c.Crowd == 2 {
r.Mini, r.MiniKind = minigame.NewCrowd(r.miniSeed(), CrowdTries, r.Stats[StatLuck], c.Crowd), MiniCrowd
return
}
r.startRide()
}
// carCrowd är trängseln i den valda vagnen (0 om inget valts).
func (r *Run) carCrowd() int {
if r.CarChosen == nil {
return 0
}
return r.CarChosen.Crowd
}
func (r *Run) startRide() {
tr := r.PendingTrain
if r.Level.TicketRequired {
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) {
if r.Outcome != Ongoing || r.Mini == nil {
return
}
r.Time += dt
if !r.opts.InfiniteBattery {
r.Wallet.Drain(dt)
}
r.Party.Advance(dt, r.Time)
r.stepNPCs(dt)
r.Mini.Update(in, dt)
if !r.Mini.Done() {
r.checkLimits()
return
}
m := r.Mini
r.Mini, r.MiniKind = nil, MiniNone
switch g := m.(type) {
case *minigame.TopUp:
if !g.Success() {
break
}
if g.Member == 0 {
if r.Wallet.TopUp(g.Amount) {
r.TopUps++
}
} else if r.Wallet.Cash >= g.Amount {
r.Wallet.Cash -= g.Amount
r.Party.Members[g.Member].Card += g.Amount
r.TopUps++
}
case *minigame.Ticket:
if !g.Success() || g.Chosen == nil {
break
}
count := 1
if r.Level.IndividualCards {
count = r.Party.Size()
}
if r.Wallet.PayCash(g.Chosen.Fare * count) {
r.Wallet.Ticket, r.Wallet.TicketFare, r.Wallet.TicketCount = g.Chosen.Dest, g.Chosen.Fare, count
}
if len(g.Unhappy) > 0 {
r.Grumpy += len(g.Unhappy)
for _, i := range g.Unhappy {
m := r.Party.Members[i]
m.Meters.Food = max(0, m.Meters.Food-GrumpyFoodPenalty)
}
r.flash(FlashGrumpy)
}
case *minigame.CarPick:
if g.Success() && g.Chosen != nil {
r.carPicked(g.Chosen, g.Early)
} else {
r.PendingTrain = nil // avbröt på perrongen
}
case *minigame.Crowd:
if g.Success() {
r.startRide()
} else {
r.MissedTrains++
r.PendingTrain = nil
r.flash(FlashMissedTrain)
}
case *minigame.Ride:
tr := r.PendingTrain
r.PendingTrain = nil
r.Blocked += g.Blocked
switch {
case g.Success():
if next := r.nextLeg(); next != nil {
r.rideDone(tr)
r.startLeg(next) // bytet: resan fortsätter på nästa station
return
}
r.finish(tr, true, "")
case !tr.Correct:
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:
r.finish(tr, false, LostMissedStop)
}
}
}
// useToilet startar ett toalettbesök för alla som behöver (tar tid).
func (r *Run) useToilet() {
if r.Facility.Active {
return
}
n := len(r.Party.NeedsToilet())
if n == 0 {
return
}
secs := ToiletSecPerMember * float64(n)
r.Facility = FacilityState{Active: true, At: r.Player.Tile, Remaining: secs, Total: secs, Count: n}
}
func (r *Run) stepFacility(dt float64) {
if !r.Facility.Active {
return
}
if r.Player.Moving() || r.Player.Tile != r.Facility.At {
r.Facility = FacilityState{}
return
}
r.Facility.Remaining -= dt
if r.Facility.Remaining > 0 {
return
}
r.Served += r.Party.UseToilet()
r.Facility = FacilityState{}
}
// buyAtKiosk köper mat och dryck åt alla som behöver, betalat med kortet.
// Med liten seedad otur blir maten dålig: matförgiftning efter banan.
func (r *Run) buyAtKiosk() {
n := r.Party.BuyAtKiosk(r.Wallet.PayCard)
if n == 0 {
return
}
r.Served += n
if !r.poisoned && r.Seed.Derive(fmt.Sprintf("poison:%d", r.Served)).New().Float64() < FoodPoisonChance/r.Stats[StatLuck] {
r.poisoned = true
r.flash(FlashPoisoned)
}
}
// checkLimits förlorar banan när tidsgränsen eller maxantalet missade tåg passeras.
func (r *Run) checkLimits() {
if r.Outcome != Ongoing {
return
}
if r.timeLimit > 0 && r.Time > float64(r.timeLimit) {
r.Mini, r.MiniKind, r.PendingTrain = nil, MiniNone, nil
r.finish(nil, false, LostTimeUp)
return
}
if r.maxMissed > 0 && r.MissedTrains > r.maxMissed {
r.finish(nil, false, LostMissedTrains)
}
}
// TimeLeft är sekunder kvar av tidsgränsen (0 utan gräns).
func (r *Run) TimeLeft() float64 {
if r.timeLimit == 0 {
return 0
}
return max(0, float64(r.timeLimit)-r.Time)
}
// computeScore fyller i poängen för en vunnen bana.
func (r *Run) computeScore() {
var sc ScoreBreakdown
sc.Time = int(ScoreTimeMax * min(1, r.parSec/max(r.Time, 0.001)))
sc.Money = max(0, ScoreMoneyMax-(r.Wallet.Spent-r.spentStart)/10)
sc.Meters = int(ScoreMetersMax * r.Party.AvgMeters())
sc.Explore = int(ScoreExploreMax * (r.exploreSum + r.Fog.Explored(r.Level)) / float64(r.legsDone+1))
sc.Penalty = PenaltyMissed*r.MissedTrains + PenaltyRescan*r.Rescans + PenaltyGrumpy*r.Grumpy +
PenaltyEarlyBoard*r.EarlyBoard + PenaltyBlocked*r.Blocked
if r.WomenOnlyViolation {
sc.Penalty += PenaltyWomenOnly
}
sc.Car = ScoreCarBonus * r.calmCars
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
}
// earnedDebuffs räknar ut vilka debuffar banan gav.
func (r *Run) earnedDebuffs() []Debuff {
var out []Debuff
if r.Steps >= SoreFeetSteps {
out = append(out, Debuff{SoreFeet, 1})
}
if r.Time >= TiredSec {
out = append(out, Debuff{Tired, 1})
}
for _, m := range r.Party.Members {
if m.Meters.Food <= 0 {
out = append(out, Debuff{Hangry, 1})
break
}
}
if r.poisoned {
out = append(out, Debuff{FoodPoisoning, FoodPoisonLevels})
}
if r.crowdedRide && r.Seed.Derive("sweaty").New().Float64() < SweatyChance/r.Stats[StatLuck] {
out = append(out, Debuff{Sweaty, 1})
}
return out
}
// 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.PlatformTiles {
ph, _ := r.PlatformPhase(t)
if r.platformPhase[t] != ph {
r.platformPhase[t] = ph
if tiles := r.Level.Find(t); ph == level.Dwelling && len(tiles) > 0 {
// Avstigande per tåg (inte per perrongruta), fördelade längs perrongen.
for i := 0; i < NPCAlightBase+r.Level.Crowd; i++ {
p := tiles[i%len(tiles)]
r.NPCs = append(r.NPCs, &NPC{Mover: NewMover(p, NPCSpeed), Kind: Exit, Target: r.Level.SpawnPoint(), Pause: 0.3 * float64(i/len(tiles))})
}
}
}
}
kept := r.NPCs[:0]
for _, n := range r.NPCs {
n.step(r.Level, dt, r.npcRand)
if !n.Done {
kept = append(kept, n)
}
}
r.NPCs = kept
}
// FacilityProgress är 0..1 för ett pågående toalettbesök.
func (r *Run) FacilityProgress() float64 {
if !r.Facility.Active || r.Facility.Total <= 0 {
return 0
}
return 1 - r.Facility.Remaining/r.Facility.Total
}
// PlatformHere returnerar tåget vid perrongen spelaren står på (nil om
// spelaren inte står på en perrong), om det är inne, och sekunder kvar
// 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.IsPlatform() {
return nil, false, 0
}
tr = r.Level.TrainAt(t)
if tr == nil {
return nil, false, 0
}
_, seconds = tr.Schedule.Status(r.Time)
return tr, tr.Schedule.Boardable(r.Time), seconds
}
// PlatformPhase är tågfasen för en perrongruta (Away om inget tåg).
func (r *Run) PlatformPhase(t level.Tile) (level.Phase, float64) {
tr := r.Level.TrainAt(t)
if tr == nil {
return level.Away, 0
}
return tr.Schedule.Phase(r.Time)
}
// NearTrack hittar närmaste perrong inom maxDist rutor (schackavstånd) från
// 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.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
}
d := max(abs(p.X-r.Player.Tile.X), abs(p.Y-r.Player.Tile.Y))
if d < best {
if cand := r.Level.TrainAt(t); cand != nil {
best, tr, platform = d, cand, p
}
}
}
}
if tr == nil {
return nil, level.Point{}, level.Away, 0, 0, false
}
phase, progress = tr.Schedule.Phase(r.Time)
return tr, platform, phase, progress, best, true
}
func abs(a int) int {
if a < 0 {
return -a
}
return a
}
// SignHere returnerar skylten spelaren står stilla på, annars nil.
func (r *Run) SignHere() *level.SignText {
if r.Player.Moving() {
return nil
}
return r.Level.SignAt(r.Player.Tile)
}
// finish avgör banan. En vinst ger stjärnor efter tid, språkpoäng, och vid
// tillräckligt bra resultat lär man sig resmålets ord.
func (r *Run) finish(tr *level.Train, won bool, reason string) {
r.Boarded = tr
r.rideDone(tr)
r.NewDebuffs = r.earnedDebuffs()
if !won {
r.Outcome = Lost
r.LostReason = reason
return
}
r.Outcome = Won
r.computeScore()
r.Stars = r.StarsFor(r.Time)
r.PointsEarned = r.Know.Award(r.Stars)
if r.Stars >= lang.StarsToLearnDestination {
for _, w := range r.Dict.WordsIn(r.Level.Destination.Get("ja")) {
if r.Know.Learn(w) {
r.LearnedDest = append(r.LearnedDest, w)
}
}
}
}
// stepReading: när spelaren stannar på en skylt eller perrong "läser" hen
// texten – varje okänt ord har chansen lang.ReadChance att ge ett framsteg,
// slumpat deterministiskt från seeden, skyltens position och besöksnumret.
func (r *Run) stepReading() {
if r.Player.Moving() || r.Player.Tile == r.lastTile {
return
}
r.lastTile = r.Player.Tile
text := ""
if sg := r.Level.SignAt(r.Player.Tile); sg != nil {
text = sg.Text.Get("ja")
} else if tr, _, _ := r.PlatformHere(); tr != nil {
text = tr.BoardText()
}
if text == "" {
return
}
r.Visits[r.Player.Tile]++
roll := r.Seed.Derive(fmt.Sprintf("read:%d,%d:%d", r.Player.Tile.X, r.Player.Tile.Y, r.Visits[r.Player.Tile])).New()
seen := map[string]bool{}
var progressed []string
for _, w := range r.Dict.WordsIn(text) {
if seen[w] || r.Know.Knows(w) {
continue
}
seen[w] = true
if roll.Float64() >= lang.ReadChance*r.Stats[StatFocus] {
continue
}
if r.Know.AddProgress(w) {
r.Learned, r.LearnedAt = w, r.Time
} else {
progressed = append(progressed, fmt.Sprintf("%s %d/%d", w, r.Know.ProgressOf(w), lang.ProgressToLearn))
}
}
if len(progressed) > 0 {
r.Progressed, r.ProgressedAt = progressed, r.Time
}
}
// TrainVisible rapporterar om tåget för en perrongruta syns just nu
// (rullar in, står inne eller rullar ut).
func (r *Run) TrainVisible(t level.Tile) bool {
tr := r.Level.TrainAt(t)
return tr != nil && tr.Schedule.Present(r.Time)
}
// AskHelp börjar fråga en förbipasserande om vad skylten betyder. Kräver att
// spelaren står stilla på en skylt som inte redan är avslöjad. Tiden beror
// på japanska-nivån (lang.HelpSeconds).
func (r *Run) AskHelp() bool {
sg := r.SignHere()
if sg == nil || r.Help.Active || r.Revealed[r.Player.Tile] || r.Outcome != Ongoing {
return false
}
secs := lang.HelpSeconds(r.Know.Level()) / r.Stats[StatFocus]
r.Help = HelpState{Active: true, At: r.Player.Tile, Remaining: secs, Total: secs}
return true
}
// stepHelp räknar ned hjälpen; att gå därifrån avbryter. När den är klar
// avslöjas skylten och spelaren lär sig det första okända ordet på den.
func (r *Run) stepHelp(dt float64) {
if !r.Help.Active {
return
}
if r.Player.Moving() || r.Player.Tile != r.Help.At {
r.Help = HelpState{}
return
}
r.Help.Remaining -= dt
if r.Help.Remaining > 0 {
return
}
r.Revealed[r.Help.At] = true
r.Know.Helps++
if sg := r.Level.SignAt(r.Help.At); sg != nil {
if w := lang.FirstUnknownWord(r.Dict, r.Know, sg.Text["ja"]); w != "" {
r.Know.Learn(w)
r.Learned, r.LearnedAt = w, r.Time
}
}
r.Help = HelpState{}
}
// SignRevealed rapporterar om skylten på rutan är helt översatt.
func (r *Run) SignRevealed(p level.Point) bool { return r.Revealed[p] }
// HelpProgress är 0..1 för en pågående hjälp.
func (r *Run) HelpProgress() float64 {
if !r.Help.Active || r.Help.Total <= 0 {
return 0
}
return 1 - r.Help.Remaining/r.Help.Total
}