castle-web-cli 0.4.176 → 0.4.178

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 (41) hide show
  1. package/dist/agent-failures.js +4 -4
  2. package/dist/agent-prompts.d.ts +9 -1
  3. package/dist/agent-prompts.js +15 -2
  4. package/dist/agent.js +98 -34
  5. package/dist/deckLocatorShape.d.ts +16 -0
  6. package/dist/deckLocatorShape.js +28 -0
  7. package/dist/editorConfig.d.ts +1 -0
  8. package/dist/serve.js +3 -1
  9. package/dist/shell/assets/index-C890YbXX.css +1 -0
  10. package/dist/shell/assets/index-z6shfW4S.js +447 -0
  11. package/dist/shell/index.html +2 -2
  12. package/kits/base/CLAUDE.md +3 -0
  13. package/kits/base/castle.json +21 -11
  14. package/kits/physics-2d/CLAUDE.md +41 -8
  15. package/kits/physics-2d/behaviors/AnalogStick.jsx +80 -4
  16. package/kits/physics-2d/behaviors/Slingshot.jsx +14 -2
  17. package/kits/physics-2d/behaviors/Sprite.jsx +17 -8
  18. package/kits/physics-2d/behaviors/Style.jsx +270 -0
  19. package/kits/physics-2d/behaviors/Text.jsx +213 -0
  20. package/kits/physics-2d/behaviors/Video.jsx +8 -4
  21. package/kits/physics-2d/blueprints/text.scene +12 -0
  22. package/kits/physics-2d/castle.json +10 -6
  23. package/kits/physics-2d/editors/SceneEditor.jsx +41 -0
  24. package/kits/physics-2d/editors/deckFont.js +65 -55
  25. package/kits/physics-2d/editors/fontPreview.js +6 -38
  26. package/kits/physics-2d/editors/pixelInspector.jsx +4 -154
  27. package/kits/physics-2d/engine/blueprint.js +28 -0
  28. package/kits/physics-2d/engine/fonts.js +125 -23
  29. package/kits/physics-2d/engine/paletteField.jsx +235 -0
  30. package/kits/physics-2d/engine/physics/controls.js +5 -81
  31. package/kits/physics-2d/engine/popoverDismiss.js +17 -0
  32. package/kits/physics-2d/engine/scene.js +12 -3
  33. package/kits/physics-2d/engine/spriteField.jsx +2 -15
  34. package/kits/physics-2d/engine/tap.js +90 -0
  35. package/kits/physics-2d/engine/text.js +94 -0
  36. package/kits/physics-2d/engine/ui.jsx +20 -0
  37. package/kits/physics-2d/engine/ui.module.css +10 -2
  38. package/kits/physics-3d/castle.json +4 -2
  39. package/package.json +7 -3
  40. package/dist/shell/assets/index-BWOEraUy.js +0 -447
  41. package/dist/shell/assets/index-BkVF1OXc.css +0 -1
@@ -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-BWOEraUy.js"></script>
14
- <link rel="stylesheet" crossorigin href="/__castle/ide/assets/index-BkVF1OXc.css">
13
+ <script type="module" crossorigin src="/__castle/ide/assets/index-z6shfW4S.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/__castle/ide/assets/index-C890YbXX.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
@@ -122,6 +122,9 @@ bottleneck.
122
122
  - `unsupported` — a refusal: why this deck must NOT be built on this
123
123
  format, shown when someone tries to upload one. Only for formats you
124
124
  recognize and reject.
125
+ - `width` — preferred card width in px when the shell opens this type's
126
+ editor in a new pane. A layout hint: the shell caps it to the window
127
+ and ignores it on phones. Absent means the shell's default.
125
128
 
126
129
  2. **Write the module** at the declared path. It is handed the file and a way
127
130
  to save it; how it renders is yours.
@@ -12,61 +12,71 @@
12
12
  "ext": ".png",
13
13
  "label": "Image",
14
14
  "icon": "file-image",
15
- "editor": "editors/ImageViewer.js"
15
+ "editor": "editors/ImageViewer.js",
16
+ "width": 480
16
17
  },
17
18
  {
18
19
  "ext": ".jpg",
19
20
  "label": "Image",
20
21
  "icon": "file-image",
21
- "editor": "editors/ImageViewer.js"
22
+ "editor": "editors/ImageViewer.js",
23
+ "width": 480
22
24
  },
23
25
  {
24
26
  "ext": ".jpeg",
25
27
  "label": "Image",
26
28
  "icon": "file-image",
27
- "editor": "editors/ImageViewer.js"
29
+ "editor": "editors/ImageViewer.js",
30
+ "width": 480
28
31
  },
29
32
  {
30
33
  "ext": ".gif",
31
34
  "label": "Image",
32
35
  "icon": "file-image",
33
- "editor": "editors/ImageViewer.js"
36
+ "editor": "editors/ImageViewer.js",
37
+ "width": 480
34
38
  },
35
39
  {
36
40
  "ext": ".webp",
37
41
  "label": "Image",
38
42
  "icon": "file-image",
39
- "editor": "editors/ImageViewer.js"
43
+ "editor": "editors/ImageViewer.js",
44
+ "width": 480
40
45
  },
41
46
  {
42
47
  "ext": ".svg",
43
48
  "label": "Image",
44
49
  "icon": "file-image",
45
- "editor": "editors/ImageViewer.js"
50
+ "editor": "editors/ImageViewer.js",
51
+ "width": 480
46
52
  },
47
53
  {
48
54
  "ext": ".mp3",
49
55
  "label": "Audio",
50
56
  "icon": "file-audio",
51
- "editor": "editors/MediaPlayer.js"
57
+ "editor": "editors/MediaPlayer.js",
58
+ "width": 360
52
59
  },
53
60
  {
54
61
  "ext": ".wav",
55
62
  "label": "Audio",
56
63
  "icon": "file-audio",
57
- "editor": "editors/MediaPlayer.js"
64
+ "editor": "editors/MediaPlayer.js",
65
+ "width": 360
58
66
  },
59
67
  {
60
68
  "ext": ".m4a",
61
69
  "label": "Audio",
62
70
  "icon": "file-audio",
63
- "editor": "editors/MediaPlayer.js"
71
+ "editor": "editors/MediaPlayer.js",
72
+ "width": 360
64
73
  },
65
74
  {
66
75
  "ext": ".mp4",
67
76
  "label": "Video",
68
77
  "icon": "file-video",
69
- "editor": "editors/MediaPlayer.js"
78
+ "editor": "editors/MediaPlayer.js",
79
+ "width": 560
70
80
  }
71
81
  ]
72
82
  },
@@ -74,7 +84,7 @@
74
84
  "title": "base",
75
85
  "deckId": "yRcmH4_aYllE",
76
86
  "cardId": "zkFdingmIm3m",
77
- "publishedVersion": "2026-09-10T23:34:50.309Z",
87
+ "publishedVersion": "2026-09-11T20:21:04.335Z",
78
88
  "provides": [
79
89
  "castle-web-sdk"
80
90
  ]
@@ -22,11 +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
+ - **Text** — place the kit preset `@imports/castle.physics-2d/blueprints/text.scene`, or add a `Text` component (optional `font`; chrome via sibling `Style`). Dynamic text is `actor.components.Text.text = …`. A sibling `onTap(actor, scene)` fires when that actor is tapped — no extra behavior.
25
26
  - 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
27
  - This kit is plain JavaScript. Use `.jsx` for files with JSX, `.js` otherwise; do not add TypeScript files or a new build step.
27
28
  - Space is reserved by the editor for play/stop; do not bind Space to gameplay.
28
29
  - 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
