jakobhellermann / bevy_mod_js_scripting

Other
83 stars 6 forks source link

Allow Users to Create Custom Ops #18

Closed zicklag closed 2 years ago

zicklag commented 2 years ago

I'm realizing as I try to integrate this with my game that it could be important to make it as easy as possible for users to create their own custom ops.

Right now we have some holes in the functionality that should be built-in eventually, such as being able to interact with reflected Vecs and HashMaps or being able to world.spawn(), but I also was thinking about the fact that I could definitely have some kind of functionality in my game that I wanted to expose more directly to my scripts, especially with reflected functions being work-in-progress.

To this end, I think I'm going to experiment a little with a way to create an abstraction for creating the ops, and at the same time try to reduce the code duplication between the native and WASM runtimes by using that ops abstraction for all the core ops we currently have.

jakobhellermann commented 2 years ago

Sounds good, it seems useful to make this extensible. The biggest question is probably what kinds of parameters and return types to allow. T: Serialize + Deserialize? This seems like it should work with v8 and wasm_bindgen, but we probably want to be able to transmit ValueRefs somehow. Maybe with #16 solved these will just be an index, so that should be good.

zicklag commented 2 years ago

My current strategy has been to take make only one native Deno/Wasm Bindgen op, that has a signature in pseudo code like:

#[op]
fn op_bevy_mod_js_scripting(
  op_name: String,
  args: serde_json::Value
) -> Result<serde_json::Value, AnyError> {
  // Here we check on the op-name and run the core/user op registered with that name.
}

That makes it super easy to register custom ops with our ops abstraction without having to generate new #[op] annotated functions for Deno or else work with the function pointers manually.

And, yes, for ValueRefs I'm going to try doing what I did for the WASM backend already and just pass handles into a SlotMap so that gets us away from pointers and makes it easy to pass through a serde_json::Value.

Haven't gotten far yet, but the concept seems to be sound so far.

Also, as a simple optimization I'm going to try to reduce the op_name to an op_index that will be mapped the string op_name to the op_index on the JavaScript side, so that we aren't having to pass strings over the FFI every time an op is called.

zicklag commented 2 years ago

Just got the log operation implemented through the new abstraction. I think the API's turning out pretty nice, and it should allow us to implement runtime agnostic ops:

struct OpLog;

#[derive(Deserialize)]
struct OpLogArgs {
    level: u8,
    args: Vec<serde_json::Value>,
}

impl JsRuntimeOp for OpLog {
    fn run(
        &self,
        script_info: &ScriptInfo,
        _world: &mut World,
        args: serde_json::Value,
    ) -> anyhow::Result<serde_json::Value> {
        let args: OpLogArgs = serde_json::from_value(args).context("Parse `log` op args")?;
        let level = args.level;

        let text = args
            .args
            .iter()
            .map(|arg| {
                // Print string args without quotes
                if let serde_json::Value::String(s) = &arg {
                    s.clone()
                // Format other values as JSON
                } else {
                    format!("{arg}")
                }
            })
            .collect::<Vec<_>>()
            .join(" ");

        if level == 0 {
            let _span = span!(Level::TRACE, "script", path = ?script_info.path).entered();
            event!(target: "js_runtime", Level::TRACE, "{text}");
        } else if level == 1 {
            let _span = span!(Level::DEBUG, "script", path = ?script_info.path).entered();
            event!(target: "js_runtime", Level::DEBUG, "{text}");
        } else if level == 2 {
            let _span = span!(Level::INFO, "script", path = ?script_info.path).entered();
            event!(target: "js_runtime", Level::INFO, "{text}");
        } else if level == 3 {
            let _span = span!(Level::WARN, "script", path = ?script_info.path).entered();
            event!(target: "js_runtime", Level::WARN, "{text}");
        } else if level == 4 {
            let _span = span!(Level::ERROR, "script", path = ?script_info.path).entered();
            event!(target: "js_runtime", Level::ERROR, "{text}");
        } else {
            anyhow::bail!("Invalid log level");
        }

        Ok(serde_json::Value::Null)
    }

    fn js(&self) -> Option<&'static str> {
        Some(
            r#"
            globalThis.trace = (...args) => bevyModJsScriptingOpSync("log", 0, args);
            globalThis.debug = (...args) => bevyModJsScriptingOpSync("log", 1, args);
            globalThis.info = (...args) => bevyModJsScriptingOpSync("log", 2, args);
            globalThis.warn = (...args) => bevyModJsScriptingOpSync("log", 3, args);
            globalThis.error = (...args) => bevyModJsScriptingOpSync("log", 4, args);
            "#,
        )
    }
}
zicklag commented 2 years ago

Got native runtime fully migrated to the new op abstraction and away from raw pointers in draft PR ( #19 ). Now we can actually #![forbid(unsafe_code)] which pretty surprising for a plugin to implement scripting support!

I've still got to migrate WASM to the new system, which shouldn't be difficult because all the ops are now runtime agnostic and implemented in one place only.

First, though, I'm going to see about solving #16 real quick to make sure that will work well with the op abstraction and that it can stay runtime agnostic.