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 con el botón derecho gira (con el izquierdo también en third) 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 con el botón derecho gira la cámara, como en Roblox, sin el menú del navegador; en third también con el izquierdo, y en chase la cámara se queda donde la dejas, y al andar «adelante» es hacia donde mira la cámara; doble clic con el derecho la pone otra vez detrás; 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. Cada nivel tiene también un presupuesto de draw calls (400, 250, 150 y 100, de high a lowest): un fotograma que lo pasa cuenta como lento y baja un nivel aunque vaya a 60.

Cambiarla en marcha (C524). world.quality.set('auto' | 'high' | 'medium' | 'low' | 'lowest') la cambia al momento y devuelve el nivel; 'auto' deja otra vez que Mundo decida, desde donde esté. Es lo que llama el menú de ajustes gráficos de un juego. world.quality.modes lista los valores y se emite el evento quality (world.on('quality', (nivel, auto) => …)).

world.stats() dice qué cuesta un fotograma: { drawCalls, triangles, fps, entities, zones, lods, quality, auto, budget, overBudget }. drawCalls y triangles son del último dibujo (0 sin dibujo) y fps va suavizado.

Efectos y sonido

Para el dibujo y el oído, nunca para las reglas: no tocan la simulación, así que dos dispositivos que no coinciden en una salpicadura siguen coincidiendo en el juego.

world.effect(nombre, { at, follow, color, scale, count, loop }) lanza partículas dibujadas en la tarjeta gráfica (un solo draw call por efecto). Los nombres: splash (salpicadura), spark (chispas), sparkle (destellos que se quedan flotando), smoke y dust. at es [x, y, z] o una cosa (un barco que salpica); follow hace que el efecto siga a una cosa mientras vive; color es un número (0xffaa00) o '#ffaa00'; scale agranda; count cambia cuántas (hasta 200; la calidad deja un 100 %, 70 %, 50 % o 30 %); loop: true no acaba hasta stop(). Devuelve { stop(), done, count, object }; acaba solo, y world.effects lista los vivos. Un nombre que no existe, sin sitio, o con scale/count fuera de rango falla con world_bad_effect diciendo qué escribir.

world.sound(nombre | dirección | hoja.json, { at, follow, loop, loopStart, loopEnd, volume, rolloff }) suena desde su sitio (Web Audio con un panner; el oyente sigue a la cámara). Sin at ni follow suena sin posición (música, menús). Los sonidos de Neblla, generados por Neblla —sin ficheros ni licencias que citar— son splash, hit, slash, coin, step, wave y gull; con una dirección (/media/olas.mp3, una URL) suena cualquier sonido de tu app. Arranca tras el primer toque o tecla de la página (el navegador no deja antes); lo que se pide antes suena en cuanto se puede. Con la hoja .json que escribe Eco (la de una canción o una música de fondo) basta una dirección: Mundo escoge la fuente más ligera que el aparato sabe tocar (Opus antes que MP3) y, con loop: true, repite entre los puntos de esa fuente sin salto (AudioBufferSourceNode, no <audio loop>); con una dirección suelta, loopStart y loopEnd (segundos, solo con loop: true) ponen los puntos a mano. Todo en sonido.txt. Devuelve { stop(), volume, playing, ready, source, loopPoints }; volume se puede cambiar mientras suena. world.audio.muted = true los calla todos (un menú de ajustes). Un nombre o una dirección que no valen, o un volume fuera de 0 a 4, fallan con world_bad_sound.

Mundos grandes en un móvil

Un mundo con islas a 300 o 400 metros pide cuatro cosas, y las cuatro se piden al crear el mundo o al añadir las cosas (C524):

Un lod mal escrito falla con world_bad_lod, una zona mal escrita con world_bad_area, una niebla con world_bad_fog y quality.set('ultra') con world_bad_quality.

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: avisa de todas las que se llaman así

Piezas con clips: puertas, plataformas, molinos

