Files
metro-puzzle-jp/internal/sim/party.go
claude 4ae6a1d24c
All checks were successful
release / build-release (push) Successful in 1m37s
M10: byten, hemresan och levelcheck
Resor med byten: en bana kan ha next som pekar på en delbana (kind leg,
syns inte på bankartan). När man klivit av rätt tåg fortsätter samma
omgång på nästa station med klocka, plånbok, sällskap och räknare;
par-tiden adderas, dimma och folk är nya, en Byte!-banner visas och
HUD:en visar resmålet via bytesstationen. Tidsgräns och missade tåg
gäller hela resan. level.ValidateChains kräver sammanhängande kedjor.
Nya resor: 007 Akihabara -> Ueno -> Asakusa och finalen 008 Tokyo (två
våningar) -> Nippori -> Narita flygplats. Slutscen när sista huvudbanan
klaras första gången: planet lyfter, resan summeras, eftertexter;
Progress.StoryCleared sparas; flaggan -ending. tools/levelcheck ritar
banorna och rapporterar problem. HUD-nivån kortas när mätarna tar plats;
poängraden radbryts. Skärmdumpar och docs uppdaterade.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-24 13:37:48 +02:00

310 lines
8.7 KiB
Go
Raw Permalink 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"
"math/rand/v2"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
)
// Urge är ett behov som en i sällskapet kan få.
type Urge int
// Behov.
const (
NoUrge Urge = iota
NeedToilet // måste på toa -> toalettruta (W)
Thirsty // törstig -> kiosk (K)
Hungry // hungrig -> kiosk (K)
Distracted // distraherad av något – går över av sig självt
)
func (u Urge) String() string {
return [...]string{"", "toilet", "thirsty", "hungry", "distracted"}[u]
}
// Meters är mat, dryck och toa: 100 = allt väl, 0 = akut.
type Meters struct{ Food, Drink, Toilet float64 }
// Min är den lägsta mätaren.
func (m Meters) Min() float64 { return min(m.Food, m.Drink, m.Toilet) }
// Lowest returnerar behovet som hör till den lägsta mätaren.
func (m Meters) Lowest() (Urge, float64) {
u, v := Hungry, m.Food
if m.Drink < v {
u, v = Thirsty, m.Drink
}
if m.Toilet < v {
u, v = NeedToilet, m.Toilet
}
return u, v
}
// Member är en i sällskapet. Members[0] är spelaren själv.
type Member struct {
Name string
Gender string // "f", "m" eller "" (okänt) – kvinnovagnar bryr sig
Meters Meters
Urge Urge
UrgeAt float64 // när behovet uppstod
Card int // eget tågkort (yen) när banan kräver egna kort; spelaren använder plånboken
}
// MemberCardYen är vad följarnas egna kort har från start (lite – de får
// fyllas på).
const MemberCardYen = 500
// Rates skalar hur fort mätarna sjunker (debuffar/stats).
type Rates struct{ Thirst, Toilet float64 }
// PartyNames är namnen i den ordning de följer med.
var PartyNames = []string{"Du", "Anna", "Ville", "Sara", "Omar"}
// partyGenders hör ihop med PartyNames. Spelaren själv är okänd ("").
var partyGenders = []string{"", "f", "m", "f", "m"}
// Takter och trösklar. Mätarna går från 100 till 0 på ungefär 10/7/8 minuter.
const (
FoodPerSec = 100.0 / 600
DrinkPerSec = 100.0 / 420
ToiletPerSec = 100.0 / 480
UrgeThreshold = 25.0 // under detta uppstår ett behov av sig självt
BaseEventSec = 45.0 // händelseintervall för en ensam resenär; delas med antalet i sällskapet
DistractionSec = 4.0
UrgeSpeedFactor = 0.8 // någon har ett behov
DistractedSpeedFactor = 0.5
EmptySpeedFactor = 0.5 // någon mätare har nått 0
KioskPriceYen = 150
ToiletSecPerMember = 3.0
RestoreBelow = 50.0 // kiosk/toalett hjälper alla under den här nivån
)
// Party är sällskapet – lämmeltåget – med mätare, behov och följarnas spår.
type Party struct {
Members []*Member
Rates Rates
trail []level.Point // ledarens rutor, senaste sist
rand *rand.Rand
nextEventAt float64
Events int // antal händelser som inträffat
Log []string // händelselogg (tester/HUD)
}
// NewParty skapar n medlemmar (minst 1) på startrutan. All slump i
// sällskapet kommer från seed.
func NewParty(n int, start level.Point, seed rng.Seed) *Party {
n = max(1, min(n, len(PartyNames)))
p := &Party{rand: seed.New(), Rates: Rates{1, 1}}
for i := 0; i < n; i++ {
p.Members = append(p.Members, &Member{Name: PartyNames[i], Gender: partyGenders[i], Meters: Meters{100, 100, 100}, Card: MemberCardYen})
}
p.trail = []level.Point{start}
p.nextEventAt = p.interval() * (0.5 + p.rand.Float64())
return p
}
// Reset ställer hela sällskapet på en ny ruta (byte till nästa delbana).
func (p *Party) Reset(at level.Point) { p.trail = []level.Point{at} }
// Size är antalet i sällskapet inkl. spelaren.
func (p *Party) Size() int { return len(p.Members) }
// Followers är alla utom spelaren.
func (p *Party) Followers() []*Member { return p.Members[1:] }
// interval är tiden mellan händelser: fler i sällskapet = oftare.
func (p *Party) interval() float64 { return BaseEventSec / float64(p.Size()) }
// Advance sänker mätarna, låter behov uppstå och kör de slumpade
// händelserna. t är omgångens klocka.
func (p *Party) Advance(dt, t float64) {
for _, m := range p.Members {
m.Meters.Food = max(0, m.Meters.Food-FoodPerSec*dt)
m.Meters.Drink = max(0, m.Meters.Drink-DrinkPerSec*p.Rates.Thirst*dt)
m.Meters.Toilet = max(0, m.Meters.Toilet-ToiletPerSec*p.Rates.Toilet*dt)
if m.Urge == Distracted && t-m.UrgeAt >= DistractionSec {
m.Urge = NoUrge
}
if m.Urge == NoUrge {
if u, v := m.Meters.Lowest(); v < UrgeThreshold {
m.setUrge(u, t)
p.log(t, m, "behovet uppstod av sig självt")
}
}
}
if t >= p.nextEventAt {
p.event(t)
p.nextEventAt = t + p.interval()*(0.7+0.6*p.rand.Float64())
}
}
// event: en slumpad i sällskapet får ett behov – oftast det som mätaren
// står lägst i, ibland bara en distraktion.
func (p *Party) event(t float64) {
p.Events++
m := p.Members[p.rand.IntN(len(p.Members))]
if m.Urge != NoUrge {
p.log(t, m, "händelse utan effekt (hade redan ett behov)")
return
}
if p.rand.Float64() < 0.25 {
m.setUrge(Distracted, t)
p.log(t, m, "blev distraherad")
return
}
u, _ := m.Meters.Lowest()
m.setUrge(u, t)
// Behovet är på riktigt: dra ned mätaren till tröskeln.
switch u {
case NeedToilet:
m.Meters.Toilet = min(m.Meters.Toilet, UrgeThreshold)
case Thirsty:
m.Meters.Drink = min(m.Meters.Drink, UrgeThreshold)
case Hungry:
m.Meters.Food = min(m.Meters.Food, UrgeThreshold)
}
p.log(t, m, "fick behovet "+u.String())
}
func (m *Member) setUrge(u Urge, t float64) { m.Urge, m.UrgeAt = u, t }
func (p *Party) log(t float64, m *Member, what string) {
p.Log = append(p.Log, fmt.Sprintf("%.1fs %s: %s", t, m.Name, what))
}
// Urges returnerar de som har ett aktivt behov.
func (p *Party) Urges() []*Member {
var out []*Member
for _, m := range p.Members {
if m.Urge != NoUrge {
out = append(out, m)
}
}
return out
}
// SpeedFactor är hur mycket sällskapet saktar ned ledaren.
func (p *Party) SpeedFactor() float64 {
f := 1.0
for _, m := range p.Members {
switch {
case m.Meters.Min() <= 0:
f = min(f, EmptySpeedFactor)
case m.Urge == Distracted:
f = min(f, DistractedSpeedFactor)
case m.Urge != NoUrge:
f = min(f, UrgeSpeedFactor)
}
}
return f
}
// HasMen rapporterar om någon i sällskapet är man (kvinnovagnen).
func (p *Party) HasMen() bool {
for _, m := range p.Members {
if m.Gender == "m" {
return true
}
}
return false
}
// Names är medlemmarnas namn i ordning.
func (p *Party) Names() []string {
out := make([]string, len(p.Members))
for i, m := range p.Members {
out[i] = m.Name
}
return out
}
// AvgMeters är medelvärdet av allas lägsta mätare, 0..1 (poängen).
func (p *Party) AvgMeters() float64 {
sum := 0.0
for _, m := range p.Members {
sum += m.Meters.Min()
}
return sum / float64(len(p.Members)) / 100
}
// NeedsToilet är de som har nytta av en toalett just nu.
func (p *Party) NeedsToilet() []*Member {
var out []*Member
for _, m := range p.Members {
if m.Urge == NeedToilet || m.Meters.Toilet < RestoreBelow {
out = append(out, m)
}
}
return out
}
// NeedsKiosk är de som har nytta av mat eller dryck just nu.
func (p *Party) NeedsKiosk() []*Member {
var out []*Member
for _, m := range p.Members {
if m.Urge == Thirsty || m.Urge == Hungry || m.Meters.Drink < RestoreBelow || m.Meters.Food < RestoreBelow {
out = append(out, m)
}
}
return out
}
// UseToilet fyller toamätaren för alla som behövde och tar bort behovet.
// Returnerar antalet som gick.
func (p *Party) UseToilet() int {
n := 0
for _, m := range p.NeedsToilet() {
m.Meters.Toilet = 100
if m.Urge == NeedToilet {
m.Urge = NoUrge
}
n++
}
return n
}
// BuyAtKiosk köper mat/dryck åt alla som behöver, en betalning per person
// via pay (returnerar false när pengarna är slut). Returnerar antalet
// försörjda.
func (p *Party) BuyAtKiosk(pay func(yen int) bool) int {
n := 0
for _, m := range p.NeedsKiosk() {
if !pay(KioskPriceYen) {
break
}
m.Meters.Food, m.Meters.Drink = 100, 100
if m.Urge == Thirsty || m.Urge == Hungry {
m.Urge = NoUrge
}
n++
}
return n
}
// OnLeaderArrived registrerar att ledaren nått en ny ruta; följarna följer
// spåret bakom.
func (p *Party) OnLeaderArrived(tile level.Point) {
if p.trail[len(p.trail)-1] == tile {
return
}
p.trail = append(p.trail, tile)
if keep := p.Size() + 1; len(p.trail) > keep {
p.trail = p.trail[len(p.trail)-keep:]
}
}
// FollowerTiles returnerar var följare i (1 = närmast ledaren) står och vart
// hen är på väg; följaren interpoleras med samma framsteg som ledaren.
func (p *Party) FollowerTiles(i int) (from, to level.Point) {
at := func(k int) level.Point {
if k < 0 {
return p.trail[0]
}
return p.trail[k]
}
n := len(p.trail)
return at(n - 1 - i), at(n - i)
}