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
37 changes: 36 additions & 1 deletion src/App.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1250,6 +1250,14 @@ const quakeRemotePlayers = createQuakeMultiplayerRemotePlayerPresenter({
localClientId: QUAKE_MULTIPLAYER_LOCAL_CLIENT_ID,
createVisual: createQuakeRemotePlayerVisual,
shouldRenderPlayer: (playerState) => playerState.playerId !== quakeMultiplayerSpectatorFollowedPlayerId,
onPlayerDamaged: (event, playerState) => {
if (!quakeMultiplayerRemoteDamageFromLocalPlayer(event)) return;
quakeImpactParticleFlow.spawnBlood({
damage: event.damage,
directionHint: quakeMultiplayerRemoteDamageDirectionHint(event, playerState),
origin: quakeMultiplayerRemoteDamageOrigin(playerState),
});
},
now: () => Date.now(),
});
const quakeLoopbackTrustedSceneMovement = {
Expand Down Expand Up @@ -2909,7 +2917,7 @@ function syncQuakeRemotePlayerVisual(
state: QuakeMultiplayerRemoteInterpolationState,
): void {
const { handle } = remote;
handle.element.hidden = state.stale;
handle.element.hidden = false;
setQuakeRenderBundleFrameSetHandleFrame(handle, quakeRemotePlayerVisualFrameIndex(remote, state));
const rotY = quakeRemotePlayerVisualRotY(state);
handle.setTransform({
Expand Down Expand Up @@ -3735,6 +3743,33 @@ function handleQuakeMultiplayerPlayerDamaged(
if (event.health <= 0 && !quakePlayerDead) showQuakePlayerDeath();
}

function quakeMultiplayerRemoteDamageOrigin(playerState: QuakeMultiplayerAuthoritativePlayerState): Vec3 {
return [
playerState.origin[0],
playerState.origin[1],
playerState.origin[2] + QUAKE_MULTIPLAYER_REMOTE_PLAYER_EYE_HEIGHT * 0.5,
];
}

function quakeMultiplayerRemoteDamageDirectionHint(
event: Extract<QuakeMultiplayerSharedWorldEvent, { eventType: "player.damaged" }>,
playerState: QuakeMultiplayerAuthoritativePlayerState,
): Vec3 | undefined {
if (!quakeMultiplayerRemoteDamageFromLocalPlayer(event)) return undefined;
const origin = getPlayer().currentOrigin();
return [
playerState.origin[0] - origin[0],
playerState.origin[1] - origin[1],
playerState.origin[2] - origin[2],
];
}

function quakeMultiplayerRemoteDamageFromLocalPlayer(
event: Extract<QuakeMultiplayerSharedWorldEvent, { eventType: "player.damaged" }>,
): boolean {
return event.attackerPlayerId === quakeMultiplayerPlayerIdForClient(QUAKE_MULTIPLAYER_LOCAL_CLIENT_ID);
}

function handleQuakeMultiplayerPlayerKilled(
event: Extract<QuakeMultiplayerSharedWorldEvent, { eventType: "player.killed" }>,
): void {
Expand Down
16 changes: 13 additions & 3 deletions src/runtime/multiplayer/presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ const QUAKE_MULTIPLAYER_REMOTE_RENDER_DELAY_MS = 100;
const QUAKE_MULTIPLAYER_REMOTE_STALE_MS = 1_000;
const QUAKE_MULTIPLAYER_REMOTE_SAMPLE_LIMIT = 12;

type QuakeMultiplayerPlayerDamagedEvent = Extract<
QuakeMultiplayerSharedWorldEvent,
{ eventType: "player.damaged" }
>;

export interface QuakeMultiplayerRemoteVisualHandle {
element?: HTMLElement;
setState(state: QuakeMultiplayerRemoteInterpolationState): void;
Expand All @@ -23,6 +28,10 @@ export interface QuakeMultiplayerRemotePlayerPresenterOptions {
localClientId: string;
createVisual(player: QuakeMultiplayerAuthoritativePlayerState): QuakeMultiplayerRemoteVisualHandle | null;
shouldRenderPlayer?: (player: QuakeMultiplayerAuthoritativePlayerState) => boolean;
onPlayerDamaged?: (
event: QuakeMultiplayerPlayerDamagedEvent,
player: QuakeMultiplayerAuthoritativePlayerState,
) => void;
now?: () => number;
requestFrame?: (callback: FrameRequestCallback) => number;
cancelFrame?: (handle: number) => void;
Expand Down Expand Up @@ -137,7 +146,7 @@ export function createQuakeMultiplayerRemotePlayerPresenter(
if (event.eventType === "player.left") {
removeRemotePlayer(event.playerId);
} else if (event.eventType === "player.damaged") {
markRemotePlayerPain(event.victimPlayerId);
markRemotePlayerPain(event);
} else if (event.eventType === "player.killed") {
markRemotePlayerDeath(event.victimPlayerId);
} else if (event.eventType === "player.respawned") {
Expand All @@ -146,10 +155,11 @@ export function createQuakeMultiplayerRemotePlayerPresenter(
}
}

function markRemotePlayerPain(playerId: string): void {
const entry = players.get(playerId);
function markRemotePlayerPain(event: QuakeMultiplayerPlayerDamagedEvent): void {
const entry = players.get(event.victimPlayerId);
if (!entry || entry.player.clientId === options.localClientId || !entry.player.alive) return;
entry.lastPainAt = now();
options.onPlayerDamaged?.(event, entry.player);
scheduleFrame();
}

Expand Down
120 changes: 120 additions & 0 deletions test/multiplayer/presentation.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import assert from "node:assert/strict";
import test from "node:test";

import { createPlayer } from "./harness.mjs";
import { importTsModule } from "../importTsModule.mjs";

const presentation = await importTsModule("src/runtime/multiplayer/presentation.ts");

function createRemotePresenterHarness() {
let now = 1_000;
const callbacks = new Map();
const damageEvents = [];
const visualStates = [];
const removed = [];
const presenter = presentation.createQuakeMultiplayerRemotePlayerPresenter({
localClientId: "local-client",
createVisual: (player) => ({
setState: (state) => visualStates.push({ playerId: player.playerId, state }),
remove: () => removed.push(player.playerId),
}),
onPlayerDamaged: (event, player) => damageEvents.push({ event, player }),
now: () => now,
renderDelayMs: 0,
requestFrame: (callback) => {
const handle = callbacks.size + 1;
callbacks.set(handle, callback);
return handle;
},
cancelFrame: (handle) => {
callbacks.delete(handle);
},
});

return {
damageEvents,
presenter,
removed,
visualStates,
flushFrame() {
const next = callbacks.entries().next().value;
assert.ok(next, "expected a scheduled frame");
const [handle, callback] = next;
callbacks.delete(handle);
callback(now);
},
setNow(value) {
now = value;
},
};
}

function roomSnapshot(players) {
return {
type: "room.snapshot",
payload: { players },
};
}

function roomEvent(event) {
return {
type: "room.event",
payload: { event },
};
}

test("remote presenter marks remote player pain and reports damage to visual layer", () => {
const harness = createRemotePresenterHarness();
const remotePlayer = createPlayer({
playerId: "remote-player",
clientId: "remote-client",
updatedAt: 1_000,
origin: [10, 20, 30],
});

harness.presenter.handleRoomMessage(roomSnapshot([remotePlayer]));
harness.flushFrame();

harness.setNow(1_100);
harness.presenter.handleRoomMessage(roomEvent({
eventType: "player.damaged",
eventId: "damage-1",
roomTime: 1_100,
victimPlayerId: "remote-player",
attackerPlayerId: "local-player",
damage: 12,
health: 88,
armor: 0,
damageSource: "shotgun",
}));
harness.flushFrame();

assert.equal(harness.damageEvents.length, 1);
assert.equal(harness.damageEvents[0].event.damage, 12);
assert.equal(harness.damageEvents[0].player.playerId, "remote-player");
assert.equal(harness.visualStates.at(-1).state.lastPainAt, 1_100);
});

test("remote presenter ignores local player damage for remote visuals", () => {
const harness = createRemotePresenterHarness();
const localPlayer = createPlayer({
playerId: "local-player",
clientId: "local-client",
updatedAt: 1_000,
});

harness.presenter.handleRoomMessage(roomSnapshot([localPlayer]));
assert.equal(harness.visualStates.length, 0);

harness.presenter.handleRoomMessage(roomEvent({
eventType: "player.damaged",
eventId: "damage-local",
roomTime: 1_100,
victimPlayerId: "local-player",
damage: 10,
health: 90,
armor: 0,
}));

assert.equal(harness.damageEvents.length, 0);
});
Loading