@quake2ts/shared 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/dist/browser/index.global.js +2 -0
  2. package/dist/browser/index.global.js.map +1 -0
  3. package/dist/cjs/index.cjs +6569 -0
  4. package/dist/cjs/index.cjs.map +1 -0
  5. package/dist/esm/index.js +6200 -0
  6. package/dist/esm/index.js.map +1 -0
  7. package/dist/tsconfig.tsbuildinfo +1 -0
  8. package/dist/types/audio/constants.d.ts +24 -0
  9. package/dist/types/audio/constants.d.ts.map +1 -0
  10. package/dist/types/bsp/collision.d.ts +201 -0
  11. package/dist/types/bsp/collision.d.ts.map +1 -0
  12. package/dist/types/bsp/contents.d.ts +72 -0
  13. package/dist/types/bsp/contents.d.ts.map +1 -0
  14. package/dist/types/bsp/spatial.d.ts +13 -0
  15. package/dist/types/bsp/spatial.d.ts.map +1 -0
  16. package/dist/types/index.d.ts +38 -0
  17. package/dist/types/index.d.ts.map +1 -0
  18. package/dist/types/inventory-helpers.d.ts +19 -0
  19. package/dist/types/inventory-helpers.d.ts.map +1 -0
  20. package/dist/types/io/binaryStream.d.ts +38 -0
  21. package/dist/types/io/binaryStream.d.ts.map +1 -0
  22. package/dist/types/io/binaryWriter.d.ts +26 -0
  23. package/dist/types/io/binaryWriter.d.ts.map +1 -0
  24. package/dist/types/io/index.d.ts +4 -0
  25. package/dist/types/io/index.d.ts.map +1 -0
  26. package/dist/types/io/messageBuilder.d.ts +21 -0
  27. package/dist/types/io/messageBuilder.d.ts.map +1 -0
  28. package/dist/types/items/ammo.d.ts +40 -0
  29. package/dist/types/items/ammo.d.ts.map +1 -0
  30. package/dist/types/items/index.d.ts +8 -0
  31. package/dist/types/items/index.d.ts.map +1 -0
  32. package/dist/types/items/powerups.d.ts +31 -0
  33. package/dist/types/items/powerups.d.ts.map +1 -0
  34. package/dist/types/items/weaponInfo.d.ts +5 -0
  35. package/dist/types/items/weaponInfo.d.ts.map +1 -0
  36. package/dist/types/items/weapons.d.ts +27 -0
  37. package/dist/types/items/weapons.d.ts.map +1 -0
  38. package/dist/types/math/angles.d.ts +19 -0
  39. package/dist/types/math/angles.d.ts.map +1 -0
  40. package/dist/types/math/anorms.d.ts +2 -0
  41. package/dist/types/math/anorms.d.ts.map +1 -0
  42. package/dist/types/math/color.d.ts +12 -0
  43. package/dist/types/math/color.d.ts.map +1 -0
  44. package/dist/types/math/mat4.d.ts +7 -0
  45. package/dist/types/math/mat4.d.ts.map +1 -0
  46. package/dist/types/math/random.d.ts +60 -0
  47. package/dist/types/math/random.d.ts.map +1 -0
  48. package/dist/types/math/vec3.d.ts +79 -0
  49. package/dist/types/math/vec3.d.ts.map +1 -0
  50. package/dist/types/net/driver.d.ts +10 -0
  51. package/dist/types/net/driver.d.ts.map +1 -0
  52. package/dist/types/net/index.d.ts +3 -0
  53. package/dist/types/net/index.d.ts.map +1 -0
  54. package/dist/types/net/netchan.d.ts +85 -0
  55. package/dist/types/net/netchan.d.ts.map +1 -0
  56. package/dist/types/pmove/apply.d.ts +5 -0
  57. package/dist/types/pmove/apply.d.ts.map +1 -0
  58. package/dist/types/pmove/categorize.d.ts +36 -0
  59. package/dist/types/pmove/categorize.d.ts.map +1 -0
  60. package/dist/types/pmove/config.d.ts +5 -0
  61. package/dist/types/pmove/config.d.ts.map +1 -0
  62. package/dist/types/pmove/constants.d.ts +76 -0
  63. package/dist/types/pmove/constants.d.ts.map +1 -0
  64. package/dist/types/pmove/currents.d.ts +58 -0
  65. package/dist/types/pmove/currents.d.ts.map +1 -0
  66. package/dist/types/pmove/dimensions.d.ts +14 -0
  67. package/dist/types/pmove/dimensions.d.ts.map +1 -0
  68. package/dist/types/pmove/duck.d.ts +39 -0
  69. package/dist/types/pmove/duck.d.ts.map +1 -0
  70. package/dist/types/pmove/fly.d.ts +34 -0
  71. package/dist/types/pmove/fly.d.ts.map +1 -0
  72. package/dist/types/pmove/index.d.ts +18 -0
  73. package/dist/types/pmove/index.d.ts.map +1 -0
  74. package/dist/types/pmove/jump.d.ts +28 -0
  75. package/dist/types/pmove/jump.d.ts.map +1 -0
  76. package/dist/types/pmove/move.d.ts +78 -0
  77. package/dist/types/pmove/move.d.ts.map +1 -0
  78. package/dist/types/pmove/pmove.d.ts +40 -0
  79. package/dist/types/pmove/pmove.d.ts.map +1 -0
  80. package/dist/types/pmove/slide.d.ts +63 -0
  81. package/dist/types/pmove/slide.d.ts.map +1 -0
  82. package/dist/types/pmove/snap.d.ts +40 -0
  83. package/dist/types/pmove/snap.d.ts.map +1 -0
  84. package/dist/types/pmove/special.d.ts +39 -0
  85. package/dist/types/pmove/special.d.ts.map +1 -0
  86. package/dist/types/pmove/stuck.d.ts +21 -0
  87. package/dist/types/pmove/stuck.d.ts.map +1 -0
  88. package/dist/types/pmove/types.d.ts +72 -0
  89. package/dist/types/pmove/types.d.ts.map +1 -0
  90. package/dist/types/pmove/view.d.ts +19 -0
  91. package/dist/types/pmove/view.d.ts.map +1 -0
  92. package/dist/types/pmove/water.d.ts +21 -0
  93. package/dist/types/pmove/water.d.ts.map +1 -0
  94. package/dist/types/protocol/bitpack.d.ts +17 -0
  95. package/dist/types/protocol/bitpack.d.ts.map +1 -0
  96. package/dist/types/protocol/configstrings.d.ts +73 -0
  97. package/dist/types/protocol/configstrings.d.ts.map +1 -0
  98. package/dist/types/protocol/constants.d.ts +36 -0
  99. package/dist/types/protocol/constants.d.ts.map +1 -0
  100. package/dist/types/protocol/contracts.d.ts +17 -0
  101. package/dist/types/protocol/contracts.d.ts.map +1 -0
  102. package/dist/types/protocol/crc.d.ts +5 -0
  103. package/dist/types/protocol/crc.d.ts.map +1 -0
  104. package/dist/types/protocol/cvar.d.ts +15 -0
  105. package/dist/types/protocol/cvar.d.ts.map +1 -0
  106. package/dist/types/protocol/effects.d.ts +33 -0
  107. package/dist/types/protocol/effects.d.ts.map +1 -0
  108. package/dist/types/protocol/entity.d.ts +46 -0
  109. package/dist/types/protocol/entity.d.ts.map +1 -0
  110. package/dist/types/protocol/entityEvent.d.ts +13 -0
  111. package/dist/types/protocol/entityEvent.d.ts.map +1 -0
  112. package/dist/types/protocol/entityState.d.ts +26 -0
  113. package/dist/types/protocol/entityState.d.ts.map +1 -0
  114. package/dist/types/protocol/index.d.ts +19 -0
  115. package/dist/types/protocol/index.d.ts.map +1 -0
  116. package/dist/types/protocol/layout.d.ts +9 -0
  117. package/dist/types/protocol/layout.d.ts.map +1 -0
  118. package/dist/types/protocol/ops.d.ts +44 -0
  119. package/dist/types/protocol/ops.d.ts.map +1 -0
  120. package/dist/types/protocol/player-state.d.ts +40 -0
  121. package/dist/types/protocol/player-state.d.ts.map +1 -0
  122. package/dist/types/protocol/player.d.ts +28 -0
  123. package/dist/types/protocol/player.d.ts.map +1 -0
  124. package/dist/types/protocol/renderFx.d.ts +23 -0
  125. package/dist/types/protocol/renderFx.d.ts.map +1 -0
  126. package/dist/types/protocol/stats.d.ts +61 -0
  127. package/dist/types/protocol/stats.d.ts.map +1 -0
  128. package/dist/types/protocol/tempEntity.d.ts +67 -0
  129. package/dist/types/protocol/tempEntity.d.ts.map +1 -0
  130. package/dist/types/protocol/usercmd.d.ts +33 -0
  131. package/dist/types/protocol/usercmd.d.ts.map +1 -0
  132. package/dist/types/protocol/writeUserCmd.d.ts +4 -0
  133. package/dist/types/protocol/writeUserCmd.d.ts.map +1 -0
  134. package/dist/types/replay/index.d.ts +3 -0
  135. package/dist/types/replay/index.d.ts.map +1 -0
  136. package/dist/types/replay/io.d.ts +7 -0
  137. package/dist/types/replay/io.d.ts.map +1 -0
  138. package/dist/types/replay/schema.d.ts +41 -0
  139. package/dist/types/replay/schema.d.ts.map +1 -0
  140. package/dist/types/testing.d.ts +6 -0
  141. package/dist/types/testing.d.ts.map +1 -0
  142. package/package.json +43 -0
  143. package/src/audio/constants.ts +35 -0
  144. package/src/bsp/collision.ts +1075 -0
  145. package/src/bsp/contents.ts +108 -0
  146. package/src/bsp/spatial.ts +116 -0
  147. package/src/index.ts +37 -0
  148. package/src/inventory-helpers.ts +81 -0
  149. package/src/io/binaryStream.ts +159 -0
  150. package/src/io/binaryWriter.ts +146 -0
  151. package/src/io/index.ts +3 -0
  152. package/src/io/messageBuilder.ts +117 -0
  153. package/src/items/ammo.ts +47 -0
  154. package/src/items/index.ts +8 -0
  155. package/src/items/powerups.ts +32 -0
  156. package/src/items/weaponInfo.ts +45 -0
  157. package/src/items/weapons.ts +28 -0
  158. package/src/math/angles.ts +135 -0
  159. package/src/math/anorms.ts +165 -0
  160. package/src/math/color.ts +42 -0
  161. package/src/math/mat4.ts +58 -0
  162. package/src/math/random.ts +182 -0
  163. package/src/math/vec3.ts +379 -0
  164. package/src/net/driver.ts +9 -0
  165. package/src/net/index.ts +2 -0
  166. package/src/net/netchan.ts +451 -0
  167. package/src/pmove/apply.ts +151 -0
  168. package/src/pmove/categorize.ts +162 -0
  169. package/src/pmove/config.ts +5 -0
  170. package/src/pmove/constants.ts +94 -0
  171. package/src/pmove/currents.ts +287 -0
  172. package/src/pmove/dimensions.ts +40 -0
  173. package/src/pmove/duck.ts +154 -0
  174. package/src/pmove/fly.ts +197 -0
  175. package/src/pmove/index.ts +18 -0
  176. package/src/pmove/jump.ts +92 -0
  177. package/src/pmove/move.ts +527 -0
  178. package/src/pmove/pmove.ts +446 -0
  179. package/src/pmove/slide.ts +267 -0
  180. package/src/pmove/snap.ts +89 -0
  181. package/src/pmove/special.ts +207 -0
  182. package/src/pmove/stuck.ts +258 -0
  183. package/src/pmove/types.ts +82 -0
  184. package/src/pmove/view.ts +57 -0
  185. package/src/pmove/water.ts +56 -0
  186. package/src/protocol/bitpack.ts +139 -0
  187. package/src/protocol/configstrings.ts +104 -0
  188. package/src/protocol/constants.ts +40 -0
  189. package/src/protocol/contracts.ts +149 -0
  190. package/src/protocol/crc.ts +32 -0
  191. package/src/protocol/cvar.ts +15 -0
  192. package/src/protocol/effects.ts +33 -0
  193. package/src/protocol/entity.ts +304 -0
  194. package/src/protocol/entityEvent.ts +14 -0
  195. package/src/protocol/entityState.ts +28 -0
  196. package/src/protocol/index.ts +19 -0
  197. package/src/protocol/layout.ts +9 -0
  198. package/src/protocol/ops.ts +49 -0
  199. package/src/protocol/player-state.ts +51 -0
  200. package/src/protocol/player.ts +165 -0
  201. package/src/protocol/renderFx.ts +22 -0
  202. package/src/protocol/stats.ts +161 -0
  203. package/src/protocol/tempEntity.ts +69 -0
  204. package/src/protocol/usercmd.ts +63 -0
  205. package/src/protocol/writeUserCmd.ts +30 -0
  206. package/src/replay/index.ts +2 -0
  207. package/src/replay/io.ts +37 -0
  208. package/src/replay/schema.ts +42 -0
  209. package/src/testing.ts +200 -0
