M13: titelskärm, språkval, tutorial, tips och pausmeny
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>
This commit is contained in:
2026-10-25 17:13:32 +01:00
parent 86f8130735
commit f0a0bc1522
26 changed files with 622 additions and 32 deletions

View File

@@ -162,8 +162,10 @@ func New(opt Options) (*Game, error) {
return nil, err
}
g.scene = s
case !exists:
g.scene = &LangScene{} // första starten: välj språk, sedan titelskärmen
default:
g.scene = NewMapScene(g)
g.scene = NewTitleScene()
}
return g, nil
}
@@ -284,8 +286,8 @@ func (g *Game) Save() {
// till banan och tystnar när man lämnar den.
func (g *Game) SetScene(s Scene) {
switch s.(type) {
case *LevelScene, *ResultScene: // banans sorl fortsätter
case *MapScene, *SettingsScene, *EndingScene, *WordsScene, *SkillsScene, *ItemsScene:
case *LevelScene, *ResultScene, *PauseScene: // banans sorl fortsätter
case *MapScene, *SettingsScene, *EndingScene, *WordsScene, *SkillsScene, *ItemsScene, *TitleScene, *LangScene, *TutorialScene:
g.Audio.Loop(SoundMusicMap, 0.6) // menymusiken (fortsätter om den redan spelas)
default:
g.Audio.StopLoop()

View File

@@ -38,14 +38,17 @@ const (
// 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
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)
@@ -185,6 +188,8 @@ func (s *LevelScene) Update(g *Game) error {
}
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()
@@ -199,7 +204,7 @@ func (s *LevelScene) Update(g *Game) error {
return nil
}
if backJustPressed() {
g.SetScene(NewMapScene(g))
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 {
@@ -353,6 +358,7 @@ func (s *LevelScene) Draw(g *Game, screen *ebiten.Image) {
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
@@ -517,6 +523,7 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
}
box = image.Rect(0, y, ScreenW, y+h)
}
s.infoBox = box
if !s.hideMap {
s.drawMinimap(g, screen, box, hasBox)
}
@@ -553,7 +560,11 @@ 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.
rd := lang.Render(a.Dict, r.Know, tr.BoardText(), UILang)
lines = append(lines, a.wrapLine(shownLine(rd.Original, colText, r.Know.ShowsReadings()), maxW)...)
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,
@@ -594,7 +605,11 @@ func (s *LevelScene) infoLines(a *Assets) (lines []line, accent color.Color, bar
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)...)
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)...)

View File

@@ -91,7 +91,8 @@ func (m *MapScene) current() *level.Level {
func (m *MapScene) Update(g *Game) error {
switch {
case backJustPressed():
return ebiten.Termination
g.SetScene(NewTitleScene())
return nil
case wordsJustPressed():
g.SetScene(NewWordsScene(g.Assets, m))
return nil

View File

@@ -0,0 +1,211 @@
package game
import (
"fmt"
"image/color"
"math"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/buildinfo"
)
// TitleScene är titelskärmen: premissen i text och menyn Fortsätt/Starta,
// Tutorial, Inställningar, Avsluta (Enter två gånger).
type TitleScene struct {
sel int
t float64
confirm bool // Avsluta valt en gång
}
// NewTitleScene öppnar titelskärmen.
func NewTitleScene() *TitleScene { return &TitleScene{} }
const titleItems = 4
func (s *TitleScene) Update(g *Game) error {
s.t += dt
if mv := menuMove(); mv != 0 {
s.sel = (s.sel + mv + titleItems) % titleItems
s.confirm = false
}
if backJustPressed() {
s.confirm = false
}
if !confirmJustPressed() {
return nil
}
g.Audio.Play(SoundConfirm, 0.6)
switch s.sel {
case 0:
g.SetScene(NewMapScene(g))
case 1:
g.SetScene(NewTutorialScene(s))
case 2:
g.SetScene(NewSettingsScene(s))
case 3:
if s.confirm {
return ebiten.Termination
}
s.confirm = true
}
return nil
}
func (s *TitleScene) Draw(g *Game, screen *ebiten.Image) {
a := g.Assets
screen.Fill(color.RGBA{24, 20, 37, 255})
// Räls längs nederkanten med ett tåg som rullar förbi.
railY := ScreenH - 28
for x := 0; x < ScreenW; x += TileSize {
var op ebiten.DrawImageOptions
op.GeoM.Translate(float64(x), float64(railY))
screen.DrawImage(a.Rails, &op)
}
tx := math.Mod(s.t*40, float64(ScreenW+6*TileSize)) - 3*TileSize
for i := 0; i < 3; i++ {
var op ebiten.DrawImageOptions
op.GeoM.Translate(tx+float64(i*TileSize), float64(railY-TileSize+4))
screen.DrawImage(a.Train, &op)
}
a.DrawBig(screen, a.T("game.title"), 16, 8, colKnown)
ver := fmt.Sprintf("%s (%s)", buildinfo.Version, buildinfo.Mode)
a.Draw(screen, ver, ScreenW-8-a.Width(ver), 10, colDim)
y := 30
for _, l := range a.wrapLine(plainLine(a.T("title.premise"), colText), ScreenW-32) {
a.drawLines(screen, []line{l}, 16, y)
y += LineH
}
start := a.T("menu.continue")
if g.Progress.TotalStars() == 0 && len(g.Progress.Levels) == 0 {
start = a.T("menu.start")
}
items := [titleItems]string{start, a.T("title.tutorial"), a.T("menu.settings"), a.T("menu.quit")}
y += 6
for i, it := range items {
col := colText
if i == s.sel {
vector.DrawFilledRect(screen, 12, float32(y-2), 130, LineH, colBar, false)
col = colKnown
a.Draw(screen, "▶", 14, y, colKnown)
}
a.Draw(screen, it, 26, y, col)
y += LineH
}
if s.confirm {
a.Draw(screen, a.T("title.quit_confirm"), 150, y-LineH, colWarn)
}
a.Draw(screen, a.T("title.hint"), 8, ScreenH-12, colDim)
}
// LangScene visas första gången spelet startar: välj språk.
type LangScene struct{ sel int }
var langChoices = []string{"sv", "en"}
func (s *LangScene) Update(g *Game) error {
if mv := menuMove(); mv != 0 {
s.sel = (s.sel + mv + len(langChoices)) % len(langChoices)
}
if confirmJustPressed() {
g.State.Settings.Lang = langChoices[s.sel]
g.applySettings()
g.Save()
g.Audio.Play(SoundConfirm, 0.6)
g.SetScene(NewTitleScene())
}
return nil
}
func (s *LangScene) Draw(g *Game, screen *ebiten.Image) {
a := g.Assets
screen.Fill(color.RGBA{24, 20, 37, 255})
a.DrawBig(screen, a.T("game.title"), 16, 10, colKnown)
a.Draw(screen, "Välj språk / Choose language", 16, 50, colText)
y := 72
for i, l := range langChoices {
col := colText
if i == s.sel {
vector.DrawFilledRect(screen, 12, float32(y-2), 130, LineH+2, colBar, false)
col = colKnown
a.Draw(screen, "▶", 14, y, colKnown)
}
a.Draw(screen, a.T("lang."+l), 26, y, col)
y += LineH + 1
}
a.Draw(screen, "Pilar + Enter / Arrows + Enter", 16, y+8, colDim)
}
// PauseScene är pausmenyn i en bana (Esc): Fortsätt, Lämna banan,
// Inställningar, Avsluta spelet (Enter två gånger). Esc igen fortsätter.
type PauseScene struct {
from *LevelScene
sel int
confirm bool
}
const pauseItems = 4
// NewPauseScene pausar banan.
func NewPauseScene(from *LevelScene) *PauseScene { return &PauseScene{from: from} }
func (s *PauseScene) Update(g *Game) error {
if backJustPressed() {
g.SetScene(s.from)
return nil
}
if mv := menuMove(); mv != 0 {
s.sel = (s.sel + mv + pauseItems) % pauseItems
s.confirm = false
}
if !confirmJustPressed() {
return nil
}
g.Audio.Play(SoundConfirm, 0.6)
switch s.sel {
case 0:
g.SetScene(s.from)
case 1:
if s.from.endless {
g.endEndless()
}
g.SetScene(NewMapScene(g))
case 2:
g.SetScene(NewSettingsScene(s))
case 3:
if s.confirm {
return ebiten.Termination
}
s.confirm = true
}
return nil
}
func (s *PauseScene) Draw(g *Game, screen *ebiten.Image) {
s.from.Draw(g, screen)
a := g.Assets
vector.DrawFilledRect(screen, 0, 0, ScreenW, ScreenH, color.RGBA{0, 0, 0, 120}, false)
vector.DrawFilledRect(screen, 40, 40, ScreenW-80, 104, colPanel, false)
vector.DrawFilledRect(screen, 40, 40, ScreenW-80, 2, colWarn, false)
a.DrawBig(screen, a.T("pause.title"), 48, 46, colText)
items := [pauseItems]string{a.T("pause.resume"), a.T("pause.leave"), a.T("menu.settings"), a.T("pause.quit")}
y := 68
for i, it := range items {
col := colText
if i == s.sel {
vector.DrawFilledRect(screen, 44, float32(y-2), ScreenW-88, LineH+2, colBar, false)
col = colKnown
}
a.Draw(screen, it, 50, y, col)
y += LineH + 2
}
msg := a.T("pause.hint")
if s.confirm {
msg = a.T("pause.quit_confirm")
}
for _, l := range a.wrapLine(plainLine(msg, colDim), ScreenW-96) {
a.drawLines(screen, []line{l}, 48, y+2)
y += LineH
}
}

View File

@@ -0,0 +1,92 @@
package game
import (
"fmt"
"image/color"
"strings"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
)
// TutorialScene är genomgången av grundmekaniken i fem sidor: målet och
// stationen, hur man läser en skylt (med resmålet markerat och resten
// nedtonat), kort/biljetter/spärrar, sällskapet och tåget, lägen och
// tangenter. ←/→ bläddrar, Esc går tillbaka.
type TutorialScene struct {
back Scene
page int
}
const tutorialPages = 5
// NewTutorialScene öppnar tutorialen.
func NewTutorialScene(back Scene) *TutorialScene { return &TutorialScene{back: back} }
func (s *TutorialScene) Update(g *Game) error {
if backJustPressed() {
g.SetScene(s.back)
return nil
}
dx, _ := miniMoveXY()
if confirmJustPressed() {
dx = 1
}
if dx != 0 {
s.page += dx
if s.page >= tutorialPages {
g.SetScene(s.back)
return nil
}
if s.page < 0 {
s.page = 0
}
g.Audio.Play(SoundConfirm, 0.4)
}
return nil
}
func (s *TutorialScene) Draw(g *Game, screen *ebiten.Image) {
a := g.Assets
screen.Fill(color.RGBA{24, 20, 37, 255})
vector.DrawFilledRect(screen, 0, 0, ScreenW, 22, color.RGBA{38, 43, 68, 255}, false)
key := fmt.Sprintf("tutorial.p%d", s.page+1)
a.DrawBig(screen, a.T(key+".title"), 8, 5, colText)
a.Draw(screen, fmt.Sprintf(a.T("tutorial.page"), s.page+1, tutorialPages), ScreenW-70, 8, colDim)
y := 28
for _, para := range strings.Split(a.T(key), "\n") {
for _, l := range a.wrapLine(plainLine(para, colText), ScreenW-16) {
a.drawLines(screen, []line{l}, 8, y)
y += LineH
}
y += 3
}
switch s.page {
case 0: // rutlegend med riktiga sprites
x := 12
for _, t := range []struct {
tile level.Tile
key string
}{{level.Sign, "tile.sign"}, {level.Gate, "tile.gate"}, {level.TicketMachine, "tile.machine"}, {level.Toilet, "tile.toilet"}, {level.Kiosk, "tile.kiosk"}, {level.Platform, "tile.platform"}} {
var op ebiten.DrawImageOptions
op.GeoM.Scale(2, 2)
op.GeoM.Translate(float64(x), float64(y))
screen.DrawImage(a.Tiles[t.tile], &op)
a.Draw(screen, a.T(t.key), x, y+2*TileSize+2, colDim)
x += 52
}
case 1: // exempelskylten: resmålet markerat, resten nedtonat
sample := "14番線 山手線 渋谷方面 → 南東口 改札"
body := append([]line{highlightLine(sample, "渋谷", colGood, colDim)}, a.wrapLine(line{{"→ ", colDim}, {a.T("tutorial.p2.sample"), colText}}, ScreenW-28)...)
vector.DrawFilledRect(screen, 8, float32(y), ScreenW-16, float32(LineH*len(body)+8), colPanel, false)
a.drawLines(screen, body, 14, y+4)
y += LineH*len(body) + 12
for _, l := range a.wrapLine(plainLine(a.T("tutorial.p2b"), colText), ScreenW-16) {
a.drawLines(screen, []line{l}, 8, y)
y += LineH
}
}
a.Draw(screen, a.T("tutorial.hint"), 8, ScreenH-12, colDim)
}

View File

@@ -135,3 +135,26 @@ func (a *Assets) drawLines(dst drawTarget, lines []line, x, y int) {
}
}
}
// highlightLine är tutorialens sätt att läsa en skylt: resmålets japanska
// tecken i markeringsfärg, allt annat nedtonat.
func highlightLine(text, needle string, hi, dim color.Color) line {
if needle == "" || !strings.Contains(text, needle) {
return plainLine(text, dim)
}
var l line
for {
i := strings.Index(text, needle)
if i < 0 {
if text != "" {
l = append(l, span{text, dim})
}
return l
}
if i > 0 {
l = append(l, span{text[:i], dim})
}
l = append(l, span{needle, hi})
text = text[i+len(needle):]
}
}

View File

@@ -0,0 +1,16 @@
package game
import "testing"
func TestHighlightDestination(t *testing.T) {
l := highlightLine("14番線 山手線 渋谷方面 → 南東", "渋谷", colGood, colDim)
if len(l) != 3 || l[0].text != "14番線 山手線 " || l[1].text != "渋谷" || l[1].col != colGood || l[2].col != colDim {
t.Fatalf("spans = %+v", l)
}
if l := highlightLine("渋谷 渋谷", "渋谷", colGood, colDim); len(l) != 3 || l[0].col != colGood || l[2].col != colGood {
t.Fatalf("flera träffar: %+v", l)
}
if l := highlightLine("東京方面", "渋谷", colGood, colDim); len(l) != 1 || l[0].col != colDim {
t.Fatalf("ingen träff: allt nedtonat, fick %+v", l)
}
}

85
internal/game/tips.go Normal file
View File

@@ -0,0 +1,85 @@
package game
import (
"strings"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
)
// Tutorialtips i spelet: visas en gång var, i ordning när villkoret
// uppfylls, tills första banan är klarad. {dest} och {ja} byts mot
// resmålet i latinska bokstäver respektive japanska tecken.
type tipDef struct {
key string
when func(s *LevelScene) bool
}
var tutorialTips = []tipDef{
{"tip.start", func(s *LevelScene) bool { return true }},
{"tip.sign", func(s *LevelScene) bool { return s.run.SignHere() != nil }},
{"tip.party", func(s *LevelScene) bool { return len(s.run.Party.Urges()) > 0 }},
{"tip.gate", func(s *LevelScene) bool { return s.run.DeniedAt >= 0 || s.run.GatePasses > 0 }},
{"tip.platform", func(s *LevelScene) bool { tr, _, _ := s.run.PlatformHere(); return tr != nil }},
}
const tipShowSec = 7.0
type tipState struct {
shown map[string]bool
current string
since float64
}
// tutorialActive: tipsen och skyltmarkeringen gäller tills första banan är
// klarad (och aldrig i oändligt läge).
func (s *LevelScene) tutorialActive(g *Game) bool {
return !s.endless && !g.Progress.Cleared("001-flygplatsen")
}
// updateTips väljer nästa tips som ska visas.
func (s *LevelScene) updateTips(g *Game) {
if !s.tutorialActive(g) {
return
}
r := s.run
t := &s.tips
if t.shown == nil {
t.shown = map[string]bool{}
}
if t.current != "" && r.Time-t.since < tipShowSec {
return
}
t.current = ""
for _, d := range tutorialTips {
if !t.shown[d.key] && d.when(s) {
t.shown[d.key] = true
t.current, t.since = d.key, r.Time
return
}
}
}
// tipText fyller i resmålet i ett tips.
func (s *LevelScene) tipText(a *Assets, key string) string {
d := s.run.Level.Destination
return strings.NewReplacer("{dest}", d.Get("romaji"), "{ja}", d.Get("ja")).Replace(a.T(key))
}
// drawTip ritar det aktuella tipset under HUD:en.
func (s *LevelScene) drawTip(g *Game, screen *ebiten.Image) {
t := &s.tips
if t.current == "" || s.run.Mini != nil || s.run.Outcome != 0 {
return
}
a := g.Assets
lines := a.wrapLine(highlightLine(s.tipText(a, t.current), s.run.Level.Destination.Get("ja"), colGood, colText), ScreenW-16)
h := 6 + LineH*len(lines)
y := 26
if s.infoBox.Dy() > 0 && s.infoBox.Min.Y < ScreenH/2 {
y = s.infoBox.Max.Y + 2 // inforutan ligger upptill: lägg tipset under den
}
vector.DrawFilledRect(screen, 4, float32(y), ScreenW-8, float32(h), colPanel, false)
vector.DrawFilledRect(screen, 4, float32(y), 2, float32(h), colKnown, false)
a.drawLines(screen, lines, 10, y+3)
}