Inlärning (framsteg, stjärnor, ordlista) och M4: lämmeltåget
All checks were successful
release / build-release (push) Successful in 1m49s

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>
This commit is contained in:
2026-10-23 21:29:09 +02:00
parent b67493fddb
commit daa2089eda
38 changed files with 1436 additions and 115 deletions

View File

@@ -24,8 +24,12 @@ const LineH = 12
// Ordningen på rutorna i resources/sprites/tiles.png (måste matcha tools/genart).
var tileOrder = []level.Tile{
level.Wall, level.Floor, level.Spawn, level.Platform, level.Gate, level.TicketMachine, level.Sign,
level.Wall, // plats 7 = tågvagnen (laddas separat som Train)
level.Toilet, level.Kiosk,
}
const trainIndex = 7
// Assets är allt som laddats från resources/.
type Assets struct {
ResDir string
@@ -55,14 +59,17 @@ func LoadAssets() (*Assets, error) {
sub := func(i int) *ebiten.Image {
return tiles.SubImage(image.Rect(i*TileSize, 0, (i+1)*TileSize, TileSize)).(*ebiten.Image)
}
if w, need := tiles.Bounds().Dx(), TileSize*(len(tileOrder)+1); w < need {
if w, need := tiles.Bounds().Dx(), TileSize*len(tileOrder); w < need {
return nil, fmt.Errorf("tiles.png är %d px bred, behöver minst %d", w, need)
}
for i, t := range tileOrder {
if i == trainIndex {
continue
}
a.Tiles[t] = sub(i)
}
a.Tiles[level.WrongPlatform] = a.Tiles[level.Platform] // samma utseende; tåget avgör
a.Train = sub(len(tileOrder))
a.Train = sub(trainIndex)
player, err := loadImage("sprites", "player.png")
if err != nil {

View File

@@ -8,6 +8,7 @@ import (
"github.com/hajimehoshi/ebiten/v2"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
)
// Intern upplösning. Allt ritas i denna storlek och skalas sedan upp med
@@ -29,6 +30,7 @@ type Scene interface {
type Game struct {
Assets *Assets
Know *lang.Knowledge // spelarens japanska – följer med mellan banor (sparas i M8)
fixedSeed uint64 // >0: alla banor startar med denna seed (tester/skärmdumpar)
scene Scene
next Scene
smokeLeft int // >0: avsluta automatiskt efter så många uppdateringar
@@ -42,6 +44,8 @@ type Options struct {
SmokeFrames int // >0 avslutar efter N uppdateringar (smoke-test)
Script string // skriptade steg för skärmdumpar/tester, se script.go
KnownWords []string // ord spelaren kan från start (för tester/skärmdumpar)
Points int // språkpoäng från start (för tester/skärmdumpar)
Seed uint64 // >0: fast seed för banorna (för tester/skärmdumpar)
}
// New laddar resurser och bygger spelet.
@@ -54,13 +58,15 @@ func New(opt Options) (*Game, error) {
for _, w := range opt.KnownWords {
g.Know.Learn(w)
}
g.Know.Points = opt.Points
g.fixedSeed = opt.Seed
if opt.Script != "" {
if g.script, err = parseScript(opt.Script); err != nil {
return nil, err
}
}
if opt.LevelID != "" {
s, err := NewLevelScene(a, opt.LevelID, g.Know)
s, err := NewLevelScene(a, opt.LevelID, g.Know, g.seed())
if err != nil {
return nil, err
}
@@ -104,3 +110,11 @@ func (g *Game) Draw(screen *ebiten.Image) {
// Layout talar om för Ebitengine vilken intern upplösning vi ritar i.
func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) { return ScreenW, ScreenH }
// seed ger seeden för en ny banomgång: fast om -seed angetts, annars färsk.
func (g *Game) seed() rng.Seed {
if g.fixedSeed > 0 {
return rng.FromInt(g.fixedSeed)
}
return rng.NewSeed()
}

View File

@@ -76,6 +76,11 @@ func helpJustPressed() bool {
return virtual.pressed["h"] || inpututil.IsKeyJustPressed(ebiten.KeyH) || gamepadJustPressed(ebiten.StandardGamepadButtonRightTop)
}
// wordsJustPressed: L = ordlistan/språkträdet.
func wordsJustPressed() bool {
return virtual.pressed["l"] || inpututil.IsKeyJustPressed(ebiten.KeyL) || gamepadJustPressed(ebiten.StandardGamepadButtonLeftRight)
}
// menuMove returnerar -1/+1 när användaren just tryckte upp/ner i en meny.
func menuMove() int {
switch {

View File

@@ -5,22 +5,22 @@ import (
"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"
)
// Spelarens grundfart i rutor per sekund (blir en stat i M7).
const playerSpeed = 6.0
// Hur länge tillfälliga meddelanden visas.
const (
deniedShowSec = 2.0
learnedShowSec = 3.0
deniedShowSec = 2.0
learnedShowSec = 3.0
progressedShowSec = 2.5
)
// LevelScene är själva spelandet av en bana. All spellogik ligger i sim.Run;
@@ -33,15 +33,15 @@ type LevelScene struct {
}
// NewLevelScene laddar banan och ställer spelaren på startrutan. know är
// spelarens japanska, som delas mellan banor.
func NewLevelScene(a *Assets, id string, know *lang.Knowledge) (*LevelScene, error) {
// 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, playerSpeed, a.Dict, know),
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()
@@ -51,6 +51,9 @@ func NewLevelScene(a *Assets, id string, know *lang.Knowledge) (*LevelScene, err
// 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()
@@ -131,14 +134,73 @@ func (s *LevelScene) Draw(g *Game, screen *ebiten.Image) {
}
}
px, py := s.run.Player.WorldPos(TileSize)
// 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(s.run.Player.Facing, s.run.Player.Moving(), s.animT), &op)
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) {
@@ -152,9 +214,9 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
}
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 %s", 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(),
a.T("hud.controls")), 4, LineH, colDim)
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
@@ -164,6 +226,16 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
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
}
@@ -182,9 +254,13 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
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)*r.HelpProgress()), 4, colKnown, false)
vector.DrawFilledRect(screen, 4, by, float32((ScreenW-8)*p), 4, colKnown, false)
}
}
@@ -199,8 +275,7 @@ func (s *LevelScene) infoLines(a *Assets) (lines []line, accent color.Color, bar
if tr, at, secs := r.PlatformHere(); tr != nil {
// Avgångstavlan är en skylt som alla andra: japanska, sedd genom kunskapen.
ja := fmt.Sprintf("%s%s %s %s%s", tr.Track, a.T("hud.track_suffix_ja"), tr.Line.Get("ja"), tr.Destination.Get("ja"), a.T("hud.for_ja"))
rd := lang.Render(a.Dict, r.Know, ja, UILang)
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 {
@@ -240,8 +315,46 @@ func (s *LevelScene) infoLines(a *Assets) (lines []line, accent color.Color, bar
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)
}
}
}

View File

@@ -54,10 +54,14 @@ func (m *MenuScene) Update(g *Game) error {
if backJustPressed() {
return ebiten.Termination
}
if wordsJustPressed() {
g.SetScene(NewWordsScene(g.Assets, m))
return nil
}
if n := len(m.entries); n > 0 {
m.sel = (m.sel + menuMove() + n) % n
if confirmJustPressed() {
s, err := NewLevelScene(g.Assets, m.entries[m.sel].id, g.Know)
s, err := NewLevelScene(g.Assets, m.entries[m.sel].id, g.Know, g.seed())
if err != nil {
m.err = err
return nil
@@ -87,7 +91,8 @@ func (m *MenuScene) Draw(g *Game, screen *ebiten.Image) {
if m.err != nil {
a.Draw(screen, "FEL: "+m.err.Error(), 8, ScreenH-40, colBad)
}
a.Draw(screen, fmt.Sprintf("%s Lv%d %d %s", a.T("hud.japanese"), g.Know.Level(), g.Know.Count(), a.T("hud.words")), 8, ScreenH-38, colDim)
a.Draw(screen, fmt.Sprintf("%s Lv%d %d %s %s %d", a.T("hud.japanese"), g.Know.Level(), g.Know.Count(), a.T("hud.words"),
a.T("words.points"), g.Know.Points), 8, ScreenH-38, colDim)
a.Draw(screen, a.T("menu.hint"), 8, ScreenH-26, colDim)
a.Draw(screen, fmt.Sprintf("%s (%s)", buildinfo.Version, buildinfo.Mode), 8, ScreenH-13, colDim)
}

View File

@@ -2,6 +2,7 @@ package game
import (
"fmt"
"strings"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
@@ -19,8 +20,10 @@ func NewResultScene(from *LevelScene) *ResultScene { return &ResultScene{from: f
func (r *ResultScene) Update(g *Game) error {
switch {
case wordsJustPressed():
g.SetScene(NewWordsScene(g.Assets, r))
case inpututil_R():
s, err := NewLevelScene(g.Assets, r.from.id, g.Know)
s, err := NewLevelScene(g.Assets, r.from.id, g.Know, g.seed())
if err != nil {
return err
}
@@ -47,6 +50,10 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) {
}
vector.DrawFilledRect(screen, 20, 36, ScreenW-40, 3, accent, false)
a.DrawBig(screen, headline, 28, 44, accent)
if run.Outcome == sim.Won {
stars := strings.Repeat("★", run.Stars) + strings.Repeat("☆", 3-run.Stars)
a.DrawBig(screen, stars, ScreenW-28-a.Width(stars)-8, 44, colWarn)
}
y := 62
if tr := run.Boarded; tr != nil {
@@ -64,10 +71,18 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) {
}
}
y += 4
a.Draw(screen, fmt.Sprintf("%s %.1fs %s %d %s %d %s %d¥", a.T("hud.time"), run.Time,
a.Draw(screen, fmt.Sprintf("%s %.1fs (%s %.0fs) %s %d %s %d %s %d¥", a.T("hud.time"), run.Time, a.T("result.par"), run.ParSec(),
a.T("hud.steps"), run.Steps, a.T("result.gates"), run.GatePasses, a.T("result.card"), run.Card.Balance), 28, y, colText)
y += LineH
a.Draw(screen, fmt.Sprintf("%s Lv%d %d %s (%d %s)", a.T("hud.japanese"), run.Know.Level(), run.Know.Count(),
a.T("hud.words"), run.Know.Helps, a.T("result.helps")), 28, y, colDim)
if run.Outcome == sim.Won {
msg := fmt.Sprintf("+%d %s", run.PointsEarned, a.T("words.points"))
if len(run.LearnedDest) > 0 {
msg += " " + a.T("hud.learned") + ": " + strings.Join(run.LearnedDest, " ") + " (" + a.T("result.destination") + ")"
}
a.Draw(screen, msg, 28, y, colKnown)
y += LineH
}
a.Draw(screen, fmt.Sprintf("%s Lv%d %d %s %s %d (%d %s)", a.T("hud.japanese"), run.Know.Level(), run.Know.Count(),
a.T("hud.words"), a.T("words.points"), run.Know.Points, run.Know.Helps, a.T("result.helps")), 28, y, colDim)
a.Draw(screen, a.T("result.hint"), 28, 130, colDim)
}

View File

@@ -0,0 +1,112 @@
package game
import (
"fmt"
"image/color"
"sort"
"strings"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang"
)
// WordsScene är ordlistan/språkträdet: alla ord i ordboken, vad spelaren
// kan, framsteg på resten, och möjligheten att lägga språkpoäng på valfria
// ord. Blir ett riktigt träd med skills i M8.
type WordsScene struct {
back Scene
entries []*lang.Entry
sel int
top int
msg string
}
const wordsRows = 9
// NewWordsScene listar ordboken sorterad på tier och ord.
func NewWordsScene(a *Assets, back Scene) *WordsScene {
var es []*lang.Entry
for _, w := range a.Dict.Words() {
es = append(es, a.Dict.Lookup(w))
}
sort.Slice(es, func(i, j int) bool {
if es[i].Tier != es[j].Tier {
return es[i].Tier < es[j].Tier
}
return es[i].Word < es[j].Word
})
return &WordsScene{back: back, entries: es}
}
func (w *WordsScene) Update(g *Game) error {
if backJustPressed() || wordsJustPressed() {
g.SetScene(w.back)
return nil
}
if n := len(w.entries); n > 0 {
w.sel = (w.sel + menuMove() + n) % n
if w.sel < w.top {
w.top = w.sel
}
if w.sel >= w.top+wordsRows {
w.top = w.sel - wordsRows + 1
}
if confirmJustPressed() {
e := w.entries[w.sel]
switch {
case g.Know.Knows(e.Word):
w.msg = g.Assets.T("words.already")
case g.Know.Buy(e):
w.msg = fmt.Sprintf(g.Assets.T("words.bought"), e.Word, e.Translation(UILang))
default:
w.msg = fmt.Sprintf(g.Assets.T("words.too_expensive"), lang.WordCost(e))
}
}
}
return nil
}
func (w *WordsScene) Draw(g *Game, screen *ebiten.Image) {
a := g.Assets
k := g.Know
screen.Fill(color.RGBA{24, 20, 37, 255})
vector.DrawFilledRect(screen, 0, 0, ScreenW, 22, color.RGBA{38, 43, 68, 255}, false)
a.DrawBig(screen, a.T("words.title"), 8, 5, colText)
hdr := fmt.Sprintf("%s %d %s Lv%d %d/%d %s", a.T("words.points"), k.Points, a.T("hud.japanese"),
k.Level(), k.Count(), len(w.entries), a.T("hud.words"))
a.Draw(screen, hdr, ScreenW-8-a.Width(hdr), 6, colKnown)
y0 := 30
for i := w.top; i < len(w.entries) && i < w.top+wordsRows; i++ {
e := w.entries[i]
y := y0 + (i-w.top)*LineH
if i == w.sel {
vector.DrawFilledRect(screen, 4, float32(y-1), ScreenW-8, LineH, colBar, false)
}
status, scol := strings.Repeat("●", lang.WordCost(e)), colDim
if k.CanBuy(e) {
scol = colKnown
}
wcol := colText
switch {
case k.Knows(e.Word):
status, scol, wcol = "✓", colGood, colKnown
case k.ProgressOf(e.Word) > 0:
status, scol = fmt.Sprintf("%d/%d", k.ProgressOf(e.Word), lang.ProgressToLearn), colWarn
}
a.Draw(screen, e.Word, 8, y, wcol)
a.Draw(screen, e.Romaji, 92, y, colDim)
a.Draw(screen, e.Translation(UILang), 180, y, colText)
a.Draw(screen, status, ScreenW-8-a.Width(status), y, scol)
}
// rullningsindikator
if len(w.entries) > wordsRows {
a.Draw(screen, fmt.Sprintf("%d-%d / %d", w.top+1, min(len(w.entries), w.top+wordsRows), len(w.entries)), 8, ScreenH-38, colDim)
}
if w.msg != "" {
a.Draw(screen, w.msg, 8, ScreenH-26, colKnown)
}
a.Draw(screen, a.T("words.hint"), 8, ScreenH-13, colDim)
}

View File

@@ -20,8 +20,8 @@ import (
//
// wait N vänta N bildrutor
// waittrain vänta tills tåget vid perrongen spelaren står på är inne
// press KEY tryck enter | esc | r | up | down en bildruta
// move DIR N gå N rutor åt up | down | left | right (släpper när rutorna är avklarade)
// press KEY tryck enter | esc | r | h | l | up | down en bildruta
// move DIR N gå N rutor åt up | down | left | right (stannar exakt där)
// shot PATH spara aktuell bild som PNG (intern upplösning × shotScale)
// quit avsluta spelet
//
@@ -29,18 +29,22 @@ import (
const shotScale = 3
// moveStallFrames: så många bildrutor utan ny ruta räknas som "vägg" och
// avslutar ett move-steg (annars skulle skriptet hänga för evigt).
const moveStallFrames = 120
type scriptStep struct {
cmd string
arg string
n int
dir sim.Dir
done bool
cmd string
arg string
n int
dir sim.Dir
}
type script struct {
steps []scriptStep
i int
counter int
stall int
lastPos level.Point
havePos bool
}
@@ -108,7 +112,10 @@ func parseDir(s string) sim.Dir {
}
// playerTiler implementeras av scener som har en spelare i rutnätet.
type playerTiler interface{ PlayerTile() level.Point }
type playerTiler interface {
PlayerTile() level.Point
PlayerMoving() bool
}
// trainWaiter implementeras av scener där man kan vänta på ett tåg.
type trainWaiter interface{ TrainHere() bool }
@@ -139,26 +146,39 @@ func (s *script) tick(g *Game, frame *ebiten.Image) error {
}
cur := pt.PlayerTile()
if !s.havePos {
s.lastPos, s.havePos = cur, true
s.lastPos, s.havePos, s.stall = cur, true, 0
}
if cur != s.lastPos {
s.lastPos = cur
s.counter++
s.stall = 0
} else {
s.stall++
}
if s.counter >= st.n {
if s.counter >= st.n && !pt.PlayerMoving() {
s.advance()
return nil
}
virtual.held = st.dir
if s.stall > moveStallFrames { // blockerad av vägg: ge upp steget
s.advance()
return nil
}
// Håll riktningen tills sista rutan har påbörjats; släpp sedan så att
// figuren stannar exakt där. Hålls tangenten kvar i ankomstbildrutan
// fortsätter figuren annars en ruta till när farten (t.ex. med
// sällskapets fartstraff) inte är bildruteexakt.
if s.counter < st.n-1 || (s.counter == st.n-1 && !pt.PlayerMoving()) {
virtual.held = st.dir
}
case "waittrain":
if tw, ok := g.scene.(trainWaiter); !ok || tw.TrainHere() {
s.advance()
}
case "shot":
if err := saveShot(frame, st.arg); err != nil {
return err
}
s.advance()
case "waittrain":
if tw, ok := g.scene.(trainWaiter); !ok || tw.TrainHere() {
s.advance()
}
case "quit":
return ebiten.Termination
}
@@ -168,6 +188,7 @@ func (s *script) tick(g *Game, frame *ebiten.Image) error {
func (s *script) advance() {
s.i++
s.counter = 0
s.stall = 0
s.havePos = false
}