M3: japanska skyltar – pixelfont, ordbok, kunskap, delvis översättning, hjälp
All checks were successful
release / build-release (push) Successful in 1m42s

- resources/text/fonts: PixelMplus 10/12 px (M+ FONT LICENSE) + licens;
  all text ritas via text/v2 på heltalspositioner, debugfonten är borta
- internal/lang: ordbok (60 ord, validerad), tokenisering med längsta
  matchning, Knowledge (ord + kana, nivå 0–5, hintar), Render (kända ord
  markerade/översatta, okända kvar, kana som romaji), EnglishShare/
  ShowEnglish per svårighet (deterministiskt per bana+position),
  HelpSeconds; datatest kräver att all japanska i banorna finns i ordboken
- sim.Run: AskHelp (H) med väntetid efter nivå, avbryts om man går,
  avslöjar skylten och lär första okända ordet; Knowledge delas mellan banor
- game: skylt- och avgångstavlor renderade genom kunskapen med färgkodning,
  engelsk rad på lätta banor, hjälp-förlopp, "Nytt ord"-notis, HUD visar
  resmålet på japanska från nivå 1 och japanska-nivå; meny med svårighet;
  pixelbaserad radbrytning av färgade textsegment; -know-flagga
- tools/shots: tre skyltbilder (skylt, hjälp, nytt ord) med förkunskap
- docs/README/wiki uppdaterade

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 20:14:36 +02:00
parent 0e10cbf123
commit b67493fddb
40 changed files with 1846 additions and 174 deletions

View File

@@ -10,19 +10,20 @@ Storyn följer några polare på semester i Japan, från landningen på
flygplatsen till avresan. Efter storyn låses ett oändligt läge och New Game+ flygplatsen till avresan. Efter storyn låses ett oändligt läge och New Game+
upp. Hela speldesignen finns i [doc/spelbeskrivning.md](doc/spelbeskrivning.md). upp. Hela speldesignen finns i [doc/spelbeskrivning.md](doc/spelbeskrivning.md).
**Status:** M2 klar (2026-10-23) – de två första banorna går att spela på **Status:** M3 klar (2026-10-23) – skyltarna är på japanska. Ord du kan
riktigt: labyrint, skyltar, spärrar som drar från tågkortet, tåg som kommer lyser gult och översätts, resten står kvar tills du frågar någon om hjälp
och går enligt tidtabell, och vinst eller förlust beroende på vilket tåg du och lär dig ett ord till. Lätta banor har engelska på skyltarna, svåra inte.
kliver på. Nästa är M3: japanska skyltar och språksystemet. Se Nästa är M4: lämmeltåget och mätarna. Se [doc/roadmap.md](doc/roadmap.md).
[doc/roadmap.md](doc/roadmap.md).
![Bana 001 – Flygplatsen](doc/img/level-001.png) ![Skylt på japanska med delvis översättning](doc/img/sign.png)
Så fungerar det: du startar på den gröna dörrmattan och letar dig genom Så fungerar det: du startar på den gröna dörrmattan och letar dig genom
stationen. Skyltar visar vilket spår som går vart. Spärrarna släpper bara stationen. Skyltarna är på japanska. Ord du kan markeras och översätts i
igenom dig om kortet har saldo. På perrongen (gul ledlinje) ser du vilket raden under, okända ord står kvar. Trycker du H frågar du en förbipasserande,
tåg som går, och när. Står tåget inne trycker du Enter för att kliva på. väntar några sekunder och får hela skylten översatt plus ett nytt ord.
Rätt tåg tar dig till resmålet, fel tåg avslutar banan med förlust. Spärrarna släpper bara igenom dig om kortet har saldo. På perrongen (gul
ledlinje) är avgångstavlan också japansk. Står tåget inne trycker du Enter
för att kliva på. Rätt tåg tar dig till resmålet, fel tåg avslutar banan.
![Perrongen – tåget är inne](doc/img/platform.png) ![Perrongen – tåget är inne](doc/img/platform.png)
@@ -100,14 +101,16 @@ metro-puzzle-jp # meny
metro-puzzle-jp -level 001-flygplatsen # hoppa direkt in i en bana metro-puzzle-jp -level 001-flygplatsen # hoppa direkt in i en bana
metro-puzzle-jp -scale 3 # fönsterstorlek (320x180 × skala) metro-puzzle-jp -scale 3 # fönsterstorlek (320x180 × skala)
metro-puzzle-jp -smoke 90 # öppna fönster, avsluta efter 90 bildrutor (smoke-test) metro-puzzle-jp -smoke 90 # öppna fönster, avsluta efter 90 bildrutor (smoke-test)
metro-puzzle-jp -know 改札,番線 # japanska ord du kan från start (tester/skärmdumpar)
metro-puzzle-jp -script "move right 4; shot bild.png; quit" # skriptläge, se internal/game/script.go metro-puzzle-jp -script "move right 4; shot bild.png; quit" # skriptläge, se internal/game/script.go
metro-puzzle-jp -version metro-puzzle-jp -version
``` ```
### Styrning ### Styrning
Pilar eller WASD går, Enter väljer, Esc går tillbaka, R spelar om. Gamepad Pilar eller WASD går, Enter väljer/kliver på, H frågar om hjälp vid en
med standardlayout fungerar: d-pad/vänster spak, A = välj, B = tillbaka. skylt, Esc går tillbaka, R spelar om. Gamepad med standardlayout fungerar:
d-pad/vänster spak, A = välj, B = tillbaka, Y = hjälp.
### Verktyg ### Verktyg
@@ -123,7 +126,8 @@ med standardlayout fungerar: d-pad/vänster spak, A = välj, B = tillbaka.
|---|---|---| |---|---|---|
| **Bygga** | Go ≥ 1.27, gcc, pkgconf, mesa (GL-headers), libx11/libxrandr/libxcursor/libxinerama/libxi/libxxf86vm, alsa-lib | Go ≥ 1.27 | | **Bygga** | Go ≥ 1.27, gcc, pkgconf, mesa (GL-headers), libx11/libxrandr/libxcursor/libxinerama/libxi/libxxf86vm, alsa-lib | Go ≥ 1.27 |
| **Köra** | libc, samt ett skrivbord med OpenGL (mesa) och X11 eller Wayland (XWayland), ALSA/PipeWire för ljud – laddas dynamiskt vid start | Windows 10+ med DirectX 11 eller OpenGL | | **Köra** | libc, samt ett skrivbord med OpenGL (mesa) och X11 eller Wayland (XWayland), ALSA/PipeWire för ljud – laddas dynamiskt vid start | Windows 10+ med DirectX 11 eller OpenGL |
| **Go-moduler** | `github.com/hajimehoshi/ebiten/v2` (spelbibliotek) + dess indirekta beroenden (`purego`, `gomobile`, `x/sys`, `x/sync`, `x/image`) | samma | | **Go-moduler** | `github.com/hajimehoshi/ebiten/v2` (spelbibliotek, inkl. `text/v2` för TTF-rendering) + indirekta beroenden (`purego`, `gomobile`, `go-text/typesetting`, `x/image`, `x/sys`, `x/sync`) | samma |
| **Font** | PixelMplus 10/12 px (M+ FONT LICENSE), ligger i `resources/text/fonts/` | samma |
Release-binären för Linux är dynamiskt länkad bara mot libc; GL/X11 laddas Release-binären för Linux är dynamiskt länkad bara mot libc; GL/X11 laddas
vid körning, så den fungerar på alla vanliga distributioner. vid körning, så den fungerar på alla vanliga distributioner.
@@ -141,7 +145,8 @@ vid körning, så den fungerar på alla vanliga distributioner.
``` ```
cmd/metro-puzzle-jp/ programstart cmd/metro-puzzle-jp/ programstart
internal/game/ Ebitengine: scener, input, rendering, skriptläge (enda paketet som får känna till Ebitengine) internal/game/ Ebitengine: scener, input, rendering, skriptläge (enda paketet som får känna till Ebitengine)
internal/sim/ rutbaserad rörelse, kollision, kamera (ren Go) internal/sim/ rutbaserad rörelse, kollision, kamera, banomgång med tåg/spärrar/hjälp (ren Go)
internal/lang/ japanska: ordbok, kunskap, delvis översättning (ren Go)
internal/level/ banformat, validering, framkomlighet internal/level/ banformat, validering, framkomlighet
internal/res/ hittar och läser resources/ internal/res/ hittar och läser resources/
internal/rng/ seedad slump internal/rng/ seedad slump

View File

