Capítulo Mundo de la guía Neblla — el motor 3D: escena, cámaras, controles, física, eventos, modelos, el catálogo de piezas y el muñeco. El contrato base está en core.txt; el criterio de cuándo usar Neblla, en empezar.txt.

Mundo, el motor 3D

Mundo es el motor 3D de Neblla (el nombre no se traduce; en el código es neblla.world). Lo usas desde client.js para un juego, una app o una web: un plataformas, un juego en tercera persona, una maqueta de un piso o un visor 3D de un producto en una tienda. Dibuja con three.js y simula con Rapier, y ya viene con luz, cielo, sombras, cámaras y controles que se ven bien sin tocar nada. Tú escribes las reglas; Mundo pone la escena, la física y los controles de teclado, ratón, mando y pantalla táctil.

Requiere el módulo world encendido en Config → Módulos (o neblla_update_app con features: { world: true }). Está apagado por defecto. Con el módulo apagado, neblla.world.create() rechaza con feature_disabled.

El motor es un fichero aparte del SDK: se descarga la primera vez que la página llama a neblla.world.create(), nunca antes. Una página sin 3D no lo paga. Son unos 1,8 MB comprimidos (la física es la mayor parte), en caché para siempre.


const world = await neblla.world.create({ container: '#juego', sky: 'day', camera: 'third' });
world.add({ shape: 'box', size: [20, 1, 20], position: [0, -0.5, 0], color: '#7fa65a' });
const yo = world.add({ character: true, position: [0, 1, 0] });
world.control(yo);   // el teclado, el ratón, el mando o el joystick táctil lo mueven; la cámara lo sigue

neblla.world.create(opts) (CLIENT) — devuelve Promise<world>. Opciones:

Las capas

Mundo va por capas y puedes bajar de capa cuando quieras: world.three es three.js ({ THREE, scene, camera, renderer }), world.rapier es Rapier y world.sim es la simulación sin dibujo. Lo que Mundo no trae lo añades tú con three.js o Rapier sobre la misma escena. await neblla.world.engine() devuelve el módulo del motor (createWorld, PRESETS, SKIES, STEP…).

Cosas: formas, cuerpos y modelos

world.add(spec) (CLIENT) — añade una cosa y la devuelve. Es síncrono.

La cosa devuelta tiene id, name, data, object (su objeto de three.js), position y velocity (leer y escribir, [x, y, z]), rotation (un cuaternión { x, y, z, w }; se escribe también en radianes), impulse([x, y, z]) (un empujón a un cuerpo dynamic), moveTo(position, rotation?) (el siguiente sitio de un cuerpo kinematic, que empuja lo que encuentra) y remove().

Personajes

world.add({ character: true, position, speed: 5, runSpeed: 8, jump: 6.5, radius: 0.35, height: 1.8 }) — una cápsula con el controlador de personaje de Rapier: sube escalones (step, 0,35 m), sube pendientes hasta slope grados (50), se pega al suelo al bajar y salta con margen (un poco después de dejar un borde y un poco antes de tocar el suelo). grounded dice si pisa suelo.

world.control(personaje) — los controles lo mueven y la cámara lo sigue. Sin control(), lo mueves tú con personaje.input = { x, z, jump, run } (x y z entre −1 y 1, en los ejes del mundo).

Cámaras

world.camera.use(preajuste, { target, transition, distance, height, yaw, pitch }) (CLIENT) — cambia de cámara, también a mitad de partida, con una transición suave (transition: segundos, 0,6 por defecto; 0 es un corte). target es una cosa o un punto [x, y, z]; con control() es el personaje.

| Preajuste | Para | Controles | |---|---|---| | side | plataformas, de lado | izquierda y derecha, saltar | | top | una arena, desde arriba | las cuatro direcciones en el plano | | iso | una granja, estrategia | las cuatro direcciones, en diagonal como se ve | | third | tipo Roblox, un visor de producto | hacia donde mira la cámara; arrastrar gira y la rueda acerca | | first | primera persona | el ratón queda capturado y mira; WASD camina | | chase | carreras | arriba acelera, los lados giran |

world.camera.preset dice cuál está puesta y world.camera.orbit ({ yaw, pitch, zoom }) es el giro que mueven los controles.

Controles

Teclado (WASD o flechas, espacio salta, Mayús corre, E o Intro es la acción), ratón (arrastrar gira en third; en first el ratón queda capturado), mando (stick izquierdo mueve, el derecho mira, A salta, X acción) y, en pantallas táctiles, un joystick a la izquierda y los botones de saltar y de acción a la derecha; un dedo en el resto de la pantalla gira la cámara. Cada preajuste de cámara trae sus controles. world.player.action dice si la acción está pulsada.

Eventos y temporizadores