En Creator, el modo Animar de una pieza de juego (creator.neblla.com/mundo, «Modelar | Animar») le pone clips con nombre: llaves de posición, giro, tamaño y color por parte o grupo, que se repiten (loop) o suenan una vez. «Animar con un clic» los hace de un golpe: girar sin parar, flotar, bisagra (abrir y cerrar), péndulo (de +Cantidad a −Cantidad y vuelta, en bucle: el martillo de un obby), ir y venir, latir (en los tres ejes o en uno). Al exportar, los clips van dentro del glb, y el motor los lee:


const puerta = await world.load(items.find(i => i.title === 'Puerta'));
const d = world.add({ model: puerta, position: [0, 0, 0] });
d.moves;                                   // ['abrir', 'cerrar']: los clips del modelo
d.play('abrir');                           // una vez; el último fotograma se queda
d.play('girar');                           // un clip que se repite empieza solo en bucle
d.play('abrir', { loop: true, onEnd: (nombre) => {} });   // `loop` manda sobre lo que dice el fichero; `onEnd` avisa al acabar uno que no se repite
d.play('girar', { together: true });       // sin parar los que ya suenan: varias piezas de un mismo fichero, cada una con su clip
d.stop();                                  // todo vuelve a su sitio de reposo

Terreno con relieve

Suelo que se pisa de verdad: colinas, una montaña, una isla. Una llamada hace la malla que se dibuja (colores por altura y pendiente: arena, hierba, roca) y el choque donde se pisa, desde la misma matriz de alturas, así que lo que se ve es lo que se pisa. Sirve para una granja, un mundo abierto, una colina en un plataformas o una isla.


const isla = world.add({ terrain: { island: { radius: 100, peak: 30, seed: 7 } } });   // una isla: playa, hierba y una montaña
const colina = world.add({ terrain: { size: [16, 16], heights: [[0, 0, 0], [0, 3, 0], [0, 0, 0]] }, position: [40, 0, 0] });
world.terrainHeight(12, -30);                     // la altura del suelo ahí, en el mundo (null donde no hay)
isla.terrain.heightAt(12, -30);                   // lo mismo, preguntado a un terreno

world.add({ terrain, position, name }) (CLIENT) — position es el centro del terreno (y es la altura del cero). terrain es una de estas dos:

Las dos admiten cells (la isla: cuántas celdas por lado, 128 por defecto y 256 como mucho: una celda de una isla de radio 100 mide 2 m) y layers, los colores por altura y pendiente: { sand: { to: 1.4, color }, grass: { to, color }, rock: { color }, steep: { from: 38, color } } (arena hasta sand.to metros, hierba hasta grass.to (el 62 % del punto más alto), roca encima, y roca también donde la pendiente pasa de steep.from grados). color: '#7fa65a' en vez de layers la pinta de un solo color; Las facetas ya salen suavizadas (normales por vértice, sin más triángulos); flat: true las deja a la vista, como un cristal tallado.

e.terrain en la cosa: heightAt(x, z), cells, size, min, max, heights. Un personaje la sube hasta su slope (50° por defecto) y la baja andando. El terreno es static; los árboles y las rocas van encima con world.scatter, a la altura que da terrainHeight. Un terrain no admite shape, model ni doll.

Agua: superficie, altura y flotar

Un lago, una piscina, un río o un mar: una superficie con olas que mueve la tarjeta gráfica, la altura del agua en cualquier punto y cuerpos que flotan en ella y se mecen con las olas. Las olas son una sola función del lugar y del reloj de la simulación, escrita una vez para la tarjeta y otra para la física con el mismo seno, así que lo que flota es lo que ves, y un cuerpo flota a la misma altura en cualquier dispositivo y en cada ejecución.


const isla = world.add({ terrain: { island: { radius: 100, peak: 30, seed: 7 } } });
const mar = world.add({ water: { size: 600, level: 0, waves: { amplitude: 0.4, length: 24, speed: 2.4 }, color: '#2f86b5' } });
const barril = world.add({ shape: 'box', size: [1, 1, 1], body: 'dynamic', position: [30, 3, 80], buoyancy: true });   // cae, flota y se mece
world.waterHeight(30, 80);                 // dónde está la superficie ahora, con las olas (null donde no hay agua)

world.add({ water, position }) (CLIENT) — position es el centro de la superficie en x y z (su y es el level). water:

world.waterHeight(x, z) — la altura del agua más alta sobre ese punto en el tiempo de la simulación ahora, o null si no hay. e.water.heightAt(x, z, t) pregunta a una sola agua en el tiempo t (segundos del reloj de la simulación).

Flotar: buoyancy en world.add hace flotar un cuerpo dynamic. true o { density, drag, angularDrag, stability, points }:

La orilla y nadar

Donde el agua toca el suelo se dibuja una franja de espuma, y un personaje que entra en el agua hasta el pecho nada solo: flota con la cabeza fuera, va más despacio y sale a la orilla andando. Es genérico (una playa, un lago, un río, una piscina) y va dentro de la simulación: nadar es igual en todos los dispositivos y en cada ejecución.


const isla = world.add({ terrain: { island: { radius: 60, peak: 10, seed: 3 } } });
const mar = world.add({ water: { size: 400, level: 0 } });          // la espuma sale sola en la orilla de la isla
const yo = world.add({ doll, position: [50, 3, 0] }); world.control(yo);
world.on('swim', (e, nadando) => { if (e === yo) console.log(nadando ? 'al agua' : 'a tierra'); });
yo.data.swimming;                                                    // true mientras nada (el muñeco ya mueve swim y float)

Vehículos (un jugador)

Subir a un barco o a un coche, conducirlo y bajar. Solo para un jugador: en una sala con varios, world.board falla con world_vehicle_single_player (compartir un vehículo necesita un árbitro que decida quién conduce, y todavía no lo hay).


const barco = world.add({ vehicle: 'boat', model: await world.load('catalog:ship-pirate-small'), position: [60, 2, 0], seat: [0, 1.5, 0] });
const coche = world.add({ vehicle: 'car', position: [0, 1, 0] });
world.board(yo, barco);        // yo se sienta, los controles y la cámara (chase) pasan al barco
world.leave(yo);               // baja en la orilla más cercana y recupera controles y cámara
world.on('board', (quien, vehiculo) => {});  world.on('unboard', (quien, vehiculo) => {});

Criaturas y combate

Cosas que se mueven, luchan y mueren solas, y golpes que hacen daño. Genérico: enemigos, animales, PNJ, un jefe.


const modelo = await world.load('catalog:monster-big-alien');           // trae sus movimientos
const bicho = world.add({ creature: modelo, brain: 'melee', health: 3, position: [8, 0, 0] });   // los pies, no el centro
const yo = world.add({ doll, position: [0, 1, 0], health: 5 }); world.control(yo);
const espada = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.9, 0.02), new THREE.MeshStandardMaterial({ color: '#c8d0d8' }));
doll.hold(espada, 'hand_r', { at: [0, 0.05, 0] });                       // atada al hueso: sigue todos los movimientos
yo.onAction = () => { doll.play('slash'); world.hit(yo, { range: 2, arc: 120, damage: 1, push: 4 }); };
world.on('hurt', (cosa, daño, desde) => {});  world.on('die', (cosa, desde) => {});

Una criatura es un personaje al que un cerebro maneja: anda sobre el terreno y el suelo como el jugador, con su cápsula sacada del tamaño del modelo. creature es un modelo de world.load(), { size: [ancho, alto, fondo] } (una cápsula, para probar) o true. Pelea contra el personaje que llevan los controles (target: (criatura) => cosa lo cambia). Cada cerebro avisa antes de golpear, para que se aprenda a esquivar:

