@waica/engine 0.24.0 → 0.26.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 (129) hide show
  1. package/README.md +79 -1
  2. package/dist/anchored-pieces.d.ts +3 -2
  3. package/dist/anchored-pieces.js +12 -8
  4. package/dist/archetype.d.ts +2 -2
  5. package/dist/assets/asset-loader.d.ts +28 -5
  6. package/dist/assets/asset-loader.js +63 -7
  7. package/dist/assets/model-backend.d.ts +21 -0
  8. package/dist/assets/model-backend.js +21 -0
  9. package/dist/assets/model-cache.d.ts +47 -0
  10. package/dist/assets/model-cache.js +117 -0
  11. package/dist/audio/audio-subsystem.d.ts +6 -1
  12. package/dist/audio/audio-subsystem.js +8 -15
  13. package/dist/audio/placement.d.ts +41 -0
  14. package/dist/audio/placement.js +21 -0
  15. package/dist/audio/spatial.d.ts +20 -0
  16. package/dist/audio/spatial.js +26 -0
  17. package/dist/audio/types.d.ts +7 -1
  18. package/dist/camera-projection.d.ts +38 -0
  19. package/dist/camera-projection.js +44 -0
  20. package/dist/camera-rig.d.ts +48 -0
  21. package/dist/camera-rig.js +98 -0
  22. package/dist/camera.d.ts +20 -3
  23. package/dist/camera.js +3 -1
  24. package/dist/component.d.ts +38 -2
  25. package/dist/component.js +3 -0
  26. package/dist/components/animated-sprite.d.ts +8 -1
  27. package/dist/components/animated-sprite.js +16 -1
  28. package/dist/components/collider.d.ts +89 -0
  29. package/dist/components/collider.js +57 -0
  30. package/dist/components/dynamic-body.d.ts +2 -1
  31. package/dist/components/dynamic-body.js +1 -0
  32. package/dist/components/hitbox.d.ts +2 -1
  33. package/dist/components/hitbox.js +1 -0
  34. package/dist/components/light.d.ts +69 -0
  35. package/dist/components/light.js +60 -0
  36. package/dist/components/model.d.ts +63 -0
  37. package/dist/components/model.js +117 -0
  38. package/dist/components/particle-emitter.d.ts +8 -1
  39. package/dist/components/particle-emitter.js +14 -0
  40. package/dist/components/point-light.d.ts +75 -0
  41. package/dist/components/point-light.js +102 -0
  42. package/dist/components/rigid-body.d.ts +114 -0
  43. package/dist/components/rigid-body.js +83 -0
  44. package/dist/components/solid.d.ts +2 -1
  45. package/dist/components/solid.js +1 -0
  46. package/dist/components/sprite.d.ts +8 -1
  47. package/dist/components/sprite.js +16 -1
  48. package/dist/components/sun.d.ts +46 -0
  49. package/dist/components/sun.js +74 -0
  50. package/dist/components/tilemap.d.ts +2 -1
  51. package/dist/components/tilemap.js +7 -0
  52. package/dist/entity-transform.d.ts +18 -0
  53. package/dist/entity-transform.js +17 -0
  54. package/dist/frame-quad.d.ts +21 -0
  55. package/dist/frame-quad.js +50 -0
  56. package/dist/game.d.ts +59 -15
  57. package/dist/game.js +100 -75
  58. package/dist/index.d.ts +39 -4
  59. package/dist/index.js +18 -1
  60. package/dist/light-field.d.ts +78 -0
  61. package/dist/light-field.js +145 -0
  62. package/dist/light-map-material.d.ts +40 -0
  63. package/dist/light-map-material.js +203 -0
  64. package/dist/light-map.d.ts +46 -0
  65. package/dist/light-map.js +132 -0
  66. package/dist/lit-frame.d.ts +48 -0
  67. package/dist/lit-frame.js +174 -0
  68. package/dist/occluder-grid.d.ts +19 -0
  69. package/dist/occluder-grid.js +50 -0
  70. package/dist/particle-batch.js +3 -2
  71. package/dist/physics-3d/body-contacts.d.ts +10 -0
  72. package/dist/physics-3d/body-contacts.js +59 -0
  73. package/dist/physics-3d/body-sync.d.ts +52 -0
  74. package/dist/physics-3d/body-sync.js +153 -0
  75. package/dist/physics-3d/character-controller.d.ts +44 -0
  76. package/dist/physics-3d/character-controller.js +126 -0
  77. package/dist/physics-3d/physics-host.d.ts +86 -0
  78. package/dist/physics-3d/physics-host.js +174 -0
  79. package/dist/physics-3d/physics-world.d.ts +65 -0
  80. package/dist/physics-3d/physics-world.js +158 -0
  81. package/dist/physics-3d/rapier-module.d.ts +17 -0
  82. package/dist/physics-3d/rapier-module.js +19 -0
  83. package/dist/physics-3d/sensor-dispatch.d.ts +10 -0
  84. package/dist/physics-3d/sensor-dispatch.js +77 -0
  85. package/dist/physics-3d/world-queries.d.ts +39 -0
  86. package/dist/physics-3d/world-queries.js +63 -0
  87. package/dist/pointer-raycast.d.ts +17 -0
  88. package/dist/pointer-raycast.js +48 -0
  89. package/dist/pointer.d.ts +27 -6
  90. package/dist/pointer.js +43 -17
  91. package/dist/post-effects.d.ts +33 -0
  92. package/dist/post-effects.js +48 -0
  93. package/dist/post-pass.d.ts +23 -0
  94. package/dist/post-pass.js +93 -0
  95. package/dist/render-layers.d.ts +18 -0
  96. package/dist/render-layers.js +19 -0
  97. package/dist/render-output.d.ts +7 -0
  98. package/dist/render-output.js +17 -1
  99. package/dist/runtime-bridge.d.ts +1 -1
  100. package/dist/runtime-inspection.d.ts +14 -0
  101. package/dist/runtime-inspection.js +12 -2
  102. package/dist/runtime-lighting-snapshot.d.ts +67 -0
  103. package/dist/runtime-lighting-snapshot.js +77 -0
  104. package/dist/runtime-physics-snapshot.d.ts +41 -0
  105. package/dist/runtime-physics-snapshot.js +45 -0
  106. package/dist/runtime-view-snapshot.d.ts +25 -0
  107. package/dist/runtime-view-snapshot.js +30 -0
  108. package/dist/scene-camera-3d.d.ts +66 -0
  109. package/dist/scene-camera-3d.js +117 -0
  110. package/dist/scene-lighting.d.ts +52 -0
  111. package/dist/scene-lighting.js +107 -0
  112. package/dist/scene-lights-3d.d.ts +17 -0
  113. package/dist/scene-lights-3d.js +27 -0
  114. package/dist/scene-render-options.d.ts +51 -0
  115. package/dist/scene-render-options.js +126 -0
  116. package/dist/scene-simulation.d.ts +22 -0
  117. package/dist/scene-simulation.js +29 -0
  118. package/dist/scene-space.d.ts +50 -0
  119. package/dist/scene-space.js +103 -0
  120. package/dist/scene.d.ts +41 -2
  121. package/dist/scene.js +11 -4
  122. package/dist/simulation-phases.d.ts +9 -0
  123. package/dist/simulation-phases.js +19 -0
  124. package/dist/spatial-query-3d.d.ts +48 -0
  125. package/dist/spatial-query-3d.js +96 -0
  126. package/dist/spatial-query.d.ts +18 -4
  127. package/dist/spatial-query.js +57 -1
  128. package/dist/sprite-batch.js +5 -1
  129. package/package.json +2 -1
