mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-03-23 22:29:31 +00:00
add map tours
This commit is contained in:
parent
7541c4e716
commit
0736feb4c5
80 changed files with 1666 additions and 638 deletions
300
src/components/CameraTourConsumer.tsx
Normal file
300
src/components/CameraTourConsumer.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"}>
|
||||
|
|
|
|||
163
src/components/MapTourPanel.module.css
Normal file
163
src/components/MapTourPanel.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
146
src/components/MapTourPanel.tsx
Normal file
146
src/components/MapTourPanel.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -392,6 +392,7 @@ const WaterReps = memo(function WaterReps({
|
|||
ref={meshRef}
|
||||
args={[surfaceGeometry, material, 9]}
|
||||
frustumCulled={false}
|
||||
renderOrder={-1}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
178
src/components/mapTourCategories.ts
Normal file
178
src/components/mapTourCategories.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue