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
package/dist/shell/index.html
CHANGED
|
@@ -3,15 +3,15 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
-
<title>Castle
|
|
6
|
+
<title>Untitled deck - Castle</title>
|
|
7
7
|
<!-- The same favicon castle.xyz uses (copied into `public/`, so vite emits
|
|
8
8
|
them next to the bundle and the published package ships them). Vite
|
|
9
9
|
rewrites these onto the build `base`, i.e. `/__castle/ide/...`. -->
|
|
10
10
|
<link rel="icon" type="image/png" sizes="32x32" href="/__castle/ide/favicon-32x32.png" />
|
|
11
11
|
<link rel="icon" type="image/png" sizes="16x16" href="/__castle/ide/favicon-16x16.png" />
|
|
12
12
|
<link rel="icon" href="/__castle/ide/favicon.ico" sizes="any" />
|
|
13
|
-
<script type="module" crossorigin src="/__castle/ide/assets/index-
|
|
14
|
-
<link rel="stylesheet" crossorigin href="/__castle/ide/assets/index-
|
|
13
|
+
<script type="module" crossorigin src="/__castle/ide/assets/index-Ws0WrCbi.js"></script>
|
|
14
|
+
<link rel="stylesheet" crossorigin href="/__castle/ide/assets/index-6odVZQSZ.css">
|
|
15
15
|
</head>
|
|
16
16
|
<body>
|
|
17
17
|
<div id="root"></div>
|
package/kits/base/castle.json
CHANGED
package/kits/base/sdk/README.md
CHANGED
|
@@ -416,7 +416,10 @@ const dailyPuzzle = (date.daysSinceCastleEpoch % 30) + 1;
|
|
|
416
416
|
Returns the signed-in player. Throws `CastleError`
|
|
417
417
|
(`LOGIN_REQUIRED`) when nobody is signed in.
|
|
418
418
|
|
|
419
|
-
|
|
419
|
+
A `CastleUser` has `userId`, `username`, `isAnonymous`, `isActive`, `photoUrl`
|
|
420
|
+
(their 256px avatar), `frameUrl` (the 96px frame drawn around it) and `color`
|
|
421
|
+
(the hex their username is shown in); the last three are `null` when unset. The
|
|
422
|
+
urls are `data:` urls, so they go straight into an `<img>` or a texture.
|
|
420
423
|
Anonymous accounts are real logins with generated `anonymous-user-...` names;
|
|
421
424
|
check `isAnonymous` before accepting writes into anything other players see,
|
|
422
425
|
such as a shared gallery.
|
|
@@ -426,6 +429,19 @@ const me = await User.getCurrent();
|
|
|
426
429
|
greet(me.username);
|
|
427
430
|
```
|
|
428
431
|
|
|
432
|
+
### `User.get(userId): Promise<CastleUser | null>`
|
|
433
|
+
|
|
434
|
+
Another player as anyone may see them, or `null` when no such user exists.
|
|
435
|
+
Results are cached per id for the life of the page. The ids come from the
|
|
436
|
+
session's player list or from `Store.user(id)` rows.
|
|
437
|
+
|
|
438
|
+
```js
|
|
439
|
+
const them = await User.get(player.userId);
|
|
440
|
+
if (them?.photoUrl) {
|
|
441
|
+
avatar.src = them.photoUrl;
|
|
442
|
+
}
|
|
443
|
+
```
|
|
444
|
+
|
|
429
445
|
## Pass
|
|
430
446
|
|
|
431
447
|
A pass is something a creator sells to players for Castle bricks (the
|
|
@@ -120,6 +120,9 @@ export interface CommandParams {
|
|
|
120
120
|
score?: number | null;
|
|
121
121
|
};
|
|
122
122
|
"user.getCurrent": Record<string, never>;
|
|
123
|
+
"user.get": {
|
|
124
|
+
userId: string;
|
|
125
|
+
};
|
|
123
126
|
"time.getServerTime": Record<string, never>;
|
|
124
127
|
"pass.has": {
|
|
125
128
|
passId: string;
|
|
@@ -214,6 +217,9 @@ export interface CommandResult {
|
|
|
214
217
|
isAnonymous?: boolean;
|
|
215
218
|
} | null;
|
|
216
219
|
};
|
|
220
|
+
"user.get": {
|
|
221
|
+
user: UserPayload | null;
|
|
222
|
+
};
|
|
217
223
|
"time.getServerTime": {
|
|
218
224
|
timestamp: number;
|
|
219
225
|
timezoneOffset: number;
|
|
@@ -254,6 +260,14 @@ export interface CommandResult {
|
|
|
254
260
|
};
|
|
255
261
|
"lifecycle.restoreState": LifecycleRestoreStateResult;
|
|
256
262
|
}
|
|
263
|
+
export interface UserPayload {
|
|
264
|
+
userId: string;
|
|
265
|
+
username: string;
|
|
266
|
+
isAnonymous: boolean;
|
|
267
|
+
photoUrl: string | null;
|
|
268
|
+
frameUrl: string | null;
|
|
269
|
+
color: string | null;
|
|
270
|
+
}
|
|
257
271
|
export type CommandName = keyof CommandParams;
|
|
258
272
|
export interface SerializedCommandError {
|
|
259
273
|
code: string;
|
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,23 @@ 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
|
+
- **Players wear their Castle avatar.** A player's `Box` draws their profile
|
|
41
|
+
photo in the circle, turned to their facing (`castle.physics-2d`'s
|
|
42
|
+
`engine/avatarArt.js`, through `User.get`). A player with no photo keeps the
|
|
43
|
+
box's color and facing dot.
|
|
44
|
+
- **A returning player starts where they left off.** A second connection from
|
|
45
|
+
the same account while the first is still playing gets a new character at the
|
|
46
|
+
spawn. The room is `session.sessionId`; every `public` shard shares one.
|
|
47
|
+
- `restore(sim, saved)` receives the last `save` result before `ready`. Storage
|
|
48
|
+
is keyed by deck, so the deck must be published; `castle-web serve` brokers it
|
|
49
|
+
as the deck's creator.
|
|
37
50
|
- `place` runs BEFORE the physics step; a write that only reaches a body at the
|
|
38
51
|
next step belongs there. `delta` returning news is its own reason to broadcast,
|
|
39
52
|
so a tick where nothing moved but a round ended still reaches players.
|
|
@@ -55,8 +68,9 @@ then `Smoothing.smooth(…)`. File names stay lowercase.
|
|
|
55
68
|
- The kit ships `code/systems/multiplayer.js`, the frame loop. A deck that writes
|
|
56
69
|
its own file of that path gets its own instead; most decks should not.
|
|
57
70
|
- Two players is the test that matters: `castle-web serve`, then the play URL in
|
|
58
|
-
two tabs. The
|
|
59
|
-
|
|
71
|
+
two tabs. The room persists in deck storage across serve restarts; a fresh one
|
|
72
|
+
means removing its `world:<room>`, `game:<room>` and `player:<room>:*` rows
|
|
73
|
+
(the local serve's room is `dev`).
|
|
60
74
|
|
|
61
75
|
## Layout
|
|
62
76
|
|
|
@@ -137,11 +151,13 @@ and then, all optional, all found by root-anchored glob in
|
|
|
137
151
|
| `code/server/game.js` | the game, as six hooks on a default export |
|
|
138
152
|
| `code/server/deckComponents.js` | `export const componentDefaults = { Cargo: {...} }` |
|
|
139
153
|
|
|
140
|
-
The kit draws no HUD, with
|
|
154
|
+
The kit draws no HUD, with two exceptions. When the join fell back to solo,
|
|
141
155
|
`castle.real-time`'s `soloBadge.js` shows a small fixed badge naming why
|
|
142
156
|
(`status().soloReason`: a private deck, no published server, or plain
|
|
143
157
|
unavailable), so a creator testing their own deck is told rather than left to
|
|
144
|
-
guess
|
|
158
|
+
guess; a deck's `soloBadge(status)` client hook hides (`false`) or rewords it.
|
|
159
|
+
While joining, its `joinOverlay.js` covers the card with "Joining…" until the
|
|
160
|
+
session's first snapshot places the player, ten seconds at most.
|
|
145
161
|
|
|
146
162
|
The six server hooks: `ready(sim)`, `message(session, player, data)`,
|
|
147
163
|
`place(sim, handovers)`, `step(sim, handovers)`, `join(sim)`, `delta(sim)`.
|
|
@@ -156,10 +172,9 @@ node ../../cli/dist/index.js serve . --port 5762 --detach
|
|
|
156
172
|
node test/two-client-test.mjs
|
|
157
173
|
```
|
|
158
174
|
|
|
159
|
-
The
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
reads.
|
|
175
|
+
The room's state persists, so crates stay where the last run pushed them. Each
|
|
176
|
+
client publishes what it believes about the room on `globalThis.__multiplayer`,
|
|
177
|
+
which is what the test reads.
|
|
163
178
|
|
|
164
179
|
## Rules for editing this kit
|
|
165
180
|
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// The Box behavior draws an actor as a filled rectangle with an optional facing
|
|
2
|
-
// marker and label.
|
|
3
|
-
// the engine
|
|
2
|
+
// marker and label. A player's actor carries their avatar on
|
|
3
|
+
// `actor.runtime.avatar` (a canvas from the engine's `avatarArt.js`), and then
|
|
4
|
+
// the box is that picture in a circle, turned to the facing. The class declares
|
|
5
|
+
// editable properties first, then provides the engine drawing methods and local
|
|
6
|
+
// canvas helpers.
|
|
4
7
|
|
|
5
8
|
export class Box {
|
|
6
9
|
// The engine registers behavior classes by this stable scene component name.
|
|
@@ -36,15 +39,19 @@ export class Box {
|
|
|
36
39
|
draw(actor, scene, ctx) {
|
|
37
40
|
const { x, y, width, height } = actor.components.Layout;
|
|
38
41
|
ctx.save();
|
|
39
|
-
ctx.fillStyle = this.props.color;
|
|
40
42
|
ctx.strokeStyle = this.props.edge;
|
|
41
43
|
ctx.lineWidth = 2;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
const avatar = actor.runtime?.avatar;
|
|
45
|
+
if (avatar) {
|
|
46
|
+
this.drawAvatar(ctx, avatar, x + width / 2, y + height / 2, Math.min(width, height) / 2);
|
|
47
|
+
} else {
|
|
48
|
+
ctx.fillStyle = this.props.color;
|
|
49
|
+
roundRect(ctx, x, y, width, height, this.props.radius);
|
|
50
|
+
ctx.fill();
|
|
51
|
+
ctx.stroke();
|
|
52
|
+
if (this.props.showFacing) {
|
|
53
|
+
this.drawFacing(ctx, x + width / 2, y + height / 2, Math.min(width, height) / 2);
|
|
54
|
+
}
|
|
48
55
|
}
|
|
49
56
|
if (this.props.label) {
|
|
50
57
|
this.drawLabel(ctx, scene, x + width / 2, y);
|
|
@@ -52,6 +59,22 @@ export class Box {
|
|
|
52
59
|
ctx.restore();
|
|
53
60
|
}
|
|
54
61
|
|
|
62
|
+
// Draw the avatar in a circle, rotated to the facing so the picture itself
|
|
63
|
+
// shows which way the player is moving.
|
|
64
|
+
drawAvatar(ctx, avatar, cx, cy, radius) {
|
|
65
|
+
ctx.save();
|
|
66
|
+
ctx.translate(cx, cy);
|
|
67
|
+
ctx.rotate(this.props.facing ?? 0);
|
|
68
|
+
ctx.beginPath();
|
|
69
|
+
ctx.arc(0, 0, radius, 0, Math.PI * 2);
|
|
70
|
+
ctx.clip();
|
|
71
|
+
ctx.drawImage(avatar, -radius, -radius, radius * 2, radius * 2);
|
|
72
|
+
ctx.restore();
|
|
73
|
+
ctx.beginPath();
|
|
74
|
+
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
|
75
|
+
ctx.stroke();
|
|
76
|
+
}
|
|
77
|
+
|
|
55
78
|
// Draw a marker toward the edge in the movement direction. The square body
|
|
56
79
|
// has no other directional feature.
|
|
57
80
|
drawFacing(ctx, cx, cy, reach) {
|
|
@@ -15,11 +15,11 @@
|
|
|
15
15
|
"imports": {
|
|
16
16
|
"castle.real-time": {
|
|
17
17
|
"deckId": "xFVr-afOLuUQ",
|
|
18
|
-
"version": "2026-09-
|
|
18
|
+
"version": "2026-09-10T20:27:27.032Z"
|
|
19
19
|
},
|
|
20
20
|
"castle.physics-2d": {
|
|
21
21
|
"deckId": "ckRZGFW4iPrx",
|
|
22
|
-
"version": "2026-09-
|
|
22
|
+
"version": "2026-09-10T20:21:12.665Z"
|
|
23
23
|
},
|
|
24
24
|
"castle.base": {
|
|
25
25
|
"deckId": "yRcmH4_aYllE",
|
|
@@ -35,5 +35,5 @@
|
|
|
35
35
|
},
|
|
36
36
|
"deckId": "oiFu96LkwZ09",
|
|
37
37
|
"cardId": "nkJgrvrCby3H",
|
|
38
|
-
"publishedVersion": "2026-09-
|
|
38
|
+
"publishedVersion": "2026-09-10T20:28:48.461Z"
|
|
39
39
|
}
|
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
// player behind them.
|
|
5
5
|
|
|
6
6
|
import { applyCharacterPose } from './poses.js';
|
|
7
|
+
import { avatarCanvas } from '@imports/castle.physics-2d/engine/avatarArt.js';
|
|
8
|
+
|
|
9
|
+
export { avatarCanvas };
|
|
7
10
|
|
|
8
11
|
// The blueprint spawned when the caller names none.
|
|
9
12
|
const BLUEPRINT = 'blueprints/other-player.scene';
|
|
@@ -29,7 +32,8 @@ export function makeAvatars() {
|
|
|
29
32
|
|
|
30
33
|
// Create or update one other player and return its actor. The multiplayer
|
|
31
34
|
// system calls this once per other player per frame with the smoothed network
|
|
32
|
-
// pose. A `username` of null draws no label.
|
|
35
|
+
// pose. A `username` of null draws no label. Options are `blueprint`, the scene
|
|
36
|
+
// to spawn from, and `userId`, whose avatar the Box draws.
|
|
33
37
|
export function showPlayer(avatars, scene, playerId, username, pose, options = {}) {
|
|
34
38
|
const blueprint = options.blueprint ?? defaultBlueprint(scene);
|
|
35
39
|
const actor = ensureActor(avatars, scene, playerId, pose, blueprint);
|
|
@@ -40,6 +44,7 @@ export function showPlayer(avatars, scene, playerId, username, pose, options = {
|
|
|
40
44
|
// A blueprint may omit Box; such an avatar still receives its Layout pose.
|
|
41
45
|
actor.components.Box.label = username === null ? '' : shortName(username);
|
|
42
46
|
}
|
|
47
|
+
actor.runtime.avatar = avatarCanvas(options.userId);
|
|
43
48
|
return actor;
|
|
44
49
|
}
|
|
45
50
|
|
|
@@ -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
|
}
|
|
@@ -212,6 +232,7 @@ class MultiplayerSystem {
|
|
|
212
232
|
}
|
|
213
233
|
Avatars.showPlayer(this.avatars, scene, playerId, look.username ?? null, pose, {
|
|
214
234
|
blueprint: look.blueprint,
|
|
235
|
+
userId: this.net.userId(playerId),
|
|
215
236
|
});
|
|
216
237
|
}
|
|
217
238
|
|
|
@@ -301,6 +322,7 @@ class MultiplayerSystem {
|
|
|
301
322
|
if (local.components.Box) {
|
|
302
323
|
local.components.Box.label = Avatars.shortName(this.net.status().you);
|
|
303
324
|
}
|
|
325
|
+
local.runtime.avatar = Avatars.avatarCanvas(this.net.userId(this.net.selfId()));
|
|
304
326
|
}
|
|
305
327
|
|
|
306
328
|
// `[id, centre]` for every scene object the server has sent a pose for. The
|
|
@@ -374,12 +396,33 @@ class MultiplayerSystem {
|
|
|
374
396
|
this.clampedTo = null;
|
|
375
397
|
}
|
|
376
398
|
|
|
399
|
+
// Whether the overlay should be up: joining or connected, not solo, and not
|
|
400
|
+
// yet placed. A session that never answers releases it after JOIN_WAIT_MS so
|
|
401
|
+
// the deck is not stuck behind it.
|
|
402
|
+
joiningOverlayWanted() {
|
|
403
|
+
if (this.joined || this.net?.status().solo) {
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
if (this.joinStartedAt && performance.now() - this.joinStartedAt > JOIN_WAIT_MS) {
|
|
407
|
+
if (!this.joinWarned) {
|
|
408
|
+
this.joinWarned = true;
|
|
409
|
+
console.warn('[multiplayer] no snapshot from the session yet; playing unplaced');
|
|
410
|
+
}
|
|
411
|
+
return false;
|
|
412
|
+
}
|
|
413
|
+
return true;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Release scene resources and leave the session when the engine runtime ends.
|
|
377
417
|
dispose(scene) {
|
|
378
418
|
// Runtime disposal also closes transport resources retained across resets.
|
|
379
419
|
game.dispose?.(scene, this);
|
|
380
420
|
this.reset(scene);
|
|
381
421
|
this.net?.close();
|
|
382
422
|
this.net = null;
|
|
423
|
+
this.joined = false;
|
|
424
|
+
this.joinStartedAt = 0;
|
|
425
|
+
showJoinOverlay(false);
|
|
383
426
|
// The badge is a fixed element outside the scene, so a disposed runtime
|
|
384
427
|
// (an editor preview closing, say) has to take it down explicitly.
|
|
385
428
|
showSoloBadge(null);
|
|
@@ -15,8 +15,10 @@
|
|
|
15
15
|
"@dnd-kit/modifiers": "^9.0.0",
|
|
16
16
|
"@dnd-kit/sortable": "^10.0.0",
|
|
17
17
|
"@dnd-kit/utilities": "^3.2.2",
|
|
18
|
+
"@fortawesome/free-solid-svg-icons": "^5.15.4",
|
|
18
19
|
"@lezer/highlight": "^1.2.3",
|
|
19
20
|
"castle-web-fonts": "^1.0.0",
|
|
21
|
+
"castle-web-sdk": "file:../../sdk",
|
|
20
22
|
"codemirror": "^6.0.2",
|
|
21
23
|
"matter-js": "^0.20.0",
|
|
22
24
|
"react": "^19.2.4",
|
|
@@ -28,7 +30,7 @@
|
|
|
28
30
|
},
|
|
29
31
|
"../../sdk": {
|
|
30
32
|
"name": "castle-web-sdk",
|
|
31
|
-
"version": "0.4.
|
|
33
|
+
"version": "0.4.27",
|
|
32
34
|
"dev": true,
|
|
33
35
|
"devDependencies": {
|
|
34
36
|
"eslint": "^9.0.0",
|
|
@@ -200,6 +202,29 @@
|
|
|
200
202
|
"react": ">=16.8.0"
|
|
201
203
|
}
|
|
202
204
|
},
|
|
205
|
+
"node_modules/@fortawesome/fontawesome-common-types": {
|
|
206
|
+
"version": "0.2.36",
|
|
207
|
+
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.36.tgz",
|
|
208
|
+
"integrity": "sha512-a/7BiSgobHAgBWeN7N0w+lAhInrGxksn13uK7231n2m8EDPE3BMCl9NZLTGrj9ZXfCmC6LM0QLqXidIizVQ6yg==",
|
|
209
|
+
"hasInstallScript": true,
|
|
210
|
+
"license": "MIT",
|
|
211
|
+
"engines": {
|
|
212
|
+
"node": ">=6"
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
"node_modules/@fortawesome/free-solid-svg-icons": {
|
|
216
|
+
"version": "5.15.4",
|
|
217
|
+
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.15.4.tgz",
|
|
218
|
+
"integrity": "sha512-JLmQfz6tdtwxoihXLg6lT78BorrFyCf59SAwBM6qV/0zXyVeDygJVb3fk+j5Qat+Yvcxp1buLTY5iDh1ZSAQ8w==",
|
|
219
|
+
"hasInstallScript": true,
|
|
220
|
+
"license": "(CC-BY-4.0 AND MIT)",
|
|
221
|
+
"dependencies": {
|
|
222
|
+
"@fortawesome/fontawesome-common-types": "^0.2.36"
|
|
223
|
+
},
|
|
224
|
+
"engines": {
|
|
225
|
+
"node": ">=6"
|
|
226
|
+
}
|
|
227
|
+
},
|
|
203
228
|
"node_modules/@lezer/common": {
|
|
204
229
|
"version": "1.5.2",
|
|
205
230
|
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
|
|
@@ -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",
|