@galfus/engine 0.0.0-stage → 0.31.1-alpha

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 (80) hide show
  1. package/README.md +190 -2
  2. package/package.json +59 -4
  3. package/src/anchors/cache.ts +101 -0
  4. package/src/anchors/component.ts +123 -0
  5. package/src/anchors/hierarchy.ts +192 -0
  6. package/src/anchors/index.ts +8 -0
  7. package/src/anchors/solver.ts +247 -0
  8. package/src/anchors/strategy.ts +142 -0
  9. package/src/animation/blending.ts +163 -0
  10. package/src/animation/clip.ts +132 -0
  11. package/src/animation/coordinator.ts +568 -0
  12. package/src/animation/index.ts +7 -0
  13. package/src/animation/native-targets.ts +37 -0
  14. package/src/animation/player.ts +287 -0
  15. package/src/audio/index.ts +4 -0
  16. package/src/audio/manager.ts +371 -0
  17. package/src/collision/components.ts +211 -0
  18. package/src/collision/index.ts +16 -0
  19. package/src/collision/query2d.ts +167 -0
  20. package/src/collision/query3d.ts +170 -0
  21. package/src/ecs/component.ts +130 -0
  22. package/src/ecs/entity.ts +75 -0
  23. package/src/ecs/index.ts +9 -0
  24. package/src/ecs/intent.ts +162 -0
  25. package/src/ecs/metadata.ts +44 -0
  26. package/src/ecs/system.ts +224 -0
  27. package/src/ecs/world.ts +291 -0
  28. package/src/index.ts +19 -0
  29. package/src/interaction/focus.ts +327 -0
  30. package/src/interaction/index.ts +9 -0
  31. package/src/interaction/listener.ts +58 -0
  32. package/src/interaction/snapshot2d.ts +210 -0
  33. package/src/interaction/snapshot3d.ts +214 -0
  34. package/src/interaction/system.ts +544 -0
  35. package/src/interaction/types.ts +27 -0
  36. package/src/math/common.ts +67 -0
  37. package/src/math/index.ts +17 -0
  38. package/src/math/mat2d.ts +320 -0
  39. package/src/math/mat4.ts +576 -0
  40. package/src/math/quat.ts +438 -0
  41. package/src/math/vec2.ts +161 -0
  42. package/src/math/vec3.ts +242 -0
  43. package/src/math/vec4.ts +171 -0
  44. package/src/multiverse/coordinator.ts +540 -0
  45. package/src/multiverse/index.ts +13 -0
  46. package/src/multiverse/presentation-graph.ts +306 -0
  47. package/src/multiverse/presentation-links.ts +134 -0
  48. package/src/multiverse/realm.ts +49 -0
  49. package/src/platform/index.ts +2 -0
  50. package/src/platform/platform.ts +149 -0
  51. package/src/platform/window.ts +181 -0
  52. package/src/realm2d/components.ts +254 -0
  53. package/src/realm2d/index.ts +12 -0
  54. package/src/realm2d/presets/index.ts +36 -0
  55. package/src/realm2d/realm.ts +522 -0
  56. package/src/realm2d/sync.ts +284 -0
  57. package/src/realm2d/utils/index.ts +32 -0
  58. package/src/realm3d/components.ts +341 -0
  59. package/src/realm3d/geometry.ts +224 -0
  60. package/src/realm3d/index.ts +12 -0
  61. package/src/realm3d/presets/index.ts +67 -0
  62. package/src/realm3d/realm.ts +588 -0
  63. package/src/realm3d/resource-ids.ts +62 -0
  64. package/src/realm3d/skinning.ts +128 -0
  65. package/src/realm3d/sync.ts +442 -0
  66. package/src/realm3d/utils/index.ts +26 -0
  67. package/src/resources/index.ts +4 -0
  68. package/src/resources/manager.ts +92 -0
  69. package/src/runtime.ts +588 -0
  70. package/src/spatial/dirty_registry.ts +217 -0
  71. package/src/spatial/flags.ts +14 -0
  72. package/src/spatial/index.ts +6 -0
  73. package/src/spatial/metrics.ts +124 -0
  74. package/src/text/editor.ts +224 -0
  75. package/src/text/encoding.ts +38 -0
  76. package/src/text/index.ts +13 -0
  77. package/src/text/input.ts +160 -0
  78. package/src/text/keyboard.ts +50 -0
  79. package/src/text/svg-decorations.ts +147 -0
  80. package/src/types.ts +85 -0
@@ -0,0 +1,306 @@
1
+ import type { MultiverseSchedule, TargetDesc } from '@galfus/transport-types';
2
+ import type { PresentationLinksSnapshot } from './presentation-links';
3
+
4
+ /** A confirmed surface that maps a consumer realm's texture slot to a source target. */
5
+ export interface PresentationSurface {
6
+ readonly realmId: number;
7
+ readonly slot: string;
8
+ readonly entityId: number;
9
+ }
10
+ export interface PresentationBinding {
11
+ readonly realmId: number;
12
+ readonly slot: string;
13
+ readonly sourceTargetId: number;
14
+ }
15
+ export interface PresentationConnection extends PresentationBinding {
16
+ readonly entityId: number;
17
+ }
18
+ export interface PresentedInvocation {
19
+ readonly invocationId: number;
20
+ readonly realmId: number;
21
+ readonly cameraSlot?: number;
22
+ readonly resolvedViewport: readonly [number, number, number, number];
23
+ readonly zIndex: number;
24
+ }
25
+ /** Immutable view of one dispatched schedule, rooted at an open window. */
26
+ export interface WindowPresentation {
27
+ readonly windowId: number;
28
+ readonly rootTargetIds: readonly number[];
29
+ readonly targetIds: readonly number[];
30
+ readonly invocationIds: readonly number[];
31
+ readonly realmIds: readonly number[];
32
+ readonly candidateBindings: readonly PresentationBinding[];
33
+ readonly connections: readonly PresentationConnection[];
34
+ /** Render dependency edges sampled from the previous frame to break cycles. */
35
+ readonly softCutEdges: readonly (readonly [number, number])[];
36
+ /** IDs are in render order, as resolved by the renderer. */
37
+ getInvocations(targetId: number): readonly number[];
38
+ getInvocation(invocationId: number): PresentedInvocation | undefined;
39
+ getCandidates(realmId: number): readonly PresentationBinding[];
40
+ }
41
+ export interface PresentationGraphView {
42
+ getWindow(windowId: number): WindowPresentation | undefined;
43
+ getWindowIds(): readonly number[];
44
+ }
45
+ interface Snapshot {
46
+ readonly signature: readonly (number | undefined)[];
47
+ readonly linksRevision: number;
48
+ readonly windowRevision: number;
49
+ readonly windows: Map<number, WindowPresentation>;
50
+ }
51
+ const EMPTY: readonly never[] = Object.freeze([]);
52
+ function sorted(values: Iterable<number>): readonly number[] {
53
+ return Object.freeze([...values].sort((a, b) => a - b));
54
+ }
55
+ function compareSlot(a: string, b: string): number {
56
+ return a < b ? -1 : a > b ? 1 : 0;
57
+ }
58
+
59
+ /** Publishes only render schedules whose dispatch completed successfully. */
60
+ export class PresentationGraph {
61
+ private readonly openWindows = new Set<number>();
62
+ private windowRevision = 0;
63
+ private published?: Snapshot;
64
+ private pending?: Snapshot;
65
+ private windowIds: readonly number[] = EMPTY;
66
+ readonly view: PresentationGraphView = Object.freeze({
67
+ getWindow: (id: number) =>
68
+ this.openWindows.has(id) ? this.published?.windows.get(id) : undefined,
69
+ getWindowIds: () => this.windowIds
70
+ });
71
+
72
+ windowOpened(windowId: number): void {
73
+ if (!this.openWindows.has(windowId)) {
74
+ this.openWindows.add(windowId);
75
+ this.windowRevision++;
76
+ }
77
+ }
78
+ windowClosed(windowId: number): void {
79
+ if (!this.openWindows.delete(windowId)) return;
80
+ this.windowRevision++;
81
+ this.published?.windows.delete(windowId);
82
+ this.pending?.windows.delete(windowId);
83
+ this.refreshWindowIds();
84
+ }
85
+ private refreshWindowIds(): void {
86
+ this.windowIds = sorted(this.published?.windows.keys() ?? []);
87
+ }
88
+
89
+ private matches(
90
+ snapshot: Snapshot,
91
+ schedule: MultiverseSchedule,
92
+ targets: ReadonlyMap<number, TargetDesc>,
93
+ linksRevision: number
94
+ ): boolean {
95
+ if (snapshot.linksRevision !== linksRevision || snapshot.windowRevision !== this.windowRevision)
96
+ return false;
97
+ const signature = snapshot.signature;
98
+ let i = 0;
99
+ const blocks = schedule.targetBlocks!;
100
+ if (signature[i++] !== blocks.length) return false;
101
+ for (const block of blocks) {
102
+ const target = targets.get(block.targetId);
103
+ const windowId = target?.kind.type === 'window' ? target.kind.windowId : undefined;
104
+ if (
105
+ signature[i++] !== block.targetId ||
106
+ signature[i++] !== windowId ||
107
+ signature[i++] !== block.invocations.length
108
+ )
109
+ return false;
110
+ for (const inv of block.invocations) {
111
+ if (
112
+ signature[i++] !== inv.invocationId ||
113
+ signature[i++] !== inv.realmId ||
114
+ signature[i++] !== inv.cameraSlot ||
115
+ signature[i++] !== inv.zIndex ||
116
+ signature[i++] !== inv.resolvedViewport.length
117
+ )
118
+ return false;
119
+ for (const value of inv.resolvedViewport) if (signature[i++] !== value) return false;
120
+ }
121
+ }
122
+ const cuts = schedule.softCutEdges;
123
+ if (signature[i++] !== (cuts?.length ?? 0)) return false;
124
+ if (cuts)
125
+ for (const edge of cuts) {
126
+ if (signature[i++] !== edge[0] || signature[i++] !== edge[1]) return false;
127
+ }
128
+ return i === signature.length;
129
+ }
130
+
131
+ stage(
132
+ schedule: MultiverseSchedule | undefined,
133
+ targets: ReadonlyMap<number, TargetDesc>,
134
+ links: PresentationLinksSnapshot
135
+ ): void {
136
+ this.pending = undefined;
137
+ if (!schedule || !Array.isArray(schedule.targetBlocks)) return;
138
+ if (this.published && this.matches(this.published, schedule, targets, links.revision)) {
139
+ this.pending = this.published;
140
+ return;
141
+ }
142
+ const roots: Array<[number, number]> = [];
143
+ const signature: (number | undefined)[] = [schedule.targetBlocks.length];
144
+ for (const block of schedule.targetBlocks) {
145
+ const target = targets.get(block.targetId);
146
+ signature.push(
147
+ block.targetId,
148
+ target?.kind.type === 'window' ? target.kind.windowId : undefined,
149
+ block.invocations.length
150
+ );
151
+ for (const inv of block.invocations)
152
+ signature.push(
153
+ inv.invocationId,
154
+ inv.realmId,
155
+ inv.cameraSlot,
156
+ inv.zIndex,
157
+ inv.resolvedViewport.length,
158
+ ...inv.resolvedViewport
159
+ );
160
+ if (target?.kind.type === 'window' && this.openWindows.has(target.kind.windowId))
161
+ roots.push([target.kind.windowId, block.targetId]);
162
+ }
163
+ const cuts = schedule.softCutEdges ?? [];
164
+ signature.push(cuts.length);
165
+ for (const [source, target] of cuts) signature.push(source, target);
166
+ const blocks = new Map(schedule.targetBlocks.map((block) => [block.targetId, block]));
167
+ const bindingsByRealm = new Map<number, PresentationBinding[]>();
168
+ for (const binding of links.bindings) {
169
+ let entries = bindingsByRealm.get(binding.realmId);
170
+ if (!entries) {
171
+ entries = [];
172
+ bindingsByRealm.set(binding.realmId, entries);
173
+ }
174
+ entries.push(binding);
175
+ }
176
+ const surfacesByRealm = new Map<number, Map<string, number[]>>();
177
+ for (const surface of links.surfaces) {
178
+ let slots = surfacesByRealm.get(surface.realmId);
179
+ if (!slots) {
180
+ slots = new Map();
181
+ surfacesByRealm.set(surface.realmId, slots);
182
+ }
183
+ let entities = slots.get(surface.slot);
184
+ if (!entities) {
185
+ entities = [];
186
+ slots.set(surface.slot, entities);
187
+ }
188
+ entities.push(surface.entityId);
189
+ }
190
+ const rootMap = new Map<number, number[]>();
191
+ for (const [windowId, targetId] of roots) {
192
+ let ids = rootMap.get(windowId);
193
+ if (!ids) {
194
+ ids = [];
195
+ rootMap.set(windowId, ids);
196
+ }
197
+ ids.push(targetId);
198
+ }
199
+ const windows = new Map<number, WindowPresentation>();
200
+ for (const [windowId, rootTargets] of rootMap) {
201
+ const visitedTargets = new Set<number>();
202
+ const realms = new Set<number>();
203
+ const invocations = new Map<number, PresentedInvocation>();
204
+ const byTarget = new Map<number, readonly number[]>();
205
+ const byRealm = new Map<number, readonly PresentationBinding[]>();
206
+ const candidates = new Set<PresentationBinding>();
207
+ const connections: PresentationConnection[] = [];
208
+ const pending = [...rootTargets];
209
+ while (pending.length) {
210
+ const targetId = pending.pop()!;
211
+ if (visitedTargets.has(targetId)) continue;
212
+ const block = blocks.get(targetId);
213
+ if (!block) continue;
214
+ visitedTargets.add(targetId);
215
+ const ids: number[] = [];
216
+ for (const inv of block.invocations) {
217
+ ids.push(inv.invocationId);
218
+ invocations.set(
219
+ inv.invocationId,
220
+ Object.freeze({
221
+ invocationId: inv.invocationId,
222
+ realmId: inv.realmId,
223
+ cameraSlot: inv.cameraSlot,
224
+ resolvedViewport: Object.freeze([...inv.resolvedViewport]) as readonly [
225
+ number,
226
+ number,
227
+ number,
228
+ number
229
+ ],
230
+ zIndex: inv.zIndex
231
+ })
232
+ );
233
+ if (realms.has(inv.realmId)) continue;
234
+ realms.add(inv.realmId);
235
+ const bindings = [...(bindingsByRealm.get(inv.realmId) ?? [])].sort((a, b) =>
236
+ compareSlot(a.slot, b.slot)
237
+ );
238
+ byRealm.set(inv.realmId, Object.freeze(bindings));
239
+ for (const binding of bindings) {
240
+ candidates.add(binding);
241
+ for (const entityId of surfacesByRealm.get(inv.realmId)?.get(binding.slot) ?? []) {
242
+ if (!blocks.has(binding.sourceTargetId)) continue;
243
+ connections.push(Object.freeze({ ...binding, entityId }));
244
+ pending.push(binding.sourceTargetId);
245
+ }
246
+ }
247
+ }
248
+ byTarget.set(targetId, Object.freeze(ids));
249
+ }
250
+ connections.sort(
251
+ (a, b) => a.realmId - b.realmId || compareSlot(a.slot, b.slot) || a.entityId - b.entityId
252
+ );
253
+ const candidateList = [...candidates].sort(
254
+ (a, b) => a.realmId - b.realmId || compareSlot(a.slot, b.slot)
255
+ );
256
+ const softCutEdges = Object.freeze(
257
+ cuts
258
+ .filter(([source, target]) => visitedTargets.has(source) && visitedTargets.has(target))
259
+ .map(([source, target]) => Object.freeze([source, target] as const))
260
+ );
261
+ windows.set(
262
+ windowId,
263
+ Object.freeze({
264
+ windowId,
265
+ rootTargetIds: sorted(rootTargets),
266
+ targetIds: sorted(visitedTargets),
267
+ invocationIds: sorted(invocations.keys()),
268
+ realmIds: sorted(realms),
269
+ candidateBindings: Object.freeze(candidateList),
270
+ connections: Object.freeze(connections),
271
+ softCutEdges,
272
+ getInvocations: (targetId: number) => byTarget.get(targetId) ?? EMPTY,
273
+ getInvocation: (id: number) => invocations.get(id),
274
+ getCandidates: (realmId: number) => byRealm.get(realmId) ?? EMPTY
275
+ })
276
+ );
277
+ }
278
+ this.pending = {
279
+ signature,
280
+ linksRevision: links.revision,
281
+ windowRevision: this.windowRevision,
282
+ windows
283
+ };
284
+ }
285
+ commit(): void {
286
+ if (this.published === this.pending) {
287
+ this.pending = undefined;
288
+ return;
289
+ }
290
+ this.published = this.pending;
291
+ this.pending = undefined;
292
+ this.refreshWindowIds();
293
+ }
294
+ discard(): void {
295
+ this.pending = undefined;
296
+ }
297
+ clear(): void {
298
+ this.published = undefined;
299
+ this.pending = undefined;
300
+ this.windowIds = EMPTY;
301
+ }
302
+ dispose(): void {
303
+ this.clear();
304
+ this.openWindows.clear();
305
+ }
306
+ }
@@ -0,0 +1,134 @@
1
+ import type { TargetTextureBinding } from '@galfus/transport-types';
2
+ import type { PresentationBinding, PresentationSurface } from './presentation-graph';
3
+
4
+ export interface PresentationLinksSnapshot {
5
+ readonly revision: number;
6
+ readonly bindings: readonly PresentationBinding[];
7
+ readonly surfaces: readonly PresentationSurface[];
8
+ }
9
+
10
+ /** Host-owned links that are not represented by the render schedule. */
11
+ export class PresentationLinks {
12
+ private readonly bindings = new Map<number, Map<string, PresentationBinding>>();
13
+ private readonly byTarget = new Map<number, Set<PresentationBinding>>();
14
+ private readonly surfaces = new Map<number, Map<string, Set<number>>>();
15
+ private revision = 0;
16
+ private cached?: PresentationLinksSnapshot;
17
+
18
+ getBindings(): readonly PresentationBinding[] {
19
+ return Array.from(this.bindings.values()).flatMap((slots) => [...slots.values()]);
20
+ }
21
+ getBindingsForTarget(targetId: number): readonly PresentationBinding[] {
22
+ return [...(this.byTarget.get(targetId) ?? [])];
23
+ }
24
+ getBindingsForRealm(realmId: number): readonly PresentationBinding[] {
25
+ return [...(this.bindings.get(realmId)?.values() ?? [])];
26
+ }
27
+ private changed(): void {
28
+ this.revision++;
29
+ this.cached = undefined;
30
+ }
31
+ bind(binding: TargetTextureBinding): void {
32
+ if (
33
+ this.bindings.get(binding.realmId)?.get(binding.slot)?.sourceTargetId ===
34
+ binding.sourceTargetId
35
+ )
36
+ return;
37
+ this.unbind(binding.realmId, binding.slot);
38
+ let slots = this.bindings.get(binding.realmId);
39
+ if (!slots) {
40
+ slots = new Map();
41
+ this.bindings.set(binding.realmId, slots);
42
+ }
43
+ const stored = Object.freeze({
44
+ realmId: binding.realmId,
45
+ slot: binding.slot,
46
+ sourceTargetId: binding.sourceTargetId
47
+ });
48
+ slots.set(binding.slot, stored);
49
+ let targetBindings = this.byTarget.get(binding.sourceTargetId);
50
+ if (!targetBindings) {
51
+ targetBindings = new Set();
52
+ this.byTarget.set(binding.sourceTargetId, targetBindings);
53
+ }
54
+ targetBindings.add(stored);
55
+ this.changed();
56
+ }
57
+ unbind(realmId: number, slot: string): void {
58
+ const slots = this.bindings.get(realmId);
59
+ const previous = slots?.get(slot);
60
+ if (!slots || !previous) return;
61
+ slots.delete(slot);
62
+ const targetBindings = this.byTarget.get(previous.sourceTargetId);
63
+ targetBindings?.delete(previous);
64
+ if (!targetBindings?.size) this.byTarget.delete(previous.sourceTargetId);
65
+ if (!slots.size) this.bindings.delete(realmId);
66
+ const surfaces = this.surfaces.get(realmId);
67
+ surfaces?.delete(slot);
68
+ if (!surfaces?.size) this.surfaces.delete(realmId);
69
+ this.changed();
70
+ }
71
+ /** Records a validated surface; callers remove it when its entity or material changes. */
72
+ connectSurface(surface: PresentationSurface): void {
73
+ if (!this.bindings.get(surface.realmId)?.has(surface.slot))
74
+ throw new Error('Surface requires a registered target texture binding.');
75
+ let slots = this.surfaces.get(surface.realmId);
76
+ if (!slots) {
77
+ slots = new Map();
78
+ this.surfaces.set(surface.realmId, slots);
79
+ }
80
+ let entities = slots.get(surface.slot);
81
+ if (!entities) {
82
+ entities = new Set();
83
+ slots.set(surface.slot, entities);
84
+ }
85
+ if (!entities.has(surface.entityId)) {
86
+ entities.add(surface.entityId);
87
+ this.changed();
88
+ }
89
+ }
90
+ disconnectSurface(surface: PresentationSurface): void {
91
+ const slots = this.surfaces.get(surface.realmId);
92
+ const entities = slots?.get(surface.slot);
93
+ if (!entities?.delete(surface.entityId)) return;
94
+ if (!entities.size) slots!.delete(surface.slot);
95
+ if (!slots!.size) this.surfaces.delete(surface.realmId);
96
+ this.changed();
97
+ }
98
+ /** Removes surface registrations for entities that are no longer alive. */
99
+ purgeDeadSurfaces(isAlive: (realmId: number, entityId: number) => boolean): void {
100
+ let purged = false;
101
+ for (const [realmId, slots] of this.surfaces) {
102
+ for (const [slot, entities] of slots) {
103
+ for (const entityId of [...entities]) {
104
+ if (!isAlive(realmId, entityId)) {
105
+ entities.delete(entityId);
106
+ purged = true;
107
+ }
108
+ }
109
+ if (!entities.size) slots.delete(slot);
110
+ }
111
+ if (!slots.size) this.surfaces.delete(realmId);
112
+ }
113
+ if (purged) this.changed();
114
+ }
115
+ snapshot(): PresentationLinksSnapshot {
116
+ if (this.cached) return this.cached;
117
+ const surfaces: PresentationSurface[] = [];
118
+ for (const [realmId, slots] of this.surfaces)
119
+ for (const [slot, entities] of slots)
120
+ for (const entityId of entities) surfaces.push(Object.freeze({ realmId, slot, entityId }));
121
+ this.cached = Object.freeze({
122
+ revision: this.revision,
123
+ bindings: Object.freeze(this.getBindings()),
124
+ surfaces: Object.freeze(surfaces)
125
+ });
126
+ return this.cached;
127
+ }
128
+ clear(): void {
129
+ this.bindings.clear();
130
+ this.byTarget.clear();
131
+ this.surfaces.clear();
132
+ this.changed();
133
+ }
134
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Realm connector contract for domain-specific realms (2D, 3D, Custom).
3
+ */
4
+
5
+ import type { GalfusEngine } from '@galfus/transport-types';
6
+ import type { SystemContext } from '../ecs/system';
7
+ import type { RealmId } from '../types';
8
+
9
+ export { type RealmId };
10
+ export type RealmKind = '2d' | '3d' | 'color' | 'custom';
11
+
12
+ /**
13
+ * Base contract implemented by all realm modules to synchronize their ECS state
14
+ * with the underlying Multiverse and GalfusEngine core.
15
+ */
16
+ export interface RealmConnector {
17
+ readonly realmId: RealmId | number;
18
+ readonly realmKind: RealmKind;
19
+
20
+ /**
21
+ * Binds this realm to the active GalfusEngine ABI driver and initializes native realm resources.
22
+ */
23
+ bindEngine(engine: GalfusEngine): void;
24
+
25
+ /**
26
+ * Runs pre-render systems: input, update, preRender (anchors, hierarchies).
27
+ */
28
+ preRender?(context: SystemContext): void;
29
+
30
+ /**
31
+ * Synchronizes ECS entity data (transforms, components, lights, cameras) to the core.
32
+ */
33
+ sync(context: SystemContext): void;
34
+
35
+ /**
36
+ * Runs post-render systems (metrics, cleanup after GPU dispatch).
37
+ */
38
+ postRender?(context: SystemContext): void;
39
+
40
+ /**
41
+ * Resets this realm: clears ECS and local resources and reinitializes native core realm state.
42
+ */
43
+ reset?(): void;
44
+
45
+ /**
46
+ * Shuts down and cleans up native resources allocated for this realm in the core.
47
+ */
48
+ dispose(): void;
49
+ }
@@ -0,0 +1,2 @@
1
+ export * from './platform';
2
+ export * from './window';
@@ -0,0 +1,149 @@
1
+ import type {
2
+ GalfusEngine,
3
+ GamepadEvent,
4
+ KeyboardEvent,
5
+ PlatformCapabilities,
6
+ PlatformEvent,
7
+ PointerEvent,
8
+ WindowEvent
9
+ } from '@galfus/transport-types';
10
+ import { WindowManager } from './window';
11
+
12
+ export interface PlatformEventMap {
13
+ window: WindowEvent;
14
+ pointer: PointerEvent;
15
+ keyboard: KeyboardEvent;
16
+ gamepad: GamepadEvent;
17
+ platform: PlatformEvent;
18
+ }
19
+
20
+ export type PlatformEventType = keyof PlatformEventMap;
21
+
22
+ export class PlatformCoordinator {
23
+ readonly window = new WindowManager();
24
+ private readonly _eventListeners = new Map<string, Set<(ev: any) => void>>();
25
+ private _engine: GalfusEngine | null = null;
26
+
27
+ bindEngine(engine: GalfusEngine): void {
28
+ this._engine = engine;
29
+ this.window.bindEngine(engine);
30
+ }
31
+
32
+ /**
33
+ * Registers an event listener for a specific platform event category.
34
+ *
35
+ * @returns Unsubscribe function to remove the registered listener.
36
+ */
37
+ on<T extends PlatformEventType>(
38
+ type: T,
39
+ listener: (ev: PlatformEventMap[T]) => void
40
+ ): () => void {
41
+ let set = this._eventListeners.get(type);
42
+ if (!set) {
43
+ set = new Set();
44
+ this._eventListeners.set(type, set);
45
+ }
46
+ set.add(listener as (ev: any) => void);
47
+ return () => {
48
+ set?.delete(listener as (ev: any) => void);
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Clears all registered event listeners, or listeners for a specific type.
54
+ */
55
+ clearListeners(type?: PlatformEventType): void {
56
+ if (type) {
57
+ this._eventListeners.get(type)?.clear();
58
+ } else {
59
+ for (const set of this._eventListeners.values()) {
60
+ set.clear();
61
+ }
62
+ this._eventListeners.clear();
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Internal dispatcher forwarding a platform event to subscribed listeners.
68
+ */
69
+ dispatch(event: PlatformEvent): void {
70
+ const kindListeners = this._eventListeners.get(event.kind);
71
+ if (kindListeners && kindListeners.size > 0) {
72
+ for (const listener of kindListeners) {
73
+ listener(event);
74
+ }
75
+ }
76
+ const allListeners = this._eventListeners.get('platform');
77
+ if (allListeners && allListeners.size > 0) {
78
+ for (const listener of allListeners) {
79
+ listener(event);
80
+ }
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Pumps platform input events (window, keyboard, mouse, pointer).
86
+ */
87
+ pumpEvents(): PlatformEvent[] {
88
+ if (this._engine && this._engine.platformPumpEvents) {
89
+ return this._engine.platformPumpEvents();
90
+ }
91
+ return [];
92
+ }
93
+
94
+ /**
95
+ * Queries hardware capabilities and platform feature support flags.
96
+ */
97
+ getCapabilities(): PlatformCapabilities | null {
98
+ if (this._engine) {
99
+ return this._engine.platformGetCapabilities();
100
+ }
101
+ return null;
102
+ }
103
+
104
+ /**
105
+ * Disposes platform resources and closes active windows.
106
+ */
107
+ dispose(): void {
108
+ this.clearListeners();
109
+ this.window.dispose();
110
+ this._engine = null;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Resolves a normalized lowercase key string from a platform KeyboardEvent or input object.
116
+ *
117
+ * @param event The keyboard event from `runtime.on('keyboard', ...)`.
118
+ * @returns Normalized lowercase key identifier (e.g. `'escape'`, `'w'`, `'enter'`, `'1'`).
119
+ */
120
+ export function getKey(event: { logicalKey?: string; physicalKey?: string }): string {
121
+ if (
122
+ typeof event.logicalKey === 'string' &&
123
+ event.logicalKey.length > 0 &&
124
+ event.logicalKey.toLowerCase() !== 'unidentified'
125
+ ) {
126
+ return event.logicalKey.toLowerCase();
127
+ }
128
+ let phys = String(event.physicalKey ?? '').toLowerCase();
129
+ const codeMatch = /^code\((.+)\)$/.exec(phys);
130
+ if (codeMatch) phys = codeMatch[1]!;
131
+ if (phys.startsWith('key') && phys.length === 4) return phys.slice(3);
132
+ if (phys.startsWith('digit') && phys.length === 6) return phys.slice(5);
133
+ return phys;
134
+ }
135
+
136
+ /**
137
+ * Checks if a keyboard event matches any of the given key names (case-insensitive).
138
+ *
139
+ * @param event The keyboard event.
140
+ * @param keys Key names to compare against (e.g. `'Escape'`, `'q'`).
141
+ * @returns `true` if the event key matches any of the target keys.
142
+ */
143
+ export function isKey(
144
+ event: { logicalKey?: string; physicalKey?: string },
145
+ ...keys: string[]
146
+ ): boolean {
147
+ const normalized = getKey(event);
148
+ return keys.some((k) => k.toLowerCase() === normalized);
149
+ }