mirror of
https://github.com/sndrec/WebMonkeyBall.git
synced 2026-02-03 02:03:33 +00:00
unreliable, wtf were we doing reliable for
This commit is contained in:
@@ -372,6 +372,8 @@ export class Game {
|
||||
};
|
||||
public simTick: number;
|
||||
public netplayRttMs: number | null;
|
||||
public netplayDebugLines: string[] | null;
|
||||
public netplayWarning: string | null;
|
||||
public suppressVisualEffects: boolean;
|
||||
public inputFeed: (QuantizedStick | QuantizedInput)[] | null;
|
||||
public inputFeedIndex: number;
|
||||
@@ -496,6 +498,8 @@ export class Game {
|
||||
};
|
||||
this.simTick = 0;
|
||||
this.netplayRttMs = null;
|
||||
this.netplayDebugLines = null;
|
||||
this.netplayWarning = null;
|
||||
this.suppressVisualEffects = false;
|
||||
this.inputFeed = null;
|
||||
this.inputFeedIndex = 0;
|
||||
|
||||
163
src/main.ts
163
src/main.ts
@@ -206,6 +206,26 @@ const gamepadCalibrationOverlay = document.getElementById('gamepad-calibration')
|
||||
const gamepadCalibrationMap = document.getElementById('gamepad-calibration-map') as HTMLCanvasElement | null;
|
||||
const gamepadCalibrationButton = document.getElementById('gamepad-calibrate') as HTMLButtonElement | null;
|
||||
const gamepadCalibrationCtx = gamepadCalibrationMap?.getContext('2d') ?? null;
|
||||
const netplayDebugWrap = document.createElement('div');
|
||||
const netplayDebugWarningEl = document.createElement('div');
|
||||
const netplayDebugInfoEl = document.createElement('div');
|
||||
netplayDebugWrap.id = 'netplay-debug';
|
||||
netplayDebugWrap.style.position = 'fixed';
|
||||
netplayDebugWrap.style.left = '12px';
|
||||
netplayDebugWrap.style.top = '120px';
|
||||
netplayDebugWrap.style.zIndex = '10000';
|
||||
netplayDebugWrap.style.color = '#ffffff';
|
||||
netplayDebugWrap.style.font = '12px/1.4 system-ui, sans-serif';
|
||||
netplayDebugWrap.style.whiteSpace = 'pre';
|
||||
netplayDebugWrap.style.pointerEvents = 'none';
|
||||
netplayDebugWrap.style.textShadow = '0 1px 2px rgba(0,0,0,0.7)';
|
||||
netplayDebugWrap.style.display = 'none';
|
||||
netplayDebugWarningEl.style.color = '#ff6666';
|
||||
netplayDebugWarningEl.style.fontWeight = '600';
|
||||
netplayDebugWarningEl.style.marginBottom = '4px';
|
||||
netplayDebugInfoEl.style.whiteSpace = 'pre';
|
||||
netplayDebugWrap.append(netplayDebugWarningEl, netplayDebugInfoEl);
|
||||
document.body.appendChild(netplayDebugWrap);
|
||||
const startButton = document.getElementById('start') as HTMLButtonElement;
|
||||
const resumeButton = document.getElementById('resume') as HTMLButtonElement;
|
||||
const difficultySelect = document.getElementById('difficulty') as HTMLSelectElement;
|
||||
@@ -660,6 +680,10 @@ const NETPLAY_LAG_FUSE_FRAMES = 24;
|
||||
const NETPLAY_LAG_FUSE_MS = 500;
|
||||
const NETPLAY_SNAPSHOT_COOLDOWN_MS = 1000;
|
||||
const NETPLAY_PING_INTERVAL_MS = 1000;
|
||||
const NETPLAY_HOST_STALL_MS = 3000;
|
||||
const NETPLAY_HOST_SNAPSHOT_BEHIND_FRAMES = 120;
|
||||
const NETPLAY_HOST_SNAPSHOT_COOLDOWN_MS = 1500;
|
||||
const NETPLAY_DEBUG_STORAGE_KEY = 'smb_netplay_debug';
|
||||
const LOBBY_HEARTBEAT_INTERVAL_MS = 15000;
|
||||
const LOBBY_HEARTBEAT_FALLBACK_MS = 12000;
|
||||
|
||||
@@ -742,10 +766,34 @@ function recordNetplayPerf(startMs: number, simTicks = 0) {
|
||||
logNetplayPerf(nowMs);
|
||||
}
|
||||
|
||||
function isNetplayDebugEnabled() {
|
||||
const globalFlag = (window as any).NETPLAY_DEBUG;
|
||||
if (globalFlag !== undefined) {
|
||||
return !!globalFlag;
|
||||
}
|
||||
try {
|
||||
return localStorage.getItem(NETPLAY_DEBUG_STORAGE_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function setNetplayDebugEnabled(enabled: boolean) {
|
||||
(window as any).NETPLAY_DEBUG = enabled;
|
||||
try {
|
||||
localStorage.setItem(NETPLAY_DEBUG_STORAGE_KEY, enabled ? '1' : '0');
|
||||
} catch {
|
||||
// Ignore storage issues.
|
||||
}
|
||||
}
|
||||
|
||||
(window as any).setNetplayDebug = setNetplayDebugEnabled;
|
||||
|
||||
type NetplayRole = 'host' | 'client';
|
||||
type NetplayClientState = {
|
||||
lastAckedHostFrame: number;
|
||||
lastAckedClientInput: number;
|
||||
lastSnapshotMs: number | null;
|
||||
};
|
||||
type NetplayState = {
|
||||
role: NetplayRole;
|
||||
@@ -878,6 +926,7 @@ function resetNetplayForStage() {
|
||||
for (const clientState of netplayState.clientStates.values()) {
|
||||
clientState.lastAckedHostFrame = -1;
|
||||
clientState.lastAckedClientInput = -1;
|
||||
clientState.lastSnapshotMs = null;
|
||||
}
|
||||
resetNetplaySession();
|
||||
}
|
||||
@@ -1831,7 +1880,7 @@ function handleClientMessage(playerId: number, msg: ClientToHostMessage) {
|
||||
}
|
||||
let clientState = state.clientStates.get(playerId);
|
||||
if (!clientState) {
|
||||
clientState = { lastAckedHostFrame: -1, lastAckedClientInput: -1 };
|
||||
clientState = { lastAckedHostFrame: -1, lastAckedClientInput: -1, lastSnapshotMs: null };
|
||||
state.clientStates.set(playerId, clientState);
|
||||
}
|
||||
if (!game.players.some((player) => player.id === playerId)) {
|
||||
@@ -1951,6 +2000,29 @@ function hostResendFrames(currentFrame: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function hostMaybeSendSnapshots(nowMs: number) {
|
||||
if (!hostRelay || !netplayState || netplayState.role !== 'host') {
|
||||
return;
|
||||
}
|
||||
const state = netplayState;
|
||||
const currentFrame = state.session.getFrame();
|
||||
for (const [playerId, clientState] of state.clientStates.entries()) {
|
||||
if (clientState.lastAckedHostFrame < 0) {
|
||||
continue;
|
||||
}
|
||||
const behind = currentFrame - clientState.lastAckedHostFrame;
|
||||
if (behind < NETPLAY_HOST_SNAPSHOT_BEHIND_FRAMES) {
|
||||
continue;
|
||||
}
|
||||
const lastSnap = clientState.lastSnapshotMs;
|
||||
if (lastSnap !== null && (nowMs - lastSnap) < NETPLAY_HOST_SNAPSHOT_COOLDOWN_MS) {
|
||||
continue;
|
||||
}
|
||||
clientState.lastSnapshotMs = nowMs;
|
||||
sendSnapshotToClient(playerId, currentFrame);
|
||||
}
|
||||
}
|
||||
|
||||
function clientSendInputBuffer(currentFrame: number) {
|
||||
if (!clientPeer || !netplayState) {
|
||||
return;
|
||||
@@ -2087,6 +2159,9 @@ function netplayTick(dtSeconds: number) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (netplayAccumulator < 0) {
|
||||
netplayAccumulator = 0;
|
||||
}
|
||||
const session = state.session;
|
||||
const currentFrame = session.getFrame();
|
||||
const targetFrame = getNetplayTargetFrame(state, currentFrame);
|
||||
@@ -2148,10 +2223,93 @@ function netplayTick(dtSeconds: number) {
|
||||
netplayStep();
|
||||
netplayAccumulator -= game.fixedStep;
|
||||
}
|
||||
if (netplayAccumulator < 0) {
|
||||
netplayAccumulator = 0;
|
||||
}
|
||||
if (state.role === 'host') {
|
||||
hostMaybeSendSnapshots(nowMs);
|
||||
}
|
||||
game.accumulator = Math.max(0, Math.min(game.fixedStep, netplayAccumulator));
|
||||
recordNetplayPerf(perfStart, ticks);
|
||||
}
|
||||
|
||||
function updateNetplayDebugOverlay(nowMs: number) {
|
||||
if (!netplayEnabled || !netplayState) {
|
||||
game.netplayDebugLines = null;
|
||||
game.netplayWarning = null;
|
||||
netplayDebugWrap.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
const state = netplayState;
|
||||
const localPlayer = game.getLocalPlayer?.() ?? null;
|
||||
let warning: string | null = null;
|
||||
if (state.role === 'client') {
|
||||
const hostAge = state.lastHostFrameTimeMs === null ? null : nowMs - state.lastHostFrameTimeMs;
|
||||
if (hostAge !== null && hostAge > NETPLAY_HOST_STALL_MS) {
|
||||
warning = `NET: host frames stale ${(hostAge / 1000).toFixed(1)}s`;
|
||||
} else if (state.awaitingStageSync) {
|
||||
warning = 'NET: awaiting stage sync';
|
||||
}
|
||||
}
|
||||
if (!warning && localPlayer) {
|
||||
if (localPlayer.isSpectator) {
|
||||
warning = 'NET: local spectator';
|
||||
} else if (localPlayer.pendingSpawn) {
|
||||
warning = 'NET: local pending spawn';
|
||||
}
|
||||
}
|
||||
game.netplayWarning = warning;
|
||||
|
||||
if (!isNetplayDebugEnabled()) {
|
||||
game.netplayDebugLines = null;
|
||||
if (!warning) {
|
||||
netplayDebugWrap.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
netplayDebugWarningEl.textContent = warning;
|
||||
netplayDebugInfoEl.textContent = '';
|
||||
netplayDebugWrap.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionFrame = state.session.getFrame();
|
||||
const simFrame = sessionFrame + (netplayAccumulator / game.fixedStep);
|
||||
const targetFrame = getNetplayTargetFrame(state, sessionFrame);
|
||||
const drift = targetFrame - simFrame;
|
||||
const lines: string[] = [];
|
||||
lines.push(`net ${state.role} id=${game.localPlayerId}`);
|
||||
lines.push(`stage=${state.currentStageId ?? game.stage?.stageId ?? 0} seq=${state.stageSeq}`);
|
||||
lines.push(`frame=${sessionFrame} host=${state.lastReceivedHostFrame} ack=${state.lastAckedLocalFrame}`);
|
||||
lines.push(`drift=${drift.toFixed(2)} acc=${netplayAccumulator.toFixed(3)}`);
|
||||
lines.push(`sync=${state.awaitingStageSync ? 1 : 0} ready=${state.awaitingStageReady ? 1 : 0} snap=${state.awaitingSnapshot ? 1 : 0}`);
|
||||
if (state.role === 'client') {
|
||||
const chanState = clientPeer?.getChannelState?.() ?? 'none';
|
||||
const hostAge = state.lastHostFrameTimeMs === null ? 'n/a' : `${((nowMs - state.lastHostFrameTimeMs) / 1000).toFixed(1)}s`;
|
||||
lines.push(`peer=${chanState} hostAge=${hostAge}`);
|
||||
} else {
|
||||
const peers = hostRelay?.getChannelStates?.() ?? [];
|
||||
const peerText = peers.length
|
||||
? peers.map((peer) => `${peer.playerId}:${peer.readyState}`).join(' ')
|
||||
: 'none';
|
||||
lines.push(`peers=${peerText}`);
|
||||
if (state.clientStates.size > 0) {
|
||||
const currentFrame = state.session.getFrame();
|
||||
const behind = Array.from(state.clientStates.entries())
|
||||
.map(([playerId, clientState]) => `${playerId}:${currentFrame - clientState.lastAckedHostFrame}`)
|
||||
.join(' ');
|
||||
lines.push(`behind=${behind}`);
|
||||
}
|
||||
}
|
||||
if (localPlayer) {
|
||||
lines.push(`local spec=${localPlayer.isSpectator ? 1 : 0} spawn=${localPlayer.pendingSpawn ? 1 : 0} state=${localPlayer.ball?.state ?? 0}`);
|
||||
}
|
||||
lines.push(`intro=${game.introTimerFrames} timeover=${game.timeoverTimerFrames}`);
|
||||
game.netplayDebugLines = lines;
|
||||
netplayDebugWarningEl.textContent = warning ?? '';
|
||||
netplayDebugInfoEl.textContent = lines.join('\n');
|
||||
netplayDebugWrap.style.display = 'block';
|
||||
}
|
||||
|
||||
function setReplayStatus(text: string) {
|
||||
if (replayStatus) {
|
||||
replayStatus.textContent = text;
|
||||
@@ -2320,7 +2478,7 @@ function startHost(room: LobbyRoom) {
|
||||
return;
|
||||
}
|
||||
if (!state.clientStates.has(playerId)) {
|
||||
state.clientStates.set(playerId, { lastAckedHostFrame: -1, lastAckedClientInput: -1 });
|
||||
state.clientStates.set(playerId, { lastAckedHostFrame: -1, lastAckedClientInput: -1, lastSnapshotMs: null });
|
||||
}
|
||||
game.addPlayer(playerId, { spectator: false });
|
||||
const player = game.players.find((p) => p.id === playerId);
|
||||
@@ -2727,6 +2885,7 @@ function renderFrame(now: number) {
|
||||
} else {
|
||||
game.update(dtSeconds);
|
||||
}
|
||||
updateNetplayDebugOverlay(now);
|
||||
|
||||
const shouldRender = interpolationEnabled || (now - lastRenderTime) >= RENDER_FRAME_MS;
|
||||
if (!shouldRender) {
|
||||
|
||||
136
src/netplay.ts
136
src/netplay.ts
@@ -12,6 +12,15 @@ export type SignalMessage = {
|
||||
};
|
||||
|
||||
const DEFAULT_STUN = [{ urls: 'stun:stun.l.google.com:19302' }];
|
||||
const FAST_MESSAGE_TYPES = new Set(['frame', 'input', 'ack', 'ping', 'pong']);
|
||||
|
||||
function isFastMessage(msg: { type: string }) {
|
||||
return FAST_MESSAGE_TYPES.has(msg.type);
|
||||
}
|
||||
|
||||
function getChannelRole(label: string) {
|
||||
return label === 'fast' ? 'fast' : 'ctrl';
|
||||
}
|
||||
|
||||
export class LobbyClient {
|
||||
constructor(private baseUrl: string) {}
|
||||
@@ -95,7 +104,8 @@ export class LobbyClient {
|
||||
|
||||
export class HostRelay {
|
||||
private peers = new Map<number, RTCPeerConnection>();
|
||||
private channels = new Map<number, RTCDataChannel>();
|
||||
private channels = new Map<number, { ctrl?: RTCDataChannel; fast?: RTCDataChannel }>();
|
||||
private connected = new Set<number>();
|
||||
|
||||
constructor(private onMessage: (playerId: number, msg: ClientToHostMessage) => void) {}
|
||||
|
||||
@@ -118,10 +128,16 @@ export class HostRelay {
|
||||
}
|
||||
|
||||
attachChannel(playerId: number, channel: RTCDataChannel) {
|
||||
this.channels.set(playerId, channel);
|
||||
const role = getChannelRole(channel.label);
|
||||
const entry = this.channels.get(playerId) ?? {};
|
||||
entry[role] = channel;
|
||||
this.channels.set(playerId, entry);
|
||||
channel.binaryType = 'arraybuffer';
|
||||
channel.addEventListener('open', () => {
|
||||
this.onConnect?.(playerId);
|
||||
if (role === 'ctrl' && !this.connected.has(playerId)) {
|
||||
this.connected.add(playerId);
|
||||
this.onConnect?.(playerId);
|
||||
}
|
||||
});
|
||||
channel.addEventListener('message', (event) => {
|
||||
try {
|
||||
@@ -134,33 +150,73 @@ export class HostRelay {
|
||||
}
|
||||
});
|
||||
channel.addEventListener('close', () => {
|
||||
this.onDisconnect?.(playerId);
|
||||
const current = this.channels.get(playerId);
|
||||
const ctrl = current?.ctrl;
|
||||
if (role === 'ctrl' || !ctrl || ctrl.readyState === 'closed' || ctrl.readyState === 'closing') {
|
||||
if (this.connected.has(playerId)) {
|
||||
this.connected.delete(playerId);
|
||||
this.onDisconnect?.(playerId);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private pickChannel(playerId: number, preferFast: boolean) {
|
||||
const entry = this.channels.get(playerId);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
const primary = preferFast ? entry.fast : entry.ctrl;
|
||||
const fallback = preferFast ? entry.ctrl : entry.fast;
|
||||
if (primary && primary.readyState === 'open') {
|
||||
return primary;
|
||||
}
|
||||
if (fallback && fallback.readyState === 'open') {
|
||||
return fallback;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
broadcast(msg: HostToClientMessage) {
|
||||
const payload = JSON.stringify(msg);
|
||||
for (const channel of this.channels.values()) {
|
||||
if (channel.readyState === 'open') {
|
||||
const preferFast = isFastMessage(msg);
|
||||
for (const playerId of this.channels.keys()) {
|
||||
const channel = this.pickChannel(playerId, preferFast);
|
||||
if (channel) {
|
||||
channel.send(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getChannelStates() {
|
||||
const states: Array<{ playerId: number; readyState: string }> = [];
|
||||
for (const [playerId, entry] of this.channels.entries()) {
|
||||
const ctrl = entry.ctrl?.readyState ?? 'none';
|
||||
const fast = entry.fast?.readyState ?? 'none';
|
||||
states.push({ playerId, readyState: `ctrl=${ctrl} fast=${fast}` });
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
sendTo(playerId: number, msg: HostToClientMessage) {
|
||||
const channel = this.channels.get(playerId);
|
||||
if (!channel || channel.readyState !== 'open') {
|
||||
const channel = this.pickChannel(playerId, isFastMessage(msg));
|
||||
if (!channel) {
|
||||
return;
|
||||
}
|
||||
channel.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
closeAll() {
|
||||
for (const channel of this.channels.values()) {
|
||||
try {
|
||||
channel.close();
|
||||
} catch {
|
||||
// Ignore.
|
||||
for (const entry of this.channels.values()) {
|
||||
for (const channel of [entry.ctrl, entry.fast]) {
|
||||
if (!channel) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
channel.close();
|
||||
} catch {
|
||||
// Ignore.
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const peer of this.peers.values()) {
|
||||
@@ -172,6 +228,7 @@ export class HostRelay {
|
||||
}
|
||||
this.channels.clear();
|
||||
this.peers.clear();
|
||||
this.connected.clear();
|
||||
}
|
||||
|
||||
hostId = 0;
|
||||
@@ -182,12 +239,18 @@ export class HostRelay {
|
||||
|
||||
export class ClientPeer {
|
||||
private pc: RTCPeerConnection | null = null;
|
||||
private channel: RTCDataChannel | null = null;
|
||||
private ctrlChannel: RTCDataChannel | null = null;
|
||||
private fastChannel: RTCDataChannel | null = null;
|
||||
|
||||
constructor(private onMessage: (msg: HostToClientMessage) => void) {}
|
||||
|
||||
private attachChannel(channel: RTCDataChannel) {
|
||||
this.channel = channel;
|
||||
const role = getChannelRole(channel.label);
|
||||
if (role === 'fast') {
|
||||
this.fastChannel = channel;
|
||||
} else {
|
||||
this.ctrlChannel = channel;
|
||||
}
|
||||
channel.addEventListener('message', (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data) as HostToClientMessage;
|
||||
@@ -197,7 +260,9 @@ export class ClientPeer {
|
||||
}
|
||||
});
|
||||
channel.addEventListener('close', () => {
|
||||
this.onDisconnect?.();
|
||||
if (role === 'ctrl') {
|
||||
this.onDisconnect?.();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -216,23 +281,42 @@ export class ClientPeer {
|
||||
}
|
||||
|
||||
send(msg: ClientToHostMessage) {
|
||||
if (this.channel?.readyState === 'open') {
|
||||
this.channel.send(JSON.stringify(msg));
|
||||
const preferFast = isFastMessage(msg);
|
||||
const primary = preferFast ? this.fastChannel : this.ctrlChannel;
|
||||
const fallback = preferFast ? this.ctrlChannel : this.fastChannel;
|
||||
if (primary?.readyState === 'open') {
|
||||
primary.send(JSON.stringify(msg));
|
||||
return;
|
||||
}
|
||||
if (fallback?.readyState === 'open') {
|
||||
fallback.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
getChannelState(): string {
|
||||
const ctrl = this.ctrlChannel?.readyState ?? 'none';
|
||||
const fast = this.fastChannel?.readyState ?? 'none';
|
||||
return `ctrl=${ctrl} fast=${fast}`;
|
||||
}
|
||||
|
||||
close() {
|
||||
try {
|
||||
this.channel?.close();
|
||||
} catch {
|
||||
// Ignore.
|
||||
for (const channel of [this.ctrlChannel, this.fastChannel]) {
|
||||
if (!channel) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
channel.close();
|
||||
} catch {
|
||||
// Ignore.
|
||||
}
|
||||
}
|
||||
try {
|
||||
this.pc?.close();
|
||||
} catch {
|
||||
// Ignore.
|
||||
}
|
||||
this.channel = null;
|
||||
this.ctrlChannel = null;
|
||||
this.fastChannel = null;
|
||||
this.pc = null;
|
||||
}
|
||||
|
||||
@@ -260,8 +344,10 @@ export class ClientPeer {
|
||||
|
||||
export async function createHostOffer(host: HostRelay, playerId: number) {
|
||||
const pc = host.getPeer(playerId);
|
||||
const channel = pc.createDataChannel('game');
|
||||
host.attachChannel(playerId, channel);
|
||||
const ctrl = pc.createDataChannel('ctrl');
|
||||
host.attachChannel(playerId, ctrl);
|
||||
const fast = pc.createDataChannel('fast', { ordered: false, maxRetransmits: 0 });
|
||||
host.attachChannel(playerId, fast);
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
return pc.localDescription;
|
||||
|
||||
Reference in New Issue
Block a user