M9b: folk, filer, reklam, sorl och vagnval
All checks were successful
release / build-release (push) Successful in 1m33s
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:
@@ -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 {
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
98
internal/game/people.go
Normal 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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user