diff --git a/src/App.ts b/src/App.ts index b5bcf96..b35e239 100644 --- a/src/App.ts +++ b/src/App.ts @@ -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 = { @@ -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({ @@ -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, + 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, +): boolean { + return event.attackerPlayerId === quakeMultiplayerPlayerIdForClient(QUAKE_MULTIPLAYER_LOCAL_CLIENT_ID); +} + function handleQuakeMultiplayerPlayerKilled( event: Extract, ): void { diff --git a/src/runtime/multiplayer/presentation.ts b/src/runtime/multiplayer/presentation.ts index 0b3abf2..c578678 100644 --- a/src/runtime/multiplayer/presentation.ts +++ b/src/runtime/multiplayer/presentation.ts @@ -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; @@ -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; @@ -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") { @@ -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(); } diff --git a/test/multiplayer/presentation.test.mjs b/test/multiplayer/presentation.test.mjs new file mode 100644 index 0000000..87b1d9b --- /dev/null +++ b/test/multiplayer/presentation.test.mjs @@ -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); +});