Skip to content

Commit cce772e

Browse files
authored
Merge pull request #37 from LayoutitStudio/damageable-trigger-brush-weapon-hits
Fix damageable trigger brush weapon hits
2 parents 6050993 + fee143e commit cce772e

5 files changed

Lines changed: 129 additions & 10 deletions

File tree

src/App.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,11 @@ import {
3939
import { isQuakeDebugHooksEnabled } from "./runtime/debug/quakeDebug";
4040
import { createQuakeDebugRecorder } from "./runtime/debug/recording";
4141
import { markQuakeTrace } from "./runtime/debug/traceMarks";
42-
import { shouldSpawnQuakeEntityForCurrentGame, shouldSpawnQuakeEntityForGameMode } from "./runtime/entities";
42+
import {
43+
quakeEntityNumber,
44+
shouldSpawnQuakeEntityForCurrentGame,
45+
shouldSpawnQuakeEntityForGameMode,
46+
} from "./runtime/entities";
4347
import {
4448
applyQuakeInventoryDelta,
4549
changeQuakeInventoryWeaponByImpulse,
@@ -1013,12 +1017,13 @@ function quakeMultiplayerMenuRoomId(
10131017
mapName: string,
10141018
forceNew: boolean,
10151019
): string {
1020+
const staticRoomId = quakeMultiplayerStaticRoomIdForMap(mapName);
10161021
if (
10171022
!forceNew &&
1018-
QUAKE_MULTIPLAYER_ROOM_ID &&
1023+
staticRoomId &&
10191024
mapName === currentMapName
10201025
) {
1021-
return QUAKE_MULTIPLAYER_ROOM_ID;
1026+
return staticRoomId;
10221027
}
10231028
if (
10241029
!forceNew &&
@@ -1093,6 +1098,13 @@ function quakeMultiplayerFallbackRoomId(mapName: string): string {
10931098
return roomId;
10941099
}
10951100

1101+
function quakeMultiplayerStaticRoomIdForMap(mapName: string): string | null {
1102+
if (!QUAKE_MULTIPLAYER_ROOM_ID) return null;
1103+
const normalizedMapName = mapName.trim().toLowerCase();
1104+
if (quakeMultiplayerCompactInvite && normalizedMapName !== quakeMultiplayerCompactInvite.mapName) return null;
1105+
return QUAKE_MULTIPLAYER_ROOM_ID;
1106+
}
1107+
10961108
interface QuakeMultiplayerCompactInvite {
10971109
mapName: string;
10981110
roomId: string;
@@ -2185,7 +2197,7 @@ function* quakeDamageableBrushWeaponTargets(): Iterable<QuakeWeaponShootableTarg
21852197
for (const entry of quakeDamageableBrushes.snapshot().brushes) {
21862198
if (entry.health <= 0) continue;
21872199
const entity = entityByIndex.get(entry.entityIndex);
2188-
if (!entity || entity.classname !== "func_button" || entity.modelIndex === undefined) continue;
2200+
if (!entity || !quakeDamageableBrushCanBeWeaponTarget(entity) || entity.modelIndex === undefined) continue;
21892201
const model = sceneResult.models.find((item) => item.index === entity.modelIndex);
21902202
if (!model) continue;
21912203
const min: Vec3 = [
@@ -2210,6 +2222,14 @@ function* quakeDamageableBrushWeaponTargets(): Iterable<QuakeWeaponShootableTarg
22102222
};
22112223
}
22122224
}
2225+
2226+
function quakeDamageableBrushCanBeWeaponTarget(entity: QuakeEntity): boolean {
2227+
if (quakeEntityNumber(entity, "health", 0) <= 0) return false;
2228+
return entity.classname === "func_button" ||
2229+
entity.classname === "trigger_multiple" ||
2230+
entity.classname === "trigger_once" ||
2231+
entity.classname === "trigger_secret";
2232+
}
22132233
const quakeMultiplayerWorldRequestAt = new Map<string, number>();
22142234
let quakeMultiplayerLastRoomEvent: Record<string, unknown> | null = null;
22152235
let quakeMultiplayerRecentRoomEvents: Record<string, unknown>[] = [];
@@ -3532,7 +3552,7 @@ function applyQuakeMultiplayerInitialSpawnHint(): void {
35323552
}
35333553

35343554
function quakeMultiplayerRoomId(roomKey: QuakeMultiplayerRoomCompatibilityKey): string {
3535-
return QUAKE_MULTIPLAYER_ROOM_ID || quakeMultiplayerFallbackRoomId(roomKey.mapName);
3555+
return quakeMultiplayerStaticRoomIdForMap(roomKey.mapName) ?? quakeMultiplayerFallbackRoomId(roomKey.mapName);
35363556
}
35373557

35383558
function quakeMultiplayerMatchSettings(): { fragLimit: number; maxPlayers: number } {
@@ -3617,7 +3637,7 @@ function quakeMultiplayerDebugSnapshot(): Record<string, unknown> {
36173637
transport: QUAKE_MULTIPLAYER_TRANSPORT,
36183638
partyHost: QUAKE_MULTIPLAYER_TRANSPORT === "party" ? QUAKE_MULTIPLAYER_PARTY_HOST : null,
36193639
clientId: QUAKE_MULTIPLAYER_LOCAL_CLIENT_ID,
3620-
roomId: QUAKE_MULTIPLAYER_ROOM_ID || quakeMultiplayerFallbackRoomId(currentMapName),
3640+
roomId: quakeMultiplayerStaticRoomIdForMap(currentMapName) ?? quakeMultiplayerFallbackRoomId(currentMapName),
36213641
localPingMs: quakeMultiplayerLocalPingMs,
36223642
sessionState: status.state,
36233643
sessionMode: status.mode,

src/runtime/routeState.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ export function normalizeQuakeUrlAngle(value: number): number {
108108
export function quakeUrlForMapView(href: string, mapName: string, view: QuakeUrlView | null = null): URL {
109109
const url = new URL(href);
110110
url.searchParams.set("map", mapName);
111+
url.searchParams.delete("room");
111112
if (view) {
112113
setQuakeUrlViewParam(url, quakeUrlViewValue(view));
113114
} else {

src/runtime/weapons.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1027,7 +1027,7 @@ export function createQuakeWeaponsController({
10271027
function traceDamageableBrushTargets(ray: QuakeViewRay): QuakeUseTrace | null {
10281028
let best: QuakeUseTrace | null = null;
10291029
for (const target of getDamageableBrushTargets?.() ?? []) {
1030-
if (target.dead || !isShootableFuncButtonEntity(target.entity)) continue;
1030+
if (target.dead || !isWeaponTraceDamageableBrushTarget(target.entity)) continue;
10311031
const trace = rayTraceAabb(ray, target.bounds.min, target.bounds.max, 1, target.entity);
10321032
if (!trace) continue;
10331033
if (!best || trace.fraction < best.fraction) best = trace;
@@ -1356,7 +1356,9 @@ export function createQuakeWeaponsController({
13561356
const ray = viewRayFromDirection(start, normalizeVec3(delta), range);
13571357
const worldTrace = getCollisionWorld()?.traceUse?.(ray.origin, ray.end) ?? null;
13581358
return (
1359-
traceShootables(ray, worldTrace?.fraction ?? 1, projectile.profile.monsterTouchHullExpansion ?? 0) ?? worldTrace
1359+
traceShootables(ray, worldTrace?.fraction ?? 1, projectile.profile.monsterTouchHullExpansion ?? 0) ??
1360+
traceDamageableBrushTargets(ray) ??
1361+
worldTrace
13601362
) as QuakeProjectileTrace | null;
13611363
}
13621364

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

2023+
function isWeaponTraceDamageableBrushTarget(entity: QuakeEntity): boolean {
2024+
if (isShootableFuncButtonEntity(entity)) return true;
2025+
if (quakeEntityNumber(entity, "health", 0) <= 0) return false;
2026+
return entity.classname === "trigger_multiple" ||
2027+
entity.classname === "trigger_once" ||
2028+
entity.classname === "trigger_secret";
2029+
}
2030+
20212031
function forwardDirection(rotX: number, rotY: number): Vec3 {
20222032
const rx = (rotX * Math.PI) / 180;
20232033
const ry = (rotY * Math.PI) / 180;

test/gameplay/weaponDamageableBrushTargets.test.mjs

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,61 @@ test("health func_button brush targets can win over earlier world trace", () =>
2929
assert.equal(trace?.entityIndex, 42);
3030
});
3131

32+
test("health trigger brush targets can win over earlier world trace", () => {
33+
const controller = createWeaponsController({
34+
damageableBrushTargets: [
35+
damageableBrushTarget({
36+
classname: "trigger_multiple",
37+
health: 1,
38+
index: 138,
39+
}),
40+
],
41+
entities: [
42+
quakeEntity({
43+
classname: "trigger_multiple",
44+
health: 1,
45+
index: 138,
46+
}),
47+
],
48+
});
49+
50+
const trace = controller.weaponTraceAtCrosshair();
51+
52+
assert.equal(trace?.classname, "trigger_multiple");
53+
assert.equal(trace?.entityIndex, 138);
54+
});
55+
56+
test("projectiles can damage health trigger brush targets over earlier world trace", () => {
57+
const damagedBrushes = [];
58+
withAnimationFrameWindow((flushFrames) => {
59+
const controller = createWeaponsController({
60+
damageBrushEntity: (entityIndex, amount) => {
61+
damagedBrushes.push({ amount, entityIndex });
62+
return true;
63+
},
64+
damageableBrushTargets: [
65+
damageableBrushTarget({
66+
classname: "trigger_multiple",
67+
health: 1,
68+
index: 138,
69+
}),
70+
],
71+
entities: [
72+
quakeEntity({
73+
classname: "trigger_multiple",
74+
health: 1,
75+
index: 138,
76+
}),
77+
],
78+
});
79+
80+
assert.equal(controller.debugFireProjectile({ directDamage: 1, now: 0 }), true);
81+
flushFrames();
82+
});
83+
84+
assert.deepEqual(damagedBrushes, [{ amount: 1, entityIndex: 138 }]);
85+
});
86+
3287
test("non-button damageable brush targets do not bypass world trace", () => {
3388
const controller = createWeaponsController({
3489
damageableBrushTargets: [
@@ -53,14 +108,14 @@ test("non-button damageable brush targets do not bypass world trace", () => {
53108
assert.equal(trace?.entityIndex, undefined);
54109
});
55110

56-
function createWeaponsController({ damageableBrushTargets, entities }) {
111+
function createWeaponsController({ damageBrushEntity = () => false, damageableBrushTargets, entities }) {
57112
const entityByIndex = new Map(entities.map((entity) => [entity.index, entity]));
58113
return weapons.createQuakeWeaponsController({
59114
scene: { camera: { state: { rotX: 90, rotY: 180 } } },
60115
controls: { getOrigin: () => [0, 0, 0] },
61116
canUseGameplayInput: () => true,
62117
consumeAmmo: () => undefined,
63-
damageBrushEntity: () => false,
118+
damageBrushEntity,
64119
damagePlayer: () => false,
65120
damageShootable: () => false,
66121
getActiveWeapon: () => "nailgun",
@@ -100,6 +155,29 @@ function damageableBrushTarget({ classname, health, index }) {
100155
};
101156
}
102157

158+
function withAnimationFrameWindow(callback) {
159+
const previousWindow = globalThis.window;
160+
const frames = [];
161+
globalThis.window = {
162+
requestAnimationFrame(frameCallback) {
163+
frames.push(frameCallback);
164+
return frames.length;
165+
},
166+
cancelAnimationFrame() {},
167+
};
168+
try {
169+
callback(() => {
170+
while (frames.length) frames.shift()(16);
171+
});
172+
} finally {
173+
if (previousWindow === undefined) {
174+
delete globalThis.window;
175+
} else {
176+
globalThis.window = previousWindow;
177+
}
178+
}
179+
}
180+
103181
function quakeEntity({ classname, health, index }) {
104182
return {
105183
classname,

test/multiplayer/inviteRegion.test.mjs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,13 @@ test("compact multiplayer invite routes use the encoded map after region suffixe
6060
assert.equal(route.mapParamValid, true);
6161
assert.equal(route.compactMultiplayerInvitePresent, true);
6262
});
63+
64+
test("map view urls drop compact multiplayer room params", () => {
65+
const url = routeState.quakeUrlForMapView(
66+
"https://quake.example/play?room=06bcdfghjkau&map=e1m7&view=1,2,3,4,5,0",
67+
"e1m1",
68+
);
69+
assert.equal(url.searchParams.get("room"), null);
70+
assert.equal(url.searchParams.get("map"), "e1m1");
71+
assert.equal(url.searchParams.get("view"), null);
72+
});

0 commit comments

Comments
 (0)