Los nombres son fijos: son los mismos que usarán los géneros en JSON.

Cada cosa tiene los suyos: cosa.onTouch(fn), cosa.onEnter(fn), cosa.onLeave(fn), cosa.onCollect(fn), cosa.onFall(fn). Todos devuelven una función que lo quita.

world.after(segundos, fn) y world.every(segundos, fn) — temporizadores en el reloj de la simulación, no en el de la pared: la misma partida da los mismos tiempos. Devuelven una función que los cancela. world.time es el tiempo simulado.

world.onFrame((dt) => …) — cada dibujo, para lo que solo se ve (una pieza que gira en un escaparate), nunca para las reglas.

La simulación

La física va a 60 pasos por segundo fijos y el dibujo, a lo que dé la pantalla, entre los dos últimos pasos. Mundo usa la versión determinista de Rapier: la misma simulación, con las mismas entradas, da el mismo estado en cualquier dispositivo. world.state() devuelve el sitio, el giro y la velocidad de cada cosa, y world.hash(), una huella corta de ese estado: dos dispositivos que la comparan saben si van iguales. El multijugador está más abajo, en «Multijugador».

world.stop() y world.start() paran y siguen el bucle; world.update(dt) avanza a mano y world.step(), un paso. world.destroy() lo quita todo.

Calidad automática

Con quality: 'auto' Mundo mide los fotogramas por segundo: si no llega a 45 baja la nitidez y las sombras, y si pasa de 58 las sube. En un móvil empieza más ligero. world.quality.level dice el nivel de ahora.

Modelos 3D (glb)

Los modelos glb se suben a los medios de la app (neblla_media o el panel, Construir › Medios) y se leen con neblla.media (referencia.txt).


const { items } = await neblla.media.collection('muebles');
const silla = await world.load(items.find(i => i.title === 'Silla'));   // también una dirección: world.load('https://…/silla.glb')
world.add({ model: silla, position: [0, 0, 0], collider: 'box' });

world.load(src) (CLIENT) — Promise<modelo>: una dirección o un elemento de neblla.media. Cada modelo se descarga una vez y se guarda: cargarlo otra vez no lo descarga. El modelo trae scene, animations, size ([ancho, alto, fondo]), center y triangles. world.parse(arrayBuffer, nombre) hace lo mismo con un glb que ya tienes.

world.add({ model, collider, scale, body }) — una copia del modelo en el mundo. La colisión sale del propio modelo: collider: 'box' (por defecto, su caja), 'convex' (su envoltura, para un cuerpo dynamic), 'mesh' (todos sus triángulos, para el escenario static) o 'none'.

world.scatter(modelo, [{ position, rotation, scale }, …], { collider }) — el mismo modelo muchas veces (árboles, vallas, farolas) en un solo dibujo por malla, cada copia con su cuerpo static. Devuelve { object, entities }.

Choques, zonas y puntos hechos en Creator

Un glb hecho en Mundo (creator.neblla.com/mundo) puede traer sus choques y sus puntos con nombre. Si el modelo los trae y no pides un collider, world.add los usa en lugar de la caja:


const plataforma = await world.load(items.find(i => i.title === 'Plataforma móvil'));
const p = world.add({ model: plataforma, position: [0, 2, 0] });
p.points.start; p.points.end;                 // [x, y, z] en el mundo, ya movidos y escalados como el modelo
p.parts;                                      // los choques sólidos y las zonas: cuerpos `static`
p.zones.lava.onEnter((quien) => quien.name === 'yo' && volverAlInicio());   // una zona avisa al entrar o salir
p.onZone('lava', 'leave', () => {});          // lo mismo con el nombre de la zona

El catálogo

Neblla trae un catálogo de piezas 3D para no tener que modelarlas: bloques de hierba y de nieve, plataformas, rampas, monedas, joyas, pinchos, sierras, muelles, puertas, llaves, vallas, árboles, flores y más, 148 piezas low-poly de un mismo estilo (el Platformer Kit de Kenney, licencia CC0: se pueden usar en cualquier app, también de pago, sin citar a nadie). No inventes cajas para lo que ya está en el catálogo: búscalo y colócalo.

Todas van en metros (1 unidad = 1 metro, la base en y = 0): un bloque mide 2 m, una puerta 2 m, un pino 4 m y una moneda 0,8 m, a la escala del muñeco (1,83 m). Cada pieza tiene su ficha: id, name ({ es, en }), kind (terrain, platform, hazard, pickup, mechanism, nature o prop), tags, size ([ancho, alto, fondo] en metros), triangles, license, author y url. Las puertas, las palancas, los botones, los cofres y las trampas traen su animación (animations).