| brain | Qué hace | |---|---| | wander | pasea por su zona (zone, 8 m) y no ataca | | chase | te persigue a menos de sight (12 m) y no hace nada más | | melee | te persigue, se para, levanta el brazo (windup, 0,7 s) y golpea hacia donde miraba: apartarse basta | | charge | se para, apunta (windup) y embiste en línea recta a chargeSpeed (3 ×) sin poder girar; descansa (rest) | | leap | se agacha (windup) y salta a donde estabas; el golpe es al aterrizar | | ranged | guarda la distancia (keep, 7 m) y lanza un proyectil recto y lento (projectile: speed, radius, life, color) que se ve venir; una pared lo para | | ambush | escondido e imbatible hasta que pasas a menos de ambush (4,5 m); sale y pelea como melee |

Vida y golpes valen para cualquier cosa, no solo criaturas: health: 5 en world.add (más team e invulnerable) y esa cosa recibe daño.

Una criatura hecha en Mundo (Creator): un lobo

En Creator, el estudio Mundo hace criaturas con esqueleto propio, sin salir del editor: la tarjeta «Un personaje del juego» (o «Darle esqueleto» en una pieza de juego) da una plantilla de huesos (cuadrúpedo, bípedo, ave, serpiente, araña), «Atar con pesos automáticos» une la malla a los huesos, «Animar con un clic» crea andar, correr, quieto, atacar, herido, morir y saltar a la velocidad del motor, y «Probar en mi juego» deja elegir un cerebro. El esqueleto del muñeco del jugador no se edita: es el mismo en todos los juegos. La pieza se baja como glb (o se usa desde la tienda) y se carga como cualquier modelo:


const lobo = await world.load('store:<id>');                          // o la URL de un glb bajado de Creator
const l = world.add({ creature: lobo, brain: 'chase', health: 3, position: [8, 0, 0] });

Lo que el motor lee del fichero, para que lo sepa quien lo genere por otro lado (Blender, otra herramienta):

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. Hay tres colecciones, 268 piezas low-poly en total, todas CC0 (se pueden usar en cualquier app, también de pago, sin citar a nadie): el Platformer Kit de Kenney (148 piezas: bloques, plataformas, trampas, monedas, puertas, árboles), el Pirate Kit de Kenney (72: barcos y botes de remos, palmeras, cañones, muelles, cabañas, torres, arena y rocas, banderas piratas) y los Ultimate Monsters de Quaternius (48 criaturas animadas). 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, prop, vehicle o creature), 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). Los barcos y los botes de remos son vehicle: un barco pequeño mide 9 m de largo y un bote 3 m. Las criaturas son creature, a la escala del muñeco (un orco mide 1,9 m, un gato blob 0,9 m, un dragón volador 2,2 m de ancho), apoyan en y = 0 y traen sus clips (animations) y una tabla moves que dice qué clip es cada movimiento (idle, attack, hit, die, y walk, run o jump si los tienen: { idle: 'Idle', attack: 'Punch', hit: 'HitReact', die: 'Death' }). Hay tres cuerpos: Big (humanoide), Blob (pequeño y redondo) y Flying (volador); sus id son monster-<cuerpo>-<especie>. Una pieza del Pirate Kit cuyo nombre ya tiene el Platformer Kit lleva pirate- delante (pirate-chest, pirate-barrel, pirate-crate).


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

const [barco] = world.catalog.search('barco pirata', { kind: 'vehicle' });   // el Pirate Kit: 'palmera', 'cañón', 'pirate ship'…
const [orco] = world.catalog.search('orco', { kind: 'creature' });           // 'monstruo' las lista todas

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(). Como es un personaje con física, sin suelo debajo cae para siempre: en un visor de muñeco pon una pieza de suelo (world.add({ shape: 'box', size: [8, 1, 8], position: [0, -0.5, 0] })) y el muñeco encima, y touch: false en world.create para que no salgan el joystick ni el botón de salto en el móvil.

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 | | slash | el golpe con espada (no sale en la tira de Creator; con muñeco.hold) |

