mcpField
E
editable-ui

editable-ui

github.com/decentraland開発4107 回インストール1 日前に更新
リポジトリ

editable-ui について

Creator Hubのビジュアルエディターで解析・編集できるUIコードの作成と改修を支援するスキルです。シーンUIを編集可能に保つReact-ECSのコード規約を説明します。

主な機能

  1. 01エディターが解析できるJSX規則
  2. 02コンポーネントとファイル構成
  3. 03既存UIの改修パターン
  4. 04レイアウトとインセットの挙動
  5. 05セルフチェックの手順

活用シーン

  1. 01Creator Hubのビジュアルエディター向けUI作成
  2. 02既存のReact-ECS画面の改修
  3. 03読み取り専用や不透明な表示の原因調査

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/decentraland/sdk-skills --skill editable-ui

インストール後、Claude Code で/editable-uiを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

references/
adapting-coded-ui.md
component-template.md
drag-slider.md
driver-pattern.md
interaction-helper.md

詳細

編集可能な UI(Creator Hub UI Designer)

Creator Hub の UI エディターには、独自の保存形式はありません。src/ui/ 内にあるシーンの実際の @dcl/react-ecs .tsx ファイルが、そのままドキュメントです。エディターはそれらを解析してノードツリーに変換し、キャンバスに描画したうえで、ビジュアル編集を最小限のテキスト差分として書き戻します。1 秒間隔のディスク監視により、外部での編集もキャンバスに反映されます。

そのため、「エディターで編集可能かどうか」はコード上で確認できる性質であり、スタイルの好みではありません。パーサーが静的に理解できないコードは、次のいずれかの形で劣化します。

劣化発生条件影響
固定ノード(dynamicProps)uiTransform/uiBackground の値、または Label/Input/Dropdown のプロパティが、リテラルでも単純な参照でもない場合ノードはキャンバスに描画されますが、パネルではそのノードの編集がすべて拒否されます
不透明ノード不明な要素名、スプレッドプロパティ、条件式/論理式/.map() を使った子要素、JSX コメントグレーの読み取り専用ブロックになります。子要素は走査されないため、サブツリー全体がキャンバスから消えます

どちらも警告は表示されません。以下の規約に従ってコードを記述すれば、どちらも発生しません。

前提知識:build-ui スキル(React-ECS 要素、uiTransform/uiBackground、フレックスレイアウト)。このスキルでは、コードを UI エディターで編集可能にする条件のみを扱います。

UI エディターは安定しており、Creator Hub では常に有効です(0.50.0 以降、creator-hub 66758e1c)。Settings > Experimental の切り替え項目も、settings.guiEditor キーもありません。ユーザーに有効化するよう案内しないでください。残る条件は SDK のバージョンのみです。シーンでは 7.26.0 以降の @dcl/sdk を使用する必要があります(ScreenInsetArea/InteractableArea と、デバイスごとのデフォルト仮想画面が導入されたバージョンです)。これより古い場合、2D モードに入るとキャンバスの代わりに SDK のアップグレードを知らせる画面が表示され、Update SDK ボタンが提示されます。この規約に従って記述したコードは通常の React-ECS であり、どこでも動作します。

エディターの動作(エディターが書き込む内容にコードを合わせるために)

creator-hub の docs/UIDesigner.md と、実装コミット(35cd6fa7、33134497、30207181、abb88456、6753eda9)を確認しています。

保存は即時に行われます。 ビジュアル編集はすべて、その場で .tsx に反映されます。バッジには "All changes saved" と表示され、手動で保存する必要はありません。同期を保つべき別のドキュメントはありません。エディターを開いている間は、ファイルが常に最新の状態であるものとして扱ってください。

レイアウトモデルは、互いに独立した2つの軸で構成されています。 混同しないでください。

軸パネルのコントロール書き込まれるプロパティ
フローFlow(row / column / Free)flexDirection のみです。Free は flexDirection が一切ない状態を意味します。「free」値に設定されるのではなく、キー自体が存在しません。
フローからの離脱Ignore Layout FlowpositionType('absolute')

コードを書く際に考慮すべき点は次のとおりです。

  • 新しくドロップした子要素に positionType: 'absolute' が設定されるのは、親が Free の場合のみです。また、ドロップ位置(丸めた座標から得られる position: { top, left })に配置されます。row/column の親の下では、子要素はフローに加わります。
  • ルートは常に absolute です。
  • 親を Free に切り替えると、測定済みの位置に既存の子要素がすべて固定されます。 変更は1回にまとめて適用されます。触れていない子要素にも positionType/position が追加される差分が生じる点に注意してください。

ウィジェットプリセット。 Full Screen ウィジェットは、width/height を指定せずに uiTransform={{ flexGrow: 1, alignSelf: 'stretch' }} を挿入します。100% x 100% ではありません。100% は読み戻すと Percent 単位になり、100% の兄弟要素が2つあると Yoga の空き領域を超えてしまうためです。Free の親の下では、代わりに absolute 版(positionType: 'absolute' と top/right/bottom/left: 0)が使われます。全画面のラッパーを手書きする場合も、同じ組み合わせを使ってください。(ポインターに関するルールも忘れないでください。全画面ラッパーにインタラクション用のスプレッドを決して設定しないでください。インタラクションレイヤーを参照してください。)

Scene Inset。 device/interactable/none を選ぶドロップダウンで、トップレベルのルートが <ScreenInsetArea>、<InteractableArea>、または何もなしで囲まれます。デフォルトは device です。これはレンダラーの screenInset オプション(build-ui を参照)と同じ設定で、ソース内に構造的に適用されます。

