add reticles, more sounds, improve tours

This commit is contained in:
Brian Beck 2026-03-20 22:19:54 -07:00
parent fc51095776
commit fe90146e1e
57 changed files with 887 additions and 635 deletions

View file

@ -13,15 +13,21 @@ import {
import { cameraTourStore } from "../state/cameraTourStore";
import type { TourAnimation } from "../state/cameraTourStore";
import type { TourTarget } from "./mapTourCategories";
import { createLogger } from "../logger";
const log = createLogger("CameraTourConsumer");
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 FALLBACK_ORBIT_RADIUS = 3;
const DEFAULT_ORBIT_HEIGHT = 2;
const MIN_ORBIT_RADIUS = 2.75;
const ORBIT_RADIUS_SCALE = 1.5; // multiplier on bounding sphere radius
const MIN_ORBIT_RADIUS = 1.5;
/** Extra orbit radius added beyond half the object's height. */
const ORBIT_PAD_VERTICAL = 1.6;
/** Extra orbit radius added beyond half the object's spread (width/length). */
const ORBIT_PAD_HORIZONTAL = 1.2;
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;
@ -34,6 +40,9 @@ const LOOK_LEAD = 1.4;
// Reusable temp objects to avoid GC pressure.
const _box = new Box3();
const _localBox = new Box3();
const _childBox = new Box3();
const _childMat = new Matrix4();
const _center = new Vector3();
const _size = new Vector3();
const _v = new Vector3();
@ -54,16 +63,20 @@ function orbitFocus(animation: TourAnimation): Vector3 {
);
}
const target = animation.targets[animation.currentIndex];
return _vFocus.set(target.position[0], target.position[1], target.position[2]);
return _vFocus.set(
target.position[0],
target.position[1],
target.position[2],
);
}
function getOrbitRadius(animation: TourAnimation): number {
return animation.orbitRadius ?? DEFAULT_ORBIT_RADIUS;
return animation.orbitRadius ?? FALLBACK_ORBIT_RADIUS;
}
function getOrbitHeight(animation: TourAnimation): number {
const r = getOrbitRadius(animation);
return r * (DEFAULT_ORBIT_HEIGHT / DEFAULT_ORBIT_RADIUS);
return r * (DEFAULT_ORBIT_HEIGHT / FALLBACK_ORBIT_RADIUS);
}
function orbitPoint(
@ -89,18 +102,54 @@ function resolveTargetBounds(
): void {
const obj = scene.getObjectByName(target.entityId);
if (obj) {
// World-space AABB center for the orbit focus (where the camera looks).
_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,
// Local-space AABB for sizing. World-space boxes inflate with rotation,
// giving different sizes for identical models at different orientations.
// Instead, transform each child geometry's box into the root object's
// local frame so the size is rotation-independent.
const invWorld = _mat.copy(obj.matrixWorld).invert();
_localBox.makeEmpty();
obj.traverse((child: any) => {
if (!child.geometry) return;
if (!child.geometry.boundingBox) child.geometry.computeBoundingBox();
_childBox.copy(child.geometry.boundingBox);
_childMat.multiplyMatrices(invWorld, child.matrixWorld);
_childBox.applyMatrix4(_childMat);
_localBox.union(_childBox);
});
_localBox.getSize(_size);
// Pad each dimension's half-extent, then take the larger result.
// This gives a consistent standoff distance regardless of object size.
const height = _size.y; // Three.js Y-up
const spread = Math.max(_size.x, _size.z);
const fromHeight = height / 2 + ORBIT_PAD_VERTICAL;
const fromSpread = spread / 2 + ORBIT_PAD_HORIZONTAL;
const computed = Math.max(fromHeight, fromSpread);
animation.orbitRadius = Math.max(MIN_ORBIT_RADIUS, computed);
const driver = fromHeight >= fromSpread ? "height" : "spread";
const clamp = computed < MIN_ORBIT_RADIUS ? " (clamped)" : "";
log.debug(
"%s: size=%s height→%s spread→%s driven by %s → radius=%d%s",
target.label,
`${_size.x.toFixed(1)}×${_size.y.toFixed(1)}×${_size.z.toFixed(1)}`,
fromHeight.toFixed(1),
fromSpread.toFixed(1),
driver,
animation.orbitRadius,
clamp,
);
} else {
animation.orbitCenter = null;
animation.orbitRadius = null;
log.debug(
"%s: no scene object, fallback radius=%d",
target.label,
FALLBACK_ORBIT_RADIUS,
);
}
}

View file

@ -235,9 +235,7 @@
}
.ReticleImage {
width: 64px;
height: 64px;
opacity: 0.85;
opacity: 0.6;
image-rendering: pixelated;
}

