@vgai/engine 0.4.1 → 0.5.0-canary.20260719.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.
Files changed (103) hide show
  1. package/README.md +48 -15
  2. package/package.json +11 -25
  3. package/schemas/engine-capabilities.json +10 -10
  4. package/schemas/entity2d.schema.json +468 -0
  5. package/schemas/mat.schema.json +2 -33
  6. package/schemas/prefab.schema.json +16 -172
  7. package/schemas/scn2d.schema.json +42 -23
  8. package/schemas/vgai-game.schema.json +34 -0
  9. package/schemas/vscn.schema.json +16 -172
  10. package/src/adapter/authoring.ts +152 -2
  11. package/src/adapter/colyseus-networking-adapter.ts +35 -1
  12. package/src/adapter/first-party-systems.ts +7 -1
  13. package/src/adapter/game-adapter.ts +13 -0
  14. package/src/adapter/index.ts +25 -0
  15. package/src/adapter/rapier-physics-adapter.ts +55 -2
  16. package/src/adapter/system-adapter.ts +249 -2
  17. package/src/adapter/vgai-scene-game-adapter.ts +149 -25
  18. package/src/ai/navigation.ts +28 -0
  19. package/src/animation/clip-map.ts +1 -8
  20. package/src/animation/theatre-director.ts +50 -0
  21. package/src/animation/xstate-animation-binding.ts +6 -0
  22. package/src/audio/audio-introspection.ts +290 -0
  23. package/src/audio/tone-context.ts +46 -0
  24. package/src/dev/chrome-trace.ts +153 -0
  25. package/src/dev/performance-profiler.ts +93 -6
  26. package/src/dev/render-debug-adapter.ts +199 -0
  27. package/src/dev/render-memory.ts +243 -0
  28. package/src/dev/webgl-frame-capture.ts +424 -0
  29. package/src/ecs/component-manager.ts +43 -10
  30. package/src/ecs/game-component.ts +39 -10
  31. package/src/input/input-manager.ts +24 -19
  32. package/src/input/input-types.ts +1 -1
  33. package/src/loader.ts +7 -0
  34. package/src/manifest/load.ts +14 -0
  35. package/src/manifest/schema.ts +65 -0
  36. package/src/react/game-state.tsx +1 -1
  37. package/src/render/render-batch-system.ts +26 -12
  38. package/src/render/spark-renderer-lifecycle.ts +64 -0
  39. package/src/runtime/create-runtime.ts +33 -5
  40. package/src/runtime/debug-bridge.ts +5 -5
  41. package/src/runtime/game.ts +102 -20
  42. package/src/runtime/mount-manifest.ts +1 -1
  43. package/src/runtime/render-control.ts +121 -0
  44. package/src/runtime/types.ts +1 -1
  45. package/src/scene/asset-loaders.ts +77 -3
  46. package/src/scene/instance-mesh.ts +25 -0
  47. package/src/scene/material-factory.ts +4 -15
  48. package/src/scene/mesh-shadow.ts +18 -0
  49. package/src/scene/particles-factory.ts +59 -0
  50. package/src/scene/scene-loader.ts +41 -16
  51. package/src/scene/schema/instances.ts +1 -2
  52. package/src/scene/schema/material.ts +83 -94
  53. package/src/scene/schema/mesh.ts +76 -90
  54. package/src/scene/schema/scene-file.ts +1 -2
  55. package/src/scene/user-data.ts +30 -14
  56. package/src/setup/setup-renderer.ts +6 -1
  57. package/src/world2d/asset-paths2d.ts +44 -0
  58. package/src/world2d/collision-2d.ts +7 -14
  59. package/src/world2d/entity2d-asset.ts +22 -0
  60. package/src/world2d/index.ts +27 -2
  61. package/src/world2d/physics2d-transform.ts +173 -0
  62. package/src/world2d/physics2d-units.ts +10 -0
  63. package/src/world2d/pixi-game-adapter.ts +148 -36
  64. package/src/world2d/scene2d-identity.ts +49 -0
  65. package/src/world2d/scene2d-loader.ts +243 -119
  66. package/src/world2d/schema/entity2d.ts +51 -33
  67. package/src/world2d/schema/physics2d.ts +14 -3
  68. package/src/world2d/schema/sprite.ts +32 -4
  69. package/src/world2d/schema/tilemap.ts +26 -9
  70. package/src/world2d/transform-writer-2d.ts +29 -11
  71. package/src/world2d/types.ts +21 -8
  72. package/src/world3d-react/behavior.tsx +138 -0
  73. package/src/world3d-react/engine-bridge.ts +48 -0
  74. package/src/world3d-react/index.ts +44 -0
  75. package/src/world3d-react/r3f-adapter.tsx +303 -0
  76. package/src/world3d-react/world-context.ts +294 -0
  77. package/vendor/realism-effects/LICENSE.md +21 -0
  78. package/vendor/realism-effects/UPSTREAM.md +19 -0
  79. package/vendor/realism-effects/dist/index.cjs +3447 -0
  80. package/vendor/realism-effects/dist/index.d.ts +59 -0
  81. package/vendor/realism-effects/dist/index.js +3434 -0
  82. package/vendor/realism-effects/package.json +23 -0
  83. package/src/character/cloth-sim.ts +0 -533
  84. package/src/character/spring-chain.ts +0 -307
  85. package/src/humanoid/body.ts +0 -663
  86. package/src/humanoid/clips.ts +0 -149
  87. package/src/humanoid/compose.ts +0 -209
  88. package/src/humanoid/generate.ts +0 -189
  89. package/src/humanoid/index.ts +0 -36
  90. package/src/humanoid/schema.ts +0 -108
  91. package/src/humanoid/skeleton.ts +0 -345
  92. package/src/react/humanoid-bake.document.tsx +0 -337
  93. package/src/scene/geometries/index.ts +0 -7
  94. package/src/scene/geometries/terrain.ts +0 -42
  95. package/src/scene/geometry-registry.ts +0 -42
  96. package/src/scene/instance-registry.ts +0 -84
  97. package/src/scene/instancers/grid.ts +0 -38
  98. package/src/scene/instancers/index.ts +0 -7
  99. package/src/scene/material-registry.ts +0 -73
  100. package/src/scene/materials/index.ts +0 -7
  101. package/src/scene/materials/water.ts +0 -56
  102. package/src/world2d/components-2d.ts +0 -86
  103. package/tools/humanoid-bake.tool.ts +0 -274
