Update RunnerCanvas to use cameraSubmerged for underwater overlay and add tests
This commit is contained in:
@@ -52,5 +52,5 @@ export function RunnerCanvas({ runtimeScene, projectAssets, loadedModelAssets, l
|
||||
useEffect(() => {
|
||||
hostRef.current?.setNavigationMode(navigationMode);
|
||||
}, [navigationMode]);
|
||||
return (_jsxs("div", { ref: containerRef, className: `runner-canvas ${navigationMode === "firstPerson" && firstPersonTelemetry?.inWaterVolume ? "runner-canvas--underwater" : ""}`, "data-testid": "runner-shell", "aria-label": "Built-in scene runner", style: createWorldBackgroundStyle(runtimeScene.world.background, runtimeScene.world.background.mode === "image" ? loadedImageAssets[runtimeScene.world.background.assetId]?.sourceUrl ?? null : null), children: [navigationMode === "firstPerson" && firstPersonTelemetry?.inWaterVolume ? _jsx("div", { className: "runner-canvas__underwater", "aria-hidden": "true" }) : null, navigationMode === "firstPerson" ? _jsx("div", { className: "runner-canvas__crosshair", "aria-hidden": "true" }) : null, navigationMode === "firstPerson" && interactionPrompt !== null ? (_jsxs("div", { className: "runner-canvas__prompt", "data-testid": "runner-interaction-prompt", role: "status", "aria-live": "polite", children: [_jsx("div", { className: "runner-canvas__prompt-badge", children: "Click" }), _jsx("div", { className: "runner-canvas__prompt-text", "data-testid": "runner-interaction-prompt-text", children: interactionPrompt.prompt }), _jsxs("div", { className: "runner-canvas__prompt-meta", "data-testid": "runner-interaction-prompt-meta", children: [interactionPrompt.distance.toFixed(1), "m away \u00B7 ", interactionPrompt.range.toFixed(1), "m range"] })] })) : null, runnerMessage === null ? null : (_jsxs("div", { className: "runner-canvas__fallback", role: "status", children: [_jsx("div", { className: "runner-canvas__fallback-title", children: "Runner Unavailable" }), _jsx("div", { children: runnerMessage })] }))] }));
|
||||
return (_jsxs("div", { ref: containerRef, className: `runner-canvas ${navigationMode === "firstPerson" && firstPersonTelemetry?.cameraSubmerged ? "runner-canvas--underwater" : ""}`, "data-testid": "runner-shell", "aria-label": "Built-in scene runner", style: createWorldBackgroundStyle(runtimeScene.world.background, runtimeScene.world.background.mode === "image" ? loadedImageAssets[runtimeScene.world.background.assetId]?.sourceUrl ?? null : null), children: [navigationMode === "firstPerson" && firstPersonTelemetry?.cameraSubmerged ? _jsx("div", { className: "runner-canvas__underwater", "aria-hidden": "true" }) : null, navigationMode === "firstPerson" ? _jsx("div", { className: "runner-canvas__crosshair", "aria-hidden": "true" }) : null, navigationMode === "firstPerson" && interactionPrompt !== null ? (_jsxs("div", { className: "runner-canvas__prompt", "data-testid": "runner-interaction-prompt", role: "status", "aria-live": "polite", children: [_jsx("div", { className: "runner-canvas__prompt-badge", children: "Click" }), _jsx("div", { className: "runner-canvas__prompt-text", "data-testid": "runner-interaction-prompt-text", children: interactionPrompt.prompt }), _jsxs("div", { className: "runner-canvas__prompt-meta", "data-testid": "runner-interaction-prompt-meta", children: [interactionPrompt.distance.toFixed(1), "m away \u00B7 ", interactionPrompt.range.toFixed(1), "m range"] })] })) : null, runnerMessage === null ? null : (_jsxs("div", { className: "runner-canvas__fallback", role: "status", children: [_jsx("div", { className: "runner-canvas__fallback-title", children: "Runner Unavailable" }), _jsx("div", { children: runnerMessage })] }))] }));
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ export function RunnerCanvas({
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`runner-canvas ${navigationMode === "firstPerson" && firstPersonTelemetry?.inWaterVolume ? "runner-canvas--underwater" : ""}`}
|
||||
className={`runner-canvas ${navigationMode === "firstPerson" && firstPersonTelemetry?.cameraSubmerged ? "runner-canvas--underwater" : ""}`}
|
||||
data-testid="runner-shell"
|
||||
aria-label="Built-in scene runner"
|
||||
style={createWorldBackgroundStyle(
|
||||
@@ -100,7 +100,7 @@ export function RunnerCanvas({
|
||||
runtimeScene.world.background.mode === "image" ? loadedImageAssets[runtimeScene.world.background.assetId]?.sourceUrl ?? null : null
|
||||
)}
|
||||
>
|
||||
{navigationMode === "firstPerson" && firstPersonTelemetry?.inWaterVolume ? <div className="runner-canvas__underwater" aria-hidden="true" /> : null}
|
||||
{navigationMode === "firstPerson" && firstPersonTelemetry?.cameraSubmerged ? <div className="runner-canvas__underwater" aria-hidden="true" /> : null}
|
||||
{navigationMode === "firstPerson" ? <div className="runner-canvas__crosshair" aria-hidden="true" /> : null}
|
||||
{navigationMode === "firstPerson" && interactionPrompt !== null ? (
|
||||
<div className="runner-canvas__prompt" data-testid="runner-interaction-prompt" role="status" aria-live="polite">
|
||||
|
||||
@@ -176,14 +176,17 @@ export class FirstPersonNavigationController {
|
||||
if (this.context === null) {
|
||||
return;
|
||||
}
|
||||
const eyePosition = toEyePosition(this.feetPosition, getFirstPersonPlayerEyeHeight(this.context.getRuntimeScene().playerCollider));
|
||||
const cameraVolumeState = this.context.resolvePlayerVolumeState(eyePosition);
|
||||
this.context.setFirstPersonTelemetry({
|
||||
feetPosition: {
|
||||
...this.feetPosition
|
||||
},
|
||||
eyePosition: toEyePosition(this.feetPosition, getFirstPersonPlayerEyeHeight(this.context.getRuntimeScene().playerCollider)),
|
||||
eyePosition,
|
||||
grounded: this.grounded,
|
||||
locomotionState: this.locomotionState,
|
||||
inWaterVolume: this.inWaterVolume,
|
||||
cameraSubmerged: cameraVolumeState.inWater,
|
||||
inFogVolume: this.inFogVolume,
|
||||
pointerLocked: this.pointerLocked,
|
||||
spawn: this.context.getRuntimeScene().spawn
|
||||
|
||||
@@ -209,14 +209,18 @@ export class FirstPersonNavigationController implements NavigationController {
|
||||
return;
|
||||
}
|
||||
|
||||
const eyePosition = toEyePosition(this.feetPosition, getFirstPersonPlayerEyeHeight(this.context.getRuntimeScene().playerCollider));
|
||||
const cameraVolumeState = this.context.resolvePlayerVolumeState(eyePosition);
|
||||
|
||||
this.context.setFirstPersonTelemetry({
|
||||
feetPosition: {
|
||||
...this.feetPosition
|
||||
},
|
||||
eyePosition: toEyePosition(this.feetPosition, getFirstPersonPlayerEyeHeight(this.context.getRuntimeScene().playerCollider)),
|
||||
eyePosition,
|
||||
grounded: this.grounded,
|
||||
locomotionState: this.locomotionState,
|
||||
inWaterVolume: this.inWaterVolume,
|
||||
cameraSubmerged: cameraVolumeState.inWater,
|
||||
inFogVolume: this.inFogVolume,
|
||||
pointerLocked: this.pointerLocked,
|
||||
spawn: this.context.getRuntimeScene().spawn
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface FirstPersonTelemetry {
|
||||
grounded: boolean;
|
||||
locomotionState: RuntimeLocomotionState;
|
||||
inWaterVolume: boolean;
|
||||
cameraSubmerged: boolean;
|
||||
inFogVolume: boolean;
|
||||
pointerLocked: boolean;
|
||||
spawn: RuntimeSpawnPoint;
|
||||
|
||||
117
tests/unit/runner-canvas.test.tsx
Normal file
117
tests/unit/runner-canvas.test.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { act } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createEmptySceneDocument } from "../../src/document/scene-document";
|
||||
import { RunnerCanvas } from "../../src/runner-web/RunnerCanvas";
|
||||
import type { FirstPersonTelemetry } from "../../src/runtime-three/navigation-controller";
|
||||
import { buildRuntimeSceneFromDocument } from "../../src/runtime-three/runtime-scene-build";
|
||||
|
||||
const { MockRuntimeHost, runtimeHostInstances } = vi.hoisted(() => {
|
||||
const runtimeHostInstances: Array<{
|
||||
mount: ReturnType<typeof vi.fn>;
|
||||
dispose: ReturnType<typeof vi.fn>;
|
||||
loadScene: ReturnType<typeof vi.fn>;
|
||||
updateAssets: ReturnType<typeof vi.fn>;
|
||||
setNavigationMode: ReturnType<typeof vi.fn>;
|
||||
setRuntimeMessageHandler: ReturnType<typeof vi.fn>;
|
||||
setFirstPersonTelemetryHandler: ReturnType<typeof vi.fn>;
|
||||
setInteractionPromptHandler: ReturnType<typeof vi.fn>;
|
||||
}> = [];
|
||||
|
||||
class MockRuntimeHost {
|
||||
mount = vi.fn();
|
||||
dispose = vi.fn();
|
||||
loadScene = vi.fn();
|
||||
updateAssets = vi.fn();
|
||||
setNavigationMode = vi.fn();
|
||||
setRuntimeMessageHandler = vi.fn();
|
||||
setFirstPersonTelemetryHandler = vi.fn();
|
||||
setInteractionPromptHandler = vi.fn();
|
||||
|
||||
constructor() {
|
||||
runtimeHostInstances.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
MockRuntimeHost,
|
||||
runtimeHostInstances
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../src/runtime-three/runtime-host", () => ({
|
||||
RuntimeHost: MockRuntimeHost
|
||||
}));
|
||||
|
||||
describe("RunnerCanvas", () => {
|
||||
beforeEach(() => {
|
||||
runtimeHostInstances.length = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("only shows the underwater overlay when the camera is submerged", async () => {
|
||||
const runtimeScene = buildRuntimeSceneFromDocument(createEmptySceneDocument());
|
||||
const onTelemetryChange = vi.fn();
|
||||
|
||||
render(
|
||||
<RunnerCanvas
|
||||
runtimeScene={runtimeScene}
|
||||
projectAssets={{}}
|
||||
loadedModelAssets={{}}
|
||||
loadedImageAssets={{}}
|
||||
loadedAudioAssets={{}}
|
||||
navigationMode="firstPerson"
|
||||
onRuntimeMessageChange={vi.fn()}
|
||||
onFirstPersonTelemetryChange={onTelemetryChange}
|
||||
onInteractionPromptChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(runtimeHostInstances).toHaveLength(1);
|
||||
expect(runtimeHostInstances[0]?.setFirstPersonTelemetryHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const publishTelemetry = runtimeHostInstances[0]?.setFirstPersonTelemetryHandler.mock.calls[0]?.[0] as ((telemetry: FirstPersonTelemetry | null) => void) | undefined;
|
||||
|
||||
expect(publishTelemetry).toBeDefined();
|
||||
|
||||
act(() => {
|
||||
publishTelemetry?.({
|
||||
feetPosition: { x: 0, y: 0, z: 0 },
|
||||
eyePosition: { x: 0, y: 1.7, z: 0 },
|
||||
grounded: false,
|
||||
locomotionState: "swimming",
|
||||
inWaterVolume: true,
|
||||
cameraSubmerged: false,
|
||||
inFogVolume: false,
|
||||
pointerLocked: true,
|
||||
spawn: runtimeScene.spawn
|
||||
});
|
||||
});
|
||||
|
||||
expect(screen.queryByLabelText("Built-in scene runner")?.className).not.toContain("runner-canvas--underwater");
|
||||
expect(document.querySelector(".runner-canvas__underwater")).toBeNull();
|
||||
|
||||
act(() => {
|
||||
publishTelemetry?.({
|
||||
feetPosition: { x: 0, y: 0, z: 0 },
|
||||
eyePosition: { x: 0, y: 0.4, z: 0 },
|
||||
grounded: false,
|
||||
locomotionState: "swimming",
|
||||
inWaterVolume: true,
|
||||
cameraSubmerged: true,
|
||||
inFogVolume: false,
|
||||
pointerLocked: true,
|
||||
spawn: runtimeScene.spawn
|
||||
});
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText("Built-in scene runner").className).toContain("runner-canvas--underwater");
|
||||
expect(document.querySelector(".runner-canvas__underwater")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user