@@ -0,0 +1,103 @@
1
+ import { isFiniteNumber, perspectiveCameraIssues, shown } from './scene-camera-3d.js';
2
+ export const SCENE_SPACES = ['2d', '3d'];
3
+ /** The scene's space: 3d only when `render.space` says exactly so. */
4
+ export function resolveSceneSpace(render) {
5
+ return recordOf(render).space === '3d' ? '3d' : '2d';
6
+ }
7
+ /**
8
+ * What a scene's render block makes of the draw-order options at runtime. A
9
+ * 3D scene has no y-sort and no isometric projection whatever the block
10
+ * says (`validate_project` reports them); a 2D scene reads them as always.
11
+ */
12
+ export function resolveRenderPolicy(render) {
13
+ const block = recordOf(render);
14
+ const space = resolveSceneSpace(block);
15
+ const flat = space === '2d';
16
+ return {
17
+ space,
18
+ sort: flat && block.sort === 'y' ? 'y' : null,
19
+ projection: flat && block.projection === 'isometric' ? 'isometric' : null,
20
+ };
21
+ }
22
+ /** The space a component class belongs to; a class that declares none is `'both'`. */
23
+ export function componentSpaceOf(Class) {
24
+ return Class.space ?? 'both';
25
+ }
26
+ /**
27
+ * True when a component of this class does not belong in a scene of this
28
+ * space: a `'2d'` component in 3d, a `'3d'` one in 2d. Reads the class's
29
+ * `static space` marker, so engine, behavior and project-owned components
30
+ * answer the same way. A class the caller does not know (undefined) is
31
+ * space-neutral.
32
+ */
33
+ export function componentSpaceMismatch(space, Class) {
34
+ if (Class === undefined)
35
+ return false;
36
+ const own = componentSpaceOf(Class);
37
+ return own !== 'both' && own !== space;
38
+ }
39
+ function recordOf(value) {
40
+ return typeof value === 'object' && value !== null && !Array.isArray(value) ? { ...value } : {};
41
+ }
42
+ /** Fields of `render` that only mean something in a 2D scene. */
43
+ const TWO_D_RENDER_FIELDS = ['sort', 'projection', 'batch'];
44
+ function cameraKindIssue(space, camera, issues) {
45
+ if (camera === undefined || camera === null)
46
+ return;
47
+ const kind = recordOf(camera).kind;
48
+ if (kind !== undefined && kind !== 'orthographic' && kind !== 'perspective') {
49
+ issues.push({ field: 'camera.kind', message: `camera.kind must be 'orthographic' or 'perspective'; got ${shown(kind)}.` });
50
+ return;
51
+ }
52
+ if (space === '2d' && kind === 'perspective') {
53
+ issues.push({ field: 'camera.kind', message: `A perspective camera needs render.space '3d'; this scene is 2d.` });
54
+ }
55
+ if (space === '3d' && kind !== 'perspective') {
56
+ issues.push({ field: 'camera.kind', message: `A 3d scene needs a perspective camera (kind: 'perspective'); got an orthographic one.` });
57
+ }
58
+ }
59
+ /**
60
+ * Every invalid field of a scene's `render.space`, its camera block and the
61
+ * render options a 3d scene cannot use: a space outside `'2d' | '3d'`, a
62
+ * camera that does not match the space, a perspective camera out of range
63
+ * and `render.sort`, `render.projection` or `render.batch` under 3d. Other
64
+ * render fields are `sceneRenderIssues`'s.
65
+ */
66
+ export function sceneSpaceIssues(render, camera) {
67
+ const block = recordOf(render);
68
+ const issues = [];
69
+ if (block.space !== undefined && block.space !== '2d' && block.space !== '3d') {
70
+ issues.push({ field: 'render.space', message: `render.space must be '2d' or '3d'; got ${shown(block.space)}.` });
71
+ }
72
+ const space = resolveSceneSpace(block);
73
+ cameraKindIssue(space, camera, issues);
74
+ issues.push(...perspectiveCameraIssues(camera));
75
+ if (space === '3d') {
76
+ for (const field of TWO_D_RENDER_FIELDS) {
77
+ if (block[field] === undefined)
78
+ continue;
79
+ issues.push({ field: `render.${field}`, message: `render.${field} is a 2D option and has no meaning in a 3d scene.` });
80
+ }
81
+ }
82
+ return issues;
83
+ }
84
+ function tupleIssue(issues, { field, lengths }, value) {
85
+ if (value === undefined)
86
+ return;
87
+ if (Array.isArray(value) && lengths.includes(value.length) && value.every(isFiniteNumber))
88
+ return;
89
+ const need = lengths.join(' or ');
90
+ issues.push({ field, message: `${field} must be ${need} finite numbers; got ${JSON.stringify(value)}.` });
91
+ }
92
+ /**
93
+ * An entity's `position` (2 or 3 finite numbers), `rotation` (degrees) and
94
+ * `scale` (3 finite numbers each) that are anything else.
95
+ */
96
+ export function entityTransformIssues(entity) {
97
+ const json = recordOf(entity);
98
+ const issues = [];
99
+ tupleIssue(issues, { field: 'position', lengths: [2, 3] }, json.position);
100
+ tupleIssue(issues, { field: 'rotation', lengths: [3] }, json.rotation);
101
+ tupleIssue(issues, { field: 'scale', lengths: [3] }, json.scale);
102
+ return issues;
103
+ }
package/dist/scene.d.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  import type { SceneCameraJson } from './camera.js';
2
2
  import type { ComponentClass } from './component.js';