@@ -28,11 +28,15 @@
28
28
  * `entity.components`.
29
29
  *
30
30
  * Runtime gameplay metadata (set by the scene loader):
31
+ * - `forward` — `[x,y,z]` model-space visual forward exported as
32
+ * ordinary glTF extras for asset-facing validation.
31
33
  * - `navRole` — `'walkable' | 'obstacle'` navmesh role; collected at runtime.
32
34
  * - `pivot` — `[x,y,z]` local-space pivot for rotate/scale-around-pivot.
33
35
  * - `splineCurve` — resolved THREE curve built from the entity's spline.
34
36
  * - `_camera` — THREE.Camera owned by a camera entity (collected on load).
35
37
  * - `_particleSystem` — three.quarks ParticleSystem (scene-sync registration + census).
38
+ * - `gaussianSplat` — native Spark splat metadata used for renderer discovery,
39
+ * inspector facts, bounds, and deterministic disposal.
36
40
  *
37
41
  * Animation (load-bearing — ED5 disposal contract):
38
42
  * - `_animMixer` — THREE.AnimationMixer driving this subtree's clips.
@@ -53,13 +57,6 @@
53
57
  * - `__shadeOrig` — original material(s) stashed before a shading override.
54
58
  * - `__shadeUnlit` — generated unlit material(s) for the unlit view mode.
55
59
  *
