@typeonce/ritmo-math 0.12.0 → 0.14.0
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/CHANGELOG.md +60 -0
- package/README.md +3 -3
- package/dist/Aabb.d.ts +15 -15
- package/dist/Aabb.d.ts.map +1 -1
- package/dist/Aabb.js +13 -5
- package/dist/Aabb.js.map +1 -1
- package/dist/Collide2.d.ts +48 -14
- package/dist/Collide2.d.ts.map +1 -1
- package/dist/Collide2.js +140 -17
- package/dist/Collide2.js.map +1 -1
- package/dist/Random.d.ts +16 -6
- package/dist/Random.d.ts.map +1 -1
- package/dist/Random.js +13 -3
- package/dist/Random.js.map +1 -1
- package/dist/Scalar.d.ts +26 -7
- package/dist/Scalar.d.ts.map +1 -1
- package/dist/Scalar.js +6 -3
- package/dist/Scalar.js.map +1 -1
- package/dist/Segment2.d.ts +56 -0
- package/dist/Segment2.d.ts.map +1 -0
- package/dist/Segment2.js +32 -0
- package/dist/Segment2.js.map +1 -0
- package/dist/Size2.d.ts +15 -15
- package/dist/Size2.d.ts.map +1 -1
- package/dist/Size2.js +13 -5
- package/dist/Size2.js.map +1 -1
- package/dist/Vector2.d.ts +15 -15
- package/dist/Vector2.d.ts.map +1 -1
- package/dist/Vector2.js +13 -5
- package/dist/Vector2.js.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -4
- package/dist/index.js.map +1 -1
- package/dist/internal/aabb.d.ts +9 -2
- package/dist/internal/aabb.d.ts.map +1 -1
- package/dist/internal/aabb.js +3 -1
- package/dist/internal/aabb.js.map +1 -1
- package/dist/internal/codec.d.ts +9 -0
- package/dist/internal/codec.d.ts.map +1 -0
- package/dist/internal/codec.js +16 -0
- package/dist/internal/codec.js.map +1 -0
- package/dist/internal/scalar.d.ts +3 -4
- package/dist/internal/scalar.d.ts.map +1 -1
- package/dist/internal/scalar.js +19 -12
- package/dist/internal/scalar.js.map +1 -1
- package/dist/internal/size2.d.ts +9 -2
- package/dist/internal/size2.d.ts.map +1 -1
- package/dist/internal/size2.js +3 -1
- package/dist/internal/size2.js.map +1 -1
- package/dist/internal/vector2.d.ts +9 -2
- package/dist/internal/vector2.d.ts.map +1 -1
- package/dist/internal/vector2.js +3 -1
- package/dist/internal/vector2.js.map +1 -1
- package/package.json +2 -2
- package/src/Aabb.ts +15 -16
- package/src/Collide2.ts +153 -22
- package/src/Random.ts +21 -7
- package/src/Scalar.ts +27 -7
- package/src/Segment2.ts +71 -0
- package/src/Size2.ts +15 -16
- package/src/Vector2.ts +15 -16
- package/src/index.ts +6 -4
- package/src/internal/aabb.ts +8 -2
- package/src/internal/codec.ts +21 -0
- package/src/internal/scalar.ts +24 -13
- package/src/internal/size2.ts +8 -2
- package/src/internal/vector2.ts +8 -2
package/src/Collide2.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Continuous collision for moving circles against static
|
|
2
|
+
* Continuous collision for moving circles against static walls (axis-aligned
|
|
3
|
+
* boxes and line segments) and circles.
|
|
3
4
|
*
|
|
4
|
-
* Movement is swept, not sampled: `
|
|
5
|
+
* Movement is swept, not sampled: `sweepCircle` finds the first time of
|
|
5
6
|
* contact along the whole displacement, so fast movers (dashes, projectiles)
|
|
6
7
|
* cannot skip over a wall between two steps. `moveCircle` stops at walls and
|
|
7
|
-
* slides along them.
|
|
8
|
+
* slides along them. Walls are passed as {@link Walls}: `Box2` boxes for a
|
|
9
|
+
* grid of rooms, `Segment2` segments for walls at any angle, or both.
|
|
8
10
|
*
|
|
9
11
|
* Every query returns `undefined` for "no contact", never throws, and
|
|
10
12
|
* allocates only its result. Inputs are expected to be finite.
|
|
@@ -12,7 +14,7 @@
|
|
|
12
14
|
* @example
|
|
13
15
|
* ```ts
|
|
14
16
|
* // Move a body by its velocity this step, sliding along the walls.
|
|
15
|
-
* const moved = Collide2.moveCircle(position, Vec2.scale(velocity, dt), radius, walls)
|
|
17
|
+
* const moved = Collide2.moveCircle(position, Vec2.scale(velocity, dt), radius, { boxes: walls })
|
|
16
18
|
* if (moved.blocked) bumpedWall()
|
|
17
19
|
* ```
|
|
18
20
|
*
|
|
@@ -30,6 +32,7 @@
|
|
|
30
32
|
*/
|
|
31
33
|
import type { Box2 } from "./Box2.ts"
|
|
32
34
|
import * as Ops from "./Ops.ts"
|
|
35
|
+
import type { Segment2 } from "./Segment2.ts"
|
|
33
36
|
import type { Vec2 } from "./Vec2.ts"
|
|
34
37
|
|
|
35
38
|
/**
|
|
@@ -40,11 +43,27 @@ import type { Vec2 } from "./Vec2.ts"
|
|
|
40
43
|
export interface Hit {
|
|
41
44
|
/** Fraction of the displacement travelled before contact, in [0, 1]. */
|
|
42
45
|
readonly time: number
|
|
43
|
-
/** Unit contact normal, pointing away from the
|
|
46
|
+
/** Unit contact normal, pointing away from the wall. */
|
|
44
47
|
readonly nx: number
|
|
45
48
|
readonly ny: number
|
|
46
49
|
}
|
|
47
50
|
|
|
51
|
+
/**
|
|
52
|
+
* The static walls a circle collides with: axis-aligned `boxes`, line
|
|
53
|
+
* `segments`, or both. A missing list is no walls of that kind.
|
|
54
|
+
*
|
|
55
|
+
* @category Collision Types
|
|
56
|
+
*/
|
|
57
|
+
export interface Walls {
|
|
58
|
+
readonly boxes?: ReadonlyArray<Box2>
|
|
59
|
+
readonly segments?: ReadonlyArray<Segment2>
|
|
60
|
+
/** Not a setting: an array of walls is passed by kind. */
|
|
61
|
+
readonly length?: "pass walls by kind: { boxes: walls } or { segments: walls }"
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const noBoxes: ReadonlyArray<Box2> = []
|
|
65
|
+
const noSegments: ReadonlyArray<Segment2> = []
|
|
66
|
+
|
|
48
67
|
/**
|
|
49
68
|
* Where a moved circle ended up.
|
|
50
69
|
*
|
|
@@ -170,7 +189,61 @@ export const sweepCircleBox = (origin: Vec2, delta: Vec2, radius: number, box: B
|
|
|
170
189
|
}
|
|
171
190
|
|
|
172
191
|
/**
|
|
173
|
-
* First contact of a
|
|
192
|
+
* First contact of a circle of `radius` at `origin` moving by `delta` with
|
|
193
|
+
* `segment`, or `undefined`. Contact is computed against the segment
|
|
194
|
+
* thickened by the radius with round ends (the exact Minkowski sum), so the
|
|
195
|
+
* circle slides along it and around its ends. A circle that starts exactly
|
|
196
|
+
* touching it and moves into it hits at time 0. A circle that starts
|
|
197
|
+
* overlapping the segment is not reported; resolve it with `pushOut` first.
|
|
198
|
+
*
|
|
199
|
+
* @category Queries
|
|
200
|
+
*/
|
|
201
|
+
export const sweepCircleSegment = (origin: Vec2, delta: Vec2, radius: number, segment: Segment2): Hit | undefined => {
|
|
202
|
+
const ex = segment.bx - segment.ax
|
|
203
|
+
const ey = segment.by - segment.ay
|
|
204
|
+
const lengthSquared = ex * ex + ey * ey
|
|
205
|
+
if (lengthSquared > 1e-12) {
|
|
206
|
+
// The flat side facing the circle, `radius` away from the segment's line.
|
|
207
|
+
const length = Math.sqrt(lengthSquared)
|
|
208
|
+
let nx = -ey / length
|
|
209
|
+
let ny = ex / length
|
|
210
|
+
let side = (origin.x - segment.ax) * nx + (origin.y - segment.ay) * ny
|
|
211
|
+
if (side < 0) {
|
|
212
|
+
nx = -nx
|
|
213
|
+
ny = -ny
|
|
214
|
+
side = -side
|
|
215
|
+
}
|
|
216
|
+
if (side < radius) {
|
|
217
|
+
// Beside the line: overlapping when level with the segment, otherwise
|
|
218
|
+
// only its ends are in the way.
|
|
219
|
+
const along = ((origin.x - segment.ax) * ex + (origin.y - segment.ay) * ey) / lengthSquared
|
|
220
|
+
if (along >= 0 && along <= 1) return undefined
|
|
221
|
+
} else {
|
|
222
|
+
const into = delta.x * nx + delta.y * ny
|
|
223
|
+
if (into < 0) {
|
|
224
|
+
// `0 +` turns a -0 (touching at the start) into 0.
|
|
225
|
+
const time = 0 + (radius - side) / into
|
|
226
|
+
if (time <= 1) {
|
|
227
|
+
const along = ((origin.x + delta.x * time - segment.ax) * ex + (origin.y + delta.y * time - segment.ay) * ey) / lengthSquared
|
|
228
|
+
// The thickened segment is convex, so entering through its flat side is the first contact.
|
|
229
|
+
if (along >= 0 && along <= 1) return { time, nx, ny }
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const atA = rayCircle(origin.x, origin.y, delta.x, delta.y, segment.ax, segment.ay, radius)
|
|
235
|
+
const atB = lengthSquared > 1e-12 ? rayCircle(origin.x, origin.y, delta.x, delta.y, segment.bx, segment.by, radius) : undefined
|
|
236
|
+
if (atA === undefined && atB === undefined) return undefined
|
|
237
|
+
const fromA = atB === undefined || (atA !== undefined && atA <= atB)
|
|
238
|
+
const time = fromA ? atA! : atB
|
|
239
|
+
const cx = origin.x + delta.x * time - (fromA ? segment.ax : segment.bx)
|
|
240
|
+
const cy = origin.y + delta.y * time - (fromA ? segment.ay : segment.by)
|
|
241
|
+
const length = Ops.hypot(cx, cy) || 1
|
|
242
|
+
return { time, nx: cx / length, ny: cy / length }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* First contact of a moving circle with any of `walls`, or `undefined`.
|
|
174
247
|
*
|
|
175
248
|
* With a `radius` of 0 the circle is a point and the sweep is a segment, so
|
|
176
249
|
* this is also line of sight: `undefined` means nothing blocks the line from
|
|
@@ -179,17 +252,21 @@ export const sweepCircleBox = (origin: Vec2, delta: Vec2, radius: number, box: B
|
|
|
179
252
|
*
|
|
180
253
|
* @example
|
|
181
254
|
* ```ts
|
|
182
|
-
* const sees = Collide2.
|
|
255
|
+
* const sees = Collide2.sweepCircle(eye, Vec2.subtract(target, eye), 0, { boxes: walls }) === undefined
|
|
183
256
|
* ```
|
|
184
257
|
*
|
|
185
258
|
* @category Queries
|
|
186
259
|
*/
|
|
187
|
-
export const
|
|
260
|
+
export const sweepCircle = (origin: Vec2, delta: Vec2, radius: number, walls: Walls): Hit | undefined => {
|
|
188
261
|
let first: Hit | undefined
|
|
189
|
-
for (const box of boxes) {
|
|
262
|
+
for (const box of walls.boxes ?? noBoxes) {
|
|
190
263
|
const hit = sweepCircleBox(origin, delta, radius, box)
|
|
191
264
|
if (hit !== undefined && (first === undefined || hit.time < first.time)) first = hit
|
|
192
265
|
}
|
|
266
|
+
for (const segment of walls.segments ?? noSegments) {
|
|
267
|
+
const hit = sweepCircleSegment(origin, delta, radius, segment)
|
|
268
|
+
if (hit !== undefined && (first === undefined || hit.time < first.time)) first = hit
|
|
269
|
+
}
|
|
193
270
|
return first
|
|
194
271
|
}
|
|
195
272
|
|
|
@@ -215,15 +292,17 @@ export const sweepCircleCircle = (
|
|
|
215
292
|
}
|
|
216
293
|
|
|
217
294
|
/**
|
|
218
|
-
* Pushes a circle out of every
|
|
219
|
-
* between it and each wall. Returns
|
|
295
|
+
* Pushes a circle out of every wall it overlaps, boxes first and then
|
|
296
|
+
* segments, each in order, leaving `skin` between it and each wall. Returns
|
|
297
|
+
* the corrected center. A center inside a box leaves through the nearest
|
|
298
|
+
* side; a center exactly on a segment leaves along the segment's left normal.
|
|
220
299
|
*
|
|
221
300
|
* @category Movement
|
|
222
301
|
*/
|
|
223
|
-
export const pushOut = (center: Vec2, radius: number,
|
|
302
|
+
export const pushOut = (center: Vec2, radius: number, walls: Walls, skin: number = defaultSkin): Vec2 => {
|
|
224
303
|
let x = center.x
|
|
225
304
|
let y = center.y
|
|
226
|
-
for (const box of boxes) {
|
|
305
|
+
for (const box of walls.boxes ?? noBoxes) {
|
|
227
306
|
const closestX = Math.max(box.minX, Math.min(x, box.maxX))
|
|
228
307
|
const closestY = Math.max(box.minY, Math.min(y, box.maxY))
|
|
229
308
|
const dx = x - closestX
|
|
@@ -247,6 +326,31 @@ export const pushOut = (center: Vec2, radius: number, boxes: ReadonlyArray<Box2>
|
|
|
247
326
|
else y = box.maxY + radius + skin
|
|
248
327
|
}
|
|
249
328
|
}
|
|
329
|
+
for (const segment of walls.segments ?? noSegments) {
|
|
330
|
+
const ex = segment.bx - segment.ax
|
|
331
|
+
const ey = segment.by - segment.ay
|
|
332
|
+
const lengthSquared = ex * ex + ey * ey
|
|
333
|
+
const along = lengthSquared > 1e-12 ? Math.max(0, Math.min(1, ((x - segment.ax) * ex + (y - segment.ay) * ey) / lengthSquared)) : 0
|
|
334
|
+
const closestX = segment.ax + ex * along
|
|
335
|
+
const closestY = segment.ay + ey * along
|
|
336
|
+
const dx = x - closestX
|
|
337
|
+
const dy = y - closestY
|
|
338
|
+
const distanceSquared = dx * dx + dy * dy
|
|
339
|
+
if (distanceSquared >= radius * radius) continue
|
|
340
|
+
if (distanceSquared > 1e-12) {
|
|
341
|
+
const distance = Math.sqrt(distanceSquared)
|
|
342
|
+
x = closestX + (dx / distance) * (radius + skin)
|
|
343
|
+
y = closestY + (dy / distance) * (radius + skin)
|
|
344
|
+
} else if (lengthSquared > 1e-12) {
|
|
345
|
+
// Center on the segment: leave along its left normal.
|
|
346
|
+
const length = Math.sqrt(lengthSquared)
|
|
347
|
+
x = closestX - (ey / length) * (radius + skin)
|
|
348
|
+
y = closestY + (ex / length) * (radius + skin)
|
|
349
|
+
} else {
|
|
350
|
+
// Center on a round post: leave along +x.
|
|
351
|
+
x = closestX + radius + skin
|
|
352
|
+
}
|
|
353
|
+
}
|
|
250
354
|
return { x, y }
|
|
251
355
|
}
|
|
252
356
|
|
|
@@ -254,19 +358,46 @@ export const pushOut = (center: Vec2, radius: number, boxes: ReadonlyArray<Box2>
|
|
|
254
358
|
* Moves a circle by `delta`, stopping at walls and sliding along them (up to
|
|
255
359
|
* four contacts per call). The whole path is swept, so no displacement can
|
|
256
360
|
* tunnel through a wall. A circle that starts inside a wall is pushed out
|
|
257
|
-
* first. A circle meeting a box's corner dead-on goes
|
|
258
|
-
* stopping against it.
|
|
361
|
+
* first. A circle meeting a box's corner or a segment's end dead-on goes
|
|
362
|
+
* around it rather than stopping against it.
|
|
363
|
+
*
|
|
364
|
+
* @example
|
|
365
|
+
* ```ts
|
|
366
|
+
* // A map traced from survey data: every wall is a segment.
|
|
367
|
+
* const moved = Collide2.moveCircle(position, delta, radius, { segments: nearbyWalls })
|
|
368
|
+
* ```
|
|
259
369
|
*
|
|
260
370
|
* @category Movement
|
|
261
371
|
*/
|
|
262
|
-
export const moveCircle = (start: Vec2, delta: Vec2, radius: number,
|
|
263
|
-
let position = pushOut(start, radius,
|
|
372
|
+
export const moveCircle = (start: Vec2, delta: Vec2, radius: number, walls: Walls, skin: number = defaultSkin): MoveResult => {
|
|
373
|
+
let position = pushOut(start, radius, walls, skin)
|
|
264
374
|
let rx = delta.x
|
|
265
375
|
let ry = delta.y
|
|
266
376
|
let blocked = false
|
|
267
377
|
for (let iteration = 0; iteration < 4; iteration++) {
|
|
268
378
|
if (rx * rx + ry * ry < 1e-12) break
|
|
269
|
-
const
|
|
379
|
+
const step = { x: rx, y: ry }
|
|
380
|
+
// The first contact, and whether it is round (a box's corner or a
|
|
381
|
+
// segment's end) rather than a flat side.
|
|
382
|
+
let hit: Hit | undefined
|
|
383
|
+
let round = false
|
|
384
|
+
for (const box of walls.boxes ?? noBoxes) {
|
|
385
|
+
const found = sweepCircleBox(position, step, radius, box)
|
|
386
|
+
if (found === undefined || (hit !== undefined && found.time >= hit.time)) continue
|
|
387
|
+
hit = found
|
|
388
|
+
round = found.nx !== 0 && found.ny !== 0
|
|
389
|
+
}
|
|
390
|
+
for (const segment of walls.segments ?? noSegments) {
|
|
391
|
+
const found = sweepCircleSegment(position, step, radius, segment)
|
|
392
|
+
if (found === undefined || (hit !== undefined && found.time >= hit.time)) continue
|
|
393
|
+
hit = found
|
|
394
|
+
const ex = segment.bx - segment.ax
|
|
395
|
+
const ey = segment.by - segment.ay
|
|
396
|
+
const lengthSquared = ex * ex + ey * ey
|
|
397
|
+
const along = found.nx * ex + found.ny * ey
|
|
398
|
+
// A flat side's normal is perpendicular to the segment.
|
|
399
|
+
round = lengthSquared < 1e-12 || along * along > 1e-18 * lengthSquared
|
|
400
|
+
}
|
|
270
401
|
if (hit === undefined) {
|
|
271
402
|
position = { x: position.x + rx, y: position.y + ry }
|
|
272
403
|
break
|
|
@@ -284,10 +415,10 @@ export const moveCircle = (start: Vec2, delta: Vec2, radius: number, boxes: Read
|
|
|
284
415
|
if (into < 0) {
|
|
285
416
|
rx -= into * hit.nx
|
|
286
417
|
ry -= into * hit.ny
|
|
287
|
-
// Dead-on into a rounded corner (as an exact diagonal meets an
|
|
288
|
-
// axis-aligned box) nothing is left to slide along: go around
|
|
289
|
-
//
|
|
290
|
-
if (
|
|
418
|
+
// Dead-on into a rounded corner or end (as an exact diagonal meets an
|
|
419
|
+
// axis-aligned box) nothing is left to slide along: go around it
|
|
420
|
+
// instead of stopping against it.
|
|
421
|
+
if (round && rx * rx + ry * ry < 1e-12 * into * into) {
|
|
291
422
|
const side = rx * -hit.ny + ry * hit.nx < 0 ? -1 : 1
|
|
292
423
|
rx = -hit.ny * -into * side
|
|
293
424
|
ry = hit.nx * -into * side
|
package/src/Random.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* integer.
|
|
4
4
|
*
|
|
5
5
|
* Keep the state in a resource, so a run replays exactly and saves with the
|
|
6
|
-
* world: declare it with
|
|
6
|
+
* world: declare it with `Random.codec` as its schema, start it with
|
|
7
7
|
* `seed`, and draw in systems through `draw` over its write cell, which writes
|
|
8
8
|
* every draw back (rolled back with a failed system). Two generators made
|
|
9
9
|
* from the same state draw the same sequence on every platform. `make` is for
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* @example
|
|
13
13
|
* ```ts
|
|
14
|
-
* const Rng = Descriptor.ConstructedResource(Random)("Game/Rng")
|
|
14
|
+
* const Rng = Descriptor.ConstructedResource(Random.codec)("Game/Rng")
|
|
15
15
|
* const runtime = Game.Runtime.make({ services, resources: { Rng: Random.seed(0x5eed) } })
|
|
16
16
|
*
|
|
17
17
|
* Game.System("Game/Spawn", { resources: { rng: Game.System.writeResource(Rng) } }, ({ resources }) => {
|
|
@@ -35,7 +35,9 @@
|
|
|
35
35
|
*/
|
|
36
36
|
import type * as Brand from "@typeonce/ritmo/Brand"
|
|
37
37
|
import type { WriteCell } from "@typeonce/ritmo/Query"
|
|
38
|
+
import type { StandardSchema } from "@typeonce/ritmo/Descriptor"
|
|
38
39
|
import * as Result from "@typeonce/ritmo/Result"
|
|
40
|
+
import { codecOf } from "./internal/codec.ts"
|
|
39
41
|
|
|
40
42
|
/**
|
|
41
43
|
* A generator. Every draw advances `state`.
|
|
@@ -143,7 +145,7 @@ export const make = (state: number): Random => new Mulberry32(state)
|
|
|
143
145
|
|
|
144
146
|
/**
|
|
145
147
|
* A generator's state as a resource holds it: an unsigned 32-bit integer.
|
|
146
|
-
* Only `seed`, `result`, and `
|
|
148
|
+
* Only `seed`, `result`, and `codec` make one, so `draw` takes the cell of a
|
|
147
149
|
* random resource and nothing else.
|
|
148
150
|
*
|
|
149
151
|
* @category Resource
|
|
@@ -160,7 +162,7 @@ const isState = (value: unknown): value is State =>
|
|
|
160
162
|
typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 0xffffffff
|
|
161
163
|
|
|
162
164
|
/**
|
|
163
|
-
* Validates a state, for
|
|
165
|
+
* Validates a state, for game code that builds one from a number.
|
|
164
166
|
*
|
|
165
167
|
* @category Resource
|
|
166
168
|
*/
|
|
@@ -168,12 +170,24 @@ export const result = (raw: number): Result.Result<State, InvalidState> =>
|
|
|
168
170
|
isState(raw) ? Result.success(raw) : Result.failure({ _tag: "InvalidRandomState", value: raw })
|
|
169
171
|
|
|
170
172
|
/**
|
|
171
|
-
*
|
|
173
|
+
* Whether any value is a generator state: a guard, also for building a
|
|
174
|
+
* state field in your own schema (`Schema.declare(Random.is)`).
|
|
172
175
|
*
|
|
173
176
|
* @category Resource
|
|
174
177
|
*/
|
|
175
|
-
export const
|
|
176
|
-
|
|
178
|
+
export const is = (raw: unknown): raw is State => isState(raw)
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The codec of a random resource's state (a Standard Schema, see
|
|
182
|
+
* `Descriptor.Codec`): `Descriptor.ConstructedResource(Random.codec)`. Its
|
|
183
|
+
* saved form is the number itself, so it needs no `encode`.
|
|
184
|
+
*
|
|
185
|
+
* @category Resource
|
|
186
|
+
*/
|
|
187
|
+
export const codec: StandardSchema<number, State> = codecOf<number, State>(
|
|
188
|
+
(raw) => isState(raw) ? Result.success(raw) : Result.failure({ _tag: "InvalidRandomState", value: raw }),
|
|
189
|
+
"a generator state: an unsigned 32-bit integer"
|
|
190
|
+
)
|
|
177
191
|
|
|
178
192
|
/**
|
|
179
193
|
* The starting state for a seed, reduced to an unsigned 32-bit integer like
|
package/src/Scalar.ts
CHANGED
|
@@ -36,13 +36,14 @@
|
|
|
36
36
|
*/
|
|
37
37
|
import type * as Brand from "@typeonce/ritmo/Brand"
|
|
38
38
|
import * as internal from "./internal/scalar.ts"
|
|
39
|
+
import type { StandardSchema } from "@typeonce/ritmo/Descriptor"
|
|
39
40
|
import type * as Result from "@typeonce/ritmo/Result"
|
|
40
41
|
|
|
41
42
|
/**
|
|
42
43
|
* Finite number brand.
|
|
43
44
|
*
|
|
44
45
|
* Values of this type are guaranteed not to be `NaN`, `Infinity`, or
|
|
45
|
-
* `-Infinity` when they come from a
|
|
46
|
+
* `-Infinity` when they come from a constructor (`result`, `option`) or a `codec`. The
|
|
46
47
|
* arithmetic helpers here and in `Vector2` keep the brand without
|
|
47
48
|
* re-validating, so their results are finite only while they stay within
|
|
48
49
|
* `±Number.MAX_VALUE`: game-sized values never come close, but validate
|
|
@@ -85,27 +86,46 @@ export type InterpolationError = {
|
|
|
85
86
|
readonly end: number
|
|
86
87
|
}
|
|
87
88
|
|
|
89
|
+
/**
|
|
90
|
+
* The constructors, guard, and schema of one scalar brand.
|
|
91
|
+
*
|
|
92
|
+
* @category Constructors
|
|
93
|
+
*/
|
|
94
|
+
export interface Constructor<Value> {
|
|
95
|
+
/** Validates one number and returns an explicit success/failure result. */
|
|
96
|
+
readonly result: (raw: number) => Result.Result<Value, Error>
|
|
97
|
+
/** Validates one number and returns `null` on failure. */
|
|
98
|
+
readonly option: (raw: number) => Value | null
|
|
99
|
+
/** Whether any value is a number of this brand. */
|
|
100
|
+
readonly is: (raw: unknown) => raw is Value
|
|
101
|
+
/** The Standard Schema for `Descriptor.ConstructedComponent(...)`: the saved form is the number itself. */
|
|
102
|
+
readonly codec: StandardSchema<number, Value>
|
|
103
|
+
}
|
|
104
|
+
|
|
88
105
|
/**
|
|
89
106
|
* Constructor for {@link Finite}: `result` (explicit failure), `option`
|
|
90
|
-
* (`null` on failure),
|
|
107
|
+
* (`null` on failure), `is` (a type guard), and `codec`, for
|
|
108
|
+
* `Descriptor.ConstructedComponent(Scalar.Finite.codec)`.
|
|
91
109
|
*
|
|
92
110
|
* @category Constructors
|
|
93
111
|
*/
|
|
94
|
-
export const Finite = internal.Finite
|
|
112
|
+
export const Finite: Constructor<Finite> = internal.Finite
|
|
95
113
|
/**
|
|
96
114
|
* Constructor for {@link NonNegative}: `result` (explicit failure), `option`
|
|
97
|
-
* (`null` on failure),
|
|
115
|
+
* (`null` on failure), `is` (a type guard), and `codec`, for
|
|
116
|
+
* `Descriptor.ConstructedComponent(Scalar.NonNegative.codec)`.
|
|
98
117
|
*
|
|
99
118
|
* @category Constructors
|
|
100
119
|
*/
|
|
101
|
-
export const NonNegative = internal.NonNegative
|
|
120
|
+
export const NonNegative: Constructor<NonNegative> = internal.NonNegative
|
|
102
121
|
/**
|
|
103
122
|
* Constructor for {@link Positive}: `result` (explicit failure), `option`
|
|
104
|
-
* (`null` on failure),
|
|
123
|
+
* (`null` on failure), `is` (a type guard), and `codec`, for
|
|
124
|
+
* `Descriptor.ConstructedComponent(Scalar.Positive.codec)`.
|
|
105
125
|
*
|
|
106
126
|
* @category Constructors
|
|
107
127
|
*/
|
|
108
|
-
export const Positive = internal.Positive
|
|
128
|
+
export const Positive: Constructor<Positive> = internal.Positive
|
|
109
129
|
|
|
110
130
|
/**
|
|
111
131
|
* Clamps one finite value between two finite bounds.
|
package/src/Segment2.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plain line segments stored by their ends, for hot-path collision against
|
|
3
|
+
* walls that are not axis-aligned (a map traced from survey data, a slanted
|
|
4
|
+
* fence, a polygon's edges).
|
|
5
|
+
*
|
|
6
|
+
* Like `Box2`, it is the plain form the collision helpers (`Collide2`) read
|
|
7
|
+
* every frame, built once from whatever the game stores. A wall is
|
|
8
|
+
* two-sided: a circle is kept off it on both sides and around both ends.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* const fence = Segment2.between({ x: 0, y: 0 }, { x: 40, y: 30 })
|
|
13
|
+
* const moved = Collide2.moveCircle(position, delta, radius, { segments: [fence] })
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* @module Segment2
|
|
17
|
+
* @docGroup helpers
|
|
18
|
+
*
|
|
19
|
+
* @categoryDescription Segment Types
|
|
20
|
+
* The structural segment shape.
|
|
21
|
+
*
|
|
22
|
+
* @categoryDescription Operations
|
|
23
|
+
* Building segments and measuring them against points and circles.
|
|
24
|
+
*/
|
|
25
|
+
import type { Vec2 } from "./Vec2.ts"
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The segment from (`ax`, `ay`) to (`bx`, `by`). Both ends may be the same
|
|
29
|
+
* point, which is then a round post.
|
|
30
|
+
*
|
|
31
|
+
* @category Segment Types
|
|
32
|
+
*/
|
|
33
|
+
export interface Segment2 {
|
|
34
|
+
readonly ax: number
|
|
35
|
+
readonly ay: number
|
|
36
|
+
readonly bx: number
|
|
37
|
+
readonly by: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The segment from `a` to `b`.
|
|
42
|
+
*
|
|
43
|
+
* @category Operations
|
|
44
|
+
*/
|
|
45
|
+
export const between = (a: Vec2, b: Vec2): Segment2 => ({ ax: a.x, ay: a.y, bx: b.x, by: b.y })
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The point of the segment nearest to `point`.
|
|
49
|
+
*
|
|
50
|
+
* @category Operations
|
|
51
|
+
*/
|
|
52
|
+
export const closestPoint = (segment: Segment2, point: Vec2): Vec2 => {
|
|
53
|
+
const ex = segment.bx - segment.ax
|
|
54
|
+
const ey = segment.by - segment.ay
|
|
55
|
+
const lengthSquared = ex * ex + ey * ey
|
|
56
|
+
if (lengthSquared < 1e-12) return { x: segment.ax, y: segment.ay }
|
|
57
|
+
const along = Math.max(0, Math.min(1, ((point.x - segment.ax) * ex + (point.y - segment.ay) * ey) / lengthSquared))
|
|
58
|
+
return { x: segment.ax + ex * along, y: segment.ay + ey * along }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Whether a circle overlaps (or touches) the segment.
|
|
63
|
+
*
|
|
64
|
+
* @category Operations
|
|
65
|
+
*/
|
|
66
|
+
export const overlapsCircle = (segment: Segment2, center: Vec2, radius: number): boolean => {
|
|
67
|
+
const nearest = closestPoint(segment, center)
|
|
68
|
+
const dx = center.x - nearest.x
|
|
69
|
+
const dy = center.y - nearest.y
|
|
70
|
+
return dx * dx + dy * dy <= radius * radius
|
|
71
|
+
}
|
package/src/Size2.ts
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
* Pure readers and raw views over already-validated size values.
|
|
32
32
|
*/
|
|
33
33
|
import type * as Brand from "@typeonce/ritmo/Brand"
|
|
34
|
+
import type { StandardSchema } from "@typeonce/ritmo/Descriptor"
|
|
34
35
|
import * as internal from "./internal/size2.ts"
|
|
35
36
|
import type * as Result from "@typeonce/ritmo/Result"
|
|
36
37
|
import type * as Scalar from "./Scalar.ts"
|
|
@@ -67,16 +68,6 @@ export type Error = {
|
|
|
67
68
|
readonly input: Raw
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
/**
|
|
71
|
-
* Failure produced by `decode` when the input does not have the raw shape.
|
|
72
|
-
*
|
|
73
|
-
* @category Size Types
|
|
74
|
-
*/
|
|
75
|
-
export type DecodeError = {
|
|
76
|
-
readonly tag: "Size2/Malformed"
|
|
77
|
-
readonly input: unknown
|
|
78
|
-
}
|
|
79
|
-
|
|
80
71
|
/**
|
|
81
72
|
* Validates one raw size and returns an explicit result.
|
|
82
73
|
*
|
|
@@ -84,13 +75,20 @@ export type DecodeError = {
|
|
|
84
75
|
*/
|
|
85
76
|
export const result: (raw: Raw) => Result.Result<Size2, Error> = internal.result
|
|
86
77
|
/**
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
78
|
+
* The codec of `Size2` (a Standard Schema, see `Descriptor.Codec`): give it to
|
|
79
|
+
* `Descriptor.ConstructedComponent(...)` so raw input and saves are validated.
|
|
80
|
+
* Its saved form is the raw shape, so it needs no `encode`. To put a `Size2`
|
|
81
|
+
* inside your own object schema, use {@link is} with your library's guard
|
|
82
|
+
* schema (`Schema.declare`, `z.custom`).
|
|
83
|
+
*
|
|
84
|
+
* @example
|
|
85
|
+
* ```ts
|
|
86
|
+
* const Position = Descriptor.ConstructedComponent(Size2.codec)("Game/Position")
|
|
87
|
+
* ```
|
|
90
88
|
*
|
|
91
89
|
* @category Construction
|
|
92
90
|
*/
|
|
93
|
-
export const
|
|
91
|
+
export const codec: StandardSchema<Raw, Size2> = internal.codec
|
|
94
92
|
/**
|
|
95
93
|
* Validates one raw size and returns `null` on failure.
|
|
96
94
|
*
|
|
@@ -98,11 +96,12 @@ export const decode: (raw: unknown) => Result.Result<Size2, Error | DecodeError>
|
|
|
98
96
|
*/
|
|
99
97
|
export const option: (raw: Raw) => Size2 | null = internal.option
|
|
100
98
|
/**
|
|
101
|
-
*
|
|
99
|
+
* Whether any value is a valid `Size2`: a guard, also for building a
|
|
100
|
+
* `Size2` field in your own schema (`Schema.declare(Size2.is)`).
|
|
102
101
|
*
|
|
103
102
|
* @category Construction
|
|
104
103
|
*/
|
|
105
|
-
export const is: (raw:
|
|
104
|
+
export const is: (raw: unknown) => raw is Size2 = internal.is
|
|
106
105
|
/**
|
|
107
106
|
* Converts one branded size back to a fresh raw object.
|
|
108
107
|
*
|
package/src/Vector2.ts
CHANGED
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
* Immutable vector math and normalization helpers on already-validated values.
|
|
37
37
|
*/
|
|
38
38
|
import type * as Brand from "@typeonce/ritmo/Brand"
|
|
39
|
+
import type { StandardSchema } from "@typeonce/ritmo/Descriptor"
|
|
39
40
|
import * as internal from "./internal/vector2.ts"
|
|
40
41
|
import type * as Result from "@typeonce/ritmo/Result"
|
|
41
42
|
import type * as Scalar from "./Scalar.ts"
|
|
@@ -72,16 +73,6 @@ export type Error = {
|
|
|
72
73
|
readonly input: Raw
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
/**
|
|
76
|
-
* Failure produced by `decode` when the input does not have the raw shape.
|
|
77
|
-
*
|
|
78
|
-
* @category Vector Types
|
|
79
|
-
*/
|
|
80
|
-
export type DecodeError = {
|
|
81
|
-
readonly tag: "Vector2/Malformed"
|
|
82
|
-
readonly input: unknown
|
|
83
|
-
}
|
|
84
|
-
|
|
85
76
|
/**
|
|
86
77
|
* Explicit failure produced when a zero-length vector cannot be normalized.
|
|
87
78
|
*
|
|
@@ -103,13 +94,20 @@ export type NormalizeError = {
|
|
|
103
94
|
*/
|
|
104
95
|
export const result: (raw: Raw) => Result.Result<Vector2, Error> = internal.result
|
|
105
96
|
/**
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
97
|
+
* The codec of `Vector2` (a Standard Schema, see `Descriptor.Codec`): give it to
|
|
98
|
+
* `Descriptor.ConstructedComponent(...)` so raw input and saves are validated.
|
|
99
|
+
* Its saved form is the raw shape, so it needs no `encode`. To put a `Vector2`
|
|
100
|
+
* inside your own object schema, use {@link is} with your library's guard
|
|
101
|
+
* schema (`Schema.declare`, `z.custom`).
|
|
102
|
+
*
|
|
103
|
+
* @example
|
|
104
|
+
* ```ts
|
|
105
|
+
* const Position = Descriptor.ConstructedComponent(Vector2.codec)("Game/Position")
|
|
106
|
+
* ```
|
|
109
107
|
*
|
|
110
108
|
* @category Construction
|
|
111
109
|
*/
|
|
112
|
-
export const
|
|
110
|
+
export const codec: StandardSchema<Raw, Vector2> = internal.codec
|
|
113
111
|
/**
|
|
114
112
|
* Validates one raw vector and returns `null` on failure.
|
|
115
113
|
*
|
|
@@ -117,11 +115,12 @@ export const decode: (raw: unknown) => Result.Result<Vector2, Error | DecodeErro
|
|
|
117
115
|
*/
|
|
118
116
|
export const option: (raw: Raw) => Vector2 | null = internal.option
|
|
119
117
|
/**
|
|
120
|
-
*
|
|
118
|
+
* Whether any value is a valid `Vector2`: a guard, also for building a
|
|
119
|
+
* `Vector2` field in your own schema (`Schema.declare(Vector2.is)`).
|
|
121
120
|
*
|
|
122
121
|
* @category Construction
|
|
123
122
|
*/
|
|
124
|
-
export const is: (raw:
|
|
123
|
+
export const is: (raw: unknown) => raw is Vector2 = internal.is
|
|
125
124
|
/**
|
|
126
125
|
* Converts one branded vector back to a fresh raw object.
|
|
127
126
|
*
|
package/src/index.ts
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
* Math for game code.
|
|
3
3
|
*
|
|
4
4
|
* Validated values (`Scalar`, `Vector2`, `Size2`, `Aabb`) expose
|
|
5
|
-
* Result-returning constructors,
|
|
6
|
-
* `Descriptor.ConstructedComponent(
|
|
7
|
-
* validated once at the boundary. `InputAxis` normalizes directional input.
|
|
5
|
+
* Result-returning constructors, a guard (`is`), and one `codec` each, so
|
|
6
|
+
* they plug into `Descriptor.ConstructedComponent(Vector2.codec)` /
|
|
7
|
+
* `ConstructedResource(Random.codec)` and are validated once at the boundary. `InputAxis` normalizes directional input.
|
|
8
8
|
*
|
|
9
9
|
* Hot-path helpers work on plain numbers every frame: `Vec2` arithmetic,
|
|
10
|
-
* `Box2` edges, `
|
|
10
|
+
* `Box2` edges, `Segment2` walls at any angle, `Collide2` swept circle
|
|
11
|
+
* collision against both, a `SpatialGrid` broad
|
|
11
12
|
* phase, a `FlowField` for finding the way around walls, and a deterministic
|
|
12
13
|
* `Random` whose state is one integer.
|
|
13
14
|
*
|
|
@@ -23,6 +24,7 @@ export * as InputAxis from "./InputAxis.ts"
|
|
|
23
24
|
export * as Ops from "./Ops.ts"
|
|
24
25
|
export * as Random from "./Random.ts"
|
|
25
26
|
export * as Scalar from "./Scalar.ts"
|
|
27
|
+
export * as Segment2 from "./Segment2.ts"
|
|
26
28
|
export * as Size2 from "./Size2.ts"
|
|
27
29
|
export * as SpatialGrid from "./SpatialGrid.ts"
|
|
28
30
|
export * as Vec2 from "./Vec2.ts"
|
package/src/internal/aabb.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { codecOf } from "./codec.ts"
|
|
1
2
|
import * as Result from "@typeonce/ritmo/Result"
|
|
2
3
|
import * as Size2 from "../Size2.ts"
|
|
3
4
|
import * as Vector2 from "../Vector2.ts"
|
|
@@ -36,7 +37,7 @@ export const option = (raw: Aabb.Raw): Aabb.Aabb | null => {
|
|
|
36
37
|
return next.ok ? next.value : null
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
export const is = (raw:
|
|
40
|
+
export const is = (raw: unknown): raw is Aabb.Aabb => decode(raw).ok
|
|
40
41
|
|
|
41
42
|
export const position = (aabb: Aabb.Aabb): Vector2.Vector2 => aabb.position
|
|
42
43
|
export const size = (aabb: Aabb.Aabb): Size2.Size2 => aabb.size
|
|
@@ -66,10 +67,15 @@ export const overlapsVertically = (first: Aabb.Aabb, second: Aabb.Aabb): boolean
|
|
|
66
67
|
export const translate = (aabb: Aabb.Aabb, delta: Vector2.Vector2): Aabb.Aabb =>
|
|
67
68
|
make(Vector2.add(aabb.position, delta), aabb.size)
|
|
68
69
|
|
|
69
|
-
|
|
70
|
+
/** Untrusted input without the raw shape. */
|
|
71
|
+
type Malformed = { readonly tag: "Aabb/Malformed"; readonly input: unknown }
|
|
72
|
+
|
|
73
|
+
export const decode = (raw: unknown): Result.Result<Aabb.Aabb, Aabb.Error | Malformed> => {
|
|
70
74
|
if (typeof raw !== "object" || raw === null) return Result.failure({ tag: "Aabb/Malformed", input: raw })
|
|
71
75
|
const { position, size } = raw as Record<string, unknown>
|
|
72
76
|
return isVector2Shape(position) && isSize2Shape(size)
|
|
73
77
|
? result({ position, size })
|
|
74
78
|
: Result.failure({ tag: "Aabb/Malformed", input: raw })
|
|
75
79
|
}
|
|
80
|
+
|
|
81
|
+
export const codec = codecOf<Aabb.Raw, Aabb.Aabb>(decode, "a box { x, y, width, height } of finite numbers with a non-negative size")
|