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:
container: el elemento (o su selector) donde va el lienzo; el lienzo ocupa todo el contenedor y lo sigue al cambiar de tamaño. Dale una altura con CSS (undivvacío mide 0). Sincontainer, ocupa la ventana entera.sky:'day'(por defecto),'sunset','night'o'studio'(luz neutra, sin niebla y un suelo que solo muestra la sombra: para un producto). Cada cielo trae su luz, su niebla y su sombra.camera: un preajuste ('side','top','iso','third'por defecto,'first','chase') o{ preset, distance, height, yaw, pitch, target }.quality:'auto'(por defecto),'high','medium','low'o'lowest'; se cambia luego conworld.quality.set().fog:[color, cerca, lejos],{ color, near, far }ofalse; la del cielo por defecto.far: hasta dónde ve la cámara (1000).shadow: { range }: los metros que cubre la sombra (20). Ver «Mundos grandes en un móvil».touch:'auto'(por defecto: el joystick y los botones salen en un móvil o una tableta),trueofalse.gravity:[x, y, z], por defecto[0, -9.81, 0].lock: un eje que nada puede dejar, como'z'. Concamera: 'side'es'z'por defecto: el plataformas es el mismo mundo visto de lado.fallY: por debajo de esta altura saltafall(por defecto-30).headless: true: solo la simulación, sin dibujo (también en Node).
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.
- Forma:
shape: 'box'consize: [ancho, alto, fondo],'sphere'conradius,'capsule'conradiusyheight,'convex'conpointso'mesh'converticeseindices. - Cuerpo:
body: 'static'(por defecto: suelo, paredes),'dynamic'(cae, rueda, choca),'kinematic'(lo mueves tú: una plataforma que va y viene) o'none'(solo se ve). - Sitio:
position: [x, y, z](metros; 1 unidad = 1 metro),rotation: [x, y, z](radianes). - Aspecto:
color('#ffd23f'o0xffd23f),material: 'matte'(por defecto),'glossy','metal'o'emissive',glow,opacity,visible. - Física:
friction,restitution(rebote),density,lock(ejes que esta cosa no deja, como'xz'). - Zonas:
sensor: true(no choca, avisa de quién entra y sale) ycollect: true(una moneda: la recoge un personaje, saltacollecty desaparece; conkeep: truese queda). nameydata: lo tuyo.world.find(name)la busca.
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.
world.on('touch', (a, b) => …)— dos cosas se tocan (empiezan a tocarse).world.on('enter', (zona, cosa) => …)yworld.on('leave', (zona, cosa) => …)— algo entra o sale de unsensor.world.on('collect', (moneda, quien) => …)— un personaje recoge uncollect.world.on('fall', (cosa) => …)— algo cae por debajo defallY.world.on('step', (dt) => …)— cada paso de la simulación: aquí van las reglas.
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):
- Niebla y cielo.
fog: [color, cerca, lejos](o{ color, near, far }, ofalse) cambia la niebla del cielo (la dedayva de 40 a 160 m: para islas lejanas,fog: [0xcfe6fb, 300, 1000]).world.fog = …la cambia después yworld.fogla lee. La cúpula del cielo sigue a la cámara (nunca se llega a su borde) yfar: 1600alarga hasta dónde ve la cámara (1000 por defecto). Un cielo por zona:world.setSky('sunset' | 'night' | 'day' | { top, bottom, fog, hemi, sun, dir }, { transition })cambia el cielo con su luz y el color de la niebla (planea entransitionsegundos, 1,5 por defecto; 0 = de golpe) yworld.skydice el nombre. Un cielo que no existe falla conworld_bad_sky;studiono se cambia después. - Sombra.
shadow: { range: 40 }amplía los metros que cubre la sombra del sol alrededor de lo que mira la cámara (20 por defecto); la sombra siempre sigue al personaje. - Zonas.
world.zone(centro, radio, (z) => { … })construye un lugar solo cuando quien juega se acerca aradiometros decentro([x, z]o una cosa) y se lo lleva cuando se aleja (un 15 % más lejos, para que quedarse en el borde no lo monte y lo desmonte a cada paso). Dentro,z.add(spec),z.scatter(modelo, lista, opciones)yz.keep(cosa)crean cosas que se van con el lugar; la función puede serasync. Lo que está lejos no se dibuja ni se simula. Opciones:{ name, focus (una cosa; por defecto, el personaje que controlas), unload }. Devuelve la zona (loaded,loading,remove());world.zoneslas lista y el eventozoneavisa:world.on('zone', (zona, 'load' | 'unload') => …). Se decide en los pasos de la simulación, así que quien juega lo mismo en dos dispositivos ve aparecer las cosas en el mismo paso. La calidad acorta el alcance (100 %, 85 %, 70 % y 50 %). - Detalle por distancia.
world.add({ …, lod: { far: 80, proxy: 'box' } }): pasadosfarmetros de la cámara la pieza no se dibuja (proxy: 'none', por defecto) o se dibuja como una caja de su tamaño ('box', concolorsi quieres otro).world.stats().lodscuenta las lejanas. - Instancias. Lo que se repite (palmeras, rocas) va con
world.scatter(modelo, lista, { collider: 'none' }): una pieza cuesta un draw call por malla del modelo, sean 60 o 3000 (sin choque propio; concollider: 'box'cada una es un cuerpo).
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í
- Un sólido es una pared: no se atraviesa. Una zona es un sensor que avisa (
enteryleave) y no frena. La forma es caja, esfera, cilindro o «la forma real» (todos los triángulos del dibujo, solo sólida). Una caja o un cilindro pueden ir girados: girar la pieza en Creator gira su choque (no lo agranda), y enplataforma.colliderscada uno trae surot(grados,[x, y, z]); unglbsinrotse lee como siempre, con los ejes del mundo. - Un nombre puede ser de varias cosas (dos baldosas «lava» en un archivo, el
startde dos plataformas):p.onZone('lava', …)avisa de todas y devuelve una función que las quita todas;p.zonesAll.lavayp.pointsAll.startson las listas, en el orden del archivo (yplataforma.pointsAllen el modelo cargado).p.zones.lavayp.points.startsiguen siendo una sola, la última, como siempre. - Un punto es una marca con nombre (
startyendde una plataforma que se mueve, el enganche de una bandera, de dónde sale un disparo).supportes el punto de apoyo, donde la pieza toca el suelo: en un kit, cada pieza de primer nivel puede llevar el suyo, que va a su fichero.p.pointstambién sale en el modelo cargado, sin mover:plataforma.pointsyplataforma.colliders. - Se crean donde está la cosa al añadirla (con su posición, giro y escala): si mueves la cosa entera después, sus choques no la siguen (para eso usa
collider: 'box'y un cuerpokinematic). Lo que sí se mueve solo es una parte animada de la pieza: ver «Piezas con clips». - Con
collider: 'box' | 'convex' | 'mesh' | 'none'se usa el del motor y no el del autor.
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
- Un clip dice qué cambia, no dónde están las cosas: mover la pieza no lo rompe. El giro es sobre el pivote de la parte (una puerta con el pivote en la bisagra gira sobre la bisagra).
- Los choques van con su parte. Un choque dibujado sobre una parte animada (el de la puerta sobre la puerta) es un cuerpo
kinematicque la sigue: cerrada no se atraviesa, abierta sí, y mientras se mueve empuja y lleva a quien está encima (una plataforma que va y viene). En Creator cada choque y cada punto dice con qué pieza va («Va con»; al crearlo con algo elegido, va con eso): se mueve, gira y crece con ella al editarla, una copia de la pieza trae los suyos y, si un clip la mueve, la sigue (va en la receta comopart: '<id>';part: null, con ninguna, se queda quieto; sin decirlo, la parte animada más pequeña cuya caja contiene su centro). «La forma real» también: los triángulos de una parte animada son un cuerpokinematicque la sigue, y los demás se quedan quietos (el martillo que oscila empuja, su soporte no se mueve). En «Exportar → Un archivo por pieza (el kit)», cada fichero lleva los clips que mueven sus propias partes y los choques y puntos que van con ella («la forma real» solo va al de su pieza). El nombre del fichero es el de la pieza (coin_gold.glb); las piezas ocultas y las marcadas «No exportar» (una escena de prueba) no salen, y la ventana lo cuenta. «Probar en mi juego» reproduce todos los clips a la vez y en bucle, y al entrar en una zona dice cuál. - El color no existe en las animaciones de glTF: va en
extras.neblla.colorsdel clip y el motor lo aplica al material de esa copia (las demás copias del modelo no cambian). - Sin pantalla (
headless) los clips corren igual, con sus choques: se puede probar un nivel sin dibujarlo. - Un modelo sin clips no tiene
moves,playnistop.
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:
island: { radius, peak, seed, seabed, margin }(oisland: true) — una montaña depeakmetros (30 por defecto) en medio de un disco deradiusmetros (100), con la playa en la orilla y el fondo del mar fuera, aseabedmetros de profundidad (3). La orilla está a altura 0: con el agua del capítulo siguiente enlevel: 0, la isla sale de ella. La mismaseedda la misma isla en cualquier dispositivo y en cada ejecución, bit a bit (las alturas no usanMath.random,sinniexp): un servidor y todos los móviles coinciden en dónde está el suelo. El lado esradius × 2 × margin(margin 1,35).size: [ancho, fondo](o un número) yheights: [[…], …]— una matriz con una fila para cadazy una columna para cadax, en metros sobre laydel terreno; 2 × 2 como mínimo. Entre punto y punto el suelo son dos triángulos por celda.
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:
size:[ancho, fondo]en metros, o un número (200 por defecto).level: la altura del agua en calma, en el mundo (0).color('#2f86b5') yopacity(0,82).waves:{ amplitude, length, speed }, la altura de las crestas en metros (0,35), la longitud de la ola más larga (24 m) y su velocidad (2,4 m/s); un número es laamplitude;falsees agua quieta. Se suman cuatro olas de distinta longitud y dirección: el agua nunca sube ni baja más de 2,15 ×amplitudede su nivel. La superficie es una malla decellspor lado (por defecto unas cuatro celdas para la ola más corta, 256 como mucho): para un mar de un kilómetro usa olas largas.
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 }:
densityes la del cuerpo entre la del agua: 0,5 (por defecto) flota con la mitad dentro, 0,25 con un cuarto, 1 justo bajo la superficie, más de 1 se hunde.- Se lee de la caja del cuerpo: la columna de cada una de sus cuatro esquinas empuja hacia arriba según cuánta está bajo el agua, así que se inclina cuando las olas levantan un lado. Para un casco o algo que no es una caja, pasa
points, una lista de[x, y, z]en el espacio del propio cuerpo (las esquinas de abajo): cada uno empuja según lo hondo que está. height(opcional) es lo hondo que se hunde el casco en metros, cuando la caja es más alta que el casco (el mástil de un barco); conpointses lo que reparte el empuje.drag(1,2) yangularDrag(1,5) son lo que el agua lo frena.stability(1) lo endereza como lo haría un centro de masa bajo; con 0 rueda como un barril.- El cuerpo no siente agua donde no la hay. A un personaje el agua no lo empuja: nada por su cuenta (siguiente capítulo, «La orilla y nadar»).
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)
- Nadar: un personaje nada cuando el agua le llega a más del 62 % de su altura desde los pies, y deja de nadar por debajo del 45 % (el margen evita que parpadee en el borde). Hasta ahí vadea: anda con el agua por las rodillas. Nadando, el centro del cuerpo se queda a un cuarto de su altura bajo la superficie, con las olas; la velocidad es el 55 % de la de andar; saltar da un impulso para subirse a un muelle o a una orilla alta. Nada donde no hay agua:
world.waterHeight(x, z)esnull. swim— evento del mundo,world.on('swim', (cosa, nadando) => …), cuando empieza (true) y cuando acaba (false). El estado está encosa.data.swimming.- Espuma: una cinta de
foamWidthmetros (1,8) a cada lado de la línea donde el suelo cruza el nivel del agua, que sube y baja con las olas y respira. Sale sola entre cadawatery cadaterrain, también si se añaden después;water: { foam: false }la quita. Se calcula sobre los mismos triángulos que se dibujan y se pisan. Va en los terrenos; una pieza que toca el agua no lleva espuma. shoreOf(terreno, nivel, [x, z])(del motor,(await neblla.world.engine()).shoreOf) devuelve la línea de la orilla como[x1, z1, x2, z2, …], por si el juego quiere poner cosas en ella.
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) => {});
vehicle: 'boat' | 'car'enworld.addhace un cuerpodynamic. Sinshapenimodeles una caja (un barco de 2,4 × 1 × 5,5 m, un coche de 2 × 1 × 4 m). Su delante es −Z; los barcos del catálogo (Kenney) tienen la proa en +Z y por eso un vehículo conmodelse gira medio giro (modelYaw: Math.PI) salvo que pases otromodelYaw.- Barco: flota por puntos del casco, no por la caja del modelo (el mástil de un barco de 10 m lo hundiría): por defecto las cuatro esquinas de abajo y el centro;
floats: [[x, y, z], …]los pone a mano ydraft(1,2 m) es lo hondo que se hunde el casco. Se mece con las olas, el motor solo empuja mientras está en el agua, la quilla le impide deslizarse de lado y tiene inercia: acelera y frena poco a poco. Choca con las islas y se para. El timón no gira parado: gira más cuanto más avanza (hasta un tercio de la velocidad máxima), como un barco de verdad; un coche igual. - Coche: un cuerpo que no puede volcar (
lockRotation: 'xz') y que se agarra al suelo; en el aire casi no gira ni acelera. speed(m/s: 14 el barco, 14 el coche),turn(rad/s: 0,55 y 1,8),accel(m/s²: 3,5 y 9) ygrip(cuánto tarda en quitarse el deslizamiento lateral) lo afinan.seat: [x, y, z]es donde se sienta el personaje, en el espacio del vehículo;exit: [x, y, z]es por dónde baja primero;camera: { distance, height }es la dechase.- Controles: arriba y abajo aceleran y frenan (hacia atrás va más lento), los lados giran; Mayús acelera un 50 % más; en un móvil, la palanca hace lo mismo.
vehiculo.vehicle.throttley.steer(−1 a 1) los puedes poner tú, para una IA o un mando propio. - Bajar:
world.leave(personaje)lo pone en el primer lado (derecha, izquierda, detrás, delante) con suelo seco, y si no lo hay, en el agua, donde nada. Devuelve el personaje, onullsi no iba en nada. - Errores:
world_vehicle_single_player,world_vehicle_taken(ya hay alguien dentro, o el personaje ya va en otro),world_bad_vehicle(un vehículo o unboardmal escrito;world_not_charactersi lo que sube no es un personaje).
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 |
health(3),speed(2,6),damage(1),reach(1,6),cooldown(1,4 s),recover,push,stagger(0,4 s que se queda aturdido al recibir un golpe;falsepara un jefe) ycorpse(2,5 s antes de desaparecer al morir) son lo que se afina. Todo en metros y segundos de la simulación.- Un jefe:
brainpuede ser una lista (['charge', 'ranged']: uno detrás de otro) yphases: [{ at: 0.5, brain: [...], speed: 1.4, windup: 0.5 }]cambia de cerebro y de números cuando su vida baja de esa fracción, una vez cada fase. - Su modelo juega los clips de cada estado: quieto, andar, correr, levantar el brazo al avisar, golpear, recibir un golpe, saltar y morir. Los monstruos del catálogo traen su tabla
moves; otro modelo pasa la suya:moves: { idle: 'Idle', walk: 'Walk', run: 'Run', attack: 'Punch', hit: 'HitReact', die: 'Death', jump: 'Jump' }. - Eventos (en
world.on):windup(avisa),strike(golpea),charge,leap,shoot,emerge,phase,hurtydie. - Todo va con el reloj de la simulación y un generador de enteros propio (nunca
Math.random): los mismos bichos y el mismo jugador dan la misma pelea.
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.
world.hit(desde, { range, arc, damage, push, dir })golpea lo que tengahealtha menos derangemetros (1,8) dentro delarcen grados (120; 360 es en redondo) por delante dedesde, de otroteamy a su altura. Devuelve lo golpeado, lo más cercano primero. «Delante» esdir: [x, z], o eldirdedesde(el personaje de los controles lo lleva: hacia donde se movió por última vez), ofacingen radianes.world.damage(cosa, cantidad, { desde, dir, push })hace daño a una. Conpush(m/s) un personaje sale despedido.cosa.health,cosa.maxHealth,cosa.deadycosa.heal(n)lo cuentan.invulnerable:true, o un segundo del reloj del mundo (world.time + 1: un segundo de gracia tras un golpe). Los de un mismoteamno se hacen daño; las criaturas son del equipo'creatures'.- El muñeco:
muñeco.hold(modelo, 'hand_r', { at, turn, size })(un modelo, o un objeto de three.js) ata una cosa a un hueso ymuñeco.drop(lo)la quita; ymuñeco.play('slash')es el golpe con espada (de la Universal Animation Library; no sale en la tira de Creator). - Errores:
world_bad_creature(unbrain, unphases, una medida o uncreatureque no valen; el mensaje dice cuál),world_bad_health,world_bad_hit.
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):
- Los clips de estado se llaman como el estado:
idle,walk,run,attack,hit,die,jump(da igual mayúsculas o minúsculas). Es el nombre que busca el motor cuando no hay tablamoves; con ella gana la tabla. Mundo escribe ademásextras.neblla.moves({ walk: 'walk', … }) en la escena, solo con los que existen, y el motor lo lee antes que sus nombres por defecto. - Los clips son «en el sitio»: es el motor quien mueve a la criatura. Un clip de estado cuya raíz viaja por el suelo hace que el lobo se deslice y salte atrás en cada vuelta; el exportador de Creator le quita x y z a la raíz y lo dice.
- Su choque es la cápsula del motor, sacada de su tamaño: no hay malla de choque.
- Con piel (
skins,JOINTS_0,WEIGHTS_0) la manada de un mismo fichero funciona (SkeletonUtils.clone). Las pegatinas sobre una malla con piel no se exportan.
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.
body: las medidas del cuerpo de arriba (height,width,legs,torso,arms,head). Pisa a las delaspectode quien juega y también al volver a vestirlo conmuñeco.dress(). Un juego que cambia el cuerpo cambia el esqueleto y responde de sus movimientos y de su equilibrio: cápsula, salto y velocidad los pones tú.world.BODY_BATTERYson los cinco cuerpos extremos con los que Neblla prueba las cosas del muñeco.shape: la forma de la carne, tres ejes de −1 a 1 en pasos de 0,05 (gender,strength,weight; 0 es el cuerpo de siempre).'player'pone la que traiga el aspecto de la persona;falsepone el cuerpo 0 a todos; un objeto pone la misma a todos. Se cambia mientras se juega conmuñeco.setShape({ weight: 0.5 })(lo que no dices se queda). Las cosas puestas (una capa, una mochila) siguen a la carne. Las formas necesitan el fichero de formas del catálogo; sin él, el cuerpo es el 0.model: tu muñeco.glb, con exactamente los huesos de Neblla (los de nombres fijos de «El muñeco», arriba; si falta alguno,world_bad_modeldice cuál). Concarry: true,dressle pinta las zonas, le pone la cara y el pelo y las cosas de la tienda, que van atadas a un hueso y por eso funcionan en cualquier cuerpo con esos huesos. Neblla no prueba esas cosas sobre tu modelo (la tienda las prueba en el suyo): que se vean bien en el tuyo es cosa de tu juego. Los movimientos son los que traiga tu.glbmás los que aprendas conmuñeco.learn().carry: false: todos llevan el muñeco por defecto (sin ropa de la tienda, pelo, colores ni cara de la persona; ni siquiera se piden sus cosas) con lashapede las reglas. Sirve para un juego con uniformes o con personajes fijos. La forma de la persona no es algo que lleve puesto: conshape: 'player'se queda.
(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].
obby(obligatorio,1),name,sky,camera(thirdpor defecto;sidepara uno de lado),fallY(por debajo, te has caído; por defecto 10 m bajo lo más bajo),player(speed,run,jumpen m/s).start(obligatorio) —{ position, facing }: donde apareces.pieces(obligatorio) — lo que pisas:piece(un id de cualquier colección del catálogo: el kit pirata, la naturaleza, las plataformas…) obox([ancho, alto, fondo]concolorymaterial),position,rotation,scale,move({ by: [x, y, z], time, pause, offset }: va hastabyy vuelve,timesegundos cada ida),spin(grados por segundo),swing(un péndulo, más abajo),crumble(se cae al pisarla, más abajo),solid: false(solo se ve) yhidden: true(empieza escondida y una regla la enseña). Una plataforma que se mueve o gira te lleva encima.- Aparecer y desaparecer (
crumble,hide,show,toggle): lo que desaparece o aparece hace un fundido de medio segundo, como pista de lo que pasa; el choque se quita al acabar el fundido y se pone al empezar el de entrada. Lo que empiezahiddeno se recoge (monedas) cambia al instante. crumble—trueo{ after: 0.6, back: 3 }: la pieza se caeaftersegundos después de que la pises (tú con ella, si sigues encima), y vuelve a losbacksegundos (back: false: no vuelve). Avisa con el eventocrumbledel juego ({ id, state: 'armed' | 'gone' | 'back' }).swing—{ axis: 'z', angle: 45, period: 3, offset: 0, pivot: [x, y, z] }: gira de un lado a otro como un péndulo,anglegrados a cada lado, una ida y vuelta cadaperiodsegundos, alrededor del ejeaxisy depivot(su propia posición si no lo dices; pon el pivote encima para que cuelgue). Vale en piezas y en peligros (una sierra que se balancea) y arrastra al jugador como cualquier plataforma que se mueve. No se mezcla conmovenispin.checkpoints—{ position, size }(zona de 2,4 × 3 × 2,4 m por defecto, con una bandera al lado;piece: null, sin ella). Al entrar, es tu nuevo punto de control; solo cuenta uno más adelante en la lista.hazards—kind:lava(una caja roja que brilla),spikes(los pinchos del catálogo),saw(la sierra, que gira) ozone(invisible); conbox,piece,size,move,spinyswing. Tocarlos te devuelve al último punto de control, igual que caerte. Dos más no hacen daño al tocarlos:wind(invisible; conwind: [x, y, z]empuja al jugador mientras está dentro de subox: x y z son m/s que se suman al paso, y son m/s² de subida contra la gravedad —9,81 lo sostiene en el aire, 25 lo eleva—) ywater(el agua del mundo,world.add({ water }): nadar en ella te devuelve al último punto de control; no llevapositionnibox).items— lo que se recoge:piece(una moneda de oro por defecto),position,points(1). Giran despacio para que se vean.finish(obligatorio) —{ position, size }con su bandera: al entrar, se para el tiempo.time—limit(segundos para acabar; si se pasan,lose) ymedals(gold,silver,bronze: los segundos que hay que batir).rules—{ when, then, once }.when.event:touch(tocar una pieza),enteryleave(una zona: punto de control, peligro o meta),collect(recoger una cosa),fall(caerte) ytime(at: segundos desde que empieza, oevery).when.thinges elidde la cosa (sin él, cualquiera).thenes una acción o una lista:respawn,checkpoint(thing),finish,lose,hide,show,toggleystop/go(thing),launch(velocity[x, y, z] en m/s),score(add),say(text,seconds),speedyjump(valueen m/s,seconds; sinseconds, para siempre). Cualquier acción puede llevarafter: 2: espera esos segundos antes de hacerse (en el reloj del mundo;start()cancela lo que esperaba), por ejemplo[{ "do": "hide", "thing": "puente" }, { "do": "show", "thing": "puente", "after": 3 }].once: true, solo la primera vez.
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:
- Las cosas del juego (las que el juego hace, regala o vende dentro de sí: licencias
app,devyplayers) siempre dan su poder. Es una regla más del juego: enroom.js,arbitro.grant(userId, { speed: 1.5 })(una estrella que acelera) yarbitro.grant(userId, null)para quitarlo. - Las cosas de la persona (las que compró en la tienda de Neblla y lleva de juego en juego: licencia
person) solo dan su poder si el juego acepta el modo sandbox. Sin él, son solo cómo se ve el muñeco.
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:
- Una cosa para el muñeco (
avatar: una gorra, una mochila, unas alas) con la licencia de persona (person): la compra un jugador y la lleva en su muñeco en todos los juegos con el sello «compatible con Mundo». Cuesta al menos 1 €; Neblla se queda el 30 %. - Una pieza para juegos (
piece), o una cosa para el muñeco que un juego quiere para sí, con la licencia de developer (dev): la compra un developer una vez, la usa en todas sus apps y puede meterla en lo que vende (su plantilla, algo que su juego vende dentro del juego). Cuesta al menos 0,50 € (el cobro más pequeño de Stripe, hasta que llegue el carrito). La comisión de Neblla baja con el precio: 2 % hasta 1 €, 10 % a 5 €, 20 % a 20 € y 30 % desde 50 €, en línea recta entre esos puntos.
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
feature_disabled— el móduloworldestá apagado.world_load_failed— el motor no se pudo descargar (sin red); la siguiente llamada lo intenta otra vez.world_no_container— ningún elemento coincide concontainer.world_bad_shape,world_bad_body,world_bad_camera— una forma, un cuerpo o una cámara que no existe; el mensaje dice cuáles hay. Una malla ('mesh') no puede serdynamic: usa'convex'.world_bad_model—modelno es lo que devolvióworld.load()(falta elawait), o la dirección no vale.world_bad_terrain— unterrainque no hace suelo: sinislandniheights, una matriz que no es rectangular o con algo que no es un número, un tamaño, un radio o unpeakde cero o menos, o unshape,modelodolla su lado. El mensaje dice el campo.world_bad_water,world_bad_buoyancy— unwatero unbuoyancysin sentido: un tamaño de cero o menos, un nivel que no es un número, olas con longitud cero, unbuoyancyen un cuerpo que no esdynamic, una densidad de cero. El mensaje dice el campo.world_model_unavailable— la dirección del modelo no respondió bien (statusdice qué); un fallo no se guarda, así que se puede volver a intentar.world_not_character—control()recibe una cosa que no es un personaje ni un vehículo, oboard()un primer argumento que no lo es.world_vehicle_single_player,world_vehicle_taken,world_bad_vehicle—world.boarden una sala con varios jugadores, en un vehículo ocupado (o con un personaje que ya va en otro), o con algo que no es un vehículo;vehiclemal escrito.world_bad_creature,world_bad_health,world_bad_hit— una criatura, unahealtho un golpe sin sentido; el mensaje dice el campo.world_bad_rules—world.avatarRules()con una regla que no existe o con un valor que no vale (modelque no es una dirección,carryque no estrueofalse); unashapefuera de rango daavatar_bad_looky unbodyfuera de rango,world_bad_proportions, los dos con sufield.world_bad_timer—afteroeverysin función.world_no_room—world.multiplayer()en un mundo que no salió deneblla.world.create().world_bad_walls,world_bad_bounds,world_bad_spawn,world_bad_object,world_bad_zone,world_bad_place— una opción del árbitro o de la red mal escrita; el mensaje dice cómo va.world_unknown_piece—world.load('catalog:…')con unidque no está en el catálogo: búscalo conworld.catalog.search().world_bad_kind— unkindque no existe; el mensaje dice cuáles hay.world_bad_proportions— una medida del cuerpo que no existe o fuera de su rango (field,minymaxlo dicen).world_bad_move—play()con un movimiento que no existe (el mensaje dice cuáles hay), olearn()con un movimiento propio que no vale: un hueso que no es del muñeco, más de 30 s, 500 KB o 70 pistas, un nombre de los 23 o ya van 32 (el mensaje dice cuál).avatar_bad_look— un aspecto con algo que no existe (un estilo de pelo, un color que no es#rrggbb);fielddice cuál.user_not_found—neblla.avatar.get(userId)con alguien que no está conectado a tu app.avatar_not_used— el sello «compatible con Mundo» pedido para una app cuya versión publicada no llama aneblla.avatar, o sin el móduloworld.world_bad_effect,world_bad_sound— un efecto o un sonido que no existe, sin sitio o con un valor fuera de rango; el mensaje dice cuáles hay.world_bad_area,world_bad_lod,world_bad_fog,world_bad_quality— una zona, unlod, una niebla o un modo de calidad mal escritos; el mensaje dice cómo va.obby_invalid—world.obby()con un JSON que tiene errores;errorslos trae todos (path,code,message). Los mismos que daneblla_assetsconcheck_obby.