56
- * Custom-shader materials (see `material-registry.ts`):
57
- * - `__matDef` — name of the registered custom-material definition.
58
- * - `__uniforms` — authored uniform values passed to the def's update().
59
- *
60
- * Instancing (see `instance-registry.ts`):
61
- * - `__instancer` — name of the registered instancer that built the mesh.
62
- *
63
60
  * Unmodified-game ingestion (editor-only, see `editor/src/ingest/`):
64
61
  * - `__ingest` — `true` on every Object3D minted by ingestion (vs the
65
62
  * first-party scene loader); routes edits to live
@@ -98,11 +95,27 @@ export type EditorHelperType =
98
95
  | 'cameras'
99
96
  | 'audio'
100
97
  | 'colliders'
98
+ // Joint anchor/axis/limit gizmos (W2a) — distinct from 'colliders' so the
99
+ // viewport's per-type visibility filter can toggle them independently.
100
+ | 'joints'
101
+ // LOD distance rings (W2b) — one ring per `mesh.lod` level, radius = the
102
+ // level's camera-distance threshold in world units.
103
+ | 'lod'
101
104
  | 'splines'
105
+ // 'particles' tags the LIVE three.quarks emitter Object3D itself (so the
106
+ // editor's helper machinery skips disposing it); 'particle-shape' tags the
107
+ // editor's emitter-shape WIREFRAME (W1b) — they must stay distinct: sweeping
108
+ // "particles"-tagged children would detach the running emitter, and quarks
109
+ // self-disposes any system whose emitter leaves the scene.
102
110
  | 'particles'
111
+ | 'particle-shape'
103
112
  | 'pivot'
104
113
  | 'navmesh'
105
- | 'skeletons';
114
+ | 'skeletons'
115
+ // Per-component viewport gizmo contributions (W3a extension contract,
116
+ // critique O11): objects a project GameComponent's `static editorGizmo`
117
+ // hook draws for entities carrying that component.
118
+ | 'components';
106
119
 
107
120
  /**
108
121
  * Maps each canonical accessor name to its value type. This is the single
@@ -111,6 +124,7 @@ export type EditorHelperType =
111
124
  export interface UserDataSchema {
112
125
  entity: SceneEntity;
113
126
  entityId: string;
127
+ forward: [number, number, number];
114
128
  tags: string[];
115
129
  components: Record<string, unknown> | string[];
116
130
  navRole: 'walkable' | 'obstacle';
@@ -119,6 +133,7 @@ export interface UserDataSchema {
119
133
  splineControlPoint: number;
120
134
  _camera: THREE.Camera;
121
135
  _particleSystem: ParticleSystem;
136
+ gaussianSplat: { src: string; numSplats: number };
122
137
  _animMixer: THREE.AnimationMixer;
123
138
  _animClips: Map<string, THREE.AnimationClip>;
124
139
  _availableClips: string[];
@@ -126,9 +141,6 @@ export interface UserDataSchema {
126
141
  __sharedGeometry: boolean;
127
142
  __shadeOrig: THREE.Material | THREE.Material[];
128
143
  __shadeUnlit: THREE.Material[];
129
- __matDef: string;
130
- __uniforms: Record<string, unknown>;
131
- __instancer: string;
132
144
  __ingest: boolean;
133
145
  __ingestNextId: number;
134
146
  engineInternal: boolean;
@@ -140,6 +152,10 @@ export interface UserDataSchema {
140
152
  skeletonEnabled: boolean;
141
153
  envObject: boolean;
142
154
  vcDirIdx: number;
155
+ /** W1a navmesh path-probe preview polyline: the routed waypoints, mirrored
156
+ * onto the preview line Object3D so e2e can read the path back from the
157
+ * scene graph (`EditorViewport.setNavPathPreview`). */
158
+ navPathPoints: { x: number; y: number; z: number }[];
143
159
  }
144
160
 
145
161
  /** Any key in the typed registry. */
