Files
metro-puzzle-jp/internal/sim/run.go
claude 9fc7e94074
All checks were successful
release / build-release (push) Successful in 1m41s
M6: plånbok, biljetter, kortet i telefonen och seedade minispel
- sim.Wallet: IC-kort, kontanter, pappersbiljett, telefon med batteri,
  spenderat; följer med mellan banor (Game.Wallet)
- internal/minigame: gemensamt Game-gränssnitt (Update/Done/Success), all
  slump från del-seeds: TopUp (hitta チャージ, belopp, mata sedlar),
  Ticket (taxetavla med resmål på japanska), Crowd (tidsfönster, två
  försök), Ride (stationerna passerar, kliv av vid resmålet)
- sim.Run: spärren kräver biljett på biljettbanor annars användbart kort;
  telefonen ger ×1,1 fart men 15 % seedad omskanning (frysning 1,5 s),
  dött batteri gör kortet oanvändbart; automaten startar påfyllning eller
  biljettköp; tåget kontrollerar biljetten; fulla tåg kräver trängsel;
  resan avgör vinst/förlust (fel station, åkte förbi, fel tåg); StepMini
  låter klockan och sällskapet ticka medan rörelsen pausar
- level: ticketRequired, rideStops, Train.Crowded; 002 är biljettbana med
  fullt tåg och fyra stopp; ordboken har stationer (kind) + sju nya namn
- game: vyer för minispelen, plånbok i HUD, P i menyn = kort i telefonen,
  förlustorsaker och spenderat på resultatet, flaggor -phone/-card,
  skriptkommandon hold/waitstop; inforuta/minikarta döljs under minispel
- plan: M7b (Björn) – tågvind, ankomstindikator nära rälsen,
  in-/utkörningsanimation för tåg och bussar – i spelbeskrivning, roadmap,
  testplan
- docs/README/wiki med tre nya bilder (automat, biljettavla, tågresa)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-24 09:50:12 +02:00

590 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}
// 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
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),
}
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 {
return r.Wallet.Ticket != ""
}
return r.Wallet.CardUsable() && r.Wallet.Card >= r.Level.FareYen
}
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()
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()
if r.Player.Tile != before && r.inGate() && !wasGate {
r.GatePasses++
if !r.Level.TicketRequired {
r.Wallet.PayCard(r.Level.FareYen)
if r.Wallet.Phone && r.Seed.Derive(fmt.Sprintf("rescan:%d", r.GatePasses)).New().Float64() < PhoneRescanChance {
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")
r.Mini, r.MiniKind = minigame.NewTicket(r.miniSeed(), dest, r.stationPool(dest, 4), r.Wallet.Cash), MiniTicket
return
}
r.Mini, r.MiniKind = minigame.NewTopUp(r.miniSeed(), r.Wallet.Cash), 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 = "", 0 // biljetten är förbrukad
}
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() {
return
}
m := r.Mini
r.Mini, r.MiniKind = nil, MiniNone
switch g := m.(type) {
case *minigame.TopUp:
if g.Success() && r.Wallet.TopUp(g.Amount) {
r.TopUps++
}
case *minigame.Ticket:
if g.Success() && g.Chosen != nil && r.Wallet.PayCash(g.Chosen.Fare) {
r.Wallet.Ticket, r.Wallet.TicketFare = g.Chosen.Dest, g.Chosen.Fare
}
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.
func (r *Run) buyAtKiosk() {
r.Served += r.Party.BuyAtKiosk(r.Wallet.PayCard)
}
// 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
}
atPlatform, seconds = tr.Schedule.Status(r.Time)
return tr, atPlatform, seconds
}
// 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
if !won {
r.Outcome = Lost
r.LostReason = reason
return
}
r.Outcome = Won
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 {
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 står inne just nu.
func (r *Run) TrainVisible(t level.Tile) bool {
tr := r.Level.TrainAt(t)
if tr == nil {
return false
}
at, _ := tr.Schedule.Status(r.Time)
return at
}
// 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.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
}