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>
30
README.md
@@ -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).
|
||||
|
||||

|
||||
@@ -74,6 +71,13 @@ fyra rätt, ett går åt fel håll och ett tar omvägen via city.
|
||||
|
||||

|
||||
|
||||
Resorna med byten fortsätter på nästa station när du klivit av: nytt
|
||||
labyrintgolv, samma klocka och samma hungriga sällskap.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
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 |
|
||||
|
||||
|
||||
@@ -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
|
After Width: | Height: | Size: 12 KiB |
BIN
doc/img/ending-summary.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
doc/img/ending.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 16 KiB |
BIN
doc/img/map.png
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
BIN
doc/img/transfer.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
@@ -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) |
|
||||
|
||||
|
||||
@@ -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+
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,6 +310,7 @@ type LevelOutcome struct {
|
||||
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()
|
||||
|
||||
178
internal/game/scene_ending.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ type Progress struct {
|
||||
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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
179
resources/data/levels/007-akihabara.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
148
resources/data/levels/007b-ueno.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
214
resources/data/levels/008-tokyo-hemresa.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
150
resources/data/levels/008b-nippori.json
Normal 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
|
After Width: | Height: | Size: 165 B |
@@ -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またね"
|
||||
}
|
||||
|
||||
@@ -1168,5 +1168,12 @@
|
||||
"en": "ticket machine",
|
||||
"sv": "biljettautomat",
|
||||
"tier": 2
|
||||
},
|
||||
"乗り換え": {
|
||||
"kana": "のりかえ",
|
||||
"romaji": "norikae",
|
||||
"en": "transfer",
|
||||
"sv": "byte",
|
||||
"tier": 2
|
||||
}
|
||||
}
|
||||
@@ -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またね"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||