@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"log" "log"
"os" "os"
"strings"
"github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2"
@@ -18,6 +19,7 @@ func main() {
scale := flag.Int("scale", 4, "fönsterskala (intern upplösning 320x180 × skala)") scale := flag.Int("scale", 4, "fönsterskala (intern upplösning 320x180 × skala)")
smoke := flag.Int("smoke", 0, "avsluta automatiskt efter N bildrutor (smoke-test)") smoke := flag.Int("smoke", 0, "avsluta automatiskt efter N bildrutor (smoke-test)")
script := flag.String("script", "", "skriptade steg för skärmdumpar/tester, t.ex. \"wait 10; shot menu.png; quit\" (se internal/game/script.go)") script := flag.String("script", "", "skriptade steg för skärmdumpar/tester, t.ex. \"wait 10; shot menu.png; quit\" (se internal/game/script.go)")
know := flag.String("know", "", "japanska ord spelaren kan från start, kommaseparerade (tester/skärmdumpar)")
version := flag.Bool("version", false, "skriv ut version och avsluta") version := flag.Bool("version", false, "skriv ut version och avsluta")
flag.Parse() flag.Parse()
@@ -26,7 +28,7 @@ func main() {
return return
} }
g, err := game.New(game.Options{LevelID: *levelID, SmokeFrames: *smoke, Script: *script}) g, err := game.New(game.Options{LevelID: *levelID, SmokeFrames: *smoke, Script: *script, KnownWords: splitWords(*know)})
if err != nil { if err != nil {
log.Fatalf("kunde inte starta: %v", err) log.Fatalf("kunde inte starta: %v", err)
} }
@@ -41,3 +43,13 @@ func main() {
os.Exit(1) os.Exit(1)
} }
} }
func splitWords(s string) []string {
var out []string
for _, w := range strings.Split(s, ",") {
if w = strings.TrimSpace(w); w != "" {
out = append(out, w)
}
}
return out
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 22 KiB

BIN
doc/img/sign-help.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
doc/img/sign-learned.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -56,6 +56,9 @@ att bygga på Linux som på Windows.
minispel och event-timing att testa deterministiskt. minispel och event-timing att testa deterministiskt.
- **Inga tester kräver display.** Smoke-testet av själva fönstret körs - **Inga tester kräver display.** Smoke-testet av själva fönstret körs
manuellt (eller på en maskin med skärm) med `-smoke`. manuellt (eller på en maskin med skärm) med `-smoke`.
- **Innehållet testas mot ordboken.** Ny japansk text i en bana som inte
finns i `jp-dictionary.json` fäller `go test` – det är så vi garanterar
att skyltsystemet alltid kan översätta allt spelaren kan möta.
### Testnivåer ### Testnivåer
@@ -89,6 +92,12 @@ att bygga på Linux som på Windows.
| `internal/sim` | `TestBotWinsLevel001` | **Simuleringstest M2:** bot går kortaste vägen, passerar spärren (saldo dras en gång), väntar in tåget, kliver på → vinst; Step är no-op efteråt | | `internal/sim` | `TestBotWinsLevel001` | **Simuleringstest M2:** bot går kortaste vägen, passerar spärren (saldo dras en gång), väntar in tåget, kliver på → vinst; Step är no-op efteråt |
| `internal/sim` | `TestBotLosesOnWrongTrain`, `TestBoardOnlyWhenTrainPresent` | Fel tåg → förlust; Enter utan tåg inne gör inget | | `internal/sim` | `TestBotLosesOnWrongTrain`, `TestBoardOnlyWhenTrainPresent` | Fel tåg → förlust; Enter utan tåg inne gör inget |
| `internal/sim` | `TestGateNeedsCardBalance`, `TestSignHereOnlyWhenStill` | Tomt kort stoppas och registreras för HUD; mellan två spärrutor gratis; skylt visas bara stilla | | `internal/sim` | `TestGateNeedsCardBalance`, `TestSignHereOnlyWhenStill` | Tomt kort stoppas och registreras för HUD; mellan två spärrutor gratis; skylt visas bara stilla |
| `internal/lang` | `TestTokenizeLongestMatchAndKinds` | Längsta matchning, ord/okänt/övrigt, `Unknown` pekar ut vad ordboken saknar |
| `internal/lang` | `TestRenderPartialTranslation`, `TestKanaReadingWithoutMeaning`, `TestReadingsShownFromLevel2` | **Samma skylt visar exakt det spelaren kan**: kända ord översatta, okända kvar, kana som romaji när kana kan, läsningar från nivå 2 |
| `internal/lang` | `TestLearnUnlocksKanaAndLevels`, `TestFirstUnknownWord` | Nivåformeln, kana låses upp efter tre kana-ord, vilket ord hjälpen lär ut |
| `internal/lang` | `TestHelpSeconds`, `TestEnglishShareAndDeterminism` | Hjälptiden sjunker med nivån men inte under golvet; engelska-andelen per svårighet (1 = alltid, 5 = aldrig), deterministisk, ~60 % på svårighet 2 |
| `internal/lang` | `TestParseDictionaryValidation`, `TestShippedDictionary`, `TestAllJapaneseInLevelsIsInDictionary` | Ordboken är komplett per post och **täcker all japanska i banorna** |
| `internal/sim` | `TestAskHelpRevealsSignAndTeachesWord`, `TestHelpIsCancelledByWalkingAway`, `TestNoHelpOffSign` | Hjälpen tar rätt tid, avslöjar skylten, lär första okända ordet; avbryts när man går; kräver skylt |
| `internal/archtest` | `TestOnlyPresentationPackagesImportEbitengine` | Beroenderegeln ovan | | `internal/archtest` | `TestOnlyPresentationPackagesImportEbitengine` | Beroenderegeln ovan |
### Planerade tester per milstolpe ### Planerade tester per milstolpe
@@ -97,7 +106,7 @@ att bygga på Linux som på Windows.
|------------------------|----------------------------------------| |------------------------|----------------------------------------|
| ~~M1 Rörelse & kamera~~ ✓ | Kollision mot vägg, rörelse i 4 riktningar, kamera klampas till kartan – finns, se tabellen ovan | | ~~M1 Rörelse & kamera~~ ✓ | Kollision mot vägg, rörelse i 4 riktningar, kamera klampas till kartan – finns, se tabellen ovan |
| ~~M2 Stationen~~ ✓ | Tidtabell, spärr med/utan saldo, vinst/förlust, bot klarar bana 001 headless – finns, se tabellen ovan | | ~~M2 Stationen~~ ✓ | Tidtabell, spärr med/utan saldo, vinst/förlust, bot klarar bana 001 headless – finns, se tabellen ovan |
| M3 Japanska & skyltar | Delvis översättning ger exakt de ord spelaren kan; andel engelska följer svårighetstabellen; ordboken: alla nycklar har kana/romaji/en/sv; alla skyltord i banorna finns i ordboken; hjälptid = f(japanska-nivå) | | ~~M3 Japanska & skyltar~~ ✓ | Delvis översättning, engelska-andel, ordbokens fullständighet och täckning, hjälptid – finns, se tabellen ovan |
| M4 Lämmeltåget | Mätare sjunker med rätt takt; event-frekvens ökar med gruppstorlek; event väljs deterministiskt per seed | | M4 Lämmeltåget | Mätare sjunker med rätt takt; event-frekvens ökar med gruppstorlek; event väljs deterministiskt per seed |
| M5 Minikarta | Upptäckta rutor markeras, återställs per bana; avgångar syns från start | | M5 Minikarta | Upptäckta rutor markeras, återställs per bana; avgångar syns från start |
| M6 Kort/biljetter/minispel | Saldo dras i spärr; tomt kort → automatkrav; biljettminispel: rätt/fel biljett; telefon-skanning misslyckas med given procent per seed; batteri tar bara slut över tröskeltid | | M6 Kort/biljetter/minispel | Saldo dras i spärr; tomt kort → automatkrav; biljettminispel: rätt/fel biljett; telefon-skanning misslyckas med given procent per seed; batteri tar bara slut över tröskeltid |

View File

@@ -32,7 +32,7 @@ brasse-linux01-runnern, inte Pi5:n.
## Milstolpar ## Milstolpar
M0 skelett ✓ → M1 rörelse/kamera ✓ → M2 tåg/spärrar/vinst ✓ → M3 japanska/skyltar M0 skelett ✓ → M1 rörelse/kamera ✓ → M2 tåg/spärrar/vinst ✓ → M3 japanska/skyltar ✓
→ M4 lämmeltåget → M5 minikarta → M6 kort/biljetter/minispel → M7 → M4 lämmeltåget → M5 minikarta → M6 kort/biljetter/minispel → M7
svårighet/poäng/debuffar → M8 progression/sparfiler → M9 navigeringslägen → svårighet/poäng/debuffar → M8 progression/sparfiler → M9 navigeringslägen →
M10 story-innehåll → M11 oändligt läge/NG+ → M12 ljud/polish/release. M10 story-innehåll → M11 oändligt läge/NG+ → M12 ljud/polish/release.
@@ -58,7 +58,8 @@ Vad som ska vara testat per milstolpe: [mal-och-tester.md](mal-och-tester.md).
- ~~Repo-namn~~ – beslutat: `metro-puzzle-jp` (Gitea 2026-10-23). Visningsnamnet "Metro Puzzle JP" är bara text i `resources/text/`. - ~~Repo-namn~~ – beslutat: `metro-puzzle-jp` (Gitea 2026-10-23). Visningsnamnet "Metro Puzzle JP" är bara text i `resources/text/`.
- "Game box-liknande" banval: tolkat som spelbräde/världskarta – stämmer det? - "Game box-liknande" banval: tolkat som spelbräde/världskarta – stämmer det?
- Font: PixelMplus (fler kanji, 10/12 px) eller Misaki (8 px, tajtare)? - ~~Font~~ – beslutat: PixelMplus 10/12 px (M3).
Avgörs i M3 när skyltar renderas på riktigt. - Hur ska spelaren lära sig ord utöver "fråga om hjälp"? Förslag: att klara
en bana lär ut resmålets namn; skill tree i M8 kan ge kana direkt.
- Ska Oändligt läge dela sparfil med story eller ha egen? - Ska Oändligt läge dela sparfil med story eller ha egen?
- Hur många huvudbanor siktar vi på i storyn? (Påverkar M10:s omfattning.) - Hur många huvudbanor siktar vi på i storyn? (Påverkar M10:s omfattning.)

View File

@@ -14,7 +14,7 @@ de styr hur milstolparna byggs.
| Speldata | **JSON** i `resources/data/` | Läsbart, diffbart i git, `encoding/json` i standardbiblioteket. Byt till TOML bara om kommentarer blir ett verkligt behov. | | Speldata | **JSON** i `resources/data/` | Läsbart, diffbart i git, `encoding/json` i standardbiblioteket. Byt till TOML bara om kommentarer blir ett verkligt behov. |
| Bygg | **`go run ./tools/build`** | Ett Go-program i stället för make/bash/PowerShell. Samma kommando på alla plattformar. Stämplar version och läge, kopierar resurser. | | Bygg | **`go run ./tools/build`** | Ett Go-program i stället för make/bash/PowerShell. Samma kommando på alla plattformar. Stämplar version och läge, kopierar resurser. |
| IDE | **VS Code + tillägget `golang.go`** | `.vscode/tasks.json` (bygg/test) och `.vscode/launch.json` (F5 med delve). Tillägget installerar delve åt dig. | | IDE | **VS Code + tillägget `golang.go`** | `.vscode/tasks.json` (bygg/test) och `.vscode/launch.json` (F5 med delve). Tillägget installerar delve åt dig. |
| Font | **PixelMplus** (10/12 px) eller **Misaki** (8 px) via `ebiten/v2/text/v2` | Pixelfonter med kana **och kanji**. Fri licens. Krävs för att skyltar ska kunna visas på riktigt. Beslut i M3. | | Font | **PixelMplus** 10/12 px via `ebiten/v2/text/v2` | Pixelfont med kana **och kanji** (JIS 1+2), M+ FONT LICENSE. Valdes framför Misaki (8 px) för läsbarhetens skull – 10 px kanji går att läsa vid 3–4× skala. |
| Ljud | `ebiten/v2/audio` med OGG Vorbis (musik, jinglar) och WAV (korta effekter) | Ingår i Ebitengine, inga externa bibliotek. | | Ljud | `ebiten/v2/audio` med OGG Vorbis (musik, jinglar) och WAV (korta effekter) | Ingår i Ebitengine, inga externa bibliotek. |
| Slump | `math/rand/v2` (PCG) genom `internal/rng` | Seedbart, deterministiskt, del-seeds per kontext. | | Slump | `math/rand/v2` (PCG) genom `internal/rng` | Seedbart, deterministiskt, del-seeds per kontext. |
| Sparfiler | JSON i `os.UserConfigDir()/metro-puzzle-jp/` | Rätt plats på både Linux (`~/.config`) och Windows (`%AppData%`). | | Sparfiler | JSON i `os.UserConfigDir()/metro-puzzle-jp/` | Rätt plats på både Linux (`~/.config`) och Windows (`%AppData%`). |
@@ -58,7 +58,8 @@ metro-puzzle-jp/
│ ├── res/ hittar och läser resources/ (ren Go) │ ├── res/ hittar och läser resources/ (ren Go)
│ ├── rng/ seedad slump (ren Go) │ ├── rng/ seedad slump (ren Go)
│ ├── level/ banformat, validering, BFS (ren Go) │ ├── level/ banformat, validering, BFS (ren Go)
│ ├── sim/ rutbaserad rörelse, kollision, kamera, banomgång (Run: klocka, kort, spärrar, tåg) – ren Go │ ├── sim/ rutbaserad rörelse, kollision, kamera, banomgång (Run: klocka, kort, spärrar, tåg, hjälp) – ren Go
│ ├── lang/ ordbok, tokenisering, kunskap, delvis översättning, engelska-andel, hjälptid – ren Go
│ ├── game/ Ebitengine-loop, scener, input, rendering, skriptläge ← får använda Ebitengine │ ├── game/ Ebitengine-loop, scener, input, rendering, skriptläge ← får använda Ebitengine
│ ├── archtest/ beroenderegler │ ├── archtest/ beroenderegler
│ └── … kommande: lang/, nav/, score/, save/, minigame/, ui/, audio/ │ └── … kommande: lang/, nav/, score/, save/, minigame/, ui/, audio/
@@ -68,7 +69,7 @@ metro-puzzle-jp/
├── resources/ kopieras bredvid binären vid bygge ├── resources/ kopieras bredvid binären vid bygge
│ ├── sprites/ bilder, tilesets, UI │ ├── sprites/ bilder, tilesets, UI
│ ├── sound/ OGG/WAV │ ├── sound/ OGG/WAV
│ ├── text/ sv.json, en.json, jp-dictionary.json, fonts/ │ ├── text/ sv.json, en.json, jp-dictionary.json, fonts/PixelMplus*.ttf
│ └── data/ levels/, items, skills (JSON) │ └── data/ levels/, items, skills (JSON)
├── doc/ denna dokumentation (doc/img/ = skärmdumpar från tools/shots) ├── doc/ denna dokumentation (doc/img/ = skärmdumpar från tools/shots)
├── .gitea/workflows/ CI: test + release-zippar för linux/windows på brasse-linux01-runnern ├── .gitea/workflows/ CI: test + release-zippar för linux/windows på brasse-linux01-runnern
@@ -162,20 +163,40 @@ på **brasse-linux01-runnern** (label `linux-amd64`), aldrig på Pi5:n.
- **Klar när:** `TestBotWinsLevel001` låter en bot gå kortaste vägen, - **Klar när:** `TestBotWinsLevel001` låter en bot gå kortaste vägen,
passera spärren, vänta in tåget och kliva på – headless. ✓ passera spärren, vänta in tåget och kliva på – headless. ✓
### M3 – Japanska och skyltsystemet ### M3 – Japanska och skyltsystemet ✓ (klar 2026-10-23)
- **Mål:** spelets identitet – skyltar på japanska som man lär sig läsa. - **Mål:** spelets identitet – skyltar på japanska som man lär sig läsa.
- Pixelfont med kana + kanji (beslut PixelMplus/Misaki, licens in i repot). - **Font:** PixelMplus 10/12 px (M+ FONT LICENSE, i repot under
- Kunskapsmodell: vilka tecken/ord spelaren kan. Skyltar renderas med `resources/text/fonts/`), ritad med `text/v2` på heltalspositioner. All
delvis översättning, egen färg för det som är översatt, interaktion för text i spelet går via den; debugfonten är borta.
att se översättning. - **Ordbok** (`resources/text/jp-dictionary.json`, 60 ord): japanska →
- Andel engelska på skyltar styrs av banans svårighet. kana, romaji, sv, en, tier. Ett test kräver att all japanska i banorna
- "Fråga en japan": NPC-interaktion med väntetid beroende på japanska-nivå. (skyltar, namn, resmål, linjer) finns i ordboken.
- Hintar i UI/bana skalar med japanska-nivå. - **Kunskapsmodell** (`lang.Knowledge`): kända ord + kana-läsning.
- **Teknik:** `internal/lang` (ordbok, kunskap, översättningslogik – ren Nivå 0–5 = kända ord / 4. Kana låses upp efter tre kända kana-ord.
Go), `text/v2` i `internal/game`. - **Rendering** (`lang.Render`): skylten tokeniseras med längsta matchning;
kända ord markeras i gult i originalet och översätts i raden under,
okända står kvar på japanska i grått, kana-ord som kan läsas men inte
förstås visas som romaji i grönt. Avgångstavlan på perrongen är en skylt
som alla andra.
- **Engelska efter svårighet** (`lang.ShowEnglish`): andel skyltar med
engelsk rad 100/60/30/10/0 % för svårighet 1–5, deterministiskt per
bana + position.
- **Fråga någon om hjälp** (H): tar `lang.HelpSeconds(nivå)` sekunder
(6 s på nivå 0, ned till 2 s), avbryts om man går, avslöjar hela skylten
och lär spelaren det första okända ordet på den. Notis "Nytt ord".
- **Hintar efter nivå:** från nivå 1 visar HUD:en resmålet på japanska (att
matcha mot skyltarna), från nivå 2 visas läsningen efter kända ord.
- Kunskapen följer med mellan banor under en session (`Game.Know`); att
spara den på disk är M8. Flaggan `-know ord,ord` ger förkunskap för
tester och skärmdumpar.
- **Teknik:** `internal/lang` (ren Go, 11 tester + datatester), `sim.Run`
(hjälp-tillstånd, avslöjade skyltar), `internal/game/text.go`
(pixelbaserad radbrytning av färgade textsegment).
- **Klar när:** samma skylt visar olika mycket beroende på kunskap, och det - **Klar när:** samma skylt visar olika mycket beroende på kunskap, och det
är testat; bana 001 spelas med japanska skyltar. är testat (`TestRenderPartialTranslation`, `TestKanaReadingWithoutMeaning`,
`TestAskHelpRevealsSignAndTeachesWord`); bana 001 spelas med japanska
skyltar. ✓
### M4 – Lämmeltåget och mätarna ### M4 – Lämmeltåget och mätarna

4
go.mod
View File

@@ -8,6 +8,10 @@ require (
github.com/ebitengine/gomobile v0.0.0-20260820040257-d11f821a26a6 // indirect github.com/ebitengine/gomobile v0.0.0-20260820040257-d11f821a26a6 // indirect
github.com/ebitengine/hideconsole v1.0.0 // indirect github.com/ebitengine/hideconsole v1.0.0 // indirect
github.com/ebitengine/purego v0.11.0 // indirect github.com/ebitengine/purego v0.11.0 // indirect
github.com/go-text/typesetting v0.3.5 // indirect
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
golang.org/x/image v0.45.0 // indirect
golang.org/x/sync v0.22.0 // indirect golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
) )

12
go.sum
View File

