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>
33
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).
|
||||
|
||||

|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 15 KiB |
BIN
doc/img/menu.png
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 22 KiB |
BIN
doc/img/sign-help.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
doc/img/sign-learned.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
doc/img/sign.png
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 23 KiB |
@@ -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 |
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
4
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
|
||||
)
|
||||
|
||||
12
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=
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
81
internal/lang/data_test.go
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
16
resources/text/fonts/LICENSE-PixelMplus.txt
Normal 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/
|
||||
BIN
resources/text/fonts/PixelMplus10-Regular.ttf
Normal file
BIN
resources/text/fonts/PixelMplus12-Regular.ttf
Normal file
15
resources/text/fonts/README.md
Normal 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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||