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

@@ -0,0 +1,56 @@
package minigame
import "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
// Crowd är "få plats på det fulla tåget": en markör pendlar över en stapel,
// tryck Enter när den är i den gröna zonen. Två försök, sedan går tåget.
type Crowd struct {
base
Pos float64 // markörens läge 0..1
dir float64
Speed float64 // enheter per sekund
ZoneLo float64
ZoneHi float64
Attempts int
MaxTries int
}
const crowdZoneWidth = 0.22
// NewCrowd seedar zonens läge och markörens fart.
func NewCrowd(seed rng.Seed, tries int) *Crowd {
r := newRand(seed, "crowd")
lo := r.between(0.1, 0.9-crowdZoneWidth)
return &Crowd{dir: 1, Speed: r.between(0.9, 1.5), ZoneLo: lo, ZoneHi: lo + crowdZoneWidth, MaxTries: max(1, tries)}
}
// InZone rapporterar om markören är i zonen just nu.
func (g *Crowd) InZone() bool { return g.Pos >= g.ZoneLo && g.Pos <= g.ZoneHi }
// Update pendlar markören och tar emot försök.
func (g *Crowd) Update(in Input, dt float64) {
if g.done {
return
}
if in.Cancel {
g.done = true
return
}
g.Pos += g.dir * g.Speed * dt
for g.Pos < 0 || g.Pos > 1 {
if g.Pos < 0 {
g.Pos = -g.Pos
} else {
g.Pos = 2 - g.Pos
}
g.dir = -g.dir
}
if in.Confirm {
g.Attempts++
if g.InZone() {
g.done, g.success = true, true
} else if g.Attempts >= g.MaxTries {
g.done = true
}
}
}

View File

@@ -0,0 +1,38 @@
// Package minigame innehåller de små spelen i spelet: fylla på kortet vid
// automaten, köpa rätt biljett, få plats på ett fullt tåg och gå av på rätt
// station. Ren logik utan Ebitengine; all slump kommer från en seed så att
// ett minispel är exakt likadant varje gång för samma seed.
//
// Gemensamt mönster: skapa med en seed, mata Update med input varje
// bildruta, läs Done/Success när det är klart. Renderingen görs av
// internal/game per typ.
package minigame
import "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
// Input är vad spelaren gjorde den här bildrutan.
type Input struct {
Move int // -1 / +1 (upp/vänster resp. ner/höger), 0 = inget
Confirm bool // Enter tryckt (just nu)
Hold bool // Enter hålls nere
Cancel bool // Esc tryckt
}
// Game är ett pågående minispel.
type Game interface {
Update(in Input, dt float64)
Done() bool
Success() bool
}
// base är det gemensamma tillståndet.
type base struct {
done, success bool
}
func (b *base) Done() bool { return b.done }
func (b *base) Success() bool { return b.success }
func newRand(seed rng.Seed, context string) *randSrc {
return &randSrc{seed.Derive(context).New()}
}

View File

