add map tours

This commit is contained in:
Brian Beck 2026-03-18 06:26:17 -07:00
parent 7541c4e716
commit 0736feb4c5
80 changed files with 1666 additions and 638 deletions

View file

@ -0,0 +1,300 @@
import { useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import {
Box3,
Camera,
CatmullRomCurve3,
Euler,
Vector3,
Quaternion,
Matrix4,
Scene,
} from "three";
import { cameraTourStore } from "../state/cameraTourStore";
import type { TourAnimation } from "../state/cameraTourStore";
import type { TourTarget } from "./mapTourCategories";
function easeInOutCubic(t: number): number {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
const DEFAULT_ORBIT_RADIUS = 4;
const DEFAULT_ORBIT_HEIGHT = 2;
const MIN_ORBIT_RADIUS = 2.75;
const ORBIT_RADIUS_SCALE = 1.5; // multiplier on bounding sphere radius
const ORBIT_ANGULAR_SPEED = 0.6; // rad/s
const ORBIT_SWEEP = (3 / 4) * (2 * Math.PI); // 270 degrees
const ORBIT_CONSTANT_DURATION = ORBIT_SWEEP / ORBIT_ANGULAR_SPEED;
const ORBIT_EASE_OUT_DURATION = 1.5; // seconds to decelerate to stop
const MIN_TRAVEL_DURATION = 1.5;
const MAX_TRAVEL_DURATION = 6.0;
const TRAVEL_SPEED = 180; // units/s for duration calc
/** Orientation completes at this fraction of total travel time (runs ahead of position). */
const LOOK_LEAD = 1.4;
// Reusable temp objects to avoid GC pressure.
const _box = new Box3();
const _center = new Vector3();
const _size = new Vector3();
const _v = new Vector3();
const _v3 = new Vector3();
const _vFocus = new Vector3();
const _q = new Quaternion();
const _qTarget = new Quaternion();
const _mat = new Matrix4();
const _euler = new Euler();
/** Get the orbit focus point: resolved bounding box center or raw position. */
function orbitFocus(animation: TourAnimation): Vector3 {
if (animation.orbitCenter) {
return _vFocus.set(
animation.orbitCenter[0],
animation.orbitCenter[1],
animation.orbitCenter[2],
);
}
const target = animation.targets[animation.currentIndex];
return _vFocus.set(target.position[0], target.position[1], target.position[2]);
}
function getOrbitRadius(animation: TourAnimation): number {
return animation.orbitRadius ?? DEFAULT_ORBIT_RADIUS;
}
function getOrbitHeight(animation: TourAnimation): number {
const r = getOrbitRadius(animation);
return r * (DEFAULT_ORBIT_HEIGHT / DEFAULT_ORBIT_RADIUS);
}
function orbitPoint(
animation: TourAnimation,
angle: number,
out: Vector3,
): Vector3 {
const focus = orbitFocus(animation);
const r = getOrbitRadius(animation);
const h = getOrbitHeight(animation);
return out.set(
focus.x + Math.cos(angle) * r,
focus.y + h,
focus.z + Math.sin(angle) * r,
);
}
/** Resolve bounding box of the target entity from the scene graph. */
function resolveTargetBounds(
scene: Scene,
target: TourTarget,
animation: TourAnimation,
): void {
const obj = scene.getObjectByName(target.entityId);
if (obj) {
_box.setFromObject(obj);
_box.getCenter(_center);
_box.getSize(_size);
animation.orbitCenter = [_center.x, _center.y, _center.z];
const sphereRadius = _size.length() / 2;
animation.orbitRadius = Math.max(
MIN_ORBIT_RADIUS,
sphereRadius * ORBIT_RADIUS_SCALE,
);
} else {
animation.orbitCenter = null;
animation.orbitRadius = null;
}
}
/** Strip roll from a quaternion, keeping only yaw and pitch. */
function stripRoll(q: Quaternion): Quaternion {
_euler.setFromQuaternion(q, "YXZ");
_euler.z = 0;
return q.setFromEuler(_euler);
}
function computeLookAtQuat(from: Vector3, to: Vector3): Quaternion {
_mat.lookAt(from, to, _v3.set(0, 1, 0));
_qTarget.setFromRotationMatrix(_mat);
return stripRoll(_qTarget);
}
function buildCurve(
startPos: Vector3,
animation: TourAnimation,
entryAngle: number,
): CatmullRomCurve3 {
const focus = orbitFocus(animation);
const entry = orbitPoint(animation, entryAngle, _v.clone());
// Midpoint: halfway between start and entry, elevated.
const mid = new Vector3().addVectors(startPos, entry).multiplyScalar(0.5);
// Pull midpoint toward the target and elevate.
mid.lerp(focus, 0.3);
mid.y += Math.max(20, startPos.distanceTo(entry) * 0.15);
return new CatmullRomCurve3(
[startPos.clone(), mid, entry],
false,
"centripetal",
);
}
function computeEntryAngle(fromPos: Vector3, animation: TourAnimation): number {
const focus = orbitFocus(animation);
return Math.atan2(fromPos.z - focus.z, fromPos.x - focus.x);
}
function computeTravelDuration(distance: number): number {
return Math.max(
MIN_TRAVEL_DURATION,
Math.min(MAX_TRAVEL_DURATION, distance / TRAVEL_SPEED),
);
}
function advanceTravel(
animation: TourAnimation,
camera: Camera,
delta: number,
scene: Scene,
): void {
const target = animation.targets[animation.currentIndex];
// First frame: capture start state, resolve bounds, and build curve.
if (!animation.curve) {
animation.startPos = [
camera.position.x,
camera.position.y,
camera.position.z,
];
stripRoll(_q.copy(camera.quaternion));
animation.startQuat = [_q.x, _q.y, _q.z, _q.w];
resolveTargetBounds(scene, target, animation);
const startVec = camera.position.clone();
const entryAngle = computeEntryAngle(startVec, animation);
animation.curve = buildCurve(startVec, animation, entryAngle);
const distance = animation.curve.getLength();
animation.phaseDuration = computeTravelDuration(distance);
animation.elapsed = 0;
return;
}
animation.elapsed += delta;
const t = Math.min(
1,
easeInOutCubic(animation.elapsed / animation.phaseDuration),
);
// Move along the curve.
animation.curve.getPointAt(t, _v);
camera.position.copy(_v);
// Orientation: slerp from start toward lookAt(target), leading position.
// lookT runs ahead of t so the camera turns before the body arrives.
const rawLookT = Math.min(
1,
(animation.elapsed / animation.phaseDuration) * LOOK_LEAD,
);
const lookT = easeInOutCubic(rawLookT);
const focus = orbitFocus(animation);
const lookQ = computeLookAtQuat(_v, focus);
if (lookT < 1 && animation.startQuat) {
_q.set(
animation.startQuat[0],
animation.startQuat[1],
animation.startQuat[2],
animation.startQuat[3],
);
_q.slerp(lookQ, lookT);
camera.quaternion.copy(_q);
} else {
camera.quaternion.copy(lookQ);
}
// Transition to orbit when travel completes.
if (animation.elapsed >= animation.phaseDuration) {
animation.phase = "orbiting";
animation.elapsed = 0;
// Derive start angle from current position.
animation.orbitStartAngle = computeEntryAngle(camera.position, animation);
}
}
function advanceOrbit(
animation: TourAnimation,
camera: Camera,
delta: number,
): void {
const isSingleTarget = animation.targets.length === 1;
const isLastTarget = animation.currentIndex >= animation.targets.length - 1;
animation.elapsed += delta;
const startAngle = animation.orbitStartAngle;
const totalDuration = ORBIT_CONSTANT_DURATION + ORBIT_EASE_OUT_DURATION;
// Compute angle: constant speed sweep, then ease-out deceleration.
let angle: number;
if (animation.elapsed <= ORBIT_CONSTANT_DURATION) {
angle = startAngle + animation.elapsed * ORBIT_ANGULAR_SPEED;
} else {
const easeElapsed = animation.elapsed - ORBIT_CONSTANT_DURATION;
const t = Math.min(1, easeElapsed / ORBIT_EASE_OUT_DURATION);
// Integral of (1 - t): distance = elapsed * speed * (1 - t/2)
const easedDistance = easeElapsed * ORBIT_ANGULAR_SPEED * (1 - t / 2);
angle = startAngle + ORBIT_SWEEP + easedDistance;
}
orbitPoint(animation, angle, _v);
camera.position.copy(_v);
const focus = orbitFocus(animation);
const lookQ = computeLookAtQuat(_v, focus);
camera.quaternion.copy(lookQ);
// Handle orbit completion.
if (animation.elapsed >= totalDuration) {
if (isSingleTarget) {
// Single flyTo: just stop.
cameraTourStore.getState().cancel();
} else if (isLastTarget) {
// Last target in tour: done.
cameraTourStore.getState().cancel();
} else {
// Mid-tour: advance to next target (via set() to notify subscribers).
cameraTourStore.getState().advanceTarget();
}
}
}
export function CameraTourConsumer() {
const invalidate = useThree((s) => s.invalidate);
const camera = useThree((s) => s.camera);
const scene = useThree((s) => s.scene);
const prevAnimationRef = useRef<TourAnimation | null>(null);
useFrame((_state, delta) => {
const animation = cameraTourStore.getState().animation;
if (!animation) {
if (prevAnimationRef.current) {
stripRoll(camera.quaternion);
prevAnimationRef.current = null;
}
return;
}
invalidate();
prevAnimationRef.current = animation;
if (animation.phase === "traveling") {
advanceTravel(animation, camera, delta, scene);
} else {
advanceOrbit(animation, camera, delta);
}
});
return null;
}

View file

@ -12,6 +12,7 @@ import { DebugSuspense } from "./DebugSuspense";
import { ShapeErrorBoundary } from "./ShapeErrorBoundary";
import { FloatingLabel } from "./FloatingLabel";
import { useSettings } from "./SettingsProvider";
import { DEFAULT_TEAM_NAMES } from "../stringUtils";
import { Camera } from "./Camera";
import { WayPoint } from "./WayPoint";
import { TerrainBlock } from "./TerrainBlock";
@ -65,10 +66,6 @@ const AudioEmitter = createLazy("AudioEmitter", () => import("./AudioEmitter"));
const WaterBlock = createLazy("WaterBlock", () => import("./WaterBlock"));
const WeaponModel = createLazy("WeaponModel", () => import("./ShapeModel"));
const TEAM_NAMES: Record<number, string> = {
1: "Storm",
2: "Inferno",
};
/**
* Renders a GameEntity by dispatching to the appropriate renderer based
@ -145,7 +142,7 @@ function ShapeEntity({ entity }: { entity: ShapeEntityType }) {
// Flag label for flag Items
const isFlag = entity.dataBlock?.toLowerCase() === "flag";
const teamName =
entity.teamId && entity.teamId > 0 ? TEAM_NAMES[entity.teamId] : null;
entity.teamId && entity.teamId > 0 ? DEFAULT_TEAM_NAMES[entity.teamId] : null;
const flagLabel = isFlag && teamName ? `${teamName} Flag` : null;
const loadingColor =

View file

@ -13,6 +13,7 @@ import { ObserverCamera } from "./ObserverCamera";
import { AudioEnabled } from "./AudioEnabled";
import { DebugEnabled } from "./DebugEnabled";
import { InputConsumer } from "./InputConsumer";
import { CameraTourConsumer } from "./CameraTourConsumer";
function createLazy(
name: string,
@ -86,6 +87,7 @@ export const GameView = memo(function GameView({
/>
</Suspense>
) : null}
<CameraTourConsumer />
<InputConsumer />
</AudioProvider>
</CamerasProvider>

View file

@ -16,6 +16,7 @@ import { Accordion, AccordionGroup } from "./Accordion";
import styles from "./InspectorControls.module.css";
import { useTouchDevice } from "./useTouchDevice";
import { DemoTimeline } from "./DemoTimeline";
import { MapTourPanel } from "./MapTourPanel";
import { useRecording } from "./RecordingProvider";
import { useDataSource, useMissionName } from "../state/gameEntityStore";
import { useLiveSelector } from "../state/liveConnectionStore";
@ -187,6 +188,11 @@ export const InspectorControls = memo(function InspectorControls({
<DemoTimeline />
</Accordion>
)}
{dataSource === "map" && !recording && (
<Accordion value="mapFeatures" label="Map Features" noPadding>
<MapTourPanel />
</Accordion>
)}
<Accordion value="controls" label="Controls">
<div className={styles.Field}>
<label htmlFor="speedInput">Fly speed</label>

View file

@ -185,3 +185,27 @@
font-size: 28px;
}
}
.ExitTourButton {
composes: ActionButton from "./StreamingMissionInfo.module.css";
gap: 6px;
padding: 4px 10px 4px 6px;
margin: 0 10px 0 auto;
font-size: 20px;
}
.ExitTourButton .ButtonLabel {
font-size: 13px;
font-weight: 500;
}
@media (max-width: 799px) {
.ExitTourButton {
padding-right: 6px;
margin: 0 10px 0 0;
}
.ExitTourButton .ButtonLabel {
display: none;
}
}

View file

@ -48,7 +48,9 @@ import {
LuPanelTopClose,
LuPanelTopOpen,
} from "react-icons/lu";
import { cameraTourStore, useCameraTour } from "../state/cameraTourStore";
import { useTouchDevice } from "./useTouchDevice";
import { HiMiniArrowLeftEndOnRectangle } from "react-icons/hi2";
import styles from "./MapInspector.module.css";
function ViewTransition({ children }: { children: ReactNode }) {
@ -102,6 +104,7 @@ export function MapInspector() {
const [missionLoadingProgress, setMissionLoadingProgress] = useState(0);
const [showLoadingIndicator, setShowLoadingIndicator] = useState(true);
const isTouch = useTouchDevice();
const isTourActive = useCameraTour((s) => s.animation !== null);
const changeMission = useCallback(
(mission: CurrentMission) => {
@ -170,6 +173,12 @@ export function MapInspector() {
}
}, [isTouch, recording, setSidebarOpen]);
useEffect(() => {
if (isTourActive && isTouch) {
setSidebarOpen(false);
}
}, [isTouch, isTourActive, setSidebarOpen]);
const loadingProgress = missionLoadingProgress;
const isLoading = loadingProgress < 1;
@ -246,7 +255,7 @@ export function MapInspector() {
<Activity mode={!hasStreamData || choosingMap ? "visible" : "hidden"}>
<MissionSelect
value={choosingMap ? "" : missionName}
missionType={choosingMap ? "" : missionType ?? ""}
missionType={choosingMap ? "" : (missionType ?? "")}
onChange={changeMission}
autoFocus={choosingMap}
onCancel={handleCancelChoosingMap}
@ -263,6 +272,16 @@ export function MapInspector() {
</button>
)}
</Activity>
{isTourActive && (
<button
type="button"
className={styles.ExitTourButton}
onClick={() => cameraTourStore.getState().cancel()}
>
<HiMiniArrowLeftEndOnRectangle />
<span className={styles.ButtonLabel}>Exit tour</span>
</button>
)}
</header>
{sidebarOpen ? <div className={styles.Backdrop} /> : null}
<Activity mode={sidebarOpen ? "visible" : "hidden"}>

View file

@ -0,0 +1,163 @@
.Root {
display: flex;
flex-direction: column;
}
.Empty {
font-size: 12px;
opacity: 0.5;
padding: 4px 10px 12px 10px;
text-align: center;
}
.CategoryHeader {
display: flex;
align-items: baseline;
gap: 6px;
padding: 4px 4px 4px 14px;
font-size: 12px;
font-weight: 600;
color: rgba(255, 255, 255, 0.6);
text-transform: uppercase;
letter-spacing: 0.04em;
user-select: none;
}
.CategoryHeader:not(:first-child) {
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding-top: 8px;
}
.CategoryCount {
font-weight: 400;
opacity: 0.7;
}
.TourButton {
display: flex;
align-items: center;
gap: 5px;
margin: 0 0 0 auto;
padding: 6px 8px;
font-family: inherit;
font-size: 12px;
font-weight: 500;
border: 0;
border-radius: 0;
background: transparent;
color: rgba(255, 255, 255, 0.8);
cursor: pointer;
text-transform: none;
}
.TourButton[data-active="true"] {
}
.PlayIcon {
color: #74c0fc;
}
.ExitIcon {
color: rgb(255, 131, 99);
}
@media (hover: hover) {
.TourButton:hover {
color: rgba(255, 255, 255, 1);
}
.TourButton[data-active="true"]:hover {
}
}
.ItemList {
display: flex;
flex-direction: column;
padding: 0 0 8px 0;
}
.ItemRow {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px 4px 12px;
border: 0;
background: transparent;
color: rgba(255, 255, 255, 0.8);
font-family: inherit;
font-size: 13px;
text-align: left;
cursor: pointer;
white-space: nowrap;
}
.ItemRow .PlayIcon {
font-size: 12px;
}
@media (hover: hover) {
.ItemRow:not(:hover) .PlayIcon {
visibility: hidden;
}
}
.ItemRow[data-active="true"] {
background: rgba(0, 85, 177, 0.5);
color: #fff;
}
@media (hover: hover) {
.ItemRow:hover {
background: rgba(255, 255, 255, 0.1);
}
.ItemRow[data-active="true"]:hover {
background: rgba(0, 85, 177, 0.6);
}
}
.ItemRow:active {
background: rgba(0, 85, 177, 0.8);
color: #fff;
}
.ItemLabel {
overflow: hidden;
text-overflow: ellipsis;
}
.TeamBadge {
flex-shrink: 0;
font-size: 11px;
margin: 0 0 0 6px;
padding: 0 4px;
border-radius: 2px;
line-height: 1.5;
color: rgba(255, 255, 255, 1);
}
.TeamBadge[data-team="1"] {
background: rgba(8, 108, 138, 0.8);
}
.TeamBadge[data-team="2"] {
background: rgba(143, 94, 20, 0.8);
}
@media (pointer: coarse) {
.CategoryHeader {
font-size: 13px;
padding: 6px 8px 6px 16px;
}
.TourButton {
font-size: 13px;
}
.ItemRow {
padding-top: 6px;
padding-bottom: 6px;
padding-left: 14px;
font-size: 14px;
}
}

View file

@ -0,0 +1,146 @@
import { useMemo } from "react";
import { useGameEntities } from "../state/gameEntityStore";
import { useEngineSelector } from "../state/engineStore";
import { cameraTourStore, useCameraTour } from "../state/cameraTourStore";
import {
categorizeEntities,
type TourCategory,
type TourTarget,
} from "./mapTourCategories";
import { DEFAULT_TEAM_NAMES } from "../stringUtils";
import styles from "./MapTourPanel.module.css";
import { BsPlayFill } from "react-icons/bs";
import { HiMiniArrowLeftEndOnRectangle } from "react-icons/hi2";
function selectTourState(state: {
animation: {
targets: TourTarget[];
categoryName: string | null;
currentIndex: number;
} | null;
}) {
if (!state.animation) return null;
return {
targets: state.animation.targets,
categoryName: state.animation.categoryName,
currentIndex: state.animation.currentIndex,
};
}
function tourStateEqual(
a: ReturnType<typeof selectTourState>,
b: ReturnType<typeof selectTourState>,
): boolean {
if (a === b) return true;
if (!a || !b) return false;
return (
a.categoryName === b.categoryName &&
a.currentIndex === b.currentIndex &&
a.targets === b.targets
);
}
export function MapTourPanel() {
const entities = useGameEntities();
const datablocks = useEngineSelector(
(state) => state.runtime.runtime?.state.datablocks,
);
const categories = useMemo(
() => categorizeEntities(entities, datablocks),
[entities, datablocks],
);
const tourState = useCameraTour(selectTourState, tourStateEqual);
if (categories.length === 0) {
return (
<div className={styles.Root}>
<p className={styles.Empty}>No map features found</p>
</div>
);
}
return (
<div className={styles.Root}>
{categories.map((category) => (
<CategoryGroup
key={category.name}
category={category}
tourState={tourState}
/>
))}
</div>
);
}
function CategoryGroup({
category,
tourState,
}: {
category: TourCategory;
tourState: ReturnType<typeof selectTourState>;
}) {
const isTouringCategory =
tourState !== null && tourState.categoryName === category.name;
const handleTourClick = () => {
if (isTouringCategory) {
cameraTourStore.getState().cancel();
} else {
cameraTourStore.getState().startTour(category.targets, category.name);
}
};
return (
<>
<div className={styles.CategoryHeader}>
<span>{category.name}</span>
<span className={styles.CategoryCount}>
({category.targets.length})
</span>
<button
type="button"
className={styles.TourButton}
data-active={isTouringCategory}
onClick={handleTourClick}
>
{isTouringCategory ? (
<>
<HiMiniArrowLeftEndOnRectangle className={styles.ExitIcon} /> Exit
tour
</>
) : (
<>
<BsPlayFill className={styles.PlayIcon} /> Tour all
</>
)}
</button>
</div>
<div className={styles.ItemList}>
{category.targets.map((target, index) => {
const isActive =
(isTouringCategory && tourState!.currentIndex === index) ||
(tourState !== null &&
tourState.targets.length === 1 &&
tourState.targets[0].entityId === target.entityId);
return (
<button
key={target.entityId}
type="button"
className={styles.ItemRow}
data-active={isActive}
onClick={() => cameraTourStore.getState().flyTo(target)}
>
<BsPlayFill className={styles.PlayIcon} />{" "}
<span className={styles.ItemLabel}>{target.label}</span>
{target.teamId != null && target.teamId > 0 && (
<span className={styles.TeamBadge} data-team={target.teamId}>
{DEFAULT_TEAM_NAMES[target.teamId] ?? `Team ${target.teamId}`}
</span>
)}
</button>
);
})}
</div>
</>
);
}

View file

@ -10,6 +10,7 @@ import {
import { useCameras } from "./CamerasProvider";
import { useInputContext } from "./InputContext";
import { useTouchDevice } from "./useTouchDevice";
import { cameraTourStore } from "../state/cameraTourStore";
export const Controls = {
forward: "forward",
@ -57,6 +58,12 @@ export const KEYBOARD_CONTROLS = [
{ name: Controls.camera9, keys: ["Digit9"] },
];
const TOUR_CANCEL_KEYS = new Set([
"KeyW", "KeyA", "KeyS", "KeyD",
"Space", "ShiftLeft", "ShiftRight",
"ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight",
]);
const MIN_SPEED_ADJUSTMENT = 2;
const MAX_SPEED_ADJUSTMENT = 11;
const DRAG_THRESHOLD = 3; // px of movement before it counts as a drag
@ -135,13 +142,21 @@ export function MouseAndKeyboardHandler() {
};
}, [camera, gl.domElement]);
// Exit pointer lock when switching to touch mode.
// Exit pointer lock when switching to touch mode or when a tour starts.
useEffect(() => {
if (isTouch && controlsRef.current?.isLocked) {
controlsRef.current.unlock();
}
}, [isTouch]);
useEffect(() => {
return cameraTourStore.subscribe((state) => {
if (state.animation && controlsRef.current?.isLocked) {
controlsRef.current.unlock();
}
});
}, []);
// Mouse handling: accumulate deltas for input frames.
// In local mode, drag-to-look works without pointer lock.
// Pointer lock and click behavior depend on mode.
@ -204,7 +219,7 @@ export function MouseAndKeyboardHandler() {
nextCamera();
}
// In fly mode, clicks while locked do nothing special.
} else if (e.target === canvas && !didDrag && !getIsTouch()) {
} else if (e.target === canvas && !didDrag && !getIsTouch() && !cameraTourStore.getState().animation) {
controls?.lock();
}
};
@ -281,6 +296,18 @@ export function MouseAndKeyboardHandler() {
};
}, [gl.domElement, setSpeedMultiplier]);
// Escape or movement keys: cancel active camera tour.
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (!cameraTourStore.getState().animation) return;
if (e.code === "Escape" || TOUR_CANCEL_KEYS.has(e.code)) {
cameraTourStore.getState().cancel();
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, []);
// 'O' key: toggle observer mode (sets trigger 2).
useEffect(() => {
if (mode === "local") return;
@ -303,6 +330,9 @@ export function MouseAndKeyboardHandler() {
// Build and emit InputFrame each render frame.
useFrame((_state, delta) => {
// Suppress all input while a camera tour is active.
if (cameraTourStore.getState().animation) return;
const {
forward,
backward,

View file

@ -1,4 +1,5 @@
import { useEngineSelector } from "../state/engineStore";
import { DEFAULT_TEAM_NAMES } from "../stringUtils";
import { textureToUrl } from "../loaders";
import type { StreamEntity, TeamScore, WeaponsHudSlot } from "../stream/types";
import styles from "./PlayerHUD.module.css";
@ -213,15 +214,6 @@ function WeaponHUD() {
);
}
/** Default team names from serverDefaults.cs. */
const DEFAULT_TEAM_NAMES: Record<number, string> = {
1: "Storm",
2: "Inferno",
3: "Starwolf",
4: "Diamond Sword",
5: "Blood Eagle",
6: "Phoenix",
};
function TeamScores() {
const teamScores = useEngineSelector(

View file

@ -5,17 +5,9 @@ import { useEngineSelector } from "../state/engineStore";
import { liveConnectionStore } from "../state/liveConnectionStore";
import { useDataSource } from "../state/gameEntityStore";
import type { PlayerRosterEntry, TeamScore } from "../stream/types";
import { DEFAULT_TEAM_NAMES } from "../stringUtils";
import styles from "./ScoreScreen.module.css";
const DEFAULT_TEAM_NAMES: Record<number, string> = {
1: "Storm",
2: "Inferno",
3: "Starwolf",
4: "Diamond Sword",
5: "Blood Eagle",
6: "Phoenix",
};
function computePingStats(players: PlayerRosterEntry[]): {
avg: number;
dev: number;

View file

@ -1,5 +1,6 @@
import { lazy, Suspense } from "react";
import { useTouchDevice } from "./useTouchDevice";
import { useCameraTour } from "../state/cameraTourStore";
const TouchJoystick = lazy(() =>
import("@/src/components/TouchJoystick").then((mod) => ({
@ -15,6 +16,9 @@ const KeyboardOverlay = lazy(() =>
export function VisualInput() {
const isTouch = useTouchDevice();
const isTourActive = useCameraTour((s) => s.animation !== null);
if (isTourActive) return null;
return (
<Suspense>

View file

@ -392,6 +392,7 @@ const WaterReps = memo(function WaterReps({
ref={meshRef}
args={[surfaceGeometry, material, 9]}
frustumCulled={false}
renderOrder={-1}
/>
);
});

View file

@ -0,0 +1,178 @@
import type { GameEntity, ShapeEntity } from "../state/gameEntityTypes";
import type { TorqueObject } from "../torqueScript/types";
import type { CaseInsensitiveMap } from "../torqueScript/utils";
import { getGameName } from "../stringUtils";
export interface TourTarget {
entityId: string;
label: string;
position: [number, number, number];
teamId?: number;
}
export interface TourCategory {
name: string;
targets: TourTarget[];
}
/** Map from lowercase dataBlock name → category display name. */
const DATABLOCK_TO_CATEGORY = new Map<string, string>([
// Flags
["flag", "Flags"],
["huntersflag1", "Flags"],
["huntersflag2", "Flags"],
["huntersflag4", "Flags"],
["huntersflag8", "Flags"],
// Stations
["stationinventory", "Inventory Stations"],
["stationammo", "Inventory Stations"],
["mobileinvstation", "Inventory Stations"],
// Vehicle pads
["stationvehiclepad", "Vehicle Pads"],
["stationvehicle", "Vehicle Pads"],
// Generators
["generatorlarge", "Generators"],
["solarpanel", "Generators"],
// Sensors
["sensorlargepulse", "Sensors"],
["sensormediumpulse", "Sensors"],
// Turrets
["turretbaselarge", "Turrets"],
["sentryturret", "Turrets"],
// Repair & support items
["repairpatch", "Health"],
["repairkit", "Health"],
// Packs
["ammopack", "Packs"],
["energypack", "Packs"],
["shieldpack", "Packs"],
["repairpack", "Packs"],
["cloakingpack", "Packs"],
["sensorjammerpack", "Packs"],
// Turret barrel packs
["aabarrelpack", "Packs"],
["elfbarrelpack", "Packs"],
["missilebarrelpack", "Packs"],
["mortarbarrelpack", "Packs"],
["plasmabarrelpack", "Packs"],
// Deployable packs
["inventorydeployable", "Packs"],
["motionsensordeployable", "Packs"],
["pulsesensordeployable", "Packs"],
["turretoutdoordeployable", "Packs"],
["turretindoordeployable", "Packs"],
["satchelcharge", "Weapons"],
// Weapons
["blaster", "Weapons"],
["chaingun", "Weapons"],
["disc", "Weapons"],
["grenadelauncher", "Weapons"],
["elfgun", "Weapons"],
["missilelauncher", "Weapons"],
["mortar", "Weapons"],
["plasma", "Weapons"],
["shocklance", "Weapons"],
["sniperrifle", "Weapons"],
["targetinglaser", "Weapons"],
// Ammo
["chaingunammo", "Ammo"],
["discammo", "Ammo"],
["grenadelauncherammo", "Ammo"],
["missilelauncherammo", "Ammo"],
["mortarammo", "Ammo"],
["plasmaammo", "Ammo"],
["bombammo", "Ammo"],
["assaultmortarammo", "Ammo"],
// Throwables & mines
["grenade", "Ammo"],
["concussiongrenade", "Ammo"],
["flashgrenade", "Ammo"],
["flaregrenade", "Ammo"],
["cameragrenade", "Ammo"],
["mine", "Ammo"],
["beacon", "Ammo"],
// Switches
["flipflop", "Switches"],
// Nexus
["nexus", "Nexus"],
["nexusbase", "Nexus"],
["nexuscap", "Nexus"],
]);
/** Display order for categories. */
const CATEGORY_ORDER = [
"Flags",
"Inventory Stations",
"Generators",
"Vehicle Pads",
"Turrets",
"Sensors",
"Nexus",
"Switches",
"Packs",
"Health",
"Weapons",
"Ammo",
];
function isShapeWithDataBlock(entity: GameEntity): entity is ShapeEntity & {
dataBlock: string;
position: [number, number, number];
} {
return (
entity.renderType === "Shape" &&
typeof (entity as ShapeEntity).dataBlock === "string" &&
(entity as ShapeEntity).dataBlock !== "" &&
Array.isArray((entity as ShapeEntity).position)
);
}
export function categorizeEntities(
entities: Map<string, GameEntity>,
datablocks?: CaseInsensitiveMap<TorqueObject>,
): TourCategory[] {
const groups = new Map<string, TourTarget[]>();
for (const entity of entities.values()) {
if (!isShapeWithDataBlock(entity)) continue;
const category = DATABLOCK_TO_CATEGORY.get(entity.dataBlock.toLowerCase());
if (!category) continue;
let label = entity.dataBlock;
if (datablocks && entity.runtimeObject) {
const gameName = getGameName(
entity.runtimeObject as TorqueObject,
datablocks,
);
if (gameName) label = gameName;
}
let targets = groups.get(category);
if (!targets) {
targets = [];
groups.set(category, targets);
}
targets.push({
entityId: entity.id,
label,
position: entity.position,
teamId: entity.teamId,
});
}
// Return in display order, only non-empty categories.
const result: TourCategory[] = [];
for (const name of CATEGORY_ORDER) {
const targets = groups.get(name);
if (targets && targets.length > 0) {
targets.sort((a, b) => {
const cmp = (a.teamId ?? 0) - (b.teamId ?? 0);
if (cmp !== 0) return cmp;
return a.label.localeCompare(b.label);
});
result.push({ name, targets });
}
}
return result;
}