@typeonce/ritmo-math 0.8.0 → 0.10.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 (57) hide show
  1. package/CHANGELOG.md +183 -0
  2. package/README.md +14 -4
  3. package/dist/Collide2.d.ts +2 -1
  4. package/dist/Collide2.d.ts.map +1 -1
  5. package/dist/Collide2.js +10 -6
  6. package/dist/Collide2.js.map +1 -1
  7. package/dist/FlowField.d.ts.map +1 -1
  8. package/dist/FlowField.js +2 -1
  9. package/dist/FlowField.js.map +1 -1
  10. package/dist/InputAxis.js +1 -1
  11. package/dist/Ops.d.ts +209 -0
  12. package/dist/Ops.d.ts.map +1 -0
  13. package/dist/Ops.js +210 -0
  14. package/dist/Ops.js.map +1 -0
  15. package/dist/Random.d.ts +63 -10
  16. package/dist/Random.d.ts.map +1 -1
  17. package/dist/Random.js +57 -32
  18. package/dist/Random.js.map +1 -1
  19. package/dist/Scalar.d.ts +7 -2
  20. package/dist/Scalar.d.ts.map +1 -1
  21. package/dist/Scalar.js +2 -1
  22. package/dist/Scalar.js.map +1 -1
  23. package/dist/SpatialGrid.d.ts +7 -2
  24. package/dist/SpatialGrid.d.ts.map +1 -1
  25. package/dist/SpatialGrid.js +35 -4
  26. package/dist/SpatialGrid.js.map +1 -1
  27. package/dist/Vec2.d.ts.map +1 -1
  28. package/dist/Vec2.js +8 -7
  29. package/dist/Vec2.js.map +1 -1
  30. package/dist/Vector2.d.ts +12 -0
  31. package/dist/Vector2.d.ts.map +1 -1
  32. package/dist/Vector2.js +12 -0
  33. package/dist/Vector2.js.map +1 -1
  34. package/dist/index.d.ts +5 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +5 -0
  37. package/dist/index.js.map +1 -1
  38. package/dist/internal/fdlibm.d.ts +43 -0
  39. package/dist/internal/fdlibm.d.ts.map +1 -0
  40. package/dist/internal/fdlibm.js +1291 -0
  41. package/dist/internal/fdlibm.js.map +1 -0
  42. package/dist/internal/vector2.d.ts.map +1 -1
  43. package/dist/internal/vector2.js +2 -1
  44. package/dist/internal/vector2.js.map +1 -1
  45. package/package.json +3 -2
  46. package/src/Collide2.ts +10 -6
  47. package/src/FlowField.ts +2 -1
  48. package/src/InputAxis.ts +1 -1
  49. package/src/Ops.ts +232 -0
  50. package/src/Random.ts +104 -16
  51. package/src/Scalar.ts +7 -2
  52. package/src/SpatialGrid.ts +40 -6
  53. package/src/Vec2.ts +9 -7
  54. package/src/Vector2.ts +12 -0
  55. package/src/index.ts +5 -0
  56. package/src/internal/fdlibm.ts +1256 -0
  57. package/src/internal/vector2.ts +2 -1
package/src/Random.ts CHANGED
@@ -3,16 +3,22 @@
3
3
  * integer.
4
4
  *
5
5
  * Keep the state in a resource, so a run replays exactly and saves with the
6
- * world: a system makes a generator from the stored state, draws from it,
7
- * then writes `random.state` back. Two generators made from the same state
8
- * draw the same sequence on every platform.
6
+ * world: declare it with this module as its validator, start it with
7
+ * `seed`, and draw in systems through `draw` over its write cell, which writes
8
+ * every draw back (rolled back with a failed system). Two generators made
9
+ * from the same state draw the same sequence on every platform. `make` is for
10
+ * a state that lives outside the world (a level generator seeded once).
9
11
  *
10
12
  * @example
11
13
  * ```ts
12
- * const random = Random.make(resources.rng.get().state)
13
- * const angle = random.range(0, Math.PI * 2)
14
- * const kind = random.pick(kinds) ?? "grunt"
15
- * resources.rng.set({ state: random.state })
14
+ * const Rng = Descriptor.ConstructedResource(Random)("Game/Rng")
15
+ * const runtime = Game.Runtime.make({ services, resources: { Rng: Random.seed(0x5eed) } })
16
+ *
17
+ * Game.System("Game/Spawn", { resources: { rng: Game.System.writeResource(Rng) } }, ({ resources }) => {
18
+ * const random = Random.draw(resources.rng)
19
+ * const angle = random.range(0, Math.PI * 2)
20
+ * const kind = random.pick(kinds) ?? "grunt"
21
+ * })
16
22
  * ```
17
23
  *
18
24
  * @module Random
@@ -23,7 +29,13 @@
23
29
  *
24
30
  * @categoryDescription Construction
25
31
  * Making a generator from a stored state.
32
+ *
33
+ * @categoryDescription Resource
34
+ * Keeping the state in a world resource.
26
35
  */
36
+ import type * as Brand from "@typeonce/ritmo/Brand"
37
+ import type { WriteCell } from "@typeonce/ritmo/Query"
38
+ import * as Result from "@typeonce/ritmo/Result"
27
39
 
28
40
  /**
29
41
  * A generator. Every draw advances `state`.
@@ -35,9 +47,9 @@ export interface Random {
35
47
  readonly state: number
36
48
  /** Uniform in [0, 1). */
37
49
  next(): number
38
- /** Uniform in [min, max). */
50
+ /** Uniform in [min, max), or `min` when they are equal. Throws when `min > max` or a bound is not finite. */
39
51
  range(min: number, max: number): number
40
- /** A uniform integer in [min, maxExclusive); both bounds are integers with min < maxExclusive. */
52
+ /** A uniform integer in [min, maxExclusive). Throws unless both bounds are integers with min < maxExclusive. */
41
53
  integer(min: number, maxExclusive: number): number
42
54
  /** A uniform element of `items`, or `undefined` when it is empty (no draw is made then). */
43
55
  pick<A>(items: ReadonlyArray<A>): A | undefined
@@ -47,7 +59,7 @@ export interface Random {
47
59
  * An element of `items` chosen with probability proportional to `weight`,
48
60
  * or `undefined` when no element has a positive finite weight (no draw is
49
61
  * made then). Elements with zero, negative, or non-finite weight are never
50
- * chosen.
62
+ * chosen. `weight` is called once per element.
51
63
  */
52
64
  weighted<A>(items: ReadonlyArray<A>, weight: (item: A) => number): A | undefined
53
65
  }
@@ -70,10 +82,16 @@ class Mulberry32 implements Random {
70
82
  }
71
83
 
72
84
  range(min: number, max: number): number {
85
+ if (!(Number.isFinite(min) && Number.isFinite(max) && min <= max)) {
86
+ throw new Error(`Random.range needs finite bounds with min <= max, got [${min}, ${max})`)
87
+ }
73
88
  return min + (max - min) * this.next()
74
89
  }
75
90
 
76
91
  integer(min: number, maxExclusive: number): number {
92
+ if (!(Number.isSafeInteger(min) && Number.isSafeInteger(maxExclusive) && min < maxExclusive)) {
93
+ throw new Error(`Random.integer needs whole bounds with min below maxExclusive, got [${min}, ${maxExclusive})`)
94
+ }
77
95
  return min + Math.floor(this.next() * (maxExclusive - min))
78
96
  }
79
97
 
@@ -93,20 +111,21 @@ class Mulberry32 implements Random {
93
111
  }
94
112
 