@@ -4,11 +4,23 @@ github.com/ebitengine/hideconsole v1.0.0 h1:5J4U0kXF+pv/DhiXt5/lTz0eO5ogJ1iXb8Yj
github.com/ebitengine/hideconsole v1.0.0/go.mod h1:hTTBTvVYWKBuxPr7peweneWdkUwEuHuB3C1R/ielR1A= github.com/ebitengine/hideconsole v1.0.0/go.mod h1:hTTBTvVYWKBuxPr7peweneWdkUwEuHuB3C1R/ielR1A=
github.com/ebitengine/purego v0.11.0 h1:jhp/D+Nyv7UUW8HAcmcjt2N2rYrYi9m3SL21k0Ua/NI= github.com/ebitengine/purego v0.11.0 h1:jhp/D+Nyv7UUW8HAcmcjt2N2rYrYi9m3SL21k0Ua/NI=
github.com/ebitengine/purego v0.11.0/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ= github.com/ebitengine/purego v0.11.0/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
github.com/go-text/typesetting v0.3.5 h1:XZPUooClHY0Vf/rFyUyuPRNEkawARaFzLMQcXLSEyPk=
github.com/go-text/typesetting v0.3.5/go.mod h1:XZO1hD+nQVyvVa5IicQk7FsCa4PFQaJ2soWAP1f//68=
github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc h1:8FGo2It5K75XkavhTiCKExUfVaVDS1feBnLCru5qeoY=
github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
github.com/hajimehoshi/bitmapfont/v4 v4.2.0 h1:bpeN2ph7aHSuX7yoWFkLPo4ado9K+E+Dq7t6MoFcJAU=
github.com/hajimehoshi/bitmapfont/v4 v4.2.0/go.mod h1:AIeoQek1nxUmDczXNtFEYWTQ4ArINijutPD729q/NeE=
github.com/hajimehoshi/ebiten/v2 v2.10.5 h1:D6XuOFhzgkvfqeA6Xwwvcz54uj6HHPNC9Y+0pE6Qfs4= github.com/hajimehoshi/ebiten/v2 v2.10.5 h1:D6XuOFhzgkvfqeA6Xwwvcz54uj6HHPNC9Y+0pE6Qfs4=
github.com/hajimehoshi/ebiten/v2 v2.10.5/go.mod h1:47QNgyS/y2ZRkjVUvlGLx8a+F7MSjcn8/GsjcCZ9Rc8= github.com/hajimehoshi/ebiten/v2 v2.10.5/go.mod h1:47QNgyS/y2ZRkjVUvlGLx8a+F7MSjcn8/GsjcCZ9Rc8=
github.com/pierrec/lz4/v4 v4.1.29 h1:CDQY6qZOLI4DW0Nx6R1vRrifrCeQHnNXkMb0hZWXFjg=
github.com/pierrec/lz4/v4 v4.1.29/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0= golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4= golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=

View File

