Skip to content

Commit

Permalink
Improve Link menu
Browse files Browse the repository at this point in the history
  • Loading branch information
keianhzo committed Jun 2, 2023
1 parent 02abe4e commit fd92672
Show file tree
Hide file tree
Showing 6 changed files with 185 additions and 89 deletions.
6 changes: 4 additions & 2 deletions src/bit-components.js
Original file line number Diff line number Diff line change
Expand Up @@ -255,11 +255,13 @@ export const ObjectMenu = defineComponent({
// TODO: Store this data elsewhere, since only one or two will ever exist.
export const LinkHoverMenu = defineComponent({
targetObjectRef: Types.eid,
linkButtonRef: Types.eid
linkButtonRef: Types.eid,
clearTargetTimer: Types.f64
});
export const LinkHoverMenuItem = defineComponent();
export const Link = defineComponent({
url: Types.ui32
url: Types.ui32,
type: Types.ui8
});
Link.url[$isStringType] = true;
// TODO: Store this data elsewhere, since only one or two will ever exist.
Expand Down
214 changes: 133 additions & 81 deletions src/bit-systems/link-hover-menu.ts
Original file line number Diff line number Diff line change
@@ -1,98 +1,150 @@
import { defineQuery, enterQuery, entityExists } from "bitecs";
import { defineQuery, entityExists } from "bitecs";
import type { HubsWorld } from "../app";
import { Interacted, Link, LinkHoverMenu, LinkHoverMenuItem, HoveredRemoteRight, TextTag } from "../bit-components";
import { anyEntityWith, findChildWithComponent } from "../utils/bit-utils";
import { isHubsRoomUrl, isLocalHubsAvatarUrl, isLocalHubsSceneUrl, isLocalHubsUrl } from "../utils/media-url-utils";
import { Link, LinkHoverMenu, HoveredRemoteRight, TextTag, Interacted, LinkHoverMenuItem } from "../bit-components";
import { findAncestorWithComponent, findChildWithComponent } from "../utils/bit-utils";
import { hubIdFromUrl } from "../utils/media-url-utils";
import { Text as TroikaText } from "troika-three-text";
import { handleExitTo2DInterstitial } from "../utils/vr-interstitial";
import { changeHub } from "../change-hub";
import { EntityID } from "../utils/networking-types";
import { setMatrixWorld } from "../utils/three-utils";
import { LinkType } from "../inflators/link";

const NULL_EID = 0;

const hoveredQuery = defineQuery([HoveredRemoteRight, Link]);
const hoveredEnterQuery = enterQuery(hoveredQuery);
const hoveredMenuItemQuery = defineQuery([HoveredRemoteRight, LinkHoverMenuItem]);
const menuQuery = defineQuery([LinkHoverMenu]);
const hoveredQuery = defineQuery([HoveredRemoteRight]);
const clickedMenuItemQuery = defineQuery([Interacted, LinkHoverMenuItem]);

export function linkHoverMenuSystem(world: HubsWorld) {
// Assumes always only single LinkHoverMenu entity exists for now.
// TODO: Take into account for more than one for VR
const menuEid = anyEntityWith(world, LinkHoverMenu)!;
const menuObject = world.eid2obj.get(menuEid)!;
function updateLinkMenuTarget(world: HubsWorld, menu: EntityID, sceneIsFrozen: boolean) {
if (LinkHoverMenu.targetObjectRef[menu] && !entityExists(world, LinkHoverMenu.targetObjectRef[menu])) {
// Clear the invalid entity reference. (The link entity was removed).
LinkHoverMenu.targetObjectRef[menu] = 0;
return;
}

if (sceneIsFrozen) {
LinkHoverMenu.targetObjectRef[menu] = 0;
return;
}

const hovered = hoveredQuery(world);
const target = hovered.map(eid => findAncestorWithComponent(world, Link, eid))[0] || 0;
if (target) {
LinkHoverMenu.targetObjectRef[menu] = target;
LinkHoverMenu.clearTargetTimer[menu] = world.time.elapsed + 1000;
return;
}

if (hovered.some(eid => findAncestorWithComponent(world, LinkHoverMenu, eid))) {
LinkHoverMenu.clearTargetTimer[menu] = world.time.elapsed + 1000;
return;
}

if (world.time.elapsed > LinkHoverMenu.clearTargetTimer[menu]) {
LinkHoverMenu.targetObjectRef[menu] = 0;
return;
}
}

async function handleLinkClick(world: HubsWorld, button: EntityID) {
const exitImmersive = async () => await handleExitTo2DInterstitial(false, () => {}, true);

const menu = findAncestorWithComponent(world, LinkHoverMenu, button)!;
const linkEid = LinkHoverMenu.targetObjectRef[menu];
const src = APP.getString(Link.url[linkEid])!;
const url = new URL(src);
const linkType = Link.type[linkEid];
switch (linkType) {
case LinkType.LINK:
await exitImmersive();
window.open(src);
break;
case LinkType.AVATAR:
const avatarId = new URL(src).pathname.split("/").pop();
window.APP.store.update({ profile: { avatarId } });
APP.scene!.emit("avatar_updated");
break;
case LinkType.SCENE:
APP.scene!.emit("scene_media_selected", src);
break;
case LinkType.WAYPOINT:
// move to waypoint w/o writing to history
window.history.replaceState(null, "", window.location.href.split("#")[0] + url.hash);
break;
case LinkType.LOCAL_ROOM:
const waypoint = url.hash && url.hash.substring(1);
// move to new room without page load or entry flow
const hubId = hubIdFromUrl(url);
changeHub(hubId, true, waypoint);
break;
case LinkType.EXTERNAL_ROOM:
await exitImmersive();
location.href = src;
break;
}
}

// Save Link object eid in LinkHoverMenu when hovered.
hoveredEnterQuery(world).forEach(async (eid: number) => {
LinkHoverMenu.targetObjectRef[menuEid] = eid;
const targetObject = world.eid2obj.get(eid)!;
targetObject.add(menuObject);
function moveToTarget(world: HubsWorld, menu: EntityID) {
const linkEid = LinkHoverMenu.targetObjectRef[menu];
const targetObject = world.eid2obj.get(linkEid)!;
targetObject.updateMatrices();
const menuObject = world.eid2obj.get(menu)!;
setMatrixWorld(menuObject, targetObject.matrixWorld);
}

const buttonRef = LinkHoverMenu.linkButtonRef[menuEid];
let text = findChildWithComponent(world, TextTag, buttonRef)!;
let textObj = world.eid2obj.get(text)! as TroikaText;
const mayChangeScene = (APP.scene?.systems as any).permissions.canOrWillIfCreator("update_hub");
const src = APP.getString(Link.url[eid])!;
let hubId;
let label = "open link";
if (await isLocalHubsAvatarUrl(src)) {
function updateButtonText(world: HubsWorld, menu: EntityID, button: EntityID) {
const text = findChildWithComponent(world, TextTag, button)!;
const textObj = world.eid2obj.get(text)! as TroikaText;
const linkEid = LinkHoverMenu.targetObjectRef[menu];
const linkType = Link.type[linkEid];
let label = "open link";
switch (linkType) {
case LinkType.AVATAR:
label = "use avatar";
} else if ((await isLocalHubsSceneUrl(src)) && mayChangeScene) {
break;
case LinkType.SCENE:
label = "use scene";
} else if ((hubId = await isHubsRoomUrl(src))) {
const url = new URL(src);
if (url.hash && APP.hub!.hub_id === hubId) {
label = "go to";
} else {
label = "visit room";
}
}
textObj.text = label;
});
break;
case LinkType.WAYPOINT:
label = "go to";
break;
case LinkType.LOCAL_ROOM:
case LinkType.EXTERNAL_ROOM:
label = "visit room";
break;
}
textObj.text = label;
}

// Check if the cursor it hovered on Link object or Link menu button object.
const hovered = hoveredQuery(world).length > 0 || hoveredMenuItemQuery(world).length > 0;
function flushToObject3Ds(world: HubsWorld, menu: EntityID, frozen: boolean) {
const target = LinkHoverMenu.targetObjectRef[menu];
const visible = !!(target && !frozen);

const linkEid = LinkHoverMenu.targetObjectRef[menuEid];
if (hovered && entityExists(world, linkEid)) {
// Hovered then make the menu visible and handle clicks if needed.
menuObject.visible = true;
clickedMenuItemQuery(world).forEach(async eid => {
const linkEid = LinkHoverMenu.targetObjectRef[menuEid];
const src = APP.getString(Link.url[linkEid])!;
const exitImmersive = async () => await handleExitTo2DInterstitial(false, () => {}, true);
const obj = world.eid2obj.get(menu)!;
obj.visible = visible;

const mayChangeScene = (APP.scene?.systems as any).permissions.canOrWillIfCreator("update_hub");
let hubId;
if (await isLocalHubsAvatarUrl(src)) {
const avatarId = new URL(src).pathname.split("/").pop();
window.APP.store.update({ profile: { avatarId } });
APP.scene!.emit("avatar_updated");
} else if ((await isLocalHubsSceneUrl(src)) && mayChangeScene) {
APP.scene!.emit("scene_media_selected", src);
} else if ((hubId = await isHubsRoomUrl(src))) {
const url = new URL(src);
if (url.hash && APP.hub!.hub_id === hubId) {
// move to waypoint w/o writing to history
window.history.replaceState(null, "", window.location.href.split("#")[0] + url.hash);
} else if (await isLocalHubsUrl(src)) {
const waypoint = url.hash && url.hash.substring(1);
// move to new room without page load or entry flow
changeHub(hubId, true, waypoint);
} else {
await exitImmersive();
location.href = src;
}
} else {
await exitImmersive();
window.open(src);
}
});
} else {
// Not hovered or target object has already been deleted
// then make the menu invisible and forget the Link object.
menuObject.visible = false;
if (menuObject.parent !== null) {
menuObject.parent.remove(menuObject);
const linkButtonRef = LinkHoverMenu.linkButtonRef[menu];
const buttonObj = world.eid2obj.get(linkButtonRef)!;
// Parent visibility doesn't block raycasting, so we must set each button to be invisible
// TODO: Ensure that children of invisible entities aren't raycastable
if (visible) {
if (visible !== buttonObj.visible) {
updateButtonText(world, menu, linkButtonRef);
buttonObj.visible = true;
}
LinkHoverMenu.targetObjectRef[menuEid] = NULL_EID;
} else {
buttonObj.visible = false;
}
}

export function linkHoverMenuSystem(world: HubsWorld, sceneIsFrozen: boolean) {
// Assumes always only single LinkHoverMenu entity exists for now.
// TODO: Take into account for more than one for VR
menuQuery(world).forEach(menu => {
updateLinkMenuTarget(world, menu, sceneIsFrozen);
if (LinkHoverMenu.targetObjectRef[menu]) {
moveToTarget(world, menu);
clickedMenuItemQuery(world).forEach(eid => handleLinkClick(world, eid));
}
flushToObject3Ds(world, menu, sceneIsFrozen);
});
}
30 changes: 30 additions & 0 deletions src/bit-systems/link-system.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { defineQuery, enterQuery } from "bitecs";
import type { HubsWorld } from "../app";
import { Link } from "../bit-components";
import { isHubsRoomUrl, isLocalHubsAvatarUrl, isLocalHubsSceneUrl, isLocalHubsUrl } from "../utils/media-url-utils";
import { LinkType } from "../inflators/link";

const linkQuery = defineQuery([Link]);
const linkEnterQuery = enterQuery(linkQuery);

export function linkSystem(world: HubsWorld) {
linkEnterQuery(world).forEach(async eid => {
const mayChangeScene = (APP.scene?.systems as any).permissions.canOrWillIfCreator("update_hub");
const src = APP.getString(Link.url[eid])!;
let hubId;
if (await isLocalHubsAvatarUrl(src)) {
Link.type[eid] = LinkType.AVATAR;
} else if ((await isLocalHubsSceneUrl(src)) && mayChangeScene) {
Link.type[eid] = LinkType.SCENE;
} else if ((hubId = await isHubsRoomUrl(src))) {
const url = new URL(src);
if (url.hash && APP.hub!.hub_id === hubId) {
Link.type[eid] = LinkType.WAYPOINT;
} else if (await isLocalHubsUrl(this.src)) {
Link.type[eid] = LinkType.LOCAL_ROOM;
} else {
Link.type[eid] = LinkType.EXTERNAL_ROOM;
}
}
});
}
11 changes: 11 additions & 0 deletions src/inflators/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,23 @@ import { addComponent } from "bitecs";
import { Link } from "../bit-components";
import { HubsWorld } from "../app";

export enum LinkType {
LINK = 0,
AVATAR = 1,
SCENE = 2,
LOCAL_ROOM = 3,
EXTERNAL_ROOM = 4,
WAYPOINT = 5
}

export type LinkParams = {
href: string;
type?: LinkType;
};

export function inflateLink(world: HubsWorld, eid: number, params: LinkParams): number {
addComponent(world, Link, eid);
Link.url[eid] = APP.getSid(params.href);
Link.type[eid] = params.type || LinkType.LINK;
return eid;
}
6 changes: 4 additions & 2 deletions src/systems/hubs-systems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ 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";

declare global {
interface Window {
Expand Down Expand Up @@ -251,7 +252,8 @@ export function mainTick(xrFrame: XRFrame, renderer: WebGLRenderer, scene: Scene
videoMenuSystem(world, aframeSystems.userinput);
videoSystem(world, hubsSystems.audioSystem);
pdfMenuSystem(world, sceneEl.is("frozen"));
linkHoverMenuSystem(world);
linkSystem(world);
linkHoverMenuSystem(world, sceneEl.is("frozen"));
pdfSystem(world);
mediaFramesSystem(world, hubsSystems.physicsSystem);
hubsSystems.audioZonesSystem.tick(hubsSystems.el);
Expand All @@ -263,7 +265,7 @@ export function mainTick(xrFrame: XRFrame, renderer: WebGLRenderer, scene: Scene
simpleWaterSystem(world);
linearTransformSystem(world);
quackSystem(world);

mixerAnimatableSystem(world);
loopAnimationSystem(world);

Expand Down
7 changes: 3 additions & 4 deletions src/utils/media-url-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -196,11 +196,10 @@ export const isLocalHubsSceneUrl = async url => (await isHubsSceneUrl(url)) && (
export const isHubsAvatarUrl = async url => (await isHubsServer(url)) && hubsAvatarRegex.test(url);
export const isLocalHubsAvatarUrl = async url => (await isHubsAvatarUrl(url)) && (await isLocalHubsUrl(url));

export const hubIdFromUrl = url => url.match(hubsRoomRegex)?.groups.id;

export const isHubsRoomUrl = async url =>
(await isHubsServer(url)) &&
!(await isHubsAvatarUrl(url)) &&
!(await isHubsSceneUrl(url)) &&
url.match(hubsRoomRegex)?.groups.id;
(await isHubsServer(url)) && !(await isHubsAvatarUrl(url)) && !(await isHubsSceneUrl(url)) && hubIdFromUrl(url);

export const isHubsDestinationUrl = async url =>
(await isHubsServer(url)) && ((await isHubsSceneUrl(url)) || (await isHubsRoomUrl(url)));
Expand Down

0 comments on commit fd92672

Please sign in to comment.