95
113
  weighted<A>(items: ReadonlyArray<A>, weight: (item: A) => number): A | undefined {
114
+ // `weight` is called once per item: it may be costly or not pure.
115
+ const weights = items.map(weight)
96
116
  let total = 0
97
- for (const item of items) {
98
- const value = weight(item)
117
+ for (const value of weights) {
99
118
  if (usable(value)) total += value
100
119
  }
101
120
  if (!usable(total)) return undefined
102
121
  let roll = this.next() * total
103
122
  let last: A | undefined
104
- for (const item of items) {
105
- const value = weight(item)
123
+ for (let index = 0; index < items.length; index++) {
124
+ const value = weights[index]!
106
125
  if (!usable(value)) continue
107
- last = item
126
+ last = items[index]
108
127
  roll -= value
109
- if (roll < 0) return item
128
+ if (roll < 0) return items[index]
110
129
  }
111
130
  // Rounding can leave a sliver of the roll past the last bucket.
112
131
  return last
@@ -121,3 +140,72 @@ class Mulberry32 implements Random {
121
140
  * @category Construction
122
141
  */
123
142
  export const make = (state: number): Random => new Mulberry32(state)
143
+
144
+ /**
145
+ * 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
147
+ * random resource and nothing else.
148
+ *
149
+ * @category Resource
150
+ */
151
+ export type State = Brand.Branded<number, "Random/State">
152
+
153
+ /** Why a value is not a generator state. @category Resource */
154
+ export interface InvalidState {
155
+ readonly _tag: "InvalidRandomState"
156
+ readonly value: unknown
157
+ }
158
+
159
+ const isState = (value: unknown): value is State =>
160
+ typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 0xffffffff
161
+
162
+ /**
163
+ * Validates a state, for `Descriptor.ConstructedResource(Random)` and saves.
164
+ *
165
+ * @category Resource
166
+ */
167
+ export const result = (raw: number): Result.Result<State, InvalidState> =>
168
+ isState(raw) ? Result.success(raw) : Result.failure({ _tag: "InvalidRandomState", value: raw })
169
+
170
+ /**
171
+ * Validates a state read from untrusted data (a save).
172
+ *
173
+ * @category Resource
174
+ */
175
+ export const decode = (raw: unknown): Result.Result<State, InvalidState> =>
176
+ isState(raw) ? Result.success(raw) : Result.failure({ _tag: "InvalidRandomState", value: raw })
177
+
178
+ /**
179
+ * The starting state for a seed, reduced to an unsigned 32-bit integer like
180
+ * `make`: the value to give a random resource in `Game.Runtime.make`.
181
+ *
182
+ * @category Resource
183
+ */
184
+ export const seed = (value: number): State => (value >>> 0) as State
185
+
186
+ class CellRandom extends Mulberry32 {
187
+ readonly #cell: WriteCell<State>
188
+
189
+ constructor(cell: WriteCell<State>) {
190
+ super(cell.get())
191
+ this.#cell = cell
192
+ }
193
+
194
+ override next(): number {
195
+ // Read the cell each time, so two generators over one cell share one sequence.
196
+ this.state = this.#cell.get()
197
+ const value = super.next()
198
+ this.#cell.set(this.state as State)
199
+ return value
200
+ }
201
+ }
202
+
203
+ /**
204
+ * A generator over a random resource's write cell: every draw continues from
205
+ * the stored state and writes the new one back, so the system needs no
206
+ * `set` of its own, a failed run rolls the draws back, and generators made
207
+ * from the same cell in one run continue one sequence.
208
+ *
209
+ * @category Resource
210
+ */
211
+ export const draw = (cell: WriteCell<State>): Random => new CellRandom(cell)
package/src/Scalar.ts CHANGED
@@ -41,7 +41,11 @@ import type * as Result from "@typeonce/ritmo/Result"
41
41
  * Finite number brand.
42
42
  *
43
43
  * Values of this type are guaranteed not to be `NaN`, `Infinity`, or
44
- * `-Infinity`.
44
+ * `-Infinity` when they come from a validator (`result`, `decode`). The
45
+ * arithmetic helpers here and in `Vector2` keep the brand without
46
+ * re-validating, so their results are finite only while they stay within
47
+ * `±Number.MAX_VALUE`: game-sized values never come close, but validate
48
+ * again with `result` when inputs may be that large.
45
49
  *
46
50
  * @category Scalar Types
47
51
  */
@@ -112,7 +116,8 @@ export const clamp: (value: Finite, min: Finite, max: Finite) => Finite = intern
112
116
  * Interpolates between two finite values by one finite amount.
113
117
  *
114
118
  * The amount is not clamped. This stays a pure interpolation primitive rather
115
- * than mixing interpolation with policy.
119
+ * than mixing interpolation with policy. The result keeps the `Finite` brand
120
+ * without re-validating (see `Finite`).
116
121
  *
117
122
  * @category Operations
118
123
  */
@@ -45,17 +45,34 @@ export interface SpatialGrid {
45
45
  readonly cellSize: Scalar.Positive
46
46
  /** Removes every circle, keeping the storage for the next rebuild. */
47
47
  clear(): void
48
- /** Adds circle `index` (a non-negative integer) covering `[x ± r] × [y ± r]`. */
48
+ /**
49
+ * Adds circle `index` (a non-negative integer) covering `[x ± r] × [y ± r]`.
50
+ * A non-finite coordinate or radius throws: it would cover no cell or all of them.
51
+ */
49
52
  insert(index: number, x: number, y: number, r: number): void
50
53
  /**
51
54
  * Calls `visit` once for every circle whose cells overlap the box, in no
52
- * particular order. `visit` must not insert into or query this grid.
55
+ * particular order. `visit` must not insert into or query this grid. A
56
+ * non-finite bound throws (an unbounded box would never finish); a box
57
+ * covering more cells than are occupied scans the occupied cells instead.
53
58
  */
54
59
  forEachIn(minX: number, minY: number, maxX: number, maxY: number, visit: (index: number) => void): void
55
60
  }
56
61
 
57
62
  const key = (cx: number, cy: number): number => ((cx + 32768) << 16) | ((cy + 32768) & 0xffff)
58
63
 
64
+ // Keys repeat every 65,536 cells, so a longer span visits nothing new.
65
+ const AXIS_CELLS = 65_536
66
+ const lastCell = (first: number, last: number): number => Math.min(last, first + AXIS_CELLS - 1)
67
+ /** Whether a cell coordinate packed in a key falls in `[first, last]`, counting the keys' wrap-around. */
68
+ const coveredBy = (coordinate: number, first: number, last: number): boolean =>
69
+ (((coordinate - first) % AXIS_CELLS) + AXIS_CELLS) % AXIS_CELLS <= last - first
70
+
71
+ const notFinite = (operation: string, ...values: ReadonlyArray<number>): never => {
72
+ const value = values.find((value) => !Number.isFinite(value))
73
+ throw new Error(`SpatialGrid.${operation} needs finite numbers, got ${value}`)
74
+ }
75
+
59
76
  class Grid implements SpatialGrid {
60
77
  readonly cellSize: Scalar.Positive
61
78
  private readonly cells = new Map<number, Array<number>>()
@@ -74,11 +91,13 @@ class Grid implements SpatialGrid {
74
91
  }
75
92
 
76
93
  insert(index: number, x: number, y: number, r: number): void {
94
+ // `x - x === 0` is false only for NaN and ±Infinity, without a call per number.
95
+ if (x - x !== 0 || y - y !== 0 || r - r !== 0) notFinite("insert", x, y, r)
77
96
  const size = this.cellSize
78
97
  const minX = Math.floor((x - r) / size)
79
- const maxX = Math.floor((x + r) / size)
98
+ const maxX = lastCell(minX, Math.floor((x + r) / size))
80
99
  const minY = Math.floor((y - r) / size)
81
- const maxY = Math.floor((y + r) / size)
100
+ const maxY = lastCell(minY, Math.floor((y + r) / size))
82
101
  for (let cx = minX; cx <= maxX; cx++) {
83
102
  for (let cy = minY; cy <= maxY; cy++) {
84
103
  const cellKey = key(cx, cy)
@@ -99,6 +118,7 @@ class Grid implements SpatialGrid {
99
118
  }
100
119
 
101
120
  forEachIn(minX: number, minY: number, maxX: number, maxY: number, visit: (index: number) => void): void {
121
+ if (minX - minX !== 0 || minY - minY !== 0 || maxX - maxX !== 0 || maxY - maxY !== 0) notFinite("forEachIn", minX, minY, maxX, maxY)
102
122
  const size = this.cellSize
103
123
  // Start the marks over before the counter leaves the stamps' range (after
104
124
  // about two billion queries), so old marks never match a new query.
@@ -110,9 +130,23 @@ class Grid implements SpatialGrid {
110
130
  const query = this.query
111
131
  const stamp = this.stamp
112
132
  const x0 = Math.floor(minX / size)
113
- const x1 = Math.floor(maxX / size)
133
+ const x1 = lastCell(x0, Math.floor(maxX / size))
114
134
  const y0 = Math.floor(minY / size)
115
- const y1 = Math.floor(maxY / size)
135
+ const y1 = lastCell(y0, Math.floor(maxY / size))
136
+ if ((x1 - x0 + 1) * (y1 - y0 + 1) > this.cells.size) {
137
+ // A box larger than the occupied area: scan the cells that exist.
138
+ for (const [cellKey, cell] of this.cells) {
139
+ if (cell.length === 0) continue
140
+ if (!coveredBy(((cellKey >> 16) & 0xffff) - 32768, x0, x1) || !coveredBy((cellKey & 0xffff) - 32768, y0, y1)) continue
141
+ for (let offset = 0; offset < cell.length; offset++) {
142
+ const index = cell[offset]!
143
+ if (stamp[index] === query) continue
144
+ stamp[index] = query
145
+ visit(index)
146
+ }
147
+ }
148
+ return
149
+ }
116
150
  for (let cx = x0; cx <= x1; cx++) {
117
151
  for (let cy = y0; cy <= y1; cy++) {
118
152
  const cell = this.cells.get(key(cx, cy))
package/src/Vec2.ts CHANGED
@@ -24,6 +24,8 @@
24
24
  * Immutable arithmetic, measurement, and steering helpers.
25
25
  */
26
26
 
27
+ import * as Ops from "./Ops.ts"
28
+
27
29
  /**
28
30
  * A two-dimensional vector in world units.
29
31
  *
@@ -68,8 +70,8 @@ export const scale = (a: Vec2, factor: number): Vec2 => ({ x: a.x * factor, y: a
68
70
  * @category Operations
69
71
  */
70
72
  export const rotate = (a: Vec2, angle: number): Vec2 => {
71
- const cos = Math.cos(angle)
72
- const sin = Math.sin(angle)
73
+ const cos = Ops.cos(angle)
74
+ const sin = Ops.sin(angle)
73
75
  return { x: a.x * cos - a.y * sin, y: a.x * sin + a.y * cos }
74
76
  }
75
77
 
@@ -85,7 +87,7 @@ export const dot = (a: Vec2, b: Vec2): number => a.x * b.x + a.y * b.y
85
87
  *
86
88
  * @category Operations
87
89
  */
88
- export const length = (a: Vec2): number => Math.hypot(a.x, a.y)
90
+ export const length = (a: Vec2): number => Ops.hypot(a.x, a.y)
89
91
 
90
92
  /**
91
93
  * Squared length of `a`, for comparisons without a square root.
@@ -99,7 +101,7 @@ export const lengthSquared = (a: Vec2): number => a.x * a.x + a.y * a.y
99
101
  *
100
102
  * @category Operations
101
103
  */
102
- export const distance = (a: Vec2, b: Vec2): number => Math.hypot(a.x - b.x, a.y - b.y)
104
+ export const distance = (a: Vec2, b: Vec2): number => Ops.hypot(a.x - b.x, a.y - b.y)
103
105
 
104
106
  /**
105
107
  * Squared distance between two points, for comparisons without a square root.
@@ -120,7 +122,7 @@ export const distanceSquared = (a: Vec2, b: Vec2): number => {
120
122
  * @category Operations
121
123
  */
122
124
  export const normalizeOr = (a: Vec2, fallback: Vec2): Vec2 => {
123
- const len = Math.hypot(a.x, a.y)
125
+ const len = Ops.hypot(a.x, a.y)
124
126
  return len < 1e-9 ? fallback : { x: a.x / len, y: a.y / len }
125
127
  }
126
128
 
@@ -139,7 +141,7 @@ export const lerp = (a: Vec2, b: Vec2, t: number): Vec2 => ({ x: a.x + (b.x - a.
139
141
  export const moveToward = (current: Vec2, target: Vec2, maxDelta: number): Vec2 => {
140
142
  const dx = target.x - current.x
141
143
  const dy = target.y - current.y
142
- const len = Math.hypot(dx, dy)
144
+ const len = Ops.hypot(dx, dy)
143
145
  if (len <= maxDelta || len < 1e-9) return target
144
146
  return { x: current.x + (dx / len) * maxDelta, y: current.y + (dy / len) * maxDelta }
145
147
  }
@@ -150,6 +152,6 @@ export const moveToward = (current: Vec2, target: Vec2, maxDelta: number): Vec2
150
152
  * @category Operations
151
153
  */
152
154
  export const clampLength = (a: Vec2, max: number): Vec2 => {
153
- const len = Math.hypot(a.x, a.y)
155
+ const len = Ops.hypot(a.x, a.y)
154
156
  return len <= max ? a : { x: (a.x / len) * max, y: (a.y / len) * max }
155
157
  }
package/src/Vector2.ts CHANGED
@@ -154,24 +154,36 @@ export const zero: () => Vector2 = internal.zero
154
154
  /**
155
155
  * Returns the sum of two branded vectors.
156
156
  *
157
+ * Keeps the brands without re-validating: finite while the result stays
158
+ * within `±Number.MAX_VALUE` (see `Scalar.Finite`).
159
+ *
157
160
  * @category Operations
158
161
  */
159
162
  export const add: (left: Vector2, right: Vector2) => Vector2 = internal.add
160
163
  /**
161
164
  * Returns the component-wise difference between two branded vectors.
162
165
  *
166
+ * Keeps the brands without re-validating: finite while the result stays
167
+ * within `±Number.MAX_VALUE` (see `Scalar.Finite`).
168
+ *
163
169
  * @category Operations
164
170
  */
165
171
  export const subtract: (left: Vector2, right: Vector2) => Vector2 = internal.subtract
166
172
  /**
167
173
  * Scales one vector by one validated finite scalar.
168
174
  *
175
+ * Keeps the brands without re-validating: finite while the result stays
176
+ * within `±Number.MAX_VALUE` (see `Scalar.Finite`).
177
+ *
169
178
  * @category Operations
170
179
  */
171
180
  export const scale: (vector: Vector2, scalar: Scalar.Finite) => Vector2 = internal.scale
172
181
  /**
173
182
  * Returns the squared length of one vector.
174
183
  *
184
+ * Keeps the brands without re-validating: finite while the result stays
185
+ * within `±Number.MAX_VALUE` (see `Scalar.Finite`).
186
+ *
175
187
  * @category Operations
176
188
  */
177
189
  export const lengthSquared: (vector: Vector2) => Scalar.NonNegative = internal.lengthSquared
package/src/index.ts CHANGED
@@ -10,12 +10,17 @@
10
10
  * `Box2` edges, `Collide2` swept circle collision, a `SpatialGrid` broad
11
11
  * phase, a `FlowField` for finding the way around walls, and a deterministic
12
12
  * `Random` whose state is one integer.
13
+ *
14
+ * `Ops` holds the math functions engines are allowed to approximate (`sin`,
15
+ * `cos`, `atan2`, `exp`, `pow`, `hypot`, ...) as ports that return the same
16
+ * bits everywhere: use them in simulation code instead of `Math`'s.
13
17
  */
14
18
  export * as Aabb from "./Aabb.ts"
15
19
  export * as Box2 from "./Box2.ts"
16
20
  export * as Collide2 from "./Collide2.ts"
17
21
  export * as FlowField from "./FlowField.ts"
18
22
  export * as InputAxis from "./InputAxis.ts"
23
+ export * as Ops from "./Ops.ts"
19
24
  export * as Random from "./Random.ts"
20
25
  export * as Scalar from "./Scalar.ts"
21
26
  export * as Size2 from "./Size2.ts"