Skip to content

プレイヤーがアップロードする画像

プレイヤーに自分のデバイスから画像を選ばせ — アバター、カスタム背景、キャラクターの写真 — それをすぐにワールド内に表示します。画像は通常の変数として保存され、セッションをまたいで永続化し、エクスポート時にバンドルと一緒に移動します。


これから作るもの

チャット横にレンダリングされる小さなアバターアップローダ:

  • プレイヤーがアバタースロットをクリック → ファイルピッカーが開く
  • .png / .jpgを選択 → 画像がスロットに即座に表示
  • 画像はリロード、セッション切り替え、バンドルエクスポートを生き残る
  • 完全にオフラインで動作 — ネットワーク呼び出しなし、サーバーへのアセットアップロードなし

このパターンは画像形式の何にでも一般化できます:背景、NPCポートレートのオーバーライド、プレイヤーが描いたアイテムアイコン、AIに反応させたいスクリーンショット。

プレイヤーアップロード vs 作者アセット

このレシピはプレイヤーがプレイ時に提供する画像用です。ワールドと一緒に固定画像を出荷したい場合は(作者として)、エディタのAssetsタブにアップロードし、コードやスタイルで@asset:xxxで参照します — それはCDNを経由し、プレイヤーのセッションには保存されません。

仕組み

全体は3つのブラウザプリミティブと1つのSDK呼び出しです:

プレイヤーがファイルを選ぶ
  → <input type="file" accept="image/*"> の change イベント
  → FileReader.readAsDataURL(file) → "data:image/png;base64,..."
  → api.setVariable("player-avatar", dataUrl)
  → 変数が更新 → コンポーネントが再レンダリング → <img src={dataUrl}> が画像を表示

データURLは単なる文字列です。Yumina変数は任意のJSONを保持できるため、文字列は他の任意のテキストのように変数内に存在します — 別のアップロードパイプラインは不要です。


ステップバイステップ

ステップ1:変数を作成

エディタ → サイドバー → Variables タブ → Add Variable

フィールド理由
Display NamePlayer Avatar自分用の識別名
IDplayer-avatarRoot ComponentがこのIDで読み書き
TypeStringデータURLは単なるテキスト
Default Value空 = まだアバターなし、プレースホルダーを表示
CategoryCustom整理用
Behavior RulesDo not modify this variable. The player provides the image; the AI must never change it.AIが画像を破損する[player-avatar: set ...]ディレクティブを出力するのを止める

なぜJSONではなくStringなのか? データURLはdata:image/png;base64,iVBORw...のような単一の文字列だからです。JSONも動作します — { avatar: "...", background: "..." }のような複数スロットがある場合に有用 — ですが、単一画像スロットはStringとしてよりシンプルです。


ステップ2:Root Component

エディタ → Custom UI セクション → index.tsxを開く → 貼り付け:

tsx
export default function MyWorld() {
  const api = useYumina();
  const avatar = String(api.variables["player-avatar"] || "");

  function handlePick(e) {
    const file = e.target.files && e.target.files[0];
    if (!file) return;

    const reader = new FileReader();
    reader.onload = function(ev) {
      const dataUrl = String(ev.target.result || "");
      api.setVariable("player-avatar", dataUrl);
    };
    reader.readAsDataURL(file);

    // Reset so picking the same file twice still fires onChange
    e.target.value = "";
  }

  return (
    <div style={{ display: "flex", height: "100vh" }}>
      {/* Left: avatar slot */}
      <div style={{ width: "200px", padding: "16px", borderRight: "1px solid #333" }}>
        <label style={{ display: "block", cursor: "pointer" }}>
          <div style={{
            width: "168px",
            height: "168px",
            borderRadius: "12px",
            background: avatar ? `url(${avatar}) center/cover` : "#1f2937",
            border: "1px solid #374151",
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            color: "#9ca3af",
            fontSize: "13px",
          }}>
            {avatar ? "" : "Click to upload"}
          </div>
          <input
            type="file"
            accept="image/*"
            onChange={handlePick}
            style={{ display: "none" }}
          />
        </label>

        {avatar && (
          <button
            onClick={() => api.setVariable("player-avatar", "")}
            style={{
              marginTop: "12px",
              padding: "6px 12px",
              fontSize: "12px",
              background: "transparent",
              border: "1px solid #4b5563",
              borderRadius: "6px",
              color: "#9ca3af",
              cursor: "pointer",
              width: "100%",
            }}
          >
            Remove
          </button>
        )}
      </div>

      {/* Right: regular chat */}
      <div style={{ flex: 1 }}>
        <Chat />
      </div>
    </div>
  );
}

行ごと:

  • api.variables["player-avatar"] — 保存されたデータURLを読む(何もアップロードされていないとき空文字列)
  • <input type="file" accept="image/*"> — 標準のブラウザファイルピッカー。accept="image/*"はOSダイアログで画像タイプにフィルタ
  • FileReader.readAsDataURL — 選択されたファイルを読み、data:image/...;base64,...文字列を非同期に生成;結果はev.target.resultに着地
  • api.setVariable("player-avatar", dataUrl) — 文字列を変数に保存。変数はセッションの一部なので、アバターはリロードを越えて永続化し、プレイヤーがセッションをエクスポートするときに含まれる
  • e.target.value = "" — これなしでは、同じファイルを連続して2回選んでもonChangeが発火しません(ブラウザはファイル入力で同一の値を重複排除する)
  • アバターdivは<img>タグではなくCSS background-imageを使うので、無料でcoverクロッピングが得られる

