From ac5bc9d264edd9b13d1e2eb8a7109bad8254f611 Mon Sep 17 00:00:00 2001 From: Jamie Wells Date: Mon, 12 Oct 2026 02:07:46 +0000 Subject: [PATCH 1/3] Add live entity foundation services to the shared scripting runtime --- .../crates/engine-play/src/entity_services.rs | 211 ++++++++ Engine/crates/engine-play/src/lib.rs | 1 + .../crates/engine-play/src/script_runtime.rs | 286 +++++++++++ Engine/crates/engine-scripting/src/cpp_sdk.rs | 31 ++ Engine/crates/engine-scripting/src/lua.rs | 5 + .../crates/engine-scripting/src/sdk/Rustic.cs | 31 ++ .../engine-scripting/src/sdk/Rustic.java | 33 ++ .../engine-scripting/src/sdk/entity-api.d.ts | 30 ++ .../engine-scripting/src/sdk/entity-api.lua | 31 ++ .../engine-scripting/src/sdk/gameplay.js | 38 ++ .../engine-scripting/src/sdk/gameplay.lua | 26 + .../crates/engine-scripting/src/sdk/rustic.h | 26 + .../engine-scripting/src/sdk/rustic.php | 31 ++ .../crates/engine-scripting/src/sdk/rustic.py | 42 ++ .../crates/engine-scripting/src/workspace.rs | 5 + Engine/crates/engine-world/src/foundations.rs | 477 ++++++++++++++++++ Engine/crates/engine-world/src/lib.rs | 1 + Engine/crates/engine-world/src/world.rs | 9 + Engine/docs/SCRIPTING_EXPANSION_STATUS.md | 391 ++++++++++++++ .../docs/Scripting/API/entity-foundations.md | 355 +++++++++++++ Engine/docs/Scripting/README.md | 3 + Engine/examples/entity-foundations/README.md | 26 + .../examples/entity-foundations/behavior.lua | 11 + .../examples/entity-foundations/behavior.py | 14 + 24 files changed, 2114 insertions(+) create mode 100644 Engine/crates/engine-play/src/entity_services.rs create mode 100644 Engine/crates/engine-scripting/src/sdk/entity-api.d.ts create mode 100644 Engine/crates/engine-scripting/src/sdk/entity-api.lua create mode 100644 Engine/crates/engine-world/src/foundations.rs create mode 100644 Engine/docs/SCRIPTING_EXPANSION_STATUS.md create mode 100644 Engine/docs/Scripting/API/entity-foundations.md create mode 100644 Engine/examples/entity-foundations/README.md create mode 100644 Engine/examples/entity-foundations/behavior.lua create mode 100644 Engine/examples/entity-foundations/behavior.py diff --git a/Engine/crates/engine-play/src/entity_services.rs b/Engine/crates/engine-play/src/entity_services.rs new file mode 100644 index 00000000..69a447d1 --- /dev/null +++ b/Engine/crates/engine-play/src/entity_services.rs @@ -0,0 +1,211 @@ +//! Typed requests shared by all language SDKs; no VM-specific entity logic. +use engine_world::{EntityId, SceneWorld}; +use serde::Deserialize; +use serde_json::{Value, json}; + +fn yes() -> bool { + true +} + +#[derive(Deserialize)] +#[serde(tag = "op", deny_unknown_fields)] +enum Query { + #[serde(rename = "tags_add")] + TagAdd { entity: EntityId, name: String }, + #[serde(rename = "tags_remove")] + TagRemove { entity: EntityId, name: String }, + #[serde(rename = "tags_find_all")] + TagFind { name: String }, + #[serde(rename = "groups_create")] + GroupCreate { name: String }, + #[serde(rename = "groups_add")] + GroupAdd { entity: EntityId, name: String }, + #[serde(rename = "groups_get_members")] + GroupMembers { name: String }, + #[serde(rename = "hierarchy_set_parent")] + Parent { + entity: EntityId, + parent: Option, + #[serde(default = "yes")] + keep_world: bool, + }, + #[serde(rename = "hierarchy_get_children")] + Children { entity: EntityId }, + #[serde(rename = "hierarchy_detach")] + Detach { + entity: EntityId, + #[serde(default = "yes")] + keep_world: bool, + }, + #[serde(rename = "entity_destroy")] + Destroy { entity: EntityId }, + #[serde(rename = "entity_destroy_after")] + DestroyAfter { entity: EntityId, delay: f64 }, + #[serde(rename = "entity_is_valid")] + Valid { entity: EntityId }, + #[serde(rename = "components_has")] + Has { entity: EntityId, name: String }, + #[serde(rename = "components_get_all")] + Components { entity: EntityId }, + #[serde(rename = "components_find_in_children")] + FindComponents { entity: EntityId, name: String }, + #[serde(rename = "world_find_by_tag")] + WorldTag { name: String }, + #[serde(rename = "world_find_in_radius")] + Radius { center: [f32; 3], radius: f64 }, +} + +pub(crate) fn handles(op: &str) -> bool { + matches!( + op, + "tags_add" + | "tags_remove" + | "tags_find_all" + | "groups_create" + | "groups_add" + | "groups_get_members" + | "hierarchy_set_parent" + | "hierarchy_get_children" + | "hierarchy_detach" + | "entity_destroy" + | "entity_destroy_after" + | "entity_is_valid" + | "components_has" + | "components_get_all" + | "components_find_in_children" + | "world_find_by_tag" + | "world_find_in_radius" + ) +} + +pub(crate) fn query(world: &mut SceneWorld, value: Value) -> Result { + let query: Query = + serde_json::from_value(value).map_err(|e| format!("invalid entity query: {e}"))?; + evaluate(world, query).map_err(|e| e.to_string()) +} + +fn evaluate(world: &mut SceneWorld, query: Query) -> Result { + match query { + Query::TagAdd { entity, name } => Ok(json!(world.add_tag(entity, &name)?)), + Query::TagRemove { entity, name } => Ok(json!(world.remove_tag(entity, &name)?)), + Query::TagFind { name } | Query::WorldTag { name } => Ok(json!(world.find_by_tag(&name)?)), + Query::GroupCreate { name } => Ok(json!(world.create_group(&name)?)), + Query::GroupAdd { entity, name } => Ok(json!(world.add_to_group(&name, entity)?)), + Query::GroupMembers { name } => Ok(json!(world.group_members(&name)?)), + Query::Parent { + entity, + parent, + keep_world, + } => { + world.reparent(entity, parent, keep_world)?; + Ok(Value::Null) + } + Query::Detach { entity, keep_world } => { + world.reparent(entity, None, keep_world)?; + Ok(Value::Null) + } + Query::Children { entity } => Ok(json!(world.children(entity)?)), + Query::Destroy { entity } => { + world.destroy_entity(entity)?; + Ok(Value::Null) + } + Query::DestroyAfter { entity, delay } => { + world.destroy_after(entity, delay)?; + Ok(Value::Null) + } + Query::Valid { entity } => Ok(json!(world.contains(entity))), + Query::Components { entity } => Ok(json!(world.component_names(entity)?)), + Query::Has { entity, name } => Ok(json!(world.component_names(entity)?.contains(&name))), + Query::FindComponents { entity, name } => { + let mut found = Vec::new(); + for id in world.descendants(entity)? { + if world.component_names(id)?.contains(&name) { + found.push(id); + } + } + Ok(json!(found)) + } + Query::Radius { center, radius } => Ok(json!( + world.find_in_radius(glam::Vec3::from_array(center), radius)? + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use engine_world::{EntitySnapshot, WorldCommand}; + + #[test] + fn typed_requests_reject_invalid_shapes_without_mutation() { + let mut world = SceneWorld::new(); + let entity = EntitySnapshot::default(); + let id = entity.id; + world + .apply_commands(&[WorldCommand::Spawn(Box::new(entity))]) + .unwrap(); + for request in [ + json!({"op":"tags_add","entity":id,"name":false}), + json!({"op":"tags_add","entity":"bad","name":"enemy"}), + json!({"op":"tags_add","entity":id,"name":"enemy","extra":true}), + json!({"op":"entity_destroy_after","entity":id,"delay":-1}), + json!({"op":"world_find_in_radius","center":[0,0],"radius":1}), + json!({"op":"hierarchy_set_parent","entity":id,"parent":id}), + ] { + assert!(query(&mut world, request).is_err()); + assert!(world.contains(id)); + assert!(world.find_by_tag("enemy").unwrap().is_empty()); + } + assert_eq!( + query( + &mut world, + json!({"op":"entity_is_valid","entity":EntityId::new()}) + ) + .unwrap(), + json!(false) + ); + } + + #[test] + fn component_queries_search_descendants_and_not_the_root() { + let mut world = SceneWorld::new(); + let root = EntitySnapshot::default(); + let child = EntitySnapshot { + parent: Some(root.id), + name: Some("child".into()), + ..EntitySnapshot::default() + }; + let (r, c) = (root.id, child.id); + world + .apply_commands(&[ + WorldCommand::Spawn(Box::new(root)), + WorldCommand::Spawn(Box::new(child)), + ]) + .unwrap(); + assert_eq!( + query( + &mut world, + json!({"op":"components_has","entity":c,"name":"rustic.name"}) + ) + .unwrap(), + json!(true) + ); + assert_eq!( + query( + &mut world, + json!({"op":"components_find_in_children","entity":r,"name":"rustic.transform"}) + ) + .unwrap(), + json!([c]) + ); + assert_eq!( + query(&mut world, json!({"op":"components_get_all","entity":c})).unwrap(), + json!([ + "rustic.name", + "rustic.parent", + "rustic.part_attributes", + "rustic.transform" + ]) + ); + } +} diff --git a/Engine/crates/engine-play/src/lib.rs b/Engine/crates/engine-play/src/lib.rs index 2b031f51..1d5849e3 100644 --- a/Engine/crates/engine-play/src/lib.rs +++ b/Engine/crates/engine-play/src/lib.rs @@ -5,6 +5,7 @@ //! runtime, tests, and future command-line tooling. pub mod changes; +mod entity_services; pub mod frame_ring; pub mod local_ipc; mod physics; diff --git a/Engine/crates/engine-play/src/script_runtime.rs b/Engine/crates/engine-play/src/script_runtime.rs index 3d4bbc82..67ff7485 100644 --- a/Engine/crates/engine-play/src/script_runtime.rs +++ b/Engine/crates/engine-play/src/script_runtime.rs @@ -467,7 +467,11 @@ impl RuntimeScripts { if let Ok(mut context) = self.gameplay.lock() { context.fixed_delta = delta; } + self.unregister_disabled(); for behavior in &mut self.behaviors { + if !behavior.active() { + continue; + } if let Err(error) = behavior.fixed_update(delta) { push_log( &self.logs, @@ -488,6 +492,9 @@ impl RuntimeScripts { .map_err(|_| "gameplay lock poisoned".to_owned()) .and_then(|mut context| { let scaled = context.runtime.scaled_delta(delta)?; + world + .tick_entity_lifetimes(scaled) + .map_err(|e| e.to_string())?; context .physics .step(&mut world, scaled) @@ -548,7 +555,11 @@ impl RuntimeScripts { Err(error) => push_log(&self.logs, &self.dropped_logs, "error", &error), } } + self.unregister_disabled(); for behavior in &mut self.behaviors { + if !behavior.active() { + continue; + } if let Err(error) = behavior.update(delta) { push_log( &self.logs, @@ -578,6 +589,10 @@ impl RuntimeScripts { active.push(behavior); } else { let _ = behavior.on_disable(); + let host = behavior.host(); + if host.lock().is_ok_and(|host| host.entity_lifetime_ended()) { + let _ = behavior.on_destroy(); + } behavior.cleanup_gameplay(); } } @@ -935,6 +950,10 @@ impl GameplayHost for RuntimeHost { .get("op") .and_then(serde_json::Value::as_str) .ok_or("query operation is missing")?; + if crate::entity_services::handles(op) { + let mut world = self.world.lock().map_err(|_| "world lock poisoned")?; + return crate::entity_services::query(&mut world, query); + } if op == "scene_environment_field" { let world = self .world @@ -1906,6 +1925,13 @@ impl GameplayHost for RuntimeHost { fn set_enabled(&mut self, enabled: bool) { self.enabled = enabled; } + fn entity_lifetime_ended(&self) -> bool { + self.entity_bound + && self + .world + .lock() + .is_ok_and(|world| !world.contains(self.entity)) + } fn enabled(&self) -> bool { self.enabled && (!self.entity_bound @@ -2656,6 +2682,266 @@ mod tests { ); } + #[test] + #[allow( + clippy::too_many_lines, + reason = "cross-language fixtures exercise the same live world contract" + )] + fn entity_foundations_round_trip_through_available_language_adapters() { + let fixtures: &[(ScriptLanguage, &str, &[u8])] = &[ + (ScriptLanguage::Lua54, "entities.lua", br"return {Start=function() + local e=rustic.entity_id() + assert(Tags.add(e,'enemy'));assert(not Tags.add(e,'enemy')) + assert(#Tags.find_all('enemy')==1);assert(#World.find_by_tag('enemy')==1) + assert(Groups.create('enemies'));assert(Groups.add('enemies',e));assert(#Groups.get_members('enemies')==1) + assert(Components.has(e,'rustic.transform'));assert(#Components.get_all(e)>=2) + assert(#Components.find_in_children(e,'rustic.transform')==0) + assert(#Hierarchy.get_children(e)==0);Hierarchy.set_parent(e,nil);Hierarchy.detach(e) + assert(#World.find_in_radius({0,0,0},1)==1);assert(Entity.is_valid(e)) + assert(Tags.remove(e,'enemy'));assert(not Tags.remove(e,'enemy'));Tags.add(e,'enemy') + Entity.destroy(Tags.find_all('disposable')[1]);Entity.destroy_after(e,1) + end}"), + (ScriptLanguage::Luau, "entities.luau", br"return {Start=function() + local e=rustic.entity_id();assert(Tags.add(e,'enemy'));assert(#Tags.find_all('enemy')==1) + assert(Groups.create('enemies'));assert(Groups.add('enemies',e));assert(#Groups.get_members('enemies')==1) + assert(Components.has(e,'rustic.transform'));Hierarchy.detach(e);assert(Entity.is_valid(e));Entity.destroy(Tags.find_all('disposable')[1]);Entity.destroy_after(e,1) + end}"), + (ScriptLanguage::JavaScript, "entities.js", br"globalThis.behavior.Start=function(){ + const e=rustic.entity_id();if(!Tags.add(e,'enemy')||Tags.add(e,'enemy'))throw Error('tags'); + if(Tags.find_all('enemy').length!==1||World.find_by_tag('enemy').length!==1)throw Error('find'); + Groups.create('enemies');Groups.add('enemies',e);if(Groups.get_members('enemies').length!==1)throw Error('group'); + if(!Components.has(e,'rustic.transform')||Components.get_all(e).length<2||Components.find_in_children(e,'rustic.transform').length)throw Error('components'); + Hierarchy.set_parent(e,null);Hierarchy.detach(e);if(Hierarchy.get_children(e).length)throw Error('children'); + if(World.find_in_radius([0,0,0],1).length!==1||!Entity.is_valid(e))throw Error('world'); + if(!Tags.remove(e,'enemy')||Tags.remove(e,'enemy'))throw Error('remove');Tags.add(e,'enemy');Entity.destroy(Tags.find_all('disposable')[0]);Entity.destroy_after(e,1); + };"), + (ScriptLanguage::Web, "entities.html", br""), + (ScriptLanguage::Python, "entities.py", br"from rustic import * +def on_start(): + e=rustic.entity_id() + assert Tags.add(e,'enemy') and not Tags.add(e,'enemy') + assert len(Tags.find_all('enemy'))==1 and len(World.find_by_tag('enemy'))==1 + assert Groups.create('enemies') and Groups.add('enemies',e) + assert len(Groups.get_members('enemies'))==1 + assert Components.has(e,'rustic.transform') and len(Components.get_all(e))>=2 + assert not Components.find_in_children(e,'rustic.transform') + Hierarchy.set_parent(e,None);Hierarchy.detach(e) + assert not Hierarchy.get_children(e) and Entity.is_valid(e) + assert len(World.find_in_radius([0,0,0],1))==1 + assert Tags.remove(e,'enemy') and not Tags.remove(e,'enemy') + Tags.add(e,'enemy');Entity.destroy(Tags.find_all('disposable')[0]);Entity.destroy_after(e,1) +run(globals())"), + (ScriptLanguage::C, "entities.c", br#"#include "rustic.h" +void start(void){const char *e=rustic.entity_id(); +if(!Tags.add(e,"enemy")||Tags.add(e,"enemy"))r_fail("tags"); +if(Tags.find_all("enemy").count!=1||World.find_by_tag("enemy").count!=1)r_fail("find"); +Groups.create("enemies");Groups.add("enemies",e);if(Groups.get_members("enemies").count!=1)r_fail("groups"); +if(!Components.has(e,"rustic.transform")||Components.get_all(e).count<2||Components.find_in_children(e,"rustic.transform").count)r_fail("components"); +Hierarchy.set_parent(e,NULL,true);Hierarchy.detach(e,true);if(Hierarchy.get_children(e).count||!Entity.is_valid(e))r_fail("hierarchy"); +if(World.find_in_radius((RusticVector3){0,0,0},1).count!=1)r_fail("radius"); +if(!Tags.remove(e,"enemy")||Tags.remove(e,"enemy")){r_fail("remove");} +Tags.add(e,"enemy");Entity.destroy(Tags.find_all("disposable").items[0]);Entity.destroy_after(e,1);} +int main(void){return rustic_run((RusticBehavior){.on_start=start});}"#), + (ScriptLanguage::Cpp, "entities.cpp", br#"#include "rustic.hpp" +void start(){auto e=rustic.entity_id(); +if(!Tags::add(e,"enemy")||Tags::add(e,"enemy"))throw std::runtime_error("tags"); +if(Tags::find_all("enemy").size()!=1||World::find_by_tag("enemy").size()!=1)throw std::runtime_error("find"); +Groups::create("enemies");Groups::add("enemies",e);if(Groups::get_members("enemies").size()!=1)throw std::runtime_error("groups"); +if(!Components::has(e,"rustic.transform")||Components::get_all(e).size()<2||!Components::find_in_children(e,"rustic.transform").empty())throw std::runtime_error("components"); +Hierarchy::set_parent(e,"");Hierarchy::detach(e);if(!Hierarchy::get_children(e).empty()||!Entity::is_valid(e))throw std::runtime_error("hierarchy"); +if(World::find_in_radius({0,0,0},1).size()!=1)throw std::runtime_error("radius"); +if(!Tags::remove(e,"enemy")||Tags::remove(e,"enemy")){throw std::runtime_error("remove");} +Tags::add(e,"enemy");Entity::destroy(Tags::find_all("disposable").front());Entity::destroy_after(e,1);} +int main(){return rustic_run(RusticBehavior{.on_start=start});}"#), + (ScriptLanguage::CSharp, "entities.cs", br#"using static Rustic; +Run((callback,dt)=>{if(callback=="on_start"){var e=rustic.entity_id(); +if(!Tags.add(e,"enemy")||Tags.add(e,"enemy"))throw new System.Exception("tags"); +if(Tags.find_all("enemy").Length!=1||World.find_by_tag("enemy").Length!=1)throw new System.Exception("find"); +Groups.create("enemies");Groups.add("enemies",e);if(Groups.get_members("enemies").Length!=1)throw new System.Exception("groups"); +if(!Components.has(e,"rustic.transform")||Components.get_all(e).Length<2||Components.find_in_children(e,"rustic.transform").Length!=0)throw new System.Exception("components"); +Hierarchy.set_parent(e,null);Hierarchy.detach(e);if(Hierarchy.get_children(e).Length!=0||!Entity.is_valid(e))throw new System.Exception("hierarchy"); +if(World.find_in_radius(new double[]{0,0,0},1).Length!=1)throw new System.Exception("radius"); +Tags.remove(e,"enemy");Tags.add(e,"enemy");Entity.destroy(Tags.find_all("disposable")[0]);Entity.destroy_after(e,1);}});"#), + (ScriptLanguage::Java, "RusticBehavior.java", br#"class RusticBehavior extends Rustic { +public static void main(String[]args)throws Exception{run((callback,dt)->{if(callback.equals("on_start")){String e=rustic.entity_id(); +if(!Tags.add(e,"enemy")||Tags.add(e,"enemy"))throw new RuntimeException("tags"); +if(Tags.find_all("enemy").length!=1||World.find_by_tag("enemy").length!=1)throw new RuntimeException("find"); +Groups.create("enemies");Groups.add("enemies",e);if(Groups.get_members("enemies").length!=1)throw new RuntimeException("groups"); +if(!Components.has(e,"rustic.transform")||Components.get_all(e).length<2||Components.find_in_children(e,"rustic.transform").length!=0)throw new RuntimeException("components"); +Hierarchy.set_parent(e,null);Hierarchy.detach(e);if(Hierarchy.get_children(e).length!=0||!Entity.is_valid(e))throw new RuntimeException("hierarchy"); +if(World.find_in_radius(new double[]{0,0,0},1).length!=1)throw new RuntimeException("radius"); +Tags.remove(e,"enemy");Tags.add(e,"enemy");Entity.destroy(Tags.find_all("disposable")[0]);Entity.destroy_after(e,1);}});}}"#), + (ScriptLanguage::Php, "entities.php", br#"entity_id(); +if(!Tags::add($e,'enemy')||Tags::add($e,'enemy'))throw new Exception('tags'); +if(count(Tags::find_all('enemy'))!==1||count(World::find_by_tag('enemy'))!==1)throw new Exception('find'); +Groups::create('enemies');Groups::add('enemies',$e);if(count(Groups::get_members('enemies'))!==1)throw new Exception('groups'); +if(!Components::has($e,'rustic.transform')||count(Components::get_all($e))<2||count(Components::find_in_children($e,'rustic.transform')))throw new Exception('components'); +Hierarchy::set_parent($e,null);Hierarchy::detach($e);if(count(Hierarchy::get_children($e))||!Entity::is_valid($e))throw new Exception('hierarchy'); +if(count(World::find_in_radius([0,0,0],1))!==1)throw new Exception('radius'); +Tags::remove($e,'enemy');Tags::add($e,'enemy');Entity::destroy(Tags::find_all("disposable")[0]);Entity::destroy_after($e,1);} +rustic_run(['on_start'=>'on_start']);"#), + ]; + for (language, name, source) in fixtures { + if !matches!( + language, + ScriptLanguage::Lua54 | ScriptLanguage::JavaScript | ScriptLanguage::Web + ) && !engine_scripting::probe_language_toolchain(*language).available + { + eprintln!( + "SKIPPED entity foundations {}: missing toolchain", + language.display_name() + ); + continue; + } + let mut world = SceneWorld::new(); + let entity = engine_world::EntitySnapshot::default(); + let id = entity.id; + world + .apply_commands(&[WorldCommand::Spawn(Box::new(entity))]) + .unwrap(); + let disposable = engine_world::EntitySnapshot { + local_transform: engine_world::LocalTransform { + translation: glam::Vec3::new(100., 0., 0.), + ..engine_world::LocalTransform::IDENTITY + }, + ..engine_world::EntitySnapshot::default() + }; + let disposable_id = disposable.id; + world + .apply_commands(&[WorldCommand::Spawn(Box::new(disposable))]) + .unwrap(); + world.add_tag(disposable_id, "disposable").unwrap(); + let world = Arc::new(Mutex::new(world)); + let mut host = environment_host(&world); + host.entity = id; + let mut behavior = + RuntimeBehavior::load(*language, ScriptId::new(), source, name, Box::new(host)) + .unwrap_or_else(|e| panic!("{} load: {e}", language.display_name())); + behavior + .on_start() + .unwrap_or_else(|e| panic!("{} start: {e}", language.display_name())); + let mut world = world.lock().unwrap(); + assert!( + !world.contains(disposable_id), + "{} destroy binding", + language.display_name() + ); + assert!(world.find_by_tag("disposable").unwrap().is_empty()); + assert_eq!(world.find_by_tag("enemy").unwrap(), vec![id]); + assert_eq!(world.group_members("enemies").unwrap(), vec![id]); + world.tick_entity_lifetimes(0.5).unwrap(); + assert!(world.contains(id)); + world.tick_entity_lifetimes(0.5).unwrap(); + assert!(!world.contains(id)); + assert!(world.group_members("enemies").unwrap().is_empty()); + eprintln!("PASSED entity foundations {}", language.display_name()); + } + } + + #[test] + fn destroying_an_owner_cleans_callbacks_and_invokes_destroy_once() { + let mut world = SceneWorld::new(); + let entity = engine_world::EntitySnapshot::default(); + let id = entity.id; + world + .apply_commands(&[WorldCommand::Spawn(Box::new(entity))]) + .unwrap(); + let world = Arc::new(Mutex::new(world)); + let mut host = environment_host(&world); + host.entity = id; + host.entity_bound = true; + let owner = Owner { + entity_bound: true, + entity: id, + script: host.owner_script, + scene: host.gameplay.lock().unwrap().scene, + }; + let gameplay = Arc::clone(&host.gameplay); + let logs = Arc::clone(&host.logs); + let mut behavior=RuntimeBehavior::load(ScriptLanguage::Lua54, ScriptId::new(), br" + return {Start=function() Events.on('retained',function() end);Timer.after(100,function() end) end, + FixedUpdate=function() error('dead owner was called') end, + OnDestroy=function() rustic.log('info','destroyed once') end} + ", "lifetime.lua",Box::new(host)).unwrap(); + behavior.on_start().unwrap(); + let mut scripts = RuntimeScripts { + gameplay, + behaviors: vec![behavior], + world: Arc::clone(&world), + input_keys: Arc::new(Mutex::new(BTreeSet::new())), + logs: Arc::clone(&logs), + dropped_logs: Arc::new(Mutex::new(0)), + valid_scene: true, + }; + world.lock().unwrap().destroy_entity(id).unwrap(); + scripts.fixed_update(0.01); + scripts.fixed_update(0.01); + assert!(scripts.behaviors.is_empty()); + let context = scripts.gameplay.lock().unwrap(); + assert!(context.runtime.states(owner).is_empty()); + assert!(context.runtime.signals.tokens(owner).is_empty()); + drop(context); + let logs = logs.lock().unwrap(); + assert_eq!( + logs.iter() + .filter(|(_, message)| message == "destroyed once") + .count(), + 1 + ); + assert!(!logs.iter().any(|(level, _)| level == "error")); + } + + #[test] + fn documented_entity_foundation_examples_execute_in_the_live_world() { + for (language, name, source) in [ + ( + ScriptLanguage::Lua54, + "behavior.lua", + include_bytes!("../../../examples/entity-foundations/behavior.lua").as_slice(), + ), + ( + ScriptLanguage::Python, + "behavior.py", + include_bytes!("../../../examples/entity-foundations/behavior.py").as_slice(), + ), + ] { + if language == ScriptLanguage::Python + && !engine_scripting::probe_language_toolchain(language).available + { + eprintln!("SKIPPED documented Python example: missing toolchain"); + continue; + } + let mut world = SceneWorld::new(); + let entity = engine_world::EntitySnapshot::default(); + let id = entity.id; + world + .apply_commands(&[WorldCommand::Spawn(Box::new(entity))]) + .unwrap(); + let world = Arc::new(Mutex::new(world)); + let mut host = environment_host(&world); + host.entity = id; + let logs = Arc::clone(&host.logs); + let mut behavior = + RuntimeBehavior::load(language, ScriptId::new(), source, name, Box::new(host)) + .unwrap(); + behavior.on_start().unwrap(); + assert!( + logs.lock() + .unwrap() + .iter() + .any(|(_, message)| message == "entity foundations ready") + ); + let mut world = world.lock().unwrap(); + assert_eq!(world.find_by_tag("enemy").unwrap(), vec![id]); + assert_eq!(world.group_members("enemies").unwrap(), vec![id]); + world.tick_entity_lifetimes(1.).unwrap(); + assert!(!world.contains(id)); + assert!(world.group_members("enemies").unwrap().is_empty()); + } + } + fn environment_host(world: &Arc>) -> RuntimeHost { RuntimeHost { gameplay: Arc::new(Mutex::new(GameplayContext::default())), diff --git a/Engine/crates/engine-scripting/src/cpp_sdk.rs b/Engine/crates/engine-scripting/src/cpp_sdk.rs index e9475104..23d97112 100644 --- a/Engine/crates/engine-scripting/src/cpp_sdk.rs +++ b/Engine/crates/engine-scripting/src/cpp_sdk.rs @@ -312,6 +312,37 @@ inline auto lookAt(const std::string&e,RusticVector3 target,double duration=0,co inline auto headTracking(const std::string&e,RusticVector3 target,double d=0,const std::string&ease="Linear"){return Movement::lookAt(e,target,d,ease);} inline auto recoil(const std::string&e,std::array r,double d,const std::string&ease="Linear"){return Gameplay::start(Tween::action(e,"Rotation",RusticValue::Array{r[0],r[1],r[2],r[3]},d,ease),false,1,true);} } + +namespace Tags { +inline bool add(const std::string& entity,const std::string& name){return rustic.query(rustic_detail::object({{"op","tags_add"},{"entity",Gameplay::entity(entity)},{"name",name}})).boolean();} +inline bool remove(const std::string& entity,const std::string& name){return rustic.query(rustic_detail::object({{"op","tags_remove"},{"entity",Gameplay::entity(entity)},{"name",name}})).boolean();} +inline std::vector find_all(const std::string& name){std::vector out;auto response=rustic.query(rustic_detail::object({{"op","tags_find_all"},{"name",name}}));for(const auto& v:response.array())out.push_back(v.string());return out;} +} +namespace Groups { +inline bool create(const std::string& name){return rustic.query(rustic_detail::object({{"op","groups_create"},{"name",name}})).boolean();} +inline bool add(const std::string& name,const std::string& entity){return rustic.query(rustic_detail::object({{"op","groups_add"},{"name",name},{"entity",Gameplay::entity(entity)}})).boolean();} +inline std::vector get_members(const std::string& name){std::vector out;auto response=rustic.query(rustic_detail::object({{"op","groups_get_members"},{"name",name}}));for(const auto& v:response.array())out.push_back(v.string());return out;} +} +namespace Hierarchy { +inline void set_parent(const std::string& entity,const std::string& parent,bool keep_world=true){(void)rustic.query(rustic_detail::object({{"op","hierarchy_set_parent"},{"entity",Gameplay::entity(entity)},{"parent",(parent.empty()?RusticValue{}:RusticValue(Gameplay::entity(parent)))},{"keep_world",keep_world}}));} +inline std::vector get_children(const std::string& entity){std::vector out;auto response=rustic.query(rustic_detail::object({{"op","hierarchy_get_children"},{"entity",Gameplay::entity(entity)}}));for(const auto& v:response.array())out.push_back(v.string());return out;} +inline void detach(const std::string& entity,bool keep_world=true){(void)rustic.query(rustic_detail::object({{"op","hierarchy_detach"},{"entity",Gameplay::entity(entity)},{"keep_world",keep_world}}));} +} +namespace Entity { +inline void destroy(const std::string& entity){(void)rustic.query(rustic_detail::object({{"op","entity_destroy"},{"entity",Gameplay::entity(entity)}}));} +inline void destroy_after(const std::string& entity,double delay){(void)rustic.query(rustic_detail::object({{"op","entity_destroy_after"},{"entity",Gameplay::entity(entity)},{"delay",delay}}));} +inline bool is_valid(const std::string& entity){return rustic.query(rustic_detail::object({{"op","entity_is_valid"},{"entity",Gameplay::entity(entity)}})).boolean();} +} +namespace Components { +inline bool has(const std::string& entity,const std::string& name){return rustic.query(rustic_detail::object({{"op","components_has"},{"entity",Gameplay::entity(entity)},{"name",name}})).boolean();} +inline std::vector get_all(const std::string& entity){std::vector out;auto response=rustic.query(rustic_detail::object({{"op","components_get_all"},{"entity",Gameplay::entity(entity)}}));for(const auto& v:response.array())out.push_back(v.string());return out;} +inline std::vector find_in_children(const std::string& entity,const std::string& name){std::vector out;auto response=rustic.query(rustic_detail::object({{"op","components_find_in_children"},{"entity",Gameplay::entity(entity)},{"name",name}}));for(const auto& v:response.array())out.push_back(v.string());return out;} +} +namespace World { +inline std::vector find_by_tag(const std::string& name){std::vector out;auto response=rustic.query(rustic_detail::object({{"op","world_find_by_tag"},{"name",name}}));for(const auto& v:response.array())out.push_back(v.string());return out;} +inline std::vector find_in_radius(RusticVector3 center,double radius){std::vector out;auto response=rustic.query(rustic_detail::object({{"op","world_find_in_radius"},{"center",Physics::vec(center)},{"radius",radius}}));for(const auto& v:response.array())out.push_back(v.string());return out;} +} + struct RusticBehavior {std::function on_create={},on_start={},on_enable={},on_disable={},on_destroy={},on_stop={};std::function fixed_update={},update={};}; inline int rustic_run(const RusticBehavior& b){std::string line;while(std::getline(std::cin,line)){try{auto state=rustic_detail::Parser(line).parse();std::vector commands;rustic.state=&state;rustic.commands=&commands;instance.commands=&commands;Game.commands=&commands;Game.scene.state=&state.at("scene_paths");auto cb=state.at("callback").string();double d=state.at("delta").is_null()?0:state.at("delta").number();if(cb=="update")Gameplay::dispatch();if(cb=="on_create"&&b.on_create)b.on_create();else if(cb=="on_start"&&b.on_start)b.on_start();else if(cb=="on_enable"&&b.on_enable)b.on_enable();else if(cb=="on_disable"&&b.on_disable)b.on_disable();else if(cb=="fixed_update"&&b.fixed_update)b.fixed_update(d);else if(cb=="update"&&b.update)b.update(d);else if(cb=="on_destroy"&&b.on_destroy)b.on_destroy();else if(cb=="on_stop"&&b.on_stop)b.on_stop();std::cout< Result<(), String>; + /// True when a component owner was destroyed; distinguishes destruction from + /// disabling a script or a global/UI host with no scene entity. + fn entity_lifetime_ended(&self) -> bool { + false + } fn enabled(&self) -> bool { true } diff --git a/Engine/crates/engine-scripting/src/sdk/Rustic.cs b/Engine/crates/engine-scripting/src/sdk/Rustic.cs index bdf12f58..12284421 100644 --- a/Engine/crates/engine-scripting/src/sdk/Rustic.cs +++ b/Engine/crates/engine-scripting/src/sdk/Rustic.cs @@ -17,6 +17,37 @@ internal static JsonElement Query(object query){ if(response.RootElement.TryGetProperty("error",out var error))throw new InvalidOperationException(error.GetString()); return response.RootElement.GetProperty("result").Clone(); } + + public static class Tags { + public static bool add(string? entity,string name)=>Query(new{op="tags_add",entity=Gameplay.Entity(entity),name=name}).GetBoolean(); + public static bool remove(string? entity,string name)=>Query(new{op="tags_remove",entity=Gameplay.Entity(entity),name=name}).GetBoolean(); + public static string[] find_all(string name)=>Query(new{op="tags_find_all",name=name}).EnumerateArray().Select(v=>v.GetString()!).ToArray(); + } + public static class Groups { + public static bool create(string name)=>Query(new{op="groups_create",name=name}).GetBoolean(); + public static bool add(string name,string? entity)=>Query(new{op="groups_add",name=name,entity=Gameplay.Entity(entity)}).GetBoolean(); + public static string[] get_members(string name)=>Query(new{op="groups_get_members",name=name}).EnumerateArray().Select(v=>v.GetString()!).ToArray(); + } + public static class Hierarchy { + public static void set_parent(string? entity,string? parent,bool keep_world=true)=>Query(new{op="hierarchy_set_parent",entity=Gameplay.Entity(entity),parent=(parent is null?null:Gameplay.Entity(parent)),keep_world=keep_world}); + public static string[] get_children(string? entity)=>Query(new{op="hierarchy_get_children",entity=Gameplay.Entity(entity)}).EnumerateArray().Select(v=>v.GetString()!).ToArray(); + public static void detach(string? entity,bool keep_world=true)=>Query(new{op="hierarchy_detach",entity=Gameplay.Entity(entity),keep_world=keep_world}); + } + public static class Entity { + public static void destroy(string? entity)=>Query(new{op="entity_destroy",entity=Gameplay.Entity(entity)}); + public static void destroy_after(string? entity,double delay)=>Query(new{op="entity_destroy_after",entity=Gameplay.Entity(entity),delay=delay}); + public static bool is_valid(string? entity)=>Query(new{op="entity_is_valid",entity=Gameplay.Entity(entity)}).GetBoolean(); + } + public static class Components { + public static bool has(string? entity,string name)=>Query(new{op="components_has",entity=Gameplay.Entity(entity),name=name}).GetBoolean(); + public static string[] get_all(string? entity)=>Query(new{op="components_get_all",entity=Gameplay.Entity(entity)}).EnumerateArray().Select(v=>v.GetString()!).ToArray(); + public static string[] find_in_children(string? entity,string name)=>Query(new{op="components_find_in_children",entity=Gameplay.Entity(entity),name=name}).EnumerateArray().Select(v=>v.GetString()!).ToArray(); + } + public static class World { + public static string[] find_by_tag(string name)=>Query(new{op="world_find_by_tag",name=name}).EnumerateArray().Select(v=>v.GetString()!).ToArray(); + public static string[] find_in_radius(double[] center,double radius)=>Query(new{op="world_find_in_radius",center=center,radius=radius}).EnumerateArray().Select(v=>v.GetString()!).ToArray(); + } + internal static object Ordinary(JsonElement v)=>v.ValueKind switch{JsonValueKind.String=>v.GetString()!,JsonValueKind.True=>true,JsonValueKind.False=>false,JsonValueKind.Number=>v.GetDouble(),JsonValueKind.Array=>v.EnumerateArray().Select(n=>n.GetDouble()).ToArray(),_=>throw new InvalidOperationException("unsupported gameplay value")}; public sealed record MotionOptions(double? duration=null,double? speed=null,string easing="Linear"); public static class Smooth{ diff --git a/Engine/crates/engine-scripting/src/sdk/Rustic.java b/Engine/crates/engine-scripting/src/sdk/Rustic.java index ee33c7e7..febbe0b8 100644 --- a/Engine/crates/engine-scripting/src/sdk/Rustic.java +++ b/Engine/crates/engine-scripting/src/sdk/Rustic.java @@ -11,6 +11,39 @@ class Rustic { static final RusticApi rustic = new RusticApi(); static final InstanceApi instance = new InstanceApi(); static final GameApi Game = new GameApi(); + + static class Tags { + static boolean add(String entity,String name){return (Boolean)query(args("tags_add","entity",Gameplay.entity(entity),"name",name));} + static boolean remove(String entity,String name){return (Boolean)query(args("tags_remove","entity",Gameplay.entity(entity),"name",name));} + static String[] find_all(String name){return ((List)query(args("tags_find_all","name",name))).stream().map(v->(String)v).toArray(String[]::new);} + } + static class Groups { + static boolean create(String name){return (Boolean)query(args("groups_create","name",name));} + static boolean add(String name,String entity){return (Boolean)query(args("groups_add","name",name,"entity",Gameplay.entity(entity)));} + static String[] get_members(String name){return ((List)query(args("groups_get_members","name",name))).stream().map(v->(String)v).toArray(String[]::new);} + } + static class Hierarchy { + static void set_parent(String entity,String parent,boolean keep_world){query(args("hierarchy_set_parent","entity",Gameplay.entity(entity),"parent",(parent==null?null:Gameplay.entity(parent)),"keep_world",keep_world));} + static void set_parent(String entity,String parent){set_parent(entity,parent,true);} + static String[] get_children(String entity){return ((List)query(args("hierarchy_get_children","entity",Gameplay.entity(entity)))).stream().map(v->(String)v).toArray(String[]::new);} + static void detach(String entity,boolean keep_world){query(args("hierarchy_detach","entity",Gameplay.entity(entity),"keep_world",keep_world));} + static void detach(String entity){detach(entity,true);} + } + static class Entity { + static void destroy(String entity){query(args("entity_destroy","entity",Gameplay.entity(entity)));} + static void destroy_after(String entity,double delay){query(args("entity_destroy_after","entity",Gameplay.entity(entity),"delay",delay));} + static boolean is_valid(String entity){return (Boolean)query(args("entity_is_valid","entity",Gameplay.entity(entity)));} + } + static class Components { + static boolean has(String entity,String name){return (Boolean)query(args("components_has","entity",Gameplay.entity(entity),"name",name));} + static String[] get_all(String entity){return ((List)query(args("components_get_all","entity",Gameplay.entity(entity)))).stream().map(v->(String)v).toArray(String[]::new);} + static String[] find_in_children(String entity,String name){return ((List)query(args("components_find_in_children","entity",Gameplay.entity(entity),"name",name))).stream().map(v->(String)v).toArray(String[]::new);} + } + static class World { + static String[] find_by_tag(String name){return ((List)query(args("world_find_by_tag","name",name))).stream().map(v->(String)v).toArray(String[]::new);} + static String[] find_in_radius(double[] center,double radius){return ((List)query(args("world_find_in_radius","center",center,"radius",radius))).stream().map(v->(String)v).toArray(String[]::new);} + } + interface Callback { void invoke(String callback, double dt) throws Exception; } static void run(Callback callback) throws Exception { input = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)); diff --git a/Engine/crates/engine-scripting/src/sdk/entity-api.d.ts b/Engine/crates/engine-scripting/src/sdk/entity-api.d.ts new file mode 100644 index 00000000..8a36a140 --- /dev/null +++ b/Engine/crates/engine-scripting/src/sdk/entity-api.d.ts @@ -0,0 +1,30 @@ +// Runtime entity services. Source: the shared engine-world/query boundary. +declare const Tags: { + add(e: string | null | RusticObjectPath, name: string): boolean; + remove(e: string | null | RusticObjectPath, name: string): boolean; + find_all(name: string): string[]; +}; +declare const Groups: { + create(group: string): boolean; + add(group: string, e: string | null | RusticObjectPath): boolean; + get_members(group: string): string[]; +}; +declare const Hierarchy: { + set_parent(e: string | null | RusticObjectPath, parent: string | null | RusticObjectPath, keep?: boolean): void; + get_children(e: string | null | RusticObjectPath): string[]; + detach(e: string | null | RusticObjectPath, keep?: boolean): void; +}; +declare const Entity: { + destroy(e: string | null | RusticObjectPath): void; + destroy_after(e: string | null | RusticObjectPath, delay: number): void; + is_valid(e: string | null | RusticObjectPath): boolean; +}; +declare const Components: { + has(e: string | null | RusticObjectPath, schema: string): boolean; + get_all(e: string | null | RusticObjectPath): string[]; + find_in_children(e: string | null | RusticObjectPath, schema: string): string[]; +}; +declare const World: { + find_by_tag(name: string): string[]; + find_in_radius(center: [number, number, number], radius: number): string[]; +}; diff --git a/Engine/crates/engine-scripting/src/sdk/entity-api.lua b/Engine/crates/engine-scripting/src/sdk/entity-api.lua new file mode 100644 index 00000000..d099543a --- /dev/null +++ b/Engine/crates/engine-scripting/src/sdk/entity-api.lua @@ -0,0 +1,31 @@ + +-- Runtime entity service annotations. +---@class TagsApi +---@field add fun(e:string|RusticObjectPath|nil,name:string):boolean +---@field remove fun(e:string|RusticObjectPath|nil,name:string):boolean +---@field find_all fun(name:string):string[] +Tags={} +---@class GroupsApi +---@field create fun(group:string):boolean +---@field add fun(group:string,e:string|RusticObjectPath|nil):boolean +---@field get_members fun(group:string):string[] +Groups={} +---@class HierarchyApi +---@field set_parent fun(e:string|RusticObjectPath|nil,parent:string|RusticObjectPath|nil,keep:boolean|nil) +---@field get_children fun(e:string|RusticObjectPath|nil):string[] +---@field detach fun(e:string|RusticObjectPath|nil,keep:boolean|nil) +Hierarchy={} +---@class EntityApi +---@field destroy fun(e:string|RusticObjectPath|nil) +---@field destroy_after fun(e:string|RusticObjectPath|nil,delay:number) +---@field is_valid fun(e:string|RusticObjectPath|nil):boolean +Entity={} +---@class ComponentsApi +---@field has fun(e:string|RusticObjectPath|nil,schema:string):boolean +---@field get_all fun(e:string|RusticObjectPath|nil):string[] +---@field find_in_children fun(e:string|RusticObjectPath|nil,schema:string):string[] +Components={} +---@class WorldApi +---@field find_by_tag fun(name:string):string[] +---@field find_in_radius fun(center:number[],radius:number):string[] +World={} diff --git a/Engine/crates/engine-scripting/src/sdk/gameplay.js b/Engine/crates/engine-scripting/src/sdk/gameplay.js index 01c0c2e5..2397c6f2 100644 --- a/Engine/crates/engine-scripting/src/sdk/gameplay.js +++ b/Engine/crates/engine-scripting/src/sdk/gameplay.js @@ -134,3 +134,41 @@ globalThis.Smooth={ globalThis.Clock={timeScale:scale=>rustic.query({op:"clock",scale}).scale,pause:()=>rustic.query({op:"clock",paused:true}),resume:()=>rustic.query({op:"clock",paused:false}),state:()=>rustic.query({op:"clock"})}; Camera.current=()=>rustic.query({op:"camera_current"}); {const move=Camera.moveTo,zoom=Camera.zoom;Camera.moveTo=(a,b,c,d)=>Array.isArray(a)?move(Camera.current(),a,b,c):move(a,b,c,d);Camera.zoom=(a,b,c,d)=>typeof a==="number"?zoom(Camera.current(),a,b,c):zoom(a,b,c,d);Camera.fov=Camera.zoom;} + +const _rusticEntity = object => { + if(object==null)return rustic.entity_id(); + if(typeof object==="object")object=object.__source||object.id||object.source; + if(typeof object!=="string")throw new TypeError("expected entity ID or path"); + return Game.scene.Find(object)||object; +}; + +// Entity services use the live shared Rust world. +globalThis.Tags={ +add(entity,name){return rustic.query({op:"tags_add",entity:_rusticEntity(entity),name:name});}, +remove(entity,name){return rustic.query({op:"tags_remove",entity:_rusticEntity(entity),name:name});}, +find_all(name){return rustic.query({op:"tags_find_all",name:name});}, +}; +globalThis.Groups={ +create(name){return rustic.query({op:"groups_create",name:name});}, +add(name,entity){return rustic.query({op:"groups_add",name:name,entity:_rusticEntity(entity)});}, +get_members(name){return rustic.query({op:"groups_get_members",name:name});}, +}; +globalThis.Hierarchy={ +set_parent(entity,parent,keep_world=true){return rustic.query({op:"hierarchy_set_parent",entity:_rusticEntity(entity),parent:(parent==null?null:_rusticEntity(parent)),keep_world:keep_world});}, +get_children(entity){return rustic.query({op:"hierarchy_get_children",entity:_rusticEntity(entity)});}, +detach(entity,keep_world=true){return rustic.query({op:"hierarchy_detach",entity:_rusticEntity(entity),keep_world:keep_world});}, +}; +globalThis.Entity={ +destroy(entity){return rustic.query({op:"entity_destroy",entity:_rusticEntity(entity)});}, +destroy_after(entity,delay){return rustic.query({op:"entity_destroy_after",entity:_rusticEntity(entity),delay:delay});}, +is_valid(entity){return rustic.query({op:"entity_is_valid",entity:_rusticEntity(entity)});}, +}; +globalThis.Components={ +has(entity,name){return rustic.query({op:"components_has",entity:_rusticEntity(entity),name:name});}, +get_all(entity){return rustic.query({op:"components_get_all",entity:_rusticEntity(entity)});}, +find_in_children(entity,name){return rustic.query({op:"components_find_in_children",entity:_rusticEntity(entity),name:name});}, +}; +globalThis.World={ +find_by_tag(name){return rustic.query({op:"world_find_by_tag",name:name});}, +find_in_radius(center,radius){return rustic.query({op:"world_find_in_radius",center:center,radius:radius});}, +}; diff --git a/Engine/crates/engine-scripting/src/sdk/gameplay.lua b/Engine/crates/engine-scripting/src/sdk/gameplay.lua index 674ca8e3..e001fcba 100644 --- a/Engine/crates/engine-scripting/src/sdk/gameplay.lua +++ b/Engine/crates/engine-scripting/src/sdk/gameplay.lua @@ -343,3 +343,29 @@ rustic.game.start_recording=rustic.game.startRecording rustic.game.stop_recording=rustic.game.stopRecording rustic.game.start_hot_mic=rustic.game.startHotMic rustic.game.stop_hot_mic=rustic.game.stopHotMic + +-- Entity services use the live shared Rust world, never cached scene paths. +local resolve_entity=entity +Tags={} +function Tags.add(entity,name) return rustic.query({op="tags_add",entity=resolve_entity(entity),name=name}) end +function Tags.remove(entity,name) return rustic.query({op="tags_remove",entity=resolve_entity(entity),name=name}) end +function Tags.find_all(name) return rustic.query({op="tags_find_all",name=name}) end +Groups={} +function Groups.create(name) return rustic.query({op="groups_create",name=name}) end +function Groups.add(name,entity) return rustic.query({op="groups_add",name=name,entity=resolve_entity(entity)}) end +function Groups.get_members(name) return rustic.query({op="groups_get_members",name=name}) end +Hierarchy={} +function Hierarchy.set_parent(entity,parent,keep_world) return rustic.query({op="hierarchy_set_parent",entity=resolve_entity(entity),parent=(parent~=nil and resolve_entity(parent) or nil),keep_world=(keep_world==nil or keep_world)}) end +function Hierarchy.get_children(entity) return rustic.query({op="hierarchy_get_children",entity=resolve_entity(entity)}) end +function Hierarchy.detach(entity,keep_world) return rustic.query({op="hierarchy_detach",entity=resolve_entity(entity),keep_world=(keep_world==nil or keep_world)}) end +Entity={} +function Entity.destroy(entity) return rustic.query({op="entity_destroy",entity=resolve_entity(entity)}) end +function Entity.destroy_after(entity,delay) return rustic.query({op="entity_destroy_after",entity=resolve_entity(entity),delay=delay}) end +function Entity.is_valid(entity) return rustic.query({op="entity_is_valid",entity=resolve_entity(entity)}) end +Components={} +function Components.has(entity,name) return rustic.query({op="components_has",entity=resolve_entity(entity),name=name}) end +function Components.get_all(entity) return rustic.query({op="components_get_all",entity=resolve_entity(entity)}) end +function Components.find_in_children(entity,name) return rustic.query({op="components_find_in_children",entity=resolve_entity(entity),name=name}) end +World={} +function World.find_by_tag(name) return rustic.query({op="world_find_by_tag",name=name}) end +function World.find_in_radius(center,radius) return rustic.query({op="world_find_in_radius",center=center,radius=radius}) end diff --git a/Engine/crates/engine-scripting/src/sdk/rustic.h b/Engine/crates/engine-scripting/src/sdk/rustic.h index e7c4b05d..4f16302b 100644 --- a/Engine/crates/engine-scripting/src/sdk/rustic.h +++ b/Engine/crates/engine-scripting/src/sdk/rustic.h @@ -153,6 +153,7 @@ static const struct { bool (*any_key_pressed)(void);void (*log)(const char*,const char*),(*set_enabled)(bool); } rustic={{r_edit_object_attribute},r_entity_id,r_delta_time,r_fixed_delta_time,r_translation,r_set_translation,r_property,r_attribute,r_set_property,r_edit_attribute,r_input,r_key,r_events,r_any,r_log,r_enabled}; static const struct {void (*add)(const char*,const char*),(*clone)(const char*,const char*);} instance={r_add,r_clone}; + static RusticValue r_environment_get(const char *field); static void r_environment_set(const char *field,RusticValue value); static void r_environment_sky(const char *path); @@ -210,6 +211,31 @@ static RQueryResult r_query_end(RQuerySave saved){ strcpy(r_source,saved.source);memcpy(r_tokens,saved.tokens,saved.count*sizeof(RToken));r_count=saved.count;r_at=saved.at; free(saved.source);free(saved.pending);free(saved.tokens);r_written=r_commands=0;r_output[0]=0;r_append("{\"format_version\":1,\"commands\":[");return out; } +/* Entity services: returned lists/strings live until the next callback. */ +static bool r_tags_add(const char* entity,const char* name){const char *resolved_entity=r_resolve(entity);RQuerySave s=r_query_begin("tags_add");r_q_string("entity",resolved_entity);r_q_string("name",name);return r_query_end(s).value.boolean;} +static bool r_tags_remove(const char* entity,const char* name){const char *resolved_entity=r_resolve(entity);RQuerySave s=r_query_begin("tags_remove");r_q_string("entity",resolved_entity);r_q_string("name",name);return r_query_end(s).value.boolean;} +static RusticList r_tags_find_all(const char* name){RQuerySave s=r_query_begin("tags_find_all");r_q_string("name",name);return r_query_end(s).list;} +static const struct {bool(*add)(const char*,const char*);bool(*remove)(const char*,const char*);RusticList(*find_all)(const char*);} Tags={r_tags_add,r_tags_remove,r_tags_find_all}; +static bool r_groups_create(const char* name){RQuerySave s=r_query_begin("groups_create");r_q_string("name",name);return r_query_end(s).value.boolean;} +static bool r_groups_add(const char* name,const char* entity){const char *resolved_entity=r_resolve(entity);RQuerySave s=r_query_begin("groups_add");r_q_string("name",name);r_q_string("entity",resolved_entity);return r_query_end(s).value.boolean;} +static RusticList r_groups_get_members(const char* name){RQuerySave s=r_query_begin("groups_get_members");r_q_string("name",name);return r_query_end(s).list;} +static const struct {bool(*create)(const char*);bool(*add)(const char*,const char*);RusticList(*get_members)(const char*);} Groups={r_groups_create,r_groups_add,r_groups_get_members}; +static void r_hierarchy_set_parent(const char* entity,const char* parent,bool keep_world){const char *resolved_entity=r_resolve(entity);const char *resolved_parent=parent?r_resolve(parent):NULL;RQuerySave s=r_query_begin("hierarchy_set_parent");r_q_string("entity",resolved_entity);if(resolved_parent)r_q_string("parent",resolved_parent);else r_append(",\"parent\":null");r_append(keep_world?",\"keep_world\":true":",\"keep_world\":false");(void)r_query_end(s);} +static RusticList r_hierarchy_get_children(const char* entity){const char *resolved_entity=r_resolve(entity);RQuerySave s=r_query_begin("hierarchy_get_children");r_q_string("entity",resolved_entity);return r_query_end(s).list;} +static void r_hierarchy_detach(const char* entity,bool keep_world){const char *resolved_entity=r_resolve(entity);RQuerySave s=r_query_begin("hierarchy_detach");r_q_string("entity",resolved_entity);r_append(keep_world?",\"keep_world\":true":",\"keep_world\":false");(void)r_query_end(s);} +static const struct {void(*set_parent)(const char*,const char*,bool);RusticList(*get_children)(const char*);void(*detach)(const char*,bool);} Hierarchy={r_hierarchy_set_parent,r_hierarchy_get_children,r_hierarchy_detach}; +static void r_entity_destroy(const char* entity){const char *resolved_entity=r_resolve(entity);RQuerySave s=r_query_begin("entity_destroy");r_q_string("entity",resolved_entity);(void)r_query_end(s);} +static void r_entity_destroy_after(const char* entity,double delay){const char *resolved_entity=r_resolve(entity);RQuerySave s=r_query_begin("entity_destroy_after");r_q_string("entity",resolved_entity);r_q_number("delay",delay);(void)r_query_end(s);} +static bool r_entity_is_valid(const char* entity){const char *resolved_entity=r_resolve(entity);RQuerySave s=r_query_begin("entity_is_valid");r_q_string("entity",resolved_entity);return r_query_end(s).value.boolean;} +static const struct {void(*destroy)(const char*);void(*destroy_after)(const char*,double);bool(*is_valid)(const char*);} Entity={r_entity_destroy,r_entity_destroy_after,r_entity_is_valid}; +static bool r_components_has(const char* entity,const char* name){const char *resolved_entity=r_resolve(entity);RQuerySave s=r_query_begin("components_has");r_q_string("entity",resolved_entity);r_q_string("name",name);return r_query_end(s).value.boolean;} +static RusticList r_components_get_all(const char* entity){const char *resolved_entity=r_resolve(entity);RQuerySave s=r_query_begin("components_get_all");r_q_string("entity",resolved_entity);return r_query_end(s).list;} +static RusticList r_components_find_in_children(const char* entity,const char* name){const char *resolved_entity=r_resolve(entity);RQuerySave s=r_query_begin("components_find_in_children");r_q_string("entity",resolved_entity);r_q_string("name",name);return r_query_end(s).list;} +static const struct {bool(*has)(const char*,const char*);RusticList(*get_all)(const char*);RusticList(*find_in_children)(const char*,const char*);} Components={r_components_has,r_components_get_all,r_components_find_in_children}; +static RusticList r_world_find_by_tag(const char* name){RQuerySave s=r_query_begin("world_find_by_tag");r_q_string("name",name);return r_query_end(s).list;} +static RusticList r_world_find_in_radius(RusticVector3 center,double radius){RQuerySave s=r_query_begin("world_find_in_radius");r_q_value("center",r_vec(center));r_q_number("radius",radius);return r_query_end(s).list;} +static const struct {RusticList(*find_by_tag)(const char*);RusticList(*find_in_radius)(RusticVector3,double);} World={r_world_find_by_tag,r_world_find_in_radius}; + static RusticValue r_environment_get(const char *field){RQuerySave s=r_query_begin("scene_environment_field");r_q_string("field",field);return r_query_end(s).value;} static void r_environment_set(const char *field,RusticValue value){RQuerySave s=r_query_begin("scene_environment");r_append(",\"settings\":{");r_quote(field);r_append(":");r_write_value(value);r_append("}");(void)r_query_end(s);} static void r_environment_sky(const char *path){r_environment_set("sky_image",(RusticValue){.type=RUSTIC_STRING,.string=path});} diff --git a/Engine/crates/engine-scripting/src/sdk/rustic.php b/Engine/crates/engine-scripting/src/sdk/rustic.php index 6729b7b6..95414333 100644 --- a/Engine/crates/engine-scripting/src/sdk/rustic.php +++ b/Engine/crates/engine-scripting/src/sdk/rustic.php @@ -267,3 +267,34 @@ final class EventConnection{function __construct(public string $token){}function class_alias(Sequence::class,"Timeline"); class_alias(Smooth::class,"Interpolation"); + +// Entity services use the shared Rust runtime. +final class Tags { + static function add(?string $entity,string $name):bool {global $rustic;return $rustic->query(["op"=>"tags_add","entity"=>Gameplay::entity($entity),"name"=>$name]);} + static function remove(?string $entity,string $name):bool {global $rustic;return $rustic->query(["op"=>"tags_remove","entity"=>Gameplay::entity($entity),"name"=>$name]);} + static function find_all(string $name):array {global $rustic;return $rustic->query(["op"=>"tags_find_all","name"=>$name]);} +} +final class Groups { + static function create(string $name):bool {global $rustic;return $rustic->query(["op"=>"groups_create","name"=>$name]);} + static function add(string $name,?string $entity):bool {global $rustic;return $rustic->query(["op"=>"groups_add","name"=>$name,"entity"=>Gameplay::entity($entity)]);} + static function get_members(string $name):array {global $rustic;return $rustic->query(["op"=>"groups_get_members","name"=>$name]);} +} +final class Hierarchy { + static function set_parent(?string $entity,?string $parent,bool $keep_world=true):void {global $rustic;$rustic->query(["op"=>"hierarchy_set_parent","entity"=>Gameplay::entity($entity),"parent"=>($parent===null?null:Gameplay::entity($parent)),"keep_world"=>$keep_world]);} + static function get_children(?string $entity):array {global $rustic;return $rustic->query(["op"=>"hierarchy_get_children","entity"=>Gameplay::entity($entity)]);} + static function detach(?string $entity,bool $keep_world=true):void {global $rustic;$rustic->query(["op"=>"hierarchy_detach","entity"=>Gameplay::entity($entity),"keep_world"=>$keep_world]);} +} +final class Entity { + static function destroy(?string $entity):void {global $rustic;$rustic->query(["op"=>"entity_destroy","entity"=>Gameplay::entity($entity)]);} + static function destroy_after(?string $entity,float $delay):void {global $rustic;$rustic->query(["op"=>"entity_destroy_after","entity"=>Gameplay::entity($entity),"delay"=>$delay]);} + static function is_valid(?string $entity):bool {global $rustic;return $rustic->query(["op"=>"entity_is_valid","entity"=>Gameplay::entity($entity)]);} +} +final class Components { + static function has(?string $entity,string $name):bool {global $rustic;return $rustic->query(["op"=>"components_has","entity"=>Gameplay::entity($entity),"name"=>$name]);} + static function get_all(?string $entity):array {global $rustic;return $rustic->query(["op"=>"components_get_all","entity"=>Gameplay::entity($entity)]);} + static function find_in_children(?string $entity,string $name):array {global $rustic;return $rustic->query(["op"=>"components_find_in_children","entity"=>Gameplay::entity($entity),"name"=>$name]);} +} +final class World { + static function find_by_tag(string $name):array {global $rustic;return $rustic->query(["op"=>"world_find_by_tag","name"=>$name]);} + static function find_in_radius(array $center,float $radius):array {global $rustic;return $rustic->query(["op"=>"world_find_in_radius","center"=>$center,"radius"=>$radius]);} +} diff --git a/Engine/crates/engine-scripting/src/sdk/rustic.py b/Engine/crates/engine-scripting/src/sdk/rustic.py index 42f13606..a0d042be 100644 --- a/Engine/crates/engine-scripting/src/sdk/rustic.py +++ b/Engine/crates/engine-scripting/src/sdk/rustic.py @@ -355,3 +355,45 @@ def resume(): rustic.query(dict(op="clock",paused=False)) @staticmethod def state(): return rustic.query(dict(op="clock")) Camera.current=staticmethod(lambda:rustic.query(dict(op="camera_current"))) + +# Entity services use the shared Rust runtime. +class Tags: + @staticmethod + def add(entity,name): return rustic.query({"op":"tags_add",'entity':_entity(entity),'name':name}) + @staticmethod + def remove(entity,name): return rustic.query({"op":"tags_remove",'entity':_entity(entity),'name':name}) + @staticmethod + def find_all(name): return rustic.query({"op":"tags_find_all",'name':name}) +class Groups: + @staticmethod + def create(name): return rustic.query({"op":"groups_create",'name':name}) + @staticmethod + def add(name,entity): return rustic.query({"op":"groups_add",'name':name,'entity':_entity(entity)}) + @staticmethod + def get_members(name): return rustic.query({"op":"groups_get_members",'name':name}) +class Hierarchy: + @staticmethod + def set_parent(entity,parent,keep_world=True): return rustic.query({"op":"hierarchy_set_parent",'entity':_entity(entity),'parent':(None if parent is None else _entity(parent)),'keep_world':keep_world}) + @staticmethod + def get_children(entity): return rustic.query({"op":"hierarchy_get_children",'entity':_entity(entity)}) + @staticmethod + def detach(entity,keep_world=True): return rustic.query({"op":"hierarchy_detach",'entity':_entity(entity),'keep_world':keep_world}) +class Entity: + @staticmethod + def destroy(entity): return rustic.query({"op":"entity_destroy",'entity':_entity(entity)}) + @staticmethod + def destroy_after(entity,delay): return rustic.query({"op":"entity_destroy_after",'entity':_entity(entity),'delay':delay}) + @staticmethod + def is_valid(entity): return rustic.query({"op":"entity_is_valid",'entity':_entity(entity)}) +class Components: + @staticmethod + def has(entity,name): return rustic.query({"op":"components_has",'entity':_entity(entity),'name':name}) + @staticmethod + def get_all(entity): return rustic.query({"op":"components_get_all",'entity':_entity(entity)}) + @staticmethod + def find_in_children(entity,name): return rustic.query({"op":"components_find_in_children",'entity':_entity(entity),'name':name}) +class World: + @staticmethod + def find_by_tag(name): return rustic.query({"op":"world_find_by_tag",'name':name}) + @staticmethod + def find_in_radius(center,radius): return rustic.query({"op":"world_find_in_radius",'center':center,'radius':radius}) diff --git a/Engine/crates/engine-scripting/src/workspace.rs b/Engine/crates/engine-scripting/src/workspace.rs index 3e090497..49423d18 100644 --- a/Engine/crates/engine-scripting/src/workspace.rs +++ b/Engine/crates/engine-scripting/src/workspace.rs @@ -120,6 +120,7 @@ pub fn generate_programming_workspace( let api_text = format!( "---@meta\n-- {GENERATED_MARKER}\n---@class RusticTransform\n---@field x number\n---@field y number\n---@field z number\n---@class RusticObjectPath\n---@field EditAttribute fun(self:RusticObjectPath,name:string,value:any)\n---@field GetAttribute fun(self:RusticObjectPath,name:string): any\n---@class RusticApi\n---@field game table\n---@field edit_object_attribute fun(source:string,name:string,value:any)\n---@field entity_id fun(): string\n---@field delta_time fun(): number\n---@field fixed_delta_time fun(): number\n---@field get_translation fun(): number, number, number\n---@field set_translation fun(x:number,y:number,z:number)\n---@field find_entity fun(name_or_id:string): string|nil, string|nil\n---@field input fun(action:string): table\n---@field log fun(level:string,message:string,fields:table|nil)\n---@field get_property fun(name:string): any\n---@field set_property fun(name:string,value:any): boolean, string|nil\n---@field set_enabled fun(enabled:boolean)\n---@type RusticApi\nrustic = rustic\n---@class RusticInstanceApi\n---@field add fun(source:string,parent:string|nil): string\n---@field clone fun(source:string,parent:string|nil): string\n---@type RusticInstanceApi\ninstance = instance\n---@class RusticSceneEnvironment\n---@field enabled boolean\n---@field sky_image string\n---@field sky_color number[]\n---@field rotation_degrees number\n---@field exposure number\n---@field ambient_color number[]\n---@field ambient_intensity number\n---@field sun_color number[]\n---@field sun_intensity number\n---@field sun_direction number[]\n---@field haze_color number[]\n---@field haze_density number\n---@field haze_start number\n---@class RusticSceneApi\n---@field Find fun(path:string): string|nil\n---@field List fun(path:string|nil): string[]\n---@field getEnvironment fun(): RusticSceneEnvironment\n---@field setEnvironment fun(settings:table): RusticSceneEnvironment\n---@field setSkyTexture fun(path:string): RusticSceneEnvironment\n---@class RusticGameApi\n---@field scene RusticSceneApi\n---@field setCurrentCamera fun(source:string|table)\n---@type RusticGameApi\nGame = Game\n" ); + let api_text = format!("{api_text}{}", include_str!("sdk/entity-api.lua")); files.write_with_options( &api, api_text.as_bytes(), @@ -129,6 +130,10 @@ pub fn generate_programming_workspace( let javascript_api_text = format!( "// {GENERATED_MARKER}\ntype RusticActionState = Readonly<{{ pressed: boolean; released: boolean; held: boolean; axis: number }}>;\ninterface RusticSceneEnvironment {{\n enabled: boolean;\n sky_image: string;\n sky_color: [number, number, number];\n rotation_degrees: number;\n exposure: number;\n ambient_color: [number, number, number];\n ambient_intensity: number;\n sun_color: [number, number, number];\n sun_intensity: number;\n sun_direction: [number, number, number];\n haze_color: [number, number, number];\n haze_density: number;\n haze_start: number;\n}}\ninterface RusticObjectPath {{ [name: string]: any; EditAttribute(name: string, value: unknown): void; edit_attribute(name: string, value: unknown): void; }}\ninterface RusticApi {{\n game: RusticObjectPath;\n edit_object_attribute(source: string, name: string, value: unknown): void;\n entity_id(): string;\n delta_time(): number;\n fixed_delta_time(): number;\n get_translation(): [number, number, number];\n set_translation(x: number, y: number, z: number): void;\n input(action: string): RusticActionState;\n log(level: string, message: string): void;\n get_property(name: string): unknown;\n set_property(name: string, value: unknown): void;\n set_enabled(enabled: boolean): void;\n}}\ndeclare const Game: Readonly<{{ setCurrentCamera(source: string): void; scene: {{ Find(path: string): string | undefined; List(): string[]; getEnvironment(): RusticSceneEnvironment; setEnvironment(settings: Partial): RusticSceneEnvironment; setSkyTexture(path: string): RusticSceneEnvironment }} }}>;\ndeclare const rustic: Readonly;\ndeclare const instance: Readonly<{{ add(source: string, parent?: string | null): void; clone(source: string, parent?: string | null): void }}>;\ndeclare interface RusticBehavior {{\n on_create?(): void;\n on_start?(): void;\n fixed_update?(delta: number): void;\n update?(delta: number): void;\n on_destroy?(): void;\n on_stop?(): void;\n}}\ndeclare var behavior: RusticBehavior;\n" ); + let javascript_api_text = format!( + "{javascript_api_text}{}", + include_str!("sdk/entity-api.d.ts") + ); files.write_with_options( &javascript_api, javascript_api_text.as_bytes(), diff --git a/Engine/crates/engine-world/src/foundations.rs b/Engine/crates/engine-world/src/foundations.rs new file mode 100644 index 00000000..292a4750 --- /dev/null +++ b/Engine/crates/engine-world/src/foundations.rs @@ -0,0 +1,477 @@ +//! Runtime entity services. Authored scene formats remain unchanged. +use crate::{EntityId, LocalTransform, RuntimeEntityRef, SceneWorld, WorldCommand, WorldError}; +use glam::{Mat4, Vec3}; +use std::collections::{BTreeMap, BTreeSet}; + +const MAX_NAMES: usize = 4096; +const MAX_ENTITY_TAGS: usize = 128; + +#[derive(Default)] +pub(crate) struct EntityServices { + pub tags: BTreeMap>, + pub groups: BTreeMap>, + pub deadlines: BTreeMap, +} + +fn name(value: &str) -> Result<(), WorldError> { + if value.trim().is_empty() || value.len() > 128 || value.chars().any(char::is_control) { + return Err(WorldError::InvalidArgument( + "name must be 1..128 bytes without control characters".into(), + )); + } + Ok(()) +} + +impl SceneWorld { + /// Adds a case-sensitive runtime tag; repeated additions are idempotent. + /// # Errors + /// Rejects absent entities, invalid names and per-entity tag limits. + pub fn add_tag(&mut self, entity: EntityId, tag: &str) -> Result { + self.reference(entity)?; + name(tag)?; + let tags = self.services.tags.entry(entity).or_default(); + if tags.len() >= MAX_ENTITY_TAGS && !tags.contains(tag) { + return Err(WorldError::InvalidArgument( + "entity tag limit reached".into(), + )); + } + Ok(tags.insert(tag.into())) + } + + /// Removes a runtime tag, returning whether it existed. + /// # Errors + /// Rejects absent entities and invalid names. + pub fn remove_tag(&mut self, entity: EntityId, tag: &str) -> Result { + self.reference(entity)?; + name(tag)?; + Ok(self + .services + .tags + .get_mut(&entity) + .is_some_and(|tags| tags.remove(tag))) + } + + /// Returns matching live entities in stable ID order. + /// # Errors + /// Rejects invalid tag names. + pub fn find_by_tag(&self, tag: &str) -> Result, WorldError> { + name(tag)?; + Ok(self + .services + .tags + .iter() + .filter(|(_, tags)| tags.contains(tag)) + .map(|(id, _)| *id) + .collect()) + } + + /// Creates an empty runtime group; repeated creation is idempotent. + /// # Errors + /// Rejects invalid names and the group count limit. + pub fn create_group(&mut self, group: &str) -> Result { + name(group)?; + if self.services.groups.contains_key(group) { + return Ok(false); + } + if self.services.groups.len() >= MAX_NAMES { + return Err(WorldError::InvalidArgument("group limit reached".into())); + } + self.services.groups.insert(group.into(), BTreeSet::new()); + Ok(true) + } + + /// Adds a live entity to an existing group. + /// # Errors + /// Rejects absent entities and unknown groups. + pub fn add_to_group(&mut self, group: &str, entity: EntityId) -> Result { + self.reference(entity)?; + name(group)?; + let members = self + .services + .groups + .get_mut(group) + .ok_or_else(|| WorldError::InvalidArgument("group does not exist".into()))?; + Ok(members.insert(entity)) + } + + /// Returns a group's live members in stable ID order. + /// # Errors + /// Rejects unknown or invalid group names. + pub fn group_members(&self, group: &str) -> Result, WorldError> { + name(group)?; + Ok(self + .services + .groups + .get(group) + .ok_or_else(|| WorldError::InvalidArgument("group does not exist".into()))? + .iter() + .copied() + .collect()) + } + + /// Reparents using the existing command transaction. By default script bindings + /// preserve world position, rotation and scale; non-TRS results are rejected. + /// # Errors + /// Rejects missing objects, cycles and transforms requiring shear. + pub fn reparent( + &mut self, + child: EntityId, + parent: Option, + keep_world: bool, + ) -> Result<(), WorldError> { + self.reference(child)?; + if let Some(parent) = parent { + self.reference(parent)?; + } + self.propagate_transforms(); + let mut commands = vec![WorldCommand::SetParent { child, parent }]; + if keep_world { + let world = self.world_transform(child)?.0; + let local = match parent { + Some(parent) => self.world_transform(parent)?.0.inverse() * world, + None => world, + }; + let (scale, rotation, translation) = local.to_scale_rotation_translation(); + let value = LocalTransform { + translation, + rotation, + scale, + }; + let reconstructed = Mat4::from_scale_rotation_translation(scale, rotation, translation); + let tolerance = local + .to_cols_array() + .iter() + .fold(1.0_f32, |m, v| m.max(v.abs())) + * 1e-4; + if !value.is_finite() + || local + .to_cols_array() + .iter() + .zip(reconstructed.to_cols_array()) + .any(|(a, b)| (*a - b).abs() > tolerance) + { + return Err(WorldError::InvalidArgument( + "world-preserving reparent requires a representable TRS transform (no shear)" + .into(), + )); + } + commands.push(WorldCommand::SetLocalTransform { + entity: child, + value, + }); + } + self.apply_commands(&commands)?; + self.propagate_transforms(); + Ok(()) + } + + /// Returns descendants in stable ID order, excluding the root. + /// # Errors + /// Rejects absent roots. + pub fn descendants(&self, root: EntityId) -> Result, WorldError> { + self.reference(root)?; + let mut pending = vec![root]; + let mut result = Vec::new(); + while let Some(id) = pending.pop() { + for &child in self.children(id)? { + result.push(child); + pending.push(child); + } + } + result.sort_unstable(); + Ok(result) + } + + /// Destroys an entire hierarchy through one validated batch; references become stale. + /// # Errors + /// Rejects absent entities or invalid world commands. + pub fn destroy_entity(&mut self, entity: EntityId) -> Result<(), WorldError> { + let mut ids = self.descendants(entity)?; + ids.push(entity); + self.apply_commands( + &ids.into_iter() + .map(WorldCommand::Despawn) + .collect::>(), + )?; + Ok(()) + } + + /// Schedules destruction on the scaled fixed simulation clock. Replaces a prior + /// deadline; zero destroys immediately. Deadlines retain generational references. + /// # Errors + /// Rejects stale entities and negative/non-finite delays. + pub fn destroy_after(&mut self, entity: EntityId, delay: f64) -> Result<(), WorldError> { + let reference = self.reference(entity)?; + if !delay.is_finite() || delay < 0.0 { + return Err(WorldError::InvalidArgument( + "delay must be finite and nonnegative".into(), + )); + } + if delay == 0.0 { + return self.destroy_entity(entity); + } + self.services.deadlines.insert(entity, (reference, delay)); + Ok(()) + } + + /// Advances runtime lifetime deadlines once per fixed step. + /// # Errors + /// Rejects invalid deltas and propagates destruction errors. + pub fn tick_entity_lifetimes(&mut self, delta: f64) -> Result<(), WorldError> { + if !delta.is_finite() || delta < 0.0 { + return Err(WorldError::InvalidArgument( + "delta must be finite and nonnegative".into(), + )); + } + let mut due = Vec::new(); + for (&id, (reference, remaining)) in &mut self.services.deadlines { + *remaining -= delta; + if *remaining <= 0.0 { + due.push((id, *reference)); + } + } + for (id, reference) in due { + self.services.deadlines.remove(&id); + if self.resolve(reference).is_ok() { + self.destroy_entity(id)?; + } + } + Ok(()) + } + + /// Finds entity origins within a radius (not a collider overlap). + /// # Errors + /// Rejects non-finite centers and negative/non-finite radii. + pub fn find_in_radius( + &mut self, + center: Vec3, + radius: f64, + ) -> Result, WorldError> { + if !center.is_finite() || !radius.is_finite() || radius < 0.0 { + return Err(WorldError::InvalidArgument( + "center and nonnegative radius must be finite".into(), + )); + } + self.propagate_transforms(); + Ok(self + .entity_ids() + .filter(|id| { + self.world_transform(*id).is_ok_and(|t| { + t.0.w_axis.truncate().as_dvec3().distance(center.as_dvec3()) <= radius + }) + }) + .collect()) + } + + /// Lists present built-in component schemas, then script IDs (`script:`). + /// # Errors + /// Rejects absent entities. + pub fn component_names(&self, entity: EntityId) -> Result, WorldError> { + let s = self.snapshot(entity)?; + let mut names: Vec = ["rustic.transform", "rustic.part_attributes"] + .into_iter() + .map(str::to_owned) + .collect(); + for (present, schema) in [ + (s.name.is_some(), "rustic.name"), + (s.folder, "rustic.folder"), + (s.parent.is_some(), "rustic.parent"), + (s.mesh.is_some(), "rustic.mesh"), + (s.material.is_some(), "rustic.material"), + (s.camera.is_some(), "rustic.camera"), + (s.light.is_some(), "rustic.light"), + (s.primitive.is_some(), "rustic.primitive"), + (!s.scripts.is_empty(), "rustic.scripts"), + ] { + if present { + names.push(schema.into()); + } + } + for script in s.scripts { + names.push(format!("script:{}", script.script_id)); + } + names.sort_unstable(); + names.dedup(); + Ok(names) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EntitySnapshot, SceneDocument}; + use glam::Quat; + + fn spawn(world: &mut SceneWorld, parent: Option, translation: Vec3) -> EntityId { + let snapshot = EntitySnapshot { + parent, + local_transform: LocalTransform { + translation, + ..LocalTransform::IDENTITY + }, + ..EntitySnapshot::default() + }; + let id = snapshot.id; + world + .apply_commands(&[WorldCommand::Spawn(Box::new(snapshot))]) + .unwrap(); + id + } + + #[test] + fn tags_groups_and_descendants_are_cleaned_for_every_despawn_path() { + let mut world = SceneWorld::new(); + let root = spawn(&mut world, None, Vec3::ZERO); + let child = spawn(&mut world, Some(root), Vec3::X); + let reference = world.reference(child).unwrap(); + assert!(world.create_group("enemies").unwrap()); + assert!(!world.create_group("enemies").unwrap()); + for id in [root, child] { + assert!(world.add_tag(id, "enemy").unwrap()); + assert!(!world.add_tag(id, "enemy").unwrap()); + world.add_to_group("enemies", id).unwrap(); + } + assert_eq!(world.find_by_tag("enemy").unwrap().len(), 2); + world.destroy_entity(root).unwrap(); + assert_eq!(world.entity_count(), 0); + assert!(world.resolve(reference).is_err()); + assert!(world.find_by_tag("enemy").unwrap().is_empty()); + assert!(world.group_members("enemies").unwrap().is_empty()); + let id = spawn(&mut world, None, Vec3::ZERO); + world.add_tag(id, "enemy").unwrap(); + world.add_to_group("enemies", id).unwrap(); + world.apply_commands(&[WorldCommand::Despawn(id)]).unwrap(); + assert!(world.find_by_tag("enemy").unwrap().is_empty()); + assert!(world.group_members("enemies").unwrap().is_empty()); + } + + #[test] + fn hierarchy_preserves_world_pose_and_rejects_cycles_atomically() { + let mut world = SceneWorld::new(); + let parent = spawn(&mut world, None, Vec3::new(10., 20., 30.)); + let child = spawn(&mut world, None, Vec3::new(1., 2., 3.)); + world.propagate_transforms(); + let original = world.world_transform(child).unwrap(); + world.reparent(child, Some(parent), true).unwrap(); + assert_eq!(world.world_transform(child).unwrap(), original); + assert_eq!( + world.local_transform(child).unwrap().translation, + Vec3::new(-9., -18., -27.) + ); + let before = world.local_transform(parent).unwrap(); + assert!(world.reparent(parent, Some(child), true).is_err()); + assert_eq!(world.local_transform(parent).unwrap(), before); + assert_eq!(world.parent(parent).unwrap(), None); + world.reparent(child, None, true).unwrap(); + assert_eq!(world.world_transform(child).unwrap(), original); + world.reparent(child, Some(parent), false).unwrap(); + assert_eq!( + world.local_transform(child).unwrap().translation, + Vec3::new(1., 2., 3.) + ); + } + + #[test] + fn hierarchy_rejects_shear_without_mutating_parent_or_local_transform() { + let mut world = SceneWorld::new(); + let parent = spawn(&mut world, None, Vec3::ZERO); + let child = spawn(&mut world, None, Vec3::ZERO); + world + .apply_commands(&[ + WorldCommand::SetLocalTransform { + entity: parent, + value: LocalTransform { + scale: Vec3::new(2., 1., 1.), + ..LocalTransform::IDENTITY + }, + }, + WorldCommand::SetLocalTransform { + entity: child, + value: LocalTransform { + rotation: Quat::from_rotation_z(0.5), + ..LocalTransform::IDENTITY + }, + }, + ]) + .unwrap(); + let before = world.local_transform(child).unwrap(); + assert!(world.reparent(child, Some(parent), true).is_err()); + assert_eq!(world.local_transform(child).unwrap(), before); + assert_eq!(world.parent(child).unwrap(), None); + } + + #[test] + fn deadlines_replace_pause_and_do_not_destroy_recreated_ids() { + let mut world = SceneWorld::new(); + let id = spawn(&mut world, None, Vec3::ZERO); + world.destroy_after(id, 1.).unwrap(); + world.destroy_after(id, 2.).unwrap(); + world.tick_entity_lifetimes(0.).unwrap(); + world.tick_entity_lifetimes(1.).unwrap(); + assert!(world.contains(id)); + world.apply_commands(&[WorldCommand::Despawn(id)]).unwrap(); + world + .apply_commands(&[WorldCommand::Spawn(Box::new(EntitySnapshot { + id, + ..EntitySnapshot::default() + }))]) + .unwrap(); + world.tick_entity_lifetimes(5.).unwrap(); + assert!(world.contains(id)); + world.destroy_after(id, 0.25).unwrap(); + world.tick_entity_lifetimes(0.25).unwrap(); + assert!(!world.contains(id)); + assert!(world.services.deadlines.is_empty()); + } + + #[test] + fn radius_queries_use_propagated_world_origins_including_parents() { + let mut world = SceneWorld::new(); + let parent = spawn(&mut world, None, Vec3::new(10., 0., 0.)); + let child = spawn(&mut world, Some(parent), Vec3::X); + assert_eq!( + world.find_in_radius(Vec3::new(11., 0., 0.), 0.).unwrap(), + vec![child] + ); + assert_eq!( + world + .find_in_radius(Vec3::new(11., 0., 0.), 1.) + .unwrap() + .len(), + 2 + ); + } + + #[test] + fn invalid_inputs_are_rejected_and_runtime_metadata_is_not_serialized() { + let mut world = SceneWorld::new(); + let id = spawn(&mut world, None, Vec3::ZERO); + for tag in ["", " ", "bad\ntag"] { + assert!(world.add_tag(id, tag).is_err()); + } + assert!(world.add_tag(EntityId::new(), "enemy").is_err()); + assert!(world.add_to_group("unknown", id).is_err()); + assert!(world.find_in_radius(Vec3::ZERO, -1.).is_err()); + assert!(world.find_in_radius(Vec3::NAN, 1.).is_err()); + for delay in [-1., f64::NAN, f64::INFINITY] { + assert!(world.destroy_after(id, delay).is_err()); + } + assert!(world.tick_entity_lifetimes(-1.).is_err()); + let before = SceneDocument::from_world(crate::SceneId::new(), "test", &world).unwrap(); + world.add_tag(id, "runtime").unwrap(); + world.create_group("runtime").unwrap(); + world.destroy_after(id, 10.).unwrap(); + let after = SceneDocument::from_world(before.id, "test", &world).unwrap(); + assert_eq!(before, after); + assert_eq!( + world.component_names(id).unwrap(), + vec!["rustic.part_attributes", "rustic.transform"] + ); + for index in 0..MAX_ENTITY_TAGS - 1 { + world.add_tag(id, &format!("tag{index}")).unwrap(); + } + assert!(world.add_tag(id, "overflow").is_err()); + assert!(world.remove_tag(id, "runtime").unwrap()); + assert!(!world.remove_tag(id, "runtime").unwrap()); + } +} diff --git a/Engine/crates/engine-world/src/lib.rs b/Engine/crates/engine-world/src/lib.rs index 101530a4..75091e4b 100644 --- a/Engine/crates/engine-world/src/lib.rs +++ b/Engine/crates/engine-world/src/lib.rs @@ -4,6 +4,7 @@ mod components; mod environment; +mod foundations; pub use environment::SceneEnvironment; mod ids; mod primitive; diff --git a/Engine/crates/engine-world/src/world.rs b/Engine/crates/engine-world/src/world.rs index 908b5915..470e8716 100644 --- a/Engine/crates/engine-world/src/world.rs +++ b/Engine/crates/engine-world/src/world.rs @@ -14,6 +14,8 @@ use thiserror::Error; /// Errors from world mutation, reference validation, or schedule execution. #[derive(Clone, Debug, Error, PartialEq)] pub enum WorldError { + #[error("invalid argument: {0}")] + InvalidArgument(String), #[error("invalid scene environment settings")] InvalidEnvironment, #[error("entity {0} does not exist")] @@ -159,6 +161,7 @@ struct Slot { /// Live ECS world with stable identities, generational references, and a scene graph. pub struct SceneWorld { + pub(crate) services: crate::foundations::EntityServices, environment: crate::SceneEnvironment, id: WorldId, ecs: World, @@ -195,6 +198,7 @@ impl Default for SceneWorld { impl SceneWorld { pub fn new() -> Self { Self { + services: crate::foundations::EntityServices::default(), environment: crate::SceneEnvironment::default(), id: WorldId::new(), ecs: World::new(), @@ -849,6 +853,11 @@ impl SceneWorld { } fn despawn_now(&mut self, id: EntityId) { + self.services.tags.remove(&id); + self.services.deadlines.remove(&id); + for members in self.services.groups.values_mut() { + members.remove(&id); + } if let Some(entity) = self.lookup.remove(&id) { let _ = self.ecs.despawn(entity); } diff --git a/Engine/docs/SCRIPTING_EXPANSION_STATUS.md b/Engine/docs/SCRIPTING_EXPANSION_STATUS.md new file mode 100644 index 00000000..f95d8099 --- /dev/null +++ b/Engine/docs/SCRIPTING_EXPANSION_STATUS.md @@ -0,0 +1,391 @@ +# Scripting expansion audit and implementation status + +This is an incremental implementation record, not a claim that the comprehensive +expansion is complete. Baseline: engine `017db99b`, docs `1a3fa6c`. + +## Architecture and reuse audit + +- `engine-world`: private Bevy ECS, stable UUIDs and generational runtime refs; + validated batched commands, deterministic hierarchy and transform propagation; + versioned scene documents and additive instantiation. Reuse these for hierarchy, + destruction, component queries, prefab/scene foundations; preserve scene format. +- `engine-core/gameplay`: engine-owned easing, movement, tween, timers, sequences, + paths, animation, IK, signal delivery and audio mixer. These are implemented; + no replacement scheduler, timer, event bus, or easing implementation is needed. +- `engine-scripting`: bundled Lua 5.4/QuickJS; external isolated C/C++, Python, + .NET, Java, PHP and Luau hosts. All use `GameplayHost.gameplay_query` and generated + SDKs; extend this single boundary. PHP/Web remain UI-scoped. Installed toolchains + determine external adapter availability. Snapshot state differs from live queries. +- `engine-play`: shared runtime world/context, fixed simulation, isolated scripts, + snapshot asset reads and lifecycle cleanup. Physics is a custom primitive AABB + solver (`physics.rs`), not Rapier/Jolt: advanced joints, ragdolls, vehicles, CCD, + slopes and robust shape casts require a deliberate backend integration first. +- `engine-rhi`, `renderer-wgpu`: typed generational GPU resources, renderer selection, + mesh/primitive extraction, WGSL pipelines and environment/sky. Resource presence + is not evidence of a script API or an advanced rendering effect. Do not advertise + volumetrics, cloth or post-processing before rendering paths exist. +- `apps/runtime/game_ui.rs`: static HTML text/CSS to SVG raster overlay. Embedded + Web JavaScript runs separately in QuickJS. No browser DOM, forms, layout engine, + input routing or reactive rendering bridge exists. Browser runtime integration + is a prerequisite for Category F; state maps alone cannot implement that category. +- `engine-assets`, `engine-project`, `engine-platform`: existing import workers, + VFS/path validation, project storage, task/logging facilities and window plumbing. + Script saves must use a per-game writable directory, not immutable play snapshots. +- `apps/editor`, `engine-editor`: scene attachment/inspection and embedded isolated + Play already share engine-world/runtime; no competing native UI framework needed. +- `docs`: Markdown guide/API sources, generated API reference, `docs-catalog.ts` + navigation and API example tests. Engine-local documentation mirrors relevant pages. +- `examples`: currently contains only a license; add small runnable scripts with + editor setup rather than fake project files or placeholders. + +## Constraints and verification + +No new runtime dependencies are needed for entity foundations. Existing project +serialization and public function names must stay compatible. Runtime-only tags, +groups and lifetime deadlines must be documented as such. Hierarchy preservation +must reject transforms that need shear rather than silently changing world poses. +Linux can run Rust and available language tests; Windows installer qualification +requires the repository Windows workflow. Hardware-dependent rendering remains +unverified unless executed. + +## Feature-by-feature checklist + +“Partial” records an existing engine substrate, not completion of the requested API. +“Not started” means no implementation was found in the inspected runtime/API paths. +The advanced physics and browser UI prerequisites above block those dependent +features; other unchecked work remains independent and not started. + +| Category | Requested capability | Status | +| --- | --- | --- | +| Category A | `Tags.add()` | Implemented: Rust/runtime/bindings/docs; executed validation below | +| Category A | `Tags.remove()` | Implemented: Rust/runtime/bindings/docs; executed validation below | +| Category A | `Tags.find_all()` | Implemented: Rust/runtime/bindings/docs; executed validation below | +| Category A | `Groups.create()` | Implemented: Rust/runtime/bindings/docs; executed validation below | +| Category A | `Groups.add()` | Implemented: Rust/runtime/bindings/docs; executed validation below | +| Category A | `Groups.get_members()` | Implemented: Rust/runtime/bindings/docs; executed validation below | +| Category A | `Hierarchy.set_parent()` | Implemented: Rust/runtime/bindings/docs; executed validation below | +| Category A | `Hierarchy.get_children()` | Implemented: Rust/runtime/bindings/docs; executed validation below | +| Category A | `Hierarchy.detach()` | Implemented: Rust/runtime/bindings/docs; executed validation below | +| Category A | `Entity.destroy()` | Implemented: Rust/runtime/bindings/docs; executed validation below | +| Category A | `Entity.destroy_after()` | Implemented: Rust/runtime/bindings/docs; executed validation below | +| Category A | `Entity.is_valid()` | Implemented: Rust/runtime/bindings/docs; executed validation below | +| Category A | `Components.has()` | Implemented: Rust/runtime/bindings/docs; executed validation below | +| Category A | `Components.get_all()` | Implemented: Rust/runtime/bindings/docs; executed validation below | +| Category A | `Components.find_in_children()` | Implemented: Rust/runtime/bindings/docs; executed validation below | +| Category A | `Components.attach()` | Partial: existing substrate; requested binding/behavior missing | +| Category A | `Components.detach()` | Partial: existing substrate; requested binding/behavior missing | +| Category A | `Components.set_active()` | Partial: existing substrate; requested binding/behavior missing | +| Category A | `Prefab.instantiate()` | Partial: existing substrate; requested binding/behavior missing | +| Category A | `Prefab.override()` | Partial: existing substrate; requested binding/behavior missing | +| Category A | `Prefab.reset()` | Partial: existing substrate; requested binding/behavior missing | +| Category A | `Scene.load_additive()` | Partial: existing substrate; requested binding/behavior missing | +| Category A | `Scene.unload_additive()` | Partial: existing substrate; requested binding/behavior missing | +| Category A | `Scene.mark_persistent()` | Partial: existing substrate; requested binding/behavior missing | +| Category A | `Scene.transfer_entity()` | Partial: existing substrate; requested binding/behavior missing | +| Category A | `GameState.set()` | Not started | +| Category A | `GameState.get()` | Not started | +| Category A | `GameState.observe()` | Not started | +| Category A | `World.find_by_tag()` | Implemented: Rust/runtime/bindings/docs; executed validation below | +| Category A | `World.find_in_radius()` | Implemented: Rust/runtime/bindings/docs; executed validation below | +| Category A | `Pool.register()` | Not started | +| Category A | `Pool.acquire()` | Not started | +| Category A | `Pool.release()` | Not started | +| Category A | `Interaction.register()` | Not started | +| Category A | `Interaction.use()` | Not started | +| Category A | `Interaction.can_use()` | Not started | +| Category A | `Damage.apply()` | Not started | +| Category A | `Damage.set_resistance()` | Not started | +| Category A | `Damage.on_death()` | Not started | +| Category A | `Inventory.add_item()` | Not started | +| Category A | `Inventory.remove_item()` | Not started | +| Category A | `Inventory.transfer()` | Not started | +| Category A | `Quest.start()` | Not started | +| Category A | `Quest.complete_objective()` | Not started | +| Category A | `Quest.get_progress()` | Not started | +| Category A | `Checkpoint.activate()` | Not started | +| Category A | `Checkpoint.respawn()` | Not started | +| Category A | `Ability.activate()` | Not started | +| Category A | `Ability.set_cooldown()` | Not started | +| Category A | `Ability.can_activate()` | Not started | +| Category B | `RigidBody.set_mass()` | Partial: existing substrate; requested binding/behavior missing | +| Category B | `RigidBody.set_velocity()` | Partial: existing substrate; requested binding/behavior missing | +| Category B | `RigidBody.set_angular_velocity()` | Partial: existing substrate; requested binding/behavior missing | +| Category B | `Gravity.set_global()` | Partial: existing substrate; requested binding/behavior missing | +| Category B | `Gravity.set_for_body()` | Partial: existing substrate; requested binding/behavior missing | +| Category B | `Gravity.enable()` | Partial: existing substrate; requested binding/behavior missing | +| Category B | `PhysicsMaterial.set_friction()` | Partial: existing substrate; requested binding/behavior missing | +| Category B | `PhysicsMaterial.set_restitution()` | Partial: existing substrate; requested binding/behavior missing | +| Category B | `CollisionLayer.set()` | Partial: existing substrate; requested binding/behavior missing | +| Category B | `CollisionLayer.ignore_pair()` | Partial: existing substrate; requested binding/behavior missing | +| Category B | `on_collision_enter()` | Not started | +| Category B | `on_collision_stay()` | Not started | +| Category B | `on_collision_exit()` | Not started | +| Category B | `Trigger.on_enter()` | Not started | +| Category B | `Trigger.on_exit()` | Not started | +| Category B | `Trigger.get_overlaps()` | Not started | +| Category B | `Joint.create()` | Not started | +| Category B | `Joint.set_limits()` | Not started | +| Category B | `Joint.break()` | Not started | +| Category B | `Character.move()` | Not started | +| Category B | `Character.is_grounded()` | Not started | +| Category B | `Character.set_slope_limit()` | Not started | +| Category B | `Vehicle.set_throttle()` | Not started | +| Category B | `Vehicle.set_steering()` | Not started | +| Category B | `Vehicle.set_brake()` | Not started | +| Category B | `Buoyancy.add_body()` | Not started | +| Category B | `Buoyancy.set_density()` | Not started | +| Category B | `Ragdoll.enable()` | Not started | +| Category B | `Ragdoll.disable()` | Not started | +| Category B | `Ragdoll.blend_weight()` | Not started | +| Category B | `RigidBody.set_ccd_mode()` | Partial: existing substrate; requested binding/behavior missing | +| Category B | `Physics.box_cast()` | Partial: existing substrate; requested binding/behavior missing | +| Category B | `Physics.capsule_cast()` | Partial: existing substrate; requested binding/behavior missing | +| Category B | `Physics.set_substeps()` | Partial: existing substrate; requested binding/behavior missing | +| Category B | `Physics.get_step_stats()` | Partial: existing substrate; requested binding/behavior missing | +| Category B | `Physics2D.set_axis_lock()` | Not started | +| Category B | `Physics2D.set_gravity()` | Not started | +| Category C | `NavMesh.build()` | Not started | +| Category C | `NavMesh.find_path()` | Not started | +| Category C | `NavMesh.update()` | Not started | +| Category C | `NavAgent.set_destination()` | Not started | +| Category C | `NavAgent.stop()` | Not started | +| Category C | `NavAgent.has_arrived()` | Not started | +| Category C | `BehaviorTree.create()` | Not started | +| Category C | `BehaviorTree.run()` | Not started | +| Category C | `BehaviorTree.set_blackboard()` | Not started | +| Category C | `StateMachine.add_state()` | Not started | +| Category C | `StateMachine.transition()` | Not started | +| Category C | `StateMachine.get_state()` | Not started | +| Category C | `Perception.can_see()` | Not started | +| Category C | `Perception.can_hear()` | Not started | +| Category C | `Perception.get_targets()` | Not started | +| Category C | `AIMemory.remember()` | Not started | +| Category C | `AIMemory.recall()` | Not started | +| Category C | `AIMemory.forget()` | Not started | +| Category C | `Patrol.set_points()` | Not started | +| Category C | `Patrol.start()` | Not started | +| Category C | `Patrol.pause()` | Not started | +| Category C | `Steering.seek()` | Not started | +| Category C | `Steering.flee()` | Not started | +| Category C | `Steering.avoid()` | Not started | +| Category C | `Flock.create()` | Not started | +| Category C | `Flock.set_cohesion()` | Not started | +| Category C | `Flock.set_separation()` | Not started | +| Category C | `Cover.find_nearest()` | Not started | +| Category C | `Cover.reserve()` | Not started | +| Category C | `Cover.release()` | Not started | +| Category C | `Threat.add()` | Not started | +| Category C | `Threat.get_highest()` | Not started | +| Category C | `Threat.clear()` | Not started | +| Category C | `Spawner.configure()` | Not started | +| Category C | `Spawner.set_limit()` | Not started | +| Category C | `Spawner.spawn_wave()` | Not started | +| Category C | `WorldStream.load_region()` | Not started | +| Category C | `WorldStream.unload_region()` | Not started | +| Category C | `WorldGen.generate_chunk()` | Not started | +| Category C | `WorldGen.set_seed()` | Not started | +| Category C | `Biome.get_at()` | Not started | +| Category C | `Biome.set_region()` | Not started | +| Category C | `Biome.get_properties()` | Not started | +| Category C | `Weather.set_rain()` | Not started | +| Category C | `Weather.set_wind()` | Not started | +| Category C | `Weather.set_snow()` | Not started | +| Category C | `DayNight.set_time()` | Not started | +| Category C | `DayNight.set_cycle_speed()` | Not started | +| Category C | `Destruction.fracture()` | Not started | +| Category C | `Destruction.apply_damage()` | Not started | +| Category D | `Material.set_texture()` | Partial: existing substrate; requested binding/behavior missing | +| Category D | `Material.set_parameter()` | Partial: existing substrate; requested binding/behavior missing | +| Category D | `Shader.load()` | Not started | +| Category D | `Shader.set_uniform()` | Not started | +| Category D | `Shader.enable_keyword()` | Not started | +| Category D | `Particles.create()` | Not started | +| Category D | `Particles.emit()` | Not started | +| Category D | `Particles.set_rate()` | Not started | +| Category D | `PostFX.set_bloom()` | Not started | +| Category D | `PostFX.set_exposure()` | Not started | +| Category D | `PostFX.set_vignette()` | Not started | +| Category D | `Light.set_intensity()` | Partial: existing substrate; requested binding/behavior missing | +| Category D | `Light.set_range()` | Partial: existing substrate; requested binding/behavior missing | +| Category D | `Light.set_shadow_enabled()` | Partial: existing substrate; requested binding/behavior missing | +| Category D | `Volumetrics.set_density()` | Not started | +| Category D | `Volumetrics.set_scattering()` | Not started | +| Category D | `Mesh.create()` | Not started | +| Category D | `Mesh.set_vertices()` | Not started | +| Category D | `Mesh.set_indices()` | Not started | +| Category D | `MeshDeform.set_vertex()` | Not started | +| Category D | `MeshDeform.apply()` | Not started | +| Category D | `Terrain.set_height()` | Not started | +| Category D | `Terrain.paint_layer()` | Not started | +| Category D | `Texture.create()` | Not started | +| Category D | `Texture.set_pixels()` | Not started | +| Category D | `Texture.generate_mipmaps()` | Not started | +| Category D | `RenderTarget.create()` | Partial: existing substrate; requested binding/behavior missing | +| Category D | `RenderTarget.capture_camera()` | Partial: existing substrate; requested binding/behavior missing | +| Category D | `Decal.spawn()` | Not started | +| Category D | `Decal.set_material()` | Not started | +| Category D | `Cloth.set_wind()` | Not started | +| Category D | `Cloth.set_stiffness()` | Not started | +| Category D | `Instancing.create_batch()` | Partial: existing substrate; requested binding/behavior missing | +| Category D | `Instancing.update_instances()` | Partial: existing substrate; requested binding/behavior missing | +| Category D | `LOD.set_distances()` | Partial: existing substrate; requested binding/behavior missing | +| Category D | `LOD.set_bias()` | Partial: existing substrate; requested binding/behavior missing | +| Category D | `Occlusion.set_enabled()` | Not started | +| Category D | `Occlusion.set_occluder()` | Not started | +| Category D | `RenderSettings.set_shadow_quality()` | Partial: existing substrate; requested binding/behavior missing | +| Category D | `RenderSettings.set_antialiasing()` | Partial: existing substrate; requested binding/behavior missing | +| Category D | `Sprite.set_region()` | Partial: existing substrate; requested binding/behavior missing | +| Category D | `Sprite.set_flip()` | Partial: existing substrate; requested binding/behavior missing | +| Category D | `Sprite.set_sorting_order()` | Partial: existing substrate; requested binding/behavior missing | +| Category D | `Tilemap.set_tile()` | Not started | +| Category D | `Tilemap.fill_area()` | Not started | +| Category D | `Tilemap.get_tile()` | Not started | +| Category E | `SaveGame.write()` | Not started | +| Category E | `SaveGame.read()` | Not started | +| Category E | `SaveGame.delete()` | Not started | +| Category E | `Preferences.set()` | Not started | +| Category E | `Preferences.get()` | Not started | +| Category E | `Preferences.reset()` | Not started | +| Category E | `InputMap.register()` | Partial: existing substrate; requested binding/behavior missing | +| Category E | `InputMap.rebind()` | Partial: existing substrate; requested binding/behavior missing | +| Category E | `InputMap.get_strength()` | Partial: existing substrate; requested binding/behavior missing | +| Category E | `InputContext.push()` | Not started | +| Category E | `InputContext.pop()` | Not started | +| Category E | `Gamepad.get_axis()` | Not started | +| Category E | `Gamepad.get_button()` | Not started | +| Category E | `Gamepad.set_vibration()` | Not started | +| Category E | `Touch.get_points()` | Not started | +| Category E | `Touch.get_swipe()` | Not started | +| Category E | `Touch.get_pinch()` | Not started | +| Category E | `Mouse.capture()` | Not started | +| Category E | `Mouse.release()` | Not started | +| Category E | `Mouse.get_delta()` | Not started | +| Category E | `Assets.load_async()` | Partial: existing substrate; requested binding/behavior missing | +| Category E | `Assets.release()` | Partial: existing substrate; requested binding/behavior missing | +| Category E | `Assets.get_status()` | Partial: existing substrate; requested binding/behavior missing | +| Category E | `AssetBundle.load()` | Not started | +| Category E | `AssetBundle.unload()` | Not started | +| Category E | `Serializer.encode()` | Partial: existing substrate; requested binding/behavior missing | +| Category E | `Serializer.decode()` | Partial: existing substrate; requested binding/behavior missing | +| Category E | `Localization.translate()` | Not started | +| Category E | `Localization.set_locale()` | Not started | +| Category E | `Random.set_seed()` | Not started | +| Category E | `Random.range()` | Not started | +| Category E | `Random.weighted_choice()` | Not started | +| Category E | `Display.set_resolution()` | Partial: existing substrate; requested binding/behavior missing | +| Category E | `Display.set_fullscreen()` | Partial: existing substrate; requested binding/behavior missing | +| Category E | `Profiler.begin_sample()` | Partial: existing substrate; requested binding/behavior missing | +| Category E | `Profiler.end_sample()` | Partial: existing substrate; requested binding/behavior missing | +| Category E | `Profiler.get_results()` | Partial: existing substrate; requested binding/behavior missing | +| Category E | `Memory.get_usage()` | Partial: existing substrate; requested binding/behavior missing | +| Category E | `Memory.get_allocations()` | Partial: existing substrate; requested binding/behavior missing | +| Category E | `Debug.draw_line()` | Partial: existing substrate; requested binding/behavior missing | +| Category E | `Debug.draw_sphere()` | Partial: existing substrate; requested binding/behavior missing | +| Category E | `Debug.draw_box()` | Partial: existing substrate; requested binding/behavior missing | +| Category E | `Capture.screenshot()` | Partial: existing substrate; requested binding/behavior missing | +| Category E | `Capture.save_to_user_folder()` | Partial: existing substrate; requested binding/behavior missing | +| Category E | `Task.run_async()` | Not started | +| Category E | `Task.cancel()` | Not started | +| Category E | `Task.get_status()` | Not started | +| Category E | `Platform.get_os()` | Not started | +| Category E | `Platform.get_architecture()` | Not started | +| Category E | `Platform.is_mobile()` | Not started | +| Category E | `Accessibility.get_preferences()` | Not started | +| Category E | `Accessibility.set_subtitles()` | Not started | +| Category F | `UI.open()` | Partial: existing substrate; requested binding/behavior missing | +| Category F | `UI.close()` | Partial: existing substrate; requested binding/behavior missing | +| Category F | `UI.is_open()` | Partial: existing substrate; requested binding/behavior missing | +| Category F | `UI.set_state()` | Partial: existing substrate; requested binding/behavior missing | +| Category F | `UI.get_state()` | Partial: existing substrate; requested binding/behavior missing | +| Category F | `UI.watch_state()` | Partial: existing substrate; requested binding/behavior missing | +| Category F | `UI.send()` | Partial: existing substrate; requested binding/behavior missing | +| Category F | `UI.on_message()` | Partial: existing substrate; requested binding/behavior missing | +| Category F | `UI.focus()` | Partial: existing substrate; requested binding/behavior missing | +| Category F | `UI.blur()` | Partial: existing substrate; requested binding/behavior missing | +| Category F | `UI.get_focused()` | Partial: existing substrate; requested binding/behavior missing | +| Category F | `UI.set_z_index()` | Partial: existing substrate; requested binding/behavior missing | +| Category F | `UI.bring_to_front()` | Partial: existing substrate; requested binding/behavior missing | +| Category F | `UI.open_modal()` | Partial: existing substrate; requested binding/behavior missing | +| Category F | `UI.close_modal()` | Partial: existing substrate; requested binding/behavior missing | +| Category F | `UI.capture_input()` | Partial: existing substrate; requested binding/behavior missing | +| Category F | `UI.release_input()` | Partial: existing substrate; requested binding/behavior missing | +| Category F | `UITheme.set()` | Not started | +| Category F | `UITheme.get()` | Not started | +| Category F | `UITheme.update_variable()` | Not started | +| Category F | `UI.translate()` | Partial: existing substrate; requested binding/behavior missing | +| Category F | `UI.set_locale()` | Partial: existing substrate; requested binding/behavior missing | +| Category F | `UI.set_viewport()` | Partial: existing substrate; requested binding/behavior missing | +| Category F | `UI.set_scale()` | Partial: existing substrate; requested binding/behavior missing | +| Category F | `WorldUI.attach()` | Not started | +| Category F | `WorldUI.set_size()` | Not started | +| Category F | `WorldUI.face_camera()` | Not started | +| Category F | `UI.world_to_screen()` | Partial: existing substrate; requested binding/behavior missing | +| Category F | `UI.screen_to_world()` | Partial: existing substrate; requested binding/behavior missing | +| Category F | `UI.notify()` | Partial: existing substrate; requested binding/behavior missing | +| Category F | `UI.dismiss_notification()` | Partial: existing substrate; requested binding/behavior missing | +| Category F | `UI.set_loading_progress()` | Partial: existing substrate; requested binding/behavior missing | +| Category F | `UI.set_loading_message()` | Partial: existing substrate; requested binding/behavior missing | +| Category F | `UI.set_screen_reader_label()` | Partial: existing substrate; requested binding/behavior missing | +| Category F | `UI.announce()` | Partial: existing substrate; requested binding/behavior missing | +| Category F | `UI.add_class()` | Partial: existing substrate; requested binding/behavior missing | +| Category F | `UI.remove_class()` | Partial: existing substrate; requested binding/behavior missing | + +## Entity-foundations slice + +17 shared runtime APIs now use the existing SceneWorld command/lifetime system. +This is the first incremental slice of Phase 1; the rest of the comprehensive +request remains unfinished. Dynamic attachment, prefabs/pooling, streaming, +physics expansion, input devices, persistence, AI, rendering and browser UI are +not implemented by this slice. Do not infer their support from this checklist. + +Implementation choices: runtime-only metadata avoids a project schema change; +subtree deletion uses validated batches; deadlines use generation-checked refs; +world-preserving reparent rejects shear; component queries use existing schemas. +C and C++ preserve their established error/ownership conventions. No dependencies +were added. The next independent slice should finish component lifecycle and +prefab/scene instance lifecycle before pooling; then wire fixed input and saves. +Advanced physics needs a backend decision/integration, and interactive HTML needs +an actual browser DOM runtime with a capability-limited gameplay bridge. + +Validation is recorded here after execution. Missing toolchains and Windows +installer/hardware qualification remain separate from API test results. + +## Executed validation (Linux) + +- `cargo test -p engine-world -p engine-play -p engine-scripting --locked`: + **137 passed**, 0 failures (30 world, 67 play, 40 scripting). +- `cargo check --workspace --all-targets --locked`: passed, including editor/runtime. +- Clippy for all targets of the three changed crates with `-D warnings`: passed. +- `cargo fmt --all --check` and Git whitespace checks: passed. +- Live foundation fixtures: Lua 5.4, Luau, JavaScript, HTML inline JavaScript, + Python, C and C++ passed. C#, Java and PHP fixtures were **skipped** because + those toolchains are unavailable. Their SDK code is included but unverified here. +- Built the separate bundled Luau host and put it on PATH for the Rust tests. + This also resolved the original audio-recording test's missing-host failure. +- Both published Lua/Python example scripts executed in the real world adapter. +- Docs: lint with zero warnings, static build, TypeScript check, moderate npm audit + (0 vulnerabilities), 17 site tests, API reference/example validation all passed. + The example validator checks coverage/rendering (1600 existing/new snippets); + it does not execute every documented snippet in every external toolchain. +- Local Windows installer command failed: `powershell` is absent on this Linux + host. Windows workflow qualification is attempted separately; no fresh installer + or Windows/macOS hardware verification is claimed by these Linux results. + +## Changed files and deliverables + +Engine implementation: `crates/engine-world/src/foundations.rs`, `world.rs`, +`lib.rs`; `crates/engine-play/src/entity_services.rs`, `script_runtime.rs`, `lib.rs`. +Bindings/editor hints: `crates/engine-scripting/src/lua.rs`, `cpp_sdk.rs`, +`workspace.rs`, and `sdk/{gameplay.lua,gameplay.js,rustic.h,rustic.py,rustic.php, +Rustic.cs,Rustic.java,entity-api.lua,entity-api.d.ts}`. +Engine docs: this checklist, `Scripting/API/entity-foundations.md`, +`Scripting/README.md`. Tested examples: `examples/entity-foundations/`. + +Docs repository: matching checklist/API/README Markdown, `lib/api-reference.ts`, +`lib/api-reference-markdown.ts`, `lib/api-example-data.json`, `lib/api-examples.ts`, +`lib/docs-catalog.ts`. Navigation adds a setup guide, status guide and 17 +individual API pages. Signatures and language differences are listed in the +[entity foundations reference](Scripting/API/entity-foundations.md). + +Examples repository: `entity-foundations/{README.md,behavior.lua,behavior.py}`. +No cloud hosting, database, billing, infrastructure or other unrelated repo changed. diff --git a/Engine/docs/Scripting/API/entity-foundations.md b/Engine/docs/Scripting/API/entity-foundations.md new file mode 100644 index 00000000..700cb955 --- /dev/null +++ b/Engine/docs/Scripting/API/entity-foundations.md @@ -0,0 +1,355 @@ +# Entity foundations + +Use the shared runtime world to label, organize, reparent, query and destroy objects. +These are live synchronous queries, so a change is visible to the next query in the +same callback, including queries from another scripting language. + +## Setup and language support + +Create an object in the editor. Use **Programming > New Script > Object Component +Script**, choose a language, and attach the script to that object with **+ Add +Component** or by dragging it from Explorer. Press **Play**. Do not edit scene or +manifest files to attach a behavior. Stop Play to restore the authored scene. + +The SDKs provide these calls in Lua 5.4, Luau, JavaScript, Python, C, C++, C#, Java, +and PHP; inline HTML JavaScript shares the JavaScript calls. External languages +require their existing toolchains. PHP and HTML remain restricted to `ui/` assets; +for those scripts pass a real scene entity ID/path rather than the UI script owner. +The HTML adapter does not provide a browser DOM or interactive HTML controls. + +Python imports services from `rustic`; C includes `rustic.h`; C++ includes +`rustic.hpp` and uses `Tags::add`; C# uses `using static Rustic`; Java extends +`Rustic` or qualifies `Rustic.Tags`; PHP uses `Tags::add`. Lua and JavaScript use +`Tags.add`. New service methods use the exact snake_case spellings below. + +## Parameters + +| Argument | Type and meaning | +| --- | --- | +| entity | Live stable UUID string or scene path; SDK object-path handles are accepted in Lua, JavaScript and Python. Passing null/nil/None to an entity parameter selects the component owner. A global/UI owner need not be a real scene entity. | +| name | Case-sensitive tag/group string, or exact component schema string for Components. Tag/group names must contain non-whitespace text, be at most 128 UTF-8 bytes, and contain no control characters. | +| parent | Entity ID/path or null to detach. C uses NULL, C++ uses an empty string, Python None, Lua/Luau nil. Required argument. | +| keep_world | Boolean, default true. With false, keep the local transform. C requires the explicit boolean; Java has a true-default overload. | +| delay | Finite nonnegative seconds on the scaled fixed simulation clock. | +| center | Three finite numbers in world coordinates. C/C++ use RusticVector3; other SDKs use arrays/tables. Values must fit finite f32 scene coordinates. | +| radius | Finite nonnegative world-space distance. | + +## Return and timing + +| Signature | Return | +| --- | --- | +| Tags.add(entity, name) | bool: true if newly added | +| Tags.remove(entity, name) | bool: true if removed | +| Tags.find_all(name) | entity ID list | +| Groups.create(name) | bool: true if newly created | +| Groups.add(name, entity) | bool: true if newly added | +| Groups.get_members(name) | entity ID list | +| Hierarchy.set_parent(entity, parent, keep_world = true) | void | +| Hierarchy.get_children(entity) | direct child entity ID list | +| Hierarchy.detach(entity, keep_world = true) | void | +| Entity.destroy(entity) | void | +| Entity.destroy_after(entity, delay) | void | +| Entity.is_valid(entity) | bool | +| Components.has(entity, name) | bool | +| Components.get_all(entity) | component schema/script ID string list | +| Components.find_in_children(entity, name) | descendant entity ID list | +| World.find_by_tag(name) | entity ID list; same implementation as Tags.find_all | +| World.find_in_radius(center, radius) | entity ID list | + +Lists are deterministic in ascending stable ID order (component names sort +lexically). Lua/Luau use tables, JavaScript/Python/PHP arrays/lists, Java and C# +string arrays, C++ `std::vector`, and C `RusticList` with `count` and +`items`. C query strings/lists live until the next callback; copy them to retain +across callbacks. An empty query returns an empty list, never a fake entity. + +## Behavior and limitations + +- Tags, groups and destruction deadlines belong to the runtime world. They reset + on Stop/new Play and are not serialized into authored scenes or save files. + Tags are idempotent, with at most 128 tags per entity; at most 4096 groups exist + per world. A group must be created before use. Empty groups remain available. +- Destruction removes the entire subtree in one validated command batch and + invalidates generational references. Tags, group membership and deadlines clean + up even when another engine subsystem despawns an object. A query that uses a + destroyed entity errors, except `Entity.is_valid`, which returns false for a + well-formed absent UUID. Malformed UUIDs are errors. IDs are never raw pointers. +- A destroyed component owner stops receiving Update/FixedUpdate; OnDisable and + OnDestroy run once at the runtime cleanup boundary, and owned operations/signals + are released. The current callback can finish; do not read owner properties + after destroying it. Global scripts do not end when an unrelated object dies. +- A new `destroy_after` replaces the old deadline. Zero destroys immediately; + positive delays advance once per fixed step using the existing Clock time + scale. Clock pause and paused Play stop countdowns. Destruction occurs at a fixed + boundary, so timing is quantized to simulation ticks and does not depend on + render frequency. Deadlines survive the scheduling script's successful reload; + they belong to the entity/world, not a script callback. +- Reparenting defaults to preserving the world pose, including descendants. It + validates both entities and rejects cycles before mutation. A nonuniform parent + scale combined with rotation can require shear; the current TRS representation + cannot preserve that pose, and the call errors without mutation. Pass false + only if keeping the local transform is intended. +- Components queries report the existing authored built-ins: rustic.transform, + rustic.part_attributes, rustic.name, rustic.folder, rustic.parent, rustic.mesh, + rustic.material, rustic.camera, rustic.light, rustic.primitive, rustic.scripts; + each attached script also reports `script:`. Missing names return + false/no matches. These are presence queries, not component payload handles; + disabled script components are still present. Unknown preserved serialized + schemas and derived private ECS bookkeeping are not reported. +- find_in_children searches every descendant and excludes the root. get_children + returns only immediate children. Radius queries measure world **origins**, not + collider extents: use [Physics queries](/docs/api/physics-actions/queries) for + collision overlap. A zero radius matches coincident origins. +- Invalid names, absent objects/groups, non-finite/negative delays or radii, bad + vector sizes, cycles and unsupported transforms produce script errors through + the existing adapter. Lua/PHP/Python/etc. can catch errors using their normal + mechanisms; C's SDK reports a callback failure. Uncaught callback failures use + the existing script disabling/logging behavior. Check the Play Console. +- Tag/radius/component searches are linear scans. Hierarchy changes/destruction + validate a whole command batch; descendant operations scale with the subtree. + Cache query results for a frame and use direct IDs for frequent queries. This + implementation adds no new dependencies or GPU/backend-specific requirements. + Linux headless tests are verification evidence, not Windows/macOS hardware + qualification or a guarantee of exported-game packaging. +- Dynamic component attachment/activation, prefabs, pooling, scene streaming, + navigation, advanced physics/rendering and DOM UI are separate unfinished work. + See the [expansion status](/docs/guides/scripting-expansion-status). Existing + [events](/docs/api/events) and [timers](/docs/api/timer) remain the callback tools. + +## Example + +Create and attach this Lua Object Component Script to one object. Press Play. +Expect `entity foundations ready` in the Console; after one scaled fixed second, +the object disappears. Stop Play to restore it. No extra assets are required. + +```lua +return {Start=function() + local e=rustic.entity_id() + Tags.add(e,'enemy') + Groups.create('enemies') + Groups.add('enemies',e) + assert(Entity.is_valid(e)) + assert(Components.has(e,'rustic.transform')) + assert(#Tags.find_all('enemy')==1) + rustic.log('info','entity foundations ready') + Entity.destroy_after(e,1) +end} +``` + +Python uses the same live services: + +```python +from rustic import rustic, Tags, Groups, Entity, Components, run + +def on_start(): + e = rustic.entity_id() + Tags.add(e, 'enemy') + Groups.create('enemies') + Groups.add('enemies', e) + assert Entity.is_valid(e) + assert Components.has(e, 'rustic.transform') + assert len(Tags.find_all('enemy')) == 1 + rustic.log('info', 'entity foundations ready') + Entity.destroy_after(e, 1) + +run(globals()) +``` + +For a world-preserving hierarchy edit in JavaScript, use live IDs returned by +`Game.scene.Find` for existing objects: + +```javascript +const child = Game.scene.Find('Child'); +const parent = Game.scene.Find('Parent'); +Hierarchy.set_parent(child, parent); // child keeps its world pose +Hierarchy.detach(child); // child keeps that pose as a root +``` + +A missing path returns null from Find; check the lookup result before passing it +as an entity parameter, because null selects the behavior owner. + +## Tags.add + +Add a runtime tag. Repeated additions return false. + +Signature: `Tags.add(entity, name)`. Parameter types, return values, lifecycle, errors and +performance are specified in the tables and limitations above. + +```lua +Tags.add(rustic.entity_id(), "enemy") +``` + +## Tags.remove + +Remove a runtime tag; return false if it was absent. + +Signature: `Tags.remove(entity, name)`. Parameter types, return values, lifecycle, errors and +performance are specified in the tables and limitations above. + +```lua +Tags.remove(rustic.entity_id(), "enemy") +``` + +## Tags.find_all + +Find all live tagged entities in stable ID order. + +Signature: `Tags.find_all(name)`. Parameter types, return values, lifecycle, errors and +performance are specified in the tables and limitations above. + +```lua +Tags.find_all("enemy") +``` + +## Groups.create + +Create an empty runtime group; return false if it already exists. + +Signature: `Groups.create(name)`. Parameter types, return values, lifecycle, errors and +performance are specified in the tables and limitations above. + +```lua +Groups.create("enemies") +``` + +## Groups.add + +Add an entity to an existing group; return false for existing membership. + +Signature: `Groups.add(name, entity)`. Parameter types, return values, lifecycle, errors and +performance are specified in the tables and limitations above. + +```lua +Groups.add("enemies", rustic.entity_id()) +``` + +## Groups.get_members + +Return live members of an existing group. + +Signature: `Groups.get_members(name)`. Parameter types, return values, lifecycle, errors and +performance are specified in the tables and limitations above. + +```lua +Groups.get_members("enemies") +``` + +## Hierarchy.set_parent + +Change the parent transactionally, rejecting cycles and unrepresentable world poses. + +Signature: `Hierarchy.set_parent(entity, parent, keep_world = true)`. Parameter types, return values, lifecycle, errors and +performance are specified in the tables and limitations above. + +```lua +Hierarchy.set_parent(rustic.entity_id(), nil, true) +``` + +## Hierarchy.get_children + +Return direct children; use Components.find_in_children to search descendants. + +Signature: `Hierarchy.get_children(entity)`. Parameter types, return values, lifecycle, errors and +performance are specified in the tables and limitations above. + +```lua +Hierarchy.get_children(rustic.entity_id()) +``` + +## Hierarchy.detach + +Make an entity a root, preserving the world pose by default. + +Signature: `Hierarchy.detach(entity, keep_world = true)`. Parameter types, return values, lifecycle, errors and +performance are specified in the tables and limitations above. + +```lua +Hierarchy.detach(rustic.entity_id()) +``` + +## Entity.destroy + +Immediately remove the entity and its entire subtree. Further reads fail safely. + +Signature: `Entity.destroy(entity)`. Parameter types, return values, lifecycle, errors and +performance are specified in the tables and limitations above. + +```lua +Entity.destroy(rustic.entity_id()) +``` + +## Entity.destroy_after + +Replace the entity destruction deadline on the scaled fixed clock. Zero destroys now. + +Signature: `Entity.destroy_after(entity, delay)`. Parameter types, return values, lifecycle, errors and +performance are specified in the tables and limitations above. + +```lua +Entity.destroy_after(rustic.entity_id(), 1) +``` + +## Entity.is_valid + +Check a well-formed stable UUID; absent IDs return false, malformed IDs error. + +Signature: `Entity.is_valid(entity)`. Parameter types, return values, lifecycle, errors and +performance are specified in the tables and limitations above. + +```lua +Entity.is_valid(rustic.entity_id()) +``` + +## Components.has + +Check presence of an exact component schema/script ID. Missing names return false. + +Signature: `Components.has(entity, name)`. Parameter types, return values, lifecycle, errors and +performance are specified in the tables and limitations above. + +```lua +Components.has(rustic.entity_id(), "rustic.transform") +``` + +## Components.get_all + +Return sorted component schema and script ID strings, not mutable component handles. + +Signature: `Components.get_all(entity)`. Parameter types, return values, lifecycle, errors and +performance are specified in the tables and limitations above. + +```lua +Components.get_all(rustic.entity_id()) +``` + +## Components.find_in_children + +Return all descendants having the component; exclude the root. + +Signature: `Components.find_in_children(entity, name)`. Parameter types, return values, lifecycle, errors and +performance are specified in the tables and limitations above. + +```lua +Components.find_in_children(rustic.entity_id(), "rustic.transform") +``` + +## World.find_by_tag + +Use the existing Tags.find_all implementation to query tagged objects. + +Signature: `World.find_by_tag(name)`. Parameter types, return values, lifecycle, errors and +performance are specified in the tables and limitations above. + +```lua +World.find_by_tag("enemy") +``` + +## World.find_in_radius + +Find entity world origins inside an inclusive radius; no physics collider test. + +Signature: `World.find_in_radius(center, radius)`. Parameter types, return values, lifecycle, errors and +performance are specified in the tables and limitations above. + +```lua +World.find_in_radius({0, 0, 0}, 5) +``` diff --git a/Engine/docs/Scripting/README.md b/Engine/docs/Scripting/README.md index 56700c49..21ab2b1c 100644 --- a/Engine/docs/Scripting/README.md +++ b/Engine/docs/Scripting/README.md @@ -66,3 +66,6 @@ last good instance running. External callbacks have a three-second deadline and See [Edit scene objects](sceneObjects.md) to target built-in properties on another object using `rustic.game..` in native language syntax. + +See [Entity foundations](API/entity-foundations.md) for live tags, groups, hierarchy, +lifetime, component and world queries in the shared scripting runtime. diff --git a/Engine/examples/entity-foundations/README.md b/Engine/examples/entity-foundations/README.md new file mode 100644 index 00000000..dd75ae50 --- /dev/null +++ b/Engine/examples/entity-foundations/README.md @@ -0,0 +1,26 @@ +# Entity foundations + +Two equivalent component scripts exercise live shared runtime services. Use one at +a time; this is a runnable script demonstration, not a serialized starter project. + +1. Build/install the engine branch linked in the companion pull request (an older + release will not contain Tags/Groups/Entity/Components). +2. Create a project and a scene object in the Rustic editor. +3. Use **Programming > New Script > Object Component Script**, choose Lua or Python, + and replace the generated source with `behavior.lua` or `behavior.py` here. +4. Attach that script to the object with **+ Add Component** or by dragging it from + Explorer onto the object. Do not edit the scene/manifest metadata manually. +5. Press **Play**. Expect `entity foundations ready` in the Console; the object + disappears after one scaled fixed second. Stop Play to restore it. + +Lua is bundled. Python needs the existing Python 3 toolchain. The engine supplies +`rustic.py`; do not install an unrelated Python package. Keep only one tagged +`enemy` object while running this example, because it asserts exactly one match. +Tags and groups exist only during Play and are not stored in authored scenes. +Paused Play/Clock pause stops the countdown. UI/global scripts should pass a real +scene ID instead of using their synthetic owner. + +The companion engine tests execute both scripts against a live SceneWorld, check +that registration reaches Rust, and check destruction and group cleanup. See the +[API reference](https://rustic-game-engine.pages.dev/docs/api/entity-foundations) +for every signature, errors, language differences and limitations. diff --git a/Engine/examples/entity-foundations/behavior.lua b/Engine/examples/entity-foundations/behavior.lua new file mode 100644 index 00000000..54a88fd2 --- /dev/null +++ b/Engine/examples/entity-foundations/behavior.lua @@ -0,0 +1,11 @@ +return {Start=function() + local e=rustic.entity_id() + Tags.add(e,'enemy') + Groups.create('enemies') + Groups.add('enemies',e) + assert(Entity.is_valid(e)) + assert(Components.has(e,'rustic.transform')) + assert(#Tags.find_all('enemy')==1) + rustic.log('info','entity foundations ready') + Entity.destroy_after(e,1) +end} diff --git a/Engine/examples/entity-foundations/behavior.py b/Engine/examples/entity-foundations/behavior.py new file mode 100644 index 00000000..d52d9aa6 --- /dev/null +++ b/Engine/examples/entity-foundations/behavior.py @@ -0,0 +1,14 @@ +from rustic import rustic, Tags, Groups, Entity, Components, run + +def on_start(): + e = rustic.entity_id() + Tags.add(e, 'enemy') + Groups.create('enemies') + Groups.add('enemies', e) + assert Entity.is_valid(e) + assert Components.has(e, 'rustic.transform') + assert len(Tags.find_all('enemy')) == 1 + rustic.log('info', 'entity foundations ready') + Entity.destroy_after(e, 1) + +run(globals()) From efc834d838780dc955ac9a4fe5925a972f27b5ff Mon Sep 17 00:00:00 2001 From: Jamie Wells Date: Mon, 12 Oct 2026 02:13:47 +0000 Subject: [PATCH 2/3] Verify complete adapter coverage and reject unsafe reparent matrices --- .github/workflows/quality.yml | 4 ++ .../crates/engine-play/src/script_runtime.rs | 5 ++ Engine/crates/engine-world/src/foundations.rs | 55 +++++++++++++++---- Engine/docs/SCRIPTING_EXPANSION_STATUS.md | 9 ++- 4 files changed, 59 insertions(+), 14 deletions(-) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 76816ddd..28f68f9b 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -156,6 +156,10 @@ jobs: - run: PATH="$PWD/target/debug:$PATH" cargo test --locked -p engine-scripting external_runtime -- --nocapture - name: Scene environment scripting across all adapters run: PATH="$PWD/target/debug:$PATH" cargo test --locked -p engine-play environment -- --nocapture + - name: Entity foundations across all adapters + env: + RUSTIC_REQUIRE_ALL_ENTITY_ADAPTERS: '1' + run: PATH="$PWD/target/debug:$PATH" cargo test --locked -p engine-play entity_foundations -- --nocapture installer: name: Windows installer diff --git a/Engine/crates/engine-play/src/script_runtime.rs b/Engine/crates/engine-play/src/script_runtime.rs index 67ff7485..3c380ef0 100644 --- a/Engine/crates/engine-play/src/script_runtime.rs +++ b/Engine/crates/engine-play/src/script_runtime.rs @@ -2789,6 +2789,11 @@ rustic_run(['on_start'=>'on_start']);"#), ScriptLanguage::Lua54 | ScriptLanguage::JavaScript | ScriptLanguage::Web ) && !engine_scripting::probe_language_toolchain(*language).available { + assert!( + std::env::var_os("RUSTIC_REQUIRE_ALL_ENTITY_ADAPTERS").is_none(), + "{} toolchain is required for complete entity adapter conformance", + language.display_name() + ); eprintln!( "SKIPPED entity foundations {}: missing toolchain", language.display_name() diff --git a/Engine/crates/engine-world/src/foundations.rs b/Engine/crates/engine-world/src/foundations.rs index 292a4750..9f021602 100644 --- a/Engine/crates/engine-world/src/foundations.rs +++ b/Engine/crates/engine-world/src/foundations.rs @@ -128,27 +128,39 @@ impl SceneWorld { if keep_world { let world = self.world_transform(child)?.0; let local = match parent { - Some(parent) => self.world_transform(parent)?.0.inverse() * world, + Some(parent) => { + let matrix = self.world_transform(parent)?.0; + if !matrix.is_finite() + || !matrix.determinant().is_finite() + || matrix.determinant() == 0.0 + { + return Err(WorldError::InvalidTransform(parent)); + } + matrix.inverse() * world + } None => world, }; + if !local.is_finite() || !local.determinant().is_finite() || local.determinant() == 0.0 + { + return Err(WorldError::InvalidTransform(child)); + } let (scale, rotation, translation) = local.to_scale_rotation_translation(); let value = LocalTransform { translation, rotation, scale, }; + if !value.is_finite() { + return Err(WorldError::InvalidTransform(child)); + } let reconstructed = Mat4::from_scale_rotation_translation(scale, rotation, translation); - let tolerance = local + // Compare each element independently: large translations must never + // increase the shear tolerance of the rotation/scale basis. + if local .to_cols_array() .iter() - .fold(1.0_f32, |m, v| m.max(v.abs())) - * 1e-4; - if !value.is_finite() - || local - .to_cols_array() - .iter() - .zip(reconstructed.to_cols_array()) - .any(|(a, b)| (*a - b).abs() > tolerance) + .zip(reconstructed.to_cols_array()) + .any(|(a, b)| (*a - b).abs() > 1e-4 * a.abs().max(b.abs()).max(1.0)) { return Err(WorldError::InvalidArgument( "world-preserving reparent requires a representable TRS transform (no shear)" @@ -375,7 +387,7 @@ mod tests { fn hierarchy_rejects_shear_without_mutating_parent_or_local_transform() { let mut world = SceneWorld::new(); let parent = spawn(&mut world, None, Vec3::ZERO); - let child = spawn(&mut world, None, Vec3::ZERO); + let child = spawn(&mut world, None, Vec3::new(1e6, 0., 0.)); world .apply_commands(&[ WorldCommand::SetLocalTransform { @@ -389,6 +401,7 @@ mod tests { entity: child, value: LocalTransform { rotation: Quat::from_rotation_z(0.5), + translation: Vec3::new(1e6, 0., 0.), ..LocalTransform::IDENTITY }, }, @@ -400,6 +413,26 @@ mod tests { assert_eq!(world.parent(child).unwrap(), None); } + #[test] + fn unrepresentable_derived_matrices_fail_without_panics_or_mutation() { + let mut world = SceneWorld::new(); + let parent = spawn(&mut world, None, Vec3::ZERO); + let child = spawn(&mut world, None, Vec3::ZERO); + world + .apply_commands(&[WorldCommand::SetLocalTransform { + entity: parent, + value: LocalTransform { + scale: Vec3::splat(1e30), + ..LocalTransform::IDENTITY + }, + }]) + .unwrap(); + let before = world.local_transform(child).unwrap(); + assert!(world.reparent(child, Some(parent), true).is_err()); + assert_eq!(world.parent(child).unwrap(), None); + assert_eq!(world.local_transform(child).unwrap(), before); + } + #[test] fn deadlines_replace_pause_and_do_not_destroy_recreated_ids() { let mut world = SceneWorld::new(); diff --git a/Engine/docs/SCRIPTING_EXPANSION_STATUS.md b/Engine/docs/SCRIPTING_EXPANSION_STATUS.md index f95d8099..731d8a68 100644 --- a/Engine/docs/SCRIPTING_EXPANSION_STATUS.md +++ b/Engine/docs/SCRIPTING_EXPANSION_STATUS.md @@ -353,13 +353,16 @@ installer/hardware qualification remain separate from API test results. ## Executed validation (Linux) - `cargo test -p engine-world -p engine-play -p engine-scripting --locked`: - **137 passed**, 0 failures (30 world, 67 play, 40 scripting). + **138 passed**, 0 failures (31 world, 67 play, 40 scripting). - `cargo check --workspace --all-targets --locked`: passed, including editor/runtime. - Clippy for all targets of the three changed crates with `-D warnings`: passed. - `cargo fmt --all --check` and Git whitespace checks: passed. - Live foundation fixtures: Lua 5.4, Luau, JavaScript, HTML inline JavaScript, - Python, C and C++ passed. C#, Java and PHP fixtures were **skipped** because + Python, C and C++ passed. Installed .NET SDK 10.0.401 in the workspace and + reran the fixture: C# also passed. Java and PHP fixtures were **skipped** because those toolchains are unavailable. Their SDK code is included but unverified here. + The CI adapter-conformance job now runs the entity fixture with all toolchains + installed and fails if any required adapter is missing. - Built the separate bundled Luau host and put it on PATH for the Rust tests. This also resolved the original audio-recording test's missing-host failure. - Both published Lua/Python example scripts executed in the real world adapter. @@ -376,7 +379,7 @@ installer/hardware qualification remain separate from API test results. Engine implementation: `crates/engine-world/src/foundations.rs`, `world.rs`, `lib.rs`; `crates/engine-play/src/entity_services.rs`, `script_runtime.rs`, `lib.rs`. Bindings/editor hints: `crates/engine-scripting/src/lua.rs`, `cpp_sdk.rs`, -`workspace.rs`, and `sdk/{gameplay.lua,gameplay.js,rustic.h,rustic.py,rustic.php, +`workspace.rs`, `.github/workflows/quality.yml`, and `sdk/{gameplay.lua,gameplay.js,rustic.h,rustic.py,rustic.php, Rustic.cs,Rustic.java,entity-api.lua,entity-api.d.ts}`. Engine docs: this checklist, `Scripting/API/entity-foundations.md`, `Scripting/README.md`. Tested examples: `examples/entity-foundations/`. From 68d11beff0ec30722b9f758470b2d7f8e6f536ab Mon Sep 17 00:00:00 2001 From: Jamie Wells Date: Mon, 12 Oct 2026 03:46:19 +0000 Subject: [PATCH 3/3] Fix runtime change review after entity destruction and reparenting --- .../crates/engine-play/src/runtime_server.rs | 138 ++++++++++++++++++ .../crates/engine-play/src/script_runtime.rs | 132 ++++++++++++++++- .../docs/Scripting/API/entity-foundations.md | 8 + 3 files changed, 276 insertions(+), 2 deletions(-) diff --git a/Engine/crates/engine-play/src/runtime_server.rs b/Engine/crates/engine-play/src/runtime_server.rs index dcdbe8ee..000c5c6f 100644 --- a/Engine/crates/engine-play/src/runtime_server.rs +++ b/Engine/crates/engine-play/src/runtime_server.rs @@ -465,3 +465,141 @@ pub enum RuntimeServerError { #[error("runtime script startup/execution failed: {0}")] Scripts(String), } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{RuntimeValue, SnapshotBuilder, SnapshotInput, connect_authenticated}; + use engine_scripting::{ScriptManifest, ScriptManifestEntry}; + use engine_world::{EntitySnapshot, LocalTransform, SceneDocument}; + + #[test] + #[allow( + clippy::too_many_lines, + reason = "exercise the complete authenticated Stop/change-review exchange" + )] + fn stop_acknowledges_after_scripts_destroy_and_reparent_authored_entities() { + let temp = tempfile::tempdir().unwrap(); + let script_id = engine_scripting::ScriptId::new(); + let old_parent = EntitySnapshot { + name: Some("OldParent".into()), + local_transform: LocalTransform { + translation: glam::Vec3::new(10., 0., 0.), + ..LocalTransform::IDENTITY + }, + ..EntitySnapshot::default() + }; + let new_parent = EntitySnapshot { + name: Some("NewParent".into()), + local_transform: LocalTransform { + translation: glam::Vec3::new(20., 0., 0.), + ..LocalTransform::IDENTITY + }, + ..EntitySnapshot::default() + }; + let child = EntitySnapshot { + name: Some("Child".into()), + parent: Some(old_parent.id), + local_transform: LocalTransform { + translation: glam::Vec3::X, + ..LocalTransform::IDENTITY + }, + ..EntitySnapshot::default() + }; + let deleted = EntitySnapshot { + scripts: vec![engine_world::ScriptComponent::new(script_id)], + name: Some("Deleted".into()), + ..EntitySnapshot::default() + }; + let moved = EntitySnapshot { + name: Some("Moved".into()), + ..EntitySnapshot::default() + }; + let moved_id = moved.id; + let mut scene = SceneDocument::new("Review"); + scene.entities = vec![old_parent, new_parent, child, deleted, moved]; + let scene_bytes = scene.to_bytes().unwrap(); + let manifest = ScriptManifest { + format_version: engine_scripting::CURRENT_SCRIPT_MANIFEST_VERSION, + scripts: vec![ScriptManifestEntry { + id: script_id, + language: engine_scripting::ScriptLanguage::Lua54, + relative_path: "scripts/review.lua".into(), + api_version: engine_scripting::ScriptApiVersion::CURRENT, + public_properties: Vec::new(), + }], + }; + let snapshot = SnapshotBuilder::new(temp.path().join("play")) + .stage( + PlayMode::Play, + SnapshotInput::new("scene/main.scene", scene_bytes.clone()), + &[ + SnapshotInput::new( + "config/scripts.ron", + ron::ser::to_string(&manifest).unwrap().into_bytes(), + ), + SnapshotInput::new( + "scripts/review.lua", + br"return {Start=function() + Entity.destroy('Deleted') + Hierarchy.set_parent('OldParent.Child','NewParent') + rustic.edit_object_attribute('Moved','Position',{4,0,0}) + end}" + .to_vec(), + ), + ], + ) + .unwrap(); + let endpoint = LocalEndpoint::unique(temp.path()); + let token = AuthenticationToken::generate(); + let config = RuntimeServerConfig { + endpoint: endpoint.clone(), + authentication_token: token.clone(), + snapshot_root: snapshot.root().into(), + logs_directory: temp.path().join("logs"), + mode: PlayMode::Play, + fixed_delta: Duration::from_millis(16), + crash_after_ready: false, + ignore_stop: false, + }; + let server = thread::spawn(move || run_runtime_server(config)); + let mut client = connect_authenticated(&endpoint, token, Duration::from_secs(5)).unwrap(); + client + .set_receive_timeout(Some(Duration::from_secs(5))) + .unwrap(); + while !matches!( + client.receive().unwrap().message, + ProtocolMessage::Ready { .. } + ) {} + client + .send(42, &ProtocolMessage::Control(ControlRequest::Stop)) + .unwrap(); + let mut changes = None; + loop { + let received = client.receive().unwrap(); + match received.message { + ProtocolMessage::RuntimeChanges(value) => { + assert_eq!(received.request_id, 42); + changes = Some(value); + } + ProtocolMessage::ControlAck(ack) => { + assert_eq!(received.request_id, 42); + assert_eq!(ack.request, ControlRequest::Stop); + assert_eq!(ack.state, RuntimeState::Stopped); + break; + } + ProtocolMessage::Error { message, .. } => panic!("Stop failed: {message}"), + _ => {} + } + } + assert!(server.join().unwrap().is_ok()); + let changes = changes.expect("Stop must deliver changes before its acknowledgement"); + assert_eq!(changes.changes.len(), 1); + assert_eq!(changes.changes[0].target.entity_id, moved_id.to_string()); + assert_eq!( + changes.changes[0].after, + RuntimeValue::Vector3([4., 0., 0.]) + ); + assert_eq!(snapshot.scene_bytes().unwrap(), scene_bytes); + } +} diff --git a/Engine/crates/engine-play/src/script_runtime.rs b/Engine/crates/engine-play/src/script_runtime.rs index 3c380ef0..3555c2c6 100644 --- a/Engine/crates/engine-play/src/script_runtime.rs +++ b/Engine/crates/engine-play/src/script_runtime.rs @@ -668,7 +668,10 @@ impl RuntimeScripts { .map(|entity| { ( entity.id, - entity.local_transform.translation.to_array().map(f64::from), + ( + entity.parent, + entity.local_transform.translation.to_array().map(f64::from), + ), ) }) .collect(); @@ -677,7 +680,16 @@ impl RuntimeScripts { .lock() .map_err(|_| "runtime world lock poisoned")?; let mut changes = Vec::new(); - for (entity, original) in before { + for (entity, (original_parent, original)) in before { + // Structural edits are runtime-only: the review/apply format can + // represent translations, but not deletions or changed parents. + // Never dereference a deleted original, or apply a rewritten local + // translation while the editor retains the authored parent. + if !world.contains(entity) + || world.parent(entity).map_err(|error| error.to_string())? != original_parent + { + continue; + } let current = world .local_transform(entity) .map_err(|error| error.to_string())? @@ -3342,6 +3354,122 @@ rustic_run(["on_start"=>"on_start"]);"#), ); } + #[test] + fn runtime_changes_skip_destroyed_originals_and_keep_survivor_edits() { + let temp = tempfile::tempdir().unwrap(); + let mut scene = SceneDocument::new("Review"); + let root = EntitySnapshot::default(); + let child = EntitySnapshot { + parent: Some(root.id), + ..EntitySnapshot::default() + }; + let survivor = EntitySnapshot::default(); + let (root_id, survivor_id) = (root.id, survivor.id); + scene.entities = vec![root, child, survivor]; + let scene_bytes = scene.to_bytes().unwrap(); + let mut runtime = RuntimeScripts::load(temp.path(), &scene_bytes).unwrap(); + let mut host = environment_host(&runtime.world); + host.gameplay_query(serde_json::json!({"op":"entity_destroy","entity":root_id})) + .unwrap(); + { + let mut world = runtime.world.lock().unwrap(); + world + .apply_commands(&[WorldCommand::SetLocalTransform { + entity: survivor_id, + value: engine_world::LocalTransform { + translation: glam::Vec3::X, + ..engine_world::LocalTransform::IDENTITY + }, + }]) + .unwrap(); + } + runtime.stop(); + let changes = runtime.runtime_changes("base", &scene_bytes).unwrap(); + assert_eq!(changes.changes.len(), 1); + assert_eq!(changes.changes[0].target.entity_id, survivor_id.to_string()); + assert_eq!( + changes.changes[0].after, + RuntimeValue::Vector3([1., 0., 0.]) + ); + assert_eq!(scene.to_bytes().unwrap(), scene_bytes); + } + + #[test] + fn runtime_changes_suppress_reparented_translations_until_parent_is_restored() { + let temp = tempfile::tempdir().unwrap(); + let mut scene = SceneDocument::new("Review"); + let parent = EntitySnapshot { + local_transform: engine_world::LocalTransform { + translation: glam::Vec3::new(10., 0., 0.), + ..engine_world::LocalTransform::IDENTITY + }, + ..EntitySnapshot::default() + }; + let other_parent = EntitySnapshot { + local_transform: engine_world::LocalTransform { + translation: glam::Vec3::new(20., 0., 0.), + ..engine_world::LocalTransform::IDENTITY + }, + ..EntitySnapshot::default() + }; + let child = EntitySnapshot { + parent: Some(parent.id), + local_transform: engine_world::LocalTransform { + translation: glam::Vec3::X, + ..engine_world::LocalTransform::IDENTITY + }, + ..EntitySnapshot::default() + }; + let (parent_id, other_id, child_id) = (parent.id, other_parent.id, child.id); + scene.entities = vec![parent, other_parent, child]; + let scene_bytes = scene.to_bytes().unwrap(); + let mut runtime = RuntimeScripts::load(temp.path(), &scene_bytes).unwrap(); + for keep_world in [true, false] { + let mut host = environment_host(&runtime.world); + host.gameplay_query(serde_json::json!({"op":"hierarchy_set_parent","entity":child_id,"parent":other_id,"keep_world":keep_world})).unwrap(); + assert!( + runtime + .runtime_changes("base", &scene_bytes) + .unwrap() + .changes + .is_empty() + ); + host.gameplay_query(serde_json::json!({"op":"hierarchy_detach","entity":child_id,"keep_world":keep_world})).unwrap(); + assert!( + runtime + .runtime_changes("base", &scene_bytes) + .unwrap() + .changes + .is_empty() + ); + // A restored parent makes local translation edits meaningful again. + host.gameplay_query(serde_json::json!({"op":"hierarchy_set_parent","entity":child_id,"parent":parent_id,"keep_world":true})).unwrap(); + } + { + let mut world = runtime.world.lock().unwrap(); + let mut value = world.local_transform(child_id).unwrap(); + value.translation = glam::Vec3::new(2., 0., 0.); + world + .apply_commands(&[WorldCommand::SetLocalTransform { + entity: child_id, + value, + }]) + .unwrap(); + } + runtime.stop(); + let changes = runtime.runtime_changes("base", &scene_bytes).unwrap(); + assert_eq!(changes.changes.len(), 1); + assert_eq!(changes.changes[0].target.entity_id, child_id.to_string()); + assert_eq!( + changes.changes[0].before, + RuntimeValue::Vector3([1., 0., 0.]) + ); + assert_eq!( + changes.changes[0].after, + RuntimeValue::Vector3([2., 0., 0.]) + ); + } + #[test] fn hot_reload_success_and_compile_failure_retain_last_good_state() { let temp = tempfile::tempdir().unwrap(); diff --git a/Engine/docs/Scripting/API/entity-foundations.md b/Engine/docs/Scripting/API/entity-foundations.md index 700cb955..b7cde90f 100644 --- a/Engine/docs/Scripting/API/entity-foundations.md +++ b/Engine/docs/Scripting/API/entity-foundations.md @@ -88,6 +88,14 @@ across callbacks. An empty query returns an empty list, never a fake entity. scale combined with rotation can require shear; the current TRS representation cannot preserve that pose, and the call errors without mutation. Pass false only if keeping the local transform is intended. +- Stop/change review exports only local translation changes. Destroyed authored + entities are skipped, so destroying an object or its subtree does not prevent + Play from stopping normally. Deletions and hierarchy edits are runtime-only; + applying reviewed changes does not delete objects or change their authored + parents. Entities whose parent differs from the original scene are excluded + from translation review, including after detach, because their local coordinates + belong to a different parent. Other surviving entities still contribute edits. + If the original parent is restored before Stop, translation review resumes. - Components queries report the existing authored built-ins: rustic.transform, rustic.part_attributes, rustic.name, rustic.folder, rustic.parent, rustic.mesh, rustic.material, rustic.camera, rustic.light, rustic.primitive, rustic.scripts;