M6: plånbok, biljetter, kortet i telefonen och seedade minispel
All checks were successful
release / build-release (push) Successful in 1m41s

- 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>
This commit is contained in:
2026-10-24 09:50:12 +02:00
parent 3f56020777
commit 9fc7e94074
45 changed files with 1779 additions and 166 deletions

View File

@@ -5,6 +5,7 @@ import (
"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"
)
@@ -18,30 +19,12 @@ const (
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
Wallet *Wallet // pengar, kort, telefon, biljett – delas mellan banor
Time float64 // sekunder sedan banstart (styr tidtabellerna)
Steps int
GatePasses int
@@ -80,8 +63,51 @@ type Run struct {
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
@@ -93,10 +119,11 @@ type FacilityState struct {
// 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)
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.
@@ -128,10 +155,14 @@ func NewRun(l *level.Level, o RunOptions) *Run {
if o.Speed <= 0 {
o.Speed = DefaultSpeed
}
if o.Wallet == nil {
o.Wallet = NewWallet()
}
r := &Run{
Level: l,
Player: NewMover(l.SpawnPoint(), o.Speed),
Card: Card{Balance: StartBalanceYen},
Wallet: o.Wallet,
FlashAt: -1,
DeniedAt: -1,
Dict: o.Dict,
Know: o.Know,
@@ -171,19 +202,27 @@ func (r *Run) StarsFor(t float64) int {
}
// 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).
// 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.Card.CanPay(r.Level.FareYen) {
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
}
@@ -194,10 +233,20 @@ 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)
@@ -212,8 +261,15 @@ func (r *Run) Step(dt float64, want Dir, board bool) {
r.stepFacility(dt)
r.stepReading()
if r.Player.Tile != before && r.inGate() && !wasGate {
r.Card.Pay(r.Level.FareYen)
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) {
@@ -221,14 +277,131 @@ func (r *Run) Step(dt float64, want Dir, board bool) {
r.useToilet()
case level.Kiosk:
r.buyAtKiosk()
case level.TicketMachine:
r.startMachine()
default:
if tr, at, _ := r.PlatformHere(); tr != nil && at {
r.Board(tr)
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 {
@@ -260,7 +433,7 @@ func (r *Run) stepFacility(dt float64) {
// 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)
r.Served += r.Party.BuyAtKiosk(r.Wallet.PayCard)
}
// FacilityProgress är 0..1 för ett pågående toalettbesök.
@@ -295,12 +468,13 @@ func (r *Run) SignHere() *level.SignText {
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) {
// 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 !tr.Correct {
if !won {
r.Outcome = Lost
r.LostReason = reason
return
}
r.Outcome = Won