kaedeasset記事 · Notes霧走町 流氷に眠る

画面は JSON で書く。UE の Slate とブラウザの canvas が、同じ木を同じ配置で描く

Screens written in JSON: UE's Slate and a browser canvas draw the same tree with the same layout

 D14-4-T28-screens-written-in-json・技術・日本語入力でプレイする仕組み

画面と入力declarative UIclaude自然言語処理

タイトル・メニュー・セーブ・本編の捜査の画面まで、『霧走町 流氷に眠る』の画面は 10 枚の JSON です。
UE のゲームは Slate で、ブラウザ版は canvas で、同じ JSON を同じ配置で描きます。部品の位置を決める式は、仕様の一か所に書いてあります。

前の回(T27)は、判定の C++ を三つの外側で共有する話でした。
画面にも同じ問題があります。UE とブラウザで別々に画面を組むと、同じゲームなのに見た目と操作が少しずつずれていく。
そこで、画面を作るための UI フレームワーク MomijiUI を作り、MIT で公開しました(GitHub の kaedeasset/momiji-ui・版は 0.1.0)。
今夜は、その画面の書き方と、二つの描画器の作りです。

この回の位置と、前後の回#

区切り: T25〜T29 画面と入力(5 回のうち 4 回目)
先に読むとよい回: T27(C++ の核を三つの外側で共有する)・T25(入力欄の中の IME)
次に読む回: T29(二つの描画器を同じに保つ)

名前 — declarative UI#

正式名
declarative UI(宣言的 UI)
略称
なし
日本語
宣言的 UI、宣言型 UI
初出
語の初出は確かめられませんでした。よく引かれる言い方の一つが、Flutter の文書の UI = f(state) です。UE の Slate も、Epic の文書は「declarative syntax」で部品を組むものとして説明しています

宣言的 UI とは#

命令的な UI は、部品を作り、状態が変わるたびに部品を書き換えます。
「ラベルの文字を変えよ」「このボタンを無効にせよ」。
画面の今の姿は、それまでに出した命令の積み重ねで決まります。

宣言的 UI は、状態から画面を毎回作り直す、と考えます。
Flutter の文書の言い方では UI = f(state)。f は build の関数、state はアプリの状態で、状態が変わったら画面を書き換えるのではなく、作り直します。
React・SwiftUI・Jetpack Compose・Flutter が、この考え方の代表として挙がります。

書き方には二つの系統があります。
一つはプログラムの中に木を書く系統(Flutter の Dart、SwiftUI の Swift、UE の Slate の C++)。
もう一つは、木をデータやマークアップで書く系統(HTML、XAML、QML など)。

よく知られた弱点です。

  • 作り直しの費用。毎回作ると重いので、差分を取って変わった所だけ直す仕組み(reconciliation)が要る
  • 状態をどこが持つか。部品の中か、外か。入力欄の途中の文字列やスクロール量のような「画面の都合の状態」の置き場で揉める
  • 抜け道。どうしても命令で触りたい所(焦点・アニメーション・外部の部品)が出る
  • データで書く系統は、条件や繰り返しを書く小さな言語を持つことになり、それが育ちすぎる

MomijiUI の考え方#

MomijiUI は、データで書く系統です。仕様の最初に三つを置いています。

  1. デザインは JSON、判断は host。JSON は形(型・寸法・色・字の大きさ・並び・条件)を持ち、host(画面を使う側のプログラム)は値(文字列・数・一覧)だけを渡して、部品を組み立てない
  2. 配置は描画系に依らない純関数。同じ木・同じ値・同じ字の計測からは、どの実装でも同じ位置と大きさが出る
  3. 画素の一致は求めない。字の塗り方や画像の補間は描画系ごとに違う

部品の種類は 15 です。
Panel・VerticalLayout・HorizontalLayout・GridLayout・ScrollView・Modal・Label・Button・Image・TextInput・Slider・Toggle・ToggleGroup・Polyline・Ellipse。

座標は論理画面 1280×720 で、実画面へは縦横比を保って拡大し、余りは黒帯にします(実寸へは追従させない)。
fontSize はポイントで、96 dpi で描くので画素は round(fontSize × 4 / 3)。17 なら 23 px です。
行送りは lineH があればそれ、無ければ round(fontSize × 30 / 22)。
色は #rrggbb か #rrggbbaa で、それ以外の文字列は無視して既定の色にします。

いちばん小さい画面#

同梱の見本 counter.json を短くしたものです。

