castle-web-cli 0.4.119 → 0.4.120

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.
@@ -10,8 +10,8 @@
10
10
  <link rel="icon" type="image/png" sizes="32x32" href="/__castle/ide/favicon-32x32.png" />
11
11
  <link rel="icon" type="image/png" sizes="16x16" href="/__castle/ide/favicon-16x16.png" />
12
12
  <link rel="icon" href="/__castle/ide/favicon.ico" sizes="any" />
13
- <script type="module" crossorigin src="/__castle/ide/assets/index-WtgFx1s8.js"></script>
14
- <link rel="stylesheet" crossorigin href="/__castle/ide/assets/index-Y6cJRRCX.css">
13
+ <script type="module" crossorigin src="/__castle/ide/assets/index-Cxmq9Sed.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/__castle/ide/assets/index-BkJ87APM.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
@@ -192,10 +192,14 @@ make it move.**
192
192
  (`mode`, `width`/`height`, `offsetX`/`offsetY`, `debug`) it has:
193
193
  - `shape: 'box' | 'circle'` — the physics shape (default `box`). A circle uses
194
194
  `radius`, or half the smaller collider dimension if `radius` is 0.
195
- - A collider can hold **multiple shapes** (a compound body) — edit them in the
196
- inspector's shape list. A **box shape takes an `angle`** (degrees), so one
197
- actor can have an angled collider (e.g. a skateboard: a flat deck box plus an
198
- upturned nose and tail box) instead of gluing separate actors together.
195
+ - A collider can hold **multiple shapes** (a compound body) — add and edit them
196
+ in the inspector's shape list. Each entry in `shapes` is `{ type, ... }` sized
197
+ in **fractions of the Layout box** (`0.5` = centered, `1` = full width), so a
198
+ shape keeps its proportions when the actor is resized: a box takes
199
+ `x`/`y`/`w`/`h`, a circle `x`/`y`/`r`.
200
+ - A **box shape takes an `angle`** (degrees), so one actor can have an angled
201
+ collider (e.g. a skateboard: a flat deck box plus an upturned nose and tail
202
+ box) instead of gluing separate actors together.
199
203
  - `isTrigger` — a **sensor**: detects overlaps (fires collision callbacks) but
200
204
  does **not** block. Use for pickups, goals, zones.
201
205
  - `bounciness` — restitution, 0 (dead) to ~1 (very bouncy), can exceed 1.
@@ -123,5 +123,5 @@
123
123
  "main": "main.jsx",
124
124
  "autoUpdateWhenImported": true,
125
125
  "title": "physics-2d",
126
- "publishedVersion": "2026-08-08T19:00:43.040Z"
126
+ "publishedVersion": "2026-08-12T18:15:12.718Z"
127
127
  }
@@ -442,8 +442,16 @@ export function SceneEditor({
442
442
  sceneData,
443
443
  files
444
444
  );
445
- void writeFile(drawingFile.path, drawingFile.text);
446
- void writeFile(blueprintFile.path, blueprintFile.text);
445
+ // Add both generated files to the live map before committing the instance.
446
+ // Otherwise another + click can mint the same numbered paths while the fs
447
+ // watcher's asynchronous echo of these direct writes is still pending.
448
+ if (onChangeFile) {
449
+ onChangeFile(drawingFile.path, drawingFile.text);
450
+ onChangeFile(blueprintFile.path, blueprintFile.text);
451
+ } else {
452
+ void writeFile(drawingFile.path, drawingFile.text);
453
+ void writeFile(blueprintFile.path, blueprintFile.text);
454
+ }
447
455
  history.commit(serialize(next));
448
456
  onSelectActorIds([newId]);
449
457
  };
