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,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"]);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user