@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
package/README.md CHANGED
@@ -1,3 +1,191 @@
1
- # Temporary Holding Version
1
+ # @galfus/engine
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The official TypeScript high-level engine runtime for **Galfus Engine**. Provides an ECS-driven architecture, incremental dirty-flag synchronization, anchor-based transform hierarchy, 2D/3D realms, multiverse composition, and spatial audio.
4
+
5
+ ---
6
+
7
+ ## Architecture Overview
8
+
9
+ 1. **ECS as Single Source of Truth**:
10
+ - Entities hold spatial `Model3D` or `Model2D` components.
11
+ - Renderables (`Mesh3D`, `Sprite2D`, `Shape2D`, `Svg2D`) attach directly to the model entity.
12
+ - Assets (`Geometry`, `Material`, `Texture`) are managed in resource registries outside the ECS and survive entity destruction.
13
+
14
+ 2. **Anchor Graph (`origin` / `target`)**:
15
+ - Replaces traditional rigid parent/child hierarchies.
16
+ - `origin` is the dependent entity; `target` is the anchor reference.
17
+ - Detaching an entity or destroying its target preserves the resolved global matrix into the local transform.
18
+
19
+ 3. **Incremental Dirty Sync & Zero Overhead**:
20
+ - Spatial and descriptor updates are tracked via `DirtyRegistry`.
21
+ - Completely static scenes produce **zero** transform ABI calls per frame.
22
+
23
+ 4. **Dynamic Multiverse Invocations**:
24
+ - Targets represent render surfaces (windows or offscreen textures).
25
+ - Invocations compose Realms onto Targets via responsive viewports.
26
+ - Mutate viewports seamlessly without rebinding via `invocation.update({ viewport: ... })`.
27
+
28
+ 5. **Deterministic Lifecycle**:
29
+ - `realm.reset()`: Resets the ECS world, entities, and realm-owned assets, keeping the realm registered.
30
+ - `realm.dispose()`: Unregisters the realm from the Multiverse and frees its native resources.
31
+ - `runtime.reset()`: Purges all realms, invocations, targets, audio, and caches while keeping the transport backend ready for new scenes.
32
+ - `runtime.dispose()`: Terminal shutdown of both runtime and transport.
33
+
34
+ 2D materials are realm-owned resources. `Realm2D.createMaterial` returns a logical ID and queues the material for the next sync or `flush()`, even if the realm is not yet bound to an engine. `destroyMaterial(id)` queues its removal without destroying entities that reference it. `reset()` and `dispose()` release the realm's materials.
35
+
36
+ The Multiverse maintains a host-side `presentationGraph` rooted at targets of open windows. It is populated from the renderer's resolved schedule and published after a successful dispatch. `getWindow(windowId)` is unavailable before the first successful dispatch or after the window closes; otherwise it exposes an immutable snapshot of reachable targets, invocations, resolved viewports, render order and texture-binding candidates. The last snapshot remains available while paused and when a dispatch fails. Unchanged schedules and links reuse the existing snapshot. A texture binding alone is a candidate, not a traversable surface; future interaction code must validate and maintain surface mappings. Cycle edges sampled from the previous frame are exposed as `softCutEdges`.
37
+
38
+ 2D objects are drawn back to front by `zLayer`, then `drawOrder`, then logical resource ID. Set distinct layers or draw orders when overlap must remain independent of resource creation order. Changing `Model2DComponent.zLayer` or `drawOrder` is synchronized on the next frame.
39
+
40
+ ## Colliders
41
+
42
+ Each collider belongs to an entity with a matching `Model2D` or `Model3D` component. `addCollider` returns a realm-owned logical collider ID; multiple colliders may share one entity. Local matrices are column-major and relative to the model. Collision queries resolve the model hierarchy on the host and return hits with collider and entity IDs; ray hits are sorted by distance. Queries do not require a bound engine or call the rendering core. Point-query boundaries are inclusive. Rays starting inside a volume return distance zero and a zero normal. A singular world transform temporarily excludes the collider until its transform is invertible again.
43
+
44
+ ```typescript
45
+ const entity = realm2d.createSpatialNode({ position: [3, 4] });
46
+ const colliderId = realm2d.addCollider(entity, { type: 'circle', radius: 2 }, { sensor: true });
47
+ const inside = realm2d.queryPoint([3, 4]);
48
+ const hits = realm2d.raycast([0, 4], [1, 0], 10);
49
+ const component = realm2d.world.getComponent<Collider2DComponent>(entity, 'Collider2D');
50
+ component?.remove(colliderId);
51
+ ```
52
+
53
+ 2D supports circles, rectangles and simple polygons. 3D supports spheres, boxes and finite planes in local XY. Queries use a layer mask; the collider's own mask is retained for future pairwise collision filtering. These APIs perform geometric queries and do not simulate rigid bodies.
54
+
55
+ ---
56
+
57
+ ## Input methods
58
+
59
+ Enable IME only while a text target is focused, and provide its caret area in physical window pixels so the platform can position its candidate UI:
60
+
61
+ ```typescript
62
+ runtime.platform.window.setImeAllowed(windowId, true);
63
+ runtime.platform.window.setImeCursorArea(windowId, x, y, width, height);
64
+ const off = runtime.on('keyboard', (event) => {
65
+ if (event.type === 'imePreedit') console.log('Preview:', event.text, event.selection);
66
+ if (event.type === 'imeCommit') console.log('Confirmed:', event.text);
67
+ });
68
+ // On blur or disposal:
69
+ runtime.platform.window.setImeAllowed(windowId, false);
70
+ off();
71
+ ```
72
+
73
+ Selection offsets, when supplied, use UTF-16 code units within the preedit text. The browser does not expose this selection in composition events. The browser input bridge takes DOM focus and releases it on disable or focus loss; enable it from a user interaction when the browser requires a user gesture.
74
+
75
+ `textInput` is the committed text stream; `input.text` and `imeCommit.text` remain available as raw platform details. Text editors should insert from `textInput` only to avoid processing the same text twice. `TextEditor` stores Unicode text and grapheme-aware selection; `applyTextKeyboardEvent` maps the normalized keyboard stream to the editor.
76
+
77
+ `runtime.textInput(realmId, { svgId, elementId, hitAreaId, value, tabIndex })` binds an editor to an SVG text element and a focusable SVG hit area. `hitAreaId` defaults to `elementId`; use a separate visible hit area when the text can be empty. The `value` option defaults to an empty string and replaces the element's existing children. The returned input supports `requestFocus`, `blur`, `setValue`, `setSelection`, `selectAll`, `on`, `off`, and `dispose`. It updates the SVG with the IME preview while retaining the committed `value`. Automatic caret and selection rendering, pointer-based text selection, candidate positioning, clipboard integration, and full native/browser IME parity are not yet implemented.
78
+
79
+ Focusable pointer targets can be registered with `runtime.focusable(realmId, target, { tabIndex })`. Call `requestFocus(windowId)`, `blur(windowId)`, and `dispose()` on the returned target; subscribe to `focus` and `blur` with `on`. `runtime.getFocused(windowId)` reports the focused target. Tab and Shift+Tab navigate registered targets in direct, successfully submitted window presentations. A negative `tabIndex` excludes a target from Tab navigation but permits explicit focus. Browser keyboard delivery still depends on platform focus and must not be interpreted as complete SVG text input support.
80
+
81
+ `SvgTextDecorations` updates an existing SVG rectangle for a caret and either rectangles or a path for selection spans. It accepts `cursorColor` and `selectionColor`; its caller must provide viewport-space geometry. Automatic cursor placement, pointer selection, and blinking are not provided by this low-level utility.
82
+
83
+ `runtime.svg.getTextLayout(realmId, svgId, elementId)` returns shaped cluster metrics on demand.
84
+ The result contains normalized text chunks, cluster byte ranges within those UTF-8 chunks,
85
+ advances, ascent and descent, text direction, and affine transforms in SVG viewport coordinates.
86
+ The host caches the result until the SVG or font data changes; missing or non-rendered text
87
+ returns `undefined`. SVG whitespace normalization may change chunk text from the original source,
88
+ and a cluster can contain multiple graphemes, so these metrics alone do not yet define every
89
+ editor caret position. Empty text is not currently returned by the SVG parser.
90
+
91
+ For explicit text/byte conversions, each runtime defaults to UTF-8. Set `createRuntime(transport, { charset: 'shift_jis' })` or call `runtime.textEncoding.setCharset('windows-1252')`, then use `runtime.textEncoding.encode(text)` and `decode(bytes)`. This setting does not restrict or recode editor strings or change the platform ABI. Unknown charsets throw; invalid bytes and characters not representable in the chosen charset are replaced according to the codec.
92
+
93
+ ---
94
+
95
+ ## Quick Start
96
+
97
+ ```typescript
98
+ import { createBunTransport } from '@galfus/transport-bun';
99
+ import { createRuntime } from '@galfus/engine';
100
+
101
+ // 1. Initialize transport and runtime
102
+ const transport = await createBunTransport({ targetFps: 60, headless: false });
103
+ const runtime = createRuntime(transport);
104
+
105
+ // 2. Create 3D Realm and resources
106
+ const realm3d = runtime.createRealm3D(1);
107
+ const geoId = realm3d.createGeometry({ kind: 'cube', size: 1.0 });
108
+ const matId = realm3d.createMaterial({ baseColor: [0.8, 0.2, 0.3, 1.0] });
109
+
110
+ // 3. Create Model entity and attach Mesh
111
+ const entity = realm3d.createModel({
112
+ geometryId: geoId,
113
+ materialId: matId,
114
+ transform: { position: [0, 1, 0] }
115
+ });
116
+
117
+ // 4. Set up Multiverse Target and Invocation
118
+ const targetId = runtime.multiverse.createTarget({
119
+ kind: { type: 'window', windowId: 1 }
120
+ });
121
+
122
+ const invocationId = runtime.multiverse.createInvocation({
123
+ realmId: realm3d.realmId,
124
+ targetId,
125
+ viewport: { width: { vw: 100 }, height: { vh: 100 } }
126
+ });
127
+
128
+ // 5. Start main loop
129
+ runtime.start();
130
+ ```
131
+
132
+ ---
133
+
134
+ ## SVG element bounds
135
+
136
+ `runtime.svg.getRect(realmId, svgId, elementId)` returns the geometric bounds of an SVG
137
+ element or `undefined` if it cannot be found. The result is expressed in the SVG viewport's
138
+ coordinates (top-left origin, Y down), before the entity transform. `viewportWidth` and
139
+ `viewportHeight` describe that coordinate space. Bounds are cached and refreshed after SVG
140
+ edits, font changes, re-creation, and native attribute animations.
141
+
142
+ ## Pointer interaction
143
+
144
+ Run `bun host:demo interaction` to try direct 2D, SVG, and 3D interaction in a window.
145
+ Run `bun host:demo text-input` to try multiple SVG text fields and Tab/Shift+Tab focus.
146
+ To test characters outside the bundled font, pass `--font /path/to/font.ttf` (TTF, OTF, or TTC).
147
+ The demo draws the keyboard caret and selection for single-line text; positions inside a ligature
148
+ are estimated because the current layout API does not expose ligature caret positions.
149
+ For a bounded headless smoke test, use `bun host:demo interaction --headless --frames 10`.
150
+ The host tutorial is available as `bun host:demo tutorial`, with the same headless option.
151
+ Use `bun host:demo help` to list the available demos.
152
+
153
+ For an entity with a `Collider2D` or `Collider3D`, or for an SVG element in a 2D realm:
154
+
155
+ ```ts
156
+ const listener = runtime.interactive(realmId, { type: 'collider', entityId });
157
+ const unsubscribe = listener.on('hover', (event) => console.log(event.pointer.position));
158
+ listener.on('click', () => console.log('clicked'));
159
+ unsubscribe();
160
+ listener.dispose();
161
+ ```
162
+
163
+ Use `{ type: 'svg', svgId, elementId }` for an SVG element. The supported event types are
164
+ `hover`, `leave`, `move`, `down`, `up`, `click`, `cancel`, `pressmove`, and `pressup`.
165
+ `pressmove` and `pressup` go to the target originally pressed even when the pointer is no
166
+ longer over it, as long as the platform continues delivering pointer events; `cancel` ends
167
+ the interaction when the pointer or window is lost. When an entity has multiple
168
+ colliders, specify `colliderId`. Events use the most recently submitted presentation state.
169
+ When the registered entity or collider is removed, the listener is cancelled and disposed
170
+ after the next successful dispatch.
171
+ Currently this supports realms presented directly to a window. Traversal through textures
172
+ connecting realms is not implemented yet, and native animations of camera or model transforms
173
+ and camera field of view are not yet reflected in interaction snapshots. Occlusion is resolved
174
+ among registered interactive targets; geometry and colliders without listeners do not block them.
175
+
176
+ ## Migration Guide
177
+
178
+ | Legacy Pattern | Modern `@galfus/engine` Pattern |
179
+ | ----------------------------------------- | -------------------------------------------------------------------------------------------------- |
180
+ | `parent` / `child` hierarchy | `origin` (dependent) and `target` (reference) via `r3d.hierarchy.attach(origin, target, strategy)` |
181
+ | `Transform3DComponent` standalone | Embedded inside `Model3DComponent.transform` (local + world matrices) |
182
+ | Standalone `Geometry3D` in ECS | Realm resource via `realm.createGeometry(...)` |
183
+ | Direct Core transform writes in animation | Animation drives ECS `Model3DComponent`/`Model2DComponent` |
184
+ | Viewport mutations via separate setters | Unified `coordinator.updateInvocation(id, { viewport })` |
185
+ | Fallbacks to ID `1` on missing component | Explicit typed error thrown describing the missing component |
186
+
187
+ The host APIs `runtime.onTick`, `animation.onEvent`, `world.clear`, `realm3d.attachMesh`,
188
+ the separate Multiverse invocation setters, `stepRealms`, and `syncAllRealms` have been removed.
189
+ Use `runtime.on('tick', ...)`, `animation.on('animation', ...)`, `world.reset`,
190
+ `realm3d.createMesh`, `multiverse.updateInvocation`, and the separate pre-render, sync, and
191
+ post-render stages respectively.
package/package.json CHANGED
@@ -1,6 +1,61 @@
1
1
  {
2
2
  "name": "@galfus/engine",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.31.1-alpha",
4
+ "description": "Lightweight, high-performance TypeScript ECS and multiverse game engine built for Galfus",
5
+ "author": "Renato Rodrigues (morbden) <renato@vulppi.dev>",
6
+ "license": "Apache-2.0",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/galfus-lang/galfus-engine.git",
10
+ "directory": "host/engine"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/galfus-lang/galfus-engine/issues"
14
+ },
15
+ "homepage": "https://github.com/galfus-lang/galfus-engine#readme",
16
+ "keywords": [
17
+ "galfus",
18
+ "game-engine",
19
+ "ecs",
20
+ "2d",
21
+ "3d",
22
+ "multiverse",
23
+ "webgpu",
24
+ "scene-graph",
25
+ "audio",
26
+ "animation",
27
+ "spatial",
28
+ "raycast",
29
+ "input"
30
+ ],
31
+ "type": "module",
32
+ "main": "./src/index.ts",
33
+ "types": "./src/index.ts",
34
+ "files": [
35
+ "src",
36
+ "README.md"
37
+ ],
38
+ "exports": {
39
+ ".": "./src/index.ts",
40
+ "./math": "./src/math/index.ts",
41
+ "./ecs": "./src/ecs/index.ts",
42
+ "./anchors": "./src/anchors/index.ts",
43
+ "./multiverse": "./src/multiverse/index.ts",
44
+ "./realm2d": "./src/realm2d/index.ts",
45
+ "./realm2d/utils": "./src/realm2d/utils/index.ts",
46
+ "./realm2d/presets": "./src/realm2d/presets/index.ts",
47
+ "./realm3d": "./src/realm3d/index.ts",
48
+ "./realm3d/utils": "./src/realm3d/utils/index.ts",
49
+ "./realm3d/presets": "./src/realm3d/presets/index.ts",
50
+ "./resources": "./src/resources/index.ts",
51
+ "./audio": "./src/audio/index.ts",
52
+ "./animation": "./src/animation/index.ts",
53
+ "./platform": "./src/platform/index.ts",
54
+ "./types": "./src/types.ts"
55
+ },
56
+ "dependencies": {
57
+ "@galfus/transport-types": "^0.31.1-alpha",
58
+ "iconv-lite": "^0.7.3",
59
+ "unicode-segmenter": "0.17.3"
60
+ }
61
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Persistent matrix memory cache for anchors and transforms.
3
+ *
4
+ * Keeps pre-allocated TypedArray buffers in memory and mutates them in-place
5
+ * across frames to eliminate Garbage Collector overhead and allocation jitter.
6
+ */
7
+
8
+ import type { EntityId } from '../ecs/entity';
9
+ import { mat2d, type Mat2d, mat4, type Mat4 } from '../math/index';
10
+
11
+ export class AnchorMatrixCache {
12
+ // 2D Matrix Buffers: Float32Array(6)
13
+ private readonly _local2D = new Map<EntityId, Mat2d>();
14
+ private readonly _world2D = new Map<EntityId, Mat2d>();
15
+
16
+ // 3D Matrix Buffers: Float32Array(16)
17
+ private readonly _local3D = new Map<EntityId, Mat4>();
18
+ private readonly _world3D = new Map<EntityId, Mat4>();
19
+
20
+ /**
21
+ * Retrieves or creates a persistent local 2D matrix buffer for the given entity.
22
+ */
23
+ getOrCreateLocal2D(entityId: EntityId): Mat2d {
24
+ let m = this._local2D.get(entityId);
25
+ if (!m) {
26
+ m = mat2d.create();
27
+ this._local2D.set(entityId, m);
28
+ }
29
+ return m;
30
+ }
31
+
32
+ /**
33
+ * Retrieves or creates a persistent world 2D matrix buffer for the given entity.
34
+ */
35
+ getOrCreateWorld2D(entityId: EntityId): Mat2d {
36
+ let m = this._world2D.get(entityId);
37
+ if (!m) {
38
+ m = mat2d.create();
39
+ this._world2D.set(entityId, m);
40
+ }
41
+ return m;
42
+ }
43
+
44
+ /**
45
+ * Retrieves or creates a persistent local 3D matrix buffer for the given entity.
46
+ */
47
+ getOrCreateLocal3D(entityId: EntityId): Mat4 {
48
+ let m = this._local3D.get(entityId);
49
+ if (!m) {
50
+ m = mat4.create();
51
+ this._local3D.set(entityId, m);
52
+ }
53
+ return m;
54
+ }
55
+
56
+ /**
57
+ * Retrieves or creates a persistent world 3D matrix buffer for the given entity.
58
+ */
59
+ getOrCreateWorld3D(entityId: EntityId): Mat4 {
60
+ let m = this._world3D.get(entityId);
61
+ if (!m) {
62
+ m = mat4.create();
63
+ this._world3D.set(entityId, m);
64
+ }
65
+ return m;
66
+ }
67
+
68
+ /**
69
+ * Gets an existing world 2D matrix buffer if already resolved.
70
+ */
71
+ getWorld2D(entityId: EntityId): Mat2d | undefined {
72
+ return this._world2D.get(entityId);
73
+ }
74
+
75
+ /**
76
+ * Gets an existing world 3D matrix buffer if already resolved.
77
+ */
78
+ getWorld3D(entityId: EntityId): Mat4 | undefined {
79
+ return this._world3D.get(entityId);
80
+ }
81
+
82
+ /**
83
+ * Removes cached buffers for an entity upon destruction.
84
+ */
85
+ remove(entityId: EntityId): void {
86
+ this._local2D.delete(entityId);
87
+ this._world2D.delete(entityId);
88
+ this._local3D.delete(entityId);
89
+ this._world3D.delete(entityId);
90
+ }
91
+
92
+ /**
93
+ * Clears all cached buffers.
94
+ */
95
+ clear(): void {
96
+ this._local2D.clear();
97
+ this._world2D.clear();
98
+ this._local3D.clear();
99
+ this._world3D.clear();
100
+ }
101
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Spatial transform components and Anchor components for hierarchical relationships.
3
+ */
4
+
5
+ import type { Component } from '../ecs/component';
6
+ import type { EntityId } from '../ecs/entity';
7
+ import type { EcsWorld } from '../ecs/world';
8
+ import { type Mat2d, type Mat4, type Quat, type Vec2, type Vec3 } from '../math/index';
9
+ import type { Model2DComponent } from '../realm2d/components';
10
+ import type { Model3DComponent } from '../realm3d/components';
11
+ import { SpatialDirtyFlags } from '../spatial/flags';
12
+ import type { AnchorConstraintCallback2D, AnchorConstraintCallback3D } from './strategy';
13
+
14
+ /**
15
+ * Spatial transform descriptors for initial setup.
16
+ */
17
+ export interface Transform2DProps {
18
+ position?: [number, number] | Vec2;
19
+ rotation?: number;
20
+ scale?: [number, number] | Vec2;
21
+ zLayer?: number;
22
+ drawOrder?: number;
23
+ }
24
+
25
+ export interface Transform3DProps {
26
+ position?: [number, number, number] | Vec3;
27
+ rotation?: [number, number, number, number] | Quat;
28
+ scale?: [number, number, number] | Vec3;
29
+ }
30
+
31
+ /**
32
+ * Anchor component linking an origin entity to a target entity with an arbitrary strategy or functional constraint.
33
+ */
34
+ export interface AnchorComponent extends Component {
35
+ readonly type: 'Anchor';
36
+ /** The target entity to which this entity is anchored. */
37
+ targetEntityId: EntityId;
38
+ /** Anchor solving strategy name (defaults to 'static'). */
39
+ strategy: string;
40
+ /** Optional distance constraint parameter. */
41
+ distance?: number;
42
+ /** Optional 2D offset matrix if anchoring in 2D space. */
43
+ offset2d?: Mat2d;
44
+ /** Optional 3D offset matrix if anchoring in 3D space. */
45
+ offset3d?: Mat4;
46
+ /** Optional custom in-place constraint callback for 2D. */
47
+ callback2d?: AnchorConstraintCallback2D;
48
+ /** Optional custom in-place constraint callback for 3D. */
49
+ callback3d?: AnchorConstraintCallback3D;
50
+ /** Whether this anchor is currently active. */
51
+ active: boolean;
52
+ }
53
+
54
+ export interface AnchorProps {
55
+ targetEntityId: EntityId;
56
+ strategy?: string;
57
+ distance?: number;
58
+ offset2d?: Mat2d;
59
+ offset3d?: Mat4;
60
+ callback2d?: AnchorConstraintCallback2D;
61
+ callback3d?: AnchorConstraintCallback3D;
62
+ active?: boolean;
63
+ }
64
+
65
+ export function createAnchor(props: AnchorProps): AnchorComponent {
66
+ return {
67
+ type: 'Anchor',
68
+ targetEntityId: props.targetEntityId,
69
+ strategy: props.strategy ?? 'static',
70
+ distance: props.distance,
71
+ offset2d: props.offset2d,
72
+ offset3d: props.offset3d,
73
+ callback2d: props.callback2d,
74
+ callback3d: props.callback3d,
75
+ active: props.active ?? true
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Observable anchor mutation helpers that invalidate origin spatial dirty flags immediately.
81
+ */
82
+ export function setAnchorDistance(world: EcsWorld, entityId: EntityId, distance: number): boolean {
83
+ const anchor = world.getComponent<AnchorComponent>(entityId, 'Anchor');
84
+ if (!anchor) return false;
85
+ anchor.distance = distance;
86
+ _invalidateOrigin(world, entityId);
87
+ return true;
88
+ }
89
+
90
+ export function setAnchorStrategy(world: EcsWorld, entityId: EntityId, strategy: string): boolean {
91
+ const anchor = world.getComponent<AnchorComponent>(entityId, 'Anchor');
92
+ if (!anchor) return false;
93
+ anchor.strategy = strategy;
94
+ _invalidateOrigin(world, entityId);
95
+ return true;
96
+ }
97
+
98
+ export function setAnchorOffset3D(world: EcsWorld, entityId: EntityId, offset: Mat4): boolean {
99
+ const anchor = world.getComponent<AnchorComponent>(entityId, 'Anchor');
100
+ if (!anchor) return false;
101
+ anchor.offset3d = offset;
102
+ _invalidateOrigin(world, entityId);
103
+ return true;
104
+ }
105
+
106
+ export function setAnchorOffset2D(world: EcsWorld, entityId: EntityId, offset: Mat2d): boolean {
107
+ const anchor = world.getComponent<AnchorComponent>(entityId, 'Anchor');
108
+ if (!anchor) return false;
109
+ anchor.offset2d = offset;
110
+ _invalidateOrigin(world, entityId);
111
+ return true;
112
+ }
113
+
114
+ function _invalidateOrigin(world: EcsWorld, entityId: EntityId): void {
115
+ const model3d = world.getComponent<Model3DComponent>(entityId, 'Model3D');
116
+ if (model3d) {
117
+ model3d.flags |= SpatialDirtyFlags.LocalDirty;
118
+ }
119
+ const model2d = world.getComponent<Model2DComponent>(entityId, 'Model2D');
120
+ if (model2d) {
121
+ model2d.flags |= SpatialDirtyFlags.LocalDirty;
122
+ }
123
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * HierarchyCoordinator provides high-level scene graph and anchor management for a Realm.
3
+ * Maintains an O(1) reverse lookup (target -> dependents), prevents cyclic relationships,
4
+ * and enforces safe world-transform preservation upon target detachment.
5
+ */
6
+
7
+ import type { EntityId } from '../ecs/entity';
8
+ import type { EcsWorld } from '../ecs/world';
9
+ import { mat2d, mat4 } from '../math/index';
10
+ import type { Model2DComponent } from '../realm2d/components';
11
+ import type { Model3DComponent } from '../realm3d/components';
12
+ import { SpatialDirtyFlags } from '../spatial/flags';
13
+ import { type AnchorComponent, createAnchor } from './component';
14
+ import type { AnchorConstraintCallback2D, AnchorConstraintCallback3D } from './strategy';
15
+
16
+ export type EntityLike = EntityId | { entityId: EntityId };
17
+
18
+ export type AnchorConstraint3D = string | AnchorConstraintCallback3D;
19
+ export type AnchorConstraint2D = string | AnchorConstraintCallback2D;
20
+
21
+ export function resolveEntityId(entity: EntityLike): EntityId {
22
+ return typeof entity === 'number' ? entity : entity.entityId;
23
+ }
24
+
25
+ export class HierarchyCoordinator<Kind extends '2d' | '3d' = '2d' | '3d'> {
26
+ private readonly _dependentsByTarget = new Map<EntityId, Set<EntityId>>();
27
+
28
+ constructor(
29
+ private readonly _world: EcsWorld,
30
+ private readonly _dimension: Kind
31
+ ) {}
32
+
33
+ /**
34
+ * Attaches an origin entity to a target reference entity with an optional constraint.
35
+ * Rejects self-anchoring and cycles proactively.
36
+ *
37
+ * @param origin The dependent entity to anchor.
38
+ * @param target The target entity to follow.
39
+ * @param constraint Optional strategy name ('static', 'translation') or in-place zero-GC callback.
40
+ */
41
+ attach(
42
+ origin: EntityLike,
43
+ target: EntityLike,
44
+ constraint?: Kind extends '3d' ? AnchorConstraint3D : AnchorConstraint2D
45
+ ): void {
46
+ const originId = resolveEntityId(origin);
47
+ const targetId = resolveEntityId(target);
48
+
49
+ if (originId === targetId) {
50
+ throw new Error(`Anchor error: Entity ${originId} cannot anchor to itself.`);
51
+ }
52
+
53
+ if (!this._world.isAlive(originId) || !this._world.isAlive(targetId)) {
54
+ throw new Error(
55
+ `Anchor error: Both origin (${originId}) and target (${targetId}) must be alive.`
56
+ );
57
+ }
58
+
59
+ // Proactive cycle detection
60
+ let curr: EntityId | undefined = targetId;
61
+ while (curr !== undefined) {
62
+ if (curr === originId) {
63
+ throw new Error(
64
+ `Anchor cycle detected: cannot attach entity ${originId} to ${targetId} as it forms a cycle.`
65
+ );
66
+ }
67
+ curr = this.getTarget(curr);
68
+ }
69
+
70
+ // Disconnect previous target from reverse index if re-anchoring
71
+ const prevTarget = this.getTarget(originId);
72
+ if (prevTarget !== undefined) {
73
+ this._dependentsByTarget.get(prevTarget)?.delete(originId);
74
+ }
75
+
76
+ let strategy = 'static';
77
+ let callback2d: AnchorConstraintCallback2D | undefined;
78
+ let callback3d: AnchorConstraintCallback3D | undefined;
79
+
80
+ if (typeof constraint === 'string') {
81
+ strategy = constraint;
82
+ } else if (typeof constraint === 'function') {
83
+ if (this._dimension === '3d') {
84
+ callback3d = constraint as AnchorConstraintCallback3D;
85
+ } else {
86
+ callback2d = constraint as AnchorConstraintCallback2D;
87
+ }
88
+ }
89
+
90
+ this._world.attachComponent(
91
+ originId,
92
+ createAnchor({
93
+ targetEntityId: targetId,
94
+ strategy,
95
+ callback2d,
96
+ callback3d
97
+ })
98
+ );
99
+
100
+ // Register in reverse index
101
+ let set = this._dependentsByTarget.get(targetId);
102
+ if (!set) {
103
+ set = new Set<EntityId>();
104
+ this._dependentsByTarget.set(targetId, set);
105
+ }
106
+ set.add(originId);
107
+
108
+ // Invalidate origin spatial state immediately
109
+ this._invalidateOrigin(originId);
110
+ }
111
+
112
+ /**
113
+ * Detaches an origin entity from its target anchor.
114
+ *
115
+ * @param origin The entity to detach.
116
+ * @param preserveWorldTransform When true (default), converts the last resolved worldMatrix
117
+ * into the entity's independent localMatrix so it does not pop back to the origin.
118
+ */
119
+ detach(origin: EntityLike, preserveWorldTransform = true): void {
120
+ const originId = resolveEntityId(origin);
121
+ const targetId = this.getTarget(originId);
122
+
123
+ if (targetId !== undefined) {
124
+ this._dependentsByTarget.get(targetId)?.delete(originId);
125
+ }
126
+
127
+ if (preserveWorldTransform) {
128
+ const model3d = this._world.getComponent<Model3DComponent>(originId, 'Model3D');
129
+ if (model3d) {
130
+ mat4.copy(model3d.localMatrix, model3d.worldMatrix);
131
+ model3d.flags |= SpatialDirtyFlags.LocalDirty;
132
+ }
133
+ const model2d = this._world.getComponent<Model2DComponent>(originId, 'Model2D');
134
+ if (model2d) {
135
+ mat2d.copy(model2d.localMatrix, model2d.worldMatrix);
136
+ model2d.flags |= SpatialDirtyFlags.LocalDirty;
137
+ }
138
+ }
139
+
140
+ this._world.detachComponent(originId, 'Anchor');
141
+ }
142
+
143
+ /**
144
+ * Checks whether an origin entity is currently attached to a target.
145
+ */
146
+ isAttached(origin: EntityLike): boolean {
147
+ const originId = resolveEntityId(origin);
148
+ return this._world.hasComponent(originId, 'Anchor');
149
+ }
150
+
151
+ /**
152
+ * Returns the target EntityId if attached, or undefined.
153
+ */
154
+ getTarget(origin: EntityLike): EntityId | undefined {
155
+ const originId = resolveEntityId(origin);
156
+ const anchor = this._world.getComponent<AnchorComponent>(originId, 'Anchor');
157
+ return anchor?.targetEntityId;
158
+ }
159
+
160
+ /**
161
+ * Returns all dependent entity IDs anchored to the given target entity in O(1) time.
162
+ */
163
+ getDependents(target: EntityLike): EntityId[] {
164
+ const targetId = resolveEntityId(target);
165
+ const set = this._dependentsByTarget.get(targetId);
166
+ return set ? Array.from(set) : [];
167
+ }
168
+
169
+ /**
170
+ * Cleans up all dependent relationships when a target entity is destroyed,
171
+ * safely detaching them and preserving their world transforms.
172
+ */
173
+ handleTargetDestroyed(target: EntityLike): void {
174
+ const targetId = resolveEntityId(target);
175
+ const dependents = this.getDependents(targetId);
176
+ for (const depId of dependents) {
177
+ this.detach(depId, true);
178
+ }
179
+ this._dependentsByTarget.delete(targetId);
180
+ }
181
+
182
+ private _invalidateOrigin(originId: EntityId): void {
183
+ const model3d = this._world.getComponent<Model3DComponent>(originId, 'Model3D');
184
+ if (model3d) {
185
+ model3d.flags |= SpatialDirtyFlags.LocalDirty;
186
+ }
187
+ const model2d = this._world.getComponent<Model2DComponent>(originId, 'Model2D');
188
+ if (model2d) {
189
+ model2d.flags |= SpatialDirtyFlags.LocalDirty;
190
+ }
191
+ }
192
+ }