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 } // 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, 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 }