{ "root": { "type": "Panel", "x": 0, "y": 0, "w": 1280, "h": 720, "variant": "spacer",
"children": [ { "type": "VerticalLayout", "anchor": "center", "w": 640, "h": 560, "pad": 24, "gap": 14, "variant": "dark",
"children": [
{ "type": "Label", "text": "押した回数: {count}", "h": 32, "fontSize": 18, "align": "center" },
{ "type": "HorizontalLayout", "h": 48, "pad": 0, "gap": 12, "children": [
{ "type": "Button", "text": "+1", "action": "count:add", "flex": 1,
"anim": { "pulse": { "scale": 1.06, "periodMs": 900, "whileIf": "count == 0" } } },
{ "type": "Button", "text": "リセット", "action": "count:reset", "flex": 1, "variant": "ghost", "enabledIf": "count > 0" } ] },
{ "type": "Label", "text": "5 回押した", "h": 24, "showIf": "count >= 5" },
{ "type": "TextInput", "flex": 1, "h": 44, "bindVar": "name", "action": "name:add", "placeholder": "名前", "maxLen": 20 },
{ "type": "ScrollView", "id": "names", "flex": 1, "repeat": "names", "templateKey": "kind",
"templates": {
"name": { "type": "Label", "text": "・{item.text}", "fontSize": 15, "wrapW": 560 },
"empty": { "type": "Label", "text": "(まだ誰もいない)", "fontSize": 14, "wrapW": 560 } },
"children": [] } ] } ] } }

host がするのは四つだけです。画面の JSON を読む → 状態から値を計算して描く → 押された action で状態を変える → もう一度描く。
「+1」を押すと host に "count:add" が届き、host は count を増やして描き直す。ボタンを有効にするか、「5 回押した」を出すかは、JSON の条件が決めます。

host との約束: 描くたびに 5 段#

host は描くたびに、元の木の写しへ、決まった順で値を入れます(元の木は書き換えない)。

  1. 設定の値を bindVar の部品へ(Slider の値・Toggle の入切)
  2. repeat の展開。一覧の要素ごとに型(template)を複製して子にする。templates と templateKey があれば、要素の鍵で型を選ぶ
  3. {鍵} の置き換え。text・sprite・action の {鍵} を、host の文字列の値、数の値の順で置き換える。知らない鍵はそのまま残す
  4. 条件。enabledIf が偽なら押せない、highlightIf が真なら強調、showIf が偽か hideIf が真なら子ごと消す
  5. 配置して描き、当たり(押せる部品の矩形)を作る

1〜5 を一度にやる入口が、UE は KaedeUI::PresentScreen、ブラウザは MomijiUICanvas.present です。
「作り直し」は差分を取らず、毎回まるごとです。いちばん大きい本編の画面でも部品は 222 なので、差分の仕組みは持っていません。

条件は小さな言語です。
式 := 項 ("||" 項)、項 := 単 ("&&" 単)、単 := "!" 単 | "(" 式 ")" | 比較。
比較は「名前 演算子 数」だけで、知らない名前は 0 です。UE 側の実装(Interp.cpp の evalCondExpr)は再帰下降の数十行で、"!=" を否定と取り違えない所だけ気をつけています。

bool parseUnary() {
skipWs();
// '!' の次が '=' なら比較演算子 "!=" の一部=項に属するので否定にしない。
if (i < s.size() && s[i] == '!' && !(i + 1 < s.size() && s[i + 1] == '=')) {
++i;
return !parseUnary();
}
return parsePrimary();
}

条件で比べられるのは数だけで、文字列の比較も関数呼び出しもありません。
「言語が育ちすぎる」弱点を、数の比較と && || ! で止めています。

ゲームの画面では#

本編の捜査の画面(Game/ui/adv/workspace.json)は、今日数えると部品 222、repeat 15、条件つきの部品 102、action 45 でした。
内訳は Label 87・VerticalLayout 51・Button 30・HorizontalLayout 20・Image 17 ほか。
このファイルは手で書かず、スクリプト(Scripts/adv-workspace.mjs)が出しています。

応答の欄は、こういう形です(型の中身は略)。

{ "type": "ScrollView", "id": "responsePanel", "h": 256, "pad": 20, "gap": 12, "selectable": true,
"repeat": "responseRows", "templateKey": "kind",
"templates": {
"rule": { "type": "Image", "w": 1180, "h": 1, "color": "#ffffff2e" },
"input": { "type": "Label", "text": "▶ {item.text}", "fontSize": 17, "wrapW": 1180, "textColor": "#a9d8ff" },
"line": { "type": "Label", "text": "{item.text}", … },
"understood": { "type": "Label", "text": "理解した行動 : {item.text}", "fontSize": 13, … },
"recorded": { "type": "Label", "text": "記録 : {item.text}", "fontSize": 13, "textColor": "#7fd39a", … },
"empty": { "type": "Label", "text": "(ここに世界の反応が出る)", … } } }