- - 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
+ - Do not read `engine/`, `editors/`, or built-in behaviors (`Layout.jsx`, `Sprite.jsx`, `Text.jsx`, `Style.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
31
  - 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
32
  - Details below: `## Behavior shape`, `## Scene file`, `## Blueprints`, `## Built-in behaviors`, `## Creating pixel art`, `## SceneRuntime API`, and `## Input shortcuts`.
32
33
 
@@ -74,6 +75,9 @@ These are ALL the fields that exist — do not invent others:
74
75
  - `unsupported` — a refusal: why this deck must NOT be built on this
75
76
  format, shown when someone tries to upload one. Only for formats you
76
77
  recognize and reject.
78
+ - `width` — preferred card width in px when the shell opens this type's
79
+ editor in a new pane. A layout hint: the shell caps it to the window
80
+ and ignores it on phones. Absent means the shell's default.
77
81
 
78
82
  2. **Write the module** at the declared path, default-exporting a component:
79
83
 
@@ -291,6 +295,10 @@ export class MyThing {
291
295
  // For letting go of anything held outside your own props.
292
296
  onDisable(actor, scene) {}
293
297
  onEnable(actor, scene) {}
298
+
299
+ // Optional. Fired on pointer release inside this actor (Collider, else
300
+ // Layout box). No extra component — implementing the method is the opt-in.
301
+ onTap(actor, scene) {}
294
302
  }
295
303
  ```
296
304
 
@@ -383,12 +391,16 @@ Template `components` keys are behavior names (the `static behaviorName`); add a
383
391
  - **Never write inline actors** (full `components`, no `blueprint` field). They aren't a lighter-weight alternative: the editor auto-migrates every such actor into its OWN new blueprint file the first time a human opens the scene — mechanically, one blueprint per actor, no dedup. That migration is a compatibility net for pre-blueprint decks, not an authoring workflow; leaning on it turns 10 inline enemies into 10 junk blueprints.
384
392
  - Never invent your own `blueprint` path pointing at a file you didn't also create — a dangling reference resolves to "no template" (the instance's own sparse `components` render alone, missing whatever it expected to inherit).
385
393
 
394
+ The kit also ships a few presets under `blueprints/` (e.g. `text.scene`, `ball.scene`, `cauldron.scene`). Reference one from a deck as `"blueprint": "@imports/castle.physics-2d/blueprints/text.scene"`.
395
+
386
396
  ## Built-in behaviors
387
397
 
388
398
  - **Layout** — `{ x, y, width, height, z?, rotation? }`. Every actor needs one. `z` orders draw (low first). `rotation` is degrees about the center.
389
399
  - **Sprite** — `{ file: "drawings/foo.sprite", tint?: "#rrggbbaa", playing?: true, tag?: "", mode?: 'cover'|'fit'|'stretch'|'tile', tileSize?: 50 }`. Renders the deck's 2D art into the Layout box. `file` is either a `.sprite` pixel-art sprite or an uploaded **image file** (`.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.svg`) — every prop below works the same on both, except that an image is a single still (no frames or tags) and is drawn smoothed rather than nearest-neighbour, which is what you want for a photo and not for pixel art. Draw modes: scaled up preserving the art's aspect ratio to fill the whole box, cropping whatever overflows a mismatched box (`mode: 'cover'`, the default -- CSS object-fit: cover), scaled preserving the art's aspect ratio and centered so the whole sprite stays visible, letterboxing a mismatched box instead of cropping (`mode: 'fit'` -- CSS object-fit: contain), scaled to fill the box exactly and distorting the art when the aspect ratios don't match (`mode: 'stretch'`), or repeated (`mode: 'tile'`). `tint` multiplies; use white (`#ffffffff`) or omit for the original colors. Animated sprites play automatically; set `playing: false` to hold the first frame, or `tag` to play a named animation tag. Tile mode repeats the art at a fixed cell size (`tileSize` card units tall, width scaled by the art's aspect) instead of stretching it across the Layout box.
390
400
  - **Missing sprites fall back to a placeholder.** If `file` names a sprite that doesn't exist yet, it renders the fallback `drawings/cauldron.sprite` — so you can give an actor its REAL intended sprite name (`drawings/paddle.sprite`) right away and it shows the placeholder until that file is created. Reference real names from the start; don't wait for the art.
391
401
  - **Fill the box, don't distort.** The default `mode: 'cover'` fills the Layout box with the art undistorted, cropping whatever overflows when the box aspect doesn't match the art's — so treat the Layout box like an image frame in a design tool: size it to frame what matters and keep the sprite's important content toward the center, since the edges may be cropped. Keeping an actor's Layout aspect ratio close to its sprite's native aspect ratio minimizes how much gets cropped. When the whole sprite must stay visible (nothing is safe to crop), use `mode: 'fit'`, which preserves the art and letterboxes the mismatched box instead. Avoid `mode: 'stretch'` for pixel art — filling a long/tall box by distorting wrecks the pixels (a brick sprite stretched into a wall ruins the bricks). For long surfaces (walls, floors, platforms), use `mode: 'tile'` on one actor to repeat the art at a fixed cell size instead of stretching or cropping a single sprite.
402
+ - **Text** — `{ text, font?: '', size?: 24, color?: '#131313', align?: 'left'|'center'|'right', verticalAlign?: 'top'|'middle'|'bottom', lineHeight?: 1.25, space?: 'world'|'screen', outlineWidth?: 0, outlineColor?: '#ffffff' }`. Draws wrapped text into the Layout box (wrap width = box minus sibling `Style.padding`; lines may overflow vertically — no clipping, no auto-resize). `font: ''` (default) uses the deck font; pick any Castle face from the inspector and it is added to `fonts.generated.js`. `space: 'world'` scrolls with the camera; `space: 'screen'` is HUD via `drawUi`. `color` / `outlineColor` are deck-palette hexes (`''` = none — skip fill or stroke). Fill/border/shadow/opacity/padding live on sibling **Style**. Hide with `"$enabled": false`. Kit preset: `@imports/castle.physics-2d/blueprints/text.scene`. Change the string from deck code with `actor.components.Text.text = '…'`.
403
+ - **Style** — `{ background?: '', border?: '', borderWidth?: 0, borderRadius?: 0, padding?: 0, shadowX?: 0, shadowY?: 0, shadowBlur?: 0, shadowColor?: '', opacity?: 1 }`. Shared chrome for **Text** and **Sprite**: background fill, border, corner radius, drop shadow (`shadowColor` + offsets + blur; empty color = no shadow), content padding, and opacity. Draws behind content. `background` / `border` are palette hexes (`''` = none). Add it beside Text or Sprite from the inspector.
392
404
  - **Collider** — `{ kind: 'solid'|'pickup', mode?: 'auto'|'manual', width, height, offsetX?, offsetY?, debug? }`. Just a rect; `offsetX`/`offsetY` nudge the collider from its default position (so `0,0` is the default). `mode: 'auto'` (the default) fits the rect to the actor's Sprite's opaque pixels, mapped through the Sprite's draw mode (`fit`'s letterboxed dest rect, `stretch`'s full Layout box, `cover`'s filled box clipped to the Layout box; `tile` mode and a missing/unresolved Sprite use the whole Layout box instead). `width`/`height` only apply in `mode: 'manual'` (a rect of that size, centered in the Layout box); they're ignored in `auto`. The framework does NOT auto-resolve collisions for you. Use it as data:
