Files
metro-puzzle-jp/internal/game/scene_level.go
claude daa2089eda
All checks were successful
release / build-release (push) Successful in 1m49s
Inlärning (framsteg, stjärnor, ordlista) och M4: lämmeltåget
Inlärning enligt Björns regler:
- lang.Knowledge: framsteg per ord (3 läsningar = känt), språkpoäng,
  köp av valfria ord (kostnad = tier), Award per stjärna
- sim.Run: RunOptions med seed; stjärnor 1–3 mot par (parSec eller
  kortaste vägen × 0,5 s + 20 s); ≥2 stjärnor lär resmålets ord; att
  stanna på skylt/perrong ger seedad 35 % chans till framsteg per okänt ord
- game: ordlista (L) med kända ord, framsteg och kostnad; stjärnor, poäng
  och inlärt resmål på resultatskärmen; framstegsnotis; flaggor -points/-seed

M4 lämmeltåget:
- sim.Party: medlemmar med mat/dryck/toa-mätare, behov under tröskel,
  seedade händelser var 45 s / antal (toa, törst, hunger, distraktion),
  fartstraff, toalett (W, 3 s/person) och kiosk (K, 150 ¥/person),
  följare på ledarens spår
- level: rutor W och K, party och parSec i bandatan; banor med sällskap
  måste ha toalett och kiosk (testas); 001 har 2, 002 har 3 i sällskapet
- game: följare med färgton, behovsmarkering över huvudet, mätarpanel,
  facilitetsrutor med förlopp; genart ritar toalett och kiosk
- skriptläget: move stannar exakt på målrutan även när farten inte är
  bildruteexakt (sällskapets fartstraff), stall-skydd mot väggar
- docs/README/wiki/spelbeskrivning uppdaterade

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-23 21:29:09 +02:00

361 lines
12 KiB
Go

package game
import (
"fmt"
"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/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
)
// 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
}
// 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, 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, 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
}
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 backJustPressed() {
m, err := NewMenuScene(g.Assets)
if err != nil {
return err
}
g.SetScene(m)
return nil
}
if helpJustPressed() {
s.run.AskHelp()
}
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)
}
// 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")
}
a.Draw(screen, r.Level.Name.Get(UILang), 4, 0, colText)
a.Draw(screen, a.T("hud.destination")+": "+dest, 4+a.Width(r.Level.Name.Get(UILang))+12, 0, colKnown)
a.Draw(screen, fmt.Sprintf("%s %3.0fs %s %d %s %d¥ %s Lv%d·%d", a.T("hud.time"), r.Time,
a.T("hud.steps"), r.Steps, a.T("hud.card"), r.Card.Balance, a.T("hud.japanese"), r.Know.Level(), r.Know.Count()), 4, LineH, colDim)
s.drawMeters(g, screen)
if r.Outcome != sim.Ongoing {
return
}
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 len(lines) == 0 {
return
}
h := 8 + LineH*len(lines)
if bar {
h += 8
}
// Rutan ligger i nederkant, men flyttas upp under HUD:en om spelaren
// (eller tåget under hen) annars skulle hamna bakom den.
_, py := r.Player.WorldPos(TileSize)
y := ScreenH - h
if int(py-s.cam.Y)+2*TileSize > y {
y = 24
}
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
}
if r.DeniedAt >= 0 && r.Time-r.DeniedAt < deniedShowSec {
return []line{plainLine(a.T("hud.gate_denied"), 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)
}
}
}