Files
metro-puzzle-jp/internal/sim/run.go
claude 3f56020777
All checks were successful
release / build-release (push) Successful in 1m40s
M5: minikarta med dimma, riktmärken och avgångslista
- sim.Fog: avslöjar tre gångsteg från spelaren plus gränsande väggar (ser
  längs gången, inte genom väggen); riktmärken (perronger, start, spärrar)
  syns alltid; Explored ger utforskad andel; nollställs per omgång
- game/minimap: 1–3 px per ruta i det hörn som varken täcker inforutan
  eller spelaren, perrong vit när tåget är inne, spelaren blinkar,
  följarna i sina toner, avgångslista per spår (● inne / ○ kommer);
  M döljer/visar
- resultatskärmen visar utforskad andel och antal försörjda; layout justerad
- docs/README/wiki uppdaterade

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

416 lines
12 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/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
)
// Card är tågkortet (IC-kortet). Saldo i yen.
type Card struct{ Balance int }
// CanPay rapporterar om saldot räcker.
func (c *Card) CanPay(yen int) bool { return c.Balance >= yen }
// Pay drar beloppet om det går.
func (c *Card) Pay(yen int) bool {
if !c.CanPay(yen) {
return false
}
c.Balance -= yen
return true
}
// StartBalanceYen är kortets saldo vid banstart tills M6 ger riktig ekonomi.
const StartBalanceYen = 2000
// 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
Card Card
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
}
// 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
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
}
r := &Run{
Level: l,
Player: NewMover(l.SpawnPoint(), o.Speed),
Card: Card{Balance: StartBalanceYen},
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 att kortet kan betala avgiften (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.Card.CanPay(r.Level.FareYen) {
r.DeniedAt = r.Time
return false
}
return true
}
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
}
r.Time += dt
before := r.Player.Tile
wasGate := r.inGate()
r.Player.Speed = r.baseSpeed * r.Party.SpeedFactor()
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.Card.Pay(r.Level.FareYen)
r.GatePasses++
}
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()
default:
if tr, at, _ := r.PlatformHere(); tr != nil && at {
r.Board(tr)
}
}
}
}
// 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.Card.Pay)
}
// 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)
}
// Board kliver på ett tåg och 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) Board(tr *level.Train) {
r.Boarded = tr
if !tr.Correct {
r.Outcome = Lost
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
}