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:
Homer Simpson
2026-07-05 20:41:02 +02:00
parent 2afb56b329
commit 9eead719b0
157 changed files with 47270 additions and 2 deletions
+332
View File
@@ -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