Files
metro-puzzle-jp/internal/game/trains.go
claude d466333f01
All checks were successful
release / build-release (push) Successful in 1m42s
M7b: tågfaser, räls, in-/utkörning, vind, ankomstindikator och melodier
- 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>
2026-10-24 11:03:12 +02:00

197 lines
6.5 KiB
Go

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)
}
}