@waica/engine 0.28.0 → 0.29.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.
- package/README.md +22 -8
- package/dist/anchored-pieces.js +21 -5
- package/dist/archetype.d.ts +15 -0
- package/dist/archetype.js +41 -1
- package/dist/camera-projection.d.ts +6 -5
- package/dist/camera-projection.js +8 -7
- package/dist/camera-rig.d.ts +32 -3
- package/dist/camera-rig.js +75 -1
- package/dist/components/animator.d.ts +6 -0
- package/dist/components/animator.js +12 -0
- package/dist/entity-update-schedules.d.ts +21 -0
- package/dist/entity-update-schedules.js +67 -0
- package/dist/game.d.ts +13 -11
- package/dist/game.js +40 -74
- package/dist/index.d.ts +7 -2
- package/dist/index.js +2 -0
- package/dist/input-mouse.d.ts +83 -0
- package/dist/input-mouse.js +205 -0
- package/dist/input.d.ts +26 -5
- package/dist/input.js +58 -10
- package/dist/orbit-camera.d.ts +68 -0
- package/dist/orbit-camera.js +92 -0
- package/dist/physics-3d/world-queries.d.ts +10 -1
- package/dist/physics-3d/world-queries.js +6 -2
- package/dist/runtime-bridge.d.ts +12 -2
- package/dist/runtime-bridge.js +12 -1
- package/dist/runtime-inspection.js +3 -2
- package/dist/runtime-view-snapshot.d.ts +5 -8
- package/dist/runtime-view-snapshot.js +13 -10
- package/dist/scene-camera-3d.d.ts +14 -15
- package/dist/scene-camera-3d.js +14 -10
- package/dist/scene-camera-orbit.d.ts +72 -0
- package/dist/scene-camera-orbit.js +155 -0
- package/dist/scene-field-issues.d.ts +16 -0
- package/dist/scene-field-issues.js +11 -0
- package/dist/scene.js +5 -3
- package/dist/spatial-query-3d.d.ts +2 -2
- package/dist/spatial-query-3d.js +13 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,6 +25,20 @@ A `Game` draws through three's `WebGPURenderer` (ADR 0025): WebGPU when the brow
|
|
|
25
25
|
- **Tests under happy-dom.** happy-dom has no GPU: a project test that builds a `Game` replaces `WebGPURenderer` from `three/webgpu` with a fake (it previously replaced `WebGLRenderer` from `three`), whose `init()` resolves.
|
|
26
26
|
- **Run Sessions.** A Run Session waits for the Render Backend before it reports ready, steps or screenshots, and its snapshots carry `backend`. A renderer that cannot initialize ends `start_project` with a runtime error that names the failure.
|
|
27
27
|
|
|
28
|
+
## Migrating to 0.29.0: third-person archetype
|
|
29
|
+
|
|
30
|
+
The release adds `@waica/archetype-third-person` (issue #181, ADR 0031) and the engine and behavior pieces it needs. Every existing scene file loads as it did; what changes:
|
|
31
|
+
|
|
32
|
+
- **`examples/smoke-3d` is gone**, and with it `pnpm dev:3d`: `pnpm dev:third-person` runs `examples/third-person`, a synced copy of the new archetype's demo (a dungeon courtyard with KayKit's CC0 Knight and Skeleton Warriors). The browser e2e's 3D legs run on that demo.
|
|
33
|
+
- **`MeleeAttack`, `Patrol` and `Chaser` run in both spaces** (they no longer declare `space: '2d'`). In 2D they behave exactly as before. In 3D: `MeleeAttack.strike()` takes no facing and hits every collider in a `range` × `height` × `width` box in front of the attacker's yaw, centred on the attacker's `Collider` (`height` is new, default 2) whose layer the attacker's Collider mask names, pushing each body it hurts away at `knockbackSpeed` (new, default 8) for 0.15 s; `Patrol` paces x (`horizontal`) or z (`vertical`) and `Chaser` walks straight at the player on xz, both through a kinematic `RigidBody`'s desired velocity; a `Patrol` blocked short of its rail's end turns around there. `validate_project` no longer reports them as a `component-space-mismatch` in a 3D scene, and reports one without a `Collider` and a kinematic `RigidBody` there as `mover-without-body`.
|
|
34
|
+
- **The perspective camera may `follow` an entity**: the block's orbit fields (`distance`, `minDistance`, `maxDistance`, `yaw`, `pitch`, `minPitch`, `maxPitch`, `lookHeight`, `sensitivity`, `orbitSpeed`, `invertY` and the four `orbit…Action` names) make it the Orbit Camera. A block that named `follow` was an error; it is now accepted, and `validate_project` reports an unknown target, inverted ranges and a pitch outside (-89, 89), and warns about an initial `distance` or `pitch` outside its range (the camera clamps it).
|
|
35
|
+
- **`Mouse:Left` and `Mouse:Right` bind Actions** beside keys and `Gamepad:` codes; an unknown `Mouse:` code is warned once. Touch and pen pointers never press them. `game.input.mouseMovement()` and `game.input.wheelDelta()` report the step's locked movement and wheel; the wheel's page scroll is prevented only over the canvas of a scene whose orbit camera plays (the `preventsWheelScroll` Input option), so other games keep the page scrolling as before. `requestPointerLock()` and `pointerLocked` reach the Pointer Lock API; a click that asks for a lock the browser refuses reaches its Action as any click. The Runtime Bridge and `control_runtime` gain `look { dx, dy }` (capability `'look'`).
|
|
36
|
+
- **`--waica-unit` is per Anchored Piece in a perspective scene**: each piece's host carries the unit at its own anchor's depth (a piece twice as far is half as wide), over the layer's unit at the world origin. Orthographic scenes are unchanged.
|
|
37
|
+
- **Camera shake moves a perspective camera** along its right and up axes, scaled so the image moves as far as the same shake in 2D; the Runtime Snapshot reports the shake offset in a 3D scene too (it reported zero).
|
|
38
|
+
- **`CharacterMotor`** gains `relativeTo` (`'world'` by default: unchanged), `runAction`/`runSpeed`, `dodgeAction`/`dodgeSpeed`/`dodgeDuration`/`dodgeCooldown`, `attackAction` and `halt()`, and a knockback that reaches it mid-roll is ignored; the `character` role gains `run`, `attack`, `roll` and `die`, entered only when those Actions are bound (and, for `attack`, beside a `MeleeAttack`; for `die`, on a Health death). `Health.guard(seconds)` opens an invulnerability window without the blink. `Respawnable.setSpawn(point)` moves its respawn point, and `respawn()` halts a `CharacterMotor` too. New: `Checkpoint`, the `enemy` role (`ENEMY_ROLE`), `knockBackFrom`, `drive3d`, `isPlayer`.
|
|
39
|
+
- **The Runtime Snapshot shows a 3D hit's push and the death sequence**: a `Health`'s state adds `knockback: { x, z, until }` while a push is pending (absent otherwise, so 2D snapshots read as before), and a `Respawnable` beside the `character` role reports `respawnPhase` (`'pose'`, `'fade-out'` or `null`) and `fadeOutSince`.
|
|
40
|
+
- **`game.query.area` in 3D** takes `colliders: 'all'` (solid colliders too; sensors only by default) and a box `yaw` in degrees. `Animator.clipProgress` reports how far the playing clip is.
|
|
41
|
+
|
|
28
42
|
## Migrating to 0.28.0: 3D editor
|
|
29
43
|
|
|
30
44
|
The editor edits 3D scenes (issue #173, ADR 0030). Scene files and game code are untouched; three engine seams become public, all additive:
|
|
@@ -247,7 +261,7 @@ The CPU reference of a light-map texel — `lightFalloff`, `lightVisibility`, `l
|
|
|
247
261
|
|
|
248
262
|
## 3D scenes
|
|
249
263
|
|
|
250
|
-
A scene declares its space (ADR 0027): `render.space: '2d'` (the default) or `'3d'`. `game.space` reports the live scene's, `'2d'` with no scene. In a 3D scene `z` is a world axis (Y is up, the ground is the XZ plane, the default camera looks down −Z), the frame is drawn straight to the canvas with a depth buffer deciding what is in front, and there is no layer band, y-sort, isometric projection, Sprite Batch or light-map. `render.post` still works. `validate_project` rejects `render.sort`, `render.projection` and `render.batch` in a 3D scene, a 2D component on a 3D scene's entities and a 3D component in a 2D scene (`component-space-mismatch`). A component declares its space on its class, `static space = '2d' | '3d' | 'both'` (absent means both): `Sprite`, `AnimatedSprite`, `Tilemap`, `Solid`, `DynamicBody`, `Hitbox`, `Light`, `ParticleEmitter` and the behaviors of `@waica/behaviors` that read or move x/y as the orthographic plane or need a 2D sibling (the motors, `
|
|
264
|
+
A scene declares its space (ADR 0027): `render.space: '2d'` (the default) or `'3d'`. `game.space` reports the live scene's, `'2d'` with no scene. In a 3D scene `z` is a world axis (Y is up, the ground is the XZ plane, the default camera looks down −Z), the frame is drawn straight to the canvas with a depth buffer deciding what is in front, and there is no layer band, y-sort, isometric projection, Sprite Batch or light-map. `render.post` still works. `validate_project` rejects `render.sort`, `render.projection` and `render.batch` in a 3D scene, a 2D component on a 3D scene's entities and a 3D component in a 2D scene (`component-space-mismatch`). A component declares its space on its class, `static space = '2d' | '3d' | 'both'` (absent means both): `Sprite`, `AnimatedSprite`, `Tilemap`, `Solid`, `DynamicBody`, `Hitbox`, `Light`, `ParticleEmitter` and the behaviors of `@waica/behaviors` that read or move x/y as the orthographic plane or need a 2D sibling (the 2D motors, `Hazard`, `Interactable`, `ClickToMove`, and the particle cues `DamagePuff`, `DustPuffs`, `DustTrail`, `SwingSparks`) are `'2d'`; `Model`, `Sun`, `PointLight`, `Collider`, `RigidBody` and `CharacterMotor` are `'3d'`; `StateMachine`, the behaviors that only react to an overlap or read y as up, and a project-owned component without a marker are allowed in both (a project-owned component says otherwise with its own `static space`).
|
|
251
265
|
|
|
252
266
|
```json
|
|
253
267
|
{
|
|
@@ -263,21 +277,21 @@ A scene declares its space (ADR 0027): `render.space: '2d'` (the default) or `'3
|
|
|
263
277
|
}
|
|
264
278
|
```
|
|
265
279
|
|
|
266
|
-
- **Camera.** `{ kind: 'perspective', position, target, fov, near, far }`, defaults `[0, 5, 10]`, `[0, 0, 0]`, 60, 0.1, 1000;
|
|
280
|
+
- **Camera.** `{ kind: 'perspective', position, target, fov, near, far }`, defaults `[0, 5, 10]`, `[0, 0, 0]`, 60, 0.1, 1000; the 2D fields other than `follow` are not accepted. A 3D scene without a camera block gets the defaults. Without `follow` the camera is fixed by the scene; game code may move `game.camera`. With `follow: '<entity>'` it is the Orbit Camera (ADR 0031): it looks at the entity plus `lookHeight` (1.2) from `distance` (6) at `yaw` (0: looking toward −z) and `pitch` (20) in degrees, turned by the step's locked mouse movement times `sensitivity` (0.2 °/px; `invertY`) and by the Actions `orbitLeftAction`, `orbitRightAction`, `orbitUpAction` and `orbitDownAction` (`look_left`… by default) at `orbitSpeed` (120 °/s), pitch clamped to `minPitch`..`maxPitch` (−10..70), the wheel moving `distance` within `minDistance`..`maxDistance` (2..12); once per Simulation Step a ray toward it keeps it 0.2 in front of fixed level geometry (never nearer than 0.5; entities with a `RigidBody` never block it) without changing the chosen distance. A click on the canvas takes the pointer lock while it plays. `game.cameraYaw` is its yaw, `null` without one. `resize()` keeps the letterbox and sets the camera's aspect from the letterboxed size. Camera Fade, Flash and Shake work in 3D.
|
|
267
281
|
- **Model.** Draws a glTF/glb from `src` (a `waica:` uri or a project path under `src/art/`, loaded once through `game.assets.model(uri)`) or a primitive `shape` (`'box'`, `'sphere'`, `'plane'` — the plane lies on the ground) of a `color` (`0xrrggbb`) and a uniform `size`, with a standard (lit) material. With both `src` and `shape`, `src` wins. The entity's `position`, `rotation` and `scale` place it; `color` and `size` follow the inspector live, a new `src` or `shape` rebuilds it. `handle` is the `ModelHandle` of the file it draws (null for a primitive): its clips play through an `Animator` beside it.
|
|
268
282
|
- **Animator.** Plays the glTF clips of the `Model` beside it (ADR 0029): one `AnimationMixer` over the entity's own clone, advanced by the Simulation Step, nothing per render frame. It is the 3D Clip Target, so a `StateMachine` plays each state's clip through it and `clip:end` fires when a `loop: false` clip finishes. Params: `initialClip` (plays when the file settles, if nothing asked for a clip before it loaded — a `play` before the model loads is remembered and applied then; `clipsPending` is true meanwhile, so a `StateMachine` asks for its state's clip anyway) and `fade` (seconds of crossfade, default 0.15; `0` cuts). The `clips` prop, authored in JSON like `AnimatedSprite.clips`, holds per-clip overrides keyed by the glTF clip name: `{ loop: false }` plays once and holds the last pose, `{ speed: 2 }` doubles the rate; they are read when the actions are built, as the file settles. A Model rebuild (a new `src` or `shape`) is noticed on the next step: the Animator drops the mixer over the old clone, attaches to the new one and asks for the clip it was playing. Clip names match the file exactly (a state's `clip` override maps `idle` to a pack's `Idle`); an unknown name warns once. `inspect_runtime` shows `{ loaded, current, time, playing, poseHash }`. In a 2D scene it creates nothing and `play` is a no-op; without a `Model` file beside it it warns once at runtime and `validate_project` reports `animator-without-model` per scene entity (an instance may complete its prefab with a `Model.src` override), while `missing-clip` checks its states, `initialClip` and override keys against the glb's clips, on the prefab and again only on an instance that changes them.
|
|
269
|
-
- **Lights.** The Ambient Light of a 3D scene drives one `THREE.AmbientLight` the Game owns (`render.lighting.ambient` and `game.lighting.ambient`, full white by default). `Sun` is a directional light: `direction` `[x, y, z]` in world space (default `[-1, -2, -1]`), `color`, `intensity`. `PointLight` shines from its entity plus `offsetX/Y/Z`: `color`, `intensity`, `distance` (0 = unlimited), `decay` (default 2). Both create nothing in a 2D scene, follow their props live and leave with their entity. No shadows. three's lights are physically based: a point light of intensity 1 is dim, so a scene tunes intensities (the
|
|
283
|
+
- **Lights.** The Ambient Light of a 3D scene drives one `THREE.AmbientLight` the Game owns (`render.lighting.ambient` and `game.lighting.ambient`, full white by default). `Sun` is a directional light: `direction` `[x, y, z]` in world space (default `[-1, -2, -1]`), `color`, `intensity`. `PointLight` shines from its entity plus `offsetX/Y/Z`: `color`, `intensity`, `distance` (0 = unlimited), `decay` (default 2). Both create nothing in a 2D scene, follow their props live and leave with their entity. No shadows. three's lights are physically based: a point light of intensity 1 is dim, so a scene tunes intensities (the third-person demo uses 2.2 for the Sun and 14 for each torch).
|
|
270
284
|
- **`game.worldToScreen(point)`.** `{ x, y, z? }` in logical coordinates to canvas CSS pixels inside the letterbox, or `null` when a perspective camera cannot see the point (behind its near plane). Anchored Pieces and spatial audio go through the same projection: in 3D a piece behind the camera is hidden, sounds attenuate by the 3D distance from the camera and pan by where they land on screen.
|
|
271
285
|
- **Picking.** A click in a 3D scene casts a ray through the letterboxed point against every live `Model` (a glTF still loading is skipped) and resolves `PointerPick` with the nearest hit's entity and its world-space point (`point.z`). With no hit the entity is `null` and the point is where the ray meets the ground plane y = 0; a ray at the sky yields no pick.
|
|
272
286
|
- **Assets.** `game.assets.model(uri)` returns a `ModelHandle` at once: `root` is empty until the file settles, then holds your own clone of the cached glTF (skinned meshes cloned with `SkeletonUtils.clone`), `animations` its clips, `settled` resolves `'loaded'` or `'failed'`. One backend load per URL for the Game's life; `status` counts models with textures, `preload` routes `.glb`/`.gltf` to them, `GameOptions.models` replaces the real `GLTFLoader` backend (`ModelBackend { load(url) }`) for a project's own tests. Only a `.glb`, or a `.gltf` with every buffer and image embedded as a `data:` uri, works in the editor (which serves each file from a `blob:` url) and in a built project (which hashes every art file's name): a `.gltf` that points at an external `.bin` or textures loads empty there, and `validate_project` warns (`gltf-external-resource`).
|
|
273
287
|
- **Simulation.** A 3D scene gets one Rapier world (ADR 0028, `@dimforge/rapier3d-deterministic-compat` 0.21.0), stepped once per Simulation Step after every component's `onUpdate`. `simulation: { gravity: [x, y, z] }` in the scene sets its gravity (default `[0, -9.81, 0]`; a `simulation` block in a 2D scene is an error, `invalid-scene-simulation`). Rapier is imported dynamically by the first 3D scene that loads, counts as a pending asset (`game.assets.ready()` and `game.ready()` of a Game with a live 3D scene wait for it, and `game.ready()` rejects naming the package if it fails), and a 2D project never loads it. Steps taken while it loads run every component update and skip only the physics. `GameOptions.physics` replaces the import for a project's own tests.
|
|
274
288
|
- **Collider.** `shape` (`'box'`, `'sphere'`, `'capsule'`), `size` (a box's full extents), `radius`, `height` (a capsule's end-to-end height), `offset` (in the entity's local frame), `sensor`, `layer`, `collidesWith`, `friction`, `restitution`. Alone on an entity it is a fixed wall or floor, created at the entity's position and rotation and scaled by its `scale` (box size and capsule height per axis, a sphere's radius by the largest axis, a capsule's by the larger of x and z); moving the entity later does not move it. A capsule's `height` is at least twice its radius (a shorter one would be a taller sphere). `validate_project` reports `invalid-collider-param` for an unknown shape, a non-positive dimension, a radius or height under 0.01, a capsule shorter than twice its radius, an `offset` that is not three finite numbers, a bad layer or mask and a friction or restitution outside 0 to 1; at runtime such a Collider creates no body and warns once.
|
|
275
289
|
- **RigidBody.** `type` (`'dynamic'` or `'kinematic'`), `mass`, `gravityScale`, `linearDamping`, `angularDamping`, `lockRotations`, `velocity` (initial). It needs a `Collider` on the same entity (`rigid-body-without-collider`). A dynamic body is Rapier's: the engine writes the entity's position and rotation from it after every step; `linearVelocity` (get and set) and `applyImpulse` are its public API (`grounded` is always false on it). A kinematic body is a character: each step it moves through Rapier's character controller by `desiredVelocity` (`{ x, z }`, set by a motor) and its own vertical velocity under gravity, climbs steps up to `stepHeight`, stands on slopes up to `maxSlope` degrees and slides down steeper ones, sticks to the ground within `snapDistance`, pushes dynamic bodies, ignores sensors, and `jump(speed)` launches it while `grounded` and reports whether it did. Setting its entity's position teleports it, with no fall speed left. `validate_project` reports a `type` outside its options, a number outside its range or a `velocity` that is not three numbers as `invalid-rigid-body-param`; at runtime such a body is not created and one warning says why. `CharacterMotor` (`@waica/behaviors`) is the stock motor: `speed`, `jumpSpeed`, `turnSpeed` and five action names, walking on the world axes (`-z` is forward, diagonals at the cardinal speed), consuming the jump press only when it jumped; `character-motor-without-body` reports one without a kinematic `RigidBody` and a `Collider`, and `invalid-character-motor-param` a param outside the range its spec declares.
|
|
276
|
-
- **Character.** A 3D character faces where it walks by turning, not by directional clips or flips (issue #166): each step it has a non-zero desired velocity, `CharacterMotor` turns the entity's yaw toward it through `turnYawToward(node, dirX, dirZ, turnSpeed, dt)` (`@waica/behaviors`; the target yaw is `atan2(dirX, dirZ)`, so a glTF's +z front leads; the shortest arc, at most `turnSpeed` degrees per second), and with no input, or a `speed` of 0, the yaw holds. `turnSpeed` defaults to 540 (about a third of a second for a U-turn), `0` snaps, and the kinematic body is unaffected: it keeps the rotation it spawned with, so give a turning character a `Collider` that looks the same from every side — a capsule or a sphere with no x/z `offset` — or the model and a box or offset collider disagree about where it ends. The `character` role (`CHARACTER_ROLE`, `CHARACTER_STATE_GRAPH`, `characterUpdate`) is the 3D counterpart of `player`: `idle`, `walk`, `jump` and `fall` on the signals its update emits by reading the kinematic `RigidBody` — `land` plus `move` (planar speed above 0.1 u/s) or `stop` on the ground, `rise` or `fall` in the air once it has vertical speed (air with none — the spawn, the steps while Rapier loads, the step after a teleport — reports nothing) — while the motor keeps reading the input itself; an `Animator` beside the machine plays each state's clip (a per-state `clip` maps `idle` to a pack's `Idle`). A project registers it with `defineRole('character', CHARACTER_ROLE)` in `src/roles/character.ts`; `validate_project` recognizes a `@waica/behaviors` role passed by reference there, so its states report no `no-state-code`.
|
|
290
|
+
- **Character.** A 3D character faces where it walks by turning, not by directional clips or flips (issue #166): each step it has a non-zero desired velocity, `CharacterMotor` turns the entity's yaw toward it through `turnYawToward(node, dirX, dirZ, turnSpeed, dt)` (`@waica/behaviors`; the target yaw is `atan2(dirX, dirZ)`, so a glTF's +z front leads; the shortest arc, at most `turnSpeed` degrees per second), and with no input, or a `speed` of 0, the yaw holds. `turnSpeed` defaults to 540 (about a third of a second for a U-turn), `0` snaps, and the kinematic body is unaffected: it keeps the rotation it spawned with, so give a turning character a `Collider` that looks the same from every side — a capsule or a sphere with no x/z `offset` — or the model and a box or offset collider disagree about where it ends. The `character` role (`CHARACTER_ROLE`, `CHARACTER_STATE_GRAPH`, `characterUpdate`) is the 3D counterpart of `player`: `idle`, `walk`, `jump` and `fall` on the signals its update emits by reading the kinematic `RigidBody` — `land` plus `move` (planar speed above 0.1 u/s) or `stop` on the ground, `rise` or `fall` in the air once it has vertical speed (air with none — the spawn, the steps while Rapier loads, the step after a teleport — reports nothing) — while the motor keeps reading the input itself; an `Animator` beside the machine plays each state's clip (a per-state `clip` maps `idle` to a pack's `Idle`). Since issue #181 the motor also walks relative to the Orbit Camera (`relativeTo: 'camera'`), runs (`runAction`, `runSpeed` on the ground) and rolls (`dodgeAction`: `dodgeDuration` seconds at `dodgeSpeed` along the input or the facing, opening a beside Health's invulnerability without the blink, then `dodgeCooldown`), and the role adds `run`, `roll`, `attack` (on an attack press beside a `MeleeAttack`, striking once at 40 % of its clip and back to `idle` on `clip:end`) and `die` (on a Health death; beside a `Respawnable`, a fade out, the respawn with full health and a fade in). A project registers it with `defineRole('character', CHARACTER_ROLE)` in `src/roles/character.ts`; `validate_project` recognizes a `@waica/behaviors` role passed by reference there, so its states report no `no-state-code`.
|
|
277
291
|
- **Contacts and sensors.** Components on an entity with a solid `Collider` get `onBodyContact({ entity, other, normal, point })` once per step for each entity they touch, the normal pointing from the entity to the other; kinematic bodies included, so a character hears the floor and the walls it touches. A `Collider{ sensor: true }` is the 3D `Hitbox`: it fires `onCollide(other)` every step it overlaps another collider, on each side whose `collidesWith` names the other's `layer` (ADR 0016); a solid collider is told only if it declares its own mask, and a sensor with the default mask (`['*']`) is not told about fixed solid colliders (the level's walls and floors), as a Hitbox never sees a Solid — a mask naming their layer still is.
|
|
278
|
-
- **Queries.** `game.query` gains 3D forms told apart by argument shape: `ray(origin, direction, maxDistance, filter?)` returns the first solid collider (`{ entity, collider, distance, point, normal }`, sensors never block it), `area({ shape: 'box', center, size } | { shape: 'sphere', center, radius }, filter?)` and `point(at, filter?)` return the entities whose sensor colliders intersect or contain them, `nearest(at, filter?)` the closest transform with `z`. The filter object is the 2D one; its `where` runs after Rapier has answered, so it may throw or change the scene, and `area` answers `[]` for a volume that is not a finite box or sphere. A form in the other space's scene throws an error naming the space. Rapier updates what queries read inside the step, so the engine refreshes it when bodies came or went.
|
|
279
|
-
- **Runtime Snapshot.** `space`, `view` (`{ kind: 'orthographic', position, zoom }` or `{ kind: 'perspective', position, target, fov }`) and, in a 3D scene, `lighting.sun[]` and `lighting.pointLights[]`, and `physics: { state: 'loading' | 'ready' | 'failed', gravity, bodies: [{ entity, id, type: 'fixed' | 'dynamic' | 'kinematic', velocity, grounded }] }` (bodies in spawn order, never filtered).
|
|
280
|
-
- **Editor.** A 3D scene is edited through the Editor Camera (ADR 0030): in edit mode the viewport draws, picks and projects through an editor-owned perspective camera set with `game.cameraRig.setViewCamera(camera)` (`game.camera` returns it while set; `game.cameraRig.sceneCamera` keeps the one the JSON places; `null` restores it; a 2D scene ignores it with a warning). It starts at the scene camera's pose, orbits (middle button or Alt+left), pans (right button or Shift+middle), zooms (wheel), steps with the arrows, Shift+arrows, `+` and `-` on the focused viewport, frames the selection with `F`, and is remembered per scene; Play draws through the scene camera. A click picks through `raycastPick` (Models, and the icons of Suns, PointLights, meshless entities and the scene camera); `TransformControls` move, rotate and scale the selection (W, E, R; World or Local; Ctrl snaps to per-user steps), one undo per drag; a prefab drops where the ray meets a Model or the ground; an XZ grid and the helpers (the camera's frustum, each Sun's direction, each PointLight's range, each Collider's shape) draw in edit mode only. The inspector edits X/Y/Z, rotation and scale and a perspective camera's position, target and fov, and the Animator's clip fields list the glb's clips, read by `glbClipNames(bytes)`.
|
|
292
|
+
- **Queries.** `game.query` gains 3D forms told apart by argument shape: `ray(origin, direction, maxDistance, filter?)` returns the first solid collider (`{ entity, collider, distance, point, normal }`, sensors never block it), `area({ shape: 'box', center, size, yaw? } | { shape: 'sphere', center, radius }, filter?)` and `point(at, filter?)` return the entities whose sensor colliders intersect or contain them (an `area` volume with `colliders: 'all'` reads solid colliders too, and a box turns by `yaw` degrees about +y — how a 3D `MeleeAttack` finds its targets), `nearest(at, filter?)` the closest transform with `z`. The filter object is the 2D one; its `where` runs after Rapier has answered, so it may throw or change the scene, and `area` answers `[]` for a volume that is not a finite box or sphere. A form in the other space's scene throws an error naming the space. Rapier updates what queries read inside the step, so the engine refreshes it when bodies came or went.
|
|
293
|
+
- **Runtime Snapshot.** `space`, `view` (`{ kind: 'orthographic', position, zoom }` or `{ kind: 'perspective', position, target, fov }`, plus `follow`, `yaw`, `pitch`, `distance` and `distanceActual` for an Orbit Camera) and, in a 3D scene, `lighting.sun[]` and `lighting.pointLights[]`, and `physics: { state: 'loading' | 'ready' | 'failed', gravity, bodies: [{ entity, id, type: 'fixed' | 'dynamic' | 'kinematic', velocity, grounded }] }` (bodies in spawn order, never filtered).
|
|
294
|
+
- **Editor.** A 3D scene is edited through the Editor Camera (ADR 0030): in edit mode the viewport draws, picks and projects through an editor-owned perspective camera set with `game.cameraRig.setViewCamera(camera)` (`game.camera` returns it while set; `game.cameraRig.sceneCamera` keeps the one the JSON places; `null` restores it; a 2D scene ignores it with a warning). It starts at the scene camera's pose, orbits (middle button or Alt+left), pans (right button or Shift+middle), zooms (wheel), steps with the arrows, Shift+arrows, `+` and `-` on the focused viewport, frames the selection with `F`, and is remembered per scene; Play draws through the scene camera. A click picks through `raycastPick` (Models, and the icons of Suns, PointLights, meshless entities and the scene camera); `TransformControls` move, rotate and scale the selection (W, E, R; World or Local; Ctrl snaps to per-user steps), one undo per drag; a prefab drops where the ray meets a Model or the ground; an XZ grid and the helpers (the camera's frustum, each Sun's direction, each PointLight's range, each Collider's shape) draw in edit mode only. The inspector edits X/Y/Z, rotation and scale and a perspective camera's position, target and fov, and the Animator's clip fields list the glb's clips, read by `glbClipNames(bytes)`. In a 3D archetype the editor's New prefab writes the same 3D set as `scaffold_prefab`, and the "+ behaviour…" picker offers only the components that run in the edited scene's space.
|
|
281
295
|
|
|
282
296
|
## State machines
|
|
283
297
|
|
|
@@ -436,7 +450,7 @@ bar.remove()
|
|
|
436
450
|
- **Handle.** `set(name, value)`, `remove()`, `alive` and `element` — the piece's content root inside the instance's shadow root, `null` once removed.
|
|
437
451
|
- **Placement.** Once per render frame — after the isometric projection and the camera step, before the render, never per Simulation Step — each instance's shadow host is placed as a zero-size box at its anchor point, converted to whole CSS pixels from the top-left corner of the game viewport; the piece's own CSS centres itself around that point (e.g. `transform: translate(-50%, -100%)`). A move therefore shows on the next frame, not before. All instances live in one layer fitted to the game viewport — the whole canvas, or the letterboxed rectangle under a fixed `resolution` — with `overflow: hidden`, so they never draw over the letterbox bars.
|
|
438
452
|
- **Per-instance values.** Inside an instance, `{{name}}` renders the instance's own value when it has one and the Game stat of that name otherwise (booleans as ✓/✕, missing as empty); `set` updates it in place, and a stat change still updates every placeholder with no instance value. Every number value is also published as the custom property `--name` on the instance (`values: { current: 7, max: 10 }` gives `--current: 7` and `--max: 10`), booleans as `1`/`0`; strings are text only. There are no binding expressions: arithmetic belongs in CSS `calc()`.
|
|
439
|
-
- **`--waica-unit`.** Every frame, the anchored layer carries `--waica-unit` inline, and every instance inherits it through its shadow host: CSS pixels per world unit, the game viewport's CSS height divided by the current view height. It follows camera zoom and the letterbox scale, so `calc(1.1 * var(--waica-unit))` sizes a piece in world units while everything else keeps its CSS pixel size.
|
|
453
|
+
- **`--waica-unit`.** Every frame, the anchored layer carries `--waica-unit` inline, and every instance inherits it through its shadow host: CSS pixels per world unit, the game viewport's CSS height divided by the current view height. It follows camera zoom and the letterbox scale, so `calc(1.1 * var(--waica-unit))` sizes a piece in world units while everything else keeps its CSS pixel size. Under a perspective camera each instance's host carries its own unit, measured at its anchor's depth: a piece twice as far from the camera is half as wide (issue #181).
|
|
440
454
|
- **Draw order.** The anchored layer sits below every screen-space piece. Within it, every frame, the instance lower on screen draws on top, like y-sort; equal heights keep creation order, later on top.
|
|
441
455
|
- **Lifetime.** Without `seconds`, an instance is removed before its entity's `destroy()` returns. With `seconds: S`, it is removed exactly when a `game.time.after(S, …)` scheduled at the same moment would fire — it counts Game Time (ADR 0017), so it never runs out while the Game is paused or not simulating, and until then it counts in `game.time.pending`. If its entity is destroyed first, it lingers frozen where the entity was at `destroy()` time (still moving with the camera) until it expires. `unloadScene()`, every scene load that unloads and `game.dispose()` remove every instance: none outlives its scene. `remove()` is immediate; `set` and `remove` on a removed handle are silent no-ops. CSS animations and transitions inside an instance follow Game Time, not the wall clock: every frame each is paused at the Game Time since it started (the attach, for those the piece starts with), so a paused Game freezes a damage number mid-flight — it resumes from there, even after the overlay hid while not simulating — and `step { frames: N }` advances it by N/60 s.
|
|
442
456
|
- **Invalid attach never throws.** An undefined piece name logs one `[waica]` warning per name per Game; an entity that is no longer `alive` logs one per call. Both return a handle with `alive: false` and `element: null` that mounts nothing. To attach only when the project defines the piece, as `Interactable` does with `npc-bubble` and `interact-prompt`, check `game.ui.has(name)` first.
|
package/dist/anchored-pieces.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { pixelsPerUnit, worldToNormalized } from './camera-projection.js';
|
|
1
|
+
import { isPerspectiveCamera, pixelsPerUnit, worldToNormalized } from './camera-projection.js';
|
|
2
2
|
import { projectIsometric } from './projection.js';
|
|
3
3
|
import { placeholders, renderStat } from './ui-bindings.js';
|
|
4
4
|
/**
|
|
@@ -155,18 +155,22 @@ export class AnchoredPieces {
|
|
|
155
155
|
layer.style.top = `${viewport.y}px`;
|
|
156
156
|
layer.style.width = `${viewport.width}px`;
|
|
157
157
|
layer.style.height = `${viewport.height}px`;
|
|
158
|
-
//
|
|
158
|
+
// Every piece inherits this unit: an orthographic camera's, the same at any
|
|
159
|
+
// depth, or a perspective camera's at the world origin, which placeUnit()
|
|
160
|
+
// refines per piece at its own anchor's depth (issue #181 CA-22).
|
|
159
161
|
layer.style.setProperty('--waica-unit', `${pixelsPerUnit(camera, viewport.height)}px`);
|
|
160
162
|
// Before any instance's style writes below: getAnimations() flushes style.
|
|
161
163
|
for (const instance of this.instances)
|
|
162
164
|
followGameTime(instance);
|
|
163
165
|
const byDepth = [];
|
|
164
166
|
for (const instance of this.instances) {
|
|
165
|
-
const
|
|
167
|
+
const anchor = anchorPoint(instance, view.projection);
|
|
168
|
+
const placement = locate(instance, view, anchor);
|
|
166
169
|
instance.placed = placement;
|
|
167
170
|
instance.host.style.left = `${placement.x}px`;
|
|
168
171
|
instance.host.style.top = `${placement.y}px`;
|
|
169
172
|
instance.host.style.visibility = placement.behind ? 'hidden' : '';
|
|
173
|
+
placeUnit(instance, view, anchor);
|
|
170
174
|
byDepth.push([instance, placement.depth]);
|
|
171
175
|
}
|
|
172
176
|
// Lower on screen draws on top, like y-sort; the sort is stable, so
|
|
@@ -258,12 +262,24 @@ function anchorPoint(instance, projection) {
|
|
|
258
262
|
const render = projection === 'isometric' ? projectIsometric(x, y) : { x, y };
|
|
259
263
|
return { x: render.x + instance.offset[0], y: render.y + instance.offset[1], z };
|
|
260
264
|
}
|
|
265
|
+
/**
|
|
266
|
+
* A perspective camera's `--waica-unit` at the piece's own anchor (issue
|
|
267
|
+
* #181 CA-22): a piece twice as far is half as wide. Under an orthographic
|
|
268
|
+
* camera the piece inherits the layer's unit, so it carries none of its own.
|
|
269
|
+
*/
|
|
270
|
+
function placeUnit(instance, { camera, viewport }, anchor) {
|
|
271
|
+
const { style } = instance.host;
|
|
272
|
+
if (!isPerspectiveCamera(camera))
|
|
273
|
+
style.removeProperty('--waica-unit');
|
|
274
|
+
else
|
|
275
|
+
style.setProperty('--waica-unit', `${pixelsPerUnit(camera, viewport.height, anchor)}px`);
|
|
276
|
+
}
|
|
261
277
|
/**
|
|
262
278
|
* The anchor point in whole CSS px from the game viewport's top-left
|
|
263
279
|
* corner: the exact inverse of the Pointer's screen→world mapping.
|
|
264
280
|
*/
|
|
265
|
-
function locate(instance, view) {
|
|
266
|
-
const at = worldToNormalized(view.camera,
|
|
281
|
+
function locate(instance, view, anchor = anchorPoint(instance, view.projection)) {
|
|
282
|
+
const at = worldToNormalized(view.camera, anchor);
|
|
267
283
|
// Behind a perspective camera there is no place on screen to put it.
|
|
268
284
|
if (!at)
|
|
269
285
|
return { x: 0, y: 0, clipped: true, depth: 1, behind: true };
|
package/dist/archetype.d.ts
CHANGED
|
@@ -52,6 +52,21 @@ export interface ArchetypeManifest {
|
|
|
52
52
|
*/
|
|
53
53
|
music?: string;
|
|
54
54
|
}
|
|
55
|
+
/** What a scene takes from its archetype: prefab refs, UI piece names and art file names, each in the archetype's order. */
|
|
56
|
+
export interface SceneArchetypeNeeds {
|
|
57
|
+
prefabs: string[];
|
|
58
|
+
ui: string[];
|
|
59
|
+
art: string[];
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* What `scene` uses from `archetype` (issue #181 CA-29): the prefabs its
|
|
63
|
+
* entities spawn, the UI pieces it lists or its components name (the
|
|
64
|
+
* `ref: 'ui'` params of the prefabs' and inline components), and the art those prefabs or the
|
|
65
|
+
* scene refer to by uri — what a project needs as files to run the scene
|
|
66
|
+
* without the archetype's own catalogs (a blank start of an archetype whose
|
|
67
|
+
* blank scene is not empty). Names the archetype does not have are left out.
|
|
68
|
+
*/
|
|
69
|
+
export declare function sceneArchetypeNeeds(archetype: Pick<ArchetypeManifest, 'prefabs' | 'art' | 'registry'>, scene: SceneJson): SceneArchetypeNeeds;
|
|
55
70
|
/** Browser manifest enriched with URLs produced by an asset-aware bundler. */
|
|
56
71
|
export interface BrowserArchetypeManifest extends ArchetypeManifest {
|
|
57
72
|
artUrls: Readonly<Record<string, string>>;
|
package/dist/archetype.js
CHANGED
|
@@ -1 +1,41 @@
|
|
|
1
|
-
|
|
1
|
+
import { authoringDefaults } from './authoring-defaults.js';
|
|
2
|
+
/** The UI piece one `ref: 'ui'` param of `component` names: its prop, or its class's default; null for none. */
|
|
3
|
+
function namedPiece(component, key, defaults) {
|
|
4
|
+
const value = component.props?.[key] ?? defaults[key];
|
|
5
|
+
return typeof value === 'string' && value ? value : null;
|
|
6
|
+
}
|
|
7
|
+
/** The UI pieces the `ref: 'ui'` params of `components` name (a Health's `healthBar`, a HUD's `piece`). */
|
|
8
|
+
function uiPiecesNamed(components, registry) {
|
|
9
|
+
const names = new Set();
|
|
10
|
+
for (const component of components) {
|
|
11
|
+
const Class = registry.components[component.type];
|
|
12
|
+
const keys = Object.entries(Class?.params ?? {}).flatMap(([key, spec]) => (spec.ref === 'ui' ? [key] : []));
|
|
13
|
+
if (!Class || keys.length === 0)
|
|
14
|
+
continue;
|
|
15
|
+
const defaults = authoringDefaults(Class);
|
|
16
|
+
for (const key of keys) {
|
|
17
|
+
const piece = namedPiece(component, key, defaults);
|
|
18
|
+
if (piece)
|
|
19
|
+
names.add(piece);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return names;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* What `scene` uses from `archetype` (issue #181 CA-29): the prefabs its
|
|
26
|
+
* entities spawn, the UI pieces it lists or its components name (the
|
|
27
|
+
* `ref: 'ui'` params of the prefabs' and inline components), and the art those prefabs or the
|
|
28
|
+
* scene refer to by uri — what a project needs as files to run the scene
|
|
29
|
+
* without the archetype's own catalogs (a blank start of an archetype whose
|
|
30
|
+
* blank scene is not empty). Names the archetype does not have are left out.
|
|
31
|
+
*/
|
|
32
|
+
export function sceneArchetypeNeeds(archetype, scene) {
|
|
33
|
+
const spawned = new Set(scene.entities.flatMap((entity) => (entity.prefab ? [entity.prefab] : [])));
|
|
34
|
+
const prefabs = Object.keys(archetype.prefabs).filter((ref) => spawned.has(ref));
|
|
35
|
+
const components = [...prefabs.flatMap((ref) => archetype.prefabs[ref]?.components ?? []), ...scene.entities.flatMap((entity) => entity.components ?? [])];
|
|
36
|
+
const listed = new Set([...(scene.ui ?? []), ...uiPiecesNamed(components, archetype.registry)]);
|
|
37
|
+
const ui = Object.keys(archetype.registry.ui ?? {}).filter((name) => listed.has(name));
|
|
38
|
+
const text = JSON.stringify([scene, prefabs.map((ref) => archetype.prefabs[ref])]);
|
|
39
|
+
const art = archetype.art.filter((row) => text.includes(JSON.stringify(row.uri))).map((row) => row.file);
|
|
40
|
+
return { prefabs, ui, art };
|
|
41
|
+
}
|
|
@@ -30,9 +30,10 @@ export declare function isPerspectiveCamera(camera: ViewCamera): camera is THREE
|
|
|
30
30
|
export declare function worldToNormalized(camera: ViewCamera, point: WorldPoint): NormalizedPoint | null;
|
|
31
31
|
/**
|
|
32
32
|
* CSS px per world unit at the game viewport's height. Orthographic: the
|
|
33
|
-
* same everywhere. Perspective: at the depth of the world origin (the
|
|
34
|
-
* a scene is built around)
|
|
35
|
-
* a unit of
|
|
36
|
-
*
|
|
33
|
+
* same everywhere. Perspective: at the depth of `at` — the world origin (the
|
|
34
|
+
* ground a scene is built around) unless an Anchored Piece's anchor is given
|
|
35
|
+
* (issue #181 CA-22) — where a unit of the piece's CSS reads as a unit of
|
|
36
|
+
* world (its distance from the camera, front or back); nearer things look
|
|
37
|
+
* bigger, as the camera draws them.
|
|
37
38
|
*/
|
|
38
|
-
export declare function pixelsPerUnit(camera: ViewCamera, viewportHeight: number): number;
|
|
39
|
+
export declare function pixelsPerUnit(camera: ViewCamera, viewportHeight: number, at?: WorldPoint): number;
|
|
@@ -29,16 +29,17 @@ export function worldToNormalized(camera, point) {
|
|
|
29
29
|
}
|
|
30
30
|
/**
|
|
31
31
|
* CSS px per world unit at the game viewport's height. Orthographic: the
|
|
32
|
-
* same everywhere. Perspective: at the depth of the world origin (the
|
|
33
|
-
* a scene is built around)
|
|
34
|
-
* a unit of
|
|
35
|
-
*
|
|
32
|
+
* same everywhere. Perspective: at the depth of `at` — the world origin (the
|
|
33
|
+
* ground a scene is built around) unless an Anchored Piece's anchor is given
|
|
34
|
+
* (issue #181 CA-22) — where a unit of the piece's CSS reads as a unit of
|
|
35
|
+
* world (its distance from the camera, front or back); nearer things look
|
|
36
|
+
* bigger, as the camera draws them.
|
|
36
37
|
*/
|
|
37
|
-
export function pixelsPerUnit(camera, viewportHeight) {
|
|
38
|
+
export function pixelsPerUnit(camera, viewportHeight, at = { x: 0, y: 0 }) {
|
|
38
39
|
if (!isPerspectiveCamera(camera))
|
|
39
40
|
return viewportHeight / (camera.top - camera.bottom);
|
|
40
41
|
camera.updateMatrixWorld();
|
|
41
|
-
// Absolute: a camera aimed away from the
|
|
42
|
-
const depth = Math.max(camera.near, Math.abs(scratch.set(
|
|
42
|
+
// Absolute: a camera aimed away from the point still measures how far it is, not the near plane.
|
|
43
|
+
const depth = Math.max(camera.near, Math.abs(scratch.set(at.x, at.y, at.z ?? 0).applyMatrix4(camera.matrixWorldInverse).z));
|
|
43
44
|
return viewportHeight / (2 * Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2) * depth);
|
|
44
45
|
}
|
package/dist/camera-rig.d.ts
CHANGED
|
@@ -2,9 +2,10 @@ import * as THREE from 'three/webgpu';
|
|
|
2
2
|
import { type ResolvedSceneCamera, type SceneCameraJson } from './camera.js';
|
|
3
3
|
import type { GameCamera } from './camera-projection.js';
|
|
4
4
|
import type { Entity } from './entity.js';
|
|
5
|
+
import { type OrbitHost, type OrbitView } from './orbit-camera.js';
|
|
5
6
|
import type { SceneSpace } from './scene-space.js';
|
|
6
|
-
/** What following an entity needs from the Game: the scene's entities and its render projection. */
|
|
7
|
-
export interface CameraFollowHost {
|
|
7
|
+
/** What following an entity needs from the Game: the scene's entities and its render projection, and the orbit's input and ray (OrbitHost). */
|
|
8
|
+
export interface CameraFollowHost extends OrbitHost {
|
|
8
9
|
find(name: string): Entity | undefined;
|
|
9
10
|
/** A logical point in render space (the isometric projection, when the scene has one). */
|
|
10
11
|
renderPoint(x: number, y: number): {
|
|
@@ -29,8 +30,10 @@ export declare class CameraRig {
|
|
|
29
30
|
/** Set once the 2D-scene warning of setViewCamera was logged. */
|
|
30
31
|
private warnedFlatView;
|
|
31
32
|
private aspect;
|
|
32
|
-
/** How far the perspective camera's declared target
|
|
33
|
+
/** How far the perspective camera's look point is: the declared target's distance, or the orbit's placed distance. */
|
|
33
34
|
private distance;
|
|
35
|
+
/** The orbit of a perspective block that follows an entity (issue #181); null for a fixed camera or a 2D scene. */
|
|
36
|
+
private orbit;
|
|
34
37
|
constructor();
|
|
35
38
|
/**
|
|
36
39
|
* The camera the frame draws, the Pointer picks and `worldToScreen`
|
|
@@ -51,6 +54,10 @@ export declare class CameraRig {
|
|
|
51
54
|
setViewCamera(camera: THREE.PerspectiveCamera | null): void;
|
|
52
55
|
/** The distance from the perspective camera to the point it was aimed at; a snapshot reports its target from it. */
|
|
53
56
|
get lookDistance(): number;
|
|
57
|
+
/** True while the scene's perspective camera orbits a followed entity. */
|
|
58
|
+
get orbiting(): boolean;
|
|
59
|
+
/** The orbit's yaw, pitch and distances for the Runtime Snapshot; null for any other camera. */
|
|
60
|
+
get orbitView(): OrbitView | null;
|
|
54
61
|
/**
|
|
55
62
|
* Adopts a scene's camera block for its space: a 3D scene gets the
|
|
56
63
|
* perspective camera (the block's, or the defaults), a 2D scene the
|
|
@@ -61,6 +68,28 @@ export declare class CameraRig {
|
|
|
61
68
|
adopt(json: SceneCameraJson | undefined, space: SceneSpace): ResolvedSceneCamera | null;
|
|
62
69
|
/** Back to the orthographic camera, as a Game with no scene has it. */
|
|
63
70
|
reset(): void;
|
|
71
|
+
/**
|
|
72
|
+
* One Simulation Step of the scene camera: the orbit of a following
|
|
73
|
+
* perspective camera (issue #181), else the orthographic follow of `cam`.
|
|
74
|
+
*/
|
|
75
|
+
step(cam: ResolvedSceneCamera | null, host: CameraFollowHost, dt: number): void;
|
|
76
|
+
/**
|
|
77
|
+
* Puts a following perspective camera on its orbit without input, so the
|
|
78
|
+
* first frame and snapshot of a loaded scene are framed (issue #181);
|
|
79
|
+
* loadScene calls it once the followed entity has spawned.
|
|
80
|
+
*/
|
|
81
|
+
placeOrbit(host: CameraFollowHost): void;
|
|
82
|
+
/**
|
|
83
|
+
* Moves the scene camera by a Camera Effects shake `offset` for one draw
|
|
84
|
+
* and returns how to put it back (ADR 0020). The orthographic camera moves
|
|
85
|
+
* by the offset; a perspective one (issue #181 CA-11) along its own right
|
|
86
|
+
* and up, scaled so the look point's image moves as far on screen as it
|
|
87
|
+
* would under an orthographic camera `viewHeight` tall.
|
|
88
|
+
*/
|
|
89
|
+
shake(offset: {
|
|
90
|
+
x: number;
|
|
91
|
+
y: number;
|
|
92
|
+
}, viewHeight: number): () => void;
|
|
64
93
|
/** Fits both cameras to the letterboxed view: the orthographic frame from `viewHeight`, the perspective aspect. */
|
|
65
94
|
fit(aspect: number, viewHeight: number): void;
|
|
66
95
|
/** One Simulation Step of the orthographic follow camera (deadzone, lookahead, smoothing, limits). */
|
package/dist/camera-rig.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import * as THREE from 'three/webgpu';
|
|
2
2
|
import { isCameraVelocityProvider, resolveSceneCamera, stepSceneCamera, } from './camera.js';
|
|
3
|
+
import { OrbitCamera } from './orbit-camera.js';
|
|
3
4
|
import { EMISSIVE_LAYER } from './render-layers.js';
|
|
4
5
|
import { placePerspectiveCamera, resolvePerspectiveCamera } from './scene-camera-3d.js';
|
|
6
|
+
const noShake = () => { };
|
|
5
7
|
/**
|
|
6
8
|
* The Game's cameras. The orthographic one is built with the Game and frames
|
|
7
9
|
* every 2D scene; a 3D scene swaps in a perspective one (built on first use).
|
|
@@ -17,8 +19,10 @@ export class CameraRig {
|
|
|
17
19
|
/** Set once the 2D-scene warning of setViewCamera was logged. */
|
|
18
20
|
warnedFlatView = false;
|
|
19
21
|
aspect = 1;
|
|
20
|
-
/** How far the perspective camera's declared target
|
|
22
|
+
/** How far the perspective camera's look point is: the declared target's distance, or the orbit's placed distance. */
|
|
21
23
|
distance = 1;
|
|
24
|
+
/** The orbit of a perspective block that follows an entity (issue #181); null for a fixed camera or a 2D scene. */
|
|
25
|
+
orbit = null;
|
|
22
26
|
constructor() {
|
|
23
27
|
this.orthographic.position.z = 10;
|
|
24
28
|
// Emissive drawables sit on their own layer; an unlit frame draws it in place.
|
|
@@ -60,6 +64,14 @@ export class CameraRig {
|
|
|
60
64
|
get lookDistance() {
|
|
61
65
|
return this.distance;
|
|
62
66
|
}
|
|
67
|
+
/** True while the scene's perspective camera orbits a followed entity. */
|
|
68
|
+
get orbiting() {
|
|
69
|
+
return this.orbit !== null && this.current === this.perspective;
|
|
70
|
+
}
|
|
71
|
+
/** The orbit's yaw, pitch and distances for the Runtime Snapshot; null for any other camera. */
|
|
72
|
+
get orbitView() {
|
|
73
|
+
return this.orbiting && this.orbit ? this.orbit.view() : null;
|
|
74
|
+
}
|
|
63
75
|
/**
|
|
64
76
|
* Adopts a scene's camera block for its space: a 3D scene gets the
|
|
65
77
|
* perspective camera (the block's, or the defaults), a 2D scene the
|
|
@@ -73,6 +85,7 @@ export class CameraRig {
|
|
|
73
85
|
return null;
|
|
74
86
|
}
|
|
75
87
|
this.current = this.orthographic;
|
|
88
|
+
this.orbit = null;
|
|
76
89
|
if (!json || json.kind === 'perspective')
|
|
77
90
|
return null;
|
|
78
91
|
return resolveSceneCamera(json);
|
|
@@ -80,6 +93,66 @@ export class CameraRig {
|
|
|
80
93
|
/** Back to the orthographic camera, as a Game with no scene has it. */
|
|
81
94
|
reset() {
|
|
82
95
|
this.current = this.orthographic;
|
|
96
|
+
this.orbit = null;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* One Simulation Step of the scene camera: the orbit of a following
|
|
100
|
+
* perspective camera (issue #181), else the orthographic follow of `cam`.
|
|
101
|
+
*/
|
|
102
|
+
step(cam, host, dt) {
|
|
103
|
+
if (this.orbit && this.perspective && this.current === this.perspective) {
|
|
104
|
+
// Only a placed camera looks at the orbit's distance; with no followed entity it keeps its declared pose.
|
|
105
|
+
if (this.orbit.step(this.perspective, host, dt))
|
|
106
|
+
this.distance = this.orbit.distanceActual;
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (cam)
|
|
110
|
+
this.follow(cam, host, dt);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Puts a following perspective camera on its orbit without input, so the
|
|
114
|
+
* first frame and snapshot of a loaded scene are framed (issue #181);
|
|
115
|
+
* loadScene calls it once the followed entity has spawned.
|
|
116
|
+
*/
|
|
117
|
+
placeOrbit(host) {
|
|
118
|
+
if (!this.orbit || !this.perspective || this.current !== this.perspective)
|
|
119
|
+
return;
|
|
120
|
+
if (this.orbit.place(this.perspective, host))
|
|
121
|
+
this.distance = this.orbit.distanceActual;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Moves the scene camera by a Camera Effects shake `offset` for one draw
|
|
125
|
+
* and returns how to put it back (ADR 0020). The orthographic camera moves
|
|
126
|
+
* by the offset; a perspective one (issue #181 CA-11) along its own right
|
|
127
|
+
* and up, scaled so the look point's image moves as far on screen as it
|
|
128
|
+
* would under an orthographic camera `viewHeight` tall.
|
|
129
|
+
*/
|
|
130
|
+
shake(offset, viewHeight) {
|
|
131
|
+
// Most frames do not shake: nothing to move, nothing to allocate.
|
|
132
|
+
if (offset.x === 0 && offset.y === 0)
|
|
133
|
+
return noShake;
|
|
134
|
+
const camera = this.current === this.perspective ? this.perspective : null;
|
|
135
|
+
if (!camera) {
|
|
136
|
+
const flat = this.orthographic;
|
|
137
|
+
const { x, y } = flat.position;
|
|
138
|
+
flat.position.x = x + offset.x;
|
|
139
|
+
flat.position.y = y + offset.y;
|
|
140
|
+
return () => {
|
|
141
|
+
flat.position.x = x;
|
|
142
|
+
flat.position.y = y;
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
const base = camera.position.clone();
|
|
146
|
+
camera.updateMatrixWorld();
|
|
147
|
+
const perUnit = (2 * this.distance * Math.tan((camera.fov * Math.PI) / 360)) / viewHeight;
|
|
148
|
+
const right = new THREE.Vector3().setFromMatrixColumn(camera.matrixWorld, 0);
|
|
149
|
+
const up = new THREE.Vector3().setFromMatrixColumn(camera.matrixWorld, 1);
|
|
150
|
+
camera.position.addScaledVector(right, offset.x * perUnit).addScaledVector(up, offset.y * perUnit);
|
|
151
|
+
camera.updateMatrixWorld();
|
|
152
|
+
return () => {
|
|
153
|
+
camera.position.copy(base);
|
|
154
|
+
camera.updateMatrixWorld();
|
|
155
|
+
};
|
|
83
156
|
}
|
|
84
157
|
/** Fits both cameras to the letterboxed view: the orthographic frame from `viewHeight`, the perspective aspect. */
|
|
85
158
|
fit(aspect, viewHeight) {
|
|
@@ -129,6 +202,7 @@ export class CameraRig {
|
|
|
129
202
|
const resolved = resolvePerspectiveCamera(json);
|
|
130
203
|
placePerspectiveCamera(this.perspective, resolved, this.aspect);
|
|
131
204
|
this.distance = new THREE.Vector3(...resolved.position).distanceTo(new THREE.Vector3(...resolved.target)) || 1;
|
|
205
|
+
this.orbit = resolved.orbit ? new OrbitCamera(resolved.orbit) : null;
|
|
132
206
|
this.current = this.perspective;
|
|
133
207
|
}
|
|
134
208
|
}
|
|
@@ -75,6 +75,12 @@ export declare class Animator extends Component implements ClipTarget {
|
|
|
75
75
|
get clipNames(): readonly string[];
|
|
76
76
|
get clipFinished(): boolean;
|
|
77
77
|
get clipsPending(): boolean;
|
|
78
|
+
/**
|
|
79
|
+
* How far the playing clip is, from 0 at its start to 1 at its end (issue
|
|
80
|
+
* #181): its time over its duration, so a finished one-shot holds 1 and a
|
|
81
|
+
* loop starts over at 0. 0 while nothing plays.
|
|
82
|
+
*/
|
|
83
|
+
get clipProgress(): number;
|
|
78
84
|
onReady(): void;
|
|
79
85
|
/** Plays a clip: a no-op for the running one, a restart for a finished one, a crossfade from any other over `fade` seconds. */
|
|
80
86
|
play(name: string): void;
|
|
@@ -58,6 +58,18 @@ export class Animator extends Component {
|
|
|
58
58
|
get clipsPending() {
|
|
59
59
|
return this.pending;
|
|
60
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* How far the playing clip is, from 0 at its start to 1 at its end (issue
|
|
63
|
+
* #181): its time over its duration, so a finished one-shot holds 1 and a
|
|
64
|
+
* loop starts over at 0. 0 while nothing plays.
|
|
65
|
+
*/
|
|
66
|
+
get clipProgress() {
|
|
67
|
+
const action = this.active;
|
|
68
|
+
if (!action)
|
|
69
|
+
return 0;
|
|
70
|
+
const { duration } = action.getClip();
|
|
71
|
+
return duration > 0 ? Math.min(action.time / duration, 1) : 1;
|
|
72
|
+
}
|
|
61
73
|
onReady() {
|
|
62
74
|
if (this.game.space !== '3d')
|
|
63
75
|
return;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Component, ComponentClass } from './component.js';
|
|
2
|
+
import type { Entity } from './entity.js';
|
|
3
|
+
/**
|
|
4
|
+
* Each entity's Component Update Schedule (ADR 0004), as the Game runs it once
|
|
5
|
+
* per Simulation Step: resolved from the entity's composition, cached per
|
|
6
|
+
* composition signature, and reported once per invalid composition.
|
|
7
|
+
*/
|
|
8
|
+
export declare class EntityUpdateSchedules {
|
|
9
|
+
private readonly invalidCompositions;
|
|
10
|
+
/**
|
|
11
|
+
* The resolved order for the last composition signature seen per entity:
|
|
12
|
+
* the resolve (Tarjan SCC + Kahn sort) it's built from is pure for a fixed
|
|
13
|
+
* composition, so a signature match skips it entirely. Only ever holds a
|
|
14
|
+
* successful resolution — an invalid composition is never cached, since
|
|
15
|
+
* invalidCompositions already dedupes its console.error.
|
|
16
|
+
*/
|
|
17
|
+
private readonly cache;
|
|
18
|
+
/** The entity's components in update order, or null (reported once) when its composition is invalid. */
|
|
19
|
+
of(entity: Entity, registered?: Readonly<Record<string, ComponentClass>>): Component[] | null;
|
|
20
|
+
private resolve;
|
|
21
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { componentClassOf } from './component-registry.js';
|
|
2
|
+
import { implementsOnUpdate, resolveComponentUpdateSchedule } from './component-update-schedule.js';
|
|
3
|
+
/** Maps a resolved update order back to the entity's components; the order only names components it was built from. */
|
|
4
|
+
function componentsInOrder(order, byName) {
|
|
5
|
+
return order.map((name) => {
|
|
6
|
+
const component = byName.get(name);
|
|
7
|
+
if (!component)
|
|
8
|
+
throw new Error(`Component update schedule names "${name}", which is not on the entity`);
|
|
9
|
+
return component;
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Each entity's Component Update Schedule (ADR 0004), as the Game runs it once
|
|
14
|
+
* per Simulation Step: resolved from the entity's composition, cached per
|
|
15
|
+
* composition signature, and reported once per invalid composition.
|
|
16
|
+
*/
|
|
17
|
+
export class EntityUpdateSchedules {
|
|
18
|
+
invalidCompositions = new WeakMap();
|
|
19
|
+
/**
|
|
20
|
+
* The resolved order for the last composition signature seen per entity:
|
|
21
|
+
* the resolve (Tarjan SCC + Kahn sort) it's built from is pure for a fixed
|
|
22
|
+
* composition, so a signature match skips it entirely. Only ever holds a
|
|
23
|
+
* successful resolution — an invalid composition is never cached, since
|
|
24
|
+
* invalidCompositions already dedupes its console.error.
|
|
25
|
+
*/
|
|
26
|
+
cache = new WeakMap();
|
|
27
|
+
/** The entity's components in update order, or null (reported once) when its composition is invalid. */
|
|
28
|
+
of(entity, registered = {}) {
|
|
29
|
+
const components = [...entity.components];
|
|
30
|
+
const byName = new Map();
|
|
31
|
+
const names = [];
|
|
32
|
+
const signatureParts = [];
|
|
33
|
+
for (const component of components) {
|
|
34
|
+
const Class = componentClassOf(component);
|
|
35
|
+
const name = Class.componentName;
|
|
36
|
+
names.push(name);
|
|
37
|
+
byName.set(name, component);
|
|
38
|
+
signatureParts.push(`${name}:${implementsOnUpdate(Class) ? 'updates' : 'passive'}:` +
|
|
39
|
+
[...new Set(Class.updateAfter ?? [])].sort().join(','));
|
|
40
|
+
}
|
|
41
|
+
const signature = signatureParts.sort().join('|');
|
|
42
|
+
const cached = this.cache.get(entity);
|
|
43
|
+
if (cached && cached.signature === signature)
|
|
44
|
+
return componentsInOrder(cached.order, byName);
|
|
45
|
+
return this.resolve(entity, { components, names, byName, signature }, registered);
|
|
46
|
+
}
|
|
47
|
+
resolve(entity, composition, registered) {
|
|
48
|
+
const registry = { ...registered };
|
|
49
|
+
for (const component of composition.components) {
|
|
50
|
+
const Class = componentClassOf(component);
|
|
51
|
+
registry[Class.componentName] = Class;
|
|
52
|
+
}
|
|
53
|
+
const result = resolveComponentUpdateSchedule(composition.names, registry);
|
|
54
|
+
if (!result.ok) {
|
|
55
|
+
this.cache.delete(entity);
|
|
56
|
+
if (this.invalidCompositions.get(entity) !== composition.signature) {
|
|
57
|
+
this.invalidCompositions.set(entity, composition.signature);
|
|
58
|
+
console.error(`[waica] invalid component update schedule for "${entity.name}": ` +
|
|
59
|
+
result.issues.map((issue) => issue.cause).join(' '));
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
this.invalidCompositions.delete(entity);
|
|
64
|
+
this.cache.set(entity, { signature: composition.signature, order: result.order });
|
|
65
|
+
return componentsInOrder(result.order, composition.byName);
|
|
66
|
+
}
|
|
67
|
+
}
|