diff --git a/Cargo.toml b/Cargo.toml index bf0bc7f..f59176f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ keywords = ["bevy", "lua", "scripting", "game", "script"] lua = ["dep:mlua", "mlua/luajit"] rhai = ["dep:rhai"] ruby = ["dep:magnus", "dep:rb-sys"] +javascript = ["dep:deno_core"] [dependencies] bevy = { default-features = false, version = "0.18", features = ["bevy_asset", "bevy_log"] } @@ -33,6 +34,7 @@ mlua = { version = "0.9.8", features = [ ], optional = true } magnus = { version = "0.8.2", optional = true } rb-sys = { version = "0.9", default-features = false, features = ["link-ruby", "ruby-static"], optional = true } +deno_core = { version = "0.403", optional = true } crossbeam-channel = "0.5.15" libc = "0.2.172" @@ -211,6 +213,56 @@ name = "side_effects_ruby" path = "examples/ruby/side_effects.rs" required-features = ["ruby"] +[[example]] +name = "call_function_from_rust_js" +path = "examples/js/call_function_from_rust.rs" +required-features = ["javascript"] + +[[example]] +name = "current_entity_js" +path = "examples/js/current_entity.rs" +required-features = ["javascript"] + +[[example]] +name = "ecs_js" +path = "examples/js/ecs.rs" +required-features = ["javascript"] + +[[example]] +name = "entity_variable_js" +path = "examples/js/entity_variable.rs" +required-features = ["javascript"] + +[[example]] +name = "function_params_js" +path = "examples/js/function_params.rs" +required-features = ["javascript"] + +[[example]] +name = "hello_world_js" +path = "examples/js/hello_world.rs" +required-features = ["javascript"] + +[[example]] +name = "multiple_plugins_js" +path = "examples/js/multiple_plugins.rs" +required-features = ["javascript"] + +[[example]] +name = "non_closure_system_js" +path = "examples/js/non_closure_system.rs" +required-features = ["javascript"] + +[[example]] +name = "promises_js" +path = "examples/js/promises.rs" +required-features = ["javascript"] + +[[example]] +name = "side_effects_js" +path = "examples/js/side_effects.rs" +required-features = ["javascript"] + [dev-dependencies] tracing-subscriber = "0.3.18" mlua = { version = "0.9.8", features = ["luajit", "vendored", "send"] } diff --git a/README.md b/README.md index 89fb97f..c3069e9 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ bevy_scriptum is a a plugin for [Bevy](https://bevyengine.org/) that allows you | 🌙 LuaJIT | `lua` | [link](https://jarkonik.github.io/bevy_scriptum/lua/lua.html) | | 🌾 Rhai | `rhai` | [link](https://jarkonik.github.io/bevy_scriptum/rhai/rhai.html) | | 💎 Ruby(currently only supported on Linux and MacOS) | `ruby` | [link](https://jarkonik.github.io/bevy_scriptum/ruby/ruby.html) | +| 🟨 JavaScript (V8 via deno_core) | `javascript` | [link](https://jarkonik.github.io/bevy_scriptum/javascript/javascript.html) | Documentation book is available [here](https://jarkonik.github.io/bevy_scriptum/) 📖 diff --git a/assets/examples/js/call_function_from_rust.js b/assets/examples/js/call_function_from_rust.js new file mode 100644 index 0000000..82e09e1 --- /dev/null +++ b/assets/examples/js/call_function_from_rust.js @@ -0,0 +1,13 @@ +var my_state = { + iterations: 0, +}; + +function on_update() { + my_state.iterations = my_state.iterations + 1; + print("on_update called " + my_state.iterations + " times"); + + if (my_state.iterations >= 10) { + print("calling quit"); + quit(); + } +} diff --git a/assets/examples/js/current_entity.js b/assets/examples/js/current_entity.js new file mode 100644 index 0000000..e3302c5 --- /dev/null +++ b/assets/examples/js/current_entity.js @@ -0,0 +1,7 @@ +// entity is a global variable that is set to the entity that is currently being +// processed, it is automatically available in all scripts. + +// get name of the entity +get_name(entity).and_then(function (name) { + print(name); +}); diff --git a/assets/examples/js/ecs.js b/assets/examples/js/ecs.js new file mode 100644 index 0000000..04d5c5c --- /dev/null +++ b/assets/examples/js/ecs.js @@ -0,0 +1 @@ +print_player_names(); diff --git a/assets/examples/js/entity_variable.js b/assets/examples/js/entity_variable.js new file mode 100644 index 0000000..27068c9 --- /dev/null +++ b/assets/examples/js/entity_variable.js @@ -0,0 +1,3 @@ +// entity is a global variable that is set to the entity that is currently being +// processed, it is automatically available in all scripts. +print("Current entity index: " + entity.index); diff --git a/assets/examples/js/function_params.js b/assets/examples/js/function_params.js new file mode 100644 index 0000000..ca362a5 --- /dev/null +++ b/assets/examples/js/function_params.js @@ -0,0 +1,4 @@ +fun_without_params(); +fun_with_string_param("hello"); +fun_with_i64_param(5); +fun_with_multiple_params(5, "hello"); diff --git a/assets/examples/js/hello_world.js b/assets/examples/js/hello_world.js new file mode 100644 index 0000000..bd18b19 --- /dev/null +++ b/assets/examples/js/hello_world.js @@ -0,0 +1 @@ +hello_bevy(); diff --git a/assets/examples/js/multiple_plugins_plugin_a.js b/assets/examples/js/multiple_plugins_plugin_a.js new file mode 100644 index 0000000..1316c32 --- /dev/null +++ b/assets/examples/js/multiple_plugins_plugin_a.js @@ -0,0 +1 @@ +hello_from_plugin_a(); diff --git a/assets/examples/js/multiple_plugins_plugin_b.js b/assets/examples/js/multiple_plugins_plugin_b.js new file mode 100644 index 0000000..1a65303 --- /dev/null +++ b/assets/examples/js/multiple_plugins_plugin_b.js @@ -0,0 +1 @@ +hello_from_plugin_b_with_parameters("hello", 42); diff --git a/assets/examples/js/promises.js b/assets/examples/js/promises.js new file mode 100644 index 0000000..d92b10f --- /dev/null +++ b/assets/examples/js/promises.js @@ -0,0 +1,3 @@ +get_player_name().and_then(function (name) { + print(name); +}); diff --git a/assets/examples/js/side_effects.js b/assets/examples/js/side_effects.js new file mode 100644 index 0000000..fbd0c56 --- /dev/null +++ b/assets/examples/js/side_effects.js @@ -0,0 +1 @@ +spawn_entity(); diff --git a/assets/tests/js/call_script_function_that_causes_runtime_error.js b/assets/tests/js/call_script_function_that_causes_runtime_error.js new file mode 100644 index 0000000..1812012 --- /dev/null +++ b/assets/tests/js/call_script_function_that_causes_runtime_error.js @@ -0,0 +1,3 @@ +function test_func() { + throw new Error("intentional runtime error"); +} diff --git a/assets/tests/js/call_script_function_with_params.js b/assets/tests/js/call_script_function_with_params.js new file mode 100644 index 0000000..e361693 --- /dev/null +++ b/assets/tests/js/call_script_function_with_params.js @@ -0,0 +1,7 @@ +var State = { + called_with: null, +}; + +function test_func(x) { + State.called_with = x; +} diff --git a/assets/tests/js/entity_variable.js b/assets/tests/js/entity_variable.js new file mode 100644 index 0000000..02ac282 --- /dev/null +++ b/assets/tests/js/entity_variable.js @@ -0,0 +1,3 @@ +function test_func() { + rust_func(entity.index); +} diff --git a/assets/tests/js/entity_variable_eval.js b/assets/tests/js/entity_variable_eval.js new file mode 100644 index 0000000..ef5fca3 --- /dev/null +++ b/assets/tests/js/entity_variable_eval.js @@ -0,0 +1,5 @@ +var index = entity.index; + +function test_func() { + rust_func(index); +} diff --git a/assets/tests/js/eval_that_causes_runtime_error.js b/assets/tests/js/eval_that_causes_runtime_error.js new file mode 100644 index 0000000..3a599a8 --- /dev/null +++ b/assets/tests/js/eval_that_causes_runtime_error.js @@ -0,0 +1,2 @@ +mark_called(); +throw new Error("intentional runtime error"); diff --git a/assets/tests/js/pass_entity_from_script.js b/assets/tests/js/pass_entity_from_script.js new file mode 100644 index 0000000..2042a8c --- /dev/null +++ b/assets/tests/js/pass_entity_from_script.js @@ -0,0 +1,3 @@ +function test_func() { + rust_func(entity); +} diff --git a/assets/tests/js/pass_vec3_from_script.js b/assets/tests/js/pass_vec3_from_script.js new file mode 100644 index 0000000..7c0ed7a --- /dev/null +++ b/assets/tests/js/pass_vec3_from_script.js @@ -0,0 +1,3 @@ +function test_func() { + rust_func(Vec3(1.5, 2.5, -3.5)); +} diff --git a/assets/tests/js/pass_vec3_to_script.js b/assets/tests/js/pass_vec3_to_script.js new file mode 100644 index 0000000..710ddd2 --- /dev/null +++ b/assets/tests/js/pass_vec3_to_script.js @@ -0,0 +1,6 @@ +function test_func(vec3) { + if (vec3.x !== 1.5 || vec3.y !== 2.5 || vec3.z !== -3.5) { + throw new Error("unexpected Vec3 components"); + } + mark_success(); +} diff --git a/assets/tests/js/promise_runtime_error.js b/assets/tests/js/promise_runtime_error.js new file mode 100644 index 0000000..f444390 --- /dev/null +++ b/assets/tests/js/promise_runtime_error.js @@ -0,0 +1,5 @@ +function test_func() { + rust_func().and_then(function (x) { + throw new Error("intentional runtime error"); + }); +} diff --git a/assets/tests/js/return_via_promise.js b/assets/tests/js/return_via_promise.js new file mode 100644 index 0000000..a175c6c --- /dev/null +++ b/assets/tests/js/return_via_promise.js @@ -0,0 +1,9 @@ +var State = { + x: null, +}; + +function test_func() { + rust_func().and_then(function (x) { + State.x = x; + }); +} diff --git a/assets/tests/js/rust_function_gets_called_from_script.js b/assets/tests/js/rust_function_gets_called_from_script.js new file mode 100644 index 0000000..adc1b45 --- /dev/null +++ b/assets/tests/js/rust_function_gets_called_from_script.js @@ -0,0 +1,3 @@ +function test_func() { + rust_func(); +} diff --git a/assets/tests/js/rust_function_gets_called_from_script_with_multiple_params.js b/assets/tests/js/rust_function_gets_called_from_script_with_multiple_params.js new file mode 100644 index 0000000..d69509a --- /dev/null +++ b/assets/tests/js/rust_function_gets_called_from_script_with_multiple_params.js @@ -0,0 +1,3 @@ +function test_func() { + rust_func(5, "test"); +} diff --git a/assets/tests/js/rust_function_gets_called_from_script_with_param.js b/assets/tests/js/rust_function_gets_called_from_script_with_param.js new file mode 100644 index 0000000..f055745 --- /dev/null +++ b/assets/tests/js/rust_function_gets_called_from_script_with_param.js @@ -0,0 +1,3 @@ +function test_func() { + rust_func(5); +} diff --git a/assets/tests/js/script_function_gets_called_from_rust.js b/assets/tests/js/script_function_gets_called_from_rust.js new file mode 100644 index 0000000..0c8319d --- /dev/null +++ b/assets/tests/js/script_function_gets_called_from_rust.js @@ -0,0 +1,7 @@ +var State = { + times_called: 0, +}; + +function test_func() { + State.times_called = State.times_called + 1; +} diff --git a/assets/tests/js/script_function_gets_called_from_rust_with_multiple_params.js b/assets/tests/js/script_function_gets_called_from_rust_with_multiple_params.js new file mode 100644 index 0000000..4f804f7 --- /dev/null +++ b/assets/tests/js/script_function_gets_called_from_rust_with_multiple_params.js @@ -0,0 +1,9 @@ +var State = { + a_value: null, + b_value: null, +}; + +function test_func(a, b) { + State.a_value = a; + State.b_value = b; +} diff --git a/assets/tests/js/script_function_gets_called_from_rust_with_single_param.js b/assets/tests/js/script_function_gets_called_from_rust_with_single_param.js new file mode 100644 index 0000000..8d72127 --- /dev/null +++ b/assets/tests/js/script_function_gets_called_from_rust_with_single_param.js @@ -0,0 +1,7 @@ +var State = { + a_value: null, +}; + +function test_func(a) { + State.a_value = a; +} diff --git a/assets/tests/js/side_effects.js b/assets/tests/js/side_effects.js new file mode 100644 index 0000000..56cefce --- /dev/null +++ b/assets/tests/js/side_effects.js @@ -0,0 +1,3 @@ +function test_func() { + spawn_entity(); +} diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index 348dea7..f744af7 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -19,6 +19,15 @@ - [Calling Ruby from Rust](./ruby/calling_script_from_rust.md) - [Interacting with bevy in callbacks](./ruby/interacting_with_bevy.md) - [Builtin types](./ruby/builtin_types.md) + - [JavaScript](./javascript/javascript.md) + - [Installation](./javascript/installation.md) + - [Hello World](./javascript/hello_world.md) + - [Spawning scripts](./javascript/spawning_scripts.md) + - [Calling Rust from JavaScript](./javascript/calling_rust_from_script.md) + - [Calling JavaScript from Rust](./javascript/calling_script_from_rust.md) + - [Interacting with bevy in callbacks](./javascript/interacting_with_bevy.md) + - [Builtin types](./javascript/builtin_types.md) + - [Builtin variables](./javascript/builtin_variables.md) - [Rhai](./rhai/rhai.md) - [Installation](./rhai/installation.md) - [Hello World(TBD)]() diff --git a/book/src/javascript/builtin_types.md b/book/src/javascript/builtin_types.md new file mode 100644 index 0000000..0c415c2 --- /dev/null +++ b/book/src/javascript/builtin_types.md @@ -0,0 +1,85 @@ +# Builtin types + +bevy_scriptum provides the following types that can be used in JavaScript: + +- `Vec3` +- `Entity` + +## Vec3 + +A `Vec3` is represented as a plain object with `x`, `y` and `z` number +properties. A global `Vec3(x, y, z)` constructor function is available to all +scripts. + +### Example JavaScript usage + +```js +var my_vec = Vec3(1, 2, 3); +print(my_vec.x, my_vec.y, my_vec.z); +set_translation(entity, my_vec); +``` + +### Example Rust usage + +```rust,no_run +# extern crate bevy; +# extern crate bevy_scriptum; + +use bevy::prelude::*; +use bevy_scriptum::prelude::*; +use bevy_scriptum::runtimes::javascript::prelude::*; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_scripting::(|runtime| { + runtime.add_function(String::from("set_translation"), set_translation); + }) + .run(); +} + +fn set_translation( + In((entity, translation)): In<(BevyEntity, BevyVec3)>, + mut entities: Query<&mut Transform>, +) { + let mut transform = entities.get_mut(entity.0).unwrap(); + transform.translation = translation.0; +} +``` + +## Entity + +The currently processed entity is available through the global `entity` +variable. It exposes an `index` property and can be passed back to Rust +functions that accept a `BevyEntity` argument. + +`entity` is currently not available within promise callbacks. + +### Example JavaScript usage + +```js +print(entity.index); +pass_to_rust(entity); +``` + +### Example Rust usage + +```rust,no_run +# extern crate bevy; +# extern crate bevy_scriptum; + +use bevy::prelude::*; +use bevy_scriptum::prelude::*; +use bevy_scriptum::runtimes::javascript::prelude::*; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_scripting::(|runtime| { + runtime.add_function(String::from("pass_to_rust"), |In((entity,)): In<(BevyEntity,)>| { + println!("pass_to_rust called with entity: {:?}", entity); + }); + }) + .run(); +} +``` diff --git a/book/src/javascript/builtin_variables.md b/book/src/javascript/builtin_variables.md new file mode 100644 index 0000000..1787d53 --- /dev/null +++ b/book/src/javascript/builtin_variables.md @@ -0,0 +1,14 @@ +# Builtin variables + +## entity + +A variable called `entity` is automatically available to all scripts - it +represents the bevy entity that the `Script` component is attached to. +It exposes an `index` property that returns the bevy entity index. +It is useful for accessing the entity's components from scripts. +It can be used in the following way: +```js +print("Current entity index: " + entity.index); +``` + +The `entity` variable is currently not available within promise callbacks. diff --git a/book/src/javascript/calling_rust_from_script.md b/book/src/javascript/calling_rust_from_script.md new file mode 100644 index 0000000..c5648f0 --- /dev/null +++ b/book/src/javascript/calling_rust_from_script.md @@ -0,0 +1,113 @@ +# Calling Rust from JavaScript + +To call a rust function from JavaScript first you need to register a function +within Rust using the builder pattern. + +```rust,no_run +# extern crate bevy; +# extern crate bevy_scriptum; + +use bevy::prelude::*; +use bevy_scriptum::prelude::*; +use bevy_scriptum::runtimes::javascript::prelude::*; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_scripting::(|runtime| { + // `runtime` is a builder that you can use to register functions + }) + .run(); +} +``` + +For example to register a function called `my_rust_func` you can do the following: + +```rust,no_run +# extern crate bevy; +# extern crate bevy_scriptum; + +use bevy::prelude::*; +use bevy_scriptum::prelude::*; +use bevy_scriptum::runtimes::javascript::prelude::*; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_scripting::(|runtime| { + runtime.add_function(String::from("my_rust_func"), || { + println!("my_rust_func has been called"); + }); + }) + .run(); +} +``` + +After you do that the function will be available to JavaScript code in your +spawned scripts. + +```js +my_rust_func(); +``` + +Since a registered callback function is a Bevy system, the parameters are passed +to it as an `In` struct with a tuple, which has to be the first parameter of the +closure. + +```rust,no_run +# extern crate bevy; +# extern crate bevy_scriptum; + +use bevy::prelude::*; +use bevy_scriptum::prelude::*; +use bevy_scriptum::runtimes::javascript::prelude::*; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_scripting::(|runtime| { + runtime.add_function(String::from("func_with_params"), |In((a, b)): In<(String, i64)>| { + println!("func_with_params has been called with string {} and i64 {}", a, b); + }); + }) + .run(); +} +``` + +The above function can be called from JavaScript + +```js +func_with_params("abc", 123); +``` + +## Return value via promise + +Any registered rust function that returns a value will return a promise when +called within a script. By calling `and_then` on the promise you can register +a callback that will receive the value returned from the Rust function. + +```rust,no_run +# extern crate bevy; +# extern crate bevy_scriptum; + +use bevy::prelude::*; +use bevy_scriptum::prelude::*; +use bevy_scriptum::runtimes::javascript::prelude::*; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_scripting::(|runtime| { + runtime.add_function(String::from("returns_value"), || { + 123 + }); + }) + .run(); +} +``` + +```js +returns_value().and_then(function (value) { + print(value); // 123 +}); +``` diff --git a/book/src/javascript/calling_script_from_rust.md b/book/src/javascript/calling_script_from_rust.md new file mode 100644 index 0000000..1fa7548 --- /dev/null +++ b/book/src/javascript/calling_script_from_rust.md @@ -0,0 +1,66 @@ +# Calling JavaScript from Rust + +To call a function defined in JavaScript + +```js +function on_update() { +} +``` + +We need to acquire the `JsRuntime` resource within a bevy system. +Then we will be able to call `call_fn` on it, providing the name +of the function to call, the `JsScriptData` that has been automatically +attached to the entity after an entity with a script attached has been spawned +and its script evaluated, the entity and optionally some arguments. + +```rust,no_run +# extern crate bevy; +# extern crate bevy_scriptum; + +use bevy::prelude::*; +use bevy_scriptum::prelude::*; +use bevy_scriptum::runtimes::javascript::prelude::*; + +fn call_js_on_update_from_rust( + mut scripted_entities: Query<(Entity, &mut JsScriptData)>, + scripting_runtime: ResMut, +) { + for (entity, mut script_data) in &mut scripted_entities { + // calling function named `on_update` defined in JavaScript script + scripting_runtime + .call_fn("on_update", &mut script_data, entity, ()) + .unwrap(); + } +} +``` + +We can also pass some arguments by providing a tuple or `Vec` as the last +`call_fn` argument. + +```rust,no_run +# extern crate bevy; +# extern crate bevy_scriptum; + +use bevy::prelude::*; +use bevy_scriptum::prelude::*; +use bevy_scriptum::runtimes::javascript::prelude::*; + +fn call_js_on_update_from_rust( + mut scripted_entities: Query<(Entity, &mut JsScriptData)>, + scripting_runtime: ResMut, +) { + for (entity, mut script_data) in &mut scripted_entities { + scripting_runtime + .call_fn("on_update", &mut script_data, entity, (123, String::from("hello"))) + .unwrap(); + } +} +``` + +They will be passed to the `on_update` JavaScript function +```js +function on_update(a, b) { + print(a); // 123 + print(b); // hello +} +``` diff --git a/book/src/javascript/hello_world.md b/book/src/javascript/hello_world.md new file mode 100644 index 0000000..0897c5c --- /dev/null +++ b/book/src/javascript/hello_world.md @@ -0,0 +1,75 @@ +# Hello World + +After you are done installing the required crates, you can start developing +your first game or application using bevy_scriptum. + +To start using the library you need to first import some structs and traits +with Rust `use` statements. + +For convenience there is a main "prelude" module provided called +`bevy_scriptum::prelude` and a prelude for each runtime you have enabled as +a crate feature. + +You can now start exposing functions to the scripting language. For example, you +can expose a function that prints a message to the console: + +```rust,no_run +# extern crate bevy; +# extern crate bevy_scriptum; + +use bevy::prelude::*; +use bevy_scriptum::prelude::*; +use bevy_scriptum::runtimes::javascript::prelude::*; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_scripting::(|runtime| { + runtime.add_function( + String::from("my_print"), + |In((x,)): In<(String,)>| { + println!("my_print: '{}'", x); + }, + ); + }) + .run(); +} +``` + +Then you can create a script file in `assets` directory called `script.js` that +calls this function: + +```js +my_print("Hello world!"); +``` + +And spawn an entity with attached `Script` component with a handle to a script +source file: + +```rust,no_run +# extern crate bevy; +# extern crate bevy_scriptum; + +use bevy::prelude::*; +use bevy_scriptum::prelude::*; +use bevy_scriptum::runtimes::javascript::prelude::*; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_scripting::(|runtime| { + runtime.add_function( + String::from("my_print"), + |In((x,)): In<(String,)>| { + println!("my_print: '{}'", x); + }, + ); + }) + .add_systems(Startup,|mut commands: Commands, asset_server: Res| { + commands.spawn(Script::::new(asset_server.load("script.js"))); + }) + .run(); +} +``` + +You should then see `my_print: 'Hello world!'` printed in your console. diff --git a/book/src/javascript/installation.md b/book/src/javascript/installation.md new file mode 100644 index 0000000..86df270 --- /dev/null +++ b/book/src/javascript/installation.md @@ -0,0 +1,18 @@ +# Installation + +JavaScript support links against V8 which is downloaded as a prebuilt static +library by the `v8` crate during the build, so the first build may take a while +and requires network access. + +## Main Library + +Add the following to your `Cargo.toml`: + +```toml +[dependencies] +bevy = "0.18" +bevy_scriptum = { version = "0.11", features = ["javascript"] } +``` + +If you need a different version of bevy you need to use a matching bevy_scriptum +version according to the [bevy support matrix](../bevy_support_matrix.md) diff --git a/book/src/javascript/interacting_with_bevy.md b/book/src/javascript/interacting_with_bevy.md new file mode 100644 index 0000000..8ce49b9 --- /dev/null +++ b/book/src/javascript/interacting_with_bevy.md @@ -0,0 +1,83 @@ +# Interacting with bevy in callbacks + +Every registered function is also just a regular Bevy system. + +That allows you to do anything you would do in a Bevy system. + +You could for example create a callback system function that prints names +of all entities with a `Player` component. + +```rust,no_run +# extern crate bevy; +# extern crate bevy_ecs; +# extern crate bevy_scriptum; + +use bevy::prelude::*; +use bevy_scriptum::prelude::*; +use bevy_scriptum::runtimes::javascript::prelude::*; + +#[derive(Component)] +struct Player; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_scripting::(|runtime| { + runtime.add_function( + String::from("print_player_names"), + |players: Query<&Name, With>| { + for player in &players { + println!("player name: {}", player); + } + }, + ); + }) + .run(); +} +``` + +In script: + +```js +print_player_names(); +``` + +You can use functions that interact with Bevy entities and resources and +take arguments at the same time. It could be used for example to mutate a +component. + +```rust,no_run +# extern crate bevy; +# extern crate bevy_ecs; +# extern crate bevy_scriptum; + +use bevy::prelude::*; +use bevy_scriptum::prelude::*; +use bevy_scriptum::runtimes::javascript::prelude::*; + +#[derive(Component)] +struct Player { + health: i32 +} + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_scripting::(|runtime| { + runtime.add_function( + String::from("hurt_player"), + |In((hit_value,)): In<(i32,)>, mut players: Query<&mut Player>| { + let mut player = players.single_mut().unwrap(); + player.health -= hit_value; + }, + ); + }) + .run(); +} +``` + +And it could be called in script like: + +```js +hurt_player(5); +``` diff --git a/book/src/javascript/javascript.md b/book/src/javascript/javascript.md new file mode 100644 index 0000000..86e1601 --- /dev/null +++ b/book/src/javascript/javascript.md @@ -0,0 +1,11 @@ +# JavaScript + +This chapter demonstrates how to work with bevy_scriptum when using the +JavaScript language runtime. + +JavaScript support is powered by the [V8](https://v8.dev/) engine through +[`deno_core`](https://crates.io/crates/deno_core). Scripts run synchronously - +the JavaScript event loop is not pumped, so APIs such as `setTimeout` or native +`Promise`s are not available. Return values from registered Rust functions are +delivered through bevy_scriptum's own promise object (see +[Calling Rust from JavaScript](./calling_rust_from_script.md)). diff --git a/book/src/javascript/spawning_scripts.md b/book/src/javascript/spawning_scripts.md new file mode 100644 index 0000000..93d10a7 --- /dev/null +++ b/book/src/javascript/spawning_scripts.md @@ -0,0 +1,42 @@ +# Spawning scripts + +To spawn a JavaScript script you will need to get a handle to a script asset +using bevy's `AssetServer`. + +```rust +# extern crate bevy; +# extern crate bevy_scriptum; + +use bevy::prelude::*; +use bevy_scriptum::prelude::*; +use bevy_scriptum::runtimes::javascript::prelude::*; + +fn my_spawner(mut commands: Commands, assets_server: Res) { + commands.spawn(Script::::new( + assets_server.load("my_script.js"), + )); +} +``` + +After the scripts have been evaled by bevy_scriptum, the entities that they've +been attached to will get the `Script::` component stripped and instead +a `JsScriptData` component will be attached. + +So to query scripted entities you could do something like: + +```rust +# extern crate bevy; +# extern crate bevy_scriptum; + +use bevy::prelude::*; +use bevy_scriptum::prelude::*; +use bevy_scriptum::runtimes::javascript::prelude::*; + +fn my_system( + mut scripted_entities: Query<(Entity, &mut JsScriptData)>, +) { + for (entity, mut script_data) in &mut scripted_entities { + // do something with scripted entities + } +} +``` diff --git a/examples/js/call_function_from_rust.rs b/examples/js/call_function_from_rust.rs new file mode 100644 index 0000000..e76ddad --- /dev/null +++ b/examples/js/call_function_from_rust.rs @@ -0,0 +1,33 @@ +use bevy::{app::AppExit, prelude::*}; +use bevy_scriptum::prelude::*; +use bevy_scriptum::runtimes::javascript::prelude::*; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_systems(Startup, startup) + .add_systems(Update, call_js_on_update_from_rust) + .add_scripting::(|runtime| { + runtime.add_function(String::from("quit"), |mut exit: MessageWriter| { + exit.write(AppExit::Success); + }); + }) + .run(); +} + +fn startup(mut commands: Commands, assets_server: Res) { + commands.spawn(Script::::new( + assets_server.load("examples/js/call_function_from_rust.js"), + )); +} + +fn call_js_on_update_from_rust( + mut scripted_entities: Query<(Entity, &mut JsScriptData)>, + scripting_runtime: ResMut, +) { + for (entity, mut script_data) in &mut scripted_entities { + scripting_runtime + .call_fn("on_update", &mut script_data, entity, ()) + .unwrap(); + } +} diff --git a/examples/js/current_entity.rs b/examples/js/current_entity.rs new file mode 100644 index 0000000..7a4673f --- /dev/null +++ b/examples/js/current_entity.rs @@ -0,0 +1,25 @@ +use bevy::prelude::*; +use bevy_scriptum::prelude::*; +use bevy_scriptum::runtimes::javascript::prelude::*; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_scripting::(|runtime| { + runtime.add_function( + String::from("get_name"), + |In((BevyEntity(entity),)): In<(BevyEntity,)>, names: Query<&Name>| { + names.get(entity).unwrap().to_string() + }, + ); + }) + .add_systems(Startup, startup) + .run(); +} + +fn startup(mut commands: Commands, assets_server: Res) { + commands.spawn(( + Name::from("MyEntityName"), + Script::::new(assets_server.load("examples/js/current_entity.js")), + )); +} diff --git a/examples/js/ecs.rs b/examples/js/ecs.rs new file mode 100644 index 0000000..f2e4011 --- /dev/null +++ b/examples/js/ecs.rs @@ -0,0 +1,33 @@ +use bevy::prelude::*; +use bevy_scriptum::prelude::*; +use bevy_scriptum::runtimes::javascript::prelude::*; + +#[derive(Component)] +struct Player; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_scripting::(|runtime| { + runtime.add_function( + String::from("print_player_names"), + |players: Query<&Name, With>| { + for player in &players { + println!("player name: {}", player); + } + }, + ); + }) + .add_systems(Startup, startup) + .run(); +} + +fn startup(mut commands: Commands, assets_server: Res) { + commands.spawn((Player, Name::new("John"))); + commands.spawn((Player, Name::new("Mary"))); + commands.spawn((Player, Name::new("Alice"))); + + commands.spawn(Script::::new( + assets_server.load("examples/js/ecs.js"), + )); +} diff --git a/examples/js/entity_variable.rs b/examples/js/entity_variable.rs new file mode 100644 index 0000000..f69d396 --- /dev/null +++ b/examples/js/entity_variable.rs @@ -0,0 +1,17 @@ +use bevy::prelude::*; +use bevy_scriptum::runtimes::javascript::prelude::*; +use bevy_scriptum::{BuildScriptingRuntime, prelude::*}; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_scripting::(|_| {}) + .add_systems(Startup, startup) + .run(); +} + +fn startup(mut commands: Commands, assets_server: Res) { + commands.spawn(Script::::new( + assets_server.load("examples/js/entity_variable.js"), + )); +} diff --git a/examples/js/function_params.rs b/examples/js/function_params.rs new file mode 100644 index 0000000..c918cc6 --- /dev/null +++ b/examples/js/function_params.rs @@ -0,0 +1,40 @@ +use bevy::prelude::*; +use bevy_scriptum::prelude::*; +use bevy_scriptum::runtimes::javascript::prelude::*; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_scripting::(|runtime| { + runtime + .add_function(String::from("fun_without_params"), || { + println!("called without params"); + }) + .add_function( + String::from("fun_with_string_param"), + |In((x,)): In<(String,)>| { + println!("called with string: '{}'", x); + }, + ) + .add_function( + String::from("fun_with_i64_param"), + |In((x,)): In<(i64,)>| { + println!("called with i64: {}", x); + }, + ) + .add_function( + String::from("fun_with_multiple_params"), + |In((x, y)): In<(i64, String)>| { + println!("called with i64: {} and string: '{}'", x, y); + }, + ); + }) + .add_systems(Startup, startup) + .run(); +} + +fn startup(mut commands: Commands, assets_server: Res) { + commands.spawn(Script::::new( + assets_server.load("examples/js/function_params.js"), + )); +} diff --git a/examples/js/hello_world.rs b/examples/js/hello_world.rs new file mode 100644 index 0000000..250303b --- /dev/null +++ b/examples/js/hello_world.rs @@ -0,0 +1,21 @@ +use bevy::prelude::*; +use bevy_scriptum::prelude::*; +use bevy_scriptum::runtimes::javascript::prelude::*; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_scripting::(|runtime| { + runtime.add_function(String::from("hello_bevy"), || { + println!("hello bevy, called from script"); + }); + }) + .add_systems(Startup, startup) + .run(); +} + +fn startup(mut commands: Commands, assets_server: Res) { + commands.spawn(Script::::new( + assets_server.load("examples/js/hello_world.js"), + )); +} diff --git a/examples/js/multiple_plugins.rs b/examples/js/multiple_plugins.rs new file mode 100644 index 0000000..aef0c0a --- /dev/null +++ b/examples/js/multiple_plugins.rs @@ -0,0 +1,67 @@ +use bevy::prelude::*; +use bevy_scriptum::prelude::*; +use bevy_scriptum::runtimes::javascript::prelude::*; + +// Plugin A +struct PluginA; +impl Plugin for PluginA { + fn build(&self, app: &mut App) { + app.add_scripting_api::(|runtime| { + runtime.add_function(String::from("hello_from_plugin_a"), || { + info!("Hello from Plugin A"); + }); + }) + .add_systems(Startup, plugin_a_startup); + } +} + +fn plugin_a_startup(mut commands: Commands, assets_server: Res) { + commands.spawn(Script::::new( + assets_server.load("examples/js/multiple_plugins_plugin_a.js"), + )); +} + +// Plugin B +struct PluginB; +impl Plugin for PluginB { + fn build(&self, app: &mut App) { + app.add_scripting_api::(|runtime| { + runtime.add_function( + String::from("hello_from_plugin_b_with_parameters"), + hello_from_b, + ); + }) + .add_systems(Startup, plugin_b_startup); + } +} + +fn plugin_b_startup(mut commands: Commands, assets_server: Res) { + commands.spawn(Script::::new( + assets_server.load("examples/js/multiple_plugins_plugin_b.js"), + )); +} + +fn hello_from_b(In((text, x)): In<(String, i32)>) { + info!("{} from Plugin B: {}", text, x); +} + +// Main +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_scripting::(|runtime| { + runtime.add_function(String::from("hello_bevy"), || { + info!("hello bevy, called from script"); + }); + }) + .add_systems(Startup, main_startup) + .add_plugins(PluginA) + .add_plugins(PluginB) + .run(); +} + +fn main_startup(mut commands: Commands, assets_server: Res) { + commands.spawn(Script::::new( + assets_server.load("examples/js/hello_world.js"), + )); +} diff --git a/examples/js/non_closure_system.rs b/examples/js/non_closure_system.rs new file mode 100644 index 0000000..7b08cdf --- /dev/null +++ b/examples/js/non_closure_system.rs @@ -0,0 +1,23 @@ +use bevy::prelude::*; +use bevy_scriptum::prelude::*; +use bevy_scriptum::runtimes::javascript::prelude::*; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_scripting::(|runtime| { + runtime.add_function(String::from("hello_bevy"), hello_bevy_callback_system); + }) + .add_systems(Startup, startup) + .run(); +} + +fn startup(mut commands: Commands, assets_server: Res) { + commands.spawn(Script::::new( + assets_server.load("examples/js/hello_world.js"), + )); +} + +fn hello_bevy_callback_system() { + println!("hello bevy, called from script"); +} diff --git a/examples/js/promises.rs b/examples/js/promises.rs new file mode 100644 index 0000000..31aae12 --- /dev/null +++ b/examples/js/promises.rs @@ -0,0 +1,31 @@ +use bevy::prelude::*; +use bevy_scriptum::prelude::*; +use bevy_scriptum::runtimes::javascript::prelude::*; + +#[derive(Component)] +struct Player; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_scripting::(|builder| { + builder.add_function( + String::from("get_player_name"), + |player_names: Query<&Name, With>| { + player_names + .single() + .expect("Missing player_names") + .to_string() + }, + ); + }) + .add_systems(Startup, startup) + .run(); +} + +fn startup(mut commands: Commands, assets_server: Res) { + commands.spawn((Player, Name::new("John"))); + commands.spawn(Script::::new( + assets_server.load("examples/js/promises.js"), + )); +} diff --git a/examples/js/side_effects.rs b/examples/js/side_effects.rs new file mode 100644 index 0000000..912556f --- /dev/null +++ b/examples/js/side_effects.rs @@ -0,0 +1,43 @@ +use bevy::{app::AppExit, prelude::*}; +use bevy_scriptum::prelude::*; +use bevy_scriptum::runtimes::javascript::prelude::*; + +fn main() { + App::new() + // This is just needed for headless console app, not needed for a regular bevy application + // that uses a winit window + .set_runner(move |mut app: App| { + loop { + app.update(); + if let Some(exit) = app.should_exit() { + return exit; + } + } + }) + .add_plugins(DefaultPlugins) + .add_systems(Startup, startup) + .add_systems(Update, print_entity_names_and_quit) + .add_scripting::(|runtime| { + runtime.add_function(String::from("spawn_entity"), spawn_entity); + }) + .run(); +} + +fn spawn_entity(mut commands: Commands) { + commands.spawn(Name::new("SpawnedEntity")); +} + +fn startup(mut commands: Commands, assets_server: Res) { + commands.spawn((Script::::new( + assets_server.load("examples/js/side_effects.js"), + ),)); +} + +fn print_entity_names_and_quit(query: Query<&Name>, mut exit: MessageWriter) { + if !query.is_empty() { + for e in &query { + println!("{}", e); + } + exit.write(AppExit::Success); + } +} diff --git a/examples/lua/entity_variable.rs b/examples/lua/entity_variable.rs index 0be6b24..290e76e 100644 --- a/examples/lua/entity_variable.rs +++ b/examples/lua/entity_variable.rs @@ -1,6 +1,6 @@ use bevy::prelude::*; use bevy_scriptum::runtimes::lua::prelude::*; -use bevy_scriptum::{prelude::*, BuildScriptingRuntime}; +use bevy_scriptum::{BuildScriptingRuntime, prelude::*}; fn main() { App::new() diff --git a/examples/lua/promises.rs b/examples/lua/promises.rs index 27c4200..8b42bb2 100644 --- a/examples/lua/promises.rs +++ b/examples/lua/promises.rs @@ -11,7 +11,12 @@ fn main() { .add_scripting::(|builder| { builder.add_function( String::from("get_player_name"), - |player_names: Query<&Name, With>| player_names.single().expect("Missing player_names").to_string(), + |player_names: Query<&Name, With>| { + player_names + .single() + .expect("Missing player_names") + .to_string() + }, ); }) .add_systems(Startup, startup) diff --git a/examples/rhai/entity_variable.rs b/examples/rhai/entity_variable.rs index d7e70bc..c7aa689 100644 --- a/examples/rhai/entity_variable.rs +++ b/examples/rhai/entity_variable.rs @@ -1,6 +1,6 @@ use bevy::prelude::*; use bevy_scriptum::runtimes::rhai::prelude::*; -use bevy_scriptum::{prelude::*, BuildScriptingRuntime}; +use bevy_scriptum::{BuildScriptingRuntime, prelude::*}; fn main() { App::new() diff --git a/examples/rhai/promises.rs b/examples/rhai/promises.rs index 5a56ac1..7737d70 100644 --- a/examples/rhai/promises.rs +++ b/examples/rhai/promises.rs @@ -11,7 +11,12 @@ fn main() { .add_scripting::(|builder| { builder.add_function( String::from("get_player_name"), - |player_names: Query<&Name, With>| player_names.single().expect("Missing player_names").to_string(), + |player_names: Query<&Name, With>| { + player_names + .single() + .expect("Missing player_names") + .to_string() + }, ); }) .add_systems(Startup, startup) diff --git a/examples/ruby/entity_variable.rs b/examples/ruby/entity_variable.rs index e9e25ee..296669c 100644 --- a/examples/ruby/entity_variable.rs +++ b/examples/ruby/entity_variable.rs @@ -1,6 +1,6 @@ use bevy::prelude::*; use bevy_scriptum::runtimes::ruby::prelude::*; -use bevy_scriptum::{prelude::*, BuildScriptingRuntime}; +use bevy_scriptum::{BuildScriptingRuntime, prelude::*}; fn main() { App::new() diff --git a/src/assets.rs b/src/assets.rs index 83ea5fc..374982e 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -1,9 +1,9 @@ use std::marker::PhantomData; use bevy::{ - asset::{io::Reader, Asset, AssetLoader, LoadContext}, - tasks::ConditionalSendFuture, + asset::{Asset, AssetLoader, LoadContext, io::Reader}, reflect::TypePath, + tasks::ConditionalSendFuture, }; /// A loader for script assets. diff --git a/src/lib.rs b/src/lib.rs index 560456b..85a61a5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,7 @@ //! | 🌙 LuaJIT | `lua` | [link](https://jarkonik.github.io/bevy_scriptum/lua/lua.html) | //! | 🌾 Rhai | `rhai` | [link](https://jarkonik.github.io/bevy_scriptum/rhai/rhai.html) | //! | 💎 Ruby(currently only supported on Linux and MacOS) | `ruby` | [link](https://jarkonik.github.io/bevy_scriptum/ruby/ruby.html) | +//! | 🟨 JavaScript (V8 via deno_core) | `javascript` | [link](https://jarkonik.github.io/bevy_scriptum/javascript/javascript.html) | //! //! Documentation book is available [here](https://jarkonik.github.io/bevy_scriptum/) 📖 //! @@ -266,7 +267,7 @@ use self::{ systems::{process_new_scripts, reload_scripts}, }; -#[cfg(any(feature = "rhai", feature = "lua"))] +#[cfg(any(feature = "rhai", feature = "lua", feature = "javascript"))] const ENTITY_VAR_NAME: &str = "entity"; /// An error that can occur when internal [ScriptingPlugin] systems are being executed diff --git a/src/promise.rs b/src/promise.rs index 038cc0c..c5d4dcc 100644 --- a/src/promise.rs +++ b/src/promise.rs @@ -58,7 +58,12 @@ impl Promise { } /// Register a callback that will be called when the [Promise] is resolved. - #[cfg(any(feature = "rhai", feature = "lua", feature = "ruby"))] + #[cfg(any( + feature = "rhai", + feature = "lua", + feature = "ruby", + feature = "javascript" + ))] pub(crate) fn then(&mut self, callback: V) -> Self { let mut inner = self .inner diff --git a/src/runtimes/javascript.rs b/src/runtimes/javascript.rs new file mode 100644 index 0000000..37c6148 --- /dev/null +++ b/src/runtimes/javascript.rs @@ -0,0 +1,776 @@ +use std::{ + cell::{Cell, RefCell}, + collections::HashMap, + rc::Rc, + sync::{Arc, Mutex}, + thread::{self, JoinHandle}, +}; + +use bevy::{ + asset::Asset, + ecs::{component::Component, entity::Entity, resource::Resource, schedule::ScheduleLabel}, + math::Vec3, + reflect::TypePath, +}; +use deno_core::{JsRuntime as DenoRuntime, RuntimeOptions}; + +pub use deno_core::v8; +use serde::Deserialize; + +use crate::{ + ENTITY_VAR_NAME, FuncArgs, Runtime, ScriptingError, + assets::GetExtensions, + callback::{FromRuntimeValueWithEngine, IntoRuntimeValueWithEngine}, + promise::Promise, +}; + +/// Boxed registered callback function as stored on the JavaScript thread. +type CallbackFn = + dyn Fn((), Vec) -> Result, ScriptingError> + 'static; + +/// A queue of value ids that have been dropped on another thread and need to be +/// removed from the value registry the next time the JavaScript thread runs. +type FreeQueue = Arc>>; + +thread_local! { + /// Registry of live `v8::Global` values, keyed by the id carried in a [JsValue]. + static REGISTRY: RefCell>> = RefCell::new(HashMap::new()); + /// Registry of pending promises, keyed by the id stored in the script-side + /// promise object. + static PROMISES: RefCell>> = RefCell::new(HashMap::new()); + /// Registered Rust callback functions, keyed by their script-visible name. + static CALLBACKS: RefCell>> = RefCell::new(HashMap::new()); + static NEXT_VALUE_ID: Cell = const { Cell::new(0) }; + static NEXT_PROMISE_ID: Cell = const { Cell::new(0) }; + static FREE_QUEUE: FreeQueue = Arc::new(Mutex::new(Vec::new())); +} + +/// A handle to a value living inside the JavaScript engine. +/// +/// The actual `v8::Global` is kept in a thread-local registry on the JavaScript +/// thread - this handle only carries an id (plus a shared free-list so it can be +/// cleaned up when dropped from any thread), which makes it cheap to `Clone` and +/// safe to `Send`. +#[derive(Clone)] +pub struct JsValue(Arc); + +struct JsValueInner { + id: u64, + free: FreeQueue, +} + +impl Drop for JsValueInner { + fn drop(&mut self) { + if let Ok(mut queue) = self.free.lock() { + queue.push(self.id); + } + } +} + +/// Removes any values that were dropped on other threads from the registry. +/// Must be called on the JavaScript thread while the isolate is entered. +fn drain_free_queue() { + let ids: Vec = FREE_QUEUE.with(|queue| { + queue + .lock() + .map(|mut queue| queue.drain(..).collect()) + .unwrap_or_default() + }); + if !ids.is_empty() { + REGISTRY.with(|registry| { + let mut registry = registry.borrow_mut(); + for id in ids { + registry.remove(&id); + } + }); + } +} + +/// Stores a `v8::Local` value in the registry and returns a [JsValue] handle for it. +fn store_value(scope: &mut v8::PinScope, local: v8::Local) -> JsValue { + let global = v8::Global::new(scope, local); + let id = NEXT_VALUE_ID.with(|next| { + let id = next.get(); + next.set(id + 1); + id + }); + REGISTRY.with(|registry| registry.borrow_mut().insert(id, global)); + let free = FREE_QUEUE.with(|queue| queue.clone()); + JsValue(Arc::new(JsValueInner { id, free })) +} + +/// Loads the `v8::Local` value referenced by a [JsValue] handle. +fn load_value<'s>(scope: &mut v8::PinScope<'s, '_>, value: &JsValue) -> v8::Local<'s, v8::Value> { + let global = REGISTRY + .with(|registry| registry.borrow().get(&value.0.id).cloned()) + .expect("JsValue referenced a value that is no longer in the registry"); + v8::Local::new(scope, global) +} + +fn v8_string<'s>(scope: &mut v8::PinScope<'s, '_>, value: &str) -> v8::Local<'s, v8::Value> { + v8::String::new(scope, value) + .expect("Failed to allocate JavaScript string") + .into() +} + +fn global_object<'s>(scope: &mut v8::PinScope<'s, '_>) -> v8::Local<'s, v8::Object> { + scope.get_current_context().global(scope) +} + +fn set_entity(scope: &mut v8::PinScope, entity: Entity) { + let global = global_object(scope); + let key = v8_string(scope, ENTITY_VAR_NAME); + let value = BevyEntity(entity).to_v8(scope); + global.set(scope, key, value); +} + +fn clear_entity(scope: &mut v8::PinScope) { + let global = global_object(scope); + let key = v8_string(scope, ENTITY_VAR_NAME); + let undefined: v8::Local = v8::undefined(scope).into(); + global.set(scope, key, undefined); +} + +/// Wraps a bevy_scriptum [Promise] into a script-side object exposing `and_then`. +fn promise_to_v8<'s>( + scope: &mut v8::PinScope<'s, '_>, + promise: Promise<(), JsValue>, +) -> v8::Local<'s, v8::Value> { + let id = NEXT_PROMISE_ID.with(|next| { + let id = next.get(); + next.set(id + 1); + id + }); + PROMISES.with(|promises| promises.borrow_mut().insert(id, promise)); + + let object = v8::Object::new(scope); + let key = v8_string(scope, "and_then"); + let data: v8::Local = v8::Number::new(scope, id as f64).into(); + let function = v8::Function::builder(and_then_callback) + .data(data) + .build(scope) + .expect("Failed to create and_then function"); + object.set(scope, key, function.into()); + object.into() +} + +/// `and_then` method on script-side promise objects. +fn and_then_callback( + scope: &mut v8::PinScope, + args: v8::FunctionCallbackArguments, + mut rv: v8::ReturnValue, +) { + let id = args + .data() + .number_value(scope) + .expect("Promise object is missing its id") as u64; + let callback = store_value(scope, args.get(0)); + + let following = PROMISES.with(|promises| { + let mut promises = promises.borrow_mut(); + let promise = promises + .get_mut(&id) + .expect("Promise referenced by script no longer exists"); + promise.then(callback) + }); + + let object = promise_to_v8(scope, following); + rv.set(object); +} + +/// Callback invoked by V8 for every registered Rust function. +fn native_callback( + scope: &mut v8::PinScope, + args: v8::FunctionCallbackArguments, + mut rv: v8::ReturnValue, +) { + let name = args.data().to_rust_string_lossy(scope); + + let mut params = Vec::with_capacity(args.length() as usize); + for i in 0..args.length() { + params.push(store_value(scope, args.get(i))); + } + + let callback = CALLBACKS.with(|callbacks| callbacks.borrow().get(&name).cloned()); + if let Some(callback) = callback { + let promise = callback((), params).expect("Failed to call registered function"); + let object = promise_to_v8(scope, promise); + rv.set(object); + } +} + +fn print_callback( + scope: &mut v8::PinScope, + args: v8::FunctionCallbackArguments, + _rv: v8::ReturnValue, +) { + let mut parts = Vec::with_capacity(args.length() as usize); + for i in 0..args.length() { + parts.push(args.get(i).to_rust_string_lossy(scope)); + } + println!("{}", parts.join(" ")); +} + +/// `Vec3(x, y, z)` global constructor available to all scripts. +fn vec3_callback( + scope: &mut v8::PinScope, + args: v8::FunctionCallbackArguments, + mut rv: v8::ReturnValue, +) { + let component = |scope: &mut v8::PinScope, index: i32| { + args.get(index).number_value(scope).unwrap_or(0.0) as f32 + }; + let x = component(scope, 0); + let y = component(scope, 1); + let z = component(scope, 2); + let value = BevyVec3::new(x, y, z).to_v8(scope); + rv.set(value); +} + +/// Registers a global function on the engine's global object. +fn register_global_function( + scope: &mut v8::PinScope, + name: &str, + callback: impl v8::MapFnTo, +) { + let global = global_object(scope); + let key = v8_string(scope, name); + let function = v8::Function::new(scope, callback) + .unwrap_or_else(|| panic!("Failed to create `{name}` function")); + global.set(scope, key, function.into()); +} + +/// Calls a global script function by name with the provided arguments. +fn call_global_fn( + scope: &mut v8::PinScope, + name: &str, + args: &[JsValue], +) -> Result { + let global = global_object(scope); + let key = v8_string(scope, name); + let value = global + .get(scope, key) + .ok_or_else(|| ScriptingError::RuntimeError(format!("function `{name}` not found")))?; + let function = value + .try_cast::() + .map_err(|_| ScriptingError::RuntimeError(format!("`{name}` is not a function")))?; + let arguments: Vec> = + args.iter().map(|arg| load_value(scope, arg)).collect(); + call_function(scope, function, &arguments) +} + +/// Calls a `v8::Function` with the provided arguments, capturing runtime errors. +fn call_function( + scope: &mut v8::PinScope, + function: v8::Local, + arguments: &[v8::Local], +) -> Result { + let recv: v8::Local = v8::undefined(scope).into(); + v8::tc_scope!(let scope, scope); + match function.call(scope, recv, arguments) { + Some(result) => Ok(store_value(scope, result)), + None => { + let message = scope + .exception() + .map(|exception| exception.to_rust_string_lossy(scope)) + .unwrap_or_else(|| String::from("unknown JavaScript error")); + Err(ScriptingError::RuntimeError(message)) + } + } +} + +/// The JavaScript engine - a thin wrapper around a deno_core [JsRuntime] that +/// allows obtaining a V8 scope through a shared reference (the rest of +/// bevy_scriptum only hands out `&RawEngine`). +pub struct JsEngine { + runtime: RefCell, +} + +impl JsEngine { + fn new() -> Self { + let engine = Self { + runtime: RefCell::new(DenoRuntime::new(RuntimeOptions::default())), + }; + engine.with_scope(|scope| { + register_global_function(scope, "print", print_callback); + register_global_function(scope, "Vec3", vec3_callback); + }); + engine + } + + /// Runs `f` with a V8 handle/context scope ready for use. + pub fn with_scope(&self, f: impl FnOnce(&mut v8::PinScope) -> R) -> R { + let mut runtime = self.runtime.borrow_mut(); + let context = runtime.main_context(); + let isolate = &mut *runtime.v8_isolate(); + v8::scope!(let scope, isolate); + let context = v8::Local::new(scope, context); + let scope = &mut v8::ContextScope::new(scope, context); + drain_free_queue(); + f(scope) + } +} + +#[derive(Resource)] +pub struct JsRuntime { + thread: Option, +} + +impl Default for JsRuntime { + fn default() -> Self { + Self { + thread: Some(JsThread::spawn()), + } + } +} + +impl JsRuntime { + fn execute_in_thread( + &self, + f: impl FnOnce(&mut JsEngine) -> T + Send + 'static, + ) -> T { + self.thread + .as_ref() + .expect("JavaScript thread is gone") + .execute(Box::new(f)) + } + + /// Runs `f` with a V8 scope on the JavaScript thread. Useful for directly + /// interacting with the engine, e.g. from tests. + pub fn with_scope( + &self, + f: impl FnOnce(&mut v8::PinScope) -> T + Send + 'static, + ) -> T { + self.execute_in_thread(move |engine| engine.with_scope(f)) + } +} + +type JsClosure = Box; + +/// A dedicated thread owning the (non-`Send`) V8 isolate. +struct JsThread { + sender: Option>, + handle: Option>, +} + +impl JsThread { + fn spawn() -> Self { + let (sender, receiver) = crossbeam_channel::unbounded::(); + + let handle = thread::spawn(move || { + let mut engine = JsEngine::new(); + while let Ok(f) = receiver.recv() { + f(&mut engine); + } + }); + + JsThread { + sender: Some(sender), + handle: Some(handle), + } + } + + fn execute(&self, f: Box T + Send>) -> T { + let (return_sender, return_receiver) = crossbeam_channel::bounded(1); + self.sender + .as_ref() + .expect("JavaScript thread sender is gone") + .send(Box::new(move |engine| { + let _ = return_sender.send(f(engine)); + })) + .expect("Failed to send execution unit to JavaScript thread"); + return_receiver + .recv() + .expect("Failed to receive callback return value from JavaScript thread") + } +} + +impl Drop for JsThread { + fn drop(&mut self) { + drop(self.sender.take()); + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + } +} + +#[derive(ScheduleLabel, Clone, PartialEq, Eq, Debug, Hash, Default)] +pub struct JsSchedule; + +#[derive(Asset, Debug, Deserialize, TypePath)] +pub struct JsScript(pub String); + +impl GetExtensions for JsScript { + fn extensions() -> &'static [&'static str] { + &["js"] + } +} + +impl From for JsScript { + fn from(value: String) -> Self { + Self(value) + } +} + +#[derive(Component)] +pub struct JsScriptData; + +#[derive(Debug, Clone, Copy)] +pub struct BevyEntity(pub Entity); + +impl BevyEntity { + pub fn index(&self) -> u32 { + self.0.index_u32() + } +} + +#[derive(Debug, Clone, Copy)] +pub struct BevyVec3(pub Vec3); + +impl BevyVec3 { + pub fn new(x: f32, y: f32, z: f32) -> Self { + Self(Vec3::new(x, y, z)) + } + + pub fn x(&self) -> f32 { + self.0.x + } + + pub fn y(&self) -> f32 { + self.0.y + } + + pub fn z(&self) -> f32 { + self.0.z + } +} + +impl Runtime for JsRuntime { + type Schedule = JsSchedule; + type ScriptAsset = JsScript; + type ScriptData = JsScriptData; + type CallContext = (); + type Value = JsValue; + type RawEngine = JsEngine; + + fn with_engine_send_mut( + &mut self, + f: impl FnOnce(&mut Self::RawEngine) -> T + Send + 'static, + ) -> T { + self.execute_in_thread(f) + } + + fn with_engine_send( + &self, + f: impl FnOnce(&Self::RawEngine) -> T + Send + 'static, + ) -> T { + self.execute_in_thread(move |engine| f(engine)) + } + + fn with_engine_mut(&mut self, _f: impl FnOnce(&mut Self::RawEngine) -> T) -> T { + unimplemented!( + "JavaScript runtime requires sending execution to another thread, use `with_engine_send_mut`" + ); + } + + fn with_engine(&self, _f: impl FnOnce(&Self::RawEngine) -> T) -> T { + unimplemented!( + "JavaScript runtime requires sending execution to another thread, use `with_engine_send`" + ); + } + + fn eval( + &self, + script: &Self::ScriptAsset, + entity: Entity, + ) -> Result { + let source = script.0.clone(); + self.execute_in_thread(move |engine| { + engine.with_scope(|scope| set_entity(scope, entity)); + let result = engine + .runtime + .borrow_mut() + .execute_script("[bevy_scriptum]", source); + engine.with_scope(clear_entity); + result + .map(|_| JsScriptData) + .map_err(|e| ScriptingError::RuntimeError(e.to_string())) + }) + } + + fn register_fn( + &mut self, + name: String, + _arg_types: Vec, + f: impl Fn( + Self::CallContext, + Vec, + ) -> Result, ScriptingError> + + Send + + Sync + + 'static, + ) -> Result<(), ScriptingError> { + self.execute_in_thread(move |engine| { + CALLBACKS.with(|callbacks| { + callbacks.borrow_mut().insert(name.clone(), Rc::new(f)); + }); + engine.with_scope(|scope| { + let global = global_object(scope); + let key = v8_string(scope, &name); + let data = v8_string(scope, &name); + let function = v8::Function::builder(native_callback) + .data(data) + .build(scope) + .expect("Failed to create registered function"); + global.set(scope, key, function.into()); + }); + }); + Ok(()) + } + + fn call_fn( + &self, + name: &str, + _script_data: &mut Self::ScriptData, + entity: Entity, + args: impl for<'a> FuncArgs<'a, Self::Value, Self> + Send + 'static, + ) -> Result { + let name = name.to_string(); + self.execute_in_thread(move |engine| { + let args = args.parse(engine); + engine.with_scope(|scope| { + set_entity(scope, entity); + let result = call_global_fn(scope, &name, &args); + clear_entity(scope); + result + }) + }) + } + + fn call_fn_from_value( + &self, + value: &Self::Value, + _context: &Self::CallContext, + args: Vec, + ) -> Result { + let value = value.clone(); + self.execute_in_thread(move |engine| { + engine.with_scope(|scope| { + let local = load_value(scope, &value); + let function = local.try_cast::().map_err(|_| { + ScriptingError::RuntimeError(String::from("value is not a function")) + })?; + let arguments: Vec> = + args.iter().map(|arg| load_value(scope, arg)).collect(); + call_function(scope, function, &arguments) + }) + }) + } +} + +/// Conversion from a Rust value into a V8 value. +trait ToV8 { + fn to_v8<'s>(self, scope: &mut v8::PinScope<'s, '_>) -> v8::Local<'s, v8::Value>; +} + +/// Conversion from a V8 value into a Rust value. +trait FromV8 { + fn from_v8(scope: &mut v8::PinScope, value: v8::Local) -> Self; +} + +macro_rules! impl_number { + ($($t:ty),*) => {$( + impl ToV8 for $t { + fn to_v8<'s>(self, scope: &mut v8::PinScope<'s, '_>) -> v8::Local<'s, v8::Value> { + v8::Number::new(scope, self as f64).into() + } + } + + impl FromV8 for $t { + fn from_v8(scope: &mut v8::PinScope, value: v8::Local) -> Self { + value.number_value(scope).unwrap_or(0.0) as $t + } + } + )*}; +} + +impl_number!(i8, i16, i32, i64, isize, u8, u16, u32, u64, usize, f32, f64); + +impl ToV8 for String { + fn to_v8<'s>(self, scope: &mut v8::PinScope<'s, '_>) -> v8::Local<'s, v8::Value> { + v8_string(scope, &self) + } +} + +impl FromV8 for String { + fn from_v8(scope: &mut v8::PinScope, value: v8::Local) -> Self { + value.to_rust_string_lossy(scope) + } +} + +impl ToV8 for bool { + fn to_v8<'s>(self, scope: &mut v8::PinScope<'s, '_>) -> v8::Local<'s, v8::Value> { + v8::Boolean::new(scope, self).into() + } +} + +impl FromV8 for bool { + fn from_v8(scope: &mut v8::PinScope, value: v8::Local) -> Self { + value.boolean_value(scope) + } +} + +impl ToV8 for () { + fn to_v8<'s>(self, scope: &mut v8::PinScope<'s, '_>) -> v8::Local<'s, v8::Value> { + v8::undefined(scope).into() + } +} + +impl FromV8 for () { + fn from_v8(_scope: &mut v8::PinScope, _value: v8::Local) -> Self {} +} + +impl ToV8 for BevyEntity { + fn to_v8<'s>(self, scope: &mut v8::PinScope<'s, '_>) -> v8::Local<'s, v8::Value> { + let object = v8::Object::new(scope); + + let key = v8_string(scope, "index"); + let value: v8::Local = + v8::Integer::new_from_unsigned(scope, self.index()).into(); + object.set(scope, key, value); + + let key = v8_string(scope, "__entity_bits"); + let value: v8::Local = v8::BigInt::new_from_u64(scope, self.0.to_bits()).into(); + object.set(scope, key, value); + + object.into() + } +} + +impl FromV8 for BevyEntity { + fn from_v8(scope: &mut v8::PinScope, value: v8::Local) -> Self { + let object = value.to_object(scope).expect("entity is not an object"); + let key = v8_string(scope, "__entity_bits"); + let bits = object + .get(scope, key) + .expect("entity object is missing `__entity_bits`") + .try_cast::() + .expect("`__entity_bits` is not a BigInt"); + let (bits, _) = bits.u64_value(); + BevyEntity(Entity::from_bits(bits)) + } +} + +impl ToV8 for BevyVec3 { + fn to_v8<'s>(self, scope: &mut v8::PinScope<'s, '_>) -> v8::Local<'s, v8::Value> { + let object = v8::Object::new(scope); + for (name, component) in [("x", self.0.x), ("y", self.0.y), ("z", self.0.z)] { + let key = v8_string(scope, name); + let value: v8::Local = v8::Number::new(scope, component as f64).into(); + object.set(scope, key, value); + } + object.into() + } +} + +impl FromV8 for BevyVec3 { + fn from_v8(scope: &mut v8::PinScope, value: v8::Local) -> Self { + let object = value.to_object(scope).expect("Vec3 is not an object"); + let read = |scope: &mut v8::PinScope, name: &str| { + let key = v8_string(scope, name); + object + .get(scope, key) + .and_then(|value| value.number_value(scope)) + .unwrap_or(0.0) as f32 + }; + let x = read(scope, "x"); + let y = read(scope, "y"); + let z = read(scope, "z"); + BevyVec3(Vec3::new(x, y, z)) + } +} + +impl<'a, T: ToV8> IntoRuntimeValueWithEngine<'a, T, JsRuntime> for T { + fn into_runtime_value_with_engine(value: T, engine: &'a JsEngine) -> JsValue { + engine.with_scope(|scope| { + let local = value.to_v8(scope); + store_value(scope, local) + }) + } +} + +impl<'a, T: FromV8> FromRuntimeValueWithEngine<'a, JsRuntime> for T { + fn from_runtime_value_with_engine(value: JsValue, engine: &'a JsEngine) -> Self { + engine.with_scope(|scope| { + let local = load_value(scope, &value); + T::from_v8(scope, local) + }) + } +} + +impl FuncArgs<'_, JsValue, JsRuntime> for () { + fn parse(self, _engine: &JsEngine) -> Vec { + Vec::new() + } +} + +impl FuncArgs<'_, JsValue, JsRuntime> for Vec { + fn parse(self, engine: &JsEngine) -> Vec { + engine.with_scope(|scope| { + self.into_iter() + .map(|value| { + let local = value.to_v8(scope); + store_value(scope, local) + }) + .collect() + }) + } +} + +pub mod prelude { + pub use super::{BevyEntity, BevyVec3, JsRuntime, JsScript, JsScriptData}; +} + +macro_rules! impl_tuple { + ($($idx:tt $t:tt),+) => { + impl<$($t: ToV8,)+> FuncArgs<'_, JsValue, JsRuntime> for ($($t,)+) { + fn parse(self, engine: &JsEngine) -> Vec { + engine.with_scope(|scope| { + vec![ + $({ + let local = self.$idx.to_v8(scope); + store_value(scope, local) + },)+ + ] + }) + } + } + }; +} + +impl_tuple!(0 A, 1 B, 2 C, 3 D, 4 E, 5 F, 6 G, 7 H, 8 I, 9 J, 10 K, 11 L, 12 M, 13 N, 14 O, 15 P, 16 Q, 17 R, 18 S, 19 T, 20 U, 21 V, 22 W, 23 X, 24 Y, 25 Z); +impl_tuple!(0 A, 1 B, 2 C, 3 D, 4 E, 5 F, 6 G, 7 H, 8 I, 9 J, 10 K, 11 L, 12 M, 13 N, 14 O, 15 P, 16 Q, 17 R, 18 S, 19 T, 20 U, 21 V, 22 W, 23 X, 24 Y); +impl_tuple!(0 A, 1 B, 2 C, 3 D, 4 E, 5 F, 6 G, 7 H, 8 I, 9 J, 10 K, 11 L, 12 M, 13 N, 14 O, 15 P, 16 Q, 17 R, 18 S, 19 T, 20 U, 21 V, 22 W, 23 X); +impl_tuple!(0 A, 1 B, 2 C, 3 D, 4 E, 5 F, 6 G, 7 H, 8 I, 9 J, 10 K, 11 L, 12 M, 13 N, 14 O, 15 P, 16 Q, 17 R, 18 S, 19 T, 20 U, 21 V, 22 W); +impl_tuple!(0 A, 1 B, 2 C, 3 D, 4 E, 5 F, 6 G, 7 H, 8 I, 9 J, 10 K, 11 L, 12 M, 13 N, 14 O, 15 P, 16 Q, 17 R, 18 S, 19 T, 20 U, 21 V); +impl_tuple!(0 A, 1 B, 2 C, 3 D, 4 E, 5 F, 6 G, 7 H, 8 I, 9 J, 10 K, 11 L, 12 M, 13 N, 14 O, 15 P, 16 Q, 17 R, 18 S, 19 T, 20 U); +impl_tuple!(0 A, 1 B, 2 C, 3 D, 4 E, 5 F, 6 G, 7 H, 8 I, 9 J, 10 K, 11 L, 12 M, 13 N, 14 O, 15 P, 16 Q, 17 R, 18 S, 19 T); +impl_tuple!(0 A, 1 B, 2 C, 3 D, 4 E, 5 F, 6 G, 7 H, 8 I, 9 J, 10 K, 11 L, 12 M, 13 N, 14 O, 15 P, 16 Q, 17 R, 18 S); +impl_tuple!(0 A, 1 B, 2 C, 3 D, 4 E, 5 F, 6 G, 7 H, 8 I, 9 J, 10 K, 11 L, 12 M, 13 N, 14 O, 15 P, 16 Q, 17 R); +impl_tuple!(0 A, 1 B, 2 C, 3 D, 4 E, 5 F, 6 G, 7 H, 8 I, 9 J, 10 K, 11 L, 12 M, 13 N, 14 O, 15 P, 16 Q); +impl_tuple!(0 A, 1 B, 2 C, 3 D, 4 E, 5 F, 6 G, 7 H, 8 I, 9 J, 10 K, 11 L, 12 M, 13 N, 14 O, 15 P); +impl_tuple!(0 A, 1 B, 2 C, 3 D, 4 E, 5 F, 6 G, 7 H, 8 I, 9 J, 10 K, 11 L, 12 M, 13 N, 14 O); +impl_tuple!(0 A, 1 B, 2 C, 3 D, 4 E, 5 F, 6 G, 7 H, 8 I, 9 J, 10 K, 11 L, 12 M, 13 N); +impl_tuple!(0 A, 1 B, 2 C, 3 D, 4 E, 5 F, 6 G, 7 H, 8 I, 9 J, 10 K, 11 L, 12 M); +impl_tuple!(0 A, 1 B, 2 C, 3 D, 4 E, 5 F, 6 G, 7 H, 8 I, 9 J, 10 K, 11 L); +impl_tuple!(0 A, 1 B, 2 C, 3 D, 4 E, 5 F, 6 G, 7 H, 8 I, 9 J, 10 K); +impl_tuple!(0 A, 1 B, 2 C, 3 D, 4 E, 5 F, 6 G, 7 H, 8 I, 9 J); +impl_tuple!(0 A, 1 B, 2 C, 3 D, 4 E, 5 F, 6 G, 7 H, 8 I); +impl_tuple!(0 A, 1 B, 2 C, 3 D, 4 E, 5 F, 6 G, 7 H); +impl_tuple!(0 A, 1 B, 2 C, 3 D, 4 E, 5 F, 6 G); +impl_tuple!(0 A, 1 B, 2 C, 3 D, 4 E, 5 F); +impl_tuple!(0 A, 1 B, 2 C, 3 D, 4 E); +impl_tuple!(0 A, 1 B, 2 C, 3 D); +impl_tuple!(0 A, 1 B, 2 C); +impl_tuple!(0 A, 1 B); +impl_tuple!(0 A); diff --git a/src/runtimes/mod.rs b/src/runtimes/mod.rs index 96bf325..963942d 100644 --- a/src/runtimes/mod.rs +++ b/src/runtimes/mod.rs @@ -1,3 +1,5 @@ +#[cfg(feature = "javascript")] +pub mod javascript; #[cfg(feature = "lua")] pub mod lua; #[cfg(feature = "rhai")] diff --git a/tests/tests.rs b/tests/tests.rs index 1445d95..55e9d99 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -1,17 +1,47 @@ -#[cfg(any(feature = "rhai", feature = "lua", feature = "ruby"))] +#[cfg(any( + feature = "rhai", + feature = "lua", + feature = "ruby", + feature = "javascript" +))] use std::sync::OnceLock; -#[cfg(any(feature = "rhai", feature = "lua", feature = "ruby"))] +#[cfg(any( + feature = "rhai", + feature = "lua", + feature = "ruby", + feature = "javascript" +))] use bevy::ecs::system::RunSystemOnce as _; -#[cfg(any(feature = "rhai", feature = "lua", feature = "ruby"))] +#[cfg(any( + feature = "rhai", + feature = "lua", + feature = "ruby", + feature = "javascript" +))] use bevy::prelude::*; -#[cfg(any(feature = "rhai", feature = "lua", feature = "ruby"))] +#[cfg(any( + feature = "rhai", + feature = "lua", + feature = "ruby", + feature = "javascript" +))] use bevy_scriptum::{FuncArgs, Runtime, prelude::*}; -#[cfg(any(feature = "rhai", feature = "lua", feature = "ruby"))] +#[cfg(any( + feature = "rhai", + feature = "lua", + feature = "ruby", + feature = "javascript" +))] static TRACING_SUBSCRIBER: OnceLock<()> = OnceLock::new(); -#[cfg(any(feature = "rhai", feature = "lua", feature = "ruby"))] +#[cfg(any( + feature = "rhai", + feature = "lua", + feature = "ruby", + feature = "javascript" +))] #[derive(Default, Resource)] struct TimesCalled { times_called: u8, @@ -29,7 +59,12 @@ macro_rules! assert_n_times_called { }; } -#[cfg(any(feature = "rhai", feature = "lua", feature = "ruby"))] +#[cfg(any( + feature = "rhai", + feature = "lua", + feature = "ruby", + feature = "javascript" +))] fn build_test_app() -> App { let mut app = App::new(); @@ -43,7 +78,12 @@ fn build_test_app() -> App { app } -#[cfg(any(feature = "rhai", feature = "lua", feature = "ruby"))] +#[cfg(any( + feature = "rhai", + feature = "lua", + feature = "ruby", + feature = "javascript" +))] fn run_script( app: &mut App, path: String, @@ -60,7 +100,12 @@ fn run_script( entity_id } -#[cfg(any(feature = "rhai", feature = "lua", feature = "ruby"))] +#[cfg(any( + feature = "rhai", + feature = "lua", + feature = "ruby", + feature = "javascript" +))] fn call_script_on_update_from_rust( mut scripted_entities: Query<(Entity, &mut R::ScriptData)>, scripting_runtime: ResMut, @@ -73,7 +118,12 @@ fn call_script_on_update_from_rust( .unwrap(); } -#[cfg(any(feature = "rhai", feature = "lua", feature = "ruby"))] +#[cfg(any( + feature = "rhai", + feature = "lua", + feature = "ruby", + feature = "javascript" +))] trait AssertStateKeyValue { type ScriptData; fn assert_state_key_value_i64(world: &World, entity_id: Entity, key: &str, value: i64); @@ -81,7 +131,12 @@ trait AssertStateKeyValue { fn assert_state_key_value_string(world: &World, entity_id: Entity, key: &str, value: &str); } -#[cfg(any(feature = "rhai", feature = "lua", feature = "ruby"))] +#[cfg(any( + feature = "rhai", + feature = "lua", + feature = "ruby", + feature = "javascript" +))] macro_rules! scripting_tests { ($runtime:ty, $script:literal, $extension:literal, $entity_type: ty, $vec_type: ty) => { use super::*; @@ -712,3 +767,60 @@ mod ruby_tests { scripting_tests!(RubyRuntime, "ruby", "rb", BevyEntity, BevyVec3); } + +#[cfg(feature = "javascript")] +mod javascript_tests { + use bevy::prelude::*; + use bevy_scriptum::runtimes::javascript::{prelude::*, v8}; + + fn with_state_value( + world: &World, + key: &str, + f: impl FnOnce(&mut v8::PinScope, v8::Local) -> T + Send + 'static, + ) -> T { + let runtime = world.get_resource::().unwrap(); + let key = key.to_string(); + runtime.with_scope(move |scope| { + let global = scope.get_current_context().global(scope); + let state_key: v8::Local = v8::String::new(scope, "State").unwrap().into(); + let state = global + .get(scope, state_key) + .unwrap() + .to_object(scope) + .unwrap(); + let value_key: v8::Local = v8::String::new(scope, &key).unwrap().into(); + let value = state.get(scope, value_key).unwrap(); + f(scope, value) + }) + } + + impl AssertStateKeyValue for JsRuntime { + type ScriptData = JsScriptData; + + fn assert_state_key_value_i64(world: &World, _entity_id: Entity, key: &str, value: i64) { + let actual = with_state_value(world, key, |scope, val| { + val.number_value(scope).unwrap() as i64 + }); + assert_eq!(actual, value); + } + + fn assert_state_key_value_i32(world: &World, _entity_id: Entity, key: &str, value: i32) { + let actual = with_state_value(world, key, |scope, val| { + val.number_value(scope).unwrap() as i32 + }); + assert_eq!(actual, value); + } + + fn assert_state_key_value_string( + world: &World, + _entity_id: Entity, + key: &str, + value: &str, + ) { + let actual = with_state_value(world, key, |scope, val| val.to_rust_string_lossy(scope)); + assert_eq!(actual, value); + } + } + + scripting_tests!(JsRuntime, "js", "js", BevyEntity, BevyVec3); +}