M9b: folk, filer, reklam, sorl och vagnval
All checks were successful
release / build-release (push) Successful in 1m33s

Folk på stationen (NPC:er som vandrar, kliver av tåg och går mot
utgången, blockerar spelaren men inte varandra), vänster-/högerfiler
som bromsar på fel sida, bredare gångar, reklamaffischer (clutter),
japanskt sorl i loop (noise), vagnval med kvinnovagn (stort avdrag med
man i sällskapet, tidsstyrd), kömarkeringar på perrongen, trängsel per
vagn (bonus för tom vagn, svettig-debuff och svårare resa i full vagn,
släpp förbi passagerare) och att folk kliver av innan man får gå på.
Ny bana 005 Ueno. Skärmdumpsverktyget kan filtrera på bana/skript.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 12:33:44 +02:00
parent 8fca14e42d
commit de61568e44
49 changed files with 1568 additions and 136 deletions

View File

@@ -28,12 +28,16 @@ var tileOrder = []level.Tile{
level.Toilet,
level.Wall, // plats 9 = rälsen (laddas separat som Rails)
level.StairsUp, level.StairsDown,
level.LaneLeft, level.LaneRight,
level.Wall, level.Wall, level.Wall, // plats 13–15 = affischer (laddas separat som Posters)
level.Kiosk,
}
const (
trainIndex = 7
railsIndex = 9
trainIndex = 7
railsIndex = 9
posterIndex = 14
posterCount = 3
)
// Assets är allt som laddats från resources/.
@@ -43,6 +47,7 @@ type Assets struct {
Tiles map[level.Tile]*ebiten.Image
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
Posters []*ebiten.Image // affischer för visuell röra (ritas över väggrutor)
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
@@ -70,7 +75,7 @@ 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 || i == railsIndex {
if i == trainIndex || i == railsIndex || (i >= posterIndex && i < posterIndex+posterCount) {
continue
}
a.Tiles[t] = sub(i)
@@ -78,6 +83,9 @@ func LoadAssets() (*Assets, error) {
a.Tiles[level.WrongPlatform] = a.Tiles[level.Platform] // samma utseende; tåget avgör
a.Train = sub(trainIndex)
a.Rails = sub(railsIndex)
for i := 0; i < posterCount; i++ {
a.Posters = append(a.Posters, sub(posterIndex+i))
}
player, err := loadImage("sprites", "player.png")
if err != nil {

View File

@@ -17,6 +17,7 @@ import (
type Audio struct {
ctx *audio.Context
clips map[string][]byte // avkodad PCM (16-bit stereo LE) per klipp
loop *audio.Player // pågående bakgrundsloop (bakgrundsprat)
}
const sampleRate = 44100
@@ -25,6 +26,7 @@ const sampleRate = 44100
const (
SoundApproach = "melody-approach"
SoundDoors = "door-chime"
SoundChatter = "chatter"
)
// AudibleTiles är hur många rutor bort man hör perrongens ljud.
@@ -36,7 +38,7 @@ func newAudio(mute bool) (*Audio, error) {
return nil, nil
}
a := &Audio{ctx: audio.NewContext(sampleRate), clips: map[string][]byte{}}
for _, name := range []string{SoundApproach, SoundDoors} {
for _, name := range []string{SoundApproach, SoundDoors, SoundChatter} {
b, err := res.ReadFile("sound", name+".wav")
if err != nil {
return nil, err
@@ -68,5 +70,34 @@ func (a *Audio) Play(name string, volume float64) {
p.Play()
}
// Loop startar (eller byter) bakgrundsloopen med given volym; 0 stoppar.
func (a *Audio) Loop(name string, volume float64) {
if a == nil {
return
}
a.StopLoop()
pcm, ok := a.clips[name]
if !ok || volume <= 0 {
return
}
stream := audio.NewInfiniteLoop(bytes.NewReader(pcm), int64(len(pcm)))
p, err := a.ctx.NewPlayer(stream)
if err != nil {
return
}
p.SetVolume(math.Min(1, volume))
p.Play()
a.loop = p
}
// StopLoop stoppar bakgrundsloopen.
func (a *Audio) StopLoop() {
if a == nil || a.loop == nil {
return
}
a.loop.Close()
a.loop = nil
}
// volumeAt ger volym efter avstånd i rutor.
func volumeAt(dist float64) float64 { return math.Max(0, 1-dist/AudibleTiles) }

View File

@@ -164,8 +164,24 @@ func (g *Game) Save() {
}
}
// SetScene byter scen från och med nästa uppdatering.
func (g *Game) SetScene(s Scene) { g.next = s }
// SetScene byter scen från och med nästa uppdatering. Bakgrundsprat hör
// till banan och tystnar när man lämnar den.
func (g *Game) SetScene(s Scene) {
if _, inLevel := s.(*LevelScene); !inLevel {
if _, isResult := s.(*ResultScene); !isResult {
g.Audio.StopLoop()
}
}
g.next = s
}
// startChatter startar banans bakgrundsprat efter ljudröra-nivån.
func (g *Game) startChatter(noise int) {
g.Audio.Loop(SoundChatter, 0.2*float64(noise))
}
// noiseMask dämpar perrongens ljud när bakgrundspratet konkurrerar.
func noiseMask(noise int) float64 { return 1 - 0.2*float64(noise) }
// Update körs 60 ggr/s.
func (g *Game) Update() error {

View File

@@ -30,9 +30,61 @@ func (s *LevelScene) drawMinigame(g *Game, screen *ebiten.Image) {
s.drawCrowd(a, screen, m, panel)
case *minigame.Ride:
s.drawRide(a, screen, m, panel)
case *minigame.CarPick:
s.drawCarPick(a, screen, m, panel)
}
}
// drawCarPick: vagnarna i rad, rosa för kvinnovagn, figurer för trängsel,
// kömarkeringar under, och en fas där folk kliver av först.
func (s *LevelScene) drawCarPick(a *Assets, screen *ebiten.Image, m *minigame.CarPick, p image.Rectangle) {
x, y := p.Min.X+8, p.Min.Y+8
a.DrawBig(screen, "乗車", x, y, colKnown)
a.Draw(screen, a.T("mini.car.title"), x+36, y+2, colText)
y += 24
if m.Alighting > 0 {
a.Draw(screen, a.T("mini.car.alighting"), x, y, colWarn)
w := float32(p.Dx() - 16)
vector.DrawFilledRect(screen, float32(x), float32(y+LineH+2), w, 4, colBar, false)
vector.DrawFilledRect(screen, float32(x), float32(y+LineH+2), w*float32(1-m.Alighting/minigame.AlightSec), 4, colKnown, false)
} else {
a.Draw(screen, a.T("mini.car.pick"), x, y, colDim)
}
y += LineH + 12
carW := min(64, (p.Dx()-16)/max(1, len(m.Cars))-4)
for i, c := range m.Cars {
cx := x + i*(carW+4)
body := color.RGBA{230, 234, 240, 255}
if c.WomenOnly {
body = color.RGBA{255, 160, 205, 255}
}
vector.DrawFilledRect(screen, float32(cx), float32(y), float32(carW), 30, body, false)
vector.DrawFilledRect(screen, float32(cx), float32(y+30), float32(carW), 3, colBad, false)
if i == m.Cursor && m.Alighting <= 0 {
vector.DrawFilledRect(screen, float32(cx), float32(y-4), float32(carW), 2, colKnown, false)
}
a.Draw(screen, fmt.Sprintf("%d%s", c.Index, a.T("hud.car_suffix_ja")), cx+3, y+2, colPanel)
// figurer = trängsel
for k := 0; k <= c.Crowd; k++ {
px := cx + 4 + k*7
vector.DrawFilledRect(screen, float32(px), float32(y+16), 4, 10, color.RGBA{60, 70, 100, 255}, false)
vector.DrawFilledRect(screen, float32(px), float32(y+13), 4, 3, color.RGBA{240, 200, 160, 255}, false)
}
if c.WomenOnly {
a.Draw(screen, "女性専用", cx+2, y+34, color.RGBA{255, 150, 200, 255})
} else {
a.Draw(screen, a.T("car.crowd."+minigame.CrowdNames[c.Crowd]), cx+2, y+34, colDim)
}
// kömarkering under vagnen
vector.DrawFilledRect(screen, float32(cx+carW/2-6), float32(y+48), 12, 2, colWarn, false)
vector.DrawFilledRect(screen, float32(cx+carW/2-1), float32(y+50), 2, 4, colWarn, false)
}
if s.run.Party.HasMen() {
a.Draw(screen, a.T("mini.car.men"), x, p.Max.Y-28, colWarn)
}
a.Draw(screen, a.T("mini.car.hint"), x, p.Max.Y-16, colDim)
}
// jp ritar japansk text sedd genom spelarens kunskap (kända ord i gult).
func (s *LevelScene) jp(a *Assets, screen *ebiten.Image, text string, x, y int) {
rd := lang.Render(a.Dict, s.run.Know, text, UILang)
@@ -171,6 +223,11 @@ func (s *LevelScene) drawRide(a *Assets, screen *ebiten.Image, m *minigame.Ride,
if dest := lvl.Destination.Get("romaji"); dest != "" {
hint = strings.Replace(hint, "%s", dest, 1)
}
if m.NeedsLetPass() {
a.Draw(screen, a.T("mini.ride.let_pass"), x, p.Max.Y-30, colBad)
} else if m.Crowd > 0 {
a.Draw(screen, fmt.Sprintf("%s: %s", a.T("mini.ride.crowd"), a.T("car.crowd."+minigame.CrowdNames[m.Crowd])), x, p.Max.Y-30, colDim)
}
a.Draw(screen, hint, x, p.Max.Y-16, colGood)
}

98
internal/game/people.go Normal file
View File

@@ -0,0 +1,98 @@
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"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
)
// Folk, affischer och kömarkeringar (M9b).
// npcTints är gråtoner för passagerare tills de har egna sprites.
var npcTints = [][3]float32{{0.75, 0.75, 0.8}, {0.7, 0.8, 0.75}, {0.8, 0.72, 0.72}, {0.78, 0.78, 0.7}}
// drawNPCs ritar passagerarna på spelarens våning.
func (s *LevelScene) drawNPCs(g *Game, screen *ebiten.Image, ox, oy float64) {
z := s.run.Player.Tile.Z
for i, n := range s.run.NPCs {
if n.Mover.Tile.Z != z {
continue
}
x, y := n.Mover.WorldPos(TileSize)
var op ebiten.DrawImageOptions
op.GeoM.Translate(ox+math.Round(x), oy+math.Round(y))
t := npcTints[i%len(npcTints)]
op.ColorScale.Scale(t[0], t[1], t[2], 1)
screen.DrawImage(g.Assets.PlayerFrame(n.Mover.Facing, n.Mover.Moving(), s.animT+float64(i)*0.07), &op)
}
}
// buildPosters väljer seedat vilka väggrutor som får affischer (visuell röra).
func (s *LevelScene) buildPosters() {
s.posters = map[level.Point]int{}
l := s.run.Level
if l.Clutter == 0 {
return
}
r := s.run.Seed.Derive("posters").New()
chance := 0.12 * float64(l.Clutter)
for z := 0; z < l.NumFloors(); z++ {
for y := 0; y < l.Height(); y++ {
for x := 0; x < l.Width(); x++ {
p := level.Point{X: x, Y: y, Z: z}
if l.At(p) != level.Wall {
continue
}
adjFloor := false
for _, n := range l.Neighbors(p) {
if n.Z == z && l.At(n).Passable() {
adjFloor = true
}
}
if adjFloor && r.Float64() < chance {
s.posters[p] = r.IntN(posterCount)
}
}
}
}
}
// drawQueueMarks ritar kömarkeringar på perrongen (var man ställer sig för
// varje vagn) när tåget är på väg in eller står inne.
func (s *LevelScene) drawQueueMarks(screen *ebiten.Image, ox, oy float64) {
z := s.run.Player.Tile.Z
for _, tr := range s.tracks {
if tr.platform.Z != z {
continue
}
phase, _ := s.run.PlatformPhase(tr.tile)
if phase == level.Away || phase == level.Leaving {
continue
}
train := s.run.Level.TrainAt(tr.tile)
if train == nil {
continue
}
x := float32(ox + float64(tr.platform.X*TileSize))
y := float32(oy + float64(tr.platform.Y*TileSize))
cars := train.Cars
for k := 0; k < cars && k < 4; k++ {
col := color.RGBA{255, 255, 255, 200}
if train.WomenOnlyActive(s.run.Time) && k+1 == train.WomenOnlyCar {
col = color.RGBA{255, 150, 200, 230} // rosa: kvinnovagn
}
if tr.horizontal {
vector.DrawFilledRect(screen, x+float32(2+k*4), y+TileSize-6, 2, 3, col, false)
} else {
vector.DrawFilledRect(screen, x+TileSize-6, y+float32(2+k*4), 3, 2, col, false)
}
}
}
}
var _ = sim.NPCSpeed

View File

@@ -39,6 +39,8 @@ type LevelScene struct {
shop *shop // öppen butik i kiosken (pausar banan)
outcome LevelOutcome // progression från banan (fylls i när den avgjorts)
posters map[level.Point]int // affischer per väggruta (visuell röra)
// Tåg och effekter (M7b).
tracks []trackInfo
rails map[level.Point]bool
@@ -64,6 +66,8 @@ func NewLevelScene(g *Game, id string, seed rng.Seed) (*LevelScene, error) {
s.lastPhase = map[level.Tile]level.Phase{}
s.rng = rand.New(rand.NewPCG(1, 2))
s.buildTracks()
s.buildPosters()
g.startChatter(lvl.Noise)
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)
}
@@ -202,11 +206,17 @@ func (s *LevelScene) Draw(g *Game, screen *ebiten.Image) {
}
op.GeoM.Reset()
op.GeoM.Translate(ox+float64(x*TileSize), oy+float64(y*TileSize))
if v, ok := s.posters[p]; ok {
screen.DrawImage(g.Assets.Posters[v], &op)
continue
}
screen.DrawImage(g.Assets.Tiles[lvl.At(p)], &op)
}
}
s.drawTrains(g, screen, ox, oy)
s.drawQueueMarks(screen, ox, oy)
s.drawParticles(screen, ox, oy)
s.drawNPCs(g, screen, ox, oy)
// Följarna ritas bakifrån så att ledaren hamnar överst.
leader := s.run.Player
@@ -356,6 +366,11 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
}
lines = append(lines, a.wrapLine(plainLine(a.T(r.FlashKey), col), ScreenW-8)...)
}
if r.WrongLane {
lines = append(lines, plainLine(a.T("hud.wrong_lane"), colWarn))
} else if r.BumpedAt >= 0 && r.Time-r.BumpedAt < 1.0 {
lines = append(lines, plainLine(a.T("hud.bump"), colDim))
}
if debuffs := g.Profile.Debuffs; len(debuffs) > 0 && r.Time < 6 {
var parts []string
for _, d := range debuffs {

View File

@@ -82,8 +82,8 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) {
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", 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.penalty"), sc.Penalty), 28, y, colDim)
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
msg := fmt.Sprintf("+%d %s", run.PointsEarned, a.T("result.lang_points"))
if len(run.LearnedDest) > 0 {

View File

@@ -40,6 +40,7 @@ func (s *LevelScene) buildTracks() {
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})
out := level.Point{X: tt.X - p.X, Y: tt.Y - p.Y} // riktningen bort från perrongen
for k := -railsReach; k <= railsReach; k++ {
c := tt
if horizontal {
@@ -47,7 +48,9 @@ func (s *LevelScene) buildTracks() {
} else {
c.Y += k
}
if s.run.Level.At(c) == level.Wall && (c.X == 0 || c.Y == 0 || c.X == s.run.Level.Width()-1 || c.Y == s.run.Level.Height()-1) {
beyond := level.Point{X: c.X + out.X, Y: c.Y + out.Y, Z: c.Z}
// Räls på väggrutor längs spåret, så länge det är vägg (eller kartkant) bortom dem.
if s.run.Level.At(c) == level.Wall && s.run.Level.At(beyond) == level.Wall {
s.rails[c] = true
}
}
@@ -158,11 +161,12 @@ func (s *LevelScene) updateEffects(g *Game) {
}
s.lastPhase[tr.tile] = phase
dist := math.Hypot(float64(tr.platform.X*TileSize)-px, float64(tr.platform.Y*TileSize)-py) / TileSize
mask := noiseMask(r.Level.Noise)
switch phase {
case level.Approaching:
g.Audio.Play(SoundApproach, volumeAt(dist))
g.Audio.Play(SoundApproach, volumeAt(dist)*mask)
case level.Closing:
g.Audio.Play(SoundDoors, volumeAt(dist))
g.Audio.Play(SoundDoors, volumeAt(dist)*mask)
}
}
// Vind längs perrongen när tåget rullar in eller ut (och strax före).