castle-web-cli 0.4.166 → 0.4.167

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 (85) hide show
  1. package/dist/agent.d.ts +1 -2
  2. package/dist/agent.js +22 -13
  3. package/dist/api.js +16 -1
  4. package/dist/imports.js +19 -4
  5. package/dist/localPaths.d.ts +2 -0
  6. package/dist/localPaths.js +6 -0
  7. package/dist/serve.js +5 -1
  8. package/dist/serveSecurity.d.ts +12 -0
  9. package/dist/serveSecurity.js +42 -0
  10. package/dist/shell/assets/{index-CG9TrvCm.js → index-C8M-p00y.js} +36 -36
  11. package/dist/shell/index.html +1 -1
  12. package/kits/base/castle.json +64 -2
  13. package/kits/base/editors/ImageViewer.js +259 -0
  14. package/kits/base/editors/MediaPlayer.js +90 -0
  15. package/kits/base/editors/imageViewer.css +95 -0
  16. package/kits/base/editors/mediaPlayer.css +58 -0
  17. package/kits/multiplayer-2d/castle.json +1 -1
  18. package/kits/multiplayer-2d/package-lock.json +4 -1
  19. package/kits/multiplayer-3d/castle.json +1 -1
  20. package/kits/multiplayer-3d/package-lock.json +1 -1
  21. package/kits/physics-2d/behaviors/Joints.jsx +11 -5
  22. package/kits/physics-2d/behaviors/Sprite.jsx +6 -21
  23. package/kits/physics-2d/castle.json +1 -61
  24. package/kits/physics-2d/editors/BlueprintLibrary.jsx +8 -68
  25. package/kits/physics-2d/editors/SingleEditor.jsx +3 -10
  26. package/kits/physics-2d/editors/editorRegistry.jsx +36 -2
  27. package/kits/physics-2d/engine/art.js +25 -1
  28. package/kits/physics-2d/engine/artThumbnail.jsx +92 -0
  29. package/kits/physics-2d/engine/files.js +4 -15
  30. package/kits/physics-2d/engine/spriteField.jsx +176 -0
  31. package/kits/physics-2d/engine/ui.module.css +136 -0
  32. package/kits/physics-2d/package-lock.json +5 -0
  33. package/kits/physics-2d/pnpm-lock.yaml +96 -3
  34. package/kits/physics-3d/castle.json +1 -1
  35. package/kits/physics-3d/package-lock.json +1 -1
  36. package/kits/physics-3d/pnpm-lock.yaml +88 -3
  37. package/kits/real-time/castle.json +1 -1
  38. package/kits/real-time/package.json +3 -0
  39. package/kits/turn-based/castle.json +1 -1
  40. package/kits/turn-based/package-lock.json +1 -1
  41. package/package.json +1 -1
  42. package/kits/base/node_modules/.package-lock.json +0 -28
  43. package/kits/base/node_modules/castle-web-fonts/README.md +0 -114
  44. package/kits/base/node_modules/castle-web-fonts/dist/catalog.d.ts +0 -18
  45. package/kits/base/node_modules/castle-web-fonts/dist/catalog.js +0 -142
  46. package/kits/base/node_modules/castle-web-fonts/dist/face.d.ts +0 -1
  47. package/kits/base/node_modules/castle-web-fonts/dist/face.js +0 -52
  48. package/kits/base/node_modules/castle-web-fonts/dist/faces/Bore.d.ts +0 -3
  49. package/kits/base/node_modules/castle-web-fonts/dist/faces/Bore.js +0 -10
  50. package/kits/base/node_modules/castle-web-fonts/dist/faces/CourierPrime.d.ts +0 -3
  51. package/kits/base/node_modules/castle-web-fonts/dist/faces/CourierPrime.js +0 -10
  52. package/kits/base/node_modules/castle-web-fonts/dist/faces/DMSans.d.ts +0 -3
  53. package/kits/base/node_modules/castle-web-fonts/dist/faces/DMSans.js +0 -10
  54. package/kits/base/node_modules/castle-web-fonts/dist/faces/Glacier.d.ts +0 -3
  55. package/kits/base/node_modules/castle-web-fonts/dist/faces/Glacier.js +0 -10
  56. package/kits/base/node_modules/castle-web-fonts/dist/faces/HelicoCentrica.d.ts +0 -3
  57. package/kits/base/node_modules/castle-web-fonts/dist/faces/HelicoCentrica.js +0 -10
  58. package/kits/base/node_modules/castle-web-fonts/dist/faces/Piazzolla.d.ts +0 -3
  59. package/kits/base/node_modules/castle-web-fonts/dist/faces/Piazzolla.js +0 -10
  60. package/kits/base/node_modules/castle-web-fonts/dist/faces/Synco.d.ts +0 -3
  61. package/kits/base/node_modules/castle-web-fonts/dist/faces/Synco.js +0 -10
  62. package/kits/base/node_modules/castle-web-fonts/dist/faces/Tektur.d.ts +0 -3
  63. package/kits/base/node_modules/castle-web-fonts/dist/faces/Tektur.js +0 -10
  64. package/kits/base/node_modules/castle-web-fonts/dist/faces/YatraOne.d.ts +0 -3
  65. package/kits/base/node_modules/castle-web-fonts/dist/faces/YatraOne.js +0 -10
  66. package/kits/base/node_modules/castle-web-fonts/dist/index.d.ts +0 -16
  67. package/kits/base/node_modules/castle-web-fonts/dist/index.js +0 -15
  68. package/kits/base/node_modules/castle-web-fonts/licenses/CC-BY-ND-4.0.txt +0 -393
  69. package/kits/base/node_modules/castle-web-fonts/licenses/FONTS.md +0 -64
  70. package/kits/base/node_modules/castle-web-fonts/licenses/OFL-1.1.txt +0 -95
  71. package/kits/base/node_modules/castle-web-fonts/package.json +0 -47
  72. package/kits/base/node_modules/castle-web-fonts/woff2/Bore.woff2 +0 -0
  73. package/kits/base/node_modules/castle-web-fonts/woff2/CourierPrime.woff2 +0 -0
  74. package/kits/base/node_modules/castle-web-fonts/woff2/DMSans.woff2 +0 -0
  75. package/kits/base/node_modules/castle-web-fonts/woff2/Glacier.woff2 +0 -0
  76. package/kits/base/node_modules/castle-web-fonts/woff2/HelicoCentrica.woff2 +0 -0
  77. package/kits/base/node_modules/castle-web-fonts/woff2/Piazzolla.woff2 +0 -0
  78. package/kits/base/node_modules/castle-web-fonts/woff2/SOURCE.txt +0 -2
  79. package/kits/base/node_modules/castle-web-fonts/woff2/Synco.woff2 +0 -0
  80. package/kits/base/node_modules/castle-web-fonts/woff2/Tektur.woff2 +0 -0
  81. package/kits/base/node_modules/castle-web-fonts/woff2/YatraOne.woff2 +0 -0
  82. package/kits/physics-2d/editors/ImageViewer.jsx +0 -206
  83. package/kits/physics-2d/editors/MediaPlayer.jsx +0 -57
  84. package/kits/physics-2d/editors/mediaFile.js +0 -31
  85. package/kits/physics-2d/editors/mediaViewer.module.css +0 -128
