import { on } from "@storyteller/framework"; import * as studio from "@storyteller/studio"; import { type EntityDespawnEvent, type EntityMultiSpawnEvent, type EntitySelectEvent, type EntitySpawnEvent, type SceneStateEvent, TransformSpace, TransformType, SceneState, StudioMode, } from "@storyteller/studio"; import { CanvasProvider } from "@storyteller/studio-web/canvas-provider"; import { match } from "@storyteller/utility"; import { LitElement, html, nothing } from "lit"; import { customElement, state } from "lit/decorators.js"; import { classMap } from "lit-html/directives/class-map.js"; import { Ref, createRef, ref } from "lit-html/directives/ref.js"; import "@storyteller/studio-web/canvas-provider"; import "@storyteller/studio-web/viewer"; import "@storyteller/studio-web/bvh-viewer"; import "@storyteller/studio-web/mixamo-viewer"; import "./button.element"; import "./help.element"; import "./icon.element"; import "./toolbar.element"; import "./transform-toolbar.element"; import "./tree.element"; import "./app.element.scss"; interface SceneObject extends Omit { children: SceneObject[]; } @customElement("app-root") export class AppElement extends LitElement { @state() showHelp = false; @state() loading = true; @state() transformSpace = TransformSpace.Local; @state() transformType = TransformType.Translate; @state() sceneObjects: SceneObject[] = []; @state() selection?: string | null; @state() bvhPath?: string; @state() mixamoPath?: string; @state() mode?: StudioMode; #sceneObjects: studio.SceneObject[] = []; #entityMap = new Map(); #canvasProvider: Ref = createRef(); get #studioCanvas(): HTMLCanvasElement | null { const provider = this.#canvasProvider.value; if (!provider) return null; return document.getElementById(provider.canvasId) as HTMLCanvasElement | null; } override connectedCallback(): void { super.connectedCallback(); const params = new URLSearchParams(document.location.search); if (params.has("bvh")) this.bvhPath = params.get("bvh")!; if (params.has("mixamo")) this.mixamoPath = params.get("mixamo")!; this.mode = match (params.get("mode"), { "studio": () => StudioMode.Editor, "viewer": () => StudioMode.Viewer, _: () => StudioMode.Viewer, }); } protected override createRenderRoot(): Element | ShadowRoot { return this; } @on("keydown") @on("keyup") pipeKeyboardEvents(event: KeyboardEvent): void { // When the user interacts with one of the web UI elements, the Bevy // canvas will lose focus, preventing it from receiving keyboard events. // As a workaround, we can listen for any events that have bubbled up to // the app root and repeat them for the canvas. // // This does unfortunately burden the web front-end with some additional // responsibility: when we handle a keyboard event and _don't_ want the // Bevy app to receive it, we'll need to call `event.stopPropagation()` in // the handler to prevent it from reaching this listener. const { type, code, charCode, key, keyCode, location, repeat, altKey, ctrlKey, metaKey, shiftKey, } = event; this.#studioCanvas?.dispatchEvent(new KeyboardEvent(type, { code, charCode, key, keyCode, location, repeat, altKey, ctrlKey, metaKey, shiftKey, bubbles: true, cancelable: true, composed: true, })); } onSceneStateChange({ detail: state }: SceneStateEvent): void { this.loading = match (state, { [SceneState.Active]: () => false, _: () => true, }); } @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; } onTransformSpaceChange({ detail: space }: CustomEvent): void { this.transformSpace = space; studio.setTransformSpace(space); } onTransformTypeChange({ detail: type }: CustomEvent): void { this.transformType = type; studio.setTransformType(type); } // 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); } protected override render = () => html` ${this.bvhPath ? html` ` : this.mixamoPath ? html` ` : html` `} Help ${this.mode === StudioMode.Editor ? html`
${this.sceneObjects.map(obj => html` `)}
` : nothing} ${this.showHelp ? html` ` : nothing} ${this.loading ? html`

Loading

` : nothing}
`; } function sortByName(a: { name: string }, b: { name: string }): number { return a.name.localeCompare(b.name); }