diff --git a/README.md b/README.md index f53c8fe..62e6eef 100644 --- a/README.md +++ b/README.md @@ -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+ 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å -riktigt: labyrint, skyltar, spärrar som drar från tågkortet, tåg som kommer -och går enligt tidtabell, och vinst eller förlust beroende på vilket tåg du -kliver på. Nästa är M3: japanska skyltar och språksystemet. Se -[doc/roadmap.md](doc/roadmap.md). +**Status:** M3 klar (2026-10-23) – skyltarna är på japanska. Ord du kan +lyser gult och översätts, resten står kvar tills du frågar någon om hjälp +och lär dig ett ord till. Lätta banor har engelska på skyltarna, svåra inte. +Nästa är M4: lämmeltåget och mätarna. Se [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 -stationen. Skyltar visar vilket spår som går vart. Spärrarna släpper bara -igenom dig om kortet har saldo. På perrongen (gul ledlinje) ser du vilket -tåg som går, och när. 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 med förlust. +stationen. Skyltarna är på japanska. Ord du kan markeras och översätts i +raden under, okända ord står kvar. Trycker du H frågar du en förbipasserande, +väntar några sekunder och får hela skylten översatt plus ett nytt ord. +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) @@ -100,14 +101,16 @@ metro-puzzle-jp # meny metro-puzzle-jp -level 001-flygplatsen # hoppa direkt in i en bana 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 -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 -version ``` ### Styrning -Pilar eller WASD går, Enter väljer, Esc går tillbaka, R spelar om. Gamepad -med standardlayout fungerar: d-pad/vänster spak, A = välj, B = tillbaka. +Pilar eller WASD går, Enter väljer/kliver på, H frågar om hjälp vid en +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 @@ -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 | | **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 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 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/res/ hittar och läser resources/ internal/rng/ seedad slump diff --git a/cmd/metro-puzzle-jp/main.go b/cmd/metro-puzzle-jp/main.go index 48653f9..18a4061 100644 --- a/cmd/metro-puzzle-jp/main.go +++ b/cmd/metro-puzzle-jp/main.go @@ -6,6 +6,7 @@ import ( "fmt" "log" "os" + "strings" "github.com/hajimehoshi/ebiten/v2" @@ -18,6 +19,7 @@ func main() { scale := flag.Int("scale", 4, "fönsterskala (intern upplösning 320x180 × skala)") 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)") + 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") flag.Parse() @@ -26,7 +28,7 @@ func main() { 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 { log.Fatalf("kunde inte starta: %v", err) } @@ -41,3 +43,13 @@ func main() { 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 +} diff --git a/doc/img/level-001.png b/doc/img/level-001.png index c9df6fd..b373d2e 100644 Binary files a/doc/img/level-001.png and b/doc/img/level-001.png differ diff --git a/doc/img/level-002.png b/doc/img/level-002.png index 216f6ef..32220dc 100644 Binary files a/doc/img/level-002.png and b/doc/img/level-002.png differ diff --git a/doc/img/menu.png b/doc/img/menu.png index f6a73fa..71a0827 100644 Binary files a/doc/img/menu.png and b/doc/img/menu.png differ diff --git a/doc/img/platform.png b/doc/img/platform.png index 3cc3bdd..4926385 100644 Binary files a/doc/img/platform.png and b/doc/img/platform.png differ diff --git a/doc/img/result.png b/doc/img/result.png index 5e0e3e1..98ca2bc 100644 Binary files a/doc/img/result.png and b/doc/img/result.png differ diff --git a/doc/img/sign-help.png b/doc/img/sign-help.png new file mode 100644 index 0000000..33299b9 Binary files /dev/null and b/doc/img/sign-help.png differ diff --git a/doc/img/sign-learned.png b/doc/img/sign-learned.png new file mode 100644 index 0000000..e1dd541 Binary files /dev/null and b/doc/img/sign-learned.png differ diff --git a/doc/img/sign.png b/doc/img/sign.png index a125515..282283e 100644 Binary files a/doc/img/sign.png and b/doc/img/sign.png differ diff --git a/doc/mal-och-tester.md b/doc/mal-och-tester.md index 29b6f84..8f29b35 100644 --- a/doc/mal-och-tester.md +++ b/doc/mal-och-tester.md @@ -56,6 +56,9 @@ att bygga på Linux som på Windows. minispel och event-timing att testa deterministiskt. - **Inga tester kräver display.** Smoke-testet av själva fönstret körs 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 @@ -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` | `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/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 | ### 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 | | ~~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 | | 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 | diff --git a/doc/plan.md b/doc/plan.md index acc33f7..e90b34b 100644 --- a/doc/plan.md +++ b/doc/plan.md @@ -32,7 +32,7 @@ brasse-linux01-runnern, inte Pi5:n. ## 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 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. @@ -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/`. - "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)? - Avgörs i M3 när skyltar renderas på riktigt. +- ~~Font~~ – beslutat: PixelMplus 10/12 px (M3). +- 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? - Hur många huvudbanor siktar vi på i storyn? (Påverkar M10:s omfattning.) diff --git a/doc/roadmap.md b/doc/roadmap.md index ae6661f..f58c731 100644 --- a/doc/roadmap.md +++ b/doc/roadmap.md @@ -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. | | 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. | -| 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. | | 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%`). | @@ -58,7 +58,8 @@ metro-puzzle-jp/ │ ├── res/ hittar och läser resources/ (ren Go) │ ├── rng/ seedad slump (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 │ ├── archtest/ beroenderegler │ └── … kommande: lang/, nav/, score/, save/, minigame/, ui/, audio/ @@ -68,7 +69,7 @@ metro-puzzle-jp/ ├── resources/ kopieras bredvid binären vid bygge │ ├── sprites/ bilder, tilesets, UI │ ├── 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) ├── 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 @@ -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, 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. -- Pixelfont med kana + kanji (beslut PixelMplus/Misaki, licens in i repot). -- Kunskapsmodell: vilka tecken/ord spelaren kan. Skyltar renderas med - delvis översättning, egen färg för det som är översatt, interaktion för - att se översättning. -- Andel engelska på skyltar styrs av banans svårighet. -- "Fråga en japan": NPC-interaktion med väntetid beroende på japanska-nivå. -- Hintar i UI/bana skalar med japanska-nivå. -- **Teknik:** `internal/lang` (ordbok, kunskap, översättningslogik – ren - Go), `text/v2` i `internal/game`. +- **Font:** PixelMplus 10/12 px (M+ FONT LICENSE, i repot under + `resources/text/fonts/`), ritad med `text/v2` på heltalspositioner. All + text i spelet går via den; debugfonten är borta. +- **Ordbok** (`resources/text/jp-dictionary.json`, 60 ord): japanska → + kana, romaji, sv, en, tier. Ett test kräver att all japanska i banorna + (skyltar, namn, resmål, linjer) finns i ordboken. +- **Kunskapsmodell** (`lang.Knowledge`): kända ord + kana-läsning. + Nivå 0–5 = kända ord / 4. Kana låses upp efter tre kända kana-ord. +- **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 - ä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 diff --git a/go.mod b/go.mod index e99ebe1..c151aa4 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,10 @@ require ( github.com/ebitengine/gomobile v0.0.0-20260820040257-d11f821a26a6 // indirect github.com/ebitengine/hideconsole v1.0.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/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect ) diff --git a/go.sum b/go.sum index 25511ba..a511953 100644 --- a/go.sum +++ b/go.sum @@ -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/purego v0.11.0 h1:jhp/D+Nyv7UUW8HAcmcjt2N2rYrYi9m3SL21k0Ua/NI= 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/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/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4= 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/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= 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= diff --git a/internal/game/assets.go b/internal/game/assets.go index b012d34..5ee36bc 100644 --- a/internal/game/assets.go +++ b/internal/game/assets.go @@ -1,16 +1,26 @@ package game import ( + "bytes" "fmt" "image" + "image/color" "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/res" "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). var tileOrder = []level.Tile{ 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 Player [4][3]*ebiten.Image // [riktning: ner, upp, vänster, höger][bildruta: stilla, gå1, gå2] Text map[string]string + Dict *lang.Dictionary + Font *text.GoTextFace // PixelMplus 10 px – UI och skyltar + FontBig *text.GoTextFace // PixelMplus 12 px – rubriker } // 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 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. func (a *Assets) T(key string) string { if s, ok := a.Text[key]; ok { diff --git a/internal/game/game.go b/internal/game/game.go index 13c6d93..3cbcc0d 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -6,6 +6,8 @@ package game import ( "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 @@ -26,6 +28,7 @@ type Scene interface { // Game är Ebitengine-tillståndet för hela programmet. type Game struct { Assets *Assets + Know *lang.Knowledge // spelarens japanska – följer med mellan banor (sparas i M8) scene Scene next Scene smokeLeft int // >0: avsluta automatiskt efter så många uppdateringar @@ -35,9 +38,10 @@ type Game struct { // Options styr uppstarten. type Options struct { - LevelID string // hoppa direkt in i en bana (tom = meny) - SmokeFrames int // >0 avslutar efter N uppdateringar (smoke-test) - Script string // skriptade steg för skärmdumpar/tester, se script.go + LevelID string // hoppa direkt in i en bana (tom = meny) + SmokeFrames int // >0 avslutar efter N uppdateringar (smoke-test) + 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. @@ -46,14 +50,17 @@ func New(opt Options) (*Game, error) { if err != nil { 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 g.script, err = parseScript(opt.Script); err != nil { return nil, err } } if opt.LevelID != "" { - s, err := NewLevelScene(a, opt.LevelID) + s, err := NewLevelScene(a, opt.LevelID, g.Know) if err != nil { return nil, err } diff --git a/internal/game/input.go b/internal/game/input.go index 257e92e..40b6126 100644 --- a/internal/game/input.go +++ b/internal/game/input.go @@ -71,6 +71,11 @@ func backJustPressed() bool { 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. func menuMove() int { switch { diff --git a/internal/game/scene_level.go b/internal/game/scene_level.go index 4f49b22..306e1af 100644 --- a/internal/game/scene_level.go +++ b/internal/game/scene_level.go @@ -7,9 +7,9 @@ import ( "path/filepath" "github.com/hajimehoshi/ebiten/v2" - "github.com/hajimehoshi/ebiten/v2/ebitenutil" "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/sim" ) @@ -17,8 +17,11 @@ import ( // Spelarens grundfart i rutor per sekund (blir en stat i M7). const playerSpeed = 6.0 -// Så länge "spärren nekade" visas efter försöket. -const deniedShowSec = 2.0 +// Hur länge tillfälliga meddelanden visas. +const ( + deniedShowSec = 2.0 + learnedShowSec = 3.0 +) // LevelScene är själva spelandet av en bana. All spellogik ligger i sim.Run; // scenen översätter input och ritar. @@ -29,15 +32,16 @@ type LevelScene struct { animT float64 } -// NewLevelScene laddar banan och ställer spelaren på startrutan. -func NewLevelScene(a *Assets, id string) (*LevelScene, error) { +// NewLevelScene laddar banan och ställer spelaren på startrutan. know är +// 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")) if err != nil { return nil, err } s := &LevelScene{ 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)), } s.followPlayer() @@ -67,6 +71,9 @@ func (s *LevelScene) Update(g *Game) error { g.SetScene(m) return nil } + if helpJustPressed() { + s.run.AskHelp() + } s.run.Step(dt, dirInput(), confirmJustPressed()) if s.run.Player.Moving() { s.animT += dt @@ -132,51 +139,109 @@ func (s *LevelScene) Draw(g *Game, screen *ebiten.Image) { 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) { r := s.run a := g.Assets 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 { return } - var lines []string - accent := color.RGBA{58, 68, 102, 255} - if tr, at, secs := r.PlatformHere(); tr != nil { - lines = append(lines, fmt.Sprintf("%s %s %s -> %s", a.T("hud.track"), tr.Track, tr.Line.Get("romaji"), tr.Destination.Get("romaji"))) - 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")} + lines, accent, bar := s.infoLines(a) + if entry := a.Dict.Lookup(r.Learned); entry != nil && r.Time-r.LearnedAt < learnedShowSec { + lines = append(lines, plainLine(fmt.Sprintf("%s: %s = %s (%s)", a.T("hud.learned"), r.Learned, + entry.Translation(UILang), entry.Romaji), colKnown)) } if len(lines) == 0 { 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 // (eller tåget under hen) annars skulle hamna bakom den. _, py := r.Player.WorldPos(TileSize) - y := float32(ScreenH) - h - if float32(py-s.cam.Y)+2*TileSize > y { + y := ScreenH - h + if int(py-s.cam.Y)+2*TileSize > y { y = 24 } - vector.DrawFilledRect(screen, 0, y, ScreenW, h, color.RGBA{24, 20, 37, 230}, false) - vector.DrawFilledRect(screen, 0, y, ScreenW, 2, accent, false) - for i, l := range lines { - ebitenutil.DebugPrintAt(screen, l, 4, int(y)+4+12*i) + vector.DrawFilledRect(screen, 0, float32(y), ScreenW, float32(h), colPanel, false) + vector.DrawFilledRect(screen, 0, float32(y), ScreenW, 2, accent, false) + a.drawLines(screen, lines, 4, y+4) + 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 +} diff --git a/internal/game/scene_menu.go b/internal/game/scene_menu.go index 7308a6b..55ed5a0 100644 --- a/internal/game/scene_menu.go +++ b/internal/game/scene_menu.go @@ -4,9 +4,9 @@ import ( "fmt" "image/color" "path/filepath" + "strings" "github.com/hajimehoshi/ebiten/v2" - "github.com/hajimehoshi/ebiten/v2/ebitenutil" "github.com/hajimehoshi/ebiten/v2/vector" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/buildinfo" @@ -14,9 +14,10 @@ import ( ) type menuEntry struct { - id string - name string - dest string + id string + name string + dest string + stars string } // MenuScene listar banorna och låter spelaren välja. @@ -39,7 +40,12 @@ func NewMenuScene(a *Assets) (*MenuScene, error) { m.err = err 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 } @@ -51,7 +57,7 @@ func (m *MenuScene) Update(g *Game) error { if n := len(m.entries); n > 0 { m.sel = (m.sel + menuMove() + n) % n 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 { m.err = err return nil @@ -63,21 +69,25 @@ func (m *MenuScene) Update(g *Game) error { } func (m *MenuScene) Draw(g *Game, screen *ebiten.Image) { + a := g.Assets screen.Fill(color.RGBA{24, 20, 37, 255}) vector.DrawFilledRect(screen, 0, 0, ScreenW, 22, color.RGBA{38, 43, 68, 255}, false) - ebitenutil.DebugPrintAt(screen, g.Assets.T("game.title"), 8, 4) - ebitenutil.DebugPrintAt(screen, g.Assets.T("menu.choose_level"), 8, 30) + a.DrawBig(screen, a.T("game.title"), 8, 5, colText) + a.Draw(screen, a.T("menu.choose_level"), 8, 30, colDim) for i, e := range m.entries { y := 46 + i*14 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 { - 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) - ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s (%s)", buildinfo.Version, buildinfo.Mode), 8, ScreenH-13) + 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) + 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) } diff --git a/internal/game/scene_result.go b/internal/game/scene_result.go index 9ee6650..d0f350e 100644 --- a/internal/game/scene_result.go +++ b/internal/game/scene_result.go @@ -2,10 +2,8 @@ package game import ( "fmt" - "image/color" "github.com/hajimehoshi/ebiten/v2" - "github.com/hajimehoshi/ebiten/v2/ebitenutil" "github.com/hajimehoshi/ebiten/v2/vector" "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 { switch { case inpututil_R(): - s, err := NewLevelScene(g.Assets, r.from.id) + s, err := NewLevelScene(g.Assets, r.from.id, g.Know) if err != nil { return err } @@ -41,31 +39,35 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) { r.from.Draw(g, screen) // banan i bakgrunden run := r.from.run 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 { - 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) - ebitenutil.DebugPrintAt(screen, headline, 28, 46) + a.DrawBig(screen, headline, 28, 44, accent) y := 62 if tr := run.Boarded; tr != nil { - // TODO(M3): japanska när fonten finns - ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s %s %s -> %s", a.T("hud.track"), tr.Track, - tr.Line.Get("romaji"), tr.Destination.Get("romaji")), 28, y) - y += 12 + a.Draw(screen, 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")), 28, y, colKnown) + y += LineH + 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 { - for _, l := range wrap(fmt.Sprintf(a.T("result.wrong_detail"), tr.Destination.Get("romaji"), - run.Level.Destination.Get("romaji")), 44) { - ebitenutil.DebugPrintAt(screen, l, 28, y) - y += 12 + for _, l := range a.wrapLine(plainLine(fmt.Sprintf(a.T("result.wrong_detail"), tr.Destination.Get("romaji"), + run.Level.Destination.Get("romaji")), colText), ScreenW-56) { + a.drawLines(screen, []line{l}, 28, y) + y += LineH } } } - y += 6 - ebitenutil.DebugPrintAt(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) - ebitenutil.DebugPrintAt(screen, a.T("result.hint"), 28, 128) + y += 4 + 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, colText) + 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) } diff --git a/internal/game/text.go b/internal/game/text.go index 39b8430..547db1b 100644 --- a/internal/game/text.go +++ b/internal/game/text.go @@ -1,27 +1,137 @@ package game -import "strings" +import ( + "image/color" + "strings" -// Debugfonten är 6 px bred per tecken; 320 px ger drygt 50 tecken. -const textCols = 50 + "github.com/hajimehoshi/ebiten/v2" -// wrap radbryter en text på ordgränser till högst width tecken per rad. -func wrap(s string, width int) []string { - var lines []string - cur := "" - for _, w := range strings.Fields(s) { + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang" +) + +type drawTarget = *ebiten.Image + +// 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 { - case cur == "": - cur = w - case len([]rune(cur))+1+len([]rune(w)) > width: - lines = append(lines, cur) - cur = w + case sh.Reading: + col = colReading + case sh.Known && lang.IsJapanese(sh.Text): + col = colKnown + case sh.Known: + col = base 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 != "" { + 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) } } - if cur != "" { - lines = append(lines, cur) - } - return lines } diff --git a/internal/lang/data_test.go b/internal/lang/data_test.go new file mode 100644 index 0000000..882f359 --- /dev/null +++ b/internal/lang/data_test.go @@ -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"]) + } + } +} diff --git a/internal/lang/dictionary.go b/internal/lang/dictionary.go new file mode 100644 index 0000000..9bb87ca --- /dev/null +++ b/internal/lang/dictionary.go @@ -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 +} diff --git a/internal/lang/knowledge.go b/internal/lang/knowledge.go new file mode 100644 index 0000000..7f85f29 --- /dev/null +++ b/internal/lang/knowledge.go @@ -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 +} diff --git a/internal/lang/lang_test.go b/internal/lang/lang_test.go new file mode 100644 index 0000000..68d1c49 --- /dev/null +++ b/internal/lang/lang_test.go @@ -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") + } +} diff --git a/internal/lang/render.go b/internal/lang/render.go new file mode 100644 index 0000000..012ef6e --- /dev/null +++ b/internal/lang/render.go @@ -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 "" +} diff --git a/internal/lang/tokenize.go b/internal/lang/tokenize.go new file mode 100644 index 0000000..43e91fd --- /dev/null +++ b/internal/lang/tokenize.go @@ -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 +} diff --git a/internal/sim/run.go b/internal/sim/run.go index 7364bdf..a35fa5b 100644 --- a/internal/sim/run.go +++ b/internal/sim/run.go @@ -1,6 +1,9 @@ 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. type Outcome int @@ -42,15 +45,42 @@ type Run struct { DeniedAt float64 // när spärren senast nekade (för HUD); < 0 = aldrig Outcome Outcome 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. -func NewRun(l *level.Level, speed float64) *Run { +// HelpState är en pågående "fråga någon om hjälp". +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{ - Level: l, - Player: NewMover(l.SpawnPoint(), speed), - Card: Card{Balance: StartBalanceYen}, - DeniedAt: -1, + Level: l, + Player: NewMover(l.SpawnPoint(), speed), + Card: Card{Balance: StartBalanceYen}, + 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 wasGate := r.inGate() r.Steps += r.Player.Step(dt, want, r.CanEnter) + r.stepHelp(dt) if r.Player.Tile != before && r.inGate() && !wasGate { r.Card.Pay(r.Level.FareYen) r.GatePasses++ @@ -136,3 +167,52 @@ func (r *Run) TrainVisible(t level.Tile) bool { at, _ := tr.Schedule.Status(r.Time) 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 +} diff --git a/internal/sim/run_test.go b/internal/sim/run_test.go index b96d66b..28e9eb3 100644 --- a/internal/sim/run_test.go +++ b/internal/sim/run_test.go @@ -1,13 +1,28 @@ package sim import ( + "math" "path/filepath" "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/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 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. func TestBotWinsLevel001(t *testing.T) { l := loadLevel(t, "001-flygplatsen") - r := NewRun(l, 6) + r := NewRun(l, 6, nil, nil) goal := l.Find(level.Platform)[0] path := l.Path(r.Player.Tile, goal, r.CanEnter) if path == nil { @@ -111,7 +126,7 @@ func TestBotWinsLevel001(t *testing.T) { func TestBotLosesOnWrongTrain(t *testing.T) { l := loadLevel(t, "001-flygplatsen") - r := NewRun(l, 6) + r := NewRun(l, 6, nil, nil) goal := l.Find(level.WrongPlatform)[0] walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter)) tr := waitForTrain(t, r) @@ -123,7 +138,7 @@ func TestBotLosesOnWrongTrain(t *testing.T) { func TestGateNeedsCardBalance(t *testing.T) { l := loadLevel(t, "001-flygplatsen") - r := NewRun(l, 6) + r := NewRun(l, 6, nil, nil) gate := l.Find(level.Gate)[0] r.Card.Balance = 0 if r.CanEnter(gate) { @@ -150,7 +165,7 @@ func TestGateNeedsCardBalance(t *testing.T) { func TestBoardOnlyWhenTrainPresent(t *testing.T) { l := loadLevel(t, "001-flygplatsen") - r := NewRun(l, 6) + r := NewRun(l, 6, nil, nil) goal := l.Find(level.Platform)[0] walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter)) // Spola fram till en tidpunkt då tåget är borta. @@ -169,7 +184,7 @@ func TestBoardOnlyWhenTrainPresent(t *testing.T) { func TestSignHereOnlyWhenStill(t *testing.T) { l := loadLevel(t, "001-flygplatsen") - r := NewRun(l, 6) + r := NewRun(l, 6, nil, nil) sign := l.Find(level.Sign)[0] walk(t, r, l.Path(r.Player.Tile, sign, r.CanEnter)) 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") } } + +// 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") + } +} diff --git a/resources/data/levels/002-shinjuku.json b/resources/data/levels/002-shinjuku.json index eb4235c..9c28fda 100644 --- a/resources/data/levels/002-shinjuku.json +++ b/resources/data/levels/002-shinjuku.json @@ -48,9 +48,9 @@ }, { "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番線 山手線 渋谷方面 →", - "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, "y": 15 @@ -58,7 +58,7 @@ { "text": { "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." }, "x": 15, @@ -108,4 +108,4 @@ "track": "8" } ] -} \ No newline at end of file +} diff --git a/resources/text/README.md b/resources/text/README.md index b2b1df6..87ac905 100644 --- a/resources/text/README.md +++ b/resources/text/README.md @@ -1,8 +1,10 @@ # resources/text – text och språk - `sv.json`, `en.json` – UI-strängar per språk (platt nyckel → text). -- `jp-dictionary.json` – ordboken över japanska skyltord: tecken, läsning - (romaji) och översättning. Används av skyltsystemet för att visa hel/delvis - översättning baserat på vad spelaren lärt sig. -- `fonts/` – (kommer) pixelfont med stöd för kana + kanji, t.ex. PixelMplus - eller Misaki. Licensfil ska ligga bredvid fonten. +- `jp-dictionary.json` – ordboken över japanska skyltord: nyckel = ordet på + japanska, `kana` (läsning), `romaji`, `en`, `sv`, `tier` (1 = vanligast). + Nycklar som börjar med `_` ignoreras. **Alla japanska ord i banorna** + (skyltar, bannamn, resmål, linjer) måste finnas här – testet + `internal/lang.TestAllJapaneseInLevelsIsInDictionary` kontrollerar det. +- `fonts/` – PixelMplus 10/12 px (kana + kanji, M+ FONT LICENSE). Se + `fonts/README.md`. diff --git a/resources/text/en.json b/resources/text/en.json index 5583654..4c66cb7 100644 --- a/resources/text/en.json +++ b/resources/text/en.json @@ -30,5 +30,15 @@ "hud.sign": "Sign", "result.wrong_detail": "You went to %s instead of %s.", "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" } diff --git a/resources/text/fonts/LICENSE-PixelMplus.txt b/resources/text/fonts/LICENSE-PixelMplus.txt new file mode 100644 index 0000000..3e6f89c --- /dev/null +++ b/resources/text/fonts/LICENSE-PixelMplus.txt @@ -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/ diff --git a/resources/text/fonts/PixelMplus10-Regular.ttf b/resources/text/fonts/PixelMplus10-Regular.ttf new file mode 100644 index 0000000..d8c10d5 Binary files /dev/null and b/resources/text/fonts/PixelMplus10-Regular.ttf differ diff --git a/resources/text/fonts/PixelMplus12-Regular.ttf b/resources/text/fonts/PixelMplus12-Regular.ttf new file mode 100644 index 0000000..3a7270b Binary files /dev/null and b/resources/text/fonts/PixelMplus12-Regular.ttf differ diff --git a/resources/text/fonts/README.md b/resources/text/fonts/README.md new file mode 100644 index 0000000..b0d22d4 --- /dev/null +++ b/resources/text/fonts/README.md @@ -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 +(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. diff --git a/resources/text/jp-dictionary.json b/resources/text/jp-dictionary.json index ba99b70..33ad95e 100644 --- a/resources/text/jp-dictionary.json +++ b/resources/text/jp-dictionary.json @@ -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).", - "出口": { "kana": "でぐち", "romaji": "deguchi", "en": "Exit", "sv": "Utgång", "tier": 1 }, - "入口": { "kana": "いりぐち", "romaji": "iriguchi", "en": "Entrance", "sv": "Ingång", "tier": 1 }, - "改札": { "kana": "かいさつ", "romaji": "kaisatsu", "en": "Ticket gate", "sv": "Spärr", "tier": 1 }, - "のりば": { "kana": "のりば", "romaji": "noriba", "en": "Platform", "sv": "Perrong", "tier": 1 }, - "番線": { "kana": "ばんせん", "romaji": "bansen", "en": "Track no.", "sv": "Spår nr", "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": "Direction / for", "sv": "Riktning / 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 } + "_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": "かいさつ", + "romaji": "kaisatsu", + "en": "ticket gate", + "sv": "spärr", + "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 + } } diff --git a/resources/text/sv.json b/resources/text/sv.json index 14c980f..88d00a6 100644 --- a/resources/text/sv.json +++ b/resources/text/sv.json @@ -30,5 +30,15 @@ "hud.sign": "Skylt", "result.wrong_detail": "Du åkte mot %s i stället för %s.", "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" } diff --git a/tools/shots/main.go b/tools/shots/main.go index a1eba7f..0177e8b 100644 --- a/tools/shots/main.go +++ b/tools/shots/main.go @@ -21,7 +21,8 @@ import ( type shot struct { 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 @@ -43,12 +44,15 @@ func buildShots(levelsDir string) error { if err != nil { return err } + // Skyltbilderna tas med några kända ord så att markeringen och den + // delvisa översättningen syns; hjälpen lär sedan in nästa ord. + known := []string{"-know", "改札,番線,方面"} shots = []shot{ - {"", "wait 10; shot $OUT/menu.png; quit"}, - {"001-flygplatsen", "move right 4; move down 2; wait 5; shot $OUT/level-001.png; quit"}, - {"002-shinjuku", "move down 6; move right 4; wait 5; shot $OUT/level-002.png; quit"}, - {"001-flygplatsen", toSign + "; wait 5; shot $OUT/sign.png; " + toGoal + - "; waittrain; wait 30; shot $OUT/platform.png; press enter; wait 5; shot $OUT/result.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", nil}, + {"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; 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", known}, } return nil } @@ -110,6 +114,7 @@ func main() { if s.level != "" { args = append(args, "-level", s.level) } + args = append(args, s.args...) run := exec.Command(exe, args...) run.Dir, run.Stdout, run.Stderr = filepath.Dir(exe), os.Stdout, os.Stderr if err := run.Run(); err != nil {