All checks were successful
release / build-release (push) Successful in 1m33s
Folk på stationen (NPC:er som vandrar, kliver av tåg och går mot utgången, blockerar spelaren men inte varandra), vänster-/högerfiler som bromsar på fel sida, bredare gångar, reklamaffischer (clutter), japanskt sorl i loop (noise), vagnval med kvinnovagn (stort avdrag med man i sällskapet, tidsstyrd), kömarkeringar på perrongen, trängsel per vagn (bonus för tom vagn, svettig-debuff och svårare resa i full vagn, släpp förbi passagerare) och att folk kliver av innan man får gå på. Ny bana 005 Ueno. Skärmdumpsverktyget kan filtrera på bana/skript. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
986 lines
28 KiB
Go
986 lines
28 KiB
Go
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/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
|
||
|
||
// 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"
|
||
FlashEarly = "hud.early_board"
|
||
FlashBump = "hud.bump"
|
||
)
|
||
|
||
// ScoreBreakdown är poängen för en vunnen bana.
|
||
type ScoreBreakdown struct {
|
||
Time, Money, Meters, Explore, Car, Penalty, Total int
|
||
}
|
||
|
||
// 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"
|
||
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
|
||
}
|
||
|
||
// 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: float64(l.ParSec),
|
||
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.Tile{level.Platform, level.WrongPlatform} {
|
||
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())
|
||
if r.parSec <= 0 {
|
||
n := len(l.Path(l.SpawnPoint(), l.CorrectPlatform(), l.CanEnter))
|
||
r.parSec = float64(max(0, n-1))*ParSecPerTile + ParSlackSec
|
||
}
|
||
return r
|
||
}
|
||
|
||
// 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")
|
||
r.Mini, r.MiniKind = minigame.NewRide(r.miniSeed(), dest, r.stationPool(dest, 8), r.Level.RideStops, tr.Correct, r.carCrowd()), MiniRide
|
||
}
|
||
|
||
// 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():
|
||
r.finish(tr, true, "")
|
||
case !tr.Correct:
|
||
r.finish(tr, false, LostWrongTrain)
|
||
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.Level.TimeLimitSec > 0 && r.Time > float64(r.Level.TimeLimitSec) {
|
||
r.Mini, r.MiniKind, r.PendingTrain = nil, MiniNone, nil
|
||
r.finish(nil, false, LostTimeUp)
|
||
return
|
||
}
|
||
if r.Level.MaxMissedTrains > 0 && r.MissedTrains > r.Level.MaxMissedTrains {
|
||
r.finish(nil, false, LostMissedTrains)
|
||
}
|
||
}
|
||
|
||
// TimeLeft är sekunder kvar av tidsgränsen (0 utan gräns).
|
||
func (r *Run) TimeLeft() float64 {
|
||
if r.Level.TimeLimitSec == 0 {
|
||
return 0
|
||
}
|
||
return max(0, float64(r.Level.TimeLimitSec)-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.Fog.Explored(r.Level))
|
||
sc.Penalty = PenaltyMissed*r.MissedTrains + PenaltyRescan*r.Rescans + PenaltyGrumpy*r.Grumpy +
|
||
PenaltyEarlyBoard*r.EarlyBoard + PenaltyBlocked*r.Blocked
|
||
if r.WomenOnlyViolation {
|
||
sc.Penalty += PenaltyWomenOnly
|
||
}
|
||
if r.CarChosen != nil && r.CarChosen.Crowd == 0 {
|
||
sc.Car = ScoreCarBonus
|
||
}
|
||
sc.Total = max(0, sc.Time+sc.Money+sc.Meters+sc.Explore+sc.Car-sc.Penalty)
|
||
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.CarChosen != nil && r.CarChosen.Crowd == 2 && 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.Tile{level.Platform, level.WrongPlatform} {
|
||
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 != level.Platform && t != level.WrongPlatform {
|
||
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.Tile{level.Platform, level.WrongPlatform} {
|
||
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.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
|
||
}
|