@@ -1675,6 +1683,7 @@ function BlueprintInspector({
1675
1683
  selectedActor={templateActor}
1676
1684
  rawActor={templateActor}
1677
1685
  files={files}
1686
+ sprites={sprites}
1678
1687
  onSetComponent={(actorId, behaviorName, nextProps) => onSetComponent(behaviorName, nextProps)}
1679
1688
  onAddBehavior={(actorId, behaviorName) => onAddBehavior(behaviorName)}
1680
1689
  onRemoveBehavior={(actorId, behaviorName) => onRemoveBehavior(behaviorName)}
@@ -271,15 +271,36 @@ export function PalettePopover({ open, anchorRef, onClose, children }) {
271
271
 
272
272
  useLayoutEffect(() => {
273
273
  if (!open || !anchorRef.current) return;
274
- const anchor = anchorRef.current.getBoundingClientRect();
275
- const popoverWidth = 280;
276
- const margin = 8;
277
- let left = anchor.left - popoverWidth - margin;
278
- if (left < margin) left = anchor.right + margin;
279
- let top = anchor.top;
280
- const maxTop = window.innerHeight - margin;
281
- if (top + 320 > maxTop) top = Math.max(margin, maxTop - 320);
282
- setPosition({ top, left });
274
+
275
+ function positionPopover() {
276
+ const anchor = anchorRef.current.getBoundingClientRect();
277
+ const rect = popoverRef.current?.getBoundingClientRect();
278
+ const margin = 8;
279
+ const width = rect?.width ?? 280;
280
+ const height = rect?.height ?? 320;
281
+ const maxLeft = Math.max(margin, window.innerWidth - width - margin);
282
+ const maxTop = Math.max(margin, window.innerHeight - height - margin);
283
+ const leftSide = anchor.left - width - margin;
284
+ const rightSide = anchor.right + margin;
285
+ // Prefer the side with room, then clamp a too-wide picker inside this
286
+ // iframe rather than sending it beyond either viewport edge.
287
+ const left =
288
+ leftSide >= margin
289
+ ? leftSide
290
+ : rightSide <= maxLeft
291
+ ? rightSide
292
+ : Math.min(maxLeft, Math.max(margin, anchor.left + anchor.width / 2 - width / 2));
293
+ const top = Math.min(maxTop, Math.max(margin, anchor.top));
294
+ setPosition({ top, left });
295
+ }
296
+
297
+ positionPopover();
298
+ window.addEventListener('resize', positionPopover);
299
+ window.addEventListener('scroll', positionPopover, true);
300
+ return () => {
301
+ window.removeEventListener('resize', positionPopover);
302
+ window.removeEventListener('scroll', positionPopover, true);
303
+ };
283
304
  }, [open, anchorRef]);
284
305
 
285
306
  useEffect(() => {
@@ -31,6 +31,8 @@ const SHOW_PER_FRAME_DURATION = false;
31
31
  // re-renders the count input, so this is a trivial, easily-revertible toggle.
32
32
  const SHOW_ONION_RANGE = false;
33
33
  const ONION_RANGE = 1;
34
+ const TIMELINE_LONG_PRESS_MS = 500;
35
+ const TIMELINE_LONG_PRESS_MOVE_TOLERANCE = 10;
34
36
 
35
37
  // faClapperboard ships in Font Awesome 6, but this kit is pinned to FA5 (see
36
38
  // package.json), which doesn't export it. Rather than silently swap in a
@@ -328,26 +330,58 @@ function TimelineGrid({ sprite, text, selection, actions }) {
328
330
  const [convertConfirm, setConvertConfirm] = useState(null);
329
331
  const closeMenu = useCallback(() => setMenu(null), []);
330
332
 
331
- // Each opener stops the browser's native menu, anchors at the cursor, and
332
- // stashes just enough to rebuild items from the latest props at render time
333
- // (so relocated actions always run against current sprite/selection state).
333
+ // A timeline entity is selected independently by its relevant axis: a layer
334
+ // by layer index, a frame by frame index, and a cel by the complete pair.
335
+ // First tap selects; a second tap on that same entity opens its action menu.
336
+ // A touch long-press always selects its target and opens the same menu.
337
+ const selectTarget = useCallback(
338
+ (target) => {
339
+ if (target.kind === 'layer') actions.selectCell(target.layerIndex, selection.frameIndex);
340
+ else if (target.kind === 'frame') actions.selectCell(selection.layerIndex, target.frameIndex);
341
+ else actions.selectCell(target.layerIndex, target.frameIndex);
342
+ },
343
+ [actions, selection.frameIndex, selection.layerIndex]
344
+ );
345
+ const targetIsSelected = useCallback(
346
+ (target) => {
347
+ if (target.kind === 'layer') return selection.layerIndex === target.layerIndex;
348
+ if (target.kind === 'frame') return selection.frameIndex === target.frameIndex;
349
+ return selection.layerIndex === target.layerIndex && selection.frameIndex === target.frameIndex;
350
+ },
351
+ [selection.frameIndex, selection.layerIndex]
352
+ );
353
+ const openMenu = useCallback(
354
+ (target, event) => {
355
+ selectTarget(target);
356
+ const rect = event.currentTarget?.getBoundingClientRect?.();
357
+ const x = Number.isFinite(event.clientX) ? event.clientX : rect?.left + rect?.width / 2;
358
+ const y = Number.isFinite(event.clientY) ? event.clientY : rect?.top + rect?.height / 2;
359
+ setMenu({ ...target, x: x ?? 0, y: y ?? 0 });
360
+ },
361
+ [selectTarget]
362
+ );
363
+ const press = useTimelineItemPress(openMenu);
364
+ const activateTarget = (target, event) => {
365
+ if (isTimelineNestedControl(event) || press.consumeClick(target)) return;
366
+ if (targetIsSelected(target)) openMenu(target, event);
367
+ else selectTarget(target);
368
+ };
369
+ // Right-click keeps the desktop path and deliberately opens its target even
370
+ // when it was not already selected.
334
371
  const openLayerMenu = (event, layerIndex) => {
335
372
  event.preventDefault();
336
373
  event.stopPropagation();
337
- setMenu({ kind: 'layer', x: event.clientX, y: event.clientY, layerIndex });
374
+ openMenu({ kind: 'layer', layerIndex }, event);
338
375
  };
339
376
  const openFrameMenu = (event, frameIndex) => {
340
377
  event.preventDefault();
341
378
  event.stopPropagation();
342
- setMenu({ kind: 'frame', x: event.clientX, y: event.clientY, frameIndex });
379
+ openMenu({ kind: 'frame', frameIndex }, event);
343
380
  };
344
381
  const openCellMenu = (event, layerIndex, frameIndex) => {
345
382
  event.preventDefault();
346
383
  event.stopPropagation();
347
- // Link/Unlink are bound to the active cell, so select this one first; the
348
- // menu re-renders with rebuilt actions that target (layerIndex, frameIndex).
349
- actions.selectCell(layerIndex, frameIndex);
350
- setMenu({ kind: 'cell', x: event.clientX, y: event.clientY, layerIndex, frameIndex });
384
+ openMenu({ kind: 'cell', layerIndex, frameIndex }, event);
351
385
  };
352
386
 
353
387
  return (
@@ -361,7 +395,8 @@ function TimelineGrid({ sprite, text, selection, actions }) {
361
395
  index={f}
362
396
  globalMs={sprite.defaultDurationMs}
363
397
  active={f === selection.frameIndex}
364
- onSelect={() => actions.selectCell(selection.layerIndex, f)}
398
+ onActivate={(event) => activateTarget({ kind: 'frame', frameIndex: f }, event)}
399
+ onPointerDown={(event) => press.onPointerDown(event, { kind: 'frame', frameIndex: f })}
365
400
  onDuration={(ms) => actions.setFrameDuration(f, ms)}
366
401
  onContextMenu={(event) => openFrameMenu(event, f)}
367
402
  />
@@ -373,7 +408,8 @@ function TimelineGrid({ sprite, text, selection, actions }) {
373
408
  layer={sprite.layers[li]}
374
409
  index={li}
375
410
  active={li === selection.layerIndex}
376
- onSelect={() => actions.selectCell(li, selection.frameIndex)}
411
+ onActivate={(event) => activateTarget({ kind: 'layer', layerIndex: li }, event)}
412
+ onPointerDown={(event) => press.onPointerDown(event, { kind: 'layer', layerIndex: li })}
377
413
  actions={actions}
378
414
  onContextMenu={openLayerMenu}
379
415
  />
@@ -385,7 +421,8 @@ function TimelineGrid({ sprite, text, selection, actions }) {
385
421
  layerIndex={li}
386
422
  frameIndex={f}
387
423
  active={li === selection.layerIndex && f === selection.frameIndex}
388
- onSelect={() => actions.selectCell(li, f)}
424
+ onActivate={(event) => activateTarget({ kind: 'cell', layerIndex: li, frameIndex: f }, event)}
425
+ onPointerDown={(event) => press.onPointerDown(event, { kind: 'cell', layerIndex: li, frameIndex: f })}
389
426
  onContextMenu={(event) => openCellMenu(event, li, f)}
390
427
  />
391
428
  ))}
@@ -425,6 +462,93 @@ function TimelineGrid({ sprite, text, selection, actions }) {
425
462
  );
426
463
  }
427
464
 
465
+ function targetKey(target) {
466
+ if (target.kind === 'layer') return `layer:${target.layerIndex}`;
467
+ if (target.kind === 'frame') return `frame:${target.frameIndex}`;
468
+ return `cell:${target.layerIndex}:${target.frameIndex}`;
469
+ }
470
+
471
+ // Form controls inside a selectable timeline header keep their direct behavior;
472
+ // pressing the layer visibility toggle or a duration input must not arm a menu.
473
+ function isTimelineNestedControl(event) {
474
+ if (!(event.target instanceof Element) || event.target === event.currentTarget) return false;
475
+ return Boolean(event.target.closest('input, select, textarea, [data-timeline-control]'));
476
+ }
477
+
478
+ // Touch-only long press. Scroll intent cancels it after a small move threshold;
479
+ // the subsequent synthetic click is consumed after a successful hold so it
480
+ // doesn't immediately close/reopen the action menu.
481
+ function useTimelineItemPress(openMenu) {
482
+ const openMenuRef = useRef(openMenu);
483
+ const pressRef = useRef(null);
484
+ const suppressClickRef = useRef(null);
485
+ openMenuRef.current = openMenu;
486
+
487
+ useEffect(() => {
488
+ const clearPress = () => {
489
+ const press = pressRef.current;
490
+ if (!press) return null;
491
+ window.clearTimeout(press.timer);
492
+ pressRef.current = null;
493
+ return press;
494
+ };
495
+ const onPointerMove = (event) => {
496
+ const press = pressRef.current;
497
+ if (!press || press.pointerId !== event.pointerId || press.opened) return;
498
+ if (Math.hypot(event.clientX - press.startX, event.clientY - press.startY) <= TIMELINE_LONG_PRESS_MOVE_TOLERANCE) return;
499
+ clearPress();
500
+ };
501
+ const onPointerEnd = (event) => {
502
+ const press = pressRef.current;
503
+ if (!press || press.pointerId !== event.pointerId) return;
504
+ const ended = clearPress();
505
+ if (ended?.opened) suppressClickRef.current = targetKey(ended.target);
506
+ };
507
+ window.addEventListener('pointermove', onPointerMove);
508
+ window.addEventListener('pointerup', onPointerEnd);
509
+ window.addEventListener('pointercancel', onPointerEnd);
510
+ return () => {
511
+ clearPress();
512
+ window.removeEventListener('pointermove', onPointerMove);
513
+ window.removeEventListener('pointerup', onPointerEnd);
514
+ window.removeEventListener('pointercancel', onPointerEnd);
515
+ };
516
+ }, []);
517
+
518
+ const onPointerDown = useCallback((event, target) => {
519
+ if (event.pointerType !== 'touch' || event.button !== 0 || isTimelineNestedControl(event)) return;
520
+ const prior = pressRef.current;
521
+ if (prior) window.clearTimeout(prior.timer);
522
+ const menuEvent = {
523
+ clientX: event.clientX,
524
+ clientY: event.clientY,
525
+ currentTarget: event.currentTarget,
526
+ };
527
+ const press = {
528
+ pointerId: event.pointerId,
529
+ target,
530
+ startX: event.clientX,
531
+ startY: event.clientY,
532
+ opened: false,
533
+ timer: 0,
534
+ };
535
+ press.timer = window.setTimeout(() => {
536
+ if (pressRef.current !== press) return;
537
+ press.opened = true;
538
+ openMenuRef.current(target, menuEvent);
539
+ }, TIMELINE_LONG_PRESS_MS);
540
+ pressRef.current = press;
541
+ }, []);
542
+
543
+ const consumeClick = useCallback((target) => {
544
+ if (suppressClickRef.current !== targetKey(target)) return false;
545
+ suppressClickRef.current = null;
546
+ return true;
547
+ }, []);
548
+
549
+ return { onPointerDown, consumeClick };
550
+ }
551
+
428
552
  // Cursor-anchored right-click menu. Reuses the scene editor's "Arrange" menu
429
553
  // chrome (the shared `selArrangeMenu`/`selArrangeItem` classes from
430
554
  // engine/ui.module.css) for visual consistency, switching to fixed positioning
@@ -588,12 +712,14 @@ function cellMenuItems(menu, sprite, actions) {
588
712
  ];
589
713
  }
590
714
 
591
- function FrameHeader({ frame, index, globalMs, active, onSelect, onDuration, onContextMenu }) {
715
+ function FrameHeader({ frame, index, globalMs, active, onActivate, onPointerDown, onDuration, onContextMenu }) {
592
716
  return (
593
717
  <div
594
718
  className={active ? tl.frameHead + ' ' + tl.frameHeadActive : tl.frameHead}
719
+ onPointerDown={onPointerDown}
720
+ onClick={onActivate}
595
721
  onContextMenu={onContextMenu}>
596
- <button type="button" className={tl.frameNum} onClick={onSelect} style={btnReset}>
722
+ <button type="button" className={tl.frameNum} style={btnReset}>
597
723
  {index + 1}
598
724
  </button>
599
725
  {SHOW_PER_FRAME_DURATION ? (
@@ -681,11 +807,12 @@ function FaGlyph({ icon, className = tl.visGlyph }) {
681
807
  );
682
808
  }
683
809
 
684
- function LayerHeader({ layer, index, active, onSelect, actions, onContextMenu }) {
810
+ function LayerHeader({ layer, index, active, onActivate, onPointerDown, actions, onContextMenu }) {
685
811
  return (
686
812
  <div
687
813
  className={active ? tl.layerHead + ' ' + tl.layerHeadActive : tl.layerHead}
688
- onClick={onSelect}
814
+ onPointerDown={onPointerDown}
815
+ onClick={onActivate}
689
816
  onContextMenu={(event) => onContextMenu(event, index)}>
690
817
  <div className={tl.layerTopRow}>
691
818
  <span
@@ -710,6 +837,7 @@ function LayerHeader({ layer, index, active, onSelect, actions, onContextMenu })
710
837
  />
711
838
  <button
712
839
  type="button"
840
+ data-timeline-control
713
841
  className={layer.visible ? tl.visBtn : tl.visBtn + ' ' + tl.visOff}
714
842
  title={layer.visible ? 'Hide layer' : 'Show layer'}
715
843
  onClick={(event) => {
@@ -723,7 +851,7 @@ function LayerHeader({ layer, index, active, onSelect, actions, onContextMenu })
723
851
  );
724
852
  }
725
853
 
726
- function CellThumb({ sprite, text, layerIndex, frameIndex, active, onSelect, onContextMenu }) {
854
+ function CellThumb({ sprite, text, layerIndex, frameIndex, active, onActivate, onPointerDown, onContextMenu }) {
727
855
  const canvasRef = useRef(null);
728
856
  const layer = sprite.layers[layerIndex];
729
857
  const kind = cellKind(layer, frameIndex);
@@ -743,7 +871,8 @@ function CellThumb({ sprite, text, layerIndex, frameIndex, active, onSelect, onC
743
871
  type="button"
744
872
  className={active ? tl.cell + ' ' + tl.cellActive : tl.cell}
745
873
  title={`Layer ${layerIndex + 1}, frame ${frameIndex + 1} (${kind})`}
746
- onClick={onSelect}
874
+ onPointerDown={onPointerDown}
875
+ onClick={onActivate}
747
876
  onContextMenu={onContextMenu}>
748
877
  {kind === 'empty' ? (
749
878
  <span className={tl.empty} />
@@ -1797,6 +1797,7 @@
1797
1797
  z-index: 200;
1798
1798
  width: min(280px, calc(100vw - 16px));
1799
1799
  max-height: min(420px, calc(100vh - 16px));
1800
+ box-sizing: border-box;
1800
1801
  overflow: auto;
1801
1802
  padding: 12px 14px 14px;
1802
1803
  border: 1px solid var(--castle-inspector-border);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-cli",
3
- "version": "0.4.119",
3
+ "version": "0.4.120",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "castle-web": "./dist/index.js"