WayPoint fix, FloatingLabel improvements

This commit is contained in:
Brian Beck 2026-03-26 11:32:08 -06:00
parent 5675b952b5
commit 9a95c04c68
40 changed files with 632 additions and 628 deletions

View file

@ -6,4 +6,5 @@
padding: 1px 3px;
border-radius: 1px;
text-align: center;
opacity: 0; /* updated in useFrame */
}

View file

@ -21,21 +21,22 @@ function isBehindCamera(
);
}
export const FloatingLabel = memo(function FloatingLabel({
children,
color = "white",
position = DEFAULT_POSITION,
opacity = "fadeWithDistance",
}: {
children: ReactNode;
color?: string;
position?: [x: number, y: number, z: number];
opacity?: number | "fadeWithDistance";
}) {
const fadeWithDistance = opacity === "fadeWithDistance";
/** Default fade distance for fadeWithDistance labels. */
const DEFAULT_FADE_DISTANCE = 200;
/**
* Hook that manages visibility and opacity fading for a floating label group.
* Attach `groupRef` to a `<group>` so world-position lookups work. Apply
* `opacityRef.current` to DOM elements each frame for smooth fading.
*/
export function useFloatingLabelFade({
opacity: opacityProp = "fadeWithDistance" as number | "fadeWithDistance",
fadeDistance = DEFAULT_FADE_DISTANCE,
} = {}) {
const fadeWithDistance = opacityProp === "fadeWithDistance";
const groupRef = useRef<Object3D>(null);
const [isVisible, setIsVisible] = useState(opacity !== 0);
const labelRef = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(opacityProp !== 0);
const opacityRef = useRef("0");
useFrame(({ camera }) => {
const group = groupRef.current;
@ -53,25 +54,49 @@ export const FloatingLabel = memo(function FloatingLabel({
const distance = behind
? Infinity
: camera.position.distanceTo(_worldPos);
const shouldBeVisible = distance < 200;
const shouldBeVisible = distance < fadeDistance;
if (isVisible !== shouldBeVisible) {
setIsVisible(shouldBeVisible);
}
// Update opacity directly on DOM element (no re-render).
if (labelRef.current && shouldBeVisible) {
const fadeOpacity = Math.max(0, Math.min(1, 1 - distance / 200));
labelRef.current.style.opacity = fadeOpacity.toString();
}
opacityRef.current = shouldBeVisible
? Math.max(0, Math.min(1, 1 - distance / fadeDistance)).toString()
: "0";
} else {
const shouldBeVisible = !behind && opacity !== 0;
const shouldBeVisible = !behind && opacityProp !== 0;
if (isVisible !== shouldBeVisible) {
setIsVisible(shouldBeVisible);
}
if (labelRef.current) {
labelRef.current.style.opacity = (opacity as number).toString();
}
opacityRef.current = (opacityProp as number).toString();
}
});
return { groupRef, isVisible, opacityRef };
}
export const FloatingLabel = memo(function FloatingLabel({
children,
color = "white",
position = DEFAULT_POSITION,
opacity = "fadeWithDistance",
fadeDistance,
}: {
children: ReactNode;
color?: string;
position?: [x: number, y: number, z: number];
opacity?: number | "fadeWithDistance";
fadeDistance?: number;
}) {
const { groupRef, isVisible, opacityRef } = useFloatingLabelFade({
opacity,
fadeDistance,
});
const labelRef = useRef<HTMLDivElement>(null);
useFrame(() => {
if (labelRef.current) {
labelRef.current.style.opacity = opacityRef.current;
}
});
@ -79,11 +104,7 @@ export const FloatingLabel = memo(function FloatingLabel({
<group ref={groupRef}>
{isVisible ? (
<Html position={position} center>
<div
ref={labelRef}
className={styles.Label}
style={{ color, opacity: fadeWithDistance ? 0 : undefined }}
>
<div ref={labelRef} className={styles.Label} style={{ color }}>
{children}
</div>
</Html>

View file

@ -159,8 +159,8 @@
}
.Description {
font-size: 13px;
line-height: calc(20 / 13);
font-size: 14px;
line-height: calc(20 / 14);
}
.Field label,

View file

@ -4,6 +4,7 @@
flex-direction: column;
align-items: center;
white-space: nowrap;
opacity: 0; /* updated in useFrame */
}
.Top {

View file

@ -1,10 +1,11 @@
import { useMemo, useRef, useState } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { useMemo, useRef } from "react";
import { useFrame } from "@react-three/fiber";
import { Html } from "@react-three/drei";
import { Box3, Object3D, Vector3 } from "three";
import { Box3 } from "three";
import { getKeyframeAtTime } from "../stream/playbackUtils";
import { textureToUrl } from "../loaders";
import { useStaticShape } from "./GenericShape";
import { useFloatingLabelFade } from "./FloatingLabel";
import { streamPlaybackStore } from "../state/streamPlaybackStore";
import type { PlayerEntity } from "../state/gameEntityTypes";
import styles from "./PlayerNameplate.module.css";
@ -21,8 +22,6 @@ const NAME_HEIGHT = -0.2;
const IFF_FRIENDLY_URL = textureToUrl("gui/hud_alliedtriangle");
const IFF_ENEMY_URL = textureToUrl("gui/hud_enemytriangle");
const _tmpVec = new Vector3();
const EMPTY_KEYFRAMES: never[] = [];
/**
@ -31,13 +30,13 @@ const EMPTY_KEYFRAMES: never[] = [];
*/
export function PlayerNameplate({ entity }: { entity: PlayerEntity }) {
const gltf = useStaticShape((entity.shapeName ?? entity.dataBlock)!);
const camera = useThree((state) => state.camera);
const groupRef = useRef<Object3D>(null);
const { groupRef, isVisible, opacityRef } = useFloatingLabelFade({
fadeDistance: NAMEPLATE_FADE_DISTANCE,
});
const iffContainerRef = useRef<HTMLDivElement>(null);
const nameContainerRef = useRef<HTMLDivElement>(null);
const fillRef = useRef<HTMLDivElement>(null);
const iffImgRef = useRef<HTMLImageElement>(null);
const [isVisible, setIsVisible] = useState(true);
const nameRef = useRef<HTMLDivElement>(null);
// Derive IFF height from the shape's bounding box.
@ -54,27 +53,7 @@ export function PlayerNameplate({ entity }: { entity: PlayerEntity }) {
);
useFrame(() => {
const group = groupRef.current;
if (!group) return;
// Compute world-space distance to camera.
group.getWorldPosition(_tmpVec);
const distance = camera.position.distanceTo(_tmpVec);
// Check if behind camera using dot product with camera forward (-Z column).
const e = camera.matrixWorld.elements;
const behind =
(_tmpVec.x - e[12]) * -e[8] +
(_tmpVec.y - e[13]) * -e[9] +
(_tmpVec.z - e[14]) * -e[10] <
0;
const shouldBeVisible = !behind && distance < NAMEPLATE_FADE_DISTANCE;
if (isVisible !== shouldBeVisible) {
setIsVisible(shouldBeVisible);
}
if (!shouldBeVisible) return;
if (!isVisible) return;
// Hide nameplate when player is dead.
const kf = getKeyframeAtTime(
@ -89,17 +68,13 @@ export function PlayerNameplate({ entity }: { entity: PlayerEntity }) {
return;
}
// Update opacity on both label containers.
const opacity = Math.max(
0,
Math.min(1, 1 - distance / NAMEPLATE_FADE_DISTANCE),
);
const opacityStr = opacity.toString();
// Apply shared fade opacity to both containers.
const opacity = opacityRef.current;
if (iffContainerRef.current) {
iffContainerRef.current.style.opacity = opacityStr;
iffContainerRef.current.style.opacity = opacity;
}
if (nameContainerRef.current) {
nameContainerRef.current.style.opacity = opacityStr;
nameContainerRef.current.style.opacity = opacity;
}
// Update player name imperatively — entity.playerName is mutated in-place

View file

@ -78,17 +78,23 @@ export function TouchHandler() {
// Map joystick to movement axes, pre-scaled by speedMultiplier.
x = Math.max(
-1,
Math.min(1, joyX * normalizedMoveForce * speedMultiplier),
Math.min(
1,
joyX * normalizedMoveForce * (0.8 * speedMultiplier + 0.05),
),
);
y = Math.max(
-1,
Math.min(1, joyY * normalizedMoveForce * speedMultiplier),
Math.min(
1,
joyY * normalizedMoveForce * (0.8 * speedMultiplier + 0.05),
),
);
}
} else if (touchMode === "moveLookStick") {
if (moveForce > 0) {
// Move forward at half speed.
y = Math.max(-1, Math.min(1, 0.5 * speedMultiplier));
y = Math.min(1, 0.5 * speedMultiplier + 0.05);
if (moveForce >= SINGLE_STICK_DEADZONE) {
// Outer zone: also control camera look (yaw + pitch).

View file

@ -3,7 +3,7 @@ import { FloatingLabel } from "./FloatingLabel";
export function WayPoint({ entity }: { entity: WayPointEntity }) {
return entity.label ? (
<FloatingLabel position={entity.position} opacity={0.6}>
<FloatingLabel opacity={0.6}>
{entity.label}
</FloatingLabel>
) : null;