393
405
 
394
406
  ```jsx
@@ -492,8 +504,10 @@ behavior imported for itself — before the first frame, so `ctx.font` is safe t
492
504
  set from the very first `draw`. Each face costs its own 12–90 KB in the bundle,
493
505
  so two or three in total is the shape to aim for.
494
506
 
495
- There is no Text behavior text is drawn by the deck's own code via `draw`
496
- (world) or `drawUi` (screen HUD).
507
+ Use the **Text** behavior for labels and HUD strings (optional per-actor `font`,
508
+ plus sibling **Style** for fill/border/padding). Hand-drawn canvas text
509
+ (`ctx.fillText` in `draw` / `drawUi`) is for per-glyph effects or text that
510
+ follows a body in a way the Layout box cannot.
497
511
 
498
512
  ## Physics
499
513
 
@@ -525,8 +539,7 @@ make it move.**
525
539
  - `bodyType: 'dynamic' | 'static' | 'kinematic'`. `dynamic` = simulated
526
540
  (gravity, collisions, forces). `static` = never moves (same as a lone
527
541
  Collider). `kinematic` = unpushable, but you move it by writing its Layout.
528
- - `gravityScale` — multiplies world gravity for this body (`0` = floats;
529
- default, so newly added bodies don't fall until you opt in).
542
+ - `gravityScale` — multiplies world gravity for this body (`0` = floats).
530
543
  - `drag` — linear damping (air resistance). `angularDrag` — spin damping.
531
544
  - `freezeRotation` — keep it from rotating (stays upright).
532
545
  - `velocityX` / `velocityY` — initial velocity, applied once when play starts.
@@ -601,6 +614,26 @@ export class StickSlowDownToggle {
601
614
  }
602
615
  ```