ステップ3:(オプション)保存前に圧縮

4K携帯写真は簡単に5MBを超えます。base64として保存すると約33%大きくなります。各レンダリングで7MB文字列をロードしてシリアライズするのは遅く、エクスポートバンドルも肥大化します。サムネイルより大きいものは何でも、まずクライアント側でダウンスケール:

tsx
function compressToDataUrl(file, maxDim = 512, quality = 0.85) {
  return new Promise((resolve, reject) => {
    const img = new Image();
    const reader = new FileReader();
    reader.onload = () => { img.src = String(reader.result); };
    reader.onerror = reject;
    img.onload = () => {
      const scale = Math.min(1, maxDim / Math.max(img.width, img.height));
      const w = Math.round(img.width * scale);
      const h = Math.round(img.height * scale);
      const canvas = document.createElement("canvas");
      canvas.width = w;
      canvas.height = h;
      const ctx = canvas.getContext("2d");
      ctx.drawImage(img, 0, 0, w, h);
      resolve(canvas.toDataURL("image/jpeg", quality));
    };
    img.onerror = reject;
    reader.readAsDataURL(file);
  });
}

async function handlePick(e) {
  const file = e.target.files && e.target.files[0];
  if (!file) return;
  const dataUrl = await compressToDataUrl(file, 512, 0.85);
  api.setVariable("player-avatar", dataUrl);
  e.target.value = "";
}

canvas.toDataURL("image/jpeg", 0.85)は通常512×512アバターを40〜80KBに収めます。ストレージに無視できる程度で即座にレンダリングできます。

目安ルール:base64エンコード後の単一画像変数を約200KB未満に保ってください。そのサイズのアバターの少数なら問題ありませんが、フル解像度写真のギャラリーはダメ — そのレベルではエディタのAssetsタブと@asset:xxx参照を代わりに使ってください。


ステップ4:保存してテスト

  1. エディタ上部のSaveをクリック
  2. セッションを開くか開始
  3. アバタースロットをクリックし画像を選ぶ — 即座に表示されるべき
  4. ページをリフレッシュ — アバターはまだそこにある
  5. Removeをクリック — スロットが「Click to upload」に戻る

うまく動かない場合:

症状想定される原因対処
ピッカーが開かない<input><label>の子でない、またはdisplay: noneがlabelに付いている<input type="file"><label>の中にあり、labelにcursor: pointerがあることを確認
画像は選ばれるが表示されないsetVariableが呼ばれていない、または変数IDのスペルミス変数定義のIDが正確にplayer-avatarと一致することを確認
同じファイル2回がトリガーしないハンドラの最後でe.target.value = ""が欠落ハンドラの最後で常に入力値をリセット
アップロード後ページが重く感じる画像が巨大上記のcompressToDataUrlステップを追加
AIが無意味な[player-avatar: ...]ディレクティブを出力し始める変数のビヘイビアルールが追加されていない変数を再オープンしてステップ1のルールを貼り付け

クイックリファレンス

やりたいこと方法
プレイヤーが画像を選ぶ<label>内の<input type="file" accept="image/*">
ファイル → 文字列new FileReader(); reader.readAsDataURL(file)
選択された画像を永続化api.setVariable("id", dataUrl) — 任意のサイズの文字列が他の変数と同じように入る
レンダリング<img src={dataUrl}> または background: url(${dataUrl})
同じファイル選択をリセット処理後にe.target.value = ""
ストレージを小さく保つ保存前にcanvas.toDataURL("image/jpeg", 0.85)でダウンスケール
プレイヤーが削除api.setVariable("id", "")

このパターンを使うべきでないとき

状況代わりに使うもの
画像がワールドと一緒に出荷される(常に同じ)エディタのAssetsタブ + @asset:xxx参照
多くの大きな画像が必要で、各プレイヤーのセッションバンドルに含めたくないAssetsタブ — 一度アップロード、CDNから配信
画像が共有ルーム内で他のプレイヤーに見える必要があるAssetsタブ — 変数はセッション単位、アセットはワールド単位
AIが画像を見る必要がある(ビジョンモデル)近日公開:チャットメッセージ添付。当面は別の変数に説明を保存し、AIがそれに反応するようにする

精神的な分割はシンプル:作者が選んだ事前焼き込みコンテンツはAssetsに、プレイヤーがランタイムに生成するコンテンツは変数に。


これはRecipe #15です

パターン — ブラウザファイルAPI → 文字列変数 — は短いオーディオクリップ(readAsDataURL + <audio src={dataUrl}>)、小さなテキストファイル(readAsText)、JSONインポートにも動作します。プレイヤーにワールドデータを持ち込ませる必要があるとき、これがその形です。