@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,158 @@
1
+ import { Collider } from '../components/collider.js';
2
+ import { RigidBody } from '../components/rigid-body.js';
3
+ import { SIMULATION_STEP } from '../fixed-step.js';
4
+ import { colliderProblem, createBody, rigidBodyProblem, syncBodyPose } from './body-sync.js';
5
+ import { CharacterMotion } from './character-controller.js';
6
+ /**
7
+ * One Rapier world and the bodies of the live 3D scene (ADR 0028): created
8
+ * with the scene, stepped once per Simulation Step, freed with the scene. It
9
+ * owns every Rapier handle; components only hold plain params and ask the
10
+ * Game's `physics` for velocity, impulses and the like by entity.
11
+ */
12
+ export class PhysicsWorld {
13
+ R;
14
+ /** The Rapier world itself, for the modules that query it; nothing outside `physics-3d/` and its tests should touch it. */
15
+ raw;
16
+ records = new Map();
17
+ byCollider = new Map();
18
+ /** Entities with a RigidBody and, so far, no Collider: warned about at the first step if it stays so. */
19
+ orphans = new Set();
20
+ /** The last warning each entity got, so the same problem seen from both of its components is said once. */
21
+ warned = new WeakMap();
22
+ sensors = 0;
23
+ /** Bodies came or went since Rapier last updated the structure its queries read. */
24
+ queriesStale = false;
25
+ characters;
26
+ constructor(
27
+ /** The Rapier module the world was made with, for the shapes and rays its queries build. */
28
+ R, gravity) {
29
+ this.R = R;
30
+ this.raw = new R.World({ x: gravity[0], y: gravity[1], z: gravity[2] });
31
+ this.raw.timestep = SIMULATION_STEP;
32
+ this.characters = new CharacterMotion(R, this.raw);
33
+ }
34
+ /** Whether any body has a sensor Collider, so the sensor dispatch can be skipped when none does. */
35
+ get hasSensors() {
36
+ return this.sensors > 0;
37
+ }
38
+ get gravity() {
39
+ const { x, y, z } = this.raw.gravity;
40
+ return [x, y, z];
41
+ }
42
+ set gravity(value) {
43
+ this.raw.gravity = { x: value[0], y: value[1], z: value[2] };
44
+ }
45
+ /** The record of an entity's body, if it has one. */
46
+ recordOf(entity) {
47
+ return this.records.get(entity);
48
+ }
49
+ /** The record that owns a Rapier collider handle. */
50
+ recordOfCollider(handle) {
51
+ return this.byCollider.get(handle);
52
+ }
53
+ /** The bodies of the given entities, in their order. */
54
+ bodiesOf(entities) {
55
+ return entities.flatMap((entity) => {
56
+ const record = this.records.get(entity);
57
+ return record ? [record] : [];
58
+ });
59
+ }
60
+ /**
61
+ * Builds (or rebuilds) the entity's body from the Collider and RigidBody it
62
+ * has now. Called from each component's `onReady`, so either order of the
63
+ * two works: the later one replaces the earlier one's body.
64
+ */
65
+ attach(entity) {
66
+ const collider = entity.get(Collider);
67
+ const rigid = entity.get(RigidBody) ?? null;
68
+ if (!collider) {
69
+ if (rigid)
70
+ this.orphans.add(entity);
71
+ return;
72
+ }
73
+ this.detach(entity);
74
+ const problem = colliderProblem(collider);
75
+ const bodyProblem = problem === null && rigid ? rigidBodyProblem(rigid) : null;
76
+ if (problem !== null)
77
+ return this.warnOnce(entity, `Collider on "${entity.name}" creates no body: ${problem}.`);
78
+ if (bodyProblem !== null)
79
+ return this.warnOnce(entity, `RigidBody on "${entity.name}" creates no body: ${bodyProblem}.`);
80
+ const record = createBody(this.R, this.raw, { entity, collider, rigid });
81
+ this.records.set(entity, record);
82
+ this.queriesStale = true;
83
+ this.byCollider.set(record.shape.handle, record);
84
+ if (record.sensor)
85
+ this.sensors += 1;
86
+ }
87
+ /** Removes the entity's body and collider; a no-op for an entity without one. */
88
+ detach(entity) {
89
+ this.orphans.delete(entity);
90
+ const record = this.records.get(entity);
91
+ if (!record)
92
+ return;
93
+ this.records.delete(entity);
94
+ this.byCollider.delete(record.shape.handle);
95
+ if (record.sensor)
96
+ this.sensors -= 1;
97
+ this.raw.removeRigidBody(record.body);
98
+ this.queriesStale = true;
99
+ }
100
+ /**
101
+ * One Simulation Step of physics: every kinematic body is moved through the
102
+ * character controller, Rapier steps, then moving bodies write their pose to
103
+ * their entities.
104
+ */
105
+ step() {
106
+ this.warnOrphans();
107
+ this.characters.follow(this.records.values());
108
+ for (const record of this.records.values()) {
109
+ if (record.kind !== 'kinematic')
110
+ continue;
111
+ this.refreshQueries();
112
+ this.characters.move(record);
113
+ }
114
+ this.raw.step();
115
+ this.queriesStale = false;
116
+ for (const record of this.records.values())
117
+ syncBodyPose(record);
118
+ }
119
+ /**
120
+ * Makes ray casts, shape queries and the character controller see the bodies
121
+ * created or removed since the last step: Rapier only updates the structure
122
+ * they read inside `step()`, so a body spawned this frame would be invisible
123
+ * (a character would fall through a floor spawned with it). A step of zero
124
+ * seconds updates it and moves nothing.
125
+ */
126
+ refreshQueries() {
127
+ if (!this.queriesStale)
128
+ return;
129
+ this.queriesStale = false;
130
+ const timestep = this.raw.timestep;
131
+ this.raw.timestep = 0;
132
+ this.raw.step();
133
+ this.raw.timestep = timestep;
134
+ }
135
+ /** Frees the Rapier world and forgets every body. The entities are destroyed (and detached) before this. */
136
+ free() {
137
+ this.records.clear();
138
+ this.byCollider.clear();
139
+ this.orphans.clear();
140
+ this.sensors = 0;
141
+ this.characters.free();
142
+ this.raw.free();
143
+ }
144
+ warnOrphans() {
145
+ for (const entity of this.orphans) {
146
+ this.orphans.delete(entity);
147
+ if (!entity.alive || entity.has(Collider))
148
+ continue;
149
+ this.warnOnce(entity, `RigidBody on "${entity.name}" needs a Collider on the same entity; no body was created.`);
150
+ }
151
+ }
152
+ warnOnce(entity, message) {
153
+ if (this.warned.get(entity) === message)
154
+ return;
155
+ this.warned.set(entity, message);
156
+ console.warn(`[waica] ${message}`);
157
+ }
158
+ }
@@ -0,0 +1,17 @@
1
+ import type * as Rapier from '@dimforge/rapier3d-deterministic-compat';
2
+ /** The initialized Rapier module: the `RAPIER` namespace the package documents. */
3
+ export type RapierModule = typeof Rapier;
4
+ export type { Collider as RapierCollider, ColliderDesc as RapierColliderDesc, KinematicCharacterController as RapierCharacterController, RigidBody as RapierBody, TempContactManifold as RapierManifold, World as RapierWorld, } from '@dimforge/rapier3d-deterministic-compat';
5
+ /** The package name, for the messages that name it. */
6
+ export declare const RAPIER_PACKAGE = "@dimforge/rapier3d-deterministic-compat";
7
+ /**
8
+ * Where a Game gets Rapier (ADR 0013's seam, applied to physics): resolves to
9
+ * the initialized module. Defaults to `loadRapier`; a project's own tests pass
10
+ * the real module they initialized once, or a rejecting stub for the failure path.
11
+ */
12
+ export type PhysicsBackend = () => Promise<RapierModule>;
13
+ /**
14
+ * Imports and initializes Rapier, once per page: every Game that asks shares
15
+ * the same module. A failed load is forgotten, so a later Game may retry.
16
+ */
17
+ export declare function loadRapier(): Promise<RapierModule>;
@@ -0,0 +1,19 @@
1
+ /** The package name, for the messages that name it. */
2
+ export const RAPIER_PACKAGE = '@dimforge/rapier3d-deterministic-compat';
3
+ let shared = null;
4
+ /**
5
+ * Imports and initializes Rapier, once per page: every Game that asks shares
6
+ * the same module. A failed load is forgotten, so a later Game may retry.
7
+ */
8
+ export function loadRapier() {
9
+ shared ??= import('@dimforge/rapier3d-deterministic-compat')
10
+ .then(async (module) => {
11
+ await module.init();
12
+ return module;
13
+ })
14
+ .catch((error) => {
15
+ shared = null;
16
+ throw error;
17
+ });
18
+ return shared;
19
+ }
@@ -0,0 +1,10 @@
1
+ import type { Game } from '../game.js';
2
+ import type { PhysicsWorld } from './physics-world.js';
3
+ /**
4
+ * The 3D counterpart of the Hitbox trigger dispatch (CA-16): after the physics
5
+ * step, every overlap involving a sensor Collider fires `onCollide(other)` on
6
+ * the components of each side whose interest names the other's layer, every
7
+ * step while they overlap. Pairs are frozen first and each is re-checked live,
8
+ * like the 2D dispatch.
9
+ */
10
+ export declare function dispatchSensorCollisions(game: Game, world: PhysicsWorld): void;
@@ -0,0 +1,77 @@
1
+ import { collisionMaskTargets } from '../collision-category.js';
2
+ import { Collider } from '../components/collider.js';
3
+ /** The mask a Collider has when nothing was declared: every layer. */
4
+ function isDefaultMask(mask) {
5
+ return mask.length === 1 && mask[0] === '*';
6
+ }
7
+ /**
8
+ * Whether `self` is notified about `target` (ADR 0016's directional
9
+ * interest). A sensor always has an interest, its `collidesWith` (default
10
+ * every layer), except that the default mask leaves out the level's fixed
11
+ * solids: a 2D Hitbox never sees a Solid, so a pickup resting on the floor is
12
+ * not told about the floor every step; naming their layer still works. A
13
+ * solid collider has no interest until it declares a mask of its own
14
+ * (inference 14, issue #159): the default `['*']` on a solid means "no
15
+ * interest", so a wall or a character is not woken by every trigger it touches.
16
+ */
17
+ function interestedIn(self, target) {
18
+ const { collider } = self;
19
+ const defaultMask = isDefaultMask(collider.collidesWith);
20
+ if (!self.sensor && defaultMask)
21
+ return false;
22
+ if (defaultMask && !target.sensor && target.kind === 'fixed')
23
+ return false;
24
+ return collisionMaskTargets(collider.collidesWith, target.collider.layer);
25
+ }
26
+ /** Every sensor overlap of the world as a frozen snapshot: each pair once, the earlier-spawned sensor first. */
27
+ function sensorPairs(game, world) {
28
+ const records = world.bodiesOf(game.entities);
29
+ const spawnIndex = new Map(records.map((record, index) => [record, index]));
30
+ const spawnedBefore = (a, b) => (spawnIndex.get(a) ?? 0) < (spawnIndex.get(b) ?? 0);
31
+ const pairs = [];
32
+ for (const sensor of records.filter((record) => record.sensor)) {
33
+ world.raw.intersectionPairsWith(sensor.shape, (shape) => {
34
+ const other = world.recordOfCollider(shape.handle);
35
+ if (!other || other === sensor)
36
+ return;
37
+ // Two sensors see each other: the earlier one reports the pair.
38
+ if (other.sensor && spawnedBefore(other, sensor))
39
+ return;
40
+ pairs.push([sensor, other]);
41
+ });
42
+ }
43
+ return pairs;
44
+ }
45
+ /** Both entities of a snapshotted pair are alive and still carry the Collider it was taken from. */
46
+ function stillOverlapping([first, second]) {
47
+ return (first.entity.alive &&
48
+ second.entity.alive &&
49
+ first.entity.get(Collider) === first.collider &&
50
+ second.entity.get(Collider) === second.collider);
51
+ }
52
+ /** `onCollide(other)` on every component of `self`'s entity, when its interest names `other`'s layer. */
53
+ function notify(self, other) {
54
+ if (!interestedIn(self, other))
55
+ return;
56
+ for (const component of [...self.entity.components])
57
+ component.onCollide?.(other.entity);
58
+ }
59
+ /**
60
+ * The 3D counterpart of the Hitbox trigger dispatch (CA-16): after the physics
61
+ * step, every overlap involving a sensor Collider fires `onCollide(other)` on
62
+ * the components of each side whose interest names the other's layer, every
63
+ * step while they overlap. Pairs are frozen first and each is re-checked live,
64
+ * like the 2D dispatch.
65
+ */
66
+ export function dispatchSensorCollisions(game, world) {
67
+ if (!world.hasSensors)
68
+ return;
69
+ for (const pair of sensorPairs(game, world)) {
70
+ if (!stillOverlapping(pair))
71
+ continue;
72
+ const [first, second] = pair;
73
+ notify(first, second);
74
+ if (stillOverlapping(pair))
75
+ notify(second, first);
76
+ }
77
+ }
@@ -0,0 +1,39 @@
1
+ import type { BodyRecord } from './body-sync.js';
2
+ import type { PhysicsWorld } from './physics-world.js';
3
+ /** A point or a direction in world space, as `game.query` takes and returns it. */
4
+ export interface Point3d {
5
+ x: number;
6
+ y: number;
7
+ z: number;
8
+ }
9
+ /** A volume `game.query.area` tests sensor colliders against. */
10
+ export type QueryVolume3d = {
11
+ shape: 'box';
12
+ center: Point3d;
13
+ size: Point3d;
14
+ } | {
15
+ shape: 'sphere';
16
+ center: Point3d;
17
+ radius: number;
18
+ };
19
+ /** One collider a ray reaches: whose it is, how far, where and which way its surface faces. */
20
+ export interface WorldRayHit {
21
+ record: BodyRecord;
22
+ distance: number;
23
+ point: Point3d;
24
+ normal: Point3d;
25
+ }
26
+ /**
27
+ * Every collider along a ray whose record `accepts`, nearest first.
28
+ * `direction` is a unit vector, so the distance is in world units; an origin
29
+ * inside a collider hits it at distance 0.
30
+ */
31
+ export declare function worldRayHits(world: PhysicsWorld, ray: {
32
+ origin: Point3d;
33
+ direction: Point3d;
34
+ maxDistance: number;
35
+ }, accepts: (record: BodyRecord) => boolean): WorldRayHit[];
36
+ /** Every record whose collider `accepts` and that intersects the volume. */
37
+ export declare function recordsInVolume(world: PhysicsWorld, volume: QueryVolume3d, accepts: (record: BodyRecord) => boolean): BodyRecord[];
38
+ /** Every record whose collider `accepts` and that contains the point. */
39
+ export declare function recordsAtPoint(world: PhysicsWorld, at: Point3d, accepts: (record: BodyRecord) => boolean): BodyRecord[];
@@ -0,0 +1,63 @@
1
+ const IDENTITY = { x: 0, y: 0, z: 0, w: 1 };
2
+ /**
3
+ * Rapier's predicate for a query: only colliders whose body record `accepts`.
4
+ * It runs inside Rapier's borrow of the world, so `accepts` must be a plain
5
+ * read of the record (the domain, liveness, the structural filter); game code
6
+ * such as a `where` predicate runs afterwards, on the collected records.
7
+ */
8
+ function accepting(world, accepts) {
9
+ return (collider) => {
10
+ const record = world.recordOfCollider(collider.handle);
11
+ return record !== undefined && accepts(record);
12
+ };
13
+ }
14
+ /**
15
+ * Every collider along a ray whose record `accepts`, nearest first.
16
+ * `direction` is a unit vector, so the distance is in world units; an origin
17
+ * inside a collider hits it at distance 0.
18
+ */
19
+ export function worldRayHits(world, ray, accepts) {
20
+ const { R, raw } = world;
21
+ const { origin, direction } = ray;
22
+ const hits = [];
23
+ raw.intersectionsWithRay(new R.Ray(origin, direction), ray.maxDistance, true, (hit) => {
24
+ const record = world.recordOfCollider(hit.collider.handle);
25
+ const distance = hit.timeOfImpact;
26
+ if (record) {
27
+ hits.push({
28
+ record,
29
+ distance,
30
+ point: { x: origin.x + direction.x * distance, y: origin.y + direction.y * distance, z: origin.z + direction.z * distance },
31
+ normal: { x: hit.normal.x, y: hit.normal.y, z: hit.normal.z },
32
+ });
33
+ }
34
+ return true;
35
+ }, undefined, undefined, undefined, undefined, accepting(world, accepts));
36
+ return hits.sort((a, b) => a.distance - b.distance);
37
+ }
38
+ /** Every record whose collider `accepts` and that intersects the volume. */
39
+ export function recordsInVolume(world, volume, accepts) {
40
+ const { R, raw } = world;
41
+ const shape = volume.shape === 'box'
42
+ ? new R.Cuboid(volume.size.x / 2, volume.size.y / 2, volume.size.z / 2)
43
+ : new R.Ball(volume.radius);
44
+ const found = [];
45
+ raw.intersectionsWithShape(volume.center, IDENTITY, shape, (collider) => {
46
+ const record = world.recordOfCollider(collider.handle);
47
+ if (record)
48
+ found.push(record);
49
+ return true;
50
+ }, undefined, undefined, undefined, undefined, accepting(world, accepts));
51
+ return found;
52
+ }
53
+ /** Every record whose collider `accepts` and that contains the point. */
54
+ export function recordsAtPoint(world, at, accepts) {
55
+ const found = [];
56
+ world.raw.intersectionsWithPoint(at, (collider) => {
57
+ const record = world.recordOfCollider(collider.handle);
58
+ if (record)
59
+ found.push(record);
60
+ return true;
61
+ }, undefined, undefined, undefined, undefined, accepting(world, accepts));
62
+ return found;
63
+ }
@@ -0,0 +1,17 @@
1
+ import * as THREE from 'three/webgpu';
2
+ import type { Entity } from './entity.js';
3
+ import type { PointerPick } from './pointer.js';
4
+ /** A position inside the letterboxed game viewport, as fractions of its width and height. */
5
+ export interface ViewportFraction {
6
+ nx: number;
7
+ ny: number;
8
+ }
9
+ /**
10
+ * The pick for a click on a 3D scene (issue #154 CA-12): a ray through the
11
+ * letterboxed point, intersected with the roots of every live Model
12
+ * (recursively, so a glTF's meshes count and a glTF still loading does not).
13
+ * The nearest hit wins, with its world-space point. With no hit the pick has
14
+ * no entity and the point where the ray meets the ground plane y = 0; a ray
15
+ * that never meets it (it looks at the sky) yields no pick.
16
+ */
17
+ export declare function raycastPick(camera: THREE.PerspectiveCamera, at: ViewportFraction, entities: readonly Entity[]): PointerPick | null;
@@ -0,0 +1,48 @@
1
+ import * as THREE from 'three/webgpu';
2
+ import { Model } from './components/model.js';
3
+ const raycaster = new THREE.Raycaster();
4
+ /** The ground of a 3D scene: y = 0, normal up. */
5
+ const GROUND = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
6
+ /** Every live Model's root, with the entity that owns it. */
7
+ function modelRoots(entities) {
8
+ const roots = new Map();
9
+ for (const entity of entities) {
10
+ if (!entity.alive)
11
+ continue;
12
+ for (const component of entity.components) {
13
+ if (component instanceof Model && component.root)
14
+ roots.set(component.root, entity);
15
+ }
16
+ }
17
+ return roots;
18
+ }
19
+ /** The entity whose Model root `object` hangs under. */
20
+ function ownerOf(object, roots) {
21
+ for (let node = object; node; node = node.parent) {
22
+ const entity = roots.get(node);
23
+ if (entity)
24
+ return entity;
25
+ }
26
+ return null;
27
+ }
28
+ /**
29
+ * The pick for a click on a 3D scene (issue #154 CA-12): a ray through the
30
+ * letterboxed point, intersected with the roots of every live Model
31
+ * (recursively, so a glTF's meshes count and a glTF still loading does not).
32
+ * The nearest hit wins, with its world-space point. With no hit the pick has
33
+ * no entity and the point where the ray meets the ground plane y = 0; a ray
34
+ * that never meets it (it looks at the sky) yields no pick.
35
+ */
36
+ export function raycastPick(camera, at, entities) {
37
+ camera.updateMatrixWorld();
38
+ raycaster.setFromCamera(new THREE.Vector2(at.nx * 2 - 1, 1 - at.ny * 2), camera);
39
+ const roots = modelRoots(entities);
40
+ // A click can land before the first frame has placed anything.
41
+ for (const root of roots.keys())
42
+ root.updateWorldMatrix(true, true);
43
+ const [hit] = raycaster.intersectObjects([...roots.keys()], true);
44
+ if (hit)
45
+ return { entity: ownerOf(hit.object, roots), point: { x: hit.point.x, y: hit.point.y, z: hit.point.z } };
46
+ const ground = raycaster.ray.intersectPlane(GROUND, new THREE.Vector3());
47
+ return ground ? { entity: null, point: { x: ground.x, y: ground.y, z: ground.z } } : null;
48
+ }
package/dist/pointer.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type ViewCamera } from './camera-projection.js';
1
2
  import type { Entity } from './entity.js';
