玩家上传图片
让玩家从设备上选择一张图片 -- 头像、自定义背景、角色照片 -- 并让它立即出现在世界中。图片作为普通变量存储,跨会话持久保留,导出包时一起带走。
你将构建什么
一个在聊天旁边渲染的小型头像上传器:
- 玩家点击头像槽 → 文件选择器打开
- 选择
.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 | 供你自己参考 |
| ID | player-avatar | Root Component 通过此 ID 读写 |
| 类型 | String | data 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 → 粘贴:
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 的字符串很慢,导出包也会相应膨胀。对于比缩略图大的内容,在客户端先缩小:
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 步:保存并测试
- 点击编辑器顶部的 保存
- 打开或开始一个会话
- 点击头像槽,选择一张图片 -- 它应该立即出现
- 刷新页面 -- 头像仍然在
- 点击 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 导入。只要你需要让玩家将数据带入世界,这就是基本形式。
