Skip to content

玩家上传图片

让玩家从设备上选择一张图片 -- 头像、自定义背景、角色照片 -- 并让它立即出现在世界中。图片作为普通变量存储,跨会话持久保留,导出包时一起带走。


你将构建什么

一个在聊天旁边渲染的小型头像上传器:

  • 玩家点击头像槽 → 文件选择器打开
  • 选择 .png / .jpg → 图片立即出现在槽中
  • 图片在刷新、会话切换和包导出后仍然存在
  • 完全离线工作 -- 无网络请求,无需上传到服务器

这个模式可以泛化到任何图片类型的内容:背景、NPC 头像覆盖、玩家画的物品图标、他们想让 AI 做出反应的截图。

玩家上传 vs. 创作者素材

本食谱是关于玩家在游戏时提供的图片。如果你(创作者)想随世界附带固定图片,在编辑器的素材选项卡中上传并在代码或样式中通过 @asset:xxx 引用 -- 那会通过 CDN 分发,不会存储在玩家的会话中。

工作原理

整个过程就是三个浏览器原语加一个 SDK 调用:

玩家选择文件
  → <input type="file" accept="image/*"> change 事件
  → FileReader.readAsDataURL(file) → "data:image/png;base64,..."
  → api.setVariable("player-avatar", dataUrl)
  → 变量更新 → 组件重新渲染 → <img src={dataUrl}> 显示图片

data URL 就是一个字符串。因为 Yumina 变量可以保存任何 JSON,所以字符串像其他文本一样存放在变量里 -- 不需要单独的上传管道。


逐步操作

第 1 步:创建变量

编辑器 → 侧边栏 → 变量 选项卡 → 添加变量

字段原因
显示名称Player Avatar供你自己参考
IDplayer-avatarRoot Component 通过此 ID 读写
类型Stringdata URL 就是文本
默认值留空空 = 还没有头像,显示占位符
分类Custom组织用途
行为规则Do not modify this variable. The player provides the image; the AI must never change it.阻止 AI 发出 [player-avatar: set ...] 指令来破坏图片

为什么用 String 而不是 JSON? data URL 是一个单独的字符串,如 data:image/png;base64,iVBORw...。JSON 也能用 -- 当你有多个槽位如 { avatar: "...", background: "..." } 时很有用 -- 但单个图片槽用 String 更简单。


第 2 步:Root Component

编辑器 → 自定义 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);

    // 重置,这样连续选择同一文件仍然会触发 onChange
    e.target.value = "";
  }

  return (
    <div style={{ display: "flex", height: "100vh" }}>
      {/* 左侧:头像槽 */}
      <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>

      {/* 右侧:普通聊天 */}
      <div style={{ flex: 1 }}>
        <Chat />
      </div>
    </div>
  );
}

逐行说明:

  • api.variables["player-avatar"] -- 读取保存的 data URL(没有上传时为空字符串)
  • <input type="file" accept="image/*"> -- 标准浏览器文件选择器。accept="image/*" 在系统对话框中过滤为图片类型
  • FileReader.readAsDataURL -- 读取选中的文件并异步生成 data:image/...;base64,... 字符串;结果在 ev.target.result
  • api.setVariable("player-avatar", dataUrl) -- 将字符串保存到变量中。因为变量是会话的一部分,头像会在刷新后持久保留,也会包含在玩家导出会话时
  • e.target.value = "" -- 没有这个,连续两次选择同一文件不会触发 onChange(浏览器会去重文件输入上的相同值)
  • 头像 div 使用 CSS background-image 而不是 <img> 标签,这样可以免费获得 cover 裁切效果

第 3 步:(可选)保存前压缩

一张 4K 手机照片很容易超过 5 MB。转成 base64 后又大了约 33%。每次渲染时加载和序列化一个 7 MB 的字符串很慢,导出包也会相应膨胀。对于比缩略图大的内容,在客户端先缩小:

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) 通常会将 512x512 的头像压缩到 40-80 KB。这对存储来说微不足道,渲染也是瞬间完成。

经验法则:将任何单张图片变量在 base64 编码后控制在约 200 KB 以内。几个这样大小的头像完全没问题;一整个全分辨率照片库就不行了 -- 那种情况请使用编辑器的素材选项卡和 @asset:xxx 引用。


第 4 步:保存并测试

  1. 点击编辑器顶部的 保存
  2. 打开或开始一个会话
  3. 点击头像槽,选择一张图片 -- 它应该立即出现
  4. 刷新页面 -- 头像仍然在
  5. 点击 Remove -- 槽位恢复为「Click to upload」

如果出了问题:

症状可能原因修复方法
选择器不打开<input> 不是 <label> 的子元素,或者 display: none 设在了 label 上而不是 input 上确保 <input type="file"><label> 内部,且 label 有 cursor: pointer
选了图片但没显示setVariable 没有被调用,或变量 ID 拼错了确认变量定义中的 ID 与 player-avatar 完全匹配
连续选同一文件不触发读取后缺少 e.target.value = ""始终在处理器末尾重置 input 值
上传后页面感觉卡顿图片太大添加上面的 compressToDataUrl 步骤
AI 开始发出无意义的 [player-avatar: ...] 指令变量上没有添加行为规则重新打开变量,粘贴第 1 步中的规则

快速参考

你想做什么怎么做
玩家选择图片<input type="file" accept="image/*"> 放在 <label>
文件 → 字符串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", "")

什么时候应该用这个模式

场景改用
图片随世界附带(始终相同)编辑器的素材选项卡 + @asset:xxx 引用
你有很多大图,不想放在每个玩家的会话包中素材选项卡 -- 上传一次,通过 CDN 分发
图片需要在共享房间中对其他玩家可见素材选项卡 -- 变量是按会话的,素材是按世界的
AI 需要看到图片(视觉模型)即将推出:聊天消息附件。目前,在另一个变量中存储描述,让 AI 对描述做出反应

心智模型很简单:创作者选择的预设内容存放在素材中;玩家在运行时产生的内容存放在变量中。


这是食谱 #15

这个模式 -- 浏览器文件 API → 字符串变量 -- 也适用于短音频片段(readAsDataURL + <audio src={dataUrl}>)、小文本文件(readAsText)和 JSON 导入。只要你需要让玩家将数据带入世界,这就是基本形式。