Movimientos propios

Los 23 movimientos de arriba vienen con el muñeco; los tuyos los haces en Mundo → Animar (creator.neblla.com/mundo: poses con el gizmo, manos y pies fijos, «Hacer un baile») y bajas como un glb solo con la animación. El muñeco los aprende:


await doll.learn('mi-baile', '/media/mi-baile.glb');      // una dirección, bytes o un AnimationClip
doll.play('mi-baile');                                     // ya es uno más

doll.learn(nombre, origen, { loop }) lee el glb, lo comprueba (packages/neblla-world/moves.js: huesos del muñeco, 30 s como mucho, 500 KB, 70 pistas, solo giros salvo la pelvis) y lo ajusta a este cuerpo al aprender, como los 23: en un cuerpo más alto o más bajo la pelvis recorre lo que le toca. Se repite si el fichero lo dice (loop lo cambia). Se pueden aprender hasta 32; aprender el mismo nombre otra vez lo sustituye, y un nombre de los 23 no vale. Cada cuerpo aprende los suyos: se llama a learn en cada muñeco. Falla con world_bad_move diciendo qué hueso sobra o qué límite se pasa.

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, su cara 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', 'bun' (y 'made', que se sigue aceptando y se lee como 'short')
  face: 'classic',                               // la cara, pintada en la cabeza como una calcomanía: 'classic', 'dots', 'sleepy' o 'joy'
  eyes: { style: 'round', color: '#22170f' },    // color: el de los ojos de la cara; style: lo de antes de las caras (sin face, da su cara)
  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»); una con look.role 'hair' es su pelo y quita el de serie
  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.

Los movimientos que compró (moves). Una persona puede comprar en la tienda de Neblla un baile o un saludo para su muñeco (licencia de persona, tipo move) y llevarlo en su cuenta. Es lo que más cuida el tono de un juego, así que cada app decide: está apagado y se enciende al publicar, neblla_publish_app({ …, playerMoves: true }) (hace falta el módulo world), y playerMovesOff: ['sp_…'] deja fuera los que no quiera (el baile de la victoria en un juego serio). Con ello neblla.avatar.get() trae moves: [{ id, name, url, loop }] (sin ello, no trae moves), world.avatar(aspecto) aprende cada uno con doll.learn(id, url) y muñeco.ownMoves es la lista [{ id, name }] para ofrecerla en un menú: muñeco.play(id). Se llevan hasta 8.

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.

Cuerpos en tu juego: altura, formas y modelos propios

Regla de Neblla. La cuenta de cada persona guarda un muñeco de cuerpo estándar (misma altura, mismo esqueleto en todos los juegos; a lo sumo una forma suave de la carne, ±0,25). La altura y los cuerpos distintos (un gigante, un niño, una forma de ±1) solo existen dentro de un juego: es el juego quien los elige, nunca la cuenta. Y el esqueleto y el cuerpo de juego de un muñeco (su cápsula, el salto, la velocidad, el alcance) no cambian con la shape: una shape solo mueve la carne.

Un juego puede elegir entre tres caminos, de más a menos Neblla:

(a) Avatares propios, compatibles con Neblla. Tu juego pone sus cuerpos (body, una shape suya o un modelo .glb propio con los 65 huesos del muñeco estándar) y decide si el jugador lleva sus cosas. Se dice una sola vez, antes de crear a nadie, y world.avatar lo aplica en cada dispositivo (el tuyo, los de los demás jugadores de world.multiplayer y lo que crees tú), así todos ven a todos igual:


