237 lines
11 KiB
Markdown
237 lines
11 KiB
Markdown
# Lua gameplay
|
|
|
|
Faset embeds **Lua 5.4.9** as an optional gameplay module. Lua and compiled C++
|
|
behaviors share the same runtime lifecycle, typed entity operations, scene components,
|
|
and Inspector metadata. The Editor does not run gameplay code in its own process.
|
|
There is no built-in script editor: edit `.lua` files in Zed or another external editor.
|
|
|
|
## Enable Lua in a project
|
|
|
|
Add explicit entry scripts to `project.faset.json`:
|
|
|
|
```json
|
|
"scripting": {
|
|
"lua": {
|
|
"scripts": ["Scripts/player.lua", "Scripts/beacon.lua"]
|
|
}
|
|
}
|
|
```
|
|
|
|
This is a manifest fragment, not a complete project file. Each entry must return one
|
|
`faset.behavior` table with a unique custom TypeId. All sources live beneath `Scripts`
|
|
and are captured as an immutable build/export snapshot. Paths must be project-relative;
|
|
symlinks and paths outside `Scripts` are rejected. Auxiliary modules do not need to
|
|
appear in the entry list.
|
|
|
|
A Lua-only project can omit both `Scripts/Gameplay.cpp` and `Scripts/Gameplay.hpp`.
|
|
A mixed project keeps that pair and adds the Lua declaration. TypeIds must be unique
|
|
across both languages, and the `faset.*` namespace is reserved for native components.
|
|
|
|
The engine developer option `FASET_ENABLE_LUA` defaults to `ON`. Project builds select
|
|
it from the manifest, so a C++-only game does not link the Lua VM. Lua is pinned and
|
|
built from source; no system Lua installation is required.
|
|
|
|
The complete `examples/lua` project includes a playable
|
|
2D controller, a non-physical animated beacon, and a shared module. Its scripts are
|
|
also loaded by the Lua contract test.
|
|
|
|
## Write a behavior
|
|
|
|
```lua
|
|
local Player = faset.behavior {
|
|
id = "game.player",
|
|
version = 1,
|
|
name = "Player",
|
|
fields = {
|
|
speed = {
|
|
name = "Move speed", type = "number", default = 5,
|
|
min = 0, max = 30, units = "m/s"
|
|
}
|
|
}
|
|
}
|
|
|
|
function Player:on_start()
|
|
self.state.elapsed = 0
|
|
end
|
|
|
|
function Player:fixed_update(delta)
|
|
self.state.elapsed = self.state.elapsed + delta
|
|
local velocity = self.entity:velocity()
|
|
velocity.x = faset.input().horizontal * self.fields.speed
|
|
self.entity:set_velocity(velocity)
|
|
end
|
|
|
|
return Player
|
|
```
|
|
|
|
Attach a component with `type: "game.player"`, `version: 1`, and the desired field
|
|
overrides to an entity with a 2D or 3D rigid body. Refresh schemas to expose the
|
|
behavior in **Add Component** and its `speed` field in the Inspector. Saved scenes
|
|
store the stable TypeId and data, not an instance of a Lua object.
|
|
|
|
Each entity/component gets its own instance:
|
|
|
|
- `self.entity`: an opaque runtime handle, checked on every call.
|
|
- `self.fields`: a configuration copy, combining schema defaults and scene overrides.
|
|
- `self.state`: a fresh mutable table for counters, timers, and retained handles.
|
|
|
|
Changing either table does not modify the saved scene or create an Undo operation.
|
|
Module-local variables are shared by instances of that module; put per-entity state
|
|
in `self.state`. Lua tables returned by getters are copies, not native pointers.
|
|
|
|
## Lifecycle
|
|
|
|
Use colon definitions so Lua supplies `self`:
|
|
|
|
| Callback | When it runs |
|
|
|---|---|
|
|
| `on_start()` | Once after the instance and initial scene objects exist |
|
|
| `fixed_update(delta)` | Before each fixed physics step; delta is seconds |
|
|
| `on_collision(event)` | After physics, for contact begin/end |
|
|
| `update(delta)` | Once per rendered frame after fixed steps |
|
|
| `late_update(delta)` | After presentation interpolation |
|
|
| `on_destroy()` | Before component/entity removal, while the handle is still valid |
|
|
|
|
Omit unused callbacks. The same [timing rules](lifecycle.md) as C++ apply, including
|
|
input edges, fixed-tick catch-up, deferred structural changes, pause and single-step.
|
|
Do not multiply velocity by delta; multiply a manually calculated displacement.
|
|
|
|
An error is reported with source location/traceback and disables the offending
|
|
instance for that generation and releases its instance state. Other instances can continue.
|
|
The VM quota is shared: allocations retained by module-level variables can still
|
|
affect other behaviors. A restart/reload creates
|
|
fresh instances; disabled instances are not automatically retried every frame.
|
|
Changes already made or queued by a failing callback are not rolled back.
|
|
|
|
## Runtime API
|
|
|
|
`faset.find("scene-id")` returns an entity handle or `nil`. Handles support equality
|
|
and `:valid()`. Retained handles become invalid after destruction or scene restart;
|
|
calling other methods on a stale handle reports an error.
|
|
|
|
| Entity method | Contract |
|
|
|---|---|
|
|
| `:transform()` / `:presentation()` | Copy of simulation/display transform |
|
|
| `:set_transform(pose)` | Non-physical objects only |
|
|
| `:set_presentation(pose)` | Display-only write during `late_update` |
|
|
| `:teleport(pose)` | Explicit discontinuous pose change; preserves velocity |
|
|
| `:fields(type_id)` | Copy of the named component's stored fields |
|
|
| `:velocity()` / `:set_velocity(v)` | Linear velocity, rigid bodies only |
|
|
| `:apply_impulse(v)` | Impulse at the rigid body's centre |
|
|
| `:is_grounded()` | Support from completed native physics contacts |
|
|
| `:destroy()` | Queue entity/descendant removal |
|
|
| `:add_component(record)` | Queue a complete component record |
|
|
| `:remove_component(type_id)` | Queue component removal |
|
|
|
|
Typed vectors are `{x = 1, y = 2, z = 0}`. Transforms contain `position`, `rotation`,
|
|
and `scale`, each a named vector. Positions use metres; rotations use XYZ Euler
|
|
radians. In contrast, **scene/component JSON arrays** are represented as ordinary
|
|
1-based Lua arrays, such as `fields.position = {1, 2, 0}`. Use `faset.null` to retain
|
|
an explicit JSON null; Lua `nil` removes a table key.
|
|
An empty Lua table converts to a JSON object; an empty schema default with
|
|
`type = "array"` is normalized to an empty JSON array.
|
|
|
|
`faset.input()` returns `horizontal`, `vertical`, `jump_pressed`, and
|
|
`interact_pressed`. Player mappings are A/D or arrows, W/S or arrows, Space, and E.
|
|
`faset.log(...)` sends a bounded message to Player logs and the Editor Console.
|
|
|
|
Collision events contain `first`, `second`, `other` (the opposite entity), and `began`.
|
|
They are copied for Lua, but retained entity handles still need validity checks.
|
|
|
|
`faset.spawn(record)` queues a full scene entity record. It returns **no handle**:
|
|
use `faset.find(id)` after the next fixed-tick barrier. Spawn, destroy, add and remove
|
|
operations follow FIFO order and do not mutate Editor documents. For example:
|
|
|
|
```lua
|
|
faset.spawn {
|
|
id = "effect-1", name = "Effect", parent = faset.null,
|
|
components = {
|
|
{
|
|
id = "effect-transform", type = "faset.transform", version = 1,
|
|
fields = { position = {0, 2, 0} }
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Shared modules and sandbox
|
|
|
|
`require("util.motion")` resolves `Scripts/util/motion.lua`, then
|
|
`Scripts/util/motion/init.lua`, inside the captured source snapshot. A module is
|
|
evaluated once and its result cached within the VM. Missing modules, cycles, and
|
|
path-like names are errors. There is no native module search, package installation,
|
|
network access, or arbitrary file access.
|
|
|
|
Basic Lua operations and the `math`, `string`, `table`, and `utf8` libraries are
|
|
available. `io`, `os`, `debug`, dynamic `load`, `loadfile`, `dofile`, `pcall`, `xpcall`,
|
|
`setmetatable`, `collectgarbage`, `string.dump`, and coroutines are not exposed.
|
|
Engine-owned metatables are locked; arbitrary finalizers cannot run during shutdown.
|
|
The restricted API intentionally prevents scripts
|
|
from catching execution-limit errors and continuing indefinitely.
|
|
|
|
The VM has memory and instruction budgets (`LuaLimits`, default 16 MiB and one
|
|
million instructions per protected entry/callback). These are gameplay reliability
|
|
limits, not a promise that executing untrusted code is equivalent to OS isolation.
|
|
JSON conversion also limits nesting, node count and expanded string/key bytes
|
|
(16 MiB), including repeated references to the same Lua string. Structural commands
|
|
are limited to 1,024 operations and 16 MiB of marshaled payload per callback.
|
|
The Player already runs separately from the Editor; only trusted local game projects
|
|
should be opened and built. C++ gameplay is native code and is not sandboxed.
|
|
|
|
Schema extraction evaluates entry scripts in the bounded VM but does not create a
|
|
world or invoke lifecycle callbacks. Keep top-level code declarative: calling runtime
|
|
operations there is an error. Metadata supports the same fields, constraints and
|
|
declarative [migration rules](api.md#editor-data-migrations) as C++ schemas. Runtime
|
|
loading does not migrate saved data automatically.
|
|
|
|
## Edit, reload, and export
|
|
|
|
The Editor command palette exposes:
|
|
|
|
| Command | Purpose |
|
|
|---|---|
|
|
| `faset_lua_refresh` | Build if needed, extract schemas, refresh Inspector metadata |
|
|
| `faset_lua_reload` | Request a Lua reload in a development Player |
|
|
| `faset_lua_setup` | Install Faset LuaLS declarations/configuration |
|
|
| `faset_script_open` | Open a script in an external editor |
|
|
|
|
The external-editor default is `zed`. Set `editor.script_editor` in
|
|
`project.faset.json` to an argument array such as `["code", "--goto", "{file}"]`,
|
|
or pass an `editor` argument array to `faset_script_open`. Exact `{file}` and
|
|
`{project}` arguments are substituted; a missing file argument is appended. The
|
|
command launches the executable directly, without a shell. The Assets panel lists
|
|
Lua sources under `Scripts` and provides **Open Script**.
|
|
|
|
Development Play watches Lua changes. The Player's `--watch-lua` option enables this
|
|
for direct development runs. A candidate source generation is loaded and validated
|
|
before replacement; an invalid candidate leaves the preceding generation running.
|
|
Successful reload **restarts the scene**, invalidates old handles, and resets all
|
|
script state. This is not state-preserving hot swapping. C++ source changes still
|
|
require a rebuild and a new Player process.
|
|
|
|
Export captures the declared entry list and Lua modules with the game. The exported
|
|
Player runs without the Editor or a separate Lua installation; development watching
|
|
is not enabled by ordinary exported-game launch. Exported Lua remains readable source,
|
|
not encrypted code. A C++-only project continues to export without the Lua VM.
|
|
|
|
## Zed and LuaLS
|
|
|
|
Run `faset_lua_setup`. It copies annotation-only declarations to
|
|
`.faset/lua/faset.lua` and creates `.luarc.json` **only if it does not already exist**.
|
|
For an existing LuaLS configuration, merge these settings yourself:
|
|
|
|
```json
|
|
{
|
|
"runtime.version": "Lua 5.4",
|
|
"runtime.path": ["Scripts/?.lua", "Scripts/?/init.lua"],
|
|
"workspace.library": [".faset/lua"],
|
|
"workspace.checkThirdParty": false,
|
|
"diagnostics.globals": ["faset"]
|
|
}
|
|
```
|
|
|
|
Use an editor with LuaLS integration and open the project directory. Annotations
|
|
describe the Faset API for completion and diagnostics; they are not runtime code and
|
|
must not be `require`d. The engine does not embed an LSP client, code editor, or a
|
|
breakpoint debugger. Player logs/tracebacks are the first debugging surface.
|