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

@@ -5,6 +5,7 @@ package sim
type Camera struct {
ViewW, ViewH float64
WorldW, WorldH float64
Margin float64 // så många pixlar utanför världen får vyn visa (räls vid kanten)
X, Y float64 // vyns övre vänstra hörn i världskoordinater
}
@@ -15,10 +16,11 @@ func NewCamera(viewW, viewH, worldW, worldH float64) *Camera {
return c
}
// Follow centrerar vyn på (cx, cy) och klampar mot världens kanter.
// Follow centrerar vyn på (cx, cy) och klampar mot världens kanter (plus
// Margin).
func (c *Camera) Follow(cx, cy float64) {
c.X = clampAxis(cx-c.ViewW/2, c.ViewW, c.WorldW)
c.Y = clampAxis(cy-c.ViewH/2, c.ViewH, c.WorldH)
c.X = clampAxis(cx-c.ViewW/2+c.Margin, c.ViewW, c.WorldW+2*c.Margin) - c.Margin
c.Y = clampAxis(cy-c.ViewH/2+c.Margin, c.ViewH, c.WorldH+2*c.Margin) - c.Margin
}
// Offset returnerar översättningen världs- -> skärmkoordinater.

View File

@@ -39,3 +39,22 @@ func TestCameraCentersSmallWorld(t *testing.T) {
t.Fatalf("X,Y=%v,%v", c.X, c.Y)
}
}
func TestCameraMarginShowsBeyondEdges(t *testing.T) {
c := NewCamera(320, 180, 320, 192)
c.Margin = 16
c.Follow(160, 1000)
if c.Y != 192-180+16 || c.X != 0 {
t.Fatalf("X,Y=%v,%v – vyn ska nå en ruta nedanför kartan", c.X, c.Y)
}
c.Follow(160, -1000)
if c.Y != -16 {
t.Fatalf("Y=%v – och en ruta ovanför", c.Y)
}
// Utan marginal som förut.
d := NewCamera(320, 180, 320, 192)
d.Follow(160, 1000)
if d.Y != 12 {
t.Fatalf("Y=%v", d.Y)
}
}

View File

@@ -634,8 +634,45 @@ func (r *Run) PlatformHere() (tr *level.Train, atPlatform bool, seconds float64)
if tr == nil {
return nil, false, 0
}
atPlatform, seconds = tr.Schedule.Status(r.Time)
return tr, atPlatform, seconds
_, seconds = tr.Schedule.Status(r.Time)
return tr, tr.Schedule.Boardable(r.Time), seconds
}
// PlatformPhase är tågfasen för en perrongruta (Away om inget tåg).
func (r *Run) PlatformPhase(t level.Tile) (level.Phase, float64) {
tr := r.Level.TrainAt(t)
if tr == nil {
return level.Away, 0
}
return tr.Schedule.Phase(r.Time)
}
// NearTrack hittar närmaste perrong inom maxDist rutor (schackavstånd) från
// spelaren – det är där man ser och hör tåget komma.
func (r *Run) NearTrack(maxDist int) (tr *level.Train, platform level.Point, phase level.Phase, progress float64, dist int, ok bool) {
best := maxDist + 1
for _, t := range []level.Tile{level.Platform, level.WrongPlatform} {
for _, p := range r.Level.Find(t) {
d := max(abs(p.X-r.Player.Tile.X), abs(p.Y-r.Player.Tile.Y))
if d < best {
if cand := r.Level.TrainAt(t); cand != nil {
best, tr, platform = d, cand, p
}
}
}
}
if tr == nil {
return nil, level.Point{}, level.Away, 0, 0, false
}
phase, progress = tr.Schedule.Phase(r.Time)
return tr, platform, phase, progress, best, true
}
func abs(a int) int {
if a < 0 {
return -a
}
return a
}
// SignHere returnerar skylten spelaren står stilla på, annars nil.
@@ -709,14 +746,11 @@ func (r *Run) stepReading() {
}
}
// TrainVisible rapporterar om tåget för en perrongruta står inne just nu.
// TrainVisible rapporterar om tåget för en perrongruta syns just nu
// (rullar in, står inne eller rullar ut).
func (r *Run) TrainVisible(t level.Tile) bool {
tr := r.Level.TrainAt(t)
if tr == nil {
return false
}
at, _ := tr.Schedule.Status(r.Time)
return at
return tr != nil && tr.Schedule.Present(r.Time)
}
// AskHelp börjar fråga en förbipasserande om vad skylten betyder. Kräver att

View File

@@ -901,3 +901,56 @@ func TestLevel003Flow(t *testing.T) {
t.Fatalf("outcome=%v reason=%q tickets=%d", r.Outcome, r.LostReason, r.Wallet.TicketCount)
}
}
func TestNearTrackAndPhases(t *testing.T) {
l := loadLevel(t, "001-flygplatsen")
r := NewRun(l, RunOptions{Seed: rng.FromInt(1)})
r.Party.nextEventAt = 1e9
if _, _, _, _, _, ok := r.NearTrack(2); ok {
t.Fatal("från starten är ingen perrong nära")
}
walk(t, r, l.Path(r.Player.Tile, l.CorrectPlatform(), r.CanEnter))
tr, p, phase, prog, dist, ok := r.NearTrack(2)
if !ok || dist != 0 || p != l.CorrectPlatform() || !tr.Correct || prog < 0 || prog > 1 {
t.Fatalf("ok=%v dist=%d p=%v", ok, dist, p)
}
if want, _ := tr.Schedule.Phase(r.Time); want != phase {
t.Fatal("fasen ska komma från tidtabellen")
}
// Ombordstigning bara med öppna dörrar: under inrullningen vägras man.
for {
ph, _ := r.PlatformPhase(level.Platform)
if ph == level.Arriving {
break
}
r.Step(frame, None, false)
}
if _, at, _ := r.PlatformHere(); at || !r.TrainVisible(level.Platform) {
t.Fatal("under inrullningen syns tåget men dörrarna är stängda")
}
r.Step(frame, None, true)
if r.Mini != nil {
t.Fatal("man kan inte kliva på ett tåg som rullar in")
}
for {
ph, _ := r.PlatformPhase(level.Platform)
if ph == level.Dwelling {
break
}
r.Step(frame, None, false)
}
if _, at, _ := r.PlatformHere(); !at {
t.Fatal("med öppna dörrar kan man kliva på")
}
// Står man två rutor bort ser man tåget komma; tre rutor bort inte.
r2 := NewRun(l, RunOptions{Seed: rng.FromInt(1)})
goal := l.CorrectPlatform()
r2.Player.Tile, r2.Player.Target = level.Point{X: goal.X, Y: goal.Y - 2}, level.Point{X: goal.X, Y: goal.Y - 2}
if _, _, _, _, d, ok := r2.NearTrack(2); !ok || d != 2 {
t.Fatalf("två rutor bort: ok=%v d=%d", ok, d)
}
r2.Player.Tile, r2.Player.Target = level.Point{X: goal.X - 3, Y: goal.Y}, level.Point{X: goal.X - 3, Y: goal.Y}
if _, _, _, _, _, ok := r2.NearTrack(2); ok {
t.Fatal("tre rutor bort är utom räckhåll")
}
}