All checks were successful
release / build-release (push) Successful in 1m42s
- level.Schedule.Phase: away/approaching/arriving/dwelling/closing/leaving ur tidtabellen med progress; Boardable (öppna dörrar) och Present; validering av minsta uppehåll/borta-tid; TrackTile för rälsen - sim.Run: ombordstigning bara med öppna dörrar, TrainVisible via Present, PlatformPhase och NearTrack (närmaste perrong inom två rutor) - game/trains.go: rälsrutor längs kantraden (roterade för vertikala spår), tre vagnar som bromsar in och accelererar ut längs rälsen, strålkastare under inrullningen, blinkande lampor vid kanten för den som står nära, dammpartiklar längs perrongen; kameran får en rutas marginal så att kanträlsen syns - game/audio.go: ebiten/v2/audio med avgångsmelodi (approaching) och dörrsignal (closing), volym efter avstånd, -mute; tools/gensound syntetiserar placeholder-WAV:ar - HUD: fasberoende perrongtext och "tåg på väg in" nära rälsen; skriptkommandot waitphase; skärmdump av ankomsten - plan (Björn 2026-10-24): räls/väg-grafik och melodierna i M7b; folk/NPC:er, vänster-/högertrafik, bredare gångar, reklam- och ljudröra som nytt steg M9b – i spelbeskrivning, roadmap, testplan - docs/README/wiki uppdaterade Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
804 lines
22 KiB
Go
804 lines
22 KiB
Go
package sim
|
||
|
||
import (
|
||
"fmt"
|
||
|
||
"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
|
||
|
||
// 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)
|
||
}
|
||
|
||
// ScoreBreakdown är poängen för en vunnen bana.
|
||
type ScoreBreakdown struct {
|
||
Time, Money, Meters, Explore, 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
|
||
)
|
||
|
||
// 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)
|
||
}
|
||
|
||
// 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,
|
||
Fog: NewFog(l, FogRadius),
|
||
Stats: CombinedStats(o.Debuffs),
|
||
spentStart: o.Wallet.Spent,
|
||
}
|
||
r.Party.Rates = Rates{Thirst: r.Stats[StatThirst], Toilet: r.Stats[StatToilet]}
|
||
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.X, p.Y)
|
||
if !t.Passable() {
|
||
return false
|
||
}
|
||
if t == level.Gate && !r.inGate() && !r.canPassGate() {
|
||
r.DeniedAt = r.Time
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
func (r *Run) canPassGate() bool {
|
||
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
|
||
}
|
||
|
||
// 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.X, r.Player.Tile.Y) == 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
|
||
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
|
||
}
|
||
r.Steps += r.Player.Step(dt, want, r.CanEnter)
|
||
if r.Player.Tile != before {
|
||
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.checkLimits()
|
||
if r.Player.Tile != before && r.inGate() && !wasGate {
|
||
r.GatePasses++
|
||
if !r.Level.TicketRequired {
|
||
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.X, r.Player.Tile.Y) {
|
||
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
|
||
if tr.Crowded {
|
||
r.Mini, r.MiniKind = minigame.NewCrowd(r.miniSeed(), CrowdTries), MiniCrowd
|
||
return
|
||
}
|
||
r.startRide()
|
||
}
|
||
|
||
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), 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
|
||
r.Wallet.Drain(dt)
|
||
r.Party.Advance(dt, r.Time)
|
||
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.Crowd:
|
||
if g.Success() {
|
||
r.startRide()
|
||
} else {
|
||
r.MissedTrains++
|
||
r.PendingTrain = nil
|
||
r.flash(FlashMissedTrain)
|
||
}
|
||
case *minigame.Ride:
|
||
tr := r.PendingTrain
|
||
r.PendingTrain = nil
|
||
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
|
||
sc.Total = max(0, sc.Time+sc.Money+sc.Meters+sc.Explore-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})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// 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.X, r.Player.Tile.Y)
|
||
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) {
|
||
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
|
||
}
|