castle-web-cli 0.4.170 → 0.4.172

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 (58) hide show
  1. package/dist/agent-prompts.d.ts +1 -0
  2. package/dist/agent-prompts.js +3 -0
  3. package/dist/agent.js +47 -11
  4. package/dist/castle-host/host.js +71 -0
  5. package/dist/devSessionServer.js +9 -5
  6. package/dist/ide.d.ts +10 -0
  7. package/dist/ide.js +5 -5
  8. package/dist/init.js +1 -1
  9. package/dist/shell/assets/index-6odVZQSZ.css +1 -0
  10. package/dist/shell/assets/index-Ws0WrCbi.js +445 -0
  11. package/dist/shell/index.html +3 -3
  12. package/kits/base/castle.json +1 -1
  13. package/kits/base/sdk/README.md +17 -1
  14. package/kits/base/sdk/commands.d.ts +14 -0
  15. package/kits/base/sdk/user.d.ts +4 -0
  16. package/kits/base/sdk/user.js +36 -1
  17. package/kits/multiplayer-2d/CLAUDE.md +24 -9
  18. package/kits/multiplayer-2d/behaviors/Box.jsx +32 -9
  19. package/kits/multiplayer-2d/castle.json +3 -3
  20. package/kits/multiplayer-2d/code/client/avatars.js +6 -1
  21. package/kits/multiplayer-2d/code/server/players.js +27 -6
  22. package/kits/multiplayer-2d/code/server/world.js +24 -1
  23. package/kits/multiplayer-2d/code/systems/multiplayer.js +47 -4
  24. package/kits/multiplayer-2d/package-lock.json +26 -1
  25. package/kits/multiplayer-2d/package.json +2 -0
  26. package/kits/multiplayer-3d/CLAUDE.md +32 -9
  27. package/kits/multiplayer-3d/castle.json +4 -4
  28. package/kits/multiplayer-3d/code/client/avatars.js +15 -11
  29. package/kits/multiplayer-3d/code/client/nameTags.js +35 -16
  30. package/kits/multiplayer-3d/code/server/players.js +54 -5
  31. package/kits/multiplayer-3d/code/server/world.js +45 -1
  32. package/kits/multiplayer-3d/code/systems/multiplayer.js +112 -7
  33. package/kits/multiplayer-3d/package-lock.json +26 -1
  34. package/kits/multiplayer-3d/package.json +2 -0
  35. package/kits/physics-2d/castle.json +1 -1
  36. package/kits/physics-2d/editors/PxArtEditor.jsx +2 -2
  37. package/kits/physics-2d/engine/avatarArt.js +91 -0
  38. package/kits/physics-3d/behaviors/Pickup.jsx +5 -3
  39. package/kits/physics-3d/castle.json +1 -1
  40. package/kits/real-time/CLAUDE.md +23 -4
  41. package/kits/real-time/castle.json +1 -1
  42. package/kits/real-time/code/client/connection.js +15 -4
  43. package/kits/real-time/code/client/joinOverlay.js +46 -0
  44. package/kits/real-time/code/client/messages.js +4 -0
  45. package/kits/real-time/code/server/gameHooks.js +4 -0
  46. package/kits/real-time/code/server/persist.js +105 -0
  47. package/kits/real-time/code/server/session.js +295 -19
  48. package/kits/real-time/package-lock.json +1139 -0
  49. package/kits/turn-based/CLAUDE.md +80 -20
  50. package/kits/turn-based/castle.json +2 -2
  51. package/kits/turn-based/code/behaviors/Board.jsx +15 -5
  52. package/kits/turn-based/code/server/index.js +17 -5
  53. package/kits/turn-based/package.json +2 -1
  54. package/kits/turn-based/room.js +164 -13
  55. package/kits/turn-based/testing.js +276 -92
  56. package/package.json +1 -2
  57. package/dist/shell/assets/index-BvQmVwlO.css +0 -1
  58. package/dist/shell/assets/index-CV5sBby1.js +0 -445
@@ -51,10 +51,31 @@ File names stay lowercase.
51
51
  builds a rapier world from the deck's own `scenes/main.scene` and steps it at
52
52
  `tickRate` (30). Never fork it.