@@ -0,0 +1,206 @@
package minigame
import (
"math"
"strings"
"testing"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
)
const frame = 1.0 / 60
func TestTopUpFlowAndDeterminism(t *testing.T) {
g := NewTopUp(rng.FromInt(1), 5000)
h := NewTopUp(rng.FromInt(1), 5000)
if strings.Join(g.Buttons, ",") != strings.Join(h.Buttons, ",") {
t.Fatal("samma seed ska ge samma knappordning")
}
if strings.Join(g.Buttons, ",") == strings.Join(NewTopUp(rng.FromInt(2), 5000).Buttons, ",") &&
strings.Join(g.Buttons, ",") == strings.Join(NewTopUp(rng.FromInt(3), 5000).Buttons, ",") {
t.Fatal("olika seeds ska (nästan alltid) ge olika ordning")
}
// Fel knapp: straff och frysning.
wrong := 0
for wrong = range g.Buttons {
if g.Buttons[wrong] != "チャージ" {
break
}
}
g.Cursor = wrong
g.Update(Input{Confirm: true}, frame)
if g.Mistakes != 1 || g.Penalty <= 0 || g.Stage != TopUpPickButton {
t.Fatalf("fel knapp: mistakes=%d penalty=%.1f stage=%v", g.Mistakes, g.Penalty, g.Stage)
}
for g.Penalty > 0 {
g.Update(Input{Move: 1}, frame) // ignoreras under straffet
}
// Rätt knapp -> belopp -> mata sedlar.
for g.Buttons[g.Cursor] != "チャージ" {
g.Update(Input{Move: 1}, frame)
}
g.Update(Input{Confirm: true}, frame)
if g.Stage != TopUpPickAmount {
t.Fatal("ska välja belopp nu")
}
g.Update(Input{Move: 1}, frame)
g.Update(Input{Confirm: true}, frame)
if g.Stage != TopUpFeed || g.Amount != 2000 {
t.Fatalf("stage=%v amount=%d", g.Stage, g.Amount)
}
for i := 0; i < 60 && !g.Done(); i++ {
g.Update(Input{Hold: true}, frame)
}
if g.Fill < 0.5 || g.Done() {
t.Fatalf("efter 1 s hållning: fill=%.2f done=%v", g.Fill, g.Done())
}
g.Update(Input{}, 0.5) // släppt: rinner tillbaka
if g.Fill >= 0.8 {
t.Fatalf("släppt Enter ska tömma: fill=%.2f", g.Fill)
}
for i := 0; i < 600 && !g.Done(); i++ {
g.Update(Input{Hold: true}, frame)
}
if !g.Done() || !g.Success() || g.Amount != 2000 {
t.Fatalf("done=%v success=%v amount=%d", g.Done(), g.Success(), g.Amount)
}
// Kontanter begränsar beloppen; utan pengar är spelet över direkt.
if a := NewTopUp(rng.FromInt(1), 2500).Amounts; len(a) != 2 || a[1] != 2000 {
t.Fatalf("amounts med 2500 kr: %v", a)
}
if !NewTopUp(rng.FromInt(1), 500).Done() {
t.Fatal("utan råd ska automaten vara klar direkt")
}
// Esc avbryter utan köp.
c := NewTopUp(rng.FromInt(1), 5000)
c.Update(Input{Cancel: true}, frame)
if !c.Done() || c.Success() {
t.Fatal("avbrutet = klart utan framgång")
}
}
func TestTicketBoardSeededAndAffordable(t *testing.T) {
g := NewTicket(rng.FromInt(4), "渋谷", []string{"東京", "上野", "新宿"}, 5000)
h := NewTicket(rng.FromInt(4), "渋谷", []string{"東京", "上野", "新宿"}, 5000)
if len(g.Options) != 4 {
t.Fatalf("%d alternativ", len(g.Options))
}
for i := range g.Options {
if g.Options[i] != h.Options[i] {
t.Fatal("samma seed ska ge samma tavla")
}
if f := g.Options[i].Fare; f < 140 || f > 620 || f%10 != 0 {
t.Fatalf("taxa %d", f)
}
}
// Flytta till 渋谷 och köp.
for g.Options[g.Cursor].Dest != "渋谷" {
g.Update(Input{Move: 1}, frame)
}
g.Update(Input{Confirm: true}, frame)
if !g.Done() || !g.Success() || g.Chosen == nil || g.Chosen.Dest != "渋谷" {
t.Fatalf("köp: %+v", g.Chosen)
}
// Utan råd går det inte att köpa.
p := NewTicket(rng.FromInt(4), "渋谷", []string{"東京"}, 100)
p.Update(Input{Confirm: true}, frame)
if p.Done() {
t.Fatal("utan råd ska köpet inte gå igenom")
}
p.Update(Input{Cancel: true}, frame)
if !p.Done() || p.Success() {
t.Fatal("Esc avbryter")
}
}
func TestCrowdZoneAndAttempts(t *testing.T) {
g := NewCrowd(rng.FromInt(7), 2)
if g.ZoneLo < 0.1 || g.ZoneHi > 0.9 || math.Abs((g.ZoneHi-g.ZoneLo)-crowdZoneWidth) > 1e-9 {
t.Fatalf("zon %.2f–%.2f", g.ZoneLo, g.ZoneHi)
}
// Vänta tills markören är utanför zonen och tryck: ett bommat försök.
for g.InZone() {
g.Update(Input{}, frame)
}
g.Update(Input{Confirm: true}, frame)
if g.Done() || g.Attempts != 1 {
t.Fatalf("done=%v attempts=%d", g.Done(), g.Attempts)
}
// Vänta in zonen och tryck: lyckas.
for i := 0; i < 600 && !g.InZone(); i++ {
g.Update(Input{}, frame)
}
g.Update(Input{Confirm: true}, frame)
if !g.Done() || !g.Success() {
t.Fatal("träff i zonen ska lyckas")
}
// Två bom = tåget går.
f := NewCrowd(rng.FromInt(7), 2)
for n := 0; n < 2; n++ {
for f.InZone() {
f.Update(Input{}, frame)
}
f.Update(Input{Confirm: true}, frame)
}
if !f.Done() || f.Success() {
t.Fatal("två bom ska misslyckas")
}
// Markören studsar och håller sig i 0..1.
b := NewCrowd(rng.FromInt(8), 1)
for i := 0; i < 600; i++ {
b.Update(Input{}, frame)
if b.Pos < 0 || b.Pos > 1 {
t.Fatalf("pos=%.2f", b.Pos)
}
}
}
func TestRideStopsAndGettingOff(t *testing.T) {
fillers := []string{"東京", "上野", "新宿", "成田"}
g := NewRide(rng.FromInt(3), "渋谷", fillers, 4, true)
if len(g.Stops) != 4 || g.Dest < 1 || g.Stops[g.Dest] != "渋谷" {
t.Fatalf("stops=%v dest=%d", g.Stops, g.Dest)
}
h := NewRide(rng.FromInt(3), "渋谷", fillers, 4, true)
if strings.Join(g.Stops, ",") != strings.Join(h.Stops, ",") {
t.Fatal("samma seed = samma linje")
}
// Åk till resmålet och kliv av.
for g.Index != g.Dest {
g.Update(Input{}, frame)
}
g.Update(Input{Confirm: true}, frame)
if !g.Done() || !g.Success() || g.GotOff != g.Dest {
t.Fatal("kliva av på resmålet ska lyckas")
}
// Kliva av för tidigt.
e := NewRide(rng.FromInt(3), "渋谷", fillers, 4, true)
e.Update(Input{Confirm: true}, frame)
if !e.Done() || e.Success() || e.GotOff != 0 {
t.Fatal("fel station = misslyckat")
}
// Åka förbi.
m := NewRide(rng.FromInt(3), "渋谷", fillers, 4, true)
for i := 0; i < 60*20 && !m.Done(); i++ {
m.Update(Input{}, frame)
}
if !m.Done() || m.Success() || m.Index != 3 {
t.Fatalf("åka förbi: done=%v success=%v index=%d", m.Done(), m.Success(), m.Index)
}
// Fel tåg: resmålet finns inte på linjen, så det går aldrig att lyckas.
w := NewRide(rng.FromInt(3), "渋谷", fillers, 4, false)
if w.Dest != -1 || strings.Contains(strings.Join(w.Stops, ","), "渋谷") {
t.Fatalf("fel tåg: %v", w.Stops)
}
w.Update(Input{Confirm: true}, frame)
if w.Success() {
t.Fatal("på fel tåg kan man inte komma rätt")
}
}
func TestRideWithoutFillersStillHasDestination(t *testing.T) {
g := NewRide(rng.FromInt(1), "渋谷", nil, 3, true)
if len(g.Stops) != 3 || g.Dest < 1 || g.Stops[g.Dest] != "渋谷" || g.Stops[0] != UnknownStop {
t.Fatalf("stops=%v dest=%d", g.Stops, g.Dest)
}
}

