Import Oxide engine (Stages 0–10) under MIT license
Full project snapshot migrated to new Gitea remote without history: engine, editor, physics, script, examples, tests, docs, and assets. Relicensed from GPLv3 to MIT and updated repo URLs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
# Scripting (`oxide-script`, rhai) — Stage 10
|
||||
|
||||
The scripting module makes game logic live in **watched `.rhai` scripts** that
|
||||
hot-reload while the editor runs. It is a feature-gated module built on
|
||||
[`rhai`](https://rhai.rs) — an embeddable, sandboxed, Rust-friendly scripting
|
||||
language — and plugs into the engine through the Stage-5 module system exactly
|
||||
like [physics](physics.md): add [`ScriptModule`](#scriptmodule) to an `App` and
|
||||
the [`Script`](#the-script-component) component becomes live.
|
||||
|
||||
> **Status.** This page tracks Stage 10 as it lands piece by piece. **Done so
|
||||
> far:** the component data model, the script asset + `.rhai` loader, the engine
|
||||
> wrapper, the module wiring, the per-frame lifecycle (`init` / `update(dt)`)
|
||||
> driven by the [`ScriptHost`](#the-scripthost-lifecycle), the engine API
|
||||
> (`Vec3` + ambient transform functions) scripts use to read/write their
|
||||
> entity's `Transform`, **headless [live reload](#live-reload)** (edit a
|
||||
> `.rhai` file → the running script recompiles, no restart), and **[editor
|
||||
> integration](#editor-integration)** (Script is addable in the inspector; Play
|
||||
> runs scripts and live-reload reaches a *playing* scene), and **[error/output
|
||||
> surfacing](#errors-and-output-in-the-console)** (a paused script's error and
|
||||
> its `print` output show in the editor Console panel), and a **[command
|
||||
> terminal](#the-command-terminal)** in that panel, plus an **[interactive PTY
|
||||
> terminal](#interactive-terminal-pty)** that runs shells / TUIs / AI-agent CLIs
|
||||
> like `claude`. **Remaining for the stage:** a richer script API (spawn/despawn
|
||||
> + component add/edit, beyond `Transform`).
|
||||
|
||||
## The model: ECS is the source of truth
|
||||
|
||||
As with physics, the **ECS owns the truth**. An entity opts into scripting with
|
||||
one serializable, reflected component — [`Script`](#the-script-component) — that
|
||||
carries only *authoring* inputs (which script, enabled or not). The script's
|
||||
behaviour is **not** stored on the component:
|
||||
|
||||
- The source lives on disk as a `.rhai` file, loaded as a
|
||||
[`ScriptAsset`](#scriptasset-the-loaded-source) (kept as plain text so a live
|
||||
edit just recompiles).
|
||||
- The compiled AST and any per-entity runtime state live in the host (a later
|
||||
piece), keyed by entity.
|
||||
|
||||
Because the authored component carries no runtime state, **play-mode
|
||||
snapshot/restore works for free**: Stop reverts the authored `Script` components
|
||||
and the next Play recompiles fresh.
|
||||
|
||||
## The `Script` component
|
||||
|
||||
```rust
|
||||
use oxide_engine::asset::AssetRef;
|
||||
use oxide_script::{Script, ScriptAsset};
|
||||
|
||||
// Empty + disabled by default; `new` points at a source and enables it.
|
||||
let script = Script::new(AssetRef::new(uid)); // uid from the asset database
|
||||
assert!(script.enabled);
|
||||
```
|
||||
|
||||
| Field | Type | Meaning |
|
||||
|-------|------|---------|
|
||||
| `source` | `AssetRef<ScriptAsset>` | Which `.rhai` script the entity runs. Empty until assigned. |
|
||||
| `enabled` | `bool` | Whether the script runs; clear to suspend it without detaching. |
|
||||
|
||||
`source` is an [`AssetRef<T>`](assets.md), not a live `Handle<T>`, so it is
|
||||
serializable and stable across runs; the inspector recognises the
|
||||
`AssetRef<ScriptAsset>` spelling and offers a picker filtered to the `scripts/`
|
||||
folder (`AssetKind::Script`). The component derives `Reflect`, so it is
|
||||
dual-editable from the inspector, from scripts, and from external tools with no
|
||||
per-type editor code.
|
||||
|
||||
## `ScriptAsset`: the loaded source
|
||||
|
||||
A `ScriptAsset` is just the script's source text plus a diagnostic name (the
|
||||
file stem). It is deliberately inert — holding *source*, not behaviour — so the
|
||||
same file can be recompiled on every live reload with no engine-specific data
|
||||
baked into the asset cache.
|
||||
|
||||
```rust
|
||||
use oxide_script::ScriptAsset;
|
||||
|
||||
let asset = ScriptAsset::from_source("spin", "let t = 0.0;");
|
||||
assert_eq!(asset.name, "spin");
|
||||
```
|
||||
|
||||
The `ScriptLoader` reads `.rhai` files; it is registered by `ScriptModule`, so
|
||||
`assets.load::<ScriptAsset>("scripts/spin.rhai")` works once the module is added.
|
||||
`.rhai` files map to the new `AssetKind::Script` (folder `scripts/`).
|
||||
|
||||
## The `ScriptEngine` wrapper
|
||||
|
||||
`ScriptEngine` owns one configured `rhai` interpreter the host reuses to compile
|
||||
and run every script, so sandbox configuration lives in one place:
|
||||
|
||||
- `print` / `debug` output is routed to the `log` crate (so the editor console
|
||||
can surface it rather than leaking to stdout);
|
||||
- an operation cap (`set_max_operations`) turns a runaway loop into a **runtime
|
||||
error** instead of hanging the editor.
|
||||
|
||||
```rust
|
||||
use oxide_script::{ScriptAsset, ScriptEngine};
|
||||
|
||||
let engine = ScriptEngine::new();
|
||||
let asset = ScriptAsset::from_source("ok", "let x = 1 + 2; print(x);");
|
||||
let compiled = engine.compile(&asset)?; // -> CompiledScript (reusable AST)
|
||||
engine.run(&compiled)?; // evaluate the top level
|
||||
```
|
||||
|
||||
Errors are a `thiserror` enum, [`ScriptError`], with two variants that both name
|
||||
the offending script so the console can attribute the failure:
|
||||
|
||||
- `ScriptError::Compile` — the source failed to parse/compile;
|
||||
- `ScriptError::Runtime` — it compiled but raised an error (or hit the operation
|
||||
cap) while running.
|
||||
|
||||
A runtime error is **returned, never panicked**, so the host can pause just that
|
||||
one script rather than crash the editor — the foundation for Stage 10's
|
||||
error-isolation goal.
|
||||
|
||||
## `ScriptModule`
|
||||
|
||||
```rust
|
||||
use oxide_engine::app::App;
|
||||
use oxide_script::ScriptModule;
|
||||
|
||||
let mut app = App::new();
|
||||
app.add_module(ScriptModule);
|
||||
assert!(app.has_module("script"));
|
||||
assert!(app.types.is_registered("Script"));
|
||||
```
|
||||
|
||||
`ScriptModule::build` registers the `Script` component type for reflection
|
||||
(making it dual-editable and snapshot-captured), installs the `.rhai`
|
||||
`ScriptLoader`, inserts the [`ScriptHost`](#the-scripthost-lifecycle), and adds
|
||||
the `run_scripts` system on the `Update` schedule (after `FixedUpdate`, so
|
||||
scripts observe post-physics poses). Removing the module drops everything it
|
||||
contributed, as for any module.
|
||||
|
||||
## The `ScriptHost` (lifecycle)
|
||||
|
||||
`ScriptHost` is the scripting counterpart to physics' `PhysicsWorld`: a transient
|
||||
`App` resource holding the **per-entity runtime state** — the compiled AST and a
|
||||
persistent `rhai` `Scope` — that the authored `Script` component deliberately
|
||||
does not. The `run_scripts` system drives it each frame:
|
||||
|
||||
1. find every entity with an **enabled** `Script` that names a source;
|
||||
2. resolve each one's `.rhai` text through the `AssetDatabase` + `AssetServer`;
|
||||
3. **(re)compile + `start`** any script that is new or whose source text changed
|
||||
(this content check is the hook live reload builds on);
|
||||
4. stage the entity's `Transform` into the engine, call `update(dt)`, and write
|
||||
the (possibly mutated) transform back to the scene.
|
||||
|
||||
Because runtime state lives in the host keyed by entity — never on the component
|
||||
— play-mode snapshot/restore is unaffected. A script that fails to compile or
|
||||
raises a runtime error is **paused** (its error remembered, reported via
|
||||
`ScriptHost::error_of`) rather than retried every frame or allowed to crash the
|
||||
host — the start of Stage 10's error-isolation goal.
|
||||
|
||||
## Live reload
|
||||
|
||||
Editing a script while the app runs takes effect with **no restart**. The host
|
||||
keeps each running script's `Handle<ScriptAsset>` alive, so the asset stays in
|
||||
the server's cache. When the Stage-6 file watcher sees a `.rhai` file change it
|
||||
calls
|
||||
[`reload_changed_assets`](file-watching.md), which reruns the loader **in place**
|
||||
on the live handle; the next frame the host reads the new source through that
|
||||
handle, sees it differs from what it compiled, and recompiles + restarts that one
|
||||
script. The entity keeps its current transform and the rest of the scene is
|
||||
untouched.
|
||||
|
||||
`examples/script_spin` proves this headlessly: it spins an entity at 1 rad/s,
|
||||
rewrites the script to 3 rad/s, reloads it the way the watcher does, and the spin
|
||||
rate jumps mid-run while the orientation carries over.
|
||||
|
||||
```sh
|
||||
cargo run -p oxide-examples --bin script_spin
|
||||
```
|
||||
|
||||
> Wiring this into the editor's **play loop** (so editing a script in an external
|
||||
> editor or via an AI agent updates a *playing* scene) is a following piece; the
|
||||
> reload mechanism itself is done and tested.
|
||||
|
||||
### Lifecycle hooks
|
||||
|
||||
A script may define either or both of these functions; top-level statements run
|
||||
once at start (a constructor for defining functions and one-shot setup):
|
||||
|
||||
```rhai
|
||||
// scripts/spin.rhai — rotate this entity around Y at a constant rate.
|
||||
let speed = 1.5; // top-level state, set once at start
|
||||
|
||||
fn init() { // optional: called once, after the top level
|
||||
print("spin starting");
|
||||
}
|
||||
|
||||
fn update(dt) { // optional: called every frame with the frame delta
|
||||
rotate_y(dt * 1.5);
|
||||
}
|
||||
```
|
||||
|
||||
| Hook | When | Signature |
|
||||
|------|------|-----------|
|
||||
| top level | once, when the script (re)starts | statements at file scope |
|
||||
| `init()` | once, right after the top level | `fn init()` |
|
||||
| `update(dt)` | every frame | `fn update(dt)` — `dt` is seconds |
|
||||
|
||||
## Editor integration
|
||||
|
||||
Scripting plugs into the editor the same way physics does:
|
||||
|
||||
- **Add Component.** `register_builtin_types` registers `Script` as an *addable*
|
||||
reflected component, so it appears in the inspector's Add Component menu and is
|
||||
rendered generically from its fields. The `source` field is an
|
||||
`AssetRef<ScriptAsset>`, which the inspector shows as an asset picker filtered
|
||||
to the project's `scripts/` folder (`AssetKind::Script`).
|
||||
- **Play loop.** When Play starts, the editor's play `App` adds `ScriptModule`
|
||||
alongside `PhysicsModule`, **shares the editor's `AssetServer`**, and is handed
|
||||
a snapshot of the project `AssetDatabase`. Sharing the server is what lets the
|
||||
file watcher's in-place reloads — which target the editor server — reach a
|
||||
**playing** scene: edit a `.rhai` file (by hand, an external editor, or an AI
|
||||
agent) and the running script recompiles without leaving Play.
|
||||
- **Snapshot/restore.** Because `Script` is a reflected component, the play-mode
|
||||
snapshot captures it; a script attached or detached *during* play is reverted
|
||||
on Stop, like any other component.
|
||||
|
||||
## Errors and output in the Console
|
||||
|
||||
A script that fails to compile or raises a runtime error is **paused** — it stops
|
||||
running but the editor stays alive (the error is caught, never panicked). The
|
||||
host logs the failure once via `log::warn!(target: "oxide_script", …)` and
|
||||
remembers it (`ScriptHost::error_of`), so it is not retried until the source
|
||||
changes.
|
||||
|
||||
The editor's **Console panel** captures the `log` stream into a ring buffer and
|
||||
renders it, coloured by severity. Because script `print`/`debug` and the
|
||||
"script paused: …" errors all flow through `log` under the `oxide_script` target,
|
||||
they appear in the Console automatically — so you see a script's output and its
|
||||
failures without leaving the editor.
|
||||
|
||||
## The command terminal
|
||||
|
||||
The Console panel doubles as a **command terminal**: a `$` prompt runs a shell
|
||||
command (`sh -c`) with the working directory set to the open project's root, and
|
||||
its stdout/stderr stream back into the same panel line by line as they arrive
|
||||
(stdout at info, stderr at warn, plus the echoed command and an exit-status
|
||||
line). A long-running command — a build, a watcher, an AI-agent CLI — streams
|
||||
rather than blocking the editor: reader threads push each line to the shared
|
||||
console buffer and the panel re-renders next frame.
|
||||
|
||||
This is the surface for non-interactive dev tools. Running arbitrary commands
|
||||
from the editor is intentional (a developer tool, compiled out of an exported
|
||||
game).
|
||||
|
||||
### Interactive terminal (PTY)
|
||||
|
||||
The command console pipes output and can't run programs that need a real
|
||||
terminal. The separate **Terminal panel** can: it opens a pseudo-terminal with
|
||||
[`portable-pty`] (Linux now, Windows later), parses the program's byte stream
|
||||
with [`vt100`] into a screen grid, renders that grid in egui, and routes
|
||||
keystrokes back — so it runs **interactive / full-screen programs**: a shell, a
|
||||
REPL, `vim`, or an **AI-agent CLI like `claude`**. Click *Shell* to start your
|
||||
`$SHELL` in the project directory, then run whatever you need inside it
|
||||
(`claude`, an editor, a build watcher). Tab, the arrow keys, and Escape are
|
||||
delivered to the program (not used for egui focus navigation) while the panel is
|
||||
focused, so completion, history, and full-screen apps work. Sessions are
|
||||
**tabbed** — `+ Shell` opens another, each tab has a close button, and a tab
|
||||
**auto-closes when its program exits** (type `exit` and the tab disappears).
|
||||
This is what hosts agents that edit the watched scripts live — their edits flow
|
||||
back through [live reload](#live-reload).
|
||||
|
||||
The two pieces compose: use the **Console** for builds/git/log output, the
|
||||
**Terminal** for interactive sessions.
|
||||
|
||||
[`portable-pty`]: https://docs.rs/portable-pty
|
||||
[`vt100`]: https://docs.rs/vt100
|
||||
|
||||
## The engine API scripts call
|
||||
|
||||
A script does not get a raw ECS pointer. The host stages the active entity's
|
||||
`Transform` into a shared context before each call; the script reads and mutates
|
||||
it through **ambient functions**, and the host writes the result back. This is
|
||||
the `Transform` half of dual-editability: a script and the inspector edit the
|
||||
**same** transform.
|
||||
|
||||
| Function | Effect |
|
||||
|----------|--------|
|
||||
| `position() -> Vec3` | the entity's local translation |
|
||||
| `set_position(Vec3)` | set the translation |
|
||||
| `translate(Vec3)` / `translate(x, y, z)` | add to the translation |
|
||||
| `scale() -> Vec3` / `set_scale(Vec3)` | get/set the local scale |
|
||||
| `rotate_x/rotate_y/rotate_z(radians)` | spin about an axis (accumulates) |
|
||||
| `dt() -> f32` | the current frame delta (also passed to `update`) |
|
||||
|
||||
The `Vec3` type is registered with `vec3(x, y, z)` / `vec3()`, `.x`/`.y`/`.z`
|
||||
get/set, `+ - *` (and scalar `*`), `length()`, `normalize()`, and `to_string()`.
|
||||
The `rhai` float type is configured to `f32` (the `f32_float` feature), so engine
|
||||
math values bridge into scripts with no casts.
|
||||
|
||||
### Spawning entities and editing components
|
||||
|
||||
Beyond its own transform, a script can mutate the whole scene graph: spawn and
|
||||
despawn entities, and add, edit, or remove **any registered component** on any
|
||||
entity. The same "stage in, read back out" discipline applies — a script cannot
|
||||
borrow the ECS directly (the `rhai` functions must be `Send + Sync`), so each of
|
||||
these calls **buffers a command** that the host drains and applies against the
|
||||
scene + reflection registry after the script returns. Component edits go through
|
||||
**RON**, so a script authors data exactly like the inspector or an AI agent does
|
||||
— the same dual-editable representation.
|
||||
|
||||
| Function | Effect |
|
||||
|----------|--------|
|
||||
| `entity() -> Entity` | the entity this script runs on |
|
||||
| `spawn_entity() -> Entity` / `spawn_entity(name)` | create a root entity, returns a handle usable immediately |
|
||||
| `despawn(Entity)` | remove an entity (and its subtree) |
|
||||
| `add_component(Entity, type_name)` | add a default-constructed component if the type is *addable* and absent |
|
||||
| `set_component(Entity, type_name, ron)` | insert or replace a component from its RON form |
|
||||
| `remove_component(Entity, type_name)` | remove the named component if present |
|
||||
|
||||
`spawn_entity` returns a **provisional** `Entity` handle: the entity does not
|
||||
exist in the ECS yet, but the script can configure it in the same frame
|
||||
(`set_component(e, …)`, `despawn(e)`) — the host resolves the provisional id to
|
||||
the real entity when it applies the buffered commands, in issue order. (`spawn`
|
||||
is a reserved word in `rhai`, hence the longer name.)
|
||||
|
||||
`type_name` is the name the component was registered under (e.g. `"MeshRenderer"`,
|
||||
`"RigidBody"`, or your own `register_type::<T>("…")`). A command that names an
|
||||
unregistered type, or whose RON fails to parse, is logged to the Console and
|
||||
skipped — one bad call never aborts the rest or crashes the host.
|
||||
|
||||
```rust
|
||||
// Spawn a pickup and configure it in one frame:
|
||||
fn init() {
|
||||
let pickup = spawn_entity("Coin");
|
||||
set_component(pickup, "MeshRenderer", "(mesh: Some(\"coin\"), ...)");
|
||||
}
|
||||
```
|
||||
|
||||
[`ScriptError`]: #the-scriptengine-wrapper
|
||||
Reference in New Issue
Block a user