53
53
  - **A game is added as hooks, not by replacing anything.** Write
54
- `code/server/game.js` with a default export of up to six functions --
54
+ `code/server/game.js` with a default export of up to six gameplay functions --
55
55
  `ready(sim)`, `message(session, player, data)`, `place(sim, handovers)`,
56
56
  `step(sim, handovers)`, `join(sim)`, `delta(sim)` -- and it is found by glob.
57
57
  Optional, as is `code/server/deckComponents.js` for component defaults.
58
+ - **What persists.** Object poses, the ids of despawned scene actors, each
59
+ player's last pose and `scene.persistent` block by account, and whatever the
60
+ optional `save(sim)` hook returns. Written every ten seconds when something
61
+ moved and at a clean shutdown. Nothing to configure.
62
+ - **A despawn is for everyone.** The client system reports a scene actor a
63
+ behavior despawned (a collected pickup); the session removes it for every
64
+ player, now and on every later visit.
65
+ - **`scene.persistent` must stay JSON** -- no Sets, no actors. It rides in the
66
+ move report when it changes, capped at 4 KB, and comes back in the WORLD
67
+ snapshot before the Player behavior runs, which is how the camera angle and
68
+ score return.
69
+ - **Players wear their Castle avatar.** The name tag over every player,
70
+ the local one included, carries their profile photo and frame
71
+ (`castle.physics-2d`'s `engine/avatarArt.js`, through `User.get`). A player
72
+ with no photo gets the name alone.
73
+ - **A returning player starts where they left off.** A second connection from
74
+ the same account while the first is still playing gets a new character at the
75
+ spawn. The room is `session.sessionId`; every `public` shard shares one.
76
+ - `restore(sim, saved)` receives the last `save` result before `ready`. Storage
77
+ is keyed by deck, so the deck must be published; `castle-web serve` brokers it
78
+ as the deck's creator.
58
79
  - `place` runs BEFORE the physics step, which is where a write through a deferred
59
80
  channel (`player.at`, `setNextKinematicTranslation`) has to happen to reach the
60
81
  body this tick. `message` is dispatched before the world exists, so a lobby
@@ -78,8 +99,9 @@ File names stay lowercase.
78
99
  - The kit ships `code/systems/multiplayer.js`, the frame loop. A deck that writes
79
100
  its own file of that path gets its own instead; most decks should not.
80
101
  - Two players is the test that matters: `castle-web serve`, then the play URL in
81
- two tabs. The session process holds the room, so restart the serve for a fresh
82
- one.
102
+ two tabs. The room persists in deck storage across serve restarts; a fresh one
103
+ means removing its `world:<room>`, `game:<room>` and `player:<room>:*` rows
104
+ (the local serve's room is `dev`).
83
105
 
84
106
  ## Layout
85
107
 
@@ -238,12 +260,13 @@ Map())` — and the next `showPlayer` respawns it.
238
260
  - **The player blueprint itself.** The kit spawns a scene from the deck's own
239
261
  root; the deck ships it. `blueprints/other-player.scene` is only the name it
240
262
  looks for when the deck names none.
241
- - **Any HUD.** Five decks wrote five HUDs with no shared lines. The one
242
- on-screen element the kit does draw is the solo badge
243
- (`castle.real-time`'s `soloBadge.js`): when the join fell back to solo it
244
- names why (`status().soloReason`), so a creator testing a private or
245
- unpublished deck is told rather than left to guess. A deck's
246
- `soloBadge(status)` client hook hides or rewords it.
263
+ - **Any HUD.** Five decks wrote five HUDs with no shared lines. The kit draws
264
+ two on-screen elements, both `castle.real-time`'s: the solo badge
265
+ (`soloBadge.js`) names why a join fell back to solo (`status().soloReason`),
266
+ so a creator testing a private or unpublished deck is told rather than left
267
+ to guess -- a deck's `soloBadge(status)` client hook hides or rewords it; and
268
+ the join overlay (`joinOverlay.js`) covers the card with "Joining…" until the
269
+ session's first snapshot places the player, ten seconds at most.
247
270
  - **Gameplay verbs** — carry, vehicle, round, lobby. Measured across six decks
248
271
  and none of them survived contact; see the audit.
249
272
 
@@ -14,15 +14,15 @@
14
14
  "imports": {
15
15
  "castle.real-time": {
16
16
  "deckId": "xFVr-afOLuUQ",
17
- "version": "2026-09-05T01:31:28.578Z"
17
+ "version": "2026-09-10T20:27:27.032Z"
18
18
  },
19
19
  "castle.physics-3d": {
20
20
  "deckId": "JH0SclbPVP0y",
21
- "version": "2026-09-10T00:57:52.158Z"
21
+ "version": "2026-09-10T19:46:37.323Z"
22
22
  },
23
23
  "castle.physics-2d": {
24
24
  "deckId": "ckRZGFW4iPrx",
25
- "version": "2026-09-10T00:57:31.408Z",
25
+ "version": "2026-09-10T20:21:12.665Z",
26
26
  "via": "castle.physics-3d"
27
27
  },
28
28
  "castle.base": {
@@ -33,7 +33,7 @@
33
33
  "autoUpdateWhenImported": true,
34
34
  "deckId": "ZwA_P_-VO-Qh",
35
35
  "cardId": "bvRmCzyUpt_a",
36
- "publishedVersion": "2026-09-10T00:58:16.876Z",
36
+ "publishedVersion": "2026-09-10T20:28:53.356Z",
37
37
  "main": "main.jsx",
38
38
  "server": {
39
39
  "main": "code/server/index.js",
@@ -4,6 +4,7 @@
4
4
 
5
5
  import * as NameTags from './nameTags.js';
6
6
  import { applyPose } from './poses.js';
7
+ import { avatarCanvas } from '@imports/castle.physics-2d/engine/avatarArt.js';
7
8
 
8
9
  // The blueprint spawned when a caller names none. A deck's own file wins; a deck
9
10
  // scaffolded from this kit has no blueprints yet, so the kit's copy stands in
@@ -33,7 +34,8 @@ export function makeAvatars() {
33
34
  //
34
35
  // A `username` of null draws no label. Options are `blueprint`, the scene to
35
36
  // spawn from (default `blueprints/other-player.scene`, the deck's own or the kit's),
36
- // and `tagHeight`, how far over the body the label floats.
37
+ // `tagHeight`, how far over the body the label floats, and `userId`, whose
38
+ // avatar the label carries.
37
39
  export function showPlayer(avatars, scene, playerId, username, pose, options = {}) {
38
40
  // The actor keeps the blueprint used at creation; `ensureActor` only spawns
39
41
  // when this player has no live actor in the current scene.
@@ -46,19 +48,20 @@ export function showPlayer(avatars, scene, playerId, username, pose, options = {
46
48
  if (username === null) {
47
49
  dropTag(avatars, scene, playerId);
48
50
  } else {
49
- showTag(avatars, scene, playerId, username, transform, options.tagHeight);
51
+ showTag(avatars, scene, playerId, username, transform, options);
50
52
  }
51
53
  return actor;
52
54
  }
53
55
 
54
56
  // Update the local player's label over the engine's local Player actor. The SDK
55
57
  // roster can arrive several frames after connection, so an empty username
56
- // leaves the label absent until the roster supplies one.
57
- export function showSelf(avatars, scene, username, transform, tagHeight) {
58
+ // leaves the label absent until the roster supplies one. Options are
59
+ // `tagHeight` and `userId`, as for `showPlayer`.
60
+ export function showSelf(avatars, scene, username, transform, options = {}) {
58
61
  if (!username) {
59
62
  return;
60
63
  }
61
- showTag(avatars, scene, 'self', username, transform, tagHeight);
64
+ showTag(avatars, scene, 'self', username, transform, options);
62
65
  }
63
66
 
64
67
  // Despawn the actor and dispose the tag of every player absent from `present`.
@@ -106,20 +109,21 @@ function ensureActor(avatars, scene, playerId, pose, blueprint) {
106
109
  return spawned;
107
110
  }
108
111
 
109
- // Create a tag when the displayed text changes, then place it over the actor.
110
- // Each redraw allocates a canvas, texture, and sprite, so unchanged text reuses
111
- // the existing resources.
112
- function showTag(avatars, scene, key, username, transform, tagHeight) {
112
+ // Create a tag when the displayed text or the avatar changes, then place it over
113
+ // the actor. Each redraw allocates a canvas, texture, and sprite, so an unchanged
114
+ // tag reuses the existing resources.
115
+ function showTag(avatars, scene, key, username, transform, { tagHeight, userId } = {}) {
113
116
  const label = NameTags.shortName(username);
117
+ const avatar = avatarCanvas(userId);
114
118
  let tag = avatars.tags.get(key);
115
119
 
116
120
  // Compare against the shortened text stored on the tag. An
117
121
  // `anonymous-user-<uuid>` differs from the `anon-xxxxxx` string on the sprite.
118
- if (!tag || tag.text !== label) {
122
+ if (!tag || tag.text !== label || tag.avatar !== avatar) {
119
123
  if (tag) {
120
124
  NameTags.disposeNameTag(scene.three, tag);
121
125
  }
122
- tag = NameTags.makeNameTag(scene.three, label);
126
+ tag = NameTags.makeNameTag(scene.three, label, avatar);
123
127
  avatars.tags.set(key, tag);
124
128
  }
125
129
 
@@ -1,6 +1,8 @@
1
- // Floating username-label sprites for other players and the local player. This module
2
- // draws text to a canvas, attaches the resulting sprite directly to the engine's
3
- // three.js scene, scales it by camera distance, and disposes its GPU resources.
1
+ // Floating name tags for other players and the local player: the player's round
2
+ // avatar over their username. This module draws them to a canvas, attaches the
3
+ // resulting sprite directly to the engine's three.js scene, scales it by camera
4
+ // distance, and disposes its GPU resources. A sprite always faces the camera,
5
+ // so the avatar reads from every side.
4
6
 
5
7
  import * as THREE from 'three';
6
8
 
@@ -8,6 +10,11 @@ import * as THREE from 'three';
8
10
  const HEIGHT_ABOVE = 1.25;
9
11
  const PIXELS_PER_UNIT = 200;
10
12
 
13
+ // Canvas pixels of the name pill, and of the avatar drawn above it.
14
+ const LABEL_PX = 68;
15
+ const AVATAR_PX = 112;
16
+ const GAP_PX = 8;
17
+
11
18
  // The distance at which the label is the size it was drawn to be: 68 canvas
12
19
  // pixels over 200 per unit is 0.34m, which on a 500x700 card at fov 55 is 29px.
13
20
  // This reference distance matches the common camera placement in kit decks.
@@ -16,11 +23,12 @@ const SAME_SIZE_M = 8;
16
23
  // The minimum scale keeps a label readable when the camera is near the player.
17
24
  const MIN_SCALE = 0.35;
18
25
 
19
- // Create `{ sprite, text, width, height }` and add the sprite to the engine's
20
- // three.js scene. The engine actor-to-mesh sync does not update these directly
21
- // attached sprites, so callers move them each frame with `moveNameTag`.
22
- export function makeNameTag(world, text) {
23
- const canvas = drawLabel(text);
26
+ // Create `{ sprite, text, avatar, width, height, lift }` and add the sprite to
27
+ // the engine's three.js scene. `avatar` is the canvas from `avatarArt.js`, or
28
+ // null for a name alone. The engine actor-to-mesh sync does not update these
29
+ // directly attached sprites, so callers move them each frame with `moveNameTag`.
30
+ export function makeNameTag(world, text, avatar = null) {
31
+ const canvas = drawLabel(text, avatar);
24
32
  const texture = new THREE.CanvasTexture(canvas);
25
33
  texture.colorSpace = THREE.SRGBColorSpace;
26
34
  const sprite = new THREE.Sprite(
@@ -41,8 +49,11 @@ export function makeNameTag(world, text) {
41
49
  world.scene.add(sprite);
42
50
 
43
51
  // `text` is the string actually drawn, not the username it came from. The
44
- // caller compares against it to decide whether a name changed.
45
- return { sprite, text, width, height };
52
+ // caller compares against it, and against `avatar`, to decide whether to
53
+ // redraw. `lift` raises the sprite's centre so the name pill stays at
54
+ // `heightAbove` when an avatar is stacked over it.
55
+ const lift = (height - LABEL_PX / PIXELS_PER_UNIT) / 2;
56
+ return { sprite, text, avatar, width, height, lift };
46
57
  }
47
58
 
48
59
  // Move and scale a tag after its player pose is drawn for the current frame.
@@ -61,6 +72,7 @@ export function moveNameTag(tag, transform, camera, heightAbove = HEIGHT_ABOVE)
61
72
  // 0.8m 286px -> 100, 2m 114px -> 40, 4m 57px -> 29, 8m 29px -> 29, 20m 11 -> 11.
62
73
  const scale = Math.max(MIN_SCALE, Math.min(1, away / SAME_SIZE_M));
63
74
  tag.sprite.scale.set(tag.width * scale, tag.height * scale, 1);
75
+ tag.sprite.position.y += tag.lift * scale;
64
76
  }
65
77
 
66
78
  // Remove a tag from the three.js scene and release its texture and material.
@@ -78,24 +90,31 @@ export function shortName(username) {
78
90
  return anon ? `anon-${anon[1].replace(/-/g, '').slice(-6)}` : (username ?? 'player');
79
91
  }
80
92
 
81
- // Rasterize one display name into the canvas used as the sprite texture.
82
- function drawLabel(text) {
93
+ // Rasterize one display name, with the avatar centred above it, into the canvas
94
+ // used as the sprite texture.
95
+ function drawLabel(text, avatar) {
83
96
  const canvas = document.createElement('canvas');
84
97
  const ctx = canvas.getContext('2d');
85
98
  const font = '600 44px Inter, system-ui, sans-serif';
86
99
  ctx.font = font;
87
- const width = Math.ceil(ctx.measureText(text).width) + 40;
100
+ const pillWidth = Math.ceil(ctx.measureText(text).width) + 40;
101
+ const width = avatar ? Math.max(pillWidth, AVATAR_PX) : pillWidth;
102
+ const top = avatar ? AVATAR_PX + GAP_PX : 0;
88
103
 
89
104
  // Canvas resizing clears the context state, so measure before resizing and
90
105
  // restore the font before drawing.
91
106
  canvas.width = width;
92
- canvas.height = 68;
107
+ canvas.height = top + LABEL_PX;
93
108
  ctx.font = font;
109
+ if (avatar) {
110
+ ctx.drawImage(avatar, (width - AVATAR_PX) / 2, 0, AVATAR_PX, AVATAR_PX);
111
+ }
112
+ const left = (width - pillWidth) / 2;
94
113
  ctx.fillStyle = 'rgba(12,12,16,0.72)';
95
- ctx.roundRect(0, 0, width, 68, 16);
114
+ ctx.roundRect(left, top, pillWidth, LABEL_PX, 16);
96
115
  ctx.fill();
97
116
  ctx.fillStyle = '#f2f2f5';
98
117
  ctx.textBaseline = 'middle';
99
- ctx.fillText(text, 20, 36);
118
+ ctx.fillText(text, left + 20, top + LABEL_PX / 2);
100
119
  return canvas;
101
120
  }
@@ -7,6 +7,9 @@ import { earned, number } from '@imports/castle.real-time/code/server/limits.js'
7
7
  import * as Limits from './limits.js';
8
8
  import { createBody } from './world.js';
9
9
 
10
+ // The largest state block a client may report, as JSON text.
11
+ const STATE_BYTES = 4096;
12
+
10
13
  // An entry in `sim.players`:
11
14
  //
12
15
  // body the kinematic capsule in the rapier world
@@ -14,18 +17,26 @@ import { createBody } from './world.js';
14
17
  // facing radians, as the client sent them
15
18
  // clamped true only on the tick a move was refused
16
19
  // movedAt simulation time of that acceptance
17
- function newPlayer(body, at) {
18
- return { body, at, facing: 0, clamped: false, movedAt: 0 };
20
+ // settled whether this connection has ever reported a pose the server took
21
+ // state the block the client last reported: the engine's `scene.persistent`
22
+ // (score, camera angle, collected pickups). Saved with the pose and
23
+ // handed back on rejoin. The server never reads inside it.
24
+ function newPlayer(body, at, movedAt) {
25
+ return { body, at, facing: 0, clamped: false, movedAt, settled: false, state: null };
19
26
  }
20
27
 
21
28
  // Add a kinematic capsule for a session player. `world.step` places it at
22
29
  // `player.at` before physics; zero friction prevents a capsule leaning against a
23
30
  // wall from remaining attached to it.
24
- export function addPlayer(sim, playerId) {
31
+ //
32
+ // `pose` is where a returning player left off, as `[x, y, z, facing]`. Without
33
+ // one the capsule starts on the spawn ring.
34
+ export function addPlayer(sim, playerId, pose) {
25
35
  // The shape comes off the scene's own player actor, so the server collides
26
36
  // with the same body the browser draws. The default is a person-sized capsule.
27
37
  const shape = sim.template?.shape ?? { kind: 'capsule', radius: 0.35, height: 1.4 };
28
- const at = spawnPoint(sim);
38
+ const resumed = pose ? Limits.readPoint(pose) : null;
39
+ const at = resumed ?? spawnPoint(sim);
29
40
  const body = createBody(
30
41
  sim,
31
42
  { components: { Solid: {} } },
@@ -33,7 +44,15 @@ export function addPlayer(sim, playerId) {
33
44
  shape,
34
45
  { type: 'kinematic', friction: 0 },
35
46
  );
36
- sim.players.set(playerId, newPlayer(body, at));
47
+
48
+ // The movement allowance runs from now. A body that starts where a player left
49
+ // off would otherwise let that player's first report cover half a second of
50
+ // travel back to wherever their own copy of the scene put them.
51
+ const player = newPlayer(body, at, sim.timeMs);
52
+ if (resumed) {
53
+ player.facing = number(pose[3]);
54
+ }
55
+ sim.players.set(playerId, player);
37
56
  }
38
57
 
39
58
  // Remove a departing player's Rapier body and simulation entry.
@@ -64,6 +83,9 @@ export function setPose(sim, playerId, message) {
64
83
  // changes the rendered model orientation and has no effect on the upright
65
84
  // capsule's movement or collision shape.
66
85
  player.facing = number(message?.p?.[3]);
86
+ if (message?.s !== undefined) {
87
+ setState(sim, playerId, message.s);
88
+ }
67
89
 
68
90
  // Combine elapsed-time movement allowance with fixed delivery slack.
69
91
  const reach = Limits.MAX_SPEED * earned(sim, player.movedAt) + Limits.MOVE_SLACK;
@@ -77,6 +99,15 @@ export function setPose(sim, playerId, message) {
77
99
  if (distance <= reach) {
78
100
  player.at = asked;
79
101
  player.clamped = false;
102
+ player.settled = true;
103
+ return;
104
+ }
105
+
106
+ // The first report moves nothing. A client starts its own body from the scene
107
+ // and does not know yet where the session put this player, so the server
108
+ // position stands until the client has agreed with it once.
109
+ if (!player.settled) {
110
+ player.clamped = true;
80
111
  return;
81
112
  }
82
113
 
@@ -92,6 +123,24 @@ export function setPose(sim, playerId, message) {
92
123
  player.clamped = true;
93
124
  }
94
125
 
126
+ // Keep a player's state block. Only a plain object under `STATE_BYTES` of JSON
127
+ // is kept; anything else leaves the block as it was.
128
+ export function setState(sim, playerId, state) {
129
+ const player = sim.players.get(playerId);
130
+ if (!player || typeof state !== 'object' || Array.isArray(state)) {
131
+ return;
132
+ }
133
+ if (state !== null && JSON.stringify(state).length > STATE_BYTES) {
134
+ return;
135
+ }
136
+ player.state = state;
137
+ }
138
+
139
+ // A player's state block, or null.
140
+ export function stateOf(sim, playerId) {
141
+ return sim.players.get(playerId)?.state ?? null;
142
+ }
143
+
95
144
  // Pack current Rapier body poses for a WORLD or STATE message. Physics contacts
96
145
  // can make the body position differ from the last accepted target in `player.at`.
97
146
  export function playerPoses(sim) {
@@ -4,7 +4,12 @@
4
4
 
5
5
  import RAPIER from '@dimforge/rapier3d-compat';
6
6
  import { packPose } from '../client/poses.js';
7
- import { MAX_STEPS_PER_TICK, STEP, clamp } from '@imports/castle.real-time/code/server/limits.js';
7
+ import {
8
+ MAX_STEPS_PER_TICK,
9
+ STEP,
10
+ clamp,
11
+ number,
12
+ } from '@imports/castle.real-time/code/server/limits.js';
8
13
  import { readScene } from './sceneBodies.js';
9
14
  import * as Limits from './limits.js';
10
15
 
@@ -13,6 +18,7 @@ const DEG = Math.PI / 180;
13
18
  // The simulation every hook is handed as `sim`:
14
19
  //
15
20
  // world the rapier world
21
+ // bodies actor id -> rapier body, for every scene actor with a shape
16
22
  // objects actor id -> { body, home, claimable, fast, owner, reportedAt,
17
23
  // claimedAt }
18
24
  // doors [{ body, props, baseY, openT }]
@@ -27,6 +33,7 @@ function emptySim() {
27
33
  return {
28
34
  world: new RAPIER.World({ x: 0, y: -9.81, z: 0 }),
29
35
  objects: new Map(),
36
+ bodies: new Map(),
30
37
  doors: [],
31
38
  players: new Map(),
32
39
  spawn: { x: 0, y: 1, z: 0 },
@@ -76,6 +83,7 @@ function buildScene(sim) {
76
83
  continue;
77
84
  }
78
85
  const rb = createBody(sim, actor, t, shape, body);
86
+ sim.bodies.set(actor.id, rb);
79
87
  if (door) {
80
88
  sim.doors.push({ body: rb, props: door, baseY: t.y, openT: 0 });
81
89
  }
@@ -102,6 +110,19 @@ function buildScene(sim) {
102
110
  }
103
111
  }
104
112
 
113
+ // Take a scene actor out of the simulation for good. An id with no body here
114
+ // (a gem, say, that the engine handles as a sensor) is a no-op.
115
+ export function removeActor(sim, id) {
116
+ const rb = sim.bodies.get(id);
117
+ if (!rb) {
118
+ return;
119
+ }
120
+ sim.world.removeRigidBody(rb);
121
+ sim.bodies.delete(id);
122
+ sim.objects.delete(id);
123
+ sim.doors = sim.doors.filter((door) => door.body !== rb);
124
+ }
125
+
105
126
  // Create one Rapier body and collider from engine component data. These mappings
106
127
  // mirror the engine's `bodyDescFor` and `colliderDescFor`, so server and browser
107
128
  // worlds use the same body type, material values, shape, and sensor state.
@@ -289,6 +310,29 @@ export function objectPoses(sim) {
289
310
  return out;
290
311
  }
291
312
 
313
+ // Put saved poses back on the bodies they were read from. An id the scene no
314
+ // longer has is skipped. Velocities are cleared: a restored body starts at rest.
315
+ // Returns how many poses were applied.
316
+ export function restorePoses(sim, poses) {
317
+ let applied = 0;
318
+ for (const pose of poses) {
319
+ const entry = sim.objects.get(pose?.[0]);
320
+ const at = entry ? Limits.readPoint(pose.slice(1)) : null;
321
+ if (!at) {
322
+ continue;
323
+ }
324
+ entry.body.setTranslation(at, true);
325
+ entry.body.setRotation(
326
+ { x: number(pose[4]), y: number(pose[5]), z: number(pose[6]), w: number(pose[7]) },
327
+ true,
328
+ );
329
+ entry.body.setLinvel({ x: 0, y: 0, z: 0 }, true);
330
+ entry.body.setAngvel({ x: 0, y: 0, z: 0 }, true);
331
+ applied += 1;
332
+ }
333
+ return applied;
334
+ }
335
+
292
336
  // Convert the engine Transform's degree Euler angles to a Rapier quaternion.
293
337
  function quatFromEuler(t) {
294
338
  const cx = Math.cos((t.rotationX ?? 0) * DEG * 0.5);