diff --git a/addons.json b/addons.json new file mode 100644 index 0000000000..8faa66d089 --- /dev/null +++ b/addons.json @@ -0,0 +1,3 @@ +{ + "addons": ["hubs-duck-addon", "hubs-portals-addon"] +} diff --git a/package-lock.json b/package-lock.json index a7e4d7d7b1..b6541eeeba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,8 @@ "history": "^4.7.2", "hls.js": "^0.14.6", "html2canvas": "^1.0.0-rc.7", + "hubs-duck-addon": "github:MozillaReality/hubs-duck-addon", + "hubs-portals-addon": "github:MozillaReality/hubs-portals-addon", "js-cookie": "^2.2.0", "jsonschema": "^1.2.2", "jwt-decode": "^2.2.0", @@ -16460,6 +16462,23 @@ "node": ">= 6" } }, + "node_modules/hubs-duck-addon": { + "version": "1.0.0", + "resolved": "git+ssh://git@github.com/MozillaReality/hubs-duck-addon.git#9bc41593e9a69b32f3e91a87ff89ffa1daa49f28", + "license": "MPL-2.0", + "peerDependencies": { + "three": "^0.141.0" + } + }, + "node_modules/hubs-portals-addon": { + "version": "1.0.0", + "resolved": "git+ssh://git@github.com/MozillaReality/hubs-portals-addon.git#9a92d909dc2c8badc08f29d62967c0d22458f002", + "license": "MPL-2.0", + "peerDependencies": { + "bitecs": "github:mozilla/bitECS#hubs-patches", + "three": "^0.141.0" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", diff --git a/package.json b/package.json index b71723c7c3..1f3a891916 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,8 @@ "history": "^4.7.2", "hls.js": "^0.14.6", "html2canvas": "^1.0.0-rc.7", + "hubs-duck-addon": "github:MozillaReality/hubs-duck-addon", + "hubs-portals-addon": "github:MozillaReality/hubs-portals-addon", "js-cookie": "^2.2.0", "jsonschema": "^1.2.2", "jwt-decode": "^2.2.0", diff --git a/src/addons.ts b/src/addons.ts new file mode 100644 index 0000000000..9be4e7ba04 --- /dev/null +++ b/src/addons.ts @@ -0,0 +1,184 @@ +import { App } from "./app"; +import { prefabs } from "./prefabs/prefabs"; + +import { + InflatorConfigT, + SystemConfigT, + SystemOrderE, + PrefabConfigT, + NetworkSchemaConfigT, + ChatCommandConfigT +} from "./types"; +import configs from "./utils/configs"; +import { commonInflators, gltfInflators, jsxInflators } from "./utils/jsx-entity"; +import { networkableComponents, schemas } from "./utils/network-schemas"; + +function getNextIdx(slot: Array, system: SystemConfigT) { + return slot.findIndex(item => { + item.order > system.order; + }); +} + +function registerSystem(system: SystemConfigT) { + let slot = APP.addon_systems.prePhysics; + if (system.order < SystemOrderE.PrePhysics) { + slot = APP.addon_systems.setup; + } else if (system.order < SystemOrderE.PostPhysics) { + slot = APP.addon_systems.prePhysics; + } else if (system.order < SystemOrderE.MatricesUpdate) { + slot = APP.addon_systems.postPhysics; + } else if (system.order < SystemOrderE.BeforeRender) { + slot = APP.addon_systems.beforeRender; + } else if (system.order < SystemOrderE.AfterRender) { + slot = APP.addon_systems.afterRender; + } else if (system.order < SystemOrderE.PostProcessing) { + slot = APP.addon_systems.postProcessing; + } else { + slot = APP.addon_systems.tearDown; + } + const nextIdx = getNextIdx(slot, system); + slot.splice(nextIdx, 0, system); +} + +function registerInflator(inflator: InflatorConfigT) { + if (inflator.common) { + commonInflators[inflator.common.id] = inflator.common.inflator; + } else { + if (inflator.jsx) { + jsxInflators[inflator.jsx.id] = inflator.jsx.inflator; + } + if (inflator.gltf) { + gltfInflators[inflator.gltf.id] = inflator.gltf.inflator; + } + } +} + +function registerPrefab(prefab: PrefabConfigT) { + if (prefabs.has(prefab.id)) { + throw Error(`Error registering prefab ${name}: prefab already registered`); + } + prefabs.set(prefab.id, prefab.config); +} + +function registerNetworkSchema(schemaConfig: NetworkSchemaConfigT) { + if (schemas.has(schemaConfig.component)) { + throw Error( + `Error registering network schema ${schemaConfig.schema.componentName}: network schema already registered` + ); + } + schemas.set(schemaConfig.component, schemaConfig.schema); + networkableComponents.push(schemaConfig.component); +} + +function registerChatCommand(command: ChatCommandConfigT) { + APP.messageDispatch.registerChatCommand(command.id, command.command); +} + +export type AddonIdT = string; +export type AddonNameT = string; +export type AddonDescriptionT = string; +export type AddonOnReadyFn = (app: App, config?: JSON) => void; + +export interface InternalAddonConfigT { + name: AddonNameT; + description?: AddonDescriptionT; + onReady?: AddonOnReadyFn; + system?: SystemConfigT | SystemConfigT[]; + inflator?: InflatorConfigT | InflatorConfigT[]; + prefab?: PrefabConfigT | PrefabConfigT[]; + networkSchema?: NetworkSchemaConfigT | NetworkSchemaConfigT[]; + chatCommand?: ChatCommandConfigT | ChatCommandConfigT[]; + enabled?: boolean; + config?: JSON | undefined; +} +type AddonConfigT = Omit; + +const pendingAddons = new Map(); +export const addons = new Map(); +export type AddonRegisterCallbackT = (app: App) => void; +export function registerAddon(id: AddonIdT, config: AddonConfigT) { + console.log(`Add-on ${id} registered`); + pendingAddons.set(id, config); +} + +export function onAddonsInit(app: App) { + app.scene?.addEventListener("hub_updated", () => { + for (const [id, addon] of pendingAddons) { + if (addons.has(id)) { + throw Error(`Addon ${id} already registered`); + } else { + addons.set(id, addon); + } + + if (app.hub?.user_data && `addon_${id}` in app.hub.user_data) { + addon.enabled = app.hub.user_data[`addon_${id}`]; + } else { + addon.enabled = false; + } + + if (!addon.enabled) { + continue; + } + + if (addon.prefab) { + if (Array.isArray(addon.prefab)) { + addon.prefab.forEach(prefab => { + registerPrefab(prefab); + }); + } else { + registerPrefab(addon.prefab); + } + } + + if (addon.networkSchema) { + if (Array.isArray(addon.networkSchema)) { + addon.networkSchema.forEach(networkSchema => { + registerNetworkSchema(networkSchema); + }); + } else { + registerNetworkSchema(addon.networkSchema); + } + } + + if (addon.inflator) { + if (Array.isArray(addon.inflator)) { + addon.inflator.forEach(inflator => { + registerInflator(inflator); + }); + } else { + registerInflator(addon.inflator); + } + } + + if (addon.system) { + if (Array.isArray(addon.system)) { + addon.system.forEach(system => { + registerSystem(system); + }); + } else { + registerSystem(addon.system); + } + } + + if (addon.chatCommand) { + if (Array.isArray(addon.chatCommand)) { + addon.chatCommand.forEach(chatCommand => { + registerChatCommand(chatCommand); + }); + } else { + registerChatCommand(addon.chatCommand); + } + } + + if (addon.onReady) { + let config; + const addonsConfig = configs.feature("addons_config"); + if (addonsConfig && id in addonsConfig) { + config = addonsConfig[id]; + } + addon.onReady(app, config); + } + } + pendingAddons.clear(); + }); +} diff --git a/src/app.ts b/src/app.ts index 16a03c475c..afe0198704 100644 --- a/src/app.ts +++ b/src/app.ts @@ -30,6 +30,8 @@ import SceneEntryManager from "./scene-entry-manager"; import { store } from "./utils/store-instance"; import { addObject3DComponent } from "./utils/jsx-entity"; import { ElOrEid } from "./utils/bit-utils"; +import { onAddonsInit } from "./addons"; +import { CoreSystemKeyT, HubsSystemKeyT, SystemConfigT, SystemKeyT, SystemT } from "./types"; declare global { interface Window { @@ -63,7 +65,7 @@ export function getScene() { return promiseToScene; } -interface HubDescription { +export interface HubDescription { hub_id: string; user_data?: any; } @@ -104,6 +106,18 @@ export class App { dialog = new DialogAdapter(); + addon_systems = { + setup: new Array<{ order: number; system: SystemT }>(), + prePhysics: new Array<{ order: number; system: SystemT }>(), + postPhysics: new Array<{ order: number; system: SystemT }>(), + matricesUpdate: new Array<{ order: number; system: SystemT }>(), + beforeRender: new Array<{ order: number; system: SystemT }>(), + render: new Array<{ order: number; system: SystemT }>(), + afterRender: new Array<{ order: number; system: SystemT }>(), + postProcessing: new Array<{ order: number; system: SystemT }>(), + tearDown: new Array<{ order: number; system: SystemT }>() + }; + RENDER_ORDER = { HUD_BACKGROUND: 1, HUD_ICONS: 2, @@ -159,6 +173,19 @@ export class App { return this.sid2str.get(sid); } + notifyOnInit() { + onAddonsInit(this); + } + + getSystem(id: SystemKeyT) { + const systems = this.scene?.systems!; + if (id in systems) { + return systems[id as CoreSystemKeyT]; + } else { + return systems["hubs-systems"][id as HubsSystemKeyT]; + } + } + // This gets called by a-scene to setup the renderer, camera, and audio listener // TODO ideally the contorl flow here would be inverted, and we would setup this stuff, // initialize aframe, and then run our own RAF loop diff --git a/src/bit-components.js b/src/bit-components.js index 1bafeafa25..e0d50332be 100644 --- a/src/bit-components.js +++ b/src/bit-components.js @@ -441,7 +441,6 @@ export const LinearScale = defineComponent({ targetY: Types.f32, targetZ: Types.f32 }); -export const Quack = defineComponent(); export const TrimeshTag = defineComponent(); export const HeightFieldTag = defineComponent(); export const LocalAvatar = defineComponent(); diff --git a/src/bit-systems/quack.ts b/src/bit-systems/quack.ts deleted file mode 100644 index e66a4bb5c4..0000000000 --- a/src/bit-systems/quack.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { HubsWorld } from "../app"; -import { Held, Quack } from "../bit-components"; -import { defineQuery, enterQuery } from "bitecs"; -import { SOUND_QUACK, SOUND_SPECIAL_QUACK } from "../systems/sound-effects-system"; - -const heldQuackQuery = defineQuery([Quack, Held]); -const heldQuackEnterQuery = enterQuery(heldQuackQuery); - -export function quackSystem(world: HubsWorld) { - heldQuackEnterQuery(world).forEach(() => { - const rand = Math.random(); - if (rand < 0.01) { - APP.scene?.systems["hubs-systems"].soundEffectsSystem.playSoundOneShot(SOUND_SPECIAL_QUACK); - } else { - APP.scene?.systems["hubs-systems"].soundEffectsSystem.playSoundOneShot(SOUND_QUACK); - } - }); -} diff --git a/src/hub.js b/src/hub.js index fb609a6b7e..cf80b6d21e 100644 --- a/src/hub.js +++ b/src/hub.js @@ -273,6 +273,7 @@ import { exposeBitECSDebugHelpers } from "./bitecs-debug-helpers"; import { loadLegacyRoomObjects } from "./utils/load-legacy-room-objects"; import { loadSavedEntityStates } from "./utils/entity-state-utils"; import { shouldUseNewLoader } from "./utils/bit-utils"; +import { addons } from "./addons"; const PHOENIX_RELIABLE_NAF = "phx-reliable"; NAF.options.firstSyncSource = PHOENIX_RELIABLE_NAF; @@ -543,8 +544,18 @@ export async function updateEnvironmentForHub(hub, entryManager) { } } -export async function updateUIForHub(hub, hubChannel, showBitECSBasedClientRefreshPrompt = false) { - remountUI({ hub, entryDisallowed: !hubChannel.canEnterRoom(hub), showBitECSBasedClientRefreshPrompt }); +export async function updateUIForHub( + hub, + hubChannel, + showBitECSBasedClientRefreshPrompt = false, + showAddonRefreshPrompt = false +) { + remountUI({ + hub, + entryDisallowed: !hubChannel.canEnterRoom(hub), + showBitECSBasedClientRefreshPrompt, + showAddonRefreshPrompt + }); } function onConnectionError(entryManager, connectError) { @@ -1388,16 +1399,27 @@ document.addEventListener("DOMContentLoaded", async () => { const displayName = (userInfo && userInfo.metas[0].profile.displayName) || "API"; let showBitECSBasedClientRefreshPrompt = false; - if (!!hub.user_data?.hubs_use_bitecs_based_client !== !!window.APP.hub.user_data?.hubs_use_bitecs_based_client) { showBitECSBasedClientRefreshPrompt = true; setTimeout(() => { document.location.reload(); }, 5000); } + let showAddonRefreshPrompt = false; + [...addons.keys()].map(id => { + const key = `addon_${id}`; + const oldAddonState = !!window.APP.hub.user_data && window.APP.hub.user_data[key]; + const newAddonState = !!hub.user_data && hub.user_data[key]; + if (newAddonState !== oldAddonState) { + showAddonRefreshPrompt = true; + setTimeout(() => { + document.location.reload(); + }, 5000); + } + }); window.APP.hub = hub; - updateUIForHub(hub, hubChannel, showBitECSBasedClientRefreshPrompt); + updateUIForHub(hub, hubChannel, showBitECSBasedClientRefreshPrompt, showAddonRefreshPrompt); if ( stale_fields.includes("scene") || @@ -1484,4 +1506,6 @@ document.addEventListener("DOMContentLoaded", async () => { authChannel.setSocket(socket); linkChannel.setSocket(socket); + + APP.notifyOnInit(); }); diff --git a/src/hubs.js b/src/hubs.js new file mode 100644 index 0000000000..121abfccf1 --- /dev/null +++ b/src/hubs.js @@ -0,0 +1,27 @@ +export * from "./utils/create-networked-entity"; +export * from "./bit-components"; +export * from "./utils/bit-utils"; +export * from "./addons"; +export * from "./inflators/physics-shape"; +export * from "./constants"; +export * from "./utils/jsx-entity"; +export * from "./utils/media-url-utils"; +export * from "./systems/floaty-object-system"; +export * from "./types"; +export * from "./camera-layers"; +export * from "./bit-systems/delete-entity-system"; +export * from "./utils/bit-pinning-helper"; +export * from "./components/gltf-model-plus"; +export * from "./utils/material-utils"; +export * from "./utils/network-schemas"; +export * from "./utils/define-network-schema"; +export * from "./utils/animate"; +export * from "./utils/easing"; +export * from "./utils/coroutine"; +export * from "./utils/coroutine-utils"; +export * from "./systems/userinput/paths"; +export * from "./systems/userinput/sets"; +export * from "./systems/userinput/userinput"; +export * from "./systems/userinput/bindings/xforms"; +export * from "./systems/userinput/bindings/keyboard-mouse-user"; +export * from "./systems/userinput/devices/keyboard"; diff --git a/src/message-dispatch.js b/src/message-dispatch.js index d555db8208..59cf6f884d 100644 --- a/src/message-dispatch.js +++ b/src/message-dispatch.js @@ -11,7 +11,6 @@ import { createNetworkedEntity } from "./utils/create-networked-entity"; import { add, testAsset, respawn } from "./utils/chat-commands"; import { isLockedDownDemoRoom } from "./utils/hub-utils"; import { loadState, clearState } from "./utils/entity-state-utils"; -import { shouldUseNewLoader } from "./utils/bit-utils"; let uiRoot; // Handles user-entered messages @@ -24,6 +23,15 @@ export default class MessageDispatch extends EventTarget { this.remountUI = remountUI; this.mediaSearchStore = mediaSearchStore; this.presenceLogEntries = []; + this.chatCommands = new Map(); + } + + registerChatCommand(name, callback) { + if (!this.chatCommands.has(name)) { + this.chatCommands.set(name, callback); + } else { + throw Error(`Error registering chat command ${name}: command already registered`); + } } addToPresenceLog(entry) { @@ -140,22 +148,6 @@ export default class MessageDispatch extends EventTarget { this.scene.systems["hubs-systems"].soundEffectsSystem.playSoundOneShot(SOUND_QUACK); } break; - case "duck": - if (shouldUseNewLoader()) { - const avatarPov = document.querySelector("#avatar-pov-node").object3D; - const eid = createNetworkedEntity(APP.world, "duck"); - const obj = APP.world.eid2obj.get(eid); - obj.position.copy(avatarPov.localToWorld(new THREE.Vector3(0, 0, -1.5))); - obj.lookAt(avatarPov.getWorldPosition(new THREE.Vector3())); - } else { - spawnChatMessage(getAbsoluteHref(location.href, ducky)); - } - if (Math.random() < 0.01) { - this.scene.systems["hubs-systems"].soundEffectsSystem.playSoundOneShot(SOUND_SPECIAL_QUACK); - } else { - this.scene.systems["hubs-systems"].soundEffectsSystem.playSoundOneShot(SOUND_QUACK); - } - break; case "cube": { const avatarPov = document.querySelector("#avatar-pov-node").object3D; const eid = createNetworkedEntity(APP.world, "cube"); @@ -269,5 +261,9 @@ export default class MessageDispatch extends EventTarget { } break; } + + if (this.chatCommands.has(command)) { + this.chatCommands.get(command)(APP, args); + } }; } diff --git a/src/prefabs/duck.tsx b/src/prefabs/duck.tsx deleted file mode 100644 index 380053c7d0..0000000000 --- a/src/prefabs/duck.tsx +++ /dev/null @@ -1,52 +0,0 @@ -/** @jsx createElementEntity */ -import { createElementEntity, EntityDef } from "../utils/jsx-entity"; -import { COLLISION_LAYERS } from "../constants"; -import { FLOATY_OBJECT_FLAGS } from "../systems/floaty-object-system"; -import ducky from "../assets/models/DuckyMesh.glb"; -import { getAbsoluteHref } from "../utils/media-url-utils"; -import { Fit, Shape } from "../inflators/physics-shape"; - -export function DuckPrefab(): EntityDef { - return ( - - ); -} diff --git a/src/prefabs/prefabs.ts b/src/prefabs/prefabs.ts index 1584653614..915da578f6 100644 --- a/src/prefabs/prefabs.ts +++ b/src/prefabs/prefabs.ts @@ -1,14 +1,8 @@ -import { MediaLoaderParams } from "../inflators/media-loader"; import { CameraPrefab, CubeMediaFramePrefab } from "../prefabs/camera-tool"; import { MediaPrefab } from "../prefabs/media"; -import { EntityDef } from "../utils/jsx-entity"; -import { DuckPrefab } from "./duck"; +import { PrefabDefinitionT, PrefabNameT } from "../types"; -type CameraPrefabT = () => EntityDef; -type CubeMediaPrefabT = () => EntityDef; -type MediaPrefabT = (params: MediaLoaderParams) => EntityDef; - -type Permission = +export type Permission = | "spawn_camera" | "spawn_and_move_media" | "update_hub" @@ -22,15 +16,7 @@ type Permission = | "kick_users" | "mute_users"; -export type PrefabDefinition = { - permission: Permission; - template: CameraPrefabT | CubeMediaPrefabT | MediaPrefabT; -}; - -export type PrefabName = "camera" | "cube" | "media" | "duck"; - -export const prefabs = new Map(); +export const prefabs = new Map(); prefabs.set("camera", { permission: "spawn_camera", template: CameraPrefab }); prefabs.set("cube", { permission: "spawn_and_move_media", template: CubeMediaFramePrefab }); prefabs.set("media", { permission: "spawn_and_move_media", template: MediaPrefab }); -prefabs.set("duck", { permission: "spawn_and_move_media", template: DuckPrefab }); diff --git a/src/react-components/room/RoomSettingsSidebar.js b/src/react-components/room/RoomSettingsSidebar.js index fff9f2918e..31607cf861 100644 --- a/src/react-components/room/RoomSettingsSidebar.js +++ b/src/react-components/room/RoomSettingsSidebar.js @@ -16,6 +16,7 @@ import { BackButton } from "../input/BackButton"; import { SceneInfo } from "./RoomSidebar"; import { Column } from "../layout/Column"; import { InviteLinkInputField } from "./InviteLinkInputField"; +import { addons } from "../../addons"; export function RoomSettingsSidebar({ showBackButton, @@ -216,6 +217,16 @@ export function RoomSettingsSidebar({ {...register("user_data.hubs_use_bitecs_based_client")} /> + + {[...addons.entries()].map(([id, addon]) => ( + + ))} + diff --git a/src/react-components/ui-root.js b/src/react-components/ui-root.js index 2b07fcbdcc..52410e7f9c 100644 --- a/src/react-components/ui-root.js +++ b/src/react-components/ui-root.js @@ -153,6 +153,7 @@ class UIRoot extends Component { initialIsFavorited: PropTypes.bool, showSignInDialog: PropTypes.bool, showBitECSBasedClientRefreshPrompt: PropTypes.bool, + showAddonRefreshPrompt: PropTypes.bool, signInMessage: PropTypes.object, onContinueAfterSignIn: PropTypes.func, showSafariMicDialog: PropTypes.bool, @@ -1705,6 +1706,14 @@ class UIRoot extends Component { /> )} + {this.props.showAddonRefreshPrompt && ( +
+ +
+ )} ); diff --git a/src/schema.toml b/src/schema.toml index f36c360117..69f7aec198 100644 --- a/src/schema.toml +++ b/src/schema.toml @@ -49,6 +49,8 @@ features.show_newsletter_signup = { category = "features", type = "boolean", int features.change_hub_near_room_links = { category = "features", type = "boolean", internal = "true" } features.is_locked_down_demo_room = { category = "features", type = "string", internal = "true", description = "A comma separated list of hubIds to be designated as demo rooms with simplified UI." } +features.addons_config = { category = "features", type="longstring", name="Add-ons config JSON", description="Add-ons config JSON file" } + images.logo = { category = "images", type = "file", name = "Hub Logo", description = "Appears throughout your hub including lobby and loading screens." } images.logo_dark = { category = "images", type = "file", name = "Hub logo for dark mode", description = "The hub logo which appears for visitors who have dark mode enabled." } images.favicon = { category = "images", type = "file", name = "Favicon", description = "The favicon is the small picture which appears in the web browser tab." } @@ -92,5 +94,5 @@ links.promotion = { category = "links", type = "string", name = "Promotion Info" links.remixing = { category = "links", type = "string", name = "Remixing Info", description = "Link to info about remixing info and licensing."} links.model_collection = { category = "links", type = "string", name = "Model Collection", description = "Link to a collection of recommended models."} -auth.login_subject = { category = "auth", type="string", name="Magic Link Email Subject", description="Customize the email subject line for users logging in" } +login_subject = { category = "auth", type="string", name="Magic Link Email Subject", description="Customize the email subject line for users logging in" } auth.login_body = { category = "auth", type="longstring", name="Magic Link Email Body", description="Customize message. Add '{{ link }}' to insert the magic link, otherwise it will be appended at the end." } diff --git a/src/systems/hubs-systems.ts b/src/systems/hubs-systems.ts index 96a6e8c86f..06496c475f 100644 --- a/src/systems/hubs-systems.ts +++ b/src/systems/hubs-systems.ts @@ -76,7 +76,6 @@ import { textSystem } from "../bit-systems/text"; import { audioTargetSystem } from "../bit-systems/audio-target-system"; import { scenePreviewCameraSystem } from "../bit-systems/scene-preview-camera-system"; import { linearTransformSystem } from "../bit-systems/linear-transform"; -import { quackSystem } from "../bit-systems/quack"; import { mixerAnimatableSystem } from "../bit-systems/mixer-animatable"; import { loopAnimationSystem } from "../bit-systems/loop-animation"; import { linkSystem } from "../bit-systems/link-system"; @@ -93,6 +92,7 @@ import { linkedPDFSystem } from "../bit-systems/linked-pdf-system"; import { inspectSystem } from "../bit-systems/inspect-system"; import { snapMediaSystem } from "../bit-systems/snap-media-system"; import { scaleWhenGrabbedSystem } from "../bit-systems/scale-when-grabbed-system"; +import { SystemConfigT } from "../types"; declare global { interface Window { @@ -198,6 +198,10 @@ export function mainTick(xrFrame: XRFrame, renderer: WebGLRenderer, scene: Scene aframeSystems[systemNames[i]].tick(t, dt); } + APP.addon_systems.setup.forEach((systemConfig: SystemConfigT) => { + systemConfig.system(APP); + }); + networkReceiveSystem(world); onOwnershipLost(world); sceneLoadingSystem(world, hubsSystems.environmentSystem, hubsSystems.characterController); @@ -213,11 +217,19 @@ export function mainTick(xrFrame: XRFrame, renderer: WebGLRenderer, scene: Scene buttonSystems(world); sfxButtonSystem(world, aframeSystems["hubs-systems"].soundEffectsSystem); + APP.addon_systems.prePhysics.forEach((systemConfig: SystemConfigT) => { + systemConfig.system(APP); + }); + physicsCompatSystem(world, hubsSystems.physicsSystem); hubsSystems.physicsSystem.tick(dt); constraintsSystem(world, hubsSystems.physicsSystem); floatyObjectSystem(world); + APP.addon_systems.postPhysics.forEach((systemConfig: SystemConfigT) => { + systemConfig.system(APP); + }); + hoverableVisualsSystem(world); // We run this earlier in the frame so things have a chance to override properties run by animations @@ -281,7 +293,6 @@ export function mainTick(xrFrame: XRFrame, renderer: WebGLRenderer, scene: Scene hubsSystems.nameTagSystem.tick(); simpleWaterSystem(world); linearTransformSystem(world); - quackSystem(world); followInFovSystem(world); linkedMediaSystem(world); linkedVideoSystem(world); @@ -320,13 +331,30 @@ export function mainTick(xrFrame: XRFrame, renderer: WebGLRenderer, scene: Scene scene.updateMatrixWorld(); + APP.addon_systems.matricesUpdate.forEach((systemConfig: SystemConfigT) => { + systemConfig.system(APP); + }); + renderer.info.reset(); if (APP.fx.composer) { + APP.addon_systems.postProcessing.forEach((systemConfig: SystemConfigT) => { + systemConfig.system(APP); + }); APP.fx.composer.render(); } else { + APP.addon_systems.beforeRender.forEach((systemConfig: SystemConfigT) => { + systemConfig.system(APP); + }); renderer.render(scene, camera); + APP.addon_systems.afterRender.forEach((systemConfig: SystemConfigT) => { + systemConfig.system(APP); + }); } // tock()s on components and system will fire here. (As well as any other time render() is called without unbinding onAfterRender) // TODO inline invoking tocks instead of using onAfterRender registered in a-scene + + APP.addon_systems.tearDown.forEach((systemConfig: SystemConfigT) => { + systemConfig.system(APP); + }); } diff --git a/src/systems/sound-effects-system.js b/src/systems/sound-effects-system.js index 906594955d..b615dbb68c 100644 --- a/src/systems/sound-effects-system.js +++ b/src/systems/sound-effects-system.js @@ -54,6 +54,17 @@ function decodeAudioData(audioContext, arrayBuffer) { }); } +function load(system, url) { + let audioBufferPromise = system.loading.get(url); + if (!audioBufferPromise) { + audioBufferPromise = fetch(url) + .then(r => r.arrayBuffer()) + .then(arrayBuffer => decodeAudioData(system.audioContext, arrayBuffer)); + system.loading.set(url, audioBufferPromise); + } + return audioBufferPromise; +} + export class SoundEffectsSystem { constructor(scene) { this.pendingAudioSourceNodes = []; @@ -92,20 +103,10 @@ export class SoundEffectsSystem { [SOUND_SPAWN_EMOJI, URL_SPAWN_EMOJI], [SOUND_SPEAKER_TONE, URL_SPEAKER_TONE] ]; - const loading = new Map(); - const load = url => { - let audioBufferPromise = loading.get(url); - if (!audioBufferPromise) { - audioBufferPromise = fetch(url) - .then(r => r.arrayBuffer()) - .then(arrayBuffer => decodeAudioData(this.audioContext, arrayBuffer)); - loading.set(url, audioBufferPromise); - } - return audioBufferPromise; - }; + this.loading = new Map(); this.sounds = new Map(); soundsAndUrls.map(([sound, url]) => { - load(url).then(audioBuffer => { + load(this, url).then(audioBuffer => { this.sounds.set(sound, audioBuffer); }); }); @@ -122,6 +123,20 @@ export class SoundEffectsSystem { }); } + registerSound(url) { + return new Promise((resolve, reject) => { + load(this, url) + .then(audioBuffer => { + soundEnum++; + this.sounds.set(soundEnum, audioBuffer); + resolve({ id: soundEnum, url }); + }) + .catch(() => { + reject(); + }); + }); + } + enqueueSound(sound, loop) { if (this.isDisabled) return null; const audioBuffer = this.sounds.get(sound); diff --git a/src/systems/userinput/userinput.js b/src/systems/userinput/userinput.js index 7ed593a746..4a9d0e492f 100644 --- a/src/systems/userinput/userinput.js +++ b/src/systems/userinput/userinput.js @@ -44,6 +44,9 @@ import { gamepadBindings } from "./bindings/generic-gamepad"; import { getAvailableVREntryTypes, VR_DEVICE_AVAILABILITY } from "../../utils/vr-caps-detect"; import { hackyMobileSafariTest } from "../../utils/detect-touchscreen"; import { ArrayBackedSet } from "./array-backed-set"; +import { addSetsToBindings } from "./bindings/utils"; +import { InputDeviceE } from "../../types"; +import deepmerge from "deepmerge"; function arrayContentsDiffer(a, b) { if (a.length !== b.length) return true; @@ -191,6 +194,25 @@ function computeExecutionStrategy(sortedBindings, masks, activeSets) { return { actives, masked }; } +const DeviceToBindingsMapping = { + [InputDeviceE.Cardboard]: cardboardUserBindings, + [InputDeviceE.Daydream]: daydreamUserBindings, + [InputDeviceE.Gamepad]: gamepadBindings, + [InputDeviceE.KeyboardMouse]: keyboardMouseUserBindings, + [InputDeviceE.OculusGo]: oculusGoUserBindings, + [InputDeviceE.OculusTouch]: oculusTouchUserBindings, + [InputDeviceE.TouchScreen]: touchscreenUserBindings, + [InputDeviceE.Vive]: viveUserBindings, + [InputDeviceE.WebXR]: webXRUserBindings, + [InputDeviceE.WindowsMixedReality]: wmrUserBindings, + [InputDeviceE.XboxController]: xboxControllerUserBindings, + [InputDeviceE.GearVR]: gearVRControllerUserBindings, + [InputDeviceE.ViveCosmos]: viveCosmosUserBindings, + [InputDeviceE.ViveFocusPlus]: viveFocusPlusUserBindings, + [InputDeviceE.ViveWand]: viveWandUserBindings, + [InputDeviceE.ValveIndex]: indexUserBindings +}; + AFRAME.registerSystem("userinput", { get(path) { if (!this.frame) return; @@ -560,5 +582,20 @@ AFRAME.registerSystem("userinput", { this.prevSortedBindings = this.sortedBindings; this.maybeToggleXboxMapping(); + }, + registerPaths(newPaths) { + for (const path of newPaths) { + if (path.value in paths[path.type]) { + throw Error(`Path ${path.key} already registered`); + } + paths[path.type][path.value] = `/${path.type}/${path.value}`; + } + }, + registerBindings(device, bindings) { + bindings = addSetsToBindings(bindings); + for (const key in bindings) { + DeviceToBindingsMapping[device][key] = deepmerge(DeviceToBindingsMapping[device][key], bindings[key]); + } + this.registeredMappingsChanged = true; } }); diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000000..d5ecb3a251 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,172 @@ +import { AScene, HubsSystems } from "aframe"; +import { App, HubsWorld } from "./app"; +import { Permission } from "./prefabs/prefabs"; +import { EntityDef } from "./utils/jsx-entity"; +import { NetworkSchema } from "./utils/network-schemas"; +import { EntityID } from "./utils/networking-types"; +import { IComponent } from "bitecs"; + +export enum SystemOrderE { + Setup = 0, + PrePhysics = 100, + PostPhysics = 200, + MatricesUpdate = 300, + BeforeRender = 400, + AfterRender = 500, + PostProcessing = 600, + TearDown = 700 +} + +export type CoreSystemKeyT = keyof AScene["systems"]; +export type HubsSystemKeyT = keyof HubsSystems; +export type SystemKeyT = CoreSystemKeyT | HubsSystemKeyT; + +export enum SystemsE { + PhysicsSystem = "physicsSystem", + AudioSystem = "audioSystem", + SoundEffectsSystem = "soundEffectsSystem", + CameraSystem = "cameraSystem", + CharacterControllerSystem = "characterController", + WaypointSystem = "waypointSystem", + UserInputSystem = "userinput", + NavMesh = "nav" +} + +export interface SystemT { + (app: App): void; +} + +export type ComponentDataT = { + [key: string]: any; +}; + +export interface InflatorT { + (world: HubsWorld, eid: EntityID, componentProps?: ComponentDataT): EntityID; +} + +export interface InflatorParamT { + id: string; + inflator: InflatorT; +} + +export type InflatorConfigT = { + common?: InflatorParamT; + jsx?: InflatorParamT; + gltf?: InflatorParamT; +}; + +export enum PermissionE { + SPAWN_CAMERA = "spawn_camera", + SPAWN_AND_MOVE_MEDIA = "spawn_and_move_media", + UPDATE_HUB = "update_hub", + PIN_OBJECTS = "pin_objects", + SPAWN_EMOJI = "spawn_emoji", + AMPLIFY_AUDIO = "amplify_audio", + FLY = "fly", + VOICE_CHAT = "voice_chat", + SPAWN_DRAWING = "spawn_drawing", + TWEET = "tweet", + KICK_USERS = "kick_users", + MUTE_USERS = "mute_users" +} + +export type PrefabTemplateFn = (params: ComponentDataT) => EntityDef; +export type PermissionT = Permission; +export type PrefabNameT = string; +export type PrefabDefinitionT = { + permission: Permission; + template: PrefabTemplateFn; +}; +export interface PrefabConfigT { + id: PrefabNameT; + config: PrefabDefinitionT; +} + +export type NetworkSchemaT = NetworkSchema; +export interface NetworkSchemaConfigT { + component: IComponent; + schema: NetworkSchemaT; +} +export type SystemConfigT = { system: SystemT; order: number }; + +export type ChatCommandCallbackFn = (app: App, args: string[]) => void; +export interface ChatCommandConfigT { + id: string; + command: ChatCommandCallbackFn; +} + +export type SoundDefT = { + id: number; + url: string; +}; + +export enum InputSetsE { + global = "global", + inputFocused = "inputFocused", + rightCursorHoveringOnPen = "rightCursorHoveringOnPen", + rightCursorHoveringOnCamera = "rightCursorHoveringOnCamera", + rightCursorHoveringOnInteractable = "rightCursorHoveringOnInteractable", + rightCursorHoveringOnUI = "rightCursorHoveringOnUI", + rightCursorHoveringOnVideo = "rightCursorHoveringOnVideo", + rightCursorHoveringOnNothing = "rightCursorHoveringOnNothing", + rightCursorHoldingPen = "rightCursorHoldingPen", + rightCursorHoldingCamera = "rightCursorHoldingCamera", + rightCursorHoldingInteractable = "rightCursorHoldingInteractable", + rightCursorHoldingUI = "rightCursorHoldingUI", + rightCursorHoldingNothing = "rightCursorHoldingNothing", + leftCursorHoveringOnPen = "leftCursorHoveringOnPen", + leftCursorHoveringOnCamera = "leftCursorHoveringOnCamera", + leftCursorHoveringOnInteractable = "leftCursorHoveringOnInteractable", + leftCursorHoveringOnUI = "leftCursorHoveringOnUI", + leftCursorHoveringOnVideo = "leftCursorHoveringOnVideo", + leftCursorHoveringOnNothing = "leftCursorHoveringOnNothing", + leftCursorHoldingPen = "leftCursorHoldingPen", + leftCursorHoldingCamera = "leftCursorHoldingCamera", + leftCursorHoldingInteractable = "leftCursorHoldingInteractable", + leftCursorHoldingUI = "leftCursorHoldingUI", + leftCursorHoldingNothing = "leftCursorHoldingNothing", + rightHandTeleporting = "rightHandTeleporting", + rightHandHoveringOnPen = "rightHandHoveringOnPen", + rightHandHoveringOnCamera = "rightHandHoveringOnCamera", + rightHandHoveringOnInteractable = "rightHandHoveringOnInteractable", + rightHandHoveringOnNothing = "rightHandHoveringOnNothing", + rightHandHoldingPen = "rightHandHoldingPen", + rightHandHoldingCamera = "rightHandHoldingCamera", + rightHandHoldingInteractable = "rightHandHoldingInteractable", + leftHandTeleporting = "leftHandTeleporting", + leftHandHoveringOnPen = "leftHandHoveringOnPen", + leftHandHoveringOnCamera = "leftHandHoveringOnCamera", + leftHandHoveringOnInteractable = "leftHandHoveringOnInteractable", + leftHandHoldingPen = "leftHandHoldingPen", + leftHandHoldingCamera = "leftHandHoldingCamera", + leftHandHoldingInteractable = "leftHandHoldingInteractable", + leftHandHoveringOnNothing = "leftHandHoveringOnNothing", + debugUserInput = "debugUserInput", + inspecting = "inspecting" +} + +export enum InputPathsE { + noop = "noop", + actions = "actions", + haptics = "haptics", + device = "device" +} + +export enum InputDeviceE { + Cardboard, + Daydream, + Gamepad, + KeyboardMouse, + OculusGo, + OculusTouch, + TouchScreen, + Vive, + WebXR, + WindowsMixedReality, + XboxController, + GearVR, + ViveCosmos, + ViveFocusPlus, + ViveWand, + ValveIndex +} diff --git a/src/utils/create-networked-entity.ts b/src/utils/create-networked-entity.ts index 60ebeb05a8..1a9c817d7c 100644 --- a/src/utils/create-networked-entity.ts +++ b/src/utils/create-networked-entity.ts @@ -3,18 +3,19 @@ import { HubsWorld } from "../app"; import { Networked } from "../bit-components"; import { createMessageDatas } from "../bit-systems/networking"; import { MediaLoaderParams } from "../inflators/media-loader"; -import { PrefabName, prefabs } from "../prefabs/prefabs"; +import { prefabs } from "../prefabs/prefabs"; import { renderAsEntity } from "../utils/jsx-entity"; import { hasPermissionToSpawn } from "../utils/permissions"; import { takeOwnership } from "../utils/take-ownership"; import { setNetworkedDataWithRoot } from "./assign-network-ids"; import type { ClientID, InitialData, NetworkID } from "./networking-types"; +import { PrefabNameT } from "../types"; export function createNetworkedMedia(world: HubsWorld, initialData: MediaLoaderParams) { return createNetworkedEntity(world, "media", initialData); } -export function createNetworkedEntity(world: HubsWorld, prefabName: PrefabName, initialData: InitialData) { +export function createNetworkedEntity(world: HubsWorld, prefabName: PrefabNameT, initialData: InitialData) { if (!hasPermissionToSpawn(NAF.clientId, prefabName)) throw new Error(`You do not have permission to spawn ${prefabName}`); const nid = NAF.utils.createNetworkId(); @@ -25,7 +26,7 @@ export function createNetworkedEntity(world: HubsWorld, prefabName: PrefabName, export function renderAsNetworkedEntity( world: HubsWorld, - prefabName: PrefabName, + prefabName: PrefabNameT, initialData: InitialData, nid: NetworkID, creator: ClientID diff --git a/src/utils/jsx-entity.ts b/src/utils/jsx-entity.ts index 15e9e9c9f8..65ed903429 100644 --- a/src/utils/jsx-entity.ts +++ b/src/utils/jsx-entity.ts @@ -36,7 +36,6 @@ import { Billboard, MaterialTag, VideoTextureSource, - Quack, MixerAnimatableInitialize, Inspectable, ObjectMenu, @@ -105,6 +104,7 @@ import { inflateObjectMenuTarget, ObjectMenuTargetParams } from "../inflators/ob import { inflateObjectMenuTransform, ObjectMenuTransformParams } from "../inflators/object-menu-transform"; import { inflatePlane, PlaneParams } from "../inflators/plane"; import { FollowInFovParams, inflateFollowInFov } from "../inflators/follow-in-fov"; +import { ComponentDataT } from "../types"; preload( new Promise(resolve => { @@ -146,7 +146,7 @@ export type Attrs = { }; export type EntityDef = { - components: JSXComponentData; + components: ComponentDataT; attrs: Attrs; children: EntityDef[]; ref?: Ref; @@ -156,10 +156,10 @@ function isReservedAttr(attr: string): attr is keyof Attrs { return reservedAttrs.includes(attr); } -type ComponentFn = string | ((attrs: Attrs & JSXComponentData, children?: EntityDef[]) => EntityDef); +type ComponentFn = string | ((attrs: Attrs & ComponentDataT, children?: EntityDef[]) => EntityDef); export function createElementEntity( tag: "entity" | ComponentFn, - attrs: Attrs & JSXComponentData, + attrs: Attrs & ComponentDataT, ...children: EntityDef[] ): EntityDef { attrs = attrs || {}; @@ -167,7 +167,7 @@ export function createElementEntity( return tag(attrs, children); } else if (tag === "entity") { const outputAttrs: Attrs = {}; - const components: JSXComponentData & Attrs = {}; + const components: ComponentDataT & Attrs = {}; let ref = undefined; for (const attr in attrs) { @@ -177,7 +177,7 @@ export function createElementEntity( ref = attrs[attr]; } else { // if jsx transformed the attr into attr: true, change it to attr: {}. - const c = attr as keyof JSXComponentData; + const c = attr as keyof ComponentDataT; components[c] = attrs[c] === true ? {} : attrs[c]; } } @@ -224,7 +224,7 @@ export function addMaterialComponent(world: HubsWorld, eid: number, mat: Materia return eid; } -const createDefaultInflator = (C: Component, defaults = {}): InflatorFn => { +export const createDefaultInflator = (C: Component, defaults = {}): InflatorFn => { return (world, eid, componentProps) => { componentProps = Object.assign({}, defaults, componentProps); addComponent(world, C, eid, true); @@ -306,7 +306,6 @@ export interface JSXComponentData extends ComponentData { deletable?: true; makeKinematicOnRelease?: true; destroyAtExtremeDistance?: true; - quack?: true; // @TODO Define all the anys networked?: any; @@ -416,7 +415,7 @@ export interface GLTFComponentData extends ComponentData { declare global { namespace createElementEntity.JSX { interface IntrinsicElements { - entity: JSXComponentData & + entity: ComponentDataT & Attrs & { children?: IntrinsicElements[]; }; @@ -428,7 +427,7 @@ declare global { } } -export const commonInflators: Required<{ [K in keyof ComponentData]: InflatorFn }> = { +export const commonInflators: Required<{ [K in keyof ComponentDataT]: InflatorFn }> = { grabbable: inflateGrabbable, billboard: createDefaultInflator(Billboard), @@ -445,7 +444,7 @@ export const commonInflators: Required<{ [K in keyof ComponentData]: InflatorFn text: inflateText }; -const jsxInflators: Required<{ [K in keyof JSXComponentData]: InflatorFn }> = { +export const jsxInflators: Required<{ [K in keyof ComponentDataT]: InflatorFn }> = { ...commonInflators, cursorRaycastable: createDefaultInflator(CursorRaycastable), remoteHoverTarget: createDefaultInflator(RemoteHoverTarget), @@ -484,7 +483,6 @@ const jsxInflators: Required<{ [K in keyof JSXComponentData]: InflatorFn }> = { waypointPreview: createDefaultInflator(WaypointPreview), pdf: inflatePDF, mediaLoader: inflateMediaLoader, - quack: createDefaultInflator(Quack), mixerAnimatable: createDefaultInflator(MixerAnimatableInitialize), loopAnimation: inflateLoopAnimationInitialize, inspectable: createDefaultInflator(Inspectable), @@ -500,7 +498,7 @@ const jsxInflators: Required<{ [K in keyof JSXComponentData]: InflatorFn }> = { plane: inflatePlane }; -export const gltfInflators: Required<{ [K in keyof GLTFComponentData]: InflatorFn }> = { +export const gltfInflators: Required<{ [K in keyof ComponentDataT]: InflatorFn }> = { ...commonInflators, pdf: inflatePDFLoader, // Temporarily reuse video loader for audio because of @@ -536,11 +534,11 @@ export const gltfInflators: Required<{ [K in keyof GLTFComponentData]: InflatorF mediaLink: inflateMediaLink }; -function jsxInflatorExists(name: string): name is keyof JSXComponentData { +function jsxInflatorExists(name: string) { return Object.prototype.hasOwnProperty.call(jsxInflators, name); } -export function gltfInflatorExists(name: string): name is keyof GLTFComponentData { +export function gltfInflatorExists(name: string) { return Object.prototype.hasOwnProperty.call(gltfInflators, name); } diff --git a/src/utils/networking-types.ts b/src/utils/networking-types.ts index 766f090173..cd46457e16 100644 --- a/src/utils/networking-types.ts +++ b/src/utils/networking-types.ts @@ -1,10 +1,10 @@ import { MediaLoaderParams } from "../inflators/media-loader"; -import { PrefabName } from "../prefabs/prefabs"; +import { PrefabNameT } from "../types"; export type EntityID = number; export type InitialData = MediaLoaderParams | any; export interface CreateMessageData { - prefabName: PrefabName; + prefabName: PrefabNameT; initialData: InitialData; } export type ClientID = string; @@ -13,7 +13,7 @@ export type StringID = number; export type CreateMessage = { version: 1; networkId: NetworkID; - prefabName: PrefabName; + prefabName: PrefabNameT; initialData: InitialData; }; export interface CursorBuffer extends Array { diff --git a/src/utils/permissions.ts b/src/utils/permissions.ts index 521c28cd6f..b37e9cc902 100644 --- a/src/utils/permissions.ts +++ b/src/utils/permissions.ts @@ -1,7 +1,8 @@ -import { PrefabName, prefabs } from "../prefabs/prefabs"; +import { prefabs } from "../prefabs/prefabs"; +import { PrefabNameT } from "../types"; import type { ClientID } from "./networking-types"; -export function hasPermissionToSpawn(creator: ClientID, prefabName: PrefabName) { +export function hasPermissionToSpawn(creator: ClientID, prefabName: PrefabNameT) { if (creator === "reticulum") return true; const perm = prefabs.get(prefabName)!.permission; return APP.hubChannel!.userCan(creator, perm); diff --git a/tsconfig.json b/tsconfig.json index cbe1e73df6..60088b491a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,13 +12,7 @@ "isolatedModules": true, "esModuleInterop": true, "skipLibCheck": true, - "typeRoots": [ - "./node_modules/@types", - "./types" - ] + "typeRoots": ["./node_modules/@types", "./types"] }, - "include": [ - "types/", - "src/" - ] + "include": ["types/", "src/"] } diff --git a/types/assets.d.ts b/types/assets.d.ts index dd05f0f49d..6fb7d55dc1 100644 --- a/types/assets.d.ts +++ b/types/assets.d.ts @@ -17,3 +17,8 @@ declare module "*.glb" { const url: string; export default url; } + +declare module "*.mp3" { + const src: string; + export default url; +} diff --git a/webpack.config.js b/webpack.config.js index 974c0bd98b..c52b924df0 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -285,6 +285,9 @@ module.exports = async (env, argv) => { // .replaceAll("connect-src", "connect-src https://example.com"); } + const addonsConfigFilePath = "./addons.json"; + const addonsConfig = JSON.parse(fs.readFileSync(addonsConfigFilePath, "utf-8")); + const internalHostname = process.env.INTERNAL_HOSTNAME || "hubs.local"; return { cache: { @@ -303,7 +306,9 @@ module.exports = async (env, argv) => { "three/examples/js/libs/basis/basis_transcoder.js": basisTranscoderPath, "three/examples/js/libs/draco/gltf/draco_wasm_wrapper.js": dracoWasmWrapperPath, "three/examples/js/libs/basis/basis_transcoder.wasm": basisWasmPath, - "three/examples/js/libs/draco/gltf/draco_decoder.wasm": dracoWasmPath + "three/examples/js/libs/draco/gltf/draco_decoder.wasm": dracoWasmPath, + + hubs$: path.resolve(__dirname, "./src/hubs.js") }, // Allows using symlinks in node_modules symlinks: false, @@ -320,7 +325,7 @@ module.exports = async (env, argv) => { entry: { support: path.join(__dirname, "src", "support.js"), index: path.join(__dirname, "src", "index.js"), - hub: path.join(__dirname, "src", "hub.js"), + hub: [path.join(__dirname, "src", "hub.js"), ...addonsConfig.addons], scene: path.join(__dirname, "src", "scene.js"), avatar: path.join(__dirname, "src", "avatar.js"), link: path.join(__dirname, "src", "link.js"),