@@ -1,16 +1,26 @@
package game package game
import ( import (
"bytes"
"fmt" "fmt"
"image" "image"
"image/color"
"github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/text/v2"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
) )
// UILang är UI-språket (sv/en) – blir en inställning i M12.
const UILang = "sv"
// Radhöjd för 10-pixelsfonten.
const LineH = 12
// Ordningen på rutorna i resources/sprites/tiles.png (måste matcha tools/genart). // Ordningen på rutorna i resources/sprites/tiles.png (måste matcha tools/genart).
var tileOrder = []level.Tile{ var tileOrder = []level.Tile{
level.Wall, level.Floor, level.Spawn, level.Platform, level.Gate, level.TicketMachine, level.Sign, level.Wall, level.Floor, level.Spawn, level.Platform, level.Gate, level.TicketMachine, level.Sign,
@@ -24,6 +34,9 @@ type Assets struct {
Train *ebiten.Image // tågvagn, ritas vid perrongen när tåget är inne Train *ebiten.Image // tågvagn, ritas vid perrongen när tåget är inne
Player [4][3]*ebiten.Image // [riktning: ner, upp, vänster, höger][bildruta: stilla, gå1, gå2] Player [4][3]*ebiten.Image // [riktning: ner, upp, vänster, höger][bildruta: stilla, gå1, gå2]
Text map[string]string Text map[string]string
Dict *lang.Dictionary
Font *text.GoTextFace // PixelMplus 10 px – UI och skyltar
FontBig *text.GoTextFace // PixelMplus 12 px – rubriker
} }
// LoadAssets läser grafik och texter. // LoadAssets läser grafik och texter.
@@ -61,12 +74,62 @@ func LoadAssets() (*Assets, error) {
} }
} }
if err := res.LoadJSON(&a.Text, "text", "sv.json"); err != nil { if err := res.LoadJSON(&a.Text, UILang+".json"); err != nil {
if err := res.LoadJSON(&a.Text, "text", UILang+".json"); err != nil {
return nil, err
}
}
dict, err := res.ReadFile("text", "jp-dictionary.json")
if err != nil {
return nil, err
}
if a.Dict, err = lang.ParseDictionary(dict); err != nil {
return nil, err
}
if a.Font, err = loadFont("PixelMplus10-Regular.ttf", 10); err != nil {
return nil, err
}
if a.FontBig, err = loadFont("PixelMplus12-Regular.ttf", 12); err != nil {
return nil, err return nil, err
} }
return a, nil return a, nil
} }
func loadFont(name string, size float64) (*text.GoTextFace, error) {
b, err := res.ReadFile("text", "fonts", name)
if err != nil {
return nil, err
}
src, err := text.NewGoTextFaceSource(bytes.NewReader(b))
if err != nil {
return nil, fmt.Errorf("font %s: %w", name, err)
}
return &text.GoTextFace{Source: src, Size: size}, nil
}
// Draw ritar en rad text med 10-pixelsfonten; (x, y) är övre vänstra hörnet.
// Heltalspositioner ger pixelskarpa glyfer.
func (a *Assets) Draw(dst *ebiten.Image, s string, x, y int, col color.Color) {
a.drawFace(dst, a.Font, s, x, y, col)
}
// DrawBig ritar med 12-pixelsfonten.
func (a *Assets) DrawBig(dst *ebiten.Image, s string, x, y int, col color.Color) {
a.drawFace(dst, a.FontBig, s, x, y, col)
}
func (a *Assets) drawFace(dst *ebiten.Image, face *text.GoTextFace, s string, x, y int, col color.Color) {
op := &text.DrawOptions{}
op.GeoM.Translate(float64(x), float64(y))
op.ColorScale.ScaleWithColor(col)
text.Draw(dst, s, face, op)
}
// Width är textens bredd i pixlar med 10-pixelsfonten.
func (a *Assets) Width(s string) int { return int(text.Advance(s, a.Font)) }
// T slår upp en UI-text; saknas nyckeln visas nyckeln själv så att det syns. // T slår upp en UI-text; saknas nyckeln visas nyckeln själv så att det syns.
func (a *Assets) T(key string) string { func (a *Assets) T(key string) string {
if s, ok := a.Text[key]; ok { if s, ok := a.Text[key]; ok {

View File

@@ -6,6 +6,8 @@ package game
import ( import (
"github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang"
) )
// Intern upplösning. Allt ritas i denna storlek och skalas sedan upp med // Intern upplösning. Allt ritas i denna storlek och skalas sedan upp med
@@ -26,6 +28,7 @@ type Scene interface {
// Game är Ebitengine-tillståndet för hela programmet. // Game är Ebitengine-tillståndet för hela programmet.
type Game struct { type Game struct {
Assets *Assets Assets *Assets
Know *lang.Knowledge // spelarens japanska – följer med mellan banor (sparas i M8)
scene Scene scene Scene
next Scene next Scene
smokeLeft int // >0: avsluta automatiskt efter så många uppdateringar smokeLeft int // >0: avsluta automatiskt efter så många uppdateringar
@@ -38,6 +41,7 @@ type Options struct {
LevelID string // hoppa direkt in i en bana (tom = meny) LevelID string // hoppa direkt in i en bana (tom = meny)
SmokeFrames int // >0 avslutar efter N uppdateringar (smoke-test) SmokeFrames int // >0 avslutar efter N uppdateringar (smoke-test)
Script string // skriptade steg för skärmdumpar/tester, se script.go Script string // skriptade steg för skärmdumpar/tester, se script.go
KnownWords []string // ord spelaren kan från start (för tester/skärmdumpar)
} }
// New laddar resurser och bygger spelet. // New laddar resurser och bygger spelet.
@@ -46,14 +50,17 @@ func New(opt Options) (*Game, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
g := &Game{Assets: a, smokeLeft: opt.SmokeFrames, frame: ebiten.NewImage(ScreenW, ScreenH)} g := &Game{Assets: a, Know: lang.NewKnowledge(), smokeLeft: opt.SmokeFrames, frame: ebiten.NewImage(ScreenW, ScreenH)}
for _, w := range opt.KnownWords {
g.Know.Learn(w)
}
if opt.Script != "" { if opt.Script != "" {
if g.script, err = parseScript(opt.Script); err != nil { if g.script, err = parseScript(opt.Script); err != nil {
return nil, err return nil, err
} }
} }
if opt.LevelID != "" { if opt.LevelID != "" {
s, err := NewLevelScene(a, opt.LevelID) s, err := NewLevelScene(a, opt.LevelID, g.Know)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -71,6 +71,11 @@ func backJustPressed() bool {
return virtual.pressed["esc"] || inpututil.IsKeyJustPressed(ebiten.KeyEscape) || gamepadJustPressed(ebiten.StandardGamepadButtonRightRight) return virtual.pressed["esc"] || inpututil.IsKeyJustPressed(ebiten.KeyEscape) || gamepadJustPressed(ebiten.StandardGamepadButtonRightRight)
} }
// helpJustPressed: H eller gamepadens övre knapp (Y/triangel) = fråga om hjälp.
func helpJustPressed() bool {
return virtual.pressed["h"] || inpututil.IsKeyJustPressed(ebiten.KeyH) || gamepadJustPressed(ebiten.StandardGamepadButtonRightTop)
}
// menuMove returnerar -1/+1 när användaren just tryckte upp/ner i en meny. // menuMove returnerar -1/+1 när användaren just tryckte upp/ner i en meny.
func menuMove() int { func menuMove() int {
switch { switch {

View File

@@ -7,9 +7,9 @@ import (
"path/filepath" "path/filepath"
"github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
"github.com/hajimehoshi/ebiten/v2/vector" "github.com/hajimehoshi/ebiten/v2/vector"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
) )
@@ -17,8 +17,11 @@ import (
// Spelarens grundfart i rutor per sekund (blir en stat i M7). // Spelarens grundfart i rutor per sekund (blir en stat i M7).
const playerSpeed = 6.0 const playerSpeed = 6.0
// Så länge "spärren nekade" visas efter försöket. // Hur länge tillfälliga meddelanden visas.
const deniedShowSec = 2.0 const (
deniedShowSec = 2.0
learnedShowSec = 3.0
)
// LevelScene är själva spelandet av en bana. All spellogik ligger i sim.Run; // LevelScene är själva spelandet av en bana. All spellogik ligger i sim.Run;
// scenen översätter input och ritar. // scenen översätter input och ritar.
@@ -29,15 +32,16 @@ type LevelScene struct {
animT float64 animT float64
} }
// NewLevelScene laddar banan och ställer spelaren på startrutan. // NewLevelScene laddar banan och ställer spelaren på startrutan. know är
func NewLevelScene(a *Assets, id string) (*LevelScene, error) { // spelarens japanska, som delas mellan banor.
func NewLevelScene(a *Assets, id string, know *lang.Knowledge) (*LevelScene, error) {
lvl, err := level.LoadFile(filepath.Join(a.LevelsDir, id+".json")) lvl, err := level.LoadFile(filepath.Join(a.LevelsDir, id+".json"))
if err != nil { if err != nil {
return nil, err return nil, err
} }
s := &LevelScene{ s := &LevelScene{
id: id, id: id,
run: sim.NewRun(lvl, playerSpeed), run: sim.NewRun(lvl, playerSpeed, a.Dict, know),
cam: sim.NewCamera(ScreenW, ScreenH, float64(lvl.Width()*TileSize), float64(lvl.Height()*TileSize)), cam: sim.NewCamera(ScreenW, ScreenH, float64(lvl.Width()*TileSize), float64(lvl.Height()*TileSize)),
} }
s.followPlayer() s.followPlayer()
@@ -67,6 +71,9 @@ func (s *LevelScene) Update(g *Game) error {
g.SetScene(m) g.SetScene(m)
return nil return nil
} }
if helpJustPressed() {
s.run.AskHelp()
}
s.run.Step(dt, dirInput(), confirmJustPressed()) s.run.Step(dt, dirInput(), confirmJustPressed())
if s.run.Player.Moving() { if s.run.Player.Moving() {
s.animT += dt s.animT += dt
@@ -132,51 +139,109 @@ func (s *LevelScene) Draw(g *Game, screen *ebiten.Image) {
s.drawHUD(g, screen) s.drawHUD(g, screen)
} }
// drawHUD ritar den övre raden (bana, resmål, tid, kort, japanska-nivå) och
// inforutan (perrong, skylt, nekad spärr, nytt ord).
func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) { func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
r := s.run r := s.run
a := g.Assets a := g.Assets
vector.DrawFilledRect(screen, 0, 0, ScreenW, 24, color.RGBA{24, 20, 37, 200}, false) vector.DrawFilledRect(screen, 0, 0, ScreenW, 24, color.RGBA{24, 20, 37, 200}, false)
// TODO(M3): visa japanska när en font med kana/kanji finns – debugfonten saknar dem.
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s %s: %s", r.Level.Name.Get("sv"), a.T("hud.destination"),
r.Level.Destination.Get("romaji")), 4, 1)
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s %3.0fs %s %d %s %d¥ %s", a.T("hud.time"), r.Time,
a.T("hud.steps"), r.Steps, a.T("hud.card"), r.Card.Balance, a.T("hud.controls")), 4, 12)
// Inforutan: perronginfo, skylt eller nekad spärr. Visas inte när banan är avgjord. dest := r.Level.Destination.Get("romaji")
if r.Know.ShowsDestinationJP() { // hint från nivå 1: resmålet på japanska, att matcha mot skyltarna
dest += " " + r.Level.Destination.Get("ja")
}
a.Draw(screen, r.Level.Name.Get(UILang), 4, 0, colText)
a.Draw(screen, a.T("hud.destination")+": "+dest, 4+a.Width(r.Level.Name.Get(UILang))+12, 0, colKnown)
a.Draw(screen, fmt.Sprintf("%s %3.0fs %s %d %s %d¥ %s Lv%d·%d %s", a.T("hud.time"), r.Time,
a.T("hud.steps"), r.Steps, a.T("hud.card"), r.Card.Balance, a.T("hud.japanese"), r.Know.Level(), r.Know.Count(),
a.T("hud.controls")), 4, LineH, colDim)
if r.Outcome != sim.Ongoing { if r.Outcome != sim.Ongoing {
return return
} }
var lines []string lines, accent, bar := s.infoLines(a)
accent := color.RGBA{58, 68, 102, 255} if entry := a.Dict.Lookup(r.Learned); entry != nil && r.Time-r.LearnedAt < learnedShowSec {
if tr, at, secs := r.PlatformHere(); tr != nil { lines = append(lines, plainLine(fmt.Sprintf("%s: %s = %s (%s)", a.T("hud.learned"), r.Learned,
lines = append(lines, fmt.Sprintf("%s %s %s -> %s", a.T("hud.track"), tr.Track, tr.Line.Get("romaji"), tr.Destination.Get("romaji"))) entry.Translation(UILang), entry.Romaji), colKnown))
if at {
accent = color.RGBA{99, 199, 77, 255}
lines = append(lines, fmt.Sprintf("%s %.0f s %s", a.T("hud.departs_in"), math.Ceil(secs), a.T("hud.board")))
} else {
accent = color.RGBA{254, 174, 52, 255}
lines = append(lines, fmt.Sprintf("%s %.0f s", a.T("hud.next_train_in"), math.Ceil(secs)))
}
} else if sg := r.SignHere(); sg != nil {
lines = wrap(a.T("hud.sign")+": "+sg.Text.Get("sv"), textCols)
} else if r.DeniedAt >= 0 && r.Time-r.DeniedAt < deniedShowSec {
accent = color.RGBA{228, 59, 68, 255}
lines = []string{a.T("hud.gate_denied")}
} }
if len(lines) == 0 { if len(lines) == 0 {
return return
} }
h := float32(8 + 12*len(lines)) h := 8 + LineH*len(lines)
if bar {
h += 8
}
// Rutan ligger i nederkant, men flyttas upp under HUD:en om spelaren // Rutan ligger i nederkant, men flyttas upp under HUD:en om spelaren
// (eller tåget under hen) annars skulle hamna bakom den. // (eller tåget under hen) annars skulle hamna bakom den.
_, py := r.Player.WorldPos(TileSize) _, py := r.Player.WorldPos(TileSize)
y := float32(ScreenH) - h y := ScreenH - h
if float32(py-s.cam.Y)+2*TileSize > y { if int(py-s.cam.Y)+2*TileSize > y {
y = 24 y = 24
} }
vector.DrawFilledRect(screen, 0, y, ScreenW, h, color.RGBA{24, 20, 37, 230}, false) vector.DrawFilledRect(screen, 0, float32(y), ScreenW, float32(h), colPanel, false)
vector.DrawFilledRect(screen, 0, y, ScreenW, 2, accent, false) vector.DrawFilledRect(screen, 0, float32(y), ScreenW, 2, accent, false)
for i, l := range lines { a.drawLines(screen, lines, 4, y+4)
ebitenutil.DebugPrintAt(screen, l, 4, int(y)+4+12*i) if bar {
by := float32(y + h - 7)
vector.DrawFilledRect(screen, 4, by, ScreenW-8, 4, colBar, false)
vector.DrawFilledRect(screen, 4, by, float32((ScreenW-8)*r.HelpProgress()), 4, colKnown, false)
} }
} }
// infoLines bygger inforutans rader: perronginfo, skylt eller nekad spärr.
func (s *LevelScene) infoLines(a *Assets) (lines []line, accent color.Color, bar bool) {
r := s.run
lvl := r.Level
tile := r.Player.Tile
accent = colBar
maxW := ScreenW - 8
english := lang.ShowEnglish(lvl.ID, tile.X, tile.Y, lvl.Difficulty)
if tr, at, secs := r.PlatformHere(); tr != nil {
// Avgångstavlan är en skylt som alla andra: japanska, sedd genom kunskapen.
ja := fmt.Sprintf("%s%s %s %s%s", tr.Track, a.T("hud.track_suffix_ja"), tr.Line.Get("ja"), tr.Destination.Get("ja"), a.T("hud.for_ja"))
rd := lang.Render(a.Dict, r.Know, ja, UILang)
lines = append(lines, a.wrapLine(shownLine(rd.Original, colText, r.Know.ShowsReadings()), maxW)...)
lines = append(lines, a.wrapLine(shownLine(rd.Partial, colText, false), maxW)...)
if english {
lines = append(lines, a.wrapLine(plainLine(fmt.Sprintf("%s: Track %s %s for %s", a.T("hud.english"), tr.Track,
tr.Line.Get("en"), tr.Destination.Get("en")), colEnglish), maxW)...)
}
if at {
accent = colGood
lines = append(lines, plainLine(fmt.Sprintf("%s %.0f s %s", a.T("hud.departs_in"), math.Ceil(secs), a.T("hud.board")), colGood))
} else {
accent = colWarn
lines = append(lines, plainLine(fmt.Sprintf("%s %.0f s", a.T("hud.next_train_in"), math.Ceil(secs)), colWarn))
}
return lines, accent, false
}
if sg := r.SignHere(); sg != nil {
know := r.Know
revealed := r.SignRevealed(tile)
if revealed {
know = nil // allt känt
accent = colKnown
}
rd := lang.Render(a.Dict, know, sg.Text["ja"], UILang)
lines = append(lines, a.wrapLine(shownLine(rd.Original, colText, r.Know.ShowsReadings()), maxW)...)
lines = append(lines, a.wrapLine(shownLine(rd.Partial, colText, false), maxW)...)
if english {
lines = append(lines, a.wrapLine(plainLine(a.T("hud.english")+": "+sg.Text.Get("en"), colEnglish), maxW)...)
}
switch {
case r.Help.Active:
lines = append(lines, plainLine(a.T("hud.help_progress"), colDim))
bar = true
case !revealed:
lines = append(lines, plainLine(fmt.Sprintf(a.T("hud.help_prompt"), lang.HelpSeconds(r.Know.Level())), colDim))
}
return lines, accent, bar
}
if r.DeniedAt >= 0 && r.Time-r.DeniedAt < deniedShowSec {
return []line{plainLine(a.T("hud.gate_denied"), colBad)}, colBad, false
}
return nil, accent, false
}

View File

@@ -4,9 +4,9 @@ import (
"fmt" "fmt"
"image/color" "image/color"
"path/filepath" "path/filepath"
"strings"
"github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
"github.com/hajimehoshi/ebiten/v2/vector" "github.com/hajimehoshi/ebiten/v2/vector"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/buildinfo" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/buildinfo"
@@ -17,6 +17,7 @@ type menuEntry struct {
id string id string
name string name string
dest string dest string
stars string
} }
// MenuScene listar banorna och låter spelaren välja. // MenuScene listar banorna och låter spelaren välja.
@@ -39,7 +40,12 @@ func NewMenuScene(a *Assets) (*MenuScene, error) {
m.err = err m.err = err
continue continue
} }
m.entries = append(m.entries, menuEntry{id: id, name: l.Name.Get("sv"), dest: l.Destination.Get("romaji")}) // TODO(M3): japanska när fonten finns m.entries = append(m.entries, menuEntry{
id: id,
name: l.Name.Get(UILang) + " " + l.Name.Get("ja"),
dest: l.Destination.Get("ja") + " " + l.Destination.Get("romaji"),
stars: strings.Repeat("★", l.Difficulty) + strings.Repeat("☆", 5-l.Difficulty),
})
} }
return m, nil return m, nil
} }
@@ -51,7 +57,7 @@ func (m *MenuScene) Update(g *Game) error {
if n := len(m.entries); n > 0 { if n := len(m.entries); n > 0 {
m.sel = (m.sel + menuMove() + n) % n m.sel = (m.sel + menuMove() + n) % n
if confirmJustPressed() { if confirmJustPressed() {
s, err := NewLevelScene(g.Assets, m.entries[m.sel].id) s, err := NewLevelScene(g.Assets, m.entries[m.sel].id, g.Know)
if err != nil { if err != nil {
m.err = err m.err = err
return nil return nil
@@ -63,21 +69,25 @@ func (m *MenuScene) Update(g *Game) error {
} }
func (m *MenuScene) Draw(g *Game, screen *ebiten.Image) { func (m *MenuScene) Draw(g *Game, screen *ebiten.Image) {
a := g.Assets
screen.Fill(color.RGBA{24, 20, 37, 255}) screen.Fill(color.RGBA{24, 20, 37, 255})
vector.DrawFilledRect(screen, 0, 0, ScreenW, 22, color.RGBA{38, 43, 68, 255}, false) vector.DrawFilledRect(screen, 0, 0, ScreenW, 22, color.RGBA{38, 43, 68, 255}, false)
ebitenutil.DebugPrintAt(screen, g.Assets.T("game.title"), 8, 4) a.DrawBig(screen, a.T("game.title"), 8, 5, colText)
ebitenutil.DebugPrintAt(screen, g.Assets.T("menu.choose_level"), 8, 30) a.Draw(screen, a.T("menu.choose_level"), 8, 30, colDim)
for i, e := range m.entries { for i, e := range m.entries {
y := 46 + i*14 y := 46 + i*14
if i == m.sel { if i == m.sel {
vector.DrawFilledRect(screen, 6, float32(y-2), ScreenW-12, 13, color.RGBA{58, 68, 102, 255}, false) vector.DrawFilledRect(screen, 6, float32(y-2), ScreenW-12, 13, colBar, false)
} }
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s -> %s", e.name, e.dest), 12, y) a.Draw(screen, e.name, 12, y, colText)
a.Draw(screen, "→ "+e.dest, 150, y, colKnown)
a.Draw(screen, e.stars, ScreenW-62, y, colWarn)
} }
if m.err != nil { if m.err != nil {
ebitenutil.DebugPrintAt(screen, "FEL: "+m.err.Error(), 8, ScreenH-40) a.Draw(screen, "FEL: "+m.err.Error(), 8, ScreenH-40, colBad)
} }
ebitenutil.DebugPrintAt(screen, g.Assets.T("menu.hint"), 8, ScreenH-26) a.Draw(screen, fmt.Sprintf("%s Lv%d %d %s", a.T("hud.japanese"), g.Know.Level(), g.Know.Count(), a.T("hud.words")), 8, ScreenH-38, colDim)
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s (%s)", buildinfo.Version, buildinfo.Mode), 8, ScreenH-13) a.Draw(screen, a.T("menu.hint"), 8, ScreenH-26, colDim)
a.Draw(screen, fmt.Sprintf("%s (%s)", buildinfo.Version, buildinfo.Mode), 8, ScreenH-13, colDim)
} }

View File

@@ -2,10 +2,8 @@ package game
import ( import (
"fmt" "fmt"
"image/color"
"github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
"github.com/hajimehoshi/ebiten/v2/vector" "github.com/hajimehoshi/ebiten/v2/vector"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
@@ -22,7 +20,7 @@ func NewResultScene(from *LevelScene) *ResultScene { return &ResultScene{from: f
func (r *ResultScene) Update(g *Game) error { func (r *ResultScene) Update(g *Game) error {
switch { switch {
case inpututil_R(): case inpututil_R():
s, err := NewLevelScene(g.Assets, r.from.id) s, err := NewLevelScene(g.Assets, r.from.id, g.Know)
if err != nil { if err != nil {
return err return err
} }
@@ -41,31 +39,35 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) {
r.from.Draw(g, screen) // banan i bakgrunden r.from.Draw(g, screen) // banan i bakgrunden
run := r.from.run run := r.from.run
a := g.Assets a := g.Assets
vector.DrawFilledRect(screen, 20, 36, ScreenW-40, 112, color.RGBA{24, 20, 37, 235}, false) vector.DrawFilledRect(screen, 20, 36, ScreenW-40, 112, colPanel, false)
accent, headline := color.RGBA{228, 59, 68, 255}, a.T("level.wrong_train") accent, headline := colBad, a.T("level.wrong_train")
if run.Outcome == sim.Won { if run.Outcome == sim.Won {
accent, headline = color.RGBA{99, 199, 77, 255}, a.T("level.cleared") accent, headline = colGood, a.T("level.cleared")
} }
vector.DrawFilledRect(screen, 20, 36, ScreenW-40, 3, accent, false) vector.DrawFilledRect(screen, 20, 36, ScreenW-40, 3, accent, false)
ebitenutil.DebugPrintAt(screen, headline, 28, 46) a.DrawBig(screen, headline, 28, 44, accent)
y := 62 y := 62
if tr := run.Boarded; tr != nil { if tr := run.Boarded; tr != nil {
// TODO(M3): japanska när fonten finns a.Draw(screen, fmt.Sprintf("%s%s %s %s%s", tr.Track, a.T("hud.track_suffix_ja"), tr.Line.Get("ja"),
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s %s %s -> %s", a.T("hud.track"), tr.Track, tr.Destination.Get("ja"), a.T("hud.for_ja")), 28, y, colKnown)
tr.Line.Get("romaji"), tr.Destination.Get("romaji")), 28, y) y += LineH
y += 12 a.Draw(screen, fmt.Sprintf("%s %s %s → %s", a.T("hud.track"), tr.Track, tr.Line.Get("romaji"), tr.Destination.Get("romaji")), 28, y, colDim)
y += LineH
if run.Outcome == sim.Lost { if run.Outcome == sim.Lost {
for _, l := range wrap(fmt.Sprintf(a.T("result.wrong_detail"), tr.Destination.Get("romaji"), for _, l := range a.wrapLine(plainLine(fmt.Sprintf(a.T("result.wrong_detail"), tr.Destination.Get("romaji"),
run.Level.Destination.Get("romaji")), 44) { run.Level.Destination.Get("romaji")), colText), ScreenW-56) {
ebitenutil.DebugPrintAt(screen, l, 28, y) a.drawLines(screen, []line{l}, 28, y)
y += 12 y += LineH
} }
} }
} }
y += 6 y += 4
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s %.1fs %s %d %s %d %s %d¥", a.T("hud.time"), run.Time, a.Draw(screen, fmt.Sprintf("%s %.1fs %s %d %s %d %s %d¥", a.T("hud.time"), run.Time,
a.T("hud.steps"), run.Steps, a.T("result.gates"), run.GatePasses, a.T("result.card"), run.Card.Balance), 28, y) a.T("hud.steps"), run.Steps, a.T("result.gates"), run.GatePasses, a.T("result.card"), run.Card.Balance), 28, y, colText)
ebitenutil.DebugPrintAt(screen, a.T("result.hint"), 28, 128) y += LineH
a.Draw(screen, fmt.Sprintf("%s Lv%d %d %s (%d %s)", a.T("hud.japanese"), run.Know.Level(), run.Know.Count(),
a.T("hud.words"), run.Know.Helps, a.T("result.helps")), 28, y, colDim)
a.Draw(screen, a.T("result.hint"), 28, 130, colDim)
} }

View File

@@ -1,27 +1,137 @@
package game package game
import "strings" import (
"image/color"
"strings"
// Debugfonten är 6 px bred per tecken; 320 px ger drygt 50 tecken. "github.com/hajimehoshi/ebiten/v2"
const textCols = 50
// wrap radbryter en text på ordgränser till högst width tecken per rad. "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang"
func wrap(s string, width int) []string { )
var lines []string
cur := "" type drawTarget = *ebiten.Image
for _, w := range strings.Fields(s) {
// Färger för text.
var (
colText = color.RGBA{255, 255, 255, 255}
colDim = color.RGBA{139, 155, 180, 255} // oöversatt / sekundärt
colKnown = color.RGBA{254, 174, 52, 255} // kända ord / översättning
colReading = color.RGBA{99, 199, 77, 255} // kana som kan läsas men inte förstås
colEnglish = color.RGBA{44, 232, 245, 255}
colBad = color.RGBA{228, 59, 68, 255}
colGood = color.RGBA{99, 199, 77, 255}
colWarn = color.RGBA{254, 174, 52, 255}
colPanel = color.RGBA{24, 20, 37, 230}
colBar = color.RGBA{58, 68, 102, 255}
)
// span är en textbit med färg; en rad är flera spans.
type span struct {
text string
col color.Color
}
type line []span
// plainLine gör en rad av en enda sträng.
func plainLine(s string, col color.Color) line { return line{{s, col}} }
// shownLine gör en rad av språksystemets segment med färg efter kunskap.
// Med readings visas läsningen (romaji) efter kända ord – hinten från nivå 2.
func shownLine(shown []lang.Shown, base color.Color, readings bool) line {
var l line
for _, sh := range shown {
col := base
switch { switch {
case cur == "": case sh.Reading:
cur = w col = colReading
case len([]rune(cur))+1+len([]rune(w)) > width: case sh.Known && lang.IsJapanese(sh.Text):
lines = append(lines, cur) col = colKnown
cur = w case sh.Known:
col = base
default: default:
cur += " " + w col = colDim
} }
l = append(l, span{sh.Text, col})
if readings && sh.Romaji != "" {
l = append(l, span{"(" + sh.Romaji + ")", colDim})
}
}
return l
}
// wrapLine radbryter en rad till maxW pixlar. Bryter vid mellanslag, och
// mitt i ett ord bara om ordet ensamt är för långt (japanska saknar mellanslag).
func (a *Assets) wrapLine(l line, maxW int) []line {
var out []line
var cur line
curW := 0
flush := func() {
if len(cur) > 0 {
out = append(out, cur)
cur, curW = nil, 0
}
}
add := func(s string, col color.Color) {
w := a.Width(s)
if curW+w > maxW && curW > 0 {
flush()
s = strings.TrimLeft(s, " ")
w = a.Width(s)
}
if s == "" {
return
}
if n := len(cur); n > 0 && cur[n-1].col == col {
cur[n-1].text += s
} else {
cur = append(cur, span{s, col})
}
curW += w
}
for _, sp := range l {
for _, tok := range tokens(sp.text) {
if a.Width(tok) <= maxW {
add(tok, sp.col)
continue
}
for _, r := range tok { // för långt ord: bryt per tecken
add(string(r), sp.col)
}
}
}
flush()
return out
}
// tokens delar en sträng i ord och mellanslagsrunor, båda bevarade.
func tokens(s string) []string {
var out []string
cur := ""
for _, r := range s {
if r == ' ' {
if cur != "" {
out = append(out, cur)
cur = ""
}
out = append(out, " ")
continue
}
cur += string(r)
} }
if cur != "" { if cur != "" {
lines = append(lines, cur) out = append(out, cur)
}
return out
}
// drawLines ritar rader från (x, y) med radhöjd LineH.
func (a *Assets) drawLines(dst drawTarget, lines []line, x, y int) {
for i, l := range lines {
cx := x
for _, sp := range l {
a.Draw(dst, sp.text, cx, y+i*LineH, sp.col)
cx += a.Width(sp.text)
}
} }
return lines
} }

View File

@@ -0,0 +1,81 @@
package lang
import (
"os"
"path/filepath"
"strings"
"testing"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res"
)
func loadDict(t *testing.T) *Dictionary {
t.Helper()
b, err := res.ReadFile("text", "jp-dictionary.json")
if err != nil {
t.Fatal(err)
}
d, err := ParseDictionary(b)
if err != nil {
t.Fatal(err)
}
return d
}
// TestShippedDictionary: ordboken parsar och har rimligt innehåll.
func TestShippedDictionary(t *testing.T) {
d := loadDict(t)
if d.Len() < 20 {
t.Fatalf("bara %d ord", d.Len())
}
for _, w := range d.Words() {
e := d.Lookup(w)
if e.Tier < 1 || e.Tier > 5 {
t.Errorf("%s: tier %d", w, e.Tier)
}
}
}
// TestAllJapaneseInLevelsIsInDictionary: all japanska i banorna (skyltar,
// namn, resmål, linjer) måste gå att slå upp, annars kan skyltsystemet
// aldrig översätta den.
func TestAllJapaneseInLevelsIsInDictionary(t *testing.T) {
d := loadDict(t)
dir, err := res.Path("data", "levels")
if err != nil {
t.Fatal(err)
}
files, _ := filepath.Glob(filepath.Join(dir, "*.json"))
if len(files) == 0 {
t.Fatal("inga banor")
}
for _, f := range files {
b, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
l, err := level.Parse(b)
if err != nil {
t.Fatal(err)
}
check := func(what, s string) {
if s == "" {
t.Errorf("%s: %s saknar japansk text", l.ID, what)
return
}
if u := d.Unknown(s); len(u) > 0 {
t.Errorf("%s: %s %q har japanska som saknas i ordboken: %s", l.ID, what, s, strings.Join(u, ", "))
}
}
check("namn", l.Name["ja"])
check("resmål", l.Destination["ja"])
for i, tr := range l.Trains {
check("tåg "+string(rune('0'+i))+" linje", tr.Line["ja"])
check("tåg "+string(rune('0'+i))+" resmål", tr.Destination["ja"])
}
for _, sg := range l.Signs {
check("skylt", sg.Text["ja"])
}
}
}

133
internal/lang/dictionary.go Normal file
View File

@@ -0,0 +1,133 @@
// Package lang är språksystemet: ordboken över japanska skyltord, spelarens
// kunskap, tokenisering av skylttext och den delvisa översättning spelaren
// ser. Ren Go – inga Ebitengine-beroenden.
package lang
import (
"encoding/json"
"fmt"
"sort"
"strings"
"unicode"
)
// Entry är ett ord i ordboken.
type Entry struct {
Word string `json:"-"`
Kana string `json:"kana"`
Romaji string `json:"romaji"`
En string `json:"en"`
Sv string `json:"sv"`
Tier int `json:"tier"`
}
// Translation returnerar översättningen på UI-språket (sv/en) med fallback.
func (e *Entry) Translation(lang string) string {
if lang == "en" || e.Sv == "" {
return e.En
}
return e.Sv
}
// Dictionary är ordboken. Orden hålls sorterade längst först för
// längsta-matchning vid tokenisering.
type Dictionary struct {
entries map[string]*Entry
words []string
}
// NewDictionary skapar en tom ordbok (praktiskt i tester).
func NewDictionary() *Dictionary { return &Dictionary{entries: map[string]*Entry{}} }
// ParseDictionary läser jp-dictionary.json och validerar att varje post har
// kana, romaji, en, sv och tier. Nycklar som börjar med "_" ignoreras.
func ParseDictionary(data []byte) (*Dictionary, error) {
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("lang: ogiltig ordbok: %w", err)
}
d := NewDictionary()
for word, msg := range raw {
if strings.HasPrefix(word, "_") {
continue
}
var e Entry
if err := json.Unmarshal(msg, &e); err != nil {
return nil, fmt.Errorf("lang: ordet %q: %w", word, err)
}
if e.Kana == "" || e.Romaji == "" || e.En == "" || e.Sv == "" || e.Tier < 1 {
return nil, fmt.Errorf("lang: ordet %q saknar kana/romaji/en/sv/tier", word)
}
if !IsJapanese(word) {
return nil, fmt.Errorf("lang: nyckeln %q innehåller inte japanska tecken", word)
}
d.Add(word, e)
}
return d, nil
}
// Add lägger till eller ersätter ett ord.
func (d *Dictionary) Add(word string, e Entry) {
e.Word = word
if _, exists := d.entries[word]; !exists {
d.words = append(d.words, word)
sort.Slice(d.words, func(i, j int) bool {
ri, rj := len([]rune(d.words[i])), len([]rune(d.words[j]))
if ri != rj {
return ri > rj
}
return d.words[i] < d.words[j]
})
}
d.entries[word] = &e
}
// Lookup returnerar posten för ett ord, eller nil.
func (d *Dictionary) Lookup(word string) *Entry { return d.entries[word] }
// Len är antalet ord.
func (d *Dictionary) Len() int { return len(d.entries) }
// Words returnerar alla ord, längsta först.
func (d *Dictionary) Words() []string { return append([]string(nil), d.words...) }
// IsKana rapporterar om tecknet är hiragana eller katakana (inkl. ー).
func IsKana(r rune) bool {
return unicode.Is(unicode.Hiragana, r) || unicode.Is(unicode.Katakana, r) || r == 'ー'
}
// IsKanji rapporterar om tecknet är ett han-tecken.
func IsKanji(r rune) bool { return unicode.Is(unicode.Han, r) || r == '々' }
// IsJapanese rapporterar om strängen innehåller minst ett kana- eller kanji-tecken.
func IsJapanese(s string) bool {
for _, r := range s {
if IsKana(r) || IsKanji(r) {
return true
}
}
return false
}
// HasKana rapporterar om ordet innehåller kana.
func HasKana(s string) bool {
for _, r := range s {
if IsKana(r) {
return true
}
}
return false
}
// IsAllKana rapporterar om ordet bara består av kana.
func IsAllKana(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if !IsKana(r) {
return false
}
}
return true
}

117
internal/lang/knowledge.go Normal file
View File

@@ -0,0 +1,117 @@
package lang
import "math"
// Knowledge är vad spelaren kan: ord ur ordboken och om hen kan läsa kana.
type Knowledge struct {
Words map[string]bool `json:"words"`
Kana bool `json:"kana"`
Helps int `json:"helps"` // antal gånger spelaren frågat om hjälp
}
// KanaUnlockWords: när så här många kända ord innehåller kana "lossnar"
// kana-läsningen och alla kana-ord går att läsa (som romaji) utan att förstås.
const KanaUnlockWords = 3
// WordsPerLevel styr japanska-nivån: nivå = kända ord / WordsPerLevel, max MaxLevel.
const (
WordsPerLevel = 4
MaxLevel = 5
)
// NewKnowledge börjar från noll.
func NewKnowledge() *Knowledge { return &Knowledge{Words: map[string]bool{}} }
// Knows rapporterar om ordet är känt.
func (k *Knowledge) Knows(word string) bool { return k.Words[word] }
// Learn lär in ett ord. Returnerar true om det var nytt. Låser upp kana när
// tillräckligt många kana-ord är kända.
func (k *Knowledge) Learn(word string) bool {
if k.Words == nil {
k.Words = map[string]bool{}
}
if k.Words[word] {
return false
}
k.Words[word] = true
if !k.Kana {
n := 0
for w := range k.Words {
if HasKana(w) {
n++
}
}
if n >= KanaUnlockWords {
k.Kana = true
}
}
return true
}
// Count är antalet kända ord.
func (k *Knowledge) Count() int { return len(k.Words) }
// Level är japanska-nivån 0..MaxLevel.
func (k *Knowledge) Level() int {
return int(math.Min(MaxLevel, float64(k.Count()/WordsPerLevel)))
}
// Hintar som växer med nivån.
// ShowsDestinationJP: från nivå 1 visar HUD:en resmålet även på japanska så
// att man kan matcha tecknen mot skyltarna.
func (k *Knowledge) ShowsDestinationJP() bool { return k.Level() >= 1 }
// ShowsReadings: från nivå 2 visas läsningen (romaji) för kända ord på skyltar.
func (k *Knowledge) ShowsReadings() bool { return k.Level() >= 2 }
// HelpSeconds är hur länge det tar att få hjälp av en förbipasserande.
// Bättre japanska = snabbare, men aldrig under HelpMinSeconds.
const (
HelpBaseSeconds = 6.0
HelpPerLevel = 0.8
HelpMinSeconds = 2.0
)
// HelpSeconds beräknar väntetiden för en given nivå.
func HelpSeconds(level int) float64 {
return math.Max(HelpMinSeconds, HelpBaseSeconds-HelpPerLevel*float64(level))
}
// EnglishShare är andelen skyltar som också visar engelska, per svårighet.
func EnglishShare(difficulty int) float64 {
switch {
case difficulty <= 1:
return 1.0
case difficulty == 2:
return 0.6
case difficulty == 3:
return 0.3
case difficulty == 4:
return 0.1
default:
return 0
}
}
// ShowEnglish avgör deterministiskt om skylten på (x, y) i en bana visar
// engelska: banor är statiska så valet hashas från bana + position, inte
// från spelomgångens seed.
func ShowEnglish(levelID string, x, y, difficulty int) bool {
share := EnglishShare(difficulty)
if share >= 1 {
return true
}
if share <= 0 {
return false
}
h := uint32(2166136261)
for _, b := range []byte(levelID) {
h = (h ^ uint32(b)) * 16777619
}
for _, v := range []int{x, y} {
h = (h ^ uint32(v+7)) * 16777619
}
return float64(h%1000)/1000 < share
}

210
internal/lang/lang_test.go Normal file
View File

@@ -0,0 +1,210 @@
package lang
import (
"math"
"strings"
"testing"
)
func testDict() *Dictionary {
d := NewDictionary()
d.Add("改札", Entry{Kana: "かいさつ", Romaji: "kaisatsu", En: "Ticket gate", Sv: "Spärr", Tier: 1})
d.Add("番線", Entry{Kana: "ばんせん", Romaji: "bansen", En: "Track no.", Sv: "Spår", Tier: 1})
d.Add("方面", Entry{Kana: "ほうめん", Romaji: "hōmen", En: "for", Sv: "mot", Tier: 2})
d.Add("新宿", Entry{Kana: "しんじゅく", Romaji: "Shinjuku", En: "Shinjuku", Sv: "Shinjuku", Tier: 2})
d.Add("新宿駅", Entry{Kana: "しんじゅくえき", Romaji: "Shinjuku-eki", En: "Shinjuku Station", Sv: "Shinjuku station", Tier: 3})
d.Add("のりば", Entry{Kana: "のりば", Romaji: "noriba", En: "Platform", Sv: "Perrong", Tier: 1})
d.Add("トイレ", Entry{Kana: "トイレ", Romaji: "toire", En: "Toilet", Sv: "Toalett", Tier: 1})
d.Add("チャージ", Entry{Kana: "チャージ", Romaji: "chāji", En: "Top up", Sv: "Fyll på", Tier: 2})
return d
}
func TestTokenizeLongestMatchAndKinds(t *testing.T) {
d := testDict()
segs := d.Tokenize("改札 ← 2番線 新宿駅方面 京成")
var got []string
for _, s := range segs {
got = append(got, map[Kind]string{Plain: "P", Word: "W", Unknown: "U"}[s.Kind]+":"+s.Text)
}
want := []string{"W:改札", "P: ← 2", "W:番線", "P: ", "W:新宿駅", "W:方面", "P: ", "U:京成"}
if strings.Join(got, "|") != strings.Join(want, "|") {
t.Fatalf("got %v\nwant %v", got, want)
}
if u := d.Unknown("改札 京成 のりば 上野"); strings.Join(u, ",") != "京成,上野" {
t.Fatalf("Unknown = %v", u)
}
if w := d.WordsIn("新宿方面 新宿"); strings.Join(w, ",") != "新宿,方面,新宿" {
t.Fatalf("WordsIn = %v", w)
}
}
func TestRenderPartialTranslation(t *testing.T) {
d := testDict()
k := NewKnowledge()
k.Learn("改札")
k.Learn("方面")
r := Render(d, k, "改札 → 2番線 新宿方面", "sv")
if r.Complete {
t.Fatal("ska inte vara komplett med okända ord")
}
if got := PlainText(r.Partial); got != "Spärr → 2番線 新宿 mot" {
t.Fatalf("Partial = %q", got)
}
known := map[string]bool{}
for _, sh := range r.Original {
known[sh.Text] = sh.Known
}
if !known["改札"] || !known["方面"] || known["番線"] || known["新宿"] {
t.Fatalf("markering fel: %+v", r.Original)
}
// Allt känt (nil) -> komplett.
full := Render(d, nil, "改札 → 2番線 新宿方面", "sv")
if !full.Complete || PlainText(full.Partial) != "Spärr → 2 Spår Shinjuku mot" {
t.Fatalf("full = %q complete=%v", PlainText(full.Partial), full.Complete)
}
// Engelska som UI-språk.
if got := PlainText(Render(d, nil, "改札", "en").Partial); got != "Ticket gate" {
t.Fatalf("en = %q", got)
}
}
func TestKanaReadingWithoutMeaning(t *testing.T) {
d := testDict()
k := NewKnowledge()
r := Render(d, k, "のりば", "sv")
if PlainText(r.Partial) != "のりば" {
t.Fatalf("utan kana ska kana-ordet stå kvar: %q", PlainText(r.Partial))
}
k.Kana = true
r = Render(d, k, "のりば 改札", "sv")
if PlainText(r.Partial) != "noriba 改札" || !r.Partial[0].Reading || r.Complete {
t.Fatalf("med kana: %q reading=%v complete=%v", PlainText(r.Partial), r.Partial[0].Reading, r.Complete)
}
}
func TestLearnUnlocksKanaAndLevels(t *testing.T) {
k := NewKnowledge()
if k.Level() != 0 || k.Kana {
t.Fatal("startar på noll")
}
if !k.Learn("改札") || k.Learn("改札") {
t.Fatal("Learn ska rapportera nytt bara första gången")
}
k.Learn("のりば")
k.Learn("トイレ")
if k.Kana {
t.Fatal("två kana-ord räcker inte")
}
k.Learn("チャージ")
if !k.Kana {
t.Fatal("tre kana-ord ska låsa upp kana")
}
if k.Count() != 4 || k.Level() != 1 || !k.ShowsDestinationJP() || k.ShowsReadings() {
t.Fatalf("count=%d level=%d", k.Count(), k.Level())
}
for i := 0; i < 40; i++ {
k.Learn(strings.Repeat("語", i+1))
}
if k.Level() != MaxLevel || !k.ShowsReadings() {
t.Fatalf("level=%d", k.Level())
}
}
func TestReadingsShownFromLevel2(t *testing.T) {
d := testDict()
k := NewKnowledge()
for _, w := range []string{"改札", "番線", "方面", "新宿", "新宿駅", "のりば", "トイレ", "チャージ"} {
k.Learn(w)
}
r := Render(d, k, "改札", "sv")
if k.Level() < 2 || r.Original[0].Romaji != "kaisatsu" {
t.Fatalf("level=%d romaji=%q", k.Level(), r.Original[0].Romaji)
}
}
func TestFirstUnknownWord(t *testing.T) {
d := testDict()
k := NewKnowledge()
k.Learn("改札")
if w := FirstUnknownWord(d, k, "改札 2番線 新宿方面"); w != "番線" {
t.Fatalf("got %q", w)
}
if w := FirstUnknownWord(d, k, "改札 改札"); w != "" {
t.Fatalf("allt känt ska ge tom sträng, fick %q", w)
}
}
func TestHelpSeconds(t *testing.T) {
prev := math.Inf(1)
for lvl := 0; lvl <= MaxLevel; lvl++ {
s := HelpSeconds(lvl)
if s > prev || s < HelpMinSeconds {
t.Fatalf("nivå %d: %.1f s (förra %.1f)", lvl, s, prev)
}
prev = s
}
if HelpSeconds(0) != HelpBaseSeconds || HelpSeconds(100) != HelpMinSeconds {
t.Fatal("gränserna stämmer inte")
}
}
func TestEnglishShareAndDeterminism(t *testing.T) {
if EnglishShare(1) != 1 || EnglishShare(0) != 1 || EnglishShare(5) != 0 || EnglishShare(9) != 0 {
t.Fatal("ytterligheterna")
}
for d := 1; d < 5; d++ {
if EnglishShare(d) <= EnglishShare(d+1) {
t.Fatalf("andelen ska minska med svårigheten: %d", d)
}
}
for i := 0; i < 50; i++ {
if !ShowEnglish("x", i, 0, 1) || ShowEnglish("x", i, 0, 5) {
t.Fatal("svårighet 1 = alltid engelska, 5 = aldrig")
}
}
if ShowEnglish("lvl", 3, 4, 2) != ShowEnglish("lvl", 3, 4, 2) {
t.Fatal("ska vara deterministiskt")
}
// Andelen ska ungefär stämma över många skyltar.
n := 0
for x := 0; x < 100; x++ {
for y := 0; y < 10; y++ {
if ShowEnglish("lvl", x, y, 2) {
n++
}
}
}
if n < 500 || n > 700 {
t.Fatalf("svårighet 2: %d/1000 visar engelska, vill ha ~600", n)
}
}
func TestParseDictionaryValidation(t *testing.T) {
good := `{"_comment": "x", "改札": {"kana": "かいさつ", "romaji": "kaisatsu", "en": "Gate", "sv": "Spärr", "tier": 1}}`
d, err := ParseDictionary([]byte(good))
if err != nil || d.Len() != 1 || d.Lookup("改札") == nil || d.Lookup("改札").Word != "改札" {
t.Fatalf("d=%v err=%v", d, err)
}
for name, src := range map[string]string{
"saknar sv": `{"改札": {"kana": "か", "romaji": "k", "en": "G", "tier": 1}}`,
"tier 0": `{"改札": {"kana": "か", "romaji": "k", "en": "G", "sv": "S", "tier": 0}}`,
"latinsk nyckel": `{"gate": {"kana": "か", "romaji": "k", "en": "G", "sv": "S", "tier": 1}}`,
"trasig": `{`,
} {
if _, err := ParseDictionary([]byte(src)); err == nil {
t.Errorf("%s: förväntade fel", name)
}
}
}
func TestCharClasses(t *testing.T) {
if !IsKana('の') || !IsKana('ト') || !IsKana('ー') || IsKana('改') || IsKana('a') {
t.Fatal("IsKana")
}
if !IsKanji('改') || IsKanji('の') || !IsAllKana("のりば") || IsAllKana("新宿") || IsAllKana("") {
t.Fatal("IsKanji/IsAllKana")
}
if !HasKana("成田エクスプレス") || HasKana("新宿") || !IsJapanese("2番線") || IsJapanese("Track 2") {
t.Fatal("HasKana/IsJapanese")
}
}

90
internal/lang/render.go Normal file
View File

@@ -0,0 +1,90 @@
package lang
import "strings"
// Shown är en bit text som spelaren ser, med hur den ska färgas.
type Shown struct {
Text string
Known bool // ordet är känt (översatt/markerat)
Reading bool // kana-ord som kan läsas men inte förstås
Romaji string // läsning att visa under kända ord vid ShowsReadings
}
// Rendered är en skylt sedd genom spelarens kunskap.
type Rendered struct {
Original []Shown // originaltexten med kända ord markerade
Partial []Shown // delvis översättning: kända ord översatta, resten kvar på japanska
Complete bool // allt gick att läsa
}
// Render tolkar en japansk text med spelarens kunskap. k == nil betyder
// "kan allt" (används när en skylt är avslöjad via hjälp).
//
// Japanska skrivs utan mellanslag, men när ett ord översätts till svenska
// behöver det luft mot grannorden, annars blir det "新宿mot". Därför får
// översatta ord ett mellanslag mot intilliggande text i den delvisa raden.
func Render(d *Dictionary, k *Knowledge, s, uiLang string) Rendered {
var out Rendered
out.Complete = true
prevTranslated := false // förra delen i Partial var ett översatt/läst ord
addPartial := func(sh Shown, translated bool) {
if n := len(out.Partial); n > 0 && (translated || prevTranslated) {
prev := &out.Partial[n-1]
if prev.Text != "" && !strings.HasSuffix(prev.Text, " ") && !strings.HasPrefix(sh.Text, " ") {
prev.Text += " "
}
}
out.Partial = append(out.Partial, sh)
prevTranslated = translated
}
for _, seg := range d.Tokenize(s) {
switch seg.Kind {
case Word:
known := k == nil || k.Knows(seg.Text)
canRead := !known && k != nil && k.Kana && IsAllKana(seg.Text)
orig := Shown{Text: seg.Text, Known: known, Reading: canRead}
if known && k != nil && k.ShowsReadings() {
orig.Romaji = seg.Entry.Romaji
}
out.Original = append(out.Original, orig)
switch {
case known:
addPartial(Shown{Text: seg.Entry.Translation(uiLang), Known: true}, true)
case canRead:
addPartial(Shown{Text: seg.Entry.Romaji, Reading: true}, true)
out.Complete = false
default:
addPartial(Shown{Text: seg.Text}, false)
out.Complete = false
}
case Unknown:
out.Original = append(out.Original, Shown{Text: seg.Text})
addPartial(Shown{Text: seg.Text}, false)
out.Complete = false
default:
out.Original = append(out.Original, Shown{Text: seg.Text, Known: true})
addPartial(Shown{Text: seg.Text, Known: true}, false)
}
}
return out
}
// PlainText slår ihop en rad till en sträng.
func PlainText(line []Shown) string {
s := ""
for _, sh := range line {
s += sh.Text
}
return s
}
// FirstUnknownWord returnerar det första ordboksordet i texten som spelaren
// inte kan, eller "" om hen kan alla.
func FirstUnknownWord(d *Dictionary, k *Knowledge, s string) string {
for _, w := range d.WordsIn(s) {
if !k.Knows(w) {
return w
}
}
return ""
}

96
internal/lang/tokenize.go Normal file
View File

@@ -0,0 +1,96 @@
package lang
import "strings"
// Kind är vad ett textsegment är.
type Kind int
// Segmenttyper.
const (
Plain Kind = iota // allt som inte är japanska: siffror, pilar, latin, mellanslag
Word // ett ord ur ordboken
Unknown // japanska tecken som inte täcks av ordboken
)
// Segment är en bit av en skylttext.
type Segment struct {
Text string
Kind Kind
Entry *Entry // satt när Kind == Word
}
// Tokenize delar upp en text i ordboksord (längsta matchning först), okänd
// japanska och övrig text. Sammanhängande okänd japanska slås ihop till ett
// segment så att tester kan peka ut vad som saknas i ordboken.
func (d *Dictionary) Tokenize(s string) []Segment {
runes := []rune(s)
var out []Segment
flush := func(kind Kind, text string) {
if text == "" {
return
}
if n := len(out); n > 0 && out[n-1].Kind == kind && kind != Word {
out[n-1].Text += text
return
}
out = append(out, Segment{Text: text, Kind: kind})
}
for i := 0; i < len(runes); {
r := runes[i]
if !IsKana(r) && !IsKanji(r) {
flush(Plain, string(r))
i++
continue
}
if w := d.longestMatch(runes[i:]); w != "" {
out = append(out, Segment{Text: w, Kind: Word, Entry: d.entries[w]})
i += len([]rune(w))
continue
}
flush(Unknown, string(r))
i++
}
return out
}
func (d *Dictionary) longestMatch(rest []rune) string {
for _, w := range d.words { // längsta först
wr := []rune(w)
if len(wr) > len(rest) {
continue
}
match := true
for k, c := range wr {
if rest[k] != c {
match = false
break
}
}
if match {
return w
}
}
return ""
}
// Unknown returnerar den japanska i texten som ordboken inte täcker.
func (d *Dictionary) Unknown(s string) []string {
var out []string
for _, seg := range d.Tokenize(s) {
if seg.Kind == Unknown {
out = append(out, strings.TrimSpace(seg.Text))
}
}
return out
}
// WordsIn returnerar ordboksorden i texten i ordning (med dubbletter).
func (d *Dictionary) WordsIn(s string) []string {
var out []string
for _, seg := range d.Tokenize(s) {
if seg.Kind == Word {
out = append(out, seg.Text)
}
}
return out
}

View File

@@ -1,6 +1,9 @@
package sim package sim
import "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" import (
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
)
// Outcome är hur en banomgång står sig. // Outcome är hur en banomgång står sig.
type Outcome int type Outcome int
@@ -42,15 +45,42 @@ type Run struct {
DeniedAt float64 // när spärren senast nekade (för HUD); < 0 = aldrig DeniedAt float64 // när spärren senast nekade (för HUD); < 0 = aldrig
Outcome Outcome Outcome Outcome
Boarded *level.Train Boarded *level.Train
// Språk: ordboken, spelarens kunskap (delas mellan banor) och hjälpen.
Dict *lang.Dictionary
Know *lang.Knowledge
Help HelpState
Revealed map[level.Point]bool // skyltar som översatts helt via hjälp
Learned string // senast inlärda ord (för HUD-notis)
LearnedAt float64 // när det lärdes in; < 0 = aldrig
} }
// NewRun startar banan med spelaren på startrutan. // HelpState är en pågående "fråga någon om hjälp".
func NewRun(l *level.Level, speed float64) *Run { type HelpState struct {
Active bool
At level.Point // skyltrutan hjälpen gäller
Remaining float64
Total float64
}
// NewRun startar banan med spelaren på startrutan. dict och know får vara
// nil (tom ordbok respektive ny kunskap).
func NewRun(l *level.Level, speed float64, dict *lang.Dictionary, know *lang.Knowledge) *Run {
if dict == nil {
dict = lang.NewDictionary()
}
if know == nil {
know = lang.NewKnowledge()
}
return &Run{ return &Run{
Level: l, Level: l,
Player: NewMover(l.SpawnPoint(), speed), Player: NewMover(l.SpawnPoint(), speed),
Card: Card{Balance: StartBalanceYen}, Card: Card{Balance: StartBalanceYen},
DeniedAt: -1, DeniedAt: -1,
Dict: dict,
Know: know,
Revealed: map[level.Point]bool{},
LearnedAt: -1,
} }
} }
@@ -82,6 +112,7 @@ func (r *Run) Step(dt float64, want Dir, board bool) {
before := r.Player.Tile before := r.Player.Tile
wasGate := r.inGate() wasGate := r.inGate()
r.Steps += r.Player.Step(dt, want, r.CanEnter) r.Steps += r.Player.Step(dt, want, r.CanEnter)
r.stepHelp(dt)
if r.Player.Tile != before && r.inGate() && !wasGate { if r.Player.Tile != before && r.inGate() && !wasGate {
r.Card.Pay(r.Level.FareYen) r.Card.Pay(r.Level.FareYen)
r.GatePasses++ r.GatePasses++
@@ -136,3 +167,52 @@ func (r *Run) TrainVisible(t level.Tile) bool {
at, _ := tr.Schedule.Status(r.Time) at, _ := tr.Schedule.Status(r.Time)
return at return at
} }
// AskHelp börjar fråga en förbipasserande om vad skylten betyder. Kräver att
// spelaren står stilla på en skylt som inte redan är avslöjad. Tiden beror
// på japanska-nivån (lang.HelpSeconds).
func (r *Run) AskHelp() bool {
sg := r.SignHere()
if sg == nil || r.Help.Active || r.Revealed[r.Player.Tile] || r.Outcome != Ongoing {
return false
}
secs := lang.HelpSeconds(r.Know.Level())
r.Help = HelpState{Active: true, At: r.Player.Tile, Remaining: secs, Total: secs}
return true
}
// stepHelp räknar ned hjälpen; att gå därifrån avbryter. När den är klar
// avslöjas skylten och spelaren lär sig det första okända ordet på den.
func (r *Run) stepHelp(dt float64) {
if !r.Help.Active {
return
}
if r.Player.Moving() || r.Player.Tile != r.Help.At {
r.Help = HelpState{}
return
}
r.Help.Remaining -= dt
if r.Help.Remaining > 0 {
return
}
r.Revealed[r.Help.At] = true
r.Know.Helps++
if sg := r.Level.SignAt(r.Help.At); sg != nil {
if w := lang.FirstUnknownWord(r.Dict, r.Know, sg.Text["ja"]); w != "" {
r.Know.Learn(w)
r.Learned, r.LearnedAt = w, r.Time
}
}
r.Help = HelpState{}
}
// SignRevealed rapporterar om skylten på rutan är helt översatt.
func (r *Run) SignRevealed(p level.Point) bool { return r.Revealed[p] }
// HelpProgress är 0..1 för en pågående hjälp.
func (r *Run) HelpProgress() float64 {
if !r.Help.Active || r.Help.Total <= 0 {
return 0
}
return 1 - r.Help.Remaining/r.Help.Total
}

View File

@@ -1,13 +1,28 @@
package sim package sim
import ( import (
"math"
"path/filepath" "path/filepath"
"testing" "testing"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res"
) )
func loadDict(t *testing.T) *lang.Dictionary {
t.Helper()
b, err := res.ReadFile("text", "jp-dictionary.json")
if err != nil {
t.Fatal(err)
}
d, err := lang.ParseDictionary(b)
if err != nil {
t.Fatal(err)
}
return d
}
const frame = 1.0 / 60 const frame = 1.0 / 60
func loadLevel(t *testing.T, id string) *level.Level { func loadLevel(t *testing.T, id string) *level.Level {
@@ -75,7 +90,7 @@ func waitForTrain(t *testing.T, r *Run) *level.Train {
// och kliver på – helt utan fönster. // och kliver på – helt utan fönster.
func TestBotWinsLevel001(t *testing.T) { func TestBotWinsLevel001(t *testing.T) {
l := loadLevel(t, "001-flygplatsen") l := loadLevel(t, "001-flygplatsen")
r := NewRun(l, 6) r := NewRun(l, 6, nil, nil)
goal := l.Find(level.Platform)[0] goal := l.Find(level.Platform)[0]
path := l.Path(r.Player.Tile, goal, r.CanEnter) path := l.Path(r.Player.Tile, goal, r.CanEnter)
if path == nil { if path == nil {
@@ -111,7 +126,7 @@ func TestBotWinsLevel001(t *testing.T) {
func TestBotLosesOnWrongTrain(t *testing.T) { func TestBotLosesOnWrongTrain(t *testing.T) {
l := loadLevel(t, "001-flygplatsen") l := loadLevel(t, "001-flygplatsen")
r := NewRun(l, 6) r := NewRun(l, 6, nil, nil)
goal := l.Find(level.WrongPlatform)[0] goal := l.Find(level.WrongPlatform)[0]
walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter)) walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter))
tr := waitForTrain(t, r) tr := waitForTrain(t, r)
@@ -123,7 +138,7 @@ func TestBotLosesOnWrongTrain(t *testing.T) {
func TestGateNeedsCardBalance(t *testing.T) { func TestGateNeedsCardBalance(t *testing.T) {
l := loadLevel(t, "001-flygplatsen") l := loadLevel(t, "001-flygplatsen")
r := NewRun(l, 6) r := NewRun(l, 6, nil, nil)
gate := l.Find(level.Gate)[0] gate := l.Find(level.Gate)[0]
r.Card.Balance = 0 r.Card.Balance = 0
if r.CanEnter(gate) { if r.CanEnter(gate) {
@@ -150,7 +165,7 @@ func TestGateNeedsCardBalance(t *testing.T) {
func TestBoardOnlyWhenTrainPresent(t *testing.T) { func TestBoardOnlyWhenTrainPresent(t *testing.T) {
l := loadLevel(t, "001-flygplatsen") l := loadLevel(t, "001-flygplatsen")
r := NewRun(l, 6) r := NewRun(l, 6, nil, nil)
goal := l.Find(level.Platform)[0] goal := l.Find(level.Platform)[0]
walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter)) walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter))
// Spola fram till en tidpunkt då tåget är borta. // Spola fram till en tidpunkt då tåget är borta.
@@ -169,7 +184,7 @@ func TestBoardOnlyWhenTrainPresent(t *testing.T) {
func TestSignHereOnlyWhenStill(t *testing.T) { func TestSignHereOnlyWhenStill(t *testing.T) {
l := loadLevel(t, "001-flygplatsen") l := loadLevel(t, "001-flygplatsen")
r := NewRun(l, 6) r := NewRun(l, 6, nil, nil)
sign := l.Find(level.Sign)[0] sign := l.Find(level.Sign)[0]
walk(t, r, l.Path(r.Player.Tile, sign, r.CanEnter)) walk(t, r, l.Path(r.Player.Tile, sign, r.CanEnter))
if sg := r.SignHere(); sg == nil || sg.Text.Get("sv") == "" { if sg := r.SignHere(); sg == nil || sg.Text.Get("sv") == "" {
@@ -180,3 +195,69 @@ func TestSignHereOnlyWhenStill(t *testing.T) {
t.Fatal("i rörelse visas ingen skylt") t.Fatal("i rörelse visas ingen skylt")
} }
} }
// TestAskHelpRevealsSignAndTeachesWord: att fråga om hjälp tar
// lang.HelpSeconds, avslöjar skylten och lär spelaren ett ord från den.
func TestAskHelpRevealsSignAndTeachesWord(t *testing.T) {
l := loadLevel(t, "001-flygplatsen")
know := lang.NewKnowledge()
r := NewRun(l, 6, loadDict(t), know)
sign := l.Find(level.Sign)[1]
walk(t, r, l.Path(r.Player.Tile, sign, r.CanEnter))
if !r.AskHelp() {
t.Fatal("ska kunna fråga om hjälp på en skylt")
}
if r.AskHelp() {
t.Fatal("inte två hjälp samtidigt")
}
want := lang.HelpSeconds(0)
if math.Abs(r.Help.Total-want) > 1e-9 {
t.Fatalf("hjälptid %.1f, vill ha %.1f", r.Help.Total, want)
}
frames := int(want/frame) - 2
for i := 0; i < frames; i++ {
r.Step(frame, None, false)
}
if !r.Help.Active || r.SignRevealed(sign) || r.HelpProgress() < 0.9 {
t.Fatalf("strax före klart: active=%v revealed=%v progress=%.2f", r.Help.Active, r.SignRevealed(sign), r.HelpProgress())
}
for i := 0; i < 4; i++ {
r.Step(frame, None, false)
}
if r.Help.Active || !r.SignRevealed(sign) {
t.Fatal("hjälpen ska vara klar och skylten avslöjad")
}
if know.Count() != 1 || r.Learned == "" || !know.Knows(r.Learned) || know.Helps != 1 {
t.Fatalf("lärde sig fel: count=%d learned=%q helps=%d", know.Count(), r.Learned, know.Helps)
}
if first := lang.FirstUnknownWord(r.Dict, lang.NewKnowledge(), l.SignAt(sign).Text["ja"]); first != r.Learned {
t.Fatalf("ska lära sig första okända ordet %q, lärde sig %q", first, r.Learned)
}
if r.AskHelp() {
t.Fatal("en avslöjad skylt behöver ingen hjälp")
}
}
func TestHelpIsCancelledByWalkingAway(t *testing.T) {
l := loadLevel(t, "001-flygplatsen")
r := NewRun(l, 6, loadDict(t), nil)
sign := l.Find(level.Sign)[1]
walk(t, r, l.Path(r.Player.Tile, sign, r.CanEnter))
r.AskHelp()
r.Step(frame, Left, false)
if r.Help.Active {
t.Fatal("att gå iväg ska avbryta hjälpen")
}
if r.Know.Count() != 0 || r.SignRevealed(sign) {
t.Fatal("inget lärt, inget avslöjat")
}
}
func TestNoHelpOffSign(t *testing.T) {
l := loadLevel(t, "001-flygplatsen")
r := NewRun(l, 6, loadDict(t), nil)
if r.AskHelp() {
t.Fatal("ingen skylt på startrutan")
}
}

View File

@@ -48,9 +48,9 @@
}, },
{ {
"text": { "text": {
"en": "\u003c- Track 8 Chuo Line (Tokyo) Track 14 Yamanote Line (Shibuya) -\u003e", "en": "<- Track 8 Chuo Line (Tokyo) Track 14 Yamanote Line (Shibuya) ->",
"ja": "← 8番線 中央線 東京方面 14番線 山手線 渋谷方面 →", "ja": "← 8番線 中央線 東京方面 14番線 山手線 渋谷方面 →",
"sv": "\u003c- Spår 8 Chuo-linjen (Tokyo) Spår 14 Yamanote-linjen (Shibuya) -\u003e" "sv": "<- Spår 8 Chuo-linjen (Tokyo) Spår 14 Yamanote-linjen (Shibuya) ->"
}, },
"x": 11, "x": 11,
"y": 15 "y": 15
@@ -58,7 +58,7 @@
{ {
"text": { "text": {
"en": "Track 14 Yamanote Line for Shibuya: the gate in the south-east corner.", "en": "Track 14 Yamanote Line for Shibuya: the gate in the south-east corner.",
"ja": "14番線 山手線 渋谷方面 → 南東口改札", "ja": "14番線 山手線 渋谷方面 → 南東口 改札",
"sv": "Spår 14 Yamanote-linjen mot Shibuya: spärren i sydöstra hörnet." "sv": "Spår 14 Yamanote-linjen mot Shibuya: spärren i sydöstra hörnet."
}, },
"x": 15, "x": 15,

View File

@@ -1,8 +1,10 @@
# resources/text – text och språk # resources/text – text och språk
- `sv.json`, `en.json` – UI-strängar per språk (platt nyckel → text). - `sv.json`, `en.json` – UI-strängar per språk (platt nyckel → text).
- `jp-dictionary.json` – ordboken över japanska skyltord: tecken, läsning - `jp-dictionary.json` – ordboken över japanska skyltord: nyckel = ordet på
(romaji) och översättning. Används av skyltsystemet för att visa hel/delvis japanska, `kana` (läsning), `romaji`, `en`, `sv`, `tier` (1 = vanligast).
översättning baserat på vad spelaren lärt sig. Nycklar som börjar med `_` ignoreras. **Alla japanska ord i banorna**
- `fonts/` – (kommer) pixelfont med stöd för kana + kanji, t.ex. PixelMplus (skyltar, bannamn, resmål, linjer) måste finnas här – testet
eller Misaki. Licensfil ska ligga bredvid fonten. `internal/lang.TestAllJapaneseInLevelsIsInDictionary` kontrollerar det.
- `fonts/` – PixelMplus 10/12 px (kana + kanji, M+ FONT LICENSE). Se
`fonts/README.md`.

View File

@@ -30,5 +30,15 @@
"hud.sign": "Sign", "hud.sign": "Sign",
"result.wrong_detail": "You went to %s instead of %s.", "result.wrong_detail": "You went to %s instead of %s.",
"result.gates": "Gates", "result.gates": "Gates",
"result.card": "Card left" "result.card": "Card left",
"hud.japanese": "日本語",
"hud.words": "words",
"hud.track_suffix_ja": "番線",
"hud.for_ja": "方面",
"hud.english": "EN",
"hud.help_prompt": "H: ask someone for help (%.0f s)",
"hud.help_progress": "Asking a passer-by…",
"hud.learned": "New word",
"hud.revealed": "Translated",
"result.helps": "asked for help"
} }

View File

@@ -0,0 +1,16 @@
M+ FONTS Copyright (C) 2002-2013 M+ FONTS PROJECT
-
LICENSE_E
These fonts are free software.
Unlimited permission is granted to use, copy, and distribute them, with
or without modification, either commercially or noncommercially.
THESE FONTS ARE PROVIDED "AS IS" WITHOUT WARRANTY.
http://mplus-fonts.sourceforge.jp/mplus-outline-fonts/

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,15 @@
# Fonter
**PixelMplus** 10 och 12 px (Regular) av itouhiro, baserad på M+ BITMAP FONTS
(Copyright 2002-2013 M+ FONTS PROJECT). Pixelfont med JIS nivå 1+2-kanji,
kana och Latin-1. Hämtad från <https://github.com/itouhiro/PixelMplus>
(release v1.0.0, 2013-06-02).
Licens: **M+ FONT LICENSE** – fri användning, kopiering och vidaredistribution,
även kommersiellt, utan garanti. Se `LICENSE-PixelMplus.txt`.
- `PixelMplus10-Regular.ttf` – UI och skyltar (halvbredd 5 px, fullbredd 10 px).
- `PixelMplus12-Regular.ttf` – rubriker.
Rita alltid i exakt 10 resp. 12 px på heltalspositioner så att pixlarna
hamnar på rutnätet.

View File

@@ -1,23 +1,423 @@
{ {
"_comment": "Skyltord. Nyckel = japanska. kana = läsning i hiragana/katakana, romaji = latinsk läsning. tier = hur tidigt ordet brukar läras (1 = allra vanligast).", "_comment": "Skyltord. Nyckel = japanska. kana = läsning, romaji = latinsk läsning, sv/en = översättning, tier = hur tidigt ordet brukar läras (1 = allra vanligast). Alla japanska ord i banorna måste finnas här (testas).",
"出口": { "kana": "でぐち", "romaji": "deguchi", "en": "Exit", "sv": "Utgång", "tier": 1 }, "出口": {
"入口": { "kana": "いりぐち", "romaji": "iriguchi", "en": "Entrance", "sv": "Ingång", "tier": 1 }, "kana": "でぐち",
"改札": { "kana": "かいさつ", "romaji": "kaisatsu", "en": "Ticket gate", "sv": "Spärr", "tier": 1 }, "romaji": "deguchi",
"のりば": { "kana": "のりば", "romaji": "noriba", "en": "Platform", "sv": "Perrong", "tier": 1 }, "en": "exit",
"番線": { "kana": "ばんせん", "romaji": "bansen", "en": "Track no.", "sv": "Spår nr", "tier": 1 }, "sv": "utgång",
"切符": { "kana": "きっぷ", "romaji": "kippu", "en": "Ticket", "sv": "Biljett", "tier": 1 }, "tier": 1
"トイレ": { "kana": "トイレ", "romaji": "toire", "en": "Toilet", "sv": "Toalett", "tier": 1 }, },
"方面": { "kana": "ほうめん", "romaji": "hōmen", "en": "Direction / for", "sv": "Riktning / mot", "tier": 2 }, "入口": {
"乗換": { "kana": "のりかえ", "romaji": "norikae", "en": "Transfer", "sv": "Byte", "tier": 2 }, "kana": "いりぐち",
"快速": { "kana": "かいそく", "romaji": "kaisoku", "en": "Rapid", "sv": "Snabbtåg", "tier": 2 }, "romaji": "iriguchi",
"各駅停車": { "kana": "かくえきていしゃ", "romaji": "kakueki teisha", "en": "Local (all stops)", "sv": "Lokaltåg (alla stopp)", "tier": 3 }, "en": "entrance",
"特急": { "kana": "とっきゅう", "romaji": "tokkyū", "en": "Limited express", "sv": "Expresståg", "tier": 3 }, "sv": "ingång",
"空港": { "kana": "くうこう", "romaji": "kūkō", "en": "Airport", "sv": "Flygplats", "tier": 2 }, "tier": 1
"東口": { "kana": "ひがしぐち", "romaji": "higashiguchi", "en": "East exit", "sv": "Östra utgången", "tier": 2 }, },
"西口": { "kana": "にしぐち", "romaji": "nishiguchi", "en": "West exit", "sv": "Västra utgången", "tier": 2 }, "改札": {
"南口": { "kana": "みなみぐち", "romaji": "minamiguchi", "en": "South exit", "sv": "Södra utgången", "tier": 2 }, "kana": "かいさつ",
"北口": { "kana": "きたぐち", "romaji": "kitaguchi", "en": "North exit", "sv": "Norra utgången", "tier": 2 }, "romaji": "kaisatsu",
"終点": { "kana": "しゅうてん", "romaji": "shūten", "en": "Terminus", "sv": "Slutstation", "tier": 3 }, "en": "ticket gate",
"自動券売機": { "kana": "じどうけんばいき", "romaji": "jidō kenbaiki", "en": "Ticket machine", "sv": "Biljettautomat", "tier": 3 }, "sv": "spärr",
"チャージ": { "kana": "チャージ", "romaji": "chāji", "en": "Top up (IC card)", "sv": "Fyll på (kort)", "tier": 2 } "tier": 1
},
"のりば": {
"kana": "のりば",
"romaji": "noriba",
"en": "platform",
"sv": "perrong",
"tier": 1
},
"番線": {
"kana": "ばんせん",
"romaji": "bansen",
"en": "track",
"sv": "spår",
"tier": 1
},
"切符": {
"kana": "きっぷ",
"romaji": "kippu",
"en": "ticket",
"sv": "biljett",
"tier": 1
},
"トイレ": {
"kana": "トイレ",
"romaji": "toire",
"en": "Toilet",
"sv": "Toalett",
"tier": 1
},
"方面": {
"kana": "ほうめん",
"romaji": "hōmen",
"en": "for",
"sv": "mot",
"tier": 2
},
"乗換": {
"kana": "のりかえ",
"romaji": "norikae",
"en": "transfer",
"sv": "byte",
"tier": 2
},
"快速": {
"kana": "かいそく",
"romaji": "kaisoku",
"en": "Rapid",
"sv": "Snabbtåg",
"tier": 2
},
"各駅停車": {
"kana": "かくえきていしゃ",
"romaji": "kakueki teisha",
"en": "Local (all stops)",
"sv": "Lokaltåg (alla stopp)",
"tier": 3
},
"特急": {
"kana": "とっきゅう",
"romaji": "tokkyū",
"en": "Limited express",
"sv": "Expresståg",
"tier": 3
},
"空港": {
"kana": "くうこう",
"romaji": "kūkō",
"en": "Airport",
"sv": "Flygplats",
"tier": 2
},
"東口": {
"kana": "ひがしぐち",
"romaji": "higashiguchi",
"en": "East exit",
"sv": "Östra utgången",
"tier": 2
},
"西口": {
"kana": "にしぐち",
"romaji": "nishiguchi",
"en": "West exit",
"sv": "Västra utgången",
"tier": 2
},
"南口": {
"kana": "みなみぐち",
"romaji": "minamiguchi",
"en": "South exit",
"sv": "Södra utgången",
"tier": 2
},
"北口": {
"kana": "きたぐち",
"romaji": "kitaguchi",
"en": "North exit",
"sv": "Norra utgången",
"tier": 2
},
"終点": {
"kana": "しゅうてん",
"romaji": "shūten",
"en": "Terminus",
"sv": "Slutstation",
"tier": 3
},
"自動券売機": {
"kana": "じどうけんばいき",
"romaji": "jidō kenbaiki",
"en": "Ticket machine",
"sv": "Biljettautomat",
"tier": 3
},
"チャージ": {
"kana": "チャージ",
"romaji": "chāji",
"en": "Top up (IC card)",
"sv": "Fyll på (kort)",
"tier": 2
},
"新宿": {
"kana": "しんじゅく",
"romaji": "Shinjuku",
"en": "Shinjuku",
"sv": "Shinjuku",
"tier": 2
},
"渋谷": {
"kana": "しぶや",
"romaji": "Shibuya",
"en": "Shibuya",
"sv": "Shibuya",
"tier": 2
},
"東京": {
"kana": "とうきょう",
"romaji": "Tōkyō",
"en": "Tokyo",
"sv": "Tokyo",
"tier": 1
},
"上野": {
"kana": "うえの",
"romaji": "Ueno",
"en": "Ueno",
"sv": "Ueno",
"tier": 2
},
"京成上野": {
"kana": "けいせいうえの",
"romaji": "Keisei-Ueno",
"en": "Keisei-Ueno",
"sv": "Keisei-Ueno",
"tier": 3
},
"京成線": {
"kana": "けいせいせん",
"romaji": "Keisei-sen",
"en": "Keisei Line",
"sv": "Keisei-linjen",
"tier": 3
},
"山手線": {
"kana": "やまのてせん",
"romaji": "Yamanote-sen",
"en": "Yamanote Line",
"sv": "Yamanote-linjen",
"tier": 2
},
"中央線": {
"kana": "ちゅうおうせん",
"romaji": "Chūō-sen",
"en": "Chuo Line",
"sv": "Chuo-linjen",
"tier": 2
},
"成田エクスプレス": {
"kana": "なりたエクスプレス",
"romaji": "Narita Ekusupuresu",
"en": "Narita Express",
"sv": "Narita Express",
"tier": 3
},
"成田": {
"kana": "なりた",
"romaji": "Narita",
"en": "Narita",
"sv": "Narita",
"tier": 3
},
"駅": {
"kana": "えき",
"romaji": "eki",
"en": "station",
"sv": "station",
"tier": 1
},
"線": {
"kana": "せん",
"romaji": "sen",
"en": "line",
"sv": "linje",
"tier": 1
},
"南東": {
"kana": "なんとう",
"romaji": "nantō",
"en": "south-east",
"sv": "sydöst",
"tier": 3
},
"南西": {
"kana": "なんせい",
"romaji": "nansei",
"en": "south-west",
"sv": "sydväst",
"tier": 3
},
"南東口": {
"kana": "なんとうぐち",
"romaji": "nantōguchi",
"en": "South-east exit",
"sv": "Sydöstra utgången",
"tier": 3
},
"電車": {
"kana": "でんしゃ",
"romaji": "densha",
"en": "train",
"sv": "tåg",
"tier": 1
},
"バス": {
"kana": "バス",
"romaji": "basu",
"en": "bus",
"sv": "buss",
"tier": 1
},
"案内": {
"kana": "あんない",
"romaji": "annai",
"en": "Information",
"sv": "Information",
"tier": 2
},
"出発": {
"kana": "しゅっぱつ",
"romaji": "shuppatsu",
"en": "Departure",
"sv": "Avgång",
"tier": 2
},
"到着": {
"kana": "とうちゃく",
"romaji": "tōchaku",
"en": "Arrival",
"sv": "Ankomst",
"tier": 2
},
"注意": {
"kana": "ちゅうい",
"romaji": "chūi",
"en": "Caution",
"sv": "Varning",
"tier": 2
},
"禁煙": {
"kana": "きんえん",
"romaji": "kin'en",
"en": "No smoking",
"sv": "Rökning förbjuden",
"tier": 3
},
"お手洗い": {
"kana": "おてあらい",
"romaji": "otearai",
"en": "Restroom",
"sv": "Toalett",
"tier": 2
},
"階段": {
"kana": "かいだん",
"romaji": "kaidan",
"en": "Stairs",
"sv": "Trappa",
"tier": 2
},
"エレベーター": {
"kana": "エレベーター",
"romaji": "erebētā",
"en": "Elevator",
"sv": "Hiss",
"tier": 2
},
"エスカレーター": {
"kana": "エスカレーター",
"romaji": "esukarētā",
"en": "Escalator",
"sv": "Rulltrappa",
"tier": 2
},
"コンビニ": {
"kana": "コンビニ",
"romaji": "konbini",
"en": "Convenience store",
"sv": "Närbutik",
"tier": 1
},
"売店": {
"kana": "ばいてん",
"romaji": "baiten",
"en": "Kiosk",
"sv": "Kiosk",
"tier": 3
},
"飲み物": {
"kana": "のみもの",
"romaji": "nomimono",
"en": "Drinks",
"sv": "Dryck",
"tier": 2
},
"食べ物": {
"kana": "たべもの",
"romaji": "tabemono",
"en": "Food",
"sv": "Mat",
"tier": 2
},
"満員": {
"kana": "まんいん",
"romaji": "man'in",
"en": "Full (crowded)",
"sv": "Fullt",
"tier": 3
},
"遅延": {
"kana": "ちえん",
"romaji": "chien",
"en": "Delay",
"sv": "Försening",
"tier": 3
},
"普通": {
"kana": "ふつう",
"romaji": "futsū",
"en": "Local",
"sv": "Lokaltåg",
"tier": 2
},
"急行": {
"kana": "きゅうこう",
"romaji": "kyūkō",
"en": "Express",
"sv": "Expresståg",
"tier": 2
},
"新幹線": {
"kana": "しんかんせん",
"romaji": "shinkansen",
"en": "Shinkansen",
"sv": "Shinkansen (snabbtåg)",
"tier": 2
},
"地下鉄": {
"kana": "ちかてつ",
"romaji": "chikatetsu",
"en": "Subway",
"sv": "Tunnelbana",
"tier": 2
},
"次": {
"kana": "つぎ",
"romaji": "tsugi",
"en": "Next",
"sv": "Nästa",
"tier": 1
},
"分": {
"kana": "ふん",
"romaji": "fun",
"en": "minute(s)",
"sv": "minut(er)",
"tier": 1
},
"円": {
"kana": "えん",
"romaji": "en",
"en": "yen",
"sv": "yen",
"tier": 1
},
"京成本線": {
"kana": "けいせいほんせん",
"romaji": "Keisei Honsen",
"en": "Keisei Main Line",
"sv": "Keisei huvudlinje",
"tier": 3
}
} }

View File

@@ -30,5 +30,15 @@
"hud.sign": "Skylt", "hud.sign": "Skylt",
"result.wrong_detail": "Du åkte mot %s i stället för %s.", "result.wrong_detail": "Du åkte mot %s i stället för %s.",
"result.gates": "Spärrar", "result.gates": "Spärrar",
"result.card": "Kort kvar" "result.card": "Kort kvar",
"hud.japanese": "日本語",
"hud.words": "ord",
"hud.track_suffix_ja": "番線",
"hud.for_ja": "方面",
"hud.english": "EN",
"hud.help_prompt": "H: fråga någon om hjälp (%.0f s)",
"hud.help_progress": "Frågar en förbipasserande…",
"hud.learned": "Nytt ord",
"hud.revealed": "Översatt",
"result.helps": "frågat om hjälp"
} }

View File

@@ -22,6 +22,7 @@ import (
type shot struct { type shot struct {
level string level string
script string // $OUT ersätts med doc/img-mappen script string // $OUT ersätts med doc/img-mappen
args []string // extra flaggor till spelet
} }
// shots byggs i main eftersom vägarna räknas ut från bandatan (BFS), så att // shots byggs i main eftersom vägarna räknas ut från bandatan (BFS), så att
@@ -43,12 +44,15 @@ func buildShots(levelsDir string) error {
if err != nil { if err != nil {
return err 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", "改札,番線,方面"}
shots = []shot{ shots = []shot{
{"", "wait 10; shot $OUT/menu.png; quit"}, {"", "wait 10; shot $OUT/menu.png; quit", nil},
{"001-flygplatsen", "move right 4; move down 2; wait 5; shot $OUT/level-001.png; quit"}, {"001-flygplatsen", "move right 4; move down 2; wait 5; shot $OUT/level-001.png; quit", nil},
{"002-shinjuku", "move down 6; move right 4; wait 5; shot $OUT/level-002.png; quit"}, {"002-shinjuku", "move down 6; move right 4; wait 5; shot $OUT/level-002.png; quit", nil},
{"001-flygplatsen", toSign + "; wait 5; shot $OUT/sign.png; " + toGoal + {"001-flygplatsen", toSign + "; wait 5; shot $OUT/sign.png; press h; wait 200; shot $OUT/sign-help.png; wait 200; shot $OUT/sign-learned.png; " + toGoal +
"; waittrain; wait 30; shot $OUT/platform.png; press enter; wait 5; shot $OUT/result.png; quit"}, "; waittrain; wait 30; shot $OUT/platform.png; press enter; wait 5; shot $OUT/result.png; quit", known},
} }
return nil return nil
} }
@@ -110,6 +114,7 @@ func main() {
if s.level != "" { if s.level != "" {
args = append(args, "-level", s.level) args = append(args, "-level", s.level)
} }
args = append(args, s.args...)
run := exec.Command(exe, args...) run := exec.Command(exe, args...)
run.Dir, run.Stdout, run.Stderr = filepath.Dir(exe), os.Stdout, os.Stderr run.Dir, run.Stdout, run.Stderr = filepath.Dir(exe), os.Stdout, os.Stderr
if err := run.Run(); err != nil { if err := run.Run(); err != nil {