プレイヤーがアップロードする画像
プレイヤーに自分のデバイスから画像を選ばせ — アバター、カスタム背景、キャラクターの写真 — それをすぐにワールド内に表示します。画像は通常の変数として保存され、セッションをまたいで永続化し、エクスポート時にバンドルと一緒に移動します。
これから作るもの
チャット横にレンダリングされる小さなアバターアップローダ:
- プレイヤーがアバタースロットをクリック → ファイルピッカーが開く
.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 Name | Player Avatar | 自分用の識別名 |
| ID | player-avatar | Root ComponentがこのIDで読み書き |
| Type | String | データURLは単なるテキスト |
| Default Value | 空 | 空 = まだアバターなし、プレースホルダーを表示 |
| Category | Custom | 整理用 |
| Behavior Rules | Do 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を開く → 貼り付け:
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>タグではなくCSSbackground-imageを使うので、無料でcoverクロッピングが得られる
ステップ3:(オプション)保存前に圧縮
4K携帯写真は簡単に5MBを超えます。base64として保存すると約33%大きくなります。各レンダリングで7MB文字列をロードしてシリアライズするのは遅く、エクスポートバンドルも肥大化します。サムネイルより大きいものは何でも、まずクライアント側でダウンスケール:
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:保存してテスト
- エディタ上部のSaveをクリック
- セッションを開くか開始
- アバタースロットをクリックし画像を選ぶ — 即座に表示されるべき
- ページをリフレッシュ — アバターはまだそこにある
- 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インポートにも動作します。プレイヤーにワールドへデータを持ち込ませる必要があるとき、これがその形です。
