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
|
@@ -10,7 +10,11 @@ import * as Smoothing from '@imports/castle.real-time/code/client/smooth.js';
|
|
|
10
10
|
import * as Poses from '../client/poses.js';
|
|
11
11
|
import { connectSession } from '@imports/castle.real-time/code/client/connection.js';
|
|
12
12
|
import { showSoloBadge, updateSoloBadge } from '@imports/castle.real-time/code/client/soloBadge.js';
|
|
13
|
-
import {
|
|
13
|
+
import { showJoinOverlay } from '@imports/castle.real-time/code/client/joinOverlay.js';
|
|
14
|
+
import { GONE, STATE, WORLD } from '@imports/castle.real-time/code/client/messages.js';
|
|
15
|
+
|
|
16
|
+
// How long the joining overlay may hold the card before giving up.
|
|
17
|
+
const JOIN_WAIT_MS = 10000;
|
|
14
18
|
import { game } from '@imports/castle.real-time/code/client/gameHooks.js';
|
|
15
19
|
|
|
16
20
|
// Apply this fraction of a server player correction per frame. Corrections over
|
|
@@ -46,6 +50,11 @@ class MultiplayerSystem {
|
|
|
46
50
|
constructor() {
|
|
47
51
|
// The Castle transport is created on the first stepped frame.
|
|
48
52
|
this.net = null;
|
|
53
|
+
// False until a server snapshot carries this player's pose. The overlay
|
|
54
|
+
// stays up from the first stepped frame until then, or until JOIN_WAIT_MS
|
|
55
|
+
// after the join began if the session never answers.
|
|
56
|
+
this.joined = false;
|
|
57
|
+
this.joinStartedAt = 0;
|
|
49
58
|
|
|
50
59
|
// `starting` prevents concurrent join attempts while the promise is pending.
|
|
51
60
|
this.starting = false;
|
|
@@ -68,6 +77,16 @@ class MultiplayerSystem {
|
|
|
68
77
|
|
|
69
78
|
// A clamp stores the last server position that rejected a local move.
|
|
70
79
|
this.clampedTo = null;
|
|
80
|
+
|
|
81
|
+
// Scene actors the session has removed for good, and the actor ids present
|
|
82
|
+
// at the end of the last frame. An id that was there and is not now was
|
|
83
|
+
// despawned by a behavior this frame, and is reported.
|
|
84
|
+
this.gone = new Set();
|
|
85
|
+
this.seen = null;
|
|
86
|
+
|
|
87
|
+
// The JSON of the last state block sent, so a block goes out only when it
|
|
88
|
+
// changes.
|
|
89
|
+
this.sentState = '';
|
|
71
90
|
}
|
|
72
91
|
|
|
73
92
|
// Run the network frame after engine behaviors and local physics. Connection,
|
|
@@ -76,12 +95,15 @@ class MultiplayerSystem {
|
|
|
76
95
|
// Connecting on the first stepped frame keeps editor-only runtimes out of
|
|
77
96
|
// multiplayer sessions because the scene editor constructs but never steps them.
|
|
78
97
|
if (!this.net) {
|
|
98
|
+
showJoinOverlay(this.joiningOverlayWanted());
|
|
79
99
|
return void this.start();
|
|
80
100
|
}
|
|
81
101
|
const local = scene.actorWith('Player');
|
|
82
102
|
|
|
83
103
|
// Drain all queued messages before choosing the render time for this frame.
|
|
84
104
|
this.receive(scene);
|
|
105
|
+
this.applyGone(scene);
|
|
106
|
+
this.reportGone(scene);
|
|
85
107
|
|
|
86
108
|
// Draw at a fixed delay behind the server clock so interpolation normally has
|
|
87
109
|
// samples on both sides. The local engine keeps stepping before the first
|
|
@@ -98,7 +120,9 @@ class MultiplayerSystem {
|
|
|
98
120
|
// Deck code can make a final local adjustment after engine Player behavior
|
|
99
121
|
// and physics, then optionally replace the kit's standard report.
|
|
100
122
|
game.beforeReport?.(scene, local, this);
|
|
101
|
-
if (
|
|
123
|
+
if (!this.joined) {
|
|
124
|
+
// Nothing reported until the session has placed this player.
|
|
125
|
+
} else if (game.report) {
|
|
102
126
|
game.report(scene, local, this);
|
|
103
127
|
} else {
|
|
104
128
|
this.report(scene, local);
|
|
@@ -117,6 +141,7 @@ class MultiplayerSystem {
|
|
|
117
141
|
}
|
|
118
142
|
this.publish(scene, local);
|
|
119
143
|
updateSoloBadge(this.net.status(), game);
|
|
144
|
+
showJoinOverlay(this.joiningOverlayWanted());
|
|
120
145
|
}
|
|
121
146
|
|
|
122
147
|
// Join the Castle public session once and retain the transport across scene loads.
|
|
@@ -125,6 +150,7 @@ class MultiplayerSystem {
|
|
|
125
150
|
return;
|
|
126
151
|
}
|
|
127
152
|
this.starting = true;
|
|
153
|
+
this.joinStartedAt ||= performance.now();
|
|
128
154
|
try {
|
|
129
155
|
this.net = await connectSession({ onLog: (text) => console.log(`[multiplayer] ${text}`) });
|
|
130
156
|
} catch (err) {
|
|
@@ -145,6 +171,21 @@ class MultiplayerSystem {
|
|
|
145
171
|
|
|
146
172
|
// Pair simulation time with the page arrival time before filing samples.
|
|
147
173
|
Clock.noteArrival(this.clock, msg.t, msg.localMs);
|
|
174
|
+
if (!this.joined && (msg.k === WORLD || (msg.ps ?? []).some((pose) => pose[0] === selfId))) {
|
|
175
|
+
this.joined = true;
|
|
176
|
+
console.log('[multiplayer] placed by the session');
|
|
177
|
+
}
|
|
178
|
+
for (const id of msg.gone ?? []) {
|
|
179
|
+
this.gone.add(id);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// The WORLD snapshot carries this player's own saved block. It goes back
|
|
183
|
+
// on `scene.persistent`, which is where the engine's Player behavior keeps
|
|
184
|
+
// the camera angle and score, before that behavior runs again.
|
|
185
|
+
if (msg.k === WORLD && msg.me) {
|
|
186
|
+
Object.assign((scene.persistent ??= {}), msg.me);
|
|
187
|
+
this.sentState = JSON.stringify(scene.persistent);
|
|
188
|
+
}
|
|
148
189
|
|
|
149
190
|
// Apply ownership first so an object granted this tick stops receiving
|
|
150
191
|
// network poses on the same frame local simulation begins.
|
|
@@ -152,9 +193,11 @@ class MultiplayerSystem {
|
|
|
152
193
|
for (const [id, x, y, z, facing, clamped] of msg.ps ?? []) {
|
|
153
194
|
if (id !== selfId) {
|
|
154
195
|
sample(this.players, msg, id, [x, y, z, facing]);
|
|
155
|
-
} else if (clamped) {
|
|
156
|
-
// The local player's returned pose is used
|
|
157
|
-
//
|
|
196
|
+
} else if (clamped || msg.k === WORLD) {
|
|
197
|
+
// The local player's returned pose is used when the server marks it
|
|
198
|
+
// clamped, and on the WORLD snapshot that starts a session: that one
|
|
199
|
+
// carries where this player left off. Ordinary local movement remains
|
|
200
|
+
// client-simulated.
|
|
158
201
|
this.clampedTo = { x, y, z };
|
|
159
202
|
}
|
|
160
203
|
}
|
|
@@ -219,6 +262,7 @@ class MultiplayerSystem {
|
|
|
219
262
|
}
|
|
220
263
|
Avatars.showPlayer(this.avatars, scene, playerId, look.username ?? null, pose, {
|
|
221
264
|
blueprint: look.blueprint,
|
|
265
|
+
userId: this.net.userId(playerId),
|
|
222
266
|
});
|
|
223
267
|
}
|
|
224
268
|
Avatars.dropMissing(this.avatars, scene, others);
|
|
@@ -300,8 +344,16 @@ class MultiplayerSystem {
|
|
|
300
344
|
|
|
301
345
|
// `sendMove` only goes out at MOVE_HZ; claims are held until it does. The
|
|
302
346
|
// transport takes a packed pose, so the Transform is encoded here.
|
|
303
|
-
|
|
304
|
-
|
|
347
|
+
// The state block rides in a move packet when it changed since the last one
|
|
348
|
+
// that went out.
|
|
349
|
+
const stateJson = JSON.stringify(scene.persistent ?? {});
|
|
350
|
+
const state = stateJson === this.sentState ? undefined : (scene.persistent ?? {});
|
|
351
|
+
if (this.net.sendMove(Poses.packMove(me), owned, claims, releases, state) && state) {
|
|
352
|
+
this.sentState = stateJson;
|
|
353
|
+
}
|
|
354
|
+
Avatars.showSelf(this.avatars, scene, this.net.status().you, me, {
|
|
355
|
+
userId: this.net.userId(this.net.selfId()),
|
|
356
|
+
});
|
|
305
357
|
}
|
|
306
358
|
|
|
307
359
|
// Return `[id, Transform]` for every scene object known from server snapshots.
|
|
@@ -388,6 +440,56 @@ class MultiplayerSystem {
|
|
|
388
440
|
this.own = Ownership.makeOwnership();
|
|
389
441
|
this.shown = Smoothing.makeSmoothing();
|
|
390
442
|
this.clampedTo = null;
|
|
443
|
+
this.seen = null;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
//
|
|
447
|
+
// Actors removed for good
|
|
448
|
+
//
|
|
449
|
+
|
|
450
|
+
// Despawn every actor the session says is gone. Runs each frame, so a scene
|
|
451
|
+
// load that brings an actor back from the file loses it again at once.
|
|
452
|
+
applyGone(scene) {
|
|
453
|
+
for (const id of this.gone) {
|
|
454
|
+
if (scene.actors.has(id)) {
|
|
455
|
+
scene.despawnActor(id);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Report actors a behavior despawned this frame (a collected pickup, say).
|
|
461
|
+
// Other players' avatars are the kit's own and are not reported. Nothing is
|
|
462
|
+
// reported before the session has placed this player.
|
|
463
|
+
reportGone(scene) {
|
|
464
|
+
const ids = new Set(scene.actors.keys());
|
|
465
|
+
if (this.seen && this.joined) {
|
|
466
|
+
const avatars = new Set(this.avatars.actors.values());
|
|
467
|
+
for (const id of this.seen) {
|
|
468
|
+
if (ids.has(id) || this.gone.has(id) || avatars.has(id)) {
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
this.gone.add(id);
|
|
472
|
+
this.net.send({ k: GONE, id });
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
this.seen = ids;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Whether the overlay should be up: joining or connected, not solo, and not
|
|
479
|
+
// yet placed. A session that never answers releases it after JOIN_WAIT_MS so
|
|
480
|
+
// the deck is not stuck behind it.
|
|
481
|
+
joiningOverlayWanted() {
|
|
482
|
+
if (this.joined || this.net?.status().solo) {
|
|
483
|
+
return false;
|
|
484
|
+
}
|
|
485
|
+
if (this.joinStartedAt && performance.now() - this.joinStartedAt > JOIN_WAIT_MS) {
|
|
486
|
+
if (!this.joinWarned) {
|
|
487
|
+
this.joinWarned = true;
|
|
488
|
+
console.warn('[multiplayer] no snapshot from the session yet; playing unplaced');
|
|
489
|
+
}
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
492
|
+
return true;
|
|
391
493
|
}
|
|
392
494
|
|
|
393
495
|
// Release scene resources and leave the session when the engine runtime ends.
|
|
@@ -396,6 +498,9 @@ class MultiplayerSystem {
|
|
|
396
498
|
this.reset(scene);
|
|
397
499
|
this.net?.close();
|
|
398
500
|
this.net = null;
|
|
501
|
+
this.joined = false;
|
|
502
|
+
this.joinStartedAt = 0;
|
|
503
|
+
showJoinOverlay(false);
|
|
399
504
|
// The badge is a fixed element outside the scene, so a disposed runtime
|
|
400
505
|
// (an editor preview closing, say) has to take it down explicitly.
|
|
401
506
|
showSoloBadge(null);
|
|
@@ -16,8 +16,10 @@
|
|
|
16
16
|
"@dnd-kit/modifiers": "^9.0.0",
|
|
17
17
|
"@dnd-kit/sortable": "^10.0.0",
|
|
18
18
|
"@dnd-kit/utilities": "^3.2.2",
|
|
19
|
+
"@fortawesome/free-solid-svg-icons": "^5.15.4",
|
|
19
20
|
"@lezer/highlight": "^1.2.3",
|
|
20
21
|
"castle-web-fonts": "^1.0.0",
|
|
22
|
+
"castle-web-sdk": "file:../../sdk",
|
|
21
23
|
"codemirror": "^6.0.2",
|
|
22
24
|
"matter-js": "^0.20.0",
|
|
23
25
|
"react": "^19.2.4",
|
|
@@ -30,7 +32,7 @@
|
|
|
30
32
|
},
|
|
31
33
|
"../../sdk": {
|
|
32
34
|
"name": "castle-web-sdk",
|
|
33
|
-
"version": "0.4.
|
|
35
|
+
"version": "0.4.27",
|
|
34
36
|
"dev": true,
|
|
35
37
|
"devDependencies": {
|
|
36
38
|
"eslint": "^9.0.0",
|
|
@@ -208,6 +210,29 @@
|
|
|
208
210
|
"react": ">=16.8.0"
|
|
209
211
|
}
|
|
210
212
|
},
|
|
213
|
+
"node_modules/@fortawesome/fontawesome-common-types": {
|
|
214
|
+
"version": "0.2.36",
|
|
215
|
+
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.36.tgz",
|
|
216
|
+
"integrity": "sha512-a/7BiSgobHAgBWeN7N0w+lAhInrGxksn13uK7231n2m8EDPE3BMCl9NZLTGrj9ZXfCmC6LM0QLqXidIizVQ6yg==",
|
|
217
|
+
"hasInstallScript": true,
|
|
218
|
+
"license": "MIT",
|
|
219
|
+
"engines": {
|
|
220
|
+
"node": ">=6"
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
"node_modules/@fortawesome/free-solid-svg-icons": {
|
|
224
|
+
"version": "5.15.4",
|
|
225
|
+
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.15.4.tgz",
|
|
226
|
+
"integrity": "sha512-JLmQfz6tdtwxoihXLg6lT78BorrFyCf59SAwBM6qV/0zXyVeDygJVb3fk+j5Qat+Yvcxp1buLTY5iDh1ZSAQ8w==",
|
|
227
|
+
"hasInstallScript": true,
|
|
228
|
+
"license": "(CC-BY-4.0 AND MIT)",
|
|
229
|
+
"dependencies": {
|
|
230
|
+
"@fortawesome/fontawesome-common-types": "^0.2.36"
|
|
231
|
+
},
|
|
232
|
+
"engines": {
|
|
233
|
+
"node": ">=6"
|
|
234
|
+
}
|
|
235
|
+
},
|
|
211
236
|
"node_modules/@lezer/common": {
|
|
212
237
|
"version": "1.5.2",
|
|
213
238
|
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
|
|
@@ -17,8 +17,10 @@
|
|
|
17
17
|
"@dnd-kit/modifiers": "^9.0.0",
|
|
18
18
|
"@dnd-kit/sortable": "^10.0.0",
|
|
19
19
|
"@dnd-kit/utilities": "^3.2.2",
|
|
20
|
+
"@fortawesome/free-solid-svg-icons": "^5.15.4",
|
|
20
21
|
"@lezer/highlight": "^1.2.3",
|
|
21
22
|
"castle-web-fonts": "^1.0.0",
|
|
23
|
+
"castle-web-sdk": "file:../../sdk",
|
|
22
24
|
"codemirror": "^6.0.2",
|
|
23
25
|
"matter-js": "^0.20.0",
|
|
24
26
|
"react": "^19.2.4",
|
|
@@ -419,7 +419,7 @@ export function PxArtEditor({ path, text, onChange, files, readOnly = false, ...
|
|
|
419
419
|
const artboardCanvasStyle = canvasSize ?? canvasStyle;
|
|
420
420
|
// `canvasStyle`'s width is the artboard's CURRENT display size (recomputed
|
|
421
421
|
// by useArtboardFit's own ResizeObserver whenever the wrap resizes — e.g. a
|
|
422
|
-
//
|
|
422
|
+
// panel resize). Feeding it in here is what keeps the supersampled
|
|
423
423
|
// canvas's resolution in sync with that display size: without it, this
|
|
424
424
|
// effect has no way to know the wrap resized (nothing about `text`,
|
|
425
425
|
// `frameIndex`, or `cornerRadius` changes on a pure container resize), so
|
|
@@ -2458,7 +2458,7 @@ function useArtboardRender(canvasRef, text, sprite, previewIndex, onion, playing
|
|
|
2458
2458
|
// the `sprite` object (a fresh object every render) so it only redraws on an
|
|
2459
2459
|
// actual content/frame/radius change, not on every hover-driven re-render of
|
|
2460
2460
|
// the tool preview — PLUS `displayWidth` (the artboard's current CSS width
|
|
2461
|
-
// from useArtboardFit), so a wrap resize (e.g. a
|
|
2461
|
+
// from useArtboardFit), so a wrap resize (e.g. a panel drag) also
|
|
2462
2462
|
// triggers a re-render at the new supersample scale, not just a content
|
|
2463
2463
|
// change. The supersample factor auto-fits to the displayed size (capped
|
|
2464
2464
|
// between 4x and 16x).
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// A player's round avatar for drawing in a scene: the photo clipped to a circle
|
|
2
|
+
// with their frame over it, as a canvas. Read through `User.get` once per user
|
|
3
|
+
// id and kept for the page. A player with no photo has no avatar, and a caller
|
|
4
|
+
// keeps its default look.
|
|
5
|
+
//
|
|
6
|
+
// `avatarCanvas` is for code that draws with a canvas context, like a name tag.
|
|
7
|
+
// `avatarSpritePath` registers the same picture as image art in `scene.sprites`,
|
|
8
|
+
// so a `Sprite` component can show it by file path.
|
|
9
|
+
|
|
10
|
+
import { User } from 'castle-web-sdk';
|
|
11
|
+
import { imageArt } from './art';
|
|
12
|
+
|
|
13
|
+
const SIZE = 128;
|
|
14
|
+
|
|
15
|
+
// A frame is drawn around a photo two thirds its size, the proportion Castle's
|
|
16
|
+
// own profile views use.
|
|
17
|
+
const FRAMED_INSET = SIZE / 6;
|
|
18
|
+
|
|
19
|
+
// userId -> { canvas }, present from the first ask; `canvas` fills in when the
|
|
20
|
+
// read completes.
|
|
21
|
+
const avatars = new Map();
|
|
22
|
+
|
|
23
|
+
// The avatar for one user id, or null until it is loaded or when there is none.
|
|
24
|
+
export function avatarCanvas(userId) {
|
|
25
|
+
if (!userId) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
let entry = avatars.get(userId);
|
|
29
|
+
if (!entry) {
|
|
30
|
+
entry = { canvas: null };
|
|
31
|
+
avatars.set(userId, entry);
|
|
32
|
+
void load(userId, entry);
|
|
33
|
+
}
|
|
34
|
+
return entry.canvas;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The `scene.sprites` path holding this user's avatar, or null while there is
|
|
38
|
+
// none. A `Sprite` with this `file` draws it.
|
|
39
|
+
export function avatarSpritePath(scene, userId) {
|
|
40
|
+
const canvas = avatarCanvas(userId);
|
|
41
|
+
if (!canvas) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
const path = `avatars/${userId}.png`;
|
|
45
|
+
if (!scene.sprites[path]) {
|
|
46
|
+
scene.sprites[path] = imageArt(path, canvas.toDataURL());
|
|
47
|
+
}
|
|
48
|
+
return path;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function load(userId, entry) {
|
|
52
|
+
try {
|
|
53
|
+
const user = await User.get(userId);
|
|
54
|
+
const [photo, frame] = await Promise.all([loadImage(user?.photoUrl), loadImage(user?.frameUrl)]);
|
|
55
|
+
if (photo) {
|
|
56
|
+
entry.canvas = compose(photo, frame);
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
// A failed read leaves the entry without a canvas; the next page asks again.
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function loadImage(url) {
|
|
64
|
+
if (!url) {
|
|
65
|
+
return Promise.resolve(null);
|
|
66
|
+
}
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
const image = new Image();
|
|
69
|
+
image.onload = () => resolve(image);
|
|
70
|
+
image.onerror = () => resolve(null);
|
|
71
|
+
image.src = url;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function compose(photo, frame) {
|
|
76
|
+
const canvas = document.createElement('canvas');
|
|
77
|
+
canvas.width = SIZE;
|
|
78
|
+
canvas.height = SIZE;
|
|
79
|
+
const ctx = canvas.getContext('2d');
|
|
80
|
+
const inset = frame ? FRAMED_INSET : 0;
|
|
81
|
+
ctx.save();
|
|
82
|
+
ctx.beginPath();
|
|
83
|
+
ctx.arc(SIZE / 2, SIZE / 2, SIZE / 2 - inset, 0, Math.PI * 2);
|
|
84
|
+
ctx.clip();
|
|
85
|
+
ctx.drawImage(photo, inset, inset, SIZE - 2 * inset, SIZE - 2 * inset);
|
|
86
|
+
ctx.restore();
|
|
87
|
+
if (frame) {
|
|
88
|
+
ctx.drawImage(frame, 0, 0, SIZE, SIZE);
|
|
89
|
+
}
|
|
90
|
+
return canvas;
|
|
91
|
+
}
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Collection is remembered in the persistent store keyed by scene + actor id,
|
|
6
6
|
// so re-entering a scene doesn't respawn (and double-count) shards
|
|
7
|
-
// -- scene loads otherwise reset the world to its authored state.
|
|
7
|
+
// -- scene loads otherwise reset the world to its authored state. The store is
|
|
8
|
+
// a plain object, not a Set: `scene.persistent` has to survive JSON, since a
|
|
9
|
+
// multiplayer kit saves it per player.
|
|
8
10
|
function pickupKey(scene, actor) {
|
|
9
11
|
return `${scene.currentScenePath ?? ''}:${actor.id}`;
|
|
10
12
|
}
|
|
@@ -18,7 +20,7 @@ export class Pickup {
|
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
update(actor, scene, dt) {
|
|
21
|
-
if (scene.persistent?.collected?.
|
|
23
|
+
if (scene.persistent?.collected?.[pickupKey(scene, actor)]) {
|
|
22
24
|
scene.despawnActor(actor.id);
|
|
23
25
|
return;
|
|
24
26
|
}
|
|
@@ -33,7 +35,7 @@ export class Pickup {
|
|
|
33
35
|
if (!other.components.Player) return;
|
|
34
36
|
const per = (scene.persistent ??= {});
|
|
35
37
|
per.score = (per.score ?? 0) + 1;
|
|
36
|
-
(per.collected ??=
|
|
38
|
+
(per.collected ??= {})[pickupKey(scene, actor)] = true;
|
|
37
39
|
scene.despawnActor(actor.id);
|
|
38
40
|
}
|
|
39
41
|
}
|
package/kits/real-time/CLAUDE.md
CHANGED
|
@@ -20,6 +20,16 @@ imports these modules from here directly.
|
|
|
20
20
|
- **A deck normally writes nothing against it.** The multiplayer kit it came with
|
|
21
21
|
already drives all of this; a deck's own code goes in `code/server/game.js` and
|
|
22
22
|
`code/client/game.js`, which that kit documents.
|
|
23
|
+
- **What persists.** Object poses, the ids of scene actors a client despawned,
|
|
24
|
+
each player's last pose and state block by account, and whatever the optional
|
|
25
|
+
`save(sim)` hook returns. Written every ten seconds when something moved and
|
|
26
|
+
at a clean shutdown. Nothing to configure.
|
|
27
|
+
- **A returning player starts where they left off.** A second connection from
|
|
28
|
+
the same account while the first is still playing gets a new character at the
|
|
29
|
+
spawn. The room is `session.sessionId`; every `public` shard shares one.
|
|
30
|
+
- `restore(sim, saved)` receives the last `save` result before `ready`. Storage
|
|
31
|
+
is keyed by deck, so the deck must be published; `castle-web serve` brokers it
|
|
32
|
+
as the deck's creator.
|
|
23
33
|
- A pose is an opaque array of numbers here, so anything reading inside one takes
|
|
24
34
|
the kit's `code/client/poses.js` as an argument. That is the seam to use when
|
|
25
35
|
something genuinely needs a lower level.
|
|
@@ -59,10 +69,19 @@ never learns the format.
|
|
|
59
69
|
values of a player pose are geometry before the `clamped` flag, and `epsilonAt`,
|
|
60
70
|
which a kit whose pose values are all one unit leaves undefined.
|
|
61
71
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
72
|
+
Persistence adds to that contract: `world.restorePoses(sim, poses)` puts saved
|
|
73
|
+
poses back on the bodies the scene still has, and `players.addPlayer` takes the
|
|
74
|
+
saved pose a returning player starts from. Optional beside those:
|
|
75
|
+
`world.removeActor(sim, id)` drops a despawned actor's body, and
|
|
76
|
+
`players.setState` / `players.stateOf` keep the block a client reports.
|
|
77
|
+
|
|
78
|
+
Two files here touch the DOM, both dependency-free. `soloBadge.js` is a fixed
|
|
79
|
+
badge that names why a session fell back to solo (`status().soloReason` from
|
|
80
|
+
`connection.js`), drawn by both kits each frame through `updateSoloBadge`; a
|
|
81
|
+
deck's `soloBadge` hook hides it. `joinOverlay.js` covers the card with
|
|
82
|
+
"Joining…" from the first frame until the session's first snapshot places the
|
|
83
|
+
player, so nobody plays from the scene's spawn before the saved spot arrives;
|
|
84
|
+
it gives up after ten seconds with a console warning.
|
|
66
85
|
|
|
67
86
|
## Rules for editing this kit
|
|
68
87
|
|
|
@@ -12,6 +12,7 @@ const SEND_MS = 1000 / Messages.MOVE_HZ;
|
|
|
12
12
|
// connection the SDK connection
|
|
13
13
|
// inbox arrived messages, each with `localMs` added
|
|
14
14
|
// roster playerId -> username, as the platform last reported it
|
|
15
|
+
// userIds playerId -> userId, from the same roster
|
|
15
16
|
// you the SDK's entry for the local player, null until a roster has it
|
|
16
17
|
// sentAt `performance.now()` of the last move, so sends stay at MOVE_HZ
|
|
17
18
|
// claims ids asked for since the last send, held until it goes
|
|
@@ -21,6 +22,7 @@ function makeNet(connection) {
|
|
|
21
22
|
connection,
|
|
22
23
|
inbox: [],
|
|
23
24
|
roster: new Map(),
|
|
25
|
+
userIds: new Map(),
|
|
24
26
|
you: null,
|
|
25
27
|
sentAt: 0,
|
|
26
28
|
claims: new Set(),
|
|
@@ -48,6 +50,7 @@ export async function connectSession({ onLog = () => {} } = {}) {
|
|
|
48
50
|
|
|
49
51
|
// Replace the map from the complete SDK roster so departures disappear.
|
|
50
52
|
net.roster = new Map(players.map((player) => [player.playerId, player.username]));
|
|
53
|
+
net.userIds = new Map(players.map((player) => [player.playerId, player.userId]));
|
|
51
54
|
net.you = you;
|
|
52
55
|
});
|
|
53
56
|
|
|
@@ -61,6 +64,9 @@ export async function connectSession({ onLog = () => {} } = {}) {
|
|
|
61
64
|
// A stable fallback until the SDK roster includes this player.
|
|
62
65
|
username: (playerId) => net.roster.get(playerId) ?? 'player',
|
|
63
66
|
|
|
67
|
+
// The account behind a player, for `User.get`; null until the roster has it.
|
|
68
|
+
userId: (playerId) => net.userIds.get(playerId) ?? null,
|
|
69
|
+
|
|
64
70
|
// Every other player's roster entry. The local player is simulated by this client,
|
|
65
71
|
// so the deck draws only these from network samples.
|
|
66
72
|
others: () => new Map([...net.roster].filter(([id]) => id !== connection.playerId)),
|
|
@@ -81,7 +87,8 @@ export async function connectSession({ onLog = () => {} } = {}) {
|
|
|
81
87
|
// the transport handles every dimensional format in the same way.
|
|
82
88
|
// The client system calls this every frame; most calls only accumulate the
|
|
83
89
|
// ownership requests, which are held until the next rate-limited send.
|
|
84
|
-
sendMove: (pose, owned, claims, releases) =>
|
|
90
|
+
sendMove: (pose, owned, claims, releases, state) =>
|
|
91
|
+
sendMove(net, pose, owned, claims, releases, state),
|
|
85
92
|
|
|
86
93
|
// Send deck-specific data. The server routes these to `code/server/game.js`.
|
|
87
94
|
send: (data) => net.connection.send(data),
|
|
@@ -106,8 +113,10 @@ export async function connectSession({ onLog = () => {} } = {}) {
|
|
|
106
113
|
};
|
|
107
114
|
}
|
|
108
115
|
|
|
109
|
-
// Collect ownership requests from every frame and send at `MOVE_HZ`.
|
|
110
|
-
|
|
116
|
+
// Collect ownership requests from every frame and send at `MOVE_HZ`. `state` is
|
|
117
|
+
// the player's own block, sent along when the caller has one to send. Returns
|
|
118
|
+
// whether a packet went out.
|
|
119
|
+
function sendMove(net, pose, owned, claims, releases, state) {
|
|
111
120
|
// Gathered on every frame, including the ones that send nothing, so a claim
|
|
112
121
|
// made in between is still in the next message. The sets also collapse
|
|
113
122
|
// repeated requests for the same object over that interval.
|
|
@@ -119,7 +128,7 @@ function sendMove(net, pose, owned, claims, releases) {
|
|
|
119
128
|
}
|
|
120
129
|
const now = performance.now();
|
|
121
130
|
if (now - net.sentAt < SEND_MS) {
|
|
122
|
-
return;
|
|
131
|
+
return false;
|
|
123
132
|
}
|
|
124
133
|
net.sentAt = now;
|
|
125
134
|
|
|
@@ -130,9 +139,11 @@ function sendMove(net, pose, owned, claims, releases) {
|
|
|
130
139
|
c: [...net.claims],
|
|
131
140
|
r: [...net.releases],
|
|
132
141
|
o: owned,
|
|
142
|
+
...(state === undefined ? {} : { s: state }),
|
|
133
143
|
});
|
|
134
144
|
|
|
135
145
|
// Clear only after a packet; rate-limited returns above retain the requests.
|
|
136
146
|
net.claims.clear();
|
|
137
147
|
net.releases.clear();
|
|
148
|
+
return true;
|
|
138
149
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// A full-card overlay shown from connecting until the server's first snapshot
|
|
2
|
+
// places this player. Under it the local character may still be standing at the
|
|
3
|
+
// scene's spawn; the overlay hides that and takes the pointer, so nothing is
|
|
4
|
+
// played until the session says where things are.
|
|
5
|
+
//
|
|
6
|
+
// It mounts inside the card when the sdk has marked one, so a capture of the
|
|
7
|
+
// card (a cover, a playtest frame) includes it; otherwise it covers the page.
|
|
8
|
+
//
|
|
9
|
+
// Dependency-free DOM, like the solo badge: the kits draw their scenes
|
|
10
|
+
// differently and share no HUD.
|
|
11
|
+
|
|
12
|
+
let element = null;
|
|
13
|
+
|
|
14
|
+
// Show or hide the overlay. Idempotent; cheap enough to call every frame.
|
|
15
|
+
export function showJoinOverlay(visible) {
|
|
16
|
+
if (typeof document === 'undefined') {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (!visible) {
|
|
20
|
+
element?.remove();
|
|
21
|
+
element = null;
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (element) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const card = document.querySelector('#castle-card, [data-castle-card]');
|
|
28
|
+
if (card && getComputedStyle(card).position === 'static') {
|
|
29
|
+
card.style.position = 'relative';
|
|
30
|
+
}
|
|
31
|
+
element = document.createElement('div');
|
|
32
|
+
element.setAttribute('data-castle-join-overlay', '');
|
|
33
|
+
element.style.cssText = [
|
|
34
|
+
card ? 'position:absolute' : 'position:fixed',
|
|
35
|
+
'inset:0',
|
|
36
|
+
'display:flex',
|
|
37
|
+
'align-items:center',
|
|
38
|
+
'justify-content:center',
|
|
39
|
+
'background:#0a0a0a',
|
|
40
|
+
'color:#fff',
|
|
41
|
+
'font:500 15px/1.4 system-ui,sans-serif',
|
|
42
|
+
'z-index:2147483646',
|
|
43
|
+
].join(';');
|
|
44
|
+
element.textContent = 'Joining…';
|
|
45
|
+
(card ?? document.body).appendChild(element);
|
|
46
|
+
}
|
|
@@ -15,6 +15,10 @@ export const MOVE = 'i';
|
|
|
15
15
|
export const WORLD = 'w';
|
|
16
16
|
export const STATE = 's';
|
|
17
17
|
|
|
18
|
+
// A client reports that it removed a scene actor: `{ k: GONE, id }`. The
|
|
19
|
+
// session removes it for everyone, for good.
|
|
20
|
+
export const GONE = 'g';
|
|
21
|
+
|
|
18
22
|
// Movement reports run below the render rate and below the platform's roughly
|
|
19
23
|
// 30-message-per-second client cap. A second report inside one server tick would
|
|
20
24
|
// be overwritten before the simulation read it.
|
|
@@ -21,8 +21,12 @@ const bodyModules = {
|
|
|
21
21
|
// `session.js` receives this value from the dimensional kit's server entry and
|
|
22
22
|
// calls the hooks at the stages listed below.
|
|
23
23
|
//
|
|
24
|
+
// restore(sim, saved) the value the last `save` returned for this
|
|
25
|
+
// room, or null. Runs before `ready`
|
|
24
26
|
// ready(sim) after the world and its scene bodies exist,
|
|
25
27
|
// before the first server tick
|
|
28
|
+
// save(sim) JSON kept for this room, written with the
|
|
29
|
+
// world every ten seconds and at shutdown
|
|
26
30
|
// message(session, player, d) client data that is not a pose report. It
|
|
27
31
|
// receives no handover list, so a world change
|
|
28
32
|
// is kept and applied later by `place` or `step`
|