2
3
  import { type ProjectedPoint } from './projection.js';
3
4
  /** Fixed-resolution letterbox target; a duck-typed subset of GameResolution. */
@@ -17,7 +18,8 @@ export interface PointerCamera {
17
18
  bottom: number;
18
19
  }
19
20
  export interface PointerDeps {
20
- camera: PointerCamera;
21
+ /** The live camera, or a function returning it when the scene can swap cameras. */
22
+ camera: ViewCamera | (() => ViewCamera);
21
23
  /** Fixed resolution (letterbox target), or null to fill the canvas. */
22
24
  resolution: PointerResolution | null;
23
25
  /** The scene's active render projection, read live. */
@@ -25,9 +27,14 @@ export interface PointerDeps {
25
27
  /** Live entity list — read at pick time, so later spawns are visible. */
26
28
  entities: readonly Entity[];
27
29
  }
28
- /** One resolved click: the logical-space point, plus the entity picked there (if any). */
30
+ /**
31
+ * One resolved click: the logical-space point, plus the entity picked there
32
+ * (if any). In a 3D scene the point is in world space and carries `z`.
33
+ */
29
34
  export interface PointerPick {
30
- readonly point: ProjectedPoint;
35
+ readonly point: ProjectedPoint & {
36
+ readonly z?: number;
37
+ };
31
38
  readonly entity: Entity | null;
32
39
  }
33
40
  /**
@@ -44,6 +51,7 @@ export declare class Pointer {
44
51
  private readonly canvas;
45
52
  private readonly deps;
46
53
  private pending;
54
+ private readonly liveCamera;
47
55
  constructor(canvas: HTMLCanvasElement, deps: PointerDeps);
48
56
  dispose(): void;
49
57
  /**
@@ -53,11 +61,24 @@ export declare class Pointer {
53
61
  */
54
62
  takePending(): PointerPick | null;
55
63
  /**
56
- * Programmatic click injection (Runtime Bridge CA-10), in logical
57
- * coordinates — resolved through the exact same picking a real click uses.
64
+ * Programmatic click injection (Runtime Bridge CA-10) — resolved through
65
+ * the exact same picking a real click uses. In a 2D scene `x` and `y` are
66
+ * logical coordinates and the pick always exists. In a 3D scene they are
67
+ * canvas CSS pixels from its top-left corner, as a pointer event reports
68
+ * them, and a ray that meets neither a model nor the ground gives null.
58
69
  */
59
- injectClick(x: number, y: number): PointerPick;
70
+ injectClick(x: number, y: number): PointerPick | null;
71
+ /** Makes a pick the pending click; no pick leaves any earlier click alone. */
72
+ private queue;
60
73
  private handlePointerDown;
74
+ /** The pick under a viewport position: the 2D sprite boxes, or a 3D scene's raycast. */
75
+ private pickAt;
76
+ /**
77
+ * Where a canvas point (CSS px from its top-left corner) falls inside the
78
+ * letterboxed viewport, as fractions of its width and height; null on a
79
+ * letterbox bar (not a click on the world) or before the canvas has a size.
80
+ */
81
+ private viewportFraction;
61
82
  private letterboxRect;
62
83
  private toRenderPoint;
63
84
  private resolveRenderPoint;
package/dist/pointer.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { isPerspectiveCamera } from './camera-projection.js';
2
+ import { raycastPick } from './pointer-raycast.js';
1
3
  import { projectIsometric, unprojectIsometric } from './projection.js';
2
4
  import { ySortZ } from './render-sort.js';
3
5
  import { AnimatedSprite } from './components/animated-sprite.js';
@@ -30,9 +32,12 @@ export class Pointer {
30
32
  canvas;
31
33
  deps;
32
34
  pending = null;
35
+ liveCamera;
33
36
  constructor(canvas, deps) {
34
37
  this.canvas = canvas;
35
38
  this.deps = deps;
39
+ const { camera } = this.deps;
40
+ this.liveCamera = typeof camera === 'function' ? camera : () => camera;
36
41
  this.canvas.addEventListener('pointerdown', this.handlePointerDown);
37
42
  }
38
43
  dispose() {
@@ -50,39 +55,60 @@ export class Pointer {
50
55
  return pick;
51
56
  }
52
57
  /**
53
- * Programmatic click injection (Runtime Bridge CA-10), in logical
54
- * coordinates — resolved through the exact same picking a real click uses.
58
+ * Programmatic click injection (Runtime Bridge CA-10) — resolved through
59
+ * the exact same picking a real click uses. In a 2D scene `x` and `y` are
60
+ * logical coordinates and the pick always exists. In a 3D scene they are
61
+ * canvas CSS pixels from its top-left corner, as a pointer event reports
62
+ * them, and a ray that meets neither a model nor the ground gives null.
55
63
  */
56
64
  injectClick(x, y) {
65
+ const camera = this.liveCamera();
66
+ if (isPerspectiveCamera(camera))
67
+ return this.queue(this.pickAt(this.viewportFraction(x, y)));
57
68
  const renderPoint = this.toRenderPoint(x, y);
58
- const pick = this.resolveRenderPoint(renderPoint.x, renderPoint.y);
59
- this.pending = pick;
69
+ return this.queue(this.resolveRenderPoint(renderPoint.x, renderPoint.y));
70
+ }
71
+ /** Makes a pick the pending click; no pick leaves any earlier click alone. */
72
+ queue(pick) {
73
+ if (pick)
74
+ this.pending = pick;
60
75
  return pick;
61
76
  }
62
77
  handlePointerDown = (event) => {
63
78
  // Primary button only; a touch tap reports the same button (ADR-0010).
64
79
  if (event.button !== 0)
65
80
  return;
81
+ const rect = this.canvas.getBoundingClientRect();
82
+ this.queue(this.pickAt(this.viewportFraction(event.clientX - rect.left, event.clientY - rect.top)));
83
+ };
84
+ /** The pick under a viewport position: the 2D sprite boxes, or a 3D scene's raycast. */
85
+ pickAt(at) {
86
+ const camera = this.liveCamera();
87
+ if (!at)
88
+ return null;
89
+ if (isPerspectiveCamera(camera))
90
+ return raycastPick(camera, at, this.deps.entities);
91
+ const renderX = camera.position.x + camera.left + at.nx * (camera.right - camera.left);
92
+ const renderY = camera.position.y + camera.top - at.ny * (camera.top - camera.bottom);
93
+ return this.resolveRenderPoint(renderX, renderY);
94
+ }
95
+ /**
96
+ * Where a canvas point (CSS px from its top-left corner) falls inside the
97
+ * letterboxed viewport, as fractions of its width and height; null on a
98
+ * letterbox bar (not a click on the world) or before the canvas has a size.
99
+ */
100
+ viewportFraction(px, py) {
66
101
  const w = this.canvas.clientWidth;
67
102
  const h = this.canvas.clientHeight;
68
103
  if (w <= 0 || h <= 0)
69
- return;
104
+ return null;
70
105
  const { vx, vy, vw, vh } = this.letterboxRect(w, h);
71
106
  if (vw <= 0 || vh <= 0)
72
- return;
73
- const rect = this.canvas.getBoundingClientRect();
74
- const px = event.clientX - rect.left;
75
- const py = event.clientY - rect.top;
107
+ return null;
76
108
  const nx = (px - vx) / vw;
77
109
  const ny = (py - vy) / vh;
78
- // Outside the visible viewport (a letterbox bar): not a click on the world.
79
- if (nx < 0 || nx > 1 || ny < 0 || ny > 1)
80
- return;
81
- const camera = this.deps.camera;
82
- const renderX = camera.position.x + camera.left + nx * (camera.right - camera.left);
83
- const renderY = camera.position.y + camera.top - ny * (camera.top - camera.bottom);
84
- this.pending = this.resolveRenderPoint(renderX, renderY);
85
- };
110
+ return nx < 0 || nx > 1 || ny < 0 || ny > 1 ? null : { nx, ny };
111
+ }
86
112
  letterboxRect(w, h) {
87
113
  const resolution = this.deps.resolution;
88
114
  if (!resolution)
@@ -0,0 +1,33 @@
1
+ import { type ColorGradeEffect, type VignetteEffect } from './scene-render-options.js';
2
+ import type { SceneRenderJson } from './scene.js';
3
+ /**
4
+ * `game.post` (issue #78): the live scene's Post Effects — a vignette and a
5
+ * color grade, each off (null) unless its scene declares it or a game turns
6
+ * it on. With either on, the frame renders through a render target and one
7
+ * full-screen pass; with both off, it draws straight to the canvas. Camera
8
+ * Effects (ADR 0020) sit above and are never altered. They die with their scene.
9
+ */
10
+ export declare class GamePost {
11
+ private state;
12
+ /** The vignette now, a copy; null when off. */
13
+ get vignette(): VignetteEffect | null;
14
+ /**
15
+ * Turns the vignette off with null, or changes it field by field over the
16
+ * current one (absent fields take their defaults when it was off), clamped to 0..1.
17
+ */
18
+ set vignette(value: Partial<VignetteEffect> | null);
19
+ /** The color grade now, a copy; null when off. */
20
+ get colorGrade(): ColorGradeEffect | null;
21
+ /**
22
+ * Turns the color grade off with null, or changes it field by field over the
23
+ * current one (absent fields neutral when it was off), clamped — like
24
+ * `game.lighting.ambient`, so a tween of one field keeps the others.
25
+ */
26
+ set colorGrade(value: Partial<ColorGradeEffect> | null);
27
+ /** Whether any Post Effect is on. */
28
+ get active(): boolean;
29
+ /** Adopts a scene's `render.post`, replacing whatever the previous scene left. Called by Game. */
30
+ loadScene(render: SceneRenderJson | undefined): void;
31
+ /** Every effect off again. */
32
+ unloadScene(): void;
33
+ }