@aura3d/physics 1.0.6
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.
- package/README.md +26 -0
- package/dist/CharacterController.d.ts +52 -0
- package/dist/CharacterController.d.ts.map +1 -0
- package/dist/CharacterController.js +146 -0
- package/dist/CharacterController.js.map +1 -0
- package/dist/ClothFixtures.d.ts +87 -0
- package/dist/ClothFixtures.d.ts.map +1 -0
- package/dist/ClothFixtures.js +264 -0
- package/dist/ClothFixtures.js.map +1 -0
- package/dist/Collider.d.ts +27 -0
- package/dist/Collider.d.ts.map +1 -0
- package/dist/Collider.js +45 -0
- package/dist/Collider.js.map +1 -0
- package/dist/CollisionEvents.d.ts +26 -0
- package/dist/CollisionEvents.d.ts.map +1 -0
- package/dist/CollisionEvents.js +46 -0
- package/dist/CollisionEvents.js.map +1 -0
- package/dist/CollisionVolumes.d.ts +57 -0
- package/dist/CollisionVolumes.d.ts.map +1 -0
- package/dist/CollisionVolumes.js +159 -0
- package/dist/CollisionVolumes.js.map +1 -0
- package/dist/Constraint.d.ts +31 -0
- package/dist/Constraint.d.ts.map +1 -0
- package/dist/Constraint.js +103 -0
- package/dist/Constraint.js.map +1 -0
- package/dist/Constraints.d.ts +2 -0
- package/dist/Constraints.d.ts.map +1 -0
- package/dist/Constraints.js +2 -0
- package/dist/Constraints.js.map +1 -0
- package/dist/Crowd.d.ts +64 -0
- package/dist/Crowd.d.ts.map +1 -0
- package/dist/Crowd.js +209 -0
- package/dist/Crowd.js.map +1 -0
- package/dist/ECSPhysicsBridge.d.ts +17 -0
- package/dist/ECSPhysicsBridge.d.ts.map +1 -0
- package/dist/ECSPhysicsBridge.js +37 -0
- package/dist/ECSPhysicsBridge.js.map +1 -0
- package/dist/FireSmokeFixtures.d.ts +103 -0
- package/dist/FireSmokeFixtures.d.ts.map +1 -0
- package/dist/FireSmokeFixtures.js +271 -0
- package/dist/FireSmokeFixtures.js.map +1 -0
- package/dist/FluidFixtures.d.ts +75 -0
- package/dist/FluidFixtures.d.ts.map +1 -0
- package/dist/FluidFixtures.js +219 -0
- package/dist/FluidFixtures.js.map +1 -0
- package/dist/FractureFixtures.d.ts +68 -0
- package/dist/FractureFixtures.d.ts.map +1 -0
- package/dist/FractureFixtures.js +199 -0
- package/dist/FractureFixtures.js.map +1 -0
- package/dist/HitboxWorld.d.ts +250 -0
- package/dist/HitboxWorld.d.ts.map +1 -0
- package/dist/HitboxWorld.js +771 -0
- package/dist/HitboxWorld.js.map +1 -0
- package/dist/KinematicBody.d.ts +157 -0
- package/dist/KinematicBody.d.ts.map +1 -0
- package/dist/KinematicBody.js +405 -0
- package/dist/KinematicBody.js.map +1 -0
- package/dist/KinematicWorld.d.ts +58 -0
- package/dist/KinematicWorld.d.ts.map +1 -0
- package/dist/KinematicWorld.js +246 -0
- package/dist/KinematicWorld.js.map +1 -0
- package/dist/Navigation.d.ts +67 -0
- package/dist/Navigation.d.ts.map +1 -0
- package/dist/Navigation.js +261 -0
- package/dist/Navigation.js.map +1 -0
- package/dist/PhysicsDebugDraw.d.ts +11 -0
- package/dist/PhysicsDebugDraw.d.ts.map +1 -0
- package/dist/PhysicsDebugDraw.js +64 -0
- package/dist/PhysicsDebugDraw.js.map +1 -0
- package/dist/PhysicsSandboxFixtures.d.ts +53 -0
- package/dist/PhysicsSandboxFixtures.d.ts.map +1 -0
- package/dist/PhysicsSandboxFixtures.js +280 -0
- package/dist/PhysicsSandboxFixtures.js.map +1 -0
- package/dist/PhysicsStepper.d.ts +15 -0
- package/dist/PhysicsStepper.d.ts.map +1 -0
- package/dist/PhysicsStepper.js +37 -0
- package/dist/PhysicsStepper.js.map +1 -0
- package/dist/PhysicsWorld.d.ts +101 -0
- package/dist/PhysicsWorld.d.ts.map +1 -0
- package/dist/PhysicsWorld.js +868 -0
- package/dist/PhysicsWorld.js.map +1 -0
- package/dist/PlatformerFixtures.d.ts +70 -0
- package/dist/PlatformerFixtures.d.ts.map +1 -0
- package/dist/PlatformerFixtures.js +120 -0
- package/dist/PlatformerFixtures.js.map +1 -0
- package/dist/Raycast.d.ts +23 -0
- package/dist/Raycast.d.ts.map +1 -0
- package/dist/Raycast.js +271 -0
- package/dist/Raycast.js.map +1 -0
- package/dist/RigidBody.d.ts +69 -0
- package/dist/RigidBody.d.ts.map +1 -0
- package/dist/RigidBody.js +255 -0
- package/dist/RigidBody.js.map +1 -0
- package/dist/ScenePhysicsBridge.d.ts +42 -0
- package/dist/ScenePhysicsBridge.d.ts.map +1 -0
- package/dist/ScenePhysicsBridge.js +168 -0
- package/dist/ScenePhysicsBridge.js.map +1 -0
- package/dist/Shape.d.ts +50 -0
- package/dist/Shape.d.ts.map +1 -0
- package/dist/Shape.js +128 -0
- package/dist/Shape.js.map +1 -0
- package/dist/SoftBodyFixtures.d.ts +70 -0
- package/dist/SoftBodyFixtures.d.ts.map +1 -0
- package/dist/SoftBodyFixtures.js +272 -0
- package/dist/SoftBodyFixtures.js.map +1 -0
- package/dist/Steering.d.ts +161 -0
- package/dist/Steering.d.ts.map +1 -0
- package/dist/Steering.js +347 -0
- package/dist/Steering.js.map +1 -0
- package/dist/VehicleDynamics.d.ts +152 -0
- package/dist/VehicleDynamics.d.ts.map +1 -0
- package/dist/VehicleDynamics.js +335 -0
- package/dist/VehicleDynamics.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/package.json +17 -0
- package/src/CharacterController.ts +184 -0
- package/src/ClothFixtures.ts +359 -0
- package/src/Collider.ts +67 -0
- package/src/CollisionEvents.ts +70 -0
- package/src/CollisionVolumes.ts +218 -0
- package/src/Constraint.ts +120 -0
- package/src/Constraints.ts +1 -0
- package/src/Crowd.ts +283 -0
- package/src/ECSPhysicsBridge.ts +53 -0
- package/src/FireSmokeFixtures.ts +382 -0
- package/src/FluidFixtures.ts +301 -0
- package/src/FractureFixtures.ts +277 -0
- package/src/HitboxWorld.ts +1179 -0
- package/src/KinematicBody.ts +553 -0
- package/src/KinematicWorld.ts +310 -0
- package/src/Navigation.ts +321 -0
- package/src/PhysicsDebugDraw.ts +71 -0
- package/src/PhysicsSandboxFixtures.ts +394 -0
- package/src/PhysicsStepper.ts +47 -0
- package/src/PhysicsWorld.ts +986 -0
- package/src/PlatformerFixtures.ts +205 -0
- package/src/Raycast.ts +315 -0
- package/src/RigidBody.ts +311 -0
- package/src/ScenePhysicsBridge.ts +207 -0
- package/src/Shape.ts +182 -0
- package/src/SoftBodyFixtures.ts +363 -0
- package/src/Steering.ts +531 -0
- package/src/VehicleDynamics.ts +524 -0
- package/src/index.ts +28 -0
|
@@ -0,0 +1,1179 @@
|
|
|
1
|
+
import {
|
|
2
|
+
aabbPenetration,
|
|
3
|
+
guardbox,
|
|
4
|
+
hitbox,
|
|
5
|
+
hurtbox,
|
|
6
|
+
overlapsVolume,
|
|
7
|
+
pushbox,
|
|
8
|
+
resolveCollisionVolume,
|
|
9
|
+
withVolumeOwner,
|
|
10
|
+
type CombatFacing,
|
|
11
|
+
type CollisionOwnerId,
|
|
12
|
+
type CollisionVolume,
|
|
13
|
+
type ResolvedCollisionVolume
|
|
14
|
+
} from "./CollisionVolumes.js";
|
|
15
|
+
import { cloneVec3, validateFiniteVec3, type Vec3 } from "./Shape.js";
|
|
16
|
+
|
|
17
|
+
export type CombatantId = CollisionOwnerId;
|
|
18
|
+
export type CombatTeam = string | number;
|
|
19
|
+
|
|
20
|
+
export type CombatantDescriptor = {
|
|
21
|
+
readonly id: CombatantId;
|
|
22
|
+
readonly team?: CombatTeam;
|
|
23
|
+
readonly position?: Vec3;
|
|
24
|
+
readonly facing?: CombatFacing;
|
|
25
|
+
readonly hurtboxes?: readonly CollisionVolume[];
|
|
26
|
+
readonly guardBoxes?: readonly CollisionVolume[];
|
|
27
|
+
readonly pushbox?: CollisionVolume | null;
|
|
28
|
+
readonly health?: number;
|
|
29
|
+
readonly maxHealth?: number;
|
|
30
|
+
readonly guard?: number;
|
|
31
|
+
readonly maxGuard?: number;
|
|
32
|
+
readonly meter?: number;
|
|
33
|
+
readonly blocking?: boolean;
|
|
34
|
+
readonly invulnerableFrames?: number;
|
|
35
|
+
readonly knockedOut?: boolean;
|
|
36
|
+
readonly metadata?: Readonly<Record<string, unknown>>;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type CombatantPose = {
|
|
40
|
+
readonly position?: Vec3;
|
|
41
|
+
readonly facing?: CombatFacing;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type ActiveFrameWindow = {
|
|
45
|
+
readonly start: number;
|
|
46
|
+
readonly end: number;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type HitboxDescriptor = {
|
|
50
|
+
readonly id?: string;
|
|
51
|
+
readonly ownerId: CombatantId;
|
|
52
|
+
readonly team?: CombatTeam;
|
|
53
|
+
readonly moveId: string;
|
|
54
|
+
readonly boxes: readonly CollisionVolume[];
|
|
55
|
+
readonly activeFrames?: ActiveFrameWindow;
|
|
56
|
+
readonly recoveryFrames?: number;
|
|
57
|
+
readonly damage?: number;
|
|
58
|
+
readonly guardDamage?: number;
|
|
59
|
+
readonly hitstunFrames?: number;
|
|
60
|
+
readonly blockstunFrames?: number;
|
|
61
|
+
readonly hitStopFrames?: number;
|
|
62
|
+
readonly selfHitStopFrames?: number;
|
|
63
|
+
readonly knockback?: Vec3;
|
|
64
|
+
readonly blockKnockback?: Vec3;
|
|
65
|
+
readonly priority?: number;
|
|
66
|
+
readonly canHitOwner?: boolean;
|
|
67
|
+
readonly canHitSameTeam?: boolean;
|
|
68
|
+
readonly hitOnce?: boolean;
|
|
69
|
+
readonly maxHits?: number;
|
|
70
|
+
readonly metadata?: Readonly<Record<string, unknown>>;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type ActiveHitboxSnapshot = {
|
|
74
|
+
readonly id: string;
|
|
75
|
+
readonly ownerId: CombatantId;
|
|
76
|
+
readonly moveId: string;
|
|
77
|
+
readonly ageFrames: number;
|
|
78
|
+
readonly activeFrames: ActiveFrameWindow;
|
|
79
|
+
readonly recoveryFrames: number;
|
|
80
|
+
readonly hitCount: number;
|
|
81
|
+
readonly expired: boolean;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export type CombatantSnapshot = {
|
|
85
|
+
readonly id: CombatantId;
|
|
86
|
+
readonly team?: CombatTeam;
|
|
87
|
+
readonly position: Vec3;
|
|
88
|
+
readonly facing: CombatFacing;
|
|
89
|
+
readonly health: number;
|
|
90
|
+
readonly maxHealth: number;
|
|
91
|
+
readonly guard: number;
|
|
92
|
+
readonly maxGuard: number;
|
|
93
|
+
readonly meter: number;
|
|
94
|
+
readonly blocking: boolean;
|
|
95
|
+
readonly invulnerableFrames: number;
|
|
96
|
+
readonly hitStopFrames: number;
|
|
97
|
+
readonly hitstunFrames: number;
|
|
98
|
+
readonly blockstunFrames: number;
|
|
99
|
+
readonly recoveryFrames: number;
|
|
100
|
+
readonly knockedOut: boolean;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export type HitboxWorldSnapshot = {
|
|
104
|
+
readonly frame: number;
|
|
105
|
+
readonly roundLocked: boolean;
|
|
106
|
+
readonly combatants: readonly CombatantSnapshot[];
|
|
107
|
+
readonly hitboxes: readonly ActiveHitboxSnapshot[];
|
|
108
|
+
readonly events: readonly CombatEvent[];
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export type CombatHitEvent = {
|
|
112
|
+
readonly type: "hit";
|
|
113
|
+
readonly frame: number;
|
|
114
|
+
readonly attackerId: CombatantId;
|
|
115
|
+
readonly defenderId: CombatantId;
|
|
116
|
+
readonly hitboxId: string;
|
|
117
|
+
readonly moveId: string;
|
|
118
|
+
readonly hitboxVolumeId: string;
|
|
119
|
+
readonly hurtboxId: string;
|
|
120
|
+
readonly damage: number;
|
|
121
|
+
readonly defenderHealth: number;
|
|
122
|
+
readonly hitstunFrames: number;
|
|
123
|
+
readonly hitStopFrames: number;
|
|
124
|
+
readonly knockback: Vec3;
|
|
125
|
+
readonly point: Vec3;
|
|
126
|
+
readonly normal: Vec3;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export type CombatBlockEvent = {
|
|
130
|
+
readonly type: "blocked";
|
|
131
|
+
readonly frame: number;
|
|
132
|
+
readonly attackerId: CombatantId;
|
|
133
|
+
readonly defenderId: CombatantId;
|
|
134
|
+
readonly hitboxId: string;
|
|
135
|
+
readonly moveId: string;
|
|
136
|
+
readonly hitboxVolumeId: string;
|
|
137
|
+
readonly guardboxId: string;
|
|
138
|
+
readonly guardDamage: number;
|
|
139
|
+
readonly defenderGuard: number;
|
|
140
|
+
readonly blockstunFrames: number;
|
|
141
|
+
readonly hitStopFrames: number;
|
|
142
|
+
readonly knockback: Vec3;
|
|
143
|
+
readonly point: Vec3;
|
|
144
|
+
readonly normal: Vec3;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export type CombatHitboxLifecycleEvent =
|
|
148
|
+
| {
|
|
149
|
+
readonly type: "hitbox-spawned";
|
|
150
|
+
readonly frame: number;
|
|
151
|
+
readonly hitboxId: string;
|
|
152
|
+
readonly ownerId: CombatantId;
|
|
153
|
+
readonly moveId: string;
|
|
154
|
+
readonly activeFrames: ActiveFrameWindow;
|
|
155
|
+
}
|
|
156
|
+
| {
|
|
157
|
+
readonly type: "hitbox-expired";
|
|
158
|
+
readonly frame: number;
|
|
159
|
+
readonly hitboxId: string;
|
|
160
|
+
readonly ownerId: CombatantId;
|
|
161
|
+
readonly moveId: string;
|
|
162
|
+
readonly hitCount: number;
|
|
163
|
+
}
|
|
164
|
+
| {
|
|
165
|
+
readonly type: "whiff";
|
|
166
|
+
readonly frame: number;
|
|
167
|
+
readonly hitboxId: string;
|
|
168
|
+
readonly ownerId: CombatantId;
|
|
169
|
+
readonly moveId: string;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export type CombatTimerEvent =
|
|
173
|
+
| {
|
|
174
|
+
readonly type: "hitstop-start";
|
|
175
|
+
readonly frame: number;
|
|
176
|
+
readonly combatantId: CombatantId;
|
|
177
|
+
readonly frames: number;
|
|
178
|
+
}
|
|
179
|
+
| {
|
|
180
|
+
readonly type: "hitstop-end";
|
|
181
|
+
readonly frame: number;
|
|
182
|
+
readonly combatantId: CombatantId;
|
|
183
|
+
}
|
|
184
|
+
| {
|
|
185
|
+
readonly type: "hitstun-start";
|
|
186
|
+
readonly frame: number;
|
|
187
|
+
readonly combatantId: CombatantId;
|
|
188
|
+
readonly frames: number;
|
|
189
|
+
}
|
|
190
|
+
| {
|
|
191
|
+
readonly type: "hitstun-end";
|
|
192
|
+
readonly frame: number;
|
|
193
|
+
readonly combatantId: CombatantId;
|
|
194
|
+
}
|
|
195
|
+
| {
|
|
196
|
+
readonly type: "blockstun-start";
|
|
197
|
+
readonly frame: number;
|
|
198
|
+
readonly combatantId: CombatantId;
|
|
199
|
+
readonly frames: number;
|
|
200
|
+
}
|
|
201
|
+
| {
|
|
202
|
+
readonly type: "blockstun-end";
|
|
203
|
+
readonly frame: number;
|
|
204
|
+
readonly combatantId: CombatantId;
|
|
205
|
+
}
|
|
206
|
+
| {
|
|
207
|
+
readonly type: "recovery-start";
|
|
208
|
+
readonly frame: number;
|
|
209
|
+
readonly combatantId: CombatantId;
|
|
210
|
+
readonly frames: number;
|
|
211
|
+
}
|
|
212
|
+
| {
|
|
213
|
+
readonly type: "recovery-end";
|
|
214
|
+
readonly frame: number;
|
|
215
|
+
readonly combatantId: CombatantId;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
export type CombatKnockoutEvent = {
|
|
219
|
+
readonly type: "knockout";
|
|
220
|
+
readonly frame: number;
|
|
221
|
+
readonly combatantId: CombatantId;
|
|
222
|
+
readonly attackerId?: CombatantId;
|
|
223
|
+
readonly moveId?: string;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
export type CombatRoundResetEvent = {
|
|
227
|
+
readonly type: "round-reset";
|
|
228
|
+
readonly frame: number;
|
|
229
|
+
readonly combatants: readonly CombatantId[];
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
export type CombatPushboxEvent = {
|
|
233
|
+
readonly type: "pushbox-overlap";
|
|
234
|
+
readonly frame: number;
|
|
235
|
+
readonly combatantA: CombatantId;
|
|
236
|
+
readonly combatantB: CombatantId;
|
|
237
|
+
readonly normal: Vec3;
|
|
238
|
+
readonly penetration: number;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
export type CombatEvent =
|
|
242
|
+
| CombatHitEvent
|
|
243
|
+
| CombatBlockEvent
|
|
244
|
+
| CombatHitboxLifecycleEvent
|
|
245
|
+
| CombatTimerEvent
|
|
246
|
+
| CombatPushboxEvent
|
|
247
|
+
| CombatKnockoutEvent
|
|
248
|
+
| CombatRoundResetEvent;
|
|
249
|
+
|
|
250
|
+
export type HitboxWorldDescriptor = {
|
|
251
|
+
readonly detectPushboxOverlaps?: boolean;
|
|
252
|
+
readonly lockOnKnockout?: boolean;
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
type CombatantRecord = {
|
|
256
|
+
id: CombatantId;
|
|
257
|
+
team: CombatTeam | undefined;
|
|
258
|
+
position: [number, number, number];
|
|
259
|
+
facing: CombatFacing;
|
|
260
|
+
hurtboxes: CollisionVolume[];
|
|
261
|
+
guardBoxes: CollisionVolume[];
|
|
262
|
+
pushbox: CollisionVolume | null;
|
|
263
|
+
health: number;
|
|
264
|
+
maxHealth: number;
|
|
265
|
+
guard: number;
|
|
266
|
+
maxGuard: number;
|
|
267
|
+
meter: number;
|
|
268
|
+
blocking: boolean;
|
|
269
|
+
invulnerableFrames: number;
|
|
270
|
+
hitStopFrames: number;
|
|
271
|
+
hitstunFrames: number;
|
|
272
|
+
blockstunFrames: number;
|
|
273
|
+
recoveryFrames: number;
|
|
274
|
+
knockedOut: boolean;
|
|
275
|
+
metadata: Readonly<Record<string, unknown>> | undefined;
|
|
276
|
+
baseline: CombatantBaseline;
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
type CombatantBaseline = {
|
|
280
|
+
readonly position: [number, number, number];
|
|
281
|
+
readonly facing: CombatFacing;
|
|
282
|
+
readonly health: number;
|
|
283
|
+
readonly maxHealth: number;
|
|
284
|
+
readonly guard: number;
|
|
285
|
+
readonly maxGuard: number;
|
|
286
|
+
readonly meter: number;
|
|
287
|
+
readonly blocking: boolean;
|
|
288
|
+
readonly invulnerableFrames: number;
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
type ActiveHitboxRecord = {
|
|
292
|
+
id: string;
|
|
293
|
+
ownerId: CombatantId;
|
|
294
|
+
team: CombatTeam | undefined;
|
|
295
|
+
moveId: string;
|
|
296
|
+
boxes: CollisionVolume[];
|
|
297
|
+
activeFrames: ActiveFrameWindow;
|
|
298
|
+
recoveryFrames: number;
|
|
299
|
+
damage: number;
|
|
300
|
+
guardDamage: number;
|
|
301
|
+
hitstunFrames: number;
|
|
302
|
+
blockstunFrames: number;
|
|
303
|
+
hitStopFrames: number;
|
|
304
|
+
selfHitStopFrames: number;
|
|
305
|
+
knockback: Vec3;
|
|
306
|
+
blockKnockback: Vec3;
|
|
307
|
+
priority: number;
|
|
308
|
+
canHitOwner: boolean;
|
|
309
|
+
canHitSameTeam: boolean;
|
|
310
|
+
hitOnce: boolean;
|
|
311
|
+
maxHits: number;
|
|
312
|
+
metadata: Readonly<Record<string, unknown>> | undefined;
|
|
313
|
+
ageFrames: number;
|
|
314
|
+
hitCount: number;
|
|
315
|
+
expired: boolean;
|
|
316
|
+
hitTargets: Set<CombatantId>;
|
|
317
|
+
recoveryStarted: boolean;
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
export class HitboxWorld {
|
|
321
|
+
readonly detectPushboxOverlaps: boolean;
|
|
322
|
+
readonly lockOnKnockout: boolean;
|
|
323
|
+
private readonly combatantsById = new Map<CombatantId, CombatantRecord>();
|
|
324
|
+
private readonly hitboxesById = new Map<string, ActiveHitboxRecord>();
|
|
325
|
+
private nextHitboxId = 1;
|
|
326
|
+
private frame = 0;
|
|
327
|
+
private roundLocked = false;
|
|
328
|
+
private lastEvents: readonly CombatEvent[] = [];
|
|
329
|
+
private readonly pendingEvents: CombatEvent[] = [];
|
|
330
|
+
|
|
331
|
+
constructor(descriptor: HitboxWorldDescriptor = {}) {
|
|
332
|
+
this.detectPushboxOverlaps = descriptor.detectPushboxOverlaps ?? true;
|
|
333
|
+
this.lockOnKnockout = descriptor.lockOnKnockout ?? true;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
registerCombatant(descriptor: CombatantDescriptor): CombatantSnapshot {
|
|
337
|
+
if (this.combatantsById.has(descriptor.id)) {
|
|
338
|
+
throw new Error(`Combatant ${String(descriptor.id)} already exists.`);
|
|
339
|
+
}
|
|
340
|
+
const combatant = createCombatantRecord(descriptor);
|
|
341
|
+
this.combatantsById.set(combatant.id, combatant);
|
|
342
|
+
return snapshotCombatant(combatant);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
upsertCombatant(descriptor: CombatantDescriptor): CombatantSnapshot {
|
|
346
|
+
const existing = this.combatantsById.get(descriptor.id);
|
|
347
|
+
if (!existing) {
|
|
348
|
+
return this.registerCombatant(descriptor);
|
|
349
|
+
}
|
|
350
|
+
applyCombatantDescriptor(existing, descriptor);
|
|
351
|
+
return snapshotCombatant(existing);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
removeCombatant(id: CombatantId): void {
|
|
355
|
+
this.combatantsById.delete(id);
|
|
356
|
+
for (const hitboxRecord of this.hitboxesById.values()) {
|
|
357
|
+
if (hitboxRecord.ownerId === id) {
|
|
358
|
+
hitboxRecord.expired = true;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
getCombatant(id: CombatantId): CombatantSnapshot | undefined {
|
|
364
|
+
const combatant = this.combatantsById.get(id);
|
|
365
|
+
return combatant ? snapshotCombatant(combatant) : undefined;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
setPose(id: CombatantId, pose: CombatantPose): void {
|
|
369
|
+
const combatant = this.requireCombatant(id);
|
|
370
|
+
if (pose.position) {
|
|
371
|
+
validateFiniteVec3(pose.position, "combatant pose position");
|
|
372
|
+
combatant.position = cloneVec3(pose.position);
|
|
373
|
+
}
|
|
374
|
+
if (pose.facing) {
|
|
375
|
+
combatant.facing = pose.facing < 0 ? -1 : 1;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
setGuarding(id: CombatantId, blocking: boolean): void {
|
|
380
|
+
const combatant = this.requireCombatant(id);
|
|
381
|
+
combatant.blocking = !combatant.knockedOut && blocking;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
setHurtboxes(id: CombatantId, volumes: readonly CollisionVolume[]): void {
|
|
385
|
+
this.requireCombatant(id).hurtboxes = volumes.map((volume) => requireVolumeKind(volume, "hurtbox"));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
setGuardBoxes(id: CombatantId, volumes: readonly CollisionVolume[]): void {
|
|
389
|
+
this.requireCombatant(id).guardBoxes = volumes.map((volume) => requireVolumeKind(volume, "guardbox"));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
setPushbox(id: CombatantId, volume: CollisionVolume | null): void {
|
|
393
|
+
this.requireCombatant(id).pushbox = volume === null ? null : requireVolumeKind(volume, "pushbox");
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
spawnHitbox(descriptor: HitboxDescriptor): ActiveHitboxSnapshot {
|
|
397
|
+
const owner = this.requireCombatant(descriptor.ownerId);
|
|
398
|
+
const id = descriptor.id ?? `${String(descriptor.ownerId)}:${descriptor.moveId}:${this.nextHitboxId}`;
|
|
399
|
+
this.nextHitboxId += 1;
|
|
400
|
+
const activeFrames = normalizeActiveFrames(descriptor.activeFrames ?? { start: 0, end: 2 });
|
|
401
|
+
if (this.hitboxesById.has(id)) {
|
|
402
|
+
throw new Error(`Hitbox ${id} already exists.`);
|
|
403
|
+
}
|
|
404
|
+
const record: ActiveHitboxRecord = {
|
|
405
|
+
id,
|
|
406
|
+
ownerId: descriptor.ownerId,
|
|
407
|
+
team: descriptor.team ?? owner.team,
|
|
408
|
+
moveId: descriptor.moveId,
|
|
409
|
+
boxes: descriptor.boxes.map((volume) => requireVolumeKind(volume, "hitbox")),
|
|
410
|
+
activeFrames,
|
|
411
|
+
recoveryFrames: nonNegativeInteger(descriptor.recoveryFrames ?? 0, "hitbox recoveryFrames"),
|
|
412
|
+
damage: nonNegativeFinite(descriptor.damage ?? 10, "hitbox damage"),
|
|
413
|
+
guardDamage: nonNegativeFinite(descriptor.guardDamage ?? Math.ceil((descriptor.damage ?? 10) * 0.35), "hitbox guardDamage"),
|
|
414
|
+
hitstunFrames: nonNegativeInteger(descriptor.hitstunFrames ?? 14, "hitbox hitstunFrames"),
|
|
415
|
+
blockstunFrames: nonNegativeInteger(descriptor.blockstunFrames ?? 8, "hitbox blockstunFrames"),
|
|
416
|
+
hitStopFrames: nonNegativeInteger(descriptor.hitStopFrames ?? 6, "hitbox hitStopFrames"),
|
|
417
|
+
selfHitStopFrames: nonNegativeInteger(descriptor.selfHitStopFrames ?? descriptor.hitStopFrames ?? 6, "hitbox selfHitStopFrames"),
|
|
418
|
+
knockback: cloneVec3(descriptor.knockback ?? [2.4, 0.8, 0]),
|
|
419
|
+
blockKnockback: cloneVec3(descriptor.blockKnockback ?? [1.1, 0, 0]),
|
|
420
|
+
priority: finite(descriptor.priority ?? 0, "hitbox priority"),
|
|
421
|
+
canHitOwner: descriptor.canHitOwner ?? false,
|
|
422
|
+
canHitSameTeam: descriptor.canHitSameTeam ?? false,
|
|
423
|
+
hitOnce: descriptor.hitOnce ?? true,
|
|
424
|
+
maxHits: positiveInteger(descriptor.maxHits ?? Number.MAX_SAFE_INTEGER, "hitbox maxHits"),
|
|
425
|
+
metadata: descriptor.metadata,
|
|
426
|
+
ageFrames: 0,
|
|
427
|
+
hitCount: 0,
|
|
428
|
+
expired: false,
|
|
429
|
+
hitTargets: new Set(),
|
|
430
|
+
recoveryStarted: false
|
|
431
|
+
};
|
|
432
|
+
validateFiniteVec3(record.knockback, "hitbox knockback");
|
|
433
|
+
validateFiniteVec3(record.blockKnockback, "hitbox blockKnockback");
|
|
434
|
+
if (this.roundLocked || owner.knockedOut) {
|
|
435
|
+
return snapshotHitbox({
|
|
436
|
+
...record,
|
|
437
|
+
expired: true
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
this.hitboxesById.set(record.id, record);
|
|
441
|
+
this.pendingEvents.push({
|
|
442
|
+
type: "hitbox-spawned",
|
|
443
|
+
frame: this.frame,
|
|
444
|
+
hitboxId: record.id,
|
|
445
|
+
ownerId: record.ownerId,
|
|
446
|
+
moveId: record.moveId,
|
|
447
|
+
activeFrames: record.activeFrames
|
|
448
|
+
});
|
|
449
|
+
return snapshotHitbox(record);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
clearHitboxes(ownerId?: CombatantId): void {
|
|
453
|
+
for (const [id, record] of this.hitboxesById) {
|
|
454
|
+
if (ownerId === undefined || record.ownerId === ownerId) {
|
|
455
|
+
this.hitboxesById.delete(id);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
reset(combatants?: readonly CombatantDescriptor[]): HitboxWorldSnapshot {
|
|
461
|
+
this.hitboxesById.clear();
|
|
462
|
+
this.pendingEvents.length = 0;
|
|
463
|
+
this.lastEvents = [];
|
|
464
|
+
this.roundLocked = false;
|
|
465
|
+
|
|
466
|
+
if (combatants) {
|
|
467
|
+
this.combatantsById.clear();
|
|
468
|
+
for (const combatant of combatants) {
|
|
469
|
+
this.registerCombatant(combatant);
|
|
470
|
+
}
|
|
471
|
+
} else {
|
|
472
|
+
for (const combatant of this.combatantsById.values()) {
|
|
473
|
+
resetCombatantRecord(combatant);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
this.lastEvents = [
|
|
478
|
+
{
|
|
479
|
+
type: "round-reset",
|
|
480
|
+
frame: this.frame,
|
|
481
|
+
combatants: this.combatants().map((combatant) => combatant.id)
|
|
482
|
+
}
|
|
483
|
+
];
|
|
484
|
+
return this.snapshot();
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
step(frames = 1): readonly CombatEvent[] {
|
|
488
|
+
const frameCount = positiveInteger(frames, "HitboxWorld.step frames");
|
|
489
|
+
const events: CombatEvent[] = [];
|
|
490
|
+
events.push(...this.drainPendingEvents());
|
|
491
|
+
for (let index = 0; index < frameCount; index += 1) {
|
|
492
|
+
events.push(...this.stepOneFrame());
|
|
493
|
+
}
|
|
494
|
+
this.lastEvents = events;
|
|
495
|
+
return events;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
drainEvents(): readonly CombatEvent[] {
|
|
499
|
+
const events = [...this.pendingEvents, ...this.lastEvents];
|
|
500
|
+
this.pendingEvents.length = 0;
|
|
501
|
+
this.lastEvents = [];
|
|
502
|
+
return events;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
snapshot(): HitboxWorldSnapshot {
|
|
506
|
+
return {
|
|
507
|
+
frame: this.frame,
|
|
508
|
+
roundLocked: this.roundLocked,
|
|
509
|
+
combatants: this.combatants(),
|
|
510
|
+
hitboxes: this.hitboxes(),
|
|
511
|
+
events: this.lastEvents.map(cloneCombatEvent)
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
combatants(): readonly CombatantSnapshot[] {
|
|
516
|
+
return Array.from(this.combatantsById.values()).sort(compareCombatants).map(snapshotCombatant);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
hitboxes(): readonly ActiveHitboxSnapshot[] {
|
|
520
|
+
return Array.from(this.hitboxesById.values()).sort(compareHitboxes).map(snapshotHitbox);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
private stepOneFrame(): CombatEvent[] {
|
|
524
|
+
const events: CombatEvent[] = [];
|
|
525
|
+
if (this.detectPushboxOverlaps) {
|
|
526
|
+
events.push(...this.collectPushboxEvents());
|
|
527
|
+
}
|
|
528
|
+
if (this.roundLocked) {
|
|
529
|
+
this.frame += 1;
|
|
530
|
+
return events;
|
|
531
|
+
}
|
|
532
|
+
for (const record of this.sortedHitboxRecords()) {
|
|
533
|
+
if (record.expired || record.hitCount >= record.maxHits) {
|
|
534
|
+
record.expired = true;
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
const owner = this.combatantsById.get(record.ownerId);
|
|
538
|
+
if (!owner) {
|
|
539
|
+
record.expired = true;
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
if (owner.knockedOut) {
|
|
543
|
+
record.expired = true;
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
if (isHitboxActive(record)) {
|
|
547
|
+
events.push(...this.resolveHitbox(record, owner));
|
|
548
|
+
if (this.roundLocked) {
|
|
549
|
+
break;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
if (!record.recoveryStarted && record.ageFrames > record.activeFrames.end && record.recoveryFrames > 0) {
|
|
553
|
+
record.recoveryStarted = true;
|
|
554
|
+
owner.recoveryFrames = Math.max(owner.recoveryFrames, record.recoveryFrames);
|
|
555
|
+
events.push({
|
|
556
|
+
type: "recovery-start",
|
|
557
|
+
frame: this.frame,
|
|
558
|
+
combatantId: owner.id,
|
|
559
|
+
frames: record.recoveryFrames
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
if (this.roundLocked) {
|
|
564
|
+
this.expireAllHitboxes(events);
|
|
565
|
+
events.push(...this.tickTimers());
|
|
566
|
+
this.frame += 1;
|
|
567
|
+
return events;
|
|
568
|
+
}
|
|
569
|
+
events.push(...this.tickTimers());
|
|
570
|
+
for (const record of this.sortedHitboxRecords()) {
|
|
571
|
+
const owner = this.combatantsById.get(record.ownerId);
|
|
572
|
+
if (owner && owner.hitStopFrames <= 0) {
|
|
573
|
+
record.ageFrames += 1;
|
|
574
|
+
}
|
|
575
|
+
if (record.ageFrames > record.activeFrames.end + record.recoveryFrames || record.hitCount >= record.maxHits || record.expired) {
|
|
576
|
+
this.expireHitbox(record, events);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
this.frame += 1;
|
|
580
|
+
return events;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
private resolveHitbox(record: ActiveHitboxRecord, owner: CombatantRecord): CombatEvent[] {
|
|
584
|
+
const events: CombatEvent[] = [];
|
|
585
|
+
const hitVolumes = record.boxes.map((volume) => resolveCollisionVolume(withVolumeOwner(volume, owner.id), owner.position, owner.facing));
|
|
586
|
+
for (const defender of this.sortedCombatantRecords()) {
|
|
587
|
+
if (!canHitCombatant(record, owner, defender)) {
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
if (defender.invulnerableFrames > 0) {
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
if (record.hitOnce && record.hitTargets.has(defender.id)) {
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
const blockEvent = defender.blocking ? this.tryResolveBlock(record, owner, defender, hitVolumes) : null;
|
|
597
|
+
if (blockEvent) {
|
|
598
|
+
record.hitTargets.add(defender.id);
|
|
599
|
+
record.hitCount += 1;
|
|
600
|
+
events.push(blockEvent);
|
|
601
|
+
events.push(...this.applyBlockTimers(record, owner, defender));
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
const hitEvent = this.tryResolveHit(record, owner, defender, hitVolumes);
|
|
605
|
+
if (hitEvent) {
|
|
606
|
+
record.hitTargets.add(defender.id);
|
|
607
|
+
record.hitCount += 1;
|
|
608
|
+
events.push(hitEvent);
|
|
609
|
+
events.push(...this.applyHitTimers(record, owner, defender));
|
|
610
|
+
if (defender.health <= 0 && !defender.knockedOut) {
|
|
611
|
+
events.push(...this.knockOutCombatant(defender, owner, record));
|
|
612
|
+
if (this.roundLocked) {
|
|
613
|
+
break;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return events;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
private tryResolveBlock(
|
|
622
|
+
record: ActiveHitboxRecord,
|
|
623
|
+
owner: CombatantRecord,
|
|
624
|
+
defender: CombatantRecord,
|
|
625
|
+
hitVolumes: readonly ResolvedCollisionVolume[]
|
|
626
|
+
): CombatBlockEvent | null {
|
|
627
|
+
const guardVolumes = (defender.guardBoxes.length > 0 ? defender.guardBoxes : defender.hurtboxes).map((volume) =>
|
|
628
|
+
resolveCollisionVolume(withVolumeOwner(volume, defender.id), defender.position, defender.facing)
|
|
629
|
+
);
|
|
630
|
+
const pair = firstOverlappingPair(hitVolumes, guardVolumes);
|
|
631
|
+
if (!pair) {
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
defender.guard = Math.max(0, defender.guard - record.guardDamage);
|
|
635
|
+
return {
|
|
636
|
+
type: "blocked",
|
|
637
|
+
frame: this.frame,
|
|
638
|
+
attackerId: owner.id,
|
|
639
|
+
defenderId: defender.id,
|
|
640
|
+
hitboxId: record.id,
|
|
641
|
+
moveId: record.moveId,
|
|
642
|
+
hitboxVolumeId: pair.attack.id,
|
|
643
|
+
guardboxId: pair.defense.id,
|
|
644
|
+
guardDamage: record.guardDamage,
|
|
645
|
+
defenderGuard: defender.guard,
|
|
646
|
+
blockstunFrames: record.blockstunFrames,
|
|
647
|
+
hitStopFrames: record.hitStopFrames,
|
|
648
|
+
knockback: orientVec3(record.blockKnockback, owner.facing),
|
|
649
|
+
point: contactPoint(pair.attack, pair.defense),
|
|
650
|
+
normal: combatNormal(owner, defender)
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
private tryResolveHit(
|
|
655
|
+
record: ActiveHitboxRecord,
|
|
656
|
+
owner: CombatantRecord,
|
|
657
|
+
defender: CombatantRecord,
|
|
658
|
+
hitVolumes: readonly ResolvedCollisionVolume[]
|
|
659
|
+
): CombatHitEvent | null {
|
|
660
|
+
const hurtVolumes = defender.hurtboxes.map((volume) => resolveCollisionVolume(withVolumeOwner(volume, defender.id), defender.position, defender.facing));
|
|
661
|
+
const pair = firstOverlappingPair(hitVolumes, hurtVolumes);
|
|
662
|
+
if (!pair) {
|
|
663
|
+
return null;
|
|
664
|
+
}
|
|
665
|
+
defender.health = Math.max(0, defender.health - record.damage);
|
|
666
|
+
return {
|
|
667
|
+
type: "hit",
|
|
668
|
+
frame: this.frame,
|
|
669
|
+
attackerId: owner.id,
|
|
670
|
+
defenderId: defender.id,
|
|
671
|
+
hitboxId: record.id,
|
|
672
|
+
moveId: record.moveId,
|
|
673
|
+
hitboxVolumeId: pair.attack.id,
|
|
674
|
+
hurtboxId: pair.defense.id,
|
|
675
|
+
damage: record.damage,
|
|
676
|
+
defenderHealth: defender.health,
|
|
677
|
+
hitstunFrames: record.hitstunFrames,
|
|
678
|
+
hitStopFrames: record.hitStopFrames,
|
|
679
|
+
knockback: orientVec3(record.knockback, owner.facing),
|
|
680
|
+
point: contactPoint(pair.attack, pair.defense),
|
|
681
|
+
normal: combatNormal(owner, defender)
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
private knockOutCombatant(
|
|
686
|
+
defender: CombatantRecord,
|
|
687
|
+
owner: CombatantRecord,
|
|
688
|
+
record: ActiveHitboxRecord
|
|
689
|
+
): CombatKnockoutEvent[] {
|
|
690
|
+
defender.knockedOut = true;
|
|
691
|
+
defender.blocking = false;
|
|
692
|
+
defender.invulnerableFrames = 0;
|
|
693
|
+
defender.hitStopFrames = 0;
|
|
694
|
+
defender.hitstunFrames = 0;
|
|
695
|
+
defender.blockstunFrames = 0;
|
|
696
|
+
defender.recoveryFrames = 0;
|
|
697
|
+
if (this.lockOnKnockout) {
|
|
698
|
+
this.roundLocked = true;
|
|
699
|
+
}
|
|
700
|
+
return [
|
|
701
|
+
{
|
|
702
|
+
type: "knockout",
|
|
703
|
+
frame: this.frame,
|
|
704
|
+
combatantId: defender.id,
|
|
705
|
+
attackerId: owner.id,
|
|
706
|
+
moveId: record.moveId
|
|
707
|
+
}
|
|
708
|
+
];
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
private applyHitTimers(record: ActiveHitboxRecord, owner: CombatantRecord, defender: CombatantRecord): CombatTimerEvent[] {
|
|
712
|
+
const events: CombatTimerEvent[] = [];
|
|
713
|
+
events.push(...setTimer(defender, "hitstunFrames", record.hitstunFrames, "hitstun-start", this.frame));
|
|
714
|
+
events.push(...setTimer(defender, "hitStopFrames", record.hitStopFrames, "hitstop-start", this.frame));
|
|
715
|
+
events.push(...setTimer(owner, "hitStopFrames", record.selfHitStopFrames, "hitstop-start", this.frame));
|
|
716
|
+
return events;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
private applyBlockTimers(record: ActiveHitboxRecord, owner: CombatantRecord, defender: CombatantRecord): CombatTimerEvent[] {
|
|
720
|
+
const events: CombatTimerEvent[] = [];
|
|
721
|
+
events.push(...setTimer(defender, "blockstunFrames", record.blockstunFrames, "blockstun-start", this.frame));
|
|
722
|
+
events.push(...setTimer(defender, "hitStopFrames", record.hitStopFrames, "hitstop-start", this.frame));
|
|
723
|
+
events.push(...setTimer(owner, "hitStopFrames", record.selfHitStopFrames, "hitstop-start", this.frame));
|
|
724
|
+
return events;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
private tickTimers(): CombatTimerEvent[] {
|
|
728
|
+
const events: CombatTimerEvent[] = [];
|
|
729
|
+
for (const combatant of this.sortedCombatantRecords()) {
|
|
730
|
+
events.push(...tickTimer(combatant, "hitStopFrames", "hitstop-end", this.frame));
|
|
731
|
+
events.push(...tickTimer(combatant, "hitstunFrames", "hitstun-end", this.frame));
|
|
732
|
+
events.push(...tickTimer(combatant, "blockstunFrames", "blockstun-end", this.frame));
|
|
733
|
+
events.push(...tickTimer(combatant, "recoveryFrames", "recovery-end", this.frame));
|
|
734
|
+
if (combatant.invulnerableFrames > 0) {
|
|
735
|
+
combatant.invulnerableFrames -= 1;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
return events;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
private collectPushboxEvents(): CombatPushboxEvent[] {
|
|
742
|
+
const events: CombatPushboxEvent[] = [];
|
|
743
|
+
const combatants = this.sortedCombatantRecords().filter((combatant) => combatant.pushbox !== null && !combatant.knockedOut);
|
|
744
|
+
for (let aIndex = 0; aIndex < combatants.length; aIndex += 1) {
|
|
745
|
+
for (let bIndex = aIndex + 1; bIndex < combatants.length; bIndex += 1) {
|
|
746
|
+
const combatantA = combatants[aIndex]!;
|
|
747
|
+
const combatantB = combatants[bIndex]!;
|
|
748
|
+
const pushA = resolveCollisionVolume(withVolumeOwner(combatantA.pushbox!, combatantA.id), combatantA.position, combatantA.facing);
|
|
749
|
+
const pushB = resolveCollisionVolume(withVolumeOwner(combatantB.pushbox!, combatantB.id), combatantB.position, combatantB.facing);
|
|
750
|
+
if (!overlapsVolume(pushA, pushB)) {
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
const penetration = aabbPenetration(pushA.bounds, pushB.bounds);
|
|
754
|
+
if (!penetration) {
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
events.push({
|
|
758
|
+
type: "pushbox-overlap",
|
|
759
|
+
frame: this.frame,
|
|
760
|
+
combatantA: combatantA.id,
|
|
761
|
+
combatantB: combatantB.id,
|
|
762
|
+
normal: penetration.normal,
|
|
763
|
+
penetration: penetration.depth
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
return events;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
private expireHitbox(record: ActiveHitboxRecord, events: CombatEvent[]): void {
|
|
771
|
+
if (!this.hitboxesById.has(record.id)) {
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
if (record.hitCount === 0) {
|
|
775
|
+
events.push({
|
|
776
|
+
type: "whiff",
|
|
777
|
+
frame: this.frame,
|
|
778
|
+
hitboxId: record.id,
|
|
779
|
+
ownerId: record.ownerId,
|
|
780
|
+
moveId: record.moveId
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
events.push({
|
|
784
|
+
type: "hitbox-expired",
|
|
785
|
+
frame: this.frame,
|
|
786
|
+
hitboxId: record.id,
|
|
787
|
+
ownerId: record.ownerId,
|
|
788
|
+
moveId: record.moveId,
|
|
789
|
+
hitCount: record.hitCount
|
|
790
|
+
});
|
|
791
|
+
this.hitboxesById.delete(record.id);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
private expireAllHitboxes(events: CombatEvent[]): void {
|
|
795
|
+
for (const record of this.sortedHitboxRecords()) {
|
|
796
|
+
this.expireHitbox(record, events);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
private requireCombatant(id: CombatantId): CombatantRecord {
|
|
801
|
+
const combatant = this.combatantsById.get(id);
|
|
802
|
+
if (!combatant) {
|
|
803
|
+
throw new Error(`Combatant ${String(id)} does not exist.`);
|
|
804
|
+
}
|
|
805
|
+
return combatant;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
private sortedCombatantRecords(): CombatantRecord[] {
|
|
809
|
+
return Array.from(this.combatantsById.values()).sort(compareCombatants);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
private sortedHitboxRecords(): ActiveHitboxRecord[] {
|
|
813
|
+
return Array.from(this.hitboxesById.values()).sort(compareHitboxes);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
private drainPendingEvents(): CombatEvent[] {
|
|
817
|
+
const events = [...this.pendingEvents];
|
|
818
|
+
this.pendingEvents.length = 0;
|
|
819
|
+
return events;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function createCombatantRecord(descriptor: CombatantDescriptor): CombatantRecord {
|
|
824
|
+
const maxHealth = positiveFinite(descriptor.maxHealth ?? descriptor.health ?? 100, "combatant maxHealth");
|
|
825
|
+
const maxGuard = positiveFinite(descriptor.maxGuard ?? descriptor.guard ?? 100, "combatant maxGuard");
|
|
826
|
+
const position = cloneVec3(descriptor.position ?? [0, 0.9, 0]);
|
|
827
|
+
validateFiniteVec3(position, "combatant position");
|
|
828
|
+
const health = clampNonNegative(descriptor.health ?? maxHealth, maxHealth);
|
|
829
|
+
const guard = clampNonNegative(descriptor.guard ?? maxGuard, maxGuard);
|
|
830
|
+
const facing = descriptor.facing && descriptor.facing < 0 ? -1 : 1;
|
|
831
|
+
const blocking = descriptor.blocking ?? false;
|
|
832
|
+
const invulnerableFrames = nonNegativeInteger(descriptor.invulnerableFrames ?? 0, "combatant invulnerableFrames");
|
|
833
|
+
const meter = nonNegativeFinite(descriptor.meter ?? 0, "combatant meter");
|
|
834
|
+
return {
|
|
835
|
+
id: descriptor.id,
|
|
836
|
+
team: descriptor.team,
|
|
837
|
+
position,
|
|
838
|
+
facing,
|
|
839
|
+
hurtboxes: (descriptor.hurtboxes ?? [hurtbox({ id: "body", halfExtents: [0.36, 0.86, 0.28] })]).map((volume) => requireVolumeKind(volume, "hurtbox")),
|
|
840
|
+
guardBoxes: (descriptor.guardBoxes ?? [guardbox({ id: "guard", offset: [0.18, 0, 0], halfExtents: [0.28, 0.82, 0.3] })]).map((volume) =>
|
|
841
|
+
requireVolumeKind(volume, "guardbox")
|
|
842
|
+
),
|
|
843
|
+
pushbox: descriptor.pushbox === null ? null : requireVolumeKind(descriptor.pushbox ?? pushbox({ id: "push", halfExtents: [0.32, 0.84, 0.26] }), "pushbox"),
|
|
844
|
+
health,
|
|
845
|
+
maxHealth,
|
|
846
|
+
guard,
|
|
847
|
+
maxGuard,
|
|
848
|
+
meter,
|
|
849
|
+
blocking,
|
|
850
|
+
invulnerableFrames,
|
|
851
|
+
hitStopFrames: 0,
|
|
852
|
+
hitstunFrames: 0,
|
|
853
|
+
blockstunFrames: 0,
|
|
854
|
+
recoveryFrames: 0,
|
|
855
|
+
knockedOut: descriptor.knockedOut ?? health <= 0,
|
|
856
|
+
metadata: descriptor.metadata,
|
|
857
|
+
baseline: {
|
|
858
|
+
position: cloneVec3(position),
|
|
859
|
+
facing,
|
|
860
|
+
health,
|
|
861
|
+
maxHealth,
|
|
862
|
+
guard,
|
|
863
|
+
maxGuard,
|
|
864
|
+
meter,
|
|
865
|
+
blocking,
|
|
866
|
+
invulnerableFrames
|
|
867
|
+
}
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function applyCombatantDescriptor(record: CombatantRecord, descriptor: CombatantDescriptor): void {
|
|
872
|
+
if (descriptor.team !== undefined) {
|
|
873
|
+
record.team = descriptor.team;
|
|
874
|
+
}
|
|
875
|
+
if (descriptor.position) {
|
|
876
|
+
validateFiniteVec3(descriptor.position, "combatant position");
|
|
877
|
+
record.position = cloneVec3(descriptor.position);
|
|
878
|
+
}
|
|
879
|
+
if (descriptor.facing) {
|
|
880
|
+
record.facing = descriptor.facing < 0 ? -1 : 1;
|
|
881
|
+
}
|
|
882
|
+
if (descriptor.hurtboxes) {
|
|
883
|
+
record.hurtboxes = descriptor.hurtboxes.map((volume) => requireVolumeKind(volume, "hurtbox"));
|
|
884
|
+
}
|
|
885
|
+
if (descriptor.guardBoxes) {
|
|
886
|
+
record.guardBoxes = descriptor.guardBoxes.map((volume) => requireVolumeKind(volume, "guardbox"));
|
|
887
|
+
}
|
|
888
|
+
if (descriptor.pushbox !== undefined) {
|
|
889
|
+
record.pushbox = descriptor.pushbox === null ? null : requireVolumeKind(descriptor.pushbox, "pushbox");
|
|
890
|
+
}
|
|
891
|
+
if (descriptor.maxHealth !== undefined) {
|
|
892
|
+
record.maxHealth = positiveFinite(descriptor.maxHealth, "combatant maxHealth");
|
|
893
|
+
}
|
|
894
|
+
if (descriptor.health !== undefined) {
|
|
895
|
+
record.health = clampNonNegative(descriptor.health, record.maxHealth);
|
|
896
|
+
record.knockedOut = descriptor.knockedOut ?? record.health <= 0;
|
|
897
|
+
}
|
|
898
|
+
if (descriptor.maxGuard !== undefined) {
|
|
899
|
+
record.maxGuard = positiveFinite(descriptor.maxGuard, "combatant maxGuard");
|
|
900
|
+
}
|
|
901
|
+
if (descriptor.guard !== undefined) {
|
|
902
|
+
record.guard = clampNonNegative(descriptor.guard, record.maxGuard);
|
|
903
|
+
}
|
|
904
|
+
if (descriptor.meter !== undefined) {
|
|
905
|
+
record.meter = nonNegativeFinite(descriptor.meter, "combatant meter");
|
|
906
|
+
}
|
|
907
|
+
if (descriptor.blocking !== undefined) {
|
|
908
|
+
record.blocking = descriptor.blocking;
|
|
909
|
+
}
|
|
910
|
+
if (descriptor.invulnerableFrames !== undefined) {
|
|
911
|
+
record.invulnerableFrames = nonNegativeInteger(descriptor.invulnerableFrames, "combatant invulnerableFrames");
|
|
912
|
+
}
|
|
913
|
+
if (descriptor.knockedOut !== undefined) {
|
|
914
|
+
record.knockedOut = descriptor.knockedOut;
|
|
915
|
+
if (record.knockedOut) {
|
|
916
|
+
record.health = 0;
|
|
917
|
+
record.blocking = false;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
if (descriptor.metadata !== undefined) {
|
|
921
|
+
record.metadata = descriptor.metadata;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function canHitCombatant(record: ActiveHitboxRecord, owner: CombatantRecord, defender: CombatantRecord): boolean {
|
|
926
|
+
if (owner.knockedOut || defender.knockedOut) {
|
|
927
|
+
return false;
|
|
928
|
+
}
|
|
929
|
+
if (owner.id === defender.id && !record.canHitOwner) {
|
|
930
|
+
return false;
|
|
931
|
+
}
|
|
932
|
+
if (!record.canHitSameTeam && record.team !== undefined && defender.team !== undefined && record.team === defender.team) {
|
|
933
|
+
return false;
|
|
934
|
+
}
|
|
935
|
+
return true;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function resetCombatantRecord(record: CombatantRecord): void {
|
|
939
|
+
record.position = cloneVec3(record.baseline.position);
|
|
940
|
+
record.facing = record.baseline.facing;
|
|
941
|
+
record.health = record.baseline.health;
|
|
942
|
+
record.maxHealth = record.baseline.maxHealth;
|
|
943
|
+
record.guard = record.baseline.guard;
|
|
944
|
+
record.maxGuard = record.baseline.maxGuard;
|
|
945
|
+
record.meter = record.baseline.meter;
|
|
946
|
+
record.blocking = record.baseline.blocking;
|
|
947
|
+
record.invulnerableFrames = record.baseline.invulnerableFrames;
|
|
948
|
+
record.hitStopFrames = 0;
|
|
949
|
+
record.hitstunFrames = 0;
|
|
950
|
+
record.blockstunFrames = 0;
|
|
951
|
+
record.recoveryFrames = 0;
|
|
952
|
+
record.knockedOut = record.health <= 0;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function firstOverlappingPair(
|
|
956
|
+
attackVolumes: readonly ResolvedCollisionVolume[],
|
|
957
|
+
defenseVolumes: readonly ResolvedCollisionVolume[]
|
|
958
|
+
): { readonly attack: ResolvedCollisionVolume; readonly defense: ResolvedCollisionVolume } | null {
|
|
959
|
+
const attacks = [...attackVolumes].sort(compareResolvedVolumes);
|
|
960
|
+
const defenses = [...defenseVolumes].sort(compareResolvedVolumes);
|
|
961
|
+
for (const attack of attacks) {
|
|
962
|
+
for (const defense of defenses) {
|
|
963
|
+
if (overlapsVolume(attack, defense)) {
|
|
964
|
+
return { attack, defense };
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
return null;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
function setTimer(
|
|
972
|
+
combatant: CombatantRecord,
|
|
973
|
+
key: "hitStopFrames" | "hitstunFrames" | "blockstunFrames" | "recoveryFrames",
|
|
974
|
+
frames: number,
|
|
975
|
+
eventType: "hitstop-start" | "hitstun-start" | "blockstun-start" | "recovery-start",
|
|
976
|
+
frame: number
|
|
977
|
+
): CombatTimerEvent[] {
|
|
978
|
+
if (frames <= 0 || combatant[key] >= frames) {
|
|
979
|
+
return [];
|
|
980
|
+
}
|
|
981
|
+
combatant[key] = frames;
|
|
982
|
+
return [
|
|
983
|
+
{
|
|
984
|
+
type: eventType,
|
|
985
|
+
frame,
|
|
986
|
+
combatantId: combatant.id,
|
|
987
|
+
frames
|
|
988
|
+
} as CombatTimerEvent
|
|
989
|
+
];
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function tickTimer(
|
|
993
|
+
combatant: CombatantRecord,
|
|
994
|
+
key: "hitStopFrames" | "hitstunFrames" | "blockstunFrames" | "recoveryFrames",
|
|
995
|
+
eventType: "hitstop-end" | "hitstun-end" | "blockstun-end" | "recovery-end",
|
|
996
|
+
frame: number
|
|
997
|
+
): CombatTimerEvent[] {
|
|
998
|
+
if (combatant[key] <= 0) {
|
|
999
|
+
return [];
|
|
1000
|
+
}
|
|
1001
|
+
combatant[key] -= 1;
|
|
1002
|
+
if (combatant[key] > 0) {
|
|
1003
|
+
return [];
|
|
1004
|
+
}
|
|
1005
|
+
return [
|
|
1006
|
+
{
|
|
1007
|
+
type: eventType,
|
|
1008
|
+
frame,
|
|
1009
|
+
combatantId: combatant.id
|
|
1010
|
+
} as CombatTimerEvent
|
|
1011
|
+
];
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
function isHitboxActive(record: ActiveHitboxRecord): boolean {
|
|
1015
|
+
return record.ageFrames >= record.activeFrames.start && record.ageFrames <= record.activeFrames.end;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function normalizeActiveFrames(value: ActiveFrameWindow): ActiveFrameWindow {
|
|
1019
|
+
const start = nonNegativeInteger(value.start, "hitbox activeFrames.start");
|
|
1020
|
+
const end = nonNegativeInteger(value.end, "hitbox activeFrames.end");
|
|
1021
|
+
if (end < start) {
|
|
1022
|
+
throw new Error("hitbox activeFrames.end must be greater than or equal to activeFrames.start.");
|
|
1023
|
+
}
|
|
1024
|
+
return { start, end };
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function requireVolumeKind(volume: CollisionVolume, kind: CollisionVolume["kind"]): CollisionVolume {
|
|
1028
|
+
if (volume.kind !== kind) {
|
|
1029
|
+
throw new Error(`Expected ${kind} volume but received ${volume.kind}.`);
|
|
1030
|
+
}
|
|
1031
|
+
return volume;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function contactPoint(attack: ResolvedCollisionVolume, defense: ResolvedCollisionVolume): Vec3 {
|
|
1035
|
+
return [
|
|
1036
|
+
(Math.max(attack.bounds.min[0], defense.bounds.min[0]) + Math.min(attack.bounds.max[0], defense.bounds.max[0])) * 0.5,
|
|
1037
|
+
(Math.max(attack.bounds.min[1], defense.bounds.min[1]) + Math.min(attack.bounds.max[1], defense.bounds.max[1])) * 0.5,
|
|
1038
|
+
(Math.max(attack.bounds.min[2], defense.bounds.min[2]) + Math.min(attack.bounds.max[2], defense.bounds.max[2])) * 0.5
|
|
1039
|
+
];
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function combatNormal(owner: CombatantRecord, defender: CombatantRecord): Vec3 {
|
|
1043
|
+
if (owner.position[0] === defender.position[0]) {
|
|
1044
|
+
return [owner.facing, 0, 0];
|
|
1045
|
+
}
|
|
1046
|
+
return [owner.position[0] < defender.position[0] ? 1 : -1, 0, 0];
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function orientVec3(value: Vec3, facing: CombatFacing): Vec3 {
|
|
1050
|
+
return [value[0] * facing, value[1], value[2]];
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function snapshotCombatant(record: CombatantRecord): CombatantSnapshot {
|
|
1054
|
+
return {
|
|
1055
|
+
id: record.id,
|
|
1056
|
+
...(record.team === undefined ? {} : { team: record.team }),
|
|
1057
|
+
position: cloneVec3(record.position),
|
|
1058
|
+
facing: record.facing,
|
|
1059
|
+
health: record.health,
|
|
1060
|
+
maxHealth: record.maxHealth,
|
|
1061
|
+
guard: record.guard,
|
|
1062
|
+
maxGuard: record.maxGuard,
|
|
1063
|
+
meter: record.meter,
|
|
1064
|
+
blocking: record.blocking,
|
|
1065
|
+
invulnerableFrames: record.invulnerableFrames,
|
|
1066
|
+
hitStopFrames: record.hitStopFrames,
|
|
1067
|
+
hitstunFrames: record.hitstunFrames,
|
|
1068
|
+
blockstunFrames: record.blockstunFrames,
|
|
1069
|
+
recoveryFrames: record.recoveryFrames,
|
|
1070
|
+
knockedOut: record.knockedOut
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function snapshotHitbox(record: ActiveHitboxRecord): ActiveHitboxSnapshot {
|
|
1075
|
+
return {
|
|
1076
|
+
id: record.id,
|
|
1077
|
+
ownerId: record.ownerId,
|
|
1078
|
+
moveId: record.moveId,
|
|
1079
|
+
ageFrames: record.ageFrames,
|
|
1080
|
+
activeFrames: record.activeFrames,
|
|
1081
|
+
recoveryFrames: record.recoveryFrames,
|
|
1082
|
+
hitCount: record.hitCount,
|
|
1083
|
+
expired: record.expired
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
function cloneCombatEvent(event: CombatEvent): CombatEvent {
|
|
1088
|
+
switch (event.type) {
|
|
1089
|
+
case "hit":
|
|
1090
|
+
return {
|
|
1091
|
+
...event,
|
|
1092
|
+
knockback: cloneVec3(event.knockback),
|
|
1093
|
+
point: cloneVec3(event.point),
|
|
1094
|
+
normal: cloneVec3(event.normal)
|
|
1095
|
+
};
|
|
1096
|
+
case "blocked":
|
|
1097
|
+
return {
|
|
1098
|
+
...event,
|
|
1099
|
+
knockback: cloneVec3(event.knockback),
|
|
1100
|
+
point: cloneVec3(event.point),
|
|
1101
|
+
normal: cloneVec3(event.normal)
|
|
1102
|
+
};
|
|
1103
|
+
case "pushbox-overlap":
|
|
1104
|
+
return {
|
|
1105
|
+
...event,
|
|
1106
|
+
normal: cloneVec3(event.normal)
|
|
1107
|
+
};
|
|
1108
|
+
default:
|
|
1109
|
+
return { ...event };
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function compareCombatants(a: CombatantRecord, b: CombatantRecord): number {
|
|
1114
|
+
return compareIds(a.id, b.id);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function compareHitboxes(a: ActiveHitboxRecord, b: ActiveHitboxRecord): number {
|
|
1118
|
+
if (a.priority !== b.priority) {
|
|
1119
|
+
return b.priority - a.priority;
|
|
1120
|
+
}
|
|
1121
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function compareResolvedVolumes(a: ResolvedCollisionVolume, b: ResolvedCollisionVolume): number {
|
|
1125
|
+
const left = `${a.kind}:${a.id}`;
|
|
1126
|
+
const right = `${b.kind}:${b.id}`;
|
|
1127
|
+
if (left === right) {
|
|
1128
|
+
return 0;
|
|
1129
|
+
}
|
|
1130
|
+
return left < right ? -1 : 1;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function compareIds(a: CombatantId, b: CombatantId): number {
|
|
1134
|
+
const left = String(a);
|
|
1135
|
+
const right = String(b);
|
|
1136
|
+
if (left === right) {
|
|
1137
|
+
return 0;
|
|
1138
|
+
}
|
|
1139
|
+
return left < right ? -1 : 1;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
function finite(value: number, name: string): number {
|
|
1143
|
+
if (!Number.isFinite(value)) {
|
|
1144
|
+
throw new Error(`${name} must be finite.`);
|
|
1145
|
+
}
|
|
1146
|
+
return value;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function nonNegativeFinite(value: number, name: string): number {
|
|
1150
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
1151
|
+
throw new Error(`${name} must be finite and non-negative.`);
|
|
1152
|
+
}
|
|
1153
|
+
return value;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
function positiveFinite(value: number, name: string): number {
|
|
1157
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
1158
|
+
throw new Error(`${name} must be finite and positive.`);
|
|
1159
|
+
}
|
|
1160
|
+
return value;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function nonNegativeInteger(value: number, name: string): number {
|
|
1164
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
1165
|
+
throw new Error(`${name} must be a non-negative integer.`);
|
|
1166
|
+
}
|
|
1167
|
+
return value;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
function positiveInteger(value: number, name: string): number {
|
|
1171
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
1172
|
+
throw new Error(`${name} must be a positive integer.`);
|
|
1173
|
+
}
|
|
1174
|
+
return value;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
function clampNonNegative(value: number, max: number): number {
|
|
1178
|
+
return Math.max(0, Math.min(max, nonNegativeFinite(value, "combatant value")));
|
|
1179
|
+
}
|