M10b: bussar, fler sidospår och överstimulerande UI
All checks were successful
release / build-release (push) Successful in 1m36s

Ett tåg kan vara en buss (vehicle: bus): samma tidtabell, faser,
bedömning i nätet och ombordstigning, men ett fordon på en väg i stället
för vagnar på räls, hållplatsskylt, "1番" på tavlan, tuta vid inrullning
och dörrsignal före avgång. Nätet har tre busslinjer från Narita. Nya
sidobanor: 001b Flygbussterminalen (biljettbana med tre bussar) och 003b
Harajuku (Yamanote åt rätt eller fel håll runt ringen). Overload-flaggan
ger en rullande reklamticker och maximalt med affischer (Ueno, Tokyo).
Romaji utan makroner (fonten saknar dem). Skärmdumparna som börjar på
bankartan går via titelskärmen. Tester, docs och wiki uppdaterade.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 17:33:55 +01:00
parent 87695b668b
commit 0c47b0ad00
35 changed files with 666 additions and 73 deletions

View File

@@ -32,6 +32,7 @@ var tileOrder = []level.Tile{
level.LaneLeft, level.LaneRight,
level.Wall, level.Wall, level.Wall, // plats 13–15 = affischer (laddas separat som Posters)
level.Kiosk,
level.Wall, level.Wall, level.Wall, // plats 18–20 = buss, väg, hållplatsskylt (laddas separat)
}
const (
@@ -39,6 +40,9 @@ const (
railsIndex = 9
posterIndex = 14
posterCount = 3
busIndex = 18
roadIndex = 19
stopIndex = 20
)
// Assets är allt som laddats från resources/.
@@ -50,6 +54,9 @@ type Assets struct {
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)
Plane *ebiten.Image // flygplanet i slutscenen
Bus *ebiten.Image // bussen (ett fordon, inte vagnar)
Road *ebiten.Image // vägruta (horisontell), roteras för vertikala
StopSign *ebiten.Image // hållplatsskylt ovanpå busshållplatsen
Icon image.Image // fönsterikonen (nil om den saknas)
Player [4][3]*ebiten.Image // [riktning: ner, upp, vänster, höger][bildruta: stilla, gå1, gå2]
Text map[string]string
@@ -78,7 +85,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 || (i >= posterIndex && i < posterIndex+posterCount) {
if i == trainIndex || i == railsIndex || (i >= posterIndex && i < posterIndex+posterCount) || i >= busIndex {
continue
}
a.Tiles[t] = sub(i)
@@ -88,6 +95,7 @@ func LoadAssets() (*Assets, error) {
}
a.Train = sub(trainIndex)
a.Rails = sub(railsIndex)
a.Bus, a.Road, a.StopSign = sub(busIndex), sub(roadIndex), sub(stopIndex)
for i := 0; i < posterCount; i++ {
a.Posters = append(a.Posters, sub(posterIndex+i))
}

View File

@@ -48,6 +48,7 @@ const (
SoundLose = "lose"
SoundStaff = "staff"
SoundPlane = "plane"
SoundBusHorn = "bus-horn"
)
// StationMelodies är avgångsmelodierna; varje station har sin (level.Melody
@@ -57,7 +58,7 @@ var StationMelodies = []string{"melody-a", "melody-b", "melody-c", "melody-d"}
// AllSounds är alla klipp som måste finnas i resources/sound.
func AllSounds() []string {
return append([]string{SoundApproach, SoundAnnounce, SoundDoors, SoundChatter, SoundMusicMap, SoundGateBeep, SoundGateDeny,
SoundKiosk, SoundToilet, SoundLearn, SoundConfirm, SoundCancel, SoundTransfer, SoundWin, SoundLose, SoundStaff, SoundPlane},
SoundKiosk, SoundToilet, SoundLearn, SoundConfirm, SoundCancel, SoundTransfer, SoundWin, SoundLose, SoundStaff, SoundPlane, SoundBusHorn},
StationMelodies...)
}

View File

@@ -36,11 +36,11 @@ func (s *LevelScene) drawNPCs(g *Game, screen *ebiten.Image, ox, oy float64) {
func (s *LevelScene) buildPosters() {
s.posters = map[level.Point]int{}
l := s.run.Level
if l.Clutter == 0 {
if s.clutterLevel() == 0 {
return
}
r := s.run.Seed.Derive("posters").New()
chance := 0.12 * float64(l.Clutter)
chance := 0.12 * float64(s.clutterLevel())
for z := 0; z < l.NumFloors(); z++ {
for y := 0; y < l.Height(); y++ {
for x := 0; x < l.Width(); x++ {

View File

@@ -58,6 +58,7 @@ type LevelScene struct {
// Tåg och effekter (M7b).
tracks []trackInfo
rails map[level.Point]bool
roads map[level.Point]bool // vägrutor för bussar (ritas med vägsprite)
lastPhase map[level.Tile]level.Phase
particles []particle
rng *rand.Rand // bara för partiklar (ingen spellogik)
@@ -299,7 +300,7 @@ func (s *LevelScene) Draw(g *Game, screen *ebiten.Image) {
for y := y0; y < y1; y++ {
for x := x0; x < x1; x++ {
p := level.Point{X: x, Y: y, Z: z}
if s.rails[p] {
if s.rails[p] || s.roads[p] {
s.drawRails(g, screen, p, ox, oy)
continue
}
@@ -358,6 +359,7 @@ func (s *LevelScene) Draw(g *Game, screen *ebiten.Image) {
s.drawIndicator(screen, ox, oy)
s.drawHUD(g, screen)
s.drawTicker(g, screen)
s.drawTip(g, screen)
if r := s.run; r.TransferAt >= 0 && r.Time-r.TransferAt < transferBannerSec && r.Mini == nil && r.Outcome == sim.Ongoing {
txt := g.Assets.T("hud.transfer_done")

View File

@@ -69,7 +69,7 @@ func (m *MapScene) nodes() ([]mapNode, map[string][]mapNode) {
}
mains = append(mains, mapNode{l, x, y})
for k, s := range m.sides[l.ID] {
sides[l.ID] = append(sides[l.ID], mapNode{s, x + 12 + k*18, y - 40})
sides[l.ID] = append(sides[l.ID], mapNode{s, x + 12 + k*18, y - 28})
}
}
return mains, sides

53
internal/game/ticker.go Normal file
View File

@@ -0,0 +1,53 @@
package game
import (
"image/color"
"math"
"strings"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
)
// Överstimulerande UI (level.Overload): en rullande reklamticker i
// blinkande färger under HUD:en, och fler affischer på väggarna. Texten är
// reklamfraser som inte betyder något för spelet – det är poängen.
var adPhrases = []string{"大セール 半額!", "新発売 ラーメン", "免税 お土産", "駅弁 人気No.1", "本日限り 特価", "ポイント2倍", "新宿 ショッピング", "温泉 旅行 キャンペーン"}
const tickerSpeed = 30.0 // px/s
// drawTicker ritar reklamtickern (bara med Overload och när inget annat
// ligger där).
func (s *LevelScene) drawTicker(g *Game, screen *ebiten.Image) {
r := s.run
if !r.Level.Overload || r.Mini != nil || r.Outcome != 0 || s.tips.current != "" {
return
}
a := g.Assets
text := strings.Join(adPhrases, " ★ ") + " ★ "
w := a.Width(text)
if w == 0 {
return
}
y := 26
blink := math.Mod(r.Time, 1.0) < 0.5
bg, fg := color.RGBA{120, 30, 60, 230}, color.RGBA{255, 230, 120, 255}
if blink {
bg, fg = color.RGBA{40, 60, 140, 230}, color.RGBA{255, 255, 255, 255}
}
vector.DrawFilledRect(screen, 0, float32(y), ScreenW, LineH+2, bg, false)
x := ScreenW - int(math.Mod(r.Time*tickerSpeed, float64(w+ScreenW)))
for _, sx := range []int{x, x + w} {
if sx < ScreenW && sx+w > 0 {
a.Draw(screen, text, sx, y+1, fg)
}
}
}
// clutterLevel är den visuella röran: banans clutter, maximal med Overload.
func (s *LevelScene) clutterLevel() int {
if s.run.Level.Overload {
return 3
}
return s.run.Level.Clutter
}

View File

@@ -31,15 +31,21 @@ type trackInfo struct {
platform level.Point // perrongrutan
track level.Point // rälsrutan bortom kanten
horizontal bool // rälsens riktning
bus bool // buss: väg och ett fordon i stället för räls och vagnar
}
func (s *LevelScene) buildTracks() {
s.tracks = nil
s.rails = map[level.Point]bool{}
s.roads = map[level.Point]bool{}
for _, t := range level.PlatformTiles {
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})
bus := false
if tr := s.run.Level.TrainAt(t); tr != nil && tr.IsBus() {
bus = true
}
s.tracks = append(s.tracks, trackInfo{tile: t, platform: p, track: tt, horizontal: horizontal, bus: bus})
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
@@ -51,7 +57,11 @@ func (s *LevelScene) buildTracks() {
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
if bus {
s.roads[c] = true
} else {
s.rails[c] = true
}
}
}
}
@@ -87,7 +97,11 @@ func (s *LevelScene) drawRails(g *Game, screen *ebiten.Image, c level.Point, ox,
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)
img := g.Assets.Rails
if s.roads[c] {
img = g.Assets.Road
}
screen.DrawImage(img, &op)
}
// drawTrains ritar tågen i rätt läge för sin fas.
@@ -98,6 +112,11 @@ func (s *LevelScene) drawTrains(g *Game, screen *ebiten.Image, ox, oy float64) {
continue // tågen på andra våningar syns inte härifrån
}
phase, prog := s.run.PlatformPhase(tr.tile)
if tr.bus { // hållplatsskylten står på hållplatsen
var op ebiten.DrawImageOptions
op.GeoM.Translate(ox+float64(tr.platform.X*TileSize), oy+float64(tr.platform.Y*TileSize))
screen.DrawImage(g.Assets.StopSign, &op)
}
// 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)
@@ -106,8 +125,12 @@ func (s *LevelScene) drawTrains(g *Game, screen *ebiten.Image, ox, oy float64) {
if !show {
continue
}
for k := 0; k < trainCars; k++ {
d := off + float64(k-trainCars/2)
cars, sprite := trainCars, g.Assets.Train
if tr.bus {
cars, sprite = 1, g.Assets.Bus
}
for k := 0; k < cars; k++ {
d := off + float64(k-cars/2)
var op ebiten.DrawImageOptions
x, y := float64(tr.track.X*TileSize), float64(tr.track.Y*TileSize)
if tr.horizontal {
@@ -119,7 +142,7 @@ func (s *LevelScene) drawTrains(g *Game, screen *ebiten.Image, ox, oy float64) {
op.GeoM.Translate(TileSize/2, TileSize/2)
}
op.GeoM.Translate(ox+math.Round(x), oy+math.Round(y))
screen.DrawImage(g.Assets.Train, &op)
screen.DrawImage(sprite, &op)
}
}
}
@@ -162,14 +185,19 @@ 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: // ankomstjingeln: tåget är på väg
switch {
case tr.bus && phase == level.Arriving: // bussen tutar när den svänger in
g.Audio.Play(SoundBusHorn, volumeAt(dist)*mask)
case tr.bus && phase == level.Closing:
g.Audio.Play(SoundDoors, volumeAt(dist)*mask)
case tr.bus:
case phase == level.Approaching: // ankomstjingeln: tåget är på väg
g.Audio.Play(SoundApproach, volumeAt(dist)*mask)
case level.Arriving: // ding-dong när det rullar in
case phase == level.Arriving: // ding-dong när det rullar in
g.Audio.Play(SoundAnnounce, volumeAt(dist)*mask)
case level.Closing: // stationens avgångsmelodi innan dörrarna stängs
case phase == level.Closing: // stationens avgångsmelodi innan dörrarna stängs
g.Audio.Play(MelodyFor(r.Level), volumeAt(dist)*mask)
case level.Leaving: // dörrsignalen när det går
case phase == level.Leaving: // dörrsignalen när det går
g.Audio.Play(SoundDoors, volumeAt(dist)*mask)
}
}