M7: svårighet, tid, poäng, debuffar, egna kort och sittplatsval
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:
2026-10-24 10:18:17 +02:00
parent 9fc7e94074
commit b2995d4ca9
44 changed files with 1567 additions and 177 deletions

View File

@@ -36,16 +36,16 @@ type LevelScene struct {
hideMap bool // M döljer/visar minikartan
}
// NewLevelScene laddar banan och ställer spelaren på startrutan. know är
// spelarens japanska, som delas mellan banor; seed styr omgångens slump.
func NewLevelScene(a *Assets, id string, know *lang.Knowledge, wallet *sim.Wallet, seed rng.Seed) (*LevelScene, error) {
// NewLevelScene laddar banan och ställer spelaren på startrutan. Kunskap,
// plånbok och debuffar tas från Game (delas mellan banor); seed styr slumpen.
func NewLevelScene(a *Assets, id string, g *Game, seed rng.Seed) (*LevelScene, error) {
lvl, err := level.LoadFile(filepath.Join(a.LevelsDir, id+".json"))
if err != nil {
return nil, err
}
s := &LevelScene{
id: id,
run: sim.NewRun(lvl, sim.RunOptions{Dict: a.Dict, Know: know, Wallet: wallet, Seed: seed}),
run: sim.NewRun(lvl, sim.RunOptions{Dict: a.Dict, Know: g.Know, Wallet: g.Wallet, Debuffs: g.Profile.Debuffs, Seed: seed}),
cam: sim.NewCamera(ScreenW, ScreenH, float64(lvl.Width()*TileSize), float64(lvl.Height()*TileSize)),
}
s.followPlayer()
@@ -79,8 +79,10 @@ func (s *LevelScene) followPlayer() {
func (s *LevelScene) Update(g *Game) error {
if s.run.Mini != nil {
// Minispel: all input går dit; Esc avbryter minispelet, inte banan.
s.run.StepMini(dt, minigame.Input{Move: miniMove(), Confirm: confirmJustPressed(), Hold: confirmHeld(), Cancel: backJustPressed()})
mx, my := miniMoveXY()
s.run.StepMini(dt, minigame.Input{Move: my, MoveX: mx, Confirm: confirmJustPressed(), Hold: confirmHeld(), Cancel: backJustPressed()})
if s.run.Outcome != sim.Ongoing {
g.levelEnded(s.run)
g.SetScene(NewResultScene(s))
}
return nil
@@ -107,11 +109,43 @@ func (s *LevelScene) Update(g *Game) error {
}
s.followPlayer()
if s.run.Outcome != sim.Ongoing {
g.levelEnded(s.run)
g.SetScene(NewResultScene(s))
}
return nil
}
// MiniPick flyttar markören i det pågående minispelet ett steg mot
// alternativet med texten text; true när det är valt (skriptläget).
func (s *LevelScene) MiniPick(text string) bool {
switch m := s.run.Mini.(type) {
case *minigame.Ticket:
if m.Stage == minigame.TicketPickDest {
if m.Options[m.Cursor].Dest == text {
return true
}
s.run.StepMini(0, minigame.Input{Move: 1})
return false
}
case *minigame.TopUp:
switch m.Stage {
case minigame.TopUpPickMember:
if m.Members[m.Cursor] == text {
return true
}
s.run.StepMini(0, minigame.Input{Move: 1})
return false
case minigame.TopUpPickButton:
if m.Buttons[m.Cursor] == text {
return true
}
s.run.StepMini(0, minigame.Input{MoveX: 1})
return false
}
}
return true
}
// trainTile är rutan "bortom perrongkanten" där tågvagnen ritas: första
// väggruta nedanför, till höger, till vänster eller ovanför perrongen.
func (s *LevelScene) trainTile(p level.Point) level.Point {
@@ -243,14 +277,26 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
a.Draw(screen, a.T("hud.destination")+": "+dest, destX, 0, colKnown)
a.Draw(screen, fmt.Sprintf("%s Lv%d·%d", a.T("hud.japanese"), r.Know.Level(), r.Know.Count()),
destX+a.Width(a.T("hud.destination")+": "+dest)+12, 0, colDim)
wallet := fmt.Sprintf("%s %3.0fs %s %d¥ %s %d¥", a.T("hud.time"), r.Time, a.T("hud.card"), r.Wallet.Card, a.T("hud.cash"), r.Wallet.Cash)
timeStr := fmt.Sprintf("%s %3.0fs", a.T("hud.time"), r.Time)
if r.Level.TimeLimitSec > 0 {
left := r.TimeLeft()
timeStr = fmt.Sprintf("%s %d:%02d", a.T("hud.time_left"), int(left)/60, int(left)%60)
}
wallet := fmt.Sprintf("%s %s %d¥ %s %d¥", timeStr, a.T("hud.card"), r.Wallet.Card, a.T("hud.cash_short"), r.Wallet.Cash)
if r.Level.MaxMissedTrains > 0 {
wallet += fmt.Sprintf(" %s %d/%d", a.T("hud.missed_short"), r.MissedTrains, r.Level.MaxMissedTrains)
}
if r.Wallet.Phone {
wallet += fmt.Sprintf(" %s %.0f%%", a.T("hud.phone"), r.Wallet.Battery)
}
if r.Wallet.Ticket != "" {
wallet += " " + a.T("hud.ticket") + " " + r.Wallet.Ticket
}
a.Draw(screen, wallet, 4, LineH, colDim)
walletCol := colDim
if r.Level.TimeLimitSec > 0 && r.TimeLeft() < 30 {
walletCol = colBad
}
a.Draw(screen, wallet, 4, LineH, walletCol)
s.drawMeters(g, screen)
if r.Outcome != sim.Ongoing || r.Mini != nil {
@@ -278,6 +324,13 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
}
lines = append(lines, a.wrapLine(plainLine(a.T(r.FlashKey), col), ScreenW-8)...)
}
if debuffs := g.Profile.Debuffs; len(debuffs) > 0 && r.Time < 6 {
var parts []string
for _, d := range debuffs {
parts = append(parts, fmt.Sprintf("%s (%d)", a.T("debuff."+string(d.Kind)), d.LevelsLeft))
}
lines = append(lines, a.wrapLine(plainLine(a.T("hud.debuffs")+": "+strings.Join(parts, ", "), colBad), ScreenW-8)...)
}
// Inforutans läge: nederkant, men flyttas upp under HUD:en om spelaren
// (eller tåget under hen) annars skulle hamna bakom den.
@@ -390,10 +443,18 @@ func (s *LevelScene) infoLines(a *Assets) (lines []line, accent color.Color, bar
if r.DeniedAt >= 0 && r.Time-r.DeniedAt < deniedShowSec {
key := "hud.gate_denied"
switch {
case lvl.TicketRequired:
case lvl.TicketRequired && r.Wallet.Ticket == "":
key = sim.FlashNoTicket
case lvl.TicketRequired:
key = "hud.need_all_tickets"
case r.Wallet.Phone && r.Wallet.Battery <= 0:
key = "hud.phone_dead"
case len(r.MembersWithoutFare()) > 0:
var names []string
for _, m := range r.MembersWithoutFare() {
names = append(names, m.Name)
}
return []line{plainLine(fmt.Sprintf(a.T(sim.FlashNoCards), strings.Join(names, ", ")), colBad)}, colBad, false
}
return []line{plainLine(a.T(key), colBad)}, colBad, false
}
@@ -403,11 +464,11 @@ func (s *LevelScene) infoLines(a *Assets) (lines []line, accent color.Color, bar
// drawMeters ritar sällskapets mat/dryck/toa-mätare uppe till höger.
func (s *LevelScene) drawMeters(g *Game, screen *ebiten.Image) {
members := s.run.Party.Members
const w, gap = 14, 4
const w, gap = 11, 3
x0 := ScreenW - 4 - len(members)*(w+gap)
for i, m := range members {
x := float32(x0 + i*(w+gap))
g.Assets.Draw(screen, string([]rune(m.Name)[:1]), int(x)+3, 0, colDim)
g.Assets.Draw(screen, string([]rune(m.Name)[:1]), int(x)+2, 0, colDim)
for j, v := range []struct {
val float64
col color.Color