diff --git a/Engine/Cargo.lock b/Engine/Cargo.lock index 0b37dc84..81a62c5f 100644 --- a/Engine/Cargo.lock +++ b/Engine/Cargo.lock @@ -164,9 +164,9 @@ dependencies = [ [[package]] name = "async-executor" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +checksum = "f426a31126c735126297bc5ea7a521263c9faff4f63ca415267ee625651c4f37" dependencies = [ "async-task", "concurrent-queue", diff --git a/Engine/crates/engine-play/src/script_runtime.rs b/Engine/crates/engine-play/src/script_runtime.rs index 8dc1bcea..c149daf2 100644 --- a/Engine/crates/engine-play/src/script_runtime.rs +++ b/Engine/crates/engine-play/src/script_runtime.rs @@ -99,7 +99,7 @@ impl RuntimeBehavior { Self::Web(old) => WebBehavior::load_shared( old.script_id(), source, - "hot-reload.html", + old.source_name(), Arc::clone(&host), 200_000, ) @@ -852,7 +852,11 @@ fn read_script_source( } Err(error) => return Err(format!("could not read {}: {error}", source_path.display())), }; - if entry.language == ScriptLanguage::Web { + if entry.language == ScriptLanguage::Web + && entry.relative_path.extension().is_some_and(|extension| { + extension.eq_ignore_ascii_case("html") || extension.eq_ignore_ascii_case("htm") + }) + { resolve_linked_web_scripts( snapshot_root, &entry.relative_path, @@ -5010,6 +5014,72 @@ rustic_run(["on_start"=>"on_start"]);"#), assert!(logs.lock().unwrap().is_empty()); } + #[test] + fn ui_javascript_receives_gameplay_signals_reads_scene_state_and_reloads() { + for path in ["ui/hud.js", "ui/nested/hud.mjs"] { + let temp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(temp.path().join("scripts")).unwrap(); + std::fs::create_dir_all(temp.path().join("ui/nested")).unwrap(); + std::fs::write( + temp.path().join("scripts/player.js"), + br"globalThis.behavior={ + FixedUpdate(){rustic.set_translation(3,0,0);Events.emit('score',[7]);} + };", + ) + .unwrap(); + let ui_source = br"globalThis.behavior={Start(){Events.on('score',score=>{ + const position=rustic.get_object_attribute('Player','Position'); + rustic.log('info',score+':'+position[0]); + });}};"; + std::fs::write(temp.path().join(path), ui_source).unwrap(); + let player_id = + engine_scripting::register_script_asset(temp.path(), "scripts/player.js").unwrap(); + let ui_id = engine_scripting::register_script_asset(temp.path(), path).unwrap(); + let manifest = + load_manifest(&std::fs::read(temp.path().join("config/scripts.ron")).unwrap()) + .unwrap() + .manifest; + assert_eq!( + manifest + .scripts + .iter() + .find(|e| e.id == ui_id) + .unwrap() + .language, + ScriptLanguage::Web + ); + let scene = SceneDocument { + environment: engine_world::SceneEnvironment::default(), + id: engine_core::SceneId::new(), + name: "UI bridge".into(), + startup_scripts: Vec::new(), + entities: vec![ + EntitySnapshot { + name: Some("Player".into()), + scripts: vec![ScriptComponent::new(player_id)], + ..EntitySnapshot::default() + }, + EntitySnapshot { + scripts: vec![ScriptComponent::new(ui_id)], + ..EntitySnapshot::default() + }, + ], + instances: Vec::new(), + }; + GameSettings::default().save(temp.path()).unwrap(); + let mut runtime = + RuntimeScripts::load(temp.path(), &scene.to_bytes().unwrap()).unwrap(); + runtime.fixed_update(0.016); + runtime.frame_update(0.016); + assert_eq!(runtime.drain_logs(), vec![("info".into(), "7:3".into())]); + assert!(runtime.reload(ui_id, b"globalThis.behavior={").is_err()); + runtime.reload(ui_id, ui_source).unwrap(); + runtime.fixed_update(0.016); + runtime.frame_update(0.016); + assert_eq!(runtime.drain_logs(), vec![("info".into(), "7:3".into())]); + } + } + #[test] fn linked_web_script_executes_on_its_attached_entity() { let temp = tempfile::tempdir().unwrap(); diff --git a/Engine/crates/engine-scripting/src/agent_authoring.md b/Engine/crates/engine-scripting/src/agent_authoring.md index f2a34ea1..b6c7e1ff 100644 --- a/Engine/crates/engine-scripting/src/agent_authoring.md +++ b/Engine/crates/engine-scripting/src/agent_authoring.md @@ -94,6 +94,8 @@ Use `object_duplicate` to copy an object/folder and its whole subtree with fresh ## Attach scripts at the correct scope +JavaScript `.js`/`.mjs` sources inside the top-level `ui/` folder (including subfolders) are automatically Web/UI assets; sources elsewhere remain gameplay JavaScript. UI scripts still require a global, scene or object attachment (or inclusion from an attached HTML entry). Avoid attaching an HTML helper twice. The same sandboxed scene APIs and `Events.on`/`Events.emit` work across UI and gameplay instances; share exposed object state or signal payloads rather than private JavaScript globals. This does not provide DOM rendering or browser/Node/module APIs. + Write the gameplay source to the project, then call `script_attach` with its relative `path` and `scope`. The tool registers the source's stable script asset ID; never hand-invent registry IDs. Scope belongs to an attachment, so one source may be attached in several places. Adding a new scope does not remove another attachment: detach the old one explicitly to move behavior and avoid duplicate execution. - `global`: game/session services such as progression, settings or shared state; listed in `settings.json` and persists across scene switches. Takes `enabled` and `execution_order` and saves immediately. diff --git a/Engine/crates/engine-scripting/src/external_runtime.rs b/Engine/crates/engine-scripting/src/external_runtime.rs index 0c211470..c839c567 100644 --- a/Engine/crates/engine-scripting/src/external_runtime.rs +++ b/Engine/crates/engine-scripting/src/external_runtime.rs @@ -372,13 +372,14 @@ impl ExternalBehavior { } } -/// HTML/CSS entry content with sandboxed inline JavaScript lifecycle callbacks. +/// UI content with sandboxed standalone or inline JavaScript lifecycle callbacks. pub struct WebBehavior { inner: JavaScriptBehavior, + source_name: String, } impl WebBehavior { - /// Validates the document and creates its sandboxed inline-script behavior. + /// Validates UI content and creates its sandboxed JavaScript behavior. /// /// # Errors /// Returns structural HTML/CSS or JavaScript diagnostics. @@ -412,7 +413,15 @@ impl WebBehavior { validate_web(source, source_name)?; let text = std::str::from_utf8(source) .map_err(|error| ExternalRuntimeError::InvalidUtf8("HTML/CSS", error.to_string()))?; - let script = extract_inline_scripts(text)?; + let script = if std::path::Path::new(source_name) + .extension() + .is_some_and(|extension| { + extension.eq_ignore_ascii_case("js") || extension.eq_ignore_ascii_case("mjs") + }) { + text.to_owned() + } else { + extract_inline_scripts(text)? + }; let script = if script.trim().is_empty() { "globalThis.behavior = {};".to_owned() } else { @@ -426,7 +435,14 @@ impl WebBehavior { instruction_budget, ) .map_err(web_javascript_error)?; - Ok(Self { inner }) + Ok(Self { + inner, + source_name: source_name.to_owned(), + }) + } + + pub fn source_name(&self) -> &str { + &self.source_name } pub const fn script_id(&self) -> ScriptId { @@ -959,6 +975,14 @@ fn validate_source_bytes( } fn validate_web(source: &[u8], source_name: &str) -> Result<(), ExternalRuntimeError> { + if std::path::Path::new(source_name) + .extension() + .is_some_and(|extension| { + extension.eq_ignore_ascii_case("js") || extension.eq_ignore_ascii_case("mjs") + }) + { + return validate_javascript(source, source_name, 1024 * 1024).map_err(web_javascript_error); + } let text = std::str::from_utf8(source) .map_err(|error| ExternalRuntimeError::InvalidUtf8("HTML/CSS", error.to_string()))?; let lower = text.to_ascii_lowercase(); @@ -1840,6 +1864,26 @@ for line in sys.stdin: } } + #[test] + fn standalone_ui_javascript_is_validated_without_html_parsing() { + validate_external( + ScriptLanguage::Web, + b"globalThis.behavior={Start(){print('');}};", + "ui/hud.js", + 1024, + ) + .unwrap(); + assert!( + validate_external( + ScriptLanguage::Web, + b"globalThis.behavior={", + "ui/hud.mjs", + 1024 + ) + .is_err() + ); + } + #[test] fn web_behavior_executes_inline_lifecycle() { let source = br"
"; diff --git a/Engine/crates/engine-scripting/src/manifest.rs b/Engine/crates/engine-scripting/src/manifest.rs index bd9fc778..7e578da9 100644 --- a/Engine/crates/engine-scripting/src/manifest.rs +++ b/Engine/crates/engine-scripting/src/manifest.rs @@ -193,7 +193,7 @@ pub fn load_manifest(bytes: &[u8]) -> Result