mirror of
https://github.com/storytold/storyteller-bevy.git
synced 2026-10-23 08:16:05 +00:00
Scene Inspector (#11)
This PR adds the bones of a user-facing scene inspector. ### Work Done To facilitate this work, the Bevy/frontend interface has been expanded to accommodate two-way communications: - The Bevy app can now dispatch JavaScript `CustomEvent`s via `wasm::dispatch_to_js(...)` - The frontend application can receive those events by listening for the given event type on the `window` object In its current state, the inspection system works by attaching the `Inspectable` marker component to any entities which should be exposed to the user. A Bevy system runs on `Update` to check for newly spawned or despawned entities with that component, and emits events to notify the frontend. The frontend listens for those events and updates its model of the scene accordingly, which is then displayed with a simple tree view. ### Future Enhancements There are many improvements that must be made to make this system actually useful, but this PR is being expedited to focus on migrating to Bevy 0.12. A shortlist of planned updates: - Identify and expose the "type" of entity (e.g. lights, cameras, meshes, skeletons, etc.) via a field on the `Inspectable` component - Highlight the currently selected entity in the frontend hierarchy - Ask the Bevy app to update the selection when the user selects an entry in the hierarchy - Add a separate UI panel to inspect and modify the selected entity's components via the frontend
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
use bevy::prelude::{Entity, GlobalTransform, Quat, Transform, Vec3};
|
||||
use serde::ser::Serialize;
|
||||
use serde_wasm_bindgen::Serializer;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
pub type JsonValue = serde_json::Value;
|
||||
|
||||
#[wasm_bindgen(typescript_custom_section)]
|
||||
const BEVY_TYPES: &str = r#"
|
||||
export interface BevyObject<Tag extends string, T> {
|
||||
type: Tag;
|
||||
value: T;
|
||||
};
|
||||
|
||||
export type Vec3 = BevyObject<"Vec3", [number, number, number]>;
|
||||
export type Quat = BevyObject<"Quat", [number, number, number, number]>;
|
||||
|
||||
export interface ITransform {
|
||||
translation: Vec3;
|
||||
rotation: Quat;
|
||||
scale: Vec3;
|
||||
}
|
||||
export type Transform = BevyObject<"Transform", ITransform>;
|
||||
export type GlobalTransform = BevyObject<"GlobalTransform", ITransform>;
|
||||
"#;
|
||||
|
||||
pub(crate) const SERIALIZER: Serializer = Serializer::new()
|
||||
.serialize_missing_as_null(true)
|
||||
.serialize_maps_as_objects(true)
|
||||
.serialize_large_number_types_as_bigints(true);
|
||||
|
||||
pub trait AsJsValue {
|
||||
fn as_js(&self) -> Result<JsValue, String>;
|
||||
}
|
||||
|
||||
pub trait AsJson {
|
||||
fn as_json(&self) -> JsonValue;
|
||||
}
|
||||
|
||||
impl AsJson for Vec3 {
|
||||
fn as_json(&self) -> JsonValue {
|
||||
serde_json::json! {{
|
||||
"type": "Vec3",
|
||||
"value": [self.x, self.y, self.z],
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsJson for Quat {
|
||||
fn as_json(&self) -> JsonValue {
|
||||
serde_json::json! {{
|
||||
"type": "Quat",
|
||||
"value": [self.x, self.y, self.z, self.w],
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsJson for Transform {
|
||||
fn as_json(&self) -> JsonValue {
|
||||
serde_json::json! {{
|
||||
"type": "Transform",
|
||||
"value": {
|
||||
"translation": self.translation.as_json(),
|
||||
"rotation": self.rotation.as_json(),
|
||||
"scale": self.scale.as_json(),
|
||||
},
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsJson for GlobalTransform {
|
||||
fn as_json(&self) -> JsonValue {
|
||||
let (scale, rotation, translation) = self.to_scale_rotation_translation();
|
||||
|
||||
serde_json::json! {{
|
||||
"type": "GlobalTransform",
|
||||
"value": {
|
||||
"translation": translation.as_json(),
|
||||
"rotation": rotation.as_json(),
|
||||
"scale": scale.as_json(),
|
||||
},
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> AsJsValue for T
|
||||
where
|
||||
T: AsJson,
|
||||
{
|
||||
fn as_js(&self) -> Result<JsValue, String> {
|
||||
self.as_json()
|
||||
.serialize(&SERIALIZER)
|
||||
.map_err(|err| format!("{err}"))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> AsJsValue for Vec<T>
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
fn as_js(&self) -> Result<JsValue, String> {
|
||||
self.serialize(&SERIALIZER).map_err(|err| format!("{err}"))
|
||||
}
|
||||
}
|
||||
|
||||
impl AsJsValue for JsonValue {
|
||||
fn as_js(&self) -> Result<JsValue, String> {
|
||||
self.serialize(&SERIALIZER).map_err(|err| format!("{err}"))
|
||||
}
|
||||
}
|
||||
|
||||
impl AsJsValue for Entity {
|
||||
fn as_js(&self) -> Result<JsValue, String> {
|
||||
Ok(JsValue::from_str(&format!("{self:?}")))
|
||||
}
|
||||
}
|
||||
|
||||
impl AsJsValue for String {
|
||||
fn as_js(&self) -> Result<JsValue, String> {
|
||||
Ok(JsValue::from_str(self))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> AsJsValue for Option<T>
|
||||
where
|
||||
T: AsJsValue,
|
||||
{
|
||||
fn as_js(&self) -> Result<JsValue, String> {
|
||||
match self.as_ref() {
|
||||
Some(value) => Ok(value.as_js()?),
|
||||
None => Ok(JsValue::NULL),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
use std::{cell::RefCell, sync::OnceLock};
|
||||
|
||||
use bevy::window::Window;
|
||||
use parking_lot::ReentrantMutex;
|
||||
use web_sys::{CustomEvent, CustomEventInit, Element};
|
||||
|
||||
mod conv;
|
||||
|
||||
pub use conv::*;
|
||||
|
||||
pub(crate) type WasmMessageBuffer<T> = OnceLock<ReentrantMutex<RefCell<Option<T>>>>;
|
||||
|
||||
pub(crate) fn dispatch_to_js(
|
||||
window: &Window,
|
||||
event_type: &str,
|
||||
// NOTE: We use dynamic dispatch here to keep the wasm binary small(-ish)
|
||||
value: &dyn AsJsValue,
|
||||
) -> Result<(), String> {
|
||||
let canvas = try_get_canvas(window)?;
|
||||
|
||||
let detail = value.as_js()?;
|
||||
let event = CustomEvent::new_with_event_init_dict(
|
||||
event_type,
|
||||
CustomEventInit::new()
|
||||
.bubbles(true)
|
||||
.cancelable(true)
|
||||
.detail(&detail),
|
||||
)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
canvas
|
||||
.dispatch_event(&event)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn try_get_canvas(window: &Window) -> Result<Element, String> {
|
||||
let canvas_selector = window
|
||||
.canvas
|
||||
.as_ref()
|
||||
.ok_or("Canvas selector not set on Bevy Window".to_string())?;
|
||||
|
||||
let window = web_sys::window().ok_or("web_sys::window() returned None".to_string())?;
|
||||
let document = window
|
||||
.document()
|
||||
.ok_or("window.document() returned None".to_string())?;
|
||||
|
||||
match document.query_selector(canvas_selector) {
|
||||
Ok(Some(canvas)) => Ok(canvas),
|
||||
Ok(None) => Err(format!(
|
||||
"Failed to find element with selector '{canvas_selector}'"
|
||||
)),
|
||||
Err(err) => Err(format!("JavaScript threw an Error: {err:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! get_buffer {
|
||||
($buffer:ident) => {
|
||||
$buffer.get_or_init(|| {
|
||||
::parking_lot::ReentrantMutex::new(::std::cell::RefCell::new(
|
||||
::core::option::Option::None,
|
||||
))
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! write_message {
|
||||
($buffer:ident, $expr:expr) => {{
|
||||
let buffer = crate::wasm::get_buffer!($buffer);
|
||||
if let Some(lock) = buffer.try_lock() {
|
||||
*lock.borrow_mut() = Some($expr);
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
macro_rules! read_buffer {
|
||||
($buffer:ident, |$msg:ident| $stmt:stmt) => {{
|
||||
let buffer = crate::wasm::get_buffer!($buffer);
|
||||
if let Some(lock) = buffer.try_lock() {
|
||||
if let Some($msg) = lock.borrow_mut().take() {
|
||||
$stmt
|
||||
}
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
pub(crate) use get_buffer;
|
||||
pub(crate) use read_buffer;
|
||||
pub(crate) use write_message;
|
||||
Reference in New Issue
Block a user