M10: byten, hemresan och levelcheck
All checks were successful
release / build-release (push) Successful in 1m37s

Resor med byten: en bana kan ha next som pekar på en delbana (kind leg,
syns inte på bankartan). När man klivit av rätt tåg fortsätter samma
omgång på nästa station med klocka, plånbok, sällskap och räknare;
par-tiden adderas, dimma och folk är nya, en Byte!-banner visas och
HUD:en visar resmålet via bytesstationen. Tidsgräns och missade tåg
gäller hela resan. level.ValidateChains kräver sammanhängande kedjor.
Nya resor: 007 Akihabara -> Ueno -> Asakusa och finalen 008 Tokyo (två
våningar) -> Nippori -> Narita flygplats. Slutscen när sista huvudbanan
klaras första gången: planet lyfter, resan summeras, eftertexter;
Progress.StoryCleared sparas; flaggan -ending. tools/levelcheck ritar
banorna och rapporterar problem. HUD-nivån kortas när mätarna tar plats;
poängraden radbryts. Skärmdumpar och docs uppdaterade.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 13:37:48 +02:00
parent f762f9554e
commit 4ae6a1d24c
36 changed files with 1681 additions and 74 deletions

View File

@@ -10,17 +10,14 @@ Storyn följer några polare på semester i Japan, från landningen på
flygplatsen till avresan. Efter storyn låses ett oändligt läge och New Game+
upp. Hela speldesignen finns i [doc/spelbeskrivning.md](doc/spelbeskrivning.md).
**Status:** M9 klar (2026-10-24) – sju banor på en bankarta i
spelbrädesstil, med sidospår och flera våningar. Japanska skyltar och
ordinlärning, lämmeltåget, minikarta, tågkort, biljetter och minispel,
svårighet med tidsgräns, poäng och debuffar, tåg som rullar in till
avgångsmelodin, progression med XP, skillträd, items och sparfil, folk,
filer, reklam, sorl och vagnval. Och nu ett riktigt linjenät: tågen bedöms
efter var de faktiskt går, resan visar linjens riktiga stationer, och man
väljer **listläge** (nästa tåg visas) eller **skyltläge** (lista ut det
själv med skyltar och terminalen, +50 % poäng – åk åt fel håll eller för
långt bort och banan är förlorad). Nästa är M10: story-innehåll och
flerbytesbanor. Se
**Status:** M10 klar (2026-10-24) – hela storyn från flygplats till
flygplats: åtta huvudbanor och ett sidospår på en bankarta i spelbrädesstil,
med flera våningar, folk, reklam, sorl, vagnval och ett riktigt linjenät med
list- och skyltläge. De två sista resorna har **byten**: man kliver av på en
mellanstation och fortsätter i samma omgång, med klockan, plånboken och
sällskapet i behåll. Klarar man finalen (Tokyo → Nippori → Narita) visas
**hemresan**: planet lyfter, resan summeras och eftertexterna rullar. Nästa
är M10b (bussar) eller M11 (oändligt läge och New Game+). Se
[doc/roadmap.md](doc/roadmap.md).
![Bankartan](doc/img/map.png)
@@ -74,6 +71,13 @@ fyra rätt, ett går åt fel håll och ett tar omvägen via city.
![Terminalen i skyltläge](doc/img/terminal.png)
Resorna med byten fortsätter på nästa station när du klivit av: nytt
labyrintgolv, samma klocka och samma hungriga sällskap.
![Byte i Ueno](doc/img/transfer.png)
![Hemresan](doc/img/ending.png)
På de svåraste banorna har alla i sällskapet egna kort som måste ha saldo,
alla behöver biljett, och när biljetten är köpt ska sällskapet placeras i
vagnen efter sina önskemål: fönster eller gång, vänster eller höger sida,
@@ -173,6 +177,7 @@ metro-puzzle-jp -mute # inget ljud
metro-puzzle-jp -save /sökväg/save.json # annan sparfil (standard ~/.config/metro-puzzle-jp/save.json)
metro-puzzle-jp -nosave # läs sparfilen men skriv aldrig (tester/skärmdumpar)
metro-puzzle-jp -mode signs # navigeringsläge från start: list eller signs (annars sparfilens; N på bankartan växlar)
metro-puzzle-jp -ending # öppna slutscenen (hemresan) direkt (tester/skärmdumpar)
metro-puzzle-jp -script "move right 4; shot bild.png; quit" # skriptläge, se internal/game/script.go
metro-puzzle-jp -version
```
@@ -193,7 +198,8 @@ Y = hjälp, X = ordlista, Back/Select = karta.
| Kommando | Gör |
|----------|-----|
| `go run ./tools/build …` | bygger (se ovan) |
| `go run ./tools/genart` | genererar placeholder-grafiken `resources/sprites/tiles.png` och `player.png` |
| `go run ./tools/genart` | genererar placeholder-grafiken `resources/sprites/tiles.png`, `player.png` och `plane.png` |
| `go run ./tools/levelcheck [ord…]` | ritar varje bana (per våning) som PNG i `build/levelcheck/` och rapporterar lösbarhet, onåbara rutor, perronger utanför spärrar, tågens bedömning i linjenätet och resornas byten; avslutar med fel om något är trasigt |
| `go run ./tools/gensound` | genererar placeholder-ljuden `resources/sound/*.wav` (avgångsmelodi, dörrsignal) |
| `go run ./tools/shots [ord…]` | tar dokumentationens skärmdumpar till `doc/img/` – **kör efter varje grafik-/UI-ändring och committa bilderna**. Med argument körs bara skott vars bana eller skript innehåller något av orden (t.ex. `go run ./tools/shots 005 ride.png`). På Linux körs spelet på en osynlig skärm (headless Wayfire + Xwayland) så att inget fönster syns eller tar fokus; `METRO_HEADLESS=0` använder skärmen |

View File

@@ -29,6 +29,7 @@ func main() {
savePath := flag.String("save", "", "sparfil (standard: ~/.config/metro-puzzle-jp/save.json resp. %AppData%)")
noSave := flag.Bool("nosave", false, "läs sparfilen men skriv aldrig (tester/skärmdumpar)")
mode := flag.String("mode", "", "navigeringsläge från start: list eller signs (tomt = sparfilens)")
ending := flag.Bool("ending", false, "öppna slutscenen direkt (tester/skärmdumpar)")
version := flag.Bool("version", false, "skriv ut version och avsluta")
flag.Parse()
@@ -37,7 +38,7 @@ func main() {
return
}
g, err := game.New(game.Options{LevelID: *levelID, SmokeFrames: *smoke, Script: *script, KnownWords: splitWords(*know), Points: *points, Seed: *seed, Phone: *phone, CardYen: *card, Debuffs: splitWords(*debuffs), Mute: *mute, SavePath: *savePath, NoSave: *noSave, Mode: *mode})
g, err := game.New(game.Options{LevelID: *levelID, SmokeFrames: *smoke, Script: *script, KnownWords: splitWords(*know), Points: *points, Seed: *seed, Phone: *phone, CardYen: *card, Debuffs: splitWords(*debuffs), Mute: *mute, SavePath: *savePath, NoSave: *noSave, Mode: *mode, Ending: *ending})
if err != nil {
log.Fatalf("kunde inte starta: %v", err)
}

BIN
doc/img/credits.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
doc/img/ending-summary.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
doc/img/ending.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

BIN
doc/img/transfer.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -34,7 +34,7 @@ att bygga på Linux som på Windows.
| Inkrementellt debug-bygge | < 10 s | 4,7 s |
| Release-binär | < 20 MB | 10,2 MB (Linux), 10,8 MB (Windows) |
| Testsviten | `go test ./...` grön på < 30 s | ~0,1 s |
| Banor | 100 % passerar det automatiska lösbarhetstestet | 7 av 7 |
| Banor | 100 % passerar det automatiska lösbarhetstestet | 11 av 11 (inkl. 4 delbanor) |
| Windows-korsbygge från Linux | fungerar utan extra verktyg | ✓ (CGO_ENABLED=0) |
| Linux-binärens länkade bibliotek | bara libc | ✓ (GL/X11 laddas dynamiskt vid körning) |
| Bildfrekvens | 60 fps vid 4× skala på integrerad grafik | ej mätt |
@@ -131,6 +131,8 @@ att bygga på Linux som på Windows.
| `internal/level` | `TestAnnotateWithNetwork`, `TestPlatformTilesAndShippedVerdicts` | **M9:** `correct` räknas ut från nätet, felaktiga kopplingar ger fel, okopplade banor orörda; perrongtecknen `P p Q q R r`; 005/006 får väntade bedömningar |
| `internal/sim` | `TestNetworkRideUsesRealStops`, `TestWrongDirectionLoses`, `TestTooFarLoses`, `TestSignsModeBonus`, `TestTerminalLines` | **M9:** resan visar linjens riktiga stationer med resmålet på rätt plats; fel håll och för långt bort förlorar med rätt orsak; skyltläget ger +50 %; terminalen listar linjer med grannstationer |
| `internal/lang` | `TestNetworkNamesInDictionary` | **M9:** alla stations- och linjenamn i nätet finns i ordboken |
| `internal/level` | `TestStoryChains` | **M10:** `next`-kedjor validerade (saknad/fel sort/dubbel/cykel/osammanhängande byte ger fel), `ChainOf`/`FinalOf`, storyn börjar och slutar på flygplatsen |
| `internal/sim` | `TestJourneyWithTransfer`, `TestJourneyWrongTrainOnSecondLeg`, `TestChainLevelsLoad` | **M10:** bytet fortsätter omgången på nästa station med klocka, plånbok, sällskap och par-summa; vinst först på sista delbanan; fel håll på bytesstationen förlorar; delbanor laddas enskilt |
| `internal/minigame` | `TestCarPickAlightingAndChoice`, `TestCrowdLevelNarrowsZone`, `TestRideCrowdLetPass` | Folk av först (för tidigt räknas), seedad trängsel med minst en lugn vagn, val; trängsel smalnar zonen; resan: kortare tid per station, passagerare som vill förbi, blockerade räknas |
| `internal/sim` | `TestNPCsWanderStayOnFloorAndBlockPlayer`, `TestAlightingNPCsSpawnAndLeave` | Folk rör sig på golv, seedat, blockerar spelaren; avstigande spawnar när dörrarna öppnas och lämnar via utgången |
| `internal/sim` | `TestLanesSlowWrongSide`, `TestCarsWomenOnlyEarlyAndCrowd` | Fel sida i filen bromsar; kvinnovagn med man ger grova minus, för tidigt ombord räknas, resan ärver vagnens trängsel, lugn vagn ger bonus |
@@ -152,7 +154,8 @@ att bygga på Linux som på Windows.
| ~~M8 Progression & sparfil~~ ✓ | Round-trip, skillkrav, upplåsningar, 33 %-regeln – finns, se tabellen ovan |
| ~~M9a Flera våningar~~ ✓ | Trappor, vägar över våningar, dimma per våning, gamla banor oförändrade – finns, se tabellen ovan |
| ~~M9 Navigeringslägen~~ ✓ | Nätets bedömningar (rätt/fel håll/för långt/ingen trafik), 1–4 rätta tåg, riktiga stationer på resan, fel-håll- och för-långt-förlust, skyltbonus, terminal – finns, se tabellen ovan |
| M10 Story-innehåll | Alla banor lösbara (redan automatiskt); flerbytesbanor har sammanhängande byten; sista banan slutar på flygplatsen |
| ~~M10 Story-innehåll~~ ✓ | Kedjorna valideras, byten hänger ihop, storyn börjar och slutar på flygplatsen, bytet för med sig tid/plånbok/sällskap – finns, se tabellen ovan; `tools/levelcheck` som manuell kontroll |
| M10b Bussar och vägar | Bussar följer tidtabell och vägrutor; hållplatser bedöms som perronger; överstimulerande UI-reglaget påverkar bara presentationen |
| M11 Oändligt läge & NG+ | Målsekvens deterministisk per seed; NG+ behåller skills men nollställer items |
| M12 Ljud & polish | Alla ljudfiler som refereras finns och går att avkoda; Windows-binär startar (manuellt) |

View File

@@ -521,7 +521,7 @@ på **brasse-linux01-runnern** (label `linux-amd64`), aldrig på Pi5:n.
- **Kvar till senare:** flera tåg i rad (byten) kommer med M10; bussar;
"terminaler" som egna rutor i stället för biljettautomaten.
### M10 – Story-innehåll och flerbytesbanor
### M10 – Story-innehåll och flerbytesbanor ✓ (klar 2026-10-24)
- **Mål:** hela resan från flygplats till flygplats.
- Alla huvudbanor och sidobanor; banor med flera stopp och byten; buss (med
@@ -532,6 +532,46 @@ på **brasse-linux01-runnern** (label `linux-amd64`), aldrig på Pi5:n.
- Överstimulerande UI/banor som extra svårighet.
- **Teknik:** baneditor-stöd behövs troligen här: ett `tools/levelcheck`
som ritar banan som bild och rapporterar onåbara rutor.
- **Så blev det:** **Byten** är kedjor av banfiler: en huvudbana har `next`
som pekar på en delbana (`kind: "leg"`, syns inte på bankartan), som i sin
tur kan ha `next`. När man klivit av rätt tåg fortsätter *samma omgång*
på nästa station: klockan, plånboken, sällskapets mätare och räknarna
följer med, par-tiden adderas, dimma och folk är nya, och en "Byte!"-
banner visas. HUD:en visar resans slutmål "via" delbanans mål och
"1/2". Tidsgräns och missade tåg gäller hela resan (första banans
värden). `level.ValidateChains` kräver att delbanor används exakt en gång,
att inga cykler finns och att nästa delbana börjar där den förra slutade
(nätets stationer). **Två nya resor:** 007 Akihabara → Ueno (Hibiya eller
Yamanote) → Asakusa (Ginza), med fel-håll- och fel-varv-fällor, egna kort
och folk; 008 Tokyo (två våningar) → Nippori (Yamanote eller
Keihin-Tohoku) → Narita flygplats (Keisei, biljetter med sittplatsval) –
storyns final. **Slutet:** när sista huvudbanan klaras första gången
visas hemresan (`EndingScene`): planet lyfter över skymningshimlen medan
sällskapet vinkar, resan summeras (banor, stjärnor, poäng, ord,
souvenirer) och eftertexterna rullar; `Progress.StoryCleared` sparas (M11
låser upp oändligt läge/NG+ på den). `-ending` öppnar scenen direkt.
**`tools/levelcheck`** ritar varje våning som PNG (onåbara rutor röda,
rätt perrong grönkantad, kortaste vägen prickad) och rapporterar
lösbarhet, onåbara rutor, perronger utanför spärrar, tågens bedömning och
resornas kedjor; `go run ./tools/levelcheck [ord…]` → `build/levelcheck/`.
Generatorn fick `platforms` (fler perronger) utöver `floors`/`wide`.
- **Klar när:** kedjorna valideras och storyn börjar/slutar på flygplatsen
(`level.TestStoryChains`), bytet för med sig tid/plånbok/sällskap och
vinner först på sista delbanan (`sim.TestJourneyWithTransfer`), fel tåg på
bytesstationen förlorar (`TestJourneyWrongTrainOnSecondLeg`), delbanor
laddas (`TestChainLevelsLoad`), alla banor passerar levelcheck. ✓
- **Kvar till M10b:** bussar och vägrutor, fler sidobanor, överstimulerande
UI som eget svårighetsreglage.
### M10b – Bussar, vägar och fler sidospår
- **Mål:** resten av M10-listan som inte rymdes: buss med vägrutor och
hållplatser (egen fordonssprite, hållplatsskylt, tidtabell som tågen),
fler sidobanor längs storyn (minst en per stad), och ett
"överstimulerande UI"-reglage (fler lockskyltar, blinkande reklam, falska
avgångstavlor) som kan slås på per bana.
- **Teknik:** vägrutor i banformatet, `Vehicle`-abstraktion i sim som tåg
och buss delar, återanvänd `nav` för busslinjer.
### M11 – Oändligt läge och New Game+

View File

@@ -267,4 +267,7 @@ från skills i skill tree, och från unika skills man får av sidouppdrag.
| "bilder och eventuella språk och tester" (resursmappen) | bilder, språk och *texter* | Se resurslayouten i roadmap |
| "slutstation ligger i fel riktning … eller hamnar för långt bort" (skyltläge) | Linjenätet (`network.json`) bedömer varje tåg: *fel håll* om linjen går till resmålet men tåget åt andra hållet; *för långt bort* om resmålet nås först efter fler än 12 stationer (fel varv på Yamanote-ringen, omvägen via city). Båda förlorar banan – i båda lägena, eftersom fel tåg alltid har förlorat | Gränsen 12 är `nav.MaxStops` och kan ändras |
| "skyltar och terminaler" | Terminalen är biljettautomaten: på nätkopplade banor visar den linjetavlan (linjer vid stationen med grannstationer åt båda hållen), läst genom ens japanska som en skylt | Egna terminalrutor kan komma senare |
| "flera stopp och byten" | En resa är en kedja av banfiler (`next` → delbana med `kind: "leg"`). Samma omgång fortsätter på nästa station med klocka, plånbok och sällskap; resmålet i HUD:en visas "via" bytesstationen. Vilken bytesstation som gäller bestämmer banan – spelaren väljer tåg, inte rutt | Fri ruttplanering (välj själv var man byter) är en möjlig utbyggnad |
| "slutar när man ska tillbaka till flygplatsen" | Sista huvudbanan (008) går Tokyo → Nippori → Narita flygplats med Keisei. När den klaras första gången visas hemresan: planet lyfter, resan summeras, eftertexter | Slutet går att se igen via `-ending` |
| Buss | Uppskjuten till M10b (vägrutor, hållplatser, egen sprite) | |
| Repo-namnet | Arbetsnamnet var "jp-subway-pussel"; Björn döpte Gitea-repot till **metro-puzzle-jp** (2026-10-23), så repo, modul, binär och titel heter så | Spelets visningsnamn "Metro Puzzle JP" kan ändras fritt i `resources/text/*.json` |

View File

@@ -48,6 +48,7 @@ type Assets struct {
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)
Plane *ebiten.Image // flygplanet i slutscenen
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
@@ -93,6 +94,9 @@ func LoadAssets() (*Assets, error) {
if err != nil {
return nil, err
}
if a.Plane, err = loadImage("sprites", "plane.png"); err != nil {
return nil, err
}
for row := 0; row < 4; row++ {
for col := 0; col < 3; col++ {
a.Player[row][col] = player.SubImage(image.Rect(col*TileSize, row*TileSize, (col+1)*TileSize, (row+1)*TileSize)).(*ebiten.Image)

View File

@@ -71,6 +71,7 @@ type Options struct {
Mute bool // inget ljud
SavePath string // sparfil ("" = standardplatsen)
NoSave bool // läs sparfilen men skriv aldrig
Ending bool // öppna slutscenen direkt (tester/skärmdumpar)
Mode string // navigeringsläge från start: "list" eller "signs" (tomt = sparfilens)
}
@@ -137,18 +138,40 @@ func New(opt Options) (*Game, error) {
g.Wallet.Card = opt.CardYen
}
if opt.LevelID != "" {
switch {
case opt.Ending:
g.scene = NewEndingScene(g)
case opt.LevelID != "":
s, err := g.startLevel(opt.LevelID)
if err != nil {
return nil, err
}
g.scene = s
} else {
default:
g.scene = NewMapScene(g)
}
return g, nil
}
// chainAfter är resans fortsättning efter banan id (delbanorna via next).
func (g *Game) chainAfter(id string) []*level.Level {
if c := level.ChainOf(g.Levels, id); len(c) > 1 {
return c[1:]
}
return nil
}
// isLastMain rapporterar om banan är storyns sista huvudbana.
func (g *Game) isLastMain(id string) bool {
var last *level.Level
for _, l := range g.Levels {
if !l.IsSide() && !l.IsLeg() && (last == nil || l.Order > last.Order) {
last = l
}
}
return last != nil && last.ID == id
}
func loadCatalog() (*progress.Catalog, error) {
sk, err := res.ReadFile("data", "skills.json")
if err != nil {
@@ -240,7 +263,7 @@ func (g *Game) runOptions(id string, seed rng.Seed) sim.RunOptions {
return sim.RunOptions{
Dict: g.Assets.Dict, Know: g.Know, Wallet: g.Wallet, Debuffs: g.Profile.Debuffs, Seed: seed,
Extra: eff.Stats, FogRadius: eff.FogRadius, Landmarks: eff.Landmarks, FreeGates: eff.FreeGates,
RevealAll: eff.Reveal[id], InfiniteBattery: eff.Battery, Mode: g.Mode,
RevealAll: eff.Reveal[id], InfiniteBattery: eff.Battery, Mode: g.Mode, Chain: g.chainAfter(id),
}
}
@@ -281,12 +304,13 @@ func (g *Game) startLevel(id string) (*LevelScene, error) {
// LevelOutcome är vad en avslutad bana gav i progression (för resultatskärmen).
type LevelOutcome struct {
XP int
LevelUps int
FirstClear bool
NewItems []string
NewPoints int
Unlocked []string // banor som öppnades
XP int
LevelUps int
FirstClear bool
NewItems []string
NewPoints int
Unlocked []string // banor som öppnades
StoryCleared bool // sista huvudbanan klarades för första gången: visa slutet
}
// levelEnded appliceras exakt en gång när en bana avgjorts: debuffar,
@@ -323,6 +347,10 @@ func (g *Game) levelEnded(run *sim.Run, id string) LevelOutcome {
out.Unlocked = append(out.Unlocked, l.ID)
}
}
if g.isLastMain(id) && !g.Progress.StoryCleared {
g.Progress.StoryCleared = true
out.StoryCleared = true
}
}
g.State.Current = nil
g.Save()

View File

@@ -0,0 +1,178 @@
package game
import (
"fmt"
"image/color"
"math"
"sort"
"strings"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
)
// EndingScene är storyns slut: sällskapet flyger hem, resan summeras och
// eftertexterna rullar. Enter går vidare mellan delarna, Esc hoppar till
// bankartan.
type EndingScene struct {
t float64 // sekunder i aktuell del
phase int // 0 flyget, 1 summeringen, 2 eftertexterna
lines []string
}
const (
endingFlightSec = 7.0 // flyget innan summeringen kommer av sig själv
endingCreditsPxS = 14.0 // eftertexternas rullhastighet
)
// NewEndingScene startar hemresan.
func NewEndingScene(g *Game) *EndingScene {
return &EndingScene{lines: strings.Split(g.Assets.T("ending.credits"), "\n")}
}
func (e *EndingScene) Update(g *Game) error {
e.t += dt
switch {
case backJustPressed():
g.SetScene(NewMapScene(g))
return nil
case confirmJustPressed():
e.phase++
e.t = 0
}
if e.phase == 0 && e.t > endingFlightSec {
e.phase, e.t = 1, 0
}
if e.phase == 2 && e.t*endingCreditsPxS > float64(len(e.lines)*LineH+ScreenH) {
e.phase++ // eftertexterna har rullat förbi
}
if e.phase > 2 {
g.SetScene(NewMapScene(g))
}
return nil
}
func (e *EndingScene) Draw(g *Game, screen *ebiten.Image) {
a := g.Assets
e.drawSky(screen)
switch e.phase {
case 0:
e.drawFlight(g, screen)
a.DrawBig(screen, a.T("ending.title"), 24, 20, colText)
a.Draw(screen, a.T("ending.sub"), 24, 44, colDim)
case 1:
e.drawSummary(g, screen)
default:
e.drawCredits(g, screen)
}
a.Draw(screen, a.T("ending.hint"), 8, ScreenH-12, colDim)
}
// drawSky är en skymningshimmel i band, från mörkblått upptill till orange
// vid horisonten, med långsamt drivande moln.
func (e *EndingScene) drawSky(screen *ebiten.Image) {
top, bottom := [3]float64{30, 32, 80}, [3]float64{230, 120, 70}
const bands = 12
for i := 0; i < bands; i++ {
f := float64(i) / float64(bands-1)
col := color.RGBA{uint8(top[0] + (bottom[0]-top[0])*f), uint8(top[1] + (bottom[1]-top[1])*f), uint8(top[2] + (bottom[2]-top[2])*f), 255}
vector.DrawFilledRect(screen, 0, float32(i*ScreenH/bands), ScreenW, float32(ScreenH/bands+1), col, false)
}
cloud := color.RGBA{250, 220, 200, 120}
for i := 0; i < 5; i++ {
x := math.Mod(float64(i)*73+e.t*(4+float64(i)), ScreenW+60) - 30
y := float64(40 + i*22)
vector.DrawFilledRect(screen, float32(x), float32(y), 28, 6, cloud, false)
vector.DrawFilledRect(screen, float32(x+6), float32(y-4), 14, 5, cloud, false)
}
vector.DrawFilledRect(screen, 0, ScreenH-20, ScreenW, 20, color.RGBA{24, 20, 37, 255}, false) // marken
}
// drawFlight: planet lyfter åt höger medan sällskapet vinkar på marken.
func (e *EndingScene) drawFlight(g *Game, screen *ebiten.Image) {
a := g.Assets
f := min(1, e.t/endingFlightSec)
px := -40 + f*(ScreenW+80)
py := 120 - f*80 + 3*math.Sin(e.t*2)
var op ebiten.DrawImageOptions
op.GeoM.Scale(2, 2)
op.GeoM.Translate(px, py)
screen.DrawImage(a.Plane, &op)
for i := range memberTints {
var o ebiten.DrawImageOptions
t := memberTints[i]
o.ColorScale.Scale(t[0], t[1], t[2], 1)
o.GeoM.Translate(float64(ScreenW/2-50+i*20), ScreenH-20-TileSize+math.Abs(2*math.Sin(e.t*3+float64(i))))
screen.DrawImage(a.PlayerFrame(sim.Up, true, e.t+float64(i)*0.3), &o)
}
}
// drawSummary är resan i siffror: banor, stjärnor, poäng, ord och souvenirer.
func (e *EndingScene) drawSummary(g *Game, screen *ebiten.Image) {
a := g.Assets
p := g.Progress
vector.DrawFilledRect(screen, 16, 14, ScreenW-32, ScreenH-40, colPanel, false)
vector.DrawFilledRect(screen, 16, 14, ScreenW-32, 2, colGood, false)
a.DrawBig(screen, a.T("ending.summary"), 24, 20, colGood)
levels, cleared, score := 0, 0, 0
for _, l := range g.Levels {
if l.IsLeg() {
continue
}
levels++
if r := p.Levels[l.ID]; r != nil && r.Cleared {
cleared++
score += r.Score
}
}
y := 44
rows := []string{
fmt.Sprintf("%s: %d/%d", a.T("ending.levels"), cleared, levels),
fmt.Sprintf("%s: %d/%d", a.T("ending.stars"), p.TotalStars(), 3*levels),
fmt.Sprintf("%s: %d", a.T("ending.score"), score),
fmt.Sprintf("%s: %d", a.T("ending.words"), g.Know.Count()),
}
for _, r := range rows {
a.Draw(screen, r, 24, y, colText)
y += LineH
}
var items []string
for id := range p.Items {
if it := g.Catalog.Item(id); it != nil {
items = append(items, it.Name.Get(UILang))
}
}
sort.Strings(items)
souvenirs := a.T("ending.none")
if len(items) > 0 {
souvenirs = strings.Join(items, ", ")
}
for _, l := range a.wrapLine(plainLine(a.T("ending.items")+": "+souvenirs, colKnown), ScreenW-48) {
a.drawLines(screen, []line{l}, 24, y)
y += LineH
}
y += 4
for _, l := range a.wrapLine(plainLine(a.T("ending.story_done"), colWarn), ScreenW-48) {
a.drawLines(screen, []line{l}, 24, y)
y += LineH
}
}
// drawCredits rullar eftertexterna nerifrån och upp.
func (e *EndingScene) drawCredits(g *Game, screen *ebiten.Image) {
a := g.Assets
y0 := ScreenH - int(e.t*endingCreditsPxS)
for i, l := range e.lines {
y := y0 + i*LineH
if y < -LineH || y > ScreenH {
continue
}
col := colDim
if i == 0 || (i > 0 && e.lines[i-1] == "" && i+1 < len(e.lines) && e.lines[i+1] != "") {
col = colText // rubriker
}
a.Draw(screen, l, (ScreenW-a.Width(l))/2, y, col)
}
}

View File

@@ -26,12 +26,20 @@ const (
learnedShowSec = 3.0
progressedShowSec = 2.5
flashShowSec = 2.5
transferBannerSec = 3.0
)
// Mätarnas bredd och mellanrum uppe till höger (per medlem).
const (
meterW = 11
meterGap = 3
)
// LevelScene är själva spelandet av en bana. All spellogik ligger i sim.Run;
// scenen översätter input och ritar.
type LevelScene struct {
id string
level *level.Level // aktuell delbana (byts när resan fortsätter på nästa station)
run *sim.Run
cam *sim.Camera
animT float64
@@ -58,14 +66,21 @@ func NewLevelScene(g *Game, id string, seed rng.Seed) (*LevelScene, error) {
if err != nil {
return nil, err
}
s := &LevelScene{
id: id,
run: sim.NewRun(lvl, g.runOptions(id, seed)),
cam: sim.NewCamera(ScreenW, ScreenH, float64(lvl.Width()*TileSize), float64(lvl.Height()*TileSize)),
}
s := &LevelScene{id: id, run: sim.NewRun(lvl, g.runOptions(id, seed))}
s.rng = rand.New(rand.NewPCG(1, 2))
s.rebind(g)
return s, nil
}
// rebind bygger om allt som hör till den aktuella delbanan: kamera, räls,
// affischer, melodifaser och bakgrundsprat. Körs vid start och vid byte.
func (s *LevelScene) rebind(g *Game) {
lvl := s.run.Level
s.level = lvl
s.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.particles = nil
s.buildTracks()
s.buildPosters()
g.startChatter(lvl.Noise)
@@ -73,7 +88,6 @@ func NewLevelScene(g *Game, id string, seed rng.Seed) (*LevelScene, error) {
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
@@ -145,6 +159,9 @@ func (s *LevelScene) Update(g *Game) error {
// Minispel: all input går dit; Esc avbryter minispelet, inte banan.
mx, my := miniMoveXY()
s.run.StepMini(dt, minigame.Input{Move: my, MoveX: mx, Confirm: confirmJustPressed(), Hold: confirmHeld(), Cancel: backJustPressed()})
if s.run.Level != s.level {
s.rebind(g) // bytet: resan fortsätter på nästa station
}
if s.run.Outcome != sim.Ongoing {
s.outcome = g.levelEnded(s.run, s.id)
g.SetScene(NewResultScene(s))
@@ -299,6 +316,14 @@ func (s *LevelScene) Draw(g *Game, screen *ebiten.Image) {
s.drawIndicator(screen, ox, oy)
s.drawHUD(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")
w := g.Assets.Width(txt) + 16
x := (ScreenW - w) / 2
vector.DrawFilledRect(screen, float32(x), 60, float32(w), LineH+8, colPanel, false)
vector.DrawFilledRect(screen, float32(x), 60, float32(w), 2, colWarn, false)
g.Assets.Draw(screen, txt, x+8, 64, colWarn)
}
if s.run.Mini != nil {
s.drawMinigame(g, screen)
}
@@ -346,27 +371,41 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
a := g.Assets
vector.DrawFilledRect(screen, 0, 0, ScreenW, 24, color.RGBA{24, 20, 37, 200}, false)
dest := r.Level.Destination.Get("romaji")
if r.Know.ShowsDestinationJP() { // hint från nivå 1: resmålet på japanska, att matcha mot skyltarna
dest := r.FinalDestination().Get("romaji")
if !r.IsLastLeg() { // resa med byte: den här delbanan slutar vid bytesstationen
dest += " " + a.T("hud.via") + " " + r.Level.Destination.Get("romaji")
}
if r.Know.ShowsDestinationJP() { // hint från nivå 1: delbanans mål på japanska, att matcha mot skyltarna
dest += " " + r.Level.Destination.Get("ja")
}
name := r.Level.Name.Get(UILang)
if r.Level.NumFloors() > 1 {
name += " " + r.Level.FloorName(r.Player.Tile.Z)
}
if r.Legs() > 1 {
name += fmt.Sprintf(" %d/%d", r.LegNo(), r.Legs())
}
a.Draw(screen, name, 4, 0, colText)
destX := 4 + a.Width(name) + 12
a.Draw(screen, a.T("hud.destination")+": "+dest, destX, 0, colKnown)
a.Draw(screen, fmt.Sprintf("%s Lv%d·%d", a.T("hud.japanese"), r.Know.Level(), r.Know.Count()),
destX+a.Width(a.T("hud.destination")+": "+dest)+12, 0, colDim)
// Japanska-nivån får plats till vänster om mätarna; annars kortas den.
jpX := destX + a.Width(a.T("hud.destination")+": "+dest) + 12
metersX := ScreenW - 4 - len(r.Party.Members)*(meterW+meterGap)
jp := fmt.Sprintf("%s Lv%d·%d", a.T("hud.japanese"), r.Know.Level(), r.Know.Count())
if jpX+a.Width(jp) > metersX-4 {
jp = fmt.Sprintf("Lv%d", r.Know.Level())
}
if jpX+a.Width(jp) <= metersX-4 {
a.Draw(screen, jp, jpX, 0, colDim)
}
timeStr := fmt.Sprintf("%s %3.0fs", a.T("hud.time"), r.Time)
if r.Level.TimeLimitSec > 0 {
if r.TimeLimit() > 0 {
left := r.TimeLeft()
timeStr = fmt.Sprintf("%s %d:%02d", a.T("hud.time_left"), int(left)/60, int(left)%60)
}
wallet := fmt.Sprintf("%s %s %d¥ %s %d¥", timeStr, a.T("hud.card"), r.Wallet.Card, a.T("hud.cash_short"), r.Wallet.Cash)
if r.Level.MaxMissedTrains > 0 {
wallet += fmt.Sprintf(" %s %d/%d", a.T("hud.missed_short"), r.MissedTrains, r.Level.MaxMissedTrains)
if r.MaxMissed() > 0 {
wallet += fmt.Sprintf(" %s %d/%d", a.T("hud.missed_short"), r.MissedTrains, r.MaxMissed())
}
if r.Wallet.Phone {
wallet += fmt.Sprintf(" %s %.0f%%", a.T("hud.phone"), r.Wallet.Battery)
@@ -375,7 +414,7 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
wallet += " " + a.T("hud.ticket") + " " + r.Wallet.Ticket
}
walletCol := colDim
if r.Level.TimeLimitSec > 0 && r.TimeLeft() < 30 {
if r.TimeLimit() > 0 && r.TimeLeft() < 30 {
walletCol = colBad
}
a.Draw(screen, wallet, 4, LineH, walletCol)
@@ -385,9 +424,6 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
return // resultatpanelen, minispelet eller butiken tar över; ingen inforuta eller minikarta
}
lines, accent, bar := s.infoLines(a)
if len(lines) == 0 {
s.drawModeHint(a, screen)
}
if entry := a.Dict.Lookup(r.Learned); entry != nil && r.Time-r.LearnedAt < learnedShowSec {
lines = append(lines, plainLine(fmt.Sprintf("%s: %s = %s (%s)", a.T("hud.learned"), r.Learned,
entry.Translation(UILang), entry.Romaji), colKnown))
@@ -402,7 +438,7 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
}
lines = append(lines, a.wrapLine(plainLine(strings.Join(parts, " "), colWarn), ScreenW-8)...)
}
if r.Flashing(flashShowSec) {
if r.Flashing(flashShowSec) && r.FlashKey != sim.FlashTransfer { // bytet har sin egen banner
col := colBad
if r.FlashKey == sim.FlashRescan {
col = colWarn
@@ -426,6 +462,9 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
// (eller tåget under hen) annars skulle hamna bakom den.
var box image.Rectangle
hasBox := len(lines) > 0
if !hasBox {
s.drawModeHint(a, screen) // lägesrutan bara när ingen inforuta visas
}
if hasBox {
h := 8 + LineH*len(lines)
if bar {
@@ -579,7 +618,7 @@ func (s *LevelScene) infoLines(a *Assets) (lines []line, accent color.Color, bar
// drawMeters ritar sällskapets mat/dryck/toa-mätare uppe till höger.
func (s *LevelScene) drawMeters(g *Game, screen *ebiten.Image) {
members := s.run.Party.Members
const w, gap = 11, 3
const w, gap = meterW, meterGap
x0 := ScreenW - 4 - len(members)*(w+gap)
for i, m := range members {
x := float32(x0 + i*(w+gap))

View File

@@ -33,9 +33,11 @@ type mapNode struct {
func NewMapScene(g *Game) *MapScene {
m := &MapScene{sides: map[string][]*level.Level{}}
for _, l := range g.Levels {
if l.IsSide() {
switch {
case l.IsLeg(): // delbanor nås via sin resa
case l.IsSide():
m.sides[l.Parent] = append(m.sides[l.Parent], l)
} else {
default:
m.mains = append(m.mains, l)
}
}
@@ -192,8 +194,12 @@ func (m *MapScene) Draw(g *Game, screen *ebiten.Image) {
name = a.T("map.side") + ": " + name
}
a.Draw(screen, name, 8, 134, colText)
info := fmt.Sprintf("→ %s %s %s %s %d", cur.Destination.Get("ja"), cur.Destination.Get("romaji"),
fd := level.FinalOf(g.Levels, cur).Destination
info := fmt.Sprintf("→ %s %s %s %s %d", fd.Get("ja"), fd.Get("romaji"),
strings.Repeat("★", cur.Difficulty)+strings.Repeat("☆", 5-cur.Difficulty), a.T("map.party"), cur.Party)
if n := len(level.ChainOf(g.Levels, cur.ID)); n > 1 {
info += fmt.Sprintf(" %s %d", a.T("map.transfers"), n-1)
}
if g.State.Current != nil && g.State.Current.LevelID == cur.ID {
info += " " + a.T("map.resume")
}

View File

@@ -29,6 +29,10 @@ func (r *ResultScene) Update(g *Game) error {
}
g.SetScene(s)
case confirmJustPressed() || backJustPressed():
if r.from.outcome.StoryCleared {
g.SetScene(NewEndingScene(g)) // storyn klar: hemresan
return nil
}
g.SetScene(NewMapScene(g))
}
return nil

View File

@@ -248,6 +248,7 @@ type Level struct {
Kind string `json:"kind"` // "main" (standard) eller "side"
Order int `json:"order"` // huvudbanornas ordning på bankartan (1, 2, …); sidobanor ärver förälderns
Parent string `json:"parent"` // sidobanans huvudbana – låses upp när den är klarad
Next string `json:"next"` // nästa delbana (kind "leg") i en resa med byten: när man klivit av här fortsätter banan där
Rewards Rewards `json:"rewards"`
Shop []string `json:"shop"` // items som kiosken säljer på banan (tom = seedat urval)
Crowd int `json:"crowd"` // folk på stationen 0–3 (NPC:er)
@@ -381,9 +382,9 @@ func (l *Level) Validate() error {
switch l.Kind {
case "":
l.Kind = "main"
case "main", "side":
case "main", "side", "leg":
default:
return fmt.Errorf("level %s: kind måste vara main eller side", l.ID)
return fmt.Errorf("level %s: kind måste vara main, side eller leg", l.ID)
}
if l.Kind == "side" && l.Parent == "" {
return fmt.Errorf("level %s: sidobana utan parent", l.ID)
@@ -782,6 +783,81 @@ func (l *Level) Annotate(net *nav.Network) error {
// IsSide rapporterar om banan är en sidobana.
func (l *Level) IsSide() bool { return l.Kind == "side" }
// IsLeg rapporterar om banan är en delbana i en resa med byten (nås bara
// via en annan banas next, visas inte på bankartan).
func (l *Level) IsLeg() bool { return l.Kind == "leg" }
// ChainOf ger resan som börjar med banan id: banan själv följd av dess
// delbanor i ordning. nil om id är okänt.
func ChainOf(all []*Level, id string) []*Level {
byID := map[string]*Level{}
for _, l := range all {
byID[l.ID] = l
}
var out []*Level
for l := byID[id]; l != nil && len(out) <= len(all); l = byID[l.Next] {
out = append(out, l)
if l.Next == "" {
break
}
}
return out
}
// FinalOf är resans sista delbana (banan själv utan byten) – dess
// destination är resans resmål.
func FinalOf(all []*Level, l *Level) *Level {
if c := ChainOf(all, l.ID); len(c) > 0 {
return c[len(c)-1]
}
return l
}
// ValidateChains kontrollerar resorna med byten: next finns och är en
// delbana, varje delbana används exakt en gång, inga cykler, och på
// nätkopplade banor börjar nästa delbana där den förra slutade.
func ValidateChains(all []*Level) error {
byID := map[string]*Level{}
for _, l := range all {
byID[l.ID] = l
}
refs := map[string]int{}
for _, l := range all {
if l.Next == "" {
continue
}
n := byID[l.Next]
if n == nil {
return fmt.Errorf("level %s: next %q finns inte", l.ID, l.Next)
}
if !n.IsLeg() {
return fmt.Errorf("level %s: next %q måste ha kind leg", l.ID, l.Next)
}
refs[n.ID]++
if l.Networked() && n.Networked() && n.Station != l.To {
return fmt.Errorf("level %s: bytet hänger inte ihop – slutar i %q men %s börjar i %q", l.ID, l.To, n.ID, n.Station)
}
}
for _, l := range all {
if l.IsLeg() && refs[l.ID] != 1 {
return fmt.Errorf("level %s: delbanan refereras av %d banor, ska vara exakt 1", l.ID, refs[l.ID])
}
}
for _, l := range all {
if l.IsLeg() {
continue
}
seen := map[string]bool{}
for c := l; c != nil && c.Next != ""; c = byID[c.Next] {
if seen[c.ID] {
return fmt.Errorf("level %s: resan går i cirkel", l.ID)
}
seen[c.ID] = true
}
}
return nil
}
// LoadAll läser alla banor i en mapp, sorterade på id, och kopplar dem som
// pekar på linjenätet till ../network.json (läses en gång).
func LoadAll(dir string) ([]*Level, error) {
@@ -810,6 +886,9 @@ func LoadAll(dir string) ([]*Level, error) {
}
out = append(out, l)
}
if err := ValidateChains(out); err != nil {
return nil, err
}
return out, nil
}

View File

@@ -665,3 +665,70 @@ func TestCarsWomenOnlyAndCrowdFields(t *testing.T) {
t.Fatal("filer")
}
}
// TestStoryChains: resorna med byten hänger ihop, storyn börjar och slutar på
// flygplatsen, och felaktiga kedjor ger fel.
func TestStoryChains(t *testing.T) {
dir, err := res.Path("data", "levels")
if err != nil {
t.Fatal(err)
}
all, err := LoadAll(dir) // kör ValidateChains
if err != nil {
t.Fatal(err)
}
var first, last *Level
legs, chains := 0, 0
for _, l := range all {
switch {
case l.IsLeg():
legs++
if l.Order != 0 {
t.Errorf("%s: delbanor har ingen order", l.ID)
}
case !l.IsSide():
if first == nil || l.Order < first.Order {
first = l
}
if last == nil || l.Order > last.Order {
last = l
}
}
if l.Next != "" {
chains++
c := ChainOf(all, l.ID)
if len(c) < 2 || c[0] != l || FinalOf(all, l) != c[len(c)-1] {
t.Errorf("%s: ChainOf/FinalOf = %v", l.ID, c)
}
}
}
if legs == 0 || chains == 0 {
t.Fatal("storyn ska ha resor med byten")
}
if first == nil || first.Station != "narita-airport" {
t.Errorf("storyn ska börja på flygplatsen, första banan är %v", first)
}
if last == nil || FinalOf(all, last).To != "narita-airport" {
t.Errorf("storyn ska sluta på flygplatsen, sista banan slutar i %q", FinalOf(all, last).To)
}
mk := func(id, kind, next, station, to string) *Level {
return &Level{ID: id, Kind: kind, Next: next, Station: station, To: to}
}
bad := map[string][]*Level{
"saknad next": {mk("a", "main", "zzz", "", "")},
"next inte leg": {mk("a", "main", "b", "", ""), mk("b", "main", "", "", "")},
"oanvänd delbana": {mk("a", "main", "", "", ""), mk("b", "leg", "", "", "")},
"delbana två gånger": {mk("a", "main", "b", "", ""), mk("c", "main", "b", "", ""), mk("b", "leg", "", "", "")},
"cirkel": {mk("a", "main", "b", "", ""), mk("b", "leg", "c", "", ""), mk("c", "leg", "b", "", "")},
"byte hänger inte": {mk("a", "main", "b", "x", "y"), mk("b", "leg", "", "z", "w")},
}
for name, lv := range bad {
if err := ValidateChains(lv); err == nil {
t.Errorf("%s: skulle ha gett fel", name)
}
}
if err := ValidateChains([]*Level{mk("a", "main", "b", "x", "y"), mk("b", "leg", "", "y", "w")}); err != nil {
t.Errorf("giltig kedja: %v", err)
}
}

View File

@@ -19,12 +19,13 @@ type LevelResult struct {
// Progress är det som växer mellan banor.
type Progress struct {
XP int `json:"xp"`
SkillPoints int `json:"skillPoints"`
Skills map[string]bool `json:"skills"`
Items map[string]bool `json:"items"`
Equipped map[string]string `json:"equipped"` // slot -> item-id
Levels map[string]*LevelResult `json:"levels"`
XP int `json:"xp"`
SkillPoints int `json:"skillPoints"`
Skills map[string]bool `json:"skills"`
Items map[string]bool `json:"items"`
Equipped map[string]string `json:"equipped"` // slot -> item-id
Levels map[string]*LevelResult `json:"levels"`
StoryCleared bool `json:"storyCleared"` // sista huvudbanan klarad: slutet har visats, oändligt läge/NG+ (M11) låses upp
}
// New börjar från noll.
@@ -235,8 +236,8 @@ func (p *Progress) Unlocked(all []*level.Level, id string) bool {
me = l
}
}
if me == nil {
return false
if me == nil || me.IsLeg() {
return false // delbanor nås bara via sin resa
}
if me.IsSide() {
return p.Cleared(me.Parent)

101
internal/sim/legs_test.go Normal file
View File

@@ -0,0 +1,101 @@
package sim
import (
"path/filepath"
"testing"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
)
// loadChain laddar alla banor och ger resan som börjar med id.
func loadChain(t *testing.T, id string) []*level.Level {
t.Helper()
dir, err := res.Path("data", "levels")
if err != nil {
t.Fatal(err)
}
all, err := level.LoadAll(dir)
if err != nil {
t.Fatal(err)
}
c := level.ChainOf(all, id)
if len(c) < 2 {
t.Fatalf("%s är ingen resa med byten: %v", id, c)
}
return c
}
// TestJourneyWithTransfer: bana 007 fortsätter på Ueno efter första tåget –
// klockan, plånboken och sällskapet följer med, par-tiden adderas, och
// resan vinns först när andra tåget nått Asakusa.
func TestJourneyWithTransfer(t *testing.T) {
c := loadChain(t, "007-akihabara")
head, leg := c[0], c[1]
r := NewRun(head, RunOptions{Seed: rng.FromInt(7), Chain: c[1:]})
if r.Legs() != 2 || r.LegNo() != 1 || r.IsLastLeg() || r.FinalDestination().Get("ja") != "浅草" || r.TimeLimit() != head.TimeLimitSec {
t.Fatalf("resans start: legs=%d no=%d final=%s limit=%d", r.Legs(), r.LegNo(), r.FinalDestination().Get("ja"), r.TimeLimit())
}
par1 := r.ParSec()
for _, m := range r.Party.Members {
m.Card = 100000 // egna kort med gott om saldo: testet handlar om bytet
}
r.Wallet.Card = 100000
tr := boardAt(t, r, level.Platform)
ride(t, r)
if r.Outcome != Ongoing || r.Level != leg || r.LegNo() != 2 || !r.IsLastLeg() {
t.Fatalf("efter första tåget: outcome=%v level=%s legNo=%d", r.Outcome, r.Level.ID, r.LegNo())
}
if r.TransferAt < 0 || r.FlashKey != FlashTransfer || r.Player.Tile != leg.SpawnPoint() || len(r.Rides) != 1 || r.Rides[0] != tr {
t.Fatalf("bytet: transferAt=%.1f flash=%q tile=%v rides=%d", r.TransferAt, r.FlashKey, r.Player.Tile, len(r.Rides))
}
if r.Time < 10 || r.ParSec() <= par1 || r.Fog.Explored(leg) >= 0.5 {
t.Fatalf("klockan ska fortsätta (%.1f), par adderas (%.0f > %.0f) och dimman är ny (%.2f)", r.Time, r.ParSec(), par1, r.Fog.Explored(leg))
}
if from, to := r.Party.FollowerTiles(1); from != leg.SpawnPoint() || to != leg.SpawnPoint() {
t.Fatalf("sällskapet ska stå på nya starten: %v %v", from, to)
}
boardAt(t, r, level.Platform)
ride(t, r)
if r.Outcome != Won || len(r.Rides) != 2 || r.Score.Total <= 0 {
t.Fatalf("resan ska vinnas efter andra tåget: outcome=%v (%s) rides=%d", r.Outcome, r.LostReason, len(r.Rides))
}
if r.Score.Explore <= 0 || r.Score.Explore > ScoreExploreMax {
t.Fatalf("utforskat är snittet över delbanorna: %d", r.Score.Explore)
}
}
// TestJourneyWrongTrainOnSecondLeg: fel tåg på bytesstationen förlorar resan.
func TestJourneyWrongTrainOnSecondLeg(t *testing.T) {
c := loadChain(t, "007-akihabara")
r := NewRun(c[0], RunOptions{Seed: rng.FromInt(8), Chain: c[1:]})
for _, m := range r.Party.Members {
m.Card = 100000
}
r.Wallet.Card = 100000
boardAt(t, r, level.Platform2) // Yamanote mot Ueno – också rätt
ride(t, r)
if r.Outcome != Ongoing || r.LegNo() != 2 {
t.Fatalf("första tåget: %v", r.Outcome)
}
boardAt(t, r, level.WrongPlatform) // Ginza mot Shibuya: fel håll
ride(t, r)
if r.Outcome != Lost || r.LostReason != LostWrongDirection {
t.Fatalf("outcome=%v reason=%q", r.Outcome, r.LostReason)
}
}
// TestChainLevelsLoad: alla delbanor går att läsa en och en också.
func TestChainLevelsLoad(t *testing.T) {
dir, err := res.Path("data", "levels")
if err != nil {
t.Fatal(err)
}
for _, id := range []string{"007b-ueno", "008b-nippori"} {
l, err := level.LoadFile(filepath.Join(dir, id+".json"))
if err != nil || !l.IsLeg() {
t.Fatalf("%s: %v", id, err)
}
}
}

View File

@@ -105,6 +105,9 @@ func NewParty(n int, start level.Point, seed rng.Seed) *Party {
return p
}
// Reset ställer hela sällskapet på en ny ruta (byte till nästa delbana).
func (p *Party) Reset(at level.Point) { p.trail = []level.Point{at} }
// Size är antalet i sällskapet inkl. spelaren.
func (p *Party) Size() int { return len(p.Members) }

View File

@@ -35,6 +35,18 @@ type Run struct {
Boarded *level.Train
Mode nav.Mode // navigeringsläge: listan visar nästa tåg, skyltläget ger bonus
// Resor med byten: Level är aktuell delbana; tiden, plånboken och
// sällskapet följer med mellan delbanorna.
LegIndex int // 0 = första delbanan
TransferAt float64 // när senaste bytet skedde (< 0 = inget)
Rides []*level.Train // tågen man åkt med hittills
calmCars int // resor i en lugn vagn (bonus)
crowdedRide bool // någon resa i en full vagn (svettig-risk)
exploreSum float64 // utforskad andel i avklarade delbanor
legsDone int
timeLimit int // resans tidsgräns (första delbanans), 0 = ingen
maxMissed int // resans gräns för missade tåg (första delbanans)
// Språk: ordboken, spelarens kunskap (delas mellan banor) och hjälpen.
Dict *lang.Dictionary
Know *lang.Knowledge
@@ -116,6 +128,7 @@ const (
// Fler flash-nycklar.
const (
FlashWomenOnly = "hud.women_only"
FlashTransfer = "hud.transfer_done"
FlashEarly = "hud.early_board"
FlashBump = "hud.bump"
)
@@ -213,6 +226,8 @@ type RunOptions struct {
InfiniteBattery bool // telefonen laddar inte ur
Mode nav.Mode // navigeringsläge (nollvärdet = listläge)
Chain []*level.Level // resans fortsättning: delbanorna efter den första (byten), i ordning
}
// DefaultSpeed är spelarens grundfart i rutor per sekund.
@@ -261,7 +276,10 @@ func NewRun(l *level.Level, o RunOptions) *Run {
Visits: map[level.Point]int{},
lastTile: l.SpawnPoint(),
ProgressedAt: -1,
parSec: float64(l.ParSec),
parSec: parFor(l),
timeLimit: l.TimeLimitSec,
maxMissed: l.MaxMissedTrains,
TransferAt: -1,
Party: NewParty(l.Party, l.SpawnPoint(), o.Seed.Derive("party")),
baseSpeed: o.Speed,
NoticeAt: -1,
@@ -288,14 +306,91 @@ func NewRun(l *level.Level, o RunOptions) *Run {
r.Fog.RevealAll(l)
}
r.Fog.Reveal(l, l.SpawnPoint())
if r.parSec <= 0 {
n := len(l.Path(l.SpawnPoint(), l.CorrectPlatform(), l.CanEnter))
r.parSec = float64(max(0, n-1))*ParSecPerTile + ParSlackSec
}
r.Mode = o.Mode
return r
}
// parFor är "bra tid" för en delbana: parSec i bandatan, annars kortaste
// vägen i rutor × ParSecPerTile + ParSlackSec.
func parFor(l *level.Level) float64 {
if l.ParSec > 0 {
return float64(l.ParSec)
}
n := len(l.Path(l.SpawnPoint(), l.CorrectPlatform(), l.CanEnter))
return float64(max(0, n-1))*ParSecPerTile + ParSlackSec
}
// Legs är antalet delbanor i resan, LegNo den aktuella (1-baserad).
func (r *Run) Legs() int { return 1 + len(r.opts.Chain) }
func (r *Run) LegNo() int { return r.LegIndex + 1 }
// IsLastLeg rapporterar om det inte blir fler byten.
func (r *Run) IsLastLeg() bool { return r.LegIndex >= len(r.opts.Chain) }
// FinalDestination är resans slutmål (sista delbanans destination).
func (r *Run) FinalDestination() level.Text {
if n := len(r.opts.Chain); n > 0 {
return r.opts.Chain[n-1].Destination
}
return r.Level.Destination
}
// TimeLimit och MaxMissed är resans gränser (0 = ingen).
func (r *Run) TimeLimit() int { return r.timeLimit }
func (r *Run) MaxMissed() int { return r.maxMissed }
// nextLeg är nästa delbana, eller nil på den sista.
func (r *Run) nextLeg() *level.Level {
if r.IsLastLeg() {
return nil
}
return r.opts.Chain[r.LegIndex]
}
// startLeg byter till nästa delbana: ny station, spelaren och sällskapet på
// dess start, ny dimma och nytt folk – men klockan, plånboken, mätarna och
// räknarna fortsätter. Par-tiden adderas.
func (r *Run) startLeg(l *level.Level) {
r.exploreSum += r.Fog.Explored(r.Level)
r.legsDone++
r.LegIndex++
r.Level = l
spawn := l.SpawnPoint()
r.Player = NewMover(spawn, r.baseSpeed)
r.Party.Reset(spawn)
r.Fog = NewFog(l, FogRadius+r.opts.FogRadius)
r.Fog.ExtraLandmarks = r.opts.Landmarks
if r.opts.RevealAll {
r.Fog.RevealAll(l)
}
r.Fog.Reveal(l, spawn)
r.NPCs = spawnNPCs(l, l.Crowd*NPCPerCrowd, r.npcRand)
r.platformPhase = map[level.Tile]level.Phase{}
for _, t := range level.PlatformTiles {
r.platformPhase[t], _ = r.PlatformPhase(t)
}
r.Revealed = map[level.Point]bool{}
r.Visits = map[level.Point]int{}
r.lastTile = spawn
r.Mini, r.MiniKind, r.PendingTrain, r.CarChosen = nil, MiniNone, nil, nil
r.Help, r.Facility = HelpState{}, FacilityState{}
r.DeniedAt, r.BumpedAt, r.WrongLane = -1, -1, false
r.parSec += parFor(l)
r.TransferAt = r.Time
r.flash(FlashTransfer)
}
// rideDone bokför en avslutad tågresa (vagnbonus, svettig-risk).
func (r *Run) rideDone(tr *level.Train) {
r.Rides = append(r.Rides, tr)
if r.CarChosen != nil && r.CarChosen.Crowd == 0 {
r.calmCars++
}
if r.CarChosen != nil && r.CarChosen.Crowd == 2 {
r.crowdedRide = true
}
}
// ParSec är tiden för tre stjärnor.
func (r *Run) ParSec() float64 { return r.parSec }
@@ -735,6 +830,11 @@ func (r *Run) StepMini(dt float64, in minigame.Input) {
r.Blocked += g.Blocked
switch {
case g.Success():
if next := r.nextLeg(); next != nil {
r.rideDone(tr)
r.startLeg(next) // bytet: resan fortsätter på nästa station
return
}
r.finish(tr, true, "")
case !tr.Correct:
reason := LostWrongTrain
@@ -801,22 +901,22 @@ func (r *Run) checkLimits() {
if r.Outcome != Ongoing {
return
}
if r.Level.TimeLimitSec > 0 && r.Time > float64(r.Level.TimeLimitSec) {
if r.timeLimit > 0 && r.Time > float64(r.timeLimit) {
r.Mini, r.MiniKind, r.PendingTrain = nil, MiniNone, nil
r.finish(nil, false, LostTimeUp)
return
}
if r.Level.MaxMissedTrains > 0 && r.MissedTrains > r.Level.MaxMissedTrains {
if r.maxMissed > 0 && r.MissedTrains > r.maxMissed {
r.finish(nil, false, LostMissedTrains)
}
}
// TimeLeft är sekunder kvar av tidsgränsen (0 utan gräns).
func (r *Run) TimeLeft() float64 {
if r.Level.TimeLimitSec == 0 {
if r.timeLimit == 0 {
return 0
}
return max(0, float64(r.Level.TimeLimitSec)-r.Time)
return max(0, float64(r.timeLimit)-r.Time)
}
// computeScore fyller i poängen för en vunnen bana.
@@ -825,15 +925,13 @@ func (r *Run) computeScore() {
sc.Time = int(ScoreTimeMax * min(1, r.parSec/max(r.Time, 0.001)))
sc.Money = max(0, ScoreMoneyMax-(r.Wallet.Spent-r.spentStart)/10)
sc.Meters = int(ScoreMetersMax * r.Party.AvgMeters())
sc.Explore = int(ScoreExploreMax * r.Fog.Explored(r.Level))
sc.Explore = int(ScoreExploreMax * (r.exploreSum + r.Fog.Explored(r.Level)) / float64(r.legsDone+1))
sc.Penalty = PenaltyMissed*r.MissedTrains + PenaltyRescan*r.Rescans + PenaltyGrumpy*r.Grumpy +
PenaltyEarlyBoard*r.EarlyBoard + PenaltyBlocked*r.Blocked
if r.WomenOnlyViolation {
sc.Penalty += PenaltyWomenOnly
}
if r.CarChosen != nil && r.CarChosen.Crowd == 0 {
sc.Car = ScoreCarBonus
}
sc.Car = ScoreCarBonus * r.calmCars
base := sc.Time + sc.Money + sc.Meters + sc.Explore + sc.Car
if r.Mode == nav.Signs {
sc.Bonus = base * ScoreSignsBonusPct / 100
@@ -860,7 +958,7 @@ func (r *Run) earnedDebuffs() []Debuff {
if r.poisoned {
out = append(out, Debuff{FoodPoisoning, FoodPoisonLevels})
}
if r.CarChosen != nil && r.CarChosen.Crowd == 2 && r.Seed.Derive("sweaty").New().Float64() < SweatyChance/r.Stats[StatLuck] {
if r.crowdedRide && r.Seed.Derive("sweaty").New().Float64() < SweatyChance/r.Stats[StatLuck] {
out = append(out, Debuff{Sweaty, 1})
}
return out
@@ -968,6 +1066,7 @@ func (r *Run) SignHere() *level.SignText {
// tillräckligt bra resultat lär man sig resmålets ord.
func (r *Run) finish(tr *level.Train, won bool, reason string) {
r.Boarded = tr
r.rideDone(tr)
r.NewDebuffs = r.earnedDebuffs()
if !won {
r.Outcome = Lost

View File

@@ -0,0 +1,179 @@
{
"clutter": 2,
"crowd": 2,
"destination": {
"en": "Ueno",
"ja": "上野",
"romaji": "Ueno",
"sv": "Ueno"
},
"difficulty": 4,
"fareYen": 170,
"grid": [
"#################################",
"#S....#...........#...........#W#",
"#####.#.#########.#.#.###.###.#.#",
"#.....#.#...#...#.#.#...#...#...#",
"#.#####.#.#.#.#.#.#.#.#.#.#.###.#",
"#i..#.....#...#.#.#.#.#.#.#.#...#",
"#.#.#.#########.#.#.#.#.#.#.#.###",
"#.#...#.......#.#...#..i#.#.#...#",
"#.#####.#####.#.#######.#.#.#####",
"#.......#T#...#.......#.#K#.#...#",
"#########.#.#####.###.#.###.#.#.#",
"#...#....i..#...#.#.#.#.#...#.#.#",
"#.###.#######.#.#.#.#.#.#.#.#.#.#",
"#.#...#.......#.#...#.#...#.#.#.#",
"#.#.#####.#.###.#####.#######.#.#",
"#.#.....#.#.#...#...#.........#.#",
"#.#####.#.#.#.###.#.###.#######.#",
"#.......#.#.#.#...#...#.#.......#",
"#G###.###G#.#.#.#####.###.#####G#",
"#pG.....Gq#.....#QG.......#...GP#",
"#################################"
],
"id": "007-akihabara",
"individualCards": true,
"kind": "main",
"maxMissedTrains": 3,
"name": {
"en": "Akihabara Station",
"ja": "秋葉原駅",
"sv": "Akihabara station"
},
"next": "007b-ueno",
"noise": 1,
"order": 7,
"party": 4,
"rideStops": 3,
"shop": [
"mask_tengu",
"onigiri_box",
"rail_pass"
],
"signs": [
{
"text": {
"en": "Akihabara Station. Track 4 Hibiya Line for Kita-Senju: south-east. Track 2 Yamanote Line for Ueno: south. Track 1 Yamanote Line for Tokyo and track 3 Hibiya Line for Naka-Meguro: south-west.",
"ja": "秋葉原駅 4番線 日比谷線 北千住方面 → 南東 2番線 山手線 上野方面 → 南 1番線 山手線 東京方面・3番線 日比谷線 中目黒方面 → 南西",
"sv": "Akihabara station. Spår 4 Hibiya-linjen mot Kita-Senju: sydost. Spår 2 Yamanote-linjen mot Ueno: söderut. Spår 1 Yamanote-linjen mot Tokyo och spår 3 Hibiya-linjen mot Naka-Meguro: sydväst."
},
"x": 1,
"y": 5,
"z": 0
},
{
"text": {
"en": "\u003c- Track 3 Hibiya Line for Naka-Meguro Track 4 Hibiya Line for Kita-Senju -\u003e",
"ja": "← 3番線 日比谷線 中目黒方面 4番線 日比谷線 北千住方面 →",
"sv": "\u003c- Spår 3 Hibiya-linjen mot Naka-Meguro Spår 4 Hibiya-linjen mot Kita-Senju -\u003e"
},
"x": 9,
"y": 11,
"z": 0
},
{
"text": {
"en": "Ueno: transfer to the Ginza Line for Asakusa.",
"ja": "上野 → 乗り換え 銀座線 浅草方面",
"sv": "Ueno: byte till Ginza-linjen mot Asakusa."
},
"x": 23,
"y": 7,
"z": 0
}
],
"station": "akihabara",
"timeLimitSec": 600,
"to": "ueno",
"trains": [
{
"cars": 4,
"destination": {
"en": "Kita-Senju",
"ja": "北千住",
"romaji": "Kita-Senju"
},
"line": {
"en": "Hibiya Line",
"ja": "日比谷線",
"romaji": "Hibiya-sen"
},
"lineId": "hibiya",
"platform": "P",
"schedule": {
"dwellSec": 9,
"intervalSec": 28,
"offsetSec": 8
},
"terminus": "kita-senju",
"track": "4"
},
{
"cars": 3,
"destination": {
"en": "Naka-Meguro",
"ja": "中目黒",
"romaji": "Naka-Meguro"
},
"line": {
"en": "Hibiya Line",
"ja": "日比谷線",
"romaji": "Hibiya-sen"
},
"lineId": "hibiya",
"platform": "p",
"schedule": {
"dwellSec": 8,
"intervalSec": 28,
"offsetSec": 16
},
"terminus": "naka-meguro",
"track": "3"
},
{
"cars": 4,
"destination": {
"en": "Ueno",
"ja": "上野",
"romaji": "Ueno"
},
"line": {
"en": "Yamanote Line",
"ja": "山手線",
"romaji": "Yamanote-sen"
},
"lineId": "yamanote",
"platform": "Q",
"schedule": {
"dwellSec": 9,
"intervalSec": 32,
"offsetSec": 12
},
"terminus": "ueno",
"track": "2"
},
{
"cars": 4,
"destination": {
"en": "Tokyo",
"ja": "東京",
"romaji": "Tokyo"
},
"line": {
"en": "Yamanote Line",
"ja": "山手線",
"romaji": "Yamanote-sen"
},
"lineId": "yamanote",
"platform": "q",
"schedule": {
"dwellSec": 8,
"intervalSec": 32,
"offsetSec": 4
},
"terminus": "tokyo",
"track": "1"
}
]
}

View File

@@ -0,0 +1,148 @@
{
"clutter": 1,
"crowd": 2,
"destination": {
"en": "Asakusa",
"ja": "浅草",
"romaji": "Asakusa",
"sv": "Asakusa"
},
"difficulty": 4,
"fareYen": 170,
"grid": [
"#################################",
"#S.#..\u003c..\u003c..\u003c..\u003c..\u003c..\u003c..\u003c..\u003c..###",
"#..#..\u003e..\u003e..\u003e..\u003e..\u003e..\u003e..\u003e..\u003e..###",
"#\u003c\u003e################\u003c\u003e#######\u003c\u003e###",
"#..#..\u003ci.\u003c..\u003c..\u003c..#..#..\u003c..#..###",
"#..#..\u003e..\u003e..\u003e..\u003eW.#..#..\u003e..#..###",
"#\u003c\u003e#\u003c\u003e##########\u003c\u003e#\u003c\u003e#\u003c\u003e####\u003c\u003e###",
"#..\u003c..#..\u003c..\u003c..#..#..#..\u003c..\u003c..###",
"#..\u003e..#..\u003e..\u003e..#..#..#..\u003e..\u003e..###",
"#######\u003c\u003e#\u003c\u003e#\u003c\u003e#\u003c\u003e#\u003c\u003e#\u003c\u003e#########",
"#..#..\u003c..\u003c..#..#..#..\u003c..#..\u003c..###",
"#..#..\u003e..\u003e..#..#..#..\u003e..#..\u003e..###",
"#\u003c\u003e#\u003c\u003e#######\u003c\u003e#\u003c\u003e#\u003c\u003e#\u003c\u003e####\u003c\u003e###",
"#..#K.\u003c..\u003c..#..#..\u003c.i#.T\u003c..\u003c..###",
"#..#i.\u003e..\u003e..#..#..\u003e..#..\u003e..\u003e..###",
"#\u003c\u003e#\u003c\u003e####\u003c\u003e#\u003c\u003e#\u003c\u003e##########\u003c\u003e###",
"#G.\u003c..\u003c..\u003c..#..\u003cG.\u003c..\u003c..\u003c..\u003c.G###",
"#pG\u003e..\u003e..\u003e..#..GQG\u003e..\u003e..\u003e..\u003eGP###",
"#################################",
"#################################",
"#################################"
],
"id": "007b-ueno",
"individualCards": true,
"kind": "leg",
"name": {
"en": "Ueno Station",
"ja": "上野駅",
"sv": "Ueno station"
},
"noise": 2,
"party": 4,
"rideStops": 3,
"signs": [
{
"text": {
"en": "Ueno Station. Track 1 Ginza Line for Asakusa: south-east. Track 2 Ginza Line for Shibuya: south-west. Track 3 Hibiya Line for Kita-Senju: south.",
"ja": "上野駅 1番線 銀座線 浅草方面 → 南東 2番線 銀座線 渋谷方面 → 南西 3番線 日比谷線 北千住方面 → 南",
"sv": "Ueno station. Spår 1 Ginza-linjen mot Asakusa: sydost. Spår 2 Ginza-linjen mot Shibuya: sydväst. Spår 3 Hibiya-linjen mot Kita-Senju: söderut."
},
"x": 7,
"y": 4,
"z": 0
},
{
"text": {
"en": "\u003c- Track 2 Ginza Line for Shibuya Track 1 Ginza Line for Asakusa -\u003e",
"ja": "← 2番線 銀座線 渋谷方面 1番線 銀座線 浅草方面 →",
"sv": "\u003c- Spår 2 Ginza-linjen mot Shibuya Spår 1 Ginza-linjen mot Asakusa -\u003e"
},
"x": 20,
"y": 13,
"z": 0
},
{
"text": {
"en": "Track 1 Ginza Line for Asakusa: the gate in the south-east corner.",
"ja": "1番線 銀座線 浅草方面 → 南東口 改札",
"sv": "Spår 1 Ginza-linjen mot Asakusa: spärren i sydöstra hörnet."
},
"x": 4,
"y": 14,
"z": 0
}
],
"station": "ueno",
"to": "asakusa",
"trains": [
{
"cars": 3,
"destination": {
"en": "Asakusa",
"ja": "浅草",
"romaji": "Asakusa"
},
"line": {
"en": "Ginza Line",
"ja": "銀座線",
"romaji": "Ginza-sen"
},
"lineId": "ginza",
"platform": "P",
"schedule": {
"dwellSec": 8,
"intervalSec": 26,
"offsetSec": 10
},
"terminus": "asakusa",
"track": "1"
},
{
"cars": 3,
"destination": {
"en": "Shibuya",
"ja": "渋谷",
"romaji": "Shibuya"
},
"line": {
"en": "Ginza Line",
"ja": "銀座線",
"romaji": "Ginza-sen"
},
"lineId": "ginza",
"platform": "p",
"schedule": {
"dwellSec": 8,
"intervalSec": 26,
"offsetSec": 18
},
"terminus": "shibuya",
"track": "2"
},
{
"cars": 4,
"destination": {
"en": "Kita-Senju",
"ja": "北千住",
"romaji": "Kita-Senju"
},
"line": {
"en": "Hibiya Line",
"ja": "日比谷線",
"romaji": "Hibiya-sen"
},
"lineId": "hibiya",
"platform": "Q",
"schedule": {
"dwellSec": 8,
"intervalSec": 30,
"offsetSec": 6
},
"terminus": "kita-senju",
"track": "3"
}
]
}

View File

@@ -0,0 +1,214 @@
{
"clutter": 2,
"crowd": 2,
"destination": {
"en": "Nippori",
"ja": "日暮里",
"romaji": "Nippori",
"sv": "Nippori"
},
"difficulty": 5,
"fareYen": 200,
"floors": [
{
"grid": [
"#################################",
"#S#.........#...........#.......#",
"#.#.#.###.#.#.#########.#.#####.#",
"#.#.#.#.#.#.#.#.#...#...#.....#.#",
"#.#.#.#.#.###.#.#.#.#.#####.###.#",
"#.#.....#.....#...#.#.......#...#",
"#.#####.###########.#########.###",
"#.....#...#.......#.....#...#...#",
"#####.###.###.###.#####.#.#.###.#",
"#...#.#.........#.......#.#.#...#",
"###.#.#########.#######.#.#.#.###",
"#...#...#......U#.......#.#.#...#",
"#.#####.#.#######.#######.#.###.#",
"#...#...#.........#.......#...#.#",
"###.#.#############.#########.#.#",
"#...#.#...#.........#...#.....#.#",
"#.###.#.#.#.###.#####.#.#.#####.#",
"#...#...#...#...#.....#.#...#...#",
"#.#.#########.###.#.#######.#.#.#",
"#.#...............#.............#",
"#################################"
],
"name": "B1"
},
{
"grid": [
"#################################",
"#T#..................K#.........#",
"#.#.#######.#####.#####.###.###.#",
"#.#.............#.....#.#...#.#.#",
"#.#####.#######.###.#.#.#.###.#.#",
"#.#.....#.....#.....#...#.#...#.#",
"#.#.#####.###.#.#########.#.###.#",
"#...#.....#...#.#.......#.#.....#",
"#.###.#####.###.###.###.#.#####.#",
"#...#.#..W#...#...#...#.........#",
"###.#.#.#####.###.#.#.#####.#.###",
"#.....#i....#..D#.#.#.#...#.#...#",
"#.#####.###.###.#.###.#.#.#####.#",
"#.......#.#.....#.#...#.#.#.....#",
"#.#######.#######.#.###.#.#.###.#",
"#.....#..........i..#...#...#...#",
"#####.###.###########.#######.###",
"#...#...#.#...........#.....#...#",
"#G#.###.###.###########.#######G#",
"#p#....iGqG.#...GQG...........GP#",
"#################################"
],
"name": "1F"
}
],
"id": "008-tokyo-hemresa",
"individualCards": true,
"kind": "main",
"maxMissedTrains": 3,
"name": {
"en": "Tokyo Station, going home",
"ja": "東京駅",
"sv": "Tokyo station, hemresan"
},
"next": "008b-nippori",
"noise": 2,
"order": 8,
"party": 4,
"rewards": {
"skillPoints": 2
},
"rideStops": 4,
"shop": [
"suit",
"mask_tengu",
"onigiri_box",
"rail_pass"
],
"signs": [
{
"text": {
"en": "Tokyo Station. Track 4 Yamanote Line for Ueno: south-east. Track 3 Keihin-Tohoku Line for Omiya: south. Track 1 Chuo Line for Takao and track 5 Yamanote Line for Shinagawa: south-west. The platforms are on 1F.",
"ja": "東京駅 4番線 山手線 上野方面 → 南東 3番線 京浜東北線 大宮方面 → 南 1番線 中央線 高尾方面・5番線 山手線 品川方面 → 南西",
"sv": "Tokyo station. Spår 4 Yamanote-linjen mot Ueno: sydost. Spår 3 Keihin-Tohoku-linjen mot Omiya: söderut. Spår 1 Chuo-linjen mot Takao och spår 5 Yamanote-linjen mot Shinagawa: sydväst. Perrongerna ligger på 1F."
},
"x": 7,
"y": 11,
"z": 1
},
{
"text": {
"en": "\u003c- Track 5 Yamanote Line for Shinagawa Track 4 Yamanote Line for Ueno -\u003e",
"ja": "← 5番線 山手線 品川方面 4番線 山手線 上野方面 →",
"sv": "\u003c- Spår 5 Yamanote-linjen mot Shinagawa Spår 4 Yamanote-linjen mot Ueno -\u003e"
},
"x": 7,
"y": 19,
"z": 1
},
{
"text": {
"en": "Nippori: transfer to the Keisei Line for Narita Airport.",
"ja": "日暮里 → 乗り換え 京成本線 成田空港方面",
"sv": "Nippori: byte till Keisei-linjen mot Narita flygplats."
},
"x": 17,
"y": 15,
"z": 1
}
],
"station": "tokyo",
"timeLimitSec": 720,
"to": "nippori",
"trains": [
{
"cars": 4,
"destination": {
"en": "Ueno",
"ja": "上野",
"romaji": "Ueno"
},
"line": {
"en": "Yamanote Line",
"ja": "山手線",
"romaji": "Yamanote-sen"
},
"lineId": "yamanote",
"platform": "P",
"schedule": {
"dwellSec": 9,
"intervalSec": 30,
"offsetSec": 10
},
"terminus": "ueno",
"track": "4"
},
{
"cars": 4,
"destination": {
"en": "Shinagawa",
"ja": "品川",
"romaji": "Shinagawa"
},
"line": {
"en": "Yamanote Line",
"ja": "山手線",
"romaji": "Yamanote-sen"
},
"lineId": "yamanote",
"platform": "p",
"schedule": {
"dwellSec": 8,
"intervalSec": 30,
"offsetSec": 20
},
"terminus": "shinagawa",
"track": "5"
},
{
"cars": 4,
"destination": {
"en": "Omiya",
"ja": "大宮",
"romaji": "Omiya"
},
"line": {
"en": "Keihin-Tohoku Line",
"ja": "京浜東北線",
"romaji": "Keihin-Tohoku-sen"
},
"lineId": "keihin-tohoku",
"platform": "Q",
"schedule": {
"dwellSec": 9,
"intervalSec": 34,
"offsetSec": 4
},
"terminus": "omiya",
"track": "3"
},
{
"cars": 3,
"destination": {
"en": "Takao",
"ja": "高尾",
"romaji": "Takao"
},
"line": {
"en": "Chuo Line",
"ja": "中央線",
"romaji": "Chuo-sen"
},
"lineId": "chuo",
"platform": "q",
"schedule": {
"dwellSec": 8,
"intervalSec": 28,
"offsetSec": 14
},
"terminus": "takao",
"track": "1"
}
]
}

View File

@@ -0,0 +1,150 @@
{
"clutter": 1,
"crowd": 1,
"destination": {
"en": "Narita Airport",
"ja": "成田空港",
"romaji": "Narita Airport",
"sv": "Narita flygplats"
},
"difficulty": 5,
"fareYen": 500,
"grid": [
"#################################",
"#S#W....#.....#.....#...#.......#",
"#.#####.#.###.#.#.#.#.#.#.#.#.#.#",
"#.....#.#...#...#.....#...#.#...#",
"#####.#.###.###############.#####",
"#K#...#...#...#.......#...#.....#",
"#.#.#####.###.#.#.###.#.#.#####.#",
"#.#.#...#.....#.#....i#.#...#...#",
"#.#.#.#.###.###.#####.#.###.#.#.#",
"#.#...#...#...#.....#...#.#...#.#",
"#.#######.#######.#.#####.#####.#",
"#...#...#.#.....#.#.#.#.......#.#",
"#.#.###.#.#.###.#.#.#.#.#####.#.#",
"#.#.....#...#T#i..#.#...#.....#.#",
"#.#.#########.#.###.#####.#####.#",
"#.#.#.....#..i..#...#.....#.....#",
"#.###.#.#.#.#####.###.###.#.#####",
"#.....#.#.#.......#...#...#.....#",
"#G#####.#.#########.#.#########G#",
"#pG...#.........GQG.#.........GP#",
"#################################"
],
"id": "008b-nippori",
"individualCards": true,
"kind": "leg",
"name": {
"en": "Nippori Station",
"ja": "日暮里駅",
"sv": "Nippori station"
},
"noise": 1,
"party": 4,
"rideStops": 4,
"seatSelection": true,
"signs": [
{
"text": {
"en": "Nippori Station. Track 1 Keisei Line for Narita Airport: south-east. Track 2 Keisei Line for Keisei-Ueno: south-west. Track 11 Yamanote Line for Ueno: south. Ticket required.",
"ja": "日暮里駅 1番線 京成本線 成田空港方面 → 南東 2番線 京成本線 京成上野方面 → 南西 11番線 山手線 上野方面 → 南",
"sv": "Nippori station. Spår 1 Keisei-linjen mot Narita flygplats: sydost. Spår 2 Keisei-linjen mot Keisei-Ueno: sydväst. Spår 11 Yamanote-linjen mot Ueno: söderut. Biljett krävs."
},
"x": 15,
"y": 13,
"z": 0
},
{
"text": {
"en": "\u003c- Track 2 Keisei Line for Keisei-Ueno Track 1 Keisei Line for Narita Airport -\u003e",
"ja": "← 2番線 京成本線 京成上野方面 1番線 京成本線 成田空港方面 →",
"sv": "\u003c- Spår 2 Keisei-linjen mot Keisei-Ueno Spår 1 Keisei-linjen mot Narita flygplats -\u003e"
},
"x": 13,
"y": 15,
"z": 0
},
{
"text": {
"en": "Narita Airport: track 1. Tickets at the machine.",
"ja": "成田空港 → 1番線 切符 → 券売機",
"sv": "Narita flygplats: spår 1. Biljetter i automaten."
},
"x": 21,
"y": 7,
"z": 0
}
],
"station": "nippori",
"ticketRequired": true,
"to": "narita-airport",
"trains": [
{
"cars": 4,
"destination": {
"en": "Narita Airport",
"ja": "成田空港",
"romaji": "Narita Airport"
},
"line": {
"en": "Keisei Main Line",
"ja": "京成本線",
"romaji": "Keisei Honsen"
},
"lineId": "keisei-main",
"platform": "P",
"schedule": {
"dwellSec": 10,
"intervalSec": 36,
"offsetSec": 12
},
"terminus": "narita-airport",
"track": "1"
},
{
"cars": 3,
"destination": {
"en": "Keisei-Ueno",
"ja": "京成上野",
"romaji": "Keisei-Ueno"
},
"line": {
"en": "Keisei Main Line",
"ja": "京成本線",
"romaji": "Keisei Honsen"
},
"lineId": "keisei-main",
"platform": "p",
"schedule": {
"dwellSec": 8,
"intervalSec": 36,
"offsetSec": 22
},
"terminus": "keisei-ueno",
"track": "2"
},
{
"cars": 4,
"destination": {
"en": "Ueno",
"ja": "上野",
"romaji": "Ueno"
},
"line": {
"en": "Yamanote Line",
"ja": "山手線",
"romaji": "Yamanote-sen"
},
"lineId": "yamanote",
"platform": "Q",
"schedule": {
"dwellSec": 8,
"intervalSec": 30,
"offsetSec": 6
},
"terminus": "ueno",
"track": "11"
}
]
}

BIN
resources/sprites/plane.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 B

View File

@@ -208,5 +208,20 @@
"result.lost_wrong_direction": "Wrong direction.",
"result.lost_too_far": "Too far away.",
"result.wrong_direction_detail": "The line does reach %[2]s, but the train for %[1]s goes the other way.",
"result.too_far_detail": "The train for %[1]s only reaches %[2]s after a long detour."
"result.too_far_detail": "The train for %[1]s only reaches %[2]s after a long detour.",
"hud.via": "via",
"hud.transfer_done": "Transfer! Find the next train.",
"map.transfers": "transfers",
"ending.title": "Flying home",
"ending.sub": "Last day. The party flies home from Narita.",
"ending.summary": "The trip in numbers",
"ending.levels": "Levels cleared",
"ending.stars": "Stars",
"ending.score": "Best scores total",
"ending.words": "Japanese words learned",
"ending.items": "Souvenirs",
"ending.none": "none",
"ending.story_done": "Story complete! Endless mode and New Game+ are up next.",
"ending.hint": "Enter: continue Esc: level map",
"ending.credits": "Metro Puzzle JP\n\nIdea and game design\nBjörn\n\nCode and pixels\nClaude (Anthropic)\n\nEngine\nEbitengine\n\nFont\nPixelMplus (M+ FONT LICENSE)\n\nThe trains, the signs and the chatter\nTokyo\n\nThanks for riding!\nまたね"
}

View File

@@ -1168,5 +1168,12 @@
"en": "ticket machine",
"sv": "biljettautomat",
"tier": 2
},
"乗り換え": {
"kana": "のりかえ",
"romaji": "norikae",
"en": "transfer",
"sv": "byte",
"tier": 2
}
}

View File

@@ -208,5 +208,20 @@
"result.lost_wrong_direction": "Fel håll.",
"result.lost_too_far": "För långt bort.",
"result.wrong_direction_detail": "Linjen går till %[2]s, men tåget mot %[1]s åker åt andra hållet.",
"result.too_far_detail": "Tåget mot %[1]s når %[2]s först efter en lång omväg."
"result.too_far_detail": "Tåget mot %[1]s når %[2]s först efter en lång omväg.",
"hud.via": "via",
"hud.transfer_done": "Byte! Hitta nästa tåg.",
"map.transfers": "byten",
"ending.title": "Hemresa",
"ending.sub": "Sista dagen. Sällskapet flyger hem från Narita.",
"ending.summary": "Resan i siffror",
"ending.levels": "Banor klarade",
"ending.stars": "Stjärnor",
"ending.score": "Bästa poäng totalt",
"ending.words": "Japanska ord lärda",
"ending.items": "Souvenirer",
"ending.none": "inga",
"ending.story_done": "Storyn är klar! Oändligt läge och New Game+ kommer härnäst.",
"ending.hint": "Enter: vidare Esc: bankartan",
"ending.credits": "Metro Puzzle JP\n\nIdé och speldesign\nBjörn\n\nKod och pixlar\nClaude (Anthropic)\n\nMotor\nEbitengine\n\nTypsnitt\nPixelMplus (M+ FONT LICENSE)\n\nTågen, skyltarna och sorlet\nTokyo\n\nTack för resan!\nまたね"
}

View File

@@ -287,6 +287,26 @@ func main() {
}
}
save(filepath.Join(out, "player.png"), playerImg)
planeImg := image.NewNRGBA(image.Rect(0, 0, 32, 12))
plane(canvas{planeImg, 0, 0})
save(filepath.Join(out, "plane.png"), planeImg)
}
// plane är flygplanet i slutscenen (32×12, nosen åt höger).
func plane(c canvas) {
white, grey, blue, red := rgb(236, 240, 245), rgb(150, 160, 175), rgb(90, 140, 220), rgb(214, 60, 60)
c.rect(4, 5, 24, 4, white) // kropp
c.rect(28, 6, 3, 2, white) // nos
c.rect(2, 4, 3, 5, white) // akter
c.rect(1, 1, 4, 4, red) // fena
c.rect(10, 3, 10, 2, grey) // bortre vingen
c.rect(12, 9, 8, 2, grey) // främre vingen
c.rect(14, 10, 3, 2, grey) // motor
for x := 8; x < 26; x += 3 {
c.rect(x, 6, 1, 1, blue) // fönster
}
c.rect(26, 6, 2, 1, blue) // cockpit
}
func save(path string, img image.Image) {

186
tools/levelcheck/main.go Normal file
View File

@@ -0,0 +1,186 @@
// levelcheck ritar banorna som bilder och rapporterar vad som är fel eller
// värt att veta: onåbara rutor, perronger utanför spärrarna, tågens
// bedömning i linjenätet och resornas byten. Ren Go (image/png), inga
// fönster.
//
// go run ./tools/levelcheck # alla banor → build/levelcheck/
// go run ./tools/levelcheck 007 008b # bara banor vars id innehåller något av orden
package main
import (
"fmt"
"image"
"image/color"
"image/png"
"os"
"path/filepath"
"strings"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/nav"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res"
)
const scale = 8 // px per ruta
var palette = map[level.Tile]color.RGBA{
level.Wall: {38, 43, 68, 255},
level.Floor: {160, 170, 190, 255},
level.LaneLeft: {150, 165, 185, 255},
level.LaneRight: {170, 175, 195, 255},
level.Spawn: {80, 200, 100, 255},
level.Gate: {70, 120, 230, 255},
level.TicketMachine: {60, 200, 220, 255},
level.Sign: {40, 70, 150, 255},
level.Toilet: {240, 240, 250, 255},
level.Kiosk: {240, 200, 60, 255},
level.StairsUp: {170, 110, 220, 255},
level.StairsDown: {120, 70, 170, 255},
}
func main() {
dir, err := res.Path("data", "levels")
fail(err)
all, err := level.LoadAll(dir)
fail(err)
out := filepath.Join(repoRoot(), "build", "levelcheck")
fail(os.MkdirAll(out, 0o755))
problems := 0
for _, l := range all {
if len(os.Args) > 1 && !matches(l.ID, os.Args[1:]) {
continue
}
problems += report(l, all, out)
}
if problems > 0 {
fmt.Printf("\n%d problem\n", problems)
os.Exit(1)
}
fmt.Println("\ninga problem")
}
func matches(id string, words []string) bool {
for _, w := range words {
if strings.Contains(id, w) {
return true
}
}
return false
}
// report skriver rapporten för en bana och ritar varje våning.
func report(l *level.Level, all []*level.Level, out string) (problems int) {
fmt.Printf("== %s (%s, %dx%d, %d våning(ar), svårighet %d, sällskap %d)\n", l.ID, l.Kind, l.Width(), l.Height(), l.NumFloors(), l.Difficulty, l.Party)
reach := l.Reachable()
if !l.Solvable() {
fmt.Println(" FEL: går inte att klara från start")
problems++
}
for _, p := range l.Unreachable() {
fmt.Printf(" FEL: onåbar %c på %v\n", l.At(p), p)
problems++
}
for _, p := range l.UngatedPlatforms() {
fmt.Printf(" FEL: perrong %v nås utan spärr\n", p)
problems++
}
for _, tr := range l.Trains {
pts := l.Find(level.Tile(tr.Platform[0]))
dist := len(l.Path(l.SpawnPoint(), pts[0], l.CanEnter)) - 1
v := "correct=" + fmt.Sprint(tr.Correct)
if tr.Verdict != nav.Unjudged {
v = tr.Verdict.String()
}
fmt.Printf(" tåg %s spår %-3s %-8s → %-10s %-16s %3d steg från start\n", tr.Platform, tr.Track, tr.Line.Get("romaji"), tr.Destination.Get("romaji"), v, dist)
}
if l.Next != "" {
var ids []string
for _, c := range level.ChainOf(all, l.ID) {
ids = append(ids, c.ID)
}
fmt.Printf(" resa: %s\n", strings.Join(ids, " → "))
}
fmt.Printf(" skyltar %d, utforskbart %d rutor\n", len(l.Signs), len(reach))
for z := 0; z < l.NumFloors(); z++ {
name := fmt.Sprintf("%s/%s-%d.png", out, l.ID, z)
fail(savePNG(name, draw(l, z, reach)))
fmt.Printf(" bild: %s\n", name)
}
return problems
}
// draw ritar en våning: rutor i palettfärg, onåbara rutor rödmarkerade,
// perronger orange (rätt tåg grönkantad), spelarens kortaste väg prickad.
func draw(l *level.Level, z int, reach map[level.Point]bool) image.Image {
img := image.NewRGBA(image.Rect(0, 0, l.Width()*scale, l.Height()*scale))
fill := func(x, y int, col color.RGBA, inset int) {
for dy := inset; dy < scale-inset; dy++ {
for dx := inset; dx < scale-inset; dx++ {
img.SetRGBA(x*scale+dx, y*scale+dy, col)
}
}
}
correct := map[level.Tile]bool{}
for _, tr := range l.Trains {
correct[level.Tile(tr.Platform[0])] = tr.Correct
}
for y := 0; y < l.Height(); y++ {
for x := 0; x < l.Width(); x++ {
p := level.Point{X: x, Y: y, Z: z}
t := l.At(p)
col, ok := palette[t]
switch {
case t.IsPlatform():
col = color.RGBA{250, 150, 50, 255}
if correct[t] {
fill(x, y, color.RGBA{60, 220, 90, 255}, 0)
fill(x, y, col, 1)
continue
}
case !ok:
col = color.RGBA{255, 0, 255, 255}
}
fill(x, y, col, 0)
if t.Passable() && !reach[p] {
fill(x, y, color.RGBA{220, 40, 40, 255}, 2)
}
}
}
if path := l.Path(l.SpawnPoint(), l.CorrectPlatform(), l.CanEnter); path != nil {
for _, p := range path {
if p.Z == z {
fill(p.X, p.Y, color.RGBA{30, 30, 30, 255}, 3)
}
}
}
return img
}
func savePNG(path string, img image.Image) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
return png.Encode(f, img)
}
func repoRoot() string {
dir, err := os.Getwd()
fail(err)
for d := dir; ; d = filepath.Dir(d) {
if _, err := os.Stat(filepath.Join(d, "go.mod")); err == nil {
return d
}
if filepath.Dir(d) == d {
return dir
}
}
}
func fail(err error) {
if err != nil {
fmt.Fprintln(os.Stderr, "levelcheck:", err)
os.Exit(2)
}
}

View File

@@ -122,6 +122,14 @@ func buildShots(levelsDir string) error {
if err != nil {
return err
}
l7, err := level.LoadFile(filepath.Join(levelsDir, "007-akihabara.json"))
if err != nil {
return err
}
toPlatform7, err := moves(l7, l7.SpawnPoint(), l7.Find(level.Platform)[0]) // Hibiya mot Kita-Senju: byte i Ueno
if err != nil {
return err
}
// Skyltbilderna tas med några kända ord så att markeringen och den
// delvisa översättningen syns; hjälpen lär sedan in nästa ord.
known := []string{"-know", "改札,番線,方面", "-seed", "7"}
@@ -143,6 +151,9 @@ func buildShots(levelsDir string) error {
{"006-ikebukuro", toMachine6 + "; wait 5; shot $OUT/terminal.png; " + "quit", []string{"-seed", "7", "-mode", "signs", "-know", "山手線,番線,方面,新宿,池袋,埼京線"}},
{"006-ikebukuro", toTrap6 + "; waittrain; wait 5; press enter; wait 100; pick calm; press enter; wait 60; shot $OUT/ride-real.png; waitover; wait 5; shot $OUT/too-far.png; quit", []string{"-seed", "7", "-mode", "signs"}},
{"", "wait 10; press n; wait 5; shot $OUT/map-signs.png; quit", []string{"-save", "$DEMO"}},
{"007-akihabara", toPlatform7 + "; waittrain; wait 5; press enter; wait 100; pick calm; press enter; waitstop; press enter; wait 40; shot $OUT/transfer.png; quit",
[]string{"-seed", "7", "-mode", "list", "-know", "上野,番線,方面,乗り換え"}},
{"", "wait 150; shot $OUT/ending.png; press enter; wait 5; shot $OUT/ending-summary.png; press enter; wait 250; shot $OUT/credits.png; quit", []string{"-ending", "-save", "$DEMO"}},
}
return nil
}