host は、手の境の罫・自分の入力・台詞・「理解した行動」・「記録」を、{kind, text} の一覧で渡すだけです。
字の大きさ・色・折り返しの幅は JSON が持ち、host は知りません。
入力の行が淡い青、記録が緑で出るのは、この型の textColor です。

UE の host(SKaedeAdvShell)と、ブラウザ版の host(app.js)は、同じ値の名前・同じ action の語彙(newgame・continue・goto:画面・save:N・load:N など)を使います。
値を差し込む規則を変えるときは両方を直す、と約束しています。

二つの描画器#

UE 側は二つのモジュールです。
MomijiUICore が、JSON の読み込み・値の差し込み・条件・診断を持ちます。UE の型に依存しない C++ です。
MomijiUISlate が、配置(ComputeDrawList。描画の命令の一覧と当たりを返す純関数)と、Slate への描画(SKaedeUiCanvas)を持ちます。

ブラウザ側は ES module 1 枚(momiji-ui.mjs・1,911 行・依存なし)です。
配置(Renderer.layout)が描画の命令(ops)と当たりを作り、描画(Renderer.paint)が canvas の 2D API で描きます。
入力欄だけは本物の <input> を重ねます(T25)。

つまり、配置の式は C++ と JavaScript の二か所に書いてあります。
判定の核(T27)は一つのヘッダを共有できましたが、画面の配置はブラウザで C++ を動かしていないので、二つ書くしかありませんでした。
二つが同じであることは、同じフィクスチャ(今日 19 枚)を両方で配置して、矩形は 1 px 以内、文字の位置は 2 px 以内で突き合わせる照合で確かめています。照合の作りは次の回(T29)で書きます。

描く前の検査(診断)もあります。
知らない type・値の型違い・repeat の型の欠け・状態を持つ部品の id の重複は error、知らない鍵や色の書き間違いは warn です。
今日、ゲームの 10 枚を診断すると VALIDATE PASS・error 0・warn 26 でした。

名前どおりでない所 — declarative UI#

宣言的 UI としては、半分です。

画面の形と条件は宣言的です。状態から毎回まるごと作り直し、部品を命令で書き換えません。
けれど、部品の中に状態や振る舞いを持たせる仕組み(コンポーネントや、状態に結びつく関数)はありません。
判断は全部 host にあり、JSON には「この値が 0 でなければ出す」までしか書けない。
宣言的 UI のフレームワークというより、宣言的に書いた画面の木と、それを決まった規則で描く描画器、の方が正確です。

もう一つ、「どの描画系でも同じ」は、全部の鍵で言えるわけではありません。
10 月 12 日に足した見た目の鍵(枠の色と太さ・地の濃淡・文字の縁取りなど)は UE だけで描き、ブラウザ版は診断の表に名前を持つだけです。
仕様にそう書いてありますが、片方にしか無い機能は作らない、という仕様の別の文と食い違っています。

弱点と、採らなかった案 — declarative UI#

弱点です。

  • 色の書き間違いが、黙って既定の色になります。今日の warn 26 件は全部、本編の画面の textColor に "muted" と書いた所でした。仕様どおり無視されて既定の色で出ています。意図した色かどうかは、絵を見ないと分かりません。
  • 字の計測は描画系で 1〜2 px 違い、折り返しの境目ちょうどの文では行数が 1 変わることがあります。Windows の Chrome・Chromium・Firefox・WebKit と UE では照合で一致を見ていますが、製品版の Safari や Mac・Linux のブラウザは確かめていません。
  • 配置の式が二か所にある以上、片方だけ直す事故の余地は残ります。照合が通らなければ PASS と言えない、という手順でしか防いでいません。

採らなかった案です。

  • UMG(UE の標準の UI)を使う。ブラウザ版で同じ画面にできません。このプロジェクトは UMG を使っていません。
  • 画面を Slate の C++ で書く。宣言的には書けますが、C++ のコードなので、ブラウザでは使えず、作る人がビルドなしに直せません。
  • 両方とも HTML と CSS で描く。UE の中にブラウザを積むことになり、重く、ゲームの描画と重ね順が分かれます。
  • 配置の C++ を WebAssembly にしてブラウザでも動かす。配置の式は一か所になりますが、字の計測はブラウザの側でするので、計測を呼び戻す橋が要ります。いまは照合で二つを揃える方を選んでいます。

画面のデザインは JSON に、判断は host に。
その線を引いたおかげで、同じ画面を二つの世界で描けています。

検索語#

declarative UI
UI = f(state) Flutter
Slate declarative syntax Unreal
data-driven UI JSON layout

MomijiUI は GitHub の kaedeasset/momiji-ui で、MIT で公開しています。プロフィールのリンクからたどって、counter.json から試してみてください。

#claude #自然言語処理