Transparency ではなく Opacity。 スタイルフィールドの名前は Opacity に変更され、パーセンテージで表示されます。デフォルトは 100%(不透明度100%、透明度0%)です。「Transparency」と呼んでいる以前の説明は古い情報です。「Transparency」は現在、無関係なマテリアルインスペクターにのみ残っています。

キャンバスの機能: ルートの幅 と高さの両方が固定の px 値(両方ともポイント単位)の場合に、アートボードのフレーミングが有効になります。スナップグリッドは 10px で、Shift を押すとスナップが無効になります。ツールモードは Free / Move / Resize です(Free にはドラッグ移動とリサイズハンドルの両方があります。回転ボタンはありますが、無効になっています)。

モードの永続化。 2D/3D モードは、コンポジットではなく、シーンごとに .editor/project.json の uiDesignerOpen として保存されます。inspector::UIState に含まれていると考えないでください。このフィールドは削除されています。プロジェクトファイルを手作業で編集する場合に重要な点です。.editor/project.json はエディターの状態であり、シーンのコンテンツではありません。

Bevy レンダラーでは、2D モードを開くとシーンが一時停止します(実行中だった場合は、3D に戻ると再開します)。ユーザーが Bevy 上で UI をレイアウトしている間はティックが進みません。ドライバーのクロックも進みません。

2つのワークフロー

A. 編集可能な新しい UI を生成する
  1. 1 ファイルにつき 1 コンポーネントとします。src/ui/<ComponentName>.tsx に配置してください。ベース名は、有効な PascalCase 識別子で、エクスポートする関数名と同じである必要があります。エディターは、ベース名がすでにこの形式になっていないファイルを無視します。
  2. {baseDir}/references/interaction-helper.md の内容をそのまま使って、src/ui/interaction.tsx と(プラットフォーム別のバリエーションを使う場合は)src/ui/platform.tsx を作成してください。この 2 つは予約済みのヘルパーファイル名です。エディターが自動でひな形を作成し、UI として一覧表示することはありません。
  3. 以下の The contract に記載された契約に従って、各コンポーネントを作成してください。{baseDir}/references/component-template.md をひな形として使用してください。
  4. src/ui/index.tsx を生成される形式(The aggregator を参照)に正確に合わせて作成し、src/index.ts の main() から setupUi() を呼び出してください。
  5. すべてのクロック、イージング、タイマー、フォーマッター、状態マシンを、src/ui/ の外にある通常の .ts ファイルに配置してください。The driver pattern を参照してください。
  6. このファイル末尾のセルフチェックリストを、作成したすべてのファイルに対して実行してください。
B. 既存のコード UI を適応させる
  1. UI を src/ui/ の下にコンポーネントごとに 1 ファイルに分割してください。JSX 以外のロジック(ヘルパー、クロック、計算、データ)はすべて、src/ui/ の外にある .ts ファイルに移動してください。
  2. 5 つの阻害要因を、次の順序で機械的に取り除いてください。それぞれの変更前後のコードについては、{baseDir}/references/adapting-coded-ui.md を参照してください。
    • .map()/ループ → 各項目を兄弟要素として展開します。
    • {cond && <X/>} と cond ? <A/> : <B/> → useInteraction の active レイヤーと display: 'none' に置き換えます(サポートされている構文であるデスクトップ/モバイルのプラットフォーム別バリエーションのみ例外です)。各ゲートは、非表示にする要素に設定し、全画面のラッパー要素には設定しないでください。スプレッドにはポインターハンドラーが含まれます(Interaction layers を参照)。
    • 計算されたスタイル値(算術演算、Math.*、関数呼び出し、文字列連結、三項演算子、共有テーマ識別子)→ 派生値ごとに、ドライバーが更新するバインド済み状態変数を 1 つ用意するか、インラインのリテラル値を使います。
    • テキスト内のインライン補間 → 混合テキストのセグメントバインディングに置き換え、書式設定処理はドライバーに移します。
    • 手動で追跡しているホバー/押下のブール値 → hover/press レイヤーに置き換えます。
  3. レンダリング中にスタイル値を計算するトゥイーン/アニメーションコードを、完成した値をバインド済み状態変数にイージングするドライバーに置き換えてください。
  4. すべてのサイズ値と位置の値は、単位が px の通常の数値にしてください。また、すべてのコンポーネントのルートとコンポーネント参照のラッパーには、明示的な width/height を設定してください。コード UI では、コンテナが子要素に合わせて自動的にサイズ調整されることがよくあります。この動作は移行後も残り、キャンバス上で高さが 0 として描画されます(Sizing and mobile を参照)。
  5. セルフチェックリストを再実行してください。

適応後のファイルは、元のファイルより大きくなることを想定してください。ループとバリエーションの展開は、編集可能にするために必要なトレードオフです。中途半端に移行した状態を黙って残すのではなく、このトレードオフをユーザーに伝えてください。

ファイル構成

src/ui/
  index.tsx        <- GENERATED aggregator. Never hand-edit.
  interaction.tsx  <- reserved helper (useInteraction). Verbatim.
  platform.tsx     <- reserved helper (usePlatform). Verbatim.
  MyScreen.tsx     <- a top-level UI (rendered by the aggregator)
  MyWidget.tsx     <- a reusable component (marked /** @ui-component */)
src/ui-behaviors.ts  <- driver: outside src/ui/, never parsed by the editor
src/mobile-hud.ts    <- GENERATED by the MobileHUD panel. Editor-owned. Never hand-edit.
  • 従来の単一ファイル src/ui.tsx は、シーンを初めて開いたときに src/ui.tsx.bak にバックアップされた後、削除されます。必ず src/ui/ の下に作成してください。
  • すべてのファイルの先頭に /** @jsx ReactEcs.createElement */ を記述し、@dcl/sdk/react-ecs から ReactEcs をインポートしてください。
  • 1 ファイルにつきエクスポートするコンポーネントは 1 つだけです(エディターは、JSX を返す最初のエクスポート関数を読み込みます)。
  • コメントには // だけを使用してください。{/* JSX comments */} は、不透明な式の子要素として解析されます。

