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.
Files changed (57) hide show
  1. package/dist/assetCompression.d.ts +3 -0
  2. package/dist/assetCompression.js +30 -0
  3. package/dist/castleJson.d.ts +1 -1
  4. package/dist/castleJson.js +2 -2
  5. package/dist/imports.js +2 -2
  6. package/dist/init.js +4 -4
  7. package/dist/serve.js +2 -0
  8. package/dist/serveSecurity.d.ts +2 -0
  9. package/dist/serveSecurity.js +17 -3
  10. package/kits/multiplayer-2d/castle.json +2 -2
  11. package/kits/multiplayer-2d/code/server/sceneBodies.js +1 -1
  12. package/kits/multiplayer-2d/code/server/world.js +2 -2
  13. package/kits/multiplayer-2d/code/systems/multiplayer.js +12 -0
  14. package/kits/multiplayer-2d/fonts.generated.js +1 -1
  15. package/kits/multiplayer-2d/package-lock.json +1 -1
  16. package/kits/multiplayer-3d/CLAUDE.md +5 -4
  17. package/kits/multiplayer-3d/castle.json +3 -3
  18. package/kits/multiplayer-3d/code/client/avatars.js +47 -18
  19. package/kits/multiplayer-3d/code/client/nameTags.js +16 -35
  20. package/kits/multiplayer-3d/code/server/sceneBodies.js +1 -1
  21. package/kits/multiplayer-3d/code/systems/multiplayer.js +14 -1
  22. package/kits/multiplayer-3d/fonts.generated.js +1 -1
  23. package/kits/multiplayer-3d/package-lock.json +1 -1
  24. package/kits/physics-2d/CLAUDE.md +7 -6
  25. package/kits/physics-2d/behaviors/Collider.jsx +7 -1
  26. package/kits/physics-2d/behaviors/RigidBody.jsx +6 -3
  27. package/kits/physics-2d/castle.json +2 -2
  28. package/kits/physics-2d/editors/SceneEditor.jsx +1 -0
  29. package/kits/physics-2d/editors/SingleEditor.jsx +4 -4
  30. package/kits/physics-2d/editors/{StyleEditor.jsx → ThemeEditor.jsx} +8 -8
  31. package/kits/physics-2d/editors/deckFont.js +2 -2
  32. package/kits/physics-2d/editors/{styleEditor.module.css → themeEditor.module.css} +2 -2
  33. package/kits/physics-2d/engine/avatarArt.js +108 -14
  34. package/kits/physics-2d/engine/files.js +2 -2
  35. package/kits/physics-2d/engine/physics/matterBridge.js +2 -2
  36. package/kits/physics-2d/engine/scene.js +1 -1
  37. package/kits/physics-2d/fonts.generated.js +1 -1
  38. package/kits/physics-2d/package-lock.json +1 -1
  39. package/kits/physics-3d/behaviors/Body.jsx +10 -1
  40. package/kits/physics-3d/behaviors/Door.jsx +5 -0
  41. package/kits/physics-3d/behaviors/Lookable.jsx +3 -0
  42. package/kits/physics-3d/behaviors/Model.jsx +4 -0
  43. package/kits/physics-3d/behaviors/Pickup.jsx +4 -0
  44. package/kits/physics-3d/behaviors/Player.jsx +7 -0
  45. package/kits/physics-3d/behaviors/Shape.jsx +13 -0
  46. package/kits/physics-3d/castle.json +1 -1
  47. package/kits/physics-3d/engine3d/editor3dInspector.jsx +1 -0
  48. package/kits/physics-3d/fonts.generated.js +1 -1
  49. package/kits/physics-3d/package-lock.json +1 -1
  50. package/kits/real-time/castle.json +1 -1
  51. package/kits/real-time/code/client/connection.js +11 -2
  52. package/kits/real-time/package-lock.json +1 -1
  53. package/kits/turn-based/castle.json +1 -1
  54. package/kits/turn-based/fonts.generated.js +1 -1
  55. package/kits/turn-based/package-lock.json +1 -1
  56. package/package.json +3 -1
  57. /package/kits/physics-2d/editors/{styleTheme.js → theme.js} +0 -0