3
3
  import type { Entity } from './entity.js';
4
+ import { type TransformJson } from './entity-transform.js';
4
5
  import type { Game } from './game.js';
6
+ import type { SceneSimulationJson } from './scene-simulation.js';
7
+ import type { SceneSpace } from './scene-space.js';
5
8
  /**
6
9
  * Waica's scene format: declarative, git-friendly data, editable by the
7
10
  * visual editor. The scene is the source of truth; the Game is its live
@@ -11,9 +14,8 @@ export interface SceneComponentJson {
11
14
  type: string;
12
15
  props?: Record<string, unknown>;
13
16
  }
14
- export interface SceneEntityJson {
17
+ export interface SceneEntityJson extends TransformJson {
15
18
  name: string;
16
- position?: [number, number];
17
19
  /** Prefab ref like "characters/slime", resolved against SceneRegistry.prefabs. */
18
20
  prefab?: string;
19
21
  /** Per-component prop overrides on top of the prefab: componentType -> propName -> value. */
@@ -28,8 +30,43 @@ export interface PrefabJson {
28
30
  type: 'character' | 'object' | 'tile';
29
31
  components: SceneComponentJson[];
30
32
  }
33
+ /**
34
+ * The scene's lighting (issue #78, ADR 0026). Declaring it — or spawning a
35
+ * Light — draws the scene lit; absent, the scene is drawn exactly as unlit.
36
+ */
37
+ export interface SceneLightingJson {
38
+ /** Where no light reaches. Absent fields: `#ffffff` and 1, the art as drawn. */
39
+ ambient?: {
40
+ color?: string;
41
+ intensity?: number;
42
+ };
43
+ }
44
+ /** The scene's Post Effects, each off unless declared (issue #78). */
45
+ export interface ScenePostJson {
46
+ /** Darkens the frame's edges: `intensity` 0..1 at the corners, starting at `radius` 0..1 from the centre. */
47
+ vignette?: {
48
+ intensity: number;
49
+ radius: number;
50
+ };
51
+ /** A `#rrggbb` tint multiplier, and contrast and saturation from 0 to 2 (1 leaves the frame unchanged). */
52
+ colorGrade?: {
53
+ tint?: string;
54
+ contrast?: number;
55
+ saturation?: number;
56
+ };
57
+ }
31
58
  /** Scene-wide render options (v3). */
