M7b: tågfaser, räls, in-/utkörning, vind, ankomstindikator och melodier
All checks were successful
release / build-release (push) Successful in 1m42s

- level.Schedule.Phase: away/approaching/arriving/dwelling/closing/leaving
  ur tidtabellen med progress; Boardable (öppna dörrar) och Present;
  validering av minsta uppehåll/borta-tid; TrackTile för rälsen
- sim.Run: ombordstigning bara med öppna dörrar, TrainVisible via Present,
  PlatformPhase och NearTrack (närmaste perrong inom två rutor)
- game/trains.go: rälsrutor längs kantraden (roterade för vertikala spår),
  tre vagnar som bromsar in och accelererar ut längs rälsen, strålkastare
  under inrullningen, blinkande lampor vid kanten för den som står nära,
  dammpartiklar längs perrongen; kameran får en rutas marginal så att
  kanträlsen syns
- game/audio.go: ebiten/v2/audio med avgångsmelodi (approaching) och
  dörrsignal (closing), volym efter avstånd, -mute; tools/gensound
  syntetiserar placeholder-WAV:ar
- HUD: fasberoende perrongtext och "tåg på väg in" nära rälsen;
  skriptkommandot waitphase; skärmdump av ankomsten
- plan (Björn 2026-10-24): räls/väg-grafik och melodierna i M7b; folk/NPC:er,
  vänster-/högertrafik, bredare gångar, reklam- och ljudröra som nytt steg
  M9b – i spelbeskrivning, roadmap, testplan
- docs/README/wiki uppdaterade

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 11:03:12 +02:00
parent b2995d4ca9
commit d466333f01
42 changed files with 855 additions and 82 deletions

View File

