= Singe-Referenz 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: de :imagesdir: .. include::locale.adoc[] [#_game_development] == Spieleentwicklung [#_lua] === Lua Singe verwendet die Programmiersprache Lua (http://www.lua.org), um die Spiellogik zu skripten. Lua ist schnell, schlank, objektorientiert, einfach zu benutzen und wird in der Spielebranche tatsächlich eingesetzt. Dieses Buch bringt dir Lua nicht bei; _Programmieren lernen mit Singe_, im selben Download wie dieses, tut es, von null bis zu einem fertigen Spiel in einunddreißig Lektionen. Video-Tutorials zu Lua gibt es auf dem YouTube-Kanal von Kangaroo Punch, und die Referenz der Sprache selbst steht unter http://www.lua.org. [#_basic_rules] === Grundregeln * Singe ist plattformübergreifend. Dinge, um die du dich unter Windows normalerweise nicht kümmern musst, sind wichtig. Dateinamen unterscheiden zwischen Groß- und Kleinschreibung -- `MyScript.singe` und `myscript.singe` sind nicht dieselbe Datei. Verwende außerdem UNIX-Pfadtrenner -- `/` statt `\`. Das macht deinen Code sauberer (keine doppelt maskierten Backslashes) und funktioniert überall, wo Singe unterstützt wird. * Wähle einen unverwechselbaren Namen für deinen Spielordner. Verwende keine Leerzeichen. * Verwende das mitgelieferte Singe-Framework. Kopiere das Framework nicht in deine Spielskripte. Indem du das mitgelieferte Framework einbindest, hilfst du der künftigen Kompatibilität, wenn Singe aktualisiert wird. Beginne (oder beende) dein Spielskript mit: + [source,lua] ---- dofile("Singe/Framework.singe") ---- * Halte dein Spiel in sich abgeschlossen. Wenn du auf einem Framework eines Drittanbieters aufbaust oder Code zwischen deinen Spielen teilst, kopiere ihn in deinen Spielordner. Verweise nie auf einen Ordner neben deinem Spiel; die einzige Datei außerhalb deines Spiels, die ein Skript laden darf, ist `Singe/Framework.singe`. Ein Spiel in einer Datei (siehe <>) packt genau einen Ordner, und ein Spiel, das über ihn hinausgreift, lässt sich nicht packen. * Halte dich aus dem Ordner `Singe/` heraus. Er wird von Singe verwaltet, und alles, was dort hinzugefügt oder geändert wird, kann künftig gelöscht werden. * Liefere eine `games.dat` mit. Das ist für neue Benutzer äußerst wichtig. Es gibt zwar andere Frontends und auf Singe basierende Menüsysteme, aber es ist unerlässlich, dass du Daten für das eingebaute Menüsystem mitlieferst. Fast alle Supportanfragen gehen auf fehlende oder fehlerhafte `games.dat`-Dateien zurück. * Verteile keine unnötigen Dateien. Liefere nie Singe-Programmdateien oder von Singe mitgelieferte Skripte mit deinem Spiel aus. Liefere keine aus den Videos erzeugten Indexdateien mit. [#reloading] === Neu laden während der Arbeit Starte das Spiel mit `--reload` (`-R`), und Singe überwacht jede ungepackte Skriptdatei, die das Spiel geladen hat. Speichere eine Änderung an einer davon, und das Spiel beginnt einen Moment später wieder bei seinem Skript: Alles, was das Skript angelegt hat, verschwindet (Sprites, Sounds, Schriftarten, Videos, GUIs, die 3D-Szene, die Physik, Emitter, das Overlay kehrt zu seiner Standardgröße zurück, die Effektlautstärke und die Maus zu ihrem Zustand beim Start), die Engine bleibt (das Fenster, die GPU, die Controller), die Disc steht wie beim Start wieder auf ihrem ersten Frame, und das Skript läuft von vorn, sodass ein 52-MB-Modell in der Zeit wieder auf dem Bildschirm ist, die sein Laden dauert, statt in der Zeit, die der Start von Singe dauert. Eine Taste oder ein Knopf, die über das Neuladen hinweg gedrückt bleiben, werden ignoriert, bis sie losgelassen werden, wie bei einem frischen Start. `F5` lädt auf Wunsch neu, und ein Skript kann es mit `singeReload()` anfordern. Gepackte Spiele haben nichts, was sich überwachen ließe; die Option ist für ein Spiel in einem Ordner. Ein Skriptfehler beim Neuladen wird ausgegeben, und das Spiel bleibt leer, bis das nächste Neuladen ihn behebt; die Datei bleibt überwacht, das Speichern der Korrektur genügt also. Weil das Neuladen denselben Abbau durchläuft wie das Beenden, zeigt sich jedes Leck eines Skripts zuerst hier. [[deterministic]] === Deterministischer Testmodus `--deterministic` ist eine Testhilfe, keine Art zu spielen. Die Option lässt die Engine die echte Zeit ganz ignorieren: Statt den Rechner zu fragen, wie spät es ist, läuft die Engine auf einer virtuellen Uhr, die sich um einen festen Schritt -- standardmäßig 15 Millisekunden -- genau einmal pro Frame weiterbewegt. Alles, was nach der Zeit getaktet ist, folgt dieser Uhr: Physikschritte, Sprite- und Modellanimation, Partikel, Navigation, die Animationen und Übergänge der GUI, `os.clock()`, `singeGetTicks()` und ein bloßes `os.time()` (ein Spiel, das seinen Zufallsgenerator beim Spielstart nach der Uhr initialisiert, initialisiert ihn nach der virtuellen und zieht also bei jedem Lauf dieselben Zahlen; `os.time` mit einer Datumstabelle rechnet diese um, wie Lua es immer getan hat). Auch die Disc folgt ihr: Während sie läuft, bleibt jeder Videoframe so viele Engine-Frames lang stehen, wie seine Darstellungszeit auf dieser Uhr wert ist (zwei oder drei beim Standardschritt für eine Disc mit dreißig Frames pro Sekunde), wie im echten Spiel, und eine pausierte oder per Sprung versetzte Disc bleibt, wo sie hingestellt wurde. Der Zufallsgenerator von Lua und der eigene der Engine werden mit einer festen Zahl initialisiert. Nichts wartet auf irgendetwas, das Spiel läuft also so schnell, wie der Rechner es zulässt. Der Sinn des Ganzen ist, dass ein Screenshot, der bei einer festen Frame-Nummer aufgenommen wird, in jedem Lauf dasselbe Bild ist. Ohne diesen Modus ist Frame 40 jedes Mal ein anderer Moment -- die Physik hat eine andere Zahl von Schritten gemacht und die Disc steht auf einem anderen Videoframe -- und so können sich zwei Läufe desselben unveränderten Spiels um eine Million Pixel unterscheiden, und eine echte Änderung kann sich in diesem Rauschen verstecken. Der Preis ist alles, was von der echten Zeit abhängt. Audio spielt weiterhin nach der eigenen Uhr des Geräts, die keine virtuelle Uhr erreicht, der Ton driftet also vom Bild weg und sagt nichts aus -- es sei denn, der Lauf wird mit `--record` aufgezeichnet; dann wird der Ton zusammen mit dem Bild Schritt für Schritt in den Speicher gemischt, sodass eine so gemachte Aufnahme Frame für Frame und Sample für Sample exakt ist (siehe <>). Auch die Bildrate sagt nichts aus, und ebenso wenig alles, was ein Spiel in Sekunden misst. Melde nie eine Leistungszahl, ein Audioproblem oder ein Problem mit der Zeitsteuerung aus einem Lauf in diesem Modus, und liefere nie einen Launcher aus, der ihn verwendet. Die Zahl, die die Option trägt, wenn sie eine trägt, ist sowohl die Größe des Schritts als auch der Startwert der Zufallsgeneratoren, `1` bis `1000`: ---- Singe --deterministic der Standardschritt von 15 ms, Startwert 15 Singe --deterministic=20 ein Schritt von 20 ms, Startwert 20 ---- Zwei Läufe mit derselben Zahl sind identisch; zwei Läufe mit verschiedenen Zahlen sind jeder für sich wiederholbar, aber nicht gleich, und so verlangt ein Test einen anderen, aber ebenso reproduzierbaren Lauf. Die Option lässt sich wie jede andere als `deterministic` in einer Einstellungsdatei setzen. [#_reference_screenshots] ==== Referenz-Screenshots Die Szenen in `testScripts` machen jeweils einen oder zwei Screenshots bei einer festen Frame-Nummer und beenden sich selbst, in diesem Modus ergeben sie also einen Satz Referenzbilder, mit dem sich eine spätere Änderung Pixel für Pixel vergleichen lässt. Um sie aufzunehmen, starte jede Szene mit der Option und behalte, was im Datenordner landet: [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 ---- Die Szenen, die diese Schleife auslässt, wollen eigene Optionen, dieselben, die ihnen die Tabelle in `testScripts/README.md` gibt: `scene12` läuft ganz ohne Disc, `scene17` mit der Disc und `scene20` mit `-C 720x480`, alle drei mit `SDL_GPU_DRIVER=nothing`; `scene27` bis `scene35` wollen `-x 1280 -y 720 -C 1280x720` und keine Disc; `scene46` will `-g '10 5'`; und `scene47` will `--bezeldir=testScripts/bezels --bezel=cabinet.png`. Kopiere nach jedem Lauf die `singe*.png`-Dateien aus `data/testScripts` heraus, denn der nächste Lauf nummeriert seine Screenshots wieder ab null. Einen späteren Build zu vergleichen heißt dann, dieselben Befehle auszuführen und nach dem Unterschied zu fragen, der null sein muss: [source,sh] ---- compare -metric AE reference/scene36-singe000.png new/scene36-singe000.png null: ---- [#_where_files_live] === Wo Dateien liegen Pfade in einem Skript sind relativ zu dem Ordner, aus dem Singe gestartet wurde, nicht zum Skript. Verwende die globale Variable `DIR`, die `Framework.singe` auf den eigenen Ordner des Skripts setzt, damit ein Spiel funktioniert, egal wo es installiert ist: [source,lua] ---- crosshair = spriteLoad(DIR .. "sprite_Crosshair.png") ---- Alles, was Singe schreibt, landet im Datenordner (`singeGetDataPath()`), `data/` neben den Spielen, sofern `--datadir` nichts anderes sagt, damit ein Spiel auch auf schreibgeschützten Medien liegen kann. Singe 2.x schrieb Spielstände und Einstellungen in den eigenen Ordner des Spiels; ein Spiel, das das noch tut, scheitert an einer schreibgeschützten Installation, halte also jedes `io.output` unter `singeGetDataPath()`. [#_packaging_your_game] === Dein Spiel verpacken Ein fertiges Spiel wird als eine einzige `.game`-Datei ausgeliefert, die als Nächstes beschrieben wird. Mehr ist nicht nötig: Der Spieler kopiert die Datei neben die Singe-Programmdatei, und das Menü listet sie auf. Singe installiert keine Archive mehr. [#singlefile] === Spiele in einer Datei Ein Spiel kann eine einzige Datei sein. `singe --pack` schreibt einen Spielordner in eine SQLite-Datenbank, und Singe startet das Spiel von dort: Skripte, Sprites, Sounds, Schriftarten, Frame-Dateien und Video werden alle direkt aus der Datenbank geladen, und das Spiel zu installieren heißt, die Datei neben die ausführbare Datei zu kopieren. Das Menü listet jede `.game`-Datei auf, die es neben den Spielordnern findet. ---- Singe --pack MyGame MyGame.game Den Ordner MyGame in MyGame.game packen Singe MyGame.game Den ersten games.dat-Eintrag darin starten Singe --entry=2 MyGame.game Seinen zweiten Eintrag starten Singe --unpack MyGame.game MyGame Die Dateien wieder herausschreiben Singe --patch MyGame.game fixes Dateien aus einem Ordner (oder einer Patch-Datenbank) ersetzen ---- Nichts an einem Spiel muss sich ändern, damit es gepackt werden kann. Jeder Name, den ein Skript verwendet, ob über `dofile`, `require`, `io.open` und seine Verwandten, `spriteLoad`, `soundLoad`, `fontLoad`, `videoLoad`, eine Frame-Datei oder eine `controls.cfg`, geht durch ein und dieselbe Suche. Bei einem ungepackten Spiel ist diese Suche das gewöhnliche Dateisystem, relativ zu dem Ordner, aus dem Singe gestartet wurde, genau wie zuvor. Bei einem gepackten Spiel wird ein Name an drei Stellen versucht, in dieser Reihenfolge, und der erste Treffer gewinnt: . Ein ungepackter Ordner, der wie die Datenbank ohne ihre Endung heißt (`MyGame/` neben `MyGame.game`). So bearbeitest du ein gepacktes Spiel weiter, und so moddet ein Spieler eines: Dateien, die dort abgelegt werden, haben Vorrang vor den gepackten Kopien. . Der Datenordner des Spiels, unter `files/`. Alles, was ein Spiel mit `io.output` oder `io.open(name, "w")` schreibt, landet hier, sodass eine gespeicherte `game.cfg` Vorrang vor dem gepackten Original hat und die Datenbank nie beschrieben wird. . Die Datenbank selbst. Innerhalb einer Datenbank werden Namen ohne Rücksicht auf Groß- und Kleinschreibung abgeglichen, mit beiden Pfadtrennern und relativ zur Wurzel des Spiels. Eine führende Komponente, die dem eigenen Ordnernamen des Spiels gleicht, wird ignoriert, sodass `MyGame/Cfg/game.cfg`, `Cfg/game.cfg` und `DIR .. "Cfg/game.cfg"` alle dieselbe Datei finden. Der Name der Datenbank selbst (`MyGame.game`, mit oder ohne nachgestellten Trenner) ist die Wurzel des Spiels, so wie `.` von innen. Namen, die mit `Singe/` oder mit dem Datenordner beginnen, bleiben im Dateisystem; `Singe/Framework.singe` und `singeGetDataPath()` funktionieren wie immer, und ein solcher Name darf `..` verwenden, solange er unterhalb dieses Präfixes bleibt (die `controls.cfg` im obersten Datenordner wird so gefunden). Jeder andere relative Name mit einer `..`-Komponente führt innerhalb eines gepackten Spiels nirgendwohin: `io.open` gibt `nil` mit einer Meldung zurück, dass der Name aus dem Spiel hinausreicht, `lfs.attributes` gibt `nil` zurück, und `spriteLoad` und seinesgleichen melden die Datei als fehlend. Die Wurzel des Spiels ist ein Ordner, keine Datei: `io.open` darauf gibt `nil` zurück, und `lfs.mkdir` und `lfs.rmdir` lehnen sie ab. `lfs.dir` auf einem gepackten Ordner listet die Vereinigung der drei Stellen auf, ohne Rücksicht auf Groß- und Kleinschreibung sortiert; wenn sich eine ungepackte und eine gepackte Datei nur in der Schreibweise unterscheiden, wird die Schreibweise der ungepackten aufgelistet. `lfs.attributes` auf einer gepackten Datei antwortet so wie das echte `lfs`: mit der ganzen Tabelle bei einem Argument, mit einem Feld nach Namen (ein unbekannter Name löst einen Fehler aus) oder indem es eine Tabelle von dir ausfüllt; eine fehlende Datei gibt `nil`, eine Meldung und die Fehlernummer zurück. `require("name")` findet `name.lua`, `name/init.lua` oder `name.singe` unter dem Ordner des Skripts, dann unter der Wurzel des Spiels. `io.open`, `io.lines` und `io.input` geben bei einem Namen, der nur in der Datenbank liegt, eine Datei von Singe selbst zurück statt einer von Lua. Das Datei-Handle von Lua ist ein `FILE *` von C, und eine Datenbankzeile ist keine Datei, deshalb hieß das Lesen einer solchen Zeile früher, sie neben das Spiel zu schreiben und diese Kopie zu öffnen; das Handle von Singe liest die Zeile dort, wo sie liegt. Es versteht alles, was das von Lua versteht -- `read` mit jedem Format (`"a"`, `"l"`, `"L"`, `"n"` und einer Byteanzahl), `lines`, `seek`, `close`, `flush` und `setvbuf` -- `io.type` nennt es eine Datei, `io.read` und `io.close` nehmen es an, und es schließt sich selbst, wenn es vom Garbage Collector eingesammelt wird oder wenn eine ``-Variable ihren Gültigkeitsbereich verlässt. Der einzige Unterschied ist, dass Schreiben darauf ein Fehler ist, denn was es liest, liegt in der Datenbank, und die Datenbank wird nie beschrieben. Ein Spiel, das eine Datei schreibt, bekommt genau wie zuvor eine echte in seinem Datenordner, und ebenso jeder Name, der nicht gepackt ist. `testScripts/packedIo` ist der Test, der all das am eigenen Verhalten von Lua misst; sein Kopfkommentar sagt, wie man ihn ausführt. Der Packer lehnt einen Ordner ab, der keine `games.dat` hat, der `controls.dat`, `Framework.singe`, eine Datei mit der Endung `exe`, `sh`, `bat` oder `cmd` oder eine Datei ohne Endung enthält, deren Name mit `singe` beginnt, der auf oberster Ebene einen Eintrag hat, der wie der Ordner selbst heißt (das würde die Regel vom eigenen Ordner mehrdeutig machen), oder dessen Skripte und Datendateien mit `..` aus dem Spiel hinausgreifen; er meldet jede solche Datei, mit der Zeile. Zwei Dateien, deren Namen sich nur in der Groß- und Kleinschreibung unterscheiden, können nicht beide gepackt werden. Veraltete `.index`-Dateien werden übersprungen. `--unpack` lehnt eine Datenbank ab, deren gespeicherte Namen außerhalb des Zielordners landen würden (ein absoluter Pfad, ein Laufwerksbuchstabe oder eine `..`-Komponente). Ein Spiel, das auf den Ordner eines anderen Spiels verweist (ein gemeinsames Framework daneben), muss diesen Ordner zuerst hineinkopieren. Bevor die Datenbank festgeschrieben wird, wird ihre `games.dat` geprüft: Jeder Eintrag braucht ein `SCRIPT`, und jedes `SCRIPT`, `VIDEO`, `CABINET`, `MARQUEE` und `ATTRACT`, das er nennt, muss eine Datei in der Datenbank sein (oder unter `Singe/`); ein falscher Pfad wird mit dem Titel des Eintrags gemeldet, und das Packen wird verweigert. `--patch` führt dieselbe Prüfung am gepatchten Spiel durch, sodass auch ein Patch keinen ins Leere zeigenden Pfad hinterlassen kann. `--patch` nimmt entweder einen Ordner, der wie das Spiel aufgebaut ist und dessen Dateien die gepackten ersetzen, oder eine `.patch`-Datei, die `--pack` aus einem solchen Ordner erzeugt (`Singe --pack fixes MyGame-1.1.patch`; ein Patch braucht keine `games.dat`). Dateien, die der Patch nicht nennt, bleiben unberührt. So oder so ist die Änderung eine einzige Transaktion: Das Spiel bleibt intakt, wenn sie unterbrochen wird. Ein Patch kann auch eine Tabelle `removed(path)` mitbringen, die zu löschende Dateien nennt. Eine `.patch` lässt sich wie ein Spiel entpacken, aber sie kann nicht gestartet werden, und das Menü ignoriert sie. Ein Patch, der eine große Datei durch eine kleine ersetzt, lässt den Platz, den der alte Inhalt belegt hat, in der Datenbank zurück, wo SQLite ihn für das Nächste behält, was geschrieben wird, statt ihn zurückzugeben. Für einen Patch, der ein Skript ändert, ist das in Ordnung, und für einen Entwickler, der ein Video ein Dutzend Mal neu packt, verschwenderisch: Die `.game` kann am Ende ein Mehrfaches der Größe ihres Inhalts haben. Deshalb sieht sich `--patch` den freien Platz an, sobald die Transaktion festgeschrieben ist, und wenn es mindestens ein Megabyte davon gibt, verdichtet es die Datenbank und sagt, wie viel es zurückgewonnen hat: ---- >>> Recovered 8007680 bytes of space the old contents were using ---- Das Ergebnis hat aufs Byte genau die Größe eines frischen Packens derselben Dateien. Das Verdichten schreibt die ganze Datei neu, deshalb wird es übersprungen, wenn es wenig zu gewinnen gibt. Nichts sonst in Singe braucht das; ein Spiel wird immer nur vom Packer geschrieben. Der Datenordner eines gepackten Spiels ist nach der Datenbank benannt und dann nach dem Ordner des Skripts darin: `MyGame/` für eine `MyGame.game`, deren Skript an ihrer Wurzel liegt, unter dem Datenordner (standardmäßig `data/MyGame/`), also dort, wo ihn auch das ungepackte `MyGame/MyGame.singe` hat, sodass Spielstände und Einstellungen zwischen einer ungepackten und einer gepackten Installation geteilt werden; ein in einem Unterordner gepacktes Skript (`Game/Main.singe`) bekommt `MyGame/Game/`. Auch der Videoindex kommt dorthin. NOTE: Frühere 3.00-Builds gaben einem gepackten Spiel, dessen Skript in einem Unterordner lag, den Datenordner `Game/` unter der Basis, den sich jedes Archiv mit diesem Aufbau teilte. Die Spielstände eines solchen Spiels gehören jetzt unter `MyGame/Game/`; verschiebe sie von Hand. Frühere 3.00-Builds behielten außerdem den eigenen Ordner des Spiels bei, den, unter dem seine `games.dat` das Skript nennt, sodass eine aus `MyGame/` gepackte `MyGame.game` nach `MyGame/MyGame/` schrieb; verschiebe, was dort liegt, eine Ebene nach oben. Wenn Singe ein Spiel aus einer Datenbank startet, übergibt das Menü `CONTAINER` im `games.dat`-Eintrag, den es startet; das Feld wird vom Menü gesetzt, nie von Hand. Für deine eigenen Daten gibt `require("sqlite3")` Skripten die Anbindung https://lua.sqlite.org[lsqlite3] an dasselbe SQLite, das die Engine verwendet. Lege Datenbanken, die das Spiel schreibt, in `singeGetDataPath()` ab: [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` findet einen Namen so wie `io.open`, eine Datenbank kann also im Spiel mitgeliefert werden -- Level-Tabellen, Dialoge, Gegenstandslisten -- und lässt sich trotzdem öffnen, wenn das Spiel gepackt ist. Nur zum Lesen geöffnet, wird eine gepackte Datenbank dort gelesen, wo sie liegt, ohne dass etwas kopiert wird: [source,lua] ---- local levels = sqlite3.open(DIR .. "levels.db", sqlite3.OPEN_READONLY) ---- Zum Schreiben geöffnet, was die Voreinstellung von `sqlite3.open` ist, wird eine gepackte Datenbank einmal in den Datenordner des Spiels kopiert, unter `files/` wie alles andere, was ein gepacktes Spiel schreibt, und die Kopie wird geöffnet. Von da an bezeichnet der Name die Kopie, ob nur zum Lesen oder nicht, und das gepackte Original wird nie verändert. Die Datenbank eines ungepackten Spiels öffnet sich genau wie zuvor an Ort und Stelle. `testScripts/packedSql` ist der Test; sein Kopfkommentar sagt, wie man ihn ausführt. Das Dateiformat, für alle, die Werkzeuge schreiben: drei Tabellen. `meta(key, value)` enthält `version` (1), `gamedir` (den Namen des gepackten Ordners), `chunk` (die Chunk-Größe in Bytes, 4194304) und `packer`. `assets(path, name, size, data)` hat eine Zeile pro Datei, wobei `path` der kleingeschriebene Schlüssel mit Schrägstrichen ist und `name` die Schreibweise des Autors; Dateien, die nicht größer als die Chunk-Größe sind, stehen in `data`, größere haben `data` NULL und liegen in `chunks(path, chunk, data)` als fortlaufend nummerierte Stücke. Singe öffnet die Datenbank nur lesend und unveränderlich, sodass es von schreibgeschützten Medien läuft und nie ein Journal daneben anlegt. [#_event_driven_or_not] === Ereignisgesteuert ... oder nicht? Traditionell verwendete Singe ein ereignisgesteuertes Programmiermodell -- Singe kümmert sich um alles und ruft deinen Code nur auf, wenn es dir etwas mitteilen oder den Bildschirm aktualisieren muss. Das ist effizient und entspricht der Arbeitsweise der meisten Lua-basierten Spiel-Engines, kann aber umständlich sein. Seit Singe 2.10 gibt es ein neues _Thread-Modell_ („threaded“), mit dem du prozeduralen Code schreiben kannst. [#_event_driven] ==== Ereignisgesteuert Beim ereignisgesteuerten Programmiermodell steuert Singe die Hauptschleife des Programms und bestimmt die Reihenfolge der Ausführung. Singe kümmert sich automatisch um alle Einzelheiten des Decodierens und Darstellens von Video und Audio. Es verwaltet Controller, Mäuse und Tastatureingaben. Wenn Singe etwas Spielspezifisches braucht, ruft es einen Teil deines Skripts auf. Das einfachste Singe-Skript, das alle vorhandenen Callbacks zeigt, sieht so aus: [source,lua] ---- -- Singe-Spielgerüst. -- Das Singe-Framework laden. dofile("Singe/Framework.singe") -- Deklariere hier alle globalen Variablen, die du brauchst. function onControllerMoved(axis, value, which) --[[ Meldet, welche Controller-Achse bewegt wurde, und ihren aktuellen Wert. (Bereich: -32768 bis 32767.) Das wird für analoge Geräte verwendet. Digitale Eingaben werden von onInput und onKey behandelt. --]] end function onInputPressed(what, device) --[[ Im Tastaturmodus MODE_NORMAL werden Eingabeereignisse hier gemeldet, sobald die Taste oder der Knopf zuerst gedrückt wird. Eine vollständige Liste der Tasten, Knöpfe und Controller steht in Singe/Framework.singe. Bei MODE_FULL wird dieses Ereignis mit dem Keysym der gedrückten Taste aufgerufen. Kam der Schalter von einer Maus oder einer Lightgun, ist device der Index dieses Geräts (0 für das erste), so wie Hypseus ihn übergibt, damit ein Spiel mit zwei Lightguns seine Spieler unterscheiden kann; bei einer Taste oder einem Pad ist es nil. --]] end function onInputReleased(what, device) --[[ Im Tastaturmodus MODE_NORMAL werden Eingabeereignisse hier gemeldet, wenn die Taste oder der Knopf losgelassen wird. Eine vollständige Liste der Tasten, Knöpfe und Controller steht in Singe/Framework.singe. Bei MODE_FULL wird dieses Ereignis mit dem Keysym der losgelassenen Taste aufgerufen. device ist wie bei onInputPressed. --]] end function onKeyPressed(key, scancode) --[[ Im Tastaturmodus MODE_FULL werden Eingabeereignisse hier gemeldet, wenn die Taste gedrückt wird. Sowohl der Keysym als auch der Scancode werden geliefert. Eine Liste der verfügbaren Scancodes steht in Singe/Framework.singe. --]] end function onKeyReleased(key, scancode) --[[ Im Tastaturmodus MODE_FULL werden Eingabeereignisse hier gemeldet, wenn die Taste losgelassen wird. Sowohl der Keysym als auch der Scancode werden geliefert. Eine Liste der verfügbaren Scancodes steht in Singe/Framework.singe. --]] end function onMouseMoved(x, y, xr, yr, which) --[[ Wird aufgerufen, wenn die Maus bewegt wird. Im Modus SINGLE_MOUSE werden absolute X- und Y-Werte sowie die relative Positionsänderung geliefert. Im Modus MANY_MOUSE ist nur die relative Änderung verfügbar, dazu die Angabe, welche Maus bewegt wurde. --]] end function onOverlayUpdate() --[[ Dies ist die einzige Stelle, an der du gefahrlos Zeichenoperationen ausführen kannst. Wenn du einen Zielcursor anzeigen willst, musst du die Mausposition aus onMouseMoved in globalen Variablen speichern und diese dann hier verwenden, um den Cursor zu zeichnen. --]] -- Singe mitteilen, ob wir die Anzeige verändert haben oder nicht. return(OVERLAY_UPDATED) -- Oder OVERLAY_NOT_UPDATED, wenn nichts gezeichnet wurde. end function onShutdown() -- Wird aufgerufen, wenn der Benutzer dein Spiel beendet. Gib hier geladene Ressourcen frei. end function onSoundCompleted(channel) -- Der Sound auf „channel“ ist gerade zu Ende gespielt. end -- Hinweis: Es gibt kein Ereignis „onStartup“. -- Code, den du beim Start brauchst, kann hier stehen. ---- [#threaded] ==== Thread-Modell Seit Singe 2.10 kannst du das klassische prozedurale Programmiermodell verwenden. Um dieses Modell zu nutzen, deklariere eine Funktion `singeMain()` und binde das Singe-Framework am *Ende* deines Programms statt am Anfang ein. Lua beherrscht kein echtes Multithreading, deshalb verlässt sich dieses Modell darauf, dass der Spieleentwickler kooperatives Multitasking betreibt, indem er überall dort `singeYield()` aufruft, wo Code nennenswert Zeit verbraucht. Wie beim ereignisgesteuerten Modell verwaltet Singe weiterhin alle Eingaben, die Videodecodierung und die Audiodecodierung. Ein Beispielprogramm im Thread-Modell sieht so aus: [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] ==== Hybrid Die beiden Modelle lassen sich frei kombinieren. Wenn `singeMain` existiert, installiert `Framework.singe` sein eigenes `onOverlayUpdate`, aber jeder andere Callback wird weiterhin ausgelöst: Definiere `onInputPressed`, `onSoundCompleted` oder `onShutdown` neben `singeMain`, und sie werden zwischen den Yields der Koroutine aufgerufen. Eine typische Aufteilung hält den Ablauf des Spiels prozedural und reagiert dabei auf Eingabe- und Audioereignisse: [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") ---- Definiere `onOverlayUpdate` in diesem Modell nicht selbst; zeichne stattdessen aus `singeMain` heraus. Ein Fehler, der in `singeMain` ausgelöst wird, beendet das Spiel mit einem Traceback, und eine Rückkehr aus `singeMain` beendet es sauber. [#pausing] === Pausieren Jedes Spiel bekommt umsonst eine funktionierende Pause. Solange die Pausentaste aktiviert ist (die Voreinstellung), lässt ein Druck auf die Taste, die `INPUT_PAUSE` zugeordnet ist, die Engine die Disc, jedes geladene Video und jeden Sound anhalten, eine Anzeige PAUSED über den letzten Frame zeichnen und das Skript nicht mehr ausführen: kein `onOverlayUpdate`, kein Fortsetzen von `singeMain`, keine Eingabe-, Maus-, Controller- oder Sound-Callbacks. Timer, die auf Lua-Bibliotheken aufbauen, halten mit an, weil nichts sie ausführt. Ein erneuter Druck auf die Taste setzt alles fort, was lief, und das Skript macht dort weiter, wo es aufgehört hat. Nur die eigenen Schalter der Engine (Pause, Beenden, Screenshot, Mausfang) funktionieren im eingefrorenen Zustand noch. Eingaben bleiben über das Einfrieren hinweg wahrheitsgetreu. Alles, was das Skript für gedrückt hält, wird beim Beginn der Pause mit `onInputReleased` (oder `onKeyReleased` in `MODE_FULL`) losgelassen, und was beim Ende der Pause noch tatsächlich gehalten wird, wird erneut gedrückt. Ein Joystick, der während einer Pause gehalten wird, bleibt nie hängen. Ein Spiel, das sein eigenes Pausenverhalten will (ein Pausenmenü, einen Halt in der Handlung, den der Spieler nicht überspringen darf), ruft `singeSetPauseKeyEnabled(false)` auf. Die Taste wird dann als `SWITCH_PAUSE` an das Skript geliefert, und das Spiel steuert `singeSetPauseFlag` selbst, was die Medien anhält, ohne das Skript einzufrieren. In `MODE_FULL` gehört die Tastatur ganz dem Spiel, Tasten, die den Schaltern der Engine zugeordnet sind, bewirken dort also nichts (ein `p`, das in eine Highscore-Eingabe getippt wird, muss ein `p` bleiben). Gamepad- und Mausknöpfe, die diesen Schaltern zugeordnet sind, funktionieren in beiden Modi weiter, da sie sich nicht tippen lassen. [#gamesdat] === games.dat Die Datei `games.dat` ermöglicht es Singe, neue Spiele automatisch zu finden, wenn der Endbenutzer sie installiert. Diese Datei ist äußerst wichtig und muss jedem Singe-Spiel beiliegen. Lege `games.dat` in den obersten Ordner deines Spiels. Ein Beispiel mit mehreren Spielen: [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" }, -- ... weitere Einträge ... } ---- Die Schlüssel `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` und `TIER` liest die Engine, wenn das Menü (oder dein eigenes Skript, über `scriptExecute` / `scriptPush`) den Eintrag startet; sie haben Vorrang vor der Befehlszeile. Die Zeile `VIDEO` ist die Disc: Ein Eintrag, der eine nennt, ist ein Laserdisc-Spiel, und ein Eintrag, der sie weglässt oder leer lässt, läuft ohne Disc und kann `CANVAS_X` / `CANVAS_Y` setzen (Standard 720x480); siehe <>. `AUDIO_SUFFIX = "-es"` spielt `-es.ogg` neben dem Video der Disc ab statt des Tons darin, für eine Veröffentlichung, deren andere Sprachen als eigene Dateien geliefert werden; das ist dasselbe, was `--altaudio` tut. `LEGACY_SPRITE_ARGS = true` führt ein für Singe 2.10 geschriebenes Spiel mit der alten Reihenfolge der Sprite-Argumente aus (siehe <>). Die übrigen Schlüssel liest das Menü für die Anzeige. `REQUIRES` sagt, was das Spiel von einem Rechner braucht, als Liste von Namen: `"SCENE"` (die 3D-Szene), `"GUI"` (GUI-Dokumente), `"SHADOWS"`, `"POST"` (Bloom und die anderen Effekte, die Float-Renderziele brauchen), `"SKIN"` (geskinnte Modelle) und `"MORPH"` (Morph-Ziele). Kein `REQUIRES` bedeutet 2D. Das Menü kennzeichnet ein Spiel, das dieser Rechner nicht ausführen kann, und sagt warum, statt es zu starten, und du schreibst die Liste selten selbst: Die Engine bemerkt beim Laufen, was ein Spiel verwendet, meldet im Trace, wenn das Spiel etwas verwendet, das `REQUIRES` nicht aufführt, und `--pack` schreibt die Liste in einen Eintrag, der keine hat. `TIER` (0 bis 3) gibt an, wie viel Rechner das Spiel will; das Menü misst die Stufe dieses Rechners an seinem eigenen Hintergrund und kennzeichnet ein Spiel, das mehr will, als eines, das langsam laufen könnte. Siehe <>. [source,lua] ---- REQUIRES = { "SCENE", "SHADOWS" }, TIER = 1, ---- Ein direkt gestartetes Skript, ob über seinen Namen oder seinen Ordner, wird genauso behandelt: Singe sucht eine `games.dat` im Ordner des Skripts und bis zu drei Ordner darüber, und wenn das `SCRIPT` eines Eintrags das gestartete Skript nennt (Pfade in Einträgen zählen ab dem Ordner über der `games.dat`, sodass `Collection/games.dat` das Skript als `Collection/AsteroidPatrol.singe` nennt), gelten die Schlüssel dieses Eintrags. Der eine Unterschied zum Start aus dem Menü ist der Vorrang: Eine auf der Befehlszeile eingegebene Option (`--framefile`, `--disc`, `--stretch`, `--nomouse`, `--xresolution`, `--yresolution`, `--sindengun`, `--audio`, `--audiodelay`, `--altaudio`, `--canvas`) behält ihren Wert, denn wer sie eintippt, meint sie so. Ein Wert aus einer Einstellungsdatei nicht: Er ist eine Voreinstellung, und der Eintrag beschreibt das Spiel besser (siehe xref:Manual.adoc#settings[Die Einstellungsdatei]). Eine `games.dat`, die sich nicht ausführen lässt, wird bei einem direkten Start gemeldet und ignoriert. [#withoutadisc] === Spiele ohne Disc Singe setzt keine Laserdisc mehr voraus. Ein Spiel ist nur dann ein Laserdisc-Spiel, wenn es eine nennt: ein `VIDEO` in seinem `games.dat`-Eintrag oder `--disc` (oder ein `--framefile`) auf der Befehlszeile. Alles andere läuft ohne Disc, und die Welt ist eine *Zeichenfläche* statt eines Videoframes: `CANVAS_X` / `CANVAS_Y` in `games.dat` oder `--canvas=WxH` auf der Befehlszeile, standardmäßig 720x480, damit ein Spiel ohne Disc wie ein Laserdisc-Spiel in Standardauflösung aussieht, sofern es nichts anderes verlangt. Ohne Disc zeichnet die Engine auf Schwarz, das Overlay ist wie üblich standardmäßig halb so groß wie die Zeichenfläche, und die Funktionen `disc*` und `vldp*` werden harmlos: `discGetState` meldet `DISC_EJECTED`, `discGetFrame` gibt `0` zurück, `discGetWidth` / `discGetHeight` geben die Zeichenfläche zurück, `vldpGetPixel` gibt Schwarz zurück, und die Transportaufrufe schreiben in den Trace und tun nichts. Alles andere -- das Overlay, Sprites, Schriftarten, Sounds, zusätzliche Videos über `videoLoad`, die Eingabe und beide Programmiermodelle -- ist identisch. Die globale Variable `SINGE_DISC` sagt einem Skript, als welche Art von Spiel es läuft. [#gui] === GUI Menüs, Optionsseiten, HUDs und die Highscore-Eingabe wurden früher aus `fontPrint`, `overlayBox` und einem selbst gebauten Cursor zusammengesetzt. Singe 3 bringt eine GUI mit: ein Dokument, geschrieben in RML (einem HTML-ähnlichen Markup) und gestaltet mit RCSS (einem CSS-ähnlichen Stylesheet), gesetzt und gezeichnet von RmlUi, mit Textumbruch, Flexbox, Nine-Slice-Panels aus deinen eigenen Grafiken, Übergängen, Animationen, Formularelementen und einem Fokusmodell, das ein Gamepad steuern kann. Eine *GUI* ist ein RmlUi-Kontext fester Größe, der in seine eigene Textur gerendert und neu gezeichnet wird, wann immer sich sein Bild ändert (siehe <>), und diese Textur kommt überallhin, wo eine Textur hinkann: mit `guiDraw` über das Overlay gelegt oder mit `materialSetGui` auf einem Material in der 3D-Szene gezeigt, sodass dieselbe Optionsseite flach auf dem Bildschirm und auf dem Monitor eines modellierten Arcade-Automaten funktioniert. Eine GUI braucht das GPU-Gerät, das die 3D-Szene verwendet, und ist deshalb auf einem Rechner ohne ein solches nicht verfügbar (siehe <>); `guiNew` beendet das Skript, statt mit false zu antworten, frage also zuerst <>, wenn dein Spiel ohne auskommt. Das mitgelieferte Menü tut genau das und zeichnet sich stattdessen ins Overlay, wenn die Antwort nein ist (siehe xref:Manual.adoc#menurenderers[Die zwei Renderer]). Jeder hier genannte Aufruf hat einen Eintrag im Abschnitt <> der Referenz. Die Sprachreferenz für RML und RCSS ist die eigene Dokumentation von RmlUi unter https://mikke89.github.io/RmlUiDoc/, und sie lohnt die Lektüre vor dem ersten Dokument; dieses Kapitel behandelt, was Singe obendrauf setzt. [#_documents_and_style_sheets] ==== Dokumente und Stylesheets `guiNew(width, height)` erzeugt eine GUI und gibt ein Ganzzahl-Handle zurück; bis zu sechzehn können gleichzeitig existieren. `guiLoad(gui, name)` lädt ein RML-Dokument hinein, zeigt es an und gibt ein Dokument-Handle zurück (bis zu zweiunddreißig pro GUI); `guiHide`, `guiShow` und `guiClose` tun, was ihre Namen sagen, und `guiDelete` verwirft die GUI, ihre Dokumente und ihre Textur. Ein Dokument ist meist so groß wie seine GUI, und eine GUI ist meist so groß wie das Overlay, für eine Vollbildseite, oder so groß wie das Panel, für ein HUD-Element: [source,lua] ---- local gui = guiNew(overlayGetWidth(), overlayGetHeight()) local doc = guiLoad(gui, DIR .. "options.rml") ---- Der Dokumentname geht wie der eines Sprites durch das virtuelle Dateisystem, er kann also ungepackt auf der Festplatte oder in einem gepackten Spiel liegen, und er wird relativ zu dem Ordner aufgelöst, in dem Singe gestartet wurde; stelle also für Dateien, die mit deinem Spiel ausgeliefert werden, `DIR` voran, genau wie bei `spriteLoad`. Namen *innerhalb* eines Dokuments (das `href` eines Stylesheets, ein Bild in einem Decorator, das `src` eines Sprite-Sheets) werden relativ zu der Datei aufgelöst, die sie nennt, wie bei RmlUi, und dann über dieselbe Suche, sodass ein Dokument, sein Stylesheet und seine Bilder nebeneinander im Spielordner liegen und ein gepacktes Spiel sie alle findet. Zwei Arten von Namen werden genommen, wie sie sind: die eigenen Dateien der Engine unter `Singe/` und absolute Pfade. Dieselbe Regel, andersherum gelesen, lässt ein Dokument, das unter `Singe/` liegt, die Dateien eines Spiels erreichen: Ein relativer Name wird vom eigenen Ordner des Dokuments aus aufgelöst, also ist von `Singe/Menu.rml` aus der Name `../Collection/cabinet.png` die Datei `Collection/cabinet.png` im Spieleverzeichnis, und ein Name unter `../MyGame.game/` ist eine Datei in diesem gepackten Spiel. Das mitgelieferte Menü tut genau das für die Automaten- und Marquee-Grafik des gewählten Spiels und setzt aus Lua einen Bild-Decorator auf das Grafikfeld, sodass das Bild unabhängig von seiner Größe hineinpasst: [source,lua] ---- -- Menu.singe: das Bild eines Spiels in eines der Grafikfelder einpassen. 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 ---- Ein RML-Dokument besteht aus einem `` mit einem Titel, Links auf Stylesheets oder einem eingebetteten `

Options

Choose a difficulty and press Start.

---- RmlUi zeichnet nichts, dessen Größe ihm nicht mitgeteilt wurde, und jedes Element ist `display: inline`, bis ein Stil etwas anderes sagt; ein Stylesheet ist also nicht optional. `Singe/gui.rcss` ist das Theme, das die Engine mitliefert: Es gibt Body, Überschriften und Absätzen eine Größe und eine Farbe, macht `div` zu einem Block und gestaltet jedes Formularelement. Binde es zuerst ein und überschreibe, was du willst, in einem eigenen Stylesheet oder einem eingebetteten `

Options

plays the intro

Press Start when ready.

---- Das Skript lädt es in eine GUI von der Größe des Overlays, verdrahtet die drei Elemente, die es interessieren, und zeichnet es in jedem Frame, bis der Spieler Start drückt; die Disc läuft die ganze Zeit darunter: [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) -- Hier kommt nur an, was die Seite nicht verwendet hat: nichts, solange sie -- den Fokus hat, alles, sobald sie versteckt ist. 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 ---- Dieselbe Seite auf dem Bildschirm eines modellierten Automaten sind die fünf Zeilen aus <> anstelle von `guiDraw`, und ein Klick auf den Monitor drückt den Button darunter. [#subtitles] === Untertitel Ein Spiel kann eine SubRip-Untertiteldatei mitliefern und Singe sie beim Abspielen über der Disc anzeigen lassen. Drei Aufrufe erledigen die ganze Arbeit: <> liest die Datei, <> schaltet sie ein und <> legt fest, wo die Zeilen stehen. <> nimmt wieder weg, was gerade angezeigt wird. Die Funktion stammt aus Hypseus Singe, und die vier Aufrufe sind die von dort. [source,lua] ---- if srtLoad("subs/english.srt") then srtPosition(80) srtEnable(true) end ---- [#_the_file_format] ==== Das Dateiformat SubRip (`.srt`) ist eine reine Textdatei aus nummerierten Cues: ---- 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. ---- Jeder Cue besteht aus einer Nummer auf einer eigenen Zeile, dann einer Zeile mit zwei durch `-->` getrennten Zeitstempeln, dann einer oder mehreren Textzeilen, dann einer Leerzeile. Ein Zeitstempel hat die Form `HH:MM:SS,mmm`: Stunden, Minuten und Sekunden durch Doppelpunkte getrennt, die Millisekunden nach einem Komma. Die Cue-Nummer wird gelesen und verworfen, die Nummern müssen also weder in der richtigen Reihenfolge noch überhaupt korrekt sein. Der Parser ist absichtlich nachsichtig an den Stellen, an denen eine von Hand bearbeitete Datei schiefgeht. Wagenrückläufe sowie führende und nachgestellte Leerzeichen werden von jeder Zeile entfernt, sodass eine unter Windows geschriebene Datei unverändert lädt. Ein Cue, dessen Zeitzeile kein `-->` enthält oder dessen Zeitstempel sich nicht parsen lassen, wird übersprungen, und die Datei wird weiter geladen. Ein Cue ohne Text wird übersprungen. Die Zeilen eines Cues werden mit Zeilenumbrüchen verbunden und so angezeigt, wie sie geschrieben sind, und `<`, `>` und `&` erscheinen als sie selbst auf dem Bildschirm, statt als Markup verstanden zu werden. Was *nicht* unterstützt wird: Die Positionskoordinaten, die manche Werkzeuge an die Zeitzeile anhängen, werden ignoriert (`srtPosition` bestimmt die Höhe), und die gelegentlichen ``- und ``-Tags von SubRip werden als Text angezeigt statt befolgt. Cues dürfen sich zeitlich überschneiden; der zuletzt begonnene gewinnt. `srtLoad` antwortet mit `false` und lädt nichts, wenn die Datei nicht gelesen werden kann, wenn sich nichts darin parsen lässt oder wenn keine Disc da ist -- eine kaputte Datei ist also ein `false`, auf das ein Spiel reagieren kann, und kein Absturz. In jedem dieser Fälle gibt es eine Warnung aus, die die Datei nennt. [#_the_timing] ==== Die Zeitsteuerung Zeitstempel werden beim Laden der Datei in *Frame-Nummern der Disc* umgerechnet, mit der Bildrate der Disc, die zu diesem Zeitpunkt geladen ist, und mit diesen Frame-Nummern gleicht die Engine jeden gezeichneten Frame ab. Daraus folgen zwei Dinge. Erstens: **Ein Untertitel übersteht einen Sprung.** Die Disc irgendwohin zu schicken -- `discSearch`, `discSkipForward`, ein Einzelschritt, eine Frame-Datei, die in ein anderes Segment übergeht -- bringt beim nächsten gezeichneten Frame den Cue auf den Bildschirm, der die Stelle abdeckt, an der sie gelandet ist, vorwärts wie rückwärts. Nichts muss neu geladen und keine Uhr zurückgesetzt werden. Zweitens: **Die Datei muss nach der Disc getaktet sein, nicht nach der Uhr.** Eine `.srt`, die für ein Video mit einer anderen Bildrate erstellt wurde, landet an der falschen Stelle, und eine, die geladen wird, bevor die Disc geöffnet ist, lädt überhaupt nicht. Lade Untertitel nach der Disc, was bei einem gewöhnlichen Spiel heißt: aus dem Hauptteil des Skripts. Ein Cue wird angezeigt, solange der Frame der Disc zwischen seinen beiden Frames liegt, beide eingeschlossen. Der Untertitel wird nur dann neu gezeichnet, wenn sich der Cue ändert, Untertitel kosten also nichts, solange eine Zeile steht. [#_styling] ==== Gestaltung Die Zeilen sind ein RmlUi-Dokument, keine geblittete Textzeile, und werden deshalb so gestaltet wie eine GUI. Zwei Dateien werden im `Singe`-Ordner mitgeliefert und beim ersten Start neben den anderen entpackt: * `Singe/subtitle.rml` -- das Dokument. Es hat ein einziges Element, `#slot`, in das die Engine den aktuellen Cue oder das aktuelle Banner schreibt. * `Singe/subtitle.rcss` -- das Aussehen. `.cue` ist der Block, der die von `srtPosition` angeforderte Höhe als inline gesetztes `top` trägt; `.cue .text` ist der Textbalken darin, standardmäßig weiß auf einem dunklen, abgerundeten Feld, das sich an den Text schmiegt, statt sich über das ganze Bild zu spannen. Beide werden bei jedem Start neu geschrieben, wie alles im Ordner `Singe`, Änderungen daran halten also nicht lange. Um das Aussehen zu ändern, liefert ein Spiel eigene Kopien aus: `subtitle.rml` neben seinem Skript, dessen Link auf ein eigenes Stylesheet zeigt (etwa `subtitle.rcss` daneben), und Singe zeichnet dann dieses Dokument statt seines eigenen; der Programm-Trace meldet das. Die Engine setzt immer nur den Inhalt von `#slot` und das `top` von `.cue`, alles andere -- die Schriftart, die Größe, die Farben, das Feld, ein Rahmen, ein Schatten -- gehört also in das Stylesheet des Spiels und übersteht ein Engine-Update. Das Dokument wird über das Bild gezeichnet, über dem Overlay und unter den GUIs im Bildschirmraum, folgt dem Videorechteck, wohin auch immer `--scalefactor`, `--shiftx`, ein Bezel-Ausschnitt oder `--rotate` es setzen, und nimmt keine Mauseingaben an, ein Klick geht also direkt hindurch. <> verwendet dasselbe Dokument, sodass ein Banner und ein Untertitel gleich aussehen und ein Spiel, das eines von beiden umgestaltet, beide umgestaltet. Auf einem Rechner ohne GPU gibt es überhaupt keine GUIs; `srtLoad` parst die Datei trotzdem und antwortet wahrheitsgemäß, und es wird nichts gezeichnet. Eine Warnung weist einmal darauf hin. [#scenes3d] === 3D-Szenen Singe 3 zeichnet eine 3D-Szene zwischen dem Laserdisc-Video und dem 2D-Overlay. Alles, was du schon weißt, gilt weiterhin: Sprites, Text und das Overlay liegen über der Szene, und das Video scheint überall dort durch, wo der Hintergrund der Szene transparent ist. Ein Spiel, das nie eine 3D-Funktion aufruft, ist nicht betroffen, und ein Spiel ohne Disc (siehe <>) kann ganz und gar ein 3D-Spiel sein. Die folgenden Abschnitte bauen eine Szene in der Reihenfolge auf, in der du eine schreiben wirst: Knoten, dann Meshes und Materialien, Lichter, die Kamera, das Aussehen des Frames als Ganzes, Modelle und schließlich das, was auf all dem aufsetzt. Jeder hier genannte Aufruf hat einen Eintrag in der <>. [#_what_a_scene_is_made_of] ==== Woraus eine Szene besteht Alles in einer Szene ist ein *Knoten* in einem einzigen Baum, und Knoten `0` ist die Wurzel. Ein Knoten hat eine Position, eine Rotation und eine Skalierung relativ zu seinem Elternknoten, und er kann eine Sache tragen: ein Mesh mit einem Material, ein Licht, ein Sprite oder eine Textzeile. Ein geladenes Modell wird zu einem Teilbaum von Knoten unter einer Wurzel, sodass dieselben Aufrufe eine modellierte Figur und einen geskripteten Würfel gleichermaßen bewegen. Die Szene ist rechtshändig, mit +Y nach oben und -Z nach vorn, wie glTF und der Export von Blender. Die Einheiten sind die, die deine Modelle verwenden, und die Grundkörper nehmen dieselben Einheiten; die Physik (siehe <>) geht von Metern aus. Rotationen sind in Grad angegeben, als drei Euler-Winkel, die als Gieren, Nicken und Rollen angewendet werden, oder als Quaternion, wenn der Wert aus einem Modell stammt. Das eigene Vorne eines Knotens ist sein -Z: Die Kamera blickt daran entlang, Richtungs- und Spotlichter strahlen daran entlang, und `nodeLookAt` dreht es zu einem Punkt in der Welt. Es gibt zwei Wege, eine Szene zu füllen, frei kombinierbar: * *Aus dem Skript bauen*, mit den Grundkörper-Meshes (`meshBox`, `meshSphere`, `meshPlane`, `meshCylinder`, `meshCone`, `meshTorus`, `meshHeightmap`) oder deinen eigenen Vertexdaten (`meshNew`), dazu Materialien, Lichter und eine Kamera. * *Modelle laden*, im binären glTF-2.0-Format (`.glb`) mit `modelLoad`, und sie dann mit `modelInstance` so oft platzieren, wie du willst. Blender, Maya, 3ds Max und jede größere Engine exportieren `.glb`. Eine minimale Szene besteht aus einem Mesh, einem Licht und einer Kamera: [source,lua] ---- sceneEnable(true) sceneSetBackground(0, 0, 0, 0) -- Transparent: Die Disc ist hinter der Szene zu sehen. 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) -- Ein Grad pro Frame um die eigene Y-Achse. return OVERLAY_UPDATED end ---- `sceneEnable(true)` ist der eine Aufruf, den jedes 3D-Spiel macht, und auf einem Rechner, der kein 3D zeichnen kann, ist es der Aufruf, der scheitert (siehe <>). Ohne Kamera wird die Szene von `(0, 0, 5)` aus mit Blick auf den Ursprung betrachtet, und ohne einen Aufruf für den Hintergrund ist der Hintergrund der Szene undurchsichtig schwarz. [#nodes] ==== Knoten Die Transformation eines Knotens wird direkt gesetzt (`nodeSetPosition`, `nodeSetRotation`, `nodeSetQuaternion`, `nodeSetScale`) oder schrittweise verändert (`nodeMove`, `nodeRotate`), immer im Raum seines Elternknotens. `nodeSetParent` hängt einen Knoten um und behält dabei seine lokale Transformation; `nodeGetWorldPosition` liest, wo er gelandet ist, nachdem jeder Elternknoten mitgeredet hat. `nodeSetVisible` versteckt einen Knoten und alles unter ihm, und `nodeDelete` gibt ihn und seine Kinder frei, zusammen mit jedem Körper, Emitter oder Sound, der an ihnen hängt. Namen kommen aus Modellen: `nodeFind(name, root)` findet ein Gelenk oder ein Teil über den Namen, den es in der Datei hatte, unter der als `root` angegebenen Instanz. Auch ein geskripteter Knoten kann einen Namen bekommen (`nodeSetName`), und diese Namen durchsucht `nodeFind` ohne Wurzel. [source,lua] ---- -- Ein Turm auf einem Panzer: Das Rohr ist ein Kind des Turms, der Turm eines der Wanne, -- sodass das Drehen des Turms das Rohr mitnimmt und das Bewegen der Wanne beides. 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) -- Den Zylinder entlang -Z legen. function onOverlayUpdate() local stick = controllerGetAxis(0, GAMEPAD_AXIS_RIGHT_X) / 32767 -- -1 bis 1. nodeRotate(turret, 0, stick * 2, 0) return OVERLAY_NOT_UPDATED end ---- Zwei Aufrufe verbinden die Szene und das Overlay. `sceneProject(x, y, z)` wandelt einen Weltpunkt in Overlay-Koordinaten um, sodass ein Namensschild oder ein Lebensbalken, der mit `fontPrint` gezeichnet wird, einem 3D-Objekt folgen kann. `sceneUnproject(sx, sy, distance)` geht den anderen Weg: der Weltpunkt so weit entlang des Strahls durch ein Overlay-Pixel, was zusammen mit `physicsRaycast` Mausauswahl ergibt. [source,lua] ---- function onMouseMoved(x, y, dx, dy, which) -- Die Maus zeigt entlang eines Strahls von der Kamera; was er trifft, ist das Ziel. 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] ==== Meshes und Materialien Ein Mesh ist Geometrie; ein Material ist, wie es aussieht; ein Knoten verbindet je eines davon (`nodeSetMesh(node, mesh, material)`). Teile beide nach Belieben: Ein `meshBox` und ein Material können auf tausend Knoten sitzen, und genau dieses Teilen macht den Renderer schnell (siehe <>). `meshNew` baut ein Mesh aus Tabellen von Positionen, Normalen, Texturkoordinaten und Indizes, wenn die Grundkörper nicht genügen. Ein Material beginnt als Grundfarbe mit Alpha (`materialSetColor`) und dem Metallic-Roughness-Look von glTF: `materialSetMetallic` und `materialSetRoughness` von `0` bis `1`. Eine Rauheit nahe `0` ergibt ein spiegelscharfes Glanzlicht und `1` eine matte Oberfläche; ein Metall färbt seine Glanzlichter und Spiegelungen mit seiner eigenen Farbe und hat keine eigene diffuse Farbe. Darüber hinaus: * `materialSetEmissive` lässt eine Oberfläche Licht in ihrer eigenen Farbe abgeben (ein Bildschirm, eine Lampe, heißes Metall); es beleuchtet keine anderen Dinge, erzeugt aber Bloom (siehe <>). * `materialSetUnlit` zeigt die Farbe, wie sie ist, und ignoriert jedes Licht: HUD-Elemente in der Szene, das eingebaute Leuchten des Glases einer Glühbirne. * `materialSetDoubleSided` zeichnet die Rückseite offener Formen, etwa einer von unten gesehenen Ebene oder einer Fahne. * `materialSetBlend` lässt das Alpha zählen, für Glas, Geister und Wasser. Überblendete Meshes werden nach allem Undurchsichtigen gezeichnet, nach Entfernung sortiert, und werfen keine Schatten. * `materialSetCutoff` behält nur die Texel, deren Alpha den Schwellwert erreicht, und verwirft den Rest; so werden Blätter, Zäune, Gitter und Maschendraht gezeichnet: ein Quad, das meiste davon weg. Anders als das Überblenden braucht es keine Sortierung und wirft trotzdem einen Schatten, und der Schatten wird ebenfalls ausgeschnitten, sodass ein Baum Blätterschatten wirft statt des Schattens seines Quads. Was den Schwellwert übersteht, ist undurchsichtig: Ein halbtransparentes Texel wird entweder ganz behalten oder verworfen, nie überblendet, sodass nichts hinter der Szene (am sichtbarsten das Bild der Disc) ein Blatt einfärbt. Ein Schwellwert von 0 (die Voreinstellung) schaltet die Maskierung ab. Ein aus glTF geladenes Modell bringt seinen eigenen mit: `MASK` setzt den Schwellwert aus der Datei, `BLEND` schaltet das Überblenden ein, und `OPAQUE` tut keines von beiden. Die Textur eines Materials kann das Bild eines beliebigen geladenen Sprites sein (`materialSetTexture`), die Laserdisc selbst oder ein geladenes Video (`materialSetVideo`), das Bild einer zweiten Kamera (`materialSetView`, siehe <>) oder ein GUI-Dokument (`materialSetGui`, siehe <>). Ein modellierter Arcade-Automat kann die Disc auf seinem eigenen Bildschirm abspielen oder dort seine Optionsseite zeigen. Texturkoordinaten kommen aus dem Mesh; die Grundkörper bilden ein Bild einmal über jede Fläche ab, und `materialSetTiling` wiederholt es, was Boden und Wände wollen. Texturen haben Mipmaps und werden anisotrop gefiltert, sodass ein Boden bis in die Ferne scharf bleibt; `materialSetFilter(material, FILTER_NEAREST)` hält Pixel-Art stattdessen blockig. [source,lua] ---- local stone = materialNew() materialSetTexture(stone, spriteLoad(DIR .. "flagstones.png")) materialSetTiling(stone, 12, 12) -- Zwölf Wiederholungen über die Ebene. materialSetRoughness(stone, 0.9) local floor = nodeNew() nodeSetMesh(floor, meshPlane(24, 24), stone) local glass = materialNew() materialSetColor(glass, 180, 220, 255, 90) -- Alpha 90 von 255. materialSetBlend(glass, true) materialSetRoughness(glass, 0.05) ---- Vier weitere Maps geben einer Oberfläche Details, die ihrer Geometrie fehlen, und ein geladenes Modell bringt sie mit, wenn seine Datei sie hat: * `materialSetNormalMap` beugt die Beleuchtung mit einer Normal-Map im Tangentenraum, sodass Ziegel aus einer flachen Wand hervortreten. Eine Stärke über `1` übertreibt den Effekt. * `materialSetOcclusionMap` verdunkelt Ritzen, die das Umgebungslicht und der Himmel nicht erreichen. * `materialSetMetallicRoughnessMap` variiert Metallic und Roughness über die Oberfläche, gepackt auf die glTF-Art (Roughness in Grün, Metallic in Blau) und mit den eigenen Faktoren des Materials multipliziert; setze diese also auf `1`, wenn die Map bestimmen soll. * `materialSetEmissiveMap` sagt, welche Teile leuchten, eingefärbt und skaliert durch `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) -- Der Mörtel leuchtet orange. ---- .Komprimierte Texturen Ein PNG wird auf der GPU zu vier Bytes pro Texel decodiert, plus ein weiteres Drittel für seine Mipmaps; ein Level von der Größe von Sponza belegt auf diese Weise 390 MB an Texturen, mehr, als ein Raspberry Pi 4 mit seiner Anzeige teilt. Texturen, die als KTX2-Dateien ausgeliefert werden (Basis Universal, erzeugt von `util/compressTextures.py` aus PNGs oder aus einem ganzen `.gltf`), bleiben auf der GPU komprimiert, bei einem Viertel der Größe oder weniger, in dem Blockformat, das der Rechner hat: BC7 auf Desktops, ASTC auf Apple-GPUs und dem Pi 4, ETC2 auf jedem Chip mit OpenGL ES 3 (dem Mali jedes Handhelds, dem Pi unter GLES), BC3 auf älteren Karten und einfaches RGBA, wo es nichts anderes gibt, alles aus der einen Datei. Gib `materialSetTexture` und den Map-Aufrufen einen `.ktx2`-Namen anstelle eines Sprites, und ein Modell, dessen Datei `KHR_texture_basisu` verwendet, lädt sie von selbst. `sceneGetStats` meldet den belegten Texturspeicher, sodass du den Unterschied sehen kannst. [source,lua] ---- materialSetTexture(ground, DIR .. "flagstones.ktx2") local draws, drawn, calls, textureKB = sceneGetStats() debugPrint("textures: " .. textureKB .. " KB") ---- [#_lights_and_shadows] ==== Lichter und Schatten Bis zu acht Lichter leuchten gleichzeitig, und jedes ist ein Knoten, sodass Lichter sich wie alles andere an Eltern hängen und animieren lassen: * `LIGHT_DIRECTIONAL` ist eine Sonne: Nur ihre Richtung zählt, und alles wird gleich beleuchtet. Eine Intensität von `1` beleuchtet eine ihr zugewandte weiße Fläche weiß. * `LIGHT_POINT` ist eine Glühbirne, die mit der Entfernung schwächer wird, und `lightSetRange` stoppt sie jenseits einer Entfernung ganz. Ihre Intensität ist ihre Helligkeit an der Glühbirne selbst, die Hälfte davon in einer Einheit Abstand; eine Glühbirne, die einen Raum beleuchten soll, braucht also eine Zahl im zweistelligen Bereich. * `LIGHT_SPOT` ist ein Kegel mit einem inneren Winkel, der voll beleuchtet ist, und einem äußeren Winkel, an dem das Licht zu nichts verblasst ist (`lightSetCone`). `sceneSetAmbient` fügt ein wenig Licht aus allen Richtungen hinzu, damit Schatten nicht schwarz sind; ein Himmel macht das besser (siehe <>). Farben, die als `0` bis `255` angegeben werden, sind sRGB, wie alles andere in Singe, und werden beim Hereinkommen in lineares Licht umgerechnet, sodass ein Licht von `128, 128, 128` ein Viertel so hell ist wie Weiß, nicht halb so hell. Jedes Licht kann weichkantige Schatten von allem Undurchsichtigen werfen (`lightSetShadow`). Richtungs- und Spotlichter werfen sie über eine Shadow-Map, Punktlichter über eine Cube-Map, die in jede Richtung reicht, sodass eine Glühbirne in dem sitzen kann, was sie beleuchtet (ein Raum, ein Lampenschirm, der Automat): Geschlossene Meshes werfen auch von ihren Rückseiten Schatten, und der Tiefenbereich des Schattens wird an die Schattenwerfer um das Licht herum angepasst. `nodeSetShadow(node, false)` befreit ein Mesh vom Schattenwerfen: das eigene Glas der Glühbirne, ein leuchtendes Schild, ein Namensschild. `sceneSetShadowSize` setzt die Auflösung der Map (`256` bis `4096`, Standard `1024`) und tauscht Schärfe gegen Geschwindigkeit. Der Schatten eines Richtungslichts wird entlang der Blickrichtung in Kaskaden aufgeteilt (`sceneSetShadowCascades`, standardmäßig drei), jede eine Map, die an einen Ausschnitt dessen angepasst ist, was die Kamera sieht, sodass Schatten in einem großen Level aus der Nähe scharf bleiben und trotzdem bis `sceneSetShadowDistance` reichen. Eine einzige Kaskade passt eine einzige Map an die ganze Szene an, was zu kleinen Szenen und orthografischen Kameras passt. Eine Ansicht (`viewNew`) zeichnet mit denselben Maps und nimmt für jeden Punkt die feinste Kaskade, die ihn abdeckt, sodass ein Monitor die Schatten zeigt, die das Fenster zeigt. [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) -- Drei Kaskaden bis 80 Einheiten. 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) -- Eine Cube-Map: sechs Durchgänge pro Frame. nodeSetShadow(bulbGlass, false) -- Das Glas um sie herum wirft nichts. ---- [#scenecamera] ==== Die Kamera und Ansichten Jeder Knoten kann die Kamera sein (`cameraSet`); sie blickt entlang ihres eigenen -Z, also richtet `nodeLookAt` sie aus, und unter einen bewegten Knoten gehängt wird sie zur Verfolgerkamera. `cameraSetPerspective(fovDegrees, near, far)` ist das Standardobjektiv; `cameraSetOrthographic(height, near, far)` zeichnet ohne Perspektive, für Diagramme, HUD-artige Szenen und Spiele in Seitenansicht. Halte `near` so groß, wie die Szene es zulässt, denn die Genauigkeit des Tiefenpuffers verteilt sich zwischen `near` und `far`. [source,lua] ---- local camera = nodeNew() nodeSetParent(camera, car) nodeSetPosition(camera, 0, 2.5, 7) -- Hinter und über dem Auto, in seinem Raum. nodeLookAt(camera, 0, 1, 0) cameraSet(camera) cameraSetPerspective(55, 0.1, 300) ---- Eine *Ansicht* ist eine zweite Kamera, die in jedem Frame in eine Textur gerendert und auf einem Material gezeigt wird: ein Überwachungsmonitor, ein Rückspiegel, ein Portal, ein Bild im Bild. `viewNew(width, height)` erzeugt eine in dieser Auflösung, `viewSetCamera` gibt ihr einen Kameraknoten, und `materialSetView` legt ihr Bild auf ein beliebiges Mesh. Ansichten teilen die Schatten des Frames und lassen Bloom aus, und jede rendert die Szene noch einmal; halte sie also wenige und klein. Jede Ansicht dreht Billboards (siehe <>) zu ihrer eigenen Kamera und zeichnet transparente Materialien von ihr aus von hinten nach vorn, sodass ein Monitor Namensschilder von vorn und Glas in der richtigen Reihenfolge sieht. [source,lua] ---- local rearCamera = nodeNew() nodeSetParent(rearCamera, car) nodeSetPosition(rearCamera, 0, 1.2, -2) nodeSetRotation(rearCamera, 0, 180, 0) -- Blick nach hinten. 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) -- Die Ebene zeigt nach oben; stell sie auf, dem Fahrer zugewandt. ---- [#scenelook] ==== Das Aussehen des Frames Die Szene wird in linearem Licht beleuchtet. Farben, die du als `0` bis `255` angibst, sind sRGB und werden beim Hereinkommen decodiert, Texturen werden von der GPU beim Abtasten decodiert, Lichter addieren sich ohne Abschneiden, und am Ende des Frames bringt eine Tonkurve das Ergebnis zurück auf den Bildschirm. Was das in der Praxis bedeutet: * `sceneSetExposure(stops)` hellt den ganzen Frame auf oder dunkelt ihn ab wie die Belichtung einer Kamera: `+1` verdoppelt das Licht, `-1` halbiert es, sodass eine Höhle und ein Strand dieselben Lichter und Materialien teilen können. * `sceneSetTonemap` wählt die Kurve: `TONEMAP_NEUTRAL` (die Voreinstellung, die Farben dort lässt, wo glTF-Viewer sie lassen, und nur das hellste Licht komprimiert), `TONEMAP_ACES` (ein filmischer Look mit mehr Kontrast) oder `TONEMAP_NONE` (bei Weiß abschneiden). * `sceneSetBloom(threshold, strength)` lässt alles leuchten, was heller als der Schwellwert ist: die Sonne durch ein Fenster, eine Glühbirne, ein selbstleuchtendes Schild, eine Flamme. Das Leuchten wird aus dem Licht der Szene vor der Tonkurve genommen, sodass nur, was wirklich hell ist, Bloom erzeugt; ein Schwellwert von `1` ist Weiß. .Der Himmel und sein Licht `sceneSetSky(file)` hüllt die Szene in ein äquirektanguläres Panorama. Ein Radiance-Bild `.hdr` behält seinen vollen Umfang; ein PNG oder JPEG funktioniert auch. Der Himmel wird hinter allem gezeichnet, und sofern nicht `sceneSetEnvironment(false)` gilt, beleuchtet er auch die Szene: Seine Gesamtfarbe aus allen Richtungen ersetzt das flache Umgebungslicht, und seine Spiegelung zeigt sich auf Metallen und glänzenden Oberflächen, scharf auf einem Spiegel und umso unschärfer, je höher die Rauheit. Ohne Himmel haben Metalle nichts zu spiegeln, und nur das Umgebungslicht und die Lichter formen sie. `sceneSetSkyIntensity` skaliert den Himmel und sein Licht zusammen, sodass ein Panorama für Mittag und Abenddämmerung dient. .Nebel `sceneSetFog(r, g, b, near, far)` blendet alles, Partikel eingeschlossen, zwischen zwei Entfernungen zu einer Farbe hin aus. Der Himmel wird nie vernebelt; passe also die Farbe des Nebels an den Horizont des Himmels an, und der ferne Rand der Szene verschwindet darin. [source,lua] ---- sceneSetSky(DIR .. "harbor.hdr") sceneSetSkyIntensity(0.8) sceneSetFog(190, 200, 215, 40, 160) -- Die Farbe des Himmels am Horizont. sceneSetExposure(-0.5) -- Etwas dunkler, als die Sonne es machen würde. sceneSetBloom(1.0, 0.35) -- Nur, was heller als Weiß ist, leuchtet. ---- [#_models_and_animation] ==== Modelle und Animation `modelLoad` nimmt eine `.glb` über denselben spielrelativen Namen, den `spriteLoad` nimmt, und eine gepackte `.game` trägt sie mit. Die Datei muss in sich abgeschlossen sein: Meshes, Skins, Animationen und Texturen alle in der einen Datei, was der Standardexport von Blender ist. Ein Modell, das auf eine `.bin` oder ein Bild daneben verweist, wird mit einer Meldung abgelehnt, die die Datei nennt, aus demselben Grund, aus dem ein gepacktes Spiel in sich abgeschlossen sein muss. Materialien verwenden das Metallic-Roughness-Modell mit `KHR_materials_emissive_strength` und `KHR_texture_basisu`; eine Textur, die über einen zweiten Satz Texturkoordinaten adressiert wird, liest den ersten Satz, und `KHR_texture_transform` wird ignoriert, jeweils mit einer Zeile im Trace des Programms. Ein Skin kann bis zu `128` Gelenke haben. Das Laden ist der teure Teil; `modelInstance` platziert dann eine Kopie des Knotenbaums des Modells, so oft du willst, und jede Instanz teilt die Meshes und Materialien der Datei. `modelDelete` gibt das Modell frei; Instanzen behalten ihre Knoten, nackt. [source,lua] ---- local foxModel = modelLoad(DIR .. "Fox.glb") local pack = {} for i = 1, 6 do local fox = modelInstance(foxModel) nodeSetScale(fox, 0.02) -- Die Datei ist in Zentimetern. nodeSetPosition(fox, i * 1.5 - 5, 0, 0) pack[i] = fox end ---- Die Animationen eines Modells listet `modelGetAnimations` auf, und gesteuert werden sie pro Instanz mit `animationPlay(node, nameOrIndex, loop, speed, fade)` auf dem Wurzelknoten der Instanz, dazu Aufrufe zum Pausieren, Fortsetzen, Stoppen und Springen. Knotenanimation (Dinge, die sich bewegen), Skinning (Figuren, die sich verformen) und Morph-Ziele (Blend Shapes: Gesichter, Lippensynchronität, Muskelwölbungen) funktionieren alle. Animationen schreiten mit der echten Zeit fort und halten an, während das Spiel pausiert ist. Ein Morph-Gewicht lässt sich auch aus dem Skript mit `nodeSetMorph` setzen, nach Name oder Nummer, auf dem Knoten, der das Mesh trägt. Ein `fade` in Sekunden blendet von dem über, was gerade lief, sodass ein Gehen ohne Ruck zu einem Laufen wird. Wenn nichts lief (nach `animationStop` oder wenn eine Ragdoll abgeschaltet wurde), beginnt die Überblendung bei der Pose, in der die Knoten sind, sodass sich eine Figur von dort aufrichtet, wo sie gefallen ist. Bis zu vier weitere Clips können gleichzeitig auf Ebenen über der Basis laufen (`animationPlayLayer`), jeder mit `animationSetLayerWeight` eingeblendet und mit `animationSetLayerMask` auf einen Teil des Skeletts beschränkt: ein Umsehen am Hals, während die Beine gehen, ein Winken mit einem Arm über einem Laufen. [source,lua] ---- animationPlay(fox, "Walk", true) animationPlayLayer(fox, 1, "Survey", true, 1, 0.5) -- Kopf dreht sich, über eine halbe Sekunde eingeblendet animationSetLayerMask(fox, 1, nodeFind("b_Neck_04", fox)) -- ... nur am Hals und darüber. 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) -- Schneller, in 0,4 s überblendet. end end ---- [#_sound_in_the_scene] ==== Sound in der Szene Ein Soundeffekt kann in der Szene platziert werden, sodass er von dort zu hören ist, wo er ist. `soundSetPosition(channel, x, y, z)` setzt einen spielenden Kanal an einen Weltpunkt, `soundSetNode(channel, node)` lässt ihn stattdessen einem Knoten folgen, und `soundSetRange(channel, near, far)` sagt, wo er in voller Lautstärke ist und wo er zu Stille verklungen ist. Sounds werden von der Kamera aus gehört, sofern `soundSetListener` nicht einen anderen Knoten nennt (den Kopf des Spielers, bei einem Third-Person-Spiel), und sie wandern nach links und rechts, wenn sich der Zuhörer dreht. Ein Kanal, der nicht in der Szene platziert ist, spielt wie immer, mit `soundSetPan` für Stereo. [source,lua] ---- local crackle = soundPlay(fireSound, -1) -- Endlosschleife. soundSetNode(crackle, brazier) soundSetRange(crackle, 2, 18) -- Voll innerhalb von 2 Einheiten, weg bei 18. soundSetListener(hero) ---- [#scenesprites] ==== Sprites und Text in der Szene Jeder Knoten kann statt eines Meshes ein Bild zeigen. `nodeSetSprite(node, sprite, height)` legt ein geladenes Sprite (jeden Frame eines animierten, wobei `nodeSetSpriteFrame` auswählt) auf ein Quad, das so viele Welteinheiten hoch ist, beleuchtet wie alles andere und Schatten werfend, sofern `nodeSetShadow` nichts anderes sagt. `nodeSetText(node, text, height)` tut dasselbe mit einer Textzeile in der gewählten Schriftart und Farbe, unbeleuchtet. Beide sind für den Rest der Engine Meshes: Sie hängen an Eltern, skalieren und nehmen das Überblenden eines Materials an. `nodeSetBillboard` dreht einen Knoten in jedem Frame zur Kamera: `BILLBOARD_ALL` frontal (Namensschilder, Leuchteffekte, Impostors), `BILLBOARD_Y` nur um seine eigene Hochachse (Bäume, Lebensbalken, alles, was aufrecht bleiben muss), und `BILLBOARD_NONE`, um aufzuhören. Es funktioniert auch bei Meshes. [source,lua] ---- local tag = nodeNew() nodeSetParent(tag, fox) nodeSetPosition(tag, 0, 110, 0) -- In den Zentimetern des Fox-Modells, über seinem Kopf. nodeSetScale(tag, 50) -- Die Skalierung 0,02 der Instanz aufheben. 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] ==== Vor dem Overlay Die Szene wird normalerweise unter dem Overlay gezeichnet, sodass alles, was auf das Overlay gezeichnet wird, sie verdeckt. `nodeSetLayer(node, LAYER_FRONT)` verschiebt einen Knoten und alles, was an ihm hängt, stattdessen in die vordere Ebene: Dieselbe Kamera zeichnet ihn nach dem Overlay, von denselben Lichtern beleuchtet und mit derselben Belichtung, derselben Tonkurve und demselben Bloom fertiggestellt, sodass ein drehendes Logo, ein 3D-Titel oder ein Modell auf einer Highscore-Tabelle über 2D-Grafik stehen kann. Die vordere Ebene ist ein eigenes Bild, transparent gelöscht, sodass das Overlay überall dort sichtbar ist, wo sie nichts zeichnet, und nichts in der Szene hinter dem Overlay sie verdecken kann. GUIs und die über das Overlay gezeichneten Partikel liegen weiterhin darüber. Vordere Knoten werfen weiterhin Schatten auf die Szene. Der Himmel, 3D-Partikel und Debug-Linien bleiben in der Szene, und Ansichten (`viewNew`) zeichnen nur die Knoten der Szene. Die Ebene kostet einen zweiten Renderdurchgang, und das nur in Frames, in denen etwas darin ist. [source,lua] ---- -- Eine goldene Münze, die sich über dem 2D-Titelbildschirm dreht. 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] ==== Gelände `meshHeightmap(image, sizeX, sizeY, sizeZ)` macht aus einem Graustufenbild hügeligen Boden: ein Vertex pro Pixel, Schwarz unten und Weiß `sizeY` hoch, mit Normalen aus den Steigungen und Texturkoordinaten über das Ganze. Gib seinem Material eine Textur und `materialSetTiling`, um sie alle paar Einheiten zu wiederholen. Bilder dürfen in jeder Richtung bis zu 1025 Pixel groß sein; eine Map von 257 Pixeln über 120 Einheiten ergibt einen Vertex pro halbe Einheit, was für Hügel reichlich ist. Ein `BODY_STATIC`-Körper mit `SHAPE_MESH` auf diesem Knoten wird zu einem Höhenfeld, weit billiger, als die Dreiecke es wären, sodass Autos darauf fahren und Spieler darauf gehen, so wie sie sind. `terrainGetHeight(node, x, z)` sagt, wie hoch der Boden an einem Punkt ist, um Dinge darauf zu platzieren, ohne einen Strahl zu werfen. [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) -- Ein Höhenfeld. 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] ==== Navigation Ein Navigationsmesh sagt Figuren, wo sie gehen können. `navNew(radius, height, maxSlope, maxStep)` beginnt eines für eine gegebene Agentengröße, `navAddNode` füttert es mit der Geometrie des Levels (die Wurzel einer Modellinstanz liefert das ganze Modell), und `navBuild` backt die begehbare Fläche: Böden, Rampen und Treppen, die der Agent erklimmen kann, abzüglich eines Rands um jede Wand und Säule. Sponza zu backen dauert auf einem Desktop eine Zehntelsekunde; `navSave` und `navLoad` bewahren ein gebackenes Mesh im Datenordner des Spiels auf, für Rechner, auf denen es länger dauert, und ein mit dem Spiel ausgeliefertes Mesh lädt genauso. `navDraw` zeigt das Mesh über der Szene, während du die Parameter richtig einstellst. Abfragen beantworten dann die Fragen, die ein Spiel stellt: `navPath` gibt die Ecken des kürzesten Wegs zwischen zwei Punkten zurück, `navNearest` setzt einen Punkt auf das Mesh, `navRaycast` sagt, ob ein gerader Weg blockiert ist, und `navRandomPoint` sucht irgendeinen Ort aus, zu dem man schlendern kann. Agenten (`navAgentNew`) laufen das Mesh für dich ab. `navAgentMoveTo` schickt einen irgendwohin, und er steuert unterwegs um die anderen Agenten herum, bewegt seinen Knoten (oder steuert einen Spieler-Controller darauf, mit `navAgentSetPlayer`) und ruft `onNavArrived` auf, wenn er ankommt. `navAgentGetVelocity` sagt dir, ob du das Gehen oder das Laufen abspielen sollst. [source,lua] ---- local nav = navNew(0.4, 1.6, 45, 0.3) -- Ein Agent 0,8 breit, 1,6 hoch, steigt 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)) -- Weiter zum nächsten Ort. end ---- [#sceneperformance] ==== Leistung und Anforderungen Die Szene braucht eine GPU, die Vulkan (Linux), Direct3D 12 (Windows 10 und neuer), Metal (macOS) oder OpenGL ES 3.1 spricht. Das ES-Backend ist es, das die billigen Linux-Handhelds und den Raspberry Pi hereinholt, deren Mali- und VideoCore-Chips einen ausgereiften GLES-Treiber haben und entweder gar kein Vulkan oder ein unausgereiftes; es wird automatisch gewählt, wenn keines der anderen drei verfügbar ist, und es zeichnet dasselbe Bild aus denselben Shadern. Der Raspberry Pi 4 bleibt der Mindest-Pi für 3D: Der Pi 3 hat nur OpenGL ES 2.0, das das Skinning und die Morph-Ziele der Szene nicht ausdrücken kann. Auf einem Rechner ohne eines davon laufen 2D-Spiele genau wie zuvor (2D-Physik eingeschlossen), und der erste 3D-Aufruf beendet das Spiel mit einem Fehler, der das Problem nennt. Meshes außerhalb des Blickfelds der Kamera werden übersprungen, und Kopien desselben Meshes mit demselben Material werden als ein instanzierter Aufruf gezeichnet, sodass ein Wald aus ein und demselben Baum etwa so viel kostet wie ein Baum. `sceneGetStats` meldet die gesammelten Zeichenvorgänge, wie viele davon im Blickfeld waren, wie viele Draw Calls das brauchte und den belegten Texturspeicher. So hilfst du nach: * Teile Meshes und Materialien zwischen Knoten (ein `meshBox`, viele Knoten; ein Modell, viele `modelInstance`-Aufrufe), statt für jedes Objekt eines zu erzeugen. * Geskinnte und morphende Meshes und überblendete werden einzeln gezeichnet; beschränke sie auf das, was die Szene braucht. * Kantenglättung (4x-Multisampling) ist überall eingeschaltet, wo die GPU sie anbietet; `sceneSetAntialias(false)` gewinnt auf einem Pi Geschwindigkeit zurück, ebenso ein kleineres `sceneSetShadowSize` und weniger schattenwerfende Punktlichter, von denen jedes sechs Durchgänge kostet. * Behalte auf kleinen Boards die Dreieckszahlen und Texturgrößen im Auge, liefere KTX2-Texturen aus und lade ein Modell einmal statt zweimal. [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] === Physik Körper, Gelenke, Trigger und Raycasts leben in der 3D-Szene und laufen auf Jolt Physics. Ein Körper hängt an einem Knoten, wie es ein Mesh tut, und von da an sind Knoten und Körper eins: Die Form des Körpers wird in Welteinheiten bemessen und vom Knoten skaliert, und welcher der beiden sich auch bewegt, der andere folgt. Die Einheiten sind Meter, Kilogramm und Sekunden, und die Schwerkraft ist standardmäßig `0, -9.81, 0`. Physik braucht keine GPU; siehe <>. [#_bodies] ==== Körper `bodyNew(node, type, shape, a, b, c)` gibt einem Knoten einen Körper von einem von drei Typen: * Ein *statischer* Körper (`BODY_STATIC`) bleibt, wo der Knoten war, als der Körper erzeugt wurde: Böden, Wände, der Automat. Seinen Knoten danach zu bewegen, bewegt den Körper nicht. * Ein *kinematischer* Körper (`BODY_KINEMATIC`) folgt seinem Knoten, wohin das Skript oder eine Animation ihn auch bewegt, und schiebt dynamische Körper aus dem Weg, ohne selbst geschoben zu werden: ein Schläger, eine bewegliche Plattform, die Hand einer Figur. * Ein *dynamischer* Körper (`BODY_DYNAMIC`) wird von der Simulation bewegt und treibt seinen Knoten: Er fällt, prallt ab, rutscht und wird geschoben. Bewege seinen Knoten nicht selbst; gib ihm stattdessen Geschwindigkeiten, Kräfte oder Impulse (`bodySetVelocity`, `bodyApplyForce`, `bodyApplyImpulse`). Die Form ist ein Quader, eine Kugel, eine Kapsel oder ein Zylinder, bemessen durch `a`, `b` und `c` (`SHAPE_BOX` nimmt Breite, Höhe und Tiefe; `SHAPE_SPHERE` einen Radius; `SHAPE_CAPSULE` und `SHAPE_CYLINDER` einen Radius und eine Höhe entlang Y), eine konvexe Hülle um das Mesh des Knotens (`SHAPE_HULL`, alles unter dem Knoten, also funktioniert die Wurzel einer Modellinstanz) oder die exakten Dreiecke des Meshes (`SHAPE_MESH`, nur für statische und kinematische Körper; auf einem Heightmap-Mesh ist es ein Höhenfeld, siehe <>). [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) -- Der kinematische Schläger fegt durch den Stapel. return OVERLAY_UPDATED end ---- .Masse und Materialien Die Masse eines dynamischen Körpers ergibt sich aus dem Volumen seiner Form bei der Dichte von Wasser, sodass ein Quader von der Größe einer Tür 140 Kilogramm wiegt und ein Stups ihn nicht bewegt. Setze `bodySetMass` bei allem, was sich leicht anfühlen soll, und bemiss Impulse nach der Masse: Ein Impuls von `mass * speed` setzt genau diese Geschwindigkeit. `bodySetFriction` und `bodySetBounce` machen Eis und Gummi; `bodySetEnabled` nimmt einen Körper aus der Welt und setzt ihn wieder ein, ohne ihn zu verlieren. .Zeitsteuerung Die Welt macht nach der Uhr feste sechzig Schritte pro Sekunde, nachdem Animationen ihre Knoten in Pose gebracht haben und bevor die Szene gerendert wird, sodass ein von einer Animation getriebener kinematischer Körper mit der richtigen Geschwindigkeit schiebt. Das Spiel zu pausieren pausiert die Physik; `physicsSetEnabled(false)` pausiert sie allein. `bodyIsResting` sagt, wann ein Körper zur Ruhe gekommen ist, also wann ein geworfenes Ding liegen geblieben ist und Punkte gezählt werden können. Ein Körper, eine Spielfigur, eine Ragdoll, ein Soft Body oder ein Fahrzeug, dessen Knoten gelöscht wurde, wird zu Beginn des nächsten Frames freigegeben, ob aktiviert oder nicht und ob pausiert oder nicht, sodass sein Platz wieder frei ist; ein Knoten-Handle, das `nodeNew` später neu vergibt, trägt nichts von der Physik des alten Knotens, und `playerExists` und seinesgleichen antworten dafür mit `false`, bis es eine eigene bekommt. [#_events_and_queries] ==== Ereignisse und Abfragen Definiere `onCollision(nodeA, nodeB, x, y, z, speed)`, und Singe ruft es für jeden neuen Kontakt einmal auf, mit dem Punkt und der Geschwindigkeit, mit der die beiden Körper aufeinandertrafen, sodass ein Rempler und ein Crash verschieden klingen können. Ein Körper, der mit `bodySetTrigger` zum Trigger gemacht wurde, schiebt nichts, meldet aber über `onTrigger(trigger, other, entered)`, was in ihn eintritt und ihn verlässt: eine Ziellinie, eine Tür, eine Gefahrenzone. `physicsRaycast` findet den ersten Körper entlang eines Strahls; mit `sceneUnproject` für den Strahl ist das die Objektauswahl mit der Maus (siehe <>). [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] ==== Gelenke `jointHinge`, `jointBall` und `jointSlider` verbinden zwei Körper oder einen Körper und die Welt, wenn der zweite Knoten `-1` ist: Türen, Pendel, Schubladen, die Räder eines von der Seite gesehenen Autos. Anker und Achse werden in Weltkoordinaten angegeben, dort, wo die Körper stehen, wenn das Gelenk erzeugt wird. Scharniere und Schieber nehmen Grenzen (`jointSetLimits`): Ein positiver Scharnierwinkel ist eine Drehung des ersten Knotens um die Achse nach der Rechte-Hand-Regel, und eine positive Schieberstrecke verläuft entlang der Achse. [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) -- Am Rahmen, um Y. jointSetLimits(hinge, 0, 110) -- Öffnet in eine Richtung, 110 Grad. ---- Körper auf Modellinstanzen funktionieren wie alle anderen: `bodyNew(instanceRoot, BODY_DYNAMIC, SHAPE_HULL)` hüllt das ganze Modell in eine konvexe Hülle. Eine geskinnte Figur ist am besten eine Spielfigur (siehe unten) oder eine kinematische Kapsel, die Dinge schiebt, statt geschoben zu werden. [#physics2d] ==== 2D-Spiele Physik ist nicht an die 3D-Szene gebunden. Knoten gibt es auf jedem Rechner, mit GPU oder ohne, also kann ein 2D-Spiel Knoten Körper geben, `physicsSet2D(true)` aufrufen, damit sie sich nur in X und Y bewegen und nur um Z drehen, in Overlay-Koordinaten arbeiten, wobei die Schwerkraft den Bildschirm hinunter zeigt, und Sprites dort zeichnen, wo die Knoten sind. Das ist die Form, die auf einem Raspberry Pi 3 läuft, der kein 3D hat. [source,lua] ---- physicsSet2D(true) physicsSetGravity(0, 900, 0) -- Pixel pro Sekunde zum Quadrat; Overlay-Y läuft nach unten. 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 ---- Gib jedem 2D-Körper etwas Tiefe (die dritte Größe), damit sich Formen in Z überlappen; `50` genügt. Kreise sind `SHAPE_SPHERE`. Spielfiguren und 2D-Emitter (siehe <>) funktionieren auch in dieser Welt, und ein Plattformspiel ist eine Spielfigur mit `playerMove(node, vx)`. [#_players] ==== Spielfiguren Ein Körper geht, wohin Kräfte ihn schicken; eine *Spielfigur* geht, wohin das Skript sagt. `playerNew` setzt einen Character-Controller auf einen Knoten: standardmäßig eine Kapsel (`playerNew(node, radius, height)`) oder jede konvexe `bodyNew`-Form (`playerNew(node, SHAPE_BOX, w, h, d)` für einen Roboter, `playerNew(node, SHAPE_SPHERE, r, 0)` für etwas, das rollt und trotzdem Stufen steigt, `SHAPE_HULL` für den eigenen Umriss des Modells; drei Argumente bedeuten immer eine Kapsel, also schreibt eine Kugel ihre Größen aus). Die Form steht auf dem Ursprung des Knotens, und der sind die Füße. Jeden Frame gibt das Skript `playerMove` die horizontale Geschwindigkeit, die es will, und der Controller läuft Böden entlang, steigt Stufen bis `playerSetStep` hinauf, gleitet an Wänden entlang, verweigert Hänge, die steiler als `playerSetSlope` sind, fällt unter der Schwerkraft, fährt auf beweglichen Plattformen mit und schiebt leichte dynamische Körper aus dem Weg (`playerSetPush`). `playerJump` funktioniert nur mit Boden unter den Füßen und sagt, ob gesprungen wurde. Die Drehung des Knotens gehört dem Skript: Dreh ihn so, dass er in seine Laufrichtung schaut, und häng das Modell als Kind darunter. [source,lua] ---- hero = nodeNew() nodeSetPosition(hero, 0, 0, 0) playerNew(hero, 0.3, 1.0) -- Eine Kapsel mit Radius 0.3, 1 hoch, Füße am Knoten. fox = modelInstance(modelLoad(DIR .. "Fox.glb")) nodeSetParent(fox, hero) function onInputPressed(switch) if switch == SWITCH_BUTTON1 then playerJump(hero, 4.5) -- Braucht Boden unter den Füßen. 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 ---- Spielfiguren melden `onCollision`, wenn sie einen Körper zu berühren beginnen, und `onTrigger`, wenn sie einen Sensor betreten oder verlassen, mit ihrem Knoten an der Stelle, die der Knoten eines Körpers einnähme, sodass eine Tür, ein Pickup und eine Lavagrube für eine Spielfigur genauso funktionieren wie für einen Ball. `playerIsOnGround` und `playerGetGround` sagen, worauf die Spielfigur steht, für Schrittgeräusche und um absichtlich von einer Plattform zu fallen. In einer 2D-Welt ist eine Spielfigur die Figur eines Plattformspiels: `playerMove(node, vx)` läuft entlang X, die Schwerkraft zeigt den Bildschirm hinunter, und alles andere ist gleich. Die Toleranzen des Controllers skalieren mit der Form, sodass sich eine 30 Pixel große Kapsel in einem Sprite-Spiel wie eine von 0,3 Metern in einem 3D-Spiel verhält. [#_vehicles] ==== Fahrzeuge Ein *Fahrzeug* ist ein dynamischer Körper (das Chassis, mit `bodyNew` erzeugt wie jeder Körper, mit Blick nach -Z und Y oben), den Jolt über Räder antreibt: `vehicleNew(node, VEHICLE_CAR)` für alles mit Achsen, `VEHICLE_MOTORCYCLE` für zwei Räder hintereinander, die sich in Kurven legen und balancieren, oder `VEHICLE_TANK` für Räder auf zwei Ketten, die auf der Stelle wenden. Räder sind Knoten: `vehicleAddWheel(node, wheelNode, radius, width, suspension)` nimmt die Position des Rads daher, wo sein Knoten relativ zum Chassis steht, und von da an stellt die Engine diesen Knoten jeden Frame (seine X-Achse ist die Radachse, also will ein `meshCylinder`-Rad ein um 90 Grad gedrehtes Kind). Räder, die in einem Modell mit `nodeFind("wheelFL", car)` gefunden werden, funktionieren genauso. Standardmäßig lenken die Vorderräder, und alle treiben an; `vehicleSetWheel` legt es anders fest. Jeden Frame übergibt das Skript die Eingaben des Fahrers: `vehicleDrive(node, forward, right, brake, handBrake)`. Motor, Automatikgetriebe und Differenziale sind die von Jolt mit den Standardwerten eines 1500-kg-Autos; `vehicleSetEngine`, `vehicleSetGears`, `vehicleSetSuspension`, `vehicleSetSteering`, `vehicleSetBrakes` und `vehicleSetAntiRoll` stimmen sie ab, und jede Änderung baut das Fahrzeug beim nächsten Schritt an Ort und Stelle neu auf. `vehicleGetSpeed`, `vehicleGetRpm` und `vehicleGetGear` speisen ein Armaturenbrett. Ein Fahrzeug ist immer noch ein Körper: Es springt über Rampen, prallt gegen Wände, schiebt Kisten und meldet `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 }, -- Vorne links und rechts ... { -0.95, -0.2, 1.4 }, { 0.95, -0.2, 1.4 }, -- ... hinten links und rechts. } 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 ---- Panzer nehmen `forward` als Gas und `right` als Drehung (ohne Gas drehen sie auf der Stelle) und ignorieren die Abstimmung von Lenkung, Bremsen und Stabilisator, da ihre Ketten selbst bremsen und lenken. Die Neigefeder des Motorrads ist auf das Chassis skaliert, sodass ein leichtes Motorrad genauso gut balanciert wie ein schweres. Fahrzeuge mit Rädern brauchen eine 3D-Welt; ein von der Seite gesehenes 2D-Auto ist ein Körper mit zwei per Scharnier befestigten Rädern, und das bauen die Gelenkaufrufe bereits. [#_water] ==== Wasser `bodySetWater(node, density, linearDrag, angularDrag)` füllt einen statischen Körper mit Wasser: Die Oberseite seines Quaders ist die Oberfläche, und jeder dynamische Körper darin schwimmt, sinkt oder rollt durch den Auftrieb von Jolt gegen diese Oberfläche (der eingetauchte Teil der Form zählt, sodass eine schräge Kiste sich aufrichtet), gebremst durch die Widerstände. `bodySetCurrent` gibt dem Wasser eine Strömung. `density` ist ein Faktor, `1` für Wasser, und das eigene `bodySetBuoyancy` jedes schwimmenden Körpers (Standard `1.2`, das sanft schwimmt; unter `1` sinkt er) skaliert ihn. Das Volumen ist ein Trigger, also feuert `onTrigger` beim Hinein- und Hinausgehen, für Spritzer. Wasser wird nicht gezeichnet: Gib dem Knoten des Volumens ein Material mit Blending oder keines. Eine Spielfigur im Wasser schwimmt: `playerIsSwimming` sagt es, `playerMove(node, vx, vy, vz)` steuert dann in drei Achsen, ohne vertikale Eingabe sinkt sie langsam (`playerSetSwim` setzt diese Rate und den Widerstand des Wassers), und die Strömung trägt sie mit. Ein Boot ist ein schwimmender Rumpf, dem `vehicleNew(node, VEHICLE_BOAT)` einen Propeller und ein Ruder gibt: `vehicleSetThrust` sagt, wie stark er schiebt und wo im Rumpf, `vehicleSetRudder`, wie stark es dreht, und `vehicleDrive` funktioniert wie bei einem Auto, alles nur, solange der Propeller unter einer Oberfläche ist. [source,lua] ---- pool = nodeNew() nodeSetMesh(pool, meshBox(8, 3, 12), blue) -- Blaues Material mit Blending, als Wasser gezeichnet. nodeSetPosition(pool, 0, -1.5, 0) -- Seine Oberseite bei Y = 0 ist die Oberfläche. bodyNew(pool, BODY_STATIC, SHAPE_BOX, 8, 3, 12) bodySetWater(pool, 1.0, 0.6, 0.2) bodySetCurrent(pool, 0.5, 0, 0) -- Treibt in Richtung +X. bodySetBuoyancy(crate, 1.6) -- Liegt hoch im Wasser. bodySetBuoyancy(anchor, 0.3) -- Geht unter. ---- [#_ragdolls] ==== Ragdolls `ragdollNew(node)` sucht unter einer Modellinstanz nach ihrem Skin und macht aus dem Skelett ein Ragdoll-Rezept: Jedes Gelenk mit einem Kindgelenk wird zu einer Kapsel entlang dieses Knochens, durch ein Swing-Twist-Gelenk mit vernünftigen Grenzen mit dem nächsten Knochen darüber verbunden, und Endgelenke (Finger, Ohren, die Schwanzspitze) bewegen sich mit ihren Eltern mit. Nichts bewegt sich bis `ragdollActivate`: Dann stoppt die Animation, Körper werden dort erzeugt, wo die Knochen sind, und von diesem Moment an treibt die Physik die Gelenkknoten, sodass das geskinnte Mesh zusammensackt, auf den Boden schlägt und mit dem Rest der Welt zur Ruhe kommt. `ragdollApplyImpulse` stößt einen Knochen über den Namen seines Gelenks an. `ragdollSetStrength` treibt jedes Gelenk zu der Pose hin, in der die Ragdoll aktiviert wurde, was einen schlaffen Sturz in ein Taumeln verwandelt oder eine am Boden liegende Figur ihre Gliedmaßen wieder einziehen lässt; einen Körper stellt es nicht auf, da sich nichts vom Boden abstößt. `ragdollDeactivate` gibt das Skelett an die Animation zurück, wo immer es liegt, und ein `animationPlay` mit Überblendung hebt es dann von dort sanft an. Stimme einen Knochen, der falsch aussieht, vor dem Aktivieren mit `ragdollSetJoint` ab. [source,lua] ---- fox = modelInstance(foxModel) ragdollNew(fox) -- Rezept aus dem Skin; noch animiert. function onCollision(a, b, x, y, z, speed) if (a == hero or b == hero) and speed > 3 then playerSetEnabled(hero, false) ragdollActivate(fox) -- Jetzt treibt die Physik das Skelett. ragdollApplyImpulse(fox, "b_Spine02_03", 40, 30, 0) end end ---- [#_soft_bodies] ==== Soft Bodies Stoff, Seil und Wackelpudding sind Meshes, deren Vertices simuliert werden. `softNew(node, SOFT_CLOTH)` macht das Mesh des Knotens zu Stoff und seine Vertices (nach Position verschweißt) zu Partikeln, verbunden durch Dehnungs-, Scher- und Biege-Constraints, dort, wo der Knoten es in die Welt gesetzt hat; ein unterteiltes `meshPlane(width, depth, columns, rows)` ist der übliche Anfang. `softNew(node, SOFT_BODY)` macht dasselbe mit einem geschlossenen Mesh, und `softSetPressure` bläst es auf: ein Ballon, ein Wackelpudding. `softNew(node, SOFT_ROPE, x, y, z, segments, radius)` legt eine Kette von Partikeln von der Position des Knotens bis zu diesem Punkt und gibt dem Knoten ein Röhren-Mesh um sie herum (sein Aussehen setzt du mit `nodeSetMaterial`). Soft Bodies kollidieren mit allem Starren, fallen unter der Schwerkraft und werden durch das Mesh gezeichnet, aus dem sie stammen und das die Engine jeden Frame neu schreibt. `softPin` hält das nächstgelegene Partikel fest, wo es ist oder an einem Knoten (die Kante der Flagge an der Stange, das obere Ende des Seils am Kran); `softSetStiffness`, `softSetMass` und `softSetDamping` stimmen das Material ab. Ein Knoten, dessen Mesh mit anderen Knoten geteilt wird, verändert diese mit, also gib einem Soft Body ein eigenes Mesh. [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) -- Hängt aufrecht. softNew(flag, SOFT_CLOTH) for _, y in ipairs({ 1.9, 2.15, 2.4, 2.65, 2.9 }) do softPin(flag, -5, y, 0) -- Entlang der Stange. end ---- Die Kosten wachsen mit der Partikelzahl: Ein Tuch von 20 mal 20 hat 441 Partikel und läuft gut auf einem Raspberry Pi 4; beschränke Stoff auf ein paar hundert Partikel und ein Seil auf ein Dutzend Segmente. Mit starren Körpern kollidieren die Partikel, nicht die Flächen, also hält erst ein ausreichend feines Mesh (Partikel näher beieinander als das kleinste Ding, das darauf landen wird) eine Kiste vom Durchfallen ab. Es gibt keine Selbstkollision: Stoff kann durch sich selbst hindurchgehen, wenn er zerknüllt wird. Geskinnte Meshes können keine Soft Bodies sein. [#_seeing_the_physics] ==== Die Physik sichtbar machen Wenn eine Kiste durch den Boden fällt oder ein Gelenk sich in die falsche Richtung biegt, zeichnet `physicsSetDebug` jeden Frame als Linien über die Szene, was die Simulation tatsächlich enthält: * `DEBUG_SHAPES`: die Körper als Drahtgitter, nach Zustand gefärbt (schlafend, bewegt, statisch), die Spielfiguren in Gelb. * `DEBUG_CONSTRAINTS`: die Gelenke, Ragdoll-Gelenke und Fahrzeug-Constraints mit ihren Grenzen. * `DEBUG_CONTACTS`: ein rotes Kreuz, wo sich Körper in diesem Schritt getroffen haben. * `DEBUG_VELOCITIES`: eine Linie entlang der Geschwindigkeit jedes bewegten Körpers. * `DEBUG_STATIC`: zusätzlich die Formen der statischen Körper, die sonst aus bleiben, weil ein Level-Mesh sehr viele Linien ergibt. Addiere sie, oder nimm `DEBUG_ALL` für die ersten vier. `DEBUG_NONE` schaltet es ab, und solange es aus ist, kostet es nichts. Ein Skript zeichnet seine eigenen Linien genauso mit `lineDraw` (einen Raycast, einen Pfad, eine Bounding Box), einen Frame nach dem anderen wie das Overlay, und `navDraw` zeigt ein Navigationsmesh. [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] ==== Wo sie läuft Die Physik läuft auf der CPU und braucht keine GPU, aber die Bibliothek ist für SSE4.1 auf x86 (2008 und später) und NEON auf dem Pi gebaut; ein Rechner darunter bekommt beim ersten `bodyNew` einen Fehler. Die Simulation nutzt alle Kerne bis auf einen, also rechnet ein Pi mit vier Kernen auf drei Threads; der Trace des Programms sagt, auf wie vielen. [#particles] === Partikel Funken, Rauch, Feuer, Regen, Staub, Magie: Alles, was aus vielen kleinen, kurzlebigen Dingen besteht, ist eine Aufgabe für einen *Emitter*. Ein Emitter hält ein Rezept (wie viele pro Sekunde, wie lange sie leben, wie schnell und in welche Richtung sie starten, was an ihnen zieht, wie sie wachsen, verblassen und sich drehen) und einen Pool von Partikeln, die er nach diesem Rezept laufen lässt. Das Skript setzt das Rezept einmal und startet dann den Emitter für einen gleichmäßigen Strom (`emitterStart`), lässt ihn für einen einmaligen Puff ausstoßen (`emitterBurst`) oder beides. Die Engine bewegt jedes lebende Partikel jeden Frame und hört damit auf, solange das Spiel pausiert ist. Eine einzige Emitter-API dient dem Overlay und der 3D-Szene; nur die Einheiten und das Zeichnen unterscheiden sich. Alles, was als Minimum und Maximum angegeben wird (`emitterSetLife`, `emitterSetSpeed`, `emitterSetSize`), wird für jedes Partikel zufällig zwischen den beiden gewählt. [#_2d_emitters] ==== 2D-Emitter `emitterNew()` ohne Argument erzeugt einen 2D-Emitter. Er lebt in Overlay-Koordinaten, Y nach unten, also ist die Schwerkraft eines Springbrunnens positives Y. Bewege ihn mit `emitterSetPosition` und zeichne ihn aus `onOverlayUpdate` mit `emitterDraw`. Das Overlay selbst ist ein Bild, das die CPU malt, also werden Partikel nicht hineingemalt: Die GPU zeichnet sie, entweder über dem ganzen Overlay (`PARTICLE_OVER`, der Standard) oder darunter und über dem Video und der 3D-Szene (`PARTICLE_UNDER`, für Rauch hinter der Punktzahl). 2D-Emitter laufen auf jedem Rechner, auf dem Singe läuft, den Raspberry Pi 3 eingeschlossen, und passen gut zu 2D-Physik: Lies die Knotenposition eines Körpers und gib sie an `emitterSetPosition`. [source,lua] ---- sparks = emitterNew() emitterSetBlend(sparks, PARTICLE_ADD) emitterSetLife(sparks, 0.3, 0.7) emitterSetSpeed(sparks, 80, 200) emitterSetDirection(sparks, 0, -1) -- Den Bildschirm hinauf. 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] ==== 3D-Emitter `emitterNew(node)` erzeugt einen an einen Knoten gehängten 3D-Emitter. Partikel entstehen an der Weltposition des Knotens (über `emitterSetRadius` verteilt), in Welteinheiten, und zeichnen sich in der Szene nach jedem Mesh als der Kamera zugewandte Billboards: gegen die Geometrie tiefengetestet, ohne je Tiefe zu schreiben, unbeleuchtet, sofern nicht verlangt, ohne Schatten zu werfen oder zu empfangen. Standardmäßig bleibt ein Partikel, sobald es den Emitter verlässt, dort, wo es entstanden ist (Rauch treibt weiter dort, wo er entstand, während die Fackel weiterzieht); `emitterSetLocal` lässt stattdessen die ganze Wolke dem Knoten folgen. Wird der Knoten gelöscht, werden seine Emitter mitgelöscht. [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] ==== Das Bild Ein Partikel ist ein texturiertes Quad. Ohne Textur ist es eine weiche Scheibe, die die Engine bereitstellt; `emitterSetTexture` gibt ihm stattdessen ein geladenes Sprite (einen Rauchpuff, einen Funkenstreifen, eine Schneeflocke), und bei einem animierten Sprite wählt `emitterSetFrames` für jedes Partikel einen zufälligen Frame, sodass ein Atlas von Puffs eine abwechslungsreiche Wolke ergibt. Das Blending ist normal (`PARTICLE_ALPHA`) oder additiv (`PARTICLE_ADD`, für alles, was leuchtet). `emitterSetColor` gibt Farbe und Alpha bei der Geburt und beim Tod an, und das Partikel geht im Lauf seines Lebens von einem zum anderen über; `emitterSetSize` tut dasselbe für seine Größe, mit einem dritten Wert dafür, wie schnell es sich dreht. [#_trails_collisions_lit_and_soft_particles] ==== Spuren, Kollisionen, beleuchtete und weiche Partikel `emitterSetTrail(emitter, length, width)` merkt sich die letzten paar Positionen jedes Partikels und zeichnet ein Band durch sie, das zum Ende hin verblasst: Funken, Leuchtspurgeschosse, Kometen, in 2D wie in 3D. `emitterSetCollide` lässt Partikel abprallen: von einem waagerechten Boden in einer Höhe, die du angibst (`COLLIDE_FLOOR`, kostenlos), oder von der Physikwelt per Raycasts (`COLLIDE_SCENE`, nur 3D, pro Frame gedeckelt, sodass ein großer Emitter die Kosten über einige Frames verteilt). Zwei weitere gibt es nur für 3D. `emitterSetLit` schattiert jedes Partikel als kleine, der Kamera zugewandte Kugel nach den Lichtern und dem Himmel der Szene, sodass Rauch die Farbe eines Kohlebeckens annimmt und Staub in der Sonne glitzert (Schatten werden nicht gelesen). `emitterSetSoftness` blendet ein Partikel über eine Strecke aus, wo es auf Geometrie trifft, sodass Rauch nicht in einen Boden schneidet und ein Springbrunnen nicht in sein Becken; das kostet einen zusätzlichen Tiefendurchgang der Szene pro Frame, solange irgendein Emitter es nutzt. [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) -- Nimmt die Farbe des Feuers von unten an. emitterSetSoftness(smoke, 0.4) -- Keine harte Kante, wo er auf die Schale trifft. emitterStart(smoke) embers = emitterNew(brazier) emitterSetBlend(embers, PARTICLE_ADD) emitterSetTrail(embers, 6, 0.02) -- Ein kurzer Streifen hinter jedem Glutfunken. emitterSetCollide(embers, COLLIDE_FLOOR, 0.3, 0.8, 0) -- Prallen vom Boden bei Y = 0 ab. emitterStart(embers) ---- [#_cost] ==== Kosten Partikel sind billig zu simulieren und kosten, was sie auf dem Bildschirm bedecken. Ein paar tausend kleine sind nichts; eine Handvoll großer additiver über dem ganzen Bildschirm ist das, was einen Raspberry Pi 4 ausbremst. Der Pool wird durch `emitterSetMax` gedeckelt (Standard 1000); wenn er voll ist, entstehen keine neuen, bis einige sterben. [#authoring] === Ein Spiel beschreiben statt es zu schreiben Ein Spiel kann als *Beschreibung* geschrieben werden -- eine Tabelle aus Ebenen, Entitäten und Regeln -- und von Forge, dem Autorenwerkzeug, in ein gewöhnliches Singe-Spiel kompiliert werden. Forge ist ein eigenes Projekt mit eigenem Download und eigenem Buch. [[migrating]] === Umstieg von Singe 2.10 Singe 3.00 hat das Sprite-Handle zum ersten Argument von `spriteDraw`, `spriteLoop`, `spriteQuality`, `spriteRotate`, `spriteRotateAndScale`, `spriteScale` und `spriteSetFrame` gemacht, sodass jeder Sprite-Aufruf jetzt zur `video*`-Familie passt. Um ein Spiel anzupassen, verschiebe das letzte Argument jedes dieser Aufrufe nach vorn: [source,lua] ---- spriteDraw(x, y, cursor) -- 2.10 spriteDraw(cursor, x, y) -- 3.00 spriteRotate(angle, cursor) -- 2.10 spriteRotate(cursor, angle) -- 3.00 ---- Ein Spiel, das du nicht bearbeiten kannst, kann sich stattdessen für die alte Reihenfolge entscheiden. Setze entweder die globale Variable, bevor das Framework geladen wird: [source,lua] ---- SINGE_LEGACY_SPRITE_ARGS = true dofile("Singe/Framework.singe") ---- oder füge `LEGACY_SPRITE_ARGS = true` zu seinem Eintrag in `games.dat` hinzu. An keiner anderen Aufrufform hat sich etwas geändert, wohl aber an einigen Verhaltensweisen: * Handles und Anzahlen werden als Lua-Ganzzahlen zurückgegeben; `videoIsPlaying` gibt einen Boolean zurück; `mouseSetMode` gibt nichts zurück. * Fehler in Callbacks beenden das Spiel jetzt mit einem Traceback, statt ausgegeben und ignoriert zu werden, so wie sich Argumentfehler schon immer verhalten haben. * Gehaltene Tasten wiederholen sich in `MODE_NORMAL` nicht mehr. * Neben das Spiel wird nichts geschrieben. Ohne `--datadir` ist der Datenordner `data//` unter dem Spieleverzeichnis, wo 2.x den eigenen Ordner des Spiels benutzte; Spielstände, Einstellungen und Highscores gehören unter `singeGetDataPath()`. * `math.randomseed` akzeptiert einen gebrochenen Startwert, wie unter dem Lua von 2.10; er wird abgerundet, bevor der Generator von Lua 5.4 ihn sieht. `os.clock` ist die Zeit nach der Uhr seit dem Start der Engine, nicht die Prozessorzeit. * Die Disc wird von der Engine auf Frame 1 geparkt, bevor dein Skript läuft; `Framework.singe` springt beim Laden nicht mehr. * `onMouseMoved` übergibt im Modus `MANY_MOUSE` im dritten und vierten Argument die echte Bewegung, und `onControllerMoved` übergibt den Controller-Index (`0` bis `3`) statt der Instanz-ID von SDL. * `mouseEnable` / `mouseDisable` und `singeEnablePauseKey` / `singeDisablePauseKey` funktionieren weiterhin, als Aliase von `mouseSetEnabled` und `singeSetPauseKeyEnabled`. * Eine Laserdisc wird nicht mehr vorausgesetzt. Ein Eintrag in `games.dat` mit einem `VIDEO` ist wie bisher ein Laserdisc-Spiel; einer ohne läuft stattdessen auf einer Zeichenfläche. Auf der Befehlszeile übergib `--disc` (oder `--framefile`), da ein neben dem Skript gefundenes Video keine Disc mehr bedeutet. Siehe <>. * Die Pausentaste pausiert jetzt das ganze Spiel, nicht nur die Medien: Das Skript ist eingefroren, bis die Taste erneut gedrückt wird, und `SWITCH_PAUSE` wird nur dann an das Skript geliefert, wenn die Taste deaktiviert wurde. Sie wirkt beim Drücken der Taste statt beim Loslassen. Siehe <>. * `fontPrint` behält die Strings, die es rendert, und `overlayClear` füllt nur neu, was seit dem letzten Löschen gezeichnet wurde, sodass ein jeden Frame neu ausgegebenes HUD billig ist. Text aus Performancegründen mit `fontToSprite` zu rendern ist veraltet; es funktioniert weiterhin und ist immer noch der Weg zu Text, den du messen, skalieren oder drehen kannst. Siehe <>. [#_engine_constants] === Engine-Konstanten Die Engine definiert diese globalen Variablen, bevor irgendein Skript läuft, sodass sie `controls.cfg` und `Framework.singe` gleichermaßen zur Verfügung stehen: [cols="2,3",options="header"] |=== | Konstante | Bedeutung | `SWITCH_UP` ... `SWITCH_RECORD` | Werte, die in `MODE_NORMAL` an `onInputPressed` / `onInputReleased` übergeben werden. `SWITCH_MOUSE_DISCONNECT` löst die Engine selbst aus, wenn eine Maus oder Lightgun abgezogen wird, einmal als Drücken und einmal als Loslassen; keine Taste und kein Knopf erzeugt ihn. `SWITCH_RECORD` ist der Aufnahmeschalter, auf den die Engine selbst reagiert, wie auf den Screenshot-Schalter. | `FONT_QUALITY_SOLID`, `FONT_QUALITY_SHADED`, `FONT_QUALITY_BLENDED` | Argumente für `fontQuality`. | `MODE_NORMAL`, `MODE_FULL` | Argumente für `keyboardSetMode`. | `MOUSE_SINGLE`, `MOUSE_MANY` (auch `SINGLE_MOUSE`, `MANY_MOUSE`) | Argumente für `mouseSetMode`. | `OVERLAY_NOT_UPDATED`, `OVERLAY_UPDATED` | Rückgabewerte für `onOverlayUpdate`. | `RENDER_PIXELATED`, `RENDER_SMOOTH` | Argumente für `spriteQuality` / `videoQuality`. | `SPRITE_ALPHA`, `SPRITE_ADD`, `SPRITE_MULTIPLY` | Blending für `spriteSetBlend`. | `DISC_STOPPED`, `DISC_PLAYING`, `DISC_PAUSED`, `DISC_EJECTED` | Rückgabewerte von `discGetState`. | `LIGHT_DIRECTIONAL`, `LIGHT_POINT`, `LIGHT_SPOT` | Argumente für `lightNew`. | `BODY_STATIC`, `BODY_DYNAMIC`, `BODY_KINEMATIC` | Körpertypen für `bodyNew`. | `SHAPE_BOX`, `SHAPE_SPHERE`, `SHAPE_CAPSULE`, `SHAPE_CYLINDER`, `SHAPE_HULL`, `SHAPE_MESH` | Kollisionsformen für `bodyNew`. | `JOINT_HINGE`, `JOINT_BALL`, `JOINT_SLIDER` | Gelenktypen (die `joint*`-Aufrufe nennen sie direkt). | `DEBUG_NONE`, `DEBUG_SHAPES`, `DEBUG_CONSTRAINTS`, `DEBUG_CONTACTS`, `DEBUG_VELOCITIES`, `DEBUG_STATIC`, `DEBUG_ALL` | Was `physicsSetDebug` zeichnet; addiere sie. | `PARTICLE_ALPHA`, `PARTICLE_ADD` | Blending für `emitterSetBlend`. | `PARTICLE_OVER`, `PARTICLE_UNDER` | Wo 2D-Partikel gezeichnet werden, für `emitterSetLayer`. | `COLLIDE_NONE`, `COLLIDE_FLOOR`, `COLLIDE_SCENE` | Wovon Partikel abprallen, für `emitterSetCollide`. | `BILLBOARD_NONE`, `BILLBOARD_ALL`, `BILLBOARD_Y` | Wie ein Knoten sich der Kamera zuwendet, für `nodeSetBillboard`. | `LAYER_SCENE`, `LAYER_FRONT` | Ob ein Knoten unter dem Overlay oder davor gezeichnet wird, für `nodeSetLayer`. | `VEHICLE_CAR`, `VEHICLE_MOTORCYCLE`, `VEHICLE_TANK`, `VEHICLE_BOAT` | Fahrzeugarten für `vehicleNew`. | `SOFT_CLOTH`, `SOFT_BODY`, `SOFT_ROPE` | Arten von Soft Bodies für `softNew`. | `FILTER_LINEAR`, `FILTER_NEAREST` | Texturabtastung für `materialSetFilter`. | `TONEMAP_NEUTRAL`, `TONEMAP_ACES`, `TONEMAP_NONE` | Tonkurven für `sceneSetTonemap`. | `SOUND_ERROR_INVALID`, `SOUND_REMOVE_HANDLE` | `-1`, was `soundPlay` zurückgibt, wenn kein Kanal frei ist. | `SINGE_VERSION_MAJOR`, `SINGE_VERSION_MINOR`, `SINGE_VERSION_STRING`, `SINGE_FRAMEWORK_VERSION` | Die Engine-Version, als Ganzzahlen, als String (`"v3.00"`) und als die Zahl, die `singeVersion()` zurückgibt. | `SINGE_DEAD_ZONE` | Die `DEAD_ZONE` aus `controls.cfg`, in rohen Achseneinheiten. | `SINGE_TRIGGER_THRESHOLD` | Die Schwelle der analogen Trigger in denselben rohen Einheiten; `0`, wenn die Trigger auf `SINGE_DEAD_ZONE` liegen, was der Standard ist. | `SINGE_LEGACY_SPRITE_ARGS` | Wahr, wenn das Spiel die Sprite-Argumentreihenfolge von 2.10 verlangt hat. | `SINGE_DISC` | Wahr, wenn das Spiel eine Laserdisc hat; falsch, wenn die Zeichenfläche die Welt ist. | `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` | Aufbau der Eingabecodes für Controller und Maus und wie viele Achsen und Knöpfe ein Pad hat; `Framework.singe` baut daraus die Tabellen `GAMEPAD_N` und `MOUSE_N`, und ein Servicebildschirm geht damit ein Pad durch. | `SINGE_SCALE_MIN`, `SINGE_SCALE_MAX`, `SINGE_SHIFT_MIN`, `SINGE_SHIFT_MAX`, `SINGE_ROTATE_STEP` | Was `vldpSetScale`, `vldpSetShift` und `vldpSetRotate` annehmen. Begrenze auf diese Werte statt auf eigene Kopien: Eine Verschiebung außerhalb des Bereichs beendet das Skript. |=== `Framework.singe` fügt die Tabellen `SCANCODE` und `MODIFIER` hinzu (die Tasten- und Modifikatorwerte von SDL), `GAMEPAD_0` bis `GAMEPAD_3`, `MOUSE_0` bis `MOUSE_3`, die Indizes `GAMEPAD_AXIS_*` für `controllerGetAxis` und `DIR`. [#_included_libraries] === Mitgelieferte Bibliotheken Zusätzlich zu den Standardbibliotheken von Lua und der Singe-API stehen die folgenden Bibliotheken in Singe-Programmen zur Verfügung, ohne dass zusätzliche Software installiert werden muss: [cols="1,3",options="header"] |=== | Bibliothek | Zweck | Copas | Asynchrone Netzwerkkommunikation | LuaFileSystem | Erweiterte Dateisystemunterstützung | json.lua | Kodieren und Dekodieren von JavaScript Object Notation | binaryheap | Datenstruktur Binärheap | timerwheel | Effiziente Timer-Planung | LuaSec | TLS-/SSL-Kommunikation | LuaSocket | TCP und UDP | LuaRS232 | Zugriff auf serielle RS232-Schnittstellen | lsqlite3 | SQLite-Datenbanken, `require("sqlite3")` | md5 | `require("md5")` für `sum`, `hexsum`, `exor`, `crypt` und `decrypt`; die PUC-Rio-Bibliothek, die Hypseus mitliefert, deren `sumhexa` sie `hexsum` nennt | LuaBitOp | `require("bit")` für die bitweisen Operationen von Lua 5.1, die 5.4 als Operatoren hat | lrandom | `require("random")` für einen Mersenne-Twister-Generator, getrennt von `math.random` | Lua CJSON | `require("cjson")` zum Kodieren und Dekodieren von JSON, neben dem Modul `json` oben | LPeg | `require("lpeg")` für Parsing Expression Grammars | middleclass | `require("middleclass")` für Klassen, falls du Entitäten lieber daraus baust als aus Tabellen | lume | `require("lume")` für die kleinen Dinge, die jedes Spiel braucht: `lerp`, `clamp`, `round`, `shuffle`, `split`, `serialize`, `weightedchoice` | inspect | `require("inspect")`, um eine Tabelle in einer Form auszugeben, die ein Mensch lesen kann, für den Fall, dass `debugPrint` der Debugger ist | bump | `require("bump")` für Dinge in Bewegung, die nicht durcheinander hindurchgehen dürfen -- es beantwortet, wo ein bewegtes Ding landet, was <> absichtlich nicht tun | RmlUi | Das eigene Objektmodell der GUI, die globale Variable `rmlui` (siehe <>) |=== Ihre Verwendung geht über den Rahmen dieses Dokuments hinaus. Die Bibliotheken md5, LuaBitOp, lrandom, Lua CJSON und LPeg sind dabei, weil Hypseus sie anbietet, sodass ein gegen Hypseus geschriebenes Spiel dieselben Module unter denselben Namen findet; nichts in Singe braucht sie. [#audiosync] === Audio-Synchronisation Singe zeigt Video nach der Audio-Uhr an: Ein Frame wird gezeigt, wenn der Ton zu hören ist, der zu ihm gehört. Die Engine misst beim Start ihren eigenen Audioweg. SDL kann nicht melden, wie viel Audio zwischen Mixer und Lautsprecher in der Warteschlange steht, also leert Singe das Gerät einmal, bevor irgendetwas Hörbares spielt, und zählt den Schub von Puffern, mit dem SDL es wieder auffüllt; dieser Schub ist die Tiefe der Warteschlange, und die Uhr zieht sie ab. Starte mit `--program`, um die Messung im Trace zu sehen (`Audio device queue: ...`). Was die Engine nicht sehen kann, ist alles hinter dem Audioserver des Betriebssystems: ein DAC, ein HDMI-Receiver, eine Bluetooth-Verbindung. Diese fügen eine feste Verzögerung hinzu, die von Rechner zu Rechner verschieden ist. Wenn Aktionen auf dem Bildschirm sichtbar vor ihrem Ton kommen, übergib die fehlenden Millisekunden mit `--audiodelay=MS`, trag `AUDIO_DELAY = MS` in `games.dat` ein, oder lass das Spiel den Wert aus einem Service-Menü mit <> setzen. Positive Werte bedeuten, dass der Ton zu spät kommt; das Video wird passend zurückgehalten. Ein negativer Wert deckt den seltenen Fall ab, dass der Ton zu früh ankommt. [#menucalibration] ==== Kalibrieren über das Menü Das mitgelieferte Menü hat einen Kalibrierbildschirm für diese nachgelagerte Verzögerung. Drück in der Spieleliste die Taste, die auf `INPUT_SERVICE` gelegt ist (standardmäßig die Taste `9`), und wähle Audio Delay aus den Service-Tools (siehe xref:Manual.adoc#servicetools[Die Service-Tools]). Der Bildschirm ist eine Seite im Dokument des Menüs: Er deckt das Menü schwarz ab, zeigt die gerade erprobte Verzögerung und die von der Engine gemessene Warteschlange des Geräts, spielt einmal pro Sekunde ein Klicken und wird bei jedem Blitz für einen Frame weiß. Stell mit links und rechts (10 ms) und hoch und runter (1 ms) nach, bis Blitz und Klicken zusammenfallen, und drück dann Knopf 1, um zu speichern. Start setzt auf null zurück, und Knopf 2 (oder noch einmal die Servicetaste) bricht ab. Der Wert wird in `machine.cfg` im obersten Datenordner gespeichert und von der Engine auf jedes Spiel auf diesem Rechner angewendet, ob es aus dem Menü oder von der Befehlszeile gestartet wird; er wird zu einem `AUDIO_DELAY` pro Spiel hinzuaddiert. Kalibriere neu, wenn du Lautsprecher, Kopfhörer oder Bildschirme wechselst. Der Bildschirm funktioniert, weil das Klicken und der Ton der Disc ein Mixer-Gerät und eine Warteschlange teilen: Der Blitz wird um die gemessene Warteschlange plus den erprobten Wert nach dem Klicken angesetzt, sodass der erprobte Wert genau dann, wenn beide zusammenfallen, die Verzögerung ist, die die Engine nicht sehen kann. Die Verzögerung des Bildschirms ist gratis mit eingerechnet. Ein Spiel kann die Werte mit <> und <> lesen oder mit <> einen eigenen Bildschirm anbieten. Zwei Eigenschaften der Videodatei selbst spielen ebenfalls eine Rolle. AAC-Audio enthält Priming-Samples des Encoders, die Decoder laut Container überspringen sollen; Dateien, die ohne diese Angabe gemuxt wurden (ein MP4 ohne Edit-Liste, eine Matroska-Datei ohne Codec-Verzögerung), spielen ihren Ton etwa 20 Millisekunden zu spät, was `AUDIO_DELAY` auffangen kann. Und wenn der erste Videoframe einen späteren Zeitstempel hat als das erste Audio-Sample, beachtet Singe die Zeitangaben des Containers, sodass vorauslaufender Ton nicht verloren geht. [#formats] === Video-, Audio- und Containerformate Singe dekodiert Video direkt mit den Bibliotheken von FFmpeg und nutzt dabei den Hardware-Decoder der Plattform, wenn sie einen für den Codec bietet (VA-API oder VDPAU unter Linux, D3D11VA unter Windows, VideoToolbox unter macOS und auf einem ARM-Board die V4L2-Decoder -- den Memory-to-Memory-Decoder, der auf einem Pi 4 H.264 abdeckt, den zustandslosen Request-API-Decoder, den ein Pi 5 für HEVC nutzt, und den eigenen von Rockchip auf einem RK3588), andernfalls seinen eigenen Software-Decoder; der Programm-Trace meldet, welcher es ist, und `--softwarevideo` erzwingt Software. Jeder Container und Codec, den das mitgelieferte FFmpeg demuxen und dekodieren kann, wird abgespielt: MP4, MKV, MPEG-Programmstreams, AVI, AV1 und die klassischen Daphne-Elementarstreams `.m2v` mit einer passenden `.ogg`-Audiodatei daneben. Jede Audiospur in der Datei steht `discSetAudioTrack` / `videoSetAudioTrack` zur Verfügung; alle Spuren müssen dasselbe Sample-Format, dieselbe Kanalzahl und dieselbe Rate haben. Eine Laserdisc enthielt Halbbilder im Zeilensprungverfahren, und ein Rip, der sie beibehalten hat, zeigt auf jedem progressiven Display Kammartefakte. `--deinterlace` entscheidet, was geschieht: `auto`, der Standard, lässt bwdif über die Frames laufen, die die Datei als interlaced markiert, und lässt alles andere in Ruhe, sodass eine progressive Disc nichts kostet; `on` ist für eine Datei, deren Markierungen falsch sind; `off` rührt das Bild nie an. Für jedes Bild, das hineingeht, kommt eines heraus -- eine Disc wird über Frame-Nummern angesprochen, also würde ein Deinterlacer, der aus jedem Frame zwei Halbbilder macht, jeden Frame im Index verschieben. Wenn ein Video zum ersten Mal geöffnet wird, indiziert Singe es und speichert den Index neben den anderen Daten des Spiels (`-.index`, wobei der Hash aus dem vollen Namen des Videos gebildet wird, sodass zwei Videos mit demselben Basisnamen getrennte Indizes behalten). Das Indizieren ist ein Durchgang über die Datei ohne Dekodieren, ein paar Sekunden selbst für eine Disc in Spielfilmlänge, und geschieht erneut, wenn sich das Video ändert. Wenn ein Video geladen wird, meldet Singe im Programm-Trace (`--program`) den Abstand seiner Keyframes, mit einer Warnung dort, wenn Keyframes mehr als zwei Sekunden auseinanderliegen, weil ein Sprung vom vorigen Keyframe aus vorwärts dekodieren muss. Das Dekodieren geschieht in einem eigenen Thread, sodass ein langsamer Sprung den vorigen Frame etwas länger zeigt, statt das Spiel anzuhalten. Die Segmente einer Frame-Datei werden geöffnet, wenn die Disc sie erreicht, nicht alle auf einmal: das, das gerade spielt, das danach und Platz für einen Sprung zwischen zweien. Eine lange Frame-Datei kann Hunderte von Segmenten haben -- ein veröffentlichtes Spiel hat 213 -- und für jedes davon einen Decoder offen zu halten, kostet Hunderte offener Dateien ohne jeden Gewinn. Eine Frame-Datei, die eine Datei nennt, die nicht da ist, meldet das weiterhin beim Start. Bei Laserdisc-Material zählen Frame-Genauigkeit und Sprunggeschwindigkeit, nicht Kompression. H.264 in MP4 oder MKV mit kurzem Keyframe-Intervall (eine oder zwei Sekunden) springt schnell und spielt auf jeder unterstützten Plattform; lange Keyframe-Intervalle machen `discSearch` sichtbar langsam, weil der Decoder vom vorigen Keyframe aus vorwärtsgehen muss. Standardauflösung (720x480 oder 720x576) ist das Optimum. Quellen in hoher Auflösung funktionieren, kosten aber auf Hardware der Raspberry-Pi-Klasse entsprechend mehr CPU, und das Overlay hat standardmäßig die halbe Videoauflösung, sodass übergroßes Video wenig bringt. Soundeffekte und Musik liest zuerst SDL_mixer und FFmpeg alles, was SDL_mixer ablehnt, sodass beide zusammen Folgendes abdecken: * WAV, AIFF, AU und VOC für unkomprimiertes Audio. Kurze unkomprimierte WAV-Dateien haben die geringste Latenz und sind das, was ein Schuss sein sollte. * Ogg Vorbis, Opus, MP3, FLAC und WavPack. * AAC (`.m4a`, `.aac`), ALAC, AC-3, WMA, APE, TTA, Speex und AMR -- alles andere, was das mitgelieferte FFmpeg dekodiert. * Tracker-Module über libxmp: MOD, S3M, XM und IT sowie rund fünfzig weitere, darunter 669, AMF, MED, OKT, PTM, STM und ULT. * Chiptunes über game-music-emu: NSF und NSFE, SPC, VGM, GBS, AY, GYM, HES, KSS und SAP. Die gzip-komprimierte Form VGZ wird nicht gelesen; entpacke sie vorher zu VGM. * MIDI, aus einem SoundFont synthetisiert. Siehe <>. Die Zeile `Audio:` im Kopf des Trace (`--program` oder `--trace`) listet die Decoder auf, die der Build tatsächlich enthält. Bilder funktionieren genauso: zuerst SDL_image, FFmpeg für den Rest. Das deckt PNG, JPEG, WebP, GIF, BMP, TGA, PCX, PNM, LBM, QOI, XCF, XPM und SVG über SDL_image ab und TIFF, OpenEXR, JPEG 2000, DDS, PSD, DPX, AVIF und HEIC über FFmpeg. Ein animiertes GIF oder WEBP wird als Sprite-Animation geladen; siehe <>, das auch eine Größe zum Rastern eines SVG annimmt. Radiance-`.hdr` und OpenEXR funktionieren beide als Himmel (<>) und behalten ihren vollen Umfang. Schriftarten liest FreeType: TrueType, OpenType, WOFF, WOFF2, Type 1, BDF und PCF, mit Farbglyphen (als Bitmap wie als Vektor) und HarfBuzz-Shaping für Arabisch, Hebräisch und die indischen Schriften. [#reference] == Lua-API-Referenz Dieses Kapitel dokumentiert die Lua-API, die die Singe-Engine bereitstellt -- jede Funktion, die dein Spielskript aufrufen kann, und jeden Callback, den Singe bei dir aufruft. Es richtet sich an Entwickler, die neue Spiele schreiben oder videolastige Spiele von anderen Engines portieren. [#conventions] === Konventionen Jeder API-Eintrag unten folgt demselben Aufbau: * *Signatur(en)* in einem Codeblock, mit jeder akzeptierten Form. * *Beschreibung* dessen, was die Funktion tut, und aller Nebenwirkungen. * *Parameter*, im Fließtext aufgeführt oder als Tabelle, wenn es mehr als zwei sind. * *Rückgabe*-Zeile, die den an Lua zurückgegebenen Wert beschreibt (entfällt, wenn die Funktion nichts zurückgibt). * *Hinweise*, wenn es Fallstricke gibt, die einen Hinweis wert sind. * *Seit* -- die Singe-Version, in der die Funktion zuerst erschienen ist. * *Siehe auch* -- verwandte Funktionen, die du wahrscheinlich an derselben Stelle benutzt. * *Beispiel* -- ein kurzer, realistischer Ausschnitt in der Form echten Spielcodes. Ein Eintrag, der mit *Hypseus-Erweiterung* beginnt, ist da, damit ein für Hypseus Singe geschriebenes Spiel hier unverändert läuft. Dasselbe gilt für eine als die von Hypseus gekennzeichnete Argumentform bei einer Funktion, die sonst die von Singe nimmt. IMPORTANT: *Schreib keine neuen Spiele gegen die Hypseus-API.* Diese Einträge gibt es aus Gründen der Kompatibilität, nicht als Empfehlung. Mehrere von ihnen antworten mit einem festen Wert, weil es das, was sie beschreiben, in Singe nicht gibt, manche werden angenommen und ignoriert, und andere tragen Skalen und Argumentreihenfolgen, die von einer anderen Engine geerbt sind -- `videoGetVolume()` zählt bis `64`, wo der Rest von Singe bis `100` zählt, und die `spriteAnim`-Namen nehmen das Sprite zuletzt, wo jeder Sprite-Aufruf von Singe es zuerst nimmt. Jeder Eintrag nennt das Singe-Gegenstück, das du stattdessen benutzen sollst. Neuer Code, der bei der Singe-API bleibt, ist kürzer, in sich einheitlich und wird nicht überrascht, wenn eine Kompatibilitätsantwort konstant bleibt. Ein paar Regeln gelten für die ganze API: * *Farben* haben 8 Bit pro Kanal: Rot, Grün, Blau und Deckkraft sind jeweils Ganzzahlen von `0` bis `255`. * *Overlay-Koordinaten* sind in der aktuellen Overlay-Auflösung angegeben (gesetzt mit `overlaySetResolution`), nicht in Bildschirmpixeln. Die Standardauflösung des Overlays ist in jeder Dimension die halbe Videoauflösung; rufe `overlaySetResolution` auf, um sie zu ändern. * *Handles*, die `*Load`-Funktionen zurückgeben (Sprites, Sounds, Schriftarten, Videos), sind opake Ganzzahlen. Gib sie unverändert an die anderen Funktionen dieses Namensraums zurück. Rechne nicht mit ihnen. * *Ungültige Argumente brechen das Skript ab.* Wird eine Funktion mit der falschen Anzahl von Argumenten, mit falschen Typen oder mit einem Handle aufgerufen, das bereits entladen wurde, beendet Singe das laufende Skript mit einem Fehler -- es gibt nicht `nil` oder einen Fehlercode zurück. Prüfe Benutzereingaben in deinem Skript, bevor du sie an die API übergibst. * *Gezeichnet werden muss aus `onOverlayUpdate`.* Aufrufe einer Funktion `overlay*`, `spriteDraw`, `videoDraw` oder `fontPrint` aus anderen Callbacks erscheinen nicht auf dem Bildschirm und können das Overlay beschädigen. Das ist die eine Regel, über die jeder neue Singe-Entwickler stolpert. [#animation] === Animation Diese Aufrufe steuern die Animationen, die ein Modell mitbringt, pro Instanz. Jeder von ihnen nimmt den *Wurzelknoten*, den `modelInstance` zurückgibt; andere Knoten lösen einen Fehler aus, außer bei `animationIsPlaying`, das `false` antwortet. Animationen sind ab `1` in der Reihenfolge nummeriert, in der `modelGetAnimations` sie auflistet, oder so benannt, wie die Datei sie benennt. Ein Clip spielt auf der Basisebene, und bis zu vier weitere können auf den Ebenen `1` bis `4` darüber spielen; die Aufrufe für Zeit, Pause, Fortsetzen und Sprung betreffen nur die Basisebene. Die Zeit wird in Sekunden gemessen, läuft mit der Uhr um höchstens eine Zehntelsekunde pro Frame weiter und steht still, solange das Spiel pausiert ist. Knotenanimation, Skinning und Morph-Target-Gewichte werden alle gesteuert. Siehe den Teil Animation in <>. [#animationgettime] ==== animationGetTime [source,text] ---- seconds = animationGetTime(node) ---- Die aktuelle Zeit im Clip der Basisebene, ob er spielt oder pausiert, in den eigenen Sekunden des Clips (ein `speed` von `2` bewegt sie doppelt so schnell). Ein Clip in Schleife springt an seinem Ende auf `0` zurück; einer ohne Schleife bleibt bei seiner Länge stehen. Wurde noch nichts gespielt, ergibt das `0`. Clips auf den oberen Ebenen werden nicht gemeldet. Ein Knoten, der keine Instanzwurzel ist, beendet das Skript. *Rückgabe:* Sekunden als Zahl. *Seit:* 3.00. *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Spiel den Schritt jedes Mal einmal, wenn der Laufzyklus seine zwei Aufsetzpunkte passiert. 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) ---- Ob der Clip der Basisebene auf dieser Instanz weiterläuft. Ein Clip ohne Schleife hört auf zu spielen, wenn er sein Ende erreicht, und `animationPause` und `animationStop` machen dies beide `false`; ein Clip in Schleife bleibt `true`, bis er gestoppt wird. Anders als bei den übrigen Aufrufen hier beendet ein Knoten, der keine Instanzwurzel ist, nicht das Skript, sondern bekommt `false` als Antwort. *Rückgabe:* Boolean. *Seit:* 3.00. *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Zurück zum Leerlauf, sobald der einmalige Angriff abgelaufen ist. 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) ---- Friert den Clip der Basisebene bei seiner aktuellen Zeit ein; die Knoten halten diese Pose, bis `animationResume` ihn fortsetzt oder ein anderer Clip gespielt wird. Clips auf den oberen Ebenen laufen weiter. Eine Instanz zu pausieren, auf der nichts spielt, ist harmlos. Ein Knoten, der keine Instanzwurzel ist, beendet das Skript. *Seit:* 3.00. *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Eine Zwischensequenz friert die Menge ein, während sich die Kamera bewegt. 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) ---- Startet einen der Clips des Modells von seinem Anfang an auf der Basisebene dieser Instanz und ersetzt, was dort gespielt hat; die Pose wird sofort aktualisiert. Mit einem `fade` läuft der alte Clip weiter, während er über so viele Sekunden ausgeblendet wird, und wenn nichts gespielt hat, beginnt die Überblendung bei der Pose, in der die Knoten gerade sind (eine eben abgeschaltete Ragdoll, eine per Skript gesetzte Pose), wobei Knoten, die der neue Clip nicht animiert, sanft in die Ruhepose des Modells zurückgleiten. Ein Knoten, der keine Instanzwurzel ist, ein Name, den das Modell nicht hat, oder eine Zahl außerhalb von `1` bis zur Anzahl der Clips beendet das Skript. *Parameter:* * `node` -- der Wurzelknoten der Instanz aus `modelInstance`. * `nameOrIndex` -- ein Clipname, wie ihn die Datei speichert, oder seine Nummer ab `1` in der Reihenfolge von `modelGetAnimations`. * `loop` -- Boolean, Standard `true`. Bei `false` bleibt der Clip bei seinem letzten Keyframe stehen, und `animationIsPlaying` wird `false`. * `speed` -- Abspielrate, Standard `1`. `2` ist doppelt so schnell; `0.5` ist halb so schnell. * `fade` -- Sekunden für die Überblendung von dem, was gespielt hat, Standard `0` (ein harter Wechsel). *Seit:* 3.00. *Siehe auch:* <>, <>, <>, <> .Beispiel [source,lua] ---- -- Eine Wache, die untätig herumsteht, bis der Spieler entdeckt wird, und dann rennt. 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) ---- Spielt einen Clip auf einer der vier Ebenen über der Basis, über alles darunter geblendet nach dem Gewicht der Ebene (`animationSetLayerWeight`, Standard `1`) und auf einen Teil des Skeletts beschränkt, wenn eine Maske gesetzt ist (`animationSetLayerMask`). Höhere Ebenen werden über niedrigere geblendet; jede Ebene führt ihren eigenen Clip, ihre eigene Zeit und Schleife unabhängig von der Basis. `nameOrIndex`, `loop`, `speed` und `fade` bedeuten dasselbe wie bei `animationPlay`, mit einem Unterschied: Ein `fade` auf einer Ebene, die leer war, fährt das Gewicht der Ebene von `0` hoch, statt von einer Pose überzublenden. Eine Ebene außerhalb von `1` bis `4`, ein Knoten, der keine Instanzwurzel ist, oder ein Clip, den das Modell nicht hat, beendet das Skript. *Parameter:* * `node` -- der Wurzelknoten der Instanz. * `layer` -- `1` bis `4`. * `nameOrIndex` -- ein Clipname oder eine Clipnummer, wie bei `animationPlay`. * `loop` -- Standard `true`. * `speed` -- Standard `1`. * `fade` -- Sekunden, Standard `0`. *Seit:* 3.00. *Siehe auch:* <>, <>, <>, <> .Beispiel [source,lua] ---- -- Mit dem ganzen Körper gehen, sich nur mit dem Kopf umsehen. animationPlay(fox, "Walk", true) animationPlayLayer(fox, 1, "Survey", true, 1, 0.5) animationSetLayerMask(fox, 1, nodeFind("b_Neck_04", fox)) -- Später mit einem Arm winken, egal, was die Beine gerade tun. function onGreet() animationPlayLayer(fox, 2, "Wave", false, 1, 0.2) end ---- [#animationresume] ==== animationResume [source,text] ---- animationResume(node) ---- Setzt den Clip der Basisebene dort fort, wo `animationPause` ihn eingefroren hat, oder dort, wohin `animationSetTime` ihn inzwischen bewegt hat. Einen Clip fortzusetzen, der bereits spielt, ändert nichts. Eine Instanz ohne Clip auf ihrer Basisebene (nie gespielt oder gestoppt) beendet das Skript, ebenso ein Knoten, der keine Instanzwurzel ist. *Seit:* 3.00. *Siehe auch:* <>, <> .Beispiel [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) ---- Beschränkt eine Ebene auf `maskNode` und alles darunter, sodass der Clip auf dieser Ebene nur diesen Teil des Modells bewegt und der Rest weiter den Ebenen darunter folgt. `maskNode` muss einer der eigenen Knoten der Instanz sein, gefunden mit `nodeFind(name, node)`; die Instanzwurzel selbst zählt nicht. `maskNode` wegzulassen oder `nil` zu übergeben hebt die Beschränkung auf. Die Maske bleibt über spätere Aufrufe von `animationPlayLayer` hinweg auf der Ebene. Eine Ebene außerhalb von `1` bis `4` beendet das Skript, ebenso ein Knoten, der keine Instanzwurzel ist, oder ein `maskNode`, der nicht zu dieser Instanz gehört. *Parameter:* * `node` -- der Wurzelknoten der Instanz. * `layer` -- `1` bis `4`. * `maskNode` -- ein Gelenk oder Knoten der Instanz; weggelassen oder `nil` für das ganze Modell. *Seit:* 3.00. *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Den Oberkörper auf das Ziel richten, während die Beine weitergehen. 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) ---- Wie stark der Clip einer Ebene über dem durchscheint, was darunter liegt, `0` (gar nicht) bis `1` (voll, der Standard); Werte außerhalb dieses Bereichs werden begrenzt. Das Gewicht gehört zur Ebene, nicht zum Clip, also übersteht es `animationPlayLayer` und `animationStop` auf dieser Ebene. Ändere es jeden Frame ein wenig, um eine Ebene von Hand ein- oder auszublenden, oder lass einen `fade` bei `animationPlayLayer` das einmal erledigen. Eine Ebene außerhalb von `1` bis `4` oder ein Knoten, der keine Instanzwurzel ist, beendet das Skript. *Seit:* 3.00. *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Das Hinken wird schlimmer, je weniger Gesundheit bleibt. function onHealthChanged(health) local limp = 1 - health / maxHealth animationSetLayerWeight(hero, 1, limp) end ---- [#animationsettime] ==== animationSetTime [source,text] ---- animationSetTime(node, seconds) ---- Lässt den Clip der Basisebene zu einer Zeit in seinen eigenen Sekunden springen; negative Werte werden zu `0`. Die Pose wird sofort aktualisiert, ob der Clip spielt oder nicht, sodass eine pausierte Instanz auf einen genauen Frame gespult werden kann und eine spielende von der neuen Zeit aus weiterläuft. Eine laufende Überblendung auf der Basisebene wird abgebrochen. Eine Instanz ohne Clip auf ihrer Basisebene beendet das Skript, ebenso ein Knoten, der keine Instanzwurzel ist. *Seit:* 3.00. *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Eine Tür, deren Öffnungsclip einem Hebel folgt, den der Spieler zieht. animationPlay(door, "Open", false) animationPause(door) function onLeverMoved(fraction) animationSetTime(door, fraction * doorClipLength) end ---- [#animationstop] ==== animationStop [source,text] ---- animationStop(node) animationStop(node, layer) ---- Stoppt und vergisst jeden Clip auf der Instanz oder nur den Clip auf einer Ebene über der Basis (`1` bis `4`); die Basisebene allein lässt sich nicht ohne die Ebenen darüber stoppen. Die Knoten behalten ihre letzte Pose, aus der ein folgendes `animationPlay` mit einem `fade` sanft herausführt. Nachdem alles gestoppt wurde, beenden `animationResume` und `animationSetTime` das Skript, bis wieder etwas gespielt wird. Gewichte und Masken der Ebenen bleiben erhalten. Eine Ebene außerhalb von `1` bis `4` oder ein Knoten, der keine Instanzwurzel ist, beendet das Skript. *Seit:* 3.00. *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Den Körper an die Physik-Ragdoll übergeben: die Pose einfrieren, dann fallen lassen. function onKilled() animationStop(hero) ragdollActivate(hero) end -- Das Winken auf Ebene 2 ist fertig; das Gehen darunter läuft weiter. function onWaveFinished() animationStop(hero, 2) end ---- [#bezelref] === Bezel Das Artwork des Automaten und die Arcade-Punktetafel, die dazugehört. `mainBezelLoaded` fragt nach dem Artwork, das `--bezel` geladen hat; die `scoreBezel`-Familie steuert die Punkteanzeige, ein RmlUi-Dokument, das die Engine mitliefert und das ein Spiel durch eine eigene Kopie ersetzen kann, `scoreBezel.rml` neben seinem Skript. Jeder Aufruf hier ist eine Erweiterung von Hypseus Singe, beibehalten, damit dessen Spiele unverändert laufen, und jeder davon wird aus Sicht des Spielers in xref:Manual.adoc#bezels[Bezels] beschrieben. Die Anzeige startet ausgeschaltet, wie in Hypseus, und nur ein Spiel schaltet sie ein. Werte, die ihr übergeben werden, bleiben gespeichert, ob sie angezeigt wird oder nicht, sodass ein Spiel sie setzen darf, bevor es die Anzeige einschaltet. Jedes Anzeigefeld hat so viele Stellen wie die Punktetafel von Hypseus -- sechs für eine Punktzahl, zwei für die Credits, eine für die Zahl der Leben -- und ein Wert wird darin rechtsbündig mit führenden Nullen angezeigt; eine Punktzahl mit mehr als sechs Stellen zeigt ihre letzten sechs. Auch die negativen Werte von Hypseus werden verstanden: `-2` lässt in jeder Stelle einen Strich leuchten, `-4` ein `H` und `-5` ein `A`, und `-1`, `-3` und jede andere negative Zahl lassen nichts leuchten. [#mainbezelloaded] ==== mainBezelLoaded [source,text] ---- loaded, id = mainBezelLoaded() ---- Hypseus-Erweiterung. Ob `--bezel` Artwork für den Automaten geladen hat, und die Kennung der geladenen Datei. Die Kennung ist der 64-Bit-FNV-1a-Hash der Bytes der Datei, als Dezimalstring ausgeschrieben, und genau das meldet auch Hypseus, sodass ein Spiel, das ein bestimmtes Bezel an seiner Nummer erkennt, hier dieselbe Datei erkennt. Ist kein Artwork geladen, lauten die Antworten `false` und `"0"`. *Rückgabe:* Boolean und die Kennung als String. *Seit:* 3.00, als Erweiterung von Hypseus Singe. *Siehe auch:* <>, xref:Manual.adoc#bezels[Bezels] .Beispiel [source,lua] ---- -- Den Sprite-Rahmen nur zeichnen, wenn der Spieler kein eigenes Artwork hat. local loaded, id = mainBezelLoaded() if not loaded then spriteDraw(frameSprite, 0, 0) end ---- [#scorebezelclear] ==== scoreBezelClear [source,text] ---- scoreBezelClear() ---- Hypseus-Erweiterung. Leert jedes Anzeigefeld der Punkteanzeige: Die Credits, beide Punktzahlen und beide Lebensanzeigen zeigen gar nichts mehr, nur die abgedunkelten Ziffern dahinter bleiben. Die Werte werden nicht vergessen, aber das nächste `scoreBezelCredits`, `scoreBezelScore` oder `scoreBezelLives` lässt die Anzeige wieder mit dem leuchten, was sie dann enthält. Kann bei ausgeschalteter Anzeige gefahrlos aufgerufen werden. *Seit:* 3.00, als Erweiterung von Hypseus Singe. *Siehe auch:* <> [#scorebezelcredits] ==== scoreBezelCredits [source,text] ---- scoreBezelCredits(credits) ---- Hypseus-Erweiterung. Setzt das Anzeigefeld für die Credits, das zwei Stellen hat. Lässt außerdem eine Anzeige wieder leuchten, die `scoreBezelClear` geleert hat. *Parameter:* * `credits` -- Ganzzahl; die letzten zwei Stellen werden angezeigt. *Seit:* 3.00, als Erweiterung von Hypseus Singe. *Siehe auch:* <> [#scorebezelenable] ==== scoreBezelEnable [source,text] ---- scoreBezelEnable(enabled) scoreBezelEnable(enabled, type) ---- Hypseus-Erweiterung. Blendet die Punkteanzeige ein oder aus. Das erste `true` baut sie aus der eigenen `scoreBezel.rml` des Spiels neben seinem Skript, wenn es eine gibt, und sonst aus `Singe/scoreBezel.rml`; sie danach aus- und wieder einzublenden kostet nichts und behält ihre Werte. Sie startet ausgeblendet, so wie auch Hypseus ausgeliefert wird, sodass sich ein Spiel, das dies nie aufruft, genau wie zuvor verhält -- und es sollte <> fragen, bevor es annimmt, dass es eine hat. Das `type` von Hypseus wählt zwischen dessen fünf Backends für die Punktetafel. Singe hat eines, ein RmlUi-Dokument, also wird das Argument geprüft und ignoriert. Ein Rechner ohne GPU hat überhaupt keine GUIs; dort kann die Anzeige nicht gebaut werden, `scoreBezelGetState` antwortet weiter `false`, und eine Warnung wird einmal ausgegeben. Ein erstes Argument, das kein Boolean ist, bricht das Skript ab. *Parameter:* * `enabled` -- Boolean. * `type` -- Ganzzahl, angenommen und ignoriert. *Seit:* 3.00, als Erweiterung von Hypseus Singe. *Siehe auch:* <>, xref:Manual.adoc#bezels[Bezels] .Beispiel [source,lua] ---- -- Die Anzeige der Engine, wenn es eine gibt, Sprites, wenn nicht. scoreBezelEnable(true, 0) if scoreBezelGetState() then scoreBezelTwinScoreOn(twoPlayer) scoreBezelCredits(credits) else drawSpriteScoreboard() end ---- [#scorebezelgetstate] ==== scoreBezelGetState [source,text] ---- state = scoreBezelGetState() ---- Hypseus-Erweiterung. Ob die Punkteanzeige zu sehen ist. Jedes Hypseus-Spiel fragt dies, bevor es die Anzeige benutzt, und zeichnet seine eigene Punktetafel mit Sprites, wenn die Antwort `false` ist, was der richtige Ausweg und kein Fehler ist. *Rückgabe:* Boolean. *Seit:* 3.00, als Erweiterung von Hypseus Singe. *Siehe auch:* <> [#scorebezellives] ==== scoreBezelLives [source,text] ---- scoreBezelLives(player, lives) ---- Hypseus-Erweiterung. Setzt das Anzeigefeld für die Leben eines Spielers, das eine Stelle hat. `player` ist `1` oder `2`; jede andere Zahl wird ignoriert, ebenso `2`, bis <> die Anzeigefelder des zweiten Spielers auf die Anzeige gebracht hat. Lässt außerdem eine Anzeige wieder leuchten, die `scoreBezelClear` geleert hat. *Parameter:* * `player` -- `1` oder `2`. * `lives` -- Ganzzahl; die letzte Stelle wird angezeigt. *Seit:* 3.00, als Erweiterung von Hypseus Singe. *Siehe auch:* <> [#scorebezelscore] ==== scoreBezelScore [source,text] ---- scoreBezelScore(player, score) ---- Hypseus-Erweiterung. Setzt das Anzeigefeld für die Punktzahl eines Spielers, das sechs Stellen hat. `player` ist `1` oder `2`, mit derselben Regel für `2` wie bei `scoreBezelLives`. Lässt außerdem eine Anzeige wieder leuchten, die `scoreBezelClear` geleert hat. *Parameter:* * `player` -- `1` oder `2`. * `score` -- Ganzzahl; die letzten sechs Stellen werden angezeigt. *Seit:* 3.00, als Erweiterung von Hypseus Singe. *Siehe auch:* <> .Beispiel [source,lua] ---- function addScore(points) score = score + points if scoreBezelGetState() then scoreBezelScore(1, score) end end ---- [#scorebezeltwinscoreon] ==== scoreBezelTwinScoreOn [source,text] ---- scoreBezelTwinScoreOn(enabled) ---- Hypseus-Erweiterung. Bringt die Anzeigefelder für Punktzahl und Leben des zweiten Spielers auf die Anzeige oder nimmt sie wieder herunter und überlässt den Platz den anderen beiden. Sie abzuschalten leert auch die Anzeige, genau wie in Hypseus, also lass einen Wert folgen, wenn die Anzeigefelder des ersten Spielers leuchten sollen. Solange sie aus sind, tun `scoreBezelScore(2, ...)` und `scoreBezelLives(2, ...)` nichts. Ein Argument, das kein Boolean ist, bricht das Skript ab. *Parameter:* * `enabled` -- Boolean. *Seit:* 3.00, als Erweiterung von Hypseus Singe. *Siehe auch:* <> [#body] === Körper Ein Körper gibt einem Szenenknoten einen Platz in der Physikwelt. Es gibt einen Körper pro Knoten, angesprochen über das Knoten-Handle: Ein statischer Körper bleibt, wo der Knoten war, als der Körper erzeugt wurde, ein kinematischer Körper folgt bei jedem Schritt seinem Knoten und schiebt, ohne geschoben zu werden, und ein dynamischer Körper wird von der Simulation bewegt und treibt seinen Knoten. Größen, Positionen und Geschwindigkeiten sind in Welteinheiten angegeben (Meter in einer 3D-Szene, Overlay-Pixel in einer 2D-Welt), Massen in Kilogramm und Zeit in Sekunden. Jeder Aufruf hier beendet das Skript, wenn der Knoten keinen Körper hat; ein Aufruf, der nur für eine Art von Körper sinnvoll ist (etwa eine Kraft auf einen statischen Körper), wird bei den anderen ignoriert, außer `bodySetMass`, das einen Fehler auslöst. Wie Körper, Knoten und der Frame zusammenpassen, steht unter <> in Spieleentwicklung. [#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) ---- Gibt dem Knoten einen Körper des angegebenen Typs, dort platziert, wo der Knoten gerade in der Welt ist (seine Eltern eingeschlossen), mit einer Kollisionsform, die durch `a`, `b` und `c` in Welteinheiten bemessen und mit der Weltskalierung des Knotens skaliert wird. Ein zweiter Aufruf auf demselben Knoten ersetzt den ersten, und ein Knoten, der eine Spielfigur hatte, verliert sie. Die Masse eines dynamischen Körpers ergibt sich aus dem Volumen seiner Form bei der Dichte von Wasser, also setze `bodySetMass` bei allem, was sich leicht anfühlen soll. Die Skalierung des Knotens später zu ändern, ändert die Größe des Körpers nicht. Beendet das Skript bei einem unbekannten Typ oder einer unbekannten Form, wenn auf diesem Rechner keine Physik verfügbar ist, wenn die Welt bereits `4096` Körper enthält oder wenn die Form nicht gebaut werden kann (ein dynamisches `SHAPE_MESH` oder eine Hülle ohne Mesh unter dem Knoten). *Parameter:* * `type` -- `BODY_STATIC`, `BODY_DYNAMIC` oder `BODY_KINEMATIC`. * `shape` -- `SHAPE_BOX` (`a`, `b`, `c` sind Breite, Höhe und Tiefe), `SHAPE_SPHERE` (`a` ist der Radius), `SHAPE_CAPSULE` oder `SHAPE_CYLINDER` (`a` ist der Radius, `b` die Gesamthöhe entlang Y, Kappen eingeschlossen), `SHAPE_HULL` (eine konvexe Hülle um das Mesh des Knotens und alles darunter, keine Größen) oder `SHAPE_MESH` (die exakten Dreiecke, nur für statische und kinematische Körper; ein Heightmap-Mesh wird zu einem Höhenfeld). * `a`, `b`, `c` -- die Größen, die die Form braucht; fehlende sind `0`, und jede Größe wird auf mindestens `0.001` angehoben. *Hinweise:* Eine Kugel wird mit der größten Skalierungsachse des Knotens skaliert, der Radius einer Kapsel oder eines Zylinders mit der größeren von X und Z. Eine Kapsel, die kürzer als ihr doppelter Radius ist, ist eine Kugel mit einem schmalen Stück in der Mitte. *Seit:* 3.00. *Siehe auch:* <>, <>, <>, <> .Beispiel [source,lua] ---- -- Eine leichte Kiste und ein Entenmodell als Hülle, auf den Boden fallen gelassen. 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) ---- Übt auf einen dynamischen Körper eine Kraft in Newton aus, nur für die Schritte dieses Frames; ruf es jeden Frame auf, um weiter zu schieben. Ohne Punkt wirkt die Kraft durch den Schwerpunkt und bewegt den Körper nur; mit einem Weltpunkt `(px, py, pz)` dreht sie den Körper außerdem um seinen Mittelpunkt. Eine Kraft beschleunigt um Kraft geteilt durch Masse, also bemiss sie nach `bodySetMass`. Statische und kinematische Körper ignorieren sie. *Parameter:* * `fx`, `fy`, `fz` -- die Kraft in Newton entlang jeder Weltachse. * `px`, `py`, `pz` -- optionaler Weltpunkt, an dem die Kraft angreift; weglassen für den Schwerpunkt. *Seit:* 3.00. *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Ein Ventilator bläst den Ballon durch den Raum, solange der Schalter an ist. 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) ---- Ändert den Impuls eines dynamischen Körpers auf einen Schlag: Ein Impuls von Masse mal Geschwindigkeit fügt diese Geschwindigkeit hinzu. Ohne Punkt wirkt er durch den Schwerpunkt; mit einem Weltpunkt `(px, py, pz)` dreht er den Körper außerdem, sodass ein Treffer nahe der Oberkante einer Kiste sie umkippt. Das ist der Aufruf für einen Treffer, einen Tritt, eine Explosion oder einen Abschuss, während `bodyApplyForce` für ein Schieben ist, das anhält. Statische und kinematische Körper ignorieren ihn. *Parameter:* * `ix`, `iy`, `iz` -- der Impuls in Kilogramm mal Meter pro Sekunde entlang jeder Weltachse. * `px`, `py`, `pz` -- optionaler Weltpunkt, an dem der Impuls angreift; weglassen für den Schwerpunkt. *Seit:* 3.00. *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Schnipp, was unter der Maus ist, senkrecht nach oben, an der Stelle, an der es getroffen wurde. 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) ---- Entfernt den Körper aus der Welt; der Knoten und sein Mesh bleiben, wo sie sind. Jedes Gelenk, das am Körper hängt, und jedes darauf gebaute Fahrzeug verschwindet mit ihm. Den Knoten zu löschen entfernt auch seinen Körper, also wird dies nur gebraucht, um einen Knoten ohne seinen Körper zu behalten. Beendet das Skript, wenn der Knoten keinen Körper hat. *Seit:* 3.00. *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Eine eingesammelte Münze ist nicht mehr fest und schwebt als einfacher Knoten nach oben. 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) ---- Wie schnell sich der Körper um jede Weltachse dreht, in Radiant pro Sekunde, beim letzten Schritt. Ein statischer Körper meldet null. Nützlich für die Tonhöhe eines Rads oder einen Drehzähler. *Rückgabe:* Drei Zahlen, Radiant pro Sekunde um X, Y und Z. *Seit:* 3.00. *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Der Roulettekessel steht, wenn er sich kaum noch dreht. 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) ---- Wie schnell sich der Körper entlang jeder Weltachse bewegt, in Welteinheiten pro Sekunde, beim letzten Schritt. Ein statischer Körper meldet null; ein kinematischer Körper meldet die Geschwindigkeit, die sich aus der Bewegung seines Knotens ergab. *Rückgabe:* Drei Zahlen, Einheiten pro Sekunde entlang X, Y und Z. *Seit:* 3.00. *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Ein Zischen abspielen, wenn der Ball richtig in Fahrt ist. 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) ---- Ob die Simulation den Körper schlafen gelegt hat, weil er sich nicht mehr bewegt. Statische Körper und mit `bodySetEnabled` aus der Welt genommene Körper gelten als ruhend. Ein ruhender Körper wacht von selbst auf, wenn ihn etwas berührt oder wenn eine Geschwindigkeit, eine Kraft oder ein Impuls angewendet wird. *Rückgabe:* Boolean. *Seit:* 3.00. *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Die Runde endet, sobald jeder Kegel zur Ruhe gekommen ist. 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) ---- Setzt die Drehung um jede Weltachse, in Radiant pro Sekunde, und weckt den Körper. Statische Körper ignorieren es. Die Drehung eines kinematischen Körpers wird beim nächsten Schritt von seinem Knoten überschrieben, also wirkt der Aufruf nur bei dynamischen Körpern fort. *Seit:* 3.00. *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Die Münze werfen: nach oben und sich dabei überschlagend. 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) ---- Setzt die Restitution des Körpers, von `0` (bleibt sofort liegen) bis `1` (prallt mit allem zurück, womit er ankam); Werte außerhalb dieses Bereichs werden begrenzt. Der Standard ist `0.1`. Die Werte der beiden sich berührenden Körper werden kombiniert, sodass ein lebhafter Ball auf einem dämpfenden Boden trotzdem springt. *Seit:* 3.00. *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Ein Gummiball, in die Szene fallen gelassen. 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) ---- Wie der Körper schwimmt, wenn er in einem Wasservolumen ist: `1` ist neutral, mehr schwimmt höher, weniger sinkt, und `0` ist ein Stein. Der Standard ist `1.2`, das sanft schwimmt. Der Faktor multipliziert die eigene Dichte des Wassers aus `bodySetWater`, und negative Werte werden auf `0` begrenzt. Nur dynamische Körper werden von Wasser bewegt, aber der Wert kann auf jedem Körper gesetzt werden. *Seit:* 3.00. *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Eine Kiste, die hoch im Wasser liegt, ein Anker, der untergeht, ein Ball, der auf und ab tanzt. 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) ---- Setzt die Strömung in einem Wasservolumen, in Welteinheiten pro Sekunde entlang jeder Weltachse; dynamische Körper, die darin schwimmen, treiben mit der Strömung, und eine schwimmende Spielfigur wird von ihr mitgetragen. Der Standard ist keine Strömung. Setze sie auf dem Knoten, auf dem `bodySetWater` aufgerufen wurde; auf jedem anderen Körper wird sie gespeichert und bewirkt nichts. *Seit:* 3.00. *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Ein Fluss, der alles, was hineinfällt, flussabwärts trägt. 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) ---- Nimmt den Körper mit `false` aus der Welt: Er kollidiert nicht mehr, bewegt sich nicht mehr, treibt seinen Knoten weder an noch folgt er ihm, und er gilt als ruhend. `true` setzt ihn dort wieder ein, wo er ist, und weckt ihn. Der Körper behält Form, Masse und Einstellungen, also ist das billiger, als ihn für etwas, das kommt und geht, zu löschen und neu zu erzeugen. *Seit:* 3.00. *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Die Zugbrücke ist nur fest, solange sie unten ist. 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) ---- Setzt die Oberflächenreibung: `0` ist Eis, `0.5` (der Standard) ist gewöhnlich, höhere Werte greifen stärker. Negative Werte werden auf `0` begrenzt. Die Werte der beiden sich berührenden Körper werden kombiniert, sodass ein rutschiger Boden einen niedrigen Wert auf dem Boden braucht, nicht auf allem, was darauf läuft. *Seit:* 3.00. *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Ein zugefrorener Teich mitten im Level. 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) ---- Skaliert die Masse eines dynamischen Körpers und mit ihr seine Trägheit neu. Tu das bei allem, was sich leicht oder schwer anfühlen soll: Der Standard ist das Volumen der Form bei der Dichte von Wasser, sodass ein türgroßer Quader etwa 140 Kilogramm wiegt und ein Stups ihn nicht bewegt. Kräfte und Impulse skalieren mit der Masse, also setze die Masse, bevor du sie abstimmst. Beendet das Skript, wenn der Körper nicht dynamisch oder die Masse nicht positiv ist. *Seit:* 3.00. *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Eine Kiste, die der Held schieben kann, und ein Amboss, den niemand schieben kann. 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) ---- Macht den Körper mit `true` zum Trigger: Er schiebt nichts und hält nichts auf, aber `onTrigger` meldet jeden Körper und jede Spielfigur, die ihn betreten und verlassen. `false` macht ihn wieder fest: Was in ihm war, wird ohne Meldung des Verlassens vergessen, während ein Trigger, in dem der Körper selbst steht, ihn weiter meldet. Ein Trigger ist meist ein statischer Quader oder eine statische Kugel über einer Tür, einer Ziellinie oder einer Gefahrenzone. Strahlen aus `physicsRaycast` treffen Trigger wie jeden anderen Körper. *Hinweise:* Eine Spielfigur gilt als in einem Trigger, wenn sich ihre Bounding Boxes überlappen, also ist ein runder Trigger für Spielfiguren ein eckiger. *Seit:* 3.00. *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Eine Ziellinie quer über die Strecke. 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) ---- Setzt die Geschwindigkeit des Körpers entlang jeder Weltachse, in Welteinheiten pro Sekunde, und weckt ihn. Das ersetzt jede Geschwindigkeit, die der Körper hatte, während `bodyApplyImpulse` etwas hinzufügt. Statische Körper ignorieren es, und die Geschwindigkeit eines kinematischen Körpers wird beim nächsten Schritt aus der Bewegung seines Knotens überschrieben. *Seit:* 3.00. *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Den Ball zu der Seite hin aufschlagen, die den letzten Punkt verloren hat. 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) ---- Füllt einen statischen Körper mit Wasser und macht ihn zum Trigger: Die Oberseite seiner Form ist die Oberfläche, und jeder dynamische Körper darin schwimmt, sinkt oder rollt durch den Auftrieb gegen diese Oberfläche (der eingetauchte Teil der Form zählt, sodass eine schräge Kiste sich aufrichtet), gebremst durch die Widerstände. Eine Spielfigur, deren Mitte unter der Oberfläche liegt, schwimmt. Weil das Volumen ein Trigger ist, feuert `onTrigger` beim Hinein- und Hinausgehen. Das Wasser wird nicht gezeichnet: Gib dem Knoten ein Material mit Blending oder keines. Beendet das Skript, wenn der Körper nicht statisch ist. *Parameter:* * `density` -- ein Faktor, `1` für Wasser; das eigene `bodySetBuoyancy` jedes Körpers skaliert ihn. Negative Werte werden auf `0` begrenzt. * `linearDrag` -- wie schnell das Wasser Bewegung durch sich hindurch bremst; Standard `0.5`. * `angularDrag` -- wie schnell es Drehung bremst; Standard `0.1`. *Hinweise:* Spielfiguren und Boote sehen höchstens acht Wasservolumen; weitere lassen Körper trotzdem schwimmen, aber in ihnen wird nicht geschwommen. *Seit:* 3.00. *Siehe auch:* <>, <>, <>, <> .Beispiel [source,lua] ---- -- Ein ins Deck eingelassenes Becken; seine Oberseite bei Y = 0 ist die Oberfläche. 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] === Kamera Die Kamera ist ein Knoten wie jeder andere: Platziere sie mit `nodeSetPosition`, richte sie mit `nodeLookAt` aus, häng sie für eine Verfolgeransicht an ein bewegtes Objekt und benenne sie mit `cameraSet`. Sie blickt entlang ihrer eigenen -Z-Richtung. Ohne Kameraknoten wird die Szene von `(0, 0, 5)` aus mit Blick auf den Ursprung betrachtet. Die Projektion wird getrennt gesetzt und gilt für den Knoten, der gerade die Kamera ist: standardmäßig perspektivisch mit einem vertikalen Sichtfeld von 60 Grad von `0.1` bis `1000` Einheiten oder orthografisch mit `cameraSetOrthographic`. Gerenderte Ansichten (`viewNew`) teilen die Projektion, blicken aber von ihren eigenen Knoten aus. Siehe <>. [#cameraset] ==== cameraSet [source,text] ---- cameraSet(node) cameraSet(-1) ---- Macht einen Knoten zur Kamera: Die Szene wird von seiner Weltposition aus mit Blick entlang seiner eigenen -Z-Richtung gezeichnet, sodass Bewegung und Drehung seiner Eltern zählen. Jeder Knoten geht, auch einer, der zugleich ein Mesh oder ein Licht trägt, und die Kamera zu wechseln ist ein einziger Aufruf. `-1` stellt die Standardansicht von `(0, 0, 5)` mit Blick auf den Ursprung wieder her. Wird der Kameraknoten gelöscht, fällt die Ansicht ebenfalls auf die Standardansicht zurück. Beendet das Skript bei einem ungültigen Knoten-Handle. *Seit:* 3.00. *Siehe auch:* <>, <>, <>, <> .Beispiel [source,lua] ---- -- Eine Verfolgerkamera hinter dem Auto des Spielers und eine feste für die Wiederholung. 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) ---- Schaltet auf eine Parallelprojektion um, die `height` Welteinheiten von oben bis unten zeigt, wobei die Breite dem Seitenverhältnis der Ebene folgt, von `near` bis `far` vor der Kamera. Entfernung lässt Dinge nicht schrumpfen, was zu Diagrammen, isometrischen und seitlichen Ansichten und HUD-artigen Szenen passt. Die Einstellung gilt für den aktuellen und jeden späteren Kameraknoten, bis `cameraSetPerspective` aufgerufen wird. Der Schatten eines gerichteten Lichts benutzt unter einer orthografischen Kamera eine einzige Shadow Map, egal, was `sceneSetShadowCascades` sagt. *Parameter:* * `height` -- die sichtbare Höhe, in Welteinheiten. * `near` -- die kleinste gezeichnete Entfernung, in Welteinheiten vor der Kamera. * `far` -- die größte gezeichnete Entfernung. *Seit:* 3.00. *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Eine isometrische Ansicht des Spielbretts: 12 Einheiten hoch, von weit oben und seitlich. 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) ---- Schaltet auf die übliche perspektivische Projektion um, den Standard. `fov` ist das vertikale Sichtfeld in Grad (Standard `60`); `near` und `far` (Standards `0.1` und `1000`) begrenzen, was gezeichnet wird, in Welteinheiten vor der Kamera. Die Tiefengenauigkeit verteilt sich zwischen beiden, also halte `near` so groß, wie die Szene es erlaubt, und `far` nicht größer als nötig: `0.05` bis `100` für einen Raum, `0.1` bis `400` für eine Landschaft. Die Einstellung gilt für den aktuellen und jeden späteren Kameraknoten und für gerenderte Ansichten. *Parameter:* * `fov` -- vertikales Sichtfeld in Grad; ein weites `70` wirkt schnell, ein enges `40` wie ein Teleobjektiv. * `near` -- die kleinste gezeichnete Entfernung. * `far` -- die größte gezeichnete Entfernung. *Seit:* 3.00. *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Ein engeres Objektiv für die Cockpitansicht, der Standard für draußen. function setView(inside) if inside then cameraSet(cockpitCamera) cameraSetPerspective(45, 0.05, 300) else cameraSet(chaseCamera) cameraSetPerspective(60, 0.1, 400) end end ---- [#collide] === Kollision Ob sich zwei flache Dinge berühren. Für Körper beantwortet Jolt das in drei Dimensionen (siehe <>); diese Aufrufe beantworten es für das Overlay, wo die Hitboxen einer Lightgun und bei einem 2D-Spiel schlicht alles zu Hause sind. Es sind schlichte Funktionen: keine Handles, kein Zustand, nichts zu erzeugen oder freizugeben und nichts, was in jedem Frame aktualisiert werden müsste. Die Koordinaten sind die, in denen du ohnehin zeichnest -- meist Overlay-Pixel. Ein Rechteck ist eine Ecke und eine Größe, so wie jeder Zeichenaufruf der Engine eines entgegennimmt. Eine Berührung genau an einer Kante zählt als Berührung, und ein Rechteck ohne Breite oder Höhe berührt überhaupt nichts. Keine davon bewegt etwas. Nimm `require("bump")` und lass es die Arbeit tun, wenn du nicht nur wissen willst, *ob* sich zwei Dinge getroffen haben, sondern *wo das bewegte Objekt am Ende steht* -- an einer Wand entlanggleitend, statt tot in ihr stecken zu bleiben; genau dafür wird es mitgeliefert. [#collidecircles] ==== collideCircles [source,text] ---- touching = collideCircles(x1, y1, radius1, x2, y2, radius2) ---- Ob sich zwei Kreise überschneiden. Billiger, als es aussieht: Für eine Ja-oder-Nein-Frage wird keine Quadratwurzel gezogen. *Parameter:* * `x1`, `y1`, `radius1` -- Zahlen; der erste Kreis. * `x2`, `y2`, `radius2` -- Zahlen; der zweite. *Rückgabe:* `true` oder `false`. *Seit:* 3.00. *Siehe auch:* <> [#collidepointcircle] ==== collidePointCircle [source,text] ---- inside = collidePointCircle(pointX, pointY, x, y, radius) ---- Ob ein Punkt innerhalb eines Kreises liegt. Der Rand zählt als innen. *Parameter:* * `pointX`, `pointY` -- Zahlen; der Punkt. * `x`, `y`, `radius` -- Zahlen; der Kreis. *Rückgabe:* `true` oder `false`. *Seit:* 3.00. [#collidepointpolygon] ==== collidePointPolygon [source,text] ---- inside = collidePointPolygon(pointX, pointY, points) ---- Ob ein Punkt innerhalb eines Polygons beliebiger Form liegt, konkave eingeschlossen. Die Tabelle ist eine flache Liste aus `x`, `y`, `x`, `y` -- die Form, die eine Hitbox in den meisten Spielen ohnehin hat -- und der Umlaufsinn spielt keine Rolle. Weniger als drei Ecken enthalten nichts. Höchstens 256 Ecken. *Parameter:* * `pointX`, `pointY` -- Zahlen; der Punkt. * `points` -- Tabelle; eine gerade Anzahl von Zahlen, abwechselnd x und y. *Rückgabe:* `true` oder `false`. *Seit:* 3.00. .Beispiel [source,lua] ---- -- Hat der Schuss den Drachen getroffen, dessen Umriss kein Rechteck ist? 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) ---- Ob ein Punkt innerhalb eines Rechtecks liegt. Der Rand zählt als innen. *Parameter:* * `pointX`, `pointY` -- Zahlen; der Punkt. * `x`, `y`, `width`, `height` -- Zahlen; das Rechteck. *Rückgabe:* `true` oder `false`. *Seit:* 3.00. .Beispiel [source,lua] ---- -- Eine Hitbox in der Größe des Sprites, zu dem sie gehört. 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) ---- Ob sich ein Rechteck und ein Kreis überschneiden; dazu wird der Punkt des Rechtecks gesucht, der dem Mittelpunkt des Kreises am nächsten liegt. *Parameter:* * `x`, `y`, `width`, `height` -- Zahlen; das Rechteck. * `circleX`, `circleY`, `radius` -- Zahlen; der Kreis. *Rückgabe:* `true` oder `false`. *Seit:* 3.00. [#colliderects] ==== collideRects [source,text] ---- touching = collideRects(x1, y1, width1, height1, x2, y2, width2, height2) ---- Ob sich zwei Rechtecke überschneiden. Die Funktion, zu der du am häufigsten greifen wirst. *Parameter:* * `x1`, `y1`, `width1`, `height1` -- Zahlen; das erste Rechteck. * `x2`, `y2`, `width2`, `height2` -- Zahlen; das zweite. *Rückgabe:* `true` oder `false`. *Seit:* 3.00. *Siehe auch:* <> [#collidesegments] ==== collideSegments [source,text] ---- crossing = collideSegments(ax1, ay1, ax2, ay2, bx1, by1, bx2, by2) ---- Ob sich zwei Strecken kreuzen: ein Schuss entlang einer Bahn gegen eine Wand oder der Schritt, den ein bewegtes Objekt machen will, gegen die Kante, die es nicht überschreiten darf. Strecken, die aufeinander liegen, ergeben `false`, weil „wo kreuzen sie sich“ dann keine eindeutige Antwort hat. *Parameter:* * `ax1`, `ay1`, `ax2`, `ay2` -- Zahlen; die Enden der ersten Strecke. * `bx1`, `by1`, `bx2`, `by2` -- Zahlen; die der zweiten. *Rückgabe:* `true` oder `false`. *Seit:* 3.00. [#color] === Farbe Die Vordergrund- und die Hintergrundfarbe sind globale Werte der Engine statt Argumente jedes Aufrufs: du setzt sie mit `colorForeground` und `colorBackground`, und jeder spätere Zeichenaufruf liest sie, bis sie sich ändern. Die Vordergrundfarbe verwenden die Formprimitive `overlay*` und TrueType-Text aus `fontPrint` und `fontToSprite`; die Hintergrundfarbe verwenden `overlayClear` und Text in `FONT_QUALITY_SHADED`. `overlayPrint` verwendet keine von beiden. Kanäle sind Ganzzahlen von `0` bis `255`, auf diesen Bereich begrenzt, und beide Farben nehmen ein optionales Alpha an. Die Aufrufe, die sie verwenden, stehen in den Abschnitten <> und <>. [#colorbackground] ==== colorBackground [source,text] ---- colorBackground(r, g, b) colorBackground(r, g, b, a) ---- Setzt die Hintergrundfarbe: womit `overlayClear` das Overlay füllt und was Text in `FONT_QUALITY_SHADED` aus `fontPrint` und `fontToSprite` hinter seine Glyphen malt. Beim Start ist sie transparentes Schwarz, und Alpha ist ohne Angabe `0`, sodass `colorBackground(0, 0, 0)` gefolgt von `overlayClear()` das Overlay wieder unsichtbar macht. Ein teilweises Alpha über Schwarz dunkelt das Video ab; `255` verdeckt es. Kanalwerte außerhalb des Bereichs werden begrenzt statt abgewiesen. *Parameter:* * `r`, `g`, `b` -- Rot, Grün und Blau, `0` bis `255`. * `a` -- Deckkraft, `0` (transparent) bis `255` (deckend); Standard `0`. *Seit:* 1.x *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Deckendes Schwarz hinter dem Kalibrierungsbildschirm, transparent während des Spiels. 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) ---- Setzt die Vordergrundfarbe, die `overlayBox`, `overlayCircle`, `overlayEllipse`, `overlayLine` und `overlayPlot` verwenden, und die Textfarbe für `fontPrint` und `fontToSprite`. Beim Start ist sie deckendes Weiß, und Alpha ist ohne Angabe `255`, das Gegenteil von `colorBackground`. Sprites und `overlayPrint` werden mit ihren eigenen Pixeln gezeichnet und nicht eingefärbt. Kanalwerte außerhalb des Bereichs werden begrenzt statt abgewiesen. *Parameter:* * `r`, `g`, `b` -- Rot, Grün und Blau, `0` bis `255`. * `a` -- Deckkraft, `0` (transparent) bis `255` (deckend); Standard `255`. *Seit:* 1.x *Siehe auch:* <>, <>, <>, <> .Beispiel [source,lua] ---- -- Punktestand in Weiß, Warnung beim letzten Leben in Rot, beides aus derselben Schriftart. 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] === Befehlszeile Drei Aufrufe melden Zahlen, die der Benutzer beim Start von Singe gewählt hat. Es gibt sie, damit sich ein Spiel vom Launcher aus abstimmen lässt, statt das Spiel bearbeiten zu müssen. Alle drei sind Erweiterungen von Hypseus Singe, und Singe selbst macht nichts mit den Werten: was sie bedeuten, entscheidet das Spiel. Die Optionen sind in xref:Manual.adoc#commandlineoptions[Befehlszeilenoptionen] beschrieben, und sie lassen sich auch in einer Einstellungsdatei setzen (siehe xref:Manual.adoc#settings[Die Einstellungsdatei]). Keiner der drei Werte ändert sich während eines Laufs, lies also jeden einmal und behalte ihn. [#getfvalue] ==== getFValue [source,text] ---- value = getFValue() ---- Hypseus-Erweiterung. Gibt die als `--fvalue` angegebene Zahl zurück oder `0`, wenn die Option nicht verwendet wurde. Sie ist ein Kanal für eine einzige Zahl von demjenigen, der Singe gestartet hat, zum Spiel, gedacht für einen Wert, den ein Frontend von Start zu Start ändern will -- einen Schwierigkeitsgrad, eine Anzahl Start-Credits, einen Menüindex. Die Engine reicht ihn nur durch. Der Wert reicht von `0` bis `100000` und behält drei Nachkommastellen, so viele, wie Hypseus behält, sodass ein Spiel, das die Zahl vergleicht, dieselbe sieht wie dort. *Rückgabe:* Zahl. *Seit:* 3.00 (Hypseus) *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Der Launcher übergibt --fvalue 2 für den Automaten auf schwer. local difficulty = getFValue() if difficulty == 0 then difficulty = 1 end ---- [#ratiogetx] ==== ratioGetX [source,text] ---- factor = ratioGetX() ---- Hypseus-Erweiterung. Gibt die als `--xratio` angegebene Zahl zurück oder `0`, wenn die Option nicht verwendet wurde. Für Hypseus geschriebene Lightgun-Spiele lesen sie und strecken damit ihre eigenen Lightgun-Koordinaten, für einen Automaten, dessen Bildschirm nicht die Form hat, in der das Video gemastert wurde -- `1.33` bildet ein 16:9-Panel auf 4:3-Material ab. Singe wendet sie auf nichts an; die Rechnung macht das Spiel, genau wie in Hypseus, und deshalb behandelt jedes Spiel, das sie liest, `0` und alles unter `1` als `1`. Der Wert reicht von `0` bis `100` und behält zwei Nachkommastellen, so wie Hypseus ihn behält. *Rückgabe:* Zahl. *Seit:* 3.00 (Hypseus) *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Die Form der Lightgun-Koordinaten, die dieser Automat will. local ratioX = ratioGetX() if ratioX <= 1 then ratioX = 1 end local offsetX = (ratioX - 1) * (overlayGetWidth() / 2) ---- [#ratiogety] ==== ratioGetY [source,text] ---- factor = ratioGetY() ---- Hypseus-Erweiterung. Die senkrechte Hälfte des Paares: die als `--yratio` angegebene Zahl oder `0`, wenn die Option nicht verwendet wurde. Siehe <>. *Rückgabe:* Zahl. *Seit:* 3.00 (Hypseus) *Siehe auch:* <>, <> .Beispiel [source,lua] ---- local ratioY = ratioGetY() if ratioY <= 1 then ratioY = 1 end ---- [#controller] === Controller Singe unterstützt bis zu vier Gamecontroller mit den Indizes `0` bis `3`, die beim Start und immer dann automatisch geöffnet werden, wenn einer eingesteckt oder entfernt wird; ein `controllerLoad` gibt es nicht. Analoge Achsen werden über `onControllerMoved` gemeldet (siehe <>) und mit `controllerGetAxis` abgefragt, im Rohbereich von SDL von `-32768` bis `32767`. Digitale Tasten und jede Richtung jeder Achse sind Schalter wie die Tasten der Tastatur: sie erreichen `onInputPressed` und `onInputReleased`, wenn eine Belegung in `controls.cfg` einen Code aus den Tabellen `GAMEPAD_0` bis `GAMEPAD_3` verwendet (zum Beispiel `INPUT_ACTION_1 = { GAMEPAD_0.BUTTON_A }`), und `controllerGetButton` fragt den aktuellen Zustand einer Taste ab. `SINGE_DEAD_ZONE` aus dem Eintrag `DEAD_ZONE` in `controls.cfg` ist die Schwelle, die die Engine selbst verwendet, bevor eine Achsenrichtung als gedrückter Schalter zählt; behandle Achsenbewegungen darunter als Rauschen. Einzelnen Achsen kann mit `DEAD_ZONES` jeweils eine andere Schwelle gegeben werden und den analogen Triggern mit `TRIGGER_THRESHOLD` eine eigene (zurückgelesen als `SINGE_TRIGGER_THRESHOLD`); siehe xref:Manual.adoc#deadzones[Totzonen und Trigger]. Ein Gerät, das SDL nicht als Gamepad erkennt, wird trotzdem als eines geöffnet, über ein Mapping, das Singe dafür schreibt, sodass ein Arcade-Encoder oder ein Flightstick in diesen Slots wie jedes Pad erscheint -- siehe xref:Manual.adoc#devices[Jedes Gerät ist ein Gamepad]. [#controllerdorumble] ==== controllerDoRumble [source,text] ---- controllerDoRumble(strength, duration) controllerDoRumble(controller, strength, duration) ---- Hypseus-Erweiterung. Lässt einen Controller mit beiden Motoren in einer von vier Stärken für eine von vier Dauern vibrieren. Die Form mit zwei Argumenten spricht Controller `0` an. Ein Slot ohne Controller tut nichts (Hypseus bricht dort ab, deshalb sichern sich seine Spiele mit `controllerIsValid` ab); eine Stärke oder Dauer außerhalb von `1` bis `4` oder ein Index außerhalb von `0` bis `3` bricht das Skript ab. Ein Controller ohne Vibrationsunterstützung ignoriert den Aufruf. *Parameter:* * `controller` -- Controller-Index, `0` bis `3`. * `strength` -- `1` bis `4`, von einem Viertel bis zur vollen Stärke. * `duration` -- `1` bis `4`, ergibt 150, 300, 600 oder 1200 ms. *Seit:* 3.00, als Erweiterung von Hypseus Singe. *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Ein kurzer Stoß bei jedem Treffer, ein langer mit voller Stärke, wenn der Spieler stirbt. 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) ---- Gibt die Stellung einer analogen Achse zurück, wie die Engine sie zuletzt gesehen hat. Der Wert wird bei jedem Achsenereignis aktualisiert, auch bei Bewegung innerhalb der Totzone, und ist `0` für einen Controller, der nicht verbunden ist, oder eine Achse, die sich nie bewegt hat. Sticks reichen von `-32768` bis `32767`; Trigger ruhen bei `0` und erreichen voll gedrückt `32767`. Ein Index außerhalb von `0` bis `3` oder eine Achse außerhalb von `0` bis `5` bricht das Skript ab. *Parameter:* * `controller` -- Controller-Index, `0` bis `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`) oder `GAMEPAD_AXIS_RIGHT_TRIGGER` (`5`). *Rückgabe:* Ganzzahl, `-32768` bis `32767`. *Seit:* 2.00 *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Lenken mit dem linken Stick, Gas geben mit dem rechten Trigger. 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) ---- Gibt zurück, ob eine Taste gerade gehalten wird, direkt vom Controller gelesen statt aus der Ereigniswarteschlange. Verwende es zum Aufladen durch Gedrückthalten und für ähnliche Fälle, in denen `onInputPressed` und `onInputReleased` umständlich sind. Ein Slot ohne Controller ergibt `false`. Der Tastencode muss aus der Tabelle `GAMEPAD_N` desselben Controller-Index stammen; ein Code aus der Tabelle eines anderen Controllers oder ein Achsencode fällt aus dem Tastenbereich und bricht das Skript ab, ebenso ein Index außerhalb von `0` bis `3`. *Parameter:* * `controller` -- Controller-Index, `0` bis `3`. * `button` -- ein Wert `BUTTON_*` oder `DPAD_*` aus der passenden Tabelle, etwa `GAMEPAD_0.BUTTON_A.value`. *Rückgabe:* Boolean. *Seit:* 2.10 *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Aufladen, solange A gehalten wird, feuern beim Loslassen. 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) ---- Der Name des Geräts in einem Controller-Slot, wie SDL ihn meldet, oder `nil`, wenn der Slot leer ist. Ein Gerät, das SDL nicht erkannt hat, meldet seinen eigenen Namen über das Mapping, das Singe dafür geschrieben hat, sodass ein Service-Bildschirm einen Arcade-Encoder ebenso leicht mit Namen auflisten kann wie ein Markenpad. Ein Index außerhalb von `0` bis `3` bricht das Skript ab. *Parameter:* * `controller` -- Controller-Index, `0` bis `3`. *Rückgabe:* String oder `nil`. *Seit:* 3.00 *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Ein Service-Bildschirm, der auflistet, was eingesteckt ist. 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() ---- Hypseus-Erweiterung, in Singe immer `0`. Hypseus addiert diesen Offset zu einem Schaltercode, der von einem Gamepad kam, damit ein Spiel erkennen kann, welches Gerät was gedrückt hat, und den Schalter trotzdem durch Abziehen zurückgewinnt. Singe liefert Gamepad-Eingaben als gewöhnliche belegte Schalter ohne eine solche Markierung, deshalb antwortet es mit `0`: ein Spiel, das auf den verschobenen Bereich prüft, nimmt seinen anderen Zweig und liest den Schalter so, wie er ist. *Rückgabe:* eine Ganzzahl, immer `0`. *Seit:* 3.00 *Siehe auch:* <> [#controllerhowmany] ==== controllerHowMany [source,text] ---- count = controllerHowMany() ---- Hypseus-Erweiterung. Wie viele Gamepads geöffnet sind, von den vier Slots, die Singe hat. Da jeder Joystick, den Singe öffnen kann, zum Gamepad wird (siehe xref:Manual.adoc#devices[Jedes Gerät ist ein Gamepad]), werden hier auch ein Arcade-Encoder oder ein Spinner mitgezählt. Die Anzahl ändert sich, wenn Geräte ein- und ausgesteckt werden, lies sie also, wenn du sie brauchst, statt einmal beim Start. Sie sagt nichts darüber, welche Slots besetzt sind: ein Gerät kann Slot 0 leer lassen, während Slot 1 in Gebrauch ist, deshalb ist <> weiterhin der Weg, nach einem bestimmten Slot zu fragen. *Parameter:* keine. *Rückgabe:* Zahl, `0` bis `4`. *Seit:* 3.00, als Erweiterung von Hypseus Singe. *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Zwei Spieler nur anbieten, wenn Spieler zwei etwas zum Festhalten hat. local twoPlayer = controllerHowMany() >= 2 ---- [#controllerisvalid] ==== controllerIsValid [source,text] ---- valid = controllerIsValid(controller) ---- Hypseus-Erweiterung. Gibt zurück, ob gerade ein Controller im Slot geöffnet ist; Controller kommen und gehen beim Ein- und Ausstecken im laufenden Betrieb, frag also vor jedem `controllerDoRumble`. Ein Index außerhalb von `0` bis `3` bricht das Skript ab. *Parameter:* * `controller` -- Controller-Index, `0` bis `3`. *Rückgabe:* Boolean. *Seit:* 3.00, als Erweiterung von Hypseus Singe. *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Die Pad-Hinweise nur zeigen, wenn es ein Pad gibt, auf das sie hinweisen können. 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) ---- Hypseus-Erweiterung, angenommen und ignoriert. Warum es den Offset, den sie verlangt, in Singe nicht gibt, steht bei <>. Ihn einzuschalten, ohne die Ereignisse zu markieren, wäre schlimmer, als ihn ausgeschaltet zu lassen, weil das Spiel dann einen Offset von Schaltercodes abziehen würde, die nie einen trugen. *Rückgabe:* nichts. *Seit:* 3.00 *Siehe auch:* <> [#debug] === Debugging Zwei Aufrufe, um zu sehen, was ein Skript tut: `debugPrint` schreibt Text, und `lineDraw` zeichnet für einen Frame eine Linie über die 3D-Szene (`physicsSetDebug` und `navDraw` zeichnen über dieselben Linien). Ausgegebenes geht an die Konsole, von der aus Singe gestartet wurde, ist also während der Entwicklung sichtbar und in einem Automaten harmlos, wo `--noconsole` es stummschaltet. Läuft Singe mit `--trace`, wird außerdem jeder API-Aufruf, auch dieser, mit seinen Argumenten in `trace.txt` im Datenordner geschrieben; so bekommst du eine dauerhafte Aufzeichnung. Die Befehlszeilenoptionen stehen im Kapitel über das Starten von Singe; die Integration von ZeroBrane Studio in `zbstudio/` bietet einen Debugger, wenn Ausgeben nicht reicht. [[traceheader]] ==== Der Trace-Kopf Ganz gleich, ob `--program` oder `--trace` sie geöffnet hat: `trace.txt` beginnt mit einem Block, der den Rechner und den Lauf beschreibt, zwischen zwei Linien, sodass er sich als Ganzes in einen Fehlerbericht einfügen lässt: ---- ---------------------------------------------------------------- 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` ist der Renderer, der tatsächlich erzeugt wurde, und das GPU-Backend, das er gewählt hat, oder ein Hinweis, dass es kein GPU-Gerät gibt und 3D nicht verfügbar ist. `Decoder` ist das, was auf diesem Rechner für Video versucht wird; welchen Decoder ein bestimmtes Video tatsächlich bekommen hat, hält der Trace beim Öffnen dieses Videos ein paar Zeilen weiter unten fest, da ein Codec womöglich keinen Hardwareweg anbietet. `Settings` listet auf, was die Einstellungsdatei geliefert hat, `none`, wenn es keine gab. Jedes Spiel in einer Warteschlange bekommt seine eigene Datei und seinen eigenen Kopf. [#debugprint] ==== debugPrint [source,text] ---- debugPrint(message) ---- Gibt eine Zeile auf der Konsole (Standardausgabe) aus. Wurde Singe mit `--noconsole` gestartet, wird nichts ausgegeben; mit `--trace` landen der Aufruf und seine Nachricht außerdem in `trace.txt`. Das Argument muss ein String oder eine Zahl sein; alles andere bricht das Skript ab, verpacke Werte also in `tostring` oder verwende `string.format`. Der Aufruf ist billig genug, um im ausgelieferten Code zu bleiben, wird aber nicht gedrosselt, eine Ausgabe in `onOverlayUpdate` läuft also in jedem Frame. *Parameter:* * `message` -- der auszugebende Text; eine Zahl wird umgewandelt. *Seit:* 1.x *Siehe auch:* <> .Beispiel [source,lua] ---- -- Protokollieren, warum der Schuss danebenging, mit dem Frame, in dem es passiert ist. 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) ---- Zeichnet eine gerade Linie zwischen zwei Punkten in Weltkoordinaten über die Szene, nur für den aktuellen Frame, weiß, sofern keine Farbe angegeben ist. Ruf es in jedem Frame auf, um eine Linie auf dem Bildschirm zu halten, wie beim Overlay. Die Linie wird gegen die Tiefe der Szene getestet, schreibt aber keine Tiefe, versteckt sich also wie alles andere hinter Wänden und verdeckt selbst nie etwas. Solange die Szene deaktiviert ist, wird nichts gezeichnet. Sie ist dafür gedacht, zu sehen, was ein Skript tut (einen Raycast, einen Pfad, eine Bounding Box); `physicsSetDebug` und `navDraw` zeichnen über dieselben Linien. *Parameter:* * `x0, y0, z0` -- der Startpunkt, in Welteinheiten. * `x1, y1, z1` -- der Endpunkt, in Welteinheiten. * `r, g, b` -- die Linienfarbe, `0` bis `255` je Kanal, begrenzt; weiß, wenn weggelassen. *Seit:* 3.00. *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Zeigen, wohin der Geschützturm zielt und ob der Schuss treffen würde. 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] === Disc Die *Disc* ist das Haupt-Laserdisc-Video, das, für dessen Emulation Singe ursprünglich gebaut wurde. Ein Spiel hat höchstens eine Disc, die beim Start aus dem in `games.dat` genannten `VIDEO` oder aus `--disc` / `--framefile` auf der Befehlszeile geöffnet wird; ein Spiel, das keine nennt, läuft ohne Disc (siehe <>). Alle `disc*`-Funktionen arbeiten mit dieser einen impliziten Disc und nehmen kein Handle. Frame-Nummern sind nullbasierte Positionen in der Videodatei (oder in der zusammengesetzten Frame-Datei) und der Zeitbezug für jede Verzweigungsentscheidung in einem Laserdisc-Spiel. Ohne Disc geben die Abfragefunktionen `0` zurück, `discGetState` meldet `DISC_EJECTED`, und die Transportfunktionen schreiben eine Trace-Zeile und tun nichts. Zusätzliche Clips über der Disc sind Aufgabe der Familie <>. [#discaudio] ==== discAudio [source,text] ---- discAudio(channel, enabled) ---- Schaltet einen Kanal der Tonspur der Disc ein oder aus. Der Kanal wird auf die eingestellte Laserdisc-Lautstärke (`--volume_vldp`, Standard `100`) gesetzt, wenn `enabled` `true` ist, und auf `0`, wenn es `false` ist; der andere Kanal bleibt, wie er ist. Viele Originaltitel hatten Erzählung oder eine Datenspur nur auf einem Kanal, und mit diesem Aufruf bringst du sie zum Schweigen. Ein anderer Kanal als `1` oder `2` beendet das Skript. Ohne Disc tut der Aufruf nichts. *Parameter:* * `channel` -- `1` für den linken Kanal, `2` für den rechten. * `enabled` -- Boolean. `true` setzt den Kanal wieder auf die eingestellte Laserdisc-Lautstärke, `false` schaltet ihn stumm. *Seit:* 1.x *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Der rechte Kanal dieser Disc trägt die Datenspur des Arcade-Automaten. function startGame() discAudio(1, true) discAudio(2, false) discSkipToFrame(SCENE_FIRST_FRAME) end ---- [#discaudiosuffix] ==== discAudioSuffix [source,text] ---- opened = discAudioSuffix(suffix) ---- Hypseus-Erweiterung. Schaltet die Disc auf eine eigene Audiodatei neben ihrem Video um: `.ogg`, wobei die Basis der Name der Videodatei ohne Endung ist, sodass `movie.m2v` mit `"-es"` die Datei `movie-es.ogg` abspielt. Die Datei wird beim aktuellen Frame geöffnet, und die Wiedergabe richtet sich neu aus, wie bei `discSetAudioTrack`; Lautstärke, Wiedergabezustand und Spurindex bleiben erhalten, sofern die neue Datei so viele Spuren hat. Mit einer Frame-Datei schaltet jedes Segment um. Ein leeres Suffix stellt das Standardaudio wieder her: die `.ogg` neben einer `.m2v` oder den Ton in jedem anderen Container. Gibt `true` zurück, wenn die Datei geöffnet wurde; fehlt sie, läuft das bisherige Audio weiter, nichts wird gemerkt, und `false` kommt zurück (Hypseus hinterlässt in diesem Fall Stille). Das Suffix wird vergessen, wenn das Skript neu geladen wird. Ohne Disc gibt der Aufruf `false` zurück. Verwende ihn für ein Sprachmenü, dessen Spuren als eigene Dateien mitgeliefert werden; Spuren innerhalb einer Datei sind Aufgabe von `discSetAudioTrack`. *Parameter:* * `suffix` -- String, der an den Basisnamen des Videos angehängt wird, etwa `"-es"`; `""` für das Standardaudio. *Rückgabe:* Boolean. *Seit:* 3.00, als Erweiterung von Hypseus Singe. *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Sprachdateien werden als movie.ogg, movie-es.ogg und movie-fr.ogg mitgeliefert. 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(...) ---- Tut nichts. Echte Laserdisc-Player konnten mit einer anderen Geschwindigkeit als 1x laufen; der Software-Player von Singe kann das nicht, deshalb nimmt der Aufruf beliebige Argumente an, schreibt eine Trace-Zeile und kehrt zurück. Er bleibt erhalten, damit Skripte von vor 2.00 ohne Änderungen laden. *Hinweise:* WARNING: *Nicht implementiert.* Nur aus Gründen der Abwärtskompatibilität erhalten. Es gibt keinen Ersatz; die Wiedergabegeschwindigkeit ist auf die Bildrate der Videodatei festgelegt. *Seit:* 1.x *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Alte Skripte umrahmten eine Zeitlupen-Wiederholung manchmal mit Geschwindigkeitswechseln. -- Die Aufrufe sind heute harmlos, aber die Wiederholung läuft mit normaler Geschwindigkeit. discChangeSpeed(0.5) discSkipToFrame(REPLAY_FIRST_FRAME) discChangeSpeed(1) ---- [#discgetaudiotrack] ==== discGetAudioTrack [source,text] ---- track = discGetAudioTrack() ---- Gibt den nullbasierten Index der Tonspur zurück, die die Disc gerade abspielt. Beim Start wählt die Engine die durch `--audio` oder den Eintrag `AUDIO_TRACK` in `games.dat` genannte Spur (Standard `0`), sofern die Disc so viele Spuren hat. Gibt ohne Disc `0` zurück. *Rückgabe:* ganzzahliger Spurindex. *Seit:* 2.10 *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Bei jedem Druck auf die Sprachtaste zur nächsten Synchronfassung wechseln. 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() ---- Gibt zurück, wie viele Audiostreams die Videodatei der Disc enthält. Mehrere Streams sind typischerweise alternative Synchronfassungen. Ein stummes Video meldet `0`, ebenso ein Spiel ohne Disc, sichere Schleifen und Modulo-Rechnungen also gegen null ab. *Rückgabe:* ganzzahlige Anzahl, `0` oder mehr. *Seit:* 2.10 *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Aus den Tonspuren der Disc ein Sprachauswahlmenü bauen. 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) ---- Die Sprache, mit der eine Untertitelspur im eigenen Container der Disc gekennzeichnet ist, als der ISO-639-Code, den die Datei trägt (`eng`, `fra`, `spa`). Eine Spur, die die Datei mit nichts kennzeichnet, ergibt einen leeren String, und das ist kein Fehler -- viele Dateien kennzeichnen gar nichts. *Parameter:* * `track` -- Zahl; `0` bis `discGetSubtitleTracks() - 1`. *Rückgabe:* ein String. *Seit:* 3.00. *Siehe auch:* <>, <> [#discgetsubtitletracks] ==== discGetSubtitleTracks [source,text] ---- count = discGetSubtitleTracks() ---- Wie viele Untertitelspuren der eigene Container der Disc enthält. `0` ohne Disc und `0` für eine Disc, deren Untertitel als eigene `.srt` mitgeliefert werden -- was die übliche Anordnung ist und wofür <> da ist. Spuren, die Bilder statt Wörter enthalten (die Arten von DVD und Blu-ray, VobSub und PGS), werden hier mitgezählt, damit die Nummerierung mit dem übereinstimmt, was jeder andere Player zeigt, aber <> kann sie nicht lesen: da gibt es nichts zu lesen, nur Bitmaps der Wörter. *Parameter:* * Keine. *Rückgabe:* eine Zahl. *Seit:* 3.00. *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Die Sprachen anbieten, die die Disc selbst mitbringt. for track = 0, discGetSubtitleTracks() - 1 do languages[#languages + 1] = { track = track, code = discGetSubtitleLanguage(track) } end ---- [#discgetframe] ==== discGetFrame [source,text] ---- frame = discGetFrame() ---- Gibt den Frame zurück, den die Disc gerade zeigt. Mit einer Frame-Datei ist die Nummer die Position in der zusammengesetzten Folge, Segmentgrenzen sind für das Skript also unsichtbar. Gibt `0` zurück, solange die Disc gestoppt ist, und ohne Disc. Frag ihn in `onOverlayUpdate` ab, um darauf zu reagieren, dass das Video einen Entscheidungspunkt erreicht. *Rückgabe:* ganzzahlige Frame-Nummer. *Seit:* 1.x *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Die Attract-Sequenz in einer Schleife abspielen und im Zeitfenster zum Ducken eine Aufforderung zeigen. 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() ---- Die Anzahl der Frames auf der Disc, also eins mehr als der höchste Frame, den <> findet. Eine Frame-Datei antwortet für die ganze Disc statt für das laufende Segment, die Anzahl ändert sich also nicht, wenn Segmente kommen und gehen. Ohne Disc antwortet sie mit `0`. *Rückgabe:* ganzzahlige Anzahl der Frames, `0` ohne Disc. *Seit:* 3.00. *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Zur Mitte der Disc springen, wo auch immer die liegt. discSearch(math.floor(discGetFrameCount() / 2)) ---- [#discgetheight] ==== discGetHeight [source,text] ---- height = discGetHeight() ---- Gibt die Höhe des Disc-Videos in Pixeln zurück. Ohne Disc gibt es die Höhe der Zeichenfläche zurück (`CANVAS_Y` in `games.dat` oder `--canvas`, Standard `480`), es ist also immer die Größe des Hintergrunds, über den das Overlay gestreckt wird. Kombiniere es mit `discGetWidth`, um dem Overlay dieselbe Auflösung wie dem Video zu geben. *Rückgabe:* ganzzahlige Höhe in Pixeln. *Seit:* 2.00 *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- In der nativen Auflösung der Disc zeichnen, damit Pixelprüfungen und Zeichnen ein Raster teilen. overlaySetResolution(discGetWidth(), discGetHeight()) crosshairLimitX = discGetWidth() - 1 crosshairLimitY = discGetHeight() - 1 ---- [#discgetlanguage] ==== discGetLanguage [source,text] ---- code = discGetLanguage(track) ---- Gibt das in der Videodatei gespeicherte Sprach-Tag für eine der Tonspuren der Disc zurück. Das Tag ist ein dreibuchstabiger ISO-639-Code wie `"eng"` oder `"jpn"`. Spuren ohne dreibuchstabiges Tag ergeben `"unk"`, ebenso jede Spur, wenn es keine Disc gibt. Mit Disc beendet eine Spur außerhalb von `0` bis `discGetAudioTracks() - 1` das Skript. *Rückgabe:* String mit dem Sprachcode. *Seit:* 2.10 *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Die japanische Synchronfassung bevorzugen, wenn die Disc eine hat. for track = 0, discGetAudioTracks() - 1 do if discGetLanguage(track) == "jpn" then discSetAudioTrack(track) break end end ---- [#discgetstate] ==== discGetState [source,text] ---- state = discGetState() ---- Gibt den Transportzustand der Disc als eine der Konstanten `DISC_*` zurück. Ein Spiel ohne Disc meldet `DISC_EJECTED`; nach `discStop` meldet die Disc `DISC_STOPPED` bis zum nächsten `discPlay`, `discSearch` oder `discSkipToFrame`; ansonsten ist der Zustand `DISC_PLAYING`, solange das Video vorrückt, und `DISC_PAUSED`, wenn nicht. Einen eigenen Suchzustand gibt es nicht; eine Disc, die gerade springt, meldet `DISC_PAUSED` oder `DISC_PLAYING`, je nachdem, was angefordert wurde. *Rückgabe:* eine der folgenden Konstanten, die die Engine definiert und die jedem Skript zur Verfügung stehen. [cols="1,1,1",options="header"] |=== | Konstante | Wert | Bedeutung | `DISC_STOPPED` | `2` | Gestoppt (`discStop` wurde aufgerufen) | `DISC_PLAYING` | `3` | Läuft | `DISC_PAUSED` | `4` | Pausiert | `DISC_EJECTED` | `5` | Das Spiel hat keine Disc (siehe <>) |=== *Seit:* 1.x (RDG) *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Die Pausentaste darf kein Anhalten fortsetzen, das das Story-Skript verlangt hat. 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() ---- Gibt die Breite des Disc-Videos in Pixeln zurück. Ohne Disc gibt es die Breite der Zeichenfläche zurück (`CANVAS_X` in `games.dat` oder `--canvas`, Standard `720`). Zusammen mit `discGetHeight` ist das die Größe, auf die das Overlay skaliert wird, weshalb das mitgelieferte Menü beide an `overlaySetResolution` übergibt. *Rückgabe:* ganzzahlige Breite in Pixeln. *Seit:* 2.00 *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Ein Titel-Sprite zentrieren, egal wie breit die Disc gerade ist. overlaySetResolution(discGetWidth(), discGetHeight()) titleSprite = spriteLoad("graphics/title.png") titleX = (discGetWidth() - spriteGetWidth(titleSprite)) / 2 ---- [#discpause] ==== discPause [source,text] ---- discPause() ---- Hält die Wiedergabe beim aktuellen Frame an, der auf dem Bildschirm bleibt. Der Ton der Disc pausiert mit. Der Aufruf wird ignoriert, solange die Disc gestoppt ist, und tut ohne Disc nichts. Fortsetzen mit `discPlay`. *Seit:* 1.x *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Den letzten Frame der Szene halten, während der Spieler eine Tür wählt. 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) ---- Springt zu `frame`, zeigt ihn und pausiert. Das ist dieselbe Bindung wie `discSearch` unter einem zweiten Namen; die beiden sind in jeder Hinsicht austauschbar, einschließlich der dort beschriebenen Regeln für die Begrenzung. Bevorzuge `discSearch` in neuem Code und behalte `discPauseAtFrame` nur beim Portieren alter Skripte. *Hinweise:* WARNING: *Veralteter Alias.* Verwende <> in neuem Code. *Seit:* 1.18 *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Portierter 1.x-Code: auf dem ersten Frame der Szene parken, bis der Spieler bereit ist. function onInputPressed(what) if what == SWITCH_START1 then discPauseAtFrame(SCENE_FIRST_FRAME) waitingForPlayer = true end end ---- [#discplay] ==== discPlay [source,text] ---- discPlay() ---- Startet die Wiedergabe beim aktuellen Frame oder setzt sie fort und hebt den von `discStop` gesetzten Stoppzustand auf. Der Ton läuft mit dem Video weiter, und die Wiedergabeuhr startet beim aktuellen Frame neu, damit eine lange Pause keinen Sprung verursacht. Tut ohne Disc nichts, außer das Stopp-Flag zu löschen. *Seit:* 1.x *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Üblicher Start des Attract-Modus: den ersten Frame anfahren, dann losrollen. function attractBegin() discSearch(ATTRACT_FIRST_FRAME) overlayClear() discPlay() attractRunning = true end ---- [#discsearch] ==== discSearch [source,text] ---- discSearch(frame) ---- Springt mit der Disc zu `frame`, zeigt diesen Frame und pausiert darauf; war die Disc gestoppt, wird der Stoppzustand aufgehoben. Die Disc hält den Frame bis zu `discPlay`, `discSkipToFrame` oder einem anderen Transportaufruf. Ein negativer Frame wird auf den ersten Frame begrenzt und einer hinter dem Ende auf den letzten; mit einer Frame-Datei wird das Segment gewählt, das den Frame enthält, und die Position innerhalb davon begrenzt. Tut ohne Disc nichts. *Parameter:* * `frame` -- Ziel-Frame-Nummer, nullbasiert. *Seit:* 1.x *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Der Spieler hat den linken Gang gewählt: die Szene anfahren und auf das Startsignal warten. function goLeft() discSearch(CORRIDOR_LEFT_FIRST_FRAME) soundPlay(SOUND_DOOR) waitingForPlayer = true end ---- [#discsearchblanking] ==== discSearchBlanking [source,text] ---- discSearchBlanking(...) ---- Tut nichts. Auf Laserdisc-Hardware wählte das, ob der Bildschirm dunkel wurde, während der Player einen Frame suchte; der Software-Player von Singe springt, ohne dunkel zu werden, deshalb nimmt der Aufruf beliebige Argumente an, schreibt eine Trace-Zeile und kehrt zurück. *Hinweise:* WARNING: *Nicht implementiert.* Nur aus Gründen der Abwärtskompatibilität erhalten. Es gibt keinen Ersatz. *Seit:* 1.x *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Skripte von vor 2.00 setzten die Austastmodi oft einmal beim Start. Harmlos. discSearchBlanking(false) discSkipBlanking(false) discSearch(TITLE_FRAME) ---- [#discsetaudiotrack] ==== discSetAudioTrack [source,text] ---- discSetAudioTrack(track) ---- Schaltet die Disc auf eine andere Tonspur um. Bereits eingereihtes Audio der alten Spur wird verworfen, und die Wiedergabe richtet sich neu aus, die Änderung ist also fast sofort zu hören. Mit Disc beendet eine Spur außerhalb von `0` bis `discGetAudioTracks() - 1` das Skript; ohne Disc tut der Aufruf nichts. Verwende ihn, um ein Sprachmenü umzusetzen oder eine zwischen Sitzungen gespeicherte Vorliebe zu beachten. *Parameter:* * `track` -- nullbasierter Index der Tonspur. *Seit:* 2.10 *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Die Sprache anwenden, die der Spieler im Optionsbildschirm gewählt hat. function applyLanguage(choice) if choice >= 0 and choice < discGetAudioTracks() then discSetAudioTrack(choice) settings.language = choice saveSettings() end end ---- [#discsetfps] ==== discSetFPS [source,text] ---- discSetFPS(...) ---- Tut nichts. Die Bildrate stammt aus der Videodatei, deshalb nimmt der Aufruf beliebige Argumente an, schreibt eine Trace-Zeile und kehrt zurück. Nichts im mitgelieferten `Framework.singe` ruft ihn auf; das tun nur Skripte von vor 2.00. *Hinweise:* WARNING: *Nicht implementiert.* Nur aus Gründen der Abwärtskompatibilität erhalten. Die Bildrate wird aus der Videodatei gelesen und kann nicht aus einem Skript gesetzt werden. *Seit:* 1.x *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Ein portiertes 1.x-Skript. Der Aufruf wird ignoriert; Bilder pro Sekunde sind eine Eigenschaft der Datei. discSetFPS(29.97) discSearch(TITLE_FRAME) discPlay() ---- [#discskipbackward] ==== discSkipBackward [source,text] ---- discSkipBackward(frames) ---- Springt um `frames` vom aktuellen Frame zurück, ohne den Wiedergabe- oder Pausenzustand zu ändern: eine laufende Disc spielt von der neuen Position aus weiter, eine pausierte bleibt dort pausiert. Der Aufruf wird ignoriert, solange die Disc gestoppt ist, und ohne Disc. Die Differenz wird nicht geprüft; ein negativer Wert springt vorwärts, und eine Differenz, die größer als der aktuelle Frame ist, wird bei Frame `0` begrenzt. Mit einer Frame-Datei gilt die Differenz für die Frame-Nummer, die `discGetFrame` meldet, ein Sprung kann also in ein früheres Segment hinüberreichen. *Parameter:* * `frames` -- Anzahl der Frames, die vom aktuellen Frame abgezogen werden. *Seit:* 1.x *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Die letzte Sekunde (30 Frames) wiederholen, wenn der Spieler eine Aufforderung verpasst. function missedPrompt() lives = lives - 1 discSkipBackward(30) promptShown = false end ---- [#discskipblanking] ==== discSkipBlanking [source,text] ---- discSkipBlanking(...) ---- Tut nichts. Früher wählte es auf Laserdisc-Hardware, ob der Bildschirm bei einem Sprung dunkel wurde; der Software-Player von Singe wird nie dunkel, deshalb nimmt der Aufruf beliebige Argumente an, schreibt eine Trace-Zeile und kehrt zurück. *Hinweise:* WARNING: *Nicht implementiert.* Nur aus Gründen der Abwärtskompatibilität erhalten. Es gibt keinen Ersatz. *Seit:* 1.x *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Skripte von vor 2.00 setzten die Austastmodi oft einmal beim Start. Harmlos. discSearchBlanking(false) discSkipBlanking(false) discSearch(TITLE_FRAME) ---- [#discskipforward] ==== discSkipForward [source,text] ---- discSkipForward(frames) ---- Springt um `frames` vom aktuellen Frame vorwärts, ohne den Wiedergabe- oder Pausenzustand zu ändern. Das Spiegelbild von `discSkipBackward`: ignoriert, solange gestoppt, und ohne Disc, die Differenz wird nicht geprüft, und ein Ziel hinter dem letzten Frame wird beim letzten Frame begrenzt. Mit einer Frame-Datei gilt die Differenz für die Frame-Nummer, die `discGetFrame` meldet, ein Sprung kann also in ein späteres Segment hinüberreichen. *Parameter:* * `frames` -- Anzahl der Frames, die zum aktuellen Frame addiert werden. *Seit:* 1.x *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Einen wiederkehrenden Spieler das zweisekündige Intro der Szene überspringen lassen. function onInputPressed(what) if what == SWITCH_BUTTON1 and inSceneIntro then discSkipForward(60) inSceneIntro = false end end ---- [#discskiptoframe] ==== discSkipToFrame [source,text] ---- discSkipToFrame(frame) ---- Springt zu `frame` und spielt von dort ab, egal in welchem Zustand die Disc ist; wenn nötig, wird der Stoppzustand aufgehoben. Im Gegensatz dazu springt `discSearch` und pausiert. Ein Frame vor dem Anfang oder hinter dem Ende wird auf den ersten oder letzten Frame begrenzt; mit einer Frame-Datei wird das Segment gewählt, das den Frame enthält, und die Position innerhalb davon begrenzt. Tut ohne Disc nichts, außer das Stopp-Flag zu löschen. *Parameter:* * `frame` -- Ziel-Frame-Nummer, nullbasiert. *Seit:* 1.x *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Beim erneuten Spielen das Intro überspringen. function startGame() if hasPlayedBefore then discSkipToFrame(GAMEPLAY_FIRST_FRAME) else discSkipToFrame(INTRO_FIRST_FRAME) end end ---- [#discstepbackward] ==== discStepBackward [source,text] ---- discStepBackward() ---- Bewegt die Disc genau einen Frame zurück und pausiert darauf, egal in welchem Wiedergabezustand sie vorher war. Ein Schritt bei Frame `0` bleibt auf Frame `0`. Wird wie `discSkipBackward` ignoriert, solange die Disc gestoppt ist. Gedacht für framegenaues Debugging und Service-Bildschirme. Tut ohne Disc nichts. *Seit:* 1.x *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Service-Bildschirm: links und rechts schieben die Disc jeweils um einen Frame. function onInputPressed(what) if what == SWITCH_LEFT then discStepBackward() elseif what == SWITCH_RIGHT then discStepForward() end end ---- [#discstepforward] ==== discStepForward [source,text] ---- discStepForward() ---- Bewegt die Disc genau einen Frame vorwärts und pausiert darauf, egal in welchem Wiedergabezustand sie vorher war. Ein Schritt auf dem letzten Frame bleibt auf dem letzten Frame. Wie `discStepBackward` wird der Aufruf ignoriert, solange die Disc gestoppt ist. Tut ohne Disc nichts. *Seit:* 1.x *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Frame-Finder: Schritt für Schritt durch eine Szene gehen und die Frame-Nummer ausgeben. function onOverlayUpdate() overlayClear() overlayPrint(8, 8, "Frame " .. discGetFrame()) if stepping then discStepForward() end return OVERLAY_UPDATED end ---- [#discstop] ==== discStop [source,text] ---- discStop() ---- Stoppt die Disc. Die Wiedergabe hält an, das Video wird durch den klassischen blauen Bildschirm ersetzt, `discGetState` meldet `DISC_STOPPED`, und `discGetFrame` gibt `0` zurück, bis die Disc wieder gestartet wird. Während sie gestoppt ist, werden `discPause`, `discSkipForward`, `discSkipBackward`, `discStepForward` und `discStepBackward` ignoriert. Jeder der Aufrufe `discPlay`, `discSearch` oder `discSkipToFrame` hebt den Stoppzustand auf. Ein zweites `discStop` wird ignoriert. *Seit:* 1.x *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Game Over: auf den blauen Bildschirm hinter der Punktetabelle zurückfallen. function gameOver() discStop() soundPlay(SOUND_GAME_OVER) showScores = true end ---- [#emitter] === Emitter Emitter erzeugen, bewegen und zeichnen Partikel; siehe <>. Handles sind Ganzzahlen. Ein 2D-Emitter lebt in Overlay-Koordinaten, Y nach unten, und zeichnet, wenn `emitterDraw` es aus `onOverlayUpdate` verlangt; ein 3D-Emitter sitzt an einem Knoten, lebt in Welteinheiten und zeichnet sich selbst in der Szene. Die Rezeptaufrufe lassen sich in beliebiger Reihenfolge machen, vor oder nach dem Start des Emitters, und wirken auf die nächsten geborenen Partikel (einige wenige, unten vermerkt, auch auf die lebenden). Alles, was als Minimum und Maximum angegeben wird, wird für jedes Partikel zufällig dazwischen gezogen. Partikel laufen mit der Uhr weiter, höchstens eine Zehntelsekunde pro Frame, und halten an, solange das Spiel pausiert ist. [#emitternew] ==== emitterNew [source,text] ---- emitter = emitterNew() emitter = emitterNew(node) ---- Erzeugt einen Emitter. Ohne Argument ist es ein 2D-Emitter an der Overlay-Position `(0, 0)`, der mit `emitterSetPosition` bewegt wird; mit einem Knoten ist es ein 3D-Emitter, dessen Partikel an der Weltposition dieses Knotens geboren werden, und er braucht die 3D-Szene, sodass der Aufruf auf einem Rechner ohne sie das Skript beendet (2D-Emitter funktionieren überall). Ein neuer Emitter strömt noch nicht; ruf `emitterStart` oder `emitterBurst` auf. Wird der Knoten gelöscht, werden seine Emitter mitgelöscht. Standardwerte: 50 Partikel pro Sekunde, die ein bis zwei Sekunden leben, 30 Grad Streuung, Weiß, das zu transparent verblasst, normales Blending, keine Schwerkraft, keine Dämpfung, keine Drehung, kein Radius, keine Textur, kein Schweif und keine Kollision, ein Pool von 1000; ein 2D-Emitter schickt sie mit 50 bis 100 Overlay-Einheiten pro Sekunde den Bildschirm hinauf und lässt sie von 16 auf 4 schrumpfen, ein 3D-Emitter mit 1 bis 2 Welteinheiten pro Sekunde die Y-Achse hinauf, schrumpfend von 0,2 auf 0,05. *Rückgabe:* Das Handle des Emitters. *Seit:* 3.00. *Siehe auch:* <>, <>, <>, <> .Beispiel [source,lua] ---- -- Abgas vom Triebwerksknoten des Schiffs; das Rezept wird gesetzt, bevor der Strom beginnt. 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) ---- Entfernt den Emitter, seine lebenden Partikel und seine Kopie der Textur sofort; nichts blendet aus. Verwende stattdessen `emitterStop`, um die letzten Partikel zu Ende leben zu lassen. Ein 3D-Emitter wird auch von selbst entfernt, wenn sein Knoten gelöscht wird. Ein Handle, das kein lebender Emitter ist, beendet das Skript, leere die Variable also danach. *Seit:* 3.00. *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Die Explosion ist vorbei: ihren Emitter freigeben, sobald der letzte Funke erloschen ist. 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) ---- Das Bild, das jedes Partikel trägt: ein geladenes Sprite oder keines (ein Argument oder `nil`) für die eingebaute weiche Scheibe. Der Emitter behält seine eigene Kopie der Pixel des Sprites, jeden Frame eines animierten Sprites eingeschlossen, das Sprite kann danach also entladen werden. Das Setzen der Textur setzt den Frame-Bereich auf Frame `0` zurück und setzt jedes lebende Partikel auf Frame `0`, ruf `emitterSetFrames` also danach auf, nicht davor. Blending und Tönung wirken auf das Bild wie auf die Scheibe. *Seit:* 3.00. *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Schnee: ein Spritesheet mit sechs Flockenformen, eine pro Partikel zufällig gewählt. 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) ---- Mit einem animierten Sprite als Textur nimmt jedes neue Partikel einen zufälligen Frame von `first` bis `last` und behält ihn sein Leben lang; Frames zählen ab `0`. Die Werte werden auf die Frames begrenzt, die die Textur hat, und `last` wird auf `first` angehoben, wenn es kleiner ist. Standard: nur Frame `0`, auf den auch `emitterSetTexture` zurücksetzt. Mit der eingebauten Scheibe oder einem Sprite aus einem einzigen Frame hat der Bereich keine Wirkung. *Seit:* 3.00. *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Das Wölkchen-Sheet hat vier weiche Wölkchen, dann vier härtere Trümmerbrocken. emitterSetTexture(smoke, puffSheet) emitterSetFrames(smoke, 0, 3) emitterSetTexture(debris, puffSheet) emitterSetFrames(debris, 4, 7) ---- [#emittersetblend] ==== emitterSetBlend [source,text] ---- emitterSetBlend(emitter, mode) ---- Wie sich Partikel mit dem verbinden, was hinter ihnen liegt. `PARTICLE_ALPHA` (Standard) blendet normal, für Rauch, Staub, Regen und Trümmer; `PARTICLE_ADD` addiert Licht, für Feuer, Funken, Leuchten und Magie, und dunkelt nie ab. Wirkt sofort auf die lebenden Partikel. Alles andere als diese beiden Konstanten beendet das Skript. *Seit:* 3.00. *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Funken leuchten; der Rauch über ihnen nicht. 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) ---- Nur 2D: wohin `emitterDraw` die Partikel setzt. `PARTICLE_OVER` (Standard) zeichnet über allem auf dem Overlay; `PARTICLE_UNDER` zeichnet unter dem Overlay und über dem Video und der 3D-Szene, für Rauch hinter dem Punktestand. Bei einem 3D-Emitter wird die Einstellung angenommen und ignoriert. Alles andere als diese beiden Konstanten beendet das Skript. *Seit:* 3.00. *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Regen fällt hinter dem HUD, die Trefferfunken davor. 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) ---- Wie viele Partikel pro Sekunde ein gestarteter Emitter erzeugt; Standard `50`, negative Werte werden zu `0`. Bruchteile summieren sich, sodass `0.5` alle zwei Sekunden eines erzeugt. Ändere den Wert während des Stroms, um eine Flamme oder eine Schubdüse zu drosseln. Ein voller Pool (`emitterSetMax`) erzeugt keine weiteren, bis einige sterben, egal bei welcher Rate. *Seit:* 3.00. *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Der Abgasstrahl der Schubdüse folgt dem Gashebel. 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) ---- Wie lange jedes neue Partikel lebt, zufällig zwischen den beiden gezogen; Standard `1` bis `2`. Seine Größe und Farbe laufen über diese Zeit von ihrem Anfang zu ihrem Ende, die Lebensdauer bestimmt also das Tempo jedes Ausblendens. Das Minimum wird bei `0.001` oder darüber gehalten, und das Maximum wird auf das Minimum angehoben, wenn es kleiner ist. Lebende Partikel behalten die Lebensdauer, mit der sie geboren wurden. *Seit:* 3.00. *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Mündungsfeuer: ein Stoß, der in einer Zehntelsekunde vorbei ist. 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) ---- Nur 3D: blendet ein Partikel über `distance` Welteinheiten aus, wo es auf Geometrie dahinter trifft, sodass ein Rauchwölkchen keine harte Linie zeichnet, wo es eine Wand kreuzt, und ein Springbrunnen in sein Becken eintaucht, statt es zu zerschneiden. `0` (der Standard) schaltet es aus; negative Werte werden zu `0`. Solange irgendein Emitter es verwendet, wird die Tiefe der Szene pro Frame einmal mehr gerendert, ein Raspberry Pi sollte es also sparsam einsetzen. Ein 2D-Emitter speichert den Wert und ignoriert ihn. *Seit:* 3.00. *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Rauch aus der Feuerschale wird weich am Pfosten, an dem er vorbeisteigt. 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) ---- Anfangsgeschwindigkeit jedes neuen Partikels, zufällig zwischen den beiden gezogen, in Overlay-Einheiten pro Sekunde (2D) oder Welteinheiten pro Sekunde (3D); Standard `50` bis `100` in 2D und `1` bis `2` in 3D. Das Maximum wird auf das Minimum angehoben, wenn es kleiner ist. Die Geschwindigkeit bestimmt, wie weit ein Partikel kommt, bevor Dämpfung, Schwerkraft und seine Lebensdauer übernehmen. *Seit:* 3.00. *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Ein Springbrunnen wirft Tropfen kräftig und gerade; Staub bewegt sich kaum. 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) ---- Die mittlere Richtung, in die neue Partikel aufbrechen; nur die Richtung zählt, nicht die Länge. 2D-Standard `(0, -1)`, den Bildschirm hinauf, da Y im Overlay nach unten wächst; 3D-Standard `(0, 1, 0)`, die Y-Achse der Welt hinauf. Ein 2D-Emitter ignoriert `z`; ein 3D-Emitter, der zwei Werte bekommt, erhält `z = 0`. Ein Nullvektor wird als `(0, -1, 0)` behandelt. Bei einem 3D-Emitter ist die Richtung in Weltachsen angegeben, nicht in denen des Knotens, dreh sie also selbst, wenn sich der Knoten dreht. *Seit:* 3.00. *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Das Abgas strömt hinter dem Schiff hinaus, egal wohin es zeigt. 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) ---- Der halbe Winkel des Kegels (3D) oder Fächers (2D) um die Richtung, in den neue Partikel geworfen werden, `0` bis `180`; `0` ist eine gerade Linie, `180` jede Richtung. Standard `30`; Werte außerhalb des Bereichs werden begrenzt. In 3D werden die Richtungen gleichmäßig über die Kappe des Kegels verteilt, sodass sich ein weiter Kegel nicht zu seiner Achse hin ballt. *Seit:* 3.00. *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Feuerwerk fliegt überallhin; die Fackelflamme bleibt eine schmale Säule. emitterSetSpread(fireworks, 180) emitterSetSpread(flame, 15) ---- [#emittersetgravity] ==== emitterSetGravity [source,text] ---- emitterSetGravity(emitter, x, y) emitterSetGravity(emitter, x, y, z) ---- Eine konstante Beschleunigung auf jedes Partikel, in Overlay-Einheiten (2D) oder Welteinheiten (3D) pro Sekunde zum Quadrat: `(0, 500)` zieht 2D-Funken den Bildschirm hinunter, `(0, -20)` lässt 2D-Rauch aufsteigen, `(0, -9.8, 0)` ist die Erde in der Szene. Standard keine. Sie wirkt ab dem nächsten Frame auf die lebenden Partikel. Partikel sind keine Physikkörper; gib ihnen `emitterSetCollide`, damit sie von einem Boden oder der Szene abprallen. *Seit:* 3.00. *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Funken fallen, Rauch steigt, beides vom selben Schleifer. emitterSetGravity(sparks, 0, 500) emitterSetGravity(smoke, 0, -20) -- Unter Wasser streben die Blasen zur Oberfläche. emitterSetGravity(bubbles, 0, 3, 0) ---- [#emittersetdrag] ==== emitterSetDrag [source,text] ---- emitterSetDrag(emitter, perSecond) ---- Wie schnell Partikel ihre Geschwindigkeit verlieren: in jedem Frame wird die Geschwindigkeit mit `1 - perSecond * dt` multipliziert, nie unter `0`, sodass `1` den größten Teil der Geschwindigkeit innerhalb einer Sekunde nimmt und `0` (der Standard) sie für immer behält. Negative Werte werden zu `0`. Rauch, Glut und Feuerwerk werden langsamer; Funken und Regen nicht. Wirkt ab dem nächsten Frame auf die lebenden Partikel. *Seit:* 3.00. *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Eine Feuerwerksbombe: schnell hinaus, dann schweben und fallen. 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) ---- Größe bei der Geburt und beim Tod, in Overlay-Einheiten (2D) oder Welteinheiten (3D), gleichmäßig über die Lebensdauer des Partikels wechselnd; Standard `16` bis `4` in 2D und `0.2` bis `0.05` in 3D. `variation` (`0` bis `1`, Standard `0`) skaliert beide Größen jedes neuen Partikels mit einem einzigen Zufallsfaktor zwischen `1 - variation` und `1 + variation`, sodass ein Wölkchen sein ganzes Leben lang etwas größer oder kleiner ist. Negative Größen werden zu `0`, und `variation` wird begrenzt. Ein texturiertes Partikel wird als Quadrat dieser Größe gezeichnet; ein breiteres Bild wird gestaucht. *Seit:* 3.00. *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Rauch wächst, während er dünner wird; Funken schrumpfen zu nichts. 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) ---- Wovon Partikel abprallen. `COLLIDE_NONE` (Standard) ist nichts; `COLLIDE_FLOOR` ist eine waagerechte Ebene in der Höhe `floor`, unterhalb derer (größeres Y in 2D, kleineres Y in 3D) ein Partikel auf die Ebene zurückgesetzt und reflektiert wird; `COLLIDE_SCENE` ist die Physikwelt, nur für 3D-Emitter, mit einem Raycast pro Partikel entlang seiner Bewegung in jedem Frame, begrenzt auf `200` Raycasts pro Emitter und Frame und dort fortgesetzt, wo sie aufgehört haben, sodass ein großer Emitter die Kosten über ein paar Frames verteilt. Bei einem 2D-Emitter wird `COLLIDE_SCENE` angenommen und tut nichts. Ein anderer Modus als diese drei beendet das Skript. *Parameter:* * `mode` -- `COLLIDE_NONE`, `COLLIDE_FLOOR` oder `COLLIDE_SCENE`. * `bounce` -- wie viel Geschwindigkeit einen Aufprall übersteht, `0` bis `1`, Standard `0.5`; begrenzt. * `friction` -- wie viel Gleitgeschwindigkeit pro Aufprall verloren geht, `0` bis `1`, Standard `0.2`; begrenzt. * `floor` -- Höhe der Bodenebene, in Welteinheiten bei einem 3D-Emitter oder in Overlay-Einheiten bei einem 2D-Emitter, Standard `0`. Wird nur von `COLLIDE_FLOOR` verwendet. *Seit:* 3.00. *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Funken prallen auf dem Werkstattboden ab (Overlay-Zeile 440) und hinterlassen Streifen. sparks = emitterNew() emitterSetBlend(sparks, PARTICLE_ADD) emitterSetGravity(sparks, 0, 500) emitterSetCollide(sparks, COLLIDE_FLOOR, 0.4, 0.2, 440) emitterSetTrail(sparks, 6, 3) -- Glut in der Szene prallt von allem ab, was die Physikwelt enthält. emitterSetCollide(embers, COLLIDE_SCENE, 0.3, 0.5) ---- [#emittersetcolor] ==== emitterSetColor [source,text] ---- emitterSetColor(emitter, r, g, b, a, r2, g2, b2, a2) ---- Farbe und Deckkraft bei der Geburt (`r, g, b, a`) und beim Tod (`r2, g2, b2, a2`), jeweils `0` bis `255`, gleichmäßig über die Lebensdauer des Partikels wechselnd; Werte außerhalb des Bereichs werden begrenzt. Standard: von Weiß und deckend zu Weiß und transparent. Die Tönung multipliziert die Textur oder die eingebaute Scheibe, ein weißes Bild nimmt die Farbe also genau an. Ein Ein- und Ausblenden braucht zwei Emitter oder eine Textur, in der die Form steckt; ein Rezept fährt eine einzige gerade Rampe. *Seit:* 3.00. *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Flamme: hellgelb am Fuß, verglimmt zu mattem Rot und ist weg. emitterSetColor(flame, 255, 200, 90, 255, 255, 40, 0, 0) -- Staubkörnchen blenden aus dem Nichts ein und schweben; ihre Lebensdauer beendet sie. emitterSetColor(dust, 255, 240, 200, 0, 255, 240, 200, 160) ---- [#emittersetspin] ==== emitterSetSpin [source,text] ---- emitterSetSpin(emitter, min, max) ---- Drehgeschwindigkeit jedes neuen Partikels in Grad pro Sekunde, zufällig zwischen den beiden gezogen; negative Werte drehen andersherum, und ein Bereich, der `0` überspannt, ergibt beides. Jedes Partikel beginnt mit einem zufälligen Winkel, ob es sich dreht oder nicht. Standard keine. Das Maximum wird auf das Minimum angehoben, wenn es kleiner ist. Die Drehung sieht man an einem texturierten Partikel oder einem Schweif; die eingebaute Scheibe ist rund und sieht in jedem Winkel gleich aus. *Seit:* 3.00. *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Blätter taumeln; Glut dreht sich kaum. emitterSetTexture(leaves, leafSprite) emitterSetSpin(leaves, -180, 180) emitterSetSpin(embers, -20, 20) ---- [#emittersetradius] ==== emitterSetRadius [source,text] ---- emitterSetRadius(emitter, radius) ---- Neue Partikel werden irgendwo innerhalb dieses Abstands vom Ursprung des Emitters geboren, gleichmäßig über eine Scheibe (2D) oder eine Kugel (3D), in Overlay- oder Welteinheiten. Standard `0`, ein Punkt; negative Werte werden zu `0`. Ein Radius füllt ein Volumen, einen Fackelkopf oder ein Stück Staub in der Luft, wo ein Punkt eine Nadel ergäbe. *Seit:* 3.00. *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Staub in einer raumgroßen Luftkugel, das meiste schon da, wenn das Level beginnt. 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) ---- Nur 3D: schattiert jedes Partikel nach den Lichtern und dem Himmel der Szene, als wäre es eine kleine Kugel, die zur Kamera zeigt, sodass Rauch die Farbe einer Feuerschale annimmt und Staub in der Sonne glitzert. Standardmäßig aus; Schatten werden nicht berücksichtigt. Mit `PARTICLE_ADD` wird die Beleuchtung trotzdem auf die Tönung angewendet, bevor sie addiert wird. Ein 2D-Emitter speichert den Wert und ignoriert ihn. *Seit:* 3.00. *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Der Rauch über jeder Feuerschale nimmt ihren Feuerschein an. 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) ---- Ob lebende Partikel dem Emitter folgen. `false` (Standard) lässt jedes dort, wo es geboren wurde, sodass eine durch einen Raum getragene Fackel Rauch hinter sich lässt; `true` hält die ganze Wolke relativ zum Ursprung des Emitters, sodass sich der Abgasstrahl einer Schubdüse mit dem Schiff bewegt. Das Umschalten überführt die lebenden Partikel ohne Sprung von einem Bezugssystem ins andere. Bei einem 3D-Emitter ist der Ursprung die Weltposition des Knotens; bei einem 2D-Emitter ist es der Punkt von `emitterSetPosition`, eine lokale 2D-Wolke gleitet also ebenfalls mit. Schweife folgen derselben Regel. *Seit:* 3.00. *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Eine magische Aura, die die Zauberin beim Gehen umhüllt. 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) ---- Der Pool: wie viele Partikel gleichzeitig leben dürfen, Standard `1000`. Ein voller Pool erzeugt keine weiteren, weder aus dem Strom noch aus einem Stoß, bis einige sterben. Eine Änderung der Anzahl baut den Pool neu und tötet jedes lebende Partikel, setz sie also vor dem Start. Bemiss den Pool als Rate mal längste Lebensdauer plus den größten Stoß. Eine Anzahl unter `1` beendet das Skript. *Seit:* 3.00. *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- 300 Tropfen pro Sekunde mit bis zu 1,6 Sekunden Leben brauchen etwa 480; lass Luft. 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) ---- Nur 2D: wo neue Partikel geboren werden, in Overlay-Koordinaten; Standard `(0, 0)`. Setze die Position in jedem Frame, um einem Sprite oder einem 2D-Physikkörper zu folgen. Lebende Partikel bleiben, wo sie sind, außer `emitterSetLocal` ist eingeschaltet. Ein 3D-Emitter folgt seinem Knoten und beendet hier das Skript; bewege stattdessen den Knoten. *Seit:* 3.00. *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Funken fliegen dort, wo das Schleifer-Sprite in diesem Frame ist. 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) ---- Ein Band hinter jedem Partikel durch seine letzten `length` Positionen, eine pro Frame aufgezeichnet (`2` bis `16`; `0` oder `1` für keines, der Standard), `width` breit in Welt- oder Overlay-Einheiten, in 2D wie in 3D. Das Band trägt die aktuelle Tönung des Partikels, verblasst zu seinem Ende hin zu transparent und wird mit der mittleren Spalte des Partikelbilds texturiert, sodass der weiche Rand der Scheibe zum Rand des Bands wird. Die Werte werden auf den Bereich begrenzt. Eine Änderung der Länge baut den Speicher der Schweife neu, lebende Partikel beginnen ihre Bänder also von vorn. Funken, Glut, Leuchtspurgeschosse, Kometen. *Seit:* 3.00. *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Leuchtspurgeschosse: winzig, schnell und ganz Streifen. 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) ---- Beginnt den stetigen Strom mit der eingestellten Rate; von da an erzeugt der Emitter in jedem Frame Partikel bis `emitterStop`. Einen Emitter zu starten, der bereits strömt, ändert nichts. Der Strom braucht keinen Zeichenaufruf, um zu laufen; ein 2D-Emitter, der strömt, aber nicht gezeichnet wird, verbraucht trotzdem seinen Pool. *Seit:* 3.00. *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Das Triebwerk zündet, wenn der Spieler Gas gibt, und erlischt, wenn er es wegnimmt. function onThrottle(open) if open then emitterStart(exhaust) else emitterStop(exhaust) end end ---- [#emitterstop] ==== emitterStop [source,text] ---- emitterStop(emitter) ---- Beendet den Strom; die lebenden Partikel leben ihr Leben zu Ende, und jeder noch geschuldete Bruchteil eines Partikels wird verworfen. `emitterBurst` funktioniert auch bei einem gestoppten Emitter, und `emitterIsActive` bleibt `true`, bis das letzte Partikel stirbt. Verwende `emitterClear`, um die Partikel außerdem sofort zu entfernen. *Seit:* 3.00. *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Die Fackel erlischt: die Flamme stoppen, den letzten Rauch davontreiben lassen. function onTorchDoused() emitterStop(flame) emitterStop(smoke) soundPlay(hiss) end ---- [#emitterburst] ==== emitterBurst [source,text] ---- emitterBurst(emitter, count) ---- Erzeugt `count` Partikel auf einmal nach dem aktuellen Rezept, mit oder ohne Strom: eine Explosion, ein Spritzer, ein Wölkchen. Es werden nur so viele erzeugt, wie im Pool Platz haben; `emitterSetMax` legt den Platz fest. Ein 2D-Emitter stößt sie an seinem Punkt von `emitterSetPosition` aus, ein 3D-Emitter an seinem Knoten, verteilt über `emitterSetRadius`. *Seit:* 3.00. *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Feuerwerk: ungefähr jede Sekunde platzt irgendwo über der Disc eine Bombe. 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) ---- Tötet jetzt jedes lebende Partikel und vergisst jeden noch geschuldeten Bruchteil eines Partikels. Der Strom wird nicht gestoppt: ein gestarteter Emitter erzeugt im nächsten Frame weiter neue Partikel, ruf also auch `emitterStop` auf, um ihn endgültig zu leeren. Verwende es, wenn eine Szene wechselt und die alten Funken nicht nachglimmen dürfen. *Seit:* 3.00. *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Das Level neu starten: die Emitter bleiben, die alten Partikel gehen. function resetLevel() for _, emitter in ipairs(levelEmitters) do emitterStop(emitter) emitterClear(emitter) end placePlayer() end ---- [#emitterdraw] ==== emitterDraw [source,text] ---- emitterDraw(emitter) ---- Nur 2D, aus `onOverlayUpdate`: stellt die Partikel des Emitters in die Warteschlange, damit sie in diesem Frame gezeichnet werden, über oder unter dem Overlay je nach `emitterSetLayer`, in Overlay-Koordinaten, die auf das Fenster abgebildet werden. Emitter auf derselben Ebene zeichnen in der Reihenfolge, in der sie eingereiht wurden. Die Warteschlange leert sich in jedem Frame, der Aufruf gehört also in jeden Frame, in dem die Partikel zu sehen sein sollen; einen Emitter nicht zu zeichnen, hält ihn nicht an. Bis zu `64` Emitter pro Ebene werden in einem Frame gezeichnet. Ein 3D-Emitter zeichnet sich selbst in der Szene und beendet hier das Skript. *Seit:* 3.00. *Siehe auch:* <>, <>, <> .Beispiel [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) ---- Wie viele Partikel des Emitters in diesem Frame leben, `0` bis zur Größe des Pools. Nützlich, um einen Pool abzustimmen, für ein Budget auf dem Bildschirm oder um zu wissen, wann ein Stoß verklungen ist. *Rückgabe:* Die Anzahl der lebenden Partikel, eine Ganzzahl. *Seit:* 3.00. *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Entwicklungs-Overlay: wie viel von jedem Pool in Gebrauch ist. 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) ---- Ob der Emitter strömt oder noch lebende Partikel hat: `true` ab `emitterStart` oder `emitterBurst`, bis der Strom gestoppt und das letzte Partikel gestorben ist, `false`, sobald die Explosion vorbei ist. Ein strömender Emitter, dessen Pool gerade leer ist, ist trotzdem aktiv. *Rückgabe:* Boolean. *Seit:* 3.00. *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Einmalige Explosionen leben in einer Liste und werden freigegeben, wenn sie ausgebrannt sind. 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] === Schriftart Singe hat zwei Text-Renderer. Die eingebaute Konsolenschrift wird von <> gezeichnet und braucht keine Assets; die Familie `font*` lädt TrueType-Dateien in einer festen Punktgröße und rendert sie entweder mit `fontPrint` direkt auf das Overlay oder mit `fontToSprite` in ein wiederverwendbares Sprite. Schriftart-Handles sind Ganzzahlen aus `fontLoad`, und die Engine hält eine global ausgewählte Schriftart, die `fontLoad` auf die gerade geladene Schriftart setzt und `fontSelect` ändert. Text wird in der aktuellen Vordergrundfarbe in der von `fontQuality` gewählten Qualität gerendert, und Textpositionen sind Overlay-Koordinaten. Gib Schriftarten mit `fontUnload` frei; `Singe/FreeSansBold.ttf` wird mit der Engine mitgeliefert und ist immer verfügbar. [#fontload] ==== fontLoad [source,text] ---- result = fontLoad(filename, pointSize) ---- Öffnet eine TrueType-Schriftart in einer Punktgröße und macht sie zur ausgewählten Schriftart. Die Größe ist für die Lebensdauer des Handles fest, lade dieselbe Datei also einmal pro benötigter Größe. Der Name geht wie jedes andere Asset durch das virtuelle Dateisystem; stelle `DIR` voran für Dateien, die mit deinem Spiel mitgeliefert werden, oder verwende `Singe/FreeSansBold.ttf` für den mitgelieferten Schriftschnitt. Eine Datei, die sich nicht öffnen lässt, beendet das Skript mit `Unable to open` und dem Namen. *Parameter:* * `filename` -- Pfad zu einer `.ttf`-Datei. * `pointSize` -- ganzzahlige Punktgröße. *Rückgabe:* ganzzahliges Schriftart-Handle. *Seit:* 1.x *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Zwei Größen des mitgelieferten Schriftschnitts: groß für den Titel, klein für das 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) ---- Rendert einen String mit der ausgewählten Schriftart, der aktuellen Qualität und der aktuellen Vordergrundfarbe (bei `FONT_QUALITY_SHADED` zusätzlich der Hintergrundfarbe) und blittet ihn dann mit seiner oberen linken Ecke bei `(x, y)` auf das Overlay. Ruf es aus `onOverlayUpdate` auf, wenn nötig in jedem Frame: die Engine behält jeden String, den sie rendert, mit Schriftart, Qualität, Farben und Text als Schlüssel, und ein späterer Aufruf, der denselben verlangt, blittet ihn nur noch. Strings, die etwa eine Sekunde lang nicht ausgegeben wurden, werden vergessen, und die Strings einer Schriftart verschwinden, wenn sie entladen wird, sodass Text, der sich ändert, nicht mehr kostet als schon immer, und Text, der sich nicht ändert, fast nichts. Eine ganze Seite Highscores in jedem Frame neu auszugeben ist in Ordnung. Ohne ausgewählte Schriftart endet das Skript mit einem Fehler. *Parameter:* * `x`, `y` -- obere linke Ecke in Overlay-Koordinaten; Nachkommastellen werden abgeschnitten. * `text` -- der zu rendernde String. *Seit:* 1.x *Siehe auch:* <>, <>, <>, <> .Beispiel [source,lua] ---- -- Live-Anzeige der Audiokalibrierung; der Text ändert sich in jedem Frame, also direkt rendern. 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) ---- Wählt, wie `fontPrint` und `fontToSprite` Text von nun an rastern. Die Einstellung ist global, nicht pro Schriftart, und beginnt bei `FONT_QUALITY_SOLID`. Sprites, die `fontToSprite` schon erzeugt hat, behalten die Qualität, mit der sie gerendert wurden. Ein Wert außerhalb der drei Konstanten beendet das Skript mit einem Fehler. *Parameter:* * `mode` -- `FONT_QUALITY_SOLID` (`1`): am schnellsten, Ein-Bit-Kanten, keine Kantenglättung. `FONT_QUALITY_SHADED` (`2`): kantengeglättete Glyphen auf einem gefüllten Rechteck in der Hintergrundfarbe. `FONT_QUALITY_BLENDED` (`3`): kantengeglättete Glyphen mit Alpha pro Pixel, die sich über alles blenden; die übliche Wahl. *Seit:* 1.x *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Text in BLENDED über dem Video, aber ein Beschriftungsfeld in SHADED für die Debug-Anzeige. 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) ---- Macht eine geladene Schriftart zu der, die `fontPrint` und `fontToSprite` verwenden. Die Auswahl bleibt bis zum nächsten `fontSelect` oder `fontLoad` bestehen, da auch das Laden auswählt. Sie kann so oft wie nötig geändert werden, auch mehrmals innerhalb eines `onOverlayUpdate`. Ein unbekanntes oder entladenes Handle (oder gar keine Zahl) lässt die aktuelle Schriftart ausgewählt und gibt eine Warnung aus, so wie Hypseus Singe es durchgehen lässt. *Seit:* 1.x *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Titel im großen Schriftschnitt, Punktestand im kleinen, in jedem Frame. 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) ---- Rendert einen String mit der ausgewählten Schriftart, Qualität und den Farben in ein neues Sprite und gibt dessen Handle zurück. Das Sprite ist von da an unabhängig von der Schriftart, übersteht also `fontUnload`, und es funktioniert mit jedem `sprite*`-Aufruf: zeichne es, miss es, skaliere oder drehe es und gib es mit `spriteUnload` frei. Für einen String, der sich ändert, rendere ein neues Sprite und entlade das alte. Ohne ausgewählte Schriftart endet das Skript mit einem Fehler. NOTE: Text aus Leistungsgründen in ein Sprite zu rendern ist veraltet. `fontPrint` behält jetzt, was es rendert, sodass es etwa so viel kostet, denselben String in jedem Frame auszugeben, wie ein daraus gemachtes Sprite zu zeichnen, ohne dass ein Sprite zu verwalten ist. Verwende `fontToSprite`, wenn der Text ein Sprite sein muss: um ihn zu skalieren, zu drehen, zu spiegeln oder mit `spriteGetWidth` und `spriteGetHeight` zu messen. *Rückgabe:* ganzzahliges Sprite-Handle. *Seit:* 1.x *Siehe auch:* <>, <>, <>, <> .Beispiel [source,lua] ---- -- Ein Titel, der sich drehend hereinwächst: Text, der ein Sprite sein muss, um gedreht und skaliert zu werden. 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) ---- Schließt eine Schriftart und macht ihr Handle ungültig. Sprites, die mit `fontToSprite` daraus gemacht wurden, sind nicht betroffen. War die entladene Schriftart die ausgewählte, ist danach keine Schriftart ausgewählt, und das nächste `fontPrint` oder `fontToSprite` beendet das Skript mit einem Fehler, bis `fontSelect` oder `fontLoad` eine andere wählt. *Seit:* 2.00 *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Die Schriftarten und die Text-Sprites, die sie erzeugt haben, freigeben. function onShutdown() spriteUnload(titleSprite) for _, line in ipairs(descriptionLines) do spriteUnload(line) end fontUnload(titleFont) fontUnload(hudFont) end ---- [#guiref] === Gui Eine GUI ist ein RmlUi-Dokumentbaum fester Größe, der in jedem Frame in seine eigene Textur gerendert wird: `guiNew` erzeugt eine und gibt ein ganzzahliges Handle zurück, `guiLoad` legt ein RML-Dokument hinein und gibt ein Dokument-Handle zurück, `guiDraw` legt sie über das Overlay, und `materialSetGui` zeigt sie auf einem Material in der Szene. Dokumente sind HTML-artiges RML, gestaltet mit CSS-artigem RCSS, und werden wie Sprites über das virtuelle Dateisystem geladen; `Singe/gui.rcss` ist das mitgelieferte Theme und `Singe/FreeSansBold.ttf` der Ersatzschriftschnitt. Die Helfer nach id, `guiGetValue`, `guiSetValue` und `guiSetHandler`, erreichen Elemente ohne das Objektmodell; alles andere ist die Lua-API von RmlUi auf dem globalen `rmlui`, wo eine GUI der Kontext namens `gui` gefolgt von ihrem Handle ist. Bis zu sechzehn GUIs gibt es gleichzeitig, jede mit zweiunddreißig Dokumenten. Eine GUI braucht das GPU-Gerät, deshalb beendet `guiNew` das Skript auf einem Rechner ohne eines; ein falsches GUI-Handle löst `No GUI N` aus, ein falsches Dokument-Handle `No document N in GUI G`. Siehe <> unter Spieleentwicklung. [#guiclose] ==== guiClose [source,text] ---- guiClose(gui, document) ---- Entlädt das Dokument aus der GUI und verwirft seine Elemente, seine Handler und seine Skripte. Das Dokument-Handle ist danach ungültig. Ein Dokument, das sein eigenes Skript über die API von RmlUi schließt (`document:Close()`), wird auf dieselbe Weise entladen. Löst bei einem ungültigen Handle `No document N in GUI G` aus. *Seit:* 3.00. *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Die Seite für den Highscore-Eintrag wird nur einmal gebraucht; nach der Eingabe des Namens weg damit. 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) ---- Gibt die GUI, jedes Dokument darin und ihre Textur frei und beendet das Aktualisieren und Rendern in jedem Frame, das sie kostete. Materialien, die sie über `materialSetGui` zeigen, kehren zu ihrer eigenen Textur zurück. Das Handle ist danach ungültig und beendet das Skript, wenn es noch einmal verwendet wird; da eine GUI weiter rendert, ob sie gezeichnet wird oder nicht, lösche die, mit denen ein Spiel fertig ist, statt sie zu verstecken. *Seit:* 3.00. *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Die Optionsseite lebt nur, solange der Spieler darin ist. function closeOptions() guiDelete(optionsGui) optionsGui = nil discPlay() end ---- [#guidraw] ==== guiDraw [source,text] ---- guiDraw(gui) guiDraw(gui, x, y) guiDraw(gui, x, y, width, height) ---- Legt die Textur der GUI für diesen Frame über das Overlay, in Overlay-Koordinaten, sodass sie wie alles andere auf dem Overlay dem Sinden-Rahmen und der Overscan-Skalierung folgt: mit einem Argument über das ganze Overlay, in ihrer eigenen Größe mit einer Ecke bei `x, y` oder skaliert in das Rechteck `width` mal `height`. Die Stelle des Aufrufs im Zeichnen des Skripts entscheidet, was unter der GUI liegt und was darüber: alles, was vorher auf das Overlay gezeichnet wurde, bleibt darunter, und alles, was danach gezeichnet wird -- bis zum nächsten `guiDraw` oder bis das Skript das nächste Mal an der Reihe ist -- kommt auf eine eigene Ebene, die über diese GUI gelegt wird. Ein Zeiger, der nach dem HUD gezeichnet wird, liegt über dem HUD; ein danach gezeichnetes zweites Dokument liegt über dem Zeiger, mit seiner eigenen Ebene darüber. Bis zu sechzehn solcher Ebenen pro Frame; ein Aufruf darüber hinaus zeichnet seine GUI ohne etwas darüber. `overlayClear` leert die Ebene, auf die gerade gezeichnet wird, und `overlaySetMonochrome` macht nur das Basis-Overlay grau. Der Aufruf gilt einen Frame; mach ihn aus `onOverlayUpdate` (oder `singeMain`) in jedem Frame, in dem die GUI sichtbar sein soll. GUIs zeichnen über dem Overlay in der Reihenfolge der Aufrufe (die letzte oben), unter Partikeln in `PARTICLE_OVER` und der Pausenanzeige, und die GUI, die zuletzt unter dem Zeiger gezeichnet wurde, ist die, die die Maus bekommt. Bis zu sechzehn Aufrufe pro Frame werden beachtet; weitere werden ignoriert. Jede andere Anzahl von Argumenten löst `Expected 1, 3 or 5 arguments` aus. *Parameter:* * `x`, `y` -- die obere linke Ecke, in Overlay-Koordinaten. * `width`, `height` -- die Größe, in der gezeichnet wird, in Overlay-Pixeln; ohne Angabe die eigene Größe der GUI. *Seit:* 3.00. *Siehe auch:* <>, <>, <> .Beispiel [source,lua] ---- -- Das HUD sitzt in der unteren rechten Ecke mit einem Drittel der Breite des Overlays, und das Visier -- der Lightgun wird danach gezeichnet, sodass das Panel das Visier nie verdeckt. 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) ---- Dasselbe wie <>, nur dass das Rechteck über das ganze Fenster gemessen wird statt über das Bild. Die Koordinaten sind weiterhin Overlay-Koordinaten -- `0, 0` ist oben links im Fenster und `overlayGetWidth(), overlayGetHeight()` unten rechts -- sodass eine GUI auf der Bezel-Grafik neben dem Bild, darüber oder darunter sitzen kann. Ohne Bezel und ohne `--scalefactor`, `--shiftx` oder `--shifty` ist das Bild das ganze Fenster, und dies ist genau `guiDraw`. GUIs im Bildschirmraum zeichnen nach allem anderen, die Bezel-Grafik eingeschlossen, und teilen sich die sechzehn Zeichenaufrufe pro Frame mit `guiDraw`. Ein Klick findet sie auf dieselbe Weise: der Zeiger wird in den Bildschirmraum übertragen, bevor er geprüft wird, sodass eine GUI im Bildschirmraum, die Eingaben annimmt, die Maus bekommt, wo auch immer sie gezeichnet wurde. *Parameter:* * `x`, `y` -- die obere linke Ecke, in Overlay-Koordinaten, über das Fenster gemessen. * `width`, `height` -- die Größe, in der gezeichnet wird; ohne Angabe die eigene Größe der GUI. *Seit:* 3.00. *Siehe auch:* <>, xref:Manual.adoc#bezels[Bezels], <> .Beispiel [source,lua] ---- -- Eine Karte auf der linken Säule der Automatengrafik, außerhalb des Bilds. function onOverlayUpdate() overlayClear() guiDrawScreen(mapGui, 8, 44, 38, 114) return OVERLAY_UPDATED end ---- [#guigetheight] ==== guiGetHeight [source,text] ---- height = guiGetHeight(gui) ---- Die Höhe der GUI in ihren eigenen Pixeln, wie an `guiNew` übergeben. Beendet das Skript bei einem ungültigen Handle. *Rückgabe:* Eine Ganzzahl. *Seit:* 3.00. *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Einen kleinen Dialog auf dem Overlay zentrieren. local x = (overlayGetWidth() - guiGetWidth(dialog)) / 2 local y = (overlayGetHeight() - guiGetHeight(dialog)) / 2 guiDraw(dialog, x, y) ---- [#guigetstats] ==== guiGetStats [source,text] ---- stats = guiGetStats() ---- Was das Rendern aller GUIs im letzten abgeschlossenen Frame gekostet hat, zum Abstimmen von Dokumenten, die Filter, Masken und Schatten verwenden (die alle über eine Ebene in der Größe der GUI zeichnen). Die Zähler gelten für alle GUIs zusammen. In einem Frame, in dem sich das Bild keiner GUI geändert hat, sind sie alle null, da nichts neu gezeichnet wurde (siehe <>). *Rückgabe:* Eine Tabelle mit den Feldern `drawCalls` (indizierte Zeichenaufrufe, einschließlich Maskenschreibvorgängen, Kompositionen und Filterdurchgängen), `layerPushes` (betretene Ebenen für Filter, Maskenbilder und Box-Schatten), `filterPasses` (Durchgänge in voller Größe und Blits innerhalb von Filterketten; eine Unschärfe kostet mehrere), `maskWrites` (Zeichenaufrufe in die Stencil-Clipmaske), `layerBytes` (Bytes der derzeit belegten Ebenentexturen; jede hat die Größe ihrer GUI bei vier Bytes pro Pixel) und `passes` (begonnene Render-Durchgänge; jeder Ebenenwechsel beginnt einen). *Seit:* 3.00. *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Beobachten, was ein Bildschirm voller Effekte kostet. 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) ---- Der Wert des Elements mit dieser `id` im Dokument, als String: der Wert eines Formularelements (der Text eines Textfelds, der `value` einer Option eines `select`, ein Schieberegler in der eigenen Formatierung von RmlUi wie `"85.000000"`, das Attribut `value` eines Kontrollkästchens oder Optionsfelds, `"on"`, wenn es keines hat, ob es angekreuzt ist oder nicht; sein Zustand ist sein Attribut `checked`, das über die API von RmlUi gelesen wird) oder bei jedem anderen Element sein inneres RML. Gibt `nil` zurück, wenn kein Element diese id hat, sodass eine Seite abgefragt werden kann, ohne das Skript zu beenden; ein falsches GUI- oder Dokument-Handle beendet es. Werte, die länger als 4095 Zeichen sind, werden abgeschnitten. *Rückgabe:* Ein String oder `nil`. *Seit:* 3.00. *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Das ganze Formular lesen, wenn Start gedrückt wird. 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) ---- Die Breite der GUI in ihren eigenen Pixeln, wie an `guiNew` übergeben. Beendet das Skript bei einem ungültigen Handle. *Rückgabe:* Eine Ganzzahl. *Seit:* 3.00. *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Liegt der abgefragte Punkt in der rechten Hälfte der Seite des Monitors? 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) ---- Versteckt das Dokument: es wird nicht gezeichnet und nimmt keine Eingaben an, behält aber seine Elemente und Werte, und `guiShow` bringt es so zurück, wie es war. Löst bei einem ungültigen Handle `No document N in GUI G` aus. *Seit:* 3.00. *Siehe auch:* <>, <> .Beispiel [source,lua] ---- -- Zwei Seiten in einer GUI: die Optionsseite verschwindet, während die Steuerungsseite erscheint. guiSetHandler(gui, options, "controls", "click", function() guiHide(gui, options) guiShow(gui, controls) end) ---- [#guiload] ==== guiLoad [source,text] ---- document = guiLoad(gui, filename) ---- Lädt ein RML-Dokument in die GUI, zeigt es an und gibt ein ganzzahliges Dokument-Handle für die anderen Aufrufe zurück. Der Name geht wie der eines Sprites durch das virtuelle Dateisystem und wird relativ zu dem Ordner aufgelöst, in dem Singe gestartet wurde, stelle also `DIR` voran für Dateien, die mit deinem Spiel mitgeliefert werden; Stylesheets, Bilder und Schriftarten, die im Dokument genannt werden, werden relativ zu der Datei aufgelöst, die sie nennt, und dann über dieselbe Suche, sodass ein gepacktes Spiel sie findet. Die `