mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-03-23 14:21:01 +00:00
add reticles, more sounds, improve tours
This commit is contained in:
parent
fc51095776
commit
fe90146e1e
57 changed files with 887 additions and 635 deletions
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -235,9 +235,7 @@
|
|||
}
|
||||
|
||||
.ReticleImage {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
opacity: 0.85;
|
||||
opacity: 0.6;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue