mirror of
https://github.com/sndrec/WebMonkeyBall.git
synced 2026-02-03 02:03:33 +00:00
multiplayer first pass
This commit is contained in:
28
index.html
28
index.html
@@ -90,6 +90,30 @@
|
||||
</div>
|
||||
<input id="replay-file" class="hidden" type="file" accept=".json" />
|
||||
|
||||
<div class="panel-section">
|
||||
<div class="panel-section-header">
|
||||
<h2>Multiplayer</h2>
|
||||
<button id="lobby-refresh" class="ghost compact" type="button">Refresh</button>
|
||||
</div>
|
||||
<div class="multiplayer-row">
|
||||
<button id="lobby-create" class="ghost compact" type="button">Create Room</button>
|
||||
<label class="checkbox-field">
|
||||
<input id="lobby-public" type="checkbox" checked />
|
||||
<span>Public</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="multiplayer-row">
|
||||
<input id="lobby-code" class="text-input" type="text" placeholder="Room code" />
|
||||
<button id="lobby-join" class="ghost compact" type="button">Join</button>
|
||||
</div>
|
||||
<div class="multiplayer-row">
|
||||
<button id="lobby-leave" class="ghost compact hidden" type="button">Leave Room</button>
|
||||
</div>
|
||||
<div id="lobby-status" class="pack-status">Lobby: idle</div>
|
||||
<div id="lobby-players" class="lobby-players hidden"></div>
|
||||
<div id="lobby-list" class="lobby-list"></div>
|
||||
</div>
|
||||
|
||||
<label id="control-mode-field" class="field hidden">
|
||||
<span>Control Mode</span>
|
||||
<div class="control-mode-row">
|
||||
@@ -336,6 +360,10 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
window.LOBBY_URL = "https://webmonkeyball-lobby.sndrec32exe.workers.dev";
|
||||
</script>
|
||||
|
||||
<script type="module" src="./dist/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -584,7 +584,7 @@ export class GameplayCamera {
|
||||
this.lookAt.y = ball.pos.y;
|
||||
this.lookAt.z = ball.pos.z;
|
||||
|
||||
const randYaw = toS16(Math.floor(Math.random() * 0x10000));
|
||||
const randYaw = toS16(ball.rotY ?? 0);
|
||||
stack.fromIdentity();
|
||||
stack.rotateY(randYaw);
|
||||
tmpVec.x = 3;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export type QuantizedStick = { x: number; y: number };
|
||||
export type QuantizedInput = { x: number; y: number; buttons?: number };
|
||||
|
||||
export function quantizeStickAxis(value: number): number {
|
||||
const clamped = Math.max(-1, Math.min(1, value));
|
||||
@@ -23,6 +24,14 @@ export function quantizeStick(stick: { x: number; y: number }): QuantizedStick {
|
||||
};
|
||||
}
|
||||
|
||||
export function quantizeInput(stick: { x: number; y: number }, buttons = 0): QuantizedInput {
|
||||
return {
|
||||
x: quantizeStickAxis(stick.x),
|
||||
y: quantizeStickAxis(stick.y),
|
||||
buttons: buttons | 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function dequantizeStick(stick: QuantizedStick): { x: number; y: number } {
|
||||
return {
|
||||
x: dequantizeStickAxis(stick.x),
|
||||
|
||||
1115
src/game.ts
1115
src/game.ts
File diff suppressed because it is too large
Load Diff
@@ -1080,12 +1080,13 @@ export class HudRenderer {
|
||||
const timeLeftRaw = (game?.stageTimeLimitFrames ?? 0) - (game?.stageTimerFrames ?? 0);
|
||||
const timeLeft = Math.max(0, timeLeftRaw);
|
||||
const { seconds, centis } = formatTimerSeconds(timeLeft);
|
||||
const bananasCollected = game?.ball?.bananas ?? 0;
|
||||
const localPlayer = game?.getLocalPlayer?.() ?? null;
|
||||
const bananasCollected = localPlayer?.ball?.bananas ?? 0;
|
||||
const bananasLeft = game?.bananasLeft ?? 0;
|
||||
const bananaTotal = bananasCollected + bananasLeft;
|
||||
const score = String(Math.max(0, Math.trunc(this.scoreDisplay)));
|
||||
const lives = Math.max(0, Math.trunc(game?.lives ?? 0));
|
||||
const speedMph = Math.max(0, (game?.ball?.speed ?? 0) * 134.21985);
|
||||
const speedMph = Math.max(0, (localPlayer?.ball?.speed ?? 0) * 134.21985);
|
||||
const isBonusStage = game?.isBonusStageActive?.() ?? false;
|
||||
const floorInfo = game?.course?.getFloorInfo?.();
|
||||
const floorPrefix = floorInfo?.prefix ?? 'FLOOR';
|
||||
|
||||
@@ -494,6 +494,14 @@ export class Input {
|
||||
return !!button && (button.pressed || button.value > 0.5);
|
||||
}
|
||||
|
||||
getButtonsBitmask() {
|
||||
let bits = 0;
|
||||
if (this.isPrimaryActionDown()) {
|
||||
bits |= 1;
|
||||
}
|
||||
return bits;
|
||||
}
|
||||
|
||||
getGamepadStick() {
|
||||
const gamepads = navigator.getGamepads?.() ?? navigator.webkitGetGamepads?.();
|
||||
if (!gamepads) {
|
||||
|
||||
827
src/main.ts
827
src/main.ts
@@ -28,6 +28,10 @@ import { decompressLZ } from './noclip/SuperMonkeyBall/AVLZ.js';
|
||||
import * as Nl from './noclip/SuperMonkeyBall/NaomiLib.js';
|
||||
import * as Gma from './noclip/SuperMonkeyBall/Gma.js';
|
||||
import { GameplaySyncState, Renderer } from './noclip/Render.js';
|
||||
import { LobbyClient, HostRelay, ClientPeer, createHostOffer, applyHostSignal } from './netplay.js';
|
||||
import type { QuantizedInput } from './determinism.js';
|
||||
import { hashSimState } from './sim_hash.js';
|
||||
import type { ClientToHostMessage, FrameBundleMessage, HostToClientMessage } from './netcode_protocol.js';
|
||||
import { parseStagedefLz } from './noclip/SuperMonkeyBall/Stagedef.js';
|
||||
import { StageId, STAGE_INFO_MAP } from './noclip/SuperMonkeyBall/StageInfo.js';
|
||||
import type { StageData } from './noclip/SuperMonkeyBall/World.js';
|
||||
@@ -565,6 +569,7 @@ const syncState: GameplaySyncState = {
|
||||
bananaCollectedByAnimGroup: null,
|
||||
animGroupTransforms: null,
|
||||
ball: null,
|
||||
balls: null,
|
||||
goalBags: null,
|
||||
goalTapes: null,
|
||||
confetti: null,
|
||||
@@ -573,6 +578,268 @@ const syncState: GameplaySyncState = {
|
||||
stageTilt: null,
|
||||
};
|
||||
|
||||
type LobbyRoom = {
|
||||
roomId: string;
|
||||
roomCode?: string;
|
||||
isPublic: boolean;
|
||||
hostId: number;
|
||||
courseId: string;
|
||||
settings: { maxPlayers: number; collisionEnabled: boolean };
|
||||
};
|
||||
|
||||
const lobbyBaseUrl = (window as any).LOBBY_URL ?? "";
|
||||
const lobbyClient = lobbyBaseUrl ? new LobbyClient(lobbyBaseUrl) : null;
|
||||
|
||||
const lobbyRefreshButton = document.getElementById('lobby-refresh') as HTMLButtonElement | null;
|
||||
const lobbyCreateButton = document.getElementById('lobby-create') as HTMLButtonElement | null;
|
||||
const lobbyJoinButton = document.getElementById('lobby-join') as HTMLButtonElement | null;
|
||||
const lobbyPublicCheckbox = document.getElementById('lobby-public') as HTMLInputElement | null;
|
||||
const lobbyCodeInput = document.getElementById('lobby-code') as HTMLInputElement | null;
|
||||
const lobbyLeaveButton = document.getElementById('lobby-leave') as HTMLButtonElement | null;
|
||||
const lobbyStatus = document.getElementById('lobby-status') as HTMLElement | null;
|
||||
const lobbyList = document.getElementById('lobby-list') as HTMLElement | null;
|
||||
const lobbyPlayers = document.getElementById('lobby-players') as HTMLElement | null;
|
||||
|
||||
let lobbyRoom: LobbyRoom | null = null;
|
||||
let lobbySignal: { send: (msg: any) => void; close: () => void } | null = null;
|
||||
let hostRelay: HostRelay | null = null;
|
||||
let clientPeer: ClientPeer | null = null;
|
||||
let netplayEnabled = false;
|
||||
let pendingSnapshot: { frame: number; state: any; stageId?: number; gameSource?: GameSource } | null = null;
|
||||
let lobbyHeartbeatTimer: number | null = null;
|
||||
let netplayAccumulator = 0;
|
||||
const NETPLAY_MAX_FRAME_DELTA = 5;
|
||||
|
||||
type NetplayRole = 'host' | 'client';
|
||||
type NetplayClientState = {
|
||||
lastAckedHostFrame: number;
|
||||
lastAckedClientInput: number;
|
||||
};
|
||||
type NetplayState = {
|
||||
role: NetplayRole;
|
||||
session: ReturnType<Game['ensureRollbackSession']>;
|
||||
inputHistory: Map<number, Map<number, QuantizedInput>>;
|
||||
lastInputs: Map<number, QuantizedInput>;
|
||||
pendingLocalInputs: Map<number, QuantizedInput>;
|
||||
lastAckedLocalFrame: number;
|
||||
lastReceivedHostFrame: number;
|
||||
hostFrameBuffer: Map<number, FrameBundleMessage>;
|
||||
clientStates: Map<number, NetplayClientState>;
|
||||
maxRollback: number;
|
||||
maxResend: number;
|
||||
hashInterval: number;
|
||||
hashHistory: Map<number, number>;
|
||||
expectedHashes: Map<number, number>;
|
||||
currentCourse: any | null;
|
||||
currentGameSource: GameSource | null;
|
||||
awaitingSnapshot: boolean;
|
||||
};
|
||||
|
||||
let netplayState: NetplayState | null = null;
|
||||
|
||||
function createNetplayId() {
|
||||
const buf = new Uint32Array(1);
|
||||
crypto.getRandomValues(buf);
|
||||
return buf[0] >>> 0;
|
||||
}
|
||||
|
||||
function quantizedEqual(a: QuantizedInput, b: QuantizedInput) {
|
||||
return a.x === b.x && a.y === b.y && (a.buttons ?? 0) === (b.buttons ?? 0);
|
||||
}
|
||||
|
||||
function ensureNetplayState(role: NetplayRole) {
|
||||
if (netplayState && netplayState.role === role) {
|
||||
return netplayState;
|
||||
}
|
||||
const session = game.ensureRollbackSession();
|
||||
session.prime(game.simTick);
|
||||
netplayState = {
|
||||
role,
|
||||
session,
|
||||
inputHistory: new Map(),
|
||||
lastInputs: new Map(),
|
||||
pendingLocalInputs: new Map(),
|
||||
lastAckedLocalFrame: -1,
|
||||
lastReceivedHostFrame: game.simTick,
|
||||
hostFrameBuffer: new Map(),
|
||||
clientStates: new Map(),
|
||||
maxRollback: 30,
|
||||
maxResend: 8,
|
||||
hashInterval: 15,
|
||||
hashHistory: new Map(),
|
||||
expectedHashes: new Map(),
|
||||
currentCourse: null,
|
||||
currentGameSource: null,
|
||||
awaitingSnapshot: false,
|
||||
};
|
||||
return netplayState;
|
||||
}
|
||||
|
||||
function resetNetplaySession() {
|
||||
game.rollbackSession = null;
|
||||
const session = game.ensureRollbackSession();
|
||||
session.prime(game.simTick);
|
||||
if (netplayState) {
|
||||
netplayState.session = session;
|
||||
}
|
||||
}
|
||||
|
||||
function getSimHash() {
|
||||
if (!game.stageRuntime || !game.world) {
|
||||
return 0;
|
||||
}
|
||||
const balls = game.players.map((player) => player.ball);
|
||||
const worlds = [game.world, ...game.players.map((player) => player.world)];
|
||||
return hashSimState(balls, worlds, game.stageRuntime);
|
||||
}
|
||||
|
||||
function updateLobbyUi() {
|
||||
if (!lobbyStatus || !lobbyLeaveButton || !lobbyPlayers) {
|
||||
return;
|
||||
}
|
||||
if (!netplayEnabled || !lobbyRoom) {
|
||||
lobbyLeaveButton.classList.add('hidden');
|
||||
lobbyPlayers.classList.add('hidden');
|
||||
lobbyPlayers.textContent = '';
|
||||
return;
|
||||
}
|
||||
lobbyLeaveButton.classList.remove('hidden');
|
||||
const role = netplayState?.role ?? 'offline';
|
||||
const playerIds = game.players.map((player) => player.id);
|
||||
lobbyPlayers.textContent = `Connected (${role}): ${playerIds.join(', ') || 'none'}`;
|
||||
lobbyPlayers.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function startLobbyHeartbeat(roomId: string) {
|
||||
if (!lobbyClient) {
|
||||
return;
|
||||
}
|
||||
if (lobbyHeartbeatTimer !== null) {
|
||||
window.clearInterval(lobbyHeartbeatTimer);
|
||||
}
|
||||
lobbyHeartbeatTimer = window.setInterval(() => {
|
||||
void lobbyClient.heartbeat(roomId);
|
||||
}, 15000);
|
||||
}
|
||||
|
||||
function stopLobbyHeartbeat() {
|
||||
if (lobbyHeartbeatTimer !== null) {
|
||||
window.clearInterval(lobbyHeartbeatTimer);
|
||||
lobbyHeartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function resetNetplayConnections() {
|
||||
lobbySignal?.close();
|
||||
lobbySignal = null;
|
||||
hostRelay?.closeAll();
|
||||
hostRelay = null;
|
||||
clientPeer?.close();
|
||||
clientPeer = null;
|
||||
netplayEnabled = false;
|
||||
netplayState = null;
|
||||
pendingSnapshot = null;
|
||||
netplayAccumulator = 0;
|
||||
stopLobbyHeartbeat();
|
||||
lobbyRoom = null;
|
||||
updateLobbyUi();
|
||||
}
|
||||
|
||||
function recordInputForFrame(frame: number, playerId: number, input: QuantizedInput) {
|
||||
if (!netplayState) {
|
||||
return false;
|
||||
}
|
||||
let frameInputs = netplayState.inputHistory.get(frame);
|
||||
if (!frameInputs) {
|
||||
frameInputs = new Map();
|
||||
netplayState.inputHistory.set(frame, frameInputs);
|
||||
}
|
||||
const prev = frameInputs.get(playerId);
|
||||
if (prev && quantizedEqual(prev, input)) {
|
||||
return false;
|
||||
}
|
||||
frameInputs.set(playerId, input);
|
||||
netplayState.lastInputs.set(playerId, input);
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildInputsForFrame(frame: number) {
|
||||
if (!netplayState) {
|
||||
return new Map<number, QuantizedInput>();
|
||||
}
|
||||
let frameInputs = netplayState.inputHistory.get(frame);
|
||||
if (!frameInputs) {
|
||||
frameInputs = new Map();
|
||||
netplayState.inputHistory.set(frame, frameInputs);
|
||||
}
|
||||
for (const player of game.players) {
|
||||
if (!frameInputs.has(player.id)) {
|
||||
const last = netplayState.lastInputs.get(player.id) ?? { x: 0, y: 0, buttons: 0 };
|
||||
frameInputs.set(player.id, last);
|
||||
}
|
||||
}
|
||||
return frameInputs;
|
||||
}
|
||||
|
||||
function trimNetplayHistory(frame: number) {
|
||||
if (!netplayState) {
|
||||
return;
|
||||
}
|
||||
const minFrame = frame - netplayState.maxRollback;
|
||||
for (const key of Array.from(netplayState.inputHistory.keys())) {
|
||||
if (key < minFrame) {
|
||||
netplayState.inputHistory.delete(key);
|
||||
}
|
||||
}
|
||||
for (const key of Array.from(netplayState.hashHistory.keys())) {
|
||||
if (key < minFrame) {
|
||||
netplayState.hashHistory.delete(key);
|
||||
}
|
||||
}
|
||||
for (const key of Array.from(netplayState.expectedHashes.keys())) {
|
||||
if (key < minFrame) {
|
||||
netplayState.expectedHashes.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rollbackAndResim(startFrame: number) {
|
||||
if (!netplayState) {
|
||||
return false;
|
||||
}
|
||||
const session = netplayState.session;
|
||||
const current = session.getFrame();
|
||||
const rollbackFrame = Math.max(0, startFrame - 1);
|
||||
if (!session.rollbackTo(rollbackFrame)) {
|
||||
return false;
|
||||
}
|
||||
for (let frame = rollbackFrame + 1; frame <= current; frame += 1) {
|
||||
const inputs = buildInputsForFrame(frame);
|
||||
session.advanceTo(frame, inputs);
|
||||
if (netplayState.hashInterval > 0 && frame % netplayState.hashInterval === 0) {
|
||||
netplayState.hashHistory.set(frame, getSimHash());
|
||||
}
|
||||
trimNetplayHistory(frame);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function tryApplyPendingSnapshot(stageId: number) {
|
||||
if (!pendingSnapshot) {
|
||||
return;
|
||||
}
|
||||
if (pendingSnapshot.stageId !== undefined && pendingSnapshot.stageId !== stageId) {
|
||||
return;
|
||||
}
|
||||
game.loadRollbackState(pendingSnapshot.state);
|
||||
resetNetplaySession();
|
||||
if (netplayState) {
|
||||
netplayState.lastReceivedHostFrame = pendingSnapshot.frame;
|
||||
netplayState.awaitingSnapshot = false;
|
||||
}
|
||||
pendingSnapshot = null;
|
||||
}
|
||||
|
||||
const cameraEye = vec3.create();
|
||||
|
||||
function applyGameCamera(alpha = 1) {
|
||||
@@ -678,6 +945,7 @@ async function handleStageLoaded(stageId: number) {
|
||||
lastTime = performance.now();
|
||||
updateMobileMenuButtonVisibility();
|
||||
maybeStartSmb2LikeStageFade();
|
||||
tryApplyPendingSnapshot(stageId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -704,6 +972,7 @@ async function handleStageLoaded(stageId: number) {
|
||||
lastTime = performance.now();
|
||||
updateMobileMenuButtonVisibility();
|
||||
maybeStartSmb2LikeStageFade();
|
||||
tryApplyPendingSnapshot(stageId);
|
||||
}
|
||||
|
||||
function setSelectOptions(select: HTMLSelectElement, values: { value: string; label: string }[]) {
|
||||
@@ -880,6 +1149,310 @@ async function startStage(
|
||||
activeGameSource !== GAME_SOURCES.SMB1 && hasSmb2LikeMode(difficulty) ? difficulty.mode : null;
|
||||
void audio.resume();
|
||||
await game.start(difficulty);
|
||||
if (netplayEnabled && netplayState) {
|
||||
netplayState.inputHistory.clear();
|
||||
netplayState.lastInputs.clear();
|
||||
netplayState.pendingLocalInputs.clear();
|
||||
netplayState.hashHistory.clear();
|
||||
netplayState.expectedHashes.clear();
|
||||
netplayState.lastAckedLocalFrame = -1;
|
||||
netplayState.lastReceivedHostFrame = game.simTick;
|
||||
resetNetplaySession();
|
||||
}
|
||||
}
|
||||
|
||||
function requestSnapshot(reason: 'mismatch' | 'lag') {
|
||||
if (!clientPeer || !netplayState || netplayState.awaitingSnapshot) {
|
||||
return;
|
||||
}
|
||||
netplayState.awaitingSnapshot = true;
|
||||
clientPeer.send({
|
||||
type: 'snapshot_request',
|
||||
frame: netplayState.session.getFrame(),
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
function handleHostMessage(msg: HostToClientMessage) {
|
||||
const state = netplayState;
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'frame') {
|
||||
const frameMsg = msg as FrameBundleMessage;
|
||||
if (frameMsg.lastAck !== undefined) {
|
||||
state.lastAckedLocalFrame = Math.max(state.lastAckedLocalFrame, frameMsg.lastAck);
|
||||
for (const frame of Array.from(state.pendingLocalInputs.keys())) {
|
||||
if (frame <= state.lastAckedLocalFrame) {
|
||||
state.pendingLocalInputs.delete(frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
state.lastReceivedHostFrame = Math.max(state.lastReceivedHostFrame, frameMsg.frame);
|
||||
let changed = false;
|
||||
for (const [id, input] of Object.entries(frameMsg.inputs)) {
|
||||
const playerId = Number(id);
|
||||
if (recordInputForFrame(frameMsg.frame, playerId, input)) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (frameMsg.hash !== undefined) {
|
||||
state.expectedHashes.set(frameMsg.frame, frameMsg.hash);
|
||||
const localHash = state.hashHistory.get(frameMsg.frame);
|
||||
if (localHash !== undefined && localHash !== frameMsg.hash) {
|
||||
requestSnapshot('mismatch');
|
||||
}
|
||||
}
|
||||
const currentFrame = state.session.getFrame();
|
||||
if (changed && frameMsg.frame <= currentFrame) {
|
||||
if (!rollbackAndResim(frameMsg.frame)) {
|
||||
requestSnapshot('lag');
|
||||
}
|
||||
}
|
||||
if (state.lastReceivedHostFrame - currentFrame > state.maxRollback) {
|
||||
requestSnapshot('lag');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'snapshot') {
|
||||
pendingSnapshot = msg;
|
||||
if (netplayState) {
|
||||
netplayState.inputHistory.clear();
|
||||
netplayState.lastInputs.clear();
|
||||
netplayState.pendingLocalInputs.clear();
|
||||
netplayState.hashHistory.clear();
|
||||
netplayState.expectedHashes.clear();
|
||||
netplayState.lastAckedLocalFrame = msg.frame;
|
||||
netplayState.lastReceivedHostFrame = msg.frame;
|
||||
}
|
||||
if (!msg.stageId || game.stage?.stageId === msg.stageId) {
|
||||
tryApplyPendingSnapshot(game.stage?.stageId ?? 0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'player_join') {
|
||||
game.addPlayer(msg.playerId, { spectator: msg.spectator });
|
||||
const player = game.players.find((p) => p.id === msg.playerId);
|
||||
if (player && msg.pendingSpawn) {
|
||||
player.pendingSpawn = true;
|
||||
}
|
||||
updateLobbyUi();
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'player_leave') {
|
||||
game.removePlayer(msg.playerId);
|
||||
updateLobbyUi();
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'room_update') {
|
||||
game.maxPlayers = msg.room.settings.maxPlayers;
|
||||
game.playerCollisionEnabled = msg.room.settings.collisionEnabled;
|
||||
lobbyRoom = msg.room;
|
||||
updateLobbyUi();
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'start') {
|
||||
activeGameSource = msg.gameSource;
|
||||
game.setGameSource(activeGameSource);
|
||||
game.stageBasePath = msg.stageBasePath ?? getStageBasePath(activeGameSource);
|
||||
currentSmb2LikeMode = activeGameSource !== GAME_SOURCES.SMB1 && msg.course?.mode ? msg.course.mode : null;
|
||||
if (netplayState) {
|
||||
netplayState.currentCourse = msg.course;
|
||||
netplayState.currentGameSource = msg.gameSource;
|
||||
}
|
||||
void startStage(msg.course);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClientMessage(playerId: number, msg: ClientToHostMessage) {
|
||||
const state = netplayState;
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
const clientState = state.clientStates.get(playerId);
|
||||
if (!clientState) {
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'input') {
|
||||
if (msg.lastAck !== undefined) {
|
||||
clientState.lastAckedHostFrame = Math.max(clientState.lastAckedHostFrame, msg.lastAck);
|
||||
}
|
||||
clientState.lastAckedClientInput = Math.max(clientState.lastAckedClientInput, msg.frame);
|
||||
const changed = recordInputForFrame(msg.frame, playerId, msg.input);
|
||||
const currentFrame = state.session.getFrame();
|
||||
if (changed && msg.frame <= currentFrame) {
|
||||
if (!rollbackAndResim(msg.frame)) {
|
||||
sendSnapshotToClient(playerId);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'ack') {
|
||||
clientState.lastAckedHostFrame = Math.max(clientState.lastAckedHostFrame, msg.frame);
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'snapshot_request') {
|
||||
sendSnapshotToClient(playerId);
|
||||
}
|
||||
}
|
||||
|
||||
function sendSnapshotToClient(playerId: number) {
|
||||
if (!hostRelay || !netplayState) {
|
||||
return;
|
||||
}
|
||||
const state = game.saveRollbackState();
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
hostRelay.sendTo(playerId, {
|
||||
type: 'snapshot',
|
||||
frame: netplayState.session.getFrame(),
|
||||
state,
|
||||
stageId: game.stage?.stageId,
|
||||
gameSource: game.gameSource,
|
||||
});
|
||||
}
|
||||
|
||||
function hostResendFrames(currentFrame: number) {
|
||||
if (!hostRelay || !netplayState) {
|
||||
return;
|
||||
}
|
||||
for (const [playerId, clientState] of netplayState.clientStates.entries()) {
|
||||
const start = Math.max(clientState.lastAckedHostFrame + 1, currentFrame - netplayState.maxResend + 1);
|
||||
for (let frame = start; frame <= currentFrame; frame += 1) {
|
||||
const bundle = netplayState.hostFrameBuffer.get(frame);
|
||||
if (!bundle) {
|
||||
continue;
|
||||
}
|
||||
hostRelay.sendTo(playerId, {
|
||||
...bundle,
|
||||
lastAck: clientState.lastAckedClientInput,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clientSendInputBuffer(currentFrame: number) {
|
||||
if (!clientPeer || !netplayState) {
|
||||
return;
|
||||
}
|
||||
const start = netplayState.lastAckedLocalFrame + 1;
|
||||
const end = currentFrame;
|
||||
const minFrame = Math.max(start, end - netplayState.maxResend + 1);
|
||||
for (let frame = minFrame; frame <= end; frame += 1) {
|
||||
const input = netplayState.pendingLocalInputs.get(frame);
|
||||
if (!input) {
|
||||
continue;
|
||||
}
|
||||
clientPeer.send({
|
||||
type: 'input',
|
||||
frame,
|
||||
playerId: game.localPlayerId,
|
||||
input,
|
||||
lastAck: netplayState.lastReceivedHostFrame,
|
||||
});
|
||||
}
|
||||
if (start > end) {
|
||||
clientPeer.send({
|
||||
type: 'ack',
|
||||
playerId: game.localPlayerId,
|
||||
frame: netplayState.lastReceivedHostFrame,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function netplayStep() {
|
||||
if (!netplayState) {
|
||||
return;
|
||||
}
|
||||
const state = netplayState;
|
||||
const session = state.session;
|
||||
const currentFrame = session.getFrame();
|
||||
let targetFrame = currentFrame;
|
||||
if (state.role === 'client') {
|
||||
targetFrame = Math.max(state.lastReceivedHostFrame, currentFrame);
|
||||
}
|
||||
const drift = targetFrame - currentFrame;
|
||||
if (state.role === 'client' && drift < -2) {
|
||||
clientSendInputBuffer(currentFrame);
|
||||
return;
|
||||
}
|
||||
const frame = session.getFrame() + 1;
|
||||
const localInput = game.sampleLocalInput();
|
||||
recordInputForFrame(frame, game.localPlayerId, localInput);
|
||||
if (state.role === 'client') {
|
||||
state.pendingLocalInputs.set(frame, localInput);
|
||||
}
|
||||
const inputs = buildInputsForFrame(frame);
|
||||
session.advanceTo(frame, inputs);
|
||||
let hash: number | undefined;
|
||||
if (state.hashInterval > 0 && frame % state.hashInterval === 0) {
|
||||
hash = getSimHash();
|
||||
state.hashHistory.set(frame, hash);
|
||||
const expected = state.expectedHashes.get(frame);
|
||||
if (expected !== undefined && expected !== hash) {
|
||||
requestSnapshot('mismatch');
|
||||
}
|
||||
}
|
||||
if (state.role === 'host') {
|
||||
const bundleInputs: Record<number, QuantizedInput> = {};
|
||||
for (const [playerId, input] of inputs.entries()) {
|
||||
bundleInputs[playerId] = input;
|
||||
}
|
||||
state.hostFrameBuffer.set(frame, {
|
||||
type: 'frame',
|
||||
frame,
|
||||
inputs: bundleInputs,
|
||||
hash,
|
||||
});
|
||||
const minFrame = frame - Math.max(state.maxRollback, state.maxResend);
|
||||
for (const key of Array.from(state.hostFrameBuffer.keys())) {
|
||||
if (key < minFrame) {
|
||||
state.hostFrameBuffer.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
trimNetplayHistory(frame);
|
||||
if (state.role === 'host') {
|
||||
hostResendFrames(session.getFrame());
|
||||
} else {
|
||||
clientSendInputBuffer(session.getFrame());
|
||||
}
|
||||
}
|
||||
|
||||
function netplayTick(dtSeconds: number) {
|
||||
if (!netplayState) {
|
||||
return;
|
||||
}
|
||||
if (!game.stageRuntime || game.loadingStage) {
|
||||
game.update(0);
|
||||
return;
|
||||
}
|
||||
netplayAccumulator = Math.min(netplayAccumulator + dtSeconds, game.fixedStep * NETPLAY_MAX_FRAME_DELTA);
|
||||
const state = netplayState;
|
||||
const session = state.session;
|
||||
const currentFrame = session.getFrame();
|
||||
let targetFrame = currentFrame;
|
||||
if (state.role === 'client') {
|
||||
targetFrame = Math.max(state.lastReceivedHostFrame, currentFrame);
|
||||
}
|
||||
const drift = targetFrame - currentFrame;
|
||||
if (state.role === 'client' && drift < -2) {
|
||||
clientSendInputBuffer(currentFrame);
|
||||
return;
|
||||
}
|
||||
let ticks = Math.floor(netplayAccumulator / game.fixedStep);
|
||||
if (ticks <= 0 && drift > 2) {
|
||||
ticks = 1;
|
||||
}
|
||||
if (drift > 4) {
|
||||
ticks = Math.min(3, Math.max(1, ticks + 1));
|
||||
}
|
||||
for (let i = 0; i < ticks; i += 1) {
|
||||
netplayStep();
|
||||
netplayAccumulator -= game.fixedStep;
|
||||
}
|
||||
game.accumulator = Math.max(0, Math.min(game.fixedStep, netplayAccumulator));
|
||||
}
|
||||
|
||||
function setReplayStatus(text: string) {
|
||||
@@ -906,11 +1479,12 @@ async function startReplay(replay: ReplayData) {
|
||||
game.course = null;
|
||||
void audio.resume();
|
||||
await game.loadStage(replay.stageId);
|
||||
if (game.ball && game.stage) {
|
||||
const localPlayer = game.getLocalPlayer?.() ?? null;
|
||||
if (localPlayer?.ball && game.stage) {
|
||||
const startTick = Math.max(0, replay.inputStartTick ?? 0);
|
||||
game.introTotalFrames = startTick;
|
||||
game.introTimerFrames = startTick;
|
||||
game.cameraController?.initForStage(game.ball, game.ball.startRotY, game.stageRuntime);
|
||||
game.cameraController?.initForStage(localPlayer.ball, localPlayer.ball.startRotY, game.stageRuntime);
|
||||
}
|
||||
game.replayInputStartTick = Math.max(0, replay.inputStartTick ?? 0);
|
||||
game.setInputFeed(replay.inputs);
|
||||
@@ -937,6 +1511,208 @@ function downloadReplay(replay: ReplayData) {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
async function refreshLobbyList() {
|
||||
if (!lobbyClient || !lobbyList || !lobbyStatus) {
|
||||
return;
|
||||
}
|
||||
lobbyStatus.textContent = 'Lobby: loading...';
|
||||
try {
|
||||
const rooms = await lobbyClient.listRooms();
|
||||
lobbyList.innerHTML = '';
|
||||
for (const room of rooms) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'lobby-item';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = `${room.courseId} • host ${room.hostId}`;
|
||||
const join = document.createElement('button');
|
||||
join.className = 'ghost compact';
|
||||
join.type = 'button';
|
||||
join.textContent = 'Join';
|
||||
join.addEventListener('click', async () => {
|
||||
await joinRoom(room.roomId);
|
||||
});
|
||||
item.append(label, join);
|
||||
lobbyList.appendChild(item);
|
||||
}
|
||||
lobbyStatus.textContent = `Lobby: ${rooms.length} room(s)`;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
lobbyStatus.textContent = 'Lobby: failed';
|
||||
}
|
||||
}
|
||||
|
||||
async function createRoom() {
|
||||
if (!lobbyClient || !lobbyStatus) {
|
||||
return;
|
||||
}
|
||||
const isPublic = lobbyPublicCheckbox?.checked ?? true;
|
||||
const hostId = game.localPlayerId ?? 0;
|
||||
const room = await lobbyClient.createRoom({
|
||||
isPublic,
|
||||
hostId,
|
||||
courseId: 'smb1-main',
|
||||
settings: { maxPlayers: 8, collisionEnabled: true },
|
||||
});
|
||||
lobbyRoom = room;
|
||||
lobbyStatus.textContent = `Lobby: hosting ${room.roomCode ?? room.roomId}`;
|
||||
startHost(room);
|
||||
}
|
||||
|
||||
async function joinRoom(roomId: string) {
|
||||
if (!lobbyClient || !lobbyStatus) {
|
||||
return;
|
||||
}
|
||||
const room = await lobbyClient.joinRoom({ roomId });
|
||||
lobbyRoom = room;
|
||||
lobbyStatus.textContent = `Lobby: joining ${room.roomCode ?? room.roomId}`;
|
||||
startClient(room);
|
||||
}
|
||||
|
||||
async function joinRoomByCode() {
|
||||
if (!lobbyClient || !lobbyStatus) {
|
||||
return;
|
||||
}
|
||||
const code = lobbyCodeInput?.value?.trim();
|
||||
if (!code) {
|
||||
lobbyStatus.textContent = 'Lobby: enter a room code';
|
||||
return;
|
||||
}
|
||||
const room = await lobbyClient.joinRoom({ roomCode: code });
|
||||
lobbyRoom = room;
|
||||
lobbyStatus.textContent = `Lobby: joining ${room.roomCode ?? room.roomId}`;
|
||||
startClient(room);
|
||||
}
|
||||
|
||||
async function leaveRoom() {
|
||||
if (!lobbyClient) {
|
||||
resetNetplayConnections();
|
||||
return;
|
||||
}
|
||||
const roomId = lobbyRoom?.roomId;
|
||||
const wasHost = netplayState?.role === 'host';
|
||||
resetNetplayConnections();
|
||||
if (roomId && wasHost) {
|
||||
try {
|
||||
await lobbyClient.closeRoom(roomId);
|
||||
} catch {
|
||||
// Ignore.
|
||||
}
|
||||
}
|
||||
if (lobbyStatus) {
|
||||
lobbyStatus.textContent = 'Lobby: idle';
|
||||
}
|
||||
}
|
||||
|
||||
function startHost(room: LobbyRoom) {
|
||||
if (!lobbyClient) {
|
||||
return;
|
||||
}
|
||||
netplayEnabled = true;
|
||||
ensureNetplayState('host');
|
||||
game.setLocalPlayerId(room.hostId);
|
||||
game.maxPlayers = room.settings.maxPlayers;
|
||||
game.playerCollisionEnabled = room.settings.collisionEnabled;
|
||||
hostRelay = new HostRelay((playerId, msg) => {
|
||||
handleClientMessage(playerId, msg);
|
||||
});
|
||||
hostRelay.hostId = room.hostId;
|
||||
hostRelay.onConnect = (playerId) => {
|
||||
const state = netplayState;
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
if (!state.clientStates.has(playerId)) {
|
||||
state.clientStates.set(playerId, { lastAckedHostFrame: -1, lastAckedClientInput: -1 });
|
||||
}
|
||||
game.addPlayer(playerId, { spectator: false });
|
||||
const player = game.players.find((p) => p.id === playerId);
|
||||
const pendingSpawn = !!player?.pendingSpawn;
|
||||
for (const existing of game.players) {
|
||||
hostRelay?.sendTo(playerId, {
|
||||
type: 'player_join',
|
||||
playerId: existing.id,
|
||||
spectator: existing.isSpectator,
|
||||
pendingSpawn: existing.pendingSpawn,
|
||||
});
|
||||
}
|
||||
hostRelay?.broadcast({ type: 'player_join', playerId, spectator: false, pendingSpawn });
|
||||
hostRelay?.sendTo(playerId, { type: 'room_update', room });
|
||||
if (state.currentCourse && state.currentGameSource) {
|
||||
hostRelay?.sendTo(playerId, {
|
||||
type: 'start',
|
||||
gameSource: state.currentGameSource,
|
||||
course: state.currentCourse,
|
||||
stageBasePath: getStageBasePath(state.currentGameSource),
|
||||
});
|
||||
}
|
||||
sendSnapshotToClient(playerId);
|
||||
updateLobbyUi();
|
||||
};
|
||||
hostRelay.onDisconnect = (playerId) => {
|
||||
game.removePlayer(playerId);
|
||||
netplayState?.clientStates.delete(playerId);
|
||||
hostRelay?.broadcast({ type: 'player_leave', playerId });
|
||||
updateLobbyUi();
|
||||
};
|
||||
lobbySignal?.close();
|
||||
lobbySignal = lobbyClient.openSignal(room.roomId, room.hostId, async (msg) => {
|
||||
if (msg.to !== room.hostId) {
|
||||
return;
|
||||
}
|
||||
if (msg.payload?.join) {
|
||||
const offer = await createHostOffer(hostRelay!, msg.from);
|
||||
hostRelay?.onSignal?.({ type: 'signal', from: room.hostId, to: msg.from, payload: { sdp: offer } });
|
||||
return;
|
||||
}
|
||||
await applyHostSignal(hostRelay!, msg.from, msg.payload);
|
||||
}, () => {
|
||||
lobbyStatus!.textContent = 'Lobby: disconnected';
|
||||
resetNetplayConnections();
|
||||
});
|
||||
hostRelay.onSignal = (signal) => lobbySignal?.send(signal);
|
||||
startLobbyHeartbeat(room.roomId);
|
||||
lobbyStatus!.textContent = `Lobby: hosting ${room.roomCode ?? room.roomId}`;
|
||||
updateLobbyUi();
|
||||
}
|
||||
|
||||
async function startClient(room: LobbyRoom) {
|
||||
if (!lobbyClient) {
|
||||
return;
|
||||
}
|
||||
netplayEnabled = true;
|
||||
ensureNetplayState('client');
|
||||
const playerId = createNetplayId();
|
||||
game.setLocalPlayerId(playerId);
|
||||
game.maxPlayers = room.settings.maxPlayers;
|
||||
game.playerCollisionEnabled = room.settings.collisionEnabled;
|
||||
game.addPlayer(room.hostId, { spectator: false });
|
||||
clientPeer = new ClientPeer((msg) => {
|
||||
handleHostMessage(msg);
|
||||
});
|
||||
clientPeer.playerId = playerId;
|
||||
clientPeer.hostId = room.hostId;
|
||||
await clientPeer.createConnection();
|
||||
lobbySignal?.close();
|
||||
lobbySignal = lobbyClient.openSignal(room.roomId, playerId, async (msg) => {
|
||||
if (msg.to !== playerId) {
|
||||
return;
|
||||
}
|
||||
await clientPeer?.handleSignal(msg.payload);
|
||||
}, () => {
|
||||
lobbyStatus!.textContent = 'Lobby: disconnected';
|
||||
resetNetplayConnections();
|
||||
});
|
||||
clientPeer.onSignal = (signal) => lobbySignal?.send(signal);
|
||||
clientPeer.onDisconnect = () => {
|
||||
lobbyStatus!.textContent = 'Lobby: disconnected';
|
||||
resetNetplayConnections();
|
||||
};
|
||||
lobbySignal.send({ type: 'signal', from: playerId, to: room.hostId, payload: { join: true } });
|
||||
startLobbyHeartbeat(room.roomId);
|
||||
lobbyStatus!.textContent = `Lobby: connected ${room.roomCode ?? room.roomId}`;
|
||||
updateLobbyUi();
|
||||
}
|
||||
|
||||
function bindVolumeControl(
|
||||
input: HTMLInputElement | null,
|
||||
output: HTMLOutputElement | null,
|
||||
@@ -1244,7 +2020,11 @@ function renderFrame(now: number) {
|
||||
viewerInput.deltaTime = 0;
|
||||
}
|
||||
|
||||
game.update(dtSeconds);
|
||||
if (netplayEnabled) {
|
||||
netplayTick(dtSeconds);
|
||||
} else {
|
||||
game.update(dtSeconds);
|
||||
}
|
||||
|
||||
const shouldRender = interpolationEnabled || (now - lastRenderTime) >= RENDER_FRAME_MS;
|
||||
if (!shouldRender) {
|
||||
@@ -1292,6 +2072,7 @@ function renderFrame(now: number) {
|
||||
syncState.bananaCollectedByAnimGroup = null;
|
||||
syncState.animGroupTransforms = game.getAnimGroupTransforms(interpolationAlpha);
|
||||
syncState.ball = game.getBallRenderState(interpolationAlpha);
|
||||
syncState.balls = game.getBallRenderStates(interpolationAlpha);
|
||||
syncState.goalBags = game.getGoalBagRenderState(interpolationAlpha);
|
||||
syncState.goalTapes = game.getGoalTapeRenderState(interpolationAlpha);
|
||||
syncState.confetti = game.getConfettiRenderState(interpolationAlpha);
|
||||
@@ -1583,6 +2364,12 @@ if (interpolationToggle) {
|
||||
}
|
||||
|
||||
startButton.addEventListener('click', () => {
|
||||
if (netplayEnabled && netplayState?.role === 'client') {
|
||||
if (hudStatus) {
|
||||
hudStatus.textContent = 'Waiting for host to start...';
|
||||
}
|
||||
return;
|
||||
}
|
||||
const resolved = resolveSelectedGameSource();
|
||||
activeGameSource = resolved.gameSource;
|
||||
const difficulty = activeGameSource === GAME_SOURCES.SMB2
|
||||
@@ -1590,6 +2377,16 @@ startButton.addEventListener('click', () => {
|
||||
: activeGameSource === GAME_SOURCES.MB2WS
|
||||
? buildMb2wsCourseConfig()
|
||||
: buildSmb1CourseConfig();
|
||||
if (netplayEnabled && netplayState?.role === 'host') {
|
||||
netplayState.currentCourse = difficulty;
|
||||
netplayState.currentGameSource = activeGameSource;
|
||||
hostRelay?.broadcast({
|
||||
type: 'start',
|
||||
gameSource: activeGameSource,
|
||||
course: difficulty,
|
||||
stageBasePath: getStageBasePath(activeGameSource),
|
||||
});
|
||||
}
|
||||
startStage(difficulty).catch((error) => {
|
||||
if (hudStatus) {
|
||||
hudStatus.textContent = 'Failed to load stage.';
|
||||
@@ -1620,4 +2417,28 @@ window.addEventListener('keydown', (event) => {
|
||||
}
|
||||
});
|
||||
|
||||
if (lobbyRefreshButton) {
|
||||
lobbyRefreshButton.addEventListener('click', () => {
|
||||
void refreshLobbyList();
|
||||
});
|
||||
}
|
||||
if (lobbyCreateButton) {
|
||||
lobbyCreateButton.addEventListener('click', () => {
|
||||
void createRoom();
|
||||
});
|
||||
}
|
||||
if (lobbyJoinButton) {
|
||||
lobbyJoinButton.addEventListener('click', () => {
|
||||
void joinRoomByCode();
|
||||
});
|
||||
}
|
||||
if (lobbyLeaveButton) {
|
||||
lobbyLeaveButton.addEventListener('click', () => {
|
||||
void leaveRoom();
|
||||
});
|
||||
}
|
||||
if (lobbyClient) {
|
||||
void refreshLobbyList();
|
||||
}
|
||||
|
||||
requestAnimationFrame(renderFrame);
|
||||
|
||||
95
src/netcode_protocol.ts
Normal file
95
src/netcode_protocol.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { QuantizedInput } from './determinism.js';
|
||||
import type { GameSource } from './constants.js';
|
||||
|
||||
export type PlayerId = number;
|
||||
|
||||
export type RoomSettings = {
|
||||
maxPlayers: number;
|
||||
collisionEnabled: boolean;
|
||||
};
|
||||
|
||||
export type RoomInfo = {
|
||||
roomId: string;
|
||||
roomCode?: string;
|
||||
isPublic: boolean;
|
||||
hostId: PlayerId;
|
||||
courseId: string;
|
||||
settings: RoomSettings;
|
||||
};
|
||||
|
||||
export type InputFrameMessage = {
|
||||
type: 'input';
|
||||
frame: number;
|
||||
playerId: PlayerId;
|
||||
input: QuantizedInput;
|
||||
lastAck?: number;
|
||||
};
|
||||
|
||||
export type InputAckMessage = {
|
||||
type: 'ack';
|
||||
playerId: PlayerId;
|
||||
frame: number;
|
||||
};
|
||||
|
||||
export type FrameBundleMessage = {
|
||||
type: 'frame';
|
||||
frame: number;
|
||||
inputs: Record<number, QuantizedInput>;
|
||||
lastAck?: number;
|
||||
hash?: number;
|
||||
};
|
||||
|
||||
export type SnapshotMessage = {
|
||||
type: 'snapshot';
|
||||
frame: number;
|
||||
state: any;
|
||||
stageId?: number;
|
||||
gameSource?: GameSource;
|
||||
};
|
||||
|
||||
export type SnapshotRequestMessage = {
|
||||
type: 'snapshot_request';
|
||||
frame: number;
|
||||
reason: 'mismatch' | 'lag';
|
||||
};
|
||||
|
||||
export type PlayerJoinMessage = {
|
||||
type: 'player_join';
|
||||
playerId: PlayerId;
|
||||
spectator: boolean;
|
||||
pendingSpawn?: boolean;
|
||||
};
|
||||
|
||||
export type PlayerLeaveMessage = {
|
||||
type: 'player_leave';
|
||||
playerId: PlayerId;
|
||||
};
|
||||
|
||||
export type RoomUpdateMessage = {
|
||||
type: 'room_update';
|
||||
room: RoomInfo;
|
||||
};
|
||||
|
||||
export type StartMatchMessage = {
|
||||
type: 'start';
|
||||
gameSource: GameSource;
|
||||
course: any;
|
||||
stageBasePath?: string;
|
||||
};
|
||||
|
||||
export type HostToClientMessage =
|
||||
| InputFrameMessage
|
||||
| InputAckMessage
|
||||
| FrameBundleMessage
|
||||
| SnapshotMessage
|
||||
| StartMatchMessage
|
||||
| PlayerJoinMessage
|
||||
| PlayerLeaveMessage
|
||||
| RoomUpdateMessage;
|
||||
|
||||
export type ClientToHostMessage =
|
||||
| InputFrameMessage
|
||||
| InputAckMessage
|
||||
| SnapshotRequestMessage
|
||||
| PlayerJoinMessage
|
||||
| PlayerLeaveMessage;
|
||||
261
src/netplay.ts
Normal file
261
src/netplay.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import type {
|
||||
ClientToHostMessage,
|
||||
HostToClientMessage,
|
||||
RoomInfo,
|
||||
} from './netcode_protocol.js';
|
||||
|
||||
export type SignalMessage = {
|
||||
type: 'signal';
|
||||
from: number;
|
||||
to: number | null;
|
||||
payload: any;
|
||||
};
|
||||
|
||||
const DEFAULT_STUN = [{ urls: 'stun:stun.l.google.com:19302' }];
|
||||
|
||||
export class LobbyClient {
|
||||
constructor(private baseUrl: string) {}
|
||||
|
||||
async listRooms(): Promise<RoomInfo[]> {
|
||||
const res = await fetch(`${this.baseUrl}/rooms`);
|
||||
const data = await res.json();
|
||||
return data.rooms ?? [];
|
||||
}
|
||||
|
||||
async createRoom(room: Partial<RoomInfo>): Promise<RoomInfo> {
|
||||
const res = await fetch(`${this.baseUrl}/rooms`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(room),
|
||||
});
|
||||
const data = await res.json();
|
||||
return data.room;
|
||||
}
|
||||
|
||||
async joinRoom(roomIdOrCode: { roomId?: string; roomCode?: string }): Promise<RoomInfo> {
|
||||
const res = await fetch(`${this.baseUrl}/rooms/join`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(roomIdOrCode),
|
||||
});
|
||||
const data = await res.json();
|
||||
return data.room;
|
||||
}
|
||||
|
||||
async heartbeat(roomId: string): Promise<void> {
|
||||
await fetch(`${this.baseUrl}/rooms/heartbeat`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ roomId }),
|
||||
});
|
||||
}
|
||||
|
||||
async closeRoom(roomId: string): Promise<void> {
|
||||
await fetch(`${this.baseUrl}/rooms/close`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ roomId }),
|
||||
});
|
||||
}
|
||||
|
||||
openSignal(roomId: string, playerId: number, onMessage: (msg: SignalMessage) => void, onClose: () => void) {
|
||||
const ws = new WebSocket(`${this.baseUrl.replace('http', 'ws')}/room/${roomId}?playerId=${playerId}`);
|
||||
ws.addEventListener('message', (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data) as SignalMessage;
|
||||
if (msg?.type === 'signal') {
|
||||
onMessage(msg);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed.
|
||||
}
|
||||
});
|
||||
ws.addEventListener('close', () => onClose());
|
||||
return {
|
||||
send: (msg: SignalMessage) => ws.readyState === WebSocket.OPEN && ws.send(JSON.stringify(msg)),
|
||||
close: () => ws.close(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class HostRelay {
|
||||
private peers = new Map<number, RTCPeerConnection>();
|
||||
private channels = new Map<number, RTCDataChannel>();
|
||||
|
||||
constructor(private onMessage: (playerId: number, msg: ClientToHostMessage) => void) {}
|
||||
|
||||
createPeer(playerId: number): RTCPeerConnection {
|
||||
const pc = new RTCPeerConnection({ iceServers: DEFAULT_STUN });
|
||||
pc.addEventListener('icecandidate', (ev) => {
|
||||
if (ev.candidate) {
|
||||
this.onSignal?.({ type: 'signal', from: this.hostId, to: playerId, payload: { ice: ev.candidate } });
|
||||
}
|
||||
});
|
||||
pc.addEventListener('datachannel', (ev) => {
|
||||
this.attachChannel(playerId, ev.channel);
|
||||
});
|
||||
this.peers.set(playerId, pc);
|
||||
return pc;
|
||||
}
|
||||
|
||||
attachChannel(playerId: number, channel: RTCDataChannel) {
|
||||
this.channels.set(playerId, channel);
|
||||
channel.binaryType = 'arraybuffer';
|
||||
channel.addEventListener('open', () => {
|
||||
this.onConnect?.(playerId);
|
||||
});
|
||||
channel.addEventListener('message', (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data) as ClientToHostMessage;
|
||||
if (msg?.type) {
|
||||
this.onMessage(playerId, msg);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed.
|
||||
}
|
||||
});
|
||||
channel.addEventListener('close', () => {
|
||||
this.onDisconnect?.(playerId);
|
||||
});
|
||||
}
|
||||
|
||||
broadcast(msg: HostToClientMessage) {
|
||||
const payload = JSON.stringify(msg);
|
||||
for (const channel of this.channels.values()) {
|
||||
if (channel.readyState === 'open') {
|
||||
channel.send(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sendTo(playerId: number, msg: HostToClientMessage) {
|
||||
const channel = this.channels.get(playerId);
|
||||
if (!channel || channel.readyState !== 'open') {
|
||||
return;
|
||||
}
|
||||
channel.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
closeAll() {
|
||||
for (const channel of this.channels.values()) {
|
||||
try {
|
||||
channel.close();
|
||||
} catch {
|
||||
// Ignore.
|
||||
}
|
||||
}
|
||||
for (const peer of this.peers.values()) {
|
||||
try {
|
||||
peer.close();
|
||||
} catch {
|
||||
// Ignore.
|
||||
}
|
||||
}
|
||||
this.channels.clear();
|
||||
this.peers.clear();
|
||||
}
|
||||
|
||||
hostId = 0;
|
||||
onSignal?: (msg: SignalMessage) => void;
|
||||
onConnect?: (playerId: number) => void;
|
||||
onDisconnect?: (playerId: number) => void;
|
||||
}
|
||||
|
||||
export class ClientPeer {
|
||||
private pc: RTCPeerConnection | null = null;
|
||||
private channel: RTCDataChannel | null = null;
|
||||
|
||||
constructor(private onMessage: (msg: HostToClientMessage) => void) {}
|
||||
|
||||
private attachChannel(channel: RTCDataChannel) {
|
||||
this.channel = channel;
|
||||
channel.addEventListener('message', (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data) as HostToClientMessage;
|
||||
this.onMessage(msg);
|
||||
} catch {
|
||||
// Ignore malformed.
|
||||
}
|
||||
});
|
||||
channel.addEventListener('close', () => {
|
||||
this.onDisconnect?.();
|
||||
});
|
||||
}
|
||||
|
||||
async createConnection(): Promise<RTCPeerConnection> {
|
||||
const pc = new RTCPeerConnection({ iceServers: DEFAULT_STUN });
|
||||
pc.addEventListener('icecandidate', (ev) => {
|
||||
if (ev.candidate) {
|
||||
this.onSignal?.({ type: 'signal', from: this.playerId, to: this.hostId, payload: { ice: ev.candidate } });
|
||||
}
|
||||
});
|
||||
pc.addEventListener('datachannel', (ev) => {
|
||||
this.attachChannel(ev.channel);
|
||||
});
|
||||
this.pc = pc;
|
||||
return pc;
|
||||
}
|
||||
|
||||
send(msg: ClientToHostMessage) {
|
||||
if (this.channel?.readyState === 'open') {
|
||||
this.channel.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
try {
|
||||
this.channel?.close();
|
||||
} catch {
|
||||
// Ignore.
|
||||
}
|
||||
try {
|
||||
this.pc?.close();
|
||||
} catch {
|
||||
// Ignore.
|
||||
}
|
||||
this.channel = null;
|
||||
this.pc = null;
|
||||
}
|
||||
|
||||
async handleSignal(payload: any) {
|
||||
if (!this.pc) {
|
||||
return;
|
||||
}
|
||||
if (payload?.sdp) {
|
||||
await this.pc.setRemoteDescription(payload.sdp);
|
||||
if (payload.sdp.type === 'offer') {
|
||||
const answer = await this.pc.createAnswer();
|
||||
await this.pc.setLocalDescription(answer);
|
||||
this.onSignal?.({ type: 'signal', from: this.playerId, to: this.hostId, payload: { sdp: this.pc.localDescription } });
|
||||
}
|
||||
} else if (payload?.ice) {
|
||||
await this.pc.addIceCandidate(payload.ice);
|
||||
}
|
||||
}
|
||||
|
||||
playerId = 0;
|
||||
hostId = 0;
|
||||
onSignal?: (msg: SignalMessage) => void;
|
||||
onDisconnect?: () => void;
|
||||
}
|
||||
|
||||
export async function createHostOffer(host: HostRelay, playerId: number) {
|
||||
const pc = host.createPeer(playerId);
|
||||
const channel = pc.createDataChannel('game');
|
||||
host.attachChannel(playerId, channel);
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
return pc.localDescription;
|
||||
}
|
||||
|
||||
export async function applyHostSignal(host: HostRelay, playerId: number, payload: any) {
|
||||
const pc = host.createPeer(playerId);
|
||||
if (payload?.sdp) {
|
||||
await pc.setRemoteDescription(payload.sdp);
|
||||
if (payload.sdp.type === 'answer') {
|
||||
return;
|
||||
}
|
||||
} else if (payload?.ice) {
|
||||
await pc.addIceCandidate(payload.ice);
|
||||
}
|
||||
}
|
||||
@@ -140,6 +140,7 @@ export type GameplaySyncState = {
|
||||
jamabars?: JamabarRenderState[] | null;
|
||||
animGroupTransforms?: Float32Array[] | null;
|
||||
ball?: BallRenderState | null;
|
||||
balls?: BallRenderState[] | null;
|
||||
goalBags?: GoalBagRenderState[] | null;
|
||||
goalTapes?: GoalTapeRenderState[] | null;
|
||||
confetti?: ConfettiRenderState[] | null;
|
||||
@@ -582,7 +583,10 @@ export class Renderer {
|
||||
if (state.bananaCollectedByAnimGroup) {
|
||||
this.world.setBananaCollectedByAnimGroup(state.bananaCollectedByAnimGroup);
|
||||
}
|
||||
if (state.ball !== undefined) {
|
||||
const hasBalls = state.balls !== undefined;
|
||||
if (hasBalls) {
|
||||
this.world.setBallsState(state.balls ?? null);
|
||||
} else if (state.ball !== undefined) {
|
||||
this.world.setBallState(state.ball ?? null);
|
||||
}
|
||||
if (state.goalBags !== undefined) {
|
||||
|
||||
@@ -651,6 +651,7 @@ export class World {
|
||||
private background: Background;
|
||||
private fgObjects: BgObjectInst[] = [];
|
||||
private ball: BallInst;
|
||||
private balls: BallInst[] = [];
|
||||
private ballPos = vec3.create();
|
||||
private ballRadius = 0;
|
||||
private ballVisible = false;
|
||||
@@ -1083,6 +1084,7 @@ export class World {
|
||||
this.background = new stageData.stageInfo.bgInfo.bgConstructor(this.worldState, bgObjects);
|
||||
this.fgObjects = fgObjects;
|
||||
this.ball = new BallInst(this.worldState.modelCache, stageData);
|
||||
this.balls = [this.ball];
|
||||
this.shadowProgram = createShadowProgram(renderCache);
|
||||
this.shadowMegaState.depthWrite = false;
|
||||
this.streakProgram = createStreakProgram(renderCache);
|
||||
@@ -1195,6 +1197,51 @@ export class World {
|
||||
}
|
||||
}
|
||||
|
||||
public setBallsState(states: BallRenderState[] | null): void {
|
||||
if (!states || states.length === 0) {
|
||||
this.balls = [this.ball];
|
||||
this.ball.setState(null);
|
||||
this.hasBallPosForTilt = false;
|
||||
this.hasBallPosForTiltPrev = false;
|
||||
this.ballVisible = false;
|
||||
this.ballRadius = 0;
|
||||
return;
|
||||
}
|
||||
if (this.balls.length !== states.length) {
|
||||
this.balls = new Array(states.length);
|
||||
for (let i = 0; i < states.length; i++) {
|
||||
this.balls[i] = new BallInst(this.worldState.modelCache, this.stageData);
|
||||
}
|
||||
}
|
||||
let primary: BallRenderState | null = null;
|
||||
for (let i = 0; i < states.length; i++) {
|
||||
const state = states[i];
|
||||
this.balls[i].setState(state);
|
||||
if (!primary && state.visible) {
|
||||
primary = state;
|
||||
}
|
||||
}
|
||||
if (!primary) {
|
||||
primary = states[0];
|
||||
}
|
||||
if (primary) {
|
||||
if (this.hasBallPosForTilt) {
|
||||
vec3.copy(this.ballPosForTiltPrev, this.ballPosForTilt);
|
||||
this.hasBallPosForTiltPrev = true;
|
||||
}
|
||||
this.ballVisible = primary.visible;
|
||||
this.ballRadius = primary.radius;
|
||||
vec3.set(this.ballPos, primary.pos.x, primary.pos.y, primary.pos.z);
|
||||
vec3.set(this.ballPosForTilt, primary.pos.x, primary.pos.y, primary.pos.z);
|
||||
this.hasBallPosForTilt = true;
|
||||
} else {
|
||||
this.hasBallPosForTilt = false;
|
||||
this.hasBallPosForTiltPrev = false;
|
||||
this.ballVisible = false;
|
||||
this.ballRadius = 0;
|
||||
}
|
||||
}
|
||||
|
||||
public update(viewerInput: Viewer.ViewerRenderInput): void {
|
||||
if (this.externalTimeFrames !== null) {
|
||||
this.worldState.time.overrideTimeFrames(this.externalTimeFrames, this.externalDeltaFrames);
|
||||
@@ -1346,7 +1393,9 @@ export class World {
|
||||
this.fgObjects[i].prepareToRenderWithViewMatrix(this.worldState, stageCtx, viewFromWorldTilted);
|
||||
}
|
||||
this.background.prepareToRender(this.worldState, bgCtx);
|
||||
this.ball.prepareToRender(this.worldState, stageCtx);
|
||||
for (let i = 0; i < this.balls.length; i++) {
|
||||
this.balls[i].prepareToRender(this.worldState, stageCtx);
|
||||
}
|
||||
}
|
||||
|
||||
public getMirrorMode(): MirrorMode {
|
||||
|
||||
@@ -295,6 +295,45 @@ export function startBallDrop(ball, frames = 24) {
|
||||
ball.prevTransform.set(ball.transform);
|
||||
}
|
||||
|
||||
export function resolveBallBallCollision(ballA, ballB) {
|
||||
const dx = ballB.pos.x - ballA.pos.x;
|
||||
const dy = ballB.pos.y - ballA.pos.y;
|
||||
const dz = ballB.pos.z - ballA.pos.z;
|
||||
const minDist = (ballA.currRadius ?? 0.5) + (ballB.currRadius ?? 0.5);
|
||||
const distSq = dx * dx + dy * dy + dz * dz;
|
||||
if (distSq <= FLT_EPSILON || distSq >= minDist * minDist) {
|
||||
return;
|
||||
}
|
||||
const dist = sqrt(distSq);
|
||||
const nx = dx / dist;
|
||||
const ny = dy / dist;
|
||||
const nz = dz / dist;
|
||||
const overlap = minDist - dist;
|
||||
const correction = overlap * 0.5;
|
||||
ballA.pos.x -= nx * correction;
|
||||
ballA.pos.y -= ny * correction;
|
||||
ballA.pos.z -= nz * correction;
|
||||
ballB.pos.x += nx * correction;
|
||||
ballB.pos.y += ny * correction;
|
||||
ballB.pos.z += nz * correction;
|
||||
|
||||
const rvx = ballB.vel.x - ballA.vel.x;
|
||||
const rvy = ballB.vel.y - ballA.vel.y;
|
||||
const rvz = ballB.vel.z - ballA.vel.z;
|
||||
const relVel = rvx * nx + rvy * ny + rvz * nz;
|
||||
if (relVel >= 0) {
|
||||
return;
|
||||
}
|
||||
const restitution = (ballA.restitution + ballB.restitution) * 0.5;
|
||||
const impulse = -((1 + restitution) * relVel) * 0.5;
|
||||
ballA.vel.x -= impulse * nx;
|
||||
ballA.vel.y -= impulse * ny;
|
||||
ballA.vel.z -= impulse * nz;
|
||||
ballB.vel.x += impulse * nx;
|
||||
ballB.vel.y += impulse * ny;
|
||||
ballB.vel.z += impulse * nz;
|
||||
}
|
||||
|
||||
function updateBallCameraSteerYaw(ball) {
|
||||
const speed = sqrt(sumSq2(ball.vel.x, ball.vel.z));
|
||||
let velYaw = ball.apeYaw;
|
||||
|
||||
74
src/rollback.ts
Normal file
74
src/rollback.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { QuantizedInput } from './determinism.js';
|
||||
|
||||
export type FrameInputs = Map<number, QuantizedInput>;
|
||||
|
||||
export type RollbackCallbacks<T> = {
|
||||
saveState: () => T;
|
||||
loadState: (state: T) => void;
|
||||
advanceFrame: (inputs: FrameInputs) => void;
|
||||
};
|
||||
|
||||
export class RollbackSession<T> {
|
||||
private callbacks: RollbackCallbacks<T>;
|
||||
private maxRollbackFrames: number;
|
||||
private stateHistory = new Map<number, T>();
|
||||
private inputHistory = new Map<number, FrameInputs>();
|
||||
private lastFrame = 0;
|
||||
|
||||
constructor(callbacks: RollbackCallbacks<T>, maxRollbackFrames = 30) {
|
||||
this.callbacks = callbacks;
|
||||
this.maxRollbackFrames = Math.max(1, maxRollbackFrames | 0);
|
||||
}
|
||||
|
||||
getFrame() {
|
||||
return this.lastFrame;
|
||||
}
|
||||
|
||||
prime(frame: number) {
|
||||
const target = frame | 0;
|
||||
this.lastFrame = target;
|
||||
this.stateHistory.set(target, this.callbacks.saveState());
|
||||
this.inputHistory.set(target, new Map());
|
||||
this.trimHistory(target);
|
||||
}
|
||||
|
||||
pushLocalFrame(frame: number, inputs: FrameInputs) {
|
||||
this.inputHistory.set(frame, inputs);
|
||||
}
|
||||
|
||||
advanceTo(frame: number, inputs: FrameInputs) {
|
||||
this.inputHistory.set(frame, inputs);
|
||||
this.callbacks.advanceFrame(inputs);
|
||||
this.lastFrame = frame;
|
||||
this.stateHistory.set(frame, this.callbacks.saveState());
|
||||
this.trimHistory(frame);
|
||||
}
|
||||
|
||||
rollbackTo(frame: number) {
|
||||
const state = this.stateHistory.get(frame);
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
this.callbacks.loadState(state);
|
||||
this.lastFrame = frame;
|
||||
return true;
|
||||
}
|
||||
|
||||
getInputs(frame: number) {
|
||||
return this.inputHistory.get(frame) ?? null;
|
||||
}
|
||||
|
||||
private trimHistory(frame: number) {
|
||||
const minFrame = frame - this.maxRollbackFrames;
|
||||
for (const key of this.stateHistory.keys()) {
|
||||
if (key < minFrame) {
|
||||
this.stateHistory.delete(key);
|
||||
}
|
||||
}
|
||||
for (const key of this.inputHistory.keys()) {
|
||||
if (key < minFrame) {
|
||||
this.inputHistory.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,34 +23,48 @@ function hashS16(hash, value) {
|
||||
return hashU32(hash, value & 0xffff);
|
||||
}
|
||||
|
||||
export function hashSimState(ball, world, stageRuntime, { includeVisual = false } = {}) {
|
||||
export function hashSimState(ballOrBalls, worldOrWorlds, stageRuntime, { includeVisual = false } = {}) {
|
||||
let h = 0x811c9dc5;
|
||||
if (!ball || !world || !stageRuntime) {
|
||||
if (!ballOrBalls || !worldOrWorlds || !stageRuntime) {
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
h = hashVec3(h, ball.pos);
|
||||
h = hashVec3(h, ball.vel);
|
||||
h = hashS16(h, ball.rotX);
|
||||
h = hashS16(h, ball.rotY);
|
||||
h = hashS16(h, ball.rotZ);
|
||||
h = hashU32(h, ball.state | 0);
|
||||
h = hashU32(h, ball.flags | 0);
|
||||
h = hashF32(h, ball.currRadius ?? 0);
|
||||
h = hashF32(h, ball.speed ?? 0);
|
||||
h = hashU32(h, ball.animGroupId | 0);
|
||||
h = hashS16(h, ball.apeYaw ?? 0);
|
||||
if (ball.orientation) {
|
||||
h = hashF32(h, ball.orientation.x);
|
||||
h = hashF32(h, ball.orientation.y);
|
||||
h = hashF32(h, ball.orientation.z);
|
||||
h = hashF32(h, ball.orientation.w);
|
||||
const balls = Array.isArray(ballOrBalls) ? ballOrBalls : [ballOrBalls];
|
||||
h = hashU32(h, balls.length | 0);
|
||||
for (const ball of balls) {
|
||||
if (!ball) {
|
||||
continue;
|
||||
}
|
||||
h = hashVec3(h, ball.pos);
|
||||
h = hashVec3(h, ball.vel);
|
||||
h = hashS16(h, ball.rotX);
|
||||
h = hashS16(h, ball.rotY);
|
||||
h = hashS16(h, ball.rotZ);
|
||||
h = hashU32(h, ball.state | 0);
|
||||
h = hashU32(h, ball.flags | 0);
|
||||
h = hashF32(h, ball.currRadius ?? 0);
|
||||
h = hashF32(h, ball.speed ?? 0);
|
||||
h = hashU32(h, ball.animGroupId | 0);
|
||||
h = hashS16(h, ball.apeYaw ?? 0);
|
||||
if (ball.orientation) {
|
||||
h = hashF32(h, ball.orientation.x);
|
||||
h = hashF32(h, ball.orientation.y);
|
||||
h = hashF32(h, ball.orientation.z);
|
||||
h = hashF32(h, ball.orientation.w);
|
||||
}
|
||||
}
|
||||
|
||||
h = hashS16(h, world.xrot ?? 0);
|
||||
h = hashS16(h, world.zrot ?? 0);
|
||||
if (world.gravity) {
|
||||
h = hashVec3(h, world.gravity);
|
||||
const worlds = Array.isArray(worldOrWorlds) ? worldOrWorlds : [worldOrWorlds];
|
||||
h = hashU32(h, worlds.length | 0);
|
||||
for (const world of worlds) {
|
||||
if (!world) {
|
||||
continue;
|
||||
}
|
||||
h = hashS16(h, world.xrot ?? 0);
|
||||
h = hashS16(h, world.zrot ?? 0);
|
||||
if (world.gravity) {
|
||||
h = hashVec3(h, world.gravity);
|
||||
}
|
||||
}
|
||||
|
||||
h = hashU32(h, stageRuntime.timerFrames | 0);
|
||||
@@ -76,6 +90,10 @@ export function hashSimState(ball, world, stageRuntime, { includeVisual = false
|
||||
if (stageRuntime.bananas) {
|
||||
h = hashU32(h, stageRuntime.bananas.length | 0);
|
||||
for (const banana of stageRuntime.bananas) {
|
||||
if (banana.collected || banana.state === 8) {
|
||||
h = hashU32(h, 0);
|
||||
continue;
|
||||
}
|
||||
h = hashU32(h, banana.state | 0);
|
||||
h = hashVec3(h, banana.localPos);
|
||||
h = hashVec3(h, banana.vel ?? { x: 0, y: 0, z: 0 });
|
||||
|
||||
@@ -5,7 +5,9 @@ export function runDeterminismTest(game, tickCount, inputFeed = null, { includeV
|
||||
const hashes = [];
|
||||
for (let i = 0; i < tickCount; i += 1) {
|
||||
game.update(game.fixedStep);
|
||||
hashes.push(hashSimState(game.ball, game.world, game.stageRuntime, { includeVisual }));
|
||||
const balls = game.players ? game.players.map((player) => player.ball) : game.ball;
|
||||
const worlds = game.players ? [game.world, ...game.players.map((player) => player.world)] : game.world;
|
||||
hashes.push(hashSimState(balls, worlds, game.stageRuntime, { includeVisual }));
|
||||
}
|
||||
return hashes;
|
||||
}
|
||||
|
||||
96
src/stage.ts
96
src/stage.ts
@@ -1542,6 +1542,87 @@ export class StageRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
getState() {
|
||||
return {
|
||||
timerFrames: this.timerFrames,
|
||||
animGroups: structuredClone(this.animGroups),
|
||||
bumpers: structuredClone(this.bumpers),
|
||||
jamabars: structuredClone(this.jamabars),
|
||||
goals: structuredClone(this.goals),
|
||||
goalBags: structuredClone(this.goalBags),
|
||||
goalTapes: structuredClone(this.goalTapes),
|
||||
bananas: structuredClone(this.bananas),
|
||||
confetti: structuredClone(this.confetti),
|
||||
effects: structuredClone(this.effects),
|
||||
switches: structuredClone(this.switches),
|
||||
switchPressCount: this.switchPressCount ?? 0,
|
||||
wormholes: structuredClone(this.wormholes),
|
||||
seesaws: structuredClone(this.seesaws),
|
||||
boundSphere: structuredClone(this.boundSphere),
|
||||
goalHoldOpen: this.goalHoldOpen,
|
||||
switchesEnabled: this.switchesEnabled,
|
||||
simRngState: this.simRng?.state ?? 0,
|
||||
visualRngState: this.visualRng?.state ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
setState(state) {
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
this.timerFrames = state.timerFrames ?? 0;
|
||||
this.animGroups = structuredClone(state.animGroups ?? []);
|
||||
this.bumpers = structuredClone(state.bumpers ?? []);
|
||||
this.jamabars = structuredClone(state.jamabars ?? []);
|
||||
this.goals = structuredClone(state.goals ?? []);
|
||||
this.goalBags = structuredClone(state.goalBags ?? []);
|
||||
this.goalTapes = structuredClone(state.goalTapes ?? []);
|
||||
this.bananas = structuredClone(state.bananas ?? []);
|
||||
this.confetti = structuredClone(state.confetti ?? []);
|
||||
this.effects = structuredClone(state.effects ?? []);
|
||||
this.switches = structuredClone(state.switches ?? []);
|
||||
this.switchPressCount = state.switchPressCount ?? 0;
|
||||
this.wormholes = structuredClone(state.wormholes ?? []);
|
||||
this.seesaws = structuredClone(state.seesaws ?? []);
|
||||
this.boundSphere = structuredClone(state.boundSphere ?? this.boundSphere);
|
||||
this.goalHoldOpen = !!state.goalHoldOpen;
|
||||
this.switchesEnabled = !!state.switchesEnabled;
|
||||
if (this.simRng) {
|
||||
this.simRng.state = state.simRngState ?? this.simRng.state;
|
||||
}
|
||||
if (this.visualRng) {
|
||||
this.visualRng.state = state.visualRngState ?? this.visualRng.state;
|
||||
}
|
||||
|
||||
const count = this.stage.animGroupCount ?? 0;
|
||||
this.goalBagsByGroup.length = count;
|
||||
this.goalTapesByGroup.length = count;
|
||||
this.switchesByGroup.length = count;
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
this.goalBagsByGroup[i] = [];
|
||||
this.goalTapesByGroup[i] = [];
|
||||
this.switchesByGroup[i] = [];
|
||||
}
|
||||
for (const bag of this.goalBags) {
|
||||
const group = bag.animGroupId ?? 0;
|
||||
if (this.goalBagsByGroup[group]) {
|
||||
this.goalBagsByGroup[group].push(bag);
|
||||
}
|
||||
}
|
||||
for (const tape of this.goalTapes) {
|
||||
const group = tape.animGroupId ?? 0;
|
||||
if (this.goalTapesByGroup[group]) {
|
||||
this.goalTapesByGroup[group].push(tape);
|
||||
}
|
||||
}
|
||||
for (const sw of this.switches) {
|
||||
const group = sw.animGroupIndex ?? sw.animGroupId ?? 0;
|
||||
if (this.switchesByGroup[group]) {
|
||||
this.switchesByGroup[group].push(sw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateSwitchesSmb2PreAnim() {
|
||||
const stack = this.matrixStack;
|
||||
for (const stageSwitch of this.switches) {
|
||||
@@ -1949,7 +2030,8 @@ export class StageRuntime {
|
||||
stack.rigidInvTfPoint(flyTargetLocal, flyTargetLocal);
|
||||
}
|
||||
}
|
||||
updateBanana(banana, ballLocalPos, flyTargetLocal);
|
||||
const allowFlyToHud = !!flyTargetLocal;
|
||||
updateBanana(banana, ballLocalPos, flyTargetLocal, allowFlyToHud);
|
||||
}
|
||||
for (const stageSwitch of this.switches) {
|
||||
if (stageSwitch.cooldown > 0) {
|
||||
@@ -2615,7 +2697,7 @@ function updateGoalBag(bag, animGroups, gravity, holdOpen, stack, rng) {
|
||||
updateGoalBagTransform(bag, stack);
|
||||
}
|
||||
|
||||
function updateBanana(banana, ballLocalPos, flyTargetLocal) {
|
||||
function updateBanana(banana, ballLocalPos, flyTargetLocal, allowFlyToHud) {
|
||||
if (banana.state === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -2656,8 +2738,14 @@ function updateBanana(banana, ballLocalPos, flyTargetLocal) {
|
||||
return;
|
||||
}
|
||||
if (banana.state === BANANA_STATE_FLY) {
|
||||
if (!flyTargetLocal) {
|
||||
banana.state = 6;
|
||||
if (!allowFlyToHud) {
|
||||
banana.localPos.y += 0.02;
|
||||
banana.scale -= 0.01;
|
||||
if (banana.scale <= 0) {
|
||||
banana.scale = 0;
|
||||
banana.state = 0;
|
||||
banana.collected = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
banana.flyTimer -= 1;
|
||||
|
||||
70
style.css
70
style.css
@@ -216,6 +216,12 @@ body.gameplay-active {
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.panel p {
|
||||
margin: 0 0 16px;
|
||||
color: var(--muted);
|
||||
@@ -305,6 +311,70 @@ body.gameplay-active {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.panel-section {
|
||||
margin-top: 18px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.panel-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.multiplayer-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
flex: 1 1 auto;
|
||||
padding: 10px 12px;
|
||||
background: rgba(12, 12, 18, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 10px;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.lobby-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.lobby-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(12, 12, 18, 0.55);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.lobby-item span {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.lobby-players {
|
||||
margin-top: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(12, 12, 18, 0.35);
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.control-mode-block {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
281
worker/src/index.ts
Normal file
281
worker/src/index.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
export interface Env {
|
||||
LOBBY: DurableObjectNamespace;
|
||||
ROOM: DurableObjectNamespace;
|
||||
}
|
||||
|
||||
type RoomSettings = {
|
||||
maxPlayers: number;
|
||||
collisionEnabled: boolean;
|
||||
};
|
||||
|
||||
type RoomRecord = {
|
||||
roomId: string;
|
||||
roomCode?: string;
|
||||
isPublic: boolean;
|
||||
hostId: number;
|
||||
courseId: string;
|
||||
settings: RoomSettings;
|
||||
createdAt: number;
|
||||
lastActiveAt: number;
|
||||
};
|
||||
|
||||
type LobbyState = {
|
||||
rooms: Record<string, RoomRecord>;
|
||||
codes: Record<string, string>;
|
||||
};
|
||||
|
||||
const ROOM_TTL_MS = 1000 * 60;
|
||||
const CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
|
||||
function jsonResponse(data: any, status = 200): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"access-control-allow-origin": "*",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function parseJson<T>(req: Request): Promise<T> {
|
||||
return req.json() as Promise<T>;
|
||||
}
|
||||
|
||||
function randomCode(length = 6): string {
|
||||
let out = "";
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
out += CODE_CHARS[Math.floor(Math.random() * CODE_CHARS.length)];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function nowMs() {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
export class Lobby implements DurableObject {
|
||||
private state: DurableObjectState;
|
||||
private env: Env;
|
||||
private data: LobbyState = { rooms: {}, codes: {} };
|
||||
|
||||
constructor(state: DurableObjectState, env: Env) {
|
||||
this.state = state;
|
||||
this.env = env;
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
const stored = await this.state.storage.get<LobbyState>("lobby");
|
||||
if (stored) {
|
||||
this.data = stored;
|
||||
}
|
||||
}
|
||||
|
||||
private async save(): Promise<void> {
|
||||
await this.state.storage.put("lobby", this.data);
|
||||
}
|
||||
|
||||
private cleanupExpired(): void {
|
||||
const now = nowMs();
|
||||
for (const roomId of Object.keys(this.data.rooms)) {
|
||||
const room = this.data.rooms[roomId];
|
||||
if (!room) {
|
||||
continue;
|
||||
}
|
||||
if (now - room.lastActiveAt > ROOM_TTL_MS) {
|
||||
delete this.data.rooms[roomId];
|
||||
if (room.roomCode) {
|
||||
delete this.data.codes[room.roomCode];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
await this.load();
|
||||
this.cleanupExpired();
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (request.method === "OPTIONS") {
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
"access-control-allow-origin": "*",
|
||||
"access-control-allow-methods": "GET,POST,OPTIONS",
|
||||
"access-control-allow-headers": "content-type",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/rooms") {
|
||||
const list = Object.values(this.data.rooms).filter((room) => room.isPublic);
|
||||
return jsonResponse({ rooms: list });
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/rooms") {
|
||||
const body = await parseJson<Partial<RoomRecord>>(request);
|
||||
const roomId = this.env.ROOM.newUniqueId().toString();
|
||||
const isPublic = !!body.isPublic;
|
||||
let roomCode: string | undefined;
|
||||
if (!isPublic) {
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
const code = randomCode();
|
||||
if (!this.data.codes[code]) {
|
||||
roomCode = code;
|
||||
this.data.codes[code] = roomId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const createdAt = nowMs();
|
||||
const record: RoomRecord = {
|
||||
roomId,
|
||||
roomCode,
|
||||
isPublic,
|
||||
hostId: body.hostId ?? 0,
|
||||
courseId: body.courseId ?? "smb1-main",
|
||||
settings: body.settings ?? { maxPlayers: 8, collisionEnabled: true },
|
||||
createdAt,
|
||||
lastActiveAt: createdAt,
|
||||
};
|
||||
this.data.rooms[roomId] = record;
|
||||
await this.save();
|
||||
return jsonResponse({ room: record });
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/rooms/join") {
|
||||
const body = await parseJson<{ roomCode?: string; roomId?: string }>(request);
|
||||
const roomId = body.roomId ?? (body.roomCode ? this.data.codes[body.roomCode] : null);
|
||||
if (!roomId || !this.data.rooms[roomId]) {
|
||||
return jsonResponse({ error: "room_not_found" }, 404);
|
||||
}
|
||||
const room = this.data.rooms[roomId];
|
||||
room.lastActiveAt = nowMs();
|
||||
this.data.rooms[roomId] = room;
|
||||
await this.save();
|
||||
return jsonResponse({ room });
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/rooms/heartbeat") {
|
||||
const body = await parseJson<{ roomId?: string }>(request);
|
||||
const roomId = body.roomId ?? null;
|
||||
if (!roomId || !this.data.rooms[roomId]) {
|
||||
return jsonResponse({ ok: false, error: "room_not_found" }, 404);
|
||||
}
|
||||
const room = this.data.rooms[roomId];
|
||||
room.lastActiveAt = nowMs();
|
||||
this.data.rooms[roomId] = room;
|
||||
await this.save();
|
||||
return jsonResponse({ ok: true });
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/rooms/close") {
|
||||
const body = await parseJson<{ roomId?: string }>(request);
|
||||
const roomId = body.roomId ?? null;
|
||||
if (!roomId || !this.data.rooms[roomId]) {
|
||||
return jsonResponse({ ok: false, error: "room_not_found" }, 404);
|
||||
}
|
||||
const room = this.data.rooms[roomId];
|
||||
delete this.data.rooms[roomId];
|
||||
if (room.roomCode) {
|
||||
delete this.data.codes[room.roomCode];
|
||||
}
|
||||
await this.save();
|
||||
return jsonResponse({ ok: true });
|
||||
}
|
||||
|
||||
return jsonResponse({ error: "not_found" }, 404);
|
||||
}
|
||||
}
|
||||
|
||||
type Connection = {
|
||||
socket: WebSocket;
|
||||
playerId: number;
|
||||
};
|
||||
|
||||
export class Room implements DurableObject {
|
||||
private state: DurableObjectState;
|
||||
private env: Env;
|
||||
private connections = new Map<string, Connection>();
|
||||
|
||||
constructor(state: DurableObjectState, env: Env) {
|
||||
this.state = state;
|
||||
this.env = env;
|
||||
}
|
||||
|
||||
private async heartbeat(roomId: string): Promise<void> {
|
||||
const id = this.env.LOBBY.idFromName("lobby");
|
||||
const stub = this.env.LOBBY.get(id);
|
||||
await stub.fetch("https://lobby.internal/rooms/heartbeat", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ roomId }),
|
||||
});
|
||||
}
|
||||
|
||||
private async closeRoom(roomId: string): Promise<void> {
|
||||
const id = this.env.LOBBY.idFromName("lobby");
|
||||
const stub = this.env.LOBBY.get(id);
|
||||
await stub.fetch("https://lobby.internal/rooms/close", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ roomId }),
|
||||
});
|
||||
}
|
||||
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
if (request.headers.get("upgrade") !== "websocket") {
|
||||
return new Response("Expected websocket", { status: 400 });
|
||||
}
|
||||
const playerId = Number(url.searchParams.get("playerId") ?? "0");
|
||||
const [client, server] = new WebSocketPair();
|
||||
server.accept();
|
||||
|
||||
const connId = crypto.randomUUID();
|
||||
this.connections.set(connId, { socket: server, playerId });
|
||||
const roomId = this.state.id.toString();
|
||||
void this.heartbeat(roomId);
|
||||
|
||||
server.addEventListener("message", (event) => {
|
||||
const payload = event.data;
|
||||
void this.heartbeat(roomId);
|
||||
for (const [id, conn] of this.connections.entries()) {
|
||||
if (id === connId) {
|
||||
continue;
|
||||
}
|
||||
conn.socket.send(payload);
|
||||
}
|
||||
});
|
||||
|
||||
server.addEventListener("close", () => {
|
||||
this.connections.delete(connId);
|
||||
if (this.connections.size === 0) {
|
||||
this.state.storage.deleteAll();
|
||||
void this.closeRoom(roomId);
|
||||
}
|
||||
});
|
||||
|
||||
return new Response(null, { status: 101, webSocket: client });
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname.startsWith("/room/")) {
|
||||
const roomId = url.pathname.slice("/room/".length);
|
||||
const id = env.ROOM.idFromString(roomId);
|
||||
const stub = env.ROOM.get(id);
|
||||
return stub.fetch(request);
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/rooms")) {
|
||||
const id = env.LOBBY.idFromName("lobby");
|
||||
const stub = env.LOBBY.get(id);
|
||||
return stub.fetch(request);
|
||||
}
|
||||
|
||||
return new Response("Not found", { status: 404 });
|
||||
},
|
||||
};
|
||||
13
worker/wrangler.toml
Normal file
13
worker/wrangler.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
name = "webmonkeyball-lobby"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2025-01-01"
|
||||
|
||||
[durable_objects]
|
||||
bindings = [
|
||||
{ name = "LOBBY", class_name = "Lobby" },
|
||||
{ name = "ROOM", class_name = "Room" }
|
||||
]
|
||||
|
||||
[[migrations]]
|
||||
tag = "v1"
|
||||
new_sqlite_classes = ["Lobby", "Room"]
|
||||
Reference in New Issue
Block a user