entity tracking and physics improvements, new HUD options

This commit is contained in:
Brian Beck 2026-04-01 22:54:00 -07:00
parent b6975da8ed
commit 2d054a7884
46 changed files with 160 additions and 85 deletions

View file

@ -25,8 +25,12 @@ import styles from "./DemoPlaybackControls.module.css";
// ];
function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60);
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) {
return `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
}
return `${m}:${s.toString().padStart(2, "0")}`;
}

View file

@ -83,6 +83,10 @@ export const InspectorControls = memo(function InspectorControls({
setFpsLimit,
showInputOverlay,
setShowInputOverlay,
showChat,
setShowChat,
showReticle,
setShowReticle,
} = useSettings();
const {
speedMultiplier,
@ -331,6 +335,39 @@ export const InspectorControls = memo(function InspectorControls({
Show input overlay
</label>
</div>
{hasStreamData && (
<>
<div className={styles.CheckboxField}>
<input
id="showChatInput"
type="checkbox"
checked={showChat}
onChange={(event) => {
setShowChat(event.target.checked);
}}
/>
<label className={styles.Label} htmlFor="showChatInput">
Show chat HUD
</label>
</div>
<div className={styles.CheckboxField}>
<input
id="showReticleInput"
type="checkbox"
checked={showReticle}
onChange={(event) => {
setShowReticle(event.target.checked);
}}
/>
<label
className={styles.Label}
htmlFor="showReticleInput"
>
Show reticles
</label>
</div>
</>
)}
</Accordion>
<Accordion value="audio" label="Audio">
<div className={styles.CheckboxField}>

View file

@ -6,6 +6,7 @@ import { textureToUrl } from "../loaders";
import type { StreamEntity, TeamScore, WeaponsHudSlot } from "../stream/types";
import styles from "./PlayerHUD.module.css";
import { ChatWindow } from "./ChatWindow";
import { useSettings } from "./SettingsProvider";
const COMPASS_URL = textureToUrl("gui/hud_new_compass");
const NSEW_URL = textureToUrl("gui/hud_new_NSEW");
@ -416,10 +417,11 @@ export function PlayerHUD() {
// In free-fly mode the camera is disconnected from the player, so
// player-specific HUD elements (health, energy, weapons, etc.) are hidden.
const showPlayerElements = hasControlPlayer && cameraMode !== "freeFly";
const { showChat, showReticle } = useSettings();
return (
<div className={styles.PlayerHUD}>
<ChatWindow />
{showChat && <ChatWindow />}
{showPlayerElements && (
<div className={styles.Bars}>
<HealthBar />
@ -431,7 +433,7 @@ export function PlayerHUD() {
<>
<WeaponHUD />
<PackAndInventoryHUD />
<Reticle />
{showReticle && <Reticle />}
</>
)}
<TeamScores />

View file

@ -49,6 +49,10 @@ type SettingsContextType = {
setFpsLimit: StateSetter<number | null>;
showInputOverlay: boolean;
setShowInputOverlay: StateSetter<boolean>;
showChat: boolean;
setShowChat: StateSetter<boolean>;
showReticle: boolean;
setShowReticle: StateSetter<boolean>;
};
type DebugContextType = {
@ -96,6 +100,8 @@ type PersistedSettings = {
sidebarOpen?: boolean;
fpsLimit?: number | null;
showInputOverlay?: boolean;
showChat?: boolean;
showReticle?: boolean;
};
export function useSettings() {
@ -149,6 +155,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [fpsLimit, setFpsLimit] = useState<number | null>(null);
const [showInputOverlay, setShowInputOverlay] = useState(true);
const [showChat, setShowChat] = useState(true);
const [showReticle, setShowReticle] = useState(true);
const [renderOnDemand, setRenderOnDemand] = useState(false);
const [fogEnabledOverride, setFogEnabledOverride] = useFogQueryState();
@ -189,6 +197,10 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
setFpsLimit,
showInputOverlay,
setShowInputOverlay,
showChat,
setShowChat,
showReticle,
setShowReticle,
}),
[
fogEnabled,
@ -205,6 +217,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
sidebarOpen,
fpsLimit,
showInputOverlay,
showChat,
showReticle,
],
);
@ -323,6 +337,12 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
if (savedSettings.showInputOverlay != null) {
setShowInputOverlay(savedSettings.showInputOverlay);
}
if (savedSettings.showChat != null) {
setShowChat(savedSettings.showChat);
}
if (savedSettings.showReticle != null) {
setShowReticle(savedSettings.showReticle);
}
if (savedSettings.sidebarOpen != null) {
// Don't restore on touch devices!
if (!isTouch) {
@ -366,6 +386,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
sidebarOpen,
fpsLimit,
showInputOverlay,
showChat,
showReticle,
};
try {
localStorage.setItem("settings", JSON.stringify(settingsToSave));
@ -398,6 +420,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
sidebarOpen,
fpsLimit,
showInputOverlay,
showChat,
showReticle,
]);
return (

View file

@ -1,13 +1,12 @@
import { ghostToSceneObject } from "../scene";
import { getTerrainHeightAt } from "../terrainHeight";
import type { SceneObject } from "../scene/types";
import { getTerrainHeightAt } from "../terrainHeight";
import {
linearProjectileClassNames,
ballisticProjectileClassNames,
seekerProjectileClassNames,
toEntityType,
allocateEntityId,
resetEntityIdCounter,
GhostMessage,
IFF_GREEN,
IFF_RED,
@ -116,10 +115,11 @@ export interface MutableEntity {
/** ShapeBase sound slots (4 max). Raw ghost SoundMask data components
* manage PositionalAudio objects directly, matching Tribes 2's approach. */
soundSlots?: Array<{ index: number; playing: boolean; profileId?: number }>;
/** Item velocity interpolation state (dropped weapons/items).
* The real Tribes 2 client does NOT simulate physics (gravity/collision)
* for items it just interpolates position using server-sent velocity
* until the next server update arrives. */
/** Item mStatic flag (from InitialUpdateMask). Static items (flags at
* flagstand) skip all physics in Item::processTick. */
isStaticItem?: boolean;
/** Item velocity interpolation state. The client simulates full physics
* (gravity, collision, bounce) for non-static, non-at-rest items. */
itemPhysics?: {
velocity: [number, number, number];
atRest: boolean;
@ -353,13 +353,14 @@ export abstract class StreamEngine implements StreamingPlayback {
// ── Shared reset logic ──
/** Clear all entity state (entities, ghostID map, ID counter, generation).
* Called on full reset and on GhostingMessageEvent (mission change). */
/** Clear all entity state (entities, ghostID map, generation).
* Called on full reset and on GhostingMessageEvent (mission change).
* Does NOT reset the ID counter IDs must never be reused to avoid
* stale entity collisions in the render store after seeks. */
protected clearAllEntities(): void {
this.entities.clear();
this.entityIdByGhostIndex.clear();
this.entityGeneration++;
resetEntityIdCounter();
}
protected resetSharedState(): void {
@ -945,6 +946,7 @@ export abstract class StreamEngine implements StreamingPlayback {
entity.explosionDataBlockId = undefined;
entity.maintainEmitterId = undefined;
entity.soundSlots = undefined;
entity.isStaticItem = undefined;
}
// ── Apply ghost data ──
@ -1257,10 +1259,19 @@ 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.
// Item physics state.
if (entity.type === "Item") {
// mStatic: sent via InitialUpdateMask. Static items (flags at home)
// skip all physics in Item::processTick.
if (typeof data.isStatic === "boolean") {
entity.isStaticItem = data.isStatic;
// When server sets mStatic=true, force atRest (matching Item::onAdd).
if (data.isStatic) {
entity.itemPhysics = undefined;
}
}
const atRest = data.atRest as boolean | undefined;
if (atRest === false && isVec3Like(data.velocity)) {
if (atRest === false && !entity.isStaticItem && isVec3Like(data.velocity)) {
const vel = data.velocity as Vec3;
entity.itemPhysics = {
velocity: [vel.x, vel.y, vel.z],
@ -1587,7 +1598,10 @@ export abstract class StreamEngine implements StreamingPlayback {
const dt = TICK_DURATION_MS / 1000;
for (const entity of this.entities.values()) {
const phys = entity.itemPhysics;
if (!phys || phys.atRest || !entity.position) continue;
// Binary-verified: Item::processTick skips physics when
// mStatic || mAtRest || isHidden. We check static and atRest.
if (!phys || phys.atRest || entity.isStaticItem || !entity.position)
continue;
const v = phys.velocity;
const p = entity.position;
@ -1604,7 +1618,6 @@ export abstract class StreamEngine implements StreamingPlayback {
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;

View file

@ -77,11 +77,6 @@ const FIRST_DYNAMIC_ID = 1027;
let _nextEntityId = FIRST_DYNAMIC_ID;
/** Reset the entity ID counter (e.g. on mission/recording change). */
export function resetEntityIdCounter(): void {
_nextEntityId = FIRST_DYNAMIC_ID;
}
/** Allocate the next sequential entity ID, mimicking Torque's registerObject. */
export function allocateEntityId(): string {
return String(_nextEntityId++);