@typeonce/ritmo 0.7.0 → 0.9.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/README.md +3 -2
- package/dist/Clock.d.ts +19 -4
- package/dist/Clock.d.ts.map +1 -1
- package/dist/Clock.js +18 -2
- package/dist/Clock.js.map +1 -1
- package/dist/Command.d.ts +181 -40
- package/dist/Command.d.ts.map +1 -1
- package/dist/Command.js +31 -11
- package/dist/Command.js.map +1 -1
- package/dist/Condition.d.ts +12 -1
- package/dist/Condition.d.ts.map +1 -1
- package/dist/Condition.js.map +1 -1
- package/dist/Debug.d.ts +127 -13
- package/dist/Debug.d.ts.map +1 -1
- package/dist/Descriptor.d.ts +61 -10
- package/dist/Descriptor.d.ts.map +1 -1
- package/dist/Descriptor.js +22 -10
- package/dist/Descriptor.js.map +1 -1
- package/dist/Entity.d.ts +22 -7
- package/dist/Entity.d.ts.map +1 -1
- package/dist/Entity.js.map +1 -1
- package/dist/EntityScope.d.ts +20 -1
- package/dist/EntityScope.d.ts.map +1 -1
- package/dist/EntityScope.js +8 -2
- package/dist/EntityScope.js.map +1 -1
- package/dist/FixedLoop.d.ts +106 -0
- package/dist/FixedLoop.d.ts.map +1 -0
- package/dist/FixedLoop.js +97 -0
- package/dist/FixedLoop.js.map +1 -0
- package/dist/HostClock.d.ts +1 -1
- package/dist/HostClock.d.ts.map +1 -1
- package/dist/HostClock.js +2 -1
- package/dist/HostClock.js.map +1 -1
- package/dist/Machine.d.ts +59 -34
- package/dist/Machine.d.ts.map +1 -1
- package/dist/Machine.js +10 -3
- package/dist/Machine.js.map +1 -1
- package/dist/Query.d.ts +100 -14
- package/dist/Query.d.ts.map +1 -1
- package/dist/Query.js +34 -5
- package/dist/Query.js.map +1 -1
- package/dist/Relation.d.ts +5 -14
- package/dist/Relation.d.ts.map +1 -1
- package/dist/Relation.js +0 -32
- package/dist/Relation.js.map +1 -1
- package/dist/Runtime.d.ts +61 -15
- package/dist/Runtime.d.ts.map +1 -1
- package/dist/Runtime.js +594 -70
- package/dist/Runtime.js.map +1 -1
- package/dist/Schedule.d.ts +24 -15
- package/dist/Schedule.d.ts.map +1 -1
- package/dist/Schedule.js +2 -1
- package/dist/Schedule.js.map +1 -1
- package/dist/Schema.d.ts +233 -48
- package/dist/Schema.d.ts.map +1 -1
- package/dist/Schema.js +33 -7
- package/dist/Schema.js.map +1 -1
- package/dist/Snapshot.d.ts +90 -1
- package/dist/Snapshot.d.ts.map +1 -1
- package/dist/Snapshot.js +42 -1
- package/dist/Snapshot.js.map +1 -1
- package/dist/System.d.ts +132 -31
- package/dist/System.d.ts.map +1 -1
- package/dist/System.js +32 -0
- package/dist/System.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/internal/cells.d.ts +2 -0
- package/dist/internal/cells.d.ts.map +1 -1
- package/dist/internal/cells.js +34 -8
- package/dist/internal/cells.js.map +1 -1
- package/dist/internal/debug.d.ts +10 -0
- package/dist/internal/debug.d.ts.map +1 -1
- package/dist/internal/debug.js +171 -12
- package/dist/internal/debug.js.map +1 -1
- package/dist/internal/equal.d.ts +8 -0
- package/dist/internal/equal.d.ts.map +1 -0
- package/dist/internal/equal.js +38 -0
- package/dist/internal/equal.js.map +1 -0
- package/dist/internal/fragments.d.ts +2 -6
- package/dist/internal/fragments.d.ts.map +1 -1
- package/dist/internal/fragments.js +36 -3
- package/dist/internal/fragments.js.map +1 -1
- package/dist/internal/game.d.ts +2 -0
- package/dist/internal/game.d.ts.map +1 -1
- package/dist/internal/game.js +48 -7
- package/dist/internal/game.js.map +1 -1
- package/dist/internal/order.d.ts.map +1 -1
- package/dist/internal/order.js +4 -2
- package/dist/internal/order.js.map +1 -1
- package/dist/internal/queries.d.ts.map +1 -1
- package/dist/internal/queries.js +57 -26
- package/dist/internal/queries.js.map +1 -1
- package/dist/internal/relations.d.ts +10 -0
- package/dist/internal/relations.d.ts.map +1 -0
- package/dist/internal/relations.js +32 -0
- package/dist/internal/relations.js.map +1 -0
- package/dist/internal/requirements.d.ts +26 -0
- package/dist/internal/requirements.d.ts.map +1 -0
- package/dist/internal/requirements.js +46 -0
- package/dist/internal/requirements.js.map +1 -0
- package/dist/internal/streams.d.ts +2 -0
- package/dist/internal/streams.d.ts.map +1 -1
- package/dist/internal/streams.js +10 -0
- package/dist/internal/streams.js.map +1 -1
- package/dist/internal/world.d.ts +7 -1
- package/dist/internal/world.d.ts.map +1 -1
- package/dist/internal/world.js +53 -3
- package/dist/internal/world.js.map +1 -1
- package/package.json +1 -1
- package/src/Clock.ts +29 -4
- package/src/Command.ts +281 -67
- package/src/Condition.ts +17 -1
- package/src/Debug.ts +124 -15
- package/src/Descriptor.ts +92 -21
- package/src/Entity.ts +23 -7
- package/src/EntityScope.ts +21 -2
- package/src/FixedLoop.ts +218 -0
- package/src/HostClock.ts +2 -1
- package/src/Machine.ts +72 -35
- package/src/Query.ts +118 -17
- package/src/Relation.ts +5 -67
- package/src/Runtime.ts +659 -85
- package/src/Schedule.ts +31 -19
- package/src/Schema.ts +297 -57
- package/src/Snapshot.ts +94 -2
- package/src/System.ts +151 -29
- package/src/index.ts +4 -0
- package/src/internal/cells.ts +42 -10
- package/src/internal/debug.ts +179 -14
- package/src/internal/equal.ts +31 -0
- package/src/internal/fragments.ts +35 -5
- package/src/internal/game.ts +69 -7
- package/src/internal/order.ts +8 -4
- package/src/internal/queries.ts +62 -12
- package/src/internal/relations.ts +52 -0
- package/src/internal/requirements.ts +71 -0
- package/src/internal/streams.ts +11 -0
- package/src/internal/world.ts +60 -4
package/src/FixedLoop.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic fixed-step timing for any host: browsers, native shells,
|
|
3
|
+
* desktop runtimes, and headless tests.
|
|
4
|
+
*
|
|
5
|
+
* The loop owns the timing policy that is easy to get subtly wrong in every
|
|
6
|
+
* game: frame clamping, catch-up limits, interpolation, cancellation, and
|
|
7
|
+
* typed update failures. It does not know about an ECS runtime, a renderer,
|
|
8
|
+
* or a platform: frames arrive through a `TickSource` that the host supplies.
|
|
9
|
+
* Use a platform adapter such as `BrowserTickSource.make(window)` from
|
|
10
|
+
* `@typeonce/ritmo-browser`, or wrap a renderer's own ticker with `source`.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* const loop = FixedLoop.start({
|
|
15
|
+
* source: BrowserTickSource.make(window),
|
|
16
|
+
* step: Clock.hz(60),
|
|
17
|
+
* maxFrameSeconds: 0.25,
|
|
18
|
+
* maxStepsPerFrame: 5,
|
|
19
|
+
* update: () => runtime.tick(update),
|
|
20
|
+
* render: (frame) => draw(frame.alpha),
|
|
21
|
+
* onFailure: (failure) => console.error(failure)
|
|
22
|
+
* })
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* @module FixedLoop
|
|
26
|
+
* @docGroup runtime
|
|
27
|
+
*/
|
|
28
|
+
import type * as Clock from "./Clock.ts"
|
|
29
|
+
import * as Result from "./Result.ts"
|
|
30
|
+
|
|
31
|
+
/** One host frame: milliseconds elapsed since the previous one. */
|
|
32
|
+
export interface Tick {
|
|
33
|
+
readonly deltaMS: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A stoppable host subscription. Calling `stop` more than once is safe. */
|
|
37
|
+
export interface Subscription {
|
|
38
|
+
stop(): void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Where host frames come from. Implemented by platform adapters
|
|
43
|
+
* (`BrowserTickSource.make(window)`), by `source` around a renderer's ticker,
|
|
44
|
+
* and by deterministic tests.
|
|
45
|
+
*/
|
|
46
|
+
export interface TickSource<TTick extends Tick = Tick> {
|
|
47
|
+
subscribe(onTick: (tick: TTick) => void): Subscription
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Validated fixed-step timing policy. */
|
|
51
|
+
export interface Options {
|
|
52
|
+
/**
|
|
53
|
+
* The fixed step every update advances (`Clock.hz(60)`): the same value
|
|
54
|
+
* the game's clock resource runs at, so the loop and the clock agree.
|
|
55
|
+
*/
|
|
56
|
+
readonly step: Clock.Step
|
|
57
|
+
/** Maximum host-frame duration admitted into the accumulator. */
|
|
58
|
+
readonly maxFrameSeconds: number
|
|
59
|
+
/** Maximum number of simulation updates performed for one host frame. */
|
|
60
|
+
readonly maxStepsPerFrame: number
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Configuration error returned before a loop subscribes to its host. */
|
|
64
|
+
export interface InvalidOptions {
|
|
65
|
+
readonly kind: "InvalidFixedLoopOptions"
|
|
66
|
+
readonly field: keyof Options
|
|
67
|
+
readonly reason: "mustBeFiniteAndPositive" | "mustBePositiveInteger"
|
|
68
|
+
readonly value: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Data passed to the renderer after all fixed updates for one host frame. */
|
|
72
|
+
export interface Frame {
|
|
73
|
+
/** Clamped time admitted for this host frame. */
|
|
74
|
+
readonly elapsedSeconds: number
|
|
75
|
+
/** Number of fixed updates completed for this host frame. */
|
|
76
|
+
readonly fixedSteps: number
|
|
77
|
+
/** Remaining accumulator fraction in the range `[0, 1)`. */
|
|
78
|
+
readonly alpha: number
|
|
79
|
+
/** Time discarded by frame clamping or the catch-up limit. */
|
|
80
|
+
readonly droppedSeconds: number
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Failure delivered by an active loop before it stops itself. */
|
|
84
|
+
export type Failure<UpdateError> =
|
|
85
|
+
| {
|
|
86
|
+
readonly kind: "InvalidTickDelta"
|
|
87
|
+
readonly deltaMS: number
|
|
88
|
+
}
|
|
89
|
+
| {
|
|
90
|
+
readonly kind: "UpdateFailure"
|
|
91
|
+
readonly error: UpdateError
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A running fixed-step driver. */
|
|
95
|
+
export interface Driver extends Subscription {
|
|
96
|
+
/** Clears accumulated partial time without removing the host subscription. */
|
|
97
|
+
reset(): void
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Input for a fixed-step driver. */
|
|
101
|
+
export interface StartOptions<TTick extends Tick, UpdateError> extends Options {
|
|
102
|
+
readonly source: TickSource<TTick>
|
|
103
|
+
readonly update: (stepSeconds: number) => Result.Result<void, UpdateError>
|
|
104
|
+
readonly render: (frame: Frame) => void
|
|
105
|
+
readonly onFailure: (failure: Failure<UpdateError>) => void
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Wraps a host-specific subscription function in the canonical source type. */
|
|
109
|
+
export const source = <TTick extends Tick>(
|
|
110
|
+
subscribe: TickSource<TTick>["subscribe"]
|
|
111
|
+
): TickSource<TTick> => ({ subscribe })
|
|
112
|
+
|
|
113
|
+
const validate = (options: Options): Result.Result<void, InvalidOptions> => {
|
|
114
|
+
for (const field of ["maxFrameSeconds"] as const) {
|
|
115
|
+
const value = options[field]
|
|
116
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
117
|
+
return Result.failure({
|
|
118
|
+
kind: "InvalidFixedLoopOptions",
|
|
119
|
+
field,
|
|
120
|
+
reason: "mustBeFiniteAndPositive",
|
|
121
|
+
value
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (!Number.isInteger(options.maxStepsPerFrame) || options.maxStepsPerFrame <= 0) {
|
|
126
|
+
return Result.failure({
|
|
127
|
+
kind: "InvalidFixedLoopOptions",
|
|
128
|
+
field: "maxStepsPerFrame",
|
|
129
|
+
reason: "mustBePositiveInteger",
|
|
130
|
+
value: options.maxStepsPerFrame
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
return Result.success(undefined)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Starts a fixed-step loop after validating its timing policy.
|
|
138
|
+
*
|
|
139
|
+
* Update failures and malformed host ticks are delivered to `onFailure`, then
|
|
140
|
+
* the driver stops. Render defects are deliberately not caught: they are
|
|
141
|
+
* programmer defects, not expected game outcomes.
|
|
142
|
+
*/
|
|
143
|
+
export const start = <TTick extends Tick, UpdateError>(
|
|
144
|
+
options: StartOptions<TTick, UpdateError>
|
|
145
|
+
): Result.Result<Driver, InvalidOptions> => {
|
|
146
|
+
const validity = validate(options)
|
|
147
|
+
if (!validity.ok) {
|
|
148
|
+
return validity
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let accumulator = 0
|
|
152
|
+
let stopped = false
|
|
153
|
+
let hostSubscription: Subscription | undefined
|
|
154
|
+
|
|
155
|
+
const stop = (): void => {
|
|
156
|
+
if (stopped) {
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
stopped = true
|
|
160
|
+
hostSubscription?.stop()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const fail = (failure: Failure<UpdateError>): void => {
|
|
164
|
+
stop()
|
|
165
|
+
options.onFailure(failure)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const onTick = (tick: TTick): void => {
|
|
169
|
+
if (stopped) {
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
if (!Number.isFinite(tick.deltaMS) || tick.deltaMS < 0) {
|
|
173
|
+
fail({ kind: "InvalidTickDelta", deltaMS: tick.deltaMS })
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const rawSeconds = tick.deltaMS / 1000
|
|
178
|
+
const elapsedSeconds = Math.min(rawSeconds, options.maxFrameSeconds)
|
|
179
|
+
let droppedSeconds = rawSeconds - elapsedSeconds
|
|
180
|
+
accumulator += elapsedSeconds
|
|
181
|
+
|
|
182
|
+
let fixedSteps = 0
|
|
183
|
+
while (accumulator >= options.step.dt && fixedSteps < options.maxStepsPerFrame) {
|
|
184
|
+
const update = options.update(options.step.dt)
|
|
185
|
+
if (!update.ok) {
|
|
186
|
+
fail({ kind: "UpdateFailure", error: update.error })
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
accumulator -= options.step.dt
|
|
190
|
+
fixedSteps += 1
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (accumulator >= options.step.dt) {
|
|
194
|
+
const droppedWholeSteps = Math.floor(accumulator / options.step.dt) * options.step.dt
|
|
195
|
+
accumulator -= droppedWholeSteps
|
|
196
|
+
droppedSeconds += droppedWholeSteps
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
options.render({
|
|
200
|
+
elapsedSeconds,
|
|
201
|
+
fixedSteps,
|
|
202
|
+
alpha: accumulator / options.step.dt,
|
|
203
|
+
droppedSeconds
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
hostSubscription = options.source.subscribe(onTick)
|
|
208
|
+
if (stopped) {
|
|
209
|
+
hostSubscription.stop()
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return Result.success({
|
|
213
|
+
stop,
|
|
214
|
+
reset() {
|
|
215
|
+
accumulator = 0
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
}
|
package/src/HostClock.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*
|
|
14
14
|
* @example
|
|
15
15
|
* ```ts
|
|
16
|
-
* const runtime = Game.Runtime.make({ services, debug: { hostClock: HostClock.standard } })
|
|
16
|
+
* const runtime = Game.Runtime.make({ services, debug: { hostClock: HostClock.standard } }).value
|
|
17
17
|
* ```
|
|
18
18
|
*
|
|
19
19
|
* @module HostClock
|
|
@@ -33,6 +33,7 @@ export interface HostClock {
|
|
|
33
33
|
* 0. Prefer a monotonic platform clock when there is one
|
|
34
34
|
* (`BrowserHostClock.make(window)`).
|
|
35
35
|
*/
|
|
36
|
+
// oxlint-disable-next-line ritmo/use-clock-for-time -- HostClock is host time, for hosts and tools; systems read Clock.
|
|
36
37
|
export const standard: HostClock = { now: () => Date.now() }
|
|
37
38
|
|
|
38
39
|
/** `adjust` must leave a finite clock reading and never move backwards. */
|
package/src/Machine.ts
CHANGED
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
*/
|
|
40
40
|
import type { AnyScheduleOrder, Schedule } from "./Schedule.ts"
|
|
41
41
|
import * as Requirement from "./Requirement.ts"
|
|
42
|
+
import type * as Result from "./Result.ts"
|
|
42
43
|
import type { Schema } from "./Schema.ts"
|
|
43
44
|
import type { SystemFailure } from "./System.ts"
|
|
44
45
|
|
|
@@ -56,60 +57,71 @@ export type StateValue = string | number
|
|
|
56
57
|
export interface StateMachineDefinition<
|
|
57
58
|
out Name extends string = string,
|
|
58
59
|
out Values extends readonly [StateValue, ...StateValue[]] = readonly [StateValue, ...StateValue[]],
|
|
59
|
-
out Root = unknown
|
|
60
|
+
out Root = unknown,
|
|
61
|
+
out Parent = unknown
|
|
60
62
|
> {
|
|
61
63
|
readonly kind: "stateMachine"
|
|
62
64
|
readonly name: Name
|
|
63
65
|
readonly key: symbol
|
|
64
66
|
readonly values: Values
|
|
67
|
+
/** For a sub-state: the machine and the state it exists in. */
|
|
68
|
+
readonly within?: SubStateParent | undefined
|
|
65
69
|
readonly __schemaRoot?: Root | undefined
|
|
70
|
+
/** The machine a sub-state exists within; `unknown` for a top-level machine. */
|
|
71
|
+
readonly __parent?: (_: never) => Parent
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Where a sub-state exists: while `machine` is in `state`. */
|
|
75
|
+
export interface SubStateParent {
|
|
76
|
+
readonly machine: StateMachine.Any
|
|
77
|
+
readonly state: StateValue
|
|
66
78
|
}
|
|
67
79
|
|
|
68
80
|
/**
|
|
69
|
-
*
|
|
81
|
+
* Whether `M` is a sub-state (declared with `within`): it has no value while
|
|
82
|
+
* its parent machine is in another state.
|
|
70
83
|
*/
|
|
71
|
-
export
|
|
72
|
-
readonly
|
|
73
|
-
readonly to: StateMachine.Value<M>
|
|
74
|
-
}
|
|
84
|
+
export type IsSubState<M> =
|
|
85
|
+
M extends { readonly __parent?: (_: never) => infer Parent } ? unknown extends Parent ? false : true : false
|
|
75
86
|
|
|
76
87
|
/**
|
|
77
|
-
* A
|
|
88
|
+
* The committed value of `M`. A sub-state has none (`undefined`) while its
|
|
89
|
+
* parent machine is not in the state it exists in; a top-level machine
|
|
90
|
+
* always has one.
|
|
78
91
|
*/
|
|
79
|
-
export
|
|
80
|
-
|
|
81
|
-
is(value: StateMachine.Value<M>): boolean
|
|
82
|
-
}
|
|
92
|
+
export type CurrentValue<M extends StateMachine.Any> =
|
|
93
|
+
IsSubState<M> extends true ? StateMachine.Value<M> | undefined : StateMachine.Value<M>
|
|
83
94
|
|
|
84
95
|
/**
|
|
85
|
-
*
|
|
96
|
+
* Why queuing a sub-state's next value failed: its parent machine is not in
|
|
97
|
+
* the state the sub-state exists in, so there is nothing to change.
|
|
86
98
|
*/
|
|
87
|
-
export interface
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
99
|
+
export interface SubStateInactive {
|
|
100
|
+
readonly _tag: "SubStateInactive"
|
|
101
|
+
/** The sub-state's name. */
|
|
102
|
+
readonly machine: string
|
|
103
|
+
/** The parent machine's name. */
|
|
104
|
+
readonly parent: string
|
|
105
|
+
/** The parent state the sub-state exists in. */
|
|
106
|
+
readonly requires: StateValue
|
|
92
107
|
}
|
|
93
108
|
|
|
94
109
|
/**
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* only exposed to systems that explicitly declare transition access.
|
|
110
|
+
* What `nextMachines.x.set(...)` returns: nothing for a top-level machine,
|
|
111
|
+
* and for a sub-state a `Result` that fails with `SubStateInactive` when its
|
|
112
|
+
* parent is in another state.
|
|
99
113
|
*/
|
|
100
|
-
export
|
|
101
|
-
|
|
102
|
-
}
|
|
114
|
+
export type SetResult<M extends StateMachine.Any> =
|
|
115
|
+
IsSubState<M> extends true ? Result.Result<void, SubStateInactive> : void
|
|
103
116
|
|
|
104
117
|
/**
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
* transition commits. Like events, each reader sees the transitions published
|
|
109
|
-
* since its own previous run.
|
|
118
|
+
* The runtime-visible transition payload for one machine. For a sub-state,
|
|
119
|
+
* `from` is `undefined` when it starts existing (its parent entered its
|
|
120
|
+
* state) and `to` is `undefined` when it stops.
|
|
110
121
|
*/
|
|
111
|
-
export interface
|
|
112
|
-
|
|
122
|
+
export interface TransitionSnapshot<M extends StateMachineDefinition = StateMachineDefinition> {
|
|
123
|
+
readonly from: CurrentValue<M>
|
|
124
|
+
readonly to: CurrentValue<M>
|
|
113
125
|
}
|
|
114
126
|
|
|
115
127
|
/**
|
|
@@ -263,7 +275,7 @@ export interface TransitionScheduleDefinition<
|
|
|
263
275
|
* Type-level and value-level helpers for finite-state machines.
|
|
264
276
|
*/
|
|
265
277
|
export namespace StateMachine {
|
|
266
|
-
export type Any = StateMachineDefinition<string, readonly [StateValue, ...StateValue[]], unknown>
|
|
278
|
+
export type Any = StateMachineDefinition<string, readonly [StateValue, ...StateValue[]], unknown, unknown>
|
|
267
279
|
export type Value<M extends Any> = M["values"][number]
|
|
268
280
|
export type Root<M extends Any> = M extends StateMachineDefinition<string, readonly [StateValue, ...StateValue[]], infer R> ? R : never
|
|
269
281
|
export type AnyCondition<Root = unknown> = Condition<Root>
|
|
@@ -271,6 +283,23 @@ export namespace StateMachine {
|
|
|
271
283
|
TransitionScheduleDefinition<S, Any, Requirement.Requirement, Root, SystemFailure>
|
|
272
284
|
}
|
|
273
285
|
|
|
286
|
+
/**
|
|
287
|
+
* `unknown` when `M` was defined by the game rooted at `Root`; otherwise a
|
|
288
|
+
* type naming the fix, so passing another game's machine fails with that
|
|
289
|
+
* message.
|
|
290
|
+
*/
|
|
291
|
+
export type SameGame<M, Root> =
|
|
292
|
+
M extends StateMachineDefinition<infer Name, readonly [StateValue, ...StateValue[]], infer MachineRoot>
|
|
293
|
+
? [Root] extends [MachineRoot]
|
|
294
|
+
? [MachineRoot] extends [Root]
|
|
295
|
+
? unknown
|
|
296
|
+
: ForeignMachine<`${Name} is a state machine of another game: use a machine defined with this game's Game.StateMachine(...)`>
|
|
297
|
+
: ForeignMachine<`${Name} is a state machine of another game: use a machine defined with this game's Game.StateMachine(...)`>
|
|
298
|
+
: unknown
|
|
299
|
+
|
|
300
|
+
/** The message type `SameGame` resolves to: a type error prints `Message`. */
|
|
301
|
+
type ForeignMachine<Message extends string> = { readonly __foreignMachine__: Message }
|
|
302
|
+
|
|
274
303
|
/** Extracts the machine token from one declared machine access slot. */
|
|
275
304
|
type MachineFromAccess<Access> =
|
|
276
305
|
Access extends { readonly machine: infer M extends StateMachine.Any } ? M : never
|
|
@@ -314,6 +343,9 @@ export type MachineNeedsFromConditions<C extends ReadonlyArray<Condition>> =
|
|
|
314
343
|
*
|
|
315
344
|
* Use `Descriptor.EntityState(...)` instead when every entity carries its own
|
|
316
345
|
* state (per-entity, written immediately, no enter/exit schedules).
|
|
346
|
+
*
|
|
347
|
+
* Pass `within` for a sub-state: a machine that exists only while another
|
|
348
|
+
* machine is in one state (`Game.StateMachine(name, values, { within })`).
|
|
317
349
|
*/
|
|
318
350
|
export const StateMachine = <
|
|
319
351
|
const Name extends string,
|
|
@@ -321,12 +353,14 @@ export const StateMachine = <
|
|
|
321
353
|
Root = unknown
|
|
322
354
|
>(
|
|
323
355
|
name: Name,
|
|
324
|
-
values: Values
|
|
356
|
+
values: Values,
|
|
357
|
+
within?: SubStateParent
|
|
325
358
|
): StateMachineDefinition<Name, Values, Root> => ({
|
|
326
359
|
kind: "stateMachine",
|
|
327
360
|
name,
|
|
328
361
|
key: Symbol.for(`ritmo/stateMachine/${name}`),
|
|
329
|
-
values
|
|
362
|
+
values,
|
|
363
|
+
...(within === undefined ? {} : { within })
|
|
330
364
|
}) as StateMachineDefinition<Name, Values, Root>
|
|
331
365
|
|
|
332
366
|
/**
|
|
@@ -384,7 +418,10 @@ export const inState = <M extends StateMachine.Any>(
|
|
|
384
418
|
})
|
|
385
419
|
|
|
386
420
|
/**
|
|
387
|
-
* Creates a condition that passes when the machine changed during the current schedule execution
|
|
421
|
+
* Creates a condition that passes when the machine changed during the current schedule execution:
|
|
422
|
+
* from the `applyStateTransitions(...)` that committed the change to the end of
|
|
423
|
+
* that schedule (or the next `applyStateTransitions(...)`), not for the rest of
|
|
424
|
+
* the tick call or the next schedule.
|
|
388
425
|
*/
|
|
389
426
|
export const stateChanged = <M extends StateMachine.Any>(
|
|
390
427
|
machine: M
|
package/src/Query.ts
CHANGED
|
@@ -69,6 +69,7 @@
|
|
|
69
69
|
* Query authoring helpers for selecting components and declaring explicit filters.
|
|
70
70
|
*/
|
|
71
71
|
import type { DecodeError } from "./Decode.ts"
|
|
72
|
+
import * as DescriptorModule from "./Descriptor.ts"
|
|
72
73
|
import type { Descriptor, EntityStateValue } from "./Descriptor.ts"
|
|
73
74
|
import type { EntityId, EntityMut, EntityRef } from "./Entity.ts"
|
|
74
75
|
import type * as Result from "./Result.ts"
|
|
@@ -179,10 +180,24 @@ export interface ChangedFilter<D extends ComponentDescriptor> {
|
|
|
179
180
|
readonly descriptor: D
|
|
180
181
|
}
|
|
181
182
|
|
|
182
|
-
|
|
183
|
+
/** An `added` or `changed` filter: the filters `anyOf` combines. */
|
|
184
|
+
export type LifecycleFilter<D extends ComponentDescriptor> =
|
|
183
185
|
| AddedFilter<D>
|
|
184
186
|
| ChangedFilter<D>
|
|
185
187
|
|
|
188
|
+
/**
|
|
189
|
+
* A filter that matches entities passing at least one of its `added` /
|
|
190
|
+
* `changed` filters since the reading system's previous run.
|
|
191
|
+
*/
|
|
192
|
+
export interface AnyOfFilter<D extends ComponentDescriptor> {
|
|
193
|
+
readonly kind: "anyOf"
|
|
194
|
+
readonly filters: ReadonlyArray<LifecycleFilter<D>>
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export type Filter<D extends ComponentDescriptor> =
|
|
198
|
+
| LifecycleFilter<D>
|
|
199
|
+
| AnyOfFilter<D>
|
|
200
|
+
|
|
186
201
|
/**
|
|
187
202
|
* Declares read-only access to a component in a query selection.
|
|
188
203
|
*
|
|
@@ -203,11 +218,29 @@ export const read = <D extends ComponentDescriptor>(descriptor: D): ReadAccess<D
|
|
|
203
218
|
* place. A write slot both requires component presence and exposes the slot as
|
|
204
219
|
* a `WriteCell`, so mutation capability stays visible in the query spec rather
|
|
205
220
|
* than appearing ad hoc in the loop body.
|
|
221
|
+
*
|
|
222
|
+
* Components declared `immutable: true` cannot be written: replace them with
|
|
223
|
+
* `commands.insert(...)` instead.
|
|
206
224
|
*/
|
|
207
|
-
export const write = <D extends ComponentDescriptor>(descriptor: D): WriteAccess<D> =>
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}
|
|
225
|
+
export const write = <D extends ComponentDescriptor>(descriptor: D & Writable<D>): WriteAccess<D> => {
|
|
226
|
+
if (DescriptorModule.isImmutable(descriptor)) {
|
|
227
|
+
throw new Error(immutableMessage(descriptor.name))
|
|
228
|
+
}
|
|
229
|
+
return { mode: "write", descriptor }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const immutableMessage = (name: string) =>
|
|
233
|
+
`${name} is immutable: replace it with commands.insert(entity, Game.Command.entry(${name}, ...))`
|
|
234
|
+
|
|
235
|
+
type NameOf<D> = D extends { readonly name: infer Name extends string } ? Name : never
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* `unknown` for a component a query may write; for one declared
|
|
239
|
+
* `immutable: true`, an argument error naming the fix.
|
|
240
|
+
*/
|
|
241
|
+
export type Writable<D> = D extends DescriptorModule.Immutable
|
|
242
|
+
? { readonly __immutable__: `${NameOf<D>} is immutable: replace it with commands.insert(entity, Game.Command.entry(${NameOf<D>}, ...))` }
|
|
243
|
+
: unknown
|
|
211
244
|
|
|
212
245
|
/**
|
|
213
246
|
* Declares maybe-present read-only access to a component in a query
|
|
@@ -265,7 +298,8 @@ export const added = <D extends ComponentDescriptor>(descriptor: D): AddedFilter
|
|
|
265
298
|
*
|
|
266
299
|
* Any write through a write cell counts, even when the value is equal to the
|
|
267
300
|
* previous one. Writes from a system whose run failed are rolled back and do
|
|
268
|
-
* not count.
|
|
301
|
+
* not count. A system never sees its own writes as changes on its next run
|
|
302
|
+
* (it does see the events it emitted).
|
|
269
303
|
*
|
|
270
304
|
* @example
|
|
271
305
|
* ```ts
|
|
@@ -282,6 +316,34 @@ export const changed = <D extends ComponentDescriptor>(descriptor: D): ChangedFi
|
|
|
282
316
|
descriptor
|
|
283
317
|
})
|
|
284
318
|
|
|
319
|
+
/**
|
|
320
|
+
* Declares a filter that matches entities passing at least one of `filters`
|
|
321
|
+
* (`added` / `changed`) since the reading system's previous run. Each entity
|
|
322
|
+
* is matched once, however many of its filters pass.
|
|
323
|
+
*
|
|
324
|
+
* Filters listed side by side in `filters: [...]` must all pass; use `anyOf`
|
|
325
|
+
* when any one of them is enough, for example to redraw when either the
|
|
326
|
+
* position or the sprite changed.
|
|
327
|
+
*
|
|
328
|
+
* @example
|
|
329
|
+
* ```ts
|
|
330
|
+
* const RedrawQuery = Game.Query({
|
|
331
|
+
* selection: { position: Game.Query.read(Position), sprite: Game.Query.read(Sprite) },
|
|
332
|
+
* filters: [Game.Query.anyOf(Game.Query.changed(Position), Game.Query.changed(Sprite))]
|
|
333
|
+
* })
|
|
334
|
+
* ```
|
|
335
|
+
*/
|
|
336
|
+
export const anyOf = <
|
|
337
|
+
const Filters extends readonly [
|
|
338
|
+
LifecycleFilter<ComponentDescriptor>,
|
|
339
|
+
LifecycleFilter<ComponentDescriptor>,
|
|
340
|
+
...ReadonlyArray<LifecycleFilter<ComponentDescriptor>>
|
|
341
|
+
]
|
|
342
|
+
>(...filters: Filters): AnyOfFilter<Filters[number]["descriptor"]> => ({
|
|
343
|
+
kind: "anyOf",
|
|
344
|
+
filters
|
|
345
|
+
})
|
|
346
|
+
|
|
285
347
|
/**
|
|
286
348
|
* A fully explicit query specification.
|
|
287
349
|
*
|
|
@@ -366,6 +428,8 @@ export namespace Query {
|
|
|
366
428
|
*/
|
|
367
429
|
export type Root<T extends Any> = T extends QuerySpec<any, any, any, any, any, any, any, any, infer R> ? R : never
|
|
368
430
|
|
|
431
|
+
type FilteredDescriptors<F> = F extends { readonly kind: "added" | "changed"; readonly descriptor: infer D } ? D : never
|
|
432
|
+
|
|
369
433
|
type RequiredSelectionDescriptors<T extends Any> = {
|
|
370
434
|
readonly [K in keyof T["selection"]]:
|
|
371
435
|
T["selection"][K] extends ReadAccess<infer D> | WriteAccess<infer D> ? D : never
|
|
@@ -380,12 +444,19 @@ export namespace Query {
|
|
|
380
444
|
export type ProvesComponent<
|
|
381
445
|
T extends Any,
|
|
382
446
|
D extends ComponentDescriptor
|
|
383
|
-
> = [Extract<
|
|
447
|
+
> = [Extract<Proven<T>, D>] extends [never]
|
|
384
448
|
? false
|
|
385
449
|
: true
|
|
386
450
|
|
|
387
|
-
/**
|
|
388
|
-
|
|
451
|
+
/**
|
|
452
|
+
* The component descriptors a query proves its matches have: required
|
|
453
|
+
* selection slots, `with`, and the component of each `added` / `changed`
|
|
454
|
+
* filter (a match has it). A `Query.anyOf` member proves nothing alone.
|
|
455
|
+
*/
|
|
456
|
+
export type Proven<T extends Any> = RequiredSelectionDescriptors<T> | T["with"][number] | FilteredDescriptors<T["filters"][number]>
|
|
457
|
+
|
|
458
|
+
/** The component descriptors a query proves its matches lack: its `without`. */
|
|
459
|
+
export type Excluded<T extends Any> = T["without"][number]
|
|
389
460
|
|
|
390
461
|
/**
|
|
391
462
|
* The readable proof produced by a query.
|
|
@@ -498,13 +569,14 @@ export interface ReadCell<T> {
|
|
|
498
569
|
*/
|
|
499
570
|
export interface WriteCell<T> extends ReadCell<T> {
|
|
500
571
|
/**
|
|
501
|
-
* Replaces the current value.
|
|
572
|
+
* Replaces the current value. Takes the readonly shape reads return, so a
|
|
573
|
+
* value read from any cell can be written back as it is.
|
|
502
574
|
*/
|
|
503
|
-
set(value: T): void
|
|
575
|
+
set(value: ReadonlyValue<T>): void
|
|
504
576
|
/**
|
|
505
577
|
* Updates the current value based on the previous one.
|
|
506
578
|
*/
|
|
507
|
-
update(f: (current: ReadonlyValue<T>) => T): void
|
|
579
|
+
update(f: (current: ReadonlyValue<T>) => ReadonlyValue<T>): void
|
|
508
580
|
}
|
|
509
581
|
|
|
510
582
|
/**
|
|
@@ -562,7 +634,10 @@ export interface EntityStateMismatchError<T> {
|
|
|
562
634
|
*/
|
|
563
635
|
export interface EntityStateWriteCell<T, Transitions> extends ConstructedWriteCell<T, T, DecodeError> {
|
|
564
636
|
/**
|
|
565
|
-
* Moves from `from` to `to` if the component is still in `from`.
|
|
637
|
+
* Moves from `from` to `to` if the component is still in `from`. Use it for
|
|
638
|
+
* every move the transition table describes; `set` writes any state
|
|
639
|
+
* without checking the table, for resets that must not depend on the
|
|
640
|
+
* current state.
|
|
566
641
|
*
|
|
567
642
|
* @example
|
|
568
643
|
* ```ts
|
|
@@ -596,6 +671,8 @@ export type WriteCellForDescriptor<D extends ComponentDescriptor> =
|
|
|
596
671
|
*/
|
|
597
672
|
export interface PresentOptionalReadCell<T> extends ReadCell<T> {
|
|
598
673
|
readonly present: true
|
|
674
|
+
/** The value, which is present. */
|
|
675
|
+
value(): T
|
|
599
676
|
}
|
|
600
677
|
|
|
601
678
|
/**
|
|
@@ -603,12 +680,17 @@ export interface PresentOptionalReadCell<T> extends ReadCell<T> {
|
|
|
603
680
|
*/
|
|
604
681
|
export interface AbsentOptionalReadCell {
|
|
605
682
|
readonly present: false
|
|
683
|
+
/** `undefined`: the entity does not hold the component. */
|
|
684
|
+
value(): undefined
|
|
606
685
|
}
|
|
607
686
|
|
|
608
687
|
/**
|
|
609
688
|
* A query slot that may or may not be present on the matched entity.
|
|
610
689
|
*
|
|
611
|
-
*
|
|
690
|
+
* Read it with `value()`: the value, or `undefined` when the entity does not
|
|
691
|
+
* hold the component. Narrow on `present` (then `get()`) only when the
|
|
692
|
+
* component's own value can be `undefined`, where `value()` cannot tell the
|
|
693
|
+
* two apart.
|
|
612
694
|
*/
|
|
613
695
|
export type OptionalReadCell<T> = PresentOptionalReadCell<T> | AbsentOptionalReadCell
|
|
614
696
|
|
|
@@ -621,11 +703,11 @@ export type OptionalReadCell<T> = PresentOptionalReadCell<T> | AbsentOptionalRea
|
|
|
621
703
|
export type QueryMatch<S extends Schema.Any, Q extends Query.Any> =
|
|
622
704
|
keyof Query.WriteProof<Q> extends never
|
|
623
705
|
? {
|
|
624
|
-
readonly entity: EntityRef<S, Query.ReadProof<Q>, Query.Root<Q>, Query.Proven<Q>>
|
|
706
|
+
readonly entity: EntityRef<S, Query.ReadProof<Q>, Query.Root<Q>, Query.Proven<Q>, Query.Excluded<Q>>
|
|
625
707
|
readonly data: Query.Cells<Q>
|
|
626
708
|
}
|
|
627
709
|
: {
|
|
628
|
-
readonly entity: EntityMut<S, Query.ReadProof<Q>, Query.WriteProof<Q>, Query.Root<Q>, Query.Proven<Q>>
|
|
710
|
+
readonly entity: EntityMut<S, Query.ReadProof<Q>, Query.WriteProof<Q>, Query.Root<Q>, Query.Proven<Q>, Query.Excluded<Q>>
|
|
629
711
|
readonly data: Query.Cells<Q>
|
|
630
712
|
}
|
|
631
713
|
|
|
@@ -692,6 +774,25 @@ export const multipleEntitiesError = (count: number): Query.MultipleEntitiesErro
|
|
|
692
774
|
// `T` appears only as the check type, which inference never reads.
|
|
693
775
|
export type Uninferred<T, Bound> = [T] extends [infer U extends Bound] ? U : never
|
|
694
776
|
|
|
777
|
+
type DescriptorName<D> = D extends { readonly name: infer Name extends string } ? Name : never
|
|
778
|
+
|
|
779
|
+
/** The names a query requires: its read and write slots, `with`, and plain `added` / `changed` filters. */
|
|
780
|
+
type RequiredNames<Selection, With extends ReadonlyArray<unknown>, Filters extends ReadonlyArray<unknown>> =
|
|
781
|
+
| DescriptorName<{ readonly [K in keyof Selection]: Selection[K] extends { readonly mode: "read" | "write"; readonly descriptor: infer D } ? D : never }[keyof Selection]>
|
|
782
|
+
| DescriptorName<With[number]>
|
|
783
|
+
| DescriptorName<Filters[number] extends infer F ? F extends { readonly kind: "added" | "changed"; readonly descriptor: infer D } ? D : never : never>
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Rejects a `without` naming a component the query also requires: such a
|
|
787
|
+
* query can never match. Use it on the `without` list of a query spec.
|
|
788
|
+
*/
|
|
789
|
+
export type ExclusionsPossible<Selection, With extends ReadonlyArray<unknown>, Without extends ReadonlyArray<unknown>, Filters extends ReadonlyArray<unknown>> =
|
|
790
|
+
[Extract<DescriptorName<Without[number]>, RequiredNames<Selection, With, Filters>>] extends [never]
|
|
791
|
+
? unknown
|
|
792
|
+
: {
|
|
793
|
+
readonly __fixQuery__: `${Extract<DescriptorName<Without[number]>, RequiredNames<Selection, With, Filters>>} is both required and excluded (without): this query can never match; remove it from one side`
|
|
794
|
+
}
|
|
795
|
+
|
|
695
796
|
export const Query = <
|
|
696
797
|
const Selection extends Record<string, SelectionAccess<any, Root>>,
|
|
697
798
|
const With extends ReadonlyArray<ComponentDescriptor> = [],
|
|
@@ -705,7 +806,7 @@ export const Query = <
|
|
|
705
806
|
>(spec: {
|
|
706
807
|
readonly selection: Selection
|
|
707
808
|
readonly with?: With
|
|
708
|
-
readonly without?: Without
|
|
809
|
+
readonly without?: Without & ExclusionsPossible<Selection, With, Without, Filters>
|
|
709
810
|
readonly filters?: Filters
|
|
710
811
|
readonly withRelations?: WithRelations
|
|
711
812
|
readonly withoutRelations?: WithoutRelations
|