@@ -0,0 +1,3 @@
1
+ import type { Connect } from 'vite';
2
+ /** Compress assets, never the token-bearing shell bootstrap or control API. */
3
+ export declare function createAssetCompression(): Connect.NextHandleFunction;
@@ -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
+ }
@@ -1,4 +1,4 @@
1
- export declare const STARTER_STYLE_FILES: string[];
1
+ export declare const STARTER_THEME_FILES: string[];
2
2
  export declare const IMPORTS_DIR = "imports";
3
3
  export interface DeckImport {
4
4
  deckId?: string;
@@ -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 style files a kit ships as a deck's starting point, seeded into a
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 STARTER_STYLE_FILES = ['theme.style', 'fonts.generated.js'];
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, STARTER_STYLE_FILES, readCastleJson as tryReadCastleJson, readCastleJsonOrThrow as readCastleJson, } from './castleJson.js';
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 STARTER_STYLE_FILES) {
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, STARTER_STYLE_FILES } from './castleJson.js';
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 style files (deck-level styling: the color palette,
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 STARTER_STYLE_FILES (castleJson.ts), which says why these two
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 STARTER_STYLE_FILES) {
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'];
@@ -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
  *
@@ -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
- // Leave room for small positive clock skew at the control plane. Ghost still
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:27:27.032Z"
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-10T20:28:48.461Z"
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: 1,
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 ?? 1;
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 ?? 1) * GRAVITY_SCALE;
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 Style editor. Do not edit -- picking a font rewrites it.
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
@@ -30,7 +30,7 @@
30
30
  },
31
31
  "../../sdk": {
32
32
  "name": "castle-web-sdk",
33
- "version": "0.4.27",
33
+ "version": "0.4.28",
34
34
  "dev": true,
35
35
  "devDependencies": {
36
36
  "eslint": "^9.0.0",
@@ -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.** The name tag over every player,
70
- the local one included, carries their profile photo and frame
71
- (`castle.physics-2d`'s `engine/avatarArt.js`, through `User.get`). A player
72
- with no photo gets the name alone.
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:27:27.032Z"
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:21:12.665Z",
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-10T20:28:53.356Z",
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 and their floating name tags. This module creates actors
2
- // from deck blueprints, updates their network poses, and removes their scene
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 { avatarCanvas } from '@imports/castle.physics-2d/engine/avatarArt.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';
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 label carries.
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 actor. The SDK
57
- // roster can arrive several frames after connection, so an empty username
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 or the avatar changes, then place it over
113
- // the actor. Each redraw allocates a canvas, texture, and sprite, so an unchanged
114
- // tag reuses the existing resources.
115
- function showTag(avatars, scene, key, username, transform, { tagHeight, userId } = {}) {
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 || tag.avatar !== avatar) {
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, avatar);
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 name tags for other players and the local player: the player's round
2
- // avatar over their username. This module draws them to a canvas, attaches the
3
- // resulting sprite directly to the engine's three.js scene, scales it by camera
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, avatar, width, height, lift }` and add the sprite to
27
- // the engine's three.js scene. `avatar` is the canvas from `avatarArt.js`, or
28
- // null for a name alone. The engine actor-to-mesh sync does not update these
29
- // directly attached sprites, so callers move them each frame with `moveNameTag`.
30
- export function makeNameTag(world, text, avatar = null) {
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, and against `avatar`, to decide whether to
53
- // redraw. `lift` raises the sprite's centre so the name pill stays at
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, with the avatar centred above it, into the canvas
94
- // used as the sprite texture.
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 pillWidth = Math.ceil(ctx.measureText(text).width) + 40;
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 = top + LABEL_PX;
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(left, top, pillWidth, LABEL_PX, 16);
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, left + 20, top + LABEL_PX / 2);
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: 1, lockRotations: false },
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 Style editor. Do not edit -- picking a font rewrites it.
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
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "../../sdk": {
34
34
  "name": "castle-web-sdk",
35
- "version": "0.4.27",
35
+ "version": "0.4.28",
36
36
  "dev": true,
37
37
  "devDependencies": {
38
38
  "eslint": "^9.0.0",