603
616
 
617
+ ### Tap hook
618
+
619
+ Any behavior may implement `onTap(actor, scene)`. No extra component: if an
620
+ enabled behavior on the actor has the method, a pointer release inside the hit
621
+ zone fires it on every sibling that implements it (Collider when present, else
622
+ the Layout box; screen-space Text uses screen coords). The finger is claimed so
623
+ it does not also drive a stick or a grab-anywhere sling. A targeted control
624
+ (Draggable, a sling pressed on its own body) still wins. An actor with no
625
+ `onTap` does not claim the finger.
626
+
627
+ ```jsx
628
+ export class TapCounter {
629
+ static behaviorName = 'TapCounter';
630
+ onTap(actor, scene) {
631
+ const text = actor.components.Text;
632
+ if (text) text.text = String(Number(text.text || 0) + 1);
633
+ }
634
+ }
635
+ ```
636
+
604
637
  ### Built-in controls (add these behaviors, no code needed)
605
638
 
606
639
  These read input and drive the actor through the physics system, so the actor
@@ -650,7 +683,7 @@ see the SceneRuntime API below.
650
683
  ### Recipe: a launch-and-bounce game
651
684
 
652
685
  - Ball blueprint: `Layout` (square), `Sprite`, `Collider {shape:'circle',
653
- bounciness:0.6}`, `RigidBody {bodyType:'dynamic', gravityScale:1}`, `Slingshot {}`.
686
+ bounciness:0.6}`, `RigidBody {bodyType:'dynamic'}`, `Slingshot {}`.
654
687
  - Walls/floor blueprint: `Layout`, `Collider {bounciness:0.4}` (no RigidBody →
655
688
  static).
656
689
  - Goal blueprint: `Collider {isTrigger:true}` + a `Goal` behavior with
@@ -853,14 +886,14 @@ if (scene.keys.has('KeyX')) /* launch ball */ ;
853
886
 
854
887
  **Space is reserved** — the editor binds it to the play/stop toggle, so don't bind Space to a gameplay action (jump / shoot / launch / ...). Use arrows, WASD, letter keys, or on-screen buttons instead.
855
888
 
856
- For HUD text and shapes use `drawUi` (screen-fixed canvas). Only in-world text or shapes go in `draw`. Both read `scene.font` — see `## Deck font`. The React `ui()` hook is deprecated.
889
+ For HUD text prefer a `Text` actor with `space: 'screen'` (or `drawUi` for custom canvas HUD). Only in-world text or shapes go in `draw`. Both read `scene.font` — see `## Deck font`. A tappable label is `Text` plus a sibling with `onTap`. The React `ui()` hook is deprecated.
857
890
 
858
891
  ## Common breakout-shaped recipe (sketch)
859
892
 
860
893
  - `Paddle` behavior: read keys, clamp x to `[0, 500 - layout.width]`.
