M10c: vem betalar i kiosken och spärrar utan återvändo
All checks were successful
release / build-release (push) Successful in 2m7s
All checks were successful
release / build-release (push) Successful in 2m7s
Björns tillägg 2026-10-24. Med egna kort och fler i sällskapet frågar kiosken vem som betalar (minigame.Payer: kontant, spelarens kort eller en följares kort; den som inte har råd nekas, Esc avbryter). Avgiften dras nu när man passerar spärren in i det betalda området (level.InsideGates), inte på spärrrutan. Banflaggan oneWayGates: incheckad släpps inte ut igen förrän man åkt; Enter vid spärren kallar på personal (10 s) som låser upp korten; ny inpassering kostar igen. På i 005, 007/007b och 008/008b. Skriptkommandon urge och push. Docs, wiki och skärmdumpar uppdaterade. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
13
README.md
13
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).
|
||||
|
||||

|
||||
|
||||
@@ -76,6 +78,8 @@ 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,
|
||||
@@ -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
|
||||
|
||||
BIN
doc/img/no-exit.png
Normal file
BIN
doc/img/no-exit.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
BIN
doc/img/payer.png
Normal file
BIN
doc/img/payer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
doc/img/staff.png
Normal file
BIN
doc/img/staff.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -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) |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
61
internal/minigame/payer.go
Normal file
61
internal/minigame/payer.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
141
internal/sim/gates_test.go
Normal file
141
internal/sim/gates_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -134,5 +134,6 @@
|
||||
}
|
||||
],
|
||||
"station": "ueno",
|
||||
"to": "akihabara"
|
||||
"to": "akihabara",
|
||||
"oneWayGates": true
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
],
|
||||
"oneWayGates": true
|
||||
}
|
||||
@@ -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.#..<..<..<..<..<..<..<..<..###",
|
||||
"#..#..>..>..>..>..>..>..>..>..###",
|
||||
"#<>################<>#######<>###",
|
||||
"#..#..<i.<..<..<..#..#..<..#..###",
|
||||
"#..#..>..>..>..>W.#..#..>..#..###",
|
||||
"#<>#<>##########<>#<>#<>####<>###",
|
||||
"#..<..#..<..<..#..#..#..<..<..###",
|
||||
"#..>..#..>..>..#..#..#..>..>..###",
|
||||
"#######<>#<>#<>#<>#<>#<>#########",
|
||||
"#..#..<..<..#..#..#..<..#..<..###",
|
||||
"#..#..>..>..#..#..#..>..#..>..###",
|
||||
"#<>#<>#######<>#<>#<>#<>####<>###",
|
||||
"#..#K.<..<..#..#..<.i#.T<..<..###",
|
||||
"#..#i.>..>..#..#..>..#..>..>..###",
|
||||
"#<>#<>####<>#<>#<>##########<>###",
|
||||
"#G.<..<..<..#..<G.<..<..<..<.G###",
|
||||
"#pG>..>..>..#..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"
|
||||
}
|
||||
]
|
||||
],
|
||||
"oneWayGates": true
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
],
|
||||
"oneWayGates": true
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
],
|
||||
"oneWayGates": true
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user