Add various runtime-three components
This commit is contained in:
232
src/runtime-three/first-person-navigation-controller.ts
Normal file
232
src/runtime-three/first-person-navigation-controller.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { Euler, Vector3 } from "three";
|
||||
|
||||
import type { Vec3 } from "../core/vector";
|
||||
|
||||
import { FIRST_PERSON_PLAYER_SHAPE, resolveFirstPersonMotion } from "./player-collision";
|
||||
import type { NavigationController, RuntimeControllerContext } from "./navigation-controller";
|
||||
|
||||
const LOOK_SENSITIVITY = 0.0022;
|
||||
const MOVE_SPEED = 4.5;
|
||||
const GRAVITY = 22;
|
||||
const MAX_PITCH_RADIANS = Math.PI * 0.48;
|
||||
|
||||
function clampPitch(pitchRadians: number): number {
|
||||
return Math.max(-MAX_PITCH_RADIANS, Math.min(MAX_PITCH_RADIANS, pitchRadians));
|
||||
}
|
||||
|
||||
function toEyePosition(feetPosition: Vec3): Vec3 {
|
||||
return {
|
||||
x: feetPosition.x,
|
||||
y: feetPosition.y + FIRST_PERSON_PLAYER_SHAPE.eyeHeight,
|
||||
z: feetPosition.z
|
||||
};
|
||||
}
|
||||
|
||||
export class FirstPersonNavigationController implements NavigationController {
|
||||
readonly id = "firstPerson" as const;
|
||||
|
||||
private context: RuntimeControllerContext | null = null;
|
||||
private readonly pressedKeys = new Set<string>();
|
||||
private readonly cameraRotation = new Euler(0, 0, 0, "YXZ");
|
||||
private readonly forwardVector = new Vector3();
|
||||
private readonly rightVector = new Vector3();
|
||||
private feetPosition = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 0
|
||||
};
|
||||
private yawRadians = 0;
|
||||
private pitchRadians = 0;
|
||||
private verticalVelocity = 0;
|
||||
private grounded = false;
|
||||
private pointerLocked = false;
|
||||
private initializedFromSpawn = false;
|
||||
|
||||
activate(ctx: RuntimeControllerContext): void {
|
||||
this.context = ctx;
|
||||
|
||||
if (!this.initializedFromSpawn) {
|
||||
const spawn = ctx.getRuntimeScene().spawn;
|
||||
this.feetPosition = {
|
||||
...spawn.position
|
||||
};
|
||||
this.yawRadians = (spawn.yawDegrees * Math.PI) / 180;
|
||||
this.pitchRadians = 0;
|
||||
this.verticalVelocity = 0;
|
||||
this.grounded = false;
|
||||
this.initializedFromSpawn = true;
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", this.handleKeyDown);
|
||||
window.addEventListener("keyup", this.handleKeyUp);
|
||||
window.addEventListener("blur", this.handleBlur);
|
||||
document.addEventListener("mousemove", this.handleMouseMove);
|
||||
document.addEventListener("pointerlockchange", this.handlePointerLockChange);
|
||||
document.addEventListener("pointerlockerror", this.handlePointerLockError);
|
||||
ctx.domElement.addEventListener("pointerdown", this.handlePointerDown);
|
||||
|
||||
this.syncPointerLockState();
|
||||
this.updateCameraTransform();
|
||||
this.publishTelemetry();
|
||||
}
|
||||
|
||||
deactivate(ctx: RuntimeControllerContext): void {
|
||||
window.removeEventListener("keydown", this.handleKeyDown);
|
||||
window.removeEventListener("keyup", this.handleKeyUp);
|
||||
window.removeEventListener("blur", this.handleBlur);
|
||||
document.removeEventListener("mousemove", this.handleMouseMove);
|
||||
document.removeEventListener("pointerlockchange", this.handlePointerLockChange);
|
||||
document.removeEventListener("pointerlockerror", this.handlePointerLockError);
|
||||
ctx.domElement.removeEventListener("pointerdown", this.handlePointerDown);
|
||||
this.pressedKeys.clear();
|
||||
|
||||
if (document.pointerLockElement === ctx.domElement) {
|
||||
document.exitPointerLock();
|
||||
}
|
||||
|
||||
this.pointerLocked = false;
|
||||
ctx.setRuntimeMessage(null);
|
||||
ctx.setFirstPersonTelemetry(null);
|
||||
this.context = null;
|
||||
}
|
||||
|
||||
update(dt: number): void {
|
||||
if (this.context === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const inputX = (this.pressedKeys.has("KeyD") ? 1 : 0) - (this.pressedKeys.has("KeyA") ? 1 : 0);
|
||||
const inputZ = (this.pressedKeys.has("KeyW") ? 1 : 0) - (this.pressedKeys.has("KeyS") ? 1 : 0);
|
||||
const inputLength = Math.hypot(inputX, inputZ);
|
||||
|
||||
let horizontalX = 0;
|
||||
let horizontalZ = 0;
|
||||
|
||||
if (inputLength > 0) {
|
||||
const normalizedInputX = inputX / inputLength;
|
||||
const normalizedInputZ = inputZ / inputLength;
|
||||
const moveDistance = MOVE_SPEED * dt;
|
||||
|
||||
this.forwardVector.set(Math.sin(this.yawRadians), 0, Math.cos(this.yawRadians));
|
||||
this.rightVector.set(Math.cos(this.yawRadians), 0, -Math.sin(this.yawRadians));
|
||||
|
||||
horizontalX = (this.forwardVector.x * normalizedInputZ + this.rightVector.x * normalizedInputX) * moveDistance;
|
||||
horizontalZ = (this.forwardVector.z * normalizedInputZ + this.rightVector.z * normalizedInputX) * moveDistance;
|
||||
}
|
||||
|
||||
this.verticalVelocity -= GRAVITY * dt;
|
||||
|
||||
const resolvedMotion = resolveFirstPersonMotion(
|
||||
this.feetPosition,
|
||||
{
|
||||
x: horizontalX,
|
||||
y: this.verticalVelocity * dt,
|
||||
z: horizontalZ
|
||||
},
|
||||
FIRST_PERSON_PLAYER_SHAPE,
|
||||
this.context.getRuntimeScene().colliders
|
||||
);
|
||||
|
||||
this.feetPosition = resolvedMotion.feetPosition;
|
||||
this.grounded = resolvedMotion.grounded;
|
||||
|
||||
if (this.grounded && this.verticalVelocity < 0) {
|
||||
this.verticalVelocity = 0;
|
||||
}
|
||||
|
||||
this.updateCameraTransform();
|
||||
this.publishTelemetry();
|
||||
}
|
||||
|
||||
private updateCameraTransform() {
|
||||
if (this.context === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eyePosition = toEyePosition(this.feetPosition);
|
||||
this.cameraRotation.x = this.pitchRadians;
|
||||
this.cameraRotation.y = this.yawRadians;
|
||||
this.cameraRotation.z = 0;
|
||||
|
||||
this.context.camera.position.set(eyePosition.x, eyePosition.y, eyePosition.z);
|
||||
this.context.camera.rotation.copy(this.cameraRotation);
|
||||
}
|
||||
|
||||
private publishTelemetry() {
|
||||
if (this.context === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.context.setFirstPersonTelemetry({
|
||||
feetPosition: {
|
||||
...this.feetPosition
|
||||
},
|
||||
eyePosition: toEyePosition(this.feetPosition),
|
||||
grounded: this.grounded,
|
||||
pointerLocked: this.pointerLocked,
|
||||
spawn: this.context.getRuntimeScene().spawn
|
||||
});
|
||||
}
|
||||
|
||||
private syncPointerLockState() {
|
||||
if (this.context === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pointerLocked = document.pointerLockElement === this.context.domElement;
|
||||
this.pointerLocked = pointerLocked;
|
||||
this.context.setRuntimeMessage(
|
||||
pointerLocked
|
||||
? "Mouse look active. Press Escape to release the cursor or switch to Orbit Visitor."
|
||||
: "Click inside the runner viewport to capture mouse look. If pointer lock fails, switch to Orbit Visitor."
|
||||
);
|
||||
this.publishTelemetry();
|
||||
}
|
||||
|
||||
private handleKeyDown = (event: KeyboardEvent) => {
|
||||
this.pressedKeys.add(event.code);
|
||||
};
|
||||
|
||||
private handleKeyUp = (event: KeyboardEvent) => {
|
||||
this.pressedKeys.delete(event.code);
|
||||
};
|
||||
|
||||
private handleBlur = () => {
|
||||
this.pressedKeys.clear();
|
||||
};
|
||||
|
||||
private handleMouseMove = (event: MouseEvent) => {
|
||||
if (!this.pointerLocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.yawRadians -= event.movementX * LOOK_SENSITIVITY;
|
||||
this.pitchRadians = clampPitch(this.pitchRadians - event.movementY * LOOK_SENSITIVITY);
|
||||
};
|
||||
|
||||
private handlePointerLockChange = () => {
|
||||
this.syncPointerLockState();
|
||||
};
|
||||
|
||||
private handlePointerLockError = () => {
|
||||
this.context?.setRuntimeMessage(
|
||||
"Pointer lock was unavailable in this browser context. Orbit Visitor remains available as the non-FPS fallback."
|
||||
);
|
||||
};
|
||||
|
||||
private handlePointerDown = () => {
|
||||
if (this.context === null || document.pointerLockElement === this.context.domElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pointerLockResult = this.context.domElement.requestPointerLock();
|
||||
|
||||
if (pointerLockResult !== undefined && "catch" in pointerLockResult) {
|
||||
pointerLockResult.catch(() => {
|
||||
this.context?.setRuntimeMessage(
|
||||
"Pointer lock request was denied. Click again or use Orbit Visitor for non-locked navigation."
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
28
src/runtime-three/navigation-controller.ts
Normal file
28
src/runtime-three/navigation-controller.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { PerspectiveCamera } from "three";
|
||||
|
||||
import type { Vec3 } from "../core/vector";
|
||||
|
||||
import type { RuntimeNavigationMode, RuntimeSceneDefinition, RuntimeSpawnPoint } from "./runtime-scene-build";
|
||||
|
||||
export interface FirstPersonTelemetry {
|
||||
feetPosition: Vec3;
|
||||
eyePosition: Vec3;
|
||||
grounded: boolean;
|
||||
pointerLocked: boolean;
|
||||
spawn: RuntimeSpawnPoint;
|
||||
}
|
||||
|
||||
export interface RuntimeControllerContext {
|
||||
camera: PerspectiveCamera;
|
||||
domElement: HTMLCanvasElement;
|
||||
getRuntimeScene(): RuntimeSceneDefinition;
|
||||
setRuntimeMessage(message: string | null): void;
|
||||
setFirstPersonTelemetry(telemetry: FirstPersonTelemetry | null): void;
|
||||
}
|
||||
|
||||
export interface NavigationController {
|
||||
id: RuntimeNavigationMode;
|
||||
activate(ctx: RuntimeControllerContext): void;
|
||||
deactivate(ctx: RuntimeControllerContext): void;
|
||||
update(dt: number): void;
|
||||
}
|
||||
145
src/runtime-three/orbit-visitor-navigation-controller.ts
Normal file
145
src/runtime-three/orbit-visitor-navigation-controller.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { Vector3 } from "three";
|
||||
|
||||
import type { Vec3 } from "../core/vector";
|
||||
|
||||
import type { NavigationController, RuntimeControllerContext } from "./navigation-controller";
|
||||
|
||||
const MIN_DISTANCE = 2;
|
||||
const MAX_DISTANCE = 48;
|
||||
const MIN_PITCH = 0.15;
|
||||
const MAX_PITCH = Math.PI * 0.48;
|
||||
|
||||
function clampDistance(distance: number): number {
|
||||
return Math.max(MIN_DISTANCE, Math.min(MAX_DISTANCE, distance));
|
||||
}
|
||||
|
||||
function clampPitch(pitchRadians: number): number {
|
||||
return Math.max(MIN_PITCH, Math.min(MAX_PITCH, pitchRadians));
|
||||
}
|
||||
|
||||
function cloneVec3(vector: Vec3): Vec3 {
|
||||
return {
|
||||
x: vector.x,
|
||||
y: vector.y,
|
||||
z: vector.z
|
||||
};
|
||||
}
|
||||
|
||||
export class OrbitVisitorNavigationController implements NavigationController {
|
||||
readonly id = "orbitVisitor" as const;
|
||||
|
||||
private context: RuntimeControllerContext | null = null;
|
||||
private readonly lookAtVector = new Vector3();
|
||||
private target: Vec3 = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 0
|
||||
};
|
||||
private distance = 8;
|
||||
private yawRadians = Math.PI * 0.25;
|
||||
private pitchRadians = Math.PI * 0.35;
|
||||
private dragging = false;
|
||||
private lastPointerClientX = 0;
|
||||
private lastPointerClientY = 0;
|
||||
private initializedFromScene = false;
|
||||
|
||||
activate(ctx: RuntimeControllerContext): void {
|
||||
this.context = ctx;
|
||||
|
||||
if (!this.initializedFromScene) {
|
||||
const runtimeScene = ctx.getRuntimeScene();
|
||||
const focusPoint = runtimeScene.playerStart?.position ?? runtimeScene.sceneBounds?.center ?? this.target;
|
||||
const focusDistance = runtimeScene.sceneBounds
|
||||
? Math.max(runtimeScene.sceneBounds.size.x, runtimeScene.sceneBounds.size.y, runtimeScene.sceneBounds.size.z) * 1.1
|
||||
: 8;
|
||||
|
||||
this.target = cloneVec3(focusPoint);
|
||||
this.distance = clampDistance(focusDistance);
|
||||
this.initializedFromScene = true;
|
||||
}
|
||||
|
||||
ctx.domElement.addEventListener("pointerdown", this.handlePointerDown);
|
||||
ctx.domElement.addEventListener("wheel", this.handleWheel, { passive: false });
|
||||
ctx.domElement.addEventListener("contextmenu", this.handleContextMenu);
|
||||
window.addEventListener("pointermove", this.handlePointerMove);
|
||||
window.addEventListener("pointerup", this.handlePointerUp);
|
||||
|
||||
ctx.setRuntimeMessage("Orbit Visitor active. Drag to orbit around the scene and use the mouse wheel to zoom.");
|
||||
ctx.setFirstPersonTelemetry(null);
|
||||
this.updateCameraTransform();
|
||||
}
|
||||
|
||||
deactivate(ctx: RuntimeControllerContext): void {
|
||||
ctx.domElement.removeEventListener("pointerdown", this.handlePointerDown);
|
||||
ctx.domElement.removeEventListener("wheel", this.handleWheel);
|
||||
ctx.domElement.removeEventListener("contextmenu", this.handleContextMenu);
|
||||
window.removeEventListener("pointermove", this.handlePointerMove);
|
||||
window.removeEventListener("pointerup", this.handlePointerUp);
|
||||
ctx.setRuntimeMessage(null);
|
||||
this.dragging = false;
|
||||
this.context = null;
|
||||
}
|
||||
|
||||
update(): void {
|
||||
this.updateCameraTransform();
|
||||
}
|
||||
|
||||
setFocusPoint(target: Vec3): void {
|
||||
this.target = cloneVec3(target);
|
||||
this.updateCameraTransform();
|
||||
}
|
||||
|
||||
private updateCameraTransform() {
|
||||
if (this.context === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const horizontalDistance = Math.cos(this.pitchRadians) * this.distance;
|
||||
const cameraPosition = {
|
||||
x: this.target.x + Math.sin(this.yawRadians) * horizontalDistance,
|
||||
y: this.target.y + Math.sin(this.pitchRadians) * this.distance,
|
||||
z: this.target.z + Math.cos(this.yawRadians) * horizontalDistance
|
||||
};
|
||||
|
||||
this.context.camera.position.set(cameraPosition.x, cameraPosition.y, cameraPosition.z);
|
||||
this.lookAtVector.set(this.target.x, this.target.y, this.target.z);
|
||||
this.context.camera.lookAt(this.lookAtVector);
|
||||
}
|
||||
|
||||
private handlePointerDown = (event: PointerEvent) => {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dragging = true;
|
||||
this.lastPointerClientX = event.clientX;
|
||||
this.lastPointerClientY = event.clientY;
|
||||
};
|
||||
|
||||
private handlePointerMove = (event: PointerEvent) => {
|
||||
if (!this.dragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = event.clientX - this.lastPointerClientX;
|
||||
const deltaY = event.clientY - this.lastPointerClientY;
|
||||
this.lastPointerClientX = event.clientX;
|
||||
this.lastPointerClientY = event.clientY;
|
||||
|
||||
this.yawRadians -= deltaX * 0.008;
|
||||
this.pitchRadians = clampPitch(this.pitchRadians + deltaY * 0.008);
|
||||
};
|
||||
|
||||
private handlePointerUp = () => {
|
||||
this.dragging = false;
|
||||
};
|
||||
|
||||
private handleWheel = (event: WheelEvent) => {
|
||||
event.preventDefault();
|
||||
this.distance = clampDistance(this.distance + event.deltaY * 0.01);
|
||||
};
|
||||
|
||||
private handleContextMenu = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
}
|
||||
149
src/runtime-three/player-collision.ts
Normal file
149
src/runtime-three/player-collision.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { Vec3 } from "../core/vector";
|
||||
|
||||
import type { RuntimeBoxCollider } from "./runtime-scene-build";
|
||||
|
||||
export interface FirstPersonPlayerShape {
|
||||
radius: number;
|
||||
height: number;
|
||||
eyeHeight: number;
|
||||
}
|
||||
|
||||
export interface ResolvedPlayerMotion {
|
||||
feetPosition: Vec3;
|
||||
grounded: boolean;
|
||||
collidedAxes: {
|
||||
x: boolean;
|
||||
y: boolean;
|
||||
z: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const FIRST_PERSON_PLAYER_SHAPE: FirstPersonPlayerShape = {
|
||||
radius: 0.3,
|
||||
height: 1.8,
|
||||
eyeHeight: 1.6
|
||||
};
|
||||
|
||||
type Axis = "x" | "y" | "z";
|
||||
|
||||
interface RuntimeAabb {
|
||||
min: Vec3;
|
||||
max: Vec3;
|
||||
}
|
||||
|
||||
function createPlayerAabb(feetPosition: Vec3, shape: FirstPersonPlayerShape): RuntimeAabb {
|
||||
return {
|
||||
min: {
|
||||
x: feetPosition.x - shape.radius,
|
||||
y: feetPosition.y,
|
||||
z: feetPosition.z - shape.radius
|
||||
},
|
||||
max: {
|
||||
x: feetPosition.x + shape.radius,
|
||||
y: feetPosition.y + shape.height,
|
||||
z: feetPosition.z + shape.radius
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function rangesOverlap(minA: number, maxA: number, minB: number, maxB: number): boolean {
|
||||
return minA < maxB && maxA > minB;
|
||||
}
|
||||
|
||||
function resolveAxis(
|
||||
axis: Axis,
|
||||
nextFeetPosition: Vec3,
|
||||
delta: number,
|
||||
shape: FirstPersonPlayerShape,
|
||||
colliders: RuntimeBoxCollider[]
|
||||
): { collided: boolean; grounded: boolean } {
|
||||
if (delta === 0) {
|
||||
return {
|
||||
collided: false,
|
||||
grounded: false
|
||||
};
|
||||
}
|
||||
|
||||
nextFeetPosition[axis] += delta;
|
||||
|
||||
let collided = false;
|
||||
let grounded = false;
|
||||
|
||||
for (const collider of colliders) {
|
||||
const playerAabb = createPlayerAabb(nextFeetPosition, shape);
|
||||
const overlapsOtherAxes =
|
||||
axis === "x"
|
||||
? rangesOverlap(playerAabb.min.y, playerAabb.max.y, collider.min.y, collider.max.y) &&
|
||||
rangesOverlap(playerAabb.min.z, playerAabb.max.z, collider.min.z, collider.max.z)
|
||||
: axis === "y"
|
||||
? rangesOverlap(playerAabb.min.x, playerAabb.max.x, collider.min.x, collider.max.x) &&
|
||||
rangesOverlap(playerAabb.min.z, playerAabb.max.z, collider.min.z, collider.max.z)
|
||||
: rangesOverlap(playerAabb.min.x, playerAabb.max.x, collider.min.x, collider.max.x) &&
|
||||
rangesOverlap(playerAabb.min.y, playerAabb.max.y, collider.min.y, collider.max.y);
|
||||
|
||||
if (!overlapsOtherAxes) {
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (axis) {
|
||||
case "x":
|
||||
if (delta > 0 && playerAabb.max.x > collider.min.x && playerAabb.min.x < collider.min.x) {
|
||||
nextFeetPosition.x = collider.min.x - shape.radius;
|
||||
collided = true;
|
||||
} else if (delta < 0 && playerAabb.min.x < collider.max.x && playerAabb.max.x > collider.max.x) {
|
||||
nextFeetPosition.x = collider.max.x + shape.radius;
|
||||
collided = true;
|
||||
}
|
||||
break;
|
||||
case "y":
|
||||
if (delta > 0 && playerAabb.max.y > collider.min.y && playerAabb.min.y < collider.min.y) {
|
||||
nextFeetPosition.y = collider.min.y - shape.height;
|
||||
collided = true;
|
||||
} else if (delta < 0 && playerAabb.min.y < collider.max.y && playerAabb.max.y > collider.max.y) {
|
||||
nextFeetPosition.y = collider.max.y;
|
||||
collided = true;
|
||||
grounded = true;
|
||||
}
|
||||
break;
|
||||
case "z":
|
||||
if (delta > 0 && playerAabb.max.z > collider.min.z && playerAabb.min.z < collider.min.z) {
|
||||
nextFeetPosition.z = collider.min.z - shape.radius;
|
||||
collided = true;
|
||||
} else if (delta < 0 && playerAabb.min.z < collider.max.z && playerAabb.max.z > collider.max.z) {
|
||||
nextFeetPosition.z = collider.max.z + shape.radius;
|
||||
collided = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
collided,
|
||||
grounded
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveFirstPersonMotion(
|
||||
feetPosition: Vec3,
|
||||
motion: Vec3,
|
||||
shape: FirstPersonPlayerShape,
|
||||
colliders: RuntimeBoxCollider[]
|
||||
): ResolvedPlayerMotion {
|
||||
const nextFeetPosition = {
|
||||
...feetPosition
|
||||
};
|
||||
|
||||
const xResolution = resolveAxis("x", nextFeetPosition, motion.x, shape, colliders);
|
||||
const zResolution = resolveAxis("z", nextFeetPosition, motion.z, shape, colliders);
|
||||
const yResolution = resolveAxis("y", nextFeetPosition, motion.y, shape, colliders);
|
||||
|
||||
return {
|
||||
feetPosition: nextFeetPosition,
|
||||
grounded: yResolution.grounded,
|
||||
collidedAxes: {
|
||||
x: xResolution.collided,
|
||||
y: yResolution.collided,
|
||||
z: zResolution.collided
|
||||
}
|
||||
};
|
||||
}
|
||||
272
src/runtime-three/runtime-host.ts
Normal file
272
src/runtime-three/runtime-host.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import {
|
||||
AmbientLight,
|
||||
BoxGeometry,
|
||||
Color,
|
||||
DirectionalLight,
|
||||
Group,
|
||||
Mesh,
|
||||
MeshStandardMaterial,
|
||||
PerspectiveCamera,
|
||||
Scene,
|
||||
WebGLRenderer
|
||||
} from "three";
|
||||
|
||||
import { applyBoxBrushFaceUvsToGeometry } from "../geometry/box-face-uvs";
|
||||
import { createStarterMaterialSignature, createStarterMaterialTexture } from "../materials/starter-material-textures";
|
||||
|
||||
import { FirstPersonNavigationController } from "./first-person-navigation-controller";
|
||||
import type { FirstPersonTelemetry, NavigationController, RuntimeControllerContext } from "./navigation-controller";
|
||||
import { OrbitVisitorNavigationController } from "./orbit-visitor-navigation-controller";
|
||||
import type { RuntimeBoxBrushInstance, RuntimeNavigationMode, RuntimeSceneDefinition } from "./runtime-scene-build";
|
||||
|
||||
interface CachedMaterialTexture {
|
||||
signature: string;
|
||||
texture: ReturnType<typeof createStarterMaterialTexture>;
|
||||
}
|
||||
|
||||
const FALLBACK_FACE_COLOR = 0x747d89;
|
||||
|
||||
export class RuntimeHost {
|
||||
private readonly scene = new Scene();
|
||||
private readonly camera = new PerspectiveCamera(70, 1, 0.05, 1000);
|
||||
private readonly renderer = new WebGLRenderer({ antialias: true });
|
||||
private readonly ambientLight = new AmbientLight();
|
||||
private readonly sunLight = new DirectionalLight();
|
||||
private readonly brushGroup = new Group();
|
||||
private readonly firstPersonController = new FirstPersonNavigationController();
|
||||
private readonly orbitVisitorController = new OrbitVisitorNavigationController();
|
||||
private readonly brushMeshes = new Map<string, Mesh<BoxGeometry, MeshStandardMaterial[]>>();
|
||||
private readonly materialTextureCache = new Map<string, CachedMaterialTexture>();
|
||||
private readonly controllerContext: RuntimeControllerContext;
|
||||
private resizeObserver: ResizeObserver | null = null;
|
||||
private animationFrame = 0;
|
||||
private previousFrameTime = 0;
|
||||
private container: HTMLElement | null = null;
|
||||
private activeController: NavigationController | null = null;
|
||||
private runtimeScene: RuntimeSceneDefinition | null = null;
|
||||
private runtimeMessageHandler: ((message: string | null) => void) | null = null;
|
||||
private firstPersonTelemetryHandler: ((telemetry: FirstPersonTelemetry | null) => void) | null = null;
|
||||
private currentRuntimeMessage: string | null = null;
|
||||
private currentFirstPersonTelemetry: FirstPersonTelemetry | null = null;
|
||||
|
||||
constructor() {
|
||||
this.scene.add(this.ambientLight);
|
||||
this.scene.add(this.sunLight);
|
||||
this.scene.add(this.brushGroup);
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
|
||||
this.controllerContext = {
|
||||
camera: this.camera,
|
||||
domElement: this.renderer.domElement,
|
||||
getRuntimeScene: () => {
|
||||
if (this.runtimeScene === null) {
|
||||
throw new Error("Runtime scene has not been loaded.");
|
||||
}
|
||||
|
||||
return this.runtimeScene;
|
||||
},
|
||||
setRuntimeMessage: (message) => {
|
||||
if (message === this.currentRuntimeMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentRuntimeMessage = message;
|
||||
this.runtimeMessageHandler?.(message);
|
||||
},
|
||||
setFirstPersonTelemetry: (telemetry) => {
|
||||
this.currentFirstPersonTelemetry = telemetry;
|
||||
this.firstPersonTelemetryHandler?.(telemetry);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
mount(container: HTMLElement) {
|
||||
this.container = container;
|
||||
container.appendChild(this.renderer.domElement);
|
||||
this.resize();
|
||||
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this.resize();
|
||||
});
|
||||
this.resizeObserver.observe(container);
|
||||
|
||||
this.previousFrameTime = performance.now();
|
||||
this.render();
|
||||
}
|
||||
|
||||
loadScene(runtimeScene: RuntimeSceneDefinition) {
|
||||
this.runtimeScene = runtimeScene;
|
||||
this.applyWorld(runtimeScene);
|
||||
this.rebuildBrushMeshes(runtimeScene.brushes);
|
||||
}
|
||||
|
||||
setNavigationMode(mode: RuntimeNavigationMode) {
|
||||
if (this.runtimeScene === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextController = mode === "firstPerson" ? this.firstPersonController : this.orbitVisitorController;
|
||||
|
||||
if (this.activeController?.id === nextController.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.activeController === this.firstPersonController && this.currentFirstPersonTelemetry !== null && nextController === this.orbitVisitorController) {
|
||||
this.orbitVisitorController.setFocusPoint(this.currentFirstPersonTelemetry.feetPosition);
|
||||
}
|
||||
|
||||
this.activeController?.deactivate(this.controllerContext);
|
||||
this.activeController = nextController;
|
||||
this.activeController.activate(this.controllerContext);
|
||||
}
|
||||
|
||||
setRuntimeMessageHandler(handler: ((message: string | null) => void) | null) {
|
||||
this.runtimeMessageHandler = handler;
|
||||
}
|
||||
|
||||
setFirstPersonTelemetryHandler(handler: ((telemetry: FirstPersonTelemetry | null) => void) | null) {
|
||||
this.firstPersonTelemetryHandler = handler;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.animationFrame !== 0) {
|
||||
cancelAnimationFrame(this.animationFrame);
|
||||
this.animationFrame = 0;
|
||||
}
|
||||
|
||||
this.activeController?.deactivate(this.controllerContext);
|
||||
this.activeController = null;
|
||||
this.resizeObserver?.disconnect();
|
||||
this.resizeObserver = null;
|
||||
this.clearBrushMeshes();
|
||||
|
||||
for (const cachedTexture of this.materialTextureCache.values()) {
|
||||
cachedTexture.texture.dispose();
|
||||
}
|
||||
|
||||
this.materialTextureCache.clear();
|
||||
this.renderer.dispose();
|
||||
|
||||
if (this.container !== null && this.container.contains(this.renderer.domElement)) {
|
||||
this.container.removeChild(this.renderer.domElement);
|
||||
}
|
||||
|
||||
this.container = null;
|
||||
}
|
||||
|
||||
private applyWorld(runtimeScene: RuntimeSceneDefinition) {
|
||||
this.scene.background = new Color(runtimeScene.world.background.colorHex);
|
||||
this.ambientLight.color.set(runtimeScene.world.ambientLight.colorHex);
|
||||
this.ambientLight.intensity = runtimeScene.world.ambientLight.intensity;
|
||||
this.sunLight.color.set(runtimeScene.world.sunLight.colorHex);
|
||||
this.sunLight.intensity = runtimeScene.world.sunLight.intensity;
|
||||
this.sunLight.position
|
||||
.set(
|
||||
runtimeScene.world.sunLight.direction.x,
|
||||
runtimeScene.world.sunLight.direction.y,
|
||||
runtimeScene.world.sunLight.direction.z
|
||||
)
|
||||
.normalize()
|
||||
.multiplyScalar(18);
|
||||
}
|
||||
|
||||
private rebuildBrushMeshes(brushes: RuntimeBoxBrushInstance[]) {
|
||||
this.clearBrushMeshes();
|
||||
|
||||
for (const brush of brushes) {
|
||||
const geometry = new BoxGeometry(brush.size.x, brush.size.y, brush.size.z);
|
||||
applyBoxBrushFaceUvsToGeometry(geometry, brush);
|
||||
|
||||
const materials = [
|
||||
this.createFaceMaterial(brush.faces.posX.material),
|
||||
this.createFaceMaterial(brush.faces.negX.material),
|
||||
this.createFaceMaterial(brush.faces.posY.material),
|
||||
this.createFaceMaterial(brush.faces.negY.material),
|
||||
this.createFaceMaterial(brush.faces.posZ.material),
|
||||
this.createFaceMaterial(brush.faces.negZ.material)
|
||||
];
|
||||
|
||||
const mesh = new Mesh(geometry, materials);
|
||||
mesh.position.set(brush.center.x, brush.center.y, brush.center.z);
|
||||
this.brushGroup.add(mesh);
|
||||
this.brushMeshes.set(brush.id, mesh);
|
||||
}
|
||||
}
|
||||
|
||||
private createFaceMaterial(material: RuntimeBoxBrushInstance["faces"]["posX"]["material"]): MeshStandardMaterial {
|
||||
if (material === null) {
|
||||
return new MeshStandardMaterial({
|
||||
color: FALLBACK_FACE_COLOR,
|
||||
roughness: 0.9,
|
||||
metalness: 0.05
|
||||
});
|
||||
}
|
||||
|
||||
return new MeshStandardMaterial({
|
||||
color: 0xffffff,
|
||||
map: this.getOrCreateTexture(material),
|
||||
roughness: 0.92,
|
||||
metalness: 0.02
|
||||
});
|
||||
}
|
||||
|
||||
private getOrCreateTexture(material: NonNullable<RuntimeBoxBrushInstance["faces"]["posX"]["material"]>) {
|
||||
const signature = createStarterMaterialSignature(material);
|
||||
const cachedTexture = this.materialTextureCache.get(material.id);
|
||||
|
||||
if (cachedTexture !== undefined && cachedTexture.signature === signature) {
|
||||
return cachedTexture.texture;
|
||||
}
|
||||
|
||||
cachedTexture?.texture.dispose();
|
||||
|
||||
const texture = createStarterMaterialTexture(material);
|
||||
this.materialTextureCache.set(material.id, {
|
||||
signature,
|
||||
texture
|
||||
});
|
||||
|
||||
return texture;
|
||||
}
|
||||
|
||||
private clearBrushMeshes() {
|
||||
for (const mesh of this.brushMeshes.values()) {
|
||||
this.brushGroup.remove(mesh);
|
||||
mesh.geometry.dispose();
|
||||
|
||||
for (const material of mesh.material) {
|
||||
material.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
this.brushMeshes.clear();
|
||||
}
|
||||
|
||||
private resize() {
|
||||
if (this.container === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const width = this.container.clientWidth;
|
||||
const height = this.container.clientHeight;
|
||||
|
||||
if (width === 0 || height === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.camera.aspect = width / height;
|
||||
this.camera.updateProjectionMatrix();
|
||||
this.renderer.setSize(width, height, false);
|
||||
}
|
||||
|
||||
private render = () => {
|
||||
this.animationFrame = window.requestAnimationFrame(this.render);
|
||||
|
||||
const now = performance.now();
|
||||
const dt = Math.min((now - this.previousFrameTime) / 1000, 1 / 20);
|
||||
this.previousFrameTime = now;
|
||||
|
||||
this.activeController?.update(dt);
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
};
|
||||
}
|
||||
221
src/runtime-three/runtime-scene-build.ts
Normal file
221
src/runtime-three/runtime-scene-build.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import type { Vec3 } from "../core/vector";
|
||||
import type { BoxBrush, BoxFaceId, FaceUvState } from "../document/brushes";
|
||||
import type { SceneDocument, WorldSettings } from "../document/scene-document";
|
||||
import { getPrimaryPlayerStartEntity } from "../entities/entity-instances";
|
||||
import { getBoxBrushBounds } from "../geometry/box-brush";
|
||||
import { cloneMaterialDef, type MaterialDef } from "../materials/starter-material-library";
|
||||
import { cloneFaceUvState } from "../document/brushes";
|
||||
|
||||
export type RuntimeNavigationMode = "firstPerson" | "orbitVisitor";
|
||||
|
||||
export interface RuntimeBrushFace {
|
||||
materialId: string | null;
|
||||
material: MaterialDef | null;
|
||||
uv: FaceUvState;
|
||||
}
|
||||
|
||||
export interface RuntimeBoxBrushInstance {
|
||||
id: string;
|
||||
kind: "box";
|
||||
center: Vec3;
|
||||
size: Vec3;
|
||||
faces: Record<BoxFaceId, RuntimeBrushFace>;
|
||||
}
|
||||
|
||||
export interface RuntimeBoxCollider {
|
||||
kind: "box";
|
||||
brushId: string;
|
||||
min: Vec3;
|
||||
max: Vec3;
|
||||
}
|
||||
|
||||
export interface RuntimeSceneBounds {
|
||||
min: Vec3;
|
||||
max: Vec3;
|
||||
center: Vec3;
|
||||
size: Vec3;
|
||||
}
|
||||
|
||||
export interface RuntimePlayerStart {
|
||||
entityId: string;
|
||||
position: Vec3;
|
||||
yawDegrees: number;
|
||||
}
|
||||
|
||||
export interface RuntimeSpawnPoint {
|
||||
source: "playerStart" | "fallback";
|
||||
entityId: string | null;
|
||||
position: Vec3;
|
||||
yawDegrees: number;
|
||||
}
|
||||
|
||||
export interface RuntimeSceneDefinition {
|
||||
world: WorldSettings;
|
||||
brushes: RuntimeBoxBrushInstance[];
|
||||
colliders: RuntimeBoxCollider[];
|
||||
sceneBounds: RuntimeSceneBounds | null;
|
||||
playerStart: RuntimePlayerStart | null;
|
||||
spawn: RuntimeSpawnPoint;
|
||||
}
|
||||
|
||||
function cloneVec3(vector: Vec3): Vec3 {
|
||||
return {
|
||||
x: vector.x,
|
||||
y: vector.y,
|
||||
z: vector.z
|
||||
};
|
||||
}
|
||||
|
||||
function buildRuntimeBrush(brush: BoxBrush, document: SceneDocument): RuntimeBoxBrushInstance {
|
||||
return {
|
||||
id: brush.id,
|
||||
kind: "box",
|
||||
center: cloneVec3(brush.center),
|
||||
size: cloneVec3(brush.size),
|
||||
faces: {
|
||||
posX: {
|
||||
materialId: brush.faces.posX.materialId,
|
||||
material: brush.faces.posX.materialId === null ? null : cloneMaterialDef(document.materials[brush.faces.posX.materialId]),
|
||||
uv: cloneFaceUvState(brush.faces.posX.uv)
|
||||
},
|
||||
negX: {
|
||||
materialId: brush.faces.negX.materialId,
|
||||
material: brush.faces.negX.materialId === null ? null : cloneMaterialDef(document.materials[brush.faces.negX.materialId]),
|
||||
uv: cloneFaceUvState(brush.faces.negX.uv)
|
||||
},
|
||||
posY: {
|
||||
materialId: brush.faces.posY.materialId,
|
||||
material: brush.faces.posY.materialId === null ? null : cloneMaterialDef(document.materials[brush.faces.posY.materialId]),
|
||||
uv: cloneFaceUvState(brush.faces.posY.uv)
|
||||
},
|
||||
negY: {
|
||||
materialId: brush.faces.negY.materialId,
|
||||
material: brush.faces.negY.materialId === null ? null : cloneMaterialDef(document.materials[brush.faces.negY.materialId]),
|
||||
uv: cloneFaceUvState(brush.faces.negY.uv)
|
||||
},
|
||||
posZ: {
|
||||
materialId: brush.faces.posZ.materialId,
|
||||
material: brush.faces.posZ.materialId === null ? null : cloneMaterialDef(document.materials[brush.faces.posZ.materialId]),
|
||||
uv: cloneFaceUvState(brush.faces.posZ.uv)
|
||||
},
|
||||
negZ: {
|
||||
materialId: brush.faces.negZ.materialId,
|
||||
material: brush.faces.negZ.materialId === null ? null : cloneMaterialDef(document.materials[brush.faces.negZ.materialId]),
|
||||
uv: cloneFaceUvState(brush.faces.negZ.uv)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function buildRuntimeCollider(brush: BoxBrush): RuntimeBoxCollider {
|
||||
const bounds = getBoxBrushBounds(brush);
|
||||
|
||||
return {
|
||||
kind: "box",
|
||||
brushId: brush.id,
|
||||
min: cloneVec3(bounds.min),
|
||||
max: cloneVec3(bounds.max)
|
||||
};
|
||||
}
|
||||
|
||||
function combineColliderBounds(colliders: RuntimeBoxCollider[]): RuntimeSceneBounds | null {
|
||||
if (colliders.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const min = cloneVec3(colliders[0].min);
|
||||
const max = cloneVec3(colliders[0].max);
|
||||
|
||||
for (const collider of colliders.slice(1)) {
|
||||
min.x = Math.min(min.x, collider.min.x);
|
||||
min.y = Math.min(min.y, collider.min.y);
|
||||
min.z = Math.min(min.z, collider.min.z);
|
||||
max.x = Math.max(max.x, collider.max.x);
|
||||
max.y = Math.max(max.y, collider.max.y);
|
||||
max.z = Math.max(max.z, collider.max.z);
|
||||
}
|
||||
|
||||
return {
|
||||
min,
|
||||
max,
|
||||
center: {
|
||||
x: (min.x + max.x) * 0.5,
|
||||
y: (min.y + max.y) * 0.5,
|
||||
z: (min.z + max.z) * 0.5
|
||||
},
|
||||
size: {
|
||||
x: max.x - min.x,
|
||||
y: max.y - min.y,
|
||||
z: max.z - min.z
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function buildFallbackSpawn(sceneBounds: RuntimeSceneBounds | null): RuntimeSpawnPoint {
|
||||
if (sceneBounds === null) {
|
||||
return {
|
||||
source: "fallback",
|
||||
entityId: null,
|
||||
position: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: -4
|
||||
},
|
||||
yawDegrees: 0
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
source: "fallback",
|
||||
entityId: null,
|
||||
position: {
|
||||
x: sceneBounds.center.x,
|
||||
y: sceneBounds.max.y + 0.1,
|
||||
z: sceneBounds.max.z + 3
|
||||
},
|
||||
yawDegrees: 180
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRuntimeSceneFromDocument(document: SceneDocument): RuntimeSceneDefinition {
|
||||
const brushes = Object.values(document.brushes).map((brush) => buildRuntimeBrush(brush, document));
|
||||
const colliders = Object.values(document.brushes).map((brush) => buildRuntimeCollider(brush));
|
||||
const sceneBounds = combineColliderBounds(colliders);
|
||||
const playerStartEntity = getPrimaryPlayerStartEntity(document.entities);
|
||||
const playerStart =
|
||||
playerStartEntity === null
|
||||
? null
|
||||
: {
|
||||
entityId: playerStartEntity.id,
|
||||
position: cloneVec3(playerStartEntity.position),
|
||||
yawDegrees: playerStartEntity.yawDegrees
|
||||
};
|
||||
|
||||
return {
|
||||
world: {
|
||||
background: {
|
||||
...document.world.background
|
||||
},
|
||||
ambientLight: {
|
||||
...document.world.ambientLight
|
||||
},
|
||||
sunLight: {
|
||||
...document.world.sunLight,
|
||||
direction: cloneVec3(document.world.sunLight.direction)
|
||||
}
|
||||
},
|
||||
brushes,
|
||||
colliders,
|
||||
sceneBounds,
|
||||
playerStart,
|
||||
spawn:
|
||||
playerStart === null
|
||||
? buildFallbackSpawn(sceneBounds)
|
||||
: {
|
||||
source: "playerStart",
|
||||
entityId: playerStart.entityId,
|
||||
position: cloneVec3(playerStart.position),
|
||||
yawDegrees: playerStart.yawDegrees
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user