32
59
  export interface SceneRenderJson {
60
+ /**
61
+ * `'2d'` (the default) or `'3d'` (ADR 0027). A 3D scene makes `z` a world
62
+ * axis under a perspective camera with a depth buffer; there is no layer
63
+ * band, y-sort, isometric projection, sprite batch or light-map in it.
64
+ */
65
+ space?: SceneSpace;
66
+ /** Ambient Light; with it (or any Light) the scene is lit (ADR 0026). */
67
+ lighting?: SceneLightingJson;
68
+ /** Vignette and color grade; with either on, the frame renders through a render target. */
69
+ post?: ScenePostJson;
33
70
  /**
34
71
  * 'y' orders same-layer sprites by their render-space Y — lower Y renders
35
72
  * in front (top-down depth). Absent: spawn order breaks same-layer ties.
@@ -54,6 +91,8 @@ export interface SceneJson {
54
91
  camera?: SceneCameraJson;
55
92
  /** Draw-order policy (v3); absent = layer bands with spawn-order ties. */
56
93
  render?: SceneRenderJson;
94
+ /** The physics world's settings (v3, 3D scenes only): gravity. Absent = earth gravity. */
95
+ simulation?: SceneSimulationJson;
57
96
  entities: SceneEntityJson[];
58
97
  /** UI pieces (src/ui/*.html) mounted visible when the scene loads. */
59
98
  ui?: string[];
package/dist/scene.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { applyTransformJson } from './entity-transform.js';
1
2
  /**
2
3
  * Own-property lookup. Scene JSON is data — a component or prefab named
3
4
  * "constructor" or "toString" must read as missing, not as whatever sits
@@ -69,6 +70,7 @@ const COMPONENT_MEMBERS = {
69
70
  onProjectionChange: true,
70
71
  onCollide: true,
71
72
  onContact: true,
73
+ onBodyContact: true,
72
74
  onInteract: true,
73
75
  onDestroy: true,
74
76
  };
@@ -91,8 +93,7 @@ function sceneProps(props, type, entityName) {
91
93
  }
92
94
  export function spawnFromJson(game, json, registry) {
93
95
  const entity = game.spawn(json.name);
94
- if (json.position)
95
- entity.position.set(json.position[0], json.position[1], 0);
96
+ applyTransformJson(entity, json);
96
97
  for (const comp of resolveEntityComponents(json, registry.prefabs)) {
97
98
  const Class = registryEntry(registry.components, comp.type);
98
99
  if (!Class) {
@@ -131,10 +132,16 @@ export function loadScene(game, scene, registry) {
131
132
  }
132
133
  game.registry = registry;
133
134
  game.setSceneRender(scene.render);
135
+ game.setSceneSimulation(scene.simulation);
136
+ // A perspective camera has no follow target to wait for: components see it from their first onReady.
137
+ const adoptsBeforeSpawns = game.space === '3d';
138
+ if (adoptsBeforeSpawns)
139
+ game.setSceneCamera(scene.camera);
134
140
  for (const entityJson of scene.entities)
135
141
  spawnFromJson(game, entityJson, registry);
136
- // After the spawns: with a follow target, the camera starts centered on it.
137
- game.setSceneCamera(scene.camera);
142
+ // After the spawns: with a follow target, the orthographic camera starts centered on it.
143
+ if (!adoptsBeforeSpawns)
144
+ game.setSceneCamera(scene.camera);
138
145
  if (registry.ui)
139
146
  game.ui.defineAll(registry.ui);
140
147
  // Scene-scoped: Game.unloadScene() unmounts these along with the entities.
@@ -0,0 +1,9 @@
1
+ import type { Game } from './game.js';
2
+ /**
3
+ * What a Simulation Step does after every component's `onUpdate` (issue #159
4
+ * CA-10), in this order: the 3D physics step (one `world.step()` of the live
5
+ * scene's Rapier world, a no-op in a 2D scene or while the module loads), the
6
+ * solid contacts it produced, the 2D Hitbox triggers, and the 3D sensor
7
+ * triggers. Kept out of `game.ts`, which is at the file-size tier.
8
+ */
9
+ export declare function runSimulationPhases(game: Game): void;
@@ -0,0 +1,19 @@
1
+ import { dispatchCollisions as dispatchHitboxCollisions } from './collision-dispatch.js';
2
+ import { dispatchBodyContacts } from './physics-3d/body-contacts.js';
3
+ import { dispatchSensorCollisions } from './physics-3d/sensor-dispatch.js';
4
+ /**
5
+ * What a Simulation Step does after every component's `onUpdate` (issue #159
6
+ * CA-10), in this order: the 3D physics step (one `world.step()` of the live
7
+ * scene's Rapier world, a no-op in a 2D scene or while the module loads), the
8
+ * solid contacts it produced, the 2D Hitbox triggers, and the 3D sensor
9
+ * triggers. Kept out of `game.ts`, which is at the file-size tier.
10
+ */
11
+ export function runSimulationPhases(game) {
12
+ const world = game.physics.world;
13
+ world?.step();
14
+ if (world)
15
+ dispatchBodyContacts(game, world);
16
+ dispatchHitboxCollisions(game);
17
+ if (world && game.physics.world === world)
18
+ dispatchSensorCollisions(game, world);
19
+ }
@@ -0,0 +1,48 @@
1
+ import type { Collider } from './components/collider.js';
2
+ import type { Entity } from './entity.js';
3
+ import type { Game } from './game.js';
4
+ import { type Point3d, type QueryVolume3d } from './physics-3d/world-queries.js';
5
+ import type { ComponentClasses, NearestSpatialQueryFilter, SpatialQueryFilter } from './spatial-query.js';
6
+ export type { Point3d, QueryVolume3d } from './physics-3d/world-queries.js';
7
+ /** The first solid collider a 3D ray reached, with its entity and the crossing (issue #159 CA-21). */
8
+ export interface RayHit3d<T extends Entity = Entity> {
9
+ readonly entity: T;
10
+ readonly collider: Collider;
11
+ readonly distance: number;
12
+ readonly point: Readonly<Point3d>;
13
+ readonly normal: Readonly<Point3d>;
14
+ }
15
+ type Filter = SpatialQueryFilter<ComponentClasses> | undefined;
16
+ type NearestFilter = NearestSpatialQueryFilter<ComponentClasses> | undefined;
17
+ /** The structural part of the entity filter the 2D forms use (`with`, `without`, `exclude`), shared with the 3D ones (`spatial-query.ts` owns it). */
18
+ export interface EntityRules {
19
+ matchesCommon(entity: Entity, filter: NearestFilter): boolean;
20
+ }
21
+ /** The 3D forms of `game.query` (ADR 0015's domains kept): `ray` reads solids, `area` and `point` read sensors, `nearest` reads transforms. */
22
+ export declare class SpatialQuery3d {
23
+ private readonly game;
24
+ private readonly rules;
25
+ constructor(game: Game, rules: EntityRules);
26
+ /** `direction` need not be a unit vector; `maxDistance` is in world units. */
27
+ ray(aim: {
28
+ origin: Point3d;
29
+ direction: Point3d;
30
+ }, maxDistance: number, filter?: Filter): RayHit3d | null;
31
+ area(volume: QueryVolume3d, filter?: Filter): Entity[];
32
+ point(at: Point3d, filter?: Filter): Entity[];
33
+ nearest(at: Point3d, filter?: NearestFilter): Entity | null;
34
+ /** How far the entity is from the search, when it is alive, passes the filter and lies within range. */
35
+ private distanceIfEligible;
36
+ /** The live world with its query structure current, or null while there is none. */
37
+ private worldNow;
38
+ /**
39
+ * A live entity's collider the query domain reads (solids for a ray, sensors
40
+ * for area and point) that passes the structural filter. Runs inside
41
+ * Rapier's callback, so it reads the record and nothing else.
42
+ */
43
+ private eligible;
44
+ /** The user's `where`, run after Rapier has answered: it may throw, spawn or destroy without the world mid-query. */
45
+ private wanted;
46
+ /** The entities of the records that pass `where`, once each, in the order they were spawned. */
47
+ private inEntityOrder;
48
+ }
@@ -0,0 +1,96 @@
1
+ import { recordsAtPoint, recordsInVolume, worldRayHits } from './physics-3d/world-queries.js';
2
+ const isPoint3d = (value) => typeof value === 'object' && value !== null && ['x', 'y', 'z'].every((axis) => Number.isFinite(Reflect.get(value, axis)));
3
+ const isExtent = (value) => typeof value === 'number' && Number.isFinite(value) && value > 0;
4
+ /** A finite box or sphere with a place: anything else is a volume Rapier cannot test. */
5
+ function isVolume3d(value) {
6
+ if (typeof value !== 'object' || value === null || !isPoint3d(Reflect.get(value, 'center')))
7
+ return false;
8
+ const shape = Reflect.get(value, 'shape');
9
+ if (shape === 'sphere')
10
+ return isExtent(Reflect.get(value, 'radius'));
11
+ const size = Reflect.get(value, 'size');
12
+ return shape === 'box' && typeof size === 'object' && size !== null && ['x', 'y', 'z'].every((axis) => isExtent(Reflect.get(size, axis)));
13
+ }
14
+ /** The farthest a nearest query looks: its `maxDistance`, or no limit. */
15
+ const rangeOf = (filter) => filter?.maxDistance ?? Infinity;
16
+ /** The 3D forms of `game.query` (ADR 0015's domains kept): `ray` reads solids, `area` and `point` read sensors, `nearest` reads transforms. */
17
+ export class SpatialQuery3d {
18
+ game;
19
+ rules;
20
+ constructor(game, rules) {
21
+ this.game = game;
22
+ this.rules = rules;
23
+ }
24
+ /** `direction` need not be a unit vector; `maxDistance` is in world units. */
25
+ ray(aim, maxDistance, filter) {
26
+ const { origin, direction } = aim;
27
+ const world = this.worldNow();
28
+ const length = isPoint3d(direction) ? Math.hypot(direction.x, direction.y, direction.z) : 0;
29
+ if (!world || !isPoint3d(origin) || length === 0 || !(maxDistance >= 0) || !Number.isFinite(maxDistance))
30
+ return null;
31
+ const unit = { x: direction.x / length, y: direction.y / length, z: direction.z / length };
32
+ const hits = worldRayHits(world, { origin, direction: unit, maxDistance }, (record) => this.eligible(record, filter, false));
33
+ // The user's `where` runs here, outside Rapier's borrow of the world: the first hit it keeps wins.
34
+ const hit = hits.find(({ record }) => this.wanted(record, filter));
35
+ if (!hit)
36
+ return null;
37
+ return { entity: hit.record.entity, collider: hit.record.collider, distance: hit.distance, point: hit.point, normal: hit.normal };
38
+ }
39
+ area(volume, filter) {
40
+ const world = this.worldNow();
41
+ if (!world || !isVolume3d(volume))
42
+ return [];
43
+ return this.inEntityOrder(recordsInVolume(world, volume, (record) => this.eligible(record, filter, true)), filter);
44
+ }
45
+ point(at, filter) {
46
+ const world = this.worldNow();
47
+ if (!world || !isPoint3d(at))
48
+ return [];
49
+ return this.inEntityOrder(recordsAtPoint(world, at, (record) => this.eligible(record, filter, true)), filter);
50
+ }
51
+ nearest(at, filter) {
52
+ const maxDistance = rangeOf(filter);
53
+ if (!isPoint3d(at) || !(maxDistance >= 0))
54
+ return null;
55
+ let best = null;
56
+ for (const entity of this.game.entities) {
57
+ const distance = this.distanceIfEligible(entity, { at, filter, maxDistance });
58
+ if (distance !== null && (best === null || distance < best.distance))
59
+ best = { entity, distance };
60
+ }
61
+ return best?.entity ?? null;
62
+ }
63
+ /** How far the entity is from the search, when it is alive, passes the filter and lies within range. */
64
+ distanceIfEligible(entity, { at, filter, maxDistance }) {
65
+ if (!entity.alive || !this.rules.matchesCommon(entity, filter))
66
+ return null;
67
+ const { x, y, z } = entity.position;
68
+ const distance = Math.hypot(x - at.x, y - at.y, z - at.z);
69
+ if (Number.isNaN(distance) || distance > maxDistance)
70
+ return null;
71
+ return filter?.where && !filter.where(entity, { distance }) ? null : distance;
72
+ }
73
+ /** The live world with its query structure current, or null while there is none. */
74
+ worldNow() {
75
+ const world = this.game.physics.world;
76
+ world?.refreshQueries();
77
+ return world;
78
+ }
79
+ /**
80
+ * A live entity's collider the query domain reads (solids for a ray, sensors
81
+ * for area and point) that passes the structural filter. Runs inside
82
+ * Rapier's callback, so it reads the record and nothing else.
83
+ */
84
+ eligible(record, filter, sensors) {
85
+ return record.sensor === sensors && record.entity.alive && this.rules.matchesCommon(record.entity, filter);
86
+ }
87
+ /** The user's `where`, run after Rapier has answered: it may throw, spawn or destroy without the world mid-query. */
88
+ wanted(record, filter) {
89
+ return record.entity.alive && (!filter?.where || filter.where(record.entity));
90
+ }
91
+ /** The entities of the records that pass `where`, once each, in the order they were spawned. */
92
+ inEntityOrder(records, filter) {
93
+ const found = new Set(records.filter((record) => this.wanted(record, filter)).map((record) => record.entity));
94
+ return this.game.entities.filter((entity) => found.has(entity));
95
+ }
96
+ }
@@ -4,7 +4,8 @@ import { Hitbox } from './components/hitbox.js';
4
4
  import { Solid } from './components/solid.js';
5
5
  import type { Entity } from './entity.js';
6
6
  import type { Game } from './game.js';
7
- type ComponentClasses = readonly ComponentClass[];
7
+ import { type Point3d, type QueryVolume3d, type RayHit3d } from './spatial-query-3d.js';
8
+ export type ComponentClasses = readonly ComponentClass[];
8
9
  type QueryEntity<Classes extends ComponentClasses> = Classes extends readonly [] ? Entity : EntityWith<Classes>;
9
10
  /** An Entity whose required component classes return non-optional instances. */
10
11
  export interface EntityWith<Classes extends ComponentClasses> extends Entity {
@@ -57,21 +58,34 @@ type NearestGuardFilter<Classes extends ComponentClasses, Narrowed extends Query
57
58
  /** Public logical-space spatial-query service owned by every Game. */
58
59
  export interface SpatialQuery {
59
60
  /** Hitbox owners with positive interior overlap, mask-agnostic and in Entity order. */
60
- area<Classes extends ComponentClasses = readonly [], Narrowed extends QueryEntity<Classes> = QueryEntity<Classes>>(body: CollisionBody, filter: SpatialGuardFilter<Classes, Narrowed>): Narrowed[];
61
- area<Classes extends ComponentClasses = readonly []>(body: CollisionBody, filter: SpatialQueryFilter<Classes>): QueryEntity<Classes>[];
62
- area(body: CollisionBody, filter?: SpatialQueryFilter): Entity[];
61
+ area<Classes extends ComponentClasses = readonly [], Narrowed extends QueryEntity<Classes> = QueryEntity<Classes>>(body: CollisionBody | QueryVolume3d, filter: SpatialGuardFilter<Classes, Narrowed>): Narrowed[];
62
+ /** In a 3D scene `area` takes a volume and returns the entities whose sensor colliders intersect it, in Entity order. */
63
+ area<Classes extends ComponentClasses = readonly []>(body: CollisionBody | QueryVolume3d, filter: SpatialQueryFilter<Classes>): QueryEntity<Classes>[];
64
+ area(body: CollisionBody | QueryVolume3d, filter?: SpatialQueryFilter): Entity[];
63
65
  /** Hitbox owners that strictly contain a point, mask-agnostic and in Entity order. */
64
66
  point<Classes extends ComponentClasses = readonly [], Narrowed extends QueryEntity<Classes> = QueryEntity<Classes>>(x: number, y: number, filter: SpatialGuardFilter<Classes, Narrowed>): Narrowed[];
65
67
  point<Classes extends ComponentClasses = readonly []>(x: number, y: number, filter: SpatialQueryFilter<Classes>): QueryEntity<Classes>[];
66
68
  point(x: number, y: number, filter?: SpatialQueryFilter): Entity[];
69
+ /** 3D scenes: entities whose sensor colliders contain the point, in Entity order. */
70
+ point<Classes extends ComponentClasses = readonly [], Narrowed extends QueryEntity<Classes> = QueryEntity<Classes>>(at: Point3d, filter: SpatialGuardFilter<Classes, Narrowed>): Narrowed[];
71
+ point<Classes extends ComponentClasses = readonly []>(at: Point3d, filter: SpatialQueryFilter<Classes>): QueryEntity<Classes>[];
72
+ point(at: Point3d, filter?: SpatialQueryFilter): Entity[];
67
73
  /** Closest filtered logical transform; equal distances retain Entity order. */
68
74
  nearest<Classes extends ComponentClasses = readonly [], Narrowed extends QueryEntity<Classes> = QueryEntity<Classes>>(x: number, y: number, filter: NearestGuardFilter<Classes, Narrowed>): Narrowed | null;
69
75
  nearest<Classes extends ComponentClasses = readonly []>(x: number, y: number, filter: NearestSpatialQueryFilter<Classes>): QueryEntity<Classes> | null;
70
76
  nearest(x: number, y: number, filter?: NearestSpatialQueryFilter): Entity | null;
77
+ /** 3D scenes: the closest filtered transform, z included. */
78
+ nearest<Classes extends ComponentClasses = readonly [], Narrowed extends QueryEntity<Classes> = QueryEntity<Classes>>(at: Point3d, filter: NearestGuardFilter<Classes, Narrowed>): Narrowed | null;
79
+ nearest<Classes extends ComponentClasses = readonly []>(at: Point3d, filter: NearestSpatialQueryFilter<Classes>): QueryEntity<Classes> | null;
80
+ nearest(at: Point3d, filter?: NearestSpatialQueryFilter): Entity | null;
71
81
  /** First strict crossing of direct or source-derived Solid geometry. */
72
82
  ray<Classes extends ComponentClasses = readonly [], Narrowed extends QueryEntity<Classes> = QueryEntity<Classes>>(x: number, y: number, dx: number, dy: number, maxDistance: number, filter: SpatialGuardFilter<Classes, Narrowed>): RayHit<Narrowed> | null;
73
83
  ray<Classes extends ComponentClasses = readonly []>(x: number, y: number, dx: number, dy: number, maxDistance: number, filter: SpatialQueryFilter<Classes>): RayHit<QueryEntity<Classes>> | null;
74
84
  ray(x: number, y: number, dx: number, dy: number, maxDistance: number, filter?: SpatialQueryFilter): RayHit | null;
85
+ /** 3D scenes: the first solid collider along the ray (sensors never block it); `direction` need not be a unit vector. */
86
+ ray<Classes extends ComponentClasses = readonly [], Narrowed extends QueryEntity<Classes> = QueryEntity<Classes>>(origin: Point3d, direction: Point3d, maxDistance: number, filter: SpatialGuardFilter<Classes, Narrowed>): RayHit3d<Narrowed> | null;
87
+ ray<Classes extends ComponentClasses = readonly []>(origin: Point3d, direction: Point3d, maxDistance: number, filter: SpatialQueryFilter<Classes>): RayHit3d<QueryEntity<Classes>> | null;
88
+ ray(origin: Point3d, direction: Point3d, maxDistance: number, filter?: SpatialQueryFilter): RayHit3d | null;
75
89
  }
76
90
  export interface HitboxCandidate {
77
91
  readonly entity: Entity;
@@ -3,6 +3,7 @@ import { collisionBounds, collisionOverlap, } from './collision-shape.js';
3
3
  import { Hitbox } from './components/hitbox.js';
4
4
  import { Solid } from './components/solid.js';
5
5
  import { createHitboxBroadphase, createSolidBroadphase, } from './spatial-broadphase.js';
6
+ import { SpatialQuery3d } from './spatial-query-3d.js';
6
7
  import { collisionBodyContainsPoint, collisionBodyRay, SPATIAL_QUERY_EPSILON, usableCollisionBody, } from './spatial-query-geometry.js';
7
8
  function indexedCandidateProviders(game) {
8
9
  return {
@@ -162,7 +163,62 @@ class LinearSpatialQuery {
162
163
  return result;
163
164
  }
164
165
  }
166
+ /** A volume names its place by `center`; a collision body by `x` and `y`. */
167
+ const isVolume = (value) => typeof value === 'object' && value !== null && 'center' in value;
168
+ const isRay3d = (args) => typeof args[0] === 'object';
169
+ /**
170
+ * Sends each call to the form its arguments name and refuses the form of the
171
+ * other space with an error naming both (issue #159 CA-21): the 2D forms read
172
+ * Hitboxes and Solids and take numbers or a collision body; the 3D forms read
173
+ * the physics world and take `{ x, y, z }` objects.
174
+ */
175
+ class SpaceSpatialQuery {
176
+ game;
177
+ flat;
178
+ solid;
179
+ constructor(game, flat) {
180
+ this.game = game;
181
+ this.flat = flat;
182
+ this.solid = new SpatialQuery3d(game, { matchesCommon: matchesCommonFilter });
183
+ }
184
+ area(first, filter) {
185
+ if (isVolume(first))
186
+ return this.in3d('area').area(first, filter);
187
+ this.require('area', '2d');
188
+ return this.flat.area(first, filter);
189
+ }
190
+ point(first, second, third) {
191
+ if (typeof first === 'object')
192
+ return this.in3d('point').point(first, typeof second === 'object' ? second : undefined);
193
+ this.require('point', '2d');
194
+ return this.flat.point(first, typeof second === 'number' ? second : Number.NaN, third);
195
+ }
196
+ nearest(first, second, third) {
197
+ if (typeof first === 'object')
198
+ return this.in3d('nearest').nearest(first, typeof second === 'object' ? second : undefined);
199
+ this.require('nearest', '2d');
200
+ return this.flat.nearest(first, typeof second === 'number' ? second : Number.NaN, third);
201
+ }
202
+ ray(...args) {
203
+ if (isRay3d(args)) {
204
+ const [origin, direction, maxDistance, filter] = args;
205
+ return this.in3d('ray').ray({ origin, direction }, maxDistance, filter);
206
+ }
207
+ this.require('ray', '2d');
208
+ return this.flat.ray(...args);
209
+ }
210
+ in3d(operation) {
211
+ this.require(operation, '3d');
212
+ return this.solid;
213
+ }
214
+ require(operation, space) {
215
+ // Only a 3D scene turns the 2D forms away; anything else (no scene, or a stand-in Game) is the 2D world.
216
+ if (space === '3d' ? this.game.space === '3d' : this.game.space !== '3d')
217
+ return;
218
+ throw new Error(`game.query.${operation}: the ${space === '3d' ? '3D' : '2D'} form needs a ${space} scene, and the live scene is ${this.game.space}.`);
219
+ }
220
+ }
165
221
  /** Package-internal constructor; only the SpatialQuery interface is public. */
166
222
  export function createSpatialQuery(game, candidates = indexedCandidateProviders(game), instrumentation) {
167
- return new LinearSpatialQuery(candidates, instrumentation);
223
+ return new SpaceSpatialQuery(game, new LinearSpatialQuery(candidates, instrumentation));
168
224
  }
@@ -1,5 +1,6 @@
1
1
  import { attribute, context, mat3, positionLocal, uv, vec3, vertexStage } from 'three/tsl';
2
2
  import * as THREE from 'three/webgpu';
3
+ import { TRANSPARENT_TEXEL_ALPHA } from './render-layers.js';
3
4
  /** The first capacity of a batch's instance buffers; it doubles when full. */
4
5
  export const INITIAL_SPRITE_BATCH_CAPACITY = 16;
5
6
  /** The key's canonical string: equal keys share one batch. */
@@ -57,7 +58,7 @@ class SpriteBatchMaterial extends THREE.MeshBasicNodeMaterial {
57
58
  function createMaterial(key, textures) {
58
59
  // Double-sided: a flipped sprite mirrors through a negative x scale in its
59
60
  // matrix, exactly like its own mesh; per instance there is no winding flip.
60
- const material = new SpriteBatchMaterial({ transparent: true, side: THREE.DoubleSide });
61
+ const material = new SpriteBatchMaterial({ transparent: true, side: THREE.DoubleSide, alphaTest: TRANSPARENT_TEXEL_ALPHA });
61
62
  material.forceSinglePass = true;
62
63
  if (key.texture === null)
63
64
  return { material, settled: null };
@@ -100,6 +101,9 @@ export class SpriteRunMesh {
100
101
  this.mesh.instanceColor = this.colors;
101
102
  // Drawn in an explicit renderOrder; its instances are already in view space.
102
103
  this.mesh.frustumCulled = false;
104
+ // A run is collected through its sprites' anchors, which carry their own
105
+ // layers (Emissive or not, ADR 0026): the run itself is on every layer.
106
+ this.mesh.layers.enableAll();
103
107
  this.mesh.matrixAutoUpdate = false;
104
108
  this.mesh.boundingSphere = new THREE.Sphere();
105
109
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waica/engine",
3
- "version": "0.24.0",
3
+ "version": "0.26.0",
4
4
  "description": "Waica game engine core — archetype-driven, web-first, 2D & 3D",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,6 +20,7 @@
20
20
  }
21
21
  },
22
22
  "dependencies": {
23
+ "@dimforge/rapier3d-deterministic-compat": "0.21.0",
23
24
  "@types/three": "^0.186.0",
24
25
  "three": "^0.186.1"
25
26
  }