M9: navigeringslägen, linjenät och terminal
All checks were successful
release / build-release (push) Successful in 1m34s

internal/nav med resources/data/network.json (76 stationer, 11 linjer,
Yamanote som ringlinje). Tåg bedöms från lineId + terminus mot banans
station/to: rätt, fel håll, för långt bort (fler än 12 stationer) eller
ingen trafik; correct räknas ut för nätkopplade banor, 1-4 rätta tåg.
Tågresan visar linjens riktiga stationer. Nya förlustorsaker "Fel håll"
och "För långt bort". Listläge (nästa tåg nere till vänster) och
skyltläge (+50 % poäng) växlas med N på bankartan och sparas; -mode.
Terminalen: biljettautomaten visar linjetavlan på nätbanor. Perronger
P p Q q R r. Bana 006 Ikebukuro med fyra perronger. Banorna 001-005
kopplade till nätet. Skript: waitover och pick calm; poängraden
radbryts; skärmdumpar uppdaterade.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 13:07:36 +02:00
parent de61568e44
commit f762f9554e
53 changed files with 3041 additions and 122 deletions

View File

@@ -80,7 +80,9 @@ func LoadAssets() (*Assets, error) {
}
a.Tiles[t] = sub(i)
}
a.Tiles[level.WrongPlatform] = a.Tiles[level.Platform] // samma utseende; tåget avgör
for _, t := range level.PlatformTiles[1:] {
a.Tiles[t] = a.Tiles[level.Platform] // samma utseende; tåget avgör
}
a.Train = sub(trainIndex)
a.Rails = sub(railsIndex)
for i := 0; i < posterCount; i++ {

View File

@@ -12,6 +12,7 @@ import (
"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/nav"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/progress"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
@@ -45,6 +46,7 @@ type Game struct {
Catalog *progress.Catalog
Levels []*level.Level // alla banor, sorterade på id
Audio *Audio // nil = tyst
Mode nav.Mode // navigeringsläge (= State.NavMode), växlas på bankartan
savePath string // "" = spara inte
fixedSeed uint64 // >0: alla banor startar med denna seed (tester/skärmdumpar)
@@ -69,6 +71,7 @@ type Options struct {
Mute bool // inget ljud
SavePath string // sparfil ("" = standardplatsen)
NoSave bool // läs sparfilen men skriv aldrig
Mode string // navigeringsläge från start: "list" eller "signs" (tomt = sparfilens)
}
// New laddar resurser, sparfil och bygger spelet.
@@ -109,6 +112,10 @@ func New(opt Options) (*Game, error) {
}
g.State = st
g.Know, g.Wallet, g.Profile, g.Progress = st.Know, st.Wallet, st.Profile, st.Progress
g.Mode = nav.ParseMode(st.NavMode)
if opt.Mode != "" {
g.Mode = nav.ParseMode(opt.Mode)
}
if !opt.NoSave {
g.savePath = path
}
@@ -233,10 +240,21 @@ func (g *Game) runOptions(id string, seed rng.Seed) sim.RunOptions {
return sim.RunOptions{
Dict: g.Assets.Dict, Know: g.Know, Wallet: g.Wallet, Debuffs: g.Profile.Debuffs, Seed: seed,
Extra: eff.Stats, FogRadius: eff.FogRadius, Landmarks: eff.Landmarks, FreeGates: eff.FreeGates,
RevealAll: eff.Reveal[id], InfiniteBattery: eff.Battery,
RevealAll: eff.Reveal[id], InfiniteBattery: eff.Battery, Mode: g.Mode,
}
}
// toggleMode växlar navigeringsläge och sparar.
func (g *Game) toggleMode() {
if g.Mode == nav.List {
g.Mode = nav.Signs
} else {
g.Mode = nav.List
}
g.State.NavMode = g.Mode.String()
g.Save()
}
// startLevel startar (eller återupptar) en bana och sparar seeden så att en
// laddad omgång ger samma slump.
func (g *Game) startLevel(id string) (*LevelScene, error) {

View File

@@ -126,6 +126,9 @@ func shopJustPressed() bool { return virtual.pressed["b"] || inpututil.IsKeyJust
// phoneJustPressed: P = kortet i telefonen av/på (meny).
func phoneJustPressed() bool { return virtual.pressed["p"] || inpututil.IsKeyJustPressed(ebiten.KeyP) }
// modeJustPressed: N växlar navigeringsläge (lista/skyltar) på bankartan.
func modeJustPressed() bool { return virtual.pressed["n"] || inpututil.IsKeyJustPressed(ebiten.KeyN) }
// mapJustPressed: M eller gamepadens vänstra mittknapp (Back/Select) = minikartan av/på.
func mapJustPressed() bool {
return virtual.pressed["m"] || inpututil.IsKeyJustPressed(ebiten.KeyM) || gamepadJustPressed(ebiten.StandardGamepadButtonCenterLeft)

View File

@@ -24,6 +24,10 @@ var minimapColors = map[level.Tile]color.RGBA{
level.Spawn: {99, 199, 77, 255},
level.Platform: {254, 174, 52, 255},
level.WrongPlatform: {254, 174, 52, 255},
level.Platform2: {254, 174, 52, 255},
level.Platform3: {254, 174, 52, 255},
level.Platform4: {254, 174, 52, 255},
level.Platform5: {254, 174, 52, 255},
level.Gate: {44, 232, 245, 255},
level.TicketMachine: {247, 118, 34, 255},
level.Sign: {90, 140, 230, 255},
@@ -111,7 +115,7 @@ func (s *LevelScene) drawMinimap(g *Game, screen *ebiten.Image, box image.Rectan
}
t := l.At(p)
c := minimapColors[t]
if (t == level.Platform || t == level.WrongPlatform) && r.TrainVisible(t) {
if t.IsPlatform() && r.TrainVisible(t) {
c = color.RGBA{255, 255, 255, 255} // tåget är inne
}
vector.DrawFilledRect(screen, float32(x0+x*sc), float32(y0+y*sc), float32(sc), float32(sc), c, false)

View File

@@ -15,6 +15,7 @@ import (
"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"
)
@@ -104,6 +105,31 @@ func (s *LevelScene) RideAtDest() bool {
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)
}
// 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)
@@ -165,6 +191,20 @@ func (s *LevelScene) MiniPick(text string) bool {
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.TopUp:
switch m.Stage {
case minigame.TopUpPickMember:
@@ -345,6 +385,9 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
return // resultatpanelen, minispelet eller butiken tar över; ingen inforuta eller minikarta
}
lines, accent, bar := s.infoLines(a)
if len(lines) == 0 {
s.drawModeHint(a, screen)
}
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))
@@ -499,10 +542,18 @@ func (s *LevelScene) infoLines(a *Assets) (lines []line, accent color.Color, bar
}
return []line{plainLine(a.T("hud.kiosk_none")+" "+a.T("hud.shop_prompt"), 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
// 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)...)
}
}
return []line{plainLine(fmt.Sprintf(a.T("hud.machine_topup"), r.Wallet.Card, r.Wallet.Cash), colGood)}, colGood, false
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"

View File

@@ -101,6 +101,8 @@ func (m *MapScene) Update(g *Game) error {
case phoneJustPressed():
g.Wallet.Phone = !g.Wallet.Phone
g.Save()
case modeJustPressed():
g.toggleMode()
}
if n := len(m.mains); n > 0 {
mx, my := miniMoveXY()
@@ -209,7 +211,7 @@ func (m *MapScene) Draw(g *Game, screen *ebiten.Image) {
foot := fmt.Sprintf("%s %d %s %d %s %d¥", a.T("map.skill_points"), p.SkillPoints, a.T("words.points"), g.Know.Points,
a.T("hud.cash"), g.Wallet.Cash)
a.Draw(screen, foot, 8, 30, colDim)
a.Draw(screen, fmt.Sprintf(a.T("menu.phone"), onoff), 8, 42, colDim)
a.Draw(screen, fmt.Sprintf(a.T("menu.phone"), onoff)+" "+fmt.Sprintf(a.T("menu.mode"), a.T("mode."+g.Mode.String())), 8, 42, colDim)
if len(g.Profile.Debuffs) > 0 {
var parts []string
for _, d := range g.Profile.Debuffs {

View File

@@ -62,8 +62,12 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) {
y += LineH
if run.Outcome == sim.Lost {
reason := a.T(run.LostReason)
if run.LostReason == sim.LostWrongTrain {
reason = fmt.Sprintf(a.T("result.wrong_detail"), tr.Destination.Get("romaji"), run.Level.Destination.Get("romaji"))
if detail, ok := map[string]string{
sim.LostWrongTrain: "result.wrong_detail",
sim.LostWrongDirection: "result.wrong_direction_detail",
sim.LostTooFar: "result.too_far_detail",
}[run.LostReason]; ok {
reason += " " + fmt.Sprintf(a.T(detail), tr.Destination.Get("romaji"), run.Level.Destination.Get("romaji"))
}
for _, l := range a.wrapLine(plainLine(reason, colText), ScreenW-56) {
a.drawLines(screen, []line{l}, 28, y)
@@ -80,11 +84,26 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) {
y += LineH
if run.Outcome == sim.Won {
sc := run.Score
a.Draw(screen, fmt.Sprintf("%s %d", a.T("result.score"), sc.Total), 28, y, colText)
y += LineH
a.Draw(screen, fmt.Sprintf("%s %d %s %d %s %d %s %d %s %d %s %d", strings.ToLower(a.T("hud.time")), sc.Time, a.T("result.money"), sc.Money,
a.T("result.meters"), sc.Meters, a.T("result.map"), sc.Explore, a.T("result.car"), sc.Car, a.T("result.penalty"), sc.Penalty), 28, y, colDim)
y += LineH
// Poängen som en uträkning; radbryts till högst två rader.
parts := []string{
fmt.Sprintf("%s %d", strings.ToLower(a.T("hud.time")), sc.Time), fmt.Sprintf("%s %d", a.T("result.money"), sc.Money),
fmt.Sprintf("%s %d", a.T("result.meters"), sc.Meters), fmt.Sprintf("%s %d", a.T("result.map"), sc.Explore),
fmt.Sprintf("%s %d", a.T("result.car"), sc.Car),
}
if sc.Bonus > 0 {
parts = append(parts, fmt.Sprintf("%s %d", a.T("result.bonus"), sc.Bonus))
}
total := fmt.Sprintf("%s %d = %s", a.T("result.score"), sc.Total, strings.Join(parts, " + "))
if sc.Penalty > 0 {
total += fmt.Sprintf(" - %s %d", a.T("result.penalty"), sc.Penalty)
}
for i, l := range a.wrapLine(plainLine(total, colText), ScreenW-56) {
if i > 1 {
break
}
a.drawLines(screen, []line{l}, 28, y)
y += LineH
}
msg := fmt.Sprintf("+%d %s", run.PointsEarned, a.T("result.lang_points"))
if len(run.LearnedDest) > 0 {
msg += " " + a.T("hud.learned") + ": " + strings.Join(run.LearnedDest, " ") + " (" + a.T("result.destination") + ")"

View File

@@ -93,7 +93,7 @@ func parseScript(src string) (*script, error) {
return nil, fmt.Errorf("script: '%s TEXT'", f[0])
}
st.arg = f[1]
case "quit", "waittrain", "waitstop":
case "quit", "waittrain", "waitstop", "waitover":
default:
return nil, fmt.Errorf("script: okänt kommando %q", f[0])
}
@@ -126,6 +126,7 @@ type playerTiler interface {
type trainWaiter interface {
TrainHere() bool
RideAtDest() bool
LevelOver() bool
MiniPick(text string) bool
TrackPhaseName() string
}
@@ -195,6 +196,11 @@ func (s *script) tick(g *Game, frame *ebiten.Image) error {
if tw, ok := g.scene.(trainWaiter); !ok || tw.RideAtDest() {
s.advance()
}
case "waitover":
s.counter++
if tw, ok := g.scene.(trainWaiter); !ok || tw.LevelOver() || s.counter > 60*120 {
s.advance()
}
case "waitphase":
s.counter++
if tw, ok := g.scene.(trainWaiter); !ok || tw.TrackPhaseName() == st.arg || s.counter > 60*60 {

View File

@@ -36,7 +36,7 @@ type trackInfo struct {
func (s *LevelScene) buildTracks() {
s.tracks = nil
s.rails = map[level.Point]bool{}
for _, t := range []level.Tile{level.Platform, level.WrongPlatform} {
for _, t := range level.PlatformTiles {
for _, p := range s.run.Level.Find(t) {
tt, horizontal := s.run.Level.TrackTile(p)
s.tracks = append(s.tracks, trackInfo{tile: t, platform: p, track: tt, horizontal: horizontal})