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 }