アグリゲーター(src/ui/index.tsx)

トップレベルルートの一覧から生成されます。エディターがシーンを開くとき、およびルートファイルが追加、名前変更、削除、再挿入されるたびに書き換えられるため、手動で編集するとすべて失われます。必ず次の形式で出力してください。

/** @jsx ReactEcs.createElement */
import ReactEcs, { UiEntity, ReactEcsRenderer, ScreenInsetArea } from '@dcl/sdk/react-ecs'
import { MyScreen } from './MyScreen'

export function setupUi() {
  ReactEcsRenderer.setUiRenderer(() => (
    <UiEntity uiTransform={{ width: '100%', height: '100%' }}>
      <ScreenInsetArea>
        <MyScreen />
      </ScreenInsetArea>
    </UiEntity>
  ))
}

画面インセットはルートごとに選択でき、デフォルトは device です。 @dcl/react-ecs 7.27.0(components/ScreenInsetArea、components/InteractableArea)で確認済みです。

インセットラッパー要素用途
device(デフォルト)<ScreenInsetArea>通常のシーン UI 全般。子要素をデバイスのセーフエリア(ノッチ、ステータスバー、ホームインジケーター、角丸部分)内に収めます。デスクトップのインセットは通常ゼロです
interactable<InteractableArea>エクスプローラー独自の画面上の操作コントロールも避ける必要がある UI
noneなし — <Component /> のみレターボックスのバー、全画面の背景、プラットフォーム UI がある領域への意図的な描画など、キャンバス全体を制御する必要がある場合のみ

ScreenInsetArea は独自の positionType/position を持ちます。サイズが 100%×100% の子要素は、セーフエリアにぴったり収まります。どちらのラッパーも index.tsx 内でのみ使用してください。コンポーネントファイル内では要素名がパーサーに認識されず、サブツリーが不透明になります。

setUiRenderer はオプションオブジェクトなしで生成されるため、シーンには SDK のプラットフォーム既定の仮想キャンバス(デスクトップは 1920x1080、モバイルは 1600x720)が設定されます。ここに virtualWidth/virtualHeight を手動で追加しないでください。次回の再生成時に削除されます。ここは build-ui の「仮想サイズを必ず明示的に渡す」というルールを適用できない唯一の箇所です。代わりに、これら2つのキャンバスサイズを基準に px 値を設計してください。