const [pino] = world.catalog.search('pino');                 // también en inglés: 'pine tree'
const arbol = await world.load('catalog:' + pino.id);       // o world.load(pino)
world.scatter(arbol, [[-6, 0, 4], [5, 0, 7], [9, 0, -3]]);   // tres pinos, cada uno con su cuerpo
const moneda = await world.load('catalog:coin-gold');
world.add({ model: moneda, collect: true, position: [0, 1, 3] });

world.catalog.search(texto, { kind, limit, lang }) (CLIENT) — las piezas que responden a texto, la mejor primero, en español o en inglés, en singular o en plural («monedas», «árboles de nieve», «spike trap»). limit es 10 por defecto (50 como mucho); lang dice qué nombre va en title. Si no hay nada, devuelve []: el catálogo no tiene lava, por ejemplo, y 'plataforma de lava' da las plataformas. world.catalog.get(id) da una ficha y world.catalog.list({ kind, offset, limit }), todas por orden ({ pieces, total }). Sin crear un mundo: (await neblla.world.engine()).catalog.

Desde el MCP, la herramienta neblla_assets hace la misma búsqueda, con los mismos resultados en el mismo orden (search, get, list y doll). No necesita cuenta.

Los ficheros se sirven junto al SDK, con su huella en el nombre y en caché para siempre. Las piezas hechas por personas se compran en la tienda de Neblla (abajo).

El muñeco

Neblla tiene un muñeco estándar: un cuerpo low-poly con un esqueleto de 65 huesos de nombres fijos (pelvis, spine_01, Head, upperarm_l, thigh_r, foot_l…, los del maniquí de Unreal) y una colección de movimientos hechos para ese esqueleto. Así una ropa, un sombrero o un juego que lo use valen para cualquier muñeco. El cuerpo y los movimientos son de Quaternius (Universal Animation Library, licencia CC0), con los colores de Neblla; el saludo lo hizo Neblla.


const muñeco = await world.doll();                               // el estándar: 1,83 m
const yo = world.add({ doll: muñeco, position: [0, 1, 0] });   // un personaje con su cuerpo
world.control(yo);                                               // anda, corre y salta solo
muñeco.play('wave');                                             // saluda y vuelve a moverse

world.doll(cuerpo) (CLIENT) — Promise<muñeco>. El fichero del muñeco se descarga una vez (1,1 MB, en caché para siempre); cada world.doll() hace uno nuevo con su propio esqueleto. world.add({ doll, position }) lo convierte en un personaje: su cápsula mide lo que el muñeco (alto y ancho de hombros), y los mismos speed, runSpeed y jump de un personaje sirven aquí. Un muñeco va en un solo personaje: para otro, otro world.doll().

El muñeco devuelto tiene object (su objeto de three.js), bones (cada hueso por su nombre: para atarle algo, muñeco.bones.hand_r.add(espada)), height (metros), body (sus medidas), play(nombre), stop(), measure() y animator.

Medidas del cuerpo

cuerpo es { height, width, legs, torso, arms, head }, cada una un factor del muñeco estándar (1): height de 0,5 a 1,6 (todo el muñeco; 1 = 1,83 m), width de 0,6 a 1,8 (lo grueso del tronco y de los brazos y piernas), legs, torso y arms de 0,7 a 1,4, y head de 0,7 a 1,5. Lo que falta vale 1. Las largas cambian la longitud de los huesos (los pies siguen en el suelo y los movimientos se ajustan: una cadera más baja da un paso más corto); width es un morph target y se puede cambiar mientras se juega con muñeco.setWidth(1.4).

En la cuenta Neblla de cada persona el muñeco tiene las medidas estándar. Dentro de tu juego puedes cambiarlas:


const enana = await world.doll({ height: 0.8, width: 1.6, legs: 0.85 });   // bajita y ancha
const gigante = await world.doll({ height: 1.5, width: 1.3 });
const niño = await world.doll({ height: 0.65, head: 1.3, legs: 0.85, torso: 0.9 });

muñeco.measure() devuelve sus medidas en metros: { height, hip, legs, shoulders, head, chest }.

Movimientos

Con world.add({ doll }) el muñeco se mueve solo según su personaje: quieto, andar, correr o esprintar según la velocidad de verdad (contra una pared deja de andar), con los pasos al ritmo del suelo; saltar al subir, caer al bajar y un aterrizaje corto tras una caída. Con personaje.data.swimming = true, nada.

muñeco.play(nombre, { loop, fade, speed }) pone un movimiento por encima (devuelve una Promise que acaba con él; la de uno que se repite, enseguida) y muñeco.stop() vuelve a los automáticos. Los que se repiten (sit, dance…) siguen hasta stop(); die se queda en el suelo.