@@ -1,5 +1,6 @@
1
1
  import React, { useState } from 'react';
2
- import { Panel, SelectField, NumberField, CheckboxField, Button, FileField } from '../engine/ui';
2
+ import { Panel, SelectField, NumberField, CheckboxField, Button } from '../engine/ui';
3
+ import { SpriteField } from '../engine/spriteField';
3
4
  import { centerOf, inActorWorldSpace } from '../engine/physics/controls';
4
5
  import { JOINT_TYPES } from '../engine/physics/joints';
5
6
  import { drawJointArt } from '../engine/physics/jointArt';
@@ -75,9 +76,12 @@ export class Joints {
75
76
  });
76
77
  }
77
78
 
78
- static Inspector({ component, setComponent, override, beginPick, pickActive, files }) {
79
+ static Inspector({ component, setComponent, override, beginPick, pickActive, sprites }) {
79
80
  const list = Array.isArray(component.list) ? component.list : [];
80
- const spriteFiles = Object.keys(files ?? {}).filter((f) => f.endsWith('.sprite') || f.endsWith('.pxart'));
81
+ // Joint art is drawings-only (tile/stretch along a link); images stay out.
82
+ const spriteFiles = Object.keys(sprites ?? {})
83
+ .filter((f) => f.endsWith('.sprite') || f.endsWith('.pxart'))
84
+ .sort();
81
85
  // Which entry's target-pick is live. Combined with the editor's global
82
86
  // `pickActive` so a cancel (Esc/empty click) clears every entry's state.
83
87
  const [pickingIndex, setPickingIndex] = useState(null);
@@ -95,6 +99,7 @@ export class Joints {
95
99
  key={i}
96
100
  index={i}
97
101
  joint={joint}
102
+ sprites={sprites}
98
103
  spriteFiles={spriteFiles}
99
104
  active={pickActive && pickingIndex === i}
100
105
  onPickToggle={() => onPickToggle(i)}
@@ -114,7 +119,7 @@ function defaultJoint() {
114
119
 
115
120
  // One editable link in the list: type, target picker, type-specific fields, and
116
121
  // how it renders in play (line / hidden / sprite art along the joint).
117
- function JointEntry({ index, joint, spriteFiles, active, onPickToggle, onPatch, onRemove }) {
122
+ function JointEntry({ index, joint, sprites, spriteFiles, active, onPickToggle, onPatch, onRemove }) {
118
123
  const [showAnchors, setShowAnchors] = useState(false);
119
124
  const type = joint.type ?? 'spring';
120
125
  const isPivot = type === 'weld';
@@ -180,10 +185,11 @@ function JointEntry({ index, joint, spriteFiles, active, onPickToggle, onPatch,
180
185
  <SelectField label="Show in play" value={render} onChange={(v) => onPatch({ render: v })} options={RENDER_MODES} />
181
186
  {render === 'sprite' ? (
182
187
  <>
183
- <FileField
188
+ <SpriteField
184
189
  label="Art"
185
190
  value={joint.sprite || ''}
186
191
  onChange={(v) => onPatch({ sprite: v })}
192
+ sprites={sprites}
187
193
  files={spriteFiles}
188
194
  allowEmpty
189
195
  />
@@ -1,10 +1,10 @@
1
1
  import { resolveDeckFile } from 'castle-web-sdk';
2
2
  import React from 'react';
3
- import { artFrameCount, isImageArt, renderArtFrame } from '../engine/art';
3
+ import { artFrameCount, isImageArt, renderDisplayFrame } from '../engine/art';
4
4
  import { isVectorRenderer } from '../engine/pxart';
5
- import { renderSmoothCompositeFrame, renderSmoothSpriteFrame } from '../engine/pxartSmooth';
6
5
  import { spriteDestRect } from '../engine/spriteGeometry';
7
- import { FileField, Panel, SelectField } from '../engine/ui';
6
+ import { Panel, SelectField } from '../engine/ui';
7
+ import { SpriteField } from '../engine/spriteField';
8
8
  import { AutoFields, overrideProps } from '../engine/autoInspector';
9
9
  import { parseTint, tintCanvas } from './tint';
10
10
 
@@ -166,14 +166,13 @@ export class Sprite {
166
166
  }
167
167
 
168
168
  static Inspector({ component, setComponent, sprites, override }) {
169
- const spriteFiles = getSpriteFiles(sprites);
170
169
  return (
171
170
  <Panel title="Sprite" overridden={override?.anyOverridden()}>
172
- <FileField
171
+ <SpriteField
173
172
  label="File"
174
173
  value={component.file}
175
174
  onChange={(file) => setComponent({ file })}
176
- files={spriteFiles}
175
+ sprites={sprites}
177
176
  {...overrideProps(override, 'file')}
178
177
  />
179
178
  <SelectField
@@ -270,15 +269,7 @@ const MAX_CACHE_LONG_SIDE = 2048;
270
269
  function frameSource(sprite, frameIndex) {
271
270
  if (isImageArt(sprite)) return sprite.ready ? sprite.image : null;
272
271
  const canvas = document.createElement('canvas');
273
- if (isVectorRenderer(sprite.renderer)) {
274
- renderSmoothCompositeFrame(sprite, frameIndex, canvas);
275
- return canvas;
276
- }
277
- if (sprite.cornerRadius > 0) {
278
- renderSmoothSpriteFrame(sprite, frameIndex, canvas, { cornerRadius: sprite.cornerRadius });
279
- return canvas;
280
- }
281
- return renderArtFrame(sprite, frameIndex, canvas) ? canvas : null;
272
+ return renderDisplayFrame(sprite, frameIndex, canvas) ? canvas : null;
282
273
  }
283
274
 
284
275
  function sourceSize(source) {
@@ -431,9 +422,3 @@ function advanceAnim(state, sprite, sequence, tag, dt) {
431
422
  }
432
423
  state.frameIndex = sequence[state.pos];
433
424
  }
434
-
435
- // Everything the deck can point a Sprite at: `.pxart` drawings and image files
436
- // alike, which is exactly what the resolved art map holds.
437
- function getSpriteFiles(sprites) {
438
- return Object.keys(sprites ?? {}).sort();
439
- }
@@ -63,66 +63,6 @@
63
63
  "icon": "file",
64
64
  "editor": "kit",
65
65
  "data": true
66
- },
67
- {
68
- "ext": ".png",
69
- "label": "Image",
70
- "icon": "image",
71
- "editor": "kit"
72
- },
73
- {
74
- "ext": ".jpg",
75
- "label": "Image",
76
- "icon": "image",
77
- "editor": "kit"
78
- },
79
- {
80
- "ext": ".jpeg",
81
- "label": "Image",
82
- "icon": "image",
83
- "editor": "kit"
84
- },
85
- {
86
- "ext": ".gif",
87
- "label": "Image",
88
- "icon": "image",
89
- "editor": "kit"
90
- },
91
- {
92
- "ext": ".webp",
93
- "label": "Image",
94
- "icon": "image",
95
- "editor": "kit"
96
- },
97
- {
98
- "ext": ".svg",
99
- "label": "Image",
100
- "icon": "image",
101
- "editor": "kit"
102
- },
103
- {
104
- "ext": ".mp3",
105
- "label": "Audio",
106
- "icon": "music",
107
- "editor": "kit"
108
- },
109
- {
110
- "ext": ".wav",
111
- "label": "Audio",
112
- "icon": "music",
113
- "editor": "kit"
114
- },
115
- {
116
- "ext": ".m4a",
117
- "label": "Audio",
118
- "icon": "music",
119
- "editor": "kit"
120
- },
121
- {
122
- "ext": ".mp4",
123
- "label": "Video",
124
- "icon": "film",
125
- "editor": "kit"
126
66
  }
127
67
  ],
128
68
  "defaultPlayFile": "scenes/main.scene"
@@ -132,7 +72,7 @@
132
72
  "main": "main.jsx",
133
73
  "autoUpdateWhenImported": true,
134
74
  "title": "physics-2d",
135
- "publishedVersion": "2026-09-04T18:30:34.333Z",
75
+ "publishedVersion": "2026-09-05T00:07:35.112Z",
136
76
  "imports": {
137
77
  "castle.base": {
138
78
  "deckId": "yRcmH4_aYllE",
@@ -1,5 +1,5 @@
1
- import React, { useEffect, useRef, useState } from 'react';
2
- import { isImageArt, onArtLoaded, renderArtFrame } from '../engine/art';
1
+ import React, { useRef, useState } from 'react';
2
+ import { ArtThumbnail } from '../engine/artThumbnail';
3
3
  import { screenToCard } from '../engine/scene';
4
4
  import { countBlueprintInstances, listBlueprints } from '../engine/blueprint';
5
5
  import { ConfirmDialog, ContextMenu, cx, Icon, styles, useWheelToHorizontal } from '../engine/ui';
@@ -235,77 +235,17 @@ function BlueprintDragPreview({ preview, sprites }) {
235
235
  );
236
236
  }
237
237
 
238
- // Asked of the CANVAS, not the source art: counting non-transparent grid chars
239
- // would call a path-drawn sprite blank. Skipped for image art, whose native
240
- // resolution is whatever was uploaded.
241
- function rendersNothing(sprite, canvas) {
242
- if (isImageArt(sprite)) return false;
243
- try {
244
- const ctx = canvas.getContext('2d', { willReadFrequently: true });
245
- const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height);
246
- for (let i = 3; i < data.length; i += 4) if (data[i] !== 0) return false;
247
- return true;
248
- } catch {
249
- // Tainted or zero-sized canvas: assume it draws something rather than
250
- // mislabeling real art as blank.
251
- return false;
252
- }
253
- }
254
-
255
- // A blueprint's slot art, falling back to its NAME when there is nothing to
256
- // show -- no Sprite, a file that doesn't resolve, or art that is entirely
257
- // transparent (a "Draw new" sprite nobody has drawn in yet). Without it a belt
258
- // of not-yet-drawn actors is a row of identical placeholders.
259
- // `showName={false}` for callers that already print the name right beside the
260
- // thumbnail (the creation modal's tiles) -- otherwise a sprite-less blueprint
261
- // renders its name inside the tile AND under it, reading as "GoalGoal".
238
+ // A blueprint's slot art delegates to ArtThumbnail so the belt and the
239
+ // sprite picker share blank-name fallback. `showName={false}` for callers that
240
+ // already print the name right beside the thumbnail (the creation modal's
241
+ // tiles) -- otherwise a sprite-less blueprint renders its name inside the tile
242
+ // AND under it, reading as "GoalGoal".
262
243
  export function BlueprintThumbnail({ blueprint, sprites, preview = false, showName = true }) {
263
- const canvasRef = useRef(null);
264
244
  const spritePath = blueprint.components?.Sprite?.file;
265
245
  const sprite = spritePath ? sprites?.[spritePath] : null;
266
- // Thumbnails draw on change, not on a frame loop, so an image that finishes
267
- // decoding after this rendered has to say so or the slot stays blank.
268
- const [loadTick, setLoadTick] = useState(0);
269
- const [blank, setBlank] = useState(false);
270
- useEffect(() => onArtLoaded(() => setLoadTick((tick) => tick + 1)), []);
271
- useEffect(() => {
272
- const canvas = canvasRef.current;
273
- if (!canvas || !sprite) return;
274
- const drew = renderArtFrame(sprite, 0, canvas);
275
- // `false` means "not here yet" (an image still loading), not blank.
276
- if (drew !== false) setBlank(rendersNothing(sprite, canvas));
277
- }, [sprite, loadTick]);
278
246
  const name = showName && typeof blueprint.name === 'string' ? blueprint.name.trim() : '';
279
- // The canvas stays mounted underneath, so art arriving later clears this.
280
- // The inner span is load-bearing, not decoration: bare text inside a flex
281
- // container becomes an anonymous item sized to max-content, so it never
282
- // "overflows a line" and `overflow-wrap` never fires -- a long single-word
283
- // name silently clipped at both edges instead of wrapping. A real child can
284
- // be held to the box's width, which is what gives the wrap something to
285
- // break against.
286
- const label = name ? (
287
- <span className={cx(styles.bpSlotName, preview && styles.bpSlotNamePreview)} title={name}>
288
- <span className={styles.bpSlotNameText}>{name}</span>
289
- </span>
290
- ) : (
291
- <span className={styles.bpSlotFallback}>
292
- <Icon name="clone" />
293
- </span>
294
- );
295
- if (!sprite) return label;
296
247
  return (
297
- <span className={styles.bpSlotArt}>
298
- <canvas
299
- ref={canvasRef}
300
- className={cx(
301
- styles.bpSlotThumb,
302
- preview && styles.bpPreviewThumb,
303
- blank && styles.bpSlotThumbHidden
304
- )}
305
- aria-hidden="true"
306
- />
307
- {blank ? label : null}
308
- </span>
248
+ <ArtThumbnail art={sprite} name={name} preview={preview} fallbackIcon="clone" />
309
249
  );
310
250
  }
311
251
 
@@ -27,8 +27,6 @@ import { hasEditorModule, loadEditorModule } from './editorRegistry';
27
27
  import { useLiveDeckFiles } from '../engine/liveReload';
28
28
  import { collectAssets } from '../engine/assets';
29
29
  import { MainEditor, styles } from '../engine/ui';
30
- import { ImageViewer } from './ImageViewer';
31
- import { MediaPlayer } from './MediaPlayer';
32
30
  import { PxArtEditor } from './PxArtEditor';
33
31
  import { SceneEditor } from './SceneEditor';
34
32
  import { StyleEditor } from './StyleEditor';
@@ -321,15 +319,10 @@ export function SingleEditor({ path, editor, editorModule }) {
321
319
  readOnly={readOnly}
322
320
  />
323
321
  );
324
- } else if (kind === 'image') {
325
- // Viewers read their file's BYTES over the serve, not the text file map --
326
- // `files` holds source, and an image decoded as utf-8 is nothing.
327
- body = <ImageViewer path={path} />;
328
- } else if (kind === 'audio' || kind === 'video') {
329
- body = <MediaPlayer path={path} kind={kind} />;
330
322
  }
331
- // No `else`: code/text files are handled by the builtin shell code editor, so
332
- // the shell never routes them here. `body` stays null for any other kind.
323
+ // Image / audio / video are declared by castle.base and land in the
324
+ // DeclaredEditor branch above via editorModule. No `else`: code/text files
325
+ // are handled by the builtin shell code editor.
333
326
  return (
334
327
  <div className={styles.panelBare} style={{ position: 'relative' }}>
335
328
  <MainEditor>{body}</MainEditor>
@@ -9,6 +9,8 @@
9
9
  //
10
10
  // Editors live under `editors/` by convention; that is what makes a third
11
11
  // party's editor reachable from here without this kit knowing it exists.
12
+ import { createElement, useEffect, useRef } from 'react';
13
+
12
14
  const modules = {
13
15
  ...import.meta.glob('/imports/*/editors/**/*.{jsx,js}'),
14
16
  ...import.meta.glob('/imports/*/code/editors/**/*.{jsx,js}'),
@@ -20,7 +22,35 @@ export function hasEditorModule(path) {
20
22
  return Boolean(path && modules[`/${path}`]);
21
23
  }
22
24
 
23
- // Load one, returning its default export. Throws a legible error rather than
25
+ // Base-kit (and bare-deck) editors default-export `mount(el, props)`. This kit's
26
+ // DeclaredEditor expects a React component. Arity distinguishes them: a mount
27
+ // takes the host element first; a React editor takes props only.
28
+ function wrapMountEditor(mount) {
29
+ return function MountEditorBridge(props) {
30
+ const ref = useRef(null);
31
+ const propsRef = useRef(props);
32
+ propsRef.current = props;
33
+ useEffect(() => {
34
+ const node = ref.current;
35
+ if (!node) return undefined;
36
+ node.textContent = '';
37
+ // Mount once per open file. Callers that need live prop updates should
38
+ // read them themselves; remounting on every onChange identity would wipe
39
+ // viewers that hold zoom/pan state.
40
+ const cleanup = mount(node, propsRef.current);
41
+ return () => {
42
+ if (typeof cleanup === 'function') cleanup();
43
+ else node.textContent = '';
44
+ };
45
+ }, [props.path, mount]);
46
+ return createElement('div', {
47
+ ref,
48
+ style: { display: 'flex', flex: 1, width: '100%', height: '100%', minHeight: 0 },
49
+ });
50
+ };
51
+ }
52
+
53
+ // Load one, returning a React component. Throws a legible error rather than
24
54
  // letting the caller see `undefined is not a function` three frames later.
25
55
  export async function loadEditorModule(path) {
26
56
  const load = modules[`/${path}`];
@@ -30,7 +60,11 @@ export async function loadEditorModule(path) {
30
60
  );
31
61
  }
32
62
  const mod = await load();
33
- const Editor = mod.default ?? mod.Editor;
63
+ const Editor = mod.default ?? mod.Editor ?? mod.mount;
34
64
  if (!Editor) throw new Error(`"${path}" has no default export to render as an editor.`);
65
+ // Mount editors (base kit): (el, props) => void|cleanup. React editors: (props) => node.
66
+ if (typeof Editor === 'function' && Editor.length >= 2) {
67
+ return wrapMountEditor(Editor);
68
+ }
35
69
  return Editor;
36
70
  }
@@ -11,7 +11,8 @@
11
11
  // really do differ -- rendering a frame, and how many frames there are -- are
12
12
  // `renderArtFrame` / `artFrameCount` here.
13
13
 
14
- import { frameCount, renderSpriteFrame } from './pxart';
14
+ import { displayFinish, frameCount, renderSpriteFrame } from './pxart';
15
+ import { renderSmoothCompositeFrame, renderSmoothSpriteFrame } from './pxartSmooth';
15
16
 
16
17
  // An image is one frame, held for as long as anyone asks (a still).
17
18
  const STILL_FRAME = Object.freeze({ durationMs: 0 });
@@ -89,6 +90,10 @@ export function artFrameCount(art) {
89
90
  // Draw one frame into `canvas` at the art's native resolution, resizing it to
90
91
  // match. Returns false when there was nothing to draw yet (an image still
91
92
  // loading), so callers don't cache the blank result.
93
+ //
94
+ // Pixel finish only — 1px/cell. Prefer `renderDisplayFrame` anywhere the art
95
+ // is shown to a person (belt, picker, scene), so Smooth / vector sprites keep
96
+ // their finish instead of looking blocky.
92
97
  export function renderArtFrame(art, frameIndex, canvas) {
93
98
  if (!isImageArt(art)) {
94
99
  renderSpriteFrame(art, frameIndex, canvas);
@@ -103,3 +108,22 @@ export function renderArtFrame(art, frameIndex, canvas) {
103
108
  ctx.drawImage(art.image, 0, 0);
104
109
  return true;
105
110
  }
111
+
112
+ // Same contract as `renderArtFrame`, but honours the sprite's display finish
113
+ // (vector / rounded corners → supersampled smooth render). Use this for any
114
+ // on-screen thumbnail or blit that should match what Sprite draws in the scene.
115
+ export function renderDisplayFrame(art, frameIndex, canvas) {
116
+ if (isImageArt(art)) return renderArtFrame(art, frameIndex, canvas);
117
+ if (!art) return false;
118
+ const finish = displayFinish(art);
119
+ if (finish.vector) {
120
+ renderSmoothCompositeFrame(art, frameIndex, canvas);
121
+ return true;
122
+ }
123
+ if (finish.cornerRadius > 0) {
124
+ renderSmoothSpriteFrame(art, frameIndex, canvas, { cornerRadius: finish.cornerRadius });
125
+ return true;
126
+ }
127
+ renderSpriteFrame(art, frameIndex, canvas);
128
+ return true;
129
+ }
@@ -0,0 +1,92 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import { isImageArt, onArtLoaded, renderDisplayFrame } from './art';
3
+ import { displayFinish } from './pxart';
4
+ import { cx, Icon, styles } from './ui';
5
+
6
+ // Asked of the CANVAS, not the source art: counting non-transparent grid chars
7
+ // would call a path-drawn sprite blank. Skipped for image art, whose native
8
+ // resolution is whatever was uploaded.
9
+ export function artRendersNothing(art, canvas) {
10
+ if (isImageArt(art)) return false;
11
+ try {
12
+ // Must match the options the render path already used to create the
13
+ // context. Asking for `{ willReadFrequently: true }` after a plain
14
+ // `getContext('2d')` returns null in browsers, and the catch below would
15
+ // then treat every transparent sprite as "has art" — blank names never
16
+ // appear in the belt or the picker.
17
+ const ctx = canvas.getContext('2d');
18
+ if (!ctx || canvas.width < 1 || canvas.height < 1) return true;
19
+ const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height);
20
+ for (let i = 3; i < data.length; i += 4) if (data[i] !== 0) return false;
21
+ return true;
22
+ } catch {
23
+ // Tainted canvas: assume it draws something rather than mislabeling real
24
+ // art as blank.
25
+ return false;
26
+ }
27
+ }
28
+
29
+ // Shared art thumb used by the blueprint belt and the sprite picker. Falls
30
+ // back to `name` when there is no art, or the art is entirely transparent
31
+ // (a "Draw new" sprite nobody has drawn in yet) — same treatment the belt
32
+ // has always given undrawn blueprints.
33
+ //
34
+ // `className` sizes the outer slot (belt defaults to 36px via CSS; the picker
35
+ // stretches it to fill its tile). `fallbackIcon` is what shows when there is
36
+ // neither art nor a name (`clone` on the belt, `image` in the picker).
37
+ export function ArtThumbnail({
38
+ art,
39
+ name = '',
40
+ className,
41
+ preview = false,
42
+ fallbackIcon = 'image',
43
+ }) {
44
+ const canvasRef = useRef(null);
45
+ const [loadTick, setLoadTick] = useState(0);
46
+ const [blank, setBlank] = useState(false);
47
+ useEffect(() => onArtLoaded(() => setLoadTick((tick) => tick + 1)), []);
48
+ useEffect(() => {
49
+ const canvas = canvasRef.current;
50
+ if (!canvas || !art) return;
51
+ const drew = renderDisplayFrame(art, 0, canvas);
52
+ // `false` means "not here yet" (an image still loading), not blank.
53
+ if (drew !== false) setBlank(artRendersNothing(art, canvas));
54
+ }, [art, loadTick]);
55
+
56
+ // The inner span is load-bearing, not decoration: bare text inside a flex
57
+ // container becomes an anonymous item sized to max-content, so it never
58
+ // "overflows a line" and `overflow-wrap` never fires -- a long single-word
59
+ // name silently clipped at both edges instead of wrapping. A real child can
60
+ // be held to the box's width, which is what gives the wrap something to
61
+ // break against.
62
+ const label = name ? (
63
+ <span className={cx(styles.bpSlotName, preview && styles.bpSlotNamePreview)} title={name}>
64
+ <span className={styles.bpSlotNameText}>{name}</span>
65
+ </span>
66
+ ) : (
67
+ <span className={styles.bpSlotFallback}>
68
+ <Icon name={fallbackIcon} />
69
+ </span>
70
+ );
71
+
72
+ if (!art) {
73
+ return className ? <span className={className}>{label}</span> : label;
74
+ }
75
+
76
+ const smooth = displayFinish(art).smooth;
77
+ return (
78
+ <span className={cx(styles.bpSlotArt, className)}>
79
+ <canvas
80
+ ref={canvasRef}
81
+ className={cx(
82
+ styles.bpSlotThumb,
83
+ smooth && styles.bpSlotThumbSmooth,
84
+ preview && styles.bpPreviewThumb,
85
+ blank && styles.bpSlotThumbHidden
86
+ )}
87
+ aria-hidden="true"
88
+ />
89
+ {blank ? label : null}
90
+ </span>
91
+ );
92
+ }
@@ -117,26 +117,15 @@ export function mediaFilesOfKind(kind) {
117
117
  const matches = { image: isImageFile, audio: isAudioFile, video: isVideoFile }[kind];
118
118
  return Object.keys(initialMedia).filter((path) => matches(path));
119
119
  }
120
- // Which editor a file opens in. The image / audio kinds are VIEWERS -- an
121
- // uploaded asset is content this kit shows but doesn't edit (art you can edit is
122
- // `.pxart`). Each extension gets its own line: the serve parses this function to
123
- // learn which extensions the kit owns an editor for, and only matches one
124
- // `endsWith` per `return`.
120
+ // Which editor a file opens in when no `editorModule` was resolved. Media
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
123
+ // gets its own line: legacy tooling matched one `endsWith` per `return`.
125
124
  export function getFileKind(path) {
126
125
  if (path.endsWith('.scene')) return 'scene';
127
126
  if (path.endsWith('.sprite')) return 'pxart';
128
127
  if (path.endsWith('.pxart')) return 'pxart';
129
128
  if (path.endsWith('.style')) return 'style';
130
- if (path.endsWith('.png')) return 'image';
131
- if (path.endsWith('.jpg')) return 'image';
132
- if (path.endsWith('.jpeg')) return 'image';
133
- if (path.endsWith('.gif')) return 'image';
134
- if (path.endsWith('.webp')) return 'image';
135
- if (path.endsWith('.svg')) return 'image';
136
- if (path.endsWith('.mp3')) return 'audio';
137
- if (path.endsWith('.wav')) return 'audio';
138
- if (path.endsWith('.m4a')) return 'audio';
139
- if (path.endsWith('.mp4')) return 'video';
140
129
  if (path.endsWith('.js') || path.endsWith('.jsx')) return 'code';
141
130
  return 'text';
142
131
  }