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.
Files changed (65) 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/devSessionServer.js +9 -5
  6. package/dist/imports.js +2 -2
  7. package/dist/init.js +4 -4
  8. package/dist/serve.js +2 -0
  9. package/dist/serveSecurity.d.ts +2 -0
  10. package/dist/serveSecurity.js +17 -3
  11. package/kits/multiplayer-2d/CLAUDE.md +4 -0
  12. package/kits/multiplayer-2d/behaviors/Box.jsx +32 -9
  13. package/kits/multiplayer-2d/castle.json +3 -3
  14. package/kits/multiplayer-2d/code/client/avatars.js +6 -1
  15. package/kits/multiplayer-2d/code/server/sceneBodies.js +1 -1
  16. package/kits/multiplayer-2d/code/server/world.js +2 -2
  17. package/kits/multiplayer-2d/code/systems/multiplayer.js +14 -0
  18. package/kits/multiplayer-2d/fonts.generated.js +1 -1
  19. package/kits/multiplayer-2d/package-lock.json +1 -1
  20. package/kits/multiplayer-3d/CLAUDE.md +5 -0
  21. package/kits/multiplayer-3d/castle.json +3 -3
  22. package/kits/multiplayer-3d/code/client/avatars.js +42 -9
  23. package/kits/multiplayer-3d/code/server/sceneBodies.js +1 -1
  24. package/kits/multiplayer-3d/code/systems/multiplayer.js +17 -1
  25. package/kits/multiplayer-3d/fonts.generated.js +1 -1
  26. package/kits/multiplayer-3d/package-lock.json +1 -1
  27. package/kits/physics-2d/CLAUDE.md +7 -6
  28. package/kits/physics-2d/behaviors/Collider.jsx +7 -1
  29. package/kits/physics-2d/behaviors/RigidBody.jsx +6 -3
  30. package/kits/physics-2d/castle.json +2 -2
  31. package/kits/physics-2d/editors/SceneEditor.jsx +1 -0
  32. package/kits/physics-2d/editors/SingleEditor.jsx +4 -4
  33. package/kits/physics-2d/editors/{StyleEditor.jsx → ThemeEditor.jsx} +8 -8
  34. package/kits/physics-2d/editors/deckFont.js +2 -2
  35. package/kits/physics-2d/editors/{styleEditor.module.css → themeEditor.module.css} +2 -2
  36. package/kits/physics-2d/engine/avatarArt.js +185 -0
  37. package/kits/physics-2d/engine/files.js +2 -2
  38. package/kits/physics-2d/engine/physics/matterBridge.js +2 -2
  39. package/kits/physics-2d/engine/scene.js +1 -1
  40. package/kits/physics-2d/fonts.generated.js +1 -1
  41. package/kits/physics-2d/package-lock.json +1 -1
  42. package/kits/physics-3d/behaviors/Body.jsx +10 -1
  43. package/kits/physics-3d/behaviors/Door.jsx +5 -0
  44. package/kits/physics-3d/behaviors/Lookable.jsx +3 -0
  45. package/kits/physics-3d/behaviors/Model.jsx +4 -0
  46. package/kits/physics-3d/behaviors/Pickup.jsx +4 -0
  47. package/kits/physics-3d/behaviors/Player.jsx +7 -0
  48. package/kits/physics-3d/behaviors/Shape.jsx +13 -0
  49. package/kits/physics-3d/castle.json +1 -1
  50. package/kits/physics-3d/engine3d/editor3dInspector.jsx +1 -0
  51. package/kits/physics-3d/fonts.generated.js +1 -1
  52. package/kits/physics-3d/package-lock.json +1 -1
  53. package/kits/real-time/castle.json +1 -1
  54. package/kits/real-time/code/client/connection.js +17 -2
  55. package/kits/real-time/code/server/session.js +12 -6
  56. package/kits/real-time/package-lock.json +1 -1
  57. package/kits/turn-based/CLAUDE.md +4 -0
  58. package/kits/turn-based/castle.json +2 -2
  59. package/kits/turn-based/code/behaviors/Board.jsx +15 -5
  60. package/kits/turn-based/code/server/index.js +1 -0
  61. package/kits/turn-based/fonts.generated.js +1 -1
  62. package/kits/turn-based/package-lock.json +1 -1
  63. package/kits/turn-based/room.js +2 -0
  64. package/package.json +3 -1
  65. /package/kits/physics-2d/editors/{styleTheme.js → theme.js} +0 -0
@@ -22,12 +22,12 @@ Do you already know what you want to make, or do you want to figure it out toget
22
22
  - Every actor is an instance of a **blueprint** (`blueprints/*.scene`) — see `## Blueprints` below. Always author the blueprint file yourself and reference it via `"blueprint"`. Don't write inline actors (full `components`, no `blueprint` field): the editor auto-migrates each one into its own new blueprint file on open, one per actor with no dedup, which litters the deck with junk blueprints.
