Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Engine/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

74 changes: 72 additions & 2 deletions Engine/crates/engine-play/src/script_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions Engine/crates/engine-scripting/src/agent_authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
52 changes: 48 additions & 4 deletions Engine/crates/engine-scripting/src/external_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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('</script>');}};",
"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"<!doctype html><html><body><script>globalThis.behavior={fixed_update(){const [x,y,z]=rustic.get_translation();rustic.set_translation(x+5,y,z);}};</script></body></html>";
Expand Down
48 changes: 47 additions & 1 deletion Engine/crates/engine-scripting/src/manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ pub fn load_manifest(bytes: &[u8]) -> Result<ManifestLoad, ManifestError> {
supported: CURRENT_SCRIPT_MANIFEST_VERSION,
});
}
let migrated = probe.format_version == 1;
let mut migrated = probe.format_version == 1;
let mut manifest = if migrated {
let old: ManifestV1 =
ron::from_str(text).map_err(|e| ManifestError::Parse(e.to_string()))?;
Expand All @@ -214,6 +214,16 @@ pub fn load_manifest(bytes: &[u8]) -> Result<ManifestLoad, ManifestError> {
} else {
ron::from_str(text).map_err(|e| ManifestError::Parse(e.to_string()))?
};
// Preserve existing UI script identities and attachments while adopting the
// folder-based default for registries written before UI JavaScript support.
for entry in &mut manifest.scripts {
if entry.language == ScriptLanguage::JavaScript
&& ScriptLanguage::from_path(&entry.relative_path) == Some(ScriptLanguage::Web)
{
entry.language = ScriptLanguage::Web;
migrated = true;
}
}
if let Some(entry) = manifest.scripts.iter().find(|entry| {
!safe_relative(&entry.relative_path)
|| !entry.language.accepts_project_path(&entry.relative_path)
Expand Down Expand Up @@ -318,6 +328,42 @@ mod tests {
Err(ManifestError::FutureVersion { .. })
));
}
#[test]
fn legacy_ui_javascript_adopts_web_category_without_changing_identity() {
let id = ScriptId::new();
let source = format!(
r#"(format_version:2,scripts:[(id:"{id}",language:java_script,relative_path:"ui/hud.js",api_version:(major:1,minor:0))])"#
);
let loaded = load_manifest(source.as_bytes()).unwrap();
assert!(loaded.migrated);
assert_eq!(loaded.manifest.scripts[0].id, id);
assert_eq!(loaded.manifest.scripts[0].language, ScriptLanguage::Web);
}

#[test]
fn moving_javascript_between_ui_and_gameplay_preserves_identity() {
let temp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(temp.path().join("scripts")).unwrap();
std::fs::write(
temp.path().join("scripts/hud.js"),
"globalThis.behavior={};",
)
.unwrap();
let id = register_script_asset(temp.path(), "scripts/hud.js").unwrap();
for (path, language) in [
("ui/nested/hud.js", ScriptLanguage::Web),
("scripts/hud.js", ScriptLanguage::JavaScript),
] {
move_script_asset(temp.path(), id, path).unwrap();
let loaded =
load_manifest(&std::fs::read(temp.path().join("config/scripts.ron")).unwrap())
.unwrap();
assert_eq!(loaded.manifest.scripts[0].id, id);
assert_eq!(loaded.manifest.scripts[0].language, language);
assert!(temp.path().join(path).is_file());
}
}

#[test]
fn duplicates_are_quarantined_and_traversal_is_rejected_on_save() {
let id = ScriptId::new();
Expand Down
17 changes: 16 additions & 1 deletion Engine/crates/engine-scripting/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ impl ScriptLanguage {
Self::CSharp => "C#",
Self::Java => "Java",
Self::Php => "PHP",
Self::Web => "HTML/CSS",
Self::Web => "HTML/CSS/JavaScript",
}
}

Expand All @@ -150,6 +150,7 @@ impl ScriptLanguage {
match extension.as_str() {
"lua" => Some(Self::Lua54),
"luau" => Some(Self::Luau),
"js" | "mjs" if path.starts_with("ui") => Some(Self::Web),
"js" | "mjs" => Some(Self::JavaScript),
"py" => Some(Self::Python),
"c" => Some(Self::C),
Expand Down Expand Up @@ -367,6 +368,20 @@ mod tests {
#[test]
fn web_languages_are_confined_to_the_ui_directory() {
assert!(ScriptLanguage::Web.accepts_project_path(Path::new("ui/hud.html")));
for path in ["ui/hud.js", "ui/nested/hud.mjs", "ui/hud.JS"] {
assert_eq!(
ScriptLanguage::from_path(Path::new(path)),
Some(ScriptLanguage::Web)
);
assert!(ScriptLanguage::Web.accepts_project_path(Path::new(path)));
assert!(!ScriptLanguage::JavaScript.accepts_project_path(Path::new(path)));
}
for path in ["scripts/ui/hud.js", "ui-other/hud.js", "hud.js"] {
assert_eq!(
ScriptLanguage::from_path(Path::new(path)),
Some(ScriptLanguage::JavaScript)
);
}
assert!(ScriptLanguage::Web.accepts_project_path(Path::new("ui/theme.css")));
assert!(ScriptLanguage::Php.accepts_project_path(Path::new("ui/menu.php")));
assert!(!ScriptLanguage::Web.accepts_project_path(Path::new("scripts/hud.html")));
Expand Down
6 changes: 6 additions & 0 deletions Engine/docs/Scripting/scriptingJavaScript.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ JavaScript is bundled and sandboxed. A script assigns one behavior object to
APIs. Create `.js` or `.mjs` gameplay files with the editor so an Asset ID and the
chosen global, scene, or component attachment are created automatically.

JavaScript under the project's top-level `ui/` folder, including subfolders,
automatically uses the Web/UI category. It keeps the same lifecycle, scene access
and `Events` APIs. Attachments still determine when it runs. See
[UI JavaScript and gameplay signals](scriptingWeb.md#javascript-in-ui) for setup,
a working example and runtime limitations.

## Behavior lifecycle

```javascript
Expand Down
61 changes: 60 additions & 1 deletion Engine/docs/Scripting/scriptingWeb.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

Web assets are `.html`, `.htm`, or `.css` files under `ui/`. The directory rule is
enforced: they are not accepted as gameplay assets elsewhere. Rustic structurally
validates the document and executes inline `<script>` content in the same bundled,
validates HTML documents and executes inline `<script>` content or standalone
`ui/*.js` / `ui/*.mjs` behaviors in the same bundled,
sandboxed JavaScript behavior runtime used by `.js` scripts. It does **not** embed a
browser renderer and does not replace the editor shell.

Expand Down Expand Up @@ -197,3 +198,61 @@ from an HTML file under `ui/`. If Save reports an external change, keep the docu
open to retain your edits, or discard and reopen to load the disk version; Save
will not overwrite that external version. Files must be UTF-8 and at most 1 MiB;
paths and symlinks that leave `ui/` are rejected. Open failures appear in Terminal.


## JavaScript in `ui/`

Files ending in `.js` or `.mjs` under the project's top-level `ui/` folder,
including subfolders, automatically use the Web/UI category. For example,
`ui/hud.js` is UI code; `scripts/player.js` is gameplay JavaScript. Existing
registrations adopt this category on load while preserving their asset IDs and
attachments. Moving a registered script into or out of `ui/` updates its category.

The folder selects the category, not when the script runs. In the Project Files
panel, right-click `ui/hud.js`, choose **Set As**, then **Scene Startup Script**
for a scene HUD. Save the scene and press Play. Use **Object Component Script**
for object-owned UI behavior or **Global Startup Script** for session services.
A helper included with `<script src="hud.js"></script>` in an attached HTML
entry runs within that entry; attach it separately only if you want another instance.

UI scripts keep the same sandboxed JavaScript APIs as gameplay scripts. They can
read scene state exposed by gameplay objects and receive or emit `Events` signals.
Each behavior has its own JavaScript globals; another script's private variables
are not directly accessible. Publish the values needed by the UI through signals
or engine-owned object state.

For example, create `scripts/player.js` and attach it to an object named `Player`:

```javascript
globalThis.behavior = {
FixedUpdate() {
rustic.set_translation(3, 0, 0);
Events.emit("ScoreChanged", [7]);
}
};
```

Create `ui/hud.js` and attach it as a scene startup script:

```javascript
globalThis.behavior = {
Start() {
Events.on("ScoreChanged", score => {
const position = rustic.get_object_attribute("Player", "Position");
rustic.log("info", `HUD score: ${score}; player x: ${position[0]}`);
});
}
};
```

Play logs show `HUD score: 7; player x: 3`. Signals deliver during frame updates;
a listener must be connected before emission, so publish current state after
subscribing or read engine-owned state to initialize a HUD. If nothing runs,
check the attachment, enabled state, active scene and script diagnostics. The
folder alone does not start a script. Use the exact top-level `ui/` name;
`scripts/ui/` remains gameplay code.

UI JavaScript still uses the bundled behavior runtime: `document`, DOM updates,
browser globals, Node APIs and ES module imports are unavailable. `.mjs` uses
the same behavior format as `.js`. Use the supported engine APIs to consume
state; this classification does not add a rendered HTML HUD.
Loading