861
894
  - `Ball` behavior: store `vx, vy` on `actor.runtime`; integrate; bounce off wall edges (`x<0`, `x+w>500`, `y<0`); on `scene.overlaps(actor, paddle)`, flip `vy`; for each `brick` in `scene.actorsWith('Brick')` check `scene.overlaps(actor, brick)` → flip `vy` and `scene.despawnActor(brick.id)`; if `y > 700` lose a life.
862
895
  - `Brick` behavior: typically just a marker — `kind: 'solid'` collider is enough. State (hit count) goes on `actor.runtime` or the brick's own props.
863
- - `GameController` (no Layout needed if you don't draw it): tracks score / lives / status; expose HUD via `drawUi()`.
896
+ - `GameController` (no Layout needed if you don't draw it): tracks score / lives / status; expose HUD via `Text` with `space: 'screen'` or `drawUi()`.
864
897
 
865
898
  ## Don't
866
899
 
@@ -4,11 +4,8 @@ import { AutoFields, overrideProps } from '../engine/autoInspector';
4
4
  import {
5
5
  GREEDY_CLAIM,
6
6
  acquirePress,
7
+ clampLength,
7
8
  length,
8
- screenPoint,
9
- stickBrake,
10
- stickDrive,
11
- stickVector,
12
9
  } from '../engine/physics/controls';
13
10
 
14
11
  // AnalogStick: on-screen virtual joystick (press anywhere) that drives the
@@ -26,6 +23,85 @@ import {
26
23
  //
27
24
  // Sibling hooks (fired on this actor only): onAnalogStickBegin / onAnalogStickEnd
28
25
  // (actor, scene) — begin on first nonzero deflection, end on release or disable.
26
+ //
27
+ // Force-mode math lives here (only consumer): stickDrive / stickBrake return
28
+ // velocity deltas via applyImpulse — mass-independent, composable, and
29
+ // restricted to driven axes so gravity on an undriven axis is never clamped.
30
+
31
+ // Stick displacement from origin, clamped to maxRadius.
32
+ function stickVector(pointer, origin, maxRadius = 60) {
33
+ return clampLength({ x: pointer.x - origin.x, y: pointer.y - origin.y }, maxRadius);
34
+ }
35
+
36
+ // Card coords before the camera — for screen-fixed controls reading a pointer.
37
+ function screenPoint(pointer) {
38
+ return { x: pointer.screenX, y: pointer.screenY };
39
+ }
40
+
41
+ // Keep only the axes the stick is allowed to drive. An undriven axis (gravity
42
+ // in a platformer) must never be clamped or braked by the stick.
43
+ function drivenAxes(v, axes) {
44
+ return {
45
+ x: axes === 'vertical' ? 0 : v.x,
46
+ y: axes === 'horizontal' ? 0 : v.y,
47
+ };
48
+ }
49
+
50
+ // Smallest signed angle from `from` to `to`, wrapped into [−π, π].
51
+ function angleDelta(from, to) {
52
+ let d = Math.atan2(to.y, to.x) - Math.atan2(from.y, from.x);
53
+ if (d > Math.PI) d -= 2 * Math.PI;
54
+ if (d < -Math.PI) d += 2 * Math.PI;
55
+ return d;
56
+ }
57
+
58
+ // Velocity delta while held: accelerate toward deflection × speed with turn
59
+ // boost on reversals, then clamp driven axes to `speed`. Impulse only.
60
+ function stickDrive(velocity, deflection, props = {}, dt) {
61
+ const speed = props.speed ?? 6;
62
+ const rampTime = props.rampTime ?? 0.2;
63
+ const turnBoost = props.turnBoost ?? 3;
64
+ const axes = props.axes ?? 'both';
65
+ const vDriven = drivenAxes(velocity, axes);
66
+
67
+ let boost = 1;
68
+ if (turnBoost > 0 && length(velocity) > 0 && length(deflection) > 0) {
69
+ boost = 1 + turnBoost * (Math.abs(angleDelta(velocity, deflection)) / Math.PI);
70
+ }
71
+
72
+ let delta;
73
+ if (rampTime <= 0) {
74
+ const target = drivenAxes(
75
+ { x: deflection.x * speed, y: deflection.y * speed },
76
+ axes,
77
+ );
78
+ delta = { x: target.x - vDriven.x, y: target.y - vDriven.y };
79
+ } else {
80
+ const accel = speed / rampTime;
81
+ delta = drivenAxes(
82
+ { x: deflection.x * accel * boost * dt, y: deflection.y * accel * boost * dt },
83
+ axes,
84
+ );
85
+ const capped = clampLength({ x: vDriven.x + delta.x, y: vDriven.y + delta.y }, speed);
86
+ delta = { x: capped.x - vDriven.x, y: capped.y - vDriven.y };
87
+ }
88
+ return delta;
89
+ }
90
+
91
+ // Velocity delta while idle: Coulomb brake on driven axes only.
92
+ // `slowDown` is speed units lost per second (px/step per second). 0 = coast.
93
+ function stickBrake(velocity, props = {}, dt) {
94
+ const slowDown = props.slowDown ?? 20;
95
+ if (slowDown <= 0) return { x: 0, y: 0 };
96
+ const axes = props.axes ?? 'both';
97
+ const vDriven = drivenAxes(velocity, axes);
98
+ const len = length(vDriven);
99
+ if (len === 0) return { x: 0, y: 0 };
100
+ const reduce = Math.min(len, slowDown * dt);
101
+ const s = -reduce / len;
102
+ return { x: vDriven.x * s, y: vDriven.y * s };
103
+ }
104
+
29
105
  function fireStickHook(actor, scene, hook) {
30
106
  scene.forEachBehavior(actor, (inst) => inst[hook]?.(actor, scene));
31
107
  }
@@ -62,6 +62,14 @@ export class Slingshot {
62
62
  // Anchor at the press point (like castle-client): the aim/launch depends
63
63
  // on the drag gesture, not where the press landed relative to the actor.
64
64
  rt._slingAnchor = { x: press.x, y: press.y };
65
+ // A same-frame tap (down already false) is not an aim — cancel before we
66
+ // can launch with a zero pull. A TARGETED control that updates later this
67
+ // frame may still steal a greedy claim; we must not freeze velocity yet.
68
+ if (!press.down) {
69
+ rt._slinging = false;
70
+ rt._slingPointerId = null;
71
+ return;
72
+ }
65
73
  }
66
74
  }
67
75
  if (!rt._slinging) return;
@@ -77,8 +85,12 @@ export class Slingshot {
77
85
  }
78
86
 
79
87
  if (p.down) {
80
- // Hold still while aiming, and remember the aim point for draw().
81
- scene.physics.setVelocity(actor, { x: 0, y: 0 });
88
+ // Hold still while aiming but not on the press frame. A greedy claim can
89
+ // still lose to a higher claim that hasn't updated yet (e.g. a Draggable
90
+ // on the pressed body); freezing here would hitch a mid-air body for one frame
91
+ // and then drop the shot with no launch. Matches claimPointer's contract
92
+ // that the press frame costs a greedy control nothing visible.
93
+ if (!p.justPressed) scene.physics.setVelocity(actor, { x: 0, y: 0 });
82
94
  rt._slingAim = { x: p.x, y: p.y };
83
95
  } else {
84
96
  scene.physics.setVelocity(actor, slingLaunch(rt._slingAnchor, p, this.props));
@@ -7,6 +7,7 @@ import { Panel, SelectField } from '../engine/ui';
7
7
  import { SpriteField } from '../engine/spriteField';
8
8
  import { AutoFields, overrideProps } from '../engine/autoInspector';
9
9
  import { parseTint, tintCanvas } from './tint';
10
+ import { paintActorStyle, styleContentBox, styleOpacity } from './Style';
10
11
 
11
12
  // Runtime behavior for a deck's 2D art: the pixel-art `.pxart` format, or an
12
13
  // image file (png / jpg / gif / webp / svg) the deck uploaded. Both come out of
@@ -81,6 +82,9 @@ export class Sprite {
81
82
  if (actor.runtime?.collected) return;
82
83
  const layout = actor.components.Layout;
83
84
  if (!layout) return;
85
+ // Style chrome first so background/border sit behind the blit even when
86
+ // the Style key is after Sprite on the components object.
87
+ paintActorStyle(actor, ctx);
84
88
  const sprite = this.resolveSprite(scene);
85
89
  if (!sprite) return;
86
90
  const total = artFrameCount(sprite);
@@ -99,6 +103,8 @@ export class Sprite {
99
103
  const smoothing =
100
104
  isVectorRenderer(sprite.renderer) || sprite.cornerRadius > 0 || isImageArt(sprite);
101
105
 
106
+ const box = styleContentBox(actor, layout);
107
+
102
108
  // Snap the destination rect to whole device pixels so tiles that abut
103
109
  // exactly in card units (e.g. adjacent 100-unit-wide tiles) land on the
104
110
  // same device-pixel edge instead of each getting its own antialiased
@@ -117,7 +123,9 @@ export class Sprite {
117
123
  // Everything downstream (snapping, tiling's clip box) works off `dest`: the
118
124
  // Layout box itself for every mode except `fit`/`cover`, which resize to the
119
125
  // art's own aspect ratio (see `spriteDestRect`).
120
- const dest = spriteDestRect(this.props.mode, layout, artSize);
126
+ const dest = spriteDestRect(this.props.mode, box, artSize);
127
+ ctx.save();
128
+ ctx.globalAlpha *= styleOpacity(actor);
121
129
  if (transform.b === 0 && transform.c === 0) {
122
130
  const x0 = dest.x * transform.a + transform.e;
123
131
  const y0 = dest.y * transform.d + transform.f;
@@ -133,12 +141,12 @@ export class Sprite {
133
141
  if (tiling) {
134
142
  drawTilesSnapped(ctx, canvas, artSize, this.props.tileSize, dest, transform, x0, y0, rx0, ry0, rx1, ry1);
135
143
  } else {
136
- // Clip cover's overflow to the snapped Layout box (undone by restore).
144
+ // Clip cover's overflow to the snapped content box (undone by restore).
137
145
  if (covering) {
138
- const lx0 = Math.round(layout.x * transform.a + transform.e);
139
- const ly0 = Math.round(layout.y * transform.d + transform.f);
140
- const lx1 = Math.round((layout.x + layout.width) * transform.a + transform.e);
141
- const ly1 = Math.round((layout.y + layout.height) * transform.d + transform.f);
146
+ const lx0 = Math.round(box.x * transform.a + transform.e);
147
+ const ly0 = Math.round(box.y * transform.d + transform.f);
148
+ const lx1 = Math.round((box.x + box.width) * transform.a + transform.e);
149
+ const ly1 = Math.round((box.y + box.height) * transform.d + transform.f);
142
150
  ctx.beginPath();
143
151
  ctx.rect(lx0, ly0, lx1 - lx0, ly1 - ly0);
144
152
  ctx.clip();
@@ -151,10 +159,10 @@ export class Sprite {
151
159
  if (tiling) {
152
160
  drawTilesUnsnapped(ctx, canvas, artSize, this.props.tileSize, dest);
153
161
  } else if (covering) {
154
- // Rotated: clip cover's overflow to the Layout box in card units.
162
+ // Rotated: clip cover's overflow to the content box in card units.
155
163
  ctx.save();
156
164
  ctx.beginPath();
157
- ctx.rect(layout.x, layout.y, layout.width, layout.height);
165
+ ctx.rect(box.x, box.y, box.width, box.height);
158
166
  ctx.clip();
159
167
  ctx.drawImage(canvas, dest.x, dest.y, dest.width, dest.height);
160
168
  ctx.restore();
@@ -163,6 +171,7 @@ export class Sprite {
163
171
  }
164
172
  ctx.imageSmoothingEnabled = prevSmoothing;
165
173
  }
174
+ ctx.restore();
166
175
  }
167
176
 
168
177
  static Inspector({ component, setComponent, sprites, override }) {