@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.
Files changed (68) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +3 -3
  3. package/dist/Aabb.d.ts +15 -15
  4. package/dist/Aabb.d.ts.map +1 -1
  5. package/dist/Aabb.js +13 -5
  6. package/dist/Aabb.js.map +1 -1
  7. package/dist/Collide2.d.ts +48 -14
  8. package/dist/Collide2.d.ts.map +1 -1
  9. package/dist/Collide2.js +140 -17
  10. package/dist/Collide2.js.map +1 -1
  11. package/dist/Random.d.ts +16 -6
  12. package/dist/Random.d.ts.map +1 -1
  13. package/dist/Random.js +13 -3
  14. package/dist/Random.js.map +1 -1
  15. package/dist/Scalar.d.ts +26 -7
  16. package/dist/Scalar.d.ts.map +1 -1
  17. package/dist/Scalar.js +6 -3
  18. package/dist/Scalar.js.map +1 -1
  19. package/dist/Segment2.d.ts +56 -0
  20. package/dist/Segment2.d.ts.map +1 -0
  21. package/dist/Segment2.js +32 -0
  22. package/dist/Segment2.js.map +1 -0
  23. package/dist/Size2.d.ts +15 -15
  24. package/dist/Size2.d.ts.map +1 -1
  25. package/dist/Size2.js +13 -5
  26. package/dist/Size2.js.map +1 -1
  27. package/dist/Vector2.d.ts +15 -15
  28. package/dist/Vector2.d.ts.map +1 -1
  29. package/dist/Vector2.js +13 -5
  30. package/dist/Vector2.js.map +1 -1
  31. package/dist/index.d.ts +6 -4
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +6 -4
  34. package/dist/index.js.map +1 -1
  35. package/dist/internal/aabb.d.ts +9 -2
  36. package/dist/internal/aabb.d.ts.map +1 -1
  37. package/dist/internal/aabb.js +3 -1
  38. package/dist/internal/aabb.js.map +1 -1
  39. package/dist/internal/codec.d.ts +9 -0
  40. package/dist/internal/codec.d.ts.map +1 -0
  41. package/dist/internal/codec.js +16 -0
  42. package/dist/internal/codec.js.map +1 -0
  43. package/dist/internal/scalar.d.ts +3 -4
  44. package/dist/internal/scalar.d.ts.map +1 -1
  45. package/dist/internal/scalar.js +19 -12
  46. package/dist/internal/scalar.js.map +1 -1
  47. package/dist/internal/size2.d.ts +9 -2
  48. package/dist/internal/size2.d.ts.map +1 -1
  49. package/dist/internal/size2.js +3 -1
  50. package/dist/internal/size2.js.map +1 -1
  51. package/dist/internal/vector2.d.ts +9 -2
  52. package/dist/internal/vector2.d.ts.map +1 -1
  53. package/dist/internal/vector2.js +3 -1
  54. package/dist/internal/vector2.js.map +1 -1
  55. package/package.json +2 -2
  56. package/src/Aabb.ts +15 -16
  57. package/src/Collide2.ts +153 -22
  58. package/src/Random.ts +21 -7
  59. package/src/Scalar.ts +27 -7
  60. package/src/Segment2.ts +71 -0
  61. package/src/Size2.ts +15 -16
  62. package/src/Vector2.ts +15 -16
  63. package/src/index.ts +6 -4
  64. package/src/internal/aabb.ts +8 -2
  65. package/src/internal/codec.ts +21 -0
  66. package/src/internal/scalar.ts +24 -13
  67. package/src/internal/size2.ts +8 -2
  68. 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 boxes and circles.
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: `sweepCircleBox` finds the first time of
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 box. */
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 moving circle with any of `boxes`, or `undefined`.
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.sweepCircleBoxes(eye, Vec2.subtract(target, eye), 0, walls) === undefined
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 sweepCircleBoxes = (origin: Vec2, delta: Vec2, radius: number, boxes: ReadonlyArray<Box2>): Hit | undefined => {
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 box it overlaps, in order, leaving `skin`
219
- * between it and each wall. Returns the corrected center.
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, boxes: ReadonlyArray<Box2>, skin: number = defaultSkin): Vec2 => {
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 around it rather than
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, boxes: ReadonlyArray<Box2>, skin: number = defaultSkin): MoveResult => {
263
- let position = pushOut(start, radius, boxes, skin)
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 hit = sweepCircleBoxes(position, { x: rx, y: ry }, radius, boxes)
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 the
289
- // corner instead of stopping against it.
290
- if (hit.nx !== 0 && hit.ny !== 0 && rx * rx + ry * ry < 1e-12 * into * into) {
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 this module as its validator, start 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 `decode` make one, so `draw` takes the cell of a
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 `Descriptor.ConstructedResource(Random)` and saves.
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
- * Validates a state read from untrusted data (a save).
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 decode = (raw: unknown): Result.Result<State, InvalidState> =>
176
- isState(raw) ? Result.success(raw) : Result.failure({ _tag: "InvalidRandomState", value: raw })
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 validator (`result`, `decode`). The
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), and `is` (a type guard).
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), and `is` (a type guard).
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), and `is` (a type guard).
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.
@@ -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
- * Validates untrusted input of any shape, for example a value read from a
88
- * save file. Descriptors built from this module use it when restoring
89
- * snapshots.
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 decode: (raw: unknown) => Result.Result<Size2, Error | DecodeError> = internal.decode
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
- * Checks whether one raw size already satisfies the size invariants.
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: Raw) => raw is Raw & Size2 = internal.is
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
- * Validates untrusted input of any shape, for example a value read from a
107
- * save file. Descriptors built from this module use it when restoring
108
- * snapshots.
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 decode: (raw: unknown) => Result.Result<Vector2, Error | DecodeError> = internal.decode
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
- * Checks whether one raw vector already satisfies the vector invariants.
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: Raw) => raw is Raw & Vector2 = internal.is
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, so they plug into
6
- * `Descriptor.ConstructedComponent(...)` / `ConstructedResource(...)` and are
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, `Collide2` swept circle collision, a `SpatialGrid` broad
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"
@@ -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: Aabb.Raw): raw is Aabb.Raw & Aabb.Aabb => result(raw).ok
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
- export const decode = (raw: unknown): Result.Result<Aabb.Aabb, Aabb.Error | Aabb.DecodeError> => {
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")