= 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. === 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). 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`). | `-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, the menu's `menu.dat`. A subdirectory named for the game's directory is created inside it. Defaults to the game's own directory. | `-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. | `-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`. | `-U`, `--unpack=GAME.game` | Write the packed game's files into the directory named after the options, then exit. | `-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. *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. === Game Directory Layout A Singe installation is a directory containing the Singe executable, the `Singe` support directory it creates on first run, one directory per game, and a `data` directory when the menu or a front end passes `--datadir`. ---- Singe/ Support files extracted by the engine Framework.singe Loaded by every game (dofile it) Menu.singe The bundled game menu 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 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()`), so a game can live on read-only media. === 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. Names starting with `Singe/` or with the data directory stay on the filesystem; `Singe/Framework.singe` and `singeGetDataPath()` work as always. `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 the file and line. Two files whose names differ only by case cannot both be packed. Stale `.index` files are skipped. 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 keeps its data directory exactly where the loose game would have it (`data/DLe/` for `DLe/DLe.singe`), so saves and settings are shared between a loose install and a packed one. The video index goes there too. 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. === 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. [#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. [#scenes3d] === 3D Scenes Singe 3 can draw 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. Two ways to fill a scene, freely mixed: * *Build it from script* with primitive meshes (`meshBox`, `meshSphere`, `meshPlane`, `meshCylinder`, `meshCone`, `meshTorus`) or your own vertex data (`meshNew`), 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`. The file must be self-contained: meshes, skins, animations, and PNG/JPEG textures all inside the one file (Blender's default). A model referring to a `.bin` or image file beside it is refused with a message naming the file, for the same reason a packed game has to be self-contained. 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 may carry a mesh with a material, or a light. A loaded model becomes a subtree of nodes under one root node, so the same calls (`nodeSetPosition`, `nodeRotate`, `nodeSetParent`, `nodeFind`, ...) move a modelled character and a scripted cube alike. .Coordinates and units The scene is right-handed with +Y up and -Z forward, like glTF and Blender's export. Units are whatever your models use; the primitives take the same units. Rotations are given in degrees as three Euler angles (applied yaw, pitch, roll), or as a quaternion for values that came from a model. A node's "forward" is its own -Z: the camera looks down it, directional and spot lights shine down it, and `nodeLookAt` points it at a world position. .A minimal scene [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 ---- .Materials A material is a base colour (with alpha), an optional texture, and the glTF metallic-roughness look: `materialSetMetallic` and `materialSetRoughness` from `0` to `1`, `materialSetEmissive` for glowing parts, `materialSetUnlit` to show the colour as is, `materialSetDoubleSided` for open shapes, and `materialSetBlend` for translucency. The texture can be any sprite's image (`materialSetTexture`), or the laserdisc itself or a loaded video (`materialSetVideo`): a modelled cabinet can play the disc on its own screen. .Lights and camera Up to eight lights shine at once: `LIGHT_DIRECTIONAL` (a sun, position irrelevant), `LIGHT_POINT` (a bulb, fading with distance, optionally out to a range), and `LIGHT_SPOT` (a cone with inner and outer angles). Lights are nodes, so they parent and animate like anything else. `sceneSetAmbient` lights everything a little from everywhere. Any light can cast soft-edged shadows from everything opaque (`lightSetShadow`): directional and spot lights through a shadow map, point lights through a cube map that reaches every direction; `sceneSetShadowSize` trades sharpness for speed. Any node can be the camera (`cameraSet`); without one, the scene is viewed from `(0, 0, 5)` looking at the origin. Perspective is the default; `cameraSetOrthographic` is there for diagrams and HUD-like scenes. .Animation A model's animations are listed by `modelGetAnimations` and driven per instance with `animationPlay(node, nameOrIndex, loop, speed)` 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; a model's morph weights can also be set from script with `nodeSetMorph`, by name or number, on the node that carries the mesh. Animations advance with real time and hold while the game is paused. .From scene to overlay and back `sceneProject(x, y, z)` turns a world point into overlay coordinates, so you can draw a name tag or a health bar with `fontPrint` above a 3D object. `sceneUnproject(sx, sy, distance)` goes the other way: the world point that far along the ray through an overlay pixel, which is what mouse picking and placing things "where the player clicked" need. .Performance The scene needs a GPU that speaks Vulkan (Linux, Raspberry Pi 4 and later), Direct3D 12 (Windows 10 and later), or Metal (macOS). On a machine without one, 2D games run exactly as before and the first 3D call raises an error; the Raspberry Pi 3 has no Vulkan driver and therefore no 3D. Antialiasing (4x multisampling) is on wherever the GPU offers it; `sceneSetAntialias(false)` buys back speed on a Pi. Keep an eye on triangle counts and texture sizes on small boards, and load models once and instance them rather than loading twice. .Packaging Put `.glb` files in the game directory like any other asset; `modelLoad` takes the same game-relative names as `spriteLoad`, and a packed `.game` carries them along. [#physics] === Physics Bodies, joints, triggers and raycasts live in the 3D scene and ride on Jolt Physics. A body attaches to a node the way a mesh does; from then on the node and the body are one thing: * A *static* body (`BODY_STATIC`) stays where the node was when the body was made: floors, walls, the cabinet. * 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. Every body has a collision shape sized in world units and scaled by the node: a box, sphere, capsule or cylinder, 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). Units are metres, kilograms and seconds; gravity defaults to `0, -9.81, 0`. .Bodies for a crate stack and a paddle [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 matters 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. .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. .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. 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. .Joints `jointHinge`, `jointBall` and `jointSlider` connect two bodies, or a body and the world when the second node is `-1`: doors, pendulums, drawers. Hinges and sliders take limits. A positive hinge angle is a right-hand turn of the first node about the axis; a positive slider distance is along it. .Models Bodies on model instances work like any other: `bodyNew(instanceRoot, BODY_DYNAMIC, SHAPE_HULL)` wraps the whole model in a convex hull. Skinned characters are best driven kinematically with a capsule and left to push things rather than be pushed. .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: [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`. This is the form that runs on a Raspberry Pi 3, which has no 3D. .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. [#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`. * 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). | `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")` |=== 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. ==== 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 menu then plays a click once a second and flashes the screen white; 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 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`). 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. == 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. Animations are numbered from `1` in the order `modelGetAnimations` lists them, or named. Time advances with the wall clock and holds while the game is paused. Node animation, skinning and morph target weights are all driven. [#animationgettime] ==== animationGetTime [source,text] ---- seconds = animationGetTime(node) ---- The current time into the playing (or paused) animation. *Returns:* Seconds as a number; `0` when nothing was played. *Since:* 3.00. *See also:* <> [#animationisplaying] ==== animationIsPlaying [source,text] ---- playing = animationIsPlaying(node) ---- Whether an animation is advancing on this instance. A non-looping animation stops playing when it reaches its end. *Returns:* Boolean. *Since:* 3.00. *See also:* <> [#animationpause] ==== animationPause [source,text] ---- animationPause(node) ---- Freezes the animation at its current time; `animationResume` continues it. *Since:* 3.00. *See also:* <> [#animationplay] ==== animationPlay [source,text] ---- animationPlay(node, nameOrIndex) animationPlay(node, nameOrIndex, loop) animationPlay(node, nameOrIndex, loop, speed) ---- Starts one of the model's animations from the beginning on this instance, replacing whatever was playing. * `node` -- the instance's root node from `modelInstance`. * `nameOrIndex` -- an animation name as the file stores it, or its number from `1`. * `loop` -- boolean, default `true`. When `false` the animation stops at its last keyframe. * `speed` -- playback rate, default `1`. `2` is twice as fast; `0.5` is half. *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) end ---- [#animationresume] ==== animationResume [source,text] ---- animationResume(node) ---- Continues an animation paused by `animationPause`. *Since:* 3.00. *See also:* <> [#animationsettime] ==== animationSetTime [source,text] ---- animationSetTime(node, seconds) ---- Jumps to a time in the current animation. The pose updates at once, whether or not the animation is playing, so a paused instance can be posed exactly. *Since:* 3.00. *See also:* <>, <> [#animationstop] ==== animationStop [source,text] ---- animationStop(node) ---- Stops and forgets the animation. The nodes keep their last pose. *Since:* 3.00. *See also:* <> [#body] === Body A body gives a scene node a place in the physics world. One body per node; static bodies stay put, kinematic bodies follow their node, dynamic bodies drive it. Every call here takes the node handle and raises an error when the node has no body. See <> for the model. [#bodynew] ==== bodyNew [source,text] ---- bodyNew(node, type, shape, ...) bodyNew(node, BODY_DYNAMIC, SHAPE_BOX, width, height, depth) bodyNew(node, BODY_DYNAMIC, SHAPE_SPHERE, radius) bodyNew(node, BODY_DYNAMIC, SHAPE_CAPSULE, radius, height) bodyNew(node, BODY_DYNAMIC, SHAPE_CYLINDER, radius, height) bodyNew(node, BODY_DYNAMIC, SHAPE_HULL) bodyNew(node, BODY_STATIC, SHAPE_MESH) ---- Gives the node a body, placed where the node is now, with a shape sized in world units and scaled by the node's scale. A second call on the same node replaces the first. * `type` -- `BODY_STATIC`, `BODY_DYNAMIC` or `BODY_KINEMATIC`. * `shape` -- a primitive with its sizes (a capsule's or cylinder's height is the total, along Y), `SHAPE_HULL` for a convex hull of the node's mesh and everything under it, or `SHAPE_MESH` for the exact triangles (static and kinematic only). *Notes:* Mass comes from the shape's volume at the density of water; set it with `bodySetMass`. Changing the node's scale later does not resize the body. A dynamic body ignores its parent moving. *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) ---- [#bodyapplyforce] ==== bodyApplyForce [source,text] ---- bodyApplyForce(node, fx, fy, fz) bodyApplyForce(node, fx, fy, fz, px, py, pz) ---- A force for this step, in newtons, at the centre of mass or at a world point (which also spins the body). Dynamic bodies only. *Since:* 3.00. *See also:* <> [#bodyapplyimpulse] ==== bodyApplyImpulse [source,text] ---- bodyApplyImpulse(node, ix, iy, iz) bodyApplyImpulse(node, ix, iy, iz, px, py, pz) ---- An instant change of momentum (mass times speed), at the centre of mass or at a world point. What a hit, a kick or a launch wants. Dynamic bodies only. *Since:* 3.00. *See also:* <>, <> .Example [source,lua] ---- -- Flick whatever the mouse is over. 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 then bodyApplyImpulse(hit, 0, 3, 0, hx, hy, hz) end ---- [#bodydelete] ==== bodyDelete [source,text] ---- bodyDelete(node) ---- Removes the body; the node stays. Joints on the body go with it. Deleting the node removes its body too. *Since:* 3.00. *See also:* <> [#bodygetangularvelocity] ==== bodyGetAngularVelocity [source,text] ---- x, y, z = bodyGetAngularVelocity(node) ---- How fast the body spins about each world axis, in radians per second. *Returns:* Three numbers. *Since:* 3.00. *See also:* <> [#bodygetvelocity] ==== bodyGetVelocity [source,text] ---- x, y, z = bodyGetVelocity(node) ---- How fast the body moves, in units per second. *Returns:* Three numbers. *Since:* 3.00. *See also:* <> [#bodyisresting] ==== bodyIsResting [source,text] ---- resting = bodyIsResting(node) ---- Whether the simulation has put the body to sleep because it stopped moving. Static and disabled bodies rest. *Returns:* Boolean. *Since:* 3.00. *See also:* <> [#bodysetangularvelocity] ==== bodySetAngularVelocity [source,text] ---- bodySetAngularVelocity(node, x, y, z) ---- Sets the spin about each world axis, in radians per second, and wakes the body. *Since:* 3.00. *See also:* <> [#bodysetbounce] ==== bodySetBounce [source,text] ---- bodySetBounce(node, bounce) ---- Restitution from `0` (stops dead) to `1` (bounces back with everything it arrived with). Default `0.1`. *Since:* 3.00. *See also:* <> [#bodysetenabled] ==== bodySetEnabled [source,text] ---- bodySetEnabled(node, enabled) ---- Takes the body out of the world (it stops colliding and moving, its node stays put) and puts it back. *Since:* 3.00. *See also:* <> [#bodysetfriction] ==== bodySetFriction [source,text] ---- bodySetFriction(node, friction) ---- Surface friction, `0` for ice upward; default `0.5`. The two touching bodies' values combine. *Since:* 3.00. *See also:* <> [#bodysetmass] ==== bodySetMass [source,text] ---- bodySetMass(node, kilograms) ---- Rescales a dynamic body's mass and inertia. Do this for anything meant to feel light: the default is the shape's volume at the density of water. *Since:* 3.00. *See also:* <>, <> [#bodysettrigger] ==== bodySetTrigger [source,text] ---- bodySetTrigger(node, trigger) ---- Makes the body a trigger: it pushes nothing and collides with nothing, but `onTrigger` reports what enters and leaves it. Usually a static box or sphere. *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 velocity in units per second and wakes the body. Kinematic bodies take theirs from their node instead. *Since:* 3.00. *See also:* <>, <> [#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. It looks down its own -Z. Without a camera node the scene is viewed from `(0, 0, 5)` looking at the origin, with a 60 degree perspective. [#cameraset] ==== cameraSet [source,text] ---- cameraSet(node) cameraSet(-1) ---- Makes a node the camera. `-1` restores the default view. *Since:* 3.00. *See also:* <>, <> .Example [source,lua] ---- -- A camera that follows the player's car from behind. camera = nodeNew(car) nodeSetPosition(camera, 0, 2, 6) nodeLookAt(camera, nodeGetWorldPosition(car)) cameraSet(camera) ---- [#camerasetorthographic] ==== cameraSetOrthographic [source,text] ---- cameraSetOrthographic(height, near, far) ---- A parallel projection showing `height` world units top to bottom (the width follows the layer's aspect ratio), from `near` to `far` in front of the camera. *Since:* 3.00. *See also:* <> [#camerasetperspective] ==== cameraSetPerspective [source,text] ---- cameraSetPerspective(fov, near, far) ---- The usual projection. `fov` is the vertical field of view in degrees (default `60`); `near` and `far` (defaults `0.1` and `1000`) bound what is drawn. Keep `near` as large as the scene allows for a precise depth buffer. *Since:* 3.00. *See also:* <> [#color] === Color The foreground and background colors are *engine globals*, not per-operation parameters. Set them once with `colorForeground` / `colorBackground`, then every subsequent drawing call (`overlayPrint`, `overlayBox`, `overlayLine`, etc.) uses those colors until you change them again. [#colorbackground] ==== colorBackground [source,text] ---- colorBackground(r, g, b) colorBackground(r, g, b, a) ---- Sets the background color used by subsequent drawing operations (primarily `overlayPrint` when it fills the text background). * `r`, `g`, `b` -- red, green, blue channels, `0` to `255`. * `a` -- optional opacity, `0` to `255`. Defaults to `0` (fully transparent) so that text drawn with `overlayPrint` shows through to whatever is behind it. The three-argument form exists for compatibility with pre-2.00 scripts and produces the same result as passing `a = 0`. *Since:* 1.x *See also:* <>, <> *(documented in the Overlay section)* .Example [source,lua] ---- -- Draw a status line with a solid black background behind the text. function onOverlayUpdate() colorBackground(0, 0, 0, 255) -- Opaque black. colorForeground(255, 255, 0, 255) -- Yellow text. overlayPrint(8, 8, "Score: " .. score) return OVERLAY_UPDATED end ---- [#colorforeground] ==== colorForeground [source,text] ---- colorForeground(r, g, b) colorForeground(r, g, b, a) ---- Sets the foreground color used by subsequent drawing operations -- text rendered by `overlayPrint` and `fontPrint`, all primitives in the `overlay*` family, and the tint used by `fontToSprite`. * `r`, `g`, `b` -- red, green, blue channels, `0` to `255`. * `a` -- optional opacity, `0` to `255`. Defaults to `255` (fully opaque). This is the opposite default from `colorBackground`, because foreground drawing almost always wants to be visible. The three-argument form exists for compatibility with pre-2.00 scripts and produces the same result as passing `a = 255`. *Since:* 1.x *See also:* <>, <>, <>, <> *(all documented in the Overlay section)* .Example [source,lua] ---- -- Flash the crosshair red when the player is hit, white otherwise. function onOverlayUpdate() if hitTimer > 0 then colorForeground(255, 0, 0, 255) hitTimer = hitTimer - 1 else colorForeground(255, 255, 255, 255) end overlayCircle(crosshairX, crosshairY, 8) return OVERLAY_UPDATED end ---- [#controller] === Controller Singe supports up to *four* simultaneous game controllers (indices `0` through `3`). Controllers are detected automatically at startup; there is no `controllerLoad`. Inputs reach your game in two ways: * *Analog axes* (sticks and triggers) -- reported in real time through the `onControllerMoved` callback *or* polled on demand with `controllerGetAxis`. Values range from `-32768` to `32767`. * *Digital buttons* (A/B/X/Y, shoulders, D-pad, etc.) -- reach your game through the generic input system only when they are mapped to a switch in `controls.cfg`. Map a button code from the `GAMEPAD_0` / `GAMEPAD_1` / `GAMEPAD_2` / `GAMEPAD_3` tables (for example `INPUT_ACTION_1 = { GAMEPAD_0.BUTTON_A }`) and `onInputPressed` / `onInputReleased` then receive the matching `SWITCH_*` value in keyboard `MODE_NORMAL`. Unmapped buttons are ignored in `MODE_NORMAL`; in `MODE_FULL` the raw button code arrives as the `scancode` argument of `onKeyPressed` / `onKeyReleased`. `controllerGetButton` is for polling the live state of a specific button. The global `SINGE_DEAD_ZONE` (set from the `DEAD_ZONE` entry in `controls.cfg`) is the recommended threshold below which you should treat axis motion as noise. [#controllergetaxis] ==== controllerGetAxis [source,text] ---- value = controllerGetAxis(controller, axis) ---- Polls the current position of an analog axis on a controller. * `controller` -- controller index, `0` through `3`. * `axis` -- axis index. Use the named constants: `GAMEPAD_AXIS_LEFT_X`, `GAMEPAD_AXIS_LEFT_Y`, `GAMEPAD_AXIS_RIGHT_X`, `GAMEPAD_AXIS_RIGHT_Y`, `GAMEPAD_AXIS_LEFT_TRIGGER`, `GAMEPAD_AXIS_RIGHT_TRIGGER`. *Returns:* integer from `-32768` to `32767`. Triggers use `0` to `32767` (they are one-sided). *Since:* 2.00 *See also:* <>, the `onControllerMoved` callback .Example [source,lua] ---- -- Steer left/right based on the left analog stick, ignoring the dead zone. function onOverlayUpdate() local x = controllerGetAxis(0, GAMEPAD_AXIS_LEFT_X) if math.abs(x) > SINGE_DEAD_ZONE then playerX = playerX + (x / 32768) * steerSpeed end drawPlayer() return OVERLAY_UPDATED end ---- [#controllergetbutton] ==== controllerGetButton [source,text] ---- down = controllerGetButton(controller, button) ---- Polls the current state of a digital button on a controller. Use this when you need to know *right now* whether a button is held, rather than waiting for an `onInputPressed` event. * `controller` -- controller index, `0` through `3`. * `button` -- one of the `BUTTON_*` values from the `GAMEPAD_N` table for that controller (e.g. `GAMEPAD_0.BUTTON_A.value`, `GAMEPAD_0.DPAD_UP.value`). *Returns:* boolean. `true` while the button is held. *Since:* 2.10 *See also:* <>, the `onInputPressed` callback .Example [source,lua] ---- -- Charge a shot while the A button is held, fire when released. function onOverlayUpdate() if controllerGetButton(0, GAMEPAD_0.BUTTON_A.value) then chargeLevel = math.min(chargeLevel + 1, MAX_CHARGE) elseif chargeLevel > 0 then fireShot(chargeLevel) chargeLevel = 0 end return OVERLAY_UPDATED end ---- [#debug] === Debug [#debugprint] ==== debugPrint [source,text] ---- debugPrint(message) ---- Writes a string to Singe's trace log (stdout and the trace file when tracing is enabled with `-t`/`--trace`). Useful for step-printf debugging when the ZeroBrane Studio integration is not available. * `message` -- any string. *Since:* 1.x *See also:* command-line options `-t` (script trace) and `-p` (engine trace) in the manual .Example [source,lua] ---- -- Check why the player isn't taking damage. debugPrint("hit check: player=" .. tostring(playerX) .. "," .. tostring(playerY) .. " enemy=" .. tostring(enemyX) .. "," .. tostring(enemyY)) ---- [#disc] === Disc The *disc* is the main laserdisc video -- the one Singe was originally built to emulate. A Singe game always has exactly one disc, loaded at startup from the video file declared in `games.dat` (or the command line). All `disc*` functions operate on this single, implicit disc. They exist in addition to the more general <> family, which handles additional video assets you load yourself. Use `disc*` for the main gameplay video; use `video*` for extra clips layered on top. A handful of `disc*` functions are *unimplemented no-ops* retained for script-level compatibility with very old Singe games. They are listed in <>. [#discaudio] ==== discAudio [source,text] ---- discAudio(channel, onOff) ---- Toggles one audio channel of the disc's soundtrack on or off. Often used to mute the left or right channel when a game originally used one channel for audio and the other for a data track. * `channel` -- `1` for left, `2` for right. * `onOff` -- boolean. `true` restores the channel to the configured VLDP volume; `false` silences it. *Since:* 1.x *See also:* <> .Example [source,lua] ---- -- Some titles bake narration into the right channel only. discAudio(1, false) -- Silence the music on the left. discAudio(2, true) -- Keep narration on the right. ---- [#discchangespeed] ==== discChangeSpeed WARNING: *Unimplemented.* This function is a no-op retained for backward compatibility with pre-2.00 scripts. [#discgetaudiotrack] ==== discGetAudioTrack [source,text] ---- track = discGetAudioTrack() ---- Returns the index of the currently selected audio track for the disc. *Returns:* integer track index. *Since:* 2.10 *See also:* <>, <> [#discgetaudiotracks] ==== discGetAudioTracks [source,text] ---- count = discGetAudioTracks() ---- Returns how many audio tracks the disc's video file contains. Multiple tracks are typically alternate language dubs. *Returns:* integer count (>= 1). *Since:* 2.10 *See also:* <>, <> .Example [source,lua] ---- -- Build a language-select menu from the disc's audio tracks. for i = 0, discGetAudioTracks() - 1 do local code = discGetLanguage(i) local name = discGetLanguageDescription(code) table.insert(languages, { track = i, label = name }) end ---- [#discgetframe] ==== discGetFrame [source,text] ---- frame = discGetFrame() ---- Returns the current frame number on the disc. This is the fundamental time reference for laserdisc games -- almost every interactive decision hinges on "what frame are we on right now?" *Returns:* integer. `0` when the disc is stopped. *Since:* 1.x *See also:* <>, <>, <> .Example [source,lua] ---- -- Trigger a prompt at the moment the player must duck. function onOverlayUpdate() local f = discGetFrame() if f >= 2847 and f <= 2882 and not promptShown then showPrompt("DUCK!") promptShown = true end return OVERLAY_UPDATED end ---- [#discgetheight] ==== discGetHeight [source,text] ---- height = discGetHeight() ---- Returns the height of the disc's video in pixels. Useful if you want to build an overlay resolution that matches the native video. *Returns:* integer pixel height. *Since:* 2.00 *See also:* <>, <> [#discgetlanguage] ==== discGetLanguage [source,text] ---- code = discGetLanguage(track) ---- Returns the ISO language code (e.g. `"eng"`, `"jpn"`, `"fre"`) for an audio track on the disc, as stored in the video file's metadata. * `track` -- audio track index, `0` through `discGetAudioTracks() - 1`. *Returns:* three-letter language code string, or an empty string if no language tag is set on that track. *Since:* 2.10 *See also:* `discGetLanguageDescription` (aliased in `Framework.singe` to <>), <> [#discgetstate] ==== discGetState [source,text] ---- state = discGetState() ---- Returns an integer describing the playback state of the disc. *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 <>) |=== Singe's lightweight player has no distinct searching state; a disc that is seeking reports `DISC_PAUSED`. *Since:* 1.x (RDG) *See also:* <>, <>, <> .Example [source,lua] ---- -- Don't let the pause key resume from a story-driven freeze. if discGetState() == DISC_PAUSED and not storyPaused then discPlay() end ---- [#discgetwidth] ==== discGetWidth [source,text] ---- width = discGetWidth() ---- Returns the width of the disc's video in pixels. *Returns:* integer pixel width. *Since:* 2.00 *See also:* <>, <> [#discpause] ==== discPause [source,text] ---- discPause() ---- Pauses playback on the current frame. A paused disc keeps displaying the frame it stopped on. Has no effect if the disc is already stopped (see `discStop` for the difference). *Since:* 1.x *See also:* <>, <>, <> [#discpauseatframe] ==== discPauseAtFrame [source,text] ---- discPauseAtFrame(frame) ---- Seeks to the specified frame and pauses on it. In the current engine this is *identical to `discSearch`* -- the two names exist for historical reasons. Prefer `discSearch` in new code; keep `discPauseAtFrame` if you are porting old scripts. * `frame` -- target frame number. *Since:* 1.18 *See also:* <> [#discplay] ==== discPlay [source,text] ---- discPlay() ---- Starts or resumes playback from the current frame. If the disc was stopped (via `discStop`), playback restarts and the disc is marked running again. *Since:* 1.x *See also:* <>, <>, <> .Example [source,lua] ---- -- Standard attract-mode loop. function attractLoop() discSearch(attractStart) -- Seek and pause on the first frame. discPlay() -- Start rolling. end ---- [#discsearch] ==== discSearch [source,text] ---- discSearch(frame) ---- Seeks the disc to the specified frame and pauses on it. The disc will display that frame until you call `discPlay`, `discSkipToFrame`, or another transport function. This is the workhorse frame-positioning call for laserdisc games -- every branch point in an interactive video game is some variation of "seek to the next scene, show me the frame, wait for my decision." * `frame` -- target frame number. *Since:* 1.x *See also:* <>, <>, <> .Example [source,lua] ---- -- Player picked "go left" -- cue the left-corridor scene and wait. function goLeft() discSearch(sceneLeftStartFrame) waitingForPlayer = true end ---- [#discsearchblanking] ==== discSearchBlanking WARNING: *Unimplemented.* No-op retained for backward compatibility with pre-2.00 scripts. On real laserdisc hardware this controlled whether the screen blanked during a seek; Singe's software player does not need this. [#discsetaudiotrack] ==== discSetAudioTrack [source,text] ---- discSetAudioTrack(track) ---- Switches the active audio track on the disc. Use this to implement a language-select menu. * `track` -- audio track index, `0` through `discGetAudioTracks() - 1`. Out-of-range values terminate the script. *Since:* 2.10 *See also:* <>, <> [#discsetfps] ==== discSetFPS WARNING: *Unimplemented.* No-op retained for backward compatibility. `Framework.singe` still calls `discSetFPS(29.97)` at startup for the benefit of old scripts that read the framerate elsewhere. The actual framerate comes from the video file. [#discskipbackward] ==== discSkipBackward [source,text] ---- discSkipBackward(delta) ---- Seeks backward by a number of frames from the current position. Does *not* change the play/pause state -- a playing disc keeps playing, a paused disc stays paused on the new frame. * `delta` -- number of frames to subtract from the current frame. Must be positive. *Since:* 1.x *See also:* <>, <> .Example [source,lua] ---- -- Rewind 30 frames (about one second at 30fps) when the player misses a prompt. discSkipBackward(30) ---- [#discskipblanking] ==== discSkipBlanking WARNING: *Unimplemented.* No-op retained for backward compatibility with pre-2.00 scripts. [#discskipforward] ==== discSkipForward [source,text] ---- discSkipForward(delta) ---- Seeks forward by a number of frames from the current position. Does not change the play/pause state. Mirror of `discSkipBackward`. * `delta` -- number of frames to add to the current frame. *Since:* 1.x *See also:* <>, <> [#discskiptoframe] ==== discSkipToFrame [source,text] ---- discSkipToFrame(frame) ---- Seeks to a specific frame and *starts playing* from that frame. Contrast with `discSearch`, which seeks and pauses. Use `discSkipToFrame` when you want continuous video playback from a new location. * `frame` -- target frame number. *Since:* 1.x *See also:* <>, <> .Example [source,lua] ---- -- Skip the intro on a replay. if hasPlayedBefore then discSkipToFrame(gameplayStart) else discSkipToFrame(introStart) end ---- [#discstepbackward] ==== discStepBackward [source,text] ---- discStepBackward() ---- Moves the disc back by exactly one frame. Always pauses on the new frame, regardless of previous play state. Intended for frame-accurate debugging and service-mode screens. *Since:* 1.x *See also:* <>, <> [#discstepforward] ==== discStepForward [source,text] ---- discStepForward() ---- Moves the disc forward by exactly one frame. Always pauses on the new frame, regardless of previous play state. Mirror of `discStepBackward`. *Since:* 1.x *See also:* <> [#discstop] ==== discStop [source,text] ---- discStop() ---- Stops the disc. Stopped is a stronger state than paused: the display is marked for refresh, and `discGetState` returns `2` rather than `4`. Transport calls that require a running disc (like `discSkipForward` and `discSkipBackward`) will fail silently while stopped. Resume with `discPlay` or any `discSearch` / `discSkipToFrame`. *Since:* 1.x *See also:* <>, <>, <> [#font] === Font Singe has *two* text rendering systems. Know which one you're using: * *Console font* -- a fixed 8-bit bitmap font baked into the engine. Accessed through <>. Fast, predictable, grid-aligned, and the only way to print text without loading any assets. * *TrueType fonts* -- arbitrary `.ttf` files you load at runtime with `fontLoad`, then render with `fontPrint` (direct to overlay) or `fontToSprite` (to a reusable sprite). Scalable, anti-aliased, colorable, but require managing the lifecycle yourself. The TrueType system keeps a global *currently selected font*. `fontLoad` selects the font it just loaded; to switch between several loaded fonts, use `fontSelect`. Three render qualities are available, set with `fontQuality`: [cols="1,1,1",options="header"] |=== | Constant | Value | Appearance | `FONT_QUALITY_SOLID` | `1` | Fastest. 1-bit alpha, jagged edges. Use for dev text. | `FONT_QUALITY_SHADED` | `2` | Anti-aliased text on a solid rectangle of `colorBackground`. Good for UI panels. | `FONT_QUALITY_BLENDED` | `3` | Smooth anti-aliased text with per-pixel alpha -- blends cleanly over any background. Highest quality, most common choice. |=== [#fontload] ==== fontLoad [source,text] ---- id = fontLoad(filename, pointSize) ---- Loads a TrueType font at the given point size and selects it as the current font. Different point sizes of the same typeface must be loaded as separate fonts -- `pointSize` is baked into the loaded handle. * `filename` -- path to a `.ttf` file. Prepend `DIR` for files shipped with your game. * `pointSize` -- integer point size. Typical values range from `12` (small UI text) to `72` (large title text). *Returns:* integer font handle. *Since:* 1.x *See also:* <>, <>, <> .Example [source,lua] ---- -- Load two sizes of the same face: big for the title, small for scores. titleFont = fontLoad(DIR .. "fonts/BreatheFire.ttf", 48) hudFont = fontLoad(DIR .. "fonts/FreeSansBold.ttf", 14) fontQuality(FONT_QUALITY_BLENDED) ---- [#fontprint] ==== fontPrint [source,text] ---- fontPrint(x, y, text) ---- Renders a string onto the overlay using the currently selected TrueType font, the current foreground color, and (for `FONT_QUALITY_SHADED`) the current background color. Must be called from `onOverlayUpdate`. * `x`, `y` -- overlay coordinates of the top-left corner of the rendered text. * `text` -- the string to render. `fontPrint` creates a fresh text surface on every call and frees it after blitting. For text that changes only occasionally (a score, a status line, menu labels), `fontToSprite` is significantly faster since it caches the surface. *Since:* 1.x *See also:* <>, <>, <> .Example [source,lua] ---- -- Render a live-updating score with the currently selected font. function onOverlayUpdate() colorForeground(255, 255, 255, 255) fontPrint(20, 20, "SCORE: " .. score) return OVERLAY_UPDATED end ---- [#fontquality] ==== fontQuality [source,text] ---- fontQuality(mode) ---- Sets the render quality used by subsequent `fontPrint` and `fontToSprite` calls. This is a *global setting*, not per-font. * `mode` -- one of `FONT_QUALITY_SOLID` (`1`), `FONT_QUALITY_SHADED` (`2`), or `FONT_QUALITY_BLENDED` (`3`). *Since:* 1.x *See also:* <>, <> [#fontselect] ==== fontSelect [source,text] ---- fontSelect(id) ---- Makes a previously loaded font the current one. All subsequent `fontPrint` / `fontToSprite` calls will use this font until you call `fontSelect` again. * `id` -- font handle returned by `fontLoad`. *Since:* 1.x *See also:* <> .Example [source,lua] ---- fontSelect(titleFont) fontPrint(centerX, 40, "DRAGON'S LAIR") fontSelect(hudFont) fontPrint(8, 8, "Score: " .. score) ---- [#fonttosprite] ==== fontToSprite [source,text] ---- id = fontToSprite(text) ---- Renders a string to a new sprite using the currently selected font, quality, and foreground color. Returns a sprite handle that can be used with every <> function (draw, scale, rotate, unload). This is the recommended path for text that doesn't change often -- a menu label, a level name, a scoreboard header. Render once, cache the sprite, draw it repeatedly. For text that changes every frame (a timer, a live score), either re-render with `fontToSprite` and unload the previous sprite, or use `fontPrint` directly. * `text` -- the string to render. *Returns:* integer sprite handle. *Since:* 1.x *See also:* <>, <>, <> .Example [source,lua] ---- -- Pre-render menu labels once at startup. menuLabels = {} for i, entry in ipairs(menuEntries) do menuLabels[i] = fontToSprite(entry.name) end function onOverlayUpdate() for i, label in ipairs(menuLabels) do spriteDraw(label, menuX, menuY + i * lineHeight) end return OVERLAY_UPDATED end ---- [#fontunload] ==== fontUnload [source,text] ---- fontUnload(id) ---- Releases a loaded font. After this call the handle is invalid. * `id` -- font handle. If you unload the currently selected font, the engine does not automatically pick another -- do not call `fontPrint` or `fontToSprite` until you have `fontSelect`ed a different loaded font. *Since:* 2.00 *See also:* <> [#joint] === Joint Joints connect two bodies, or a body and the world, and hand back a handle. Anchors and axes are given in world space at the moment the joint is made, with the bodies where they are then. Deleting either body deletes the joint. [#jointhinge] ==== jointHinge [source,text] ---- joint = jointHinge(nodeA, nodeB, ax, ay, az, dx, dy, dz) ---- A hinge through the world point `(ax, ay, az)` along the axis `(dx, dy, dz)`: a door, a wheel, a lever. `nodeB` may be `-1` for the world. *Returns:* The joint handle. *Since:* 3.00. *See also:* <>, <> .Example [source,lua] ---- -- A door hinged to the world along its left edge, opening 100 degrees one way. door = nodeNew() nodeSetMesh(door, meshBox(1.4, 2.2, 0.1), wood) nodeSetPosition(door, 0, 0, 0) bodyNew(door, BODY_DYNAMIC, SHAPE_BOX, 1.4, 2.2, 0.1) bodySetMass(door, 8) hinge = jointHinge(door, -1, -0.7, 0, 0, 0, 1, 0) jointSetLimits(hinge, 0, 100) ---- [#jointball] ==== jointBall [source,text] ---- joint = jointBall(nodeA, nodeB, ax, ay, az) ---- A ball-and-socket at the world point: the bodies may turn any way about it but not separate. A pendulum, a chain link, a ragdoll shoulder. `nodeB` may be `-1` for the world. *Returns:* The joint handle. *Since:* 3.00. *See also:* <> [#jointslider] ==== jointSlider [source,text] ---- joint = jointSlider(nodeA, nodeB, ax, ay, az, dx, dy, dz) ---- Lets the bodies slide relative to each other only along the axis through the anchor, keeping their orientation: a drawer, a piston, a lift. `nodeB` may be `-1` for the world. *Returns:* The joint handle. *Since:* 3.00. *See also:* <> [#jointsetlimits] ==== jointSetLimits [source,text] ---- jointSetLimits(joint, low, high) ---- Bounds a hinge's angle in degrees, or a slider's travel in world units, either side of where the joint started; `low` at or below `0`, `high` at or above. A positive hinge angle is a right-hand turn of `nodeA` about the axis; a positive slider distance is along the axis. *Notes:* A ball joint has no limits. *Since:* 3.00. *See also:* <>, <> [#jointdelete] ==== jointDelete [source,text] ---- jointDelete(joint) ---- Removes the joint; the bodies are free of each other again. *Since:* 3.00. *See also:* <> [#keyboard] === Keyboard Singe exposes keyboard input in two modes, selected with `keyboardSetMode`: * *`MODE_NORMAL`* (default, value `0`) -- Singe translates raw keys into the high-level "switches" defined in `controls.cfg` (jump, coin, start, etc.). Your script only sees `onInputPressed(switch)` / `onInputReleased(switch)` with the `SWITCH_*` values from `Framework.singe`. Use this for gameplay. * *`MODE_FULL`* (value `1`) -- Singe also delivers every physical keypress through `onKeyPressed(keysym, scancode)` / `onKeyReleased(...)`. Use this when you need raw text entry (high score initials, a debug console, typing player names). Either mode, you can poll keyboard state directly with `keyboardIsDown`, `keyboardGetLastDown`, and `keyboardGetLastUp` -- those always work regardless of mode. *Scancodes vs. keysyms:* a *scancode* is the physical key position (the `A` key is always scancode `4`, no matter the layout). A *keysym* is the logical character that key produces (`'a'` on US QWERTY, `'q'` on AZERTY). The `SCANCODE` table in `Framework.singe` gives you named scancodes. `keyboardIsDown` works in scancodes. `onKeyPressed` gives you both. [#keyboardgetlastdown] ==== keyboardGetLastDown [source,text] ---- scancode = keyboardGetLastDown() ---- Returns the SDL scancode of the most recently pressed key (compare against `SCANCODE.*.value`), or `0` if nothing has been pressed since the last read. The value is cleared on every `onOverlayUpdate` pass, making this a one-shot "was a key just pressed?" poll -- ideal for the threaded model. *Returns:* integer scancode. *Since:* 2.10 *See also:* <>, <> .Example [source,lua] ---- -- Threaded-model main loop reacts to one keypress per iteration. function singeMain() while true do local k = keyboardGetLastDown() if k == SCANCODE.ESCAPE.value then singeQuit() end if k == SCANCODE.SPACE.value then jump() end singeYield() end end ---- [#keyboardgetlastup] ==== keyboardGetLastUp [source,text] ---- scancode = keyboardGetLastUp() ---- Returns the SDL scancode of the most recently released key (compare against `SCANCODE.*.value`), or `0` if nothing has been released since the last read. Cleared each frame, like `keyboardGetLastDown`. *Returns:* integer scancode. *Since:* 2.10 *See also:* <> [#keyboardgetmode] ==== keyboardGetMode [source,text] ---- mode = keyboardGetMode() ---- Returns the current keyboard mode: `MODE_NORMAL` (`0`) or `MODE_FULL` (`1`). *Since:* 1.x (RDG) *See also:* <> [#keyboardgetmodifiers] ==== keyboardGetModifiers [source,text] ---- mods = keyboardGetModifiers() ---- Returns a bitmask of the currently held modifier keys. Test bits against values in the `MODIFIER` table -- for example `mods & MODIFIER.SHIFT.value ~= 0` for either shift key. *Returns:* integer bitmask. *Since:* 2.10 *See also:* the `MODIFIER` table in `Framework.singe` .Example [source,lua] ---- -- Shift+tab opens the debug overlay. if keyboardGetLastDown() == SCANCODE.TAB.value then if keyboardGetModifiers() & MODIFIER.SHIFT.value ~= 0 then debugOverlayVisible = not debugOverlayVisible end end ---- [#keyboardisdown] ==== keyboardIsDown [source,text] ---- down = keyboardIsDown(scancode) ---- Tests whether a specific key is physically held right now. Unlike the `on*` callbacks, this does not wait for events -- it reads the live keyboard state. * `scancode` -- a value from the `SCANCODE` table (e.g. `SCANCODE.UP.value`). Out-of-range values quietly return `false`. *Returns:* boolean. *Since:* 2.10 *See also:* the `SCANCODE` table in `Framework.singe` .Example [source,lua] ---- -- Diagonal movement by checking multiple keys each frame. local dx, dy = 0, 0 if keyboardIsDown(SCANCODE.LEFT.value) then dx = -1 end if keyboardIsDown(SCANCODE.RIGHT.value) then dx = 1 end if keyboardIsDown(SCANCODE.UP.value) then dy = -1 end if keyboardIsDown(SCANCODE.DOWN.value) then dy = 1 end playerX = playerX + dx * speed playerY = playerY + dy * speed ---- [#keyboardsetmode] ==== keyboardSetMode [source,text] ---- keyboardSetMode(mode) ---- Switches the keyboard between the two input models. Most games should call this once at startup and leave it alone. * `mode` -- `MODE_NORMAL` (`0`) for mapped switches only, `MODE_FULL` (`1`) to also receive raw key events. *Since:* 1.x (RDG) *See also:* <> .Example [source,lua] ---- -- Enter high-score entry, capture raw keys, then restore gameplay mode. keyboardSetMode(MODE_FULL) collectInitials() keyboardSetMode(MODE_NORMAL) ---- [#light] === Light Lights are nodes carrying a light. Up to eight visible lights shine in a frame, in node order; `sceneSetAmbient` adds light from everywhere. Directional and spot lights shine down the node's -Z, so aim them with `nodeLookAt` or `nodeSetRotation`. [#lightnew] ==== lightNew [source,text] ---- node = lightNew(type) node = lightNew(type, parent) ---- A new node carrying a light, white at intensity `1`. * `type` -- `LIGHT_DIRECTIONAL` (a sun; only its direction matters), `LIGHT_POINT` (a bulb fading with distance), or `LIGHT_SPOT` (a cone). * `parent` -- optional parent node; the root when omitted. *Returns:* The light's node handle. *Since:* 3.00. *See also:* <>, <>, <>, <>, <> .Example [source,lua] ---- -- Warm key light from the upper left, a cool fill from the right. key = lightNew(LIGHT_DIRECTIONAL) nodeSetPosition(key, -3, 4, 3) nodeLookAt(key, 0, 0, 0) lightSetColor(key, 255, 235, 200) lightSetIntensity(key, 1.4) fill = lightNew(LIGHT_POINT) nodeSetPosition(fill, 3, 1, 2) lightSetColor(fill, 150, 190, 255) lightSetIntensity(fill, 4) lightSetRange(fill, 10) ---- [#lightsetcolor] ==== lightSetColor [source,text] ---- lightSetColor(node, r, g, b) ---- The light's colour, `0` to `255` per channel. *Since:* 3.00. *See also:* <> [#lightsetcone] ==== lightSetCone [source,text] ---- lightSetCone(node, innerDegrees, outerDegrees) ---- For spot lights: full brightness inside `innerDegrees` from the axis, fading to nothing at `outerDegrees`. Defaults `20` and `30`. *Since:* 3.00. *See also:* <> [#lightsetintensity] ==== lightSetIntensity [source,text] ---- lightSetIntensity(node, intensity) ---- Brightness multiplier, default `1`. Point and spot lights fade with the square of the distance, so they usually want values well above `1`. *Since:* 3.00. *See also:* <> [#lightsetrange] ==== lightSetRange [source,text] ---- lightSetRange(node, range) ---- How far a point or spot light reaches before fading to nothing, in world units; `0` (the default) means no limit. *Since:* 3.00. *See also:* <> [#lightsetshadow] ==== lightSetShadow [source,text] ---- lightSetShadow(node, shadow) ---- Makes the light cast shadows from everything opaque in the scene, or stops it. Any of the eight lights may cast, in any mix. A directional or spot light gets one shadow map; a point light gets a cube map, six views, which covers every direction (a bulb inside a room) at six times the cost. *Notes:* Each shadow map costs one extra pass over the scene per frame, and each cube map six; the maps' size is set by `sceneSetShadowSize`. A modern integrated GPU manages eight casters comfortably; a Raspberry Pi wants one or two at `512`. *Since:* 3.00. *See also:* <>, <> .Example [source,lua] ---- -- The sun casts shadows; the lamps only light. sun = lightNew(LIGHT_DIRECTIONAL) nodeSetPosition(sun, 3, 6, 4) nodeLookAt(sun, 0, 0, 0) lightSetShadow(sun, true) ---- [#material] === Material A material is how a mesh looks: a base colour (with alpha), an optional texture from a sprite or a video, and the glTF metallic-roughness parameters. Materials are shared: change one and every mesh using it changes. A node without a material draws white and half rough. Loaded models bring their own materials. [#materialnew] ==== materialNew [source,text] ---- material = materialNew() ---- A new material: white, opaque, roughness `0.5`, no texture. *Returns:* The material handle. *Since:* 3.00. *See also:* <>, <>, <> .Example [source,lua] ---- -- Brushed gold for a trophy, translucent blue glass for its case. gold = materialNew() materialSetColor(gold, 255, 200, 60) materialSetMetallic(gold, 1) materialSetRoughness(gold, 0.35) glass = materialNew() materialSetColor(glass, 80, 160, 255, 90) materialSetBlend(glass, true) materialSetRoughness(glass, 0.1) ---- [#materialdelete] ==== materialDelete [source,text] ---- materialDelete(material) ---- Frees the material. Nodes that used it fall back to the default look. *Since:* 3.00. *See also:* <> [#materialsetblend] ==== materialSetBlend [source,text] ---- materialSetBlend(material, blend) ---- When `true` the material is drawn translucent using the base colour's alpha (and the texture's), after every opaque mesh, farthest first. Leave it `false` for solid surfaces: blended meshes do not write depth. *Since:* 3.00. *See also:* <> [#materialsetcolor] ==== materialSetColor [source,text] ---- materialSetColor(material, r, g, b) materialSetColor(material, r, g, b, a) ---- The base colour, `0` to `255` per channel; `a` defaults to `255`. Multiplies the texture when there is one. *Since:* 3.00. *See also:* <>, <> [#materialsetdoublesided] ==== materialSetDoubleSided [source,text] ---- materialSetDoubleSided(material, doubleSided) ---- Draws both faces of every triangle. Needed for open shapes such as a single `meshPlane` seen from below; costs fill rate on closed ones. *Since:* 3.00. *See also:* <> [#materialsetemissive] ==== materialSetEmissive [source,text] ---- materialSetEmissive(material, r, g, b) ---- Light the surface gives off regardless of lighting, `0` to `255` per channel: screens, lamps, neon. *Since:* 3.00. *See also:* <> [#materialsetmetallic] ==== materialSetMetallic [source,text] ---- materialSetMetallic(material, metallic) ---- `0` for dielectrics (paint, plastic, wood) to `1` for metals, which reflect their own colour. *Since:* 3.00. *See also:* <> [#materialsetroughness] ==== materialSetRoughness [source,text] ---- materialSetRoughness(material, roughness) ---- `0` for a mirror-tight highlight to `1` for matte. Default `0.5`. *Since:* 3.00. *See also:* <> [#materialsettexture] ==== materialSetTexture [source,text] ---- materialSetTexture(material, sprite) materialSetTexture(material) ---- Uses a sprite's image as the base colour texture (a copy is made, so the sprite may be unloaded afterwards). Without a sprite the texture is removed. Texture coordinates come from the mesh; the primitives map an image once across each face. *Since:* 3.00. *See also:* <>, <> .Example [source,lua] ---- -- A crate textured from a sprite the game already loads for the menu. crateArt = spriteLoad(DIR .. "Art/crate.png") crateLook = materialNew() materialSetTexture(crateLook, crateArt) crate = nodeNew() nodeSetMesh(crate, meshBox(1, 1, 1), crateLook) ---- [#materialsetunlit] ==== materialSetUnlit [source,text] ---- materialSetUnlit(material, unlit) ---- Shows the base colour and texture exactly as they are, ignoring lights: for video screens, skyboxes, and flat-shaded looks. *Since:* 3.00. *See also:* <>, <> [#materialsetvideo] ==== materialSetVideo [source,text] ---- materialSetVideo(material) materialSetVideo(material, video) ---- Uses the laserdisc (no `video`) or a video loaded with `videoLoad` as the base colour texture, updated every frame. Usually paired with `materialSetUnlit` so the picture is not tinted by the lights. The video keeps playing whether or not it is also drawn on the overlay. *Notes:* The disc form raises an error in a game without a disc. *Since:* 3.00. *See also:* <>, <>, <> .Example [source,lua] ---- -- A modelled cabinet whose screen shows the laserdisc. cabinet = modelInstance(modelLoad(DIR .. "Models/Cabinet.glb")) screen = materialNew() materialSetVideo(screen) materialSetUnlit(screen, true) nodeSetMesh(nodeFind("Screen", cabinet), meshPlane(0.6, 0.45), screen) ---- [#mesh] === Mesh A mesh is geometry on the GPU: primitives built by the engine or vertex data from your script. A mesh can be shared by any number of nodes. Meshes are centred on the origin unless stated otherwise; the node's transform places them. Texture coordinates put an image once across each primitive face. [#meshbox] ==== meshBox [source,text] ---- mesh = meshBox(width, height, depth) ---- A box with flat-shaded faces, `width` along X, `height` along Y, `depth` along Z. *Returns:* The mesh handle. *Since:* 3.00. *See also:* <> [#meshcone] ==== meshCone [source,text] ---- mesh = meshCone(radius, height) mesh = meshCone(radius, height, segments) ---- A cone standing on Y, its base at `-height / 2` and apex at `+height / 2`. `segments` (default `24`) sets how round it is. *Returns:* The mesh handle. *Since:* 3.00. *See also:* <> [#meshcylinder] ==== meshCylinder [source,text] ---- mesh = meshCylinder(radius, height) mesh = meshCylinder(radius, height, segments) ---- A closed cylinder along Y. `segments` (default `24`) sets how round it is. *Returns:* The mesh handle. *Since:* 3.00. *See also:* <> [#meshdelete] ==== meshDelete [source,text] ---- meshDelete(mesh) ---- Frees the mesh; nodes that used it draw nothing until given another. *Since:* 3.00. *See also:* <> [#meshnew] ==== meshNew [source,text] ---- mesh = meshNew(positions, normals, uvs, indices) ---- Geometry from tables of numbers. * `positions` -- `x, y, z` for every vertex, one after another. * `normals` -- `x, y, z` per vertex, or `nil` to have smooth normals computed from the triangles. * `uvs` -- `u, v` per vertex (`0` to `1`, `v` down the image), or `nil` for none. * `indices` -- three vertex numbers per triangle, counted from `1` as the vertices appear in `positions`, counter-clockwise when seen from outside. *Returns:* The mesh handle. *Since:* 3.00. *See also:* <>, <> .Example [source,lua] ---- -- A flat arrow pointing along +X, seen from above (double sided so it works from below too). arrow = meshNew( { 0, 0, -0.5, 1, 0, 0, 0, 0, 0.5, -0.6, 0, -0.5, -0.6, 0, 0.5 }, nil, nil, { 1, 2, 3, 4, 1, 3, 4, 3, 5 }) ---- [#meshplane] ==== meshPlane [source,text] ---- mesh = meshPlane(width, depth) ---- A flat rectangle in the XZ plane facing +Y, `width` along X and `depth` along Z. Rotate the node `90` degrees about X to stand it up facing the camera. Single sided unless its material is double sided. *Returns:* The mesh handle. *Since:* 3.00. *See also:* <>, <> [#meshsphere] ==== meshSphere [source,text] ---- mesh = meshSphere(radius) mesh = meshSphere(radius, segments) ---- A sphere; `segments` (default `32`) around the equator, half as many pole to pole. *Returns:* The mesh handle. *Since:* 3.00. *See also:* <> [#meshtorus] ==== meshTorus [source,text] ---- mesh = meshTorus(radius, tubeRadius) mesh = meshTorus(radius, tubeRadius, segments) ---- A ring lying in the XZ plane: `radius` to the middle of the tube, `tubeRadius` the tube's own. `segments` (default `32`) around the ring, half as many around the tube. *Returns:* The mesh handle. *Since:* 3.00. *See also:* <> [#model] === Model Models are glTF 2.0 binary files (`.glb`) loaded once and placed any number of times. A model must be self-contained (buffers and images inside the file); one referring to a file beside it is refused. Loading gives a handle; `modelInstance` builds the model's nodes into the scene and returns their root, which every `node*` and `animation*` call then takes. [#modelload] ==== modelLoad [source,text] ---- model = modelLoad(name) ---- Loads a `.glb` through the same lookup as every other asset (game directory, data directory, packed database). Its meshes and materials go to the GPU once; its node tree, skins, and animations are kept for instancing. * `name` -- game-relative path, for example `DIR .. "Models/Ship.glb"`. *Returns:* The model handle. Fails with a message naming the problem: not a glTF file, an external buffer or image, a primitive that could not be uploaded. *Since:* 3.00. *See also:* <>, <>, <> .Example [source,lua] ---- -- Load once, place three times. treeModel = modelLoad(DIR .. "Models/Tree.glb") for i = 1, 3 do local tree = modelInstance(treeModel) nodeSetPosition(tree, i * 3 - 6, 0, -4) nodeSetRotation(tree, 0, i * 70, 0) end ---- [#modeldelete] ==== modelDelete [source,text] ---- modelDelete(model) ---- Frees the model's meshes and materials. Existing instances keep their nodes but draw nothing, and their animations stop. *Since:* 3.00. *See also:* <> [#modelgetanimations] ==== modelGetAnimations [source,text] ---- names = modelGetAnimations(model) ---- The model's animation names in file order, as a table. Unnamed animations give empty strings; they can still be played by number. *Returns:* A table of strings. *Since:* 3.00. *See also:* <> [#modelinstance] ==== modelInstance [source,text] ---- node = modelInstance(model) node = modelInstance(model, parent) ---- Builds the model's node tree under a new root node (under `parent`, or the scene root). The root is a plain node: position, rotate, scale, or parent it like any other. The model's own nodes keep their glTF names (or their mesh's name when unnamed) for `nodeFind`; its lights become scene lights; its skins are attached. A mesh with several materials becomes one child node per part. *Returns:* The instance's root node handle. *Since:* 3.00. *See also:* <>, <>, <> [#mouse] === Mouse Singe supports up to *four* simultaneous mice (or lightguns -- Singe treats them identically). One-mouse mode is the default; switch to many-mouse mode only if your game genuinely needs to tell the devices apart (e.g. two-player lightgun cabinets). * Enable / disable event dispatch with `mouseSetEnabled`. * Poll position with `mouseGetPosition`. Event-driven games get position updates through `onMouseMoved`. * `mouseSetCaptured(true)` grabs the cursor and hides it -- typical for fullscreen gameplay. Digital mouse buttons reach `onInputPressed` (keyboard `MODE_NORMAL`) only when mapped to a switch in `controls.cfg`, using the codes in the `MOUSE_0` / `MOUSE_1` / `MOUSE_2` / `MOUSE_3` tables. The shipped defaults map the left button to `SWITCH_BUTTON3`, the right button to `SWITCH_BUTTON1`, and the middle button to `SWITCH_BUTTON2`. [#mousedisable] ==== mouseDisable [source,text] ---- mouseDisable() ---- Legacy alias for `mouseSetEnabled(false)`, defined in `Framework.singe`. *Since:* 1.18 (RDG) *See also:* <> [#mouseenable] ==== mouseEnable [source,text] ---- mouseEnable() ---- Legacy alias for `mouseSetEnabled(true)`, defined in `Framework.singe`. *Since:* 1.18 (RDG) *See also:* <> [#mousegetposition] ==== mouseGetPosition [source,text] ---- x, y = mouseGetPosition(index) ---- Returns the current overlay-coordinate position of a mouse. Use this for polling instead of tracking `onMouseMoved` events if that's more convenient for your architecture (especially in the threaded model). * `index` -- mouse index, `0` through `3`. Passing an out-of-range index terminates the script. *Returns:* two values, `x` and `y`, in overlay units. *Since:* 2.00 *See also:* <>, the `onMouseMoved` callback .Example [source,lua] ---- -- Draw a crosshair at mouse 0's position every frame. function onOverlayUpdate() local x, y = mouseGetPosition(0) colorForeground(255, 255, 255, 255) overlayCircle(x, y, 10) overlayPlot(x, y) return OVERLAY_UPDATED end ---- [#mousehowmany] ==== mouseHowMany [source,text] ---- count = mouseHowMany() ---- Returns the number of mice currently connected. Capped at 4. Useful for deciding whether a second-player cursor should be drawn. *Returns:* integer, `0` to `4`. *Since:* 1.18 (RDG) *See also:* <> [#mousesetcaptured] ==== mouseSetCaptured [source,text] ---- mouseSetCaptured(grabbed) ---- Grabs or releases the mouse cursor. When grabbed, the cursor is hidden and confined to the Singe window -- the right default for fullscreen gameplay with a targeting reticle. * `grabbed` -- boolean. `true` captures; `false` releases. *Since:* 2.00 *See also:* <> [#mousesetenabled] ==== mouseSetEnabled [source,text] ---- mouseSetEnabled(enabled) ---- Turns mouse event dispatch to your script on or off. When off, the cursor still moves on screen; your callbacks just stop being called. Enabling honors the `--nomouse` command-line flag -- if the user launched Singe with `--nomouse`, the mouse stays disabled. * `enabled` -- boolean. *Since:* 3.00 *See also:* <>, <>, <> [#mousesetmode] ==== mouseSetMode [source,text] ---- ok = mouseSetMode(mode) ---- Switches between single-mouse and many-mouse input modes. * `mode` -- `SINGLE_MOUSE` (`100`) for games that treat all mice as one virtual pointer, or `MANY_MOUSE` (`200`) to distinguish multiple mice. `MOUSE_SINGLE` and `MOUSE_MANY` are accepted as aliases. Switch to `MANY_MOUSE` when you need to tell two lightguns or two mice apart -- typically a two-player cabinet or a cooperative lightgun game. In `SINGLE_MOUSE` mode, `onMouseMoved` reports absolute and relative positions; in `MANY_MOUSE` mode, only relative motion and the source device index are reliable. *Returns:* boolean. `true` if the mode was set successfully. *Since:* 1.18 (RDG) *See also:* <>, the `onMouseMoved` callback [#node] === Node Every object in the scene is a node in one tree. A node has a position, rotation, and scale relative to its parent, may carry a mesh with a material or a light, and can be hidden with everything under it. Node `0` is the root and cannot be deleted or re-parented. Handles are integers; a deleted node's handle is invalid (and may be reused). [#nodenew] ==== nodeNew [source,text] ---- node = nodeNew() node = nodeNew(parent) ---- A new empty node at its parent's origin, unrotated, scale `1`. Empty nodes are useful as pivots and groups. *Returns:* The node handle. *Since:* 3.00. *See also:* <>, <>, <> .Example [source,lua] ---- -- A turret whose barrel pivots on the turret, which turns on the base. base = nodeNew() turret = nodeNew(base) barrel = nodeNew(turret) nodeSetMesh(base, meshCylinder(1, 0.3), steel) nodeSetMesh(turret, meshSphere(0.6), steel) nodeSetMesh(barrel, meshCylinder(0.1, 1.2), steel) nodeSetPosition(barrel, 0, 0, -0.8) nodeSetRotation(barrel, 90, 0, 0) ---- [#nodedelete] ==== nodeDelete [source,text] ---- nodeDelete(node) ---- Removes the node and everything under it. Meshes and materials it used are untouched (they may be shared). *Since:* 3.00. *See also:* <> [#nodefind] ==== nodeFind [source,text] ---- node = nodeFind(name) node = nodeFind(name, root) ---- The first node named `name` below `root` (the scene root when omitted), depth first; `nil` when there is none. Model nodes carry their glTF names. *Returns:* A node handle or `nil`. *Since:* 3.00. *See also:* <>, <> [#nodegetchildren] ==== nodeGetChildren [source,text] ---- children = nodeGetChildren(node) ---- The node's direct children as a table of handles, in the order they were added. *Returns:* A table. *Since:* 3.00. *See also:* <> [#nodegetmorph] ==== nodeGetMorph [source,text] ---- weight = nodeGetMorph(node, nameOrIndex) ---- The current weight of one of the mesh's morph targets. *Returns:* A number. *Since:* 3.00. *See also:* <>, <> [#nodegetmorphs] ==== nodeGetMorphs [source,text] ---- names = nodeGetMorphs(node) ---- The morph targets of the node's mesh, in the order the file stores them, as a table of names. Exporters that write no names give empty strings; those targets can still be set by number. *Returns:* A table of strings. *Since:* 3.00. *See also:* <> [#nodegetname] ==== nodeGetName [source,text] ---- name = nodeGetName(node) ---- The node's name, or an empty string. *Returns:* A string. *Since:* 3.00. *See also:* <> [#nodegetparent] ==== nodeGetParent [source,text] ---- parent = nodeGetParent(node) ---- The parent's handle, or `nil` for the root. *Returns:* A node handle or `nil`. *Since:* 3.00. *See also:* <> [#nodegetposition] ==== nodeGetPosition [source,text] ---- x, y, z = nodeGetPosition(node) ---- The node's position relative to its parent. *Returns:* Three numbers. *Since:* 3.00. *See also:* <>, <> [#nodegetquaternion] ==== nodeGetQuaternion [source,text] ---- x, y, z, w = nodeGetQuaternion(node) ---- The node's rotation as a quaternion, the form models store. *Returns:* Four numbers. *Since:* 3.00. *See also:* <>, <> [#nodegetrotation] ==== nodeGetRotation [source,text] ---- x, y, z = nodeGetRotation(node) ---- The node's rotation as Euler angles in degrees, relative to its parent. *Returns:* Three numbers. *Since:* 3.00. *See also:* <> [#nodegetscale] ==== nodeGetScale [source,text] ---- x, y, z = nodeGetScale(node) ---- The node's scale per axis. *Returns:* Three numbers. *Since:* 3.00. *See also:* <> [#nodegetworldposition] ==== nodeGetWorldPosition [source,text] ---- x, y, z = nodeGetWorldPosition(node) ---- Where the node's origin is in world space, as of the last rendered frame (changes made this frame show after the next render). *Returns:* Three numbers. *Since:* 3.00. *See also:* <>, <> [#nodelookat] ==== nodeLookAt [source,text] ---- nodeLookAt(node, x, y, z) ---- Rotates the node so its -Z points at a world position, keeping +Y up as far as possible. What cameras and lights want; also handy for a turret tracking a target. *Since:* 3.00. *See also:* <>, <> [#nodemove] ==== nodeMove [source,text] ---- nodeMove(node, dx, dy, dz) ---- Moves the node along its own axes: `nodeMove(ship, 0, 0, -0.1)` drives it forward wherever it is pointing. *Since:* 3.00. *See also:* <>, <> [#noderotate] ==== nodeRotate [source,text] ---- nodeRotate(node, dx, dy, dz) ---- Turns the node by the given degrees about its own axes. *Since:* 3.00. *See also:* <>, <> [#nodesetmesh] ==== nodeSetMesh [source,text] ---- nodeSetMesh(node, mesh) nodeSetMesh(node, mesh, material) ---- Gives the node a mesh to draw, optionally with a material (default look otherwise). Any node can draw, including a model's nodes. *Since:* 3.00. *See also:* <>, <>, <> [#nodesetmorph] ==== nodeSetMorph [source,text] ---- nodeSetMorph(node, nameOrIndex, weight) ---- How much of a morph target (blend shape) the node's mesh shows, usually `0` to `1`; several targets add up. The node is the one carrying the mesh, which for a model is a child of the instance root (`nodeFind` by the mesh's name, or `nodeGetChildren`). An animation with a weights channel overrides what you set while it plays. *Notes:* Up to eight targets with non-zero weights apply per mesh in a frame, the strongest if there are more. *Since:* 3.00. *See also:* <>, <>, <> .Example [source,lua] ---- -- Lip sync from a table of mouth openings per disc frame. face = nodeFind("Head", hero) function onOverlayUpdate() nodeSetMorph(face, "mouthOpen", mouthByFrame[discGetFrame()] or 0) return OVERLAY_UPDATED end ---- [#nodesetname] ==== nodeSetName [source,text] ---- nodeSetName(node, name) ---- Names the node for `nodeFind`. *Since:* 3.00. *See also:* <> [#nodesetparent] ==== nodeSetParent [source,text] ---- nodeSetParent(node, parent) ---- Moves the node (and its subtree) under another parent, keeping its local position, rotation, and scale, so it now moves with the new parent. A node cannot go under itself or its own descendants. *Since:* 3.00. *See also:* <>, <> [#nodesetposition] ==== nodeSetPosition [source,text] ---- nodeSetPosition(node, x, y, z) ---- Places the node relative to its parent. *Since:* 3.00. *See also:* <>, <> [#nodesetquaternion] ==== nodeSetQuaternion [source,text] ---- nodeSetQuaternion(node, x, y, z, w) ---- Sets the rotation as a quaternion, for values that came from a model or your own maths. *Since:* 3.00. *See also:* <> [#nodesetrotation] ==== nodeSetRotation [source,text] ---- nodeSetRotation(node, x, y, z) ---- Sets the rotation as Euler angles in degrees: turned about Y first, then tilted about X, then rolled about Z. *Since:* 3.00. *See also:* <>, <>, <> [#nodesetscale] ==== nodeSetScale [source,text] ---- nodeSetScale(node, scale) nodeSetScale(node, x, y, z) ---- Scales the node and everything under it, uniformly or per axis. Loaded models are often in centimetres or metres; scale their root to fit your scene. *Since:* 3.00. *See also:* <> [#nodesetvisible] ==== nodeSetVisible [source,text] ---- nodeSetVisible(node, visible) ---- Hides or shows the node and everything under it. Hidden lights stop shining. *Since:* 3.00. *See also:* <> [#overlay] === Overlay The overlay is a transparent 32-bit RGBA surface composited over the disc/video every frame. Everything your script draws -- shapes, text, sprites, videos -- goes onto the overlay, not directly to the screen. Overlay coordinates are *not* screen pixels. They are in the resolution set by `overlaySetResolution`, which the engine scales to the window size at display time. By convention most games set the overlay to match the disc's native resolution. All drawing must happen inside `onOverlayUpdate`. Calls from other callbacks will not be reflected on screen and may corrupt the overlay surface. Two things that behave differently from the rest of the overlay API: * *`overlayBox` draws outlines only.* There is no filled-rectangle primitive. Build filled regions out of sprites or pre-rendered images. * *`overlayPrint` uses character-cell coordinates*, not overlay units. The `x` and `y` arguments are multiplied by the built-in console font's cell size internally -- so `overlayPrint(0, 0, "HI")` prints at the top-left in character cell `(0, 0)`, not pixel `(0, 0)`. Every other overlay function uses pixel coordinates. All primitives draw in the current <>. `overlayClear` fills with the current <>. [#overlaybox] ==== overlayBox [source,text] ---- overlayBox(x1, y1, x2, y2) ---- Draws the outline of an axis-aligned rectangle. *Outline only -- there is no fill.* * `x1`, `y1`, `x2`, `y2` -- corners of the rectangle in overlay units. *Since:* 2.00 *See also:* <>, <> [#overlaycircle] ==== overlayCircle [source,text] ---- overlayCircle(x, y, radius) ---- Draws the outline of a circle using the midpoint algorithm. * `x`, `y` -- center of the circle in overlay units. * `radius` -- radius in overlay units. *Since:* 2.00 *See also:* <>, <> [#overlayclear] ==== overlayClear [source,text] ---- overlayClear() ---- Fills the entire overlay with the current background color (`colorBackground`). Typically called first in `onOverlayUpdate` to wipe last frame's drawing -- unless your game composites over a dynamic video and prefers to redraw only dirty regions. Note: because `colorBackground` defaults to transparent (`a = 0`), `overlayClear()` without first setting a solid background is the standard way to erase the overlay back to "invisible." *Since:* 1.x *See also:* <> .Example [source,lua] ---- function onOverlayUpdate() overlayClear() -- Erase previous frame (back to transparent). drawHUD() drawPlayerIcon() return OVERLAY_UPDATED end ---- [#overlayellipse] ==== overlayEllipse [source,text] ---- overlayEllipse(x1, y1, x2, y2) ---- Draws the outline of an axis-aligned ellipse inscribed in the given bounding rectangle. * `x1`, `y1`, `x2`, `y2` -- corners of the bounding box. *Since:* 2.00 *See also:* <> [#overlaygetheight] ==== overlayGetHeight [source,text] ---- height = overlayGetHeight() ---- Returns the height of the overlay surface in overlay units (whatever `overlaySetResolution` set it to). *Returns:* integer height. *Since:* 1.x *See also:* <>, <> [#overlaygetwidth] ==== overlayGetWidth [source,text] ---- width = overlayGetWidth() ---- Returns the width of the overlay surface in overlay units. *Returns:* integer width. *Since:* 1.x *See also:* <>, <> [#overlayline] ==== overlayLine [source,text] ---- overlayLine(x1, y1, x2, y2) ---- Draws a line between two points. * `x1`, `y1` -- first endpoint. * `x2`, `y2` -- second endpoint. *Since:* 2.00 *See also:* <>, <> [#overlayplot] ==== overlayPlot [source,text] ---- overlayPlot(x, y) ---- Plots a single pixel in the foreground color. Use sparingly -- plotting many individual pixels in Lua is slow; consider a sprite or a line for anything with structure. * `x`, `y` -- pixel coordinates. *Since:* 2.00 *See also:* <> [#overlayprint] ==== overlayPrint [source,text] ---- overlayPrint(column, row, text) ---- Prints text using the engine's built-in fixed-width console font. *Coordinates are character cells, not pixels* -- `column = 0, row = 0` is the top-left character position, `column = 1` is one font-width to the right. This is different from every other overlay function. * `column`, `row` -- character cell coordinates. * `text` -- the string to print. Text that would extend past the right edge of the overlay is truncated silently. Rendered in the current foreground color on a background filled with the current background color (so `colorBackground(0, 0, 0, 0)` gives you background-transparent text). *Since:* 1.x *See also:* <> (for proportional TrueType text), <>, <> .Example [source,lua] ---- -- Quick dev HUD using the built-in font. function onOverlayUpdate() colorBackground(0, 0, 0, 0) -- Transparent background. colorForeground(255, 255, 0, 255) overlayPrint(0, 0, "FRAME: " .. discGetFrame()) overlayPrint(0, 1, "STATE: " .. discGetState()) return OVERLAY_UPDATED end ---- [#overlaysetresolution] ==== overlaySetResolution [source,text] ---- overlaySetResolution(width, height) ---- Resizes the overlay surface. All previously drawn content is discarded. Call this once at startup to pick the coordinate space your game will draw in; don't change it during gameplay. * `width`, `height` -- overlay dimensions in pixels. Higher resolutions give finer control over sprite placement and sharper text at the cost of more per-frame compositing work. Matching the disc's native resolution (via `discGetWidth` / `discGetHeight`) is a reasonable default. *Since:* 2.00 *See also:* <>, <>, <> .Example [source,lua] ---- -- Use the disc's native resolution as the overlay coordinate system. overlaySetResolution(discGetWidth(), discGetHeight()) ---- [#physicsref] === Physics World-level calls. Physics is always initialised with the engine; there is nothing to enable before the first `bodyNew`. See <> in Game Development for how bodies, nodes and the frame fit together. [#physicsraycast] ==== physicsRaycast [source,text] ---- node, hx, hy, hz, nx, ny, nz = physicsRaycast(x, y, z, dx, dy, dz) node, hx, hy, hz, nx, ny, nz = physicsRaycast(x, y, z, dx, dy, dz, maxDistance) ---- The first body along a ray from `(x, y, z)` in the direction `(dx, dy, dz)` (any length), within `maxDistance` (default `1000`). Triggers are hit too. *Returns:* The body's node, the hit point and the surface normal there, or `nil` when nothing is hit. *Since:* 3.00. *See also:* <>, <> [#physicsset2d] ==== physicsSet2D [source,text] ---- physicsSet2D(planar) ---- Bodies made from now on move only in X and Y and turn only about Z: a 2D game's world, usually in overlay coordinates with gravity pointing +Y. Bodies already made keep their freedom. Works without a GPU, the Raspberry Pi 3 included. *Since:* 3.00. *See also:* <>, <> [#physicssetenabled] ==== physicsSetEnabled [source,text] ---- physicsSetEnabled(enabled) ---- Pauses and resumes the simulation on its own; the game's pause does the same. Bodies hold still and keep their velocities. *Since:* 3.00. *See also:* <> [#physicssetgravity] ==== physicsSetGravity [source,text] ---- physicsSetGravity(x, y, z) ---- The acceleration every dynamic body feels, in units per second squared. Default `0, -9.81, 0`; `0, 0, 0` for space. *Since:* 3.00. *See also:* <> [#scene] === Scene The scene is the 3D layer drawn between the disc video and the overlay. It is off until `sceneEnable(true)`, is sized like the overlay, and clears to `sceneSetBackground` every frame. These calls also bridge world space and overlay coordinates. [#sceneenable] ==== sceneEnable [source,text] ---- sceneEnable(enabled) ---- Turns the 3D layer on or off. Raises an error on a machine with no usable GPU (see <>); check for that in `onShutdown`-safe code paths if you want to fall back to a 2D presentation. *Since:* 3.00. *See also:* <> .Example [source,lua] ---- -- Fall back to the plain overlay when 3D is unavailable. has3d = pcall(sceneEnable, true) if not has3d then debugPrint("No 3D here; using the 2D attract mode.") end ---- [#scenegetsize] ==== sceneGetSize [source,text] ---- width, height = sceneGetSize() ---- The layer's size, which is the overlay's resolution: the space `sceneProject` and `sceneUnproject` work in. *Returns:* Two integers. *Since:* 3.00. *See also:* <> [#sceneproject] ==== sceneProject [source,text] ---- x, y, depth, inFront = sceneProject(wx, wy, wz) ---- Where a world point falls in overlay coordinates, using the camera of the last rendered frame. `depth` is `0` at the near plane and `1` at the far plane; `inFront` is `false` when the point is behind the camera (and `x`, `y` meaningless). *Returns:* Two numbers, a number, and a boolean. *Since:* 3.00. *See also:* <>, <> .Example [source,lua] ---- -- A name floating above a character. function onOverlayUpdate() local x, y, depth, visible = sceneProject(nodeGetWorldPosition(hero)) if visible then fontPrint(x - 30, y - 80, "DIRK") end return OVERLAY_UPDATED end ---- [#sceneunproject] ==== sceneUnproject [source,text] ---- x, y, z = sceneUnproject(sx, sy, distance) ---- The world point `distance` units along the ray through an overlay pixel, using the camera of the last rendered frame. Two distances give a ray for picking; one places an object "where the player clicked". *Returns:* Three numbers. *Since:* 3.00. *See also:* <>, <> .Example [source,lua] ---- -- Drop a marker 8 units into the scene under the mouse. local mx, my = mouseGetPosition(0) local x, y, z = sceneUnproject(mx, my, 8) nodeSetPosition(marker, x, y, z) ---- [#scenesetambient] ==== sceneSetAmbient [source,text] ---- sceneSetAmbient(r, g, b) ---- Light from everywhere, `0` to `255` per channel (default a dim grey). Lifts shadowed sides so they are not black. *Since:* 3.00. *See also:* <> [#scenesetantialias] ==== sceneSetAntialias [source,text] ---- sceneSetAntialias(antialias) ---- 4x multisampling, on by default wherever the GPU offers it. Turn it off to buy speed on a Raspberry Pi. *Since:* 3.00. *See also:* <> [#scenesetbackground] ==== sceneSetBackground [source,text] ---- sceneSetBackground(r, g, b) sceneSetBackground(r, g, b, a) ---- The colour the layer clears to each frame. `a` below `255` lets the video show through; `0` (the usual choice for a game with a disc) makes the background fully transparent. Default black, transparent. *Since:* 3.00. *See also:* <> [#scenesetshadowsize] ==== sceneSetShadowSize [source,text] ---- sceneSetShadowSize(size) ---- Texels per side of every shadow map and cube face, `256` to `4096` (default `1024`). Larger is sharper and slower; `512` is a fair choice on a Raspberry Pi. *Since:* 3.00. *See also:* <>, <> [#script] === Script WARNING: These functions let a Singe script hand control to another Singe script -- the mechanism behind the built-in `Menu.singe` launcher. Game developers should not reach for these; build your game as a single script and let the menu system handle chaining. They are documented here for completeness and for anyone maintaining the menu itself. [#scriptexecute] ==== scriptExecute [source,text] ---- scriptExecute(config) ---- Replaces the currently running script with a new one described by a games.dat-style table. The current script exits; control does not return. * `config` -- a Lua table with the same fields as a `GAMES[]` entry in `games.dat` (at minimum `SCRIPT`, and usually `VIDEO`, `DATA`, and resolution fields). *Since:* 2.00 [#scriptpush] ==== scriptPush [source,text] ---- scriptPush(config) ---- Starts a new script but remembers the current one on an internal stack. When the new script calls `singeQuit`, the previously running script resumes from startup (it is re-run, not unfrozen in place). This is how `Menu.singe` launches a game and comes back to the menu when the game exits. * `config` -- a Lua table describing the script to launch (as for `scriptExecute`). *Since:* 2.00 [#singe] === Singe Functions in the `singe*` namespace control the engine itself -- window properties, screenshots, the pause system, quitting, and a handful of useful paths and flags exposed from the command line and `games.dat`. [#singedisablepausekey] ==== singeDisablePauseKey [source,text] ---- singeDisablePauseKey() ---- Legacy alias for `singeSetPauseKeyEnabled(false)`, defined in `Framework.singe`. *Since:* 1.18 (RDG) *See also:* <> [#singeenablepausekey] ==== singeEnablePauseKey [source,text] ---- singeEnablePauseKey() ---- Legacy alias for `singeSetPauseKeyEnabled(true)`, defined in `Framework.singe`. *Since:* 1.18 (RDG) *See also:* <> [#singegetaudiocalibration] ==== singeGetAudioCalibration [source,text] ---- milliseconds = singeGetAudioCalibration() ---- Returns the per-machine audio delay saved by the menu's calibration screen (see <>), in milliseconds. Zero when the machine has not been calibrated. *Since:* 3.00 *See also:* <>, <> [#singegetaudiodelay] ==== singeGetAudioDelay [source,text] ---- milliseconds = singeGetAudioDelay() ---- Returns the audio delay compensation currently in effect, in milliseconds, as set by `--audiodelay`, the `AUDIO_DELAY` key in `games.dat`, or <>. Zero when none has been set. *Since:* 3.00 *See also:* <>, <> [#singegetaudiolatency] ==== singeGetAudioLatency [source,text] ---- milliseconds = singeGetAudioLatency() ---- Returns the audio device queue the engine measured at startup, in milliseconds: the time between handing audio to the mixer and hearing it, before any calibration or per-game delay. Useful for diagnostics and for calibration screens. *Since:* 3.00 *See also:* <> [#singegetdatapath] ==== singeGetDataPath [source,text] ---- path = singeGetDataPath() ---- Returns the absolute path to the writable data directory for the running game (the directory specified by the `DATA` field in `games.dat`, resolved under Singe's data root). Use this for save games, high scores, and any other per-game persistent state. *Returns:* string path, ending in a platform-appropriate separator. *Since:* 2.00 *See also:* <> .Example [source,lua] ---- -- Read and write high scores alongside the game's data. local highScoreFile = singeGetDataPath() .. "highscores.json" ---- [#singegetheight] ==== singeGetHeight [source,text] ---- height = singeGetHeight() ---- Returns the height of the Singe window in *screen pixels*, not overlay units. Useful if you want to know the actual display size (for a configuration screen, or to letterbox the overlay). *Returns:* integer pixel height. *Since:* 1.x *See also:* <>, <> [#singegetpauseflag] ==== singeGetPauseFlag [source,text] ---- paused = singeGetPauseFlag() ---- Returns the engine's pause flag: `true` while the player has paused with the pause key, or while the script has set it with `singeSetPauseFlag`. This is separate from whether the disc is sitting on a paused frame (`discGetState`); read it when you want to know whether a pause was deliberately requested. Note that while the pause key holds the game frozen your script is not running, so from inside a callback the flag can only be seen as `true` after your own `singeSetPauseFlag(true)`. *Returns:* boolean. *Since:* 1.x (RDG) *See also:* <> [#singegetscriptpath] ==== singeGetScriptPath [source,text] ---- path = singeGetScriptPath() ---- Returns the absolute path of the currently running script file. `Framework.singe` derives the global `DIR` variable from this -- you will usually use `DIR` directly rather than calling `singeGetScriptPath` yourself. *Returns:* string path. *Since:* 1.15 (RDG) *See also:* <>, the `DIR` global [#singegetticks] ==== singeGetTicks [source,text] ---- milliseconds = singeGetTicks() ---- Returns the wall clock in milliseconds since the engine started. This is the clock to use for timers, debounces, and animation. Lua's `os.clock()` normally measures processor time, which drifts whenever the engine idles; because existing games use it as a wall clock, Singe replaces it with one that returns wall seconds since the engine started, so either call is safe. *Since:* 3.00 [#singegetwidth] ==== singeGetWidth [source,text] ---- width = singeGetWidth() ---- Returns the width of the Singe window in screen pixels. Mirror of `singeGetHeight`. *Returns:* integer pixel width. *Since:* 1.x *See also:* <>, <> [#singequit] ==== singeQuit [source,text] ---- singeQuit() ---- Exits the currently running script. If the script was pushed via `scriptPush` (typically, launched from the menu), control returns to the calling script; otherwise Singe itself terminates. Call this from `onInputPressed` or a game-over flow when the user chooses to exit. Do not call it from `onShutdown` -- that callback is already the exit path. *Since:* 1.x (RDG) .Example [source,lua] ---- function onInputPressed(what) if what == SWITCH_QUIT then singeQuit() end end ---- [#singescreenshot] ==== singeScreenshot [source,text] ---- singeScreenshot() ---- Captures a PNG screenshot of the currently composited frame (disc + overlay) and writes it under the Singe screenshots directory. The capture is asynchronous; the file is written on the next rendering pass. *Since:* 1.x [#singesetaudiocalibration] ==== singeSetAudioCalibration [source,text] ---- singeSetAudioCalibration(milliseconds) ---- Sets the per-machine audio delay, applies it immediately, and saves it to `audio.cfg` in the data root so every game on the machine uses it. This is what the menu's calibration screen calls; a game with its own service menu may call it too. Values outside `-1000` to `1000` terminate the script. * `milliseconds` -- integer, positive when the audio is heard late. *Since:* 3.00 *See also:* <>, <> [#singesetaudiodelay] ==== singeSetAudioDelay [source,text] ---- singeSetAudioDelay(milliseconds) ---- Tells the engine how much later (positive) or earlier (negative) the audio is heard than it can measure, so video presentation is shifted to match. Applies to the disc and to every video the script plays. Use it to offer a sync adjustment in a service menu and save the value with the game's other settings; a value outside `-1000` to `1000` terminates the script. * `milliseconds` -- integer, positive when the audio is late. *Since:* 3.00 *See also:* <>, <> [#singesetgamename] ==== singeSetGameName [source,text] ---- singeSetGameName(title) ---- Sets the OS window title for the Singe window. Call this once at startup with your game's display name. * `title` -- any string. *Since:* 1.15 (RDG) .Example [source,lua] ---- singeSetGameName("Dragon's Lair Remastered") ---- [#singesetpauseflag] ==== singeSetPauseFlag [source,text] ---- singeSetPauseFlag(paused) ---- Sets the engine's pause flag from the script. `true` pauses the disc, every loaded video, and every sound channel, and remembers which of them were playing; `false` resumes exactly those. Unlike the pause key, this does not freeze your script: callbacks keep firing so the script can decide when to clear the flag again. Use it for story freezes and menus in games that manage their own pause; disable the engine's pause key (`singeSetPauseKeyEnabled(false)`) so the two do not fight. * `paused` -- boolean. *Since:* 1.x (RDG) *See also:* <>, <> [#singesetpausekeyenabled] ==== singeSetPauseKeyEnabled [source,text] ---- singeSetPauseKeyEnabled(enabled) ---- Chooses who owns the pause key mapped to `INPUT_PAUSE` in `controls.cfg`. While enabled (the default), the engine owns it: pressing the key freezes the game completely (see <>) and the script never receives `SWITCH_PAUSE`. While disabled, the engine ignores the key and delivers `SWITCH_PAUSE` to `onInputPressed` / `onInputReleased` like any other switch, so a game can run its own pause logic with `singeSetPauseFlag`. * `enabled` -- boolean. *Since:* 3.00 *See also:* <>, <>, <> [#singeversion] ==== singeVersion [source,text] ---- version = singeVersion() ---- Returns the running Singe engine version as a floating-point number (e.g. `2.10`). Script compatibility shims often branch on this -- look at `Framework.singe` for the pattern. *Returns:* number. *Since:* 1.x (RDG) .Example [source,lua] ---- if singeVersion() < 2.10 then error("This game requires Singe 2.10 or newer.") end ---- [#singewantscrosshairs] ==== singeWantsCrosshairs [source,text] ---- wants = singeWantsCrosshairs() ---- Returns `true` if the user launched Singe without the `--nocrosshair` flag. Your game should draw its targeting reticle only when this returns `true`; this lets users with physical Sinden-style lightguns (which display their own on-screen reticle in hardware) turn the software crosshair off. *Returns:* boolean. *Since:* 2.00 .Example [source,lua] ---- function onOverlayUpdate() drawScene() if singeWantsCrosshairs() then local x, y = mouseGetPosition(0) drawCrosshair(x, y) end return OVERLAY_UPDATED end ---- [#sound] === Sound Sound effects are short audio clips (typical format: WAV) loaded into memory and played through SDL_mixer. Distinct from the disc's own audio (which rides with the video) and from `video*`-asset audio. *Handles and channels -- the single most confusing thing about this API:* * `soundLoad` returns a *sound handle*. Keep it; pass it to `soundPlay` and `soundUnload`. * `soundPlay` returns a *channel number* -- the mixer slot the sound is playing in. This is *not* the same value as the sound handle. * `soundPause`, `soundResume`, `soundStop`, `soundIsPlaying` all take the *channel number* returned by `soundPlay`, not the sound handle. In short: the handle identifies the *data* (the loaded clip); the channel identifies an *instance* of that clip playing right now. One sound can be playing in multiple channels simultaneously (N overlapping footsteps, etc.). There is a master effects volume (`soundSetVolume`) that applies to all channels. [#soundfullstop] ==== soundFullStop [source,text] ---- soundFullStop() ---- Stops every currently playing sound channel at once. Does not unload any sounds. *Since:* 1.16 *See also:* <> [#soundgetvolume] ==== soundGetVolume [source,text] ---- volume = soundGetVolume() ---- Returns the current master effects volume, `0` through `63`. *Returns:* integer. *Since:* 1.16 *See also:* <> [#soundisplaying] ==== soundIsPlaying [source,text] ---- playing = soundIsPlaying(channel) ---- Tests whether a specific mixer channel is currently playing audio. * `channel` -- the channel number returned by `soundPlay`. *Returns:* boolean. *Since:* 1.16 (RDG) *See also:* <> .Example [source,lua] ---- -- Don't re-trigger the alarm while a previous instance is still playing. if not soundIsPlaying(alarmChannel) then alarmChannel = soundPlay(alarmSound) end ---- [#soundload] ==== soundLoad [source,text] ---- id = soundLoad(filename) ---- Loads an audio clip from disk. * `filename` -- path to a sound file (WAV is safest; other SDL_mixer-supported formats work but are not officially supported). *Returns:* integer sound handle (a data handle, *not* a playing-channel number). *Since:* 1.x *See also:* <>, <> .Example [source,lua] ---- gunshot = soundLoad(DIR .. "sounds/shot.wav") miss = soundLoad(DIR .. "sounds/miss.wav") ---- [#soundpause] ==== soundPause [source,text] ---- wasPlaying = soundPause(channel) ---- Pauses a playing channel. The channel keeps its position; call `soundResume` to continue. * `channel` -- channel number from `soundPlay`. *Returns:* boolean indicating whether the channel was playing at the moment of the call. *Since:* 1.16 (RDG) *See also:* <> [#soundplay] ==== soundPlay [source,text] ---- channel = soundPlay(id) ---- Plays a loaded sound on the next available mixer channel. * `id` -- sound handle from `soundLoad`. *Returns:* integer channel number you will pass to `soundPause`, `soundResume`, `soundStop`, and `soundIsPlaying`. Returns `-1` (`SOUND_ERROR_INVALID`) if all channels are in use -- check for this if your game can fire lots of concurrent sounds. *Since:* 1.x *See also:* <>, <> .Example [source,lua] ---- -- Fire and remember the channel so we can cut it off early if needed. shotChannel = soundPlay(gunshot) if shotChannel < 0 then debugPrint("All sound channels in use -- dropping gunshot") end ---- [#soundresume] ==== soundResume [source,text] ---- wasPaused = soundResume(channel) ---- Resumes a paused channel from the position where `soundPause` left it. * `channel` -- channel number from `soundPlay`. *Returns:* boolean indicating whether the channel was paused at the moment of the call. *Since:* 1.16 (RDG) *See also:* <> [#soundsetvolume] ==== soundSetVolume [source,text] ---- soundSetVolume(volume) ---- Sets the master effects volume. Applied immediately to every currently playing channel. * `volume` -- integer `0` (silent) through `63` (loudest). Out-of-range values terminate the script. *Since:* 1.16 *See also:* <> [#soundstop] ==== soundStop [source,text] ---- wasPlaying = soundStop(channel) ---- Halts a channel immediately. The channel is released back to the pool and the previous channel number is invalidated -- discard it on the Lua side. * `channel` -- channel number from `soundPlay`. *Returns:* boolean indicating whether the channel was playing when the call was made. *Since:* 1.x (RDG) *See also:* <> [#soundunload] ==== soundUnload [source,text] ---- soundUnload(id) ---- Frees a loaded sound. Stop any channels playing this sound before unloading; unloading audio data that the mixer is still reading from is undefined behavior. * `id` -- sound handle from `soundLoad` (*not* a channel number). *Since:* 2.00 *See also:* <> [#sprite] === Sprite Sprites are 2D bitmap assets loaded from disk. They support: * *Any SDL_image-supported format* -- PNG, JPG, BMP, GIF, WEBP, and more. * *Animation* when the source file is an animated GIF or WEBP. Single-frame images are treated as static sprites; animation functions still work on them but have no effect. * *Per-sprite rotation and scaling* with optional bilinear filtering. * *Transparency via color-key* -- pixels whose raw value is `0` in the image's own pixel format are drawn as fully transparent: palette index `0` for indexed images, pure black for 24-bit RGB images, and fully transparent black for images with an alpha channel. Alpha channels are honored as well, so a PNG with real transparency needs no color key. Every sprite you load must eventually be released with `spriteUnload`, or from `onShutdown` for assets that live for the lifetime of the game. [#spritedraw] ==== spriteDraw [source,text] ---- spriteDraw(id, x, y) -- Natural size, top-left anchor spriteDraw(id, x, y, centered) -- Natural size, centered anchor spriteDraw(id, x, y, x2, y2) -- Stretched to rectangle, top-left anchor spriteDraw(id, x, y, x2, y2, centered) -- Stretched to rectangle, centered anchor ---- Draws a previously loaded sprite onto the overlay. Must be called from `onOverlayUpdate`. * `x`, `y` -- anchor coordinates in overlay units. * `x2`, `y2` -- opposite corner for the stretched forms. The sprite is scaled to fit `(x, y)`-`(x2, y2)`. * `centered` -- boolean. When `true`, `(x, y)` (and the center of the `(x, y)`-`(x2, y2)` rectangle for the stretched forms) is treated as the *center* of the sprite. When `false`, it is the top-left corner. Using `centered = true` is strongly recommended when the sprite has been rotated with `spriteRotate`, because rotated sprites change size and a top-left anchor will appear to drift as the sprite spins. * `id` -- sprite handle returned by `spriteLoad` or `fontToSprite`. Animation advances automatically each frame the sprite is drawn, based on real time elapsed since the previous `spriteDraw` of that sprite. A sprite that is not drawn does not advance its animation. *Since:* 1.x. Centered and stretched-centered forms added in 2.10. *See also:* <>, <>, <> .Example [source,lua] ---- -- Draw a menu cursor that rotates slowly as a visual flourish. cursor = spriteLoad(DIR .. "cursor.png") cursorAngle = 0 function onOverlayUpdate() cursorAngle = (cursorAngle + 2) % 360 spriteRotate(cursor, cursorAngle) spriteDraw(cursor, selectionX, selectionY, true) -- Center-anchored. return OVERLAY_UPDATED end ---- [#spritegetframe] ==== spriteGetFrame [source,text] ---- frame = spriteGetFrame(id) ---- Returns the index of the currently displayed frame of an animated sprite. Frames are numbered starting at `0`. * `id` -- sprite handle. *Returns:* integer frame index, or `0` for a static sprite that has no animation. *Since:* 2.10 *See also:* <>, <> .Example [source,lua] ---- -- Fire the gun only on the frame where the muzzle flash is visible. if spriteGetFrame(muzzleFlash) == 2 and not shotFired then soundPlay(gunshot) shotFired = true end ---- [#spritegetheight] ==== spriteGetHeight [source,text] ---- h = spriteGetHeight(id) ---- Returns the current height of the sprite in overlay units. This reflects any scaling or rotation previously applied via `spriteScale`, `spriteRotate`, or `spriteRotateAndScale` -- it is the *drawn* height, not the height of the source image. * `id` -- sprite handle. *Returns:* integer height. *Since:* 2.00 *See also:* <>, <> [#spritegetwidth] ==== spriteGetWidth [source,text] ---- w = spriteGetWidth(id) ---- Returns the current width of the sprite in overlay units. Reflects any scaling or rotation currently applied, like `spriteGetHeight`. * `id` -- sprite handle. *Returns:* integer width. *Since:* 2.00 *See also:* <>, <> .Example [source,lua] ---- -- Center a cabinet image inside a fixed drawing area. function drawCabinet() local x = cabinetX + (cabinetW - spriteGetWidth(cabinetSprite)) * 0.5 local y = cabinetY + (cabinetH - spriteGetHeight(cabinetSprite)) * 0.5 spriteDraw(cabinetSprite, x, y) end ---- [#spriteisplaying] ==== spriteIsPlaying [source,text] ---- playing = spriteIsPlaying(id) ---- Reports whether an animated sprite is currently advancing its animation. * `id` -- sprite handle. *Returns:* boolean. `true` if the animation is playing, `false` if it has been paused (`spritePause`), has finished a non-looping animation, or the sprite has no animation data at all. *Since:* 2.10 *See also:* <>, <>, <> [#spriteload] ==== spriteLoad [source,text] ---- id = spriteLoad(filename) ---- Loads a bitmap from disk and returns an opaque integer handle. The file is resolved relative to the working directory Singe launched from -- in practice, prepend `DIR` (the running script's directory) for files shipped with your game. * `filename` -- path to a PNG, JPG, BMP, GIF, WEBP, or other SDL_image-supported format. Animated GIF and WEBP files load as animated sprites; all other formats load as static sprites. *Returns:* integer sprite handle. Store it; you will pass it to every other `sprite*` function. *Notes:* * There is no `nil` return on failure. If the file cannot be read or decoded, the script is terminated with an error. * Loading is synchronous and can take noticeable time for large images. In the threaded model, preload sprites before entering your main loop. *Since:* 1.x. Animated GIF/WEBP support added in 2.10. *See also:* <>, <> *(Font section)* .Example [source,lua] ---- -- Preload all the game's sprites once at startup. cabinetSprite = spriteLoad(DIR .. "cabinet.png") marqueeSprite = spriteLoad(DIR .. "marquee.png") explosion = spriteLoad(DIR .. "explosion.gif") -- Animated. spriteLoop(explosion, false) -- Play once, do not loop. ---- [#spriteloop] ==== spriteLoop [source,text] ---- spriteLoop(id, shouldLoop) ---- Sets whether an animated sprite loops when it reaches the last frame. The default for newly loaded animated sprites is to loop. * `id` -- sprite handle. * `shouldLoop` -- boolean. `true` restarts from frame `0`; `false` stops on the last frame and `spriteIsPlaying` begins returning `false`. *Since:* 2.10 *See also:* <>, <>, <> [#spritepause] ==== spritePause [source,text] ---- spritePause(id) ---- Pauses animation on an animated sprite. The current frame keeps being drawn. Call `spritePlay` to resume from the same frame. * `id` -- sprite handle. *Since:* 2.10 *See also:* <>, <> [#spriteplay] ==== spritePlay [source,text] ---- spritePlay(id) ---- Starts or resumes animation on an animated sprite. If the animation is already playing, this is a no-op (the frame timer is not reset). * `id` -- sprite handle. *Since:* 2.10 *See also:* <>, <> [#spritequality] ==== spriteQuality [source,text] ---- spriteQuality(id, smooth) ---- Selects the filtering mode used when the sprite is scaled or rotated. * `id` -- sprite handle. * `smooth` -- `RENDER_PIXELATED` (`0`) for nearest-neighbor (sharp, pixelated -- right for low-res art that should stay crunchy), `RENDER_SMOOTH` (`1`) for bilinear filtering (smooth -- right for photographic art and large decorative elements). Quality is applied immediately: the cached rotated/scaled surface is rebuilt, so the next `spriteDraw` reflects the new setting. Changing quality on every frame is wasteful; set it once after `spriteLoad`. *Since:* 2.10 *See also:* <>, <> [#spriterotate] ==== spriteRotate [source,text] ---- spriteRotate(id, angle) ---- Rotates a sprite by the given angle. * `id` -- sprite handle. * `angle` -- rotation in degrees, clockwise. The value is wrapped into `0`-`360` automatically, so `spriteRotate(s, 450)` and `spriteRotate(s, 90)` are equivalent. Rotation changes the sprite's bounding box, so `spriteGetWidth` and `spriteGetHeight` return different values after a rotation. Use `spriteDraw(id, x, y, true)` -- the centered form -- to keep the visual center fixed as the sprite rotates. *Since:* 2.10 *See also:* <>, <> .Example [source,lua] ---- -- Spin the "PRESS START" indicator to draw the eye during attract mode. function onOverlayUpdate() attractAngle = (attractAngle + 3) % 360 spriteRotate(pressStart, attractAngle) spriteDraw(pressStart, screenCenterX, screenCenterY, true) return OVERLAY_UPDATED end ---- [#spriterotateandscale] ==== spriteRotateAndScale [source,text] ---- spriteRotateAndScale(id, angle, scale) spriteRotateAndScale(id, angle, scaleX, scaleY) ---- Applies both rotation and scaling in a single call. Cheaper than calling `spriteRotate` and `spriteScale` separately, because the intermediate surface only has to be rebuilt once. * `angle` -- rotation in degrees, wrapped to `0`-`360`. * `scale` -- uniform scale factor (`1.0` = natural size, `2.0` = double size, `0.5` = half size). * `scaleX`, `scaleY` -- separate horizontal and vertical scale factors for stretching or squishing. * `id` -- sprite handle. *Since:* 2.10 *See also:* <>, <> [#spritescale] ==== spriteScale [source,text] ---- spriteScale(id, scale) spriteScale(id, scaleX, scaleY) ---- Scales a sprite uniformly or along each axis independently. * `scale` -- uniform scale factor. * `scaleX`, `scaleY` -- separate horizontal and vertical factors. * `id` -- sprite handle. The scaled surface is cached, so repeated `spriteDraw` calls are cheap -- but each new `spriteScale` rebuilds that cache. Avoid changing the scale every frame if you do not need to. *Since:* 2.10 *See also:* <>, <> [#spritesetframe] ==== spriteSetFrame [source,text] ---- spriteSetFrame(id, frame) ---- Jumps an animated sprite directly to a specific frame. The per-frame delay timer is reset, so the sprite will sit on the new frame for its full duration before advancing. * `id` -- sprite handle. * `frame` -- zero-based frame index. Silently ignored if out of range, and for sprites that are not animations. *Since:* 2.10 *See also:* <>, <> [#spriteunload] ==== spriteUnload [source,text] ---- spriteUnload(id) ---- Releases all memory associated with a sprite -- the source image, the cached rotated/scaled surface, and animation frames for animated sprites. After this call the handle is invalid; passing it to any other sprite function will terminate the script. * `id` -- sprite handle. *Since:* 2.00 *See also:* <> .Example [source,lua] ---- -- Free everything we loaded, called by Singe when the user exits. function onShutdown() spriteUnload(cabinetSprite) spriteUnload(marqueeSprite) spriteUnload(explosion) end ---- [#video] === Video The `video*` family handles *additional video assets* on top of the main laserdisc video. Typical uses: a side-video of a character talking in a corner of the screen, a pre-rendered animation that plays once at a story beat, multiple concurrent video cues. You can load any number of videos, unlike the disc (which is a singleton). Key differences from the `disc*` family: * Videos are loaded by filename at runtime with `videoLoad`; the disc comes from `games.dat`. * Videos can be *drawn at arbitrary positions on the overlay* (with `videoDraw`), rotated, and scaled. The disc fills the background behind the overlay; videos composite through the overlay like sprites do. * Each video has its own audio track selection and volume, independent of the disc. * Argument order is *different* from sprites: `videoDraw(id, x, y, ...)` takes the handle first. Every other `video*` function also takes the handle first. Like sprites, videos must be drawn from `onOverlayUpdate` and eventually freed with `videoUnload`. [#videodraw] ==== videoDraw [source,text] ---- videoDraw(id, x, y, x2, y2) -- Stretched to rectangle, top-left anchor videoDraw(id, x, y, centered) -- Scaled/rotated per videoScale/videoRotate, optional center anchor ---- Draws the current frame of a video onto the overlay, advancing the video's decode by one frame of wall time. * `id` -- video handle from `videoLoad`. * `x`, `y` -- anchor coordinates (overlay units). * `x2`, `y2` -- opposite corner for the stretched form. Video is scaled to fit the rectangle. * `centered` -- boolean (for the scaled/rotated form). When `true`, `(x, y)` is the center of the drawn video; when `false`, it is the top-left. Use `true` for rotated videos. To use the non-stretched form with rotation or scaling, apply `videoScale` and/or `videoRotate` first, then pass the boolean form: `videoDraw(id, x, y, true)`. *Since:* 2.00. The scaled/rotated form with `centered` flag added in 2.10. *See also:* <>, <>, <>, <> .Example [source,lua] ---- -- Draw a picture-in-picture video of the NPC in the upper right. function onOverlayUpdate() videoDraw(npcVideo, 900, 40, 1260, 240) -- Stretched to a 360x200 box. return OVERLAY_UPDATED end ---- [#videogetaudiotrack] ==== videoGetAudioTrack [source,text] ---- track = videoGetAudioTrack(id) ---- Returns the index of the currently selected audio track for a loaded video. *Returns:* integer track index. *Since:* 2.10 *See also:* <>, <> [#videogetaudiotracks] ==== videoGetAudioTracks [source,text] ---- count = videoGetAudioTracks(id) ---- Returns the number of audio tracks available on a loaded video. *Returns:* integer count. *Since:* 2.10 *See also:* <> [#videogetframe] ==== videoGetFrame [source,text] ---- frame = videoGetFrame(id) ---- Returns the current frame number of a loaded video. *Returns:* integer frame. *Since:* 2.00 *See also:* <>, <> [#videogetframecount] ==== videoGetFrameCount [source,text] ---- total = videoGetFrameCount(id) ---- Returns the total number of frames in a loaded video. Useful for progress bars, for wrapping a loop, or for detecting when a one-shot clip is almost done. *Returns:* integer total frame count. *Since:* 2.00 *See also:* <>, <> [#videogetheight] ==== videoGetHeight [source,text] ---- height = videoGetHeight(id) ---- Returns the source height of a loaded video in pixels (the native resolution of the file, not how big it's drawn on the overlay). *Returns:* integer height. *Since:* 2.00 *See also:* <> [#videogetlanguage] ==== videoGetLanguage [source,text] ---- code = videoGetLanguage(id, track) ---- Returns the ISO language code for an audio track on a loaded video. * `id` -- video handle. * `track` -- audio track index. *Returns:* three-letter language code string (e.g. `"eng"`, `"jpn"`), or empty if no language tag is set. *Since:* 2.10 *See also:* <>, <> [#videogetlanguagedescription] ==== videoGetLanguageDescription [source,text] ---- name = videoGetLanguageDescription(code) ---- Looks up a human-readable name for an ISO language code. Unlike the other `video*` functions this one does *not* take a video handle -- it is a pure lookup table. `Framework.singe` aliases this as `discGetLanguageDescription` for symmetry with the disc API. * `code` -- three-letter code like `"eng"` or `"jpn"`. *Returns:* English language name (e.g. `"English"`, `"Japanese"`), or `"Unknown"` for unrecognized codes. *Since:* 2.10 *See also:* <>, <> .Example [source,lua] ---- -- Build a language-select menu from a video's tracks. for i = 0, videoGetAudioTracks(cutscene) - 1 do local code = videoGetLanguage(cutscene, i) local name = videoGetLanguageDescription(code) table.insert(options, { track = i, label = name }) end ---- [#videogetvolume] ==== videoGetVolume [source,text] ---- left, right = videoGetVolume(id) ---- Returns the current per-channel volume of a loaded video. Note this returns *two values* -- left and right. *Returns:* two integers, each `0` to `100`. *Since:* 2.00 *See also:* <> [#videogetwidth] ==== videoGetWidth [source,text] ---- width = videoGetWidth(id) ---- Returns the source width of a loaded video in pixels. *Returns:* integer width. *Since:* 2.00 *See also:* <> [#videoisplaying] ==== videoIsPlaying [source,text] ---- playing = videoIsPlaying(id) ---- Reports whether a loaded video is currently playing. *Returns:* boolean (pushed as a number, `0` or `1` -- treat as boolean in Lua: `videoIsPlaying(id) ~= 0`). *Since:* 2.00 *See also:* <>, <>, <> [#videoload] ==== videoLoad [source,text] ---- id = videoLoad(filename) ---- Loads a video file. Supported formats are everything FFmpeg can decode that Singe ships a codec for (MP4, MKV, WEBM, MPEG, and more). Loading a video creates a per-game data subdirectory under Singe's data root (used for things like seek indexes); this is managed automatically. * `filename` -- path to a video file. *Returns:* integer video handle. The default audio track and volume are taken from the command line or `games.dat`. Change them afterwards with `videoSetAudioTrack` and `videoSetVolume` if needed. *Since:* 2.00 *See also:* <>, <>, <> .Example [source,lua] ---- -- Load a cutscene video to play over the paused laserdisc. cutscene = videoLoad(DIR .. "videos/intro.mp4") videoSetVolume(cutscene, 80, 80) videoPlay(cutscene) ---- [#videopause] ==== videoPause [source,text] ---- videoPause(id) ---- Pauses decoding and audio on a video. The last-decoded frame keeps being drawn by `videoDraw`. *Since:* 2.00 *See also:* <> [#videoplay] ==== videoPlay [source,text] ---- videoPlay(id) ---- Starts or resumes a video. *Since:* 2.00 *See also:* <>, <> [#videoquality] ==== videoQuality [source,text] ---- videoQuality(id, smooth) ---- Selects the filtering mode used when the video is scaled or rotated. Applied on the next frame. * `id` -- video handle. * `smooth` -- `RENDER_PIXELATED` (`0`) for nearest-neighbor, `RENDER_SMOOTH` (`1`) for bilinear filtering. *Since:* 2.10 *See also:* <>, <> [#videorotate] ==== videoRotate [source,text] ---- videoRotate(id, angle) ---- Sets the rotation applied to the video by `videoDraw` (in the non-stretched form). * `id` -- video handle. * `angle` -- degrees clockwise. Wrapped to `0`-`360`. *Since:* 2.10 *See also:* <>, <> [#videorotateandscale] ==== videoRotateAndScale [source,text] ---- videoRotateAndScale(id, angle, scale) videoRotateAndScale(id, angle, scaleX, scaleY) ---- Applies both rotation and scaling in one call. * `id` -- video handle. * `angle` -- degrees clockwise. * `scale` -- uniform factor. * `scaleX`, `scaleY` -- separate horizontal/vertical factors. *Since:* 2.10 *See also:* <>, <> [#videoscale] ==== videoScale [source,text] ---- videoScale(id, scale) videoScale(id, scaleX, scaleY) ---- Scales the video for subsequent `videoDraw` calls in the non-stretched form. * `id` -- video handle. * `scale` -- uniform factor. * `scaleX`, `scaleY` -- separate horizontal/vertical factors. *Since:* 2.10 *See also:* <>, <> [#videoseek] ==== videoSeek [source,text] ---- videoSeek(id, frame) ---- Jumps a loaded video to a specific frame. Does not change play/pause state. * `id` -- video handle. * `frame` -- target frame number. *Since:* 2.00 *See also:* <>, <> [#videosetaudiotrack] ==== videoSetAudioTrack [source,text] ---- videoSetAudioTrack(id, track) ---- Switches the active audio track on a loaded video. Out-of-range tracks terminate the script. * `id` -- video handle. * `track` -- audio track index, `0` through `videoGetAudioTracks(id) - 1`. *Since:* 2.10 *See also:* <> [#videosetvolume] ==== videoSetVolume [source,text] ---- videoSetVolume(id, left, right) ---- Sets per-channel volume for a loaded video. Silently clamps values to `0`-`100`. * `id` -- video handle. * `left`, `right` -- channel volumes, `0` (silent) to `100` (loudest). *Since:* 2.00 *See also:* <> .Example [source,lua] ---- -- Fade the cutscene audio down to 20% as we return control to the player. for v = 100, 20, -4 do videoSetVolume(cutscene, v, v) coroutine.yield() -- In threaded mode; or spread across frames otherwise. end ---- [#videounload] ==== videoUnload [source,text] ---- videoUnload(id) ---- Releases a loaded video and all associated resources (decoder, surfaces, cached frames). * `id` -- video handle. *Since:* 2.00 *See also:* <> [#vldp] === VLDP WARNING: *Legacy.* The `vldp*` namespace predates Singe 2.00 and exists to keep pre-2.00 scripts running. For new code, prefer the modern equivalents: - `vldpGetHeight` -> <> - `vldpGetWidth` -> <> - `vldpSetVerbose` -- unimplemented; ignore. `vldpGetPixel` is the one function in this namespace without a modern replacement and is still useful. [#vldpgetheight] ==== vldpGetHeight [source,text] ---- height = vldpGetHeight() ---- Returns the height of the main laserdisc video in pixels. Equivalent to `discGetHeight`. *Returns:* integer height. *Since:* 1.x *See also:* <> [#vldpgetpixel] ==== vldpGetPixel [source,text] ---- r, g, b = vldpGetPixel(x, y) ---- Reads the color of a single pixel from the current laserdisc frame at overlay coordinates `(x, y)`. The coordinates are internally scaled from overlay units to video pixels, so you pass the same coordinates you use everywhere else on the overlay. * `x`, `y` -- overlay coordinates. *Returns:* three integers: red, green, blue, each `0` to `255`. This is the preferred technique for *hit detection on pre-recorded video*. Rather than hand-authoring hit boxes for every frame, many laserdisc games tag targets with a specific color in the source footage (e.g. a bright magenta outline invisible to the eye) and check the pixel under the crosshair on a trigger pull. *Since:* 1.x *See also:* <> .Example [source,lua] ---- -- Lightgun hit check: the target is drawn in pure magenta on enemy frames. function onInputPressed(what) -- The default controls.cfg maps MOUSE_0.BUTTON_LEFT to SWITCH_BUTTON3. if what == SWITCH_BUTTON3 then local x, y = mouseGetPosition(0) local r, g, b = vldpGetPixel(x, y) if r > 200 and g < 50 and b > 200 then scoreHit() else scoreMiss() end end end ---- [#vldpgetwidth] ==== vldpGetWidth [source,text] ---- width = vldpGetWidth() ---- Returns the width of the main laserdisc video in pixels. Equivalent to `discGetWidth`. *Returns:* integer width. *Since:* 1.x *See also:* <> [#vldpsetverbose] ==== vldpSetVerbose WARNING: *Unimplemented.* No-op. Retained for backward compatibility. [#enginecallbacks] === Engine Callbacks These are functions *you define* in your script. Singe calls them when the corresponding event happens. All are optional -- define only the ones your game needs. In the threaded programming model, the `singeMain` function replaces most of them (see <>), but the callbacks still exist and still fire, with one exception: `Framework.singe` installs its own `onOverlayUpdate` to drive the `singeMain` coroutine. Most threaded games define only `singeMain` and `onShutdown`. [#oncontrollermoved] ==== onControllerMoved [source,text] ---- function onControllerMoved(axis, value, which) -- axis: physical axis index on the controller -- value: -32768 to 32767 -- which: SDL internal controller instance ID end ---- Called when an analog axis on any connected controller moves. Not throttled -- a continuously moved stick will fire many times per second. Apply the `SINGE_DEAD_ZONE` threshold before reacting. The `which` parameter is the controller index, `0` through `3`, matching the index used by `controllerGetAxis` and the `GAMEPAD_0` through `GAMEPAD_3` tables. Most games ignore it and treat all controllers equivalently. *See also:* <>, the `GAMEPAD_AXIS_*` constants [#oninputpressedoninputreleased] ==== onInputPressed / onInputReleased [source,text] ---- function onInputPressed(what) -- what: an integer code for the logical input end function onInputReleased(what) -- what: same as above end ---- Called when a mapped input fires. The value passed depends on the current keyboard mode: * *`MODE_NORMAL`*: one of the `SWITCH_*` constants (`SWITCH_UP`, `SWITCH_START1`, `SWITCH_COIN1`, etc.). Keys, controller buttons, and mouse buttons all arrive this way, translated through the mappings in `controls.cfg`; an input that is not mapped to any switch is ignored in this mode. `SWITCH_PAUSE` arrives only when the engine's pause key has been disabled with `singeSetPauseKeyEnabled(false)`; otherwise the engine handles it and freezes the game (see <>). * *`MODE_FULL`*: the SDL keysym of the key pressed (the character value, which is not the same as the scancode). Controller and mouse buttons pass `0` here. In this mode every raw event also comes through `onKeyPressed` / `onKeyReleased` (see below), which is where the scancode or button code lives. .Example [source,lua] ---- function onInputPressed(what) if what == SWITCH_COIN1 then addCredit() elseif what == SWITCH_START1 then startGame() elseif what == SWITCH_QUIT then singeQuit() elseif what == SWITCH_BUTTON3 then fireShot() -- Left mouse button by default. end end ---- [#onkeypressedonkeyreleased] ==== onKeyPressed / onKeyReleased [source,text] ---- function onKeyPressed(keysym, scancode) end function onKeyReleased(keysym, scancode) end ---- *`MODE_FULL` only.* Called when any keyboard key is pressed or released, with both the logical keysym (what character the key produces) and the physical scancode (what key position was pressed). For text entry, use `keysym`. For key-as-button controls, use `scancode` so the binding works across layouts. .Example [source,lua] ---- -- High score initial entry -- text, so use keysym. function onKeyPressed(keysym, scancode) if keysym >= string.byte('A') and keysym <= string.byte('Z') then initials = initials .. string.char(keysym) end end ---- [#onmousemoved] ==== onMouseMoved [source,text] ---- function onMouseMoved(x, y, xRelative, yRelative, which) end ---- Called on every mouse-movement event. Parameters depend on the mouse mode: * *`SINGLE_MOUSE`* (default): `x` and `y` are absolute overlay coordinates; `xRelative` and `yRelative` are deltas since the previous event; `which` is always `0`. * *`MANY_MOUSE`*: only `xRelative` / `yRelative` are reliable (they are the raw deltas from the physical device); `which` identifies which mouse moved. For a persistently visible crosshair, track the latest `(x, y)` in globals from this callback and render from `onOverlayUpdate`. .Example [source,lua] ---- cursorX, cursorY = 640, 360 function onMouseMoved(x, y, xr, yr, which) cursorX, cursorY = x, y end ---- [#oncollision] ==== onCollision [source,text] ---- function onCollision(nodeA, nodeB, x, y, z, speed) -- nodeA, nodeB: the two bodies' nodes -- x, y, z: where they touched, in world space -- speed: how fast they met, in units per second end ---- Called once for each new contact between two bodies after a physics step, never for a pair already touching. A crate landing, a ball hitting the cabinet, the player's car clipping a wall. Triggers report through `onTrigger` instead. *Since:* 3.00. *See also:* <>, <> [#ontrigger] ==== onTrigger [source,text] ---- function onTrigger(trigger, other, entered) -- trigger: the trigger body's node -- other: the node that entered or left it -- entered: true on the way in, false on the way out end ---- Called when a body enters or leaves a trigger made with `bodySetTrigger`. *Since:* 3.00. *See also:* <>, <> [#onoverlayupdate] ==== onOverlayUpdate [source,text] ---- function onOverlayUpdate() -- Draw your frame here. return OVERLAY_UPDATED -- Or OVERLAY_NOT_UPDATED if nothing changed. end ---- The single callback where drawing is allowed. Singe calls it once per rendering pass. Any `overlay*`, `spriteDraw`, `videoDraw`, `fontPrint`, or similar call outside this callback will not appear on screen and may corrupt the overlay. *Return value:* * `OVERLAY_UPDATED` (`1`) -- the overlay changed; Singe re-composites it on top of the video. * `OVERLAY_NOT_UPDATED` (`0`) -- you drew nothing (or the overlay looks the same as last frame); Singe reuses the previous composite. Optimization only; when in doubt, return `OVERLAY_UPDATED`. In the threaded model, `Framework.singe` auto-defines `onOverlayUpdate` to resume `singeMain`'s coroutine and always returns `OVERLAY_UPDATED`. Don't define it yourself when using threaded mode. [#onshutdown] ==== onShutdown [source,text] ---- function onShutdown() -- Free loaded sprites, sounds, videos, fonts here. end ---- Called once, just before Singe exits or switches to the next script (via `scriptExecute` / `scriptPush` / `singeQuit`). Use it to free every handle you loaded. [#onsoundcompleted] ==== onSoundCompleted [source,text] ---- function onSoundCompleted(channel) -- channel: the channel number returned by the soundPlay call that just finished. end ---- Called each time a sound finishes playing on its own (not when it is stopped via `soundStop`). Useful for chaining music stingers or reusing channel numbers. .Example [source,lua] ---- function onSoundCompleted(channel) if channel == musicChannel then musicChannel = soundPlay(nextTrack) end end ---- [#singemain] ==== singeMain [source,text] ---- function singeMain() while true do -- Your game logic here. singeYield() -- Cooperatively yield once per frame. end end ---- *Threaded model entry point.* Define this function and `Framework.singe` sets up a coroutine that runs it, scheduled from the engine's overlay-update tick. See the Manual's "Threaded" section for the full pattern. * `singeYield()` is an alias for `coroutine.yield` -- call it anywhere your code would otherwise consume substantial time, to let the engine continue servicing input, audio, and video. * Do not also define `onOverlayUpdate` -- `Framework.singe` installs its own when `singeMain` is defined. [#unimplementedandlegacy] === Unimplemented and Legacy A handful of API functions are retained for script-level compatibility but do nothing. They are safe to call; they simply log a trace message and return. Prefer the listed replacements for new code. [cols="1,1,1",options="header"] |=== | Function | Status | Replacement | `discChangeSpeed` | No-op | -- (was for variable-speed playback on real LD hardware) | `discSearchBlanking` | No-op | -- (was screen-blanking during seek on real LD hardware) | `discSkipBlanking` | No-op | -- (same reason) | `discSetFPS` | No-op | Framerate is read from the video file. | `vldpGetHeight` | Works | <> | `vldpGetWidth` | Works | <> | `vldpSetVerbose` | No-op | -- | `colorBackground(r,g,b)` | Works | Prefer the 4-argument form, `colorBackground(r, g, b, a)`. | `colorForeground(r,g,b)` | Works | Prefer the 4-argument form, `colorForeground(r, g, b, a)`. | `daphneGetWidth` / `daphneGetHeight` / `daphneScreenshot` | Aliases in `Framework.singe` | Prefer the `singe*` equivalents. | `discPauseAtFrame` | Works (alias) | <>. |=== In addition, `Framework.singe` rebinds `random = { new = math.random }` so that pre-2.00 scripts using `random.new()` still work. New code should use `math.random` directly.