@galfus/engine 0.0.0-stage → 0.31.0-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,522 @@
1
+ /**
2
+ * High-level Realm2D manager combining an ECS World, Anchor solver, and core sync.
3
+ */
4
+
5
+ import type {
6
+ Camera2DDesc,
7
+ GalfusEngine,
8
+ Light2DDesc,
9
+ Material2DDesc,
10
+ ParticleEmitter2DDesc,
11
+ Shadow2DConfigDesc,
12
+ Shape2DDesc,
13
+ Sprite2DDesc,
14
+ Svg2DDesc,
15
+ SvgElementRect,
16
+ SvgTextLayout,
17
+ TransitionConfig
18
+ } from '@galfus/transport-types';
19
+ import { assertValidLogicalId } from '@galfus/transport-types';
20
+ import type { Transform2DProps } from '../anchors/component';
21
+ import { HierarchyCoordinator } from '../anchors/hierarchy';
22
+ import { AnchorSolveSystem } from '../anchors/solver';
23
+ import type { AnimationCoordinator } from '../animation/coordinator';
24
+ import { Collider2DComponent, type ColliderOptions } from '../collision/components';
25
+ import { queryPoint2D, raycast2D } from '../collision/query2d';
26
+ import type { EntityId } from '../ecs/entity';
27
+ import type { SystemContext } from '../ecs/system';
28
+ import { EcsWorld } from '../ecs/world';
29
+ import type { RealmConnector, RealmId, RealmKind } from '../multiverse/realm';
30
+ import type { TransitionId } from '../types';
31
+ import {
32
+ createCamera2D,
33
+ createLight2D,
34
+ createModel2D,
35
+ createParticleEmitter2D,
36
+ createShape2D,
37
+ createSprite2D,
38
+ createSvg2D,
39
+ type ParticleEmitter2DComponent,
40
+ type Svg2DComponent
41
+ } from './components';
42
+ import { Realm2DSyncSystem } from './sync';
43
+
44
+ export class Realm2D implements RealmConnector {
45
+ readonly realmKind: RealmKind = '2d';
46
+ readonly realmId: RealmId;
47
+ readonly world = new EcsWorld();
48
+ readonly solver = new AnchorSolveSystem();
49
+ readonly syncSystem: Realm2DSyncSystem;
50
+ readonly hierarchy: HierarchyCoordinator<'2d'>;
51
+
52
+ private _engine: GalfusEngine | null = null;
53
+ private _nextResourceLogicalId = 1;
54
+ private _preRenderSteppedFrame = -1;
55
+ private _disposed = false;
56
+ private readonly _materials = new Map<number, Material2DDesc>();
57
+ private readonly _svgCache = new Map<
58
+ number,
59
+ {
60
+ rects: Map<string, Readonly<SvgElementRect> | null>;
61
+ layouts: Map<string, Readonly<SvgTextLayout> | null>;
62
+ }
63
+ >();
64
+
65
+ constructor(realmId: RealmId | number) {
66
+ this.realmId = realmId as RealmId;
67
+ assertValidLogicalId(this.realmId, 'RealmId');
68
+ this.hierarchy = new HierarchyCoordinator(this.world, '2d');
69
+ this.syncSystem = new Realm2DSyncSystem(this.realmId, this.solver.cache, (id) => {
70
+ this.invalidateSvgCache(id);
71
+ });
72
+ this._registerInternalSystems();
73
+ }
74
+
75
+ private _registerInternalSystems(): void {
76
+ this.world.addSystem('preRender', this.solver.createSystem(), { kind: 'anchor' });
77
+ }
78
+
79
+ get isDisposed(): boolean {
80
+ return this._disposed;
81
+ }
82
+
83
+ allocateResourceId(): number {
84
+ const id = this._nextResourceLogicalId++;
85
+ assertValidLogicalId(id, 'ResourceId');
86
+ return id;
87
+ }
88
+
89
+ bindEngine(engine: GalfusEngine): void {
90
+ if (this._disposed) return;
91
+ this._engine = engine;
92
+ engine.realm2dCreate(this.realmId);
93
+ }
94
+
95
+ preRender(context: SystemContext): void {
96
+ if (this._disposed) return;
97
+ this.world.stepPreRender(context);
98
+ this._preRenderSteppedFrame = context.frameId;
99
+ }
100
+
101
+ sync(context: SystemContext): void {
102
+ if (this._disposed) return;
103
+ if (this._preRenderSteppedFrame !== context.frameId) {
104
+ this.preRender(context);
105
+ }
106
+ if (this._engine) {
107
+ this.syncSystem.sync(this.world, this._engine, this._materials);
108
+ }
109
+ }
110
+
111
+ postRender(context: SystemContext): void {
112
+ if (this._disposed) return;
113
+ this.world.stepPostRender(context);
114
+ }
115
+
116
+ flush(): void {
117
+ if (this._disposed) return;
118
+ if (this._engine) {
119
+ this.solver.solve(this.world, { time: 0, dt: 0, frameId: 0, engine: this._engine });
120
+ this.syncSystem.sync(this.world, this._engine, this._materials);
121
+ }
122
+ }
123
+
124
+ reset(): void {
125
+ if (this._disposed) throw new Error('Cannot reset a disposed Realm2D.');
126
+ this.world.reset();
127
+ this.solver.cache.clear();
128
+ this._materials.clear();
129
+ this._svgCache.clear();
130
+ this._preRenderSteppedFrame = -1;
131
+ this._registerInternalSystems();
132
+ if (this._engine) {
133
+ this.syncSystem.dispose(this._engine);
134
+ this._engine.realm2dDestroy(this.realmId);
135
+ this._engine.realm2dCreate(this.realmId);
136
+ }
137
+ }
138
+
139
+ dispose(): void {
140
+ if (this._disposed) return;
141
+ this._disposed = true;
142
+ this.syncSystem.dispose(this._engine);
143
+ if (this._engine) {
144
+ this._engine.realm2dDestroy(this.realmId);
145
+ }
146
+ this._engine = null;
147
+ this.world.reset();
148
+ this.solver.cache.clear();
149
+ this._materials.clear();
150
+ this._svgCache.clear();
151
+ }
152
+
153
+ /** Adds a collider to an entity with a matching Model component. */
154
+ addCollider(
155
+ entityId: EntityId,
156
+ shape: import('../collision/components').ColliderShape2D,
157
+ options?: ColliderOptions
158
+ ): number {
159
+ if (!this.world.isAlive(entityId) || !this.world.hasComponent(entityId, 'Model2D'))
160
+ throw new Error('Collider requires a live entity with Model2D.');
161
+ const existing = this.world.getComponent<Collider2DComponent>(entityId, 'Collider2D');
162
+ const component = existing ?? new Collider2DComponent();
163
+ const id = this.allocateResourceId();
164
+ component.add(id, shape, options);
165
+ if (!existing) this.world.attachComponent(entityId, component);
166
+ return id;
167
+ }
168
+
169
+ queryPoint(point: [number, number], mask = 0xffffffff) {
170
+ if (this._disposed) throw new Error('Cannot query a disposed Realm2D.');
171
+ this.solver.solve(this.world, { time: 0, dt: 0, frameId: 0 });
172
+ return queryPoint2D(this.world, point, mask);
173
+ }
174
+ raycast(
175
+ origin: [number, number],
176
+ direction: [number, number],
177
+ maxDistance: number,
178
+ mask = 0xffffffff
179
+ ) {
180
+ if (this._disposed) throw new Error('Cannot query a disposed Realm2D.');
181
+ this.solver.solve(this.world, { time: 0, dt: 0, frameId: 0 });
182
+ return raycast2D(this.world, origin, direction, maxDistance, mask);
183
+ }
184
+
185
+ destroyEntity(entityId: EntityId): boolean {
186
+ if (!this.world.isAlive(entityId)) return false;
187
+ this.hierarchy.detach(entityId);
188
+ this.hierarchy.handleTargetDestroyed(entityId);
189
+ this.solver.cache.remove(entityId);
190
+ return this.world.destroyEntity(entityId);
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Fluent Entity Spawning Helpers
195
+ // ---------------------------------------------------------------------------
196
+ /**
197
+ * Creates a pure 2D spatial node holding Model2DComponent without visual attachment.
198
+ */
199
+ createSpatialNode(transform?: Transform2DProps): EntityId {
200
+ const entityId = this.world.createEntity();
201
+ const modelId = this.allocateResourceId();
202
+ const model = createModel2D(modelId);
203
+ if (transform) {
204
+ model.setTRS(transform.position, transform.rotation, transform.scale);
205
+ model.zLayer = transform.zLayer ?? 0;
206
+ model.drawOrder = transform.drawOrder ?? 0;
207
+ }
208
+ this.world.attachComponent(entityId, model);
209
+ return entityId;
210
+ }
211
+
212
+ createSprite(desc: Sprite2DDesc, transform?: Transform2DProps): EntityId {
213
+ const entityId = this.world.createEntity();
214
+ const spriteId = this.allocateResourceId();
215
+
216
+ const model = createModel2D(spriteId);
217
+ if (transform) {
218
+ model.setTRS(transform.position, transform.rotation, transform.scale);
219
+ model.zLayer = transform.zLayer ?? 0;
220
+ model.drawOrder = transform.drawOrder ?? 0;
221
+ }
222
+ this.world.attachComponent(entityId, model);
223
+ this.world.attachComponent(entityId, createSprite2D(spriteId, desc));
224
+ return entityId;
225
+ }
226
+
227
+ createShape(desc: Shape2DDesc, transform?: Transform2DProps): EntityId {
228
+ const entityId = this.world.createEntity();
229
+ const shapeId = this.allocateResourceId();
230
+
231
+ const model = createModel2D(shapeId);
232
+ if (transform) {
233
+ model.setTRS(transform.position, transform.rotation, transform.scale);
234
+ model.zLayer = transform.zLayer ?? 0;
235
+ model.drawOrder = transform.drawOrder ?? 0;
236
+ }
237
+ this.world.attachComponent(entityId, model);
238
+ this.world.attachComponent(entityId, createShape2D(shapeId, desc));
239
+ return entityId;
240
+ }
241
+
242
+ createLight(desc: Light2DDesc, transform?: Transform2DProps): EntityId {
243
+ const entityId = this.world.createEntity();
244
+ const lightId = this.allocateResourceId();
245
+ const model = createModel2D(lightId);
246
+ if (transform) {
247
+ model.setTRS(transform.position, transform.rotation, transform.scale);
248
+ model.zLayer = transform.zLayer ?? 0;
249
+ model.drawOrder = transform.drawOrder ?? 0;
250
+ }
251
+ this.world.attachComponent(entityId, model);
252
+ this.world.attachComponent(entityId, createLight2D(lightId, desc));
253
+ return entityId;
254
+ }
255
+
256
+ createCamera(
257
+ desc: Camera2DDesc,
258
+ transform?: Transform2DProps
259
+ ): { entityId: EntityId; cameraId: number } {
260
+ const entityId = this.world.createEntity();
261
+ const cameraId = this.allocateResourceId();
262
+ const model = createModel2D(cameraId);
263
+ if (transform) {
264
+ model.setTRS(transform.position, transform.rotation, transform.scale);
265
+ }
266
+ this.world.attachComponent(entityId, model);
267
+ this.world.attachComponent(entityId, createCamera2D(cameraId, desc));
268
+ return { entityId, cameraId };
269
+ }
270
+
271
+ createSvg(desc: Svg2DDesc, transform?: Transform2DProps): { entityId: EntityId; svgId: number } {
272
+ const entityId = this.world.createEntity();
273
+ const svgId = this.allocateResourceId();
274
+
275
+ const model = createModel2D(svgId);
276
+ if (transform) {
277
+ model.setTRS(transform.position, transform.rotation, transform.scale);
278
+ model.zLayer = transform.zLayer ?? 0;
279
+ model.drawOrder = transform.drawOrder ?? 0;
280
+ }
281
+ this.world.attachComponent(entityId, model);
282
+ this.world.attachComponent(entityId, createSvg2D(svgId, desc));
283
+ return { entityId, svgId };
284
+ }
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // SVG Dynamic Manipulation
288
+ // ---------------------------------------------------------------------------
289
+ /** Returns element bounds in the SVG viewport coordinate system. */
290
+ getSvgElementRect(svgId: number, elementId: string): Readonly<SvgElementRect> | undefined {
291
+ if (this._disposed || !this._engine) return undefined;
292
+ if (!this.syncSystem.isSvgSynced(svgId)) this.flush();
293
+ if (!this.syncSystem.isSvgSynced(svgId)) return undefined;
294
+ const entries = this._svgCacheEntry(svgId).rects;
295
+ if (!entries.has(elementId)) {
296
+ const rect = this._engine.realm2dGetSvgElementRect(this.realmId, svgId, elementId).rect;
297
+ entries.set(elementId, rect ? Object.freeze(rect) : null);
298
+ }
299
+ return entries.get(elementId) ?? undefined;
300
+ }
301
+
302
+ /** Returns cached raw shaping metrics in SVG viewport coordinates. */
303
+ getSvgTextLayout(svgId: number, elementId: string): Readonly<SvgTextLayout> | undefined {
304
+ if (this._disposed || !this._engine) return undefined;
305
+ if (!this.syncSystem.isSvgSynced(svgId)) this.flush();
306
+ if (!this.syncSystem.isSvgSynced(svgId)) return undefined;
307
+ const entries = this._svgCacheEntry(svgId).layouts;
308
+ if (!entries.has(elementId)) {
309
+ const layout = this._engine.realm2dGetSvgTextLayout(this.realmId, svgId, elementId).layout;
310
+ if (layout) {
311
+ for (const cluster of layout.clusters) {
312
+ Object.freeze(cluster.transform);
313
+ Object.freeze(cluster);
314
+ }
315
+ Object.freeze(layout.clusters);
316
+ Object.freeze(layout.chunks);
317
+ Object.freeze(layout);
318
+ }
319
+ entries.set(elementId, layout ?? null);
320
+ }
321
+ return entries.get(elementId) ?? undefined;
322
+ }
323
+
324
+ /** @internal Invalidates cached SVG geometry after native animation updates. */
325
+ invalidateSvgCache(svgId: number): void {
326
+ this._svgCache.delete(svgId);
327
+ }
328
+
329
+ private _svgCacheEntry(svgId: number) {
330
+ let entry = this._svgCache.get(svgId);
331
+ if (!entry) {
332
+ entry = { rects: new Map(), layouts: new Map() };
333
+ this._svgCache.set(svgId, entry);
334
+ }
335
+ return entry;
336
+ }
337
+
338
+ private _resolveSvgId(svg: number | { svgId?: number; entityId?: EntityId }): number {
339
+ if (typeof svg === 'number') {
340
+ return this.world.getComponent<Svg2DComponent>(svg as EntityId, 'Svg2D')?.svgId ?? svg;
341
+ }
342
+ const resolved =
343
+ svg.svgId ??
344
+ (svg.entityId
345
+ ? this.world.getComponent<Svg2DComponent>(svg.entityId, 'Svg2D')?.svgId
346
+ : undefined);
347
+ if (resolved === undefined) {
348
+ throw new Error('Cannot resolve Svg2D ID: entity does not have a Svg2DComponent.');
349
+ }
350
+ return resolved;
351
+ }
352
+
353
+ setSvgSource(svg: number | { svgId?: number; entityId?: EntityId }, source: string): void {
354
+ const svgId = this._resolveSvgId(svg);
355
+ this.invalidateSvgCache(svgId);
356
+ if (this._engine) {
357
+ if (!this.syncSystem.isSvgSynced(svgId)) {
358
+ this.flush();
359
+ }
360
+ this._engine.realm2dSetSvgSource(this.realmId, svgId, source);
361
+ }
362
+ }
363
+
364
+ setSvgAttribute(
365
+ svg: number | { svgId?: number; entityId?: EntityId },
366
+ elementId: string,
367
+ name: string,
368
+ value: string
369
+ ): void {
370
+ const svgId = this._resolveSvgId(svg);
371
+ this.invalidateSvgCache(svgId);
372
+ if (this._engine) {
373
+ if (!this.syncSystem.isSvgSynced(svgId)) {
374
+ this.flush();
375
+ }
376
+ this._engine.realm2dSetSvgAttribute(this.realmId, svgId, elementId, name, value);
377
+ }
378
+ }
379
+
380
+ removeSvgAttribute(
381
+ svg: number | { svgId?: number; entityId?: EntityId },
382
+ elementId: string,
383
+ name: string
384
+ ): void {
385
+ const svgId = this._resolveSvgId(svg);
386
+ this.invalidateSvgCache(svgId);
387
+ if (this._engine) {
388
+ if (!this.syncSystem.isSvgSynced(svgId)) {
389
+ this.flush();
390
+ }
391
+ this._engine.realm2dRemoveSvgAttribute(this.realmId, svgId, elementId, name);
392
+ }
393
+ }
394
+
395
+ setSvgText(
396
+ svg: number | { svgId?: number; entityId?: EntityId },
397
+ elementId: string,
398
+ text: string
399
+ ): void {
400
+ const svgId = this._resolveSvgId(svg);
401
+ this.invalidateSvgCache(svgId);
402
+ if (this._engine) {
403
+ if (!this.syncSystem.isSvgSynced(svgId)) {
404
+ this.flush();
405
+ }
406
+ this._engine.realm2dSetSvgText(this.realmId, svgId, elementId, text);
407
+ }
408
+ }
409
+
410
+ // ---------------------------------------------------------------------------
411
+ // 2D Polar Shadows, Fonts & Materials
412
+ // ---------------------------------------------------------------------------
413
+ setShadowConfig(config: Shadow2DConfigDesc): void {
414
+ if (this._engine) {
415
+ this._engine.realm2dSetShadowConfig(this.realmId, config);
416
+ }
417
+ }
418
+
419
+ loadFontData(data: Uint8Array): string {
420
+ if (!this._engine) {
421
+ throw new Error('Cannot load font: Realm2D is not bound to an engine instance.');
422
+ }
423
+ const family = this._engine.realm2dLoadFontData(this.realmId, data, data.byteLength);
424
+ this._svgCache.clear();
425
+ return family;
426
+ }
427
+
428
+ /** Registers a material for upload during the next sync or flush. */
429
+ createMaterial(desc: Material2DDesc): number {
430
+ const materialId = this.allocateResourceId();
431
+ this._materials.set(materialId, desc);
432
+ return materialId;
433
+ }
434
+
435
+ /** Removes a material on the next sync or flush; entities referencing it are not destroyed. */
436
+ destroyMaterial(materialId: number): void {
437
+ this._materials.delete(materialId);
438
+ }
439
+
440
+ // ---------------------------------------------------------------------------
441
+ // 2D Particle Emitters
442
+ // ---------------------------------------------------------------------------
443
+ createParticleEmitter(
444
+ desc: ParticleEmitter2DDesc,
445
+ transform?: Transform2DProps
446
+ ): { entityId: EntityId; emitterId: number } {
447
+ const entityId = this.world.createEntity();
448
+ const emitterId = this.allocateResourceId();
449
+ const model = createModel2D(emitterId);
450
+ if (transform) {
451
+ model.setTRS(transform.position, transform.rotation, transform.scale);
452
+ model.zLayer = transform.zLayer ?? 0;
453
+ model.drawOrder = transform.drawOrder ?? 0;
454
+ }
455
+ this.world.attachComponent(entityId, model);
456
+ this.world.attachComponent(entityId, createParticleEmitter2D(emitterId, desc));
457
+ return { entityId, emitterId };
458
+ }
459
+
460
+ particleEmitterBurst(
461
+ emitter: number | { emitterId?: number; entityId?: EntityId },
462
+ count: number
463
+ ): void {
464
+ const emitterId =
465
+ typeof emitter === 'number'
466
+ ? (this.world.getComponent<ParticleEmitter2DComponent>(
467
+ emitter as EntityId,
468
+ 'ParticleEmitter2D'
469
+ )?.emitterId ?? emitter)
470
+ : (emitter.emitterId ??
471
+ (emitter.entityId
472
+ ? this.world.getComponent<ParticleEmitter2DComponent>(
473
+ emitter.entityId,
474
+ 'ParticleEmitter2D'
475
+ )?.emitterId
476
+ : undefined));
477
+ if (emitterId === undefined) {
478
+ throw new Error(
479
+ 'Cannot resolve ParticleEmitter2D ID: entity does not have a ParticleEmitter2DComponent.'
480
+ );
481
+ }
482
+ if (this._engine) {
483
+ if (!this.syncSystem.isEmitterSynced(emitterId)) {
484
+ this.flush();
485
+ }
486
+ this._engine.realm2dParticleEmitterBurst(this.realmId, emitterId, count);
487
+ }
488
+ }
489
+
490
+ /**
491
+ * Helper to animate a 2D model transform directly through ECS.
492
+ */
493
+ animateModel(
494
+ entityId: EntityId,
495
+ targetValue: unknown,
496
+ config: TransitionConfig,
497
+ animation: AnimationCoordinator
498
+ ): TransitionId | undefined {
499
+ return animation.animateEcsProperty(this.world, entityId, 'transform2d', targetValue, config);
500
+ }
501
+
502
+ /**
503
+ * Helper to animate SVG opacity directly without manual target ID construction.
504
+ */
505
+ animateSvgOpacity(
506
+ entityId: EntityId,
507
+ initialOpacity: number,
508
+ targetOpacity: number,
509
+ duration: number,
510
+ animation: AnimationCoordinator
511
+ ): TransitionId | undefined {
512
+ const svg = this.world.getComponent<Svg2DComponent>(entityId, 'Svg2D');
513
+ if (!svg) return undefined;
514
+ const transId = animation.createScalarTransition(initialOpacity, duration, 0, 0, {
515
+ kind: 'svgOpacity',
516
+ realmId: this.realmId,
517
+ svgId: svg.svgId
518
+ });
519
+ animation.transitionToScalar(transId, targetOpacity, duration);
520
+ return transId;
521
+ }
522
+ }