diff --git a/README.md b/README.md index aaa3415..34876f3 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,11 @@ 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). +**hemresan**: planet lyfter, resan summeras och eftertexterna rullar. Med +egna kort väljer man **vem som betalar** i kiosken, och på de svåraste +stationerna släpper spärren inte ut en som checkat in: man får **kalla på +personal** med Enter vid spärren. Nästa är M11 (oändligt läge och New +Game+). Se [doc/roadmap.md](doc/roadmap.md). ![Bankartan](doc/img/map.png) @@ -76,6 +78,8 @@ labyrintgolv, samma klocka och samma hungriga sällskap. ![Byte i Ueno](doc/img/transfer.png) +![Vem betalar?](doc/img/payer.png) + ![Hemresan](doc/img/ending.png) På de svåraste banorna har alla i sällskapet egna kort som måste ha saldo, @@ -185,7 +189,8 @@ metro-puzzle-jp -version ### Styrning Pilar eller WASD går. Enter väljer, kliver på tåget, använder toaletten, -handlar i kiosken eller startar automaten; i minispelen väljer pilarna och +handlar i kiosken, startar automaten eller kallar på personal vid en spärr +som inte släpper ut en; i minispelen väljer pilarna och Enter bekräftar (håll Enter för att mata in sedlar). H frågar om hjälp vid en skylt, L öppnar ordlistan, T skillträdet, I garderoben, B butiken vid en kiosk, M döljer/visar minikartan, P på kartan lägger kortet i telefonen, N på kartan diff --git a/doc/img/no-exit.png b/doc/img/no-exit.png new file mode 100644 index 0000000..6bea212 Binary files /dev/null and b/doc/img/no-exit.png differ diff --git a/doc/img/payer.png b/doc/img/payer.png new file mode 100644 index 0000000..2910cd3 Binary files /dev/null and b/doc/img/payer.png differ diff --git a/doc/img/staff.png b/doc/img/staff.png new file mode 100644 index 0000000..8e5ee4d Binary files /dev/null and b/doc/img/staff.png differ diff --git a/doc/mal-och-tester.md b/doc/mal-och-tester.md index c5343c6..80d48d5 100644 --- a/doc/mal-och-tester.md +++ b/doc/mal-och-tester.md @@ -132,6 +132,9 @@ att bygga på Linux som på Windows. | `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/minigame` | `TestPayerPicksSourceAndRefusesPoor` | **M10c:** markören på första som har råd, nekande utan avslut, avbrott utan köp | +| `internal/sim` | `TestKioskAsksWhoPays`, `TestOneWayGatesNeedStaff` | **M10c:** kiosken frågar vem som betalar med egna kort, vald följares kort dras, kontant fungerar, avbrott drar inget; avgift vid inpassering i betalt område, spärren släpper inte ut incheckad, personal låser upp efter 10 s, ny inpassering kostar igen | +| `internal/level` | `TestOneWayGatesAndInside` | **M10c:** `oneWayGates` läses, `InsideGates` = rutorna innanför spärrarna | | `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 | @@ -155,6 +158,7 @@ att bygga på Linux som på Windows. | ~~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~~ ✓ | 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 | +| ~~M10c Vem betalar, spärrar utan återvändo~~ ✓ | Väljaren betalar från rätt källa och nekar den utan pengar; spärren håller kvar, personalen låser upp, ny inpassering kostar – finns, se tabellen ovan | | 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) | diff --git a/doc/roadmap.md b/doc/roadmap.md index 8b3af63..22fad79 100644 --- a/doc/roadmap.md +++ b/doc/roadmap.md @@ -573,6 +573,45 @@ på **brasse-linux01-runnern** (label `linux-amd64`), aldrig på Pi5:n. - **Teknik:** vägrutor i banformatet, `Vehicle`-abstraktion i sim som tåg och buss delar, återanvänd `nav` för busslinjer. +### M10c – Vem betalar, och spärrar utan återvändo ✓ (klar 2026-10-24) + +- **Mål:** två av Björns tillägg (2026-10-24) som gör kort- och + spärrhanteringen mer strategisk. +- **Vem betalar:** med fler än en i följet och egna kort väljer man vid + kiosken/automaten vem som betalar – kontant, spelarens kort eller någon + följares kort – så att alla har kvar pengar till spärrarna. Ett litet + väljarminispel (`minigame.Payer`) framför köpet; de som inte har råd + markeras. +- **Spärrar utan återvändo:** banflaggan `oneWayGates`. När man har + checkat in i spärren släpper den inte ut en igen förrän man åkt. Måste + man ut (toalett, kiosk, automat utanför) kallar man på personal med + Enter vid spärren (knappen sitter vid spärren – ett kontor kan komma + senare); det tar tid, sedan är korten upplåsta och man kan gå ut. + Går man in igen dras avgiften igen. +- **Teknik:** `level.InsideGates` (rutorna innanför spärrarna, BFS från + perrongerna), `Run.checkedIn`/`StaffUnlocked`/`Staff`-tillstånd i sim, + prompt och förlopp i inforutan, skriptkommandot `urge` för skärmdumpar. +- **Så blev det:** `minigame.Payer` (kontant / spelarens kort / varje + följares kort med saldo; de som inte har råd i rött och nekas; Esc + avbryter köpet) öppnas av kiosken när banan har egna kort och fler i + sällskapet. Spärrregeln ändrades samtidigt: **avgiften dras när man + passerar spärren in i det betalda området** (`level.InsideGates`, rutorna + som nås från perrongerna utan att korsa en spärr), inte när man står på + spärrrutan – så spärrar som ligger i en gång kostar inget att passera + utifrån. Med `oneWayGates` blir man incheckad vid betalningen; en spärr + man nått inifrån släpper inte ut en (meddelande i inforutan), Enter på + spärren kallar på personal (10 s, förloppsstapel, avbryts om man går), + sedan är korten upplåsta tills man gått ut; ny inpassering kostar igen. + Flaggan är på i 005 Ueno, 007/007b och 008/008b. Skriptkommandona `urge + NAMN` och `push RIKTNING N` för skärmdumparna. +- **Klar när:** väljaren betalar från rätt källa och nekar den som inte + har råd (`minigame.TestPayerPicksSourceAndRefusesPoor`, + `sim.TestKioskAsksWhoPays`); spärren släpper inte ut en, personalen låser + upp efter väntetiden och ny inpassering kostar igen + (`sim.TestOneWayGatesNeedStaff`, `level.TestOneWayGatesAndInside`). ✓ +- **Kvar till senare:** ett kontor (egen ruta) som alternativ till knappen + vid spärren; personal som också kan hjälpa med tomt kort. + ### M11 – Oändligt läge och New Game+ - Slumpade mål från seed, poäng per mål, egen poänglista. NG+ behåller skills. diff --git a/doc/spelbeskrivning.md b/doc/spelbeskrivning.md index c6035c5..2ca0015 100644 --- a/doc/spelbeskrivning.md +++ b/doc/spelbeskrivning.md @@ -178,6 +178,15 @@ otydligt står tolkningen i [§15](#15-tolkningar-av-originalet). viss person**, någon vill sitta vid **gången eller fönstret**, någon på **höger eller vänster sida** av tåget. Du måste placera alla så att det stämmer. +- **Vem betalar** (tillägg 2026-10-24): har man fler än en person i + följet ska man kunna **välja vem som köper** drickan i automaten, så att + man kan vara strategisk och se till att **alla har pengar kvar på sina + kort** till spärrarna. +- **Spärrar utan återvändo** (tillägg 2026-10-24): på vissa stationer kan + man **inte gå tillbaka ut** genom spärren efter att man kommit in utan att + åka någonstans. Måste man ut ändå får man **kalla på personal** – det + finns oftast en **knapp vid spärren eller vid ett kontor** – så att de + kan **låsa upp korten** åt en. ## 9. Svårighet och tid diff --git a/internal/game/minigame_view.go b/internal/game/minigame_view.go index 16e1157..9c0e3a5 100644 --- a/internal/game/minigame_view.go +++ b/internal/game/minigame_view.go @@ -32,9 +32,44 @@ func (s *LevelScene) drawMinigame(g *Game, screen *ebiten.Image) { s.drawRide(a, screen, m, panel) case *minigame.CarPick: s.drawCarPick(a, screen, m, panel) + case *minigame.Payer: + s.drawPayer(a, screen, m, panel) } } +// drawPayer: vem betalar i kiosken – kontant, spelarens kort eller en +// följares kort, med saldon; de som inte har råd i rött. +func (s *LevelScene) drawPayer(a *Assets, screen *ebiten.Image, m *minigame.Payer, p image.Rectangle) { + x, y := p.Min.X+8, p.Min.Y+8 + a.DrawBig(screen, "支払い", x, y, colKnown) + a.Draw(screen, a.T("mini.payer.title"), x+60, y+2, colText) + y += 20 + a.Draw(screen, fmt.Sprintf(a.T("mini.payer.price"), m.Price), x, y, colDim) + y += LineH + 4 + for i, o := range m.Options { + name, kind := o.Name, a.T("mini.payer.card") + if o.Cash { + name, kind = a.T("mini.payer.cash"), "" + } + col := colText + if !m.CanAfford(i) { + col = colBad + } + if i == m.Cursor { + vector.DrawFilledRect(screen, float32(x-2), float32(y+i*LineH-1), 220, LineH, colBar, false) + if m.CanAfford(i) { + col = colKnown + } + } + a.Draw(screen, fmt.Sprintf("%-8s %5d¥ %s", name, o.Yen, kind), x, y+i*LineH, col) + } + y += len(m.Options)*LineH + 4 + if m.Refused { + a.Draw(screen, a.T("mini.payer.short"), x, y, colBad) + } + a.Draw(screen, a.T("mini.payer.hint"), x, p.Max.Y-14, colDim) +} + // drawCarPick: vagnarna i rad, rosa för kvinnovagn, figurer för trängsel, // kömarkeringar under, och en fas där folk kliver av först. func (s *LevelScene) drawCarPick(a *Assets, screen *ebiten.Image, m *minigame.CarPick, p image.Rectangle) { diff --git a/internal/game/scene_level.go b/internal/game/scene_level.go index af87d72..3c4b17e 100644 --- a/internal/game/scene_level.go +++ b/internal/game/scene_level.go @@ -141,6 +141,18 @@ func (s *LevelScene) drawModeHint(a *Assets, screen *ebiten.Image) { a.Draw(screen, txt, 4, y+3, col) } +// ForceUrge gör en i sällskapet törstig (skriptläget: "urge NAMN" för +// skärmdumpar av kiosken). +func (s *LevelScene) ForceUrge(name string) bool { + for _, m := range s.run.Party.Members { + if m.Name == name { + m.Meters.Drink, m.Urge = 5, sim.Thirsty + return true + } + } + return false +} + // LevelOver rapporterar om banan är avgjord (skriptläget: "waitover"). func (s *LevelScene) LevelOver() bool { return s.run.Outcome != sim.Ongoing } @@ -222,6 +234,13 @@ func (s *LevelScene) MiniPick(text string) bool { s.run.StepMini(0, minigame.Input{MoveX: 1}) return false } + case *minigame.Payer: + o := m.Options[m.Cursor] + if o.Name == text || (text == "cash" && o.Cash) { + return true + } + s.run.StepMini(0, minigame.Input{MoveX: 1}) + return false case *minigame.TopUp: switch m.Stage { case minigame.TopUpPickMember: @@ -492,6 +511,9 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) { if r.Facility.Active { p = r.FacilityProgress() } + if r.Staff.Active { + p = r.StaffProgress() + } by := float32(y + h - 7) vector.DrawFilledRect(screen, 4, by, ScreenW-8, 4, colBar, false) vector.DrawFilledRect(screen, 4, by, float32((ScreenW-8)*p), 4, colKnown, false) @@ -567,6 +589,17 @@ func (s *LevelScene) infoLines(a *Assets) (lines []line, accent color.Color, bar } switch lvl.At(tile) { + case level.Gate: + switch { + case r.Staff.Active: + return []line{plainLine(a.T("hud.staff_busy"), colDim)}, colGood, true + case r.NoExitAt >= 0 && r.Time-r.NoExitAt < deniedShowSec: + return a.wrapLine(plainLine(fmt.Sprintf(a.T("hud.no_exit"), sim.StaffSec), colBad), maxW), colBad, false + case r.NeedsStaff(): + return []line{plainLine(fmt.Sprintf(a.T("hud.staff_prompt"), sim.StaffSec), colWarn)}, colWarn, false + case lvl.OneWayGates && r.StaffUnlocked: + return []line{plainLine(a.T("hud.unlocked_prompt"), colGood)}, colGood, false + } case level.Toilet: if r.Facility.Active { return []line{plainLine(a.T("hud.toilet_busy"), colDim)}, colGood, true diff --git a/internal/game/script.go b/internal/game/script.go index 5a20c52..3476f0a 100644 --- a/internal/game/script.go +++ b/internal/game/script.go @@ -88,11 +88,20 @@ func parseScript(src string) (*script, error) { return nil, fmt.Errorf("script: okänd riktning %q", f[1]) } st.n, _ = strconv.Atoi(f[2]) - case "shot", "pick", "waitphase": + case "shot", "pick", "waitphase", "urge": if len(f) != 2 { return nil, fmt.Errorf("script: '%s TEXT'", f[0]) } st.arg = f[1] + case "push": // håll en riktning N bildrutor oavsett om spelaren kommer någonstans (t.ex. mot en spärr) + if len(f) != 3 { + return nil, fmt.Errorf("script: 'push DIR N'") + } + st.dir = parseDir(f[1]) + if st.dir == sim.None { + return nil, fmt.Errorf("script: okänd riktning %q", f[1]) + } + st.n, _ = strconv.Atoi(f[2]) case "quit", "waittrain", "waitstop", "waitover": default: return nil, fmt.Errorf("script: okänt kommando %q", f[0]) @@ -128,6 +137,7 @@ type trainWaiter interface { RideAtDest() bool LevelOver() bool MiniPick(text string) bool + ForceUrge(name string) bool TrackPhaseName() string } @@ -201,6 +211,17 @@ func (s *script) tick(g *Game, frame *ebiten.Image) error { if tw, ok := g.scene.(trainWaiter); !ok || tw.LevelOver() || s.counter > 60*120 { s.advance() } + case "urge": + if tw, ok := g.scene.(trainWaiter); ok { + tw.ForceUrge(st.arg) + } + s.advance() + case "push": + virtual.held = st.dir + s.counter++ + if s.counter >= st.n { + s.advance() + } case "waitphase": s.counter++ if tw, ok := g.scene.(trainWaiter); !ok || tw.TrackPhaseName() == st.arg || s.counter > 60*60 { diff --git a/internal/level/level.go b/internal/level/level.go index 79edee7..93f3f64 100644 --- a/internal/level/level.go +++ b/internal/level/level.go @@ -267,6 +267,7 @@ type Level struct { MaxMissedTrains int `json:"maxMissedTrains"` // >0: fler missade tåg än så förlorar banan IndividualCards bool `json:"individualCards"` // alla i sällskapet har egna kort (och biljetter) som spärren kräver SeatSelection bool `json:"seatSelection"` // biljettköpet fortsätter med sittplatsval efter sällskapets önskemål + OneWayGates bool `json:"oneWayGates"` // spärren släpper inte ut en igen förrän man åkt; personal vid spärren låser upp (tar tid) FareYen int `json:"fareYen"` // vad spärren drar från kortet (0 = standard) Grid []string `json:"grid,omitempty"` // envåningsbana (kompatibilitet) – blir Floors[0] Floors []Storey `json:"floors,omitempty"` // flervåningsbana; alla våningar lika stora @@ -618,6 +619,32 @@ func (l *Level) Solvable() bool { return false } +// InsideGates är rutorna innanför spärrarna: allt som nås från perrongerna +// utan att passera en spärr. Används av spärrar utan återvändo. +func (l *Level) InsideGates() map[Point]bool { + seen := map[Point]bool{} + var queue []Point + for _, t := range PlatformTiles { + for _, p := range l.Find(t) { + seen[p] = true + queue = append(queue, p) + } + } + for len(queue) > 0 { + p := queue[0] + queue = queue[1:] + for _, n := range l.Neighbors(p) { + t := l.At(n) + if seen[n] || !t.Passable() || t == Gate { + continue + } + seen[n] = true + queue = append(queue, n) + } + } + return seen +} + // UngatedPlatforms listar perronger som går att nå från start utan att // passera någon spärr. Speldesignen kräver att alla perronger ligger // innanför spärrar, så listan ska vara tom för skeppade banor. diff --git a/internal/level/level_test.go b/internal/level/level_test.go index 1b3f130..2e10387 100644 --- a/internal/level/level_test.go +++ b/internal/level/level_test.go @@ -732,3 +732,29 @@ func TestStoryChains(t *testing.T) { t.Errorf("giltig kedja: %v", err) } } + +func TestOneWayGatesAndInside(t *testing.T) { + src := `{"id":"ow","name":{"sv":"x"},"difficulty":1,"destination":{"sv":"x"},"oneWayGates":true, + "grid":["#########","#S.G.P.G#","#########"], + "trains":[{"platform":"P","track":"1","line":{"en":"A"},"destination":{"en":"B"},"correct":true}]}` + l, err := Parse([]byte(src)) + if err != nil { + t.Fatal(err) + } + if !l.OneWayGates { + t.Fatal("flaggan ska läsas") + } + in := l.InsideGates() + want := map[Point]bool{{X: 4, Y: 1}: true, {X: 5, Y: 1}: true, {X: 6, Y: 1}: true} + if len(in) != len(want) { + t.Fatalf("innanför spärrarna: %v", in) + } + for p := range want { + if !in[p] { + t.Errorf("%v ska vara innanför", p) + } + } + if in[Point{X: 3, Y: 1}] || in[Point{X: 1, Y: 1}] { + t.Fatal("spärren och starten är inte innanför") + } +} diff --git a/internal/minigame/minigame_test.go b/internal/minigame/minigame_test.go index 50595dd..7dfeda3 100644 --- a/internal/minigame/minigame_test.go +++ b/internal/minigame/minigame_test.go @@ -409,3 +409,32 @@ func TestRideCrowdLetPass(t *testing.T) { } } } + +func TestPayerPicksSourceAndRefusesPoor(t *testing.T) { + opts := []PayOption{{Cash: true, Yen: 100}, {Name: "Du", Yen: 50}, {Name: "Anna", Member: 1, Yen: 400}, {Name: "Ville", Member: 2, Yen: 0}} + g := NewPayer(300, opts) + if g.Cursor != 2 { + t.Fatalf("markören ska stå på första som har råd, står på %d", g.Cursor) + } + g.Update(Input{MoveX: 1}, 0) // Ville: har inte råd + g.Update(Input{Confirm: true}, 0) + if g.Done() || !g.Refused { + t.Fatal("den som inte har råd ska nekas utan att köpet avslutas") + } + g.Update(Input{MoveX: -1}, 0) + if g.Refused { + t.Fatal("att flytta markören nollställer nekandet") + } + g.Update(Input{Confirm: true}, 0) + if !g.Done() || !g.Success() || g.Chosen == nil || g.Chosen.Name != "Anna" || g.Chosen.Member != 1 { + t.Fatalf("Anna ska betala: %+v", g.Chosen) + } + h := NewPayer(300, opts) + h.Update(Input{Cancel: true}, 0) + if !h.Done() || h.Success() || h.Chosen != nil { + t.Fatal("Esc avbryter utan köp") + } + if NewPayer(1000, opts).Cursor != 0 { + t.Fatal("utan någon som har råd står markören först") + } +} diff --git a/internal/minigame/payer.go b/internal/minigame/payer.go new file mode 100644 index 0000000..5ca9e6d --- /dev/null +++ b/internal/minigame/payer.go @@ -0,0 +1,61 @@ +package minigame + +// Payer är frågan "vem betalar?" vid kiosken när alla i sällskapet har egna +// kort: kontant, spelarens kort eller en följares kort. Pilarna väljer, +// Enter betalar (bara om det räcker), Esc avbryter köpet. Poängen är att +// kunna vara strategisk så att alla har kvar pengar till spärrarna. +type PayOption struct { + Name string // vem (tom för kontant) + Cash bool // kontanter i plånboken + Member int // 0 = spelarens kort, annars följarens index i sällskapet + Yen int // tillgängligt belopp +} + +// Payer är väljaren. +type Payer struct { + base + Price int + Options []PayOption + Cursor int + Chosen *PayOption // vald källa när det lyckats + Refused bool // försökte betala med något som inte räcker +} + +// NewPayer skapar väljaren med markören på första källan som har råd. +func NewPayer(price int, options []PayOption) *Payer { + g := &Payer{Price: price, Options: append([]PayOption(nil), options...)} + for i, o := range g.Options { + if o.Yen >= price { + g.Cursor = i + break + } + } + return g +} + +// CanAfford rapporterar om källan i räcker till priset. +func (g *Payer) CanAfford(i int) bool { return g.Options[i].Yen >= g.Price } + +// Update flyttar markören och tar emot betala/avbryt. +func (g *Payer) Update(in Input, dt float64) { + if g.done || len(g.Options) == 0 { + return + } + if in.Cancel { + g.done = true + return + } + if mv := in.MoveX + in.Move; mv != 0 { + g.Cursor = wrap(g.Cursor+mv, len(g.Options)) + g.Refused = false + } + if in.Confirm { + if !g.CanAfford(g.Cursor) { + g.Refused = true + return + } + o := g.Options[g.Cursor] + g.Chosen = &o + g.done, g.success = true, true + } +} diff --git a/internal/sim/gates_test.go b/internal/sim/gates_test.go new file mode 100644 index 0000000..b75bcd7 --- /dev/null +++ b/internal/sim/gates_test.go @@ -0,0 +1,141 @@ +package sim + +import ( + "testing" + + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/minigame" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng" +) + +// pathTo är testbotens väg till goal: som PathCanEnter, men andra perronger +// används inte som genvägar (på spärrar utan återvändo skulle man fastna). +func pathTo(r *Run, goal level.Point) []level.Point { + return r.Level.Path(r.Player.Tile, goal, func(p level.Point) bool { + return r.PathCanEnter(p) && (!r.Level.At(p).IsPlatform() || p == goal) + }) +} + +// stepUntilStill tar ett steg i riktningen och låter rörelsen bli klar. +func stepUntilStill(r *Run, d Dir) { + r.Step(frame, d, false) + for i := 0; i < 120 && r.Player.Moving(); i++ { + r.Step(frame, None, false) + } +} + +// TestKioskAsksWhoPays: med egna kort och fler i sällskapet frågar kiosken +// vem som betalar; den valda följarens kort dras, inte spelarens. +func TestKioskAsksWhoPays(t *testing.T) { + l := loadLevel(t, "007-akihabara") + if !l.IndividualCards || l.Party < 3 { + t.Fatal("007 ska ha egna kort och sällskap") + } + r := NewRun(l, RunOptions{Seed: rng.FromInt(7)}) + r.NPCs = nil + r.Party.nextEventAt = 1e9 + ville := r.Party.Members[2] + ville.Meters.Drink, ville.Urge = 5, Thirsty + walk(t, r, pathTo(r, l.Find(level.Kiosk)[0])) + card, villeCard := r.Wallet.Card, ville.Card + r.Step(frame, None, true) + g, ok := r.Mini.(*minigame.Payer) + if !ok || r.MiniKind != MiniPayer || len(g.Options) != r.Party.Size()+1 || g.Price != KioskPriceYen { + t.Fatalf("kiosken ska fråga vem som betalar: kind=%v", r.MiniKind) + } + if !g.Options[0].Cash || g.Options[1].Member != 0 || g.Options[3].Name != ville.Name { + t.Fatalf("källorna: %+v", g.Options) + } + for g.Cursor != 3 { + r.StepMini(frame, minigame.Input{MoveX: 1}) + } + r.StepMini(frame, minigame.Input{Confirm: true}) + if r.Mini != nil || ville.Card != villeCard-KioskPriceYen || r.Wallet.Card != card || ville.Meters.Drink != 100 || r.Served != 1 { + t.Fatalf("Villes kort ska betala: ville=%d (var %d) kort=%d served=%d", ville.Card, villeCard, r.Wallet.Card, r.Served) + } + // Ingen som behöver något: ingen fråga. Avbrutet köp: inget dras. + r.Step(frame, None, true) + if r.Mini != nil { + t.Fatal("utan behov ska kiosken inte fråga") + } + anna := r.Party.Members[1] + anna.Meters.Food, anna.Urge, anna.Card = 5, Hungry, 0 + r.Step(frame, None, true) + g = r.Mini.(*minigame.Payer) + for g.Cursor != 2 { // Anna, utan pengar + r.StepMini(frame, minigame.Input{MoveX: 1}) + } + r.StepMini(frame, minigame.Input{Confirm: true}) + if r.Mini == nil || !g.Refused { + t.Fatal("den som inte har råd nekas") + } + r.StepMini(frame, minigame.Input{Cancel: true}) + if r.Mini != nil || anna.Meters.Food > 10 || r.Served != 1 { + t.Fatal("avbrutet köp ska inte dra något") + } + // Kontant betalar. + cash := r.Wallet.Cash + r.Step(frame, None, true) + r.StepMini(frame, minigame.Input{Confirm: true}) // markören står på kontant (Anna har inte råd, kontant står först) + if r.Wallet.Cash != cash-KioskPriceYen || anna.Meters.Food != 100 { + t.Fatalf("kontant: cash=%d (var %d) food=%.0f", r.Wallet.Cash, cash, anna.Meters.Food) + } +} + +// TestOneWayGatesNeedStaff: i Ueno släpper spärren inte ut en som checkat +// in; Enter vid spärren kallar på personal, sedan får man gå ut, och går man +// in igen dras avgiften igen. +func TestOneWayGatesNeedStaff(t *testing.T) { + l := loadLevel(t, "005-ueno") + if !l.OneWayGates { + t.Fatal("005 ska ha spärrar utan återvändo") + } + r := NewRun(l, RunOptions{Seed: rng.FromInt(5)}) + r.NPCs = nil + r.Party.nextEventAt = 1e9 + plat := l.CorrectPlatform() + start := r.Wallet.Card + walk(t, r, pathTo(r, plat)) + if !r.CheckedIn() || !r.NeedsStaff() || r.GatePasses != 1 || r.Wallet.Card != start-l.FareYen { + t.Fatalf("på perrongen ska man vara incheckad och ha betalat en gång: checkedIn=%v passes=%d card=%d", r.CheckedIn(), r.GatePasses, r.Wallet.Card) + } + card := r.Wallet.Card + stepUntilStill(r, Left) // tillbaka på spärren: kostar inget + if !r.inGate() || r.GatePasses != 1 || r.Wallet.Card != card { + t.Fatalf("spärren igen: inGate=%v passes=%d card=%d", r.inGate(), r.GatePasses, r.Wallet.Card) + } + gate := r.Player.Tile + stepUntilStill(r, Left) // ut? nej + if r.Player.Tile != gate || r.NoExitAt < 0 { + t.Fatalf("spärren ska inte släppa ut: tile=%v noExitAt=%.1f", r.Player.Tile, r.NoExitAt) + } + r.Step(frame, None, true) // kalla på personal + if !r.Staff.Active { + t.Fatal("Enter vid spärren ska kalla på personal") + } + for i := 0; i < int(StaffSec*60)+5 && r.Staff.Active; i++ { + r.Step(frame, None, false) + } + if r.Staff.Active || !r.StaffUnlocked || r.StaffCalls != 1 || r.FlashKey != FlashStaff { + t.Fatalf("personalen ska ha låst upp: active=%v unlocked=%v calls=%d", r.Staff.Active, r.StaffUnlocked, r.StaffCalls) + } + stepUntilStill(r, Left) + if r.Player.Tile == gate || r.CheckedIn() || r.StaffUnlocked { + t.Fatalf("ute igen: tile=%v checkedIn=%v unlocked=%v", r.Player.Tile, r.CheckedIn(), r.StaffUnlocked) + } + stepUntilStill(r, Right) // på spärren utifrån: inget dras än + if !r.inGate() || r.CheckedIn() || r.GatePasses != 1 { + t.Fatalf("spärren utifrån: inGate=%v checkedIn=%v passes=%d", r.inGate(), r.CheckedIn(), r.GatePasses) + } + stepUntilStill(r, Right) // in igen: kostar igen + if r.Player.Tile != plat || !r.CheckedIn() || r.GatePasses != 2 || r.Wallet.Card != card-l.FareYen { + t.Fatalf("ny inpassering: tile=%v checkedIn=%v passes=%d card=%d (var %d)", r.Player.Tile, r.CheckedIn(), r.GatePasses, r.Wallet.Card, card) + } + // Utan flaggan är det fritt fram. + l1 := loadLevel(t, "001-flygplatsen") + r1 := NewRun(l1, RunOptions{Seed: rng.FromInt(1)}) + walk(t, r1, l1.Path(r1.Player.Tile, l1.CorrectPlatform(), r1.PathCanEnter)) + if r1.CheckedIn() || r1.NeedsStaff() { + t.Fatal("utan oneWayGates ska ingen vara incheckad") + } +} diff --git a/internal/sim/nav_test.go b/internal/sim/nav_test.go index 57e8c4d..b8b1001 100644 --- a/internal/sim/nav_test.go +++ b/internal/sim/nav_test.go @@ -17,7 +17,7 @@ func boardAt(t *testing.T, r *Run, tile level.Tile) *level.Train { r.NPCs = nil // folket ska inte stå i vägen för testboten r.Party.nextEventAt = 1e9 l := r.Level - path := l.Path(r.Player.Tile, l.Find(tile)[0], r.PathCanEnter) + path := pathTo(r, l.Find(tile)[0]) if path == nil { t.Fatalf("ingen väg till perrong %c", tile) } diff --git a/internal/sim/run.go b/internal/sim/run.go index 5272eeb..fa96418 100644 --- a/internal/sim/run.go +++ b/internal/sim/run.go @@ -47,6 +47,16 @@ type Run struct { timeLimit int // resans tidsgräns (första delbanans), 0 = ingen maxMissed int // resans gräns för missade tåg (första delbanans) + // Spärrar utan återvändo (level.OneWayGates): incheckad släpps inte ut + // förrän personalen låst upp korten. + inside map[level.Point]bool // rutorna innanför spärrarna + checkedIn bool + gateFromIn bool // spärrrutan man står på nåddes inifrån (på väg ut) + StaffUnlocked bool + Staff StaffState + StaffCalls int + NoExitAt float64 // när spärren senast vägrade släppa ut (< 0 = aldrig) + // Språk: ordboken, spelarens kunskap (delas mellan banor) och hjälpen. Dict *lang.Dictionary Know *lang.Knowledge @@ -129,6 +139,7 @@ const ( const ( FlashWomenOnly = "hud.women_only" FlashTransfer = "hud.transfer_done" + FlashStaff = "hud.staff_unlocked" FlashEarly = "hud.early_board" FlashBump = "hud.bump" ) @@ -177,6 +188,7 @@ const ( MiniCrowd MiniRide MiniCarPick + MiniPayer ) // Förlustorsaker (textnycklar). @@ -208,6 +220,18 @@ type FacilityState struct { Count int } +// StaffState är ett pågående personalanrop vid spärren (tar tid, avbryts +// om man går). +type StaffState struct { + Active bool + At level.Point + Remaining float64 + Total float64 +} + +// StaffSec är hur länge man väntar på personalen vid spärren. +const StaffSec = 10.0 + // RunOptions styr en banomgång. type RunOptions struct { Speed float64 // rutor per sekund (0 = DefaultSpeed) @@ -280,6 +304,8 @@ func NewRun(l *level.Level, o RunOptions) *Run { timeLimit: l.TimeLimitSec, maxMissed: l.MaxMissedTrains, TransferAt: -1, + inside: l.InsideGates(), + NoExitAt: -1, Party: NewParty(l.Party, l.SpawnPoint(), o.Seed.Derive("party")), baseSpeed: o.Speed, NoticeAt: -1, @@ -375,6 +401,8 @@ func (r *Run) startLeg(l *level.Level) { 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.inside = l.InsideGates() + r.checkedIn, r.StaffUnlocked, r.gateFromIn, r.Staff = false, false, false, StaffState{} r.parSec += parFor(l) r.TransferAt = r.Time r.flash(FlashTransfer) @@ -418,6 +446,10 @@ func (r *Run) CanEnter(p level.Point) bool { r.DeniedAt = r.Time return false } + if r.NeedsStaff() && r.inGate() && r.gateFromIn && t != level.Gate && !r.inside[p] { + r.NoExitAt = r.Time // incheckad: spärren släpper inte ut en + return false + } for _, n := range r.NPCs { if n.Occupies(p) { r.BumpedAt = r.Time @@ -427,6 +459,48 @@ func (r *Run) CanEnter(p level.Point) bool { return true } +// NeedsStaff rapporterar om man är incheckad i en spärr utan återvändo och +// behöver personalen för att komma ut. +func (r *Run) NeedsStaff() bool { return r.Level.OneWayGates && r.checkedIn && !r.StaffUnlocked } + +// CheckedIn rapporterar om spelaren har checkat in i spärren (och inte åkt). +func (r *Run) CheckedIn() bool { return r.checkedIn } + +// callStaff trycker på knappen vid spärren: efter StaffSec låser personalen +// upp korten så att man får gå ut. +func (r *Run) callStaff() { + if !r.NeedsStaff() || r.Staff.Active { + return + } + r.Staff = StaffState{Active: true, At: r.Player.Tile, Remaining: StaffSec, Total: StaffSec} +} + +func (r *Run) stepStaff(dt float64) { + if !r.Staff.Active { + return + } + if r.Player.Moving() || r.Player.Tile != r.Staff.At { + r.Staff = StaffState{} + return + } + r.Staff.Remaining -= dt + if r.Staff.Remaining > 0 { + return + } + r.Staff = StaffState{} + r.StaffUnlocked = true + r.StaffCalls++ + r.flash(FlashStaff) +} + +// StaffProgress är 0..1 för ett pågående personalanrop. +func (r *Run) StaffProgress() float64 { + if !r.Staff.Active || r.Staff.Total <= 0 { + return 0 + } + return 1 - r.Staff.Remaining/r.Staff.Total +} + func (r *Run) canPassGate() bool { if r.opts.FreeGates { return true // JR Pass: genväg genom alla spärrar @@ -538,11 +612,21 @@ func (r *Run) Step(dt float64, want Dir, board bool) { } r.stepHelp(dt) r.stepFacility(dt) + r.stepStaff(dt) r.stepReading() r.stepNPCs(dt) r.checkLimits() if r.Player.Tile != before && r.inGate() && !wasGate { + r.gateFromIn = r.inside[before] // på väg ut (eller bara på genomresa utifrån) + } + // Avgiften dras när man passerar spärren in i det betalda området, inte + // när man står på spärrrutan: spärrar som ligger i en gång kan passeras + // utifrån utan att det kostar. + if r.Player.Tile != before && r.inside[r.Player.Tile] && !r.inside[before] { r.GatePasses++ + if r.Level.OneWayGates { + r.checkedIn = true // incheckad: spärren släpper inte ut en förrän man åkt + } if !r.Level.TicketRequired && !r.opts.FreeGates { r.Wallet.PayCard(r.Level.FareYen) if r.Level.IndividualCards { @@ -558,8 +642,13 @@ func (r *Run) Step(dt float64, want Dir, board bool) { } } } + if r.Level.OneWayGates && r.Player.Tile != before && wasGate && r.gateFromIn && !r.inside[r.Player.Tile] && !r.inGate() { + r.checkedIn, r.StaffUnlocked = false, false // ute igen (efter personalens upplåsning); ny inpassering kostar igen + } if board && !r.Player.Moving() { switch r.Level.At(r.Player.Tile) { + case level.Gate: + r.callStaff() case level.Toilet: r.useToilet() case level.Kiosk: @@ -791,6 +880,10 @@ func (r *Run) StepMini(dt float64, in minigame.Input) { r.Party.Members[g.Member].Card += g.Amount r.TopUps++ } + case *minigame.Payer: + if g.Success() && g.Chosen != nil { + r.purchaseAtKiosk(r.payFrom(*g.Chosen)) + } case *minigame.Ticket: if !g.Success() || g.Chosen == nil { break @@ -882,10 +975,58 @@ func (r *Run) stepFacility(dt float64) { r.Facility = FacilityState{} } -// buyAtKiosk köper mat och dryck åt alla som behöver, betalat med kortet. -// Med liten seedad otur blir maten dålig: matförgiftning efter banan. +// buyAtKiosk köper mat och dryck åt alla som behöver. Med egna kort och +// fler i sällskapet frågar kiosken först vem som betalar (minigame.Payer), +// annars dras det från spelarens kort. func (r *Run) buyAtKiosk() { - n := r.Party.BuyAtKiosk(r.Wallet.PayCard) + needs := r.Party.NeedsKiosk() + if len(needs) == 0 { + return + } + if r.Level.IndividualCards && r.Party.Size() > 1 { + r.Mini, r.MiniKind = minigame.NewPayer(KioskPriceYen*len(needs), r.payOptions()), MiniPayer + return + } + r.purchaseAtKiosk(r.Wallet.PayCard) +} + +// payOptions är källorna man kan betala kiosken med: kontant, spelarens +// kort och varje följares kort. +func (r *Run) payOptions() []minigame.PayOption { + card := r.Wallet.Card + if !r.Wallet.CardUsable() { + card = 0 + } + opts := []minigame.PayOption{{Cash: true, Yen: r.Wallet.Cash}, {Name: r.Party.Members[0].Name, Member: 0, Yen: card}} + for i, m := range r.Party.Followers() { + opts = append(opts, minigame.PayOption{Name: m.Name, Member: i + 1, Yen: m.Card}) + } + return opts +} + +// payFrom ger betalfunktionen för en vald källa. +func (r *Run) payFrom(o minigame.PayOption) func(int) bool { + switch { + case o.Cash: + return r.Wallet.PayCash + case o.Member == 0: + return r.Wallet.PayCard + } + m := r.Party.Members[o.Member] + return func(yen int) bool { + if m.Card < yen { + return false + } + m.Card -= yen + r.Wallet.Spent += yen + return true + } +} + +// purchaseAtKiosk genomför köpet med en betalfunktion. Med liten seedad +// otur blir maten dålig: matförgiftning efter banan. +func (r *Run) purchaseAtKiosk(pay func(int) bool) { + n := r.Party.BuyAtKiosk(pay) if n == 0 { return } diff --git a/internal/sim/run_test.go b/internal/sim/run_test.go index 5f16f20..5d43141 100644 --- a/internal/sim/run_test.go +++ b/internal/sim/run_test.go @@ -155,6 +155,25 @@ func walk(t *testing.T, r *Run, path []level.Point) { } } +// throughGate kapar en väg vid första rutan innanför spärrarna – där dras +// avgiften (inte på spärrrutan). +func throughGate(l *level.Level, path []level.Point) []level.Point { + in := l.InsideGates() + for i, p := range path { + if in[p] { + return path[:i+1] + } + } + if len(path) > 0 { // vägen slutar på spärren: kliv in på rutan innanför + for _, n := range l.Neighbors(path[len(path)-1]) { + if in[n] { + return append(path, n) + } + } + } + return path +} + // waitForTrain står still tills tåget vid perrongen är inne. func waitForTrain(t *testing.T, r *Run) *level.Train { t.Helper() @@ -698,12 +717,7 @@ func TestPhoneSpeedRescanAndBattery(t *testing.T) { } gate := l.Find(level.Gate)[0] path := l.Path(r.Player.Tile, gate, r.PathCanEnter) - for i, p := range path { // stanna på den första spärrutan – där sker skanningen - if l.At(p) == level.Gate { - path = path[:i+1] - break - } - } + path = throughGate(l, path) // in i det betalda området: där dras avgiften och sker skanningen walk(t, r, path) rescans[r.Rescans]++ if r.Rescans == 1 { @@ -873,12 +887,7 @@ func TestIndividualCardsAtGateAndMachine(t *testing.T) { // Spärren drar från allas kort. before := r.Wallet.Spent path := l1.Path(r.Player.Tile, gate, r.PathCanEnter) - for i, p := range path { - if l1.At(p) == level.Gate { - path = path[:i+1] - break - } - } + path = throughGate(l1, path) // in i det betalda området: där dras avgiften och sker skanningen walk(t, r, path) if anna.Card != 1000-l1.FareYen || r.Wallet.Card != StartCardYen-l1.FareYen || r.Wallet.Spent != before+2*l1.FareYen { t.Fatalf("efter spärren: anna=%d egen=%d spent=%d", anna.Card, r.Wallet.Card, r.Wallet.Spent) @@ -1039,12 +1048,7 @@ func TestRunOptionsFromProgression(t *testing.T) { r.Step(frame, None, false) } path := l.Path(r.Player.Tile, gate, r.PathCanEnter) - for i, p := range path { - if l.At(p) == level.Gate { - path = path[:i+1] - break - } - } + path = throughGate(l, path) // in i det betalda området: där dras avgiften och sker skanningen walk(t, r, path) if r.Wallet.Spent != 0 || r.GatePasses != 1 { t.Fatalf("gratis spärr: spent=%d passes=%d", r.Wallet.Spent, r.GatePasses) diff --git a/resources/data/levels/005-ueno.json b/resources/data/levels/005-ueno.json index 0f02b1f..caf975c 100644 --- a/resources/data/levels/005-ueno.json +++ b/resources/data/levels/005-ueno.json @@ -134,5 +134,6 @@ } ], "station": "ueno", - "to": "akihabara" + "to": "akihabara", + "oneWayGates": true } diff --git a/resources/data/levels/007-akihabara.json b/resources/data/levels/007-akihabara.json index 5bb2739..ba1e5f7 100644 --- a/resources/data/levels/007-akihabara.json +++ b/resources/data/levels/007-akihabara.json @@ -64,9 +64,9 @@ }, { "text": { - "en": "\u003c- Track 3 Hibiya Line for Naka-Meguro Track 4 Hibiya Line for Kita-Senju -\u003e", + "en": "<- Track 3 Hibiya Line for Naka-Meguro Track 4 Hibiya Line for Kita-Senju ->", "ja": "← 3番線 日比谷線 中目黒方面 4番線 日比谷線 北千住方面 →", - "sv": "\u003c- Spår 3 Hibiya-linjen mot Naka-Meguro Spår 4 Hibiya-linjen mot Kita-Senju -\u003e" + "sv": "<- Spår 3 Hibiya-linjen mot Naka-Meguro Spår 4 Hibiya-linjen mot Kita-Senju ->" }, "x": 9, "y": 11, @@ -175,5 +175,6 @@ "terminus": "tokyo", "track": "1" } - ] -} \ No newline at end of file + ], + "oneWayGates": true +} diff --git a/resources/data/levels/007b-ueno.json b/resources/data/levels/007b-ueno.json index 0d4778c..9a8d269 100644 --- a/resources/data/levels/007b-ueno.json +++ b/resources/data/levels/007b-ueno.json @@ -11,23 +11,23 @@ "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###", + "#S.#..<..<..<..<..<..<..<..<..###", + "#..#..>..>..>..>..>..>..>..>..###", + "#<>################<>#######<>###", + "#..#....>..>..>W.#..#..>..#..###", + "#<>#<>##########<>#<>#<>####<>###", + "#..<..#..<..<..#..#..#..<..<..###", + "#..>..#..>..>..#..#..#..>..>..###", + "#######<>#<>#<>#<>#<>#<>#########", + "#..#..<..<..#..#..#..<..#..<..###", + "#..#..>..>..#..#..#..>..#..>..###", + "#<>#<>#######<>#<>#<>#<>####<>###", + "#..#K.<..<..#..#..<.i#.T<..<..###", + "#..#i.>..>..#..#..>..#..>..>..###", + "#<>#<>####<>#<>#<>##########<>###", + "#G.<..<..<..#....>..>..#..GQG>..>..>..>GP###", "#################################", "#################################", "#################################" @@ -56,9 +56,9 @@ }, { "text": { - "en": "\u003c- Track 2 Ginza Line for Shibuya Track 1 Ginza Line for Asakusa -\u003e", + "en": "<- Track 2 Ginza Line for Shibuya Track 1 Ginza Line for Asakusa ->", "ja": "← 2番線 銀座線 渋谷方面 1番線 銀座線 浅草方面 →", - "sv": "\u003c- Spår 2 Ginza-linjen mot Shibuya Spår 1 Ginza-linjen mot Asakusa -\u003e" + "sv": "<- Spår 2 Ginza-linjen mot Shibuya Spår 1 Ginza-linjen mot Asakusa ->" }, "x": 20, "y": 13, @@ -144,5 +144,6 @@ "terminus": "kita-senju", "track": "3" } - ] -} \ No newline at end of file + ], + "oneWayGates": true +} diff --git a/resources/data/levels/008-tokyo-hemresa.json b/resources/data/levels/008-tokyo-hemresa.json index 38fe937..084e3df 100644 --- a/resources/data/levels/008-tokyo-hemresa.json +++ b/resources/data/levels/008-tokyo-hemresa.json @@ -99,9 +99,9 @@ }, { "text": { - "en": "\u003c- Track 5 Yamanote Line for Shinagawa Track 4 Yamanote Line for Ueno -\u003e", + "en": "<- Track 5 Yamanote Line for Shinagawa Track 4 Yamanote Line for Ueno ->", "ja": "← 5番線 山手線 品川方面 4番線 山手線 上野方面 →", - "sv": "\u003c- Spår 5 Yamanote-linjen mot Shinagawa Spår 4 Yamanote-linjen mot Ueno -\u003e" + "sv": "<- Spår 5 Yamanote-linjen mot Shinagawa Spår 4 Yamanote-linjen mot Ueno ->" }, "x": 7, "y": 19, @@ -210,5 +210,6 @@ "terminus": "takao", "track": "1" } - ] -} \ No newline at end of file + ], + "oneWayGates": true +} diff --git a/resources/data/levels/008b-nippori.json b/resources/data/levels/008b-nippori.json index e34a47d..6794b52 100644 --- a/resources/data/levels/008b-nippori.json +++ b/resources/data/levels/008b-nippori.json @@ -57,9 +57,9 @@ }, { "text": { - "en": "\u003c- Track 2 Keisei Line for Keisei-Ueno Track 1 Keisei Line for Narita Airport -\u003e", + "en": "<- Track 2 Keisei Line for Keisei-Ueno Track 1 Keisei Line for Narita Airport ->", "ja": "← 2番線 京成本線 京成上野方面 1番線 京成本線 成田空港方面 →", - "sv": "\u003c- Spår 2 Keisei-linjen mot Keisei-Ueno Spår 1 Keisei-linjen mot Narita flygplats -\u003e" + "sv": "<- Spår 2 Keisei-linjen mot Keisei-Ueno Spår 1 Keisei-linjen mot Narita flygplats ->" }, "x": 13, "y": 15, @@ -146,5 +146,6 @@ "terminus": "ueno", "track": "11" } - ] -} \ No newline at end of file + ], + "oneWayGates": true +} diff --git a/resources/text/en.json b/resources/text/en.json index 26d17b4..4bfea50 100644 --- a/resources/text/en.json +++ b/resources/text/en.json @@ -223,5 +223,16 @@ "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またね" + "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またね", + "mini.payer.title": "Who pays?", + "mini.payer.price": "To pay: %d¥ (food and drink for everyone who needs it)", + "mini.payer.cash": "Cash", + "mini.payer.card": "card", + "mini.payer.short": "Not enough - pick someone else.", + "mini.payer.hint": "Arrows: choose Enter: pay Esc: cancel", + "hud.no_exit": "The gate will not let you out until you have travelled. Enter here calls the staff (%.0f s).", + "hud.staff_prompt": "Enter: call the staff to get out (%.0f s)", + "hud.staff_busy": "Staff on the way...", + "hud.staff_unlocked": "The staff unlocked the cards - you can leave.", + "hud.unlocked_prompt": "Cards unlocked: you can leave. Entering again costs again." } diff --git a/resources/text/sv.json b/resources/text/sv.json index cab3156..57acfc0 100644 --- a/resources/text/sv.json +++ b/resources/text/sv.json @@ -223,5 +223,16 @@ "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またね" + "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またね", + "mini.payer.title": "Vem betalar?", + "mini.payer.price": "Att betala: %d¥ (mat och dryck åt alla som behöver)", + "mini.payer.cash": "Kontant", + "mini.payer.card": "kort", + "mini.payer.short": "Det räcker inte - välj någon annan.", + "mini.payer.hint": "Pilar: välj Enter: betala Esc: avbryt", + "hud.no_exit": "Spärren släpper inte ut dig förrän du åkt. Enter här kallar på personal (%.0f s).", + "hud.staff_prompt": "Enter: kalla på personal för att komma ut (%.0f s)", + "hud.staff_busy": "Personalen kommer...", + "hud.staff_unlocked": "Personalen låste upp korten - du kan gå ut.", + "hud.unlocked_prompt": "Korten är upplåsta: du kan gå ut. Ny inpassering kostar igen." } diff --git a/tools/shots/main.go b/tools/shots/main.go index 01f3a38..5b51364 100644 --- a/tools/shots/main.go +++ b/tools/shots/main.go @@ -130,6 +130,10 @@ func buildShots(levelsDir string) error { if err != nil { return err } + toKiosk7, err := moves(l7, l7.SpawnPoint(), l7.Find(level.Kiosk)[0]) + 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"} @@ -154,13 +158,17 @@ func buildShots(levelsDir string) error { {"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"}}, + {"007-akihabara", toKiosk7 + "; urge Ville; press enter; wait 5; press right; wait 3; shot $OUT/payer.png; quit", []string{"-seed", "7"}}, + {"005-ueno", toPlatform5 + "; move left 1; wait 5; push left 30; wait 3; shot $OUT/no-exit.png; press enter; wait 150; shot $OUT/staff.png; quit", []string{"-seed", "7"}}, } return nil } // moves gör om kortaste vägen mellan två rutor till "move DIR N"-steg. func moves(l *level.Level, from, to level.Point) (string, error) { - path := l.Path(from, to, l.CanEnter) + path := l.Path(from, to, func(p level.Point) bool { // andra perronger är inga genvägar (spärrar utan återvändo) + return l.CanEnter(p) && (!l.At(p).IsPlatform() || p == to) + }) if path == nil { return "", fmt.Errorf("ingen väg %v -> %v i %s", from, to, l.ID) }