Merge pull request #56 from cicbaltic/feat/map

Feat/map
This commit is contained in:
Lukas Ralys
2026-08-07 15:16:38 +03:00
committed by GitHub Enterprise
24 changed files with 1234 additions and 56 deletions
Binary file not shown.
+2
View File
@@ -10,6 +10,7 @@ import Chat from "./components/Chat/Chat";
import RenderStats from "./components/debug/RenderStats";
import HudControls from "./components/HudControls/HudControls";
import Loader from "./components/Loader";
import Map from "./components/Map/Map";
import PlayerActionPrompt from "./components/PlayerActionPrompt/PlayerActionPrompt";
import Scene from "./components/Scene";
import Toaster from "./components/Toast/Toaster";
@@ -73,6 +74,7 @@ const App = () => {
<HudControls />
<PlayerActionPrompt />
<Chat />
<Map />
</>
)}
<Toaster />
+148
View File
@@ -0,0 +1,148 @@
.map-overlay {
position: fixed;
inset: 0;
z-index: 1100;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: var(--ibm-blur-sm);
-webkit-backdrop-filter: var(--ibm-blur-sm);
pointer-events: none;
}
.map-overlay__panel {
position: relative;
width: min(82vw, 82vh);
height: min(82vw, 82vh);
display: flex;
flex-direction: column;
background: var(--ibm-glass-default);
backdrop-filter: var(--ibm-blur-md);
-webkit-backdrop-filter: var(--ibm-blur-md);
border: 0.5px solid var(--ibm-border-glow);
border-radius: var(--ibm-radius-lg);
overflow: hidden;
color: var(--ibm-text-primary);
font-family: var(--ibm-font);
pointer-events: auto;
}
.map-overlay__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--ibm-space-md) var(--ibm-space-lg);
border-bottom: 0.5px solid var(--ibm-border-subtle);
}
.map-overlay__title {
font-family: var(--ibm-font);
font-size: 13px;
font-weight: 600;
letter-spacing: 1.3px;
text-transform: uppercase;
color: var(--ibm-text-primary);
}
.map-overlay__scale {
font-family: var(--ibm-font-mono);
font-size: 13px;
font-variant-numeric: tabular-nums;
color: var(--ibm-text-muted);
}
.map-overlay__svg {
flex: 1;
width: 100%;
height: 100%;
display: block;
touch-action: none;
user-select: none;
}
.map-overlay__footer {
display: flex;
align-items: center;
gap: var(--ibm-space-sm);
padding: var(--ibm-space-sm) var(--ibm-space-lg);
font-size: 12px;
color: var(--ibm-text-secondary);
font-family: var(--ibm-font);
border-top: 0.5px solid var(--ibm-border-subtle);
}
.map-overlay__count-dot {
width: 8px;
height: 8px;
border-radius: var(--ibm-radius-pill);
background: var(--ibm-accent-cyan);
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--ibm-accent-cyan) 25%, transparent);
}
.map-overlay__hint {
margin-left: auto;
font-size: 11px;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--ibm-text-muted);
}
.map-polygon {
fill-opacity: 0.94;
stroke-linejoin: round;
}
.map-seat {
fill: color-mix(in srgb, var(--ibm-text-muted) 10%, transparent);
stroke: color-mix(in srgb, var(--ibm-text-muted) 50%, transparent);
}
.map-seat--taken {
fill: var(--ibm-info-alt);
fill-opacity: 0.92;
stroke: rgba(0, 0, 0, 0.55);
}
.map-player-dot {
stroke: rgba(0, 0, 0, 0.65);
}
.map-player-dot--local {
fill: var(--ibm-accent-cyan);
}
.map-player-dot--remote {
fill: var(--ibm-warning);
}
.map-player-pulse {
fill: none;
stroke: var(--ibm-accent-cyan);
opacity: 0.5;
}
.map-label__pill {
fill: color-mix(in srgb, var(--ibm-bg-deep) 85%, transparent);
stroke: var(--ibm-border-subtle);
}
.map-label--local .map-label__pill {
stroke: color-mix(in srgb, var(--ibm-accent-cyan) 60%, transparent);
}
.map-label--seat .map-label__pill {
stroke: color-mix(in srgb, var(--ibm-info-alt) 60%, transparent);
}
.map-label__text {
fill: var(--ibm-text-primary);
font-family: var(--ibm-font);
font-weight: 500;
pointer-events: none;
}
.map-label--local .map-label__text {
fill: var(--ibm-accent-cyan);
}
+232
View File
@@ -0,0 +1,232 @@
import { useEffect, useRef, useState } from "react";
import { getSocket } from "../../socket-client";
import useChairStore from "../../stores/chairStore";
import useMapStore from "../../stores/mapStore";
import { usePlayerControlsStore } from "../../stores/playerControlsStore";
import usePlayerStore from "../../stores/playerStore";
import MapPlayerLayer from "./components/MapPlayerLayer";
import MapSeatLayer from "./components/MapSeatLayer";
import MapStaticLayer from "./components/MapStaticLayer";
import "./Map.css";
// The part of the scene the map is currently showing, in scene units. Panning
// moves x/y, zooming scales width/height.
interface ViewBox {
x: number;
y: number;
width: number;
height: number;
}
interface DragState {
startX: number;
startY: number;
viewX: number;
viewY: number;
pointerId: number;
}
const MIN_ZOOM = 0.5;
const MAX_ZOOM = 8;
const ZOOM_STEP = 1.15;
// Fraction of the scene's shorter side, keeping the drop shadow proportional
// to the map rather than to the scene's size in metres.
const SHADOW_BLUR_RATIO = 0.003;
const Map = () => {
const toggleMap = usePlayerControlsStore((state) => state.toggleMap);
const sceneMap = useMapStore((state) => state.sceneMap);
const players = usePlayerStore((state) => state.players);
const chairs = useChairStore((state) => state.chairs);
const svgRef = useRef<SVGSVGElement | null>(null);
const [view, setView] = useState<ViewBox | null>(null);
const viewRef = useRef<ViewBox | null>(null);
const dragRef = useRef<DragState | null>(null);
const [isDragging, setIsDragging] = useState(false);
useEffect(() => {
viewRef.current = view;
}, [view]);
useEffect(() => {
if (sceneMap) setView({ ...sceneMap.viewBox });
}, [sceneMap]);
useEffect(() => {
if (!toggleMap || !sceneMap) return;
const svg = svgRef.current;
if (!svg) return;
const baseWidth = sceneMap.viewBox.width;
const baseHeight = sceneMap.viewBox.height;
const handleWheelZoom = (e: WheelEvent) => {
e.preventDefault();
const currentView = viewRef.current;
if (!currentView) return;
const rect = svg.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
// Zoom about the cursor: keep the scene point under the pointer fixed.
const pointerFractionX = (e.clientX - rect.left) / rect.width;
const pointerFractionY = (e.clientY - rect.top) / rect.height;
const anchorX = currentView.x + pointerFractionX * currentView.width;
const anchorY = currentView.y + pointerFractionY * currentView.height;
const step = e.deltaY > 0 ? ZOOM_STEP : 1 / ZOOM_STEP;
let newWidth = currentView.width * step;
let newHeight = currentView.height * step;
const newZoom = baseWidth / newWidth;
if (newZoom < MIN_ZOOM) {
newWidth = baseWidth / MIN_ZOOM;
newHeight = baseHeight / MIN_ZOOM;
} else if (newZoom > MAX_ZOOM) {
newWidth = baseWidth / MAX_ZOOM;
newHeight = baseHeight / MAX_ZOOM;
}
setView({
x: anchorX - pointerFractionX * newWidth,
y: anchorY - pointerFractionY * newHeight,
width: newWidth,
height: newHeight,
});
};
svg.addEventListener("wheel", handleWheelZoom, { passive: false });
return () => svg.removeEventListener("wheel", handleWheelZoom);
}, [sceneMap, toggleMap]);
if (!toggleMap || !sceneMap || !view) return null;
const baseMinDim = Math.min(sceneMap.viewBox.width, sceneMap.viewBox.height);
const zoom = sceneMap.viewBox.width / view.width;
const shadowBlur = baseMinDim * SHADOW_BLUR_RATIO;
const localId = getSocket()?.id;
const handleDragStart = (e: React.PointerEvent<SVGSVGElement>) => {
if (e.button !== 0) return;
dragRef.current = {
startX: e.clientX,
startY: e.clientY,
viewX: view.x,
viewY: view.y,
pointerId: e.pointerId,
};
setIsDragging(true);
e.currentTarget.setPointerCapture(e.pointerId);
};
const handleDragMove = (e: React.PointerEvent<SVGSVGElement>) => {
const drag = dragRef.current;
const currentView = viewRef.current;
if (!drag || !currentView || !svgRef.current) return;
const rect = svgRef.current.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
// Pointer travel is in CSS pixels; scale it into scene units so the map
// tracks the cursor at any zoom level.
const dx = ((e.clientX - drag.startX) / rect.width) * currentView.width;
const dy = ((e.clientY - drag.startY) / rect.height) * currentView.height;
setView({
x: drag.viewX - dx,
y: drag.viewY - dy,
width: currentView.width,
height: currentView.height,
});
};
const handleDragEnd = (e: React.PointerEvent<SVGSVGElement>) => {
const drag = dragRef.current;
if (!drag) return;
if (e.currentTarget.hasPointerCapture(drag.pointerId)) {
e.currentTarget.releasePointerCapture(drag.pointerId);
}
dragRef.current = null;
setIsDragging(false);
};
return (
<div className="map-overlay">
<div className="map-overlay__panel">
<header className="map-overlay__header">
<span className="map-overlay__title">Map</span>
<span className="map-overlay__scale">{zoom.toFixed(1)}×</span>
</header>
<svg
ref={svgRef}
viewBox={`${view.x} ${view.y} ${view.width} ${view.height}`}
preserveAspectRatio="xMidYMid meet"
className="map-overlay__svg"
style={{ cursor: isDragging ? "grabbing" : "grab" }}
onPointerDown={handleDragStart}
onPointerMove={handleDragMove}
onPointerUp={handleDragEnd}
onPointerCancel={handleDragEnd}
>
<defs>
<filter
id="poly-shadow"
x="-10%"
y="-10%"
width="120%"
height="120%"
>
<feDropShadow
dx="0"
dy="0"
stdDeviation={shadowBlur}
floodOpacity="0.55"
/>
</filter>
</defs>
{/* Transparent hit target so wheel/pointer events fire anywhere in
the SVG. Kept out of MapStaticLayer so that heavy layer stays
memoized across pan/zoom. */}
<rect
x={view.x}
y={view.y}
width={view.width}
height={view.height}
fill="transparent"
/>
<MapStaticLayer sceneMap={sceneMap} />
<MapSeatLayer
seats={sceneMap.seats}
chairs={chairs}
flipOffset={sceneMap.flipOffset}
flipOffsetX={sceneMap.flipOffsetX}
zoom={zoom}
baseMinDim={baseMinDim}
/>
<MapPlayerLayer
players={players}
localId={localId}
flipOffset={sceneMap.flipOffset}
flipOffsetX={sceneMap.flipOffsetX}
zoom={zoom}
baseMinDim={baseMinDim}
/>
</svg>
<footer className="map-overlay__footer">
<span className="map-overlay__count-dot" aria-hidden />
<span>
{players.length} {players.length === 1 ? "player" : "players"}
</span>
<span className="map-overlay__hint">
scroll to zoom · drag to pan
</span>
</footer>
</div>
</div>
);
};
export default Map;
@@ -0,0 +1,58 @@
type MapLabelVariant = "local" | "remote" | "seat";
interface MapLabelProps {
// Screen-space anchor, i.e. already mapped out of the mirrored group.
x: number;
y: number;
text: string;
fontSize: number;
variant: MapLabelVariant;
}
// Ratios of the font size rather than pixel values: the map is drawn in scene
// units and scaled by zoom, so fixed padding would not survive either.
const PAD_X_RATIO = 0.45;
const PAD_Y_RATIO = 0.2;
const CHAR_WIDTH_RATIO = 0.55;
const CORNER_RATIO = 0.32;
const STROKE_RATIO = 0.05;
const BASELINE_PAD_RATIO = 0.7;
// Gap between a marker and the label above it, applied by the layers that
// place labels.
export const LABEL_GAP_RATIO = 0.5;
// Name plate shared by player markers and occupied seats. Rendered outside the
// mirrored group so text is never drawn upside down; colours live in Map.css so
// they stay tied to the design tokens.
const MapLabel = ({ x, y, text, fontSize, variant }: MapLabelProps) => {
const padX = fontSize * PAD_X_RATIO;
const padY = fontSize * PAD_Y_RATIO;
const textWidth = text.length * fontSize * CHAR_WIDTH_RATIO;
const corner = fontSize * CORNER_RATIO;
return (
<g className={`map-label map-label--${variant}`}>
<rect
className="map-label__pill"
x={x - textWidth / 2 - padX}
y={y - fontSize - padY}
width={textWidth + padX * 2}
height={fontSize + padY * 2}
rx={corner}
ry={corner}
strokeWidth={fontSize * STROKE_RATIO}
/>
<text
className="map-label__text"
x={x}
y={y - padY * BASELINE_PAD_RATIO}
textAnchor="middle"
fontSize={fontSize}
>
{text}
</text>
</g>
);
};
export default MapLabel;
@@ -0,0 +1,113 @@
import { memo } from "react";
import MapLabel, { LABEL_GAP_RATIO } from "./MapLabel";
import type { Player } from "@metaverse/types";
interface MapPlayerLayerProps {
players: Player[];
localId: string | undefined;
flipOffset: number;
flipOffsetX: number;
zoom: number;
baseMinDim: number;
}
// Fractions of the scene's shorter side, divided by the zoom so markers keep a
// constant on-screen size. Dots are smaller than a seat so a seated player
// stays legible on top of one; their names outrank seat names.
const DOT_RATIO = 0.012;
const FONT_RATIO = 0.024;
// Fractions of the dot radius.
const DOT_STROKE_RATIO = 0.25;
const PULSE_STROKE_RATIO = 0.3;
const PULSE_MIN_RATIO = 1.5;
const PULSE_MAX_RATIO = 3.5;
const PULSE_DURATION = "2.4s";
// One dot per player, with a pulsing ring around the local one. Dots are drawn
// inside the mirrored group (scene coordinates)
const MapPlayerLayer = memo(
({
players,
localId,
flipOffset,
flipOffsetX,
zoom,
baseMinDim,
}: MapPlayerLayerProps) => {
const dotRadius = (baseMinDim * DOT_RATIO) / zoom;
const fontSize = (baseMinDim * FONT_RATIO) / zoom;
const labelGap = dotRadius + fontSize * LABEL_GAP_RATIO;
const pulseMin = dotRadius * PULSE_MIN_RATIO;
const pulseMax = dotRadius * PULSE_MAX_RATIO;
return (
<>
<g
transform={`translate(${flipOffsetX.toFixed(3)} ${flipOffset.toFixed(3)}) scale(-1 -1)`}
>
{players.map((player) => {
const [px, , pz] = player.position;
const isLocal = player.id === localId;
return (
<g key={`marker-${player.id}`}>
{isLocal && (
<circle
className="map-player-pulse"
cx={px}
cy={pz}
r={dotRadius * 2}
strokeWidth={dotRadius * PULSE_STROKE_RATIO}
>
<animate
attributeName="r"
values={`${pulseMin};${pulseMax};${pulseMin}`}
dur={PULSE_DURATION}
repeatCount="indefinite"
/>
<animate
attributeName="opacity"
values="0.55;0;0.55"
dur={PULSE_DURATION}
repeatCount="indefinite"
/>
</circle>
)}
<circle
className={`map-player-dot map-player-dot--${isLocal ? "local" : "remote"}`}
cx={px}
cy={pz}
r={dotRadius}
strokeWidth={dotRadius * DOT_STROKE_RATIO}
/>
</g>
);
})}
</g>
<g>
{players.map((player) => {
const [px, , pz] = player.position;
return (
<MapLabel
key={`label-${player.id}`}
x={flipOffsetX - px}
y={flipOffset - pz - labelGap}
text={player.user.name}
fontSize={fontSize}
variant={player.id === localId ? "local" : "remote"}
/>
);
})}
</g>
</>
);
},
);
MapPlayerLayer.displayName = "MapPlayerLayer";
export default MapPlayerLayer;
@@ -0,0 +1,95 @@
import { memo } from "react";
import MapLabel, { LABEL_GAP_RATIO } from "./MapLabel";
import type { Chair } from "../../../stores/chairStore";
import type { MapSeat } from "../../../utils/sceneMap";
interface MapSeatLayerProps {
seats: MapSeat[];
chairs: Record<string, Chair>;
flipOffset: number;
flipOffsetX: number;
zoom: number;
baseMinDim: number;
}
// Occupant names only become legible once zoomed in past this factor.
const LABEL_ZOOM = 2.5;
// Fractions of the scene's shorter side, divided by the zoom so a seat keeps a
// constant on-screen size while panning. 0.018 is about the footprint of a
// chair in the office scene.
const SIZE_RATIO = 0.018;
const FONT_RATIO = 0.02;
// Fractions of the seat square itself.
const CORNER_RATIO = 0.25;
const STROKE_RATIO = 0.12;
// Claimable seats: an outlined square per chair, filled when occupied. Once the
// user has zoomed in past LABEL_ZOOM, the occupant's name is drawn above each
// taken seat.
const MapSeatLayer = memo(
({
seats,
chairs,
flipOffset,
flipOffsetX,
zoom,
baseMinDim,
}: MapSeatLayerProps) => {
const size = (baseMinDim * SIZE_RATIO) / zoom;
const half = size / 2;
const corner = size * CORNER_RATIO;
const showNames = zoom >= LABEL_ZOOM;
const fontSize = (baseMinDim * FONT_RATIO) / zoom;
const labelGap = half + fontSize * LABEL_GAP_RATIO;
return (
<>
<g
transform={`translate(${flipOffsetX.toFixed(3)} ${flipOffset.toFixed(3)}) scale(-1 -1)`}
>
{seats.map((seat) => {
const taken = chairs[seat.key]?.status === "taken";
return (
<rect
key={seat.key}
className={`map-seat${taken ? " map-seat--taken" : ""}`}
x={seat.x - half}
y={seat.z - half}
width={size}
height={size}
rx={corner}
ry={corner}
strokeWidth={size * STROKE_RATIO}
/>
);
})}
</g>
{showNames && (
<g>
{seats.map((seat) => {
const name = chairs[seat.key]?.user?.name;
if (!name) return null;
return (
<MapLabel
key={`seat-label-${seat.key}`}
x={flipOffsetX - seat.x}
y={flipOffset - seat.z - labelGap}
text={name}
fontSize={fontSize}
variant="seat"
/>
);
})}
</g>
)}
</>
);
},
);
MapSeatLayer.displayName = "MapSeatLayer";
export default MapSeatLayer;
@@ -0,0 +1,68 @@
import { memo } from "react";
import type { MapPolygon, SceneMap } from "../../../utils/sceneMap";
interface MapStaticLayerProps {
sceneMap: SceneMap;
}
// Stroke widths are fractions of the scene's shorter side, so the map reads the
// same whatever the scene measures in metres.
const STROKE_RATIO = 0.002;
// Added per unit of normalised height, giving taller objects a heavier outline
// so they read as closer to the camera.
const STROKE_HEIGHT_RATIO = 0.0008;
// Floor-level objects are drawn dark blue, tall ones pale, everything between
// interpolated by normalised height.
const FILL_LOW = [56, 86, 122];
const FILL_HIGH = [186, 219, 230];
const STROKE_LOW = [20, 32, 48];
const STROKE_HIGH = [108, 148, 172];
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
const mixChannels = (low: number[], high: number[], t: number) => {
const [r, g, b] = low.map((channel, i) =>
Math.round(lerp(channel, high[i], t)),
);
return `rgb(${r}, ${g}, ${b})`;
};
// Polygons only. With no `view` prop, this heavy subtree stays memoized across
// pan/zoom — the browser's native viewBox transform handles those — so it
// re-renders only when the scene itself changes.
const MapStaticLayer = memo(({ sceneMap }: MapStaticLayerProps) => {
const baseMinDim = Math.min(sceneMap.viewBox.width, sceneMap.viewBox.height);
const polyStrokeBase = baseMinDim * STROKE_RATIO;
return (
<g
transform={`translate(${sceneMap.flipOffsetX.toFixed(3)} ${sceneMap.flipOffset.toFixed(3)}) scale(-1 -1)`}
>
<g filter="url(#poly-shadow)">
{sceneMap.polygons.map((poly: MapPolygon, i: number) => {
const points = poly.points
.map(([x, z]) => `${x.toFixed(3)},${z.toFixed(3)}`)
.join(" ");
const strokeWidth =
polyStrokeBase + baseMinDim * STROKE_HEIGHT_RATIO * poly.height;
return (
<polygon
key={i}
className="map-polygon"
points={points}
fill={mixChannels(FILL_LOW, FILL_HIGH, poly.height)}
stroke={mixChannels(STROKE_LOW, STROKE_HIGH, poly.height)}
strokeWidth={strokeWidth}
/>
);
})}
</g>
</g>
);
});
MapStaticLayer.displayName = "MapStaticLayer";
export default MapStaticLayer;
+17 -2
View File
@@ -4,7 +4,10 @@ import { useEffect, useRef } from "react";
import useSceneWithColliders from "../hooks/useSceneWithColliders";
import { getSocket } from "../socket-client";
import useChairStore from "../stores/chairStore";
import useMapStore from "../stores/mapStore";
import usePlayerStore, { LoadingStage } from "../stores/playerStore";
import { getSceneFloor } from "../utils/sceneFloor";
import { buildSceneMap } from "../utils/sceneMap";
import { InteractableOverlays } from "./interactables";
import Player from "./Player";
@@ -13,16 +16,18 @@ import type { Group } from "three";
interface SceneProps {
url: string;
optimize?: boolean;
}
const Scene = ({ url }: SceneProps) => {
const Scene = ({ url, optimize = false }: SceneProps) => {
useGLTF.preload("/models/player.glb");
const setSceneLoaded = usePlayerStore((state) => state.setSceneLoaded);
const players = usePlayerStore((state) => state.players);
const { sceneObjects, scene, loading } = useSceneWithColliders(url);
const { sceneObjects, scene, loading } = useSceneWithColliders(url, optimize);
const sceneGroupRef = useRef<Group | null>(null);
const fetchChairs = useChairStore((state) => state.fetchChairs);
const setSceneMap = useMapStore((state) => state.setSceneMap);
const loadingStage = usePlayerStore((state) => state.loadingStage);
@@ -30,6 +35,16 @@ const Scene = ({ url }: SceneProps) => {
fetchChairs();
}, [fetchChairs]);
useEffect(() => {
if (!scene || loading || sceneObjects.length === 0) return;
const map = buildSceneMap(
scene as unknown as Parameters<typeof buildSceneMap>[0],
getSceneFloor(url),
);
setSceneMap(map ?? null);
return () => setSceneMap(null);
}, [scene, loading, sceneObjects, setSceneMap, url]);
useEffect(() => {
// Only set scene as loaded when all conditions are met:
// 1. Scene objects are available (colliders are applied)
@@ -2,6 +2,7 @@ import { useMemo } from "react";
import { Vector3 } from "three";
import usePlayerStore from "../../stores/playerStore";
import { getSceneFloor } from "../../utils/sceneFloor";
import HighlightEffect from "./HighlightEffect";
import { INTERACTABLE_REGISTRY } from "./registry";
@@ -25,7 +26,7 @@ const InteractableOverlays = ({
currentScene,
}: InteractableOverlaysProps) => {
const targetedObject = usePlayerStore((state) => state.targetedObject);
const floor = currentScene.match(/(\d+)/)?.[1] || "10";
const floor = getSceneFloor(currentScene);
const found = useMemo(() => {
const results: FoundObject[] = [];
+19 -9
View File
@@ -13,6 +13,7 @@ const useMouseControls = (isLocalPlayer: boolean) => {
const toggledMouseLook = usePlayerControlsStore(
(state) => state.toggleMouseLook,
);
const toggleMap = usePlayerControlsStore((state) => state.toggleMap);
const setControl = usePlayerControlsStore((state) => state.setControl);
const currentDeltas = useRef<MouseDeltas>({ yawDelta: 0, pitchDelta: 0 });
@@ -22,6 +23,7 @@ const useMouseControls = (isLocalPlayer: boolean) => {
const canvas = gl.domElement;
const handleMouseClick = () => {
if (toggleMap) return;
setControl("toggleMouseLook", true);
};
@@ -30,7 +32,7 @@ const useMouseControls = (isLocalPlayer: boolean) => {
return () => {
canvas.removeEventListener("click", handleMouseClick);
};
}, [isLocalPlayer, gl, setControl]);
}, [isLocalPlayer, gl, setControl, toggleMap]);
useEffect(() => {
if (!isLocalPlayer) return;
@@ -38,10 +40,11 @@ const useMouseControls = (isLocalPlayer: boolean) => {
const canvas = gl.domElement;
const handlePointerLockChange = () => {
if (document.pointerLockElement !== canvas) {
setControl("toggleMouseLook", false);
currentDeltas.current = { yawDelta: 0, pitchDelta: 0 };
}
if (document.pointerLockElement === canvas) return;
currentDeltas.current = { yawDelta: 0, pitchDelta: 0 };
if (toggleMap) return;
setControl("toggleMouseLook", false);
};
const handleMouseMove = (e: MouseEvent) => {
@@ -62,9 +65,16 @@ const useMouseControls = (isLocalPlayer: boolean) => {
document.addEventListener("pointerlockchange", handlePointerLockChange);
canvas.addEventListener("mousemove", handleMouseMove);
if (toggledMouseLook && document.pointerLockElement !== canvas) {
canvas.requestPointerLock();
} else if (document.pointerLockElement) {
if (
toggledMouseLook &&
!toggleMap &&
document.pointerLockElement !== canvas
) {
void Promise.resolve(canvas.requestPointerLock()).catch(() => {});
} else if (
(!toggledMouseLook || toggleMap) &&
document.pointerLockElement
) {
document.exitPointerLock();
currentDeltas.current = { yawDelta: 0, pitchDelta: 0 };
}
@@ -76,7 +86,7 @@ const useMouseControls = (isLocalPlayer: boolean) => {
);
canvas.removeEventListener("mousemove", handleMouseMove);
};
}, [gl, toggledMouseLook, isLocalPlayer, setControl]);
}, [gl, toggledMouseLook, toggleMap, isLocalPlayer, setControl]);
return currentDeltas;
};
@@ -18,6 +18,7 @@ const usePlayerControls = () => {
strafeRight,
toggleFirstPerson,
toggleDebug,
toggleMap,
toggleMouseLook,
setControl,
} = usePlayerControlsStore();
@@ -43,6 +44,10 @@ const usePlayerControls = () => {
const handleKeyDown = (e: KeyboardEvent) => {
if (isChatInputFocused || isSettingsModalOpen) return;
if (toggleMap && getControl(toggleFirstPerson, e.code) !== "toggleMap") {
return;
}
if (e.code === "KeyD" && e.ctrlKey && config.isDevEnv) {
setControl("toggleDebug", !toggleDebug);
return;
@@ -59,6 +64,9 @@ const usePlayerControls = () => {
case "toggleFirstPerson":
setControl(control, !toggleFirstPerson);
break;
case "toggleMap":
setControl(control, !toggleMap);
break;
case "toggleMouseLook":
setControl(control, !toggleMouseLook);
break;
@@ -79,6 +87,7 @@ const usePlayerControls = () => {
switch (control) {
case "toggleFirstPerson":
case "toggleMap":
break;
case "toggleMouseLook":
break;
@@ -112,6 +121,7 @@ const usePlayerControls = () => {
setControl,
toggleDebug,
toggleFirstPerson,
toggleMap,
toggleMouseLook,
isChatInputFocused,
isSettingsModalOpen,
@@ -13,6 +13,7 @@ import { Vector3, Euler, Quaternion } from "three";
import { getSocket } from "../socket-client";
import { useChatStore } from "../stores/chatStore";
import usePlayerStore from "../stores/playerStore";
import usePlayerControls from "./usePlayerControls";
@@ -34,6 +35,9 @@ export default function usePlayerMovement({
const isActiveSpeechBubbleForUser = useChatStore(
(state) => state.isActiveSpeechBubbleForUser,
);
const updatePlayerPosition = usePlayerStore(
(state) => state.updatePlayerPosition,
);
const [currentAnimation, setCurrentAnimation] =
useState<PlayerStates>("Idle");
@@ -182,6 +186,8 @@ export default function usePlayerMovement({
state,
});
updatePlayerPosition(id, [pos.x, pos.y, pos.z]);
lastEmitTime.current = performance.now();
}
});
@@ -13,7 +13,7 @@ import MeshRigidBody from "../components/MeshRigidBody";
import usePlayerStore, { LoadingStage } from "../stores/playerStore";
import { optimizeScene } from "../utils/sceneOptimizer";
const useSceneWithColliders = (url: string) => {
const useSceneWithColliders = (url: string, optimize = true) => {
const { scene } = useGLTF(url, "/draco/");
const { progress } = useProgress();
const [loading, setLoading] = useState(true);
@@ -104,7 +104,7 @@ const useSceneWithColliders = (url: string) => {
setSceneObjects([...instancedElements, ...rigidBodies]);
setLoading(false);
}, [scene, getRigidBodies, url, setLoadingStage]);
}, [scene, getRigidBodies, url, setLoadingStage, optimize]);
return { loading, sceneObjects, scene };
};
+15
View File
@@ -0,0 +1,15 @@
import { create } from "zustand";
import type { SceneMap } from "../utils/sceneMap";
interface MapStore {
sceneMap: SceneMap | null;
setSceneMap: (sceneMap: SceneMap | null) => void;
}
const useMapStore = create<MapStore>((set) => ({
sceneMap: null,
setSceneMap: (sceneMap) => set({ sceneMap }),
}));
export default useMapStore;
@@ -16,6 +16,7 @@ export const usePlayerControlsStore = create<PlayerControlsStore>((set) => ({
strafeRight: false,
toggleFirstPerson: false,
toggleDebug: false,
toggleMap: false,
triggerAction: false,
voiceRecording: false,
toggleMouseLook: false,
+13 -1
View File
@@ -1,4 +1,9 @@
import { type Player, type PlayerActions, type User } from "@metaverse/types";
import {
type Player,
type PlayerActions,
type User,
type Vector3,
} from "@metaverse/types";
import { type Mesh } from "three";
import { create } from "zustand";
@@ -23,6 +28,7 @@ interface PlayerStore {
setPlayers: (actors: Player[]) => void;
setUser: (user: User) => void;
setAvailableAction: (availableAction?: PlayerActions) => void;
updatePlayerPosition: (id: string, position: Vector3) => void;
setTargetedObject: (object?: Mesh) => void;
set: (payload: Partial<PlayerStore>) => void;
}
@@ -40,6 +46,12 @@ const usePlayerStore = create<PlayerStore>((set) => {
setPlayers: (players) => set({ players }),
setUser: (user) => set({ user }),
setAvailableAction: (availableAction) => set({ availableAction }),
updatePlayerPosition: (id, position) =>
set((state) => ({
players: state.players.map((p) =>
p.id === id ? { ...p, position } : p,
),
})),
setTargetedObject: (targetedObject) => set({ targetedObject }),
set,
};
@@ -0,0 +1,22 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`PlayerActionPrompt Component > when rendering > should render when action is available and settings modal is closed 1`] = `
<div
class="actions-layout"
>
<div
class="action-prompt"
>
<kbd
class="action-key"
>
E
</kbd>
<span
class="action-text"
>
Change Floor
</span>
</div>
</div>
`;
+6
View File
@@ -0,0 +1,6 @@
// The floor id embedded in a scene name/url, e.g. "/scenes/floor10.glb" -> "10".
// Seat ids follow the `chair_<floor>_<index>` convention, so this derivation
// must stay identical between the 3D interactable overlays and the 2D map for
// their seat lookups to line up.
export const getSceneFloor = (scene: string): string =>
scene.match(/(\d+)/)?.[1] || "10";
+277
View File
@@ -0,0 +1,277 @@
import { Mesh, Vector3, type Object3D } from "three";
// Metres above the detected floor that still count as "in the room" — anything
// starting higher is ceiling structure and is skipped.
const GROUND_ZONE_HEIGHT = 3.5;
// Objects taller than this are walls/pillars rather than furniture.
const MAX_OBJECT_HEIGHT = 4;
// A footprint covering more than this share of the scene is the building shell
// itself, which would paint over everything else.
const SHELL_AREA_RATIO = 0.6;
// Metres; discards degenerate slivers.
const MIN_FOOTPRINT = 0.05;
// Mesh bottoms are bucketed into bands this tall to find the floor level.
const BUCKET_SIZE = 0.5;
// Sub-sample stride for high-poly geometries so we don't hull every vertex:
// each mesh contributes at most ~64 evenly spaced vertices to its hull.
const VERTEX_STRIDE_TARGET = 64;
// Share of the scene's longer side left as empty margin around the map.
const VIEWBOX_PADDING_RATIO = 0.04;
type Vec2 = { x: number; z: number };
interface MeshFootprint {
polygon: Vec2[];
minX: number;
maxX: number;
minZ: number;
maxZ: number;
area: number;
minY: number;
maxY: number;
}
// 2D cross product of o→a and o→b on the XZ plane. Only the sign is used:
// positive means the turn from o→a to o→b is counter-clockwise, negative
// clockwise, zero collinear — which is how the hull below tells a real corner
// from a dent it has to pop off.
const cross = (o: Vec2, a: Vec2, b: Vec2) =>
(a.x - o.x) * (b.z - o.z) - (a.z - o.z) * (b.x - o.x);
// Andrew's monotone chain: sort the points, sweep left-to-right for the lower
// hull and right-to-left for the upper one, dropping any point that would make
// the chain turn the wrong way.
const convexHull = (points: Vec2[]): Vec2[] => {
const pts = points.slice().sort((a, b) => a.x - b.x || a.z - b.z);
if (pts.length < 3) return pts;
const lower: Vec2[] = [];
for (const p of pts) {
while (
lower.length >= 2 &&
cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0
)
lower.pop();
lower.push(p);
}
const upper: Vec2[] = [];
for (let i = pts.length - 1; i >= 0; i--) {
const p = pts[i];
while (
upper.length >= 2 &&
cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0
)
upper.pop();
upper.push(p);
}
lower.pop();
upper.pop();
return lower.concat(upper);
};
const polygonArea = (poly: Vec2[]): number => {
let area = 0;
for (let i = 0; i < poly.length; i++) {
const a = poly[i];
const b = poly[(i + 1) % poly.length];
area += a.x * b.z - b.x * a.z;
}
return Math.abs(area) / 2;
};
const computeMeshFootprint = (mesh: Mesh): MeshFootprint | null => {
if (!mesh.geometry) return null;
const posAttr = mesh.geometry.getAttribute("position");
if (!posAttr) return null;
mesh.updateWorldMatrix(true, false);
const stride = Math.max(1, Math.floor(posAttr.count / VERTEX_STRIDE_TARGET));
const v = new Vector3();
const projected: Vec2[] = [];
let minY = Infinity;
let maxY = -Infinity;
for (let i = 0; i < posAttr.count; i += stride) {
v.set(posAttr.getX(i), posAttr.getY(i), posAttr.getZ(i));
v.applyMatrix4(mesh.matrixWorld);
if (!isFinite(v.x) || !isFinite(v.y) || !isFinite(v.z)) return null;
if (v.y < minY) minY = v.y;
if (v.y > maxY) maxY = v.y;
projected.push({ x: v.x, z: v.z });
}
if (projected.length < 3) return null;
const polygon = convexHull(projected);
if (polygon.length < 3) return null;
let minX = Infinity;
let maxX = -Infinity;
let minZ = Infinity;
let maxZ = -Infinity;
for (const p of polygon) {
if (p.x < minX) minX = p.x;
if (p.x > maxX) maxX = p.x;
if (p.z < minZ) minZ = p.z;
if (p.z > maxZ) maxZ = p.z;
}
return {
polygon,
minX,
maxX,
minZ,
maxZ,
area: polygonArea(polygon),
minY,
maxY,
};
};
export interface MapPolygon {
points: Array<[number, number]>;
height: number;
}
// A claimable seat anchor, keyed by its chair store id (`chair_<floor>_<index>`)
// so the map can cross-reference live occupancy from the chair store.
export interface MapSeat {
key: string;
x: number;
z: number;
}
export interface SceneMap {
polygons: MapPolygon[];
seats: MapSeat[];
bounds: { minX: number; minZ: number; width: number; depth: number };
viewBox: { x: number; y: number; width: number; height: number };
flipOffset: number;
flipOffsetX: number;
}
export const buildSceneMap = (
root: Object3D,
floor: string,
): SceneMap | null => {
// The original useGLTF scene hasn't necessarily had its world matrices
// refreshed — propagate parent transforms before sampling corners.
root.updateMatrixWorld(true);
const all: MeshFootprint[] = [];
// Seat anchors are scene objects tagged with a userData.id (same heuristic the
// 3D InteractableOverlays use). Deduped by key so a chair built from several
// child meshes only yields one marker.
const seatMap = new Map<string, MapSeat>();
const seatPos = new Vector3();
root.traverse((obj) => {
const seatId = obj.userData?.id;
if (seatId != null) {
const key = `chair_${floor}_${seatId}`;
if (!seatMap.has(key)) {
obj.getWorldPosition(seatPos);
seatMap.set(key, { key, x: seatPos.x, z: seatPos.z });
}
}
if (!(obj instanceof Mesh) || !obj.visible) return;
const fp = computeMeshFootprint(obj);
if (fp) all.push(fp);
});
if (all.length === 0) return null;
// Find the most populated mesh-bottom level — that's the playable floor.
const buckets = new Map<number, number>();
for (const fp of all) {
const key = Math.floor(fp.minY / BUCKET_SIZE) * BUCKET_SIZE;
buckets.set(key, (buckets.get(key) ?? 0) + 1);
}
let floorY = 0;
let bestCount = 0;
for (const [key, count] of buckets) {
if (count > bestCount) {
bestCount = count;
floorY = key;
}
}
const ceilingCutoff = floorY + GROUND_ZONE_HEIGHT;
const groundFootprints: MeshFootprint[] = [];
for (const fp of all) {
if (fp.minY > ceilingCutoff) continue;
if (fp.minY < floorY - 1) continue;
if (fp.maxY - fp.minY > MAX_OBJECT_HEIGHT) continue;
if (fp.maxX - fp.minX < MIN_FOOTPRINT) continue;
if (fp.maxZ - fp.minZ < MIN_FOOTPRINT) continue;
groundFootprints.push(fp);
}
if (groundFootprints.length === 0) return null;
let bMinX = Infinity;
let bMaxX = -Infinity;
let bMinZ = Infinity;
let bMaxZ = -Infinity;
for (const fp of groundFootprints) {
if (fp.minX < bMinX) bMinX = fp.minX;
if (fp.maxX > bMaxX) bMaxX = fp.maxX;
if (fp.minZ < bMinZ) bMinZ = fp.minZ;
if (fp.maxZ > bMaxZ) bMaxZ = fp.maxZ;
}
const sceneArea =
Math.max(bMaxX - bMinX, 0.001) * Math.max(bMaxZ - bMinZ, 0.001);
const drawable = groundFootprints.filter(
(fp) => fp.area < sceneArea * SHELL_AREA_RATIO,
);
if (drawable.length === 0) return null;
// Tallest objects drawn last so they sit on top.
drawable.sort((a, b) => a.maxY - b.maxY);
console.info(
`[sceneMap] all=${all.length} ground=${groundFootprints.length} drawn=${drawable.length} floor=${floorY.toFixed(2)} cutoff=${ceilingCutoff.toFixed(2)}`,
);
const minX = bMinX;
const minZ = bMinZ;
const width = bMaxX - bMinX;
const depth = bMaxZ - bMinZ;
if (width <= 0 || depth <= 0) return null;
const padding = Math.max(width, depth) * VIEWBOX_PADDING_RATIO;
const viewMinX = minX - padding;
const viewMinZ = minZ - padding;
const viewWidth = width + padding * 2;
const viewDepth = depth + padding * 2;
const polygons: MapPolygon[] = drawable.map((fp) => {
const heightAboveFloor = Math.max(0, fp.maxY - floorY);
const height = Math.max(
0,
Math.min(1, heightAboveFloor / GROUND_ZONE_HEIGHT),
);
return {
points: fp.polygon.map((p) => [p.x, p.z] as [number, number]),
height,
};
});
// Flip offsets keep the mirrored axis inside the viewBox: a point p on axis
// A maps to (2*viewMinA + viewSizeA) - p, i.e. it reflects across the axis
// midpoint. flipOffset flips Z (north stays up); flipOffsetX flips X so the
// map's left/right matches what the player sees in the 3D scene.
const flipOffset = viewMinZ * 2 + viewDepth;
const flipOffsetX = viewMinX * 2 + viewWidth;
return {
polygons,
seats: Array.from(seatMap.values()),
bounds: { minX, minZ, width, depth },
viewBox: { x: viewMinX, y: viewMinZ, width: viewWidth, height: viewDepth },
flipOffset,
flipOffsetX,
};
};
+125 -41
View File
@@ -25,8 +25,12 @@ function getColliderType(name: string): ColliderType {
}
/**
* Compute a transform-independent shape hash for a mesh geometry.
* Uses vertex count, sorted bounding-box dimensions, index count and attribute names.
* Compute a transform-independent shape hash used to bucket candidate meshes
* for instancing. Combines vertex/index count, the *unsorted* local bounding
* box (min & max, so origin offset and orientation of the extents matter) and
* an FNV-1a checksum over sampled vertex positions. This makes accidental
* collisions between genuinely different shapes rare — and geometryEquals()
* still verifies exact identity before anything is actually instanced.
*/
function geometryShapeHash(geo: Mesh["geometry"]): string {
const pos = geo.attributes.position;
@@ -34,14 +38,69 @@ function geometryShapeHash(geo: Mesh["geometry"]): string {
geo.computeBoundingBox();
const bb = geo.boundingBox!;
const dims = [
Math.round((bb.max.x - bb.min.x) * 1000),
Math.round((bb.max.y - bb.min.y) * 1000),
Math.round((bb.max.z - bb.min.z) * 1000),
].sort((a, b) => a - b);
const q = (n: number) => Math.round(n * 1000);
// FNV-1a over ~64 sampled position values so different vertex layouts with
// the same count and bounds don't hash together.
const arr = pos.array;
const step = Math.max(1, Math.floor(arr.length / 64));
let h = 2166136261;
for (let i = 0; i < arr.length; i += step) {
h ^= q(arr[i]);
h = Math.imul(h, 16777619);
}
h >>>= 0;
const bbKey = [
q(bb.min.x),
q(bb.min.y),
q(bb.min.z),
q(bb.max.x),
q(bb.max.y),
q(bb.max.z),
].join(",");
const attrs = Object.keys(geo.attributes).sort().join(",");
return `v${pos.count}_bb${dims.join("x")}_idx${geo.index ? geo.index.count : 0}_${attrs}`;
return `v${pos.count}_bb${bbKey}_idx${geo.index ? geo.index.count : 0}_h${h}_${attrs}`;
}
/**
* Strict geometry identity check used to verify a bucket before instancing.
* Fast-paths shared references (linked GLTF/Blender duplicates keep the same
* geometry object after scene.clone()), otherwise compares position and index
* buffers exactly. Duplicated identical geometry decodes to identical float
* values, so an exact comparison never produces false positives — a mismatch
* just leaves the mesh as an individual (un-instanced) mesh, which is always
* safe to render.
*/
function geometryEquals(a: Mesh["geometry"], b: Mesh["geometry"]): boolean {
if (a === b) return true;
const pa = a.attributes.position;
const pb = b.attributes.position;
if (!pa || !pb || pa.count !== pb.count) return false;
const ia = a.index;
const ib = b.index;
if (!!ia !== !!ib) return false;
if (ia && ib && ia.count !== ib.count) return false;
const aArr = pa.array;
const bArr = pb.array;
if (aArr.length !== bArr.length) return false;
for (let i = 0; i < aArr.length; i++) {
if (aArr[i] !== bArr[i]) return false;
}
if (ia && ib) {
const aIdx = ia.array;
const bIdx = ib.array;
if (aIdx.length !== bIdx.length) return false;
for (let i = 0; i < aIdx.length; i++) {
if (aIdx[i] !== bIdx[i]) return false;
}
}
return true;
}
/**
@@ -124,43 +183,68 @@ export function optimizeScene(root: Object3D): SceneOptimization {
for (const [, { material, colliderType, meshes }] of meshByShape) {
if (meshes.length < MIN_INSTANCES) continue;
const vertCount = meshes[0].geometry.attributes.position?.count ?? 0;
// Skip instancing for high-poly / low-count groups — frustum culling
// saves more than the draw-call reduction from instancing.
if (vertCount > MAX_VERTS_PER_INSTANCE && meshes.length < 30) continue;
const instanced = new InstancedMesh(
meshes[0].geometry,
material,
meshes.length,
);
const box = new Box3();
for (let i = 0; i < meshes.length; i++) {
meshes[i].updateWorldMatrix(true, false);
instanced.setMatrixAt(i, meshes[i].matrixWorld);
// Expand bounding box to cover all instances
meshes[i].geometry.computeBoundingBox();
const meshBox = meshes[i].geometry.boundingBox!.clone();
meshBox.applyMatrix4(meshes[i].matrixWorld);
box.union(meshBox);
if (colliderType !== "none") {
meshes[i].layers.set(1);
colliderOnlyUUIDs.add(meshes[i].uuid);
} else {
skipUUIDs.add(meshes[i].uuid);
// Partition the shape-hash group into buckets of *exactly* identical
// geometry. Hash collisions (different shapes with the same vertex count
// and bounds) land in separate buckets, and any mesh that doesn't match
// the others ends up alone — so mismatched geometry is never instanced,
// and those meshes fall through to be rendered individually, as-is.
const buckets: { geometry: Mesh["geometry"]; meshes: Mesh[] }[] = [];
for (const mesh of meshes) {
let bucket = buckets.find((b) =>
geometryEquals(b.geometry, mesh.geometry),
);
if (!bucket) {
bucket = { geometry: mesh.geometry, meshes: [] };
buckets.push(bucket);
}
bucket.meshes.push(mesh);
}
// Set bounding sphere so Three.js can frustum-cull the whole group
instanced.geometry.boundingSphere = box.getBoundingSphere(new Sphere());
instanced.frustumCulled = true;
for (const bucket of buckets) {
const group = bucket.meshes;
instanced.instanceMatrix.needsUpdate = true;
instancedMeshes.push(instanced);
// Singletons (including collision rejects) are left in the scene tree
// and rendered as normal meshes — never added to skip/colliderOnly.
if (group.length < MIN_INSTANCES) continue;
const vertCount = group[0].geometry.attributes.position?.count ?? 0;
// Skip instancing for high-poly / low-count groups — frustum culling
// saves more than the draw-call reduction from instancing.
if (vertCount > MAX_VERTS_PER_INSTANCE && group.length < 30) continue;
const instanced = new InstancedMesh(
group[0].geometry,
material,
group.length,
);
const box = new Box3();
for (let i = 0; i < group.length; i++) {
group[i].updateWorldMatrix(true, false);
instanced.setMatrixAt(i, group[i].matrixWorld);
// Expand bounding box to cover all instances
group[i].geometry.computeBoundingBox();
const meshBox = group[i].geometry.boundingBox!.clone();
meshBox.applyMatrix4(group[i].matrixWorld);
box.union(meshBox);
if (colliderType !== "none") {
group[i].layers.set(1);
colliderOnlyUUIDs.add(group[i].uuid);
} else {
skipUUIDs.add(group[i].uuid);
}
}
// Set bounding sphere so Three.js can frustum-cull the whole group
instanced.geometry.boundingSphere = box.getBoundingSphere(new Sphere());
instanced.frustumCulled = true;
instanced.instanceMatrix.needsUpdate = true;
instancedMeshes.push(instanced);
}
}
return { instancedMeshes, colliderOnlyUUIDs, skipUUIDs };
Binary file not shown.
+2
View File
@@ -9,6 +9,7 @@ export const KEY_MAPPINGS: Record<KeyboardEvent["code"], keyof PlayerControls> =
KeyF: "toggleFirstPerson",
KeyE: "triggerAction",
KeyK: "voiceRecording",
KeyM: "toggleMap",
Space: "jump",
};
@@ -34,6 +35,7 @@ export const KEY_DESCRIPTIONS: Record<keyof PlayerControls, string> = {
toggleMouseLook: "Toggle camera view with a mouse",
triggerAction: "Trigger Action",
voiceRecording: "Start voice recording",
toggleMap: "Toggle Map",
};
export const ACTION_KEY_DISPLAY: Record<keyof PlayerControls, string> = {
+1
View File
@@ -27,6 +27,7 @@ export type PlayerControls = {
strafeRight: boolean;
toggleFirstPerson: boolean;
toggleDebug: boolean;
toggleMap: boolean;
toggleMouseLook: boolean;
triggerAction: boolean;
voiceRecording: boolean;