= Referencia de Singe Scott Duensing :revnumber: 3.00 :revdate: 2026 :doctype: book :toc: left :toclevels: 3 :sectnums: :sectnumlevels: 3 :title-logo-image: image:images/singeLogo.png[Singe, pdfwidth=2.6in, align=center] :source-highlighter: rouge :icons: font :experimental: :lang: es :imagesdir: .. include::locale.adoc[] [#_game_development] == Desarrollo de juegos [#_lua] === Lua Singe usa el lenguaje de programación Lua (http://www.lua.org) para programar la lógica del juego. Lua es rápido, ligero, orientado a objetos, fácil de usar y se usa de verdad en la industria del videojuego. Este libro no lo enseña; _Aprende a programar con Singe_, incluido en la misma descarga que este, sí lo hace, desde cero hasta un juego terminado en treinta y una lecciones. En el canal de YouTube de Kangaroo Punch hay videotutoriales de Lua, y la referencia del propio lenguaje está en http://www.lua.org. [#_basic_rules] === Reglas básicas * Singe es multiplataforma. Cosas de las que normalmente no tienes que preocuparte en Windows son importantes. Los nombres de archivo distinguen entre mayúsculas y minúsculas -- `MyScript.singe` y `myscript.singe` no son el mismo archivo. También deberías usar los separadores de ruta de UNIX -- `/` en lugar de `\`. Así tu código queda más limpio (sin barras invertidas con doble escape) y funciona en todos los sistemas en los que Singe está soportado. * Elige un nombre distintivo para la carpeta de tu juego. No uses espacios. * Usa el framework de Singe incluido. No copies el framework en los scripts de tu juego. Al incluir el framework proporcionado ayudas a la compatibilidad futura a medida que Singe se actualiza. Empieza (o termina) el script de tu juego con: + [source,lua] ---- dofile("Singe/Framework.singe") ---- * Mantén tu juego autocontenido. Si construyes sobre un framework de terceros o compartes código entre tus juegos, cópialo en la carpeta de tu juego. Nunca hagas referencia a una carpeta que esté junto a tu juego; el único archivo de fuera de tu juego que puede cargar un script es `Singe/Framework.singe`. Un juego de un solo archivo (véase <>) empaqueta exactamente una carpeta, y un juego que sale de ella no se puede empaquetar. * No toques la carpeta `Singe/`. La gestiona Singe, y todo lo que se añada o se cambie ahí puede borrarse en el futuro. * Incluye un `games.dat`. Esto es extremadamente importante para los usuarios nuevos. Aunque existen otros front-ends y sistemas de menús basados en Singe, es vital que incluyas los datos para el sistema de menús integrado. Casi todos los problemas de soporte se deben a archivos `games.dat` ausentes o mal formados. * No distribuyas archivos que no sean imprescindibles. Nunca incluyas binarios de Singe ni scripts proporcionados por Singe con tu juego. No incluyas archivos de índice generados a partir de los vídeos. [#reloading] === Recargar mientras trabajas Ejecuta el juego con `--reload` (`-R`) y Singe vigila todos los archivos de script sueltos que el juego ha cargado. Guarda un cambio en uno de ellos y, un momento después, el juego vuelve a empezar desde su script: todo lo que hizo el script desaparece (los sprites, los sonidos, las fuentes, los vídeos, las GUI, la escena 3D, la física, los emisores; el overlay vuelve a su tamaño por defecto, y el volumen de efectos y el ratón a su estado inicial), el motor se queda (la ventana, la GPU, los mandos), el disco vuelve a quedar aparcado en su primer fotograma como al arrancar, y el script se ejecuta de nuevo, así que un modelo de 52 MB vuelve a estar en pantalla en lo que tarda en cargarse en lugar de en lo que tarda Singe en arrancar. Una tecla o un botón que se mantiene pulsado durante la recarga se ignora hasta que se suelta, como en un arranque nuevo. `F5` recarga cuando quieras, y un script puede pedirlo con `singeReload()`. Los juegos empaquetados no tienen nada que vigilar; la opción es para un juego en una carpeta. Un error de script al recargar se imprime y el juego se queda vacío hasta que la siguiente recarga lo arregla; el archivo sigue vigilado, así que basta con guardar la corrección. Como la recarga hace el mismo desmontaje que al salir, cualquier fuga de un script aparece aquí primero. [[zerobrane]] === Depurar en ZeroBrane Studio https://studio.zerobrane.com[ZeroBrane Studio] es un IDE de Lua gratuito, y Singe trae lo que hace falta para ejecutar y depurar un juego desde él: puntos de ruptura, ejecución paso a paso, las ventanas Pila e Inspeccionar, un juego que se queda quieto mientras está detenido, una parada en el IDE cuando un error está a punto de terminar el juego, y autocompletado con tooltips para toda la API, en inglés, francés, italiano, alemán o español. La parte del depurador es MobDebug, integrado en Singe y arrancado por `--debug`, así que un juego no necesita ninguna línea propia; cualquier otra herramienta que hable el protocolo de MobDebug puede conectarse de la misma manera. [[zerobranesetup]] ==== Prepararlo Singe descomprime un paquete para ZeroBrane Studio en su carpeta de soporte, como `Singe/ZeroBrane/singe.lua`, y lo mantiene al día de una versión a la siguiente. Dile a ZeroBrane Studio dónde está con una sola línea en su `user.lua` (Editar > Preferencias > Configuración: Usuario) y luego reinícialo: [source,lua] ---- package "/home/me/Singe/Singe/ZeroBrane/singe.lua" ---- Singe aparece entonces en Proyecto > Intérprete Lua, con Proyecto > Depurar con Singe y un botón de Singe en la barra de herramientas para arrancar un juego bajo el depurador en un solo paso. Los archivos `.singe` se abren como Lua, los documentos `.rml` como XML y las hojas de estilo `.rcss` como CSS. Haz de la carpeta del juego el proyecto (Proyecto > Directorio de proyecto > Elegir...). El paquete busca Singe en esa carpeta y en las tres de encima, tomando la versión más reciente para tu máquina, y después en el `PATH`; si no encuentra ninguno, te lo pregunta la primera vez que ejecutas un juego, y lo recuerda. Proyecto > Elegir el ejecutable de Singe... elige otro, y `path.singe = "/path/to/Singe"` en `user.lua` se impone a los dos. [[zerobranerun]] ==== Ejecutar y depurar Proyecto > Ejecutar (`F6`) arranca el juego, y Proyecto > Comenzar depuración (`F5`) lo arranca bajo el depurador. El script es el fichero de inicio del proyecto cuando hay uno (haz clic derecho sobre él en el árbol del proyecto y elige Establecer como fichero de inicio), y si no, el archivo que se está editando; cuando ese es uno de los módulos del juego en lugar de un archivo `.singe`, se ejecuta el único archivo `.singe` del proyecto. Singe arranca en la carpeta que contiene su ejecutable, como lo arrancaría el menú, así que `Singe/Framework.singe` y todo nombre relativo a esa carpeta se resuelven como siempre, y la entrada del juego en `games.dat` aporta su vídeo y sus ajustes. Proyecto > Parámetros de línea de comandos... añade opciones tuyas, entre ellas `-v` y un vídeo para un juego sin entrada. Haz clic en el margen para poner un punto de ruptura. El juego se detiene allí con sus variables locales en la ventana Pila, la ventana Inspeccionar evalúa expresiones, y Paso dentro (`F10`), Paso sin entrar (`Shift+F10`) y Paso fuera (`Ctrl+F10`) se comportan como en cualquier programa de Lua, también dentro de `singeMain`. Proyecto > Ruptura detiene un juego dondequiera que esté. Mientras el juego está detenido, el disco, cada vídeo y cada sonido se pausan igual que con la tecla de pausa, y después siguen desde el mismo punto. Cuando un error está a punto de terminar el juego -- un error de Lua, o una llamada del motor que recibe algo que no puede usar --, el IDE se detiene antes, en el `Debugger.singe` de la carpeta Singe, y la ventana Salida dice qué ha ido mal. La ventana Pila conserva debajo de ese archivo los propios marcos del script, con sus variables locales como estaban. Continúa, y el juego termina como habría terminado, con el error y una traza en la ventana Salida; haz doble clic en una línea de la traza para abrir el archivo en ese punto. El juego se ejecuta con `--reload`, así que guardar un script vuelve a arrancar el juego y el depurador con él, puntos de ruptura incluidos. Proyecto > Recargar Singe cuando cambie un script lo desactiva. [[zerobranecompletion]] ==== Autocompletado y la Referencia El autocompletado ofrece cada función, callback y constante de la API, y un tooltip muestra los argumentos y el primer párrafo de la entrada. Proyecto > Analizar (`Shift+F7`) también conoce la API, así que señala una llamada mal escrita en lugar de cada llamada. El ajuste de idioma de ZeroBrane Studio elige el idioma de los tooltips: `language = "fr"` en `user.lua` da el francés, y lo mismo vale para el italiano (`it`), el alemán (`de`) y el español (`es`). Haz clic derecho en un nombre y elige Buscar en la Referencia de Singe para abrir su entrada en un navegador; Ayuda > Referencia de Singe abre el principio. Los dos necesitan la descarga de la documentación descomprimida junto a Singe, como `Singe-Docs-v3.00` o `Singe-Docs-v3.00-fr` y así sucesivamente, tomando primero la del idioma de ZeroBrane Studio; `singe = { reference = "/path/to/Reference.html" }` en `user.lua` indica otra. [[zerobranecabinet]] ==== Depurar una recreativa El depurador funciona a través de una red. En la máquina con ZeroBrane Studio, abre la carpeta del juego como proyecto y elige Proyecto > Lanzar servidor de depuración. En la recreativa, arranca el juego con `--debug=HOST`, donde `HOST` es el nombre o la dirección de esa máquina; el puerto es `8172` salvo que `--debug=HOST:PORT` diga otro, y la primera máquina tiene que aceptar conexiones en él. Una recreativa que arranca sus juegos desde el menú puede llevar en su lugar `debug = "HOST:PORT"` en su archivo de ajustes, y cada juego que arranca se conecta, el menú incluido. El IDE detiene cada uno en su primera línea, donde Continuar (`F5`) lo pone en marcha; `debugger.runonstart = true` en `user.lua` lo deja seguir derecho hasta el primer punto de ruptura. La recreativa informa de sus propias rutas, que ZeroBrane Studio hace corresponder con los archivos del proyecto la primera vez que se detiene, así que basta con la misma carpeta del juego en las dos máquinas. Si nadie escucha, un juego se juega como siempre una vez que el intento de conectarse se rinde, lo que no tarda más de dos segundos. [[zerobranelimits]] ==== Límites * Depura un juego sin empaquetar, desde su carpeta. Un `.game` empaquetado no tiene archivos en disco que el IDE pueda abrir. * Un error dentro de `singeMain` se detiene en `Framework.singe`, que ejecuta `singeMain` como una corrutina; el mensaje indica la línea de tu script. * Mientras el IDE tiene el juego detenido, la ventana no se redibuja, y cerrarla espera hasta que continúes. Parar proceso (`Shift+F5`) termina el juego al instante. * Singe detiene un script que nunca devuelve el control cuando se cierra su ventana, pero no bajo el depurador, que es dueño del script mientras se ejecuta; Ruptura encuentra dónde está atascado. [[deterministic]] === Modo de prueba determinista `--deterministic` es una herramienta de pruebas, no una forma de jugar. Hace que el motor ignore por completo el tiempo real: en lugar de preguntar al ordenador qué hora es, el motor funciona con un reloj virtual que avanza un paso fijo -- 15 milisegundos por defecto -- exactamente una vez por fotograma. Todo lo que se rige por el tiempo sigue ese reloj: los pasos de la física, la animación de sprites y modelos, las partículas, la navegación, las animaciones y transiciones de la GUI, `os.clock()`, `singeGetTicks()` y un `os.time()` sin argumentos (un juego que siembra su generador con el reloj al empezar una partida lo siembra con el virtual, así que obtiene los mismos números en cada ejecución; `os.time` con una tabla de fecha la convierte como Lua ha hecho siempre). El disco también lo sigue: mientras se reproduce, cada fotograma de vídeo permanece en pantalla durante tantos fotogramas del motor como valga su tiempo de presentación en ese reloj (dos o tres con el paso por defecto para un disco de treinta fotogramas), como al jugar de verdad, y un disco en pausa o tras una búsqueda se queda donde se dejó. El generador de Lua y el del propio motor se siembran con un número fijo. Nada espera a nada, así que el juego va tan rápido como le deja el ordenador. El objetivo de todo esto es que una captura de pantalla tomada en un número de fotograma fijo sea la misma imagen en cada ejecución. Sin él, el fotograma 40 es un momento distinto cada vez -- la física ha dado un número distinto de pasos y el disco está en otro fotograma de vídeo -- así que dos ejecuciones del mismo juego sin cambios pueden diferir en un millón de píxeles, y un cambio real puede esconderse en ese ruido. Lo que cuesta es todo lo que depende del tiempo real. El audio sigue sonando con el reloj del propio dispositivo, al que no llega ningún reloj virtual, así que el sonido se desfasa de la imagen y no significa nada -- salvo que la ejecución se grabe con `--record`, en cuyo caso el sonido se mezcla en memoria paso a paso junto con la imagen, de modo que una grabación hecha así es exacta fotograma a fotograma y muestra a muestra (véase <>). La tasa de fotogramas tampoco significa nada, ni nada que un juego mida en segundos. Nunca informes de una cifra de rendimiento, de un problema de audio ni de un problema de sincronización a partir de una ejecución en este modo, y nunca distribuyas un lanzador que lo use. El número que lleva la opción, si lleva uno, es a la vez el tamaño del paso y la semilla con la que empiezan los generadores, de `1` a `1000`: ---- Singe --deterministic el paso por defecto de 15 ms, con semilla 15 Singe --deterministic=20 un paso de 20 ms, con semilla 20 ---- Dos ejecuciones con el mismo número son idénticas; dos ejecuciones con números distintos son cada una repetible pero no iguales entre sí, que es como una prueba pide una ejecución distinta pero igual de reproducible. Puede ponerse en un archivo de ajustes como `deterministic`, igual que cualquier otra opción. [#_reference_screenshots] ==== Capturas de pantalla de referencia Cada una de las escenas de `testScripts` toma una o dos capturas de pantalla en un número de fotograma fijo y sale por sí sola, así que en este modo forman un conjunto de imágenes de referencia con las que comparar píxel a píxel un cambio posterior. Para grabarlas, ejecuta todas las escenas con la opción y guarda lo que acabe en la carpeta de datos: [source,sh] ---- for n in 2 3 4 5 6 7 8 9 10 11 13 14 15 16 18 19 21 22 23 24 25 26 \ 36 37 38 39 40 41 42 43 44 45 48 49 50 51; do Singe -w -d data --deterministic -v Singe/menuBackground.mkv \ testScripts/scene$n.singe done ---- Las escenas que se quedan fuera de ese bucle quieren sus propias opciones, las mismas que les da la tabla de `testScripts/README.md`: `scene12` se ejecuta sin disco, `scene17` con el disco y `scene20` con `-C 720x480`, las tres con `SDL_GPU_DRIVER=nothing`; de `scene27` a `scene35` quieren `-x 1280 -y 720 -C 1280x720` y ningún disco; `scene46` quiere `-g '10 5'`; y `scene47` quiere `--bezeldir=testScripts/bezels --bezel=cabinet.png`. Copia los archivos `singe*.png` fuera de `data/testScripts` después de cada ejecución, ya que la siguiente vuelve a numerar sus capturas desde cero. Comparar una compilación posterior consiste entonces en ejecutar los mismos comandos y pedir la diferencia, que debe ser nula: [source,sh] ---- compare -metric AE reference/scene36-singe000.png new/scene36-singe000.png null: ---- [#_where_files_live] === Dónde están los archivos Las rutas dentro de un script son relativas a la carpeta desde la que se inició Singe, no al script. Usa la variable global `DIR`, que `Framework.singe` fija a la carpeta del propio script, para que un juego funcione esté donde esté instalado: [source,lua] ---- crosshair = spriteLoad(DIR .. "sprite_Crosshair.png") ---- Todo lo que escribe Singe va a la carpeta de datos (`singeGetDataPath()`), `data/` junto a los juegos salvo que `--datadir` diga otra cosa, así que un juego puede estar en un soporte de solo lectura. Singe 2.x escribía las partidas guardadas y los ajustes en la propia carpeta del juego; un juego que aún lo hace falla en una instalación de solo lectura, así que mantén todo `io.output` dentro de `singeGetDataPath()`. [#_packaging_your_game] === Empaquetar tu juego Un juego terminado se distribuye como un único archivo `.game`, que se describe a continuación. No hace falta nada más: el jugador copia el archivo junto al binario de Singe y el menú lo muestra. Singe ya no instala archivos comprimidos. [#singlefile] === Juegos de un solo archivo Un juego puede ser un único archivo. `singe --pack` escribe una carpeta de juego en una base de datos SQLite, y Singe lo ejecuta desde ahí: los scripts, los sprites, los sonidos, las fuentes, los framefiles y el vídeo se cargan directamente de la base de datos, e instalar el juego es copiar el archivo junto al ejecutable. El menú muestra todos los archivos `.game` que encuentra junto a las carpetas de los juegos. ---- Singe --pack MyGame MyGame.game Empaqueta la carpeta MyGame en MyGame.game Singe MyGame.game Ejecuta la primera entrada de su games.dat Singe --entry=2 MyGame.game Ejecuta su segunda entrada Singe --unpack MyGame.game MyGame Vuelve a escribir los archivos fuera Singe --patch MyGame.game fixes Sustituye archivos desde una carpeta (o una base de datos de parche) ---- Para empaquetar un juego no hay que cambiar nada en él. Todo nombre que usa un script, ya sea mediante `dofile`, `require`, `io.open` y sus parientes, `spriteLoad`, `soundLoad`, `fontLoad`, `videoLoad`, un framefile o un `controls.cfg`, pasa por una única búsqueda. Para un juego suelto esa búsqueda es el sistema de archivos sin más, relativa a la carpeta desde la que se inició Singe, exactamente como antes. Para un juego empaquetado, un nombre se busca en tres sitios, en este orden, y gana el primero que lo tiene: . Una carpeta suelta con el nombre de la base de datos sin su extensión (`MyGame/` junto a `MyGame.game`). Así es como sigues editando un juego empaquetado, y como un jugador le hace un mod: los archivos que se dejan ahí ganan a las copias empaquetadas. . La carpeta de datos del juego, dentro de `files/`. Todo lo que un juego escribe con `io.output` o `io.open(name, "w")` acaba aquí, así que un `game.cfg` guardado gana al original empaquetado y la base de datos nunca se escribe. . La propia base de datos. Dentro de una base de datos, los nombres se comparan sin distinguir mayúsculas de minúsculas, con cualquiera de los dos separadores de ruta y de forma relativa a la raíz del juego. Un primer componente igual al nombre de la propia carpeta del juego se ignora, así que `MyGame/Cfg/game.cfg`, `Cfg/game.cfg` y `DIR .. "Cfg/game.cfg"` encuentran el mismo archivo. El nombre de la propia base de datos (`MyGame.game`, con o sin separador final) es la raíz del juego, como lo es `.` desde dentro. Los nombres que empiezan por `Singe/` o por la carpeta de datos se quedan en el sistema de archivos; `Singe/Framework.singe` y `singeGetDataPath()` funcionan como siempre, y un nombre así puede usar `..` siempre que se quede por debajo de ese prefijo (el `controls.cfg` de la raíz de la carpeta de datos se encuentra así). Cualquier otro nombre relativo con un componente `..` no se resuelve en nada dentro de un juego empaquetado: `io.open` devuelve `nil` con un mensaje que dice que el nombre sale del juego, `lfs.attributes` devuelve `nil`, y `spriteLoad` y las de su clase informan de que el archivo falta. La raíz del juego es una carpeta, no un archivo: `io.open` sobre ella devuelve `nil`, y `lfs.mkdir` y `lfs.rmdir` la rechazan. `lfs.dir` sobre una carpeta empaquetada lista la unión de los tres sitios, ordenada sin distinguir mayúsculas de minúsculas; cuando un archivo suelto y uno empaquetado solo difieren en mayúsculas y minúsculas, se lista la grafía del suelto. `lfs.attributes` sobre un archivo empaquetado responde como lo hace el `lfs` real: la tabla completa con un argumento, un campo por su nombre (un nombre desconocido lanza un error), o una tabla tuya rellenada; un archivo que falta devuelve `nil`, un mensaje y el número de error. `require("name")` encuentra `name.lua`, `name/init.lua` o `name.singe` en la carpeta del script y después en la raíz del juego. `io.open`, `io.lines` e `io.input` sobre un nombre que solo existe dentro de la base de datos devuelven un archivo propio de Singe en lugar de uno de Lua. El handle de archivo de Lua es un `FILE *` de C, y una fila de una base de datos no es un archivo, así que leer una suponía antes escribirla junto al juego y abrir esa copia; el handle de Singe lee la fila donde está. Responde a todo lo que responde el de Lua -- `read` con todos los formatos (`"a"`, `"l"`, `"L"`, `"n"` y un número de bytes), `lines`, `seek`, `close`, `flush` y `setvbuf` -- `io.type` lo considera un archivo, `io.read` e `io.close` lo aceptan, y se cierra solo cuando el recolector de basura lo recoge o cuando una variable `` sale de su ámbito. La única diferencia es que escribir en él es un error, porque lo que lee está dentro de la base de datos y la base de datos nunca se escribe. Un juego que escribe un archivo obtiene uno real en su carpeta de datos exactamente como antes, y lo mismo ocurre con todo nombre que no está empaquetado. `testScripts/packedIo` es la prueba que ata todo esto al comportamiento del propio Lua; su cabecera explica cómo ejecutarla. El empaquetador rechaza una carpeta que no tiene `games.dat`, que contiene `controls.dat`, `Framework.singe`, un archivo cuya extensión es `exe`, `sh`, `bat` o `cmd`, o un archivo sin extensión cuyo nombre empieza por `singe`, que tiene una entrada de primer nivel con el mismo nombre que la propia carpeta (eso haría ambigua la regla de la propia carpeta), o cuyos scripts y archivos de datos salen del juego con `..`; e informa de cada uno de esos archivos, con la línea. Dos archivos cuyos nombres solo difieren en mayúsculas y minúsculas no se pueden empaquetar juntos. Los archivos `.index` obsoletos se omiten. `--unpack` rechaza una base de datos cuyos nombres guardados acabarían fuera de la carpeta de destino (una ruta absoluta, una letra de unidad o un componente `..`). Un juego que hace referencia a la carpeta de otro juego (un framework compartido junto a él) debe copiar antes esa carpeta dentro. Antes de confirmar la base de datos se comprueba su `games.dat`: toda entrada necesita un `SCRIPT`, y cada `SCRIPT`, `VIDEO`, `CABINET`, `MARQUEE` y `ATTRACT` que nombra debe ser un archivo de la base de datos (o de `Singe/`); una ruta errónea se notifica con el título de la entrada y el empaquetado se rechaza. `--patch` hace la misma comprobación sobre el juego parcheado, así que un parche tampoco puede dejar atrás una ruta colgante. `--patch` acepta o bien una carpeta organizada como el juego, cuyos archivos sustituyen a los empaquetados, o bien un archivo `.patch` hecho por `--pack` a partir de una carpeta así (`Singe --pack fixes MyGame-1.1.patch`; un parche no necesita `games.dat`). Los archivos que el parche no nombra se dejan como están. En ambos casos el cambio es una sola transacción: el juego queda intacto si se interrumpe. Un parche también puede llevar una tabla `removed(path)` que nombra archivos que borrar. Un `.patch` se puede desempaquetar como un juego, pero no se puede ejecutar y el menú lo ignora. Un parche que sustituye un archivo grande por uno pequeño deja dentro de la base de datos el espacio que usaba el contenido antiguo, donde SQLite lo guarda para lo siguiente que se escriba en lugar de devolverlo. Eso está bien para un parche que cambia un script, y es un desperdicio para un desarrollador que vuelve a empaquetar un vídeo una docena de veces: el `.game` puede acabar ocupando varias veces el tamaño de su contenido. Así que `--patch` mira el espacio libre cuando la transacción se ha confirmado y, cuando hay al menos un megabyte, compacta la base de datos e indica cuánto ha recuperado: ---- >>> Recovered 8007680 bytes of space the old contents were using ---- El resultado tiene, byte a byte, el tamaño de un empaquetado nuevo de los mismos archivos. Compactar reescribe el archivo entero, así que se omite cuando hay poco que ganar. Nada más en Singe lo necesita; un juego solo lo escribe el empaquetador. La carpeta de datos de un juego empaquetado lleva el nombre de la base de datos y después el de la carpeta del script dentro de ella: `MyGame/` para un `MyGame.game` cuyo script está en su raíz, dentro de la carpeta de datos (`data/MyGame/` por defecto), que es donde la tiene el `MyGame/MyGame.singe` suelto, así que las partidas guardadas y los ajustes se comparten entre una instalación suelta y una empaquetada; un script empaquetado en una subcarpeta (`Game/Main.singe`) recibe `MyGame/Game/`. El índice del vídeo también va ahí. NOTE: Las compilaciones 3.00 anteriores daban a un juego empaquetado cuyo script estaba en una subcarpeta la carpeta de datos `Game/`, bajo la base, y esa carpeta la compartían todos los archivos con esa estructura. Las partidas guardadas de un juego así pertenecen ahora a `MyGame/Game/`; muévelas a mano. Las compilaciones 3.00 anteriores también conservaban la carpeta propia del juego, aquella bajo la que su `games.dat` nombra el script, así que un `MyGame.game` empaquetado desde `MyGame/` escribía en `MyGame/MyGame/`; sube un nivel lo que haya ahí. Cuando Singe ejecuta un juego desde una base de datos, el menú pasa `CONTAINER` en la entrada de `games.dat` que lanza; el campo lo pone el menú, nunca se pone a mano. Para tus propios datos, `require("sqlite3")` da a los scripts el binding https://lua.sqlite.org[lsqlite3] para el mismo SQLite que usa el motor. Guarda las bases de datos que escribe el juego en `singeGetDataPath()`: [source,lua] ---- local sqlite3 = require("sqlite3") local db = sqlite3.open(singeGetDataPath() .. "scores.db") db:exec("CREATE TABLE IF NOT EXISTS scores (name TEXT, score INTEGER)") for row in db:nrows("SELECT name, score FROM scores ORDER BY score DESC LIMIT 10") do print(row.name, row.score) end db:close() ---- `sqlite3.open` encuentra un nombre igual que `io.open`, así que una base de datos puede ir dentro del juego -- tablas de niveles, diálogos, listas de objetos -- y seguir abriéndose cuando el juego está empaquetado. Abierta de solo lectura, una base de datos empaquetada se lee donde está, sin copiar nada: [source,lua] ---- local levels = sqlite3.open(DIR .. "levels.db", sqlite3.OPEN_READONLY) ---- Abierta para escritura, que es lo que hace `sqlite3.open` por defecto, una base de datos empaquetada se copia una vez a la carpeta de datos del juego, dentro de `files/` como todo lo demás que escribe un juego empaquetado, y se abre la copia. A partir de entonces el nombre se refiere a la copia, sea de solo lectura o no, y el original empaquetado nunca cambia. La base de datos de un juego suelto se abre en su sitio exactamente como antes. `testScripts/packedSql` es la prueba; su cabecera explica cómo ejecutarla. El formato de archivo, para quien escriba herramientas: tres tablas. `meta(key, value)` contiene `version` (1), `gamedir` (el nombre de la carpeta empaquetada), `chunk` (el tamaño de bloque en bytes, 4194304) y `packer`. `assets(path, name, size, data)` tiene una fila por archivo, donde `path` es la clave en minúsculas con barras normales y `name` la grafía del autor; los archivos no mayores que el tamaño de bloque están en `data`, y los más grandes tienen `data` a NULL y viven en `chunks(path, chunk, data)` como trozos numerados consecutivos. Singe abre la base de datos de solo lectura e inmutable, así que funciona desde soportes de solo lectura y nunca crea un diario junto a ella. [#_event_driven_or_not] === ¿Dirigido por eventos... o no? Tradicionalmente, Singe usaba un modelo de programación dirigido por eventos -- Singe se encarga de todo y solo llama a tu código cuando necesita decirte algo o actualizar la pantalla. Es eficiente y es como funcionan la mayoría de los motores de juegos basados en Lua, pero puede resultar prolijo. Desde Singe 2.10 hay un nuevo modelo _con hilo_ («threaded») que te permite escribir código procedimental. [#_event_driven] ==== Dirigido por eventos Con el modelo de programación dirigido por eventos, Singe controla el bucle principal del programa y decide el orden de ejecución. Singe se encarga automáticamente de todos los detalles de decodificar y presentar el vídeo y el audio. Gestiona los mandos, los ratones y la entrada de teclado. Cuando Singe necesita algo propio del juego, llama a una parte de tu script. El script de Singe más básico que muestra todos los callbacks existentes tiene este aspecto: [source,lua] ---- -- Esqueleto de un juego de Singe. -- Carga el framework de Singe. dofile("Singe/Framework.singe") -- Declara aquí las variables globales que necesites. function onControllerMoved(axis, value, which) --[[ Informa de qué eje del mando se ha movido y de su valor actual. (Rango: de -32768 a 32767.) Se usa para dispositivos analógicos. La entrada digital se gestiona con onInput y onKey. --]] end function onInputPressed(what, device) --[[ En el modo de teclado MODE_NORMAL, los eventos de entrada se notifican aquí cuando la tecla o el botón se pulsa por primera vez. Para la lista completa de teclas, botones y mandos, consulta Singe/Framework.singe. En MODE_FULL, este evento se llama con el keysym de la tecla pulsada. Cuando el interruptor viene de un ratón o de una pistola de luz, device es el índice de ese dispositivo (0 para el primero), como lo pasa Hypseus, para que un juego de dos pistolas distinga a sus jugadores; para una tecla o un mando es nil. --]] end function onInputReleased(what, device) --[[ En el modo de teclado MODE_NORMAL, los eventos de entrada se notifican aquí cuando la tecla o el botón se suelta. Para la lista completa de teclas, botones y mandos, consulta Singe/Framework.singe. En MODE_FULL, este evento se llama con el keysym de la tecla soltada. device funciona igual que en onInputPressed. --]] end function onKeyPressed(key, scancode) --[[ En el modo de teclado MODE_FULL, los eventos de entrada se notifican aquí cuando se pulsa la tecla. Se devuelven tanto el keysym como el scancode. Para la lista de scancodes disponibles, consulta Singe/Framework.singe. --]] end function onKeyReleased(key, scancode) --[[ En el modo de teclado MODE_FULL, los eventos de entrada se notifican aquí cuando se suelta la tecla. Se devuelven tanto el keysym como el scancode. Para la lista de scancodes disponibles, consulta Singe/Framework.singe. --]] end function onMouseMoved(x, y, xr, yr, which) --[[ Se llama cuando se mueve el ratón. En el modo SINGLE_MOUSE se devuelven los valores absolutos de X e Y y también el cambio relativo de posición. En el modo MANY_MOUSE solo está disponible el cambio relativo, además de qué ratón se ha movido. --]] end function onOverlayUpdate() --[[ Este es el único sitio donde puedes dibujar con seguridad. Si quieres mostrar un cursor de puntería, tendrás que guardar la posición del ratón de onMouseMoved en variables globales y después usarlas aquí para dibujar el cursor. --]] -- Dile a Singe si hemos cambiado la pantalla o no. return(OVERLAY_UPDATED) -- O OVERLAY_NOT_UPDATED si no se ha dibujado nada. end function onShutdown() -- Se llama cuando el usuario sale de tu juego. Libera aquí los recursos cargados. end function onSoundCompleted(channel) -- El sonido del canal "channel" acaba de terminar de reproducirse. end -- Nota: No hay ningún evento "onStartup". -- Cualquier código de arranque que necesites puede ir aquí. ---- [#threaded] ==== Con hilo Desde Singe 2.10 puedes usar el clásico modelo de programación procedimental. Para usar este modelo, declara una función `singeMain()` e incluye el framework de Singe al *final* de tu programa en lugar de al principio. Lua no es realmente multihilo, así que este modelo depende de que el desarrollador del juego haga multitarea cooperativa llamando a `singeYield()` en cualquier sitio donde el código consuma una cantidad de tiempo considerable. Igual que con el modelo dirigido por eventos, Singe sigue gestionando toda la entrada, la decodificación del vídeo y la del audio. Un programa de ejemplo con hilo tiene este aspecto: [source,lua] ---- function singeMain() local x = 10 local y = 10 while true do colorBackground(0, 0, 0, 255) overlayClear() colorForeground(255, 255, 255, 255) overlayPrint(x, y, "+") if keyboardGetLastUp() == SCANCODE.LEFT.value then x = x - 1 end if keyboardGetLastUp() == SCANCODE.RIGHT.value then x = x + 1 end if keyboardGetLastUp() == SCANCODE.UP.value then y = y - 1 end if keyboardGetLastUp() == SCANCODE.DOWN.value then y = y + 1 end singeYield() end end dofile("Singe/Framework.singe") ---- [#_hybrid] ==== Híbrido Los dos modelos se combinan libremente. Cuando existe `singeMain`, `Framework.singe` instala su propio `onOverlayUpdate`, pero todos los demás callbacks se siguen disparando: define `onInputPressed`, `onSoundCompleted` u `onShutdown` junto a `singeMain` y se les llama entre las cesiones de la corrutina. Un reparto típico mantiene procedimental el flujo del juego mientras reacciona a los eventos de entrada y de audio: [source,lua] ---- local fired = false function onInputPressed(what) if what == SWITCH_BUTTON1 then fired = true end end function onSoundCompleted(channel) if channel == gunshotChannel then soundPlay(reloadSound) end end function singeMain() while true do if fired then fired = false gunshotChannel = soundPlay(gunshotSound) end singeYield() end end dofile("Singe/Framework.singe") ---- No definas tú mismo `onOverlayUpdate` en este modelo; dibuja desde dentro de `singeMain`. Un error lanzado dentro de `singeMain` termina el juego con una traza de la pila, y volver de `singeMain` sale limpiamente. [#pausing] === Pausa Todo juego tiene gratis una pausa que funciona. Mientras la tecla de pausa está activada (lo está por defecto), pulsar la tecla asignada a `INPUT_PAUSE` hace que el motor pause el disco, todos los vídeos cargados y todos los sonidos, dibuje un indicador PAUSED sobre el último fotograma y deje de ejecutar el script: ni `onOverlayUpdate`, ni reanudación de `singeMain`, ni callbacks de entrada, ratón, mando o sonido. Los temporizadores construidos sobre bibliotecas de Lua se detienen con él porque nada los ejecuta. Pulsar la tecla otra vez reanuda todo lo que se estaba reproduciendo y el script continúa donde lo dejó. Solo los interruptores propios del motor (pausa, salir, captura de pantalla, captura del ratón) siguen funcionando mientras está congelado. Las entradas se mantienen fieles a la realidad durante la congelación. Todo lo que el script cree pulsado se suelta con `onInputReleased` (u `onKeyReleased` en `MODE_FULL`) cuando empieza la pausa, y lo que sigue pulsado físicamente cuando termina la pausa se vuelve a pulsar. Un joystick mantenido durante una pausa nunca se queda atascado. Un juego que quiere su propio comportamiento de pausa (un menú de pausa, una pausa de la historia que el jugador no puede saltarse) llama a `singeSetPauseKeyEnabled(false)`. Entonces la tecla se entrega al script como `SWITCH_PAUSE` y el propio juego maneja `singeSetPauseFlag`, que pausa los medios sin congelar el script. En `MODE_FULL` el teclado pertenece por completo al juego, así que las teclas asignadas a los interruptores del motor no hacen nada ahí (una `p` tecleada al introducir un récord debe seguir siendo una `p`). Los botones del mando y del ratón asignados a esos interruptores siguen funcionando en cualquiera de los dos modos, ya que no se pueden teclear. [#gamesdat] === games.dat El archivo `games.dat` permite a Singe localizar automáticamente los juegos nuevos cuando el usuario final los instala. Este archivo es extremadamente importante y debe incluirse con todos los juegos de Singe. Pon `games.dat` en la carpeta superior de tu juego. Un ejemplo que contiene varios juegos: [source,lua] ---- GAMES = { { TITLE = "Asteroid Patrol", SCRIPT = "Collection/AsteroidPatrol.singe", VIDEO = "Collection/frame_AsteroidPatrol.txt", STRETCH = false, NO_MOUSE = false, RESOLUTION_X = 720, RESOLUTION_Y = 480, SINDEN_GUN = "", GAME_ID = "6f1e7b62-0a4e-4d9c-9b2f-1c7a5e3d8a10", CABINET = "Collection/cabinet_AsteroidPatrol.png", MARQUEE = "Collection/marquee_Collection.png", ATTRACT = "Collection/video_AsteroidPatrol.mkv", ATTRACT_START = 3000, ATTRACT_END = 3500, YEAR = 2026, PLATFORM = "Laserdisc", DEVELOPER = "Your Studio", PUBLISHER = "Your Studio", GENRE = "Shooter", DESCRIPTION = "Keep the asteroids away from the station for as long as you can.", CREATOR = "Scott Duensing", SOURCE = "http://kangaroopunch.com" }, -- ... más entradas ... } ---- Las claves `SCRIPT`, `VIDEO`, `CANVAS_X`, `CANVAS_Y`, `STRETCH`, `NO_MOUSE`, `RESOLUTION_X`, `RESOLUTION_Y`, `SINDEN_GUN`, `AUDIO_TRACK`, `AUDIO_DELAY`, `AUDIO_SUFFIX`, `GAME_ID`, `LEGACY_SPRITE_ARGS`, `REQUIRES` y `TIER` las lee el motor cuando el menú (o tu propio script, mediante `scriptExecute` / `scriptPush`) lanza la entrada; tienen prioridad sobre la línea de comandos. La línea `VIDEO` es el disco: una entrada que lo nombra es un juego de laserdisc, y una entrada que lo omite o lo deja en blanco se ejecuta sin disco y puede fijar `CANVAS_X` / `CANVAS_Y` (por defecto 720x480); véase <>. `AUDIO_SUFFIX = "-es"` reproduce `-es.ogg`, junto al vídeo del disco, en lugar del audio que lleva dentro, para una edición cuyos otros idiomas se distribuyen como archivos aparte; es lo mismo que hace `--altaudio`. `LEGACY_SPRITE_ARGS = true` ejecuta un juego escrito para Singe 2.10 con el antiguo orden de argumentos de los sprites (véase <>). El menú lee las claves restantes para mostrarlas. `REQUIRES` dice lo que el juego necesita de una máquina, como una lista de nombres: `"SCENE"` (la escena 3D), `"GUI"` (documentos de GUI), `"SHADOWS"`, `"POST"` (el bloom y los demás efectos que quieren destinos de renderizado en coma flotante), `"SKIN"` (modelos con skinning) y `"MORPH"` (morph targets). Sin `REQUIRES` se entiende 2D. El menú marca un juego que esta máquina no puede ejecutar y dice por qué en lugar de iniciarlo, y rara vez escribes la lista tú mismo: el motor se da cuenta de lo que usa un juego mientras se ejecuta, avisa en la traza cuando el juego usa algo que `REQUIRES` no incluye, y `--pack` escribe la lista en una entrada que no tiene ninguna. `TIER` (de 0 a 3) es cuánta máquina pide el juego; el menú mide el nivel de esta máquina con su propio fondo y marca un juego que pide más como uno que puede ir lento. Véase <>. [source,lua] ---- REQUIRES = { "SCENE", "SHADOWS" }, TIER = 1, ---- Un script lanzado directamente, por su nombre o por su carpeta, recibe el mismo trato: Singe busca un `games.dat` en la carpeta del script y hasta tres carpetas por encima, y si el `SCRIPT` de una entrada nombra el script lanzado (las rutas de las entradas cuentan desde la carpeta que está por encima del `games.dat`, así que `Collection/games.dat` nombra `Collection/AsteroidPatrol.singe`), se aplican las claves de esa entrada. La única diferencia con un lanzamiento desde el menú es la prioridad: una opción escrita en la línea de comandos (`--framefile`, `--disc`, `--stretch`, `--nomouse`, `--xresolution`, `--yresolution`, `--sindengun`, `--audio`, `--audiodelay`, `--altaudio`, `--canvas`) conserva su valor, ya que quien la escribió lo hizo a propósito. Un valor de un archivo de ajustes no: es un valor por defecto, y la entrada describe mejor el juego (véase xref:Manual.adoc#settings[El archivo de ajustes]). Un `games.dat` que no se puede ejecutar se notifica y se ignora en un lanzamiento directo. [#withoutadisc] === Juegos sin disco Singe ya no da por hecho un laserdisc. Un juego es un juego de laserdisc solo cuando nombra uno: un `VIDEO` en su entrada de `games.dat`, o `--disc` (o un `--framefile`) en la línea de comandos. Todo lo demás se ejecuta sin disco, y el mundo es un *lienzo* en lugar de un fotograma de vídeo: `CANVAS_X` / `CANVAS_Y` en `games.dat` o `--canvas=WxH` en la línea de comandos, 720x480 por defecto, para que un juego sin disco parezca un juego de laserdisc de definición estándar salvo que pida otra cosa. Sin disco, el motor dibuja sobre negro, el overlay ocupa por defecto la mitad del lienzo como siempre, y las funciones `disc*` y `vldp*` se vuelven inofensivas: `discGetState` informa de `DISC_EJECTED`, `discGetFrame` devuelve `0`, `discGetWidth` / `discGetHeight` devuelven el lienzo, `vldpGetPixel` devuelve negro, y las llamadas de transporte escriben en la traza y no hacen nada. Todo lo demás -- el overlay, los sprites, las fuentes, los sonidos, los vídeos adicionales mediante `videoLoad`, la entrada y los dos modelos de programación -- es idéntico. La variable global `SINGE_DISC` le dice a un script como qué tipo de juego se está ejecutando. [#gui] === GUI Los menús, las páginas de opciones, los HUD y la introducción de récords se construían antes con `fontPrint`, `overlayBox` y un cursor hecho a mano. Singe 3 añade una GUI: un documento escrito en RML (un lenguaje de marcado parecido a HTML) y con el estilo de RCSS (una hoja de estilo parecida a CSS), maquetado y dibujado por RmlUi, con ajuste de línea del texto, flexbox, paneles nine-slice hechos con tus propios gráficos, transiciones, animaciones, controles de formulario y un modelo de foco que un mando puede manejar. Una *GUI* es un contexto de RmlUi de tamaño fijo renderizado en su propia textura, que se vuelve a dibujar cada vez que cambia su imagen (véase <>), y esa textura va a cualquier sitio al que pueda ir una textura: compuesta sobre el overlay con `guiDraw`, o mostrada en un material de la escena 3D con `materialSetGui`, de modo que la misma página de opciones funciona plana en la pantalla y en el monitor de una máquina recreativa modelada. Una GUI necesita el dispositivo GPU que usa la escena 3D, así que no está disponible en una máquina que no lo tenga (véase <>); `guiNew` termina el script en lugar de responder false, así que pregunta antes a <> si tu juego puede pasar sin ella. El menú incluido hace exactamente eso, y se dibuja en el overlay cuando la respuesta es no (véase xref:Manual.adoc#menurenderers[Los dos renderizadores]). Todas las llamadas que se nombran aquí tienen una entrada en la sección <> de la referencia. La referencia del lenguaje para RML y RCSS es la propia documentación de RmlUi en https://mikke89.github.io/RmlUiDoc/, y vale la pena leerla antes del primer documento; este capítulo cubre lo que Singe añade encima. [#_documents_and_style_sheets] ==== Documentos y hojas de estilo `guiNew(width, height)` crea una GUI y devuelve un handle entero; puede haber hasta dieciséis a la vez. `guiLoad(gui, name)` carga en ella un documento RML, lo muestra y devuelve un handle de documento (hasta treinta y dos por GUI); `guiHide`, `guiShow` y `guiClose` hacen lo que dicen sus nombres, y `guiDelete` elimina la GUI, sus documentos y su textura. Un documento suele tener el tamaño de su GUI, y una GUI suele tener el tamaño del overlay para una página a pantalla completa o el del panel para un elemento del HUD: [source,lua] ---- local gui = guiNew(overlayGetWidth(), overlayGetHeight()) local doc = guiLoad(gui, DIR .. "options.rml") ---- El nombre del documento pasa por el sistema de archivos virtual como el de un sprite, así que puede estar suelto en disco o dentro de un juego empaquetado, y se resuelve de forma relativa a la carpeta en la que se inició Singe, así que antepón `DIR` a los archivos que se distribuyen con tu juego, exactamente como con `spriteLoad`. Los nombres *dentro* de un documento (el `href` de una hoja de estilo, una imagen en un decorador, el `src` de una hoja de sprites) se resuelven de forma relativa al archivo que los nombra, como hace RmlUi, y después mediante la misma búsqueda, así que un documento, su hoja de estilo y sus imágenes están uno junto a otro en la carpeta del juego y un juego empaquetado los encuentra todos. Dos tipos de nombre se toman tal cual: los archivos propios del motor bajo `Singe/` y las rutas absolutas. La misma regla, leída al revés, permite que un documento que está bajo `Singe/` llegue a los archivos de un juego: un nombre relativo se resuelve desde la propia carpeta del documento, así que desde `Singe/Menu.rml` el nombre `../Collection/cabinet.png` es `Collection/cabinet.png` en la carpeta del juego, y un nombre bajo `../MyGame.game/` es un archivo dentro de ese juego empaquetado. El menú incluido hace exactamente esto con las imágenes de recreativa y de marquesina del juego seleccionado, poniendo desde Lua un decorador de imagen en el panel de imagen para que la imagen quepa dentro sea cual sea su tamaño: [source,lua] ---- -- Menu.singe: encaja la imagen de un juego en uno de los paneles de imagen. function menuArt(id, file) local path = tostring(file or "") if path:sub(1, 6):lower() ~= "singe/" then path = "../" .. path end menuElement(id).style.decorator = 'image("' .. path .. '" contain)' end ---- Un documento RML es un `` con un título, enlaces a hojas de estilo o un `

Options

Choose a difficulty and press Start.

---- RmlUi no dibuja nada cuyo tamaño no se le haya dicho, y todo elemento es `display: inline` hasta que un estilo diga otra cosa, así que una hoja de estilo no es opcional. `Singe/gui.rcss` es el tema que incluye el motor: da al cuerpo, a los encabezados y a los párrafos un tamaño y un color, hace de `div` un bloque y da estilo a todos los controles de formulario. Enlázalo primero y sobrescribe lo que quieras en una hoja tuya o en un `

Options

plays the intro

Press Start when ready.

---- El script la carga en una GUI del tamaño del overlay, conecta los tres elementos que le interesan y la dibuja en cada fotograma hasta que el jugador pulsa Start; el disco se reproduce debajo todo el tiempo: [source,lua] ---- dofile("Singe/Framework.singe") local gui = guiNew(overlayGetWidth(), overlayGetHeight()) local doc = guiLoad(gui, DIR .. "options.rml") local settings = { name = "Dirk", volume = 48, hard = false } local inMenu = true local function report(text) guiSetValue(gui, doc, "status", text) end guiSetHandler(gui, doc, "volume", "change", function(g, d, id, event, value) settings.volume = math.floor(tonumber(value)) soundSetVolume(settings.volume) report("Volume " .. settings.volume) end) guiSetHandler(gui, doc, "difficulty", "change", function(g, d, id, event, value) settings.hard = (value == "hard") report("Difficulty " .. value) end) guiSetHandler(gui, doc, "go", "click", function(g, d) settings.name = guiGetValue(g, d, "name") guiHide(g, d) guiSetInput(g, false) inMenu = false discPlay() end) guiSetHandler(gui, doc, "back", "click", function() singeQuit() end) function onInputPressed(what) -- Aquí solo llega lo que la página no ha usado: nada mientras tiene -- el foco, todo en cuanto se oculta. if not inMenu and what == SWITCH_BUTTON1 then fire() end end function onOverlayUpdate() overlayClear() if inMenu then guiDraw(gui) else fontPrint(10, 10, settings.name .. " " .. (settings.hard and "HARD" or "NORMAL")) end return OVERLAY_UPDATED end ---- La misma página en la pantalla de una recreativa modelada son las cinco líneas de <> en lugar de `guiDraw`, y un clic en el monitor pulsa el botón que hay debajo. [#subtitles] === Subtítulos Un juego puede incluir un archivo de subtítulos SubRip y hacer que Singe lo muestre sobre el disco mientras se reproduce. Tres llamadas hacen todo el trabajo: <> lee el archivo, <> lo activa y <> dice dónde se colocan las líneas. <> vuelve a quitar lo que esté en pantalla. Esta característica viene de Hypseus Singe y las cuatro llamadas son las suyas. [source,lua] ---- if srtLoad("subs/english.srt") then srtPosition(80) srtEnable(true) end ---- [#_the_file_format] ==== El formato del archivo SubRip (`.srt`) es un archivo de texto plano con entradas numeradas: ---- 1 00:00:04,120 --> 00:00:06,880 Stay away from the caves. 2 00:00:07,000 --> 00:00:09,500 I mean it -- the caves are where they nest. ---- Cada entrada es un número en una línea propia, después una línea con dos marcas de tiempo separadas por `-->`, después una o más líneas de texto y después una línea en blanco. Una marca de tiempo es `HH:MM:SS,mmm`: horas, minutos y segundos separados por dos puntos, y milisegundos tras una coma. El número de la entrada se lee y se descarta, así que los números no tienen por qué estar en orden, ni siquiera ser correctos. El analizador es tolerante a propósito en los sitios donde falla un archivo editado a mano. Los retornos de carro y los espacios iniciales y finales se eliminan de cada línea, así que un archivo escrito en Windows se carga sin cambios. Una entrada cuya línea de tiempo no tiene `-->`, o cuyas marcas de tiempo no se pueden analizar, se omite y el archivo sigue cargándose. Una entrada sin texto se omite. Las líneas de una entrada se unen con saltos de línea y se muestran tal como están escritas, y `<`, `>` y `&` llegan a la pantalla como tales en lugar de tomarse por marcado. Lo que *no* está soportado: las coordenadas de posición que algunas herramientas añaden a la línea de tiempo se ignoran (la altura la decide `srtPosition`), y las etiquetas `` y `` que SubRip lleva de vez en cuando se muestran como texto en lugar de obedecerse. Las entradas pueden solaparse en el tiempo; gana la última que ha empezado. `srtLoad` responde `false` y no carga nada cuando el archivo no se puede leer, cuando no se puede analizar nada de él o cuando no hay disco -- así que un archivo roto es un `false` con el que un juego puede actuar y no un cierre inesperado. En cada caso imprime un aviso que nombra el archivo. [#_the_timing] ==== La sincronización Las marcas de tiempo se convierten en *números de fotograma del disco* al cargar el archivo, usando la tasa de fotogramas del disco que esté cargado en ese momento, y el motor compara cada fotograma dibujado con esos números de fotograma. De eso se siguen dos cosas. Primero, **un subtítulo sobrevive a un salto.** Mandar el disco a cualquier sitio -- `discSearch`, `discSkipForward`, un paso, un framefile que pasa a otro segmento -- pone en pantalla la entrada que cubre el sitio donde ha caído, en el siguiente fotograma dibujado, hacia delante o hacia atrás. No hay que recargar nada ni poner a cero ningún reloj. Segundo, **el archivo debe estar sincronizado con el disco, no con el reloj.** Un `.srt` preparado para un vídeo con otra tasa de fotogramas cae en el sitio equivocado, y uno que se carga antes de abrir el disco no se carga en absoluto. Carga los subtítulos después del disco, lo que para un juego normal significa desde el cuerpo del script. Una entrada se muestra mientras el fotograma del disco está entre sus dos fotogramas, ambos incluidos. El subtítulo solo se vuelve a dibujar cuando cambia la entrada, así que los subtítulos no cuestan nada mientras una línea está en pantalla. [#_styling] ==== Estilo Las líneas son un documento de RmlUi, no una línea de texto copiada con un blit, así que se les da estilo igual que a una GUI. En la carpeta `Singe` se incluyen dos archivos, que se extraen junto a los demás en el primer arranque: * `Singe/subtitle.rml` -- el documento. Tiene un único elemento, `#slot`, en el que el motor escribe la entrada o el rótulo actual. * `Singe/subtitle.rcss` -- el aspecto. `.cue` es el bloque que lleva la altura que pidió `srtPosition`, como un `top` en línea; `.cue .text` es la barra de texto que hay dentro, que por defecto es blanca sobre un panel oscuro redondeado que se ciñe al texto en lugar de ocupar todo el ancho de la imagen. Los dos se escriben de nuevo en cada arranque, como todo lo que hay en la carpeta `Singe`, así que editarlos no cambia nada por mucho tiempo. Para cambiar el estilo, un juego trae sus propias copias: `subtitle.rml` junto a su script, con su enlace apuntando a una hoja propia (`subtitle.rcss` junto a él, por ejemplo), y Singe dibuja ese documento en lugar del suyo; la traza del programa lo indica. El motor solo fija el contenido de `#slot` y el `top` de `.cue`, así que todo lo demás -- la fuente, el tamaño, los colores, el panel, un borde, una sombra -- pertenece a la hoja del juego y sobrevive a una actualización del motor. El documento se dibuja sobre la imagen, por encima del overlay y por debajo de las GUI en espacio de pantalla, sigue al rectángulo del vídeo allí donde lo pongan `--scalefactor`, `--shiftx`, un recorte del bezel o `--rotate`, y no acepta entrada del ratón, así que un clic lo atraviesa sin más. <> usa el mismo documento, así que un rótulo y un subtítulo tienen el mismo aspecto, y un juego que cambia el estilo de uno cambia el de los dos. En una máquina sin GPU no hay GUI en absoluto; `srtLoad` sigue analizando el archivo y respondiendo con la verdad, y no se dibuja nada. Un aviso lo dice una vez. [#scenes3d] === Escenas 3D Singe 3 dibuja una escena 3D entre el vídeo del laserdisc y el overlay 2D. Todo lo que ya sabes sigue valiendo: los sprites, el texto y el overlay quedan encima de la escena, y el vídeo se ve allí donde el fondo de la escena es transparente. Un juego que nunca llama a una función 3D no se ve afectado, y un juego sin disco (véase <>) puede ser directamente un juego 3D. Las secciones siguientes construyen una escena en el orden en que la escribirás: los nodos, después las mallas y los materiales, las luces, la cámara, el aspecto del fotograma en su conjunto, los modelos y, por último, lo que va encima de todo eso. Todas las llamadas que se nombran aquí tienen una entrada en la <>. [#_what_a_scene_is_made_of] ==== De qué está hecha una escena Todo en una escena es un *nodo* de un único árbol, y el nodo `0` es la raíz. Un nodo tiene una posición, una rotación y una escala relativas a su padre, y puede llevar una cosa: una malla con un material, una luz, un sprite o una línea de texto. Un modelo cargado se convierte en un subárbol de nodos bajo una raíz, así que las mismas llamadas mueven por igual un personaje modelado y un cubo creado por script. La escena es de mano derecha, con +Y hacia arriba y -Z hacia delante, como glTF y la exportación de Blender. Las unidades son las que usen tus modelos, y las primitivas usan las mismas unidades; la física (véase <>) supone metros. Las rotaciones son en grados, dadas como tres ángulos de Euler aplicados como guiñada, cabeceo y alabeo, o como un cuaternión cuando el valor viene de un modelo. La dirección de avance propia de un nodo es su -Z: la cámara mira a lo largo de ella, las luces direccionales y los focos alumbran a lo largo de ella, y `nodeLookAt` la gira hacia un punto del mundo. Hay dos formas de llenar una escena, que se pueden mezclar libremente: * *Construirla desde el script* con las mallas primitivas (`meshBox`, `meshSphere`, `meshPlane`, `meshCylinder`, `meshCone`, `meshTorus`, `meshHeightmap`) o con tus propios datos de vértices (`meshNew`), además de materiales, luces y una cámara. * *Cargar modelos* en formato binario glTF 2.0 (`.glb`) con `modelLoad` y después colocarlos con `modelInstance` tantas veces como quieras. Blender, Maya, 3ds Max y todos los motores importantes exportan `.glb`. Una escena mínima es una malla, una luz y una cámara: [source,lua] ---- sceneEnable(true) sceneSetBackground(0, 0, 0, 0) -- Transparente: el disco se ve detrás de la escena. local red = materialNew() materialSetColor(red, 220, 40, 40) local cube = nodeNew() nodeSetMesh(cube, meshBox(1, 1, 1), red) local sun = lightNew(LIGHT_DIRECTIONAL) nodeSetPosition(sun, 2, 4, 3) nodeLookAt(sun, 0, 0, 0) local camera = nodeNew() nodeSetPosition(camera, 0, 1.5, 5) nodeLookAt(camera, 0, 0, 0) cameraSet(camera) function onOverlayUpdate() nodeRotate(cube, 0, 1, 0) -- Un grado por fotograma alrededor de su propio eje Y. return OVERLAY_UPDATED end ---- `sceneEnable(true)` es la única llamada que hacen todos los juegos 3D, y en una máquina que no puede dibujar 3D es la llamada que falla (véase <>). Sin cámara, la escena se ve desde `(0, 0, 5)` mirando al origen, y sin una llamada que fije el fondo, el fondo de la escena es negro opaco. [#nodes] ==== Nodos La transformación de un nodo se fija directamente (`nodeSetPosition`, `nodeSetRotation`, `nodeSetQuaternion`, `nodeSetScale`) o se ajusta poco a poco (`nodeMove`, `nodeRotate`), siempre en el espacio de su padre. `nodeSetParent` cuelga un nodo de otro padre conservando su transformación local; `nodeGetWorldPosition` lee dónde ha acabado después de que todos sus padres hayan dicho la suya. `nodeSetVisible` oculta un nodo y todo lo que hay debajo de él, y `nodeDelete` lo libera junto con sus hijos y con cualquier cuerpo, emisor o sonido unido a ellos. Los nombres vienen de los modelos: `nodeFind(name, root)` encuentra una articulación o una pieza por el nombre que tenía en el archivo, bajo la instancia dada como `root`. Un nodo creado por script también puede tener nombre (`nodeSetName`), y eso es lo que busca `nodeFind` sin raíz. [source,lua] ---- -- Una torreta sobre un tanque: el cañón es hijo de la torreta, y la torreta del casco, -- así que girar la torreta arrastra el cañón y mover el casco arrastra a los dos. local hull = nodeNew() local turret = nodeNew() local barrel = nodeNew() nodeSetMesh(hull, meshBox(2, 0.8, 3.2), olive) nodeSetMesh(turret, meshCylinder(0.7, 0.5, 16), olive) nodeSetMesh(barrel, meshCylinder(0.1, 1.8, 8), steel) nodeSetParent(turret, hull) nodeSetParent(barrel, turret) nodeSetPosition(turret, 0, 0.65, 0) nodeSetPosition(barrel, 0, 0, -1.2) nodeSetRotation(barrel, 90, 0, 0) -- Tumba el cilindro a lo largo de -Z. function onOverlayUpdate() local stick = controllerGetAxis(0, GAMEPAD_AXIS_RIGHT_X) / 32767 -- De -1 a 1. nodeRotate(turret, 0, stick * 2, 0) return OVERLAY_NOT_UPDATED end ---- Dos llamadas unen la escena y el overlay. `sceneProject(x, y, z)` convierte un punto del mundo en coordenadas del overlay, para que una etiqueta de nombre o una barra de vida dibujada con `fontPrint` pueda seguir a un objeto 3D. `sceneUnproject(sx, sy, distance)` va en sentido contrario: el punto del mundo a esa distancia a lo largo del rayo que pasa por un píxel del overlay, lo que junto con `physicsRaycast` es la selección con el ratón. [source,lua] ---- function onMouseMoved(x, y, dx, dy, which) -- El ratón apunta a lo largo de un rayo desde la cámara; lo que toca es el objetivo. local cx, cy, cz = nodeGetWorldPosition(camera) local fx, fy, fz = sceneUnproject(x, y, 50) local hit = physicsRaycast(cx, cy, cz, fx - cx, fy - cy, fz - cz, 50) highlighted = hit end ---- [#_meshes_and_materials] ==== Mallas y materiales Una malla es geometría; un material es su aspecto; un nodo une uno de cada (`nodeSetMesh(node, mesh, material)`). Comparte ambos libremente: un `meshBox` y un material pueden estar en mil nodos, y compartirlos es lo que hace rápido al renderizador (véase <>). `meshNew` construye una malla a partir de tablas de posiciones, normales, coordenadas de textura e índices cuando las primitivas no bastan. Un material empieza siendo un color base con alfa (`materialSetColor`) y el aspecto metallic-roughness de glTF: `materialSetMetallic` y `materialSetRoughness` de `0` a `1`. Una rugosidad cercana a `0` da un brillo nítido como de espejo y `1` una superficie mate; un metal tiñe sus brillos y reflejos con su propio color y no tiene color difuso propio. Además: * `materialSetEmissive` hace que una superficie emita luz de su propio color (una pantalla, una lámpara, metal al rojo); no ilumina otras cosas, pero sí produce bloom (véase <>). * `materialSetUnlit` muestra el color tal cual, ignorando todas las luces: elementos de HUD en la escena, el brillo propio del cristal de una bombilla. * `materialSetDoubleSided` dibuja la cara trasera de las formas abiertas, como un plano visto desde abajo o una bandera. * `materialSetBlend` hace que el alfa cuente, para cristal, fantasmas y agua. Las mallas con mezcla se dibujan después de todo lo opaco, ordenadas por distancia, y no proyectan sombras. * `materialSetCutoff` conserva solo los téxeles cuyo alfa alcanza el umbral y descarta el resto, que es como se dibujan las hojas, las vallas, las rejillas y las alambradas: un quad, con la mayor parte eliminada. A diferencia de la mezcla, no necesita ordenación y sigue proyectando sombra, y la sombra también se recorta, así que un árbol proyecta sombras de hojas en lugar de la sombra de su quad. Lo que supera el umbral es opaco: un téxel semitransparente o se conserva entero o se descarta, nunca se mezcla, así que nada de lo que hay detrás de la escena (la imagen del disco, sobre todo) tiñe una hoja. Un umbral de 0 (el valor por defecto) desactiva el enmascarado. Un modelo cargado desde glTF trae el suyo: `MASK` fija el umbral desde el archivo, `BLEND` activa la mezcla y `OPAQUE` no hace ninguna de las dos cosas. La textura de un material puede ser la imagen de cualquier sprite cargado (`materialSetTexture`), el propio laserdisc o un vídeo cargado (`materialSetVideo`), la imagen de una segunda cámara (`materialSetView`, véase <>) o un documento de GUI (`materialSetGui`, véase <>). Una máquina recreativa modelada puede reproducir el disco en su propia pantalla, o mostrar allí su página de opciones. Las coordenadas de textura vienen de la malla; las primitivas proyectan una imagen una vez en cada cara, y `materialSetTiling` la repite, que es lo que quieren el suelo y las paredes. Las texturas tienen mipmaps y filtrado anisótropo, así que un suelo se ve nítido hasta la distancia; `materialSetFilter(material, FILTER_NEAREST)`, en cambio, conserva los bloques del pixel art. [source,lua] ---- local stone = materialNew() materialSetTexture(stone, spriteLoad(DIR .. "flagstones.png")) materialSetTiling(stone, 12, 12) -- Doce repeticiones a lo ancho del plano. materialSetRoughness(stone, 0.9) local floor = nodeNew() nodeSetMesh(floor, meshPlane(24, 24), stone) local glass = materialNew() materialSetColor(glass, 180, 220, 255, 90) -- Alfa 90 de 255. materialSetBlend(glass, true) materialSetRoughness(glass, 0.05) ---- Cuatro mapas más dan a una superficie el detalle que le falta a su geometría, y un modelo cargado los trae consigo cuando su archivo los tiene: * `materialSetNormalMap` desvía la iluminación con un mapa de normales en espacio tangente, para que los ladrillos sobresalgan de una pared plana. Una intensidad superior a `1` lo exagera. * `materialSetOcclusionMap` oscurece las grietas a las que no llegan la luz ambiental ni el cielo. * `materialSetMetallicRoughnessMap` varía la metalicidad y la rugosidad a lo largo de la superficie, empaquetadas a la manera de glTF (la rugosidad en el verde, la metalicidad en el azul) y multiplicadas por los factores propios del material, así que pon esos a `1` cuando deba mandar el mapa. * `materialSetEmissiveMap` dice qué partes brillan, teñidas y escaladas por `materialSetEmissive`. [source,lua] ---- local brick = materialNew() materialSetTexture(brick, spriteLoad(DIR .. "brick.png")) materialSetNormalMap(brick, spriteLoad(DIR .. "brickNormal.png"), 1.5) materialSetMetallicRoughnessMap(brick, spriteLoad(DIR .. "brickMetalRough.png")) materialSetMetallic(brick, 1) materialSetRoughness(brick, 1) materialSetEmissiveMap(brick, spriteLoad(DIR .. "brickEmissive.png")) materialSetEmissive(brick, 180, 100, 40) -- La argamasa brilla en naranja. ---- .Texturas comprimidas Un PNG se decodifica a cuatro bytes por téxel en la GPU, más otro tercio para sus mipmaps; un nivel del tamaño de Sponza ocupa así 390 MB de texturas, más de lo que una Raspberry Pi 4 comparte con su pantalla. Las texturas distribuidas como archivos KTX2 (Basis Universal, hechos por `util/compressTextures.py` a partir de PNG o de un `.gltf` entero) permanecen comprimidas en la GPU a una cuarta parte del tamaño o menos, en el formato de bloque que tenga la máquina: BC7 en los ordenadores de sobremesa, ASTC en las GPU de Apple y en la Pi 4, ETC2 en cualquier chip OpenGL ES 3 (la Mali de todas las consolas portátiles, la Pi con GLES), BC3 en las tarjetas más antiguas y RGBA sin más donde no exista nada de eso, todo a partir del mismo archivo. Pasa un nombre `.ktx2` en lugar de un sprite a `materialSetTexture` y a las llamadas de mapas, y un modelo cuyo archivo usa `KHR_texture_basisu` las carga por sí solo. `sceneGetStats` informa de la memoria de texturas en uso, así que puedes ver la diferencia. [source,lua] ---- materialSetTexture(ground, DIR .. "flagstones.ktx2") local draws, drawn, calls, textureKB = sceneGetStats() debugPrint("textures: " .. textureKB .. " KB") ---- [#_lights_and_shadows] ==== Luces y sombras Hasta ocho luces alumbran a la vez, y cada una es un nodo, así que las luces tienen padres y se animan como cualquier otra cosa: * `LIGHT_DIRECTIONAL` es un sol: solo importa su dirección, y todo se ilumina igual. Una intensidad de `1` ilumina hasta el blanco una superficie blanca orientada hacia ella. * `LIGHT_POINT` es una bombilla, que se atenúa con la distancia, y `lightSetRange` la apaga por completo más allá de una distancia. Su intensidad es su brillo en la propia bombilla, y la mitad a una unidad de distancia, así que una bombilla que tenga que iluminar una habitación necesita un número de varias decenas. * `LIGHT_SPOT` es un cono con un ángulo interior totalmente iluminado y un ángulo exterior en el que la luz se ha atenuado hasta desaparecer (`lightSetCone`). `sceneSetAmbient` añade un poco de luz desde todas partes para que las sombras no sean negras; un cielo lo hace mejor (véase <>). Los colores dados de `0` a `255` son sRGB, como todo lo demás en Singe, y se convierten a luz lineal al entrar, así que una luz de `128, 128, 128` tiene una cuarta parte del brillo del blanco, no la mitad. Cualquier luz puede proyectar sombras de bordes suaves desde todo lo opaco (`lightSetShadow`). Las luces direccionales y los focos proyectan a través de un mapa de sombras; las luces puntuales, a través de un mapa cúbico que llega en todas las direcciones, así que una bombilla puede estar dentro de lo que ilumina (una habitación, una pantalla de lámpara, la recreativa): las mallas cerradas también proyectan desde sus caras traseras, y el rango de profundidad de la sombra se ajusta a los objetos que proyectan sombra alrededor de la luz. `nodeSetShadow(node, false)` exime a una malla de proyectar: el propio cristal de la bombilla, un cartel luminoso, una etiqueta de nombre. `sceneSetShadowSize` fija la resolución del mapa (de `256` a `4096`, por defecto `1024`) y cambia nitidez por velocidad. La sombra de una luz direccional se divide en cascadas a lo largo de la vista (`sceneSetShadowCascades`, tres por defecto), cada una un mapa ajustado a una porción de lo que ve la cámara, así que las sombras siguen nítidas de cerca en un nivel grande y aun así llegan hasta `sceneSetShadowDistance`. Una sola cascada ajusta un único mapa a toda la escena, lo que va bien para escenas pequeñas y cámaras ortográficas. Una vista (`viewNew`) dibuja con los mismos mapas, tomando para cada punto la cascada más fina que lo cubre, así que un monitor muestra las mismas sombras que la ventana. [source,lua] ---- local sun = lightNew(LIGHT_DIRECTIONAL) nodeSetPosition(sun, 30, 40, 20) nodeLookAt(sun, 0, 0, 0) lightSetIntensity(sun, 2.5) lightSetColor(sun, 255, 240, 220) lightSetShadow(sun, true) sceneSetShadowSize(2048) sceneSetShadowDistance(80) -- Tres cascadas hasta 80 unidades. local lamp = lightNew(LIGHT_POINT) nodeSetPosition(lamp, 3, 3.1, -2) lightSetColor(lamp, 255, 230, 190) lightSetIntensity(lamp, 12) lightSetRange(lamp, 14) lightSetShadow(lamp, true) -- Un mapa cúbico: seis pasadas por fotograma. nodeSetShadow(bulbGlass, false) -- El cristal que la rodea no proyecta nada. ---- [#scenecamera] ==== La cámara y las vistas Cualquier nodo puede ser la cámara (`cameraSet`); mira a lo largo de su propio -Z, así que `nodeLookAt` la apunta, y colgarla de un nodo en movimiento crea una cámara de persecución. `cameraSetPerspective(fovDegrees, near, far)` es el objetivo por defecto; `cameraSetOrthographic(height, near, far)` dibuja sin perspectiva, para diagramas, escenas tipo HUD y juegos de vista lateral. Mantén `near` tan grande como permita la escena, porque la precisión del búfer de profundidad se reparte entre `near` y `far`. [source,lua] ---- local camera = nodeNew() nodeSetParent(camera, car) nodeSetPosition(camera, 0, 2.5, 7) -- Detrás y encima del coche, en su espacio. nodeLookAt(camera, 0, 1, 0) cameraSet(camera) cameraSetPerspective(55, 0.1, 300) ---- Una *vista* es una segunda cámara renderizada a una textura en cada fotograma y mostrada en un material: un monitor de seguridad, un retrovisor, un portal, una imagen dentro de otra. `viewNew(width, height)` crea una a esa resolución, `viewSetCamera` le da un nodo de cámara, y `materialSetView` pone su imagen en cualquier malla. Las vistas comparten las sombras del fotograma y se saltan el bloom, y cada una renderiza la escena otra vez, así que conviene que sean pocas y pequeñas. Cada vista orienta los billboards (véase <>) hacia su propia cámara y dibuja los materiales transparentes de atrás hacia delante desde ella, así que un monitor ve las etiquetas de nombre de frente y el cristal en el orden correcto. [source,lua] ---- local rearCamera = nodeNew() nodeSetParent(rearCamera, car) nodeSetPosition(rearCamera, 0, 1.2, -2) nodeSetRotation(rearCamera, 0, 180, 0) -- Mirando hacia atrás. local rear = viewNew(384, 256) viewSetCamera(rear, rearCamera) local mirror = materialNew() materialSetView(mirror, rear) materialSetUnlit(mirror, true) local panel = nodeNew() nodeSetParent(panel, car) nodeSetMesh(panel, meshPlane(0.6, 0.2), mirror) nodeSetPosition(panel, 0, 1.6, 0.5) nodeSetRotation(panel, 90, 0, 0) -- El plano mira hacia arriba; ponlo de pie mirando al conductor. ---- [#scenelook] ==== El aspecto del fotograma La escena se ilumina en luz lineal. Los colores que das de `0` a `255` son sRGB y se decodifican al entrar, la GPU decodifica las texturas al muestrearlas, las luces se suman sin recortarse y, al final del fotograma, una curva tonal devuelve el resultado a la pantalla. Lo que eso significa en la práctica: * `sceneSetExposure(stops)` aclara u oscurece todo el fotograma como la exposición de una cámara: `+1` duplica la luz y `-1` la reduce a la mitad, así que una cueva y una playa pueden compartir las mismas luces y materiales. * `sceneSetTonemap` elige la curva: `TONEMAP_NEUTRAL` (la de por defecto, que deja los colores donde los dejan los visores de glTF y solo comprime la luz más intensa), `TONEMAP_ACES` (un aspecto cinematográfico con más contraste) o `TONEMAP_NONE` (recorta en el blanco). * `sceneSetBloom(threshold, strength)` hace que brille todo lo que supere el umbral: el sol a través de una ventana, una bombilla, un cartel emisivo, una llama. El brillo se toma de la luz de la escena antes de la curva tonal, así que solo produce bloom lo que es realmente brillante; un umbral de `1` es el blanco. .El cielo y su luz `sceneSetSky(file)` envuelve la escena en un panorama equirrectangular. Un `.hdr` de Radiance conserva todo su rango; un PNG o un JPEG también sirven. El cielo se dibuja detrás de todo y, salvo con `sceneSetEnvironment(false)`, también ilumina la escena: su color general desde todas las direcciones sustituye a la luz ambiental plana, y su reflejo se ve en los metales y en las superficies brillantes, nítido en un espejo y más borroso a medida que aumenta la rugosidad. Sin cielo, los metales no tienen nada que reflejar y solo les dan forma la luz ambiental y las luces. `sceneSetSkyIntensity` escala el cielo y su luz a la vez, así que un mismo panorama sirve para el mediodía y para el anochecer. .Niebla `sceneSetFog(r, g, b, near, far)` funde todo, partículas incluidas, hacia un color entre dos distancias. El cielo nunca tiene niebla, así que haz coincidir el color de la niebla con el horizonte del cielo y el borde lejano de la escena se perderá en él. [source,lua] ---- sceneSetSky(DIR .. "harbor.hdr") sceneSetSkyIntensity(0.8) sceneSetFog(190, 200, 215, 40, 160) -- El color del cielo en el horizonte. sceneSetExposure(-0.5) -- Un poco más oscuro de lo que lo dejaría el sol. sceneSetBloom(1.0, 0.35) -- Solo brilla lo que supera al blanco. ---- [#_models_and_animation] ==== Modelos y animación `modelLoad` acepta un `.glb` por el mismo nombre relativo al juego que acepta `spriteLoad`, y un `.game` empaquetado lo lleva consigo. El archivo debe ser autocontenido: mallas, skins, animaciones y texturas, todo dentro del mismo archivo, que es la exportación por defecto de Blender. Un modelo que hace referencia a un `.bin` o a una imagen que tiene al lado se rechaza con un mensaje que nombra el archivo, por la misma razón por la que un juego empaquetado tiene que ser autocontenido. Los materiales usan el modelo metallic-roughness con `KHR_materials_emissive_strength` y `KHR_texture_basisu`; una textura direccionada mediante un segundo conjunto de coordenadas de textura lee el primero, y `KHR_texture_transform` se ignora, cada caso con una línea en la traza del programa. Un skin puede tener hasta `128` articulaciones. La carga es la parte cara; después `modelInstance` coloca una copia del árbol de nodos del modelo, tantas veces como quieras, y todas las instancias comparten las mallas y los materiales del archivo. `modelDelete` libera el modelo; las instancias conservan sus nodos, desnudos. [source,lua] ---- local foxModel = modelLoad(DIR .. "Fox.glb") local pack = {} for i = 1, 6 do local fox = modelInstance(foxModel) nodeSetScale(fox, 0.02) -- El archivo está en centímetros. nodeSetPosition(fox, i * 1.5 - 5, 0, 0) pack[i] = fox end ---- `modelGetAnimations` lista las animaciones de un modelo, que se manejan por instancia con `animationPlay(node, nameOrIndex, loop, speed, fade)` sobre el nodo raíz de la instancia, además de llamadas para pausar, reanudar, detener y saltar. La animación de nodos (cosas que se mueven), el skinning (personajes que se deforman) y los morph targets (blend shapes: caras, sincronización labial, músculos que se hinchan) funcionan todos. Las animaciones avanzan con el tiempo real y se detienen mientras el juego está en pausa. El peso de un morph también se puede fijar desde el script con `nodeSetMorph`, por nombre o por número, en el nodo que lleva la malla. Un `fade` en segundos hace un fundido cruzado desde lo que se estuviera reproduciendo, así que andar pasa a correr sin un tirón. Cuando no se estaba reproduciendo nada (después de `animationStop`, o cuando se ha desactivado un ragdoll), el fundido empieza desde la pose en que están los nodos, así que un personaje se levanta suavemente desde donde cayó. Hasta cuatro clips más pueden reproducirse a la vez en capas por encima de la base (`animationPlayLayer`), cada uno mezclado según `animationSetLayerWeight` y limitado a una parte del esqueleto con `animationSetLayerMask`: mirar alrededor con el cuello mientras las piernas caminan, saludar con un brazo durante una carrera. [source,lua] ---- animationPlay(fox, "Walk", true) animationPlayLayer(fox, 1, "Survey", true, 1, 0.5) -- La cabeza gira, mezclada durante medio segundo animationSetLayerMask(fox, 1, nodeFind("b_Neck_04", fox)) -- ... solo en el cuello y por encima. function onCollision(a, b, x, y, z, speed) if (a == fox or b == fox) and speed > 3 then animationPlay(fox, "Run", true, 1.2, 0.4) -- Más rápido, con un fundido cruzado de 0.4 s. end end ---- [#_sound_in_the_scene] ==== Sonido en la escena Un efecto de sonido se puede colocar en la escena para que se oiga desde donde está. `soundSetPosition(channel, x, y, z)` pone un canal que está sonando en un punto del mundo, `soundSetNode(channel, node)` hace en cambio que siga a un nodo, y `soundSetRange(channel, near, far)` dice dónde está a todo volumen y dónde se ha atenuado hasta el silencio. Los sonidos se oyen desde la cámara salvo que `soundSetListener` nombre otro nodo (la cabeza del jugador, en un juego en tercera persona), y se desplazan a izquierda y derecha cuando el oyente gira. Un canal que no está colocado en la escena suena como siempre, con `soundSetPan` para el estéreo. [source,lua] ---- local crackle = soundPlay(fireSound, -1) -- Se repite sin fin. soundSetNode(crackle, brazier) soundSetRange(crackle, 2, 18) -- A todo volumen hasta 2 unidades, inaudible a 18. soundSetListener(hero) ---- [#scenesprites] ==== Sprites y texto en la escena Cualquier nodo puede mostrar una imagen en lugar de una malla. `nodeSetSprite(node, sprite, height)` pone un sprite cargado (todos los fotogramas de uno animado, con `nodeSetSpriteFrame` para elegir) en un quad de esa altura en unidades del mundo, iluminado como todo lo demás y proyectando sombras salvo que `nodeSetShadow` diga lo contrario. `nodeSetText(node, text, height)` hace lo mismo con una línea de texto en la fuente y el color seleccionados, sin iluminar. Ambos son mallas para el resto del motor: tienen padres, se escalan y aceptan la mezcla de un material. `nodeSetBillboard` gira un nodo para que mire a la cámara en cada fotograma: `BILLBOARD_ALL` de frente del todo (etiquetas de nombre, brillos, impostores), `BILLBOARD_Y` solo alrededor de su propio eje vertical (árboles, barras de vida, cualquier cosa que deba mantenerse derecha), y `BILLBOARD_NONE` para dejar de hacerlo. También funciona con mallas. [source,lua] ---- local tag = nodeNew() nodeSetParent(tag, fox) nodeSetPosition(tag, 0, 110, 0) -- En los centímetros del Fox, por encima de su cabeza. nodeSetScale(tag, 50) -- Deshace la escala de 0.02 de la instancia. nodeSetText(tag, "FOX", 0.6) nodeSetBillboard(tag, BILLBOARD_ALL) nodeSetShadow(tag, false) local icon = nodeNew() nodeSetParent(icon, fox) nodeSetPosition(icon, 0, 150, 0) nodeSetScale(icon, 50) nodeSetSprite(icon, spriteLoad(DIR .. "crate.png"), 0.5) nodeSetBillboard(icon, BILLBOARD_Y) ---- [#scenefront] ==== Delante del overlay La escena se dibuja normalmente por debajo del overlay, así que todo lo que se dibuja en el overlay la tapa. `nodeSetLayer(node, LAYER_FRONT)` mueve en cambio un nodo, y todo lo que cuelga de él, a la capa frontal: la misma cámara lo dibuja después del overlay, iluminado por las mismas luces y acabado con la misma exposición, la misma curva tonal y el mismo bloom, así que un logotipo que gira, un título 3D o un modelo en una tabla de récords pueden quedar sobre los gráficos 2D. La capa frontal es una imagen aparte, borrada a transparente, así que el overlay se ve allí donde ella no dibuja nada, y nada de la escena que hay detrás del overlay puede taparla. Las GUI y las partículas dibujadas sobre el overlay siguen quedando encima de ella. Los nodos frontales siguen proyectando sombras sobre la escena. El cielo, las partículas 3D y las líneas de depuración se quedan en la escena, y las vistas (`viewNew`) solo dibujan los nodos de la escena. La capa cuesta una segunda pasada de renderizado, y solo en los fotogramas en que hay algo en ella. [source,lua] ---- -- Una moneda de oro girando sobre la pantalla de título 2D. local coin = nodeNew() nodeSetMesh(coin, meshCylinder(1, 0.2, 48), gold) nodeSetRotation(coin, 90, 0, 0) nodeSetLayer(coin, LAYER_FRONT) function onOverlayUpdate() overlayClear() spriteDraw(titleArt, 0, 0) nodeRotate(coin, 0, 2, 0) return OVERLAY_UPDATED end ---- [#sceneterrain] ==== Terreno `meshHeightmap(image, sizeX, sizeY, sizeZ)` convierte una imagen en escala de grises en un terreno ondulado: un vértice por píxel, el negro abajo y el blanco a `sizeY` de altura, con normales sacadas de las pendientes y coordenadas de textura a lo largo de todo él. Da a su material una textura y `materialSetTiling` para repetirla cada pocas unidades. Las imágenes pueden tener hasta 1025 píxeles en cada dirección; un mapa de 257 píxeles sobre 120 unidades da un vértice cada media unidad, que es de sobra para unas colinas. Un cuerpo `BODY_STATIC` con `SHAPE_MESH` en ese nodo se convierte en un campo de alturas, mucho más barato de lo que serían los triángulos, así que los coches circulan por él y los jugadores caminan por él tal cual. `terrainGetHeight(node, x, z)` dice a qué altura está el suelo en un punto, para colocar cosas encima sin lanzar un rayo. [source,lua] ---- local ground = materialNew() materialSetTexture(ground, DIR .. "grass.ktx2") materialSetTiling(ground, 24, 24) local hills = nodeNew() nodeSetMesh(hills, meshHeightmap(DIR .. "hills.png", 120, 14, 120), ground) bodyNew(hills, BODY_STATIC, SHAPE_MESH) -- Un campo de alturas. for i = 1, 20 do local x, z = math.random(-50, 50), math.random(-50, 50) local tree = modelInstance(treeModel) nodeSetPosition(tree, x, terrainGetHeight(hills, x, z), z) end ---- [#_navigation] ==== Navegación Una malla de navegación les dice a los personajes por dónde pueden caminar. `navNew(radius, height, maxSlope, maxStep)` empieza una para un tamaño de agente dado, `navAddNode` le pasa la geometría del nivel (la raíz de una instancia de modelo aporta el modelo entero), y `navBuild` precalcula la superficie transitable: suelos, rampas y escaleras que el agente puede subir, menos un margen alrededor de cada pared y columna. Precalcular Sponza lleva una décima de segundo en un ordenador de sobremesa; `navSave` y `navLoad` guardan una malla precalculada en la carpeta de datos del juego para las máquinas en las que tarda más, y una malla que se distribuye con el juego se carga del mismo modo. `navDraw` muestra la malla sobre la escena mientras ajustas los parámetros. Después, las consultas responden a las preguntas que hace un juego: `navPath` devuelve las esquinas de la ruta más corta entre dos puntos, `navNearest` ajusta un punto a la malla, `navRaycast` dice si un camino en línea recta está bloqueado, y `navRandomPoint` elige un sitio al que ir de paseo. Los agentes (`navAgentNew`) recorren la malla por ti. `navAgentMoveTo` envía a uno a algún sitio, y este esquiva a los demás agentes por el camino, moviendo su nodo (o manejando un controlador de jugador sobre él, con `navAgentSetPlayer`) y llamando a `onNavArrived` cuando llega. `navAgentGetVelocity` te dice si reproducir la animación de andar o la de correr. [source,lua] ---- local nav = navNew(0.4, 1.6, 45, 0.3) -- Un agente de 0.8 de ancho y 1.6 de alto, que sube 0.3. navAddNode(nav, level) navBuild(nav) local guards = {} for i = 1, 6 do local guard = modelInstance(guardModel) local x, y, z = navRandomPoint(nav) nodeSetPosition(guard, x, y, z) animationPlay(guard, "Walk", true) local agent = navAgentNew(nav, guard, 0.35, 1.6, 1.4) navAgentMoveTo(agent, navRandomPoint(nav)) guards[agent] = guard end function onNavArrived(agent) navAgentMoveTo(agent, navRandomPoint(nav)) -- Al siguiente sitio. end ---- [#sceneperformance] ==== Rendimiento y requisitos La escena necesita una GPU que hable Vulkan (Linux), Direct3D 12 (Windows 10 y posteriores), Metal (macOS) u OpenGL ES 3.1. El backend de ES es el que trae las consolas portátiles Linux baratas y la Raspberry Pi, cuyos chips Mali y VideoCore tienen un controlador GLES maduro y, o bien ningún Vulkan, o bien uno inmaduro; se elige automáticamente cuando ninguno de los otros tres está disponible, y dibuja la misma imagen con los mismos shaders. La Raspberry Pi 4 sigue siendo la Pi mínima para 3D: la Pi 3 solo tiene OpenGL ES 2.0, que no puede expresar el skinning ni los morph targets de la escena. En una máquina sin ninguno de ellos, los juegos 2D funcionan exactamente igual que antes (física 2D incluida) y la primera llamada 3D termina el juego con un error que nombra el problema. Las mallas que quedan fuera de la vista de la cámara se omiten, y las copias de la misma malla con el mismo material se dibujan como una sola llamada instanciada, así que un bosque de un mismo árbol cuesta más o menos lo que cuesta un árbol. `sceneGetStats` informa de los dibujos recogidos, de cuántos estaban a la vista, de cuántas llamadas de dibujo han hecho falta y de la memoria de texturas en uso. Para ponérselo fácil: * Comparte mallas y materiales entre nodos (un `meshBox`, muchos nodos; un modelo, muchas llamadas a `modelInstance`) en lugar de crear uno por objeto. * Las mallas con skinning, las que tienen morphs y las que usan mezcla se dibujan de una en una; limítalas a lo que necesite la escena. * El antialiasing (multimuestreo 4x) está activado allí donde la GPU lo ofrece; `sceneSetAntialias(false)` recupera velocidad en una Pi, igual que un `sceneSetShadowSize` más pequeño y menos luces puntuales que proyecten sombras, cada una de las cuales cuesta seis pasadas. * Vigila el número de triángulos y el tamaño de las texturas en las placas pequeñas, distribuye texturas KTX2 y carga un modelo una vez en lugar de dos. [source,lua] ---- function onOverlayUpdate() local draws, drawn, calls, textureKB = sceneGetStats() overlayClear() local text = string.format("%d/%d meshes, %d calls, %d KB", drawn, draws, calls, textureKB) fontPrint(10, 10, text) return OVERLAY_UPDATED end ---- [#physics] === Física Los cuerpos, las articulaciones, los activadores y los lanzamientos de rayos viven en la escena 3D y funcionan sobre Jolt Physics. Un cuerpo se une a un nodo igual que una malla, y desde ese momento el nodo y el cuerpo son una sola cosa: la forma del cuerpo se mide en unidades del mundo y la escala el nodo, y se mueva el que se mueva de los dos, el otro lo sigue. Las unidades son metros, kilogramos y segundos, y la gravedad es por defecto `0, -9.81, 0`. La física no necesita GPU; véase <>. [#_bodies] ==== Cuerpos `bodyNew(node, type, shape, a, b, c)` da a un nodo un cuerpo de uno de tres tipos: * Un cuerpo *estático* (`BODY_STATIC`) se queda donde estaba el nodo cuando se creó el cuerpo: suelos, paredes, la recreativa. Mover su nodo después no mueve el cuerpo. * Un cuerpo *cinemático* (`BODY_KINEMATIC`) sigue a su nodo dondequiera que lo muevan el script o una animación, y aparta de su camino a los cuerpos dinámicos sin que a él lo empujen: una pala, una plataforma móvil, la mano de un personaje. * Un cuerpo *dinámico* (`BODY_DYNAMIC`) lo mueve la simulación, y él mueve su nodo: cae, rebota, se desliza y recibe empujones. No muevas tú su nodo; dale en cambio velocidades, fuerzas o impulsos (`bodySetVelocity`, `bodyApplyForce`, `bodyApplyImpulse`). La forma es una caja, una esfera, una cápsula o un cilindro con las medidas `a`, `b` y `c` (`SHAPE_BOX` toma anchura, altura y profundidad; `SHAPE_SPHERE`, un radio; `SHAPE_CAPSULE` y `SHAPE_CYLINDER`, un radio y una altura a lo largo de Y), una envolvente convexa de la malla del nodo (`SHAPE_HULL`, todo lo que hay bajo el nodo, así que sirve la raíz de una instancia de modelo), o los triángulos exactos de la malla (`SHAPE_MESH`, solo para cuerpos estáticos y cinemáticos; sobre una malla de mapa de alturas es un campo de alturas, véase <>). [source,lua] ---- floor = nodeNew() nodeSetMesh(floor, meshBox(14, 0.2, 14), stone) nodeSetPosition(floor, 0, -1.9, 0) bodyNew(floor, BODY_STATIC, SHAPE_BOX, 14, 0.2, 14) for i = 1, 6 do local crate = nodeNew() nodeSetMesh(crate, meshBox(0.6, 0.6, 0.6), wood) nodeSetPosition(crate, -1, -1.5 + (i - 1) * 0.62, 0) bodyNew(crate, BODY_DYNAMIC, SHAPE_BOX, 0.6, 0.6, 0.6) bodySetMass(crate, 5) end paddle = nodeNew() nodeSetMesh(paddle, meshBox(0.3, 1.2, 1.6), red) nodeSetPosition(paddle, -4, -1.2, 0) bodyNew(paddle, BODY_KINEMATIC, SHAPE_BOX, 0.3, 1.2, 1.6) function onOverlayUpdate() nodeMove(paddle, 0.06, 0, 0) -- La pala cinemática barre la pila. return OVERLAY_UPDATED end ---- .Masa y materiales La masa de un cuerpo dinámico sale del volumen de su forma con la densidad del agua, así que una caja del tamaño de una puerta pesa 140 kilogramos y un empujoncito no la mueve. Usa `bodySetMass` en todo lo que quieras que se sienta ligero, y ajusta los impulsos a la masa: un impulso de `mass * speed` fija esa velocidad. `bodySetFriction` y `bodySetBounce` hacen hielo y goma; `bodySetEnabled` saca un cuerpo del mundo y lo vuelve a poner sin perderlo. .Sincronización El mundo avanza a un ritmo fijo de sesenta pasos por segundo según el reloj, después de que las animaciones hayan puesto en pose sus nodos y antes de que se renderice la escena, así que un cuerpo cinemático movido por una animación empuja con la velocidad correcta. Poner el juego en pausa pausa la física; `physicsSetEnabled(false)` la pausa por sí sola. `bodyIsResting` dice cuándo un cuerpo se ha asentado, que es cuando algo lanzado se ha detenido y se puede contar una puntuación. Un cuerpo, jugador, ragdoll, cuerpo blando o vehículo cuyo nodo se ha borrado se libera al principio del siguiente fotograma, esté activado o no y en pausa o no, así que su hueco vuelve a quedar libre; un handle de nodo que `nodeNew` vuelva a entregar más tarde no lleva nada de la física del nodo anterior, y `playerExists` y las de su clase responden `false` para él hasta que tenga la suya propia. [#_events_and_queries] ==== Eventos y consultas Define `onCollision(nodeA, nodeB, x, y, z, speed)` y Singe la llama una vez por cada contacto nuevo, con el punto y la velocidad a la que se encontraron los dos cuerpos, para que un golpecito y un choque puedan sonar distinto. Un cuerpo convertido en activador con `bodySetTrigger` no empuja nada, pero informa de lo que entra en él y sale de él mediante `onTrigger(trigger, other, entered)`: una línea de meta, una puerta, una zona de peligro. `physicsRaycast` encuentra el primer cuerpo a lo largo de un rayo; con `sceneUnproject` para el rayo, eso es seleccionar con el ratón (véase <>). [source,lua] ---- finish = nodeNew() nodeSetPosition(finish, 0, 1, -40) bodyNew(finish, BODY_STATIC, SHAPE_BOX, 12, 2, 0.5) bodySetTrigger(finish, true) function onTrigger(trigger, other, entered) if trigger == finish and other == car and entered then lapDone() end end function onCollision(a, b, x, y, z, speed) if speed > 6 then local channel = soundPlay(crashSound) soundSetPosition(channel, x, y, z) end end ---- [#_joints] ==== Articulaciones `jointHinge`, `jointBall` y `jointSlider` unen dos cuerpos, o un cuerpo y el mundo cuando el segundo nodo es `-1`: puertas, péndulos, cajones, las ruedas de un coche visto de lado. El anclaje y el eje se dan en espacio del mundo, allí donde están los cuerpos cuando se crea la articulación. Las bisagras y las correderas aceptan límites (`jointSetLimits`): un ángulo de bisagra positivo es un giro del primer nodo alrededor del eje según la regla de la mano derecha, y una distancia de corredera positiva va en el sentido del eje. [source,lua] ---- door = nodeNew() nodeSetMesh(door, meshBox(0.9, 2.0, 0.05), oak) nodeSetPosition(door, 0.45, 1.0, 0) bodyNew(door, BODY_DYNAMIC, SHAPE_BOX, 0.9, 2.0, 0.05) bodySetMass(door, 20) local hinge = jointHinge(door, -1, 0, 1.0, 0, 0, 1, 0) -- En el marco, alrededor de Y. jointSetLimits(hinge, 0, 110) -- Se abre hacia un lado, 110 grados. ---- Los cuerpos en instancias de modelo funcionan como cualquier otro: `bodyNew(instanceRoot, BODY_DYNAMIC, SHAPE_HULL)` envuelve el modelo entero en una envolvente convexa. Un personaje con skinning queda mejor como jugador (más abajo) o como una cápsula cinemática que empuja las cosas en lugar de dejarse empujar. [#physics2d] ==== Juegos 2D La física no está atada a la escena 3D. Los nodos existen en todas las máquinas, con GPU o sin ella, así que un juego 2D puede dar cuerpos a los nodos, llamar a `physicsSet2D(true)` para que solo se muevan en X e Y y solo giren alrededor de Z, trabajar en coordenadas del overlay con la gravedad apuntando hacia abajo en la pantalla, y dibujar sprites donde están los nodos. Esta es la forma que funciona en una Raspberry Pi 3, que no tiene 3D. [source,lua] ---- physicsSet2D(true) physicsSetGravity(0, 900, 0) -- Píxeles por segundo al cuadrado; la Y del overlay crece hacia abajo. floor = nodeNew() nodeSetPosition(floor, 360, 470, 0) bodyNew(floor, BODY_STATIC, SHAPE_BOX, 720, 20, 50) crate = nodeNew() nodeSetPosition(crate, 200, -40, 0) bodyNew(crate, BODY_DYNAMIC, SHAPE_BOX, 40, 40, 50) bodySetMass(crate, 1) function onOverlayUpdate() local x, y = nodeGetPosition(crate) local _, _, angle = nodeGetRotation(crate) spriteRotate(crateArt, angle) spriteDraw(crateArt, x, y, true) return OVERLAY_UPDATED end ---- Da a cada cuerpo 2D algo de profundidad (la tercera medida) para que las formas se solapen en Z; `50` vale. Los círculos son `SHAPE_SPHERE`. Los jugadores y los emisores 2D (véase <>) también funcionan en este mundo, y un juego de plataformas es un jugador con `playerMove(node, vx)`. [#_players] ==== Jugadores Un cuerpo va adonde lo mandan las fuerzas; un *jugador* va adonde dice el script. `playerNew` pone un controlador de personaje en un nodo: una cápsula por defecto (`playerNew(node, radius, height)`), o cualquier forma convexa de `bodyNew` (`playerNew(node, SHAPE_BOX, w, h, d)` para un robot, `playerNew(node, SHAPE_SPHERE, r, 0)` para algo que rueda y aun así sube escalones, `SHAPE_HULL` para el contorno del propio modelo; tres argumentos siempre significan una cápsula, así que una esfera escribe todas sus medidas). La forma se apoya en el origen del nodo, que son los pies. Cada fotograma el script da a `playerMove` la velocidad horizontal que quiere, y el controlador camina por los suelos, sube escalones de hasta `playerSetStep`, se desliza a lo largo de las paredes, rechaza pendientes más empinadas que `playerSetSlope`, cae por la gravedad, viaja sobre plataformas móviles y aparta a empujones de su camino los cuerpos dinámicos ligeros (`playerSetPush`). `playerJump` solo funciona con suelo bajo los pies y dice si ha saltado. La rotación del nodo es cosa del script: gíralo para que mire hacia donde camina, y cuelga el modelo debajo como hijo. [source,lua] ---- hero = nodeNew() nodeSetPosition(hero, 0, 0, 0) playerNew(hero, 0.3, 1.0) -- Una cápsula de radio 0.3 y 1 de alto, con los pies en el nodo. fox = modelInstance(modelLoad(DIR .. "Fox.glb")) nodeSetParent(fox, hero) function onInputPressed(switch) if switch == SWITCH_BUTTON1 then playerJump(hero, 4.5) -- Necesita suelo bajo los pies. end end function onOverlayUpdate() local vx = controllerGetAxis(0, GAMEPAD_AXIS_LEFT_X) / 32767 * 3 local vz = controllerGetAxis(0, GAMEPAD_AXIS_LEFT_Y) / 32767 * 3 playerMove(hero, vx, vz) if vx ~= 0 or vz ~= 0 then nodeSetRotation(hero, 0, math.deg(math.atan(-vx, -vz)), 0) end return OVERLAY_NOT_UPDATED end ---- Los jugadores avisan con `onCollision` cuando empiezan a tocar un cuerpo y con `onTrigger` cuando entran en un sensor o salen de él, con su nodo en el lugar que ocuparía el nodo de un cuerpo, así que una puerta, un objeto que recoger y un pozo de lava funcionan igual para un jugador que para una pelota. `playerIsOnGround` y `playerGetGround` dicen sobre qué está el jugador, para los sonidos de pasos y para dejarse caer de una plataforma a propósito. En un mundo 2D un jugador es un personaje de plataformas: `playerMove(node, vx)` corre a lo largo de X, la gravedad apunta hacia abajo en la pantalla y todo lo demás es igual. Las tolerancias del controlador escalan con la forma, así que una cápsula de 30 píxeles en un juego de sprites se comporta como una de 0,3 metros en un juego 3D. [#_vehicles] ==== Vehículos Un *vehículo* es un cuerpo dinámico (el chasis, creado con `bodyNew` como cualquier cuerpo, mirando hacia -Z con la Y hacia arriba) que Jolt conduce mediante ruedas: `vehicleNew(node, VEHICLE_CAR)` para cualquier cosa con ejes, `VEHICLE_MOTORCYCLE` para dos ruedas en línea que se inclinan en las curvas y mantienen el equilibrio, o `VEHICLE_TANK` para ruedas sobre dos orugas que giran sin moverse del sitio. Las ruedas son nodos: `vehicleAddWheel(node, wheelNode, radius, width, suspension)` toma la posición de la rueda de dónde está su nodo respecto al chasis, y desde ese momento el motor coloca ese nodo en cada fotograma (su eje X es el eje de la rueda, así que una rueda hecha con `meshCylinder` necesita un hijo girado 90 grados). Las ruedas encontradas en un modelo con `nodeFind("wheelFL", car)` funcionan igual. Por defecto las ruedas delanteras giran y todas dan tracción; `vehicleSetWheel` dispone otra cosa. Cada fotograma el script hace de conductor: `vehicleDrive(node, forward, right, brake, handBrake)`. El motor, la caja de cambios automática y los diferenciales son los de Jolt, con los valores por defecto de un coche de 1500 kg; `vehicleSetEngine`, `vehicleSetGears`, `vehicleSetSuspension`, `vehicleSetSteering`, `vehicleSetBrakes` y `vehicleSetAntiRoll` los ajustan, y cualquier cambio reconstruye el vehículo en su sitio en el siguiente paso. `vehicleGetSpeed`, `vehicleGetRpm` y `vehicleGetGear` alimentan un cuadro de instrumentos. Un vehículo sigue siendo un cuerpo: salta rampas, choca con paredes, aparta cajas y avisa con `onCollision`. [source,lua] ---- car = nodeNew() nodeSetMesh(car, meshBox(1.8, 0.5, 4.0), paint) nodeSetPosition(car, 0, 1, 0) bodyNew(car, BODY_DYNAMIC, SHAPE_BOX, 1.8, 0.5, 4.0) bodySetMass(car, 1500) vehicleNew(car, VEHICLE_CAR) local corners = { { -0.95, -0.2, -1.4 }, { 0.95, -0.2, -1.4 }, -- Delanteras izquierda y derecha ... { -0.95, -0.2, 1.4 }, { 0.95, -0.2, 1.4 }, -- ... traseras izquierda y derecha. } for i, p in ipairs(corners) do local wheel = nodeNew() nodeSetParent(wheel, car) nodeSetPosition(wheel, p[1], p[2], p[3]) vehicleAddWheel(car, wheel, 0.35, 0.25, 0.4) end function onOverlayUpdate() local throttle = controllerGetAxis(0, GAMEPAD_AXIS_RIGHT_TRIGGER) / 32767 local brake = controllerGetAxis(0, GAMEPAD_AXIS_LEFT_TRIGGER) / 32767 local steer = controllerGetAxis(0, GAMEPAD_AXIS_LEFT_X) / 32767 vehicleDrive(car, throttle, steer, brake, 0) return OVERLAY_NOT_UPDATED end ---- Los tanques toman `forward` como acelerador y `right` como giro (sin acelerador pivotan sobre sí mismos), e ignoran el ajuste de dirección, frenos y antivuelco, ya que sus orugas frenan y giran por su cuenta. El muelle de inclinación de la moto se escala según el chasis, así que una moto ligera se equilibra tan bien como una pesada. Los vehículos con ruedas necesitan un mundo 3D; un coche 2D visto de lado es un cuerpo con dos ruedas con bisagra, que las llamadas de articulaciones ya construyen. [#_water] ==== Agua `bodySetWater(node, density, linearDrag, angularDrag)` llena de agua un cuerpo estático: la cara superior de su caja es la superficie, y cada cuerpo dinámico que haya dentro flota, se hunde o se balancea por la flotabilidad de Jolt respecto a esa superficie (cuenta la parte sumergida de la forma, así que una caja inclinada se endereza sola), frenado por las resistencias. `bodySetCurrent` da una corriente al agua. `density` es un multiplicador, `1` para el agua, y el `bodySetBuoyancy` propio de cada cuerpo que flota (por defecto `1.2`, que flota suavemente; por debajo de `1` se hunde) lo escala. El volumen es un activador, así que `onTrigger` se dispara al entrar y al salir, para las salpicaduras. El agua no se dibuja: da al nodo del volumen un material con mezcla, o ninguno. Un jugador en el agua nada: `playerIsSwimming` lo dice, `playerMove(node, vx, vy, vz)` dirige entonces en tres ejes, se hunde despacio si no hay entrada vertical (`playerSetSwim` fija ese ritmo y la resistencia del agua), y la corriente lo arrastra. Un barco es un casco flotante al que `vehicleNew(node, VEHICLE_BOAT)` da una hélice y un timón: `vehicleSetThrust` dice con cuánta fuerza empuja y en qué punto del casco, `vehicleSetRudder` con cuánta fuerza gira, y `vehicleDrive` funciona como en un coche, todo ello solo mientras la hélice está bajo una superficie. [source,lua] ---- pool = nodeNew() nodeSetMesh(pool, meshBox(8, 3, 12), blue) -- Azul con mezcla, dibujado como el agua. nodeSetPosition(pool, 0, -1.5, 0) -- Su cara superior, en Y = 0, es la superficie. bodyNew(pool, BODY_STATIC, SHAPE_BOX, 8, 3, 12) bodySetWater(pool, 1.0, 0.6, 0.2) bodySetCurrent(pool, 0.5, 0, 0) -- Deriva hacia +X. bodySetBuoyancy(crate, 1.6) -- Flota alto. bodySetBuoyancy(anchor, 0.3) -- Se hunde. ---- [#_ragdolls] ==== Ragdolls `ragdollNew(node)` busca el skin bajo una instancia de modelo y convierte el esqueleto en una receta de ragdoll: cada articulación con una articulación hija se convierte en una cápsula a lo largo de ese hueso, unida al hueso más cercano por encima de ella mediante una articulación de giro y torsión (swing-twist) con límites razonables, y las articulaciones terminales (dedos, orejas, la punta de una cola) van con sus padres. Nada se mueve hasta `ragdollActivate`: entonces la animación se detiene, se crean cuerpos donde están los huesos, y desde ese momento la física mueve los nodos de las articulaciones, así que la malla con skinning se desploma, golpea el suelo y queda en reposo con el resto del mundo. `ragdollApplyImpulse` empuja un hueso por el nombre de su articulación. `ragdollSetStrength` impulsa cada articulación hacia la pose en la que se activó el ragdoll, lo que convierte una caída flácida en un tambaleo o deja que un personaje caído recoja las extremidades; no pone de pie un cuerpo, ya que nada empuja contra el suelo. `ragdollDeactivate` devuelve el esqueleto a la animación desde dondequiera que esté tirado, y un `animationPlay` con fundido lo levanta suavemente desde ahí. Ajusta con `ragdollSetJoint` un hueso que se vea mal antes de activarlo. [source,lua] ---- fox = modelInstance(foxModel) ragdollNew(fox) -- Receta a partir del skin; sigue animado. function onCollision(a, b, x, y, z, speed) if (a == hero or b == hero) and speed > 3 then playerSetEnabled(hero, false) ragdollActivate(fox) -- Ahora la física mueve el esqueleto. ragdollApplyImpulse(fox, "b_Spine02_03", 40, 30, 0) end end ---- [#_soft_bodies] ==== Cuerpos blandos La tela, la cuerda y la gelatina son mallas cuyos vértices se simulan. `softNew(node, SOFT_CLOTH)` convierte la malla del nodo en tela, y sus vértices (soldados por posición) en partículas unidas por restricciones de estiramiento, cizalla y flexión, allí donde el nodo la ha puesto en el mundo; un `meshPlane(width, depth, columns, rows)` subdividido es el punto de partida habitual. `softNew(node, SOFT_BODY)` hace lo mismo con una malla cerrada, y `softSetPressure` la infla: un globo, una gelatina. `softNew(node, SOFT_ROPE, x, y, z, segments, radius)` tiende una cadena de partículas desde la posición del nodo hasta ese punto y da al nodo una malla de tubo a su alrededor (su aspecto se fija con `nodeSetMaterial`). Los cuerpos blandos chocan con todo lo rígido, caen por la gravedad y se dibujan con la malla de la que salieron, que el motor reescribe en cada fotograma. `softPin` sujeta la partícula más cercana donde está o a un nodo (el borde de la bandera al mástil, el extremo de la cuerda a la grúa); `softSetStiffness`, `softSetMass` y `softSetDamping` ajustan el material. Un nodo cuya malla comparten otros nodos los cambia también a ellos, así que da a un cuerpo blando una malla propia. [source,lua] ---- flag = nodeNew() nodeSetMesh(flag, meshPlane(1.6, 1.0, 16, 10), red) nodeSetPosition(flag, -4.2, 2.4, 0) nodeSetRotation(flag, 90, 0, 0) -- Colgada en vertical. softNew(flag, SOFT_CLOTH) for _, y in ipairs({ 1.9, 2.15, 2.4, 2.65, 2.9 }) do softPin(flag, -5, y, 0) -- A lo largo del mástil. end ---- El coste crece con el número de partículas: una lámina de 20 por 20 son 441 partículas y va bien en una Raspberry Pi 4; limita la tela a unos cientos de partículas y una cuerda a una docena de segmentos. Las partículas chocan con cuerpos rígidos, no con caras, así que una malla lo bastante fina (con las partículas más juntas que lo más pequeño que vaya a caer encima) es lo que impide que una caja la atraviese. No hay autocolisión: la tela puede atravesarse a sí misma cuando se arruga. Las mallas con skinning no pueden ser blandas. [#_seeing_the_physics] ==== Ver la física Cuando una caja atraviesa el suelo o una articulación se dobla hacia el lado equivocado, `physicsSetDebug` dibuja lo que la simulación contiene de verdad como líneas sobre la escena en cada fotograma: * `DEBUG_SHAPES`: los cuerpos en alambre, coloreados según su estado (dormido, en movimiento, estático), con los jugadores en amarillo. * `DEBUG_CONSTRAINTS`: las articulaciones, las articulaciones de ragdoll y las restricciones de los vehículos, con sus límites. * `DEBUG_CONTACTS`: una cruz roja donde se han encontrado cuerpos en este paso. * `DEBUG_VELOCITIES`: una línea a lo largo de la velocidad de cada cuerpo en movimiento. * `DEBUG_STATIC`: también las formas de los cuerpos estáticos, que si no se quedan ocultas porque la malla de un nivel son muchas líneas. Súmalas, o usa `DEBUG_ALL` para las cuatro primeras. `DEBUG_NONE` lo desactiva, y no cuesta nada mientras está desactivado. Un script dibuja sus propias líneas del mismo modo con `lineDraw` (un lanzamiento de rayo, un camino, una caja envolvente), un fotograma cada vez, como el overlay, y `navDraw` muestra una malla de navegación. [source,lua] ---- if debugging then physicsSetDebug(DEBUG_SHAPES + DEBUG_CONTACTS) end function onOverlayUpdate() local hit, hx, hy, hz = physicsRaycast(gunX, gunY, gunZ, aimX, aimY, aimZ, 60) if hit then lineDraw(gunX, gunY, gunZ, hx, hy, hz, 255, 255, 0) end return OVERLAY_NOT_UPDATED end ---- [#_where_it_runs] ==== Dónde funciona La física funciona en la CPU y no necesita GPU, pero la biblioteca está compilada para SSE4.1 en x86 (de 2008 en adelante) y NEON en la Pi; una máquina por debajo de eso recibe un error en el primer `bodyNew`. La simulación usa todos los núcleos menos uno, así que una Pi de cuatro núcleos avanza los pasos en tres hilos; la traza del programa dice cuántos. [#particles] === Partículas Chispas, humo, fuego, lluvia, polvo, magia: todo lo que está hecho de muchas cosas pequeñas y de vida corta es trabajo para un *emisor*. Un emisor guarda una receta (cuántas por segundo, cuánto viven, a qué velocidad y en qué dirección salen, qué tira de ellas, cómo crecen, se desvanecen y giran) y una reserva de partículas que gestiona según esa receta. El script fija la receta una vez y luego arranca el emisor para un flujo constante (`emitterStart`), lo dispara en ráfaga para una bocanada suelta (`emitterBurst`), o ambas cosas. El motor mueve cada partícula viva en cada fotograma, y deja de moverlas mientras el juego está en pausa. Una sola API de emisores sirve para el overlay y para la escena 3D; solo cambian las unidades y el dibujo. Todo lo que se da como un mínimo y un máximo (`emitterSetLife`, `emitterSetSpeed`, `emitterSetSize`) se elige al azar entre los dos para cada partícula. [#_2d_emitters] ==== Emisores 2D `emitterNew()` sin argumento crea un emisor 2D. Vive en coordenadas del overlay, con la Y hacia abajo, así que la gravedad de una fuente es Y positiva. Muévelo con `emitterSetPosition`, y dibújalo desde `onOverlayUpdate` con `emitterDraw`. El overlay en sí es una imagen que pinta la CPU, así que las partículas no se pintan en él: las dibuja la GPU, o bien por encima de todo el overlay (`PARTICLE_OVER`, por defecto) o bien por debajo de él y por encima del vídeo y de la escena 3D (`PARTICLE_UNDER`, para humo detrás de la puntuación). Los emisores 2D funcionan en todas las máquinas en las que funciona Singe, la Raspberry Pi 3 incluida, y combinan bien con la física 2D: lee la posición del nodo de un cuerpo y pásasela a `emitterSetPosition`. [source,lua] ---- sparks = emitterNew() emitterSetBlend(sparks, PARTICLE_ADD) emitterSetLife(sparks, 0.3, 0.7) emitterSetSpeed(sparks, 80, 200) emitterSetDirection(sparks, 0, -1) -- Hacia arriba en la pantalla. emitterSetSpread(sparks, 70) emitterSetGravity(sparks, 0, 500) emitterSetSize(sparks, 5, 1) emitterSetColor(sparks, 255, 255, 200, 255, 255, 120, 0, 0) function onOverlayUpdate() overlayClear() emitterSetPosition(sparks, grinderX, grinderY) emitterDraw(sparks) return OVERLAY_UPDATED end ---- [#_3d_emitters] ==== Emisores 3D `emitterNew(node)` crea un emisor 3D unido a un nodo. Las partículas nacen en la posición del nodo en el mundo (repartidas según `emitterSetRadius`), en unidades del mundo, y se dibujan en la escena como billboards orientados a la cámara después de todas las mallas: con prueba de profundidad contra la geometría, sin escribir nunca la profundidad, sin iluminar salvo que se pida, sin proyectar ni recibir sombras. Por defecto una partícula se queda donde nació una vez que deja el emisor (el humo sigue flotando donde se hizo mientras la antorcha avanza); `emitterSetLocal` hace en cambio que toda la nube siga al nodo. Borrar el nodo borra sus emisores. [source,lua] ---- flame = emitterNew(torchTip) emitterSetBlend(flame, PARTICLE_ADD) emitterSetRate(flame, 140) emitterSetLife(flame, 0.4, 0.9) emitterSetSpeed(flame, 0.6, 1.4) emitterSetDirection(flame, 0, 1, 0) emitterSetSpread(flame, 15) emitterSetSize(flame, 0.35, 0.05, 0.3) emitterSetColor(flame, 255, 200, 90, 255, 255, 40, 0, 0) emitterStart(flame) ---- [#_the_picture] ==== La imagen Una partícula es un quad con textura. Sin textura es un disco suave que proporciona el motor; `emitterSetTexture` le da en su lugar un sprite cargado (una bocanada, un trazo de chispa, un copo de nieve), y para un sprite animado `emitterSetFrames` elige un fotograma al azar por partícula, así que un solo atlas de bocanadas da una nube variada. La mezcla es normal (`PARTICLE_ALPHA`) o aditiva (`PARTICLE_ADD`, para todo lo que brilla). `emitterSetColor` da el color y el alfa al nacer y al morir, y la partícula pasa de uno a otro a lo largo de su vida; `emitterSetSize` hace lo mismo con su tamaño, con un tercer valor para la velocidad a la que gira. [#_trails_collisions_lit_and_soft_particles] ==== Estelas, colisiones, partículas iluminadas y suaves `emitterSetTrail(emitter, length, width)` guarda las últimas posiciones de cada partícula y dibuja una cinta a través de ellas, que se desvanece hacia la cola: chispas, balas trazadoras, cometas, en 2D y en 3D por igual. `emitterSetCollide` hace que las partículas reboten: en un suelo horizontal a la altura que indiques (`COLLIDE_FLOOR`, gratis), o en el mundo físico mediante lanzamientos de rayos (`COLLIDE_SCENE`, solo 3D, con un tope por fotograma para que un emisor grande reparta el coste a lo largo de unos cuantos fotogramas). Otras dos son solo para 3D. `emitterSetLit` sombrea cada partícula con las luces y el cielo de la escena como si fuera una pequeña esfera orientada a la cámara, así que el humo toma el color de un brasero y el polvo destella al sol (las sombras no se leen). `emitterSetSoftness` desvanece una partícula a lo largo de una distancia allí donde se encuentra con la geometría, para que el humo no se corte contra un suelo ni una fuente contra su estanque; esto cuesta una pasada de profundidad extra de la escena por fotograma mientras algún emisor lo use. [source,lua] ---- smoke = emitterNew(brazier) emitterSetTexture(smoke, spriteLoad(DIR .. "puff.png")) emitterSetRate(smoke, 30) emitterSetLife(smoke, 2, 4) emitterSetSpeed(smoke, 0.3, 0.6) emitterSetDirection(smoke, 0, 1, 0) emitterSetSize(smoke, 0.3, 1.2, 0.2) emitterSetColor(smoke, 90, 90, 90, 160, 60, 60, 60, 0) emitterSetLit(smoke, true) -- Toma desde abajo el color del fuego. emitterSetSoftness(smoke, 0.4) -- Sin borde duro donde toca el cuenco. emitterStart(smoke) embers = emitterNew(brazier) emitterSetBlend(embers, PARTICLE_ADD) emitterSetTrail(embers, 6, 0.02) -- Una estela corta detrás de cada brasa. emitterSetCollide(embers, COLLIDE_FLOOR, 0.3, 0.8, 0) -- Rebota en el suelo en Y = 0. emitterStart(embers) ---- [#_cost] ==== Coste Las partículas son baratas de simular y cuestan lo que cubren en pantalla. Unos miles de partículas pequeñas no son nada; un puñado de partículas grandes y aditivas sobre toda la pantalla es lo que ralentiza una Raspberry Pi 4. La reserva tiene un tope que fija `emitterSetMax` (1000 por defecto); cuando está llena, no nacen más hasta que algunas mueren. [#authoring] === Describir un juego en vez de escribirlo Un juego se puede escribir como una *descripción* -- una tabla de capas, entidades y reglas -- y compilarse en un juego de Singe corriente con Forge, la herramienta de creación. Forge es un proyecto aparte con su propia descarga y su propio libro. [[migrating]] === Migración desde Singe 2.10 Singe 3.00 pasó el handle del sprite al primer argumento de `spriteDraw`, `spriteLoop`, `spriteQuality`, `spriteRotate`, `spriteRotateAndScale`, `spriteScale` y `spriteSetFrame`, así que ahora todas las llamadas de sprites coinciden con la familia `video*`. Para actualizar un juego, pasa al principio el último argumento de cada una de esas llamadas: [source,lua] ---- spriteDraw(x, y, cursor) -- 2.10 spriteDraw(cursor, x, y) -- 3.00 spriteRotate(angle, cursor) -- 2.10 spriteRotate(cursor, angle) -- 3.00 ---- Un juego que no puedes editar puede optar en cambio por el orden antiguo. O bien define la variable global antes de cargar el framework: [source,lua] ---- SINGE_LEGACY_SPRITE_ARGS = true dofile("Singe/Framework.singe") ---- o bien añade `LEGACY_SPRITE_ARGS = true` a su entrada de `games.dat`. Nada más cambió de forma, pero algunos comportamientos sí: * Los handles y los recuentos se devuelven como enteros de Lua; `videoIsPlaying` devuelve un booleano; `mouseSetMode` no devuelve nada. * Los errores dentro de los callbacks ahora terminan el juego con una traza de la pila en lugar de imprimirse e ignorarse, igual que se han comportado siempre los errores de argumentos. * Las teclas mantenidas pulsadas ya no se repiten en `MODE_NORMAL`. * No se escribe nada junto al juego. Sin `--datadir`, la carpeta de datos es `data//` dentro de la carpeta del juego, donde 2.x usaba la propia carpeta del juego; las partidas guardadas, los ajustes y los récords van dentro de `singeGetDataPath()`. * `math.randomseed` acepta una semilla fraccionaria, como hacía con el Lua de 2.10; se redondea hacia abajo antes de que la vea el generador de Lua 5.4. `os.clock` es tiempo real desde que arrancó el motor, no tiempo de procesador. * El motor deja el disco en el fotograma 1 antes de que se ejecute tu script; `Framework.singe` ya no hace un salto al cargarse. * `onMouseMoved` en el modo `MANY_MOUSE` pasa el movimiento real en su tercer y cuarto argumentos, y `onControllerMoved` pasa el índice del mando (de `0` a `3`) en lugar del ID de instancia de SDL. * `mouseEnable` / `mouseDisable` y `singeEnablePauseKey` / `singeDisablePauseKey` siguen funcionando, como alias de `mouseSetEnabled` y `singeSetPauseKeyEnabled`. * Ya no se da por supuesto un laserdisc. Una entrada de `games.dat` con `VIDEO` es un juego de laserdisc, como antes; una sin él se ejecuta sobre un lienzo. En la línea de comandos pasa `--disc` (o `--framefile`), ya que un vídeo encontrado junto al script ya no implica un disco. Véase <>. * La tecla de pausa ahora pausa el juego entero, no solo el contenido multimedia: el script queda congelado hasta que se vuelve a pulsar la tecla, y `SWITCH_PAUSE` solo se entrega al script cuando la tecla se ha desactivado. Actúa al pulsar la tecla y no al soltarla. Véase <>. * `fontPrint` conserva las cadenas que renderiza, y `overlayClear` vuelve a rellenar solo lo que se ha dibujado desde el último borrado, así que un HUD que se vuelve a imprimir en cada fotograma es barato. Renderizar texto con `fontToSprite` por rendimiento está desaconsejado; sigue funcionando, y sigue siendo la forma de obtener texto que se puede medir, escalar o girar. Véase <>. [#_engine_constants] === Constantes del motor El motor define estas variables globales antes de que se ejecute ningún script, así que están disponibles tanto para `controls.cfg` como para `Framework.singe`: [cols="2,3",options="header"] |=== | Constante | Significado | `SWITCH_UP` ... `SWITCH_RECORD` | Valores que se pasan a `onInputPressed` / `onInputReleased` en `MODE_NORMAL`. `SWITCH_MOUSE_DISCONNECT` lo genera el propio motor cuando se desconecta un ratón o una pistola de luz, una vez como pulsación y otra como liberación; ninguna tecla ni botón lo produce. `SWITCH_RECORD` es el interruptor de grabación, sobre el que actúa el propio motor, igual que con el interruptor de captura de pantalla. | `FONT_QUALITY_SOLID`, `FONT_QUALITY_SHADED`, `FONT_QUALITY_BLENDED` | Argumentos para `fontQuality`. | `MODE_NORMAL`, `MODE_FULL` | Argumentos para `keyboardSetMode`. | `MOUSE_SINGLE`, `MOUSE_MANY` (también `SINGLE_MOUSE`, `MANY_MOUSE`) | Argumentos para `mouseSetMode`. | `OVERLAY_NOT_UPDATED`, `OVERLAY_UPDATED` | Valores de retorno de `onOverlayUpdate`. | `RENDER_PIXELATED`, `RENDER_SMOOTH` | Argumentos para `spriteQuality` / `videoQuality`. | `SPRITE_ALPHA`, `SPRITE_ADD`, `SPRITE_MULTIPLY` | Mezcla para `spriteSetBlend`. | `DISC_STOPPED`, `DISC_PLAYING`, `DISC_PAUSED`, `DISC_EJECTED` | Valores de retorno de `discGetState`. | `LIGHT_DIRECTIONAL`, `LIGHT_POINT`, `LIGHT_SPOT` | Argumentos para `lightNew`. | `BODY_STATIC`, `BODY_DYNAMIC`, `BODY_KINEMATIC` | Tipos de cuerpo para `bodyNew`. | `SHAPE_BOX`, `SHAPE_SPHERE`, `SHAPE_CAPSULE`, `SHAPE_CYLINDER`, `SHAPE_HULL`, `SHAPE_MESH` | Formas de colisión para `bodyNew`. | `JOINT_HINGE`, `JOINT_BALL`, `JOINT_SLIDER` | Tipos de articulación (las llamadas `joint*` los nombran directamente). | `DEBUG_NONE`, `DEBUG_SHAPES`, `DEBUG_CONSTRAINTS`, `DEBUG_CONTACTS`, `DEBUG_VELOCITIES`, `DEBUG_STATIC`, `DEBUG_ALL` | Lo que dibuja `physicsSetDebug`; se suman entre sí. | `PARTICLE_ALPHA`, `PARTICLE_ADD` | Mezcla para `emitterSetBlend`. | `PARTICLE_OVER`, `PARTICLE_UNDER` | Dónde se dibujan las partículas 2D, para `emitterSetLayer`. | `COLLIDE_NONE`, `COLLIDE_FLOOR`, `COLLIDE_SCENE` | Contra qué rebotan las partículas, para `emitterSetCollide`. | `BILLBOARD_NONE`, `BILLBOARD_ALL`, `BILLBOARD_Y` | Cómo se orienta un nodo hacia la cámara, para `nodeSetBillboard`. | `LAYER_SCENE`, `LAYER_FRONT` | Si un nodo se dibuja por debajo del overlay o delante de él, para `nodeSetLayer`. | `VEHICLE_CAR`, `VEHICLE_MOTORCYCLE`, `VEHICLE_TANK`, `VEHICLE_BOAT` | Clases de vehículo para `vehicleNew`. | `SOFT_CLOTH`, `SOFT_BODY`, `SOFT_ROPE` | Clases de cuerpo blando para `softNew`. | `FILTER_LINEAR`, `FILTER_NEAREST` | Muestreo de texturas para `materialSetFilter`. | `TONEMAP_NEUTRAL`, `TONEMAP_ACES`, `TONEMAP_NONE` | Curvas de tono para `sceneSetTonemap`. | `SOUND_ERROR_INVALID`, `SOUND_REMOVE_HANDLE` | `-1`, lo que devuelve `soundPlay` cuando no hay ningún canal libre. | `SINGE_VERSION_MAJOR`, `SINGE_VERSION_MINOR`, `SINGE_VERSION_STRING`, `SINGE_FRAMEWORK_VERSION` | La versión del motor, como enteros, como cadena (`"v3.00"`) y como el número que devuelve `singeVersion()`. | `SINGE_DEAD_ZONE` | El `DEAD_ZONE` de `controls.cfg`, en unidades brutas de eje. | `SINGE_TRIGGER_THRESHOLD` | El umbral de los gatillos analógicos en esas mismas unidades brutas; `0` cuando los gatillos usan `SINGE_DEAD_ZONE`, que es lo predeterminado. | `SINGE_LEGACY_SPRITE_ARGS` | Verdadero cuando el juego ha pedido el orden de argumentos de sprites de 2.10. | `SINGE_DISC` | Verdadero cuando el juego tiene un laserdisc; falso cuando el lienzo es el mundo. | `SINGE_GAMEPAD_BASE`, `SINGE_GAMEPAD_STRIDE`, `SINGE_AXIS_STRIDE`, `SINGE_GAMEPAD_BUTTON_OFFSET`, `SINGE_MOUSE_BASE`, `SINGE_MOUSE_STRIDE`, `SINGE_MAX_CONTROLLERS`, `SINGE_MAX_MICE`, `SINGE_CONTROLLER_AXES`, `SINGE_CONTROLLER_BUTTONS` | Disposición de los códigos de entrada de los mandos y los ratones, y cuántos ejes y botones tiene un mando; `Framework.singe` construye con ellas las tablas `GAMEPAD_N` y `MOUSE_N`, y una pantalla de servicio recorre un mando con ellas. | `SINGE_SCALE_MIN`, `SINGE_SCALE_MAX`, `SINGE_SHIFT_MIN`, `SINGE_SHIFT_MAX`, `SINGE_ROTATE_STEP` | Lo que aceptan `vldpSetScale`, `vldpSetShift` y `vldpSetRotate`. Limita los valores a estas y no a tus propias copias: un desplazamiento fuera de rango termina el script. |=== `Framework.singe` añade las tablas `SCANCODE` y `MODIFIER` (los valores de teclas y modificadores de SDL), de `GAMEPAD_0` a `GAMEPAD_3`, de `MOUSE_0` a `MOUSE_3`, los índices `GAMEPAD_AXIS_*` para `controllerGetAxis`, y `DIR`. [#_included_libraries] === Bibliotecas incluidas Además de las bibliotecas estándar de Lua y de la API de Singe, las siguientes bibliotecas también están disponibles para los programas de Singe sin necesidad de instalar ningún software adicional: [cols="1,3",options="header"] |=== | Biblioteca | Uso | Copas | Red asíncrona | LuaFileSystem | Soporte ampliado del sistema de archivos | json.lua | Codificación y decodificación de JavaScript Object Notation | binaryheap | Estructura de datos de montículo binario | timerwheel | Planificación eficiente de temporizadores | LuaSec | Comunicación TLS / SSL | LuaSocket | TCP y UDP | LuaRS232 | Acceso al puerto serie RS232 | lsqlite3 | Bases de datos SQLite, `require("sqlite3")` | md5 | `require("md5")` para `sum`, `hexsum`, `exor`, `crypt` y `decrypt`; la biblioteca de PUC-Rio que incluye Hypseus, cuya función `sumhexa` se llama aquí `hexsum` | LuaBitOp | `require("bit")` para las operaciones a nivel de bits de Lua 5.1, que 5.4 tiene como operadores | lrandom | `require("random")` para un generador Mersenne Twister, independiente de `math.random` | Lua CJSON | `require("cjson")` para codificar y decodificar JSON, junto al módulo `json` de arriba | LPeg | `require("lpeg")` para gramáticas de análisis sintáctico por expresiones | middleclass | `require("middleclass")` para clases, si prefieres construir las entidades con ellas en lugar de con tablas | lume | `require("lume")` para las cosas pequeñas que necesita todo juego: `lerp`, `clamp`, `round`, `shuffle`, `split`, `serialize`, `weightedchoice` | inspect | `require("inspect")` para imprimir una tabla con una forma que una persona pueda leer, para cuando `debugPrint` es el depurador | bump | `require("bump")` para mover cosas que no deben atravesarse unas a otras -- responde dónde acaba algo que se mueve, cosa que <> no hacen a propósito | MobDebug | `require("mobdebug")`, el depurador con el que habla <>; `--debug` lo arranca, así que un juego no necesita ninguna línea propia | RmlUi | El modelo de objetos propio de la GUI, la variable global `rmlui` (véase <>) |=== Su uso queda fuera del alcance de este documento. Las bibliotecas md5, LuaBitOp, lrandom, Lua CJSON y LPeg están ahí porque Hypseus las expone, así que un juego escrito para Hypseus encuentra los mismos módulos con los mismos nombres; nada en Singe las necesita. [#audiosync] === Sincronización del audio Singe presenta el vídeo según el reloj del audio: un fotograma se muestra cuando se oye el sonido que le corresponde. El motor mide su propia ruta de audio al arrancar. SDL no puede informar de cuánto audio hay en cola entre el mezclador y el altavoz, así que Singe vacía el dispositivo una vez, antes de que suene nada audible, y cuenta la ráfaga de búferes que usa SDL para volver a llenarlo; esa ráfaga es la profundidad de la cola, y el reloj la resta. Ejecuta con `--program` para ver la medida en la traza (`Audio device queue: ...`). Lo que el motor no puede ver es todo lo que hay después del servidor de audio del sistema operativo: un DAC, un receptor HDMI, un enlace Bluetooth. Estos añaden un retardo fijo que varía según la máquina. Si las acciones en pantalla se adelantan visiblemente a su sonido, pasa los milisegundos que faltan con `--audiodelay=MS`, pon `AUDIO_DELAY = MS` en `games.dat`, o deja que el juego lo fije desde un menú de servicio con <>. Los valores positivos significan que el audio llega tarde; el vídeo se retrasa para igualarlo. Un valor negativo cubre el caso raro de que el audio llegue antes. [#menucalibration] ==== Calibrar desde el menú El menú incluido tiene una pantalla de calibración para ese retardo posterior. Pulsa la tecla asignada a `INPUT_SERVICE` (la tecla `9` por defecto) en la lista de juegos y elige Audio Delay en las herramientas de servicio (véase xref:Manual.adoc#servicetools[Las herramientas de servicio]). La pantalla es una página del documento del menú: cubre el menú de negro, muestra el retardo que se está probando y la cola del dispositivo que ha medido el motor, reproduce un clic una vez por segundo y se pone en blanco durante un fotograma en cada destello. Ajusta con izquierda y derecha (10 ms) y arriba y abajo (1 ms) hasta que el destello y el clic coincidan, y luego pulsa el botón 1 para guardar. Start lo pone a cero y el botón 2 (o de nuevo la tecla de servicio) cancela. El valor se guarda en `machine.cfg`, en la raíz de datos, y el motor lo aplica a todos los juegos de esa máquina, tanto si se lanzan desde el menú como desde la línea de comandos; se suma a cualquier `AUDIO_DELAY` por juego. Vuelve a calibrar después de cambiar de altavoces, auriculares o pantallas. La pantalla funciona porque el clic y el audio del disco comparten un dispositivo mezclador y una cola: el destello se programa a la cola medida más el valor candidato después del clic, así que cuando los dos coinciden el candidato es exactamente el retardo que el motor no puede ver. El retardo de la pantalla entra en la cuenta sin coste. Un juego puede leer los valores con <> y <>, u ofrecer su propia pantalla con <>. También importan dos propiedades del propio archivo de vídeo. El audio AAC lleva muestras de cebado del codificador que el contenedor debe indicar a los decodificadores que se salten; los archivos multiplexados sin esa información (un MP4 sin lista de edición, un archivo Matroska sin retardo de códec) reproducen su audio unos 20 milisegundos tarde, cosa que `AUDIO_DELAY` puede absorber. Y cuando el primer fotograma de vídeo tiene una marca de tiempo posterior a la primera muestra de audio, Singe respeta la sincronización del contenedor, así que el audio inicial no se pierde. [#formats] === Formatos de vídeo, audio y contenedor Singe decodifica el vídeo directamente con las bibliotecas de FFmpeg, usando el decodificador por hardware de la plataforma cuando ofrece uno para el códec (VA-API o VDPAU en Linux, D3D11VA en Windows, VideoToolbox en macOS y, en una placa ARM, los decodificadores V4L2 -- el de memoria a memoria, que en una Pi 4 cubre H.264, el de la Request API sin estado que usa una Pi 5 para HEVC, y el propio de Rockchip en un RK3588) y, si no, su propio decodificador por software; la traza del programa indica cuál, y `--softwarevideo` fuerza el software. Cualquier contenedor y códec que el FFmpeg incluido pueda demultiplexar y decodificar se reproduce: MP4, MKV, flujos de programa MPEG, AVI, AV1 y los clásicos flujos elementales `.m2v` de Daphne con un archivo de audio `.ogg` a juego al lado. Todas las pistas de audio del archivo están disponibles para `discSetAudioTrack` / `videoSetAudioTrack`; todas las pistas deben compartir un mismo formato de muestra, número de canales y frecuencia. Un laserdisc guardaba campos entrelazados, y una copia que los conservó muestra el efecto peine en cualquier pantalla progresiva. `--deinterlace` decide lo que pasa: `auto`, el valor por defecto, aplica bwdif a los fotogramas que el archivo marca como entrelazados y deja todo lo demás tal cual, así que un disco progresivo no paga nada; `on` es para un archivo cuyas marcas están mal; `off` nunca toca la imagen. Sale una imagen por cada una que entra -- un disco se direcciona por número de fotograma, así que un desentrelazador que convirtiera cada fotograma en dos campos movería todos los fotogramas del índice. La primera vez que se abre un vídeo, Singe lo indexa y guarda el índice junto a los demás datos del juego (`-.index`, con el hash hecho a partir del nombre completo del vídeo para que dos vídeos con el mismo nombre base mantengan índices separados). Indexar es una pasada por el archivo sin decodificar, unos segundos incluso para un disco de largometraje, y vuelve a ocurrir si el vídeo cambia. Cuando se carga un vídeo, Singe informa en la traza del programa (`--program`) del espaciado entre sus fotogramas clave, con un aviso si los fotogramas clave están separados más de dos segundos, porque un salto tiene que decodificar hacia delante desde el fotograma clave anterior. La decodificación ocurre en un hilo aparte, así que un salto lento muestra el fotograma anterior un poco más de tiempo en lugar de detener el juego. Los segmentos de un framefile se abren a medida que el disco llega a ellos, no todos a la vez: el que se está reproduciendo, el siguiente, y margen para que un salto se mueva entre dos. Un framefile largo puede tener cientos de segmentos -- un juego publicado tiene 213 -- y mantener un decodificador abierto para cada uno de ellos cuesta cientos de archivos abiertos sin ganar nada. Un framefile que nombra un archivo que no existe sigue avisando al arrancar. Para las imágenes de laserdisc las restricciones son la precisión de fotograma y la velocidad de salto, no la compresión. H.264 en MP4 o MKV con un intervalo de fotogramas clave corto (uno o dos segundos) salta rápido y se reproduce en todas las plataformas compatibles; los intervalos de fotogramas clave largos hacen que `discSearch` sea visiblemente lento porque el decodificador debe avanzar desde el fotograma clave anterior. La definición estándar (720x480 o 720x576) es el punto óptimo. Las fuentes de alta definición funcionan, pero cuestan proporcionalmente más CPU en hardware de la clase de la Raspberry Pi, y el overlay tiene por defecto la mitad de la resolución del vídeo, así que un vídeo sobredimensionado aporta poco. Los efectos de sonido y la música los lee primero SDL_mixer, y FFmpeg lee todo lo que este rechaza, así que entre los dos cubren: * WAV, AIFF, AU y VOC para audio sin comprimir. Los archivos WAV cortos sin comprimir dan la latencia más baja, y es lo que debería ser un disparo. * Ogg Vorbis, Opus, MP3, FLAC y WavPack. * AAC (`.m4a`, `.aac`), ALAC, AC-3, WMA, APE, TTA, Speex y AMR -- todo lo demás que decodifica el FFmpeg incluido. * Módulos de tracker mediante libxmp: MOD, S3M, XM e IT, y otros cincuenta y tantos, entre ellos 669, AMF, MED, OKT, PTM, STM y ULT. * Chiptunes mediante game-music-emu: NSF y NSFE, SPC, VGM, GBS, AY, GYM, HES, KSS y SAP. La forma VGZ, comprimida con gzip, no se lee; descomprímela antes a VGM. Un chiptune que no dice cuánto dura suena hasta quedarse en silencio, lo que puede no ocurrir nunca, así que se decodifica mientras suena en lugar de por adelantado. * MIDI, sintetizado a partir de una SoundFont. Véase <>. La línea `Audio:` de la cabecera de la traza (`--program` o `--trace`) enumera los decodificadores que lleva realmente la compilación. Las imágenes funcionan igual: primero SDL_image, y FFmpeg para el resto. Eso cubre PNG, JPEG, WebP, GIF, BMP, TGA, PCX, PNM, LBM, QOI, XCF, XPM y SVG desde SDL_image, y TIFF, OpenEXR, JPEG 2000, DDS, PSD, DPX, AVIF y HEIC desde FFmpeg. Un GIF o WEBP animado se carga como una animación de sprite; véase <>, que también acepta un tamaño para rasterizar un SVG. Tanto Radiance `.hdr` como OpenEXR funcionan como cielo (<>) y conservan todo su rango. Las fuentes las lee FreeType: TrueType, OpenType, WOFF, WOFF2, Type 1, BDF y PCF, con glifos en color (tanto de mapa de bits como vectoriales) y conformación de texto con HarfBuzz para el árabe, el hebreo y las escrituras índicas. [#reference] == Referencia de la API de Lua Este capítulo documenta la API de Lua que expone el motor Singe -- cada función a la que puede llamar el script de tu juego, y cada callback con el que Singe llamará a tu código. Está dirigido a desarrolladores que escriben juegos nuevos o que portan juegos con mucho vídeo desde otros motores. [#conventions] === Convenciones Cada entrada de la API que sigue tiene la misma estructura: * *Firma(s)* en un bloque de código, con todas las formas aceptadas. * *Descripción* de lo que hace la función y de sus efectos secundarios. * *Parámetros* enumerados en línea, o en una tabla cuando hay más de dos. * Línea *Devuelve*, que describe el valor que se devuelve a Lua (se omite cuando la función no devuelve nada). * *Notas*, si hay trampas que merezca la pena señalar. * *Desde* -- la versión de Singe en la que apareció la función por primera vez. * *Véase también* -- funciones relacionadas que probablemente usarás en el mismo punto de llamada. * *Ejemplo* -- un fragmento corto y realista con la forma del código de un juego de verdad. Una entrada que empieza por *Extensión de Hypseus* está ahí para que un juego escrito para Hypseus Singe funcione aquí sin cambios. Lo mismo vale para una forma de argumentos marcada como propia de Hypseus en una función que, por lo demás, acepta las de Singe. IMPORTANT: *No escribas juegos nuevos para la API de Hypseus.* Esas entradas existen por compatibilidad, no como recomendación. Varias de ellas responden un valor fijo porque lo que describen no existe en Singe, algunas se aceptan y se ignoran, y otras llevan escalas y órdenes de argumentos heredados de otro motor -- `videoGetVolume()` cuenta hasta `64` donde el resto de Singe cuenta hasta `100`, y los nombres `spriteAnim` toman el sprite en último lugar donde todas las llamadas de sprites de Singe lo toman primero. Cada entrada nombra el equivalente de Singe que hay que usar en su lugar. El código nuevo que se ciñe a la API de Singe es más corto, coherente consigo mismo, y no se llevará sorpresas cuando una respuesta de compatibilidad no cambie. Unas cuantas reglas se aplican a toda la API: * Los *colores* son de 8 bits por canal: rojo, verde, azul y opacidad son cada uno enteros de `0` a `255`. * Las *coordenadas del overlay* están en la resolución actual del overlay (fijada con `overlaySetResolution`), no en píxeles de pantalla. La resolución predeterminada del overlay es la mitad de la resolución del vídeo en cada dimensión; llama a `overlaySetResolution` para cambiarla. * Los *handles* que devuelven las funciones `*Load` (sprites, sonidos, fuentes, vídeos) son enteros opacos. Devuélvelos sin cambios a las demás funciones de ese espacio de nombres. No hagas cuentas con ellos. * *Los argumentos no válidos abortan el script.* Si se llama a una función con un número de argumentos incorrecto, con tipos incorrectos o con un handle que ya se ha descargado, Singe termina el script en ejecución con un error -- no devuelve `nil` ni un código de fallo. Valida la entrada del usuario en tu script antes de pasársela a la API. * *Hay que dibujar desde `onOverlayUpdate`.* Las llamadas a cualquier función `overlay*`, `spriteDraw`, `videoDraw` o `fontPrint` desde otros callbacks no se reflejarán en pantalla y pueden corromper el overlay. Esta es la regla que pilla a todo desarrollador nuevo de Singe. [#animation] === Animación Estas llamadas controlan las animaciones que lleva un modelo, instancia por instancia. Todas toman el *nodo raíz* que devuelve `modelInstance`; otros nodos provocan un error, salvo en `animationIsPlaying`, que responde `false`. Las animaciones se numeran desde `1` en el orden en que las enumera `modelGetAnimations`, o se nombran como las nombra el archivo. Un clip se reproduce en la capa base, y hasta cuatro más pueden reproducirse en las capas `1` a `4` por encima de ella; las llamadas de tiempo, pausa, reanudación y salto actúan solo sobre la capa base. El tiempo va en segundos, avanza con el reloj como mucho una décima de segundo por fotograma, y se detiene mientras el juego está en pausa. Se controlan la animación de nodos, el skinning y los pesos de los morph targets. Véase el apartado Animación de <>. [#animationgettime] ==== animationGetTime [source,text] ---- seconds = animationGetTime(node) ---- El tiempo actual dentro del clip de la capa base, en reproducción o en pausa, en los segundos propios del clip (un `speed` de `2` lo hace avanzar el doble de rápido). Un clip en bucle vuelve a `0` al llegar al final; uno sin bucle se detiene en su duración. Si aún no se ha reproducido nada, da `0`. Los clips de las capas superiores no se informan. Un nodo que no es raíz de instancia termina el script. *Devuelve:* Los segundos, como número. *Desde:* 3.00. *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- Reproduce el paso una vez cada vez que el ciclo de andar pasa por sus dos puntos de contacto. function onOverlayUpdate() local t = animationGetTime(hero) if (lastStepTime < 0.25 and t >= 0.25) or (lastStepTime < 0.75 and t >= 0.75) then soundPlay(footstep) end lastStepTime = t return OVERLAY_NOT_UPDATED end ---- [#animationisplaying] ==== animationIsPlaying [source,text] ---- playing = animationIsPlaying(node) ---- Si el clip de la capa base está avanzando en esta instancia. Un clip sin bucle deja de reproducirse cuando llega a su final, y tanto `animationPause` como `animationStop` hacen que esto sea `false`; un clip en bucle sigue siendo `true` hasta que se detiene. A diferencia de las demás llamadas de aquí, un nodo que no es raíz de instancia no termina el script, sino que responde `false`. *Devuelve:* Booleano. *Desde:* 3.00. *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- Vuelve a la espera cuando el ataque de una sola vez ha terminado. function onOverlayUpdate() if attacking and not animationIsPlaying(hero) then attacking = false animationPlay(hero, "Idle", true, 1, 0.2) end return OVERLAY_NOT_UPDATED end ---- [#animationpause] ==== animationPause [source,text] ---- animationPause(node) ---- Congela el clip de la capa base en su tiempo actual; los nodos mantienen esa pose hasta que `animationResume` lo continúa o se reproduce otro clip. Los clips de las capas superiores siguen en marcha. Pausar una instancia que no tiene nada en reproducción no hace daño. Un nodo que no es raíz de instancia termina el script. *Desde:* 3.00. *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- Una escena cinemática congela a la multitud mientras la cámara se mueve. function beginCutscene() for _, npc in ipairs(crowd) do animationPause(npc) end cameraSet(cutsceneCamera) end ---- [#animationplay] ==== animationPlay [source,text] ---- animationPlay(node, nameOrIndex) animationPlay(node, nameOrIndex, loop) animationPlay(node, nameOrIndex, loop, speed) animationPlay(node, nameOrIndex, loop, speed, fade) ---- Empieza desde el principio uno de los clips del modelo en la capa base de esta instancia, sustituyendo lo que se estuviera reproduciendo ahí; la pose se actualiza en el acto. Con un `fade`, el clip antiguo sigue en marcha mientras se desvanece durante ese número de segundos, y cuando no se estaba reproduciendo nada el fundido parte de la pose en la que están ahora los nodos (un ragdoll que se acaba de desactivar, una pose puesta por el script), y los nodos que el clip nuevo no anima vuelven suavemente a la pose de reposo del modelo. Un nodo que no es raíz de instancia, un nombre que el modelo no tiene o un número fuera del intervalo de `1` al número de clips termina el script. *Parámetros:* * `node` -- el nodo raíz de la instancia, de `modelInstance`. * `nameOrIndex` -- un nombre de clip tal como lo guarda el archivo, o su número desde `1` en el orden de `modelGetAnimations`. * `loop` -- booleano, por defecto `true`. Con `false` el clip se detiene en su último fotograma clave y `animationIsPlaying` pasa a ser `false`. * `speed` -- velocidad de reproducción, por defecto `1`. `2` es el doble de rápido; `0.5`, la mitad. * `fade` -- segundos de fundido cruzado desde lo que se estaba reproduciendo, por defecto `0` (un corte seco). *Desde:* 3.00. *Véase también:* <>, <>, <>, <> .Ejemplo [source,lua] ---- -- Un guardia que espera quieto hasta que ve al jugador, y entonces corre. guardModel = modelLoad(DIR .. "Models/Guard.glb") guard = modelInstance(guardModel) animationPlay(guard, "Idle") function onSpotted() animationPlay(guard, "Run", true, 1.3, 0.4) end ---- [#animationplaylayer] ==== animationPlayLayer [source,text] ---- animationPlayLayer(node, layer, nameOrIndex) animationPlayLayer(node, layer, nameOrIndex, loop) animationPlayLayer(node, layer, nameOrIndex, loop, speed) animationPlayLayer(node, layer, nameOrIndex, loop, speed, fade) ---- Reproduce un clip en una de las cuatro capas por encima de la base, mezclado sobre lo que haya debajo según el peso de la capa (`animationSetLayerWeight`, por defecto `1`) y limitado a una parte del esqueleto cuando hay una máscara (`animationSetLayerMask`). Las capas más altas se mezclan sobre las más bajas; cada capa lleva su propio clip, tiempo y bucle, independientes de los de la base. `nameOrIndex`, `loop`, `speed` y `fade` significan lo mismo que en `animationPlay`, con una diferencia: un `fade` en una capa que estaba vacía sube el peso de la capa desde `0` en lugar de hacer un fundido cruzado desde una pose. Una capa fuera del intervalo de `1` a `4`, un nodo que no es raíz de instancia o un clip que el modelo no tiene termina el script. *Parámetros:* * `node` -- el nodo raíz de la instancia. * `layer` -- de `1` a `4`. * `nameOrIndex` -- un nombre o número de clip, como en `animationPlay`. * `loop` -- por defecto `true`. * `speed` -- por defecto `1`. * `fade` -- segundos, por defecto `0`. *Desde:* 3.00. *Véase también:* <>, <>, <>, <> .Ejemplo [source,lua] ---- -- Camina con todo el cuerpo y mira alrededor solo con la cabeza. animationPlay(fox, "Walk", true) animationPlayLayer(fox, 1, "Survey", true, 1, 0.5) animationSetLayerMask(fox, 1, nodeFind("b_Neck_04", fox)) -- Más tarde, saluda con un brazo por encima de lo que estén haciendo las piernas. function onGreet() animationPlayLayer(fox, 2, "Wave", false, 1, 0.2) end ---- [#animationresume] ==== animationResume [source,text] ---- animationResume(node) ---- Continúa el clip de la capa base desde donde lo congeló `animationPause`, o desde donde lo haya movido entretanto `animationSetTime`. Reanudar un clip que ya se está reproduciendo no cambia nada. Una instancia sin clip en su capa base (nunca reproducido, o detenido) termina el script, igual que un nodo que no es raíz de instancia. *Desde:* 3.00. *Véase también:* <>, <> .Ejemplo [source,lua] ---- function endCutscene() for _, npc in ipairs(crowd) do animationResume(npc) end cameraSet(gameCamera) end ---- [#animationsetlayermask] ==== animationSetLayerMask [source,text] ---- animationSetLayerMask(node, layer, maskNode) animationSetLayerMask(node, layer) ---- Limita una capa a `maskNode` y a todo lo que hay debajo de él, de modo que el clip de esa capa solo mueve esa parte del modelo y el resto sigue a las capas de debajo. `maskNode` debe ser uno de los nodos de la propia instancia, encontrado con `nodeFind(name, node)`; la raíz de la instancia en sí no cuenta. Omitir `maskNode`, o pasar `nil`, quita el límite. La máscara se queda en la capa a lo largo de las llamadas posteriores a `animationPlayLayer`. Una capa fuera del intervalo de `1` a `4` termina el script, y también un nodo que no es raíz de instancia o un `maskNode` que no forma parte de esa instancia. *Parámetros:* * `node` -- el nodo raíz de la instancia. * `layer` -- de `1` a `4`. * `maskNode` -- una articulación o un nodo de la instancia; omitido o `nil` para el modelo entero. *Desde:* 3.00. *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Apunta al objetivo con la parte superior del cuerpo mientras las piernas siguen andando. spine = nodeFind("b_Spine02_03", hero) animationSetLayerMask(hero, 1, spine) animationPlayLayer(hero, 1, "AimForward", true) function onHolster() animationStop(hero, 1) animationSetLayerMask(hero, 1) end ---- [#animationsetlayerweight] ==== animationSetLayerWeight [source,text] ---- animationSetLayerWeight(node, layer, weight) ---- Cuánto se ve el clip de una capa sobre lo que hay debajo, de `0` (nada) a `1` (del todo, el valor por defecto); los valores fuera de ese intervalo se recortan. El peso pertenece a la capa, no al clip, así que sobrevive a `animationPlayLayer` y `animationStop` en esa capa. Cámbialo un poco en cada fotograma para hacer aparecer o desaparecer una capa a mano, o deja que lo haga una vez un `fade` en `animationPlayLayer`. Una capa fuera del intervalo de `1` a `4` o un nodo que no es raíz de instancia termina el script. *Desde:* 3.00. *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- La cojera empeora a medida que baja la salud. function onHealthChanged(health) local limp = 1 - health / maxHealth animationSetLayerWeight(hero, 1, limp) end ---- [#animationsettime] ==== animationSetTime [source,text] ---- animationSetTime(node, seconds) ---- Lleva el clip de la capa base a un tiempo en sus propios segundos; los valores negativos se convierten en `0`. La pose se actualiza en el acto, se esté reproduciendo o no el clip, así que una instancia en pausa se puede llevar hasta un fotograma exacto, y una en reproducción continúa desde el nuevo tiempo. Cualquier fundido cruzado en curso en la capa base se corta. Una instancia sin clip en su capa base termina el script, igual que un nodo que no es raíz de instancia. *Desde:* 3.00. *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Una puerta cuyo clip de apertura sigue a una palanca que arrastra el jugador. animationPlay(door, "Open", false) animationPause(door) function onLeverMoved(fraction) animationSetTime(door, fraction * doorClipLength) end ---- [#animationstop] ==== animationStop [source,text] ---- animationStop(node) animationStop(node, layer) ---- Detiene y olvida todos los clips de la instancia, o solo el clip de una capa por encima de la base (de `1` a `4`); la capa base sola no se puede detener sin las capas que tiene encima. Los nodos conservan su última pose, de la que sale suavemente un `animationPlay` posterior con `fade`. Después de detenerlo todo, `animationResume` y `animationSetTime` terminan el script hasta que se vuelva a reproducir algo. Los pesos y las máscaras de las capas se conservan. Una capa fuera del intervalo de `1` a `4` o un nodo que no es raíz de instancia termina el script. *Desde:* 3.00. *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Pasa el cuerpo al ragdoll de la física: congela la pose y luego deja que caiga. function onKilled() animationStop(hero) ragdollActivate(hero) end -- El saludo de la capa 2 ha terminado; el paso de debajo continúa. function onWaveFinished() animationStop(hero, 2) end ---- [#bezelref] === Bezel La ilustración de la recreativa y el marcador arcade que la acompaña. `mainBezelLoaded` pregunta por la ilustración que ha cargado `--bezel`; la familia `scoreBezel` controla el panel de puntuación, un documento RmlUi que el motor incluye y que un juego puede sustituir por su propia copia, `scoreBezel.rml` junto a su script. Todas las llamadas de aquí son extensiones de Hypseus Singe, que se mantienen para que sus juegos funcionen sin cambios, y todas se describen desde el lado del jugador en xref:Manual.adoc#bezels[Bezels]. El panel empieza apagado, como en Hypseus, y solo un juego lo enciende. Los valores que se le dan se recuerdan tanto si se muestra como si no, así que un juego puede fijarlos antes de activar el panel. Cada indicador tiene el número de dígitos que tiene el marcador de Hypseus -- seis para una puntuación, dos para los créditos, uno para un recuento de vidas -- y un valor se muestra en ellos alineado a la derecha y con ceros a la izquierda; una puntuación de más de seis dígitos muestra los seis últimos. También se entienden los valores negativos de Hypseus: `-2` enciende un guion en cada dígito, `-4` una `H` y `-5` una `A`, y `-1`, `-3` y cualquier otro número negativo no encienden nada. [#mainbezelloaded] ==== mainBezelLoaded [source,text] ---- loaded, id = mainBezelLoaded() ---- Extensión de Hypseus. Si `--bezel` ha cargado una ilustración de recreativa, y el identificador del archivo que cargó. El identificador es el hash FNV-1a de 64 bits de los bytes del archivo escrito como cadena decimal, que es lo que informa Hypseus, así que un juego que reconoce un bezel concreto por su número reconoce aquí el mismo archivo. Sin ninguna ilustración cargada, las respuestas son `false` y `"0"`. *Devuelve:* booleano, y el identificador como cadena. *Desde:* 3.00, como extensión de Hypseus Singe. *Véase también:* <>, xref:Manual.adoc#bezels[Bezels] .Ejemplo [source,lua] ---- -- Dibuja el marco de sprites solo cuando el jugador no tiene una ilustración propia. local loaded, id = mainBezelLoaded() if not loaded then spriteDraw(frameSprite, 0, 0) end ---- [#scorebezelclear] ==== scoreBezelClear [source,text] ---- scoreBezelClear() ---- Extensión de Hypseus. Deja en blanco todos los indicadores del panel de puntuación: los créditos, las dos puntuaciones y los dos recuentos de vidas no muestran nada en absoluto, y solo quedan los dígitos tenues de detrás. Los valores no se olvidan, pero el siguiente `scoreBezelCredits`, `scoreBezelScore` o `scoreBezelLives` vuelve a encender el panel con lo que tenga en ese momento. Se puede llamar sin peligro con el panel apagado. *Desde:* 3.00, como extensión de Hypseus Singe. *Véase también:* <> [#scorebezelcredits] ==== scoreBezelCredits [source,text] ---- scoreBezelCredits(credits) ---- Extensión de Hypseus. Fija el indicador de créditos, que tiene dos dígitos. También enciende un panel que `scoreBezelClear` dejó en blanco. *Parámetros:* * `credits` -- entero; se muestran los dos últimos dígitos. *Desde:* 3.00, como extensión de Hypseus Singe. *Véase también:* <> [#scorebezelenable] ==== scoreBezelEnable [source,text] ---- scoreBezelEnable(enabled) scoreBezelEnable(enabled, type) ---- Extensión de Hypseus. Muestra u oculta el panel de puntuación. El primer `true` lo construye a partir del `scoreBezel.rml` propio del juego, junto a su script, si lo hay, o si no a partir de `Singe/scoreBezel.rml`; ocultarlo y volver a mostrarlo después no cuesta nada y conserva sus valores. Empieza oculto, que es también como viene en Hypseus, así que un juego que nunca llama a esto se comporta exactamente como antes -- y debería preguntar a <> antes de dar por hecho que tiene uno. El `type` de Hypseus elige entre sus cinco implementaciones de marcador. Singe tiene una, un documento RmlUi, así que el argumento se comprueba y se ignora. Una máquina sin GPU no tiene ninguna GUI; allí el panel no se puede construir, `scoreBezelGetState` sigue respondiendo `false` y se imprime un aviso una sola vez. Un primer argumento que no sea booleano aborta el script. *Parámetros:* * `enabled` -- booleano. * `type` -- entero; se acepta y se ignora. *Desde:* 3.00, como extensión de Hypseus Singe. *Véase también:* <>, xref:Manual.adoc#bezels[Bezels] .Ejemplo [source,lua] ---- -- El panel del motor cuando lo hay, sprites cuando no. scoreBezelEnable(true, 0) if scoreBezelGetState() then scoreBezelTwinScoreOn(twoPlayer) scoreBezelCredits(credits) else drawSpriteScoreboard() end ---- [#scorebezelgetstate] ==== scoreBezelGetState [source,text] ---- state = scoreBezelGetState() ---- Extensión de Hypseus. Si el panel de puntuación se está mostrando. Todos los juegos de Hypseus preguntan esto antes de usar el panel y dibujan su propio marcador con sprites cuando la respuesta es `false`, que es la alternativa correcta y no un fallo. *Devuelve:* booleano. *Desde:* 3.00, como extensión de Hypseus Singe. *Véase también:* <> [#scorebezellives] ==== scoreBezelLives [source,text] ---- scoreBezelLives(player, lives) ---- Extensión de Hypseus. Fija el indicador de vidas de un jugador, que tiene un dígito. `player` es `1` o `2`; cualquier otro número se ignora, y también `2` hasta que <> haya puesto en el panel los indicadores del segundo jugador. También enciende un panel que `scoreBezelClear` dejó en blanco. *Parámetros:* * `player` -- `1` o `2`. * `lives` -- entero; se muestra el último dígito. *Desde:* 3.00, como extensión de Hypseus Singe. *Véase también:* <> [#scorebezelscore] ==== scoreBezelScore [source,text] ---- scoreBezelScore(player, score) ---- Extensión de Hypseus. Fija el indicador de puntuación de un jugador, que tiene seis dígitos. `player` es `1` o `2`, con la misma regla para `2` que en `scoreBezelLives`. También enciende un panel que `scoreBezelClear` dejó en blanco. *Parámetros:* * `player` -- `1` o `2`. * `score` -- entero; se muestran los seis últimos dígitos. *Desde:* 3.00, como extensión de Hypseus Singe. *Véase también:* <> .Ejemplo [source,lua] ---- function addScore(points) score = score + points if scoreBezelGetState() then scoreBezelScore(1, score) end end ---- [#scorebezeltwinscoreon] ==== scoreBezelTwinScoreOn [source,text] ---- scoreBezelTwinScoreOn(enabled) ---- Extensión de Hypseus. Pone en el panel los indicadores de puntuación y de vidas del segundo jugador, o los vuelve a quitar y deja el sitio a los otros dos. Apagarlos también deja el panel en blanco, exactamente como en Hypseus, así que fija después un valor si los indicadores del primer jugador deben seguir encendidos. Mientras están apagados, `scoreBezelScore(2, ...)` y `scoreBezelLives(2, ...)` no hacen nada. Un argumento que no sea booleano aborta el script. *Parámetros:* * `enabled` -- booleano. *Desde:* 3.00, como extensión de Hypseus Singe. *Véase también:* <> [#body] === Cuerpo Un cuerpo da a un nodo de la escena un lugar en el mundo físico. Hay un cuerpo por nodo, al que se llega por el handle del nodo: un cuerpo estático se queda donde estaba el nodo cuando se creó el cuerpo, un cuerpo cinemático sigue a su nodo en cada paso y empuja sin que lo empujen, y un cuerpo dinámico lo mueve la simulación, y él mueve su nodo. Los tamaños, las posiciones y las velocidades van en unidades del mundo (metros en una escena 3D, píxeles del overlay en un mundo 2D), las masas en kilogramos y el tiempo en segundos. Todas las llamadas de aquí terminan el script cuando el nodo no tiene cuerpo; una llamada que solo tiene sentido para una clase de cuerpo (una fuerza sobre un cuerpo estático, por ejemplo) se ignora en las demás, salvo `bodySetMass`, que da un error. Véase <> en Desarrollo de juegos para ver cómo encajan los cuerpos, los nodos y el fotograma. [#bodynew] ==== bodyNew [source,text] ---- bodyNew(node, type, shape) bodyNew(node, type, shape, a) bodyNew(node, type, shape, a, b) bodyNew(node, type, shape, a, b, c) ---- Da al nodo un cuerpo del tipo indicado, colocado donde está el nodo en el mundo en este momento (con sus padres incluidos), con una forma de colisión de las medidas `a`, `b` y `c` en unidades del mundo y escalada por la escala del nodo en el mundo. Una segunda llamada sobre el mismo nodo sustituye a la primera, y un nodo que tenía un jugador lo pierde. La masa de un cuerpo dinámico sale del volumen de su forma con la densidad del agua, así que usa `bodySetMass` en todo lo que deba sentirse ligero. Cambiar más tarde la escala del nodo no cambia el tamaño del cuerpo. Termina el script con un tipo o una forma desconocidos, cuando la física no está disponible en esta máquina, cuando el mundo ya tiene `4096` cuerpos o cuando no se puede construir la forma (un `SHAPE_MESH` dinámico, o una envolvente sin malla bajo el nodo). *Parámetros:* * `type` -- `BODY_STATIC`, `BODY_DYNAMIC` o `BODY_KINEMATIC`. * `shape` -- `SHAPE_BOX` (`a`, `b`, `c` son anchura, altura y profundidad), `SHAPE_SPHERE` (`a` es el radio), `SHAPE_CAPSULE` o `SHAPE_CYLINDER` (`a` es el radio, `b` la altura total a lo largo de Y, con los extremos incluidos), `SHAPE_HULL` (una envolvente convexa de la malla del nodo y de todo lo que hay bajo él, sin medidas) o `SHAPE_MESH` (los triángulos exactos, solo para cuerpos estáticos y cinemáticos; una malla de mapa de alturas se convierte en un campo de alturas). * `a`, `b`, `c` -- las medidas que necesita la forma; las que faltan son `0`, y cualquier medida se eleva como mínimo a `0.001`. *Notas:* Una esfera se escala por el eje de escala más grande del nodo, y el radio de una cápsula o de un cilindro por el mayor de X y Z. Una cápsula más corta que el doble de su radio es una esfera con una lámina en medio. *Desde:* 3.00. *Véase también:* <>, <>, <>, <> .Ejemplo [source,lua] ---- -- Una caja ligera y un modelo de pato como envolvente, soltados sobre el suelo. crate = nodeNew() nodeSetMesh(crate, meshBox(0.6, 0.6, 0.6), wood) nodeSetPosition(crate, -1, 1, 0) bodyNew(crate, BODY_DYNAMIC, SHAPE_BOX, 0.6, 0.6, 0.6) bodySetMass(crate, 5) duck = modelInstance(modelLoad(DIR .. "Models/Duck.glb")) nodeSetPosition(duck, 3, 2, 0) bodyNew(duck, BODY_DYNAMIC, SHAPE_HULL) ---- [#bodyapplyforce] ==== bodyApplyForce [source,text] ---- bodyApplyForce(node, fx, fy, fz) bodyApplyForce(node, fx, fy, fz, px, py, pz) ---- Añade una fuerza, en newtons, a un cuerpo dinámico solo durante los pasos de este fotograma; llámala en cada fotograma para seguir empujando. Sin un punto, la fuerza actúa a través del centro de masas y solo desplaza el cuerpo; con un punto del mundo `(px, py, pz)` también lo hace girar alrededor de su centro. Una fuerza acelera según la fuerza dividida entre la masa, así que ajústala a `bodySetMass`. Los cuerpos estáticos y cinemáticos la ignoran. *Parámetros:* * `fx`, `fy`, `fz` -- la fuerza en newtons a lo largo de cada eje del mundo. * `px`, `py`, `pz` -- punto del mundo opcional en el que actúa la fuerza; omítelo para el centro de masas. *Desde:* 3.00. *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- Un ventilador empuja el globo por la habitación mientras el interruptor está encendido. function onOverlayUpdate() if fanOn then local bx, by, bz = nodeGetPosition(balloon) if bx < fanReach then bodyApplyForce(balloon, 2.5, 0.4, 0) end end return OVERLAY_NOT_UPDATED end ---- [#bodyapplyimpulse] ==== bodyApplyImpulse [source,text] ---- bodyApplyImpulse(node, ix, iy, iz) bodyApplyImpulse(node, ix, iy, iz, px, py, pz) ---- Cambia de golpe la cantidad de movimiento de un cuerpo dinámico: un impulso de masa por velocidad añade esa velocidad. Sin un punto actúa a través del centro de masas; con un punto del mundo `(px, py, pz)` también hace girar el cuerpo, así que golpear una caja cerca de su parte superior la vuelca. Esta es la llamada para un golpe, una patada, una explosión o un lanzamiento, mientras que `bodyApplyForce` es para un empuje que dura. Los cuerpos estáticos y cinemáticos lo ignoran. *Parámetros:* * `ix`, `iy`, `iz` -- el impulso en kilogramos-metro por segundo a lo largo de cada eje del mundo. * `px`, `py`, `pz` -- punto del mundo opcional en el que actúa el impulso; omítelo para el centro de masas. *Desde:* 3.00. *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Lanza hacia arriba lo que haya bajo el ratón, en el punto donde se ha tocado. local mx, my = mouseGetPosition(0) local ox, oy, oz = sceneUnproject(mx, my, 0) local fx, fy, fz = sceneUnproject(mx, my, 10) local hit, hx, hy, hz = physicsRaycast(ox, oy, oz, fx - ox, fy - oy, fz - oz) if hit and hit ~= floor then bodyApplyImpulse(hit, 0, 3, 0, hx, hy, hz) end ---- [#bodydelete] ==== bodyDelete [source,text] ---- bodyDelete(node) ---- Quita el cuerpo del mundo; el nodo y su malla se quedan donde están. Todas las articulaciones unidas al cuerpo, y cualquier vehículo construido sobre él, se van con él. Borrar el nodo también quita su cuerpo, así que esto solo hace falta para conservar un nodo sin su cuerpo. Termina el script cuando el nodo no tiene cuerpo. *Desde:* 3.00. *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Una moneda recogida deja de ser sólida y sube flotando como un simple nodo. function onTrigger(trigger, other, entered) if trigger == coin and other == hero and entered then bodyDelete(coin) collected = collected + 1 coinRising = true end end ---- [#bodygetangularvelocity] ==== bodyGetAngularVelocity [source,text] ---- x, y, z = bodyGetAngularVelocity(node) ---- A qué velocidad gira el cuerpo alrededor de cada eje del mundo, en radianes por segundo, según el último paso. Un cuerpo estático informa de cero. Útil para el tono del sonido de una rueda o para un contador de vueltas. *Devuelve:* Tres números, radianes por segundo alrededor de X, Y y Z. *Desde:* 3.00. *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- La ruleta se ha parado cuando apenas gira. function onOverlayUpdate() local _, spin = bodyGetAngularVelocity(wheel) if spinning and math.abs(spin) < 0.05 then spinning = false announceWinner(pocketUnder(ball)) end return OVERLAY_NOT_UPDATED end ---- [#bodygetvelocity] ==== bodyGetVelocity [source,text] ---- x, y, z = bodyGetVelocity(node) ---- A qué velocidad se mueve el cuerpo a lo largo de cada eje del mundo, en unidades del mundo por segundo, según el último paso. Un cuerpo estático informa de cero; un cuerpo cinemático informa de la velocidad que implicaba el movimiento de su nodo. *Devuelve:* Tres números, unidades por segundo a lo largo de X, Y y Z. *Desde:* 3.00. *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- Reproduce un silbido cuando la pelota se mueve de verdad. function onOverlayUpdate() local vx, vy, vz = bodyGetVelocity(ball) local speed = math.sqrt(vx * vx + vy * vy + vz * vz) if speed > 8 and not whooshing then soundPlay(whoosh) whooshing = true elseif speed < 2 then whooshing = false end return OVERLAY_NOT_UPDATED end ---- [#bodyisresting] ==== bodyIsResting [source,text] ---- resting = bodyIsResting(node) ---- Si la simulación ha dormido el cuerpo porque ha dejado de moverse. Los cuerpos estáticos y los cuerpos sacados del mundo con `bodySetEnabled` cuentan como en reposo. Un cuerpo en reposo se despierta solo cuando algo lo toca o cuando se le aplica una velocidad, una fuerza o un impulso. *Devuelve:* Booleano. *Desde:* 3.00. *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- La ronda termina cuando todos los bolos se han asentado. function onOverlayUpdate() local settled = true for _, pin in ipairs(pins) do settled = settled and bodyIsResting(pin) end if settled then scoreFrame() end return OVERLAY_NOT_UPDATED end ---- [#bodysetangularvelocity] ==== bodySetAngularVelocity [source,text] ---- bodySetAngularVelocity(node, x, y, z) ---- Fija el giro alrededor de cada eje del mundo, en radianes por segundo, y despierta el cuerpo. Los cuerpos estáticos lo ignoran. El giro de un cuerpo cinemático se sobrescribe a partir de su nodo en el siguiente paso, así que la llamada solo perdura en los cuerpos dinámicos. *Desde:* 3.00. *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- Lanza la moneda: hacia arriba, y dando vueltas sobre sí misma. function tossCoin() nodeSetPosition(coin, 0, 1.2, 0) bodySetVelocity(coin, 0, 4, 0) bodySetAngularVelocity(coin, 25, 0, 0) tossed = true end ---- [#bodysetbounce] ==== bodySetBounce [source,text] ---- bodySetBounce(node, bounce) ---- Fija la restitución del cuerpo, de `0` (se para en seco) a `1` (rebota con todo lo que traía); los valores fuera de ese intervalo se recortan. El valor por defecto es `0.1`. Los valores de los dos cuerpos que se tocan se combinan, así que una pelota viva sobre un suelo muerto sigue rebotando. *Desde:* 3.00. *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- Una pelota de goma que cae en la escena. ball = nodeNew() nodeSetMesh(ball, meshSphere(0.3, 32), blue) nodeSetPosition(ball, 0, 3, 0) bodyNew(ball, BODY_DYNAMIC, SHAPE_SPHERE, 0.3) bodySetMass(ball, 0.5) bodySetBounce(ball, 0.7) ---- [#bodysetbuoyancy] ==== bodySetBuoyancy [source,text] ---- bodySetBuoyancy(node, factor) ---- Cómo flota el cuerpo cuando está dentro de un volumen de agua: `1` es neutro, más flota más alto, menos se hunde, y `0` es una piedra. El valor por defecto es `1.2`, que flota suavemente. El factor multiplica la densidad propia del agua dada con `bodySetWater`, y los valores negativos se recortan a `0`. El agua solo mueve los cuerpos dinámicos, pero el valor se puede fijar en cualquier cuerpo. *Desde:* 3.00. *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- Una caja que flota alto, un ancla que se hunde, una pelota que cabecea. bodyNew(crate, BODY_DYNAMIC, SHAPE_BOX, 0.8, 0.8, 0.8) bodySetMass(crate, 20) bodySetBuoyancy(crate, 1.6) bodyNew(anchor, BODY_DYNAMIC, SHAPE_BOX, 0.5, 0.5, 0.5) bodySetMass(anchor, 200) bodySetBuoyancy(anchor, 0.3) bodyNew(ball, BODY_DYNAMIC, SHAPE_SPHERE, 0.4) bodySetBuoyancy(ball, 2.0) ---- [#bodysetcurrent] ==== bodySetCurrent [source,text] ---- bodySetCurrent(node, x, y, z) ---- Fija el flujo dentro de un volumen de agua, en unidades del mundo por segundo a lo largo de cada eje del mundo; los cuerpos dinámicos que flotan en él derivan con el flujo, y un jugador que nada se deja llevar por él. Por defecto no hay flujo. Fíjalo en el nodo sobre el que se llamó a `bodySetWater`; en cualquier otro cuerpo se guarda y no hace nada. *Desde:* 3.00. *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- Un río que arrastra corriente abajo todo lo que cae en él. river = nodeNew() nodeSetMesh(river, meshBox(6, 3, 12), water) nodeSetPosition(river, 10, -1.5, 0) bodyNew(river, BODY_STATIC, SHAPE_BOX, 6, 3, 12) bodySetWater(river, 1.0, 0.8, 0.2) bodySetCurrent(river, 0, 0, 2.5) ---- [#bodysetenabled] ==== bodySetEnabled [source,text] ---- bodySetEnabled(node, enabled) ---- Saca el cuerpo del mundo con `false`: deja de chocar, deja de moverse, ya no mueve ni sigue a su nodo, y cuenta como en reposo. `true` lo vuelve a poner donde está, despierto. El cuerpo conserva su forma, su masa y sus ajustes, así que esto es más barato que borrarlo y volver a crearlo para algo que va y viene. *Desde:* 3.00. *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- El puente levadizo solo es sólido mientras está bajado. function lowerBridge() animationPlay(bridge, "Lower", false) bodySetEnabled(bridgeDeck, true) end function raiseBridge() animationPlay(bridge, "Raise", false) bodySetEnabled(bridgeDeck, false) end ---- [#bodysetfriction] ==== bodySetFriction [source,text] ---- bodySetFriction(node, friction) ---- Fija la fricción de la superficie: `0` es hielo, `0.5` (el valor por defecto) es lo normal, y los valores más altos agarran más. Los valores negativos se recortan a `0`. Los valores de los dos cuerpos que se tocan se combinan, así que un suelo resbaladizo necesita un valor bajo en el suelo, no en todo lo que camina sobre él. *Desde:* 3.00. *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- Un estanque helado en mitad del nivel. pond = nodeNew() nodeSetMesh(pond, meshBox(6, 0.1, 6), ice) nodeSetPosition(pond, 8, -1.95, 0) bodyNew(pond, BODY_STATIC, SHAPE_BOX, 6, 0.1, 6) bodySetFriction(pond, 0.02) ---- [#bodysetmass] ==== bodySetMass [source,text] ---- bodySetMass(node, kilograms) ---- Cambia la masa de un cuerpo dinámico y, con ella, su inercia. Hazlo con todo lo que deba sentirse ligero o pesado: el valor por defecto es el volumen de la forma con la densidad del agua, así que una caja del tamaño de una puerta pesa unos 140 kilogramos y un empujoncito no la mueve. Las fuerzas y los impulsos escalan con la masa, así que fija la masa antes de ajustarlos. Termina el script cuando el cuerpo no es dinámico o la masa no es positiva. *Desde:* 3.00. *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Una caja que el héroe puede empujar y un yunque que nadie puede mover. bodyNew(crate, BODY_DYNAMIC, SHAPE_BOX, 0.6, 0.6, 0.6) bodySetMass(crate, 5) bodyNew(anvil, BODY_DYNAMIC, SHAPE_BOX, 0.5, 0.3, 0.3) bodySetMass(anvil, 200) ---- [#bodysettrigger] ==== bodySetTrigger [source,text] ---- bodySetTrigger(node, trigger) ---- Convierte el cuerpo en un activador con `true`: no empuja nada ni detiene nada, pero `onTrigger` informa de cada cuerpo o jugador que entra en él y sale de él. `false` lo vuelve a hacer sólido: lo que hubiera dentro se olvida sin una salida, mientras que un activador en el que esté el propio cuerpo sigue informando de él. Un activador suele ser una caja o una esfera estática colocada sobre una puerta, una línea de meta o una zona de peligro. Los rayos de `physicsRaycast` alcanzan a los activadores como a cualquier otro cuerpo. *Notas:* Se informa de que un jugador está dentro de un activador cuando sus cajas envolventes se solapan, así que para los jugadores un activador redondo es uno cuadrado. *Desde:* 3.00. *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Una línea de meta que cruza la pista. line = nodeNew() nodeSetPosition(line, 0, 0, -20) bodyNew(line, BODY_STATIC, SHAPE_BOX, 6, 3, 0.5) bodySetTrigger(line, true) function onTrigger(trigger, other, entered) if trigger == line and other == car and entered then lapDone() end end ---- [#bodysetvelocity] ==== bodySetVelocity [source,text] ---- bodySetVelocity(node, x, y, z) ---- Fija la velocidad del cuerpo a lo largo de cada eje del mundo, en unidades del mundo por segundo, y lo despierta. Esto sustituye la velocidad que tuviera el cuerpo, mientras que `bodyApplyImpulse` se la suma. Los cuerpos estáticos lo ignoran, y la velocidad de un cuerpo cinemático se sobrescribe a partir del movimiento de su nodo en el siguiente paso. *Desde:* 3.00. *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Saca la pelota hacia el lado que perdió el último punto. function serve() nodeSetPosition(ball, 0, 0.5, 0) bodyNew(ball, BODY_DYNAMIC, SHAPE_SPHERE, 0.15) bodySetMass(ball, 0.1) local dir = (lastWinner == 1) and -1 or 1 bodySetVelocity(ball, 4 * dir, 2, (math.random() - 0.5) * 2) end ---- [#bodysetwater] ==== bodySetWater [source,text] ---- bodySetWater(node, density) bodySetWater(node, density, linearDrag) bodySetWater(node, density, linearDrag, angularDrag) ---- Llena de agua un cuerpo estático y lo convierte en activador: la parte superior de su forma es la superficie, y cada cuerpo dinámico que haya dentro flota, se hunde o se balancea por la flotabilidad respecto a esa superficie (cuenta la parte sumergida de la forma, así que una caja inclinada se endereza sola), frenado por las resistencias. Un jugador cuyo centro está por debajo de la superficie nada. Como el volumen es un activador, `onTrigger` se dispara al entrar y al salir. El agua no se dibuja: da al nodo un material con mezcla, o ninguno. Termina el script cuando el cuerpo no es estático. *Parámetros:* * `density` -- un multiplicador, `1` para el agua; el `bodySetBuoyancy` propio de cada cuerpo lo escala. Los valores negativos se recortan a `0`. * `linearDrag` -- lo rápido que el agua frena el movimiento a través de ella; por defecto `0.5`. * `angularDrag` -- lo rápido que frena los giros; por defecto `0.1`. *Notas:* Los jugadores y los barcos ven como máximo ocho volúmenes de agua; los que pasen de ahí siguen haciendo flotar cuerpos, pero no se puede nadar en ellos. *Desde:* 3.00. *Véase también:* <>, <>, <>, <> .Ejemplo [source,lua] ---- -- Una piscina hundida en la cubierta; su cara superior, en Y = 0, es la superficie. pool = nodeNew() nodeSetMesh(pool, meshBox(8, 3, 12), water) nodeSetPosition(pool, 0, -1.5, 0) bodyNew(pool, BODY_STATIC, SHAPE_BOX, 8, 3, 12) bodySetWater(pool, 1.0, 0.6, 0.2) function onTrigger(trigger, other, entered) if trigger == pool and entered then soundPlay(splash) end end ---- [#camera] === Cámara La cámara es un nodo como cualquier otro: colócala con `nodeSetPosition`, apúntala con `nodeLookAt`, hazla hija de un objeto en movimiento para una vista de persecución, y desígnala con `cameraSet`. Mira hacia su propio -Z. Sin un nodo de cámara, la escena se ve desde `(0, 0, 5)` mirando al origen. La proyección se fija por separado y se aplica a cualquier nodo que sea la cámara: en perspectiva, con un campo de visión vertical de 60 grados de `0.1` a `1000` unidades por defecto, u ortográfica con `cameraSetOrthographic`. Las vistas renderizadas (`viewNew`) comparten la proyección, pero miran desde sus propios nodos. Véase <>. [#cameraset] ==== cameraSet [source,text] ---- cameraSet(node) cameraSet(-1) ---- Convierte un nodo en la cámara: la escena se dibuja desde su posición en el mundo mirando hacia su propio -Z, así que cuentan el movimiento y la rotación de sus padres. Sirve cualquier nodo, incluido uno que también lleve una malla o una luz, y cambiar de cámara es una sola llamada. `-1` restablece la vista por defecto desde `(0, 0, 5)` mirando al origen. Borrar el nodo de la cámara también vuelve a la vista por defecto. Termina el script con un handle de nodo que no sea válido. *Desde:* 3.00. *Véase también:* <>, <>, <>, <> .Ejemplo [source,lua] ---- -- Una cámara de persecución detrás del coche del jugador, y una fija para la repetición. chaseCamera = nodeNew(car) nodeSetPosition(chaseCamera, 0, 2, 6) nodeLookAt(chaseCamera, nodeGetWorldPosition(car)) cameraSet(chaseCamera) function onReplayStart() replayCamera = nodeNew() nodeSetPosition(replayCamera, 30, 12, 30) nodeLookAt(replayCamera, 0, 0, 0) cameraSet(replayCamera) end ---- [#camerasetorthographic] ==== cameraSetOrthographic [source,text] ---- cameraSetOrthographic(height, near, far) ---- Cambia a una proyección paralela que muestra `height` unidades del mundo de arriba abajo, con la anchura según la relación de aspecto de la capa, de `near` a `far` delante de la cámara. La distancia no encoge las cosas, lo que va bien para diagramas, vistas isométricas y laterales, y escenas tipo HUD. El ajuste se aplica al nodo de cámara actual y a cualquiera posterior hasta que se llame a `cameraSetPerspective`. La sombra de una luz direccional usa un solo mapa con una cámara ortográfica, diga lo que diga `sceneSetShadowCascades`. *Parámetros:* * `height` -- la altura visible, en unidades del mundo. * `near` -- la distancia más cercana que se dibuja, en unidades del mundo delante de la cámara. * `far` -- la distancia más lejana que se dibuja. *Desde:* 3.00. *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Una vista isométrica del tablero: 12 unidades de alto, desde arriba y a un lado. boardCamera = nodeNew() nodeSetPosition(boardCamera, 20, 20, 20) nodeLookAt(boardCamera, 0, 0, 0) cameraSet(boardCamera) cameraSetOrthographic(12, 1, 100) ---- [#camerasetperspective] ==== cameraSetPerspective [source,text] ---- cameraSetPerspective(fov, near, far) ---- Cambia a la proyección en perspectiva habitual, la predeterminada. `fov` es el campo de visión vertical en grados (por defecto `60`); `near` y `far` (por defecto `0.1` y `1000`) limitan lo que se dibuja, en unidades del mundo delante de la cámara. La precisión de profundidad se reparte entre los dos, así que mantén `near` tan grande como permita la escena y `far` no más grande de lo necesario: de `0.05` a `100` para una habitación, de `0.1` a `400` para un paisaje. El ajuste se aplica al nodo de cámara actual y a cualquiera posterior, y a las vistas renderizadas. *Parámetros:* * `fov` -- campo de visión vertical en grados; un `70` amplio da sensación de velocidad, un `40` estrecho parece un teleobjetivo. * `near` -- la distancia más cercana que se dibuja. * `far` -- la distancia más lejana que se dibuja. *Desde:* 3.00. *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Un objetivo más cerrado para la vista de cabina, el predeterminado para el exterior. function setView(inside) if inside then cameraSet(cockpitCamera) cameraSetPerspective(45, 0.05, 300) else cameraSet(chaseCamera) cameraSetPerspective(60, 0.1, 400) end end ---- [#collide] === Colisión Si dos cosas planas se tocan. Jolt lo resuelve en tres dimensiones para los cuerpos (véase <>); estas funciones lo resuelven para el overlay, que es donde viven las zonas de impacto de una pistola de luz y todo lo de un juego 2D. Son funciones simples: sin handles, sin estado, nada que crear ni liberar y nada que actualizar en cada fotograma. Las coordenadas son aquellas en las que ya estás dibujando -- normalmente, píxeles del overlay. Un rectángulo es una esquina y un tamaño, igual que lo recibe toda llamada de dibujo del motor. Tocarse justo en un borde cuenta como tocarse, y un rectángulo sin anchura o sin altura no toca nada en absoluto. Ninguna de ellas mueve nada. Cuando no solo quieres saber *si* dos cosas se encontraron, sino *dónde acaba lo que se mueve* -- que se deslice a lo largo de una pared en lugar de quedarse clavado en ella -- usa `require("bump")` y deja que se encargue él; viene incluido precisamente por eso. [#collidecircles] ==== collideCircles [source,text] ---- touching = collideCircles(x1, y1, radius1, x2, y2, radius2) ---- Indica si dos círculos se solapan. Es más barato de lo que parece: nada calcula una raíz cuadrada para responder a una pregunta de sí o no. *Parámetros:* * `x1`, `y1`, `radius1` -- números; el primer círculo. * `x2`, `y2`, `radius2` -- números; el segundo. *Devuelve:* `true` o `false`. *Desde:* 3.00. *Véase también:* <> [#collidepointcircle] ==== collidePointCircle [source,text] ---- inside = collidePointCircle(pointX, pointY, x, y, radius) ---- Indica si un punto cae dentro de un círculo. El borde cuenta como dentro. *Parámetros:* * `pointX`, `pointY` -- números; el punto. * `x`, `y`, `radius` -- números; el círculo. *Devuelve:* `true` o `false`. *Desde:* 3.00. [#collidepointpolygon] ==== collidePointPolygon [source,text] ---- inside = collidePointPolygon(pointX, pointY, points) ---- Indica si un punto cae dentro de un polígono de cualquier forma, incluidos los cóncavos. La tabla es una lista plana de `x`, `y`, `x`, `y` -- la forma que ya tiene una zona de impacto en la mayoría de los juegos -- y el sentido en que se recorren los vértices no importa. Con menos de tres vértices no contiene nada. Como máximo, 256 vértices. *Parámetros:* * `pointX`, `pointY` -- números; el punto. * `points` -- tabla; un número par de números, x e y alternados. *Devuelve:* `true` o `false`. *Desde:* 3.00. .Ejemplo [source,lua] ---- -- ¿Ha dado el disparo en el dragón, cuyo contorno no es un rectángulo? local dragon = { 120,40, 200,60, 210,140, 150,180, 100,120 } function onInputPressed(key) if key == SWITCH_BUTTON1 then local x, y = mouseGetPosition(0) if collidePointPolygon(x, y, dragon) then hit() end end end ---- [#collidepointrect] ==== collidePointRect [source,text] ---- inside = collidePointRect(pointX, pointY, x, y, width, height) ---- Indica si un punto cae dentro de un rectángulo. El borde cuenta como dentro. *Parámetros:* * `pointX`, `pointY` -- números; el punto. * `x`, `y`, `width`, `height` -- números; el rectángulo. *Devuelve:* `true` o `false`. *Desde:* 3.00. .Ejemplo [source,lua] ---- -- Una zona de impacto del tamaño del sprite al que pertenece. local x, y = mouseGetPosition(0) if collidePointRect(x, y, enemyX, enemyY, spriteGetWidth(enemy), spriteGetHeight(enemy)) then kill(enemy) end ---- [#colliderectcircle] ==== collideRectCircle [source,text] ---- touching = collideRectCircle(x, y, width, height, circleX, circleY, radius) ---- Indica si un rectángulo y un círculo se solapan, buscando el punto del rectángulo más cercano al centro del círculo. *Parámetros:* * `x`, `y`, `width`, `height` -- números; el rectángulo. * `circleX`, `circleY`, `radius` -- números; el círculo. *Devuelve:* `true` o `false`. *Desde:* 3.00. [#colliderects] ==== collideRects [source,text] ---- touching = collideRects(x1, y1, width1, height1, x2, y2, width2, height2) ---- Indica si dos rectángulos se solapan. La que más vas a usar. *Parámetros:* * `x1`, `y1`, `width1`, `height1` -- números; el primer rectángulo. * `x2`, `y2`, `width2`, `height2` -- números; el segundo. *Devuelve:* `true` o `false`. *Desde:* 3.00. *Véase también:* <> [#collidesegments] ==== collideSegments [source,text] ---- crossing = collideSegments(ax1, ay1, ax2, ay2, bx1, by1, bx2, by2) ---- Indica si dos segmentos se cruzan: un disparo a lo largo de una trayectoria contra una pared, o el paso que quiere dar algo que se mueve contra el borde que no debe cruzar. Dos segmentos superpuestos uno a lo largo del otro responden `false`, porque entonces «¿dónde se cruzan?» no tiene una única respuesta. *Parámetros:* * `ax1`, `ay1`, `ax2`, `ay2` -- números; los extremos del primer segmento. * `bx1`, `by1`, `bx2`, `by2` -- números; los del segundo. *Devuelve:* `true` o `false`. *Desde:* 3.00. [#color] === Color Los colores de primer plano y de fondo son globales del motor y no argumentos de cada llamada: se fijan con `colorForeground` y `colorBackground`, y todas las llamadas de dibujo posteriores los leen hasta que cambian. El color de primer plano lo usan las primitivas de formas `overlay*` y el texto TrueType de `fontPrint` y `fontToSprite`; el color de fondo lo usan `overlayClear` y el texto `FONT_QUALITY_SHADED`. `overlayPrint` no usa ninguno de los dos. Los canales son enteros de `0` a `255`, limitados a ese rango, y ambos colores aceptan un alfa opcional. Consulta las secciones <> y <> para ver las llamadas que los usan. [#colorbackground] ==== colorBackground [source,text] ---- colorBackground(r, g, b) colorBackground(r, g, b, a) ---- Fija el color de fondo: aquel con el que `overlayClear` rellena el overlay y el que el texto `FONT_QUALITY_SHADED` de `fontPrint` y `fontToSprite` pinta detrás de sus glifos. Al arrancar es negro transparente, y el alfa vale `0` por defecto cuando se omite, así que `colorBackground(0, 0, 0)` seguido de `overlayClear()` vuelve a hacer invisible el overlay. Un alfa parcial sobre negro oscurece el vídeo; `255` lo oculta. Los valores de canal fuera de rango se limitan en lugar de rechazarse. *Parámetros:* * `r`, `g`, `b` -- rojo, verde y azul, de `0` a `255`. * `a` -- opacidad, de `0` (transparente) a `255` (opaco); por defecto `0`. *Desde:* 1.x *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Negro sólido detrás de la pantalla de calibración, transparente durante la partida. function onOverlayUpdate() if calibrating then colorBackground(0, 0, 0, 255) else colorBackground(0, 0, 0, 0) end overlayClear() drawHud() return OVERLAY_UPDATED end ---- [#colorforeground] ==== colorForeground [source,text] ---- colorForeground(r, g, b) colorForeground(r, g, b, a) ---- Fija el color de primer plano que usan `overlayBox`, `overlayCircle`, `overlayEllipse`, `overlayLine` y `overlayPlot`, y el color del texto de `fontPrint` y `fontToSprite`. Al arrancar es blanco opaco, y el alfa vale `255` por defecto cuando se omite, al contrario que en `colorBackground`. Los sprites y `overlayPrint` se dibujan con sus propios píxeles y no se tiñen. Los valores de canal fuera de rango se limitan en lugar de rechazarse. *Parámetros:* * `r`, `g`, `b` -- rojo, verde y azul, de `0` a `255`. * `a` -- opacidad, de `0` (transparente) a `255` (opaco); por defecto `255`. *Desde:* 1.x *Véase también:* <>, <>, <>, <> .Ejemplo [source,lua] ---- -- La puntuación en blanco y el aviso de última vida en rojo, ambos con la misma fuente. function onOverlayUpdate() overlayClear() fontSelect(hudFont) colorForeground(255, 255, 255) fontPrint(8, 8, "Score: " .. score) if lives == 1 then colorForeground(255, 32, 32) fontPrint(8, 32, "LAST LIFE") end return OVERLAY_UPDATED end ---- [#commandlineref] === Línea de comandos Tres llamadas informan de números que el usuario eligió al arrancar Singe. Existen para que un juego pueda ajustarse desde el lanzador en lugar de editando el juego. Las tres son extensiones de Hypseus Singe, y Singe no hace nada con los valores por sí mismo: el juego decide qué significan. Las opciones se describen en xref:Manual.adoc#commandlineoptions[Opciones de línea de comandos], y también pueden fijarse en un archivo de ajustes (véase xref:Manual.adoc#settings[El archivo de ajustes]). Ninguna de las tres cambia durante una ejecución, así que lee cada una una sola vez y guárdala. [#getfvalue] ==== getFValue [source,text] ---- value = getFValue() ---- Extensión de Hypseus. Devuelve el número dado con `--fvalue`, o `0` si no se usó la opción. Es un canal de un solo número desde quien arrancó Singe hasta el juego, pensado para un valor que un front-end quiere variar de un lanzamiento a otro -- una dificultad, un número inicial de créditos, un índice de menú. El motor solo lo transporta. El valor va de `0` a `100000` y conserva tres decimales, que es lo que conserva Hypseus, así que un juego que compare el número ve el mismo que veía allí. *Devuelve:* número. *Desde:* 3.00 (Hypseus) *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- El lanzador pasa --fvalue 2 para la recreativa difícil. local difficulty = getFValue() if difficulty == 0 then difficulty = 1 end ---- [#ratiogetx] ==== ratioGetX [source,text] ---- factor = ratioGetX() ---- Extensión de Hypseus. Devuelve el número dado con `--xratio`, o `0` si no se usó la opción. Los juegos de pistola de luz escritos para Hypseus lo leen y estiran con él sus propias coordenadas de pistola, para una recreativa cuya pantalla no tiene la forma con la que se masterizó el vídeo -- `1.33` lleva un panel 16:9 a imágenes 4:3. Singe no lo aplica a nada; el juego hace las cuentas, exactamente como en Hypseus, y por eso todo juego que lo lee trata `0` y cualquier valor por debajo de `1` como `1`. El valor va de `0` a `100` y conserva dos decimales, como lo conserva Hypseus. *Devuelve:* número. *Desde:* 3.00 (Hypseus) *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- La forma de las coordenadas de pistola que quiere esta recreativa. local ratioX = ratioGetX() if ratioX <= 1 then ratioX = 1 end local offsetX = (ratioX - 1) * (overlayGetWidth() / 2) ---- [#ratiogety] ==== ratioGetY [source,text] ---- factor = ratioGetY() ---- Extensión de Hypseus. La mitad vertical de la pareja: el número dado con `--yratio`, o `0` si no se usó la opción. Véase <>. *Devuelve:* número. *Desde:* 3.00 (Hypseus) *Véase también:* <>, <> .Ejemplo [source,lua] ---- local ratioY = ratioGetY() if ratioY <= 1 then ratioY = 1 end ---- [#controller] === Mando Singe admite hasta cuatro mandos, con índices de `0` a `3`, que se abren automáticamente al arrancar y cada vez que se conecta o se retira uno; no existe `controllerLoad`. Los ejes analógicos se notifican mediante `onControllerMoved` (véase <>) y se consultan con `controllerGetAxis`, en el rango en bruto de SDL, de `-32768` a `32767`. Los botones digitales, y cada dirección de cada eje, son interruptores como las teclas: llegan a `onInputPressed` y `onInputReleased` cuando `controls.cfg` asigna un código de las tablas `GAMEPAD_0` a `GAMEPAD_3` (por ejemplo `INPUT_ACTION_1 = { GAMEPAD_0.BUTTON_A }`), y `controllerGetButton` consulta el estado actual de un botón. `SINGE_DEAD_ZONE`, de la entrada `DEAD_ZONE` de `controls.cfg`, es el umbral que usa el propio motor antes de que una dirección de un eje cuente como interruptor pulsado; trata el movimiento del eje por debajo de él como ruido. A cada eje por separado se le puede dar un umbral distinto con `DEAD_ZONES`, y a los gatillos analógicos un umbral propio con `TRIGGER_THRESHOLD` (que se lee como `SINGE_TRIGGER_THRESHOLD`); véase xref:Manual.adoc#deadzones[Zonas muertas y gatillos]. Un dispositivo que SDL no reconoce como mando se abre igualmente como tal, mediante una asignación que Singe escribe para él, de modo que una placa encoder de recreativa o un joystick de vuelo aparece en estas ranuras como cualquier mando -- véase xref:Manual.adoc#devices[Todo dispositivo es un mando]. [#controllerdorumble] ==== controllerDoRumble [source,text] ---- controllerDoRumble(strength, duration) controllerDoRumble(controller, strength, duration) ---- Extensión de Hypseus. Hace vibrar un mando con ambos motores a una de cuatro intensidades durante una de cuatro duraciones. La forma de dos argumentos se dirige al mando `0`. Una ranura sin mando no hace nada (Hypseus aborta ahí, así que sus juegos se protegen con `controllerIsValid`); una intensidad o una duración fuera de `1` a `4`, o un índice fuera de `0` a `3`, aborta el script. Un mando sin vibración ignora la llamada. *Parámetros:* * `controller` -- índice del mando, de `0` a `3`. * `strength` -- de `1` a `4`, de un cuarto a toda la intensidad. * `duration` -- de `1` a `4`, que dan 150, 300, 600 o 1200 ms. *Desde:* 3.00, como extensión de Hypseus Singe. *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- Un golpe corto en cada impacto y uno largo a toda intensidad cuando el jugador muere. function onHit() if controllerIsValid(0) then controllerDoRumble(0, 2, 1) end end function onPlayerDied() if controllerIsValid(0) then controllerDoRumble(0, 4, 4) end end ---- [#controllergetaxis] ==== controllerGetAxis [source,text] ---- value = controllerGetAxis(controller, axis) ---- Devuelve la posición de un eje analógico tal como la vio el motor por última vez. El valor se actualiza con cada evento de eje, incluido el movimiento dentro de la zona muerta, y es `0` para un mando que no está conectado o un eje que nunca se ha movido. Los sticks van de `-32768` a `32767`; los gatillos reposan en `0` y llegan a `32767` cuando se pulsan a fondo. Un índice fuera de `0` a `3`, o un eje fuera de `0` a `5`, aborta el script. *Parámetros:* * `controller` -- índice del mando, de `0` a `3`. * `axis` -- `GAMEPAD_AXIS_LEFT_X` (`0`), `GAMEPAD_AXIS_LEFT_Y` (`1`), `GAMEPAD_AXIS_RIGHT_X` (`2`), `GAMEPAD_AXIS_RIGHT_Y` (`3`), `GAMEPAD_AXIS_LEFT_TRIGGER` (`4`) o `GAMEPAD_AXIS_RIGHT_TRIGGER` (`5`). *Devuelve:* entero, de `-32768` a `32767`. *Desde:* 2.00 *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- Dirección con el stick izquierdo, acelerador con el gatillo derecho. function onOverlayUpdate() local steer = controllerGetAxis(0, GAMEPAD_AXIS_LEFT_X) local throttle = controllerGetAxis(0, GAMEPAD_AXIS_RIGHT_TRIGGER) if math.abs(steer) > SINGE_DEAD_ZONE then heading = heading + (steer / 32768) * turnRate end speed = speed + (throttle / 32767) * acceleration drawHud() return OVERLAY_UPDATED end ---- [#controllergetbutton] ==== controllerGetButton [source,text] ---- pressed = controllerGetButton(controller, button) ---- Devuelve si un botón está pulsado en este momento, leído directamente del mando y no de la cola de eventos. Úsalo para mantener pulsado y cargar, y para casos parecidos en los que `onInputPressed` y `onInputReleased` resultan incómodos. Una ranura sin mando devuelve `false`. El código del botón debe venir de la tabla `GAMEPAD_N` del mismo índice de mando; un código de la tabla de otro mando, o un código de eje, cae fuera del rango de botones y aborta el script, igual que un índice fuera de `0` a `3`. *Parámetros:* * `controller` -- índice del mando, de `0` a `3`. * `button` -- un valor `BUTTON_*` o `DPAD_*` de la tabla correspondiente, como `GAMEPAD_0.BUTTON_A.value`. *Devuelve:* booleano. *Desde:* 2.10 *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Carga mientras A está pulsado y dispara al soltarlo. function onOverlayUpdate() if controllerGetButton(0, GAMEPAD_0.BUTTON_A.value) then charge = math.min(charge + 1, chargeMax) elseif charge > 0 then fireShot(charge) charge = 0 end drawChargeBar(charge) return OVERLAY_UPDATED end ---- [#controllergetname] ==== controllerGetName [source,text] ---- name = controllerGetName(controller) ---- El nombre del dispositivo de una ranura de mando, tal como lo notifica SDL, o `nil` si la ranura está vacía. Un dispositivo que SDL no reconoció notifica su propio nombre mediante la asignación que Singe escribió para él, así que una pantalla de servicio puede listar una placa encoder de recreativa por su nombre con la misma facilidad que un mando de marca. Un índice fuera de `0` a `3` aborta el script. *Parámetros:* * `controller` -- índice del mando, de `0` a `3`. *Devuelve:* cadena, o `nil`. *Desde:* 3.00 *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- Una pantalla de servicio que lista lo que está conectado. for slot = 0, 3 do if controllerIsValid(slot) then fontPrint(20, 20 + slot * 18, string.format("Pad %d: %s", slot + 1, controllerGetName(slot))) else fontPrint(20, 20 + slot * 18, string.format("Pad %d: not connected", slot + 1)) end end ---- [#controllergetpadding] ==== controllerGetPadding [source,text] ---- padding = controllerGetPadding() ---- Extensión de Hypseus, siempre `0` en Singe. Hypseus suma este desplazamiento al código de un interruptor que vino de un mando, para que un juego pueda saber qué dispositivo pulsó qué y aun así recuperar el interruptor restándolo. Singe entrega la entrada de los mandos como interruptores asignados corrientes que no llevan esa marca, así que responde `0`: un juego que comprueba el rango desplazado toma su otra rama y lee el interruptor tal cual. *Devuelve:* un entero, siempre `0`. *Desde:* 3.00 *Véase también:* <> [#controllerhowmany] ==== controllerHowMany [source,text] ---- count = controllerHowMany() ---- Extensión de Hypseus. Cuántos mandos están abiertos, de las cuatro ranuras que tiene Singe. Como todo joystick que Singe puede abrir se convierte en mando (véase xref:Manual.adoc#devices[Todo dispositivo es un mando]), una placa encoder de recreativa o un spinner también se cuentan aquí. La cuenta cambia a medida que se conectan y desconectan dispositivos, así que léela cuando la necesites y no una sola vez al arrancar. No dice nada de qué ranuras están ocupadas: un dispositivo puede dejar vacía la ranura 0 mientras se usa la ranura 1, así que <> sigue siendo la forma de preguntar por una ranura concreta. *Parámetros:* ninguno. *Devuelve:* número, de `0` a `4`. *Desde:* 3.00, como extensión de Hypseus Singe. *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Ofrece dos jugadores solo cuando el jugador dos tiene algo que sujetar. local twoPlayer = controllerHowMany() >= 2 ---- [#controllerisvalid] ==== controllerIsValid [source,text] ---- valid = controllerIsValid(controller) ---- Extensión de Hypseus. Devuelve si hay un mando abierto en la ranura en este momento; los mandos van y vienen con la conexión en caliente, así que pregunta antes de cada `controllerDoRumble`. Un índice fuera de `0` a `3` aborta el script. *Parámetros:* * `controller` -- índice del mando, de `0` a `3`. *Devuelve:* booleano. *Desde:* 3.00, como extensión de Hypseus Singe. *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- Muestra las indicaciones del mando solo cuando hay un mando al que se refieran. function drawAttract() if controllerIsValid(0) then fontPrint(HINT_X, HINT_Y, "Press A to start") else fontPrint(HINT_X, HINT_Y, "Press 1 to start") end end ---- [#controllersetpadding] ==== controllerSetPadding [source,text] ---- controllerSetPadding(boolean) ---- Extensión de Hypseus, se acepta y se ignora. Consulta <> para saber por qué el desplazamiento que pide no existe en Singe. Activarlo sin marcar los eventos sería peor que dejarlo desactivado, porque el juego restaría entonces un desplazamiento a códigos de interruptor que nunca lo llevaron. *Devuelve:* nada. *Desde:* 3.00 *Véase también:* <> [#debug] === Depuración Dos llamadas para ver qué hace un script: `debugPrint` escribe texto, y `lineDraw` dibuja una línea sobre la escena 3D durante un fotograma (`physicsSetDebug` y `navDraw` dibujan con esas mismas líneas). La salida impresa va a la consola desde la que se arrancó Singe, así que se ve durante el desarrollo y es inofensiva en una recreativa, donde `--noconsole` la silencia. Cuando Singe se ejecuta con `--trace`, cada llamada a la API, incluida esta, se escribe también con sus argumentos en `trace.txt`, en la carpeta de datos, que es la forma de obtener un registro permanente. Las opciones de línea de comandos se enumeran en el capítulo sobre cómo ejecutar Singe. Cuando imprimir no basta, <> detiene el juego en un punto de ruptura, muestra qué hay en sus variables y lo recorre línea a línea. [[traceheader]] ==== La cabecera de la traza Tanto si lo abrió `--program` como `--trace`, `trace.txt` empieza con un bloque que describe la máquina y la ejecución, entre dos líneas horizontales para que pueda pegarse entero en un informe de errores: ---- ---------------------------------------------------------------- Singe: v3.00 Command: ./Singe -w -p -d data --screen 2 MyGame OS: Linux 6.8.0-124-generic (x86_64) CPU: Intel(R) Xeon(R) CPU E5-2680 v4 @ 2.40GHz, 8 cores, 20177 MB Renderer: gpu, GPU driver vulkan Decoder: hardware vaapi or vdpau where the codec allows, software otherwise SDL: built 3.4.16, linked 3.4.16 Settings: haptic=0 screen=2 volume_vldp=80 Game: MyGame/MyGame.singe ---------------------------------------------------------------- ---- `Renderer` es el renderizador que se creó realmente y el backend de GPU que eligió, o una nota de que no hay dispositivo GPU y el 3D no está disponible. `Decoder` es lo que se intentará para el vídeo en esta máquina; el decodificador que obtuvo realmente un vídeo concreto se registra en la traza al abrirse ese vídeo, unas líneas más abajo, ya que un códec puede no ofrecer ninguna vía por hardware. `Settings` lista lo que aportó el archivo de ajustes, o `none` si no había ninguno. Cada juego de una cola tiene su propio archivo y su propia cabecera. [#debugprint] ==== debugPrint [source,text] ---- debugPrint(message) ---- Imprime una línea en la consola (la salida estándar). No se imprime nada si Singe se arrancó con `--noconsole`; con `--trace`, la llamada y su mensaje también acaban en `trace.txt`. El argumento debe ser una cadena o un número; cualquier otra cosa aborta el script, así que envuelve los valores en `tostring` o usa `string.format`. Es lo bastante barata como para dejarla en el código que se publica, pero no está limitada, así que una llamada dentro de `onOverlayUpdate` imprime en cada fotograma. *Parámetros:* * `message` -- el texto que se imprime; un número se convierte. *Desde:* 1.x *Véase también:* <> .Ejemplo [source,lua] ---- -- Registra por qué falló el disparo, con el fotograma en que ocurrió. function resolveShot(x, y) local hit = targetAt(x, y) if not hit then debugPrint(string.format("miss at %d,%d on frame %d", x, y, discGetFrame())) soundPlay(missClip) return end score = score + hit.points end ---- [#linedraw] ==== lineDraw [source,text] ---- lineDraw(x0, y0, z0, x1, y1, z1) lineDraw(x0, y0, z0, x1, y1, z1, r, g, b) ---- Dibuja una línea recta entre dos puntos del espacio del mundo sobre la escena, solo durante el fotograma actual, blanca salvo que se indique un color. Llámala en cada fotograma para mantener una línea en pantalla, como con el overlay. La línea pasa la prueba de profundidad contra la escena pero no escribe profundidad, así que se oculta tras las paredes como cualquier otra cosa y nunca tapa nada. No se dibuja nada mientras la escena está desactivada. Sirve para ver qué hace un script (un rayo lanzado, un camino, una caja envolvente); `physicsSetDebug` y `navDraw` dibujan con esas mismas líneas. *Parámetros:* * `x0, y0, z0` -- el punto inicial, en unidades del mundo. * `x1, y1, z1` -- el punto final, en unidades del mundo. * `r, g, b` -- el color de la línea, de `0` a `255` por canal, limitado a ese rango; blanco si se omite. *Desde:* 3.00. *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Muestra hacia dónde apunta la torreta y si el disparo acertaría. function onOverlayUpdate() local tx, ty, tz = nodeGetWorldPosition(turret) local hit, hx, hy, hz = physicsRaycast(tx, ty, tz, aimX, aimY, aimZ, 40) if hit then lineDraw(tx, ty, tz, hx, hy, hz, 255, 255, 0) else lineDraw(tx, ty, tz, tx + aimX * 40, ty + aimY * 40, tz + aimZ * 40, 120, 120, 120) end return OVERLAY_UPDATED end ---- [#disc] === Disco El *disco* es el vídeo principal de laserdisc, el que Singe se creó originalmente para emular. Un juego tiene como mucho un disco, que se abre al arrancar a partir del `VIDEO` indicado en `games.dat` o de `--disc` / `--framefile` en la línea de comandos; un juego que no indica ninguno funciona sin disco (véase <>). Todas las funciones `disc*` operan sobre este único disco implícito y no reciben ningún handle. Los números de fotograma son posiciones, empezando en cero, en el archivo de vídeo (o en el framefile combinado) y son la referencia de tiempo de cada decisión de ramificación en un juego de laserdisc. Sin disco, las funciones de consulta devuelven `0`, `discGetState` notifica `DISC_EJECTED` y las funciones de transporte escriben en la traza y no hacen nada. Los clips adicionales superpuestos al disco son cosa de la familia <>. [#discaudio] ==== discAudio [source,text] ---- discAudio(channel, enabled) ---- Activa o desactiva un canal del sonido del disco. El canal se pone al volumen de laserdisc configurado (`--volume_vldp`, por defecto `100`) cuando `enabled` es `true` y a `0` cuando es `false`; el otro canal se queda como está. Muchos títulos originales llevaban narración o una pista de datos en un solo canal, y esta es la llamada que la silencia. Un canal distinto de `1` o `2` termina el script. Sin disco la llamada no hace nada. *Parámetros:* * `channel` -- `1` para el canal izquierdo, `2` para el derecho. * `enabled` -- booleano. `true` devuelve el canal al volumen de laserdisc configurado, `false` lo silencia. *Desde:* 1.x *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- El canal derecho de este disco lleva la pista de datos de la recreativa. function startGame() discAudio(1, true) discAudio(2, false) discSkipToFrame(SCENE_FIRST_FRAME) end ---- [#discaudiosuffix] ==== discAudioSuffix [source,text] ---- opened = discAudioSuffix(suffix) ---- Extensión de Hypseus. Cambia el disco a un archivo de audio aparte, junto a su vídeo: `.ogg`, donde la base es el nombre del archivo de vídeo sin su extensión, así que `movie.m2v` con `"-es"` reproduce `movie-es.ogg`. El archivo se abre en el fotograma actual y la reproducción se realinea, como hace `discSetAudioTrack`, conservando el volumen, el estado de reproducción y el índice de pista si el nuevo archivo tiene tantas pistas. Con un framefile cambian todos los segmentos. Un sufijo vacío restaura el audio por defecto: el `.ogg` junto a un `.m2v`, o el audio de dentro de cualquier otro contenedor. Devuelve `true` si el archivo se abrió; si falta, el audio anterior sigue sonando, no se recuerda nada y se devuelve `false` (Hypseus deja silencio en ese caso). El sufijo se olvida cuando el script se recarga. Sin disco la llamada devuelve `false`. Úsala para un menú de idioma cuyas pistas se distribuyen como archivos separados; las pistas dentro de un mismo archivo son cosa de `discSetAudioTrack`. *Parámetros:* * `suffix` -- cadena que se añade al nombre base del vídeo, como `"-es"`; `""` para el audio por defecto. *Devuelve:* booleano. *Desde:* 3.00, como extensión de Hypseus Singe. *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Los archivos de idioma se distribuyen como movie.ogg, movie-es.ogg y movie-fr.ogg. languages = { { "English", "" }, { "Spanish", "-es" }, { "French", "-fr" } } function applyLanguage(index) if not discAudioSuffix(languages[index][2]) then discAudioSuffix("") index = 1 end settings.language = index end ---- [#discchangespeed] ==== discChangeSpeed [source,text] ---- discChangeSpeed(...) ---- No hace nada. Los reproductores de laserdisc reales podían funcionar a velocidades distintas de 1x; el reproductor por software de Singe no puede, así que la llamada acepta cualquier argumento, escribe una línea en la traza y vuelve. Se mantiene para que los scripts anteriores a la 2.00 se carguen sin modificaciones. *Notas:* WARNING: *Sin implementar.* Se conserva solo por compatibilidad con versiones anteriores. No hay sustituto; la velocidad de reproducción es fija, la tasa de fotogramas del archivo de vídeo. *Desde:* 1.x *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- Los scripts antiguos a veces rodeaban una repetición a cámara lenta con cambios de velocidad. -- Hoy las llamadas son inofensivas, pero la repetición se reproduce a velocidad normal. discChangeSpeed(0.5) discSkipToFrame(REPLAY_FIRST_FRAME) discChangeSpeed(1) ---- [#discgetaudiotrack] ==== discGetAudioTrack [source,text] ---- track = discGetAudioTrack() ---- Devuelve el índice, empezando en cero, de la pista de audio que el disco está reproduciendo. Al arrancar, el motor selecciona la pista indicada por `--audio` o por la entrada `AUDIO_TRACK` de `games.dat` (por defecto `0`) si el disco tiene tantas pistas. Devuelve `0` sin disco. *Devuelve:* índice de pista, entero. *Desde:* 2.10 *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Pasa al siguiente doblaje cada vez que el jugador pulsa la tecla de idioma. function onInputPressed(what) if what == SWITCH_BUTTON2 then local next = (discGetAudioTrack() + 1) % discGetAudioTracks() discSetAudioTrack(next) languageLabel = discGetLanguageDescription(discGetLanguage(next)) end end ---- [#discgetaudiotracks] ==== discGetAudioTracks [source,text] ---- count = discGetAudioTracks() ---- Devuelve cuántos flujos de audio contiene el archivo de vídeo del disco. Cuando hay varios, suelen ser doblajes a otros idiomas. Un vídeo sin sonido devuelve `0`, igual que un juego sin disco, así que protege los bucles y la aritmética de módulo contra el cero. *Devuelve:* número entero, `0` o más. *Desde:* 2.10 *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Construye un menú de selección de idioma a partir de las pistas de audio del disco. languages = {} for track = 0, discGetAudioTracks() - 1 do local code = discGetLanguage(track) local name = discGetLanguageDescription(code) table.insert(languages, { track = track, label = name }) end ---- [#discgetsubtitlelanguage] ==== discGetSubtitleLanguage [source,text] ---- language = discGetSubtitleLanguage(track) ---- El idioma con que está etiquetada una pista de subtítulos dentro del propio contenedor del disco, como el código ISO 639 que lleva el archivo (`eng`, `fra`, `spa`). Una pista que el archivo no etiqueta responde con una cadena vacía, lo cual no es un error -- muchísimos archivos no etiquetan nada. *Parámetros:* * `track` -- número; de `0` a `discGetSubtitleTracks() - 1`. *Devuelve:* una cadena. *Desde:* 3.00. *Véase también:* <>, <> [#discgetsubtitletracks] ==== discGetSubtitleTracks [source,text] ---- count = discGetSubtitleTracks() ---- Cuántas pistas de subtítulos contiene el propio contenedor del disco. `0` sin disco, y `0` para un disco cuyos subtítulos se distribuyen como un `.srt` aparte -- que es lo habitual, y para lo que sirve <>. Las pistas que contienen imágenes en lugar de palabras (las de DVD y Blu-ray, VobSub y PGS) se cuentan aquí para que la numeración coincida con la que muestra cualquier otro reproductor, pero <> no puede leerlas: no hay nada que leer, solo mapas de bits de las palabras. *Parámetros:* * Ninguno. *Devuelve:* un número. *Desde:* 3.00. *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- Ofrece los idiomas que traiga el propio disco. for track = 0, discGetSubtitleTracks() - 1 do languages[#languages + 1] = { track = track, code = discGetSubtitleLanguage(track) } end ---- [#discgetframe] ==== discGetFrame [source,text] ---- frame = discGetFrame() ---- Devuelve el fotograma que el disco está mostrando. Con un framefile, el número es la posición en la secuencia combinada, así que los límites entre segmentos son invisibles para el script. Devuelve `0` mientras el disco está detenido y sin disco. Consúltalo desde `onOverlayUpdate` para reaccionar cuando el vídeo llegue a un punto de decisión. *Devuelve:* número de fotograma, entero. *Desde:* 1.x *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Repite en bucle la secuencia de atracción y muestra un aviso durante la ventana para agacharse. function onOverlayUpdate() local frame = discGetFrame() if frame >= ATTRACT_LAST_FRAME then discSkipToFrame(ATTRACT_FIRST_FRAME) elseif frame >= DUCK_FIRST_FRAME and frame <= DUCK_LAST_FRAME then drawPrompt("DUCK") end return OVERLAY_UPDATED end ---- [#discgetframecount] ==== discGetFrameCount [source,text] ---- frames = discGetFrameCount() ---- El número de fotogramas del disco, que es uno más que el fotograma más alto que encontrará <>. Un framefile responde por todo el disco y no por el segmento que se está reproduciendo, así que la cuenta no cambia a medida que los segmentos van y vienen. Sin disco responde `0`. *Devuelve:* número de fotogramas, entero; `0` sin disco. *Desde:* 3.00. *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- Salta a la mitad del disco, esté donde esté. discSearch(math.floor(discGetFrameCount() / 2)) ---- [#discgetheight] ==== discGetHeight [source,text] ---- height = discGetHeight() ---- Devuelve la altura del vídeo del disco en píxeles. Sin disco devuelve la altura del lienzo (`CANVAS_Y` en `games.dat` o `--canvas`, por defecto `480`), así que siempre es el tamaño del fondo sobre el que se estira el overlay. Combínala con `discGetWidth` para dar al overlay la misma resolución que el vídeo. *Devuelve:* altura en píxeles, entero. *Desde:* 2.00 *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Dibuja a la resolución nativa del disco para que las comprobaciones de píxeles y el dibujo compartan cuadrícula. overlaySetResolution(discGetWidth(), discGetHeight()) crosshairLimitX = discGetWidth() - 1 crosshairLimitY = discGetHeight() - 1 ---- [#discgetlanguage] ==== discGetLanguage [source,text] ---- code = discGetLanguage(track) ---- Devuelve la etiqueta de idioma guardada en el archivo de vídeo para una de las pistas de audio del disco. La etiqueta es un código ISO 639 de tres letras, como `"eng"` o `"jpn"`. Las pistas sin una etiqueta de tres letras devuelven `"unk"`, igual que todas las pistas cuando no hay disco. Con disco, una pista fuera de `0` a `discGetAudioTracks() - 1` termina el script. *Devuelve:* código de idioma, cadena. *Desde:* 2.10 *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Prefiere el doblaje japonés cuando el disco lo trae. for track = 0, discGetAudioTracks() - 1 do if discGetLanguage(track) == "jpn" then discSetAudioTrack(track) break end end ---- [#discgetstate] ==== discGetState [source,text] ---- state = discGetState() ---- Devuelve el estado de transporte del disco como una de las constantes `DISC_*`. Un juego sin disco notifica `DISC_EJECTED`; tras `discStop`, el disco notifica `DISC_STOPPED` hasta el siguiente `discPlay`, `discSearch` o `discSkipToFrame`; en los demás casos, el estado es `DISC_PLAYING` mientras el vídeo avanza y `DISC_PAUSED` cuando no avanza. No hay un estado de búsqueda aparte; un disco que está saltando notifica `DISC_PAUSED` o `DISC_PLAYING` según lo que se pidiera. *Devuelve:* una de las siguientes constantes, definidas por el motor y disponibles para todo script. [cols="1,1,1",options="header"] |=== | Constante | Valor | Significado | `DISC_STOPPED` | `2` | Detenido (se llamó a `discStop`) | `DISC_PLAYING` | `3` | Reproduciendo | `DISC_PAUSED` | `4` | En pausa | `DISC_EJECTED` | `5` | El juego no tiene disco (véase <>) |=== *Desde:* 1.x (RDG) *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- La tecla de pausa no debe reanudar una congelación que pidió el script de la historia. function onInputPressed(what) if what == SWITCH_PAUSE then if discGetState() == DISC_PAUSED and not storyPaused then discPlay() elseif discGetState() == DISC_PLAYING then discPause() end end end ---- [#discgetwidth] ==== discGetWidth [source,text] ---- width = discGetWidth() ---- Devuelve la anchura del vídeo del disco en píxeles. Sin disco devuelve la anchura del lienzo (`CANVAS_X` en `games.dat` o `--canvas`, por defecto `720`). Junto con `discGetHeight`, es el tamaño sobre el que se escala el overlay, y por eso el menú incluido pasa ambos a `overlaySetResolution`. *Devuelve:* anchura en píxeles, entero. *Desde:* 2.00 *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Centra un sprite de título sea cual sea la anchura del disco. overlaySetResolution(discGetWidth(), discGetHeight()) titleSprite = spriteLoad("graphics/title.png") titleX = (discGetWidth() - spriteGetWidth(titleSprite)) / 2 ---- [#discpause] ==== discPause [source,text] ---- discPause() ---- Pone en pausa la reproducción en el fotograma actual, que se queda en pantalla. El audio del disco se pausa con ella. La llamada se ignora mientras el disco está detenido y no hace nada sin disco. Se reanuda con `discPlay`. *Desde:* 1.x *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Mantiene el último fotograma de la escena mientras el jugador elige una puerta. function onOverlayUpdate() if discGetFrame() >= SCENE_LAST_FRAME and not choosing then discPause() choosing = true end return OVERLAY_UPDATED end ---- [#discpauseatframe] ==== discPauseAtFrame [source,text] ---- discPauseAtFrame(frame) ---- Salta a `frame`, lo muestra y se pone en pausa. Es la misma función que `discSearch` con un segundo nombre; las dos son intercambiables en todos los aspectos, incluidas las reglas de límites que se describen allí. Prefiere `discSearch` en código nuevo y conserva `discPauseAtFrame` solo al portar scripts antiguos. *Notas:* WARNING: *Alias heredado.* Usa <> en código nuevo. *Desde:* 1.18 *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- Código 1.x portado: se queda en el primer fotograma de la escena hasta que el jugador esté listo. function onInputPressed(what) if what == SWITCH_START1 then discPauseAtFrame(SCENE_FIRST_FRAME) waitingForPlayer = true end end ---- [#discplay] ==== discPlay [source,text] ---- discPlay() ---- Inicia o reanuda la reproducción desde el fotograma actual y borra el estado de detenido que fija `discStop`. El audio se reanuda con el vídeo, y el reloj de reproducción vuelve a empezar desde el fotograma actual, de modo que una pausa larga no provoca un salto. Sin disco no hace nada, aparte de borrar la marca de detenido. *Desde:* 1.x *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Arranque habitual del modo de atracción: prepara el primer fotograma y a rodar. function attractBegin() discSearch(ATTRACT_FIRST_FRAME) overlayClear() discPlay() attractRunning = true end ---- [#discsearch] ==== discSearch [source,text] ---- discSearch(frame) ---- Salta con el disco a `frame`, muestra ese fotograma y se pone en pausa en él, borrando el estado de detenido si el disco estaba detenido. El disco mantiene el fotograma hasta `discPlay`, `discSkipToFrame` u otra llamada de transporte. Un fotograma negativo se limita al primero y uno más allá del final se limita al último; con un framefile se selecciona el segmento que contiene el fotograma y la posición se limita dentro de él. Sin disco no hace nada. *Parámetros:* * `frame` -- número del fotograma de destino, empezando en cero. *Desde:* 1.x *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- El jugador eligió el pasillo izquierdo: prepara la escena y espera la señal. function goLeft() discSearch(CORRIDOR_LEFT_FIRST_FRAME) soundPlay(SOUND_DOOR) waitingForPlayer = true end ---- [#discsearchblanking] ==== discSearchBlanking [source,text] ---- discSearchBlanking(...) ---- No hace nada. En el hardware de laserdisc elegía si la pantalla se apagaba mientras el reproductor buscaba un fotograma; el reproductor por software de Singe salta sin apagar la pantalla, así que la llamada acepta cualquier argumento, escribe una línea en la traza y vuelve. *Notas:* WARNING: *Sin implementar.* Se conserva solo por compatibilidad con versiones anteriores. No hay sustituto. *Desde:* 1.x *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- Los scripts anteriores a la 2.00 solían fijar los modos de apagado una vez al arrancar. Inofensivo. discSearchBlanking(false) discSkipBlanking(false) discSearch(TITLE_FRAME) ---- [#discsetaudiotrack] ==== discSetAudioTrack [source,text] ---- discSetAudioTrack(track) ---- Cambia el disco a otra pista de audio. Se descarta el audio en cola de la pista anterior y la reproducción se realinea, así que el cambio se oye casi de inmediato. Con disco, una pista fuera de `0` a `discGetAudioTracks() - 1` termina el script; sin disco la llamada no hace nada. Úsala para implementar un menú de idioma o para respetar una preferencia guardada entre sesiones. *Parámetros:* * `track` -- índice de la pista de audio, empezando en cero. *Desde:* 2.10 *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Aplica el idioma que eligió el jugador en la pantalla de opciones. function applyLanguage(choice) if choice >= 0 and choice < discGetAudioTracks() then discSetAudioTrack(choice) settings.language = choice saveSettings() end end ---- [#discsetfps] ==== discSetFPS [source,text] ---- discSetFPS(...) ---- No hace nada. La tasa de fotogramas viene del archivo de vídeo, así que la llamada acepta cualquier argumento, escribe una línea en la traza y vuelve. Nada en el `Framework.singe` incluido la llama; solo lo hacen los scripts anteriores a la 2.00. *Notas:* WARNING: *Sin implementar.* Se conserva solo por compatibilidad con versiones anteriores. La tasa de fotogramas se lee del archivo de vídeo y no se puede fijar desde un script. *Desde:* 1.x *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- Un script 1.x portado. La llamada se ignora; los fotogramas por segundo son una propiedad del archivo. discSetFPS(29.97) discSearch(TITLE_FRAME) discPlay() ---- [#discskipbackward] ==== discSkipBackward [source,text] ---- discSkipBackward(frames) ---- Salta hacia atrás `frames` fotogramas desde el actual sin cambiar el estado de reproducción o pausa: un disco que se está reproduciendo sigue reproduciéndose desde la nueva posición, y uno en pausa sigue en pausa en ella. La llamada se ignora mientras el disco está detenido y sin disco. El desplazamiento no se valida; un valor negativo salta hacia delante, y un desplazamiento mayor que el fotograma actual se limita al fotograma `0`. Con un framefile, el desplazamiento se aplica al número de fotograma que notifica `discGetFrame`, así que un salto puede pasar a un segmento anterior. *Parámetros:* * `frames` -- número de fotogramas que se restan al fotograma actual. *Desde:* 1.x *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Repite el último segundo (30 fotogramas) cuando el jugador falla un aviso. function missedPrompt() lives = lives - 1 discSkipBackward(30) promptShown = false end ---- [#discskipblanking] ==== discSkipBlanking [source,text] ---- discSkipBlanking(...) ---- No hace nada. En su día elegía si la pantalla se apagaba durante un salto en el hardware de laserdisc; el reproductor por software de Singe nunca apaga la pantalla, así que la llamada acepta cualquier argumento, escribe una línea en la traza y vuelve. *Notas:* WARNING: *Sin implementar.* Se conserva solo por compatibilidad con versiones anteriores. No hay sustituto. *Desde:* 1.x *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- Los scripts anteriores a la 2.00 solían fijar los modos de apagado una vez al arrancar. Inofensivo. discSearchBlanking(false) discSkipBlanking(false) discSearch(TITLE_FRAME) ---- [#discskipforward] ==== discSkipForward [source,text] ---- discSkipForward(frames) ---- Salta hacia delante `frames` fotogramas desde el actual sin cambiar el estado de reproducción o pausa. Es el reflejo de `discSkipBackward`: se ignora mientras está detenido y sin disco, el desplazamiento no se valida, y un destino más allá del último fotograma se limita al último. Con un framefile, el desplazamiento se aplica al número de fotograma que notifica `discGetFrame`, así que un salto puede pasar a un segmento posterior. *Parámetros:* * `frames` -- número de fotogramas que se suman al fotograma actual. *Desde:* 1.x *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Deja que un jugador que vuelve se salte los dos segundos de introducción de la escena. function onInputPressed(what) if what == SWITCH_BUTTON1 and inSceneIntro then discSkipForward(60) inSceneIntro = false end end ---- [#discskiptoframe] ==== discSkipToFrame [source,text] ---- discSkipToFrame(frame) ---- Salta a `frame` y empieza a reproducir desde él sea cual sea el estado del disco, borrando el estado de detenido si hace falta. Compárala con `discSearch`, que salta y se pone en pausa. Un fotograma anterior al principio o posterior al final se limita al primero o al último; con un framefile se selecciona el segmento que contiene el fotograma y la posición se limita dentro de él. Sin disco no hace nada, aparte de borrar la marca de detenido. *Parámetros:* * `frame` -- número del fotograma de destino, empezando en cero. *Desde:* 1.x *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Se salta la introducción al volver a jugar. function startGame() if hasPlayedBefore then discSkipToFrame(GAMEPLAY_FIRST_FRAME) else discSkipToFrame(INTRO_FIRST_FRAME) end end ---- [#discstepbackward] ==== discStepBackward [source,text] ---- discStepBackward() ---- Retrocede el disco exactamente un fotograma y se pone en pausa en él, sea cual sea el estado de reproducción anterior. Retroceder desde el fotograma `0` se queda en el fotograma `0`. Se ignora mientras el disco está detenido, como `discSkipBackward`. Pensada para depurar con precisión de fotograma y para pantallas de servicio. Sin disco no hace nada. *Desde:* 1.x *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Pantalla de servicio: izquierda y derecha mueven el disco fotograma a fotograma. function onInputPressed(what) if what == SWITCH_LEFT then discStepBackward() elseif what == SWITCH_RIGHT then discStepForward() end end ---- [#discstepforward] ==== discStepForward [source,text] ---- discStepForward() ---- Avanza el disco exactamente un fotograma y se pone en pausa en él, sea cual sea el estado de reproducción anterior. Avanzar desde el último fotograma se queda en el último fotograma. Como `discStepBackward`, se ignora mientras el disco está detenido. Sin disco no hace nada. *Desde:* 1.x *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Buscador de fotogramas: avanza por una escena e imprime el número de fotograma. function onOverlayUpdate() overlayClear() overlayPrint(8, 8, "Frame " .. discGetFrame()) if stepping then discStepForward() end return OVERLAY_UPDATED end ---- [#discstop] ==== discStop [source,text] ---- discStop() ---- Detiene el disco. La reproducción se pausa, el vídeo se sustituye por la clásica pantalla azul, `discGetState` notifica `DISC_STOPPED` y `discGetFrame` devuelve `0` hasta que el disco se vuelve a poner en marcha. Mientras está detenido, se ignoran `discPause`, `discSkipForward`, `discSkipBackward`, `discStepForward` y `discStepBackward`. Cualquiera de `discPlay`, `discSearch` o `discSkipToFrame` borra el estado de detenido. Un segundo `discStop` se ignora. *Desde:* 1.x *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Fin de partida: pasa a la pantalla azul detrás de la tabla de puntuaciones. function gameOver() discStop() soundPlay(SOUND_GAME_OVER) showScores = true end ---- [#emitter] === Emisor Los emisores generan, mueven y dibujan partículas; véase <>. Los handles son enteros. Un emisor 2D vive en coordenadas del overlay, con la Y hacia abajo, y dibuja cuando `emitterDraw` se lo pide desde `onOverlayUpdate`; un emisor 3D está sobre un nodo, vive en unidades del mundo y se dibuja solo en la escena. Las llamadas de la receta pueden hacerse en cualquier orden, antes o después de que el emisor arranque, y afectan a las siguientes partículas que nazcan (unas pocas, indicadas abajo, actúan también sobre las vivas). Todo lo que se da como mínimo y máximo se sortea entre ambos para cada partícula. Las partículas avanzan con el reloj, como mucho una décima de segundo por fotograma, y se detienen mientras el juego está en pausa. [#emitternew] ==== emitterNew [source,text] ---- emitter = emitterNew() emitter = emitterNew(node) ---- Crea un emisor. Sin argumentos es uno 2D en la posición del overlay `(0, 0)`, que se mueve con `emitterSetPosition`; con un nodo es uno 3D cuyas partículas nacen en la posición en el mundo de ese nodo, y necesita la escena 3D, así que en una máquina sin ella la llamada termina el script (los emisores 2D funcionan en todas partes). Un emisor nuevo no está emitiendo; llama a `emitterStart` o a `emitterBurst`. Borrar el nodo borra sus emisores. Valores por defecto: 50 partículas por segundo, que viven de uno a dos segundos, dispersión de 30 grados, de blanco a transparente, mezcla normal, sin gravedad, resistencia, giro, radio, textura, estela ni colisión, y una reserva de 1000; un emisor 2D las lanza hacia arriba en la pantalla a entre 50 y 100 unidades del overlay por segundo, encogiendo de 16 a 4, y uno 3D hacia arriba por el eje Y a entre 1 y 2 unidades del mundo por segundo, encogiendo de 0,2 a 0,05. *Devuelve:* el handle del emisor. *Desde:* 3.00. *Véase también:* <>, <>, <>, <> .Ejemplo [source,lua] ---- -- Gases de escape del nodo del motor de la nave; la receta se fija antes de que arranque el flujo. exhaust = emitterNew(nodeFind("Engine", ship)) emitterSetBlend(exhaust, PARTICLE_ADD) emitterSetRate(exhaust, 120) emitterSetLife(exhaust, 0.3, 0.6) emitterSetDirection(exhaust, 0, 0, 1) emitterSetSpeed(exhaust, 3, 5) emitterSetSize(exhaust, 0.3, 0.05) emitterSetColor(exhaust, 120, 180, 255, 255, 30, 60, 255, 0) emitterStart(exhaust) ---- [#emitterdelete] ==== emitterDelete [source,text] ---- emitterDelete(emitter) ---- Elimina el emisor, sus partículas vivas y su copia de la textura de inmediato; nada se desvanece. Usa `emitterStop` en su lugar para dejar que terminen las últimas partículas. Un emisor 3D también se elimina por sí solo cuando se borra su nodo. Un handle que no es un emisor vivo termina el script, así que vacía la variable después. *Desde:* 3.00. *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- La explosión ha terminado: libera su emisor cuando se haya apagado la última chispa. function onOverlayUpdate() if explosion ~= nil and not emitterIsActive(explosion) then emitterDelete(explosion) explosion = nil end return OVERLAY_NOT_UPDATED end ---- [#emittersettexture] ==== emitterSetTexture [source,text] ---- emitterSetTexture(emitter, sprite) emitterSetTexture(emitter) ---- La imagen que lleva cada partícula: un sprite cargado, o ninguno (un solo argumento, o `nil`) para el disco difuso integrado. El emisor guarda su propia copia de los píxeles del sprite, incluidos todos los fotogramas de un sprite animado, así que el sprite puede descargarse después. Fijar la textura devuelve el rango de fotogramas al fotograma `0` y pasa todas las partículas vivas al fotograma `0`, así que llama a `emitterSetFrames` después, no antes. La mezcla y el tinte se aplican a la imagen igual que al disco. *Desde:* 3.00. *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Nieve: una hoja con seis formas de copo, una elegida al azar para cada partícula. flakes = spriteLoadFrames(6, DIR .. "Sprites/flakes.png") snow = emitterNew() emitterSetTexture(snow, flakes) emitterSetFrames(snow, 0, 5) emitterSetDirection(snow, 0, 1) emitterSetSpeed(snow, 20, 40) emitterSetLife(snow, 6, 9) emitterSetSize(snow, 6, 6, 0.4) emitterStart(snow) ---- [#emittersetframes] ==== emitterSetFrames [source,text] ---- emitterSetFrames(emitter, first, last) ---- Con un sprite animado como textura, cada partícula nueva toma un fotograma al azar de `first` a `last` y lo conserva toda su vida; los fotogramas se cuentan desde `0`. Los valores se limitan a los fotogramas que tiene la textura, y `last` se sube hasta `first` si es menor. Por defecto: solo el fotograma `0`, que es también a lo que vuelve `emitterSetTexture`. Con el disco integrado o con un sprite de un solo fotograma, el rango no tiene efecto. *Desde:* 3.00. *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- La hoja de bocanadas tiene cuatro bocanadas suaves y luego cuatro trozos de escombros más duros. emitterSetTexture(smoke, puffSheet) emitterSetFrames(smoke, 0, 3) emitterSetTexture(debris, puffSheet) emitterSetFrames(debris, 4, 7) ---- [#emittersetblend] ==== emitterSetBlend [source,text] ---- emitterSetBlend(emitter, mode) ---- Cómo se combinan las partículas con lo que tienen detrás. `PARTICLE_ALPHA` (por defecto) mezcla de forma normal, para humo, polvo, lluvia y escombros; `PARTICLE_ADD` suma luz, para fuego, chispas, resplandores y magia, y nunca oscurece. Afecta de inmediato a las partículas vivas. Cualquier cosa que no sea una de esas dos constantes termina el script. *Desde:* 3.00. *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- Las chispas brillan; el humo que hay encima de ellas, no. emitterSetBlend(sparks, PARTICLE_ADD) emitterSetColor(sparks, 255, 220, 120, 255, 255, 80, 20, 0) emitterSetBlend(smoke, PARTICLE_ALPHA) emitterSetColor(smoke, 160, 160, 170, 120, 120, 120, 130, 0) ---- [#emittersetlayer] ==== emitterSetLayer [source,text] ---- emitterSetLayer(emitter, layer) ---- Solo 2D: dónde pone `emitterDraw` las partículas. `PARTICLE_OVER` (por defecto) dibuja por encima de todo lo del overlay; `PARTICLE_UNDER` dibuja por debajo del overlay y por encima del vídeo y de la escena 3D, para humo detrás de la puntuación. En un emisor 3D el ajuste se acepta y se ignora. Cualquier cosa que no sea una de esas dos constantes termina el script. *Desde:* 3.00. *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- La lluvia cae detrás del HUD; las chispas de los impactos, delante. rain = emitterNew() emitterSetLayer(rain, PARTICLE_UNDER) hits = emitterNew() emitterSetLayer(hits, PARTICLE_OVER) function onOverlayUpdate() overlayClear() emitterDraw(rain) drawHud() emitterDraw(hits) return OVERLAY_UPDATED end ---- [#emittersetrate] ==== emitterSetRate [source,text] ---- emitterSetRate(emitter, perSecond) ---- Cuántas partículas por segundo crea un emisor arrancado; por defecto `50`, y los valores negativos pasan a `0`. Las fracciones se acumulan, así que `0.5` crea una cada dos segundos. Cámbialo mientras emite para regular una llama o un propulsor. Una reserva llena (`emitterSetMax`) no crea más hasta que mueran algunas, sea cual sea la tasa. *Desde:* 3.00. *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- El penacho del propulsor sigue al acelerador. function onControllerMoved(axis, value, which) if axis == GAMEPAD_AXIS_RIGHT_TRIGGER then throttle = value / 32767 emitterSetRate(plume, 30 + 300 * throttle) end end ---- [#emittersetlife] ==== emitterSetLife [source,text] ---- emitterSetLife(emitter, minSeconds, maxSeconds) ---- Cuánto vive cada partícula nueva, sorteado entre los dos valores; por defecto de `1` a `2`. Su tamaño y su color van de su inicio a su final a lo largo de ese tiempo, así que la vida marca el ritmo de cada desvanecimiento. El mínimo se mantiene en `0.001` o más, y el máximo se sube hasta el mínimo si es menor. Las partículas vivas conservan la vida con la que nacieron. *Desde:* 3.00. *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Fogonazo: una ráfaga que desaparece en una décima de segundo. flash = emitterNew(muzzle) emitterSetBlend(flash, PARTICLE_ADD) emitterSetLife(flash, 0.05, 0.1) emitterSetSpeed(flash, 2, 6) emitterSetSize(flash, 0.15, 0.4) function onFire() emitterBurst(flash, 12) end ---- [#emittersetsoftness] ==== emitterSetSoftness [source,text] ---- emitterSetSoftness(emitter, distance) ---- Solo 3D: desvanece una partícula a lo largo de `distance` unidades del mundo donde se encuentra con la geometría que tiene detrás, para que una bocanada de humo no dibuje una línea dura donde atraviesa una pared y una fuente se hunda en su estanque en lugar de cortarlo. `0` (el valor por defecto) lo desactiva; los valores negativos pasan a `0`. Mientras algún emisor lo use, la profundidad de la escena se renderiza una vez más por fotograma, así que en una Raspberry Pi conviene usarlo con moderación. Un emisor 2D guarda el valor y lo ignora. *Desde:* 3.00. *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- El humo del brasero se suaviza contra el poste junto al que sube. smoke = emitterNew(brazierTop) emitterSetRate(smoke, 25) emitterSetLife(smoke, 1.5, 2.5) emitterSetSize(smoke, 0.2, 0.9, 0.2) emitterSetColor(smoke, 160, 160, 170, 120, 120, 120, 130, 0) emitterSetLit(smoke, true) emitterSetSoftness(smoke, 0.3) emitterStart(smoke) ---- [#emittersetspeed] ==== emitterSetSpeed [source,text] ---- emitterSetSpeed(emitter, min, max) ---- Velocidad inicial de cada partícula nueva, sorteada entre los dos valores, en unidades del overlay por segundo (2D) o unidades del mundo por segundo (3D); por defecto de `50` a `100` en 2D y de `1` a `2` en 3D. El máximo se sube hasta el mínimo si es menor. La velocidad determina hasta dónde llega una partícula antes de que tomen el relevo la resistencia, la gravedad y su vida. *Desde:* 3.00. *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Una fuente lanza las gotas con fuerza y rectas; el polvo apenas se mueve. emitterSetSpeed(fountain, 4.0, 5.0) emitterSetSpread(fountain, 8) emitterSetSpeed(dust, 0.05, 0.2) emitterSetSpread(dust, 180) ---- [#emittersetdirection] ==== emitterSetDirection [source,text] ---- emitterSetDirection(emitter, x, y) emitterSetDirection(emitter, x, y, z) ---- La dirección media en que salen las partículas nuevas; solo importa la dirección, no la longitud. Por defecto en 2D `(0, -1)`, hacia arriba en la pantalla, ya que la Y del overlay crece hacia abajo; por defecto en 3D `(0, 1, 0)`, hacia arriba por el eje Y del mundo. Un emisor 2D ignora `z`; un emisor 3D que recibe dos valores toma `z = 0`. Un vector nulo se trata como `(0, -1, 0)`. En un emisor 3D, la dirección está en los ejes del mundo, no en los del nodo, así que gírala tú cuando gire el nodo. *Desde:* 3.00. *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- Los gases de escape salen por detrás de la nave, mire hacia donde mire. function onOverlayUpdate() local heading = math.rad(shipYaw) emitterSetDirection(exhaust, -math.sin(heading), 0, -math.cos(heading)) return OVERLAY_NOT_UPDATED end ---- [#emittersetspread] ==== emitterSetSpread [source,text] ---- emitterSetSpread(emitter, degrees) ---- La mitad del ángulo del cono (3D) o del abanico (2D) alrededor de la dirección en que se lanzan las partículas nuevas, de `0` a `180`; `0` es una línea recta y `180`, todas las direcciones. Por defecto `30`; los valores fuera del rango se limitan. En 3D las direcciones se reparten uniformemente sobre el casquete del cono, así que un cono ancho no se amontona hacia su eje. *Desde:* 3.00. *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- Los fuegos artificiales van a todas partes; la llama de la antorcha se queda en una columna estrecha. emitterSetSpread(fireworks, 180) emitterSetSpread(flame, 15) ---- [#emittersetgravity] ==== emitterSetGravity [source,text] ---- emitterSetGravity(emitter, x, y) emitterSetGravity(emitter, x, y, z) ---- Una aceleración constante sobre cada partícula, en unidades del overlay (2D) o unidades del mundo (3D) por segundo al cuadrado: `(0, 500)` tira de las chispas 2D hacia abajo en la pantalla, `(0, -20)` deja que el humo 2D suba, `(0, -9.8, 0)` es la Tierra en la escena. Por defecto, ninguna. Se aplica a las partículas vivas desde el siguiente fotograma. Las partículas no son cuerpos físicos; dales `emitterSetCollide` para que reboten en un suelo o en la escena. *Desde:* 3.00. *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Las chispas caen y el humo sube, ambos de la misma amoladora. emitterSetGravity(sparks, 0, 500) emitterSetGravity(smoke, 0, -20) -- Bajo el agua, las burbujas van hacia la superficie. emitterSetGravity(bubbles, 0, 3, 0) ---- [#emittersetdrag] ==== emitterSetDrag [source,text] ---- emitterSetDrag(emitter, perSecond) ---- Lo rápido que las partículas pierden su velocidad: en cada fotograma la velocidad se multiplica por `1 - perSecond * dt`, que nunca baja de `0`, así que `1` les quita casi toda la velocidad en un segundo y `0` (el valor por defecto) la conserva para siempre. Los valores negativos pasan a `0`. El humo, las brasas y los fuegos artificiales se frenan; las chispas y la lluvia, no. Se aplica a las partículas vivas desde el siguiente fotograma. *Desde:* 3.00. *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- Una carcasa de fuegos artificiales: sale rápida y luego queda suspendida y cae. fireworks = emitterNew() emitterSetBlend(fireworks, PARTICLE_ADD) emitterSetSpeed(fireworks, 120, 320) emitterSetSpread(fireworks, 180) emitterSetGravity(fireworks, 0, 220) emitterSetDrag(fireworks, 0.8) emitterSetLife(fireworks, 0.8, 1.6) ---- [#emittersetsize] ==== emitterSetSize [source,text] ---- emitterSetSize(emitter, start, finish) emitterSetSize(emitter, start, finish, variation) ---- Tamaño al nacer y al morir, en unidades del overlay (2D) o unidades del mundo (3D), que cambia de manera uniforme a lo largo de la vida de la partícula; por defecto de `16` a `4` en 2D y de `0.2` a `0.05` en 3D. `variation` (de `0` a `1`, por defecto `0`) escala los dos tamaños de cada partícula nueva por un mismo factor aleatorio entre `1 - variation` y `1 + variation`, así que una bocanada es un poco más grande o más pequeña durante toda su vida. Los tamaños negativos pasan a `0` y `variation` se limita a su rango. Una partícula con textura se dibuja como un cuadrado de ese tamaño; una imagen más ancha se aplasta. *Desde:* 3.00. *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- El humo crece a medida que se aclara; las chispas encogen hasta desaparecer. emitterSetSize(smoke, 12, 48, 0.2) emitterSetSize(sparks, 5, 1) ---- [#emittersetcollide] ==== emitterSetCollide [source,text] ---- emitterSetCollide(emitter, mode) emitterSetCollide(emitter, mode, bounce) emitterSetCollide(emitter, mode, bounce, friction) emitterSetCollide(emitter, mode, bounce, friction, floor) ---- Contra qué rebotan las partículas. `COLLIDE_NONE` (por defecto) es nada; `COLLIDE_FLOOR` es un plano horizontal a la altura `floor`, por debajo del cual (Y mayor en 2D, Y menor en 3D) una partícula se devuelve al plano y se refleja; `COLLIDE_SCENE` es el mundo físico, solo para emisores 3D, mediante un rayo por partícula a lo largo de su movimiento en cada fotograma, con un tope de `200` rayos por emisor y fotograma que se reanudan donde se quedaron, así que un emisor grande reparte el coste entre unos pocos fotogramas. En un emisor 2D, `COLLIDE_SCENE` se acepta y no hace nada. Un modo distinto de esos tres termina el script. *Parámetros:* * `mode` -- `COLLIDE_NONE`, `COLLIDE_FLOOR` o `COLLIDE_SCENE`. * `bounce` -- cuánta velocidad sobrevive a un choque, de `0` a `1`, por defecto `0.5`; se limita a ese rango. * `friction` -- cuánta velocidad de deslizamiento se pierde en cada choque, de `0` a `1`, por defecto `0.2`; se limita a ese rango. * `floor` -- altura del plano del suelo, en unidades del mundo para un emisor 3D o en unidades del overlay para uno 2D, por defecto `0`. Solo la usa `COLLIDE_FLOOR`. *Desde:* 3.00. *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Las chispas rebotan en el suelo del taller (fila 440 del overlay) y dejan estelas. sparks = emitterNew() emitterSetBlend(sparks, PARTICLE_ADD) emitterSetGravity(sparks, 0, 500) emitterSetCollide(sparks, COLLIDE_FLOOR, 0.4, 0.2, 440) emitterSetTrail(sparks, 6, 3) -- Las brasas de la escena rebotan en lo que contenga el mundo físico. emitterSetCollide(embers, COLLIDE_SCENE, 0.3, 0.5) ---- [#emittersetcolor] ==== emitterSetColor [source,text] ---- emitterSetColor(emitter, r, g, b, a, r2, g2, b2, a2) ---- Color y opacidad al nacer (`r, g, b, a`) y al morir (`r2, g2, b2, a2`), cada uno de `0` a `255`, que cambian de manera uniforme a lo largo de la vida de la partícula; los valores fuera del rango se limitan. Por defecto, de blanco opaco a blanco transparente. El tinte multiplica la textura o el disco integrado, así que una imagen blanca toma el color exacto. Un fundido de entrada y salida necesita dos emisores o una textura que lleve esa forma; una receta sigue una sola rampa recta. *Desde:* 3.00. *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Llama: amarillo intenso en la base, que se apaga hasta un rojo tenue y desaparece. emitterSetColor(flame, 255, 200, 90, 255, 255, 40, 0, 0) -- Las motas de polvo aparecen desde la nada y se quedan flotando; su vida les pone fin. emitterSetColor(dust, 255, 240, 200, 0, 255, 240, 200, 160) ---- [#emittersetspin] ==== emitterSetSpin [source,text] ---- emitterSetSpin(emitter, min, max) ---- Velocidad de giro de cada partícula nueva en grados por segundo, sorteada entre los dos valores; los valores negativos giran en el otro sentido, y un rango que abarca el `0` da ambos. Toda partícula empieza con un ángulo aleatorio, gire o no. Por defecto, ninguno. El máximo se sube hasta el mínimo si es menor. El giro se nota en una partícula con textura o en una estela; el disco integrado es redondo y se ve igual en cualquier ángulo. *Desde:* 3.00. *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- Las hojas dan vueltas; las brasas apenas giran. emitterSetTexture(leaves, leafSprite) emitterSetSpin(leaves, -180, 180) emitterSetSpin(embers, -20, 20) ---- [#emittersetradius] ==== emitterSetRadius [source,text] ---- emitterSetRadius(emitter, radius) ---- Las partículas nuevas nacen en cualquier punto dentro de esta distancia del origen del emisor, repartidas uniformemente por un disco (2D) o una esfera (3D), en unidades del overlay o del mundo. Por defecto `0`, un punto; los valores negativos pasan a `0`. Un radio llena un volumen, la cabeza de una antorcha o una nube de polvo en el aire, donde un punto daría una aguja. *Desde:* 3.00. *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- Polvo en una bola de aire del tamaño de una habitación, casi todo ya presente cuando empieza el nivel. air = nodeNew() nodeSetPosition(air, 0, 2, 0) dust = emitterNew(air) emitterSetRadius(dust, 2.5) emitterSetSpread(dust, 180) emitterSetSpeed(dust, 0.05, 0.2) emitterSetLife(dust, 4, 6) emitterStart(dust) emitterBurst(dust, 120) ---- [#emittersetlit] ==== emitterSetLit [source,text] ---- emitterSetLit(emitter, lit) ---- Solo 3D: sombrea cada partícula según las luces y el cielo de la escena como si fuera una pequeña esfera orientada hacia la cámara, así que el humo toma el color de un brasero y el polvo destella al sol. Desactivado por defecto; no se tienen en cuenta las sombras. Con `PARTICLE_ADD`, la iluminación se aplica igualmente al tinte antes de sumarlo. Un emisor 2D guarda el valor y lo ignora. *Desde:* 3.00. *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- El humo sobre cada brasero recoge la luz de su fuego. for _, brazier in ipairs(braziers) do local smoke = emitterNew(brazier.top) emitterSetRate(smoke, 12) emitterSetLife(smoke, 2.5, 4) emitterSetColor(smoke, 90, 80, 75, 90, 50, 45, 45, 0) emitterSetLit(smoke, true) emitterStart(smoke) end ---- [#emittersetlocal] ==== emitterSetLocal [source,text] ---- emitterSetLocal(emitter, local) ---- Si las partículas vivas siguen al emisor. `false` (por defecto) deja cada una donde nació, así que una antorcha llevada por una habitación va dejando humo detrás; `true` mantiene toda la nube relativa al origen del emisor, así que el penacho de un propulsor se mueve con la nave. Cambiarlo pasa las partículas vivas de un sistema de referencia a otro sin saltos. En un emisor 3D, el origen es la posición en el mundo del nodo; en uno 2D es el punto de `emitterSetPosition`, así que una nube 2D local también se desliza con él. Las estelas siguen la misma regla. *Desde:* 3.00. *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- Un aura mágica que sigue envolviendo a la hechicera mientras camina. aura = emitterNew(nodeFind("Chest", wizard)) emitterSetLocal(aura, true) emitterSetRadius(aura, 0.6) emitterSetSpeed(aura, 0.05, 0.15) emitterSetSpread(aura, 180) emitterSetBlend(aura, PARTICLE_ADD) emitterStart(aura) ---- [#emittersetmax] ==== emitterSetMax [source,text] ---- emitterSetMax(emitter, count) ---- La reserva: cuántas partículas pueden estar vivas a la vez, por defecto `1000`. Una reserva llena no crea más, ni del flujo ni de una ráfaga, hasta que mueran algunas. Cambiar la cantidad reconstruye la reserva y mata todas las partículas vivas, así que fíjala antes de arrancar. Dimensiona la reserva como la tasa por la vida más larga, más la ráfaga más grande. Una cantidad menor que `1` termina el script. *Desde:* 3.00. *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- 300 gotas por segundo que viven hasta 1,6 segundos necesitan unas 480; deja margen. fountain = emitterNew(spout) emitterSetRate(fountain, 300) emitterSetLife(fountain, 1.0, 1.6) emitterSetMax(fountain, 600) emitterStart(fountain) ---- [#emittersetposition] ==== emitterSetPosition [source,text] ---- emitterSetPosition(emitter, x, y) ---- Solo 2D: dónde nacen las partículas nuevas, en coordenadas del overlay; por defecto `(0, 0)`. Fíjala en cada fotograma para seguir a un sprite o a un cuerpo físico 2D. Las partículas vivas se quedan donde están, salvo que `emitterSetLocal` esté activado. Un emisor 3D sigue a su nodo y aquí termina el script; mueve el nodo en su lugar. *Desde:* 3.00. *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Las chispas salen de donde esté el sprite de la amoladora en este fotograma. function onOverlayUpdate() overlayClear() spriteDraw(grinder, grinderX, grinderY) emitterSetPosition(sparks, grinderX + 24, grinderY + 8) emitterDraw(sparks) return OVERLAY_UPDATED end ---- [#emittersettrail] ==== emitterSetTrail [source,text] ---- emitterSetTrail(emitter, length, width) ---- Una cinta detrás de cada partícula a través de sus últimas `length` posiciones, registradas una por fotograma (de `2` a `16`; `0` o `1` para ninguna, el valor por defecto), de `width` de ancho en unidades del mundo o del overlay, igual en 2D que en 3D. La cinta lleva el tinte actual de la partícula, se desvanece hasta transparente hacia su cola y se texturiza con la columna central de la imagen de la partícula, de modo que el borde suave del disco pasa a ser el de la cinta. Los valores se limitan al rango. Cambiar la longitud reconstruye el almacenamiento de las estelas, así que las partículas vivas empiezan de nuevo sus cintas. Chispas, brasas, balas trazadoras, cometas. *Desde:* 3.00. *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Balas trazadoras: diminutas, rápidas y todo estela. tracers = emitterNew(gunMuzzle) emitterSetBlend(tracers, PARTICLE_ADD) emitterSetSpeed(tracers, 40, 50) emitterSetSpread(tracers, 2) emitterSetSize(tracers, 0.04, 0.04) emitterSetLife(tracers, 0.8, 1.0) emitterSetTrail(tracers, 8, 0.03) ---- [#emitterstart] ==== emitterStart [source,text] ---- emitterStart(emitter) ---- Inicia el flujo constante a la tasa fijada; a partir de entonces el emisor crea partículas en cada fotograma hasta `emitterStop`. Arrancar un emisor que ya está emitiendo no cambia nada. El flujo no necesita ninguna llamada de dibujo para funcionar; un emisor 2D que emite pero no se dibuja sigue gastando su reserva. *Desde:* 3.00. *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- El motor se enciende cuando el jugador acelera y se apaga cuando corta el gas. function onThrottle(open) if open then emitterStart(exhaust) else emitterStop(exhaust) end end ---- [#emitterstop] ==== emitterStop [source,text] ---- emitterStop(emitter) ---- Termina el flujo; las partículas vivas acaban sus vidas y se descarta cualquier fracción de partícula pendiente. `emitterBurst` sigue funcionando en un emisor detenido, y `emitterIsActive` sigue siendo `true` hasta que muere la última partícula. Usa `emitterClear` para eliminar además las partículas de inmediato. *Desde:* 3.00. *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- La antorcha se apaga: detén la llama y deja que el último humo se aleje. function onTorchDoused() emitterStop(flame) emitterStop(smoke) soundPlay(hiss) end ---- [#emitterburst] ==== emitterBurst [source,text] ---- emitterBurst(emitter, count) ---- Crea `count` partículas a la vez con la receta actual, haya flujo o no: una explosión, una salpicadura, una bocanada. Solo se crean tantas como quepan en la reserva; `emitterSetMax` fija el espacio. Un emisor 2D lanza la ráfaga en su punto de `emitterSetPosition`, y uno 3D en su nodo, repartida según `emitterSetRadius`. *Desde:* 3.00. *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Fuegos artificiales: una carcasa estalla en algún punto sobre el disco cada segundo, más o menos. function onOverlayUpdate() if frames % 60 == 0 then emitterSetPosition(fireworks, 120 + (frames * 7) % (width - 240), 80 + (frames * 13) % 160) emitterBurst(fireworks, 260) end overlayClear() emitterDraw(fireworks) frames = frames + 1 return OVERLAY_UPDATED end ---- [#emitterclear] ==== emitterClear [source,text] ---- emitterClear(emitter) ---- Mata ahora todas las partículas vivas y olvida cualquier fracción de partícula pendiente. El flujo no se detiene: un emisor arrancado sigue creando partículas nuevas en el siguiente fotograma, así que llama también a `emitterStop` para vaciarlo definitivamente. Úsala cuando cambie una escena y las chispas antiguas no deban quedarse. *Desde:* 3.00. *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- Reiniciar el nivel: los emisores se quedan, las partículas antiguas se van. function resetLevel() for _, emitter in ipairs(levelEmitters) do emitterStop(emitter) emitterClear(emitter) end placePlayer() end ---- [#emitterdraw] ==== emitterDraw [source,text] ---- emitterDraw(emitter) ---- Solo 2D, desde `onOverlayUpdate`: pone en cola las partículas del emisor para dibujarlas en este fotograma, por encima o por debajo del overlay según `emitterSetLayer`, en coordenadas del overlay llevadas a la ventana. Los emisores de la misma capa se dibujan en el orden en que se pusieron en cola. La cola se vacía en cada fotograma, así que la llamada va en cada fotograma en que deban verse las partículas; no dibujar un emisor no lo detiene. Se dibujan hasta `64` emisores por capa en un fotograma. Un emisor 3D se dibuja solo en la escena y aquí termina el script. *Desde:* 3.00. *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- function onOverlayUpdate() overlayClear() emitterSetPosition(smoke, chimneyX, chimneyY) emitterDraw(smoke) emitterDraw(sparks) fontPrint(20, 20, "Score " .. score) return OVERLAY_UPDATED end ---- [#emittergetcount] ==== emitterGetCount [source,text] ---- count = emitterGetCount(emitter) ---- Cuántas partículas del emisor están vivas en este fotograma, de `0` al tamaño de la reserva. Útil para ajustar una reserva, para un presupuesto en pantalla o para saber cuándo se ha apagado una ráfaga. *Devuelve:* la cantidad de partículas vivas, un entero. *Desde:* 3.00. *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- Overlay de desarrollo: cuánto de cada reserva está en uso. function onOverlayUpdate() overlayClear() fontPrint(20, 20, string.format("live %d / %d", emitterGetCount(fireworks), fireworksPool)) return OVERLAY_UPDATED end ---- [#emitterisactive] ==== emitterIsActive [source,text] ---- active = emitterIsActive(emitter) ---- Si el emisor está emitiendo o aún tiene partículas vivas: `true` desde `emitterStart` o `emitterBurst` hasta que se detiene el flujo y muere la última partícula, `false` cuando la explosión ha terminado. Un emisor que está emitiendo y cuya reserva resulta estar vacía sigue activo. *Devuelve:* booleano. *Desde:* 3.00. *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Las explosiones de un solo uso viven en una lista y se liberan cuando se consumen. function onOverlayUpdate() for i = #explosions, 1, -1 do if not emitterIsActive(explosions[i]) then emitterDelete(explosions[i]) table.remove(explosions, i) end end return OVERLAY_NOT_UPDATED end ---- [#font] === Fuente Singe tiene dos renderizadores de texto. La fuente de consola integrada la dibuja <> y no necesita recursos; la familia `font*` carga archivos TrueType con un tamaño en puntos fijo y los renderiza directamente sobre el overlay con `fontPrint` o en un sprite reutilizable con `fontToSprite`. Los handles de fuente son enteros que devuelve `fontLoad`, y el motor mantiene una única fuente seleccionada global, que `fontLoad` fija a la fuente que acaba de cargar y que `fontSelect` cambia. El texto se renderiza en el color de primer plano actual con la calidad elegida con `fontQuality`, y las posiciones del texto son coordenadas del overlay. Libera las fuentes con `fontUnload`; `Singe/FreeSansBold.ttf` viene con el motor y está siempre disponible. [#fontload] ==== fontLoad [source,text] ---- result = fontLoad(filename, pointSize) ---- Abre una fuente TrueType con un tamaño en puntos y la convierte en la fuente seleccionada. El tamaño es fijo durante toda la vida del handle, así que carga el mismo archivo una vez por cada tamaño que necesites. El nombre pasa por el sistema de archivos virtual como cualquier otro recurso; antepón `DIR` para los archivos que se distribuyen con tu juego, o usa `Singe/FreeSansBold.ttf` para el tipo de letra incluido. Un archivo que no se puede abrir termina el script con `Unable to open` y el nombre. *Parámetros:* * `filename` -- ruta a un archivo `.ttf`. * `pointSize` -- tamaño en puntos, entero. *Devuelve:* handle de fuente, entero. *Desde:* 1.x *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Dos tamaños del tipo de letra incluido: grande para el título, pequeño para el HUD. fontQuality(FONT_QUALITY_BLENDED) titleFont = fontLoad("Singe/FreeSansBold.ttf", 32) hudFont = fontLoad("Singe/FreeSansBold.ttf", 18) fontSelect(titleFont) titleSprite = fontToSprite("GAME OVER") fontSelect(hudFont) ---- [#fontprint] ==== fontPrint [source,text] ---- fontPrint(x, y, text) ---- Renderiza una cadena con la fuente seleccionada, la calidad actual y el color de primer plano actual (más el color de fondo con `FONT_QUALITY_SHADED`), y luego la vuelca sobre el overlay con su esquina superior izquierda en `(x, y)`. Llámala desde `onOverlayUpdate`, en cada fotograma si hace falta: el motor guarda cada cadena que renderiza, indexada por la fuente, la calidad, los colores y el texto, y una llamada posterior que pida la misma solo la vuelca. Las cadenas que no se imprimen durante un segundo, más o menos, se olvidan, y las cadenas de una fuente desaparecen cuando esta se descarga, así que el texto que cambia no cuesta más de lo que siempre costó y el que no cambia sale casi gratis. Reimprimir en cada fotograma una página entera de récords no es ningún problema. Sin una fuente seleccionada, el script termina con un error. *Parámetros:* * `x`, `y` -- esquina superior izquierda en coordenadas del overlay; las fracciones se truncan. * `text` -- la cadena que se renderiza. *Desde:* 1.x *Véase también:* <>, <>, <>, <> .Ejemplo [source,lua] ---- -- Lectura en directo de la calibración de audio; el texto cambia en cada fotograma, así que se renderiza directamente. function onOverlayUpdate() overlayClear() colorForeground(255, 255, 255, 255) fontSelect(hudFont) fontPrint(MARGIN_X, MARGIN_Y, "AUDIO DELAY CALIBRATION") fontPrint(MARGIN_X, MARGIN_Y + 30, "Delay: " .. calibrationDelay .. " ms") fontPrint(MARGIN_X, MARGIN_Y + 60, "Left / Right: 10 ms Up / Down: 1 ms") return OVERLAY_UPDATED end ---- [#fontquality] ==== fontQuality [source,text] ---- fontQuality(mode) ---- Selecciona cómo rasterizan el texto `fontPrint` y `fontToSprite` a partir de ahora. El ajuste es global, no por fuente, y empieza en `FONT_QUALITY_SOLID`. Los sprites que ya creó `fontToSprite` conservan la calidad con la que se renderizaron. Un valor que no sea una de las tres constantes termina el script con un error. *Parámetros:* * `mode` -- `FONT_QUALITY_SOLID` (`1`): la más rápida, bordes de un bit, sin suavizado. `FONT_QUALITY_SHADED` (`2`): glifos suavizados sobre un rectángulo sólido del color de fondo. `FONT_QUALITY_BLENDED` (`3`): glifos suavizados con alfa por píxel que se mezclan sobre cualquier cosa; la opción habitual. *Desde:* 1.x *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Texto mezclado sobre el vídeo, pero un recuadro de etiqueta sombreado para la lectura de depuración. fontQuality(FONT_QUALITY_BLENDED) titleSprite = fontToSprite("HIGH SCORES") colorBackground(0, 0, 0, 255) fontQuality(FONT_QUALITY_SHADED) debugLabel = fontToSprite("FRAME") fontQuality(FONT_QUALITY_BLENDED) ---- [#fontselect] ==== fontSelect [source,text] ---- fontSelect(id) ---- Hace que una fuente cargada sea la que usan `fontPrint` y `fontToSprite`. La selección se mantiene hasta el siguiente `fontSelect` o `fontLoad`, ya que cargar también selecciona. Puede cambiarse tantas veces como haga falta, incluso varias veces dentro de un mismo `onOverlayUpdate`. Un handle desconocido o descargado (o ningún número) deja seleccionada la fuente actual e imprime un aviso, porque Hypseus Singe lo deja pasar. *Desde:* 1.x *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- El título con el tipo de letra grande y la puntuación con el pequeño, en cada fotograma. function onOverlayUpdate() overlayClear() fontSelect(titleFont) fontPrint(TITLE_X, 40, "HIGH SCORES") fontSelect(hudFont) fontPrint(8, 8, "Score: " .. score) return OVERLAY_UPDATED end ---- [#fonttosprite] ==== fontToSprite [source,text] ---- result = fontToSprite(text) ---- Renderiza una cadena con la fuente, la calidad y los colores seleccionados en un sprite nuevo y devuelve su handle. A partir de ese momento el sprite es independiente de la fuente, así que sobrevive a `fontUnload`, y funciona con todas las llamadas `sprite*`: dibújalo, mídelo, escálalo o gíralo, y libéralo con `spriteUnload`. Para una cadena que cambia, renderiza un sprite nuevo y descarga el antiguo. Sin una fuente seleccionada, el script termina con un error. NOTE: Renderizar texto en un sprite por rendimiento está desaconsejado. `fontPrint` ahora guarda lo que renderiza, así que imprimir la misma cadena en cada fotograma cuesta más o menos lo mismo que dibujar un sprite hecho con ella, sin el sprite que gestionar. Usa `fontToSprite` cuando el texto tenga que ser un sprite: para escalarlo, girarlo, voltearlo o medirlo con `spriteGetWidth` y `spriteGetHeight`. *Devuelve:* handle de sprite, entero. *Desde:* 1.x *Véase también:* <>, <>, <>, <> .Ejemplo [source,lua] ---- -- Un título que entra girando y creciendo: texto que tiene que ser un sprite para poder girarlo y escalarlo. titleSprite = fontToSprite("GAME OVER") titleFrames = 0 function onOverlayUpdate() titleFrames = titleFrames + 1 spriteRotate(titleSprite, (titleFrames * 12) % 360) spriteScale(titleSprite, math.min(titleFrames / 30, 1)) spriteDraw(titleSprite, overlayGetWidth() / 2, overlayGetHeight() / 2, true) return OVERLAY_UPDATED end ---- [#fontunload] ==== fontUnload [source,text] ---- fontUnload(id) ---- Cierra una fuente e invalida su handle. Los sprites creados a partir de ella con `fontToSprite` no se ven afectados. Si la fuente descargada era la seleccionada, después no queda ninguna fuente seleccionada, y el siguiente `fontPrint` o `fontToSprite` termina el script con un error hasta que `fontSelect` o `fontLoad` elija otra. *Desde:* 2.00 *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- Libera las fuentes y los sprites de texto que produjeron. function onShutdown() spriteUnload(titleSprite) for _, line in ipairs(descriptionLines) do spriteUnload(line) end fontUnload(titleFont) fontUnload(hudFont) end ---- [#guiref] === Gui Una GUI es un árbol de documentos de RmlUi de tamaño fijo que se renderiza en su propia textura en cada fotograma: `guiNew` crea una y devuelve un handle entero, `guiLoad` pone en ella un documento RML y devuelve un handle de documento, `guiDraw` la compone sobre el overlay y `materialSetGui` la muestra en un material de la escena. Los documentos son RML, parecido a HTML, con estilo en RCSS, parecido a CSS, y se cargan a través del sistema de archivos virtual como los sprites; `Singe/gui.rcss` es el tema incluido y `Singe/FreeSansBold.ttf` el tipo de letra de respaldo. Las funciones auxiliares por id `guiGetValue`, `guiSetValue` y `guiSetHandler` llegan a los elementos sin pasar por el modelo de objetos; todo lo demás es la API de Lua de RmlUi en la global `rmlui`, donde una GUI es el contexto llamado `gui` seguido de su handle. Puede haber hasta dieciséis GUI a la vez, cada una con treinta y dos documentos. Una GUI necesita el dispositivo GPU, así que `guiNew` termina el script en una máquina sin él; un handle de GUI incorrecto provoca `No GUI N`, y un handle de documento incorrecto, `No document N in GUI G`. Véase <> en Desarrollo de juegos. [#guiclose] ==== guiClose [source,text] ---- guiClose(gui, document) ---- Descarga el documento de la GUI, y con él sus elementos, sus manejadores y sus scripts. Después, el handle del documento ya no es válido. Un documento que cierra su propio script mediante la API de RmlUi (`document:Close()`) se descarga del mismo modo. Provoca `No document N in GUI G` con un handle que no es válido. *Desde:* 3.00. *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- La página para introducir el récord solo se necesita una vez; descártala cuando esté el nombre. guiSetHandler(gui, entryPage, "enter", "click", function(g, d) scores[#scores + 1] = { name = guiGetValue(g, d, "name"), score = score } guiClose(g, d) entryPage = nil end) ---- [#guidelete] ==== guiDelete [source,text] ---- guiDelete(gui) ---- Libera la GUI, todos sus documentos y su textura, y detiene la actualización y el renderizado de cada fotograma que costaba. Los materiales que la mostraban mediante `materialSetGui` vuelven a su propia textura. Después, el handle ya no es válido y termina el script si se vuelve a usar; como una GUI sigue renderizándose se dibuje o no, borra las que un juego ya no necesite en lugar de ocultarlas. *Desde:* 3.00. *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- La página de opciones solo existe mientras el jugador está en ella. function closeOptions() guiDelete(optionsGui) optionsGui = nil discPlay() end ---- [#guidraw] ==== guiDraw [source,text] ---- guiDraw(gui) guiDraw(gui, x, y) guiDraw(gui, x, y, width, height) ---- Compone la textura de la GUI sobre el overlay en este fotograma, en coordenadas del overlay, así que respeta el borde Sinden y la escala de overscan como todo lo demás del overlay: cubriendo todo el overlay con un argumento, a su propio tamaño con una esquina en `x, y`, o escalada al rectángulo de `width` por `height`. El lugar de la llamada dentro del dibujo del script decide qué queda debajo de la GUI y qué encima: todo lo dibujado en el overlay antes de ella queda debajo, y todo lo dibujado después -- hasta el siguiente `guiDraw` o el siguiente turno del script -- va a una capa propia compuesta sobre esa GUI. Un puntero dibujado después del HUD se dibuja sobre el HUD; un segundo documento dibujado después de eso queda sobre el puntero, con su propia capa encima. Hasta dieciséis capas así por fotograma; una llamada que pase de ahí dibuja su GUI sin nada encima. `overlayClear` borra la capa en la que se esté dibujando, y `overlaySetMonochrome` solo pone en gris el overlay base. La llamada dura un fotograma; hazla desde `onOverlayUpdate` (o `singeMain`) en cada fotograma en que deba verse la GUI. Las GUI se dibujan sobre el overlay en el orden de las llamadas (la última, encima), por debajo de las partículas `PARTICLE_OVER` y del indicador de pausa, y la última GUI dibujada bajo el puntero es la que recibe el ratón. Se atienden hasta dieciséis llamadas por fotograma; las demás se ignoran. Cualquier otro número de argumentos provoca `Expected 1, 3 or 5 arguments`. *Parámetros:* * `x`, `y` -- la esquina superior izquierda, en coordenadas del overlay. * `width`, `height` -- el tamaño al que se dibuja, en píxeles del overlay; el tamaño propio de la GUI si se omiten. *Desde:* 3.00. *Véase también:* <>, <>, <> .Ejemplo [source,lua] ---- -- El HUD está en la esquina inferior derecha, a un tercio de la anchura del overlay, y la mira de -- la pistola se dibuja después, así que el panel nunca tapa la mira. function onOverlayUpdate() overlayClear() guiSetValue(hud, hudPage, "lives", tostring(lives)) guiDraw(hud, overlayGetWidth() * 0.66, overlayGetHeight() * 0.8, overlayGetWidth() * 0.33, overlayGetHeight() * 0.2) local x, y = mouseGetPosition(0) spriteDraw(sight, x, y, true) return OVERLAY_UPDATED end ---- [#guidrawscreen] ==== guiDrawScreen [source,text] ---- guiDrawScreen(gui) guiDrawScreen(gui, x, y) guiDrawScreen(gui, x, y, width, height) ---- Igual que <>, salvo que el rectángulo se mide sobre toda la ventana en lugar de sobre la imagen. Las coordenadas siguen siendo coordenadas del overlay -- `0, 0` es la esquina superior izquierda de la ventana y `overlayGetWidth(), overlayGetHeight()` la inferior derecha -- así que una GUI puede colocarse sobre la ilustración del bezel, junto a la imagen, encima o debajo de ella. Sin bezel, y sin `--scalefactor`, `--shiftx` ni `--shifty`, la imagen es toda la ventana y esto es exactamente `guiDraw`. Las GUI en espacio de pantalla se dibujan después de todo lo demás, incluida la ilustración del bezel, y comparten con `guiDraw` los dieciséis dibujos por fotograma. Un clic las encuentra del mismo modo: el puntero se lleva al espacio de pantalla antes de comprobarlo, así que una GUI en espacio de pantalla que acepta entrada recibe el ratón dondequiera que se haya dibujado. *Parámetros:* * `x`, `y` -- la esquina superior izquierda, en coordenadas del overlay medidas sobre la ventana. * `width`, `height` -- el tamaño al que se dibuja; el tamaño propio de la GUI si se omiten. *Desde:* 3.00. *Véase también:* <>, xref:Manual.adoc#bezels[Bezels], <> .Ejemplo [source,lua] ---- -- Un mapa en el pilar izquierdo de la ilustración de la recreativa, fuera de la imagen. function onOverlayUpdate() overlayClear() guiDrawScreen(mapGui, 8, 44, 38, 114) return OVERLAY_UPDATED end ---- [#guigetheight] ==== guiGetHeight [source,text] ---- height = guiGetHeight(gui) ---- La altura de la GUI en sus propios píxeles, tal como se dio a `guiNew`. Termina el script con un handle que no es válido. *Devuelve:* un entero. *Desde:* 3.00. *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- Centra un pequeño cuadro de diálogo en el overlay. local x = (overlayGetWidth() - guiGetWidth(dialog)) / 2 local y = (overlayGetHeight() - guiGetHeight(dialog)) / 2 guiDraw(dialog, x, y) ---- [#guigetstats] ==== guiGetStats [source,text] ---- stats = guiGetStats() ---- Lo que costó renderizar todas las GUI en el último fotograma completo, para ajustar documentos que usan filtros, máscaras y sombras (cada uno de los cuales dibuja a través de una capa del tamaño de la GUI). Los contadores abarcan todas las GUI juntas. Todos valen cero en un fotograma en el que no cambió la imagen de ninguna GUI, ya que no se volvió a dibujar nada (véase <>). *Devuelve:* una tabla con los campos `drawCalls` (dibujos indexados, incluidas las escrituras de máscara, las composiciones y las pasadas de filtro), `layerPushes` (capas en las que se entra por filtros, imágenes de máscara y sombras de caja), `filterPasses` (pasadas a tamaño completo y copias dentro de las cadenas de filtros; un desenfoque cuesta varias), `maskWrites` (dibujos en la máscara de recorte del stencil), `layerBytes` (bytes de texturas de capa reservados en este momento; cada una tiene el tamaño de su GUI a cuatro bytes por píxel) y `passes` (pasadas de renderizado iniciadas; cada cambio de capa inicia una). *Desde:* 3.00. *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- Observa lo que cuesta una pantalla llena de efectos. local s = guiGetStats() debugPrint(string.format("gui draws %d layers %d filters %d passes %d, %d KB of layers", s.drawCalls, s.layerPushes, s.filterPasses, s.passes, s.layerBytes // 1024)) ---- [#guigetvalue] ==== guiGetValue [source,text] ---- value = guiGetValue(gui, document, id) ---- El valor del elemento con ese `id` en el documento, como cadena: el valor de un control de formulario (el texto de un campo de texto, el `value` de una opción de `select`, un control de rango con el formato propio de RmlUi, como `"85.000000"`, el atributo `value` de una casilla de verificación o de un botón de opción, o `"on"` cuando no lo tiene, esté marcado o no; su estado es su atributo `checked`, que se lee mediante la API de RmlUi) o, para cualquier otro elemento, su RML interno. Devuelve `nil` cuando ningún elemento tiene ese id, así que se puede sondear una página sin terminar el script; un handle de GUI o de documento incorrecto sí lo termina. Los valores de más de 4095 caracteres se recortan. *Devuelve:* una cadena, o `nil`. *Desde:* 3.00. *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- Lee todo el formulario cuando se pulsa Start. guiSetHandler(gui, doc, "go", "click", function(g, d) player.name = guiGetValue(g, d, "name") player.hard = guiGetValue(g, d, "difficulty") == "hard" player.volume = math.floor(tonumber(guiGetValue(g, d, "volume"))) end) ---- [#guigetwidth] ==== guiGetWidth [source,text] ---- width = guiGetWidth(gui) ---- La anchura de la GUI en sus propios píxeles, tal como se dio a `guiNew`. Termina el script con un handle que no es válido. *Devuelve:* un entero. *Desde:* 3.00. *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- ¿Está el punto sondeado en la mitad derecha de la página del monitor? local g, px, py = sceneProbeGui(mouseX, mouseY) if g ~= nil and px > guiGetWidth(g) / 2 then showRightTooltip() end ---- [#guihide] ==== guiHide [source,text] ---- guiHide(gui, document) ---- Oculta el documento: no se dibuja ni recibe entrada, pero conserva sus elementos y valores, y `guiShow` lo devuelve tal como estaba. Provoca `No document N in GUI G` con un handle que no es válido. *Desde:* 3.00. *Véase también:* <>, <> .Ejemplo [source,lua] ---- -- Dos páginas en una GUI: la página de opciones se oculta mientras se muestra la de controles. guiSetHandler(gui, options, "controls", "click", function() guiHide(gui, options) guiShow(gui, controls) end) ---- [#guiload] ==== guiLoad [source,text] ---- document = guiLoad(gui, filename) ---- Carga un documento RML en la GUI, lo muestra y devuelve un handle de documento entero para las demás llamadas. El nombre pasa por el sistema de archivos virtual como el de un sprite, relativo a la carpeta desde la que se arrancó Singe, así que antepón `DIR` para los archivos que se distribuyen con tu juego; las hojas de estilo, imágenes y fuentes nombradas dentro del documento se resuelven relativas al archivo que las nombra y luego mediante la misma búsqueda, así que un juego empaquetado las encuentra. Los bloques `