All checks were successful
release / build-release (push) Successful in 1m41s
- sim.Wallet: IC-kort, kontanter, pappersbiljett, telefon med batteri, spenderat; följer med mellan banor (Game.Wallet) - internal/minigame: gemensamt Game-gränssnitt (Update/Done/Success), all slump från del-seeds: TopUp (hitta チャージ, belopp, mata sedlar), Ticket (taxetavla med resmål på japanska), Crowd (tidsfönster, två försök), Ride (stationerna passerar, kliv av vid resmålet) - sim.Run: spärren kräver biljett på biljettbanor annars användbart kort; telefonen ger ×1,1 fart men 15 % seedad omskanning (frysning 1,5 s), dött batteri gör kortet oanvändbart; automaten startar påfyllning eller biljettköp; tåget kontrollerar biljetten; fulla tåg kräver trängsel; resan avgör vinst/förlust (fel station, åkte förbi, fel tåg); StepMini låter klockan och sällskapet ticka medan rörelsen pausar - level: ticketRequired, rideStops, Train.Crowded; 002 är biljettbana med fullt tåg och fyra stopp; ordboken har stationer (kind) + sju nya namn - game: vyer för minispelen, plånbok i HUD, P i menyn = kort i telefonen, förlustorsaker och spenderat på resultatet, flaggor -phone/-card, skriptkommandon hold/waitstop; inforuta/minikarta döljs under minispel - plan: M7b (Björn) – tågvind, ankomstindikator nära rälsen, in-/utkörningsanimation för tåg och bussar – i spelbeskrivning, roadmap, testplan - docs/README/wiki med tre nya bilder (automat, biljettavla, tågresa) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
425 lines
14 KiB
Go
425 lines
14 KiB
Go
package game
|
|
|
|
import (
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"math"
|
|
"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/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
|
|
)
|
|
|
|
// 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
|
|
run *sim.Run
|
|
cam *sim.Camera
|
|
animT float64
|
|
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) {
|
|
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}),
|
|
cam: sim.NewCamera(ScreenW, ScreenH, float64(lvl.Width()*TileSize), float64(lvl.Height()*TileSize)),
|
|
}
|
|
s.followPlayer()
|
|
return s, nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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.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()})
|
|
if s.run.Outcome != sim.Ongoing {
|
|
g.SetScene(NewResultScene(s))
|
|
}
|
|
return nil
|
|
}
|
|
if backJustPressed() {
|
|
m, err := NewMenuScene(g.Assets)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
g.SetScene(m)
|
|
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 {
|
|
g.SetScene(NewResultScene(s))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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 {
|
|
for _, d := range [...]level.Point{{X: 0, Y: 1}, {X: 1, Y: 0}, {X: -1, Y: 0}, {X: 0, Y: -1}} {
|
|
n := level.Point{X: p.X + d.X, Y: p.Y + d.Y}
|
|
if s.run.Level.At(n.X, n.Y) == level.Wall {
|
|
return n
|
|
}
|
|
}
|
|
return p
|
|
}
|
|
|
|
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)
|
|
var op ebiten.DrawImageOptions
|
|
for y := y0; y < y1; y++ {
|
|
for x := x0; x < x1; x++ {
|
|
op.GeoM.Reset()
|
|
op.GeoM.Translate(ox+float64(x*TileSize), oy+float64(y*TileSize))
|
|
screen.DrawImage(g.Assets.Tiles[lvl.At(x, y)], &op)
|
|
}
|
|
}
|
|
// Tåg som står inne.
|
|
for _, t := range []level.Tile{level.Platform, level.WrongPlatform} {
|
|
if !s.run.TrainVisible(t) {
|
|
continue
|
|
}
|
|
for _, p := range lvl.Find(t) {
|
|
tt := s.trainTile(p)
|
|
op.GeoM.Reset()
|
|
op.GeoM.Translate(ox+float64(tt.X*TileSize), oy+float64(tt.Y*TileSize))
|
|
screen.DrawImage(g.Assets.Train, &op)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
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))
|
|
screen.DrawImage(g.Assets.PlayerFrame(leader.Facing, leader.Moving(), s.animT), &op)
|
|
s.drawUrge(g, screen, s.run.Party.Members[0], int(ox+math.Round(px)), int(oy+math.Round(py)))
|
|
|
|
s.drawHUD(g, screen)
|
|
if s.run.Mini != nil {
|
|
s.drawMinigame(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.Level.Destination.Get("romaji")
|
|
if r.Know.ShowsDestinationJP() { // hint från nivå 1: resmålet på japanska, att matcha mot skyltarna
|
|
dest += " " + r.Level.Destination.Get("ja")
|
|
}
|
|
name := r.Level.Name.Get(UILang)
|
|
a.Draw(screen, name, 4, 0, colText)
|
|
destX := 4 + a.Width(name) + 12
|
|
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)
|
|
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)
|
|
s.drawMeters(g, screen)
|
|
|
|
if r.Outcome != sim.Ongoing || r.Mini != nil {
|
|
return // resultatpanelen eller minispelet 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) {
|
|
col := colBad
|
|
if r.FlashKey == sim.FlashRescan {
|
|
col = colWarn
|
|
}
|
|
lines = append(lines, a.wrapLine(plainLine(a.T(r.FlashKey), col), 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 {
|
|
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()
|
|
}
|
|
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)...)
|
|
}
|
|
if 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))
|
|
} else {
|
|
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
|
|
}
|
|
|
|
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.X, tile.Y) {
|
|
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), colGood)}, colGood, false
|
|
}
|
|
return []line{plainLine(a.T("hud.kiosk_none"), colDim)}, accent, false
|
|
case level.TicketMachine:
|
|
if lvl.TicketRequired {
|
|
return []line{plainLine(fmt.Sprintf(a.T("hud.machine_ticket"), r.Wallet.Cash), colGood)}, colGood, false
|
|
}
|
|
return []line{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:
|
|
key = sim.FlashNoTicket
|
|
case r.Wallet.Phone && r.Wallet.Battery <= 0:
|
|
key = "hud.phone_dead"
|
|
}
|
|
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 = 14, 4
|
|
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)
|
|
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)
|
|
}
|
|
}
|
|
}
|