diff --git a/src/app/App.tsx b/src/app/App.tsx
index bc4b89e7..c6f28f65 100644
--- a/src/app/App.tsx
+++ b/src/app/App.tsx
@@ -6910,21 +6910,15 @@ export function App({ store, initialStatusMessage }: AppProps) {
Interaction
- {activeNavigationMode === "firstPerson"
- ? runtimeInteractionPrompt === null
- ? "No target"
- : "Ready"
- : "Not available"}
+ {runtimeInteractionPrompt === null ? "No target" : "Ready"}
- {activeNavigationMode === "firstPerson"
- ? runtimeInteractionPrompt === null
- ? "Aim at an authored Interactable or Scene Exit and click when a prompt appears."
- : `Click "${runtimeInteractionPrompt.prompt}" within ${runtimeInteractionPrompt.range.toFixed(1)}m.`
- : "Switch to First Person to use click interactions."}
+ {runtimeInteractionPrompt === null
+ ? "Aim at an authored Interactable or Scene Exit and click when a prompt appears."
+ : `Click "${runtimeInteractionPrompt.prompt}" within ${runtimeInteractionPrompt.range.toFixed(1)}m.`}
@@ -6937,7 +6931,7 @@ export function App({ store, initialStatusMessage }: AppProps) {
{runtimeGlobalState.lastSceneTransition.toSceneName}
)}
- {activeNavigationMode === "firstPerson" ? (
+ {
- ) : null}
+ }
diff --git a/src/runner-web/RunnerCanvas.tsx b/src/runner-web/RunnerCanvas.tsx
index 65a0f442..56e30abd 100644
--- a/src/runner-web/RunnerCanvas.tsx
+++ b/src/runner-web/RunnerCanvas.tsx
@@ -225,9 +225,7 @@ export function RunnerCanvas({
{runnerReady && navigationMode === "firstPerson" ? (
) : null}
- {runnerReady &&
- navigationMode === "firstPerson" &&
- interactionPrompt !== null ? (
+ {runnerReady && interactionPrompt !== null ? (
{
if (
!this.sceneReady ||
this.runtimeScene === null ||
- this.activeController !== this.firstPersonController ||
+ (this.activeController !== this.firstPersonController &&
+ this.activeController !== this.thirdPersonController) ||
this.currentInteractionPrompt === null
) {
return;
diff --git a/src/runtime-three/runtime-interaction-system.ts b/src/runtime-three/runtime-interaction-system.ts
index 793cc896..7ccbcee6 100644
--- a/src/runtime-three/runtime-interaction-system.ts
+++ b/src/runtime-three/runtime-interaction-system.ts
@@ -178,8 +178,13 @@ export class RuntimeInteractionSystem {
}
}
- resolveClickInteractionPrompt(viewOrigin: Vec3, viewDirection: Vec3, runtimeScene: RuntimeSceneDefinition): RuntimeInteractionPrompt | null {
- const normalizedViewDirection = normalizeVec3(viewDirection);
+ resolveClickInteractionPrompt(
+ interactionOrigin: Vec3,
+ rayOrigin: Vec3,
+ rayDirection: Vec3,
+ runtimeScene: RuntimeSceneDefinition
+ ): RuntimeInteractionPrompt | null {
+ const normalizedViewDirection = normalizeVec3(rayDirection);
if (normalizedViewDirection === null) {
return null;
@@ -193,14 +198,17 @@ export class RuntimeInteractionSystem {
continue;
}
- const distance = distanceBetweenVec3(viewOrigin, interactable.position);
+ const distance = distanceBetweenVec3(
+ interactionOrigin,
+ interactable.position
+ );
if (distance > interactable.radius) {
continue;
}
const hitDistance = raySphereHitDistance(
- viewOrigin,
+ rayOrigin,
normalizedViewDirection,
interactable.position,
getInteractableTargetRadius(interactable)
@@ -227,14 +235,14 @@ export class RuntimeInteractionSystem {
continue;
}
- const distance = distanceBetweenVec3(viewOrigin, sceneExit.position);
+ const distance = distanceBetweenVec3(interactionOrigin, sceneExit.position);
if (distance > sceneExit.radius) {
continue;
}
const hitDistance = raySphereHitDistance(
- viewOrigin,
+ rayOrigin,
normalizedViewDirection,
sceneExit.position,
Math.min(DEFAULT_INTERACTABLE_TARGET_RADIUS, sceneExit.radius)
diff --git a/tests/domain/runtime-interaction-system.test.ts b/tests/domain/runtime-interaction-system.test.ts
index bac08cdd..bdb1e9fe 100644
--- a/tests/domain/runtime-interaction-system.test.ts
+++ b/tests/domain/runtime-interaction-system.test.ts
@@ -346,6 +346,11 @@ describe("RuntimeInteractionSystem", () => {
y: 1.6,
z: 0
},
+ {
+ x: 0,
+ y: 1.6,
+ z: 0
+ },
{
x: 0,
y: 0,
@@ -362,6 +367,11 @@ describe("RuntimeInteractionSystem", () => {
expect(
interactionSystem.resolveClickInteractionPrompt(
+ {
+ x: 0,
+ y: 1.6,
+ z: 0
+ },
{
x: 0,
y: 1.6,
@@ -377,6 +387,46 @@ describe("RuntimeInteractionSystem", () => {
).toBeNull();
});
+ it("uses the player eye for interaction range while aiming with a third-person camera ray", () => {
+ const runtimeScene = createRuntimeSceneFixture();
+ runtimeScene.interactionLinks = [
+ createTeleportPlayerInteractionLink({
+ id: "link-click-teleport",
+ sourceEntityId: "entity-interactable-console",
+ trigger: "click",
+ targetEntityId: "entity-teleport-main"
+ })
+ ];
+
+ const interactionSystem = new RuntimeInteractionSystem();
+
+ expect(
+ interactionSystem.resolveClickInteractionPrompt(
+ {
+ x: 0,
+ y: 1.6,
+ z: 0
+ },
+ {
+ x: 0,
+ y: 1.6,
+ z: -2
+ },
+ {
+ x: 0,
+ y: 0,
+ z: 1
+ },
+ runtimeScene
+ )
+ ).toEqual({
+ sourceEntityId: "entity-interactable-console",
+ prompt: "Use Console",
+ distance: expect.any(Number),
+ range: 2
+ });
+ });
+
it("dispatches click actions for the targeted Interactable", () => {
const runtimeScene = createRuntimeSceneFixture();
runtimeScene.interactionLinks = [
@@ -483,6 +533,11 @@ describe("RuntimeInteractionSystem", () => {
y: 1.6,
z: 0
},
+ {
+ x: 0,
+ y: 1.6,
+ z: 0
+ },
{
x: 0,
y: 0,
diff --git a/tests/e2e/runtime-click-interaction.e2e.ts b/tests/e2e/runtime-click-interaction.e2e.ts
index c342f231..8d4d9a48 100644
--- a/tests/e2e/runtime-click-interaction.e2e.ts
+++ b/tests/e2e/runtime-click-interaction.e2e.ts
@@ -2,70 +2,84 @@ import { expect, test } from "@playwright/test";
import { clickViewport, setViewportCreationPreview } from "./viewport-test-helpers";
-test("Interactable click prompt can teleport the player in run mode", async ({ page }) => {
- const pageErrors: string[] = [];
- const consoleErrors: string[] = [];
+for (const navigationMode of [
+ {
+ buttonLabel: "First Person",
+ name: "first-person"
+ },
+ {
+ buttonLabel: "Third Person",
+ name: "third-person"
+ }
+] as const) {
+ test(`Interactable click prompt can teleport the player in ${navigationMode.name} run mode`, async ({ page }) => {
+ const pageErrors: string[] = [];
+ const consoleErrors: string[] = [];
- page.on("pageerror", (error) => {
- pageErrors.push(error.message);
+ page.on("pageerror", (error) => {
+ pageErrors.push(error.message);
+ });
+
+ page.on("console", (message) => {
+ if (message.type() === "error") {
+ consoleErrors.push(message.text());
+ }
+ });
+
+ await page.goto("/");
+ await page.evaluate((storageKey) => {
+ window.localStorage.removeItem(storageKey);
+ }, "webeditor3d.scene-document-draft");
+ await page.reload();
+
+ await page.getByTestId("outliner-add-button").click();
+ await page.getByTestId("add-menu-entities").click();
+ await page.getByTestId("add-menu-player-start").click();
+ await setViewportCreationPreview(page, "topLeft", { kind: "entity", entityKind: "playerStart", audioAssetId: null }, { x: 0, y: 0, z: 0 });
+ await clickViewport(page, "topLeft");
+ await page.getByTestId("outliner-add-button").click();
+ await page.getByTestId("add-menu-entities").click();
+ await page.getByTestId("add-menu-interactable").click();
+ await setViewportCreationPreview(page, "topLeft", { kind: "entity", entityKind: "interactable", audioAssetId: null }, { x: 0, y: 0, z: 0 });
+ await clickViewport(page, "topLeft");
+ await page.getByTestId("interactable-position-y").fill("1");
+ await page.getByTestId("interactable-position-y").press("Tab");
+ await page.getByTestId("interactable-position-z").fill("1");
+ await page.getByTestId("interactable-position-z").press("Tab");
+ await page.getByTestId("interactable-radius").fill("4");
+ await page.getByTestId("interactable-radius").press("Tab");
+ await page.getByTestId("interactable-prompt").fill("Use Console");
+ await page.getByTestId("interactable-prompt").press("Tab");
+
+ await page.getByTestId("outliner-add-button").click();
+ await page.getByTestId("add-menu-entities").click();
+ await page.getByTestId("add-menu-teleport-target").click();
+ await setViewportCreationPreview(page, "topLeft", { kind: "entity", entityKind: "teleportTarget", audioAssetId: null }, { x: 0, y: 0, z: 0 });
+ await clickViewport(page, "topLeft");
+ await page.getByTestId("teleportTarget-position-x").fill("6");
+ await page.getByTestId("teleportTarget-position-x").press("Tab");
+
+ await page
+ .locator('[data-testid^="outliner-entity-"]')
+ .filter({ hasText: "Interactable" })
+ .first()
+ .click();
+
+ await page.getByTestId("add-interactable-teleport-link").click();
+ await page.getByRole("button", { name: navigationMode.buttonLabel }).first().click();
+ await page.getByTestId("enter-run-mode").click();
+
+ await expect(page.getByTestId("runner-shell")).toBeVisible();
+ await expect(page.getByTestId("runner-interaction-state")).toContainText(
+ "Ready"
+ );
+ await expect(page.getByTestId("runner-interaction-prompt")).toBeVisible();
+ await expect(page.getByTestId("runner-interaction-prompt-text")).toContainText("Use Console");
+
+ await page.locator('[data-testid="runner-shell"] canvas').click();
+ await expect(page.getByTestId("runner-player-position")).toContainText("6.00,");
+
+ expect(pageErrors).toEqual([]);
+ expect(consoleErrors).toEqual([]);
});
-
- page.on("console", (message) => {
- if (message.type() === "error") {
- consoleErrors.push(message.text());
- }
- });
-
- await page.goto("/");
- await page.evaluate((storageKey) => {
- window.localStorage.removeItem(storageKey);
- }, "webeditor3d.scene-document-draft");
- await page.reload();
-
- await page.getByTestId("outliner-add-button").click();
- await page.getByTestId("add-menu-entities").click();
- await page.getByTestId("add-menu-player-start").click();
- await setViewportCreationPreview(page, "topLeft", { kind: "entity", entityKind: "playerStart", audioAssetId: null }, { x: 0, y: 0, z: 0 });
- await clickViewport(page, "topLeft");
- await page.getByTestId("outliner-add-button").click();
- await page.getByTestId("add-menu-entities").click();
- await page.getByTestId("add-menu-interactable").click();
- await setViewportCreationPreview(page, "topLeft", { kind: "entity", entityKind: "interactable", audioAssetId: null }, { x: 0, y: 0, z: 0 });
- await clickViewport(page, "topLeft");
- await page.getByTestId("interactable-position-y").fill("1");
- await page.getByTestId("interactable-position-y").press("Tab");
- await page.getByTestId("interactable-position-z").fill("1");
- await page.getByTestId("interactable-position-z").press("Tab");
- await page.getByTestId("interactable-radius").fill("4");
- await page.getByTestId("interactable-radius").press("Tab");
- await page.getByTestId("interactable-prompt").fill("Use Console");
- await page.getByTestId("interactable-prompt").press("Tab");
-
- await page.getByTestId("outliner-add-button").click();
- await page.getByTestId("add-menu-entities").click();
- await page.getByTestId("add-menu-teleport-target").click();
- await setViewportCreationPreview(page, "topLeft", { kind: "entity", entityKind: "teleportTarget", audioAssetId: null }, { x: 0, y: 0, z: 0 });
- await clickViewport(page, "topLeft");
- await page.getByTestId("teleportTarget-position-x").fill("6");
- await page.getByTestId("teleportTarget-position-x").press("Tab");
-
- await page
- .locator('[data-testid^="outliner-entity-"]')
- .filter({ hasText: "Interactable" })
- .first()
- .click();
-
- await page.getByTestId("add-interactable-teleport-link").click();
- await page.getByRole("button", { name: "First Person" }).first().click();
- await page.getByTestId("enter-run-mode").click();
-
- await expect(page.getByTestId("runner-shell")).toBeVisible();
- await expect(page.getByTestId("runner-interaction-prompt")).toBeVisible();
- await expect(page.getByTestId("runner-interaction-prompt-text")).toContainText("Use Console");
-
- await page.locator('[data-testid="runner-shell"] canvas').click();
- await expect(page.getByTestId("runner-player-position")).toContainText("6.00,");
-
- expect(pageErrors).toEqual([]);
- expect(consoleErrors).toEqual([]);
-});
+}
diff --git a/tests/unit/runner-canvas.test.tsx b/tests/unit/runner-canvas.test.tsx
index 13456642..e3dc18ba 100644
--- a/tests/unit/runner-canvas.test.tsx
+++ b/tests/unit/runner-canvas.test.tsx
@@ -329,7 +329,7 @@ describe("RunnerCanvas", () => {
).toHaveBeenCalledTimes(2);
});
- it("keeps first-person HUD affordances hidden in third-person mode", async () => {
+ it("keeps the crosshair hidden in third-person mode while still showing interaction prompts", async () => {
const runtimeScene = buildRuntimeSceneFromDocument(
createEmptySceneDocument()
);
@@ -362,17 +362,33 @@ describe("RunnerCanvas", () => {
?.setSceneLoadStateHandler.mock.calls[0]?.[0] as
| ((state: RuntimeSceneLoadState) => void)
| undefined;
+ const publishInteractionPrompt = runtimeHostInstances[0]
+ ?.setInteractionPromptHandler.mock.calls[0]?.[0] as
+ | ((prompt: {
+ sourceEntityId: string;
+ prompt: string;
+ distance: number;
+ range: number;
+ } | null) => void)
+ | undefined;
act(() => {
publishSceneLoadState?.({
status: "ready",
message: null
});
+ publishInteractionPrompt?.({
+ sourceEntityId: "entity-interactable-console",
+ prompt: "Use Console",
+ distance: 1.2,
+ range: 2
+ });
});
expect(document.querySelector(".runner-canvas__crosshair")).toBeNull();
- expect(
- screen.queryByTestId("runner-interaction-prompt")
- ).toBeNull();
+ expect(screen.getByTestId("runner-interaction-prompt")).toBeVisible();
+ expect(screen.getByTestId("runner-interaction-prompt-text")).toHaveTextContent(
+ "Use Console"
+ );
});
});