castle-web-cli 0.4.171 → 0.4.173
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/assetCompression.d.ts +3 -0
- package/dist/assetCompression.js +30 -0
- package/dist/castleJson.d.ts +1 -1
- package/dist/castleJson.js +2 -2
- package/dist/devSessionServer.js +9 -5
- package/dist/imports.js +2 -2
- package/dist/init.js +4 -4
- package/dist/serve.js +2 -0
- package/dist/serveSecurity.d.ts +2 -0
- package/dist/serveSecurity.js +17 -3
- package/kits/multiplayer-2d/CLAUDE.md +4 -0
- 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/sceneBodies.js +1 -1
- package/kits/multiplayer-2d/code/server/world.js +2 -2
- package/kits/multiplayer-2d/code/systems/multiplayer.js +14 -0
- package/kits/multiplayer-2d/fonts.generated.js +1 -1
- package/kits/multiplayer-2d/package-lock.json +1 -1
- package/kits/multiplayer-3d/CLAUDE.md +5 -0
- package/kits/multiplayer-3d/castle.json +3 -3
- package/kits/multiplayer-3d/code/client/avatars.js +42 -9
- package/kits/multiplayer-3d/code/server/sceneBodies.js +1 -1
- package/kits/multiplayer-3d/code/systems/multiplayer.js +17 -1
- package/kits/multiplayer-3d/fonts.generated.js +1 -1
- package/kits/multiplayer-3d/package-lock.json +1 -1
- package/kits/physics-2d/CLAUDE.md +7 -6
- package/kits/physics-2d/behaviors/Collider.jsx +7 -1
- package/kits/physics-2d/behaviors/RigidBody.jsx +6 -3
- package/kits/physics-2d/castle.json +2 -2
- package/kits/physics-2d/editors/SceneEditor.jsx +1 -0
- package/kits/physics-2d/editors/SingleEditor.jsx +4 -4
- package/kits/physics-2d/editors/{StyleEditor.jsx → ThemeEditor.jsx} +8 -8
- package/kits/physics-2d/editors/deckFont.js +2 -2
- package/kits/physics-2d/editors/{styleEditor.module.css → themeEditor.module.css} +2 -2
- package/kits/physics-2d/engine/avatarArt.js +185 -0
- package/kits/physics-2d/engine/files.js +2 -2
- package/kits/physics-2d/engine/physics/matterBridge.js +2 -2
- package/kits/physics-2d/engine/scene.js +1 -1
- package/kits/physics-2d/fonts.generated.js +1 -1
- package/kits/physics-2d/package-lock.json +1 -1
- package/kits/physics-3d/behaviors/Body.jsx +10 -1
- package/kits/physics-3d/behaviors/Door.jsx +5 -0
- package/kits/physics-3d/behaviors/Lookable.jsx +3 -0
- package/kits/physics-3d/behaviors/Model.jsx +4 -0
- package/kits/physics-3d/behaviors/Pickup.jsx +4 -0
- package/kits/physics-3d/behaviors/Player.jsx +7 -0
- package/kits/physics-3d/behaviors/Shape.jsx +13 -0
- package/kits/physics-3d/castle.json +1 -1
- package/kits/physics-3d/engine3d/editor3dInspector.jsx +1 -0
- package/kits/physics-3d/fonts.generated.js +1 -1
- package/kits/physics-3d/package-lock.json +1 -1
- package/kits/real-time/castle.json +1 -1
- package/kits/real-time/code/client/connection.js +17 -2
- package/kits/real-time/code/server/session.js +12 -6
- package/kits/real-time/package-lock.json +1 -1
- package/kits/turn-based/CLAUDE.md +4 -0
- 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 +1 -0
- package/kits/turn-based/fonts.generated.js +1 -1
- package/kits/turn-based/package-lock.json +1 -1
- package/kits/turn-based/room.js +2 -0
- package/package.json +3 -1
- /package/kits/physics-2d/editors/{styleTheme.js → theme.js} +0 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import compression from 'compression';
|
|
2
|
+
/** Compress assets, never the token-bearing shell bootstrap or control API. */
|
|
3
|
+
export function createAssetCompression() {
|
|
4
|
+
// compression supports plain Node HTTP/Connect; its DefinitelyTyped signature
|
|
5
|
+
// unnecessarily requires Express request/response extensions.
|
|
6
|
+
return compression({
|
|
7
|
+
threshold: 1024,
|
|
8
|
+
filter(req, res) {
|
|
9
|
+
const requestPath = (req.url || '/').split('?')[0];
|
|
10
|
+
if ((req.method !== 'GET' && req.method !== 'HEAD') ||
|
|
11
|
+
requestPath === '/' ||
|
|
12
|
+
(requestPath.startsWith('/__castle/') &&
|
|
13
|
+
!requestPath.startsWith('/__castle/ide/assets/')) ||
|
|
14
|
+
req.headers.range ||
|
|
15
|
+
res.hasHeader('Content-Range')) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
// Conditional responses must retain the representation's Vary header,
|
|
19
|
+
// but must never start a compressor or acquire a body.
|
|
20
|
+
if (res.statusCode === 304) {
|
|
21
|
+
if (res.getHeader('Vary') !== '*')
|
|
22
|
+
res.appendHeader('Vary', 'Accept-Encoding');
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
return (res.statusCode === 200 &&
|
|
26
|
+
!String(res.getHeader('Content-Type')).startsWith('text/event-stream') &&
|
|
27
|
+
compression.filter(req, res));
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
}
|
package/dist/castleJson.d.ts
CHANGED
package/dist/castleJson.js
CHANGED
|
@@ -5,7 +5,7 @@ import picomatch from 'picomatch';
|
|
|
5
5
|
// module with no imports of its own, because both the import machinery and the
|
|
6
6
|
// fork overlay need it and importing it from either of those would close a
|
|
7
7
|
// cycle.
|
|
8
|
-
// Root-level
|
|
8
|
+
// Root-level theme files a kit ships as a deck's starting point, seeded into a
|
|
9
9
|
// deck at scaffold (init.ts `writeStarterTheme`) and when an import newly brings
|
|
10
10
|
// them (imports.ts `seedKitTheme`).
|
|
11
11
|
//
|
|
@@ -18,7 +18,7 @@ import picomatch from 'picomatch';
|
|
|
18
18
|
// `theme.style` names the deck's font, `fonts.generated.js` is what carries that
|
|
19
19
|
// face's bytes into the bundle, and a deck given the first without the second
|
|
20
20
|
// renders in the fallback while claiming otherwise.
|
|
21
|
-
export const
|
|
21
|
+
export const STARTER_THEME_FILES = ['theme.style', 'fonts.generated.js'];
|
|
22
22
|
export const IMPORTS_DIR = 'imports';
|
|
23
23
|
export const DEFAULT_STARTER_SCENE = 'scenes/main.scene';
|
|
24
24
|
export const DEFAULT_SERVER_TICK_RATE = 20;
|
package/dist/devSessionServer.js
CHANGED
|
@@ -326,6 +326,15 @@ class DevSessionServerImpl {
|
|
|
326
326
|
this.clearReadyWaiters();
|
|
327
327
|
return;
|
|
328
328
|
}
|
|
329
|
+
// A storage request is answered whenever it arrives. The session's `onStart`
|
|
330
|
+
// runs inside boot, before the child reports ready, and a read made there
|
|
331
|
+
// would otherwise never settle.
|
|
332
|
+
if (frame.t === 'storageRequest' &&
|
|
333
|
+
typeof frame.reqId === 'string' &&
|
|
334
|
+
typeof frame.op === 'string') {
|
|
335
|
+
void this.handleStorageRequest(frame.reqId, frame.op, asRecord(frame.args));
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
329
338
|
if (!this.runtimeReady)
|
|
330
339
|
return;
|
|
331
340
|
if (frame.t === 'sendTo' && typeof frame.playerId === 'string') {
|
|
@@ -359,11 +368,6 @@ class DevSessionServerImpl {
|
|
|
359
368
|
else if (frame.t === 'kick' && typeof frame.playerId === 'string') {
|
|
360
369
|
this.kick(frame.playerId, typeof frame.reason === 'string' ? frame.reason : undefined);
|
|
361
370
|
}
|
|
362
|
-
else if (frame.t === 'storageRequest' &&
|
|
363
|
-
typeof frame.reqId === 'string' &&
|
|
364
|
-
typeof frame.op === 'string') {
|
|
365
|
-
void this.handleStorageRequest(frame.reqId, frame.op, asRecord(frame.args));
|
|
366
|
-
}
|
|
367
371
|
}
|
|
368
372
|
async handleStorageRequest(reqId, op, args) {
|
|
369
373
|
const deckId = readCastleJson(this.projectDir)?.deckId;
|
package/dist/imports.js
CHANGED
|
@@ -6,7 +6,7 @@ import { runTar } from './save-deck.js';
|
|
|
6
6
|
import { normalizeDeckPackageJson } from './normalize.js';
|
|
7
7
|
import { getKitsDir } from './localPaths.js';
|
|
8
8
|
import { writePlatformDocs } from './platformDoc.js';
|
|
9
|
-
import { ensureVisibleGlob, IMPORTS_DIR,
|
|
9
|
+
import { ensureVisibleGlob, IMPORTS_DIR, STARTER_THEME_FILES, readCastleJson as tryReadCastleJson, readCastleJsonOrThrow as readCastleJson, } from './castleJson.js';
|
|
10
10
|
// Adding another deck as a dependency (`castle-web add-import`; removing one is a
|
|
11
11
|
// later command). Deliberately NOT `get-deck`: that one
|
|
12
12
|
// replaces THIS deck's own source from the server (and carries guards for the
|
|
@@ -614,7 +614,7 @@ function seedKitTheme(deckDir, alias, log) {
|
|
|
614
614
|
if (typeof main !== 'string' || !main.trim())
|
|
615
615
|
return;
|
|
616
616
|
const seeded = [];
|
|
617
|
-
for (const name of
|
|
617
|
+
for (const name of STARTER_THEME_FILES) {
|
|
618
618
|
const src = path.join(importDir, name);
|
|
619
619
|
const dest = path.join(deckDir, name);
|
|
620
620
|
if (!fs.existsSync(src) || fs.existsSync(dest))
|
package/dist/init.js
CHANGED
|
@@ -2,7 +2,7 @@ import * as fs from 'fs';
|
|
|
2
2
|
import * as path from 'path';
|
|
3
3
|
import { CLAUDE_MD_PLATFORM_SECTION } from './platformDoc.js';
|
|
4
4
|
import { installDeps } from './install.js';
|
|
5
|
-
import { deckMainFile, deckStarterScene,
|
|
5
|
+
import { deckMainFile, deckStarterScene, STARTER_THEME_FILES } from './castleJson.js';
|
|
6
6
|
import { addImportTo, IMPORTS_DIR, lockImportTree, restoreMissingImports, syncImportDependencies, } from './imports.js';
|
|
7
7
|
import { discardParentSource, fetchParentSource, unpackParentSource, writeRemixIdentity, } from './remix.js';
|
|
8
8
|
import { getCliEntryPath, getKitsDir, getSdkPackagePath, toPosixPath } from './localPaths.js';
|
|
@@ -309,7 +309,7 @@ export function writeStarterScene(projectDir, kitDir, alias) {
|
|
|
309
309
|
fs.mkdirSync(path.join(projectDir, 'scenes'), { recursive: true });
|
|
310
310
|
writeJsonFile(path.join(projectDir, 'scenes', 'main.scene'), rewrite(scene));
|
|
311
311
|
}
|
|
312
|
-
// A kit may ship starter
|
|
312
|
+
// A kit may ship starter theme files (deck-level theme: the color palette,
|
|
313
313
|
// the deck font). Like the starter scene, they are deck content: the engine
|
|
314
314
|
// reads them from the DECK root, and they are the creator's to edit -- so they
|
|
315
315
|
// get seeded once at scaffold time rather than living read-only in the import.
|
|
@@ -317,11 +317,11 @@ export function writeStarterScene(projectDir, kitDir, alias) {
|
|
|
317
317
|
// kit file paths, so no ref rewriting). Exported for the same two scaffold paths
|
|
318
318
|
// as writeStarterScene.
|
|
319
319
|
//
|
|
320
|
-
// The set is
|
|
320
|
+
// The set is STARTER_THEME_FILES (castleJson.ts), which says why these two
|
|
321
321
|
// travel together: a deck given the record without the generated module renders
|
|
322
322
|
// in the fallback while claiming otherwise.
|
|
323
323
|
export function writeStarterTheme(projectDir, kitDir) {
|
|
324
|
-
for (const name of
|
|
324
|
+
for (const name of STARTER_THEME_FILES) {
|
|
325
325
|
const src = path.join(kitDir, name);
|
|
326
326
|
const dest = path.join(projectDir, name);
|
|
327
327
|
if (!fs.existsSync(src) || fs.existsSync(dest))
|
package/dist/serve.js
CHANGED
|
@@ -19,6 +19,7 @@ import { graphql as castleGraphql } from './api.js';
|
|
|
19
19
|
import { executeCommand } from './castle-host/host.js';
|
|
20
20
|
import { ServeSecurity } from './serveSecurity.js';
|
|
21
21
|
import { isSameOriginUpgrade } from './wsOrigin.js';
|
|
22
|
+
import { createAssetCompression } from './assetCompression.js';
|
|
22
23
|
import { resolveServerConfig } from './castleJson.js';
|
|
23
24
|
import { startDevSessionServer, } from './devSessionServer.js';
|
|
24
25
|
// The log is a tail, not an archive: append, and once it passes the cap drop
|
|
@@ -315,6 +316,7 @@ window.__castleShellOrigin=(function(){
|
|
|
315
316
|
},
|
|
316
317
|
},
|
|
317
318
|
configureServer(server) {
|
|
319
|
+
server.middlewares.use(createAssetCompression());
|
|
318
320
|
server.middlewares.use((req, res, next) => {
|
|
319
321
|
const reqPath = (req.url || '/').split('?')[0];
|
|
320
322
|
const trustedSurface = req.headers['x-castle-sandbox-surface'];
|
package/dist/serveSecurity.d.ts
CHANGED
|
@@ -18,6 +18,7 @@ export declare class ServeSecurity {
|
|
|
18
18
|
private readonly contentProxyToken;
|
|
19
19
|
private readonly signingKey;
|
|
20
20
|
private readonly serveNonce;
|
|
21
|
+
private contentGrantExpiresAtSeconds;
|
|
21
22
|
private readonly pathKey;
|
|
22
23
|
private readonly tickets;
|
|
23
24
|
private localContentPorts;
|
|
@@ -50,6 +51,7 @@ export declare class ServeSecurity {
|
|
|
50
51
|
editOrigin: string;
|
|
51
52
|
playOrigin: string;
|
|
52
53
|
};
|
|
54
|
+
private contentGrantExpiry;
|
|
53
55
|
/**
|
|
54
56
|
* Origin a browser should use for a local content role.
|
|
55
57
|
*
|
package/dist/serveSecurity.js
CHANGED
|
@@ -6,6 +6,7 @@ const CROCKFORD = '0123456789abcdefghjkmnpqrstvwxyz';
|
|
|
6
6
|
const CONTENT_LABEL_RE = /^1([ep])([0-9a-z]{1,4})-([a-z0-9]{14})-([0-9a-z]{1,8})-([0-9a-hjkmnp-tv-z]{13})-([0-9a-hjkmnp-tv-z]{20})$/;
|
|
7
7
|
const MAX_GRANT_SECONDS = 12 * 60 * 60;
|
|
8
8
|
const GRANT_CLOCK_SKEW_SECONDS = 60;
|
|
9
|
+
const MIN_CONTENT_GRANT_REMAINING_SECONDS = 11 * 60 * 60;
|
|
9
10
|
const BROWSER_GRANT_SECONDS = 3 * 60;
|
|
10
11
|
export const MOBILE_HOST_PROTOCOL = '2';
|
|
11
12
|
// A signature covers the request path and nothing else, so every path it can
|
|
@@ -79,6 +80,7 @@ export class ServeSecurity {
|
|
|
79
80
|
contentProxyToken = randomBytes(32).toString('base64url');
|
|
80
81
|
signingKey = process.env.CASTLE_AGENT_TOKEN || randomBytes(32).toString('base64url');
|
|
81
82
|
serveNonce = base32Lower(randomBytes(8));
|
|
83
|
+
contentGrantExpiresAtSeconds = 0;
|
|
82
84
|
pathKey = randomBytes(32).toString('base64url');
|
|
83
85
|
tickets = new Map();
|
|
84
86
|
localContentPorts;
|
|
@@ -284,9 +286,7 @@ window.fetch=function(input,init){
|
|
|
284
286
|
: cloud
|
|
285
287
|
? 'sandboxes.castlexyz.com'
|
|
286
288
|
: null;
|
|
287
|
-
|
|
288
|
-
// enforces the hard 12-hour maximum when it resolves the capability.
|
|
289
|
-
const expiresAtSeconds = Math.floor(Date.now() / 1000) + MAX_GRANT_SECONDS - GRANT_CLOCK_SKEW_SECONDS;
|
|
289
|
+
const expiresAtSeconds = cloud && contentDomain ? this.contentGrantExpiry() : 0;
|
|
290
290
|
const originFor = (role) => {
|
|
291
291
|
// Safari on macOS 15 asks DNS to resolve `*.localhost`, where it fails.
|
|
292
292
|
// Dedicated loopback ports are still distinct browser origins and need no DNS.
|
|
@@ -297,6 +297,20 @@ window.fetch=function(input,init){
|
|
|
297
297
|
};
|
|
298
298
|
return { editOrigin: originFor('edit'), playOrigin: originFor('play') };
|
|
299
299
|
}
|
|
300
|
+
contentGrantExpiry() {
|
|
301
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
302
|
+
const latestExpiry = nowSeconds + MAX_GRANT_SECONDS - GRANT_CLOCK_SKEW_SECONDS;
|
|
303
|
+
// Expiry is part of the hostname: reuse it so reopening the shell can reuse
|
|
304
|
+
// the browser's module cache. Until open shells support automatic refresh,
|
|
305
|
+
// keep at least 11 hours for every new shell (about an hour of cache reuse).
|
|
306
|
+
// Already-open shells keep their old, still-valid grants until expiry.
|
|
307
|
+
// Also remint after a backwards clock jump rather than exceed Ghost's cap.
|
|
308
|
+
if (this.contentGrantExpiresAtSeconds - nowSeconds < MIN_CONTENT_GRANT_REMAINING_SECONDS ||
|
|
309
|
+
this.contentGrantExpiresAtSeconds > latestExpiry) {
|
|
310
|
+
this.contentGrantExpiresAtSeconds = latestExpiry;
|
|
311
|
+
}
|
|
312
|
+
return this.contentGrantExpiresAtSeconds;
|
|
313
|
+
}
|
|
300
314
|
/**
|
|
301
315
|
* Origin a browser should use for a local content role.
|
|
302
316
|
*
|
|
@@ -37,6 +37,10 @@ then `Smoothing.smooth(…)`. File names stay lowercase.
|
|
|
37
37
|
- **What persists.** Object poses, each player's last pose by account, and
|
|
38
38
|
whatever the optional `save(sim)` hook returns are written every ten seconds
|
|
39
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.
|
|
40
44
|
- **A returning player starts where they left off.** A second connection from
|
|
41
45
|
the same account while the first is still playing gets a new character at the
|
|
42
46
|
spawn. The room is `session.sessionId`; every `public` shard shares one.
|
|
@@ -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:32:33.371Z"
|
|
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-10T22:05:16.080Z"
|
|
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
|
|
|
@@ -34,7 +34,7 @@ const DEFAULTS = {
|
|
|
34
34
|
Collider: { friction: 0.1, frictionStatic: 0.5, bounciness: 0, density: 0.001, isTrigger: false },
|
|
35
35
|
RigidBody: {
|
|
36
36
|
bodyType: 'dynamic',
|
|
37
|
-
gravityScale:
|
|
37
|
+
gravityScale: 0,
|
|
38
38
|
drag: 0.01,
|
|
39
39
|
angularDrag: 0,
|
|
40
40
|
freezeRotation: false,
|
|
@@ -190,7 +190,7 @@ function patchBody(body, actor) {
|
|
|
190
190
|
body.frictionStatic = collider.frictionStatic ?? 0.5;
|
|
191
191
|
body.isSensor = Boolean(collider.isTrigger);
|
|
192
192
|
body.frictionAir = rigid?.drag ?? 0.01;
|
|
193
|
-
body.plugin.gravityScale = rigid?.gravityScale ??
|
|
193
|
+
body.plugin.gravityScale = rigid?.gravityScale ?? 0;
|
|
194
194
|
body.plugin.angularDrag = rigid?.angularDrag ?? 0;
|
|
195
195
|
|
|
196
196
|
// Density sets mass, and only a dynamic body may have it: on a static one it
|
|
@@ -261,7 +261,7 @@ function applyGravity(sim) {
|
|
|
261
261
|
if (body.isStatic) {
|
|
262
262
|
continue;
|
|
263
263
|
}
|
|
264
|
-
const scale = (body.plugin.gravityScale ??
|
|
264
|
+
const scale = (body.plugin.gravityScale ?? 0) * GRAVITY_SCALE;
|
|
265
265
|
body.force.x += body.mass * sim.gravity.x * scale;
|
|
266
266
|
body.force.y += body.mass * sim.gravity.y * scale;
|
|
267
267
|
}
|
|
@@ -96,6 +96,18 @@ class MultiplayerSystem {
|
|
|
96
96
|
return void this.start();
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
// The session ended under this connection (the server was replaced, or the
|
|
100
|
+
// platform closed it). Everything drawn from it goes, and the next frame
|
|
101
|
+
// joins afresh behind the overlay.
|
|
102
|
+
if (this.net.closed()) {
|
|
103
|
+
this.reset(scene);
|
|
104
|
+
this.net = null;
|
|
105
|
+
this.joined = false;
|
|
106
|
+
this.joinStartedAt = 0;
|
|
107
|
+
this.starting = false;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
99
111
|
// Player is a behavior component on the actor controlled by this browser.
|
|
100
112
|
const local = scene.actorWith('Player');
|
|
101
113
|
|
|
@@ -232,6 +244,7 @@ class MultiplayerSystem {
|
|
|
232
244
|
}
|
|
233
245
|
Avatars.showPlayer(this.avatars, scene, playerId, look.username ?? null, pose, {
|
|
234
246
|
blueprint: look.blueprint,
|
|
247
|
+
userId: this.net.userId(playerId),
|
|
235
248
|
});
|
|
236
249
|
}
|
|
237
250
|
|
|
@@ -321,6 +334,7 @@ class MultiplayerSystem {
|
|
|
321
334
|
if (local.components.Box) {
|
|
322
335
|
local.components.Box.label = Avatars.shortName(this.net.status().you);
|
|
323
336
|
}
|
|
337
|
+
local.runtime.avatar = Avatars.avatarCanvas(this.net.userId(this.net.selfId()));
|
|
324
338
|
}
|
|
325
339
|
|
|
326
340
|
// `[id, centre]` for every scene object the server has sent a pose for. The
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Generated by the
|
|
1
|
+
// Generated by the Theme editor. Do not edit -- picking a font rewrites it.
|
|
2
2
|
//
|
|
3
3
|
// `theme.style` records that this deck's font is DMSans; this module is what
|
|
4
4
|
// puts DMSans's bytes in the published bundle. It has to name the face in a
|
|
@@ -66,6 +66,11 @@ File names stay lowercase.
|
|
|
66
66
|
move report when it changes, capped at 4 KB, and comes back in the WORLD
|
|
67
67
|
snapshot before the Player behavior runs, which is how the camera angle and
|
|
68
68
|
score return.
|
|
69
|
+
- **Players wear their Castle avatar.** Every player's profile photo, the
|
|
70
|
+
local one included, is the texture on the four side faces of their model's
|
|
71
|
+
`head` part (`castle.physics-2d`'s `engine/avatarArt.js`, through
|
|
72
|
+
`User.get`). A player with no photo, or a model with no `head`, keeps the
|
|
73
|
+
model's own face.
|
|
69
74
|
- **A returning player starts where they left off.** A second connection from
|
|
70
75
|
the same account while the first is still playing gets a new character at the
|
|
71
76
|
spawn. The room is `session.sessionId`; every `public` shard shares one.
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"imports": {
|
|
15
15
|
"castle.real-time": {
|
|
16
16
|
"deckId": "xFVr-afOLuUQ",
|
|
17
|
-
"version": "2026-09-
|
|
17
|
+
"version": "2026-09-10T20:32:33.371Z"
|
|
18
18
|
},
|
|
19
19
|
"castle.physics-3d": {
|
|
20
20
|
"deckId": "JH0SclbPVP0y",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
},
|
|
23
23
|
"castle.physics-2d": {
|
|
24
24
|
"deckId": "ckRZGFW4iPrx",
|
|
25
|
-
"version": "2026-09-
|
|
25
|
+
"version": "2026-09-10T20:37:41.733Z",
|
|
26
26
|
"via": "castle.physics-3d"
|
|
27
27
|
},
|
|
28
28
|
"castle.base": {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"autoUpdateWhenImported": true,
|
|
34
34
|
"deckId": "ZwA_P_-VO-Qh",
|
|
35
35
|
"cardId": "bvRmCzyUpt_a",
|
|
36
|
-
"publishedVersion": "2026-09-
|
|
36
|
+
"publishedVersion": "2026-09-10T22:05:24.002Z",
|
|
37
37
|
"main": "main.jsx",
|
|
38
38
|
"server": {
|
|
39
39
|
"main": "code/server/index.js",
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
// Other-player actors
|
|
2
|
-
// from deck blueprints, updates their network poses,
|
|
3
|
-
// resources when players leave.
|
|
1
|
+
// Other-player actors, their floating name tags, and the avatar on their head.
|
|
2
|
+
// This module creates actors from deck blueprints, updates their network poses,
|
|
3
|
+
// and removes their scene resources when players leave.
|
|
4
4
|
|
|
5
5
|
import * as NameTags from './nameTags.js';
|
|
6
6
|
import { applyPose } from './poses.js';
|
|
7
|
+
import { artReady } from '@imports/castle.physics-2d/engine/art.js';
|
|
8
|
+
import { avatarFacePath } from '@imports/castle.physics-2d/engine/avatarArt.js';
|
|
9
|
+
import { makeShapeMesh } from '@imports/castle.physics-3d/engine3d/materials.js';
|
|
7
10
|
|
|
8
11
|
// The blueprint spawned when a caller names none. A deck's own file wins; a deck
|
|
9
12
|
// scaffolded from this kit has no blueprints yet, so the kit's copy stands in
|
|
@@ -33,7 +36,8 @@ export function makeAvatars() {
|
|
|
33
36
|
//
|
|
34
37
|
// A `username` of null draws no label. Options are `blueprint`, the scene to
|
|
35
38
|
// spawn from (default `blueprints/other-player.scene`, the deck's own or the kit's),
|
|
36
|
-
//
|
|
39
|
+
// `tagHeight`, how far over the body the label floats, and `userId`, whose
|
|
40
|
+
// avatar goes on the model's head.
|
|
37
41
|
export function showPlayer(avatars, scene, playerId, username, pose, options = {}) {
|
|
38
42
|
// The actor keeps the blueprint used at creation; `ensureActor` only spawns
|
|
39
43
|
// when this player has no live actor in the current scene.
|
|
@@ -48,17 +52,20 @@ export function showPlayer(avatars, scene, playerId, username, pose, options = {
|
|
|
48
52
|
} else {
|
|
49
53
|
showTag(avatars, scene, playerId, username, transform, options.tagHeight);
|
|
50
54
|
}
|
|
55
|
+
dressHead(scene, actor.id, options.userId);
|
|
51
56
|
return actor;
|
|
52
57
|
}
|
|
53
58
|
|
|
54
|
-
// Update the local player's label over the engine's local Player
|
|
55
|
-
// roster can arrive several frames after connection, so an empty
|
|
56
|
-
// leaves the label absent until the roster supplies one.
|
|
57
|
-
|
|
59
|
+
// Update the local player's label and head over the engine's local Player
|
|
60
|
+
// actor. The SDK roster can arrive several frames after connection, so an empty
|
|
61
|
+
// username leaves the label absent until the roster supplies one. Options are
|
|
62
|
+
// `tagHeight` and `userId`, as for `showPlayer`.
|
|
63
|
+
export function showSelf(avatars, scene, local, username, transform, options = {}) {
|
|
64
|
+
dressHead(scene, local?.id, options.userId);
|
|
58
65
|
if (!username) {
|
|
59
66
|
return;
|
|
60
67
|
}
|
|
61
|
-
showTag(avatars, scene, 'self', username, transform, tagHeight);
|
|
68
|
+
showTag(avatars, scene, 'self', username, transform, options.tagHeight);
|
|
62
69
|
}
|
|
63
70
|
|
|
64
71
|
// Despawn the actor and dispose the tag of every player absent from `present`.
|
|
@@ -127,6 +134,32 @@ function showTag(avatars, scene, key, username, transform, tagHeight) {
|
|
|
127
134
|
NameTags.moveNameTag(tag, transform, scene.three?.camera, tagHeight);
|
|
128
135
|
}
|
|
129
136
|
|
|
137
|
+
// Put the player's photo, square and edge to edge, on the four side faces of
|
|
138
|
+
// their model's head. The part is the one named `head`, the same convention
|
|
139
|
+
// `pxmodel.js` animates by; a model without one shows no avatar. The materials
|
|
140
|
+
// are rebuilt once per picture, when its art has loaded.
|
|
141
|
+
function dressHead(scene, actorId, userId) {
|
|
142
|
+
const path = avatarFacePath(scene, userId);
|
|
143
|
+
const head = scene.three?.nodes?.get(actorId)?.node.userData.modelParts?.get('head');
|
|
144
|
+
const mesh = head?.children.find((child) => child.userData.partMesh === 'head');
|
|
145
|
+
if (!path || !mesh || mesh.userData.avatarPath === path || !artReady(scene.sprites[path])) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// White under the picture, so the head's skin color does not tint it.
|
|
150
|
+
const { width, height, depth } = mesh.geometry.parameters;
|
|
151
|
+
const dressed = makeShapeMesh(
|
|
152
|
+
{ kind: 'box', width, height, depth, color: '#ffffff', art: { sides: path, front: path } },
|
|
153
|
+
scene.sprites,
|
|
154
|
+
);
|
|
155
|
+
for (const material of [].concat(mesh.material)) {
|
|
156
|
+
material.dispose();
|
|
157
|
+
}
|
|
158
|
+
mesh.material = dressed.material;
|
|
159
|
+
dressed.geometry.dispose();
|
|
160
|
+
mesh.userData.avatarPath = path;
|
|
161
|
+
}
|
|
162
|
+
|
|
130
163
|
function dropTag(avatars, scene, key) {
|
|
131
164
|
const tag = avatars.tags.get(key);
|
|
132
165
|
if (tag) {
|
|
@@ -31,7 +31,7 @@ const blueprintTexts = import.meta.glob(['/blueprints/*.scene', '/imports/*/blue
|
|
|
31
31
|
const DEFAULTS = {
|
|
32
32
|
Transform: { x: 0, y: 0, z: 0, rotationX: 0, rotationY: 0, rotationZ: 0 },
|
|
33
33
|
Shape: { kind: 'box', width: 1, height: 1, depth: 1, radius: 0.5, solid: false },
|
|
34
|
-
Body: { type: 'fixed', friction: 0.6, restitution: 0, gravityScale:
|
|
34
|
+
Body: { type: 'fixed', friction: 0.6, restitution: 0, gravityScale: 0, lockRotations: false },
|
|
35
35
|
Door: { range: 3, slide: 2.1, speed: 2.5 },
|
|
36
36
|
|
|
37
37
|
// `Ball` marks a fast-moving actor. `world.js` enables continuous collision
|
|
@@ -98,6 +98,19 @@ class MultiplayerSystem {
|
|
|
98
98
|
showJoinOverlay(this.joiningOverlayWanted());
|
|
99
99
|
return void this.start();
|
|
100
100
|
}
|
|
101
|
+
|
|
102
|
+
// The session ended under this connection (the server was replaced, or the
|
|
103
|
+
// platform closed it). Everything drawn from it goes, and the next frame
|
|
104
|
+
// joins afresh behind the overlay.
|
|
105
|
+
if (this.net.closed()) {
|
|
106
|
+
this.reset(scene);
|
|
107
|
+
this.net = null;
|
|
108
|
+
this.joined = false;
|
|
109
|
+
this.joinStartedAt = 0;
|
|
110
|
+
this.starting = false;
|
|
111
|
+
this.sentState = '';
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
101
114
|
const local = scene.actorWith('Player');
|
|
102
115
|
|
|
103
116
|
// Drain all queued messages before choosing the render time for this frame.
|
|
@@ -262,6 +275,7 @@ class MultiplayerSystem {
|
|
|
262
275
|
}
|
|
263
276
|
Avatars.showPlayer(this.avatars, scene, playerId, look.username ?? null, pose, {
|
|
264
277
|
blueprint: look.blueprint,
|
|
278
|
+
userId: this.net.userId(playerId),
|
|
265
279
|
});
|
|
266
280
|
}
|
|
267
281
|
Avatars.dropMissing(this.avatars, scene, others);
|
|
@@ -350,7 +364,9 @@ class MultiplayerSystem {
|
|
|
350
364
|
if (this.net.sendMove(Poses.packMove(me), owned, claims, releases, state) && state) {
|
|
351
365
|
this.sentState = stateJson;
|
|
352
366
|
}
|
|
353
|
-
Avatars.showSelf(this.avatars, scene, this.net.status().you, me
|
|
367
|
+
Avatars.showSelf(this.avatars, scene, local, this.net.status().you, me, {
|
|
368
|
+
userId: this.net.userId(this.net.selfId()),
|
|
369
|
+
});
|
|
354
370
|
}
|
|
355
371
|
|
|
356
372
|
// Return `[id, Transform]` for every scene object known from server snapshots.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Generated by the
|
|
1
|
+
// Generated by the Theme editor. Do not edit -- picking a font rewrites it.
|
|
2
2
|
//
|
|
3
3
|
// `theme.style` records that this deck's font is DMSans; this module is what
|
|
4
4
|
// puts DMSans's bytes in the published bundle. It has to name the face in a
|