12
internal/minigame/rand.go Normal file
View File

@@ -0,0 +1,12 @@
package minigame
import "math/rand/v2"
// randSrc är ett tunt lager över math/rand/v2 så att minispelen bara använder
// ett fåtal väldefinierade operationer (lätt att hålla deterministiska).
type randSrc struct{ r *rand.Rand }
func (s *randSrc) intn(n int) int { return s.r.IntN(n) }
func (s *randSrc) float() float64 { return s.r.Float64() }
func (s *randSrc) between(lo, hi float64) float64 { return lo + (hi-lo)*s.r.Float64() }
func (s *randSrc) shuffle(n int, swap func(i, j int)) { s.r.Shuffle(n, swap) }

70
internal/minigame/ride.go Normal file
View File

@@ -0,0 +1,70 @@
package minigame
import "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
// Ride är tågresan: stationerna passerar en i taget med "次は …"-utrop;
// tryck Enter vid ditt resmål. Fel station eller att åka förbi är misslyckat.
type Ride struct {
base
Stops []string // stationsnamn på japanska i ordning
Dest int // index för resmålet, -1 om det inte finns på linjen (fel tåg)
Index int // aktuell station
Timer float64 // sekunder kvar vid aktuell station
StopSec float64
GotOff int // index där spelaren klev av, -1 = inte än
}
const rideStopSec = 2.5
// NewRide bygger linjen: fillers är andra stationsnamn (blandas), dest läggs
// på en seedad plats (aldrig först) om det är rätt tåg, annars utelämnas det.
func NewRide(seed rng.Seed, dest string, fillers []string, stops int, correctTrain bool) *Ride {
r := newRand(seed, "ride")
f := append([]string(nil), fillers...)
r.shuffle(len(f), func(i, j int) { f[i], f[j] = f[j], f[i] })
stops = max(2, stops)
g := &Ride{StopSec: rideStopSec, Timer: rideStopSec, GotOff: -1, Dest: -1}
if correctTrain {
g.Dest = 1 + r.intn(stops-1)
}
for i := 0; i < stops; i++ {
switch {
case i == g.Dest:
g.Stops = append(g.Stops, dest)
case len(f) > 0:
g.Stops = append(g.Stops, f[0])
f = f[1:]
default:
g.Stops = append(g.Stops, UnknownStop) // slut på namn: en namnlös station
}
}
return g
}
// UnknownStop används när det inte finns fler stationsnamn att ta av.
const UnknownStop = "・・・"
// Current är stationen tåget står vid.
func (g *Ride) Current() string { return g.Stops[g.Index] }
// Update låter stationerna passera och tar emot "kliv av".
func (g *Ride) Update(in Input, dt float64) {
if g.done {
return
}
if in.Confirm {
g.GotOff = g.Index
g.done = true
g.success = g.Index == g.Dest
return
}
g.Timer -= dt
if g.Timer <= 0 {
g.Index++
g.Timer = g.StopSec
if g.Index >= len(g.Stops) {
g.Index = len(g.Stops) - 1
g.done = true // åkte till slutstationen utan att kliva av
}
}
}

