castle-web-cli 0.4.169 → 0.4.171
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 +70 -24
- package/dist/atomicFile.d.ts +29 -0
- package/dist/atomicFile.js +120 -0
- package/dist/castle-host/host.js +108 -0
- package/dist/get-deck.js +2 -11
- package/dist/headlessCover.d.ts +4 -0
- package/dist/headlessCover.js +7 -5
- package/dist/ide.d.ts +10 -0
- package/dist/ide.js +19 -11
- package/dist/index.js +10 -6
- package/dist/init.d.ts +1 -1
- package/dist/init.js +11 -1
- package/dist/remix.js +2 -14
- package/dist/save-deck.d.ts +3 -0
- package/dist/save-deck.js +40 -73
- package/dist/saveCover.d.ts +18 -0
- package/dist/saveCover.js +61 -0
- package/dist/serve.js +18 -5
- package/dist/serveSecurity.d.ts +11 -1
- package/dist/serveSecurity.js +38 -4
- 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/dist/versionStore.js +13 -4
- package/kits/base/CLAUDE.md +9 -0
- package/kits/base/castle.json +16 -11
- package/kits/base/sdk/README.md +83 -2
- package/kits/base/sdk/commands.d.ts +37 -1
- package/kits/base/sdk/commands.js +9 -0
- package/kits/base/sdk/lifecycle.d.ts +20 -0
- package/kits/base/sdk/lifecycle.js +7 -1
- package/kits/base/sdk/resumeState.d.ts +2 -0
- package/kits/base/sdk/resumeState.js +84 -0
- package/kits/base/sdk/runtime.js +65 -0
- package/kits/base/sdk/transport.d.ts +3 -0
- package/kits/base/sdk/transport.js +39 -5
- package/kits/base/sdk/unloadHandshake.d.ts +6 -0
- package/kits/base/sdk/unloadHandshake.js +47 -0
- package/kits/base/sdk/user.d.ts +4 -0
- package/kits/base/sdk/user.js +36 -1
- package/kits/multiplayer-2d/CLAUDE.md +20 -9
- package/kits/multiplayer-2d/castle.json +4 -4
- 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 +45 -4
- package/kits/multiplayer-2d/package-lock.json +2 -4
- package/kits/multiplayer-2d/package.json +1 -0
- package/kits/multiplayer-3d/CLAUDE.md +28 -9
- package/kits/multiplayer-3d/castle.json +5 -5
- 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 +108 -6
- package/kits/multiplayer-3d/package-lock.json +2 -4
- package/kits/multiplayer-3d/package.json +1 -0
- package/kits/physics-2d/CLAUDE.md +75 -43
- package/kits/physics-2d/behaviors/AnalogStick.jsx +75 -45
- package/kits/physics-2d/behaviors/Slingshot.jsx +1 -2
- package/kits/physics-2d/castle.json +6 -6
- package/kits/physics-2d/editors/PxArtEditor.jsx +85 -182
- package/kits/physics-2d/editors/SceneEditor.jsx +25 -4
- package/kits/physics-2d/editors/StyleEditor.jsx +6 -2
- package/kits/physics-2d/editors/overlayCanvas.js +77 -0
- package/kits/physics-2d/editors/pathOverlay.js +5 -13
- package/kits/physics-2d/editors/pixelEditorChrome.jsx +5 -1
- package/kits/physics-2d/editors/pixelInspector.jsx +4 -4
- package/kits/physics-2d/editors/pxArtTimeline.jsx +8 -36
- package/kits/physics-2d/editors/pxArtTimeline.module.css +4 -28
- package/kits/physics-2d/editors/styleEditor.module.css +23 -4
- package/kits/physics-2d/editors/useArtboardZoomPan.js +322 -0
- package/kits/physics-2d/engine/SceneUI.jsx +6 -12
- package/kits/physics-2d/engine/icons.js +56 -0
- package/kits/physics-2d/engine/panelSorting.jsx +7 -14
- package/kits/physics-2d/engine/physics/PhysicsSystem.js +66 -11
- package/kits/physics-2d/engine/physics/controls.js +76 -12
- package/kits/physics-2d/engine/scene.js +47 -9
- package/kits/physics-2d/engine/ui.jsx +13 -125
- package/kits/physics-2d/engine/ui.module.css +53 -28
- package/kits/physics-2d/package-lock.json +0 -29
- package/kits/physics-2d/package.json +3 -1
- package/kits/physics-2d/pnpm-lock.yaml +1 -17
- package/kits/physics-3d/behaviors/Pickup.jsx +5 -3
- package/kits/physics-3d/castle.json +5 -5
- package/kits/physics-3d/package-lock.json +0 -29
- package/kits/physics-3d/package.json +0 -1
- package/kits/physics-3d/pnpm-lock.yaml +0 -17
- package/kits/real-time/CLAUDE.md +23 -4
- package/kits/real-time/castle.json +1 -1
- package/kits/real-time/code/client/connection.js +9 -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 +289 -19
- package/kits/real-time/package-lock.json +1139 -0
- package/kits/turn-based/CLAUDE.md +76 -20
- package/kits/turn-based/castle.json +3 -3
- package/kits/turn-based/code/server/index.js +16 -5
- package/kits/turn-based/package-lock.json +0 -27
- package/kits/turn-based/package.json +2 -2
- package/kits/turn-based/room.js +162 -13
- package/kits/turn-based/testing.js +276 -92
- package/package.json +5 -5
- package/dist/shell/assets/index-CVHj503j.css +0 -1
- package/dist/shell/assets/index-x_QkP3Xq.js +0 -444
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// - web → window.parent.postMessage; responses via 'message' events
|
|
8
8
|
// - local → the castle-web serve dev server, over runtime.ts's websocket
|
|
9
9
|
// Error reconstruction is uniform here so callers always get a CastleError.
|
|
10
|
-
import { CASTLE_SDK_PROTOCOL, isResponseEnvelope, } from "./commands";
|
|
10
|
+
import { CASTLE_SDK_PROTOCOL, isResponseEnvelope, unloadRequestId, } from "./commands";
|
|
11
11
|
import { getCastleEmbed } from "./context";
|
|
12
12
|
import { CastleError } from "./errors";
|
|
13
13
|
import { sendLocalCommand } from "./runtime";
|
|
@@ -22,6 +22,7 @@ const INTERACTIVE_COMMANDS = new Set([
|
|
|
22
22
|
let nextRequestId = 1;
|
|
23
23
|
const pending = new Map();
|
|
24
24
|
let listenersInstalled = false;
|
|
25
|
+
let unloadHandler = null;
|
|
25
26
|
export async function hostRequest(command, params) {
|
|
26
27
|
try {
|
|
27
28
|
const channel = resolveChannel();
|
|
@@ -67,6 +68,26 @@ export function hostNotify(event) {
|
|
|
67
68
|
};
|
|
68
69
|
sendEnvelope(channel, envelope);
|
|
69
70
|
}
|
|
71
|
+
// Reply to a host unload request. One-way like hostNotify, but it carries the
|
|
72
|
+
// handshake's whole point, so it posts even with no state: the host is holding
|
|
73
|
+
// its teardown open until this arrives or its cap runs out.
|
|
74
|
+
export function postUnloadDone(unloadId, state) {
|
|
75
|
+
if (typeof window === "undefined")
|
|
76
|
+
return;
|
|
77
|
+
const channel = resolveChannel();
|
|
78
|
+
if (channel === "local")
|
|
79
|
+
return;
|
|
80
|
+
const envelope = {
|
|
81
|
+
castleSdk: CASTLE_SDK_PROTOCOL,
|
|
82
|
+
unloadDone: state === undefined ? { unloadId } : { unloadId, state },
|
|
83
|
+
};
|
|
84
|
+
sendEnvelope(channel, envelope);
|
|
85
|
+
}
|
|
86
|
+
// Registered by unloadHandshake.ts rather than imported from it, so this module
|
|
87
|
+
// keeps no dependency on the one that depends on it.
|
|
88
|
+
export function setUnloadHandler(handler) {
|
|
89
|
+
unloadHandler = handler;
|
|
90
|
+
}
|
|
70
91
|
function resolveChannel() {
|
|
71
92
|
if (typeof window === "undefined")
|
|
72
93
|
return "local";
|
|
@@ -82,7 +103,7 @@ function resolveChannel() {
|
|
|
82
103
|
return window.parent && window.parent !== window ? "web" : "local";
|
|
83
104
|
}
|
|
84
105
|
function postCommand(channel, command, params) {
|
|
85
|
-
|
|
106
|
+
installHostListener();
|
|
86
107
|
const requestId = `csdk_${nextRequestId++}`;
|
|
87
108
|
return new Promise((resolve, reject) => {
|
|
88
109
|
const timeout = INTERACTIVE_COMMANDS.has(command)
|
|
@@ -106,15 +127,28 @@ function sendEnvelope(channel, envelope) {
|
|
|
106
127
|
}
|
|
107
128
|
// The mobile host can't dispatch a DOM 'message' event, so it calls this global
|
|
108
129
|
// directly with the parsed envelope. The web host posts a 'message' event.
|
|
109
|
-
|
|
130
|
+
//
|
|
131
|
+
// Exported because a deck that only registers an unload subscriber never posts a
|
|
132
|
+
// command, and without this the global would never exist — the host's inject
|
|
133
|
+
// would land on nothing and its handshake would run out its cap in silence.
|
|
134
|
+
export function installHostListener() {
|
|
110
135
|
if (listenersInstalled || typeof window === "undefined")
|
|
111
136
|
return;
|
|
112
137
|
listenersInstalled = true;
|
|
113
|
-
window.__castleSdkHost = { receive: (message) =>
|
|
138
|
+
window.__castleSdkHost = { receive: (message) => receive(message) };
|
|
114
139
|
window.addEventListener("message", (event) => {
|
|
115
|
-
|
|
140
|
+
receive(event.data);
|
|
116
141
|
});
|
|
117
142
|
}
|
|
143
|
+
function receive(message) {
|
|
144
|
+
if (isResponseEnvelope(message)) {
|
|
145
|
+
settle(message);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const unloadId = unloadRequestId(message);
|
|
149
|
+
if (unloadId !== null)
|
|
150
|
+
unloadHandler?.(unloadId);
|
|
151
|
+
}
|
|
118
152
|
function settle(message) {
|
|
119
153
|
if (!isResponseEnvelope(message))
|
|
120
154
|
return;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// The host-driven half of "this page is about to go away".
|
|
2
|
+
//
|
|
3
|
+
// `unloadFlush.ts` is the browser-event half — pagehide / visibilitychange,
|
|
4
|
+
// which a deck's own document raises. This one is the host's: an RN WebView
|
|
5
|
+
// being torn down on a feed swipe never raises those reliably, so the mobile
|
|
6
|
+
// host sends an explicit unload request first and holds its teardown open,
|
|
7
|
+
// briefly, for the reply.
|
|
8
|
+
//
|
|
9
|
+
// Subscribers run synchronously and the reply goes out in the same tick. That
|
|
10
|
+
// is the contract that makes the host's wait short: it is waiting on one
|
|
11
|
+
// postMessage, not on whatever work a subscriber might want to do. A subscriber
|
|
12
|
+
// with host-bound work of its own must post it BEFORE returning — deck→host
|
|
13
|
+
// messages are FIFO per WebView, so anything posted first arrives first.
|
|
14
|
+
import { hostNotify, installHostListener, postUnloadDone, setUnloadHandler } from "./transport";
|
|
15
|
+
const subscribers = new Set();
|
|
16
|
+
let installed = false;
|
|
17
|
+
let interestSent = false;
|
|
18
|
+
export function onHostUnload(subscriber) {
|
|
19
|
+
subscribers.add(subscriber);
|
|
20
|
+
if (!installed) {
|
|
21
|
+
installed = true;
|
|
22
|
+
installHostListener();
|
|
23
|
+
setUnloadHandler(runHandshake);
|
|
24
|
+
}
|
|
25
|
+
return () => subscribers.delete(subscriber);
|
|
26
|
+
}
|
|
27
|
+
// Tells the host this deck has something to hand over, so it runs the handshake
|
|
28
|
+
// (and pays its bounded wait) for this deck instead of skipping it. Sent once;
|
|
29
|
+
// the host doesn't care which subscriber prompted it.
|
|
30
|
+
export function notifyUnloadInterest() {
|
|
31
|
+
if (interestSent)
|
|
32
|
+
return;
|
|
33
|
+
interestSent = true;
|
|
34
|
+
hostNotify("unloadInterest");
|
|
35
|
+
}
|
|
36
|
+
function runHandshake(unloadId) {
|
|
37
|
+
const ctx = {};
|
|
38
|
+
for (const subscriber of subscribers) {
|
|
39
|
+
try {
|
|
40
|
+
subscriber(ctx);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
console.warn("Castle unload subscriber threw.", error);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
postUnloadDone(unloadId, ctx.state);
|
|
47
|
+
}
|
package/kits/base/sdk/user.d.ts
CHANGED
|
@@ -3,8 +3,12 @@ export interface CastleUser {
|
|
|
3
3
|
username: string;
|
|
4
4
|
isAnonymous: boolean;
|
|
5
5
|
isActive: boolean;
|
|
6
|
+
photoUrl: string | null;
|
|
7
|
+
frameUrl: string | null;
|
|
8
|
+
color: string | null;
|
|
6
9
|
}
|
|
7
10
|
export interface CastleUserApi {
|
|
8
11
|
getCurrent(): Promise<CastleUser>;
|
|
12
|
+
get(userId: string): Promise<CastleUser | null>;
|
|
9
13
|
}
|
|
10
14
|
export declare const User: CastleUserApi;
|
package/kits/base/sdk/user.js
CHANGED
|
@@ -2,8 +2,12 @@ import { CastleError } from "./errors";
|
|
|
2
2
|
import { hostRequest } from "./transport";
|
|
3
3
|
let currentUser = null;
|
|
4
4
|
let currentUserPromise = null;
|
|
5
|
+
// Profiles by user id. A profile changes rarely, and a multiplayer deck asks for
|
|
6
|
+
// the same few ids every time a player joins.
|
|
7
|
+
const profiles = new Map();
|
|
5
8
|
export const User = {
|
|
6
9
|
getCurrent,
|
|
10
|
+
get,
|
|
7
11
|
};
|
|
8
12
|
async function getCurrent() {
|
|
9
13
|
if (currentUser)
|
|
@@ -25,15 +29,46 @@ async function fetchCurrentUser() {
|
|
|
25
29
|
});
|
|
26
30
|
}
|
|
27
31
|
const username = requiredString(user.username, "user.username", operation);
|
|
32
|
+
const userId = requiredString(user.userId, "user.userId", operation);
|
|
33
|
+
// The host answers identity from what it already knows. The profile fields
|
|
34
|
+
// come from the same read `get` does; a host that predates that command
|
|
35
|
+
// leaves them null.
|
|
36
|
+
const profile = await get(userId).catch(() => null);
|
|
28
37
|
return {
|
|
29
|
-
userId
|
|
38
|
+
userId,
|
|
30
39
|
username,
|
|
31
40
|
// A host that predates the flag omits it; anonymous account usernames are
|
|
32
41
|
// always minted as `anonymous-user-<uuid>`, so the prefix is the fallback.
|
|
33
42
|
isAnonymous: user.isAnonymous === true || username.toLowerCase().startsWith("anonymous-user-"),
|
|
34
43
|
isActive: true,
|
|
44
|
+
photoUrl: profile?.photoUrl ?? null,
|
|
45
|
+
frameUrl: profile?.frameUrl ?? null,
|
|
46
|
+
color: profile?.color ?? null,
|
|
35
47
|
};
|
|
36
48
|
}
|
|
49
|
+
// One user by id, or null when no such user exists. Throws `CastleError` on a
|
|
50
|
+
// host that predates the command (`UNKNOWN_COMMAND`).
|
|
51
|
+
async function get(userId) {
|
|
52
|
+
if (typeof userId !== "string" || userId.length === 0) {
|
|
53
|
+
throw new CastleError({
|
|
54
|
+
code: "INVALID_ARGUMENT",
|
|
55
|
+
message: "User.get(userId) needs a user id.",
|
|
56
|
+
operation: "User.get",
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
let pending = profiles.get(userId);
|
|
60
|
+
if (!pending) {
|
|
61
|
+
pending = fetchProfile(userId);
|
|
62
|
+
profiles.set(userId, pending);
|
|
63
|
+
// A failed read is not kept, so the next call asks again.
|
|
64
|
+
pending.catch(() => profiles.delete(userId));
|
|
65
|
+
}
|
|
66
|
+
return pending;
|
|
67
|
+
}
|
|
68
|
+
async function fetchProfile(userId) {
|
|
69
|
+
const { user } = await hostRequest("user.get", { userId });
|
|
70
|
+
return user ? { ...user, isActive: true } : null;
|
|
71
|
+
}
|
|
37
72
|
function requiredString(value, field, operation) {
|
|
38
73
|
if (typeof value === "string" && value.length > 0)
|
|
39
74
|
return value;
|
|
@@ -30,10 +30,19 @@ then `Smoothing.smooth(…)`. File names stay lowercase.
|
|
|
30
30
|
builds a matter world from the deck's own `scenes/main.scene` and steps it at
|
|
31
31
|
`tickRate` (30). Never fork it.
|
|
32
32
|
- **A game is added as hooks, not by replacing anything.** Write
|
|
33
|
-
`code/server/game.js` with a default export of up to six functions --
|
|
33
|
+
`code/server/game.js` with a default export of up to six gameplay functions --
|
|
34
34
|
`ready(sim)`, `message(session, player, data)`, `place(sim, handovers)`,
|
|
35
35
|
`step(sim, handovers)`, `join(sim)`, `delta(sim)` -- and it is found by glob.
|
|
36
36
|
`code/client/game.js` is the browser half. Both optional.
|
|
37
|
+
- **What persists.** Object poses, each player's last pose by account, and
|
|
38
|
+
whatever the optional `save(sim)` hook returns are written every ten seconds
|
|
39
|
+
when something moved and at a clean shutdown. Nothing to configure.
|
|
40
|
+
- **A returning player starts where they left off.** A second connection from
|
|
41
|
+
the same account while the first is still playing gets a new character at the
|
|
42
|
+
spawn. The room is `session.sessionId`; every `public` shard shares one.
|
|
43
|
+
- `restore(sim, saved)` receives the last `save` result before `ready`. Storage
|
|
44
|
+
is keyed by deck, so the deck must be published; `castle-web serve` brokers it
|
|
45
|
+
as the deck's creator.
|
|
37
46
|
- `place` runs BEFORE the physics step; a write that only reaches a body at the
|
|
38
47
|
next step belongs there. `delta` returning news is its own reason to broadcast,
|
|
39
48
|
so a tick where nothing moved but a round ended still reaches players.
|
|
@@ -55,8 +64,9 @@ then `Smoothing.smooth(…)`. File names stay lowercase.
|
|
|
55
64
|
- The kit ships `code/systems/multiplayer.js`, the frame loop. A deck that writes
|
|
56
65
|
its own file of that path gets its own instead; most decks should not.
|
|
57
66
|
- Two players is the test that matters: `castle-web serve`, then the play URL in
|
|
58
|
-
two tabs. The
|
|
59
|
-
|
|
67
|
+
two tabs. The room persists in deck storage across serve restarts; a fresh one
|
|
68
|
+
means removing its `world:<room>`, `game:<room>` and `player:<room>:*` rows
|
|
69
|
+
(the local serve's room is `dev`).
|
|
60
70
|
|
|
61
71
|
## Layout
|
|
62
72
|
|
|
@@ -137,11 +147,13 @@ and then, all optional, all found by root-anchored glob in
|
|
|
137
147
|
| `code/server/game.js` | the game, as six hooks on a default export |
|
|
138
148
|
| `code/server/deckComponents.js` | `export const componentDefaults = { Cargo: {...} }` |
|
|
139
149
|
|
|
140
|
-
The kit draws no HUD, with
|
|
150
|
+
The kit draws no HUD, with two exceptions. When the join fell back to solo,
|
|
141
151
|
`castle.real-time`'s `soloBadge.js` shows a small fixed badge naming why
|
|
142
152
|
(`status().soloReason`: a private deck, no published server, or plain
|
|
143
153
|
unavailable), so a creator testing their own deck is told rather than left to
|
|
144
|
-
guess
|
|
154
|
+
guess; a deck's `soloBadge(status)` client hook hides (`false`) or rewords it.
|
|
155
|
+
While joining, its `joinOverlay.js` covers the card with "Joining…" until the
|
|
156
|
+
session's first snapshot places the player, ten seconds at most.
|
|
145
157
|
|
|
146
158
|
The six server hooks: `ready(sim)`, `message(session, player, data)`,
|
|
147
159
|
`place(sim, handovers)`, `step(sim, handovers)`, `join(sim)`, `delta(sim)`.
|
|
@@ -156,10 +168,9 @@ node ../../cli/dist/index.js serve . --port 5762 --detach
|
|
|
156
168
|
node test/two-client-test.mjs
|
|
157
169
|
```
|
|
158
170
|
|
|
159
|
-
The
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
reads.
|
|
171
|
+
The room's state persists, so crates stay where the last run pushed them. Each
|
|
172
|
+
client publishes what it believes about the room on `globalThis.__multiplayer`,
|
|
173
|
+
which is what the test reads.
|
|
163
174
|
|
|
164
175
|
## Rules for editing this kit
|
|
165
176
|
|
|
@@ -15,15 +15,15 @@
|
|
|
15
15
|
"imports": {
|
|
16
16
|
"castle.real-time": {
|
|
17
17
|
"deckId": "xFVr-afOLuUQ",
|
|
18
|
-
"version": "2026-09-
|
|
18
|
+
"version": "2026-09-10T19:46:34.652Z"
|
|
19
19
|
},
|
|
20
20
|
"castle.physics-2d": {
|
|
21
21
|
"deckId": "ckRZGFW4iPrx",
|
|
22
|
-
"version": "2026-09-
|
|
22
|
+
"version": "2026-09-10T00:57:31.408Z"
|
|
23
23
|
},
|
|
24
24
|
"castle.base": {
|
|
25
25
|
"deckId": "yRcmH4_aYllE",
|
|
26
|
-
"version": "2026-09-
|
|
26
|
+
"version": "2026-09-10T00:56:59.388Z"
|
|
27
27
|
}
|
|
28
28
|
},
|
|
29
29
|
"autoUpdateWhenImported": true,
|
|
@@ -35,5 +35,5 @@
|
|
|
35
35
|
},
|
|
36
36
|
"deckId": "oiFu96LkwZ09",
|
|
37
37
|
"cardId": "nkJgrvrCby3H",
|
|
38
|
-
"publishedVersion": "2026-09-
|
|
38
|
+
"publishedVersion": "2026-09-10T20:10:36.784Z"
|
|
39
39
|
}
|
|
@@ -17,16 +17,21 @@ import { addBody } from './world.js';
|
|
|
17
17
|
// facing radians, as the client sent them
|
|
18
18
|
// clamped true only on the tick a move was refused
|
|
19
19
|
// movedAt the simulation time of that acceptance
|
|
20
|
-
|
|
20
|
+
// settled whether this connection has ever reported a pose the server took
|
|
21
|
+
function newPlayer(entry, at, movedAt) {
|
|
21
22
|
// Spread the body's offset data beside the report-validation state.
|
|
22
|
-
return { ...entry, at, facing: 0, clamped: false, movedAt:
|
|
23
|
+
return { ...entry, at, facing: 0, clamped: false, movedAt, settled: false };
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
// Add a character body for a session player. world.step writes `player.at`
|
|
26
27
|
// before each physics step. The engine represents a kinematic body as a static
|
|
27
28
|
// Matter body moved through explicit position writes.
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
//
|
|
30
|
+
// `pose` is where a returning player left off, as `[x, y, facing]`. Without one
|
|
31
|
+
// the character starts on the spawn ring.
|
|
32
|
+
export function addPlayer(sim, playerId, pose) {
|
|
33
|
+
const resumed = pose ? Limits.readPoint(pose) : null;
|
|
34
|
+
const at = resumed ?? spawnPoint(sim);
|
|
30
35
|
const template = sim.template?.components;
|
|
31
36
|
|
|
32
37
|
// Reuse the authored Player shape and material while forcing the server body
|
|
@@ -40,8 +45,15 @@ export function addPlayer(sim, playerId) {
|
|
|
40
45
|
},
|
|
41
46
|
};
|
|
42
47
|
|
|
43
|
-
// addBody returns the centre offset needed to translate Layout reports.
|
|
44
|
-
|
|
48
|
+
// addBody returns the centre offset needed to translate Layout reports. The
|
|
49
|
+
// movement allowance runs from now: a body that starts where a player left off
|
|
50
|
+
// would otherwise let that player's first report cover half a second of travel
|
|
51
|
+
// back to wherever their own copy of the scene put them.
|
|
52
|
+
const player = newPlayer(addBody(sim, actor), at, sim.timeMs);
|
|
53
|
+
if (resumed) {
|
|
54
|
+
player.facing = number(pose[2]);
|
|
55
|
+
}
|
|
56
|
+
sim.players.set(playerId, player);
|
|
45
57
|
}
|
|
46
58
|
|
|
47
59
|
// Remove a departing player's body and its entry.
|
|
@@ -82,6 +94,15 @@ export function setPose(sim, playerId, message) {
|
|
|
82
94
|
// An accepted move becomes the target written before the next Matter step.
|
|
83
95
|
player.at = asked;
|
|
84
96
|
player.clamped = false;
|
|
97
|
+
player.settled = true;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// The first report moves nothing. A client starts its own character from the
|
|
102
|
+
// scene and does not know yet where the session put this player, so the server
|
|
103
|
+
// position stands until the client has agreed with it once.
|
|
104
|
+
if (!player.settled) {
|
|
105
|
+
player.clamped = true;
|
|
85
106
|
return;
|
|
86
107
|
}
|
|
87
108
|
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
pointsBounds,
|
|
10
10
|
} from '@imports/castle.physics-2d/engine/collider.js';
|
|
11
11
|
import { packPose } from '../client/poses.js';
|
|
12
|
-
import { MAX_STEPS_PER_TICK, STEP } from '@imports/castle.real-time/code/server/limits.js';
|
|
12
|
+
import { MAX_STEPS_PER_TICK, STEP, number } from '@imports/castle.real-time/code/server/limits.js';
|
|
13
13
|
import { readScene } from './sceneBodies.js';
|
|
14
14
|
import * as Limits from './limits.js';
|
|
15
15
|
|
|
@@ -326,6 +326,29 @@ export function objectPoses(sim) {
|
|
|
326
326
|
return out;
|
|
327
327
|
}
|
|
328
328
|
|
|
329
|
+
// Put saved poses back on the bodies they were read from. An id the scene no
|
|
330
|
+
// longer has is skipped. Velocities are cleared: a restored body starts at rest.
|
|
331
|
+
// Returns how many poses were applied.
|
|
332
|
+
export function restorePoses(sim, poses) {
|
|
333
|
+
let applied = 0;
|
|
334
|
+
for (const pose of poses) {
|
|
335
|
+
const entry = sim.objects.get(pose?.[0]);
|
|
336
|
+
const at = entry ? Limits.readPoint(pose.slice(1)) : null;
|
|
337
|
+
if (!at) {
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
Matter.Body.setPosition(entry.body, {
|
|
341
|
+
x: at.x + entry.offset.dx,
|
|
342
|
+
y: at.y + entry.offset.dy,
|
|
343
|
+
});
|
|
344
|
+
Matter.Body.setAngle(entry.body, number(pose[3]));
|
|
345
|
+
Matter.Body.setVelocity(entry.body, { x: 0, y: 0 });
|
|
346
|
+
Matter.Body.setAngularVelocity(entry.body, 0);
|
|
347
|
+
applied += 1;
|
|
348
|
+
}
|
|
349
|
+
return applied;
|
|
350
|
+
}
|
|
351
|
+
|
|
329
352
|
// A body's pose in the terms the client writes onto a Layout box. The offset is
|
|
330
353
|
// not rotated with the body, matching how the engine writes its own bodies back.
|
|
331
354
|
export function layoutOf(entry) {
|
|
@@ -15,7 +15,11 @@ import * as Smoothing from '@imports/castle.real-time/code/client/smooth.js';
|
|
|
15
15
|
import * as Poses from '../client/poses.js';
|
|
16
16
|
import { connectSession } from '@imports/castle.real-time/code/client/connection.js';
|
|
17
17
|
import { showSoloBadge, updateSoloBadge } from '@imports/castle.real-time/code/client/soloBadge.js';
|
|
18
|
+
import { showJoinOverlay } from '@imports/castle.real-time/code/client/joinOverlay.js';
|
|
18
19
|
import { STATE, WORLD } from '@imports/castle.real-time/code/client/messages.js';
|
|
20
|
+
|
|
21
|
+
// How long the joining overlay may hold the card before giving up.
|
|
22
|
+
const JOIN_WAIT_MS = 10000;
|
|
19
23
|
import { game } from '@imports/castle.real-time/code/client/gameHooks.js';
|
|
20
24
|
|
|
21
25
|
// How much of a refused move is taken back per frame, and the distance past
|
|
@@ -51,6 +55,11 @@ class MultiplayerSystem {
|
|
|
51
55
|
constructor() {
|
|
52
56
|
// The transport is created on the first stepped frame.
|
|
53
57
|
this.net = null;
|
|
58
|
+
// False until a server snapshot carries this player's pose. The overlay
|
|
59
|
+
// stays up from the first stepped frame until then, or until JOIN_WAIT_MS
|
|
60
|
+
// after the join began if the session never answers.
|
|
61
|
+
this.joined = false;
|
|
62
|
+
this.joinStartedAt = 0;
|
|
54
63
|
|
|
55
64
|
// `starting` keeps a second join attempt out while the first is pending.
|
|
56
65
|
this.starting = false;
|
|
@@ -83,6 +92,7 @@ class MultiplayerSystem {
|
|
|
83
92
|
// Connecting on the first stepped frame keeps the editor's own runtimes out
|
|
84
93
|
// of the session: it builds them and never steps them.
|
|
85
94
|
if (!this.net) {
|
|
95
|
+
showJoinOverlay(this.joiningOverlayWanted());
|
|
86
96
|
return void this.start();
|
|
87
97
|
}
|
|
88
98
|
|
|
@@ -105,7 +115,9 @@ class MultiplayerSystem {
|
|
|
105
115
|
this.acceptClamp(scene, local);
|
|
106
116
|
|
|
107
117
|
game.beforeReport?.(scene, local, this);
|
|
108
|
-
if (
|
|
118
|
+
if (!this.joined) {
|
|
119
|
+
// Nothing reported until the session has placed this player.
|
|
120
|
+
} else if (game.report) {
|
|
109
121
|
game.report(scene, local, this);
|
|
110
122
|
} else {
|
|
111
123
|
this.report(scene, local);
|
|
@@ -113,6 +125,7 @@ class MultiplayerSystem {
|
|
|
113
125
|
game.afterFrame?.(scene, dt, this);
|
|
114
126
|
this.publish(scene, local);
|
|
115
127
|
updateSoloBadge(this.net.status(), game);
|
|
128
|
+
showJoinOverlay(this.joiningOverlayWanted());
|
|
116
129
|
}
|
|
117
130
|
|
|
118
131
|
// Join the public session once. Scene resets retain this transport.
|
|
@@ -121,6 +134,7 @@ class MultiplayerSystem {
|
|
|
121
134
|
return;
|
|
122
135
|
}
|
|
123
136
|
this.starting = true;
|
|
137
|
+
this.joinStartedAt ||= performance.now();
|
|
124
138
|
try {
|
|
125
139
|
this.net = await connectSession({ onLog: (text) => console.log(`[multiplayer] ${text}`) });
|
|
126
140
|
} catch (err) {
|
|
@@ -141,6 +155,10 @@ class MultiplayerSystem {
|
|
|
141
155
|
|
|
142
156
|
// Pair server simulation time with page arrival time before filing samples.
|
|
143
157
|
Clock.noteArrival(this.clock, msg.t, msg.localMs);
|
|
158
|
+
if (!this.joined && (msg.k === WORLD || (msg.ps ?? []).some((pose) => pose[0] === selfId))) {
|
|
159
|
+
this.joined = true;
|
|
160
|
+
console.log('[multiplayer] placed by the session');
|
|
161
|
+
}
|
|
144
162
|
|
|
145
163
|
// Apply ownership before poses so a newly granted object stops consuming
|
|
146
164
|
// network poses on the first frame of local simulation.
|
|
@@ -148,9 +166,11 @@ class MultiplayerSystem {
|
|
|
148
166
|
for (const [id, x, y, facing, clamped] of msg.ps ?? []) {
|
|
149
167
|
if (id !== selfId) {
|
|
150
168
|
sample(this.players, msg, id, [x, y, facing]);
|
|
151
|
-
} else if (clamped) {
|
|
152
|
-
// Store this client's returned pose
|
|
153
|
-
//
|
|
169
|
+
} else if (clamped || msg.k === WORLD) {
|
|
170
|
+
// Store this client's returned pose when the server marks the report
|
|
171
|
+
// clamped, and on the WORLD snapshot that starts a session: that one
|
|
172
|
+
// carries where this player left off. Accepted movement remains
|
|
173
|
+
// client-simulated.
|
|
154
174
|
this.clampedTo = { x, y };
|
|
155
175
|
}
|
|
156
176
|
}
|
|
@@ -374,12 +394,33 @@ class MultiplayerSystem {
|
|
|
374
394
|
this.clampedTo = null;
|
|
375
395
|
}
|
|
376
396
|
|
|
397
|
+
// Whether the overlay should be up: joining or connected, not solo, and not
|
|
398
|
+
// yet placed. A session that never answers releases it after JOIN_WAIT_MS so
|
|
399
|
+
// the deck is not stuck behind it.
|
|
400
|
+
joiningOverlayWanted() {
|
|
401
|
+
if (this.joined || this.net?.status().solo) {
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
if (this.joinStartedAt && performance.now() - this.joinStartedAt > JOIN_WAIT_MS) {
|
|
405
|
+
if (!this.joinWarned) {
|
|
406
|
+
this.joinWarned = true;
|
|
407
|
+
console.warn('[multiplayer] no snapshot from the session yet; playing unplaced');
|
|
408
|
+
}
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Release scene resources and leave the session when the engine runtime ends.
|
|
377
415
|
dispose(scene) {
|
|
378
416
|
// Runtime disposal also closes transport resources retained across resets.
|
|
379
417
|
game.dispose?.(scene, this);
|
|
380
418
|
this.reset(scene);
|
|
381
419
|
this.net?.close();
|
|
382
420
|
this.net = null;
|
|
421
|
+
this.joined = false;
|
|
422
|
+
this.joinStartedAt = 0;
|
|
423
|
+
showJoinOverlay(false);
|
|
383
424
|
// The badge is a fixed element outside the scene, so a disposed runtime
|
|
384
425
|
// (an editor preview closing, say) has to take it down explicitly.
|
|
385
426
|
showSoloBadge(null);
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"@fortawesome/free-solid-svg-icons": "^5.15.4",
|
|
19
19
|
"@lezer/highlight": "^1.2.3",
|
|
20
20
|
"castle-web-fonts": "^1.0.0",
|
|
21
|
+
"castle-web-sdk": "file:../../sdk",
|
|
21
22
|
"codemirror": "^6.0.2",
|
|
22
23
|
"matter-js": "^0.20.0",
|
|
23
24
|
"react": "^19.2.4",
|
|
@@ -29,7 +30,7 @@
|
|
|
29
30
|
},
|
|
30
31
|
"../../sdk": {
|
|
31
32
|
"name": "castle-web-sdk",
|
|
32
|
-
"version": "0.4.
|
|
33
|
+
"version": "0.4.27",
|
|
33
34
|
"dev": true,
|
|
34
35
|
"devDependencies": {
|
|
35
36
|
"eslint": "^9.0.0",
|
|
@@ -151,7 +152,6 @@
|
|
|
151
152
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
|
152
153
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
|
153
154
|
"license": "MIT",
|
|
154
|
-
"peer": true,
|
|
155
155
|
"dependencies": {
|
|
156
156
|
"@dnd-kit/accessibility": "^3.1.1",
|
|
157
157
|
"@dnd-kit/utilities": "^3.2.2",
|
|
@@ -308,7 +308,6 @@
|
|
|
308
308
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
|
309
309
|
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
|
|
310
310
|
"license": "MIT",
|
|
311
|
-
"peer": true,
|
|
312
311
|
"engines": {
|
|
313
312
|
"node": ">=0.10.0"
|
|
314
313
|
}
|
|
@@ -318,7 +317,6 @@
|
|
|
318
317
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
|
|
319
318
|
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
|
|
320
319
|
"license": "MIT",
|
|
321
|
-
"peer": true,
|
|
322
320
|
"dependencies": {
|
|
323
321
|
"scheduler": "^0.27.0"
|
|
324
322
|
},
|
|
@@ -51,10 +51,27 @@ 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
|
+
- **A returning player starts where they left off.** A second connection from
|
|
70
|
+
the same account while the first is still playing gets a new character at the
|
|
71
|
+
spawn. The room is `session.sessionId`; every `public` shard shares one.
|
|
72
|
+
- `restore(sim, saved)` receives the last `save` result before `ready`. Storage
|
|
73
|
+
is keyed by deck, so the deck must be published; `castle-web serve` brokers it
|
|
74
|
+
as the deck's creator.
|
|
58
75
|
- `place` runs BEFORE the physics step, which is where a write through a deferred
|
|
59
76
|
channel (`player.at`, `setNextKinematicTranslation`) has to happen to reach the
|
|
60
77
|
body this tick. `message` is dispatched before the world exists, so a lobby
|
|
@@ -78,8 +95,9 @@ File names stay lowercase.
|
|
|
78
95
|
- The kit ships `code/systems/multiplayer.js`, the frame loop. A deck that writes
|
|
79
96
|
its own file of that path gets its own instead; most decks should not.
|
|
80
97
|
- Two players is the test that matters: `castle-web serve`, then the play URL in
|
|
81
|
-
two tabs. The
|
|
82
|
-
|
|
98
|
+
two tabs. The room persists in deck storage across serve restarts; a fresh one
|
|
99
|
+
means removing its `world:<room>`, `game:<room>` and `player:<room>:*` rows
|
|
100
|
+
(the local serve's room is `dev`).
|
|
83
101
|
|
|
84
102
|
## Layout
|
|
85
103
|
|
|
@@ -238,12 +256,13 @@ Map())` — and the next `showPlayer` respawns it.
|
|
|
238
256
|
- **The player blueprint itself.** The kit spawns a scene from the deck's own
|
|
239
257
|
root; the deck ships it. `blueprints/other-player.scene` is only the name it
|
|
240
258
|
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
|
-
`
|
|
259
|
+
- **Any HUD.** Five decks wrote five HUDs with no shared lines. The kit draws
|
|
260
|
+
two on-screen elements, both `castle.real-time`'s: the solo badge
|
|
261
|
+
(`soloBadge.js`) names why a join fell back to solo (`status().soloReason`),
|
|
262
|
+
so a creator testing a private or unpublished deck is told rather than left
|
|
263
|
+
to guess -- a deck's `soloBadge(status)` client hook hides or rewords it; and
|
|
264
|
+
the join overlay (`joinOverlay.js`) covers the card with "Joining…" until the
|
|
265
|
+
session's first snapshot places the player, ten seconds at most.
|
|
247
266
|
- **Gameplay verbs** — carry, vehicle, round, lobby. Measured across six decks
|
|
248
267
|
and none of them survived contact; see the audit.
|
|
249
268
|
|