= Riferimento di 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: it :imagesdir: .. include::locale.adoc[] [#_game_development] == Sviluppo di giochi [#_lua] === Lua Singe usa il linguaggio di programmazione Lua (http://www.lua.org) per scrivere la logica dei giochi. Lua è veloce, leggero, orientato agli oggetti, facile da usare e usato davvero nell'industria dei videogiochi. Questo libro non lo insegna; lo fa _Impara a programmare con Singe_, nello stesso download di questo, partendo da zero fino a un gioco finito in trentuno lezioni. Ci sono video tutorial su Lua sul canale YouTube di Kangaroo Punch, e il manuale di riferimento del linguaggio si trova su http://www.lua.org. [#_basic_rules] === Regole di base * Singe è multipiattaforma. Cose di cui di solito non devi preoccuparti su Windows qui sono importanti. I nomi dei file distinguono tra maiuscole e minuscole -- `MyScript.singe` e `myscript.singe` non sono lo stesso file. Dovresti anche usare i separatori di percorso UNIX -- `/` invece di `\`. Così il codice è più pulito (niente barre rovesciate con doppio escape) e funziona ovunque Singe sia supportato. * Scegli un nome distintivo per la cartella del gioco. Non usare spazi. * Usa il Singe Framework incluso. Non copiare il framework negli script del tuo gioco. Includendo il framework fornito aiuti la compatibilità futura man mano che Singe viene aggiornato. Inizia (o termina) lo script del gioco con: + [source,lua] ---- dofile("Singe/Framework.singe") ---- * Mantieni il gioco autosufficiente. Se lo costruisci su un framework di terze parti o condividi codice tra i tuoi giochi, copialo nella cartella del gioco. Non fare mai riferimento a una cartella accanto al gioco; l'unico file fuori dal gioco che uno script può caricare è `Singe/Framework.singe`. Un gioco in un solo file (vedi <>) impacchetta esattamente una cartella, e un gioco che esce da essa non può essere impacchettato. * Stai fuori dalla cartella `Singe/`. È gestita da Singe, e qualunque cosa vi sia aggiunta o modificata può essere cancellata in futuro. * Includi un `games.dat`. È estremamente importante per i nuovi utenti. Anche se esistono altri front-end e sistemi di menu basati su Singe, è fondamentale che tu includa i dati per il menu integrato. Quasi tutti i problemi di assistenza sono dovuti a file `games.dat` mancanti o malformati. * Non distribuire file non essenziali. Non distribuire mai i binari di Singe o gli script forniti da Singe con il tuo gioco. Non distribuire i file indice generati dai video. [#reloading] === Ricaricare mentre lavori Avvia il gioco con `--reload` (`-R`) e Singe sorveglia ogni file di script sciolto che il gioco ha caricato. Salva una modifica a uno di essi e il gioco riparte dal suo script un attimo dopo: tutto ciò che lo script ha creato se ne va (sprite, suoni, font, video, GUI, la scena 3D, la fisica, gli emettitori, l'overlay torna alla dimensione predefinita, il volume degli effetti e il mouse tornano allo stato di avvio), il motore resta (la finestra, la GPU, i controller), il disco viene di nuovo fermato sul suo primo fotogramma come all'avvio, e lo script riparte da capo, così un modello da 52 MB torna sullo schermo nel tempo necessario a caricarlo invece che nel tempo necessario ad avviare Singe. Un tasto o un pulsante tenuto premuto durante il ricaricamento viene ignorato finché non viene rilasciato, come a un avvio da zero. `F5` ricarica a richiesta, e uno script può chiederlo con `singeReload()`. I giochi impacchettati non hanno niente da sorvegliare; l'opzione è per un gioco in una cartella. Un errore di script durante il ricaricamento viene stampato e il gioco resta vuoto finché il ricaricamento successivo non lo corregge; il file resta sorvegliato, quindi basta salvare la correzione. Poiché il ricaricamento esegue la stessa procedura di smontaggio dell'uscita dal gioco, qualunque risorsa uno script si lasci sfuggire si vede qui per prima. [[zerobrane]] === Fare il debug in ZeroBrane Studio https://studio.zerobrane.com[ZeroBrane Studio] è un IDE Lua gratuito, e Singe porta con sé quello che serve per avviarci un gioco e farne il debug: breakpoint, esecuzione passo passo, le finestre Stack e Watch, un gioco che resta fermo finché è fermo il debugger, una sosta nell'IDE quando un errore sta per chiudere il gioco, e l'autocompletamento con i tooltip per tutta l'API, in inglese, francese, italiano, tedesco o spagnolo. La parte del debugger è MobDebug, integrato in Singe e avviato da `--debug`, quindi a un gioco non serve nessuna riga sua; qualsiasi altro strumento che parli il protocollo di MobDebug può collegarsi allo stesso modo. [[zerobranesetup]] ==== Prepararlo Singe decomprime un pacchetto per ZeroBrane Studio nella sua cartella di supporto, come `Singe/ZeroBrane/singe.lua`, e lo tiene aggiornato da una versione all'altra. Di' a ZeroBrane Studio dove si trova con una sola riga nel suo `user.lua` (Modifica > Preferenze > Impostazioni: Utente), poi riavvialo: [source,lua] ---- package "/home/me/Singe/Singe/ZeroBrane/singe.lua" ---- Singe compare allora in Progetto > Interprete Lua, con Progetto > Debug con Singe e un pulsante Singe nella barra degli strumenti per avviare un gioco sotto il debugger in un solo passo. I file `.singe` si aprono come Lua, i documenti `.rml` come XML e i fogli di stile `.rcss` come CSS. Fai della cartella del gioco il progetto (Progetto > Directory del Progetto > Scegli...). Il pacchetto cerca Singe in quella cartella e nelle tre sopra, prendendo la versione più recente per la tua macchina, e poi nel `PATH`; se non ne trova nessuno te lo chiede la prima volta che avvii un gioco, e se lo ricorda. Progetto > Scegli l'eseguibile di Singe... ne indica un altro, e `path.singe = "/path/to/Singe"` in `user.lua` ha la meglio su entrambi. [[zerobranerun]] ==== Avviare e fare il debug Progetto > Lancia (`F6`) avvia il gioco, e Progetto > Inizia il Debug (`F5`) lo avvia sotto il debugger. Lo script è il file di partenza del progetto, se ce n'è uno (fai clic destro su di esso nell'albero del progetto e scegli Imposta come File di Partenza), e altrimenti il file che stai modificando; quando questo è uno dei moduli del gioco invece di un file `.singe`, parte l'unico file `.singe` del progetto. Singe parte dalla cartella che contiene il suo eseguibile, come lo avvierebbe il menu, così `Singe/Framework.singe` e ogni nome relativo a quella cartella si risolvono come sempre, e la voce del gioco in `games.dat` fornisce il suo video e le sue impostazioni. Progetto > Parametri Riga di Comando... aggiunge opzioni tue, tra cui `-v` e un video per un gioco senza voce. Fai clic nel margine per mettere un breakpoint. Il gioco si ferma lì con le sue variabili locali nella finestra Stack, la finestra Watch valuta espressioni, e Step Into (`F10`), Step Over (`Shift+F10`) e Step Out (`Ctrl+F10`) si comportano come in qualsiasi programma Lua, anche dentro `singeMain`. Progetto > Interrompi ferma un gioco dovunque sia. Finché il gioco è fermo, il disco, ogni video e ogni suono vanno in pausa proprio come con il tasto di pausa, e poi riprendono dallo stesso punto. Quando un errore sta per chiudere il gioco -- un errore Lua, o una chiamata del motore che riceve qualcosa che non sa usare -- l'IDE si ferma prima, nel `Debugger.singe` della cartella Singe, e la finestra Output dice che cosa è andato storto. La finestra Stack tiene sotto quel file i frame dello script stesso, con le loro variabili locali com'erano. Continua, e il gioco finisce come sarebbe finito, con l'errore e un traceback nella finestra Output; fai doppio clic su una riga del traceback per aprire il file in quel punto. Il gioco gira con `--reload`, quindi salvare uno script fa ripartire il gioco e il debugger con lui, breakpoint compresi. Progetto > Ricarica Singe quando cambia uno script lo disattiva. [[zerobranecompletion]] ==== Autocompletamento e Riferimento L'autocompletamento propone ogni funzione, callback e costante dell'API, e un tooltip ne mostra gli argomenti e il primo paragrafo della voce. Progetto > Analizza (`Shift+F7`) conosce anch'esso l'API, quindi segnala una chiamata scritta male invece di ogni chiamata. È l'impostazione della lingua di ZeroBrane Studio a scegliere la lingua dei tooltip: `language = "fr"` in `user.lua` dà il francese, e lo stesso vale per l'italiano (`it`), il tedesco (`de`) e lo spagnolo (`es`). Fai clic destro su un nome e scegli Cerca nel Riferimento di Singe per aprire la sua voce in un browser; Aiuto > Riferimento di Singe apre l'inizio. Tutti e due hanno bisogno del download della documentazione decompresso accanto a Singe, come `Singe-Docs-v3.00` o `Singe-Docs-v3.00-fr` e così via, prendendo per primo quello nella lingua di ZeroBrane Studio; `singe = { reference = "/path/to/Reference.html" }` in `user.lua` ne indica un altro. [[zerobranecabinet]] ==== Fare il debug di un cabinato Il debugger funziona attraverso una rete. Sulla macchina con ZeroBrane Studio, apri la cartella del gioco come progetto e scegli Progetto > Avvia Debugger Server. Sul cabinato, avvia il gioco con `--debug=HOST`, dove `HOST` è il nome o l'indirizzo di quella macchina; la porta è `8172` a meno che `--debug=HOST:PORT` non ne dica un'altra, e la prima macchina deve accettare connessioni su di essa. Un cabinato che avvia i suoi giochi dal menu può avere invece `debug = "HOST:PORT"` nel suo file delle impostazioni, e ogni gioco che avvia si collega, menu compreso. L'IDE ferma ciascuno alla sua prima riga, dove Continua (`F5`) lo fa partire; `debugger.runonstart = true` in `user.lua` lo lascia andare dritto fino al primo breakpoint. Il cabinato comunica i propri percorsi, che ZeroBrane Studio fa corrispondere ai file del progetto la prima volta che si ferma, così basta la stessa cartella del gioco su entrambe le macchine. Se nessuno è in ascolto, un gioco si gioca come al solito una volta che il tentativo di collegarsi rinuncia, il che non richiede più di due secondi. [[zerobranelimits]] ==== Limiti * Fai il debug di un gioco non impacchettato, dalla sua cartella. Un `.game` impacchettato non ha file su disco che l'IDE possa aprire. * Un errore dentro `singeMain` si ferma in `Framework.singe`, che esegue `singeMain` come una coroutine; il messaggio indica la riga del tuo script. * Finché l'IDE tiene il gioco fermo, la finestra non si ridisegna, e chiuderla aspetta che tu continui. Ferma il processo (`Shift+F5`) chiude subito il gioco. * Singe ferma uno script che non restituisce mai il controllo quando si chiude la sua finestra, ma non sotto il debugger, che possiede lo script mentre gira; Interrompi trova dove è bloccato. [[deterministic]] === Modalità di test deterministica `--deterministic` è uno strumento di test, non un modo di giocare. Fa sì che il motore ignori del tutto il tempo reale: invece di chiedere alla macchina che ora è, il motore gira su un orologio virtuale che avanza di un passo fisso -- 15 millisecondi per impostazione predefinita -- esattamente una volta per fotogramma. Tutto ciò che è scandito dal tempo segue quell'orologio: i passi della fisica, l'animazione di sprite e modelli, le particelle, la navigazione, le animazioni e le transizioni della GUI, `os.clock()`, `singeGetTicks()` e un `os.time()` senza argomenti (un gioco che inizializza il suo generatore dall'orologio quando comincia una partita lo inizializza da quello virtuale, quindi estrae gli stessi numeri a ogni esecuzione; `os.time` con una tabella di data la converte come Lua ha sempre fatto). Anche il disco lo segue: mentre è in riproduzione, ogni fotogramma video resta visibile per tanti fotogrammi del motore quanti ne vale il suo tempo di presentazione su quell'orologio (due o tre con il passo predefinito per un disco a trenta fotogrammi), come nel gioco reale, e un disco in pausa o dopo un salto resta dove è stato messo. Il generatore di Lua e quello del motore partono da un seme fisso. Niente aspetta niente, quindi il gioco gira alla massima velocità che la macchina gli consente. Il senso di tutto questo è che uno screenshot preso a un numero di fotogramma fisso è la stessa immagine in ogni esecuzione. Senza, il fotogramma 40 è un momento diverso ogni volta -- la fisica ha fatto un numero diverso di passi e il disco è su un fotogramma video diverso -- quindi due esecuzioni dello stesso gioco invariato possono differire di un milione di pixel, e una modifica vera può nascondersi in quel rumore. Il prezzo è tutto ciò che dipende dal tempo reale. L'audio suona ancora sull'orologio del dispositivo, che nessun orologio virtuale raggiunge, quindi il suono si allontana dall'immagine e non significa nulla -- a meno che l'esecuzione non venga registrata con `--record`, nel qual caso il suono viene mixato in memoria un passo alla volta insieme all'immagine, così una registrazione fatta in questo modo è esatta fotogramma per fotogramma e campione per campione (vedi <>). Nemmeno la frequenza dei fotogrammi significa nulla, né qualsiasi cosa un gioco misuri in secondi. Non riportare mai un dato di prestazioni, un problema audio o un problema di tempistica da un'esecuzione in questa modalità, e non distribuire mai un launcher che la usi. Il numero che l'opzione porta, se ne porta uno, è sia la dimensione del passo sia il seme da cui partono i generatori, da `1` a `1000`: ---- Singe --deterministic il passo predefinito di 15 ms, seme 15 Singe --deterministic=20 un passo di 20 ms, seme 20 ---- Due esecuzioni con lo stesso numero sono identiche; due esecuzioni con numeri diversi sono ciascuna ripetibile ma diverse tra loro, ed è così che un test chiede un'esecuzione diversa ma ugualmente riproducibile. Si può impostare in un file di impostazioni come `deterministic`, come qualsiasi altra opzione. [#_reference_screenshots] ==== Screenshot di riferimento Le scene in `testScripts` prendono ciascuna uno o due screenshot a un numero di fotogramma fisso e si chiudono da sole, quindi in questa modalità producono una serie di immagini di riferimento con cui una modifica successiva può essere confrontata pixel per pixel. Per registrarle, esegui ogni scena con l'opzione e conserva ciò che finisce nella cartella dei dati: [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 ---- Le scene lasciate fuori da quel ciclo vogliono le loro opzioni, le stesse che assegna loro la tabella in `testScripts/README.md`: `scene12` gira senza alcun disco, `scene17` con il disco e `scene20` con `-C 720x480`, tutte e tre con `SDL_GPU_DRIVER=nothing`; da `scene27` a `scene35` vogliono `-x 1280 -y 720 -C 1280x720` e nessun disco; `scene46` vuole `-g '10 5'`; e `scene47` vuole `--bezeldir=testScripts/bezels --bezel=cabinet.png`. Copia i file `singe*.png` fuori da `data/testScripts` dopo ogni esecuzione, perché l'esecuzione successiva numera di nuovo i suoi screenshot da zero. Confrontare una build successiva è poi questione di eseguire gli stessi comandi e chiedere la differenza, che deve essere nulla: [source,sh] ---- compare -metric AE reference/scene36-singe000.png new/scene36-singe000.png null: ---- [#_where_files_live] === Dove stanno i file I percorsi dentro uno script sono relativi alla cartella da cui Singe è stato avviato, non allo script. Usa la variabile globale `DIR`, che `Framework.singe` imposta sulla cartella dello script stesso, così un gioco funziona ovunque sia installato: [source,lua] ---- crosshair = spriteLoad(DIR .. "sprite_Crosshair.png") ---- Tutto ciò che Singe scrive va nella cartella dei dati (`singeGetDataPath()`), `data/` accanto ai giochi a meno che `--datadir` non dica altrimenti, così un gioco può stare su un supporto di sola lettura. Singe 2.x scriveva salvataggi e impostazioni nella cartella del gioco stesso; un gioco che lo fa ancora non funziona su un'installazione di sola lettura, quindi tieni ogni `io.output` sotto `singeGetDataPath()`. [#_packaging_your_game] === Impacchettare il tuo gioco Un gioco finito si distribuisce come un unico file `.game`, descritto qui sotto. Non serve nient'altro: il giocatore copia il file accanto al binario di Singe e il menu lo elenca. Singe non installa più archivi. [#singlefile] === Giochi in un solo file Un gioco può essere un solo file. `singe --pack` scrive la cartella del gioco in un database SQLite, e Singe lo esegue da lì: script, sprite, suoni, font, framefile e video si caricano tutti direttamente dal database, e installare il gioco significa copiare il file accanto all'eseguibile. Il menu elenca ogni file `.game` che trova accanto alle cartelle dei giochi. ---- Singe --pack MyGame MyGame.game Impacchetta la cartella MyGame in MyGame.game Singe MyGame.game Esegue la prima voce di games.dat che contiene Singe --entry=2 MyGame.game Esegue la sua seconda voce Singe --unpack MyGame.game MyGame Riscrive fuori i file Singe --patch MyGame.game fixes Sostituisce i file da una cartella (o da un database di patch) ---- Niente in un gioco deve cambiare perché possa essere impacchettato. Ogni nome che uno script usa, che sia tramite `dofile`, `require`, `io.open` e i suoi parenti, `spriteLoad`, `soundLoad`, `fontLoad`, `videoLoad`, un framefile o un `controls.cfg`, passa per un'unica ricerca. Per un gioco sciolto quella ricerca è il normale filesystem, relativo alla cartella da cui Singe è stato avviato, esattamente come prima. Per un gioco impacchettato un nome viene cercato in tre posti, in quest'ordine, e vince il primo che lo trova: . Una cartella sciolta con il nome del database senza estensione (`MyGame/` accanto a `MyGame.game`). È così che continui a modificare un gioco impacchettato, ed è così che un giocatore ne fa una mod: i file messi lì vincono sulle copie impacchettate. . La cartella dei dati del gioco, sotto `files/`. Tutto ciò che un gioco scrive con `io.output` o `io.open(name, "w")` finisce qui, quindi un `game.cfg` salvato vince sull'originale impacchettato e il database non viene mai scritto. . Il database stesso. Dentro un database, i nomi vengono confrontati senza distinguere tra maiuscole e minuscole, con l'uno o l'altro separatore di percorso, e relativi alla radice del gioco. Un primo componente uguale al nome della cartella del gioco stesso viene ignorato, quindi `MyGame/Cfg/game.cfg`, `Cfg/game.cfg` e `DIR .. "Cfg/game.cfg"` trovano tutti lo stesso file. Il nome del database stesso (`MyGame.game`, con o senza separatore finale) è la radice del gioco, come lo è `.` dall'interno. I nomi che cominciano con `Singe/` o con la cartella dei dati restano sul filesystem; `Singe/Framework.singe` e `singeGetDataPath()` funzionano come sempre, e un tale nome può usare `..` purché resti sotto quel prefisso (il `controls.cfg` alla radice della cartella dei dati si trova così). Qualsiasi altro nome relativo con un componente `..` non porta da nessuna parte dentro un gioco impacchettato: `io.open` restituisce `nil` con un messaggio che dice che il nome esce dal gioco, `lfs.attributes` restituisce `nil`, e `spriteLoad` e simili segnalano il file come mancante. La radice del gioco è una cartella, non un file: `io.open` su di essa restituisce `nil`, e `lfs.mkdir` e `lfs.rmdir` la rifiutano. `lfs.dir` su una cartella impacchettata elenca l'unione dei tre posti, ordinata senza distinguere tra maiuscole e minuscole; quando un file sciolto e uno impacchettato differiscono solo per maiuscole e minuscole, viene elencata la grafia di quello sciolto. `lfs.attributes` su un file impacchettato risponde come fa il vero `lfs`: l'intera tabella con un argomento, un campo per nome (un nome sconosciuto solleva un errore) o una tua tabella riempita; un file mancante restituisce `nil`, un messaggio e il numero dell'errore. `require("name")` trova `name.lua`, `name/init.lua` o `name.singe` sotto la cartella dello script, poi sotto la radice del gioco. `io.open`, `io.lines` e `io.input` su un nome che vive solo dentro il database restituiscono un file di Singe invece di uno di Lua. L'handle di file di Lua è un `FILE *` del C, e una riga di database non è un file, quindi leggerne una significava scriverla accanto al gioco e aprire quella copia; l'handle di Singe legge la riga dove si trova. Risponde a tutto ciò a cui risponde quello di Lua -- `read` con ogni formato (`"a"`, `"l"`, `"L"`, `"n"` e un numero di byte), `lines`, `seek`, `close`, `flush` e `setvbuf` -- `io.type` lo chiama file, `io.read` e `io.close` lo accettano, e si chiude da solo quando viene raccolto dal garbage collector o quando una variabile `` esce dal suo ambito. L'unica differenza è che scriverci è un errore, perché ciò che sta leggendo è dentro il database e il database non viene mai scritto. Un gioco che scrive un file ne ottiene uno vero nella sua cartella dei dati esattamente come prima, e lo stesso vale per ogni nome che non è impacchettato. `testScripts/packedIo` è il test che tiene tutto questo fedele al comportamento di Lua stesso; la sua intestazione spiega come eseguirlo. L'impacchettatore rifiuta una cartella che non ha un `games.dat`, che contiene `controls.dat`, `Framework.singe`, un file con estensione `exe`, `sh`, `bat` o `cmd`, o un file senza estensione il cui nome comincia con `singe`, che ha una voce di primo livello con lo stesso nome della cartella (renderebbe ambigua la regola della cartella del gioco stesso), o i cui script e file di dati escono dal gioco con `..`; segnala ognuno di questi file, con la riga. Due file i cui nomi differiscono solo per maiuscole e minuscole non possono essere impacchettati entrambi. I file `.index` obsoleti vengono saltati. `--unpack` rifiuta un database i cui nomi memorizzati finirebbero fuori dalla cartella di destinazione (un percorso assoluto, una lettera di unità o un componente `..`). Un gioco che fa riferimento alla cartella di un altro gioco (un framework condiviso accanto a esso) deve prima copiare quella cartella al suo interno. Prima del commit del database, il suo `games.dat` viene controllato: ogni voce ha bisogno di uno `SCRIPT`, e ogni `SCRIPT`, `VIDEO`, `CABINET`, `MARQUEE` e `ATTRACT` che nomina deve essere un file nel database (o sotto `Singe/`); un percorso errato viene segnalato con il titolo della voce e l'impacchettamento viene rifiutato. `--patch` esegue lo stesso controllo sul gioco modificato, così nemmeno una patch può lasciarsi dietro un percorso che non porta da nessuna parte. `--patch` accetta o una cartella organizzata come il gioco, i cui file sostituiscono quelli impacchettati, o un file `.patch` creato da `--pack` da una tale cartella (`Singe --pack fixes MyGame-1.1.patch`; una patch non ha bisogno di `games.dat`). I file che la patch non nomina restano come sono. In entrambi i casi la modifica è un'unica transazione: se viene interrotta, il gioco resta intatto. Una patch può anche portare una tabella `removed(path)` che nomina i file da cancellare. Un file `.patch` può essere spacchettato come un gioco, ma non può essere eseguito e il menu lo ignora. Una patch che sostituisce un file grande con uno piccolo lascia dentro il database lo spazio che occupavano i vecchi contenuti, dove SQLite lo tiene per la prossima cosa scritta invece di restituirlo. Va bene per una patch che modifica uno script, ed è uno spreco per uno sviluppatore che reimpacchetta un video una dozzina di volte: il `.game` può finire per essere diverse volte più grande del suo contenuto. Così `--patch` guarda lo spazio libero dopo il commit della transazione e, quando ce n'è almeno un megabyte, compatta il database e dice quanto ha recuperato: ---- >>> Recovered 8007680 bytes of space the old contents were using ---- Il risultato è, byte per byte, grande quanto un impacchettamento nuovo degli stessi file. La compattazione riscrive l'intero file, quindi viene saltata quando c'è poco da guadagnare. Nient'altro in Singe ne ha bisogno; un gioco viene scritto soltanto dall'impacchettatore. La cartella dei dati di un gioco impacchettato prende il nome dal database e poi dalla cartella dello script al suo interno: `MyGame/` per un `MyGame.game` il cui script sta alla sua radice, sotto la cartella dei dati (`data/MyGame/` per impostazione predefinita), che è dove la ha il `MyGame/MyGame.singe` sciolto, così salvataggi e impostazioni sono condivisi tra un'installazione sciolta e una impacchettata; uno script impacchettato in una sottocartella (`Game/Main.singe`) ottiene `MyGame/Game/`. Anche l'indice del video va lì. NOTE: Le build 3.00 precedenti davano a un gioco impacchettato il cui script stava in una sottocartella la cartella dei dati `Game/` sotto la base, condivisa da ogni archivio con quella struttura. I salvataggi di un gioco del genere ora stanno sotto `MyGame/Game/`; spostali a mano. Le build 3.00 precedenti tenevano anche la cartella del gioco stesso, quella sotto cui il suo `games.dat` nomina lo script, quindi un `MyGame.game` impacchettato da `MyGame/` scriveva in `MyGame/MyGame/`; sposta ciò che c'è lì su di un livello. Quando Singe esegue un gioco da un database il menu passa `CONTAINER` nella voce di `games.dat` che lancia; il campo viene impostato dal menu, mai a mano. Per i tuoi dati, `require("sqlite3")` dà agli script il binding https://lua.sqlite.org[lsqlite3] allo stesso SQLite che usa il motore. Tieni i database che il gioco scrive in `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` trova un nome come fa `io.open`, quindi un database può essere distribuito dentro il gioco -- tabelle dei livelli, dialoghi, elenchi di oggetti -- e aprirsi comunque quando il gioco è impacchettato. Aperto in sola lettura, un database impacchettato viene letto dove si trova, senza copiare nulla: [source,lua] ---- local levels = sqlite3.open(DIR .. "levels.db", sqlite3.OPEN_READONLY) ---- Aperto in scrittura, che è il comportamento predefinito di `sqlite3.open`, un database impacchettato viene copiato una volta nella cartella dei dati del gioco, sotto `files/` come tutto il resto che un gioco impacchettato scrive, e viene aperta la copia. Da lì in poi il nome indica la copia, in sola lettura o no, e l'originale impacchettato non viene mai modificato. Il database di un gioco sciolto si apre sul posto esattamente come prima. `testScripts/packedSql` è il test; la sua intestazione spiega come eseguirlo. Il formato del file, per chi scrive strumenti: tre tabelle. `meta(key, value)` contiene `version` (1), `gamedir` (il nome della cartella impacchettata), `chunk` (la dimensione dei blocchi in byte, 4194304) e `packer`. `assets(path, name, size, data)` ha una riga per file, dove `path` è la chiave in minuscolo con le barre normali e `name` la grafia dell'autore; i file non più grandi della dimensione dei blocchi stanno in `data`, quelli più grandi hanno `data` NULL e vivono in `chunks(path, chunk, data)` come pezzi numerati consecutivi. Singe apre il database in sola lettura e come immutabile, così gira da supporti di sola lettura e non scrive mai un journal accanto a esso. [#_event_driven_or_not] === Guidato dagli eventi... o no? Tradizionalmente, Singe usava un modello di programmazione guidato dagli eventi -- Singe gestisce tutto e chiama il tuo codice solo quando deve dirti qualcosa o aggiornare lo schermo. È efficiente ed è il modo in cui funziona la maggior parte dei motori di gioco basati su Lua, ma può essere prolisso. Da Singe 2.10 c'è un nuovo modello _a thread_ («threaded») che ti permette di scrivere codice procedurale. [#_event_driven] ==== Guidato dagli eventi Con il modello di programmazione guidato dagli eventi, Singe controlla il ciclo principale del programma ed è responsabile dell'ordine di esecuzione. Singe gestisce automaticamente tutti i dettagli della decodifica e della presentazione di video e audio. Gestisce controller, mouse e input da tastiera. Quando Singe ha bisogno di qualcosa di specifico del gioco, chiama una parte del tuo script. Lo script Singe più elementare che mostra tutte le callback esistenti è questo: [source,lua] ---- -- Scheletro di un gioco Singe. -- Carica il Singe Framework. dofile("Singe/Framework.singe") -- Dichiara qui le variabili globali che ti servono. function onControllerMoved(axis, value, which) --[[ Riporta quale asse del controller è stato mosso e il suo valore attuale. (Intervallo: da -32768 a 32767.) Si usa per i dispositivi analogici. L'input digitale è gestito da onInput e onKey. --]] end function onInputPressed(what, device) --[[ In modalità tastiera MODE_NORMAL, gli eventi di input arrivano qui quando il tasto o il pulsante viene premuto. Per l'elenco completo di tasti, pulsanti e controller, vedi Singe/Framework.singe. In MODE_FULL, questo evento viene chiamato con il keysym del tasto premuto. Quando l'interruttore viene da un mouse o da una pistola ottica, device è l'indice di quel dispositivo (0 per il primo), come lo passa Hypseus, così un gioco a due pistole può distinguere i suoi giocatori; per un tasto o un pad è nil. --]] end function onInputReleased(what, device) --[[ In modalità tastiera MODE_NORMAL, gli eventi di input arrivano qui quando il tasto o il pulsante viene rilasciato. Per l'elenco completo di tasti, pulsanti e controller, vedi Singe/Framework.singe. In MODE_FULL, questo evento viene chiamato con il keysym del tasto rilasciato. device è come per onInputPressed. --]] end function onKeyPressed(key, scancode) --[[ In modalità tastiera MODE_FULL, gli eventi di input arrivano qui quando il tasto viene premuto. Vengono restituiti sia il keysym sia lo scancode. Per l'elenco degli scancode disponibili, vedi Singe/Framework.singe. --]] end function onKeyReleased(key, scancode) --[[ In modalità tastiera MODE_FULL, gli eventi di input arrivano qui quando il tasto viene rilasciato. Vengono restituiti sia il keysym sia lo scancode. Per l'elenco degli scancode disponibili, vedi Singe/Framework.singe. --]] end function onMouseMoved(x, y, xr, yr, which) --[[ Chiamata quando il mouse viene mosso. In modalità SINGLE_MOUSE vengono restituiti i valori assoluti di X e Y e la variazione relativa della posizione. In modalità MANY_MOUSE è disponibile solo la variazione relativa, insieme a quale mouse è stato mosso. --]] end function onOverlayUpdate() --[[ Questo è l'unico posto in cui puoi disegnare in sicurezza. Se vuoi mostrare un mirino, dovrai salvare la posizione del mouse da onMouseMoved in variabili globali e poi usarle qui per disegnare il cursore. --]] -- Di' a Singe se abbiamo cambiato o no lo schermo. return(OVERLAY_UPDATED) -- Oppure OVERLAY_NOT_UPDATED se non si è disegnato nulla. end function onShutdown() -- Chiamata quando l'utente esce dal gioco. Libera qui le risorse caricate. end function onSoundCompleted(channel) -- Il suono sul canale "channel" ha appena finito di suonare. end -- Nota: non esiste un evento "onStartup". -- Il codice di avvio che ti serve può stare qui. ---- [#threaded] ==== A thread Da Singe 2.10 puoi usare il classico modello di programmazione procedurale. Per usare questo modello, dichiara una funzione `singeMain()` e includi il Singe Framework alla *fine* del programma invece che all'inizio. Lua non è davvero multithread, quindi questo modello si affida allo sviluppatore del gioco perché faccia multitasking cooperativo chiamando `singeYield()` ovunque il codice consumi una quantità di tempo considerevole. Come con il modello guidato dagli eventi, Singe continua a gestire tutto l'input, la decodifica video e la decodifica audio. Un programma a thread di esempio è questo: [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] ==== Ibrido I due modelli si combinano liberamente. Quando `singeMain` esiste, `Framework.singe` installa il proprio `onOverlayUpdate`, ma ogni altra callback continua a essere chiamata: definisci `onInputPressed`, `onSoundCompleted` o `onShutdown` accanto a `singeMain` e verranno chiamate tra uno yield e l'altro della coroutine. Una divisione tipica mantiene procedurale il flusso del gioco e intanto reagisce agli eventi di input e 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") ---- Non definire tu stesso `onOverlayUpdate` in questo modello; disegna invece dall'interno di `singeMain`. Un errore sollevato dentro `singeMain` termina il gioco con un traceback, e il ritorno da `singeMain` fa uscire in modo pulito. [#pausing] === La pausa Ogni gioco ha gratis una pausa funzionante. Finché il tasto di pausa è abilitato (l'impostazione predefinita), premere il tasto assegnato a `INPUT_PAUSE` fa sì che il motore metta in pausa il disco, ogni video caricato e ogni suono, disegni un indicatore PAUSED sopra l'ultimo fotogramma e smetta di eseguire lo script: niente `onOverlayUpdate`, nessuna ripresa di `singeMain`, nessuna callback di input, mouse, controller o suono. I timer costruiti su librerie Lua si fermano con lui, perché niente li fa girare. Premere di nuovo il tasto riprende tutto ciò che era in riproduzione, e lo script continua da dove si era fermato. Solo gli interruttori del motore stesso (pausa, uscita, screenshot, cattura) funzionano ancora durante il blocco. Gli input restano veritieri attraverso il blocco. Tutto ciò che lo script crede premuto viene rilasciato con `onInputReleased` (o `onKeyReleased` in `MODE_FULL`) quando la pausa comincia, e tutto ciò che è ancora fisicamente premuto quando la pausa finisce viene premuto di nuovo. Un joystick tenuto spinto durante una pausa non resta mai bloccato. Un gioco che vuole un proprio comportamento di pausa (un menu di pausa, un blocco della storia che il giocatore non può saltare) chiama `singeSetPauseKeyEnabled(false)`. Il tasto viene allora consegnato allo script come `SWITCH_PAUSE` e il gioco gestisce `singeSetPauseFlag` da sé, il che mette in pausa i media senza bloccare lo script. In `MODE_FULL` la tastiera appartiene interamente al gioco, quindi i tasti assegnati agli interruttori del motore lì non fanno nulla (una `p` digitata nell'inserimento di un punteggio record deve restare una `p`). I pulsanti del controller e del mouse assegnati a quegli interruttori continuano a funzionare in entrambe le modalità, perché non si possono digitare. [#gamesdat] === games.dat Il file `games.dat` permette a Singe di trovare automaticamente i nuovi giochi quando l'utente finale li installa. Questo file è estremamente importante e deve essere incluso in ogni gioco Singe. Metti `games.dat` nella cartella di livello più alto del tuo gioco. Un esempio che contiene più giochi: [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" }, -- ... altre voci ... } ---- Le chiavi `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` e `TIER` vengono lette dal motore quando il menu (o il tuo script, tramite `scriptExecute` / `scriptPush`) lancia la voce; prevalgono sulla riga di comando. La riga `VIDEO` è il disco: una voce che ne nomina uno è un gioco laserdisc, e una voce che la omette o la lascia vuota gira senza disco e può impostare `CANVAS_X` / `CANVAS_Y` (720x480 per impostazione predefinita); vedi <>. `AUDIO_SUFFIX = "-es"` riproduce `-es.ogg` accanto al video del disco invece dell'audio contenuto in esso, per un'edizione le cui altre lingue sono distribuite come file separati, la stessa cosa che fa `--altaudio`. `LEGACY_SPRITE_ARGS = true` esegue un gioco scritto per Singe 2.10 con il vecchio ordine degli argomenti degli sprite (vedi <>). Le chiavi restanti vengono lette dal menu per la visualizzazione. `REQUIRES` dice di che cosa il gioco ha bisogno da una macchina, come elenco di nomi: `"SCENE"` (la scena 3D), `"GUI"` (i documenti GUI), `"SHADOWS"`, `"POST"` (il bloom e gli altri effetti che vogliono render target in virgola mobile), `"SKIN"` (modelli con skinning) e `"MORPH"` (morph target). Nessun `REQUIRES` significa 2D. Il menu segnala un gioco che questa macchina non può eseguire e dice perché invece di avviarlo, e raramente scrivi tu stesso l'elenco: il motore nota che cosa usa un gioco mentre gira, dice nella traccia quando il gioco usa qualcosa che `REQUIRES` non elenca, e `--pack` scrive l'elenco in una voce che non ce l'ha. `TIER` (da 0 a 3) è quanta macchina vuole il gioco; il menu misura la fascia di questa macchina dal proprio sfondo e segnala un gioco che vuole di più come uno che potrebbe girare lentamente. Vedi <>. [source,lua] ---- REQUIRES = { "SCENE", "SHADOWS" }, TIER = 1, ---- Uno script lanciato direttamente, per nome o tramite la sua cartella, riceve lo stesso trattamento: Singe cerca un `games.dat` nella cartella dello script e fino a tre cartelle sopra di essa, e se lo `SCRIPT` di una voce nomina lo script lanciato (i percorsi delle voci contano a partire dalla cartella sopra il `games.dat`, quindi `Collection/games.dat` nomina `Collection/AsteroidPatrol.singe`), si applicano le chiavi di quella voce. L'unica differenza rispetto a un lancio dal menu è la precedenza: un'opzione digitata sulla riga di comando (`--framefile`, `--disc`, `--stretch`, `--nomouse`, `--xresolution`, `--yresolution`, `--sindengun`, `--audio`, `--audiodelay`, `--altaudio`, `--canvas`) mantiene il suo valore, perché chi l'ha digitata lo voleva. Un valore da un file di impostazioni no: è un valore predefinito, e la voce descrive meglio il gioco (vedi xref:Manual.adoc#settings[Il file delle impostazioni]). Un `games.dat` che non si riesce a eseguire viene segnalato e ignorato per un lancio diretto. [#withoutadisc] === Giochi senza disco Singe non presuppone più un laserdisc. Un gioco è un gioco laserdisc solo quando ne nomina uno: un `VIDEO` nella sua voce di `games.dat`, o `--disc` (o un `--framefile`) sulla riga di comando. Tutto il resto gira senza disco, e il mondo è una *tela* invece di un fotogramma video: `CANVAS_X` / `CANVAS_Y` in `games.dat` o `--canvas=WxH` sulla riga di comando, 720x480 per impostazione predefinita, così un gioco senza disco ha l'aspetto di un gioco laserdisc a definizione standard a meno che non chieda altrimenti. Senza disco il motore disegna sul nero, l'overlay è per impostazione predefinita grande metà della tela come al solito, e le funzioni `disc*` e `vldp*` diventano innocue: `discGetState` riporta `DISC_EJECTED`, `discGetFrame` restituisce `0`, `discGetWidth` / `discGetHeight` restituiscono la tela, `vldpGetPixel` restituisce nero, e le chiamate di trasporto lasciano una riga nella traccia e non fanno nulla. Tutto il resto -- l'overlay, gli sprite, i font, i suoni, i video aggiuntivi tramite `videoLoad`, l'input ed entrambi i modelli di programmazione -- è identico. La variabile globale `SINGE_DISC` dice a uno script come quale tipo di gioco sta girando. [#gui] === GUI Menu, pagine di opzioni, HUD e inserimento dei punteggi record si costruivano con `fontPrint`, `overlayBox` e un cursore fatto a mano. Singe 3 aggiunge una GUI: un documento scritto in RML (un markup simile all'HTML) e con lo stile dato da RCSS (un foglio di stile simile al CSS), impaginato e disegnato da RmlUi, con a capo automatico del testo, flexbox, pannelli nine-slice fatti con la tua grafica, transizioni, animazioni, controlli dei moduli e un modello di focus che un controller può pilotare. Una *GUI* è un contesto RmlUi di dimensione fissa disegnato nella propria texture, ridisegnato ogni volta che la sua immagine cambia (vedi <>), e quella texture va ovunque possa andare una texture: composta sopra l'overlay con `guiDraw`, o mostrata su un materiale nella scena 3D con `materialSetGui`, così la stessa pagina di opzioni funziona piatta sullo schermo e sul monitor di un cabinato modellato. Una GUI ha bisogno del dispositivo GPU che usa la scena 3D, quindi non è disponibile su una macchina che non ne ha uno (vedi <>); `guiNew` termina lo script invece di rispondere false, quindi chiedi prima a <> se il tuo gioco può farne a meno. Il menu incluso fa esattamente così, e quando la risposta è no si disegna invece nell'overlay (vedi xref:Manual.adoc#menurenderers[I due renderer]). Ogni chiamata nominata qui ha una voce nella sezione <> del riferimento. Il riferimento dei linguaggi RML e RCSS è la documentazione di RmlUi stessa su https://mikke89.github.io/RmlUiDoc/, e vale la pena leggerla prima del primo documento; questo capitolo tratta ciò che Singe aggiunge sopra. [#_documents_and_style_sheets] ==== Documenti e fogli di stile `guiNew(width, height)` crea una GUI e restituisce un handle intero; ne possono esistere fino a sedici alla volta. `guiLoad(gui, name)` vi carica un documento RML, lo mostra e restituisce un handle di documento (fino a trentadue per GUI); `guiHide`, `guiShow` e `guiClose` fanno ciò che dicono i loro nomi, e `guiDelete` elimina la GUI, i suoi documenti e la sua texture. Un documento è di solito grande quanto la sua GUI, e una GUI è di solito grande quanto l'overlay per una pagina a schermo intero o quanto il pannello per un elemento dell'HUD: [source,lua] ---- local gui = guiNew(overlayGetWidth(), overlayGetHeight()) local doc = guiLoad(gui, DIR .. "options.rml") ---- Il nome del documento passa per il file system virtuale come quello di uno sprite, quindi può stare sciolto sul disco o dentro un gioco impacchettato, e viene risolto relativamente alla cartella da cui Singe è stato avviato, quindi anteponi `DIR` per i file distribuiti con il gioco, esattamente come per `spriteLoad`. I nomi *dentro* un documento (lo `href` di un foglio di stile, un'immagine in un decoratore, lo `src` di uno sprite sheet) si risolvono relativamente al file che li nomina, come fa RmlUi, e poi tramite la stessa ricerca, quindi un documento, il suo foglio di stile e le sue immagini stanno uno accanto all'altro nella cartella del gioco e un gioco impacchettato li trova tutti. Due tipi di nome vengono presi così come sono: i file del motore stesso sotto `Singe/`, e i percorsi assoluti. La stessa regola letta al contrario permette a un documento che sta sotto `Singe/` di raggiungere i file di un gioco: un nome relativo si risolve dalla cartella del documento stesso, quindi da `Singe/Menu.rml` il nome `../Collection/cabinet.png` è `Collection/cabinet.png` nella cartella dei giochi, e un nome sotto `../MyGame.game/` è un file dentro quel gioco impacchettato. Il menu incluso fa esattamente questo per la grafica del cabinato e del marquee del gioco selezionato, impostando da Lua un decoratore di immagine sul pannello della grafica, così l'immagine viene adattata al suo interno qualunque sia la sua dimensione: [source,lua] ---- -- Menu.singe: adatta l'immagine di un gioco a uno dei pannelli della grafica. 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 è un `` con un titolo, collegamenti a fogli di stile o uno `

Options

Choose a difficulty and press Start.

---- RmlUi non disegna niente di cui non gli sia stata detta la dimensione, e ogni elemento è `display: inline` finché uno stile non dice altrimenti, quindi un foglio di stile non è facoltativo. `Singe/gui.rcss` è il tema che il motore distribuisce: dà al body, ai titoli e ai paragrafi una dimensione e un colore, rende `div` un blocco e dà uno stile a ogni controllo dei moduli. Collegalo per primo e ridefinisci ciò che vuoi in un foglio tuo o in uno `

Options

plays the intro

Press Start when ready.

---- Lo script lo carica in una GUI grande quanto l'overlay, collega i tre elementi che gli interessano e lo disegna a ogni fotogramma finché il giocatore non preme Start; il disco viene riprodotto sotto per tutto il tempo: [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) -- Qui arriva solo ciò che la pagina non ha usato: niente finché ha il -- focus, tutto una volta che è nascosta. 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 stessa pagina sullo schermo di un cabinato modellato si ottiene con le cinque righe di <> al posto di `guiDraw`, e un clic sul monitor preme il pulsante sotto di esso. [#subtitles] === Sottotitoli Un gioco può distribuire un file di sottotitoli SubRip e farlo mostrare da Singe sopra il disco mentre viene riprodotto. Tre chiamate fanno tutto il lavoro: <> legge il file, <> lo attiva e <> dice dove stanno le righe. <> toglie di nuovo qualunque cosa sia visibile. La funzionalità viene da Hypseus Singe, e le quattro chiamate sono le sue. [source,lua] ---- if srtLoad("subs/english.srt") then srtPosition(80) srtEnable(true) end ---- [#_the_file_format] ==== Il formato del file SubRip (`.srt`) è un file di testo semplice fatto di battute numerate: ---- 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. ---- Ogni battuta è un numero su una riga a sé, poi una riga che contiene due marche temporali separate da `-->`, poi una o più righe di testo, poi una riga vuota. Una marca temporale è `HH:MM:SS,mmm`: ore, minuti e secondi separati da due punti, e i millisecondi dopo una virgola. Il numero della battuta viene letto e scartato, quindi i numeri non devono essere in ordine e nemmeno corretti. Il parser è volutamente tollerante nei punti in cui un file modificato a mano va storto. I ritorni a capo (carriage return), gli spazi iniziali e quelli finali vengono tolti da ogni riga, quindi un file scritto su Windows si carica senza modifiche. Una battuta la cui riga dei tempi non ha `-->`, o le cui marche temporali non si riescono a interpretare, viene saltata e il caricamento del file continua. Una battuta senza testo viene saltata. Le righe di una battuta vengono unite con degli a capo e mostrate come sono scritte, e `<`, `>` e `&` arrivano sullo schermo come tali invece di essere presi per markup. Ciò che *non* è supportato: le coordinate di posizionamento che alcuni strumenti aggiungono alla riga dei tempi vengono ignorate (`srtPosition` decide l'altezza), e gli occasionali tag `` e `` di SubRip vengono mostrati come testo invece di essere rispettati. Le battute possono sovrapporsi nel tempo; vince l'ultima che è cominciata. `srtLoad` risponde `false` e non carica nulla quando il file non si può leggere, quando niente al suo interno si riesce a interpretare o quando non c'è un disco -- quindi un file rotto è un `false` su cui un gioco può agire, e non un crash. In ogni caso stampa un avviso che nomina il file. [#_the_timing] ==== La tempistica Le marche temporali vengono trasformate in *numeri di fotogramma del disco* quando il file viene caricato, usando la frequenza dei fotogrammi del disco caricato in quel momento, e il motore confronta ogni fotogramma disegnato con quei numeri di fotogramma. Ne seguono due cose. Primo, **un sottotitolo sopravvive a un salto.** Mandare il disco ovunque -- `discSearch`, `discSkipForward`, un passo, un framefile che passa in un altro segmento -- mostra la battuta che copre il punto in cui è arrivato, al fotogramma disegnato successivo, in avanti o all'indietro. Non bisogna ricaricare niente né azzerare alcun orologio. Secondo, **il file deve essere temporizzato sul disco, non sull'orologio.** Un `.srt` preparato per un video con una frequenza dei fotogrammi diversa finisce nel posto sbagliato, e uno caricato prima che il disco sia stato aperto non si carica affatto. Carica i sottotitoli dopo il disco, il che per un gioco normale significa dal corpo dello script. Una battuta viene mostrata finché il fotogramma del disco è compreso tra i suoi due fotogrammi, estremi inclusi. Il sottotitolo viene ridisegnato solo quando la battuta cambia, quindi i sottotitoli non costano nulla mentre una riga è visibile. [#_styling] ==== Stile Le righe sono un documento RmlUi, non una riga di testo copiata con un blit, quindi ricevono lo stile come una GUI. Due file vengono distribuiti nella cartella `Singe` e vengono estratti accanto agli altri al primo avvio: * `Singe/subtitle.rml` -- il documento. Ha un solo elemento, `#slot`, in cui il motore scrive la battuta o il banner attuale. * `Singe/subtitle.rcss` -- l'aspetto. `.cue` è il blocco che porta l'altezza chiesta da `srtPosition`, come `top` in linea; `.cue .text` è la barra di testo al suo interno, che per impostazione predefinita è bianca su un pannello scuro arrotondato che si stringe attorno al testo invece di attraversare l'immagine. Entrambi vengono riscritti da capo a ogni avvio, come tutto ciò che sta nella cartella `Singe`, quindi modificarli non cambia nulla a lungo. Per cambiare lo stile, un gioco distribuisce le proprie copie: `subtitle.rml` accanto al suo script, con il collegamento a un foglio tutto suo (`subtitle.rcss` accanto a esso, per esempio), e Singe disegna quel documento al posto del proprio; la traccia del programma lo dice. Il motore imposta sempre e solo il contenuto di `#slot` e il `top` di `.cue`, quindi tutto il resto -- il carattere, la dimensione, i colori, il pannello, un bordo, un'ombra -- appartiene al foglio del gioco e sopravvive a un aggiornamento del motore. Il documento viene disegnato sopra l'immagine, sopra l'overlay e sotto le GUI nello spazio dello schermo, segue il rettangolo del video ovunque lo mettano `--scalefactor`, `--shiftx`, un ritaglio del bezel (la cornice decorativa) o `--rotate`, e non accetta input dal mouse, quindi un clic lo attraversa direttamente. <> usa lo stesso documento, quindi un banner e un sottotitolo hanno lo stesso aspetto, e un gioco che cambia lo stile dell'uno lo cambia a entrambi. Su una macchina senza GPU non ci sono affatto GUI; `srtLoad` interpreta comunque il file e risponde in modo veritiero, e non viene disegnato nulla. Un avviso lo dice una volta. [#scenes3d] === Scene 3D Singe 3 disegna una scena 3D tra il video del laserdisc e l'overlay 2D. Tutto ciò che sai già vale ancora: gli sprite, il testo e l'overlay stanno sopra la scena, e il video si vede ovunque lo sfondo della scena sia trasparente. Un gioco che non chiama mai una funzione 3D non ne è toccato, e un gioco senza disco (vedi <>) può essere un gioco 3D a tutti gli effetti. Le sezioni qui sotto costruiscono una scena nell'ordine in cui ne scriverai una: i nodi, poi le mesh e i materiali, le luci, la telecamera, l'aspetto del fotogramma nel suo insieme, i modelli e infine le cose che stanno sopra tutto questo. Ogni chiamata nominata qui ha una voce nel <>. [#_what_a_scene_is_made_of] ==== Di che cosa è fatta una scena Tutto in una scena è un *nodo* in un unico albero, e il nodo `0` è la radice. Un nodo ha una posizione, una rotazione e una scala relative al suo genitore, e può portare una sola cosa: una mesh con un materiale, una luce, uno sprite o una riga di testo. Un modello caricato diventa un sottoalbero di nodi sotto un'unica radice, quindi le stesse chiamate muovono allo stesso modo un personaggio modellato e un cubo creato da script. La scena è destrorsa, con +Y in alto e -Z in avanti, come glTF e l'esportazione di Blender. Le unità sono quelle che usano i tuoi modelli, e le primitive usano le stesse unità; la fisica (vedi <>) presuppone i metri. Le rotazioni sono in gradi, date come tre angoli di Eulero applicati come imbardata, beccheggio e rollio, o come quaternione quando il valore viene da un modello. L'avanti proprio di un nodo è la sua -Z: la telecamera guarda lungo di essa, le luci direzionali e spot illuminano lungo di essa, e `nodeLookAt` la gira verso un punto del mondo. Ci sono due modi per riempire una scena, liberamente mescolabili: * *Costruirla da script* con le mesh primitive (`meshBox`, `meshSphere`, `meshPlane`, `meshCylinder`, `meshCone`, `meshTorus`, `meshHeightmap`) o con i tuoi dati dei vertici (`meshNew`), più materiali, luci e una telecamera. * *Caricare modelli* in formato binario glTF 2.0 (`.glb`) con `modelLoad`, poi collocarli con `modelInstance` tutte le volte che vuoi. Blender, Maya, 3ds Max e tutti i motori principali esportano `.glb`. Una scena minima è una mesh, una luce e una telecamera: [source,lua] ---- sceneEnable(true) sceneSetBackground(0, 0, 0, 0) -- Trasparente: il disco si vede dietro la scena. 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 per fotogramma attorno alla propria Y. return OVERLAY_UPDATED end ---- `sceneEnable(true)` è la chiamata che ogni gioco 3D fa, e su una macchina che non può disegnare in 3D è la chiamata che fallisce (vedi <>). Senza telecamera la scena è vista da `(0, 0, 5)` guardando verso l'origine, e senza una chiamata per lo sfondo lo sfondo della scena è nero opaco. [#nodes] ==== Nodi La trasformazione di un nodo si imposta direttamente (`nodeSetPosition`, `nodeSetRotation`, `nodeSetQuaternion`, `nodeSetScale`) o si ritocca (`nodeMove`, `nodeRotate`), sempre nello spazio del genitore. `nodeSetParent` appende un nodo a un altro genitore, mantenendo la sua trasformazione locale; `nodeGetWorldPosition` legge dove è finito dopo che ogni genitore ha detto la sua. `nodeSetVisible` nasconde un nodo e tutto ciò che sta sotto di esso, e `nodeDelete` libera lui e i suoi figli, insieme a qualunque corpo, emettitore o suono collegato a essi. I nomi vengono dai modelli: `nodeFind(name, root)` trova un giunto o una parte per il nome che aveva nel file, sotto l'istanza data come `root`. Anche un nodo creato da script può avere un nome (`nodeSetName`), ed è lì che cerca `nodeFind` senza una radice. [source,lua] ---- -- Una torretta su un carro armato: la canna è figlia della torretta, la torretta dello scafo, -- quindi girare la torretta porta con sé la canna e muovere lo scafo le porta entrambe. 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) -- Stendi il cilindro lungo -Z. function onOverlayUpdate() local stick = controllerGetAxis(0, GAMEPAD_AXIS_RIGHT_X) / 32767 -- Da -1 a 1. nodeRotate(turret, 0, stick * 2, 0) return OVERLAY_NOT_UPDATED end ---- Due chiamate fanno da ponte tra la scena e l'overlay. `sceneProject(x, y, z)` trasforma un punto del mondo in coordinate dell'overlay, così un'etichetta con il nome o una barra della salute disegnata con `fontPrint` può seguire un oggetto 3D. `sceneUnproject(sx, sy, distance)` fa il contrario: dà il punto del mondo a quella distanza lungo il raggio che passa per un pixel dell'overlay, il che, insieme a `physicsRaycast`, è la selezione con il mouse. [source,lua] ---- function onMouseMoved(x, y, dx, dy, which) -- Il mouse punta lungo un raggio dalla telecamera; ciò che colpisce è il bersaglio. 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] ==== Mesh e materiali Una mesh è geometria; un materiale è il suo aspetto; un nodo ne unisce uno di ciascuno (`nodeSetMesh(node, mesh, material)`). Condividili entrambi liberamente: un solo `meshBox` e un solo materiale possono stare su mille nodi, ed è questa condivisione che rende veloce il renderer (vedi <>). `meshNew` costruisce una mesh da tabelle di posizioni, normali, coordinate di texture e indici quando le primitive non bastano. Un materiale parte da un colore di base con alfa (`materialSetColor`) e dall'aspetto metallic-roughness di glTF: `materialSetMetallic` e `materialSetRoughness` da `0` a `1`. Una rugosità vicina a `0` dà un punto luce nitido come in uno specchio e `1` una superficie opaca; un metallo tinge con il proprio colore i punti luce e i riflessi e non ha un colore diffuso suo. Oltre a questo: * `materialSetEmissive` fa emettere a una superficie luce del suo colore (uno schermo, una lampada, metallo rovente); non illumina altre cose, ma produce bloom (vedi <>). * `materialSetUnlit` mostra il colore così com'è, ignorando ogni luce: elementi dell'HUD nella scena, il bagliore intrinseco del vetro di una lampadina. * `materialSetDoubleSided` disegna il retro delle forme aperte, come un piano visto da sotto o una bandiera. * `materialSetBlend` fa contare l'alfa, per vetro, fantasmi e acqua. Le mesh con fusione vengono disegnate dopo tutto ciò che è opaco, ordinate per distanza, e non proiettano ombre. * `materialSetCutoff` tiene solo i texel il cui alfa raggiunge la soglia e butta via il resto, ed è così che si disegnano foglie, recinzioni, grate e reti metalliche: un solo quad, in gran parte sparito. A differenza della fusione non richiede ordinamento e proietta comunque un'ombra, e anche l'ombra viene ritagliata, così un albero proietta ombre di foglie invece dell'ombra del suo quad. Ciò che sopravvive alla soglia è opaco: un texel semitrasparente viene tenuto intero o scartato, mai fuso, quindi niente dietro la scena (l'immagine del disco, soprattutto) tinge una foglia. Una soglia di 0 (il valore predefinito) disattiva il mascheramento. Un modello caricato da glTF porta la sua: `MASK` imposta la soglia dal file, `BLEND` attiva la fusione e `OPAQUE` non fa nessuna delle due cose. La texture di un materiale può essere l'immagine di qualsiasi sprite caricato (`materialSetTexture`), il laserdisc stesso o un video caricato (`materialSetVideo`), l'immagine di una seconda telecamera (`materialSetView`, vedi <>), o un documento GUI (`materialSetGui`, vedi <>). Un cabinato modellato può riprodurre il disco sul proprio schermo, o mostrarci la sua pagina di opzioni. Le coordinate di texture vengono dalla mesh; le primitive stendono un'immagine una volta su ogni faccia, e `materialSetTiling` la ripete, che è ciò che vogliono il terreno e i muri. Le texture hanno le mipmap e sono filtrate in modo anisotropo, così un pavimento resta nitido in lontananza; `materialSetFilter(material, FILTER_NEAREST)` mantiene invece squadrata la pixel art. [source,lua] ---- local stone = materialNew() materialSetTexture(stone, spriteLoad(DIR .. "flagstones.png")) materialSetTiling(stone, 12, 12) -- Dodici ripetizioni sul piano. materialSetRoughness(stone, 0.9) local floor = nodeNew() nodeSetMesh(floor, meshPlane(24, 24), stone) local glass = materialNew() materialSetColor(glass, 180, 220, 255, 90) -- Alfa 90 su 255. materialSetBlend(glass, true) materialSetRoughness(glass, 0.05) ---- Altre quattro mappe danno a una superficie un dettaglio che la sua geometria non ha, e un modello caricato le porta con sé quando il suo file le contiene: * `materialSetNormalMap` piega l'illuminazione con una mappa delle normali nello spazio tangente, così i mattoni sporgono da un muro piatto. Un'intensità sopra `1` la esagera. * `materialSetOcclusionMap` scurisce le fessure che la luce ambiente e il cielo non raggiungono. * `materialSetMetallicRoughnessMap` fa variare metallicità e rugosità sulla superficie, combinate alla maniera di glTF (rugosità nel verde, metallicità nel blu) e moltiplicate per i fattori propri del materiale, quindi imposta questi a `1` quando deve comandare la mappa. * `materialSetEmissiveMap` dice quali parti brillano, tinte e scalate da `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 malta brilla d'arancione. ---- .Texture compresse Un PNG viene decodificato in quattro byte per texel sulla GPU, più un altro terzo per le sue mipmap; un livello grande quanto Sponza occupa così 390 MB di texture, più di quanto un Raspberry Pi 4 condivida con lo schermo. Le texture distribuite come file KTX2 (Basis Universal, creati da `util/compressTextures.py` a partire da PNG o da un intero `.gltf`) restano compresse sulla GPU a un quarto della dimensione o meno, in qualunque formato a blocchi abbia la macchina: BC7 sui desktop, ASTC sulle GPU Apple e sul Pi 4, ETC2 su qualsiasi chip OpenGL ES 3 (il Mali di ogni console portatile, il Pi sotto GLES), BC3 sulle schede più vecchie e RGBA semplice dove non esiste altro, tutto dallo stesso file. Dai un nome `.ktx2` al posto di uno sprite a `materialSetTexture` e alle chiamate delle mappe, e un modello il cui file usa `KHR_texture_basisu` le carica da solo. `sceneGetStats` riporta la memoria delle texture in uso, così puoi vedere la differenza. [source,lua] ---- materialSetTexture(ground, DIR .. "flagstones.ktx2") local draws, drawn, calls, textureKB = sceneGetStats() debugPrint("textures: " .. textureKB .. " KB") ---- [#_lights_and_shadows] ==== Luci e ombre Possono illuminare insieme fino a otto luci, e ognuna è un nodo, quindi le luci si appendono a un genitore e si animano come qualsiasi altra cosa: * `LIGHT_DIRECTIONAL` è un sole: conta solo la sua direzione, e tutto è illuminato allo stesso modo. Un'intensità di `1` porta al bianco una superficie bianca rivolta verso di essa. * `LIGHT_POINT` è una lampadina, che si attenua con la distanza, e `lightSetRange` la spegne del tutto oltre una certa distanza. La sua intensità è la sua luminosità nella lampadina stessa, la metà a un'unità di distanza, quindi una lampadina che deve illuminare una stanza ha bisogno di un numero nell'ordine delle decine. * `LIGHT_SPOT` è un cono con un angolo interno completamente illuminato e un angolo esterno dove la luce si è attenuata fino a sparire (`lightSetCone`). `sceneSetAmbient` aggiunge un po' di luce da ogni direzione così le ombre non sono nere; un cielo lo fa meglio (vedi <>). I colori dati da `0` a `255` sono sRGB, come tutto il resto in Singe, e vengono convertiti in luce lineare all'ingresso, quindi una luce di `128, 128, 128` è luminosa un quarto del bianco, non la metà. Qualsiasi luce può proiettare ombre dai bordi morbidi da tutto ciò che è opaco (`lightSetShadow`). Le luci direzionali e spot proiettano tramite una mappa delle ombre; le luci puntiformi tramite una mappa cubica che raggiunge ogni direzione, quindi una lampadina può stare dentro ciò che illumina (una stanza, un paralume, il cabinato): le mesh chiuse proiettano anche dalle loro facce posteriori, e l'intervallo di profondità dell'ombra viene adattato agli oggetti che proiettano ombra attorno alla luce. `nodeSetShadow(node, false)` dispensa una mesh dal proiettare: il vetro della lampadina stessa, un'insegna luminosa, un'etichetta con il nome. `sceneSetShadowSize` imposta la risoluzione della mappa (da `256` a `4096`, predefinita `1024`) e scambia nitidezza con velocità. L'ombra di una luce direzionale è divisa in cascate lungo la vista (`sceneSetShadowCascades`, tre per impostazione predefinita), ognuna una mappa adattata a una fetta di ciò che vede la telecamera, così in un livello grande le ombre restano nitide da vicino e arrivano comunque fino a `sceneSetShadowDistance`. Una sola cascata adatta un'unica mappa all'intera scena, il che va bene per scene piccole e telecamere ortografiche. Una vista (`viewNew`) disegna con le stesse mappe, prendendo per ogni punto la cascata più fine che lo copre, così un monitor mostra le stesse ombre della finestra. [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) -- Tre cascate fino a 80 unità. 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) -- Una mappa cubica: sei passate per fotogramma. nodeSetShadow(bulbGlass, false) -- Il vetro attorno non proietta nulla. ---- [#scenecamera] ==== La telecamera e le viste Qualsiasi nodo può essere la telecamera (`cameraSet`); guarda lungo la propria -Z, quindi `nodeLookAt` la punta, e appenderla sotto un nodo in movimento crea una telecamera che insegue. `cameraSetPerspective(fovDegrees, near, far)` è l'obiettivo predefinito; `cameraSetOrthographic(height, near, far)` disegna senza prospettiva, per diagrammi, scene simili a un HUD e giochi visti di lato. Tieni `near` grande quanto la scena consente, perché la precisione del buffer di profondità si spende tra `near` e `far`. [source,lua] ---- local camera = nodeNew() nodeSetParent(camera, car) nodeSetPosition(camera, 0, 2.5, 7) -- Dietro e sopra l'auto, nel suo spazio. nodeLookAt(camera, 0, 1, 0) cameraSet(camera) cameraSetPerspective(55, 0.1, 300) ---- Una *vista* è una seconda telecamera disegnata in una texture a ogni fotogramma e mostrata su un materiale: un monitor di sorveglianza, uno specchietto retrovisore, un portale, un picture in picture. `viewNew(width, height)` ne crea una a quella risoluzione, `viewSetCamera` le dà un nodo telecamera, e `materialSetView` mette la sua immagine su qualsiasi mesh. Le viste condividono le ombre del fotogramma e saltano il bloom, e ognuna disegna di nuovo la scena, quindi tienile poche e piccole. Ogni vista gira i billboard (vedi <>) verso la propria telecamera e disegna da essa i materiali trasparenti da dietro in avanti, così un monitor vede le etichette con il nome di fronte e il vetro nell'ordine giusto. [source,lua] ---- local rearCamera = nodeNew() nodeSetParent(rearCamera, car) nodeSetPosition(rearCamera, 0, 1.2, -2) nodeSetRotation(rearCamera, 0, 180, 0) -- Guarda indietro. 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) -- Il piano guarda in alto; raddrizzalo verso il guidatore. ---- [#scenelook] ==== L'aspetto del fotogramma La scena è illuminata in luce lineare. I colori che dai da `0` a `255` sono sRGB e vengono decodificati all'ingresso, le texture vengono decodificate dalla GPU mentre vengono campionate, le luci si sommano senza saturare, e alla fine del fotogramma una curva tonale riporta il risultato allo schermo. Che cosa significa in pratica: * `sceneSetExposure(stops)` schiarisce o scurisce l'intero fotogramma come l'esposizione di una macchina fotografica: `+1` raddoppia la luce, `-1` la dimezza, così una grotta e una spiaggia possono condividere le stesse luci e gli stessi materiali. * `sceneSetTonemap` sceglie la curva: `TONEMAP_NEUTRAL` (il valore predefinito, che tiene i colori dove li tengono i visualizzatori glTF e comprime solo la luce più intensa), `TONEMAP_ACES` (un aspetto cinematografico con più contrasto) o `TONEMAP_NONE` (taglia al bianco). * `sceneSetBloom(threshold, strength)` fa brillare tutto ciò che è più luminoso della soglia: il sole attraverso una finestra, una lampadina, un'insegna emissiva, una fiamma. Il bagliore viene preso dalla luce della scena prima della curva tonale, così fa bloom solo ciò che è davvero luminoso; una soglia di `1` è il bianco. .Il cielo e la sua luce `sceneSetSky(file)` avvolge la scena in un panorama equirettangolare. Un `.hdr` Radiance mantiene tutta la sua gamma; funziona anche un PNG o un JPEG. Il cielo viene disegnato dietro a tutto e, a meno di `sceneSetEnvironment(false)`, illumina anche la scena: il suo colore complessivo da ogni direzione sostituisce la luce ambiente uniforme, e il suo riflesso si vede sui metalli e sulle superfici lucide, nitido su uno specchio e sfocato man mano che la rugosità aumenta. Senza un cielo, i metalli non hanno niente da riflettere e solo la luce ambiente e le luci danno loro forma. `sceneSetSkyIntensity` scala insieme il cielo e la sua luce, così un solo panorama serve per mezzogiorno e per il tramonto. .Nebbia `sceneSetFog(r, g, b, near, far)` fa sfumare tutto, particelle comprese, verso un colore tra due distanze. Il cielo non viene mai annebbiato, quindi abbina il colore della nebbia all'orizzonte del cielo e il bordo lontano della scena sparisce dentro di esso. [source,lua] ---- sceneSetSky(DIR .. "harbor.hdr") sceneSetSkyIntensity(0.8) sceneSetFog(190, 200, 215, 40, 160) -- Il colore del cielo all'orizzonte. sceneSetExposure(-0.5) -- Un po' più scuro di quanto lo renderebbe il sole. sceneSetBloom(1.0, 0.35) -- Brilla solo ciò che è più luminoso del bianco. ---- [#_models_and_animation] ==== Modelli e animazione `modelLoad` prende un `.glb` con lo stesso nome relativo al gioco che prende `spriteLoad`, e un `.game` impacchettato lo porta con sé. Il file deve essere autosufficiente: mesh, skin, animazioni e texture tutte dentro lo stesso file, che è l'esportazione predefinita di Blender. Un modello che fa riferimento a un `.bin` o a un'immagine accanto a sé viene rifiutato con un messaggio che nomina il file, per la stessa ragione per cui un gioco impacchettato deve essere autosufficiente. I materiali usano il modello metallic-roughness con `KHR_materials_emissive_strength` e `KHR_texture_basisu`; una texture indirizzata tramite un secondo insieme di coordinate di texture legge il primo insieme, e `KHR_texture_transform` viene ignorato, ognuno con una riga nella traccia del programma. Una skin può avere fino a `128` giunti. Il caricamento è la parte costosa; `modelInstance` colloca poi una copia dell'albero di nodi del modello, tutte le volte che vuoi, e ogni istanza condivide le mesh e i materiali del file. `modelDelete` libera il modello; le istanze tengono i loro nodi, spogli. [source,lua] ---- local foxModel = modelLoad(DIR .. "Fox.glb") local pack = {} for i = 1, 6 do local fox = modelInstance(foxModel) nodeSetScale(fox, 0.02) -- Il file è in centimetri. nodeSetPosition(fox, i * 1.5 - 5, 0, 0) pack[i] = fox end ---- Le animazioni di un modello sono elencate da `modelGetAnimations` e pilotate per istanza con `animationPlay(node, nameOrIndex, loop, speed, fade)` sul nodo radice dell'istanza, più le chiamate per pausa, ripresa, arresto e salto. L'animazione dei nodi (cose che si muovono), lo skinning (personaggi che si deformano) e i morph target (blend shape: volti, sincronizzazione labiale, muscoli che si gonfiano) funzionano tutti. Le animazioni avanzano con il tempo reale e si fermano mentre il gioco è in pausa. Il peso di un morph si può anche impostare da script con `nodeSetMorph`, per nome o per numero, sul nodo che porta la mesh. Un `fade` in secondi fa una dissolvenza incrociata da qualunque cosa fosse in riproduzione, così una camminata diventa una corsa senza scatti. Quando non c'era niente in riproduzione (dopo `animationStop`, o quando un ragdoll è stato disattivato), la dissolvenza parte dalla posa in cui si trovano i nodi, così un personaggio si rialza dolcemente da dove è caduto. Fino a quattro altre clip possono essere riprodotte insieme su livelli sopra quello di base (`animationPlayLayer`), ognuna miscelata con `animationSetLayerWeight` e limitata a una parte dello scheletro da `animationSetLayerMask`: uno sguardo intorno sul collo mentre le gambe camminano, un saluto con un braccio sopra una corsa. [source,lua] ---- animationPlay(fox, "Walk", true) animationPlayLayer(fox, 1, "Survey", true, 1, 0.5) -- La testa si gira, sfumata in mezzo secondo animationSetLayerMask(fox, 1, nodeFind("b_Neck_04", fox)) -- ... solo sul collo e sopra. 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) -- Più veloce, in dissolvenza incrociata in 0.4 s. end end ---- [#_sound_in_the_scene] ==== Il suono nella scena Un effetto sonoro può essere collocato nella scena, così si sente da dove si trova. `soundSetPosition(channel, x, y, z)` mette un canale in riproduzione in un punto del mondo, `soundSetNode(channel, node)` gli fa invece seguire un nodo, e `soundSetRange(channel, near, far)` dice dove è a volume pieno e dove è sfumato fino al silenzio. I suoni si sentono dalla telecamera a meno che `soundSetListener` non nomini un altro nodo (la testa del giocatore, per un gioco in terza persona), e si spostano a sinistra e a destra mentre l'ascoltatore si gira. Un canale non collocato nella scena suona come ha sempre fatto, con `soundSetPan` per lo stereo. [source,lua] ---- local crackle = soundPlay(fireSound, -1) -- Ripete all'infinito. soundSetNode(crackle, brazier) soundSetRange(crackle, 2, 18) -- Pieno entro 2 unità, sparito a 18. soundSetListener(hero) ---- [#scenesprites] ==== Sprite e testo nella scena Qualsiasi nodo può mostrare un'immagine invece di una mesh. `nodeSetSprite(node, sprite, height)` mette uno sprite caricato (ogni fotogramma di uno animato, con `nodeSetSpriteFrame` a scegliere) su un quad alto altrettante unità del mondo, illuminato come qualsiasi altra cosa e che proietta ombre a meno che `nodeSetShadow` non dica altrimenti. `nodeSetText(node, text, height)` fa lo stesso con una riga di testo nel font e nel colore selezionati, senza illuminazione. Per il resto del motore entrambi sono mesh: si appendono a un genitore, si scalano e accettano la fusione di un materiale. `nodeSetBillboard` gira un nodo verso la telecamera a ogni fotogramma: `BILLBOARD_ALL` di fronte (etichette con il nome, bagliori, impostor), `BILLBOARD_Y` solo attorno al proprio asse verticale (alberi, barre della salute, qualsiasi cosa debba restare dritta), e `BILLBOARD_NONE` per smettere. Funziona anche sulle mesh. [source,lua] ---- local tag = nodeNew() nodeSetParent(tag, fox) nodeSetPosition(tag, 0, 110, 0) -- Nei centimetri di Fox, sopra la sua testa. nodeSetScale(tag, 50) -- Annulla la scala 0.02 dell'istanza. 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] ==== Davanti all'overlay La scena normalmente viene disegnata sotto l'overlay, quindi qualsiasi cosa disegnata sull'overlay la copre. `nodeSetLayer(node, LAYER_FRONT)` sposta invece un nodo, e tutto ciò che vi è appeso, nel livello frontale: la stessa telecamera lo disegna dopo l'overlay, illuminato dalle stesse luci e rifinito dalla stessa esposizione, curva tonale e bloom, così un logo che gira, un titolo 3D o un modello in una tabella dei punteggi record può stare sopra una grafica 2D. Il livello frontale è un'immagine a sé, svuotata a trasparente, quindi l'overlay si vede ovunque esso non disegna nulla, e niente nella scena dietro l'overlay può nasconderlo. Le GUI e le particelle disegnate sopra l'overlay gli stanno comunque sopra. I nodi frontali proiettano comunque ombre sulla scena. Il cielo, le particelle 3D e le linee di debug restano nella scena, e le viste (`viewNew`) disegnano solo i nodi della scena. Il livello costa una seconda passata di rendering, e solo nei fotogrammi in cui c'è qualcosa. [source,lua] ---- -- Una moneta d'oro che gira sopra la schermata del titolo 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)` trasforma un'immagine in scala di grigi in un terreno ondulato: un vertice per pixel, il nero in basso e il bianco alto `sizeY`, con le normali ricavate dalle pendenze e le coordinate di texture stese sull'insieme. Dai al suo materiale una texture e `materialSetTiling` per ripeterla ogni poche unità. Le immagini possono arrivare a 1025 pixel per lato; una mappa da 257 pixel su 120 unità dà un vertice ogni mezza unità, che basta e avanza per le colline. Un corpo `BODY_STATIC` con `SHAPE_MESH` su quel nodo diventa un campo di altezze, molto meno costoso di quanto lo sarebbero i triangoli, così le auto ci guidano sopra e i giocatori ci camminano senza bisogno d'altro. `terrainGetHeight(node, x, z)` dice quanto è alto il terreno in un punto, per collocarci sopra delle cose senza un raycast. [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 di altezze. 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] ==== Navigazione Una navmesh (mesh di navigazione) dice ai personaggi dove possono camminare. `navNew(radius, height, maxSlope, maxStep)` ne avvia una per una data dimensione dell'agente, `navAddNode` le fornisce la geometria del livello (la radice di un'istanza di modello fornisce l'intero modello), e `navBuild` precalcola la superficie percorribile: pavimenti, rampe e scale che l'agente può salire, meno un margine attorno a ogni muro e colonna. Precalcolare Sponza richiede un decimo di secondo su un desktop; `navSave` e `navLoad` tengono una navmesh precalcolata nella cartella dei dati del gioco per le macchine su cui ci vuole di più, e una navmesh distribuita con il gioco si carica allo stesso modo. `navDraw` mostra la navmesh sopra la scena mentre stai sistemando i parametri. Le interrogazioni rispondono poi alle domande che un gioco fa: `navPath` restituisce gli angoli del percorso più breve tra due punti, `navNearest` aggancia un punto alla navmesh, `navRaycast` dice se una camminata in linea retta è bloccata, e `navRandomPoint` sceglie un posto verso cui vagare. Gli agenti (`navAgentNew`) percorrono la navmesh al posto tuo. `navAgentMoveTo` ne manda uno da qualche parte, e lui aggira gli altri agenti lungo la strada, muovendo il suo nodo (o pilotando un controller del giocatore su di esso, con `navAgentSetPlayer`) e chiamando `onNavArrived` quando arriva. `navAgentGetVelocity` ti dice se riprodurre la camminata o la corsa. [source,lua] ---- local nav = navNew(0.4, 1.6, 45, 0.3) -- Un agente largo 0.8, alto 1.6, che sale 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)) -- Via verso il prossimo punto. end ---- [#sceneperformance] ==== Prestazioni e requisiti La scena ha bisogno di una GPU che parli Vulkan (Linux), Direct3D 12 (Windows 10 e successivi), Metal (macOS) o OpenGL ES 3.1. Il backend ES è quello che porta dentro le console portatili Linux economiche e il Raspberry Pi, i cui chip Mali e VideoCore hanno un driver GLES maturo e un Vulkan assente o immaturo; viene scelto automaticamente quando nessuno degli altri tre è disponibile, e disegna la stessa immagine con gli stessi shader. Il Raspberry Pi 4 resta il Pi minimo per il 3D: il Pi 3 ha solo OpenGL ES 2.0, che non può esprimere lo skinning né i morph target della scena. Su una macchina senza nessuno di essi, i giochi 2D girano esattamente come prima (fisica 2D compresa) e la prima chiamata 3D termina il gioco con un errore che nomina il problema. Le mesh fuori dalla vista della telecamera vengono saltate, e le copie della stessa mesh con lo stesso materiale vengono disegnate come un'unica chiamata con istanze, così una foresta di un solo albero costa più o meno quanto costa un albero. `sceneGetStats` riporta i disegni raccolti, quanti erano in vista, quante chiamate di disegno hanno richiesto e la memoria delle texture in uso. Per aiutarlo: * Condividi mesh e materiali tra i nodi (un solo `meshBox`, molti nodi; un solo modello, molte chiamate a `modelInstance`) invece di crearne uno per oggetto. * Le mesh con skinning, quelle con morph e quelle con fusione vengono disegnate una alla volta; limitale a ciò di cui la scena ha bisogno. * L'antialiasing (multisampling 4x) è attivo ovunque la GPU lo offra; `sceneSetAntialias(false)` fa recuperare velocità su un Pi, come anche un `sceneSetShadowSize` più piccolo e meno luci puntiformi che proiettano ombre, ognuna delle quali costa sei passate. * Tieni d'occhio il numero di triangoli e la dimensione delle texture sulle schede piccole, distribuisci texture KTX2, e carica un modello una volta invece di due. [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] === Fisica Corpi, giunti, trigger e raycast vivono nella scena 3D e si appoggiano a Jolt Physics. Un corpo si collega a un nodo come fa una mesh, e da quel momento il nodo e il corpo sono una cosa sola: la forma del corpo è dimensionata in unità del mondo e scalata dal nodo, e qualunque dei due si muova, l'altro lo segue. Le unità sono metri, chilogrammi e secondi, e la gravità predefinita è `0, -9.81, 0`. La fisica non ha bisogno di una GPU; vedi <>. [#_bodies] ==== Corpi `bodyNew(node, type, shape, a, b, c)` dà a un nodo un corpo di uno di tre tipi: * Un corpo *statico* (`BODY_STATIC`) resta dov'era il nodo quando il corpo è stato creato: pavimenti, muri, il cabinato. Spostare il suo nodo in seguito non sposta il corpo. * Un corpo *cinematico* (`BODY_KINEMATIC`) segue il suo nodo ovunque lo spostino lo script o un'animazione, e spinge via i corpi dinamici senza essere spinto a sua volta: una racchetta, una piattaforma mobile, la mano di un personaggio. * Un corpo *dinamico* (`BODY_DYNAMIC`) è mosso dalla simulazione e guida il suo nodo: cade, rimbalza, scivola e viene spinto. Non spostare tu il suo nodo; dagli invece velocità, forze o impulsi (`bodySetVelocity`, `bodyApplyForce`, `bodyApplyImpulse`). La forma è un parallelepipedo, una sfera, una capsula o un cilindro dimensionati da `a`, `b` e `c` (`SHAPE_BOX` prende larghezza, altezza e profondità; `SHAPE_SPHERE` un raggio; `SHAPE_CAPSULE` e `SHAPE_CYLINDER` un raggio e un'altezza lungo Y), un inviluppo convesso della mesh del nodo (`SHAPE_HULL`, tutto ciò che sta sotto il nodo, quindi va bene anche la radice di un'istanza di modello), oppure i triangoli esatti della mesh (`SHAPE_MESH`, solo per corpi statici e cinematici; su una mesh da heightmap è un campo di altezze, vedi <>). [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 racchetta cinematica attraversa la pila. return OVERLAY_UPDATED end ---- .Massa e materiali La massa di un corpo dinamico deriva dal volume della sua forma alla densità dell'acqua, quindi un parallelepipedo grande come una porta pesa 140 chilogrammi e una spintarella non lo smuove. Imposta `bodySetMass` su tutto ciò che vuoi che sembri leggero, e commisura gli impulsi alla massa: un impulso di `mass * speed` imprime quella velocità. `bodySetFriction` e `bodySetBounce` fanno il ghiaccio e la gomma; `bodySetEnabled` toglie un corpo dal mondo e lo rimette senza perderlo. .Tempistica Il mondo avanza a passo fisso, sessanta volte al secondo, in base all'orologio, dopo che le animazioni hanno messo in posa i loro nodi e prima del rendering della scena, così un corpo cinematico guidato da un'animazione spinge con la velocità giusta. Mettere in pausa il gioco mette in pausa la fisica; `physicsSetEnabled(false)` mette in pausa solo lei. `bodyIsResting` dice quando un corpo si è assestato, cioè quando una cosa lanciata si è fermata e si può contare un punteggio. Un corpo, un giocatore, un ragdoll, un corpo morbido o un veicolo il cui nodo è stato eliminato viene rilasciato all'inizio del fotogramma successivo, abilitato o no e in pausa o no, così il suo posto torna libero; un handle di nodo che `nodeNew` assegna di nuovo in seguito non si porta dietro nulla della fisica del vecchio nodo, e `playerExists` e i suoi simili rispondono `false` finché non ne riceve una propria. [#_events_and_queries] ==== Eventi e interrogazioni Definisci `onCollision(nodeA, nodeB, x, y, z, speed)` e Singe la chiama una volta per ogni nuovo contatto, con il punto e la velocità a cui i due corpi si sono incontrati, così un urto e uno schianto possono suonare diversi. Un corpo reso trigger con `bodySetTrigger` non spinge niente ma segnala ciò che vi entra e ne esce tramite `onTrigger(trigger, other, entered)`: un traguardo, una porta, una zona di pericolo. `physicsRaycast` trova il primo corpo lungo un raggio; con `sceneUnproject` a fornire il raggio, questa è la selezione con il mouse (vedi <>). [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] ==== Giunti `jointHinge`, `jointBall` e `jointSlider` collegano due corpi, o un corpo e il mondo quando il secondo nodo è `-1`: porte, pendoli, cassetti, le ruote di un'auto vista di lato. L'ancora e l'asse si danno nello spazio del mondo, dove si trovano i corpi quando il giunto viene creato. Cerniere e guide accettano dei limiti (`jointSetLimits`): un angolo positivo della cerniera è una rotazione destrorsa del primo nodo attorno all'asse, e una distanza positiva della guida è lungo l'asse. [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) -- Sullo stipite, attorno a Y. jointSetLimits(hinge, 0, 110) -- Si apre in un solo verso, 110 gradi. ---- I corpi sulle istanze di modello funzionano come tutti gli altri: `bodyNew(instanceRoot, BODY_DYNAMIC, SHAPE_HULL)` avvolge l'intero modello in un inviluppo convesso. Per un personaggio con skin la scelta migliore è un giocatore (vedi sotto) o una capsula cinematica che spinge le cose anziché essere spinta. [#physics2d] ==== Giochi 2D La fisica non è legata alla scena 3D. I nodi esistono su ogni macchina, con o senza GPU, quindi un gioco 2D può dare dei corpi ai nodi, chiamare `physicsSet2D(true)` perché si muovano solo in X e Y e ruotino solo attorno a Z, lavorare in coordinate dell'overlay con la gravità rivolta verso il basso dello schermo, e disegnare gli sprite dove si trovano i nodi. Questa è la forma che gira su un Raspberry Pi 3, che non ha il 3D. [source,lua] ---- physicsSet2D(true) physicsSetGravity(0, 900, 0) -- Pixel al secondo quadrato; la Y dell'overlay va verso il basso. 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 ---- Dai a ogni corpo 2D un po' di profondità (la terza misura) così che le forme si sovrappongano in Z; `50` va bene. I cerchi sono `SHAPE_SPHERE`. Anche i giocatori e gli emettitori 2D (vedi <>) funzionano in questo mondo, e un platform è un giocatore con `playerMove(node, vx)`. [#_players] ==== Giocatori Un corpo va dove lo mandano le forze; un *giocatore* va dove dice lo script. `playerNew` mette un controller del personaggio su un nodo: una capsula per impostazione predefinita (`playerNew(node, radius, height)`), oppure qualsiasi forma convessa di `bodyNew` (`playerNew(node, SHAPE_BOX, w, h, d)` per un robot, `playerNew(node, SHAPE_SPHERE, r, 0)` per qualcosa che rotola ma sale comunque i gradini, `SHAPE_HULL` per il contorno del modello stesso; tre argomenti significano sempre una capsula, quindi una sfera scrive le sue misure per intero). La forma poggia sull'origine del nodo, che sono i piedi. A ogni fotogramma lo script dà a `playerMove` la velocità orizzontale che vuole, e il controller cammina sui pavimenti, sale i gradini fino a `playerSetStep`, scivola lungo i muri, rifiuta i pendii più ripidi di `playerSetSlope`, cade per gravità, viaggia sulle piattaforme mobili e spinge via i corpi dinamici leggeri (`playerSetPush`). `playerJump` funziona solo con il terreno sotto i piedi e dice se ha saltato. La rotazione del nodo spetta allo script: giralo verso la direzione in cui cammina, e appendi il modello sotto di esso come figlio. [source,lua] ---- hero = nodeNew() nodeSetPosition(hero, 0, 0, 0) playerNew(hero, 0.3, 1.0) -- Una capsula di raggio 0.3, alta 1, piedi sul nodo. fox = modelInstance(modelLoad(DIR .. "Fox.glb")) nodeSetParent(fox, hero) function onInputPressed(switch) if switch == SWITCH_BUTTON1 then playerJump(hero, 4.5) -- Serve il terreno sotto i piedi. 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 ---- I giocatori segnalano `onCollision` quando cominciano a toccare un corpo e `onTrigger` quando entrano in un sensore o ne escono, con il loro nodo al posto che occuperebbe il nodo di un corpo, quindi una porta, un oggetto da raccogliere e una pozza di lava funzionano per un giocatore come per una palla. `playerIsOnGround` e `playerGetGround` dicono su che cosa poggia il giocatore, per il suono dei passi e per scendere apposta da una piattaforma. In un mondo 2D un giocatore è un personaggio da platform: `playerMove(node, vx)` corre lungo X, la gravità punta verso il basso dello schermo, e tutto il resto è uguale. Le tolleranze del controller scalano con la forma, quindi una capsula di 30 pixel in un gioco di sprite si comporta come una di 0,3 metri in un gioco 3D. [#_vehicles] ==== Veicoli Un *veicolo* è un corpo dinamico (il telaio, creato con `bodyNew` come qualsiasi corpo, rivolto verso -Z con Y in alto) che Jolt guida tramite delle ruote: `vehicleNew(node, VEHICLE_CAR)` per qualsiasi cosa abbia degli assali, `VEHICLE_MOTORCYCLE` per due ruote in linea che si inclinano in curva e stanno in equilibrio, o `VEHICLE_TANK` per ruote su due cingoli che girano sul posto. Le ruote sono nodi: `vehicleAddWheel(node, wheelNode, radius, width, suspension)` prende la posizione della ruota da dove si trova il suo nodo rispetto al telaio, e da quel momento il motore di gioco mette in posa quel nodo a ogni fotogramma (il suo asse X è l'assale, quindi una ruota `meshCylinder` vuole un figlio ruotato di 90 gradi). Le ruote trovate in un modello con `nodeFind("wheelFL", car)` funzionano allo stesso modo. Per impostazione predefinita sterzano le ruote anteriori e sono motrici tutte; `vehicleSetWheel` dice altrimenti. A ogni fotogramma lo script passa i comandi del pilota: `vehicleDrive(node, forward, right, brake, handBrake)`. Il motore, il cambio automatico e i differenziali sono quelli di Jolt, con i valori predefiniti di un'auto da 1500 kg; `vehicleSetEngine`, `vehicleSetGears`, `vehicleSetSuspension`, `vehicleSetSteering`, `vehicleSetBrakes` e `vehicleSetAntiRoll` li regolano, e qualsiasi modifica ricostruisce il veicolo sul posto al passo successivo. `vehicleGetSpeed`, `vehicleGetRpm` e `vehicleGetGear` alimentano un cruscotto. Un veicolo resta un corpo: salta dalle rampe, sbatte contro i muri, spinge via le casse e segnala `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 }, -- Anteriore sinistra e destra ... { -0.95, -0.2, 1.4 }, { 0.95, -0.2, 1.4 }, -- ... posteriore sinistra e destra. } 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 ---- I carri armati prendono `forward` come acceleratore e `right` come sterzata (senza acceleratore ruotano sul posto), e ignorano le regolazioni di sterzo, freni e antirollio, perché i loro cingoli frenano e sterzano da soli. La molla che inclina la moto è scalata sul telaio, quindi una moto leggera sta in equilibrio bene quanto una pesante. I veicoli a ruote hanno bisogno di un mondo 3D; un'auto 2D vista di lato è un corpo con due ruote incernierate, che le chiamate dei giunti costruiscono già. [#_water] ==== Acqua `bodySetWater(node, density, linearDrag, angularDrag)` riempie d'acqua un corpo statico: la faccia superiore del suo parallelepipedo è la superficie, e ogni corpo dinamico al suo interno galleggia, affonda o rolla secondo la spinta di galleggiamento di Jolt rispetto a quella superficie (conta la parte immersa della forma, quindi una cassa inclinata si raddrizza da sola), rallentato dalle resistenze. `bodySetCurrent` dà all'acqua un flusso. `density` è un moltiplicatore, `1` per l'acqua, e il `bodySetBuoyancy` di ciascun corpo galleggiante (predefinito `1.2`, che galleggia dolcemente; sotto `1` affonda) lo scala. Il volume è un trigger, quindi `onTrigger` scatta all'entrata e all'uscita, per gli spruzzi. L'acqua non viene disegnata: dai al nodo del volume un materiale con fusione, o nessun materiale. Un giocatore in acqua nuota: `playerIsSwimming` lo dice, `playerMove(node, vx, vy, vz)` allora governa su tre assi, il giocatore affonda lentamente se non riceve input verticale (`playerSetSwim` imposta quella velocità e la resistenza dell'acqua), e la corrente lo trascina. Una barca è uno scafo galleggiante a cui `vehicleNew(node, VEHICLE_BOAT)` dà un'elica e un timone: `vehicleSetThrust` dice quanto forte spinge e in quale punto dello scafo, `vehicleSetRudder` quanto forte vira, e `vehicleDrive` funziona come per un'auto, il tutto solo finché l'elica è sotto una superficie. [source,lua] ---- pool = nodeNew() nodeSetMesh(pool, meshBox(8, 3, 12), blue) -- Blu con fusione, disegnato come l'acqua. nodeSetPosition(pool, 0, -1.5, 0) -- La sua faccia superiore, a Y = 0, è 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 verso +X. bodySetBuoyancy(crate, 1.6) -- Galleggia alta. bodySetBuoyancy(anchor, 0.3) -- Va a fondo. ---- [#_ragdolls] ==== Ragdoll `ragdollNew(node)` cerca la skin sotto un'istanza di modello e trasforma lo scheletro in una ricetta di ragdoll: ogni giunto che ha un giunto figlio diventa una capsula lungo quell'osso, collegata all'osso più vicino sopra di essa da un giunto swing-twist con limiti sensati, e i giunti foglia (dita, orecchie, la punta di una coda) seguono i loro genitori. Niente si muove fino a `ragdollActivate`: allora l'animazione si ferma, i corpi vengono creati dove sono le ossa, e da quel momento la fisica guida i nodi dei giunti, così la mesh con skin si affloscia, colpisce il pavimento e si ferma insieme al resto del mondo. `ragdollApplyImpulse` dà una spinta a un osso tramite il nome del suo giunto. `ragdollSetStrength` dà forza a ogni giunto verso la posa in cui il ragdoll è stato attivato, il che trasforma una caduta inerte in un barcollare o permette a un personaggio a terra di ritirare a sé gli arti; non rimette in piedi un corpo, perché niente fa leva sul pavimento. `ragdollDeactivate` restituisce lo scheletro all'animazione da dovunque giaccia, e un `animationPlay` con dissolvenza lo rialza dolcemente da lì. Regola un osso che sembra sbagliato con `ragdollSetJoint` prima dell'attivazione. [source,lua] ---- fox = modelInstance(foxModel) ragdollNew(fox) -- Ricetta dalla skin; ancora animato. function onCollision(a, b, x, y, z, speed) if (a == hero or b == hero) and speed > 3 then playerSetEnabled(hero, false) ragdollActivate(fox) -- Ora è la fisica a guidare lo scheletro. ragdollApplyImpulse(fox, "b_Spine02_03", 40, 30, 0) end end ---- [#_soft_bodies] ==== Corpi morbidi Stoffa, corde e gelatina sono mesh i cui vertici sono simulati. `softNew(node, SOFT_CLOTH)` trasforma la mesh del nodo in stoffa, e i suoi vertici (saldati per posizione) in particelle unite da vincoli di stiramento, taglio e flessione, nel punto in cui il nodo l'ha messa nel mondo; una `meshPlane(width, depth, columns, rows)` suddivisa è il punto di partenza abituale. `softNew(node, SOFT_BODY)` fa lo stesso per una mesh chiusa e `softSetPressure` la gonfia: un palloncino, una gelatina. `softNew(node, SOFT_ROPE, x, y, z, segments, radius)` stende una catena di particelle dalla posizione del nodo a quel punto e dà al nodo una mesh a tubo attorno a esse (imposta il suo aspetto con `nodeSetMaterial`). I corpi morbidi collidono con tutto ciò che è rigido, cadono per gravità e vengono disegnati dalla mesh da cui sono nati, che il motore di gioco riscrive a ogni fotogramma. `softPin` tiene la particella più vicina dov'è o la lega a un nodo (il bordo della bandiera all'asta, la cima della corda alla gru); `softSetStiffness`, `softSetMass` e `softSetDamping` regolano il materiale. Un nodo la cui mesh è condivisa con altri nodi cambia anche quelli, quindi dai a un corpo morbido una mesh tutta sua. [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) -- Appesa in verticale. softNew(flag, SOFT_CLOTH) for _, y in ipairs({ 1.9, 2.15, 2.4, 2.65, 2.9 }) do softPin(flag, -5, y, 0) -- Lungo l'asta. end ---- Il costo cresce con il numero di particelle: un telo di 20 per 20 sono 441 particelle e va bene su un Raspberry Pi 4; tieni la stoffa entro qualche centinaio di particelle e una corda entro una dozzina di segmenti. Le particelle collidono con i corpi rigidi, non con le facce, quindi è una mesh abbastanza fitta (particelle più vicine della cosa più piccola che ci cadrà sopra) a impedire che una cassa la attraversi. Non c'è autocollisione: la stoffa accartocciata può attraversare se stessa. Le mesh con skin non possono essere morbide. [#_seeing_the_physics] ==== Vedere la fisica Quando una cassa attraversa il pavimento o un giunto si piega nel verso sbagliato, `physicsSetDebug` disegna ciò che la simulazione contiene davvero, come linee sopra la scena a ogni fotogramma: * `DEBUG_SHAPES`: i corpi in wireframe, colorati secondo lo stato (dormiente, in movimento, statico), con i giocatori in giallo. * `DEBUG_CONSTRAINTS`: i giunti, i giunti dei ragdoll e i vincoli dei veicoli, con i loro limiti. * `DEBUG_CONTACTS`: una croce rossa dove i corpi si sono incontrati in questo passo. * `DEBUG_VELOCITIES`: una linea lungo la velocità di ogni corpo in movimento. * `DEBUG_STATIC`: anche le forme dei corpi statici, che altrimenti restano spente perché una mesh di livello sono tantissime linee. Sommali, o usa `DEBUG_ALL` per i primi quattro. `DEBUG_NONE` lo spegne, e da spento non costa nulla. Uno script disegna le proprie linee allo stesso modo con `lineDraw` (un raycast, un percorso, un riquadro di delimitazione), un fotogramma alla volta come l'overlay, e `navDraw` mostra una navmesh. [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] ==== Dove gira La fisica gira sulla CPU e non ha bisogno di una GPU, ma la libreria è compilata per SSE4.1 su x86 (dal 2008 in poi) e per NEON sul Pi; una macchina che ne è priva riceve un errore dal primo `bodyNew`. La simulazione usa tutti i core tranne uno, quindi un Pi a quattro core avanza su tre thread; la traccia del programma dice quanti. [#particles] === Particelle Scintille, fumo, fuoco, pioggia, polvere, magia: tutto ciò che è fatto di tante piccole cose di breve durata è un lavoro per un *emettitore*. Un emettitore contiene una ricetta (quante al secondo, quanto vivono, quanto veloci e in che direzione partono, che cosa le attira, come crescono, sbiadiscono e ruotano) e una riserva di particelle che gestisce secondo quella ricetta. Lo script imposta la ricetta una volta, poi avvia l'emettitore per un flusso costante (`emitterStart`), lo fa esplodere per uno sbuffo singolo (`emitterBurst`), o entrambe le cose. Il motore di gioco muove ogni particella viva a ogni fotogramma, e smette di muoverle mentre il gioco è in pausa. Un'unica API degli emettitori serve l'overlay e la scena 3D; cambiano solo le unità e il disegno. Tutto ciò che si dà come minimo e massimo (`emitterSetLife`, `emitterSetSpeed`, `emitterSetSize`) viene estratto a caso tra i due per ogni particella. [#_2d_emitters] ==== Emettitori 2D `emitterNew()` senza argomenti crea un emettitore 2D. Vive in coordinate dell'overlay, con Y verso il basso, quindi la gravità di una fontana è una Y positiva. Spostalo con `emitterSetPosition`, e disegnalo da `onOverlayUpdate` con `emitterDraw`. L'overlay stesso è un'immagine che dipinge la CPU, quindi le particelle non vi vengono dipinte dentro: le disegna la GPU, o sopra l'intero overlay (`PARTICLE_OVER`, il predefinito) o sotto di esso e sopra il video e la scena 3D (`PARTICLE_UNDER`, per il fumo dietro il punteggio). Gli emettitori 2D girano su ogni macchina su cui gira Singe, compreso il Raspberry Pi 3, e si sposano bene con la fisica 2D: leggi la posizione del nodo di un corpo e passala a `emitterSetPosition`. [source,lua] ---- sparks = emitterNew() emitterSetBlend(sparks, PARTICLE_ADD) emitterSetLife(sparks, 0.3, 0.7) emitterSetSpeed(sparks, 80, 200) emitterSetDirection(sparks, 0, -1) -- Verso l'alto dello schermo. 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] ==== Emettitori 3D `emitterNew(node)` crea un emettitore 3D collegato a un nodo. Le particelle nascono nella posizione del nodo nel mondo (sparse su `emitterSetRadius`), in unità del mondo, e si disegnano nella scena come billboard rivolti verso la telecamera dopo ogni mesh: con test di profondità contro la geometria, senza mai scrivere la profondità, non illuminate se non richiesto, senza proiettare né ricevere ombre. Per impostazione predefinita una particella resta dove è nata una volta lasciato l'emettitore (il fumo continua a fluttuare dove è stato prodotto mentre la torcia si sposta); `emitterSetLocal` fa invece seguire il nodo all'intera nube. Eliminare il nodo elimina i suoi emettitori. [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] ==== L'immagine Una particella è un quad con texture. Senza texture è un disco sfumato fornito dal motore di gioco; `emitterSetTexture` le dà invece uno sprite caricato (uno sbuffo, una scia di scintilla, un fiocco di neve), e per uno sprite animato `emitterSetFrames` sceglie un fotogramma a caso per ogni particella, così un solo atlante di sbuffi dà una nube varia. La fusione è normale (`PARTICLE_ALPHA`) o additiva (`PARTICLE_ADD`, per tutto ciò che brilla). `emitterSetColor` dà il colore e l'alfa alla nascita e alla morte, e la particella sfuma dall'uno all'altro nel corso della sua vita; `emitterSetSize` fa lo stesso per la sua dimensione, con un terzo valore per la velocità con cui ruota. [#_trails_collisions_lit_and_soft_particles] ==== Scie, collisioni, particelle illuminate e morbide `emitterSetTrail(emitter, length, width)` conserva le ultime posizioni di ogni particella e disegna un nastro che le attraversa, sfumando verso la coda: scintille, proiettili traccianti, comete, in 2D come in 3D. `emitterSetCollide` fa rimbalzare le particelle: su un pavimento orizzontale a un'altezza che indichi tu (`COLLIDE_FLOOR`, gratuito), o sul mondo fisico tramite raycast (`COLLIDE_SCENE`, solo 3D, con un tetto per fotogramma così che un grande emettitore distribuisca il costo su qualche fotogramma). Altre due sono solo per il 3D. `emitterSetLit` ombreggia ogni particella secondo le luci e il cielo della scena come una piccola sfera rivolta verso la telecamera, così il fumo prende il colore di un braciere e la polvere luccica al sole (le ombre non vengono lette). `emitterSetSoftness` fa svanire una particella lungo una certa distanza dove incontra la geometria, così il fumo non taglia un pavimento né una fontana la sua vasca; costa una passata di profondità in più della scena per fotogramma finché un emettitore la usa. [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) -- Prende il colore del fuoco da sotto. emitterSetSoftness(smoke, 0.4) -- Nessun bordo netto dove tocca la conca. emitterStart(smoke) embers = emitterNew(brazier) emitterSetBlend(embers, PARTICLE_ADD) emitterSetTrail(embers, 6, 0.02) -- Una breve scia dietro ogni brace. emitterSetCollide(embers, COLLIDE_FLOOR, 0.3, 0.8, 0) -- Rimbalza sul terreno a Y = 0. emitterStart(embers) ---- [#_cost] ==== Costo Le particelle costano poco da simulare e costano quanto coprono sullo schermo. Qualche migliaio di particelle piccole non sono niente; è una manciata di particelle additive grandi su tutto lo schermo a rallentare un Raspberry Pi 4. La riserva ha un tetto fissato da `emitterSetMax` (predefinito 1000); quando è piena, non ne nascono altre finché alcune non muoiono. [#authoring] === Descrivere un gioco invece di scriverlo Un gioco può essere scritto come *descrizione* -- una tabella di livelli, entità e regole -- e compilato in un normale gioco Singe da Forge, lo strumento di creazione. Forge è un progetto separato, con il suo download e il suo libro. [[migrating]] === Migrazione da Singe 2.10 Singe 3.00 ha spostato l'handle dello sprite al primo argomento di `spriteDraw`, `spriteLoop`, `spriteQuality`, `spriteRotate`, `spriteRotateAndScale`, `spriteScale` e `spriteSetFrame`, così ogni chiamata agli sprite ora corrisponde alla famiglia `video*`. Per aggiornare un gioco, sposta in testa l'ultimo argomento di ciascuna di quelle chiamate: [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 gioco che non puoi modificare può invece scegliere il vecchio ordine. O imposti la variabile globale prima di caricare il framework: [source,lua] ---- SINGE_LEGACY_SPRITE_ARGS = true dofile("Singe/Framework.singe") ---- oppure aggiungi `LEGACY_SPRITE_ARGS = true` alla sua voce in `games.dat`. Nient'altro ha cambiato forma, ma alcuni comportamenti sì: * Handle e conteggi vengono restituiti come interi Lua; `videoIsPlaying` restituisce un booleano; `mouseSetMode` non restituisce nulla. * Gli errori dentro le callback ora terminano il gioco con un traceback invece di essere stampati e ignorati, come si sono sempre comportati gli errori negli argomenti. * I tasti tenuti premuti non si ripetono più in `MODE_NORMAL`. * Accanto al gioco non viene scritto nulla. Senza `--datadir` la cartella dei dati è `data//` sotto la cartella dei giochi, mentre la 2.x usava la cartella del gioco stesso; salvataggi, impostazioni e punteggi record vanno sotto `singeGetDataPath()`. * `math.randomseed` accetta un seme frazionario, come faceva con il Lua della 2.10; viene arrotondato per difetto prima che il generatore di Lua 5.4 lo veda. `os.clock` è il tempo reale trascorso dall'avvio del motore di gioco, non il tempo di processore. * Il disco viene posizionato sul fotogramma 1 dal motore di gioco prima che il tuo script parta; `Framework.singe` non fa più un salto quando viene caricato. * `onMouseMoved` in modalità `MANY_MOUSE` passa il movimento reale nel terzo e nel quarto argomento, e `onControllerMoved` passa l'indice del controller (da `0` a `3`) invece dell'ID di istanza di SDL. * `mouseEnable` / `mouseDisable` e `singeEnablePauseKey` / `singeDisablePauseKey` funzionano ancora, come alias di `mouseSetEnabled` e `singeSetPauseKeyEnabled`. * Il laserdisc non è più dato per scontato. Una voce di `games.dat` con un `VIDEO` è un gioco laserdisc, come prima; una senza gira invece su un canvas. Dalla riga di comando passa `--disc` (o `--framefile`), perché un video trovato accanto allo script non implica più un disco. Vedi <>. * Il tasto di pausa ora mette in pausa l'intero gioco, non solo i media: lo script resta congelato finché il tasto non viene premuto di nuovo, e `SWITCH_PAUSE` viene consegnato allo script solo quando il tasto è stato disabilitato. Agisce alla pressione del tasto anziché al rilascio. Vedi <>. * `fontPrint` conserva le stringhe che disegna, e `overlayClear` ridipinge solo ciò che è stato disegnato dall'ultima pulizia, quindi un HUD ristampato a ogni fotogramma costa poco. Disegnare il testo con `fontToSprite` per le prestazioni è deprecato; funziona ancora, ed è ancora il modo per ottenere un testo che puoi misurare, scalare o ruotare. Vedi <>. [#_engine_constants] === Costanti del motore Il motore definisce queste variabili globali prima che parta qualsiasi script, quindi sono disponibili sia a `controls.cfg` sia a `Framework.singe`: [cols="2,3",options="header"] |=== | Costante | Significato | `SWITCH_UP` ... `SWITCH_RECORD` | Valori passati a `onInputPressed` / `onInputReleased` in `MODE_NORMAL`. `SWITCH_MOUSE_DISCONNECT` lo genera il motore stesso quando un mouse o una pistola ottica viene scollegato, una volta come pressione e una volta come rilascio; nessun tasto o pulsante lo produce. `SWITCH_RECORD` è l'interruttore di registrazione, su cui il motore agisce da sé, come fa con l'interruttore dello screenshot. | `FONT_QUALITY_SOLID`, `FONT_QUALITY_SHADED`, `FONT_QUALITY_BLENDED` | Argomenti per `fontQuality`. | `MODE_NORMAL`, `MODE_FULL` | Argomenti per `keyboardSetMode`. | `MOUSE_SINGLE`, `MOUSE_MANY` (anche `SINGLE_MOUSE`, `MANY_MOUSE`) | Argomenti per `mouseSetMode`. | `OVERLAY_NOT_UPDATED`, `OVERLAY_UPDATED` | Valori di ritorno per `onOverlayUpdate`. | `RENDER_PIXELATED`, `RENDER_SMOOTH` | Argomenti per `spriteQuality` / `videoQuality`. | `SPRITE_ALPHA`, `SPRITE_ADD`, `SPRITE_MULTIPLY` | Fusione per `spriteSetBlend`. | `DISC_STOPPED`, `DISC_PLAYING`, `DISC_PAUSED`, `DISC_EJECTED` | Valori restituiti da `discGetState`. | `LIGHT_DIRECTIONAL`, `LIGHT_POINT`, `LIGHT_SPOT` | Argomenti per `lightNew`. | `BODY_STATIC`, `BODY_DYNAMIC`, `BODY_KINEMATIC` | Tipi di corpo per `bodyNew`. | `SHAPE_BOX`, `SHAPE_SPHERE`, `SHAPE_CAPSULE`, `SHAPE_CYLINDER`, `SHAPE_HULL`, `SHAPE_MESH` | Forme di collisione per `bodyNew`. | `JOINT_HINGE`, `JOINT_BALL`, `JOINT_SLIDER` | Tipi di giunto (le chiamate `joint*` li nominano direttamente). | `DEBUG_NONE`, `DEBUG_SHAPES`, `DEBUG_CONSTRAINTS`, `DEBUG_CONTACTS`, `DEBUG_VELOCITIES`, `DEBUG_STATIC`, `DEBUG_ALL` | Ciò che disegna `physicsSetDebug`; sommali. | `PARTICLE_ALPHA`, `PARTICLE_ADD` | Fusione per `emitterSetBlend`. | `PARTICLE_OVER`, `PARTICLE_UNDER` | Dove si disegnano le particelle 2D, per `emitterSetLayer`. | `COLLIDE_NONE`, `COLLIDE_FLOOR`, `COLLIDE_SCENE` | Su che cosa rimbalzano le particelle, per `emitterSetCollide`. | `BILLBOARD_NONE`, `BILLBOARD_ALL`, `BILLBOARD_Y` | Come un nodo si rivolge alla telecamera, per `nodeSetBillboard`. | `LAYER_SCENE`, `LAYER_FRONT` | Se un nodo si disegna sotto l'overlay o davanti a esso, per `nodeSetLayer`. | `VEHICLE_CAR`, `VEHICLE_MOTORCYCLE`, `VEHICLE_TANK`, `VEHICLE_BOAT` | Tipi di veicolo per `vehicleNew`. | `SOFT_CLOTH`, `SOFT_BODY`, `SOFT_ROPE` | Tipi di corpo morbido per `softNew`. | `FILTER_LINEAR`, `FILTER_NEAREST` | Campionamento delle texture per `materialSetFilter`. | `TONEMAP_NEUTRAL`, `TONEMAP_ACES`, `TONEMAP_NONE` | Curve tonali per `sceneSetTonemap`. | `SOUND_ERROR_INVALID`, `SOUND_REMOVE_HANDLE` | `-1`, ciò che `soundPlay` restituisce quando nessun canale è libero. | `SINGE_VERSION_MAJOR`, `SINGE_VERSION_MINOR`, `SINGE_VERSION_STRING`, `SINGE_FRAMEWORK_VERSION` | La versione del motore, come interi, come stringa (`"v3.00"`) e come il numero che restituisce `singeVersion()`. | `SINGE_DEAD_ZONE` | Il `DEAD_ZONE` di `controls.cfg`, in unità grezze dell'asse. | `SINGE_TRIGGER_THRESHOLD` | La soglia dei grilletti analogici, nelle stesse unità grezze; `0` quando i grilletti usano `SINGE_DEAD_ZONE`, che è il predefinito. | `SINGE_LEGACY_SPRITE_ARGS` | Vero quando il gioco ha chiesto l'ordine degli argomenti degli sprite della 2.10. | `SINGE_DISC` | Vero quando il gioco ha un laserdisc; falso quando il mondo è il canvas. | `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` | La disposizione dei codici di input di controller e mouse, e quanti assi e pulsanti ha un controller; `Framework.singe` costruisce da essi le tabelle `GAMEPAD_N` e `MOUSE_N`, e una schermata di servizio li usa per percorrere un controller. | `SINGE_SCALE_MIN`, `SINGE_SCALE_MAX`, `SINGE_SHIFT_MIN`, `SINGE_SHIFT_MAX`, `SINGE_ROTATE_STEP` | Ciò che accettano `vldpSetScale`, `vldpSetShift` e `vldpSetRotate`. Limita i valori a queste costanti anziché a copie tue: uno spostamento fuori intervallo termina lo script. |=== `Framework.singe` aggiunge le tabelle `SCANCODE` e `MODIFIER` (i valori di tasti e modificatori di SDL), da `GAMEPAD_0` a `GAMEPAD_3`, da `MOUSE_0` a `MOUSE_3`, gli indici `GAMEPAD_AXIS_*` per `controllerGetAxis`, e `DIR`. [#_included_libraries] === Librerie incluse Oltre alle librerie standard di Lua e all'API di Singe, nei programmi Singe sono disponibili anche le seguenti librerie, senza bisogno di installare altro software: [cols="1,3",options="header"] |=== | Libreria | Scopo | Copas | Rete asincrona | LuaFileSystem | Supporto esteso al file system | json.lua | Codifica e decodifica JavaScript Object Notation | binaryheap | Struttura dati heap binario | timerwheel | Pianificazione efficiente dei timer | LuaSec | Comunicazione TLS / SSL | LuaSocket | TCP e UDP | LuaRS232 | Accesso alla porta seriale RS232 | lsqlite3 | Database SQLite, `require("sqlite3")` | md5 | `require("md5")` per `sum`, `hexsum`, `exor`, `crypt` e `decrypt`; la libreria PUC-Rio inclusa in Hypseus, che ne chiama `hexsum` il `sumhexa` | LuaBitOp | `require("bit")` per le operazioni bit a bit di Lua 5.1, che la 5.4 ha come operatori | lrandom | `require("random")` per un generatore Mersenne Twister, separato da `math.random` | Lua CJSON | `require("cjson")` per codifica e decodifica JSON, accanto al modulo `json` qui sopra | LPeg | `require("lpeg")` per le parsing expression grammar | middleclass | `require("middleclass")` per le classi, se preferisci costruire le entità con quelle anziché con le tabelle | lume | `require("lume")` per le piccole cose che servono a ogni gioco: `lerp`, `clamp`, `round`, `shuffle`, `split`, `serialize`, `weightedchoice` | inspect | `require("inspect")` per stampare una tabella in una forma che una persona riesce a leggere, per quando il debugger è `debugPrint` | bump | `require("bump")` per muovere cose che non devono attraversarsi a vicenda -- dice dove va a finire chi si muove, cosa che <> deliberatamente non fanno | MobDebug | `require("mobdebug")`, il debugger con cui parla <>; `--debug` lo avvia, quindi a un gioco non serve nessuna riga sua | RmlUi | Il modello a oggetti della GUI stessa, la variabile globale `rmlui` (vedi <>) |=== Il loro uso va oltre lo scopo di questo documento. Le librerie md5, LuaBitOp, lrandom, Lua CJSON e LPeg ci sono perché Hypseus le espone, così un gioco scritto per Hypseus trova gli stessi moduli con gli stessi nomi; niente in Singe ne ha bisogno. [#audiosync] === Sincronizzazione dell'audio Singe presenta il video rispetto all'orologio dell'audio: un fotogramma viene mostrato quando si sente il suono che gli appartiene. Il motore misura il proprio percorso audio all'avvio. SDL non può riferire quanto audio è in coda tra il mixer e l'altoparlante, quindi Singe svuota il dispositivo una volta, prima che si senta qualsiasi cosa, e conta la raffica di buffer che SDL usa per riempirlo di nuovo; quella raffica è la profondità della coda, e l'orologio la sottrae. Avvia con `--program` per vedere la misura nella traccia (`Audio device queue: ...`). Ciò che il motore non può vedere è tutto quello che sta a valle del server audio del sistema operativo: un DAC, un ricevitore HDMI, un collegamento Bluetooth. Questi aggiungono un ritardo fisso che varia da macchina a macchina. Se le azioni sullo schermo precedono visibilmente il loro suono, passa i millisecondi mancanti con `--audiodelay=MS`, metti `AUDIO_DELAY = MS` in `games.dat`, o lascia che il gioco lo imposti da un menu di servizio con <>. I valori positivi significano che l'audio è in ritardo; il video viene trattenuto per allinearsi. Un valore negativo gestisce il raro caso di un audio che arriva in anticipo. [#menucalibration] ==== Calibrare dal menu Il menu incluso ha una schermata di calibrazione per quel ritardo a valle. Premi il tasto assegnato a `INPUT_SERVICE` (di base il tasto `9`) sull'elenco dei giochi e scegli Audio Delay dagli strumenti di servizio (vedi xref:Manual.adoc#servicetools[Gli strumenti di servizio]). La schermata è una pagina del documento del menu: copre il menu di nero, mostra il ritardo in prova e la coda del dispositivo misurata dal motore, riproduce un clic una volta al secondo e diventa bianca per un fotogramma a ogni lampo. Regola con sinistra e destra (10 ms) e con su e giù (1 ms) finché il lampo e il clic non avvengono insieme, poi premi il pulsante 1 per salvare. Start azzera, e il pulsante 2 (o di nuovo il tasto di servizio) annulla. Il valore viene salvato in `machine.cfg` nella radice dei dati e applicato dal motore a ogni gioco su quella macchina, che sia avviato dal menu o dalla riga di comando; si somma a qualsiasi `AUDIO_DELAY` del singolo gioco. Ricalibra dopo aver cambiato altoparlanti, cuffie o schermi. La schermata funziona perché il clic e l'audio del disco condividono un solo dispositivo mixer e una sola coda: il lampo è programmato dopo il clic a una distanza pari alla coda misurata più il valore candidato, quindi quando i due coincidono il candidato è esattamente il ritardo che il motore non può vedere. Il ritardo dello schermo viene compreso gratis. Un gioco può leggere i valori con <> e <>, o offrire una propria schermata con <>. Contano anche due proprietà del file video stesso. L'audio AAC porta con sé dei campioni di priming dell'encoder che il contenitore deve dire ai decoder di saltare; i file multiplexati senza quell'informazione (un MP4 senza edit list, un file Matroska senza codec delay) riproducono l'audio circa 20 millisecondi in ritardo, cosa che `AUDIO_DELAY` può assorbire. E quando il primo fotogramma video ha una marca temporale successiva al primo campione audio, Singe rispetta la tempistica del contenitore, così l'audio iniziale non va perso. [#formats] === Formati video, audio e dei contenitori Singe decodifica il video direttamente con le librerie di FFmpeg, usando il decoder hardware della piattaforma quando ne offre uno per il codec (VA-API o VDPAU su Linux, D3D11VA su Windows, VideoToolbox su macOS, e su una scheda ARM i decoder V4L2 -- quello memory-to-memory, che su un Pi 4 copre l'H.264, quello stateless della Request API che un Pi 5 usa per l'HEVC, e quello di Rockchip su un RK3588), e altrimenti il proprio decoder software; la traccia del programma riporta quale, e `--softwarevideo` forza il software. Qualsiasi contenitore e codec che l'FFmpeg incluso sa demultiplexare e decodificare verrà riprodotto: MP4, MKV, program stream MPEG, AVI, AV1 e i classici elementary stream `.m2v` di Daphne con un file audio `.ogg` corrispondente accanto. Ogni traccia audio del file è disponibile per `discSetAudioTrack` / `videoSetAudioTrack`; tutte le tracce devono condividere lo stesso formato dei campioni, numero di canali e frequenza. Un laserdisc conteneva semiquadri interlacciati, e un rip che li ha conservati mostra l'effetto pettine su ogni schermo progressivo. `--deinterlace` decide che cosa succede: `auto`, il predefinito, applica bwdif ai fotogrammi che il file segna come interlacciati e lascia stare tutto il resto, così un disco progressivo non paga nulla; `on` è per un file i cui flag sono sbagliati; `off` non tocca mai l'immagine. Esce un'immagine per ognuna che entra -- un disco è indirizzato per numero di fotogramma, quindi un deinterlacciatore che trasformasse ogni fotogramma in due semiquadri sposterebbe ogni fotogramma nell'indice. La prima volta che un video viene aperto, Singe lo indicizza e salva l'indice accanto agli altri dati del gioco (`-.index`, con l'hash ricavato dal nome completo del video, così due video con lo stesso nome base mantengono indici separati). L'indicizzazione è una passata sul file senza decodifica, pochi secondi anche per un disco lungo quanto un film, e si ripete se il video cambia. Quando un video viene caricato, Singe riporta la spaziatura dei suoi fotogrammi chiave nella traccia del programma (`--program`), con un avviso se i fotogrammi chiave distano più di due secondi, perché un salto deve decodificare in avanti dal fotogramma chiave precedente. La decodifica avviene su un thread separato, quindi un salto lento mostra il fotogramma precedente un po' più a lungo invece di bloccare il gioco. I segmenti di un framefile vengono aperti man mano che il disco li raggiunge, non tutti insieme: quello in riproduzione, quello successivo, e lo spazio perché un salto passi da uno all'altro. Un framefile lungo può avere centinaia di segmenti -- un gioco pubblicato ne ha 213 -- e tenere aperto un decoder per ognuno di essi costa centinaia di file aperti senza alcun guadagno. Un framefile che nomina un file che non c'è lo segnala comunque all'avvio. Per il materiale da laserdisc i vincoli sono la precisione al fotogramma e la velocità dei salti, non la compressione. L'H.264 in MP4 o MKV con un intervallo breve tra i fotogrammi chiave (uno o due secondi) salta rapidamente e si riproduce su ogni piattaforma supportata; intervalli lunghi rendono `discSearch` visibilmente lento, perché il decoder deve avanzare dal fotogramma chiave precedente. La definizione standard (720x480 o 720x576) è il punto ideale. Le sorgenti ad alta definizione funzionano ma costano proporzionalmente più CPU su hardware di classe Raspberry Pi, e l'overlay è per impostazione predefinita a metà della risoluzione del video, quindi un video sovradimensionato porta poco. Gli effetti sonori e la musica vengono letti prima da SDL_mixer, e da FFmpeg per tutto ciò che quello rifiuta, quindi insieme coprono: * WAV, AIFF, AU e VOC per l'audio non compresso. I file WAV brevi non compressi danno la latenza più bassa, e sono ciò che dovrebbe essere uno sparo. * Ogg Vorbis, Opus, MP3, FLAC e WavPack. * AAC (`.m4a`, `.aac`), ALAC, AC-3, WMA, APE, TTA, Speex e AMR -- tutto il resto che l'FFmpeg incluso decodifica. * Moduli tracker tramite libxmp: MOD, S3M, XM e IT, e una cinquantina di altri, tra cui 669, AMF, MED, OKT, PTM, STM e ULT. * Chiptune tramite game-music-emu: NSF e NSFE, SPC, VGM, GBS, AY, GYM, HES, KSS e SAP. La forma VGZ compressa con gzip non viene letta; decomprimila prima in VGM. Un chiptune che non dice quanto dura suona finché non tace, il che può non accadere mai, quindi viene decodificato man mano che suona invece che subito. * MIDI, sintetizzato da un SoundFont. Vedi <>. La riga `Audio:` dell'intestazione della traccia (`--program` o `--trace`) elenca i decoder che la build contiene davvero. Le immagini funzionano allo stesso modo: prima SDL_image, poi FFmpeg per il resto. Questo copre PNG, JPEG, WebP, GIF, BMP, TGA, PCX, PNM, LBM, QOI, XCF, XPM e SVG da SDL_image, e TIFF, OpenEXR, JPEG 2000, DDS, PSD, DPX, AVIF e HEIC da FFmpeg. Una GIF o una WEBP animata si carica come animazione di sprite; vedi <>, che accetta anche una dimensione per rasterizzare un SVG. Radiance `.hdr` e OpenEXR funzionano entrambi come cielo (<>) e conservano tutta la loro gamma. I font vengono letti da FreeType: TrueType, OpenType, WOFF, WOFF2, Type 1, BDF e PCF, con glifi a colori (sia di tipo bitmap sia vettoriali) e shaping HarfBuzz per l'arabo, l'ebraico e le scritture indiane. [#reference] == Riferimento dell'API Lua Questo capitolo documenta l'API Lua esposta dal motore Singe -- ogni funzione che lo script del tuo gioco può chiamare, e ogni callback che Singe invocherà su di te. È rivolto a chi sviluppa nuovi giochi o porta da altri motori giochi ricchi di video. [#conventions] === Convenzioni Ogni voce dell'API qui sotto segue la stessa struttura: * *Firma (o firme)* in un blocco di codice, con ogni forma accettata. * *Descrizione* di ciò che fa la funzione e di eventuali effetti collaterali. * *Parametri* elencati nel testo, o in una tabella quando sono più di due. * Riga *Restituisce* che descrive il valore restituito a Lua (omessa quando la funzione non restituisce nulla). * *Note*, se ci sono insidie da segnalare. * *Disponibile da* -- la versione di Singe in cui la funzione è comparsa per la prima volta. * *Vedi anche* -- funzioni correlate che probabilmente userai nello stesso punto del codice. * *Esempio* -- un frammento breve e realistico, con la forma del vero codice di un gioco. Una voce che inizia con *Estensione di Hypseus* c'è perché un gioco scritto per Hypseus Singe giri qui senza modifiche. Lo stesso vale per una forma degli argomenti segnata come di Hypseus su una funzione che per il resto accetta quella di Singe. IMPORTANT: *Non scrivere nuovi giochi per l'API di Hypseus.* Quelle voci esistono per compatibilità, non come raccomandazione. Diverse di esse rispondono con un valore fisso perché la cosa che descrivono in Singe non esiste, alcune sono accettate e ignorate, e altre portano scale e ordini degli argomenti ereditati da un altro motore -- `videoGetVolume()` conta fino a `64` dove il resto di Singe conta fino a `100`, e i nomi `spriteAnim` prendono lo sprite per ultimo dove ogni chiamata agli sprite di Singe lo prende per primo. Ogni voce indica l'equivalente Singe da usare al suo posto. Il nuovo codice che si attiene all'API di Singe è più breve, coerente con se stesso, e non avrà sorprese quando una risposta di compatibilità resta costante. Alcune regole valgono per tutta l'API: * *I colori* sono a 8 bit per canale: rosso, verde, blu e opacità sono ciascuno interi da `0` a `255`. * *Le coordinate dell'overlay* sono nella risoluzione corrente dell'overlay (impostata da `overlaySetResolution`), non in pixel dello schermo. La risoluzione predefinita dell'overlay è metà della risoluzione del video in ogni dimensione; chiama `overlaySetResolution` per cambiarla. * *Gli handle* restituiti dalle funzioni `*Load` (sprite, suoni, font, video) sono interi opachi. Ripassali invariati alle altre funzioni di quello spazio dei nomi. Non farci calcoli. * *Gli argomenti non validi terminano lo script.* Se una funzione viene chiamata con il numero sbagliato di argomenti, con tipi sbagliati o con un handle già scaricato, Singe termina lo script in esecuzione con un errore -- non restituisce `nil` né un codice di errore. Convalida l'input dell'utente nel tuo script prima di passarlo all'API. * *Si disegna solo da `onOverlayUpdate`.* Le chiamate a qualsiasi funzione `overlay*`, `spriteDraw`, `videoDraw` o `fontPrint` da altre callback non si rifletteranno sullo schermo e possono corrompere l'overlay. È l'unica regola in cui inciampa ogni nuovo sviluppatore Singe. [#animation] === Animazione Queste chiamate guidano le animazioni che un modello porta con sé, per istanza. Ognuna accetta il *nodo radice* restituito da `modelInstance`; gli altri nodi sollevano un errore, tranne con `animationIsPlaying`, che risponde `false`. Le animazioni sono numerate da `1` nell'ordine in cui le elenca `modelGetAnimations`, o chiamate con il nome che dà loro il file. Una clip si riproduce sul livello base, e fino ad altre quattro possono riprodursi sui livelli da `1` a `4` sopra di esso; le chiamate di tempo, pausa, ripresa e salto agiscono solo sul livello base. Il tempo è in secondi, avanza con l'orologio di al massimo un decimo di secondo per fotogramma, e si ferma mentre il gioco è in pausa. Vengono guidati l'animazione dei nodi, lo skinning e i pesi dei morph target. Vedi la parte Animazione di <>. [#animationgettime] ==== animationGetTime [source,text] ---- seconds = animationGetTime(node) ---- Il tempo corrente nella clip del livello base, in riproduzione o in pausa, nei secondi propri della clip (una `speed` di `2` lo fa avanzare due volte più veloce). Una clip in loop torna a `0` alla fine; una che non è in loop si ferma alla sua durata. Se non è stato ancora riprodotto niente dà `0`. Le clip sui livelli superiori non vengono riportate. Un nodo che non è una radice di istanza termina lo script. *Restituisce:* i secondi, come numero. *Disponibile da:* 3.00. *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- Suona il passo una volta ogni volta che il ciclo di camminata supera i suoi due punti di contatto. 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) ---- Se la clip del livello base sta avanzando su questa istanza. Una clip non in loop smette di riprodursi quando raggiunge la fine, e `animationPause` e `animationStop` rendono entrambe questo valore `false`; una clip in loop resta `true` finché non viene fermata. A differenza delle altre chiamate qui, un nodo che non è una radice di istanza non termina lo script ma risponde `false`. *Restituisce:* booleano. *Disponibile da:* 3.00. *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- Torna a riposo quando l'attacco singolo si è esaurito. 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) ---- Blocca la clip del livello base al suo tempo corrente; i nodi mantengono quella posa finché `animationResume` non la fa continuare o non viene riprodotta un'altra clip. Le clip sui livelli superiori continuano. Mettere in pausa un'istanza che non sta riproducendo niente è innocuo. Un nodo che non è una radice di istanza termina lo script. *Disponibile da:* 3.00. *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- Una scena d'intermezzo blocca la folla mentre la telecamera si muove. 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) ---- Avvia dall'inizio una delle clip del modello sul livello base di questa istanza, sostituendo ciò che vi era in riproduzione; la posa si aggiorna subito. Con un `fade` la vecchia clip continua mentre svanisce in quel numero di secondi, e quando non c'era niente in riproduzione la dissolvenza parte dalla posa in cui si trovano ora i nodi (un ragdoll appena disattivato, una posa impostata dallo script), e i nodi che la nuova clip non anima tornano dolcemente alla posa di riposo del modello. Un nodo che non è una radice di istanza, un nome che il modello non ha o un numero fuori dall'intervallo da `1` al numero di clip termina lo script. *Parametri:* * `node` -- il nodo radice dell'istanza, da `modelInstance`. * `nameOrIndex` -- il nome di una clip come lo salva il file, o il suo numero da `1` nell'ordine di `modelGetAnimations`. * `loop` -- booleano, predefinito `true`. Con `false` la clip si ferma al suo ultimo fotogramma chiave e `animationIsPlaying` diventa `false`. * `speed` -- velocità di riproduzione, predefinita `1`. `2` è due volte più veloce; `0.5` è la metà. * `fade` -- secondi di dissolvenza incrociata da ciò che era in riproduzione, predefinito `0` (uno stacco netto). *Disponibile da:* 3.00. *Vedi anche:* <>, <>, <>, <> .Esempio [source,lua] ---- -- Una guardia che resta a riposo finché non avvista il giocatore, poi 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) ---- Riproduce una clip su uno dei quattro livelli sopra il base, fusa sopra ciò che sta sotto secondo il peso del livello (`animationSetLayerWeight`, predefinito `1`) e limitata a una parte dello scheletro quando è impostata una maschera (`animationSetLayerMask`). I livelli più alti si fondono sopra quelli più bassi; ogni livello gestisce la propria clip, il proprio tempo e il proprio loop indipendentemente dal base. `nameOrIndex`, `loop`, `speed` e `fade` significano ciò che significano in `animationPlay`, con una differenza: un `fade` su un livello che era vuoto fa salire il peso del livello da `0` invece di fare una dissolvenza incrociata da una posa. Un livello fuori dall'intervallo da `1` a `4`, un nodo che non è una radice di istanza o una clip che il modello non ha termina lo script. *Parametri:* * `node` -- il nodo radice dell'istanza. * `layer` -- da `1` a `4`. * `nameOrIndex` -- il nome o il numero di una clip, come in `animationPlay`. * `loop` -- predefinito `true`. * `speed` -- predefinito `1`. * `fade` -- secondi, predefinito `0`. *Disponibile da:* 3.00. *Vedi anche:* <>, <>, <>, <> .Esempio [source,lua] ---- -- Cammina con tutto il corpo, si guarda intorno solo con la testa. animationPlay(fox, "Walk", true) animationPlayLayer(fox, 1, "Survey", true, 1, 0.5) animationSetLayerMask(fox, 1, nodeFind("b_Neck_04", fox)) -- Più tardi, saluta con un braccio sopra qualunque cosa stiano facendo le gambe. function onGreet() animationPlayLayer(fox, 2, "Wave", false, 1, 0.2) end ---- [#animationresume] ==== animationResume [source,text] ---- animationResume(node) ---- Fa continuare la clip del livello base da dove l'ha bloccata `animationPause`, o da dovunque l'abbia spostata nel frattempo `animationSetTime`. Riprendere una clip già in riproduzione non cambia nulla. Un'istanza senza clip sul livello base (mai riprodotta, o fermata) termina lo script, come un nodo che non è una radice di istanza. *Disponibile da:* 3.00. *Vedi anche:* <>, <> .Esempio [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 un livello a `maskNode` e a tutto ciò che sta sotto di esso, così la clip su quel livello muove solo quella parte del modello e il resto continua a seguire i livelli sottostanti. `maskNode` deve essere uno dei nodi dell'istanza stessa, trovato con `nodeFind(name, node)`; la radice dell'istanza in sé non conta. Omettere `maskNode`, o passare `nil`, toglie il limite. La maschera resta sul livello anche nelle successive chiamate ad `animationPlayLayer`. Un livello fuori dall'intervallo da `1` a `4` termina lo script, e così un nodo che non è una radice di istanza o un `maskNode` che non fa parte di quell'istanza. *Parametri:* * `node` -- il nodo radice dell'istanza. * `layer` -- da `1` a `4`. * `maskNode` -- un giunto o un nodo dell'istanza; omesso o `nil` per l'intero modello. *Disponibile da:* 3.00. *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Punta la parte superiore del corpo verso il bersaglio mentre le gambe continuano a camminare. 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) ---- Quanto si vede la clip di un livello sopra ciò che sta sotto, da `0` (per niente) a `1` (del tutto, il predefinito); i valori fuori da quell'intervallo vengono limitati. Il peso appartiene al livello, non alla clip, quindi sopravvive ad `animationPlayLayer` e ad `animationStop` su quel livello. Cambialo un poco a ogni fotogramma per far apparire o svanire un livello a mano, o lascia che lo faccia una volta un `fade` su `animationPlayLayer`. Un livello fuori dall'intervallo da `1` a `4` o un nodo che non è una radice di istanza termina lo script. *Disponibile da:* 3.00. *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- La zoppia peggiora man mano che la salute cala. function onHealthChanged(health) local limp = 1 - health / maxHealth animationSetLayerWeight(hero, 1, limp) end ---- [#animationsettime] ==== animationSetTime [source,text] ---- animationSetTime(node, seconds) ---- Fa saltare la clip del livello base a un tempo nei suoi secondi; i valori negativi diventano `0`. La posa si aggiorna subito, che la clip sia in riproduzione o no, così un'istanza in pausa può essere portata a un fotogramma esatto, e una in riproduzione continua dal nuovo tempo. Qualsiasi dissolvenza incrociata in corso sul livello base viene interrotta. Un'istanza senza clip sul livello base termina lo script, come un nodo che non è una radice di istanza. *Disponibile da:* 3.00. *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Una porta la cui clip di apertura segue una leva trascinata dal giocatore. animationPlay(door, "Open", false) animationPause(door) function onLeverMoved(fraction) animationSetTime(door, fraction * doorClipLength) end ---- [#animationstop] ==== animationStop [source,text] ---- animationStop(node) animationStop(node, layer) ---- Ferma e dimentica ogni clip sull'istanza, o solo la clip su un livello sopra il base (da `1` a `4`); il livello base da solo non può essere fermato senza i livelli sopra di esso. I nodi mantengono la loro ultima posa, da cui un successivo `animationPlay` con un `fade` esce dolcemente. Dopo aver fermato tutto, `animationResume` e `animationSetTime` terminano lo script finché non si riproduce di nuovo qualcosa. I pesi e le maschere dei livelli vengono conservati. Un livello fuori dall'intervallo da `1` a `4` o un nodo che non è una radice di istanza termina lo script. *Disponibile da:* 3.00. *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Passa il corpo al ragdoll della fisica: blocca la posa, poi lascialo cadere. function onKilled() animationStop(hero) ragdollActivate(hero) end -- Il saluto sul livello 2 è finito; la camminata sotto di esso continua. function onWaveFinished() animationStop(hero, 2) end ---- [#bezelref] === Bezel La grafica del cabinato e il tabellone segnapunti da sala giochi che l'accompagna. `mainBezelLoaded` chiede della grafica caricata da `--bezel`; la famiglia `scoreBezel` guida il pannello dei punteggi, un documento RmlUi che il motore fornisce e che un gioco può sostituire con una copia propria, `scoreBezel.rml` accanto al suo script. Ogni chiamata qui è un'estensione di Hypseus Singe, mantenuta perché i suoi giochi girino senza modifiche, e ognuna di esse è descritta dal lato del giocatore in xref:Manual.adoc#bezels[Bezel]. Il pannello parte spento, come in Hypseus, e solo un gioco lo accende. I valori che riceve vengono ricordati sia che sia visibile sia che non lo sia, quindi un gioco può impostarli prima di abilitare il pannello. Ogni indicatore ha il numero di cifre che ha il tabellone segnapunti di Hypseus -- sei per un punteggio, due per i crediti, una per il numero di vite -- e un valore vi viene mostrato allineato a destra con zeri iniziali; un punteggio di più di sei cifre mostra le ultime sei. Anche i valori negativi di Hypseus vengono capiti: `-2` accende un trattino in ogni cifra, `-4` una `H` e `-5` una `A`, e `-1`, `-3` e qualsiasi altro numero negativo non accendono niente. [#mainbezelloaded] ==== mainBezelLoaded [source,text] ---- loaded, id = mainBezelLoaded() ---- Estensione di Hypseus. Se `--bezel` ha caricato una grafica del cabinato, e l'identificativo del file che ha caricato. L'identificativo è l'hash FNV-1a a 64 bit dei byte del file scritto come stringa decimale, che è ciò che riporta Hypseus, quindi un gioco che riconosce un certo bezel dal suo numero riconosce qui lo stesso file. Senza grafica caricata le risposte sono `false` e `"0"`. *Restituisce:* un booleano, e l'identificativo come stringa. *Disponibile da:* 3.00, come estensione di Hypseus Singe. *Vedi anche:* <>, xref:Manual.adoc#bezels[Bezel] .Esempio [source,lua] ---- -- Disegna la cornice di sprite solo quando il giocatore non ha una grafica sua. local loaded, id = mainBezelLoaded() if not loaded then spriteDraw(frameSprite, 0, 0) end ---- [#scorebezelclear] ==== scoreBezelClear [source,text] ---- scoreBezelClear() ---- Estensione di Hypseus. Svuota ogni indicatore del pannello dei punteggi: i crediti, entrambi i punteggi ed entrambi i numeri di vite non mostrano proprio nulla, e restano solo le cifre attenuate dietro di essi. I valori non vengono dimenticati, ma il successivo `scoreBezelCredits`, `scoreBezelScore` o `scoreBezelLives` riaccende il pannello con ciò che contiene in quel momento. Si può chiamare senza rischi con il pannello spento. *Disponibile da:* 3.00, come estensione di Hypseus Singe. *Vedi anche:* <> [#scorebezelcredits] ==== scoreBezelCredits [source,text] ---- scoreBezelCredits(credits) ---- Estensione di Hypseus. Imposta l'indicatore dei crediti, che ha due cifre. Riaccende anche un pannello che `scoreBezelClear` ha svuotato. *Parametri:* * `credits` -- intero; vengono mostrate le ultime due cifre. *Disponibile da:* 3.00, come estensione di Hypseus Singe. *Vedi anche:* <> [#scorebezelenable] ==== scoreBezelEnable [source,text] ---- scoreBezelEnable(enabled) scoreBezelEnable(enabled, type) ---- Estensione di Hypseus. Mostra o nasconde il pannello dei punteggi. Il primo `true` lo costruisce dal `scoreBezel.rml` del gioco stesso, accanto al suo script, quando c'è, oppure da `Singe/scoreBezel.rml`; nasconderlo e mostrarlo di nuovo dopo di allora non costa nulla e ne conserva i valori. Parte nascosto, che è anche il modo in cui viene distribuito Hypseus, quindi un gioco che non chiama mai questa funzione si comporta esattamente come prima -- e dovrebbe chiedere a <> prima di dare per scontato di averne uno. Il `type` di Hypseus sceglie tra i suoi cinque backend di tabellone segnapunti. Singe ne ha uno, un documento RmlUi, quindi l'argomento viene controllato e ignorato. Una macchina senza GPU non ha alcuna GUI; lì il pannello non può essere costruito, `scoreBezelGetState` continua a rispondere `false`, e viene stampato un avviso, una volta sola. Un primo argomento non booleano termina lo script. *Parametri:* * `enabled` -- booleano. * `type` -- intero, accettato e ignorato. *Disponibile da:* 3.00, come estensione di Hypseus Singe. *Vedi anche:* <>, xref:Manual.adoc#bezels[Bezel] .Esempio [source,lua] ---- -- Il pannello del motore quando c'è, gli sprite quando non c'è. scoreBezelEnable(true, 0) if scoreBezelGetState() then scoreBezelTwinScoreOn(twoPlayer) scoreBezelCredits(credits) else drawSpriteScoreboard() end ---- [#scorebezelgetstate] ==== scoreBezelGetState [source,text] ---- state = scoreBezelGetState() ---- Estensione di Hypseus. Se il pannello dei punteggi è visibile. Ogni gioco Hypseus lo chiede prima di usare il pannello e disegna il proprio tabellone segnapunti con gli sprite quando la risposta è `false`, che è il ripiego giusto e non un errore. *Restituisce:* booleano. *Disponibile da:* 3.00, come estensione di Hypseus Singe. *Vedi anche:* <> [#scorebezellives] ==== scoreBezelLives [source,text] ---- scoreBezelLives(player, lives) ---- Estensione di Hypseus. Imposta l'indicatore delle vite di un giocatore, che ha una cifra. `player` è `1` o `2`; qualsiasi altro numero viene ignorato, e così `2` finché <> non ha messo sul pannello gli indicatori del secondo giocatore. Riaccende anche un pannello che `scoreBezelClear` ha svuotato. *Parametri:* * `player` -- `1` o `2`. * `lives` -- intero; viene mostrata l'ultima cifra. *Disponibile da:* 3.00, come estensione di Hypseus Singe. *Vedi anche:* <> [#scorebezelscore] ==== scoreBezelScore [source,text] ---- scoreBezelScore(player, score) ---- Estensione di Hypseus. Imposta l'indicatore del punteggio di un giocatore, che ha sei cifre. `player` è `1` o `2`, con la stessa regola per `2` di `scoreBezelLives`. Riaccende anche un pannello che `scoreBezelClear` ha svuotato. *Parametri:* * `player` -- `1` o `2`. * `score` -- intero; vengono mostrate le ultime sei cifre. *Disponibile da:* 3.00, come estensione di Hypseus Singe. *Vedi anche:* <> .Esempio [source,lua] ---- function addScore(points) score = score + points if scoreBezelGetState() then scoreBezelScore(1, score) end end ---- [#scorebezeltwinscoreon] ==== scoreBezelTwinScoreOn [source,text] ---- scoreBezelTwinScoreOn(enabled) ---- Estensione di Hypseus. Mette sul pannello gli indicatori del punteggio e delle vite del secondo giocatore, o li toglie di nuovo e lascia lo spazio agli altri due. Spegnerli svuota anche il pannello, esattamente come in Hypseus, quindi falli seguire da un valore se gli indicatori del primo giocatore devono restare accesi. Mentre sono spenti, `scoreBezelScore(2, ...)` e `scoreBezelLives(2, ...)` non fanno nulla. Un argomento non booleano termina lo script. *Parametri:* * `enabled` -- booleano. *Disponibile da:* 3.00, come estensione di Hypseus Singe. *Vedi anche:* <> [#body] === Corpo Un corpo dà a un nodo della scena un posto nel mondo fisico. C'è un corpo per nodo, indicato dall'handle del nodo: un corpo statico resta dov'era il nodo quando il corpo è stato creato, un corpo cinematico segue il suo nodo a ogni passo e spinge senza essere spinto, e un corpo dinamico è mosso dalla simulazione e guida il suo nodo. Dimensioni, posizioni e velocità sono in unità del mondo (metri in una scena 3D, pixel dell'overlay in un mondo 2D), le masse in chilogrammi e il tempo in secondi. Ogni chiamata qui termina lo script quando il nodo non ha un corpo; una chiamata che ha senso solo per un tipo di corpo (una forza su un corpo statico, per esempio) viene ignorata sugli altri, tranne `bodySetMass`, che solleva un errore. Vedi <> in Sviluppo di giochi per come si combinano corpi, nodi e fotogramma. [#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) ---- Dà al nodo un corpo del tipo indicato, posizionato dove si trova il nodo nel mondo in questo momento (genitori compresi), con una forma di collisione dimensionata da `a`, `b` e `c` in unità del mondo e scalata dalla scala del nodo nel mondo. Una seconda chiamata sullo stesso nodo sostituisce la prima, e un nodo che aveva un giocatore lo perde. La massa di un corpo dinamico deriva dal volume della sua forma alla densità dell'acqua, quindi imposta `bodySetMass` su tutto ciò che deve sembrare leggero. Cambiare in seguito la scala del nodo non ridimensiona il corpo. Termina lo script per un tipo o una forma sconosciuti, quando la fisica non è disponibile su questa macchina, quando il mondo contiene già `4096` corpi, o quando la forma non può essere costruita (una `SHAPE_MESH` dinamica, o un inviluppo senza mesh sotto il nodo). *Parametri:* * `type` -- `BODY_STATIC`, `BODY_DYNAMIC` o `BODY_KINEMATIC`. * `shape` -- `SHAPE_BOX` (`a`, `b`, `c` sono larghezza, altezza e profondità), `SHAPE_SPHERE` (`a` è il raggio), `SHAPE_CAPSULE` o `SHAPE_CYLINDER` (`a` è il raggio, `b` l'altezza totale lungo Y, calotte comprese), `SHAPE_HULL` (un inviluppo convesso della mesh del nodo e di tutto ciò che sta sotto, senza misure), o `SHAPE_MESH` (i triangoli esatti, solo per corpi statici e cinematici; una mesh da heightmap diventa un campo di altezze). * `a`, `b`, `c` -- le misure di cui la forma ha bisogno; quelle mancanti valgono `0` e ogni misura viene portata ad almeno `0.001`. *Note:* Una sfera è scalata dall'asse di scala più grande del nodo, il raggio di una capsula o di un cilindro dal maggiore tra X e Z. Una capsula più corta del doppio del suo raggio è una sfera con una fettina nel mezzo. *Disponibile da:* 3.00. *Vedi anche:* <>, <>, <>, <> .Esempio [source,lua] ---- -- Una cassa leggera e un modello di papera come inviluppo, lasciati cadere sul pavimento. 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) ---- Aggiunge una forza, in newton, a un corpo dinamico solo per i passi di questo fotogramma; chiamala a ogni fotogramma per continuare a spingere. Senza un punto la forza agisce attraverso il centro di massa e si limita a spostare il corpo; con un punto del mondo `(px, py, pz)` fa anche ruotare il corpo attorno al suo centro. Una forza accelera di forza diviso massa, quindi commisurala a `bodySetMass`. I corpi statici e cinematici la ignorano. *Parametri:* * `fx`, `fy`, `fz` -- la forza in newton lungo ciascun asse del mondo. * `px`, `py`, `pz` -- punto del mondo facoltativo in cui agisce la forza; omettilo per il centro di massa. *Disponibile da:* 3.00. *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- Un ventilatore spinge il palloncino attraverso la stanza finché l'interruttore è acceso. 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 subito la quantità di moto di un corpo dinamico: un impulso di massa per velocità aggiunge quella velocità. Senza un punto agisce attraverso il centro di massa; con un punto del mondo `(px, py, pz)` fa anche ruotare il corpo, quindi colpire una cassa vicino alla sommità la ribalta. Questa è la chiamata per un colpo, un calcio, un'esplosione o un lancio, mentre `bodyApplyForce` è per una spinta che dura. I corpi statici e cinematici lo ignorano. *Parametri:* * `ix`, `iy`, `iz` -- l'impulso in chilogrammi per metro al secondo lungo ciascun asse del mondo. * `px`, `py`, `pz` -- punto del mondo facoltativo in cui agisce l'impulso; omettilo per il centro di massa. *Disponibile da:* 3.00. *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Fa schizzare verso l'alto ciò che sta sotto il mouse, nel punto in cui è stato colpito. 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) ---- Toglie il corpo dal mondo; il nodo e la sua mesh restano dove sono. Ogni giunto collegato al corpo, e qualsiasi veicolo costruito su di esso, se ne va con lui. Eliminare il nodo toglie anche il suo corpo, quindi questa chiamata serve solo per tenere un nodo senza il suo corpo. Termina lo script quando il nodo non ha un corpo. *Disponibile da:* 3.00. *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Una moneta raccolta smette di essere solida e sale fluttuando come un semplice 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) ---- Quanto velocemente il corpo ruota attorno a ciascun asse del mondo, in radianti al secondo, all'ultimo passo. Un corpo statico riporta zero. Utile per l'altezza del suono di una ruota o per un contagiri. *Restituisce:* tre numeri, radianti al secondo attorno a X, Y e Z. *Disponibile da:* 3.00. *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- La ruota della roulette si è fermata quando gira appena. 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) ---- Quanto velocemente si muove il corpo lungo ciascun asse del mondo, in unità del mondo al secondo, all'ultimo passo. Un corpo statico riporta zero; un corpo cinematico riporta la velocità implicata dal movimento del suo nodo. *Restituisce:* tre numeri, unità al secondo lungo X, Y e Z. *Disponibile da:* 3.00. *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- Riproduce un sibilo quando la palla si muove davvero. 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) ---- Se la simulazione ha messo a dormire il corpo perché ha smesso di muoversi. I corpi statici e quelli tolti dal mondo con `bodySetEnabled` contano come a riposo. Un corpo a riposo si risveglia da solo quando qualcosa lo tocca o quando gli si applica una velocità, una forza o un impulso. *Restituisce:* booleano. *Disponibile da:* 3.00. *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- Il turno finisce quando tutti i birilli si sono assestati. 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) ---- Imposta la rotazione attorno a ciascun asse del mondo, in radianti al secondo, e risveglia il corpo. I corpi statici la ignorano. La rotazione di un corpo cinematico viene sovrascritta dal suo nodo al passo successivo, quindi la chiamata dura solo per i corpi dinamici. *Disponibile da:* 3.00. *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- Lancia la moneta: verso l'alto, facendola girare su se stessa. 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) ---- Imposta il coefficiente di restituzione del corpo, da `0` (si ferma di colpo) a `1` (rimbalza indietro con tutto lo slancio con cui è arrivato); i valori fuori da quell'intervallo vengono limitati. Il predefinito è `0.1`. I valori dei due corpi che si toccano si combinano, quindi una palla vivace su un pavimento sordo rimbalza comunque. *Disponibile da:* 3.00. *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- Una palla di gomma lasciata cadere nella scena. 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) ---- Come galleggia il corpo quando si trova dentro un volume d'acqua: `1` è neutro, di più galleggia più in alto, di meno affonda, e `0` è un sasso. Il predefinito è `1.2`, che galleggia dolcemente. Il fattore moltiplica la densità dell'acqua stessa data da `bodySetWater`, e i valori negativi vengono limitati a `0`. Solo i corpi dinamici vengono mossi dall'acqua, ma il valore può essere impostato su qualsiasi corpo. *Disponibile da:* 3.00. *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- Una cassa che galleggia alta, un'ancora che va a fondo, una palla che ondeggia. 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) ---- Imposta il flusso dentro un volume d'acqua, in unità del mondo al secondo lungo ciascun asse del mondo; i corpi dinamici che vi galleggiano derivano con il flusso, e un giocatore che nuota ne viene trascinato. Il predefinito è nessun flusso. Impostalo sul nodo su cui è stato chiamato `bodySetWater`; su qualsiasi altro corpo viene conservato e non fa nulla. *Disponibile da:* 3.00. *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- Un fiume che trascina a valle qualsiasi cosa ci cada dentro. 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) ---- Con `false` toglie il corpo dal mondo: smette di collidere, smette di muoversi, non guida né segue più il suo nodo, e conta come a riposo. `true` lo rimette dove si trova, sveglio. Il corpo conserva forma, massa e impostazioni, quindi per qualcosa che va e viene costa meno che eliminarlo e ricrearlo. *Disponibile da:* 3.00. *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- Il ponte levatoio è solido solo quando è abbassato. 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) ---- Imposta l'attrito della superficie: `0` è ghiaccio, `0.5` (il predefinito) è normale, valori più alti fanno più presa. I valori negativi vengono limitati a `0`. I valori dei due corpi che si toccano si combinano, quindi un pavimento scivoloso ha bisogno di un valore basso sul pavimento, non su tutto ciò che ci cammina sopra. *Disponibile da:* 3.00. *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- Uno stagno ghiacciato in mezzo al livello. 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) ---- Riscala la massa di un corpo dinamico e, con essa, la sua inerzia. Fallo per tutto ciò che deve sembrare leggero o pesante: il predefinito è il volume della forma alla densità dell'acqua, quindi un parallelepipedo grande come una porta pesa circa 140 chilogrammi e una spintarella non lo smuove. Forze e impulsi scalano con la massa, quindi imposta la massa prima di regolarli. Termina lo script quando il corpo non è dinamico o la massa non è positiva. *Disponibile da:* 3.00. *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Una cassa che l'eroe può spingere e un'incudine che nessuno può smuovere. 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) ---- Con `true` rende il corpo un trigger: non spinge niente e non ferma niente, ma `onTrigger` segnala ogni corpo o giocatore che vi entra e ne esce. `false` lo rende di nuovo solido: ciò che c'era dentro viene dimenticato senza un'uscita, mentre un trigger in cui si trova il corpo stesso continua a segnalarlo. Un trigger è di solito un parallelepipedo o una sfera statici collocati su una porta, un traguardo o una zona di pericolo. I raggi di `physicsRaycast` colpiscono i trigger come qualsiasi altro corpo. *Note:* Un giocatore viene segnalato dentro un trigger quando i loro riquadri di delimitazione si sovrappongono, quindi per i giocatori un trigger rotondo è un trigger quadrato. *Disponibile da:* 3.00. *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Un traguardo di traverso sulla 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) ---- Imposta la velocità del corpo lungo ciascun asse del mondo, in unità del mondo al secondo, e lo risveglia. Sostituisce qualsiasi velocità avesse il corpo, mentre `bodyApplyImpulse` vi si aggiunge. I corpi statici la ignorano, e la velocità di un corpo cinematico viene sovrascritta dal movimento del suo nodo al passo successivo. *Disponibile da:* 3.00. *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Serve la palla verso il lato che ha perso l'ultimo 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) ---- Riempie d'acqua un corpo statico e lo rende un trigger: la faccia superiore della sua forma è la superficie, e ogni corpo dinamico al suo interno galleggia, affonda o rolla per galleggiamento rispetto a quella superficie (conta la parte immersa della forma, quindi una cassa inclinata si raddrizza da sola), rallentato dalle resistenze. Un giocatore con il punto centrale sotto la superficie nuota. Poiché il volume è un trigger, `onTrigger` scatta all'entrata e all'uscita. L'acqua non viene disegnata: dai al nodo un materiale con fusione, o nessun materiale. Termina lo script quando il corpo non è statico. *Parametri:* * `density` -- un moltiplicatore, `1` per l'acqua; il `bodySetBuoyancy` di ciascun corpo lo scala. I valori negativi vengono limitati a `0`. * `linearDrag` -- quanto in fretta l'acqua rallenta il movimento al suo interno; predefinito `0.5`. * `angularDrag` -- quanto in fretta rallenta la rotazione; predefinito `0.1`. *Note:* I giocatori e le barche vedono al massimo otto volumi d'acqua; quelli in più fanno comunque galleggiare i corpi, ma non ci si può nuotare. *Disponibile da:* 3.00. *Vedi anche:* <>, <>, <>, <> .Esempio [source,lua] ---- -- Una piscina ricavata nel ponte; la sua faccia superiore, a Y = 0, è 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] === Telecamera La telecamera è un nodo come gli altri: posizionala con `nodeSetPosition`, puntala con `nodeLookAt`, rendila figlia di un oggetto in movimento per una visuale d'inseguimento, e designala con `cameraSet`. Guarda lungo il proprio -Z. Senza un nodo telecamera la scena è vista da `(0, 0, 5)` verso l'origine. La proiezione si imposta a parte e vale per qualunque nodo sia la telecamera: prospettica con un campo visivo verticale di 60 gradi da `0.1` a `1000` unità per impostazione predefinita, oppure ortografica con `cameraSetOrthographic`. Le viste renderizzate (`viewNew`) condividono la proiezione ma guardano dai propri nodi. Vedi <>. [#cameraset] ==== cameraSet [source,text] ---- cameraSet(node) cameraSet(-1) ---- Rende un nodo la telecamera: la scena viene disegnata dalla sua posizione nel mondo guardando lungo il suo -Z, quindi contano il movimento e la rotazione dei suoi genitori. Va bene qualsiasi nodo, anche uno che porta una mesh o una luce, e cambiare telecamera è una sola chiamata. `-1` ripristina la vista predefinita da `(0, 0, 5)` verso l'origine. Anche eliminare il nodo telecamera fa tornare alla vista predefinita. Termina lo script per un handle di nodo non valido. *Disponibile da:* 3.00. *Vedi anche:* <>, <>, <>, <> .Esempio [source,lua] ---- -- Una telecamera d'inseguimento dietro l'auto del giocatore, e una fissa per il replay. 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) ---- Passa a una proiezione parallela che mostra `height` unità del mondo dall'alto in basso, con la larghezza che segue le proporzioni del livello, da `near` a `far` davanti alla telecamera. La distanza non rimpicciolisce le cose, il che si addice a diagrammi, viste isometriche e di lato, e scene simili a un HUD. L'impostazione vale per il nodo telecamera corrente e per quelli successivi finché non viene chiamata `cameraSetPerspective`. L'ombra di una luce direzionale usa una sola mappa sotto una telecamera ortografica, qualunque cosa dica `sceneSetShadowCascades`. *Parametri:* * `height` -- l'altezza visibile, in unità del mondo. * `near` -- la distanza più vicina disegnata, in unità del mondo davanti alla telecamera. * `far` -- la distanza più lontana disegnata. *Disponibile da:* 3.00. *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Una vista isometrica della plancia: alta 12 unità, dall'alto e di lato. 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) ---- Passa alla consueta proiezione prospettica, quella predefinita. `fov` è il campo visivo verticale in gradi (predefinito `60`); `near` e `far` (predefiniti `0.1` e `1000`) delimitano ciò che viene disegnato, in unità del mondo davanti alla telecamera. La precisione della profondità si distribuisce tra i due, quindi tieni `near` grande quanto la scena consente e `far` non più grande del necessario: da `0.05` a `100` per una stanza, da `0.1` a `400` per un paesaggio. L'impostazione vale per il nodo telecamera corrente e per quelli successivi, e per le viste renderizzate. *Parametri:* * `fov` -- campo visivo verticale in gradi; un ampio `70` dà un senso di velocità, uno stretto `40` sembra un teleobiettivo. * `near` -- la distanza più vicina disegnata. * `far` -- la distanza più lontana disegnata. *Disponibile da:* 3.00. *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Un obiettivo più stretto per la vista dall'abitacolo, quello predefinito per l'esterno. function setView(inside) if inside then cameraSet(cockpitCamera) cameraSetPerspective(45, 0.05, 300) else cameraSet(chaseCamera) cameraSetPerspective(60, 0.1, 400) end end ---- [#collide] === Collisioni Se due cose piatte si toccano. Jolt risponde a questa domanda in tre dimensioni per i corpi (vedi <>); queste funzioni rispondono per l'overlay, che è dove vivono le hitbox di una pistola ottica e tutto quanto di un gioco 2D. Sono semplici funzioni: niente handle, niente stato, niente da creare o liberare e niente da aggiornare a ogni fotogramma. Le coordinate sono quelle in cui stai già disegnando -- di solito i pixel dell'overlay. Un rettangolo è un vertice e una dimensione, così come lo accetta ogni chiamata di disegno del motore. Toccarsi esattamente su un bordo conta come toccarsi, e un rettangolo senza larghezza o altezza non tocca proprio niente. Nessuna di queste funzioni sposta qualcosa. Quando vuoi sapere non solo *se* due cose si sono incontrate ma *dove va a finire ciò che si muove* -- scivolando lungo un muro invece di fermarsi di colpo contro di esso -- usa `require("bump")` e lascia fare a lui; è incluso proprio per questo. [#collidecircles] ==== collideCircles [source,text] ---- touching = collideCircles(x1, y1, radius1, x2, y2, radius2) ---- Se due cerchi si sovrappongono. Costa meno di quanto sembri: niente calcola una radice quadrata per rispondere sì o no. *Parametri:* * `x1`, `y1`, `radius1` -- numeri; il primo cerchio. * `x2`, `y2`, `radius2` -- numeri; il secondo. *Restituisce:* `true` o `false`. *Disponibile da:* 3.00. *Vedi anche:* <> [#collidepointcircle] ==== collidePointCircle [source,text] ---- inside = collidePointCircle(pointX, pointY, x, y, radius) ---- Se un punto cade dentro un cerchio. Il bordo conta come dentro. *Parametri:* * `pointX`, `pointY` -- numeri; il punto. * `x`, `y`, `radius` -- numeri; il cerchio. *Restituisce:* `true` o `false`. *Disponibile da:* 3.00. [#collidepointpolygon] ==== collidePointPolygon [source,text] ---- inside = collidePointPolygon(pointX, pointY, points) ---- Se un punto cade dentro un poligono di qualsiasi forma, compresi quelli concavi. La tabella è un elenco piatto di `x`, `y`, `x`, `y` -- la forma che una hitbox ha già nella maggior parte dei giochi -- e il verso in cui sono elencati i vertici non conta. Con meno di tre vertici non contiene nulla. Al massimo 256 vertici. *Parametri:* * `pointX`, `pointY` -- numeri; il punto. * `points` -- tabella; un numero pari di numeri, x e y alternati. *Restituisce:* `true` o `false`. *Disponibile da:* 3.00. .Esempio [source,lua] ---- -- Il colpo ha centrato il drago, il cui contorno non è un rettangolo? 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) ---- Se un punto cade dentro un rettangolo. Il bordo conta come dentro. *Parametri:* * `pointX`, `pointY` -- numeri; il punto. * `x`, `y`, `width`, `height` -- numeri; il rettangolo. *Restituisce:* `true` o `false`. *Disponibile da:* 3.00. .Esempio [source,lua] ---- -- Una hitbox grande quanto lo sprite a cui appartiene. 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) ---- Se un rettangolo e un cerchio si sovrappongono, trovando il punto del rettangolo più vicino al centro del cerchio. *Parametri:* * `x`, `y`, `width`, `height` -- numeri; il rettangolo. * `circleX`, `circleY`, `radius` -- numeri; il cerchio. *Restituisce:* `true` o `false`. *Disponibile da:* 3.00. [#colliderects] ==== collideRects [source,text] ---- touching = collideRects(x1, y1, width1, height1, x2, y2, width2, height2) ---- Se due rettangoli si sovrappongono. Quella che userai più spesso. *Parametri:* * `x1`, `y1`, `width1`, `height1` -- numeri; il primo rettangolo. * `x2`, `y2`, `width2`, `height2` -- numeri; il secondo. *Restituisce:* `true` o `false`. *Disponibile da:* 3.00. *Vedi anche:* <> [#collidesegments] ==== collideSegments [source,text] ---- crossing = collideSegments(ax1, ay1, ax2, ay2, bx1, by1, bx2, by2) ---- Se due segmenti si incrociano: un colpo lungo un percorso contro un muro, o il passo che ciò che si muove vuole fare contro il bordo che non deve superare. Segmenti che giacciono l'uno lungo l'altro rispondono `false`, perché allora «dove si incrociano» non ha un'unica risposta. *Parametri:* * `ax1`, `ay1`, `ax2`, `ay2` -- numeri; gli estremi del primo segmento. * `bx1`, `by1`, `bx2`, `by2` -- numeri; quelli del secondo. *Restituisce:* `true` o `false`. *Disponibile da:* 3.00. [#color] === Colore I colori di primo piano e di sfondo sono valori globali del motore e non argomenti di ogni chiamata: li imposti con `colorForeground` e `colorBackground`, e ogni chiamata di disegno successiva li legge finché non cambiano. Il colore di primo piano è usato dalle primitive di forma `overlay*` e dal testo TrueType di `fontPrint` e `fontToSprite`; il colore di sfondo è usato da `overlayClear` e dal testo `FONT_QUALITY_SHADED`. `overlayPrint` non usa né l'uno né l'altro. I canali sono interi da `0` a `255`, limitati a quell'intervallo, ed entrambi i colori accettano un alfa facoltativo. Vedi le sezioni <> e <> per le chiamate che li usano. [#colorbackground] ==== colorBackground [source,text] ---- colorBackground(r, g, b) colorBackground(r, g, b, a) ---- Imposta il colore di sfondo: quello con cui `overlayClear` riempie l'overlay, e quello che il testo `FONT_QUALITY_SHADED` di `fontPrint` e `fontToSprite` dipinge dietro i suoi glifi. All'avvio è nero trasparente, e l'alfa vale `0` quando viene omesso, quindi `colorBackground(0, 0, 0)` seguito da `overlayClear()` rende di nuovo invisibile l'overlay. Un alfa parziale sul nero attenua il video; `255` lo nasconde. I valori dei canali fuori intervallo vengono limitati, non rifiutati. *Parametri:* * `r`, `g`, `b` -- rosso, verde e blu, da `0` a `255`. * `a` -- opacità, da `0` (trasparente) a `255` (opaco); predefinito `0`. *Disponibile da:* 1.x *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Nero pieno dietro la schermata di calibrazione, trasparente durante il gioco. 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) ---- Imposta il colore di primo piano usato da `overlayBox`, `overlayCircle`, `overlayEllipse`, `overlayLine` e `overlayPlot`, e il colore del testo per `fontPrint` e `fontToSprite`. All'avvio è bianco opaco, e l'alfa vale `255` quando viene omesso, l'opposto di `colorBackground`. Gli sprite e `overlayPrint` sono disegnati con i propri pixel e non vengono tinti. I valori dei canali fuori intervallo vengono limitati, non rifiutati. *Parametri:* * `r`, `g`, `b` -- rosso, verde e blu, da `0` a `255`. * `a` -- opacità, da `0` (trasparente) a `255` (opaco); predefinito `255`. *Disponibile da:* 1.x *Vedi anche:* <>, <>, <>, <> .Esempio [source,lua] ---- -- Punteggio in bianco, avviso dell'ultima vita in rosso, entrambi con lo stesso font. 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] === Riga di comando Tre chiamate riportano numeri che l'utente ha scelto all'avvio di Singe. Esistono perché un gioco si possa regolare dal launcher invece che modificando il gioco. Tutte e tre sono estensioni di Hypseus Singe, e Singe da sé non fa nulla con i valori: è il gioco a decidere che cosa significano. Le opzioni sono descritte in xref:Manual.adoc#commandlineoptions[Opzioni da riga di comando], e si possono impostare anche in un file delle impostazioni (vedi xref:Manual.adoc#settings[Il file delle impostazioni]). Nessuna delle tre cambia durante un'esecuzione, quindi leggi ciascuna una volta sola e conservala. [#getfvalue] ==== getFValue [source,text] ---- value = getFValue() ---- Estensione di Hypseus. Restituisce il numero dato come `--fvalue`, o `0` quando l'opzione non è stata usata. È un canale di un solo numero da chi ha avviato Singe al gioco, pensato per un valore che un front-end vuole variare da un avvio all'altro -- una difficoltà, un numero di crediti iniziali, un indice di menu. Il motore si limita a trasportarlo. Il valore va da `0` a `100000` e conserva tre decimali, quanti ne conserva Hypseus, così un gioco che confronta il numero vede lo stesso che vedeva lì. *Restituisce:* numero. *Disponibile da:* 3.00 (Hypseus) *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- Il launcher passa --fvalue 2 per il cabinato difficile. local difficulty = getFValue() if difficulty == 0 then difficulty = 1 end ---- [#ratiogetx] ==== ratioGetX [source,text] ---- factor = ratioGetX() ---- Estensione di Hypseus. Restituisce il numero dato come `--xratio`, o `0` quando l'opzione non è stata usata. I giochi per pistola ottica scritti per Hypseus lo leggono e ci stirano le proprie coordinate della pistola, per un cabinato il cui schermo non ha la forma per cui il video è stato masterizzato -- `1.33` adatta un pannello 16:9 a un filmato 4:3. Singe non lo applica a niente; i conti li fa il gioco, esattamente come in Hypseus, ed è per questo che ogni gioco che lo legge tratta `0` e qualsiasi valore sotto `1` come `1`. Il valore va da `0` a `100` e conserva due decimali, come li conserva Hypseus. *Restituisce:* numero. *Disponibile da:* 3.00 (Hypseus) *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- La forma delle coordinate della pistola che vuole questo cabinato. local ratioX = ratioGetX() if ratioX <= 1 then ratioX = 1 end local offsetX = (ratioX - 1) * (overlayGetWidth() / 2) ---- [#ratiogety] ==== ratioGetY [source,text] ---- factor = ratioGetY() ---- Estensione di Hypseus. La metà verticale della coppia: il numero dato come `--yratio`, o `0` quando l'opzione non è stata usata. Vedi <>. *Restituisce:* numero. *Disponibile da:* 3.00 (Hypseus) *Vedi anche:* <>, <> .Esempio [source,lua] ---- local ratioY = ratioGetY() if ratioY <= 1 then ratioY = 1 end ---- [#controller] === Controller Singe gestisce fino a quattro controller, indici da `0` a `3`, aperti automaticamente all'avvio e ogni volta che se ne collega o se ne scollega uno; non esiste `controllerLoad`. Gli assi analogici vengono segnalati tramite `onControllerMoved` (vedi <>) e interrogati con `controllerGetAxis`, nell'intervallo grezzo di SDL da `-32768` a `32767`. I pulsanti digitali, e ciascuna direzione di ciascun asse, sono interruttori come i tasti: arrivano a `onInputPressed` e `onInputReleased` quando `controls.cfg` assegna un codice delle tabelle da `GAMEPAD_0` a `GAMEPAD_3` (per esempio `INPUT_ACTION_1 = { GAMEPAD_0.BUTTON_A }`), e `controllerGetButton` interroga lo stato attuale di un pulsante. `SINGE_DEAD_ZONE`, dalla voce `DEAD_ZONE` di `controls.cfg`, è la soglia che il motore stesso usa prima che la direzione di un asse conti come un interruttore premuto; considera rumore il movimento di un asse al di sotto di essa. A un asse alla volta si può dare una soglia diversa con `DEAD_ZONES`, e ai grilletti analogici una soglia tutta loro con `TRIGGER_THRESHOLD` (che si rilegge come `SINGE_TRIGGER_THRESHOLD`); vedi xref:Manual.adoc#deadzones[Zone morte e grilletti]. Un dispositivo che SDL non riconosce come controller viene aperto comunque come tale, tramite un'assegnazione che Singe scrive apposta, così un encoder arcade o una cloche compare in questi slot come qualsiasi altro controller -- vedi xref:Manual.adoc#devices[Ogni dispositivo è un controller]. [#controllerdorumble] ==== controllerDoRumble [source,text] ---- controllerDoRumble(strength, duration) controllerDoRumble(controller, strength, duration) ---- Estensione di Hypseus. Fa vibrare un controller con entrambi i motori a una di quattro intensità per una di quattro durate. La forma a due argomenti si rivolge al controller `0`. Uno slot senza controller non fa nulla (lì Hypseus si interrompe, quindi i suoi giochi si proteggono con `controllerIsValid`); un'intensità o una durata non compresa tra `1` e `4`, o un indice non compreso tra `0` e `3`, termina lo script. Un controller che non supporta la vibrazione ignora la chiamata. *Parametri:* * `controller` -- indice del controller, da `0` a `3`. * `strength` -- da `1` a `4`, da un quarto alla piena intensità. * `duration` -- da `1` a `4`, cioè 150, 300, 600 o 1200 ms. *Disponibile da:* 3.00, come estensione di Hypseus Singe. *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- Una breve scossa a ogni colpo, una lunga a piena intensità quando il giocatore muore. 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) ---- Restituisce la posizione di un asse analogico come il motore l'ha vista l'ultima volta. Il valore viene aggiornato da ogni evento dell'asse, compreso il movimento dentro la zona morta, ed è `0` per un controller non collegato o per un asse che non si è mai mosso. Le levette vanno da `-32768` a `32767`; i grilletti stanno a riposo su `0` e arrivano a `32767` quando sono premuti fino in fondo. Un indice non compreso tra `0` e `3`, o un asse non compreso tra `0` e `5`, termina lo script. *Parametri:* * `controller` -- indice del controller, da `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`). *Restituisce:* intero, da `-32768` a `32767`. *Disponibile da:* 2.00 *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- Sterza con la levetta sinistra, accelera con il grilletto destro. 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) ---- Restituisce se un pulsante è tenuto premuto in questo momento, letto direttamente dal controller anziché dalla coda degli eventi. Usalo per «tieni premuto per caricare» e per casi simili in cui `onInputPressed` e `onInputReleased` sono scomodi. Uno slot senza controller restituisce `false`. Il codice del pulsante deve venire dalla tabella `GAMEPAD_N` dello stesso indice di controller; un codice della tabella di un altro controller, o il codice di un asse, cade fuori dall'intervallo dei pulsanti e termina lo script, come un indice non compreso tra `0` e `3`. *Parametri:* * `controller` -- indice del controller, da `0` a `3`. * `button` -- un valore `BUTTON_*` o `DPAD_*` della tabella corrispondente, come `GAMEPAD_0.BUTTON_A.value`. *Restituisce:* booleano. *Disponibile da:* 2.10 *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Carica mentre A è premuto, spara al rilascio. 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) ---- Il nome del dispositivo in uno slot dei controller, come lo riporta SDL, o `nil` quando lo slot è vuoto. Un dispositivo che SDL non ha riconosciuto riporta il proprio nome tramite l'assegnazione che Singe ha scritto apposta, così una schermata di servizio può elencare per nome un encoder arcade con la stessa facilità di un controller di marca. Un indice non compreso tra `0` e `3` termina lo script. *Parametri:* * `controller` -- indice del controller, da `0` a `3`. *Restituisce:* stringa, o `nil`. *Disponibile da:* 3.00 *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- Una schermata di servizio che elenca ciò che è collegato. 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() ---- Estensione di Hypseus, sempre `0` in Singe. Hypseus aggiunge questo scostamento al codice di un interruttore arrivato da un controller, così un gioco può capire quale dispositivo ha premuto cosa e recuperare comunque l'interruttore sottraendolo. Singe consegna l'input dei controller come normali interruttori assegnati, senza alcun segno del genere, quindi risponde `0`: un gioco che controlla l'intervallo spostato prende l'altro ramo e legge l'interruttore così com'è. *Restituisce:* un intero, sempre `0`. *Disponibile da:* 3.00 *Vedi anche:* <> [#controllerhowmany] ==== controllerHowMany [source,text] ---- count = controllerHowMany() ---- Estensione di Hypseus. Quanti controller sono aperti, dei quattro slot che Singe ha. Dato che ogni joystick che Singe riesce ad aprire diventa un controller (vedi xref:Manual.adoc#devices[Ogni dispositivo è un controller]), anche un encoder arcade o uno spinner viene contato qui. Il conteggio cambia man mano che i dispositivi vengono collegati e scollegati, quindi leggilo quando ti serve invece che una volta sola all'avvio. Non dice nulla su quali slot siano occupati: un dispositivo può lasciare vuoto lo slot 0 mentre lo slot 1 è in uso, quindi <> resta il modo per chiedere di uno slot in particolare. *Parametri:* nessuno. *Restituisce:* numero, da `0` a `4`. *Disponibile da:* 3.00, come estensione di Hypseus Singe. *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Offri la partita a due giocatori solo quando il giocatore due ha qualcosa da impugnare. local twoPlayer = controllerHowMany() >= 2 ---- [#controllerisvalid] ==== controllerIsValid [source,text] ---- valid = controllerIsValid(controller) ---- Estensione di Hypseus. Restituisce se in questo momento c'è un controller aperto nello slot; i controller vanno e vengono con il collegamento a caldo, quindi chiedi prima di ogni `controllerDoRumble`. Un indice non compreso tra `0` e `3` termina lo script. *Parametri:* * `controller` -- indice del controller, da `0` a `3`. *Restituisce:* booleano. *Disponibile da:* 3.00, come estensione di Hypseus Singe. *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- Mostra i suggerimenti per il controller solo quando c'è un controller a cui riferirli. 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) ---- Estensione di Hypseus, accettata e ignorata. Vedi <> per sapere perché lo scostamento che richiede non esiste in Singe. Attivarlo senza marcare gli eventi sarebbe peggio che lasciarlo spento, perché il gioco sottrarrebbe allora uno scostamento da codici di interruttore che non ne hanno mai avuto uno. *Restituisce:* niente. *Disponibile da:* 3.00 *Vedi anche:* <> [#debug] === Debug Due chiamate per vedere che cosa sta facendo uno script: `debugPrint` scrive del testo, e `lineDraw` disegna una linea sopra la scena 3D per un fotogramma (`physicsSetDebug` e `navDraw` disegnano tramite le stesse linee). L'output stampato va alla console da cui è stato avviato Singe, quindi è visibile durante lo sviluppo e innocuo in un cabinato, dove `--noconsole` lo zittisce. Quando Singe gira con `--trace`, ogni chiamata dell'API, compresa questa, viene anche scritta con i suoi argomenti in `trace.txt` nella cartella dei dati, ed è questo il modo di averne una registrazione permanente. Le opzioni da riga di comando sono elencate nel capitolo sull'avvio di Singe. Quando stampare non basta, <> ferma il gioco a un breakpoint, mostra che cosa c'è nelle sue variabili e lo fa avanzare una riga alla volta. [[traceheader]] ==== L'intestazione della traccia Qualunque fra `--program` e `--trace` l'abbia aperto, `trace.txt` comincia con un blocco che descrive la macchina e l'esecuzione, racchiuso tra due righe orizzontali, così da poterlo incollare intero in una segnalazione di bug: ---- ---------------------------------------------------------------- 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` è il renderer effettivamente creato e il backend GPU che ha scelto, oppure una nota che dice che non c'è un dispositivo GPU e che il 3D non è disponibile. `Decoder` è ciò che verrà tentato per il video su questa macchina; quale decoder abbia effettivamente ottenuto un certo video viene tracciato all'apertura di quel video, qualche riga più in basso, dato che un codec può non offrire alcuna via hardware. `Settings` elenca ciò che ha fornito il file delle impostazioni, `none` quando non ce n'era. Ogni gioco di una coda ha il proprio file e la propria intestazione. [#debugprint] ==== debugPrint [source,text] ---- debugPrint(message) ---- Stampa una riga sulla console (standard output). Non viene stampato nulla quando Singe è stato avviato con `--noconsole`; con `--trace` la chiamata e il suo messaggio finiscono anche in `trace.txt`. L'argomento deve essere una stringa o un numero; qualsiasi altra cosa termina lo script, quindi racchiudi i valori in `tostring` o usa `string.format`. Costa abbastanza poco da poterla lasciare nel codice che distribuisci, ma non è limitata, quindi una stampa dentro `onOverlayUpdate` viene eseguita a ogni fotogramma. *Parametri:* * `message` -- il testo da stampare; un numero viene convertito. *Disponibile da:* 1.x *Vedi anche:* <> .Esempio [source,lua] ---- -- Registra perché il colpo è andato a vuoto, con il fotogramma in cui è successo. 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) ---- Disegna una linea retta tra due punti nello spazio del mondo, sopra la scena, solo per il fotogramma corrente, bianca a meno che non si indichi un colore. Chiamala a ogni fotogramma per tenere una linea sullo schermo, come con l'overlay. La linea è sottoposta al test di profondità rispetto alla scena ma non scrive la profondità, quindi si nasconde dietro i muri come qualsiasi altra cosa e non copre mai niente. Non viene disegnato nulla mentre la scena è disattivata. Serve a vedere che cosa sta facendo uno script (un raycast, un percorso, un bounding box); `physicsSetDebug` e `navDraw` disegnano tramite le stesse linee. *Parametri:* * `x0, y0, z0` -- il punto di partenza, in unità del mondo. * `x1, y1, z1` -- il punto di arrivo, in unità del mondo. * `r, g, b` -- il colore della linea, da `0` a `255` per canale, limitato a quell'intervallo; bianco quando omesso. *Disponibile da:* 3.00. *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Mostra dove mira la torretta e se il colpo andrebbe a segno. 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 Il *disco* è il video laserdisc principale, quello che Singe era nato per emulare. Un gioco ha al massimo un disco, aperto all'avvio dal `VIDEO` indicato in `games.dat` o da `--disc` / `--framefile` sulla riga di comando; un gioco che non ne indica nessuno gira senza disco (vedi <>). Tutte le funzioni `disc*` agiscono su questo unico disco implicito e non accettano un handle. I numeri dei fotogrammi sono posizioni a base zero nel file video (o nel framefile combinato) e sono il riferimento temporale di ogni decisione di diramazione in un gioco laserdisc. Senza disco le funzioni di interrogazione restituiscono `0`, `discGetState` riporta `DISC_EJECTED`, e le funzioni di trasporto scrivono una riga nella traccia e non fanno nulla. Le clip in più sovrapposte al disco sono compito della famiglia <>. [#discaudio] ==== discAudio [source,text] ---- discAudio(channel, enabled) ---- Accende o spegne un canale della colonna sonora del disco. Il canale viene portato al volume laserdisc configurato (`--volume_vldp`, predefinito `100`) quando `enabled` è `true` e a `0` quando è `false`; l'altro canale resta com'è. Molti titoli originali avevano la narrazione o una traccia dati su un solo canale, e questa è la chiamata che la zittisce. Un canale diverso da `1` o `2` termina lo script. Senza disco la chiamata non fa nulla. *Parametri:* * `channel` -- `1` per il canale sinistro, `2` per il destro. * `enabled` -- booleano. `true` riporta il canale al volume laserdisc configurato, `false` lo zittisce. *Disponibile da:* 1.x *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Il canale destro di questo disco porta la traccia dati della macchina arcade. function startGame() discAudio(1, true) discAudio(2, false) discSkipToFrame(SCENE_FIRST_FRAME) end ---- [#discaudiosuffix] ==== discAudioSuffix [source,text] ---- opened = discAudioSuffix(suffix) ---- Estensione di Hypseus. Passa il disco a un file audio separato che sta accanto al suo video: `.ogg`, dove la base è il nome del file video senza estensione, così `movie.m2v` con `"-es"` riproduce `movie-es.ogg`. Il file viene aperto al fotogramma corrente e la riproduzione si riallinea, come fa `discSetAudioTrack`, mantenendo il volume, lo stato di riproduzione e l'indice della traccia quando il nuovo file ha abbastanza tracce. Con un framefile cambiano tutti i segmenti. Un suffisso vuoto ripristina l'audio predefinito: il `.ogg` accanto a un `.m2v`, o l'audio dentro qualsiasi altro contenitore. Restituisce `true` quando il file si è aperto; quando manca, l'audio precedente continua a suonare, non viene memorizzato nulla e torna `false` (in quel caso Hypseus lascia il silenzio). Il suffisso viene dimenticato quando lo script si ricarica. Senza disco la chiamata restituisce `false`. Usala per un menu delle lingue le cui tracce vengono distribuite come file separati; le tracce dentro un unico file sono compito di `discSetAudioTrack`. *Parametri:* * `suffix` -- stringa aggiunta al nome base del video, come `"-es"`; `""` per l'audio predefinito. *Restituisce:* booleano. *Disponibile da:* 3.00, come estensione di Hypseus Singe. *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- I file delle lingue vengono distribuiti come movie.ogg, movie-es.ogg e 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(...) ---- Non fa nulla. I veri lettori laserdisc potevano andare a velocità diverse da 1x; il lettore software di Singe no, quindi la chiamata accetta qualsiasi argomento, scrive una riga nella traccia e ritorna. È mantenuta perché gli script anteriori alla 2.00 si carichino senza modifiche. *Note:* WARNING: *Non implementata.* Mantenuta solo per compatibilità con le versioni precedenti. Non c'è un sostituto; la velocità di riproduzione è fissata alla frequenza dei fotogrammi del file video. *Disponibile da:* 1.x *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- I vecchi script a volte racchiudevano un replay al rallentatore tra due cambi di velocità. -- Oggi le chiamate sono innocue, ma il replay va a velocità normale. discChangeSpeed(0.5) discSkipToFrame(REPLAY_FIRST_FRAME) discChangeSpeed(1) ---- [#discgetaudiotrack] ==== discGetAudioTrack [source,text] ---- track = discGetAudioTrack() ---- Restituisce l'indice a base zero della traccia audio che il disco sta riproducendo. All'avvio il motore seleziona la traccia indicata da `--audio` o dalla voce `AUDIO_TRACK` di `games.dat` (predefinita `0`) quando il disco ha abbastanza tracce. Restituisce `0` senza disco. *Restituisce:* indice intero della traccia. *Disponibile da:* 2.10 *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Passa al doppiaggio successivo ogni volta che il giocatore preme il tasto della lingua. 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() ---- Restituisce quanti flussi audio contiene il file video del disco. Più flussi sono di solito doppiaggi in lingue diverse. Un video muto riporta `0`, e così un gioco senza disco, quindi proteggi i cicli e l'aritmetica in modulo dallo zero. *Restituisce:* conteggio intero, `0` o più. *Disponibile da:* 2.10 *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Costruisci un menu di scelta della lingua dalle tracce 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) ---- La lingua con cui è etichettata una traccia di sottotitoli dentro il contenitore stesso del disco, come il codice ISO 639 che riporta il file (`eng`, `fra`, `spa`). Una traccia che il file non etichetta risponde con una stringa vuota, che non è un errore -- moltissimi file non etichettano niente. *Parametri:* * `track` -- numero; da `0` a `discGetSubtitleTracks() - 1`. *Restituisce:* una stringa. *Disponibile da:* 3.00. *Vedi anche:* <>, <> [#discgetsubtitletracks] ==== discGetSubtitleTracks [source,text] ---- count = discGetSubtitleTracks() ---- Quante tracce di sottotitoli contiene il contenitore stesso del disco. `0` senza disco, e `0` per un disco i cui sottotitoli vengono distribuiti come `.srt` separato -- che è la disposizione abituale, ed è a questo che serve <>. Le tracce che contengono immagini anziché parole (quelle dei DVD e dei Blu-ray, VobSub e PGS) vengono contate qui perché la numerazione corrisponda a quella che mostra qualsiasi altro lettore, ma <> non può leggerle: non c'è niente da leggere, solo bitmap delle parole. *Parametri:* * Nessuno. *Restituisce:* un numero. *Disponibile da:* 3.00. *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- Offri tutte le lingue che il disco stesso contiene. for track = 0, discGetSubtitleTracks() - 1 do languages[#languages + 1] = { track = track, code = discGetSubtitleLanguage(track) } end ---- [#discgetframe] ==== discGetFrame [source,text] ---- frame = discGetFrame() ---- Restituisce il fotogramma che il disco sta mostrando. Con un framefile il numero è la posizione nella sequenza combinata, quindi i confini tra i segmenti sono invisibili allo script. Restituisce `0` mentre il disco è fermo e senza disco. Interrogala da `onOverlayUpdate` per reagire quando il video raggiunge un punto di decisione. *Restituisce:* numero intero del fotogramma. *Disponibile da:* 1.x *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Ripeti in ciclo la sequenza attract e mostra un'indicazione nella finestra in cui abbassarsi. 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() ---- Il numero di fotogrammi sul disco, che è uno in più del fotogramma più alto che <> troverà. Un framefile risponde per l'intero disco anziché per il segmento in riproduzione, quindi il conteggio non cambia man mano che i segmenti vanno e vengono. Senza disco risponde `0`. *Restituisce:* numero intero di fotogrammi, `0` senza disco. *Disponibile da:* 3.00. *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- Salta a metà del disco, ovunque si trovi. discSearch(math.floor(discGetFrameCount() / 2)) ---- [#discgetheight] ==== discGetHeight [source,text] ---- height = discGetHeight() ---- Restituisce l'altezza del video del disco in pixel. Senza disco restituisce l'altezza del canvas (`CANVAS_Y` in `games.dat` o `--canvas`, predefinita `480`), quindi è sempre la dimensione dello sfondo su cui l'overlay viene stirato. Abbinala a `discGetWidth` per dare all'overlay la stessa risoluzione del video. *Restituisce:* altezza intera in pixel. *Disponibile da:* 2.00 *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Disegna alla risoluzione nativa del disco, così i controlli sui pixel e il disegno condividono una sola griglia. overlaySetResolution(discGetWidth(), discGetHeight()) crosshairLimitX = discGetWidth() - 1 crosshairLimitY = discGetHeight() - 1 ---- [#discgetlanguage] ==== discGetLanguage [source,text] ---- code = discGetLanguage(track) ---- Restituisce l'etichetta della lingua memorizzata nel file video per una delle tracce audio del disco. L'etichetta è un codice ISO 639 di tre lettere come `"eng"` o `"jpn"`. Le tracce senza un'etichetta di tre lettere restituiscono `"unk"`, e così ogni traccia quando non c'è un disco. Con un disco, una traccia non compresa tra `0` e `discGetAudioTracks() - 1` termina lo script. *Restituisce:* stringa con il codice della lingua. *Disponibile da:* 2.10 *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Preferisci il doppiaggio giapponese quando il disco ne contiene uno. for track = 0, discGetAudioTracks() - 1 do if discGetLanguage(track) == "jpn" then discSetAudioTrack(track) break end end ---- [#discgetstate] ==== discGetState [source,text] ---- state = discGetState() ---- Restituisce lo stato di trasporto del disco come una delle costanti `DISC_*`. Un gioco senza disco riporta `DISC_EJECTED`; dopo `discStop` il disco riporta `DISC_STOPPED` fino al successivo `discPlay`, `discSearch` o `discSkipToFrame`; altrimenti lo stato è `DISC_PLAYING` mentre il video avanza e `DISC_PAUSED` quando non avanza. Non esiste uno stato di ricerca a parte; un disco che sta eseguendo un salto riporta `DISC_PAUSED` o `DISC_PLAYING` a seconda di ciò che è stato richiesto. *Restituisce:* una delle costanti seguenti, definite dal motore e disponibili a ogni script. [cols="1,1,1",options="header"] |=== | Costante | Valore | Significato | `DISC_STOPPED` | `2` | Fermo (è stato chiamato `discStop`) | `DISC_PLAYING` | `3` | In riproduzione | `DISC_PAUSED` | `4` | In pausa | `DISC_EJECTED` | `5` | Il gioco non ha un disco (vedi <>) |=== *Disponibile da:* 1.x (RDG) *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Il tasto di pausa non deve far ripartire un fermo immagine chiesto dallo script della storia. 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() ---- Restituisce la larghezza del video del disco in pixel. Senza disco restituisce la larghezza del canvas (`CANVAS_X` in `games.dat` o `--canvas`, predefinita `720`). Insieme a `discGetHeight` questa è la dimensione su cui viene scalato l'overlay, ed è per questo che il menu fornito con Singe le passa entrambe a `overlaySetResolution`. *Restituisce:* larghezza intera in pixel. *Disponibile da:* 2.00 *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Centra lo sprite del titolo su qualunque sia la larghezza del disco. overlaySetResolution(discGetWidth(), discGetHeight()) titleSprite = spriteLoad("graphics/title.png") titleX = (discGetWidth() - spriteGetWidth(titleSprite)) / 2 ---- [#discpause] ==== discPause [source,text] ---- discPause() ---- Mette in pausa la riproduzione sul fotogramma corrente, che resta sullo schermo. L'audio del disco va in pausa insieme al video. La chiamata viene ignorata mentre il disco è fermo e non fa nulla senza disco. Riprendi con `discPlay`. *Disponibile da:* 1.x *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Tieni l'ultimo fotogramma della scena mentre il giocatore sceglie una porta. 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 mostra e si mette in pausa. È la stessa funzione di `discSearch` sotto un secondo nome; le due sono intercambiabili sotto ogni aspetto, comprese le regole di limitazione descritte lì. Preferisci `discSearch` nel codice nuovo e tieni `discPauseAtFrame` solo quando porti vecchi script. *Note:* WARNING: *Alias storico.* Usa <> nel codice nuovo. *Disponibile da:* 1.18 *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- Codice 1.x portato: fermati sul primo fotogramma della scena finché il giocatore non è pronto. function onInputPressed(what) if what == SWITCH_START1 then discPauseAtFrame(SCENE_FIRST_FRAME) waitingForPlayer = true end end ---- [#discplay] ==== discPlay [source,text] ---- discPlay() ---- Avvia o riprende la riproduzione dal fotogramma corrente e annulla lo stato di fermo impostato da `discStop`. L'audio riprende con il video, e l'orologio della riproduzione riparte dal fotogramma corrente, così una lunga pausa non provoca uno scatto in avanti. Senza disco non fa nulla, a parte annullare l'indicatore di fermo. *Disponibile da:* 1.x *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Avvio standard della modalità attract: porta il disco sul primo fotogramma, poi via. function attractBegin() discSearch(ATTRACT_FIRST_FRAME) overlayClear() discPlay() attractRunning = true end ---- [#discsearch] ==== discSearch [source,text] ---- discSearch(frame) ---- Salta al fotogramma `frame`, lo mostra e si mette in pausa su di esso, annullando lo stato di fermo se il disco era fermo. Il disco tiene il fotogramma fino a `discPlay`, `discSkipToFrame` o a un'altra chiamata di trasporto. Un fotogramma negativo viene limitato al primo fotogramma e uno oltre la fine all'ultimo; con un framefile viene selezionato il segmento che contiene il fotogramma e la posizione viene limitata al suo interno. Senza disco non fa nulla. *Parametri:* * `frame` -- numero del fotogramma di destinazione, a base zero. *Disponibile da:* 1.x *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Il giocatore ha scelto il corridoio di sinistra: prepara la scena e aspetta il via. function goLeft() discSearch(CORRIDOR_LEFT_FIRST_FRAME) soundPlay(SOUND_DOOR) waitingForPlayer = true end ---- [#discsearchblanking] ==== discSearchBlanking [source,text] ---- discSearchBlanking(...) ---- Non fa nulla. Sui lettori laserdisc sceglieva se lo schermo si oscurasse mentre il lettore cercava un fotogramma; il lettore software di Singe salta senza oscurare, quindi la chiamata accetta qualsiasi argomento, scrive una riga nella traccia e ritorna. *Note:* WARNING: *Non implementata.* Mantenuta solo per compatibilità con le versioni precedenti. Non c'è un sostituto. *Disponibile da:* 1.x *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- Gli script anteriori alla 2.00 spesso impostavano le modalità di oscuramento una volta all'avvio. Innocuo. discSearchBlanking(false) discSkipBlanking(false) discSearch(TITLE_FRAME) ---- [#discsetaudiotrack] ==== discSetAudioTrack [source,text] ---- discSetAudioTrack(track) ---- Passa il disco a un'altra traccia audio. L'audio in coda della vecchia traccia viene scartato e la riproduzione si riallinea, così il cambio si sente quasi subito. Con un disco, una traccia non compresa tra `0` e `discGetAudioTracks() - 1` termina lo script; senza disco la chiamata non fa nulla. Usala per realizzare un menu delle lingue o per rispettare una preferenza salvata tra una sessione e l'altra. *Parametri:* * `track` -- indice della traccia audio, a base zero. *Disponibile da:* 2.10 *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Applica la lingua che il giocatore ha scelto nella schermata delle opzioni. function applyLanguage(choice) if choice >= 0 and choice < discGetAudioTracks() then discSetAudioTrack(choice) settings.language = choice saveSettings() end end ---- [#discsetfps] ==== discSetFPS [source,text] ---- discSetFPS(...) ---- Non fa nulla. La frequenza dei fotogrammi viene dal file video, quindi la chiamata accetta qualsiasi argomento, scrive una riga nella traccia e ritorna. Niente nel `Framework.singe` distribuito la chiama; lo fanno solo gli script anteriori alla 2.00. *Note:* WARNING: *Non implementata.* Mantenuta solo per compatibilità con le versioni precedenti. La frequenza dei fotogrammi viene letta dal file video e non si può impostare da uno script. *Disponibile da:* 1.x *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- Uno script 1.x portato. La chiamata viene ignorata; i fotogrammi al secondo sono una proprietà del file. discSetFPS(29.97) discSearch(TITLE_FRAME) discPlay() ---- [#discskipbackward] ==== discSkipBackward [source,text] ---- discSkipBackward(frames) ---- Salta indietro di `frames` fotogrammi dal fotogramma corrente senza cambiare lo stato di riproduzione o di pausa: un disco in riproduzione continua a riprodurre dalla nuova posizione, un disco in pausa resta in pausa lì. La chiamata viene ignorata mentre il disco è fermo e senza disco. Il delta non viene convalidato; un valore negativo salta in avanti, e un delta maggiore del fotogramma corrente viene limitato al fotogramma `0`. Con un framefile il delta si applica al numero di fotogramma che riporta `discGetFrame`, quindi un salto può passare in un segmento precedente. *Parametri:* * `frames` -- numero di fotogrammi da sottrarre al fotogramma corrente. *Disponibile da:* 1.x *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Ripeti l'ultimo secondo (30 fotogrammi) quando il giocatore manca un'indicazione. function missedPrompt() lives = lives - 1 discSkipBackward(30) promptShown = false end ---- [#discskipblanking] ==== discSkipBlanking [source,text] ---- discSkipBlanking(...) ---- Non fa nulla. Un tempo sceglieva se lo schermo si oscurasse durante un salto sui lettori laserdisc; il lettore software di Singe non oscura mai, quindi la chiamata accetta qualsiasi argomento, scrive una riga nella traccia e ritorna. *Note:* WARNING: *Non implementata.* Mantenuta solo per compatibilità con le versioni precedenti. Non c'è un sostituto. *Disponibile da:* 1.x *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- Gli script anteriori alla 2.00 spesso impostavano le modalità di oscuramento una volta all'avvio. Innocuo. discSearchBlanking(false) discSkipBlanking(false) discSearch(TITLE_FRAME) ---- [#discskipforward] ==== discSkipForward [source,text] ---- discSkipForward(frames) ---- Salta in avanti di `frames` fotogrammi dal fotogramma corrente senza cambiare lo stato di riproduzione o di pausa. Speculare a `discSkipBackward`: viene ignorata mentre il disco è fermo e senza disco, il delta non viene convalidato, e una destinazione oltre l'ultimo fotogramma viene limitata all'ultimo fotogramma. Con un framefile il delta si applica al numero di fotogramma che riporta `discGetFrame`, quindi un salto può passare in un segmento successivo. *Parametri:* * `frames` -- numero di fotogrammi da aggiungere al fotogramma corrente. *Disponibile da:* 1.x *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Lascia che chi ha già giocato salti l'introduzione di due secondi della scena. 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` e inizia a riprodurre da lì qualunque sia lo stato del disco, annullando lo stato di fermo se necessario. Al contrario, `discSearch` salta e si mette in pausa. Un fotogramma prima dell'inizio o oltre la fine viene limitato al primo o all'ultimo fotogramma; con un framefile viene selezionato il segmento che contiene il fotogramma e la posizione viene limitata al suo interno. Senza disco non fa nulla, a parte annullare l'indicatore di fermo. *Parametri:* * `frame` -- numero del fotogramma di destinazione, a base zero. *Disponibile da:* 1.x *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Salta l'introduzione quando si gioca di nuovo. function startGame() if hasPlayedBefore then discSkipToFrame(GAMEPLAY_FIRST_FRAME) else discSkipToFrame(INTRO_FIRST_FRAME) end end ---- [#discstepbackward] ==== discStepBackward [source,text] ---- discStepBackward() ---- Porta il disco indietro di esattamente un fotogramma e si mette in pausa su di esso, qualunque fosse lo stato di riproduzione precedente. Un passo indietro dal fotogramma `0` resta sul fotogramma `0`. Viene ignorata mentre il disco è fermo, come `discSkipBackward`. Pensata per il debug al fotogramma esatto e per le schermate di servizio. Senza disco non fa nulla. *Disponibile da:* 1.x *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Schermata di servizio: sinistra e destra spostano il disco di un fotogramma alla volta. function onInputPressed(what) if what == SWITCH_LEFT then discStepBackward() elseif what == SWITCH_RIGHT then discStepForward() end end ---- [#discstepforward] ==== discStepForward [source,text] ---- discStepForward() ---- Porta il disco avanti di esattamente un fotogramma e si mette in pausa su di esso, qualunque fosse lo stato di riproduzione precedente. Un passo avanti dall'ultimo fotogramma resta sull'ultimo fotogramma. Come `discStepBackward`, viene ignorata mentre il disco è fermo. Senza disco non fa nulla. *Disponibile da:* 1.x *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Cercafotogrammi: scorri una scena un fotogramma alla volta e stampa il numero del fotogramma. function onOverlayUpdate() overlayClear() overlayPrint(8, 8, "Frame " .. discGetFrame()) if stepping then discStepForward() end return OVERLAY_UPDATED end ---- [#discstop] ==== discStop [source,text] ---- discStop() ---- Ferma il disco. La riproduzione va in pausa, il video viene sostituito dalla classica schermata blu, `discGetState` riporta `DISC_STOPPED` e `discGetFrame` restituisce `0` finché il disco non viene riavviato. Mentre il disco è fermo, `discPause`, `discSkipForward`, `discSkipBackward`, `discStepForward` e `discStepBackward` vengono ignorate. Una qualsiasi fra `discPlay`, `discSearch` e `discSkipToFrame` annulla lo stato di fermo. Un secondo `discStop` viene ignorato. *Disponibile da:* 1.x *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Game over: passa alla schermata blu dietro la tabella dei punteggi. function gameOver() discStop() soundPlay(SOUND_GAME_OVER) showScores = true end ---- [#emitter] === Emettitore Gli emettitori generano, muovono e disegnano particelle; vedi <>. Gli handle sono interi. Un emettitore 2D vive nelle coordinate dell'overlay, con la Y verso il basso, e disegna quando `emitterDraw` lo chiede da `onOverlayUpdate`; un emettitore 3D sta su un nodo, vive in unità del mondo e si disegna da sé nella scena. Le chiamate della ricetta si possono fare in qualsiasi ordine, prima o dopo che l'emettitore parta, e hanno effetto sulle particelle che nascono da quel momento (alcune, indicate sotto, agiscono anche su quelle vive). Tutto ciò che è dato come minimo e massimo viene estratto a caso tra i due per ogni particella. Le particelle avanzano con l'orologio, al massimo di un decimo di secondo per fotogramma, e restano ferme mentre il gioco è in pausa. [#emitternew] ==== emitterNew [source,text] ---- emitter = emitterNew() emitter = emitterNew(node) ---- Crea un emettitore. Senza argomenti è un emettitore 2D nella posizione dell'overlay `(0, 0)`, che si sposta con `emitterSetPosition`; con un nodo è un emettitore 3D le cui particelle nascono nella posizione nel mondo di quel nodo, e ha bisogno della scena 3D, quindi su una macchina che non ce l'ha la chiamata termina lo script (gli emettitori 2D funzionano ovunque). Un nuovo emettitore non emette un flusso; chiama `emitterStart` o `emitterBurst`. Eliminare il nodo elimina i suoi emettitori. Valori predefiniti: 50 particelle al secondo, che vivono da uno a due secondi, apertura di 30 gradi, bianco che sfuma nel trasparente, fusione normale, niente gravità, resistenza, rotazione, raggio, texture, scia o collisione, una riserva di 1000; un emettitore 2D le lancia verso l'alto dello schermo a 50-100 unità dell'overlay al secondo, rimpicciolendole da 16 a 4, uno 3D verso l'alto lungo l'asse Y a 1-2 unità del mondo al secondo, rimpicciolendole da 0,2 a 0,05. *Restituisce:* L'handle dell'emettitore. *Disponibile da:* 3.00. *Vedi anche:* <>, <>, <>, <> .Esempio [source,lua] ---- -- Gas di scarico dal nodo del motore della nave; la ricetta si imposta prima che parta il flusso. 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) ---- Rimuove all'istante l'emettitore, le sue particelle vive e la sua copia della texture; niente si dissolve. Usa invece `emitterStop` per lasciare che le ultime particelle finiscano. Un emettitore 3D viene rimosso da sé anche quando viene eliminato il suo nodo. Un handle che non è un emettitore vivo termina lo script, quindi dopo azzera la variabile. *Disponibile da:* 3.00. *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- L'esplosione è finita: libera il suo emettitore quando l'ultima scintilla si è spenta. 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) ---- L'immagine che porta ogni particella: uno sprite caricato, o nessuna (un solo argomento, o `nil`) per il disco sfumato incorporato. L'emettitore tiene una propria copia dei pixel dello sprite, compreso ogni fotogramma di uno sprite animato, quindi dopo lo sprite si può scaricare. Impostare la texture riporta l'intervallo di fotogrammi al fotogramma `0` e sposta ogni particella viva al fotogramma `0`, quindi chiama `emitterSetFrames` dopo, non prima. La fusione e la tinta si applicano all'immagine come al disco. *Disponibile da:* 3.00. *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Neve: un foglio con sei forme di fiocco, una scelta a caso per ogni particella. 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 uno sprite animato come texture, ogni nuova particella prende un fotogramma a caso da `first` a `last` e lo tiene per tutta la vita; i fotogrammi si contano da `0`. I valori vengono limitati ai fotogrammi che ha la texture, e `last` viene alzato a `first` quando è più piccolo. Predefinito: solo il fotogramma `0`, che è anche ciò a cui riporta `emitterSetTexture`. Con il disco incorporato o con uno sprite di un solo fotogramma l'intervallo non ha effetto. *Disponibile da:* 3.00. *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- Il foglio degli sbuffi ha quattro sbuffi morbidi, poi quattro frammenti di detriti più duri. emitterSetTexture(smoke, puffSheet) emitterSetFrames(smoke, 0, 3) emitterSetTexture(debris, puffSheet) emitterSetFrames(debris, 4, 7) ---- [#emittersetblend] ==== emitterSetBlend [source,text] ---- emitterSetBlend(emitter, mode) ---- Come le particelle si combinano con ciò che sta dietro di loro. `PARTICLE_ALPHA` (predefinito) fonde normalmente, per fumo, polvere, pioggia e detriti; `PARTICLE_ADD` aggiunge luce, per fuoco, scintille, bagliori e magia, e non scurisce mai. Ha effetto subito sulle particelle vive. Qualsiasi cosa diversa da queste due costanti termina lo script. *Disponibile da:* 3.00. *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- Le scintille brillano; il fumo sopra di loro 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: dove `emitterDraw` mette le particelle. `PARTICLE_OVER` (predefinito) disegna sopra tutto ciò che c'è sull'overlay; `PARTICLE_UNDER` disegna sotto l'overlay e sopra il video e la scena 3D, per il fumo dietro il punteggio. Su un emettitore 3D l'impostazione viene accettata e ignorata. Qualsiasi cosa diversa da queste due costanti termina lo script. *Disponibile da:* 3.00. *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- La pioggia cade dietro l'HUD, le scintille dei colpi davanti. 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) ---- Quante particelle al secondo crea un emettitore avviato; predefinito `50`, i valori negativi diventano `0`. Le frazioni si accumulano, quindi `0.5` ne crea una ogni due secondi. Cambialo durante il flusso per regolare una fiamma o un propulsore. Una riserva piena (`emitterSetMax`) non ne crea altre finché alcune non muoiono, qualunque sia la frequenza. *Disponibile da:* 3.00. *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Il pennacchio del propulsore segue l'acceleratore. 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) ---- Quanto vive ogni nuova particella, estratto a caso tra i due valori; predefinito da `1` a `2`. La sua dimensione e il suo colore vanno dal valore iniziale a quello finale in quel tempo, quindi la vita detta il ritmo di ogni dissolvenza. Il minimo viene tenuto a `0.001` o più e il massimo viene alzato al minimo quando è più piccolo. Le particelle vive mantengono la vita con cui sono nate. *Disponibile da:* 3.00. *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Vampa di bocca: una raffica che sparisce in un decimo di secondo. 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: dissolve una particella nell'arco di `distance` unità del mondo dove incontra la geometria dietro di essa, così uno sbuffo di fumo non disegna una linea netta dove attraversa un muro e una fontana affonda nella sua vasca invece di tagliarla. `0` (il predefinito) la disattiva; i valori negativi diventano `0`. Finché un qualsiasi emettitore la usa, la profondità della scena viene resa una volta in più per fotogramma, quindi su un Raspberry Pi va usata con parsimonia. Un emettitore 2D memorizza il valore e lo ignora. *Disponibile da:* 3.00. *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- Il fumo del braciere si ammorbidisce contro il palo accanto a cui sale. 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) ---- Velocità iniziale di ogni nuova particella, estratta a caso tra i due valori, in unità dell'overlay al secondo (2D) o unità del mondo al secondo (3D); predefinita da `50` a `100` in 2D e da `1` a `2` in 3D. Il massimo viene alzato al minimo quando è più piccolo. La velocità stabilisce quanto lontano arriva una particella prima che la resistenza, la gravità e la sua vita prendano il sopravvento. *Disponibile da:* 3.00. *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Una fontana lancia le gocce forte e dritte; la polvere si muove appena. 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 direzione media in cui partono le nuove particelle; conta solo la direzione, non la lunghezza. Predefinita in 2D `(0, -1)`, verso l'alto dello schermo dato che la Y dell'overlay cresce verso il basso; in 3D `(0, 1, 0)`, verso l'alto lungo l'asse Y del mondo. Un emettitore 2D ignora `z`; un emettitore 3D a cui si danno due valori riceve `z = 0`. Un vettore nullo viene trattato come `(0, -1, 0)`. Per un emettitore 3D la direzione è negli assi del mondo, non in quelli del nodo, quindi ruotala tu quando il nodo ruota. *Disponibile da:* 3.00. *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- I gas di scarico escono dietro la nave da qualunque parte sia rivolta. 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) ---- Metà dell'angolo del cono (3D) o del ventaglio (2D) attorno alla direzione in cui vengono lanciate le nuove particelle, da `0` a `180`; `0` è una linea retta, `180` è ogni direzione. Predefinito `30`; i valori fuori intervallo vengono limitati. In 3D le direzioni sono distribuite uniformemente sulla calotta del cono, così un cono ampio non si addensa verso il suo asse. *Disponibile da:* 3.00. *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- I fuochi d'artificio vanno ovunque; la fiamma della torcia resta in una colonna stretta. emitterSetSpread(fireworks, 180) emitterSetSpread(flame, 15) ---- [#emittersetgravity] ==== emitterSetGravity [source,text] ---- emitterSetGravity(emitter, x, y) emitterSetGravity(emitter, x, y, z) ---- Un'accelerazione costante su ogni particella, in unità dell'overlay (2D) o unità del mondo (3D) al secondo quadrato: `(0, 500)` tira le scintille 2D verso il basso dello schermo, `(0, -20)` fa salire il fumo 2D, `(0, -9.8, 0)` è la Terra nella scena. Predefinita: nessuna. Si applica alle particelle vive dal fotogramma successivo. Le particelle non sono corpi fisici; dai loro `emitterSetCollide` per farle rimbalzare su un pavimento o sulla scena. *Disponibile da:* 3.00. *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Le scintille cadono, il fumo sale, entrambi dalla stessa smerigliatrice. emitterSetGravity(sparks, 0, 500) emitterSetGravity(smoke, 0, -20) -- Sott'acqua, le bolle puntano verso la superficie. emitterSetGravity(bubbles, 0, 3, 0) ---- [#emittersetdrag] ==== emitterSetDrag [source,text] ---- emitterSetDrag(emitter, perSecond) ---- Quanto in fretta le particelle perdono la loro velocità: a ogni fotogramma la velocità viene moltiplicata per `1 - perSecond * dt`, mai sotto `0`, quindi `1` toglie gran parte della velocità entro un secondo e `0` (il predefinito) la conserva per sempre. I valori negativi diventano `0`. Fumo, braci e fuochi d'artificio rallentano; scintille e pioggia no. Si applica alle particelle vive dal fotogramma successivo. *Disponibile da:* 3.00. *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- Una bomba pirotecnica: esce veloce, poi resta sospesa e ricade. 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) ---- La dimensione alla nascita e alla morte, in unità dell'overlay (2D) o unità del mondo (3D), che cambia in modo uniforme nel corso della vita della particella; predefinita da `16` a `4` in 2D e da `0.2` a `0.05` in 3D. `variation` (da `0` a `1`, predefinito `0`) scala entrambe le dimensioni di ogni nuova particella per un unico fattore casuale tra `1 - variation` e `1 + variation`, così uno sbuffo è un po' più grande o più piccolo per tutta la vita. Le dimensioni negative diventano `0` e `variation` viene limitato. Una particella con texture viene disegnata come un quadrato di quella dimensione; un'immagine più larga viene schiacciata. *Disponibile da:* 3.00. *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Il fumo cresce mentre si dirada; le scintille si rimpiccioliscono fino a sparire. 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) ---- Su che cosa rimbalzano le particelle. `COLLIDE_NONE` (predefinito) è niente; `COLLIDE_FLOOR` è un piano orizzontale all'altezza `floor`, sotto il quale (Y maggiore in 2D, Y minore in 3D) una particella viene rimessa sul piano e riflessa; `COLLIDE_SCENE` è il mondo fisico, solo per gli emettitori 3D, tramite un raycast per particella lungo il suo moto a ogni fotogramma, con un tetto di `200` raycast per emettitore per fotogramma che riprendono da dove si erano fermati, così un emettitore grande distribuisce il costo su qualche fotogramma. Su un emettitore 2D `COLLIDE_SCENE` viene accettato e non fa nulla. Una modalità diversa da queste tre termina lo script. *Parametri:* * `mode` -- `COLLIDE_NONE`, `COLLIDE_FLOOR` o `COLLIDE_SCENE`. * `bounce` -- quanta velocità sopravvive a un urto, da `0` a `1`, predefinito `0.5`; limitato. * `friction` -- quanta velocità di scivolamento si perde a ogni urto, da `0` a `1`, predefinito `0.2`; limitato. * `floor` -- altezza del piano del pavimento, in unità del mondo per un emettitore 3D o in unità dell'overlay per uno 2D, predefinita `0`. Usato solo da `COLLIDE_FLOOR`. *Disponibile da:* 3.00. *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Le scintille rimbalzano sul pavimento dell'officina (riga 440 dell'overlay) e lasciano strisce. sparks = emitterNew() emitterSetBlend(sparks, PARTICLE_ADD) emitterSetGravity(sparks, 0, 500) emitterSetCollide(sparks, COLLIDE_FLOOR, 0.4, 0.2, 440) emitterSetTrail(sparks, 6, 3) -- Le braci nella scena rimbalzano su qualunque cosa contenga il mondo fisico. emitterSetCollide(embers, COLLIDE_SCENE, 0.3, 0.5) ---- [#emittersetcolor] ==== emitterSetColor [source,text] ---- emitterSetColor(emitter, r, g, b, a, r2, g2, b2, a2) ---- Colore e opacità alla nascita (`r, g, b, a`) e alla morte (`r2, g2, b2, a2`), ciascuno da `0` a `255`, che cambiano in modo uniforme nel corso della vita della particella; i valori fuori intervallo vengono limitati. Predefinito: da bianco e opaco a bianco e trasparente. La tinta moltiplica la texture o il disco incorporato, quindi un'immagine bianca prende esattamente il colore. Una dissolvenza in entrata e in uscita richiede due emettitori o una texture che contenga già quella forma; una ricetta esegue una sola rampa lineare. *Disponibile da:* 3.00. *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Fiamma: giallo acceso alla base, che si spegne in un rosso cupo e sparisce. emitterSetColor(flame, 255, 200, 90, 255, 255, 40, 0, 0) -- I granelli di polvere compaiono dal nulla e restano sospesi; è la loro vita a farli finire. emitterSetColor(dust, 255, 240, 200, 0, 255, 240, 200, 160) ---- [#emittersetspin] ==== emitterSetSpin [source,text] ---- emitterSetSpin(emitter, min, max) ---- Velocità di rotazione di ogni nuova particella in gradi al secondo, estratta a caso tra i due valori; i valori negativi ruotano nell'altro verso, e un intervallo a cavallo di `0` dà entrambi. Ogni particella parte da un angolo casuale, che ruoti o no. Predefinita: nessuna. Il massimo viene alzato al minimo quando è più piccolo. La rotazione si vede su una particella con texture o su una scia; il disco incorporato è rotondo e appare uguale a qualsiasi angolo. *Disponibile da:* 3.00. *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- Le foglie ruzzolano; le braci girano appena. emitterSetTexture(leaves, leafSprite) emitterSetSpin(leaves, -180, 180) emitterSetSpin(embers, -20, 20) ---- [#emittersetradius] ==== emitterSetRadius [source,text] ---- emitterSetRadius(emitter, radius) ---- Le nuove particelle nascono in un punto qualsiasi entro questa distanza dall'origine dell'emettitore, distribuite uniformemente su un disco (2D) o una sfera (3D), in unità dell'overlay o del mondo. Predefinito `0`, un punto; i valori negativi diventano `0`. Un raggio riempie un volume, la testa di una torcia o una chiazza di polvere nell'aria, dove un punto darebbe un ago. *Disponibile da:* 3.00. *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- Polvere in una sfera d'aria grande quanto una stanza, quasi tutta già lì quando il livello comincia. 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: ombreggia ogni particella con le luci e il cielo della scena come se fosse una piccola sfera rivolta alla telecamera, così il fumo prende il colore di un braciere e la polvere scintilla al sole. Disattivata in modo predefinito; le ombre non vengono considerate. Con `PARTICLE_ADD` l'illuminazione viene comunque applicata alla tinta prima che venga aggiunta. Un emettitore 2D memorizza il valore e lo ignora. *Disponibile da:* 3.00. *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- Il fumo sopra ogni braciere prende la luce del suo fuoco. 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) ---- Se le particelle vive seguono l'emettitore. `false` (predefinito) lascia ognuna dove è nata, così una torcia portata attraverso una stanza si lascia dietro il fumo; `true` tiene l'intera nube relativa all'origine dell'emettitore, così il pennacchio di un propulsore si muove con la nave. Cambiarlo sposta le particelle vive da un sistema di riferimento all'altro senza scatti. Su un emettitore 3D l'origine è la posizione nel mondo del nodo; su uno 2D è il punto di `emitterSetPosition`, quindi una nube 2D locale scivola anche con quello. Le scie seguono la stessa regola. *Disponibile da:* 3.00. *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- Un'aura magica che resta avvolta attorno alla maga mentre cammina. 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 riserva: quante particelle possono essere vive contemporaneamente, predefinito `1000`. Una riserva piena non ne crea altre, né dal flusso né da una raffica, finché alcune non muoiono. Cambiare il numero ricostruisce la riserva e uccide ogni particella viva, quindi impostalo prima di avviare. Dimensiona la riserva come frequenza per vita più lunga, più la raffica più grande. Un numero inferiore a `1` termina lo script. *Disponibile da:* 3.00. *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- 300 gocce al secondo che vivono fino a 1.6 secondi richiedono circa 480; lascia margine. 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: dove nascono le nuove particelle, in coordinate dell'overlay; predefinito `(0, 0)`. Impostalo a ogni fotogramma per seguire uno sprite o un corpo fisico 2D. Le particelle vive restano dove sono a meno che `emitterSetLocal` non sia attivo. Un emettitore 3D segue il suo nodo e qui termina lo script; sposta invece il nodo. *Disponibile da:* 3.00. *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Le scintille volano da dovunque si trovi lo sprite della smerigliatrice in questo fotogramma. 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) ---- Un nastro dietro ogni particella attraverso le sue ultime `length` posizioni, una registrata per fotogramma (da `2` a `16`; `0` o `1` per nessuno, il predefinito), largo `width` in unità del mondo o dell'overlay, in 2D come in 3D. Il nastro porta la tinta corrente della particella, sfuma nel trasparente verso la coda ed è texturizzato con la colonna centrale dell'immagine della particella, così il bordo sfumato del disco diventa quello del nastro. I valori vengono limitati all'intervallo. Cambiare la lunghezza ricostruisce la memoria delle scie, quindi le particelle vive ricominciano da capo i loro nastri. Scintille, braci, proiettili traccianti, comete. *Disponibile da:* 3.00. *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Proiettili traccianti: minuscoli, veloci, e tutti scia. 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) ---- Avvia il flusso costante alla frequenza impostata; da quel momento l'emettitore crea particelle a ogni fotogramma fino a `emitterStop`. Avviare un emettitore che sta già emettendo il flusso non cambia nulla. Il flusso non ha bisogno di una chiamata di disegno per funzionare; un emettitore 2D che emette il flusso ma non viene disegnato consuma comunque la sua riserva. *Disponibile da:* 3.00. *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Il motore si accende quando il giocatore dà gas e si spegne quando lo toglie. function onThrottle(open) if open then emitterStart(exhaust) else emitterStop(exhaust) end end ---- [#emitterstop] ==== emitterStop [source,text] ---- emitterStop(emitter) ---- Termina il flusso; le particelle vive finiscono la loro vita e qualsiasi frazione di particella ancora dovuta viene scartata. `emitterBurst` funziona ancora su un emettitore fermo, e `emitterIsActive` resta `true` finché non muore l'ultima particella. Usa `emitterClear` per rimuovere anche le particelle all'istante. *Disponibile da:* 3.00. *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- La torcia si spegne: ferma la fiamma, lascia che l'ultimo fumo si disperda. function onTorchDoused() emitterStop(flame) emitterStop(smoke) soundPlay(hiss) end ---- [#emitterburst] ==== emitterBurst [source,text] ---- emitterBurst(emitter, count) ---- Crea `count` particelle in una volta dalla ricetta corrente, con o senza flusso: un'esplosione, uno spruzzo, uno sbuffo. Ne vengono create solo quante ne entrano nella riserva; lo spazio lo stabilisce `emitterSetMax`. Un emettitore 2D le emette nel suo punto di `emitterSetPosition`, uno 3D sul suo nodo, sparse entro `emitterSetRadius`. *Disponibile da:* 3.00. *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Fuochi d'artificio: una bomba esplode da qualche parte sopra il disco più o meno ogni secondo. 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) ---- Uccide subito ogni particella viva e dimentica qualsiasi frazione di particella ancora dovuta. Il flusso non viene fermato: un emettitore avviato continua a creare nuove particelle dal fotogramma successivo, quindi chiama anche `emitterStop` per svuotarlo definitivamente. Usala quando una scena cambia e le vecchie scintille non devono restare. *Disponibile da:* 3.00. *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- Si ricomincia il livello: gli emettitori restano, le vecchie particelle se ne vanno. function resetLevel() for _, emitter in ipairs(levelEmitters) do emitterStop(emitter) emitterClear(emitter) end placePlayer() end ---- [#emitterdraw] ==== emitterDraw [source,text] ---- emitterDraw(emitter) ---- Solo 2D, da `onOverlayUpdate`: mette in coda le particelle dell'emettitore da disegnare in questo fotogramma, sopra o sotto l'overlay secondo `emitterSetLayer`, in coordinate dell'overlay mappate sulla finestra. Gli emettitori sullo stesso livello disegnano nell'ordine in cui sono stati messi in coda. La coda si svuota a ogni fotogramma, quindi la chiamata va fatta in ogni fotogramma in cui le particelle devono vedersi; non disegnare un emettitore non lo ferma. In un fotogramma disegnano fino a `64` emettitori per livello. Un emettitore 3D si disegna da sé nella scena e qui termina lo script. *Disponibile da:* 3.00. *Vedi anche:* <>, <>, <> .Esempio [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) ---- Quante particelle dell'emettitore sono vive in questo fotogramma, da `0` alla dimensione della riserva. Utile per regolare una riserva, per un budget a schermo o per sapere quando una raffica si è esaurita. *Restituisce:* Il numero di particelle vive, un intero. *Disponibile da:* 3.00. *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- Overlay di sviluppo: quanto di ogni riserva è in 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) ---- Se l'emettitore sta emettendo il flusso o ha ancora particelle vive: `true` da `emitterStart` o `emitterBurst` finché il flusso non è fermato e l'ultima particella non è morta, `false` quando l'esplosione è finita. Un emettitore che emette il flusso e la cui riserva risulta vuota è comunque attivo. *Restituisce:* Booleano. *Disponibile da:* 3.00. *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Le esplosioni singole vivono in un elenco e vengono liberate quando si esauriscono. 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] === Font Singe ha due renderer di testo. Il font da console incorporato viene disegnato da <> e non ha bisogno di risorse; la famiglia `font*` carica file TrueType a una dimensione in punti fissa e li rende o direttamente sull'overlay con `fontPrint` o in uno sprite riutilizzabile con `fontToSprite`. Gli handle dei font sono interi restituiti da `fontLoad`, e il motore tiene un unico font selezionato a livello globale, che `fontLoad` imposta sul font appena caricato e `fontSelect` cambia. Il testo viene reso nel colore di primo piano corrente con la qualità scelta da `fontQuality`, e le posizioni del testo sono coordinate dell'overlay. Rilascia i font con `fontUnload`; `Singe/FreeSansBold.ttf` viene distribuito con il motore ed è sempre disponibile. [#fontload] ==== fontLoad [source,text] ---- result = fontLoad(filename, pointSize) ---- Apre un font TrueType a una dimensione in punti e lo rende il font selezionato. La dimensione è fissa per tutta la vita dell'handle, quindi carica lo stesso file una volta per ogni dimensione che ti serve. Il nome passa per il file system virtuale come qualsiasi altra risorsa; anteponi `DIR` per i file distribuiti con il tuo gioco, o usa `Singe/FreeSansBold.ttf` per il carattere incluso. Un file che non si riesce ad aprire termina lo script con `Unable to open` e il nome. *Parametri:* * `filename` -- percorso di un file `.ttf`. * `pointSize` -- dimensione in punti, intera. *Restituisce:* handle intero del font. *Disponibile da:* 1.x *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Due dimensioni del carattere incluso: grande per il titolo, piccola per l'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) ---- Rende una stringa con il font selezionato, la qualità corrente e il colore di primo piano corrente (più il colore di sfondo per `FONT_QUALITY_SHADED`), poi la copia sull'overlay con l'angolo in alto a sinistra in `(x, y)`. Chiamala da `onOverlayUpdate`, a ogni fotogramma se serve: il motore conserva ogni stringa che rende, indicizzata per font, qualità, colori e testo, e una chiamata successiva che chiede la stessa stringa si limita a copiarla. Le stringhe non stampate per circa un secondo vengono dimenticate, e le stringhe di un font se ne vanno quando lo si scarica, quindi il testo che cambia non costa più di quanto sia sempre costato e quello che non cambia è quasi gratis. Un'intera pagina di punteggi record ristampata a ogni fotogramma va benissimo. Senza un font selezionato lo script termina con un errore. *Parametri:* * `x`, `y` -- angolo in alto a sinistra in coordinate dell'overlay; le frazioni vengono troncate. * `text` -- la stringa da rendere. *Disponibile da:* 1.x *Vedi anche:* <>, <>, <>, <> .Esempio [source,lua] ---- -- Lettura dal vivo della calibrazione dell'audio; il testo cambia a ogni fotogramma, quindi rendilo direttamente. 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) ---- Sceglie come `fontPrint` e `fontToSprite` rasterizzano il testo da quel momento in poi. L'impostazione è globale, non per font, e parte da `FONT_QUALITY_SOLID`. Gli sprite già creati da `fontToSprite` mantengono la qualità con cui sono stati resi. Un valore diverso dalle tre costanti termina lo script con un errore. *Parametri:* * `mode` -- `FONT_QUALITY_SOLID` (`1`): il più veloce, bordi a un bit, niente anti-aliasing. `FONT_QUALITY_SHADED` (`2`): glifi con anti-aliasing su un rettangolo pieno del colore di sfondo. `FONT_QUALITY_BLENDED` (`3`): glifi con anti-aliasing e alfa per pixel che si fondono su qualsiasi cosa; la scelta abituale. *Disponibile da:* 1.x *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Testo con fusione sopra il video, ma un'etichetta su riquadro pieno per la lettura di debug. 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) ---- Rende un font caricato quello usato da `fontPrint` e `fontToSprite`. La selezione resta fino al successivo `fontSelect` o `fontLoad`, dato che anche il caricamento seleziona. Si può cambiare tutte le volte che serve, anche più volte dentro un solo `onOverlayUpdate`. Un handle sconosciuto o scaricato (o nessun numero) lascia selezionato il font corrente e stampa un avviso, dato che Hypseus Singe lo lascia passare. *Disponibile da:* 1.x *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Titolo nel carattere grande, punteggio in quello piccolo, a ogni fotogramma. 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) ---- Rende una stringa con il font, la qualità e i colori selezionati in un nuovo sprite e ne restituisce l'handle. Da quel momento lo sprite è indipendente dal font, quindi sopravvive a `fontUnload`, e funziona con ogni chiamata `sprite*`: disegnalo, misuralo, scalalo o ruotalo, e liberalo con `spriteUnload`. Per una stringa che cambia, rendi un nuovo sprite e scarica quello vecchio. Senza un font selezionato lo script termina con un errore. NOTE: Rendere il testo in uno sprite per le prestazioni è deprecato. Ora `fontPrint` conserva ciò che rende, quindi stampare la stessa stringa a ogni fotogramma costa più o meno quanto disegnare uno sprite ricavato da essa, senza lo sprite da gestire. Usa `fontToSprite` quando il testo deve essere uno sprite: per scalarlo, ruotarlo, capovolgerlo o misurarlo con `spriteGetWidth` e `spriteGetHeight`. *Restituisce:* handle intero dello sprite. *Disponibile da:* 1.x *Vedi anche:* <>, <>, <>, <> .Esempio [source,lua] ---- -- Un titolo che entra ruotando e crescendo: testo che deve essere uno sprite, per essere girato e scalato. 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) ---- Chiude un font e invalida il suo handle. Gli sprite creati da esso con `fontToSprite` non ne risentono. Se il font scaricato era quello selezionato, dopo non c'è nessun font selezionato e il successivo `fontPrint` o `fontToSprite` termina lo script con un errore finché `fontSelect` o `fontLoad` non ne sceglie un altro. *Disponibile da:* 2.00 *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- Rilascia i font e gli sprite di testo che hanno prodotto. function onShutdown() spriteUnload(titleSprite) for _, line in ipairs(descriptionLines) do spriteUnload(line) end fontUnload(titleFont) fontUnload(hudFont) end ---- [#guiref] === Gui Una GUI è un albero di documenti RmlUi di dimensione fissa, reso nella propria texture a ogni fotogramma: `guiNew` ne crea una e restituisce un handle intero, `guiLoad` ci mette un documento RML e restituisce un handle di documento, `guiDraw` la compone sopra l'overlay e `materialSetGui` la mostra su un materiale nella scena. I documenti sono RML simile all'HTML, con lo stile dato da RCSS simile al CSS, e vengono caricati tramite il file system virtuale come gli sprite; `Singe/gui.rcss` è il tema distribuito e `Singe/FreeSansBold.ttf` il carattere di riserva. Le funzioni di aiuto per id `guiGetValue`, `guiSetValue` e `guiSetHandler` raggiungono gli elementi senza il modello a oggetti; tutto il resto è l'API Lua di RmlUi sulla variabile globale `rmlui`, dove una GUI è il contesto chiamato `gui` seguito dal suo handle. Possono esistere fino a sedici GUI contemporaneamente, ciascuna con trentadue documenti. Una GUI ha bisogno del dispositivo GPU, quindi `guiNew` termina lo script su una macchina che non ce l'ha; un handle di GUI non valido solleva `No GUI N`, e un handle di documento non valido `No document N in GUI G`. Vedi <> in Sviluppo di giochi. [#guiclose] ==== guiClose [source,text] ---- guiClose(gui, document) ---- Scarica il documento dalla GUI, eliminando i suoi elementi, i suoi handler e i suoi script. Dopo, l'handle del documento non è più valido. Un documento chiuso dal proprio script tramite l'API di RmlUi (`document:Close()`) viene scaricato allo stesso modo. Solleva `No document N in GUI G` per un handle non valido. *Disponibile da:* 3.00. *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- La pagina di inserimento del punteggio record serve una volta sola; chiudila dopo che il nome è stato inserito. 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, ogni documento che contiene e la sua texture, e ferma l'aggiornamento e il rendering per fotogramma che costava. I materiali che la mostrano tramite `materialSetGui` tornano alla propria texture. Dopo, l'handle non è più valido e termina lo script se viene usato di nuovo; dato che una GUI continua a essere resa che venga disegnata o no, elimina quelle con cui un gioco ha finito invece di nasconderle. *Disponibile da:* 3.00. *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- La pagina delle opzioni vive solo finché il giocatore ci sta dentro. 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 texture della GUI sopra l'overlay per questo fotogramma, in coordinate dell'overlay, quindi rispetta il bordo Sinden e la scala di overscan come tutto il resto sull'overlay: coprendo l'intero overlay con un solo argomento, alla propria dimensione con un angolo in `x, y`, oppure scalata nel rettangolo `width` per `height`. Il posto della chiamata nel disegno dello script decide che cosa sta sotto la GUI e che cosa sopra: tutto ciò che viene disegnato sull'overlay prima di essa resta sotto, e tutto ciò che viene disegnato dopo -- fino al successivo `guiDraw`, o al turno successivo dello script -- va su un livello tutto suo composto sopra quella GUI. Un puntatore disegnato dopo l'HUD viene disegnato sopra l'HUD; un secondo documento disegnato dopo ancora sta sopra il puntatore, con il proprio livello al di sopra. Fino a sedici livelli del genere per fotogramma; una chiamata oltre quel numero disegna la sua GUI senza niente sopra. `overlayClear` pulisce il livello su cui si sta disegnando, e `overlaySetMonochrome` rende grigio solo l'overlay di base. La chiamata dura un fotogramma; falla da `onOverlayUpdate` (o da `singeMain`) in ogni fotogramma in cui la GUI deve essere visibile. Le GUI si disegnano sopra l'overlay nell'ordine di chiamata (l'ultima in cima), sotto le particelle `PARTICLE_OVER` e l'indicatore di pausa, e la GUI disegnata per ultima sotto il puntatore è quella che riceve il mouse. Vengono onorate fino a sedici chiamate per fotogramma; quelle in più vengono ignorate. Qualsiasi altro numero di argomenti solleva `Expected 1, 3 or 5 arguments`. *Parametri:* * `x`, `y` -- l'angolo in alto a sinistra, in coordinate dell'overlay. * `width`, `height` -- la dimensione a cui disegnare, in pixel dell'overlay; la dimensione propria della GUI quando vengono omessi. *Disponibile da:* 3.00. *Vedi anche:* <>, <>, <> .Esempio [source,lua] ---- -- L'HUD sta nell'angolo in basso a destra, largo un terzo dell'overlay, e il mirino della pistola -- viene disegnato dopo, così il pannello non lo nasconde mai. 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) ---- Uguale a <>, salvo che il rettangolo viene misurato sull'intera finestra invece che sull'immagine. Le coordinate sono sempre coordinate dell'overlay -- `0, 0` è l'angolo in alto a sinistra della finestra e `overlayGetWidth(), overlayGetHeight()` quello in basso a destra -- così una GUI può stare sulla grafica del bezel accanto all'immagine, sopra o sotto di essa. Senza bezel, e senza `--scalefactor`, `--shiftx` o `--shifty`, l'immagine è l'intera finestra e questa è esattamente `guiDraw`. Le GUI nello spazio dello schermo vengono disegnate dopo tutto il resto, grafica del bezel compresa, e condividono con `guiDraw` i sedici disegni per fotogramma. Un clic le trova allo stesso modo: il puntatore viene portato nello spazio dello schermo prima di essere verificato, quindi una GUI nello spazio dello schermo che accetta input riceve il mouse ovunque sia stata disegnata. *Parametri:* * `x`, `y` -- l'angolo in alto a sinistra, in coordinate dell'overlay misurate sulla finestra. * `width`, `height` -- la dimensione a cui disegnare; la dimensione propria della GUI quando vengono omessi. *Disponibile da:* 3.00. *Vedi anche:* <>, xref:Manual.adoc#bezels[Bezel], <> .Esempio [source,lua] ---- -- Una mappa sul montante sinistro della grafica del cabinato, fuori dall'immagine. function onOverlayUpdate() overlayClear() guiDrawScreen(mapGui, 8, 44, 38, 114) return OVERLAY_UPDATED end ---- [#guigetheight] ==== guiGetHeight [source,text] ---- height = guiGetHeight(gui) ---- L'altezza della GUI nei suoi pixel, come data a `guiNew`. Termina lo script per un handle non valido. *Restituisce:* Un intero. *Disponibile da:* 3.00. *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- Centra una piccola finestra di dialogo sull'overlay. local x = (overlayGetWidth() - guiGetWidth(dialog)) / 2 local y = (overlayGetHeight() - guiGetHeight(dialog)) / 2 guiDraw(dialog, x, y) ---- [#guigetstats] ==== guiGetStats [source,text] ---- stats = guiGetStats() ---- Quanto è costato il rendering di tutte le GUI nell'ultimo fotogramma completato, per mettere a punto i documenti che usano filtri, maschere e ombre (ognuno dei quali disegna attraverso un livello grande quanto la GUI). I contatori coprono tutte le GUI insieme. Sono tutti zero in un fotogramma in cui l'immagine di nessuna GUI è cambiata, dato che non è stato ridisegnato nulla (vedi <>). *Restituisce:* Una tabella con i campi `drawCalls` (disegni indicizzati, comprese le scritture della maschera, le composizioni e le passate dei filtri), `layerPushes` (livelli aperti per filtri, immagini maschera e ombre dei riquadri), `filterPasses` (passate a dimensione piena e copie dentro le catene di filtri; una sfocatura ne costa diverse), `maskWrites` (disegni nella maschera di ritaglio dello stencil), `layerBytes` (byte delle texture dei livelli attualmente allocate; ognuna è grande quanto la sua GUI, a quattro byte per pixel) e `passes` (passate di rendering iniziate; ogni cambio di livello ne inizia una). *Disponibile da:* 3.00. *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- Guarda quanto costa una schermata piena di effetti. 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) ---- Il valore dell'elemento con quell’`id` nel documento, come stringa: il valore di un controllo di un modulo (il testo di un campo di testo, il `value` di un'opzione di `select`, un cursore nella formattazione propria di RmlUi come `"85.000000"`, l'attributo `value` di una casella di controllo o di un pulsante di opzione, `"on"` quando non ce l'ha, che sia selezionato o no; il suo stato è il suo attributo `checked`, letto tramite l'API di RmlUi), oppure, per qualsiasi altro elemento, il suo RML interno. Restituisce `nil` quando nessun elemento ha quell'id, così una pagina si può sondare senza terminare lo script; un handle di GUI o di documento non valido lo termina. I valori più lunghi di 4095 caratteri vengono troncati. *Restituisce:* Una stringa, o `nil`. *Disponibile da:* 3.00. *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- Leggi l'intero modulo quando viene premuto 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 larghezza della GUI nei suoi pixel, come data a `guiNew`. Termina lo script per un handle non valido. *Restituisce:* Un intero. *Disponibile da:* 3.00. *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- Il punto sondato è nella metà destra della pagina 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) ---- Nasconde il documento: non viene disegnato e non riceve input, ma conserva i suoi elementi e i suoi valori e `guiShow` lo riporta com'era. Solleva `No document N in GUI G` per un handle non valido. *Disponibile da:* 3.00. *Vedi anche:* <>, <> .Esempio [source,lua] ---- -- Due pagine in una GUI: la pagina delle opzioni si nasconde mentre quella dei controlli si mostra. guiSetHandler(gui, options, "controls", "click", function() guiHide(gui, options) guiShow(gui, controls) end) ---- [#guiload] ==== guiLoad [source,text] ---- document = guiLoad(gui, filename) ---- Carica un documento RML nella GUI, lo mostra e restituisce un handle intero di documento per le altre chiamate. Il nome passa per il file system virtuale come quello di uno sprite, risolto relativamente alla cartella da cui è stato avviato Singe, quindi anteponi `DIR` per i file distribuiti con il tuo gioco; i fogli di stile, le immagini e i font nominati dentro il documento vengono risolti relativamente al file che li nomina e poi tramite la stessa ricerca, così un gioco impacchettato li trova. I blocchi `