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:
@@ -96,3 +96,38 @@
|
||||
top: 60px;
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
.hierarchy {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
overflow: auto;
|
||||
|
||||
box-sizing: border-box;
|
||||
width: 290px;
|
||||
max-height: calc(100vh - 32px);
|
||||
padding: 10px 15px {
|
||||
inline-end: 30px;
|
||||
}
|
||||
|
||||
background: #0006;
|
||||
backdrop-filter: blur(16px);
|
||||
border: 8px solid transparent;
|
||||
border-radius: 0.5rem;
|
||||
|
||||
pointer-events: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
|
||||
&-thumb {
|
||||
border-radius: 2px;
|
||||
background: #FFF2;
|
||||
|
||||
&:hover {
|
||||
background: #FFF3;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
import { on } from "@storyteller/framework";
|
||||
import * as studio from "@storyteller/studio";
|
||||
import { TransformSpace, TransformType, ViewportSize } from "@storyteller/studio";
|
||||
import {
|
||||
type EntityDespawnEvent,
|
||||
type EntityMultiSpawnEvent,
|
||||
type EntitySelectEvent,
|
||||
type EntitySpawnEvent,
|
||||
TransformSpace,
|
||||
TransformType,
|
||||
ViewportSize,
|
||||
} from "@storyteller/studio";
|
||||
import { match } from "@storyteller/utility";
|
||||
import { LitElement, html, unsafeCSS } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { classMap } from "lit/directives/class-map.js";
|
||||
|
||||
import "./button.element";
|
||||
import "./help.element";
|
||||
import "./icon.element";
|
||||
import "./tree.element";
|
||||
|
||||
import styles from "./app.element.scss?inline";
|
||||
|
||||
interface SceneObject extends Omit<studio.SceneObject, "children"> {
|
||||
children: SceneObject[];
|
||||
}
|
||||
|
||||
@customElement("app-root")
|
||||
export class AppElement extends LitElement {
|
||||
static override styles = unsafeCSS(styles);
|
||||
@@ -20,6 +34,11 @@ export class AppElement extends LitElement {
|
||||
@state() transformSpace = TransformSpace.Local;
|
||||
@state() transformType = TransformType.Translate;
|
||||
|
||||
@state() sceneObjects: SceneObject[] = [];
|
||||
@state() selection?: string | null;
|
||||
|
||||
#sceneObjects: studio.SceneObject[] = [];
|
||||
#entityMap = new Map<string, SceneObject>();
|
||||
#studioCanvas?: HTMLCanvasElement | null;
|
||||
|
||||
override connectedCallback(): void {
|
||||
@@ -61,6 +80,34 @@ export class AppElement extends LitElement {
|
||||
}));
|
||||
}
|
||||
|
||||
@on("window:entity-select")
|
||||
onEntitySelectionChange({ detail: entity }: EntitySelectEvent): void {
|
||||
const sceneObject = this.#entityMap.get(entity!);
|
||||
console.log("Entity selection changed:", sceneObject);
|
||||
this.selection = entity;
|
||||
}
|
||||
|
||||
@on("window:entity-spawn")
|
||||
onEntitySpawned({ detail: value }: EntitySpawnEvent): void {
|
||||
console.log("Entity spawned:", value);
|
||||
this.#sceneObjects = this.#sceneObjects.concat(value).sort(sortByName);
|
||||
this.#updateHierarchy();
|
||||
}
|
||||
|
||||
@on("window:entity-multi-spawn")
|
||||
onEntitiesSpawned({ detail: values }: EntityMultiSpawnEvent): void {
|
||||
console.log("Entities spawned:", values);
|
||||
this.#sceneObjects = this.#sceneObjects.concat(values).sort(sortByName);
|
||||
this.#updateHierarchy();
|
||||
}
|
||||
|
||||
@on("window:entity-despawn")
|
||||
onEntityDespawned({ detail: entity }: EntityDespawnEvent): void {
|
||||
console.log("Entity despawned:", entity);
|
||||
this.#sceneObjects = this.#sceneObjects.filter(({ id }) => id !== entity);
|
||||
this.#updateHierarchy();
|
||||
}
|
||||
|
||||
toggleHelp(): void {
|
||||
this.showHelp = !this.showHelp;
|
||||
}
|
||||
@@ -99,6 +146,34 @@ export class AppElement extends LitElement {
|
||||
studio.setTransformType(this.transformType);
|
||||
}
|
||||
|
||||
// TODO: Get some help from someone who knows what an algorithm is
|
||||
#updateHierarchy(): void {
|
||||
this.#entityMap.clear();
|
||||
|
||||
for (let obj of this.#sceneObjects)
|
||||
this.#entityMap.set(obj.id, { ...obj, children: [] });
|
||||
|
||||
for (let obj of this.#entityMap.values()) {
|
||||
if (obj.parent != null) {
|
||||
const parent = this.#entityMap.get(obj.parent);
|
||||
if (!parent)
|
||||
throw new Error(`Missing parent: ${obj.parent}`);
|
||||
|
||||
parent.children.push(obj);
|
||||
}
|
||||
}
|
||||
|
||||
for (let obj of this.#entityMap.values())
|
||||
obj.children.sort(sortByName);
|
||||
|
||||
this.sceneObjects = Array
|
||||
.from(this.#entityMap.values())
|
||||
.filter(obj => obj.parent == null)
|
||||
.sort(sortByName);
|
||||
|
||||
console.log("Hierarchy:", this.sceneObjects);
|
||||
}
|
||||
|
||||
#initStudio(): HTMLCanvasElement | null {
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
@@ -165,6 +240,16 @@ export class AppElement extends LitElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hierarchy">
|
||||
${this.sceneObjects.map(obj => html`
|
||||
<sts-tree class="scene-object"
|
||||
id=${obj.id}
|
||||
.name=${obj.name}
|
||||
.treeChildren=${obj.children}
|
||||
></sts-tree>
|
||||
`)}
|
||||
</div>
|
||||
|
||||
<sts-button class="btn-help"
|
||||
icon="circle-info"
|
||||
@click=${this.toggleHelp}
|
||||
@@ -177,3 +262,7 @@ export class AppElement extends LitElement {
|
||||
` : null}
|
||||
`;
|
||||
}
|
||||
|
||||
function sortByName(a: { name: string }, b: { name: string }): number {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import fasGlobe from "@fortawesome/fontawesome-pro/svgs/regular/globe.svg?raw";
|
||||
import fasUpDownLeftRight from "@fortawesome/fontawesome-pro/svgs/regular/arrows-up-down-left-right.svg?raw";
|
||||
import fasRotate from "@fortawesome/fontawesome-pro/svgs/regular/arrows-rotate.svg?raw";
|
||||
import fasScale from "@fortawesome/fontawesome-pro/svgs/regular/arrow-up-right-and-arrow-down-left-from-center.svg?raw";
|
||||
import fasCaretRight from "@fortawesome/fontawesome-pro/svgs/solid/caret-right.svg?raw";
|
||||
|
||||
import styles from "./icon.element.scss?inline";
|
||||
|
||||
@@ -37,6 +38,7 @@ function renderSvg(iconId: string) {
|
||||
|
||||
const svgSrc = match (iconId, {
|
||||
"circle-info": () => fasCircleInfo,
|
||||
"caret-right": () => fasCaretRight,
|
||||
"cube": () => fasCube,
|
||||
"globe": () => fasGlobe,
|
||||
"up-down-left-right": () => fasUpDownLeftRight,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
:host, .children {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
padding-block: 5px;
|
||||
|
||||
font: {
|
||||
family: "Fira Sans", sans-serif;
|
||||
size: 14px;
|
||||
weight: 400;
|
||||
}
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
|
||||
pointer-events: auto;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
|
||||
color: #FFFC;
|
||||
|
||||
&.selected {
|
||||
font-weight: 500;
|
||||
color: #FFF;
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
width: 16px;
|
||||
height: 24px;
|
||||
margin-block: -5px;
|
||||
padding-inline-start: 4px;
|
||||
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font: inherit;
|
||||
|
||||
color: #FFFC;
|
||||
|
||||
&:hover {
|
||||
color: #FFF;
|
||||
}
|
||||
}
|
||||
|
||||
.icon.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.name {
|
||||
padding-inline-start: 5px;
|
||||
}
|
||||
|
||||
.children {
|
||||
margin-inline-start: 7px;
|
||||
padding-inline-start: 9px;
|
||||
border-inline-start: 1px solid #FFF2;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { LitElement, html, nothing, unsafeCSS } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { classMap } from "lit/directives/class-map.js";
|
||||
|
||||
import "./icon.element";
|
||||
|
||||
import styles from "./tree.element.scss?inline";
|
||||
|
||||
export interface Tree {
|
||||
id?: string;
|
||||
name: string;
|
||||
children?: Tree[];
|
||||
}
|
||||
|
||||
@customElement("sts-tree")
|
||||
export class TreeElement extends LitElement {
|
||||
static override styles = unsafeCSS(styles);
|
||||
|
||||
@property() name = "";
|
||||
@property() treeChildren?: Tree[];
|
||||
@property() expanded = false;
|
||||
|
||||
toggleExpanded(): void {
|
||||
this.expanded = !this.expanded;
|
||||
}
|
||||
|
||||
// TODO: A11y
|
||||
protected override render = () => html`
|
||||
<header>
|
||||
${this.treeChildren?.length ? html`
|
||||
<button class="button"
|
||||
@click=${this.toggleExpanded}
|
||||
>
|
||||
<sts-icon
|
||||
class=${classMap({
|
||||
icon: true,
|
||||
expanded: this.expanded,
|
||||
})}
|
||||
icon="caret-right"
|
||||
aria-label=${this.expanded ? "Expand" : "Collapse"}
|
||||
></sts-icon>
|
||||
</button>
|
||||
` : nothing}
|
||||
<span class="name">
|
||||
${this.name}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
${this.expanded && this.treeChildren?.length ? html`
|
||||
<div class="children">
|
||||
${this.treeChildren?.map(tree => html`
|
||||
<sts-tree
|
||||
id=${tree.id}
|
||||
.name=${tree.name}
|
||||
.treeChildren=${tree.children}
|
||||
></sts-tree>
|
||||
`)}
|
||||
</div>
|
||||
` : nothing}
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user