| Movimiento | Qué es | |---|---| | idle, walk, run, sprint | quieto, andar, correr, esprintar (automáticos) | | jump, fall, land | saltar, caer, aterrizar (automáticos) | | swim, float | nadar, flotar quieto en el agua | | sit, sit-down, stand-up | sentado, sentarse, levantarse | | wave, talk, dance | saludar, hablar, bailar | | crouch, crouch-walk, roll | agachado, andar agachado, rodar | | interact, pick-up, punch, hit, die | usar algo, coger del suelo, un puñetazo, recibir un golpe, morir |

El muñeco de cada persona: neblla.avatar

Cada persona con cuenta Neblla tiene su muñeco, el mismo en todos los juegos: lo hace en Neblla Creator (https://creator.neblla.com), con su piel, su pelo, sus ojos y los colores de su ropa. Tu juego lo lee y decide: lo usa tal cual, le pone encima algo de tu mundo (un casco, muñeco.bones.Head.add(casco)), le cambia las medidas o lo ignora.


const world = await neblla.world.create({ container: '#juego', camera: 'third' });
const muñeco = await neblla.avatar.doll(world);              // el mío, vestido
const yo = world.add({ doll: muñeco, position: [0, 1, 0] });
world.control(yo);
// El de otro jugador de la sala, por su userId:
for (const u of neblla.room.users) if (u.id !== neblla.room.me.id) world.add({ doll: await neblla.avatar.doll(world, u.id), position: [2, 1, 0] });

neblla.avatar.get(userId?) (CLIENT) — Promise<aspecto>: el muñeco de quien juega (sin userId) o el de otro jugador conectado ahora a tu app. No hace falta 3D para leerlo. El aspecto es:


{ body: { height: 1, width: 1, legs: 1, torso: 1, arms: 1, head: 1 },   // en la cuenta, siempre el estándar
  skin: '#8d5a3b',
  hair: { style: 'long', color: '#2f9e44' },     // style: 'none', 'short', 'long' o 'bun'
  eyes: { style: 'round', color: '#22170f' },    // style: 'round', 'dots' o 'sleepy'
  colors: { top: '#e04f5f', bottom: '#2f3446', shoes: '#f2c14e' },
  outfit: [],                                     // lo que lleva de la tienda de Neblla: sus fichas («El catálogo de cosas de Neblla»)
  default: false }                                // true: el muñeco por defecto

Un invitado, un usuario del login propio de tu app o una persona que aún no hizo el suyo reciben el muñeco por defecto, con default: true (por ejemplo, para invitarle a hacerse el suyo en Neblla Creator). Un userId que no está conectado a tu app falla con user_not_found.

neblla.avatar.doll(world, userId?) (CLIENT) — lo mismo, ya montado: Promise<muñeco>, como el de world.doll() y vestido. Es world.avatar(await neblla.avatar.get(userId)).

world.avatar(aspecto) (CLIENT) — un muñeco vestido con cualquier aspecto (sin aspecto, el de por defecto). Las medidas del muñeco de la cuenta son las estándar; en tu juego puedes cambiarlas: world.avatar({ ...aspecto, body: { height: 1.3 } }). muñeco.dress(aspecto) lo viste otra vez (un uniforme de equipo: muñeco.dress({ ...aspecto, colors: { top: '#3f7fd8' } })); muñeco.look es el que lleva.

El sello «compatible con Mundo». Si tu juego enseña a cada jugador su propio muñeco, publícalo con neblla_publish_app({ …, avatar: true }) y la galería lo sacará en el filtro «Sale tu muñeco». Neblla lo comprueba: la versión publicada (client.js, no server/) tiene que llamar a neblla.avatar.get() o neblla.avatar.doll(), con el módulo world encendido; si no, falla con avatar_not_used. Si una versión que pasas a pro deja de usarlo, la app pierde el sello y te llega un aviso.

Ejemplo: un parque con pinos y monedas


const world = await neblla.world.create({ container: '#juego', camera: 'third' });
world.add({ shape: 'box', size: [40, 1, 40], position: [0, -0.5, 0], color: '#7fa65a' });
const pino = await world.load('catalog:tree-pine');
world.scatter(pino, [[-8, 0, 6], [7, 0, 9], [10, 0, -5], [-6, 0, -9]]);
const moneda = await world.load('catalog:coin-gold');
for (const x of [-4, 0, 4]) world.add({ model: moneda, collect: true, position: [x, 1, 4] });
const muñeco = await world.doll();
const yo = world.add({ doll: muñeco, position: [0, 1, 0] });
world.control(yo);
let monedas = 0;
world.on('collect', () => { monedas++; if (monedas === 3) muñeco.play('dance'); });

Ejemplo: un visor 3D de producto

No hace falta un juego. Una tienda de lámparas de Madrid enseña cada lámpara en 3D: se arrastra para girarla y la rueda acerca.


<div id="visor" style="height: 70vh"></div>
<p id="nombre"></p>

// client.js
const world = await neblla.world.create({ container: '#visor', sky: 'studio', camera: 'third' });
const { items } = await neblla.media.collection('lamparas');
const item = items.find(i => i.fields.ref === 'GV-24');           // la lámpara «Gran Vía»
const modelo = await world.load(item);
const lampara = world.add({ model: modelo, body: 'none' });
world.camera.use('third', { target: [0, modelo.size[1] / 2, 0], distance: Math.max(...modelo.size) * 2, pitch: 0.25, transition: 0 });
world.onFrame(() => { lampara.rotation = [0, world.time * 0.4, 0]; });  // gira despacio en el escaparate
document.getElementById('nombre').textContent = item.title + ' — ' + item.fields.price + ' €';

Ejemplo: un plataformas


const world = await neblla.world.create({ container: '#juego', camera: 'side', sky: 'sunset' });
world.add({ shape: 'box', size: [30, 1, 3], position: [0, -0.5, 0], color: '#6b8f4e' });
world.add({ shape: 'box', size: [3, 0.5, 3], position: [6, 2, 0] });
const plataforma = world.add({ shape: 'box', size: [2, 0.4, 3], body: 'kinematic', position: [11, 3, 0] });
world.on('step', () => plataforma.moveTo([11 + Math.sin(world.time) * 2, 3, 0]));
const yo = world.add({ character: true, position: [0, 1, 0] });
world.control(yo);
let monedas = 0;
world.add({ shape: 'sphere', radius: 0.3, collect: true, position: [6, 3, 0], color: '#ffd23f', material: 'emissive' });
world.on('collect', () => { monedas++; });
world.on('fall', (cosa) => { if (cosa === yo) yo.position = [0, 2, 0]; });

Obby con JSON

Un obby (una carrera de obstáculos, como en Roblox) es un JSON: world.obby(json) lo convierte en un juego jugable. El JSON dice qué piezas del catálogo hay y dónde, cuáles se mueven o giran, los puntos de control, los peligros, lo que se recoge, la meta y sus tiempos, y reglas «cuando X, entonces Y» con los mismos eventos del motor. Lo escribe tu Claude y se comprueba antes con neblla_assets (action: "check_obby") o con world.checkObby(json): salen todos los errores a la vez, cada uno con su sitio (pieces[3].move.by) y qué escribir en su lugar.


{
  "obby": 1,
  "name": "Mi primer obby",
  "sky": "sunset",
  "start": { "position": [0, 2, 0] },
  "pieces": [
    { "piece": "block-grass-large", "position": [0, 0, 0] },
    { "id": "ascensor", "piece": "platform", "position": [0, 1.6, 4.4], "move": { "by": [0, 0, 4], "time": 2, "pause": 0.5 } },
    { "id": "muelle", "piece": "spring", "position": [0, 1.6, 10] },
    { "piece": "block-grass-large", "position": [0, 4, 15] }
  ],
  "checkpoints": [{ "id": "control", "position": [0, 2, 10] }],
  "hazards": [{ "kind": "lava", "box": [12, 0.2, 40], "position": [0, -0.6, 10] }],
  "items": [{ "position": [0, 2.6, 4.4] }],
  "finish": { "position": [0, 6, 15] },
  "time": { "limit": 120, "medals": { "gold": 20, "silver": 35, "bronze": 60 } },
  "rules": [
    { "when": { "event": "touch", "thing": "muelle" }, "then": [{ "do": "launch", "velocity": [0, 12, 0] }, { "do": "say", "text": "¡Boing!" }] }
  ]
}

const world = await neblla.world.create({ container: '#juego', sky: 'sunset' });
const json = await (await fetch('obby.json')).json();
const juego = await world.obby(json, { doll: await neblla.avatar.doll(world) });
juego.on('finish', ({ time, medal, score, deaths }) => neblla.db.save({ mejor: time }));
juego.on('say', ({ text }) => mostrar(text));
botonJugar.onclick = () => juego.start();

Las partes. Las posiciones son metros [x, y, z] (y hacia arriba) y la base de cada cosa se apoya en su y. Los giros, en grados: un número (alrededor de la vertical) o [x, y, z].

El juego que devuelve world.obby(): start() (desde la salida, tiempo a cero; también para la revancha), respawn(), on(evento, fn) con start, checkpoint, respawn, collect, score, say, rule, finish ({ time, medal, score, deaths }) y lose; y time, running, finished, result, checkpoint, score, deaths, player y things (id → sus cosas del mundo). Todo corre en los pasos fijos del mundo: el mismo obby con las mismas teclas acaba igual.

Un JSON con errores no carga: world.obby() falla con obby_invalid y errors (la lista entera). Los campos que no existen no rompen nada, pero salen como avisos (warnings): un colour en vez de color se ignoraría.

Multijugador

Mundo sigue el modelo de propiedad de red: cada dispositivo simula su propio cuerpo y le dice a la sala dónde está unas 15 veces por segundo; los demás lo dibujan entre los dos últimos sitios que oyeron. La física entera no corre en room.js: ahí hay un árbitro que comprueba cada sitio con reglas sencillas (velocidad máxima, teletransportes, paredes en cajas) y solo lo que acepta entra en el estado de la sala, que es lo que comparan las huellas de los tres Fog (servidor.txt). Hace falta el módulo world y los de multijugador (message, state, room, rooms).


// server/room.js — el árbitro, y nada más si no hay más reglas
const arbitro = neblla.world.referee({
  walls: [{ position: [0, 1, 6], size: [20, 2, 1] }],   // cajas que nadie atraviesa
  objects: { pelota: { owner: 'toucher' }, puerta: { owner: 'server' } },
});
arbitro.on('reject', ({ userId, why }) => { /* aquí, si quieres, tu anticheat */ });

// client.js — mi cuerpo y los de los demás, ya vestidos con su muñeco
const world = await neblla.world.create({ container: '#juego' });
world.add({ shape: 'box', size: [60, 1, 60], position: [0, -0.5, 0] });
world.add({ shape: 'box', size: [20, 2, 1], position: [0, 1, 6] });   // la misma pared que en room.js
const yo = world.add({ doll: await neblla.avatar.doll(world), position: [0, 1, 0] });
world.control(yo);
const red = world.multiplayer({ me: yo });
red.on('join', (userId, cuerpo) => { /* alguien aparece */ });
red.on('leave', (userId) => { /* alguien se va */ });

world.multiplayer(opts) (CLIENT) — me (tu personaje; por defecto el de world.control()), rate (envíos por segundo, 15; quieto, dos), delay (cuánto por detrás dibuja a los demás, 0,1 s), avatars (true: cada uno con su muñeco de Neblla; false: una cápsula), make(userId) (la ficha de add() del cuerpo de otro, si quieres otro aspecto) y zone (abajo). Devuelve la red: red.others (userId → su cuerpo, que el juego mueve solo), red.share(cosa, { name }), red.on('join' | 'leave' | 'fix' | 'powers', fn) y red.stop(). Los cuerpos de los demás son cinemáticos: empujan lo que tocan, pero solo los mueve su dispositivo.

neblla.world.referee(opts) (SERVER) — speed (metros por segundo en el suelo, 9: el muñeco corre a 8), up (hacia arriba, 8: un salto es 6,5), fall (hacia abajo, 60), slack (margen, 0,5 m), radius y height del cuerpo (0,35 y 1,8: las paredes crecen con ellos), walls, bounds ({ min, max }), spawn (un sitio o una lista: el primer sitio de cada uno tiene que estar a menos de spawnRadius, 3 m), objects, rise (lo que se sube en el aire, 2,5 m: abajo en «Poderes») y check(userId, sitio, anterior) (devuelve true, o el motivo para rechazarlo). Guarda lo aceptado en el estado de la sala, en world (key lo cambia): { bodies: { userId: { p, v, f, a, g, t } }, objects: { … } }. Usa neblla.state.merge() para lo tuyo, no set(), o te llevas su clave por delante. Además: arbitro.bodies, arbitro.objects, arbitro.teleport(userId, [x, y, z]) (un salto que el juego permite: reaparecer, un portal), arbitro.set(nombre, { p, q }) (mueve una cosa de room.js), arbitro.forget(userId) (lo hace solo al salir alguien) y arbitro.on('reject' | 'accept', fn).

Lo que se rechaza vuelve atrás. El árbitro no lo apunta, avisa con reject ({ userId, why, sample, last }; why: too_fast, through_wall, in_wall, out_of_bounds, too_high (subir en el aire sin poder volar, abajo en «Poderes»), far_from_spawn, bad_sample, o el tuyo de check) y devuelve el cuerpo de ese dispositivo a su último sitio bueno (fix). Los demás nunca lo ven donde no podía estar. El árbitro es justo, no castiga: qué hacer con quien insiste (un aviso, neblla.anticheat.flag) lo decides tú.

Es igual en los tres Fog. Los sitios se guardan en milímetros, cada uno con la hora común en que se mandó, y el estado sale cuando esa hora entra en un nuevo quinceavo de segundo: los tres Fog aplican los mismos mensajes en el mismo orden y publican los mismos estados. Un Fog que toma el relevo, o que rebobina, sigue desde el estado.

Cosas compartidas (una pelota, una puerta): red.share(cosa, { name: 'pelota' }) en el cliente, con el mismo nombre en objects del árbitro. Con owner: 'toucher', la mueve el último que la tocó, si estaba cerca (reach, 3 m): en su dispositivo es dinámica y en los demás se dibuja como llega. Con owner: 'server', solo la mueve room.js con arbitro.set().

Mundos grandes: con una zona 3d (servidor.txt, «Zonas»), cada cuerpo viaja P2P solo a los que están cerca y el servidor no sabe dónde está nadie. Crea la zona en room.js y pásala con su visión: world.multiplayer({ me: yo, zone: { name: 'mundo', vision: 24 } }). El SDK cambia de zona al pasar de celda. Como room.js no ve los sitios, en ese modo el árbitro no los comprueba: lo que vigila Neblla es que nadie salte a una zona que no es vecina.

Probarlo es el guion de dos dispositivos de empezar.txt: A camina y B lo ve en el mismo sitio; A y B empujan la pelota por turnos; A cierra la pestaña y su cuerpo desaparece en B.

Poderes y modo sandbox

Algunas cosas dan poderes: correr más, saltar más, volar. La ficha de una cosa separa su aspecto (lo que se dibuja) de su poder, así que siempre se puede llevar sin el poder:


{ id: 'botas-cohete', license: 'person', look: { … }, power: { speed: 1.5, jump: 1.3 } }

Hay tres poderes: speed y jump multiplican (de 1 a 2 veces lo normal) y fly es sí o no. Los poderes no se suman: de dos cosas que aceleran, cuenta la mayor.

Quién los enciende:

El sandbox es todo o nada: si cuenta un poder, cuentan todos. Es un ajuste de la app, que sale en la galería («Sandbox»), para que el jugador lo sepa antes de jugar: neblla_publish_app con sandbox: true (necesita el módulo world; si no, world_off). Cada sala puede apagarlo, por ejemplo una partida clasificatoria: neblla.rooms.book({ config: { sandbox: false } }). Una sala no puede encenderlo si la app no lo acepta. neblla.room.sandbox dice si la sala lo es.

El árbitro mide con los poderes. Deja ir a cada cuerpo tan rápido y tan alto como sus poderes: speed ensancha la velocidad máxima, jump la de subida, y sin fly nadie sube en el aire más de un salto desde donde pisó por última vez (rise, 2,5 m; con jump, por su cuadrado): lo que pase de ahí se rechaza con too_high. Las cosas que lleva cada jugador llegan solas con él a la sala; en la sala sandbox cuentan, y en las demás no. Lo que cuenta queda en el estado (world.powers[userId]: { speed, jump, fly, from, off }; off son las que no dan poder por no ser sandbox), y world.multiplayer() se lo aplica a tu personaje solo, con el evento red.on('powers', (poderes, { from, off }) => …).

Sin multijugador, el poder lo pone tu código: yo.powers = { speed: 1.5, fly: true }. Con fly, mantener el salto en el aire sube.

arbitro.grant(userId, poder | null), arbitro.wear(userId, cosas) (lo hace el SDK al entrar alguien), arbitro.sandbox (lo pone el SDK con el de la sala) y arbitro.powers(userId).

El catálogo de cosas de Neblla

Las cosas que acompañan a la persona de juego en juego se compran en la tienda de Neblla, donde venden los developers. Cada venta se cobra en la cuenta de Stripe del autor (la de cobros de su panel): las devoluciones y las disputas son suyas, como en «muchos vendedores» del capítulo de pagos. El autor puede vender lo mismo en otros sitios. Lo que da o vende un juego sigue como siempre (las compras de la app, db.offer) y no sale de ese juego.

Hay dos cosas a la venta y una licencia por portal:

Ninguna licencia deja revender ni compartir el fichero. No se compra dos veces la misma licencia, ni lo tuyo. Las plantillas de interfaz, los bloques y los packs de bloques (de Templates, en Neblla Creator) también se venden, con la licencia de developer: como su receta (tipos template, block, pack; el campo recipe, sin fichero), con versiones numeradas (release da la 1.1 después de la 1.0, la 2.0 con major), documentación hecha sola y vista previa en vivo. El comprador toma una copia en sus creaciones y la actualiza cuando el autor publica una versión nueva, sin perder sus cambios. Un developer puede meter lo que compró en lo que vende, pero no venderlo tal como llegó (store_unchanged_copy).

Cómo va una cosa en el muñeco: rígida sobre un hueso, y sigue cada movimiento igual en todos los dispositivos. Su ficha separa el aspecto del poder (arriba, «Poderes y modo sandbox»):


{ id, name, license: 'person',
  look: { model, bone: 'Head', at: [0, 0.2, 0], turn: [0, 0, 0], size: 1 },
  power: { speed: 1.3 } }   // o null

bone es uno de world.WEAR_BONES (la cabeza, el tronco, los brazos, las manos, las piernas, los pies; nunca un dedo). at son metros desde la articulación, en los ejes del muñeco en reposo (+y arriba, +z hacia donde mira) y medidos en el cuerpo estándar; turn, grados. En otro cuerpo crece con la parte en la que va: su distancia sigue el largo y el ancho de esa parte, y la cosa, su tamaño.

Atada a varios huesos. Para lo que va entre dos partes (un bolso cruzado, de tronco a cadera), look.bones: [{ bone: 'spine_03', w: 0.6 }, { bone: 'pelvis', w: 0.4 }] (de 2 a 4 huesos distintos; los pesos se escalan hasta sumar 1 y bone, que debe ser el de más peso, es el que mide at). La cosa no se dobla: cuelga del muñeco y en cada fotograma su sitio es la media, según los pesos, de cómo llevaría cada hueso por separado el punto del que cuelga, con el giro mezclado. En reposo queda exactamente donde la dejaría un solo hueso. La piel que se dobla con el cuerpo no existe aún.

Antes de publicarla, Neblla la prueba. El fichero (glb) tiene un peso, unos triángulos y unas texturas como mucho (cosa para el muñeco: 1 MB, 4.000 triángulos, 2 texturas de 1024 px; pieza: 4 MB, 20.000, 4 de 2048). Una cosa para el muñeco se prueba además en cinco cuerpos extremos (world.BODY_BATTERY) y cinco posturas (de pie, corriendo, sentado, con los brazos arriba, agachado): puede tocar la parte en la que va y las de al lado (una gorra tapa la cabeza; una mochila toca los hombros), nunca meterse en una parte más lejana ni quedarse flotando. Si falla un caso, no entra, y el informe dice cuál: { code: 'through', body: 'child', pose: 'sit', bone: 'thigh_r', by: 0.07 } es «en el niño, sentado, se mete 7 cm en el muslo derecho». En el navegador, la misma prueba: world.inspectGlb(bytes), world.checkWeight(info, 'avatar') y world.tryOn(...); con un mundo creado, await world.tryOn(caja, look) hace la prueba de cuerpos con el muñeco de la página (caja es { min, max } en los ejes del muñeco).

Vender, desde tu Claude: neblla_store (create con kind, name, offers en céntimos, look y power; upload con el glb y su informe; update; publish cuando pasa y tienes la cuenta de cobros; withdraw, y quien la compró la conserva). check da el informe sin crear nada, y fee la comisión de un precio.

Comprar: GET /api/store/products?portal=person|dev es el escaparate y POST /api/store/checkout { product, license } abre el pago, con la cuenta Neblla de quien compra. Lo comprado sale en Neblla Creator («De la tienda»), donde la persona elige qué lleva; los juegos lo reciben solo: neblla.avatar.get() trae cada cosa con su ficha en outfit, world.avatar(look) se la pone, y en la sala cada jugador trae lo que lleva en worn, que el árbitro lee para los poderes. Una devolución se la quita. Hasta que Neblla abra la tienda, vende en el modo de pruebas de Stripe (test: true): no se cobra nada.

Usar lo que compraste en una app: con la licencia de developer, await world.load('store:<id>') carga la cosa (el id sale en «Mis assets»). Mundo pregunta a Neblla con la app en que corre (GET /api/store/asset/:id?app=) y solo se da la dirección si el dueño de esa app tiene la licencia de developer de esa cosa. Si no, world_store_not_licensed; fuera de una app de Neblla, world_store_no_app. La cesta (POST /api/store/cart/quote y /cart, items: [{ product, license, pay: 'eur' | 'credits' }]) sirve para comprar varias cosas de una vez y es lo único que permite las de menos de 0,50 €.

Denunciar: cualquier cosa de la tienda y cualquier pieza del catálogo se puede denunciar: POST /api/store/report { kind: 'product' | 'piece', id, reason, message } con la cuenta Neblla, o neblla_store report desde tu Claude. Los motivos son los de la comunidad más sexual (sexual, copyright, inappropriate, hate, harassment, spam, illegal, privacy, other, que pide message). Neblla actúa solo cuando le llega una denuncia, y el contenido sexual lo atiende en horas: con una sola denuncia de alguien fiable la cosa sale del escaparate y de todos los muñecos mientras se revisa (suspended); por otros motivos, con tres. Quien compró conserva su licencia. Al autor nunca se le dice quién denunció. Si Neblla da la razón a la denuncia, la escalera es advertencia, retirada del producto (removed: no sale ni lo lleva nadie) y baneo del developer en la tienda (no vende más y se retira todo lo suyo); cada paso llega por email al autor, que ve su historial con neblla_store sanctions y puede recurrir respondiendo.

Errores