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