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.
- package/dist/agent-prompts.d.ts +1 -0
- package/dist/agent-prompts.js +3 -0
- package/dist/agent.js +47 -11
- package/dist/castle-host/host.js +71 -0
- package/dist/devSessionServer.js +9 -5
- package/dist/ide.d.ts +10 -0
- package/dist/ide.js +5 -5
- package/dist/init.js +1 -1
- package/dist/shell/assets/index-6odVZQSZ.css +1 -0
- package/dist/shell/assets/index-Ws0WrCbi.js +445 -0
- package/dist/shell/index.html +3 -3
- package/kits/base/castle.json +1 -1
- package/kits/base/sdk/README.md +17 -1
- package/kits/base/sdk/commands.d.ts +14 -0
- package/kits/base/sdk/user.d.ts +4 -0
- package/kits/base/sdk/user.js +36 -1
- package/kits/multiplayer-2d/CLAUDE.md +24 -9
- package/kits/multiplayer-2d/behaviors/Box.jsx +32 -9
- package/kits/multiplayer-2d/castle.json +3 -3
- package/kits/multiplayer-2d/code/client/avatars.js +6 -1
- package/kits/multiplayer-2d/code/server/players.js +27 -6
- package/kits/multiplayer-2d/code/server/world.js +24 -1
- package/kits/multiplayer-2d/code/systems/multiplayer.js +47 -4
- package/kits/multiplayer-2d/package-lock.json +26 -1
- package/kits/multiplayer-2d/package.json +2 -0
- package/kits/multiplayer-3d/CLAUDE.md +32 -9
- package/kits/multiplayer-3d/castle.json +4 -4
- package/kits/multiplayer-3d/code/client/avatars.js +15 -11
- package/kits/multiplayer-3d/code/client/nameTags.js +35 -16
- package/kits/multiplayer-3d/code/server/players.js +54 -5
- package/kits/multiplayer-3d/code/server/world.js +45 -1
- package/kits/multiplayer-3d/code/systems/multiplayer.js +112 -7
- package/kits/multiplayer-3d/package-lock.json +26 -1
- package/kits/multiplayer-3d/package.json +2 -0
- package/kits/physics-2d/castle.json +1 -1
- package/kits/physics-2d/editors/PxArtEditor.jsx +2 -2
- package/kits/physics-2d/engine/avatarArt.js +91 -0
- package/kits/physics-3d/behaviors/Pickup.jsx +5 -3
- package/kits/physics-3d/castle.json +1 -1
- package/kits/real-time/CLAUDE.md +23 -4
- package/kits/real-time/castle.json +1 -1
- package/kits/real-time/code/client/connection.js +15 -4
- package/kits/real-time/code/client/joinOverlay.js +46 -0
- package/kits/real-time/code/client/messages.js +4 -0
- package/kits/real-time/code/server/gameHooks.js +4 -0
- package/kits/real-time/code/server/persist.js +105 -0
- package/kits/real-time/code/server/session.js +295 -19
- package/kits/real-time/package-lock.json +1139 -0
- package/kits/turn-based/CLAUDE.md +80 -20
- package/kits/turn-based/castle.json +2 -2
- package/kits/turn-based/code/behaviors/Board.jsx +15 -5
- package/kits/turn-based/code/server/index.js +17 -5
- package/kits/turn-based/package.json +2 -1
- package/kits/turn-based/room.js +164 -13
- package/kits/turn-based/testing.js +276 -92
- package/package.json +1 -2
- package/dist/shell/assets/index-BvQmVwlO.css +0 -1
- 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
|
|
82
|
-
|
|
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
|
|
242
|
-
on-screen
|
|
243
|
-
(`
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
`
|
|
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-
|
|
17
|
+
"version": "2026-09-10T20:27:27.032Z"
|
|
18
18
|
},
|
|
19
19
|
"castle.physics-3d": {
|
|
20
20
|
"deckId": "JH0SclbPVP0y",
|
|
21
|
-
"version": "2026-09-
|
|
21
|
+
"version": "2026-09-10T19:46:37.323Z"
|
|
22
22
|
},
|
|
23
23
|
"castle.physics-2d": {
|
|
24
24
|
"deckId": "ckRZGFW4iPrx",
|
|
25
|
-
"version": "2026-09-
|
|
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-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
110
|
-
// Each redraw allocates a canvas, texture, and sprite, so unchanged
|
|
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
|
|
2
|
-
// draws
|
|
3
|
-
// three.js scene, scales it by camera
|
|
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
|
|
20
|
-
// three.js scene.
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
45
|
-
|
|
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
|
|
82
|
-
|
|
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
|
|
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 =
|
|
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(
|
|
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,
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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);
|