Imágenes subidas por el jugador
Permite que el jugador elija una imagen de su dispositivo — un avatar, un fondo personalizado, una foto de su personaje — y que aparezca dentro del mundo de inmediato. La imagen se almacena como una variable regular, persiste entre sesiones y viaja con el bundle cuando exportas.
Lo que vas a construir
Un pequeño cargador de avatar renderizado junto al chat:
- El jugador hace clic en la ranura del avatar → se abre el selector de archivos
- Elige un
.png/.jpg→ la imagen aparece instantáneamente en la ranura - La imagen sobrevive a recargas, cambios de sesión y exportaciones de bundle
- Funciona completamente sin conexión — sin llamada de red, sin subida de recursos al servidor
El patrón se generaliza a cualquier cosa con forma de imagen: fondos, sobrescrituras de retratos de NPC, iconos de artículos dibujados por el jugador, capturas de pantalla a las que quieran que la IA reaccione.
Subida del jugador vs. recurso del creador
Esta receta es para imágenes que el jugador proporciona en tiempo de juego. Si tú (el creador) quieres enviar una imagen fija con tu mundo, súbela en la pestaña Assets del editor y refiérete a ella mediante @asset:xxx en tu código o estilos — eso pasa por el CDN y no se almacena en la sesión del jugador.
Cómo funciona
Todo el asunto son tres primitivas del navegador más una llamada al SDK:
El jugador elige un archivo
→ evento change de <input type="file" accept="image/*">
→ FileReader.readAsDataURL(file) → "data:image/png;base64,..."
→ api.setVariable("player-avatar", dataUrl)
→ la variable se actualiza → el componente re-renderiza → <img src={dataUrl}> muestra la imagenLa URL de datos es solo una cadena. Como las variables de Yumina pueden contener cualquier JSON, la cadena vive dentro de la variable como cualquier otro texto — sin pipeline de subida separado.
Paso a paso
Paso 1: Crea la variable
Editor → barra lateral → pestaña Variables → Add Variable:
| Campo | Valor | Por qué |
|---|---|---|
| Nombre visible | Avatar del jugador | Para tu propia referencia |
| ID | player-avatar | El Root Component lee/escribe este ID |
| Tipo | String | Una URL de datos es solo texto |
| Valor por defecto | vacío | Vacío = sin avatar aún, mostrar un placeholder |
| Categoría | Custom | Organizacional |
| Reglas de comportamiento | No modifiques esta variable. El jugador proporciona la imagen; la IA nunca debe cambiarla. | Impide que la IA emita directivas [player-avatar: set ...] que corromperían la imagen |
¿Por qué un String, no JSON? Una URL de datos es una cadena única como
data:image/png;base64,iVBORw.... JSON también funcionaría — útil cuando tienes múltiples ranuras como{ avatar: "...", background: "..." }— pero una sola ranura de imagen es más simple como String.
Paso 2: Root Component
Editor → sección Custom UI → abre index.tsx → pega:
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);
// Reinicia para que elegir el mismo archivo dos veces siga disparando onChange
e.target.value = "";
}
return (
<div style={{ display: "flex", height: "100vh" }}>
{/* Izquierda: ranura de avatar */}
<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>
{/* Derecha: chat normal */}
<div style={{ flex: 1 }}>
<Chat />
</div>
</div>
);
}Línea por línea:
api.variables["player-avatar"]— lee la URL de datos guardada (cadena vacía cuando no se ha subido nada)<input type="file" accept="image/*">— el selector de archivos estándar del navegador.accept="image/*"filtra a tipos de imagen en el diálogo del SOFileReader.readAsDataURL— lee el archivo elegido y produce una cadenadata:image/...;base64,...de forma asíncrona; el resultado aterriza enev.target.resultapi.setVariable("player-avatar", dataUrl)— guarda la cadena en la variable. Como las variables son parte de la sesión, el avatar persiste entre recargas y se incluye cuando el jugador exporta la sesióne.target.value = ""— sin esto, elegir el mismo archivo dos veces seguidas no disparaonChange(los navegadores deduplican valores idénticos en entradas de archivo)- El div del avatar usa CSS
background-imageen lugar de una etiqueta<img>para que obtengamos recortecovergratis
Paso 3: (Opcional) Comprime antes de guardar
Una foto de teléfono de 4K puede superar fácilmente los 5 MB. Almacenada como base64, es ~33% más grande de nuevo. Cargar y serializar una cadena de 7 MB en cada renderizado es lento, y el bundle de exportación se hincha en consecuencia. Para cualquier cosa más grande que una miniatura, redimensiona en el cliente primero:
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) típicamente deja un avatar de 512×512 en 40-80 KB. Eso es insignificante para almacenamiento e instantáneo para renderizar.
Regla de oro de cordura: mantén cualquier variable individual de imagen por debajo de ~200 KB una vez codificada en base64. Un puñado de avatares de ese tamaño está bien; una galería de fotos a resolución completa no — en ese punto usa la pestaña Assets del editor y referencias
@asset:xxxen su lugar.
Paso 4: Guarda y prueba
- Haz clic en Save en la parte superior del editor
- Abre o inicia una sesión
- Haz clic en la ranura del avatar, elige una imagen — debería aparecer inmediatamente
- Refresca la página — el avatar sigue ahí
- Haz clic en Remove — la ranura vuelve a "Click to upload"
Si algo sale mal:
| Síntoma | Causa probable | Solución |
|---|---|---|
| El selector no se abre | El <input> no es hijo del <label>, o display: none está en el label en su lugar | Asegúrate de que <input type="file"> esté dentro del <label> y el label tenga cursor: pointer |
| La imagen se elige pero no se muestra | setVariable no se llamó, o el ID de la variable está mal escrito | Confirma que el ID en la definición de la variable coincida exactamente con player-avatar |
| El mismo archivo dos veces no dispara | Falta e.target.value = "" tras leer | Siempre resetea el valor de la entrada al final del handler |
| La página se siente lenta tras subir | La imagen es enorme | Añade el paso compressToDataUrl de arriba |
La IA empieza a emitir directivas [player-avatar: ...] sin sentido | La regla de comportamiento en la variable no se añadió | Vuelve a abrir la variable y pega la regla del Paso 1 |
Referencia rápida
| Qué quieres | Cómo hacerlo |
|---|---|
| El jugador elige una imagen | <input type="file" accept="image/*"> dentro de un <label> |
| Archivo → cadena | new FileReader(); reader.readAsDataURL(file) |
| Persistir la imagen elegida | api.setVariable("id", dataUrl) — cadenas de cualquier tamaño entran como cualquier otra variable |
| Renderizarla | <img src={dataUrl}> o background: url(${dataUrl}) |
| Resetear elección del mismo archivo | e.target.value = "" tras manejar |
| Mantener el almacenamiento pequeño | Redimensiona mediante canvas.toDataURL("image/jpeg", 0.85) antes de guardar |
| El jugador la elimina | api.setVariable("id", "") |
Cuándo no usar este patrón
| Situación | Usa en su lugar |
|---|---|
| La imagen se envía con el mundo (siempre la misma) | Pestaña Assets del editor + referencia @asset:xxx |
| Necesitas muchas imágenes grandes y no quieres que estén en el bundle de sesión de cada jugador | Pestaña Assets — subida una vez, servida desde CDN |
| La imagen necesita ser visible para otros jugadores en una sala compartida | Pestaña Assets — las variables son por sesión, los recursos son por mundo |
| La IA necesita ver la imagen (modelos de visión) | Próximamente: adjuntos de mensajes de chat. Por ahora, almacena una descripción en otra variable y deja que la IA reaccione a eso |
La división mental es simple: el contenido pre-horneado que el creador eligió vive en Assets; el contenido que el jugador produce en tiempo de ejecución vive en variables.
Esta es la Receta #15
El patrón — API de archivo del navegador → variable de cadena — también funciona para clips de audio cortos (readAsDataURL + <audio src={dataUrl}>), archivos de texto pequeños (readAsText), e importaciones de JSON. Cada vez que necesites que el jugador traiga datos al mundo, esta es la forma.
