M3: japanska skyltar – pixelfont, ordbok, kunskap, delvis översättning, hjälp
All checks were successful
release / build-release (push) Successful in 1m42s
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:
81
internal/lang/data_test.go
Normal file
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
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
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
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
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
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
|
||||
}
|
||||
Reference in New Issue
Block a user