diff --git a/src/App.ts b/src/App.ts index 8a024e7..f942fae 100644 --- a/src/App.ts +++ b/src/App.ts @@ -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, @@ -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 && @@ -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; @@ -2185,7 +2197,7 @@ function* quakeDamageableBrushWeaponTargets(): Iterable item.index === entity.modelIndex); if (!model) continue; const min: Vec3 = [ @@ -2210,6 +2222,14 @@ function* quakeDamageableBrushWeaponTargets(): Iterable(); let quakeMultiplayerLastRoomEvent: Record | null = null; let quakeMultiplayerRecentRoomEvents: Record[] = []; @@ -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 } { @@ -3617,7 +3637,7 @@ function quakeMultiplayerDebugSnapshot(): Record { 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, diff --git a/src/runtime/routeState.ts b/src/runtime/routeState.ts index fce9ed1..babb61c 100644 --- a/src/runtime/routeState.ts +++ b/src/runtime/routeState.ts @@ -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 { diff --git a/src/runtime/weapons.ts b/src/runtime/weapons.ts index 3ec2d99..bc13f33 100644 --- a/src/runtime/weapons.ts +++ b/src/runtime/weapons.ts @@ -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; @@ -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; } @@ -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; diff --git a/test/gameplay/weaponDamageableBrushTargets.test.mjs b/test/gameplay/weaponDamageableBrushTargets.test.mjs index 198033d..eb09c0b 100644 --- a/test/gameplay/weaponDamageableBrushTargets.test.mjs +++ b/test/gameplay/weaponDamageableBrushTargets.test.mjs @@ -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: [ @@ -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", @@ -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, diff --git a/test/multiplayer/inviteRegion.test.mjs b/test/multiplayer/inviteRegion.test.mjs index a65ccef..31e502b 100644 --- a/test/multiplayer/inviteRegion.test.mjs +++ b/test/multiplayer/inviteRegion.test.mjs @@ -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); +});