Files
metro-puzzle-jp/internal/game/scene_level.go
claude 5114115976
All checks were successful
release / build-release (push) Successful in 1m37s
Texterna ryms på skärmen på svenska och engelska
Björn såg att bankartans tangentrad klipptes på engelska. Nu mäts alla
enradstexter med den riktiga fonten i båda språken mot sitt utrymme
(game.TestSingleLineTextsFit) och HUD-raden för alla banor
(TestLevelNamesFitHUD). Åtgärder: kortare etiketter ("Mål"/"To"),
bankartans tangentrad på två rader (versionen står på titelskärmen),
HUD:en stryker japansk hint, "via" och valfria plånboksdelar som inte
får plats och kortar med "..", fler rutor radbryts, kortare bannamn,
kortare minispelstexter. Skärmdumpar på engelska (map-en, sign-en) via en
engelsk demosparfil. Alla skärmdumpar omtagna.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-25 18:10:26 +01:00

737 lines
25 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 game
import (
"fmt"
"image"
"image/color"
"math"
"math/rand/v2"
"path/filepath"
"strings"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
"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/nav"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
)
// Hur länge tillfälliga meddelanden visas.
const (
deniedShowSec = 2.0
learnedShowSec = 3.0
progressedShowSec = 2.5
flashShowSec = 2.5
transferBannerSec = 3.0
)
// Mätarnas bredd och mellanrum uppe till höger (per medlem).
const (
meterW = 11
meterGap = 3
)
// LevelScene är själva spelandet av en bana. All spellogik ligger i sim.Run;
// scenen översätter input och ritar.
type LevelScene struct {
id string
level *level.Level // aktuell delbana (byts när resan fortsätter på nästa station)
run *sim.Run
endless bool // målet är ett lottat mål i oändligt läge (inte storyn)
sfx sfxState
tips tipState
highlight bool // tutorialen: resmålets tecken markeras på skyltar, resten tonas ned
infoBox image.Rectangle // inforutans läge denna bildruta (tom = ingen), så att tipsen inte täcker den
cam *sim.Camera
animT float64
hideMap bool // M döljer/visar minikartan
shop *shop // öppen butik i kiosken (pausar banan)
outcome LevelOutcome // progression från banan (fylls i när den avgjorts)
posters map[level.Point]int // affischer per väggruta (visuell röra)
// Tåg och effekter (M7b).
tracks []trackInfo
rails map[level.Point]bool
roads map[level.Point]bool // vägrutor för bussar (ritas med vägsprite)
lastPhase map[level.Tile]level.Phase
particles []particle
rng *rand.Rand // bara för partiklar (ingen spellogik)
}
// NewLevelScene laddar banan och ställer spelaren på startrutan. Kunskap,
// plånbok, debuffar, skills och items tas från Game; seed styr slumpen.
func NewLevelScene(g *Game, id string, seed rng.Seed) (*LevelScene, error) {
a := g.Assets
lvl, err := level.LoadFile(filepath.Join(a.LevelsDir, id+".json"))
if err != nil {
return nil, err
}
return newLevelSceneFor(g, lvl, id, seed), nil
}
// newLevelSceneFor startar en redan laddad (eller lottad) bana.
func newLevelSceneFor(g *Game, lvl *level.Level, id string, seed rng.Seed) *LevelScene {
s := &LevelScene{id: id, run: sim.NewRun(lvl, g.runOptions(id, seed))}
s.rng = rand.New(rand.NewPCG(1, 2))
s.rebind(g)
return s
}
// ended bokför banans utgång: storyn via levelEnded, oändligt läge via
// endlessEnded.
func (s *LevelScene) ended(g *Game) {
if s.endless {
s.outcome = g.endlessEnded(s.run)
return
}
s.outcome = g.levelEnded(s.run, s.id)
}
// rebind bygger om allt som hör till den aktuella delbanan: kamera, räls,
// affischer, melodifaser och bakgrundsprat. Körs vid start och vid byte.
func (s *LevelScene) rebind(g *Game) {
lvl := s.run.Level
s.level = lvl
s.cam = sim.NewCamera(ScreenW, ScreenH, float64(lvl.Width()*TileSize), float64(lvl.Height()*TileSize))
s.cam.Margin = TileSize // rälsen ligger i kantraden: visa en ruta utanför kartan
s.lastPhase = map[level.Tile]level.Phase{}
s.particles = nil
s.buildTracks()
s.buildPosters()
g.startChatter(lvl.Noise)
for _, tr := range s.tracks { // ingen melodi för fasen banan råkar börja i
s.lastPhase[tr.tile], _ = s.run.PlatformPhase(tr.tile)
}
s.followPlayer()
}
// TrackPhaseName är fasen för närmaste perrong inom indikatoravståndet
// (skriptläget), "" om ingen är nära.
func (s *LevelScene) TrackPhaseName() string {
_, _, phase, _, _, ok := s.run.NearTrack(indicatorDist)
if !ok {
return ""
}
return phase.String()
}
// PlayerTile returnerar rutan spelaren står på/lämnar (används av skriptläget).
func (s *LevelScene) PlayerTile() level.Point { return s.run.Player.Tile }
// PlayerMoving rapporterar om spelaren är mellan två rutor (skriptläget).
func (s *LevelScene) PlayerMoving() bool { return s.run.Player.Moving() }
// TrainHere rapporterar om spelaren står på en perrong vars tåg är inne (skriptläget).
func (s *LevelScene) TrainHere() bool {
tr, at, _ := s.run.PlatformHere()
return tr != nil && at
}
// RideAtDest rapporterar om tågresan står vid resmålet (skriptläget). Utan
// pågående resa: true, så att skriptet inte hänger.
func (s *LevelScene) RideAtDest() bool {
g, ok := s.run.Mini.(*minigame.Ride)
return !ok || g.Index == g.Dest
}
// drawModeHint ritar navigeringslägets ruta nere till vänster när inforutan
// är tom: listläget säger vilket tåg man ska ta, skyltläget påminner om att
// man får lista ut det själv (och om bonusen).
func (s *LevelScene) drawModeHint(a *Assets, screen *ebiten.Image) {
r := s.run
txt, col := "", colGood
if r.Mode == nav.Signs {
txt, col = a.T("hud.mode_signs"), colWarn
} else if next := r.NextTrains(); len(next) > 0 {
txt = fmt.Sprintf("%s %s %s %s", a.T("hud.take"), strings.ToLower(a.T("hud.track")), next[0].Track, next[0].Line.Get("romaji"))
if len(next) > 1 {
txt += fmt.Sprintf(" (+%d)", len(next)-1)
}
}
if txt == "" {
return
}
y := ScreenH - LineH - 6
vector.DrawFilledRect(screen, 0, float32(y), float32(a.Width(txt)+8), float32(LineH+6), colPanel, false)
a.Draw(screen, txt, 4, y+3, col)
}
// ForceUrge gör en i sällskapet törstig (skriptläget: "urge NAMN" för
// skärmdumpar av kiosken).
func (s *LevelScene) ForceUrge(name string) bool {
for _, m := range s.run.Party.Members {
if m.Name == name {
m.Meters.Drink, m.Urge = 5, sim.Thirsty
return true
}
}
return false
}
// LevelOver rapporterar om banan är avgjord (skriptläget: "waitover").
func (s *LevelScene) LevelOver() bool { return s.run.Outcome != sim.Ongoing }
func (s *LevelScene) followPlayer() {
x, y := s.run.Player.WorldPos(TileSize)
s.cam.Follow(x+TileSize/2, y+TileSize/2)
}
func (s *LevelScene) Update(g *Game) error {
if s.shop != nil { // butiken pausar banan
s.updateShop(g)
return nil
}
s.updateEffects(g)
s.updateSfx(g)
s.updateTips(g)
s.highlight = s.tutorialActive(g)
if s.run.Mini != nil {
// Minispel: all input går dit; Esc avbryter minispelet, inte banan.
mx, my := miniMoveXY()
s.run.StepMini(dt, minigame.Input{Move: my, MoveX: mx, Confirm: confirmJustPressed(), Hold: confirmHeld(), Cancel: backJustPressed()})
if s.run.Level != s.level {
s.rebind(g) // bytet: resan fortsätter på nästa station
}
if s.run.Outcome != sim.Ongoing {
s.outcome = g.levelEnded(s.run, s.id)
g.SetScene(NewResultScene(s))
}
return nil
}
if backJustPressed() {
g.SetScene(NewPauseScene(s)) // Esc pausar; att lämna banan kräver ett val i menyn
return nil
}
if shopJustPressed() && !s.run.Player.Moving() && s.run.Level.At(s.run.Player.Tile) == level.Kiosk {
s.openShop(g)
return nil
}
if helpJustPressed() {
s.run.AskHelp()
}
if mapJustPressed() {
s.hideMap = !s.hideMap
}
s.run.Step(dt, dirInput(), confirmJustPressed())
if s.run.Player.Moving() {
s.animT += dt
} else {
s.animT = 0
}
s.followPlayer()
if s.run.Outcome != sim.Ongoing {
s.outcome = g.levelEnded(s.run, s.id)
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.CarPick:
if text == "calm" { // lugnaste vagnen som inte är kvinnovagn
best := -1
for i, c := range m.Cars {
if !c.WomenOnly && (best < 0 || c.Crowd < m.Cars[best].Crowd) {
best = i
}
}
if best < 0 || m.Cursor == best {
return true
}
s.run.StepMini(0, minigame.Input{MoveX: 1})
return false
}
case *minigame.Payer:
o := m.Options[m.Cursor]
if o.Name == text || (text == "cash" && o.Cash) {
return true
}
s.run.StepMini(0, minigame.Input{MoveX: 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
}
func (s *LevelScene) Draw(g *Game, screen *ebiten.Image) {
screen.Fill(color.RGBA{24, 20, 37, 255})
lvl := s.run.Level
ox, oy := s.cam.Offset()
ox, oy = math.Round(ox), math.Round(oy)
// Rita bara rutor som syns.
x0 := max(0, int(s.cam.X)/TileSize)
y0 := max(0, int(s.cam.Y)/TileSize)
x1 := min(lvl.Width(), int(s.cam.X+ScreenW)/TileSize+2)
y1 := min(lvl.Height(), int(s.cam.Y+ScreenH)/TileSize+2)
z := s.run.Player.Tile.Z // bara spelarens våning ritas
var op ebiten.DrawImageOptions
for y := y0; y < y1; y++ {
for x := x0; x < x1; x++ {
p := level.Point{X: x, Y: y, Z: z}
if s.rails[p] || s.roads[p] {
s.drawRails(g, screen, p, ox, oy)
continue
}
op.GeoM.Reset()
op.GeoM.Translate(ox+float64(x*TileSize), oy+float64(y*TileSize))
if v, ok := s.posters[p]; ok {
screen.DrawImage(g.Assets.Posters[v], &op)
continue
}
screen.DrawImage(g.Assets.Tiles[lvl.At(p)], &op)
}
}
s.drawTrains(g, screen, ox, oy)
s.drawQueueMarks(screen, ox, oy)
s.drawParticles(screen, ox, oy)
s.drawNPCs(g, screen, ox, oy)
// Följarna ritas bakifrån så att ledaren hamnar överst.
leader := s.run.Player
followers := s.run.Party.Followers()
for i := len(followers); i >= 1; i-- {
from, to := s.run.Party.FollowerTiles(i)
if from.Z != z && to.Z != z {
continue // på en annan våning
}
fx := float64(from.X*TileSize) + float64((to.X-from.X)*TileSize)*leader.Progress
fy := float64(from.Y*TileSize) + float64((to.Y-from.Y)*TileSize)*leader.Progress
facing := sim.Down
switch {
case to.X > from.X:
facing = sim.Right
case to.X < from.X:
facing = sim.Left
case to.Y < from.Y:
facing = sim.Up
}
op.GeoM.Reset()
op.GeoM.Translate(ox+math.Round(fx), oy+math.Round(fy))
op.ColorScale.Reset()
t := memberTints[i%len(memberTints)]
op.ColorScale.Scale(t[0], t[1], t[2], 1)
screen.DrawImage(g.Assets.PlayerFrame(facing, leader.Moving() && from != to, s.animT), &op)
op.ColorScale.Reset()
s.drawUrge(g, screen, followers[i-1], int(ox+math.Round(fx)), int(oy+math.Round(fy)))
}
px, py := leader.WorldPos(TileSize)
op.GeoM.Reset()
op.GeoM.Translate(ox+math.Round(px), oy+math.Round(py))
tint := g.Effects().Tint
op.ColorScale.Reset()
op.ColorScale.Scale(tint[0], tint[1], tint[2], 1)
screen.DrawImage(g.Assets.PlayerFrame(leader.Facing, leader.Moving(), s.animT), &op)
op.ColorScale.Reset()
s.drawUrge(g, screen, s.run.Party.Members[0], int(ox+math.Round(px)), int(oy+math.Round(py)))
s.drawIndicator(screen, ox, oy)
s.drawHUD(g, screen)
s.drawTicker(g, screen)
s.drawTip(g, screen)
if r := s.run; r.TransferAt >= 0 && r.Time-r.TransferAt < transferBannerSec && r.Mini == nil && r.Outcome == sim.Ongoing {
txt := g.Assets.T("hud.transfer_done")
w := g.Assets.Width(txt) + 16
x := (ScreenW - w) / 2
vector.DrawFilledRect(screen, float32(x), 60, float32(w), LineH+8, colPanel, false)
vector.DrawFilledRect(screen, float32(x), 60, float32(w), 2, colWarn, false)
g.Assets.Draw(screen, txt, x+8, 64, colWarn)
}
if s.run.Mini != nil {
s.drawMinigame(g, screen)
}
if s.shop != nil {
s.drawShop(g, screen)
}
}
// memberTints färgar följarnas sprites tills de har egna.
var memberTints = [][3]float32{{1, 1, 1}, {1, 0.75, 0.55}, {0.7, 0.95, 0.75}, {0.8, 0.7, 1}, {1, 0.95, 0.55}}
// urgeMark är tecknet som visas över huvudet på någon med ett behov.
func urgeMark(u sim.Urge) (string, color.Color) {
switch u {
case sim.NeedToilet:
return "WC", colBad
case sim.Thirsty:
return "飲", colEnglish
case sim.Hungry:
return "食", colWarn
case sim.Distracted:
return "?", colDim
}
return "", nil
}
func (s *LevelScene) drawUrge(g *Game, screen *ebiten.Image, m *sim.Member, x, y int) {
mark, col := urgeMark(m.Urge)
if mark == "" {
if m.Meters.Min() <= 0 {
mark, col = "!!", colBad
} else {
return
}
}
w := g.Assets.Width(mark)
vector.DrawFilledRect(screen, float32(x+8-w/2-1), float32(y-11), float32(w+2), 11, colPanel, false)
g.Assets.Draw(screen, mark, x+8-w/2, y-12, col)
}
// drawHUD ritar den övre raden (bana, resmål, tid, kort, japanska-nivå) och
// inforutan (perrong, skylt, nekad spärr, nytt ord).
func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
r := s.run
a := g.Assets
vector.DrawFilledRect(screen, 0, 0, ScreenW, 24, color.RGBA{24, 20, 37, 200}, false)
name := r.Level.Name.Get(UILang)
if r.Level.NumFloors() > 1 {
name += " " + r.Level.FloorName(r.Player.Tile.Z)
}
if r.Legs() > 1 {
name += fmt.Sprintf(" %d/%d", r.LegNo(), r.Legs())
}
if s.endless && g.Endless != nil {
name = fmt.Sprintf("#%d %s", g.Endless.Step+1, name)
}
metersX := ScreenW - 4 - len(r.Party.Members)*(meterW+meterGap)
name = a.fitText(name, metersX/2)
a.Draw(screen, name, 4, 0, colText)
destX := 4 + a.Width(name) + 12
// Resmålet: slutmålet, "via" bytesstationen och japanska hinten – i den
// ordningen stryks delar som inte får plats före mätarna.
final := r.FinalDestination().Get("romaji")
via, jp := "", ""
if !r.IsLastLeg() { // resa med byte: den här delbanan slutar vid bytesstationen
via = " " + a.T("hud.via") + " " + r.Level.Destination.Get("romaji")
}
if r.Know.ShowsDestinationJP() { // hint från nivå 1: delbanans mål på japanska, att matcha mot skyltarna
jp = " " + r.Level.Destination.Get("ja")
}
label := a.T("hud.destination") + ": "
dest := label + final + via + jp
for _, cand := range []string{label + final + via + jp, label + final + via, label + final + jp, label + final} {
dest = cand
if destX+a.Width(cand) <= metersX-4 {
break
}
}
dest = a.fitText(dest, metersX-4-destX)
a.Draw(screen, dest, destX, 0, colKnown)
// Japanska-nivån får plats till vänster om mätarna; annars kortas den.
jpX := destX + a.Width(dest) + 12
lv := fmt.Sprintf("%s Lv%d·%d", a.T("hud.japanese"), r.Know.Level(), r.Know.Count())
if jpX+a.Width(lv) > metersX-4 {
lv = fmt.Sprintf("Lv%d", r.Know.Level())
}
if jpX+a.Width(lv) <= metersX-4 {
a.Draw(screen, lv, jpX, 0, colDim)
}
timeStr := fmt.Sprintf("%s %3.0fs", a.T("hud.time"), r.Time)
if r.TimeLimit() > 0 {
left := r.TimeLeft()
timeStr = fmt.Sprintf("%s %d:%02d", a.T("hud.time_left"), int(left)/60, int(left)%60)
}
// Plånboksraden: tid, kort, kontanter, sedan valfria delar (missade tåg,
// biljett, telefon) som stryks bakifrån om de inte får plats före mätarna.
must := fmt.Sprintf("%s %s %d¥ %s %d¥", timeStr, a.T("hud.card"), r.Wallet.Card, a.T("hud.cash_short"), r.Wallet.Cash)
var optional []string
if r.MaxMissed() > 0 {
optional = append(optional, fmt.Sprintf("%s %d/%d", a.T("hud.missed_short"), r.MissedTrains, r.MaxMissed()))
}
if r.Wallet.Ticket != "" {
optional = append(optional, a.T("hud.ticket")+" "+r.Wallet.Ticket)
}
if r.Wallet.Phone {
optional = append(optional, fmt.Sprintf("%s %.0f%%", a.T("hud.phone"), r.Wallet.Battery))
}
wallet := must
for n := len(optional); n >= 0; n-- {
wallet = must
for _, o := range optional[:n] {
wallet += " " + o
}
if 4+a.Width(wallet) <= metersX-4 {
break
}
}
walletCol := colDim
if r.TimeLimit() > 0 && r.TimeLeft() < 30 {
walletCol = colBad
}
a.Draw(screen, a.fitText(wallet, metersX-8), 4, LineH, walletCol)
s.drawMeters(g, screen)
if r.Outcome != sim.Ongoing || r.Mini != nil || s.shop != nil {
return // resultatpanelen, minispelet eller butiken tar över; ingen inforuta eller minikarta
}
lines, accent, bar := s.infoLines(a)
if entry := a.Dict.Lookup(r.Learned); entry != nil && r.Time-r.LearnedAt < learnedShowSec {
lines = append(lines, plainLine(fmt.Sprintf("%s: %s = %s (%s)", a.T("hud.learned"), r.Learned,
entry.Translation(UILang), entry.Romaji), colKnown))
}
if len(r.Progressed) > 0 && r.Time-r.ProgressedAt < progressedShowSec {
lines = append(lines, a.wrapLine(plainLine(a.T("hud.progress")+": "+strings.Join(r.Progressed, ", "), colWarn), ScreenW-8)...)
}
if urges := r.Party.Urges(); len(urges) > 0 {
var parts []string
for _, m := range urges {
parts = append(parts, m.Name+": "+a.T("urge."+m.Urge.String()))
}
lines = append(lines, a.wrapLine(plainLine(strings.Join(parts, " "), colWarn), ScreenW-8)...)
}
if r.Flashing(flashShowSec) && r.FlashKey != sim.FlashTransfer { // bytet har sin egen banner
col := colBad
if r.FlashKey == sim.FlashRescan {
col = colWarn
}
lines = append(lines, a.wrapLine(plainLine(a.T(r.FlashKey), col), ScreenW-8)...)
}
if r.WrongLane {
lines = append(lines, plainLine(a.T("hud.wrong_lane"), colWarn))
} else if r.BumpedAt >= 0 && r.Time-r.BumpedAt < 1.0 {
lines = append(lines, plainLine(a.T("hud.bump"), colDim))
}
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.
var box image.Rectangle
hasBox := len(lines) > 0
if !hasBox {
s.drawModeHint(a, screen) // lägesrutan bara när ingen inforuta visas
}
if hasBox {
h := 8 + LineH*len(lines)
if bar {
h += 8
}
_, py := r.Player.WorldPos(TileSize)
y := ScreenH - h
if int(py-s.cam.Y)+2*TileSize > y {
y = 24
}
box = image.Rect(0, y, ScreenW, y+h)
}
s.infoBox = box
if !s.hideMap {
s.drawMinimap(g, screen, box, hasBox)
}
if !hasBox {
return
}
y, h := box.Min.Y, box.Dy()
vector.DrawFilledRect(screen, 0, float32(y), ScreenW, float32(h), colPanel, false)
vector.DrawFilledRect(screen, 0, float32(y), ScreenW, 2, accent, false)
a.drawLines(screen, lines, 4, y+4)
if bar {
p := r.HelpProgress()
if r.Facility.Active {
p = r.FacilityProgress()
}
if r.Staff.Active {
p = r.StaffProgress()
}
by := float32(y + h - 7)
vector.DrawFilledRect(screen, 4, by, ScreenW-8, 4, colBar, false)
vector.DrawFilledRect(screen, 4, by, float32((ScreenW-8)*p), 4, colKnown, false)
}
}
// infoLines bygger inforutans rader: perronginfo, skylt eller nekad spärr.
func (s *LevelScene) infoLines(a *Assets) (lines []line, accent color.Color, bar bool) {
r := s.run
lvl := r.Level
tile := r.Player.Tile
accent = colBar
maxW := ScreenW - 8
english := lang.ShowEnglish(lvl.ID, tile.X, tile.Y, lvl.Difficulty)
if tr, at, secs := r.PlatformHere(); tr != nil {
// Avgångstavlan är en skylt som alla andra: japanska, sedd genom kunskapen.
rd := lang.Render(a.Dict, r.Know, tr.BoardText(), UILang)
if s.highlight {
lines = append(lines, a.wrapLine(highlightLine(tr.BoardText(), lvl.Destination.Get("ja"), colGood, colDim), maxW)...)
} else {
lines = append(lines, a.wrapLine(shownLine(rd.Original, colText, r.Know.ShowsReadings()), maxW)...)
}
lines = append(lines, a.wrapLine(shownLine(rd.Partial, colText, false), maxW)...)
if english {
lines = append(lines, a.wrapLine(plainLine(fmt.Sprintf("%s: Track %s %s for %s", a.T("hud.english"), tr.Track,
tr.Line.Get("en"), tr.Destination.Get("en")), colEnglish), maxW)...)
}
phase, _ := r.PlatformPhase(lvl.At(tile))
switch {
case phase == level.Closing:
accent = colWarn
lines = append(lines, plainLine(fmt.Sprintf("%s %.0f s", a.T("hud.closing"), math.Ceil(secs)), colWarn))
case at:
accent = colGood
lines = append(lines, plainLine(fmt.Sprintf("%s %.0f s %s", a.T("hud.departs_in"), math.Ceil(secs), a.T("hud.board")), colGood))
case phase == level.Arriving:
accent = colWarn
lines = append(lines, plainLine(a.T("hud.arriving"), colWarn))
case phase == level.Approaching:
accent = colWarn
lines = append(lines, plainLine(fmt.Sprintf(a.T("hud.approaching"), math.Ceil(secs)), colWarn))
case phase == level.Leaving:
lines = append(lines, plainLine(fmt.Sprintf("%s %s %.0f s", a.T("hud.leaving"), a.T("hud.next_train_in"), math.Ceil(secs)), colDim))
default:
accent = colWarn
lines = append(lines, plainLine(fmt.Sprintf("%s %.0f s", a.T("hud.next_train_in"), math.Ceil(secs)), colWarn))
}
return lines, accent, false
}
// Nära rälsen (men inte på perrongen): man ser och hör tåget komma.
if _, _, phase, _, dist, ok := r.NearTrack(indicatorDist); ok && dist > 0 && phase == level.Approaching && r.SignHere() == nil {
return []line{plainLine(a.T("hud.train_near"), colWarn)}, colWarn, false
}
if sg := r.SignHere(); sg != nil {
know := r.Know
revealed := r.SignRevealed(tile)
if revealed {
know = nil // allt känt
accent = colKnown
}
rd := lang.Render(a.Dict, know, sg.Text["ja"], UILang)
if s.highlight {
lines = append(lines, a.wrapLine(highlightLine(sg.Text["ja"], lvl.Destination.Get("ja"), colGood, colDim), maxW)...)
} else {
lines = append(lines, a.wrapLine(shownLine(rd.Original, colText, r.Know.ShowsReadings()), maxW)...)
}
lines = append(lines, a.wrapLine(shownLine(rd.Partial, colText, false), maxW)...)
if english {
lines = append(lines, a.wrapLine(plainLine(a.T("hud.english")+": "+sg.Text.Get("en"), colEnglish), maxW)...)
}
switch {
case r.Help.Active:
lines = append(lines, plainLine(a.T("hud.help_progress"), colDim))
bar = true
case !revealed:
lines = append(lines, plainLine(fmt.Sprintf(a.T("hud.help_prompt"), lang.HelpSeconds(r.Know.Level())), colDim))
}
return lines, accent, bar
}
switch lvl.At(tile) {
case level.Gate:
switch {
case r.Staff.Active:
return []line{plainLine(a.T("hud.staff_busy"), colDim)}, colGood, true
case r.NoExitAt >= 0 && r.Time-r.NoExitAt < deniedShowSec:
return a.wrapLine(plainLine(fmt.Sprintf(a.T("hud.no_exit"), sim.StaffSec), colBad), maxW), colBad, false
case r.NeedsStaff():
return a.wrapLine(plainLine(fmt.Sprintf(a.T("hud.staff_prompt"), sim.StaffSec), colWarn), maxW), colWarn, false
case lvl.OneWayGates && r.StaffUnlocked:
return a.wrapLine(plainLine(a.T("hud.unlocked_prompt"), colGood), maxW), colGood, false
}
case level.Toilet:
if r.Facility.Active {
return []line{plainLine(a.T("hud.toilet_busy"), colDim)}, colGood, true
}
if n := len(r.Party.NeedsToilet()); n > 0 {
return []line{plainLine(fmt.Sprintf(a.T("hud.toilet_prompt"), n, sim.ToiletSecPerMember*float64(n)), colGood)}, colGood, false
}
return []line{plainLine(a.T("hud.toilet_none"), colDim)}, accent, false
case level.Kiosk:
if n := len(r.Party.NeedsKiosk()); n > 0 {
return []line{plainLine(fmt.Sprintf(a.T("hud.kiosk_prompt"), n, sim.KioskPriceYen*n)+" "+a.T("hud.shop_prompt"), colGood)}, colGood, false
}
return []line{plainLine(a.T("hud.kiosk_none")+" "+a.T("hud.shop_prompt"), colDim)}, accent, false
case level.TicketMachine:
// Terminalen: linjetavlan för stationen (nätkopplade banor), läst genom kunskapen som en skylt.
if term := r.TerminalLines(); len(term) > 0 {
lines = append(lines, plainLine(a.T("hud.terminal"), colDim))
for _, t := range term {
rd := lang.Render(a.Dict, r.Know, t, UILang)
lines = append(lines, a.wrapLine(shownLine(rd.Partial, colText, false), maxW)...)
}
}
if lvl.TicketRequired {
return append(lines, plainLine(fmt.Sprintf(a.T("hud.machine_ticket"), r.Wallet.Cash), colGood)), colGood, false
}
return append(lines, plainLine(fmt.Sprintf(a.T("hud.machine_topup"), r.Wallet.Card, r.Wallet.Cash), colGood)), colGood, false
}
if r.DeniedAt >= 0 && r.Time-r.DeniedAt < deniedShowSec {
key := "hud.gate_denied"
switch {
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
}
return nil, accent, false
}
// 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 = meterW, meterGap
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)+2, 0, colDim)
for j, v := range []struct {
val float64
col color.Color
}{{m.Meters.Food, colWarn}, {m.Meters.Drink, colEnglish}, {m.Meters.Toilet, colGood}} {
y := float32(13 + j*3)
vector.DrawFilledRect(screen, x, y, w, 2, colBar, false)
c := v.col
if v.val < sim.UrgeThreshold {
c = colBad
}
vector.DrawFilledRect(screen, x, y, float32(w*v.val/100), 2, c, false)
}
}
}