Compare commits

...
Author SHA1 Message Date
Yonokid 553fbf72fa Merge branch 'main' into text-labels 2026-09-18 16:17:31 +02:00
Yonokid 9382345c39 Merge pull request 'Lua: every user-visible string comes from skin_config (tex.skin_text)' (#35) from GanYiZhong/YataiDONNijiiro:lua-strings-to-config into main
Reviewed-on: Yonokid/YataiDONNijiiro#35
2026-09-18 16:17:00 +02:00
TaikoZhongandClaude Fable 5.1 ad0a3b69f2 entry: the dan board's strings are mode_dan, not entry_dan
The engine puts a Dan-i Dojo board on the entry screen whenever the skin
defines an entry_dan text; this skin lists the dojo inside song select
instead (75f5d27), so the strings moved into skin_config must not use that
key.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 01:46:19 +08:00
TaikoZhongandClaude Fable 5.1 8ea7934a73 Lua: every user-visible string comes from skin_config (tex.skin_text)
entry board titles/comments (entry_* / entry_*_comment_N), costume-menu guide words
(guide_select_label / guide_decide_label), the sort window (sort_window_* /
folder_sort_select / course_name_*), the dan HUD plate (dan_gauge_name /
dan_exam_text_gauge), the N曲 song limit (song_num_max) and the 演奏スキップON
badge (skip_badge_text) are read through the engine's tex.skin_text; the only
Japanese left in Lua are folder-name matchers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 01:45:48 +08:00
TaikoZhongandClaude Fable 5.1 a0e57c6e23 i18n: Korean for every dictionary string
Every skin_config text entry now carries ja / en / zh (Simplified) / ko.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 01:43:39 +08:00
Yonokid 8d106e1df5 Merge pull request 'zh is Simplified Chinese: convert the zh strings, translate the last Japanese-only labels' (#34) from GanYiZhong/YataiDONNijiiro:zh-simplified into main
Reviewed-on: Yonokid/YataiDONNijiiro#34
2026-09-15 17:48:00 +02:00
Yonokid 0f2aa5ddb4 Merge branch 'main' into zh-simplified 2026-09-15 17:47:49 +02:00
Yonokid c8b6568d9a Merge pull request 'Loading transition: the game hint block follows the interface language' (#33) from GanYiZhong/YataiDONNijiiro:hint-i18n into main
Reviewed-on: Yonokid/YataiDONNijiiro#33
2026-09-15 17:47:24 +02:00
Yonokid e256d42f9c Merge branch 'main' into hint-i18n 2026-09-15 17:46:35 +02:00
Yonokid 54afedc34b Merge pull request 'font.ttf 3.000: all five cabinet atlases (adds Simplified Chinese and Korean)' (#32) from GanYiZhong/YataiDONNijiiro:font-all-atlases into main
Reviewed-on: Yonokid/YataiDONNijiiro#32
2026-09-15 17:46:12 +02:00
Yonokid 80f4726c00 Merge branch 'main' into font-all-atlases 2026-09-15 17:44:59 +02:00
Yonokid 1c5d258314 Merge pull request 'Dan gauge: 1P red / 2P blue unfilled track' (#31) from GanYiZhong/YataiDONNijiiro:dan-gauge-player-colors into main
Reviewed-on: Yonokid/YataiDONNijiiro#31
2026-09-15 17:44:46 +02:00
Yonokid 0eea45143e Merge branch 'main' into dan-gauge-player-colors 2026-09-15 17:44:35 +02:00
Yonokid 65066bbe46 Merge pull request 'Dan result: 未到達 plates per language and the "not recorded" banner' (#30) from GanYiZhong/YataiDONNijiiro:dan-result-nosave into main
Reviewed-on: Yonokid/YataiDONNijiiro#30
2026-09-15 17:44:28 +02:00
Yonokid 37b8bd609b Merge branch 'main' into dan-result-nosave 2026-09-15 17:44:17 +02:00
Yonokid 67f7c48d6e Merge pull request 'Dan select: the cabinet's exam grid (soul gauge box + per-song segments)' (#29) from GanYiZhong/YataiDONNijiiro:dan-exam-grid into main
Reviewed-on: Yonokid/YataiDONNijiiro#29
2026-09-15 17:44:09 +02:00
Yonokid 4e79c6eed6 Merge branch 'main' into dan-exam-grid 2026-09-15 17:43:15 +02:00
TaikoZhongandClaude Fable 5.1 8b6dff1189 fonts: Han from the language atlas first, kana / Latin from jp (--han-order)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:45:15 +08:00
TaikoZhongandClaude Fable 5.1 7ba7e142ae fonts: every family keeps the jp brush glyphs first, its own atlas is the first fallback
font_cn / font_tw / font_kr are now jp first (kana, Latin, digits and every
kanji the jp atlas has stay in the brush face); cn / tw / kr only supply
the characters jp lacks. build_font.py gains --han-order for a separate
ideograph priority.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 14:23:51 +08:00
TaikoZhongandClaude Fable 5.1 638b8b37c5 zh is Simplified Chinese: convert the zh strings, translate the last Japanese-only labels
- every zh string in skin_config.json and the Lua scripts converted Traditional ->
  Simplified (OpenCC TSPhrases + TSCharacters; 裏 kept for 裏魔王); the sort window
  uses the cabinet's own Simplified rows (根据难度选择 / ★的个数 / 排序)
- dan_exam_text_more/less/gauge/gauge_less gain en / zh / ko
- dan_info 魂ゲージ + %以上 and the N曲 song limit follow the interface language
  (cabinet wordlist: clear_theme_soul_gauge, sort_window_songs)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 06:31:22 +08:00
TaikoZhongandClaude Fable 5.1 c13226e3b3 One font per cabinet family: font_cn / font_tw / font_en / font_kr
font_zh.ttf becomes font_cn.ttf (cn, tw, jp, en, kr) and gains siblings built the
same way: font_tw (tw, jp, cn, en, kr), font_en (en, jp, tw, cn, kr), font_kr
(kr, jp, en, tw, cn). font.ttf (jp first) is unchanged and stays the fallback.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 06:16:49 +08:00
TaikoZhongandClaude Fable 5.1 2dc940c6aa Loading transition: the game hint block follows the interface language
The 「ゲームのヒント」 block is a baked Japanese texture. For any other language the
three lines are drawn as text (skin_config transition_hint_title / _line1 / _line2,
en / zh / ko) at the rows measured off the texture, on the same fade; Japanese keeps
the texture. The title strings are the cabinet's own wordlist entries.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 06:15:35 +08:00
TaikoZhongandClaude Fable 5.1 d5ef78970e font_zh.ttf: Simplified Chinese glyph set first, tw/jp only for what it lacks
With the interface language set to Simplified Chinese the engine now loads
Graphics/font_zh.ttf, built with the cn atlas first (7 588 glyphs from cn,
11 966 from tw, 8 from jp) so shared characters take the Simplified face too,
as the cabinet does with its per-language atlas families.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 05:56:37 +08:00
TaikoZhongandClaude Fable 5.1 ecfaed05f7 font.ttf 3.000: all five cabinet atlases (adds Simplified Chinese and Korean)
Rebuilt from the cabinet's five _64 glyph atlases (jp, tw, cn, kr, en) instead of
jp+tw only: 22 382 glyphs, +1 956 Simplified Chinese and +2 737 Hangul, so zh-CN
and ko text no longer falls back to '?'. Same face, metrics and baseline as 2.000.
tools/font/build_font.py + FONT.md document the cabinet's font selection and the build.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 05:37:03 +08:00
TaikoZhongandClaude Fable 5.1 1a39365e21 Dan gauge: 1P red / 2P blue unfilled track
The dan gauge track was the same yellow for both sides. The cabinet colours
it per player like the normal gauge below the clear line: dark red for 1P,
dark teal-blue for 2P. Same body colours as gauge/1p_unfilled_normal and
gauge/2p_unfilled_normal; frame and alpha untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 02:15:03 +08:00
TaikoZhongandClaude Fable 5.1 a4a107f894 Dan result: 未到達 plates per language and the "not recorded" banner
From the cabinet's result movie: the unreached plate (ja / en / ko / zh
frames, 400x128) placed over a song row's counters, and the 610x70 notice
plate at the top right for a skipped run, with the notice text per language
(dan_result_nosave) drawn by the engine (Yonokid/YataiDON#180).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 11:33:08 +08:00
TaikoZhongandClaude Fable 5.1 7da693af35 Dan select: the cabinet's exam grid (soul gauge box + per-song segments)
Turn on the engine's dan_exam_grid layout: the soul gauge in its own box on
the left, the other conditions stacked on the right, and per-song conditions
split into 1st / 2nd / 3rd segments with each song's own border. The art has
shipped with the skin since the board rework; only the option was missing.
The segment strips are re-cut on a window that keeps the 2nd / 3rd badges'
rim (163x34) and the strip anchor moves by the same 3 / 1 px.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 10:22:45 +08:00
27 changed files with 782 additions and 127 deletions

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

@@ -128,5 +128,25 @@
"rank_plate": {
"x": 64,
"y": 57
},
"unreached_ja": {
"x": 1252,
"y": 171
},
"unreached_en": {
"x": 1252,
"y": 171
},
"unreached_ko": {
"x": 1252,
"y": 171
},
"unreached_zh": {
"x": 1252,
"y": 171
},
"nosave": {
"x": 1344,
"y": 22
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+300 -41
View File
@@ -1,4 +1,31 @@
{
"transition_hint_title": {
"font_size": 32,
"text": {
"ja": "ゲームのヒント",
"en": "Hints",
"zh": "游戏提示",
"ko": "게임의 힌트"
}
},
"transition_hint_line1": {
"font_size": 26,
"text": {
"ja": "太鼓の面とフチをたたいてリズムにのろう!",
"en": "Hit the drum face and rim and ride the rhythm!",
"zh": "敲击鼓面和鼓边,跟上节奏吧!",
"ko": "북면과 테두리를 두드려 리듬을 타자!"
}
},
"transition_hint_line2": {
"font_size": 26,
"text": {
"ja": "黄色い連打音符はたたいた数だけ得点アップ!",
"en": "Yellow drumroll notes: every hit adds to your score!",
"zh": "黄色连打音符敲得越多得分越高!",
"ko": "노란 연타 음표는 두드린 만큼 점수 UP!"
}
},
"screen": {
"parent": "PyTaikoGreen",
"width": 1920,
@@ -18,7 +45,8 @@
"costume_preset_slots": true,
"dan_title_horizontal": true,
"combo_color_tiers": true,
"gauge_cell_fade_in": true
"gauge_cell_fade_in": true,
"dan_exam_grid": true
}
},
"score_counter_pos": {
@@ -109,7 +137,8 @@
"text": {
"ja": "{0}曲目",
"en": "Song {0}",
"zh": "{0}曲目"
"zh": "{0}曲目",
"ko": "{0}곡째"
},
"outline": 2.3333333333333335
},
@@ -120,7 +149,8 @@
"text": {
"ja": "もどる",
"en": "BACK",
"zh": "返回"
"zh": "返回",
"ko": "돌아가기"
}
},
"transition_title": {
@@ -141,7 +171,8 @@
"text": {
"ja": "フリープレイ",
"en": "FREE PLAY",
"zh": "自由游戏"
"zh": "自由游戏",
"ko": "프리 플레이"
}
},
"entry_overlay_entry": {
@@ -155,7 +186,8 @@
"text": {
"ja": "2人プレイ",
"en": "2P",
"zh": "2P"
"zh": "2P",
"ko": "2인 플레이"
}
},
"credit_side_msg": {
@@ -165,7 +197,8 @@
"text": {
"ja": "太鼓をたたいてスタート!",
"en": "Hit the taiko to start!",
"zh": "敲打太鼓開始!"
"zh": "敲打太鼓开始!",
"ko": "북을 쳐서 시작!"
}
},
"crown_message_miss": {
@@ -175,7 +208,8 @@
"text": {
"ja": "もう少し\nがんばるドン!",
"en": "Good try.",
"zh": "下次\n加油鼓咚!"
"zh": "下次\n加油鼓咚!",
"ko": "조금 더\n힘내라 동!"
}
},
"crown_message_near": {
@@ -185,7 +219,8 @@
"text": {
"ja": "おしかったドン",
"en": "Almost.",
"zh": "可惜!\n就差一點了鼓咚…"
"zh": "可惜!\n就差一点了鼓咚…",
"ko": "아쉬웠다 동"
}
},
"crown_message_success": {
@@ -195,7 +230,8 @@
"text": {
"ja": "ノリノリだドン",
"en": "Cleared!\nCongrats, Don!",
"zh": "過關\n恭喜鼓咚!"
"zh": "过关\n恭喜鼓咚!",
"ko": "신난다 동"
}
},
"crown_message_perfect": {
@@ -205,7 +241,8 @@
"text": {
"ja": "よくできたドン!",
"en": "Awesome!",
"zh": "魂槽全滿!\n太厲害了鼓咚!"
"zh": "魂槽全满!\n太厉害了鼓咚!",
"ko": "잘했다 동!"
}
},
"song_select_indicator": {
@@ -249,7 +286,8 @@
"text": {
"en": "Select your outfit!",
"ja": "きせかえをえらぼう!",
"zh": "選擇服飾吧!"
"zh": "选择服饰吧!",
"ko": "옷을 골라 보자!"
}
},
"result_chara": {
@@ -288,7 +326,8 @@
"text": {
"ja": "ベストスコア更新!",
"en": "New High Score!",
"zh": "最高分更新!"
"zh": "最高分更新!",
"ko": "베스트 스코어 갱신!"
}
},
"course_name_0": {
@@ -298,7 +337,8 @@
"text": {
"ja": "かんたん",
"en": "Easy",
"zh": "簡單"
"zh": "简单",
"ko": "쉬움"
}
},
"course_name_1": {
@@ -308,7 +348,8 @@
"text": {
"ja": "ふつう",
"en": "Normal",
"zh": "普通"
"zh": "普通",
"ko": "보통"
}
},
"course_name_2": {
@@ -318,7 +359,8 @@
"text": {
"ja": "むずかしい",
"en": "Hard",
"zh": "困難"
"zh": "困难",
"ko": "어려움"
}
},
"course_name_3": {
@@ -328,7 +370,8 @@
"text": {
"ja": "おに",
"en": "Oni",
"zh": "魔王"
"zh": "魔王",
"ko": "오니"
}
},
"course_name_4": {
@@ -338,7 +381,8 @@
"text": {
"ja": "おに(裏)",
"en": "Ura",
"zh": "裏魔王"
"zh": "裏魔王",
"ko": "오니(우라)"
}
},
"guide_select_label": {
@@ -348,7 +392,8 @@
"text": {
"ja": "えらぶ",
"en": "Select",
"zh": "選擇"
"zh": "选择",
"ko": "선택"
}
},
"guide_decide_label": {
@@ -358,7 +403,8 @@
"text": {
"ja": "決定",
"en": "Decide",
"zh": "決定"
"zh": "决定",
"ko": "결정"
}
},
"nameplate_entry_left": {
@@ -376,7 +422,8 @@
"text": {
"ja": "かんたん",
"en": "Easy",
"zh": "簡單"
"zh": "简单",
"ko": "쉬움"
}
},
"chip_course_name_1": {
@@ -386,7 +433,8 @@
"text": {
"ja": "ふつう",
"en": "Normal",
"zh": "普通"
"zh": "普通",
"ko": "보통"
}
},
"chip_course_name_2": {
@@ -396,7 +444,8 @@
"text": {
"ja": "むずかしい",
"en": "Hard",
"zh": "困難"
"zh": "困难",
"ko": "어려움"
}
},
"chip_course_name_3": {
@@ -406,7 +455,8 @@
"text": {
"ja": "おに",
"en": "Oni",
"zh": "魔王"
"zh": "魔王",
"ko": "오니"
}
},
"chip_course_name_4": {
@@ -416,7 +466,8 @@
"text": {
"ja": "おに(裏)",
"en": "Ura",
"zh": "裏魔王"
"zh": "裏魔王",
"ko": "오니(우라)"
}
},
"modifier_text": {
@@ -462,7 +513,8 @@
"text": {
"en": "Used Skip Song",
"ja": "演奏スキップをつかいました",
"zh": "使用了跳略演奏"
"zh": "使用了跳略演奏",
"ko": "연주 스킵을 사용했습니다"
}
},
"game_nameplate_1p": {
@@ -477,21 +529,24 @@
"text": {
"en": "Skip Song",
"ja": "演奏スキップ",
"zh": "跳略演奏"
"zh": "跳略演奏",
"ko": "연주 스킵"
}
},
"modifier_name_neiro": {
"text": {
"en": "Sound",
"ja": "音色",
"zh": "音色"
"zh": "音色",
"ko": "음색"
}
},
"modifier_text_muon": {
"text": {
"en": "No sound",
"ja": "無音",
"zh": "無聲"
"zh": "无声",
"ko": "무음"
}
},
"modifier_title": {
@@ -501,7 +556,8 @@
"text": {
"ja": "演奏オプション",
"en": "Play Options",
"zh": "演奏選項"
"zh": "演奏选项",
"ko": "연주 옵션"
}
},
"song_select_chara_1p": {
@@ -662,7 +718,8 @@
"text": {
"en": "1-Player",
"ja": "1人プレイ",
"zh": "1人遊玩"
"zh": "1人游玩",
"ko": "1인 플레이"
}
},
"entry_credit_2p": {
@@ -672,7 +729,8 @@
"text": {
"en": "2-Player",
"ja": "2人プレイ",
"zh": "2人遊玩"
"zh": "2人游玩",
"ko": "2인 플레이"
}
},
"entry_credit_start": {
@@ -682,7 +740,8 @@
"text": {
"en": "Hit the drum to start!",
"ja": "太鼓をたたいてスタート!",
"zh": "敲打太鼓開始遊戲!"
"zh": "敲打太鼓开始游戏!",
"ko": "북을 쳐서 시작!"
}
},
"entry_cancel": {
@@ -692,7 +751,8 @@
"text": {
"en": "Cancel",
"ja": "つかわない",
"zh": "不使用"
"zh": "不使用",
"ko": "사용 안 함"
}
},
"chara_entry": {
@@ -774,14 +834,14 @@
"text": {
"ja": "{0}曲",
"zh": "{0}曲",
"en": "of {0}"
"en": "of {0}",
"ko": "{0}곡"
},
"outline": 2.0
},
"song_num_max_game": {
"x": 1814,
"y": 122
},
"note_arc_curve_height": {
"height": 608
@@ -873,8 +933,8 @@
"height": 132
},
"dan_exam_seg": {
"x": 14,
"y": 48,
"x": 11,
"y": 47,
"width": 327
},
"dan_select_exam_border_text": {
@@ -926,22 +986,45 @@
},
"dan_exam_text_more": {
"text": {
"ja": "%s 以上"
"ja": "%s 以上",
"en": "%s or more",
"zh": "%s 以上",
"ko": "%s 이상"
}
},
"dan_exam_text_less": {
"text": {
"ja": "%s 未満"
"ja": "%s 未満",
"en": "under %s",
"zh": "%s 未满",
"ko": "%s 미만"
}
},
"dan_result_nosave": {
"text": {
"ja": "今回の挑戦は記録されません",
"en": "This attempt will not be recorded",
"zh": "本次挑戰不會被記錄",
"ko": "이번 도전은 기록되지 않습니다"
}
},
"dan_result_nosave_text": {
"font_size": 24
},
"dan_exam_text_gauge": {
"text": {
"ja": "%s %以上"
"ja": "%s %以上",
"en": "%s%% or more",
"zh": "%s%以上",
"ko": "%s% 이상"
}
},
"dan_exam_text_gauge_less": {
"text": {
"ja": "%s %未満"
"ja": "%s %未満",
"en": "under %s%%",
"zh": "%s%未满",
"ko": "%s% 미만"
}
},
"note_arc_pivot": {
@@ -951,5 +1034,181 @@
"note_arc_pivot_2p": {
"x": 1282.19,
"y": -167.83
},
"entry_game_comment_1": {
"text": {
"ja": "すきな曲や、むずかしさを",
"en": "Select a song and",
"zh": "可选择喜爱的"
}
},
"entry_game_comment_2": {
"text": {
"ja": "えらんであそべるよ!",
"en": "difficulty to play!",
"zh": "歌曲、难度游玩哦!"
}
},
"entry_practice_comment_1": {
"text": {
"ja": "曲の一部をくりかえし練習して",
"en": "Practice a section of a song",
"zh": "反复练习歌曲的片段"
}
},
"entry_practice_comment_2": {
"text": {
"ja": "腕をみがこう!",
"en": "over and over to improve!",
"zh": "磨练技巧吧!"
}
},
"entry_costume_comment_1": {
"text": {
"ja": "どんちゃんのきせかえを",
"en": "Choose an outfit",
"zh": "可为咚酱选择"
}
},
"entry_costume_comment_2": {
"text": {
"ja": "えらんであそべるよ!",
"en": "for your Don-chan!",
"zh": "服饰游玩哦!"
}
},
"entry_ai_battle_comment_1": {
"text": {
"ja": "「AIどん」と勝つか負けるか",
"en": "See if you can beat AI-DON",
"zh": "与「AI咚」来一场"
}
},
"entry_ai_battle_comment_2": {
"text": {
"ja": "ギリギリのバトルをたのしもう!",
"en": "in this neck-and-neck competition!",
"zh": "不到最后难分胜负的对战吧!"
}
},
"mode_dan_comment_1": {
"text": {
"ja": "3曲連続して演奏しながら、",
"en": "Play three songs in a row",
"zh": "连续演奏3首指定曲"
}
},
"mode_dan_comment_2": {
"text": {
"ja": "お題の合格を目指そう!",
"en": "and aim to clear the task!",
"zh": "并达成题目的合格条件吧!"
}
},
"mode_dan_comment_3": {
"text": {
"ja": "合格すると腕前が認定されるよ!",
"en": "Your skills will be rewarded!",
"zh": "只要合格实力就会备受肯定哦!"
}
},
"entry_settings_comment_1": {
"text": {
"ja": "ゲームのせっていを",
"en": "Change the game's",
"zh": "可以变更"
}
},
"entry_settings_comment_2": {
"text": {
"ja": "かえられるよ!",
"en": "settings here!",
"zh": "游戏设定哦!"
}
},
"sort_window_diff": {
"text": {
"ja": "むずかしさ",
"en": "Difficulty",
"zh": "难度"
}
},
"sort_window_star": {
"text": {
"ja": "★の数",
"en": "Number of \"★\"",
"zh": "★的个数"
}
},
"sort_window_order": {
"text": {
"ja": "表示順",
"en": "Sort by",
"zh": "排序"
}
},
"sort_window_order_0": {
"text": {
"ja": "いつもどおり",
"en": "Default",
"zh": "通常"
}
},
"sort_window_order_1": {
"text": {
"ja": "未クリア優先",
"en": "Not cleared",
"zh": "未通关优先"
}
},
"sort_window_order_2": {
"text": {
"ja": "未フルコンボ優先",
"en": "Not Full Combo",
"zh": "未全连段优先"
}
},
"sort_window_order_3": {
"text": {
"ja": "未ドンダフルコンボ優先",
"en": "Not Donderful Combo",
"zh": "未全良连段优先"
}
},
"sort_window_songs": {
"text": {
"ja": "曲",
"en": "Songs",
"zh": "首"
}
},
"folder_sort_select": {
"text": {
"ja": "むずかしさからえらぶ",
"en": "Select by difficulty",
"zh": "根据难度选择"
}
},
"dan_gauge_name": {
"text": {
"ja": "魂ゲージ",
"en": "Soul Gauge",
"zh": "魂槽",
"ko": "클리어 게이지"
}
},
"skip_badge_text": {
"text": {
"ja": "演奏スキップON",
"en": "演奏スキップON",
"zh": "演奏スキップON"
}
},
"mode_dan": {
"text": {
"ja": "段位道場",
"en": "Dan-i Dojo",
"zh": "段位道场"
}
}
}
+5 -2
View File
@@ -251,10 +251,13 @@ function M:draw()
local x = GAUGE_X0 + CELL_PITCH * n - CELL_PITCH / 2 - 1
if self.label_for ~= ex.red then
self.label = text.create_raw_text(tostring(ex.red) .. "%以上",
-- skin_config: dan_gauge_name (魂ゲージ) and dan_exam_text_gauge ("%s %以上", %% = %)
local fmt = tex.skin_text("dan_exam_text_gauge"):gsub("%%%%", "\1")
local caption = fmt:gsub("%%s", tostring(ex.red)):gsub("\1", "%%")
self.label = text.create_raw_text(caption,
VALUE_SIZE, CAP_FILL, VALUE_OUTLINE, false,
4.0 / tex.get_screen_scale())
self.name_label = text.create_raw_text("魂ゲージ",
self.name_label = text.create_raw_text(tex.skin_text("dan_gauge_name"),
NAME_SIZE, CAP_FILL, CAP_OUTLINE, false,
3.0 / tex.get_screen_scale())
self.label_for = ex.red
+20 -40
View File
@@ -60,39 +60,23 @@ local TITLE_SPACING, INFO_SPACING = 2.0, 1.0
local MODES = {
game = {
frame = 0, outline = {251, 1, 29, 255},
titles = { ja = "演奏ゲーム", en = "Game", zh = "节奏游戏" },
comment = {
ja = { "すきな曲や、むずかしさを", "えらんであそべるよ!" },
en = { "Select a song and", "difficulty to play!" },
zh = { "可選擇喜愛的", "歌曲、難度遊玩哦!" },
},
title_key = "entry_game",
comment_keys = { "entry_game_comment_1", "entry_game_comment_2" },
},
practice = {
frame = 2, outline = {34, 138, 117, 255},
titles = { ja = "特訓モード", en = "Practice", zh = "练习游戏" },
comment = {
ja = { "曲の一部をくりかえし練習して", "腕をみがこう!" },
en = { "Practice a section of a song", "over and over to improve!" },
zh = { "反覆練習歌曲的片段", "磨練技巧吧!" },
},
title_key = "entry_practice",
comment_keys = { "entry_practice_comment_1", "entry_practice_comment_2" },
},
costume = {
frame = 4, outline = {200, 120, 222, 255},
titles = { ja = "きせかえ", en = "Costume", zh = "装扮" },
comment = {
ja = { "どんちゃんのきせかえを", "えらんであそべるよ!" },
en = { "Choose an outfit", "for your Don-chan!" },
zh = { "可為咚醬選擇", "服飾遊玩哦!" },
},
title_key = "entry_costume",
comment_keys = { "entry_costume_comment_1", "entry_costume_comment_2" },
},
ai = {
frame = 2, outline = {34, 138, 117, 255},
titles = { ja = "AIバトル", en = "AI Battle", zh = "AI斗争" },
comment = {
ja = { "「AIどん」と勝つか負けるか", "ギリギリのバトルをたのしもう!" },
en = { "See if you can beat AI-DON", "in this neck-and-neck competition!" },
zh = { "與「AI咚」來一場", "不到最後難分勝負的對戰吧!" },
},
title_key = "entry_ai_battle",
comment_keys = { "entry_ai_battle_comment_1", "entry_ai_battle_comment_2" },
},
-- ROUND 83 (r83-dandojo-as-mode) — 段位道場 is a MODE on the cabinet
-- (`mode_select.lua` CreateBoardList l.289-296 appends GameMode.kDani right after
@@ -103,24 +87,16 @@ local MODES = {
-- mode_select_comment_dani (three lines; every other board here has two).
dan = {
frame = 6, outline = {76, 60, 151, 255},
titles = { ja = "段位道場", en = "Dan-i Dojo", zh = "段位道場" },
comment = {
ja = { "3曲連続して演奏しながら、", "お題の合格を目指そう!", "合格すると腕前が認定されるよ!" },
en = { "Play three songs in a row", "and aim to clear the task!", "Your skills will be rewarded!" },
zh = { "連續演奏3首指定曲", "並達成題目的合格條件吧!", "只要合格實力就會備受肯定哦!" },
},
title_key = "mode_dan",
comment_keys = { "mode_dan_comment_1", "mode_dan_comment_2", "mode_dan_comment_3" },
},
-- ROUND 83: ゲーム設定 has no cabinet counterpart and was wearing 段位道場's board
-- (dani, frames 6/7) since ROUND 6. Moved onto `aprilfool`, baked as frames 9/10 by
-- bake_entry.py's EXTRA list; its title colour-add at label `aprilfool` is (0,132,212).
settings = {
frame = 9, outline = {0, 132, 212, 255},
titles = { ja = "ゲーム設定", en = "Settings", zh = "游戏設定" },
comment = {
ja = { "ゲームのせっていを", "かえられるよ!" },
en = { "Change the game's", "settings here!" },
zh = { "可以變更", "遊戲設定哦!" },
},
title_key = "entry_settings",
comment_keys = { "entry_settings_comment_1", "entry_settings_comment_2" },
},
}
@@ -129,16 +105,19 @@ local MODES = {
-- (pairs() order is unspecified). Ask the engine which language it is actually running
-- (`tex.language()`, script.cpp:693 -> config.general.language) and try that key first;
-- the old scan stays as the fallback for a language the table has no column for.
-- Titles and comments live in skin_config (entry_* / entry_*_comment_N); the engine hands
-- us the title it drew, so the mode is the one whose skin_config title matches.
local LANGS = { "ja", "en", "zh", "ko" }
local function find_mode(text_str)
local ok, cur = pcall(function() return tex.language() end)
if ok and type(cur) == "string" then
for key, m in pairs(MODES) do
if m.titles[cur] == text_str then return m, cur end
if tex.skin_text(m.title_key, cur) == text_str then return m, cur end
end
end
for key, m in pairs(MODES) do
for lang, t in pairs(m.titles) do
if t == text_str then return m, lang end
for _, lang in ipairs(LANGS) do
if tex.skin_text(m.title_key, lang) == text_str then return m, lang end
end
end
return MODES.settings, "ja", true
@@ -297,7 +276,8 @@ function EntryBox.new(text_str, font_size)
self.title = text.create_raw_text(text_str, TITLE_SIZE, { 255, 255, 255, 255 }, mode.outline, false, TITLE_INNER_T, TITLE_SPACING)
self.info = {}
if not unknown then
local lines = mode.comment[lang] or mode.comment.ja
local lines = {}
for i, key in ipairs(mode.comment_keys) do lines[i] = tex.skin_text(key, lang) end
for i, line in ipairs(lines) do
self.info[i] = text.create_raw_text(line, INFO_SIZE, INFO_COLOR, INFO_OUTLINE, false,
INFO_BORDER / tex.get_screen_scale(), INFO_SPACING)
+3 -19
View File
@@ -86,21 +86,6 @@ local ITEM_KEYS = {
"preset_1", "preset_2", "preset_3", "random_item",
}
local GUIDE_TEXT = {
ja = { "えらぶ", "決定" },
en = { "Move", "Confirm" },
zh = { "選擇", "決定" },
}
local function detect_lang(s)
if s:find("[\227-\239]") then
-- kana block (U+3040..U+30FF) => ja, otherwise CJK => zh
if s:find("\227[\129-\131]") then return "ja" end
return "zh"
end
return "en"
end
local function clamp(v, lo, hi)
if v < lo then return lo end
if v > hi then return hi end
@@ -140,10 +125,9 @@ function CostumeMenu.new(is_2p, text_str, title_x, title_y)
-- arcade: font 54 white, outline 6 (renderer default = black); thickness here is x1.5 on screen
self.title_text = text.create_raw_text(text_str, TITLE_SIZE, { 255, 255, 255, 255 }, { 0, 0, 0, 255 }, false, 4, 2.0)
local lang = detect_lang(text_str)
local gt = GUIDE_TEXT[lang] or GUIDE_TEXT.ja
self.guide_sel = text.create_raw_text(gt[1], GUIDE_SIZE, { 30, 30, 30, 255 }, { 30, 30, 30, 255 }, false, 0, 1.0)
self.guide_dec = text.create_raw_text(gt[2], GUIDE_SIZE, { 30, 30, 30, 255 }, { 30, 30, 30, 255 }, false, 0, 1.0)
-- guide words from skin_config (guide_select_label / guide_decide_label)
self.guide_sel = text.create_raw_text(tex.skin_text("guide_select_label"), GUIDE_SIZE, { 30, 30, 30, 255 }, { 30, 30, 30, 255 }, false, 0, 1.0)
self.guide_dec = text.create_raw_text(tex.skin_text("guide_decide_label"), GUIDE_SIZE, { 30, 30, 30, 255 }, { 30, 30, 30, 255 }, false, 0, 1.0)
self.costume_select_mode = false
-- arcade cursor_idx_ (1..5, here 0-based) and select_offset_
+1 -1
View File
@@ -61,7 +61,7 @@ function AllNetIcon:draw_song_max()
local limit = tex.song_limit()
if not limit or limit <= 0 then return end
if self._song_max_n ~= limit then
self._song_max = text.create_raw_text(tostring(limit) .. "曲",
self._song_max = text.create_raw_text((tex.skin_text("song_num_max"):gsub("{0}", tostring(limit))),
SONG_MAX_FONT, { 255, 255, 255, 255 }, { 0, 0, 0, 255 }, false, SONG_MAX_BORDER / tex.get_screen_scale(), 2.0)
self._song_max_n = limit
end
+23 -2
View File
@@ -153,6 +153,16 @@ function SongTransition.new(title, subtitle, is_second)
self.chara_l = tex.load_texture("global/rainbow_transition/chara_left")
self.chara_r = tex.load_texture("global/rainbow_transition/chara_right")
self.hint = tex.load_texture("global/rainbow_transition/chara_center")
-- The hint block is baked in Japanese. Any other interface language draws the
-- three lines as text (skin_config transition_hint_title / _line1 / _line2) at the
-- rows measured off the texture, on the same fade.
local lang = tex.language and tex.language() or "ja"
if lang ~= "ja" and text and text.create_text then
local white, black = { 255, 255, 255, 255 }, { 0, 0, 0, 255 }
self.hint_title = text.create_text("transition_hint_title", white, black, false, 4.0, 2.0)
self.hint_line1 = text.create_text("transition_hint_line1", white, black, false, 3.0, 2.0)
self.hint_line2 = text.create_text("transition_hint_line2", white, black, false, 3.0, 2.0)
end
-- 演奏スキップON badge (the reference build loading/loading_song.nulm, mc `enso_skip_on`)
self.skip_badge = tex.load_texture("loading/skip_badge/badge")
-- NOT latched here: GameScreen::on_screen_start builds the transition
@@ -170,7 +180,7 @@ function SongTransition.new(title, subtitle, is_second)
-- locale and so do we. Built HERE and not in skip_armed(): the glyph
-- atlas needs a frame to upload, so creating it on the first drawn frame
-- left the first frames showing an empty pill.
self.skip_text = text.create_raw_text("演奏スキップON", SKIP_TEXT_SIZE,
self.skip_text = text.create_raw_text(tex.skin_text("skip_badge_text"), SKIP_TEXT_SIZE,
{ 0, 0, 0, 255 }, { 0, 0, 0, 0 }, false, 0, 2)
end
return self
@@ -276,7 +286,18 @@ function SongTransition:draw_bg(total_offset, rainbow_up, mini_up, chara_down)
put(self.atlas_b, ORIGIN, 960 - bw * 0.5, 400 - bh * 0.5, bw, bh, info, SRC_BAND)
-- 6. the hint block (「ゲームのヒント」 + 3 lines) on the same curve: the
-- arcade turns every text field on at `stop` and off at `out`.
put(self.hint, REST_HINT, 0, 568, 1920, 512, info)
if self.hint_title then
-- texture rows (alpha > 200, outline included): title 165..202, line 1 228..257,
-- line 2 282..310, all centred on x = 960; the texture sits at y = 568.
local function centred(t, y)
if t then t:draw({ x = math.floor(960 - t.width / 2 + 0.5), y = y, fade = info }) end
end
centred(self.hint_title, 568 + 165)
centred(self.hint_line1, 568 + 228)
centred(self.hint_line2, 568 + 282)
else
put(self.hint, REST_HINT, 0, 568, 1920, 512, info)
end
end
-- 7. 演奏スキップON badge, top-left, on the same fade as the band. The arcade
+15 -22
View File
@@ -329,7 +329,7 @@ function SongSelect:draw_song_max()
if self._song_max_n ~= limit then
local sc = tex.get_skin_config("song_num") or {}
self._song_max_y = sc.y or 252
self._song_max = text.create_raw_text(tostring(limit) .. "曲",
self._song_max = text.create_raw_text((tex.skin_text("song_num_max"):gsub("{0}", tostring(limit))),
-- `common_song_select_number_of_songs` id 3 `text_total_songs`: size 24, border 3.5
sc.font_size or 24, { 255, 255, 255, 255 }, { 0, 0, 0, 255 }, false, ol(3.5), 2.0)
self._song_max_n = limit
@@ -2498,30 +2498,23 @@ local SW_CURSOR_CYCLE = 51.0
-- The strings are the cabinet's own word table (the reference build `datatable` wordlist), not invented:
-- sort_window_diff / _star / _order, course_easy..course_mania, sort_window_order_*,
-- folder_sort_select, sort_window_songs. Keyed by `tex.language()` with en as the fallback.
local SW_WORDS = {
header = {
ja = { "むずかしさ", "★の数", "表示順" },
en = { "Difficulty", 'Number of "★"', "Sort by" },
zh = { "難度", "★數", "顯示順序" },
},
course = {
ja = { "かんたん", "ふつう", "むずかしい", "おに", "おに" },
en = { "Easy", "Normal", "Hard", "Extreme", "Extreme" },
zh = { "簡單", "普通", "困難", "魔王", "魔王" },
},
order = {
ja = { "いつもどおり", "未クリア優先", "未フルコンボ優先", "未ドンダフルコンボ優先" },
en = { "Default", "Not cleared", "Not Full Combo", "Not Donderful Combo" },
zh = { "通常", "未通關優先", "未全連段優先", "未全良連段優先" },
},
title = { ja = "むずかしさからえらぶ", en = "Select by difficulty", zh = "以難易度選擇" },
songs = { ja = "曲", en = "Songs", zh = "首" },
-- skin_config keys (the strings themselves live there, per language)
local SW_KEYS = {
header = { "sort_window_diff", "sort_window_star", "sort_window_order" },
course = { "course_name_0", "course_name_1", "course_name_2", "course_name_3", "course_name_3" },
order = { "sort_window_order_0", "sort_window_order_1", "sort_window_order_2", "sort_window_order_3" },
title = "folder_sort_select",
songs = "sort_window_songs",
}
local function sw_words(group)
local lang = (tex.language and tex.language()) or "en"
local t = SW_WORDS[group]
return t[lang] or t.en
local k = SW_KEYS[group]
if type(k) == "table" then
local out = {}
for i, key in ipairs(k) do out[i] = tex.skin_text(key) end
return out
end
return tex.skin_text(k)
end
-- OutlinedText cache: key = group .. "\0" .. index (the strings never change at runtime).
+83
View File
@@ -0,0 +1,83 @@
# `Graphics/font.ttf` — how it is made, and how the cabinet actually uses its fonts
## What the cabinet ships
The cabinet has **no vector font**. `Data\x64\font\` holds pre-rasterised BC7 glyph atlases,
one family per language and one atlas per size/weight bucket, each with an XML sidecar
(`codepoint → rect`; no bearings, no kerning):
| language | buckets on disk | distinct codepoints | face (as drawn in the atlas) |
|---|---|---|---|
| `jp` | `30`, `32_B`, `32_DB`, `32_EB`, `64` | 7 532 | 30/32: rounded gothic in three weights; 64: brush/勘亭流-style display face |
| `tw` | `30`, `32`, `64` | 17 678 | same split |
| `cn` | `30`, `32`, `64` | 7 608 | same split (the 64 face is a heavier brush) |
| `kr` | `30`, `32`, `64` | 8 407 | same split |
| `en` | `30`, `32`, `64` | 299 | same split |
(The JP 39.06 dump has the same files minus `cn_*`; the CN 05 dump adds `cn_*`. The
`jp/tw/kr/en` XMLs are byte-identical between the two.)
## How a text record picks its atlas
* **Language** comes from the cabinet language setting: wordlist rows are looked up in the
language's column and rendered from that language's atlas family. Rows flagged
`fontType 0` (untranslated) always render from `jp`.
* **Size/weight bucket** is authored per text record in the `.nulm` (`DefineFont` entry
symbol `"0".."4"` → `64`, `30`, `32_EB`, `32_B`, `32_DB`). It is not language dependent.
For `en/tw/kr/cn` the three weighted `32_*` buckets are registered onto the plain `32`
atlas, so a record that asks for `32_EB` on a `cn` cabinet is drawn from `cn_32`.
* The only fallback is `jp_64` when the language itself is unknown; a codepoint missing from
the chosen atlas is simply not drawn. Of the 692 text records in the shipped movies, 449
use the 30/32 (rounded gothic) buckets and 168 use the 64 (brush) bucket.
YataiDON draws every string through a single `.ttf`, so one face has to serve all of it.
This skin's `font.ttf` is built from the **`_64` (brush) atlases**, which is the face used for
headings, song titles and most of the large text.
## Building `font.ttf`
`tools/font/build_font.py` merges the five `_64` atlases into one TrueType font:
1. Decode each `<lang>_64.nutexb` to a DDS (BC7, `nutexb_dump -o`) next to its XML.
2. For each language in priority order `jp, tw, cn, kr, en`, every codepoint that does not
yet have an inked glyph takes its bitmap from that atlas (blank placeholder cells are
skipped so a later atlas can supply the character).
3. Each bitmap is upscaled 4× (bicubic), thresholded at alpha ≥ 128, traced with potrace
and converted to quadratic outlines (`cu2qu`).
4. Metrics: `unitsPerEm 1024`, `scale = 1024 / fontPoint` of the atlas (16 units per pixel
for `jp_64`), `advance = cell width × scale`, baseline = the atlas's median bottom ink row
of its Han/Hangul glyphs, so the five families sit on one baseline with equal Han height.
`ascender 880 / descender −144`, as the earlier build.
```
pip install potracer fonttools pillow numpy
python build_font.py <dir with *_64.dds + *_64.xml> font.ttf --order jp,tw,cn,kr,en
```
Result (version 3.000): 22 382 glyphs — 7 499 from `jp`, 10 107 from `tw`, 1 956 from `cn`,
2 737 from `kr`, 82 from `en`. Coverage of each atlas's repertoire: jp 7521/7532,
tw 17651/17678, cn 7589/7608, kr 8070/8407, en 268/295 (the remainder are blank cells in
the atlases themselves). Version 2.000 had no `cn` or `kr` glyphs, which is why Simplified
Chinese and Korean text showed `?`.
## Per-language fonts (`Graphics/font_<family>.ttf`)
The engine loads `Graphics/font_<language code>.ttf` if present, else `Graphics/font_<cabinet family>.ttf`
(zh → cn, ko → kr, ja → jp), else `font.ttf`. The settings screen reloads the skin, so switching
language switches the font. This mirrors the cabinet, which draws everything from the language's own
atlas family and only lacks what that family does not have.
| file | atlas priority | used for |
|---|---|---|
| `font.ttf` | `jp, tw, cn, kr, en` | Japanese, and any language without its own file (unchanged 3.000) |
| `font_cn.ttf` | `cn, tw, jp, en, kr` | Simplified Chinese (`zh`) |
| `font_tw.ttf` | `tw, jp, cn, en, kr` | Traditional Chinese (`zh_tw` / `tw`) |
| `font_en.ttf` | `en, jp, tw, cn, kr` | English |
| `font_kr.ttf` | `kr, jp, en, tw, cn` | Korean (`ko`) |
```
python build_font.py <dir> font_cn.ttf --order cn,tw,jp,en,kr
python build_font.py <dir> font_tw.ttf --order tw,jp,cn,en,kr
python build_font.py <dir> font_en.ttf --order en,jp,tw,cn,kr
python build_font.py <dir> font_kr.ttf --order kr,jp,en,tw,cn
```
+157
View File
@@ -0,0 +1,157 @@
"""Build a TrueType font from the cabinet's bitmap glyph atlases (font/<lang>_64.{xml,nutexb}).
The cabinet ships no vector font: each language has its own BC7 atlas + XML (codepoint ->
rect, no bearings, no kerning), and RenderText picks the atlas family by the cabinet
language (fontType 0 rows -> jp) and the size/weight bucket per text record. YataiDON
draws everything through one .ttf, so this merges the five 64pt atlases into one font:
every codepoint takes its glyph from the first atlas (in --order) that has ink for it.
Per atlas: scale = upm / fontPoint (jp_64: 16 units per pixel)
advance = glyph width * scale (A: 51 px -> 816, same as the earlier build)
baseline row = median bottom ink row of the Han/Hangul glyphs (+1), so the
different atlases sit on one baseline; Latin-only atlases use 'A'.
Tracing: alpha >= 128 at 4x bicubic upscale -> potrace -> cubic -> quadratic (cu2qu).
usage: python build_font.py <atlas_dir with *_64.dds/*_64.xml> <out.ttf> [--order jp,tw,cn,kr,en] [--jobs N]
"""
import sys, os, argparse, statistics, time
import xml.etree.ElementTree as ET
import numpy as np
from PIL import Image
import potrace
from multiprocessing import Pool
UPM = 1024
UPSCALE = 4
ASCENT, DESCENT = 880, -144
def parse_xml(path):
r = ET.parse(path).getroot().find('font')
glyphs = {int(x.get('index')): (int(x.get('offsetU')), int(x.get('offsetV')),
int(x.get('width')), int(x.get('height'))) for x in r.findall('glyph')}
return glyphs, {k: int(v) for k, v in r.attrib.items()}
_A = None
def _init(alpha_path):
global _A
_A = np.load(alpha_path, mmap_mode='r')
def _trace(job):
cp, u, v, w, h, scale, baseline_px = job
a = np.asarray(_A[v:v + h, u:u + w])
rows = np.where(a.max(axis=1) >= 128)[0]
if rows.size == 0:
return cp, int(round(w * scale)), None # blank: advance only
big = np.array(Image.fromarray(a).resize((w * UPSCALE, h * UPSCALE), Image.BICUBIC)) < 128 # potracer: True = white
path = potrace.Bitmap(big).trace(turdsize=4,
alphamax=1.0, opticurve=True, opttolerance=0.2)
k = scale / UPSCALE
def P(p): return (p.x * k, (baseline_px * UPSCALE - p.y) * k)
contours = []
for curve in path.curves:
segs = [('M', P(curve.start_point))]
for s in curve.segments:
if s.is_corner:
segs.append(('L', P(s.c))); segs.append(('L', P(s.end_point)))
else:
segs.append(('C', P(s.c1), P(s.c2), P(s.end_point)))
contours.append(segs)
return cp, int(round(w * scale)), contours
def main():
ap = argparse.ArgumentParser()
ap.add_argument('atlas_dir'); ap.add_argument('out')
ap.add_argument('--order', default='jp,tw,cn,kr,en')
ap.add_argument('--han-order', default='', help='separate priority for CJK ideographs (U+2E80-9FFF, F900-FAFF, 20000+); --order then applies to everything else')
ap.add_argument('--jobs', type=int, default=max(1, os.cpu_count() - 4))
ap.add_argument('--limit', type=int, default=0, help='debug: glyphs per atlas')
ap.add_argument('--embolden', default='', help='lang=px: dilate that atlas alpha by px before tracing')
args = ap.parse_args()
langs = args.order.split(',')
han_langs = args.han_order.split(',') if args.han_order else langs
def is_han(cp): return 0x2E80 <= cp <= 0x9FFF or 0xF900 <= cp <= 0xFAFF or cp >= 0x20000
def rank(lang, cp): return (han_langs if is_han(cp) else langs).index(lang) if lang in (han_langs if is_han(cp) else langs) else 99
taken = {} # cp -> (advance, contours or None, lang)
all_langs = list(dict.fromkeys(langs + han_langs))
for lang in all_langs:
t0 = time.time()
glyphs, attr = parse_xml(os.path.join(args.atlas_dir, f'{lang}_64.xml'))
im = Image.open(os.path.join(args.atlas_dir, f'{lang}_64.dds'))
A = np.array(im)[:, :, 3]
emb = dict(kv.split('=') for kv in args.embolden.split(',') if kv)
if lang in emb and int(emb[lang]) > 0:
from scipy.ndimage import grey_dilation
k = 2 * int(emb[lang]) + 1; A = grey_dilation(A, size=(k, k))
scale = UPM / attr['fontPoint']
# baseline: where the Han / Hangul glyphs sit (Latin-only atlas: 'A')
bots = []
for cp, (u, v, w, h) in glyphs.items():
if 0x4E00 <= cp <= 0x9FFF or 0xAC00 <= cp <= 0xD7A3:
r = np.where(A[v:v + h, u:u + w].max(axis=1) >= 128)[0]
if r.size: bots.append(int(r.max()))
if bots:
baseline_px = statistics.median(bots) + 1
else:
u, v, w, h = glyphs[0x41]
r = np.where(A[v:v + h, u:u + w].max(axis=1) >= 128)[0]
baseline_px = int(r.max()) + 1 + 2 * 64 / attr['fontPoint'] # 'A' sits ~2 jp-px above the Han bottom
alpha_path = os.path.join(args.atlas_dir, f'_{lang}_alpha.npy')
np.save(alpha_path, A)
jobs = [(cp, u, v, w, h, scale, baseline_px) for cp, (u, v, w, h) in sorted(glyphs.items())
if (cp not in taken or taken[cp][1] is None and cp not in (0x20, 0x3000, 0xA0) or rank(lang, cp) < rank(taken[cp][2], cp))]
if args.limit: jobs = jobs[:args.limit]
got = 0
with Pool(args.jobs, initializer=_init, initargs=(alpha_path,)) as pool:
for cp, adv, contours in pool.imap_unordered(_trace, jobs, chunksize=64):
if contours is None:
if cp in (0x20, 0x3000, 0xA0) and cp not in taken:
taken[cp] = (adv, None, lang)
continue
if cp in taken and taken[cp][1] is not None and rank(lang, cp) >= rank(taken[cp][2], cp): continue
taken[cp] = (adv, contours, lang); got += 1
os.remove(alpha_path)
print(f'{lang}_64: fontPoint {attr["fontPoint"]} scale {scale:.2f} baseline {baseline_px:.1f}px '
f'-> {got} new glyphs ({time.time() - t0:.0f}s)', flush=True)
# ---- assemble
from fontTools.fontBuilder import FontBuilder
from fontTools.pens.ttGlyphPen import TTGlyphPen
from fontTools.pens.cu2quPen import Cu2QuPen
order = ['.notdef'] + [(f'u{cp:05X}' if cp > 0xFFFF else f'uni{cp:04X}') for cp in sorted(taken)]
fb = FontBuilder(UPM, isTTF=True)
fb.setupGlyphOrder(order)
cmap, glyf, hmtx = {}, {}, {}
pen = TTGlyphPen(None); glyf['.notdef'] = pen.glyph(); hmtx['.notdef'] = (512, 0)
for name, cp in zip(order[1:], sorted(taken)):
adv, contours, _ = taken[cp]
pen = TTGlyphPen(None)
if contours:
qpen = Cu2QuPen(pen, max_err=1.0, reverse_direction=False)
for segs in contours:
for s in segs:
if s[0] == 'M': qpen.moveTo(s[1])
elif s[0] == 'L': qpen.lineTo(s[1])
else: qpen.curveTo(s[1], s[2], s[3])
qpen.closePath()
g = pen.glyph()
glyf[name] = g
g.recalcBounds(glyf) if contours else None
hmtx[name] = (adv, getattr(g, 'xMin', 0) if contours else 0)
cmap[cp] = name
fb.setupCharacterMap(cmap)
fb.setupGlyf(glyf)
fb.setupHorizontalMetrics(hmtx)
fb.setupHorizontalHeader(ascent=ASCENT, descent=DESCENT)
fb.setupNameTable({'familyName': 'YataiDON HSS Atlas', 'styleName': 'Regular',
'uniqueFontIdentifier': 'YataiDON HSS Atlas 3.000', 'fullName': 'YataiDON HSS Atlas Regular',
'version': 'Version 3.000', 'psName': 'YataiDONHSSAtlas-Regular'})
fb.setupOS2(sTypoAscender=ASCENT, sTypoDescender=DESCENT, sTypoLineGap=0,
usWinAscent=ASCENT, usWinDescent=-DESCENT, fsType=0)
fb.setupPost()
fb.save(args.out)
from collections import Counter
print('saved', args.out, len(order), 'glyphs;', dict(Counter(v[2] for v in taken.values())))
if __name__ == '__main__':
main()
+155
View File
@@ -0,0 +1,155 @@
"""Build a TrueType font from the cabinet's bitmap glyph atlases (font/<lang>_64.{xml,nutexb}).
The cabinet ships no vector font: each language has its own BC7 atlas + XML (codepoint ->
rect, no bearings, no kerning), and RenderText picks the atlas family by the cabinet
language (fontType 0 rows -> jp) and the size/weight bucket per text record. YataiDON
draws everything through one .ttf, so this merges the five 64pt atlases into one font:
every codepoint takes its glyph from the first atlas (in --order) that has ink for it.
Per atlas: scale = upm / fontPoint (jp_64: 16 units per pixel)
advance = glyph width * scale (A: 51 px -> 816, same as the earlier build)
baseline row = median bottom ink row of the Han/Hangul glyphs (+1), so the
different atlases sit on one baseline; Latin-only atlases use 'A'.
Tracing: alpha >= 128 at 4x bicubic upscale -> potrace -> cubic -> quadratic (cu2qu).
usage: python build_font.py <atlas_dir with *_64.dds/*_64.xml> <out.ttf> [--order jp,tw,cn,kr,en] [--jobs N]
"""
import sys, os, argparse, statistics, time
import xml.etree.ElementTree as ET
import numpy as np
from PIL import Image
import potrace
from multiprocessing import Pool
UPM = 1024
UPSCALE = 4
ASCENT, DESCENT = 880, -144
def parse_xml(path):
r = ET.parse(path).getroot().find('font')
glyphs = {int(x.get('index')): (int(x.get('offsetU')), int(x.get('offsetV')),
int(x.get('width')), int(x.get('height'))) for x in r.findall('glyph')}
return glyphs, {k: int(v) for k, v in r.attrib.items()}
_A = None
def _init(alpha_path):
global _A
_A = np.load(alpha_path, mmap_mode='r')
def _trace(job):
cp, u, v, w, h, scale, baseline_px = job
a = np.asarray(_A[v:v + h, u:u + w])
rows = np.where(a.max(axis=1) >= 128)[0]
if rows.size == 0:
return cp, int(round(w * scale)), None # blank: advance only
big = np.array(Image.fromarray(a).resize((w * UPSCALE, h * UPSCALE), Image.BICUBIC)) < 128 # potracer: True = white
path = potrace.Bitmap(big).trace(turdsize=4,
alphamax=1.0, opticurve=True, opttolerance=0.5)
k = scale / UPSCALE
def P(p): return (p.x * k, (baseline_px * UPSCALE - p.y) * k)
contours = []
for curve in path.curves:
segs = [('M', P(curve.start_point))]
for s in curve.segments:
if s.is_corner:
segs.append(('L', P(s.c))); segs.append(('L', P(s.end_point)))
else:
segs.append(('C', P(s.c1), P(s.c2), P(s.end_point)))
contours.append(segs)
return cp, int(round(w * scale)), contours
def main():
ap = argparse.ArgumentParser()
ap.add_argument('atlas_dir'); ap.add_argument('out')
ap.add_argument('--order', default='jp,tw,cn,kr,en')
ap.add_argument('--jobs', type=int, default=max(1, os.cpu_count() - 4))
ap.add_argument('--limit', type=int, default=0, help='debug: glyphs per atlas')
ap.add_argument('--files', default='', help='lang=atlas stem overrides, e.g. jp=jp_32_EB,tw=tw_32')
ap.add_argument('--embolden', default='', help='lang=px: dilate that atlas alpha by px before tracing')
args = ap.parse_args()
langs = args.order.split(',')
stems = dict(kv.split('=') for kv in args.files.split(',') if kv)
taken = {} # cp -> (advance, contours or None, lang)
for lang in langs:
t0 = time.time()
stem = stems.get(lang, f'{lang}_64')
glyphs, attr = parse_xml(os.path.join(args.atlas_dir, f'{stem}.xml'))
im = Image.open(os.path.join(args.atlas_dir, f'{stem}.dds'))
A = np.array(im)[:, :, 3]
emb = dict(kv.split('=') for kv in args.embolden.split(',') if kv)
if lang in emb and int(emb[lang]) > 0:
from scipy.ndimage import grey_dilation
k = 2 * int(emb[lang]) + 1; A = grey_dilation(A, size=(k, k))
scale = UPM / attr['fontPoint']
# baseline: where the Han / Hangul glyphs sit (Latin-only atlas: 'A')
bots = []
for cp, (u, v, w, h) in glyphs.items():
if 0x4E00 <= cp <= 0x9FFF or 0xAC00 <= cp <= 0xD7A3:
r = np.where(A[v:v + h, u:u + w].max(axis=1) >= 128)[0]
if r.size: bots.append(int(r.max()))
if bots:
baseline_px = statistics.median(bots) + 1
else:
u, v, w, h = glyphs[0x41]
r = np.where(A[v:v + h, u:u + w].max(axis=1) >= 128)[0]
baseline_px = int(r.max()) + 1 + 2 * 64 / attr['fontPoint'] # 'A' sits ~2 jp-px above the Han bottom
alpha_path = os.path.join(args.atlas_dir, f'_{lang}_alpha.npy')
np.save(alpha_path, A)
jobs = [(cp, u, v, w, h, scale, baseline_px) for cp, (u, v, w, h) in sorted(glyphs.items())
if cp not in taken or taken[cp][1] is None and cp not in (0x20, 0x3000, 0xA0)]
if args.limit: jobs = jobs[:args.limit]
got = 0
with Pool(args.jobs, initializer=_init, initargs=(alpha_path,)) as pool:
for cp, adv, contours in pool.imap_unordered(_trace, jobs, chunksize=64):
if contours is None:
if cp in (0x20, 0x3000, 0xA0) and cp not in taken:
taken[cp] = (adv, None, lang)
continue
if cp in taken and taken[cp][1] is not None: continue
taken[cp] = (adv, contours, lang); got += 1
os.remove(alpha_path)
print(f'{stem}: fontPoint {attr["fontPoint"]} scale {scale:.2f} baseline {baseline_px:.1f}px '
f'-> {got} new glyphs ({time.time() - t0:.0f}s)', flush=True)
# ---- assemble
from fontTools.fontBuilder import FontBuilder
from fontTools.pens.ttGlyphPen import TTGlyphPen
from fontTools.pens.cu2quPen import Cu2QuPen
order = ['.notdef'] + [(f'u{cp:05X}' if cp > 0xFFFF else f'uni{cp:04X}') for cp in sorted(taken)]
fb = FontBuilder(UPM, isTTF=True)
fb.setupGlyphOrder(order)
cmap, glyf, hmtx = {}, {}, {}
pen = TTGlyphPen(None); glyf['.notdef'] = pen.glyph(); hmtx['.notdef'] = (512, 0)
for name, cp in zip(order[1:], sorted(taken)):
adv, contours, _ = taken[cp]
pen = TTGlyphPen(None)
if contours:
qpen = Cu2QuPen(pen, max_err=2.0, reverse_direction=False)
for segs in contours:
for s in segs:
if s[0] == 'M': qpen.moveTo(s[1])
elif s[0] == 'L': qpen.lineTo(s[1])
else: qpen.curveTo(s[1], s[2], s[3])
qpen.closePath()
g = pen.glyph()
glyf[name] = g
g.recalcBounds(glyf) if contours else None
hmtx[name] = (adv, getattr(g, 'xMin', 0) if contours else 0)
cmap[cp] = name
fb.setupCharacterMap(cmap)
fb.setupGlyf(glyf)
fb.setupHorizontalMetrics(hmtx)
fb.setupHorizontalHeader(ascent=ASCENT, descent=DESCENT)
fb.setupNameTable({'familyName': os.environ.get('FONT_FAMILY', 'YataiDON HSS Atlas'), 'styleName': 'Regular',
'uniqueFontIdentifier': os.environ.get('FONT_FAMILY', 'YataiDON HSS Atlas') + ' 3.000', 'fullName': os.environ.get('FONT_FAMILY', 'YataiDON HSS Atlas') + ' Regular',
'version': 'Version 3.000', 'psName': os.environ.get('FONT_FAMILY', 'YataiDON HSS Atlas').replace(' ', '') + '-Regular'})
fb.setupOS2(sTypoAscender=ASCENT, sTypoDescender=DESCENT, sTypoLineGap=0,
usWinAscent=ASCENT, usWinDescent=-DESCENT, fsType=0)
fb.setupPost()
fb.save(args.out)
from collections import Counter
print('saved', args.out, len(order), 'glyphs;', dict(Counter(v[2] for v in taken.values())))
if __name__ == '__main__':
main()