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

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

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

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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
}

View File

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -1,6 +1,9 @@
package sim
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
}

View File

@@ -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")
}
}