All checks were successful
release / build-release (push) Successful in 1m40s
Björns feedback efter v0.1.0. Titelskärm med premissen i text och menyn Fortsätt/Starta, Tutorial, Inställningar, Avsluta (Enter två gånger); språkval första gången spelet startar. Tutorial i fem sidor med riktiga sprites och en exempelskylt där resmålet markeras och resten tonas ned, plus nyckeln 番線/方面/改札 och biljettipset. Kontextuella tips i spelet tills första banan är klarad, och resmålets tecken markeras på skyltar och avgångstavlor under tiden. Esc i en bana öppnar en pausmeny (Fortsätt, Lämna banan, Inställningar, Avsluta spelet) i stället för att lämna banan; Esc på bankartan går till titeln. Kortare lägestexter så att bankartans rader ryms på svenska. Test för markeringen, docs, wiki och skärmdumpar. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
709 lines
24 KiB
Go
709 lines
24 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
|
|
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
|
|
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.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.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)
|
|
|
|
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)
|
|
}
|
|
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 []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)
|
|
}
|
|
}
|
|
}
|