Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
303 changes: 275 additions & 28 deletions src/App.ts

Large diffs are not rendered by default.

36 changes: 33 additions & 3 deletions src/runtime/multiplayer/items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
QuakeMultiplayerInventoryState,
QuakeMultiplayerPickupEffect,
QuakeMultiplayerPickupDefinition,
QuakeMultiplayerVec3,
} from "./protocol";

const QUAKE_MULTIPLAYER_MAX_AMMO = {
Expand All @@ -20,6 +21,8 @@ const QUAKE_MULTIPLAYER_DEFAULT_WEAPON_FLAGS = {

const QUAKE_MULTIPLAYER_ARMOR_FLAGS = 8192 | 16384 | 32768;
const QUAKE_MULTIPLAYER_PICKUP_REACH_DISTANCE = 3.5;
const QUAKE_MULTIPLAYER_PICKUP_ORIGIN_HINT_MAX_HORIZONTAL_DRIFT = 3;
const QUAKE_MULTIPLAYER_PICKUP_ORIGIN_HINT_MAX_VERTICAL_DRIFT = 8;

export interface QuakeMultiplayerDamageOptions {
applyHealth?: boolean;
Expand Down Expand Up @@ -75,14 +78,41 @@ export function quakeMultiplayerPlayerCanReachPickup(
player: QuakeMultiplayerAuthoritativePlayerState,
pickup: QuakeMultiplayerPickupDefinition,
maxDistance = QUAKE_MULTIPLAYER_PICKUP_REACH_DISTANCE,
originHint?: QuakeMultiplayerVec3,
): boolean {
if (!player.alive) return false;
const dx = player.origin[0] - pickup.origin[0];
const dy = player.origin[1] - pickup.origin[1];
const dz = player.origin[2] - pickup.origin[2];
if (quakeMultiplayerOriginCanReachPickup(player.origin, pickup, maxDistance)) return true;
return Boolean(
originHint &&
quakeMultiplayerPickupOriginHintWithinDrift(player.origin, originHint) &&
quakeMultiplayerOriginCanReachPickup(originHint, pickup, maxDistance)
);
}

function quakeMultiplayerOriginCanReachPickup(
origin: QuakeMultiplayerVec3,
pickup: QuakeMultiplayerPickupDefinition,
maxDistance: number,
): boolean {
const dx = origin[0] - pickup.origin[0];
const dy = origin[1] - pickup.origin[1];
const dz = origin[2] - pickup.origin[2];
return Math.hypot(dx, dy, dz) <= maxDistance;
}

function quakeMultiplayerPickupOriginHintWithinDrift(
authoritativeOrigin: QuakeMultiplayerVec3,
hintOrigin: QuakeMultiplayerVec3,
): boolean {
const horizontalDrift = Math.hypot(
authoritativeOrigin[0] - hintOrigin[0],
authoritativeOrigin[1] - hintOrigin[1],
);
const verticalDrift = Math.abs(authoritativeOrigin[2] - hintOrigin[2]);
return horizontalDrift <= QUAKE_MULTIPLAYER_PICKUP_ORIGIN_HINT_MAX_HORIZONTAL_DRIFT &&
verticalDrift <= QUAKE_MULTIPLAYER_PICKUP_ORIGIN_HINT_MAX_VERTICAL_DRIFT;
}

export function quakeMultiplayerPickupStateWithoutOwner(
state: QuakeMultiplayerAuthoritativePickupState,
playerId: string,
Expand Down
9 changes: 6 additions & 3 deletions src/runtime/multiplayer/loopback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ import {
quakeMultiplayerShootableWorldHit,
quakeMultiplayerTriggerCounterMessage,
quakeMultiplayerTriggerUsesMultiTrigger,
quakeMultiplayerWorldIntentRejectionIsIgnorableTouchMiss,
rejectQuakeMultiplayerClientWorldEvent,
resolveQuakeMultiplayerWorldIntent,
} from "./world";
Expand Down Expand Up @@ -1169,15 +1170,14 @@ export function createQuakeLoopbackMultiplayerSession(
const definition = pickupDefinitions.get(message.payload.pickup.entityIndex);
const state = pickupStates.get(message.payload.pickup.entityIndex);
if (!definition || !state) {
emitPickupRejected(message, undefined, "unknown-pickup");
return;
}
const ownerPlayerIds = new Set(state.ownerPlayerIds ?? []);
if (!state.available || ownerPlayerIds.has(playerState.playerId)) {
emitPickupRejected(message, definition, "unavailable");
return;
}
if (!quakeMultiplayerPlayerCanReachPickup(playerState, definition)) {
if (!quakeMultiplayerPlayerCanReachPickup(playerState, definition, undefined, message.payload.pickup.origin)) {
emitPickupRejected(message, definition, "too-far");
return;
}
Expand Down Expand Up @@ -1293,12 +1293,15 @@ export function createQuakeLoopbackMultiplayerSession(
now(),
);
if (!resolution.ok) {
if (quakeMultiplayerWorldIntentRejectionIsIgnorableTouchMiss(message.payload.intent, resolution.reason)) {
return;
}
emitReject({
code: "unsupported",
message: resolution.message,
recoverable: true,
rejectedMessageId: message.messageId,
details: { reason: resolution.reason },
details: { reason: resolution.reason, ...(resolution.details ?? {}) },
});
return;
}
Expand Down
9 changes: 6 additions & 3 deletions src/runtime/multiplayer/partyRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ import {
sameQuakeMultiplayerMoverOffset,
quakeMultiplayerTriggerUsesMultiTrigger,
quakeMultiplayerWorldDefinitionsFromScene,
quakeMultiplayerWorldIntentRejectionIsIgnorableTouchMiss,
rejectQuakeMultiplayerClientWorldEvent,
resolveQuakeMultiplayerWorldIntent,
} from "./world";
Expand Down Expand Up @@ -1086,15 +1087,14 @@ export default class CssQuakeMultiplayerRoom implements Party.Server {
const definition = this.pickupDefinitions.get(message.payload.pickup.entityIndex);
const state = this.pickupStates.get(message.payload.pickup.entityIndex);
if (!definition || !state) {
this.broadcastPickupRejected(player.playerId, message, undefined, "unknown-pickup");
return;
}
const ownerPlayerIds = new Set(state.ownerPlayerIds ?? []);
if (!state.available || ownerPlayerIds.has(player.playerId)) {
this.broadcastPickupRejected(player.playerId, message, definition, "unavailable");
return;
}
if (!quakeMultiplayerPlayerCanReachPickup(player, definition)) {
if (!quakeMultiplayerPlayerCanReachPickup(player, definition, undefined, message.payload.pickup.origin)) {
this.broadcastPickupRejected(player.playerId, message, definition, "too-far");
return;
}
Expand Down Expand Up @@ -1183,12 +1183,15 @@ export default class CssQuakeMultiplayerRoom implements Party.Server {
Date.now(),
);
if (!resolution.ok) {
if (quakeMultiplayerWorldIntentRejectionIsIgnorableTouchMiss(message.payload.intent, resolution.reason)) {
return;
}
this.reject(sender, {
code: "unsupported",
message: resolution.message,
recoverable: true,
rejectedMessageId: message.messageId,
details: { reason: resolution.reason },
details: { reason: resolution.reason, ...(resolution.details ?? {}) },
});
return;
}
Expand Down
15 changes: 12 additions & 3 deletions src/runtime/multiplayer/presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ type QuakeMultiplayerPlayerDamagedEvent = Extract<
QuakeMultiplayerSharedWorldEvent,
{ eventType: "player.damaged" }
>;
type QuakeMultiplayerPlayerKilledEvent = Extract<
QuakeMultiplayerSharedWorldEvent,
{ eventType: "player.killed" }
>;

export interface QuakeMultiplayerRemoteVisualHandle {
element?: HTMLElement;
Expand All @@ -32,6 +36,10 @@ export interface QuakeMultiplayerRemotePlayerPresenterOptions {
event: QuakeMultiplayerPlayerDamagedEvent,
player: QuakeMultiplayerAuthoritativePlayerState,
) => void;
onPlayerKilled?: (
event: QuakeMultiplayerPlayerKilledEvent,
player: QuakeMultiplayerAuthoritativePlayerState,
) => void;
now?: () => number;
requestFrame?: (callback: FrameRequestCallback) => number;
cancelFrame?: (handle: number) => void;
Expand Down Expand Up @@ -148,7 +156,7 @@ export function createQuakeMultiplayerRemotePlayerPresenter(
} else if (event.eventType === "player.damaged") {
markRemotePlayerPain(event);
} else if (event.eventType === "player.killed") {
markRemotePlayerDeath(event.victimPlayerId);
markRemotePlayerDeath(event);
} else if (event.eventType === "player.respawned") {
if (event.player.clientId === options.localClientId) return;
syncRemotePlayer(event.player);
Expand All @@ -163,11 +171,12 @@ export function createQuakeMultiplayerRemotePlayerPresenter(
scheduleFrame();
}

function markRemotePlayerDeath(playerId: string): void {
const entry = players.get(playerId);
function markRemotePlayerDeath(event: QuakeMultiplayerPlayerKilledEvent): void {
const entry = players.get(event.victimPlayerId);
if (!entry || entry.player.clientId === options.localClientId) return;
entry.lastPainAt = undefined;
entry.deathAt = now();
options.onPlayerKilled?.(event, entry.player);
entry.player = {
...entry.player,
alive: false,
Expand Down
1 change: 1 addition & 0 deletions src/runtime/multiplayer/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ export interface QuakeMultiplayerPickupIntent {
pickupSequence: number;
requestedAt: number;
entityIndex: number;
origin?: QuakeMultiplayerVec3;
}

export interface QuakeMultiplayerMatchIntent {
Expand Down
3 changes: 2 additions & 1 deletion src/runtime/multiplayer/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,8 @@ function isPickupIntent(value: unknown): value is QuakeMultiplayerPickupIntent {
if (!isRecord(value)) return false;
return isNonNegativeInteger(value.pickupSequence) &&
Number.isFinite(value.requestedAt) &&
isNonNegativeInteger(value.entityIndex);
isNonNegativeInteger(value.entityIndex) &&
(value.origin === undefined || isVec3(value.origin));
}

function isMatchIntent(value: unknown): value is QuakeMultiplayerMatchIntent {
Expand Down
65 changes: 63 additions & 2 deletions src/runtime/multiplayer/world.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import {
} from "./sceneFacts";

const QUAKE_MULTIPLAYER_WORLD_TOUCH_TOLERANCE = 1;
const QUAKE_MULTIPLAYER_WORLD_TOUCH_ORIGIN_HINT_MAX_HORIZONTAL_DRIFT = 3;
const QUAKE_MULTIPLAYER_WORLD_TOUCH_ORIGIN_HINT_MAX_VERTICAL_DRIFT = 8;
export const QUAKE_MULTIPLAYER_TELEPORT_EXIT_SPEED = 300 * QUAKE_COLLISION_UNIT_SCALE;
export const QUAKE_MULTIPLAYER_TELEPORT_TARGET_ACTIVATION_WINDOW_MS = 200;
export const QUAKE_MULTIPLAYER_TELEFRAG_DAMAGE = 50000;
Expand Down Expand Up @@ -110,6 +112,7 @@ export type QuakeMultiplayerWorldIntentResolution =
ok: false;
reason: string;
message: string;
details?: Record<string, QuakeMultiplayerJson>;
};

export function quakeMultiplayerWorldDefinitionsFromScene(
Expand Down Expand Up @@ -287,11 +290,12 @@ export function resolveQuakeMultiplayerWorldIntent(
message: "Multiplayer world intent targets an unknown shared world entity.",
};
}
if (!quakeMultiplayerPlayerTouchesWorldDefinition(player, definition)) {
if (!quakeMultiplayerWorldIntentTouchesDefinition(player, intent, definition)) {
return {
ok: false,
reason: "too-far",
message: "Multiplayer world intent is too far from the authoritative player position.",
details: quakeMultiplayerWorldIntentTouchRejectDetails(player, intent, definition),
};
}
if (intent.intentType === "teleport" && definition.kind !== "teleport") {
Expand Down Expand Up @@ -402,6 +406,13 @@ export function resolveQuakeMultiplayerWorldIntent(
};
}

export function quakeMultiplayerWorldIntentRejectionIsIgnorableTouchMiss(
intent: QuakeMultiplayerWorldIntent,
reason: string,
): boolean {
return intent.intentType === "touch" && (reason === "too-far" || reason === "not-alive");
}

export function rejectQuakeMultiplayerClientWorldEvent(
message: QuakeMultiplayerClientWorldEnvelope,
): QuakeMultiplayerRoomRejectPayload {
Expand Down Expand Up @@ -615,8 +626,58 @@ function quakeMultiplayerWorldBoundsFromLogic(
};
}

function quakeMultiplayerPlayerTouchesWorldDefinition(
function quakeMultiplayerWorldIntentTouchesDefinition(
player: QuakeMultiplayerAuthoritativePlayerState,
intent: QuakeMultiplayerWorldIntent,
definition: QuakeMultiplayerWorldDefinition,
): boolean {
if (quakeMultiplayerPlayerTouchesWorldDefinition(player, definition)) return true;
const origin = "origin" in intent ? intent.origin : undefined;
if (!origin) return false;
if (!quakeMultiplayerTouchOriginHintWithinDrift(player.origin, origin)) {
return false;
}
return quakeMultiplayerPlayerTouchesWorldDefinition({ origin }, definition);
}

function quakeMultiplayerWorldIntentTouchRejectDetails(
player: QuakeMultiplayerAuthoritativePlayerState,
intent: QuakeMultiplayerWorldIntent,
definition: QuakeMultiplayerWorldDefinition,
): Record<string, QuakeMultiplayerJson> {
const origin = "origin" in intent ? intent.origin : undefined;
const horizontalDrift = origin
? Math.hypot(player.origin[0] - origin[0], player.origin[1] - origin[1])
: null;
const verticalDrift = origin ? Math.abs(player.origin[2] - origin[2]) : null;
return {
authoritativeOrigin: player.origin,
authoritativeTouches: quakeMultiplayerPlayerTouchesWorldDefinition(player, definition),
definitionKind: definition.kind,
entityIndex: definition.entityIndex,
hintOrigin: origin ?? null,
hintTouches: origin ? quakeMultiplayerPlayerTouchesWorldDefinition({ origin }, definition) : false,
horizontalDrift,
hintWithinDrift: origin ? quakeMultiplayerTouchOriginHintWithinDrift(player.origin, origin) : false,
verticalDrift,
};
}

function quakeMultiplayerTouchOriginHintWithinDrift(
authoritativeOrigin: QuakeMultiplayerVec3,
hintOrigin: QuakeMultiplayerVec3,
): boolean {
const horizontalDrift = Math.hypot(
authoritativeOrigin[0] - hintOrigin[0],
authoritativeOrigin[1] - hintOrigin[1],
);
const verticalDrift = Math.abs(authoritativeOrigin[2] - hintOrigin[2]);
return horizontalDrift <= QUAKE_MULTIPLAYER_WORLD_TOUCH_ORIGIN_HINT_MAX_HORIZONTAL_DRIFT &&
verticalDrift <= QUAKE_MULTIPLAYER_WORLD_TOUCH_ORIGIN_HINT_MAX_VERTICAL_DRIFT;
}

function quakeMultiplayerPlayerTouchesWorldDefinition(
player: Pick<QuakeMultiplayerAuthoritativePlayerState, "origin">,
definition: QuakeMultiplayerWorldDefinition,
): boolean {
if (!definition.bounds) return true;
Expand Down
Loading
Loading