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
+29
View File
@@ -0,0 +1,29 @@
[package]
name = "oxide-script"
description = "Oxide 3D game engine — scripting module (rhai)"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
rust-version.workspace = true
[dependencies]
oxide-engine = { path = "../engine" }
glam.workspace = true
hecs.workspace = true
serde.workspace = true
ron.workspace = true
thiserror.workspace = true
log.workspace = true
# The scripting backend. rhai is embeddable and sandboxed (no file/OS access by
# default), which is exactly what an in-engine, hot-reloaded game-logic language
# needs.
# - `sync` makes its handles `Send + Sync` so a compiled script can live in an
# App resource alongside the rest of the engine state.
# - `f32_float` makes the script `FLOAT` type `f32`, matching `glam`, so the
# engine math types (`Vec3`, angles, …) bridge into scripts with no casts.
rhai = { version = "1.21", features = ["sync", "f32_float"] }
[dev-dependencies]
ron.workspace = true
+101
View File
@@ -0,0 +1,101 @@
//! The [`ScriptAsset`] — a loaded script's source — and its [`ScriptLoader`].
//!
//! A script lives on disk as a `.rhai` file under the project's `assets/scripts/`
//! folder. Loading one yields a [`ScriptAsset`], which is just the source text
//! plus its origin path; turning that text into something executable (a compiled
//! `rhai` AST) is the job of the [`ScriptEngine`](crate::ScriptEngine), done at
//! run time so a live edit can recompile without touching the asset plumbing.
//!
//! Keeping the asset as plain source (rather than a pre-compiled AST) is what
//! makes hot-reload cheap: the file watcher swaps in fresh source on change and
//! the host recompiles, with no engine-specific data baked into the asset cache.
use std::path::Path;
use oxide_engine::asset::{AssetError, AssetLoader};
/// A loaded script: its source text and the path it came from.
///
/// This is the *asset* a [`Script`](crate::Script) component points at via an
/// [`AssetRef<ScriptAsset>`](oxide_engine::asset::AssetRef). It is deliberately
/// inert — holding source, not behaviour — so the same file can be recompiled on
/// every live reload.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScriptAsset {
/// The script's source code.
pub source: String,
/// A human-readable name for diagnostics (the file stem, when loaded from
/// disk), used in error messages and the script console.
pub name: String,
}
impl ScriptAsset {
/// Builds an asset from in-memory source with the given diagnostic `name`.
pub fn from_source(name: impl Into<String>, source: impl Into<String>) -> Self {
Self {
source: source.into(),
name: name.into(),
}
}
}
/// The [`AssetServer`](oxide_engine::asset::AssetServer) loader for `.rhai`
/// scripts.
///
/// Registered by the [`ScriptModule`](crate::ScriptModule) (handles `.rhai`), so
/// `assets.load::<ScriptAsset>("scripts/spin.rhai")` works once the module is
/// added. It reads the file as UTF-8 and records the file stem as the asset's
/// diagnostic name.
pub struct ScriptLoader;
impl AssetLoader for ScriptLoader {
type Asset = ScriptAsset;
fn extensions(&self) -> &'static [&'static str] {
&["rhai"]
}
fn load(&self, path: &Path) -> Result<ScriptAsset, AssetError> {
let source = std::fs::read_to_string(path).map_err(|err| AssetError::Load {
path: path.to_path_buf(),
message: err.to_string(),
})?;
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("script")
.to_string();
Ok(ScriptAsset { source, name })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_source_keeps_name_and_text() {
let a = ScriptAsset::from_source("spin", "let x = 1;");
assert_eq!(a.name, "spin");
assert_eq!(a.source, "let x = 1;");
}
#[test]
fn loader_reads_a_file_and_uses_the_stem_as_name() {
let dir = std::env::temp_dir().join("oxide-script-loader-test");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("hello.rhai");
std::fs::write(&path, "print(\"hi\");").unwrap();
let asset = ScriptLoader.load(&path).unwrap();
assert_eq!(asset.name, "hello");
assert!(asset.source.contains("print"));
std::fs::remove_file(&path).ok();
}
#[test]
fn loader_claims_the_rhai_extension() {
assert_eq!(ScriptLoader.extensions(), &["rhai"]);
}
}
+374
View File
@@ -0,0 +1,374 @@
//! The **engine API** exposed to scripts, and the shared [`ScriptContext`] that
//! backs it.
//!
//! A script does not get a raw pointer into the ECS; instead the host stages the
//! current entity's [`Transform`] into a shared [`ScriptContext`] before each
//! call, the script reads and mutates it through ambient functions (`position`,
//! `translate`, `rotate_y`, …), and the host writes the result back to the ECS
//! afterwards. This keeps the bridge tiny and single-threaded-safe while giving
//! scripts a Unity-like `transform`-style API.
//!
//! The context is shared as an `Arc<Mutex<…>>` because the `rhai` `sync` feature
//! requires every registered function to be `Send + Sync`; the editor runs
//! scripts one at a time, so the mutex is never actually contended.
use std::sync::{Arc, Mutex};
use oxide_engine::math::{Quat, Transform, Vec3};
use oxide_engine::scene::Entity;
use rhai::Engine;
/// A script-facing reference to an entity.
///
/// A script can name either a **real** entity (e.g. its own, via `entity()`) or
/// one it `spawn`ed earlier this same frame, which does not exist in the ECS yet
/// — the latter is a **provisional** id the host resolves to a real [`Entity`]
/// when it drains and applies the [`ScriptCommand`] buffer. This lets a script
/// spawn an entity and configure it in one go without the host round-tripping
/// the new id back into the running script.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum EntityHandle {
/// An entity that already exists in the scene.
Real(Entity),
/// An entity `spawn`ed this frame, identified by a monotonic id until the
/// host creates it and maps the id to a real [`Entity`].
Provisional(u64),
}
/// A scene mutation a script requested, buffered for the host to apply after the
/// script returns.
///
/// Scripts cannot touch the ECS directly (the `rhai` engine's registered
/// functions must be `Send + Sync` and hold no borrow of the world), so every
/// structural change a script makes is recorded here and replayed by the host
/// against the scene + reflection registry — the same "stage in, read back out"
/// discipline the transform API uses, extended to the whole entity/component
/// graph. Component edits go through RON so they round-trip the reflection
/// registry exactly like the inspector and AI agents do.
#[derive(Debug, Clone)]
pub(crate) enum ScriptCommand {
/// Create a new root entity with the given node name; its provisional id is
/// the one handed back to the script by `spawn`.
Spawn {
/// The provisional id the script holds for the new entity.
provisional: u64,
/// The new entity's node name.
name: String,
},
/// Despawn an entity (recursively, taking its subtree).
Despawn {
/// The entity to remove.
target: EntityHandle,
},
/// Add a default-constructed component of the named type, if absent.
AddComponent {
/// The entity to add to.
target: EntityHandle,
/// The registered component type name.
type_name: String,
},
/// Insert or replace a component from its RON serialization.
SetComponent {
/// The entity to write to.
target: EntityHandle,
/// The registered component type name.
type_name: String,
/// The component value as RON.
ron: String,
},
/// Remove the named component if present.
RemoveComponent {
/// The entity to remove from.
target: EntityHandle,
/// The registered component type name.
type_name: String,
},
}
/// The per-call scratch a running script reads from and writes to.
///
/// The host sets [`transform`](Self::transform) to the current entity's pose and
/// [`dt`](Self::dt) to the frame delta before invoking the script, then reads
/// `transform` back out to apply the script's changes.
#[derive(Debug, Clone, Default)]
pub(crate) struct ScriptContext {
/// The active entity's transform — staged in by the host, mutated by the
/// script, read back out by the host.
pub transform: Transform,
/// The current frame delta (seconds), also passed to `update(dt)`.
pub dt: f32,
/// The entity the script currently runs on, so `entity()` can name it.
pub current: Option<Entity>,
/// Scene mutations the script requested this call; drained by the host.
pub commands: Vec<ScriptCommand>,
/// Monotonic source of provisional ids for `spawn`. Never reset, so ids stay
/// unique across a frame's `init` + `update` calls (the host maps each to a
/// real entity), avoiding collisions in the apply step.
pub next_provisional: u64,
}
/// The shared handle the engine's registered functions close over.
pub(crate) type SharedContext = Arc<Mutex<ScriptContext>>;
/// Registers Oxide's engine API on `engine`, backed by the shared `ctx`.
///
/// This is the surface a script sees: the [`Vec3`] type plus the ambient
/// transform functions. It is intentionally small for this first piece — more
/// engine types and component accessors layer on here in later pieces.
pub(crate) fn register_api(engine: &mut Engine, ctx: &SharedContext) {
register_vec3(engine);
register_transform_api(engine, ctx);
register_world_api(engine, ctx);
}
/// Registers the [`Vec3`] type with constructors, component access, and the
/// arithmetic a script needs to do vector math.
fn register_vec3(engine: &mut Engine) {
engine
.register_type_with_name::<Vec3>("Vec3")
.register_fn("vec3", Vec3::new)
.register_fn("vec3", || Vec3::ZERO)
.register_get_set("x", |v: &mut Vec3| v.x, |v: &mut Vec3, x: f32| v.x = x)
.register_get_set("y", |v: &mut Vec3| v.y, |v: &mut Vec3, y: f32| v.y = y)
.register_get_set("z", |v: &mut Vec3| v.z, |v: &mut Vec3, z: f32| v.z = z)
.register_fn("+", |a: Vec3, b: Vec3| a + b)
.register_fn("-", |a: Vec3, b: Vec3| a - b)
.register_fn("*", |a: Vec3, s: f32| a * s)
.register_fn("*", |s: f32, a: Vec3| a * s)
.register_fn("length", |v: &mut Vec3| v.length())
.register_fn("normalize", |v: &mut Vec3| v.normalize_or_zero())
.register_fn("to_string", |v: &mut Vec3| {
format!("({}, {}, {})", v.x, v.y, v.z)
});
}
/// Registers the ambient transform API: functions that read and mutate the
/// active entity's pose via the shared context.
fn register_transform_api(engine: &mut Engine, ctx: &SharedContext) {
let c = ctx.clone();
engine.register_fn("position", move || c.lock().unwrap().transform.translation);
let c = ctx.clone();
engine.register_fn("set_position", move |p: Vec3| {
c.lock().unwrap().transform.translation = p;
});
let c = ctx.clone();
engine.register_fn("translate", move |v: Vec3| {
c.lock().unwrap().transform.translation += v;
});
let c = ctx.clone();
engine.register_fn("translate", move |x: f32, y: f32, z: f32| {
c.lock().unwrap().transform.translation += Vec3::new(x, y, z);
});
let c = ctx.clone();
engine.register_fn("scale", move || c.lock().unwrap().transform.scale);
let c = ctx.clone();
engine.register_fn("set_scale", move |s: Vec3| {
c.lock().unwrap().transform.scale = s;
});
// Rotations are right-handed, in radians, pre-multiplied onto the current
// orientation (so repeated calls accumulate spin).
let c = ctx.clone();
engine.register_fn("rotate_x", move |angle: f32| {
let mut g = c.lock().unwrap();
g.transform.rotation = Quat::from_rotation_x(angle) * g.transform.rotation;
});
let c = ctx.clone();
engine.register_fn("rotate_y", move |angle: f32| {
let mut g = c.lock().unwrap();
g.transform.rotation = Quat::from_rotation_y(angle) * g.transform.rotation;
});
let c = ctx.clone();
engine.register_fn("rotate_z", move |angle: f32| {
let mut g = c.lock().unwrap();
g.transform.rotation = Quat::from_rotation_z(angle) * g.transform.rotation;
});
let c = ctx.clone();
engine.register_fn("dt", move || c.lock().unwrap().dt);
}
/// Registers the **world API**: the [`Entity`] handle type plus the ambient
/// functions a script uses to spawn/despawn entities and add, edit, or remove
/// their components. Every mutation is recorded as a [`ScriptCommand`] in the
/// shared context for the host to apply after the script returns.
fn register_world_api(engine: &mut Engine, ctx: &SharedContext) {
engine
.register_type_with_name::<EntityHandle>("Entity")
.register_fn("to_string", |e: &mut EntityHandle| match e {
EntityHandle::Real(ent) => format!("Entity({})", ent.to_bits()),
EntityHandle::Provisional(id) => format!("Entity(new#{id})"),
});
// The entity this script runs on. Provisional(0) is never produced by
// `spawn` (its counter starts at 1), so it reads as an unset placeholder if
// the host forgot to stage a current entity — which never happens in normal
// operation.
let c = ctx.clone();
engine.register_fn("entity", move || {
c.lock()
.unwrap()
.current
.map(EntityHandle::Real)
.unwrap_or(EntityHandle::Provisional(0))
});
// spawn_entity() / spawn_entity(name) -> a provisional Entity, created on
// apply. (`spawn` is a reserved word in `rhai`, hence the longer name.)
let c = ctx.clone();
engine.register_fn("spawn_entity", move || spawn(&c, "Entity"));
let c = ctx.clone();
engine.register_fn("spawn_entity", move |name: &str| spawn(&c, name));
let c = ctx.clone();
engine.register_fn("despawn", move |target: EntityHandle| {
c.lock()
.unwrap()
.commands
.push(ScriptCommand::Despawn { target });
});
let c = ctx.clone();
engine.register_fn(
"add_component",
move |target: EntityHandle, type_name: &str| {
c.lock()
.unwrap()
.commands
.push(ScriptCommand::AddComponent {
target,
type_name: type_name.to_string(),
});
},
);
let c = ctx.clone();
engine.register_fn(
"set_component",
move |target: EntityHandle, type_name: &str, ron: &str| {
c.lock()
.unwrap()
.commands
.push(ScriptCommand::SetComponent {
target,
type_name: type_name.to_string(),
ron: ron.to_string(),
});
},
);
let c = ctx.clone();
engine.register_fn(
"remove_component",
move |target: EntityHandle, type_name: &str| {
c.lock()
.unwrap()
.commands
.push(ScriptCommand::RemoveComponent {
target,
type_name: type_name.to_string(),
});
},
);
}
/// Allocates a fresh provisional id, records a [`ScriptCommand::Spawn`], and
/// hands the provisional handle back to the script.
fn spawn(ctx: &SharedContext, name: &str) -> EntityHandle {
let mut g = ctx.lock().unwrap();
g.next_provisional += 1;
let provisional = g.next_provisional;
g.commands.push(ScriptCommand::Spawn {
provisional,
name: name.to_string(),
});
EntityHandle::Provisional(provisional)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ScriptAsset, ScriptEngine};
/// A throwaway entity id for unit tests that exercise the context directly
/// without a real scene.
fn dummy_entity() -> Entity {
// hecs packs a nonzero generation in the high 32 bits; id 0, generation 1.
Entity::from_bits(1 << 32).expect("valid entity bits")
}
#[test]
fn a_script_can_read_and_translate_the_transform() {
let engine = ScriptEngine::new();
engine.set_context(
dummy_entity(),
Transform::from_translation(Vec3::new(1.0, 0.0, 0.0)),
0.5,
);
let compiled = engine
.compile(&ScriptAsset::from_source(
"move",
"translate(vec3(2.0, 0.0, 0.0)); set_position(position() + vec3(0.0, dt(), 0.0));",
))
.unwrap();
engine.run(&compiled).unwrap();
let t = engine.take_transform();
assert!((t.translation.x - 3.0).abs() < 1e-6);
assert!((t.translation.y - 0.5).abs() < 1e-6); // dt was 0.5
}
#[test]
fn world_calls_buffer_commands_for_the_host() {
let engine = ScriptEngine::new();
engine.set_context(dummy_entity(), Transform::IDENTITY, 0.016);
// spawn returns a provisional handle the script can configure at once.
let compiled = engine
.compile(&ScriptAsset::from_source(
"spawner",
r#"
let e = spawn_entity("Bullet");
set_component(e, "MeshRenderer", "()");
add_component(entity(), "Marker");
despawn(e);
"#,
))
.unwrap();
engine.run(&compiled).unwrap();
let cmds = engine.take_commands();
assert_eq!(cmds.len(), 4, "four world calls were buffered");
match &cmds[0] {
ScriptCommand::Spawn { provisional, name } => {
assert_eq!(provisional, &1);
assert_eq!(name, "Bullet");
}
other => panic!("expected Spawn, got {other:?}"),
}
// The provisional id from spawn flows into the later set_component/despawn.
assert!(matches!(
&cmds[1],
ScriptCommand::SetComponent { target: EntityHandle::Provisional(1), type_name, .. }
if type_name == "MeshRenderer"
));
assert!(matches!(
&cmds[2],
ScriptCommand::AddComponent { target: EntityHandle::Real(_), type_name }
if type_name == "Marker"
));
assert!(matches!(
&cmds[3],
ScriptCommand::Despawn {
target: EntityHandle::Provisional(1)
}
));
// Draining cleared the buffer.
assert!(engine.take_commands().is_empty());
}
}
+85
View File
@@ -0,0 +1,85 @@
//! The [`Script`] component — attaches a `rhai` script to an entity.
//!
//! Like every Oxide component, `Script` is plain serializable data with
//! `#[derive(Reflect)]`, so it is editable from the inspector and from scripts
//! with no per-type editor code, and is captured by the play-mode snapshot. It
//! holds only *authoring* inputs — which script to run and whether it is active.
//! The compiled AST and per-entity runtime state are **not** stored here; they
//! live in the host (added in a later piece) keyed by entity, so a live reload
//! can recompile without disturbing the authored scene.
use oxide_engine::asset::AssetRef;
use oxide_engine::reflect::Reflect;
use serde::{Deserialize, Serialize};
use crate::ScriptAsset;
/// Component: a `rhai` script driving an entity.
///
/// Attach it and point [`source`](Self::source) at a `.rhai` asset; once the
/// [`ScriptModule`](crate::ScriptModule) is running, the script's lifecycle
/// hooks (`init`, `update(dt)`, …) execute for this entity. Clear
/// [`enabled`](Self::enabled) to suspend it without detaching.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, Reflect)]
pub struct Script {
/// The `.rhai` script asset this entity runs. Empty until one is assigned
/// (in the inspector, pick a script from the `scripts/` folder).
pub source: AssetRef<ScriptAsset>,
/// Whether the script runs. `true` by default; clear to suspend it while
/// keeping the component attached.
pub enabled: bool,
}
impl Script {
/// A script component pointing at `source`, enabled.
pub fn new(source: AssetRef<ScriptAsset>) -> Self {
Self {
source,
enabled: true,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use oxide_engine::asset::AssetUid;
#[test]
fn default_is_empty_and_disabled_until_constructed() {
// Derived Default: no source, and `enabled` is the bool default (false).
let s = Script::default();
assert!(!s.source.is_some());
assert!(!s.enabled);
}
#[test]
fn new_points_at_a_source_and_is_enabled() {
let s = Script::new(AssetRef::new(AssetUid(7)));
assert_eq!(s.source.uid(), Some(AssetUid(7)));
assert!(s.enabled);
}
#[test]
fn round_trips_through_ron() {
let s = Script::new(AssetRef::new(AssetUid(42)));
let text = ron::to_string(&s).unwrap();
let back: Script = ron::from_str(&text).unwrap();
assert_eq!(s, back);
}
#[test]
fn the_source_field_is_a_script_asset_ref() {
// The inspector reads this spelling to offer a `scripts/` asset picker.
let s = Script::default();
let field = s
.fields()
.iter()
.find(|f| f.name == "source")
.expect("source field is reflected");
assert_eq!(
oxide_engine::asset::asset_ref_target(field.type_name),
Some("ScriptAsset")
);
}
}
+271
View File
@@ -0,0 +1,271 @@
//! The [`ScriptEngine`] — a thin wrapper over a `rhai` interpreter — plus the
//! [`CompiledScript`] handle and the [`ScriptError`] type.
//!
//! `rhai` is sandboxed by default (no filesystem or OS access), which is exactly
//! what hot-reloaded game logic wants. This wrapper owns one configured engine
//! that the host reuses to compile every script, keeping engine setup (limits,
//! `print`/`debug` routing, later the engine API bindings) in one place. A
//! script is compiled once into a [`CompiledScript`] (a reusable AST) and then
//! evaluated cheaply each frame.
use oxide_engine::math::Transform;
use oxide_engine::scene::Entity;
use rhai::{Engine, Scope, AST};
use crate::bridge::{register_api, ScriptContext, SharedContext};
use crate::ScriptAsset;
/// Errors raised while compiling or running a script.
///
/// Both variants name the offending script so the editor console can attribute
/// the failure; the host uses a runtime error to **pause** that one script
/// rather than crash the editor (Stage 10 error-isolation goal).
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ScriptError {
/// The source failed to parse / compile.
#[error("script '{name}' failed to compile: {message}")]
Compile {
/// The script's diagnostic name.
name: String,
/// The compiler's message.
message: String,
},
/// The script compiled but raised an error while running.
#[error("script '{name}' raised a runtime error: {message}")]
Runtime {
/// The script's diagnostic name.
name: String,
/// The runtime error message.
message: String,
},
}
/// A compiled, ready-to-run script: its `rhai` AST and the diagnostic name it
/// was compiled from.
///
/// Cheap to keep around and re-evaluate; the host stores one per live script and
/// replaces it wholesale on live reload.
#[derive(Clone, Debug)]
pub struct CompiledScript {
/// The compiled abstract syntax tree.
pub ast: AST,
/// The source script's diagnostic name (for error messages).
pub name: String,
}
/// A configured `rhai` engine the host reuses to compile and run scripts.
///
/// One engine compiles many scripts; the configuration (resource limits,
/// `print`/`debug` routing, and — in later pieces — the engine type API) lives
/// here so every script sees the same sandbox.
pub struct ScriptEngine {
engine: Engine,
/// Shared scratch the engine API reads/writes: the host stages the active
/// entity's transform here, the script mutates it, the host reads it back.
ctx: SharedContext,
}
impl ScriptEngine {
/// Builds an engine with Oxide's default sandbox configuration and the
/// engine API (the [`Vec3`](oxide_engine::math::Vec3) type + ambient
/// transform functions) registered.
///
/// `print` and `debug` output is routed to the `log` crate (so the editor
/// console can surface it); operation limits guard against an accidental
/// infinite loop wedging the editor.
pub fn new() -> Self {
let mut engine = Engine::new();
// Route script `print`/`debug` to the log so the console can capture it
// instead of leaking to stdout.
engine.on_print(|text| log::info!(target: "oxide_script", "{text}"));
engine.on_debug(|text, source, pos| {
log::debug!(target: "oxide_script", "{}:{pos:?}: {text}", source.unwrap_or("script"));
});
// A generous cap: enough for real per-frame logic, low enough that a
// runaway loop surfaces as a runtime error rather than a hang.
engine.set_max_operations(2_000_000);
let ctx: SharedContext =
std::sync::Arc::new(std::sync::Mutex::new(ScriptContext::default()));
register_api(&mut engine, &ctx);
Self { engine, ctx }
}
// --- Context staging (host ↔ script transform hand-off) ----------------
/// Stages the active `entity`, its `transform`, and frame delta `dt` into the
/// shared context before a call, so the script's `position`/`translate`/`dt`/
/// `entity()`/… see the entity's current pose and identity. Clears any
/// command buffer left over from a previous call (the host always
/// [`take_commands`](Self::take_commands) after each call, so this is just
/// belt-and-suspenders); the provisional-id counter is deliberately *not*
/// reset, keeping spawn ids unique across a frame's `init` + `update`.
pub fn set_context(&self, entity: Entity, transform: Transform, dt: f32) {
let mut g = self.ctx.lock().unwrap();
g.current = Some(entity);
g.transform = transform;
g.dt = dt;
g.commands.clear();
}
/// Reads the (possibly script-mutated) transform back out of the context
/// after a call, so the host can write it to the ECS.
pub fn take_transform(&self) -> Transform {
self.ctx.lock().unwrap().transform
}
/// Drains the scene-mutation commands the script issued this call, so the
/// host can apply them against the scene + reflection registry.
pub(crate) fn take_commands(&self) -> Vec<crate::bridge::ScriptCommand> {
std::mem::take(&mut self.ctx.lock().unwrap().commands)
}
// --- Lifecycle ---------------------------------------------------------
/// Whether the compiled script defines a function named `name` taking
/// `arity` parameters (used to skip absent lifecycle hooks rather than treat
/// "no such function" as an error).
pub fn has_function(&self, script: &CompiledScript, name: &str, arity: usize) -> bool {
script
.ast
.iter_functions()
.any(|f| f.name == name && f.params.len() == arity)
}
/// Starts a script in `scope`: runs its top level once (defining functions
/// and one-shot setup), then calls `init()` if it defines one.
///
/// `scope` persists across frames, so top-level `let` bindings live on as the
/// script's state. A [`ScriptError::Runtime`] is returned (never panicked) so
/// the host can pause just this script.
pub fn start(&self, scope: &mut Scope, script: &CompiledScript) -> Result<(), ScriptError> {
self.engine
.run_ast_with_scope(scope, &script.ast)
.map_err(|err| self.runtime_error(script, err))?;
if self.has_function(script, "init", 0) {
self.engine
.call_fn::<()>(scope, &script.ast, "init", ())
.map_err(|err| self.runtime_error(script, err))?;
}
Ok(())
}
/// Runs one frame of a started script: calls `update(dt)` if it defines one.
/// The host must [`set_context`](Self::set_context) first.
pub fn run_update(
&self,
scope: &mut Scope,
script: &CompiledScript,
dt: f32,
) -> Result<(), ScriptError> {
if self.has_function(script, "update", 1) {
self.engine
.call_fn::<()>(scope, &script.ast, "update", (dt,))
.map_err(|err| self.runtime_error(script, err))?;
}
Ok(())
}
/// Builds a [`ScriptError::Runtime`] from a `rhai` evaluation error.
fn runtime_error(&self, script: &CompiledScript, err: Box<rhai::EvalAltResult>) -> ScriptError {
ScriptError::Runtime {
name: script.name.clone(),
message: err.to_string(),
}
}
/// Borrows the underlying `rhai` engine (for host wiring that registers
/// types/functions on it).
pub fn raw(&self) -> &Engine {
&self.engine
}
/// Mutably borrows the underlying engine, e.g. to register engine API
/// functions a script can call.
pub fn raw_mut(&mut self) -> &mut Engine {
&mut self.engine
}
/// Compiles `asset`'s source into a reusable [`CompiledScript`], or reports a
/// [`ScriptError::Compile`] naming the script.
pub fn compile(&self, asset: &ScriptAsset) -> Result<CompiledScript, ScriptError> {
match self.engine.compile(&asset.source) {
Ok(ast) => Ok(CompiledScript {
ast,
name: asset.name.clone(),
}),
Err(err) => Err(ScriptError::Compile {
name: asset.name.clone(),
message: err.to_string(),
}),
}
}
/// Runs a compiled script's top level in a fresh scope, discarding its value.
///
/// This executes statements at file scope (where a script defines its
/// functions and any one-shot setup). A [`ScriptError::Runtime`] is returned
/// — never panicked — so the host can pause just this script.
pub fn run(&self, script: &CompiledScript) -> Result<(), ScriptError> {
self.engine
.run_ast(&script.ast)
.map_err(|err| ScriptError::Runtime {
name: script.name.clone(),
message: err.to_string(),
})
}
}
impl Default for ScriptEngine {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compiles_and_runs_valid_source() {
let engine = ScriptEngine::new();
let asset = ScriptAsset::from_source("ok", "let x = 1 + 2; print(x);");
let compiled = engine.compile(&asset).expect("compiles");
assert_eq!(compiled.name, "ok");
engine.run(&compiled).expect("runs");
}
#[test]
fn a_syntax_error_is_a_compile_error_naming_the_script() {
let engine = ScriptEngine::new();
let asset = ScriptAsset::from_source("broken", "let x = ;");
let err = engine.compile(&asset).unwrap_err();
match err {
ScriptError::Compile { name, .. } => assert_eq!(name, "broken"),
other => panic!("expected a compile error, got {other:?}"),
}
}
#[test]
fn a_runtime_failure_is_isolated_as_a_runtime_error() {
let engine = ScriptEngine::new();
// Throws at run time, not compile time.
let asset = ScriptAsset::from_source("throws", "throw \"boom\";");
let compiled = engine.compile(&asset).expect("compiles");
let err = engine.run(&compiled).unwrap_err();
match err {
ScriptError::Runtime { name, .. } => assert_eq!(name, "throws"),
other => panic!("expected a runtime error, got {other:?}"),
}
}
#[test]
fn an_infinite_loop_is_capped_rather_than_hanging() {
let engine = ScriptEngine::new();
let asset = ScriptAsset::from_source("spin", "let i = 0; loop { i += 1; }");
let compiled = engine.compile(&asset).expect("compiles");
// The operation cap turns the runaway loop into a runtime error.
assert!(engine.run(&compiled).is_err());
}
}
+594
View File
@@ -0,0 +1,594 @@
//! The [`ScriptHost`] — the runtime that compiles and runs each entity's script.
//!
//! The host is the scripting counterpart to physics' `PhysicsWorld`: a transient
//! [`App`] resource that holds the per-entity runtime state (compiled AST +
//! persistent scope) the authored [`Script`] components do **not**. A single
//! system, [`run_scripts`], drives it each frame:
//!
//! 1. find every entity with an enabled [`Script`];
//! 2. resolve each one's `.rhai` source through the [`AssetDatabase`] +
//! [`AssetServer`](oxide_engine::asset::AssetServer);
//! 3. (re)compile and `start` any script that is new or whose source changed;
//! 4. stage the entity's [`Transform`] into the engine, call `update(dt)`, and
//! write the (possibly mutated) transform back.
//!
//! Because runtime state lives here keyed by entity — never on the component —
//! play-mode snapshot/restore is unaffected. **Live reload** falls out of step
//! 3: the host keeps each script's asset [`Handle`] alive, so when the file
//! watcher reruns the loader in place on a disk edit, the next frame reads the
//! new source through that handle and recompiles — no restart, scene state
//! preserved (see `examples/script_spin`). A script that fails to compile or
//! raises a runtime error is **paused** (its error remembered) rather than
//! retried every frame or allowed to crash the host.
use std::collections::HashMap;
use oxide_engine::app::App;
use oxide_engine::asset::{AssetDatabase, AssetUid, Handle};
use oxide_engine::math::Transform;
use oxide_engine::scene::{DespawnPolicy, Entity};
use rhai::Scope;
use crate::bridge::{EntityHandle, ScriptCommand};
use crate::{CompiledScript, Script, ScriptAsset, ScriptEngine};
/// The compiled, runnable form of a started script: its AST and the persistent
/// scope that carries top-level `let` state across frames.
struct Runnable {
compiled: CompiledScript,
scope: Scope<'static>,
}
/// Per-entity script runtime state.
struct ScriptState {
/// Which asset is compiled here (recompile when it changes).
uid: AssetUid,
/// The live handle to the script asset. Held so the asset stays cached and
/// the file watcher can reload fresh source **into it in place** — that
/// in-place update is what makes live reload work: the next frame reads the
/// new source through this handle and recompiles.
handle: Handle<ScriptAsset>,
/// The exact source compiled, so a content change (e.g. from a live reload)
/// triggers a recompile.
source: String,
/// The runnable script, or `None` if it failed to compile/start.
runnable: Option<Runnable>,
/// If set, the script is paused after this error and skipped until its
/// source changes. Surfaced to the editor console in a later piece.
paused_error: Option<String>,
}
impl ScriptState {
/// Whether the script should run its per-frame `update` this frame.
fn is_runnable(&self) -> bool {
self.paused_error.is_none() && self.runnable.is_some()
}
}
/// The scripting runtime resource. Add it via
/// [`ScriptModule`](crate::ScriptModule); [`run_scripts`] drives it each frame.
pub struct ScriptHost {
engine: ScriptEngine,
states: HashMap<Entity, ScriptState>,
}
impl ScriptHost {
/// A host with a fresh engine and no live scripts.
pub fn new() -> Self {
Self {
engine: ScriptEngine::new(),
states: HashMap::new(),
}
}
/// The number of scripts currently live (compiled or paused) in the host.
pub fn live_count(&self) -> usize {
self.states.len()
}
/// The remembered error for `entity`'s script, if it is paused.
pub fn error_of(&self, entity: Entity) -> Option<&str> {
self.states
.get(&entity)
.and_then(|s| s.paused_error.as_deref())
}
/// Compiles `asset` and runs its `start` (top level + `init`), yielding a
/// [`Runnable`] or the error string that paused it.
fn compile_and_start(engine: &ScriptEngine, asset: &ScriptAsset) -> Result<Runnable, String> {
let compiled = engine.compile(asset).map_err(|e| e.to_string())?;
let mut scope = Scope::new();
engine
.start(&mut scope, &compiled)
.map_err(|e| e.to_string())?;
Ok(Runnable { compiled, scope })
}
/// Drives one frame: (re)compiles changed scripts and runs `update(dt)` for
/// each live one, applying transform changes back to the scene.
fn run_frame(&mut self, app: &mut App, dt: f32) {
// 1. Snapshot the enabled, source-bearing scripts (immutable scene
// borrow), so we can mutate the scene later without a borrow clash.
let scripted: Vec<(Entity, AssetUid)> = app
.scene
.world()
.query::<&Script>()
.iter()
.filter_map(|(e, s)| {
if s.enabled {
s.source.uid().map(|uid| (e, uid))
} else {
None
}
})
.collect();
// Forget state for entities that lost or disabled their script.
let live: std::collections::HashSet<Entity> = scripted.iter().map(|(e, _)| *e).collect();
self.states.retain(|e, _| live.contains(e));
// 2. Resolve each script to a live handle + its current source text
// (still an immutable App borrow). A handle already held for the same
// asset is reused — keeping it cached so the watcher's in-place reload
// reaches it — so only a brand-new script touches the database/disk.
// No database ⇒ nothing to run.
let mut resolved: Vec<(Entity, AssetUid, Handle<ScriptAsset>, ScriptAsset)> = Vec::new();
let db = app.get_resource::<AssetDatabase>();
for (e, uid) in &scripted {
let handle = match self.states.get(e) {
Some(st) if st.uid == *uid => Some(st.handle.clone()),
_ => db.and_then(|db| db.load::<ScriptAsset>(&app.assets, *uid)),
};
if let Some(handle) = handle {
if let Some(asset) = handle.wait() {
resolved.push((*e, *uid, handle, (*asset).clone()));
}
}
}
// 3 + 4. Compile/start as needed, then run update — staging the entity's
// pose into the engine around each call and writing it back so both
// `init` and `update` transform changes land in the scene.
for (e, uid, handle, asset) in resolved {
// The entity's current local pose; mutated by start/update below.
let mut transform = app.scene.local_transform(e).unwrap_or(Transform::IDENTITY);
let mut dirty = false;
// Scene mutations (spawn/despawn/component edits) the script requested
// this frame, applied after its transform is written back.
let mut pending = Vec::new();
// (Re)start when the script is new, points at a different asset, or
// its source changed (the live-reload trigger).
let needs_start = match self.states.get(&e) {
Some(st) => st.uid != uid || st.source != asset.source,
None => true,
};
if needs_start {
self.engine.set_context(e, transform, dt);
let (runnable, paused_error) = match Self::compile_and_start(&self.engine, &asset) {
Ok(r) => {
// `init` may have moved the entity / issued commands —
// carry both out.
transform = self.engine.take_transform();
pending.append(&mut self.engine.take_commands());
dirty = true;
(Some(r), None)
}
Err(err) => {
// Discard any commands the failed start partially buffered.
let _ = self.engine.take_commands();
log::warn!(target: "oxide_script", "script '{}' paused: {err}", asset.name);
(None, Some(err))
}
};
self.states.insert(
e,
ScriptState {
uid,
handle,
source: asset.source.clone(),
runnable,
paused_error,
},
);
}
let state = self
.states
.get_mut(&e)
.expect("just inserted or pre-existing");
if state.is_runnable() {
let runnable = state.runnable.as_mut().expect("runnable when is_runnable");
self.engine.set_context(e, transform, dt);
match self
.engine
.run_update(&mut runnable.scope, &runnable.compiled, dt)
{
Ok(()) => {
transform = self.engine.take_transform();
pending.append(&mut self.engine.take_commands());
dirty = true;
}
Err(err) => {
let _ = self.engine.take_commands();
log::warn!(target: "oxide_script", "script '{}' paused: {err}", runnable.compiled.name);
state.paused_error = Some(err.to_string());
}
}
}
if dirty {
app.scene.set_local_transform(e, transform);
}
// Apply structural changes last, so an explicit component edit (e.g.
// a `set_component(entity(), "Transform", …)`) wins over the staged
// transform write above.
if !pending.is_empty() {
Self::apply_commands(app, pending);
}
}
}
/// Applies a script's buffered [`ScriptCommand`]s against the scene and the
/// reflection registry (`app.types`), resolving each provisional `spawn` id
/// to the real [`Entity`] it created. Commands run in issue order, so a
/// script can spawn an entity and immediately configure it. Component edits
/// that name an unregistered type or fail to parse are logged and skipped —
/// one bad command never aborts the rest or crashes the host.
fn apply_commands(app: &mut App, commands: Vec<ScriptCommand>) {
// Maps a `spawn`'s provisional id to the entity it created this frame.
let mut spawned: HashMap<u64, Entity> = HashMap::new();
// Resolves a handle to a live entity, or `None` if it names a provisional
// id that was never spawned (a script bug — skipped, not fatal).
let resolve = |target: EntityHandle, spawned: &HashMap<u64, Entity>| match target {
EntityHandle::Real(e) => Some(e),
EntityHandle::Provisional(id) => spawned.get(&id).copied(),
};
for cmd in commands {
match cmd {
ScriptCommand::Spawn { provisional, name } => {
let e = app.scene.spawn(name, Transform::IDENTITY);
spawned.insert(provisional, e);
}
ScriptCommand::Despawn { target } => {
if let Some(e) = resolve(target, &spawned) {
app.scene.despawn(e, DespawnPolicy::Recursive);
}
}
ScriptCommand::AddComponent { target, type_name } => {
if let Some(e) = resolve(target, &spawned) {
if let Err(err) =
app.types.add_default(app.scene.world_mut(), e, &type_name)
{
log::warn!(target: "oxide_script", "add_component({type_name}) failed: {err}");
}
}
}
ScriptCommand::SetComponent {
target,
type_name,
ron,
} => {
if let Some(e) = resolve(target, &spawned) {
if let Err(err) =
app.types
.set_ron(app.scene.world_mut(), e, &type_name, &ron)
{
log::warn!(target: "oxide_script", "set_component({type_name}) failed: {err}");
}
}
}
ScriptCommand::RemoveComponent { target, type_name } => {
if let Some(e) = resolve(target, &spawned) {
if let Err(err) = app.types.remove(app.scene.world_mut(), e, &type_name) {
log::warn!(target: "oxide_script", "remove_component({type_name}) failed: {err}");
}
}
}
}
}
}
}
impl Default for ScriptHost {
fn default() -> Self {
Self::new()
}
}
/// The per-frame system: takes the [`ScriptHost`] out, drives it, puts it back.
///
/// Mirrors physics' `step_physics`: removing the resource hands the system
/// exclusive ownership of the host while it borrows the [`App`] (scene + assets)
/// mutably, then it is re-inserted.
pub(crate) fn run_scripts(app: &mut App) {
let Some(mut host) = app.remove_resource::<ScriptHost>() else {
return;
};
let dt = app.time.delta;
host.run_frame(app, dt);
app.insert_resource(host);
}
#[cfg(test)]
mod tests {
use super::*;
use oxide_engine::app::{App, DefaultModules, Schedule};
use oxide_engine::asset::AssetDatabase;
use oxide_engine::math::Vec3;
/// Builds an app with a temp project whose `assets/scripts/` holds `source`
/// under `name.rhai`, the script module, and an entity running that script.
/// Returns the app and the entity.
fn app_with_script(name: &str, source: &str) -> (App, Entity, tempdir::TempProject) {
let project = tempdir::TempProject::new();
let rel = format!("scripts/{name}.rhai");
project.write_asset(&rel, source);
let mut db = AssetDatabase::new(project.root());
let uid = db.register(&rel);
let mut app = App::new();
app.add_modules(DefaultModules);
app.add_module(crate::ScriptModule);
app.insert_resource(db);
let e = app
.scene
.spawn("scripted", Transform::from_translation(Vec3::ZERO));
app.scene
.world_mut()
.insert_one(e, Script::new(oxide_engine::asset::AssetRef::new(uid)))
.unwrap();
(app, e, project)
}
#[test]
fn update_moves_the_entity_transform_each_frame() {
// Moves +1 on X per second; with dt = 0.5 that is +0.5 per frame.
let (mut app, e, _p) =
app_with_script("move", "fn update(dt) { translate(dt, 0.0, 0.0); }");
app.update(0.5);
let x1 = app.scene.local_transform(e).unwrap().translation.x;
assert!((x1 - 0.5).abs() < 1e-6, "after one frame x = {x1}");
app.update(0.5);
let x2 = app.scene.local_transform(e).unwrap().translation.x;
assert!((x2 - 1.0).abs() < 1e-6, "after two frames x = {x2}");
}
#[test]
fn init_runs_once_before_update() {
// init sets x to 10; update adds 1 each frame. After 2 frames: 12.
let (mut app, e, _p) = app_with_script(
"initd",
"fn init() { set_position(vec3(10.0, 0.0, 0.0)); } \
fn update(dt) { translate(1.0, 0.0, 0.0); }",
);
app.update(0.016);
app.update(0.016);
let x = app.scene.local_transform(e).unwrap().translation.x;
assert!((x - 12.0).abs() < 1e-6, "x = {x}");
}
#[test]
fn a_disabled_script_does_not_run() {
let (mut app, e, _p) =
app_with_script("dis", "fn update(dt) { translate(1.0, 0.0, 0.0); }");
// Disable it before any frame.
app.scene.get_mut::<Script>(e).unwrap().enabled = false;
app.update(0.5);
let x = app.scene.local_transform(e).unwrap().translation.x;
assert_eq!(x, 0.0);
}
#[test]
fn a_runtime_error_pauses_the_script_and_keeps_the_app_alive() {
let (mut app, e, _p) = app_with_script("boom", "fn update(dt) { throw \"nope\"; }");
app.update(0.5); // raises, must not panic
let host = app.get_resource::<ScriptHost>().unwrap();
assert!(host.error_of(e).is_some(), "script should be paused");
// A second frame is a no-op (still paused), app stays alive.
app.update(0.5);
}
#[test]
fn editing_the_script_file_live_reloads_behavior() {
use oxide_engine::watch::{reload_changed_assets, ChangeEvent, ChangeKind};
// Starts moving on +X.
let (mut app, e, p) =
app_with_script("spin", "fn update(dt) { translate(1.0, 0.0, 0.0); }");
app.update(0.5);
let after_first = app.scene.local_transform(e).unwrap().translation;
assert!((after_first.x - 1.0).abs() < 1e-6, "x = {}", after_first.x);
assert_eq!(after_first.y, 0.0);
// Edit the script on disk to move on +Y instead, then reload it the way
// the editor's file watcher does: rerun the loader in place for the
// changed path. The host holds the handle alive, so this updates the
// very asset the next frame reads — no restart, scene state preserved.
let abs = {
let db = app.get_resource::<AssetDatabase>().unwrap();
let uid = db.uid_of("scripts/spin.rhai").unwrap();
db.absolute_path(uid).unwrap()
};
p.write_asset(
"scripts/spin.rhai",
"fn update(dt) { translate(0.0, 1.0, 0.0); }",
);
let reloaded = reload_changed_assets(
&app.assets,
[ChangeEvent {
path: abs,
kind: ChangeKind::Modified,
}],
);
assert_eq!(reloaded, 1, "the live script handle should reload in place");
// The entity keeps its position (state preserved) and now moves on +Y.
app.update(0.5);
let after_reload = app.scene.local_transform(e).unwrap().translation;
assert!(
(after_reload.x - 1.0).abs() < 1e-6,
"x preserved across reload, got {}",
after_reload.x
);
assert!(
(after_reload.y - 1.0).abs() < 1e-6,
"now moving on Y, got {}",
after_reload.y
);
}
#[test]
fn a_script_spawns_configures_and_despawns_through_ron() {
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
struct Health {
current: i64,
max: i64,
}
// On init the script spawns an enemy and authors its Health via RON in
// the same frame (the provisional handle is resolved by the host), and
// spawns + immediately despawns a throwaway entity.
let (mut app, _e, _p) = app_with_script(
"spawner",
r#"
fn init() {
let enemy = spawn_entity("Enemy");
set_component(enemy, "Health", "(current: 30, max: 100)");
let temp = spawn_entity("Temp");
set_component(temp, "Health", "(current: 1, max: 1)");
despawn(temp);
}
"#,
);
app.types.register::<Health>("Health");
app.update(0.016);
// Exactly one entity carries Health — the enemy; the temp was despawned —
// with the RON-authored values.
let healths: Vec<(Entity, Health)> = app
.scene
.world()
.query::<&Health>()
.iter()
.map(|(e, h)| (e, h.clone()))
.collect();
assert_eq!(
healths.iter().map(|(_, h)| h.clone()).collect::<Vec<_>>(),
vec![Health {
current: 30,
max: 100
}]
);
// And it round-trips back out through the registry as RON.
let enemy = healths[0].0;
let ron = app
.types
.get_ron(app.scene.world(), enemy, "Health")
.unwrap();
assert!(
ron.contains("30") && ron.contains("100"),
"round-trip RON: {ron}"
);
}
#[test]
fn a_script_can_add_then_remove_a_component_across_frames() {
use serde::{Deserialize, Serialize};
#[derive(Clone, Serialize, Deserialize)]
struct Tag {
v: i64,
}
// Frame 1 adds the tag to the script's own entity; frame 2 removes it.
// The `added` flag lives in the persistent scope across frames.
let (mut app, e, _p) = app_with_script(
"tagger",
r#"
let added = false;
fn update(dt) {
if !added { set_component(entity(), "Tag", "(v: 7)"); added = true; }
else { remove_component(entity(), "Tag"); }
}
"#,
);
app.types.register::<Tag>("Tag");
app.update(0.016);
assert!(
app.types.has(app.scene.world(), e, "Tag").unwrap(),
"frame 1 should have added Tag"
);
app.update(0.016);
assert!(
!app.types.has(app.scene.world(), e, "Tag").unwrap(),
"frame 2 should have removed Tag"
);
}
#[test]
fn scripts_run_on_the_update_schedule() {
// Sanity: the module registered the system on Update (covered indirectly
// by the moving-entity test, but assert the phase wiring directly).
let mut app = App::new();
app.add_module(crate::ScriptModule);
let before = app.system_count();
assert!(before >= 1);
let _ = Schedule::Update;
}
/// A throwaway project directory for tests, cleaned up on drop.
mod tempdir {
use std::path::{Path, PathBuf};
pub struct TempProject {
root: PathBuf,
}
impl TempProject {
pub fn new() -> Self {
let mut root = std::env::temp_dir();
let unique = format!(
"oxide-script-host-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
);
root.push(unique);
std::fs::create_dir_all(root.join("assets")).unwrap();
Self { root }
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn write_asset(&self, relative: &str, contents: &str) {
let path = self.root.join("assets").join(relative);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, contents).unwrap();
}
}
impl Drop for TempProject {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
}
}
+39
View File
@@ -0,0 +1,39 @@
//! Oxide Engine — scripting module (`oxide-script`).
//!
//! Stage 10 makes game logic live in watched scripts that hot-reload while the
//! editor runs, built on [`rhai`](https://rhai.rs) — an embeddable, sandboxed,
//! Rust-friendly scripting language. It plugs into the engine through the
//! Stage-5 module system: add [`ScriptModule`] to an [`App`](oxide_engine::app::App)
//! and the [`Script`] component becomes live.
//!
//! The design follows the same shape as [`oxide-physics`]: the **ECS is the
//! source of truth**. An entity opts into scripting with one serializable,
//! reflected component —
//! - [`Script`] — *which* `.rhai` script runs on the entity and whether it is
//! enabled.
//!
//! The script's source is loaded as a [`ScriptAsset`] (kept as plain text so a
//! live edit just recompiles), compiled by the shared [`ScriptEngine`], and run
//! per entity by the host. Because the authored component carries no runtime
//! state, play-mode snapshot/restore (Stage 8.7) works for free.
//!
//! [`oxide-physics`]: https://docs.rs/oxide-physics
//!
//! This first piece lands the component data model, the asset + loader, the
//! engine wrapper, and the module wiring; the lifecycle execution system, the
//! engine type API exposed to scripts, and live reload follow in later pieces.
#![deny(warnings)]
mod asset;
mod bridge;
mod component;
mod engine;
mod host;
mod module;
pub use asset::{ScriptAsset, ScriptLoader};
pub use component::Script;
pub use engine::{CompiledScript, ScriptEngine, ScriptError};
pub use host::ScriptHost;
pub use module::ScriptModule;
+82
View File
@@ -0,0 +1,82 @@
//! The [`ScriptModule`] — the Stage-10 entry point that wires scripting into an
//! [`App`].
//!
//! Following the engine's module convention, everything the scripting layer
//! contributes is registered here so it can be enabled, disabled, or removed as
//! a unit (and so an exported game that uses no scripts never compiles them in).
//! This first piece registers the [`Script`] component *type* for reflection
//! (making it dual-editable and captured by the play-mode snapshot), installs
//! the `.rhai` [`ScriptLoader`], and inserts the shared [`ScriptEngine`]. The
//! lifecycle system that compiles and runs scripts each frame is added in a
//! later piece.
use oxide_engine::app::{App, Module, Schedule};
use crate::host::run_scripts;
use crate::{Script, ScriptHost, ScriptLoader};
/// The scripting module. Add it after [`DefaultModules`] to give an app a
/// `rhai`-backed scripting layer.
///
/// [`DefaultModules`]: oxide_engine::app::DefaultModules
///
/// ```
/// 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"));
/// ```
pub struct ScriptModule;
impl Module for ScriptModule {
fn name(&self) -> &'static str {
"script"
}
fn build(&self, app: &mut App) {
// Register the component type for reflection so it round-trips through
// RON (scripts/AI/inspector) and is captured by the play-mode snapshot.
app.register_type::<Script>("Script");
// Teach the asset server to load `.rhai` files as `ScriptAsset`s.
app.add_loader(ScriptLoader);
// The runtime that compiles and runs each entity's script, driven on
// `Update` (after `FixedUpdate`, so scripts observe post-physics poses).
app.insert_resource(ScriptHost::new());
app.add_system(Schedule::Update, run_scripts);
}
}
#[cfg(test)]
mod tests {
use super::*;
use oxide_engine::app::DefaultModules;
#[test]
fn module_registers_its_type_loader_and_host() {
let mut app = App::new();
app.add_modules(DefaultModules);
app.add_module(ScriptModule);
assert!(app.has_module("script"));
assert!(app.types.is_registered("Script"));
assert!(app.has_resource::<ScriptHost>());
// The per-frame run_scripts system was registered.
assert!(app.system_count() >= 1);
}
#[test]
fn removing_the_module_drops_its_type() {
let mut app = App::new();
app.add_module(ScriptModule);
assert!(app.types.is_registered("Script"));
assert!(app.remove_module("script"));
assert!(!app.has_module("script"));
assert!(!app.types.is_registered("Script"));
}
}