Files
metro-puzzle-jp/internal/game/scene_level.go
claude 86f8130735
All checks were successful
release / build-release (push) Successful in 1m35s
M12: ljud, inställningar och release v0.1.0
tools/gensound syntetiserar 21 ljud: fyra stationsmelodier i klockklang
(en per station via melody-fältet eller banans id), ankomstjingel,
ding-dong vid inrullning, dörrsignal vid avgång, spärrens pip och
nekande, kiosk, toalett, lärt ord, bekräfta/varning, byte, personal,
vinst, förlust, flygplan, sorl och menymusik. sfx.go spelar effekter
utifrån vad som hänt i sim.Run sedan förra bildrutan. Ljudlagret har
huvudvolym × effekter/musik och byter inte slinga i onödan.
Inställningar (S på bankartan, sparas): volymer, språk sv/en som laddas
om direkt, fönsterskala, fullskärm (F11 överallt) och tangentöversikt.
Fönsterikon från genart. CHANGELOG.md. Tester för ljudfiler, melodival
och inställningar. Docs, wiki och skärmdumpar uppdaterade.

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

694 lines
23 KiB
Go

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
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
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)
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(NewMapScene(g))
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.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)
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)
dest := r.FinalDestination().Get("romaji")
if !r.IsLastLeg() { // resa med byte: den här delbanan slutar vid bytesstationen
dest += " " + 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
dest += " " + r.Level.Destination.Get("ja")
}
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)
}
a.Draw(screen, name, 4, 0, colText)
destX := 4 + a.Width(name) + 12
a.Draw(screen, a.T("hud.destination")+": "+dest, destX, 0, colKnown)
// Japanska-nivån får plats till vänster om mätarna; annars kortas den.
jpX := destX + a.Width(a.T("hud.destination")+": "+dest) + 12
metersX := ScreenW - 4 - len(r.Party.Members)*(meterW+meterGap)
jp := fmt.Sprintf("%s Lv%d·%d", a.T("hud.japanese"), r.Know.Level(), r.Know.Count())
if jpX+a.Width(jp) > metersX-4 {
jp = fmt.Sprintf("Lv%d", r.Know.Level())
}
if jpX+a.Width(jp) <= metersX-4 {
a.Draw(screen, jp, 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)
}
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.MaxMissed() > 0 {
wallet += fmt.Sprintf(" %s %d/%d", a.T("hud.missed_short"), r.MissedTrains, r.MaxMissed())
}
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
}
walletCol := colDim
if r.TimeLimit() > 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 || 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)
}
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)
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)
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 []line{plainLine(fmt.Sprintf(a.T("hud.staff_prompt"), sim.StaffSec), colWarn)}, colWarn, false
case lvl.OneWayGates && r.StaffUnlocked:
return []line{plainLine(a.T("hud.unlocked_prompt"), colGood)}, 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)
}
}
}