import { useRecording } from "./RecordingProvider"; import { useEngineSelector } from "../state"; import { textureToUrl } from "../loaders"; import type { ChatSegment, ChatMessage, StreamEntity, TeamScore, WeaponsHudSlot, } from "../stream/types"; import styles from "./PlayerHUD.module.css"; // ── Compass ── const COMPASS_URL = textureToUrl("gui/hud_new_compass"); const NSEW_URL = textureToUrl("gui/hud_new_NSEW"); function Compass({ yaw }: { yaw: number | undefined }) { if (yaw == null) return null; // The ring notch is the fixed heading indicator (always "forward" at top). // The NSEW letters rotate to show world cardinal directions relative to // the player's heading. Positive Torque yaw = turning right (clockwise // from above), so N moves counter-clockwise on the display. const deg = (yaw * 180) / Math.PI; return (
); } // ── Health / Energy bars ── function HealthBar({ value }: { value: number }) { const pct = Math.max(0, Math.min(100, value * 100)); return (
); } function EnergyBar({ value }: { value: number }) { const pct = Math.max(0, Math.min(100, value * 100)); return (
); } // ── Reticle ── const RETICLE_TEXTURES: Record = { weapon_sniper: "gui/hud_ret_sniper", weapon_shocklance: "gui/hud_ret_shocklance", weapon_targeting: "gui/hud_ret_targlaser", }; function normalizeWeaponName(shape: string | undefined): string { if (!shape) return ""; return shape.replace(/\.dts$/i, "").toLowerCase(); } function Reticle() { const weaponShape = useEngineSelector((state) => { const snap = state.playback.streamSnapshot; if (!snap || snap.camera?.mode !== "first-person") return undefined; const ctrl = snap.controlPlayerGhostId; if (!ctrl) return undefined; return snap.entities.find((e: StreamEntity) => e.id === ctrl) ?.weaponShape; }); if (weaponShape === undefined) return null; const weapon = normalizeWeaponName(weaponShape); const textureName = RETICLE_TEXTURES[weapon]; if (textureName) { return (
{/* eslint-disable-next-line @next/next/no-img-element */}
); } return (
); } // ── Weapon HUD (right side weapon list) ── /** Maps $WeaponsHudData indices to simple icon textures (no baked background) * and labels. Mortar uses hud_new_ because no simple variant exists. */ const WEAPON_HUD_SLOTS: Record = { 0: { icon: "gui/hud_blaster", label: "Blaster" }, 1: { icon: "gui/hud_plasma", label: "Plasma" }, 2: { icon: "gui/hud_chaingun", label: "Chaingun" }, 3: { icon: "gui/hud_disc", label: "Spinfusor" }, 4: { icon: "gui/hud_grenlaunch", label: "GL" }, 5: { icon: "gui/hud_sniper", label: "Laser Rifle" }, 6: { icon: "gui/hud_elfgun", label: "ELF Gun" }, 7: { icon: "gui/hud_new_mortar", label: "Mortar" }, 8: { icon: "gui/hud_missiles", label: "Missile" }, 9: { icon: "gui/hud_targetlaser", label: "Targeting" }, 10: { icon: "gui/hud_shocklance", label: "Shocklance" }, // TR2 variants reuse the same icons. 11: { icon: "gui/hud_disc", label: "Spinfusor" }, 12: { icon: "gui/hud_grenlaunch", label: "GL" }, 13: { icon: "gui/hud_chaingun", label: "Chaingun" }, 14: { icon: "gui/hud_targetlaser", label: "Targeting" }, 15: { icon: "gui/hud_targetlaser", label: "Targeting" }, 16: { icon: "gui/hud_shocklance", label: "Shocklance" }, 17: { icon: "gui/hud_new_mortar", label: "Mortar" }, }; // Precompute URLs so we don't call textureToUrl on every render. const WEAPON_HUD_ICON_URLS = new Map( Object.entries(WEAPON_HUD_SLOTS).map(([idx, w]) => [ Number(idx), textureToUrl(w.icon), ]), ); /** Targeting laser HUD indices (standard + TR2 variants). */ const TARGETING_LASER_INDICES = new Set([9, 14, 15]); const INFINITY_ICON_URL = textureToUrl("gui/hud_infinity"); function WeaponSlotIcon({ slot, isSelected, }: { slot: WeaponsHudSlot; isSelected: boolean; }) { const info = WEAPON_HUD_SLOTS[slot.index]; if (!info) return null; const isInfinite = slot.ammo < 0; return (
{/* eslint-disable-next-line @next/next/no-img-element */} {info.label} {isInfinite ? ( // eslint-disable-next-line @next/next/no-img-element \u221E ) : ( {slot.ammo} )}
); } function WeaponHUD() { const weaponsHud = useEngineSelector( (state) => state.playback.streamSnapshot?.weaponsHud, ); if (!weaponsHud || !weaponsHud.slots.length) return null; const weapons: WeaponsHudSlot[] = []; const targeting: WeaponsHudSlot[] = []; for (const slot of weaponsHud.slots) { if (TARGETING_LASER_INDICES.has(slot.index)) { targeting.push(slot); } else { weapons.push(slot); } } return (
{weapons.map((slot) => ( ))} {targeting.length > 0 &&
} {targeting.map((slot) => ( ))}
); } // ── Team Scores (bottom-left) ── /** Default team names from serverDefaults.cs. */ const DEFAULT_TEAM_NAMES: Record = { 1: "Storm", 2: "Inferno", 3: "Starwolf", 4: "Diamond Sword", 5: "Blood Eagle", 6: "Phoenix", }; function TeamScores() { const teamScores = useEngineSelector( (state) => state.playback.streamSnapshot?.teamScores, ); const playerSensorGroup = useEngineSelector( (state) => state.playback.streamSnapshot?.playerSensorGroup, ); if (!teamScores?.length) return null; // Sort: friendly team first (if known), then by teamId. const sorted = [...teamScores].sort((a, b) => { if (playerSensorGroup) { if (a.teamId === playerSensorGroup) return -1; if (b.teamId === playerSensorGroup) return 1; } return a.teamId - b.teamId; }); return (
{sorted.map((team: TeamScore) => { const isFriendly = playerSensorGroup > 0 && team.teamId === playerSensorGroup; const name = team.name || (DEFAULT_TEAM_NAMES[team.teamId] ?? `Team ${team.teamId}`); return (
{name} {team.score} ({team.playerCount})
); })}
); } // ── Chat Window (top-left) ── /** Map a colorCode to a CSS module class name (c0–c9 GuiChatHudProfile). */ const CHAT_COLOR_CLASSES: Record = { 0: styles.ChatColor0, 1: styles.ChatColor1, 2: styles.ChatColor2, 3: styles.ChatColor3, 4: styles.ChatColor4, 5: styles.ChatColor5, 6: styles.ChatColor6, 7: styles.ChatColor7, 8: styles.ChatColor8, 9: styles.ChatColor9, }; function segmentColorClass(colorCode: number): string { return CHAT_COLOR_CLASSES[colorCode] ?? CHAT_COLOR_CLASSES[0]; } function chatColorClass(msg: ChatMessage): string { if (msg.colorCode != null && CHAT_COLOR_CLASSES[msg.colorCode]) { return CHAT_COLOR_CLASSES[msg.colorCode]; } // Fallback: default to \c0 (teal). Messages with detected codes (like \c2 // for flag events) will match above; \c0 kill messages may lose their null // byte color code, so the correct default for server messages is c0. return CHAT_COLOR_CLASSES[0]; } function ChatWindow() { const messages = useEngineSelector( (state) => state.playback.streamSnapshot?.chatMessages, ); const timeSec = useEngineSelector( (state) => state.playback.streamSnapshot?.timeSec, ); if (!messages || !messages.length || timeSec == null) return null; const fadeStart = 6; const fadeDuration = 1.5; const cutoff = timeSec - (fadeStart + fadeDuration); const visible = messages.filter( (m: ChatMessage) => m.timeSec > cutoff && m.text.trim() !== "", ); if (!visible.length) return null; return (
{visible.map((msg: ChatMessage, i: number) => { const age = timeSec - msg.timeSec; const opacity = age <= fadeStart ? 1 : Math.max(0, 1 - (age - fadeStart) / fadeDuration); return (
{msg.segments ? ( msg.segments.map((seg: ChatSegment, j: number) => ( {seg.text} )) ) : ( {msg.sender ? `${msg.sender}: ` : ""} {msg.text} )}
); })}
); } // ── Backpack + Inventory HUD (bottom-right) ── /** Maps $BackpackHudData indices to icon textures. */ const BACKPACK_ICONS: Record = { 0: "gui/hud_new_packammo", 1: "gui/hud_new_packcloak", 2: "gui/hud_new_packenergy", 3: "gui/hud_new_packrepair", 4: "gui/hud_new_packsatchel", 5: "gui/hud_new_packshield", 6: "gui/hud_new_packinventory", 7: "gui/hud_new_packmotionsens", 8: "gui/hud_new_packradar", 9: "gui/hud_new_packturretout", 10: "gui/hud_new_packturretin", 11: "gui/hud_new_packsensjam", 12: "gui/hud_new_packturret", 13: "gui/hud_new_packturret", 14: "gui/hud_new_packturret", 15: "gui/hud_new_packturret", 16: "gui/hud_new_packturret", 17: "gui/hud_new_packturret", 18: "gui/hud_satchel_unarmed", 19: "gui/hud_new_packenergy", }; /** Pack indices that have an armed/activated icon variant. */ const BACKPACK_ARMED_ICONS: Record = { 1: "gui/hud_new_packcloak_armed", 3: "gui/hud_new_packrepair_armed", 4: "gui/hud_satchel_armed", 5: "gui/hud_new_packshield_armed", 11: "gui/hud_new_packsensjam_armed", }; // Precompute URLs. const BACKPACK_ICON_URLS = new Map( Object.entries(BACKPACK_ICONS).map(([idx, tex]) => [ Number(idx), textureToUrl(tex), ]), ); const BACKPACK_ARMED_ICON_URLS = new Map( Object.entries(BACKPACK_ARMED_ICONS).map(([idx, tex]) => [ Number(idx), textureToUrl(tex), ]), ); /** Simple icons per inventory display slot (no baked-in background). */ const INVENTORY_SLOT_ICONS: Record = { 0: { icon: "gui/hud_handgren", label: "Grenade" }, 1: { icon: "gui/hud_mine", label: "Mine" }, 2: { icon: "gui/hud_beacon", label: "Beacon" }, 3: { icon: "gui/hud_medpack", label: "Repair Kit" }, }; const INVENTORY_ICON_URLS = new Map( Object.entries(INVENTORY_SLOT_ICONS).map(([slot, info]) => [ Number(slot), textureToUrl(info.icon), ]), ); function PackAndInventoryHUD() { const backpackHud = useEngineSelector( (state) => state.playback.streamSnapshot?.backpackHud, ); const inventoryHud = useEngineSelector( (state) => state.playback.streamSnapshot?.inventoryHud, ); const hasPack = backpackHud && backpackHud.packIndex >= 0; // Resolve pack icon. let packIconUrl: string | undefined; if (hasPack) { const armedUrl = backpackHud.active ? BACKPACK_ARMED_ICON_URLS.get(backpackHud.packIndex) : undefined; packIconUrl = armedUrl ?? BACKPACK_ICON_URLS.get(backpackHud.packIndex); } // Build count lookup from snapshot data. const countBySlot = new Map(); if (inventoryHud) { for (const s of inventoryHud.slots) { countBySlot.set(s.slot, s.count); } } // Always show all inventory slot types, defaulting to 0. const allSlotIds = Object.keys(INVENTORY_SLOT_ICONS) .map(Number) .sort((a, b) => a - b); if (!hasPack && !countBySlot.size) return null; return (
{packIconUrl && (
{/* eslint-disable-next-line @next/next/no-img-element */} {backpackHud!.text || "\u00A0"}
)} {allSlotIds.map((slotId) => { const info = INVENTORY_SLOT_ICONS[slotId]; const iconUrl = INVENTORY_ICON_URLS.get(slotId); if (!info || !iconUrl) return null; return (
{/* eslint-disable-next-line @next/next/no-img-element */} {info.label} {countBySlot.get(slotId) ?? 0}
); })}
); } // ── Main HUD ── export function PlayerHUD() { const recording = useRecording(); const streamSnapshot = useEngineSelector( (state) => state.playback.streamSnapshot, ); if (!recording) return null; const status = streamSnapshot?.status; if (!status) return null; return (
); }