world.avatarRules({
  body:  { height: 1.1 },        // el cuerpo del juego: cambia el esqueleto (opcional)
  shape: 'player',               // 'player' (la suya, por defecto) | false (ninguna: el cuerpo 0) | { gender: 1, strength: 0.6, weight: -0.4 }
  model: 'https://…/mi-avatar.glb',   // un modelo propio con los 65 huesos (opcional; sin él, el muñeco de Neblla)
  carry: true,                   // true (por defecto): lleva su ropa, pelo, colores, cara y cosas de la tienda · false: nada de eso
});

world.avatarRules(reglas) (CLIENT) — devuelve las reglas puestas; world.avatarRules() las lee; world.avatarRules(null) las quita. Cambia los muñecos que se hagan a partir de entonces, no los que ya hay.

(b) Un modelo propio, compatible con el mundo pero no con los jugadores. El cuerpo de los demás es una criatura de Mundo con su propio esqueleto (un lobo, un robot), sin nada de lo que el jugador lleva. Es lo de world.multiplayer, con avatars: false y la ficha de la criatura en make:


const lobo = await world.load('store:<id>');   // o la URL de un glb con esqueleto propio (ver «Una criatura hecha en Mundo»)
const red = world.multiplayer({ me: yo, avatars: false, make: async (userId) => ({ creature: lobo }) });

world.add({ creature, character: false, body: 'kinematic' }) hace una marioneta: el modelo y la cápsula de la criatura, sin cerebro (no está en world.creatures, no tiene vida ni ataca), cinemática, que mueve quien la tiene (aquí, el dispositivo de ese jugador; world.multiplayer pone character: false y body: 'kinematic' a los cuerpos de los demás). La marioneta enseña el modelo tal cual, sin sus clips (todavía no los anima según lo que hace el otro jugador). Una criatura sin esas dos cosas sigue siendo la de siempre, con su cerebro. Tu propio cuerpo (me) lo haces tú como quieras. Aquí el jugador no lleva sus cosas, y el muñeco de su cuenta no se usa.

(c) Tu propio motor. Tu juego usa el SDK sin world (identidad, salas, tienda, datos) y dibuja con su propio 3D (three.js, Babylon, lo que quieras). neblla.avatar.get() sigue dando el aspecto de la persona (piel, pelo, colores, cosas) para que lo pintes a tu manera, pero Neblla no pone ni comprueba nada en pantalla. Es solo documentación: no hay nada más que activar.

El sello «compatible con Mundo». Solo tiene sentido si el jugador ve su muñeco de Neblla: con carry: false, (b) o (c) no lo pidas (avatar: true falla con avatar_not_used si la versión publicada no llama a neblla.avatar). Con (a) y carry: true sí.

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

(El portal donde se hacen las cosas, sus rutas y las dos licencias, en creator.txt.)

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: la distancia al centro de la cosa (el de su caja, no el origen de su fichero) sigue el largo y el ancho de esa parte, y la cosa crece con su tamaño alrededor de ese centro.

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

Buscar y usar, desde tu Claude: neblla_store_search (search con query y kind, get, licences) busca lo que publicaron otros autores y dice el precio de cada licencia y cómo cargarlo (world.load('store:<id>')); si falta la licencia da la dirección de la cosa en la tienda para comprarla en la web: el MCP nunca cobra.

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»). Un sonido comprado se toca con world.sound('store:<id>', { loop: true }) (con las mismas opciones que cualquier sonido; «Mis assets» enseña esa línea en su tarjeta, y si la cosa no es un sonido sale world_store_wrong_kind). 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 (el mensaje trae la dirección de la cosa en la tienda para comprarla); 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 €. Solo funciona cuando Neblla lo ha abierto: mientras no, /cart responde store_cart_closed (409) y se compra de una en una, y pagar con créditos (pay: 'credits', para los packs) responde store_credits_closed. El escaparate (GET /api/store/products) dice qué está abierto en cart y credits, y en test si la tienda sigue cobrando en el modo de pruebas de Stripe, antes de abrirse de verdad.

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,. 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 en Creator y puede recurrir respondiendo.

Errores