Binary file not shown.
@@ -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 />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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.
@@ -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> = {
|
||||
|
||||
@@ -27,6 +27,7 @@ export type PlayerControls = {
|
||||
strafeRight: boolean;
|
||||
toggleFirstPerson: boolean;
|
||||
toggleDebug: boolean;
|
||||
toggleMap: boolean;
|
||||
toggleMouseLook: boolean;
|
||||
triggerAction: boolean;
|
||||
voiceRecording: boolean;
|
||||
|
||||
Reference in New Issue
Block a user