begin live server support

This commit is contained in:
Brian Beck 2026-03-09 12:38:40 -07:00
parent 0c9ddb476a
commit e4ae265184
368 changed files with 17756 additions and 7738 deletions

View file

@ -0,0 +1,194 @@
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import type { ServerInfo } from "../../relay/types";
import styles from "./ServerBrowser.module.css";
export function ServerBrowser({
open,
onClose,
servers,
loading,
onRefresh,
onJoin,
wsPing,
}: {
open: boolean;
onClose: () => void;
servers: ServerInfo[];
loading: boolean;
onRefresh: () => void;
onJoin: (address: string) => void;
/** Browser↔relay RTT to add to server pings for effective latency. */
wsPing?: number | null;
}) {
const [selectedAddress, setSelectedAddress] = useState<string | null>(null);
const [sortKey, setSortKey] = useState<keyof ServerInfo>("ping");
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
const dialogRef = useRef<HTMLDivElement>(null);
const onRefreshRef = useRef(onRefresh);
onRefreshRef.current = onRefresh;
const didAutoRefreshRef = useRef(false);
useEffect(() => {
if (open) {
dialogRef.current?.focus();
try {
document.exitPointerLock();
} catch {
/* expected */
}
} else {
didAutoRefreshRef.current = false;
}
}, [open]);
// Refresh on open if no servers cached
useEffect(() => {
if (open && servers.length === 0 && !didAutoRefreshRef.current) {
didAutoRefreshRef.current = true;
onRefreshRef.current();
}
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
// Block keyboard events from reaching Three.js while open
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
e.stopPropagation();
if (e.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [open, onClose]);
const handleSort = useCallback(
(key: keyof ServerInfo) => {
if (sortKey === key) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortKey(key);
setSortDir("desc");
}
},
[sortKey],
);
const sorted = useMemo(() => {
return [...servers].sort((a, b) => {
const av = a[sortKey];
const bv = b[sortKey];
const cmp =
typeof av === "number" && typeof bv === "number"
? av - bv
: String(av).localeCompare(String(bv));
return sortDir === "asc" ? cmp : -cmp;
});
}, [servers, sortDir, sortKey]);
const handleJoin = useCallback(() => {
if (selectedAddress) {
onJoin(selectedAddress);
onClose();
}
}, [selectedAddress, onJoin, onClose]);
if (!open) return null;
return (
<div className={styles.Overlay} onClick={onClose}>
<div
className={styles.Dialog}
ref={dialogRef}
tabIndex={-1}
onClick={(e) => e.stopPropagation()}
>
<div className={styles.Header}>
<h2 className={styles.Title}>Server Browser</h2>
<span className={styles.ServerCount}>
{servers.length} server{servers.length !== 1 ? "s" : ""}
</span>
<button
className={styles.RefreshButton}
onClick={onRefresh}
disabled={loading}
>
{loading ? "Refreshing..." : "Refresh"}
</button>
</div>
<div className={styles.TableWrapper}>
<table className={styles.Table}>
<thead>
<tr>
<th onClick={() => handleSort("name")}>Server Name</th>
<th onClick={() => handleSort("playerCount")}>Players</th>
<th onClick={() => handleSort("ping")}>Ping</th>
<th onClick={() => handleSort("mapName")}>Map</th>
<th onClick={() => handleSort("gameType")}>Type</th>
<th onClick={() => handleSort("mod")}>Mod</th>
</tr>
</thead>
<tbody>
{sorted.map((server) => (
<tr
key={server.address}
className={
selectedAddress === server.address
? styles.Selected
: undefined
}
onClick={() => setSelectedAddress(server.address)}
onDoubleClick={() => {
setSelectedAddress(server.address);
onJoin(server.address);
onClose();
}}
>
<td>
{server.passwordRequired && (
<span className={styles.PasswordIcon}>&#x1F512;</span>
)}
{server.name}
</td>
<td>
{server.playerCount}/{server.maxPlayers}
</td>
<td>
{wsPing != null
? (server.ping + wsPing).toLocaleString()
: "\u2014"}
</td>
<td>{server.mapName}</td>
<td>{server.gameType}</td>
<td>{server.mod}</td>
</tr>
))}
{sorted.length === 0 && !loading && (
<tr>
<td colSpan={6} className={styles.Empty}>
No servers found
</td>
</tr>
)}
{loading && sorted.length === 0 && (
<tr>
<td colSpan={6} className={styles.Empty}>
Querying master server...
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className={styles.Footer}>
<button
onClick={handleJoin}
disabled={!selectedAddress}
className={styles.JoinButton}
>
Join
</button>
<button onClick={onClose} className={styles.CloseButton}>
Cancel
</button>
<span className={styles.Hint}>Double-click a server to join</span>
</div>
</div>
</div>
);
}