singe/docs/Manual.adoc

13868 lines
505 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). A script or directory
launched this way also runs with its `games.dat` entry when one names it (see
<<gamesdat,games.dat>>), so a game plays the same from the command line as from
the menu. For a laserdisc game (`--disc`) with no
`--framefile`, Singe looks for a video next to the script with the same base
name and any extension FFmpeg can demux, then for a `.txt` framefile.
[cols="1,2",options="header"]
|===
| Option | Purpose
| `-A`, `--audiodelay=MS` | Compensate for audio that is heard `MS` milliseconds later than the engine can measure (negative when it is heard early), `-1000` to `1000`. See <<audiosync,Audio Sync>>.
| `-a`, `--aspect=N:D` | Force the aspect ratio used to pick a window size (`4:3`, `16:9`, `16:10`).
| `-b`, `--scalefactor=PERCENT` | Shrink the picture about its centre to `PERCENT` (`50` to `100`) of the window for displays that lose their edges to overscan. Mouse and light gun positions follow the shrunken picture. Cannot be combined with `--sindengun`.
| `-c`, `--showcalculated` | Print the frame ranges of every segment of a framefile, for debugging.
| `-C`, `--canvas=WxH` | World size for a game without a disc, default 720x480. Ignored when there is a disc.
| `-D`, `--disc` | Play a laserdisc video: the one named by `--framefile`, or the video found next to the script. Implied by `--framefile`. Without it a video next to the script is reported and ignored.
| `-d`, `--datadir=PATHNAME` | Directory for everything Singe writes: video indexes, `trace.txt`, screenshots, the menu's `menu.dat`. A subdirectory named for the game's directory is created inside it (for a packed game, one named for the database and then for the script's directory inside it). Defaults to 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.
| `-g`, `--sindengun='PARAMS'` | Draw the border a Sinden light gun tracks and map mouse and light gun positions into the picture inside it: the black ring, when one is given, runs along the edge of the video, the white ring sits inside it, and the game is drawn in the middle. `PARAMS` is one quoted list of numbers: `WW` (white border width in video pixels); `WW WB` (white width, then a black border outside it); `RW GW BW WW` (a custom border color and width); `RW GW BW WW WB` (the same plus a black width); or `RW GW BW WW RB GB BB WB` (custom colors and widths for both). A border that leaves no room for the game (twice the white and black widths together reaching the smaller side of the video), or a negative width, ends the program with a message. Cannot be combined with `--scalefactor`.
| `-H`, `--softwarevideo` | Decode video in software even when the platform offers a hardware decoder (VA-API or VDPAU on Linux, D3D11VA on Windows, VideoToolbox on macOS). Use it to rule the hardware path in or out when a video misbehaves; the program trace says which decoder is in use.
| `-h`, `--help` | Show the option summary and exit.
| `-k`, `--nologos` | Skip the splash screens.
| `-l`, `--volume_vldp=PERCENT` | Laserdisc volume, `0` to `100`.
| `-m`, `--nomouse` | Disable mouse and lightgun input entirely.
| `-n`, `--nocrosshair` | Ask the game not to draw its own crosshair. Exposed to scripts as `singeWantsCrosshairs()`.
| `-o`, `--audio=TRACK` | Default audio track (zero based) for videos with several, such as multi-language releases.
| `-P`, `--pack=DIRECTORY` | Pack the game in `DIRECTORY` into the database named after the options, then exit. See <<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`.
| `-R`, `--reload` | Watch the game's loose script files and run the game again from scratch when one changes; `F5` does the same on demand. A script that fails to compile stays watched, so saving the fix reloads it. For working on a game; see Reloading While You Work.
| `-U`, `--unpack=GAME.game` | Write the packed game's files into the directory named after the options, then exit. A database whose stored names would land outside that directory is refused.
| `-u`, `--stretch` | Stretch the video to fill the window instead of keeping its aspect ratio.
| `-v`, `--framefile=FILENAME` | Video file or framefile to use instead of the one found next to the script.
| `-w`, `--fullscreen_window` | Borderless window covering the desktop.
| `-x`, `--xresolution=VALUE` | Window width. The height is taken from the matching entry of the built in resolution table when omitted.
| `-y`, `--yresolution=VALUE` | Window height.
| `-z`, `--noconsole` | Print nothing (and open no console window on Windows).
|===
Options may be given as `-x 640`, `-x640`, or `--xresolution=640`. Numeric
values are validated; a bad value prints the usage text and exits.
== Frequently Asked Questions
*Why is it named Singe?*
Singe is the name of the dragon in Dragon's Lair. As Singe (the program) began
as an add-on to Daphne (the princess in Dragon's Lair) the Dragon's Lair theme
was kept.
*What is the difference between Daphne, Singe, Hypseus, and Singe 2.xx?*
Daphne is an actual laserdisc game emulator that can run a handful of classic
laser games. Daphne/Singe (Singe 1.xx) is the original version of Singe that
was an add-on for the Daphne emulator -- originally shipped as a game DLL for
Daphne, then later combined into a single binary with the Daphne emulation
features removed. Hypseus-Singe is an enhanced fork of Daphne with Singe 1.xx
and some Singe 2.xx support. Finally, Singe 2.xx is an all-new, built-from-
scratch upgrade of the original Singe. In short, if you want accuracy, use
Daphne or Hypseus if they support emulating the desired game. If it is a Singe
game, use Singe 2.xx, or try Hypseus.
*Why should we not call it an "emulator"?*
Emulators use the real software or ROMs from the original game and pretend to
be the machine they were originally intended to run on. From the game's
perspective, it is business as usual. Singe, on the other hand, requires that
every game be re-implemented (ported) to run on Singe. The end result may be
very similar, but how it works is entirely different.
*Can I run Singe 1.xx games on Singe 2.xx?*
Yes, probably. But you likely will not want to. Almost everything from Singe
1.xx has been converted and enhanced for Singe 2.xx.
*Why does the game I just installed not show in the menu?*
Whoever packaged the game failed to include a proper `games.dat` file.
*Why is HD video slow?*
Due to the way Singe accesses video files to provide frame seeking, it is
unable to offload video decoding to the video card. High definition video
requires a lot of CPU.
*Why does my audio stutter on Windows?*
Users have discovered that most Singe audio and stuttering problems are related
to their installed audio driver -- especially Realtek-based devices. Try
updating your sound drivers or switching to the generic Windows HD Audio
driver. Disable surround sound -- use stereo, not 5.1 or 7.1. If your drivers
do not allow you to use stereo, you can use Virtual Audio Cable to fix this:
. Install VB-CABLE (https://vb-audio.com/Cable/).
. Go to Windows sound settings (right-click sound on the taskbar, then
*Sounds*, then *Playback*).
. Right-click the *Cable Input* device and choose *Set as default device*.
(You will lose your current sound output temporarily.)
. Go to the *Recording* tab.
. Right-click *Cable Output*, then *Properties*, then *Listen*, and finally
*Enable Listen to This Device*. In the drop-down list, choose your actual
listening device.
. You should have sound again. Load up Singe and your audio will be working
and lag-free.
*Does 3D work on a Raspberry Pi?*
On a Raspberry Pi 4 or later, yes: they have the Vulkan driver the 3D scene
needs. The Pi 3 and earlier do not, so they run 2D games (2D physics included)
exactly as before and refuse the first 3D call. The Pi 4 is the minimum for
any game that uses the 3D Scenes chapter.
*Why does my audio stutter on the Raspberry Pi?*
The Raspberry Pi OS now uses Pipewire as the default audio backend. Switching
to PulseAudio seems to fix the issue:
. Run `sudo raspi-config` from a terminal window.
. Select *Advanced Options*.
. Select *Audio Config*.
. Select *PulseAudio*.
. Press kbd:[Tab], select *Ok*, and let it reboot.
== Game Development
=== Lua
Singe uses the Lua programming language (http://www.lua.org) for scripting game
logic. Lua is fast, lightweight, object-oriented, easy to use, and actually
used in the games industry. A tutorial in Lua is beyond the scope of this
document. You can find video tutorials for Lua on the Kangaroo Punch YouTube
channel and additional documentation by searching the web.
=== Basic Rules
* Singe is cross-platform. Things you do not normally need to worry about on
Windows are important. Filenames are case-sensitive -- `MyScript.singe` and
`myscript.singe` are not the same file. You should also use UNIX path
separators -- `/` rather than `\`. This both makes your code cleaner (no
double-escaped backslashes) and works everywhere Singe is supported.
* Pick a distinctive name for your game folder. Do not use spaces.
* Use the included Singe Framework. Do not copy the framework into your game
scripts. By including the provided framework you help with future
compatibility as Singe is updated. Begin (or end) your game script with:
+
[source,lua]
----
dofile("Singe/Framework.singe")
----
* Keep your game self-contained. If you build on a third-party framework or
share code between your games, copy it into your game directory. Never
reference a directory beside your game; the only file outside your game a
script may load is `Singe/Framework.singe`. A single-file game (see
<<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.
=== Reloading While You Work
Run the game with `--reload` (`-R`) and Singe watches every loose script file
the game has loaded. Save a change to one of them and the game starts again
from its script a moment later: everything the script made goes (sprites,
sounds, fonts, videos, the 3D scene, physics, emitters, the overlay back to
its default size, the effects volume and the mouse back to their startup
state), the engine stays (the window, the GPU, the controllers), the disc is
parked on its first frame again as at startup, and the script runs afresh,
so a 52 MB model is back on screen in the time it takes to load it rather
than the time it takes to start Singe. A key or button held through the reload is
ignored until it is released, as at a fresh start. `F5` reloads on demand,
and a script can ask for it with `singeReload()`. Packed games have nothing to watch; the option is for
a game in a directory. A script error on reload is printed and the game sits
empty until the next reload fixes it; the file stays watched, so saving the
fix is enough. Because the reload runs the same
teardown that quitting does, anything a script leaks shows up here first.
=== Game Directory Layout
A Singe installation is a 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. The
name of the database itself (`DLe.game`, with or without a trailing
separator) is the game root, as `.` is from inside. Names
starting with `Singe/` or with the data directory stay on the filesystem;
`Singe/Framework.singe` and `singeGetDataPath()` work as always, and such a
name may use `..` as long as it stays below that prefix (the `controls.cfg`
at the root of the data directory is found that way). Any other relative
name with a `..` component resolves nowhere inside a packed game: `io.open`
returns `nil` with a message saying the name reaches outside the game,
`lfs.attributes` returns `nil`, and `spriteLoad` and its kind report the
file as missing. The game root is a directory, not a file: `io.open` on it
returns `nil`, and `lfs.mkdir` and `lfs.rmdir` refuse it. `lfs.dir` on a
packed directory lists the union of the three places, sorted without regard
to case; when a loose file and a packed one differ only in case, the loose
spelling is listed. `lfs.attributes` on a packed file answers as the real
`lfs` does: the whole table with one argument, one field by name (an
unknown name raises an error), or a table of yours filled in; a missing
file returns `nil`, a message and the error number.
`require("name")` finds `name.lua`, `name/init.lua`, or `name.singe` under the
script's directory, then under the game root.
The packer refuses a directory that has no `games.dat`, that contains
`controls.dat`, `Framework.singe`, a file whose extension is `exe`, `sh`,
`bat`, or `cmd`, or an extensionless file whose name starts with `singe`,
that has a top level entry named like the directory itself (that would make the own-directory
rule ambiguous), or whose scripts and data files reach outside the game
with `..`; it reports every such file, with the line. Two files whose
names differ only by case cannot both be packed. Stale `.index` files are
skipped. `--unpack` refuses a database whose stored names would land outside the target
directory (an absolute path, a drive letter, or a `..` component). A game
that references another game's directory (a shared framework beside it)
must copy that directory inside first. Before the database is committed,
its `games.dat` is checked: every entry needs a `SCRIPT`, and each
`SCRIPT`, `VIDEO`, `CABINET`, `MARQUEE`, and `ATTRACT` it names must be a
file in the database (or under `Singe/`); a bad path is reported with the
entry's title and the pack is refused. `--patch` runs the same check on the
patched game, so a patch cannot leave a dangling path behind either.
`--patch` takes either a directory laid out like the game, whose files
replace the packed ones, or a `.patch` file made by `--pack` from such a
directory (`Singe --pack fixes DLe-1.1.patch`; a patch needs no
`games.dat`). Files the patch does not name are left alone. Either way the
change is one transaction: the game is intact if it is interrupted. A patch
may also carry a `removed(path)` table naming files to delete. A `.patch`
can be unpacked like a game, but it cannot be run and the menu ignores it.
A packed game's data directory is named for the database and then for the
script's directory inside it: `DLe/` for a `DLe.game` whose script sits at
its root (or `data/DLe/` under `--datadir=data`), which is where the loose
`DLe/DLe.singe` has it, so saves and settings are shared between a loose
install and a packed one; a script packed in a subdirectory
(`Game/Main.singe`) gets `DLe/Game/`. The video index goes there too.
NOTE: Earlier 3.00 builds gave a packed game whose script sat in a
subdirectory the data directory `Game/` under the base, which every archive
with that layout shared. Such a game's saves now belong under `DLe/Game/`;
move them by hand.
When Singe runs a game from a database the menu passes `CONTAINER` in the
`games.dat` entry it launches; the field is set by the menu, never by hand.
For your own data, `require("sqlite3")` gives scripts the
https://lua.sqlite.org[lsqlite3] binding to the same SQLite the engine
uses. Keep such databases in `singeGetDataPath()`:
[source,lua]
----
local sqlite3 = require("sqlite3")
local db = sqlite3.open(singeGetDataPath() .. "scores.db")
db:exec("CREATE TABLE IF NOT EXISTS scores (name TEXT, score INTEGER)")
for row in db:nrows("SELECT name, score FROM scores ORDER BY score DESC LIMIT 10") do
print(row.name, row.score)
end
db:close()
----
The file format, for anyone writing tools: three tables.
`meta(key, value)` holds `version` (1), `gamedir` (the packed directory's
name), `chunk` (chunk size in bytes, 4194304), and `packer`.
`assets(path, name, size, data)` has one row per file, `path` being the
lower case forward-slash key and `name` the author's spelling; files no
larger than the chunk size are in `data`, larger ones have `data` NULL and
live in `chunks(path, chunk, data)` as consecutive numbered pieces. Singe
opens the database read only and immutable, so it runs from read-only
media and never journals beside it.
=== Event Driven... Or Not?
Traditionally, Singe used an event-driven programming model -- Singe handles
everything and only calls your code when it needs to tell you something or
update the screen. This is efficient and is how most Lua-based game engines
work, but it can be verbose. As of Singe 2.10 there is a new _threaded_ model
that allows you to write procedural code.
==== Event Driven
With the event-driven programming model, Singe controls the main program loop
and is in charge of the order of execution. Singe automatically handles all
the details of decoding and presenting video and audio. It manages
controllers, mice, and keyboard input. When Singe needs something
game-specific, it calls part of your script. The most basic Singe script that
demonstrates all the existing callbacks looks like this:
[source,lua]
----
-- Singe Game Skeleton.
-- Load the Singe Framework.
dofile("Singe/Framework.singe")
-- Declare any global variables you need here.
function onControllerMoved(axis, value, which)
--[[
Reports which controller axis was moved as well as its current value.
(Range: -32768 to 32767.) This is used for analog devices. Digital
input is handled by onInput and onKey.
--]]
end
function onInputPressed(what)
--[[
When in keyboard MODE_NORMAL, input events are reported here when the
key or button is first depressed. For a full list of keys, buttons,
and controllers, see Singe/Framework.singe. For MODE_FULL, this event
will be called with the keysym of the key pressed.
--]]
end
function onInputReleased(what)
--[[
When in keyboard MODE_NORMAL, input events are reported here when the
key or button is released. For a full list of keys, buttons, and
controllers, see Singe/Framework.singe. For MODE_FULL, this event
will be called with the keysym of the key released.
--]]
end
function onKeyPressed(key, scancode)
--[[
When in keyboard MODE_FULL, input events are reported here when the
key is pressed. Both the keysym and scancode are returned. For a list
of available scancodes, see Singe/Framework.singe.
--]]
end
function onKeyReleased(key, scancode)
--[[
When in keyboard MODE_FULL, input events are reported here when the
key is released. Both the keysym and scancode are returned. For a
list of available scancodes, see Singe/Framework.singe.
--]]
end
function onMouseMoved(x, y, xr, yr, which)
--[[
Called when the mouse is moved. When in SINGLE_MOUSE mode, absolute
X and Y values as well as the relative change in position are
returned. For MANY_MOUSE mode, only the relative change is available
as well as which mouse was moved.
--]]
end
function onOverlayUpdate()
--[[
This is the only place you can safely perform drawing operations.
If you wish to display a targeting cursor, you will need to save the
mouse position from onMouseMoved in global variables and then use
those here to render the cursor.
--]]
-- Tell Singe if we changed the display or not.
return(OVERLAY_UPDATED) -- Or OVERLAY_NOT_UPDATED if no drawing was done.
end
function onShutdown()
-- Called when the user exits your game. Free loaded resources here.
end
function onSoundCompleted(id)
-- The sound "id" just finished playing.
end
-- Note: There is no "onStartup" event.
-- Any startup code you need can be placed here.
----
[#threaded]
==== Threaded
As of Singe 2.10, you can use the classic procedural programming model. To
use this model, declare a `singeMain()` function and include the Singe
framework at the *end* of your program rather than at the beginning. Lua is
not truly multithreaded, so this model relies on the game developer to
cooperatively multitask by calling `singeYield()` anywhere code consumes a
substantial amount of time. As with the event-driven model, Singe still
manages all input, video decoding, and audio decoding. A sample threaded
program looks like this:
[source,lua]
----
function singeMain()
local x = 10
local y = 10
while true do
colorBackground(0, 0, 0, 255)
overlayClear()
colorForeground(255, 255, 255, 255)
overlayPrint(x, y, "+")
if keyboardGetLastUp() == SCANCODE.LEFT.value then x = x - 1 end
if keyboardGetLastUp() == SCANCODE.RIGHT.value then x = x + 1 end
if keyboardGetLastUp() == SCANCODE.UP.value then y = y - 1 end
if keyboardGetLastUp() == SCANCODE.DOWN.value then y = y + 1 end
singeYield()
end
end
dofile("Singe/Framework.singe")
----
==== Hybrid
The two models combine freely. When `singeMain` exists, `Framework.singe`
installs its own `onOverlayUpdate`, but every other callback still fires:
define `onInputPressed`, `onSoundCompleted`, or `onShutdown` next to
`singeMain` and they are called between the coroutine's yields. A typical
split keeps the game's flow procedural while reacting to input and audio
events:
[source,lua]
----
local fired = false
function onInputPressed(what)
if what == SWITCH_BUTTON1 then
fired = true
end
end
function onSoundCompleted(channel)
if channel == gunshotChannel then
soundPlay(reloadSound)
end
end
function singeMain()
while true do
if fired then
fired = false
gunshotChannel = soundPlay(gunshotSound)
end
singeYield()
end
end
dofile("Singe/Framework.singe")
----
Do not define `onOverlayUpdate` yourself in this model; draw from inside
`singeMain` instead. An error raised inside `singeMain` ends the game with a
traceback, and returning from `singeMain` quits cleanly.
[#pausing]
=== Pausing
Every game gets a working pause for free. While the pause key is enabled
(the default), pressing the key mapped to `INPUT_PAUSE` makes the engine
pause the disc, every loaded video, and every sound, draw a PAUSED
indicator over the last frame, and stop running the script: no
`onOverlayUpdate`, no `singeMain` resumption, no input, mouse, controller,
or sound callbacks. Timers built on Lua libraries stop with it because
nothing runs them. Pressing the key again resumes everything that was
playing and the script continues where it left off. Only the engine's own
switches (pause, quit, screenshot, grab) still work while frozen.
Inputs are kept truthful across the freeze. Anything the script believes is
held down is released with `onInputReleased` (or `onKeyReleased` in
`MODE_FULL`) when the pause starts, and whatever is still physically held
when the pause ends is pressed again. A joystick held through a pause never
sticks.
A game that wants its own pause behavior (a pause menu, a story freeze that
the player may not skip) calls `singeSetPauseKeyEnabled(false)`. The key is
then delivered to the script as `SWITCH_PAUSE` and the game drives
`singeSetPauseFlag` itself, which pauses the media without freezing the
script.
In `MODE_FULL` the keyboard belongs entirely to the game, so keys mapped to
the engine's switches do nothing there (a `p` typed into a high score entry
must stay a `p`). Gamepad and mouse buttons mapped to those switches keep
working in either mode, since they cannot be typed.
[#gamesdat]
=== games.dat
The `games.dat` file allows Singe to automatically locate new games when they
are installed by the end user. This file is extremely important and must be
included with every Singe game. Place `games.dat` in the top-most directory
of your game. An example containing multiple games:
[source,lua]
----
GAMES = {
{
TITLE = ".38 Ambush Alley",
SCRIPT = "ActionMax/38AmbushAlley.singe",
VIDEO = "ActionMax/frame_38AmbushAlley.txt",
DATA = "ActionMax",
STRETCH = false,
NO_MOUSE = false,
RESOLUTION_X = 720,
RESOLUTION_Y = 480,
SINDEN_GUN = "",
CABINET = "ActionMax/cabinet_38AmbushAlley.png",
MARQUEE = "ActionMax/marquee_ActionMax.png",
ATTRACT = "ActionMax/video_38AmbushAlley.mkv",
ATTRACT_START = 3000,
ATTRACT_END = 3500,
YEAR = 1987,
PLATFORM = "ActionMax",
DEVELOPER = "Sourcing International, Ltd.",
PUBLISHER = "Worlds of Wonder, Inc.",
GENRE = "Shooter",
DESCRIPTION = "Get your target practice in with real police officers then hit the streets.",
CREATOR = "Scott Duensing",
SOURCE = "http://kangaroopunch.com"
},
-- ... additional entries ...
}
----
The keys `SCRIPT`, `VIDEO`, `CANVAS_X`, `CANVAS_Y`, `STRETCH`,
`NO_MOUSE`, `RESOLUTION_X`, `RESOLUTION_Y`, `SINDEN_GUN`, `AUDIO_TRACK`,
`AUDIO_DELAY`, and `LEGACY_SPRITE_ARGS` are read by the engine when the menu (or your own
script, through `scriptExecute` / `scriptPush`) launches the entry; they
override the command line. The `VIDEO` line is the disc: an entry that names
one is a laserdisc game, and an entry that leaves it out or blank runs
without a disc and may set `CANVAS_X` / `CANVAS_Y` (default 720x480); see
<<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.
A script launched directly, by name or by its directory, gets the same
treatment: Singe looks for a `games.dat` in the script's directory and up to
three directories above it, and if an entry's `SCRIPT` names the launched
script (entry paths count from the directory above the `games.dat`, so
`ActionMax/games.dat` names `ActionMax/38AmbushAlley.singe`), that entry's
keys apply. The one difference from a menu launch is precedence: an option
typed on the command line (`--framefile`, `--disc`, `--stretch`, `--nomouse`,
`--xresolution`, `--yresolution`, `--sindengun`, `--audio`, `--audiodelay`,
`--canvas`) keeps its value, since the person typing it meant it. A
`games.dat` that fails to run is reported and ignored for a direct launch.
[#withoutadisc]
=== Games Without a Disc
Singe no longer assumes a laserdisc. A game is a laserdisc game only when it
names one: a `VIDEO` in its `games.dat` entry, or `--disc` (or a
`--framefile`) on the command line. Everything else runs without a disc, and
the world is a *canvas* instead of a video frame: `CANVAS_X` / `CANVAS_Y` in
`games.dat` or `--canvas=WxH` on the command line, 720x480 by default so a
discless game looks like a standard definition laserdisc game unless it
asks otherwise.
Without a disc the engine draws on black, the overlay defaults to half the
canvas as usual, and the `disc*` and `vldp*` functions become harmless:
`discGetState` reports `DISC_EJECTED`, `discGetFrame` returns `0`,
`discGetWidth` / `discGetHeight` return the canvas, `vldpGetPixel` returns
black, and the transport calls trace and do nothing. Everything else -- the
overlay, sprites, fonts, sounds, extra videos through `videoLoad`, input,
and both programming models -- is identical. The global `SINGE_DISC` tells a
script which kind of game it is running as.
[#scenes3d]
=== 3D Scenes
Singe 3 draws a 3D scene between the laserdisc video and the 2D overlay.
Everything you already know still applies: sprites, text and the overlay sit
on top of the scene, and the video shows through wherever the scene's
background is transparent. A game that never calls a 3D function is
unaffected, and a game without a disc (see <<withoutadisc,Games Without a
Disc>>) can be a 3D game outright.
The sections below build up a scene in the order you will write one: nodes,
then meshes and materials, lights, the camera, the look of the frame as a
whole, models, and finally the things that sit on top of all that. Every
call named here has an entry in the <<reference,Lua API Reference>>.
==== What a Scene Is Made Of
Everything in a scene is a *node* in one tree, and node `0` is the root. A
node has a position, a rotation and a scale relative to its parent, and it
may carry one thing: a mesh with a material, a light, a sprite, or a line of
text. A loaded model becomes a subtree of nodes under one root, so the same
calls move a modeled character and a scripted cube alike.
The scene is right-handed with +Y up and -Z forward, like glTF and Blender's
export. Units are whatever your models use, and the primitives take the same
units; physics (see <<physics,Physics>>) assumes meters. Rotations are
degrees, given as three Euler angles applied as yaw, pitch and roll, or as a
quaternion when the value came from a model. A node's own forward is its -Z:
the camera looks down it, directional and spot lights shine down it, and
`nodeLookAt` turns it toward a world point.
There are two ways to fill a scene, freely mixed:
* *Build it from script* with the primitive meshes (`meshBox`, `meshSphere`,
`meshPlane`, `meshCylinder`, `meshCone`, `meshTorus`, `meshHeightmap`) or
your own vertex data (`meshNew`), plus materials, lights and a camera.
* *Load models* in glTF 2.0 binary form (`.glb`) with `modelLoad`, then place
them with `modelInstance` as many times as you like. Blender, Maya, 3ds
Max and every major engine export `.glb`.
A minimal scene is a mesh, a light and a camera:
[source,lua]
----
sceneEnable(true)
sceneSetBackground(0, 0, 0, 0) -- Transparent: the disc shows behind the scene.
local red = materialNew()
materialSetColor(red, 220, 40, 40)
local cube = nodeNew()
nodeSetMesh(cube, meshBox(1, 1, 1), red)
local sun = lightNew(LIGHT_DIRECTIONAL)
nodeSetPosition(sun, 2, 4, 3)
nodeLookAt(sun, 0, 0, 0)
local camera = nodeNew()
nodeSetPosition(camera, 0, 1.5, 5)
nodeLookAt(camera, 0, 0, 0)
cameraSet(camera)
function onOverlayUpdate()
nodeRotate(cube, 0, 1, 0) -- One degree a frame about its own Y.
return OVERLAY_UPDATED
end
----
`sceneEnable(true)` is the one call every 3D game makes, and on a machine
that cannot draw 3D it is the call that fails (see
<<sceneperformance,Performance and Requirements>>). Without a camera the scene is viewed from `(0, 0, 5)`
looking at the origin, and without a background call the scene's background
is opaque black.
[#nodes]
==== Nodes
A node's transform is set outright (`nodeSetPosition`, `nodeSetRotation`,
`nodeSetQuaternion`, `nodeSetScale`) or nudged (`nodeMove`, `nodeRotate`),
always in its parent's space. `nodeSetParent` re-hangs a node, keeping its
local transform; `nodeGetWorldPosition` reads where it ended up after every
parent has had its say. `nodeSetVisible` hides a node and everything under
it, and `nodeDelete` frees it and its children, along with any body, emitter
or sound attached to them.
Names come from models: `nodeFind(name, root)` finds a joint or a part by
the name it had in the file, under the instance given as `root`. A scripted
node can be named too (`nodeSetName`), which is what `nodeFind` without a
root searches.
[source,lua]
----
-- A turret on a tank: the barrel is a child of the turret, the turret of the hull,
-- so turning the turret carries the barrel and moving the hull carries both.
local hull = nodeNew()
local turret = nodeNew()
local barrel = nodeNew()
nodeSetMesh(hull, meshBox(2, 0.8, 3.2), olive)
nodeSetMesh(turret, meshCylinder(0.7, 0.5, 16), olive)
nodeSetMesh(barrel, meshCylinder(0.1, 1.8, 8), steel)
nodeSetParent(turret, hull)
nodeSetParent(barrel, turret)
nodeSetPosition(turret, 0, 0.65, 0)
nodeSetPosition(barrel, 0, 0, -1.2)
nodeSetRotation(barrel, 90, 0, 0) -- Lay the cylinder along -Z.
function onOverlayUpdate()
local stick = controllerGetAxis(0, GAMEPAD_AXIS_RIGHT_X) / 32767 -- -1 to 1.
nodeRotate(turret, 0, stick * 2, 0)
return OVERLAY_NOT_UPDATED
end
----
Two calls bridge the scene and the overlay. `sceneProject(x, y, z)` turns a
world point into overlay coordinates, so a name tag or a health bar drawn
with `fontPrint` can follow a 3D object. `sceneUnproject(sx, sy, distance)`
goes the other way: the world point that far along the ray through an
overlay pixel, which with `physicsRaycast` is mouse picking.
[source,lua]
----
function onMouseMoved(x, y, dx, dy, which)
-- The mouse points along a ray from the camera; what it hits is the target.
local cx, cy, cz = nodeGetWorldPosition(camera)
local fx, fy, fz = sceneUnproject(x, y, 50)
local hit = physicsRaycast(cx, cy, cz, fx - cx, fy - cy, fz - cz, 50)
highlighted = hit
end
----
==== Meshes and Materials
A mesh is geometry; a material is how it looks; a node joins one of each
(`nodeSetMesh(node, mesh, material)`). Share both freely: one `meshBox` and
one material can sit on a thousand nodes, and that sharing is what makes the
renderer fast (see <<sceneperformance,Performance and Requirements>>).
`meshNew` builds a mesh from tables of positions, normals, texture
coordinates and indices when the primitives will not do.
A material starts as a base color with alpha (`materialSetColor`) and the
glTF metallic-roughness look: `materialSetMetallic` and
`materialSetRoughness` from `0` to `1`. Roughness near `0` gives a
mirror-sharp highlight and `1` a matte surface; a metal tints its highlights
and reflections with its own color and has no diffuse color of its own.
Beyond that:
* `materialSetEmissive` makes a surface give off light of its own color (a
screen, a lamp, hot metal); it does not light other things, but it does
bloom (see <<scenelook,The Look of the Frame>>).
* `materialSetUnlit` shows the color as it is, ignoring every light: HUD
elements in the scene, the built-in glow of a bulb's glass.
* `materialSetDoubleSided` draws the back of open shapes, such as a plane
seen from below or a flag.
* `materialSetBlend` makes the alpha count, for glass, ghosts and water.
Blended meshes draw after everything opaque, sorted by distance, and do
not cast shadows.
The texture of a material can be any loaded sprite's image
(`materialSetTexture`), the laserdisc itself or a loaded video
(`materialSetVideo`), or a second camera's picture (`materialSetView`, see
<<scenecamera,The Camera and Views>>). A modeled arcade cabinet can play the
disc on its own screen. Texture coordinates come from the mesh; the
primitives map an image once across each face, and `materialSetTiling`
repeats it, which is what ground and walls want. Textures are mipmapped and
filtered anisotropically, so a floor stays crisp into the distance;
`materialSetFilter(material, FILTER_NEAREST)` keeps pixel art blocky
instead.
[source,lua]
----
local stone = materialNew()
materialSetTexture(stone, spriteLoad(DIR .. "flagstones.png"))
materialSetTiling(stone, 12, 12) -- Twelve repeats across the plane.
materialSetRoughness(stone, 0.9)
local floor = nodeNew()
nodeSetMesh(floor, meshPlane(24, 24), stone)
local glass = materialNew()
materialSetColor(glass, 180, 220, 255, 90) -- Alpha 90 of 255.
materialSetBlend(glass, true)
materialSetRoughness(glass, 0.05)
----
Four more maps give a surface detail its geometry lacks, and a loaded model
brings them along when its file has them:
* `materialSetNormalMap` bends the lighting with a tangent-space normal map,
so bricks stand out of a flat wall. A strength above `1` exaggerates it.
* `materialSetOcclusionMap` darkens crevices that ambient light and the sky
cannot reach.
* `materialSetMetallicRoughnessMap` varies metallic and roughness across the
surface, packed the glTF way (roughness in green, metallic in blue) and
multiplied by the material's own factors, so set those to `1` when the map
should rule.
* `materialSetEmissiveMap` says which parts glow, tinted and scaled by
`materialSetEmissive`.
[source,lua]
----
local brick = materialNew()
materialSetTexture(brick, spriteLoad(DIR .. "brick.png"))
materialSetNormalMap(brick, spriteLoad(DIR .. "brickNormal.png"), 1.5)
materialSetMetallicRoughnessMap(brick, spriteLoad(DIR .. "brickMetalRough.png"))
materialSetMetallic(brick, 1)
materialSetRoughness(brick, 1)
materialSetEmissiveMap(brick, spriteLoad(DIR .. "brickEmissive.png"))
materialSetEmissive(brick, 180, 100, 40) -- The mortar glows orange.
----
.Compressed textures
A PNG is decoded to four bytes a texel on the GPU, plus a third again for
its mipmaps; a level the size of Sponza holds 390 MB of textures that way,
more than a Raspberry Pi 4 shares with its display. Textures shipped as KTX2
files (Basis Universal, made by `util/compressTextures.py` from PNGs or from
a whole `.gltf`) stay compressed on the GPU at a quarter of the size or
less, in whichever block format the machine has: BC7 on desktops, ASTC on
Apple GPUs and the Pi 4, BC3 on older cards, and plain RGBA where nothing
else exists, all from the one file. Give a `.ktx2` name in place of a sprite
to `materialSetTexture` and the map calls, and a model whose file uses
`KHR_texture_basisu` loads them on its own. `sceneGetStats` reports the
texture memory in use, so you can see the difference.
[source,lua]
----
materialSetTexture(ground, DIR .. "flagstones.ktx2")
local draws, drawn, calls, textureKB = sceneGetStats()
debugPrint("textures: " .. textureKB .. " KB")
----
==== Lights and Shadows
Up to eight lights shine at once, and each is a node, so lights parent and
animate like anything else:
* `LIGHT_DIRECTIONAL` is a sun: only its direction matters, and everything
is lit the same. An intensity of `1` lights a white surface facing it to
white.
* `LIGHT_POINT` is a bulb, fading with distance, and `lightSetRange` stops
it altogether beyond a distance. Its intensity is its brightness one unit
away, so a bulb that has to light a room needs a number in the tens.
* `LIGHT_SPOT` is a cone with an inner angle that is fully lit and an outer
angle where the light has faded to nothing (`lightSetCone`).
`sceneSetAmbient` adds a little light from everywhere so shadows are not
black; a sky does that better (see <<scenelook,The Look of the Frame>>).
Colors given as `0` to `255` are sRGB, like everything else in Singe, and
are converted to linear light on the way in, so a light of `128, 128, 128`
is a quarter as bright as white, not half.
Any light can cast soft-edged shadows from everything opaque
(`lightSetShadow`). Directional and spot lights cast through a shadow map;
point lights through a cube map that reaches every direction, so a bulb may
sit inside what it lights (a room, a lamp shade, the cabinet): closed meshes
cast from their back faces too, and the shadow's depth range is fitted to
the casters around the light. `nodeSetShadow(node, false)` excuses a mesh
from casting: the bulb's own glass, a glowing sign, a name tag.
`sceneSetShadowSize` sets the map's resolution (`256` to `4096`, default
`1024`) and trades sharpness for speed.
A directional light's shadow is split into cascades along the view
(`sceneSetShadowCascades`, three by default), each a map fitted to a slice
of what the camera sees, so shadows stay sharp close up in a large level and
still reach as far as `sceneSetShadowDistance`. One cascade fits a single
map to the whole scene, which suits small scenes and orthographic cameras.
A view (`viewNew`) draws with the same maps, taking for each point the
finest cascade that covers it, so a monitor shows the shadows the window
does.
[source,lua]
----
local sun = lightNew(LIGHT_DIRECTIONAL)
nodeSetPosition(sun, 30, 40, 20)
nodeLookAt(sun, 0, 0, 0)
lightSetIntensity(sun, 2.5)
lightSetColor(sun, 255, 240, 220)
lightSetShadow(sun, true)
sceneSetShadowSize(2048)
sceneSetShadowDistance(80) -- Three cascades out to 80 units.
local lamp = lightNew(LIGHT_POINT)
nodeSetPosition(lamp, 3, 3.1, -2)
lightSetColor(lamp, 255, 230, 190)
lightSetIntensity(lamp, 12)
lightSetRange(lamp, 14)
lightSetShadow(lamp, true) -- A cube map: six passes a frame.
nodeSetShadow(bulbGlass, false) -- The glass around it casts nothing.
----
[#scenecamera]
==== The Camera and Views
Any node can be the camera (`cameraSet`); it looks down its own -Z, so
`nodeLookAt` aims it, and hanging it under a moving node makes a chase
camera. `cameraSetPerspective(fovDegrees, near, far)` is the default lens;
`cameraSetOrthographic(height, near, far)` draws without perspective, for
diagrams, HUD-like scenes and side-on games. Keep `near` as large as the
scene allows, because the depth buffer's precision is spent between `near`
and `far`.
[source,lua]
----
local camera = nodeNew()
nodeSetParent(camera, car)
nodeSetPosition(camera, 0, 2.5, 7) -- Behind and above the car, in its space.
nodeLookAt(camera, 0, 1, 0)
cameraSet(camera)
cameraSetPerspective(55, 0.1, 300)
----
A *view* is a second camera rendered to a texture every frame and shown on
a material: a security monitor, a rear-view mirror, a portal, a picture in
picture. `viewNew(width, height)` makes one at that resolution,
`viewSetCamera` gives it a camera node, and `materialSetView` puts its
picture on any mesh. Views share the frame's shadows and skip bloom, and
each one renders the scene again, so keep them few and small. Each view
turns billboards (see <<scenesprites,Sprites and Text in the Scene>>) to
its own camera and draws transparent materials back to front from it, so
a monitor sees name tags face on and glass in the right order.
[source,lua]
----
local rearCamera = nodeNew()
nodeSetParent(rearCamera, car)
nodeSetPosition(rearCamera, 0, 1.2, -2)
nodeSetRotation(rearCamera, 0, 180, 0) -- Looking back.
local rear = viewNew(384, 256)
viewSetCamera(rear, rearCamera)
local mirror = materialNew()
materialSetView(mirror, rear)
materialSetUnlit(mirror, true)
local panel = nodeNew()
nodeSetParent(panel, car)
nodeSetMesh(panel, meshPlane(0.6, 0.2), mirror)
nodeSetPosition(panel, 0, 1.6, 0.5)
nodeSetRotation(panel, 90, 0, 0) -- The plane faces up; stand it facing the driver.
----
[#scenelook]
==== The Look of the Frame
The scene is lit in linear light. Colors you give as `0` to `255` are sRGB
and are decoded on the way in, textures are decoded by the GPU as they are
sampled, lights add up without clipping, and at the end of the frame a tone
curve brings the result back to the screen. What that means in practice:
* `sceneSetExposure(stops)` brightens or darkens the whole frame like a
camera's exposure: `+1` doubles the light, `-1` halves it, so a cave and a
beach can share the same lights and materials.
* `sceneSetTonemap` picks the curve: `TONEMAP_NEUTRAL` (the default, which
keeps colors where glTF viewers keep them and compresses only the
brightest light), `TONEMAP_ACES` (a filmic look with more contrast), or
`TONEMAP_NONE` (clip at white).
* `sceneSetBloom(threshold, strength)` makes everything brighter than the
threshold glow: the sun through a window, a bulb, an emissive sign, a
flame. The glow is taken from the scene's light before the tone curve, so
only what is truly bright blooms; a threshold of `1` is white.
.The sky and its light
`sceneSetSky(file)` wraps the scene in an equirectangular panorama. A
Radiance `.hdr` keeps its full range; a PNG or JPEG works too. The sky is
drawn behind everything, and unless `sceneSetEnvironment(false)` it also
lights the scene: its overall color from every direction replaces the flat
ambient, and its reflection shows on metals and glossy surfaces, sharp on a
mirror and blurred as roughness rises. Without a sky, metals have nothing to
reflect and only the ambient and the lights shape them.
`sceneSetSkyIntensity` scales the sky and its light together, so one
panorama serves noon and dusk.
.Fog
`sceneSetFog(r, g, b, near, far)` fades everything, particles included,
toward a color between two distances. The sky is never fogged, so match the
fog's color to the sky's horizon and the far edge of the scene disappears
into it.
[source,lua]
----
sceneSetSky(DIR .. "harbor.hdr")
sceneSetSkyIntensity(0.8)
sceneSetFog(190, 200, 215, 40, 160) -- The color of the sky at the horizon.
sceneSetExposure(-0.5) -- A little darker than the sun would make it.
sceneSetBloom(1.0, 0.35) -- Only what is brighter than white glows.
----
==== Models and Animation
`modelLoad` takes a `.glb` by the same game-relative name `spriteLoad`
takes, and a packed `.game` carries it along. The file must be
self-contained: meshes, skins, animations and textures all inside the one
file, which is Blender's default export. A model that refers to a `.bin` or
an image beside it is refused with a message naming the file, for the same
reason a packed game has to be self-contained. Materials use the
metallic-roughness model with `KHR_materials_emissive_strength` and
`KHR_texture_basisu`; an alpha `MASK` material draws opaque, a texture
addressed through a second set of texture coordinates reads the first set,
and `KHR_texture_transform` is ignored, each with a line in the program
trace. A skin may have up to `128` joints. Loading is the expensive
part; `modelInstance` then places a copy of the model's node tree, as many
times as you like, and every instance shares the file's meshes and
materials. `modelDelete` frees the model; instances keep their nodes, bare.
[source,lua]
----
local foxModel = modelLoad(DIR .. "Fox.glb")
local pack = {}
for i = 1, 6 do
local fox = modelInstance(foxModel)
nodeSetScale(fox, 0.02) -- The file is in centimeters.
nodeSetPosition(fox, i * 1.5 - 5, 0, 0)
pack[i] = fox
end
----
A model's animations are listed by `modelGetAnimations` and driven per
instance with `animationPlay(node, nameOrIndex, loop, speed, fade)` on the
instance's root node, plus pause, resume, stop and seek calls. Node
animation (things moving), skinning (characters deforming) and morph targets
(blend shapes: faces, lip sync, muscle bulges) all work. Animations advance
with real time and hold while the game is paused. A morph weight can also be
set from script with `nodeSetMorph`, by name or number, on the node that
carries the mesh.
A `fade` in seconds crossfades from whatever was playing, so a walk becomes
a run without a snap. When nothing was playing (after `animationStop`, or
when a ragdoll has been switched off), the fade starts from the pose the
nodes are in, so a character eases up from where it fell. Up to four more
clips can play at once on layers above the base (`animationPlayLayer`), each
blended in by `animationSetLayerWeight` and limited to part of the skeleton
by `animationSetLayerMask`: a look-around on the neck while the legs walk, a
wave on one arm over a run.
[source,lua]
----
animationPlay(fox, "Walk", true)
animationPlayLayer(fox, 1, "Survey", true, 1, 0.5) -- Head turns, blended in over half a second
animationSetLayerMask(fox, 1, nodeFind("b_Neck_04", fox)) -- ... on the neck and above only.
function onCollision(a, b, x, y, z, speed)
if (a == fox or b == fox) and speed > 3 then
animationPlay(fox, "Run", true, 1.2, 0.4) -- Faster, crossfaded in 0.4 s.
end
end
----
==== Sound in the Scene
A sound effect can be placed in the scene so it is heard from where it is.
`soundSetPosition(channel, x, y, z)` puts a playing channel at a world
point, `soundSetNode(channel, node)` makes it follow a node instead, and
`soundSetRange(channel, near, far)` says where it is at full volume and
where it has faded to silence. Sounds are heard from the camera unless
`soundSetListener` names another node (the player's head, for a third-person
game), and they pan left and right as the listener turns. A channel not
placed in the scene plays as it always has, with `soundSetPan` for stereo.
[source,lua]
----
local crackle = soundPlay(fireSound, -1) -- Loops forever.
soundSetNode(crackle, brazier)
soundSetRange(crackle, 2, 18) -- Full within 2 units, gone at 18.
soundSetListener(hero)
----
[#scenesprites]
==== Sprites and Text in the Scene
Any node can show a picture instead of a mesh. `nodeSetSprite(node, sprite,
height)` puts a loaded sprite (every frame of an animated one, with
`nodeSetSpriteFrame` choosing) on a quad that many world units tall, lit like
anything else and casting shadows unless `nodeSetShadow` says otherwise.
`nodeSetText(node, text, height)` does the same with a line of text in the
selected font and color, unlit. Both are meshes as far as the rest of the
engine is concerned: they parent, scale, and take a material's blend.
`nodeSetBillboard` turns a node to face the camera every frame:
`BILLBOARD_ALL` squarely (name tags, glows, impostors), `BILLBOARD_Y` about
its own up axis only (trees, health bars, anything that must stay upright),
and `BILLBOARD_NONE` to stop. It works on meshes too.
[source,lua]
----
local tag = nodeNew()
nodeSetParent(tag, fox)
nodeSetPosition(tag, 0, 110, 0) -- In the Fox's centimeters, above its head.
nodeSetScale(tag, 50) -- Undo the instance's 0.02 scale.
nodeSetText(tag, "FOX", 0.6)
nodeSetBillboard(tag, BILLBOARD_ALL)
nodeSetShadow(tag, false)
local icon = nodeNew()
nodeSetParent(icon, fox)
nodeSetPosition(icon, 0, 150, 0)
nodeSetScale(icon, 50)
nodeSetSprite(icon, spriteLoad(DIR .. "crate.png"), 0.5)
nodeSetBillboard(icon, BILLBOARD_Y)
----
[#sceneterrain]
==== Terrain
`meshHeightmap(image, sizeX, sizeY, sizeZ)` turns a grayscale image into
rolling ground: one vertex per pixel, black low and white `sizeY` high, with
normals from the slopes and texture coordinates across the whole. Give its
material a texture and `materialSetTiling` to repeat it every few units.
Images may be up to 1025 pixels each way; a 257 pixel map over 120 units
gives a vertex every half unit, which is plenty for hills.
A `BODY_STATIC` body with `SHAPE_MESH` on that node becomes a height field,
far cheaper than the triangles would be, so cars drive it and players walk
it as they are. `terrainGetHeight(node, x, z)` says how high the ground is
at a point, for placing things on it without a ray cast.
[source,lua]
----
local ground = materialNew()
materialSetTexture(ground, DIR .. "grass.ktx2")
materialSetTiling(ground, 24, 24)
local hills = nodeNew()
nodeSetMesh(hills, meshHeightmap(DIR .. "hills.png", 120, 14, 120), ground)
bodyNew(hills, BODY_STATIC, SHAPE_MESH) -- A height field.
for i = 1, 20 do
local x, z = math.random(-50, 50), math.random(-50, 50)
local tree = modelInstance(treeModel)
nodeSetPosition(tree, x, terrainGetHeight(hills, x, z), z)
end
----
==== Navigation
A navigation mesh tells characters where they can walk. `navNew(radius,
height, maxSlope, maxStep)` starts one for a given agent size, `navAddNode`
feeds it the level's geometry (a model instance's root does the whole
model), and `navBuild` bakes the walkable surface: floors, ramps and stairs
the agent can climb, minus a margin around every wall and column. Baking
Sponza takes a tenth of a second on a desktop; `navSave` and `navLoad` keep
a baked mesh in the game's data folder for machines where it takes longer,
and a mesh shipped with the game loads the same way. `navDraw` shows the
mesh over the scene while you are getting the parameters right.
Queries then answer the questions a game asks: `navPath` returns the corners
of the shortest route between two points, `navNearest` snaps a point onto
the mesh, `navRaycast` says whether a straight walk is blocked, and
`navRandomPoint` picks somewhere to wander to.
Agents (`navAgentNew`) walk the mesh for you. `navAgentMoveTo` sends one
somewhere and it steers around the other agents on the way, moving its node
(or driving a player controller on it, with `navAgentSetPlayer`) and calling
`onNavArrived` when it gets there. `navAgentGetVelocity` tells you whether
to play the walk or the run.
[source,lua]
----
local nav = navNew(0.4, 1.6, 45, 0.3) -- An agent 0.4 wide, 1.6 tall, climbing 0.3.
navAddNode(nav, level)
navBuild(nav)
local guards = {}
for i = 1, 6 do
local guard = modelInstance(guardModel)
local x, y, z = navRandomPoint(nav)
nodeSetPosition(guard, x, y, z)
animationPlay(guard, "Walk", true)
local agent = navAgentNew(nav, guard, 0.35, 1.6, 1.4)
navAgentMoveTo(agent, navRandomPoint(nav))
guards[agent] = guard
end
function onNavArrived(agent)
navAgentMoveTo(agent, navRandomPoint(nav)) -- Off to the next spot.
end
----
[#sceneperformance]
==== Performance and Requirements
The scene needs a GPU that speaks Vulkan (Linux), Direct3D 12 (Windows 10
and later) or Metal (macOS). The Raspberry Pi 4 is the minimum Pi for 3D:
it and every later model have a Vulkan driver, the Pi 3 and earlier do not.
On a machine without a suitable GPU, 2D games run exactly as before (2D
physics included) and the first 3D call ends the game with an error naming
the problem.
Meshes outside the camera's view are skipped, and copies of the same mesh
with the same material draw as one instanced call, so a forest of one tree
costs about what one tree costs. `sceneGetStats` reports the draws
collected, how many were in view, how many draw calls that took, and the
texture memory in use. To help it along:
* Share meshes and materials between nodes (one `meshBox`, many nodes; one
model, many `modelInstance` calls) rather than making one per object.
* Skinned and morphing meshes and blended ones draw one at a time; keep
them to what the scene needs.
* Antialiasing (4x multisampling) is on wherever the GPU offers it;
`sceneSetAntialias(false)` buys back speed on a Pi, as does a smaller
`sceneSetShadowSize` and fewer shadow-casting point lights, each of which
costs six passes.
* Keep an eye on triangle counts and texture sizes on small boards, ship
KTX2 textures, and load a model once rather than twice.
[source,lua]
----
function onOverlayUpdate()
local draws, drawn, calls, textureKB = sceneGetStats()
overlayClear()
local text = string.format("%d/%d meshes, %d calls, %d KB", drawn, draws, calls, textureKB)
fontPrint(10, 10, text)
return OVERLAY_UPDATED
end
----
[#physics]
=== Physics
Bodies, joints, triggers and ray casts live in the 3D scene and ride on Jolt
Physics. A body attaches to a node the way a mesh does, and from then on the
node and the body are one thing: the body's shape is sized in world units
and scaled by the node, and whichever of the two moves, the other follows.
Units are meters, kilograms and seconds, and gravity defaults to
`0, -9.81, 0`. Physics does not need a GPU; see <<physics2d,2D Games>>.
==== Bodies
`bodyNew(node, type, shape, a, b, c)` gives a node a body of one of three
types:
* A *static* body (`BODY_STATIC`) stays where the node was when the body
was made: floors, walls, the cabinet. Moving its node afterwards does not
move the body.
* A *kinematic* body (`BODY_KINEMATIC`) follows its node wherever the script
or an animation moves it, and pushes dynamic bodies out of its way without
being pushed itself: a paddle, a moving platform, a character's hand.
* A *dynamic* body (`BODY_DYNAMIC`) is moved by the simulation and drives
its node: it falls, bounces, slides and gets pushed. Do not move its node
yourself; give it velocities, forces or impulses instead
(`bodySetVelocity`, `bodyApplyForce`, `bodyApplyImpulse`).
The shape is a box, sphere, capsule or cylinder sized by `a`, `b` and `c`
(`SHAPE_BOX` takes width, height and depth; `SHAPE_SPHERE` a radius;
`SHAPE_CAPSULE` and `SHAPE_CYLINDER` a radius and a height along Y), a
convex hull of the node's mesh (`SHAPE_HULL`, everything under the node, so
a model instance's root works), or the mesh's exact triangles
(`SHAPE_MESH`, static and kinematic bodies only; on a heightmap mesh it is
a height field, see <<sceneterrain,Terrain>>).
[source,lua]
----
floor = nodeNew()
nodeSetMesh(floor, meshBox(14, 0.2, 14), stone)
nodeSetPosition(floor, 0, -1.9, 0)
bodyNew(floor, BODY_STATIC, SHAPE_BOX, 14, 0.2, 14)
for i = 1, 6 do
local crate = nodeNew()
nodeSetMesh(crate, meshBox(0.6, 0.6, 0.6), wood)
nodeSetPosition(crate, -1, -1.5 + (i - 1) * 0.62, 0)
bodyNew(crate, BODY_DYNAMIC, SHAPE_BOX, 0.6, 0.6, 0.6)
bodySetMass(crate, 5)
end
paddle = nodeNew()
nodeSetMesh(paddle, meshBox(0.3, 1.2, 1.6), red)
nodeSetPosition(paddle, -4, -1.2, 0)
bodyNew(paddle, BODY_KINEMATIC, SHAPE_BOX, 0.3, 1.2, 1.6)
function onOverlayUpdate()
nodeMove(paddle, 0.06, 0, 0) -- The kinematic paddle sweeps through the stack.
return OVERLAY_UPDATED
end
----
.Mass and materials
A dynamic body's mass comes from its shape's volume at the density of
water, so a box the size of a door weighs 140 kilograms and a nudge will not
move it. Set `bodySetMass` on anything you want to feel light, and size
impulses to the mass: an impulse of `mass * speed` sets that speed.
`bodySetFriction` and `bodySetBounce` make ice and rubber; `bodySetEnabled`
takes a body out of the world and puts it back without losing it.
.Timing
The world steps at a fixed sixty times a second from the wall clock, after
animations have posed their nodes and before the scene renders, so a
kinematic body driven by an animation pushes with the right velocity.
Pausing the game pauses physics; `physicsSetEnabled(false)` pauses it on
its own. `bodyIsResting` says when a body has settled, which is when a
thrown thing has stopped and a score can be counted. A body, player,
ragdoll, soft body or vehicle whose node has been deleted is released at
the start of the next frame, enabled or not and paused or not, so its slot
is free again; a node handle that `nodeNew` later hands out afresh carries
none of the old node's physics, and `playerExists` and its kind answer
`false` for it until it gets its own.
==== Events and Queries
Define `onCollision(nodeA, nodeB, x, y, z, speed)` and Singe calls it once
for each new contact, with the point and the speed the two bodies met at,
so a bump and a crash can sound different. A body made a trigger with
`bodySetTrigger` pushes nothing but reports what enters and leaves it
through `onTrigger(trigger, other, entered)`: a finish line, a doorway, a
danger zone. `physicsRaycast` finds the first body along a ray; with
`sceneUnproject` for the ray, that is mouse picking (see <<nodes,Nodes>>).
[source,lua]
----
finish = nodeNew()
nodeSetPosition(finish, 0, 1, -40)
bodyNew(finish, BODY_STATIC, SHAPE_BOX, 12, 2, 0.5)
bodySetTrigger(finish, true)
function onTrigger(trigger, other, entered)
if trigger == finish and other == car and entered then
lapDone()
end
end
function onCollision(a, b, x, y, z, speed)
if speed > 6 then
local channel = soundPlay(crashSound)
soundSetPosition(channel, x, y, z)
end
end
----
==== Joints
`jointHinge`, `jointBall` and `jointSlider` connect two bodies, or a body
and the world when the second node is `-1`: doors, pendulums, drawers, the
wheels of a side-on car. The anchor and the axis are given in world space
where the bodies stand when the joint is made. Hinges and sliders take
limits (`jointSetLimits`): a positive hinge angle is a right-hand turn of
the first node about the axis, and a positive slider distance is along it.
[source,lua]
----
door = nodeNew()
nodeSetMesh(door, meshBox(0.9, 2.0, 0.05), oak)
nodeSetPosition(door, 0.45, 1.0, 0)
bodyNew(door, BODY_DYNAMIC, SHAPE_BOX, 0.9, 2.0, 0.05)
bodySetMass(door, 20)
local hinge = jointHinge(door, -1, 0, 1.0, 0, 0, 1, 0) -- On the frame, about Y.
jointSetLimits(hinge, 0, 110) -- Opens one way, 110 degrees.
----
Bodies on model instances work like any other: `bodyNew(instanceRoot,
BODY_DYNAMIC, SHAPE_HULL)` wraps the whole model in a convex hull. A skinned
character is best a player (below) or a kinematic capsule that pushes things
rather than being pushed.
[#physics2d]
==== 2D Games
Physics is not tied to the 3D scene. Nodes exist on every machine, GPU or
not, so a 2D game can give nodes bodies, call `physicsSet2D(true)` so they
move in X and Y and turn about Z only, work in overlay coordinates with
gravity pointing down the screen, and draw sprites where the nodes are.
This is the form that runs on a Raspberry Pi 3, which has no 3D.
[source,lua]
----
physicsSet2D(true)
physicsSetGravity(0, 900, 0) -- Pixels per second squared; overlay Y runs down.
floor = nodeNew()
nodeSetPosition(floor, 360, 470, 0)
bodyNew(floor, BODY_STATIC, SHAPE_BOX, 720, 20, 50)
crate = nodeNew()
nodeSetPosition(crate, 200, -40, 0)
bodyNew(crate, BODY_DYNAMIC, SHAPE_BOX, 40, 40, 50)
bodySetMass(crate, 1)
function onOverlayUpdate()
local x, y = nodeGetPosition(crate)
local _, _, angle = nodeGetRotation(crate)
spriteRotate(crateArt, angle)
spriteDraw(crateArt, x, y, true)
return OVERLAY_UPDATED
end
----
Give every 2D body some depth (the third size) so shapes overlap in Z; `50`
is fine. Circles are `SHAPE_SPHERE`. Players and 2D emitters (see
<<particles,Particles>>) work in this world too, and a platformer is a
player with `playerMove(node, vx)`.
==== Players
A body goes where forces send it; a *player* goes where the script says.
`playerNew` puts a character controller on a node: a capsule by default
(`playerNew(node, radius, height)`), or any convex `bodyNew` shape
(`playerNew(node, SHAPE_BOX, w, h, d)` for a robot, `playerNew(node,
SHAPE_SPHERE, r, 0)` for something that rolls yet still climbs steps,
`SHAPE_HULL` for the model's own outline; three arguments always mean a
capsule, so a sphere spells its sizes out). The shape stands on the node's origin, which is the feet. Each
frame the script gives `playerMove` the horizontal velocity it wants, and
the controller walks along floors, climbs steps up to `playerSetStep`,
slides along walls, refuses slopes steeper than `playerSetSlope`, falls
under gravity, rides moving platforms, and shoves light dynamic bodies out
of the way (`playerSetPush`). `playerJump` works only with ground underfoot
and says whether it did. The node's rotation is the script's: turn it to
face the way it walks, and hang the model under it as a child.
[source,lua]
----
hero = nodeNew()
nodeSetPosition(hero, 0, 0, 0)
playerNew(hero, 0.3, 1.0) -- A capsule of radius 0.3, 1 tall, feet at the node.
fox = modelInstance(modelLoad(DIR .. "Fox.glb"))
nodeSetParent(fox, hero)
function onInputPressed(switch)
if switch == SWITCH_BUTTON1 then
playerJump(hero, 4.5) -- Needs ground underfoot.
end
end
function onOverlayUpdate()
local vx = controllerGetAxis(0, GAMEPAD_AXIS_LEFT_X) / 32767 * 3
local vz = controllerGetAxis(0, GAMEPAD_AXIS_LEFT_Y) / 32767 * 3
playerMove(hero, vx, vz)
if vx ~= 0 or vz ~= 0 then
nodeSetRotation(hero, 0, math.deg(math.atan(-vx, -vz)), 0)
end
return OVERLAY_NOT_UPDATED
end
----
Players report `onCollision` when they start touching a body and
`onTrigger` when they enter or leave a sensor, with their node in the place
a body's node would take, so a door, a pickup and a lava pit work the same
for a player as for a ball. `playerIsOnGround` and `playerGetGround` say
what the player stands on, for footstep sounds and for dropping off a
platform on purpose. In a 2D world a player is a platformer character:
`playerMove(node, vx)` runs along X, gravity points down the screen, and
everything else is the same. The controller's tolerances scale with the
shape, so a 30 pixel capsule in a sprite game behaves like a 0.3 meter one
in a 3D game.
==== Vehicles
A *vehicle* is a dynamic body (the chassis, made with `bodyNew` like any
body, facing -Z with Y up) that Jolt drives through wheels:
`vehicleNew(node, VEHICLE_CAR)` for anything with axles,
`VEHICLE_MOTORCYCLE` for two wheels in a line that lean through turns and
balance, or `VEHICLE_TANK` for wheels on two tracks that turn on the spot.
Wheels are nodes: `vehicleAddWheel(node, wheelNode, radius, width,
suspension)` takes the wheel's position from where its node is relative to
the chassis, and from then on the engine poses that node every frame (its X
axis is the axle, so a `meshCylinder` wheel wants a child turned 90
degrees). Wheels found in a model with `nodeFind("wheelFL", car)` work the
same. By default the front wheels steer and all of them drive;
`vehicleSetWheel` says otherwise.
Each frame the script hands over the driver: `vehicleDrive(node, forward,
right, brake, handBrake)`. The engine, automatic gearbox and differentials
are Jolt's with a 1500 kg car's defaults; `vehicleSetEngine`,
`vehicleSetGears`, `vehicleSetSuspension`, `vehicleSetSteering`,
`vehicleSetBrakes` and `vehicleSetAntiRoll` tune them, and any change
rebuilds the vehicle in place at the next step. `vehicleGetSpeed`,
`vehicleGetRpm` and `vehicleGetGear` feed a dashboard. A vehicle is still a
body: it jumps ramps, hits walls, shoves crates and reports `onCollision`.
[source,lua]
----
car = nodeNew()
nodeSetMesh(car, meshBox(1.8, 0.5, 4.0), paint)
nodeSetPosition(car, 0, 1, 0)
bodyNew(car, BODY_DYNAMIC, SHAPE_BOX, 1.8, 0.5, 4.0)
bodySetMass(car, 1500)
vehicleNew(car, VEHICLE_CAR)
local corners = {
{ -0.95, -0.2, -1.4 }, { 0.95, -0.2, -1.4 }, -- Front left and right ...
{ -0.95, -0.2, 1.4 }, { 0.95, -0.2, 1.4 }, -- ... rear left and right.
}
for i, p in ipairs(corners) do
local wheel = nodeNew()
nodeSetParent(wheel, car)
nodeSetPosition(wheel, p[1], p[2], p[3])
vehicleAddWheel(car, wheel, 0.35, 0.25, 0.4)
end
function onOverlayUpdate()
local throttle = controllerGetAxis(0, GAMEPAD_AXIS_RIGHT_TRIGGER) / 32767
local brake = controllerGetAxis(0, GAMEPAD_AXIS_LEFT_TRIGGER) / 32767
local steer = controllerGetAxis(0, GAMEPAD_AXIS_LEFT_X) / 32767
vehicleDrive(car, throttle, steer, brake, 0)
return OVERLAY_NOT_UPDATED
end
----
Tanks take `forward` as throttle and `right` as the turn (with no throttle
they pivot), and ignore the steering, brake and anti-roll tuning, since
their tracks brake and turn on their own. The motorcycle's lean spring is
scaled to the chassis, so a light bike balances as well as a heavy one.
Wheeled vehicles need a 3D world; a side-on 2D
car is a body with two hinged wheels, which the joint calls already build.
==== Water
`bodySetWater(node, density, linearDrag, angularDrag)` fills a static body
with water: the top of its box is the surface, and every dynamic body
inside floats, sinks or rolls by Jolt's buoyancy against that surface (the
submerged part of the shape counts, so a tilted crate rights itself),
slowed by the drags. `bodySetCurrent` gives the water a flow. `density` is
a multiplier, `1` for water, and each floating body's own `bodySetBuoyancy`
(default `1.2`, which floats gently; below `1` sinks) scales it. The volume
is a trigger, so `onTrigger` fires on the way in and out for splashes.
Water is not drawn: give the volume's node a blended material, or none.
A player in water swims: `playerIsSwimming` says so, `playerMove(node, vx,
vy, vz)` then steers in three axes, it sinks slowly with no vertical input
(`playerSetSwim` sets that rate and the water's drag), and the current
carries it. A boat is a buoyant hull that `vehicleNew(node, VEHICLE_BOAT)`
gives a propeller and a rudder: `vehicleSetThrust` says how hard it pushes
and where in the hull, `vehicleSetRudder` how hard it turns, and
`vehicleDrive` works as for a car, all only while the propeller is under a
surface.
[source,lua]
----
pool = nodeNew()
nodeSetMesh(pool, meshBox(8, 3, 12), blue) -- Blended blue, drawn as the water.
nodeSetPosition(pool, 0, -1.5, 0) -- Its top, at Y = 0, is the surface.
bodyNew(pool, BODY_STATIC, SHAPE_BOX, 8, 3, 12)
bodySetWater(pool, 1.0, 0.6, 0.2)
bodySetCurrent(pool, 0.5, 0, 0) -- Drifting toward +X.
bodySetBuoyancy(crate, 1.6) -- Rides high.
bodySetBuoyancy(anchor, 0.3) -- Goes down.
----
==== Ragdolls
`ragdollNew(node)` looks under a model instance for its skin and turns the
skeleton into a ragdoll recipe: every joint with a child joint becomes a
capsule along that bone, jointed to the nearest bone above it by a
swing-twist joint with sensible limits, and leaf joints (fingers, ears, the
tip of a tail) ride along with their parents. Nothing moves until
`ragdollActivate`: then the animation stops, bodies are made where the
bones are, and from that moment physics drives the joint nodes, so the
skinned mesh flops, hits the floor and comes to rest with the rest of the
world.
`ragdollApplyImpulse` shoves one bone by its joint's name.
`ragdollSetStrength` powers every joint toward the pose the ragdoll was
activated in, which turns a limp fall into a stagger or lets a downed
character pull its limbs back in; it does not stand a body up, since
nothing pushes off the floor. `ragdollDeactivate` hands the skeleton back
to animation from wherever it lies, and an `animationPlay` with a fade then
eases it up from there. Tune a bone that looks wrong with `ragdollSetJoint`
before activating.
[source,lua]
----
fox = modelInstance(foxModel)
ragdollNew(fox) -- Recipe from the skin; still animated.
function onCollision(a, b, x, y, z, speed)
if (a == hero or b == hero) and speed > 3 then
playerSetEnabled(hero, false)
ragdollActivate(fox) -- Physics drives the skeleton now.
ragdollApplyImpulse(fox, "b_Spine02_03", 40, 30, 0)
end
end
----
==== Soft Bodies
Cloth, rope and jelly are meshes whose vertices are simulated.
`softNew(node, SOFT_CLOTH)` turns the node's mesh into cloth, its vertices
(welded by position) into particles joined by stretch, shear and bend
constraints, where the node has put it in the world; a subdivided
`meshPlane(width, depth, columns, rows)` is the usual start. `softNew(node,
SOFT_BODY)` does the same for a closed mesh and `softSetPressure` inflates
it: a balloon, a jelly. `softNew(node, SOFT_ROPE, x, y, z, segments,
radius)` runs a chain of particles from the node's position to that point
and gives the node a tube mesh around them (set its look with
`nodeSetMaterial`).
Soft bodies collide with everything rigid, fall under gravity, and are
drawn by the mesh they came from, which the engine rewrites every frame.
`softPin` holds the nearest particle where it is or to a node (the flag's
edge to the pole, the rope's top to the crane); `softSetStiffness`,
`softSetMass` and `softSetDamping` tune the material. A node whose mesh is
shared with other nodes changes them too, so give a soft body a mesh of its
own.
[source,lua]
----
flag = nodeNew()
nodeSetMesh(flag, meshPlane(1.6, 1.0, 16, 10), red)
nodeSetPosition(flag, -4.2, 2.4, 0)
nodeSetRotation(flag, 90, 0, 0) -- Hanging upright.
softNew(flag, SOFT_CLOTH)
for _, y in ipairs({ 1.9, 2.15, 2.4, 2.65, 2.9 }) do
softPin(flag, -5, y, 0) -- Along the pole.
end
----
Cost scales with the particle count: a 20 by 20 sheet is 441 particles and
fine on a Raspberry Pi 4; keep cloth to a few hundred particles and a rope
to a dozen segments. Particles collide with rigid bodies, not faces, so a
fine enough mesh (particles closer than the smallest thing that will land
on it) is what stops a crate falling through. There is no self-collision:
cloth can pass through itself when crumpled. Skinned meshes cannot be soft.
==== Seeing the Physics
When a crate falls through the floor or a joint bends the wrong way,
`physicsSetDebug` draws what the simulation actually holds as lines over
the scene each frame:
* `DEBUG_SHAPES`: the bodies in wireframe, colored by state (sleeping,
moving, static), with the players in yellow.
* `DEBUG_CONSTRAINTS`: the joints, ragdoll joints and vehicle constraints
with their limits.
* `DEBUG_CONTACTS`: a red cross where bodies met this step.
* `DEBUG_VELOCITIES`: a line along each moving body's velocity.
* `DEBUG_STATIC`: the static bodies' shapes as well, which stay off
otherwise because a level mesh is a lot of lines.
Add them together, or use `DEBUG_ALL` for the first four. `DEBUG_NONE`
turns it off, and it costs nothing while off. A script draws its own lines
the same way with `lineDraw` (a ray cast, a path, a bounding box), one frame
at a time like the overlay, and `navDraw` shows a navigation mesh.
[source,lua]
----
if debugging then
physicsSetDebug(DEBUG_SHAPES + DEBUG_CONTACTS)
end
function onOverlayUpdate()
local hit, hx, hy, hz = physicsRaycast(gunX, gunY, gunZ, aimX, aimY, aimZ, 60)
if hit then
lineDraw(gunX, gunY, gunZ, hx, hy, hz, 255, 255, 0)
end
return OVERLAY_NOT_UPDATED
end
----
==== Where It Runs
Physics runs on the CPU and needs no GPU, but the library is built for
SSE4.1 on x86 (2008 and later) and NEON on the Pi; a machine below that
gets an error from the first `bodyNew`. The simulation uses every core but
one, so a desktop steps in parallel and a Pi on a single thread.
[#particles]
=== Particles
Sparks, smoke, fire, rain, dust, magic: anything made of many small
short-lived things is a job for an *emitter*. An emitter holds a recipe
(how many a second, how long they live, how fast and which way they set
off, what pulls on them, how they grow, fade and spin) and a pool of
particles it runs from that recipe. The script sets the recipe once, then
starts the emitter for a steady stream (`emitterStart`), bursts it for a
one-off puff (`emitterBurst`), or both. The engine moves every live
particle every frame, and stops moving them while the game is paused.
One emitter API serves the overlay and the 3D scene; only the units and
the drawing differ. Everything given as a minimum and a maximum
(`emitterSetLife`, `emitterSetSpeed`, `emitterSetSize`) is drawn at random
between the two for every particle.
==== 2D Emitters
`emitterNew()` with no argument makes a 2D emitter. It lives in overlay
coordinates, Y down, so a fountain's gravity is positive Y. Move it with
`emitterSetPosition`, and draw it from `onOverlayUpdate` with
`emitterDraw`. The overlay itself is a picture the CPU paints, so particles
are not painted into it: the GPU draws them, either above the whole overlay
(`PARTICLE_OVER`, the default) or beneath it and above the video and the 3D
scene (`PARTICLE_UNDER`, for smoke behind the score). 2D emitters run on
every machine Singe runs on, the Raspberry Pi 3 included, and go well with
2D physics: read a body's node position and give it to `emitterSetPosition`.
[source,lua]
----
sparks = emitterNew()
emitterSetBlend(sparks, PARTICLE_ADD)
emitterSetLife(sparks, 0.3, 0.7)
emitterSetSpeed(sparks, 80, 200)
emitterSetDirection(sparks, 0, -1) -- Up the screen.
emitterSetSpread(sparks, 70)
emitterSetGravity(sparks, 0, 500)
emitterSetSize(sparks, 5, 1)
emitterSetColor(sparks, 255, 255, 200, 255, 255, 120, 0, 0)
function onOverlayUpdate()
overlayClear()
emitterSetPosition(sparks, grinderX, grinderY)
emitterDraw(sparks)
return OVERLAY_UPDATED
end
----
==== 3D Emitters
`emitterNew(node)` makes a 3D emitter attached to a node. Particles are born
at the node's world position (spread over `emitterSetRadius`), in world
units, and draw themselves in the scene as camera-facing billboards after
every mesh: depth-tested against the geometry, never writing depth, unlit
unless asked, casting and receiving no shadows. By default a particle stays
where it was born once it leaves the emitter (smoke keeps drifting where it
was made while the torch moves on); `emitterSetLocal` makes the whole cloud
follow the node instead. Deleting the node deletes its emitters.
[source,lua]
----
flame = emitterNew(torchTip)
emitterSetBlend(flame, PARTICLE_ADD)
emitterSetRate(flame, 140)
emitterSetLife(flame, 0.4, 0.9)
emitterSetSpeed(flame, 0.6, 1.4)
emitterSetDirection(flame, 0, 1, 0)
emitterSetSpread(flame, 15)
emitterSetSize(flame, 0.35, 0.05, 0.3)
emitterSetColor(flame, 255, 200, 90, 255, 255, 40, 0, 0)
emitterStart(flame)
----
==== The Picture
A particle is a textured quad. Without a texture it is a soft disc the
engine provides; `emitterSetTexture` gives it a loaded sprite instead (a
puff, a spark streak, a snowflake), and for an animated sprite
`emitterSetFrames` picks a random frame per particle, so one atlas of puffs
gives a varied cloud. Blending is normal (`PARTICLE_ALPHA`) or additive
(`PARTICLE_ADD`, for anything that glows). `emitterSetColor` gives the
color and alpha at birth and at death, and the particle fades between them
over its life; `emitterSetSize` does the same for its size, with a third
value for how fast it spins.
==== Trails, Collisions, Lit and Soft Particles
`emitterSetTrail(emitter, length, width)` keeps the last few positions of
every particle and draws a ribbon through them, fading toward the tail:
sparks, tracer rounds, comets, in 2D and 3D alike. `emitterSetCollide` lets
particles bounce: off a horizontal floor at a height you give
(`COLLIDE_FLOOR`, free), or off the physics world by ray casts
(`COLLIDE_SCENE`, 3D only, capped per frame so a big emitter spreads the
cost over a few frames).
Two more are for 3D only. `emitterSetLit` shades each particle by the
scene's lights and sky as a small sphere facing the camera, so smoke takes a
brazier's color and dust glints in the sun (shadows are not read).
`emitterSetSoftness` fades a particle out over a distance where it meets
geometry, so smoke does not cut into a floor or a fountain into its pool;
this costs one extra depth pass of the scene per frame while any emitter
uses it.
[source,lua]
----
smoke = emitterNew(brazier)
emitterSetTexture(smoke, spriteLoad(DIR .. "puff.png"))
emitterSetRate(smoke, 30)
emitterSetLife(smoke, 2, 4)
emitterSetSpeed(smoke, 0.3, 0.6)
emitterSetDirection(smoke, 0, 1, 0)
emitterSetSize(smoke, 0.3, 1.2, 0.2)
emitterSetColor(smoke, 90, 90, 90, 160, 60, 60, 60, 0)
emitterSetLit(smoke, true) -- Takes the fire's color from below.
emitterSetSoftness(smoke, 0.4) -- No hard edge where it meets the bowl.
emitterStart(smoke)
embers = emitterNew(brazier)
emitterSetBlend(embers, PARTICLE_ADD)
emitterSetTrail(embers, 6, 0.02) -- A short streak behind each ember.
emitterSetCollide(embers, COLLIDE_FLOOR, 0.3, 0.8, 0) -- Bounce off the ground at Y = 0.
emitterStart(embers)
----
==== Cost
Particles are cheap to simulate and cost what they cover on screen. A few
thousand small ones are nothing; a handful of large additive ones over the
whole screen are what slow a Raspberry Pi 4 down. The pool is capped by
`emitterSetMax` (default 1000); when it is full, no more are born until some
die.
[#migrating]
=== Migrating from Singe 2.10
Singe 3.00 moved the sprite handle to the first argument of `spriteDraw`,
`spriteLoop`, `spriteQuality`, `spriteRotate`, `spriteRotateAndScale`,
`spriteScale`, and `spriteSetFrame`, so every sprite call now matches the
`video*` family. To update a game, move the last argument of each of those
calls to the front:
[source,lua]
----
spriteDraw(x, y, cursor) -- 2.10
spriteDraw(cursor, x, y) -- 3.00
spriteRotate(angle, cursor) -- 2.10
spriteRotate(cursor, angle) -- 3.00
----
A game you cannot edit can opt into the old order instead. Either set the
global before loading the framework:
[source,lua]
----
SINGE_LEGACY_SPRITE_ARGS = true
dofile("Singe/Framework.singe")
----
or add `LEGACY_SPRITE_ARGS = true` to its `games.dat` entry. Nothing else
changed shape, but a few behaviors did:
* Handles and counts are returned as Lua integers; `videoIsPlaying` returns
a boolean; `mouseSetMode` returns nothing.
* Errors inside callbacks now end the game with a traceback instead of being
printed and ignored, matching how argument errors have always behaved.
* Held keys no longer repeat in `MODE_NORMAL`.
* `math.randomseed` accepts a fractional seed, as it did under 2.10's Lua;
it is floored before Lua 5.4's generator sees it. `os.clock` is wall time
since the engine started, not processor time.
* The disc is parked on frame 1 by the engine before your script runs;
`Framework.singe` no longer seeks when it is loaded.
* `onMouseMoved` in `MANY_MOUSE` mode passes real movement in its third and
fourth arguments, and `onControllerMoved` passes the controller index
(`0` to `3`) instead of SDL's instance ID.
* `mouseEnable` / `mouseDisable` and `singeEnablePauseKey` /
`singeDisablePauseKey` still work, as aliases of `mouseSetEnabled` and
`singeSetPauseKeyEnabled`.
* A laserdisc is no longer assumed. A `games.dat` entry with a `VIDEO` is a
laserdisc game, as before; one without runs on a canvas instead. On the
command line pass `--disc` (or `--framefile`), since a video found beside
the script no longer implies a disc. See <<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).
| `DEBUG_NONE`, `DEBUG_SHAPES`, `DEBUG_CONSTRAINTS`, `DEBUG_CONTACTS`, `DEBUG_VELOCITIES`, `DEBUG_STATIC`, `DEBUG_ALL` | What `physicsSetDebug` draws; add them together.
| `PARTICLE_ALPHA`, `PARTICLE_ADD` | Blending for `emitterSetBlend`.
| `PARTICLE_OVER`, `PARTICLE_UNDER` | Where 2D particles draw, for `emitterSetLayer`.
| `COLLIDE_NONE`, `COLLIDE_FLOOR`, `COLLIDE_SCENE` | What particles bounce off, for `emitterSetCollide`.
| `BILLBOARD_NONE`, `BILLBOARD_ALL`, `BILLBOARD_Y` | How a node faces the camera, for `nodeSetBillboard`.
| `VEHICLE_CAR`, `VEHICLE_MOTORCYCLE`, `VEHICLE_TANK`, `VEHICLE_BOAT` | Kinds of vehicle for `vehicleNew`.
| `SOFT_CLOTH`, `SOFT_BODY`, `SOFT_ROPE` | Kinds of soft body for `softNew`.
| `FILTER_LINEAR`, `FILTER_NEAREST` | Texture sampling for `materialSetFilter`.
| `TONEMAP_NEUTRAL`, `TONEMAP_ACES`, `TONEMAP_NONE` | Tone curves for `sceneSetTonemap`.
| `SOUND_ERROR_INVALID`, `SOUND_REMOVE_HANDLE` | `-1`, what `soundPlay` returns when no channel is free.
| `SINGE_VERSION_MAJOR`, `SINGE_VERSION_MINOR`, `SINGE_VERSION_STRING`, `SINGE_FRAMEWORK_VERSION` | The engine version, as integers, as a string (`"v3.00"`), and as the number `singeVersion()` returns.
| `SINGE_DEAD_ZONE` | The `DEAD_ZONE` from `controls.cfg`.
| `SINGE_LEGACY_SPRITE_ARGS` | True when the game asked for the 2.10 sprite argument order.
| `SINGE_DISC` | True when the game has a laserdisc; false when the canvas is the world.
| `SINGE_GAMEPAD_BASE`, `SINGE_GAMEPAD_STRIDE`, `SINGE_AXIS_STRIDE`, `SINGE_GAMEPAD_BUTTON_OFFSET`, `SINGE_MOUSE_BASE`, `SINGE_MOUSE_STRIDE`, `SINGE_MAX_CONTROLLERS`, `SINGE_MAX_MICE` | Layout of the controller and mouse input codes; `Framework.singe` builds the `GAMEPAD_N` and `MOUSE_N` tables from them.
|===
`Framework.singe` adds the `SCANCODE` and `MODIFIER` tables (SDL's key and
modifier values), `GAMEPAD_0` to `GAMEPAD_3`, `MOUSE_0` to `MOUSE_3`, the
`GAMEPAD_AXIS_*` indexes for `controllerGetAxis`, and `DIR`.
=== Included Libraries
In addition to the standard Lua libraries and the Singe API, the following
libraries are also available for use in Singe programs without the need to
install any additional software:
[cols="1,3",options="header"]
|===
| Library | Purpose
| Copas | Asynchronous networking
| LuaFileSystem | Expanded filesystem support
| json.lua | JavaScript Object Notation encoding and decoding
| binaryheap | Binary heap data structure
| timerwheel | Efficient timer scheduling
| LuaSec | TLS / SSL communication
| LuaSocket | TCP and UDP
| LuaRS232 | RS232 serial port access
| lsqlite3 | SQLite databases, `require("sqlite3")`
|===
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>-<hash>.index`, the hash made from the
video's full name so two videos with one base name keep separate indexes). Indexing is one pass over the file
without decoding, a few seconds even for a feature length disc, and happens
again if the video changes. When a video is loaded, Singe
reports its keyframe spacing in the program trace (`--program`), with a
warning there if keyframes are more than two seconds apart, because a seek
has to decode forward from the previous keyframe. Decoding happens on a separate thread,
so a slow seek shows the previous frame a little longer instead of stalling
the game.
For laserdisc footage the constraints are frame accuracy and seek speed, not
compression. H.264 in MP4 or MKV with a short keyframe interval (one or two
seconds) seeks quickly and plays on every supported platform; long keyframe
intervals make `discSearch` visibly slow because the decoder must walk from
the previous keyframe. Standard definition (720x480 or 720x576) is the sweet
spot. High definition sources work but cost proportionally more CPU on
Raspberry Pi class hardware, and the overlay defaults to half the video
resolution, so oversized video buys little.
Sound effects go through SDL_mixer: WAV, OGG, FLAC, MP3, Opus, WavPack, and
tracker modules are supported. Short uncompressed WAV files give the lowest
latency.
[#reference]
== Lua API Reference
This chapter documents the Lua API exposed by the Singe engine -- every
function your game script can call, and every callback Singe will invoke on
you. It is aimed at developers writing new games or porting video-heavy games
from other engines.
[#conventions]
=== Conventions
Every API entry below follows the same structure:
* *Signature(s)* in a code fence, showing every accepted form.
* *Description* of what the function does and any side effects.
* *Parameters* listed inline, or as a table when there are more than two.
* *Returns* line describing the value pushed back to Lua (omitted when the function returns nothing).
* *Notes*, if there are gotchas worth flagging.
* *Since* -- the Singe version the function first appeared in.
* *See also* -- related functions you will likely use in the same call site.
* *Example* -- a short, realistic snippet in the shape of real game code.
A few rules apply across the whole API:
* *Colors* are 8-bit per channel: red, green, blue, and opacity are each integers from `0` to `255`.
* *Overlay coordinates* are in the current overlay resolution (set by `overlaySetResolution`), not screen pixels. The default overlay resolution is half the video resolution in each dimension; call `overlaySetResolution` to change it.
* *Handles* returned by `*Load` functions (sprites, sounds, fonts, videos) are opaque integers. Pass them back unchanged to the other functions in that namespace. Do not do math on them.
* *Invalid arguments abort the script.* If a function is called with the wrong number of arguments, wrong types, or a handle that has already been unloaded, Singe terminates the running script with an error -- it does not return `nil` or a failure code. Validate user input in your script before handing it to the API.
* *Drawing must happen from `onOverlayUpdate`.* Calls to any `overlay*`, `spriteDraw`, `videoDraw`, or `fontPrint` function from other callbacks will not be reflected on screen and may corrupt the overlay. This is the one rule that catches every new Singe developer.
[#animation]
=== Animation
These calls drive the animations a model carries, per instance. Every one
takes the *root node* returned by `modelInstance`; other nodes raise an
error, except `animationIsPlaying`, which answers `false`. Animations are
numbered from `1` in the order `modelGetAnimations` lists them, or named as
the file names them. A clip plays on the base layer, and up to four more can
play on layers `1` to `4` above it; the time, pause, resume and seek calls
address the base layer only. Time is in seconds, advances with the wall
clock by at most a tenth of a second a frame, and holds while the game is
paused. Node animation, skinning and
morph target weights are all driven. See the Animation part of
<<scenes3d,3D Scenes>>.
[#animationgettime]
==== animationGetTime
[source,text]
----
seconds = animationGetTime(node)
----
The current time into the base layer's clip, playing or paused, in the clip's own seconds (a `speed` of `2` moves it twice as fast). A looping clip wraps back to `0` at its end; a non-looping one stops at its length. Nothing played yet gives `0`. Clips on the upper layers are not reported. A node that is not an instance root raises an error.
*Returns:* Seconds as a number.
*Since:* 3.00.
*See also:* <<animationsettime,animationSetTime>>, <<animationisplaying,animationIsPlaying>>
.Example
[source,lua]
----
-- Play the footstep once each time the walk cycle passes its two contact points.
function onOverlayUpdate()
local t = animationGetTime(hero)
if (lastStepTime < 0.25 and t >= 0.25) or (lastStepTime < 0.75 and t >= 0.75) then
soundPlay(footstep)
end
lastStepTime = t
return OVERLAY_NOT_UPDATED
end
----
[#animationisplaying]
==== animationIsPlaying
[source,text]
----
playing = animationIsPlaying(node)
----
Whether the base layer's clip is advancing on this instance. A non-looping clip stops playing when it reaches its end, and `animationPause` and `animationStop` both make this `false`; a looping clip stays `true` until stopped. Unlike the other calls here, a node that is not an instance root does not raise an error but answers `false`.
*Returns:* Boolean.
*Since:* 3.00.
*See also:* <<animationplay,animationPlay>>, <<animationpause,animationPause>>
.Example
[source,lua]
----
-- Back to idling once the one-shot attack has run out.
function onOverlayUpdate()
if attacking and not animationIsPlaying(hero) then
attacking = false
animationPlay(hero, "Idle", true, 1, 0.2)
end
return OVERLAY_NOT_UPDATED
end
----
[#animationpause]
==== animationPause
[source,text]
----
animationPause(node)
----
Freezes the base layer's clip at its current time; the nodes hold that pose until `animationResume` continues it or another clip is played. Clips on the upper layers keep running. Pausing an instance that has nothing playing is harmless. A node that is not an instance root raises an error.
*Since:* 3.00.
*See also:* <<animationresume,animationResume>>, <<animationsettime,animationSetTime>>
.Example
[source,lua]
----
-- A cutscene freezes the crowd while the camera moves.
function beginCutscene()
for _, npc in ipairs(crowd) do
animationPause(npc)
end
cameraSet(cutsceneCamera)
end
----
[#animationplay]
==== animationPlay
[source,text]
----
animationPlay(node, nameOrIndex)
animationPlay(node, nameOrIndex, loop)
animationPlay(node, nameOrIndex, loop, speed)
animationPlay(node, nameOrIndex, loop, speed, fade)
----
Starts one of the model's clips from its beginning on the base layer of this instance, replacing whatever was playing there; the pose updates at once. With a `fade` the old clip keeps running while it fades out over that many seconds, and when nothing was playing the fade starts from the pose the nodes are in now (a ragdoll that was just switched off, a scripted pose), with nodes the new clip does not animate easing back to the model's rest pose. A node that is not an instance root, a name the model does not have, or a number outside `1` to the clip count raises an error.
*Parameters:*
* `node` -- the instance's root node from `modelInstance`.
* `nameOrIndex` -- a clip name as the file stores it, or its number from `1` in `modelGetAnimations` order.
* `loop` -- boolean, default `true`. When `false` the clip stops at its last keyframe and `animationIsPlaying` becomes `false`.
* `speed` -- playback rate, default `1`. `2` is twice as fast; `0.5` is half.
* `fade` -- seconds to crossfade from what was playing, default `0` (a snap).
*Since:* 3.00.
*See also:* <<modelgetanimations,modelGetAnimations>>, <<animationplaylayer,animationPlayLayer>>, <<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, 0.4)
end
----
[#animationplaylayer]
==== animationPlayLayer
[source,text]
----
animationPlayLayer(node, layer, nameOrIndex)
animationPlayLayer(node, layer, nameOrIndex, loop)
animationPlayLayer(node, layer, nameOrIndex, loop, speed)
animationPlayLayer(node, layer, nameOrIndex, loop, speed, fade)
----
Plays a clip on one of the four layers above the base, blended over whatever is beneath it by the layer's weight (`animationSetLayerWeight`, default `1`) and limited to part of the skeleton when a mask is set (`animationSetLayerMask`). Higher layers blend over lower ones; each layer runs its own clip, time and loop independently of the base. `nameOrIndex`, `loop`, `speed` and `fade` mean what they do in `animationPlay`, with one difference: a `fade` on a layer that was empty ramps the layer's weight in from `0` instead of crossfading from a pose. A layer outside `1` to `4`, a node that is not an instance root, or a clip the model does not have raises an error.
*Parameters:*
* `node` -- the instance's root node.
* `layer` -- `1` to `4`.
* `nameOrIndex` -- a clip name or number, as in `animationPlay`.
* `loop` -- default `true`.
* `speed` -- default `1`.
* `fade` -- seconds, default `0`.
*Since:* 3.00.
*See also:* <<animationplay,animationPlay>>, <<animationsetlayermask,animationSetLayerMask>>, <<animationsetlayerweight,animationSetLayerWeight>>, <<animationstop,animationStop>>
.Example
[source,lua]
----
-- Walk with the whole body, look about with the head only.
animationPlay(fox, "Walk", true)
animationPlayLayer(fox, 1, "Survey", true, 1, 0.5)
animationSetLayerMask(fox, 1, nodeFind("b_Neck_04", fox))
-- Later, wave with one arm over whatever the legs are doing.
function onGreet()
animationPlayLayer(fox, 2, "Wave", false, 1, 0.2)
end
----
[#animationresume]
==== animationResume
[source,text]
----
animationResume(node)
----
Continues the base layer's clip from where `animationPause` froze it, or from wherever `animationSetTime` moved it meanwhile. Resuming a clip that is already playing changes nothing. An instance with no clip on its base layer (never played, or stopped) raises an error, as does a node that is not an instance root.
*Since:* 3.00.
*See also:* <<animationpause,animationPause>>, <<animationsettime,animationSetTime>>
.Example
[source,lua]
----
function endCutscene()
for _, npc in ipairs(crowd) do
animationResume(npc)
end
cameraSet(gameCamera)
end
----
[#animationsetlayermask]
==== animationSetLayerMask
[source,text]
----
animationSetLayerMask(node, layer, maskNode)
animationSetLayerMask(node, layer)
----
Limits a layer to `maskNode` and everything under it, so the clip on that layer moves only that part of the model and the rest keeps following the layers beneath. `maskNode` must be one of the instance's own nodes, found with `nodeFind(name, node)`; the instance root itself does not count. Leaving `maskNode` out, or passing `nil`, lifts the limit. The mask stays on the layer across later `animationPlayLayer` calls. A layer outside `1` to `4` raises an error, and so does a node that is not an instance root or a `maskNode` that is not part of that instance.
*Parameters:*
* `node` -- the instance's root node.
* `layer` -- `1` to `4`.
* `maskNode` -- a joint or node of the instance; omitted or `nil` for the whole model.
*Since:* 3.00.
*See also:* <<animationplaylayer,animationPlayLayer>>, <<nodefind,nodeFind>>, <<animationsetlayerweight,animationSetLayerWeight>>
.Example
[source,lua]
----
-- Aim the upper body at the target while the legs keep walking.
spine = nodeFind("b_Spine02_03", hero)
animationSetLayerMask(hero, 1, spine)
animationPlayLayer(hero, 1, "AimForward", true)
function onHolster()
animationStop(hero, 1)
animationSetLayerMask(hero, 1)
end
----
[#animationsetlayerweight]
==== animationSetLayerWeight
[source,text]
----
animationSetLayerWeight(node, layer, weight)
----
How much a layer's clip shows over what is beneath it, `0` (not at all) to `1` (fully, the default); values outside that range are clamped. The weight belongs to the layer, not the clip, so it survives `animationPlayLayer` and `animationStop` on that layer. Change it a little every frame to fade a layer in or out by hand, or let a `fade` on `animationPlayLayer` do it once. A layer outside `1` to `4` or a node that is not an instance root raises an error.
*Since:* 3.00.
*See also:* <<animationplaylayer,animationPlayLayer>>, <<animationsetlayermask,animationSetLayerMask>>
.Example
[source,lua]
----
-- The limp gets worse as health drops.
function onHealthChanged(health)
local limp = 1 - health / maxHealth
animationSetLayerWeight(hero, 1, limp)
end
----
[#animationsettime]
==== animationSetTime
[source,text]
----
animationSetTime(node, seconds)
----
Jumps the base layer's clip to a time in its own seconds; negative values become `0`. The pose updates at once whether or not the clip is playing, so a paused instance can be scrubbed to an exact frame, and a playing one continues from the new time. Any crossfade in progress on the base layer is cut short. An instance with no clip on its base layer raises an error, as does a node that is not an instance root.
*Since:* 3.00.
*See also:* <<animationgettime,animationGetTime>>, <<animationpause,animationPause>>, <<animationresume,animationResume>>
.Example
[source,lua]
----
-- A door whose opening clip follows a lever the player drags.
animationPlay(door, "Open", false)
animationPause(door)
function onLeverMoved(fraction)
animationSetTime(door, fraction * doorClipLength)
end
----
[#animationstop]
==== animationStop
[source,text]
----
animationStop(node)
animationStop(node, layer)
----
Stops and forgets every clip on the instance, or only the clip on one layer above the base (`1` to `4`); the base layer alone cannot be stopped without the layers above it. The nodes keep their last pose, which a following `animationPlay` with a `fade` eases out of. After stopping everything, `animationResume` and `animationSetTime` raise an error until something is played again. Layer weights and masks are kept. A layer outside `1` to `4` or a node that is not an instance root raises an error.
*Since:* 3.00.
*See also:* <<animationplay,animationPlay>>, <<animationplaylayer,animationPlayLayer>>, <<animationpause,animationPause>>
.Example
[source,lua]
----
-- Hand the body to the physics ragdoll: freeze the pose, then let it fall.
function onKilled()
animationStop(hero)
ragdollActivate(hero)
end
-- The wave on layer 2 is done; the walk beneath it carries on.
function onWaveFinished()
animationStop(hero, 2)
end
----
[#body]
=== Body
A body gives a scene node a place in the physics world. There is one body per node, addressed by the node handle: a static body stays where the node was when the body was made, a kinematic body follows its node each step and pushes without being pushed, and a dynamic body is moved by the simulation and drives its node. Sizes, positions and velocities are in world units (meters in a 3D scene, overlay pixels in a 2D world), masses in kilograms, and time in seconds. Every call here raises an error when the node has no body; a call that only makes sense for one kind of body (a force on a static body, say) is ignored on the others, except `bodySetMass`, which raises. See <<physics,Physics>> in Game Development for how bodies, nodes and the frame fit together.
[#bodynew]
==== bodyNew
[source,text]
----
bodyNew(node, type, shape)
bodyNew(node, type, shape, a)
bodyNew(node, type, shape, a, b)
bodyNew(node, type, shape, a, b, c)
----
Gives the node a body of the given type, placed where the node is in the world right now (its parents included), with a collision shape sized by `a`, `b` and `c` in world units and scaled by the node's world scale. A second call on the same node replaces the first, and a node that had a player loses it. A dynamic body's mass comes from its shape's volume at the density of water, so set `bodySetMass` on anything meant to feel light. Changing the node's scale later does not resize the body. Raises an error for an unknown type or shape, when physics is not available on this machine, when the world already holds `4096` bodies, or when the shape cannot be built (a dynamic `SHAPE_MESH`, or a hull with no mesh under the node).
*Parameters:*
* `type` -- `BODY_STATIC`, `BODY_DYNAMIC` or `BODY_KINEMATIC`.
* `shape` -- `SHAPE_BOX` (`a`, `b`, `c` are width, height and depth), `SHAPE_SPHERE` (`a` is the radius), `SHAPE_CAPSULE` or `SHAPE_CYLINDER` (`a` is the radius, `b` the total height along Y, caps included), `SHAPE_HULL` (a convex hull of the node's mesh and everything under it, no sizes), or `SHAPE_MESH` (the exact triangles, static and kinematic bodies only; a heightmap mesh becomes a height field).
* `a`, `b`, `c` -- the sizes the shape needs; missing ones are `0` and any size is raised to at least `0.001`.
*Notes:* A sphere is scaled by the node's largest scale axis, a capsule or cylinder radius by the larger of X and Z. A capsule shorter than twice its radius is a sphere with a sliver in the middle.
*Since:* 3.00.
*See also:* <<bodysetmass,bodySetMass>>, <<bodydelete,bodyDelete>>, <<physicssetgravity,physicsSetGravity>>, <<playernew,playerNew>>
.Example
[source,lua]
----
-- A light crate and a duck model as a hull, dropped onto the floor.
crate = nodeNew()
nodeSetMesh(crate, meshBox(0.6, 0.6, 0.6), wood)
nodeSetPosition(crate, -1, 1, 0)
bodyNew(crate, BODY_DYNAMIC, SHAPE_BOX, 0.6, 0.6, 0.6)
bodySetMass(crate, 5)
duck = modelInstance(modelLoad(DIR .. "Models/Duck.glb"))
nodeSetPosition(duck, 3, 2, 0)
bodyNew(duck, BODY_DYNAMIC, SHAPE_HULL)
----
[#bodyapplyforce]
==== bodyApplyForce
[source,text]
----
bodyApplyForce(node, fx, fy, fz)
bodyApplyForce(node, fx, fy, fz, px, py, pz)
----
Adds a force, in newtons, to a dynamic body for the steps of this frame only; call it every frame to keep pushing. Without a point the force acts through the center of mass and only moves the body; with a world point `(px, py, pz)` it also spins the body about its center. A force accelerates by force divided by mass, so size it to `bodySetMass`. Static and kinematic bodies ignore it.
*Parameters:*
* `fx`, `fy`, `fz` -- the force in newtons along each world axis.
* `px`, `py`, `pz` -- optional world point the force acts at; omit for the center of mass.
*Since:* 3.00.
*See also:* <<bodyapplyimpulse,bodyApplyImpulse>>, <<bodysetmass,bodySetMass>>
.Example
[source,lua]
----
-- A fan blows the balloon across the room while the switch is on.
function onOverlayUpdate()
if fanOn then
local bx, by, bz = nodeGetPosition(balloon)
if bx < fanReach then
bodyApplyForce(balloon, 2.5, 0.4, 0)
end
end
return OVERLAY_NOT_UPDATED
end
----
[#bodyapplyimpulse]
==== bodyApplyImpulse
[source,text]
----
bodyApplyImpulse(node, ix, iy, iz)
bodyApplyImpulse(node, ix, iy, iz, px, py, pz)
----
Changes a dynamic body's momentum at once: an impulse of mass times speed adds that speed. Without a point it acts through the center of mass; with a world point `(px, py, pz)` it also spins the body, so hitting a crate near its top tips it over. This is the call for a hit, a kick, an explosion or a launch, where `bodyApplyForce` is for a push that lasts. Static and kinematic bodies ignore it.
*Parameters:*
* `ix`, `iy`, `iz` -- the impulse in kilogram meters per second along each world axis.
* `px`, `py`, `pz` -- optional world point the impulse acts at; omit for the center of mass.
*Since:* 3.00.
*See also:* <<bodyapplyforce,bodyApplyForce>>, <<bodysetvelocity,bodySetVelocity>>, <<physicsraycast,physicsRaycast>>
.Example
[source,lua]
----
-- Flick whatever the mouse is over straight up, at the point it was hit.
local mx, my = mouseGetPosition(0)
local ox, oy, oz = sceneUnproject(mx, my, 0)
local fx, fy, fz = sceneUnproject(mx, my, 10)
local hit, hx, hy, hz = physicsRaycast(ox, oy, oz, fx - ox, fy - oy, fz - oz)
if hit and hit ~= floor then
bodyApplyImpulse(hit, 0, 3, 0, hx, hy, hz)
end
----
[#bodydelete]
==== bodyDelete
[source,text]
----
bodyDelete(node)
----
Removes the body from the world; the node and its mesh stay where they are. Every joint attached to the body, and any vehicle built on it, goes with it. Deleting the node removes its body too, so this is only needed to keep a node without its body. Raises an error when the node has no body.
*Since:* 3.00.
*See also:* <<bodynew,bodyNew>>, <<bodysetenabled,bodySetEnabled>>, <<jointdelete,jointDelete>>
.Example
[source,lua]
----
-- A collected coin stops being solid and floats up as a plain node.
function onTrigger(trigger, other, entered)
if trigger == coin and other == hero and entered then
bodyDelete(coin)
collected = collected + 1
coinRising = true
end
end
----
[#bodygetangularvelocity]
==== bodyGetAngularVelocity
[source,text]
----
x, y, z = bodyGetAngularVelocity(node)
----
How fast the body is spinning about each world axis, in radians per second, as of the last step. A static body reports zero. Useful for a wheel's sound pitch or a spin counter.
*Returns:* Three numbers, radians per second about X, Y and Z.
*Since:* 3.00.
*See also:* <<bodysetangularvelocity,bodySetAngularVelocity>>, <<bodygetvelocity,bodyGetVelocity>>
.Example
[source,lua]
----
-- The roulette wheel has stopped when it barely turns.
function onOverlayUpdate()
local _, spin = bodyGetAngularVelocity(wheel)
if spinning and math.abs(spin) < 0.05 then
spinning = false
announceWinner(pocketUnder(ball))
end
return OVERLAY_NOT_UPDATED
end
----
[#bodygetvelocity]
==== bodyGetVelocity
[source,text]
----
x, y, z = bodyGetVelocity(node)
----
How fast the body is moving along each world axis, in world units per second, as of the last step. A static body reports zero; a kinematic body reports the velocity its node's movement implied.
*Returns:* Three numbers, units per second along X, Y and Z.
*Since:* 3.00.
*See also:* <<bodysetvelocity,bodySetVelocity>>, <<bodygetangularvelocity,bodyGetAngularVelocity>>
.Example
[source,lua]
----
-- Play a whoosh when the ball is really moving.
function onOverlayUpdate()
local vx, vy, vz = bodyGetVelocity(ball)
local speed = math.sqrt(vx * vx + vy * vy + vz * vz)
if speed > 8 and not whooshing then
soundPlay(whoosh)
whooshing = true
elseif speed < 2 then
whooshing = false
end
return OVERLAY_NOT_UPDATED
end
----
[#bodyisresting]
==== bodyIsResting
[source,text]
----
resting = bodyIsResting(node)
----
Whether the simulation has put the body to sleep because it stopped moving. Static bodies and bodies taken out of the world with `bodySetEnabled` count as resting. A resting body wakes on its own when something touches it or when a velocity, force or impulse is applied.
*Returns:* Boolean.
*Since:* 3.00.
*See also:* <<bodysetenabled,bodySetEnabled>>, <<bodysetvelocity,bodySetVelocity>>
.Example
[source,lua]
----
-- The round ends once every pin has settled.
function onOverlayUpdate()
local settled = true
for _, pin in ipairs(pins) do
settled = settled and bodyIsResting(pin)
end
if settled then
scoreFrame()
end
return OVERLAY_NOT_UPDATED
end
----
[#bodysetangularvelocity]
==== bodySetAngularVelocity
[source,text]
----
bodySetAngularVelocity(node, x, y, z)
----
Sets the spin about each world axis, in radians per second, and wakes the body. Static bodies ignore it. A kinematic body's spin is overwritten from its node at the next step, so the call only lasts for dynamic bodies.
*Since:* 3.00.
*See also:* <<bodygetangularvelocity,bodyGetAngularVelocity>>, <<bodysetvelocity,bodySetVelocity>>
.Example
[source,lua]
----
-- Toss the coin: up, and spinning end over end.
function tossCoin()
nodeSetPosition(coin, 0, 1.2, 0)
bodySetVelocity(coin, 0, 4, 0)
bodySetAngularVelocity(coin, 25, 0, 0)
tossed = true
end
----
[#bodysetbounce]
==== bodySetBounce
[source,text]
----
bodySetBounce(node, bounce)
----
Sets the body's restitution, from `0` (stops dead) to `1` (bounces back with everything it arrived with); values outside that range are clamped. The default is `0.1`. The two touching bodies' values combine, so a lively ball on a dead floor still bounces.
*Since:* 3.00.
*See also:* <<bodysetfriction,bodySetFriction>>, <<bodynew,bodyNew>>
.Example
[source,lua]
----
-- A rubber ball dropped onto the scene.
ball = nodeNew()
nodeSetMesh(ball, meshSphere(0.3, 32), blue)
nodeSetPosition(ball, 0, 3, 0)
bodyNew(ball, BODY_DYNAMIC, SHAPE_SPHERE, 0.3)
bodySetMass(ball, 0.5)
bodySetBounce(ball, 0.7)
----
[#bodysetbuoyancy]
==== bodySetBuoyancy
[source,text]
----
bodySetBuoyancy(node, factor)
----
How the body floats when it is inside a water volume: `1` is neutral, more floats higher, less sinks, and `0` is a stone. The default is `1.2`, which floats gently. The factor multiplies the water's own density from `bodySetWater`, and negative values are clamped to `0`. Only dynamic bodies are moved by water, but the value may be set on any body.
*Since:* 3.00.
*See also:* <<bodysetwater,bodySetWater>>, <<bodysetcurrent,bodySetCurrent>>
.Example
[source,lua]
----
-- A crate that rides high, an anchor that goes down, a ball that bobs.
bodyNew(crate, BODY_DYNAMIC, SHAPE_BOX, 0.8, 0.8, 0.8)
bodySetMass(crate, 20)
bodySetBuoyancy(crate, 1.6)
bodyNew(anchor, BODY_DYNAMIC, SHAPE_BOX, 0.5, 0.5, 0.5)
bodySetMass(anchor, 200)
bodySetBuoyancy(anchor, 0.3)
bodyNew(ball, BODY_DYNAMIC, SHAPE_SPHERE, 0.4)
bodySetBuoyancy(ball, 2.0)
----
[#bodysetcurrent]
==== bodySetCurrent
[source,text]
----
bodySetCurrent(node, x, y, z)
----
Sets the flow inside a water volume, in world units per second along each world axis; dynamic bodies floating in it drift with the flow and a swimming player is carried by it. The default is no flow. Set it on the node that `bodySetWater` was called on; on any other body it is stored and does nothing.
*Since:* 3.00.
*See also:* <<bodysetwater,bodySetWater>>, <<bodysetbuoyancy,bodySetBuoyancy>>
.Example
[source,lua]
----
-- A river that carries anything dropped in it downstream.
river = nodeNew()
nodeSetMesh(river, meshBox(6, 3, 12), water)
nodeSetPosition(river, 10, -1.5, 0)
bodyNew(river, BODY_STATIC, SHAPE_BOX, 6, 3, 12)
bodySetWater(river, 1.0, 0.8, 0.2)
bodySetCurrent(river, 0, 0, 2.5)
----
[#bodysetenabled]
==== bodySetEnabled
[source,text]
----
bodySetEnabled(node, enabled)
----
Takes the body out of the world with `false`: it stops colliding, stops moving, no longer drives or follows its node, and counts as resting. `true` puts it back where it is, awake. The body keeps its shape, mass and settings, so this is cheaper than deleting and remaking it for something that comes and goes.
*Since:* 3.00.
*See also:* <<bodyisresting,bodyIsResting>>, <<bodydelete,bodyDelete>>
.Example
[source,lua]
----
-- The drawbridge is solid only while it is down.
function lowerBridge()
animationPlay(bridge, "Lower", false)
bodySetEnabled(bridgeDeck, true)
end
function raiseBridge()
animationPlay(bridge, "Raise", false)
bodySetEnabled(bridgeDeck, false)
end
----
[#bodysetfriction]
==== bodySetFriction
[source,text]
----
bodySetFriction(node, friction)
----
Sets the surface friction: `0` is ice, `0.5` (the default) is ordinary, higher values grip more. Negative values are clamped to `0`. The two touching bodies' values combine, so a slippery floor needs a low value on the floor, not on everything that walks on it.
*Since:* 3.00.
*See also:* <<bodysetbounce,bodySetBounce>>, <<bodynew,bodyNew>>
.Example
[source,lua]
----
-- A frozen pond in the middle of the level.
pond = nodeNew()
nodeSetMesh(pond, meshBox(6, 0.1, 6), ice)
nodeSetPosition(pond, 8, -1.95, 0)
bodyNew(pond, BODY_STATIC, SHAPE_BOX, 6, 0.1, 6)
bodySetFriction(pond, 0.02)
----
[#bodysetmass]
==== bodySetMass
[source,text]
----
bodySetMass(node, kilograms)
----
Rescales a dynamic body's mass and, with it, its inertia. Do this for anything meant to feel light or heavy: the default is the shape's volume at the density of water, so a door-sized box weighs about 140 kilograms and a nudge will not move it. Forces and impulses scale with mass, so set the mass before tuning them. Raises an error when the body is not dynamic or the mass is not positive.
*Since:* 3.00.
*See also:* <<bodynew,bodyNew>>, <<bodyapplyimpulse,bodyApplyImpulse>>, <<playersetpush,playerSetPush>>
.Example
[source,lua]
----
-- A crate the hero can shove and an anvil nobody can.
bodyNew(crate, BODY_DYNAMIC, SHAPE_BOX, 0.6, 0.6, 0.6)
bodySetMass(crate, 5)
bodyNew(anvil, BODY_DYNAMIC, SHAPE_BOX, 0.5, 0.3, 0.3)
bodySetMass(anvil, 200)
----
[#bodysettrigger]
==== bodySetTrigger
[source,text]
----
bodySetTrigger(node, trigger)
----
Makes the body a trigger with `true`: it pushes nothing and stops nothing, but `onTrigger` reports each body or player that enters and leaves it. `false` makes it solid again: whatever was inside it is forgotten without a leave, while a trigger the body itself stands in goes on reporting it. A trigger is usually a static box or sphere placed over a doorway, a finish line or a danger zone. Rays from `physicsRaycast` hit triggers like any other body.
*Notes:* A player is reported inside a trigger when their bounding boxes overlap, so a round trigger is a square one for players.
*Since:* 3.00.
*See also:* <<ontrigger,onTrigger>>, <<bodynew,bodyNew>>, <<bodysetwater,bodySetWater>>
.Example
[source,lua]
----
-- A finish line across the track.
line = nodeNew()
nodeSetPosition(line, 0, 0, -20)
bodyNew(line, BODY_STATIC, SHAPE_BOX, 6, 3, 0.5)
bodySetTrigger(line, true)
function onTrigger(trigger, other, entered)
if trigger == line and other == car and entered then
lapDone()
end
end
----
[#bodysetvelocity]
==== bodySetVelocity
[source,text]
----
bodySetVelocity(node, x, y, z)
----
Sets the body's velocity along each world axis, in world units per second, and wakes it. This replaces whatever speed the body had, where `bodyApplyImpulse` adds to it. Static bodies ignore it, and a kinematic body's velocity is overwritten from its node's movement at the next step.
*Since:* 3.00.
*See also:* <<bodygetvelocity,bodyGetVelocity>>, <<bodyapplyimpulse,bodyApplyImpulse>>, <<bodysetangularvelocity,bodySetAngularVelocity>>
.Example
[source,lua]
----
-- Serve the ball toward whichever side lost the last point.
function serve()
nodeSetPosition(ball, 0, 0.5, 0)
bodyNew(ball, BODY_DYNAMIC, SHAPE_SPHERE, 0.15)
bodySetMass(ball, 0.1)
local dir = (lastWinner == 1) and -1 or 1
bodySetVelocity(ball, 4 * dir, 2, (math.random() - 0.5) * 2)
end
----
[#bodysetwater]
==== bodySetWater
[source,text]
----
bodySetWater(node, density)
bodySetWater(node, density, linearDrag)
bodySetWater(node, density, linearDrag, angularDrag)
----
Fills a static body with water and makes it a trigger: the top of its shape is the surface, and every dynamic body inside floats, sinks or rolls by buoyancy against that surface (the submerged part of the shape counts, so a tilted crate rights itself), slowed by the drags. A player whose middle is below the surface swims. Because the volume is a trigger, `onTrigger` fires on the way in and out. The water is not drawn: give the node a blended material, or none. Raises an error when the body is not static.
*Parameters:*
* `density` -- a multiplier, `1` for water; each body's own `bodySetBuoyancy` scales it. Negative values are clamped to `0`.
* `linearDrag` -- how quickly the water slows movement through it; default `0.5`.
* `angularDrag` -- how quickly it slows spinning; default `0.1`.
*Notes:* Players and boats see at most eight water volumes; more than that still float bodies but are not swum in.
*Since:* 3.00.
*See also:* <<bodysetbuoyancy,bodySetBuoyancy>>, <<bodysetcurrent,bodySetCurrent>>, <<bodysettrigger,bodySetTrigger>>, <<playerisswimming,playerIsSwimming>>
.Example
[source,lua]
----
-- A pool cut into the deck; its top, at Y = 0, is the surface.
pool = nodeNew()
nodeSetMesh(pool, meshBox(8, 3, 12), water)
nodeSetPosition(pool, 0, -1.5, 0)
bodyNew(pool, BODY_STATIC, SHAPE_BOX, 8, 3, 12)
bodySetWater(pool, 1.0, 0.6, 0.2)
function onTrigger(trigger, other, entered)
if trigger == pool and entered then
soundPlay(splash)
end
end
----
[#camera]
=== Camera
The camera is a node like any other: place it with `nodeSetPosition`, aim it with `nodeLookAt`, parent it to a moving object for a chase view, and name it with `cameraSet`. It looks down its own -Z. Without a camera node the scene is viewed from `(0, 0, 5)` looking at the origin. The projection is set separately and applies to whichever node is the camera: perspective with a 60 degree vertical field of view from `0.1` to `1000` units by default, or orthographic with `cameraSetOrthographic`. Rendered views (`viewNew`) share the projection but look from their own nodes. See <<scenes3d,3D Scenes>>.
[#cameraset]
==== cameraSet
[source,text]
----
cameraSet(node)
cameraSet(-1)
----
Makes a node the camera: the scene is drawn from its world position looking down its own -Z, so its parents' movement and rotation count. Any node will do, including one that also carries a mesh or a light, and switching cameras is one call. `-1` restores the default view from `(0, 0, 5)` looking at the origin. Deleting the camera node also falls back to the default view. Raises an error for a node handle that is not valid.
*Since:* 3.00.
*See also:* <<nodelookat,nodeLookAt>>, <<camerasetperspective,cameraSetPerspective>>, <<viewsetcamera,viewSetCamera>>, <<nodenew,nodeNew>>
.Example
[source,lua]
----
-- A chase camera behind the player's car, and a fixed one for the replay.
chaseCamera = nodeNew(car)
nodeSetPosition(chaseCamera, 0, 2, 6)
nodeLookAt(chaseCamera, nodeGetWorldPosition(car))
cameraSet(chaseCamera)
function onReplayStart()
replayCamera = nodeNew()
nodeSetPosition(replayCamera, 30, 12, 30)
nodeLookAt(replayCamera, 0, 0, 0)
cameraSet(replayCamera)
end
----
[#camerasetorthographic]
==== cameraSetOrthographic
[source,text]
----
cameraSetOrthographic(height, near, far)
----
Switches to a parallel projection showing `height` world units top to bottom, the width following the layer's aspect ratio, from `near` to `far` in front of the camera. Distance does not shrink things, which suits diagrams, isometric and side-on views, and HUD-like scenes. The setting applies to the current and any later camera node until `cameraSetPerspective` is called. A directional light's shadow uses a single map under an orthographic camera, whatever `sceneSetShadowCascades` says.
*Parameters:*
* `height` -- the visible height, in world units.
* `near` -- the closest distance drawn, in world units in front of the camera.
* `far` -- the farthest distance drawn.
*Since:* 3.00.
*See also:* <<camerasetperspective,cameraSetPerspective>>, <<cameraset,cameraSet>>, <<scenesetshadowcascades,sceneSetShadowCascades>>
.Example
[source,lua]
----
-- An isometric view of the board: 12 units tall, from high up and to the side.
boardCamera = nodeNew()
nodeSetPosition(boardCamera, 20, 20, 20)
nodeLookAt(boardCamera, 0, 0, 0)
cameraSet(boardCamera)
cameraSetOrthographic(12, 1, 100)
----
[#camerasetperspective]
==== cameraSetPerspective
[source,text]
----
cameraSetPerspective(fov, near, far)
----
Switches to the usual perspective projection, the default. `fov` is the vertical field of view in degrees (default `60`); `near` and `far` (defaults `0.1` and `1000`) bound what is drawn, in world units in front of the camera. Depth precision is spread between the two, so keep `near` as large as the scene allows and `far` no larger than needed: `0.05` to `100` for a room, `0.1` to `400` for a landscape. The setting applies to the current and any later camera node, and to rendered views.
*Parameters:*
* `fov` -- vertical field of view in degrees; a wide `70` feels fast, a narrow `40` feels like a long lens.
* `near` -- the closest distance drawn.
* `far` -- the farthest distance drawn.
*Since:* 3.00.
*See also:* <<camerasetorthographic,cameraSetOrthographic>>, <<cameraset,cameraSet>>, <<sceneproject,sceneProject>>
.Example
[source,lua]
----
-- A tighter lens for the cockpit view, the default for the outside.
function setView(inside)
if inside then
cameraSet(cockpitCamera)
cameraSetPerspective(45, 0.05, 300)
else
cameraSet(chaseCamera)
cameraSetPerspective(60, 0.1, 400)
end
end
----
[#color]
=== Color
The foreground and background colors are engine globals rather than per-call arguments: set them with `colorForeground` and `colorBackground`, and every later drawing call reads them until they change. The foreground color is used by the `overlay*` shape primitives and by TrueType text from `fontPrint` and `fontToSprite`; the background color is used by `overlayClear` and by `FONT_QUALITY_SHADED` text. `overlayPrint` uses neither. Channels are integers from `0` to `255`, clamped into that range, and both colors accept an optional alpha. See the <<overlay,Overlay>> and <<font,Font>> sections for the calls that consume them.
[#colorbackground]
==== colorBackground
[source,text]
----
colorBackground(r, g, b)
colorBackground(r, g, b, a)
----
Sets the background color: what `overlayClear` fills the overlay with, and what `FONT_QUALITY_SHADED` text from `fontPrint` and `fontToSprite` paints behind its glyphs. At startup it is transparent black, and alpha defaults to `0` when omitted, so `colorBackground(0, 0, 0)` followed by `overlayClear()` makes the overlay invisible again. A partial alpha over black dims the video; `255` hides it. Out-of-range channel values are clamped rather than rejected.
*Parameters:*
* `r`, `g`, `b` -- red, green, and blue, `0` to `255`.
* `a` -- opacity, `0` (transparent) to `255` (opaque); default `0`.
*Since:* 1.x
*See also:* <<colorforeground,colorForeground>>, <<overlayclear,overlayClear>>, <<fontquality,fontQuality>>
.Example
[source,lua]
----
-- Solid black behind the calibration screen, transparent during play.
function onOverlayUpdate()
if calibrating then
colorBackground(0, 0, 0, 255)
else
colorBackground(0, 0, 0, 0)
end
overlayClear()
drawHud()
return OVERLAY_UPDATED
end
----
[#colorforeground]
==== colorForeground
[source,text]
----
colorForeground(r, g, b)
colorForeground(r, g, b, a)
----
Sets the foreground color used by `overlayBox`, `overlayCircle`, `overlayEllipse`, `overlayLine`, and `overlayPlot`, and the text color for `fontPrint` and `fontToSprite`. At startup it is opaque white, and alpha defaults to `255` when omitted, the opposite of `colorBackground`. Sprites and `overlayPrint` are drawn with their own pixels and are not tinted. Out-of-range channel values are clamped rather than rejected.
*Parameters:*
* `r`, `g`, `b` -- red, green, and blue, `0` to `255`.
* `a` -- opacity, `0` (transparent) to `255` (opaque); default `255`.
*Since:* 1.x
*See also:* <<colorbackground,colorBackground>>, <<overlaybox,overlayBox>>, <<fontprint,fontPrint>>, <<fonttosprite,fontToSprite>>
.Example
[source,lua]
----
-- Score in white, low-health warning in red, both from the same font.
function onOverlayUpdate()
overlayClear()
fontSelect(hudFont)
colorForeground(255, 255, 255)
fontPrint(8, 8, "Score: " .. score)
if lives == 1 then
colorForeground(255, 32, 32)
fontPrint(8, 32, "LAST LIFE")
end
return OVERLAY_UPDATED
end
----
[#controller]
=== Controller
Singe supports up to four game controllers, indices `0` through `3`, opened automatically at startup and whenever one is plugged in or removed; there is no `controllerLoad`. Analog axes are reported through `onControllerMoved` (see <<enginecallbacks,Engine Callbacks>>) and polled with `controllerGetAxis`, in SDL's raw range of `-32768` to `32767`. Digital buttons, and each direction of each axis, are switches like keys: they reach `onInputPressed` and `onInputReleased` when `controls.cfg` maps a code from the `GAMEPAD_0` through `GAMEPAD_3` tables (for example `INPUT_ACTION_1 = { GAMEPAD_0.BUTTON_A }`), and `controllerGetButton` polls a button's live state. `SINGE_DEAD_ZONE`, from the `DEAD_ZONE` entry in `controls.cfg`, is the threshold the engine itself uses before an axis direction counts as a pressed switch; treat axis motion below it as noise.
[#controllergetaxis]
==== controllerGetAxis
[source,text]
----
value = controllerGetAxis(controller, axis)
----
Returns the position of an analog axis as the engine last saw it. The value is updated from every axis event, including motion inside the dead zone, and is `0` for a controller that is not connected or an axis that has never moved. Sticks range from `-32768` to `32767`; triggers rest at `0` and reach `32767` when fully pressed. An index outside `0` to `3`, or an axis outside `0` to `5`, aborts the script.
*Parameters:*
* `controller` -- controller index, `0` through `3`.
* `axis` -- `GAMEPAD_AXIS_LEFT_X` (`0`), `GAMEPAD_AXIS_LEFT_Y` (`1`), `GAMEPAD_AXIS_RIGHT_X` (`2`), `GAMEPAD_AXIS_RIGHT_Y` (`3`), `GAMEPAD_AXIS_LEFT_TRIGGER` (`4`) or `GAMEPAD_AXIS_RIGHT_TRIGGER` (`5`).
*Returns:* integer, `-32768` to `32767`.
*Since:* 2.00
*See also:* <<controllergetbutton,controllerGetButton>>, <<oncontrollermoved,onControllerMoved>>
.Example
[source,lua]
----
-- Steer with the left stick, throttle with the right trigger.
function onOverlayUpdate()
local steer = controllerGetAxis(0, GAMEPAD_AXIS_LEFT_X)
local throttle = controllerGetAxis(0, GAMEPAD_AXIS_RIGHT_TRIGGER)
if math.abs(steer) > SINGE_DEAD_ZONE then
heading = heading + (steer / 32768) * turnRate
end
speed = speed + (throttle / 32767) * acceleration
drawHud()
return OVERLAY_UPDATED
end
----
[#controllergetbutton]
==== controllerGetButton
[source,text]
----
pressed = controllerGetButton(controller, button)
----
Returns whether a button is held right now, read directly from the controller rather than from the event queue. Use it for hold-to-charge and similar cases where `onInputPressed` and `onInputReleased` are awkward. A slot with no controller returns `false`. The button code must come from the `GAMEPAD_N` table of the same controller index; a code from another controller's table, or an axis code, falls outside the button range and aborts the script, as does an index outside `0` to `3`.
*Parameters:*
* `controller` -- controller index, `0` through `3`.
* `button` -- a `BUTTON_*` or `DPAD_*` value from the matching table, such as `GAMEPAD_0.BUTTON_A.value`.
*Returns:* boolean.
*Since:* 2.10
*See also:* <<controllergetaxis,controllerGetAxis>>, <<keyboardisdown,keyboardIsDown>>, <<oninputpressedoninputreleased,onInputPressed>>
.Example
[source,lua]
----
-- Charge while A is held, fire on release.
function onOverlayUpdate()
if controllerGetButton(0, GAMEPAD_0.BUTTON_A.value) then
charge = math.min(charge + 1, chargeMax)
elseif charge > 0 then
fireShot(charge)
charge = 0
end
drawChargeBar(charge)
return OVERLAY_UPDATED
end
----
[#debug]
=== Debug
Two calls for seeing what a script is doing: `debugPrint` writes text, and `lineDraw` draws a line over the 3D scene for one frame (`physicsSetDebug` and `navDraw` draw through the same lines). Printed output goes to the console Singe was started from, so it is visible during development and harmless in a cabinet, where `--noconsole` silences it. When Singe runs with `--trace`, every API call including this one is also written with its arguments to `trace.txt` in the data directory, which is the way to get a permanent record. Command line options are listed in the chapter on running Singe; the ZeroBrane Studio integration in `zbstudio/` offers a debugger when printing is not enough.
[#debugprint]
==== debugPrint
[source,text]
----
debugPrint(message)
----
Prints a line to the console (standard output). Nothing is printed when Singe was started with `--noconsole`; with `--trace` the call and its message also land in `trace.txt`. The argument must be a string or a number; anything else aborts the script, so wrap values in `tostring` or use `string.format`. It is cheap enough to leave in shipping code but is not throttled, so a print inside `onOverlayUpdate` runs every frame.
*Parameters:*
* `message` -- the text to print; a number is converted.
*Since:* 1.x
*See also:* <<singegetticks,singeGetTicks>>
.Example
[source,lua]
----
-- Log why the shot missed, with the frame it happened on.
function resolveShot(x, y)
local hit = targetAt(x, y)
if not hit then
debugPrint(string.format("miss at %d,%d on frame %d", x, y, discGetFrame()))
soundPlay(missClip)
return
end
score = score + hit.points
end
----
[#linedraw]
==== lineDraw
[source,text]
----
lineDraw(x0, y0, z0, x1, y1, z1)
lineDraw(x0, y0, z0, x1, y1, z1, r, g, b)
----
Draws a straight line between two world-space points over the scene for the current frame only, white unless a color is given. Call it every frame to keep a line on screen, as with the overlay. The line is depth tested against the scene but does not write depth, so it hides behind walls like anything else and never occludes. Nothing is drawn while the scene is disabled. It is meant for seeing what a script is doing (a ray cast, a path, a bounding box); `physicsSetDebug` and `navDraw` draw through the same lines.
*Parameters:*
* `x0, y0, z0` -- the start point, in world units.
* `x1, y1, z1` -- the end point, in world units.
* `r, g, b` -- the line color, `0` to `255` per channel, clamped; white when omitted.
*Since:* 3.00.
*See also:* <<physicssetdebug,physicsSetDebug>>, <<navdraw,navDraw>>, <<physicsraycast,physicsRaycast>>
.Example
[source,lua]
----
-- Show where the turret is aiming and whether the shot would land.
function onOverlayUpdate()
local tx, ty, tz = nodeGetWorldPosition(turret)
local hit, hx, hy, hz = physicsRaycast(tx, ty, tz, aimX, aimY, aimZ, 40)
if hit then
lineDraw(tx, ty, tz, hx, hy, hz, 255, 255, 0)
else
lineDraw(tx, ty, tz, tx + aimX * 40, ty + aimY * 40, tz + aimZ * 40, 120, 120, 120)
end
return OVERLAY_UPDATED
end
----
[#disc]
=== Disc
The *disc* is the main laserdisc video, the one Singe was originally built to emulate. A game has at most one disc, opened at startup from the `VIDEO` named in `games.dat` or from `--disc` / `--framefile` on the command line; a game that names none runs without a disc (see <<withoutadisc,Games Without a Disc>>). All `disc*` functions operate on this single implicit disc and take no handle. Frame numbers are zero based positions in the video file (or in the combined framefile) and are the time reference for every branch decision in a laserdisc game. Without a disc the query functions return `0`, `discGetState` reports `DISC_EJECTED`, and the transport functions trace and do nothing. Extra clips layered on top of the disc are the job of the <<video,Video>> family.
[#discaudio]
==== discAudio
[source,text]
----
discAudio(channel, enabled)
----
Turns one channel of the disc's soundtrack on or off. The channel is set to the configured laserdisc volume (`--volume_vldp`, default `100`) when `enabled` is `true` and to `0` when it is `false`; the other channel is left as it is. Many original titles carried narration or a data track on one channel only, and this is the call that silences it. A channel other than `1` or `2` terminates the script. Without a disc the call does nothing.
*Parameters:*
* `channel` -- `1` for the left channel, `2` for the right.
* `enabled` -- boolean. `true` restores the channel to the configured laserdisc volume, `false` silences it.
*Since:* 1.x
*See also:* <<discsetaudiotrack,discSetAudioTrack>>, <<discgetaudiotracks,discGetAudioTracks>>, <<videosetvolume,videoSetVolume>>
.Example
[source,lua]
----
-- The right channel of this disc carries the arcade's data track.
function startGame()
discAudio(1, true)
discAudio(2, false)
discSkipToFrame(SCENE_FIRST_FRAME)
end
----
[#discchangespeed]
==== discChangeSpeed
[source,text]
----
discChangeSpeed(...)
----
Does nothing. Real laserdisc players could run at other than 1x; Singe's software player cannot, so the call accepts any arguments, logs a trace line and returns. It is kept so that pre-2.00 scripts load without edits.
*Notes:*
WARNING: *Unimplemented.* Retained for backward compatibility only. There is no replacement; playback speed is fixed at the video file's frame rate.
*Since:* 1.x
*See also:* <<discplay,discPlay>>, <<discskiptoframe,discSkipToFrame>>
.Example
[source,lua]
----
-- Old scripts sometimes bracketed a slow-motion replay with speed changes.
-- The calls are harmless today, but the replay plays at normal speed.
discChangeSpeed(0.5)
discSkipToFrame(REPLAY_FIRST_FRAME)
discChangeSpeed(1)
----
[#discgetaudiotrack]
==== discGetAudioTrack
[source,text]
----
track = discGetAudioTrack()
----
Returns the zero based index of the audio track the disc is currently playing. At startup the engine selects the track named by `--audio` or the `AUDIO_TRACK` entry in `games.dat` (default `0`) when the disc has that many tracks. Returns `0` without a disc.
*Returns:* integer track index.
*Since:* 2.10
*See also:* <<discgetaudiotracks,discGetAudioTracks>>, <<discsetaudiotrack,discSetAudioTrack>>, <<discgetlanguage,discGetLanguage>>
.Example
[source,lua]
----
-- Cycle to the next dub each time the player presses the language key.
function onInputPressed(what)
if what == SWITCH_BUTTON2 then
local next = (discGetAudioTrack() + 1) % discGetAudioTracks()
discSetAudioTrack(next)
languageLabel = discGetLanguageDescription(discGetLanguage(next))
end
end
----
[#discgetaudiotracks]
==== discGetAudioTracks
[source,text]
----
count = discGetAudioTracks()
----
Returns how many audio streams the disc's video file contains. Multiple streams are typically alternate language dubs. A silent video reports `0`, and so does a game without a disc, so guard loops and modulo arithmetic against zero.
*Returns:* integer count, `0` or more.
*Since:* 2.10
*See also:* <<discgetaudiotrack,discGetAudioTrack>>, <<discgetlanguage,discGetLanguage>>, <<discsetaudiotrack,discSetAudioTrack>>
.Example
[source,lua]
----
-- Build a language-select menu from the disc's audio tracks.
languages = {}
for track = 0, discGetAudioTracks() - 1 do
local code = discGetLanguage(track)
local name = discGetLanguageDescription(code)
table.insert(languages, { track = track, label = name })
end
----
[#discgetframe]
==== discGetFrame
[source,text]
----
frame = discGetFrame()
----
Returns the frame the disc is currently showing. With a framefile the number is the position in the combined sequence, so segment boundaries are invisible to the script. Returns `0` while the disc is stopped and without a disc. Poll it from `onOverlayUpdate` to react to the video reaching a decision point.
*Returns:* integer frame number.
*Since:* 1.x
*See also:* <<discsearch,discSearch>>, <<discskiptoframe,discSkipToFrame>>, <<discgetstate,discGetState>>
.Example
[source,lua]
----
-- Loop the attract sequence and show a prompt during the duck window.
function onOverlayUpdate()
local frame = discGetFrame()
if frame >= ATTRACT_LAST_FRAME then
discSkipToFrame(ATTRACT_FIRST_FRAME)
elseif frame >= DUCK_FIRST_FRAME and frame <= DUCK_LAST_FRAME then
drawPrompt("DUCK")
end
return OVERLAY_UPDATED
end
----
[#discgetheight]
==== discGetHeight
[source,text]
----
height = discGetHeight()
----
Returns the height of the disc's video in pixels. Without a disc it returns the canvas height (`CANVAS_Y` in `games.dat` or `--canvas`, default `480`), so it is always the size of the background the overlay is stretched over. Pair it with `discGetWidth` to give the overlay the same resolution as the video.
*Returns:* integer height in pixels.
*Since:* 2.00
*See also:* <<discgetwidth,discGetWidth>>, <<overlaysetresolution,overlaySetResolution>>, <<vldpgetheight,vldpGetHeight>>
.Example
[source,lua]
----
-- Draw at native disc resolution so pixel checks and drawing share one grid.
overlaySetResolution(discGetWidth(), discGetHeight())
crosshairLimitX = discGetWidth() - 1
crosshairLimitY = discGetHeight() - 1
----
[#discgetlanguage]
==== discGetLanguage
[source,text]
----
code = discGetLanguage(track)
----
Returns the language tag stored in the video file for one of the disc's audio tracks. The tag is a three letter ISO 639 code such as `"eng"` or `"jpn"`. Tracks without a three letter tag return `"unk"`, and so does every track when there is no disc. With a disc, a track outside `0` to `discGetAudioTracks() - 1` terminates the script.
*Returns:* string language code.
*Since:* 2.10
*See also:* <<videogetlanguagedescription,videoGetLanguageDescription>>, <<discgetaudiotracks,discGetAudioTracks>>, <<discsetaudiotrack,discSetAudioTrack>>
.Example
[source,lua]
----
-- Prefer the Japanese dub when the disc carries one.
for track = 0, discGetAudioTracks() - 1 do
if discGetLanguage(track) == "jpn" then
discSetAudioTrack(track)
break
end
end
----
[#discgetstate]
==== discGetState
[source,text]
----
state = discGetState()
----
Returns the transport state of the disc as one of the `DISC_*` constants. A game without a disc reports `DISC_EJECTED`; after `discStop` the disc reports `DISC_STOPPED` until the next `discPlay`, `discSearch` or `discSkipToFrame`; otherwise the state is `DISC_PLAYING` while the video advances and `DISC_PAUSED` when it does not. There is no separate searching state; a disc that is seeking reports `DISC_PAUSED` or `DISC_PLAYING` according to what was requested.
*Returns:* one of the following constants, defined by the engine and available to every script.
[cols="1,1,1",options="header"]
|===
| Constant | Value | Meaning
| `DISC_STOPPED` | `2` | Stopped (`discStop` was called)
| `DISC_PLAYING` | `3` | Playing
| `DISC_PAUSED` | `4` | Paused
| `DISC_EJECTED` | `5` | The game has no disc (see <<withoutadisc,Games Without a Disc>>)
|===
*Since:* 1.x (RDG)
*See also:* <<discplay,discPlay>>, <<discpause,discPause>>, <<discstop,discStop>>
.Example
[source,lua]
----
-- The pause key must not resume a freeze the story script asked for.
function onInputPressed(what)
if what == SWITCH_PAUSE then
if discGetState() == DISC_PAUSED and not storyPaused then
discPlay()
elseif discGetState() == DISC_PLAYING then
discPause()
end
end
end
----
[#discgetwidth]
==== discGetWidth
[source,text]
----
width = discGetWidth()
----
Returns the width of the disc's video in pixels. Without a disc it returns the canvas width (`CANVAS_X` in `games.dat` or `--canvas`, default `720`). Together with `discGetHeight` this is the size the overlay is scaled onto, which is why the shipped menu passes both to `overlaySetResolution`.
*Returns:* integer width in pixels.
*Since:* 2.00
*See also:* <<discgetheight,discGetHeight>>, <<overlaysetresolution,overlaySetResolution>>, <<vldpgetwidth,vldpGetWidth>>
.Example
[source,lua]
----
-- Center a title sprite on whatever the disc's width happens to be.
overlaySetResolution(discGetWidth(), discGetHeight())
titleSprite = spriteLoad("graphics/title.png")
titleX = (discGetWidth() - spriteGetWidth(titleSprite)) / 2
----
[#discpause]
==== discPause
[source,text]
----
discPause()
----
Pauses playback on the current frame, which stays on screen. The disc's audio pauses with it. The call is ignored while the disc is stopped and does nothing without a disc. Resume with `discPlay`.
*Since:* 1.x
*See also:* <<discplay,discPlay>>, <<discstop,discStop>>, <<singesetpauseflag,singeSetPauseFlag>>
.Example
[source,lua]
----
-- Hold the last frame of the scene while the player chooses a door.
function onOverlayUpdate()
if discGetFrame() >= SCENE_LAST_FRAME and not choosing then
discPause()
choosing = true
end
return OVERLAY_UPDATED
end
----
[#discpauseatframe]
==== discPauseAtFrame
[source,text]
----
discPauseAtFrame(frame)
----
Seeks to `frame`, shows it and pauses. This is the same binding as `discSearch` under a second name; the two are interchangeable in every respect, including the wrapping and clamping rules described there. Prefer `discSearch` in new code and keep `discPauseAtFrame` only when porting old scripts.
*Notes:*
WARNING: *Legacy alias.* Use <<discsearch,discSearch>> in new code.
*Since:* 1.18
*See also:* <<discsearch,discSearch>>, <<discplay,discPlay>>
.Example
[source,lua]
----
-- Ported 1.x code: park on the scene's first frame until the player is ready.
function onInputPressed(what)
if what == SWITCH_START1 then
discPauseAtFrame(SCENE_FIRST_FRAME)
waitingForPlayer = true
end
end
----
[#discplay]
==== discPlay
[source,text]
----
discPlay()
----
Starts or resumes playback from the current frame and clears the stopped state set by `discStop`. Audio resumes with the video, and the playback clock restarts from the current frame so a long pause does not cause a jump. Does nothing without a disc, apart from clearing the stopped flag.
*Since:* 1.x
*See also:* <<discpause,discPause>>, <<discstop,discStop>>, <<discskiptoframe,discSkipToFrame>>
.Example
[source,lua]
----
-- Standard attract-mode start: cue the first frame, then roll.
function attractBegin()
discSearch(ATTRACT_FIRST_FRAME)
overlayClear()
discPlay()
attractRunning = true
end
----
[#discsearch]
==== discSearch
[source,text]
----
discSearch(frame)
----
Seeks the disc to `frame`, shows that frame and pauses on it, clearing the stopped state if the disc was stopped. The disc holds the frame until `discPlay`, `discSkipToFrame` or another transport call. A negative frame clamps to the first frame and one past the end clamps to the last; with a framefile the segment containing the frame is selected and the position is clamped inside it. Does nothing without a disc.
*Parameters:*
* `frame` -- target frame number, zero based.
*Since:* 1.x
*See also:* <<discskiptoframe,discSkipToFrame>>, <<discplay,discPlay>>, <<discgetframe,discGetFrame>>
.Example
[source,lua]
----
-- Player picked the left corridor: cue the scene and wait for the go.
function goLeft()
discSearch(CORRIDOR_LEFT_FIRST_FRAME)
soundPlay(SOUND_DOOR)
waitingForPlayer = true
end
----
[#discsearchblanking]
==== discSearchBlanking
[source,text]
----
discSearchBlanking(...)
----
Does nothing. On laserdisc hardware this chose whether the screen blanked while the player sought a frame; Singe's software player seeks without blanking, so the call accepts any arguments, logs a trace line and returns.
*Notes:*
WARNING: *Unimplemented.* Retained for backward compatibility only. There is no replacement.
*Since:* 1.x
*See also:* <<discsearch,discSearch>>, <<discskipblanking,discSkipBlanking>>
.Example
[source,lua]
----
-- Pre-2.00 scripts often set the blanking modes once at startup. Harmless.
discSearchBlanking(false)
discSkipBlanking(false)
discSearch(TITLE_FRAME)
----
[#discsetaudiotrack]
==== discSetAudioTrack
[source,text]
----
discSetAudioTrack(track)
----
Switches the disc to another audio track. Queued audio from the old track is dropped and playback realigns, so the change is heard almost immediately. With a disc, a track outside `0` to `discGetAudioTracks() - 1` terminates the script; without a disc the call does nothing. Use it to implement a language menu or to honor a preference saved between sessions.
*Parameters:*
* `track` -- zero based audio track index.
*Since:* 2.10
*See also:* <<discgetaudiotrack,discGetAudioTrack>>, <<discgetaudiotracks,discGetAudioTracks>>, <<discgetlanguage,discGetLanguage>>
.Example
[source,lua]
----
-- Apply the language the player chose in the options screen.
function applyLanguage(choice)
if choice >= 0 and choice < discGetAudioTracks() then
discSetAudioTrack(choice)
settings.language = choice
saveSettings()
end
end
----
[#discsetfps]
==== discSetFPS
[source,text]
----
discSetFPS(...)
----
Does nothing. The frame rate comes from the video file, so the call accepts any arguments, logs a trace line and returns. Nothing in the shipped `Framework.singe` calls it; only pre-2.00 scripts do.
*Notes:*
WARNING: *Unimplemented.* Retained for backward compatibility only. The frame rate is read from the video file and cannot be set from a script.
*Since:* 1.x
*See also:* <<discgetframe,discGetFrame>>, <<discskiptoframe,discSkipToFrame>>
.Example
[source,lua]
----
-- A ported 1.x script. The call is ignored; frames per second is a property of the file.
discSetFPS(29.97)
discSearch(TITLE_FRAME)
discPlay()
----
[#discskipbackward]
==== discSkipBackward
[source,text]
----
discSkipBackward(frames)
----
Seeks backward by `frames` from the current frame without changing the play or pause state: a playing disc keeps playing from the new position, a paused disc stays paused on it. The call is ignored while the disc is stopped and without a disc. The delta is not validated; a negative value skips forward, and a delta larger than the current frame clamps at frame `0`. With a framefile the delta applies to the frame number `discGetFrame` reports, so a skip may cross into an earlier segment.
*Parameters:*
* `frames` -- number of frames to subtract from the current frame.
*Since:* 1.x
*See also:* <<discskipforward,discSkipForward>>, <<discstepbackward,discStepBackward>>, <<discgetframe,discGetFrame>>
.Example
[source,lua]
----
-- Replay the last second (30 frames) when the player misses a prompt.
function missedPrompt()
lives = lives - 1
discSkipBackward(30)
promptShown = false
end
----
[#discskipblanking]
==== discSkipBlanking
[source,text]
----
discSkipBlanking(...)
----
Does nothing. It once chose whether the screen blanked during a skip on laserdisc hardware; Singe's software player never blanks, so the call accepts any arguments, logs a trace line and returns.
*Notes:*
WARNING: *Unimplemented.* Retained for backward compatibility only. There is no replacement.
*Since:* 1.x
*See also:* <<discsearchblanking,discSearchBlanking>>, <<discskiptoframe,discSkipToFrame>>
.Example
[source,lua]
----
-- Pre-2.00 scripts often set the blanking modes once at startup. Harmless.
discSearchBlanking(false)
discSkipBlanking(false)
discSearch(TITLE_FRAME)
----
[#discskipforward]
==== discSkipForward
[source,text]
----
discSkipForward(frames)
----
Seeks forward by `frames` from the current frame without changing the play or pause state. Mirror of `discSkipBackward`: ignored while stopped and without a disc, the delta is not validated, and a target past the last frame clamps at the last frame. With a framefile the delta applies to the frame number `discGetFrame` reports, so a skip may cross into a later segment.
*Parameters:*
* `frames` -- number of frames to add to the current frame.
*Since:* 1.x
*See also:* <<discskipbackward,discSkipBackward>>, <<discstepforward,discStepForward>>, <<discgetframe,discGetFrame>>
.Example
[source,lua]
----
-- Let a returning player skip the two-second scene intro.
function onInputPressed(what)
if what == SWITCH_BUTTON1 and inSceneIntro then
discSkipForward(60)
inSceneIntro = false
end
end
----
[#discskiptoframe]
==== discSkipToFrame
[source,text]
----
discSkipToFrame(frame)
----
Seeks to `frame` and starts playing from it no matter the disc's state, clearing the stopped state if necessary. Contrast with `discSearch`, which seeks and pauses. A frame before the start or past the end clamps to the first or last frame; with a framefile the segment containing the frame is selected and the position is clamped inside it. Does nothing without a disc, apart from clearing the stopped flag.
*Parameters:*
* `frame` -- target frame number, zero based.
*Since:* 1.x
*See also:* <<discsearch,discSearch>>, <<discplay,discPlay>>, <<discgetframe,discGetFrame>>
.Example
[source,lua]
----
-- Skip the intro on a replay.
function startGame()
if hasPlayedBefore then
discSkipToFrame(GAMEPLAY_FIRST_FRAME)
else
discSkipToFrame(INTRO_FIRST_FRAME)
end
end
----
[#discstepbackward]
==== discStepBackward
[source,text]
----
discStepBackward()
----
Moves the disc back exactly one frame and pauses on it, whatever the previous play state. Stepping at frame `0` stays on frame `0`. Ignored while the disc is stopped, like `discSkipBackward`. Intended for frame-accurate debugging and service screens. Does nothing without a disc.
*Since:* 1.x
*See also:* <<discstepforward,discStepForward>>, <<discskipbackward,discSkipBackward>>, <<discgetframe,discGetFrame>>
.Example
[source,lua]
----
-- Service screen: left and right nudge the disc one frame at a time.
function onInputPressed(what)
if what == SWITCH_LEFT then
discStepBackward()
elseif what == SWITCH_RIGHT then
discStepForward()
end
end
----
[#discstepforward]
==== discStepForward
[source,text]
----
discStepForward()
----
Moves the disc forward exactly one frame and pauses on it, whatever the previous play state. Stepping on the last frame stays on the last frame. Like `discStepBackward` it is ignored while the disc is stopped. Does nothing without a disc.
*Since:* 1.x
*See also:* <<discstepbackward,discStepBackward>>, <<discskipforward,discSkipForward>>, <<discgetframe,discGetFrame>>
.Example
[source,lua]
----
-- Frame finder: step through a scene and print the frame number.
function onOverlayUpdate()
overlayClear()
overlayPrint(8, 8, "Frame " .. discGetFrame())
if stepping then
discStepForward()
end
return OVERLAY_UPDATED
end
----
[#discstop]
==== discStop
[source,text]
----
discStop()
----
Stops the disc. Playback pauses, the video is replaced by the classic blue screen, `discGetState` reports `DISC_STOPPED` and `discGetFrame` returns `0` until the disc is started again. While stopped, `discPause`, `discSkipForward`, `discSkipBackward`, `discStepForward` and `discStepBackward` are ignored. Any of `discPlay`, `discSearch` or `discSkipToFrame` clears the stopped state. A second `discStop` is ignored.
*Since:* 1.x
*See also:* <<discplay,discPlay>>, <<discpause,discPause>>, <<discgetstate,discGetState>>
.Example
[source,lua]
----
-- Game over: drop to the blue screen behind the score table.
function gameOver()
discStop()
soundPlay(SOUND_GAME_OVER)
showScores = true
end
----
[#emitter]
=== Emitter
Emitters spawn, move and draw particles; see <<particles,Particles>>. Handles
are integers. A 2D emitter lives in overlay coordinates, Y down, and draws
when `emitterDraw` asks from `onOverlayUpdate`; a 3D emitter sits on a node,
lives in world units, and draws itself in the scene. The recipe calls can be
made in any order, before or after the emitter starts, and take effect on the
next particles born (a few, noted below, act on the live ones too).
Everything given as a minimum and maximum is drawn at random between them for
every particle. Particles advance with the wall clock, at most a tenth of a
second per frame, and hold while the game is paused.
[#emitternew]
==== emitterNew
[source,text]
----
emitter = emitterNew()
emitter = emitterNew(node)
----
Makes an emitter. With no argument it is a 2D one at overlay position `(0, 0)`, moved with `emitterSetPosition`; with a node it is a 3D one whose particles are born at that node's world position, and it needs the 3D scene, so on a machine without one the call raises an error (2D emitters work everywhere). A new emitter is not streaming; call `emitterStart` or `emitterBurst`. Deleting the node deletes its emitters. Defaults: 50 particles a second, living one to two seconds, 30 degree spread, white fading to transparent, normal blending, no gravity, drag, spin, radius, texture, trail or collision, a pool of 1000; a 2D emitter starts them up the screen at 50 to 100 overlay units a second shrinking from 16 to 4, a 3D one up the Y axis at 1 to 2 world units a second shrinking from 0.2 to 0.05.
*Returns:* The emitter handle.
*Since:* 3.00.
*See also:* <<emitterdelete,emitterDelete>>, <<emitterstart,emitterStart>>, <<emitterburst,emitterBurst>>, <<emitterdraw,emitterDraw>>
.Example
[source,lua]
----
-- Exhaust from the ship's engine node; the recipe is set before the stream starts.
exhaust = emitterNew(nodeFind("Engine", ship))
emitterSetBlend(exhaust, PARTICLE_ADD)
emitterSetRate(exhaust, 120)
emitterSetLife(exhaust, 0.3, 0.6)
emitterSetDirection(exhaust, 0, 0, 1)
emitterSetSpeed(exhaust, 3, 5)
emitterSetSize(exhaust, 0.3, 0.05)
emitterSetColor(exhaust, 120, 180, 255, 255, 30, 60, 255, 0)
emitterStart(exhaust)
----
[#emitterdelete]
==== emitterDelete
[source,text]
----
emitterDelete(emitter)
----
Removes the emitter, its live particles and its copy of the texture at once; nothing fades out. Use `emitterStop` instead to let the last particles finish. A 3D emitter is also removed on its own when its node is deleted. A handle that is not a live emitter raises an error, so clear the variable afterward.
*Since:* 3.00.
*See also:* <<emitternew,emitterNew>>, <<emitterstop,emitterStop>>, <<emitterisactive,emitterIsActive>>
.Example
[source,lua]
----
-- The explosion is over: free its emitter once the last spark has died.
function onOverlayUpdate()
if explosion ~= nil and not emitterIsActive(explosion) then
emitterDelete(explosion)
explosion = nil
end
return OVERLAY_NOT_UPDATED
end
----
[#emittersettexture]
==== emitterSetTexture
[source,text]
----
emitterSetTexture(emitter, sprite)
emitterSetTexture(emitter)
----
The picture each particle wears: a loaded sprite, or none (one argument, or `nil`) for the built-in soft disc. The emitter keeps its own copy of the sprite's pixels, every frame of an animated sprite included, so the sprite may be unloaded afterward. Setting the texture resets the frame range to frame `0` and moves every live particle to frame `0`, so call `emitterSetFrames` after it, not before. Blending and tint apply to the picture as they do to the disc.
*Since:* 3.00.
*See also:* <<emittersetframes,emitterSetFrames>>, <<spriteload,spriteLoad>>, <<emittersetblend,emitterSetBlend>>
.Example
[source,lua]
----
-- Snow: a sheet of six flake shapes, one picked at random per particle.
flakes = spriteLoadFrames(DIR .. "Sprites/Flakes", 6)
snow = emitterNew()
emitterSetTexture(snow, flakes)
emitterSetFrames(snow, 0, 5)
emitterSetDirection(snow, 0, 1)
emitterSetSpeed(snow, 20, 40)
emitterSetLife(snow, 6, 9)
emitterSetSize(snow, 6, 6, 0.4)
emitterStart(snow)
----
[#emittersetframes]
==== emitterSetFrames
[source,text]
----
emitterSetFrames(emitter, first, last)
----
With an animated sprite as the texture, each new particle takes a random frame from `first` to `last` and keeps it for life; frames count from `0`. Values are clamped to the frames the texture has, and `last` is raised to `first` when it is smaller. Default: frame `0` only, which is also what `emitterSetTexture` resets to. With the built-in disc or a single-frame sprite the range has no effect.
*Since:* 3.00.
*See also:* <<emittersettexture,emitterSetTexture>>, <<spriteload,spriteLoad>>
.Example
[source,lua]
----
-- The puff sheet has four soft puffs, then four harder debris chunks.
emitterSetTexture(smoke, puffSheet)
emitterSetFrames(smoke, 0, 3)
emitterSetTexture(debris, puffSheet)
emitterSetFrames(debris, 4, 7)
----
[#emittersetblend]
==== emitterSetBlend
[source,text]
----
emitterSetBlend(emitter, mode)
----
How particles combine with what is behind them. `PARTICLE_ALPHA` (default) blends normally, for smoke, dust, rain and debris; `PARTICLE_ADD` adds light, for fire, sparks, glows and magic, and never darkens. Takes effect on the live particles at once. Anything other than those two constants raises an error.
*Since:* 3.00.
*See also:* <<emittersetcolor,emitterSetColor>>, <<emittersettexture,emitterSetTexture>>
.Example
[source,lua]
----
-- Sparks glow; the smoke above them does not.
emitterSetBlend(sparks, PARTICLE_ADD)
emitterSetColor(sparks, 255, 220, 120, 255, 255, 80, 20, 0)
emitterSetBlend(smoke, PARTICLE_ALPHA)
emitterSetColor(smoke, 160, 160, 170, 120, 120, 120, 130, 0)
----
[#emittersetlayer]
==== emitterSetLayer
[source,text]
----
emitterSetLayer(emitter, layer)
----
2D only: where `emitterDraw` puts the particles. `PARTICLE_OVER` (default) draws above everything on the overlay; `PARTICLE_UNDER` draws beneath the overlay and above the video and the 3D scene, for smoke behind the score. On a 3D emitter the setting is accepted and ignored. Anything other than those two constants raises an error.
*Since:* 3.00.
*See also:* <<emitterdraw,emitterDraw>>, <<emittersetposition,emitterSetPosition>>
.Example
[source,lua]
----
-- Rain falls behind the HUD, the hit sparks in front of it.
rain = emitterNew()
emitterSetLayer(rain, PARTICLE_UNDER)
hits = emitterNew()
emitterSetLayer(hits, PARTICLE_OVER)
function onOverlayUpdate()
overlayClear()
emitterDraw(rain)
drawHud()
emitterDraw(hits)
return OVERLAY_UPDATED
end
----
[#emittersetrate]
==== emitterSetRate
[source,text]
----
emitterSetRate(emitter, perSecond)
----
How many particles a second a started emitter makes; default `50`, negative values become `0`. Fractions accumulate, so `0.5` makes one every two seconds. Change it while streaming to throttle a flame or a thruster. A full pool (`emitterSetMax`) makes no more until some die, whatever the rate.
*Since:* 3.00.
*See also:* <<emitterstart,emitterStart>>, <<emitterburst,emitterBurst>>, <<emittersetmax,emitterSetMax>>
.Example
[source,lua]
----
-- The thruster's plume follows the throttle.
function onControllerMoved(controller, axis, value)
if axis == AXIS_TRIGGER_RIGHT then
throttle = value
emitterSetRate(plume, 30 + 300 * throttle)
end
end
----
[#emittersetlife]
==== emitterSetLife
[source,text]
----
emitterSetLife(emitter, minSeconds, maxSeconds)
----
How long each new particle lives, drawn at random between the two; default `1` to `2`. Its size and color run from their start to their end over that time, so life sets the pace of every fade. The minimum is held at `0.001` or above and the maximum is raised to the minimum when it is smaller. Live particles keep the life they were born with.
*Since:* 3.00.
*See also:* <<emittersetsize,emitterSetSize>>, <<emittersetcolor,emitterSetColor>>, <<emittersetrate,emitterSetRate>>
.Example
[source,lua]
----
-- Muzzle flash: a burst that is gone in a tenth of a second.
flash = emitterNew(muzzle)
emitterSetBlend(flash, PARTICLE_ADD)
emitterSetLife(flash, 0.05, 0.1)
emitterSetSpeed(flash, 2, 6)
emitterSetSize(flash, 0.15, 0.4)
function onFire()
emitterBurst(flash, 12)
end
----
[#emittersetsoftness]
==== emitterSetSoftness
[source,text]
----
emitterSetSoftness(emitter, distance)
----
3D only: fades a particle out over `distance` world units where it meets geometry behind it, so a smoke puff does not draw a hard line where it crosses a wall and a fountain sinks into its pool instead of cutting it. `0` (the default) switches it off; negative values become `0`. While any emitter uses it the scene's depth is rendered once more per frame, so a Raspberry Pi should use it sparingly. A 2D emitter stores the value and ignores it.
*Since:* 3.00.
*See also:* <<emittersetlit,emitterSetLit>>, <<emittersetblend,emitterSetBlend>>
.Example
[source,lua]
----
-- Smoke from the brazier softens against the post it rises past.
smoke = emitterNew(brazierTop)
emitterSetRate(smoke, 25)
emitterSetLife(smoke, 1.5, 2.5)
emitterSetSize(smoke, 0.2, 0.9, 0.2)
emitterSetColor(smoke, 160, 160, 170, 120, 120, 120, 130, 0)
emitterSetLit(smoke, true)
emitterSetSoftness(smoke, 0.3)
emitterStart(smoke)
----
[#emittersetspeed]
==== emitterSetSpeed
[source,text]
----
emitterSetSpeed(emitter, min, max)
----
Starting speed of each new particle, drawn at random between the two, in overlay units a second (2D) or world units a second (3D); defaults `50` to `100` in 2D and `1` to `2` in 3D. The maximum is raised to the minimum when it is smaller. Speed sets how far a particle gets before drag, gravity and its life take over.
*Since:* 3.00.
*See also:* <<emittersetdirection,emitterSetDirection>>, <<emittersetdrag,emitterSetDrag>>, <<emittersetgravity,emitterSetGravity>>
.Example
[source,lua]
----
-- A fountain throws drops hard and straight; dust barely moves.
emitterSetSpeed(fountain, 4.0, 5.0)
emitterSetSpread(fountain, 8)
emitterSetSpeed(dust, 0.05, 0.2)
emitterSetSpread(dust, 180)
----
[#emittersetdirection]
==== emitterSetDirection
[source,text]
----
emitterSetDirection(emitter, x, y)
emitterSetDirection(emitter, x, y, z)
----
The mean direction new particles set off in; only the direction matters, not the length. 2D default `(0, -1)`, up the screen since overlay Y grows downward; 3D default `(0, 1, 0)`, up the world Y axis. A 2D emitter ignores `z`; a 3D emitter given two values gets `z = 0`. A zero vector is treated as `(0, -1, 0)`. The direction is in world axes for a 3D emitter, not the node's, so turn it yourself when the node turns.
*Since:* 3.00.
*See also:* <<emittersetspread,emitterSetSpread>>, <<emittersetspeed,emitterSetSpeed>>
.Example
[source,lua]
----
-- Exhaust streams out behind the ship whichever way it faces.
function onOverlayUpdate()
local heading = math.rad(shipYaw)
emitterSetDirection(exhaust, -math.sin(heading), 0, -math.cos(heading))
return OVERLAY_NOT_UPDATED
end
----
[#emittersetspread]
==== emitterSetSpread
[source,text]
----
emitterSetSpread(emitter, degrees)
----
Half the angle of the cone (3D) or fan (2D) around the direction that new particles are thrown into, `0` to `180`; `0` is a straight line, `180` is every direction. Default `30`; values outside the range are clamped. In 3D the directions are spread evenly over the cone's cap, so a wide cone does not bunch toward its axis.
*Since:* 3.00.
*See also:* <<emittersetdirection,emitterSetDirection>>, <<emittersetradius,emitterSetRadius>>
.Example
[source,lua]
----
-- Fireworks go everywhere; the torch flame stays in a tight column.
emitterSetSpread(fireworks, 180)
emitterSetSpread(flame, 15)
----
[#emittersetgravity]
==== emitterSetGravity
[source,text]
----
emitterSetGravity(emitter, x, y)
emitterSetGravity(emitter, x, y, z)
----
A constant acceleration on every particle, in overlay units (2D) or world units (3D) a second squared: `(0, 500)` pulls 2D sparks down the screen, `(0, -20)` lets 2D smoke rise, `(0, -9.8, 0)` is Earth in the scene. Default none. It applies to the live particles from the next frame. Particles are not physics bodies; give them `emitterSetCollide` to bounce off a floor or the scene.
*Since:* 3.00.
*See also:* <<emittersetdrag,emitterSetDrag>>, <<emittersetcollide,emitterSetCollide>>, <<emittersetspeed,emitterSetSpeed>>
.Example
[source,lua]
----
-- Sparks fall, smoke rises, both from the same grinder.
emitterSetGravity(sparks, 0, 500)
emitterSetGravity(smoke, 0, -20)
-- Underwater, the bubbles head for the surface.
emitterSetGravity(bubbles, 0, 3, 0)
----
[#emittersetdrag]
==== emitterSetDrag
[source,text]
----
emitterSetDrag(emitter, perSecond)
----
How quickly particles lose their velocity: every frame the velocity is multiplied by `1 - perSecond * dt`, never below `0`, so `1` takes most of the speed away within a second and `0` (the default) keeps it forever. Negative values become `0`. Smoke, embers and fireworks slow down; sparks and rain do not. Applies to the live particles from the next frame.
*Since:* 3.00.
*See also:* <<emittersetgravity,emitterSetGravity>>, <<emittersetspeed,emitterSetSpeed>>
.Example
[source,lua]
----
-- A firework shell: fast out, then hanging and falling.
fireworks = emitterNew()
emitterSetBlend(fireworks, PARTICLE_ADD)
emitterSetSpeed(fireworks, 120, 320)
emitterSetSpread(fireworks, 180)
emitterSetGravity(fireworks, 0, 220)
emitterSetDrag(fireworks, 0.8)
emitterSetLife(fireworks, 0.8, 1.6)
----
[#emittersetsize]
==== emitterSetSize
[source,text]
----
emitterSetSize(emitter, start, finish)
emitterSetSize(emitter, start, finish, variation)
----
Size at birth and at death, in overlay units (2D) or world units (3D), changing evenly over the particle's life; defaults `16` to `4` in 2D and `0.2` to `0.05` in 3D. `variation` (`0` to `1`, default `0`) scales both sizes of each new particle by one random factor between `1 - variation` and `1 + variation`, so a puff is a little bigger or smaller for its whole life. Negative sizes become `0` and `variation` is clamped. A textured particle is drawn as a square of that size; a wider picture is squashed.
*Since:* 3.00.
*See also:* <<emittersetlife,emitterSetLife>>, <<emittersetcolor,emitterSetColor>>, <<emittersetspin,emitterSetSpin>>
.Example
[source,lua]
----
-- Smoke grows as it thins; sparks shrink to nothing.
emitterSetSize(smoke, 12, 48, 0.2)
emitterSetSize(sparks, 5, 1)
----
[#emittersetcollide]
==== emitterSetCollide
[source,text]
----
emitterSetCollide(emitter, mode)
emitterSetCollide(emitter, mode, bounce)
emitterSetCollide(emitter, mode, bounce, friction)
emitterSetCollide(emitter, mode, bounce, friction, floor)
----
What particles bounce off. `COLLIDE_NONE` (default) is nothing; `COLLIDE_FLOOR` is a horizontal plane at height `floor`, below which (larger Y in 2D, smaller Y in 3D) a particle is put back on the plane and reflected; `COLLIDE_SCENE` is the physics world, for 3D emitters only, by a ray cast per particle along its motion each frame, capped at `200` casts per emitter per frame and resumed where they left off, so a big emitter spreads the cost over a few frames. On a 2D emitter `COLLIDE_SCENE` is accepted and does nothing. A mode other than those three raises an error.
*Parameters:*
* `mode` -- `COLLIDE_NONE`, `COLLIDE_FLOOR` or `COLLIDE_SCENE`.
* `bounce` -- how much speed survives a hit, `0` to `1`, default `0.5`; clamped.
* `friction` -- how much sliding speed is lost per hit, `0` to `1`, default `0.2`; clamped.
* `floor` -- height of the floor plane, in world units for a 3D emitter or overlay units for a 2D one, default `0`. Used by `COLLIDE_FLOOR` only.
*Since:* 3.00.
*See also:* <<emittersetgravity,emitterSetGravity>>, <<emittersettrail,emitterSetTrail>>, <<emittersetsoftness,emitterSetSoftness>>
.Example
[source,lua]
----
-- Sparks bounce on the workshop floor (overlay row 440) and leave streaks.
sparks = emitterNew()
emitterSetBlend(sparks, PARTICLE_ADD)
emitterSetGravity(sparks, 0, 500)
emitterSetCollide(sparks, COLLIDE_FLOOR, 0.4, 0.2, 440)
emitterSetTrail(sparks, 6, 3)
-- Embers in the scene bounce off whatever the physics world holds.
emitterSetCollide(embers, COLLIDE_SCENE, 0.3, 0.5)
----
[#emittersetcolor]
==== emitterSetColor
[source,text]
----
emitterSetColor(emitter, r, g, b, a, r2, g2, b2, a2)
----
Color and opacity at birth (`r, g, b, a`) and at death (`r2, g2, b2, a2`), each `0` to `255`, changing evenly over the particle's life; values outside the range are clamped. Default white and opaque to white and transparent. The tint multiplies the texture or the built-in disc, so a white picture takes the color exactly. A fade in and out needs two emitters or a texture with the shape in it; one recipe runs one straight ramp.
*Since:* 3.00.
*See also:* <<emittersetblend,emitterSetBlend>>, <<emittersetlife,emitterSetLife>>, <<emittersetsize,emitterSetSize>>
.Example
[source,lua]
----
-- Flame: bright yellow at the base, dying to dim red and gone.
emitterSetColor(flame, 255, 200, 90, 255, 255, 40, 0, 0)
-- Dust motes fade in from nothing and hang; their life ends them.
emitterSetColor(dust, 255, 240, 200, 0, 255, 240, 200, 160)
----
[#emittersetspin]
==== emitterSetSpin
[source,text]
----
emitterSetSpin(emitter, min, max)
----
Rotation speed of each new particle in degrees a second, drawn at random between the two; negative values spin the other way, and a range that straddles `0` gives both. Every particle starts at a random angle whether or not it spins. Default none. The maximum is raised to the minimum when it is smaller. Spin shows on a textured particle or a trail; the built-in disc is round and looks the same at any angle.
*Since:* 3.00.
*See also:* <<emittersettexture,emitterSetTexture>>, <<emittersetsize,emitterSetSize>>
.Example
[source,lua]
----
-- Leaves tumble; embers hardly turn.
emitterSetTexture(leaves, leafSprite)
emitterSetSpin(leaves, -180, 180)
emitterSetSpin(embers, -20, 20)
----
[#emittersetradius]
==== emitterSetRadius
[source,text]
----
emitterSetRadius(emitter, radius)
----
New particles are born anywhere within this distance of the emitter's origin, evenly over a disc (2D) or a ball (3D), in overlay or world units. Default `0`, a point; negative values become `0`. A radius fills a volume, a torch head or a patch of dust in the air, where a point would give a needle.
*Since:* 3.00.
*See also:* <<emittersetspread,emitterSetSpread>>, <<emittersetposition,emitterSetPosition>>
.Example
[source,lua]
----
-- Dust in a room-sized ball of air, most of it already there when the level starts.
air = nodeNew()
nodeSetPosition(air, 0, 2, 0)
dust = emitterNew(air)
emitterSetRadius(dust, 2.5)
emitterSetSpread(dust, 180)
emitterSetSpeed(dust, 0.05, 0.2)
emitterSetLife(dust, 4, 6)
emitterStart(dust)
emitterBurst(dust, 120)
----
[#emittersetlit]
==== emitterSetLit
[source,text]
----
emitterSetLit(emitter, lit)
----
3D only: shades each particle by the scene's lights and sky as if it were a small sphere facing the camera, so smoke takes a brazier's color and dust glints in the sun. Off by default; shadows are not consulted. With `PARTICLE_ADD` the lighting is still applied to the tint before it is added. A 2D emitter stores the value and ignores it.
*Since:* 3.00.
*See also:* <<emittersetsoftness,emitterSetSoftness>>, <<emittersetblend,emitterSetBlend>>
.Example
[source,lua]
----
-- Smoke over each brazier picks up its firelight.
for _, brazier in ipairs(braziers) do
local smoke = emitterNew(brazier.top)
emitterSetRate(smoke, 12)
emitterSetLife(smoke, 2.5, 4)
emitterSetColor(smoke, 90, 80, 75, 90, 50, 45, 45, 0)
emitterSetLit(smoke, true)
emitterStart(smoke)
end
----
[#emittersetlocal]
==== emitterSetLocal
[source,text]
----
emitterSetLocal(emitter, local)
----
Whether live particles follow the emitter. `false` (default) leaves each one where it was born, so a torch carried through a room leaves smoke behind it; `true` keeps the whole cloud relative to the emitter's origin, so a thruster plume moves with the ship. Switching it moves the live particles between frames of reference without a jump. On a 3D emitter the origin is the node's world position; on a 2D one it is the `emitterSetPosition` point, so a local 2D cloud slides with that too. Trails follow the same rule.
*Since:* 3.00.
*See also:* <<emittersetposition,emitterSetPosition>>, <<emittersetradius,emitterSetRadius>>
.Example
[source,lua]
----
-- A magic aura that stays wrapped around the wizard as she walks.
aura = emitterNew(nodeFind("Chest", wizard))
emitterSetLocal(aura, true)
emitterSetRadius(aura, 0.6)
emitterSetSpeed(aura, 0.05, 0.15)
emitterSetSpread(aura, 180)
emitterSetBlend(aura, PARTICLE_ADD)
emitterStart(aura)
----
[#emittersetmax]
==== emitterSetMax
[source,text]
----
emitterSetMax(emitter, count)
----
The pool: how many particles may be alive at once, default `1000`. A full pool makes no more, from the stream or a burst, until some die. Changing the count rebuilds the pool and kills every live particle, so set it before starting. Size the pool to rate times longest life, plus the biggest burst. A count below `1` raises an error.
*Since:* 3.00.
*See also:* <<emittersetrate,emitterSetRate>>, <<emitterburst,emitterBurst>>, <<emittergetcount,emitterGetCount>>
.Example
[source,lua]
----
-- 300 drops a second living up to 1.6 seconds needs about 480; leave room.
fountain = emitterNew(spout)
emitterSetRate(fountain, 300)
emitterSetLife(fountain, 1.0, 1.6)
emitterSetMax(fountain, 600)
emitterStart(fountain)
----
[#emittersetposition]
==== emitterSetPosition
[source,text]
----
emitterSetPosition(emitter, x, y)
----
2D only: where new particles are born, in overlay coordinates; default `(0, 0)`. Set it every frame to follow a sprite or a 2D physics body. Live particles stay where they are unless `emitterSetLocal` is on. A 3D emitter follows its node and raises an error here; move the node instead.
*Since:* 3.00.
*See also:* <<emitterdraw,emitterDraw>>, <<emittersetlocal,emitterSetLocal>>, <<emittersetradius,emitterSetRadius>>
.Example
[source,lua]
----
-- Sparks fly from wherever the grinder sprite is this frame.
function onOverlayUpdate()
overlayClear()
spriteDraw(grinder, grinderX, grinderY)
emitterSetPosition(sparks, grinderX + 24, grinderY + 8)
emitterDraw(sparks)
return OVERLAY_UPDATED
end
----
[#emittersettrail]
==== emitterSetTrail
[source,text]
----
emitterSetTrail(emitter, length, width)
----
A ribbon behind every particle through its last `length` positions, one recorded per frame (`2` to `16`; `0` or `1` for none, the default), `width` across in world or overlay units, in 2D and 3D alike. The ribbon carries the particle's current tint, fades to transparent toward its tail, and is textured by the middle column of the particle's picture so the disc's soft edge becomes the ribbon's. Values are clamped to the range. Changing the length rebuilds the trail storage, so live particles start their ribbons again. Sparks, embers, tracer rounds, comets.
*Since:* 3.00.
*See also:* <<emittersetcollide,emitterSetCollide>>, <<emittersetsize,emitterSetSize>>, <<emittersetlocal,emitterSetLocal>>
.Example
[source,lua]
----
-- Tracer rounds: tiny, fast, and all streak.
tracers = emitterNew(gunMuzzle)
emitterSetBlend(tracers, PARTICLE_ADD)
emitterSetSpeed(tracers, 40, 50)
emitterSetSpread(tracers, 2)
emitterSetSize(tracers, 0.04, 0.04)
emitterSetLife(tracers, 0.8, 1.0)
emitterSetTrail(tracers, 8, 0.03)
----
[#emitterstart]
==== emitterStart
[source,text]
----
emitterStart(emitter)
----
Begins the steady stream at the rate set; from then on the emitter makes particles every frame until `emitterStop`. Starting an emitter that is already streaming changes nothing. The stream needs no drawing call to run; a 2D emitter that is streaming but not drawn still spends its pool.
*Since:* 3.00.
*See also:* <<emitterstop,emitterStop>>, <<emittersetrate,emitterSetRate>>, <<emitterburst,emitterBurst>>
.Example
[source,lua]
----
-- The engine lights when the player throttles up and dies when they cut it.
function onThrottle(open)
if open then
emitterStart(exhaust)
else
emitterStop(exhaust)
end
end
----
[#emitterstop]
==== emitterStop
[source,text]
----
emitterStop(emitter)
----
Ends the stream; the live particles finish their lives and any fraction of a particle owed is dropped. `emitterBurst` still works on a stopped emitter, and `emitterIsActive` stays `true` until the last particle dies. Use `emitterClear` to remove the particles at once as well.
*Since:* 3.00.
*See also:* <<emitterclear,emitterClear>>, <<emitterstart,emitterStart>>, <<emitterisactive,emitterIsActive>>
.Example
[source,lua]
----
-- The torch goes out: stop the flame, let the last smoke drift away.
function onTorchDoused()
emitterStop(flame)
emitterStop(smoke)
soundPlay(hiss)
end
----
[#emitterburst]
==== emitterBurst
[source,text]
----
emitterBurst(emitter, count)
----
Makes `count` particles at once from the current recipe, stream or no stream: an explosion, a splash, a puff. Only as many as the pool has room for are made; `emitterSetMax` sets the room. A 2D emitter bursts at its `emitterSetPosition` point, a 3D one at its node, spread over `emitterSetRadius`.
*Since:* 3.00.
*See also:* <<emitterstart,emitterStart>>, <<emittersetmax,emitterSetMax>>, <<emittersetradius,emitterSetRadius>>
.Example
[source,lua]
----
-- Fireworks: a shell bursts somewhere over the disc every second or so.
function onOverlayUpdate()
if frames % 60 == 0 then
emitterSetPosition(fireworks, 120 + (frames * 7) % (width - 240), 80 + (frames * 13) % 160)
emitterBurst(fireworks, 260)
end
overlayClear()
emitterDraw(fireworks)
frames = frames + 1
return OVERLAY_UPDATED
end
----
[#emitterclear]
==== emitterClear
[source,text]
----
emitterClear(emitter)
----
Kills every live particle now and forgets any fraction of a particle owed. The stream is not stopped: a started emitter goes on making new particles next frame, so call `emitterStop` as well to empty it for good. Use it when a scene changes and the old sparks must not linger.
*Since:* 3.00.
*See also:* <<emitterstop,emitterStop>>, <<emitterdelete,emitterDelete>>
.Example
[source,lua]
----
-- Restarting the level: the emitters stay, the old particles go.
function resetLevel()
for _, emitter in ipairs(levelEmitters) do
emitterStop(emitter)
emitterClear(emitter)
end
placePlayer()
end
----
[#emitterdraw]
==== emitterDraw
[source,text]
----
emitterDraw(emitter)
----
2D only, from `onOverlayUpdate`: queues the emitter's particles to be drawn this frame, above or beneath the overlay per `emitterSetLayer`, in overlay coordinates mapped onto the window. Emitters on the same layer draw in the order they were queued. The queue empties every frame, so the call belongs in every frame the particles should show; not drawing an emitter does not stop it. Up to `64` emitters per layer draw in a frame. A 3D emitter draws itself in the scene and raises an error here.
*Since:* 3.00.
*See also:* <<emittersetlayer,emitterSetLayer>>, <<emittersetposition,emitterSetPosition>>, <<onoverlayupdate,onOverlayUpdate>>
.Example
[source,lua]
----
function onOverlayUpdate()
overlayClear()
emitterSetPosition(smoke, chimneyX, chimneyY)
emitterDraw(smoke)
emitterDraw(sparks)
fontPrint(20, 20, "Score " .. score)
return OVERLAY_UPDATED
end
----
[#emittergetcount]
==== emitterGetCount
[source,text]
----
count = emitterGetCount(emitter)
----
How many of the emitter's particles are alive this frame, `0` to the pool size. Useful for tuning a pool, for an on-screen budget, or for knowing when a burst has died away.
*Returns:* The live count, an integer.
*Since:* 3.00.
*See also:* <<emittersetmax,emitterSetMax>>, <<emitterisactive,emitterIsActive>>
.Example
[source,lua]
----
-- Development overlay: how much of each pool is in use.
function onOverlayUpdate()
overlayClear()
fontPrint(20, 20, string.format("live %d / %d", emitterGetCount(fireworks), fireworksPool))
return OVERLAY_UPDATED
end
----
[#emitterisactive]
==== emitterIsActive
[source,text]
----
active = emitterIsActive(emitter)
----
Whether the emitter is streaming or still has live particles: `true` from `emitterStart` or `emitterBurst` until the stream is stopped and the last particle has died, `false` once the explosion is over. A streaming emitter whose pool happens to be empty is still active.
*Returns:* Boolean.
*Since:* 3.00.
*See also:* <<emitterstop,emitterStop>>, <<emittergetcount,emitterGetCount>>, <<emitterdelete,emitterDelete>>
.Example
[source,lua]
----
-- One-shot explosions live in a list and are freed when they burn out.
function onOverlayUpdate()
for i = #explosions, 1, -1 do
if not emitterIsActive(explosions[i]) then
emitterDelete(explosions[i])
table.remove(explosions, i)
end
end
return OVERLAY_NOT_UPDATED
end
----
[#font]
=== Font
Singe has two text renderers. The built-in console font is drawn by <<overlayprint,overlayPrint>> and needs no assets; the `font*` family loads TrueType files at a fixed point size and renders them either straight onto the overlay with `fontPrint` or into a reusable sprite with `fontToSprite`. Font handles are integers from `fontLoad`, and the engine keeps one globally selected font, which `fontLoad` sets to the font it just loaded and `fontSelect` changes. Text is rendered in the current foreground color at the quality chosen by `fontQuality`, and text positions are overlay coordinates. Release fonts with `fontUnload`; `Singe/FreeSansBold.ttf` ships with the engine and is always available.
[#fontload]
==== fontLoad
[source,text]
----
result = fontLoad(filename, pointSize)
----
Opens a TrueType font at one point size and makes it the selected font. The size is fixed for the life of the handle, so load the same file once per size you need. The name goes through the virtual file system like any other asset; prepend `DIR` for files shipped with your game, or use `Singe/FreeSansBold.ttf` for the bundled face. A file that cannot be opened ends the script with `Unable to open` and the name.
*Parameters:*
* `filename` -- path to a `.ttf` file.
* `pointSize` -- integer point size.
*Returns:* integer font handle.
*Since:* 1.x
*See also:* <<fontselect,fontSelect>>, <<fontunload,fontUnload>>, <<fontquality,fontQuality>>
.Example
[source,lua]
----
-- Two sizes of the bundled face: large for the title, small for the HUD.
fontQuality(FONT_QUALITY_BLENDED)
titleFont = fontLoad("Singe/FreeSansBold.ttf", 32)
hudFont = fontLoad("Singe/FreeSansBold.ttf", 18)
fontSelect(titleFont)
titleSprite = fontToSprite("SPACE ACE")
fontSelect(hudFont)
----
[#fontprint]
==== fontPrint
[source,text]
----
fontPrint(x, y, text)
----
Renders a string with the selected font, the current quality, and the current foreground color (plus the background color for `FONT_QUALITY_SHADED`), then blits it onto the overlay with its top-left corner at `(x, y)`. Call it from `onOverlayUpdate`. A new text image is rendered and discarded on every call, which is fine for a few lines but expensive for text that never changes; `fontToSprite` renders once and draws many times. With no font selected the script ends with an error.
*Parameters:*
* `x`, `y` -- top-left corner in overlay coordinates; fractions are truncated.
* `text` -- the string to render.
*Since:* 1.x
*See also:* <<fonttosprite,fontToSprite>>, <<fontselect,fontSelect>>, <<colorforeground,colorForeground>>, <<overlayprint,overlayPrint>>
.Example
[source,lua]
----
-- Live audio calibration readout; the text changes every frame, so render it directly.
function onOverlayUpdate()
overlayClear()
colorForeground(255, 255, 255, 255)
fontSelect(hudFont)
fontPrint(MARGIN_X, MARGIN_Y, "AUDIO DELAY CALIBRATION")
fontPrint(MARGIN_X, MARGIN_Y + 30, "Delay: " .. calibrationDelay .. " ms")
fontPrint(MARGIN_X, MARGIN_Y + 60, "Left / Right: 10 ms Up / Down: 1 ms")
return OVERLAY_UPDATED
end
----
[#fontquality]
==== fontQuality
[source,text]
----
fontQuality(mode)
----
Selects how `fontPrint` and `fontToSprite` rasterize text from now on. The setting is global, not per font, and starts at `FONT_QUALITY_SOLID`. Sprites already made by `fontToSprite` keep the quality they were rendered with. A value outside the three constants ends the script with an error.
*Parameters:*
* `mode` -- `FONT_QUALITY_SOLID` (`1`): fastest, one-bit edges, no anti-aliasing. `FONT_QUALITY_SHADED` (`2`): anti-aliased glyphs on a solid rectangle of the background color. `FONT_QUALITY_BLENDED` (`3`): anti-aliased glyphs with per-pixel alpha that blend over anything; the usual choice.
*Since:* 1.x
*See also:* <<fontprint,fontPrint>>, <<fonttosprite,fontToSprite>>, <<colorbackground,colorBackground>>
.Example
[source,lua]
----
-- Blended text over the video, but a shaded label box for the debug readout.
fontQuality(FONT_QUALITY_BLENDED)
titleSprite = fontToSprite("DRAGON'S LAIR")
colorBackground(0, 0, 0, 255)
fontQuality(FONT_QUALITY_SHADED)
debugLabel = fontToSprite("FRAME")
fontQuality(FONT_QUALITY_BLENDED)
----
[#fontselect]
==== fontSelect
[source,text]
----
fontSelect(id)
----
Makes a loaded font the one used by `fontPrint` and `fontToSprite`. The selection persists until the next `fontSelect` or `fontLoad`, since loading also selects. It can be changed as often as needed, including several times within one `onOverlayUpdate`. An unknown or unloaded handle ends the script with an error.
*Since:* 1.x
*See also:* <<fontload,fontLoad>>, <<fontprint,fontPrint>>, <<fonttosprite,fontToSprite>>
.Example
[source,lua]
----
-- Title in the large face, score in the small one, every frame.
function onOverlayUpdate()
overlayClear()
fontSelect(titleFont)
fontPrint(TITLE_X, 40, "DRAGON'S LAIR")
fontSelect(hudFont)
fontPrint(8, 8, "Score: " .. score)
return OVERLAY_UPDATED
end
----
[#fonttosprite]
==== fontToSprite
[source,text]
----
result = fontToSprite(text)
----
Renders a string with the selected font, quality, and colors into a new sprite and returns its handle. The sprite is independent of the font from then on, so it survives `fontUnload`, and it works with every `sprite*` call: draw it, measure it, scale or rotate it, and free it with `spriteUnload`. Render once and draw many times for labels and headings; for a string that changes, render a new sprite and unload the old one. With no font selected the script ends with an error.
*Returns:* integer sprite handle.
*Since:* 1.x
*See also:* <<fontprint,fontPrint>>, <<spritedraw,spriteDraw>>, <<spritegetwidth,spriteGetWidth>>, <<spriteunload,spriteUnload>>
.Example
[source,lua]
----
-- Pre-render the game description, wrapping lines to the panel width.
descriptionLines = {}
for _, line in ipairs(wrapText(GAME_LIST[selected].DESCRIPTION, TEXT_W)) do
table.insert(descriptionLines, fontToSprite(line))
end
function onOverlayUpdate()
local y = TEXT_Y
for _, line in ipairs(descriptionLines) do
spriteDraw(line, TEXT_X, y)
y = y + spriteGetHeight(line) + 1
end
return OVERLAY_UPDATED
end
----
[#fontunload]
==== fontUnload
[source,text]
----
fontUnload(id)
----
Closes a font and invalidates its handle. Sprites made from it with `fontToSprite` are unaffected. If the unloaded font was the selected one, no font is selected afterward and the next `fontPrint` or `fontToSprite` ends the script with an error until `fontSelect` or `fontLoad` picks another.
*Since:* 2.00
*See also:* <<fontload,fontLoad>>, <<fontselect,fontSelect>>, <<onshutdown,onShutdown>>
.Example
[source,lua]
----
-- Release the fonts and the text sprites they produced.
function onShutdown()
spriteUnload(titleSprite)
for _, line in ipairs(descriptionLines) do
spriteUnload(line)
end
fontUnload(titleFont)
fontUnload(hudFont)
end
----
[#joint]
=== Joint
Joints connect two bodies, or a body and the world when the second node is `-1`, and hand back an integer joint handle that the other calls take. Anchors and axes are given in world space at the moment the joint is made, with the bodies where they are then, so place the nodes and give them bodies before jointing them. Distances are in world units and hinge angles in degrees. Deleting either body, or its node, deletes the joint; a handle that has been deleted raises an error. See <<physics,Physics>> in Game Development, Joints.
[#jointhinge]
==== jointHinge
[source,text]
----
joint = jointHinge(nodeA, nodeB, ax, ay, az, dx, dy, dz)
----
Makes a hinge between the two bodies through the world point `(ax, ay, az)` along the axis `(dx, dy, dz)`: a door, a wheel, a lever, a seesaw. The bodies keep that point and axis in common and may only turn about it. The axis is normalized, so its length does not matter; a zero axis becomes `(0, 1, 0)`. Both nodes must carry a body, or `nodeB` may be `-1` to hinge `nodeA` to the world. Raises an error when a node has no body or the joint cannot be made.
*Parameters:*
* `nodeA` -- the body whose turn is measured; a positive `jointSetLimits` angle is its right-hand turn about the axis.
* `nodeB` -- the other body, or `-1` for the world.
* `ax`, `ay`, `az` -- the anchor, a world point both bodies pass through the hinge at.
* `dx`, `dy`, `dz` -- the hinge axis in world space.
*Returns:* The joint handle.
*Since:* 3.00.
*See also:* <<jointsetlimits,jointSetLimits>>, <<jointdelete,jointDelete>>, <<jointball,jointBall>>
.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.1, -0.62, 1.4)
bodyNew(door, BODY_DYNAMIC, SHAPE_BOX, 1.4, 2.2, 0.1)
bodySetMass(door, 8)
hinge = jointHinge(door, -1, -0.85, -0.62, 1.4, 0, 1, 0)
jointSetLimits(hinge, 0, 100)
-- Later, a shove near the handle swings it open.
bodyApplyImpulse(door, 0, 0, -60, 0.5, -0.62, 1.45)
----
[#jointball]
==== jointBall
[source,text]
----
joint = jointBall(nodeA, nodeB, ax, ay, az)
----
Makes a ball-and-socket joint at the world point `(ax, ay, az)`: the bodies may turn any way about it but cannot separate. A pendulum, a chain link, a hanging sign, a ragdoll shoulder. Both nodes must carry a body, or `nodeB` may be `-1` to hang `nodeA` from a fixed point in the world. A ball joint has no limits, so `jointSetLimits` refuses it. Raises an error when a node has no body or the joint cannot be made.
*Parameters:*
* `nodeA` -- the first body.
* `nodeB` -- the other body, or `-1` for the world.
* `ax`, `ay`, `az` -- the anchor, the world point the bodies pivot about.
*Returns:* The joint handle.
*Since:* 3.00.
*See also:* <<jointhinge,jointHinge>>, <<jointdelete,jointDelete>>, <<bodyapplyimpulse,bodyApplyImpulse>>
.Example
[source,lua]
----
-- A chain of four links hanging from a hook, each balled to the one above.
local above = -1
for i = 1, 4 do
local link = nodeNew()
nodeSetMesh(link, meshSphere(0.12, 16), iron)
nodeSetPosition(link, 2, 3 - i * 0.3, 0)
bodyNew(link, BODY_DYNAMIC, SHAPE_SPHERE, 0.12)
bodySetMass(link, 1)
jointBall(link, above, 2, 3.15 - i * 0.3, 0)
above = link
end
----
[#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 `(dx, dy, dz)` through the anchor `(ax, ay, az)`, keeping their orientation: a drawer, a piston, a lift, a sliding door. The axis is normalized, so its length does not matter; a zero axis becomes `(0, 1, 0)`. Both nodes must carry a body, or `nodeB` may be `-1` to run `nodeA` along a rail fixed in the world. Raises an error when a node has no body or the joint cannot be made.
*Parameters:*
* `nodeA` -- the body whose travel is measured; a positive `jointSetLimits` distance is along the axis.
* `nodeB` -- the other body, or `-1` for the world.
* `ax`, `ay`, `az` -- a world point on the rail.
* `dx`, `dy`, `dz` -- the direction of travel in world space.
*Returns:* The joint handle.
*Since:* 3.00.
*See also:* <<jointsetlimits,jointSetLimits>>, <<jointhinge,jointHinge>>, <<bodysetvelocity,bodySetVelocity>>
.Example
[source,lua]
----
-- A crate on a rail along X, pushed sideways at the start and stopped 2.4 units on.
crate = nodeNew()
nodeSetMesh(crate, meshBox(0.5, 0.5, 0.5), wood)
nodeSetPosition(crate, -0.6, 0.9, -3.0)
bodyNew(crate, BODY_DYNAMIC, SHAPE_BOX, 0.5, 0.5, 0.5)
slider = jointSlider(crate, -1, -0.6, 0.9, -3.0, 1, 0, 0)
jointSetLimits(slider, 0, 2.4)
bodySetVelocity(crate, 3, 0, 0)
----
[#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. A positive hinge angle is a right-hand turn of `nodeA` about the axis; a positive slider distance is along the axis. `low` is clamped to at most `0` and `high` to at least `0`, so the starting pose is always inside the range. Call it again to change the range at any time. Raises an error for a ball joint or a handle that no longer exists.
*Parameters:*
* `joint` -- the handle from `jointHinge` or `jointSlider`.
* `low` -- the farthest negative angle or distance, `0` or below.
* `high` -- the farthest positive angle or distance, `0` or above.
*Notes:* The two bodies swap roles if they are given the other way round, which mirrors the sign of the range.
*Since:* 3.00.
*See also:* <<jointhinge,jointHinge>>, <<jointslider,jointSlider>>
.Example
[source,lua]
----
-- A lever that rocks 30 degrees each way, then locks once the puzzle is solved.
lever = nodeNew()
nodeSetMesh(lever, meshBox(0.1, 0.8, 0.1), iron)
nodeSetPosition(lever, 4, 1.2, 0)
bodyNew(lever, BODY_DYNAMIC, SHAPE_BOX, 0.1, 0.8, 0.1)
pivot = jointHinge(lever, -1, 4, 0.8, 0, 0, 0, 1)
jointSetLimits(pivot, -30, 30)
function lockLever()
jointSetLimits(pivot, 0, 0)
end
----
[#jointdelete]
==== jointDelete
[source,text]
----
jointDelete(joint)
----
Removes the joint; the bodies are free of each other again and keep whatever velocity they had. Joints also go away with either of their bodies, so this is only needed to free two bodies that both stay. Raises an error for a handle that does not exist or was already deleted.
*Since:* 3.00.
*See also:* <<jointhinge,jointHinge>>, <<jointball,jointBall>>, <<bodydelete,bodyDelete>>
.Example
[source,lua]
----
-- The rope snaps when the crate hanging from it is hit hard enough.
function onCollision(a, b, x, y, z, speed)
if (a == crate or b == crate) and speed > 6 and rope then
jointDelete(rope)
rope = nil
soundPlay(snap)
end
end
----
[#keyboard]
=== Keyboard
Singe reads the keyboard in one of two modes chosen with `keyboardSetMode`. In `MODE_NORMAL` (the default) keys are translated through the mappings in `controls.cfg` into the `SWITCH_*` values that reach `onInputPressed` and `onInputReleased`, and unmapped keys are ignored; in `MODE_FULL` every key reaches the script raw, through `onKeyPressed` and `onKeyReleased` as well as `onInputPressed` and `onInputReleased`. Whatever the mode, `keyboardIsDown`, `keyboardGetLastDown` and `keyboardGetLastUp` poll the live keyboard state, which is what the threaded model wants (see <<threaded,Threaded>>). A scancode names a physical key position (the `A` key is scancode `4` on every layout) while a keysym is the character the key produces; the `SCANCODE` table in `Framework.singe` names every scancode, `keyboardIsDown` takes scancodes, and `onKeyPressed` delivers both. The callbacks themselves are described under <<enginecallbacks,Engine Callbacks>>.
[#keyboardgetlastdown]
==== keyboardGetLastDown
[source,text]
----
scancode = keyboardGetLastDown()
----
Returns the scancode of the key pressed most recently in the current frame, or `0` when nothing has been pressed since the last frame. The value is cleared after every `onOverlayUpdate` call, which makes it a one-shot "was a key just pressed" poll for the threaded model. Controller and mouse buttons are recorded here too, as their `GAMEPAD_N` and `MOUSE_N` codes, since the engine routes them through the same path as keys. The value is kept regardless of keyboard mode and of the `controls.cfg` mappings.
*Returns:* integer scancode, or `0`.
*Since:* 2.10
*See also:* <<keyboardgetlastup,keyboardGetLastUp>>, <<keyboardisdown,keyboardIsDown>>
.Example
[source,lua]
----
-- Threaded model: react to one key press per frame.
function singeMain()
while true do
local key = keyboardGetLastDown()
if key == SCANCODE.ESCAPE.value then
singeQuit()
elseif key == SCANCODE.SPACE.value then
jump()
end
singeYield()
end
end
----
[#keyboardgetlastup]
==== keyboardGetLastUp
[source,text]
----
scancode = keyboardGetLastUp()
----
Returns the scancode of the key released most recently in the current frame, or `0` when nothing has been released since the last frame. It is cleared after every `onOverlayUpdate` call, like `keyboardGetLastDown`, and records controller and mouse button codes the same way.
*Returns:* integer scancode, or `0`.
*Since:* 2.10
*See also:* <<keyboardgetlastdown,keyboardGetLastDown>>, <<keyboardisdown,keyboardIsDown>>
.Example
[source,lua]
----
-- A charged shot fires when the fire key is let go.
function singeMain()
while true do
if keyboardIsDown(SCANCODE.LCTRL.value) then
charge = math.min(charge + 1, chargeMax)
elseif keyboardGetLastUp() == SCANCODE.LCTRL.value then
fireShot(charge)
charge = 0
end
singeYield()
end
end
----
[#keyboardgetmode]
==== keyboardGetMode
[source,text]
----
mode = keyboardGetMode()
----
Returns the keyboard mode in effect: `MODE_NORMAL` (`0`) or `MODE_FULL` (`1`). Every script starts in `MODE_NORMAL`, including after a reload.
*Returns:* integer mode.
*Since:* 1.x (RDG)
*See also:* <<keyboardsetmode,keyboardSetMode>>
.Example
[source,lua]
----
-- Shared text-entry helper that restores whatever mode the caller had.
function promptForName()
local previous = keyboardGetMode()
keyboardSetMode(MODE_FULL)
entering = true
restoreMode = previous
end
----
[#keyboardgetmodifiers]
==== keyboardGetModifiers
[source,text]
----
modifiers = keyboardGetModifiers()
----
Returns the modifier keys held right now as a bitmask of SDL `KMOD_*` bits. Test it with a bitwise and against the values in the `MODIFIER` table: the single-key entries such as `MODIFIER.LSHIFT` and the combined `MODIFIER.SHIFT`, `MODIFIER.CTRL`, `MODIFIER.ALT` and `MODIFIER.GUI`, which cover either side. It reads the live state, so it works in every keyboard mode and from any callback.
*Returns:* integer bitmask.
*Since:* 2.10
*See also:* <<keyboardgetlastdown,keyboardGetLastDown>>, <<onkeypressedonkeyreleased,onKeyPressed>>
.Example
[source,lua]
----
-- Shift+Tab toggles the statistics overlay.
function onKeyPressed(keysym, scancode)
if scancode == SCANCODE.TAB.value then
if keyboardGetModifiers() & MODIFIER.SHIFT.value ~= 0 then
showStats = not showStats
end
end
end
----
[#keyboardisdown]
==== keyboardIsDown
[source,text]
----
down = keyboardIsDown(scancode)
----
Tests whether a key is physically held right now, from the state the engine keeps for every key event it sees. It does not depend on the keyboard mode or on `controls.cfg`, and it works from any callback, so it suits movement that should continue while a key is held. Scancodes outside the range SDL defines return `false` rather than aborting.
*Parameters:*
* `scancode` -- a value from the `SCANCODE` table, such as `SCANCODE.LEFT.value`.
*Returns:* boolean.
*Since:* 2.10
*See also:* <<keyboardgetlastdown,keyboardGetLastDown>>, <<controllergetbutton,controllerGetButton>>
.Example
[source,lua]
----
-- Eight-way movement from held keys.
function onOverlayUpdate()
local dx = 0
local dy = 0
if keyboardIsDown(SCANCODE.LEFT.value) then dx = dx - 1 end
if keyboardIsDown(SCANCODE.RIGHT.value) then dx = dx + 1 end
if keyboardIsDown(SCANCODE.UP.value) then dy = dy - 1 end
if keyboardIsDown(SCANCODE.DOWN.value) then dy = dy + 1 end
playerX = playerX + dx * speed
playerY = playerY + dy * speed
drawPlayer()
return OVERLAY_UPDATED
end
----
[#keyboardsetmode]
==== keyboardSetMode
[source,text]
----
keyboardSetMode(mode)
----
Switches between the two keyboard models. In `MODE_NORMAL` only keys mapped in `controls.cfg` reach the script, as `SWITCH_*` values, key repeat is dropped so each press arrives once, and the engine acts on its own switches (pause, quit, screenshot, mouse grab). In `MODE_FULL` every key reaches `onInputPressed` and `onInputReleased` as its keysym and `onKeyPressed` and `onKeyReleased` as keysym and scancode, key repeat is delivered for text entry, and keyboard mappings of the engine's own switches are ignored so that the game keeps every key; controller and mouse button mappings still act. Most games set the mode once at startup; a game with text entry switches to `MODE_FULL` for the duration and back. Any other value aborts the script.
*Parameters:*
* `mode` -- `MODE_NORMAL` (`0`) or `MODE_FULL` (`1`).
*Since:* 1.x (RDG)
*See also:* <<keyboardgetmode,keyboardGetMode>>, <<oninputpressedoninputreleased,onInputPressed>>, <<onkeypressedonkeyreleased,onKeyPressed>>
.Example
[source,lua]
----
-- High score entry takes raw keys, then gameplay mode comes back.
function beginInitials()
initials = ""
keyboardSetMode(MODE_FULL)
end
function onKeyPressed(keysym, scancode)
if scancode == SCANCODE.RETURN.value then
keyboardSetMode(MODE_NORMAL)
saveScore(initials, score)
elseif keysym >= string.byte("a") and keysym <= string.byte("z") and #initials < 3 then
initials = initials .. string.char(keysym):upper()
end
end
----
[#light]
=== Light
Lights are nodes carrying a light, made by `lightNew` and moved, parented and animated with the node calls. Up to eight visible lights shine in a frame, in node order; `sceneSetAmbient` and a sky from `sceneSetSky` add light from everywhere. Directional and spot lights shine down the node's -Z, so aim them with `nodeLookAt` or `nodeSetRotation`; point lights only need a position. Colors are integers from `0` to `255`, intensities are multipliers in the scene's linear light, angles are degrees, and distances are world units. Every `lightSet*` call raises an error for a node that does not carry a light. See <<scenes3d,3D Scenes>> for lighting, exposure and shadows.
[#lightnew]
==== lightNew
[source,text]
----
node = lightNew(type)
node = lightNew(type, parent)
----
Makes a new node carrying a light and returns the node handle. The light starts white at intensity `1`, with no range limit and, for a spot, a cone of `20` to `30` degrees. Place and aim it with the node calls: `nodeSetPosition` for a point or spot light, `nodeLookAt` for a directional or spot light. A light parented to a moving node moves with it, so a headlight on a car or a torch in a hand is one call. Raises an error for an unknown type or when no node can be made.
*Parameters:*
* `type` -- `LIGHT_DIRECTIONAL` (a sun; only its direction matters), `LIGHT_POINT` (a bulb fading with distance in every direction), or `LIGHT_SPOT` (a cone down the node's -Z).
* `parent` -- the parent node; the root when omitted.
*Returns:* The light's node handle, an integer.
*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,
-- and a headlight that rides with the car.
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)
headlight = lightNew(LIGHT_SPOT, car)
nodeSetPosition(headlight, 0.6, 0.5, -1.8)
lightSetIntensity(headlight, 30)
----
[#lightsetcolor]
==== lightSetColor
[source,text]
----
lightSetColor(node, r, g, b)
----
The light's color, `0` to `255` per channel (out-of-range values are clamped), treated as sRGB; white by default. Brightness belongs in `lightSetIntensity`, so keep the color for the hue (a warm `255, 230, 190` bulb, a cool `150, 190, 255` moon) and leave at least one channel near `255`. Raises an error when the node carries no light.
*Since:* 3.00.
*See also:* <<lightsetintensity,lightSetIntensity>>, <<lightnew,lightNew>>
.Example
[source,lua]
----
-- The neon sign cycles through its colors.
signLight = lightNew(LIGHT_POINT)
nodeSetPosition(signLight, 2, 3, -1)
lightSetIntensity(signLight, 8)
lightSetRange(signLight, 6)
neon = { { 255, 40, 120 }, { 40, 220, 255 }, { 255, 210, 40 } }
function onOverlayUpdate()
local phase = math.floor(singeGetTicks() / 700) % #neon + 1
lightSetColor(signLight, neon[phase][1], neon[phase][2], neon[phase][3])
return OVERLAY_UPDATED
end
----
[#lightsetcone]
==== lightSetCone
[source,text]
----
lightSetCone(node, innerDegrees, outerDegrees)
----
The shape of a spot light's cone, both angles measured from the axis (the node's -Z): full brightness within `innerDegrees`, fading smoothly to nothing at `outerDegrees`. The defaults are `20` and `30`. A narrow gap between the two gives a hard-edged theater spot, a wide one a soft pool. The values are stored for every light but only a `LIGHT_SPOT` uses them. Raises an error when the node carries no light.
*Parameters:*
* `node` -- the light's node.
* `innerDegrees` -- half angle of full brightness, in degrees from the axis.
* `outerDegrees` -- half angle where the light reaches nothing, in degrees from the axis; raised to `innerDegrees` when smaller.
*Since:* 3.00.
*See also:* <<lightnew,lightNew>>, <<lightsetrange,lightSetRange>>, <<nodelookat,nodeLookAt>>
.Example
[source,lua]
----
-- A stage spot on the singer, hard edged, from the lighting rig.
spot = lightNew(LIGHT_SPOT)
nodeSetPosition(spot, 0, 8, 4)
nodeLookAt(spot, nodeGetWorldPosition(singer))
lightSetColor(spot, 255, 245, 220)
lightSetIntensity(spot, 80)
lightSetCone(spot, 8, 10)
lightSetShadow(spot, true)
----
[#lightsetintensity]
==== lightSetIntensity
[source,text]
----
lightSetIntensity(node, intensity)
----
The light's brightness multiplier in the scene's linear light, default `1`. A directional light of intensity `1` lights a white surface facing it to white. A point or spot light fades with distance as `1 / (1 + d^2)`, so its intensity is the brightness at the light itself, half of it one unit away and a hundredth ten units away; bulbs usually want values well above `1`, `10` to `50` for a room, and `sceneSetExposure` brings the whole scene back into range. Raises an error when the node carries no light.
*Since:* 3.00.
*See also:* <<lightsetrange,lightSetRange>>, <<scenesetexposure,sceneSetExposure>>, <<lightsetcolor,lightSetColor>>
.Example
[source,lua]
----
-- A lantern that flickers in the wind.
lantern = lightNew(LIGHT_POINT, lanternNode)
lightSetColor(lantern, 255, 200, 140)
lightSetRange(lantern, 9)
function onOverlayUpdate()
local flicker = 10 + math.random() * 3
lightSetIntensity(lantern, flicker)
return OVERLAY_UPDATED
end
----
[#lightsetrange]
==== lightSetRange
[source,text]
----
lightSetRange(node, range)
----
How far a point or spot light reaches, in world units; `0` (the default) means no limit. The light fades to exactly nothing at the range, on top of its distance falloff, so lights do not pop as objects cross it. A range also bounds how far a shadow-casting light's shadow map has to reach, which keeps its shadows sharp. Directional lights ignore it. Raises an error when the node carries no light.
*Since:* 3.00.
*See also:* <<lightsetintensity,lightSetIntensity>>, <<lightsetshadow,lightSetShadow>>
.Example
[source,lua]
----
-- Street lamps down the road, each lighting its own stretch.
for i = 0, 9 do
local lamp = lightNew(LIGHT_POINT)
nodeSetPosition(lamp, 4, 5, -i * 20)
lightSetColor(lamp, 255, 220, 170)
lightSetIntensity(lamp, 20)
lightSetRange(lamp, 14)
end
----
[#lightsetshadow]
==== lightSetShadow
[source,text]
----
lightSetShadow(node, shadow)
----
Makes the light cast shadows from everything opaque in the scene, or stops it; off by default. Any of the eight lights may cast, in any mix. A spot light gets one shadow map; a directional light gets one map per cascade (`sceneSetShadowCascades`); a point light gets a cube map of six views, which covers every direction (a bulb inside a room) at six times the cost. `nodeSetShadow` excuses a mesh from casting. Raises an error when the node carries no light.
*Notes:* Each shadow map costs one extra pass over the scene per frame, a cube map six, and a cascaded sun as many as it has cascades; 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>>, <<scenesetshadowcascades,sceneSetShadowCascades>>, <<nodesetshadow,nodeSetShadow>>, <<lightnew,lightNew>>
.Example
[source,lua]
----
-- The sun casts shadows; the lamps only light. The bulb's own glass
-- must not shadow its lamp.
sun = lightNew(LIGHT_DIRECTIONAL)
nodeSetPosition(sun, 3, 6, 4)
nodeLookAt(sun, 0, 0, 0)
lightSetShadow(sun, true)
lamp = lightNew(LIGHT_POINT)
nodeSetPosition(lamp, 3, 3.1, -2)
lightSetIntensity(lamp, 12)
lightSetRange(lamp, 14)
nodeSetShadow(bulbGlass, false)
----
[#material]
=== Material
A material is how a mesh looks: a base color with alpha, an optional base texture from a sprite, a KTX2 file, a video or a rendered view, and the glTF metallic-roughness parameters with four optional maps. `materialNew` returns an integer handle; pass it to `nodeSetMesh` or `nodeSetMaterial`, and to every `materialSet*` call. Materials are shared, so changing one changes every mesh drawn with it, and a node without one draws white and half rough. Colors are integers from `0` to `255` per channel and are treated as sRGB; factors such as roughness run from `0` to `1`. A bad handle raises an error. See <<scenes3d,3D Scenes>> for how materials are lit.
[#materialnew]
==== materialNew
[source,text]
----
material = materialNew()
----
Allocates a new material and returns its handle. It starts white and opaque (color `255, 255, 255, 255`), with roughness `0.5`, metallic `0`, no emissive color, tiling `1, 1`, linear filtering, single sided, lit, and no texture or maps. Set what differs from that and hand the handle to `nodeSetMesh`. Loaded models bring their own materials, so this is for meshes built from script. Raises an error on a machine with no usable GPU.
*Returns:* The material handle, an integer.
*Since:* 3.00.
*See also:* <<materialsetcolor,materialSetColor>>, <<materialsettexture,materialSetTexture>>, <<materialdelete,materialDelete>>, <<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)
trophy = nodeNew()
nodeSetMesh(trophy, meshCone(0.3, 0.8, 24), gold)
----
[#materialdelete]
==== materialDelete
[source,text]
----
materialDelete(material)
----
Frees the material and any texture it uploaded. Every node that was drawn with it falls back to the default look (white, half rough), so reassign those nodes with `nodeSetMaterial` first if they should keep a look. The handle is invalid afterwards and raises an error if used again. The private material a node makes for itself under `nodeSetSprite` or `nodeSetText` belongs to the node and cannot be deleted; the call raises an error, and clearing the sprite or text frees it. Materials are cheap; deleting them matters mostly for ones that carried large textures.
*Since:* 3.00.
*See also:* <<materialnew,materialNew>>, <<nodesetmaterial,nodeSetMaterial>>
.Example
[source,lua]
----
-- Swap the level's ground look when the season changes.
function setSeason(texture)
local look = materialNew()
materialSetTexture(look, texture)
materialSetTiling(look, 16, 16)
nodeSetMaterial(ground, look)
if groundLook then
materialDelete(groundLook)
end
groundLook = look
end
----
[#materialsetblend]
==== materialSetBlend
[source,text]
----
materialSetBlend(material, blend)
----
When `true` the material is drawn translucent, blending by the base color's alpha multiplied by the texture's alpha, after every opaque mesh and sorted farthest first. Blended meshes do not write depth, so leave it `false` (the default) for solid surfaces: an opaque mesh with blending on can show things behind it drawing through. Use it with an alpha below `255` from `materialSetColor` or a texture with transparency.
*Since:* 3.00.
*See also:* <<materialsetcolor,materialSetColor>>, <<materialsettexture,materialSetTexture>>
.Example
[source,lua]
----
-- A force field that fades in when it is hit.
field = materialNew()
materialSetColor(field, 60, 200, 255, 0)
materialSetBlend(field, true)
materialSetDoubleSided(field, true)
nodeSetMesh(shield, meshSphere(2, 32), field)
function onShieldHit()
materialSetColor(field, 60, 200, 255, 160)
end
----
[#materialsetcolor]
==== materialSetColor
[source,text]
----
materialSetColor(material, r, g, b)
materialSetColor(material, r, g, b, a)
----
Sets the base color, `0` to `255` per channel (out-of-range values are clamped), with `a` defaulting to `255`. The color is treated as sRGB and lit in linear light. When the material has a texture, video or view, the color multiplies it, so white leaves the picture as is and a darker color tints it. The alpha only shows through with `materialSetBlend`.
*Parameters:*
* `material` -- the material handle.
* `r, g, b` -- red, green and blue, `0` to `255`.
* `a` -- opacity, `0` to `255`; `255` when omitted.
*Since:* 3.00.
*See also:* <<materialsettexture,materialSetTexture>>, <<materialsetblend,materialSetBlend>>, <<materialsetemissive,materialSetEmissive>>
.Example
[source,lua]
----
-- Team colors on the one car mesh.
teamLooks = {}
for team, rgb in ipairs({ { 220, 40, 40 }, { 40, 90, 220 }, { 240, 200, 30 } }) do
local look = materialNew()
materialSetColor(look, rgb[1], rgb[2], rgb[3])
materialSetMetallic(look, 0.6)
materialSetRoughness(look, 0.3)
teamLooks[team] = look
end
nodeSetMesh(playerCar, carMesh, teamLooks[1])
----
[#materialsetdoublesided]
==== materialSetDoubleSided
[source,text]
----
materialSetDoubleSided(material, doubleSided)
----
Draws both faces of every triangle instead of culling the back face. Needed for open shapes seen from behind, such as a single `meshPlane` viewed from below, a flag, or a leaf card. Closed shapes look the same either way, so leave it `false` (the default) for them: a double-sided material costs fill rate and shadows are cast from both faces too.
*Since:* 3.00.
*See also:* <<meshplane,meshPlane>>, <<materialsetblend,materialSetBlend>>
.Example
[source,lua]
----
-- A banner hanging over the street, readable from both sides.
cloth = materialNew()
materialSetTexture(cloth, spriteLoad(DIR .. "Art/banner.png"))
materialSetDoubleSided(cloth, true)
materialSetRoughness(cloth, 0.9)
banner = nodeNew()
nodeSetMesh(banner, meshPlane(3, 1), cloth)
nodeSetPosition(banner, 0, 4, -6)
nodeSetRotation(banner, 90, 0, 0)
----
[#materialsetemissive]
==== materialSetEmissive
[source,text]
----
materialSetEmissive(material, r, g, b)
----
The light the surface gives off on its own, `0` to `255` per channel (out-of-range values are clamped), added on top of the lighting: screens, lamp bulbs, neon, instrument panels. The default is black, no glow. It lights nothing else and casts no light; put a light node at the same place for that. With `materialSetEmissiveMap` the map says where on the surface this color applies. Emissive surfaces bright enough to pass the threshold of `sceneSetBloom` glow.
*Notes:* An unlit material (`materialSetUnlit`) shows its base color only; the emissive color is ignored on it.
*Since:* 3.00.
*See also:* <<materialsetemissivemap,materialSetEmissiveMap>>, <<materialsetunlit,materialSetUnlit>>, <<scenesetbloom,sceneSetBloom>>
.Example
[source,lua]
----
-- A warning lamp that pulses red when the reactor overheats.
lampLook = materialNew()
materialSetColor(lampLook, 60, 10, 10)
nodeSetMesh(lamp, meshSphere(0.15, 16), lampLook)
function onOverlayUpdate()
local pulse = (math.sin(singeGetTicks() / 120) + 1) / 2
if overheating then
materialSetEmissive(lampLook, math.floor(255 * pulse), 20, 20)
else
materialSetEmissive(lampLook, 0, 0, 0)
end
return OVERLAY_UPDATED
end
----
[#materialsetemissivemap]
==== materialSetEmissiveMap
[source,text]
----
materialSetEmissiveMap(material, sprite)
materialSetEmissiveMap(material, "file.ktx2")
materialSetEmissiveMap(material, nil)
materialSetEmissiveMap(material)
----
A texture saying which parts of the surface glow. Its color is multiplied by the color from `materialSetEmissive`, so set that too: white to use the map's own colors, or a tint. The image comes from a loaded sprite (a copy is uploaded, so the sprite may be unloaded afterwards) or from a `.ktx2` file by name, kept compressed on the GPU. With `nil` or no second argument the map is removed. Raises an error when the texture cannot be uploaded or the file is not a KTX2 texture.
*Since:* 3.00.
*See also:* <<materialsetemissive,materialSetEmissive>>, <<materialsettexture,materialSetTexture>>, <<materialsettiling,materialSetTiling>>
.Example
[source,lua]
----
-- A control panel whose buttons and readouts light up.
panel = materialNew()
materialSetTexture(panel, spriteLoad(DIR .. "Art/panel.png"))
materialSetEmissiveMap(panel, spriteLoad(DIR .. "Art/panelLights.png"))
materialSetEmissive(panel, 255, 255, 255)
materialSetRoughness(panel, 0.6)
nodeSetMesh(nodeFind("Panel", cockpit), meshPlane(1.2, 0.4), panel)
function onPowerDown()
materialSetEmissive(panel, 0, 0, 0)
end
----
[#materialsetfilter]
==== materialSetFilter
[source,text]
----
materialSetFilter(material, filter)
----
How the material's textures are sampled. `FILTER_LINEAR` (the default) blends between texels and between mipmap levels, with anisotropic filtering on surfaces seen at a grazing angle. `FILTER_NEAREST` picks the closest texel and the closest mipmap level, so pixel art stays crisp on a quad. Every texture carries a full mipmap chain, so a texture seen small never sparkles either way. Any other value raises an error.
*Since:* 3.00.
*See also:* <<materialsettexture,materialSetTexture>>, <<materialsetunlit,materialSetUnlit>>
.Example
[source,lua]
----
-- A 16 by 16 pixel tile drawn on a block without smearing.
pixelLook = materialNew()
materialSetTexture(pixelLook, spriteLoad(DIR .. "Art/dirt16.png"))
materialSetFilter(pixelLook, FILTER_NEAREST)
materialSetRoughness(pixelLook, 1)
block = nodeNew()
nodeSetMesh(block, meshBox(1, 1, 1), pixelLook)
----
[#materialsetmetallic]
==== materialSetMetallic
[source,text]
----
materialSetMetallic(material, metallic)
----
How metallic the surface is, `0` for dielectrics (paint, plastic, wood, skin) to `1` for metals, which tint their highlights and reflections with the base color. The default is `0`. Values between are for blends and worn surfaces; most materials want one end or the other. With `materialSetMetallicRoughnessMap` the map's blue channel multiplies this value. A metal has little to reflect without a sky from `sceneSetSky`, so pair the two.
*Since:* 3.00.
*See also:* <<materialsetroughness,materialSetRoughness>>, <<materialsetmetallicroughnessmap,materialSetMetallicRoughnessMap>>, <<scenesetsky,sceneSetSky>>
.Example
[source,lua]
----
-- Chrome bumper, painted body.
chrome = materialNew()
materialSetColor(chrome, 240, 240, 245)
materialSetMetallic(chrome, 1)
materialSetRoughness(chrome, 0.15)
paint = materialNew()
materialSetColor(paint, 30, 80, 200)
materialSetMetallic(paint, 0)
materialSetRoughness(paint, 0.4)
nodeSetMaterial(nodeFind("Bumper", car), chrome)
nodeSetMaterial(nodeFind("Body", car), paint)
----
[#materialsetmetallicroughnessmap]
==== materialSetMetallicRoughnessMap
[source,text]
----
materialSetMetallicRoughnessMap(material, sprite)
materialSetMetallicRoughnessMap(material, "file.ktx2")
materialSetMetallicRoughnessMap(material, nil)
materialSetMetallicRoughnessMap(material)
----
A texture varying the finish across the surface: its green channel multiplies the roughness from `materialSetRoughness` and its blue channel the metallic value from `materialSetMetallic`, the way glTF packs them, so a map from a glTF tool works as is. Set both factors to `1` to use the map's values alone. The image comes from a sprite or a `.ktx2` file by name; `nil` or no second argument removes the map. Raises an error when the texture cannot be uploaded.
*Since:* 3.00.
*See also:* <<materialsetmetallic,materialSetMetallic>>, <<materialsetroughness,materialSetRoughness>>, <<materialsettexture,materialSetTexture>>
.Example
[source,lua]
----
-- Scratched metal plating: shiny where the paint has worn off.
plating = materialNew()
materialSetTexture(plating, DIR .. "Textures/plating.ktx2")
materialSetMetallicRoughnessMap(plating, DIR .. "Textures/platingMetalRough.ktx2")
materialSetMetallic(plating, 1)
materialSetRoughness(plating, 1)
materialSetNormalMap(plating, DIR .. "Textures/platingNormal.ktx2")
nodeSetMesh(hullPanel, meshPlane(4, 2), plating)
----
[#materialsetnormalmap]
==== materialSetNormalMap
[source,text]
----
materialSetNormalMap(material, sprite)
materialSetNormalMap(material, sprite, strength)
materialSetNormalMap(material, "file.ktx2")
materialSetNormalMap(material, "file.ktx2", strength)
materialSetNormalMap(material, nil)
materialSetNormalMap(material)
----
A tangent-space normal map (the usual bluish kind, flat at `128, 128, 255`) that tilts the lighting per texel so bricks, grooves and rivets catch the light without extra geometry. Every mesh has tangents for it: a model's own when the file carries them, otherwise computed from the UVs. The image comes from a sprite or a `.ktx2` file by name; `nil` or no second argument removes the map. Raises an error when the texture cannot be uploaded.
*Parameters:*
* `material` -- the material handle.
* `sprite` or `"file.ktx2"` -- the map, from a loaded sprite or a KTX2 file; `nil` or omitted removes it.
* `strength` -- how far the bumps tilt the lighting, `1` (the default) as authored, `0` flat, above `1` exaggerated.
*Since:* 3.00.
*See also:* <<materialsettexture,materialSetTexture>>, <<materialsetocclusionmap,materialSetOcclusionMap>>, <<materialsettiling,materialSetTiling>>
.Example
[source,lua]
----
-- A brick wall from a flat plane, its mortar lines catching the low sun.
brick = materialNew()
materialSetTexture(brick, spriteLoad(DIR .. "Art/brick.png"))
materialSetNormalMap(brick, spriteLoad(DIR .. "Art/brickNormal.png"), 1.5)
materialSetRoughness(brick, 0.9)
materialSetTiling(brick, 6, 2)
wall = nodeNew()
nodeSetMesh(wall, meshPlane(12, 4), brick)
nodeSetPosition(wall, 0, 2, -3)
nodeSetRotation(wall, 90, 0, 0)
----
[#materialsetocclusionmap]
==== materialSetOcclusionMap
[source,text]
----
materialSetOcclusionMap(material, sprite)
materialSetOcclusionMap(material, sprite, strength)
materialSetOcclusionMap(material, "file.ktx2")
materialSetOcclusionMap(material, "file.ktx2", strength)
materialSetOcclusionMap(material, nil)
materialSetOcclusionMap(material)
----
An ambient occlusion map, occlusion in the red channel (white for open, dark for crevices), that darkens the ambient and sky light in corners and folds. Direct light from light nodes is not affected, as in glTF. The image comes from a sprite or a `.ktx2` file by name; `nil` or no second argument removes the map. Raises an error when the texture cannot be uploaded.
*Parameters:*
* `material` -- the material handle.
* `sprite` or `"file.ktx2"` -- the map, from a loaded sprite or a KTX2 file; `nil` or omitted removes it.
* `strength` -- how much of the map to apply, `0` none to `1` (the default) fully.
*Since:* 3.00.
*See also:* <<materialsetnormalmap,materialSetNormalMap>>, <<scenesetambient,sceneSetAmbient>>, <<scenesetenvironment,sceneSetEnvironment>>
.Example
[source,lua]
----
-- Baked shading for the cave walls, half strength so the lamps still reach the corners.
rock = materialNew()
materialSetTexture(rock, DIR .. "Textures/caveRock.ktx2")
materialSetNormalMap(rock, DIR .. "Textures/caveRockNormal.ktx2")
materialSetOcclusionMap(rock, DIR .. "Textures/caveRockAO.ktx2", 0.5)
materialSetRoughness(rock, 1)
nodeSetMaterial(nodeFind("Walls", cave), rock)
----
[#materialsetroughness]
==== materialSetRoughness
[source,text]
----
materialSetRoughness(material, roughness)
----
How rough the surface is, `0` for a mirror-tight highlight to `1` for matte. The default is `0.5`. Highlights follow the GGX model, so low values give a small bright spot and sharp sky reflections, high values a broad soft sheen and blurred ones. With `materialSetMetallicRoughnessMap` the map's green channel multiplies this value.
*Since:* 3.00.
*See also:* <<materialsetmetallic,materialSetMetallic>>, <<materialsetmetallicroughnessmap,materialSetMetallicRoughnessMap>>
.Example
[source,lua]
----
-- Wet the road when it rains: the same asphalt, tighter highlights.
asphalt = materialNew()
materialSetColor(asphalt, 50, 50, 55)
materialSetRoughness(asphalt, 0.95)
nodeSetMesh(road, meshPlane(8, 200), asphalt)
function onWeatherChange(raining)
if raining then
materialSetRoughness(asphalt, 0.25)
else
materialSetRoughness(asphalt, 0.95)
end
end
----
[#materialsettexture]
==== materialSetTexture
[source,text]
----
materialSetTexture(material, sprite)
materialSetTexture(material, "file.ktx2")
materialSetTexture(material, nil)
materialSetTexture(material)
----
Uses a sprite's image as the base color texture (a copy is uploaded, so the sprite may be unloaded afterwards), or a KTX2 file by name, kept compressed on the GPU in the block format the machine has (see Compressed textures in <<scenes3d,3D Scenes>>). With `nil` or no second argument the texture is removed and the material shows its base color alone. Setting a texture also drops any video or view the material was showing. Texture coordinates come from the mesh; the primitives map an image once across each face, and `materialSetTiling` repeats it. A name that is not a KTX2 file raises an error (load other pictures with `spriteLoad`), as does a failed upload.
*Since:* 3.00.
*See also:* <<spriteload,spriteLoad>>, <<materialsettiling,materialSetTiling>>, <<materialsetvideo,materialSetVideo>>, <<materialsetview,materialSetView>>
.Example
[source,lua]
----
-- A crate textured from a sprite the game already loads for the menu,
-- and a floor from a compressed texture shipped with the game.
crateArt = spriteLoad(DIR .. "Art/crate.png")
crateLook = materialNew()
materialSetTexture(crateLook, crateArt)
crate = nodeNew()
nodeSetMesh(crate, meshBox(1, 1, 1), crateLook)
floorLook = materialNew()
materialSetTexture(floorLook, DIR .. "Textures/tiles.ktx2")
materialSetTiling(floorLook, 10, 10)
nodeSetMesh(floor, meshPlane(20, 20), floorLook)
----
[#materialsettiling]
==== materialSetTiling
[source,text]
----
materialSetTiling(material, u, v)
----
How many times the material's textures (base and maps alike) repeat across a surface's `0` to `1` UV range, `1` and `1` by default. `24, 24` on a heightmap 120 units across tiles a texture every five units. Textures repeat past their edges, so the picture needs to tile cleanly. Values below `1` show part of the image; negative values mirror it.
*Parameters:*
* `material` -- the material handle.
* `u` -- repeats across the surface's U direction.
* `v` -- repeats across the surface's V direction.
*Since:* 3.00.
*See also:* <<materialsettexture,materialSetTexture>>, <<meshheightmap,meshHeightmap>>
.Example
[source,lua]
----
-- Grass every five units across the hills.
ground = materialNew()
materialSetTexture(ground, DIR .. "Textures/grass.ktx2")
materialSetTiling(ground, 24, 24)
materialSetRoughness(ground, 0.95)
hills = nodeNew()
nodeSetMesh(hills, meshHeightmap(DIR .. "Art/hills.png", 120, 14, 120), ground)
bodyNew(hills, BODY_STATIC, SHAPE_MESH)
----
[#materialsetunlit]
==== materialSetUnlit
[source,text]
----
materialSetUnlit(material, unlit)
----
Shows the base color and texture exactly as they are, ignoring every light, the ambient and the sky: for video screens, monitors showing a view, skyboxes, and flat-shaded looks. Fog still applies. The emissive color and the maps are not used on an unlit material. The default is `false`.
*Since:* 3.00.
*See also:* <<materialsetemissive,materialSetEmissive>>, <<materialsetvideo,materialSetVideo>>, <<materialsetview,materialSetView>>
.Example
[source,lua]
----
-- The arcade cabinet's marquee: a picture that is lit from inside, so the
-- room's lights should not shade it.
marquee = materialNew()
materialSetTexture(marquee, spriteLoad(DIR .. "Art/marquee.png"))
materialSetUnlit(marquee, true)
nodeSetMesh(nodeFind("Marquee", cabinet), meshPlane(0.7, 0.2), marquee)
----
[#materialsetview]
==== materialSetView
[source,text]
----
materialSetView(material, view)
materialSetView(material, nil)
materialSetView(material)
----
Shows a rendered view from `viewNew` as the material's base color texture, updated every frame: a security monitor, a rear-view mirror, a portal. Usually paired with `materialSetUnlit` so the picture is not shaded by the lights, and tinted by the base color like any texture. With `nil` or no second argument the material goes back to its own texture, which is kept underneath. Setting a view drops any video the material was showing. Raises an error for a view handle that is not valid.
*Since:* 3.00.
*See also:* <<viewnew,viewNew>>, <<viewsetcamera,viewSetCamera>>, <<materialsetvideo,materialSetVideo>>, <<materialsetunlit,materialSetUnlit>>
.Example
[source,lua]
----
-- A rear-view mirror on the player's car.
rearCamera = nodeNew(car)
nodeSetPosition(rearCamera, 0, 1.2, -0.5)
nodeSetRotation(rearCamera, 0, 180, 0)
rearView = viewNew(384, 256)
viewSetCamera(rearView, rearCamera)
mirror = materialNew()
materialSetView(mirror, rearView)
materialSetUnlit(mirror, true)
nodeSetMesh(nodeFind("Mirror", car), meshPlane(0.3, 0.1), mirror)
----
[#materialsetvideo]
==== materialSetVideo
[source,text]
----
materialSetVideo(material)
materialSetVideo(material, video)
----
Uses the laserdisc (no `video`) or a video loaded with `videoLoad` as the base color texture, updated every frame while it plays. 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, and several materials may show the same one. Setting a video drops any texture or view the material had; `materialSetTexture` takes it off again.
*Notes:* The disc form raises an error in a game without a disc; a bad video handle raises one too.
*Since:* 3.00.
*See also:* <<videoload,videoLoad>>, <<materialsetunlit,materialSetUnlit>>, <<materialsettexture,materialSetTexture>>
.Example
[source,lua]
----
-- A modeled cabinet whose screen shows the laserdisc, and a wall of
-- monitors looping an attract clip.
cabinet = modelInstance(modelLoad(DIR .. "Models/Cabinet.glb"))
screen = materialNew()
materialSetVideo(screen)
materialSetUnlit(screen, true)
nodeSetMesh(nodeFind("Screen", cabinet), meshPlane(0.6, 0.45), screen)
attract = videoLoad(DIR .. "Video/attract.mp4")
videoPlay(attract)
monitors = materialNew()
materialSetVideo(monitors, attract)
materialSetUnlit(monitors, true)
----
[#mesh]
=== Mesh
A mesh is geometry on the GPU: a primitive the engine builds, a terrain
from an image, or vertex data from your script. Handles are integers
returned by the `mesh*` constructors; any number of nodes can share one
mesh, and nodes drawing the same mesh with the same material draw as one
instanced call, so make one mesh and many nodes. Sizes are in scene units
and each primitive is centered on its node's origin unless stated
otherwise, so the node's transform places, turns, and scales it. Texture
coordinates run from `0` to `1` once across each face or surface. Every
constructor needs the 3D renderer described in <<scenes3d,3D Scenes>> and
aborts the script when it is unavailable.
[#meshbox]
==== meshBox
[source,text]
----
mesh = meshBox(width, height, depth)
----
A box `width` along X, `height` along Y, and `depth` along Z, centered on
the origin. Each of the six faces has its own four vertices with a flat
normal, so edges shade crisply, and the texture is laid once across every
face. Crates, walls, floors, and car bodies are all boxes.
*Returns:* The mesh handle.
*Since:* 3.00.
*See also:* <<nodesetmesh,nodeSetMesh>>, <<meshplane,meshPlane>>, <<bodynew,bodyNew>>
.Example
[source,lua]
----
-- A stone floor slab and a stack of crates sharing one mesh.
local floor = nodeNew()
nodeSetMesh(floor, meshBox(16, 0.2, 10), stone)
nodeSetPosition(floor, 0, -0.1, 0)
local crateMesh = meshBox(0.6, 0.6, 0.6)
for i = 1, 4 do
local crate = nodeNew()
nodeSetMesh(crate, crateMesh, wood)
nodeSetPosition(crate, 0, i * 0.6 - 0.3, -2)
bodyNew(crate, BODY_DYNAMIC, SHAPE_BOX, 0.6, 0.6, 0.6)
end
----
[#meshcone]
==== meshCone
[source,text]
----
mesh = meshCone(radius, height)
mesh = meshCone(radius, height, segments)
----
A cone standing on Y with a closed base: the base circle of `radius` at
`-height / 2` and the apex at `+height / 2`. `segments` (default `24`, at
least `3`) is how many facets go round it; the sides shade smoothly across
them. The texture wraps once round the sides and is laid once across the
base.
*Returns:* The mesh handle.
*Since:* 3.00.
*See also:* <<meshcylinder,meshCylinder>>, <<nodesetmesh,nodeSetMesh>>
.Example
[source,lua]
----
-- A lampshade over the bulb, and a row of pine trees from two cones each.
local shade = nodeNew(stand)
nodeSetMesh(shade, meshCone(0.45, 0.5, 24), brass)
nodeSetPosition(shade, 0, 0.9, 0)
local foliage = meshCone(0.8, 2.0, 12)
for i = 1, 5 do
local tree = nodeNew()
nodeSetMesh(tree, foliage, pine)
nodeSetPosition(tree, i * 3 - 9, 1.0, -6)
end
----
[#meshcylinder]
==== meshCylinder
[source,text]
----
mesh = meshCylinder(radius, height)
mesh = meshCylinder(radius, height, segments)
----
A cylinder along Y, closed at both ends, from `-height / 2` to
`+height / 2`. `segments` (default `24`, at least `3`) is how many facets
go round it, shaded smoothly. For a wheel or a coin, rotate the node `90`
degrees about X or Z so the axis lies where you need it.
*Returns:* The mesh handle.
*Since:* 3.00.
*See also:* <<meshcone,meshCone>>, <<meshtorus,meshTorus>>, <<nodesetrotation,nodeSetRotation>>
.Example
[source,lua]
----
-- A lamp post, and a tire lying on its side as a wheel.
local post = nodeNew()
nodeSetMesh(post, meshCylinder(0.08, 3, 10), dark)
nodeSetPosition(post, 3, 1.5, -2)
local tire = nodeNew(wheel)
nodeSetMesh(tire, meshCylinder(0.35, 0.25, 16), rubber)
nodeSetRotation(tire, 0, 0, 90)
----
[#meshdelete]
==== meshDelete
[source,text]
----
meshDelete(mesh)
----
Frees the mesh's GPU buffers and its CPU copy. Nodes that used it are
left with no mesh (and no morph weights) and draw nothing until given
another; the handle becomes invalid and may be reused by a later
constructor. Meshes are not freed when a node is deleted, so delete a mesh
yourself when nothing will use it again.
*Since:* 3.00.
*See also:* <<meshnew,meshNew>>, <<nodesetmesh,nodeSetMesh>>, <<nodedelete,nodeDelete>>
.Example
[source,lua]
----
-- Rebuild the track from a new heightmap between rounds.
function loadTrack(image)
if trackMesh then
meshDelete(trackMesh)
end
trackMesh = meshHeightmap(image, 200, 20, 200)
nodeSetMesh(track, trackMesh, tarmac)
bodyNew(track, BODY_STATIC, SHAPE_MESH)
end
----
[#meshheightmap]
==== meshHeightmap
[source,text]
----
mesh = meshHeightmap(image, sizeX, sizeY, sizeZ)
----
A terrain mesh from a grayscale image, `2` to `1025` pixels each way:
one vertex per pixel across `sizeX` by `sizeZ` units centered on the node,
each raised by the pixel's red channel (`0` black to `255` white) times
`sizeY`, with the image's top row at the far (`-Z`) edge. Normals are
computed from the slopes and texture coordinates run `0` to `1` across the
whole, so `materialSetTiling` sets the texture's repeat. The height samples
are kept: `terrainGetHeight` reads them, and a `SHAPE_MESH` body on the
node becomes a height field. A file that cannot be opened or decoded, the
wrong size, or no 3D renderer aborts the script.
*Parameters:*
* `image` -- the picture's game-relative name, any format `spriteLoad` reads.
* `sizeX` -- the terrain's width along X in world units.
* `sizeY` -- the height of a white pixel above a black one.
* `sizeZ` -- the terrain's depth along Z in world units.
*Returns:* The mesh handle.
*Since:* 3.00.
*See also:* <<terraingetheight,terrainGetHeight>>, <<materialsettiling,materialSetTiling>>, <<bodynew,bodyNew>>
.Example
[source,lua]
----
local ground = materialNew()
materialSetTexture(ground, spriteLoad("grass.png"))
materialSetTiling(ground, 24, 24)
materialSetRoughness(ground, 0.95)
local hills = nodeNew()
nodeSetMesh(hills, meshHeightmap("hills.png", 120, 14, 120), ground)
bodyNew(hills, BODY_STATIC, SHAPE_MESH)
-- The dragon's feet on the slope, wherever the ground is there.
nodeSetPosition(dragon, -8, terrainGetHeight(hills, -8, 3) or 0, 3)
----
[#meshnew]
==== meshNew
[source,text]
----
mesh = meshNew(positions, normals, uvs, indices)
----
Builds geometry from tables of numbers. Tangents for normal mapping are
computed for you, and so are smooth normals when `normals` is `nil`, by
averaging the triangles that share each vertex. Triangles are front facing
when their vertices run counter-clockwise seen from outside; give the
material `materialSetDoubleSided` if the back must show. A table of the
wrong shape, or an index outside the vertex list, aborts the script with a
message saying which.
*Parameters:*
* `positions` -- `x, y, z` for every vertex, one after another; at least one vertex.
* `normals` -- `x, y, z` per vertex, the same count as `positions`, or `nil` to have them computed.
* `uvs` -- `u, v` per vertex (`0` to `1`, `v` down the image), or `nil` for none (every vertex at `0, 0`).
* `indices` -- three vertex numbers per triangle, counted from `1` as the vertices appear in `positions`; at least one triangle.
*Returns:* The mesh handle.
*Since:* 3.00.
*See also:* <<nodesetmesh,nodeSetMesh>>, <<materialsetdoublesided,materialSetDoubleSided>>, <<meshdelete,meshDelete>>
.Example
[source,lua]
----
-- A flat arrow pointing along +X, seen from above (double sided so it works from below too).
local 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 })
local marker = materialNew()
materialSetColor(marker, 255, 200, 40)
materialSetDoubleSided(marker, true)
local pointer = nodeNew()
nodeSetMesh(pointer, arrow, marker)
nodeSetPosition(pointer, 0, 0.05, 0)
----
[#meshplane]
==== meshPlane
[source,text]
----
mesh = meshPlane(width, depth)
mesh = meshPlane(width, depth, columns)
mesh = meshPlane(width, depth, columns, rows)
----
A flat rectangle in the XZ plane facing +Y, `width` along X and `depth`
along Z, centered on the origin, with the texture laid once across it.
Two arguments give a single quad. `columns` (and `rows`, which defaults to
`columns`) subdivide it into that many quads each way, which a soft body
(`softNew`) needs to bend as cloth; both must be at least `1`. Rotate the
node `90` degrees about X to stand it up facing +Z. It is single sided
unless its material is double sided.
*Returns:* The mesh handle.
*Since:* 3.00.
*See also:* <<materialsetdoublesided,materialSetDoubleSided>>, <<materialsetvideo,materialSetVideo>>, <<softnew,softNew>>
.Example
[source,lua]
----
-- The ground, a screen showing the disc, and a flag that will be cloth.
local plain = nodeNew()
nodeSetMesh(plain, meshPlane(60, 60), ground)
local tv = nodeNew()
nodeSetMesh(tv, meshPlane(3.0, 2.2), screen)
nodeSetPosition(tv, 0, 1.6, -4)
nodeSetRotation(tv, 90, 0, 0)
local flag = nodeNew()
nodeSetMesh(flag, meshPlane(1.6, 1.0, 16, 10), cloth)
nodeSetPosition(flag, 0.8, 2.5, 0)
nodeSetRotation(flag, 90, 0, 0)
----
[#meshsphere]
==== meshSphere
[source,text]
----
mesh = meshSphere(radius)
mesh = meshSphere(radius, segments)
----
A sphere of `radius`, centered on the origin, built from latitude and
longitude bands: `segments` (default `32`, at least `3`) around the
equator and half as many from pole to pole. The texture wraps once round
it, `v` from the top pole to the bottom. Fewer segments make a lighter
mesh for balls seen from afar; more make a smoother silhouette up close.
*Returns:* The mesh handle.
*Since:* 3.00.
*See also:* <<meshtorus,meshTorus>>, <<nodesetmesh,nodeSetMesh>>, <<bodynew,bodyNew>>
.Example
[source,lua]
----
-- A glass ball on the floor, and a small marker sphere that shares a look.
local ball = nodeNew()
nodeSetMesh(ball, meshSphere(0.7, 48), glass)
nodeSetPosition(ball, -1.5, 0.7, 0)
bodyNew(ball, BODY_DYNAMIC, SHAPE_SPHERE, 0.7)
local dot = nodeNew()
nodeSetMesh(dot, meshSphere(0.08, 16), marker)
nodeSetPosition(dot, 0, 1.2, 0)
----
[#meshtorus]
==== meshTorus
[source,text]
----
mesh = meshTorus(radius, tubeRadius)
mesh = meshTorus(radius, tubeRadius, segments)
----
A ring lying in the XZ plane around Y, centered on the origin: `radius`
from the center to the middle of the tube and `tubeRadius` the tube's own.
`segments` (default `32`, at least `3`) go around the ring and half as
many (at least `3`) around the tube. The texture wraps once round the ring
in `u` and once round the tube in `v`.
*Returns:* The mesh handle.
*Since:* 3.00.
*See also:* <<meshsphere,meshSphere>>, <<meshcylinder,meshCylinder>>
.Example
[source,lua]
----
-- A gold ring standing on edge for the ball to roll through.
local gold = materialNew()
materialSetColor(gold, 255, 200, 80)
materialSetMetallic(gold, 1)
materialSetRoughness(gold, 0.25)
local ring = nodeNew()
nodeSetMesh(ring, meshTorus(1.2, 0.25, 40), gold)
nodeSetPosition(ring, 0, 1.2, -2)
nodeSetRotation(ring, 90, 0, 0)
----
[#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 an integer
handle; `modelInstance` builds the model's nodes into the scene and returns
their root, which every `node*` and `animation*` call then takes. Positions
and sizes inside a model are in world units, as the file stores them; the
root node scales the whole instance. See <<scenes3d,3D Scenes>>.
[#modelload]
==== modelLoad
[source,text]
----
model = modelLoad(name)
----
Reads a `.glb` through the same lookup as every other asset (game directory, data directory, packed database), uploads its meshes and materials to the GPU once, and keeps its node tree, skins and animations for instancing. Call it once at startup and instance the handle as many times as needed; loading the same file twice makes two copies on the GPU. It needs the 3D scene, so on a machine without a suitable GPU it fails like any other 3D call. A bad file ends the script with a message naming the problem: the file could not be read, is not glTF, refers to an external buffer or image, fails validation, holds a primitive that could not be uploaded, has a skin with more than `128` joints, or has a skinned mesh naming a joint numbered `128` or more. A skinned mesh naming a joint beyond the skins that use it loads, as does an animation whose keyframes cannot be read (that sampler is skipped); either is noted in the program trace. A texture that cannot be decoded, or a missing KTX2 image, does not fail the load; the material goes without that map and the program trace (`--program`) says which one.
*Returns:* The model handle, an integer.
*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, materials and parsed data. Every instance made from it is forgotten at the same time: its nodes stay in the scene (position, parent and children intact) but draw nothing, its animations stop where they are, and a later `animation*` call on its root raises an error because the node is no longer a model instance. Delete the instance roots with `nodeDelete` when the nodes are not wanted either. A handle that is not a loaded model raises an error.
*Since:* 3.00.
*See also:* <<modelload,modelLoad>>, <<modelinstance,modelInstance>>, <<nodedelete,nodeDelete>>
.Example
[source,lua]
----
-- Leaving the hangar level: drop its props and the model behind them.
function unloadHangar()
for _, crate in ipairs(hangarCrates) do
nodeDelete(crate)
end
hangarCrates = {}
modelDelete(crateModel)
crateModel = nil
end
----
[#modelgetanimations]
==== modelGetAnimations
[source,text]
----
names = modelGetAnimations(model)
----
Lists the model's animation clips in file order as a table of strings, so `names[3]` is the clip that `animationPlay(node, 3)` plays. A clip the exporter left unnamed gives an empty string; it can still be played by number. A model with no animations gives an empty table. A handle that is not a loaded model raises an error.
*Returns:* A table of strings indexed from `1`.
*Since:* 3.00.
*See also:* <<animationplay,animationPlay>>, <<modelload,modelLoad>>
.Example
[source,lua]
----
-- Find the clips by name once, so the rest of the game uses numbers.
guardModel = modelLoad(DIR .. "Models/Guard.glb")
guardClips = {}
for index, name in ipairs(modelGetAnimations(guardModel)) do
guardClips[name] = index
end
if guardClips["Idle"] == nil then
debugPrint("Guard.glb has no Idle clip")
end
----
[#modelinstance]
==== modelInstance
[source,text]
----
node = modelInstance(model)
node = modelInstance(model, parent)
----
Builds the model's node tree under a new root node, itself under `parent` or the scene root. The root is a plain node: position, rotate, scale or parent it like any other, and delete it with `nodeDelete` to remove the whole instance. The model's own nodes take their glTF names (or their mesh's name when the node is unnamed) so `nodeFind(name, node)` reaches them; a mesh with several materials becomes one child node per part; lights in the file become scene lights with the file's color, intensity, range and cone; skins are attached and morph weights set. The nodes stand in the file's pose until an animation plays. A handle that is not a loaded model or a `parent` that is not a node raises an error, as does running out of nodes.
*Returns:* The instance's root node handle.
*Since:* 3.00.
*See also:* <<modelload,modelLoad>>, <<nodefind,nodeFind>>, <<animationplay,animationPlay>>, <<nodedelete,nodeDelete>>
.Example
[source,lua]
----
-- A turret on the tank's hull, so it turns with the hull and pivots on its own.
tank = modelInstance(hullModel)
turret = modelInstance(turretModel, tank)
nodeSetPosition(turret, 0, 1.1, -0.3)
barrel = nodeFind("Barrel", turret)
function onOverlayUpdate()
nodeSetRotation(turret, 0, aimYaw, 0)
nodeSetRotation(barrel, aimPitch, 0, 0)
return OVERLAY_UPDATED
end
----
[#mouse]
=== Mouse
Singe reads up to four mice, and treats lightguns as mice. In the default single-mouse mode every pointing device drives one cursor reported as mouse `0`; `mouseSetMode(MOUSE_MANY)` tells the devices apart, which a two-player lightgun cabinet needs. Positions arrive in overlay coordinates, not screen pixels, through `onMouseMoved` (see <<enginecallbacks,Engine Callbacks>>) and on demand from `mouseGetPosition`. Mouse buttons and the wheel are switches like keys: they reach `onInputPressed` and `onInputReleased` when `controls.cfg` maps a code from the `MOUSE_0` through `MOUSE_3` tables, and the shipped defaults map the left button to `SWITCH_BUTTON3`, the right to `SWITCH_BUTTON1` and the middle to `SWITCH_BUTTON2`. The engine starts with the mouse captured, and enabled unless `--nomouse` was given or no mouse was found.
[#mousedisable]
==== mouseDisable
[source,text]
----
mouseDisable()
----
Turns mouse input off, exactly as `mouseSetEnabled(false)` does. It is a function defined in `Framework.singe`, kept so older games run unchanged.
*Notes:* Legacy alias.
WARNING: New code should call <<mousesetenabled,mouseSetEnabled>> with `false`.
*Since:* 1.18 (RDG)
*See also:* <<mousesetenabled,mouseSetEnabled>>, <<mouseenable,mouseEnable>>
.Example
[source,lua]
----
-- Older game code; the modern spelling is mouseSetEnabled(false).
function cutsceneBegin()
mouseDisable()
discSearch(CUTSCENE_START)
end
----
[#mouseenable]
==== mouseEnable
[source,text]
----
mouseEnable()
----
Turns mouse input on, exactly as `mouseSetEnabled(true)` does, and like it stays off when Singe was started with `--nomouse`. It is a function defined in `Framework.singe`, kept so older games run unchanged.
*Notes:* Legacy alias.
WARNING: New code should call <<mousesetenabled,mouseSetEnabled>> with `true`.
*Since:* 1.18 (RDG)
*See also:* <<mousesetenabled,mouseSetEnabled>>, <<mousedisable,mouseDisable>>
.Example
[source,lua]
----
-- Older game code; the modern spelling is mouseSetEnabled(true).
function cutsceneEnd()
mouseEnable()
discSearch(LEVEL_START)
end
----
[#mousegetposition]
==== mouseGetPosition
[source,text]
----
x, y = mouseGetPosition(index)
----
Returns the last reported position of a mouse in overlay coordinates, the same values the most recent `onMouseMoved` for that device carried. In single-mouse mode only mouse `0` moves; in many-mouse mode each device has its own position, starting at the center of the video. The position stops updating while mouse input is disabled. Poll it from `onOverlayUpdate` or `singeMain` when that is simpler than tracking the callback. An index outside `0` to `3` aborts the script.
*Parameters:*
* `index` -- mouse index, `0` through `3`.
*Returns:* two integers, `x` and `y`, in overlay coordinates.
*Since:* 2.00
*See also:* <<mousehowmany,mouseHowMany>>, <<onmousemoved,onMouseMoved>>, <<singewantscrosshairs,singeWantsCrosshairs>>
.Example
[source,lua]
----
-- Draw the crosshair where the gun points.
function onOverlayUpdate()
overlayClear()
drawTargets()
if singeWantsCrosshairs() then
local x, y = mouseGetPosition(0)
spriteDraw(crosshair, x, y, true)
end
return OVERLAY_UPDATED
end
----
[#mousehowmany]
==== mouseHowMany
[source,text]
----
count = mouseHowMany()
----
Returns the number of mice the engine found at startup, `0` through `4`. Devices plugged in later are not counted. When none were found the mouse stays disabled. Use it to decide whether to offer a second lightgun player.
*Returns:* integer, `0` to `4`.
*Since:* 1.18 (RDG)
*See also:* <<mousesetmode,mouseSetMode>>, <<mousegetposition,mouseGetPosition>>
.Example
[source,lua]
----
-- Two guns present: give each player a cursor.
if mouseHowMany() >= 2 then
mouseSetMode(MOUSE_MANY)
players = 2
else
players = 1
end
----
[#mousesetcaptured]
==== mouseSetCaptured
[source,text]
----
mouseSetCaptured(captured)
----
Grabs the mouse, confining it to the Singe window and hiding the cursor, or releases it and shows the cursor again. The engine captures the mouse at startup, so most games never call this; a windowed level editor or a game that wants the desktop cursor visible releases it. The switch mapped to `INPUT_GRAB` in `controls.cfg` toggles the same state from the keyboard.
*Parameters:*
* `captured` -- boolean; `true` grabs and hides, `false` releases and shows.
*Since:* 2.00
*See also:* <<mousesetenabled,mouseSetEnabled>>, <<mousegetposition,mouseGetPosition>>
.Example
[source,lua]
----
-- The level editor wants the desktop cursor; gameplay hides it again.
function editorToggle()
editing = not editing
mouseSetCaptured(not editing)
end
----
[#mousesetenabled]
==== mouseSetEnabled
[source,text]
----
mouseSetEnabled(enabled)
----
Turns mouse input on or off. While off, motion, button and wheel events are discarded before they reach the script: `onMouseMoved` stops firing, mouse buttons no longer produce switches, and `mouseGetPosition` keeps returning the last position it had. The cursor itself still moves. Enabling honors `--nomouse`; when Singe was started with that option the mouse stays off. A non-boolean argument aborts the script.
*Parameters:*
* `enabled` -- boolean.
*Since:* 3.00
*See also:* <<mousesetcaptured,mouseSetCaptured>>, <<mousesetmode,mouseSetMode>>, <<mouseenable,mouseEnable>>
.Example
[source,lua]
----
-- Ignore the gun while the attract loop plays.
function attractBegin()
mouseSetEnabled(false)
discSearch(ATTRACT_START)
end
function attractEnd()
mouseSetEnabled(true)
end
----
[#mousesetmode]
==== mouseSetMode
[source,text]
----
mouseSetMode(mode)
----
Chooses how mice are read. In `MOUSE_SINGLE` (the default) the window's pointer events drive one cursor reported as device `0`, with absolute positions and relative motion both converted to overlay coordinates. In `MOUSE_MANY` each physical device is read separately: relative motion is integrated into a per-device position clamped to the video area, absolute devices such as tablets and some lightguns are mapped onto the video, and `onMouseMoved` reports the device index. Both modes deliver the same five buttons and the vertical wheel as switches. `SINGLE_MOUSE` and `MANY_MOUSE` are accepted as older spellings; any other value aborts the script.
*Parameters:*
* `mode` -- `MOUSE_SINGLE` (`100`) or `MOUSE_MANY` (`200`).
*Since:* 1.18 (RDG)
*See also:* <<mousehowmany,mouseHowMany>>, <<onmousemoved,onMouseMoved>>, <<mousegetposition,mouseGetPosition>>
.Example
[source,lua]
----
-- Cooperative lightgun game: each gun steers its own cursor.
mouseSetMode(MOUSE_MANY)
function onMouseMoved(x, y, xr, yr, which)
if which < 2 then
cursorX[which + 1] = x
cursorY[which + 1] = y
end
end
----
[#nav]
=== Nav
Navigation meshes baked from scene geometry, and the crowd agents that walk them (see <<scenes3d,3D Scenes>>, Navigation). `navNew` and `navLoad` return a mesh handle and `navAgentNew` an agent handle; both are integers, checked on every call, and an invalid one terminates the script. Positions are world units, speeds units a second and slopes degrees. Up to eight meshes at once, 128 agents on each and 256 in all. The queries and the agents need a mesh that `navBuild` has baked or `navLoad` has read; before that the queries return `nil`, `navDraw` draws nothing and `navAgentNew` raises an error.
[#navaddnode]
==== navAddNode
[source,text]
----
navAddNode(nav, node)
----
Adds the node's mesh, and the mesh of every node under it, to the geometry the navigation mesh will be baked from, as they stand in the world right now, so place them first. A model instance's root node adds the whole model, and a level built from primitives can be added node by node. Only mesh geometry counts: bodies, sprites and text add nothing. Raises an error once the mesh has been built.
*Since:* 3.00.
*See also:* <<navnew,navNew>>, <<navbuild,navBuild>>, <<modelinstance,modelInstance>>
.Example
[source,lua]
----
-- The ground and the walls are walkable geometry; the crates are not, they move.
local nav = navNew(0.4, 1.8, 45, 0.4)
navAddNode(nav, ground)
for _, wall in ipairs(walls) do
navAddNode(nav, wall)
end
navAddNode(nav, modelInstance(towerModel))
navBuild(nav)
----
[#navagentdelete]
==== navAgentDelete
[source,text]
----
navAgentDelete(agent)
----
Takes the agent off the mesh and frees its handle; the node stays where it is and is not moved again. Deleting the mesh with `navDelete` does this for every agent on it. An invalid handle terminates the script.
*Since:* 3.00.
*See also:* <<navagentnew,navAgentNew>>, <<navdelete,navDelete>>
.Example
[source,lua]
----
-- A guard that dies stops navigating; the ragdoll takes the model from here.
function killGuard(guard)
navAgentDelete(guard.agent)
guard.agent = nil
ragdollActivate(guard.model)
ragdollApplyImpulse(guard.model, "b_Spine02_03", 0, 20, -30)
end
----
[#navagentgetvelocity]
==== navAgentGetVelocity
[source,text]
----
x, y, z = navAgentGetVelocity(agent)
----
How the crowd is moving the agent this frame, in world units a second along each axis; its length runs up to the agent's speed and falls to zero as it stops. For choosing between an idle, a walk and a run animation, or scaling the walk cycle to the ground covered.
*Returns:* Three numbers.
*Since:* 3.00.
*See also:* <<navagentmoveto,navAgentMoveTo>>, <<animationplay,animationPlay>>
.Example
[source,lua]
----
function onOverlayUpdate()
for agent, fox in pairs(foxes) do
local vx, vy, vz = navAgentGetVelocity(agent)
local speed = math.sqrt(vx * vx + vz * vz)
local clip = (speed < 0.1) and "Survey" or ((speed < 2.5) and "Walk" or "Run")
if fox.clip ~= clip then
animationPlay(fox.node, clip, true, 1, 0.2)
fox.clip = clip
end
end
return OVERLAY_NOT_UPDATED
end
----
[#navagentisarrived]
==== navAgentIsArrived
[source,text]
----
arrived = navAgentIsArrived(agent)
----
Whether the agent has reached the last target `navAgentMoveTo` gave it: within one and a half of its radii of the target and nearly stopped. It turns `true` at the moment `onNavArrived` fires and stays `true` until the next `navAgentMoveTo`; it is `false` for an agent that was never sent anywhere or was stopped on the way.
*Returns:* True or false.
*Since:* 3.00.
*See also:* <<onnavarrived,onNavArrived>>, <<navagentmoveto,navAgentMoveTo>>, <<navagentstop,navAgentStop>>
.Example
[source,lua]
----
-- The patrol waits at each post for a while before moving on.
function onOverlayUpdate()
if navAgentIsArrived(patrol) then
waitFrames = waitFrames + 1
if waitFrames > 180 then
post = post % #posts + 1
waitFrames = 0
navAgentMoveTo(patrol, posts[post][1], posts[post][2], posts[post][3])
end
end
return OVERLAY_NOT_UPDATED
end
----
[#navagentmoveto]
==== navAgentMoveTo
[source,text]
----
ok = navAgentMoveTo(agent, x, y, z)
----
Sends the agent walking to the point of the mesh nearest `x, y, z`, looking up to 2 units sideways and 4 up or down for one. The crowd plans the route over the following frames and steers the agent round the other agents on the way; `onNavArrived` fires once it gets there. Returns `false`, and leaves the agent as it was, when no point of the mesh is near enough or the crowd refuses the request. A new target replaces the old one at any time.
*Parameters:*
* `agent` -- the handle from `navAgentNew`.
* `x`, `y`, `z` -- the destination in world units; it is snapped onto the mesh.
*Returns:* True when the agent set off.
*Since:* 3.00.
*See also:* <<navagentstop,navAgentStop>>, <<navnearest,navNearest>>, <<navpath,navPath>>
.Example
[source,lua]
----
-- The sidekick follows the hero, re-aimed twice a second.
function onOverlayUpdate()
ticks = ticks + 1
if ticks % 30 == 0 then
local hx, hy, hz = nodeGetWorldPosition(hero)
if not navAgentMoveTo(sidekickAgent, hx, hy, hz) then
navAgentStop(sidekickAgent) -- The hero is off the mesh: jumping, swimming
end
end
return OVERLAY_NOT_UPDATED
end
----
[#navagentnew]
==== navAgentNew
[source,text]
----
agent = navAgentNew(nav, node, radius, height, speed)
----
Puts an agent of that size on the mesh at the point nearest the node's world position (the raw position when nothing is within 2 units sideways and 4 up or down), walking at `speed` units a second and accelerating at 8 units a second squared. The radius and height are kept at `0.01` or more, as `navNew` keeps its own. From then on the engine moves the node along the mesh every frame, in its parent's frame, and turns it about Y to face the way it is going whenever it moves faster than `0.05` units a second; nodes look down -Z, as models do. The crowd steers agents round each other, anticipates turns and keeps them apart. To drive a player controller on the node instead of placing the node, see `navAgentSetPlayer`. Raises an error when the mesh is not built or 128 agents are already on it.
*Parameters:*
* `nav` -- a built mesh from `navNew` or `navLoad`.
* `node` -- the node to move; usually a model instance's root.
* `radius` -- the agent's radius for keeping clear of others, in world units; the mesh's own radius or less.
* `height` -- the agent's height, in world units.
* `speed` -- top walking speed, in world units a second.
*Returns:* The agent's handle, an integer.
*Since:* 3.00.
*See also:* <<navagentmoveto,navAgentMoveTo>>, <<navagentsetplayer,navAgentSetPlayer>>, <<navagentdelete,navAgentDelete>>, <<navrandompoint,navRandomPoint>>
.Example
[source,lua]
----
for i = 1, 6 do
local fox = modelInstance(foxModel)
nodeSetScale(fox, 0.01)
nodeSetPosition(fox, navRandomPoint(nav))
animationPlay(fox, "Walk", true)
local agent = navAgentNew(nav, fox, 0.35, 1.0, 1.4)
navAgentMoveTo(agent, navRandomPoint(nav))
end
function onNavArrived(agent)
navAgentMoveTo(agent, navRandomPoint(nav))
end
----
[#navagentsetplayer]
==== navAgentSetPlayer
[source,text]
----
navAgentSetPlayer(agent, player)
----
With `true`, the agent stops placing its node and instead feeds its velocity, each frame, to the player controller on the node as a `playerMove` (horizontal only), so stairs, slopes, pushing and collisions keep working; before every crowd update the controller's world position is read back as the agent's own, so being shoved off course is planned around. The node still turns to face its way. The node needs a controller from `playerNew`, or nothing moves. With `false` the agent goes back to placing the node itself.
*Since:* 3.00.
*See also:* <<playernew,playerNew>>, <<playermove,playerMove>>, <<navagentnew,navAgentNew>>
.Example
[source,lua]
----
-- A companion who follows the hero through the level and can be knocked about.
local companion = nodeNew()
nodeSetPosition(companion, 2, 0.05, 0)
playerNew(companion, 0.3, 1.0)
local companionFox = modelInstance(foxModel)
nodeSetParent(companionFox, companion)
nodeSetScale(companionFox, 0.01)
local companionAgent = navAgentNew(nav, companion, 0.3, 1.0, 2.0)
navAgentSetPlayer(companionAgent, true)
----
[#navagentstop]
==== navAgentStop
[source,text]
----
navAgentStop(agent)
----
Forgets the agent's target: the crowd stops steering it toward anything, it slows to a stand where it is, and `onNavArrived` does not fire. Other agents still walk round it. Send it somewhere with `navAgentMoveTo` to set off again.
*Since:* 3.00.
*See also:* <<navagentmoveto,navAgentMoveTo>>, <<navagentisarrived,navAgentIsArrived>>
.Example
[source,lua]
----
-- Guards freeze when the alarm sounds and turn toward the noise.
function raiseAlarm(x, y, z)
for _, guard in ipairs(guards) do
navAgentStop(guard.agent)
nodeLookAt(guard.node, x, y, z)
animationPlay(guard.node, "Survey", true, 1, 0.3)
end
soundPlay(alarm)
end
----
[#navbuild]
==== navBuild
[source,text]
----
navBuild(nav)
----
Bakes the walkable mesh from everything `navAddNode` added: floors and slopes up to the mesh's angle, steps up to its height, with the agent's radius trimmed off round every wall and drop, and anything with less than the agent's height above it left out. The bake works on cells a third of the agent's radius across and covers at most 4096 by 4096 of them, so a small radius over a big level is slow and can fail; a tenth of a second for Sponza on a desktop, a few seconds on a Raspberry Pi. The geometry that was added is freed afterward, and nothing more can be added; bake another mesh instead. `navSave` keeps the result. Raises an error when nothing was added, the mesh is already built, or the bake fails.
*Since:* 3.00.
*See also:* <<navnew,navNew>>, <<navaddnode,navAddNode>>, <<navsave,navSave>>, <<navdraw,navDraw>>
.Example
[source,lua]
----
-- The bake happens once, while the title screen is up, and is kept for next time.
local sponza = modelInstance(modelLoad("Models/Sponza.glb"))
local nav = navNew(0.4, 1.6, 45, 0.3)
navAddNode(nav, sponza)
navBuild(nav)
navSave(nav, "sponza.nav")
if debugging then
navDraw(nav) -- One frame, to eyeball the bake
end
----
[#navdelete]
==== navDelete
[source,text]
----
navDelete(nav)
----
Frees the mesh and every agent on it; their nodes stay where they are. The mesh handle and the agent handles are invalid afterward and terminate the script if used. The slot is free for a new `navNew` or `navLoad`.
*Since:* 3.00.
*See also:* <<navnew,navNew>>, <<navagentdelete,navAgentDelete>>
.Example
[source,lua]
----
function loadLevel(index)
if levelNav then
navDelete(levelNav) -- Takes the old level's agents with it
end
nodeDelete(levelRoot)
levelRoot = modelInstance(levels[index].model)
levelNav = navNew(0.4, 1.6, 45, 0.3)
navAddNode(levelNav, levelRoot)
navBuild(levelNav)
end
----
[#navdraw]
==== navDraw
[source,text]
----
navDraw(nav)
navDraw(nav, r, g, b)
----
Draws the baked mesh's polygons as lines over the scene for this frame, cyan unless colored, each polygon as a fan of triangles so a polygon with more than three sides shows its diagonals. Call it every frame while looking; it costs three lines per triangle, and a mesh that is not built yet draws nothing. For checking that a bake covers the floor and stays off the furniture, alongside `physicsSetDebug`.
*Parameters:*
* `nav` -- the mesh.
* `r`, `g`, `b` -- the line color, `0` to `255` each; default `0, 255, 255`.
*Since:* 3.00.
*See also:* <<linedraw,lineDraw>>, <<navbuild,navBuild>>, <<physicssetdebug,physicsSetDebug>>
.Example
[source,lua]
----
function onOverlayUpdate()
if showNavMesh then
navDraw(nav) -- Cyan over the floor
local path = navPath(nav, heroX, heroY, heroZ, goalX, goalY, goalZ)
if path then
for i = 2, #path do
lineDraw(path[i - 1][1], path[i - 1][2], path[i - 1][3], path[i][1], path[i][2], path[i][3], 255, 60, 40)
end
end
end
return OVERLAY_NOT_UPDATED
end
----
[#navload]
==== navLoad
[source,text]
----
nav = navLoad(name, agentRadius, agentHeight)
----
Loads a mesh `navSave` wrote, looked for among the game's files first (so a baked mesh can ship with the game, packed or not) and then in the game's data folder. The mesh comes back built, ready for agents and queries, and takes no more `navAddNode`. The slope and step it was baked with are in the file; the agent radius and height are what the crowd steers with, so give the values it was baked for. Raises an error when the file cannot be read, is not a navigation mesh, is shorter than the mesh its header describes (truncated, or edited by hand), or no mesh slot is free.
*Parameters:*
* `name` -- the file's name, relative to the game directory or the data folder.
* `agentRadius` -- the agent radius the mesh was baked for, in world units.
* `agentHeight` -- the agent height it was baked for, in world units.
*Returns:* The mesh handle, an integer.
*Since:* 3.00.
*See also:* <<navsave,navSave>>, <<navnew,navNew>>
.Example
[source,lua]
----
-- The shipped bake for the museum; agents 0.4 wide and 1.7 tall.
local museum = modelInstance(modelLoad("museum.glb"))
local nav = navLoad("museum.nav", 0.4, 1.7)
for _, visitor in ipairs(visitors) do
visitor.agent = navAgentNew(nav, visitor.node, 0.35, 1.7, 1.2)
navAgentMoveTo(visitor.agent, navRandomPoint(nav))
end
----
[#navnearest]
==== navNearest
[source,text]
----
x, y, z = navNearest(nav, x, y, z)
----
The closest point of the mesh to a point, searching up to 2 units sideways and 4 up or down, or `nil` when there is nothing that near. For snapping a spawn point, a drop or a click onto the floor before handing it to an agent, and for finding the floor's height under a point.
*Parameters:*
* `nav` -- the mesh.
* `x`, `y`, `z` -- the point to snap, in world units.
*Returns:* Three numbers, or `nil`.
*Since:* 3.00.
*See also:* <<navpath,navPath>>, <<navagentmoveto,navAgentMoveTo>>, <<sceneunproject,sceneUnproject>>
.Example
[source,lua]
----
-- Spawn the enemy on the floor nearest the spawner, or not at all.
function spawnEnemy(spawner)
local sx, sy, sz = nodeGetWorldPosition(spawner)
local x, y, z = navNearest(nav, sx, sy, sz)
if x then
local enemy = modelInstance(enemyModel)
nodeSetPosition(enemy, x, y, z)
enemies[#enemies + 1] = { node = enemy, agent = navAgentNew(nav, enemy, 0.4, 1.8, 2.5) }
end
end
----
[#navnew]
==== navNew
[source,text]
----
nav = navNew(agentRadius, agentHeight, maxSlope, maxStep)
----
Starts a navigation mesh for agents of that radius and height that can climb steps up to `maxStep` units and slopes up to `maxSlope` degrees. The radius and height are kept at `0.01` or more, the slope between `0` and `89` and the step at `0` or more. The radius also sets the bake's resolution, a cell being a third of it, so it decides how long `navBuild` takes and how big a level it can cover. Add geometry with `navAddNode`, then `navBuild`. Raises an error when eight meshes already exist.
*Parameters:*
* `agentRadius` -- how wide the agents are, in world units; walls are padded by this.
* `agentHeight` -- how tall they are; lower ceilings are not walkable.
* `maxSlope` -- the steepest walkable slope, in degrees.
* `maxStep` -- the highest step they climb, in world units.
*Returns:* The mesh handle, an integer.
*Since:* 3.00.
*See also:* <<navaddnode,navAddNode>>, <<navbuild,navBuild>>, <<navload,navLoad>>
.Example
[source,lua]
----
-- Sponza for an agent 0.4 wide and 1.6 tall that climbs 0.3 and walks slopes to 45 degrees.
local sponza = modelInstance(modelLoad("Models/Sponza.glb"))
local nav = navNew(0.4, 1.6, 45, 0.3)
navAddNode(nav, sponza)
navBuild(nav)
----
[#navpath]
==== navPath
[source,text]
----
points = navPath(nav, x0, y0, z0, x1, y1, z1)
----
The corners of the shortest walk from one point to another, both snapped onto the mesh first, as a table of `{ x, y, z }` tables with the first at the start and the last at the end; up to 256 corners. When the end cannot be reached the walk stops at the nearest reachable point on the way, so the last corner is always somewhere an agent can stand. `nil` when either end is nowhere near the mesh or no path is found. For drawing a route, measuring how far something is on foot, or moving something along it yourself; agents do this on their own.
*Parameters:*
* `nav` -- the mesh.
* `x0`, `y0`, `z0` -- where the walk starts.
* `x1`, `y1`, `z1` -- where it ends.
*Returns:* A table of `{ x, y, z }` tables, or `nil`.
*Since:* 3.00.
*See also:* <<navagentmoveto,navAgentMoveTo>>, <<navraycast,navRaycast>>, <<navnearest,navNearest>>
.Example
[source,lua]
----
-- How far the exit is on foot, for the map's distance readout.
function walkingDistance(fromX, fromY, fromZ, toX, toY, toZ)
local path = navPath(nav, fromX, fromY, fromZ, toX, toY, toZ)
local total = 0
for i = 2, #(path or {}) do
local dx = path[i][1] - path[i - 1][1]
local dz = path[i][3] - path[i - 1][3]
total = total + math.sqrt(dx * dx + dz * dz)
end
return path and total or nil
end
----
[#navrandompoint]
==== navRandomPoint
[source,text]
----
x, y, z = navRandomPoint(nav)
----
A random point on the walkable mesh, anywhere it reaches, for wandering, scattering pickups or placing a spawn. `nil` only when the mesh is not built.
*Returns:* Three numbers, or `nil`.
*Since:* 3.00.
*See also:* <<navagentmoveto,navAgentMoveTo>>, <<navnearest,navNearest>>
.Example
[source,lua]
----
-- Scatter twenty coins over the floor, each a little above it.
for i = 1, 20 do
local x, y, z = navRandomPoint(nav)
local coin = nodeNew()
nodeSetMesh(coin, coinMesh, gold)
nodeSetPosition(coin, x, y + 0.3, z)
coins[#coins + 1] = coin
end
----
[#navraycast]
==== navRaycast
[source,text]
----
blocked, x, y, z = navRaycast(nav, x0, y0, z0, x1, y1, z1)
----
Whether a straight walk from one point to another leaves the mesh, at a wall or a drop, and where it would. The start is snapped onto the mesh; `false` alone means the way is clear, or that the start is nowhere near the mesh. When blocked, the point is where the line crosses the mesh's edge, its height interpolated between the two ends. A cheap check for whether a shortcut exists before asking `navPath`.
*Parameters:*
* `nav` -- the mesh.
* `x0`, `y0`, `z0` -- where the walk starts.
* `x1`, `y1`, `z1` -- where it would end.
*Returns:* `false`, or `true` and the point where the walk leaves the mesh.
*Since:* 3.00.
*See also:* <<navpath,navPath>>, <<physicsraycast,physicsRaycast>>
.Example
[source,lua]
----
-- A thrown grenade only rolls as far as the floor lets it.
function landingSpot(fromX, fromY, fromZ, toX, toY, toZ)
local blocked, hx, hy, hz = navRaycast(nav, fromX, fromY, fromZ, toX, toY, toZ)
if blocked then
return hx, hy, hz
end
return toX, toY, toZ
end
----
[#navsave]
==== navSave
[source,text]
----
navSave(nav, name)
----
Writes the baked mesh to the game's data folder under `name`, for `navLoad` next time, or to copy into the game directory to ship. The mesh must be built. Raises an error when it is not, or when the file cannot be written.
*Since:* 3.00.
*See also:* <<navload,navLoad>>, <<navbuild,navBuild>>
.Example
[source,lua]
----
-- Baked once on this machine, then loaded from the data folder in later runs.
local nav = navNew(0.4, 1.6, 45, 0.3)
navAddNode(nav, level)
navBuild(nav)
navSave(nav, "level1.nav")
debugPrint("Navigation mesh baked and saved.")
----
[#node]
=== Node
Every object in a scene is a node in one tree: node `0` is the root, and
every other node hangs under a parent. A node carries a position, a
rotation, and a scale relative to that parent, and may draw a mesh with a
material, show a sprite or a line of text, or shine as a light; children
inherit the transform of everything above them. Handles are integers
returned by `nodeNew`, `modelInstance`, and `lightNew`; a handle whose node
has been deleted aborts the script when passed back, and a freed handle may
be reused by a later `nodeNew`. Positions and sizes are in scene units and
rotations in degrees, with +Y up and -Z forward as described in
<<scenes3d,3D Scenes>>.
[#nodenew]
==== nodeNew
[source,text]
----
node = nodeNew()
node = nodeNew(parent)
----
Creates an empty node at its parent's origin, unrotated, at scale `1`,
visible, casting shadows, with no mesh or material. Without `parent` the
node goes under the root; with one it becomes that node's last child and
moves with it from then on. Empty nodes serve as pivots and groups: place
one, then hang meshes, lights, or a camera under it and move the group as a
whole.
*Returns:* The node handle.
*Since:* 3.00.
*See also:* <<nodesetmesh,nodeSetMesh>>, <<nodesetparent,nodeSetParent>>, <<nodedelete,nodeDelete>>, <<lightnew,lightNew>>
.Example
[source,lua]
----
-- A turret whose barrel pivots on the turret, which turns on the base.
local steel = materialNew()
materialSetColor(steel, 140, 145, 150)
local base = nodeNew()
local turret = nodeNew(base)
local 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, freeing every handle in that
subtree; a sprite or text on any of them is released too. Meshes and
materials the nodes drew are untouched, since other nodes may share them.
If the deleted node was the camera, the scene falls back to the default
view. The root node cannot be deleted; asking aborts the script.
*Since:* 3.00.
*See also:* <<nodenew,nodeNew>>, <<nodesetvisible,nodeSetVisible>>, <<meshdelete,meshDelete>>
.Example
[source,lua]
----
-- Shots live for two seconds, then their nodes go.
function fireShot(ship)
local shot = nodeNew()
nodeSetMesh(shot, shotMesh, tracer)
nodeSetPosition(shot, nodeGetWorldPosition(ship))
shots[#shots + 1] = { node = shot, born = os.clock() }
end
function reapShots()
for i = #shots, 1, -1 do
if os.clock() - shots[i].born > 2 then
nodeDelete(shots[i].node)
table.remove(shots, i)
end
end
end
----
[#nodefind]
==== nodeFind
[source,text]
----
node = nodeFind(name)
node = nodeFind(name, root)
----
Searches depth first for the first node named `name`, starting at `root`
(the scene root when omitted) and checking `root` itself before its
children, each child's subtree in turn. Returns `nil` when nothing
matches. Model nodes carry the names from their glTF file, so a bone,
a mesh, or a marker empty placed in Blender can be found by that name
under the instance's root. Names set with `nodeSetName` are found the same
way.
*Returns:* A node handle, or `nil`.
*Since:* 3.00.
*See also:* <<nodesetname,nodeSetName>>, <<nodegetname,nodeGetName>>, <<modelinstance,modelInstance>>, <<nodegetchildren,nodeGetChildren>>
.Example
[source,lua]
----
-- Hang a torch on the hero's right hand, found by its bone name.
local hero = modelInstance(heroModel)
local hand = nodeFind("hand_R", hero)
if hand then
local torch = modelInstance(torchModel)
nodeSetParent(torch, hand)
nodeSetPosition(torch, 0, 0.05, 0)
nodeSetRotation(torch, 0, 0, -90)
end
----
[#nodegetchildren]
==== nodeGetChildren
[source,text]
----
children = nodeGetChildren(node)
----
The node's direct children as a table of handles, in the order they were
attached; re-parenting a node puts it at the end of its new parent's list.
Grandchildren are not included; recurse to walk a whole subtree. An
empty table means the node has no children.
*Returns:* A table of node handles.
*Since:* 3.00.
*See also:* <<nodegetparent,nodeGetParent>>, <<nodefind,nodeFind>>, <<nodesetparent,nodeSetParent>>
.Example
[source,lua]
----
-- Give every part of a loaded prop the same worn material.
local function paint(node, material)
nodeSetMaterial(node, material)
for _, child in ipairs(nodeGetChildren(node)) do
paint(child, material)
end
end
local crate = modelInstance(crateModel)
paint(crate, wornWood)
----
[#nodegetmorph]
==== nodeGetMorph
[source,text]
----
weight = nodeGetMorph(node, nameOrIndex)
----
The current weight of one morph target of the node's mesh, given by name or
by number from `1` in the order `nodeGetMorphs` lists them. The node is the
one carrying the mesh, not a model instance's root. A name or number the
mesh does not have aborts the script.
*Returns:* A number, `0` until something sets the target.
*Since:* 3.00.
*See also:* <<nodesetmorph,nodeSetMorph>>, <<nodegetmorphs,nodeGetMorphs>>
.Example
[source,lua]
----
-- Ease the smile in over several frames instead of snapping it on.
function onOverlayUpdate()
local now = nodeGetMorph(face, "smile")
local target = happy and 1 or 0
nodeSetMorph(face, "smile", now + (target - now) * 0.1)
return OVERLAY_UPDATED
end
----
[#nodegetmorphs]
==== nodeGetMorphs
[source,text]
----
names = nodeGetMorphs(node)
----
The morph targets of the node's mesh as a table of names, in the order the
file stores them; that order is the numbering `nodeSetMorph` and
`nodeGetMorph` accept. Exporters that write no names give empty strings,
and those targets can still be set by number. A node with no mesh, or a
mesh without targets, gives an empty table.
*Returns:* A table of strings.
*Since:* 3.00.
*See also:* <<nodesetmorph,nodeSetMorph>>, <<nodegetmorph,nodeGetMorph>>, <<modelload,modelLoad>>
.Example
[source,lua]
----
-- Map the face's target names to numbers once, so lip sync can index them.
local head = nodeFind("Head", hero)
local visemes = {}
for index, name in ipairs(nodeGetMorphs(head)) do
if name ~= "" then
visemes[name] = index
end
end
debugPrint(string.format("%d morph targets on the head", #nodeGetMorphs(head)))
----
[#nodegetname]
==== nodeGetName
[source,text]
----
name = nodeGetName(node)
----
The node's name: whatever `nodeSetName` gave it or its glTF name for a
model node, an empty string when it has neither. The root node is named
`root`.
*Returns:* A string.
*Since:* 3.00.
*See also:* <<nodesetname,nodeSetName>>, <<nodefind,nodeFind>>
.Example
[source,lua]
----
-- Print a model's node tree to the console while working out bone names.
local function dump(node, depth)
debugPrint(string.rep(" ", depth) .. nodeGetName(node) .. " (" .. node .. ")")
for _, child in ipairs(nodeGetChildren(node)) do
dump(child, depth + 1)
end
end
dump(modelInstance(heroModel), 0)
----
[#nodegetparent]
==== nodeGetParent
[source,text]
----
parent = nodeGetParent(node)
----
The handle of the node's parent, or `nil` for the root. Every other node
has a parent, even if only the root.
*Returns:* A node handle, or `nil`.
*Since:* 3.00.
*See also:* <<nodesetparent,nodeSetParent>>, <<nodegetchildren,nodeGetChildren>>
.Example
[source,lua]
----
-- Climb from a picked mesh node to the top of its model instance.
local function instanceRoot(node)
local parent = nodeGetParent(node)
while parent and parent ~= 0 do
node = parent
parent = nodeGetParent(node)
end
return node
end
----
[#nodegetposition]
==== nodeGetPosition
[source,text]
----
x, y, z = nodeGetPosition(node)
----
The node's position relative to its parent, as last set by
`nodeSetPosition`, `nodeMove`, or the engine (a body, a vehicle, or an
animation moving it). For where the node is in the world, use
`nodeGetWorldPosition`.
*Returns:* Three numbers.
*Since:* 3.00.
*See also:* <<nodesetposition,nodeSetPosition>>, <<nodegetworldposition,nodeGetWorldPosition>>, <<nodemove,nodeMove>>
.Example
[source,lua]
----
-- The camera trails the car, three units up and nine behind.
function onOverlayUpdate()
local cx, cy, cz = nodeGetPosition(car)
nodeSetPosition(camera, cx + 3, cy + 3, cz + 9)
nodeLookAt(camera, cx, cy, cz - 3)
return OVERLAY_UPDATED
end
----
[#nodegetquaternion]
==== nodeGetQuaternion
[source,text]
----
x, y, z, w = nodeGetQuaternion(node)
----
The node's rotation relative to its parent as a unit quaternion, the form
models and animations store. Use it to copy a rotation between nodes or
to keep a pose without the ambiguity of Euler angles.
*Returns:* Four numbers.
*Since:* 3.00.
*See also:* <<nodesetquaternion,nodeSetQuaternion>>, <<nodegetrotation,nodeGetRotation>>
.Example
[source,lua]
----
-- Remember the door's pose before the ragdoll test, then put it back.
local px, py, pz = nodeGetPosition(door)
local qx, qy, qz, qw = nodeGetQuaternion(door)
function resetDoor()
nodeSetPosition(door, px, py, pz)
nodeSetQuaternion(door, qx, qy, qz, qw)
end
----
[#nodegetrotation]
==== nodeGetRotation
[source,text]
----
x, y, z = nodeGetRotation(node)
----
The node's rotation relative to its parent as Euler angles in degrees,
decomposed the same way `nodeSetRotation` composes them: turned about Y,
then tilted about X, then rolled about Z. The angles describe the same
rotation that was set but need not be the same numbers: `X` comes back
between `-90` and `90`, and when it reaches `90` or `-90` the twist is
given to `Y` with `Z` reported as `0`.
*Returns:* Three numbers.
*Since:* 3.00.
*See also:* <<nodesetrotation,nodeSetRotation>>, <<nodegetquaternion,nodeGetQuaternion>>
.Example
[source,lua]
----
-- A compass in the overlay from the ship's heading.
function onOverlayUpdate()
local _, heading = nodeGetRotation(ship)
overlayClear()
fontPrint(20, 20, string.format("Heading %03d", (360 - heading) % 360))
return OVERLAY_UPDATED
end
----
[#nodegetscale]
==== nodeGetScale
[source,text]
----
x, y, z = nodeGetScale(node)
----
The node's own scale per axis, as set by `nodeSetScale` (`1, 1, 1` for a
new node). Parents' scales are not folded in.
*Returns:* Three numbers.
*Since:* 3.00.
*See also:* <<nodesetscale,nodeSetScale>>
.Example
[source,lua]
----
-- Grow the power-up toward full size, a little each frame.
function onOverlayUpdate()
local size = nodeGetScale(powerUp)
if size < 1 then
nodeSetScale(powerUp, math.min(1, size + 0.05))
end
return OVERLAY_UPDATED
end
----
[#nodegetworldposition]
==== nodeGetWorldPosition
[source,text]
----
x, y, z = nodeGetWorldPosition(node)
----
Where the node's origin is in world space, with every parent's transform
applied, as of the last rendered frame. Changes made this frame (yours, or
a body's) show after the next render, and a node created this frame reports
`0, 0, 0` until then. This is the position to feed `sceneProject` or a
distance check.
*Returns:* Three numbers.
*Since:* 3.00.
*See also:* <<sceneproject,sceneProject>>, <<nodelookat,nodeLookAt>>, <<nodegetposition,nodeGetPosition>>
.Example
[source,lua]
----
-- A name tag in the overlay above the duck, wherever the physics took it.
function onOverlayUpdate()
local dx, dy, dz = nodeGetWorldPosition(duck)
local sx, sy = sceneProject(dx, dy + 0.8, dz)
overlayClear()
if sx then
fontPrint(sx - 20, sy, "DUCK")
end
return OVERLAY_UPDATED
end
----
[#nodelookat]
==== nodeLookAt
[source,text]
----
nodeLookAt(node, x, y, z)
----
Rotates the node so its own -Z points at a world position, keeping its +Y
as close to world up as the direction allows. That is what cameras and
lights want, and it serves a turret or a head tracking a target as well.
Only the node's rotation changes; its position stays. If the target
coincides with the node's position nothing changes.
*Notes:* The world point is converted into the parent's space using the
parent's transform from the last rendered frame; a node whose parent moved
this same frame aims from where the parent was.
*Since:* 3.00.
*See also:* <<cameraset,cameraSet>>, <<lightnew,lightNew>>, <<nodesetrotation,nodeSetRotation>>
.Example
[source,lua]
----
local sun = lightNew(LIGHT_DIRECTIONAL)
nodeSetPosition(sun, -8, 3, 6)
nodeLookAt(sun, 0, 0, 0)
lightSetShadow(sun, true)
local camera = nodeNew()
nodeSetPosition(camera, 0, 2.2, 7)
nodeLookAt(camera, 0, 1.3, 0)
cameraSet(camera)
----
[#nodemove]
==== nodeMove
[source,text]
----
nodeMove(node, dx, dy, dz)
----
Moves the node along its own axes: the offset is turned by the node's
current rotation and added to its position, so `nodeMove(ship, 0, 0, -0.1)`
drives the ship forward wherever it is pointing and `nodeMove(ship, 0.1, 0,
0)` strafes it right. Use `nodeSetPosition` for an absolute place in the
parent's space.
*Since:* 3.00.
*See also:* <<nodesetposition,nodeSetPosition>>, <<noderotate,nodeRotate>>
.Example
[source,lua]
----
-- Tank controls: turn with left and right, drive with up and down.
function onOverlayUpdate()
if keyboardIsDown(SCANCODE_LEFT) then nodeRotate(tank, 0, 2, 0) end
if keyboardIsDown(SCANCODE_RIGHT) then nodeRotate(tank, 0, -2, 0) end
if keyboardIsDown(SCANCODE_UP) then nodeMove(tank, 0, 0, -0.08) end
if keyboardIsDown(SCANCODE_DOWN) then nodeMove(tank, 0, 0, 0.04) end
return OVERLAY_UPDATED
end
----
[#noderotate]
==== nodeRotate
[source,text]
----
nodeRotate(node, dx, dy, dz)
----
Turns the node by the given degrees about its own axes, on top of its
current rotation: `nodeRotate(wheel, 5, 0, 0)` every frame spins the wheel
on its own X however the axle is tilted. The three angles are combined the
way `nodeSetRotation` combines them (Y, then X, then Z) before being
applied. Positive angles turn counter-clockwise when looking down the axis
toward the origin.
*Since:* 3.00.
*See also:* <<nodesetrotation,nodeSetRotation>>, <<nodemove,nodeMove>>, <<nodesetquaternion,nodeSetQuaternion>>
.Example
[source,lua]
----
-- The logo turns half a degree a frame about its own up axis.
local logo = modelInstance(logoModel)
nodeSetPosition(logo, 0, -2.5, 0)
nodeSetRotation(logo, 0, -30, 0)
function onOverlayUpdate()
nodeRotate(logo, 0, 0.5, 0)
return OVERLAY_UPDATED
end
----
[#nodesetbillboard]
==== nodeSetBillboard
[source,text]
----
nodeSetBillboard(node, mode)
----
Turns the node to face the camera every frame. `BILLBOARD_ALL` faces it
squarely, taking all three axes from the camera; `BILLBOARD_Y` turns about
world up only, so the node stays upright (trees, health bars);
`BILLBOARD_NONE`, the default, does not turn at all. The node's world
position and scale are kept and its own rotation is ignored while a mode is
set. Any other value aborts the script. It works on meshes as well as
sprites and text, and turns to whichever camera is drawing: a view sees
the billboard face on too, while its shadow follows the window's camera.
*Since:* 3.00.
*See also:* <<nodesetsprite,nodeSetSprite>>, <<nodesettext,nodeSetText>>, <<cameraset,cameraSet>>
.Example
[source,lua]
----
-- A name over the fox facing the camera squarely, and a crate icon that
-- turns about its own up axis only.
local tag = nodeNew()
nodeSetParent(tag, fox)
nodeSetPosition(tag, 0, 110, 0)
nodeSetScale(tag, 50)
nodeSetText(tag, "FOX", 0.6)
nodeSetBillboard(tag, BILLBOARD_ALL)
nodeSetShadow(tag, false)
local icon = nodeNew()
nodeSetParent(icon, fox)
nodeSetPosition(icon, 0, 150, 0)
nodeSetScale(icon, 50)
nodeSetSprite(icon, spriteLoad("crate.png"), 0.5)
nodeSetBillboard(icon, BILLBOARD_Y)
----
[#nodesetmaterial]
==== nodeSetMaterial
[source,text]
----
nodeSetMaterial(node, material)
nodeSetMaterial(node, nil)
----
Puts a material on whatever mesh the node has, keeping the mesh. `nil`
removes it, leaving the default look: white, half rough, untextured. Use
it to restyle a model's part, to swap a highlight material in and out, or
to dress a mesh the engine made for you, such as a rope's tube from
`softNew`. A material handle that does not exist aborts the script.
*Since:* 3.00.
*See also:* <<nodesetmesh,nodeSetMesh>>, <<materialnew,materialNew>>, <<softnew,softNew>>
.Example
[source,lua]
----
-- The rope hangs from the crane; the engine makes its tube, we dress it.
local hemp = materialNew()
materialSetColor(hemp, 170, 140, 90)
materialSetRoughness(hemp, 1)
local rope = nodeNew()
nodeSetPosition(rope, 4, 4, -1)
softNew(rope, SOFT_ROPE, 6.5, 4, -1, 16, 0.04)
nodeSetMaterial(rope, hemp)
softSetMass(rope, 1.5)
softPin(rope, 4, 4, -1, crane)
----
[#nodesetmesh]
==== nodeSetMesh
[source,text]
----
nodeSetMesh(node, mesh)
nodeSetMesh(node, mesh, material)
----
Gives the node a mesh to draw, with a material or, when omitted, the
default look (white, half rough). Both arguments must be live handles; a
missing mesh or material aborts the script. Any node can draw, including a
node inside a model instance, and any number of nodes can share one mesh.
The node's morph weights are sized to the new mesh's targets and start at
`0`.
*Since:* 3.00.
*See also:* <<meshbox,meshBox>>, <<materialnew,materialNew>>, <<nodesetmaterial,nodeSetMaterial>>, <<nodesetvisible,nodeSetVisible>>
.Example
[source,lua]
----
-- A car body with four wheels, each a cylinder turned so its X is the axle.
local car = nodeNew()
nodeSetMesh(car, meshBox(1.8, 0.5, 4.0), paint)
nodeSetPosition(car, 0, 0.9, 0)
local tyreMesh = meshCylinder(0.35, 0.25, 16)
for _, p in ipairs({ { -0.95, -0.2, -1.4 }, { 0.95, -0.2, -1.4 }, { -0.95, -0.2, 1.4 }, { 0.95, -0.2, 1.4 } }) do
local wheel = nodeNew(car)
nodeSetPosition(wheel, p[1], p[2], p[3])
local tyre = nodeNew(wheel)
nodeSetMesh(tyre, tyreMesh, rubber)
nodeSetRotation(tyre, 0, 0, 90)
end
----
[#nodesetmorph]
==== nodeSetMorph
[source,text]
----
nodeSetMorph(node, nameOrIndex, weight)
----
Sets how much of one morph target (blend shape) the node's mesh shows,
by name or by number from `1`; weights are usually `0` to `1` and several
targets add up. The node is the one carrying the mesh, which for a model is
a child of the instance root (`nodeFind` by the mesh's name, or
`nodeGetChildren`). A target the mesh does not have aborts the script.
An animation with a weights channel writes the weights every frame while
it plays, overriding what you set.
*Notes:* Up to eight targets with non-zero weights apply per mesh in a
frame; with more, the eight strongest by absolute weight are used.
*Since:* 3.00.
*See also:* <<nodegetmorph,nodeGetMorph>>, <<nodegetmorphs,nodeGetMorphs>>, <<animationplay,animationPlay>>
.Example
[source,lua]
----
-- Lip sync from a table of mouth openings per disc frame.
local face = nodeFind("Head", hero)
function onOverlayUpdate()
nodeSetMorph(face, "mouthOpen", mouthByFrame[discGetFrame()] or 0)
return OVERLAY_UPDATED
end
----
[#nodesetname]
==== nodeSetName
[source,text]
----
nodeSetName(node, name)
----
Names the node so `nodeFind` can find it later, replacing any name it had
(a model node's glTF name included). Names need not be unique; `nodeFind`
returns the first match in depth-first order.
*Since:* 3.00.
*See also:* <<nodefind,nodeFind>>, <<nodegetname,nodeGetName>>
.Example
[source,lua]
----
-- Spawned crates are named so the level script can find them by slot.
for slot = 1, 6 do
local crate = nodeNew()
nodeSetMesh(crate, crateMesh, wood)
nodeSetPosition(crate, slot * 1.5 - 5, 0.5, -3)
nodeSetName(crate, "crate" .. slot)
end
nodeSetVisible(nodeFind("crate4"), false)
----
[#nodesetparent]
==== nodeSetParent
[source,text]
----
nodeSetParent(node, parent)
----
Moves the node, with its whole subtree, under another parent, becoming
that parent's last child. The node's local position, rotation, and scale
are kept as they are, so it now sits at that offset from the new parent and
moves with it; pass `0` to return it to the root. The root cannot be moved,
and a node cannot go under itself or one of its own descendants; either
aborts the script.
*Since:* 3.00.
*See also:* <<nodegetparent,nodeGetParent>>, <<nodenew,nodeNew>>, <<nodesetposition,nodeSetPosition>>
.Example
[source,lua]
----
-- Picking up a crate: it rides in the hand from now on, set down later.
function pickUp(crate)
nodeSetParent(crate, nodeFind("hand_R", hero))
nodeSetPosition(crate, 0, 0.3, 0)
nodeSetRotation(crate, 0, 0, 0)
end
function putDown(crate, x, y, z)
nodeSetParent(crate, 0)
nodeSetPosition(crate, x, y, z)
end
----
[#nodesetposition]
==== nodeSetPosition
[source,text]
----
nodeSetPosition(node, x, y, z)
----
Places the node at `x, y, z` in its parent's space; for a node under the
root that is world space. Children come along. A node with a dynamic body
on it is moved by the physics afterward; set its position before
`bodyNew`, or teleport it through the body calls.
*Since:* 3.00.
*See also:* <<nodemove,nodeMove>>, <<nodegetposition,nodeGetPosition>>, <<nodesetrotation,nodeSetRotation>>
.Example
[source,lua]
----
-- Crates dropped along a ridge, each six units above the ground there.
for i = 1, 10 do
local crate = nodeNew()
local x, z = -20 + i * 4, -12
nodeSetMesh(crate, meshBox(1, 1, 1), wood)
nodeSetPosition(crate, x, (terrainGetHeight(hills, x, z) or 0) + 6, z)
nodeSetRotation(crate, i * 17, i * 31, 0)
bodyNew(crate, BODY_DYNAMIC, SHAPE_BOX, 1, 1, 1)
end
----
[#nodesetquaternion]
==== nodeSetQuaternion
[source,text]
----
nodeSetQuaternion(node, x, y, z, w)
----
Sets the node's rotation relative to its parent as a quaternion, for
values that came from `nodeGetQuaternion`, a model, or your own math. The
quaternion is normalized on the way in, so it need not be unit length.
*Since:* 3.00.
*See also:* <<nodesetrotation,nodeSetRotation>>, <<nodegetquaternion,nodeGetQuaternion>>
.Example
[source,lua]
----
-- The ghost copies the hero's pose exactly, a beat behind.
local qx, qy, qz, qw = nodeGetQuaternion(hero)
local px, py, pz = nodeGetPosition(hero)
history[#history + 1] = { px, py, pz, qx, qy, qz, qw }
if #history > 30 then
local old = table.remove(history, 1)
nodeSetPosition(ghost, old[1], old[2], old[3])
nodeSetQuaternion(ghost, old[4], old[5], old[6], old[7])
end
----
[#nodesetrotation]
==== nodeSetRotation
[source,text]
----
nodeSetRotation(node, x, y, z)
----
Sets the node's rotation relative to its parent as Euler angles in
degrees, replacing whatever it had: the node is turned about its Y (yaw)
first, then tilted about its X (pitch), then rolled about its Z. Positive
angles turn counter-clockwise looking down the axis toward the origin, so
`nodeSetRotation(plane, 90, 0, 0)` tips a floor plane up to face +Z.
*Since:* 3.00.
*See also:* <<noderotate,nodeRotate>>, <<nodesetquaternion,nodeSetQuaternion>>, <<nodelookat,nodeLookAt>>
.Example
[source,lua]
----
-- A monitor screen standing on a stand, facing the camera at +Z.
local monitor = nodeNew()
nodeSetMesh(monitor, meshPlane(2.0, 1.5), screen)
nodeSetPosition(monitor, -3, 2.1, 1.06)
nodeSetRotation(monitor, 90, 0, 0)
-- A ramp tilted 25 degrees about Z.
local ramp = nodeNew()
nodeSetMesh(ramp, meshBox(6, 0.2, 3), asphalt)
nodeSetRotation(ramp, 0, 0, 25)
----
[#nodesetscale]
==== nodeSetScale
[source,text]
----
nodeSetScale(node, scale)
nodeSetScale(node, x, y, z)
----
Scales the node and everything under it, uniformly with one factor or per
axis with three. Loaded models are often in centimeters or meters; scale
the instance root once to fit your scene and place things under it in the
model's own units. Sprites and text on the node are scaled too, on top of
their `height`.
*Since:* 3.00.
*See also:* <<nodegetscale,nodeGetScale>>, <<modelinstance,modelInstance>>
.Example
[source,lua]
----
-- The fox is modeled in centimeters: a hundredth brings it to meters.
local fox = modelInstance(foxModel)
nodeSetParent(fox, player)
nodeSetScale(fox, 0.01)
nodeSetRotation(fox, 0, 90, 0)
-- A flattened sphere for a puddle.
local puddle = nodeNew()
nodeSetMesh(puddle, meshSphere(0.5, 24), water)
nodeSetScale(puddle, 1.5, 0.05, 1.2)
----
[#nodesetshadow]
==== nodeSetShadow
[source,text]
----
nodeSetShadow(node, casts)
----
Whether the node's mesh is drawn into shadow maps; `true` for every new
node. Turn it off for the mesh round a light (a bulb, a flame) so it does
not shadow its own light, and for anything that glows. The node still
draws and still receives shadows from others. It applies to the node only,
not its children.
*Since:* 3.00.
*See also:* <<lightsetshadow,lightSetShadow>>, <<nodesetvisible,nodeSetVisible>>, <<materialsetunlit,materialSetUnlit>>
.Example
[source,lua]
----
-- A bulb inside its shade: the glass casts nothing, the shade does.
local shade = nodeNew(stand)
nodeSetMesh(shade, meshCone(0.45, 0.5, 24), brass)
nodeSetPosition(shade, 0, 0.9, 0)
local bulbMesh = nodeNew(stand)
nodeSetMesh(bulbMesh, meshSphere(0.08, 12), glow)
nodeSetPosition(bulbMesh, 0, 0.8, 0)
nodeSetShadow(bulbMesh, false)
local bulb = lightNew(LIGHT_POINT)
nodeSetParent(bulb, bulbMesh)
lightSetShadow(bulb, true)
----
[#nodesetsprite]
==== nodeSetSprite
[source,text]
----
nodeSetSprite(node, sprite)
nodeSetSprite(node, sprite, height)
nodeSetSprite(node, sprite, height, lit)
nodeSetSprite(node, nil)
nodeSetSprite(node)
----
Shows a loaded sprite on the node: a quad `height` world units tall
(default `1`) and as wide as the picture's aspect makes it, blended by the
picture's alpha, drawn from both sides, and lit by the scene unless `lit`
is `false`. Every frame of an animated sprite is uploaded and the first
shows; `nodeSetSpriteFrame` picks another. The quad becomes the node's
mesh with a private material, replacing any mesh, sprite, or text it had;
`nil` or no second argument takes the picture off and leaves the node
empty. It aborts the script when the 3D renderer is unavailable.
*Parameters:*
* `sprite` -- a handle from `spriteLoad`, or `nil` to clear.
* `height` -- the quad's height in world units, default `1`; the node's scale multiplies it.
* `lit` -- `true` (default) shades the picture with the scene's lights and shadows; `false` shows it as is.
*Since:* 3.00.
*See also:* <<nodesetspriteframe,nodeSetSpriteFrame>>, <<nodesetbillboard,nodeSetBillboard>>, <<spriteload,spriteLoad>>, <<nodesettext,nodeSetText>>
.Example
[source,lua]
----
-- A crate icon floating over the fox, turning to face the player.
local icon = nodeNew()
nodeSetParent(icon, fox)
nodeSetPosition(icon, 0, 150, 0)
nodeSetScale(icon, 50)
nodeSetSprite(icon, spriteLoad("crate.png"), 0.5)
nodeSetBillboard(icon, BILLBOARD_Y)
-- An unlit glow that reads the same in shadow.
local flare = nodeNew(torchTip)
nodeSetSprite(flare, spriteLoad("flare.png"), 0.3, false)
nodeSetBillboard(flare, BILLBOARD_ALL)
----
[#nodesetspriteframe]
==== nodeSetSpriteFrame
[source,text]
----
nodeSetSpriteFrame(node, frame)
----
Which frame of the node's animated sprite shows, counted from `0`. The
node's sprite does not advance on its own; drive it from
`onOverlayUpdate`. A node without a sprite, or a frame it does not have,
aborts the script; text and a still sprite have only frame `0`.
*Since:* 3.00.
*See also:* <<nodesetsprite,nodeSetSprite>>, <<spriteload,spriteLoad>>
.Example
[source,lua]
----
-- A campfire sprite cycling its frames at ten a second.
local fire = nodeNew()
nodeSetPosition(fire, 2, 0.4, -1)
nodeSetSprite(fire, spriteLoad("campfire.gif"), 0.8, false)
nodeSetBillboard(fire, BILLBOARD_Y)
function onOverlayUpdate()
nodeSetSpriteFrame(fire, math.floor(os.clock() * 10) % fireFrames)
return OVERLAY_UPDATED
end
----
[#nodesettext]
==== nodeSetText
[source,text]
----
nodeSetText(node, text)
nodeSetText(node, text, height)
nodeSetText(node, nil)
nodeSetText(node)
----
Shows a line of text on the node, rendered in the selected font
(`fontSelect`) at the current quality (`fontQuality`) in the foreground
color (`colorForeground`), on a quad `height` world units tall (default
`1`) and as wide as the rendered text makes it. It is unlit, blended, and
drawn from both sides: name tags, signs, a score over a table. Like a
sprite it becomes the node's mesh, replacing any mesh, sprite, or text
there; call again to change the text, `nil` or no second argument to take
it off. With no font selected, or without the 3D renderer, it aborts the
script.
*Since:* 3.00.
*See also:* <<nodesetsprite,nodeSetSprite>>, <<nodesetbillboard,nodeSetBillboard>>, <<fontselect,fontSelect>>, <<colorforeground,colorForeground>>
.Example
[source,lua]
----
-- A fixed sign by the lamp, and a score that updates when it changes.
fontSelect(signFont)
colorForeground(255, 240, 200)
local sign = nodeNew()
nodeSetPosition(sign, 4.2, 1.2, -1.2)
nodeSetRotation(sign, 0, -30, 0)
nodeSetText(sign, "LAMP", 0.5)
function setScore(points)
nodeSetText(scoreboard, string.format("%06d", points), 0.4)
end
----
[#nodesetvisible]
==== nodeSetVisible
[source,text]
----
nodeSetVisible(node, visible)
----
Hides or shows the node and everything under it; a child's own flag still
applies when its parent is shown again. Hidden meshes are not drawn and
hidden lights stop shining. Use it to keep a marker or a spare prop ready
without deleting and rebuilding it.
*Since:* 3.00.
*See also:* <<nodesetmesh,nodeSetMesh>>, <<nodedelete,nodeDelete>>
.Example
[source,lua]
----
-- A marker that appears where the ball last hit the paddle.
local mark = nodeNew()
nodeSetMesh(mark, meshSphere(0.08, 12), markLook)
nodeSetVisible(mark, false)
function onCollision(a, b, x, y, z)
if (a == ball and b == paddle) or (a == paddle and b == ball) then
nodeSetPosition(mark, x, y, z)
nodeSetVisible(mark, true)
end
end
----
[#overlay]
=== Overlay
The overlay is a 32-bit RGBA surface composited over the video every frame; all 2D drawing lands on it. Its coordinates are the overlay resolution set by `overlaySetResolution`, which defaults to half the video's width and height, and are scaled to the window at display time. Draw from `onOverlayUpdate` (or from `singeMain` in the <<threaded,Threaded>> model) and return `OVERLAY_UPDATED` when something changed. The `overlay*` primitives draw one-pixel outlines in the current foreground color and silently clip whatever falls outside the surface. `overlayPrint` is the exception: it takes character-cell coordinates and copies the built-in console font as is, ignoring the color settings.
[#overlaybox]
==== overlayBox
[source,text]
----
overlayBox(x1, y1, x2, y2)
----
Draws the outline of the axis-aligned rectangle with opposite corners `(x1, y1)` and `(x2, y2)`, as four one-pixel lines in the foreground color. Both corners are inclusive and may be given in any order. There is no filled variant; fill an area with `overlayClear` on a background color, or with a stretched sprite.
*Since:* 2.00
*See also:* <<overlayline,overlayLine>>, <<colorforeground,colorForeground>>, <<overlayclear,overlayClear>>
.Example
[source,lua]
----
-- Frame the overlay in red and outline each platform block.
function onOverlayUpdate()
overlayClear()
colorForeground(255, 0, 0, 255)
overlayBox(0, 0, overlayGetWidth() - 1, overlayGetHeight() - 1)
colorForeground(90, 90, 110, 255)
for _, b in ipairs(blocks) do
overlayBox(b.x - b.w / 2, b.y - b.h / 2, b.x + b.w / 2, b.y + b.h / 2)
end
return OVERLAY_UPDATED
end
----
[#overlaycircle]
==== overlayCircle
[source,text]
----
overlayCircle(x, y, radius)
----
Draws a one-pixel circle outline in the foreground color, centered on `(x, y)`, using the midpoint algorithm. A radius of `0` or less draws nothing. Coordinates and radius are integers in overlay units; fractions are truncated.
*Parameters:*
* `x`, `y` -- center in overlay coordinates.
* `radius` -- radius in overlay units.
*Since:* 2.00
*See also:* <<overlayellipse,overlayEllipse>>, <<overlayplot,overlayPlot>>, <<colorforeground,colorForeground>>
.Example
[source,lua]
----
-- A gun-sight crosshair that turns red while the player is hit.
function onOverlayUpdate()
if hitTimer > 0 then
colorForeground(255, 0, 0, 255)
hitTimer = hitTimer - 1
else
colorForeground(255, 255, 255, 255)
end
overlayCircle(aimX, aimY, 8)
overlayLine(aimX - 12, aimY, aimX + 12, aimY)
overlayLine(aimX, aimY - 12, aimX, aimY + 12)
return OVERLAY_UPDATED
end
----
[#overlayclear]
==== overlayClear
[source,text]
----
overlayClear()
----
Fills the whole overlay with the current background color. The background starts as transparent black, so with no `colorBackground` call this erases the overlay back to invisible and the video shows through. A partly opaque background dims the video; a fully opaque one hides it. Most games call it first thing in `onOverlayUpdate`. Any arguments are ignored.
*Since:* 1.x
*See also:* <<colorbackground,colorBackground>>, <<onoverlayupdate,onOverlayUpdate>>
.Example
[source,lua]
----
-- Dim the paused video to half brightness behind the service menu.
function onOverlayUpdate()
colorBackground(0, 0, 0, 127)
overlayClear()
spriteDraw(serviceTitle, (overlayGetWidth() - spriteGetWidth(serviceTitle)) / 2, 25)
return OVERLAY_UPDATED
end
----
[#overlayellipse]
==== overlayEllipse
[source,text]
----
overlayEllipse(x1, y1, x2, y2)
----
Draws a one-pixel ellipse outline in the foreground color, inscribed in the axis-aligned rectangle with opposite corners `(x1, y1)` and `(x2, y2)`. The corners may be given in any order. A rectangle of equal width and height gives a circle, so this is also the way to draw a circle from its bounding box rather than its center.
*Since:* 2.00
*See also:* <<overlaycircle,overlayCircle>>, <<overlaybox,overlayBox>>
.Example
[source,lua]
----
-- Highlight the selected cabinet with an oval that fits its panel.
function onOverlayUpdate()
spriteDraw(cabinetSprite, CABINET_X, CABINET_Y)
if selected == CABINET then
colorForeground(255, 220, 0, 255)
overlayEllipse(CABINET_X - 6, CABINET_Y - 6, CABINET_X + CABINET_W + 6, CABINET_Y + CABINET_H + 6)
end
return OVERLAY_UPDATED
end
----
[#overlaygetheight]
==== overlayGetHeight
[source,text]
----
result = overlayGetHeight()
----
Returns the height of the overlay surface in overlay units: the value last given to `overlaySetResolution`, or half the video height before any call. Read it at startup, after setting the resolution, to lay out positions in terms of the actual drawing area. Any arguments are ignored.
*Returns:* integer height.
*Since:* 1.x
*See also:* <<overlaygetwidth,overlayGetWidth>>, <<overlaysetresolution,overlaySetResolution>>
.Example
[source,lua]
----
-- Anchor the text panel to the bottom margin whatever the overlay size is.
overlaySetResolution(discGetWidth(), discGetHeight())
TEXT_Y = 200
TEXT_H = overlayGetHeight() - MARGIN_Y - TEXT_Y
TEXT_LINE_LIMIT = math.floor(TEXT_H / LINE_HEIGHT)
----
[#overlaygetwidth]
==== overlayGetWidth
[source,text]
----
result = overlayGetWidth()
----
Returns the width of the overlay surface in overlay units: the value last given to `overlaySetResolution`, or half the video width before any call. Use it to right-align or center elements instead of hard-coding a width. Any arguments are ignored.
*Returns:* integer width.
*Since:* 1.x
*See also:* <<overlaygetheight,overlayGetHeight>>, <<overlaysetresolution,overlaySetResolution>>
.Example
[source,lua]
----
-- Right-align the marquee and center the title, independent of resolution.
function onOverlayUpdate()
local marqueeX = overlayGetWidth() - MARGIN_X - spriteGetWidth(marqueeSprite)
spriteDraw(marqueeSprite, marqueeX, MARGIN_Y)
spriteDraw(titleSprite, (overlayGetWidth() - spriteGetWidth(titleSprite)) / 2, 25)
return OVERLAY_UPDATED
end
----
[#overlayline]
==== overlayLine
[source,text]
----
overlayLine(x1, y1, x2, y2)
----
Draws a one-pixel Bresenham line in the foreground color from `(x1, y1)` to `(x2, y2)`, both endpoints included. Pixels outside the overlay are dropped, so endpoints may lie off the surface. Coordinates are truncated to integers.
*Since:* 2.00
*See also:* <<overlayplot,overlayPlot>>, <<overlaybox,overlayBox>>, <<colorforeground,colorForeground>>
.Example
[source,lua]
----
-- Draw the laser from the gun to the aim point, and a simple aim reticle.
function onOverlayUpdate()
overlayClear()
if firing then
colorForeground(255, 64, 64, 255)
overlayLine(GUN_X, GUN_Y, aimX, aimY)
end
colorForeground(255, 255, 255, 255)
overlayCircle(aimX, aimY, 6)
return OVERLAY_UPDATED
end
----
[#overlayplot]
==== overlayPlot
[source,text]
----
overlayPlot(x, y)
----
Sets one overlay pixel to the foreground color. A point outside the surface is ignored. Each call locks and unlocks the overlay, so plotting thousands of points per frame from Lua is slow; use a sprite for anything with structure and keep this for stars, sparks, and debugging marks.
*Since:* 2.00
*See also:* <<overlayline,overlayLine>>, <<colorforeground,colorForeground>>
.Example
[source,lua]
----
-- A slowly twinkling star field behind the title.
function onOverlayUpdate()
overlayClear()
for _, star in ipairs(stars) do
local shade = 128 + math.floor(127 * math.sin(frameCount * star.speed))
colorForeground(shade, shade, shade, 255)
overlayPlot(star.x, star.y)
end
frameCount = frameCount + 1
return OVERLAY_UPDATED
end
----
[#overlayprint]
==== overlayPrint
[source,text]
----
overlayPrint(column, row, text)
----
Prints a string with the engine's built-in fixed-width console font. Unlike every other drawing call, the position is a character cell: the engine multiplies `column` and `row` by the glyph width and height, so `(0, 0)` is the top-left cell and `(1, 0)` is one glyph to the right. Glyphs are copied from the font image as they are; the current foreground and background colors are not applied, and only the font's own background is transparent. Each byte of the string selects one of the font's 256 glyphs, so the text is treated as bytes, not UTF-8. Characters that would run past the right edge of the overlay are dropped, and text starting beyond the edge draws nothing.
*Parameters:*
* `column`, `row` -- character cell, `0` at the top left.
* `text` -- the string to print.
*Notes:* The console font is meant for diagnostics and service screens. Use `fontPrint` or `fontToSprite` for colored or proportional text.
*Since:* 1.x
*See also:* <<fontprint,fontPrint>>, <<overlayclear,overlayClear>>, <<colorbackground,colorBackground>>
.Example
[source,lua]
----
-- Diagnostics in the corner while a download completes.
function onOverlayUpdate()
colorBackground(0, 0, 0, 127)
overlayClear()
overlayPrint(1, 1, "FRAME " .. discGetFrame())
overlayPrint(1, 2, download.url .. " " .. tostring(download.finished))
if download.finished then
overlayPrint(1, 3, download.result)
end
return OVERLAY_UPDATED
end
----
[#overlaysetresolution]
==== overlaySetResolution
[source,text]
----
overlaySetResolution(width, height)
----
Replaces the overlay with a new surface of the given size and discards everything drawn so far. From then on every overlay coordinate, sprite position, and `overlayGetWidth` or `overlayGetHeight` result is in the new space, which the engine scales to the window at display time. Call it once during startup, before drawing; the default is half the video resolution in each dimension. A width or height of `0` or less ends the script with an error. Higher resolutions give finer placement and sharper text for more compositing work per frame; the disc's native size from `discGetWidth` and `discGetHeight` is the common choice.
*Parameters:*
* `width` -- new overlay width in pixels, greater than `0`.
* `height` -- new overlay height in pixels, greater than `0`.
*Since:* 2.00
*See also:* <<overlaygetwidth,overlayGetWidth>>, <<overlaygetheight,overlayGetHeight>>, <<discgetwidth,discGetWidth>>
.Example
[source,lua]
----
-- Match the disc, then lay the menu out in that coordinate space.
overlaySetResolution(discGetWidth(), discGetHeight())
MARGIN_X = 16
MARGIN_Y = 16
MARQUEE_W = 200
MARQUEE_X = overlayGetWidth() - MARGIN_X - MARQUEE_W
CABINET_H = overlayGetHeight() - MARGIN_Y - CABINET_Y
----
[#physicsref]
=== Physics
World-level calls: the ray query, gravity, the 2D mode, pausing and debug drawing. Physics is initialized with the engine, so there is nothing to enable before the first `bodyNew`; the world steps sixty times a second from the wall clock, after animations and before the scene renders. Positions and distances are in world units and gravity in units per second squared. 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)
----
Finds the first body along a ray from `(x, y, z)` in the direction `(dx, dy, dz)`, within `maxDistance` world units. The direction is normalized, so any length works, but a zero direction hits nothing. Triggers and players are hit like any other body; a player is reported by its node. With `sceneUnproject` supplying two points along the mouse, this is picking; straight down from a point, it is a height probe.
*Parameters:*
* `x`, `y`, `z` -- the ray's start in world space.
* `dx`, `dy`, `dz` -- its direction; only the direction matters.
* `maxDistance` -- how far to look; default `1000`, and `0` or less means the default.
*Returns:* The body's node, the hit point, and the surface normal there, or a single `nil` when nothing is hit.
*Since:* 3.00.
*See also:* <<sceneunproject,sceneUnproject>>, <<bodyapplyimpulse,bodyApplyImpulse>>, <<linedraw,lineDraw>>
.Example
[source,lua]
----
-- Drop the marker onto whatever level ground is under the cursor.
function onOverlayUpdate()
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, nx, ny, nz = physicsRaycast(ox, oy, oz, fx - ox, fy - oy, fz - oz, 60)
nodeSetVisible(marker, hit ~= nil and ny > 0.7)
if hit then
nodeSetPosition(marker, hx, hy + 0.05, hz)
end
return OVERLAY_NOT_UPDATED
end
----
[#physicsset2d]
==== physicsSet2D
[source,text]
----
physicsSet2D(planar)
----
With `true`, bodies made from now on move only in X and Y and turn only about Z: a 2D game's world, usually in overlay coordinates with gravity pointing +Y so things fall down the screen. Bodies already made keep their freedom, so call it before any `bodyNew`; `false` returns to full 3D for bodies made afterward. Players are different: while the mode is on, every player, whenever it was made, runs along X with its Z held at `0` and `playerMove` takes a single value. The 2D world works without a GPU, the Raspberry Pi 3 included.
*Since:* 3.00.
*See also:* <<physicssetgravity,physicsSetGravity>>, <<bodynew,bodyNew>>, <<playermove,playerMove>>
.Example
[source,lua]
----
-- A platformer world in overlay pixels: gravity down the screen, a floor and walls.
physicsSet2D(true)
physicsSetGravity(0, 900, 0)
local width, height = overlayGetWidth(), overlayGetHeight()
floor = nodeNew()
nodeSetPosition(floor, width / 2, height - 10, 0)
bodyNew(floor, BODY_STATIC, SHAPE_BOX, width, 20, 50)
leftWall = nodeNew()
nodeSetPosition(leftWall, -10, height / 2, 0)
bodyNew(leftWall, BODY_STATIC, SHAPE_BOX, 20, height, 50)
----
[#physicssetdebug]
==== physicsSetDebug
[source,text]
----
physicsSetDebug(mask)
----
Draws what the simulation holds as lines over the scene each frame, for as long as the mask is set: `DEBUG_SHAPES` for every moving body's shape in wireframe (soft bodies included, and enabled players in yellow), `DEBUG_CONSTRAINTS` for joints, ragdoll joints and vehicle constraints with their limits, `DEBUG_CONTACTS` for a red cross where bodies met this step, and `DEBUG_VELOCITIES` for a line along each moving body's velocity. Add the flags together; `DEBUG_ALL` is all four. Static bodies' shapes stay off unless `DEBUG_STATIC` is added, because a level mesh is a lot of lines. `DEBUG_NONE` turns it off, and nothing is drawn or costs while it is off.
*Notes:* A build of Singe made without Jolt's debug renderer prints a message to the console and draws nothing.
*Since:* 3.00.
*See also:* <<linedraw,lineDraw>>, <<navdraw,navDraw>>, <<physicssetenabled,physicsSetEnabled>>
.Example
[source,lua]
----
-- Toggle the physics overlay with a debug key; static shapes too, since the level is small.
function onInputPressed(switch)
if switch == SWITCH_BUTTON3 then
showPhysics = not showPhysics
if showPhysics then
physicsSetDebug(DEBUG_ALL + DEBUG_STATIC)
else
physicsSetDebug(DEBUG_NONE)
end
end
end
----
[#physicssetenabled]
==== physicsSetEnabled
[source,text]
----
physicsSetEnabled(enabled)
----
Pauses the simulation with `false` and resumes it with `true`. While paused no step runs: dynamic bodies hold still and keep their velocities, kinematic bodies stop following their nodes, players stop moving, and no `onCollision` or `onTrigger` fires. Time does not accumulate while paused, so resuming does not replay the missed steps. The game's own pause does the same without this call; use it for a menu, a cutscene or a freeze-frame that leaves the rest of the game running.
*Since:* 3.00.
*See also:* <<physicssetgravity,physicsSetGravity>>, <<bodyisresting,bodyIsResting>>
.Example
[source,lua]
----
-- Freeze the world while the inventory is open.
function openInventory()
inventoryOpen = true
physicsSetEnabled(false)
end
function closeInventory()
inventoryOpen = false
physicsSetEnabled(true)
end
----
[#physicssetgravity]
==== physicsSetGravity
[source,text]
----
physicsSetGravity(x, y, z)
----
Sets the acceleration every dynamic body and player feels, in world units per second squared along each world axis. The default is `0, -9.81, 0`, Earth's gravity in meters; `0, 0, 0` is space, and a 2D game in overlay pixels wants something like `0, 900, 0` since overlay Y runs down the screen. The direction opposite to gravity is "up" for players: the way they stand, jump and climb steps.
*Since:* 3.00.
*See also:* <<bodynew,bodyNew>>, <<physicsset2d,physicsSet2D>>, <<playersetgravityscale,playerSetGravityScale>>
.Example
[source,lua]
----
-- The moon level: a sixth of Earth's pull, and a longer jump to match.
function startLevel(name)
if name == "moon" then
physicsSetGravity(0, -1.62, 0)
jumpSpeed = 3.0
else
physicsSetGravity(0, -9.81, 0)
jumpSpeed = 4.5
end
end
----
[#player]
=== Player
A player is a character controller on a node: where a body goes where forces send it, a player goes where the script says, walking floors, climbing steps, sliding along walls, riding platforms and shoving light bodies aside. Players are addressed by their node, and a node carries a body or a player, not both. Sizes and speeds are in world units (meters in a 3D scene, overlay pixels in a 2D world) and the controller's tolerances scale with the shape, so a 30 pixel capsule behaves like a 0.3 meter one. The player drives its node's position each step and leaves the rotation to the script. Every call here raises an error when the node has no player. See <<physics,Physics>> in Game Development, Players.
[#playernew]
==== playerNew
[source,text]
----
playerNew(node, radius, height)
playerNew(node, SHAPE_CAPSULE, radius, height)
playerNew(node, SHAPE_CYLINDER, radius, height)
playerNew(node, SHAPE_SPHERE, radius, 0)
playerNew(node, SHAPE_BOX, width, height, depth)
playerNew(node, SHAPE_HULL)
----
Puts a player on the node, where the node is in the world right now: a capsule of that radius and total height, or any convex `bodyNew` shape with its sizes, scaled by the node's world scale. The shape stands on the node's origin, so the node is the feet. It starts with a mass of `70` kilograms, a push of `300` newtons, a slope limit of `45` degrees, a step height of `0.3` scaled by the shape, gravity scale `1` and the default swimming rates. Replaces any body or player the node had. Raises an error for `SHAPE_MESH` (a player needs a convex shape; use `SHAPE_HULL`), when physics is not available on this machine, or when the player cannot be made (a hull with too little mesh, or sixteen players already).
*Parameters:*
* `radius`, `height` -- a capsule, radius and total height along up, caps included.
* `SHAPE_*` -- a shape constant followed by its `bodyNew` sizes; `SHAPE_HULL` takes none and wraps the node's mesh and everything under it.
*Notes:* A call with exactly three arguments is always a capsule, so a sphere needs a fourth argument (any number) after its radius to be read as `SHAPE_SPHERE`.
*Since:* 3.00.
*See also:* <<playermove,playerMove>>, <<playerdelete,playerDelete>>, <<bodynew,bodyNew>>, <<nodesetparent,nodeSetParent>>
.Example
[source,lua]
----
-- The hero: a capsule with the Fox model hanging off the node, feet on the ground.
hero = nodeNew()
nodeSetPosition(hero, 0, 0.05, 0)
playerNew(hero, 0.3, 1.0)
playerSetStep(hero, 0.35)
fox = modelInstance(modelLoad(DIR .. "Models/Fox.glb"))
nodeSetParent(fox, hero)
nodeSetScale(fox, 0.01)
animationPlay(fox, "Walk", true)
-- A robot is a box; it turns square corners and cannot roll.
robot = nodeNew()
playerNew(robot, SHAPE_BOX, 0.8, 1.6, 0.6)
----
[#playerdelete]
==== playerDelete
[source,text]
----
playerDelete(node)
----
Removes the player; the node and whatever hangs under it stay where they are. Deleting the node removes its player too, and so does giving the node a body with `bodyNew`. Raises an error when the node has no player.
*Since:* 3.00.
*See also:* <<playernew,playerNew>>, <<playersetenabled,playerSetEnabled>>
.Example
[source,lua]
----
-- The hero climbs into the car: the controller goes, the model rides along.
function boardCar()
playerDelete(hero)
nodeSetParent(hero, car)
nodeSetPosition(hero, 0, 0.4, 0)
driving = true
end
----
[#playermove]
==== playerMove
[source,text]
----
playerMove(node, vx, vz)
playerMove(node, vx, vy, vz)
playerMove(node, vx)
----
Tells the player the velocity it wants this frame, in world units per second. On the ground or in the air the two horizontal values are used as given, on top of whatever platform it stands on; the vertical part comes from gravity, the ground and `playerJump`, and `vy` is ignored. When the player is swimming all three values steer it, and the water eases its velocity toward them at the drag from `playerSetSwim`, sinking at the sink speed when `vy` is `0`. In a 2D world a single `vx` runs along X and any `vz` is ignored. Call it every frame from `onOverlayUpdate`: the request is consumed by that frame's steps, and a frame without one stands still.
*Parameters:*
* `vx`, `vz` -- the horizontal velocity wanted, world units per second (2D: overlay pixels along X).
* `vy` -- the vertical velocity wanted while swimming; positive is up.
*Since:* 3.00.
*See also:* <<playerjump,playerJump>>, <<playersetvelocity,playerSetVelocity>>, <<playerisswimming,playerIsSwimming>>, <<physicsset2d,physicsSet2D>>
.Example
[source,lua]
----
-- Walk with the left stick, swim up while the button is held, face the way of travel.
function onOverlayUpdate()
local vx = controllerGetAxis(0, GAMEPAD_AXIS_LEFT_X) / 32767 * 3
local vz = controllerGetAxis(0, GAMEPAD_AXIS_LEFT_Y) / 32767 * 3
if playerIsSwimming(hero) then
playerMove(hero, vx, surfacing and 1.5 or 0, vz)
else
playerMove(hero, vx, vz)
end
nodeSetRotation(hero, 0, math.deg(math.atan(-vz, vx)), 0)
return OVERLAY_NOT_UPDATED
end
----
[#playerisswimming]
==== playerIsSwimming
[source,text]
----
swimming = playerIsSwimming(node)
----
Whether the player was in water at the last step: the middle of its shape below the surface of a `bodySetWater` volume. While swimming, `playerMove` steers in three axes, gravity gives way to the sink speed, and the water's current carries the player.
*Returns:* Boolean.
*Since:* 3.00.
*See also:* <<playersetswim,playerSetSwim>>, <<bodysetwater,bodySetWater>>, <<playermove,playerMove>>
.Example
[source,lua]
----
-- Switch the animation and the footstep sounds when the hero goes in.
function onOverlayUpdate()
local swimming = playerIsSwimming(hero)
if swimming ~= wasSwimming then
animationPlay(fox, swimming and "Swim" or "Walk", true)
wasSwimming = swimming
end
return OVERLAY_NOT_UPDATED
end
----
[#playerjump]
==== playerJump
[source,text]
----
jumped = playerJump(node, speed)
----
Asks the player to leave the ground at that upward speed, in world units per second, at the next step. It is granted only when there is walkable ground underfoot right now, so holding a button down does not bounce the player and a jump in mid-air does nothing. A negative speed is treated as `0`. The player keeps its horizontal velocity through the jump and `playerMove` still steers it in the air.
*Returns:* `true` when the jump was granted.
*Since:* 3.00.
*See also:* <<playerisonground,playerIsOnGround>>, <<playermove,playerMove>>, <<playersetgravityscale,playerSetGravityScale>>
.Example
[source,lua]
----
-- Jump on the action button, with a sound only when it actually happened.
function onInputPressed(switch)
if switch == SWITCH_BUTTON1 then
if playerJump(hero, 4.5) then
soundPlay(jumpSound)
end
end
end
----
[#playersetswim]
==== playerSetSwim
[source,text]
----
playerSetSwim(node, sinkSpeed, drag)
----
Sets how the player behaves in water: how fast it sinks, in world units per second, when `playerMove` asks for no vertical movement (default `0.3`), and how quickly the water pulls its velocity toward what `playerMove` asks, as a rate per second (default `2`, so most of the difference is gone within a second; `0` never changes velocity). Both are clamped to at least `0`. A heavy diver sinks fast with a high drag; a cork sinks not at all.
*Parameters:*
* `sinkSpeed` -- the drift downward with no vertical input, units per second.
* `drag` -- the rate the water pulls velocity toward the request, per second.
*Since:* 3.00.
*See also:* <<playerisswimming,playerIsSwimming>>, <<playermove,playerMove>>, <<bodysetwater,bodySetWater>>
.Example
[source,lua]
----
-- The suit of armor drags the hero down; take it off to float.
function wearArmor(on)
armored = on
if on then
playerSetSwim(hero, 1.2, 4)
else
playerSetSwim(hero, 0.3, 2)
end
end
----
[#playersetvelocity]
==== playerSetVelocity
[source,text]
----
playerSetVelocity(node, x, y, z)
----
Sets the player's whole velocity, in world units per second along each world axis, and drops any `playerMove` request made this frame. The vertical part is what lasts: an upward speed lifts the player off the ground and it falls back under gravity, which is a launch pad or a bounce. The horizontal part holds only until the next step, when it is replaced by what `playerMove` asks (nothing, when it is not called), because a player has no horizontal momentum of its own. A knock-back or a dash is therefore an upward kick here plus a `playerMove` velocity fed over the following frames.
*Since:* 3.00.
*See also:* <<playergetvelocity,playerGetVelocity>>, <<playermove,playerMove>>, <<playerjump,playerJump>>
.Example
[source,lua]
----
-- A hit from the ogre throws the hero up and, over the next frames, away from it.
function onCollision(a, b, x, y, z, speed)
if (a == hero and b == ogre) or (a == ogre and b == hero) then
local hx, hy, hz = nodeGetPosition(hero)
local ox, oy, oz = nodeGetPosition(ogre)
local dx, dz = hx - ox, hz - oz
local len = math.sqrt(dx * dx + dz * dz)
playerSetVelocity(hero, 0, 3, 0)
knockX, knockZ, knockFrames = dx / len * 6, dz / len * 6, 20
health = health - 10
end
end
----
[#playergetvelocity]
==== playerGetVelocity
[source,text]
----
x, y, z = playerGetVelocity(node)
----
The player's velocity after the last step, in world units per second along each world axis, including what the ground it rides on gave it. Read the vertical part to tell rising from falling, or the whole thing for a landing thud.
*Returns:* Three numbers.
*Since:* 3.00.
*See also:* <<playersetvelocity,playerSetVelocity>>, <<playerisonground,playerIsOnGround>>
.Example
[source,lua]
----
-- Fall damage from the speed the hero hit the ground at.
function onOverlayUpdate()
local _, vy = playerGetVelocity(hero)
local onGround = playerIsOnGround(hero)
if onGround and not wasOnGround and lastVy < -8 then
health = health - math.floor((-lastVy - 8) * 5)
end
wasOnGround = onGround
lastVy = vy
return OVERLAY_NOT_UPDATED
end
----
[#playerisonground]
==== playerIsOnGround
[source,text]
----
onGround = playerIsOnGround(node)
----
Whether the player is standing on walkable ground as of the last step: a surface no steeper than `playerSetSlope`. In the air or on a slope too steep to climb it is `false`. This is the same test `playerJump` makes.
*Returns:* Boolean.
*Since:* 3.00.
*See also:* <<playergetground,playerGetGround>>, <<playerjump,playerJump>>, <<playersetslope,playerSetSlope>>
.Example
[source,lua]
----
-- Coyote time: a jump is still allowed for a few frames after walking off an edge.
function onOverlayUpdate()
if playerIsOnGround(hero) then
framesSinceGround = 0
else
framesSinceGround = framesSinceGround + 1
end
if jumpPressed and framesSinceGround < 6 then
playerSetVelocity(hero, vx, 4.5, vz)
end
return OVERLAY_NOT_UPDATED
end
----
[#playergetground]
==== playerGetGround
[source,text]
----
ground, nx, ny, nz = playerGetGround(node)
----
What the player is standing on and the ground's surface normal there. The node is the body underfoot, walkable or not; it is `nil` in the air, and the normal is then `0, 1, 0`. Use the node to tell a moving platform, ice or a pressure plate from the floor, and the normal to lean a model into a slope.
*Returns:* The node stood on or `nil`, then the normal.
*Since:* 3.00.
*See also:* <<playerisonground,playerIsOnGround>>, <<bodysetfriction,bodySetFriction>>
.Example
[source,lua]
----
-- Footsteps sound different on the wooden bridge, and the plate opens the gate.
function onOverlayUpdate()
local ground = playerGetGround(hero)
if ground == bridge then
footstep = woodStep
elseif ground == plate then
openGate()
else
footstep = stoneStep
end
return OVERLAY_NOT_UPDATED
end
----
[#playersetstep]
==== playerSetStep
[source,text]
----
playerSetStep(node, height)
----
The tallest step the player climbs without jumping, in world units; anything higher is a wall it slides along. The default is `0.3` for a shape whose smallest half-size is `0.3` (a capsule of radius `0.3`), scaling with the shape, so a 30 pixel player in a 2D world starts at 30 pixels. The same distance is how far down the player sticks to the floor over the lip of a step, so a bigger step also keeps it glued down slopes. Negative values are clamped to `0`.
*Since:* 3.00.
*See also:* <<playersetslope,playerSetSlope>>, <<playernew,playerNew>>
.Example
[source,lua]
----
-- Stairs in this level rise 0.35 a tread; make sure the hero takes them.
playerNew(hero, 0.3, 1.0)
playerSetStep(hero, 0.4)
-- The 2D platformer's ledges are 26 pixels.
playerNew(runner, 16, 48)
playerSetStep(runner, 26)
----
[#playersetslope]
==== playerSetSlope
[source,text]
----
playerSetSlope(node, degrees)
----
The steepest slope the player walks up, in degrees from level; steeper ground does not count as standing, so the player slides down it and cannot jump from it. The default is `45`, and the value is clamped between `0` and `89`. A low value makes hills into obstacles, a high one lets the player climb almost anything short of a wall.
*Since:* 3.00.
*See also:* <<playersetstep,playerSetStep>>, <<playerisonground,playerIsOnGround>>
.Example
[source,lua]
----
-- The mountain goat climbs where the hero slides.
playerNew(goat, 0.25, 0.8)
playerSetSlope(goat, 70)
playerNew(hero, 0.3, 1.0)
playerSetSlope(hero, 40)
----
[#playersetmass]
==== playerSetMass
[source,text]
----
playerSetMass(node, kilograms)
----
The player's mass, used when it shoves dynamic bodies and as its weight on any it stands on. The default is `70` kilograms; values below `0.001` are raised to that. Mass has no effect on how the player falls or walks, only on the shoving, so pair it with `playerSetPush`.
*Since:* 3.00.
*See also:* <<playersetpush,playerSetPush>>, <<bodysetmass,bodySetMass>>
.Example
[source,lua]
----
-- The troll barges through what the hero has to walk around.
troll = nodeNew()
nodeSetPosition(troll, 6, 0, -3)
playerNew(troll, 0.6, 2.4)
playerSetMass(troll, 400)
playerSetPush(troll, 2000)
playerSetStep(troll, 0.8)
----
[#playersetpush]
==== playerSetPush
[source,text]
----
playerSetPush(node, strength)
----
How hard the player shoves dynamic bodies it walks into, in newtons. The default `300` moves crates and balls but not cabinets; `0` walks around everything as if it were static. Negative values are clamped to `0`. A body's own `bodySetMass` decides what a given strength can move.
*Since:* 3.00.
*See also:* <<playersetmass,playerSetMass>>, <<bodysetmass,bodySetMass>>
.Example
[source,lua]
----
-- The strength potion lets the hero move the boulder for ten seconds.
function drinkStrength()
playerSetPush(hero, 3000)
strengthUntil = frames + 600
end
function onOverlayUpdate()
if strengthUntil and frames > strengthUntil then
playerSetPush(hero, 300)
strengthUntil = nil
end
return OVERLAY_NOT_UPDATED
end
----
[#playersetgravityscale]
==== playerSetGravityScale
[source,text]
----
playerSetGravityScale(node, scale)
----
Multiplies the world's gravity for this player alone: `1` (the default) falls like everything else, `0` floats where it is (a ladder, a zero-gravity room, a flying mount), more falls faster for a heavy feel, and less makes a floaty jump. It does not change what `playerJump` does at takeoff, only how quickly the player comes back down. While swimming the water's sink speed takes gravity's place.
*Since:* 3.00.
*See also:* <<physicssetgravity,physicsSetGravity>>, <<playerjump,playerJump>>, <<playersetswim,playerSetSwim>>
.Example
[source,lua]
----
-- On the ladder the hero climbs with the stick instead of falling.
function onTrigger(trigger, other, entered)
if trigger == ladder and other == hero then
onLadder = entered
playerSetGravityScale(hero, entered and 0 or 1)
end
end
----
[#playersetposition]
==== playerSetPosition
[source,text]
----
playerSetPosition(node, x, y, z)
----
Teleports the player to a world position, touching nothing on the way, and stops it dead. The node moves with it and keeps its rotation. Use it for spawning, respawning and level transitions; moving the node with `nodeSetPosition` instead leaves the player where it was and the node is pulled back at the next step.
*Since:* 3.00.
*See also:* <<playersetvelocity,playerSetVelocity>>, <<nodesetposition,nodeSetPosition>>
.Example
[source,lua]
----
-- Back to the last checkpoint after a fall into the pit.
function onTrigger(trigger, other, entered)
if trigger == pit and other == hero and entered then
lives = lives - 1
playerSetPosition(hero, checkpointX, checkpointY, checkpointZ)
end
end
----
[#playersetenabled]
==== playerSetEnabled
[source,text]
----
playerSetEnabled(node, enabled)
----
With `false` the player leaves the simulation: its body is removed from the world, so it does not move, fall, drive its node or block anything, `onTrigger` reports it leaving every trigger it stood in, and it is left out of the debug drawing. `true` puts the body back and picks it up again from wherever the node is. Disable a player while a ragdoll, a cutscene or a vehicle owns the character.
*Since:* 3.00.
*See also:* <<playerdelete,playerDelete>>, <<ragdollnew,ragdollNew>>, <<bodysetenabled,bodySetEnabled>>
.Example
[source,lua]
----
-- A hard hit hands the hero's skeleton to the ragdoll.
function onCollision(a, b, x, y, z, speed)
if (a == hero or b == hero) and speed > 6 then
playerSetEnabled(hero, false)
ragdollActivate(fox)
end
end
----
[#ragdoll]
=== Ragdoll
A skinned model's skeleton handed to physics (see <<physics,Physics>>, Ragdolls). A ragdoll is addressed by the node given to `ragdollNew`, usually a model instance's root, and a node without a ragdoll terminates the script in every call after `ragdollNew`. Bones are named by their joint nodes, the names `nodeFind` sees, compared without regard to case; distances are world units, angles degrees, impulses newton seconds and motor strength newton meters. Up to eight ragdolls at once, of up to 48 bones each.
[#ragdollnew]
==== ragdollNew
[source,text]
----
ragdollNew(node)
----
Looks at the node and under it for the first skinned mesh and builds a ragdoll recipe from its skeleton: every joint that has a child joint becomes a bone, a capsule running from the joint toward its children, jointed to the nearest bone above it by a swing-twist joint limited to 45 degrees of swing and 30 of twist. Joints with no child joint, such as fingertips and the tip of a tail, ride along with their parent bone and cannot be named in the other calls. Nothing is simulated yet: the model keeps animating until `ragdollActivate`. Calling it again on the same node discards the old ragdoll first. Raises an error when physics is unavailable, when no skinned mesh with bones is under the node, or when eight ragdolls already exist.
*Since:* 3.00.
*See also:* <<ragdollactivate,ragdollActivate>>, <<ragdollsetjoint,ragdollSetJoint>>, <<modelinstance,modelInstance>>
.Example
[source,lua]
----
-- A guard who runs at the player; the ragdoll is ready before the tackle.
local guard = nodeNew()
nodeSetPosition(guard, -1, 0.05, -1)
playerNew(guard, 0.3, 0.9)
local guardFox = modelInstance(foxModel)
nodeSetParent(guardFox, guard)
nodeSetScale(guardFox, 0.01)
nodeSetRotation(guardFox, 0, 90, 0)
animationPlay(guardFox, "Run", true)
ragdollNew(guardFox) -- Recipe only; still running
----
[#ragdolldelete]
==== ragdollDelete
[source,text]
----
ragdollDelete(node)
----
Removes the ragdoll: its bodies and joints if it is active, then the recipe. The joint nodes stay where physics left them, so the model lies as it fell until an animation moves it, and the node is free for a new `ragdollNew`. A node without a ragdoll terminates the script.
*Since:* 3.00.
*See also:* <<ragdollnew,ragdollNew>>, <<ragdolldeactivate,ragdollDeactivate>>, <<nodedelete,nodeDelete>>
.Example
[source,lua]
----
function onOverlayUpdate()
for i = #fallen, 1, -1 do
local body = fallen[i]
body.restingFrames = ragdollIsResting(body.node) and body.restingFrames + 1 or 0
if body.restingFrames > 300 then
ragdollDelete(body.node) -- Still for five seconds: clean it up
nodeDelete(body.node)
table.remove(fallen, i)
end
end
return OVERLAY_NOT_UPDATED
end
----
[#ragdollsetjoint]
==== ragdollSetJoint
[source,text]
----
ragdollSetJoint(node, jointName, radius, swingDegrees, twistDegrees)
----
Tunes one bone's recipe. The values are read when the bodies are made, so call it before `ragdollActivate`; a ragdoll that is already active keeps its bodies until it is deactivated and activated again. The radius `0` asks for the automatic capsule, 0.22 of the bone's length and never thinner than `0.02` units. The swing is the half-angle of the cone the bone may move through relative to its parent, the twist how far it may turn about its own axis; both are clamped to `0` through `179`. Only bones can be named, not the leaf joints; the name is matched without regard to case, and a name with no bone raises an error.
*Parameters:*
* `node` -- the ragdoll's node.
* `jointName` -- the joint node's name, as in the model.
* `radius` -- the capsule's radius in world units, or `0` for automatic.
* `swingDegrees` -- how far the bone may swing from its parent; default `45`.
* `twistDegrees` -- how far it may twist about its own axis; default `30`.
*Since:* 3.00.
*See also:* <<ragdollnew,ragdollNew>>, <<ragdollactivate,ragdollActivate>>, <<nodefind,nodeFind>>
.Example
[source,lua]
----
ragdollNew(fox)
ragdollSetJoint(fox, "b_Neck_04", 0, 30, 20) -- A stiffer neck
ragdollSetJoint(fox, "b_Tail01_012", 0.03, 80, 60) -- A whippy tail
for _, leg in ipairs({ "b_LeftLeg01_015", "b_RightLeg01_019" }) do
ragdollSetJoint(fox, leg, 0, 60, 15) -- Hips swing wide, barely twist
end
ragdollActivate(fox)
----
[#ragdollactivate]
==== ragdollActivate
[source,text]
----
ragdollActivate(node)
----
Hands the skeleton to physics from its current pose: every animation layer on the node stops, a dynamic capsule is made for each bone where its joint stands right now, the swing-twist joints are made between them, and from then on each physics step drives the joint nodes from the bodies, so the skinned mesh follows. The bones collide with the world and with each other, except each bone with its parent. The pose at this moment is what `ragdollSetStrength` pulls toward. A ragdoll that is already active is left as it is. Raises an error when a bone's body cannot be made.
*Since:* 3.00.
*See also:* <<ragdolldeactivate,ragdollDeactivate>>, <<ragdollapplyimpulse,ragdollApplyImpulse>>, <<ragdollsetstrength,ragdollSetStrength>>
.Example
[source,lua]
----
local crashed = false
function onCollision(a, b, x, y, z, speed)
if not crashed and (a == guard or b == guard) and speed > 1.5 then
crashed = true
playerSetEnabled(guard, false) -- The controller lets go
ragdollActivate(guardFox) -- Physics takes the skeleton from here
ragdollApplyImpulse(guardFox, "b_Spine02_03", 40, 30, 0)
end
end
----
[#ragdolldeactivate]
==== ragdollDeactivate
[source,text]
----
ragdollDeactivate(node)
----
Removes the bodies and joints and hands the skeleton back. The joint nodes keep the pose the ragdoll left them in, and nothing moves them until an animation plays; an `animationPlay` with a fade blends up from that pose, so a downed character can get to its feet. The recipe stays, so `ragdollActivate` can take over again later from wherever the animation has moved the skeleton. Does nothing to a ragdoll that is not active.
*Since:* 3.00.
*See also:* <<ragdollactivate,ragdollActivate>>, <<animationplay,animationPlay>>
.Example
[source,lua]
----
-- Once the body has settled, the hero gets up over half a second.
function onOverlayUpdate()
if downed and ragdollIsResting(hero) then
downed = false
ragdollDeactivate(hero)
animationPlay(hero, "GetUp", false, 1, 0.5)
playerSetEnabled(heroController, true)
end
return OVERLAY_NOT_UPDATED
end
----
[#ragdollsetstrength]
==== ragdollSetStrength
[source,text]
----
ragdollSetStrength(node, strength)
----
Powers every joint with a motor pulling toward the pose the ragdoll was activated in, limited to that many newton meters; `0` (default) turns the motors off and the body goes limp. The motors are springs at 4 Hz, critically damped. It can be set before activation or while the ragdoll is moving, and takes effect at the next step. It does not stand a body up, since nothing pushes off the floor: a few tens of newton meters turn a fall into a stagger and let a downed character pull its limbs in.
*Since:* 3.00.
*See also:* <<ragdollactivate,ragdollActivate>>, <<ragdollisresting,ragdollIsResting>>
.Example
[source,lua]
----
-- Knocked down limp; a moment later the fighter tenses and curls up.
ragdollActivate(fighter)
ragdollSetStrength(fighter, 0)
downFrames = 0
function onOverlayUpdate()
downFrames = downFrames + 1
if downFrames == 60 then
ragdollSetStrength(fighter, 60)
end
return OVERLAY_NOT_UPDATED
end
----
[#ragdollapplyimpulse]
==== ragdollApplyImpulse
[source,text]
----
hit = ragdollApplyImpulse(node, jointName, x, y, z)
----
Adds an impulse in newton seconds, along the world axes, to the body of the bone that starts at the named joint: the punch, the shot, the explosion. The ragdoll must be active, so call `ragdollActivate` first and then shove. Returns `false`, and does nothing, when the ragdoll is not active or when no bone has that name (leaf joints are not bones); the name is matched without regard to case.
*Parameters:*
* `node` -- the ragdoll's node.
* `jointName` -- the joint node's name, as in the model.
* `x`, `y`, `z` -- the impulse in newton seconds, world axes.
*Returns:* True when the bone was found and the ragdoll was active.
*Since:* 3.00.
*See also:* <<ragdollactivate,ragdollActivate>>, <<bodyapplyimpulse,bodyApplyImpulse>>, <<nodefind,nodeFind>>
.Example
[source,lua]
----
-- A hit from the shooter's position: the impulse points away from the gun.
function shoot(target, gunX, gunY, gunZ, boneName)
local tx, ty, tz = nodeGetWorldPosition(target)
local dx = tx - gunX
local dz = tz - gunZ
local length = math.sqrt(dx * dx + dz * dz)
if not ragdollIsActive(target) then
ragdollActivate(target)
end
ragdollApplyImpulse(target, boneName, dx / length * 30, 10, dz / length * 30)
end
----
[#ragdollisactive]
==== ragdollIsActive
[source,text]
----
active = ragdollIsActive(node)
----
Whether physics is driving the skeleton right now: `true` from `ragdollActivate` until `ragdollDeactivate` or `ragdollDelete`. A node without a ragdoll terminates the script.
*Returns:* True or false.
*Since:* 3.00.
*See also:* <<ragdollactivate,ragdollActivate>>, <<ragdollisresting,ragdollIsResting>>
.Example
[source,lua]
----
-- The health bar hangs over the model only while it is on its feet.
function onOverlayUpdate()
overlayClear()
for _, enemy in ipairs(enemies) do
if not ragdollIsActive(enemy.node) then
local x, y, z = nodeGetWorldPosition(enemy.node)
local sx, sy = sceneProject(x, y + 1.2, z)
drawHealthBar(sx, sy, enemy.health)
end
end
return OVERLAY_UPDATED
end
----
[#ragdollisresting]
==== ragdollIsResting
[source,text]
----
resting = ragdollIsResting(node)
----
Whether every bone's body has gone to sleep, which physics does once a body has been still for a moment. `false` while the ragdoll is not active, so it cannot be used to tell an idle model from a settled one; check `ragdollIsActive` too.
*Returns:* True or false.
*Since:* 3.00.
*See also:* <<ragdollisactive,ragdollIsActive>>, <<ragdolldeactivate,ragdollDeactivate>>
.Example
[source,lua]
----
-- The round ends once the loser has stopped moving.
function onOverlayUpdate()
if roundOver and ragdollIsResting(loser) then
restingFrames = restingFrames + 1
if restingFrames == 90 then
showResults()
end
end
return OVERLAY_NOT_UPDATED
end
----
[#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. The `sceneSet*` calls set the whole layer's look: ambient light, sky, fog, exposure and tone curve, bloom, antialiasing and shadow quality; colors are integers from `0` to `255` and distances are world units. `sceneProject` and `sceneUnproject` bridge world space and overlay coordinates, and `sceneGetStats` reports last frame's work. See <<scenes3d,3D Scenes>>.
[#sceneenable]
==== sceneEnable
[source,text]
----
sceneEnable(enabled)
----
Turns the 3D layer on or off. Nodes, meshes and materials can be built before it is on and survive it being turned off; only drawing stops. Raises an error when turning it on with no usable GPU (see <<scenes3d,3D Scenes>>), so wrap the first call in `pcall` if the game has a 2D fallback. The display refreshes on the next frame either way.
*Since:* 3.00.
*See also:* <<scenesetbackground,sceneSetBackground>>, <<scenesetantialias,sceneSetAntialias>>
.Example
[source,lua]
----
-- Fall back to the plain overlay when 3D is unavailable.
has3d = pcall(sceneEnable, true)
if has3d then
buildArena()
else
debugPrint("No 3D here; using the 2D attract mode.")
end
function onPause()
sceneEnable(false)
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, and the size the scene is rendered at. Change it with `overlaySetResolution`. Before the layer has been sized both values are `0`.
*Returns:* Two integers, the width and height in overlay coordinates.
*Since:* 3.00.
*See also:* <<overlaysetresolution,overlaySetResolution>>, <<sceneunproject,sceneUnproject>>
.Example
[source,lua]
----
-- A crosshair at the center of the view, and the thing under it.
function onOverlayUpdate()
local width, height = sceneGetSize()
local cx = width / 2
local cy = height / 2
local ox, oy, oz = sceneUnproject(cx, cy, 0)
local fx, fy, fz = sceneUnproject(cx, cy, 50)
target = physicsRaycast(ox, oy, oz, fx - ox, fy - oy, fz - oz)
fontPrint(cx - 4, cy - 8, "+")
return OVERLAY_UPDATED
end
----
[#scenegetstats]
==== sceneGetStats
[source,text]
----
total, drawn, calls, textureKB = sceneGetStats()
----
Last frame's numbers from the window's camera: meshes collected for drawing, how many of them were inside the camera's view, the draw calls they became after instancing, and the texture memory the scene holds in kilobytes. For tuning: a scene with hundreds of `drawn` but a handful of `calls` is sharing its meshes well, and a `textureKB` that will not fit a Raspberry Pi's memory wants KTX2 textures. Rendered views do not count.
*Returns:* Four integers.
*Since:* 3.00.
*See also:* <<sceneenable,sceneEnable>>, <<modelinstance,modelInstance>>, <<materialsettexture,materialSetTexture>>
.Example
[source,lua]
----
-- A debug readout in the corner while tuning the level.
function onOverlayUpdate()
if showStats then
local total, drawn, calls, textureKB = sceneGetStats()
fontPrint(10, 10, string.format("%d meshes, %d in view, %d calls, %d MB textures", total, drawn, calls, math.floor(textureKB / 1024)))
end
return OVERLAY_UPDATED
end
----
[#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, for sorting labels or fading them with distance. `inFront` is `false` when the point is behind the camera, and `x` and `y` are then meaningless; before the first frame has rendered there is nothing to project against, and every value comes back `0` with `inFront` `false`. Points in front but outside the view come back with coordinates off the overlay's edges, so check them against `sceneGetSize` before drawing. The usual use is a name tag or health bar drawn with `fontPrint` or `spriteDraw` above a 3D object.
*Parameters:*
* `wx, wy, wz` -- the world point, in world units.
*Returns:* Two numbers, a number, and a boolean.
*Since:* 3.00.
*See also:* <<sceneunproject,sceneUnproject>>, <<nodegetworldposition,nodeGetWorldPosition>>, <<scenegetsize,sceneGetSize>>
.Example
[source,lua]
----
-- A name and health bar floating above each character.
function onOverlayUpdate()
for _, enemy in ipairs(enemies) do
local x, y, depth, visible = sceneProject(nodeGetWorldPosition(enemy.node))
if visible then
fontPrint(x - 30, y - 80, enemy.name)
overlayBox(x - 30, y - 60, x - 30 + enemy.health * 0.6, y - 54)
end
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 with `physicsRaycast`; one places an object "where the player clicked". Before the first frame has been drawn it returns the origin.
*Parameters:*
* `sx, sy` -- the overlay pixel, in overlay coordinates.
* `distance` -- how far along the ray, in world units; `0` is the near plane.
*Returns:* Three numbers, the world point.
*Since:* 3.00.
*See also:* <<sceneproject,sceneProject>>, <<mousegetposition,mouseGetPosition>>, <<physicsraycast,physicsRaycast>>
.Example
[source,lua]
----
-- Drop a marker on whatever the player clicked, or 8 units out if nothing.
function onMouseDown(button)
local mx, my = mouseGetPosition(0)
local ox, oy, oz = sceneUnproject(mx, my, 0)
local fx, fy, fz = sceneUnproject(mx, my, 100)
local hit, hx, hy, hz = physicsRaycast(ox, oy, oz, fx - ox, fy - oy, fz - oz)
if hit then
nodeSetPosition(marker, hx, hy, hz)
else
nodeSetPosition(marker, sceneUnproject(mx, my, 8))
end
end
----
[#scenesetambient]
==== sceneSetAmbient
[source,text]
----
sceneSetAmbient(r, g, b)
----
Light from everywhere, `0` to `255` per channel, treated as sRGB; the default is a dim gray of `26, 26, 26`. It lifts the sides that no light reaches so they are not black, and `materialSetOcclusionMap` darkens it in crevices. When a sky is set and `sceneSetEnvironment` is on, the sky's light replaces this value.
*Since:* 3.00.
*See also:* <<lightnew,lightNew>>, <<scenesetenvironment,sceneSetEnvironment>>, <<scenesetsky,sceneSetSky>>
.Example
[source,lua]
----
-- A moonlit yard: a faint blue everywhere, one cold directional moon.
sceneSetAmbient(20, 25, 45)
moon = lightNew(LIGHT_DIRECTIONAL)
nodeSetPosition(moon, -5, 10, 3)
nodeLookAt(moon, 0, 0, 0)
lightSetColor(moon, 170, 190, 255)
lightSetIntensity(moon, 0.4)
----
[#scenesetantialias]
==== sceneSetAntialias
[source,text]
----
sceneSetAntialias(antialias)
----
Turns 4x multisampling on or off; it is on by default wherever the GPU offers it for the layer's formats, and silently absent where it does not. Turning it off buys speed on a Raspberry Pi. The render targets are rebuilt at once, so call it during setup rather than every frame.
*Since:* 3.00.
*See also:* <<sceneenable,sceneEnable>>, <<scenesetshadowsize,sceneSetShadowSize>>
.Example
[source,lua]
----
-- A quality setting from the game's options screen.
function applyQuality(level)
if level == "low" then
sceneSetAntialias(false)
sceneSetShadowSize(512)
sceneSetShadowCascades(2)
else
sceneSetAntialias(true)
sceneSetShadowSize(2048)
sceneSetShadowCascades(3)
end
end
----
[#scenesetbackground]
==== sceneSetBackground
[source,text]
----
sceneSetBackground(r, g, b)
sceneSetBackground(r, g, b, a)
----
The color the layer clears to each frame, `0` to `255` per channel. `a` is `255` when omitted, an opaque backdrop; below `255` lets the video show through, and `0` (the usual choice for a game with a disc) makes the background fully transparent. Until it is called the layer clears to black and transparent. A sky from `sceneSetSky` is drawn over the background, so the video no longer shows through while one is set.
*Parameters:*
* `r, g, b` -- the clear color.
* `a` -- the clear alpha; `255` when omitted.
*Since:* 3.00.
*See also:* <<sceneenable,sceneEnable>>, <<scenesetsky,sceneSetSky>>, <<scenesetfog,sceneSetFog>>
.Example
[source,lua]
----
-- The disc shows behind the 3D cockpit; a solid night sky for the menu.
function showCockpit()
sceneSetBackground(0, 0, 0, 0)
discPlay()
end
function showMenu()
discPause()
sceneSetBackground(10, 12, 30)
end
----
[#scenesetbloom]
==== sceneSetBloom
[source,text]
----
sceneSetBloom(threshold, strength)
----
A glow around everything brighter than `threshold`, taken from the scene's linear light before exposure and the tone curve (where `1` is white), and added back at `strength`. The defaults are a threshold of `1` and a strength of `0`, no bloom; `0.2` to `0.4` is subtle, `1` is a haze. The glow is blurred over a chain of half-size passes, so it costs little, and only the window's camera gets it: rendered views skip it. Negative values are clamped to `0`.
*Parameters:*
* `threshold` -- the linear brightness above which a pixel glows; `1` is white before the tone curve.
* `strength` -- how much of the glow is added back; `0` for none.
*Since:* 3.00.
*See also:* <<scenesetexposure,sceneSetExposure>>, <<materialsetemissive,materialSetEmissive>>, <<scenesettonemap,sceneSetTonemap>>
.Example
[source,lua]
----
-- Neon and headlights glow; the pavement does not.
sceneSetBloom(1.0, 0.35)
neonLook = materialNew()
materialSetColor(neonLook, 255, 40, 120)
materialSetEmissive(neonLook, 255, 40, 120)
nodeSetMesh(sign, meshBox(2, 0.3, 0.05), neonLook)
----
[#scenesetenvironment]
==== sceneSetEnvironment
[source,text]
----
sceneSetEnvironment(lit)
----
Whether the sky set by `sceneSetSky` lights the scene: its diffuse light from every direction in place of `sceneSetAmbient`, and its reflections on metals and glossy surfaces, sharp on a mirror and blurred as roughness rises. On by default, so a sky lights the scene as soon as it is set; `false` keeps the sky as a backdrop only and the flat ambient returns. It has no effect without a sky.
*Since:* 3.00.
*See also:* <<scenesetsky,sceneSetSky>>, <<scenesetambient,sceneSetAmbient>>, <<scenesetskyintensity,sceneSetSkyIntensity>>
.Example
[source,lua]
----
-- A painted backdrop that must not tint the chrome.
sceneSetSky(DIR .. "Skies/matteBackdrop.png")
sceneSetEnvironment(false)
sceneSetAmbient(40, 40, 45)
----
[#scenesetexposure]
==== sceneSetExposure
[source,text]
----
sceneSetExposure(stops)
----
Brightens or darkens the whole scene in photographic stops, `-10` to `10` (values outside are clamped): `+1` doubles the light, `-1` halves it. The default is `0`. It is applied after bloom and before the tone curve, so a bright scene at `+2` compresses toward white rather than clipping, and a cave and a beach can share the same lights. Animate it for a slow adjustment when walking from indoors to daylight.
*Since:* 3.00.
*See also:* <<scenesettonemap,sceneSetTonemap>>, <<lightsetintensity,lightSetIntensity>>, <<scenesetskyintensity,sceneSetSkyIntensity>>
.Example
[source,lua]
----
-- Eyes adjusting: ease the exposure toward the area's target each frame.
targetStops = 0
currentStops = 0
function onEnterArea(area)
targetStops = area.indoors and 1.5 or -0.5
end
function onOverlayUpdate()
currentStops = currentStops + (targetStops - currentStops) * 0.05
sceneSetExposure(currentStops)
return OVERLAY_UPDATED
end
----
[#scenesetfog]
==== sceneSetFog
[source,text]
----
sceneSetFog(r, g, b, near, far)
sceneSetFog()
----
Distance fog: clear up to `near` units from the camera, fully the fog color at `far` and beyond, on meshes (unlit ones too) and particles alike; additive particles fade out instead of going gray. With no arguments, or a `far` no greater than `near`, there is no fog. The sky is not fogged, so pick a color close to its horizon, or to the background when there is no sky. Any other number of arguments raises an error.
*Parameters:*
* `r, g, b` -- the fog color, `0` to `255`.
* `near` -- the distance where fog begins, in world units from the camera.
* `far` -- the distance where nothing but fog remains.
*Since:* 3.00.
*See also:* <<scenesetsky,sceneSetSky>>, <<scenesetbackground,sceneSetBackground>>, <<camerasetperspective,cameraSetPerspective>>
.Example
[source,lua]
----
-- Morning haze over the hills that burns off as the level goes on.
sceneSetSky(DIR .. "Skies/dawn.hdr")
sceneSetFog(190, 200, 215, 40, 160)
function onOverlayUpdate()
if levelTime > 60 then
sceneSetFog()
end
return OVERLAY_UPDATED
end
----
[#scenesetshadowcascades]
==== sceneSetShadowCascades
[source,text]
----
sceneSetShadowCascades(count)
----
How many maps a directional light's shadow is split into along the view, `1` to `4` (values outside are clamped; the default is `3`). Each cascade covers a slice of the camera's view out to `sceneSetShadowDistance`, the nearest slice the smallest, so shadows near the camera are sharp however large the level. `1` fits one map to the whole scene, which is right for small scenes, and is always used with an orthographic camera. Each cascade is one more shadow pass for every casting directional light; `2` is a fair choice on a Raspberry Pi.
*Since:* 3.00.
*See also:* <<scenesetshadowdistance,sceneSetShadowDistance>>, <<scenesetshadowsize,sceneSetShadowSize>>, <<lightsetshadow,lightSetShadow>>
.Example
[source,lua]
----
-- A large outdoor level: sharp shadows near the player, reaching far.
sun = lightNew(LIGHT_DIRECTIONAL)
nodeSetPosition(sun, 30, 40, 20)
nodeLookAt(sun, 0, 0, 0)
lightSetShadow(sun, true)
sceneSetShadowCascades(4)
sceneSetShadowDistance(120)
sceneSetShadowSize(2048)
----
[#scenesetshadowdistance]
==== sceneSetShadowDistance
[source,text]
----
sceneSetShadowDistance(distance)
----
How far from the camera cascaded shadows reach, in world units; the default is `60` and values below `1` are clamped to `1`. Beyond it a directional light casts no shadow. Shorter distances make every cascade sharper, since the same maps cover less ground, so set it to about as far as the player can make out a shadow. It only matters with more than one cascade under a perspective camera.
*Since:* 3.00.
*See also:* <<scenesetshadowcascades,sceneSetShadowCascades>>, <<scenesetshadowsize,sceneSetShadowSize>>
.Example
[source,lua]
----
-- Shadows only need to reach the far wall of the arena.
sceneSetShadowCascades(2)
sceneSetShadowDistance(35)
sun = lightNew(LIGHT_DIRECTIONAL)
nodeSetPosition(sun, 10, 20, 8)
nodeLookAt(sun, 0, 0, 0)
lightSetShadow(sun, true)
----
[#scenesetshadowsize]
==== sceneSetShadowSize
[source,text]
----
sceneSetShadowSize(size)
----
Texels per side of every shadow map, cascade and cube face, `256` to `4096` (values outside are clamped; the default is `1024`). Larger is sharper and slower, in memory as well as time, since every casting light has its own maps at this size. `2048` suits a desktop, `512` a Raspberry Pi. The maps are rebuilt on the next frame.
*Since:* 3.00.
*See also:* <<lightsetshadow,lightSetShadow>>, <<scenesetshadowcascades,sceneSetShadowCascades>>, <<scenesetantialias,sceneSetAntialias>>
.Example
[source,lua]
----
-- Size the shadow maps to the machine.
if singeGetPlatform() == "raspberrypi" then
sceneSetShadowSize(512)
else
sceneSetShadowSize(2048)
end
----
[#scenesetsky]
==== sceneSetSky
[source,text]
----
sceneSetSky(file)
sceneSetSky(nil)
sceneSetSky()
----
Wraps the scene in an equirectangular panorama: a Radiance `.hdr` (as HDRI sites ship, keeping its range), or a PNG, JPEG or other image SDL_image reads, decoded from sRGB. The name is game relative like `spriteLoad`. The sky is drawn behind everything in place of the background color (so the video no longer shows through) and lights the scene unless `sceneSetEnvironment(false)`. Its center faces -Z and its top is up. It is resampled into a cube of up to 1024 texels a face, so a 2048 by 1024 image is plenty and larger ones take a moment to prepare on a Raspberry Pi. With no argument, or `nil`, the sky is removed. Raises an error when the file cannot be read or 3D is unavailable.
*Since:* 3.00.
*See also:* <<scenesetskyintensity,sceneSetSkyIntensity>>, <<scenesetenvironment,sceneSetEnvironment>>, <<scenesetfog,sceneSetFog>>, <<scenesetbackground,sceneSetBackground>>
.Example
[source,lua]
----
-- An overcast sky for the docks level, with fog matched to its horizon.
sceneEnable(true)
sceneSetSky(DIR .. "Skies/overcastHarbor.hdr")
sceneSetSkyIntensity(0.8)
sceneSetFog(190, 200, 215, 25, 90)
function onLevelEnd()
sceneSetSky()
sceneSetBackground(0, 0, 0, 0)
end
----
[#scenesetskyintensity]
==== sceneSetSkyIntensity
[source,text]
----
sceneSetSkyIntensity(scale)
----
Brightness of the sky and of the light it casts, `1` as loaded; negative values are clamped to `0`. Useful to dim a PNG sky that was too bright, to make an `.hdr` sky the scene's main light, or to fade the sky in and out. It multiplies the sky itself, unlike `sceneSetExposure`, which scales everything.
*Since:* 3.00.
*See also:* <<scenesetsky,sceneSetSky>>, <<scenesetexposure,sceneSetExposure>>, <<scenesetenvironment,sceneSetEnvironment>>
.Example
[source,lua]
----
-- Dusk: the sky and its light dim over ten seconds.
sceneSetSky(DIR .. "Skies/sunset.hdr")
duskStart = singeGetTicks()
function onOverlayUpdate()
local t = math.min((singeGetTicks() - duskStart) / 10000, 1)
sceneSetSkyIntensity(1 - 0.8 * t)
return OVERLAY_UPDATED
end
----
[#scenesettonemap]
==== sceneSetTonemap
[source,text]
----
sceneSetTonemap(curve)
----
The curve that maps the scene's linear light to the screen, applied after exposure. `TONEMAP_NEUTRAL` (the default) keeps colors faithful and compresses only the brightest light; `TONEMAP_ACES` is the filmic curve with more contrast and a warmer roll-off; `TONEMAP_NONE` clips at white. Video materials, unlit surfaces and the sky pass through the same curve, so a game whose disc must look exactly as filmed wants `TONEMAP_NONE` with an exposure of `0`. Any other value raises an error.
*Since:* 3.00.
*See also:* <<scenesetexposure,sceneSetExposure>>, <<scenesetbloom,sceneSetBloom>>
.Example
[source,lua]
----
-- A filmic look for the cutscene, faithful color for play.
function onCutsceneStart()
sceneSetTonemap(TONEMAP_ACES)
sceneSetExposure(0.5)
end
function onCutsceneEnd()
sceneSetTonemap(TONEMAP_NEUTRAL)
sceneSetExposure(0)
end
----
[#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.
Both calls take a table shaped like a `GAMES[]` entry in `games.dat` and queue it for the engine's script runner. The new configuration starts from the running one, so command line options such as the data directory and tracing carry over, but the disc, video and container never do: each entry brings its own. The current script ends at the end of the frame, `onShutdown` fires, and the queued script starts with a fresh Lua state (see <<enginecallbacks,Engine Callbacks>>). The new script's data directory follows the same rule as a command line launch (see `singeGetDataPath`); when it cannot be created the calling script is aborted.
[#scriptexecute]
==== scriptExecute
[source,text]
----
scriptExecute(config)
----
Replaces the running script with the one the table describes. The current script stops at the end of this frame and `onShutdown` runs; control never comes back to it, and when the new script quits the engine moves on to whatever else is queued or exits. String keys the engine knows are `SCRIPT`, `CONTAINER`, `VIDEO`, `STRETCH`, `NO_MOUSE`, `RESOLUTION_X`, `RESOLUTION_Y`, `SINDEN_GUN`, `AUDIO_TRACK`, `AUDIO_DELAY`, `LEGACY_SPRITE_ARGS`, `CANVAS_X` and `CANVAS_Y`; other keys are ignored and non-string keys are skipped. A key of the wrong type ends the program; a non-table argument aborts the script.
*Parameters:*
* `config` -- a table of `games.dat` fields, at minimum `SCRIPT` or `CONTAINER`.
*Since:* 2.00
*See also:* <<scriptpush,scriptPush>>, <<singequit,singeQuit>>
.Example
[source,lua]
----
-- A launcher that never returns to itself.
function onInputPressed(what)
if what == SWITCH_START1 then
scriptExecute({
SCRIPT = DIR .. "../Lair/Lair.singe",
VIDEO = DIR .. "../Lair/Lair.mp4",
})
end
end
----
[#scriptpush]
==== scriptPush
[source,text]
----
scriptPush(config)
----
Starts the script the table describes and queues the current configuration behind it, so when the new script quits the current script runs again from the top. Nothing is kept in place: the current script's `onShutdown` runs now, its Lua state is discarded, and the later run is a fresh start, which is why `Menu.singe` saves its selection to a file before pushing. The table takes the same keys as `scriptExecute`.
*Parameters:*
* `config` -- a table of `games.dat` fields, as for `scriptExecute`.
*Since:* 2.00
*See also:* <<scriptexecute,scriptExecute>>, <<singequit,singeQuit>>, <<onshutdown,onShutdown>>
.Example
[source,lua]
----
-- Menu.singe: start the selected game and come back afterward.
function onInputPressed(what)
if what == SWITCH_START1 or what == SWITCH_BUTTON1 then
SHUTDOWN_FROM_PUSH = true
scriptPush(GAME_LIST[GAME_SELECTED])
end
end
----
[#singe]
=== Singe
Functions in the `singe*` namespace control the engine itself: the window, screenshots, the pause system, quitting and reloading, the audio delay used for lip sync, and a few paths and flags that come from the command line and `games.dat`. Nothing here takes a handle. Pause behavior is described in <<pausing,Pausing>> and the audio delay values in <<audiosync,Audio Sync>>.
[#singedisablepausekey]
==== singeDisablePauseKey
[source,text]
----
singeDisablePauseKey()
----
Gives the pause key to the script, exactly as `singeSetPauseKeyEnabled(false)` does. It is a function defined in `Framework.singe`, kept so older games run unchanged.
*Notes:* Legacy alias.
WARNING: New code should call <<singesetpausekeyenabled,singeSetPauseKeyEnabled>> with `false`.
*Since:* 1.18 (RDG)
*See also:* <<singesetpausekeyenabled,singeSetPauseKeyEnabled>>, <<singeenablepausekey,singeEnablePauseKey>>
.Example
[source,lua]
----
-- Older game code; the modern spelling is singeSetPauseKeyEnabled(false).
singeDisablePauseKey()
function onInputPressed(what)
if what == SWITCH_PAUSE then
togglePauseMenu()
end
end
----
[#singeenablepausekey]
==== singeEnablePauseKey
[source,text]
----
singeEnablePauseKey()
----
Returns the pause key to the engine, exactly as `singeSetPauseKeyEnabled(true)` does. It is a function defined in `Framework.singe`, kept so older games run unchanged.
*Notes:* Legacy alias.
WARNING: New code should call <<singesetpausekeyenabled,singeSetPauseKeyEnabled>> with `true`.
*Since:* 1.18 (RDG)
*See also:* <<singesetpausekeyenabled,singeSetPauseKeyEnabled>>, <<singedisablepausekey,singeDisablePauseKey>>
.Example
[source,lua]
----
-- Older game code; the modern spelling is singeSetPauseKeyEnabled(true).
function pauseMenuClose()
singeSetPauseFlag(false)
singeEnablePauseKey()
end
----
[#singegetaudiocalibration]
==== singeGetAudioCalibration
[source,text]
----
milliseconds = singeGetAudioCalibration()
----
Returns the per-machine audio delay in effect, in milliseconds: the value the menu's calibration screen saved to `audio.cfg` in the data root, or one set since by `singeSetAudioCalibration`. Zero when the machine has never been calibrated. It applies to every game on the machine, on top of the per-game delay from `singeGetAudioDelay`.
*Returns:* integer milliseconds.
*Since:* 3.00
*See also:* <<singesetaudiocalibration,singeSetAudioCalibration>>, <<singegetaudiodelay,singeGetAudioDelay>>, <<singegetaudiolatency,singeGetAudioLatency>>
.Example
[source,lua]
----
-- Menu.singe: start the calibration screen from the saved value.
function calibrationBegin()
CALIBRATING = true
CAL_ORIGINAL = singeGetAudioCalibration()
CAL_VALUE = CAL_ORIGINAL
CAL_LATENCY = singeGetAudioLatency()
CAL_BEAT = singeGetTicks() - CAL_PERIOD
end
----
[#singegetaudiodelay]
==== singeGetAudioDelay
[source,text]
----
milliseconds = singeGetAudioDelay()
----
Returns the per-game audio delay in effect, in milliseconds, whether it came from `--audiodelay`, the `AUDIO_DELAY` key of the game's `games.dat` entry, or `singeSetAudioDelay`. Zero when none has been set. It is added to the machine calibration and the measured device latency when the engine decides when to show each video frame.
*Returns:* integer milliseconds.
*Since:* 3.00
*See also:* <<singesetaudiodelay,singeSetAudioDelay>>, <<singegetaudiocalibration,singeGetAudioCalibration>>
.Example
[source,lua]
----
-- Service menu: show the current sync offset next to its slider.
function drawSyncSetting(y)
local delay = singeGetAudioDelay()
fontPrint(40, y, string.format("A/V offset: %+d ms", delay))
end
----
[#singegetaudiolatency]
==== singeGetAudioLatency
[source,text]
----
milliseconds = singeGetAudioLatency()
----
Returns the audio device queue the engine measured when it opened the mixer, in milliseconds: the time between handing samples to the mixer and their reaching the device, before any calibration or per-game delay. The engine already compensates for it; a calibration screen needs it to schedule its click and flash, and it is worth printing in a diagnostic view.
*Returns:* integer milliseconds.
*Since:* 3.00
*See also:* <<singegetaudiocalibration,singeGetAudioCalibration>>, <<singegetaudiodelay,singeGetAudioDelay>>
.Example
[source,lua]
----
-- Menu.singe: the flash follows the click by the device queue plus the candidate.
function calibrationUpdate()
local now = singeGetTicks()
local gap = CAL_LATENCY + CAL_VALUE
if now - CAL_BEAT >= CAL_PERIOD then
CAL_BEAT = now
CAL_CLICK_AT = now + math.max(0, -gap)
CAL_FLASH_AT = now + math.max(0, gap)
end
end
----
[#singegetdatapath]
==== singeGetDataPath
[source,text]
----
path = singeGetDataPath()
----
Returns the directory Singe writes to for this game, with a trailing path separator so a file name can be appended directly. With `--datadir`, or for a packed game, it is a subdirectory of that base named for the game (for a packed game, the database's name and then the script's directory inside it); otherwise it is the game's own directory. Screenshots, video indexes and `trace.txt` go there, and so should save games, high scores and settings.
*Returns:* string path ending in a separator.
*Since:* 2.00
*See also:* <<singegetscriptpath,singeGetScriptPath>>, <<singescreenshot,singeScreenshot>>
.Example
[source,lua]
----
-- Menu.singe keeps its last selection between runs.
CONFIG_FILE = singeGetDataPath() .. "menu.dat"
function saveConfig()
local cfg = io.open(CONFIG_FILE, "w")
if cfg then
cfg:write("GAME_SELECTED = " .. GAME_SELECTED .. "\n")
cfg:close()
else
debugPrint("Unable to write " .. CONFIG_FILE)
end
end
----
[#singegetheight]
==== singeGetHeight
[source,text]
----
height = singeGetHeight()
----
Returns the height of the Singe window in screen pixels, not overlay units, as the window is at the moment of the call. Use `overlayGetHeight` for drawing; this is for a settings screen that reports the display, or for deciding how much detail a scene can afford.
*Returns:* integer pixels.
*Since:* 1.x
*See also:* <<singegetwidth,singeGetWidth>>, <<overlaygetheight,overlayGetHeight>>
.Example
[source,lua]
----
-- Report the display in the service menu.
function drawDisplayInfo(y)
fontPrint(40, y, string.format("Window %dx%d, overlay %dx%d", singeGetWidth(), singeGetHeight(), overlayGetWidth(), overlayGetHeight()))
end
----
[#singegetpauseflag]
==== singeGetPauseFlag
[source,text]
----
paused = singeGetPauseFlag()
----
Returns the engine's pause flag: `true` while the game is paused by the pause key or by `singeSetPauseFlag`. This is separate from whether the disc is on a paused frame (`discGetState`); read it to know whether a pause was deliberately requested. While the pause key holds the game frozen no callback runs, so from inside a callback the flag can only be seen as `true` after the script's own `singeSetPauseFlag(true)`.
*Returns:* boolean.
*Since:* 1.x (RDG)
*See also:* <<singesetpauseflag,singeSetPauseFlag>>, <<singesetpausekeyenabled,singeSetPauseKeyEnabled>>
.Example
[source,lua]
----
-- Timers stand still while the game's own pause is up.
function onOverlayUpdate()
if not singeGetPauseFlag() then
elapsed = elapsed + 1
updateEnemies()
end
drawFrame()
return OVERLAY_UPDATED
end
----
[#singegetscriptpath]
==== singeGetScriptPath
[source,text]
----
path = singeGetScriptPath()
----
Returns the path of the running script file as the engine resolved it. `Framework.singe` derives the global `DIR`, the script's directory with its trailing separator, from this value, and `DIR` is what most games use to build asset paths.
*Returns:* string path.
*Since:* 1.15 (RDG)
*See also:* <<singegetdatapath,singeGetDataPath>>
.Example
[source,lua]
----
-- Framework.singe derives DIR this way; games then load relative to it.
DIR = singeGetScriptPath():match("(.*[/\\])") or "./"
titleSprite = spriteLoad(DIR .. "images/title.png")
----
[#singegetticks]
==== singeGetTicks
[source,text]
----
milliseconds = singeGetTicks()
----
Returns the wall clock in milliseconds since the engine started. It is the clock for timers, debounces and animation, and it keeps running while the game is frozen by the pause key, so a timer that must not count paused time should read `singeGetPauseFlag` or measure from the moment of thawing. Lua's `os.clock()` normally measures processor time, which stalls while the engine idles; Singe replaces it with wall seconds since the engine started, so existing games that used it as a wall clock keep working.
*Returns:* integer milliseconds.
*Since:* 3.00
*See also:* <<singegetpauseflag,singeGetPauseFlag>>, <<debugprint,debugPrint>>
.Example
[source,lua]
----
-- Blink the "insert coin" prompt twice a second.
function drawAttractPrompt()
local phase = (singeGetTicks() // 250) % 2
if phase == 0 then
fontPrint(120, 200, "INSERT COIN")
end
end
----
[#singegetwidth]
==== singeGetWidth
[source,text]
----
width = singeGetWidth()
----
Returns the width of the Singe window in screen pixels, the counterpart of `singeGetHeight`. Overlay drawing uses `overlayGetWidth` instead.
*Returns:* integer pixels.
*Since:* 1.x
*See also:* <<singegetheight,singeGetHeight>>, <<overlaygetwidth,overlayGetWidth>>
.Example
[source,lua]
----
-- Pick a texture budget from the window size.
local pixels = singeGetWidth() * singeGetHeight()
if pixels > 1920 * 1080 then
textureSet = "high"
else
textureSet = "low"
end
----
[#singequit]
==== singeQuit
[source,text]
----
singeQuit()
----
Ends the running script at the end of this frame: the game loop stops, `onShutdown` runs, and the engine moves on to the next queued script or exits when there is none. A game launched from the menu with `scriptPush` therefore returns to the menu, which restarts from the top. In the threaded model returning from `singeMain` calls this for you. The switch mapped to `INPUT_QUIT` in `controls.cfg` and closing the window end the script the same way. Do not call it from `onShutdown`, which is already the exit path.
*Since:* 1.x (RDG)
*See also:* <<onshutdown,onShutdown>>, <<singereload,singeReload>>, <<scriptpush,scriptPush>>
.Example
[source,lua]
----
function onInputPressed(what)
if what == SWITCH_QUIT then
singeQuit()
elseif what == SWITCH_START1 and gameOver then
startGame()
end
end
----
[#singereload]
==== singeReload
[source,text]
----
singeReload()
----
Runs the game again from its script at the end of this frame, as `F5` and a changed file do under `--reload`; this call works without the option. Every sound stops, the Lua state is discarded without calling `onShutdown`, everything the script loaded is freed, the scene, physics (its debug drawing included), particles and navigation are reset, the overlay returns to its default resolution, and the sound effect volume, mouse mode, mouse capture, keyboard mode, pause key, sound listener and pause flag return to their defaults. Keys and buttons held through the reload are ignored until released, as at a fresh start, and the disc is parked on its first frame, paused, with the default audio track selected, as at startup. The engine itself (the window, the controllers, the mice) stays as it is. It suits a debug menu's restart item or a level editor reloading what it just saved.
*Since:* 3.00.
*See also:* <<singequit,singeQuit>>
.Example
[source,lua]
----
-- Editor: save the level, then restart the game on it.
function onKeyPressed(keysym, scancode)
if scancode == SCANCODE.F9.value then
saveLevel(singeGetDataPath() .. "level.lua")
singeReload()
end
end
----
[#singescreenshot]
==== singeScreenshot
[source,text]
----
singeScreenshot()
----
Requests a screenshot of the whole window, letterbox included, taken after the next frame is drawn and saved as a PNG in the game's data directory (see `singeGetDataPath`). Files are named `singe000.png` upward; each script run scans from zero for the first free name and later shots continue past the last one saved. The call forces a redraw so the shot is taken even while the display is idle. The switch mapped to `INPUT_SCREENSHOT` in `controls.cfg` does the same thing.
*Since:* 1.x
*See also:* <<singegetdatapath,singeGetDataPath>>
.Example
[source,lua]
----
-- A test script captures a few frames as it flies through the scene.
function onOverlayUpdate()
frames = frames + 1
if frames == 40 or frames == 85 or frames == 130 then
singeScreenshot()
end
if frames == 180 then
singeQuit()
end
end
----
[#singesetaudiocalibration]
==== singeSetAudioCalibration
[source,text]
----
singeSetAudioCalibration(milliseconds)
----
Sets the per-machine audio delay, applies it at once, and writes it to `audio.cfg` in the data root so every game on the machine picks it up. The menu's calibration screen calls it; a game with its own service menu may too. The value is positive when the audio is heard later than the device reports; the engine delays video presentation by that much. Values outside `-1000` to `1000` abort the script. If `audio.cfg` cannot be written the value still applies for this run and a message goes to the console.
*Parameters:*
* `milliseconds` -- integer, `-1000` to `1000`, positive when the audio is late.
*Since:* 3.00
*See also:* <<singegetaudiocalibration,singeGetAudioCalibration>>, <<singesetaudiodelay,singeSetAudioDelay>>
.Example
[source,lua]
----
-- Menu.singe: keep or discard the calibration the player dialed in.
function calibrationEnd(save)
if save then
singeSetAudioCalibration(CAL_VALUE)
end
CALIBRATING = false
end
----
[#singesetaudiodelay]
==== singeSetAudioDelay
[source,text]
----
singeSetAudioDelay(milliseconds)
----
Sets the per-game audio delay for this run: how much later (positive) or earlier (negative) the audio is heard than the engine can measure, so that video presentation shifts to match. It applies to the disc and to every video the script plays, on top of the machine calibration. Nothing is saved; a game offering the adjustment in a service menu stores the value with its other settings and calls this again at startup. Values outside `-1000` to `1000` abort the script.
*Parameters:*
* `milliseconds` -- integer, `-1000` to `1000`, positive when the audio is late.
*Since:* 3.00
*See also:* <<singegetaudiodelay,singeGetAudioDelay>>, <<singesetaudiocalibration,singeSetAudioCalibration>>
.Example
[source,lua]
----
-- Service menu: nudge the offset and remember it.
function onInputPressed(what)
if what == SWITCH_LEFT then
settings.audioDelay = math.max(-1000, settings.audioDelay - 10)
elseif what == SWITCH_RIGHT then
settings.audioDelay = math.min(1000, settings.audioDelay + 10)
end
singeSetAudioDelay(settings.audioDelay)
saveSettings()
end
----
[#singesetgamename]
==== singeSetGameName
[source,text]
----
singeSetGameName(title)
----
Sets the title of the Singe window. Call it once at startup with the game's display name; nothing else uses the value. The argument must be a string or a number.
*Parameters:*
* `title` -- the window title.
*Since:* 1.15 (RDG)
*See also:* <<singeversion,singeVersion>>
.Example
[source,lua]
----
singeSetGameName("Space Ace")
keyboardSetMode(MODE_NORMAL)
hudFont = fontLoad(DIR .. "fonts/FreeSansBold.ttf", 18)
----
[#singesetpauseflag]
==== singeSetPauseFlag
[source,text]
----
singeSetPauseFlag(paused)
----
Sets the engine's pause flag from the script. `true` pauses the disc if it is playing, every loaded video, and every sound effect channel, remembering which were running; `false` resumes exactly those. Unlike the pause key, this does not freeze the script: callbacks keep firing so the script can draw its pause menu and decide when to clear the flag. Games that manage their own pause should also take the pause key with `singeSetPauseKeyEnabled(false)`, because the engine's key toggles the same flag and would otherwise fight the script. A non-boolean argument aborts the script.
*Parameters:*
* `paused` -- boolean.
*Since:* 1.x (RDG)
*See also:* <<singegetpauseflag,singeGetPauseFlag>>, <<singesetpausekeyenabled,singeSetPauseKeyEnabled>>
.Example
[source,lua]
----
singeSetPauseKeyEnabled(false)
function onInputPressed(what)
if what == SWITCH_PAUSE then
menuOpen = not menuOpen
singeSetPauseFlag(menuOpen)
elseif menuOpen and what == SWITCH_QUIT then
singeQuit()
end
end
----
[#singesetpausekeyenabled]
==== singeSetPauseKeyEnabled
[source,text]
----
singeSetPauseKeyEnabled(enabled)
----
Chooses who owns the pause key mapped to `INPUT_PAUSE` in `controls.cfg`. While enabled (the default, restored on every reload) the engine owns it: pressing the key freezes the game completely (see <<pausing,Pausing>>), and in `MODE_NORMAL` the script never receives `SWITCH_PAUSE`. While disabled the engine ignores the key and `SWITCH_PAUSE` reaches `onInputPressed` and `onInputReleased` like any other switch, so the game can run its own pause with `singeSetPauseFlag`. A non-boolean argument aborts the script.
*Parameters:*
* `enabled` -- boolean.
*Since:* 3.00
*See also:* <<singesetpauseflag,singeSetPauseFlag>>, <<singegetpauseflag,singeGetPauseFlag>>, <<singedisablepausekey,singeDisablePauseKey>>
.Example
[source,lua]
----
-- The engine may pause during play, but not while the player enters initials.
function beginInitials()
singeSetPauseKeyEnabled(false)
keyboardSetMode(MODE_FULL)
end
function endInitials()
keyboardSetMode(MODE_NORMAL)
singeSetPauseKeyEnabled(true)
end
----
[#singeversion]
==== singeVersion
[source,text]
----
version = singeVersion()
----
Returns the engine version as a number, major plus minor as a fraction, so `3.00` comes back as `3`. Compare it with `<` and `>=` to refuse an engine that is too old or to branch around features; `SINGE_VERSION_MAJOR`, `SINGE_VERSION_MINOR` and `SINGE_VERSION_STRING` are also set as globals for scripts that want the parts or the text.
*Returns:* number.
*Since:* 1.x (RDG)
*See also:* <<singesetgamename,singeSetGameName>>
.Example
[source,lua]
----
if singeVersion() < 3 then
error("This game needs Singe 3.00 or newer, found " .. SINGE_VERSION_STRING)
end
----
[#singewantscrosshairs]
==== singeWantsCrosshairs
[source,text]
----
wanted = singeWantsCrosshairs()
----
Returns `true` unless Singe was started with `--nocrosshair`. Draw the software targeting reticle only when it returns `true`; players with lightguns that put their own reticle on screen turn the software one off with that option. The value does not change during a run, so read it once.
*Returns:* boolean.
*Since:* 2.00
*See also:* <<mousegetposition,mouseGetPosition>>, <<onmousemoved,onMouseMoved>>
.Example
[source,lua]
----
drawCrosshair = singeWantsCrosshairs()
function onOverlayUpdate()
overlayClear()
drawTargets()
if drawCrosshair then
spriteDraw(reticle, cursorX, cursorY, true)
end
return OVERLAY_UPDATED
end
----
[#soft]
=== Soft
Cloth, pressure bodies and ropes: meshes whose vertices are simulated as particles (see <<physics,Physics>>, Soft bodies). A soft body is addressed by its node, and a node without one terminates the script in every call after `softNew`. Positions are world units, mass kilograms, and the stiffness values run `0` to `1`. The engine rewrites the node's mesh from the particles after every step, and the simulation ends by itself when the node is deleted or given a different mesh. Up to 16 soft bodies at once, with up to 32 pins each.
[#softnew]
==== softNew
[source,text]
----
softNew(node, SOFT_CLOTH)
softNew(node, SOFT_BODY)
softNew(node, SOFT_ROPE, x, y, z, segments, radius)
----
For `SOFT_CLOTH` and `SOFT_BODY` the node's mesh, of three vertices or more, becomes the particles: vertices at the same position (within `0.0001` units) are welded into one, placed where the node's transform puts them in the world, and the triangles give the stretch, shear and bend constraints. Cloth collides on both faces and is what an open sheet wants; a body is a closed mesh that `softSetPressure` can inflate. For `SOFT_ROPE` the node needs no mesh: `segments + 1` particles run in a line from the node's world position to the world point, the node is given a new six-sided tube mesh of that radius round them (keeping whatever material it had), and the particles collide as spheres of that radius, where cloth and body particles are spheres of radius `0.03`. Every kind starts with stretch `0.9`, bend `0.2`, mass `1` kilogram, damping `0.1` and pressure `0`. A soft body already on the node is removed before the new one is made, so a call that fails leaves the node with none; a rope's tube mesh goes with it, which is why a rope's node cannot become cloth or a body (the call fails for want of a mesh). Raises an error when physics is unavailable, the kind is unknown, the argument count does not fit the kind, the mesh is missing, a rope has no segments or no radius, or 16 soft bodies already exist.
*Parameters:*
* `node` -- the node carrying the mesh (cloth, body), or the rope's starting point.
* `x`, `y`, `z` -- the rope's far end, in world units.
* `segments` -- how many links the rope has, `1` or more; a dozen is plenty.
* `radius` -- the rope's thickness, in world units; also the size its particles collide with.
*Notes:* A mesh shared by several nodes is rewritten for all of them, so give a soft body a mesh of its own. Skinned meshes cannot be soft.
*Since:* 3.00.
*See also:* <<softpin,softPin>>, <<softsetstiffness,softSetStiffness>>, <<softsetpressure,softSetPressure>>, <<meshplane,meshPlane>>
.Example
[source,lua]
----
-- A flag hanging from the pole: an upright grid, pinned down its left edge.
local flag = nodeNew()
nodeSetMesh(flag, meshPlane(1.6, 1.0, 16, 10), cloth)
nodeSetPosition(flag, -4.16, 2.4, 0)
nodeSetRotation(flag, 90, 0, 0)
softNew(flag, SOFT_CLOTH)
softSetStiffness(flag, 0.95, 0.1)
softSetMass(flag, 0.5)
for _, y in ipairs({ 1.9, 2.15, 2.4, 2.65, 2.9 }) do
softPin(flag, -4.96, y, 0) -- Along the pole
end
----
[#softdelete]
==== softDelete
[source,text]
----
softDelete(node)
----
Ends the simulation and frees its body. The node keeps its mesh in the shape it was last written, so a dropped sheet stays draped over the crate and a rope keeps its tube; the original mesh shape is not restored. A node without a soft body terminates the script.
*Since:* 3.00.
*See also:* <<softnew,softNew>>, <<nodedelete,nodeDelete>>
.Example
[source,lua]
----
-- Once the sheet has settled over the statue, freeze it and stop paying for it.
function onOverlayUpdate()
if covering then
settleFrames = settleFrames + 1
if settleFrames > 240 then
softDelete(sheet) -- The drape stays as it is
covering = false
end
end
return OVERLAY_NOT_UPDATED
end
----
[#softpin]
==== softPin
[source,text]
----
softPin(node, x, y, z)
softPin(node, x, y, z, otherNode)
----
Finds the particle nearest the world point, wherever the particles are right now, and fixes it. On its own the particle is held where it is; with `otherNode` it is moved to that node's world position before every step, so a rope hangs from a crane that swings and a cape follows the shoulders. The point need not lie on the mesh, since the nearest particle wins, and pinning the same particle again only changes what it follows. Past 32 pins the call does nothing.
*Parameters:*
* `node` -- the soft body's node.
* `x`, `y`, `z` -- a world point; the particle nearest it is pinned.
* `otherNode` -- a node for the particle to follow from now on.
*Since:* 3.00.
*See also:* <<softunpin,softUnpin>>, <<softnew,softNew>>, <<nodegetworldposition,nodeGetWorldPosition>>
.Example
[source,lua]
----
-- A rope from the crane's hook out and down, swinging with the crane.
local crane = nodeNew()
nodeSetPosition(crane, 4, 4, -1)
local rope = nodeNew()
nodeSetPosition(rope, 4, 4, -1)
softNew(rope, SOFT_ROPE, 6.5, 4, -1, 16, 0.04)
nodeSetMaterial(rope, hemp)
softSetMass(rope, 1.5)
softPin(rope, 4, 4, -1, crane) -- The top end rides the hook
----
[#softunpin]
==== softUnpin
[source,text]
----
softUnpin(node, x, y, z)
----
Frees the pinned particle nearest the world point. Nothing happens when that particle is not pinned. The body is remade around the current shape, which also stops every particle dead, so a flag let go of starts falling from rest.
*Since:* 3.00.
*See also:* <<softpin,softPin>>
.Example
[source,lua]
----
-- The banner's corner tears loose when the wind gust hits.
function onOverlayUpdate()
gustFrames = gustFrames + 1
if gustFrames == 200 then
softUnpin(banner, 3.0, 4.5, 0)
soundPlay(ripSound)
end
return OVERLAY_NOT_UPDATED
end
----
[#softsetstiffness]
==== softSetStiffness
[source,text]
----
softSetStiffness(node, stretch, bend)
----
How much the material resists being stretched (and, for cloth, sheared) and being folded, `0` to `1`; values outside are clamped. `1` is rigid, `0` the loosest the solver allows. Defaults `0.9` and `0.2`. The body is remade around the current shape: the rest lengths become the lengths of this moment, and every particle stops, so set the material up before the cloth starts to move. A rope has only stretch.
*Parameters:*
* `node` -- the soft body's node.
* `stretch` -- resistance to stretching, `0` to `1`; default `0.9`.
* `bend` -- resistance to folding, `0` to `1`; default `0.2`.
*Since:* 3.00.
*See also:* <<softsetmass,softSetMass>>, <<softsetdamping,softSetDamping>>
.Example
[source,lua]
----
-- Silk, canvas and a rubber sheet from the same grid.
local silk = clothNode(silkMaterial)
softNew(silk, SOFT_CLOTH)
softSetStiffness(silk, 0.7, 0.02)
local canvas = clothNode(canvasMaterial)
softNew(canvas, SOFT_CLOTH)
softSetStiffness(canvas, 1.0, 0.4)
local rubber = clothNode(rubberMaterial)
softNew(rubber, SOFT_CLOTH)
softSetStiffness(rubber, 0.3, 0.1)
----
[#softsetpressure]
==== softSetPressure
[source,text]
----
softSetPressure(node, pressure)
----
Inflates a `SOFT_BODY`: `0` (default) is a deflated bag and a few units a firm balloon; a negative value becomes `0`. It changes the live body without remaking it and wakes it, so a balloon can be pumped up or let down while it bounces. Cloth and rope have no volume to inflate.
*Since:* 3.00.
*See also:* <<softnew,softNew>>, <<softsetstiffness,softSetStiffness>>, <<meshsphere,meshSphere>>
.Example
[source,lua]
----
softNew(balloon, SOFT_BODY) -- A meshSphere of 0.5
softSetMass(balloon, 0.6)
softSetPressure(balloon, 8) -- A firm balloon
function onOverlayUpdate()
if punctured then
pressure = math.max(pressure - 0.1, 0)
softSetPressure(balloon, pressure) -- Hissing down over a couple of seconds
end
return OVERLAY_NOT_UPDATED
end
----
[#softsetmass]
==== softSetMass
[source,text]
----
softSetMass(node, kilograms)
----
The whole body's mass, shared evenly between its particles; default `1`, kept at `0.001` or more. Pinned particles weigh nothing regardless. The body is remade around the current shape and every particle stops, so set it up front.
*Since:* 3.00.
*See also:* <<softsetstiffness,softSetStiffness>>, <<softsetdamping,softSetDamping>>
.Example
[source,lua]
----
-- A sheet pinned by four corners catches a crate dropped onto it.
local sheet = nodeNew()
nodeSetMesh(sheet, meshPlane(3, 3, 20, 20), linen)
nodeSetPosition(sheet, 0, 1.6, 0)
softNew(sheet, SOFT_CLOTH)
softSetStiffness(sheet, 1.0, 0.05)
softSetMass(sheet, 4) -- Heavy enough not to flutter
for _, c in ipairs({ { -1.5, -1.5 }, { 1.5, -1.5 }, { -1.5, 1.5 }, { 1.5, 1.5 } }) do
softPin(sheet, c[1], 1.6, c[2])
end
----
[#softsetdamping]
==== softSetDamping
[source,text]
----
softSetDamping(node, damping)
----
How quickly the particles' motion dies away, `0` upward; default `0.1`, and a negative value becomes `0`. Higher values calm a flag that will not stop rippling or a rope that swings forever. The body is remade around the current shape and every particle stops.
*Since:* 3.00.
*See also:* <<softsetmass,softSetMass>>, <<softsetstiffness,softSetStiffness>>
.Example
[source,lua]
----
-- A curtain in still air settles fast; the same curtain by the open window keeps moving.
local curtain = nodeNew()
nodeSetMesh(curtain, meshPlane(1.2, 2.4, 12, 24), velvet)
nodeSetPosition(curtain, 2, 2.4, -3)
nodeSetRotation(curtain, 90, 0, 0)
softNew(curtain, SOFT_CLOTH)
softSetDamping(curtain, windowOpen and 0.05 or 0.4)
for x = -0.6, 0.6, 0.1 do
softPin(curtain, 2 + x, 3.6, -3)
end
----
[#sound]
=== Sound
Sound effects are audio clips (WAV is the safe choice) loaded into memory and mixed through SDL_mixer, separate from the disc's own audio and from the audio of `video*` assets. `soundLoad` returns a sound handle that identifies the clip's data; `soundPlay` starts that clip on one of sixteen mixer channels and returns the channel number, `0` through `15`, which is what `soundPause`, `soundResume`, `soundStop`, `soundIsPlaying` and every `soundSet*` call take. One clip can play on several channels at once, and a channel number is reused as soon as the channel is free, so keep the two kinds of number apart. A master effects volume (`soundSetVolume`, `0` to `63`) scales every channel. A playing channel can also sit in the 3D scene, following a node or fixed at a point in world units, where it is panned and faded by its distance from the listener (see <<scenes3d,3D Scenes>>); for 2D games `soundSetPan` pans a channel without a scene.
[#soundfullstop]
==== soundFullStop
[source,text]
----
soundFullStop()
----
Stops every effect channel at once, paused ones included, and frees them for reuse. No clip is unloaded; the handles from `soundLoad` stay valid. Every channel that was playing or paused reports through `onSoundCompleted`, exactly as it does after `soundStop`. Call it on a scene change or before a cutscene rather than tracking every channel you started.
*Since:* 1.16
*See also:* <<soundstop,soundStop>>, <<onsoundcompleted,onSoundCompleted>>
.Example
[source,lua]
----
function enterAttractMode()
soundFullStop()
ambientChannel = -1
engineChannel = -1
discPlay()
end
----
[#soundgetposition]
==== soundGetPosition
[source,text]
----
x, y, z, gain = soundGetPosition(channel)
----
Reports where a positioned channel sits relative to the listener, as a direction of unit length in the listener's frame (`x` positive to the right, `y` positive up, `z` positive behind the listener), and the volume factor its distance currently gives it, `0` to `1`. The values are refreshed once per frame from the listener and the channel's node or point. A channel that has not been placed in the scene since it started returns `0, 0, 0, 1`. Meant for radar-style indicators and for checking a mix from a test script; a channel outside `0` to `15` aborts the script.
*Returns:* three numbers for the direction and one for the distance gain.
*Since:* 3.00.
*See also:* <<soundsetnode,soundSetNode>>, <<soundsetposition,soundSetPosition>>, <<soundsetrange,soundSetRange>>
.Example
[source,lua]
----
-- Point an arrow on the HUD toward the nearest audible enemy.
function onOverlayUpdate()
overlayClear()
local x, y, z, gain = soundGetPosition(growlChannel)
if gain > 0 then
local angle = math.atan(x, -z)
drawThreatArrow(angle, gain)
end
return OVERLAY_UPDATED
end
----
[#soundgetvolume]
==== soundGetVolume
[source,text]
----
volume = soundGetVolume()
----
Returns the master effects volume, `0` through `63`. At startup it reflects the effects volume from the command line, so a game that shows a volume setting should read it rather than assume the maximum.
*Returns:* integer, `0` to `63`.
*Since:* 1.16
*See also:* <<soundsetvolume,soundSetVolume>>
.Example
[source,lua]
----
-- Duck the effects while the narrator speaks, then put them back.
function narratorBegin()
savedVolume = soundGetVolume()
soundSetVolume(math.floor(savedVolume / 3))
narratorChannel = soundPlay(narratorClip)
end
----
[#soundisplaying]
==== soundIsPlaying
[source,text]
----
playing = soundIsPlaying(channel)
----
Tests whether a channel is playing right now. A paused channel reports `false`, as does a channel whose clip has finished. A channel outside `0` to `15` aborts the script; a channel that has merely gone quiet does not.
*Returns:* boolean.
*Since:* 1.16 (RDG)
*See also:* <<soundplay,soundPlay>>, <<soundpause,soundPause>>
.Example
[source,lua]
----
-- Do not restart the alarm while it is still sounding.
function raiseAlarm()
if alarmChannel < 0 or not soundIsPlaying(alarmChannel) then
alarmChannel = soundPlay(alarmClip)
end
end
----
[#soundload]
==== soundLoad
[source,text]
----
id = soundLoad(filename)
----
Loads an audio clip into memory and returns its handle. The path goes through the game's virtual file system, so it works the same from a loose directory and from a packed game. Load clips once at startup, not inside a callback that fires every frame. A file that cannot be opened or decoded aborts the script.
*Returns:* integer sound handle. This is a data handle, not a channel number.
*Since:* 1.x
*See also:* <<soundplay,soundPlay>>, <<soundunload,soundUnload>>
.Example
[source,lua]
----
-- Clip handles, loaded once and unloaded in onShutdown.
shotClip = soundLoad(DIR .. "sounds/shot.wav")
missClip = soundLoad(DIR .. "sounds/miss.wav")
coinClip = soundLoad(DIR .. "sounds/coin.wav")
ambientClip = soundLoad(DIR .. "sounds/wind.wav")
----
[#soundpause]
==== soundPause
[source,text]
----
wasPlaying = soundPause(channel)
----
Pauses a channel in place; `soundResume` continues it from the same point. A paused channel still counts as busy, so `soundPlay` will not hand it out to another clip, and `soundIsPlaying` reports `false` for it. Pausing a channel that is not playing does nothing. A channel outside `0` to `15` aborts the script.
*Returns:* boolean, `true` when the channel was playing at the moment of the call.
*Since:* 1.16 (RDG)
*See also:* <<soundresume,soundResume>>, <<soundisplaying,soundIsPlaying>>
.Example
[source,lua]
----
-- Hold the engine noise while the radio message plays.
function radioBegin(messageClip)
engineWasRunning = soundPause(engineChannel)
radioChannel = soundPlay(messageClip)
end
----
[#soundplay]
==== soundPlay
[source,text]
----
channel = soundPlay(id)
channel = soundPlay(id, loops)
----
Starts a loaded clip on the first free channel and returns that channel's number. A channel counts as free when it is neither playing nor paused. The new channel starts at the master volume, centered, with no scene position and the default range, whatever the channel's previous use set. When all sixteen channels are busy nothing plays and `-1` (`SOUND_ERROR_INVALID`) comes back; a game that fires many overlapping sounds should check for it. A handle that is not loaded aborts the script.
*Parameters:*
* `id` -- sound handle from `soundLoad`.
* `loops` -- `0` (the default) plays the clip once, a positive count repeats it that many more times, `-1` loops it until it is stopped. Anything below `-1` aborts the script.
*Returns:* integer channel number, `0` to `15`, or `-1` when no channel is free.
*Since:* 1.x
*See also:* <<soundload,soundLoad>>, <<soundstop,soundStop>>, <<soundsetnode,soundSetNode>>
.Example
[source,lua]
----
function onInputPressed(what)
if what == SWITCH_BUTTON1 then
local channel = soundPlay(shotClip)
if channel < 0 then
debugPrint("All sound channels busy; shot dropped")
end
elseif what == SWITCH_COIN1 then
soundPlay(coinClip)
credits = credits + 1
end
end
----
[#soundresume]
==== soundResume
[source,text]
----
wasPaused = soundResume(channel)
----
Continues a channel that `soundPause` stopped, from where it left off. Resuming a channel that is not paused does nothing. A channel outside `0` to `15` aborts the script.
*Returns:* boolean, `true` when the channel was paused at the moment of the call.
*Since:* 1.16 (RDG)
*See also:* <<soundpause,soundPause>>
.Example
[source,lua]
----
function onSoundCompleted(channel)
if channel == radioChannel then
radioChannel = -1
if engineWasRunning then
soundResume(engineChannel)
end
end
end
----
[#soundsetlistener]
==== soundSetListener
[source,text]
----
soundSetListener(node)
soundSetListener()
----
Chooses the node that positioned channels are heard from, facing the way it faces. With no argument, or `nil`, the listener is the scene camera, which is right for a first-person or fixed-camera game; a third-person game usually prefers the player's node so that sounds pan around the character rather than the camera. A node that is later deleted drops the listener back to the camera. A node handle that does not exist aborts the script.
*Since:* 3.00.
*See also:* <<soundsetnode,soundSetNode>>, <<soundsetposition,soundSetPosition>>, <<cameraset,cameraSet>>
.Example
[source,lua]
----
-- Chase camera, but hear the world from the car.
local car = modelInstance(carModel)
local chaseCamera = nodeNew(car)
nodeSetPosition(chaseCamera, 0, 2, 6)
cameraSet(chaseCamera)
soundSetListener(car)
----
[#soundsetnode]
==== soundSetNode
[source,text]
----
soundSetNode(channel, node)
soundSetNode(channel)
----
Attaches a playing channel to a scene node so it is heard from wherever the node is each frame: the channel is panned toward the node's direction from the listener and faded by its distance according to `soundSetRange`. Calling it with no node, or `nil`, takes the channel back out of the scene at full master volume. A channel whose node is deleted drops back to plain playback on its own. The attachment lasts until the channel is stopped or reused by `soundPlay`. A channel outside `0` to `15`, or a node that does not exist, aborts the script.
*Since:* 3.00.
*See also:* <<soundsetposition,soundSetPosition>>, <<soundsetrange,soundSetRange>>, <<soundsetlistener,soundSetListener>>
.Example
[source,lua]
----
-- Each brazier crackles from where it stands.
for i, brazier in ipairs(braziers) do
local channel = soundPlay(crackleClip, -1)
if channel >= 0 then
soundSetNode(channel, brazier)
soundSetRange(channel, 1.5, 14)
end
end
----
[#soundsetpan]
==== soundSetPan
[source,text]
----
soundSetPan(channel, pan)
----
Pans a channel across the stereo pair for games without a scene: `-1` is fully left, `1` fully right, `0` centered, and values outside that range are clamped. Moving away from center reduces the far speaker only; at `pan = 0.5` the left speaker plays at half gain and the right at full. The call takes the channel out of the scene if `soundSetNode` or `soundSetPosition` had placed it there. A channel outside `0` to `15` aborts the script.
*Since:* 3.00.
*See also:* <<soundplay,soundPlay>>, <<soundsetnode,soundSetNode>>
.Example
[source,lua]
----
-- Pan a passing car by where it is on the overlay.
function carPassBy(x)
local channel = soundPlay(carClip)
if channel >= 0 then
soundSetPan(channel, (x / overlayGetWidth()) * 2 - 1)
end
end
----
[#soundsetposition]
==== soundSetPosition
[source,text]
----
soundSetPosition(channel, x, y, z)
----
Places a playing channel at a fixed point in the scene, in world units, replacing any node it was following. It is then panned and faded from the listener like a node-attached channel. Suits a sound that stays put, such as a waterfall or a ringing telephone; a moving source is easier with `soundSetNode`. A channel outside `0` to `15` aborts the script.
*Since:* 3.00.
*See also:* <<soundsetnode,soundSetNode>>, <<soundsetrange,soundSetRange>>, <<soundgetposition,soundGetPosition>>
.Example
[source,lua]
----
-- The waterfall never moves, so no node is needed.
waterfallChannel = soundPlay(waterfallClip, -1)
if waterfallChannel >= 0 then
soundSetPosition(waterfallChannel, 42, 3, -18)
soundSetRange(waterfallChannel, 6, 80)
end
----
[#soundsetrange]
==== soundSetRange
[source,text]
----
soundSetRange(channel, near, far)
----
Sets how a positioned channel fades with distance from the listener. Within `near` it plays at full volume; beyond that its gain is `near` divided by the distance; over the last fifth of `far` it also fades linearly, reaching silence at `far`. Defaults are `1` and `30` world units, restored whenever `soundPlay` reuses the channel. A whisper wants a small `far`, a waterfall a large one.
*Parameters:*
* `channel` -- channel number from `soundPlay`.
* `near` -- distance within which the channel is at full volume, greater than `0`.
* `far` -- distance at which it is silent, greater than `near`. Any other pair aborts the script.
*Since:* 3.00.
*See also:* <<soundsetnode,soundSetNode>>, <<soundsetposition,soundSetPosition>>, <<soundgetposition,soundGetPosition>>
.Example
[source,lua]
----
-- A guard's footsteps are only audible nearby; the siren carries.
local steps = soundPlay(footstepClip, -1)
soundSetNode(steps, guard)
soundSetRange(steps, 0.5, 8)
local siren = soundPlay(sirenClip, -1)
soundSetNode(siren, tower)
soundSetRange(siren, 10, 200)
----
[#soundsetvolume]
==== soundSetVolume
[source,text]
----
soundSetVolume(volume)
----
Sets the master effects volume and applies it at once to every channel, playing or paused. Positioned channels keep their distance fade on top of it. Values outside `0` to `63` abort the script.
*Parameters:*
* `volume` -- integer, `0` (silent) through `63` (loudest).
*Since:* 1.16
*See also:* <<soundgetvolume,soundGetVolume>>
.Example
[source,lua]
----
-- Service menu volume slider driven by the left and right switches.
function onInputPressed(what)
local volume = soundGetVolume()
if what == SWITCH_LEFT then
soundSetVolume(math.max(0, volume - 4))
elseif what == SWITCH_RIGHT then
soundSetVolume(math.min(63, volume + 4))
end
soundPlay(clickClip)
end
----
[#soundstop]
==== soundStop
[source,text]
----
wasPlaying = soundStop(channel)
----
Halts a channel immediately, whether playing or paused, and frees it for reuse. The channel reports through `onSoundCompleted` just as it does when the clip finishes on its own, so a script that chains sounds from that callback should check whether it stopped the channel itself. The next `soundPlay` may hand the same number out again, so discard the number on the Lua side. A channel outside `0` to `15` aborts the script.
*Returns:* boolean, `true` when the channel was playing at the moment of the call. A paused channel returns `false` but is still stopped.
*Since:* 1.x (RDG)
*See also:* <<soundfullstop,soundFullStop>>, <<onsoundcompleted,onSoundCompleted>>
.Example
[source,lua]
----
function playerDied()
if engineChannel >= 0 then
soundStop(engineChannel)
engineChannel = -1
end
soundPlay(explosionClip)
lives = lives - 1
end
----
[#soundunload]
==== soundUnload
[source,text]
----
soundUnload(id)
----
Frees a loaded clip and invalidates its handle. The mixer keeps the clip's data alive until every channel still playing it has stopped, so unloading does not cut a playing channel short. Any later use of the handle, including another `soundUnload`, aborts the script. The usual place for it is `onShutdown`.
*Parameters:*
* `id` -- sound handle from `soundLoad`, not a channel number.
*Since:* 2.00
*See also:* <<soundload,soundLoad>>, <<onshutdown,onShutdown>>
.Example
[source,lua]
----
function onShutdown()
soundUnload(shotClip)
soundUnload(missClip)
soundUnload(coinClip)
soundUnload(ambientClip)
fontUnload(hudFont)
end
----
[#sprite]
=== Sprite
Sprites are 2D bitmaps loaded from disk with `spriteLoad`, or rendered from text with `fontToSprite`, and drawn onto the overlay in overlay coordinates. Every `sprite*` call takes the integer handle as its first argument (Singe 2.10 put it last; see <<migrating,Migrating from Singe 2.10>>), and a handle that has been unloaded ends the script with an error. Any format SDL_image reads is accepted; an animated GIF or WEBP with two or more frames loads as an animation that sits on frame `0`, stopped and non-looping, until `spritePlay` and `spriteLoop` say otherwise. Pixels whose raw value is `0` in the image's own pixel format are drawn transparent (palette index `0`, or black in an RGB image without alpha), and a real alpha channel is honored as well. Release every sprite with `spriteUnload`, usually from <<onshutdown,onShutdown>>.
[#spritedraw]
==== spriteDraw
[source,text]
----
spriteDraw(id, x, y)
spriteDraw(id, x, y, centered)
spriteDraw(id, x, y, x2, y2)
spriteDraw(id, x, y, x2, y2, centered)
----
Blits the sprite onto the overlay as currently rotated and scaled. Call it from `onOverlayUpdate`. The first two forms draw at the size `spriteGetWidth` and `spriteGetHeight` report, with `(x, y)` as the top-left corner; the last two stretch that image into the rectangle from `(x, y)` to `(x2, y2)` inclusive, always with nearest-neighbor sampling whatever `spriteQuality` says. If the sprite is a playing animation, the draw first advances it by the real time elapsed since the previous draw (or since `spritePlay`), stepping through as many frames as that time covers, and shows each frame for at least 10 ms even when the file asks for less. When the last frame passes, playback wraps to frame `0` if `spriteLoop` is set, otherwise it stays on the last frame and stops. Parts that fall outside the overlay are clipped.
*Parameters:*
* `id` -- sprite handle from `spriteLoad` or `fontToSprite`.
* `x`, `y` -- anchor in overlay coordinates; fractions are truncated.
* `x2`, `y2` -- opposite corner of the target rectangle, inclusive, for the stretched forms.
* `centered` -- boolean. When `true`, the image (or the stretched rectangle) is shifted so that `(x, y)` is its center instead of its top-left corner.
*Notes:* A playing animation that is not drawn for a while catches up on its next draw; call `spritePause` if it should hold its frame instead. Use the centered form for rotated sprites, since rotation changes the bounding box and a top-left anchor appears to drift.
*Since:* 1.x. Centered and stretched-centered forms added in 2.10.
*See also:* <<spriteload,spriteLoad>>, <<spriterotate,spriteRotate>>, <<spritegetwidth,spriteGetWidth>>, <<videodraw,videoDraw>>
.Example
[source,lua]
----
-- Cabinet art at natural size, the attract still squeezed into its box,
-- and a cursor anchored on its center.
function onOverlayUpdate()
overlayClear()
spriteDraw(cabinetSprite, CABINET_X, CABINET_Y)
spriteDraw(attractStill, THUMB_X, THUMB_Y, THUMB_X + THUMB_W - 1, THUMB_Y + THUMB_H - 1)
spriteDraw(cursorSprite, cursorX, cursorY, true)
return OVERLAY_UPDATED
end
----
[#spritegetframe]
==== spriteGetFrame
[source,text]
----
result = spriteGetFrame(id)
----
Returns the zero-based index of the frame the sprite currently shows. Still images and animations that have not advanced report `0`. Frames advance only inside `spriteDraw`, so the value changes between draws, not between arbitrary calls. Use it to line up sound or game logic with a particular frame of an animation.
*Returns:* integer frame index, `0` for a still image.
*Since:* 2.10
*See also:* <<spritesetframe,spriteSetFrame>>, <<spriteisplaying,spriteIsPlaying>>, <<spritedraw,spriteDraw>>
.Example
[source,lua]
----
-- Fire the gunshot on the frame where the muzzle flash appears.
function onOverlayUpdate()
spriteDraw(muzzleFlash, gunX, gunY, true)
local frame = spriteGetFrame(muzzleFlash)
if frame == FLASH_FRAME and frame ~= lastFlashFrame then
soundPlay(gunshot)
end
lastFlashFrame = frame
return OVERLAY_UPDATED
end
----
[#spritegetheight]
==== spriteGetHeight
[source,text]
----
result = spriteGetHeight(id)
----
Returns the height of the sprite as it would be drawn right now, in overlay coordinates. Scaling and rotation applied with `spriteScale`, `spriteRotate`, or `spriteRotateAndScale` are included, so a rotated sprite reports the height of its rotated bounding box, not of the source image. For an animation the current frame is measured. The stretched forms of `spriteDraw` do not affect the value.
*Returns:* integer height.
*Since:* 2.00
*See also:* <<spritegetwidth,spriteGetWidth>>, <<spritescale,spriteScale>>, <<spriterotate,spriteRotate>>
.Example
[source,lua]
----
-- Stack pre-rendered description lines, spaced by the height of the text sprites.
function onOverlayUpdate()
local y = TEXT_Y
for _, line in ipairs(descriptionLines) do
spriteDraw(line, TEXT_X, y)
y = y + spriteGetHeight(line) + 1
end
return OVERLAY_UPDATED
end
----
[#spritegetwidth]
==== spriteGetWidth
[source,text]
----
result = spriteGetWidth(id)
----
Returns the width of the sprite as it would be drawn right now, in overlay coordinates, including any scaling and rotation currently applied. For an animation the current frame is measured. Together with `spriteGetHeight` this is the usual way to center a sprite in a box or to measure text rendered by `fontToSprite`.
*Returns:* integer width.
*Since:* 2.00
*See also:* <<spritegetheight,spriteGetHeight>>, <<spritescale,spriteScale>>, <<fonttosprite,fontToSprite>>
.Example
[source,lua]
----
-- Center the cabinet image inside its panel, and the title across the overlay.
function onOverlayUpdate()
local x = CABINET_X + (CABINET_W - spriteGetWidth(cabinetSprite)) * 0.5
local y = CABINET_Y + (CABINET_H - spriteGetHeight(cabinetSprite)) * 0.5
spriteDraw(cabinetSprite, x, y)
spriteDraw(titleSprite, (overlayGetWidth() - spriteGetWidth(titleSprite)) / 2, 25)
return OVERLAY_UPDATED
end
----
[#spriteisplaying]
==== spriteIsPlaying
[source,text]
----
result = spriteIsPlaying(id)
----
Reports whether the sprite's animation is currently advancing. A freshly loaded sprite reports `false`; `spritePlay` turns it on, and `spritePause` or the end of a non-looping animation turns it off. Poll it to find out when a one-shot animation has finished.
*Returns:* boolean, `true` while the animation is playing.
*Notes:* `spritePlay` and `spritePause` do not check for animation data, so a still image reports whatever those calls last set.
*Since:* 2.10
*See also:* <<spriteplay,spritePlay>>, <<spritepause,spritePause>>, <<spriteloop,spriteLoop>>
.Example
[source,lua]
----
-- Hold the menu until the one-shot logo animation has finished.
function onOverlayUpdate()
overlayClear()
spriteDraw(logoReveal, overlayGetWidth() / 2, overlayGetHeight() / 2, true)
if not spriteIsPlaying(logoReveal) then
drawMenu()
end
return OVERLAY_UPDATED
end
----
[#spriteload]
==== spriteLoad
[source,text]
----
result = spriteLoad(filename)
----
Loads an image and returns an integer handle for the other `sprite*` calls. The name goes through the engine's virtual file system, so it may live loose on disk or inside a packed game; it is resolved relative to the directory Singe was started in, so prepend `DIR` for files shipped with your game. An animated GIF or WEBP with two or more frames loads as an animation, parked on frame `0`, not playing and not looping; every other file, including a single-frame GIF, loads as a still image. An image with more than 8 bits per channel, or floating point pixels, is converted to 8-bit RGBA on load and treated as one from then on. Pixels with a raw value of `0` in the file's own pixel format become transparent. Loading is synchronous, and a file that cannot be opened or decoded ends the script with the loader's error message rather than returning `nil`.
*Returns:* integer sprite handle.
*Notes:* Animations do not start by themselves. Call `spriteLoop(id, true)` and `spritePlay(id)` after loading if the animation should run and repeat.
*Since:* 1.x. Animated GIF/WEBP support added in 2.10.
*See also:* <<spriteunload,spriteUnload>>, <<spriteplay,spritePlay>>, <<spriteloop,spriteLoop>>, <<fonttosprite,fontToSprite>>
.Example
[source,lua]
----
-- Load the menu art once at startup; the attract loop is an animated WEBP.
cabinetSprite = spriteLoad(DIR .. "cabinet.png")
marqueeSprite = spriteLoad(DIR .. "marquee.png")
attractLoop = spriteLoad(DIR .. "attract.webp")
spriteQuality(attractLoop, RENDER_SMOOTH)
spriteLoop(attractLoop, true)
spritePlay(attractLoop)
----
[#spriteloop]
==== spriteLoop
[source,text]
----
spriteLoop(id, loop)
----
Sets whether the animation restarts from frame `0` after its last frame or stops there. New sprites do not loop. The setting is consulted each time `spriteDraw` steps past the last frame, so it can be changed while the animation runs. Setting it on a sprite that has already stopped does not restart playback; call `spritePlay` for that. The argument must be a Lua boolean.
*Parameters:*
* `id` -- sprite handle.
* `loop` -- boolean. `true` wraps to frame `0`; `false` (the default) stops on the last frame and `spriteIsPlaying` starts returning `false`.
*Since:* 2.10
*See also:* <<spriteplay,spritePlay>>, <<spriteisplaying,spriteIsPlaying>>, <<spritesetframe,spriteSetFrame>>
.Example
[source,lua]
----
-- The torch flickers forever; the explosion plays once per hit.
torchFlame = spriteLoad(DIR .. "torch.gif")
explosion = spriteLoad(DIR .. "explosion.gif")
spriteLoop(torchFlame, true)
spriteLoop(explosion, false)
spritePlay(torchFlame)
----
[#spritepause]
==== spritePause
[source,text]
----
spritePause(id)
----
Stops the animation on its current frame. The frame keeps being drawn by `spriteDraw`, and the time spent paused is not counted when `spritePlay` resumes, so the frame does not skip ahead. Pausing a sprite that is not playing changes nothing.
*Since:* 2.10
*See also:* <<spriteplay,spritePlay>>, <<spriteisplaying,spriteIsPlaying>>
.Example
[source,lua]
----
-- Freeze the enemy mid-stride while the hit flash plays.
function onOverlayUpdate()
if playerHit and spriteIsPlaying(enemyWalk) then
spritePause(enemyWalk)
end
spriteDraw(enemyWalk, enemyX, enemyY, true)
return OVERLAY_UPDATED
end
----
[#spriteplay]
==== spritePlay
[source,text]
----
spritePlay(id)
----
Starts or resumes the animation from its current frame, timing the first step from the moment of the call. A sprite that is already playing is left alone, so the frame timer is not reset. A new sprite must be started with this call; loading alone does not animate it.
*Notes:* A non-looping animation that has reached its last frame stays on that frame when played again. To replay it, call `spriteSetFrame(id, 0)` first.
*Since:* 2.10
*See also:* <<spritepause,spritePause>>, <<spritesetframe,spriteSetFrame>>, <<spriteloop,spriteLoop>>
.Example
[source,lua]
----
-- Restart the one-shot explosion at the point of impact.
function onCollision(a, b, x, y, z, speed)
if speed > IMPACT_SPEED then
blastX = x
blastY = y
spriteSetFrame(explosion, 0)
spritePlay(explosion)
soundPlay(boom)
end
end
----
[#spritequality]
==== spriteQuality
[source,text]
----
spriteQuality(id, mode)
----
Chooses the sampling used when the sprite is rebuilt for rotation or scaling. `RENDER_PIXELATED` keeps hard pixel edges, `RENDER_SMOOTH` interpolates. New sprites are pixelated. The setting only matters while a rotation or a scale other than `1.0` is in effect; an untransformed sprite is drawn straight from its source image, and the stretched forms of `spriteDraw` always use nearest-neighbor sampling. Changing the mode rebuilds the transformed image at once, so set it once after loading rather than every frame.
*Parameters:*
* `id` -- sprite handle.
* `mode` -- `RENDER_PIXELATED` (`0`) or `RENDER_SMOOTH` (`1`). Any nonzero number selects smooth.
*Since:* 2.10
*See also:* <<spritescale,spriteScale>>, <<spriterotate,spriteRotate>>, <<spriterotateandscale,spriteRotateAndScale>>
.Example
[source,lua]
----
-- Photographic marquee art should interpolate; pixel art must stay crisp.
marqueeSprite = spriteLoad(DIR .. "marquee.jpg")
playerSprite = spriteLoad(DIR .. "player.png")
spriteQuality(marqueeSprite, RENDER_SMOOTH)
spriteQuality(playerSprite, RENDER_PIXELATED)
spriteScale(marqueeSprite, 0.5)
spriteScale(playerSprite, 3)
----
[#spriterotate]
==== spriteRotate
[source,text]
----
spriteRotate(id, degrees)
----
Rotates the sprite clockwise about its center. The angle is reduced modulo 360 and may be fractional or negative. The current scale is kept. The rotated image is rebuilt only when the angle actually changes, so repeating the same angle every frame costs nothing, while a new angle every frame rebuilds every frame. Rotation enlarges the bounding box, so `spriteGetWidth` and `spriteGetHeight` change and the centered form of `spriteDraw` is the one to use.
*Since:* 2.10
*See also:* <<spriterotateandscale,spriteRotateAndScale>>, <<spritedraw,spriteDraw>>, <<spritequality,spriteQuality>>
.Example
[source,lua]
----
-- Crates from a 2D physics scene, drawn at the angle of their bodies.
function onOverlayUpdate()
overlayClear()
for _, crate in ipairs(crates) do
local x, y = nodeGetPosition(crate)
local _, _, angle = nodeGetRotation(crate)
spriteRotate(crateSprite, angle)
spriteDraw(crateSprite, x, y, true)
end
return OVERLAY_UPDATED
end
----
[#spriterotateandscale]
==== spriteRotateAndScale
[source,text]
----
spriteRotateAndScale(id, degrees, scale)
spriteRotateAndScale(id, degrees, scaleX, scaleY)
----
Sets rotation and scale together. The result is the same as `spriteRotate` followed by `spriteScale`, but the transformed image is rebuilt once instead of twice, and not at all when nothing changed. Scale factors must be greater than `0`; a zero or negative factor cannot be rendered and ends the game with an error.
*Parameters:*
* `id` -- sprite handle.
* `degrees` -- clockwise rotation, reduced modulo 360.
* `scale` -- uniform factor, `1.0` for natural size.
* `scaleX`, `scaleY` -- separate horizontal and vertical factors.
*Since:* 2.10
*See also:* <<spriterotate,spriteRotate>>, <<spritescale,spriteScale>>, <<spritequality,spriteQuality>>
.Example
[source,lua]
----
-- A coin that spins and shrinks as it flies toward the score counter.
function onOverlayUpdate()
if coinFlying then
coinT = math.min(coinT + 0.02, 1)
spriteRotateAndScale(coinSprite, coinT * 720, 1 - coinT * 0.75)
spriteDraw(coinSprite, coinX + (SCORE_X - coinX) * coinT, coinY + (SCORE_Y - coinY) * coinT, true)
coinFlying = coinT < 1
end
return OVERLAY_UPDATED
end
----
[#spritescale]
==== spriteScale
[source,text]
----
spriteScale(id, scale)
spriteScale(id, scaleX, scaleY)
----
Scales the sprite about its center, uniformly or per axis, keeping the current rotation. Factors must be greater than `0`; `1.0` is natural size, and a factor of exactly `1.0` on both axes with no rotation draws the source image directly. The scaled image is rebuilt only when a factor changes, using the sampling chosen by `spriteQuality`. Prefer the stretched form of `spriteDraw` when the target size changes every frame, since it does not rebuild anything.
*Parameters:*
* `id` -- sprite handle.
* `scale` -- uniform factor.
* `scaleX`, `scaleY` -- separate horizontal and vertical factors.
*Since:* 2.10
*See also:* <<spriterotateandscale,spriteRotateAndScale>>, <<spritequality,spriteQuality>>, <<spritedraw,spriteDraw>>
.Example
[source,lua]
----
-- Enlarge the highlighted menu entry; the rebuild happens only when the selection moves.
function onOverlayUpdate()
for i, label in ipairs(menuLabels) do
if i == selected then
spriteScale(label, 1.25)
else
spriteScale(label, 1)
end
spriteDraw(label, MENU_X, MENU_Y + i * LINE_HEIGHT, true)
end
return OVERLAY_UPDATED
end
----
[#spritesetframe]
==== spriteSetFrame
[source,text]
----
spriteSetFrame(id, frame)
----
Jumps an animation to the given frame and restarts that frame's delay timer, so the frame is shown for its full duration before the next one. Playback state is not changed: a paused sprite stays paused on the new frame and a playing one continues from it. The call is ignored for still images, for frames outside `0` to the last frame, and when the sprite already shows that frame.
*Since:* 2.10
*See also:* <<spritegetframe,spriteGetFrame>>, <<spriteplay,spritePlay>>
.Example
[source,lua]
----
-- Show the door in the state the game logic says it is in.
function onOverlayUpdate()
if doorOpen then
spriteSetFrame(doorSprite, DOOR_OPEN_FRAME)
else
spriteSetFrame(doorSprite, DOOR_CLOSED_FRAME)
end
spriteDraw(doorSprite, DOOR_X, DOOR_Y)
return OVERLAY_UPDATED
end
----
[#spriteunload]
==== spriteUnload
[source,text]
----
spriteUnload(id)
----
Frees the sprite: the source image, the transformed copy if one exists, and all animation frames. The handle is invalid afterward and any further use ends the script with an error. Sprites made by `fontToSprite` are released the same way. Sprites still loaded when the game ends are freed by the engine, but games that create sprites during play (text re-rendered as scores change, for instance) must unload the old ones or memory grows without bound.
*Since:* 2.00
*See also:* <<spriteload,spriteLoad>>, <<fonttosprite,fontToSprite>>, <<onshutdown,onShutdown>>
.Example
[source,lua]
----
-- Re-render the score only when it changes, releasing the previous sprite.
function updateScoreSprite()
if scoreSprite ~= nil then
spriteUnload(scoreSprite)
end
scoreSprite = fontToSprite("SCORE " .. score)
end
function onShutdown()
spriteUnload(scoreSprite)
spriteUnload(cabinetSprite)
end
----
[#terrain]
=== Terrain
Heightmap ground made with `meshHeightmap`, which turns a grayscale image into a mesh one vertex per pixel; `terrainGetHeight` reads that mesh back through the node carrying it. Positions and heights are in world units, and the node's position and scale are taken into account. See Terrain in <<scenes3d,3D Scenes>>.
[#terraingetheight]
==== terrainGetHeight
[source,text]
----
height = terrainGetHeight(node, x, z)
----
The world height of the heightmap mesh on `node` at world `x, z`, interpolated between the nearest four samples, or `nil` beyond its edges or when the node's mesh is not a heightmap. The node's world position and scale count; its rotation is ignored, since terrains lie flat. Use it for placing trees, spawning things on the ground, or a camera that follows the surface, without the cost of a ray cast. Raises an error for a node handle that is not valid.
*Parameters:*
* `node` -- the node whose mesh came from `meshHeightmap`.
* `x, z` -- the world position to sample.
*Returns:* A number, or `nil` off the terrain.
*Since:* 3.00.
*See also:* <<meshheightmap,meshHeightmap>>, <<physicsraycast,physicsRaycast>>, <<nodesetposition,nodeSetPosition>>
.Example
[source,lua]
----
-- Trees on the hills, and a camera that never dips below the ground.
for i = 1, 40 do
local x = math.random(-50, 50)
local z = math.random(-50, 50)
local tree = modelInstance(treeModel)
nodeSetPosition(tree, x, terrainGetHeight(hills, x, z) or 0, z)
end
function onOverlayUpdate()
local cx, cy, cz = nodeGetWorldPosition(camera)
local ground = terrainGetHeight(hills, cx, cz)
if ground and cy < ground + 1.5 then
nodeSetPosition(camera, cx, ground + 1.5, cz)
end
return OVERLAY_UPDATED
end
----
[#vehicle]
=== Vehicle
Cars, motorcycles, tanks and boats built on a chassis body (see <<physics,Physics>>, Vehicles and Water). A vehicle has no handle of its own: it is addressed by its chassis node, which must already carry a `BODY_DYNAMIC` body from `bodyNew`, and a node without a vehicle terminates the script in every call after `vehicleNew`. The chassis faces -Z with Y up; distances are world units, forces newtons, torques newton meters and engine speeds revolutions a minute. Every tuning call marks the vehicle for a rebuild at the next physics step, around the wheel positions recorded when the wheels were added. Up to 16 vehicles at once, each with up to 16 wheels.
[#vehiclenew]
==== vehicleNew
[source,text]
----
vehicleNew(node, kind)
----
Turns the dynamic body on `node` into the chassis of a vehicle. A car, motorcycle or tank needs at least two wheels from `vehicleAddWheel` before the next physics step builds it; a boat has no wheels and is pushed by a propeller that only works under a surface made with `bodySetWater`. The new vehicle starts from the defaults: a 500 Nm engine peaking at 6000 rpm and idling at 1000, Jolt's five-speed automatic gearbox, 35 degrees of steering lock, 1500 Nm brakes and a 4000 Nm hand brake, 1.5 Hz suspension at 0.5 damping and no anti-roll bars. Calling it again on the same node discards the old vehicle and its wheels first. Raises an error for an unknown kind, a node whose body is not dynamic, a wheeled kind in a 2D world, or when 16 vehicles already exist.
*Parameters:*
* `node` -- the chassis node, carrying a `BODY_DYNAMIC` body.
* `kind` -- `VEHICLE_CAR` (any number of axles), `VEHICLE_MOTORCYCLE` (two wheels in line, leaning through turns), `VEHICLE_TANK` (wheels on two tracks, turning on the spot) or `VEHICLE_BOAT` (a buoyant hull with a propeller and a rudder).
*Since:* 3.00.
*See also:* <<vehicleaddwheel,vehicleAddWheel>>, <<vehicledrive,vehicleDrive>>, <<vehiclesetthrust,vehicleSetThrust>>, <<bodynew,bodyNew>>
.Example
[source,lua]
----
local car = nodeNew()
nodeSetMesh(car, meshBox(1.8, 0.5, 4.0), paint)
nodeSetPosition(car, 0, 0.9, 0)
bodyNew(car, BODY_DYNAMIC, SHAPE_BOX, 1.8, 0.5, 4.0)
bodySetMass(car, 1500)
vehicleNew(car, VEHICLE_CAR)
for _, p in ipairs({ { -0.95, -0.2, -1.4 }, { 0.95, -0.2, -1.4 }, { -0.95, -0.2, 1.4 }, { 0.95, -0.2, 1.4 } }) do
local hub = nodeNew()
nodeSetParent(hub, car)
nodeSetPosition(hub, p[1], p[2], p[3])
vehicleAddWheel(car, hub, 0.35, 0.25, 0.4)
end
----
[#vehicledelete]
==== vehicleDelete
[source,text]
----
vehicleDelete(node)
----
Removes the vehicle from its chassis: the wheel constraint, the driver input and all the tuning. The body stays an ordinary dynamic body, so a wreck keeps sliding and tumbling, and the wheel nodes stay where the last step posed them; nothing moves them again unless the script does. A node without a vehicle terminates the script.
*Since:* 3.00.
*See also:* <<vehiclenew,vehicleNew>>, <<bodydelete,bodyDelete>>
.Example
[source,lua]
----
function onCollision(a, b, x, y, z, speed)
if (a == car or b == car) and speed > 25 and not wrecked then
wrecked = true
vehicleDelete(car) -- The chassis tumbles on as a body
soundPlay(crashSound)
emitterStart(smoke)
end
end
----
[#vehicleaddwheel]
==== vehicleAddWheel
[source,text]
----
index = vehicleAddWheel(node, wheelNode, radius, width, suspension)
----
Adds a wheel to the vehicle where `wheelNode` stands relative to the chassis when the vehicle is next built. The suspension hangs the wheel `suspension` units below that point when unloaded and compresses to half that. From then on the engine writes the wheel node's world transform after every step, with X as the axle and Y up, so the visible tire belongs on a child node: a `meshCylinder` turned 90 degrees about Z. Wheels ahead of the average wheel position along the nose count as front wheels: they steer by default and carry no hand brake. A tank sorts its wheels onto the left or right track by which side of the chassis they sit on and needs at least one on each side. Values below `0.001` are raised to it. Raises an error at the 17th wheel.
*Parameters:*
* `node` -- the chassis node.
* `wheelNode` -- the node the wheel hangs from; its position relative to the chassis is the attachment point, and the engine poses it from now on.
* `radius` -- the wheel's radius, in world units.
* `width` -- the wheel's width, in world units.
* `suspension` -- the suspension's length at full extension, in world units; it compresses to half.
*Returns:* The wheel's index, from `0`, for `vehicleSetWheel`, `vehicleIsWheelOnGround` and `vehicleGetWheelSlip`.
*Notes:* A vehicle is only built once it has two wheels, and a car or motorcycle also needs a driven wheel; until then it sits as a plain body and the drive calls do nothing.
*Since:* 3.00.
*See also:* <<vehiclesetwheel,vehicleSetWheel>>, <<vehiclesetsuspension,vehicleSetSuspension>>, <<nodefind,nodeFind>>
.Example
[source,lua]
----
-- The truck model carries empty nodes named for its wheels.
local truck = modelInstance(truckModel)
local wheels = {}
nodeSetPosition(truck, 0, 1.2, -8)
bodyNew(truck, BODY_DYNAMIC, SHAPE_BOX, 2.4, 1.4, 6.0)
bodySetMass(truck, 4000)
vehicleNew(truck, VEHICLE_CAR)
for i, name in ipairs({ "wheelFL", "wheelFR", "wheelRL", "wheelRR" }) do
wheels[i] = vehicleAddWheel(truck, nodeFind(name, truck), 0.5, 0.35, 0.45)
end
----
[#vehiclesetthrust]
==== vehicleSetThrust
[source,text]
----
vehicleSetThrust(node, maxForce, x, y, z)
----
Sets a boat's propeller: the force it pushes with at full throttle, default `2000` newtons, and the point in the hull's frame it pushes at, default `0, -0.2, 1`, just below the deck at the stern. The push is along the hull's nose and only happens while that point is under a water surface; the same point decides whether the rudder and the brake act. A negative force becomes `0`. Wheeled kinds store the values and ignore them.
*Parameters:*
* `node` -- the hull node.
* `maxForce` -- the push at full throttle, in newtons.
* `x`, `y`, `z` -- where the propeller sits, in the hull's frame; the stern is +Z.
*Since:* 3.00.
*See also:* <<vehiclesetrudder,vehicleSetRudder>>, <<bodysetwater,bodySetWater>>, <<bodysetbuoyancy,bodySetBuoyancy>>
.Example
[source,lua]
----
local raft = nodeNew()
nodeSetMesh(raft, meshBox(1.6, 0.3, 2.4), wood)
nodeSetPosition(raft, 1, 0.3, 4)
bodyNew(raft, BODY_DYNAMIC, SHAPE_BOX, 1.6, 0.3, 2.4)
bodySetMass(raft, 120)
bodySetBuoyancy(raft, 3.0) -- Rides high on the pool
vehicleNew(raft, VEHICLE_BOAT)
vehicleSetThrust(raft, 600, 0, -0.1, 1.2) -- An outboard hung off the stern
vehicleSetRudder(raft, 300)
----
[#vehiclesetwheel]
==== vehicleSetWheel
[source,text]
----
vehicleSetWheel(node, index, steered, driven)
----
Says whether one wheel turns with the steering input and whether the engine drives it, replacing the defaults for that wheel: front wheels steer, and every wheel drives except a motorcycle's front one. Driven wheels pair up across the chassis into differentials, one per axle, and the engine's torque is shared equally between the axles; a driven wheel with no partner is an axle of its own. A tank ignores both flags, since its tracks drive every wheel and turn the hull. Takes effect at the next step's rebuild. Raises an error for an index the vehicle has no wheel for.
*Parameters:*
* `node` -- the chassis node.
* `index` -- the wheel's index from `vehicleAddWheel`.
* `steered` -- `true` for the wheel to turn up to `vehicleSetSteering` at full lock.
* `driven` -- `true` for the engine to turn it.
*Since:* 3.00.
*See also:* <<vehicleaddwheel,vehicleAddWheel>>, <<vehiclesetsteering,vehicleSetSteering>>, <<vehiclesetantiroll,vehicleSetAntiRoll>>
.Example
[source,lua]
----
-- A rear-wheel drive sports car: the front wheels only steer.
vehicleSetWheel(car, wheels[1], true, false)
vehicleSetWheel(car, wheels[2], true, false)
vehicleSetWheel(car, wheels[3], false, true)
vehicleSetWheel(car, wheels[4], false, true)
if fourWheelSteer then
vehicleSetWheel(car, wheels[3], true, true)
vehicleSetWheel(car, wheels[4], true, true)
end
----
[#vehiclesetengine]
==== vehicleSetEngine
[source,text]
----
vehicleSetEngine(node, maxTorque, maxRpm)
vehicleSetEngine(node, maxTorque, maxRpm, minRpm)
----
Sets the engine: peak torque in newton meters, the redline and the idle speed in revolutions a minute. Defaults are `500`, `6000` and `1000`, and the idle is `1000` again when it is left out. The torque is kept at `1` or more, the redline at `100` or more and the idle between `1` and the redline. A boat has no engine and ignores the call. Takes effect at the next step's rebuild.
*Parameters:*
* `node` -- the chassis node.
* `maxTorque` -- peak torque, in newton meters.
* `maxRpm` -- the redline, in revolutions a minute.
* `minRpm` -- the idle speed, in revolutions a minute; default `1000`.
*Since:* 3.00.
*See also:* <<vehiclesetgears,vehicleSetGears>>, <<vehiclegetrpm,vehicleGetRpm>>
.Example
[source,lua]
----
vehicleSetEngine(tank, 1500, 4000, 800) -- A diesel: torque low down
vehicleSetEngine(bike, 150, 9000, 1200) -- A screamer
function onOverlayUpdate()
local rpm = vehicleGetRpm(bike)
overlayClear()
fontPrint(20, 20, string.format("%5.0f rpm", rpm))
if rpm > 8500 then
spriteDraw(shiftLight, 200, 20) -- Time to change up
end
return OVERLAY_UPDATED
end
----
[#vehiclesetgears]
==== vehicleSetGears
[source,text]
----
vehicleSetGears(node, { ratio, ... })
vehicleSetGears(node, { ratio, ... }, reverseRatio)
vehicleSetGears(node, { ratio, ... }, reverseRatio, automatic)
----
Sets the gearbox: one to eight forward ratios, first gear first, the reverse ratio (default `2.9`, given as a positive number) and whether the box shifts by itself (default `true`). A vehicle that never gets this call runs Jolt's five-speed, `2.66`, `1.78`, `1.3`, `1.0` and `0.74`, with the same reverse. With `automatic` false the box no longer shifts on its own, and there is no call to shift it from script. A boat has no gearbox and ignores the call. Takes effect at the next step's rebuild. Raises an error when the table is empty or holds more than eight ratios.
*Parameters:*
* `node` -- the chassis node.
* `{ ratio, ... }` -- forward gear ratios, engine turns per gearbox turn, first gear first; one to eight of them.
* `reverseRatio` -- the reverse gear's ratio; default `2.9`.
* `automatic` -- `true` (default) for the box to shift by itself.
*Since:* 3.00.
*See also:* <<vehiclegetgear,vehicleGetGear>>, <<vehiclesetengine,vehicleSetEngine>>
.Example
[source,lua]
----
-- A close-ratio six-speed for the race car, a tall three-speed for the bus.
vehicleSetGears(raceCar, { 3.2, 2.3, 1.8, 1.4, 1.1, 0.9 }, 3.0)
vehicleSetGears(bus, { 2.4, 1.3, 0.8 })
function onOverlayUpdate()
overlayClear()
fontPrint(20, 20, string.format("Gear %d %.0f rpm", vehicleGetGear(raceCar), vehicleGetRpm(raceCar)))
return OVERLAY_UPDATED
end
----
[#vehiclesetrudder]
==== vehicleSetRudder
[source,text]
----
vehicleSetRudder(node, maxTorque)
----
Sets a boat's rudder: the torque about the hull's up axis at full steering input, default `800` newton meters. The rudder bites with speed, a fifth of the torque at rest and all of it from 3 world units a second, and it turns the other way while the boat moves astern. It only acts while the propeller point is under water. A negative torque becomes `0`; wheeled kinds ignore the call.
*Since:* 3.00.
*See also:* <<vehiclesetthrust,vehicleSetThrust>>, <<vehicledrive,vehicleDrive>>
.Example
[source,lua]
----
vehicleNew(patrolBoat, VEHICLE_BOAT)
vehicleSetThrust(patrolBoat, 4000, 0, -0.4, 3.5)
vehicleSetRudder(patrolBoat, 2500) -- Heavy hull, big rudder
function onOverlayUpdate()
local helm = controllerGetAxis(0, GAMEPAD_AXIS_LEFT_X) / 32767
local gas = controllerGetAxis(0, GAMEPAD_AXIS_RIGHT_TRIGGER) / 32767
vehicleDrive(patrolBoat, gas, helm, 0)
return OVERLAY_NOT_UPDATED
end
----
[#vehiclesetsuspension]
==== vehicleSetSuspension
[source,text]
----
vehicleSetSuspension(node, frequency, damping)
----
Sets every wheel's spring: its natural frequency in hertz (default `1.5`, kept at `0.1` or more; stiffer is higher) and its damping ratio (default `0.5`; `0` bounces, `1` settles without overshoot). Takes effect at the next step's rebuild, on every wheel at once; there is no per-wheel spring. A boat has no suspension and ignores the call.
*Parameters:*
* `node` -- the chassis node.
* `frequency` -- the spring's natural frequency, in hertz.
* `damping` -- the damping ratio, `0` upward.
*Since:* 3.00.
*See also:* <<vehicleaddwheel,vehicleAddWheel>>, <<vehiclesetantiroll,vehicleSetAntiRoll>>
.Example
[source,lua]
----
-- The buggy floats over the dunes; the race car barely moves on its springs.
vehicleSetSuspension(buggy, 1.0, 0.3)
vehicleSetSuspension(raceCar, 3.0, 0.7)
if surface == "gravel" then
vehicleSetSuspension(raceCar, 2.0, 0.5)
end
----
[#vehiclesetsteering]
==== vehicleSetSteering
[source,text]
----
vehicleSetSteering(node, maxDegrees)
----
Sets how far the steered wheels turn at full lock, in degrees; default `35`, clamped to `0` through `89`. A steering input of `-1` or `1` from `vehicleDrive` reaches this angle, and smaller inputs a proportion of it. Takes effect at the next step's rebuild. A tank turns by its tracks and a boat by its rudder, so both ignore the call.
*Since:* 3.00.
*See also:* <<vehiclesetwheel,vehicleSetWheel>>, <<vehicledrive,vehicleDrive>>
.Example
[source,lua]
----
-- The go-kart turns on a dime; the truck needs a wide arc.
vehicleSetSteering(kart, 45)
vehicleSetSteering(truck, 25)
vehicleSetSteering(bus, 20)
if difficulty == "arcade" then
vehicleSetSteering(truck, 35) -- Forgiving handling
end
----
[#vehiclesetbrakes]
==== vehicleSetBrakes
[source,text]
----
vehicleSetBrakes(node, brakeTorque, handBrakeTorque)
----
Sets the brake torque every wheel can apply at full pedal, default `1500` newton meters, and the hand brake torque on the rear wheels, default `4000`; the front wheels, those ahead of the average wheel position, have no hand brake. Negative values become `0`. A tank brakes its tracks with Jolt's track defaults and ignores the call, and a boat brakes by dragging against its own velocity, so it ignores the call too. Takes effect at the next step's rebuild.
*Parameters:*
* `node` -- the chassis node.
* `brakeTorque` -- foot brake torque on every wheel, in newton meters.
* `handBrakeTorque` -- hand brake torque on the rear wheels, in newton meters.
*Since:* 3.00.
*See also:* <<vehicledrive,vehicleDrive>>, <<vehiclegetwheelslip,vehicleGetWheelSlip>>
.Example
[source,lua]
----
vehicleSetBrakes(car, 2200, 6000) -- Race pads and a drift-ready hand brake
function onOverlayUpdate()
local brake = controllerGetAxis(0, GAMEPAD_AXIS_LEFT_TRIGGER) / 32767
local handBrake = controllerGetButton(0, GAMEPAD_0.BUTTON_B.value) and 1 or 0
vehicleDrive(car, throttle(), steering(), brake, handBrake)
return OVERLAY_NOT_UPDATED
end
----
[#vehiclesetantiroll]
==== vehicleSetAntiRoll
[source,text]
----
vehicleSetAntiRoll(node, stiffness)
----
Adds an anti-roll bar across each axle, in newtons a meter; `0` (default) fits none and a negative value becomes `0`. A bar joins the two driven wheels of an axle, so an axle whose wheels do not drive gets no bar, and a motorcycle has none. A few hundred keeps a car flat in corners; too much lifts the inside wheels. A tank ignores the call. Takes effect at the next step's rebuild.
*Since:* 3.00.
*See also:* <<vehiclesetsuspension,vehicleSetSuspension>>, <<vehiclesetwheel,vehicleSetWheel>>
.Example
[source,lua]
----
local car = nodeNew()
nodeSetMesh(car, meshBox(1.8, 0.5, 4.0), paint)
nodeSetPosition(car, 0, 0.9, 0)
bodyNew(car, BODY_DYNAMIC, SHAPE_BOX, 1.8, 0.5, 4.0)
bodySetMass(car, 1500)
vehicleNew(car, VEHICLE_CAR)
addFourWheels(car)
vehicleSetEngine(car, 600, 6500, 1000)
vehicleSetAntiRoll(car, 800) -- Stays flat through the chicane
----
[#vehicledrive]
==== vehicleDrive
[source,text]
----
vehicleDrive(node, forward, right)
vehicleDrive(node, forward, right, brake)
vehicleDrive(node, forward, right, brake, handBrake)
----
Sets the driver's input, which holds until the next call, so call it every frame from `onOverlayUpdate`. Throttle and steering run `-1` to `1`, brake and hand brake `0` to `1`; values outside are clamped and inputs left out are `0`. A car or motorcycle gets all four as Jolt's driver input, a negative throttle reversing through the reverse gear. A tank takes `forward` as its throttle and `right` slows the inside track; with no throttle the tracks run against each other so the hull pivots on the spot, and the hand brake is ignored. A boat takes `forward` as the propeller's throttle, `right` as the rudder and `brake` as a drag against the hull's velocity, ignores the hand brake, and does nothing at all while the propeller is out of the water. Any non-zero input wakes a chassis that has gone to sleep.
*Parameters:*
* `node` -- the chassis node.
* `forward` -- throttle, `-1` to `1`; negative reverses.
* `right` -- steering, `-1` (left) to `1` (right).
* `brake` -- foot brake, `0` to `1`; default `0`.
* `handBrake` -- hand brake, `0` to `1`; default `0`.
*Since:* 3.00.
*See also:* <<vehiclegetspeed,vehicleGetSpeed>>, <<vehiclesetbrakes,vehicleSetBrakes>>, <<controllergetaxis,controllerGetAxis>>
.Example
[source,lua]
----
function onOverlayUpdate()
local stick = controllerGetAxis(0, GAMEPAD_AXIS_LEFT_X)
local steer = (math.abs(stick) > SINGE_DEAD_ZONE) and stick / 32767 or 0
local gas = controllerGetAxis(0, GAMEPAD_AXIS_RIGHT_TRIGGER) / 32767
local brake = controllerGetAxis(0, GAMEPAD_AXIS_LEFT_TRIGGER) / 32767
local handBrake = controllerGetButton(0, GAMEPAD_0.BUTTON_B.value) and 1 or 0
if controllerGetButton(0, GAMEPAD_0.BUTTON_A.value) then
gas = -gas -- Reverse while A is held
end
vehicleDrive(car, gas, steer, brake, handBrake)
return OVERLAY_NOT_UPDATED
end
----
[#vehiclegetspeed]
==== vehicleGetSpeed
[source,text]
----
speed = vehicleGetSpeed(node)
----
The chassis body's velocity along its nose, in world units a second, negative while it moves astern; sideways motion in a drift does not count. Works for every kind, boats included, from the moment `vehicleNew` is called.
*Returns:* A number.
*Since:* 3.00.
*See also:* <<vehiclegetrpm,vehicleGetRpm>>, <<vehiclegetgear,vehicleGetGear>>, <<bodygetvelocity,bodyGetVelocity>>
.Example
[source,lua]
----
function onOverlayUpdate()
local speed = vehicleGetSpeed(car)
overlayClear()
fontPrint(20, 20, string.format("%3.0f km/h", math.abs(speed) * 3.6))
if speed < -1 then
spriteDraw(reversingLamp, 300, 20)
end
return OVERLAY_UPDATED
end
----
[#vehiclegetrpm]
==== vehicleGetRpm
[source,text]
----
rpm = vehicleGetRpm(node)
----
The engine's speed in revolutions a minute, between the idle and the redline from `vehicleSetEngine`, for a rev counter or the pitch of an engine loop. `0` for a boat, which has no engine, and for a vehicle that has not been built yet because it lacks wheels.
*Returns:* A number.
*Since:* 3.00.
*See also:* <<vehiclesetengine,vehicleSetEngine>>, <<vehiclegetgear,vehicleGetGear>>
.Example
[source,lua]
----
-- A rev counter bar that turns red past 6000.
function onOverlayUpdate()
local rpm = vehicleGetRpm(car)
overlayClear()
if rpm > 6000 then
colorForeground(255, 40, 40)
else
colorForeground(60, 220, 60)
end
overlayBox(20, 20, 20 + math.floor(rpm / 6500 * 200), 32)
return OVERLAY_UPDATED
end
----
[#vehiclegetgear]
==== vehicleGetGear
[source,text]
----
gear = vehicleGetGear(node)
----
The gear the box has engaged: `0` in neutral, `-1` in reverse, `1` upward for the forward gears in the order `vehicleSetGears` gave them. `0` for a boat and for a vehicle that has not been built yet.
*Returns:* An integer.
*Since:* 3.00.
*See also:* <<vehiclesetgears,vehicleSetGears>>, <<vehiclegetrpm,vehicleGetRpm>>
.Example
[source,lua]
----
local lastGear = 0
function onOverlayUpdate()
local gear = vehicleGetGear(car)
if gear ~= lastGear then
soundPlay(gearChange)
lastGear = gear
end
overlayClear()
fontPrint(20, 20, (gear == -1) and "R" or ((gear == 0) and "N" or tostring(gear)))
return OVERLAY_UPDATED
end
----
[#vehicleiswheelonground]
==== vehicleIsWheelOnGround
[source,text]
----
touching = vehicleIsWheelOnGround(node, index)
----
Whether that wheel's suspension cast found anything to press against this step. `false` for an index the vehicle has no wheel for and for a vehicle that has not been built yet; no error is raised for either.
*Returns:* True or false.
*Since:* 3.00.
*See also:* <<vehicleaddwheel,vehicleAddWheel>>, <<vehiclegetwheelslip,vehicleGetWheelSlip>>
.Example
[source,lua]
----
-- Airborne when no wheel touches; a landing after a jump shakes the camera.
function onOverlayUpdate()
local airborne = true
for i = 0, 3 do
airborne = airborne and not vehicleIsWheelOnGround(car, i)
end
if wasAirborne and not airborne then
cameraShake = 12
end
wasAirborne = airborne
return OVERLAY_NOT_UPDATED
end
----
[#vehiclegetwheelslip]
==== vehicleGetWheelSlip
[source,text]
----
slip = vehicleGetWheelSlip(node, index)
----
How much the wheel spins or skids along its rolling direction, `0` gripping to `1` spinning or locked, for tire squeal, smoke and skid marks. Always `0` for a tank's wheels, for an index the vehicle has no wheel for, and for a vehicle that has not been built yet.
*Returns:* A number.
*Since:* 3.00.
*See also:* <<vehicleiswheelonground,vehicleIsWheelOnGround>>, <<vehiclegetspeed,vehicleGetSpeed>>
.Example
[source,lua]
----
function onOverlayUpdate()
local slip = math.max(vehicleGetWheelSlip(car, 2), vehicleGetWheelSlip(car, 3))
if slip > 0.4 and vehicleIsWheelOnGround(car, 2) then
emitterStart(tireSmoke)
if not soundIsPlaying(squealChannel) then
squealChannel = soundPlay(squeal)
end
else
emitterStop(tireSmoke)
end
return OVERLAY_NOT_UPDATED
end
----
[#view]
=== View
A view is a second camera rendered to a texture every frame, for a monitor, a mirror or a portal in the scene: `viewNew` makes one and returns an integer handle, `viewSetCamera` points it at a node, and `materialSetView` shows it on a material. Up to four exist at once; each renders the whole scene again at its own size, in overlay-independent pixels, with the main camera's projection, the frame's shadows (a directional light's cascades included: each point takes the finest cascade that covers it) and no bloom, so keep them few and small. Every view turns billboards (`nodeSetBillboard`) to its own camera and sorts blended draws back to front from it, so a view whose camera looks from elsewhere still sees billboards face on and transparent objects in the right order. A bad handle raises an error. See <<scenes3d,3D Scenes>>.
[#viewdelete]
==== viewDelete
[source,text]
----
viewDelete(view)
----
Frees the view and its textures, and stops the extra render pass it cost. Materials showing it go back to their own texture. The handle is invalid afterwards and raises an error if used again; since only four views exist at once, delete the ones a level no longer needs.
*Since:* 3.00.
*See also:* <<viewnew,viewNew>>, <<materialsetview,materialSetView>>
.Example
[source,lua]
----
-- The security room's monitors only render while the player is in it.
function onEnterSecurityRoom()
feed = viewNew(512, 384)
viewSetCamera(feed, hallwayCamera)
materialSetView(monitorLook, feed)
end
function onLeaveSecurityRoom()
viewDelete(feed)
feed = nil
end
----
[#viewnew]
==== viewNew
[source,text]
----
view = viewNew(width, height)
----
Makes a camera rendered to a `width` by `height` texture every frame (each clamped to `1` to `4096`), from the default view until `viewSetCamera` names a node, and returns the view handle for `materialSetView`. The view uses the projection from `cameraSetPerspective` or `cameraSetOrthographic` at its own aspect ratio. The scene renders once more per view, so `512` by `384` is plenty for a monitor and a Raspberry Pi wants smaller. Raises an error when 3D is unavailable or four views are already in use.
*Parameters:*
* `width` -- the texture's width in pixels.
* `height` -- the texture's height in pixels.
*Returns:* The view handle, an integer.
*Since:* 3.00.
*See also:* <<viewsetcamera,viewSetCamera>>, <<materialsetview,materialSetView>>, <<viewdelete,viewDelete>>
.Example
[source,lua]
----
-- A security monitor showing a camera behind the player.
eye = nodeNew()
nodeSetPosition(eye, 0, 2.2, -5)
nodeLookAt(eye, 0, 0.6, 0)
feed = viewNew(512, 384)
viewSetCamera(feed, eye)
screen = materialNew()
materialSetView(screen, feed)
materialSetUnlit(screen, true)
nodeSetMesh(monitor, meshPlane(2, 1.5), screen)
nodeSetRotation(monitor, 90, 0, 0)
----
[#viewsetcamera]
==== viewSetCamera
[source,text]
----
viewSetCamera(view, node)
viewSetCamera(view, nil)
viewSetCamera(view)
----
The node the view looks from, down its own -Z like the main camera, so its parents' movement counts. With `nil` or no second argument the view goes back to the default view from `(0, 0, 5)` looking at the origin. Parent the node to a car for a rear-view mirror, or reflect the main camera's position about a wall for a mirror in it. Raises an error for a node handle that is not valid.
*Since:* 3.00.
*See also:* <<viewnew,viewNew>>, <<cameraset,cameraSet>>, <<nodelookat,nodeLookAt>>
.Example
[source,lua]
----
-- The lobby monitor cycles through three security cameras.
cameras = { lobbyCamera, garageCamera, roofCamera }
current = 1
function onOverlayUpdate()
if singeGetTicks() - lastSwitch > 4000 then
current = current % #cameras + 1
lastSwitch = singeGetTicks()
viewSetCamera(feed, cameras[current])
end
return OVERLAY_UPDATED
end
----
[#video]
=== Video
The `video*` family handles additional video clips on top of the disc: a character talking in a corner, a pre-rendered animation at a story beat, several concurrent cues. Any number of videos can be loaded with `videoLoad`, and each returns an opaque integer handle that every other `video*` function takes as its first argument; a handle that has been unloaded terminates the script. Videos are drawn onto the overlay with `videoDraw` in overlay coordinates and composite like sprites, and a video advances only while it is being drawn (or used as a material through <<materialsetvideo,materialSetVideo>>). Each video has its own audio track selection and stereo volume, independent of the disc. Like sprites, videos must be drawn from `onOverlayUpdate` and eventually freed with `videoUnload`; the drawing rules are in <<overlay,Overlay>>.
[#videodraw]
==== videoDraw
[source,text]
----
videoDraw(id, x, y, x2, y2)
videoDraw(id, x, y, centered)
----
Advances a video to the frame its clock calls for and draws that frame on the overlay. The clock follows the video's audio when it has any and wall time otherwise, so a call may advance zero, one or several frames; a video that reaches its end loops back to frame `0`. The five argument form stretches the frame into the rectangle from `(x, y)` to `(x2, y2)` inclusive, always with nearest-neighbor sampling and ignoring `videoScale`, `videoRotate` and `videoQuality`. The four argument form draws the frame at its native size transformed by `videoScale` and `videoRotate`, anchored at its top left corner or, when `centered` is `true`, at its center; the transformed frame is rebuilt only when the frame or the transform changes, and rotated corners are transparent. If the decoder has not produced a frame yet nothing is drawn.
*Parameters:*
* `id` -- video handle from `videoLoad`.
* `x`, `y` -- anchor in overlay coordinates.
* `x2`, `y2` -- opposite corner of the destination rectangle, inclusive.
* `centered` -- boolean. `true` places the center of the drawn frame at `(x, y)`, `false` its top left corner. Use `true` for rotated videos.
*Notes:* A video that is not drawn does not advance, so pausing is implicit while a clip is hidden. Draw from `onOverlayUpdate` only.
*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]
----
-- Attract clip in a fixed box, looping over its own start and end frames.
function onOverlayUpdate()
overlayClear()
videoDraw(attractVideo, VIDEO_X, VIDEO_Y, VIDEO_X + VIDEO_W - 1, VIDEO_Y + VIDEO_H - 1)
if videoGetFrame(attractVideo) > ATTRACT_END then
videoSeek(attractVideo, ATTRACT_START)
end
return OVERLAY_UPDATED
end
----
[#videogetaudiotrack]
==== videoGetAudioTrack
[source,text]
----
track = videoGetAudioTrack(id)
----
Returns the zero based index of the audio track a loaded video is playing. `videoLoad` selects the track named by `--audio` or the `AUDIO_TRACK` entry in `games.dat` (default `0`) when the file has that many tracks; a video with no audio at all reports `-1`.
*Returns:* integer track index.
*Since:* 2.10
*See also:* <<videosetaudiotrack,videoSetAudioTrack>>, <<videogetaudiotracks,videoGetAudioTracks>>, <<videogetlanguage,videoGetLanguage>>
.Example
[source,lua]
----
-- Show which dub the cutscene is using in the options overlay.
function drawAudioOption()
local track = videoGetAudioTrack(cutscene)
local label = "None"
if track >= 0 then
label = videoGetLanguageDescription(videoGetLanguage(cutscene, track))
end
overlayPrint(OPTION_X, OPTION_Y, "Cutscene audio: " .. label)
end
----
[#videogetaudiotracks]
==== videoGetAudioTracks
[source,text]
----
count = videoGetAudioTracks(id)
----
Returns how many audio streams a loaded video contains. A silent clip reports `0`; guard loops and modulo arithmetic against that.
*Returns:* integer count, `0` or more.
*Since:* 2.10
*See also:* <<videogetlanguage,videoGetLanguage>>, <<videosetaudiotrack,videoSetAudioTrack>>, <<videogetaudiotrack,videoGetAudioTrack>>
.Example
[source,lua]
----
-- Only offer the language menu when the cutscene actually has choices.
cutscene = videoLoad("videos/briefing.mkv")
if videoGetAudioTracks(cutscene) > 1 then
buildLanguageMenu(cutscene)
else
languageMenuEnabled = false
end
----
[#videogetframe]
==== videoGetFrame
[source,text]
----
frame = videoGetFrame(id)
----
Returns the frame a loaded video is positioned on, zero based. The position only changes while the video plays and is drawn, and it wraps to `0` when the clip loops. Compare it with your own start and end markers to loop a section, or with `videoGetFrameCount` to detect the end of a one-shot clip.
*Returns:* integer frame number.
*Since:* 2.00
*See also:* <<videoseek,videoSeek>>, <<videogetframecount,videoGetFrameCount>>, <<videodraw,videoDraw>>
.Example
[source,lua]
----
-- Return control to the player when the briefing has played through once.
function onOverlayUpdate()
if briefingRunning then
videoDraw(briefing, 0, 0, false)
if videoGetFrame(briefing) >= videoGetFrameCount(briefing) - 1 then
briefingRunning = false
videoPause(briefing)
end
end
return OVERLAY_UPDATED
end
----
[#videogetframecount]
==== videoGetFrameCount
[source,text]
----
count = videoGetFrameCount(id)
----
Returns the total number of frames in a loaded video, as counted from the file's frame table when it was indexed. Frames run from `0` to `count - 1`. Use it for progress bars, for wrapping a loop, or for detecting when a one-shot clip is almost done.
*Returns:* integer frame count.
*Since:* 2.00
*See also:* <<videogetframe,videoGetFrame>>, <<videoseek,videoSeek>>, <<videoisplaying,videoIsPlaying>>
.Example
[source,lua]
----
-- Progress line under a loading clip: an outline frame and a fill that grows.
function drawProgress()
local total = videoGetFrameCount(loadingClip)
local done = videoGetFrame(loadingClip) / (total - 1)
colorForeground(255, 255, 255, 255)
overlayBox(BAR_X - 1, BAR_Y - 1, BAR_X + BAR_W + 1, BAR_Y + 1)
overlayLine(BAR_X, BAR_Y, BAR_X + BAR_W * done, BAR_Y)
end
----
[#videogetheight]
==== videoGetHeight
[source,text]
----
height = videoGetHeight(id)
----
Returns the height of a loaded video's frames in pixels, the native size of the file rather than the size it is drawn at. Combine it with `videoScale` to fit a clip into a box while keeping its aspect ratio.
*Returns:* integer height in pixels.
*Since:* 2.00
*See also:* <<videogetwidth,videoGetWidth>>, <<videoscale,videoScale>>
.Example
[source,lua]
----
-- Fit the clip into a 320x240 window without distorting it.
local fitX = 320 / videoGetWidth(clip)
local fitY = 240 / videoGetHeight(clip)
local fit = math.min(fitX, fitY)
videoScale(clip, fit)
----
[#videogetlanguage]
==== videoGetLanguage
[source,text]
----
code = videoGetLanguage(id, track)
----
Returns the language tag stored in the file for one audio track of a loaded video. The tag is a three letter ISO 639 code such as `"eng"` or `"jpn"`; a track without a three letter tag returns `"unk"`. A track outside `0` to `videoGetAudioTracks(id) - 1` terminates the script.
*Parameters:*
* `id` -- video handle.
* `track` -- zero based audio track index.
*Returns:* string language code.
*Since:* 2.10
*See also:* <<videogetlanguagedescription,videoGetLanguageDescription>>, <<videogetaudiotracks,videoGetAudioTracks>>, <<videosetaudiotrack,videoSetAudioTrack>>
.Example
[source,lua]
----
-- Match the cutscene's dub to the language chosen for the disc.
local wanted = discGetLanguage(discGetAudioTrack())
for track = 0, videoGetAudioTracks(cutscene) - 1 do
if videoGetLanguage(cutscene, track) == wanted then
videoSetAudioTrack(cutscene, track)
break
end
end
----
[#videogetlanguagedescription]
==== videoGetLanguageDescription
[source,text]
----
name = videoGetLanguageDescription(code)
----
Looks up the English name of a language code. Unlike the other `video*` functions it takes no handle; it is a pure table lookup that accepts ISO 639-1 two letter codes and ISO 639-2 three letter codes (both the T and B forms), compared without regard to case. Unrecognized codes, including the `"unk"` returned for untagged tracks, give `"Unknown"`. `Framework.singe` aliases it as `discGetLanguageDescription` for symmetry with the disc API.
*Parameters:*
* `code` -- language code such as `"en"`, `"eng"` or `"jpn"`.
*Returns:* string language name, or `"Unknown"`.
*Since:* 2.10
*See also:* <<videogetlanguage,videoGetLanguage>>, <<discgetlanguage,discGetLanguage>>
.Example
[source,lua]
----
-- Build a language-select menu from a video's tracks.
options = {}
for track = 0, videoGetAudioTracks(cutscene) - 1 do
local code = videoGetLanguage(cutscene, track)
local name = videoGetLanguageDescription(code)
table.insert(options, { track = track, label = name })
end
----
[#videogetvolume]
==== videoGetVolume
[source,text]
----
left, right = videoGetVolume(id)
----
Returns the stereo volume of a loaded video as two percentages. A freshly loaded video is at the `--volume_nonvldp` level on both channels (default `100`). Note that two values come back, left first.
*Returns:* two integers, left and right, each `0` to `100`.
*Since:* 2.00
*See also:* <<videosetvolume,videoSetVolume>>, <<videoload,videoLoad>>
.Example
[source,lua]
----
-- Duck the cutscene while a voice line plays, then restore it.
function playVoiceOver(sound)
savedLeft, savedRight = videoGetVolume(cutscene)
videoSetVolume(cutscene, savedLeft / 4, savedRight / 4)
soundPlay(sound)
duckingCutscene = true
end
----
[#videogetwidth]
==== videoGetWidth
[source,text]
----
width = videoGetWidth(id)
----
Returns the width of a loaded video's frames in pixels, the native size of the file rather than the size it is drawn at.
*Returns:* integer width in pixels.
*Since:* 2.00
*See also:* <<videogetheight,videoGetHeight>>, <<videoscale,videoScale>>
.Example
[source,lua]
----
-- Center a clip drawn at native size at the top of the overlay.
local x = (overlayGetWidth() - videoGetWidth(clip)) / 2
function onOverlayUpdate()
videoDraw(clip, x, 16, false)
return OVERLAY_UPDATED
end
----
[#videoisplaying]
==== videoIsPlaying
[source,text]
----
playing = videoIsPlaying(id)
----
Reports whether a loaded video is in the playing state, that is, whether `videoPlay` has been called since the last `videoPause`. A playing video still only advances while it is drawn. Videos start paused after `videoLoad`.
*Returns:* boolean.
*Since:* 2.00
*See also:* <<videoplay,videoPlay>>, <<videopause,videoPause>>, <<videogetframe,videoGetFrame>>
.Example
[source,lua]
----
-- One key toggles the picture-in-picture clip.
function onInputPressed(what)
if what == SWITCH_BUTTON2 then
if videoIsPlaying(pipVideo) then
videoPause(pipVideo)
else
videoPlay(pipVideo)
end
end
end
----
[#videoload]
==== videoLoad
[source,text]
----
id = videoLoad(filename)
----
Opens a video file and returns its handle. Any container and codec the bundled FFmpeg can decode is accepted. The engine builds or reads a frame index in a data directory named for the video's location under Singe's data root, so the first load of a large file can take a moment; failing to create that directory terminates the script. The video starts paused on frame `0`, with its audio track set from `--audio` or the `AUDIO_TRACK` entry in `games.dat` (default `0`) when the file has that many tracks, and both channels at the `--volume_nonvldp` level. Frames are decoded to RGB so they can be drawn on the overlay.
*Parameters:*
* `filename` -- path to the video file, relative to the game directory or inside a packed game.
*Returns:* integer video handle.
*Since:* 2.00
*See also:* <<videoplay,videoPlay>>, <<videodraw,videoDraw>>, <<videounload,videoUnload>>
.Example
[source,lua]
----
-- Load the attract clip for the selected game and cue its loop start.
function loadGameAssets()
attractVideo = videoLoad(GAME_LIST[GAME_SELECTED].ATTRACT)
if GAME_LIST[GAME_SELECTED].AUDIO_TRACK then
videoSetAudioTrack(attractVideo, GAME_LIST[GAME_SELECTED].AUDIO_TRACK)
end
videoPlay(attractVideo)
videoSeek(attractVideo, GAME_LIST[GAME_SELECTED].ATTRACT_START)
end
----
[#videopause]
==== videoPause
[source,text]
----
videoPause(id)
----
Pauses a loaded video. Its clock stops, its audio track pauses, and `videoDraw` keeps drawing the frame it stopped on. Pausing a video that is already paused is harmless.
*Since:* 2.00
*See also:* <<videoplay,videoPlay>>, <<videoisplaying,videoIsPlaying>>
.Example
[source,lua]
----
-- Freeze the attract clip while the calibration screen is up.
function calibrationBegin()
calibrating = true
if attractVideo then
videoPause(attractVideo)
end
end
----
[#videoplay]
==== videoPlay
[source,text]
----
videoPlay(id)
----
Starts or resumes a loaded video from its current frame. The clock is restarted at that frame, so a video resumed after a long pause continues where it stopped rather than jumping ahead, and the audio track resumes with it. The video only advances while it is drawn with `videoDraw` or used as a material.
*Since:* 2.00
*See also:* <<videopause,videoPause>>, <<videoseek,videoSeek>>, <<videodraw,videoDraw>>
.Example
[source,lua]
----
-- Start the briefing over the paused disc.
function startBriefing()
discPause()
videoSeek(briefing, 0)
videoPlay(briefing)
briefingRunning = true
end
----
[#videoquality]
==== videoQuality
[source,text]
----
videoQuality(id, smooth)
----
Selects the filtering used when the four argument form of `videoDraw` scales or rotates the video. Any nonzero value selects `RENDER_SMOOTH` (bilinear), zero selects `RENDER_PIXELATED` (nearest-neighbor), the default. A change marks the transformed frame for rebuilding on the next draw. It has no effect on the stretched five argument form, which always uses nearest-neighbor, nor on an untransformed video.
*Parameters:*
* `id` -- video handle.
* `smooth` -- `RENDER_PIXELATED` (`0`) or `RENDER_SMOOTH` (`1`).
*Since:* 2.10
*See also:* <<videoscale,videoScale>>, <<videorotate,videoRotate>>, <<videodraw,videoDraw>>
.Example
[source,lua]
----
-- A photographic clip shrunk into a corner looks better filtered.
pipVideo = videoLoad("videos/copilot.mkv")
videoScale(pipVideo, 0.4)
videoQuality(pipVideo, RENDER_SMOOTH)
videoPlay(pipVideo)
----
[#videorotate]
==== videoRotate
[source,text]
----
videoRotate(id, degrees)
----
Sets the rotation the four argument form of `videoDraw` applies, in degrees clockwise, using the same convention as `spriteRotate`. The angle is reduced modulo `360` and keeps its sign, so `450` and `90` are the same and `-90` rotates a quarter turn counterclockwise. The scale set by `videoScale` is kept. Rotation is applied about the frame's center, so draw rotated videos with `centered` set to `true`. A changed angle marks the transformed frame for rebuilding on the next draw.
*Parameters:*
* `id` -- video handle.
* `degrees` -- rotation in degrees, clockwise.
*Since:* 2.10
*See also:* <<videorotateandscale,videoRotateAndScale>>, <<videoscale,videoScale>>, <<videodraw,videoDraw>>
.Example
[source,lua]
----
-- Spin the radar feed slowly while it plays.
function onOverlayUpdate()
radarAngle = radarAngle + 0.5
videoRotate(radarVideo, radarAngle)
videoDraw(radarVideo, RADAR_CENTER_X, RADAR_CENTER_Y, true)
return OVERLAY_UPDATED
end
----
[#videorotateandscale]
==== videoRotateAndScale
[source,text]
----
videoRotateAndScale(id, degrees, scale)
videoRotateAndScale(id, degrees, scaleX, scaleY)
----
Sets rotation and scale in one call, with the same rules as `videoRotate` and `videoScale`: degrees clockwise reduced modulo `360`, and scale factors that must be greater than `0`. Use it when both change every frame so the transformed frame is rebuilt once rather than twice.
*Parameters:*
* `id` -- video handle.
* `degrees` -- rotation in degrees, clockwise.
* `scale` -- uniform scale factor, `1.0` for native size.
* `scaleX`, `scaleY` -- separate horizontal and vertical factors.
*Since:* 2.10
*See also:* <<videorotate,videoRotate>>, <<videoscale,videoScale>>, <<videodraw,videoDraw>>
.Example
[source,lua]
----
-- A clip tumbles and shrinks away as it is dismissed.
function onOverlayUpdate()
if dismissing then
dismissTime = dismissTime + 1
videoRotateAndScale(popupVideo, dismissTime * 6, 1.0 - dismissTime / 60)
videoDraw(popupVideo, POPUP_CENTER_X, POPUP_CENTER_Y, true)
end
return OVERLAY_UPDATED
end
----
[#videoscale]
==== videoScale
[source,text]
----
videoScale(id, scale)
videoScale(id, scaleX, scaleY)
----
Sets the scale the four argument form of `videoDraw` applies, as a factor of the native size; `1.0` is unscaled. The rotation set by `videoRotate` is kept. A changed scale marks the transformed frame for rebuilding on the next draw. Factors of `0` or less cannot be rendered and abort the engine on the next `videoDraw`, so clamp animated scales above zero. The stretched five argument form of `videoDraw` ignores this setting.
*Parameters:*
* `id` -- video handle.
* `scale` -- uniform scale factor, greater than `0`.
* `scaleX`, `scaleY` -- separate horizontal and vertical factors, each greater than `0`.
*Since:* 2.10
*See also:* <<videorotateandscale,videoRotateAndScale>>, <<videoquality,videoQuality>>, <<videogetwidth,videoGetWidth>>
.Example
[source,lua]
----
-- Show the cutscene at a quarter of its size in the corner while play continues.
pipVideo = videoLoad("videos/copilot.mkv")
videoScale(pipVideo, 0.25)
videoPlay(pipVideo)
function onOverlayUpdate()
videoDraw(pipVideo, PIP_X, PIP_Y, false)
return OVERLAY_UPDATED
end
----
[#videoseek]
==== videoSeek
[source,text]
----
videoSeek(id, frame)
----
Positions a loaded video on `frame` and restarts its clock there. The play or pause state is unchanged: a playing video continues from the new frame at its next draw, a paused one shows it. The frame number wraps modulo the frame count, so `-1` is the last frame and a value past the end lands back inside the clip.
*Parameters:*
* `id` -- video handle.
* `frame` -- target frame, zero based.
*Since:* 2.00
*See also:* <<videogetframe,videoGetFrame>>, <<videogetframecount,videoGetFrameCount>>, <<videoplay,videoPlay>>
.Example
[source,lua]
----
-- Loop the attract clip between the two markers from games.dat.
function onOverlayUpdate()
videoDraw(attractVideo, VIDEO_X, VIDEO_Y, VIDEO_X + VIDEO_W - 1, VIDEO_Y + VIDEO_H - 1)
if videoGetFrame(attractVideo) > GAME_LIST[GAME_SELECTED].ATTRACT_END then
videoSeek(attractVideo, GAME_LIST[GAME_SELECTED].ATTRACT_START)
end
return OVERLAY_UPDATED
end
----
[#videosetaudiotrack]
==== videoSetAudioTrack
[source,text]
----
videoSetAudioTrack(id, track)
----
Switches a loaded video to another of its audio tracks. Queued audio from the old track is dropped and playback realigns, so the change is heard almost immediately. Selecting the track already in use is a no-op. A track outside `0` to `videoGetAudioTracks(id) - 1` terminates the script, which for a silent clip means any value at all.
*Parameters:*
* `id` -- video handle.
* `track` -- zero based audio track index.
*Since:* 2.10
*See also:* <<videogetaudiotrack,videoGetAudioTrack>>, <<videogetaudiotracks,videoGetAudioTracks>>, <<videogetlanguage,videoGetLanguage>>
.Example
[source,lua]
----
-- The menu stores a per-game audio track for its attract clip.
attractVideo = videoLoad(GAME_LIST[GAME_SELECTED].ATTRACT)
local wanted = GAME_LIST[GAME_SELECTED].AUDIO_TRACK
if wanted and wanted < videoGetAudioTracks(attractVideo) then
videoSetAudioTrack(attractVideo, wanted)
end
videoPlay(attractVideo)
----
[#videosetvolume]
==== videoSetVolume
[source,text]
----
videoSetVolume(id, left, right)
----
Sets the stereo volume of a loaded video as percentages. Values are clamped to `0` through `100` without complaint. The setting is remembered even for a silent clip and is reported back by `videoGetVolume`.
*Parameters:*
* `id` -- video handle.
* `left`, `right` -- channel volumes, `0` (silent) to `100` (full).
*Since:* 2.00
*See also:* <<videogetvolume,videoGetVolume>>, <<discaudio,discAudio>>
.Example
[source,lua]
----
-- Fade the cutscene audio out over about a second as control returns.
function onOverlayUpdate()
if fadingOut then
fadeLevel = fadeLevel - 4
videoSetVolume(cutscene, fadeLevel, fadeLevel)
if fadeLevel <= 0 then
fadingOut = false
videoPause(cutscene)
end
end
return OVERLAY_UPDATED
end
----
[#videounload]
==== videoUnload
[source,text]
----
videoUnload(id)
----
Stops the decoder and releases everything a loaded video holds: decoder, audio track, cached frames and the transformed surface. The handle is invalid afterward and passing it to any `video*` function terminates the script, so clear the variable that held it. Videos still loaded when the script ends are released by the engine.
*Parameters:*
* `id` -- video handle.
*Since:* 2.00
*See also:* <<videoload,videoLoad>>, <<videopause,videoPause>>
.Example
[source,lua]
----
-- Drop the selected game's assets before loading the next one.
function unloadGameAssets()
if attractVideo then
videoUnload(attractVideo)
attractVideo = nil
end
end
----
[#vldp]
=== VLDP
The `vldp*` namespace predates Singe 2.00 and exists to keep older scripts running. `vldpGetHeight` and `vldpGetWidth` are the same bindings as `discGetHeight` and `discGetWidth`, `vldpSetVerbose` does nothing, and `vldpGetPixel` is the one member with no modern equivalent: it reads a pixel of the current disc frame at overlay coordinates and is still the standard way to do hit detection against pre-recorded video. Without a disc it returns black (see <<withoutadisc,Games Without a Disc>>).
WARNING: *Legacy.* Prefer <<discgetheight,discGetHeight>> and <<discgetwidth,discGetWidth>> in new code. `vldpGetPixel` remains current.
[#vldpgetheight]
==== vldpGetHeight
[source,text]
----
height = vldpGetHeight()
----
Returns the height of the disc's video in pixels, or the canvas height without a disc. This is the same binding as `discGetHeight` registered under its pre-2.00 name.
*Returns:* integer height in pixels.
*Notes:*
WARNING: *Legacy alias.* Use <<discgetheight,discGetHeight>> in new code.
*Since:* 1.x
*See also:* <<discgetheight,discGetHeight>>, <<vldpgetwidth,vldpGetWidth>>
.Example
[source,lua]
----
-- A 1.x script sizing its overlay from the disc.
overlaySetResolution(vldpGetWidth(), vldpGetHeight())
centerX = vldpGetWidth() / 2
centerY = vldpGetHeight() / 2
----
[#vldpgetpixel]
==== vldpGetPixel
[source,text]
----
r, g, b = vldpGetPixel(x, y)
----
Reads the color of one pixel of the disc frame currently on screen. The coordinates are overlay coordinates: the engine divides them by the overlay-to-video scale and truncates, so you pass the same numbers you draw with. Returns `0, 0, 0` when there is no disc, when no frame has been decoded yet, or when the scaled coordinates fall outside the video. Games that use it typically tag targets in the source footage with a distinctive color and test the pixel under the crosshair on a trigger pull instead of authoring hit boxes for every frame.
*Parameters:*
* `x`, `y` -- overlay coordinates; fractional values are accepted.
*Returns:* three integers, red, green and blue, each `0` to `255`.
*Since:* 1.x
*See also:* <<discgetframe,discGetFrame>>, <<mousegetposition,mouseGetPosition>>, <<overlaysetresolution,overlaySetResolution>>
.Example
[source,lua]
----
-- Lightgun hit check: targets are painted pure magenta on enemy frames.
function onInputPressed(what)
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 disc's video in pixels, or the canvas width without a disc. This is the same binding as `discGetWidth` registered under its pre-2.00 name.
*Returns:* integer width in pixels.
*Notes:*
WARNING: *Legacy alias.* Use <<discgetwidth,discGetWidth>> in new code.
*Since:* 1.x
*See also:* <<discgetwidth,discGetWidth>>, <<vldpgetheight,vldpGetHeight>>
.Example
[source,lua]
----
-- A 1.x script sizing its overlay from the disc.
overlaySetResolution(vldpGetWidth(), vldpGetHeight())
centerX = vldpGetWidth() / 2
centerY = vldpGetHeight() / 2
----
[#vldpsetverbose]
==== vldpSetVerbose
[source,text]
----
vldpSetVerbose(...)
----
Does nothing. It once toggled logging in the original video player; the call accepts any arguments, logs a trace line and returns. Engine and script tracing are now command line options (`--program` and `--trace`).
*Notes:*
WARNING: *Unimplemented.* Retained for backward compatibility only. Use `--trace` on the command line to log API calls.
*Since:* 1.x
*See also:* <<debugprint,debugPrint>>, <<discgetstate,discGetState>>
.Example
[source,lua]
----
-- Ported 1.x startup code. The call is ignored; run Singe with --trace instead.
vldpSetVerbose(true)
overlaySetResolution(discGetWidth(), discGetHeight())
discSearch(TITLE_FRAME)
----
[#enginecallbacks]
=== Engine Callbacks
These are functions the script defines and Singe calls when the matching event happens; all are optional, and an undefined one is skipped. Input callbacks fire as events are read, `onOverlayUpdate` runs at most once every 15 milliseconds, and none of them runs while the pause key holds the game frozen (see <<pausing,Pausing>>). An error inside a callback ends the game with a traceback. In the threaded model `singeMain` takes the place of most per-frame logic (see <<threaded,Threaded>>): the other callbacks still fire, but `Framework.singe` installs its own `onOverlayUpdate` to drive the `singeMain` coroutine, so a threaded game defines `singeMain`, `onShutdown` and whichever input callbacks it wants.
[#oncontrollermoved]
==== onControllerMoved
[source,text]
----
function onControllerMoved(axis, value, which)
end
----
Called for every axis motion event from a connected controller, including motion inside the dead zone, so a stick under a thumb fires many times a second. Directions past `SINGE_DEAD_ZONE` also act as switches when `controls.cfg` maps them, so a game that only needs digital steering can map `GAMEPAD_0.AXIS_LEFT_X_L` and `GAMEPAD_0.AXIS_LEFT_X_R` and ignore this callback. The value is stored for `controllerGetAxis` before the callback runs.
*Parameters:*
* `axis` -- `0` through `5`, matching the `GAMEPAD_AXIS_*` constants.
* `value` -- raw position, `-32768` to `32767`; triggers rest at `0`.
* `which` -- controller index, `0` through `3`, the index `controllerGetAxis` and the `GAMEPAD_N` tables use.
*See also:* <<controllergetaxis,controllerGetAxis>>, <<controllergetbutton,controllerGetButton>>
.Example
[source,lua]
----
-- Analog steering from the first controller's left stick only.
function onControllerMoved(axis, value, which)
if which ~= 0 or axis ~= GAMEPAD_AXIS_LEFT_X then
return
end
if math.abs(value) < SINGE_DEAD_ZONE then
steering = 0
else
steering = value / 32768
end
end
----
[#oninputpressedoninputreleased]
==== onInputPressed / onInputReleased
[source,text]
----
function onInputPressed(what)
end
function onInputReleased(what)
end
----
Called when a logical input goes down or up. What arrives depends on the keyboard mode. In `MODE_NORMAL`, `what` is a `SWITCH_*` value: keys, controller buttons, axis directions past the dead zone, mouse buttons and the mouse wheel all arrive this way, translated through `controls.cfg`, each press once with no key repeat, and inputs mapped to no switch are ignored. `SWITCH_PAUSE` arrives only after `singeSetPauseKeyEnabled(false)`; otherwise the engine freezes the game on that key and the script never sees it. In `MODE_FULL`, `what` is the keysym of the key (the character value, not the scancode), repeats are delivered, and controller and mouse buttons pass `0`; every event also reaches `onKeyPressed` and `onKeyReleased`, which carry the scancode or button code. When the pause key freezes the game every switch the script holds is released through `onInputReleased` first, and on thawing whatever is still physically down is pressed again, so a game never sees a stale button. Presses within the first second after the script starts, or after the window regains focus, are treated as held over from before and swallowed together with their release.
*See also:* <<keyboardsetmode,keyboardSetMode>>, <<onkeypressedonkeyreleased,onKeyPressed>>, <<singesetpausekeyenabled,singeSetPauseKeyEnabled>>, <<controllergetbutton,controllerGetButton>>
.Example
[source,lua]
----
function onInputPressed(what)
if what == SWITCH_COIN1 then
credits = credits + 1
soundPlay(coinClip)
elseif what == SWITCH_START1 and credits > 0 then
startGame()
elseif what == SWITCH_BUTTON3 then
fireShot(cursorX, cursorY)
elseif what == SWITCH_QUIT then
singeQuit()
end
end
function onInputReleased(what)
if what == SWITCH_BUTTON1 then
thrusting = false
end
end
----
[#onkeypressedonkeyreleased]
==== onKeyPressed / onKeyReleased
[source,text]
----
function onKeyPressed(keysym, scancode)
end
function onKeyReleased(keysym, scancode)
end
----
Called in `MODE_FULL` only, for every key going down or up, with both the logical keysym (the character the key produces on the current layout) and the physical scancode (the key's position, compared against the `SCANCODE` table). Held keys repeat in this mode, which is what text entry wants. Controller and mouse buttons come through as well, with `keysym` `0` and their `GAMEPAD_N` or `MOUSE_N` code as `scancode`. For text use `keysym`; for key-as-button controls use `scancode` so the binding is the same on every layout. Keyboard mappings of the engine's own switches do not act in this mode, so a game in `MODE_FULL` must provide its own way out.
*See also:* <<keyboardsetmode,keyboardSetMode>>, <<keyboardgetmodifiers,keyboardGetModifiers>>, <<oninputpressedoninputreleased,onInputPressed>>
.Example
[source,lua]
----
-- High score initials: text, so keysym; Return and Escape by position.
function onKeyPressed(keysym, scancode)
if scancode == SCANCODE.RETURN.value then
finishInitials()
elseif scancode == SCANCODE.BACKSPACE.value then
initials = initials:sub(1, -2)
elseif keysym >= string.byte("a") and keysym <= string.byte("z") and #initials < 3 then
initials = initials .. string.char(keysym):upper()
end
end
----
[#onmousemoved]
==== onMouseMoved
[source,text]
----
function onMouseMoved(x, y, xRelative, yRelative, which)
end
----
Called for every mouse motion event, with the position and the motion since the previous event in overlay coordinates. In `MOUSE_SINGLE` mode the window's pointer position is converted to overlay coordinates and `which` is always `0`. In `MOUSE_MANY` mode each device's relative motion is integrated into its own position, clamped to the video area, and `which` is the device index; an absolute device such as a tablet reports its mapped position with `xRelative` and `yRelative` of `0`. The position is stored for `mouseGetPosition` before the callback runs, so a game may ignore this callback and poll instead. Drawing must still wait for `onOverlayUpdate`: keep the latest position in globals here and draw the cursor there.
*Parameters:*
* `x`, `y` -- position in overlay coordinates.
* `xRelative`, `yRelative` -- motion since the previous event, in overlay coordinates.
* `which` -- mouse index, `0` through `3`.
*See also:* <<mousegetposition,mouseGetPosition>>, <<mousesetmode,mouseSetMode>>, <<mousesetenabled,mouseSetEnabled>>
.Example
[source,lua]
----
cursorX = overlayGetWidth() // 2
cursorY = overlayGetHeight() // 2
function onMouseMoved(x, y, xRelative, yRelative, which)
cursorX = x
cursorY = y
end
function onOverlayUpdate()
overlayClear()
spriteDraw(reticle, cursorX, cursorY, true)
return OVERLAY_UPDATED
end
----
[#oncollision]
==== onCollision
[source,text]
----
function onCollision(nodeA, nodeB, x, y, z, speed)
end
----
Called once for each new contact between two bodies after a physics step, never again for a pair that stays in touch. A crate landing, a ball hitting the cabinet, the player's car clipping a wall. Contacts with triggers report through `onTrigger` instead. The events of a step are delivered together after it, before the frame is drawn.
*Parameters:*
* `nodeA`, `nodeB` -- the two bodies' nodes.
* `x`, `y`, `z` -- where they touched, in world units.
* `speed` -- how fast they met, in world units per second.
*Since:* 3.00.
*See also:* <<ontrigger,onTrigger>>, <<bodynew,bodyNew>>
.Example
[source,lua]
----
-- Hard landings make noise where they happen.
function onCollision(nodeA, nodeB, x, y, z, speed)
if speed > 4 and (nodeA == crate or nodeB == crate) then
local channel = soundPlay(thudClip)
if channel >= 0 then
soundSetPosition(channel, x, y, z)
end
end
end
----
[#ontrigger]
==== onTrigger
[source,text]
----
function onTrigger(trigger, other, entered)
end
----
Called when a body enters or leaves a trigger made with `bodySetTrigger`, once on the way in and once on the way out, whatever the shapes involved (a mesh or compound shape touching in several places still counts as one body). Triggers never push anything, so this is the way to notice a body crossing a doorway, reaching a checkpoint or falling into a kill volume.
*Parameters:*
* `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.
*Since:* 3.00.
*See also:* <<bodysettrigger,bodySetTrigger>>, <<oncollision,onCollision>>
.Example
[source,lua]
----
function onTrigger(trigger, other, entered)
if trigger == finishLine and other == playerCar and entered then
lap = lap + 1
soundPlay(lapClip)
elseif trigger == pit and entered then
respawn(other)
end
end
----
[#onnavarrived]
==== onNavArrived
[source,text]
----
function onNavArrived(agent)
end
----
Called when a navigation agent reaches the target `navAgentMoveTo` gave it, once per arrival, after the navigation update of the frame. Send the agent somewhere else, or play its idle animation. A script that would rather poll can call `navAgentIsArrived` instead.
*Parameters:*
* `agent` -- the handle from `navAgentNew`.
*Since:* 3.00.
*See also:* <<navagentmoveto,navAgentMoveTo>>, <<navagentisarrived,navAgentIsArrived>>
.Example
[source,lua]
----
-- Patrol between waypoints forever.
function onNavArrived(agent)
local guard = guards[agent]
if guard then
guard.waypoint = guard.waypoint % #guard.route + 1
local next = guard.route[guard.waypoint]
navAgentMoveTo(agent, next.x, next.y, next.z)
end
end
----
[#onoverlayupdate]
==== onOverlayUpdate
[source,text]
----
function onOverlayUpdate()
return OVERLAY_UPDATED
end
----
The one callback where drawing is allowed. Singe calls it at most once every 15 milliseconds while the game is not frozen; any `overlay*`, `spriteDraw`, `videoDraw`, `fontPrint` or similar call made elsewhere will not appear on screen and may corrupt the overlay. Return `OVERLAY_UPDATED` (`1`) when the overlay changed so that Singe composites it over the video again, or `OVERLAY_NOT_UPDATED` (`0`) when nothing changed so the previous composite is reused; returning nothing counts as `OVERLAY_NOT_UPDATED`. The display is redrawn regardless whenever a new video frame arrives or a 3D scene is enabled, so the return value is an optimization for static screens. After it returns the engine clears the values behind `keyboardGetLastDown` and `keyboardGetLastUp`. In the threaded model `Framework.singe` defines this function itself to resume `singeMain` and always returns `OVERLAY_UPDATED`; do not define it as well.
*Returns:* `OVERLAY_UPDATED` or `OVERLAY_NOT_UPDATED`.
*See also:* <<singemain,singeMain>>, <<keyboardgetlastdown,keyboardGetLastDown>>, <<onshutdown,onShutdown>>
.Example
[source,lua]
----
function onOverlayUpdate()
if not hudDirty then
return OVERLAY_NOT_UPDATED
end
overlayClear()
fontPrint(20, 20, "SCORE " .. score)
fontPrint(20, 44, "LIVES " .. lives)
spriteDraw(reticle, cursorX, cursorY, true)
hudDirty = false
return OVERLAY_UPDATED
end
----
[#onshutdown]
==== onShutdown
[source,text]
----
function onShutdown()
end
----
Called once when the script ends, whether by `singeQuit`, the quit switch, the window closing, `scriptExecute` or `scriptPush`, after the last frame and before the Lua state is discarded. It is not called by `singeReload`. Save settings and free the sprites, sounds, fonts and videos the script loaded here; the engine stops every sound and frees whatever is left afterward, so a missed handle is not a leak, but explicit unloading keeps the shutdown order under the script's control. Do not call `singeQuit` from here.
*See also:* <<singequit,singeQuit>>, <<soundunload,soundUnload>>, <<scriptpush,scriptPush>>
.Example
[source,lua]
----
function onShutdown()
saveHighScores()
spriteUnload(reticle)
fontUnload(hudFont)
soundUnload(shotClip)
soundUnload(coinClip)
end
----
[#onsoundcompleted]
==== onSoundCompleted
[source,text]
----
function onSoundCompleted(channel)
end
----
Called with the channel number each time a channel stops: when its clip ends, when the last loop finishes, and when `soundStop` or `soundFullStop` halts it, so a script that chains sounds from here should know which channels it stopped itself. The mixer notes the stop on its own thread and the engine delivers the callback on the game thread between frames; while the pause key holds the game frozen the notices wait, and they are dropped when a script ends or reloads. Up to 64 completions are queued between frames; more than that in one frame are lost.
*Parameters:*
* `channel` -- the channel number the `soundPlay` call returned.
*See also:* <<soundplay,soundPlay>>, <<soundstop,soundStop>>, <<soundisplaying,soundIsPlaying>>
.Example
[source,lua]
----
-- Play the music playlist in order, forever.
function onSoundCompleted(channel)
if channel == musicChannel and not musicStopped then
track = track % #playlist + 1
musicChannel = soundPlay(playlist[track])
end
end
----
[#singemain]
==== singeMain
[source,text]
----
function singeMain()
while true do
singeYield()
end
end
----
The threaded model's entry point. When a script defines it, `Framework.singe` wraps it in a coroutine and installs an `onOverlayUpdate` that resumes the coroutine once per frame, so the function runs as a straight line of game logic that calls `singeYield()` (an alias of `coroutine.yield`) wherever it wants to let a frame pass. Everything drawn before a yield lands on that frame's overlay. Returning from `singeMain` calls `singeQuit`; an error inside it ends the game with a traceback. The input, sound, physics and navigation callbacks still fire between resumes, so a threaded game may mix them with polling.
*See also:* <<onoverlayupdate,onOverlayUpdate>>, <<keyboardgetlastdown,keyboardGetLastDown>>, <<singequit,singeQuit>>
.Example
[source,lua]
----
function singeMain()
showTitle()
while credits == 0 do
singeYield()
end
for level = 1, #levels do
playLevel(levels[level])
if lives == 0 then
break
end
end
showGameOver()
end
----
[#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.