= Singe Manual Scott Duensing :revnumber: 3.00 :revdate: 2026 :doctype: book :toc: left :toclevels: 3 :sectnums: :sectnumlevels: 3 :source-highlighter: rouge :icons: font :experimental: [preface] == About Singe Singe (SINGE Is Not a Game Emulator, and named after the dragon in Dragon's Lair) is a Lua-based scripting system that allows for rapid prototyping of new laserdisc games, or the creation of entirely new games. The language is easy to learn, very powerful, and fast. All the features needed to develop your own game are made available through a simple application programming interface (API). Singe provides numerous features to the game developer. Some of the more interesting ones are: * Object-oriented programming language * Animated sprites * TrueType font support * 32-bit color space with transparency * Multi-channel, overlapping, stereo sound * Analog and digital input device support * Wide support for video and audio formats For players, Singe allows many unemulated and new games to be enjoyed using any desired front end or via the included menu system. Since the original release of Singe back in 2006, several revisions have been released, both officially and unofficially. In 2020, Singe 2.00 was released. This was a total rewrite from the ground up, adding numerous features while staying compatible with existing 1.xx games. As of the writing of this manual, the latest release is {revnumber}. == Installation and Upgrading === Installing Singe To install Singe, simply download the appropriate binary for your system. Place it in a new folder by itself and run it. On the first run, Singe will create all the necessary directories and support files needed. You are now ready to install some games. === Upgrading Singe To upgrade Singe to a newer version, place the new binary in the installation folder and rename (or delete) the existing `/Singe` folder. As with the initial installation, run the new binary with no command line arguments to generate the new support files. WARNING: Do not run a new Singe binary against an out-of-date set of support files. Always refresh the `Singe/` directory when upgrading. === Things to Know Do not touch the `Singe/` folder. Files in this folder belong to Singe and may be updated or deleted at any time. == Usage === Installing Games A game is either a single `.game` file or a directory. Copy it into the same folder where you placed the Singe binary. The included menu system will automatically detect it and add it to the menu. [#bundledmenu] === The Bundled Menu `Menu.sh` (or `Menu.bat`) starts `Singe/Menu.singe`, the bundled menu: an RmlUi document, `Singe/Menu.rml` styled by `Singe/menu.rcss`, drawn over the engine's own disc (see <>). It reads the `games.dat` of every game directory and `.game` file beside it and lists every game it finds, sorted by title, down the left of the screen. Beside the list are the selected game's cabinet art, its marquee and its attract clip, with the year, genre, platform, developer, publisher, port credits and description underneath and a line of key hints along the bottom. Up and down move the selection one game at a time and left and right move it a page at a time; both wrap from either end of the list to the other. Start or any action button launches the selected game, and the key mapped to `INPUT_SERVICE` (the `9` key by default) opens the audio delay calibration screen (see <>). With a mouse, a click on a row selects that game and a second click on the same row, or a click on the Start button in the footer, launches it; the mouse wheel scrolls a description too long for its panel. The menu keeps its selection in `menu.dat` in its data directory, so it comes back to the same game when one quits or when Singe is next started. A GUI needs the GPU device the 3D scene uses, so on a machine without one `guiNew` fails and the menu cannot start. `Singe/MenuClassic.singe` is the previous menu, drawn with sprites and `fontPrint` straight into the overlay, kept for one release for exactly that machine: edit `Menu.sh` (or `Menu.bat`) to name it in place of `Singe/Menu.singe`. === Customizing the Controls By default, Singe maps controllers as if they were Xbox gamepads. If your controller is not recognized or you wish to change the default mappings as they appear to Singe, you can use the free SDL2 Gamepad Tool from http://generalarcade.com/gamepadtool/. In addition to configuring your controller, you can remap all the input options for Singe as a whole or for any individual game by using `controls.cfg` files. When determining how inputs are mapped, Singe reads configuration settings in the following order: . The defaults from inside Singe. . `controls.cfg` from the directory Singe was started in (the `Singe` directory holds a `controls.cfg.example` template you can copy). . `controls.cfg` from the root of the data directory. . `controls.cfg` from the game's data directory. . `controls.cfg` from the game's main script directory. You do not have to specify every option in a custom `controls.cfg` file. Feel free to just provide the entries you want changed. After installing Singe, you can find a sample input configuration file at `/Singe/controls.cfg.example`. To see the available configuration options available to be used in this file, read through `/Singe/Framework.singe`. === Command Line Options ---- Singe [OPTIONS] gameName ---- The game name is the only required argument. It may be a `.singe` script, a directory containing a script of the same name (`ActionMax` finds `ActionMax/ActionMax.singe`), or a packed game (`DLe.game` runs the first entry of the `games.dat` inside it, `--entry=N` another). A script or directory launched this way also runs with its `games.dat` entry when one names it (see <>), so a game plays the same from the command line as from the menu. For a laserdisc game (`--disc`) with no `--framefile`, Singe looks for a video next to the script with the same base name and any extension FFmpeg can demux, then for a `.txt` framefile. [cols="1,2",options="header"] |=== | Option | Purpose | `-A`, `--audiodelay=MS` | Compensate for audio that is heard `MS` milliseconds later than the engine can measure (negative when it is heard early), `-1000` to `1000`. See <>. | `-a`, `--aspect=N:D` | Force the aspect ratio used to pick a window size (`4:3`, `16:9`, `16:10`). | `-b`, `--scalefactor=PERCENT` | Shrink the picture about its centre to `PERCENT` (`50` to `100`) of the window for displays that lose their edges to overscan. Mouse and light gun positions follow the shrunken picture. Cannot be combined with `--sindengun`. | `-c`, `--showcalculated` | Print the frame ranges of every segment of a framefile, for debugging. | `-C`, `--canvas=WxH` | World size for a game without a disc, default 720x480. Ignored when there is a disc. | `-D`, `--disc` | Play a laserdisc video: the one named by `--framefile`, or the video found next to the script. Implied by `--framefile`. Without it a video next to the script is reported and ignored. | `-d`, `--datadir=PATHNAME` | Directory for everything Singe writes: video indexes, `trace.txt`, screenshots, saves, the menu's `menu.dat`. A subdirectory named for the game's directory is created inside it (for a packed game, one named for the database and then for the script's directory inside it). Defaults to `data` in the game directory. Singe 2.x wrote beside the game instead; game directories may be read only, so nothing is written there any more. | `-E`, `--entry=N` | Run the Nth entry of the `games.dat` inside a `.game` file (default 1). See <>. | `-e`, `--volume_nonvldp=PERCENT` | Sound effect and extra video volume, `0` to `100`. | `-f`, `--fullscreen` | Exclusive full screen at the desktop resolution. | `-G`, `--gamedir=PATHNAME` | The directory holding the games, the packed `.game` files and the `Singe` support folder, when the executable lives somewhere else. It becomes the working directory, so the game name, a relative `--datadir` and the packer's names all count from it; absolute paths work as they are. Defaults to the current directory. | `-g`, `--sindengun='PARAMS'` | Draw the border a Sinden light gun tracks and map mouse and light gun positions into the picture inside it: the black ring, when one is given, runs along the edge of the video, the white ring sits inside it, and the game is drawn in the middle. `PARAMS` is one quoted list of numbers: `WW` (white border width in video pixels); `WW WB` (white width, then a black border outside it); `RW GW BW WW` (a custom border color and width); `RW GW BW WW WB` (the same plus a black width); or `RW GW BW WW RB GB BB WB` (custom colors and widths for both). A border that leaves no room for the game (twice the white and black widths together reaching the smaller side of the video), or a negative width, ends the program with a message. Cannot be combined with `--scalefactor`. | `-H`, `--softwarevideo` | Decode video in software even when the platform offers a hardware decoder (VA-API or VDPAU on Linux, D3D11VA on Windows, VideoToolbox on macOS). Use it to rule the hardware path in or out when a video misbehaves; the program trace says which decoder is in use. | `-h`, `--help` | Show the option summary and exit. | `-k`, `--nologos` | Skip the splash screens. | `-l`, `--volume_vldp=PERCENT` | Laserdisc volume, `0` to `100`. | `-m`, `--nomouse` | Disable mouse and lightgun input entirely. | `-n`, `--nocrosshair` | Ask the game not to draw its own crosshair. Exposed to scripts as `singeWantsCrosshairs()`. | `-o`, `--audio=TRACK` | Default audio track (zero based) for videos with several, such as multi-language releases. | `-P`, `--pack=DIRECTORY` | Pack the game in `DIRECTORY` into the database named after the options, then exit. See <>. | `-p`, `--program` | Trace engine activity to the console and to `trace.txt` in the data directory. | `-s`, `--nosound` | Mute all audio. | `-T`, `--patch=GAME.game` | Replace files in the packed game from the directory or patch database named after the options, then exit. | `-t`, `--trace` | Trace every Lua API call, with the script line that made it, to the console and to `trace.txt`. | `-R`, `--reload` | Watch the game's loose script files and run the game again from scratch when one changes; `F5` does the same on demand. A script that fails to compile stays watched, so saving the fix reloads it. For working on a game; see Reloading While You Work. | `-U`, `--unpack=GAME.game` | Write the packed game's files into the directory named after the options, then exit. A database whose stored names would land outside that directory is refused. | `-u`, `--stretch` | Stretch the video to fill the window instead of keeping its aspect ratio. | `-v`, `--framefile=FILENAME` | Video file or framefile to use instead of the one found next to the script. | `-w`, `--fullscreen_window` | Borderless window covering the desktop. | `-x`, `--xresolution=VALUE` | Window width. The height is taken from the matching entry of the built in resolution table when omitted. | `-y`, `--yresolution=VALUE` | Window height. | `-z`, `--noconsole` | Print nothing (and open no console window on Windows). |=== Options may be given as `-x 640`, `-x640`, or `--xresolution=640`. Numeric values are validated; a bad value prints the usage text and exits. == Frequently Asked Questions *Why is it named Singe?* Singe is the name of the dragon in Dragon's Lair. As Singe (the program) began as an add-on to Daphne (the princess in Dragon's Lair) the Dragon's Lair theme was kept. *What is the difference between Daphne, Singe, Hypseus, and Singe 2.xx?* Daphne is an actual laserdisc game emulator that can run a handful of classic laser games. Daphne/Singe (Singe 1.xx) is the original version of Singe that was an add-on for the Daphne emulator -- originally shipped as a game DLL for Daphne, then later combined into a single binary with the Daphne emulation features removed. Hypseus-Singe is an enhanced fork of Daphne with Singe 1.xx and some Singe 2.xx support. Finally, Singe 2.xx is an all-new, built-from- scratch upgrade of the original Singe. In short, if you want accuracy, use Daphne or Hypseus if they support emulating the desired game. If it is a Singe game, use Singe 2.xx, or try Hypseus. *Why should we not call it an "emulator"?* Emulators use the real software or ROMs from the original game and pretend to be the machine they were originally intended to run on. From the game's perspective, it is business as usual. Singe, on the other hand, requires that every game be re-implemented (ported) to run on Singe. The end result may be very similar, but how it works is entirely different. *Can I run Singe 1.xx games on Singe 2.xx?* Yes, probably. But you likely will not want to. Almost everything from Singe 1.xx has been converted and enhanced for Singe 2.xx. *Why does the game I just installed not show in the menu?* Whoever packaged the game failed to include a proper `games.dat` file. *Why is HD video slow?* Due to the way Singe accesses video files to provide frame seeking, it is unable to offload video decoding to the video card. High definition video requires a lot of CPU. *Why does my audio stutter on Windows?* Users have discovered that most Singe audio and stuttering problems are related to their installed audio driver -- especially Realtek-based devices. Try updating your sound drivers or switching to the generic Windows HD Audio driver. Disable surround sound -- use stereo, not 5.1 or 7.1. If your drivers do not allow you to use stereo, you can use Virtual Audio Cable to fix this: . Install VB-CABLE (https://vb-audio.com/Cable/). . Go to Windows sound settings (right-click sound on the taskbar, then *Sounds*, then *Playback*). . Right-click the *Cable Input* device and choose *Set as default device*. (You will lose your current sound output temporarily.) . Go to the *Recording* tab. . Right-click *Cable Output*, then *Properties*, then *Listen*, and finally *Enable Listen to This Device*. In the drop-down list, choose your actual listening device. . You should have sound again. Load up Singe and your audio will be working and lag-free. *Does 3D work on a Raspberry Pi?* On a Raspberry Pi 4 or later, yes: they have the Vulkan driver the 3D scene needs. The Pi 3 and earlier do not, so they run 2D games (2D physics included) exactly as before and refuse the first 3D call. The Pi 4 is the minimum for any game that uses the 3D Scenes chapter. *Why does my audio stutter on the Raspberry Pi?* The Raspberry Pi OS now uses Pipewire as the default audio backend. Switching to PulseAudio seems to fix the issue: . Run `sudo raspi-config` from a terminal window. . Select *Advanced Options*. . Select *Audio Config*. . Select *PulseAudio*. . Press kbd:[Tab], select *Ok*, and let it reboot. == Game Development === Lua Singe uses the Lua programming language (http://www.lua.org) for scripting game logic. Lua is fast, lightweight, object-oriented, easy to use, and actually used in the games industry. A tutorial in Lua is beyond the scope of this document. You can find video tutorials for Lua on the Kangaroo Punch YouTube channel and additional documentation by searching the web. === Basic Rules * Singe is cross-platform. Things you do not normally need to worry about on Windows are important. Filenames are case-sensitive -- `MyScript.singe` and `myscript.singe` are not the same file. You should also use UNIX path separators -- `/` rather than `\`. This both makes your code cleaner (no double-escaped backslashes) and works everywhere Singe is supported. * Pick a distinctive name for your game folder. Do not use spaces. * Use the included Singe Framework. Do not copy the framework into your game scripts. By including the provided framework you help with future compatibility as Singe is updated. Begin (or end) your game script with: + [source,lua] ---- dofile("Singe/Framework.singe") ---- * Keep your game self-contained. If you build on a third-party framework or share code between your games, copy it into your game directory. Never reference a directory beside your game; the only file outside your game a script may load is `Singe/Framework.singe`. A single-file game (see <>) packs exactly one directory, and a game that reaches outside it cannot be packed. * Stay out of the `Singe/` folder. This is managed by Singe and anything added or changed here is subject to future deletion. * Include a `games.dat`. This is extremely important for new users. While other front ends and Singe-based menu systems exist, it is vital that you include data for the built-in menu system. Almost all support issues are due to missing or malformed `games.dat` files. * Do not distribute non-essential files. Never ship Singe binaries or Singe-provided scripts with your game. Do not ship index files generated from the videos. [#reloading] === Reloading While You Work Run the game with `--reload` (`-R`) and Singe watches every loose script file the game has loaded. Save a change to one of them and the game starts again from its script a moment later: everything the script made goes (sprites, sounds, fonts, videos, GUIs, the 3D scene, physics, emitters, the overlay back to its default size, the effects volume and the mouse back to their startup state), the engine stays (the window, the GPU, the controllers), the disc is parked on its first frame again as at startup, and the script runs afresh, so a 52 MB model is back on screen in the time it takes to load it rather than the time it takes to start Singe. A key or button held through the reload is ignored until it is released, as at a fresh start. `F5` reloads on demand, and a script can ask for it with `singeReload()`. Packed games have nothing to watch; the option is for a game in a directory. A script error on reload is printed and the game sits empty until the next reload fixes it; the file stays watched, so saving the fix is enough. Because the reload runs the same teardown that quitting does, anything a script leaks shows up here first. === Game Directory Layout A Singe installation is a game directory containing the `Singe` support directory the engine creates on first run, one directory per game, any packed `.game` files, and a `data` directory for everything the games and the engine write. The executable usually sits there too, but need not: `--gamedir` names the game directory from anywhere, and the `Menu.sh` (or `Menu.bat`) the engine writes there then runs the executable by its full path. ---- Singe/ Support files extracted by the engine Framework.singe Loaded by every game (dofile it) Menu.singe The bundled game menu Menu.rml The menu's RmlUi document menu.rcss The menu's style sheet, on top of gui.rcss MenuClassic.singe The previous, overlay-drawn menu (see The Bundled Menu) gui.rcss The shipped GUI theme controls.cfg.example Template for input mappings click.wav Used by the menu's audio delay calibration Manual.pdf This manual ActionMax/ One game games.dat Menu entries for the games in this directory 38AmbushAlley.singe A script frame_38AmbushAlley.txt Its framefile (or a video with the same base name) sprite_*.png, sound_*.wav, font_*.ttf DLe.game A game packed into one file (see Single-File Games) data/ ActionMax/ Indexes, trace.txt, screenshots, saves for that game ---- Paths inside a script are relative to the directory Singe was started from, not to the script. Use the `DIR` global, which `Framework.singe` sets to the script's own directory, so a game works no matter where it is installed: [source,lua] ---- crosshair = spriteLoad(DIR .. "sprite_Crosshair.png") ---- Everything Singe writes goes to the data directory (`singeGetDataPath()`), `data/` beside the games unless `--datadir` says otherwise, so a game can live on read-only media. Singe 2.x wrote saves and settings into the game's own directory; a game that still does so fails on a read-only install, so keep every `io.output` under `singeGetDataPath()`. === Packaging Your Game A finished game ships as one `.game` file, described next. Nothing else is needed: the player copies the file beside the Singe binary and the menu lists it. Singe no longer installs archives. [#singlefile] === Single-File Games A game can be one file. `singe --pack` writes a game directory into an SQLite database, and Singe runs it from there: scripts, sprites, sounds, fonts, framefiles, and video all load straight out of the database, and installing the game is copying the file next to the executable. The menu lists every `.game` file it finds beside the game directories. ---- Singe --pack DLe DLe.game Pack the DLe directory into DLe.game Singe DLe.game Run the first games.dat entry in it Singe --entry=2 DLe.game Run its second entry Singe --unpack DLe.game DLe Write the files back out Singe --patch DLe.game fixes Replace files from a directory (or a patch database) ---- Nothing in a game has to change to be packed. Every name a script uses, whether through `dofile`, `require`, `io.open` and its relatives, `spriteLoad`, `soundLoad`, `fontLoad`, `videoLoad`, a framefile, or a `controls.cfg`, goes through one lookup. For a loose game that lookup is the plain filesystem, relative to the directory Singe was started from, exactly as before. For a packed game a name is tried in three places, in this order, and the first hit wins: . A loose directory named like the database without its extension (`DLe/` beside `DLe.game`). This is how you keep editing a packed game, and how a player mods one: files dropped there beat the packed copies. . The game's data directory, under `files/`. Everything a game writes with `io.output` or `io.open(name, "w")` lands here, so a saved `game.cfg` beats the packed original and the database is never written. . The database itself. Inside a database, names are matched without regard to case, with either path separator, and relative to the game root. A leading component equal to the game's own directory name is ignored, so `DLe/Cfg/game.cfg`, `Cfg/game.cfg`, and `DIR .. "Cfg/game.cfg"` all find the same file. The name of the database itself (`DLe.game`, with or without a trailing separator) is the game root, as `.` is from inside. Names starting with `Singe/` or with the data directory stay on the filesystem; `Singe/Framework.singe` and `singeGetDataPath()` work as always, and such a name may use `..` as long as it stays below that prefix (the `controls.cfg` at the root of the data directory is found that way). Any other relative name with a `..` component resolves nowhere inside a packed game: `io.open` returns `nil` with a message saying the name reaches outside the game, `lfs.attributes` returns `nil`, and `spriteLoad` and its kind report the file as missing. The game root is a directory, not a file: `io.open` on it returns `nil`, and `lfs.mkdir` and `lfs.rmdir` refuse it. `lfs.dir` on a packed directory lists the union of the three places, sorted without regard to case; when a loose file and a packed one differ only in case, the loose spelling is listed. `lfs.attributes` on a packed file answers as the real `lfs` does: the whole table with one argument, one field by name (an unknown name raises an error), or a table of yours filled in; a missing file returns `nil`, a message and the error number. `require("name")` finds `name.lua`, `name/init.lua`, or `name.singe` under the script's directory, then under the game root. The packer refuses a directory that has no `games.dat`, that contains `controls.dat`, `Framework.singe`, a file whose extension is `exe`, `sh`, `bat`, or `cmd`, or an extensionless file whose name starts with `singe`, that has a top level entry named like the directory itself (that would make the own-directory rule ambiguous), or whose scripts and data files reach outside the game with `..`; it reports every such file, with the line. Two files whose names differ only by case cannot both be packed. Stale `.index` files are skipped. `--unpack` refuses a database whose stored names would land outside the target directory (an absolute path, a drive letter, or a `..` component). A game that references another game's directory (a shared framework beside it) must copy that directory inside first. Before the database is committed, its `games.dat` is checked: every entry needs a `SCRIPT`, and each `SCRIPT`, `VIDEO`, `CABINET`, `MARQUEE`, and `ATTRACT` it names must be a file in the database (or under `Singe/`); a bad path is reported with the entry's title and the pack is refused. `--patch` runs the same check on the patched game, so a patch cannot leave a dangling path behind either. `--patch` takes either a directory laid out like the game, whose files replace the packed ones, or a `.patch` file made by `--pack` from such a directory (`Singe --pack fixes DLe-1.1.patch`; a patch needs no `games.dat`). Files the patch does not name are left alone. Either way the change is one transaction: the game is intact if it is interrupted. A patch may also carry a `removed(path)` table naming files to delete. A `.patch` can be unpacked like a game, but it cannot be run and the menu ignores it. A packed game's data directory is named for the database and then for the script's directory inside it: `DLe/` for a `DLe.game` whose script sits at its root under the data directory (`data/DLe/` by default), which is where the loose `DLe/DLe.singe` has it, so saves and settings are shared between a loose install and a packed one; a script packed in a subdirectory (`Game/Main.singe`) gets `DLe/Game/`. The video index goes there too. NOTE: Earlier 3.00 builds gave a packed game whose script sat in a subdirectory the data directory `Game/` under the base, which every archive with that layout shared. Such a game's saves now belong under `DLe/Game/`; move them by hand. When Singe runs a game from a database the menu passes `CONTAINER` in the `games.dat` entry it launches; the field is set by the menu, never by hand. For your own data, `require("sqlite3")` gives scripts the https://lua.sqlite.org[lsqlite3] binding to the same SQLite the engine uses. Keep such databases in `singeGetDataPath()`: [source,lua] ---- local sqlite3 = require("sqlite3") local db = sqlite3.open(singeGetDataPath() .. "scores.db") db:exec("CREATE TABLE IF NOT EXISTS scores (name TEXT, score INTEGER)") for row in db:nrows("SELECT name, score FROM scores ORDER BY score DESC LIMIT 10") do print(row.name, row.score) end db:close() ---- The file format, for anyone writing tools: three tables. `meta(key, value)` holds `version` (1), `gamedir` (the packed directory's name), `chunk` (chunk size in bytes, 4194304), and `packer`. `assets(path, name, size, data)` has one row per file, `path` being the lower case forward-slash key and `name` the author's spelling; files no larger than the chunk size are in `data`, larger ones have `data` NULL and live in `chunks(path, chunk, data)` as consecutive numbered pieces. Singe opens the database read only and immutable, so it runs from read-only media and never journals beside it. === Event Driven... Or Not? Traditionally, Singe used an event-driven programming model -- Singe handles everything and only calls your code when it needs to tell you something or update the screen. This is efficient and is how most Lua-based game engines work, but it can be verbose. As of Singe 2.10 there is a new _threaded_ model that allows you to write procedural code. ==== Event Driven With the event-driven programming model, Singe controls the main program loop and is in charge of the order of execution. Singe automatically handles all the details of decoding and presenting video and audio. It manages controllers, mice, and keyboard input. When Singe needs something game-specific, it calls part of your script. The most basic Singe script that demonstrates all the existing callbacks looks like this: [source,lua] ---- -- Singe Game Skeleton. -- Load the Singe Framework. dofile("Singe/Framework.singe") -- Declare any global variables you need here. function onControllerMoved(axis, value, which) --[[ Reports which controller axis was moved as well as its current value. (Range: -32768 to 32767.) This is used for analog devices. Digital input is handled by onInput and onKey. --]] end function onInputPressed(what) --[[ When in keyboard MODE_NORMAL, input events are reported here when the key or button is first depressed. For a full list of keys, buttons, and controllers, see Singe/Framework.singe. For MODE_FULL, this event will be called with the keysym of the key pressed. --]] end function onInputReleased(what) --[[ When in keyboard MODE_NORMAL, input events are reported here when the key or button is released. For a full list of keys, buttons, and controllers, see Singe/Framework.singe. For MODE_FULL, this event will be called with the keysym of the key released. --]] end function onKeyPressed(key, scancode) --[[ When in keyboard MODE_FULL, input events are reported here when the key is pressed. Both the keysym and scancode are returned. For a list of available scancodes, see Singe/Framework.singe. --]] end function onKeyReleased(key, scancode) --[[ When in keyboard MODE_FULL, input events are reported here when the key is released. Both the keysym and scancode are returned. For a list of available scancodes, see Singe/Framework.singe. --]] end function onMouseMoved(x, y, xr, yr, which) --[[ Called when the mouse is moved. When in SINGLE_MOUSE mode, absolute X and Y values as well as the relative change in position are returned. For MANY_MOUSE mode, only the relative change is available as well as which mouse was moved. --]] end function onOverlayUpdate() --[[ This is the only place you can safely perform drawing operations. If you wish to display a targeting cursor, you will need to save the mouse position from onMouseMoved in global variables and then use those here to render the cursor. --]] -- Tell Singe if we changed the display or not. return(OVERLAY_UPDATED) -- Or OVERLAY_NOT_UPDATED if no drawing was done. end function onShutdown() -- Called when the user exits your game. Free loaded resources here. end function onSoundCompleted(id) -- The sound "id" just finished playing. end -- Note: There is no "onStartup" event. -- Any startup code you need can be placed here. ---- [#threaded] ==== Threaded As of Singe 2.10, you can use the classic procedural programming model. To use this model, declare a `singeMain()` function and include the Singe framework at the *end* of your program rather than at the beginning. Lua is not truly multithreaded, so this model relies on the game developer to cooperatively multitask by calling `singeYield()` anywhere code consumes a substantial amount of time. As with the event-driven model, Singe still manages all input, video decoding, and audio decoding. A sample threaded program looks like this: [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 The two models combine freely. When `singeMain` exists, `Framework.singe` installs its own `onOverlayUpdate`, but every other callback still fires: define `onInputPressed`, `onSoundCompleted`, or `onShutdown` next to `singeMain` and they are called between the coroutine's yields. A typical split keeps the game's flow procedural while reacting to input and audio events: [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") ---- Do not define `onOverlayUpdate` yourself in this model; draw from inside `singeMain` instead. An error raised inside `singeMain` ends the game with a traceback, and returning from `singeMain` quits cleanly. [#pausing] === Pausing Every game gets a working pause for free. While the pause key is enabled (the default), pressing the key mapped to `INPUT_PAUSE` makes the engine pause the disc, every loaded video, and every sound, draw a PAUSED indicator over the last frame, and stop running the script: no `onOverlayUpdate`, no `singeMain` resumption, no input, mouse, controller, or sound callbacks. Timers built on Lua libraries stop with it because nothing runs them. Pressing the key again resumes everything that was playing and the script continues where it left off. Only the engine's own switches (pause, quit, screenshot, grab) still work while frozen. Inputs are kept truthful across the freeze. Anything the script believes is held down is released with `onInputReleased` (or `onKeyReleased` in `MODE_FULL`) when the pause starts, and whatever is still physically held when the pause ends is pressed again. A joystick held through a pause never sticks. A game that wants its own pause behavior (a pause menu, a story freeze that the player may not skip) calls `singeSetPauseKeyEnabled(false)`. The key is then delivered to the script as `SWITCH_PAUSE` and the game drives `singeSetPauseFlag` itself, which pauses the media without freezing the script. In `MODE_FULL` the keyboard belongs entirely to the game, so keys mapped to the engine's switches do nothing there (a `p` typed into a high score entry must stay a `p`). Gamepad and mouse buttons mapped to those switches keep working in either mode, since they cannot be typed. [#gamesdat] === games.dat The `games.dat` file allows Singe to automatically locate new games when they are installed by the end user. This file is extremely important and must be included with every Singe game. Place `games.dat` in the top-most directory of your game. An example containing multiple games: [source,lua] ---- GAMES = { { TITLE = ".38 Ambush Alley", SCRIPT = "ActionMax/38AmbushAlley.singe", VIDEO = "ActionMax/frame_38AmbushAlley.txt", DATA = "ActionMax", STRETCH = false, NO_MOUSE = false, RESOLUTION_X = 720, RESOLUTION_Y = 480, SINDEN_GUN = "", CABINET = "ActionMax/cabinet_38AmbushAlley.png", MARQUEE = "ActionMax/marquee_ActionMax.png", ATTRACT = "ActionMax/video_38AmbushAlley.mkv", ATTRACT_START = 3000, ATTRACT_END = 3500, YEAR = 1987, PLATFORM = "ActionMax", DEVELOPER = "Sourcing International, Ltd.", PUBLISHER = "Worlds of Wonder, Inc.", GENRE = "Shooter", DESCRIPTION = "Get your target practice in with real police officers then hit the streets.", CREATOR = "Scott Duensing", SOURCE = "http://kangaroopunch.com" }, -- ... additional entries ... } ---- The keys `SCRIPT`, `VIDEO`, `CANVAS_X`, `CANVAS_Y`, `STRETCH`, `NO_MOUSE`, `RESOLUTION_X`, `RESOLUTION_Y`, `SINDEN_GUN`, `AUDIO_TRACK`, `AUDIO_DELAY`, and `LEGACY_SPRITE_ARGS` are read by the engine when the menu (or your own script, through `scriptExecute` / `scriptPush`) launches the entry; they override the command line. The `VIDEO` line is the disc: an entry that names one is a laserdisc game, and an entry that leaves it out or blank runs without a disc and may set `CANVAS_X` / `CANVAS_Y` (default 720x480); see <>. `LEGACY_SPRITE_ARGS = true` runs a game written for Singe 2.10 with the old sprite argument order (see <>). The remaining keys are read by the menu for display. A script launched directly, by name or by its directory, gets the same treatment: Singe looks for a `games.dat` in the script's directory and up to three directories above it, and if an entry's `SCRIPT` names the launched script (entry paths count from the directory above the `games.dat`, so `ActionMax/games.dat` names `ActionMax/38AmbushAlley.singe`), that entry's keys apply. The one difference from a menu launch is precedence: an option typed on the command line (`--framefile`, `--disc`, `--stretch`, `--nomouse`, `--xresolution`, `--yresolution`, `--sindengun`, `--audio`, `--audiodelay`, `--canvas`) keeps its value, since the person typing it meant it. A `games.dat` that fails to run is reported and ignored for a direct launch. [#withoutadisc] === Games Without a Disc Singe no longer assumes a laserdisc. A game is a laserdisc game only when it names one: a `VIDEO` in its `games.dat` entry, or `--disc` (or a `--framefile`) on the command line. Everything else runs without a disc, and the world is a *canvas* instead of a video frame: `CANVAS_X` / `CANVAS_Y` in `games.dat` or `--canvas=WxH` on the command line, 720x480 by default so a discless game looks like a standard definition laserdisc game unless it asks otherwise. Without a disc the engine draws on black, the overlay defaults to half the canvas as usual, and the `disc*` and `vldp*` functions become harmless: `discGetState` reports `DISC_EJECTED`, `discGetFrame` returns `0`, `discGetWidth` / `discGetHeight` return the canvas, `vldpGetPixel` returns black, and the transport calls trace and do nothing. Everything else -- the overlay, sprites, fonts, sounds, extra videos through `videoLoad`, input, and both programming models -- is identical. The global `SINGE_DISC` tells a script which kind of game it is running as. [#gui] === GUI Menus, option pages, HUDs and high score entry used to be built out of `fontPrint`, `overlayBox` and a hand rolled cursor. Singe 3 adds a GUI: a document written in RML (an HTML-like markup) and styled by RCSS (a CSS-like style sheet), laid out and drawn by RmlUi, with text wrapping, flexbox, nine-slice panels from your own artwork, transitions, animations, form controls and a focus model a gamepad can drive. A *GUI* is one RmlUi context of a fixed size rendered into its own texture every frame, and that texture goes wherever a texture can: composited over the overlay with `guiDraw`, or shown on a material in the 3D scene with `materialSetGui`, so the same options page works flat on the screen and on the monitor of a modeled arcade cabinet. A GUI needs the GPU device the 3D scene uses, so it is unavailable on a machine without one (see <>); `guiNew` raises an error there. The bundled menu is such a document, which is why `Singe/MenuClassic.singe` exists (see <>). Every call named here has an entry in the <> section of the reference. The language reference for RML and RCSS is RmlUi's own documentation at https://mikke89.github.io/RmlUiDoc/, and it is worth reading before the first document; this chapter covers what Singe adds on top. ==== Documents and Style Sheets `guiNew(width, height)` makes a GUI and returns an integer handle; up to sixteen exist at once. `guiLoad(gui, name)` loads an RML document into it, shows it and returns a document handle (up to thirty-two per GUI); `guiHide`, `guiShow` and `guiClose` do what their names say, and `guiDelete` drops the GUI, its documents and its texture. A document is usually the size of its GUI, and a GUI is usually the size of the overlay for a full screen page or the size of the panel for a HUD element: [source,lua] ---- local gui = guiNew(overlayGetWidth(), overlayGetHeight()) local doc = guiLoad(gui, DIR .. "options.rml") ---- The document name goes through the virtual file system like a sprite's, so it may live loose on disk or inside a packed game, and it is resolved relative to the directory Singe was started in, so prepend `DIR` for files shipped with your game, exactly as for `spriteLoad`. Names *inside* a document (a style sheet's `href`, an image in a decorator, a sprite sheet's `src`) resolve relative to the file that names them, as RmlUi does, and then through the same lookup, so a document, its style sheet and its images sit beside each other in the game directory and a packed game finds them all. Two kinds of name are taken as they are: the engine's own files under `Singe/`, and absolute paths. The same rule read the other way lets a document that lives under `Singe/` reach a game's files: a relative name resolves from the document's own directory, so from `Singe/Menu.rml` the name `../ActionMax/cabinet.png` is `ActionMax/cabinet.png` in the game directory, and a name under `../DLe.game/` is a file inside that packed game. The bundled menu does exactly this for the cabinet and marquee art of the selected game, setting an image decorator on the art panel from Lua so the picture is fitted inside it whatever its size: [source,lua] ---- -- Menu.singe: fit a game's picture into one of the art panels. 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 ---- An RML document is a `` with a title, style sheet links or an inline `

Options

Choose a difficulty and press Start.

---- RmlUi draws nothing it has not been told the size of, and every element is `display: inline` until a style says otherwise, so a style sheet is not optional. `Singe/gui.rcss` is the theme the engine ships: it gives the body, headings and paragraphs a size and a color, makes `div` a block, and styles every form control. Link it first and override what you like in a sheet of your own or an inline `

Options

plays the intro

Press Start when ready.

---- The script loads it into a GUI the size of the overlay, wires the three elements it cares about, and draws it every frame until the player presses Start; the disc plays underneath the whole time: [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) -- Only what the page did not use arrives here: nothing while it has -- focus, everything once it is hidden. 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 ---- The same page on the screen of a modeled cabinet is the two lines from <> in place of `guiDraw`, and a click on the monitor presses the button under it. [#scenes3d] === 3D Scenes Singe 3 draws a 3D scene between the laserdisc video and the 2D overlay. Everything you already know still applies: sprites, text and the overlay sit on top of the scene, and the video shows through wherever the scene's background is transparent. A game that never calls a 3D function is unaffected, and a game without a disc (see <>) can be a 3D game outright. The sections below build up a scene in the order you will write one: nodes, then meshes and materials, lights, the camera, the look of the frame as a whole, models, and finally the things that sit on top of all that. Every call named here has an entry in the <>. ==== What a Scene Is Made Of Everything in a scene is a *node* in one tree, and node `0` is the root. A node has a position, a rotation and a scale relative to its parent, and it may carry one thing: a mesh with a material, a light, a sprite, or a line of text. A loaded model becomes a subtree of nodes under one root, so the same calls move a modeled character and a scripted cube alike. The scene is right-handed with +Y up and -Z forward, like glTF and Blender's export. Units are whatever your models use, and the primitives take the same units; physics (see <>) assumes meters. Rotations are degrees, given as three Euler angles applied as yaw, pitch and roll, or as a quaternion when the value came from a model. A node's own forward is its -Z: the camera looks down it, directional and spot lights shine down it, and `nodeLookAt` turns it toward a world point. There are two ways to fill a scene, freely mixed: * *Build it from script* with the primitive meshes (`meshBox`, `meshSphere`, `meshPlane`, `meshCylinder`, `meshCone`, `meshTorus`, `meshHeightmap`) or your own vertex data (`meshNew`), plus materials, lights and a camera. * *Load models* in glTF 2.0 binary form (`.glb`) with `modelLoad`, then place them with `modelInstance` as many times as you like. Blender, Maya, 3ds Max and every major engine export `.glb`. A minimal scene is a mesh, a light and a camera: [source,lua] ---- sceneEnable(true) sceneSetBackground(0, 0, 0, 0) -- Transparent: the disc shows behind the scene. 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) -- One degree a frame about its own Y. return OVERLAY_UPDATED end ---- `sceneEnable(true)` is the one call every 3D game makes, and on a machine that cannot draw 3D it is the call that fails (see <>). Without a camera the scene is viewed from `(0, 0, 5)` looking at the origin, and without a background call the scene's background is opaque black. [#nodes] ==== Nodes A node's transform is set outright (`nodeSetPosition`, `nodeSetRotation`, `nodeSetQuaternion`, `nodeSetScale`) or nudged (`nodeMove`, `nodeRotate`), always in its parent's space. `nodeSetParent` re-hangs a node, keeping its local transform; `nodeGetWorldPosition` reads where it ended up after every parent has had its say. `nodeSetVisible` hides a node and everything under it, and `nodeDelete` frees it and its children, along with any body, emitter or sound attached to them. Names come from models: `nodeFind(name, root)` finds a joint or a part by the name it had in the file, under the instance given as `root`. A scripted node can be named too (`nodeSetName`), which is what `nodeFind` without a root searches. [source,lua] ---- -- A turret on a tank: the barrel is a child of the turret, the turret of the hull, -- so turning the turret carries the barrel and moving the hull carries both. 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) -- Lay the cylinder along -Z. function onOverlayUpdate() local stick = controllerGetAxis(0, GAMEPAD_AXIS_RIGHT_X) / 32767 -- -1 to 1. nodeRotate(turret, 0, stick * 2, 0) return OVERLAY_NOT_UPDATED end ---- Two calls bridge the scene and the overlay. `sceneProject(x, y, z)` turns a world point into overlay coordinates, so a name tag or a health bar drawn with `fontPrint` can follow a 3D object. `sceneUnproject(sx, sy, distance)` goes the other way: the world point that far along the ray through an overlay pixel, which with `physicsRaycast` is mouse picking. [source,lua] ---- function onMouseMoved(x, y, dx, dy, which) -- The mouse points along a ray from the camera; what it hits is the target. 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 A mesh is geometry; a material is how it looks; a node joins one of each (`nodeSetMesh(node, mesh, material)`). Share both freely: one `meshBox` and one material can sit on a thousand nodes, and that sharing is what makes the renderer fast (see <>). `meshNew` builds a mesh from tables of positions, normals, texture coordinates and indices when the primitives will not do. A material starts as a base color with alpha (`materialSetColor`) and the glTF metallic-roughness look: `materialSetMetallic` and `materialSetRoughness` from `0` to `1`. Roughness near `0` gives a mirror-sharp highlight and `1` a matte surface; a metal tints its highlights and reflections with its own color and has no diffuse color of its own. Beyond that: * `materialSetEmissive` makes a surface give off light of its own color (a screen, a lamp, hot metal); it does not light other things, but it does bloom (see <>). * `materialSetUnlit` shows the color as it is, ignoring every light: HUD elements in the scene, the built-in glow of a bulb's glass. * `materialSetDoubleSided` draws the back of open shapes, such as a plane seen from below or a flag. * `materialSetBlend` makes the alpha count, for glass, ghosts and water. Blended meshes draw after everything opaque, sorted by distance, and do not cast shadows. The texture of a material can be any loaded sprite's image (`materialSetTexture`), the laserdisc itself or a loaded video (`materialSetVideo`), a second camera's picture (`materialSetView`, see <>), or a GUI document (`materialSetGui`, see <>). A modeled arcade cabinet can play the disc on its own screen, or show its options page there. Texture coordinates come from the mesh; the primitives map an image once across each face, and `materialSetTiling` repeats it, which is what ground and walls want. Textures are mipmapped and filtered anisotropically, so a floor stays crisp into the distance; `materialSetFilter(material, FILTER_NEAREST)` keeps pixel art blocky instead. [source,lua] ---- local stone = materialNew() materialSetTexture(stone, spriteLoad(DIR .. "flagstones.png")) materialSetTiling(stone, 12, 12) -- Twelve repeats across the plane. materialSetRoughness(stone, 0.9) local floor = nodeNew() nodeSetMesh(floor, meshPlane(24, 24), stone) local glass = materialNew() materialSetColor(glass, 180, 220, 255, 90) -- Alpha 90 of 255. materialSetBlend(glass, true) materialSetRoughness(glass, 0.05) ---- Four more maps give a surface detail its geometry lacks, and a loaded model brings them along when its file has them: * `materialSetNormalMap` bends the lighting with a tangent-space normal map, so bricks stand out of a flat wall. A strength above `1` exaggerates it. * `materialSetOcclusionMap` darkens crevices that ambient light and the sky cannot reach. * `materialSetMetallicRoughnessMap` varies metallic and roughness across the surface, packed the glTF way (roughness in green, metallic in blue) and multiplied by the material's own factors, so set those to `1` when the map should rule. * `materialSetEmissiveMap` says which parts glow, tinted and scaled by `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) -- The mortar glows orange. ---- .Compressed textures A PNG is decoded to four bytes a texel on the GPU, plus a third again for its mipmaps; a level the size of Sponza holds 390 MB of textures that way, more than a Raspberry Pi 4 shares with its display. Textures shipped as KTX2 files (Basis Universal, made by `util/compressTextures.py` from PNGs or from a whole `.gltf`) stay compressed on the GPU at a quarter of the size or less, in whichever block format the machine has: BC7 on desktops, ASTC on Apple GPUs and the Pi 4, BC3 on older cards, and plain RGBA where nothing else exists, all from the one file. Give a `.ktx2` name in place of a sprite to `materialSetTexture` and the map calls, and a model whose file uses `KHR_texture_basisu` loads them on its own. `sceneGetStats` reports the texture memory in use, so you can see the difference. [source,lua] ---- materialSetTexture(ground, DIR .. "flagstones.ktx2") local draws, drawn, calls, textureKB = sceneGetStats() debugPrint("textures: " .. textureKB .. " KB") ---- ==== Lights and Shadows Up to eight lights shine at once, and each is a node, so lights parent and animate like anything else: * `LIGHT_DIRECTIONAL` is a sun: only its direction matters, and everything is lit the same. An intensity of `1` lights a white surface facing it to white. * `LIGHT_POINT` is a bulb, fading with distance, and `lightSetRange` stops it altogether beyond a distance. Its intensity is its brightness one unit away, so a bulb that has to light a room needs a number in the tens. * `LIGHT_SPOT` is a cone with an inner angle that is fully lit and an outer angle where the light has faded to nothing (`lightSetCone`). `sceneSetAmbient` adds a little light from everywhere so shadows are not black; a sky does that better (see <>). Colors given as `0` to `255` are sRGB, like everything else in Singe, and are converted to linear light on the way in, so a light of `128, 128, 128` is a quarter as bright as white, not half. Any light can cast soft-edged shadows from everything opaque (`lightSetShadow`). Directional and spot lights cast through a shadow map; point lights through a cube map that reaches every direction, so a bulb may sit inside what it lights (a room, a lamp shade, the cabinet): closed meshes cast from their back faces too, and the shadow's depth range is fitted to the casters around the light. `nodeSetShadow(node, false)` excuses a mesh from casting: the bulb's own glass, a glowing sign, a name tag. `sceneSetShadowSize` sets the map's resolution (`256` to `4096`, default `1024`) and trades sharpness for speed. A directional light's shadow is split into cascades along the view (`sceneSetShadowCascades`, three by default), each a map fitted to a slice of what the camera sees, so shadows stay sharp close up in a large level and still reach as far as `sceneSetShadowDistance`. One cascade fits a single map to the whole scene, which suits small scenes and orthographic cameras. A view (`viewNew`) draws with the same maps, taking for each point the finest cascade that covers it, so a monitor shows the shadows the window does. [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) -- Three cascades out to 80 units. 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) -- A cube map: six passes a frame. nodeSetShadow(bulbGlass, false) -- The glass around it casts nothing. ---- [#scenecamera] ==== The Camera and Views Any node can be the camera (`cameraSet`); it looks down its own -Z, so `nodeLookAt` aims it, and hanging it under a moving node makes a chase camera. `cameraSetPerspective(fovDegrees, near, far)` is the default lens; `cameraSetOrthographic(height, near, far)` draws without perspective, for diagrams, HUD-like scenes and side-on games. Keep `near` as large as the scene allows, because the depth buffer's precision is spent between `near` and `far`. [source,lua] ---- local camera = nodeNew() nodeSetParent(camera, car) nodeSetPosition(camera, 0, 2.5, 7) -- Behind and above the car, in its space. nodeLookAt(camera, 0, 1, 0) cameraSet(camera) cameraSetPerspective(55, 0.1, 300) ---- A *view* is a second camera rendered to a texture every frame and shown on a material: a security monitor, a rear-view mirror, a portal, a picture in picture. `viewNew(width, height)` makes one at that resolution, `viewSetCamera` gives it a camera node, and `materialSetView` puts its picture on any mesh. Views share the frame's shadows and skip bloom, and each one renders the scene again, so keep them few and small. Each view turns billboards (see <>) to its own camera and draws transparent materials back to front from it, so a monitor sees name tags face on and glass in the right order. [source,lua] ---- local rearCamera = nodeNew() nodeSetParent(rearCamera, car) nodeSetPosition(rearCamera, 0, 1.2, -2) nodeSetRotation(rearCamera, 0, 180, 0) -- Looking back. 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) -- The plane faces up; stand it facing the driver. ---- [#scenelook] ==== The Look of the Frame The scene is lit in linear light. Colors you give as `0` to `255` are sRGB and are decoded on the way in, textures are decoded by the GPU as they are sampled, lights add up without clipping, and at the end of the frame a tone curve brings the result back to the screen. What that means in practice: * `sceneSetExposure(stops)` brightens or darkens the whole frame like a camera's exposure: `+1` doubles the light, `-1` halves it, so a cave and a beach can share the same lights and materials. * `sceneSetTonemap` picks the curve: `TONEMAP_NEUTRAL` (the default, which keeps colors where glTF viewers keep them and compresses only the brightest light), `TONEMAP_ACES` (a filmic look with more contrast), or `TONEMAP_NONE` (clip at white). * `sceneSetBloom(threshold, strength)` makes everything brighter than the threshold glow: the sun through a window, a bulb, an emissive sign, a flame. The glow is taken from the scene's light before the tone curve, so only what is truly bright blooms; a threshold of `1` is white. .The sky and its light `sceneSetSky(file)` wraps the scene in an equirectangular panorama. A Radiance `.hdr` keeps its full range; a PNG or JPEG works too. The sky is drawn behind everything, and unless `sceneSetEnvironment(false)` it also lights the scene: its overall color from every direction replaces the flat ambient, and its reflection shows on metals and glossy surfaces, sharp on a mirror and blurred as roughness rises. Without a sky, metals have nothing to reflect and only the ambient and the lights shape them. `sceneSetSkyIntensity` scales the sky and its light together, so one panorama serves noon and dusk. .Fog `sceneSetFog(r, g, b, near, far)` fades everything, particles included, toward a color between two distances. The sky is never fogged, so match the fog's color to the sky's horizon and the far edge of the scene disappears into it. [source,lua] ---- sceneSetSky(DIR .. "harbor.hdr") sceneSetSkyIntensity(0.8) sceneSetFog(190, 200, 215, 40, 160) -- The color of the sky at the horizon. sceneSetExposure(-0.5) -- A little darker than the sun would make it. sceneSetBloom(1.0, 0.35) -- Only what is brighter than white glows. ---- ==== Models and Animation `modelLoad` takes a `.glb` by the same game-relative name `spriteLoad` takes, and a packed `.game` carries it along. The file must be self-contained: meshes, skins, animations and textures all inside the one file, which is Blender's default export. A model that refers to a `.bin` or an image beside it is refused with a message naming the file, for the same reason a packed game has to be self-contained. Materials use the metallic-roughness model with `KHR_materials_emissive_strength` and `KHR_texture_basisu`; an alpha `MASK` material draws opaque, a texture addressed through a second set of texture coordinates reads the first set, and `KHR_texture_transform` is ignored, each with a line in the program trace. A skin may have up to `128` joints. Loading is the expensive part; `modelInstance` then places a copy of the model's node tree, as many times as you like, and every instance shares the file's meshes and materials. `modelDelete` frees the model; instances keep their nodes, bare. [source,lua] ---- local foxModel = modelLoad(DIR .. "Fox.glb") local pack = {} for i = 1, 6 do local fox = modelInstance(foxModel) nodeSetScale(fox, 0.02) -- The file is in centimeters. nodeSetPosition(fox, i * 1.5 - 5, 0, 0) pack[i] = fox end ---- A model's animations are listed by `modelGetAnimations` and driven per instance with `animationPlay(node, nameOrIndex, loop, speed, fade)` on the instance's root node, plus pause, resume, stop and seek calls. Node animation (things moving), skinning (characters deforming) and morph targets (blend shapes: faces, lip sync, muscle bulges) all work. Animations advance with real time and hold while the game is paused. A morph weight can also be set from script with `nodeSetMorph`, by name or number, on the node that carries the mesh. A `fade` in seconds crossfades from whatever was playing, so a walk becomes a run without a snap. When nothing was playing (after `animationStop`, or when a ragdoll has been switched off), the fade starts from the pose the nodes are in, so a character eases up from where it fell. Up to four more clips can play at once on layers above the base (`animationPlayLayer`), each blended in by `animationSetLayerWeight` and limited to part of the skeleton by `animationSetLayerMask`: a look-around on the neck while the legs walk, a wave on one arm over a run. [source,lua] ---- animationPlay(fox, "Walk", true) animationPlayLayer(fox, 1, "Survey", true, 1, 0.5) -- Head turns, blended in over half a second animationSetLayerMask(fox, 1, nodeFind("b_Neck_04", fox)) -- ... on the neck and above only. 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) -- Faster, crossfaded in 0.4 s. end end ---- ==== Sound in the Scene A sound effect can be placed in the scene so it is heard from where it is. `soundSetPosition(channel, x, y, z)` puts a playing channel at a world point, `soundSetNode(channel, node)` makes it follow a node instead, and `soundSetRange(channel, near, far)` says where it is at full volume and where it has faded to silence. Sounds are heard from the camera unless `soundSetListener` names another node (the player's head, for a third-person game), and they pan left and right as the listener turns. A channel not placed in the scene plays as it always has, with `soundSetPan` for stereo. [source,lua] ---- local crackle = soundPlay(fireSound, -1) -- Loops forever. soundSetNode(crackle, brazier) soundSetRange(crackle, 2, 18) -- Full within 2 units, gone at 18. soundSetListener(hero) ---- [#scenesprites] ==== Sprites and Text in the Scene Any node can show a picture instead of a mesh. `nodeSetSprite(node, sprite, height)` puts a loaded sprite (every frame of an animated one, with `nodeSetSpriteFrame` choosing) on a quad that many world units tall, lit like anything else and casting shadows unless `nodeSetShadow` says otherwise. `nodeSetText(node, text, height)` does the same with a line of text in the selected font and color, unlit. Both are meshes as far as the rest of the engine is concerned: they parent, scale, and take a material's blend. `nodeSetBillboard` turns a node to face the camera every frame: `BILLBOARD_ALL` squarely (name tags, glows, impostors), `BILLBOARD_Y` about its own up axis only (trees, health bars, anything that must stay upright), and `BILLBOARD_NONE` to stop. It works on meshes too. [source,lua] ---- local tag = nodeNew() nodeSetParent(tag, fox) nodeSetPosition(tag, 0, 110, 0) -- In the Fox's centimeters, above its head. nodeSetScale(tag, 50) -- Undo the instance's 0.02 scale. 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) ---- [#sceneterrain] ==== Terrain `meshHeightmap(image, sizeX, sizeY, sizeZ)` turns a grayscale image into rolling ground: one vertex per pixel, black low and white `sizeY` high, with normals from the slopes and texture coordinates across the whole. Give its material a texture and `materialSetTiling` to repeat it every few units. Images may be up to 1025 pixels each way; a 257 pixel map over 120 units gives a vertex every half unit, which is plenty for hills. A `BODY_STATIC` body with `SHAPE_MESH` on that node becomes a height field, far cheaper than the triangles would be, so cars drive it and players walk it as they are. `terrainGetHeight(node, x, z)` says how high the ground is at a point, for placing things on it without a ray cast. [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) -- A height field. 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 A navigation mesh tells characters where they can walk. `navNew(radius, height, maxSlope, maxStep)` starts one for a given agent size, `navAddNode` feeds it the level's geometry (a model instance's root does the whole model), and `navBuild` bakes the walkable surface: floors, ramps and stairs the agent can climb, minus a margin around every wall and column. Baking Sponza takes a tenth of a second on a desktop; `navSave` and `navLoad` keep a baked mesh in the game's data folder for machines where it takes longer, and a mesh shipped with the game loads the same way. `navDraw` shows the mesh over the scene while you are getting the parameters right. Queries then answer the questions a game asks: `navPath` returns the corners of the shortest route between two points, `navNearest` snaps a point onto the mesh, `navRaycast` says whether a straight walk is blocked, and `navRandomPoint` picks somewhere to wander to. Agents (`navAgentNew`) walk the mesh for you. `navAgentMoveTo` sends one somewhere and it steers around the other agents on the way, moving its node (or driving a player controller on it, with `navAgentSetPlayer`) and calling `onNavArrived` when it gets there. `navAgentGetVelocity` tells you whether to play the walk or the run. [source,lua] ---- local nav = navNew(0.4, 1.6, 45, 0.3) -- An agent 0.4 wide, 1.6 tall, climbing 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)) -- Off to the next spot. end ---- [#sceneperformance] ==== Performance and Requirements The scene needs a GPU that speaks Vulkan (Linux), Direct3D 12 (Windows 10 and later) or Metal (macOS). The Raspberry Pi 4 is the minimum Pi for 3D: it and every later model have a Vulkan driver, the Pi 3 and earlier do not. On a machine without a suitable GPU, 2D games run exactly as before (2D physics included) and the first 3D call ends the game with an error naming the problem. Meshes outside the camera's view are skipped, and copies of the same mesh with the same material draw as one instanced call, so a forest of one tree costs about what one tree costs. `sceneGetStats` reports the draws collected, how many were in view, how many draw calls that took, and the texture memory in use. To help it along: * Share meshes and materials between nodes (one `meshBox`, many nodes; one model, many `modelInstance` calls) rather than making one per object. * Skinned and morphing meshes and blended ones draw one at a time; keep them to what the scene needs. * Antialiasing (4x multisampling) is on wherever the GPU offers it; `sceneSetAntialias(false)` buys back speed on a Pi, as does a smaller `sceneSetShadowSize` and fewer shadow-casting point lights, each of which costs six passes. * Keep an eye on triangle counts and texture sizes on small boards, ship KTX2 textures, and load a model once rather than twice. [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] === Physics Bodies, joints, triggers and ray casts live in the 3D scene and ride on Jolt Physics. A body attaches to a node the way a mesh does, and from then on the node and the body are one thing: the body's shape is sized in world units and scaled by the node, and whichever of the two moves, the other follows. Units are meters, kilograms and seconds, and gravity defaults to `0, -9.81, 0`. Physics does not need a GPU; see <>. ==== Bodies `bodyNew(node, type, shape, a, b, c)` gives a node a body of one of three types: * A *static* body (`BODY_STATIC`) stays where the node was when the body was made: floors, walls, the cabinet. Moving its node afterwards does not move the body. * A *kinematic* body (`BODY_KINEMATIC`) follows its node wherever the script or an animation moves it, and pushes dynamic bodies out of its way without being pushed itself: a paddle, a moving platform, a character's hand. * A *dynamic* body (`BODY_DYNAMIC`) is moved by the simulation and drives its node: it falls, bounces, slides and gets pushed. Do not move its node yourself; give it velocities, forces or impulses instead (`bodySetVelocity`, `bodyApplyForce`, `bodyApplyImpulse`). The shape is a box, sphere, capsule or cylinder sized by `a`, `b` and `c` (`SHAPE_BOX` takes width, height and depth; `SHAPE_SPHERE` a radius; `SHAPE_CAPSULE` and `SHAPE_CYLINDER` a radius and a height along Y), a convex hull of the node's mesh (`SHAPE_HULL`, everything under the node, so a model instance's root works), or the mesh's exact triangles (`SHAPE_MESH`, static and kinematic bodies only; on a heightmap mesh it is a height field, see <>). [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) -- The kinematic paddle sweeps through the stack. return OVERLAY_UPDATED end ---- .Mass and materials A dynamic body's mass comes from its shape's volume at the density of water, so a box the size of a door weighs 140 kilograms and a nudge will not move it. Set `bodySetMass` on anything you want to feel light, and size impulses to the mass: an impulse of `mass * speed` sets that speed. `bodySetFriction` and `bodySetBounce` make ice and rubber; `bodySetEnabled` takes a body out of the world and puts it back without losing it. .Timing The world steps at a fixed sixty times a second from the wall clock, after animations have posed their nodes and before the scene renders, so a kinematic body driven by an animation pushes with the right velocity. Pausing the game pauses physics; `physicsSetEnabled(false)` pauses it on its own. `bodyIsResting` says when a body has settled, which is when a thrown thing has stopped and a score can be counted. A body, player, ragdoll, soft body or vehicle whose node has been deleted is released at the start of the next frame, enabled or not and paused or not, so its slot is free again; a node handle that `nodeNew` later hands out afresh carries none of the old node's physics, and `playerExists` and its kind answer `false` for it until it gets its own. ==== Events and Queries Define `onCollision(nodeA, nodeB, x, y, z, speed)` and Singe calls it once for each new contact, with the point and the speed the two bodies met at, so a bump and a crash can sound different. A body made a trigger with `bodySetTrigger` pushes nothing but reports what enters and leaves it through `onTrigger(trigger, other, entered)`: a finish line, a doorway, a danger zone. `physicsRaycast` finds the first body along a ray; with `sceneUnproject` for the ray, that is mouse picking (see <>). [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 `jointHinge`, `jointBall` and `jointSlider` connect two bodies, or a body and the world when the second node is `-1`: doors, pendulums, drawers, the wheels of a side-on car. The anchor and the axis are given in world space where the bodies stand when the joint is made. Hinges and sliders take limits (`jointSetLimits`): a positive hinge angle is a right-hand turn of the first node about the axis, and a positive slider distance is along it. [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) -- On the frame, about Y. jointSetLimits(hinge, 0, 110) -- Opens one way, 110 degrees. ---- Bodies on model instances work like any other: `bodyNew(instanceRoot, BODY_DYNAMIC, SHAPE_HULL)` wraps the whole model in a convex hull. A skinned character is best a player (below) or a kinematic capsule that pushes things rather than being pushed. [#physics2d] ==== 2D Games Physics is not tied to the 3D scene. Nodes exist on every machine, GPU or not, so a 2D game can give nodes bodies, call `physicsSet2D(true)` so they move in X and Y and turn about Z only, work in overlay coordinates with gravity pointing down the screen, and draw sprites where the nodes are. This is the form that runs on a Raspberry Pi 3, which has no 3D. [source,lua] ---- physicsSet2D(true) physicsSetGravity(0, 900, 0) -- Pixels per second squared; overlay Y runs down. 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 ---- Give every 2D body some depth (the third size) so shapes overlap in Z; `50` is fine. Circles are `SHAPE_SPHERE`. Players and 2D emitters (see <>) work in this world too, and a platformer is a player with `playerMove(node, vx)`. ==== Players A body goes where forces send it; a *player* goes where the script says. `playerNew` puts a character controller on a node: a capsule by default (`playerNew(node, radius, height)`), or any convex `bodyNew` shape (`playerNew(node, SHAPE_BOX, w, h, d)` for a robot, `playerNew(node, SHAPE_SPHERE, r, 0)` for something that rolls yet still climbs steps, `SHAPE_HULL` for the model's own outline; three arguments always mean a capsule, so a sphere spells its sizes out). The shape stands on the node's origin, which is the feet. Each frame the script gives `playerMove` the horizontal velocity it wants, and the controller walks along floors, climbs steps up to `playerSetStep`, slides along walls, refuses slopes steeper than `playerSetSlope`, falls under gravity, rides moving platforms, and shoves light dynamic bodies out of the way (`playerSetPush`). `playerJump` works only with ground underfoot and says whether it did. The node's rotation is the script's: turn it to face the way it walks, and hang the model under it as a child. [source,lua] ---- hero = nodeNew() nodeSetPosition(hero, 0, 0, 0) playerNew(hero, 0.3, 1.0) -- A capsule of radius 0.3, 1 tall, feet at the node. fox = modelInstance(modelLoad(DIR .. "Fox.glb")) nodeSetParent(fox, hero) function onInputPressed(switch) if switch == SWITCH_BUTTON1 then playerJump(hero, 4.5) -- Needs ground underfoot. 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 ---- Players report `onCollision` when they start touching a body and `onTrigger` when they enter or leave a sensor, with their node in the place a body's node would take, so a door, a pickup and a lava pit work the same for a player as for a ball. `playerIsOnGround` and `playerGetGround` say what the player stands on, for footstep sounds and for dropping off a platform on purpose. In a 2D world a player is a platformer character: `playerMove(node, vx)` runs along X, gravity points down the screen, and everything else is the same. The controller's tolerances scale with the shape, so a 30 pixel capsule in a sprite game behaves like a 0.3 meter one in a 3D game. ==== Vehicles A *vehicle* is a dynamic body (the chassis, made with `bodyNew` like any body, facing -Z with Y up) that Jolt drives through wheels: `vehicleNew(node, VEHICLE_CAR)` for anything with axles, `VEHICLE_MOTORCYCLE` for two wheels in a line that lean through turns and balance, or `VEHICLE_TANK` for wheels on two tracks that turn on the spot. Wheels are nodes: `vehicleAddWheel(node, wheelNode, radius, width, suspension)` takes the wheel's position from where its node is relative to the chassis, and from then on the engine poses that node every frame (its X axis is the axle, so a `meshCylinder` wheel wants a child turned 90 degrees). Wheels found in a model with `nodeFind("wheelFL", car)` work the same. By default the front wheels steer and all of them drive; `vehicleSetWheel` says otherwise. Each frame the script hands over the driver: `vehicleDrive(node, forward, right, brake, handBrake)`. The engine, automatic gearbox and differentials are Jolt's with a 1500 kg car's defaults; `vehicleSetEngine`, `vehicleSetGears`, `vehicleSetSuspension`, `vehicleSetSteering`, `vehicleSetBrakes` and `vehicleSetAntiRoll` tune them, and any change rebuilds the vehicle in place at the next step. `vehicleGetSpeed`, `vehicleGetRpm` and `vehicleGetGear` feed a dashboard. A vehicle is still a body: it jumps ramps, hits walls, shoves crates and reports `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 }, -- Front left and right ... { -0.95, -0.2, 1.4 }, { 0.95, -0.2, 1.4 }, -- ... rear left and right. } 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 ---- Tanks take `forward` as throttle and `right` as the turn (with no throttle they pivot), and ignore the steering, brake and anti-roll tuning, since their tracks brake and turn on their own. The motorcycle's lean spring is scaled to the chassis, so a light bike balances as well as a heavy one. Wheeled vehicles need a 3D world; a side-on 2D car is a body with two hinged wheels, which the joint calls already build. ==== Water `bodySetWater(node, density, linearDrag, angularDrag)` fills a static body with water: the top of its box is the surface, and every dynamic body inside floats, sinks or rolls by Jolt's buoyancy against that surface (the submerged part of the shape counts, so a tilted crate rights itself), slowed by the drags. `bodySetCurrent` gives the water a flow. `density` is a multiplier, `1` for water, and each floating body's own `bodySetBuoyancy` (default `1.2`, which floats gently; below `1` sinks) scales it. The volume is a trigger, so `onTrigger` fires on the way in and out for splashes. Water is not drawn: give the volume's node a blended material, or none. A player in water swims: `playerIsSwimming` says so, `playerMove(node, vx, vy, vz)` then steers in three axes, it sinks slowly with no vertical input (`playerSetSwim` sets that rate and the water's drag), and the current carries it. A boat is a buoyant hull that `vehicleNew(node, VEHICLE_BOAT)` gives a propeller and a rudder: `vehicleSetThrust` says how hard it pushes and where in the hull, `vehicleSetRudder` how hard it turns, and `vehicleDrive` works as for a car, all only while the propeller is under a surface. [source,lua] ---- pool = nodeNew() nodeSetMesh(pool, meshBox(8, 3, 12), blue) -- Blended blue, drawn as the water. nodeSetPosition(pool, 0, -1.5, 0) -- Its top, at Y = 0, is the surface. bodyNew(pool, BODY_STATIC, SHAPE_BOX, 8, 3, 12) bodySetWater(pool, 1.0, 0.6, 0.2) bodySetCurrent(pool, 0.5, 0, 0) -- Drifting toward +X. bodySetBuoyancy(crate, 1.6) -- Rides high. bodySetBuoyancy(anchor, 0.3) -- Goes down. ---- ==== Ragdolls `ragdollNew(node)` looks under a model instance for its skin and turns the skeleton into a ragdoll recipe: every joint with a child joint becomes a capsule along that bone, jointed to the nearest bone above it by a swing-twist joint with sensible limits, and leaf joints (fingers, ears, the tip of a tail) ride along with their parents. Nothing moves until `ragdollActivate`: then the animation stops, bodies are made where the bones are, and from that moment physics drives the joint nodes, so the skinned mesh flops, hits the floor and comes to rest with the rest of the world. `ragdollApplyImpulse` shoves one bone by its joint's name. `ragdollSetStrength` powers every joint toward the pose the ragdoll was activated in, which turns a limp fall into a stagger or lets a downed character pull its limbs back in; it does not stand a body up, since nothing pushes off the floor. `ragdollDeactivate` hands the skeleton back to animation from wherever it lies, and an `animationPlay` with a fade then eases it up from there. Tune a bone that looks wrong with `ragdollSetJoint` before activating. [source,lua] ---- fox = modelInstance(foxModel) ragdollNew(fox) -- Recipe from the skin; still animated. function onCollision(a, b, x, y, z, speed) if (a == hero or b == hero) and speed > 3 then playerSetEnabled(hero, false) ragdollActivate(fox) -- Physics drives the skeleton now. ragdollApplyImpulse(fox, "b_Spine02_03", 40, 30, 0) end end ---- ==== Soft Bodies Cloth, rope and jelly are meshes whose vertices are simulated. `softNew(node, SOFT_CLOTH)` turns the node's mesh into cloth, its vertices (welded by position) into particles joined by stretch, shear and bend constraints, where the node has put it in the world; a subdivided `meshPlane(width, depth, columns, rows)` is the usual start. `softNew(node, SOFT_BODY)` does the same for a closed mesh and `softSetPressure` inflates it: a balloon, a jelly. `softNew(node, SOFT_ROPE, x, y, z, segments, radius)` runs a chain of particles from the node's position to that point and gives the node a tube mesh around them (set its look with `nodeSetMaterial`). Soft bodies collide with everything rigid, fall under gravity, and are drawn by the mesh they came from, which the engine rewrites every frame. `softPin` holds the nearest particle where it is or to a node (the flag's edge to the pole, the rope's top to the crane); `softSetStiffness`, `softSetMass` and `softSetDamping` tune the material. A node whose mesh is shared with other nodes changes them too, so give a soft body a mesh of its own. [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) -- Hanging upright. softNew(flag, SOFT_CLOTH) for _, y in ipairs({ 1.9, 2.15, 2.4, 2.65, 2.9 }) do softPin(flag, -5, y, 0) -- Along the pole. end ---- Cost scales with the particle count: a 20 by 20 sheet is 441 particles and fine on a Raspberry Pi 4; keep cloth to a few hundred particles and a rope to a dozen segments. Particles collide with rigid bodies, not faces, so a fine enough mesh (particles closer than the smallest thing that will land on it) is what stops a crate falling through. There is no self-collision: cloth can pass through itself when crumpled. Skinned meshes cannot be soft. ==== Seeing the Physics When a crate falls through the floor or a joint bends the wrong way, `physicsSetDebug` draws what the simulation actually holds as lines over the scene each frame: * `DEBUG_SHAPES`: the bodies in wireframe, colored by state (sleeping, moving, static), with the players in yellow. * `DEBUG_CONSTRAINTS`: the joints, ragdoll joints and vehicle constraints with their limits. * `DEBUG_CONTACTS`: a red cross where bodies met this step. * `DEBUG_VELOCITIES`: a line along each moving body's velocity. * `DEBUG_STATIC`: the static bodies' shapes as well, which stay off otherwise because a level mesh is a lot of lines. Add them together, or use `DEBUG_ALL` for the first four. `DEBUG_NONE` turns it off, and it costs nothing while off. A script draws its own lines the same way with `lineDraw` (a ray cast, a path, a bounding box), one frame at a time like the overlay, and `navDraw` shows a navigation mesh. [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 Physics runs on the CPU and needs no GPU, but the library is built for SSE4.1 on x86 (2008 and later) and NEON on the Pi; a machine below that gets an error from the first `bodyNew`. The simulation uses every core but one, so a desktop steps in parallel and a Pi on a single thread. [#particles] === Particles Sparks, smoke, fire, rain, dust, magic: anything made of many small short-lived things is a job for an *emitter*. An emitter holds a recipe (how many a second, how long they live, how fast and which way they set off, what pulls on them, how they grow, fade and spin) and a pool of particles it runs from that recipe. The script sets the recipe once, then starts the emitter for a steady stream (`emitterStart`), bursts it for a one-off puff (`emitterBurst`), or both. The engine moves every live particle every frame, and stops moving them while the game is paused. One emitter API serves the overlay and the 3D scene; only the units and the drawing differ. Everything given as a minimum and a maximum (`emitterSetLife`, `emitterSetSpeed`, `emitterSetSize`) is drawn at random between the two for every particle. ==== 2D Emitters `emitterNew()` with no argument makes a 2D emitter. It lives in overlay coordinates, Y down, so a fountain's gravity is positive Y. Move it with `emitterSetPosition`, and draw it from `onOverlayUpdate` with `emitterDraw`. The overlay itself is a picture the CPU paints, so particles are not painted into it: the GPU draws them, either above the whole overlay (`PARTICLE_OVER`, the default) or beneath it and above the video and the 3D scene (`PARTICLE_UNDER`, for smoke behind the score). 2D emitters run on every machine Singe runs on, the Raspberry Pi 3 included, and go well with 2D physics: read a body's node position and give it to `emitterSetPosition`. [source,lua] ---- sparks = emitterNew() emitterSetBlend(sparks, PARTICLE_ADD) emitterSetLife(sparks, 0.3, 0.7) emitterSetSpeed(sparks, 80, 200) emitterSetDirection(sparks, 0, -1) -- Up the screen. 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 `emitterNew(node)` makes a 3D emitter attached to a node. Particles are born at the node's world position (spread over `emitterSetRadius`), in world units, and draw themselves in the scene as camera-facing billboards after every mesh: depth-tested against the geometry, never writing depth, unlit unless asked, casting and receiving no shadows. By default a particle stays where it was born once it leaves the emitter (smoke keeps drifting where it was made while the torch moves on); `emitterSetLocal` makes the whole cloud follow the node instead. Deleting the node deletes its emitters. [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 A particle is a textured quad. Without a texture it is a soft disc the engine provides; `emitterSetTexture` gives it a loaded sprite instead (a puff, a spark streak, a snowflake), and for an animated sprite `emitterSetFrames` picks a random frame per particle, so one atlas of puffs gives a varied cloud. Blending is normal (`PARTICLE_ALPHA`) or additive (`PARTICLE_ADD`, for anything that glows). `emitterSetColor` gives the color and alpha at birth and at death, and the particle fades between them over its life; `emitterSetSize` does the same for its size, with a third value for how fast it spins. ==== Trails, Collisions, Lit and Soft Particles `emitterSetTrail(emitter, length, width)` keeps the last few positions of every particle and draws a ribbon through them, fading toward the tail: sparks, tracer rounds, comets, in 2D and 3D alike. `emitterSetCollide` lets particles bounce: off a horizontal floor at a height you give (`COLLIDE_FLOOR`, free), or off the physics world by ray casts (`COLLIDE_SCENE`, 3D only, capped per frame so a big emitter spreads the cost over a few frames). Two more are for 3D only. `emitterSetLit` shades each particle by the scene's lights and sky as a small sphere facing the camera, so smoke takes a brazier's color and dust glints in the sun (shadows are not read). `emitterSetSoftness` fades a particle out over a distance where it meets geometry, so smoke does not cut into a floor or a fountain into its pool; this costs one extra depth pass of the scene per frame while any emitter uses it. [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) -- Takes the fire's color from below. emitterSetSoftness(smoke, 0.4) -- No hard edge where it meets the bowl. emitterStart(smoke) embers = emitterNew(brazier) emitterSetBlend(embers, PARTICLE_ADD) emitterSetTrail(embers, 6, 0.02) -- A short streak behind each ember. emitterSetCollide(embers, COLLIDE_FLOOR, 0.3, 0.8, 0) -- Bounce off the ground at Y = 0. emitterStart(embers) ---- ==== Cost Particles are cheap to simulate and cost what they cover on screen. A few thousand small ones are nothing; a handful of large additive ones over the whole screen are what slow a Raspberry Pi 4 down. The pool is capped by `emitterSetMax` (default 1000); when it is full, no more are born until some die. [#migrating] === Migrating from Singe 2.10 Singe 3.00 moved the sprite handle to the first argument of `spriteDraw`, `spriteLoop`, `spriteQuality`, `spriteRotate`, `spriteRotateAndScale`, `spriteScale`, and `spriteSetFrame`, so every sprite call now matches the `video*` family. To update a game, move the last argument of each of those calls to the front: [source,lua] ---- spriteDraw(x, y, cursor) -- 2.10 spriteDraw(cursor, x, y) -- 3.00 spriteRotate(angle, cursor) -- 2.10 spriteRotate(cursor, angle) -- 3.00 ---- A game you cannot edit can opt into the old order instead. Either set the global before loading the framework: [source,lua] ---- SINGE_LEGACY_SPRITE_ARGS = true dofile("Singe/Framework.singe") ---- or add `LEGACY_SPRITE_ARGS = true` to its `games.dat` entry. Nothing else changed shape, but a few behaviors did: * Handles and counts are returned as Lua integers; `videoIsPlaying` returns a boolean; `mouseSetMode` returns nothing. * Errors inside callbacks now end the game with a traceback instead of being printed and ignored, matching how argument errors have always behaved. * Held keys no longer repeat in `MODE_NORMAL`. * Nothing is written beside the game. Without `--datadir` the data directory is `data//` under the game directory, where 2.x used the game's own directory; saves, settings and high scores belong under `singeGetDataPath()`. * `math.randomseed` accepts a fractional seed, as it did under 2.10's Lua; it is floored before Lua 5.4's generator sees it. `os.clock` is wall time since the engine started, not processor time. * The disc is parked on frame 1 by the engine before your script runs; `Framework.singe` no longer seeks when it is loaded. * `onMouseMoved` in `MANY_MOUSE` mode passes real movement in its third and fourth arguments, and `onControllerMoved` passes the controller index (`0` to `3`) instead of SDL's instance ID. * `mouseEnable` / `mouseDisable` and `singeEnablePauseKey` / `singeDisablePauseKey` still work, as aliases of `mouseSetEnabled` and `singeSetPauseKeyEnabled`. * A laserdisc is no longer assumed. A `games.dat` entry with a `VIDEO` is a laserdisc game, as before; one without runs on a canvas instead. On the command line pass `--disc` (or `--framefile`), since a video found beside the script no longer implies a disc. See <>. * The pause key now pauses the whole game, not just the media: the script is frozen until the key is pressed again, and `SWITCH_PAUSE` is delivered to the script only when the key has been disabled. It acts on the key press rather than the release. See <>. === Engine Constants The engine defines these globals before any script runs, so they are available to `controls.cfg` and to `Framework.singe` alike: [cols="2,3",options="header"] |=== | Constant | Meaning | `SWITCH_UP` ... `SWITCH_GRAB` | Values passed to `onInputPressed` / `onInputReleased` in `MODE_NORMAL`. | `FONT_QUALITY_SOLID`, `FONT_QUALITY_SHADED`, `FONT_QUALITY_BLENDED` | Arguments for `fontQuality`. | `MODE_NORMAL`, `MODE_FULL` | Arguments for `keyboardSetMode`. | `MOUSE_SINGLE`, `MOUSE_MANY` (also `SINGLE_MOUSE`, `MANY_MOUSE`) | Arguments for `mouseSetMode`. | `OVERLAY_NOT_UPDATED`, `OVERLAY_UPDATED` | Return values for `onOverlayUpdate`. | `RENDER_PIXELATED`, `RENDER_SMOOTH` | Arguments for `spriteQuality` / `videoQuality`. | `DISC_STOPPED`, `DISC_PLAYING`, `DISC_PAUSED`, `DISC_EJECTED` | Return values of `discGetState`. | `LIGHT_DIRECTIONAL`, `LIGHT_POINT`, `LIGHT_SPOT` | Arguments for `lightNew`. | `BODY_STATIC`, `BODY_DYNAMIC`, `BODY_KINEMATIC` | Body types for `bodyNew`. | `SHAPE_BOX`, `SHAPE_SPHERE`, `SHAPE_CAPSULE`, `SHAPE_CYLINDER`, `SHAPE_HULL`, `SHAPE_MESH` | Collision shapes for `bodyNew`. | `JOINT_HINGE`, `JOINT_BALL`, `JOINT_SLIDER` | Joint types (the `joint*` calls name them directly). | `DEBUG_NONE`, `DEBUG_SHAPES`, `DEBUG_CONSTRAINTS`, `DEBUG_CONTACTS`, `DEBUG_VELOCITIES`, `DEBUG_STATIC`, `DEBUG_ALL` | What `physicsSetDebug` draws; add them together. | `PARTICLE_ALPHA`, `PARTICLE_ADD` | Blending for `emitterSetBlend`. | `PARTICLE_OVER`, `PARTICLE_UNDER` | Where 2D particles draw, for `emitterSetLayer`. | `COLLIDE_NONE`, `COLLIDE_FLOOR`, `COLLIDE_SCENE` | What particles bounce off, for `emitterSetCollide`. | `BILLBOARD_NONE`, `BILLBOARD_ALL`, `BILLBOARD_Y` | How a node faces the camera, for `nodeSetBillboard`. | `VEHICLE_CAR`, `VEHICLE_MOTORCYCLE`, `VEHICLE_TANK`, `VEHICLE_BOAT` | Kinds of vehicle for `vehicleNew`. | `SOFT_CLOTH`, `SOFT_BODY`, `SOFT_ROPE` | Kinds of soft body for `softNew`. | `FILTER_LINEAR`, `FILTER_NEAREST` | Texture sampling for `materialSetFilter`. | `TONEMAP_NEUTRAL`, `TONEMAP_ACES`, `TONEMAP_NONE` | Tone curves for `sceneSetTonemap`. | `SOUND_ERROR_INVALID`, `SOUND_REMOVE_HANDLE` | `-1`, what `soundPlay` returns when no channel is free. | `SINGE_VERSION_MAJOR`, `SINGE_VERSION_MINOR`, `SINGE_VERSION_STRING`, `SINGE_FRAMEWORK_VERSION` | The engine version, as integers, as a string (`"v3.00"`), and as the number `singeVersion()` returns. | `SINGE_DEAD_ZONE` | The `DEAD_ZONE` from `controls.cfg`. | `SINGE_LEGACY_SPRITE_ARGS` | True when the game asked for the 2.10 sprite argument order. | `SINGE_DISC` | True when the game has a laserdisc; false when the canvas is the world. | `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` | Layout of the controller and mouse input codes; `Framework.singe` builds the `GAMEPAD_N` and `MOUSE_N` tables from them. |=== `Framework.singe` adds the `SCANCODE` and `MODIFIER` tables (SDL's key and modifier values), `GAMEPAD_0` to `GAMEPAD_3`, `MOUSE_0` to `MOUSE_3`, the `GAMEPAD_AXIS_*` indexes for `controllerGetAxis`, and `DIR`. === Included Libraries In addition to the standard Lua libraries and the Singe API, the following libraries are also available for use in Singe programs without the need to install any additional software: [cols="1,3",options="header"] |=== | Library | Purpose | Copas | Asynchronous networking | LuaFileSystem | Expanded filesystem support | json.lua | JavaScript Object Notation encoding and decoding | binaryheap | Binary heap data structure | timerwheel | Efficient timer scheduling | LuaSec | TLS / SSL communication | LuaSocket | TCP and UDP | LuaRS232 | RS232 serial port access | lsqlite3 | SQLite databases, `require("sqlite3")` | RmlUi | The GUI's own object model, the `rmlui` global (see <>) |=== Their usage is beyond the scope of this document. [#audiosync] === Audio Sync Singe presents video against the audio clock: a frame is shown when the sound that belongs with it is heard. The engine measures its own audio path at startup. SDL cannot report how much audio is queued between the mixer and the speaker, so Singe drains the device once, before anything audible plays, and counts the burst of buffers SDL uses to refill it; that burst is the queue depth, and the clock subtracts it. Run with `--program` to see the measurement in the trace (`Audio device queue: ...`). What the engine cannot see is anything downstream of the operating system's audio server: a DAC, an HDMI receiver, a Bluetooth link. Those add a fixed delay that varies by machine. If actions on screen visibly precede their sound, pass the missing milliseconds with `--audiodelay=MS`, put `AUDIO_DELAY = MS` in `games.dat`, or let the game set it from a service menu with <>. Positive values mean the audio is late; the video is held back to match. A negative value handles the rare case of audio arriving early. [#menucalibration] ==== Calibrating from the menu The bundled menu has a calibration screen for that downstream delay. Press the key mapped to `INPUT_SERVICE` (the `9` key by default) on the game list. The screen is a page of the menu's document: it covers the menu in black, shows the delay being tried and the device queue the engine measured, plays a click once a second and turns white for one frame at each flash. Adjust with left and right (10 ms) and up and down (1 ms) until the flash and the click happen together, then press button 1 to save. Start resets to zero and button 2 (or the service key again) cancels. The value is stored in `audio.cfg` in the data root and applied by the engine to every game on that machine, whether launched from the menu or from the command line; it is added to any per-game `AUDIO_DELAY`. Recalibrate after changing speakers, headphones, or displays. The screen works because the click and the disc audio share one mixer device and one queue: the flash is scheduled the measured queue plus the candidate value after the click, so when the two coincide the candidate is exactly the delay the engine cannot see. Display lag is folded in for free. A game can read the values with <> and <>, or offer its own screen with <>. Two properties of the video file itself also matter. AAC audio carries encoder priming samples that the container must tell decoders to skip; files muxed without that information (an MP4 with no edit list, a Matroska file with no codec delay) play their audio about 20 milliseconds late, which `AUDIO_DELAY` can absorb. And when the first video frame is timestamped later than the first audio sample, Singe honours the container's timing, so leading audio is not lost. === Video, Audio, and Container Formats Singe decodes video with FFmpeg's libraries directly, using the platform's hardware decoder when it offers one for the codec (VA-API or VDPAU on Linux, D3D11VA on Windows, VideoToolbox on macOS) and its own software decoder otherwise; the program trace reports which, and `--softwarevideo` forces software. Any container and codec the bundled FFmpeg can demux and decode will play: MP4, MKV, MPEG program streams, AVI, and the classic Daphne `.m2v` elementary streams with a matching `.ogg` audio file next to them. Every audio track in the file is available to `discSetAudioTrack` / `videoSetAudioTrack`; all tracks must share one sample format, channel count, and rate. The first time a video is opened, Singe indexes it and stores the index next to the game's other data (`-.index`, the hash made from the video's full name so two videos with one base name keep separate indexes). Indexing is one pass over the file without decoding, a few seconds even for a feature length disc, and happens again if the video changes. When a video is loaded, Singe reports its keyframe spacing in the program trace (`--program`), with a warning there if keyframes are more than two seconds apart, because a seek has to decode forward from the previous keyframe. Decoding happens on a separate thread, so a slow seek shows the previous frame a little longer instead of stalling the game. For laserdisc footage the constraints are frame accuracy and seek speed, not compression. H.264 in MP4 or MKV with a short keyframe interval (one or two seconds) seeks quickly and plays on every supported platform; long keyframe intervals make `discSearch` visibly slow because the decoder must walk from the previous keyframe. Standard definition (720x480 or 720x576) is the sweet spot. High definition sources work but cost proportionally more CPU on Raspberry Pi class hardware, and the overlay defaults to half the video resolution, so oversized video buys little. Sound effects go through SDL_mixer: WAV, OGG, FLAC, MP3, Opus, WavPack, and tracker modules are supported. Short uncompressed WAV files give the lowest latency. [#reference] == Lua API Reference This chapter documents the Lua API exposed by the Singe engine -- every function your game script can call, and every callback Singe will invoke on you. It is aimed at developers writing new games or porting video-heavy games from other engines. [#conventions] === Conventions Every API entry below follows the same structure: * *Signature(s)* in a code fence, showing every accepted form. * *Description* of what the function does and any side effects. * *Parameters* listed inline, or as a table when there are more than two. * *Returns* line describing the value pushed back to Lua (omitted when the function returns nothing). * *Notes*, if there are gotchas worth flagging. * *Since* -- the Singe version the function first appeared in. * *See also* -- related functions you will likely use in the same call site. * *Example* -- a short, realistic snippet in the shape of real game code. A few rules apply across the whole API: * *Colors* are 8-bit per channel: red, green, blue, and opacity are each integers from `0` to `255`. * *Overlay coordinates* are in the current overlay resolution (set by `overlaySetResolution`), not screen pixels. The default overlay resolution is half the video resolution in each dimension; call `overlaySetResolution` to change it. * *Handles* returned by `*Load` functions (sprites, sounds, fonts, videos) are opaque integers. Pass them back unchanged to the other functions in that namespace. Do not do math on them. * *Invalid arguments abort the script.* If a function is called with the wrong number of arguments, wrong types, or a handle that has already been unloaded, Singe terminates the running script with an error -- it does not return `nil` or a failure code. Validate user input in your script before handing it to the API. * *Drawing must happen from `onOverlayUpdate`.* Calls to any `overlay*`, `spriteDraw`, `videoDraw`, or `fontPrint` function from other callbacks will not be reflected on screen and may corrupt the overlay. This is the one rule that catches every new Singe developer. [#animation] === Animation These calls drive the animations a model carries, per instance. Every one takes the *root node* returned by `modelInstance`; other nodes raise an error, except `animationIsPlaying`, which answers `false`. Animations are numbered from `1` in the order `modelGetAnimations` lists them, or named as the file names them. A clip plays on the base layer, and up to four more can play on layers `1` to `4` above it; the time, pause, resume and seek calls address the base layer only. Time is in seconds, advances with the wall clock by at most a tenth of a second a frame, and holds while the game is paused. Node animation, skinning and morph target weights are all driven. See the Animation part of <>. [#animationgettime] ==== animationGetTime [source,text] ---- seconds = animationGetTime(node) ---- The current time into the base layer's clip, playing or paused, in the clip's own seconds (a `speed` of `2` moves it twice as fast). A looping clip wraps back to `0` at its end; a non-looping one stops at its length. Nothing played yet gives `0`. Clips on the upper layers are not reported. A node that is not an instance root raises an error. *Returns:* Seconds as a number. *Since:* 3.00. *See also:* <>, <> .Example [source,lua] ---- -- Play the footstep once each time the walk cycle passes its two contact points. 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) ---- Whether the base layer's clip is advancing on this instance. A non-looping clip stops playing when it reaches its end, and `animationPause` and `animationStop` both make this `false`; a looping clip stays `true` until stopped. Unlike the other calls here, a node that is not an instance root does not raise an error but answers `false`. *Returns:* Boolean. *Since:* 3.00. *See also:* <>, <> .Example [source,lua] ---- -- Back to idling once the one-shot attack has run out. 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) ---- Freezes the base layer's clip at its current time; the nodes hold that pose until `animationResume` continues it or another clip is played. Clips on the upper layers keep running. Pausing an instance that has nothing playing is harmless. A node that is not an instance root raises an error. *Since:* 3.00. *See also:* <>, <> .Example [source,lua] ---- -- A cutscene freezes the crowd while the camera moves. 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) ---- Starts one of the model's clips from its beginning on the base layer of this instance, replacing whatever was playing there; the pose updates at once. With a `fade` the old clip keeps running while it fades out over that many seconds, and when nothing was playing the fade starts from the pose the nodes are in now (a ragdoll that was just switched off, a scripted pose), with nodes the new clip does not animate easing back to the model's rest pose. A node that is not an instance root, a name the model does not have, or a number outside `1` to the clip count raises an error. *Parameters:* * `node` -- the instance's root node from `modelInstance`. * `nameOrIndex` -- a clip name as the file stores it, or its number from `1` in `modelGetAnimations` order. * `loop` -- boolean, default `true`. When `false` the clip stops at its last keyframe and `animationIsPlaying` becomes `false`. * `speed` -- playback rate, default `1`. `2` is twice as fast; `0.5` is half. * `fade` -- seconds to crossfade from what was playing, default `0` (a snap). *Since:* 3.00. *See also:* <>, <>, <>, <> .Example [source,lua] ---- -- A guard that idles until the player is spotted, then runs. 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) ---- Plays a clip on one of the four layers above the base, blended over whatever is beneath it by the layer's weight (`animationSetLayerWeight`, default `1`) and limited to part of the skeleton when a mask is set (`animationSetLayerMask`). Higher layers blend over lower ones; each layer runs its own clip, time and loop independently of the base. `nameOrIndex`, `loop`, `speed` and `fade` mean what they do in `animationPlay`, with one difference: a `fade` on a layer that was empty ramps the layer's weight in from `0` instead of crossfading from a pose. A layer outside `1` to `4`, a node that is not an instance root, or a clip the model does not have raises an error. *Parameters:* * `node` -- the instance's root node. * `layer` -- `1` to `4`. * `nameOrIndex` -- a clip name or number, as in `animationPlay`. * `loop` -- default `true`. * `speed` -- default `1`. * `fade` -- seconds, default `0`. *Since:* 3.00. *See also:* <>, <>, <>, <> .Example [source,lua] ---- -- Walk with the whole body, look about with the head only. animationPlay(fox, "Walk", true) animationPlayLayer(fox, 1, "Survey", true, 1, 0.5) animationSetLayerMask(fox, 1, nodeFind("b_Neck_04", fox)) -- Later, wave with one arm over whatever the legs are doing. function onGreet() animationPlayLayer(fox, 2, "Wave", false, 1, 0.2) end ---- [#animationresume] ==== animationResume [source,text] ---- animationResume(node) ---- Continues the base layer's clip from where `animationPause` froze it, or from wherever `animationSetTime` moved it meanwhile. Resuming a clip that is already playing changes nothing. An instance with no clip on its base layer (never played, or stopped) raises an error, as does a node that is not an instance root. *Since:* 3.00. *See also:* <>, <> .Example [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) ---- Limits a layer to `maskNode` and everything under it, so the clip on that layer moves only that part of the model and the rest keeps following the layers beneath. `maskNode` must be one of the instance's own nodes, found with `nodeFind(name, node)`; the instance root itself does not count. Leaving `maskNode` out, or passing `nil`, lifts the limit. The mask stays on the layer across later `animationPlayLayer` calls. A layer outside `1` to `4` raises an error, and so does a node that is not an instance root or a `maskNode` that is not part of that instance. *Parameters:* * `node` -- the instance's root node. * `layer` -- `1` to `4`. * `maskNode` -- a joint or node of the instance; omitted or `nil` for the whole model. *Since:* 3.00. *See also:* <>, <>, <> .Example [source,lua] ---- -- Aim the upper body at the target while the legs keep walking. 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) ---- How much a layer's clip shows over what is beneath it, `0` (not at all) to `1` (fully, the default); values outside that range are clamped. The weight belongs to the layer, not the clip, so it survives `animationPlayLayer` and `animationStop` on that layer. Change it a little every frame to fade a layer in or out by hand, or let a `fade` on `animationPlayLayer` do it once. A layer outside `1` to `4` or a node that is not an instance root raises an error. *Since:* 3.00. *See also:* <>, <> .Example [source,lua] ---- -- The limp gets worse as health drops. function onHealthChanged(health) local limp = 1 - health / maxHealth animationSetLayerWeight(hero, 1, limp) end ---- [#animationsettime] ==== animationSetTime [source,text] ---- animationSetTime(node, seconds) ---- Jumps the base layer's clip to a time in its own seconds; negative values become `0`. The pose updates at once whether or not the clip is playing, so a paused instance can be scrubbed to an exact frame, and a playing one continues from the new time. Any crossfade in progress on the base layer is cut short. An instance with no clip on its base layer raises an error, as does a node that is not an instance root. *Since:* 3.00. *See also:* <>, <>, <> .Example [source,lua] ---- -- A door whose opening clip follows a lever the player drags. animationPlay(door, "Open", false) animationPause(door) function onLeverMoved(fraction) animationSetTime(door, fraction * doorClipLength) end ---- [#animationstop] ==== animationStop [source,text] ---- animationStop(node) animationStop(node, layer) ---- Stops and forgets every clip on the instance, or only the clip on one layer above the base (`1` to `4`); the base layer alone cannot be stopped without the layers above it. The nodes keep their last pose, which a following `animationPlay` with a `fade` eases out of. After stopping everything, `animationResume` and `animationSetTime` raise an error until something is played again. Layer weights and masks are kept. A layer outside `1` to `4` or a node that is not an instance root raises an error. *Since:* 3.00. *See also:* <>, <>, <> .Example [source,lua] ---- -- Hand the body to the physics ragdoll: freeze the pose, then let it fall. function onKilled() animationStop(hero) ragdollActivate(hero) end -- The wave on layer 2 is done; the walk beneath it carries on. function onWaveFinished() animationStop(hero, 2) end ---- [#body] === Body A body gives a scene node a place in the physics world. There is one body per node, addressed by the node handle: a static body stays where the node was when the body was made, a kinematic body follows its node each step and pushes without being pushed, and a dynamic body is moved by the simulation and drives its node. Sizes, positions and velocities are in world units (meters in a 3D scene, overlay pixels in a 2D world), masses in kilograms, and time in seconds. Every call here raises an error when the node has no body; a call that only makes sense for one kind of body (a force on a static body, say) is ignored on the others, except `bodySetMass`, which raises. See <> in Game Development for how bodies, nodes and the frame fit together. [#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) ---- Gives the node a body of the given type, placed where the node is in the world right now (its parents included), with a collision shape sized by `a`, `b` and `c` in world units and scaled by the node's world scale. A second call on the same node replaces the first, and a node that had a player loses it. A dynamic body's mass comes from its shape's volume at the density of water, so set `bodySetMass` on anything meant to feel light. Changing the node's scale later does not resize the body. Raises an error for an unknown type or shape, when physics is not available on this machine, when the world already holds `4096` bodies, or when the shape cannot be built (a dynamic `SHAPE_MESH`, or a hull with no mesh under the node). *Parameters:* * `type` -- `BODY_STATIC`, `BODY_DYNAMIC` or `BODY_KINEMATIC`. * `shape` -- `SHAPE_BOX` (`a`, `b`, `c` are width, height and depth), `SHAPE_SPHERE` (`a` is the radius), `SHAPE_CAPSULE` or `SHAPE_CYLINDER` (`a` is the radius, `b` the total height along Y, caps included), `SHAPE_HULL` (a convex hull of the node's mesh and everything under it, no sizes), or `SHAPE_MESH` (the exact triangles, static and kinematic bodies only; a heightmap mesh becomes a height field). * `a`, `b`, `c` -- the sizes the shape needs; missing ones are `0` and any size is raised to at least `0.001`. *Notes:* A sphere is scaled by the node's largest scale axis, a capsule or cylinder radius by the larger of X and Z. A capsule shorter than twice its radius is a sphere with a sliver in the middle. *Since:* 3.00. *See also:* <>, <>, <>, <> .Example [source,lua] ---- -- A light crate and a duck model as a hull, dropped onto the floor. 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) ---- Adds a force, in newtons, to a dynamic body for the steps of this frame only; call it every frame to keep pushing. Without a point the force acts through the center of mass and only moves the body; with a world point `(px, py, pz)` it also spins the body about its center. A force accelerates by force divided by mass, so size it to `bodySetMass`. Static and kinematic bodies ignore it. *Parameters:* * `fx`, `fy`, `fz` -- the force in newtons along each world axis. * `px`, `py`, `pz` -- optional world point the force acts at; omit for the center of mass. *Since:* 3.00. *See also:* <>, <> .Example [source,lua] ---- -- A fan blows the balloon across the room while the switch is on. 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) ---- Changes a dynamic body's momentum at once: an impulse of mass times speed adds that speed. Without a point it acts through the center of mass; with a world point `(px, py, pz)` it also spins the body, so hitting a crate near its top tips it over. This is the call for a hit, a kick, an explosion or a launch, where `bodyApplyForce` is for a push that lasts. Static and kinematic bodies ignore it. *Parameters:* * `ix`, `iy`, `iz` -- the impulse in kilogram meters per second along each world axis. * `px`, `py`, `pz` -- optional world point the impulse acts at; omit for the center of mass. *Since:* 3.00. *See also:* <>, <>, <> .Example [source,lua] ---- -- Flick whatever the mouse is over straight up, at the point it was hit. 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) ---- Removes the body from the world; the node and its mesh stay where they are. Every joint attached to the body, and any vehicle built on it, goes with it. Deleting the node removes its body too, so this is only needed to keep a node without its body. Raises an error when the node has no body. *Since:* 3.00. *See also:* <>, <>, <> .Example [source,lua] ---- -- A collected coin stops being solid and floats up as a plain node. 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) ---- How fast the body is spinning about each world axis, in radians per second, as of the last step. A static body reports zero. Useful for a wheel's sound pitch or a spin counter. *Returns:* Three numbers, radians per second about X, Y and Z. *Since:* 3.00. *See also:* <>, <> .Example [source,lua] ---- -- The roulette wheel has stopped when it barely turns. 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) ---- How fast the body is moving along each world axis, in world units per second, as of the last step. A static body reports zero; a kinematic body reports the velocity its node's movement implied. *Returns:* Three numbers, units per second along X, Y and Z. *Since:* 3.00. *See also:* <>, <> .Example [source,lua] ---- -- Play a whoosh when the ball is really moving. 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) ---- Whether the simulation has put the body to sleep because it stopped moving. Static bodies and bodies taken out of the world with `bodySetEnabled` count as resting. A resting body wakes on its own when something touches it or when a velocity, force or impulse is applied. *Returns:* Boolean. *Since:* 3.00. *See also:* <>, <> .Example [source,lua] ---- -- The round ends once every pin has settled. 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) ---- Sets the spin about each world axis, in radians per second, and wakes the body. Static bodies ignore it. A kinematic body's spin is overwritten from its node at the next step, so the call only lasts for dynamic bodies. *Since:* 3.00. *See also:* <>, <> .Example [source,lua] ---- -- Toss the coin: up, and spinning end over end. 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) ---- Sets the body's restitution, from `0` (stops dead) to `1` (bounces back with everything it arrived with); values outside that range are clamped. The default is `0.1`. The two touching bodies' values combine, so a lively ball on a dead floor still bounces. *Since:* 3.00. *See also:* <>, <> .Example [source,lua] ---- -- A rubber ball dropped onto the scene. 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) ---- How the body floats when it is inside a water volume: `1` is neutral, more floats higher, less sinks, and `0` is a stone. The default is `1.2`, which floats gently. The factor multiplies the water's own density from `bodySetWater`, and negative values are clamped to `0`. Only dynamic bodies are moved by water, but the value may be set on any body. *Since:* 3.00. *See also:* <>, <> .Example [source,lua] ---- -- A crate that rides high, an anchor that goes down, a ball that bobs. 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) ---- Sets the flow inside a water volume, in world units per second along each world axis; dynamic bodies floating in it drift with the flow and a swimming player is carried by it. The default is no flow. Set it on the node that `bodySetWater` was called on; on any other body it is stored and does nothing. *Since:* 3.00. *See also:* <>, <> .Example [source,lua] ---- -- A river that carries anything dropped in it downstream. 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) ---- Takes the body out of the world with `false`: it stops colliding, stops moving, no longer drives or follows its node, and counts as resting. `true` puts it back where it is, awake. The body keeps its shape, mass and settings, so this is cheaper than deleting and remaking it for something that comes and goes. *Since:* 3.00. *See also:* <>, <> .Example [source,lua] ---- -- The drawbridge is solid only while it is down. 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) ---- Sets the surface friction: `0` is ice, `0.5` (the default) is ordinary, higher values grip more. Negative values are clamped to `0`. The two touching bodies' values combine, so a slippery floor needs a low value on the floor, not on everything that walks on it. *Since:* 3.00. *See also:* <>, <> .Example [source,lua] ---- -- A frozen pond in the middle of the 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) ---- Rescales a dynamic body's mass and, with it, its inertia. Do this for anything meant to feel light or heavy: the default is the shape's volume at the density of water, so a door-sized box weighs about 140 kilograms and a nudge will not move it. Forces and impulses scale with mass, so set the mass before tuning them. Raises an error when the body is not dynamic or the mass is not positive. *Since:* 3.00. *See also:* <>, <>, <> .Example [source,lua] ---- -- A crate the hero can shove and an anvil nobody can. 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) ---- Makes the body a trigger with `true`: it pushes nothing and stops nothing, but `onTrigger` reports each body or player that enters and leaves it. `false` makes it solid again: whatever was inside it is forgotten without a leave, while a trigger the body itself stands in goes on reporting it. A trigger is usually a static box or sphere placed over a doorway, a finish line or a danger zone. Rays from `physicsRaycast` hit triggers like any other body. *Notes:* A player is reported inside a trigger when their bounding boxes overlap, so a round trigger is a square one for players. *Since:* 3.00. *See also:* <>, <>, <> .Example [source,lua] ---- -- A finish line across the track. 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) ---- Sets the body's velocity along each world axis, in world units per second, and wakes it. This replaces whatever speed the body had, where `bodyApplyImpulse` adds to it. Static bodies ignore it, and a kinematic body's velocity is overwritten from its node's movement at the next step. *Since:* 3.00. *See also:* <>, <>, <> .Example [source,lua] ---- -- Serve the ball toward whichever side lost the last point. 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) ---- Fills a static body with water and makes it a trigger: the top of its shape is the surface, and every dynamic body inside floats, sinks or rolls by buoyancy against that surface (the submerged part of the shape counts, so a tilted crate rights itself), slowed by the drags. A player whose middle is below the surface swims. Because the volume is a trigger, `onTrigger` fires on the way in and out. The water is not drawn: give the node a blended material, or none. Raises an error when the body is not static. *Parameters:* * `density` -- a multiplier, `1` for water; each body's own `bodySetBuoyancy` scales it. Negative values are clamped to `0`. * `linearDrag` -- how quickly the water slows movement through it; default `0.5`. * `angularDrag` -- how quickly it slows spinning; default `0.1`. *Notes:* Players and boats see at most eight water volumes; more than that still float bodies but are not swum in. *Since:* 3.00. *See also:* <>, <>, <>, <> .Example [source,lua] ---- -- A pool cut into the deck; its top, at Y = 0, is the surface. 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] === Camera The camera is a node like any other: place it with `nodeSetPosition`, aim it with `nodeLookAt`, parent it to a moving object for a chase view, and name it with `cameraSet`. It looks down its own -Z. Without a camera node the scene is viewed from `(0, 0, 5)` looking at the origin. The projection is set separately and applies to whichever node is the camera: perspective with a 60 degree vertical field of view from `0.1` to `1000` units by default, or orthographic with `cameraSetOrthographic`. Rendered views (`viewNew`) share the projection but look from their own nodes. See <>. [#cameraset] ==== cameraSet [source,text] ---- cameraSet(node) cameraSet(-1) ---- Makes a node the camera: the scene is drawn from its world position looking down its own -Z, so its parents' movement and rotation count. Any node will do, including one that also carries a mesh or a light, and switching cameras is one call. `-1` restores the default view from `(0, 0, 5)` looking at the origin. Deleting the camera node also falls back to the default view. Raises an error for a node handle that is not valid. *Since:* 3.00. *See also:* <>, <>, <>, <> .Example [source,lua] ---- -- A chase camera behind the player's car, and a fixed one for the replay. chaseCamera = nodeNew(car) nodeSetPosition(chaseCamera, 0, 2, 6) nodeLookAt(chaseCamera, nodeGetWorldPosition(car)) cameraSet(chaseCamera) function onReplayStart() replayCamera = nodeNew() nodeSetPosition(replayCamera, 30, 12, 30) nodeLookAt(replayCamera, 0, 0, 0) cameraSet(replayCamera) end ---- [#camerasetorthographic] ==== cameraSetOrthographic [source,text] ---- cameraSetOrthographic(height, near, far) ---- Switches to a parallel projection showing `height` world units top to bottom, the width following the layer's aspect ratio, from `near` to `far` in front of the camera. Distance does not shrink things, which suits diagrams, isometric and side-on views, and HUD-like scenes. The setting applies to the current and any later camera node until `cameraSetPerspective` is called. A directional light's shadow uses a single map under an orthographic camera, whatever `sceneSetShadowCascades` says. *Parameters:* * `height` -- the visible height, in world units. * `near` -- the closest distance drawn, in world units in front of the camera. * `far` -- the farthest distance drawn. *Since:* 3.00. *See also:* <>, <>, <> .Example [source,lua] ---- -- An isometric view of the board: 12 units tall, from high up and to the side. 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) ---- Switches to the usual perspective projection, the default. `fov` is the vertical field of view in degrees (default `60`); `near` and `far` (defaults `0.1` and `1000`) bound what is drawn, in world units in front of the camera. Depth precision is spread between the two, so keep `near` as large as the scene allows and `far` no larger than needed: `0.05` to `100` for a room, `0.1` to `400` for a landscape. The setting applies to the current and any later camera node, and to rendered views. *Parameters:* * `fov` -- vertical field of view in degrees; a wide `70` feels fast, a narrow `40` feels like a long lens. * `near` -- the closest distance drawn. * `far` -- the farthest distance drawn. *Since:* 3.00. *See also:* <>, <>, <> .Example [source,lua] ---- -- A tighter lens for the cockpit view, the default for the outside. function setView(inside) if inside then cameraSet(cockpitCamera) cameraSetPerspective(45, 0.05, 300) else cameraSet(chaseCamera) cameraSetPerspective(60, 0.1, 400) end end ---- [#color] === Color The foreground and background colors are engine globals rather than per-call arguments: set them with `colorForeground` and `colorBackground`, and every later drawing call reads them until they change. The foreground color is used by the `overlay*` shape primitives and by TrueType text from `fontPrint` and `fontToSprite`; the background color is used by `overlayClear` and by `FONT_QUALITY_SHADED` text. `overlayPrint` uses neither. Channels are integers from `0` to `255`, clamped into that range, and both colors accept an optional alpha. See the <> and <> sections for the calls that consume them. [#colorbackground] ==== colorBackground [source,text] ---- colorBackground(r, g, b) colorBackground(r, g, b, a) ---- Sets the background color: what `overlayClear` fills the overlay with, and what `FONT_QUALITY_SHADED` text from `fontPrint` and `fontToSprite` paints behind its glyphs. At startup it is transparent black, and alpha defaults to `0` when omitted, so `colorBackground(0, 0, 0)` followed by `overlayClear()` makes the overlay invisible again. A partial alpha over black dims the video; `255` hides it. Out-of-range channel values are clamped rather than rejected. *Parameters:* * `r`, `g`, `b` -- red, green, and blue, `0` to `255`. * `a` -- opacity, `0` (transparent) to `255` (opaque); default `0`. *Since:* 1.x *See also:* <>, <>, <> .Example [source,lua] ---- -- Solid black behind the calibration screen, transparent during play. 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) ---- Sets the foreground color used by `overlayBox`, `overlayCircle`, `overlayEllipse`, `overlayLine`, and `overlayPlot`, and the text color for `fontPrint` and `fontToSprite`. At startup it is opaque white, and alpha defaults to `255` when omitted, the opposite of `colorBackground`. Sprites and `overlayPrint` are drawn with their own pixels and are not tinted. Out-of-range channel values are clamped rather than rejected. *Parameters:* * `r`, `g`, `b` -- red, green, and blue, `0` to `255`. * `a` -- opacity, `0` (transparent) to `255` (opaque); default `255`. *Since:* 1.x *See also:* <>, <>, <>, <> .Example [source,lua] ---- -- Score in white, low-health warning in red, both from the same font. function onOverlayUpdate() overlayClear() fontSelect(hudFont) colorForeground(255, 255, 255) fontPrint(8, 8, "Score: " .. score) if lives == 1 then colorForeground(255, 32, 32) fontPrint(8, 32, "LAST LIFE") end return OVERLAY_UPDATED end ---- [#controller] === Controller Singe supports up to four game controllers, indices `0` through `3`, opened automatically at startup and whenever one is plugged in or removed; there is no `controllerLoad`. Analog axes are reported through `onControllerMoved` (see <>) and polled with `controllerGetAxis`, in SDL's raw range of `-32768` to `32767`. Digital buttons, and each direction of each axis, are switches like keys: they reach `onInputPressed` and `onInputReleased` when `controls.cfg` maps a code from the `GAMEPAD_0` through `GAMEPAD_3` tables (for example `INPUT_ACTION_1 = { GAMEPAD_0.BUTTON_A }`), and `controllerGetButton` polls a button's live state. `SINGE_DEAD_ZONE`, from the `DEAD_ZONE` entry in `controls.cfg`, is the threshold the engine itself uses before an axis direction counts as a pressed switch; treat axis motion below it as noise. [#controllergetaxis] ==== controllerGetAxis [source,text] ---- value = controllerGetAxis(controller, axis) ---- Returns the position of an analog axis as the engine last saw it. The value is updated from every axis event, including motion inside the dead zone, and is `0` for a controller that is not connected or an axis that has never moved. Sticks range from `-32768` to `32767`; triggers rest at `0` and reach `32767` when fully pressed. An index outside `0` to `3`, or an axis outside `0` to `5`, aborts the script. *Parameters:* * `controller` -- controller index, `0` through `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`) or `GAMEPAD_AXIS_RIGHT_TRIGGER` (`5`). *Returns:* integer, `-32768` to `32767`. *Since:* 2.00 *See also:* <>, <> .Example [source,lua] ---- -- Steer with the left stick, throttle with the right 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) ---- Returns whether a button is held right now, read directly from the controller rather than from the event queue. Use it for hold-to-charge and similar cases where `onInputPressed` and `onInputReleased` are awkward. A slot with no controller returns `false`. The button code must come from the `GAMEPAD_N` table of the same controller index; a code from another controller's table, or an axis code, falls outside the button range and aborts the script, as does an index outside `0` to `3`. *Parameters:* * `controller` -- controller index, `0` through `3`. * `button` -- a `BUTTON_*` or `DPAD_*` value from the matching table, such as `GAMEPAD_0.BUTTON_A.value`. *Returns:* boolean. *Since:* 2.10 *See also:* <>, <>, <> .Example [source,lua] ---- -- Charge while A is held, fire on release. 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 ---- [#debug] === Debug Two calls for seeing what a script is doing: `debugPrint` writes text, and `lineDraw` draws a line over the 3D scene for one frame (`physicsSetDebug` and `navDraw` draw through the same lines). Printed output goes to the console Singe was started from, so it is visible during development and harmless in a cabinet, where `--noconsole` silences it. When Singe runs with `--trace`, every API call including this one is also written with its arguments to `trace.txt` in the data directory, which is the way to get a permanent record. Command line options are listed in the chapter on running Singe; the ZeroBrane Studio integration in `zbstudio/` offers a debugger when printing is not enough. [#debugprint] ==== debugPrint [source,text] ---- debugPrint(message) ---- Prints a line to the console (standard output). Nothing is printed when Singe was started with `--noconsole`; with `--trace` the call and its message also land in `trace.txt`. The argument must be a string or a number; anything else aborts the script, so wrap values in `tostring` or use `string.format`. It is cheap enough to leave in shipping code but is not throttled, so a print inside `onOverlayUpdate` runs every frame. *Parameters:* * `message` -- the text to print; a number is converted. *Since:* 1.x *See also:* <> .Example [source,lua] ---- -- Log why the shot missed, with the frame it happened on. 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) ---- Draws a straight line between two world-space points over the scene for the current frame only, white unless a color is given. Call it every frame to keep a line on screen, as with the overlay. The line is depth tested against the scene but does not write depth, so it hides behind walls like anything else and never occludes. Nothing is drawn while the scene is disabled. It is meant for seeing what a script is doing (a ray cast, a path, a bounding box); `physicsSetDebug` and `navDraw` draw through the same lines. *Parameters:* * `x0, y0, z0` -- the start point, in world units. * `x1, y1, z1` -- the end point, in world units. * `r, g, b` -- the line color, `0` to `255` per channel, clamped; white when omitted. *Since:* 3.00. *See also:* <>, <>, <> .Example [source,lua] ---- -- Show where the turret is aiming and whether the shot would land. 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 The *disc* is the main laserdisc video, the one Singe was originally built to emulate. A game has at most one disc, opened at startup from the `VIDEO` named in `games.dat` or from `--disc` / `--framefile` on the command line; a game that names none runs without a disc (see <>). All `disc*` functions operate on this single implicit disc and take no handle. Frame numbers are zero based positions in the video file (or in the combined framefile) and are the time reference for every branch decision in a laserdisc game. Without a disc the query functions return `0`, `discGetState` reports `DISC_EJECTED`, and the transport functions trace and do nothing. Extra clips layered on top of the disc are the job of the <> family. [#discaudio] ==== discAudio [source,text] ---- discAudio(channel, enabled) ---- Turns one channel of the disc's soundtrack on or off. The channel is set to the configured laserdisc volume (`--volume_vldp`, default `100`) when `enabled` is `true` and to `0` when it is `false`; the other channel is left as it is. Many original titles carried narration or a data track on one channel only, and this is the call that silences it. A channel other than `1` or `2` terminates the script. Without a disc the call does nothing. *Parameters:* * `channel` -- `1` for the left channel, `2` for the right. * `enabled` -- boolean. `true` restores the channel to the configured laserdisc volume, `false` silences it. *Since:* 1.x *See also:* <>, <>, <> .Example [source,lua] ---- -- The right channel of this disc carries the arcade's data track. function startGame() discAudio(1, true) discAudio(2, false) discSkipToFrame(SCENE_FIRST_FRAME) end ---- [#discchangespeed] ==== discChangeSpeed [source,text] ---- discChangeSpeed(...) ---- Does nothing. Real laserdisc players could run at other than 1x; Singe's software player cannot, so the call accepts any arguments, logs a trace line and returns. It is kept so that pre-2.00 scripts load without edits. *Notes:* WARNING: *Unimplemented.* Retained for backward compatibility only. There is no replacement; playback speed is fixed at the video file's frame rate. *Since:* 1.x *See also:* <>, <> .Example [source,lua] ---- -- Old scripts sometimes bracketed a slow-motion replay with speed changes. -- The calls are harmless today, but the replay plays at normal speed. discChangeSpeed(0.5) discSkipToFrame(REPLAY_FIRST_FRAME) discChangeSpeed(1) ---- [#discgetaudiotrack] ==== discGetAudioTrack [source,text] ---- track = discGetAudioTrack() ---- Returns the zero based index of the audio track the disc is currently playing. At startup the engine selects the track named by `--audio` or the `AUDIO_TRACK` entry in `games.dat` (default `0`) when the disc has that many tracks. Returns `0` without a disc. *Returns:* integer track index. *Since:* 2.10 *See also:* <>, <>, <> .Example [source,lua] ---- -- Cycle to the next dub each time the player presses the language key. 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() ---- Returns how many audio streams the disc's video file contains. Multiple streams are typically alternate language dubs. A silent video reports `0`, and so does a game without a disc, so guard loops and modulo arithmetic against zero. *Returns:* integer count, `0` or more. *Since:* 2.10 *See also:* <>, <>, <> .Example [source,lua] ---- -- Build a language-select menu from the disc's audio tracks. languages = {} for track = 0, discGetAudioTracks() - 1 do local code = discGetLanguage(track) local name = discGetLanguageDescription(code) table.insert(languages, { track = track, label = name }) end ---- [#discgetframe] ==== discGetFrame [source,text] ---- frame = discGetFrame() ---- Returns the frame the disc is currently showing. With a framefile the number is the position in the combined sequence, so segment boundaries are invisible to the script. Returns `0` while the disc is stopped and without a disc. Poll it from `onOverlayUpdate` to react to the video reaching a decision point. *Returns:* integer frame number. *Since:* 1.x *See also:* <>, <>, <> .Example [source,lua] ---- -- Loop the attract sequence and show a prompt during the duck window. 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 ---- [#discgetheight] ==== discGetHeight [source,text] ---- height = discGetHeight() ---- Returns the height of the disc's video in pixels. Without a disc it returns the canvas height (`CANVAS_Y` in `games.dat` or `--canvas`, default `480`), so it is always the size of the background the overlay is stretched over. Pair it with `discGetWidth` to give the overlay the same resolution as the video. *Returns:* integer height in pixels. *Since:* 2.00 *See also:* <>, <>, <> .Example [source,lua] ---- -- Draw at native disc resolution so pixel checks and drawing share one grid. overlaySetResolution(discGetWidth(), discGetHeight()) crosshairLimitX = discGetWidth() - 1 crosshairLimitY = discGetHeight() - 1 ---- [#discgetlanguage] ==== discGetLanguage [source,text] ---- code = discGetLanguage(track) ---- Returns the language tag stored in the video file for one of the disc's audio tracks. The tag is a three letter ISO 639 code such as `"eng"` or `"jpn"`. Tracks without a three letter tag return `"unk"`, and so does every track when there is no disc. With a disc, a track outside `0` to `discGetAudioTracks() - 1` terminates the script. *Returns:* string language code. *Since:* 2.10 *See also:* <>, <>, <> .Example [source,lua] ---- -- Prefer the Japanese dub when the disc carries one. for track = 0, discGetAudioTracks() - 1 do if discGetLanguage(track) == "jpn" then discSetAudioTrack(track) break end end ---- [#discgetstate] ==== discGetState [source,text] ---- state = discGetState() ---- Returns the transport state of the disc as one of the `DISC_*` constants. A game without a disc reports `DISC_EJECTED`; after `discStop` the disc reports `DISC_STOPPED` until the next `discPlay`, `discSearch` or `discSkipToFrame`; otherwise the state is `DISC_PLAYING` while the video advances and `DISC_PAUSED` when it does not. There is no separate searching state; a disc that is seeking reports `DISC_PAUSED` or `DISC_PLAYING` according to what was requested. *Returns:* one of the following constants, defined by the engine and available to every script. [cols="1,1,1",options="header"] |=== | Constant | Value | Meaning | `DISC_STOPPED` | `2` | Stopped (`discStop` was called) | `DISC_PLAYING` | `3` | Playing | `DISC_PAUSED` | `4` | Paused | `DISC_EJECTED` | `5` | The game has no disc (see <>) |=== *Since:* 1.x (RDG) *See also:* <>, <>, <> .Example [source,lua] ---- -- The pause key must not resume a freeze the story script asked for. 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() ---- Returns the width of the disc's video in pixels. Without a disc it returns the canvas width (`CANVAS_X` in `games.dat` or `--canvas`, default `720`). Together with `discGetHeight` this is the size the overlay is scaled onto, which is why the shipped menu passes both to `overlaySetResolution`. *Returns:* integer width in pixels. *Since:* 2.00 *See also:* <>, <>, <> .Example [source,lua] ---- -- Center a title sprite on whatever the disc's width happens to be. overlaySetResolution(discGetWidth(), discGetHeight()) titleSprite = spriteLoad("graphics/title.png") titleX = (discGetWidth() - spriteGetWidth(titleSprite)) / 2 ---- [#discpause] ==== discPause [source,text] ---- discPause() ---- Pauses playback on the current frame, which stays on screen. The disc's audio pauses with it. The call is ignored while the disc is stopped and does nothing without a disc. Resume with `discPlay`. *Since:* 1.x *See also:* <>, <>, <> .Example [source,lua] ---- -- Hold the last frame of the scene while the player chooses a door. 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) ---- Seeks to `frame`, shows it and pauses. This is the same binding as `discSearch` under a second name; the two are interchangeable in every respect, including the wrapping and clamping rules described there. Prefer `discSearch` in new code and keep `discPauseAtFrame` only when porting old scripts. *Notes:* WARNING: *Legacy alias.* Use <> in new code. *Since:* 1.18 *See also:* <>, <> .Example [source,lua] ---- -- Ported 1.x code: park on the scene's first frame until the player is ready. function onInputPressed(what) if what == SWITCH_START1 then discPauseAtFrame(SCENE_FIRST_FRAME) waitingForPlayer = true end end ---- [#discplay] ==== discPlay [source,text] ---- discPlay() ---- Starts or resumes playback from the current frame and clears the stopped state set by `discStop`. Audio resumes with the video, and the playback clock restarts from the current frame so a long pause does not cause a jump. Does nothing without a disc, apart from clearing the stopped flag. *Since:* 1.x *See also:* <>, <>, <> .Example [source,lua] ---- -- Standard attract-mode start: cue the first frame, then roll. function attractBegin() discSearch(ATTRACT_FIRST_FRAME) overlayClear() discPlay() attractRunning = true end ---- [#discsearch] ==== discSearch [source,text] ---- discSearch(frame) ---- Seeks the disc to `frame`, shows that frame and pauses on it, clearing the stopped state if the disc was stopped. The disc holds the frame until `discPlay`, `discSkipToFrame` or another transport call. A negative frame clamps to the first frame and one past the end clamps to the last; with a framefile the segment containing the frame is selected and the position is clamped inside it. Does nothing without a disc. *Parameters:* * `frame` -- target frame number, zero based. *Since:* 1.x *See also:* <>, <>, <> .Example [source,lua] ---- -- Player picked the left corridor: cue the scene and wait for the go. function goLeft() discSearch(CORRIDOR_LEFT_FIRST_FRAME) soundPlay(SOUND_DOOR) waitingForPlayer = true end ---- [#discsearchblanking] ==== discSearchBlanking [source,text] ---- discSearchBlanking(...) ---- Does nothing. On laserdisc hardware this chose whether the screen blanked while the player sought a frame; Singe's software player seeks without blanking, so the call accepts any arguments, logs a trace line and returns. *Notes:* WARNING: *Unimplemented.* Retained for backward compatibility only. There is no replacement. *Since:* 1.x *See also:* <>, <> .Example [source,lua] ---- -- Pre-2.00 scripts often set the blanking modes once at startup. Harmless. discSearchBlanking(false) discSkipBlanking(false) discSearch(TITLE_FRAME) ---- [#discsetaudiotrack] ==== discSetAudioTrack [source,text] ---- discSetAudioTrack(track) ---- Switches the disc to another audio track. Queued audio from the old track is dropped and playback realigns, so the change is heard almost immediately. With a disc, a track outside `0` to `discGetAudioTracks() - 1` terminates the script; without a disc the call does nothing. Use it to implement a language menu or to honor a preference saved between sessions. *Parameters:* * `track` -- zero based audio track index. *Since:* 2.10 *See also:* <>, <>, <> .Example [source,lua] ---- -- Apply the language the player chose in the options screen. function applyLanguage(choice) if choice >= 0 and choice < discGetAudioTracks() then discSetAudioTrack(choice) settings.language = choice saveSettings() end end ---- [#discsetfps] ==== discSetFPS [source,text] ---- discSetFPS(...) ---- Does nothing. The frame rate comes from the video file, so the call accepts any arguments, logs a trace line and returns. Nothing in the shipped `Framework.singe` calls it; only pre-2.00 scripts do. *Notes:* WARNING: *Unimplemented.* Retained for backward compatibility only. The frame rate is read from the video file and cannot be set from a script. *Since:* 1.x *See also:* <>, <> .Example [source,lua] ---- -- A ported 1.x script. The call is ignored; frames per second is a property of the file. discSetFPS(29.97) discSearch(TITLE_FRAME) discPlay() ---- [#discskipbackward] ==== discSkipBackward [source,text] ---- discSkipBackward(frames) ---- Seeks backward by `frames` from the current frame without changing the play or pause state: a playing disc keeps playing from the new position, a paused disc stays paused on it. The call is ignored while the disc is stopped and without a disc. The delta is not validated; a negative value skips forward, and a delta larger than the current frame clamps at frame `0`. With a framefile the delta applies to the frame number `discGetFrame` reports, so a skip may cross into an earlier segment. *Parameters:* * `frames` -- number of frames to subtract from the current frame. *Since:* 1.x *See also:* <>, <>, <> .Example [source,lua] ---- -- Replay the last second (30 frames) when the player misses a prompt. function missedPrompt() lives = lives - 1 discSkipBackward(30) promptShown = false end ---- [#discskipblanking] ==== discSkipBlanking [source,text] ---- discSkipBlanking(...) ---- Does nothing. It once chose whether the screen blanked during a skip on laserdisc hardware; Singe's software player never blanks, so the call accepts any arguments, logs a trace line and returns. *Notes:* WARNING: *Unimplemented.* Retained for backward compatibility only. There is no replacement. *Since:* 1.x *See also:* <>, <> .Example [source,lua] ---- -- Pre-2.00 scripts often set the blanking modes once at startup. Harmless. discSearchBlanking(false) discSkipBlanking(false) discSearch(TITLE_FRAME) ---- [#discskipforward] ==== discSkipForward [source,text] ---- discSkipForward(frames) ---- Seeks forward by `frames` from the current frame without changing the play or pause state. Mirror of `discSkipBackward`: ignored while stopped and without a disc, the delta is not validated, and a target past the last frame clamps at the last frame. With a framefile the delta applies to the frame number `discGetFrame` reports, so a skip may cross into a later segment. *Parameters:* * `frames` -- number of frames to add to the current frame. *Since:* 1.x *See also:* <>, <>, <> .Example [source,lua] ---- -- Let a returning player skip the two-second scene intro. function onInputPressed(what) if what == SWITCH_BUTTON1 and inSceneIntro then discSkipForward(60) inSceneIntro = false end end ---- [#discskiptoframe] ==== discSkipToFrame [source,text] ---- discSkipToFrame(frame) ---- Seeks to `frame` and starts playing from it no matter the disc's state, clearing the stopped state if necessary. Contrast with `discSearch`, which seeks and pauses. A frame before the start or past the end clamps to the first or last frame; with a framefile the segment containing the frame is selected and the position is clamped inside it. Does nothing without a disc, apart from clearing the stopped flag. *Parameters:* * `frame` -- target frame number, zero based. *Since:* 1.x *See also:* <>, <>, <> .Example [source,lua] ---- -- Skip the intro on a replay. function startGame() if hasPlayedBefore then discSkipToFrame(GAMEPLAY_FIRST_FRAME) else discSkipToFrame(INTRO_FIRST_FRAME) end end ---- [#discstepbackward] ==== discStepBackward [source,text] ---- discStepBackward() ---- Moves the disc back exactly one frame and pauses on it, whatever the previous play state. Stepping at frame `0` stays on frame `0`. Ignored while the disc is stopped, like `discSkipBackward`. Intended for frame-accurate debugging and service screens. Does nothing without a disc. *Since:* 1.x *See also:* <>, <>, <> .Example [source,lua] ---- -- Service screen: left and right nudge the disc one frame at a time. function onInputPressed(what) if what == SWITCH_LEFT then discStepBackward() elseif what == SWITCH_RIGHT then discStepForward() end end ---- [#discstepforward] ==== discStepForward [source,text] ---- discStepForward() ---- Moves the disc forward exactly one frame and pauses on it, whatever the previous play state. Stepping on the last frame stays on the last frame. Like `discStepBackward` it is ignored while the disc is stopped. Does nothing without a disc. *Since:* 1.x *See also:* <>, <>, <> .Example [source,lua] ---- -- Frame finder: step through a scene and print the frame number. function onOverlayUpdate() overlayClear() overlayPrint(8, 8, "Frame " .. discGetFrame()) if stepping then discStepForward() end return OVERLAY_UPDATED end ---- [#discstop] ==== discStop [source,text] ---- discStop() ---- Stops the disc. Playback pauses, the video is replaced by the classic blue screen, `discGetState` reports `DISC_STOPPED` and `discGetFrame` returns `0` until the disc is started again. While stopped, `discPause`, `discSkipForward`, `discSkipBackward`, `discStepForward` and `discStepBackward` are ignored. Any of `discPlay`, `discSearch` or `discSkipToFrame` clears the stopped state. A second `discStop` is ignored. *Since:* 1.x *See also:* <>, <>, <> .Example [source,lua] ---- -- Game over: drop to the blue screen behind the score table. function gameOver() discStop() soundPlay(SOUND_GAME_OVER) showScores = true end ---- [#emitter] === Emitter Emitters spawn, move and draw particles; see <>. Handles are integers. A 2D emitter lives in overlay coordinates, Y down, and draws when `emitterDraw` asks from `onOverlayUpdate`; a 3D emitter sits on a node, lives in world units, and draws itself in the scene. The recipe calls can be made in any order, before or after the emitter starts, and take effect on the next particles born (a few, noted below, act on the live ones too). Everything given as a minimum and maximum is drawn at random between them for every particle. Particles advance with the wall clock, at most a tenth of a second per frame, and hold while the game is paused. [#emitternew] ==== emitterNew [source,text] ---- emitter = emitterNew() emitter = emitterNew(node) ---- Makes an emitter. With no argument it is a 2D one at overlay position `(0, 0)`, moved with `emitterSetPosition`; with a node it is a 3D one whose particles are born at that node's world position, and it needs the 3D scene, so on a machine without one the call raises an error (2D emitters work everywhere). A new emitter is not streaming; call `emitterStart` or `emitterBurst`. Deleting the node deletes its emitters. Defaults: 50 particles a second, living one to two seconds, 30 degree spread, white fading to transparent, normal blending, no gravity, drag, spin, radius, texture, trail or collision, a pool of 1000; a 2D emitter starts them up the screen at 50 to 100 overlay units a second shrinking from 16 to 4, a 3D one up the Y axis at 1 to 2 world units a second shrinking from 0.2 to 0.05. *Returns:* The emitter handle. *Since:* 3.00. *See also:* <>, <>, <>, <> .Example [source,lua] ---- -- Exhaust from the ship's engine node; the recipe is set before the stream starts. 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) ---- Removes the emitter, its live particles and its copy of the texture at once; nothing fades out. Use `emitterStop` instead to let the last particles finish. A 3D emitter is also removed on its own when its node is deleted. A handle that is not a live emitter raises an error, so clear the variable afterward. *Since:* 3.00. *See also:* <>, <>, <> .Example [source,lua] ---- -- The explosion is over: free its emitter once the last spark has died. 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) ---- The picture each particle wears: a loaded sprite, or none (one argument, or `nil`) for the built-in soft disc. The emitter keeps its own copy of the sprite's pixels, every frame of an animated sprite included, so the sprite may be unloaded afterward. Setting the texture resets the frame range to frame `0` and moves every live particle to frame `0`, so call `emitterSetFrames` after it, not before. Blending and tint apply to the picture as they do to the disc. *Since:* 3.00. *See also:* <>, <>, <> .Example [source,lua] ---- -- Snow: a sheet of six flake shapes, one picked at random per particle. flakes = spriteLoadFrames(DIR .. "Sprites/Flakes", 6) 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) ---- With an animated sprite as the texture, each new particle takes a random frame from `first` to `last` and keeps it for life; frames count from `0`. Values are clamped to the frames the texture has, and `last` is raised to `first` when it is smaller. Default: frame `0` only, which is also what `emitterSetTexture` resets to. With the built-in disc or a single-frame sprite the range has no effect. *Since:* 3.00. *See also:* <>, <> .Example [source,lua] ---- -- The puff sheet has four soft puffs, then four harder debris chunks. emitterSetTexture(smoke, puffSheet) emitterSetFrames(smoke, 0, 3) emitterSetTexture(debris, puffSheet) emitterSetFrames(debris, 4, 7) ---- [#emittersetblend] ==== emitterSetBlend [source,text] ---- emitterSetBlend(emitter, mode) ---- How particles combine with what is behind them. `PARTICLE_ALPHA` (default) blends normally, for smoke, dust, rain and debris; `PARTICLE_ADD` adds light, for fire, sparks, glows and magic, and never darkens. Takes effect on the live particles at once. Anything other than those two constants raises an error. *Since:* 3.00. *See also:* <>, <> .Example [source,lua] ---- -- Sparks glow; the smoke above them does not. 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) ---- 2D only: where `emitterDraw` puts the particles. `PARTICLE_OVER` (default) draws above everything on the overlay; `PARTICLE_UNDER` draws beneath the overlay and above the video and the 3D scene, for smoke behind the score. On a 3D emitter the setting is accepted and ignored. Anything other than those two constants raises an error. *Since:* 3.00. *See also:* <>, <> .Example [source,lua] ---- -- Rain falls behind the HUD, the hit sparks in front of it. 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) ---- How many particles a second a started emitter makes; default `50`, negative values become `0`. Fractions accumulate, so `0.5` makes one every two seconds. Change it while streaming to throttle a flame or a thruster. A full pool (`emitterSetMax`) makes no more until some die, whatever the rate. *Since:* 3.00. *See also:* <>, <>, <> .Example [source,lua] ---- -- The thruster's plume follows the throttle. function onControllerMoved(controller, axis, value) if axis == AXIS_TRIGGER_RIGHT then throttle = value emitterSetRate(plume, 30 + 300 * throttle) end end ---- [#emittersetlife] ==== emitterSetLife [source,text] ---- emitterSetLife(emitter, minSeconds, maxSeconds) ---- How long each new particle lives, drawn at random between the two; default `1` to `2`. Its size and color run from their start to their end over that time, so life sets the pace of every fade. The minimum is held at `0.001` or above and the maximum is raised to the minimum when it is smaller. Live particles keep the life they were born with. *Since:* 3.00. *See also:* <>, <>, <> .Example [source,lua] ---- -- Muzzle flash: a burst that is gone in a tenth of a second. 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) ---- 3D only: fades a particle out over `distance` world units where it meets geometry behind it, so a smoke puff does not draw a hard line where it crosses a wall and a fountain sinks into its pool instead of cutting it. `0` (the default) switches it off; negative values become `0`. While any emitter uses it the scene's depth is rendered once more per frame, so a Raspberry Pi should use it sparingly. A 2D emitter stores the value and ignores it. *Since:* 3.00. *See also:* <>, <> .Example [source,lua] ---- -- Smoke from the brazier softens against the post it rises past. 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) ---- Starting speed of each new particle, drawn at random between the two, in overlay units a second (2D) or world units a second (3D); defaults `50` to `100` in 2D and `1` to `2` in 3D. The maximum is raised to the minimum when it is smaller. Speed sets how far a particle gets before drag, gravity and its life take over. *Since:* 3.00. *See also:* <>, <>, <> .Example [source,lua] ---- -- A fountain throws drops hard and straight; dust barely moves. 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) ---- The mean direction new particles set off in; only the direction matters, not the length. 2D default `(0, -1)`, up the screen since overlay Y grows downward; 3D default `(0, 1, 0)`, up the world Y axis. A 2D emitter ignores `z`; a 3D emitter given two values gets `z = 0`. A zero vector is treated as `(0, -1, 0)`. The direction is in world axes for a 3D emitter, not the node's, so turn it yourself when the node turns. *Since:* 3.00. *See also:* <>, <> .Example [source,lua] ---- -- Exhaust streams out behind the ship whichever way it faces. 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) ---- Half the angle of the cone (3D) or fan (2D) around the direction that new particles are thrown into, `0` to `180`; `0` is a straight line, `180` is every direction. Default `30`; values outside the range are clamped. In 3D the directions are spread evenly over the cone's cap, so a wide cone does not bunch toward its axis. *Since:* 3.00. *See also:* <>, <> .Example [source,lua] ---- -- Fireworks go everywhere; the torch flame stays in a tight column. emitterSetSpread(fireworks, 180) emitterSetSpread(flame, 15) ---- [#emittersetgravity] ==== emitterSetGravity [source,text] ---- emitterSetGravity(emitter, x, y) emitterSetGravity(emitter, x, y, z) ---- A constant acceleration on every particle, in overlay units (2D) or world units (3D) a second squared: `(0, 500)` pulls 2D sparks down the screen, `(0, -20)` lets 2D smoke rise, `(0, -9.8, 0)` is Earth in the scene. Default none. It applies to the live particles from the next frame. Particles are not physics bodies; give them `emitterSetCollide` to bounce off a floor or the scene. *Since:* 3.00. *See also:* <>, <>, <> .Example [source,lua] ---- -- Sparks fall, smoke rises, both from the same grinder. emitterSetGravity(sparks, 0, 500) emitterSetGravity(smoke, 0, -20) -- Underwater, the bubbles head for the surface. emitterSetGravity(bubbles, 0, 3, 0) ---- [#emittersetdrag] ==== emitterSetDrag [source,text] ---- emitterSetDrag(emitter, perSecond) ---- How quickly particles lose their velocity: every frame the velocity is multiplied by `1 - perSecond * dt`, never below `0`, so `1` takes most of the speed away within a second and `0` (the default) keeps it forever. Negative values become `0`. Smoke, embers and fireworks slow down; sparks and rain do not. Applies to the live particles from the next frame. *Since:* 3.00. *See also:* <>, <> .Example [source,lua] ---- -- A firework shell: fast out, then hanging and falling. 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) ---- Size at birth and at death, in overlay units (2D) or world units (3D), changing evenly over the particle's life; defaults `16` to `4` in 2D and `0.2` to `0.05` in 3D. `variation` (`0` to `1`, default `0`) scales both sizes of each new particle by one random factor between `1 - variation` and `1 + variation`, so a puff is a little bigger or smaller for its whole life. Negative sizes become `0` and `variation` is clamped. A textured particle is drawn as a square of that size; a wider picture is squashed. *Since:* 3.00. *See also:* <>, <>, <> .Example [source,lua] ---- -- Smoke grows as it thins; sparks shrink to nothing. 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) ---- What particles bounce off. `COLLIDE_NONE` (default) is nothing; `COLLIDE_FLOOR` is a horizontal plane at height `floor`, below which (larger Y in 2D, smaller Y in 3D) a particle is put back on the plane and reflected; `COLLIDE_SCENE` is the physics world, for 3D emitters only, by a ray cast per particle along its motion each frame, capped at `200` casts per emitter per frame and resumed where they left off, so a big emitter spreads the cost over a few frames. On a 2D emitter `COLLIDE_SCENE` is accepted and does nothing. A mode other than those three raises an error. *Parameters:* * `mode` -- `COLLIDE_NONE`, `COLLIDE_FLOOR` or `COLLIDE_SCENE`. * `bounce` -- how much speed survives a hit, `0` to `1`, default `0.5`; clamped. * `friction` -- how much sliding speed is lost per hit, `0` to `1`, default `0.2`; clamped. * `floor` -- height of the floor plane, in world units for a 3D emitter or overlay units for a 2D one, default `0`. Used by `COLLIDE_FLOOR` only. *Since:* 3.00. *See also:* <>, <>, <> .Example [source,lua] ---- -- Sparks bounce on the workshop floor (overlay row 440) and leave streaks. sparks = emitterNew() emitterSetBlend(sparks, PARTICLE_ADD) emitterSetGravity(sparks, 0, 500) emitterSetCollide(sparks, COLLIDE_FLOOR, 0.4, 0.2, 440) emitterSetTrail(sparks, 6, 3) -- Embers in the scene bounce off whatever the physics world holds. emitterSetCollide(embers, COLLIDE_SCENE, 0.3, 0.5) ---- [#emittersetcolor] ==== emitterSetColor [source,text] ---- emitterSetColor(emitter, r, g, b, a, r2, g2, b2, a2) ---- Color and opacity at birth (`r, g, b, a`) and at death (`r2, g2, b2, a2`), each `0` to `255`, changing evenly over the particle's life; values outside the range are clamped. Default white and opaque to white and transparent. The tint multiplies the texture or the built-in disc, so a white picture takes the color exactly. A fade in and out needs two emitters or a texture with the shape in it; one recipe runs one straight ramp. *Since:* 3.00. *See also:* <>, <>, <> .Example [source,lua] ---- -- Flame: bright yellow at the base, dying to dim red and gone. emitterSetColor(flame, 255, 200, 90, 255, 255, 40, 0, 0) -- Dust motes fade in from nothing and hang; their life ends them. emitterSetColor(dust, 255, 240, 200, 0, 255, 240, 200, 160) ---- [#emittersetspin] ==== emitterSetSpin [source,text] ---- emitterSetSpin(emitter, min, max) ---- Rotation speed of each new particle in degrees a second, drawn at random between the two; negative values spin the other way, and a range that straddles `0` gives both. Every particle starts at a random angle whether or not it spins. Default none. The maximum is raised to the minimum when it is smaller. Spin shows on a textured particle or a trail; the built-in disc is round and looks the same at any angle. *Since:* 3.00. *See also:* <>, <> .Example [source,lua] ---- -- Leaves tumble; embers hardly turn. emitterSetTexture(leaves, leafSprite) emitterSetSpin(leaves, -180, 180) emitterSetSpin(embers, -20, 20) ---- [#emittersetradius] ==== emitterSetRadius [source,text] ---- emitterSetRadius(emitter, radius) ---- New particles are born anywhere within this distance of the emitter's origin, evenly over a disc (2D) or a ball (3D), in overlay or world units. Default `0`, a point; negative values become `0`. A radius fills a volume, a torch head or a patch of dust in the air, where a point would give a needle. *Since:* 3.00. *See also:* <>, <> .Example [source,lua] ---- -- Dust in a room-sized ball of air, most of it already there when the level starts. 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) ---- 3D only: shades each particle by the scene's lights and sky as if it were a small sphere facing the camera, so smoke takes a brazier's color and dust glints in the sun. Off by default; shadows are not consulted. With `PARTICLE_ADD` the lighting is still applied to the tint before it is added. A 2D emitter stores the value and ignores it. *Since:* 3.00. *See also:* <>, <> .Example [source,lua] ---- -- Smoke over each brazier picks up its firelight. 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) ---- Whether live particles follow the emitter. `false` (default) leaves each one where it was born, so a torch carried through a room leaves smoke behind it; `true` keeps the whole cloud relative to the emitter's origin, so a thruster plume moves with the ship. Switching it moves the live particles between frames of reference without a jump. On a 3D emitter the origin is the node's world position; on a 2D one it is the `emitterSetPosition` point, so a local 2D cloud slides with that too. Trails follow the same rule. *Since:* 3.00. *See also:* <>, <> .Example [source,lua] ---- -- A magic aura that stays wrapped around the wizard as she walks. 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) ---- The pool: how many particles may be alive at once, default `1000`. A full pool makes no more, from the stream or a burst, until some die. Changing the count rebuilds the pool and kills every live particle, so set it before starting. Size the pool to rate times longest life, plus the biggest burst. A count below `1` raises an error. *Since:* 3.00. *See also:* <>, <>, <> .Example [source,lua] ---- -- 300 drops a second living up to 1.6 seconds needs about 480; leave room. 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) ---- 2D only: where new particles are born, in overlay coordinates; default `(0, 0)`. Set it every frame to follow a sprite or a 2D physics body. Live particles stay where they are unless `emitterSetLocal` is on. A 3D emitter follows its node and raises an error here; move the node instead. *Since:* 3.00. *See also:* <>, <>, <> .Example [source,lua] ---- -- Sparks fly from wherever the grinder sprite is this frame. 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) ---- A ribbon behind every particle through its last `length` positions, one recorded per frame (`2` to `16`; `0` or `1` for none, the default), `width` across in world or overlay units, in 2D and 3D alike. The ribbon carries the particle's current tint, fades to transparent toward its tail, and is textured by the middle column of the particle's picture so the disc's soft edge becomes the ribbon's. Values are clamped to the range. Changing the length rebuilds the trail storage, so live particles start their ribbons again. Sparks, embers, tracer rounds, comets. *Since:* 3.00. *See also:* <>, <>, <> .Example [source,lua] ---- -- Tracer rounds: tiny, fast, and all streak. 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) ---- Begins the steady stream at the rate set; from then on the emitter makes particles every frame until `emitterStop`. Starting an emitter that is already streaming changes nothing. The stream needs no drawing call to run; a 2D emitter that is streaming but not drawn still spends its pool. *Since:* 3.00. *See also:* <>, <>, <> .Example [source,lua] ---- -- The engine lights when the player throttles up and dies when they cut it. function onThrottle(open) if open then emitterStart(exhaust) else emitterStop(exhaust) end end ---- [#emitterstop] ==== emitterStop [source,text] ---- emitterStop(emitter) ---- Ends the stream; the live particles finish their lives and any fraction of a particle owed is dropped. `emitterBurst` still works on a stopped emitter, and `emitterIsActive` stays `true` until the last particle dies. Use `emitterClear` to remove the particles at once as well. *Since:* 3.00. *See also:* <>, <>, <> .Example [source,lua] ---- -- The torch goes out: stop the flame, let the last smoke drift away. function onTorchDoused() emitterStop(flame) emitterStop(smoke) soundPlay(hiss) end ---- [#emitterburst] ==== emitterBurst [source,text] ---- emitterBurst(emitter, count) ---- Makes `count` particles at once from the current recipe, stream or no stream: an explosion, a splash, a puff. Only as many as the pool has room for are made; `emitterSetMax` sets the room. A 2D emitter bursts at its `emitterSetPosition` point, a 3D one at its node, spread over `emitterSetRadius`. *Since:* 3.00. *See also:* <>, <>, <> .Example [source,lua] ---- -- Fireworks: a shell bursts somewhere over the disc every second or so. 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) ---- Kills every live particle now and forgets any fraction of a particle owed. The stream is not stopped: a started emitter goes on making new particles next frame, so call `emitterStop` as well to empty it for good. Use it when a scene changes and the old sparks must not linger. *Since:* 3.00. *See also:* <>, <> .Example [source,lua] ---- -- Restarting the level: the emitters stay, the old particles go. function resetLevel() for _, emitter in ipairs(levelEmitters) do emitterStop(emitter) emitterClear(emitter) end placePlayer() end ---- [#emitterdraw] ==== emitterDraw [source,text] ---- emitterDraw(emitter) ---- 2D only, from `onOverlayUpdate`: queues the emitter's particles to be drawn this frame, above or beneath the overlay per `emitterSetLayer`, in overlay coordinates mapped onto the window. Emitters on the same layer draw in the order they were queued. The queue empties every frame, so the call belongs in every frame the particles should show; not drawing an emitter does not stop it. Up to `64` emitters per layer draw in a frame. A 3D emitter draws itself in the scene and raises an error here. *Since:* 3.00. *See also:* <>, <>, <> .Example [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) ---- How many of the emitter's particles are alive this frame, `0` to the pool size. Useful for tuning a pool, for an on-screen budget, or for knowing when a burst has died away. *Returns:* The live count, an integer. *Since:* 3.00. *See also:* <>, <> .Example [source,lua] ---- -- Development overlay: how much of each pool is in use. 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) ---- Whether the emitter is streaming or still has live particles: `true` from `emitterStart` or `emitterBurst` until the stream is stopped and the last particle has died, `false` once the explosion is over. A streaming emitter whose pool happens to be empty is still active. *Returns:* Boolean. *Since:* 3.00. *See also:* <>, <>, <> .Example [source,lua] ---- -- One-shot explosions live in a list and are freed when they burn out. function onOverlayUpdate() for i = #explosions, 1, -1 do if not emitterIsActive(explosions[i]) then emitterDelete(explosions[i]) table.remove(explosions, i) end end return OVERLAY_NOT_UPDATED end ---- [#font] === Font Singe has two text renderers. The built-in console font is drawn by <> and needs no assets; the `font*` family loads TrueType files at a fixed point size and renders them either straight onto the overlay with `fontPrint` or into a reusable sprite with `fontToSprite`. Font handles are integers from `fontLoad`, and the engine keeps one globally selected font, which `fontLoad` sets to the font it just loaded and `fontSelect` changes. Text is rendered in the current foreground color at the quality chosen by `fontQuality`, and text positions are overlay coordinates. Release fonts with `fontUnload`; `Singe/FreeSansBold.ttf` ships with the engine and is always available. [#fontload] ==== fontLoad [source,text] ---- result = fontLoad(filename, pointSize) ---- Opens a TrueType font at one point size and makes it the selected font. The size is fixed for the life of the handle, so load the same file once per size you need. The name goes through the virtual file system like any other asset; prepend `DIR` for files shipped with your game, or use `Singe/FreeSansBold.ttf` for the bundled face. A file that cannot be opened ends the script with `Unable to open` and the name. *Parameters:* * `filename` -- path to a `.ttf` file. * `pointSize` -- integer point size. *Returns:* integer font handle. *Since:* 1.x *See also:* <>, <>, <> .Example [source,lua] ---- -- Two sizes of the bundled face: large for the title, small for the HUD. fontQuality(FONT_QUALITY_BLENDED) titleFont = fontLoad("Singe/FreeSansBold.ttf", 32) hudFont = fontLoad("Singe/FreeSansBold.ttf", 18) fontSelect(titleFont) titleSprite = fontToSprite("SPACE ACE") fontSelect(hudFont) ---- [#fontprint] ==== fontPrint [source,text] ---- fontPrint(x, y, text) ---- Renders a string with the selected font, the current quality, and the current foreground color (plus the background color for `FONT_QUALITY_SHADED`), then blits it onto the overlay with its top-left corner at `(x, y)`. Call it from `onOverlayUpdate`. A new text image is rendered and discarded on every call, which is fine for a few lines but expensive for text that never changes; `fontToSprite` renders once and draws many times. With no font selected the script ends with an error. *Parameters:* * `x`, `y` -- top-left corner in overlay coordinates; fractions are truncated. * `text` -- the string to render. *Since:* 1.x *See also:* <>, <>, <>, <> .Example [source,lua] ---- -- Live audio calibration readout; the text changes every frame, so render it directly. 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) ---- Selects how `fontPrint` and `fontToSprite` rasterize text from now on. The setting is global, not per font, and starts at `FONT_QUALITY_SOLID`. Sprites already made by `fontToSprite` keep the quality they were rendered with. A value outside the three constants ends the script with an error. *Parameters:* * `mode` -- `FONT_QUALITY_SOLID` (`1`): fastest, one-bit edges, no anti-aliasing. `FONT_QUALITY_SHADED` (`2`): anti-aliased glyphs on a solid rectangle of the background color. `FONT_QUALITY_BLENDED` (`3`): anti-aliased glyphs with per-pixel alpha that blend over anything; the usual choice. *Since:* 1.x *See also:* <>, <>, <> .Example [source,lua] ---- -- Blended text over the video, but a shaded label box for the debug readout. fontQuality(FONT_QUALITY_BLENDED) titleSprite = fontToSprite("DRAGON'S LAIR") colorBackground(0, 0, 0, 255) fontQuality(FONT_QUALITY_SHADED) debugLabel = fontToSprite("FRAME") fontQuality(FONT_QUALITY_BLENDED) ---- [#fontselect] ==== fontSelect [source,text] ---- fontSelect(id) ---- Makes a loaded font the one used by `fontPrint` and `fontToSprite`. The selection persists until the next `fontSelect` or `fontLoad`, since loading also selects. It can be changed as often as needed, including several times within one `onOverlayUpdate`. An unknown or unloaded handle ends the script with an error. *Since:* 1.x *See also:* <>, <>, <> .Example [source,lua] ---- -- Title in the large face, score in the small one, every frame. function onOverlayUpdate() overlayClear() fontSelect(titleFont) fontPrint(TITLE_X, 40, "DRAGON'S LAIR") fontSelect(hudFont) fontPrint(8, 8, "Score: " .. score) return OVERLAY_UPDATED end ---- [#fonttosprite] ==== fontToSprite [source,text] ---- result = fontToSprite(text) ---- Renders a string with the selected font, quality, and colors into a new sprite and returns its handle. The sprite is independent of the font from then on, so it survives `fontUnload`, and it works with every `sprite*` call: draw it, measure it, scale or rotate it, and free it with `spriteUnload`. Render once and draw many times for labels and headings; for a string that changes, render a new sprite and unload the old one. With no font selected the script ends with an error. *Returns:* integer sprite handle. *Since:* 1.x *See also:* <>, <>, <>, <> .Example [source,lua] ---- -- Pre-render the game description, wrapping lines to the panel width. descriptionLines = {} for _, line in ipairs(wrapText(GAME_LIST[selected].DESCRIPTION, TEXT_W)) do table.insert(descriptionLines, fontToSprite(line)) end function onOverlayUpdate() local y = TEXT_Y for _, line in ipairs(descriptionLines) do spriteDraw(line, TEXT_X, y) y = y + spriteGetHeight(line) + 1 end return OVERLAY_UPDATED end ---- [#fontunload] ==== fontUnload [source,text] ---- fontUnload(id) ---- Closes a font and invalidates its handle. Sprites made from it with `fontToSprite` are unaffected. If the unloaded font was the selected one, no font is selected afterward and the next `fontPrint` or `fontToSprite` ends the script with an error until `fontSelect` or `fontLoad` picks another. *Since:* 2.00 *See also:* <>, <>, <> .Example [source,lua] ---- -- Release the fonts and the text sprites they produced. function onShutdown() spriteUnload(titleSprite) for _, line in ipairs(descriptionLines) do spriteUnload(line) end fontUnload(titleFont) fontUnload(hudFont) end ---- [#guiref] === Gui A GUI is an RmlUi document tree of a fixed size rendered into its own texture every frame: `guiNew` makes one and returns an integer handle, `guiLoad` puts an RML document in it and returns a document handle, `guiDraw` composites it over the overlay and `materialSetGui` shows it on a material in the scene. Documents are HTML-like RML styled by CSS-like RCSS, loaded through the virtual file system like sprites; `Singe/gui.rcss` is the shipped theme and `Singe/FreeSansBold.ttf` the fallback face. The by-id helpers `guiGetValue`, `guiSetValue` and `guiSetHandler` reach elements without the object model; everything else is RmlUi's Lua API on the `rmlui` global, where a GUI is the context named `gui` followed by its handle. Up to sixteen GUIs exist at once, each with thirty-two documents. A GUI needs the GPU device, so `guiNew` raises an error on a machine without one; a bad GUI handle raises `No GUI N`, and a bad document handle `No document N in GUI G`. See <> in Game Development. [#guiclose] ==== guiClose [source,text] ---- guiClose(gui, document) ---- Unloads the document from the GUI, dropping its elements, its handlers and its scripts. The document handle is invalid afterwards. A document closed by its own script through RmlUi's API (`document:Close()`) is unloaded the same way. Raises `No document N in GUI G` for a handle that is not valid. *Since:* 3.00. *See also:* <>, <>, <> .Example [source,lua] ---- -- The high score entry page is only needed once; drop it after the name is in. 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) ---- Frees the GUI, every document in it and its texture, and stops the per-frame update and render it cost. Materials showing it through `materialSetGui` go back to their own texture. The handle is invalid afterwards and raises an error if used again; since a GUI keeps rendering whether or not it is drawn, delete the ones a game is finished with rather than hiding them. *Since:* 3.00. *See also:* <>, <>, <> .Example [source,lua] ---- -- The options page lives only while the player is in it. function closeOptions() guiDelete(optionsGui) optionsGui = nil discPlay() end ---- [#guidraw] ==== guiDraw [source,text] ---- guiDraw(gui) guiDraw(gui, x, y) guiDraw(gui, x, y, width, height) ---- Composites the GUI's texture over the overlay for this frame, in overlay coordinates, so it obeys the Sinden border and the overscan scale like everything else on the overlay: covering the whole overlay with one argument, at its own size with a corner at `x, y`, or scaled into the `width` by `height` rectangle. The call lasts one frame; make it from `onOverlayUpdate` (or `singeMain`) every frame the GUI should be visible. GUIs draw above the overlay in the order called (the last on top), under `PARTICLE_OVER` particles and the pause indicator, and the GUI drawn last under the pointer is the one that receives the mouse. Up to sixteen calls are honored per frame; more are ignored. Any other argument count raises `Expected 1, 3 or 5 arguments`. *Parameters:* * `x`, `y` -- the top left corner, in overlay coordinates. * `width`, `height` -- the size to draw at, in overlay pixels; the GUI's own size when left out. *Since:* 3.00. *See also:* <>, <>, <> .Example [source,lua] ---- -- The HUD sits in the bottom right corner at a third of the overlay's width. function onOverlayUpdate() overlayClear() guiSetValue(hud, hudPage, "lives", tostring(lives)) guiDraw(hud, overlayGetWidth() * 0.66, overlayGetHeight() * 0.8, overlayGetWidth() * 0.33, overlayGetHeight() * 0.2) return OVERLAY_UPDATED end ---- [#guigetheight] ==== guiGetHeight [source,text] ---- height = guiGetHeight(gui) ---- The GUI's height in its own pixels, as given to `guiNew`. Raises an error for a handle that is not valid. *Returns:* An integer. *Since:* 3.00. *See also:* <>, <> .Example [source,lua] ---- -- Center a small dialog on the overlay. local x = (overlayGetWidth() - guiGetWidth(dialog)) / 2 local y = (overlayGetHeight() - guiGetHeight(dialog)) / 2 guiDraw(dialog, x, y) ---- [#guigetstats] ==== guiGetStats [source,text] ---- stats = guiGetStats() ---- What rendering every GUI cost in the last completed frame, for tuning documents that use filters, masks and shadows (each of which draws through a layer the size of the GUI). The counters cover all GUIs together. *Returns:* A table with the fields `drawCalls` (indexed draws, including mask writes, composites and filter passes), `layerPushes` (layers entered for filters, mask images and box shadows), `filterPasses` (full-size passes and blits inside filter chains; a blur costs several), `maskWrites` (draws into the stencil clip mask), `layerBytes` (bytes of layer textures currently allocated; each is the size of its GUI at four bytes a pixel) and `passes` (render passes begun; every layer switch begins one). *Since:* 3.00. *See also:* <>, <> .Example [source,lua] ---- -- Watch what a screen full of effects costs. 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) ---- The value of the element with that `id` in the document, as a string: a form control's value (a text field's text, a `select` option's `value`, a range in RmlUi's own formatting such as `"85.000000"`, a checkbox's or radio button's `value` attribute, `"on"` when it has none, whether or not it is checked; its state is its `checked` attribute, read through RmlUi's API), or, for any other element, its inner RML. Returns `nil` when no element has that id, so a page can be probed without an error; a bad GUI or document handle raises one. Values longer than 4095 characters are cut. *Returns:* A string, or `nil`. *Since:* 3.00. *See also:* <>, <> .Example [source,lua] ---- -- Read the whole form when Start is pressed. 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) ---- The GUI's width in its own pixels, as given to `guiNew`. Raises an error for a handle that is not valid. *Returns:* An integer. *Since:* 3.00. *See also:* <>, <> .Example [source,lua] ---- -- Is the probed point in the right half of the monitor's page? 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) ---- Hides the document: it is not drawn and takes no input, but it keeps its elements and values and `guiShow` brings it back as it was. Raises `No document N in GUI G` for a handle that is not valid. *Since:* 3.00. *See also:* <>, <> .Example [source,lua] ---- -- Two pages in one GUI: the options page hides while the controls page shows. guiSetHandler(gui, options, "controls", "click", function() guiHide(gui, options) guiShow(gui, controls) end) ---- [#guiload] ==== guiLoad [source,text] ---- document = guiLoad(gui, filename) ---- Loads an RML document into the GUI, shows it, and returns an integer document handle for the other calls. The name goes through the virtual file system like a sprite's, resolved relative to the directory Singe was started in, so prepend `DIR` for files shipped with your game; style sheets, images and fonts named inside the document resolve relative to the file that names them and then through the same lookup, so a packed game finds them. The document's `