エディターは main() が setupUi() を呼び出すことも保証します(テンプレートの //setupUi() 行のコメントを解除し、インポートを追加します)。手動で作成する場合は、自分で呼び出しを設定してください。

MobileHUD (src/mobile-hud.ts) — エディターが管理するファイルです。手書きしないでください

Creator Hub 0.50.0+(creator-hub 75ad6f5e)。MobileHUD は、UI Designer の GUIs レールで最初に固定表示される項目です(シーンに GUI が 1 つ以上あると表示されます)。シーンの TouchScreenControls(ネイティブのモバイル用ジョイスティック、照準、ゲームパッドボタン)を編集し、タッチ HUD の読み取り専用キャンバスプレビューを表示します。

react-ecs の UI ルートではありません。 TouchScreenControls.createOrReplace(engine.RootEntity, { … }) を 1 回だけ呼び出すもので、src/mobile-hud.ts に書き込まれます。ルートスキャナーが GUI として扱わないよう、意図的に src/ui/ の外に配置されています。setupUi() の隣で './mobile-hud' からインポートされ、src/index.ts で setupMobileHud としてエクスポートされます。

UI Designer を使用するシーンで作業するエージェント向けのルール:

  • src/mobile-hud.ts を手書きしたり編集したりしないでください。 パネルは独自にその正確な形式を解析してファイルを再生成します。解析できないコードは、黙ってデフォルト値に置き換えられます。
  • そのようなシーンのほかの場所に TouchScreenControls.createOrReplace を 2 つ目として記述しないでください。 engine.RootEntity 上のコンポーネントは 1 つで、最後に書き込まれた値が有効になります。そのため、別途記述した呼び出しはパネルの設定に上書きされるか、パネルの設定を黙って上書きします。
  • ファイルは遅延生成され、自動的に削除されます。 設定が SDK のデフォルト値と異なる場合にのみ存在します。すべての項目をリセットするか、最後の GUI を削除すると、モジュールと src/index.ts の配線が削除されます。ファイルがないシーンは HUD が「欠落」しているのではなく、標準の動作になっています。
  • UI Designer を使用するシーンでコードからモバイル HUD を変更する場合は、パネルで変更してください。シーンで実行時の制御(カットシーン中にボタンを隠す、ゲーム状態に応じてアイコンを切り替えるなど)が本当に必要な場合は、src/ui/ の外にドライバーを配置し、パネルの静的な呼び出しと競合することを受け入れてください。コンポーネント API と拡張ヘルパーについては advanced-input を参照してください。

パネルで設定できる項目(hideJoystick、hideCrosshair、デフォルトが IA_JUMP の mainAction、および IA_JUMP、IA_POINTER、IA_PRIMARY(E)、IA_SECONDARY(F)、IA_ACTION_3~IA_ACTION_6(1~4)ごとの { hide, icon })。「入力アクションを隠す」は派生値(すべてのアクションが非表示)であり、保存されるフィールドではありません。アイコンにはシーン画像のみ使用でき、外部 URL、アバター、動画テクスチャは使用できません。ドラッグによる並べ替えには対応していません。エクスプローラーでは固定の優先順位でボタンが表示され、mainAction によって中央のスロットに配置されるのは 1 つだけです。

生成されるファイルでは、ゼロ値も含めて、必要な すべての PBTouchScreenControls フィールドが出力されます。これは、createOrReplace が Partial ではなく、protobuf メッセージ全体を受け取るためです。ここでデフォルト値を省略すると TS2741 のコンパイルエラーになります。react-ecs の作成プロパティではデフォルト値の省略が正しいため、こちらとは逆です。

契約

要素

モデル化される要素名は、次の5つだけです:UiEntity、Label、Input、Dropdown、Button。

もう1つのファーストクラス要素は、src/ui/ 内の別のルートファイルへの参照です — <MyWidget />。これが正式にサポートされる再利用単位です。選択や移動ができ、インスタンスごとに props を編集できます。それ以外の要素名(ローカルのヘルパーコンポーネント、ライブラリコンポーネント、ScreenInsetArea)は不透明な要素として扱われます。

  • テキストを持てるのは Label / Button のみです:value、fontSize、textAlign、color、font、textWrap。UiEntity の uiText={{...}} バッグはモデル化されません — Label の子要素として再構成してください。テキスト値に絵文字は使用できません(Sizing and mobileを参照)。
  • Input の props:placeholder、value、color、placeholderColor、disabled、textAlign、font、fontSize。
  • Dropdown の props:acceptEmpty、emptyLabel、options、selectedIndex、disabled、color、textAlign、font、fontSize。
  • <MyWidget /> のインスタンスは外側から移動やサイズ変更ができません — マージンや位置指定が必要な場合は、各インスタンスにラッパーの UiEntity を付けてください。そのラッパーにはマージンだけでなく、明示的な width/height も必要です(Sizing and mobileを参照)。
  • コンポーネント参照は、入れ子の JSX 子要素を受け付けません — スロット/children の仕組みがないため、汎用的な <Card> ラッパーは表現できません。
State:バインディングの対象
export interface State {
  score: number
  label: string
  visible: boolean
  panelWidth: number
  labelColor: { r: number; g: number; b: number; a: number }
  options: string[]
}
export const state: State = {
  score: 0,
  label: '00.00',
  visible: false,
  panelWidth: 320,
  labelColor: { r: 1, g: 1, b: 1, a: 1 },
  options: ['A', 'B'],
}

モジュールレベルの export interface State + export const state: State が認識されるシグネチャです。すべてのプロパティが編集可能な変数になります。サポートされる型:number、string、boolean、Color4({ r: number; g: number; b: number; a: number } として構造的に注釈してください — 注釈は Color4 という名前ではなく、r/g/b メンバーの有無によって判定されます)、および string[]。

  • state は単純なエクスポート済みモジュールオブジェクトです — これによりドライバーが値を変更できます(以下を参照)。
  • モジュールの state は、ファイルのすべてのインスタンスで共有されます。インスタンスごとの値は親側に置き、props として渡す必要があります(React の制御コンポーネントパターン)。
  • number[] 変数(例:uvs の四角形)はバインドされ、動作しますが、パネルは初期値から string[] と推測し、オプションリストとして誤ったラベルを付けます。使用できますが、ラベルは誤っています。
スタイルのバインディング

uiTransform / uiBackground のキーのうち、値が単独の参照であるもの — 演算子、呼び出し、連結を含まない state.x または props.x — は、固定値ではなく、編集可能なファーストクラスのバインディングになります:

uiTransform={{ width: state.panelWidth, position: { top: state.panelTop }, borderColor: state.frameColor }}
uiBackground={{ color: state.panelColor, texture: { src: state.iconSrc } }}

認識されるバインディング位置:トップレベルのキー(width、height、zIndex、color、uvs など)、ネストされたエッジグループのメンバー(position/margin/padding → { left: state.x })、グループ全体を一度に記述する場合(borderColor: state.c)、およびドット区切りのパス texture.src / avatarTexture.userId です。1つのオブジェクト内で、リテラル値とバインディングを自由に混在させられます。ネストは1階層までです — react-ecs 自体の形状と同じです。

テキストと要素の props も同様にバインドされます:value={state.label}、color={state.labelColor}、fontSize={state.size}、selectedIndex={state.i}、options={state.options}。

混在テキスト(カウンター/タイマー/読み出し表示の定番)

補間がすべて単独の参照であるテンプレートリテラルは、順序を保ったリテラル/バインディングのセグメントとして往復変換され、完全に編集可能な状態を維持します:

<Label value={`Score: <b>${state.score}</b>`} />
<Label value={`${state.mins}:${state.secs}`} />
<Label value={`env: ${state.realm}\nplayers: ${state.count}`} />

複数行の \n リテラルも問題ありません。計算を含む補間(${state.a + 1}、${fmt(state.t)})が1つでもあると、属性全体が処理できず、ノードが固定されます — 書式設定はドライバー側で行い、完成した値を補間してください。

(上の3行は value 属性のみを示しています。実際の Label にはすべて、明示的な uiTransform width/height も必要です — Sizing and mobileを参照。)

アクション(イベントハンドラー)
type UiAction = { state: State; props: Parameters<typeof MyScreen>[0]; value?: unknown }

/** @ui-action */
function openPanel({ state }: UiAction) {
  state.visible = true
}

標準的な thunk を使用して接続します。onMouseDown={() => openPanel({ state, props })} のように指定します。値を持つイベントの場合は、onChange={(value) => setName({ state, props, value })} のように指定します。認識されるイベントは、onMouseDown、onMouseUp、onMouseEnter、onMouseLeave、および Input/Dropdown の onChange/onSubmit です。

  • ハンドラーの本体は自由形式のコードで、どのようなロジックでも記述できます。スタイルとして解析されることはありません。
  • 認識されないハンドラー式(ブロック本体を持つインラインのアロー関数)は、バインドされていないものとして表示されないだけです。ノードがフリーズすることはありません。
  • アクションは状態を同期的に変更します。時間に依存する処理はドライバーに記述します。アクションでフラグを設定し、ドライバーでアニメーションさせます。
インタラクションレイヤー:ホバー、押下、表示状態

useInteraction は、状態ごとのスタイル設定に認識される構文です。レイヤーは優先順位 base → active → hover → press に従ってディープマージされます。第 2 引数は active レイヤーを制御し、任意の式を指定できます(そのまま保存されます)。

const panel = useInteraction(
  {
    base: { uiTransform: { display: 'flex', width: 320, height: 200 } },
    active: { uiTransform: { display: 'none' } },
  },
  state.visible !== true,
)
return <UiEntity {...panel}>…</UiEntity>

ルール:

  • 表示状態は常に active の表示ゲートで制御します。 {state.visible && <X/>}(不透明)や display: state.visible ? 'flex' : 'none'(フリーズ)は使用しないでください。
  • ホバー/押下時のフィードバックは常に hover/press レイヤーで制御します。 onMouseEnter/onMouseLeave でブール値を手動管理しないでください。
  • {...someInteractionConst} は、パーサーが受け付ける唯一のスプレッドです。それ以外のスプレッドがあると、ノードは不透明になります。追加の属性はスプレッドと併記できます(<UiEntity {...panel} onMouseDown={…}>)。
  • スプレッドにはポインターハンドラーが含まれるため、全画面ラッパーには決して配置しないでください。 useInteraction は、ホバーと押下を追跡するために、onMouseDown/Up/Enter/Leave の 4 つのリスナーを常に返します。base と active レイヤーのみを使用した表示ゲートとして呼び出した場合も同様です。リスナーを持つ UI 要素は、その矩形全体でポインター入力を捕捉します。そのため、100%×100% のレイアウトラッパーに {...gate} を配置すると、画面全体がクリックを遮り、ほかの UI 要素も 3D ワールド内のオブジェクトもクリックできなくなります。一方、表示されるパネルは小さいため、UI の見た目は正しいままです。ゲートはパネル(表示状態の判断が適用される最小の要素)に配置し、全画面ラッパーは位置決めのみを行う、リテラルなスタイル指定の UiEntity のままにしてください。 {baseDir}/references/component-template.md §4 のダイアログの変更前/変更後と、{baseDir}/references/adapting-coded-ui.md §4 のレシピを参照してください。意図的にクリックを遮る全画面オーバーレイとして認められているのは 2 つです。クリックを遮ることを目的とするモーダルの背景と、ドラッグ中以外は無効になるドラッグ終了位置の捕捉要素({baseDir}/references/drag-slider.md)です。クリックの遮断は常に明示的にゲートされた判断で行い、スプレッドの配置場所による副作用にしてはいけません。
  • 要素はアンマウントされず、非表示になります。ツリーには常にすべての要素が存在し、キャンバスには base レイヤーが描画されるため、編集時にはすべての状態が重なって表示されます。これは想定された動作です。
  • 非表示になる前に UI をアニメーションで消したい場合は、2 つの変数を使うパターンを使用します。アクションで切り替える意図を表す visible と、終了アニメーションが完了した後にのみドライバーがクリアする描画ゲートの hidden です。state.hidden === true でゲートします。
コンポーネントの props
/** @ui-component */
export function MyWidget(props: { label?: string; fillPx?: number; on?: boolean; onPress?: (value?: unknown) => void }) {
  • エクスポートされた関数の前にある /** @ui-component */ は、そのファイルを再利用可能なコンポーネントとしてマークします(別のルートにネストされている箇所でのみレンダリングされます)。このマーカーがない場合、ファイルはトップレベルのルートとなり、アグリゲーターによってレンダリングされます。
  • 宣言する props は、唯一の props パラメーターに指定するインラインオブジェクト型です。サポートされる型は number、string、boolean、コールバック((value?: unknown) => void)です。それ以外の型は読み取り専用として表示されます。props は必ずオプショナルで宣言してください。
  • コンポーネント内では、props.x をバインディングの対象として使用できます。スタイルキー、テキスト値、useInteraction の active 式(props.active === true)から参照できます。
  • color、texture-array、children の props はありません。色を選択する variant prop は表現できません。コンポーネント内で表示バリエーションをすべて兄弟要素として展開し、active 表示レイヤーで切り替えてください。
  • value={props.label} は厳密な TS チェックで失敗します(string | undefined)。value={`${props.label}`} を使用してください。型チェックに通り、バインディングとして認識されます(親が prop を省略すると、undefined というテキストがレンダリングされます)。
  • 子のコールバックは、1 行のアクションで転送できます。/** @ui-action */ function forwardClose({ props }: UiAction) { props.onClose?.() }。
プラットフォーム別バリエーション — 唯一の構造的な条件分岐
const platform = usePlatform()
return platform === 'mobile' ? <PhoneMenu /> : <DesktopBar />

コンポーネントの return 内および JSX の子として認識されます。!==、オペランドの逆順、インラインの usePlatform() === 'mobile' はすべて解析されます。両方の分岐は、単一の JSX 要素またはリテラルの null である必要があり、少なくとも一方は要素でなければなりません。これは予約済みの src/ui/platform.tsx ヘルパーを使用します。それ以外の条件分岐は不透明です。

デバイスごとに構造を本当に変える場合に使用してください。プロパティ単位のオーバーライドはモデル化されていません。比例スケーリングはすでに仮想キャンバスで処理されています。

表現できないもの

以下は試さず、代わりに記載されている方法を選択してください。

表現できないもの代替方法
データに対するループ/.map()すべての要素を手作業で展開する
共有テーマ定数(color: THEME.primary)各箇所に { r, g, b, a } リテラルをインラインで指定する(スタイルオブジェクト内の識別子はノードを固定します)
計算されたスタイル値(算術演算、Math.*、呼び出し、連結、三項演算子)派生値ごとに、ドライバーが管理する状態変数を 1 つ用意する
パーセント文字列のバインディング(width: state.pct + '%')px 数値をバインドする。静的なパーセントのリテラル(width: '90%')は使用できます
条件付きの要素 props(disabled={state.i === -1})事前計算された boolean の状態変数
同じファイル内のローカルなヘルパーコンポーネント/** @ui-component */ マーカーを付けた別の src/ui/ ファイル
コンポーネントの children/スロットレイアウトは画面ファイル内に記述し、リーフウィジェットのみを切り出す
prop としての color または texture-setコンポーネント内でバリエーションを展開する(texture.src は文字列 prop にバインドできますが、uvs はできません)
行ごとのドラフトとクロージャを持つ、データ駆動の行移植できません。この部分はコードのまま、src/ui/ 以外の独立したモジュールに置く必要があることをユーザーに伝える

ナインスライス背景(textureMode: 'nine-slices' と textureSlices)と、リテラルの 8 浮動小数点数 uvs アトラス切り抜きは、ラウンドトリップに対応しています。

ドライバーパターン

エディターは src/ui/ 以外のファイルを解析せず、state はエクスポートされた単純なオブジェクトです。そのため、エディターが構造、スタイル、その他の値を管理し、ドライバーが時間管理と計算を担当します。

// src/ui-behaviors.ts — outside src/ui/, invisible to the editor
import { engine } from '@dcl/sdk/ecs'
import { state as panel } from './ui/MyPanel'

const OPEN_WIDTH = 480
let anim = 0

export function registerUiBehaviors() {
  engine.addSystem((dt: number) => {
    const target = panel.visible ? 1 : 0
    anim = Math.max(0, Math.min(1, anim + (target > anim ? dt : -dt) / 0.25))
    const t = 1 - Math.pow(1 - anim, 3) // easeOutCubic
    panel.panelWidth = Math.max(1, Math.round(OPEN_WIDTH * t))
    panel.textColor.a = t
    if (target === 0 && anim <= 0) panel.hidden = true // release the display gate
  })
}

main() から登録します。state 内の各バインド変数の初期値は、デザイン可能な静止状態です。登録時にその値を取得し、その値を基準にアニメーションさせます。これにより、デザイナーはドライバーに手を加えることなく、パネルからスタイルを変更できます。

ドライバーに含めるもの:クロック、トゥイーン、イージング、タイマー、自動非表示の期限、padStart/丸め処理/テキストの書式設定、ステートマシン、派生値(パーセントから算出する px 幅など)、ECS を読み取るもの(プレイヤーの位置など)。アニメーション自体はエディター上で表現されません。エディターから見えるのは、バインドされたキーとその静止値だけです。

詳しい例(イージング付きの開閉、書式設定されたタイマーラベル、2 変数の終了ゲート、クリックでトリガーされる単発処理、命名規則に基づくドライバー):{baseDir}/references/driver-pattern.md。

サイズ設定とモバイル

  • すべての /** @ui-component */ ファイルのルート要素では、width と height の両方を明示的に指定します。値には px の数値かパーセントのリテラルを指定してください。子要素のサイズに基づく自動サイズやコンテンツに合わせたサイズには頼らないでください。キャンバスはコンポーネントのインスタンスを、宣言されたボックスのサイズで描画するため、寸法を設定しないと 0 として扱われます。インスタンスはプレビューで潰れて見え、パネルには高さ 0 と表示されますが、実行時には Yoga が正しくレイアウトします。これがこの不具合の全容です。width: 400 のルートに 26 px のラベル行と 44 px のトラックを配置すると、ワールド内では自動的に高さ 70 px になりますが、キャンバス上では何も表示されません。height: 70 を指定してください。
  • コンポーネント参照を囲むラッパーの UiEntity にも同じ対応が必要です。マージンや位置を指定する場合は、それに加えて、コンポーネントのルートサイズに合わせた width と height を明示してください。margin や position だけを持つラッパーも、同じように潰れてしまいます。
  • 固定サイズのフロー内の子要素は実行時に正しくレイアウトされるため、どちらの不具合も、エディターを開くまで気づきません。まさにこの契約に従って実装する理由です。
  • すべての Label では uiTransform の width と height も明示し、ラベルを積み重ねるすべてのコンテナーでも同様に指定します。これは上記のエディターキャンバスのルールに対応する実行時のルールで、別の問題が発生します。テキストの固有サイズはエンジンによって異なります。Bevy のエクスプローラーは描画したテキストのサイズを測定し、その高さをフレックスレイアウトに反映します。一方、Unity のエクスプローラーでは、寸法を指定していないテキストの高さは約 0 になりますが、高さ 0 のノード上にもグリフは描画されます。そのため Unity では、積み重ねたラベルが重なり、テキストの子要素に基づいて親のサイズを自動計算すると、親はパディングのサイズまで潰れます。実際のワールド内で並べたスクリーンショットでも確認済みです。高さを指定していない width: '100%'、textWrap="wrap" のラベルが 2 つあり、パネルにも height を指定していない高さ 720 px のダイアログは、Bevy では正しく表示されましたが、Unity では潰れていました。両方のラベルが互いに重なり、パネルはパディングとボタンのサイズまで縮んでいました。そのシーンでの修正は、パネルに height: 210、名前ラベルに height: 30、折り返しのある本文ラベルに height: 60、ボタンラベルに width: '100%', height: '100%' を指定することです。複数行に折り返すラベルには行数に応じた高さが必要です(fontSize: 20 で 2 行の場合は 60)。固定サイズの親全体に広がるラベルには 100%/100% を指定します。3 つすべての表示環境で一貫した結果を得られるのは、すべてのボックスを明示した場合だけです。絵文字のルールと同様、これはエンジンによって異なるため、いずれか 1 つのエクスプローラーでプレビューが正しく表示されても、何も証明されません。
  • バインドされるサイズや位置には、単純な px の number を指定します。値に算術演算を含めたり、パーセント文字列を使ったりしないでください。バインドされないキーに静的なパーセントリテラルを指定するのは問題なく、流動的なレイアウトに最適な方法です。
  • デフォルトの仮想キャンバスは、デスクトップの 1920x1080 とモバイルの 1600x720 の 2 つを前提にデザインします。モバイルのキャンバスは高さが 33% 短いため、デスクトップでは収まる縦積みレイアウトが、スマートフォンでははみ出す場合があります。片方の高さに合わせて絶対オフセットを計算するのではなく、端に固定し、流動的にする軸にはフレックスやパーセントリテラルを使ってください。
  • タッチターゲット:押せる要素にはすべて、仮想キャンバス上で少なくとも約 48 px の実際のボックスを設けてください。テキストサイズのヒット領域に頼らないでください。
  • テキスト:本文の fontSize は 16 以上にし、動きながら読む必要があるモバイルプレイヤー向けのテキストには 20 以上を推奨します。サイズが変わる可能性のあるラベルには、textWrap="wrap" と明示的な幅を設定してください。
  • テキスト値には絵文字を使わないでください。対象は Label/Button の value、Input の placeholder、Dropdown の選択肢、およびテキスト値にバインドされるすべての状態変数です。絵文字のグリフは SDK ではなく、エクスプローラーにバンドルされたフォントに含まれています。そして Unity のエクスプローラーには絵文字フォントが含まれていないため、欠落グリフのボックスとして表示されるか、何も表示されません。実際のワールド内で確認したところ、value="✨ Particles" のメニューボタンは Unity でキラキラの部分が表示されませんでした。これはエンジンによって異なるため、あるクライアントで表示されても何も証明されません。絵で操作方法を示す場合は、ラベルの横に uiBackground={{ texture: { src: 'images/icon.png' } }} を指定した小さな UiEntity を配置してください。texture.src は正式にサポートされるバインド項目なので、パネルからアイコンを編集したり差し替えたりできます。文字列に埋め込んだ絵文字では、そうしたことはできません。
  • モバイルレイアウトの構造が実際に異なる場合(サイドレールではなくボトムシートにする、表示する列を減らすなど)は、デスクトップレイアウトを縮小して無理に収めるのではなく、プラットフォーム別のバリアントを使用してください。
  • ホバー表示はタッチ操作では機能しません。値を読み取る唯一の方法や、操作方法を示す唯一の手段として hover レイヤーを使わないでください。
エディターのモバイルプレビュー(creator-hub 379aa27e)

キャンバスをモバイルに切り替えると、UI は横向きノッチ付きのスマートフォン本体の中に表示され、サイズは 1600x720 になります。これは react-ecs がモバイルで使用する仮想キャンバスと同じなので、fitScale は 1 となり、レターボックスはありません。デスクトップは 1920x1080 のままです。ほかの画面サイズも選択できます(モバイル:1600x720 DCL のリファレンス、2340x1080 19.5:9、2048x1536 4:3、デスクトップ:1920x1080、2560x1080 ウルトラワイド、1440x900)。デフォルト以外のプリセットではレターボックスが表示されます。

キャンバスには、使用中のレンダラーの screenInset に対応する2つのガイド領域と、切り替え可能な参照用 HUD コントロール(ジョイスティック、ジャンプ、F/E、エモート、プロフィール、チャット、コンパス、カウンター、ポインター)が表示されます。これらは操作できない円形要素として描画されます。HUD の切り替えボタンはキャンバスのズームピルにあるゲームコントローラーのボタンです(モバイルのみ)。セーフエリアモードではデフォルトで表示され、フルスクリーンではデフォルトで非表示になります。

これを踏まえて、作成時には次の点に注意してください。

  • interactable で除外されるのは左側の HUD 列だけです。 その右端はデバイス領域と一致するため、右下のアクション群(ジャンプ/E/F/ポインター)は、設計上、screenInset: 'interactable' でもインタラクティブ領域の内側にあります。右下に固定した要素は、それらのボタンと場所を取り合います。HUD ガイドを使って、それらを避けて配置してください。
  • セーフエリアの輪郭を越えた部分も、クリップされずに表示されます。これは意図的な仕様であり、ワールド内でも同様に動作します(ScreenInsetArea/InteractableArea では overflow が設定されていません)。輪郭からはみ出すコンテンツは修正すべき配置上の警告であり、描画上の不具合ではありません。
  • プレビューのインセット値は固定の概算値です(iPhone 14 Pro の横向き表示:デバイス領域は幅の約86%、インタラクティブ領域は幅の約65%、上下の余白は6%)。ワールド内では、エクスプローラーが実際の UiCanvasInformation.screenInsetArea/interactableArea を毎ティック報告します。どちらの場合も、生成される react-ecs のソースは同一です。そのため、プレビューの表示が正しく見えなくてもコード生成の不具合とは限らず、正しく見えても特定のデバイスでレイアウトが正しいことの証明にはなりません。

セルフチェックリスト

作成または改変したすべての.tsxファイルに対して、以下を実行してください。各項目に違反すると、エディター上で警告なく失敗します。

  1. ファイルがsrc/ui/<PascalCaseName>.tsxにあり、ベース名がエクスポートするコンポーネント名と一致し、1行目が/** @jsx ReactEcs.createElement */で、エクスポートするコンポーネントが1つだけであること。
  2. すべてのJSX要素名がUiEntity、Label、Input、Dropdown、Button、またはsrc/ui/内の別のファイルがエクスポートするコンポーネントであること。
  3. {cond && <X/>}およびcond ? <A/> : <B/>を使用しないこと。ただし、usePlatform()の分岐で、両方の分岐が単一の要素またはnullである場合を除きます。
  4. .map()、ループ、配列から構築した子要素を使用しないこと。
  5. {/* JSX comments */}を一切使用しないこと。コメントはJSXの外側で//を使って記述します。
  6. uiTransform/uiBackground内の値はすべて、リテラルまたは裸のstate.x/props.x参照であること。ファイル内で?、+、*、Math.、(およびこれらのオブジェクト内の裸の識別子を検索し、それぞれがフリーズされたノードであることを確認してください。
  7. すべてのテキストがLabel/Buttonのvalueプロパティに設定されていること。UiEntityにuiTextバッグを設定しないこと。
  8. プロパティ内のテンプレートリテラルは、裸の参照のみを補間すること。
  9. 要素で使用できるスプレッドは、単一の{...someUseInteractionConst}のみであること。
  10. 可視性はuseInteractionのactiveレイヤーでdisplay: 'none'に設定すること。ホバーと押下にはhover/pressレイヤーを使用します。
  11. 100%×100%の要素にポインターハンドラーや{...useInteraction}のスプレッドを設定しないこと。width: '100%', height: '100%'でサイズ指定された要素(およびサイズ指定がなく画面全体を埋める要素)をすべて検索し、onMouse*属性、スプレッド、pointerFilter: 'block'が設定されていないことを確認してください。useInteractionは可視性ゲートとしてのみ使う場合も、常に4つのリスナーすべてを返すため、スプレッドも対象です。リスナーが1つでもあると、その要素が矩形全体のクリックを捕捉し、シーン内のほかのすべてのクリックを妨げます。ゲートとハンドラーはパネル/ボタンに設定し、画面全体を覆うラッパーは位置指定用のコンテナーとしてのみ使ってください。唯一の例外は、アイドル時にゲートで無効化するモーダルのバックドロップとドラッグ終了検知用のキャッチャーです。
  12. export interface Stateとexport const state: Stateがあり、すべてのプロパティがnumber、string、boolean、string[]、または色を表す構造体{ r, g, b, a }であること。
  13. 宣言するプロパティは、オプションのnumber/string/boolean/コールバックメンバーのみからなるインラインオブジェクト型であること。
  14. すべてのハンドラーが、({ state, props, value }: UiAction)を引数に取る/** @ui-action */関数であり、サンク経由で接続されていること。
  15. バインドされたサイズ/位置はすべてpx数値にすること。バインドされた値にパーセント文字列を使用しないこと。
  16. コンポーネントのルート要素でwidthとheightの両方を明示的に宣言すること(px数値またはパーセントリテラル)。子要素からの自動サイズ指定は使用しないこと。また、コンポーネント参照を囲むすべてのラッパーUiEntityにも両方を指定し、コンポーネントのルートサイズと一致させます。サイズ未指定の寸法は、実行時には正しくレイアウトされても、エディターのキャンバスでは0として描画されます。
  17. src/ui/内では、時計、Date.now()、setTimeout、イージング、丸め処理、文字列フォーマットを一切使用しないこと。これらはすべてドライバーに記述します。
  18. src/ui/index.tsxが生成された形式と完全に一致し、フルキャンバスでの操作を明示的に要求していない限り、各ルートをScreenInsetAreaで囲むこと。
  19. モバイルでの確認:1600x720のキャンバスでレイアウトが崩れず、タッチターゲットが約48 px以上で、ホバー時のみ使える機能がないこと。
  20. 作成した箇所でTouchScreenControlsを呼び出さないこと。モバイル操作は、src/mobile-hud.tsを介してMobileHUDパネルが担います。シーン内でTouchScreenControlsを検索し、該当が生成されたファイル以外にないことを確認してください。

参考資料

  • {baseDir}/references/interaction-helper.md — src/ui/interaction.tsx および src/ui/platform.tsx の原文です(エディターで作成されていないシーンでは必須です)。
  • {baseDir}/references/component-template.md — 最小限の編集可能なコンポーネント、機能を網羅したコンポーネント(バインディング、アクション、ホバー、ゲート、プラットフォーム別バリアント)、および合成された画面とアグリゲーターです。
  • {baseDir}/references/driver-pattern.md — ドライバーの例:イージング付きの開閉、書式設定されたタイマー、2 変数の終了ゲート、1 回限りのアニメーション、命名規則です。
  • {baseDir}/references/adapting-coded-ui.md — 本番環境のコード化された UI を移植する際の変更前後のレシピと、移植できない内容をユーザーに伝える方法です。
  • {baseDir}/references/drag-slider.md — この仕様に準拠した、実機で検証済みのドラッグスライダーの実例です。再利用可能なハイブリッド型のタップで段階的に移動するドラッグコンポーネント、常時表示されるゲート付きリリースキャッチャー、ドライバーのドラッグセクションを扱います。編集可能な UI にスライダー、スクラブバー、ドラッグハンドルがある場合は参照してください。

コメント