23
23
  - Real game objects and scenery should usually be editable sprites: generate `.sprite` with `npm run draw -- name`, then place it via a `Sprite` component pointing at `drawings/name.sprite`. Dynamic UI/effects stay procedural.
24
24
  - A deck can also hold uploaded media (the Files panel's Upload button): `Sprite` draws image files as well as `.sprite`, and `Video` plays video files. Point their `file` at the deck path (`assets/logo.png`). For audio see `## Sound` below -- sound effects need no component at all.
25
- - The deck's body/UI font is picked in the Style editor (`theme.style`): canvas text reads `scene.font`. A second display face is imported in the behavior that uses it. See `## Deck font` below.
25
+ - The deck's body/UI font is picked in the Theme editor (`theme.style`): canvas text reads `scene.font`. A second display face is imported in the behavior that uses it. See `## Deck font` below.
26
26
  - This kit is plain JavaScript. Use `.jsx` for files with JSX, `.js` otherwise; do not add TypeScript files or a new build step.
27
27
  - Space is reserved by the editor for play/stop; do not bind Space to gameplay.
28
28
  - Two render hooks: `draw(ctx)` is world space and scrolls with `Camera`; `drawUi(ctx)` is screen space (card units, camera-free). On-screen controls, gauges and HUD go in `drawUi`, never `draw`. The React `ui()` hook is deprecated — prefer `drawUi`.
29
29
  - Do not read `engine/`, `editors/`, or built-in behaviors (`Layout.jsx`, `Sprite.jsx`, `Collider.jsx`, `Camera.jsx`, and the physics ones — `RigidBody.jsx`, `Joints.jsx`, `Draggable.jsx`, `Slingshot.jsx`, `AnalogStick.jsx`) to build a game. Their public API is documented below. Forking is the exception: when you're deliberately copying a kit file into the deck to change it — an editor (see `## Editors`) or a behavior you're shadowing — read the original. `engine/` stays off-limits either way.
30
- - An editor is for content FILES the creator keeps coming back to (catalogs, dialogue, level tables) that nothing already opens. Scenes, sprites and styles have editors; per-actor settings belong on behavior props in the inspector — not in a new editor.
30
+ - An editor is for content FILES the creator keeps coming back to (catalogs, dialogue, level tables) that nothing already opens. Scenes, sprites and themes have editors; per-actor settings belong on behavior props in the inspector — not in a new editor.
31
31
  - Details below: `## Behavior shape`, `## Scene file`, `## Blueprints`, `## Built-in behaviors`, `## Creating pixel art`, `## SceneRuntime API`, and `## Input shortcuts`.
32
32
 
33
33
  ## Editors
@@ -440,7 +440,7 @@ Name a sound by its filename without the extension, or by its full deck path. Tw
440
440
 
441
441
  ## Deck font
442
442
 
443
- A physics-2d deck picks its **deck font** in the Style editor — open
443
+ A physics-2d deck picks its **deck font** in the Theme editor — open
444
444
  `theme.style` and choose one of the nine Castle faces, or None. That is the
445
445
  deck's body and UI face: `scene.font` returns it for `draw` / `drawUi`.
446
446
 
@@ -525,7 +525,8 @@ make it move.**
525
525
  - `bodyType: 'dynamic' | 'static' | 'kinematic'`. `dynamic` = simulated
526
526
  (gravity, collisions, forces). `static` = never moves (same as a lone
527
527
  Collider). `kinematic` = unpushable, but you move it by writing its Layout.
528
- - `gravityScale` — multiplies world gravity for this body (`0` = floats).
528
+ - `gravityScale` — multiplies world gravity for this body (`0` = floats;
529
+ default, so newly added bodies don't fall until you opt in).
529
530
  - `drag` — linear damping (air resistance). `angularDrag` — spin damping.
530
531
  - `freezeRotation` — keep it from rotating (stays upright).
531
532
  - `velocityX` / `velocityY` — initial velocity, applied once when play starts.
@@ -649,7 +650,7 @@ see the SceneRuntime API below.
649
650
  ### Recipe: a launch-and-bounce game
650
651
 
651
652
  - Ball blueprint: `Layout` (square), `Sprite`, `Collider {shape:'circle',
652
- bounciness:0.6}`, `RigidBody {bodyType:'dynamic'}`, `Slingshot {}`.
653
+ bounciness:0.6}`, `RigidBody {bodyType:'dynamic', gravityScale:1}`, `Slingshot {}`.
653
654
  - Walls/floor blueprint: `Layout`, `Collider {bounciness:0.4}` (no RigidBody →
654
655
  static).
655
656
  - Goal blueprint: `Collider {isTrigger:true}` + a `Goal` behavior with
@@ -835,7 +836,7 @@ To generate path art, emit an svg of **shapes** rather than per-pixel rects and
835
836
  - `scene.spawnFromBlueprint('blueprints/enemy.scene', { components: { Layout: { x, y } } })` — spawn an instance of a blueprint, same merge semantics as a placed instance. Prefer this over `spawnActor` when you're spawning copies of something that has (or should have) a blueprint, e.g. `scene.spawnFromBlueprint(actor.blueprint, { components: { Layout: { x: actor.components.Layout.x, y: actor.components.Layout.y } } })` to spawn another of the same kind as an existing actor.
836
837
  - `scene.despawnActor(id)` — remove an actor at runtime. Use this; don't `splice` + `delete` by hand.
837
838
  - `scene.sound` — the deck's sound library: `play(name, opts)`, `stopAll()`, `isLoaded`, `whenLoaded`, `has(name)`, `voiceCount`. See `## Sound`.
838
- - `scene.font` — the deck's font as a CSS family, for `ctx.font` in `draw` / `drawUi`. Always usable: the face picked in the Style editor, or a sans stack. See `## Deck font`.
839
+ - `scene.font` — the deck's font as a CSS family, for `ctx.font` in `draw` / `drawUi`. Always usable: the face picked in the Theme editor, or a sans stack. See `## Deck font`.
839
840
  - `scene.status` — string you can set/read for game-state ('playing', 'gameover', ...).
840
841
  - `scene.load(sceneData)` — replace the running scene with the given scene data object.
841
842
  - `scene.readFromFile(name)` — read and parse a scene file (`'gameover.scene'`, `'levels/2.scene'`), returning its scene data. Use this instead of importing a `.scene` file.
@@ -304,7 +304,13 @@ export class Collider {
304
304
  </div>
305
305
 
306
306
  {/* Material (collider-wide) */}
307
- <NumberField label="Density" value={density} onChange={(v) => setComponent({ density: v })} />
307
+ <NumberField
308
+ label="Density"
309
+ value={density}
310
+ min={0}
311
+ step={0.001}
312
+ onChange={(v) => setComponent({ density: v })}
313
+ />
308
314
  <AutoFields
309
315
  defaultProps={Collider.defaultProps}
310
316
  meta={Collider.propertyMeta}
@@ -24,7 +24,9 @@ export class RigidBody {
24
24
 
25
25
  static defaultProps = {
26
26
  bodyType: 'dynamic',
27
- gravityScale: 1,
27
+ // New bodies float until the author opts into gravity — falling immediately
28
+ // on add is the common surprise when placing a dynamic body to push around.
29
+ gravityScale: 0,
28
30
  drag: 0.01,
29
31
  angularDrag: 0,
30
32
  freezeRotation: false,
@@ -44,10 +46,11 @@ export class RigidBody {
44
46
  // `drag`/`angularDrag` are matter's air friction, where values above 1 flip
45
47
  // the velocity sign and destabilize the body -- so 1 is a real ceiling, not a
46
48
  // convenience. `gravityScale` deliberately has none: negative is legitimate
47
- // (it inverts gravity), as is scaling well past 1.
49
+ // (it inverts gravity), as is scaling well past 1. Drag steps at 0.005 so the
50
+ // scrubber lands on the default (0.01) and the finer authored values kits use.
48
51
  static propertyMeta = {
49
52
  gravityScale: { step: 0.1 },
50
- drag: DAMPING,
53
+ drag: { ...DAMPING, step: 0.005 },
51
54
  angularDrag: DAMPING,
52
55
  angularVelocity: { step: 0.1 },
53
56
  };
@@ -59,7 +59,7 @@
59
59
  },
60
60
  {
61
61
  "ext": ".style",
62
- "label": "Style",
62
+ "label": "Theme",
63
63
  "icon": "palette",
64
64
  "editor": "kit",
65
65
  "data": true
@@ -72,7 +72,7 @@
72
72
  "main": "main.jsx",
73
73
  "autoUpdateWhenImported": true,
74
74
  "title": "physics-2d",
75
- "publishedVersion": "2026-09-10T20:09:53.271Z",
75
+ "publishedVersion": "2026-09-10T22:05:01.629Z",
76
76
  "imports": {
77
77
  "castle.base": {
78
78
  "deckId": "yRcmH4_aYllE",
@@ -2303,6 +2303,7 @@ function BehaviorRow({
2303
2303
  component={component}
2304
2304
  setComponent={setComponent}
2305
2305
  override={override}
2306
+ meta={Behavior?.propertyMeta}
2306
2307
  />
2307
2308
  );
2308
2309
  // Layout is core to every actor -- it cannot be removed. A component an
@@ -1,7 +1,7 @@
1
1
  // Single-editor mount for the panel system. The shell (dockview) opens this as
2
2
  // its own iframe via the deck route in main.jsx:
3
3
  // ?file=<path>[&editor=<id>] -> <SingleEditor> (one editor for one file)
4
- // The shell only routes the kit's RICH file types here (scene / pxart / style);
4
+ // The shell only routes the kit's RICH file types here (scene / pxart / theme);
5
5
  // file browsing and the code/text editor are now builtin shell panels.
6
6
 
7
7
  import React, { useCallback, useEffect, useRef, useState } from 'react';
@@ -29,7 +29,7 @@ import { collectAssets } from '../engine/assets';
29
29
  import { MainEditor, styles } from '../engine/ui';
30
30
  import { PxArtEditor } from './PxArtEditor';
31
31
  import { SceneEditor } from './SceneEditor';
32
- import { StyleEditor } from './StyleEditor';
32
+ import { ThemeEditor } from './ThemeEditor';
33
33
 
34
34
  // Debounce per path on top of the SDK's save queue: this decides WHEN a change
35
35
  // is worth writing (a drag shouldn't write every frame), the queue decides that
@@ -308,9 +308,9 @@ export function SingleEditor({ path, editor, editorModule }) {
308
308
  body = (
309
309
  <PxArtEditor path={path} text={text} onChange={onChange} files={files} readOnly={readOnly} />
310
310
  );
311
- } else if (kind === 'style') {
311
+ } else if (kind === 'theme') {
312
312
  body = (
313
- <StyleEditor
313
+ <ThemeEditor
314
314
  path={path}
315
315
  text={text}
316
316
  onChange={onChange}
@@ -1,4 +1,4 @@
1
- // Deck style picker for `theme.style`: a palette and a font.
1
+ // Deck theme picker for `theme.style`: a palette and a font.
2
2
  //
3
3
  // Palettes: official entries come from OFFICIAL_PALETTES; a custom
4
4
  // `{ name, colors }` in the file shows as a read-only selected card.
@@ -19,8 +19,8 @@ import { OFFICIAL_PALETTES, parseThemeData, resolveDeckPalette } from '../engine
19
19
  import { EditorBody } from '../engine/ui';
20
20
  import { writeDeckFontModule } from './deckFont';
21
21
  import { usePreviewFaces } from './fontPreview';
22
- import { applyOfficialFont, applyOfficialPalette } from './styleTheme';
23
- import styles from './styleEditor.module.css';
22
+ import { applyOfficialFont, applyOfficialPalette } from './theme';
23
+ import styles from './themeEditor.module.css';
24
24
 
25
25
  function cardClassName(selected, clickable) {
26
26
  return [styles.card, selected ? styles.cardSelected : '', clickable ? '' : styles.cardStatic]
@@ -30,7 +30,7 @@ function cardClassName(selected, clickable) {
30
30
 
31
31
  // One card, palette or font: a head (name + meta) over whatever the type shows
32
32
  // of itself. A button when it can be picked, a plain div when it can't.
33
- function styleCard({ cardKey, name, meta, preview, selected, onPick }) {
33
+ function themeCard({ cardKey, name, meta, preview, selected, onPick }) {
34
34
  // Inner wrapper is the flex column, not the <button>. WebKit still wraps
35
35
  // button contents in a shrink-to-fit anonymous box, so a flex button never
36
36
  // stretches its children on some iOS versions — the palette strip stays
@@ -70,7 +70,7 @@ function swatchStrip(colors) {
70
70
  }
71
71
 
72
72
  function paletteCard({ palette, selected, badge, onPick }) {
73
- return styleCard({
73
+ return themeCard({
74
74
  cardKey: palette.id ?? 'custom',
75
75
  name: palette.name,
76
76
  meta: (
@@ -92,7 +92,7 @@ function paletteCard({ palette, selected, badge, onPick }) {
92
92
  // arrives it renders in the panel font, which reads as an ordinary label rather
93
93
  // than as a broken card.
94
94
  function fontCard({ face, loaded, selected, onPick }) {
95
- return styleCard({
95
+ return themeCard({
96
96
  cardKey: face.name,
97
97
  name: face.label,
98
98
  preview: (
@@ -161,7 +161,7 @@ function FontSection({ text, onChange, onChangeFile, readOnly }) {
161
161
  code asks for another face.
162
162
  </p>
163
163
  <div className={styles.list}>
164
- {styleCard({
164
+ {themeCard({
165
165
  cardKey: 'none',
166
166
  name: 'None',
167
167
  preview: <div className={styles.specimen}>The quick brown fox</div>,
@@ -181,7 +181,7 @@ function FontSection({ text, onChange, onChangeFile, readOnly }) {
181
181
  );
182
182
  }
183
183
 
184
- export function StyleEditor({ text, onChange, onChangeFile, readOnly = false }) {
184
+ export function ThemeEditor({ text, onChange, onChangeFile, readOnly = false }) {
185
185
  // An editor host too old to hand over `onChangeFile` can still pick a
186
186
  // palette; a font needs a second file written, so it stays read-only there.
187
187
  const canPickFont = Boolean(onChangeFile) && !readOnly;
@@ -24,7 +24,7 @@ const deleteFile = castleSdk.deleteFile ?? null;
24
24
 
25
25
  /** The whole content of `fonts.generated.js` for one face. */
26
26
  export function deckFontModuleSource(name) {
27
- return `// Generated by the Style editor. Do not edit -- picking a font rewrites it.
27
+ return `// Generated by the Theme editor. Do not edit -- picking a font rewrites it.
28
28
  //
29
29
  // \`theme.style\` records that this deck's font is ${name}; this module is what
30
30
  // puts ${name}'s bytes in the published bundle. It has to name the face in a
@@ -39,7 +39,7 @@ export const deckFont = '${name}';
39
39
  /** An empty module, for the SDK too old to delete a file. Renders as no deck
40
40
  * font, same as the file being absent. */
41
41
  function emptyModuleSource() {
42
- return `// Generated by the Style editor. Do not edit.
42
+ return `// Generated by the Theme editor. Do not edit.
43
43
  //
44
44
  // This deck has no font of its own; text renders in the default sans stack.
45
45
  export const deckFont = null;
@@ -1,4 +1,4 @@
1
- /* Deck style picker for theme.style: palette + font. Tokens match
1
+ /* Deck theme picker for theme.style: palette + font. Tokens match
2
2
  engine/ui.module.css. */
3
3
 
4
4
  .root {
@@ -56,7 +56,7 @@
56
56
 
57
57
  .card {
58
58
  /* Block, not flex: WebKit's button anonymous box shrink-wraps flex
59
- children (style editor palette strips on some iOS). appearance wipe
59
+ children (theme editor palette strips on some iOS). appearance wipe
60
60
  matches other kit buttons. */
61
61
  display: block;
62
62
  box-sizing: border-box;
@@ -0,0 +1,185 @@
1
+ // A player's avatar for drawing in a scene, read through `User.get` once per
2
+ // user id and kept for the page. Two pictures: the round one, the photo clipped
3
+ // to a circle with their frame over it, and the face, the photo alone, square,
4
+ // for a surface it should cover edge to edge. A player with no photo has no
5
+ // avatar, and a caller keeps its default look.
6
+ //
7
+ // `avatarCanvas` is for code that draws with a canvas context, like a Box.
8
+ // `avatarSpritePath` and `avatarFacePath` register a picture as image art in
9
+ // `scene.sprites`, so a `Sprite` or a model face can show it by file path.
10
+
11
+ import { User } from 'castle-web-sdk';
12
+ import { imageArt } from './art';
13
+
14
+ const SIZE = 128;
15
+
16
+ // A frame is drawn around a photo two thirds its size, the proportion Castle's
17
+ // own profile views use.
18
+ const FRAMED_INSET = SIZE / 6;
19
+
20
+ // userId -> { canvas, face }, present from the first ask; both fill in when the
21
+ // read completes.
22
+ const avatars = new Map();
23
+
24
+ // The avatar for one user id, or null until it is loaded or when there is none.
25
+ export function avatarCanvas(userId) {
26
+ return entryFor(userId)?.canvas ?? null;
27
+ }
28
+
29
+ // The user's photo alone, square, or null as for `avatarCanvas`.
30
+ export function avatarFace(userId) {
31
+ return entryFor(userId)?.face ?? null;
32
+ }
33
+
34
+ function entryFor(userId) {
35
+ if (!userId) {
36
+ return null;
37
+ }
38
+ let entry = avatars.get(userId);
39
+ if (!entry) {
40
+ entry = { canvas: null, face: null };
41
+ avatars.set(userId, entry);
42
+ void load(userId, entry);
43
+ }
44
+ return entry;
45
+ }
46
+
47
+ // The `scene.sprites` path holding this user's round avatar, or null while there
48
+ // is none. A `Sprite` with this `file` draws it.
49
+ export function avatarSpritePath(scene, userId) {
50
+ return registered(scene, `avatars/${userId}.png`, avatarCanvas(userId));
51
+ }
52
+
53
+ // The same for the square face.
54
+ export function avatarFacePath(scene, userId) {
55
+ return registered(scene, `avatars/${userId}-face.png`, avatarFace(userId));
56
+ }
57
+
58
+ function registered(scene, path, canvas) {
59
+ if (!canvas) {
60
+ return null;
61
+ }
62
+ if (!scene.sprites[path]) {
63
+ scene.sprites[path] = imageArt(path, canvas.toDataURL());
64
+ }
65
+ return path;
66
+ }
67
+
68
+ async function load(userId, entry) {
69
+ try {
70
+ const user = await User.get(userId);
71
+ const [photo, frame] = await Promise.all([loadImage(user?.photoUrl), loadImage(user?.frameUrl)]);
72
+ if (photo) {
73
+ entry.canvas = compose(photo, frame);
74
+ entry.face = square(photo);
75
+ }
76
+ } catch {
77
+ // A failed read leaves the entry without a canvas; the next page asks again.
78
+ }
79
+ }
80
+
81
+ function loadImage(url) {
82
+ if (!url) {
83
+ return Promise.resolve(null);
84
+ }
85
+ return new Promise((resolve) => {
86
+ const image = new Image();
87
+ image.onload = () => resolve(image);
88
+ image.onerror = () => resolve(null);
89
+ image.src = url;
90
+ });
91
+ }
92
+
93
+ // How much of the opaque box is cut from each side, so a round photo's edge and
94
+ // its anti-aliased rim stay off the face.
95
+ const FACE_INSET = 0.12;
96
+
97
+ // The opaque box is never taken smaller than this share of the photo on either
98
+ // axis. Transparency reaching far inward would otherwise blow a few pixels up
99
+ // to the whole face.
100
+ const FACE_MIN = 0.55;
101
+
102
+ // The photo cropped to its opaque pixels and then inset, filling the square.
103
+ // Profile photos are circles on a transparent ground, and a face texture wants
104
+ // no corners and no rim.
105
+ function square(photo) {
106
+ const canvas = document.createElement('canvas');
107
+ canvas.width = SIZE;
108
+ canvas.height = SIZE;
109
+ const ctx = canvas.getContext('2d');
110
+ ctx.drawImage(photo, 0, 0, SIZE, SIZE);
111
+ const box = atLeast(opaqueBox(ctx.getImageData(0, 0, SIZE, SIZE)), SIZE * FACE_MIN, SIZE);
112
+ const inset = Math.round(Math.min(box.width, box.height) * FACE_INSET);
113
+ const face = {
114
+ x: box.x + inset,
115
+ y: box.y + inset,
116
+ width: box.width - 2 * inset,
117
+ height: box.height - 2 * inset,
118
+ };
119
+ if (face.width <= 0 || face.height <= 0) {
120
+ return canvas;
121
+ }
122
+ const source = document.createElement('canvas');
123
+ source.width = SIZE;
124
+ source.height = SIZE;
125
+ source.getContext('2d').drawImage(canvas, 0, 0);
126
+ ctx.clearRect(0, 0, SIZE, SIZE);
127
+ ctx.drawImage(source, face.x, face.y, face.width, face.height, 0, 0, SIZE, SIZE);
128
+ return canvas;
129
+ }
130
+
131
+ // The box grown around its centre to at least `min` on each axis, kept inside
132
+ // `size`.
133
+ function atLeast(box, min, size) {
134
+ const out = { ...box };
135
+ for (const [at, extent] of [
136
+ ['x', 'width'],
137
+ ['y', 'height'],
138
+ ]) {
139
+ if (out[extent] < min) {
140
+ out[at] = Math.round(Math.min(Math.max(0, out[at] + out[extent] / 2 - min / 2), size - min));
141
+ out[extent] = Math.round(min);
142
+ }
143
+ }
144
+ return out;
145
+ }
146
+
147
+ // The smallest box holding every pixel with alpha over a small threshold.
148
+ function opaqueBox({ data, width, height }) {
149
+ let left = width;
150
+ let top = height;
151
+ let right = -1;
152
+ let bottom = -1;
153
+ for (let y = 0; y < height; y += 1) {
154
+ for (let x = 0; x < width; x += 1) {
155
+ if (data[(y * width + x) * 4 + 3] > 16) {
156
+ left = Math.min(left, x);
157
+ top = Math.min(top, y);
158
+ right = Math.max(right, x);
159
+ bottom = Math.max(bottom, y);
160
+ }
161
+ }
162
+ }
163
+ if (right < 0) {
164
+ return { x: 0, y: 0, width, height };
165
+ }
166
+ return { x: left, y: top, width: right - left + 1, height: bottom - top + 1 };
167
+ }
168
+
169
+ function compose(photo, frame) {
170
+ const canvas = document.createElement('canvas');
171
+ canvas.width = SIZE;
172
+ canvas.height = SIZE;
173
+ const ctx = canvas.getContext('2d');
174
+ const inset = frame ? FRAMED_INSET : 0;
175
+ ctx.save();
176
+ ctx.beginPath();
177
+ ctx.arc(SIZE / 2, SIZE / 2, SIZE / 2 - inset, 0, Math.PI * 2);
178
+ ctx.clip();
179
+ ctx.drawImage(photo, inset, inset, SIZE - 2 * inset, SIZE - 2 * inset);
180
+ ctx.restore();
181
+ if (frame) {
182
+ ctx.drawImage(frame, 0, 0, SIZE, SIZE);
183
+ }
184
+ return canvas;
185
+ }
@@ -119,13 +119,13 @@ export function mediaFilesOfKind(kind) {
119
119
  }
120
120
  // Which editor a file opens in when no `editorModule` was resolved. Media
121
121
  // viewing (image / audio / video) lives on castle.base now; this only covers
122
- // types this kit still routes itself (scene / sprite / style). Each extension
122
+ // types this kit still routes itself (scene / sprite / theme). Each extension
123
123
  // gets its own line: legacy tooling matched one `endsWith` per `return`.
124
124
  export function getFileKind(path) {
125
125
  if (path.endsWith('.scene')) return 'scene';
126
126
  if (path.endsWith('.sprite')) return 'pxart';
127
127
  if (path.endsWith('.pxart')) return 'pxart';
128
- if (path.endsWith('.style')) return 'style';
128
+ if (path.endsWith('.style')) return 'theme';
129
129
  if (path.endsWith('.js') || path.endsWith('.jsx')) return 'code';
130
130
  return 'text';
131
131
  }
@@ -147,7 +147,7 @@ export function patchBody(body, actor) {
147
147
  // used the retired `kind: 'pickup'` label are still honored as sensors.
148
148
  body.isSensor = Boolean(collider.isTrigger || collider.kind === 'pickup');
149
149
  body.frictionAir = rb?.drag ?? 0.01;
150
- body.plugin.gravityScale = rb?.gravityScale ?? 1;
150
+ body.plugin.gravityScale = rb?.gravityScale ?? 0;
151
151
  body.plugin.angularDrag = rb?.angularDrag ?? 0;
152
152
  // Density sets mass (= density x area) -- DYNAMIC bodies only. On a static or
153
153
  // kinematic body, setDensity would replace the infinite mass that setStatic
@@ -172,7 +172,7 @@ export function syncBodyToPoint(body, point, angleDeg) {
172
172
  export function applyWorldGravity(bodies, gravity) {
173
173
  for (const body of bodies) {
174
174
  if (body.isStatic || body.isSleeping) continue;
175
- const scale = (body.plugin.gravityScale ?? 1) * gravity.scale;
175
+ const scale = (body.plugin.gravityScale ?? 0) * gravity.scale;
176
176
  body.force.x += body.mass * gravity.x * scale;
177
177
  body.force.y += body.mass * gravity.y * scale;
178
178
  }
@@ -66,7 +66,7 @@ export class SceneRuntime {
66
66
  this.camera = undefined;
67
67
  this.status = undefined;
68
68
  // The deck's font as a CSS font-family value, always usable: the face
69
- // picked in the Style editor, or a plain sans stack when there is none.
69
+ // picked in the Theme editor, or a plain sans stack when there is none.
70
70
  // For `draw` / `drawUi` hooks -- `ctx.font = `20px ${scene.font}`` -- since
71
71
  // canvas takes a family name, not a class. Prefer `drawUi` for HUD text;
72
72
  // the deprecated React `ui` overlay still inherits this face via CSS.
@@ -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.26",
35
+ "version": "0.4.28",
36
36
  "dev": true,
37
37
  "devDependencies": {
38
38
  "eslint": "^9.0.0",
@@ -6,6 +6,7 @@
6
6
  import React from 'react';
7
7
  import { SelectField } from '@imports/castle.physics-2d/engine/ui';
8
8
  import { AutoFields, overrideProps } from '@imports/castle.physics-2d/engine/autoInspector';
9
+ import { POSITIVE } from '@imports/castle.physics-2d/engine/propertyRanges';
9
10
 
10
11
  const TYPE_OPTIONS = [
11
12
  { value: 'fixed', label: 'Fixed' },
@@ -25,6 +26,7 @@ function BodyInspector({ component, setComponent, override }) {
25
26
  />
26
27
  <AutoFields
27
28
  defaultProps={Body.defaultProps}
29
+ meta={Body.propertyMeta}
28
30
  component={component}
29
31
  setComponent={setComponent}
30
32
  exclude={['type']}
@@ -41,10 +43,17 @@ export class Body {
41
43
  type: 'fixed', // 'fixed' | 'dynamic' | 'kinematic'
42
44
  friction: 0.6,
43
45
  restitution: 0,
44
- gravityScale: 1,
46
+ // Match physics-2d RigidBody: newly added dynamics float until opted in.
47
+ gravityScale: 0,
45
48
  lockRotations: false,
46
49
  };
47
50
 
51
+ static propertyMeta = {
52
+ friction: POSITIVE,
53
+ restitution: POSITIVE,
54
+ gravityScale: { step: 0.1 },
55
+ };
56
+
48
57
  constructor(props) {
49
58
  this.props = props;
50
59
  }
@@ -4,6 +4,11 @@
4
4
  export class Door {
5
5
  static behaviorName = 'Door';
6
6
  static defaultProps = { range: 3, slide: 2.1, speed: 2.5 };
7
+ static propertyMeta = {
8
+ range: { min: 0, step: 0.1 },
9
+ slide: { min: 0, step: 0.1 },
10
+ speed: { min: 0, step: 0.1 },
11
+ };
7
12
 
8
13
  constructor(props) {
9
14
  this.props = props;
@@ -4,6 +4,9 @@
4
4
  export class Lookable {
5
5
  static behaviorName = 'Lookable';
6
6
  static defaultProps = { text: '', range: 4 };
7
+ static propertyMeta = {
8
+ range: { min: 0, step: 0.5 },
9
+ };
7
10
 
8
11
  constructor(props) {
9
12
  this.props = props;
@@ -7,6 +7,10 @@
7
7
  export class Model {
8
8
  static behaviorName = 'Model';
9
9
  static defaultProps = { file: '', scale: 1, yOffset: 0 };
10
+ static propertyMeta = {
11
+ scale: { min: 0, step: 0.05 },
12
+ yOffset: { step: 0.05 },
13
+ };
10
14
 
11
15
  constructor(props) {
12
16
  this.props = props;
@@ -14,6 +14,10 @@ function pickupKey(scene, actor) {
14
14
  export class Pickup {
15
15
  static behaviorName = 'Pickup';
16
16
  static defaultProps = { bob: 0.12, spin: 120 };
17
+ static propertyMeta = {
18
+ bob: { min: 0, step: 0.01 },
19
+ spin: { min: 0, step: 5 },
20
+ };
17
21
 
18
22
  constructor(props) {
19
23
  this.props = props;
@@ -112,6 +112,13 @@ function findLookText(scene, actor, t, facing) {
112
112
  export class Player {
113
113
  static behaviorName = 'Player';
114
114
  static defaultProps = { speed: 6, turnSpeed: 2.4, jumpSpeed: 8, camDistance: 9, camHeight: 7 };
115
+ static propertyMeta = {
116
+ speed: { min: 0, step: 0.5 },
117
+ turnSpeed: { min: 0, step: 0.1 },
118
+ jumpSpeed: { min: 0, step: 0.5 },
119
+ camDistance: { min: 0, step: 0.5 },
120
+ camHeight: { min: 0, step: 0.5 },
121
+ };
115
122
 
116
123
  constructor(props) {
117
124
  this.props = props;
@@ -8,6 +8,7 @@
8
8
  import React from 'react';
9
9
  import { SelectField, TextField } from '@imports/castle.physics-2d/engine/ui';
10
10
  import { AutoFields, overrideProps } from '@imports/castle.physics-2d/engine/autoInspector';
11
+ import { POSITIVE, UNIT } from '@imports/castle.physics-2d/engine/propertyRanges';
11
12
 
12
13
  const KIND_OPTIONS = [
13
14
  { value: 'box', label: 'Box' },
@@ -39,6 +40,7 @@ function ShapeInspector({ component, setComponent, override }) {
39
40
  )}
40
41
  <AutoFields
41
42
  defaultProps={Shape.defaultProps}
43
+ meta={Shape.propertyMeta}
42
44
  component={component}
43
45
  setComponent={setComponent}
44
46
  exclude={['kind', 'art', 'artRepeat']}
@@ -69,6 +71,17 @@ export class Shape {
69
71
  solid: false,
70
72
  };
71
73
 
74
+ static propertyMeta = {
75
+ width: POSITIVE,
76
+ height: POSITIVE,
77
+ depth: POSITIVE,
78
+ radius: POSITIVE,
79
+ roughness: UNIT,
80
+ metalness: UNIT,
81
+ clearcoat: UNIT,
82
+ emissiveIntensity: POSITIVE,
83
+ };
84
+
72
85
  constructor(props) {
73
86
  this.props = props;
74
87
  }
@@ -69,5 +69,5 @@
69
69
  "autoUpdateWhenImported": true,
70
70
  "deckId": "JH0SclbPVP0y",
71
71
  "cardId": "eXfAaUdbSU5A",
72
- "publishedVersion": "2026-09-10T20:10:28.916Z"
72
+ "publishedVersion": "2026-09-10T22:05:11.276Z"
73
73
  }
@@ -332,6 +332,7 @@ function BehaviorPanel({
332
332
  component={component}
333
333
  setComponent={setComponent}
334
334
  override={override}
335
+ meta={Behavior?.propertyMeta}
335
336
  />
336
337
  );
337
338
  // Transform is core to every actor; a component an instance only has via
@@ -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