castle-web-cli 0.4.172 → 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/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/castle.json +2 -2
- 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 +12 -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 -4
- package/kits/multiplayer-3d/castle.json +3 -3
- package/kits/multiplayer-3d/code/client/avatars.js +47 -18
- package/kits/multiplayer-3d/code/client/nameTags.js +16 -35
- package/kits/multiplayer-3d/code/server/sceneBodies.js +1 -1
- package/kits/multiplayer-3d/code/systems/multiplayer.js +14 -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 +108 -14
- 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 +11 -2
- package/kits/real-time/package-lock.json +1 -1
- package/kits/turn-based/castle.json +1 -1
- package/kits/turn-based/fonts.generated.js +1 -1
- package/kits/turn-based/package-lock.json +1 -1
- 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/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
|
*
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"imports": {
|
|
16
16
|
"castle.real-time": {
|
|
17
17
|
"deckId": "xFVr-afOLuUQ",
|
|
18
|
-
"version": "2026-09-10T20:
|
|
18
|
+
"version": "2026-09-10T20:32:33.371Z"
|
|
19
19
|
},
|
|
20
20
|
"castle.physics-2d": {
|
|
21
21
|
"deckId": "ckRZGFW4iPrx",
|
|
@@ -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
|
}
|
|
@@ -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
|
|
|
@@ -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,10 +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.**
|
|
70
|
-
|
|
71
|
-
(`castle.physics-2d`'s `engine/avatarArt.js`, through
|
|
72
|
-
with no photo
|
|
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.
|
|
73
74
|
- **A returning player starts where they left off.** A second connection from
|
|
74
75
|
the same account while the first is still playing gets a new character at the
|
|
75
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-10T20:
|
|
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-10T20:
|
|
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,10 +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 {
|
|
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';
|
|
8
10
|
|
|
9
11
|
// The blueprint spawned when a caller names none. A deck's own file wins; a deck
|
|
10
12
|
// scaffolded from this kit has no blueprints yet, so the kit's copy stands in
|
|
@@ -35,7 +37,7 @@ export function makeAvatars() {
|
|
|
35
37
|
// A `username` of null draws no label. Options are `blueprint`, the scene to
|
|
36
38
|
// spawn from (default `blueprints/other-player.scene`, the deck's own or the kit's),
|
|
37
39
|
// `tagHeight`, how far over the body the label floats, and `userId`, whose
|
|
38
|
-
// avatar the
|
|
40
|
+
// avatar goes on the model's head.
|
|
39
41
|
export function showPlayer(avatars, scene, playerId, username, pose, options = {}) {
|
|
40
42
|
// The actor keeps the blueprint used at creation; `ensureActor` only spawns
|
|
41
43
|
// when this player has no live actor in the current scene.
|
|
@@ -48,20 +50,22 @@ export function showPlayer(avatars, scene, playerId, username, pose, options = {
|
|
|
48
50
|
if (username === null) {
|
|
49
51
|
dropTag(avatars, scene, playerId);
|
|
50
52
|
} else {
|
|
51
|
-
showTag(avatars, scene, playerId, username, transform, options);
|
|
53
|
+
showTag(avatars, scene, playerId, username, transform, options.tagHeight);
|
|
52
54
|
}
|
|
55
|
+
dressHead(scene, actor.id, options.userId);
|
|
53
56
|
return actor;
|
|
54
57
|
}
|
|
55
58
|
|
|
56
|
-
// Update the local player's label over the engine's local Player
|
|
57
|
-
// roster can arrive several frames after connection, so an empty
|
|
58
|
-
// leaves the label absent until the roster supplies one. Options are
|
|
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
|
|
59
62
|
// `tagHeight` and `userId`, as for `showPlayer`.
|
|
60
|
-
export function showSelf(avatars, scene, username, transform, options = {}) {
|
|
63
|
+
export function showSelf(avatars, scene, local, username, transform, options = {}) {
|
|
64
|
+
dressHead(scene, local?.id, options.userId);
|
|
61
65
|
if (!username) {
|
|
62
66
|
return;
|
|
63
67
|
}
|
|
64
|
-
showTag(avatars, scene, 'self', username, transform, options);
|
|
68
|
+
showTag(avatars, scene, 'self', username, transform, options.tagHeight);
|
|
65
69
|
}
|
|
66
70
|
|
|
67
71
|
// Despawn the actor and dispose the tag of every player absent from `present`.
|
|
@@ -109,21 +113,20 @@ function ensureActor(avatars, scene, playerId, pose, blueprint) {
|
|
|
109
113
|
return spawned;
|
|
110
114
|
}
|
|
111
115
|
|
|
112
|
-
// Create a tag when the displayed text
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
function showTag(avatars, scene, key, username, transform,
|
|
116
|
+
// Create a tag when the displayed text changes, then place it over the actor.
|
|
117
|
+
// Each redraw allocates a canvas, texture, and sprite, so unchanged text reuses
|
|
118
|
+
// the existing resources.
|
|
119
|
+
function showTag(avatars, scene, key, username, transform, tagHeight) {
|
|
116
120
|
const label = NameTags.shortName(username);
|
|
117
|
-
const avatar = avatarCanvas(userId);
|
|
118
121
|
let tag = avatars.tags.get(key);
|
|
119
122
|
|
|
120
123
|
// Compare against the shortened text stored on the tag. An
|
|
121
124
|
// `anonymous-user-<uuid>` differs from the `anon-xxxxxx` string on the sprite.
|
|
122
|
-
if (!tag || tag.text !== label
|
|
125
|
+
if (!tag || tag.text !== label) {
|
|
123
126
|
if (tag) {
|
|
124
127
|
NameTags.disposeNameTag(scene.three, tag);
|
|
125
128
|
}
|
|
126
|
-
tag = NameTags.makeNameTag(scene.three, label
|
|
129
|
+
tag = NameTags.makeNameTag(scene.three, label);
|
|
127
130
|
avatars.tags.set(key, tag);
|
|
128
131
|
}
|
|
129
132
|
|
|
@@ -131,6 +134,32 @@ function showTag(avatars, scene, key, username, transform, { tagHeight, userId }
|
|
|
131
134
|
NameTags.moveNameTag(tag, transform, scene.three?.camera, tagHeight);
|
|
132
135
|
}
|
|
133
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
|
+
|
|
134
163
|
function dropTag(avatars, scene, key) {
|
|
135
164
|
const tag = avatars.tags.get(key);
|
|
136
165
|
if (tag) {
|
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
// Floating
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// distance, and disposes its GPU resources. A sprite always faces the camera,
|
|
5
|
-
// so the avatar reads from every side.
|
|
1
|
+
// Floating username-label sprites for other players and the local player. This module
|
|
2
|
+
// draws text to a canvas, attaches the resulting sprite directly to the engine's
|
|
3
|
+
// three.js scene, scales it by camera distance, and disposes its GPU resources.
|
|
6
4
|
|
|
7
5
|
import * as THREE from 'three';
|
|
8
6
|
|
|
@@ -10,11 +8,6 @@ import * as THREE from 'three';
|
|
|
10
8
|
const HEIGHT_ABOVE = 1.25;
|
|
11
9
|
const PIXELS_PER_UNIT = 200;
|
|
12
10
|
|
|
13
|
-
// Canvas pixels of the name pill, and of the avatar drawn above it.
|
|
14
|
-
const LABEL_PX = 68;
|
|
15
|
-
const AVATAR_PX = 112;
|
|
16
|
-
const GAP_PX = 8;
|
|
17
|
-
|
|
18
11
|
// The distance at which the label is the size it was drawn to be: 68 canvas
|
|
19
12
|
// pixels over 200 per unit is 0.34m, which on a 500x700 card at fov 55 is 29px.
|
|
20
13
|
// This reference distance matches the common camera placement in kit decks.
|
|
@@ -23,12 +16,11 @@ const SAME_SIZE_M = 8;
|
|
|
23
16
|
// The minimum scale keeps a label readable when the camera is near the player.
|
|
24
17
|
const MIN_SCALE = 0.35;
|
|
25
18
|
|
|
26
|
-
// Create `{ sprite, text,
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const canvas = drawLabel(text, avatar);
|
|
19
|
+
// Create `{ sprite, text, width, height }` and add the sprite to the engine's
|
|
20
|
+
// three.js scene. The engine actor-to-mesh sync does not update these directly
|
|
21
|
+
// attached sprites, so callers move them each frame with `moveNameTag`.
|
|
22
|
+
export function makeNameTag(world, text) {
|
|
23
|
+
const canvas = drawLabel(text);
|
|
32
24
|
const texture = new THREE.CanvasTexture(canvas);
|
|
33
25
|
texture.colorSpace = THREE.SRGBColorSpace;
|
|
34
26
|
const sprite = new THREE.Sprite(
|
|
@@ -49,11 +41,8 @@ export function makeNameTag(world, text, avatar = null) {
|
|
|
49
41
|
world.scene.add(sprite);
|
|
50
42
|
|
|
51
43
|
// `text` is the string actually drawn, not the username it came from. The
|
|
52
|
-
// caller compares against it
|
|
53
|
-
|
|
54
|
-
// `heightAbove` when an avatar is stacked over it.
|
|
55
|
-
const lift = (height - LABEL_PX / PIXELS_PER_UNIT) / 2;
|
|
56
|
-
return { sprite, text, avatar, width, height, lift };
|
|
44
|
+
// caller compares against it to decide whether a name changed.
|
|
45
|
+
return { sprite, text, width, height };
|
|
57
46
|
}
|
|
58
47
|
|
|
59
48
|
// Move and scale a tag after its player pose is drawn for the current frame.
|
|
@@ -72,7 +61,6 @@ export function moveNameTag(tag, transform, camera, heightAbove = HEIGHT_ABOVE)
|
|
|
72
61
|
// 0.8m 286px -> 100, 2m 114px -> 40, 4m 57px -> 29, 8m 29px -> 29, 20m 11 -> 11.
|
|
73
62
|
const scale = Math.max(MIN_SCALE, Math.min(1, away / SAME_SIZE_M));
|
|
74
63
|
tag.sprite.scale.set(tag.width * scale, tag.height * scale, 1);
|
|
75
|
-
tag.sprite.position.y += tag.lift * scale;
|
|
76
64
|
}
|
|
77
65
|
|
|
78
66
|
// Remove a tag from the three.js scene and release its texture and material.
|
|
@@ -90,31 +78,24 @@ export function shortName(username) {
|
|
|
90
78
|
return anon ? `anon-${anon[1].replace(/-/g, '').slice(-6)}` : (username ?? 'player');
|
|
91
79
|
}
|
|
92
80
|
|
|
93
|
-
// Rasterize one display name
|
|
94
|
-
|
|
95
|
-
function drawLabel(text, avatar) {
|
|
81
|
+
// Rasterize one display name into the canvas used as the sprite texture.
|
|
82
|
+
function drawLabel(text) {
|
|
96
83
|
const canvas = document.createElement('canvas');
|
|
97
84
|
const ctx = canvas.getContext('2d');
|
|
98
85
|
const font = '600 44px Inter, system-ui, sans-serif';
|
|
99
86
|
ctx.font = font;
|
|
100
|
-
const
|
|
101
|
-
const width = avatar ? Math.max(pillWidth, AVATAR_PX) : pillWidth;
|
|
102
|
-
const top = avatar ? AVATAR_PX + GAP_PX : 0;
|
|
87
|
+
const width = Math.ceil(ctx.measureText(text).width) + 40;
|
|
103
88
|
|
|
104
89
|
// Canvas resizing clears the context state, so measure before resizing and
|
|
105
90
|
// restore the font before drawing.
|
|
106
91
|
canvas.width = width;
|
|
107
|
-
canvas.height =
|
|
92
|
+
canvas.height = 68;
|
|
108
93
|
ctx.font = font;
|
|
109
|
-
if (avatar) {
|
|
110
|
-
ctx.drawImage(avatar, (width - AVATAR_PX) / 2, 0, AVATAR_PX, AVATAR_PX);
|
|
111
|
-
}
|
|
112
|
-
const left = (width - pillWidth) / 2;
|
|
113
94
|
ctx.fillStyle = 'rgba(12,12,16,0.72)';
|
|
114
|
-
ctx.roundRect(
|
|
95
|
+
ctx.roundRect(0, 0, width, 68, 16);
|
|
115
96
|
ctx.fill();
|
|
116
97
|
ctx.fillStyle = '#f2f2f5';
|
|
117
98
|
ctx.textBaseline = 'middle';
|
|
118
|
-
ctx.fillText(text,
|
|
99
|
+
ctx.fillText(text, 20, 36);
|
|
119
100
|
return canvas;
|
|
120
101
|
}
|
|
@@ -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.
|
|
@@ -351,7 +364,7 @@ class MultiplayerSystem {
|
|
|
351
364
|
if (this.net.sendMove(Poses.packMove(me), owned, claims, releases, state) && state) {
|
|
352
365
|
this.sentState = stateJson;
|
|
353
366
|
}
|
|
354
|
-
Avatars.showSelf(this.avatars, scene, this.net.status().you, me, {
|
|
367
|
+
Avatars.showSelf(this.avatars, scene, local, this.net.status().you, me, {
|
|
355
368
|
userId: this.net.userId(this.net.selfId()),
|
|
356
369
|
});
|
|
357
370
|
}
|
|
@@ -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
|