@@ -0,0 +1,1075 @@
1
+ import { CONTENTS_TRIGGER } from './contents.js';
2
+ import type { Vec3 } from '../math/vec3.js';
3
+ import { ZERO_VEC3, addVec3, scaleVec3, subtractVec3 } from '../math/vec3.js';
4
+ import { createSpatialTree, linkEntityToSpatialTree, querySpatialTree, SpatialNode } from './spatial.js';
5
+
6
+ export interface CollisionPlane {
7
+ normal: Vec3;
8
+ dist: number;
9
+ type: number;
10
+ signbits: number;
11
+ }
12
+
13
+ export interface CollisionBrushSide {
14
+ plane: CollisionPlane;
15
+ surfaceFlags: number;
16
+ }
17
+
18
+ export interface CollisionBrush {
19
+ contents: number;
20
+ sides: CollisionBrushSide[];
21
+ checkcount?: number;
22
+ }
23
+
24
+ export interface CollisionLeaf {
25
+ contents: number;
26
+ cluster: number;
27
+ area: number;
28
+ firstLeafBrush: number;
29
+ numLeafBrushes: number;
30
+ }
31
+
32
+ export interface CollisionNode {
33
+ plane: CollisionPlane;
34
+ children: [number, number];
35
+ }
36
+
37
+ export interface CollisionBmodel {
38
+ mins: Vec3;
39
+ maxs: Vec3;
40
+ origin: Vec3;
41
+ headnode: number;
42
+ }
43
+
44
+ export interface CollisionModel {
45
+ planes: CollisionPlane[];
46
+ nodes: CollisionNode[];
47
+ leaves: CollisionLeaf[];
48
+ brushes: CollisionBrush[];
49
+ leafBrushes: number[];
50
+ bmodels: CollisionBmodel[];
51
+ visibility?: CollisionVisibility;
52
+ }
53
+
54
+ export interface CollisionVisibilityCluster {
55
+ pvs: Uint8Array;
56
+ phs: Uint8Array;
57
+ }
58
+
59
+ export interface CollisionVisibility {
60
+ numClusters: number;
61
+ clusters: readonly CollisionVisibilityCluster[];
62
+ }
63
+
64
+ export interface CollisionLumpData {
65
+ planes: Array<{ normal: Vec3; dist: number; type: number }>;
66
+ nodes: Array<{ planenum: number; children: [number, number] }>;
67
+ leaves: Array<{ contents: number; cluster: number; area: number; firstLeafBrush: number; numLeafBrushes: number }>;
68
+ brushes: Array<{ firstSide: number; numSides: number; contents: number }>;
69
+ brushSides: Array<{ planenum: number; surfaceFlags: number }>;
70
+ leafBrushes: number[];
71
+ bmodels: Array<{ mins: Vec3; maxs: Vec3; origin: Vec3; headnode: number }>;
72
+ visibility?: CollisionVisibility;
73
+ }
74
+
75
+ export interface TraceResult {
76
+ fraction: number;
77
+ plane: CollisionPlane | null;
78
+ contents: number;
79
+ surfaceFlags: number;
80
+ startsolid: boolean;
81
+ allsolid: boolean;
82
+ }
83
+
84
+ export interface CollisionTraceResult {
85
+ fraction: number;
86
+ endpos: Vec3;
87
+ plane: CollisionPlane | null;
88
+ planeNormal?: Vec3;
89
+ contents?: number;
90
+ surfaceFlags?: number;
91
+ startsolid: boolean;
92
+ allsolid: boolean;
93
+ }
94
+
95
+ export enum PlaneSide {
96
+ FRONT = 1,
97
+ BACK = 2,
98
+ CROSS = 3,
99
+ }
100
+
101
+ export interface TraceDebugInfo {
102
+ nodesTraversed: number;
103
+ leafsReached: number;
104
+ brushesTested: number;
105
+ }
106
+
107
+ export let traceDebugInfo: TraceDebugInfo | null = null;
108
+
109
+ export function enableTraceDebug(): void {
110
+ traceDebugInfo = { nodesTraversed: 0, leafsReached: 0, brushesTested: 0 };
111
+ // console.log('DEBUG: Trace debug enabled');
112
+ }
113
+
114
+ export function disableTraceDebug(): void {
115
+ traceDebugInfo = null;
116
+ }
117
+
118
+ export const DIST_EPSILON = 0.03125;
119
+
120
+ const MAX_CHECKCOUNT = Number.MAX_SAFE_INTEGER - 1;
121
+ let globalBrushCheckCount = 1;
122
+
123
+ export function buildCollisionModel(lumps: CollisionLumpData): CollisionModel {
124
+ const planes: CollisionPlane[] = lumps.planes.map((plane) => ({
125
+ ...plane,
126
+ signbits: computePlaneSignBits(plane.normal),
127
+ }));
128
+
129
+ const nodes: CollisionNode[] = lumps.nodes.map((node) => ({
130
+ plane: planes[node.planenum],
131
+ children: node.children,
132
+ }));
133
+
134
+ const brushes: CollisionBrush[] = lumps.brushes.map((brush) => {
135
+ const sides = lumps.brushSides.slice(brush.firstSide, brush.firstSide + brush.numSides).map((side) => ({
136
+ plane: planes[side.planenum],
137
+ surfaceFlags: side.surfaceFlags,
138
+ }));
139
+
140
+ return {
141
+ contents: brush.contents,
142
+ sides,
143
+ checkcount: 0,
144
+ };
145
+ });
146
+
147
+ const leaves: CollisionLeaf[] = lumps.leaves.map((leaf) => ({
148
+ contents: leaf.contents,
149
+ cluster: leaf.cluster,
150
+ area: leaf.area,
151
+ firstLeafBrush: leaf.firstLeafBrush,
152
+ numLeafBrushes: leaf.numLeafBrushes,
153
+ }));
154
+
155
+ const bmodels: CollisionBmodel[] = lumps.bmodels.map((model) => ({
156
+ mins: model.mins,
157
+ maxs: model.maxs,
158
+ origin: model.origin,
159
+ headnode: model.headnode,
160
+ }));
161
+
162
+ return {
163
+ planes,
164
+ nodes,
165
+ leaves,
166
+ brushes,
167
+ leafBrushes: lumps.leafBrushes,
168
+ bmodels,
169
+ visibility: lumps.visibility,
170
+ };
171
+ }
172
+
173
+ export function computePlaneSignBits(normal: Vec3): number {
174
+ let bits = 0;
175
+ if (normal.x < 0) bits |= 1;
176
+ if (normal.y < 0) bits |= 2;
177
+ if (normal.z < 0) bits |= 4;
178
+ return bits;
179
+ }
180
+
181
+ export function planeDistanceToPoint(plane: CollisionPlane, point: Vec3): number {
182
+ return plane.normal.x * point.x + plane.normal.y * point.y + plane.normal.z * point.z - plane.dist;
183
+ }
184
+
185
+ export function pointOnPlaneSide(plane: CollisionPlane, point: Vec3, epsilon = 0): PlaneSide.FRONT | PlaneSide.BACK | PlaneSide.CROSS {
186
+ const dist = planeDistanceToPoint(plane, point);
187
+ if (dist > epsilon) {
188
+ return PlaneSide.FRONT;
189
+ }
190
+ if (dist < -epsilon) {
191
+ return PlaneSide.BACK;
192
+ }
193
+ return PlaneSide.CROSS;
194
+ }
195
+
196
+ export function boxOnPlaneSide(mins: Vec3, maxs: Vec3, plane: CollisionPlane, epsilon = 0): PlaneSide {
197
+ let dist1: number;
198
+ let dist2: number;
199
+
200
+ switch (plane.signbits) {
201
+ case 0:
202
+ dist1 = plane.normal.x * maxs.x + plane.normal.y * maxs.y + plane.normal.z * maxs.z;
203
+ dist2 = plane.normal.x * mins.x + plane.normal.y * mins.y + plane.normal.z * mins.z;
204
+ break;
205
+ case 1:
206
+ dist1 = plane.normal.x * mins.x + plane.normal.y * maxs.y + plane.normal.z * maxs.z;
207
+ dist2 = plane.normal.x * maxs.x + plane.normal.y * mins.y + plane.normal.z * mins.z;
208
+ break;
209
+ case 2:
210
+ dist1 = plane.normal.x * maxs.x + plane.normal.y * mins.y + plane.normal.z * maxs.z;
211
+ dist2 = plane.normal.x * mins.x + plane.normal.y * maxs.y + plane.normal.z * mins.z;
212
+ break;
213
+ case 3:
214
+ dist1 = plane.normal.x * mins.x + plane.normal.y * mins.y + plane.normal.z * maxs.z;
215
+ dist2 = plane.normal.x * maxs.x + plane.normal.y * maxs.y + plane.normal.z * mins.z;
216
+ break;
217
+ case 4:
218
+ dist1 = plane.normal.x * maxs.x + plane.normal.y * maxs.y + plane.normal.z * mins.z;
219
+ dist2 = plane.normal.x * mins.x + plane.normal.y * mins.y + plane.normal.z * maxs.z;
220
+ break;
221
+ case 5:
222
+ dist1 = plane.normal.x * mins.x + plane.normal.y * maxs.y + plane.normal.z * mins.z;
223
+ dist2 = plane.normal.x * maxs.x + plane.normal.y * mins.y + plane.normal.z * maxs.z;
224
+ break;
225
+ case 6:
226
+ dist1 = plane.normal.x * maxs.x + plane.normal.y * mins.y + plane.normal.z * mins.z;
227
+ dist2 = plane.normal.x * mins.x + plane.normal.y * maxs.y + plane.normal.z * maxs.z;
228
+ break;
229
+ default:
230
+ dist1 = plane.normal.x * mins.x + plane.normal.y * mins.y + plane.normal.z * mins.z;
231
+ dist2 = plane.normal.x * maxs.x + plane.normal.y * maxs.y + plane.normal.z * maxs.z;
232
+ break;
233
+ }
234
+
235
+ let sides = 0;
236
+ if (dist1 - plane.dist >= -epsilon) sides = PlaneSide.FRONT;
237
+ if (dist2 - plane.dist <= epsilon) sides |= PlaneSide.BACK;
238
+ return sides as PlaneSide;
239
+ }
240
+
241
+ export function pointInsideBrush(point: Vec3, brush: CollisionBrush, epsilon = DIST_EPSILON): boolean {
242
+ for (const side of brush.sides) {
243
+ const dist = planeDistanceToPoint(side.plane, point);
244
+ if (dist > epsilon) {
245
+ return false;
246
+ }
247
+ }
248
+ return true;
249
+ }
250
+
251
+ export interface BoxBrushTestResult {
252
+ startsolid: boolean;
253
+ allsolid: boolean;
254
+ contents: number;
255
+ }
256
+
257
+ export function testBoxInBrush(origin: Vec3, mins: Vec3, maxs: Vec3, brush: CollisionBrush): BoxBrushTestResult {
258
+ for (const side of brush.sides) {
259
+ const offset = side.plane.normal.x * (side.plane.normal.x < 0 ? maxs.x : mins.x) +
260
+ side.plane.normal.y * (side.plane.normal.y < 0 ? maxs.y : mins.y) +
261
+ side.plane.normal.z * (side.plane.normal.z < 0 ? maxs.z : mins.z);
262
+
263
+ const dist = side.plane.dist - offset;
264
+ const d1 = origin.x * side.plane.normal.x + origin.y * side.plane.normal.y + origin.z * side.plane.normal.z - dist;
265
+
266
+ if (d1 > 0) {
267
+ return { startsolid: false, allsolid: false, contents: 0 };
268
+ }
269
+ }
270
+
271
+ return { startsolid: true, allsolid: true, contents: brush.contents };
272
+ }
273
+
274
+ export interface ClipBoxParams {
275
+ start: Vec3;
276
+ end: Vec3;
277
+ mins: Vec3;
278
+ maxs: Vec3;
279
+ brush: CollisionBrush;
280
+ trace: TraceResult;
281
+ }
282
+
283
+ /**
284
+ * Clips a movement box against a brush using the Liang-Barsky algorithm.
285
+ *
286
+ * This function determines if and where the swept box (from start to end) intersects the brush.
287
+ * It works by clipping the movement line segment against the infinite planes defined by the brush sides.
288
+ *
289
+ * Algorithm Overview (Liang-Barsky):
290
+ * The movement is treated as a parameterized line P(t) = Start + t * (End - Start), for t in [0, 1].
291
+ * We maintain an interval [enterfrac, leavefrac] (initially [-1, 1]) representing the portion of the
292
+ * line that is potentially inside the brush.
293
+ * For each plane:
294
+ * 1. We determine the distance of Start (d1) and End (d2) from the plane.
295
+ * - For box traces, planes are expanded by the box extents, effectively treating the box as a point.
296
+ * 2. If both points are in front (outside), the line is outside the brush.
297
+ * 3. If the line crosses the plane:
298
+ * - If entering (d1 > d2), we update `enterfrac` (max of entry times).
299
+ * - If leaving (d1 < d2), we update `leavefrac` (min of exit times).
300
+ * 4. If at any point enterfrac > leavefrac, the line misses the brush.
301
+ *
302
+ * @see CM_ClipBoxToBrush in qcommon/cm_trace.c:145-220
303
+ *
304
+ * @param params ClipBoxParams containing start/end vectors, box mins/maxs, target brush, and trace result to update.
305
+ */
306
+ export function clipBoxToBrush({ start, end, mins, maxs, brush, trace }: ClipBoxParams): void {
307
+ if (brush.sides.length === 0) return;
308
+
309
+ const isPoint = mins.x === 0 && mins.y === 0 && mins.z === 0 && maxs.x === 0 && maxs.y === 0 && maxs.z === 0;
310
+
311
+ // enterfrac: The fraction of movement where the box FIRST fully enters the brush volume (intersection start).
312
+ // leavefrac: The fraction of movement where the box STARTS to leave the brush volume (intersection end).
313
+ // Initialized to -1 and 1 to cover the full potential range + buffers.
314
+ let enterfrac = -1;
315
+ let leavefrac = 1;
316
+ let clipplane: CollisionPlane | null = null;
317
+ let leadside: CollisionBrushSide | null = null;
318
+
319
+ let getout = false; // True if the end point is outside at least one plane (not trapped in brush)
320
+ let startout = false; // True if the start point is outside at least one plane (not starting stuck)
321
+
322
+ for (const side of brush.sides) {
323
+ const { plane } = side;
324
+ let dist = plane.dist;
325
+
326
+ // Expand the plane by the box extents to perform a point-plane test.
327
+ // This reduces the AABB sweep vs convex brush problem to a line segment vs expanded planes problem.
328
+ if (!isPoint) {
329
+ const ofsX = plane.normal.x < 0 ? maxs.x : mins.x;
330
+ const ofsY = plane.normal.y < 0 ? maxs.y : mins.y;
331
+ const ofsZ = plane.normal.z < 0 ? maxs.z : mins.z;
332
+ dist -= plane.normal.x * ofsX + plane.normal.y * ofsY + plane.normal.z * ofsZ;
333
+ }
334
+
335
+ // d1: Distance of start point from the (expanded) plane. Positive = in front (outside).
336
+ // d2: Distance of end point from the (expanded) plane.
337
+ const d1 = start.x * plane.normal.x + start.y * plane.normal.y + start.z * plane.normal.z - dist;
338
+ const d2 = end.x * plane.normal.x + end.y * plane.normal.y + end.z * plane.normal.z - dist;
339
+
340
+ if (d2 > 0) getout = true;
341
+ if (d1 > 0) startout = true;
342
+
343
+ // Case 1: Entirely outside this plane.
344
+ // Since brushes are convex intersections of half-spaces (defined by planes pointing OUT),
345
+ // being in front of ANY plane means being outside the brush.
346
+ // The d2 >= d1 check handles the case where the line is parallel or moving away from the plane.
347
+ if (d1 > 0 && d2 >= d1) {
348
+ return;
349
+ }
350
+
351
+ // Case 2: Entirely inside this plane (back side).
352
+ // Does not restrict the entry/exit interval further than other planes might.
353
+ if (d1 <= 0 && d2 <= 0) {
354
+ continue;
355
+ }
356
+
357
+ // Case 3: Line intersects the plane.
358
+ // d1 > d2 means we are moving from Front (outside) to Back (inside) -> Entering.
359
+ if (d1 > d2) {
360
+ // Calculate intersection fraction f.
361
+ // DIST_EPSILON is subtracted to ensure we stop slightly *before* the plane,
362
+ // preventing the object from getting stuck in the next frame due to float precision.
363
+ const f = (d1 - DIST_EPSILON) / (d1 - d2);
364
+ if (f > enterfrac) {
365
+ enterfrac = f;
366
+ clipplane = plane;
367
+ leadside = side;
368
+ }
369
+ } else {
370
+ // Moving from Back (inside) to Front (outside) -> Leaving.
371
+ // DIST_EPSILON is added to push the exit point slightly further out (or in depending on perspective),
372
+ // effectively narrowing the "inside" interval.
373
+ const f = (d1 + DIST_EPSILON) / (d1 - d2);
374
+ if (f < leavefrac) leavefrac = f;
375
+ }
376
+ }
377
+
378
+ // If we never started outside any plane, we started inside the brush.
379
+ if (!startout) {
380
+ trace.startsolid = true;
381
+ // If we also never got out of any plane (meaning we stayed behind all planes),
382
+ // then the entire movement is inside the brush (allsolid).
383
+ if (!getout) {
384
+ trace.allsolid = true;
385
+ }
386
+ trace.fraction = 0;
387
+ return;
388
+ }
389
+
390
+ // If the entry fraction is less than the exit fraction, we have a valid intersection interval.
391
+ if (enterfrac < leavefrac && enterfrac > -1 && enterfrac < trace.fraction) {
392
+ if (enterfrac < 0) enterfrac = 0;
393
+ trace.fraction = enterfrac;
394
+ trace.plane = clipplane;
395
+ trace.contents = brush.contents;
396
+ trace.surfaceFlags = leadside?.surfaceFlags ?? 0;
397
+ }
398
+ }
399
+
400
+ export function createDefaultTrace(): TraceResult {
401
+ return {
402
+ fraction: 1,
403
+ plane: null,
404
+ contents: 0,
405
+ surfaceFlags: 0,
406
+ startsolid: false,
407
+ allsolid: false,
408
+ };
409
+ }
410
+
411
+ function findLeafIndex(point: Vec3, model: CollisionModel, headnode: number): number {
412
+ let nodeIndex = headnode;
413
+
414
+ while (nodeIndex >= 0) {
415
+ const node = model.nodes[nodeIndex];
416
+ const dist = planeDistanceToPoint(node.plane, point);
417
+ nodeIndex = dist >= 0 ? node.children[0] : node.children[1];
418
+ }
419
+
420
+ return -1 - nodeIndex;
421
+ }
422
+
423
+ function computeLeafContents(model: CollisionModel, leafIndex: number, point: Vec3): number {
424
+ const leaf = model.leaves[leafIndex];
425
+ let contents = leaf.contents;
426
+
427
+ const brushCheckCount = nextBrushCheckCount();
428
+ const start = leaf.firstLeafBrush;
429
+ const end = start + leaf.numLeafBrushes;
430
+
431
+ for (let i = start; i < end; i += 1) {
432
+ const brushIndex = model.leafBrushes[i];
433
+ const brush = model.brushes[brushIndex];
434
+
435
+ if (brush.checkcount === brushCheckCount) continue;
436
+ brush.checkcount = brushCheckCount;
437
+
438
+ if (brush.sides.length === 0) continue;
439
+ if (pointInsideBrush(point, brush)) {
440
+ contents |= brush.contents;
441
+ }
442
+ }
443
+
444
+ return contents;
445
+ }
446
+
447
+ function nextBrushCheckCount(): number {
448
+ const count = globalBrushCheckCount;
449
+ globalBrushCheckCount += 1;
450
+ if (globalBrushCheckCount >= MAX_CHECKCOUNT) {
451
+ globalBrushCheckCount = 1;
452
+ }
453
+ return count;
454
+ }
455
+
456
+ function isPointBounds(mins: Vec3, maxs: Vec3): boolean {
457
+ return mins.x === 0 && mins.y === 0 && mins.z === 0 && maxs.x === 0 && maxs.y === 0 && maxs.z === 0;
458
+ }
459
+
460
+ function planeOffsetForBounds(plane: CollisionPlane, mins: Vec3, maxs: Vec3): number {
461
+ if (isPointBounds(mins, maxs)) return 0;
462
+
463
+ const offset =
464
+ plane.normal.x * (plane.normal.x < 0 ? maxs.x : mins.x) +
465
+ plane.normal.y * (plane.normal.y < 0 ? maxs.y : mins.y) +
466
+ plane.normal.z * (plane.normal.z < 0 ? maxs.z : mins.z);
467
+
468
+ return offset;
469
+ }
470
+
471
+ function planeOffsetMagnitude(plane: CollisionPlane, mins: Vec3, maxs: Vec3): number {
472
+ return Math.abs(planeOffsetForBounds(plane, mins, maxs));
473
+ }
474
+
475
+ function lerpPoint(start: Vec3, end: Vec3, t: number): Vec3 {
476
+ return addVec3(start, scaleVec3(subtractVec3(end, start), t));
477
+ }
478
+
479
+ function finalizeTrace(trace: TraceResult, start: Vec3, end: Vec3): CollisionTraceResult {
480
+ const clampedFraction = trace.allsolid ? 0 : trace.fraction;
481
+ const endpos = lerpPoint(start, end, clampedFraction);
482
+
483
+ return {
484
+ fraction: clampedFraction,
485
+ endpos,
486
+ plane: trace.plane,
487
+ planeNormal: trace.startsolid ? undefined : trace.plane?.normal,
488
+ contents: trace.contents,
489
+ surfaceFlags: trace.surfaceFlags,
490
+ startsolid: trace.startsolid,
491
+ allsolid: trace.allsolid,
492
+ };
493
+ }
494
+
495
+ function clusterForPoint(point: Vec3, model: CollisionModel, headnode: number): number {
496
+ const leafIndex = findLeafIndex(point, model, headnode);
497
+ return model.leaves[leafIndex]?.cluster ?? -1;
498
+ }
499
+
500
+ function clusterVisible(
501
+ visibility: CollisionVisibility,
502
+ from: number,
503
+ to: number,
504
+ usePhs: boolean,
505
+ ): boolean {
506
+ if (!visibility || visibility.numClusters === 0) return true;
507
+ if (from < 0 || to < 0) return false;
508
+ if (from >= visibility.clusters.length || to >= visibility.numClusters) return false;
509
+
510
+ const cluster = visibility.clusters[from];
511
+ const set = usePhs ? cluster.phs : cluster.pvs;
512
+ const byte = set[to >> 3];
513
+ if (byte === undefined) return false;
514
+
515
+ return (byte & (1 << (to & 7))) !== 0;
516
+ }
517
+
518
+ /**
519
+ * Recursively checks a hull sweep against a BSP tree.
520
+ * Implements a Liang-Barsky like clipping algorithm against the BSP planes.
521
+ *
522
+ * Based on CM_RecursiveHullCheck in qcommon/cm_trace.c.
523
+ */
524
+ function recursiveHullCheck(params: {
525
+ readonly model: CollisionModel;
526
+ readonly nodeIndex: number;
527
+ readonly startFraction: number;
528
+ readonly endFraction: number;
529
+ readonly start: Vec3;
530
+ readonly end: Vec3;
531
+ readonly traceStart: Vec3;
532
+ readonly traceEnd: Vec3;
533
+ readonly mins: Vec3;
534
+ readonly maxs: Vec3;
535
+ readonly contentMask: number;
536
+ readonly trace: TraceResult;
537
+ readonly brushCheckCount: number;
538
+ }): void {
539
+ const {
540
+ model,
541
+ nodeIndex,
542
+ startFraction,
543
+ endFraction,
544
+ start,
545
+ end,
546
+ traceStart,
547
+ traceEnd,
548
+ mins,
549
+ maxs,
550
+ contentMask,
551
+ trace,
552
+ brushCheckCount,
553
+ } = params;
554
+
555
+ // If we've already hit something earlier in the trace than where we are starting this check,
556
+ // we can stop.
557
+ if (trace.fraction <= startFraction) {
558
+ return;
559
+ }
560
+
561
+ // If we reached a leaf, check the brushes in it.
562
+ if (nodeIndex < 0) {
563
+ if (traceDebugInfo) {
564
+ traceDebugInfo.leafsReached++;
565
+ }
566
+
567
+ const leafIndex = -1 - nodeIndex;
568
+ const leaf = model.leaves[leafIndex];
569
+
570
+ const brushStart = leaf.firstLeafBrush;
571
+ const brushEnd = brushStart + leaf.numLeafBrushes;
572
+
573
+ for (let i = brushStart; i < brushEnd; i += 1) {
574
+ const brushIndex = model.leafBrushes[i];
575
+ const brush = model.brushes[brushIndex];
576
+
577
+ if ((brush.contents & contentMask) === 0) continue;
578
+ if (!brush.sides.length) continue;
579
+ // Optimization: Avoid checking the same brush multiple times in a single trace.
580
+ if (brush.checkcount === brushCheckCount) continue;
581
+
582
+ brush.checkcount = brushCheckCount;
583
+
584
+ if (traceDebugInfo) {
585
+ traceDebugInfo.brushesTested++;
586
+ }
587
+
588
+ clipBoxToBrush({ start: traceStart, end: traceEnd, mins, maxs, brush, trace });
589
+ if (trace.allsolid) {
590
+ return;
591
+ }
592
+ }
593
+ return;
594
+ }
595
+
596
+ if (traceDebugInfo) {
597
+ traceDebugInfo.nodesTraversed++;
598
+ }
599
+
600
+ const node = model.nodes[nodeIndex];
601
+ const plane = node.plane;
602
+
603
+ // Calculate the distance from the plane to the box's nearest corner.
604
+ // This effectively expands the plane by the box extents.
605
+ // Use absolute value of offset like original C code (full/qcommon/cmodel.c:1269-1271).
606
+ const offset = planeOffsetMagnitude(plane, mins, maxs);
607
+
608
+ const startDist = planeDistanceToPoint(plane, start);
609
+ const endDist = planeDistanceToPoint(plane, end);
610
+
611
+ // If both start and end points are in front of the plane (including offset),
612
+ // we only need to check the front child.
613
+ if (startDist >= offset && endDist >= offset) {
614
+ recursiveHullCheck({
615
+ model,
616
+ nodeIndex: node.children[0],
617
+ startFraction,
618
+ endFraction,
619
+ start,
620
+ end,
621
+ traceStart,
622
+ traceEnd,
623
+ mins,
624
+ maxs,
625
+ contentMask,
626
+ trace,
627
+ brushCheckCount,
628
+ });
629
+ return;
630
+ }
631
+
632
+ // If both start and end points are behind the plane (including offset),
633
+ // we only need to check the back child.
634
+ if (startDist < -offset && endDist < -offset) {
635
+ recursiveHullCheck({
636
+ model,
637
+ nodeIndex: node.children[1],
638
+ startFraction,
639
+ endFraction,
640
+ start,
641
+ end,
642
+ traceStart,
643
+ traceEnd,
644
+ mins,
645
+ maxs,
646
+ contentMask,
647
+ trace,
648
+ brushCheckCount,
649
+ });
650
+ return;
651
+ }
652
+
653
+ // The segment straddles the plane. We need to split the segment and recurse down both sides.
654
+ // Put the crosspoint DIST_EPSILON pixels on the near side to avoid precision issues.
655
+ // See full/qcommon/cmodel.c:1293-1313 (CM_RecursiveHullCheck)
656
+ // fraction1 (frac) is used for "move up to node" - the near-side recursion
657
+ // fraction2 (frac2) is used for "go past the node" - the far-side recursion
658
+ let side = 0;
659
+ let idist = 1 / (startDist - endDist);
660
+ let fraction1, fraction2;
661
+
662
+ if (startDist < endDist) {
663
+ side = 1;
664
+ fraction2 = (startDist + offset + DIST_EPSILON) * idist;
665
+ fraction1 = (startDist - offset + DIST_EPSILON) * idist;
666
+ } else {
667
+ side = 0;
668
+ fraction2 = (startDist - offset - DIST_EPSILON) * idist;
669
+ fraction1 = (startDist + offset + DIST_EPSILON) * idist;
670
+ }
671
+
672
+ if (fraction1 < 0) fraction1 = 0;
673
+ else if (fraction1 > 1) fraction1 = 1;
674
+
675
+ if (fraction2 < 0) fraction2 = 0;
676
+ else if (fraction2 > 1) fraction2 = 1;
677
+
678
+ const midFraction = startFraction + (endFraction - startFraction) * fraction1;
679
+ const midPoint = lerpPoint(start, end, fraction1);
680
+
681
+ // Recurse down the near side
682
+ recursiveHullCheck({
683
+ model,
684
+ nodeIndex: node.children[side],
685
+ startFraction,
686
+ endFraction: midFraction,
687
+ start,
688
+ end: midPoint,
689
+ traceStart,
690
+ traceEnd,
691
+ mins,
692
+ maxs,
693
+ contentMask,
694
+ trace,
695
+ brushCheckCount,
696
+ });
697
+
698
+ const updatedFraction = trace.fraction;
699
+
700
+ // Optimisation: if we hit something closer than the split point, we don't need to check the far side
701
+ if (updatedFraction <= midFraction) {
702
+ return;
703
+ }
704
+
705
+ const midFraction2 = startFraction + (endFraction - startFraction) * fraction2;
706
+ const midPoint2 = lerpPoint(start, end, fraction2);
707
+
708
+ // Recurse down the far side
709
+ recursiveHullCheck({
710
+ model,
711
+ nodeIndex: node.children[1 - side],
712
+ startFraction: midFraction2,
713
+ endFraction,
714
+ start: midPoint2,
715
+ end,
716
+ traceStart,
717
+ traceEnd,
718
+ mins,
719
+ maxs,
720
+ contentMask,
721
+ trace,
722
+ brushCheckCount,
723
+ });
724
+ }
725
+
726
+ export interface CollisionTraceParams {
727
+ readonly model: CollisionModel;
728
+ readonly start: Vec3;
729
+ readonly end: Vec3;
730
+ readonly mins?: Vec3;
731
+ readonly maxs?: Vec3;
732
+ readonly headnode?: number;
733
+ readonly contentMask?: number;
734
+ }
735
+
736
+ export function traceBox(params: CollisionTraceParams): CollisionTraceResult {
737
+ const { model, start, end } = params;
738
+ const mins = params.mins ?? ZERO_VEC3;
739
+ const maxs = params.maxs ?? ZERO_VEC3;
740
+ const contentMask = params.contentMask ?? 0xffffffff;
741
+ const headnode = params.headnode ?? 0;
742
+
743
+ const trace = createDefaultTrace();
744
+ const brushCheckCount = nextBrushCheckCount();
745
+
746
+ recursiveHullCheck({
747
+ model,
748
+ nodeIndex: headnode,
749
+ startFraction: 0,
750
+ endFraction: 1,
751
+ start,
752
+ end,
753
+ traceStart: start,
754
+ traceEnd: end,
755
+ mins,
756
+ maxs,
757
+ contentMask,
758
+ trace,
759
+ brushCheckCount,
760
+ });
761
+
762
+ return finalizeTrace(trace, start, end);
763
+ }
764
+
765
+ export function pointContents(point: Vec3, model: CollisionModel, headnode = 0): number {
766
+ const leafIndex = findLeafIndex(point, model, headnode);
767
+ return computeLeafContents(model, leafIndex, point);
768
+ }
769
+
770
+ export function pointContentsMany(points: readonly Vec3[], model: CollisionModel, headnode = 0): number[] {
771
+ const leafCache = new Map<number, number>();
772
+
773
+ return points.map((point) => {
774
+ const leafIndex = findLeafIndex(point, model, headnode);
775
+ const leaf = model.leaves[leafIndex];
776
+
777
+ if (leaf.numLeafBrushes === 0) {
778
+ const cached = leafCache.get(leafIndex);
779
+ if (cached !== undefined) {
780
+ return cached;
781
+ }
782
+
783
+ leafCache.set(leafIndex, leaf.contents);
784
+ return leaf.contents;
785
+ }
786
+
787
+ return computeLeafContents(model, leafIndex, point);
788
+ });
789
+ }
790
+
791
+ export function boxContents(origin: Vec3, mins: Vec3, maxs: Vec3, model: CollisionModel, headnode = 0): number {
792
+ const brushCheckCount = nextBrushCheckCount();
793
+ let contents = 0;
794
+
795
+ function traverse(nodeIndex: number) {
796
+ if (nodeIndex < 0) {
797
+ if (traceDebugInfo) {
798
+ traceDebugInfo.leafsReached++;
799
+ }
800
+ const leafIndex = -1 - nodeIndex;
801
+ const leaf = model.leaves[leafIndex];
802
+
803
+ contents |= leaf.contents;
804
+
805
+ const brushStart = leaf.firstLeafBrush;
806
+ const brushEnd = brushStart + leaf.numLeafBrushes;
807
+
808
+ for (let i = brushStart; i < brushEnd; i += 1) {
809
+ const brushIndex = model.leafBrushes[i];
810
+ const brush = model.brushes[brushIndex];
811
+
812
+ if (brush.checkcount === brushCheckCount) continue;
813
+ brush.checkcount = brushCheckCount;
814
+
815
+ if (brush.sides.length === 0) continue;
816
+
817
+ const result = testBoxInBrush(origin, mins, maxs, brush);
818
+ if (result.startsolid) {
819
+ contents |= result.contents;
820
+ }
821
+ }
822
+ return;
823
+ }
824
+
825
+ const node = model.nodes[nodeIndex];
826
+ const plane = node.plane;
827
+ const offset = planeOffsetMagnitude(plane, mins, maxs);
828
+ const dist = planeDistanceToPoint(plane, origin);
829
+
830
+ if (offset === 0) {
831
+ traverse(dist >= 0 ? node.children[0] : node.children[1]);
832
+ return;
833
+ }
834
+
835
+ if (dist > offset) {
836
+ traverse(node.children[0]);
837
+ return;
838
+ }
839
+
840
+ if (dist < -offset) {
841
+ traverse(node.children[1]);
842
+ return;
843
+ }
844
+
845
+ traverse(node.children[0]);
846
+ traverse(node.children[1]);
847
+ }
848
+
849
+ traverse(headnode);
850
+
851
+ return contents;
852
+ }
853
+
854
+ export function inPVS(p1: Vec3, p2: Vec3, model: CollisionModel, headnode = 0): boolean {
855
+ const { visibility } = model;
856
+ if (!visibility) return true;
857
+
858
+ const cluster1 = clusterForPoint(p1, model, headnode);
859
+ const cluster2 = clusterForPoint(p2, model, headnode);
860
+
861
+ return clusterVisible(visibility, cluster1, cluster2, false);
862
+ }
863
+
864
+ export function inPHS(p1: Vec3, p2: Vec3, model: CollisionModel, headnode = 0): boolean {
865
+ const { visibility } = model;
866
+ if (!visibility) return true;
867
+
868
+ const cluster1 = clusterForPoint(p1, model, headnode);
869
+ const cluster2 = clusterForPoint(p2, model, headnode);
870
+
871
+ return clusterVisible(visibility, cluster1, cluster2, true);
872
+ }
873
+
874
+ export interface CollisionEntityLink {
875
+ readonly id: number;
876
+ readonly origin: Vec3;
877
+ readonly mins: Vec3;
878
+ readonly maxs: Vec3;
879
+ readonly contents: number;
880
+ readonly surfaceFlags?: number;
881
+ }
882
+
883
+ interface CollisionEntityState extends CollisionEntityLink {
884
+ readonly brush: CollisionBrush;
885
+ readonly bounds: { readonly mins: Vec3; readonly maxs: Vec3 };
886
+ }
887
+
888
+ function axisAlignedPlane(normal: Vec3, dist: number, type: number): CollisionPlane {
889
+ return { normal, dist, type, signbits: computePlaneSignBits(normal) };
890
+ }
891
+
892
+ function makeEntityBrush(link: CollisionEntityLink): CollisionBrush {
893
+ const sx = link.surfaceFlags ?? 0;
894
+ const xMax = link.origin.x + link.maxs.x;
895
+ const xMin = link.origin.x + link.mins.x;
896
+ const yMax = link.origin.y + link.maxs.y;
897
+ const yMin = link.origin.y + link.mins.y;
898
+ const zMax = link.origin.z + link.maxs.z;
899
+ const zMin = link.origin.z + link.mins.z;
900
+
901
+ const planes: CollisionPlane[] = [
902
+ axisAlignedPlane({ x: 1, y: 0, z: 0 }, xMax, 0),
903
+ axisAlignedPlane({ x: -1, y: 0, z: 0 }, -xMin, 0),
904
+ axisAlignedPlane({ x: 0, y: 1, z: 0 }, yMax, 1),
905
+ axisAlignedPlane({ x: 0, y: -1, z: 0 }, -yMin, 1),
906
+ axisAlignedPlane({ x: 0, y: 0, z: 1 }, zMax, 2),
907
+ axisAlignedPlane({ x: 0, y: 0, z: -1 }, -zMin, 2),
908
+ ];
909
+
910
+ const sides: CollisionBrushSide[] = planes.map((plane) => ({ plane, surfaceFlags: sx }));
911
+
912
+ return { contents: link.contents, sides, checkcount: 0 };
913
+ }
914
+
915
+ function makeEntityState(link: CollisionEntityLink): CollisionEntityState {
916
+ const brush = makeEntityBrush(link);
917
+ return {
918
+ ...link,
919
+ brush,
920
+ bounds: {
921
+ mins: {
922
+ x: link.origin.x + link.mins.x,
923
+ y: link.origin.y + link.mins.y,
924
+ z: link.origin.z + link.mins.z,
925
+ },
926
+ maxs: {
927
+ x: link.origin.x + link.maxs.x,
928
+ y: link.origin.y + link.maxs.y,
929
+ z: link.origin.z + link.maxs.z,
930
+ },
931
+ },
932
+ };
933
+ }
934
+
935
+ function boundsIntersect(a: { mins: Vec3; maxs: Vec3 }, b: { mins: Vec3; maxs: Vec3 }): boolean {
936
+ return !(
937
+ a.mins.x > b.maxs.x ||
938
+ a.maxs.x < b.mins.x ||
939
+ a.mins.y > b.maxs.y ||
940
+ a.maxs.y < b.mins.y ||
941
+ a.mins.z > b.maxs.z ||
942
+ a.maxs.z < b.mins.z
943
+ );
944
+ }
945
+
946
+ function pickBetterTrace(
947
+ best: CollisionTraceResult,
948
+ candidate: CollisionTraceResult,
949
+ ): boolean {
950
+ if (candidate.allsolid && !best.allsolid) return true;
951
+ if (candidate.startsolid && !best.startsolid) return true;
952
+ return candidate.fraction < best.fraction;
953
+ }
954
+
955
+ export interface CollisionEntityTraceParams extends CollisionTraceParams {
956
+ readonly passId?: number;
957
+ }
958
+
959
+ export interface CollisionEntityTraceResult extends CollisionTraceResult {
960
+ readonly entityId: number | null;
961
+ }
962
+
963
+ export class CollisionEntityIndex {
964
+ private readonly entities = new Map<number, CollisionEntityState>();
965
+ private readonly entityNodes = new Map<number, SpatialNode>();
966
+ private readonly rootNode = createSpatialTree();
967
+
968
+ link(entity: CollisionEntityLink): void {
969
+ const state = makeEntityState(entity);
970
+ this.entities.set(entity.id, state);
971
+
972
+ // Update spatial index
973
+ const existingNode = this.entityNodes.get(entity.id);
974
+ if (existingNode) {
975
+ existingNode.items.delete(entity.id);
976
+ }
977
+
978
+ const newNode = linkEntityToSpatialTree(
979
+ this.rootNode,
980
+ entity.id,
981
+ state.bounds.mins,
982
+ state.bounds.maxs
983
+ );
984
+ this.entityNodes.set(entity.id, newNode);
985
+ }
986
+
987
+ unlink(entityId: number): void {
988
+ this.entities.delete(entityId);
989
+
990
+ const node = this.entityNodes.get(entityId);
991
+ if (node) {
992
+ node.items.delete(entityId);
993
+ this.entityNodes.delete(entityId);
994
+ }
995
+ }
996
+
997
+ trace(params: CollisionEntityTraceParams): CollisionEntityTraceResult {
998
+ const { passId } = params;
999
+ const mins = params.mins ?? ZERO_VEC3;
1000
+ const maxs = params.maxs ?? ZERO_VEC3;
1001
+ const contentMask = params.contentMask ?? 0xffffffff;
1002
+
1003
+ let bestTrace: CollisionTraceResult;
1004
+ let bestEntity: number | null = null;
1005
+
1006
+ if (params.model) {
1007
+ bestTrace = traceBox(params);
1008
+ } else {
1009
+ bestTrace = finalizeTrace(createDefaultTrace(), params.start, params.end);
1010
+ }
1011
+
1012
+ // Determine query bounds for spatial lookup
1013
+ const traceAbsMin = {
1014
+ x: Math.min(params.start.x, params.end.x) + mins.x,
1015
+ y: Math.min(params.start.y, params.end.y) + mins.y,
1016
+ z: Math.min(params.start.z, params.end.z) + mins.z,
1017
+ };
1018
+ const traceAbsMax = {
1019
+ x: Math.max(params.start.x, params.end.x) + maxs.x,
1020
+ y: Math.max(params.start.y, params.end.y) + maxs.y,
1021
+ z: Math.max(params.start.z, params.end.z) + maxs.z,
1022
+ };
1023
+
1024
+ const candidates = new Set<number>();
1025
+ querySpatialTree(this.rootNode, traceAbsMin, traceAbsMax, candidates);
1026
+
1027
+ for (const entityId of candidates) {
1028
+ if (entityId === passId) continue;
1029
+
1030
+ const entity = this.entities.get(entityId);
1031
+ if (!entity) continue;
1032
+ if ((entity.contents & contentMask) === 0) continue;
1033
+
1034
+ const trace = createDefaultTrace();
1035
+ clipBoxToBrush({ start: params.start, end: params.end, mins, maxs, brush: entity.brush, trace });
1036
+
1037
+ if (trace.contents === 0) {
1038
+ trace.contents = entity.contents;
1039
+ }
1040
+
1041
+ if (trace.startsolid || trace.allsolid || trace.fraction < bestTrace.fraction) {
1042
+ const candidate = finalizeTrace(trace, params.start, params.end);
1043
+ if (pickBetterTrace(bestTrace, candidate)) {
1044
+ bestTrace = candidate;
1045
+ bestEntity = entity.id;
1046
+ }
1047
+ }
1048
+ }
1049
+
1050
+ return { ...bestTrace, entityId: bestEntity };
1051
+ }
1052
+
1053
+ gatherTriggerTouches(origin: Vec3, mins: Vec3, maxs: Vec3, mask = CONTENTS_TRIGGER): number[] {
1054
+ const results: number[] = [];
1055
+ const queryBounds = {
1056
+ mins: addVec3(origin, mins),
1057
+ maxs: addVec3(origin, maxs),
1058
+ };
1059
+
1060
+ const candidates = new Set<number>();
1061
+ querySpatialTree(this.rootNode, queryBounds.mins, queryBounds.maxs, candidates);
1062
+
1063
+ for (const entityId of candidates) {
1064
+ const entity = this.entities.get(entityId);
1065
+ if (!entity) continue;
1066
+
1067
+ if ((entity.contents & mask) === 0) continue;
1068
+ if (boundsIntersect(queryBounds, entity.bounds)) {
1069
+ results.push(entity.id);
1070
+ }
1071
+ }
1072
+
1073
+ return results;
1074
+ }
1075
+ }