View File

@@ -0,0 +1,53 @@
package minigame
import "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
// Ticket är biljettautomaten: en taxetavla med resmål (på japanska) i seedad
// ordning. Spelaren väljer ett resmål och betalar – om det var rätt avgör
// spärren/tåget senare.
type Ticket struct {
base
Options []TicketOption
Cursor int
Chosen *TicketOption
Cash int
}
// TicketOption är ett resmål på tavlan.
type TicketOption struct {
Dest string // resmålets namn på japanska
Fare int
}
// NewTicket bygger tavlan: rätt resmål + lockande alternativ, blandade.
// Taxan är seedad per resmål (140–620 yen i steg om 10).
func NewTicket(seed rng.Seed, correct string, others []string, cash int) *Ticket {
r := newRand(seed, "ticket")
g := &Ticket{Cash: cash}
names := append([]string{correct}, others...)
for _, n := range names {
g.Options = append(g.Options, TicketOption{Dest: n, Fare: 140 + 10*r.intn(49)})
}
r.shuffle(len(g.Options), func(i, j int) { g.Options[i], g.Options[j] = g.Options[j], g.Options[i] })
return g
}
// Update flyttar markören och köper.
func (g *Ticket) Update(in Input, dt float64) {
if g.done {
return
}
if in.Cancel {
g.done = true
return
}
g.Cursor = wrap(g.Cursor+in.Move, len(g.Options))
if in.Confirm {
opt := g.Options[g.Cursor]
if opt.Fare > g.Cash {
return // har inte råd: välj annat eller avbryt
}
g.Chosen = &opt
g.done, g.success = true, true
}
}