@@ -25,17 +25,23 @@ const LineH = 12
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,
level.Toilet,
level.Wall, // plats 9 = rälsen (laddas separat som Rails)
level.Kiosk,
}
const trainIndex = 7
const (
trainIndex = 7
railsIndex = 9
)
// Assets är allt som laddats från resources/.
type Assets struct {
ResDir string
LevelsDir string
Tiles map[level.Tile]*ebiten.Image
Train *ebiten.Image // tågvagn, ritas vid perrongen när tåget är inne
Train *ebiten.Image // tågvagn, ritas längs rälsen när tåget syns
Rails *ebiten.Image // rälsruta (horisontell), roteras för vertikala spår
Player [4][3]*ebiten.Image // [riktning: ner, upp, vänster, höger][bildruta: stilla, gå1, gå2]
Text map[string]string
Dict *lang.Dictionary
@@ -63,13 +69,14 @@ func LoadAssets() (*Assets, error) {
return nil, fmt.Errorf("tiles.png är %d px bred, behöver minst %d", w, need)
}
for i, t := range tileOrder {
if i == trainIndex {
if i == trainIndex || i == railsIndex {
continue
}
a.Tiles[t] = sub(i)
}
a.Tiles[level.WrongPlatform] = a.Tiles[level.Platform] // samma utseende; tåget avgör
a.Train = sub(trainIndex)
a.Rails = sub(railsIndex)
player, err := loadImage("sprites", "player.png")
if err != nil {

72
internal/game/audio.go Normal file
View File

@@ -0,0 +1,72 @@
package game
import (
"bytes"
"fmt"
"io"
"math"
"github.com/hajimehoshi/ebiten/v2/audio"
"github.com/hajimehoshi/ebiten/v2/audio/wav"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res"
)
// Audio spelar korta effekter. nil-säkert: utan ljud (−mute eller fel) är
// alla anrop no-ops.
type Audio struct {
ctx *audio.Context
clips map[string][]byte // avkodad PCM (16-bit stereo LE) per klipp
}
const sampleRate = 44100
// Ljudklipp (filnamn utan .wav i resources/sound).
const (
SoundApproach = "melody-approach"
SoundDoors = "door-chime"
)
// AudibleTiles är hur många rutor bort man hör perrongens ljud.
const AudibleTiles = 12.0
// newAudio laddar klippen. Returnerar nil om ljudet är avstängt.
func newAudio(mute bool) (*Audio, error) {
if mute {
return nil, nil
}
a := &Audio{ctx: audio.NewContext(sampleRate), clips: map[string][]byte{}}
for _, name := range []string{SoundApproach, SoundDoors} {
b, err := res.ReadFile("sound", name+".wav")
if err != nil {
return nil, err
}
stream, err := wav.DecodeWithoutResampling(bytes.NewReader(b))
if err != nil {
return nil, fmt.Errorf("ljud %s: %w", name, err)
}
pcm, err := io.ReadAll(stream)
if err != nil {
return nil, err
}
a.clips[name] = pcm
}
return a, nil
}
// Play spelar ett klipp med volym 0..1.
func (a *Audio) Play(name string, volume float64) {
if a == nil || volume <= 0 {
return
}
pcm, ok := a.clips[name]
if !ok {
return
}
p := a.ctx.NewPlayerFromBytes(pcm)
p.SetVolume(math.Min(1, volume))
p.Play()
}
// volumeAt ger volym efter avstånd i rutor.
func volumeAt(dist float64) float64 { return math.Max(0, 1-dist/AudibleTiles) }

View File

@@ -33,6 +33,7 @@ type Game struct {
Know *lang.Knowledge // spelarens japanska – följer med mellan banor (sparas i M8)
Wallet *sim.Wallet // pengar, kort, telefon, biljett – följer med mellan banor
Profile *sim.Profile // aktiva debuffar – följer med mellan banor
Audio *Audio // nil = tyst
fixedSeed uint64 // >0: alla banor startar med denna seed (tester/skärmdumpar)
scene Scene
next Scene
@@ -51,6 +52,7 @@ type Options struct {
Seed uint64 // >0: fast seed för banorna (för tester/skärmdumpar)
Phone bool // kortet i telefonen från start
Debuffs []string // debuffar från start (för tester/skärmdumpar)
Mute bool // inget ljud
CardYen int // >=0: kortets startsaldo (för tester/skärmdumpar), -1 = standard
}
@@ -66,6 +68,9 @@ func New(opt Options) (*Game, error) {
}
g.Know.Points = opt.Points
g.fixedSeed = opt.Seed
if g.Audio, err = newAudio(opt.Mute); err != nil {
return nil, err
}
g.Wallet = sim.NewWallet()
g.Profile = &sim.Profile{}
for _, d := range opt.Debuffs {

View File

@@ -5,6 +5,7 @@ import (
"image"
"image/color"
"math"
"math/rand/v2"
"path/filepath"
"strings"
@@ -34,6 +35,13 @@ type LevelScene struct {
cam *sim.Camera
animT float64
hideMap bool // M döljer/visar minikartan
// Tåg och effekter (M7b).
tracks []trackInfo
rails map[level.Point]bool
lastPhase map[level.Tile]level.Phase
particles []particle
rng *rand.Rand // bara för partiklar (ingen spellogik)
}
// NewLevelScene laddar banan och ställer spelaren på startrutan. Kunskap,
@@ -48,10 +56,27 @@ func NewLevelScene(a *Assets, id string, g *Game, seed rng.Seed) (*LevelScene, e
run: sim.NewRun(lvl, sim.RunOptions{Dict: a.Dict, Know: g.Know, Wallet: g.Wallet, Debuffs: g.Profile.Debuffs, Seed: seed}),
cam: sim.NewCamera(ScreenW, ScreenH, float64(lvl.Width()*TileSize), float64(lvl.Height()*TileSize)),
}
s.cam.Margin = TileSize // rälsen ligger i kantraden: visa en ruta utanför kartan
s.lastPhase = map[level.Tile]level.Phase{}
s.rng = rand.New(rand.NewPCG(1, 2))
s.buildTracks()
for _, tr := range s.tracks { // ingen melodi för fasen banan råkar börja i
s.lastPhase[tr.tile], _ = s.run.PlatformPhase(tr.tile)
}
s.followPlayer()
return s, nil
}
// TrackPhaseName är fasen för närmaste perrong inom indikatoravståndet
// (skriptläget), "" om ingen är nära.
func (s *LevelScene) TrackPhaseName() string {
_, _, phase, _, _, ok := s.run.NearTrack(indicatorDist)
if !ok {
return ""
}
return phase.String()
}
// 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 }
@@ -77,6 +102,7 @@ func (s *LevelScene) followPlayer() {
}
func (s *LevelScene) Update(g *Game) error {
s.updateEffects(g)
if s.run.Mini != nil {
// Minispel: all input går dit; Esc avbryter minispelet, inte banan.
mx, my := miniMoveXY()
@@ -146,18 +172,6 @@ func (s *LevelScene) MiniPick(text string) bool {
return true
}
// 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
@@ -172,23 +186,17 @@ func (s *LevelScene) Draw(g *Game, screen *ebiten.Image) {
var op ebiten.DrawImageOptions
for y := y0; y < y1; y++ {
for x := x0; x < x1; x++ {
if s.rails[level.Point{X: x, Y: y}] {
s.drawRails(g, screen, level.Point{X: x, Y: y}, ox, oy)
continue
}
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)
}
}
s.drawTrains(g, screen, ox, oy)
s.drawParticles(screen, ox, oy)
// Följarna ritas bakifrån så att ledaren hamnar överst.
leader := s.run.Player
@@ -221,6 +229,7 @@ func (s *LevelScene) Draw(g *Game, screen *ebiten.Image) {
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.drawIndicator(screen, ox, oy)
s.drawHUD(g, screen)
if s.run.Mini != nil {
@@ -387,15 +396,32 @@ func (s *LevelScene) infoLines(a *Assets) (lines []line, accent color.Color, bar
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 {
phase, _ := r.PlatformPhase(lvl.At(tile.X, tile.Y))
switch {
case phase == level.Closing:
accent = colWarn
lines = append(lines, plainLine(fmt.Sprintf("%s %.0f s", a.T("hud.closing"), math.Ceil(secs)), colWarn))
case 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 {
case phase == level.Arriving:
accent = colWarn
lines = append(lines, plainLine(a.T("hud.arriving"), colWarn))
case phase == level.Approaching:
accent = colWarn
lines = append(lines, plainLine(fmt.Sprintf(a.T("hud.approaching"), math.Ceil(secs)), colWarn))
case phase == level.Leaving:
lines = append(lines, plainLine(fmt.Sprintf("%s %s %.0f s", a.T("hud.leaving"), a.T("hud.next_train_in"), math.Ceil(secs)), colDim))
default:
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
}
// Nära rälsen (men inte på perrongen): man ser och hör tåget komma.
if _, _, phase, _, dist, ok := r.NearTrack(indicatorDist); ok && dist > 0 && phase == level.Approaching && r.SignHere() == nil {
return []line{plainLine(a.T("hud.train_near"), colWarn)}, colWarn, false
}
if sg := r.SignHere(); sg != nil {
know := r.Know

View File

@@ -23,6 +23,7 @@ import (
// waittrain vänta tills tåget vid perrongen spelaren står på är inne
// waitstop vänta tills tågresan står vid resmålet
// pick TEXT flytta markören i minispelet tills alternativet TEXT är valt
// waitphase NAME vänta tills närmaste perrongs tåg är i fasen NAME (approaching, dwelling …)
// 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)
@@ -87,7 +88,7 @@ func parseScript(src string) (*script, error) {
return nil, fmt.Errorf("script: okänd riktning %q", f[1])
}
st.n, _ = strconv.Atoi(f[2])
case "shot", "pick":
case "shot", "pick", "waitphase":
if len(f) != 2 {
return nil, fmt.Errorf("script: '%s TEXT'", f[0])
}
@@ -126,6 +127,7 @@ type trainWaiter interface {
TrainHere() bool
RideAtDest() bool
MiniPick(text string) bool
TrackPhaseName() string
}
// tick körs i början av varje Update. frame är senast ritade bild.
@@ -193,6 +195,11 @@ func (s *script) tick(g *Game, frame *ebiten.Image) error {
if tw, ok := g.scene.(trainWaiter); !ok || tw.RideAtDest() {
s.advance()
}
case "waitphase":
s.counter++
if tw, ok := g.scene.(trainWaiter); !ok || tw.TrackPhaseName() == st.arg || s.counter > 60*60 {
s.advance()
}
case "pick":
s.counter++
if tw, ok := g.scene.(trainWaiter); !ok || tw.MiniPick(st.arg) || s.counter > 60 {

196
internal/game/trains.go Normal file
View File

@@ -0,0 +1,196 @@
package game
import (
"image/color"
"math"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
)
// Tågens ankomst och avgång (M7b): räls längs perrongkanten, vagnar som
// rullar in och ut, vind, ankomstindikator och melodier.
const (
trainCars = 3 // vagnar per tåg
trainRunTiles = 6.0 // hur långt bort tåget startar/försvinner
railsReach = 8 // räls ritas så här många rutor åt varje håll
maxParticles = 40
indicatorDist = 2 // rutor från perrongen där man ser/hör tåget komma
)
type particle struct {
x, y, vx, vy, life float64
}
// trackInfo är det som behövs för att rita ett tågs räls och rörelse.
type trackInfo struct {
tile level.Tile // P eller p
platform level.Point // perrongrutan
track level.Point // rälsrutan bortom kanten
horizontal bool // rälsens riktning
}
func (s *LevelScene) buildTracks() {
s.tracks = nil
s.rails = map[level.Point]bool{}
for _, t := range []level.Tile{level.Platform, level.WrongPlatform} {
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})
for k := -railsReach; k <= railsReach; k++ {
c := tt
if horizontal {
c.X += k
} else {
c.Y += k
}
if s.run.Level.At(c.X, c.Y) == level.Wall && (c.X == 0 || c.Y == 0 || c.X == s.run.Level.Width()-1 || c.Y == s.run.Level.Height()-1) {
s.rails[c] = true
}
}
}
}
}
// trainOffset är tågets förskjutning i rutor längs rälsen i en fas:
// det kommer in från minus-sidan och lämnar åt plus-sidan.
func trainOffset(phase level.Phase, prog float64) (float64, bool) {
switch phase {
case level.Arriving:
return -trainRunTiles * (1 - prog) * (1 - prog), true // bromsar in
case level.Dwelling, level.Closing:
return 0, true
case level.Leaving:
return trainRunTiles * prog * prog, true // accelererar ut
}
return 0, false
}
// drawRails ritar rälsrutan (roterad för vertikala spår).
func (s *LevelScene) drawRails(g *Game, screen *ebiten.Image, c level.Point, ox, oy float64) {
var op ebiten.DrawImageOptions
vertical := false
for _, tr := range s.tracks {
if !tr.horizontal && (c.X == tr.track.X) {
vertical = true
}
}
if vertical {
op.GeoM.Translate(-TileSize/2, -TileSize/2)
op.GeoM.Rotate(math.Pi / 2)
op.GeoM.Translate(TileSize/2, TileSize/2)
}
op.GeoM.Translate(ox+float64(c.X*TileSize), oy+float64(c.Y*TileSize))
screen.DrawImage(g.Assets.Rails, &op)
}
// drawTrains ritar tågen i rätt läge för sin fas.
func (s *LevelScene) drawTrains(g *Game, screen *ebiten.Image, ox, oy float64) {
for _, tr := range s.tracks {
phase, prog := s.run.PlatformPhase(tr.tile)
// Strålkastare när tåget är på väg in: ett sken från det håll det kommer.
if phase == level.Approaching {
s.drawHeadlight(screen, tr, prog, ox, oy)
}
off, show := trainOffset(phase, prog)
if !show {
continue
}
for k := 0; k < trainCars; k++ {
d := off + float64(k-trainCars/2)
var op ebiten.DrawImageOptions
x, y := float64(tr.track.X*TileSize), float64(tr.track.Y*TileSize)
if tr.horizontal {
x += d * TileSize
} else {
y += d * TileSize
op.GeoM.Translate(-TileSize/2, -TileSize/2)
op.GeoM.Rotate(math.Pi / 2)
op.GeoM.Translate(TileSize/2, TileSize/2)
}
op.GeoM.Translate(ox+math.Round(x), oy+math.Round(y))
screen.DrawImage(g.Assets.Train, &op)
}
}
}
func (s *LevelScene) drawHeadlight(screen *ebiten.Image, tr trackInfo, prog float64, ox, oy float64) {
a := 0.25 + 0.55*prog // Ebitengine vill ha premultiplicerad alfa
c := color.RGBA{uint8(255 * a), uint8(240 * a), uint8(180 * a), uint8(255 * a)}
x, y := float64(tr.track.X*TileSize), float64(tr.track.Y*TileSize)
length := float32(TileSize * (1 + 4*prog))
if tr.horizontal {
vector.DrawFilledRect(screen, float32(ox+x)-length, float32(oy+y)+4, length, 8, c, false)
} else {
vector.DrawFilledRect(screen, float32(ox+x)+4, float32(oy+y)-length, 8, length, c, false)
}
}
// drawIndicator: blinkande lampor vid perrongkanten när tåget är på väg in
// och spelaren är nära rälsen.
func (s *LevelScene) drawIndicator(screen *ebiten.Image, ox, oy float64) {
_, platform, phase, _, _, ok := s.run.NearTrack(indicatorDist)
if !ok || phase != level.Approaching || math.Mod(s.run.Time, 0.5) > 0.25 {
return
}
x, y := float32(ox+float64(platform.X*TileSize)), float32(oy+float64(platform.Y*TileSize))
vector.DrawFilledRect(screen, x+2, y+TileSize-3, 3, 2, colWarn, false)
vector.DrawFilledRect(screen, x+TileSize-5, y+TileSize-3, 3, 2, colWarn, false)
}
// updateEffects sköter fasbyten (ljud), vind och partiklar.
func (s *LevelScene) updateEffects(g *Game) {
r := s.run
px, py := r.Player.WorldPos(TileSize)
for _, tr := range s.tracks {
phase, prog := r.PlatformPhase(tr.tile)
if last, seen := s.lastPhase[tr.tile]; !seen || last != phase {
s.lastPhase[tr.tile] = phase
dist := math.Hypot(float64(tr.platform.X*TileSize)-px, float64(tr.platform.Y*TileSize)-py) / TileSize
switch phase {
case level.Approaching:
g.Audio.Play(SoundApproach, volumeAt(dist))
case level.Closing:
g.Audio.Play(SoundDoors, volumeAt(dist))
}
}
// Vind längs perrongen när tåget rullar in eller ut (och strax före).
windy := phase == level.Arriving || phase == level.Leaving || (phase == level.Approaching && prog > 0.7)
if windy && len(s.particles) < maxParticles {
dir := 1.0
if phase == level.Arriving {
dir = 1.0 // luften trycks framför tåget: samma håll som tåget rör sig (in från minus-sidan)
}
spread := float64(s.rng.IntN(5*TileSize)) - 2*TileSize
x := float64(tr.platform.X*TileSize) + spread
y := float64(tr.platform.Y*TileSize) + float64(s.rng.IntN(TileSize))
vx, vy := dir*(60+float64(s.rng.IntN(60))), float64(s.rng.IntN(10))-5
if !tr.horizontal {
x, y = float64(tr.platform.X*TileSize)+float64(s.rng.IntN(TileSize)), float64(tr.platform.Y*TileSize)+spread
vx, vy = vy, vx
}
s.particles = append(s.particles, particle{x, y, vx, vy, 0.4 + 0.3*s.rng.Float64()})
}
}
kept := s.particles[:0]
for _, p := range s.particles {
p.life -= dt
if p.life <= 0 {
continue
}
p.x += p.vx * dt
p.y += p.vy * dt
kept = append(kept, p)
}
s.particles = kept
}
func (s *LevelScene) drawParticles(screen *ebiten.Image, ox, oy float64) {
for _, p := range s.particles {
a := uint8(200 * math.Min(1, p.life*3))
vector.DrawFilledRect(screen, float32(ox+p.x), float32(oy+p.y), 2, 1, color.RGBA{a, a, a, a}, false)
}
}