Files
metro-puzzle-jp/internal/sim/run.go
claude 36a45563ec
All checks were successful
release / build-release (push) Successful in 1m34s
M11: oändligt läge och New Game+
internal/endless lottar mål ur en seed: en av storyns nätkopplade
stationer (aldrig samma två gånger i rad) med ett resmål som minst ett
tåg går rakt till men inte alla; banan kopieras, bedöms om mot nätet,
sällskapet växer var tredje mål och tidsgränsen krymper. O på bankartan
(eller -endless) startar en omgång; klarade mål ger poäng och XP, Enter
ger nästa mål, förlust eller Esc avslutar och omgången hamnar i
sparfilens poänglista (fem bästa). G två gånger startar New Game+: items,
banresultat, pengar och debuffar nollställs, skills, XP och japanskan
behålls, sällskapet är en person större per varv. Docs, wiki, tester och
skärmdumpar uppdaterade.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-24 23:20:12 +02:00

1327 lines
38 KiB
Go
Raw Permalink 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)
// Spärrar utan återvändo (level.OneWayGates): incheckad släpps inte ut
// förrän personalen låst upp korten.
inside map[level.Point]bool // rutorna innanför spärrarna
checkedIn bool
gateFromIn bool // spärrrutan man står på nåddes inifrån (på väg ut)
StaffUnlocked bool
Staff StaffState
StaffCalls int
NoExitAt float64 // när spärren senast vägrade släppa ut (< 0 = aldrig)
// 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"
FlashStaff = "hud.staff_unlocked"
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
MiniPayer
)
// 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
}
// StaffState är ett pågående personalanrop vid spärren (tar tid, avbryts
// om man går).
type StaffState struct {
Active bool
At level.Point
Remaining float64
Total float64
}
// StaffSec är hur länge man väntar på personalen vid spärren.
const StaffSec = 10.0
// 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
ExtraParty int // fler i sällskapet än banan säger (New Game+)
}
// 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,
inside: l.InsideGates(),
NoExitAt: -1,
Party: NewParty(l.Party+o.ExtraParty, 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.inside = l.InsideGates()
r.checkedIn, r.StaffUnlocked, r.gateFromIn, r.Staff = false, false, false, StaffState{}
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
}
if r.NeedsStaff() && r.inGate() && r.gateFromIn && t != level.Gate && !r.inside[p] {
r.NoExitAt = r.Time // incheckad: spärren släpper inte ut en
return false
}
for _, n := range r.NPCs {
if n.Occupies(p) {
r.BumpedAt = r.Time
return false
}
}
return true
}
// NeedsStaff rapporterar om man är incheckad i en spärr utan återvändo och
// behöver personalen för att komma ut.
func (r *Run) NeedsStaff() bool { return r.Level.OneWayGates && r.checkedIn && !r.StaffUnlocked }
// CheckedIn rapporterar om spelaren har checkat in i spärren (och inte åkt).
func (r *Run) CheckedIn() bool { return r.checkedIn }
// callStaff trycker på knappen vid spärren: efter StaffSec låser personalen
// upp korten så att man får gå ut.
func (r *Run) callStaff() {
if !r.NeedsStaff() || r.Staff.Active {
return
}
r.Staff = StaffState{Active: true, At: r.Player.Tile, Remaining: StaffSec, Total: StaffSec}
}
func (r *Run) stepStaff(dt float64) {
if !r.Staff.Active {
return
}
if r.Player.Moving() || r.Player.Tile != r.Staff.At {
r.Staff = StaffState{}
return
}
r.Staff.Remaining -= dt
if r.Staff.Remaining > 0 {
return
}
r.Staff = StaffState{}
r.StaffUnlocked = true
r.StaffCalls++
r.flash(FlashStaff)
}
// StaffProgress är 0..1 för ett pågående personalanrop.
func (r *Run) StaffProgress() float64 {
if !r.Staff.Active || r.Staff.Total <= 0 {
return 0
}
return 1 - r.Staff.Remaining/r.Staff.Total
}
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.stepStaff(dt)
r.stepReading()
r.stepNPCs(dt)
r.checkLimits()
if r.Player.Tile != before && r.inGate() && !wasGate {
r.gateFromIn = r.inside[before] // på väg ut (eller bara på genomresa utifrån)
}
// Avgiften dras när man passerar spärren in i det betalda området, inte
// när man står på spärrrutan: spärrar som ligger i en gång kan passeras
// utifrån utan att det kostar.
if r.Player.Tile != before && r.inside[r.Player.Tile] && !r.inside[before] {
r.GatePasses++
if r.Level.OneWayGates {
r.checkedIn = true // incheckad: spärren släpper inte ut en förrän man åkt
}
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 r.Level.OneWayGates && r.Player.Tile != before && wasGate && r.gateFromIn && !r.inside[r.Player.Tile] && !r.inGate() {
r.checkedIn, r.StaffUnlocked = false, false // ute igen (efter personalens upplåsning); ny inpassering kostar igen
}
if board && !r.Player.Moving() {
switch r.Level.At(r.Player.Tile) {
case level.Gate:
r.callStaff()
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.Payer:
if g.Success() && g.Chosen != nil {
r.purchaseAtKiosk(r.payFrom(*g.Chosen))
}
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. Med egna kort och
// fler i sällskapet frågar kiosken först vem som betalar (minigame.Payer),
// annars dras det från spelarens kort.
func (r *Run) buyAtKiosk() {
needs := r.Party.NeedsKiosk()
if len(needs) == 0 {
return
}
if r.Level.IndividualCards && r.Party.Size() > 1 {
r.Mini, r.MiniKind = minigame.NewPayer(KioskPriceYen*len(needs), r.payOptions()), MiniPayer
return
}
r.purchaseAtKiosk(r.Wallet.PayCard)
}
// payOptions är källorna man kan betala kiosken med: kontant, spelarens
// kort och varje följares kort.
func (r *Run) payOptions() []minigame.PayOption {
card := r.Wallet.Card
if !r.Wallet.CardUsable() {
card = 0
}
opts := []minigame.PayOption{{Cash: true, Yen: r.Wallet.Cash}, {Name: r.Party.Members[0].Name, Member: 0, Yen: card}}
for i, m := range r.Party.Followers() {
opts = append(opts, minigame.PayOption{Name: m.Name, Member: i + 1, Yen: m.Card})
}
return opts
}
// payFrom ger betalfunktionen för en vald källa.
func (r *Run) payFrom(o minigame.PayOption) func(int) bool {
switch {
case o.Cash:
return r.Wallet.PayCash
case o.Member == 0:
return r.Wallet.PayCard
}
m := r.Party.Members[o.Member]
return func(yen int) bool {
if m.Card < yen {
return false
}
m.Card -= yen
r.Wallet.Spent += yen
return true
}
}
// purchaseAtKiosk genomför köpet med en betalfunktion. Med liten seedad
// otur blir maten dålig: matförgiftning efter banan.
func (r *Run) purchaseAtKiosk(pay func(int) bool) {
n := r.Party.BuyAtKiosk(pay)
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
}