M7: svårighet, tid, poäng, debuffar, egna kort och sittplatsval
All checks were successful
release / build-release (push) Successful in 1m37s
All checks were successful
release / build-release (push) Successful in 1m37s
- sim/stats.go: Stats (fart, törst, fokus, toalett, tur), debuffar som multiplikatorer, Profile som följer med mellan banor och räknar ned - sim.Run: tidsgräns och maxantal missade tåg förlorar banan; poängformel (tid mot par, pengar, sällskapets mätare, utforskat, avdrag); debuffar tjänas av många steg, lång tid, tom matmätare och seedad otur vid kiosken; stats påverkar fart, mätartakter, läsning och hjälptid - egna kort (individualCards): Member.Card, spärren kräver allas saldo och drar från alla, automaten frågar vems kort, biljetter köps åt alla - sittplatsval (seatSelection): Ticket får ett sittplatssteg med seedade önskemål (fönster/gång/sida/bredvid); missnöjda ger mätar- och poängavdrag - level: maxMissedTrains, individualCards, seatSelection; bana 003 Shibuya (svårighet 3, fyra i sällskapet) och 002 med tidsgräns och sittplatser; ordboken utökad; generatorn är spec-driven - game: tid kvar/missade i HUD, debuffar i meny/HUD/resultat, poäng och förlustorsaker på resultatet, vyer för personval och vagn, skript-pick, flaggan -debuffs; HUD och resultat komprimerade för fyra i sällskapet - plan (Björn 2026-10-24): flera våningar med trappor som M9a, egna kort och sittplatsval som svårighetsinställningar i M7 – i spelbeskrivning, roadmap, testplan - docs/README/wiki med sittplatsbilden Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -76,8 +76,46 @@ type Run struct {
|
||||
LostReason string // varför banan förlorades (nyckel för UI)
|
||||
FlashKey string // kort meddelande till HUD (textnyckel)
|
||||
FlashAt float64 // < 0 = aldrig
|
||||
|
||||
// Svårighet, poäng och debuffar (M7).
|
||||
Stats Stats // multiplikatorer från profilens debuffar
|
||||
Grumpy int // missnöjda med sittplatsen
|
||||
poisoned bool // otur vid kiosken: matförgiftning efter banan
|
||||
spentStart int // plånbokens Spent vid banstart
|
||||
Score ScoreBreakdown
|
||||
NewDebuffs []Debuff // debuffar banan gav (appliceras på profilen efteråt)
|
||||
}
|
||||
|
||||
// ScoreBreakdown är poängen för en vunnen bana.
|
||||
type ScoreBreakdown struct {
|
||||
Time, Money, Meters, Explore, Penalty, Total int
|
||||
}
|
||||
|
||||
// Poängformelns vikter.
|
||||
const (
|
||||
ScoreTimeMax = 500 // par eller snabbare
|
||||
ScoreMoneyMax = 300 // minus 1 per 10 yen spenderade på banan
|
||||
ScoreMetersMax = 200 // sällskapets mätare vid målgång
|
||||
ScoreExploreMax = 100 // utforskad andel
|
||||
PenaltyMissed = 50
|
||||
PenaltyRescan = 10
|
||||
PenaltyGrumpy = 30
|
||||
GrumpyFoodPenalty = 25.0
|
||||
)
|
||||
|
||||
// Fler förlustorsaker.
|
||||
const (
|
||||
LostTimeUp = "result.lost_time_up"
|
||||
LostMissedTrains = "result.lost_missed_trains"
|
||||
)
|
||||
|
||||
// Fler flash-nycklar.
|
||||
const (
|
||||
FlashGrumpy = "hud.grumpy"
|
||||
FlashPoisoned = "hud.poisoned"
|
||||
FlashNoCards = "hud.need_all_cards"
|
||||
)
|
||||
|
||||
// MiniKind är vilket minispel som pågår.
|
||||
type MiniKind int
|
||||
|
||||
@@ -119,11 +157,12 @@ 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
|
||||
Wallet *Wallet // nil = startkassa
|
||||
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
|
||||
Debuffs []Debuff // aktiva debuffar från profilen
|
||||
Seed rng.Seed // omgångens seed (sparas med spelet)
|
||||
}
|
||||
|
||||
// DefaultSpeed är spelarens grundfart i rutor per sekund.
|
||||
@@ -177,7 +216,10 @@ func NewRun(l *level.Level, o RunOptions) *Run {
|
||||
baseSpeed: o.Speed,
|
||||
NoticeAt: -1,
|
||||
Fog: NewFog(l, FogRadius),
|
||||
Stats: CombinedStats(o.Debuffs),
|
||||
spentStart: o.Wallet.Spent,
|
||||
}
|
||||
r.Party.Rates = Rates{Thirst: r.Stats[StatThirst], Toilet: r.Stats[StatToilet]}
|
||||
r.Fog.Reveal(l, l.SpawnPoint())
|
||||
if r.parSec <= 0 {
|
||||
n := len(l.Path(l.SpawnPoint(), l.CorrectPlatform(), l.CanEnter))
|
||||
@@ -218,9 +260,36 @@ func (r *Run) CanEnter(p level.Point) bool {
|
||||
|
||||
func (r *Run) canPassGate() bool {
|
||||
if r.Level.TicketRequired {
|
||||
return r.Wallet.Ticket != ""
|
||||
if r.Wallet.Ticket == "" {
|
||||
return false
|
||||
}
|
||||
return !r.Level.IndividualCards || r.Wallet.TicketCount >= r.Party.Size()
|
||||
}
|
||||
return r.Wallet.CardUsable() && r.Wallet.Card >= r.Level.FareYen
|
||||
if !r.Wallet.CardUsable() || r.Wallet.Card < r.Level.FareYen {
|
||||
return false
|
||||
}
|
||||
if r.Level.IndividualCards {
|
||||
for _, m := range r.Party.Followers() {
|
||||
if m.Card < r.Level.FareYen {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MembersWithoutFare listar följare vars egna kort inte räcker (HUD).
|
||||
func (r *Run) MembersWithoutFare() []*Member {
|
||||
if !r.Level.IndividualCards {
|
||||
return nil
|
||||
}
|
||||
var out []*Member
|
||||
for _, m := range r.Party.Followers() {
|
||||
if m.Card < r.Level.FareYen {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *Run) inGate() bool {
|
||||
@@ -240,7 +309,7 @@ func (r *Run) Step(dt float64, want Dir, board bool) {
|
||||
r.Wallet.Drain(dt)
|
||||
before := r.Player.Tile
|
||||
wasGate := r.inGate()
|
||||
r.Player.Speed = r.baseSpeed * r.Party.SpeedFactor()
|
||||
r.Player.Speed = r.baseSpeed * r.Party.SpeedFactor() * r.Stats[StatSpeed]
|
||||
if r.Wallet.Phone && r.Wallet.Battery > 0 {
|
||||
r.Player.Speed *= PhoneSpeedFactor
|
||||
}
|
||||
@@ -260,11 +329,18 @@ func (r *Run) Step(dt float64, want Dir, board bool) {
|
||||
r.stepHelp(dt)
|
||||
r.stepFacility(dt)
|
||||
r.stepReading()
|
||||
r.checkLimits()
|
||||
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 {
|
||||
if r.Level.IndividualCards {
|
||||
for _, m := range r.Party.Followers() {
|
||||
m.Card -= r.Level.FareYen
|
||||
r.Wallet.Spent += r.Level.FareYen
|
||||
}
|
||||
}
|
||||
if r.Wallet.Phone && r.Seed.Derive(fmt.Sprintf("rescan:%d", r.GatePasses)).New().Float64() < PhoneRescanChance/r.Stats[StatLuck] {
|
||||
r.Rescans++
|
||||
r.RescanUntil = r.Time + RescanSec
|
||||
r.flash(FlashRescan)
|
||||
@@ -321,10 +397,22 @@ func (r *Run) startMachine() {
|
||||
}
|
||||
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
|
||||
var seats []string
|
||||
if r.Level.SeatSelection {
|
||||
seats = r.Party.Names()
|
||||
}
|
||||
cash := r.Wallet.Cash
|
||||
if r.Level.IndividualCards {
|
||||
cash /= r.Party.Size() // alla behöver en biljett: visa vad som går att köpa per person
|
||||
}
|
||||
r.Mini, r.MiniKind = minigame.NewTicket(r.miniSeed(), dest, r.stationPool(dest, 4), cash, seats), MiniTicket
|
||||
return
|
||||
}
|
||||
r.Mini, r.MiniKind = minigame.NewTopUp(r.miniSeed(), r.Wallet.Cash), MiniTopUp
|
||||
var members []string
|
||||
if r.Level.IndividualCards {
|
||||
members = r.Party.Names()
|
||||
}
|
||||
r.Mini, r.MiniKind = minigame.NewTopUp(r.miniSeed(), r.Wallet.Cash, members), MiniTopUp
|
||||
}
|
||||
|
||||
// tryBoard försöker kliva på: biljetten måste gälla tåget, fulla tåg kräver
|
||||
@@ -348,7 +436,7 @@ func (r *Run) tryBoard(tr *level.Train) {
|
||||
func (r *Run) startRide() {
|
||||
tr := r.PendingTrain
|
||||
if r.Level.TicketRequired {
|
||||
r.Wallet.Ticket, r.Wallet.TicketFare = "", 0 // biljetten är förbrukad
|
||||
r.Wallet.Ticket, r.Wallet.TicketFare, r.Wallet.TicketCount = "", 0, 0 // biljetterna är förbrukade
|
||||
}
|
||||
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
|
||||
@@ -365,18 +453,43 @@ func (r *Run) StepMini(dt float64, in minigame.Input) {
|
||||
r.Party.Advance(dt, r.Time)
|
||||
r.Mini.Update(in, dt)
|
||||
if !r.Mini.Done() {
|
||||
r.checkLimits()
|
||||
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) {
|
||||
if !g.Success() {
|
||||
break
|
||||
}
|
||||
if g.Member == 0 {
|
||||
if r.Wallet.TopUp(g.Amount) {
|
||||
r.TopUps++
|
||||
}
|
||||
} else if r.Wallet.Cash >= g.Amount {
|
||||
r.Wallet.Cash -= g.Amount
|
||||
r.Party.Members[g.Member].Card += 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
|
||||
if !g.Success() || g.Chosen == nil {
|
||||
break
|
||||
}
|
||||
count := 1
|
||||
if r.Level.IndividualCards {
|
||||
count = r.Party.Size()
|
||||
}
|
||||
if r.Wallet.PayCash(g.Chosen.Fare * count) {
|
||||
r.Wallet.Ticket, r.Wallet.TicketFare, r.Wallet.TicketCount = g.Chosen.Dest, g.Chosen.Fare, count
|
||||
}
|
||||
if len(g.Unhappy) > 0 {
|
||||
r.Grumpy += len(g.Unhappy)
|
||||
for _, i := range g.Unhappy {
|
||||
m := r.Party.Members[i]
|
||||
m.Meters.Food = max(0, m.Meters.Food-GrumpyFoodPenalty)
|
||||
}
|
||||
r.flash(FlashGrumpy)
|
||||
}
|
||||
case *minigame.Crowd:
|
||||
if g.Success() {
|
||||
@@ -432,8 +545,73 @@ func (r *Run) stepFacility(dt float64) {
|
||||
}
|
||||
|
||||
// buyAtKiosk köper mat och dryck åt alla som behöver, betalat med kortet.
|
||||
// Med liten seedad otur blir maten dålig: matförgiftning efter banan.
|
||||
func (r *Run) buyAtKiosk() {
|
||||
r.Served += r.Party.BuyAtKiosk(r.Wallet.PayCard)
|
||||
n := r.Party.BuyAtKiosk(r.Wallet.PayCard)
|
||||
if n == 0 {
|
||||
return
|
||||
}
|
||||
r.Served += n
|
||||
if !r.poisoned && r.Seed.Derive(fmt.Sprintf("poison:%d", r.Served)).New().Float64() < FoodPoisonChance/r.Stats[StatLuck] {
|
||||
r.poisoned = true
|
||||
r.flash(FlashPoisoned)
|
||||
}
|
||||
}
|
||||
|
||||
// checkLimits förlorar banan när tidsgränsen eller maxantalet missade tåg passeras.
|
||||
func (r *Run) checkLimits() {
|
||||
if r.Outcome != Ongoing {
|
||||
return
|
||||
}
|
||||
if r.Level.TimeLimitSec > 0 && r.Time > float64(r.Level.TimeLimitSec) {
|
||||
r.Mini, r.MiniKind, r.PendingTrain = nil, MiniNone, nil
|
||||
r.finish(nil, false, LostTimeUp)
|
||||
return
|
||||
}
|
||||
if r.Level.MaxMissedTrains > 0 && r.MissedTrains > r.Level.MaxMissedTrains {
|
||||
r.finish(nil, false, LostMissedTrains)
|
||||
}
|
||||
}
|
||||
|
||||
// TimeLeft är sekunder kvar av tidsgränsen (0 utan gräns).
|
||||
func (r *Run) TimeLeft() float64 {
|
||||
if r.Level.TimeLimitSec == 0 {
|
||||
return 0
|
||||
}
|
||||
return max(0, float64(r.Level.TimeLimitSec)-r.Time)
|
||||
}
|
||||
|
||||
// computeScore fyller i poängen för en vunnen bana.
|
||||
func (r *Run) computeScore() {
|
||||
var sc ScoreBreakdown
|
||||
sc.Time = int(ScoreTimeMax * min(1, r.parSec/max(r.Time, 0.001)))
|
||||
sc.Money = max(0, ScoreMoneyMax-(r.Wallet.Spent-r.spentStart)/10)
|
||||
sc.Meters = int(ScoreMetersMax * r.Party.AvgMeters())
|
||||
sc.Explore = int(ScoreExploreMax * r.Fog.Explored(r.Level))
|
||||
sc.Penalty = PenaltyMissed*r.MissedTrains + PenaltyRescan*r.Rescans + PenaltyGrumpy*r.Grumpy
|
||||
sc.Total = max(0, sc.Time+sc.Money+sc.Meters+sc.Explore-sc.Penalty)
|
||||
r.Score = sc
|
||||
}
|
||||
|
||||
// earnedDebuffs räknar ut vilka debuffar banan gav.
|
||||
func (r *Run) earnedDebuffs() []Debuff {
|
||||
var out []Debuff
|
||||
if r.Steps >= SoreFeetSteps {
|
||||
out = append(out, Debuff{SoreFeet, 1})
|
||||
}
|
||||
if r.Time >= TiredSec {
|
||||
out = append(out, Debuff{Tired, 1})
|
||||
}
|
||||
for _, m := range r.Party.Members {
|
||||
if m.Meters.Food <= 0 {
|
||||
out = append(out, Debuff{Hangry, 1})
|
||||
break
|
||||
}
|
||||
}
|
||||
if r.poisoned {
|
||||
out = append(out, Debuff{FoodPoisoning, FoodPoisonLevels})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FacilityProgress är 0..1 för ett pågående toalettbesök.
|
||||
@@ -472,12 +650,14 @@ func (r *Run) SignHere() *level.SignText {
|
||||
// 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
|
||||
r.NewDebuffs = r.earnedDebuffs()
|
||||
if !won {
|
||||
r.Outcome = Lost
|
||||
r.LostReason = reason
|
||||
return
|
||||
}
|
||||
r.Outcome = Won
|
||||
r.computeScore()
|
||||
r.Stars = r.StarsFor(r.Time)
|
||||
r.PointsEarned = r.Know.Award(r.Stars)
|
||||
if r.Stars >= lang.StarsToLearnDestination {
|
||||
@@ -515,7 +695,7 @@ func (r *Run) stepReading() {
|
||||
continue
|
||||
}
|
||||
seen[w] = true
|
||||
if roll.Float64() >= lang.ReadChance {
|
||||
if roll.Float64() >= lang.ReadChance*r.Stats[StatFocus] {
|
||||
continue
|
||||
}
|
||||
if r.Know.AddProgress(w) {
|
||||
@@ -547,7 +727,7 @@ func (r *Run) AskHelp() bool {
|
||||
if sg == nil || r.Help.Active || r.Revealed[r.Player.Tile] || r.Outcome != Ongoing {
|
||||
return false
|
||||
}
|
||||
secs := lang.HelpSeconds(r.Know.Level())
|
||||
secs := lang.HelpSeconds(r.Know.Level()) / r.Stats[StatFocus]
|
||||
r.Help = HelpState{Active: true, At: r.Player.Tile, Remaining: secs, Total: secs}
|
||||
return true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user