Skip to content

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 imagen

La 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 VariablesAdd Variable:

CampoValorPor qué
Nombre visibleAvatar del jugadorPara tu propia referencia
IDplayer-avatarEl Root Component lee/escribe este ID
TipoStringUna URL de datos es solo texto
Valor por defectovacíoVacío = sin avatar aún, mostrar un placeholder
CategoríaCustomOrganizacional
Reglas de comportamientoNo 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:

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);

    // 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 SO
  • FileReader.readAsDataURL — lee el archivo elegido y produce una cadena data:image/...;base64,... de forma asíncrona; el resultado aterriza en ev.target.result
  • api.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ón
  • e.target.value = "" — sin esto, elegir el mismo archivo dos veces seguidas no dispara onChange (los navegadores deduplican valores idénticos en entradas de archivo)
  • El div del avatar usa CSS background-image en lugar de una etiqueta <img> para que obtengamos recorte cover gratis

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:

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) 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:xxx en su lugar.


Paso 4: Guarda y prueba

  1. Haz clic en Save en la parte superior del editor
  2. Abre o inicia una sesión
  3. Haz clic en la ranura del avatar, elige una imagen — debería aparecer inmediatamente
  4. Refresca la página — el avatar sigue ahí
  5. Haz clic en Remove — la ranura vuelve a "Click to upload"

Si algo sale mal:

SíntomaCausa probableSolución
El selector no se abreEl <input> no es hijo del <label>, o display: none está en el label en su lugarAsegúrate de que <input type="file"> esté dentro del <label> y el label tenga cursor: pointer
La imagen se elige pero no se muestrasetVariable no se llamó, o el ID de la variable está mal escritoConfirma que el ID en la definición de la variable coincida exactamente con player-avatar
El mismo archivo dos veces no disparaFalta e.target.value = "" tras leerSiempre resetea el valor de la entrada al final del handler
La página se siente lenta tras subirLa imagen es enormeAñade el paso compressToDataUrl de arriba
La IA empieza a emitir directivas [player-avatar: ...] sin sentidoLa 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é quieresCómo hacerlo
El jugador elige una imagen<input type="file" accept="image/*"> dentro de un <label>
Archivo → cadenanew FileReader(); reader.readAsDataURL(file)
Persistir la imagen elegidaapi.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 archivoe.target.value = "" tras manejar
Mantener el almacenamiento pequeñoRedimensiona mediante canvas.toDataURL("image/jpeg", 0.85) antes de guardar
El jugador la eliminaapi.setVariable("id", "")

Cuándo no usar este patrón

SituaciónUsa 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 jugadorPestaña Assets — subida una vez, servida desde CDN
La imagen necesita ser visible para otros jugadores en una sala compartidaPestañ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.