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
32 changes: 26 additions & 6 deletions src/App.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ import {
import { isQuakeDebugHooksEnabled } from "./runtime/debug/quakeDebug";
import { createQuakeDebugRecorder } from "./runtime/debug/recording";
import { markQuakeTrace } from "./runtime/debug/traceMarks";
import { shouldSpawnQuakeEntityForCurrentGame, shouldSpawnQuakeEntityForGameMode } from "./runtime/entities";
import {
quakeEntityNumber,
shouldSpawnQuakeEntityForCurrentGame,
shouldSpawnQuakeEntityForGameMode,
} from "./runtime/entities";
import {
applyQuakeInventoryDelta,
changeQuakeInventoryWeaponByImpulse,
Expand Down Expand Up @@ -1013,12 +1017,13 @@ function quakeMultiplayerMenuRoomId(
mapName: string,
forceNew: boolean,
): string {
const staticRoomId = quakeMultiplayerStaticRoomIdForMap(mapName);
if (
!forceNew &&
QUAKE_MULTIPLAYER_ROOM_ID &&
staticRoomId &&
mapName === currentMapName
) {
return QUAKE_MULTIPLAYER_ROOM_ID;
return staticRoomId;
}
if (
!forceNew &&
Expand Down Expand Up @@ -1093,6 +1098,13 @@ function quakeMultiplayerFallbackRoomId(mapName: string): string {
return roomId;
}

function quakeMultiplayerStaticRoomIdForMap(mapName: string): string | null {
if (!QUAKE_MULTIPLAYER_ROOM_ID) return null;
const normalizedMapName = mapName.trim().toLowerCase();
if (quakeMultiplayerCompactInvite && normalizedMapName !== quakeMultiplayerCompactInvite.mapName) return null;
return QUAKE_MULTIPLAYER_ROOM_ID;
}

interface QuakeMultiplayerCompactInvite {
mapName: string;
roomId: string;
Expand Down Expand Up @@ -2185,7 +2197,7 @@ function* quakeDamageableBrushWeaponTargets(): Iterable<QuakeWeaponShootableTarg
for (const entry of quakeDamageableBrushes.snapshot().brushes) {
if (entry.health <= 0) continue;
const entity = entityByIndex.get(entry.entityIndex);
if (!entity || entity.classname !== "func_button" || entity.modelIndex === undefined) continue;
if (!entity || !quakeDamageableBrushCanBeWeaponTarget(entity) || entity.modelIndex === undefined) continue;
const model = sceneResult.models.find((item) => item.index === entity.modelIndex);
if (!model) continue;
const min: Vec3 = [
Expand All @@ -2210,6 +2222,14 @@ function* quakeDamageableBrushWeaponTargets(): Iterable<QuakeWeaponShootableTarg
};
}
}

function quakeDamageableBrushCanBeWeaponTarget(entity: QuakeEntity): boolean {
if (quakeEntityNumber(entity, "health", 0) <= 0) return false;
return entity.classname === "func_button" ||
entity.classname === "trigger_multiple" ||
entity.classname === "trigger_once" ||
entity.classname === "trigger_secret";
}
const quakeMultiplayerWorldRequestAt = new Map<string, number>();
let quakeMultiplayerLastRoomEvent: Record<string, unknown> | null = null;
let quakeMultiplayerRecentRoomEvents: Record<string, unknown>[] = [];
Expand Down Expand Up @@ -3532,7 +3552,7 @@ function applyQuakeMultiplayerInitialSpawnHint(): void {
}

function quakeMultiplayerRoomId(roomKey: QuakeMultiplayerRoomCompatibilityKey): string {
return QUAKE_MULTIPLAYER_ROOM_ID || quakeMultiplayerFallbackRoomId(roomKey.mapName);
return quakeMultiplayerStaticRoomIdForMap(roomKey.mapName) ?? quakeMultiplayerFallbackRoomId(roomKey.mapName);
}

function quakeMultiplayerMatchSettings(): { fragLimit: number; maxPlayers: number } {
Expand Down Expand Up @@ -3617,7 +3637,7 @@ function quakeMultiplayerDebugSnapshot(): Record<string, unknown> {
transport: QUAKE_MULTIPLAYER_TRANSPORT,
partyHost: QUAKE_MULTIPLAYER_TRANSPORT === "party" ? QUAKE_MULTIPLAYER_PARTY_HOST : null,
clientId: QUAKE_MULTIPLAYER_LOCAL_CLIENT_ID,
roomId: QUAKE_MULTIPLAYER_ROOM_ID || quakeMultiplayerFallbackRoomId(currentMapName),
roomId: quakeMultiplayerStaticRoomIdForMap(currentMapName) ?? quakeMultiplayerFallbackRoomId(currentMapName),
localPingMs: quakeMultiplayerLocalPingMs,
sessionState: status.state,
sessionMode: status.mode,
Expand Down
1 change: 1 addition & 0 deletions src/runtime/routeState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export function normalizeQuakeUrlAngle(value: number): number {
export function quakeUrlForMapView(href: string, mapName: string, view: QuakeUrlView | null = null): URL {
const url = new URL(href);
url.searchParams.set("map", mapName);
url.searchParams.delete("room");
if (view) {
setQuakeUrlViewParam(url, quakeUrlViewValue(view));
} else {
Expand Down
14 changes: 12 additions & 2 deletions src/runtime/weapons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1027,7 +1027,7 @@ export function createQuakeWeaponsController({
function traceDamageableBrushTargets(ray: QuakeViewRay): QuakeUseTrace | null {
let best: QuakeUseTrace | null = null;
for (const target of getDamageableBrushTargets?.() ?? []) {
if (target.dead || !isShootableFuncButtonEntity(target.entity)) continue;
if (target.dead || !isWeaponTraceDamageableBrushTarget(target.entity)) continue;
const trace = rayTraceAabb(ray, target.bounds.min, target.bounds.max, 1, target.entity);
if (!trace) continue;
if (!best || trace.fraction < best.fraction) best = trace;
Expand Down Expand Up @@ -1356,7 +1356,9 @@ export function createQuakeWeaponsController({
const ray = viewRayFromDirection(start, normalizeVec3(delta), range);
const worldTrace = getCollisionWorld()?.traceUse?.(ray.origin, ray.end) ?? null;
return (
traceShootables(ray, worldTrace?.fraction ?? 1, projectile.profile.monsterTouchHullExpansion ?? 0) ?? worldTrace
traceShootables(ray, worldTrace?.fraction ?? 1, projectile.profile.monsterTouchHullExpansion ?? 0) ??
traceDamageableBrushTargets(ray) ??
worldTrace
) as QuakeProjectileTrace | null;
}

Expand Down Expand Up @@ -2018,6 +2020,14 @@ function isShootableFuncButtonEntity(entity: QuakeEntity): boolean {
return entity.classname === "func_button" && quakeEntityNumber(entity, "health", 0) > 0;
}

function isWeaponTraceDamageableBrushTarget(entity: QuakeEntity): boolean {
if (isShootableFuncButtonEntity(entity)) return true;
if (quakeEntityNumber(entity, "health", 0) <= 0) return false;
return entity.classname === "trigger_multiple" ||
entity.classname === "trigger_once" ||
entity.classname === "trigger_secret";
}

function forwardDirection(rotX: number, rotY: number): Vec3 {
const rx = (rotX * Math.PI) / 180;
const ry = (rotY * Math.PI) / 180;
Expand Down
82 changes: 80 additions & 2 deletions test/gameplay/weaponDamageableBrushTargets.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,61 @@ test("health func_button brush targets can win over earlier world trace", () =>
assert.equal(trace?.entityIndex, 42);
});

test("health trigger brush targets can win over earlier world trace", () => {
const controller = createWeaponsController({
damageableBrushTargets: [
damageableBrushTarget({
classname: "trigger_multiple",
health: 1,
index: 138,
}),
],
entities: [
quakeEntity({
classname: "trigger_multiple",
health: 1,
index: 138,
}),
],
});

const trace = controller.weaponTraceAtCrosshair();

assert.equal(trace?.classname, "trigger_multiple");
assert.equal(trace?.entityIndex, 138);
});

test("projectiles can damage health trigger brush targets over earlier world trace", () => {
const damagedBrushes = [];
withAnimationFrameWindow((flushFrames) => {
const controller = createWeaponsController({
damageBrushEntity: (entityIndex, amount) => {
damagedBrushes.push({ amount, entityIndex });
return true;
},
damageableBrushTargets: [
damageableBrushTarget({
classname: "trigger_multiple",
health: 1,
index: 138,
}),
],
entities: [
quakeEntity({
classname: "trigger_multiple",
health: 1,
index: 138,
}),
],
});

assert.equal(controller.debugFireProjectile({ directDamage: 1, now: 0 }), true);
flushFrames();
});

assert.deepEqual(damagedBrushes, [{ amount: 1, entityIndex: 138 }]);
});

test("non-button damageable brush targets do not bypass world trace", () => {
const controller = createWeaponsController({
damageableBrushTargets: [
Expand All @@ -53,14 +108,14 @@ test("non-button damageable brush targets do not bypass world trace", () => {
assert.equal(trace?.entityIndex, undefined);
});

function createWeaponsController({ damageableBrushTargets, entities }) {
function createWeaponsController({ damageBrushEntity = () => false, damageableBrushTargets, entities }) {
const entityByIndex = new Map(entities.map((entity) => [entity.index, entity]));
return weapons.createQuakeWeaponsController({
scene: { camera: { state: { rotX: 90, rotY: 180 } } },
controls: { getOrigin: () => [0, 0, 0] },
canUseGameplayInput: () => true,
consumeAmmo: () => undefined,
damageBrushEntity: () => false,
damageBrushEntity,
damagePlayer: () => false,
damageShootable: () => false,
getActiveWeapon: () => "nailgun",
Expand Down Expand Up @@ -100,6 +155,29 @@ function damageableBrushTarget({ classname, health, index }) {
};
}

function withAnimationFrameWindow(callback) {
const previousWindow = globalThis.window;
const frames = [];
globalThis.window = {
requestAnimationFrame(frameCallback) {
frames.push(frameCallback);
return frames.length;
},
cancelAnimationFrame() {},
};
try {
callback(() => {
while (frames.length) frames.shift()(16);
});
} finally {
if (previousWindow === undefined) {
delete globalThis.window;
} else {
globalThis.window = previousWindow;
}
}
}

function quakeEntity({ classname, health, index }) {
return {
classname,
Expand Down
10 changes: 10 additions & 0 deletions test/multiplayer/inviteRegion.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,13 @@ test("compact multiplayer invite routes use the encoded map after region suffixe
assert.equal(route.mapParamValid, true);
assert.equal(route.compactMultiplayerInvitePresent, true);
});

test("map view urls drop compact multiplayer room params", () => {
const url = routeState.quakeUrlForMapView(
"https://quake.example/play?room=06bcdfghjkau&map=e1m7&view=1,2,3,4,5,0",
"e1m1",
);
assert.equal(url.searchParams.get("room"), null);
assert.equal(url.searchParams.get("map"), "e1m1");
assert.equal(url.searchParams.get("view"), null);
});
Loading