M7b: tågfaser, räls, in-/utkörning, vind, ankomstindikator och melodier
All checks were successful
release / build-release (push) Successful in 1m42s
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:
@@ -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
72
internal/game/audio.go
Normal 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) }
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
196
internal/game/trains.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,72 @@ func (s Schedule) Status(t float64) (atPlatform bool, seconds float64) {
|
||||
return false, s.IntervalSec - phase
|
||||
}
|
||||
|
||||
// Phase är var i cykeln ett tåg befinner sig.
|
||||
type Phase int
|
||||
|
||||
// Tågfaser i ordning över en cykel.
|
||||
const (
|
||||
Away Phase = iota // borta
|
||||
Approaching // på väg in: melodi, lampor, vind (ApproachSec före ankomst)
|
||||
Arriving // rullar in och bromsar (ArriveSec), dörrarna stängda
|
||||
Dwelling // står inne med öppna dörrar
|
||||
Closing // strax avgång: dörrsignal (sista WarnSec av uppehållet)
|
||||
Leaving // rullar ut (LeaveSec efter avgång)
|
||||
)
|
||||
|
||||
func (p Phase) String() string {
|
||||
return [...]string{"away", "approaching", "arriving", "dwelling", "closing", "leaving"}[p]
|
||||
}
|
||||
|
||||
// Fasernas längder i sekunder.
|
||||
const (
|
||||
ApproachSec = 5.0
|
||||
ArriveSec = 1.5
|
||||
WarnSec = 3.0
|
||||
LeaveSec = 1.5
|
||||
MinDwellSec = ArriveSec + WarnSec + 1 // minst en sekund öppna dörrar utan signal
|
||||
)
|
||||
|
||||
// Phase returnerar fasen vid tiden t och hur långt in i fasen man är (0..1).
|
||||
func (s Schedule) Phase(t float64) (Phase, float64) {
|
||||
var phi float64 // tid sedan senaste ankomst, kan vara negativ före första
|
||||
if t < s.OffsetSec {
|
||||
phi = t - s.OffsetSec
|
||||
if -phi <= ApproachSec {
|
||||
return Approaching, 1 + phi/ApproachSec
|
||||
}
|
||||
return Away, 0
|
||||
} else {
|
||||
phi = math.Mod(t-s.OffsetSec, s.IntervalSec)
|
||||
}
|
||||
switch {
|
||||
case phi < ArriveSec:
|
||||
return Arriving, phi / ArriveSec
|
||||
case phi < s.DwellSec-WarnSec:
|
||||
return Dwelling, (phi - ArriveSec) / max(0.001, s.DwellSec-WarnSec-ArriveSec)
|
||||
case phi < s.DwellSec:
|
||||
return Closing, (phi - (s.DwellSec - WarnSec)) / WarnSec
|
||||
case phi < s.DwellSec+LeaveSec:
|
||||
return Leaving, (phi - s.DwellSec) / LeaveSec
|
||||
case phi >= s.IntervalSec-ApproachSec:
|
||||
return Approaching, (phi - (s.IntervalSec - ApproachSec)) / ApproachSec
|
||||
default:
|
||||
return Away, (phi - s.DwellSec - LeaveSec) / max(0.001, s.IntervalSec-ApproachSec-s.DwellSec-LeaveSec)
|
||||
}
|
||||
}
|
||||
|
||||
// Boardable rapporterar om dörrarna är öppna så att man kan kliva på.
|
||||
func (s Schedule) Boardable(t float64) bool {
|
||||
p, _ := s.Phase(t)
|
||||
return p == Dwelling || p == Closing
|
||||
}
|
||||
|
||||
// Present rapporterar om tåget syns vid perrongen (rullar in, står, rullar ut).
|
||||
func (s Schedule) Present(t float64) bool {
|
||||
p, _ := s.Phase(t)
|
||||
return p == Arriving || p == Dwelling || p == Closing || p == Leaving
|
||||
}
|
||||
|
||||
// Train är ett tåg (eller en buss) som avgår från en perrong i banan.
|
||||
type Train struct {
|
||||
Platform string `json:"platform"` // vilket perrong-tecken (P/p) tåget står vid
|
||||
@@ -234,6 +300,9 @@ func (l *Level) Validate() error {
|
||||
if sc.OffsetSec < 0 || sc.IntervalSec <= 0 || sc.DwellSec <= 0 || sc.DwellSec >= sc.IntervalSec {
|
||||
return fmt.Errorf("level %s: tåg %d har orimlig tidtabell %+v (kräver offset ≥ 0, 0 < dwell < interval)", l.ID, i, *sc)
|
||||
}
|
||||
if sc.DwellSec < MinDwellSec || sc.IntervalSec-sc.DwellSec < LeaveSec+ApproachSec+1 {
|
||||
return fmt.Errorf("level %s: tåg %d: uppehållet måste vara ≥ %.1f s och tiden borta ≥ %.1f s för tågfaserna", l.ID, i, MinDwellSec, LeaveSec+ApproachSec+1)
|
||||
}
|
||||
}
|
||||
if correct == 0 {
|
||||
return fmt.Errorf("level %s: inget tåg är markerat som rätt", l.ID)
|
||||
@@ -417,6 +486,19 @@ func (tr *Train) BoardText() string {
|
||||
return tr.Track + "番線 " + tr.Line.Get("ja") + " " + tr.Destination.Get("ja") + "方面"
|
||||
}
|
||||
|
||||
// TrackTile är rutan "bortom perrongkanten" där rälsen går: första väggruta
|
||||
// nedanför, till höger, till vänster eller ovanför perrongen. horizontal
|
||||
// anger om rälsen (och tågets rörelse) går i sidled.
|
||||
func (l *Level) TrackTile(p Point) (tile Point, horizontal bool) {
|
||||
for _, d := range [...]Point{{X: 0, Y: 1}, {X: 1, Y: 0}, {X: -1, Y: 0}, {X: 0, Y: -1}} {
|
||||
n := Point{X: p.X + d.X, Y: p.Y + d.Y}
|
||||
if l.At(n.X, n.Y) == Wall {
|
||||
return n, d.Y != 0
|
||||
}
|
||||
}
|
||||
return p, true
|
||||
}
|
||||
|
||||
// CorrectPlatform returnerar en perrongruta med rätt tåg (valideringen
|
||||
// garanterar att det finns minst ett rätt tåg vid en befintlig perrong).
|
||||
func (l *Level) CorrectPlatform() Point {
|
||||
|
||||
@@ -321,3 +321,66 @@ func TestDifficultySettings(t *testing.T) {
|
||||
t.Fatal("inställningarna ska läsas")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchedulePhases(t *testing.T) {
|
||||
s := Schedule{OffsetSec: 10, IntervalSec: 30, DwellSec: 8}
|
||||
cases := []struct {
|
||||
t float64
|
||||
phase Phase
|
||||
}{
|
||||
{0, Away}, {4.9, Away}, {5.1, Approaching}, {9.9, Approaching},
|
||||
{10, Arriving}, {11.4, Arriving}, {11.6, Dwelling}, {14.9, Dwelling},
|
||||
{15.1, Closing}, {17.9, Closing}, {18.1, Leaving}, {19.4, Leaving},
|
||||
{19.6, Away}, {34.9, Away}, {35.1, Approaching}, {40.5, Arriving}, {42, Dwelling},
|
||||
}
|
||||
for _, c := range cases {
|
||||
p, prog := s.Phase(c.t)
|
||||
if p != c.phase {
|
||||
t.Errorf("t=%.1f: fas %s, vill ha %s", c.t, p, c.phase)
|
||||
}
|
||||
if prog < 0 || prog > 1.0001 {
|
||||
t.Errorf("t=%.1f: progress %.2f utanför 0..1", c.t, prog)
|
||||
}
|
||||
}
|
||||
if !s.Boardable(13) || s.Boardable(10.5) || s.Boardable(18.5) || !s.Boardable(16) {
|
||||
t.Fatal("ombordstigning bara med öppna dörrar (dwelling/closing)")
|
||||
}
|
||||
if !s.Present(10.5) || !s.Present(19) || s.Present(19.6) || s.Present(9) {
|
||||
t.Fatal("tåget syns från inrullning till utrullning")
|
||||
}
|
||||
// Progress växer monotont inom en fas.
|
||||
p1, a := s.Phase(5.5)
|
||||
p2, b := s.Phase(9.5)
|
||||
if p1 != Approaching || p2 != Approaching || b <= a {
|
||||
t.Fatalf("approaching-progress %.2f -> %.2f", a, b)
|
||||
}
|
||||
// Status och Phase är konsekventa: "vid perrongen" = arriving/dwelling/closing.
|
||||
for tt := 0.0; tt < 60; tt += 0.25 {
|
||||
at, _ := s.Status(tt)
|
||||
p, _ := s.Phase(tt)
|
||||
if at != (p == Arriving || p == Dwelling || p == Closing) {
|
||||
t.Fatalf("t=%.2f: Status=%v men fas=%s", tt, at, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleValidationForPhases(t *testing.T) {
|
||||
if _, err := Parse([]byte(strings.Replace(sample, `"correct": true`, `"correct": true, "schedule": {"intervalSec": 30, "dwellSec": 4}`, 1))); err == nil {
|
||||
t.Fatal("för kort uppehåll för faserna ska ge fel")
|
||||
}
|
||||
if _, err := Parse([]byte(strings.Replace(sample, `"correct": true`, `"correct": true, "schedule": {"intervalSec": 12, "dwellSec": 6}`, 1))); err == nil {
|
||||
t.Fatal("för kort tid borta ska ge fel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackTile(t *testing.T) {
|
||||
l := mustParse(t, sample)
|
||||
tile, horizontal := l.TrackTile(Point{5, 1}) // P uppe till höger: vägg ovanför? nej, (5,0) är vägg -> men ordningen är ner, höger, vänster, upp
|
||||
if tile != (Point{5, 2}) && tile != (Point{6, 1}) && tile != (Point{5, 0}) {
|
||||
t.Fatalf("TrackTile = %v", tile)
|
||||
}
|
||||
// (5,2) är golv, (6,1) är vägg -> höger: vertikal räls
|
||||
if tile == (Point{6, 1}) && horizontal {
|
||||
t.Fatal("räls till höger om perrongen är vertikal")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user