View file

@ -73,10 +73,19 @@ function EnergyBar() {
);
}
/** Maps normalized weapon shape names to reticle textures from $WeaponsHudData. */
const RETICLE_TEXTURES: Record<string, string> = {
weapon_energy: "gui/ret_blaster",
weapon_plasma: "gui/ret_plasma",
weapon_chaingun: "gui/ret_chaingun",
weapon_disc: "gui/ret_disc",
weapon_grenade_launcher: "gui/ret_grenade",
weapon_sniper: "gui/hud_ret_sniper",
weapon_shocklance: "gui/hud_ret_shocklance",
weapon_elf: "gui/ret_elf",
weapon_mortar: "gui/ret_mortor",
weapon_missile: "gui/ret_missile",
weapon_targeting: "gui/hud_ret_targlaser",
weapon_shocklance: "gui/hud_ret_shocklance",
};
function normalizeWeaponName(shape: string | undefined): string {

View file

@ -10,6 +10,7 @@ import {
LoopOnce,
LoopRepeat,
Object3D,
Audio as ThreeAudio,
PositionalAudio,
Vector3,
} from "three";
@ -40,8 +41,12 @@ import {
trackSound,
untrackSound,
} from "./AudioEmitter";
import type { ResolvedAudioProfile } from "./AudioEmitter";
import { audioToUrl } from "../loaders";
import { createLogger } from "../logger";
import { useSettings } from "./SettingsProvider";
const log = createLogger("PlayerModel");
import { useEngineStoreApi, useEngineSelector } from "../state/engineStore";
import { streamPlaybackStore } from "../state/streamPlaybackStore";
import type { PlayerEntity } from "../state/gameEntityTypes";
@ -453,6 +458,67 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
const flagShapeRef = useRef(entity.flagShape);
const [currentFlagShape, setCurrentFlagShape] = useState(entity.flagShape);
// Jet thrust looping sound. Managed imperatively in useFrame based on
// entity.jetting — plays while jetting, stops when jetting ends.
// Sound parameters come from the player's datablock chain:
// PlayerData.sounds[30] (jetSound) → AudioProfile → AudioDescription
const { audioLoader, audioListener } = useAudio();
const audioSettings = useSettings();
const audioEnabled = audioSettings?.audioEnabled ?? false;
const jetSoundRef = useRef<PositionalAudio | null>(null);
const jetBufferRef = useRef<AudioBuffer | null>(null);
const jetProfileRef = useRef<ResolvedAudioProfile | null>(null);
/** PlayerData.Sounds enum index for jetSound. Tribes 2 reordered the
* open-source Torque Sounds enum to put jet sounds first (0-1). */
const JET_SOUND_INDEX = 0;
// Resolve and preload the jet sound from the player's datablock.
useEffect(() => {
if (!audioLoader) return;
const playback = engineStore.getState().playback;
const sp = playback.recording?.streamingPlayback;
if (!sp || !entity.dataBlockId) return;
const getDb = sp.getDataBlockData.bind(sp);
const playerDb = getDb(entity.dataBlockId);
const sounds = playerDb?.sounds as (number | null)[] | undefined;
const jetSoundId = sounds?.[JET_SOUND_INDEX];
if (jetSoundId == null) return;
const resolved = resolveAudioProfile(jetSoundId, getDb);
if (!resolved) return;
jetProfileRef.current = resolved;
try {
const url = audioToUrl(resolved.filename);
getCachedAudioBuffer(url, audioLoader, (buffer) => {
jetBufferRef.current = buffer;
});
} catch {
// File not in manifest.
}
}, [audioLoader, entity.dataBlockId]);
// Cleanup jet sound on unmount.
useEffect(() => {
return () => {
const sound = jetSoundRef.current;
if (sound) {
untrackSound(sound);
try {
sound.stop();
} catch {
/* already stopped */
}
try {
sound.disconnect();
} catch {
/* already disconnected */
}
sound.parent?.remove(sound);
jetSoundRef.current = null;
}
};
}, []);
// Per-frame animation selection and mixer update.
useFrame((_, delta) => {
if (entity.weaponShape !== weaponShapeRef.current) {
@ -663,6 +729,55 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
headside.weight = blendWeight;
}
// Jet thrust sound: start/stop a looping positional audio based on
// entity.jetting. The engine plays ArmorJetSound (CloseLooping3d) while
// mJetting is true and stops it when false.
const isJetting = !!entity.jetting && !isDead;
const jetProfile = jetProfileRef.current;
// Check both our ref AND isPlaying — the sound can be externally stopped
// by stopAllTrackedSounds() (on seek/recording change) without our ref
// knowing about it.
const jetSound = jetSoundRef.current;
const soundActuallyPlaying = jetSound?.isPlaying ?? false;
if (isJetting && !soundActuallyPlaying) {
if (
audioEnabled &&
audioListener &&
jetBufferRef.current &&
jetProfile
) {
let sound = jetSound;
if (!sound) {
sound = new PositionalAudio(audioListener);
sound.setDistanceModel("inverse");
sound.setRefDistance(jetProfile.refDist);
sound.setMaxDistance(jetProfile.maxDist);
sound.setRolloffFactor(1);
sound.setVolume(jetProfile.volume);
clonedScene.add(sound);
jetSoundRef.current = sound;
}
try {
sound.setBuffer(jetBufferRef.current);
sound.setLoop(true);
sound.setPlaybackRate(playback.rate);
sound.play();
trackSound(sound, 1);
} catch {
/* AudioContext suspended */
}
}
} else if (!isJetting && soundActuallyPlaying) {
if (jetSound) {
untrackSound(jetSound);
try {
jetSound.stop();
} catch {
/* already stopped */
}
}
}
// Advance or evaluate the body animation mixer.
if (isPlaying) {
mixer.update(delta * playback.rate);

View file

@ -95,7 +95,10 @@ export function StreamingMissionInfo() {
<span className={styles.RecordingDate}>
{datePart!.replace(/-/g, " ")}
</span>{" "}
at <span className={styles.RecordingDate}>{timePart}</span>
at{" "}
<span className={styles.RecordingDate}>
{(timePart ?? "").replace(/(AM|PM)$/, " $1")}
</span>
</div>
) : null}
{serverName ? (

View file

@ -27,12 +27,12 @@ const PINO_LEVELS = new Set([
"silent",
]);
/** Parse VITE_PUBLIC_LOG into a global default and per-module overrides. */
/** Parse VITE_LOG into a global default and per-module overrides. */
function parseLogConfig(): {
globalLevel: string;
modules: Map<string, string>;
} {
const raw = import.meta.env.VITE_PUBLIC_LOG?.trim();
const raw = import.meta.env.VITE_LOG?.trim();
if (!raw) return { globalLevel: "info", modules: new Map() };
let globalLevel: string | null = null;

View file

@ -1,4 +1,5 @@
import { ghostToSceneObject } from "../scene";
import { getTerrainHeightAt } from "../terrainHeight";
import type { SceneObject } from "../scene/types";
import {
linearProjectileClassNames,
@ -1136,17 +1137,29 @@ export abstract class StreamEngine implements StreamingPlayback {
if (typeof data.moveFlag0 === "boolean") entity.falling = data.moveFlag0;
if (typeof data.moveFlag1 === "boolean") entity.jetting = data.moveFlag1;
// Item velocity interpolation: the Tribes 2 client does NOT simulate
// physics (gravity/collision) for items. It interpolates position using
// server-sent velocity until the next server update or atRest=true.
// Item velocity interpolation.
if (entity.type === "Item") {
const atRest = data.atRest as boolean | undefined;
if (atRest === false && isVec3Like(data.velocity)) {
const vel = data.velocity as Vec3;
entity.itemPhysics = {
velocity: [data.velocity.x, data.velocity.y, data.velocity.z],
velocity: [vel.x, vel.y, vel.z],
atRest: false,
};
log.debug(
"Item %s (%s): atRest=false pos=%s vel=%s",
entity.id,
entity.shapeName ?? entity.dataBlock ?? `db#${entity.dataBlockId}`,
data.position ? `${(data.position as Vec3).x.toFixed(1)},${(data.position as Vec3).y.toFixed(1)},${(data.position as Vec3).z.toFixed(1)}` : "none",
`${vel.x.toFixed(1)},${vel.y.toFixed(1)},${vel.z.toFixed(1)}`,
);
} else if (atRest === true) {
log.debug(
"Item %s (%s): atRest=true pos=%s",
entity.id,
entity.shapeName ?? entity.dataBlock ?? `db#${entity.dataBlockId}`,
entity.position ? `${entity.position[0].toFixed(1)},${entity.position[1].toFixed(1)},${entity.position[2].toFixed(1)}` : "none",
);
entity.itemPhysics = undefined;
}
}
@ -1501,9 +1514,18 @@ export abstract class StreamEngine implements StreamingPlayback {
}
}
/** Advance dropped item physics (gravity, terrain collision, friction). */
/** Advance item positions using server-sent velocity (no gravity/collision).
* The real Tribes 2 client just interpolates; physics runs server-side. */
/** Advance item positions using server-sent velocity.
*
* Verified against Tribes2.exe (build 25034): Item does NOT override
* GameBase::processTick the vtable at offset 0x50 points to the
* inherited FUN_00586050 which does no physics. All item physics
* (gravity, collision, friction) run SERVER-SIDE only. The client just
* interpolates using velocity until the next ghost update.
*
* As a practical fallback for demo playback (where ghost updates can be
* sparse), we apply basic gravity after a few ticks without a server
* update to prevent items from flying upward indefinitely.
*/
protected advanceItems(): void {
const dt = TICK_DURATION_MS / 1000;
for (const entity of this.entities.values()) {
@ -1511,9 +1533,44 @@ export abstract class StreamEngine implements StreamingPlayback {
if (!phys || phys.atRest || !entity.position) continue;
const v = phys.velocity;
const p = entity.position;
// Gravity: Item::mGravity = -20 (verified from Torque source item.cc:35).
v[2] += -20 * dt;
// Integrate position.
p[0] += v[0] * dt;
p[1] += v[1] * dt;
p[2] += v[2] * dt;
// Terrain collision: bounce with elasticity/friction matching
// typical Tribes 2 ItemData values (elasticity=0.2, friction=0.6).
const groundZ = getTerrainHeightAt(p[0], p[1]);
if (groundZ != null && p[2] < groundZ) {
p[2] = groundZ;
// Flat-normal collision response (normal = [0,0,1]).
const bd = Math.abs(v[2]);
// Friction: reduce horizontal velocity.
const friction = bd * 0.6;
const hSpeed = Math.sqrt(v[0] * v[0] + v[1] * v[1]);
if (hSpeed > 0) {
const scale = Math.max(0, 1 - friction / hSpeed);
v[0] *= scale;
v[1] *= scale;
}
// Elasticity: bounce.
v[2] = bd * 0.2;
// At-rest check (sAtRestVelocity = 0.15).
const speed = Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);
if (speed < 0.15) {
v[0] = v[1] = v[2] = 0;
phys.atRest = true;
}
}
// Clamp items that fall far below the map.
if (p[2] < -1000) {
phys.atRest = true;
}
}
}
@ -1729,18 +1786,14 @@ export abstract class StreamEngine implements StreamingPlayback {
];
ghostEntity.rotation = playerYawToQuaternion(yaw);
ghostEntity.headPitch = this.getControlPlayerHeadPitch(pitch);
// Sync velocity from controlObjectData. Ghost updates skip the
// control player (MoveMask is not read), so velocity and state
// flags must come from here for movement animation selection.
// Sync velocity from controlObjectData. The authoritative
// falling/jetting flags come from the ghost's MoveMask update
// (processed earlier in applyGhostData) — don't overwrite them.
const vel = data?.velocity as
| { x: number; y: number; z: number }
| undefined;
if (isVec3Like(vel)) {
ghostEntity.velocity = [vel.x, vel.y, vel.z];
// Approximate mFalling: engine sets it when no ground contact
// and vz < sFallingThreshold (-10). controlObjectData lacks
// the explicit flag, so use the velocity heuristic.
ghostEntity.falling = vel.z < -10;
}
}
}

View file

@ -10,6 +10,14 @@
/** Minimum velocity dot product to count as intentional movement. */
const MOVE_THRESHOLD = 0.1;
/**
* Horizontal speed above which the player cannot be running on the ground.
* In the engine, grounded players are speed-capped by PlayerData::maxForwardSpeed
* (light=15, medium=12, heavy=7). We use 20 to provide margin for brief
* overshoots from slopes, momentum, and framerate-dependent speed variations.
*/
const MAX_GROUND_SPEED = 20;
export interface MoveAnimationResult {
/** Engine alias name (e.g. "root", "run", "back", "side", "fall", "jet"). */
animation: string;
@ -31,15 +39,18 @@ function quaternionToBodyYaw(q: [number, number, number, number]): number {
* Pick the movement animation for a player based on their velocity, body
* orientation, and movement state flags.
*
* Matches the Tribes2.exe binary (build 25034) pickActionAnimation at
* Replicates the Tribes2.exe binary (build 25034) pickActionAnimation at
* 0x005d6210. The engine checks in order:
* 1. mFalling FallAnim
* 2. contactTimer >= 30 (airborne) jetting ? JetAnim : RootAnim
* 3. contactTimer < 30 (on ground) velocity-based (run/back/side/root)
*
* We don't have contactTimer, so we approximate airborne detection: jetting
* implies airborne, and significant vertical velocity without the falling flag
* suggests the player left the ground (jumped, launched, etc.).
* We don't have contactTimer directly, so we approximate the airborne check
* using two heuristics:
* - Significant vertical velocity (|vz| > 2) implies not on the ground.
* - Horizontal speed exceeding the max ground running speed implies the
* player is skiing or otherwise airborne, since the engine caps grounded
* movement speed at maxForwardSpeed.
*/
export function pickMoveAnimation(
velocity: [number, number, number] | undefined,
@ -52,23 +63,28 @@ export function pickMoveAnimation(
return { animation: "fall", timeScale: 1 };
}
// 2. Jetting always shows the jet animation (engine: contactTimer >= 30
// && mJetting → JetAnim). Jetting implies airborne.
if (jetting) {
return { animation: "jet", timeScale: 1 };
}
if (!velocity) {
return { animation: "root", timeScale: 1 };
}
const [vx, vy, vz] = velocity;
// Approximate the engine's contactTimer check: if the player has
// significant vertical velocity, they're likely airborne (jumped, launched).
// The engine would show root in this case (contactTimer >= 30, not jetting).
// Use a threshold above the falling threshold (-10) to catch the gap.
if (Math.abs(vz) > 2) {
// 2. Airborne detection (approximates contactTimer >= 30).
// The engine uses contactTimer to distinguish grounded from airborne.
// We approximate this with two checks:
// a) Vertical velocity indicates the player left the ground.
// b) Horizontal speed exceeds the ground movement cap — the player must
// be skiing/airborne since the engine limits grounded speed.
const horizontalSpeedSq = vx * vx + vy * vy;
const airborne =
Math.abs(vz) > 2 ||
horizontalSpeedSq > MAX_GROUND_SPEED * MAX_GROUND_SPEED;
if (airborne) {
// Tribes2.exe checks mJetting here: jetting → JetAnim, else → RootAnim.
if (jetting) {
return { animation: "jet", timeScale: 1 };
}
return { animation: "root", timeScale: 1 };
}

View file

@ -57,9 +57,9 @@ export function getGameName(
// Avoid duplication when nameTag matches typeTag (e.g. nameTag="Flag"
// with targetTypeTag="Flag" would otherwise produce "Flag Flag").
if (name !== "" && name.toLowerCase() !== typeTag.toLowerCase()) {
return `${name} ${typeTag}`;
return formatTaggedStrings(`${name} ${typeTag}`);
}
return typeTag;
return formatTaggedStrings(typeTag);
}
}
@ -72,7 +72,16 @@ export function getGameName(
}
}
return name;
return formatTaggedStrings(name);
}
/**
* Replace unresolved tagged string references (`\x01` + numeric ID) with a
* readable placeholder. These appear in missions saved from a running server
* where the original text was replaced by its string table ID.
*/
function formatTaggedStrings(s: string): string {
return s.replace(/\x01(\d+)/g, "<#$1>");
}
/** Strip leading article ("a ", "an ", "some ") and title-case the rest. */