@@ -153,6 +169,7 @@ export type UserDataKey = keyof UserDataSchema;
153
169
  export const UserDataKeys = {
154
170
  entity: 'entity',
155
171
  entityId: 'entityId',
172
+ forward: 'forward',
156
173
  tags: 'tags',
157
174
  components: 'components',
158
175
  navRole: 'navRole',
@@ -161,6 +178,7 @@ export const UserDataKeys = {
161
178
  splineControlPoint: 'splineControlPoint',
162
179
  _camera: '_camera',
163
180
  _particleSystem: '_particleSystem',
181
+ gaussianSplat: 'gaussianSplat',
164
182
  _animMixer: '_animMixer',
165
183
  _animClips: '_animClips',
166
184
  _availableClips: '_availableClips',
@@ -168,9 +186,6 @@ export const UserDataKeys = {
168
186
  __sharedGeometry: '__sharedGeometry',
169
187
  __shadeOrig: '__shadeOrig',
170
188
  __shadeUnlit: '__shadeUnlit',
171
- __matDef: '__matDef',
172
- __uniforms: '__uniforms',
173
- __instancer: '__instancer',
174
189
  __ingest: '__ingest',
175
190
  __ingestNextId: '__ingestNextId',
176
191
  engineInternal: 'engineInternal',
@@ -182,6 +197,7 @@ export const UserDataKeys = {
182
197
  skeletonEnabled: 'skeletonEnabled',
183
198
  envObject: 'envObject',
184
199
  vcDirIdx: 'vcDirIdx',
200
+ navPathPoints: 'navPathPoints',
185
201
  } as const satisfies Record<UserDataKey, string>;
186
202
 
187
203
  /**
@@ -28,8 +28,13 @@ import {
28
28
  TiltShiftEffect,
29
29
  VignetteEffect,
30
30
  } from 'postprocessing';
31
- import { MotionBlurEffect, SSGIEffect, SSREffect, VelocityDepthNormalPass } from 'realism-effects';
32
31
  import * as THREE from 'three';
32
+ import {
33
+ MotionBlurEffect,
34
+ SSGIEffect,
35
+ SSREffect,
36
+ VelocityDepthNormalPass,
37
+ } from '../../vendor/realism-effects/dist/index.js';
33
38
  import { type RenderScope, resolveRenderSettings } from '../render/render-settings';
34
39
  import { DEFAULTS } from '../scene/defaults';
35
40
  import type { SceneEnvironment } from '../scene/scene-types';
@@ -0,0 +1,44 @@
1
+ import type { Entity2D, Entity2DAsset, Scene2DFile } from './schema/entity2d';
2
+
3
+ function visit(entities: Entity2D[], apply: (entity: Entity2D) => void): void {
4
+ for (const entity of entities) {
5
+ apply(entity);
6
+ if (entity.children) visit(entity.children, apply);
7
+ }
8
+ }
9
+
10
+ export function collectEntity2DAssetPaths(entities: Entity2D[]): Set<string> {
11
+ const paths = new Set<string>();
12
+ visit(entities, (entity) => {
13
+ if (entity.sprite?.texture) paths.add(entity.sprite.texture);
14
+ if (entity.tilemap?.texture) paths.add(entity.tilemap.texture);
15
+ });
16
+ return paths;
17
+ }
18
+
19
+ function rename(entities: Entity2D[], oldPath: string, newPath: string): void {
20
+ visit(entities, (entity) => {
21
+ if (entity.sprite?.texture === oldPath) entity.sprite.texture = newPath;
22
+ if (entity.tilemap?.texture === oldPath) entity.tilemap.texture = newPath;
23
+ });
24
+ }
25
+
26
+ export function applyScene2DAssetRename(
27
+ document: Scene2DFile,
28
+ oldPath: string,
29
+ newPath: string,
30
+ ): Scene2DFile {
31
+ const cloned = structuredClone(document);
32
+ rename(cloned.entities, oldPath, newPath);
33
+ return cloned;
34
+ }
35
+
36
+ export function applyEntity2DAssetRename(
37
+ asset: Entity2DAsset,
38
+ oldPath: string,
39
+ newPath: string,
40
+ ): Entity2DAsset {
41
+ const cloned = structuredClone(asset);
42
+ rename([cloned.root], oldPath, newPath);
43
+ return cloned;
44
+ }
@@ -1,7 +1,7 @@
1
1
  import type RAPIER from '@dimforge/rapier2d-compat';
2
2
  import type { NodeOf } from '../ecs/game-component';
3
- import type { GameContext } from '../runtime/types';
4
3
  import type { Physics2DRegistry } from './physics2d-registry';
4
+ import type { World2DContext } from './types';
5
5
 
6
6
  export type Collision2DHandler = (handleA: number, handleB: number, started: boolean) => void;
7
7
 
@@ -52,8 +52,8 @@ export type Collision2DSystem = ReturnType<typeof createCollision2DSystem>;
52
52
  */
53
53
  export interface TriggerDispatchComponents {
54
54
  getComponents(node: NodeOf<'pixijs'>): ReadonlyArray<{
55
- onTriggerEnter?(other: NodeOf<'pixijs'>, ctx: GameContext): void;
56
- onTriggerExit?(other: NodeOf<'pixijs'>, ctx: GameContext): void;
55
+ onTriggerEnter?(other: NodeOf<'pixijs'>, ctx: World2DContext): void;
56
+ onTriggerExit?(other: NodeOf<'pixijs'>, ctx: World2DContext): void;
57
57
  }>;
58
58
  }
59
59
 
@@ -62,21 +62,14 @@ export interface TriggerDispatchComponents {
62
62
  * the GameComponents of the two involved display objects — the 2D analog of
63
63
  * `createTriggerDispatch`. Only sensor-involved collisions dispatch triggers.
64
64
  *
65
- * `ctx` is typed `GameContext` (the unified manager's typing) — production
66
- * (`pixi-game-adapter.ts`) casts its real `World2DContext` to `GameContext`
67
- * before passing it here; individual `GameComponent<'pixijs'>` subclasses
68
- * that need real 2D fields off `ctx` widen their own lifecycle-method
69
- * signature to `GameContext | World2DContext` and narrow back down (see
70
- * `Camera2DFollow.update` in `./components-2d.ts` — the worked example of
71
- * the pattern since the world2d-demo silo and its `first-party-scene.ts`
72
- * were deleted, T6.1 slice 4). This function only ever forwards `ctx` — it
73
- * never reads a field off it itself.
65
+ * The callback receives the native `World2DContext`; Pixi components do not
66
+ * widen or cast their lifecycle context to reach their own first-party APIs.
74
67
  */
75
68
  export function createTriggerDispatch2D(
76
69
  physics: Physics2DRegistry,
77
70
  components: TriggerDispatchComponents,
78
71
  rapierWorld: RAPIER.World,
79
- ctx: GameContext,
72
+ ctx: World2DContext,
80
73
  ): Collision2DHandler {
81
74
  return (handleA, handleB, started) => {
82
75
  const colA = rapierWorld.getCollider(handleA);
@@ -97,7 +90,7 @@ function notify(
97
90
  self: NodeOf<'pixijs'>,
98
91
  other: NodeOf<'pixijs'>,
99
92
  started: boolean,
100
- ctx: GameContext,
93
+ ctx: World2DContext,
101
94
  ): void {
102
95
  for (const inst of components.getComponents(self)) {
103
96
  if (started) inst.onTriggerEnter?.(other, ctx);
@@ -0,0 +1,22 @@
1
+ import { SceneParseError } from '../scene/parse';
2
+ import { type Entity2D, type Entity2DAsset, Entity2DAssetSchema } from './schema/entity2d';
3
+
4
+ /** Parse fetched reusable 2D entity bytes through the same named-error
5
+ * convention as every other authored project asset. */
6
+ export function parseEntity2DAsset(value: unknown, sourceName?: string): Entity2DAsset {
7
+ const result = Entity2DAssetSchema.safeParse(value);
8
+ if (!result.success) throw new SceneParseError(result.error.issues, sourceName);
9
+ return result.data;
10
+ }
11
+
12
+ /** Copy a reusable subtree into a scene. Every copy receives fresh authored
13
+ * identities so repeated drops cannot alias hierarchy/history targets. */
14
+ export function instantiateEntity2DAsset(asset: Entity2DAsset): Entity2D {
15
+ const root = structuredClone(asset.root);
16
+ const assignFreshIds = (entity: Entity2D): void => {
17
+ entity.id = crypto.randomUUID();
18
+ for (const child of entity.children ?? []) assignFreshIds(child);
19
+ };
20
+ assignFreshIds(root);
21
+ return root;
22
+ }
@@ -13,6 +13,11 @@
13
13
  * See docs/PIXI-WORLD2D-MAXIMAL-SPEC.json (FROZEN).
14
14
  */
15
15
 
16
+ export {
17
+ applyEntity2DAssetRename,
18
+ applyScene2DAssetRename,
19
+ collectEntity2DAssetPaths,
20
+ } from './asset-paths2d';
16
21
  export {
17
22
  AuthoringAdapter2D,
18
23
  type EditorNode2D,
@@ -27,7 +32,7 @@ export {
27
32
  createCollision2DSystem,
28
33
  createTriggerDispatch2D,
29
34
  } from './collision-2d';
30
- export { Camera2DFollow, Clickable2D } from './components-2d';
35
+ export { instantiateEntity2DAsset, parseEntity2DAsset } from './entity2d-asset';
31
36
  export {
32
37
  type EmbedMount2D,
33
38
  type IframeReachableMount2D,
@@ -40,6 +45,19 @@ export {
40
45
  type Physics2DRefs,
41
46
  type Physics2DRegistry,
42
47
  } from './physics2d-registry';
48
+ export {
49
+ assertMovingPhysicsParent2D,
50
+ computePhysicsPose2D,
51
+ type PhysicsPose2D,
52
+ relativeTransform2D,
53
+ type ScaledCollider2D,
54
+ scaleCollider2D,
55
+ } from './physics2d-transform';
56
+ export {
57
+ DEFAULT_WORLD2D_GRAVITY_METERS_PER_SECOND_SQUARED,
58
+ DEFAULT_WORLD2D_PIXELS_PER_METER,
59
+ defaultWorld2DGravityY,
60
+ } from './physics2d-units';
43
61
  export {
44
62
  createWorld2DRuntime,
45
63
  type MountedGame2D,
@@ -55,7 +73,14 @@ export {
55
73
  installSceneCapture2D,
56
74
  type SceneCapture2DHandle,
57
75
  } from './scene-capture-2d';
58
- export { loadScene2DFromData, type Scene2DInstance } from './scene2d-loader';
76
+ export { materializeScene2DEntityIds, resolveScene2DEntityIds } from './scene2d-identity';
77
+ export {
78
+ applySceneEntityTransform2D,
79
+ captureSceneSpriteBaseScale2D,
80
+ getSceneTilemapTextures2D,
81
+ loadScene2DFromData,
82
+ type Scene2DInstance,
83
+ } from './scene2d-loader';
59
84
  export * from './schema/entity2d';
60
85
  export * from './schema/physics2d';
61
86
  export * from './schema/sprite';
@@ -0,0 +1,173 @@
1
+ import { type Container, Matrix } from 'pixi.js';
2
+ import type { Entity2D } from './schema/entity2d';
3
+ import type { SceneCollider2D } from './schema/physics2d';
4
+
5
+ const EPSILON = 1e-6;
6
+
7
+ export interface PhysicsPose2D {
8
+ matrix: Matrix;
9
+ position: [number, number];
10
+ rotation: number;
11
+ scale: [number, number];
12
+ }
13
+
14
+ export type ScaledCollider2D =
15
+ | { shape: 'cuboid'; halfExtents: [number, number] }
16
+ | { shape: 'ball'; radius: number }
17
+ | { shape: 'capsule'; halfHeight: number; radius: number };
18
+
19
+ function isFiniteMatrix(matrix: Matrix): boolean {
20
+ return [matrix.a, matrix.b, matrix.c, matrix.d, matrix.tx, matrix.ty].every(Number.isFinite);
21
+ }
22
+
23
+ function linearMetrics(matrix: Matrix): {
24
+ determinant: number;
25
+ orthogonality: number;
26
+ scaleX: number;
27
+ scaleY: number;
28
+ } {
29
+ return {
30
+ determinant: matrix.a * matrix.d - matrix.b * matrix.c,
31
+ orthogonality: Math.abs(matrix.a * matrix.c + matrix.b * matrix.d),
32
+ scaleX: Math.hypot(matrix.a, matrix.b),
33
+ scaleY: Math.hypot(matrix.c, matrix.d),
34
+ };
35
+ }
36
+
37
+ function assertRigidRepresentable(matrix: Matrix, label: string): [number, number] {
38
+ if (!isFiniteMatrix(matrix)) {
39
+ throw new Error(`world2d physics entity "${label}" has a non-finite composed transform`);
40
+ }
41
+ const { determinant, orthogonality, scaleX, scaleY } = linearMetrics(matrix);
42
+ if (scaleX <= EPSILON || scaleY <= EPSILON || Math.abs(determinant) <= EPSILON) {
43
+ throw new Error(`world2d physics entity "${label}" has a singular composed transform`);
44
+ }
45
+ if (orthogonality > EPSILON * scaleX * scaleY) {
46
+ throw new Error(
47
+ `world2d physics entity "${label}" has a sheared composed transform; ` +
48
+ 'Rapier rigid bodies cannot represent shear',
49
+ );
50
+ }
51
+ return [scaleX, scaleY];
52
+ }
53
+
54
+ /** Matrix from `container` local coordinates into `root` coordinates. The
55
+ * root's own camera/pan transform is deliberately cancelled: Rapier lives in
56
+ * the world2d root's logical pixel space, not application/screen space. */
57
+ export function relativeTransform2D(container: Container, root: Container): Matrix {
58
+ let ancestor: Container | null = container;
59
+ while (ancestor && ancestor !== root) ancestor = ancestor.parent;
60
+ if (ancestor !== root) {
61
+ throw new Error('world2d physics object is not a descendant of the scene root');
62
+ }
63
+
64
+ const rootGlobal = root.getGlobalTransform();
65
+ const global = container.getGlobalTransform();
66
+ const origin = rootGlobal.applyInverse(global.apply({ x: 0, y: 0 }));
67
+ const unitX = rootGlobal.applyInverse(global.apply({ x: 1, y: 0 }));
68
+ const unitY = rootGlobal.applyInverse(global.apply({ x: 0, y: 1 }));
69
+ return new Matrix(
70
+ unitX.x - origin.x,
71
+ unitX.y - origin.y,
72
+ unitY.x - origin.x,
73
+ unitY.y - origin.y,
74
+ origin.x,
75
+ origin.y,
76
+ );
77
+ }
78
+
79
+ /** Moving Rapier bodies may rotate after spawn. Their Pixi parent chain must
80
+ * therefore be conformal (uniform scale plus rotation/reflection); otherwise a
81
+ * later rigid-body rotation would become shear when converted back to the
82
+ * parent-local display transform. Fixed bodies can retain a non-uniform parent
83
+ * because their authored relative rotation does not change. */
84
+ export function assertMovingPhysicsParent2D(
85
+ display: Container,
86
+ root: Container,
87
+ label: string,
88
+ ): void {
89
+ const parent = display.parent;
90
+ if (!parent) throw new Error(`world2d physics entity "${label}" is not mounted`);
91
+ if (parent === root) return;
92
+ const parentMatrix = relativeTransform2D(parent, root);
93
+ const [scaleX, scaleY] = assertRigidRepresentable(parentMatrix, label);
94
+ if (Math.abs(scaleX - scaleY) > EPSILON * Math.max(scaleX, scaleY)) {
95
+ throw new Error(
96
+ `world2d moving physics entity "${label}" has a non-conformal parent transform ` +
97
+ `(composed scale [${scaleX}, ${scaleY}]); rotating a Rapier body beneath ` +
98
+ 'non-uniform parent scale would shear the Pixi visual',
99
+ );
100
+ }
101
+ }
102
+
103
+ /** Compose the transform Rapier actually owns for one authored entity.
104
+ *
105
+ * The parent uses its real Pixi transform so nested visuals and physics agree.
106
+ * The entity's own linear transform comes from `transform2d`, not from the
107
+ * display object's `scale`: Sprite width/height are implemented internally by
108
+ * Pixi as scale, but collider dimensions are already authored in pixels and
109
+ * must not be multiplied by that implementation detail. The body origin is
110
+ * the entity position (the world position of Pixi's pivot), matching the
111
+ * existing world2d physics contract. */
112
+ export function computePhysicsPose2D(
113
+ entity: Entity2D,
114
+ display: Container,
115
+ root: Container,
116
+ label = entity.name,
117
+ ): PhysicsPose2D {
118
+ const parent = display.parent;
119
+ if (!parent) throw new Error(`world2d physics entity "${label}" is not mounted`);
120
+ const parentMatrix = parent === root ? new Matrix() : relativeTransform2D(parent, root);
121
+ const transform = entity.transform2d;
122
+ const position = transform?.position ?? [0, 0];
123
+ const scale = transform?.scale ?? [1, 1];
124
+ const local = new Matrix().setTransform(
125
+ position[0],
126
+ position[1],
127
+ 0,
128
+ 0,
129
+ scale[0],
130
+ scale[1],
131
+ transform?.rotation ?? 0,
132
+ 0,
133
+ 0,
134
+ );
135
+ const matrix = new Matrix().appendFrom(local, parentMatrix);
136
+ const [scaleX, scaleY] = assertRigidRepresentable(matrix, label);
137
+
138
+ return {
139
+ matrix,
140
+ position: [matrix.tx, matrix.ty],
141
+ rotation: Math.atan2(matrix.b, matrix.a),
142
+ scale: [scaleX, scaleY],
143
+ };
144
+ }
145
+
146
+ /** Bake the composed visual scale into the native Rapier collider dimensions.
147
+ * Round shapes require uniform scale because Rapier has no ellipse collider. */
148
+ export function scaleCollider2D(
149
+ collider: SceneCollider2D,
150
+ scale: readonly [number, number],
151
+ label: string,
152
+ ): ScaledCollider2D {
153
+ const [scaleX, scaleY] = scale;
154
+ if (collider.shape === 'cuboid') {
155
+ return {
156
+ shape: 'cuboid',
157
+ halfExtents: [collider.halfExtents[0] * scaleX, collider.halfExtents[1] * scaleY],
158
+ };
159
+ }
160
+ if (Math.abs(scaleX - scaleY) > EPSILON * Math.max(scaleX, scaleY)) {
161
+ throw new Error(
162
+ `world2d physics entity "${label}" uses non-uniform composed scale ` +
163
+ `[${scaleX}, ${scaleY}] with a ${collider.shape} collider; Rapier cannot represent ` +
164
+ 'an elliptical round collider',
165
+ );
166
+ }
167
+ if (collider.shape === 'ball') return { shape: 'ball', radius: collider.radius * scaleX };
168
+ return {
169
+ shape: 'capsule',
170
+ halfHeight: collider.halfHeight * scaleY,
171
+ radius: collider.radius * scaleX,
172
+ };
173
+ }
@@ -0,0 +1,10 @@
1
+ /** Canonical Pixi-world scale: 100 authored pixels represent one physics meter. */
2
+ export const DEFAULT_WORLD2D_PIXELS_PER_METER = 100;
3
+
4
+ /** Earth-like downward acceleration used when a 2D scene omits gravity. */
5
+ export const DEFAULT_WORLD2D_GRAVITY_METERS_PER_SECOND_SQUARED = 9.8;
6
+
7
+ /** Derive the pixel-space default while preserving friendly decimal values. */
8
+ export function defaultWorld2DGravityY(pixelsPerMeter: number): number {
9
+ return (DEFAULT_WORLD2D_GRAVITY_METERS_PER_SECOND_SQUARED * 10 * pixelsPerMeter) / 10;
10
+ }