ohzi-core 13.2.2 → 14.0.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.
@@ -0,0 +1,287 @@
1
+ const DEFAULT_HOLD_MS = 60;
2
+ const DEFAULT_DRAG_STEPS = 8;
3
+ const DEFAULT_DRAG_DURATION_MS = 240;
4
+ const MAX_DRAG_STEPS = 120;
5
+ // Bounded so a caller cannot request a gesture that outlives the command timeout.
6
+ const MAX_DURATION_MS = 10000;
7
+ const MAX_HOLD_MS = 10000;
8
+
9
+ const BUTTONS: Record<string, number> = { left: 0, middle: 1, right: 2 };
10
+
11
+ export type DispatchEvent = (type: string, init: Record<string, unknown>) => void;
12
+ export type Sleep = (ms: number) => Promise<void>;
13
+ export type ToClient = (x: number, y: number, space: string) => { x: number; y: number };
14
+
15
+ export interface PointerRequest
16
+ {
17
+ action?: unknown;
18
+ x?: unknown;
19
+ y?: unknown;
20
+ button?: unknown;
21
+ space?: unknown;
22
+ hold_ms?: unknown;
23
+ }
24
+
25
+ export interface DragRequest
26
+ {
27
+ from?: unknown;
28
+ to?: unknown;
29
+ steps?: unknown;
30
+ duration_ms?: unknown;
31
+ button?: unknown;
32
+ space?: unknown;
33
+ }
34
+
35
+ export interface ScrollRequest
36
+ {
37
+ delta?: unknown;
38
+ x?: unknown;
39
+ y?: unknown;
40
+ space?: unknown;
41
+ }
42
+
43
+ export interface KeyRequest
44
+ {
45
+ code?: unknown;
46
+ action?: unknown;
47
+ hold_ms?: unknown;
48
+ }
49
+
50
+ export interface InputResult
51
+ {
52
+ dispatched: string[];
53
+ at?: number[];
54
+ from?: number[];
55
+ to?: number[];
56
+ code?: string;
57
+ delta?: number;
58
+ }
59
+
60
+ // Drives real DOM events at the element PIT already listens on, rather than
61
+ // writing into PIT's state. That way the whole genuine path runs: the region
62
+ // maths, Pointer bookkeeping, and any Input subclass deriving clicked or
63
+ // swiped from the raw flags. A parallel fake input module would drift from it.
64
+ //
65
+ // Timing matters. PIT clears pressed and released at the end of every frame, so
66
+ // a press and its release must be separated by real time for the application's
67
+ // update to observe them. Hence the sleeps rather than back-to-back dispatch.
68
+ class InputSynthesizer
69
+ {
70
+ private dispatch: DispatchEvent;
71
+ private sleep: Sleep;
72
+ private to_client: ToClient;
73
+
74
+ constructor(dispatch: DispatchEvent, sleep: Sleep, to_client: ToClient)
75
+ {
76
+ this.dispatch = dispatch;
77
+ this.sleep = sleep;
78
+ this.to_client = to_client;
79
+ }
80
+
81
+ async pointer(request: PointerRequest): Promise<InputResult>
82
+ {
83
+ const action = this.text(request.action, 'click');
84
+ const space = this.space(request.space);
85
+ const point = this.point(request.x, request.y, space);
86
+ const button = this.button(request.button);
87
+ const hold = Math.min(MAX_HOLD_MS, this.positive(request.hold_ms, DEFAULT_HOLD_MS));
88
+ const dispatched: string[] = [];
89
+
90
+ if (action === 'move' || action === 'down' || action === 'click')
91
+ {
92
+ this.send('mousemove', { clientX: point.x, clientY: point.y });
93
+ dispatched.push('mousemove');
94
+ }
95
+
96
+ if (action === 'down' || action === 'click')
97
+ {
98
+ this.send('mousedown', { clientX: point.x, clientY: point.y, button });
99
+ dispatched.push('mousedown');
100
+ }
101
+
102
+ if (action === 'click')
103
+ {
104
+ await this.sleep(hold);
105
+ }
106
+
107
+ if (action === 'up' || action === 'click')
108
+ {
109
+ this.send('mouseup', { clientX: point.x, clientY: point.y, button });
110
+ dispatched.push('mouseup');
111
+ }
112
+
113
+ if (dispatched.length === 0)
114
+ {
115
+ throw this.error('bad_request', `Unknown pointer action '${action}'. Use click, down, up or move.`);
116
+ }
117
+
118
+ return { dispatched, at: [point.x, point.y] };
119
+ }
120
+
121
+ async drag(request: DragRequest): Promise<InputResult>
122
+ {
123
+ const space = this.space(request.space);
124
+ const from = this.pair(request.from, space, 'from');
125
+ const to = this.pair(request.to, space, 'to');
126
+ const steps = Math.min(MAX_DRAG_STEPS, Math.max(1, Math.floor(this.positive(request.steps, DEFAULT_DRAG_STEPS))));
127
+ const duration = Math.min(MAX_DURATION_MS, this.positive(request.duration_ms, DEFAULT_DRAG_DURATION_MS));
128
+ const button = this.button(request.button);
129
+ const dispatched: string[] = [];
130
+
131
+ this.send('mousemove', { clientX: from.x, clientY: from.y });
132
+ dispatched.push('mousemove');
133
+
134
+ this.send('mousedown', { clientX: from.x, clientY: from.y, button });
135
+ dispatched.push('mousedown');
136
+
137
+ for (let step = 1; step <= steps; step++)
138
+ {
139
+ const t = step / steps;
140
+
141
+ await this.sleep(duration / steps);
142
+
143
+ this.send('mousemove', {
144
+ clientX: from.x + (to.x - from.x) * t,
145
+ clientY: from.y + (to.y - from.y) * t
146
+ });
147
+
148
+ dispatched.push('mousemove');
149
+ }
150
+
151
+ this.send('mouseup', { clientX: to.x, clientY: to.y, button });
152
+ dispatched.push('mouseup');
153
+
154
+ return { dispatched, from: [from.x, from.y], to: [to.x, to.y] };
155
+ }
156
+
157
+ // Not async: a wheel event is a single dispatch with nothing to wait on.
158
+ // The Promise return keeps the four methods interchangeable for the caller.
159
+ scroll(request: ScrollRequest): Promise<InputResult>
160
+ {
161
+ const delta = this.number(request.delta);
162
+
163
+ if (delta === null)
164
+ {
165
+ throw this.error('bad_request', 'scroll requires a numeric delta.');
166
+ }
167
+
168
+ const space = this.space(request.space);
169
+ const point = this.point(request.x === undefined ? 0 : request.x, request.y === undefined ? 0 : request.y, space);
170
+
171
+ this.send('wheel', { clientX: point.x, clientY: point.y, deltaY: delta });
172
+
173
+ return Promise.resolve({ dispatched: ['wheel'], at: [point.x, point.y], delta });
174
+ }
175
+
176
+ async key(request: KeyRequest): Promise<InputResult>
177
+ {
178
+ const code = typeof request.code === 'string' && request.code.length > 0 ? request.code : null;
179
+
180
+ if (code === null)
181
+ {
182
+ throw this.error('bad_request', "key requires a code, for example 'Space' or 'KeyW'.");
183
+ }
184
+
185
+ const action = this.text(request.action, 'press');
186
+ const hold = Math.min(MAX_HOLD_MS, this.positive(request.hold_ms, DEFAULT_HOLD_MS));
187
+ const dispatched: string[] = [];
188
+
189
+ if (action === 'down' || action === 'press')
190
+ {
191
+ this.send('keydown', { code, key: code });
192
+ dispatched.push('keydown');
193
+ }
194
+
195
+ if (action === 'press')
196
+ {
197
+ await this.sleep(hold);
198
+ }
199
+
200
+ if (action === 'up' || action === 'press')
201
+ {
202
+ this.send('keyup', { code, key: code });
203
+ dispatched.push('keyup');
204
+ }
205
+
206
+ if (dispatched.length === 0)
207
+ {
208
+ throw this.error('bad_request', `Unknown key action '${action}'. Use press, down or up.`);
209
+ }
210
+
211
+ return { dispatched, code };
212
+ }
213
+
214
+ private send(type: string, init: Record<string, unknown>): void
215
+ {
216
+ this.dispatch(type, { ...init, bubbles: true, cancelable: true });
217
+ }
218
+
219
+ private point(x: unknown, y: unknown, space: string): { x: number; y: number }
220
+ {
221
+ const px = this.number(x);
222
+ const py = this.number(y);
223
+
224
+ if (px === null || py === null)
225
+ {
226
+ throw this.error('bad_request', 'x and y must be numbers.');
227
+ }
228
+
229
+ return this.to_client(px, py, space);
230
+ }
231
+
232
+ private pair(value: unknown, space: string, label: string): { x: number; y: number }
233
+ {
234
+ if (!Array.isArray(value) || value.length < 2)
235
+ {
236
+ throw this.error('bad_request', `${label} must be [x, y].`);
237
+ }
238
+
239
+ const supplied = value as unknown[];
240
+
241
+ return this.point(supplied[0], supplied[1], space);
242
+ }
243
+
244
+ private space(value: unknown): string
245
+ {
246
+ return value === 'ndc' ? 'ndc' : 'pixels';
247
+ }
248
+
249
+ private button(value: unknown): number
250
+ {
251
+ if (typeof value !== 'string')
252
+ {
253
+ return 0;
254
+ }
255
+
256
+ const mapped = BUTTONS[value.toLowerCase()];
257
+
258
+ return mapped === undefined ? 0 : mapped;
259
+ }
260
+
261
+ private text(value: unknown, fallback: string): string
262
+ {
263
+ return typeof value === 'string' && value.length > 0 ? value.toLowerCase() : fallback;
264
+ }
265
+
266
+ private number(value: unknown): number | null
267
+ {
268
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
269
+ }
270
+
271
+ private positive(value: unknown, fallback: number): number
272
+ {
273
+ const parsed = this.number(value);
274
+
275
+ return parsed === null || parsed <= 0 ? fallback : parsed;
276
+ }
277
+
278
+ private error(code: string, message: string): Error
279
+ {
280
+ const error: Error & { code?: string } = new Error(message);
281
+ error.code = code;
282
+
283
+ return error;
284
+ }
285
+ }
286
+
287
+ export { InputSynthesizer };
@@ -0,0 +1,118 @@
1
+ export interface TimeLike
2
+ {
3
+ delta_time: number;
4
+ smooth_delta_time: number;
5
+ elapsed_time: number;
6
+ }
7
+
8
+ export interface ScreenLike
9
+ {
10
+ width: number;
11
+ height: number;
12
+ render_width: number;
13
+ render_height: number;
14
+ dpr: number;
15
+ }
16
+
17
+ export interface PerformanceReport
18
+ {
19
+ fps: number;
20
+ smooth_fps: number;
21
+ frame_ms: number;
22
+ elapsed_s: number;
23
+ canvas: ScreenLike;
24
+ draw_calls?: number;
25
+ triangles?: number;
26
+ geometries?: number;
27
+ textures?: number;
28
+ }
29
+
30
+ // Reads frame timing and, when the renderer exposes them, its counters.
31
+ // Counters are omitted rather than zeroed when unavailable, because a reported
32
+ // zero draw calls is a claim, while an absent field is an admission.
33
+ class PerformanceProbe
34
+ {
35
+ read(time: TimeLike, screen: ScreenLike, info: unknown): PerformanceReport
36
+ {
37
+ const report: PerformanceReport = {
38
+ fps: this.rate(time.delta_time),
39
+ smooth_fps: this.rate(time.smooth_delta_time),
40
+ frame_ms: this.round(this.seconds(time.delta_time) * 1000, 100),
41
+ elapsed_s: this.round(this.seconds(time.elapsed_time), 10),
42
+ canvas: {
43
+ width: screen.width,
44
+ height: screen.height,
45
+ render_width: screen.render_width,
46
+ render_height: screen.render_height,
47
+ dpr: screen.dpr
48
+ }
49
+ };
50
+
51
+ const render = this.section(info, 'render');
52
+ const memory = this.section(info, 'memory');
53
+
54
+ this.assign(report, 'draw_calls', render, 'drawCalls');
55
+ this.assign(report, 'triangles', render, 'triangles');
56
+ this.assign(report, 'geometries', memory, 'geometries');
57
+ this.assign(report, 'textures', memory, 'textures');
58
+
59
+ return report;
60
+ }
61
+
62
+ private section(info: unknown, key: string): Record<string, unknown> | null
63
+ {
64
+ if (typeof info !== 'object' || info === null)
65
+ {
66
+ return null;
67
+ }
68
+
69
+ const section = (info as Record<string, unknown>)[key];
70
+
71
+ if (typeof section !== 'object' || section === null)
72
+ {
73
+ return null;
74
+ }
75
+
76
+ return section as Record<string, unknown>;
77
+ }
78
+
79
+ private assign(report: PerformanceReport, field: keyof PerformanceReport, source: Record<string, unknown> | null, key: string): void
80
+ {
81
+ if (source === null)
82
+ {
83
+ return;
84
+ }
85
+
86
+ const value = source[key];
87
+
88
+ if (typeof value === 'number' && Number.isFinite(value))
89
+ {
90
+ (report[field] as unknown) = value;
91
+ }
92
+ }
93
+
94
+ // A zero delta on the first frame would otherwise report Infinity fps.
95
+ private rate(delta: unknown): number
96
+ {
97
+ const seconds = this.seconds(delta);
98
+
99
+ if (seconds <= 0)
100
+ {
101
+ return 0;
102
+ }
103
+
104
+ return this.round(1 / seconds, 10);
105
+ }
106
+
107
+ private seconds(value: unknown): number
108
+ {
109
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0;
110
+ }
111
+
112
+ private round(value: number, factor: number): number
113
+ {
114
+ return Math.round(value * factor) / factor;
115
+ }
116
+ }
117
+
118
+ export { PerformanceProbe };
@@ -0,0 +1,66 @@
1
+ export type RenderModeFactory = (options: Record<string, unknown>) => unknown;
2
+
3
+ export interface RenderModeDescriptor
4
+ {
5
+ name: string;
6
+ description?: string;
7
+ options?: string[];
8
+ factory: RenderModeFactory;
9
+ }
10
+
11
+ export interface RenderModeSummary
12
+ {
13
+ name: string;
14
+ description: string;
15
+ options: string[];
16
+ }
17
+
18
+ // Render modes cannot be constructed from a string, and they do not share a
19
+ // constructor signature: UnrealBloomRender takes three required booleans while
20
+ // NormalRender takes none. The consumer therefore registers a factory per mode
21
+ // carrying that mode's sensible defaults.
22
+ class RenderModeRegistry
23
+ {
24
+ private descriptors: RenderModeDescriptor[];
25
+
26
+ constructor(descriptors: RenderModeDescriptor[])
27
+ {
28
+ this.descriptors = descriptors;
29
+ }
30
+
31
+ list(): RenderModeSummary[]
32
+ {
33
+ return this.descriptors.map((descriptor) => ({
34
+ name: descriptor.name,
35
+ description: descriptor.description === undefined ? '' : descriptor.description,
36
+ options: descriptor.options === undefined ? [] : descriptor.options
37
+ }));
38
+ }
39
+
40
+ create(name: unknown, options: Record<string, unknown>): unknown
41
+ {
42
+ const wanted = typeof name === 'string' ? name.toLowerCase() : null;
43
+ const descriptor = wanted === null
44
+ ? undefined
45
+ : this.descriptors.find((entry) => entry.name.toLowerCase() === wanted);
46
+
47
+ if (descriptor === undefined)
48
+ {
49
+ const available = this.descriptors.map((entry) => entry.name).join(', ');
50
+
51
+ throw this.error('unknown_render_mode', `Unknown render mode. Available modes: ${available}.`);
52
+ }
53
+
54
+ return descriptor.factory(options);
55
+ }
56
+
57
+ private error(code: string, message: string): Error
58
+ {
59
+ const error: Error & { code?: string } = new Error(message);
60
+ error.code = code;
61
+
62
+ return error;
63
+ }
64
+ }
65
+
66
+ export { RenderModeRegistry };