improve observer mode and disconnect

This commit is contained in:
Brian Beck 2026-03-14 20:41:54 -07:00
parent 25b4a53a00
commit 78e791f763
39 changed files with 877 additions and 837 deletions

View file

@ -4,6 +4,7 @@
left: 6px;
width: 400px;
max-width: 50%;
max-height: 25%;
display: flex;
flex-direction: column;
pointer-events: auto;

View file

@ -185,9 +185,6 @@ export function InputConsumer() {
const tickMoveZ = useRef(0);
const tickTriggers = useRef([false, false, false, false, false, false]);
// Previous trigger state for edge detection.
const prevTriggers = useRef([false, false, false, false, false, false]);
const isLive =
!!adapter &&
(gameStatus === "connected" || gameStatus === "authenticating");
@ -241,8 +238,9 @@ export function InputConsumer() {
// Reset prediction state and mode when liveReady goes false (map cycle).
// During map transitions, liveReady is false from MissionStartPhase1 until
// the first ghost arrives on the new map. The adapter resets observerMode
// to "fly" at phase 1, and we mirror that here.
// the first ghost arrives on the new map. Reset to "fly" here; the server-
// authoritative mode derivation in useFrame will correct it once the first
// camera snapshot arrives on the new map.
useEffect(() => {
if (!liveReady && activeAdapterRef.current) {
log.info("mission change: resetting prediction state and mode");
@ -259,7 +257,6 @@ export function InputConsumer() {
tickMoveY.current = 0;
tickMoveZ.current = 0;
tickTriggers.current.fill(false);
prevTriggers.current.fill(false);
setMode("fly");
}
}, [liveReady, setMode]);
@ -285,16 +282,6 @@ export function InputConsumer() {
const triggers = [...tickTriggers.current];
tickTriggers.current.fill(false);
// Trigger edge detection.
if (triggers[2] && !prevTriggers.current[2]) {
activeAdapterRef.current.toggleObserverMode();
log.info("observer mode: %s", activeAdapterRef.current.observerMode);
setMode(
activeAdapterRef.current.observerMode === "follow" ? "follow" : "fly",
);
}
prevTriggers.current = triggers;
// ── Camera::processTick equivalent ──
// Quantize rotation to match server's Move::clamp/unclamp round-trip.
@ -556,6 +543,26 @@ export function InputConsumer() {
predInitialized.current = true;
// Derive observer mode from the server's camera data — the server
// is authoritative. "third-person" with a camera control type means
// the server's Camera is in OrbitObject mode (follow). "observer"
// means free-fly. This corrects local state if a trigger 2 move
// was lost or the server rejected the mode transition.
const serverMode = serverCam.mode === "third-person" ? "follow" : "fly";
if (serverMode !== mode) {
log.info("server corrected observer mode: %s → %s", mode, serverMode);
setMode(serverMode);
if (activeAdapterRef.current) {
activeAdapterRef.current.observerMode = serverMode;
}
// Reset orbit tracking when leaving follow mode so it re-initializes
// cleanly next time we enter follow.
if (serverMode === "fly") {
orbitTargetInitialized.current = false;
lastOrbitSnapshot.current = null;
}
}
// Initialize orbit target position on first reconciliation with
// orbit data, so follow mode works immediately after mode switch.
if (serverCam.orbitTargetId && !orbitTargetInitialized.current) {

View file

@ -17,7 +17,6 @@ export function JoinServerButton({
// const serverName = useLiveSelector((s) => s.serverName);
const ping = useLiveSelector(selectPing);
const disconnectServer = useLiveSelector((s) => s.disconnectServer);
const disconnectRelay = useLiveSelector((s) => s.disconnectRelay);
const isLive = gameStatus === "connected";
const isConnecting =
@ -34,7 +33,6 @@ export function JoinServerButton({
onClick={() => {
if (isLive) {
disconnectServer();
disconnectRelay();
} else {
onOpenServerBrowser();
}

View file

@ -55,7 +55,6 @@ export function LoadDemoButton({
// Disconnect from any live server before loading the demo.
const liveState = liveConnectionStore.getState();
liveState.disconnectServer();
liveState.disconnectRelay();
// Metadata-first: mission/game-mode sync happens immediately.
setRecording(recording);
} catch (err) {

View file

@ -130,7 +130,6 @@ export function MapInspector() {
// clear stream state before loading the new mission in map mode.
const liveState = liveConnectionStore.getState();
liveState.disconnectServer();
liveState.disconnectRelay();
engineStore.getState().setRecording(null);
gameEntityStore.getState().endStreaming();
setCurrentMission(mission);

View file

@ -49,7 +49,6 @@
transform: translate(-50%, -50%);
font-size: 10px;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: rgba(200, 240, 230, 0.9);
text-shadow: 0 0 4px rgba(0, 0, 0, 0.8);
pointer-events: none;
@ -149,7 +148,7 @@
.TeamScore {
color: #fff;
font-weight: 500;
text-align: right;
text-align: center;
padding: 0 10px;
border-left: 1px solid rgba(128, 255, 200, 0.15);
}
@ -157,6 +156,7 @@
.TeamCount {
color: rgb(125, 155, 150);
font-size: 11px;
text-align: center;
padding: 0 6px;
}

View file

@ -42,7 +42,6 @@ export function StreamingMissionInfo() {
const handleDisconnect = useCallback(() => {
const liveState = liveConnectionStore.getState();
liveState.disconnectServer();
liveState.disconnectRelay();
engineStore.getState().setRecording(null);
}, []);

View file

@ -243,17 +243,9 @@ export const liveConnectionStore = createStore<LiveConnectionStore>(
},
disconnectServer() {
const s = get();
s._relay?.disconnectServer();
s._adapter = null;
set({
adapter: null,
liveReady: false,
gameStatus: null,
mapName: undefined,
serverName: undefined,
relayToGameServerPing: null,
});
// Close the WebSocket — the relay's ws.on("close") handler will
// automatically send the disconnect packet to the game server.
get().disconnectRelay();
},
sendMoves(moves, moveStartIndex) {

View file

@ -217,7 +217,13 @@ export abstract class StreamEngine implements StreamingPlayback {
protected teamScores: TeamScore[] = [];
protected playerRoster = new Map<
number,
{ name: string; teamId: number; score: number; ping: number; packetLoss: number }
{
name: string;
teamId: number;
score: number;
ping: number;
packetLoss: number;
}
>();
/** Stream time (seconds) when the clock was last set. */
protected clockAnchorStreamSec: number | null = null;
@ -548,7 +554,10 @@ export abstract class StreamEngine implements StreamingPlayback {
if (targetId != null && nameTag != null) {
const resolved = this.netStrings.get(nameTag);
if (resolved) {
this.targetNames.set(targetId, stripTaggedStringMarkup(resolved).trim());
this.targetNames.set(
targetId,
stripTaggedStringMarkup(resolved).trim(),
);
} else {
// NetStringEvent hasn't arrived yet — defer resolution.
this.pendingNameTags.set(nameTag, targetId);
@ -1921,10 +1930,7 @@ export abstract class StreamEngine implements StreamingPlayback {
if (existing) {
const score = parseInt(this.resolveNetString(args[3]), 10);
const ping = parseInt(this.resolveNetString(args[4]), 10);
const packetLoss = parseInt(
this.resolveNetString(args[5] ?? ""),
10,
);
const packetLoss = parseInt(this.resolveNetString(args[5] ?? ""), 10);
if (!isNaN(score)) existing.score = score;
if (!isNaN(ping)) existing.ping = ping;
if (!isNaN(packetLoss)) existing.packetLoss = packetLoss;

View file

@ -121,7 +121,13 @@ interface ParsedDemoValues {
teamScores: TeamScore[];
playerRoster: Map<
number,
{ name: string; teamId: number; score: number; ping: number; packetLoss: number }
{
name: string;
teamId: number;
score: number;
ping: number;
packetLoss: number;
}
>;
chatMessages: string[];
/** Value from clockHud.getTime() — minutes passed to setTime(). */
@ -171,7 +177,13 @@ function parseDemoValues(demoValues: string[]): ParsedDemoValues {
const ping = parseInt(fields[6], 10) || 0;
const packetLoss = parseInt(fields[7], 10) || 0;
if (!isNaN(clientId) && !isNaN(teamId)) {
result.playerRoster.set(clientId, { name, teamId, score, ping, packetLoss });
result.playerRoster.set(clientId, {
name,
teamId,
score,
ping,
packetLoss,
});
}
if (!isNaN(teamId) && teamId > 0) {
playerCountByTeam.set(teamId, (playerCountByTeam.get(teamId) ?? 0) + 1);

View file

@ -305,23 +305,12 @@ export class LiveStreamAdapter extends StreamEngine {
}
/**
* Server-side observer camera mode. In "fly" mode, trigger 0 (fire) would
* make the server assign a team so we must NEVER send fire in fly mode.
* Jump (trigger 2) transitions between modes.
* Observer camera mode, kept in sync with the server's camera state.
* In "fly" mode, trigger 0 (fire) would make the server assign a team
* so we must NEVER send fire in fly mode.
*/
observerMode: "fly" | "follow" = "fly";
/** Toggle between follow and free-fly observer modes (local state only). */
toggleObserverMode(): void {
if (this.observerMode === "fly") {
log.info("observer: fly → follow");
this.observerMode = "follow";
} else {
log.info("observer: follow → fly");
this.observerMode = "fly";
}
}
/** Request updated scores from the server (triggers MsgPlayerScore messages). */
requestScores(): void {
this.relay.sendCommand("getScores", []);

View file

@ -134,11 +134,6 @@ export class RelayClient {
this.send({ type: "joinServer", address, warriorName });
}
/** Disconnect from the current game server. */
disconnectServer(): void {
this.send({ type: "disconnect" });
}
/** Forward a T2csri auth event to the relay. */
sendAuthEvent(command: string, args: string[]): void {
this.send({ type: "sendCommand", command, args });