107
internal/minigame/topup.go Normal file
View File

@@ -0,0 +1,107 @@
package minigame
import "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
// TopUp är påfyllningsautomaten: hitta チャージ-knappen bland japanska
// knappar i seedad ordning, välj belopp, mata in sedlar genom att hålla
// Enter. Fel knapp kostar en straffsekund eller två.
type TopUp struct {
base
Stage TopUpStage
Buttons []string // knapparnas text, seedad ordning
Cursor int
Amounts []int
Mistakes int
Penalty float64 // sekunder kvar av frysning efter fel knapp
Fill float64 // 0..1 sedelinmatning
Amount int // valt belopp (resultat)
MaxCash int // vad spelaren har råd med
}
// TopUpStage är var i flödet man är.
type TopUpStage int
// Steg.
const (
TopUpPickButton TopUpStage = iota
TopUpPickAmount
TopUpFeed
)
// Knapparna på en japansk biljettautomat. チャージ är den rätta.
var topUpButtons = []string{"チャージ", "きっぷ", "定期券", "取消"}
// TopUpAmounts är beloppen man kan välja.
var TopUpAmounts = []int{1000, 2000, 3000, 5000}
const (
topUpPenaltySec = 1.5
topUpFeedPerSec = 0.8 // hålla Enter: fyller på 1/0.8 s
topUpDrainPerSec = 1.2
)
// NewTopUp skapar automaten. maxCash begränsar beloppen.
func NewTopUp(seed rng.Seed, maxCash int) *TopUp {
r := newRand(seed, "topup")
g := &TopUp{Buttons: append([]string(nil), topUpButtons...), MaxCash: maxCash}
r.shuffle(len(g.Buttons), func(i, j int) { g.Buttons[i], g.Buttons[j] = g.Buttons[j], g.Buttons[i] })
for _, a := range TopUpAmounts {
if a <= maxCash {
g.Amounts = append(g.Amounts, a)
}
}
if len(g.Amounts) == 0 {
g.done = true // inga pengar: inget att göra
}
return g
}
// Update för automaten framåt.
func (g *TopUp) Update(in Input, dt float64) {
if g.done {
return
}
if in.Cancel {
g.done = true
return
}
if g.Penalty > 0 {
g.Penalty -= dt
return
}
switch g.Stage {
case TopUpPickButton:
g.Cursor = wrap(g.Cursor+in.Move, len(g.Buttons))
if in.Confirm {
if g.Buttons[g.Cursor] == topUpButtons[0] {
g.Stage, g.Cursor = TopUpPickAmount, 0
} else {
g.Mistakes++
g.Penalty = topUpPenaltySec
}
}
case TopUpPickAmount:
g.Cursor = wrap(g.Cursor+in.Move, len(g.Amounts))
if in.Confirm {
g.Amount = g.Amounts[g.Cursor]
g.Stage = TopUpFeed
}
case TopUpFeed:
if in.Hold {
g.Fill += topUpFeedPerSec * dt
} else {
g.Fill = max(0, g.Fill-topUpDrainPerSec*dt)
}
if g.Fill >= 1 {
g.Fill = 1
g.done, g.success = true, true
}
}
}
func wrap(i, n int) int {
if n == 0 {
return 0
}
return ((i % n) + n) % n
}