@typeonce/ritmo-browser 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.
package/src/FixedLoop.ts DELETED
@@ -1,236 +0,0 @@
1
- /**
2
- * Deterministic fixed-step timing for browser and renderer hosts.
3
- *
4
- * The loop owns the timing policy that is easy to get subtly wrong in every
5
- * game: frame clamping, catch-up limits, interpolation, cancellation, and
6
- * typed update failures. It does not know about an ECS runtime or renderer.
7
- *
8
- * @module FixedLoop
9
- * @docGroup browser
10
- */
11
- import type * as Clock from "@typeonce/ritmo/Clock"
12
- import * as Result from "@typeonce/ritmo/Result"
13
-
14
- /** One renderer or browser tick measured in milliseconds. */
15
- export interface Tick {
16
- readonly deltaMS: number
17
- }
18
-
19
- /** A stoppable host subscription. Calling `stop` more than once is safe. */
20
- export interface Subscription {
21
- stop(): void
22
- }
23
-
24
- /** Minimal adapter implemented by Pixi tickers, RAF, and deterministic tests. */
25
- export interface TickSource<TTick extends Tick = Tick> {
26
- subscribe(onTick: (tick: TTick) => void): Subscription
27
- }
28
-
29
- /** Validated fixed-step timing policy. */
30
- export interface Options {
31
- /**
32
- * The fixed step every update advances (`Clock.hz(60)`): the same value
33
- * the game's clock resource runs at, so the loop and the clock agree.
34
- */
35
- readonly step: Clock.Step
36
- /** Maximum host-frame duration admitted into the accumulator. */
37
- readonly maxFrameSeconds: number
38
- /** Maximum number of simulation updates performed for one host frame. */
39
- readonly maxStepsPerFrame: number
40
- }
41
-
42
- /** Configuration error returned before a loop subscribes to its host. */
43
- export interface InvalidOptions {
44
- readonly kind: "InvalidFixedLoopOptions"
45
- readonly field: keyof Options
46
- readonly reason: "mustBeFiniteAndPositive" | "mustBePositiveInteger"
47
- readonly value: number
48
- }
49
-
50
- /** Data passed to the renderer after all fixed updates for one host frame. */
51
- export interface Frame {
52
- /** Clamped time admitted for this host frame. */
53
- readonly elapsedSeconds: number
54
- /** Number of fixed updates completed for this host frame. */
55
- readonly fixedSteps: number
56
- /** Remaining accumulator fraction in the range `[0, 1)`. */
57
- readonly alpha: number
58
- /** Time discarded by frame clamping or the catch-up limit. */
59
- readonly droppedSeconds: number
60
- }
61
-
62
- /** Failure delivered by an active loop before it stops itself. */
63
- export type Failure<UpdateError> =
64
- | {
65
- readonly kind: "InvalidTickDelta"
66
- readonly deltaMS: number
67
- }
68
- | {
69
- readonly kind: "UpdateFailure"
70
- readonly error: UpdateError
71
- }
72
-
73
- /** A running fixed-step driver. */
74
- export interface Driver extends Subscription {
75
- /** Clears accumulated partial time without removing the host subscription. */
76
- reset(): void
77
- }
78
-
79
- /** Input for a fixed-step driver. */
80
- export interface StartOptions<TTick extends Tick, UpdateError> extends Options {
81
- readonly source: TickSource<TTick>
82
- readonly update: (stepSeconds: number) => Result.Result<void, UpdateError>
83
- readonly render: (frame: Frame) => void
84
- readonly onFailure: (failure: Failure<UpdateError>) => void
85
- }
86
-
87
- /** Wraps a host-specific subscription function in the canonical source type. */
88
- export const source = <TTick extends Tick>(
89
- subscribe: TickSource<TTick>["subscribe"]
90
- ): TickSource<TTick> => ({ subscribe })
91
-
92
- const validate = (options: Options): Result.Result<void, InvalidOptions> => {
93
- for (const field of ["maxFrameSeconds"] as const) {
94
- const value = options[field]
95
- if (!Number.isFinite(value) || value <= 0) {
96
- return Result.failure({
97
- kind: "InvalidFixedLoopOptions",
98
- field,
99
- reason: "mustBeFiniteAndPositive",
100
- value
101
- })
102
- }
103
- }
104
- if (!Number.isInteger(options.maxStepsPerFrame) || options.maxStepsPerFrame <= 0) {
105
- return Result.failure({
106
- kind: "InvalidFixedLoopOptions",
107
- field: "maxStepsPerFrame",
108
- reason: "mustBePositiveInteger",
109
- value: options.maxStepsPerFrame
110
- })
111
- }
112
- return Result.success(undefined)
113
- }
114
-
115
- /**
116
- * Starts a fixed-step loop after validating its timing policy.
117
- *
118
- * Update failures and malformed host ticks are delivered to `onFailure`, then
119
- * the driver stops. Render defects are deliberately not caught: they are
120
- * programmer defects, not expected game outcomes.
121
- */
122
- export const start = <TTick extends Tick, UpdateError>(
123
- options: StartOptions<TTick, UpdateError>
124
- ): Result.Result<Driver, InvalidOptions> => {
125
- const validity = validate(options)
126
- if (!validity.ok) {
127
- return validity
128
- }
129
-
130
- let accumulator = 0
131
- let stopped = false
132
- let hostSubscription: Subscription | undefined
133
-
134
- const stop = (): void => {
135
- if (stopped) {
136
- return
137
- }
138
- stopped = true
139
- hostSubscription?.stop()
140
- }
141
-
142
- const fail = (failure: Failure<UpdateError>): void => {
143
- stop()
144
- options.onFailure(failure)
145
- }
146
-
147
- const onTick = (tick: TTick): void => {
148
- if (stopped) {
149
- return
150
- }
151
- if (!Number.isFinite(tick.deltaMS) || tick.deltaMS < 0) {
152
- fail({ kind: "InvalidTickDelta", deltaMS: tick.deltaMS })
153
- return
154
- }
155
-
156
- const rawSeconds = tick.deltaMS / 1000
157
- const elapsedSeconds = Math.min(rawSeconds, options.maxFrameSeconds)
158
- let droppedSeconds = rawSeconds - elapsedSeconds
159
- accumulator += elapsedSeconds
160
-
161
- let fixedSteps = 0
162
- while (accumulator >= options.step.dt && fixedSteps < options.maxStepsPerFrame) {
163
- const update = options.update(options.step.dt)
164
- if (!update.ok) {
165
- fail({ kind: "UpdateFailure", error: update.error })
166
- return
167
- }
168
- accumulator -= options.step.dt
169
- fixedSteps += 1
170
- }
171
-
172
- if (accumulator >= options.step.dt) {
173
- const droppedWholeSteps = Math.floor(accumulator / options.step.dt) * options.step.dt
174
- accumulator -= droppedWholeSteps
175
- droppedSeconds += droppedWholeSteps
176
- }
177
-
178
- options.render({
179
- elapsedSeconds,
180
- fixedSteps,
181
- alpha: accumulator / options.step.dt,
182
- droppedSeconds
183
- })
184
- }
185
-
186
- hostSubscription = options.source.subscribe(onTick)
187
- if (stopped) {
188
- hostSubscription.stop()
189
- }
190
-
191
- return Result.success({
192
- stop,
193
- reset() {
194
- accumulator = 0
195
- }
196
- })
197
- }
198
-
199
- /** Host surface needed by the built-in requestAnimationFrame adapter. */
200
- export interface AnimationFrameHost {
201
- requestAnimationFrame(callback: (timestamp: number) => void): number
202
- cancelAnimationFrame(handle: number): void
203
- }
204
-
205
- /**
206
- * Adapts requestAnimationFrame to a `TickSource` without reading global state.
207
- * The first frame reports zero elapsed time because it establishes the clock.
208
- */
209
- export const animationFrames = (host: AnimationFrameHost): TickSource => source((onTick) => {
210
- let stopped = false
211
- let handle = 0
212
- let previous: number | undefined
213
-
214
- const frame = (timestamp: number): void => {
215
- if (stopped) {
216
- return
217
- }
218
- const deltaMS = previous === undefined ? 0 : timestamp - previous
219
- previous = timestamp
220
- onTick({ deltaMS })
221
- if (!stopped) {
222
- handle = host.requestAnimationFrame(frame)
223
- }
224
- }
225
-
226
- handle = host.requestAnimationFrame(frame)
227
- return {
228
- stop() {
229
- if (stopped) {
230
- return
231
- }
232
- stopped = true
233
- host.cancelAnimationFrame(handle)
234
- }
235
- }
236
- })