singe/docs/Manual.adoc

5532 lines
175 KiB
Text

= Singe Manual
Scott Duensing <scott@kangaroopunch.com>
: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 `<InstallDir>/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
`<InstallDir>/Singe/controls.cfg.example`. To see the available configuration
options available to be used in this file, read through
`<InstallDir>/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 <<audiosync,Audio Sync>>.
| `-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 <<singlefile,Single-File Games>>.
| `-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 <<singlefile,Single-File Games>>.
| `-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
<<singlefile,Single-File Games>>) 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
<<withoutadisc,Games Without a Disc>>. `LEGACY_SPRITE_ARGS = true` runs a game written for Singe 2.10
with the old sprite argument order (see <<migrating,Migrating from Singe
2.10>>). 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) and skinning (characters deforming) both work;
morph targets do not yet. 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.
.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 <<withoutadisc,Games Without a
Disc>>.
* 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 <<pausing,Pausing>>.
=== 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 <<singesetaudiodelay,singeSetAudioDelay>>. 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 <<singegetaudiocalibration,singeGetAudioCalibration>> and <<singegetaudiolatency,singeGetAudioLatency>>, or offer its own screen with <<singesetaudiocalibration,singeSetAudioCalibration>>.
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 (`<name>.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 and skinning are supported; morph targets are
not.
[#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:* <<animationsettime,animationSetTime>>
[#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:* <<animationplay,animationPlay>>
[#animationpause]
==== animationPause
[source,text]
----
animationPause(node)
----
Freezes the animation at its current time; `animationResume` continues it.
*Since:* 3.00.
*See also:* <<animationresume,animationResume>>
[#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:* <<modelgetanimations,modelGetAnimations>>, <<animationstop,animationStop>>, <<animationsettime,animationSetTime>>
.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:* <<animationpause,animationPause>>
[#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:* <<animationgettime,animationGetTime>>, <<animationpause,animationPause>>
[#animationstop]
==== animationStop
[source,text]
----
animationStop(node)
----
Stops and forgets the animation. The nodes keep their last pose.
*Since:* 3.00.
*See also:* <<animationplay,animationPlay>>
[#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 <<physics,Physics>> 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:* <<bodysetmass,bodySetMass>>, <<bodydelete,bodyDelete>>, <<physicssetgravity,physicsSetGravity>>
.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>>
[#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:* <<bodyapplyforce,bodyApplyForce>>, <<bodysetvelocity,bodySetVelocity>>
.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:* <<bodynew,bodyNew>>
[#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:* <<bodysetangularvelocity,bodySetAngularVelocity>>
[#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:* <<bodysetvelocity,bodySetVelocity>>
[#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:* <<bodysetenabled,bodySetEnabled>>
[#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:* <<bodygetangularvelocity,bodyGetAngularVelocity>>
[#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:* <<bodysetfriction,bodySetFriction>>
[#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:* <<bodyisresting,bodyIsResting>>
[#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:* <<bodysetbounce,bodySetBounce>>
[#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:* <<bodynew,bodyNew>>, <<bodyapplyimpulse,bodyApplyImpulse>>
[#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:* <<ontrigger,ontrigger>>, <<bodynew,bodyNew>>
.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:* <<bodygetvelocity,bodyGetVelocity>>, <<bodyapplyimpulse,bodyApplyImpulse>>
[#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:* <<nodelookat,nodeLookAt>>, <<camerasetperspective,cameraSetPerspective>>
.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>>
[#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:* <<camerasetorthographic,cameraSetOrthographic>>
[#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:* <<colorforeground,colorForeground>>, <<overlayprint,overlayPrint>> *(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:* <<colorbackground,colorBackground>>, <<overlaybox,overlayBox>>, <<overlayline,overlayLine>>, <<overlayprint,overlayPrint>> *(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:* <<controllergetbutton,controllerGetButton>>, 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:* <<controllergetaxis,controllerGetAxis>>, 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 <<video,`video*`>> 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 <<unimplementedandlegacy,Unimplemented and Legacy>>.
[#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:* <<discsetaudiotrack,discSetAudioTrack>>
.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>>, <<discsetaudiotrack,discSetAudioTrack>>
[#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:* <<discgetaudiotrack,discGetAudioTrack>>, <<discgetlanguage,discGetLanguage>>
.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:* <<discsearch,discSearch>>, <<discpauseatframe,discPauseAtFrame>>, <<discskiptoframe,discSkipToFrame>>
.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:* <<discgetwidth,discGetWidth>>, <<overlaysetresolution,overlaySetResolution>>
[#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 <<videogetlanguagedescription,videoGetLanguageDescription>>), <<discgetaudiotracks,discGetAudioTracks>>
[#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 <<withoutadisc,Games Without a Disc>>)
|===
Singe's lightweight player has no distinct searching state; a disc that is seeking reports `DISC_PAUSED`.
*Since:* 1.x (RDG)
*See also:* <<discplay,discPlay>>, <<discpause,discPause>>, <<discstop,discStop>>
.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:* <<discgetheight,discGetHeight>>, <<overlaysetresolution,overlaySetResolution>>
[#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:* <<discplay,discPlay>>, <<discstop,discStop>>, <<singesetpauseflag,singeSetPauseFlag>>
[#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:* <<discsearch,discSearch>>
[#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:* <<discpause,discPause>>, <<discstop,discStop>>, <<discskiptoframe,discSkipToFrame>>
.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:* <<discskiptoframe,discSkipToFrame>>, <<discpauseatframe,discPauseAtFrame>>, <<discgetframe,discGetFrame>>
.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:* <<discgetaudiotracks,discGetAudioTracks>>, <<discgetlanguage,discGetLanguage>>
[#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:* <<discskipforward,discSkipForward>>, <<discstepbackward,discStepBackward>>
.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:* <<discskipbackward,discSkipBackward>>, <<discstepforward,discStepForward>>
[#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:* <<discsearch,discSearch>>, <<discplay,discPlay>>
.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>>, <<discskipbackward,discSkipBackward>>
[#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:* <<discstepbackward,discStepBackward>>
[#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:* <<discplay,discPlay>>, <<discpause,discPause>>, <<discgetstate,discGetState>>
[#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 <<overlayprint,`overlayPrint`>>. 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:* <<fontselect,fontSelect>>, <<fontunload,fontUnload>>, <<fontprint,fontPrint>>
.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:* <<fonttosprite,fontToSprite>>, <<fontselect,fontSelect>>, <<overlayprint,overlayPrint>>
.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:* <<fontprint,fontPrint>>, <<fonttosprite,fontToSprite>>
[#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:* <<fontload,fontLoad>>
.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 <<sprite,sprite>> 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:* <<fontprint,fontPrint>>, <<spritedraw,spriteDraw>>, <<spriteunload,spriteUnload>>
.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:* <<fontload,fontLoad>>
[#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:* <<jointsetlimits,jointSetLimits>>, <<jointdelete,jointDelete>>
.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:* <<jointhinge,jointHinge>>
[#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>>
[#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:* <<jointhinge,jointHinge>>, <<jointslider,jointSlider>>
[#jointdelete]
==== jointDelete
[source,text]
----
jointDelete(joint)
----
Removes the joint; the bodies are free of each other again.
*Since:* 3.00.
*See also:* <<jointhinge,jointHinge>>
[#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:* <<keyboardgetlastup,keyboardGetLastUp>>, <<keyboardisdown,keyboardIsDown>>
.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:* <<keyboardgetlastdown,keyboardGetLastDown>>
[#keyboardgetmode]
==== keyboardGetMode
[source,text]
----
mode = keyboardGetMode()
----
Returns the current keyboard mode: `MODE_NORMAL` (`0`) or `MODE_FULL` (`1`).
*Since:* 1.x (RDG)
*See also:* <<keyboardsetmode,keyboardSetMode>>
[#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:* <<keyboardgetmode,keyboardGetMode>>
.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:* <<lightsetcolor,lightSetColor>>, <<lightsetintensity,lightSetIntensity>>, <<lightsetrange,lightSetRange>>, <<lightsetcone,lightSetCone>>, <<scenesetambient,sceneSetAmbient>>
.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:* <<lightsetintensity,lightSetIntensity>>
[#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:* <<lightnew,lightNew>>
[#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>>
[#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:* <<lightsetintensity,lightSetIntensity>>
[#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:* <<scenesetshadowsize,sceneSetShadowSize>>, <<lightnew,lightNew>>
.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:* <<materialsetcolor,materialSetColor>>, <<materialsettexture,materialSetTexture>>, <<nodesetmesh,nodeSetMesh>>
.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:* <<materialnew,materialNew>>
[#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>>
[#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:* <<materialsettexture,materialSetTexture>>, <<materialsetblend,materialSetBlend>>
[#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:* <<meshplane,meshPlane>>
[#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:* <<materialsetunlit,materialSetUnlit>>
[#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>>
[#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:* <<materialsetmetallic,materialSetMetallic>>
[#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:* <<spriteload,spriteLoad>>, <<materialsetvideo,materialSetVideo>>
.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:* <<materialsetemissive,materialSetEmissive>>, <<materialsetvideo,materialSetVideo>>
[#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:* <<videoload,videoLoad>>, <<materialsetunlit,materialSetUnlit>>, <<materialsettexture,materialSetTexture>>
.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:* <<nodesetmesh,nodeSetMesh>>
[#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>>
[#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:* <<meshcone,meshCone>>
[#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>>
[#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:* <<nodesetmesh,nodeSetMesh>>, <<materialsetdoublesided,materialSetDoubleSided>>
.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:* <<materialsetdoublesided,materialSetDoubleSided>>, <<materialsetvideo,materialSetVideo>>
[#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>>
[#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:* <<meshsphere,meshSphere>>
[#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:* <<modelinstance,modelInstance>>, <<modelgetanimations,modelGetAnimations>>, <<modeldelete,modelDelete>>
.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:* <<modelload,modelLoad>>
[#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:* <<animationplay,animationPlay>>
[#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:* <<modelload,modelLoad>>, <<nodefind,nodeFind>>, <<animationplay,animationPlay>>
[#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:* <<mousesetenabled,mouseSetEnabled>>
[#mouseenable]
==== mouseEnable
[source,text]
----
mouseEnable()
----
Legacy alias for `mouseSetEnabled(true)`, defined in `Framework.singe`.
*Since:* 1.18 (RDG)
*See also:* <<mousesetenabled,mouseSetEnabled>>
[#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:* <<mousehowmany,mouseHowMany>>, 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:* <<mousesetmode,mouseSetMode>>
[#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>>
[#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:* <<mouseenable,mouseEnable>>, <<mousedisable,mouseDisable>>, <<mousesetcaptured,mouseSetCaptured>>
[#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:* <<mousehowmany,mouseHowMany>>, 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:* <<nodesetmesh,nodeSetMesh>>, <<nodesetparent,nodeSetParent>>, <<nodedelete,nodeDelete>>
.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:* <<nodenew,nodeNew>>
[#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:* <<nodesetname,nodeSetName>>, <<modelinstance,modelInstance>>
[#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:* <<nodegetparent,nodeGetParent>>
[#nodegetname]
==== nodeGetName
[source,text]
----
name = nodeGetName(node)
----
The node's name, or an empty string.
*Returns:* A string.
*Since:* 3.00.
*See also:* <<nodesetname,nodeSetName>>
[#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:* <<nodesetparent,nodeSetParent>>
[#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:* <<nodesetposition,nodeSetPosition>>, <<nodegetworldposition,nodeGetWorldPosition>>
[#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:* <<nodesetquaternion,nodeSetQuaternion>>, <<nodegetrotation,nodeGetRotation>>
[#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:* <<nodesetrotation,nodeSetRotation>>
[#nodegetscale]
==== nodeGetScale
[source,text]
----
x, y, z = nodeGetScale(node)
----
The node's scale per axis.
*Returns:* Three numbers.
*Since:* 3.00.
*See also:* <<nodesetscale,nodeSetScale>>
[#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:* <<sceneproject,sceneProject>>, <<nodelookat,nodeLookAt>>
[#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:* <<cameraset,cameraSet>>, <<lightnew,lightNew>>
[#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:* <<nodesetposition,nodeSetPosition>>, <<noderotate,nodeRotate>>
[#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:* <<nodesetrotation,nodeSetRotation>>, <<nodemove,nodeMove>>
[#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:* <<meshbox,meshBox>>, <<materialnew,materialNew>>, <<nodesetvisible,nodeSetVisible>>
[#nodesetname]
==== nodeSetName
[source,text]
----
nodeSetName(node, name)
----
Names the node for `nodeFind`.
*Since:* 3.00.
*See also:* <<nodefind,nodeFind>>
[#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:* <<nodegetparent,nodeGetParent>>, <<nodenew,nodeNew>>
[#nodesetposition]
==== nodeSetPosition
[source,text]
----
nodeSetPosition(node, x, y, z)
----
Places the node relative to its parent.
*Since:* 3.00.
*See also:* <<nodemove,nodeMove>>, <<nodegetposition,nodeGetPosition>>
[#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>>
[#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:* <<noderotate,nodeRotate>>, <<nodesetquaternion,nodeSetQuaternion>>, <<nodelookat,nodeLookAt>>
[#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:* <<nodegetscale,nodeGetScale>>
[#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:* <<nodesetmesh,nodeSetMesh>>
[#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 <<colorforeground,foreground color>>. `overlayClear` fills with the current <<colorbackground,background color>>.
[#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:* <<overlayline,overlayLine>>, <<colorforeground,colorForeground>>
[#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:* <<overlayellipse,overlayEllipse>>, <<overlayplot,overlayPlot>>
[#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:* <<colorbackground,colorBackground>>
.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:* <<overlaycircle,overlayCircle>>
[#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>>, <<overlaysetresolution,overlaySetResolution>>
[#overlaygetwidth]
==== overlayGetWidth
[source,text]
----
width = overlayGetWidth()
----
Returns the width of the overlay surface in overlay units.
*Returns:* integer width.
*Since:* 1.x
*See also:* <<overlaygetheight,overlayGetHeight>>, <<overlaysetresolution,overlaySetResolution>>
[#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>>, <<overlaybox,overlayBox>>
[#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:* <<overlayline,overlayLine>>
[#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:* <<fontprint,fontPrint>> (for proportional TrueType text), <<colorforeground,colorForeground>>, <<colorbackground,colorBackground>>
.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:* <<overlaygetwidth,overlayGetWidth>>, <<overlaygetheight,overlayGetHeight>>, <<discgetwidth,discGetWidth>>
.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 <<physics,Physics>> 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:* <<sceneunproject,sceneUnproject>>, <<bodyapplyimpulse,bodyApplyImpulse>>
[#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>>
[#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:* <<bodynew,bodyNew>>
[#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 <<scenes3d,3D Scenes>>); check for that in `onShutdown`-safe code paths if you want to fall back to a 2D presentation.
*Since:* 3.00.
*See also:* <<scenesetbackground,sceneSetBackground>>
.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:* <<overlaysetresolution,overlaySetResolution>>
[#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:* <<sceneunproject,sceneUnproject>>, <<nodegetworldposition,nodeGetWorldPosition>>
.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:* <<sceneproject,sceneProject>>, <<mousegetposition,mouseGetPosition>>
.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:* <<lightnew,lightNew>>
[#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:* <<sceneenable,sceneEnable>>
[#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:* <<sceneenable,sceneEnable>>
[#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:* <<lightsetshadow,lightSetShadow>>, <<scenesetantialias,sceneSetAntialias>>
[#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:* <<singesetpausekeyenabled,singeSetPauseKeyEnabled>>
[#singeenablepausekey]
==== singeEnablePauseKey
[source,text]
----
singeEnablePauseKey()
----
Legacy alias for `singeSetPauseKeyEnabled(true)`, defined in `Framework.singe`.
*Since:* 1.18 (RDG)
*See also:* <<singesetpausekeyenabled,singeSetPauseKeyEnabled>>
[#singegetaudiocalibration]
==== singeGetAudioCalibration
[source,text]
----
milliseconds = singeGetAudioCalibration()
----
Returns the per-machine audio delay saved by the menu's calibration screen (see <<audiosync,Audio Sync>>), in milliseconds. Zero when the machine has not been calibrated.
*Since:* 3.00
*See also:* <<singesetaudiocalibration,singeSetAudioCalibration>>, <<singegetaudiodelay,singeGetAudioDelay>>
[#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 <<singesetaudiodelay,singeSetAudioDelay>>. Zero when none has been set.
*Since:* 3.00
*See also:* <<singesetaudiodelay,singeSetAudioDelay>>, <<audiosync,Audio Sync>>
[#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:* <<audiosync,Audio Sync>>
[#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:* <<singegetscriptpath,singeGetScriptPath>>
.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:* <<singegetwidth,singeGetWidth>>, <<overlaygetheight,overlayGetHeight>>
[#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:* <<singesetpauseflag,singeSetPauseFlag>>
[#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:* <<singegetdatapath,singeGetDataPath>>, 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:* <<singegetheight,singeGetHeight>>, <<overlaygetwidth,overlayGetWidth>>
[#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:* <<singegetaudiocalibration,singeGetAudioCalibration>>, <<audiosync,Audio Sync>>
[#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:* <<singegetaudiodelay,singeGetAudioDelay>>, <<audiosync,Audio Sync>>
[#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:* <<singegetpauseflag,singeGetPauseFlag>>, <<singedisablepausekey,singeDisablePauseKey>>
[#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 <<pausing,Pausing>>) 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:* <<singeenablepausekey,singeEnablePauseKey>>, <<singedisablepausekey,singeDisablePauseKey>>, <<singesetpauseflag,singeSetPauseFlag>>
[#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:* <<soundstop,soundStop>>
[#soundgetvolume]
==== soundGetVolume
[source,text]
----
volume = soundGetVolume()
----
Returns the current master effects volume, `0` through `63`.
*Returns:* integer.
*Since:* 1.16
*See also:* <<soundsetvolume,soundSetVolume>>
[#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:* <<soundplay,soundPlay>>
.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:* <<soundplay,soundPlay>>, <<soundunload,soundUnload>>
.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:* <<soundresume,soundResume>>
[#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:* <<soundload,soundLoad>>, <<soundstop,soundStop>>
.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:* <<soundpause,soundPause>>
[#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:* <<soundgetvolume,soundGetVolume>>
[#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:* <<soundfullstop,soundFullStop>>
[#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:* <<soundload,soundLoad>>
[#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:* <<spriteload,spriteLoad>>, <<spriterotate,spriteRotate>>, <<spritescale,spriteScale>>
.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:* <<spritesetframe,spriteSetFrame>>, <<spriteisplaying,spriteIsPlaying>>
.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>>, <<spritescale,spriteScale>>
[#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:* <<spritegetheight,spriteGetHeight>>, <<spritescale,spriteScale>>
.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:* <<spriteplay,spritePlay>>, <<spritepause,spritePause>>, <<spriteloop,spriteLoop>>
[#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:* <<spriteunload,spriteUnload>>, <<fonttosprite,fontToSprite>> *(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:* <<spriteplay,spritePlay>>, <<spritepause,spritePause>>, <<spriteisplaying,spriteIsPlaying>>
[#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>>, <<spriteisplaying,spriteIsPlaying>>
[#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:* <<spritepause,spritePause>>, <<spriteisplaying,spriteIsPlaying>>
[#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:* <<spritescale,spriteScale>>, <<spriterotate,spriteRotate>>
[#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:* <<spriterotateandscale,spriteRotateAndScale>>, <<spritedraw,spriteDraw>>
.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:* <<spriterotate,spriteRotate>>, <<spritescale,spriteScale>>
[#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:* <<spriterotateandscale,spriteRotateAndScale>>, <<spritequality,spriteQuality>>
[#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:* <<spritegetframe,spriteGetFrame>>, <<spriteplay,spritePlay>>
[#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:* <<spriteload,spriteLoad>>
.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:* <<videoload,videoLoad>>, <<videoscale,videoScale>>, <<videorotate,videoRotate>>, <<spritedraw,spriteDraw>>
.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:* <<videosetaudiotrack,videoSetAudioTrack>>, <<videogetaudiotracks,videoGetAudioTracks>>
[#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:* <<videogetlanguage,videoGetLanguage>>
[#videogetframe]
==== videoGetFrame
[source,text]
----
frame = videoGetFrame(id)
----
Returns the current frame number of a loaded video.
*Returns:* integer frame.
*Since:* 2.00
*See also:* <<videoseek,videoSeek>>, <<videogetframecount,videoGetFrameCount>>
[#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:* <<videogetframe,videoGetFrame>>, <<videoisplaying,videoIsPlaying>>
[#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:* <<videogetwidth,videoGetWidth>>
[#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>>, <<videogetaudiotracks,videoGetAudioTracks>>
[#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:* <<videogetlanguage,videoGetLanguage>>, <<discgetlanguage,discGetLanguage>>
.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:* <<videosetvolume,videoSetVolume>>
[#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:* <<videogetheight,videoGetHeight>>
[#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:* <<videoplay,videoPlay>>, <<videopause,videoPause>>, <<videogetframe,videoGetFrame>>
[#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:* <<videounload,videoUnload>>, <<videoplay,videoPlay>>, <<videodraw,videoDraw>>
.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>>
[#videoplay]
==== videoPlay
[source,text]
----
videoPlay(id)
----
Starts or resumes a video.
*Since:* 2.00
*See also:* <<videopause,videoPause>>, <<videoseek,videoSeek>>
[#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:* <<videoscale,videoScale>>, <<videorotate,videoRotate>>
[#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>>, <<videodraw,videoDraw>>
[#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:* <<videorotate,videoRotate>>, <<videoscale,videoScale>>
[#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:* <<videorotateandscale,videoRotateAndScale>>, <<videoquality,videoQuality>>
[#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:* <<videogetframe,videoGetFrame>>, <<videogetframecount,videoGetFrameCount>>
[#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:* <<videogetaudiotrack,videoGetAudioTrack>>
[#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:* <<videogetvolume,videoGetVolume>>
.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:* <<videoload,videoLoad>>
[#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` -> <<discgetheight,`discGetHeight`>> - `vldpGetWidth` -> <<discgetwidth,`discGetWidth`>> - `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:* <<discgetheight,discGetHeight>>
[#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:* <<discgetframe,discGetFrame>>
.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:* <<discgetwidth,discGetWidth>>
[#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 <<threaded,Threaded>>), 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:* <<controllergetaxis,controllerGetAxis>>, 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 <<pausing,Pausing>>).
* *`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>>, <<bodynew,bodyNew>>
[#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:* <<bodysettrigger,bodySetTrigger>>, <<oncollision,onCollision>>
[#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 | <<discgetheight,`discGetHeight`>>
| `vldpGetWidth` | Works | <<discgetwidth,`discGetWidth`>>
| `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) | <<discsearch,`discSearch`>>.
|===
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.