castle-web-cli 0.4.129 → 0.4.131

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 (144) hide show
  1. package/dist/agent-prompts.js +4 -4
  2. package/dist/filesChanged.d.ts +2 -0
  3. package/dist/filesChanged.js +6 -2
  4. package/dist/ide.js +15 -4
  5. package/dist/importBrowse.js +1 -0
  6. package/dist/index.js +10 -4
  7. package/dist/init.js +63 -15
  8. package/dist/native/loop.js +1 -1
  9. package/dist/native/tools.js +1 -1
  10. package/dist/serve.d.ts +1 -0
  11. package/dist/serve.js +20 -1
  12. package/dist/shell/assets/index-BpOLUyVO.js +441 -0
  13. package/dist/shell/assets/{index-CAq6f9B5.css → index-D6K-0YDB.css} +1 -1
  14. package/dist/shell/index.html +2 -2
  15. package/kits/physics-2d/CLAUDE.md +9 -10
  16. package/kits/physics-2d/castle.json +1 -1
  17. package/kits/physics-2d/editors/PlayOnly.jsx +16 -6
  18. package/kits/physics-2d/editors/PxArtEditor.jsx +95 -31
  19. package/kits/physics-2d/editors/SceneEditor.jsx +10 -10
  20. package/kits/physics-2d/editors/SingleEditor.jsx +37 -8
  21. package/kits/physics-2d/editors/StyleEditor.jsx +7 -4
  22. package/kits/physics-2d/editors/pixelEditorChrome.jsx +7 -7
  23. package/kits/physics-2d/editors/pixelInspector.jsx +13 -7
  24. package/kits/physics-2d/editors/pxArtTimeline.jsx +29 -11
  25. package/kits/physics-2d/editors/pxArtTimeline.module.css +11 -0
  26. package/kits/physics-2d/engine/ScenePlayer.jsx +2 -2
  27. package/kits/physics-2d/engine/blueprint.js +23 -5
  28. package/kits/physics-2d/engine/liveReload.js +33 -21
  29. package/kits/physics-2d/engine/scene.js +17 -5
  30. package/kits/physics-2d/engine/systemRegistry.js +1 -1
  31. package/kits/physics-2d/engine/ui.jsx +2 -2
  32. package/kits/physics-2d/engine/ui.module.css +42 -0
  33. package/kits/physics-3d/CLAUDE.md +108 -0
  34. package/kits/physics-3d/behaviors/Body.jsx +51 -0
  35. package/kits/physics-3d/behaviors/Door.jsx +31 -0
  36. package/kits/physics-3d/behaviors/Lookable.jsx +11 -0
  37. package/kits/physics-3d/behaviors/Model.jsx +14 -0
  38. package/kits/physics-3d/behaviors/Pickup.jsx +39 -0
  39. package/kits/physics-3d/behaviors/Player.jsx +172 -0
  40. package/kits/physics-3d/behaviors/Shape.jsx +75 -0
  41. package/kits/physics-3d/behaviors/Solid.jsx +11 -0
  42. package/kits/physics-3d/behaviors/Transform.jsx +24 -0
  43. package/kits/physics-3d/blueprints/barrel.scene +15 -0
  44. package/kits/physics-3d/blueprints/crate.scene +29 -0
  45. package/kits/physics-3d/blueprints/door.scene +35 -0
  46. package/kits/physics-3d/blueprints/gem.scene +26 -0
  47. package/kits/physics-3d/blueprints/pillar.scene +25 -0
  48. package/kits/physics-3d/blueprints/platform.scene +23 -0
  49. package/kits/physics-3d/blueprints/player.scene +29 -0
  50. package/kits/physics-3d/blueprints/rock.scene +14 -0
  51. package/kits/physics-3d/blueprints/statue.scene +26 -0
  52. package/kits/physics-3d/blueprints/tree.scene +14 -0
  53. package/kits/physics-3d/blueprints/wall.scene +26 -0
  54. package/kits/{basic-2d → physics-3d}/castle.json +23 -5
  55. package/kits/physics-3d/docs/pxmodel-format.md +111 -0
  56. package/kits/physics-3d/drawings/crate.pxart +26 -0
  57. package/kits/physics-3d/drawings/door-edge.pxart +26 -0
  58. package/kits/physics-3d/drawings/door.pxart +27 -0
  59. package/kits/physics-3d/drawings/face.pxart +26 -0
  60. package/kits/physics-3d/drawings/floor.pxart +27 -0
  61. package/kits/physics-3d/drawings/platform.pxart +27 -0
  62. package/kits/physics-3d/drawings/statue.pxart +27 -0
  63. package/kits/physics-3d/drawings/wall.pxart +27 -0
  64. package/kits/physics-3d/engine3d/PlayOnly3D.jsx +36 -0
  65. package/kits/physics-3d/engine3d/PxModelEditor.jsx +619 -0
  66. package/kits/physics-3d/engine3d/Scene3DEditor.jsx +618 -0
  67. package/kits/physics-3d/engine3d/Scene3DPlayer.jsx +309 -0
  68. package/kits/physics-3d/engine3d/editor3dData.js +114 -0
  69. package/kits/physics-3d/engine3d/editor3dInspector.jsx +404 -0
  70. package/kits/physics-3d/engine3d/editorChrome.jsx +218 -0
  71. package/kits/physics-3d/engine3d/editorWorld.js +249 -0
  72. package/kits/physics-3d/engine3d/materials.js +174 -0
  73. package/kits/physics-3d/engine3d/meshops.js +123 -0
  74. package/kits/physics-3d/engine3d/modelEditorWorld.js +439 -0
  75. package/kits/physics-3d/engine3d/modelFiles.js +136 -0
  76. package/kits/physics-3d/engine3d/pxmodel.js +265 -0
  77. package/kits/physics-3d/engine3d/thumbnails.js +137 -0
  78. package/kits/physics-3d/engine3d/world3d.js +216 -0
  79. package/kits/{basic-2d → physics-3d}/eslint.config.js +10 -25
  80. package/kits/physics-3d/index.html +20 -0
  81. package/kits/physics-3d/main.jsx +30 -0
  82. package/kits/physics-3d/models/barrel.pxmodel +10 -0
  83. package/kits/physics-3d/models/player.pxmodel +222 -0
  84. package/kits/physics-3d/models/rock.pxmodel +30 -0
  85. package/kits/physics-3d/models/tree.pxmodel +10 -0
  86. package/kits/{basic-2d → physics-3d}/package-lock.json +140 -119
  87. package/kits/{basic-2d → physics-3d}/package.json +11 -9
  88. package/kits/{basic-2d → physics-3d}/pnpm-lock.yaml +162 -137
  89. package/kits/physics-3d/scenes/main.scene +398 -0
  90. package/kits/physics-3d/scenes/model-lab.scene +111 -0
  91. package/kits/physics-3d/systems/physics3d.js +356 -0
  92. package/package.json +5 -2
  93. package/dist/shell/assets/index-DKu9ejyh.js +0 -436
  94. package/kits/basic-2d/CLAUDE.md +0 -221
  95. package/kits/basic-2d/behaviors/Camera.jsx +0 -43
  96. package/kits/basic-2d/behaviors/Collider.jsx +0 -213
  97. package/kits/basic-2d/behaviors/Layout.jsx +0 -53
  98. package/kits/basic-2d/behaviors/Sprite.jsx +0 -357
  99. package/kits/basic-2d/behaviors/tint.js +0 -47
  100. package/kits/basic-2d/blueprints/cauldron.scene +0 -20
  101. package/kits/basic-2d/docs/pxart-format.md +0 -377
  102. package/kits/basic-2d/drawings/cauldron.pxart +0 -113
  103. package/kits/basic-2d/editors/BlueprintLibrary.jsx +0 -270
  104. package/kits/basic-2d/editors/ErrorBoundary.jsx +0 -59
  105. package/kits/basic-2d/editors/PlayOnly.jsx +0 -31
  106. package/kits/basic-2d/editors/PxArtEditor.jsx +0 -1092
  107. package/kits/basic-2d/editors/SceneEditor.jsx +0 -1780
  108. package/kits/basic-2d/editors/SelectionOverlay.jsx +0 -909
  109. package/kits/basic-2d/editors/SingleEditor.jsx +0 -122
  110. package/kits/basic-2d/editors/behaviorRegistry.js +0 -34
  111. package/kits/basic-2d/editors/editorHistory.js +0 -157
  112. package/kits/basic-2d/editors/inspectorSheet.js +0 -13
  113. package/kits/basic-2d/editors/pixelCanvas.js +0 -11
  114. package/kits/basic-2d/editors/pixelEditorChrome.jsx +0 -74
  115. package/kits/basic-2d/editors/pixelGeometry.js +0 -140
  116. package/kits/basic-2d/editors/pixelInspector.jsx +0 -633
  117. package/kits/basic-2d/editors/pxArtEditorModel.js +0 -732
  118. package/kits/basic-2d/editors/pxArtPlayback.js +0 -92
  119. package/kits/basic-2d/editors/pxArtTimeline.jsx +0 -752
  120. package/kits/basic-2d/editors/pxArtTimeline.module.css +0 -506
  121. package/kits/basic-2d/editors/pxArtTools.js +0 -232
  122. package/kits/basic-2d/editors/useArtboardFit.js +0 -105
  123. package/kits/basic-2d/engine/ScenePlayer.jsx +0 -209
  124. package/kits/basic-2d/engine/SceneUI.jsx +0 -59
  125. package/kits/basic-2d/engine/assets.js +0 -15
  126. package/kits/basic-2d/engine/autoInspector.jsx +0 -70
  127. package/kits/basic-2d/engine/behaviorExtensions.js +0 -32
  128. package/kits/basic-2d/engine/blueprint.js +0 -557
  129. package/kits/basic-2d/engine/collider.js +0 -200
  130. package/kits/basic-2d/engine/files.js +0 -141
  131. package/kits/basic-2d/engine/liveReload.js +0 -88
  132. package/kits/basic-2d/engine/pxart.js +0 -1032
  133. package/kits/basic-2d/engine/pxartSmooth.js +0 -222
  134. package/kits/basic-2d/engine/scene.js +0 -696
  135. package/kits/basic-2d/engine/spriteGeometry.js +0 -32
  136. package/kits/basic-2d/engine/systemRegistry.js +0 -16
  137. package/kits/basic-2d/engine/ui.jsx +0 -695
  138. package/kits/basic-2d/engine/ui.module.css +0 -2287
  139. package/kits/basic-2d/index.html +0 -24
  140. package/kits/basic-2d/main.jsx +0 -24
  141. package/kits/basic-2d/scenes/main.scene +0 -16
  142. package/kits/basic-2d/scripts/draw.mjs +0 -121
  143. /package/kits/{basic-2d → physics-3d}/.prettierrc +0 -0
  144. /package/kits/{basic-2d → physics-3d}/vite.config.js +0 -0
@@ -0,0 +1,108 @@
1
+ # physics-3d kit
2
+
3
+ A 3D actor/behavior kit: three.js rendering with PBR lighting and rapier
4
+ physics, built ON TOP of the `physics-2d` kit as an import. The actor /
5
+ behavior / scene / blueprint model, deck file map, live reload, and the
6
+ `.pxart` pixel-art pipeline (including its editor) all come from
7
+ `imports/castle.physics-2d/` and work exactly as that kit's CLAUDE.md
8
+ describes. This file covers what is different in 3D.
9
+
10
+ ## Quick reference
11
+
12
+ <!-- Injected into Castle's create assistant every turn; keep this compact and factual. -->
13
+
14
+ - 3D actor / behavior / scene framework: three.js + rapier, 1 unit = 1 meter,
15
+ y up. Scenes are `scenes/*.scene` (blueprints too, under `blueprints/`),
16
+ behaviors `behaviors/*.jsx` -- same JSON shapes as physics-2d, but the
17
+ component vocabulary is 3D (`Transform`, `Shape`, `Body`, `Solid`, ...).
18
+ - Every actor needs a `Transform` (`x/y/z`, `rotationX/Y/Z` in degrees).
19
+ Every Transform actor gets a rapier body automatically -- fixed by default,
20
+ `Body { type: 'dynamic' }` to be simulated, `'kinematic'` to be unpushable
21
+ but follow Transform edits (doors, platforms).
22
+ - Colliders come from `Shape` and are **sensors unless solid**: add a
23
+ `Solid {}` behavior (or `Shape.solid: true`) to block; leave it off for
24
+ pickups / trigger zones. Behaviors get `onCollisionEnter(actor, scene,
25
+ other)` / `onCollisionExit` for both contacts and sensor overlaps.
26
+ - `Shape`: `kind` `'box' | 'sphere' | 'capsule' | 'cylinder'` with
27
+ `width/height/depth` or `radius`, PBR knobs (`color`, `roughness`,
28
+ `metalness`, `clearcoat`, `emissive`, `emissiveIntensity`), and `art` -- a
29
+ `.pxart`/image path baked as a nearest-filtered albedo map. On boxes `art`
30
+ may be a per-face object `{ all, sides, front, back, left, right, top,
31
+ bottom }`; any ref can pick a frame of a multi-frame pxart with
32
+ `path#<frame>`. A flat `color` with no art is a fine look.
33
+ - Motion goes through `scene.physics3d`, never by writing a dynamic actor's
34
+ Transform: `setVelocity(actor, {x,y,z})`, `applyImpulse`, `getVelocity`,
35
+ `mass`, `castRay(origin, dir, maxToi, excludeActor)` (rays skip sensors).
36
+ Fixed/kinematic actors ARE moved by writing their Transform.
37
+ - The scene has the lighting rig built in (hemisphere + ambient + one
38
+ shadow-casting sun, ground plane at y=0); meshes cast and receive shadows
39
+ automatically. Don't add your own lights for a normal scene.
40
+ - Built-in gameplay behaviors: `Player` (WASD/arrows move, space jumps,
41
+ drag orbits the camera, faux-isometric follow cam), `Pickup` (sensor
42
+ collectible -> player score), `Door` (kinematic slide-up when the player is
43
+ near), `Lookable { text }` (hud caption when the player faces it nearby).
44
+ - Hud: behaviors write `scene.hud = { score, look }`; the player behavior
45
+ already does.
46
+ - Scene switching: set `scene.requestedScene = 'scenes/other.scene'` from any
47
+ behavior (applied
48
+ between frames; safer than calling `scene.loadFromFile` mid-callback). A
49
+ load rebuilds the physics world and resets the scene to its authored state;
50
+ `scene.persistent` survives loads -- the player's score, collected-pickup
51
+ memory, and camera angle live there, so they cross scenes.
52
+ - A scene can dress the ground: `"ground": { "art": "drawings/floor.pxart",
53
+ "tile": 2, "color": "#..." }` at the scene file's top level (`tile` = meters
54
+ per texture repeat). `Shape.artRepeat: [u, v]` tiles a texture across a
55
+ face so long walls don't stretch.
56
+ - `.scene` files open in the kit's 3d scene editor (same chrome as the 2d
57
+ kit's): blueprint hotbar (tap to inspect/edit the template, drag onto the
58
+ stage to place), orbit camera (drag orbits, wheel zooms), click to select /
59
+ click again to cycle / shift to multi-select, move & rotate gizmos (keys
60
+ 1/2/3 pick select/move/rotate), floating clone/delete toolbar on the
61
+ selection, behavior-panel inspector with instance-override tints, undo/redo
62
+ (cmd+z / cmd+shift+z), cmd+d duplicate, delete key removes. `.pxart` opens
63
+ in the imported pixel-art editor.
64
+ - Blueprint thumbnails in the hotbar / inspector are live 3d corner-view
65
+ renders of each template (engine3d/thumbnails.js) -- no icon data needed on
66
+ the blueprint.
67
+ - 3d models are `.pxmodel` files under `models/` -- part-based JSON (named
68
+ primitive parts with hierarchy, per-part material/art), spec in
69
+ docs/pxmodel-format.md. A `Model` behavior (`{ file, scale, yOffset }`)
70
+ renders one instead of the Shape mesh; part ids legL/legR/armL/armR/torso
71
+ walk-animate automatically from observed motion. `.pxmodel` opens in the
72
+ kit's model editor (parts list, gizmos, part inspector, walk preview);
73
+ `scenes/model-lab.scene` is an all-model scene for trying it.
74
+ - Controls are tank-style: W/S (or up/down) walk forward/back along facing,
75
+ A/D (or left/right) turn; a pointer/touch drag is a virtual stick where the
76
+ drag angle blends walking and turning. Space jumps.
77
+ - After any edit: `npm run restart`. After a coherent change: `npm run check`
78
+ (lint + duplicate gate + single-file bundle) must pass.
79
+
80
+ ## What lives where
81
+
82
+ - `engine3d/` -- the renderer (`world3d.js`, vase-style rig: PCFSoft shadows,
83
+ EffectComposer RenderPass + OutputPass), `materials.js` (pxart -> albedo
84
+ textures, per-face box materials), `Scene3DPlayer.jsx` / `PlayOnly3D.jsx`
85
+ (the play mount). Internal; behaviors shouldn't import from here.
86
+ - `systems/physics3d.js` -- the rapier system, auto-discovered by the imported
87
+ engine's `systems/*.js` seam. Fixed 1/60 step, body-per-actor reconcile,
88
+ collision/overlap events, `scene.physics3d` API.
89
+ - `behaviors/` -- the 3D component vocabulary plus gameplay behaviors. Same
90
+ rules as physics-2d: `static behaviorName`, `defaultProps`, per-frame
91
+ instances (state on `actor.runtime`).
92
+ - `imports/castle.physics-2d/` -- read-only. Its engine modules are imported
93
+ directly (scene runtime, files, live reload, pxart, art, behavior
94
+ registry); its 2D-only pieces (canvas ScenePlayer, matter physics, 2D
95
+ editors other than pxart) are present but unused here.
96
+
97
+ ## Known state / caveats
98
+
99
+ - The 2D kit's behaviors (Layout, Sprite, Collider, RigidBody, ...) are still
100
+ in the behavior registry via the import's root-anchored globs, and its
101
+ matter-js system installs (idle) into every runtime. Harmless but present;
102
+ don't attach 2D components to 3D actors.
103
+ - Transform is the source of truth: dynamic bodies write back position (and
104
+ rotation unless `lockRotations`), fixed/kinematic bodies follow Transform.
105
+ A locked-rotation dynamic actor (the player) keeps `rotationY` free for
106
+ facing.
107
+ - Space is bound to jump in this kit (unlike physics-2d, where the editor
108
+ reserves it).
@@ -0,0 +1,51 @@
1
+ // Physics options. Every actor with a Transform gets a rapier body whether or
2
+ // not it carries this behavior -- fixed by default. Add a Body to opt into
3
+ // motion: 'dynamic' is simulated (gravity, pushes), 'kinematic' is unpushable
4
+ // but follows Transform edits with proper contact resolution (moving doors,
5
+ // platforms). `lockRotations` keeps a dynamic body upright (characters).
6
+ import React from 'react';
7
+ import { SelectField } from '@imports/castle.physics-2d/engine/ui';
8
+ import { AutoFields, overrideProps } from '@imports/castle.physics-2d/engine/autoInspector';
9
+
10
+ const TYPE_OPTIONS = [
11
+ { value: 'fixed', label: 'Fixed' },
12
+ { value: 'dynamic', label: 'Dynamic' },
13
+ { value: 'kinematic', label: 'Kinematic' },
14
+ ];
15
+
16
+ function BodyInspector({ component, setComponent, override }) {
17
+ return (
18
+ <>
19
+ <SelectField
20
+ label="Type"
21
+ value={component.type}
22
+ options={TYPE_OPTIONS}
23
+ onChange={(type) => setComponent({ type })}
24
+ {...overrideProps(override, 'type')}
25
+ />
26
+ <AutoFields
27
+ defaultProps={Body.defaultProps}
28
+ component={component}
29
+ setComponent={setComponent}
30
+ exclude={['type']}
31
+ override={override}
32
+ />
33
+ </>
34
+ );
35
+ }
36
+
37
+ export class Body {
38
+ static Inspector = BodyInspector;
39
+ static behaviorName = 'Body';
40
+ static defaultProps = {
41
+ type: 'fixed', // 'fixed' | 'dynamic' | 'kinematic'
42
+ friction: 0.6,
43
+ restitution: 0,
44
+ gravityScale: 1,
45
+ lockRotations: false,
46
+ };
47
+
48
+ constructor(props) {
49
+ this.props = props;
50
+ }
51
+ }
@@ -0,0 +1,31 @@
1
+ // A sliding door: solid, kinematic, and opens (slides up) while the player is
2
+ // within range. Motion is written to the Transform; the physics system feeds
3
+ // it to the kinematic body so contacts resolve properly.
4
+ export class Door {
5
+ static behaviorName = 'Door';
6
+ static defaultProps = { range: 3, slide: 2.1, speed: 2.5 };
7
+
8
+ constructor(props) {
9
+ this.props = props;
10
+ }
11
+
12
+ update(actor, scene, dt) {
13
+ const player = scene.actorWith('Player');
14
+ const pt = player?.components.Transform;
15
+ const t = actor.components.Transform;
16
+ const rt = actor.runtime;
17
+ if (rt.openT === undefined) {
18
+ rt.openT = 0;
19
+ rt.baseY = t.y;
20
+ }
21
+ let target = 0;
22
+ if (pt) {
23
+ const dist = Math.hypot(pt.x - t.x, pt.z - t.z);
24
+ target = dist < this.props.range ? 1 : 0;
25
+ }
26
+ const delta = Math.sign(target - rt.openT) * this.props.speed * dt;
27
+ rt.openT = Math.max(0, Math.min(1, rt.openT + delta));
28
+ const eased = rt.openT * rt.openT * (3 - 2 * rt.openT);
29
+ t.y = rt.baseY + eased * this.props.slide;
30
+ }
31
+ }
@@ -0,0 +1,11 @@
1
+ // Something the player can look at. When the player is near and facing this
2
+ // actor, the hud shows `text`. Pure data -- the Player behavior does the
3
+ // looking.
4
+ export class Lookable {
5
+ static behaviorName = 'Lookable';
6
+ static defaultProps = { text: '', range: 4 };
7
+
8
+ constructor(props) {
9
+ this.props = props;
10
+ }
11
+ }
@@ -0,0 +1,14 @@
1
+ // Render a `.pxmodel` (see docs/pxmodel-format.md) instead of the Shape mesh.
2
+ // The Shape still provides the physics collider. `yOffset` places the model's
3
+ // own origin relative to the actor's Transform -- e.g. -0.7 puts a
4
+ // feet-at-zero model under a capsule whose center is the Transform. Models
5
+ // with the walk part ids (legL/legR/armL/armR/torso) animate automatically
6
+ // from how the actor actually moves.
7
+ export class Model {
8
+ static behaviorName = 'Model';
9
+ static defaultProps = { file: '', scale: 1, yOffset: 0 };
10
+
11
+ constructor(props) {
12
+ this.props = props;
13
+ }
14
+ }
@@ -0,0 +1,39 @@
1
+ // A collectible: bobs and spins in place, and despawns into the player's score
2
+ // on touch. No Solid behavior, so its collider is a sensor -- it overlaps
3
+ // instead of blocking, which is what fires onCollisionEnter here.
4
+ //
5
+ // Collection is remembered in the persistent store keyed by scene + actor id,
6
+ // so re-entering a scene doesn't respawn (and double-count) shards
7
+ // -- scene loads otherwise reset the world to its authored state.
8
+ function pickupKey(scene, actor) {
9
+ return `${scene.currentScenePath ?? ''}:${actor.id}`;
10
+ }
11
+
12
+ export class Pickup {
13
+ static behaviorName = 'Pickup';
14
+ static defaultProps = { bob: 0.12, spin: 120 };
15
+
16
+ constructor(props) {
17
+ this.props = props;
18
+ }
19
+
20
+ update(actor, scene, dt) {
21
+ if (scene.persistent?.collected?.has(pickupKey(scene, actor))) {
22
+ scene.despawnActor(actor.id);
23
+ return;
24
+ }
25
+ const t = actor.components.Transform;
26
+ const rt = actor.runtime;
27
+ if (rt.baseY === undefined) rt.baseY = t.y;
28
+ t.y = rt.baseY + Math.sin(scene.time * 2.5) * this.props.bob;
29
+ t.rotationY += this.props.spin * dt;
30
+ }
31
+
32
+ onCollisionEnter(actor, scene, other) {
33
+ if (!other.components.Player) return;
34
+ const per = (scene.persistent ??= {});
35
+ per.score = (per.score ?? 0) + 1;
36
+ (per.collected ??= new Set()).add(pickupKey(scene, actor));
37
+ scene.despawnActor(actor.id);
38
+ }
39
+ }
@@ -0,0 +1,172 @@
1
+ // The walkable player: a dynamic locked-rotation capsule driven by impulses
2
+ // (the vase floating-capsule controller), a faux-isometric follow camera, and
3
+ // the "look at things" reading of nearby Lookables into the hud.
4
+ //
5
+ // Controls: WASD / arrows move (camera-relative), Q / E orbit the camera.
6
+ // State lives on `actor.runtime` -- behavior instances are per-frame.
7
+
8
+ const DEG = Math.PI / 180;
9
+
10
+ function keyDown(keys, ...codes) {
11
+ return codes.some((code) => keys.has(code));
12
+ }
13
+
14
+ // Tank-style drive input: `forward` (-1..1) walks along the facing direction,
15
+ // `turn` (-1..1) steers it. W/S (and up/down arrows) drive, A/D (and
16
+ // left/right) turn. The pointer-drag stick maps the same way -- drag up walks,
17
+ // drag sideways turns, and the drag ANGLE blends the two, so a diagonal drag
18
+ // walks a curve and a pure sideways drag turns in place.
19
+ // Keys turn a bit harder than a full stick deflection -- discrete input wants
20
+ // the snappier response.
21
+ const KEY_TURN = 1.35;
22
+
23
+ function readDrive(keys, stick) {
24
+ let forward = 0;
25
+ let turn = 0;
26
+ if (keyDown(keys, 'KeyW', 'ArrowUp')) forward += 1;
27
+ if (keyDown(keys, 'KeyS', 'ArrowDown')) forward -= 1;
28
+ if (keyDown(keys, 'KeyD', 'ArrowRight')) turn += KEY_TURN;
29
+ if (keyDown(keys, 'KeyA', 'ArrowLeft')) turn -= KEY_TURN;
30
+ if (forward === 0 && turn === 0 && stick?.active && Math.hypot(stick.x, stick.y) >= 0.15) {
31
+ forward = -stick.y;
32
+ turn = stick.x;
33
+ }
34
+ return {
35
+ forward: Math.max(-1, Math.min(1, forward)),
36
+ turn: Math.max(-KEY_TURN, Math.min(KEY_TURN, turn)),
37
+ };
38
+ }
39
+
40
+ // Spring-hover the capsule a touch above the ground (smooths steps and edges);
41
+ // returns whether the player counts as grounded. The spring stays out of the
42
+ // way while the player is on the way up from a jump.
43
+ function applyHover(phys, actor, t, dims, dt) {
44
+ const hit = phys.castRay({ x: t.x, y: t.y, z: t.z }, { x: 0, y: -1, z: 0 }, dims.height, actor);
45
+ const hoverHeight = dims.height / 2 + 0.15;
46
+ const grounded = Boolean(hit && hit.distance < hoverHeight + 0.25);
47
+ const vel = phys.getVelocity(actor);
48
+ if (hit && hit.distance < hoverHeight + 0.4 && vel.y <= 1) {
49
+ const accel = 160 * (hoverHeight - hit.distance) - 18 * vel.y;
50
+ phys.applyImpulse(actor, { x: 0, y: accel * phys.mass(actor) * dt, z: 0 });
51
+ }
52
+ return grounded;
53
+ }
54
+
55
+ function applyMove(phys, actor, move, speed, grounded, dt) {
56
+ const mass = phys.mass(actor);
57
+ const vel = phys.getVelocity(actor);
58
+ if (move) {
59
+ const factor = Math.min(1, dt * 14) * (grounded ? 1 : 0.3);
60
+ phys.applyImpulse(actor, {
61
+ x: (move.x * speed - vel.x) * mass * factor,
62
+ y: 0,
63
+ z: (move.z * speed - vel.z) * mass * factor,
64
+ });
65
+ } else if (grounded) {
66
+ const factor = Math.min(1, dt * 12);
67
+ phys.applyImpulse(actor, { x: -vel.x * mass * factor, y: 0, z: -vel.z * mass * factor });
68
+ }
69
+ }
70
+
71
+ function updateCamera(scene, t, rt, props, dt) {
72
+ const cam = scene.three?.camera;
73
+ if (!cam) return;
74
+ const targetX = t.x - props.camDistance * Math.sin(rt.camAngle);
75
+ const targetY = t.y + props.camHeight;
76
+ const targetZ = t.z + props.camDistance * Math.cos(rt.camAngle);
77
+ // A scene switch teleports the player; snap instead of swooshing the
78
+ // camera across the whole map.
79
+ if (Math.hypot(targetX - cam.position.x, targetZ - cam.position.z) > 20) {
80
+ cam.position.set(targetX, targetY, targetZ);
81
+ } else {
82
+ const lerp = 1 - Math.exp(-12 * dt);
83
+ cam.position.x += (targetX - cam.position.x) * lerp;
84
+ cam.position.y += (targetY - cam.position.y) * lerp;
85
+ cam.position.z += (targetZ - cam.position.z) * lerp;
86
+ }
87
+ cam.lookAt(t.x, t.y + 0.5, t.z);
88
+ }
89
+
90
+ // The nearest Lookable the player is facing, within its range.
91
+ function findLookText(scene, actor, t, facing) {
92
+ const fx = Math.sin(facing);
93
+ const fz = Math.cos(facing);
94
+ let bestText = '';
95
+ let bestDist = Infinity;
96
+ for (const other of scene.actorsWith('Lookable')) {
97
+ if (other.id === actor.id) continue;
98
+ const ot = other.components.Transform;
99
+ if (!ot) continue;
100
+ const dx = ot.x - t.x;
101
+ const dz = ot.z - t.z;
102
+ const dist = Math.hypot(dx, dz);
103
+ const range = other.components.Lookable.range ?? 4;
104
+ if (dist < 0.01 || dist > range || dist >= bestDist) continue;
105
+ if ((dx * fx + dz * fz) / dist < 0.7) continue;
106
+ bestText = other.components.Lookable.text ?? '';
107
+ bestDist = dist;
108
+ }
109
+ return bestText;
110
+ }
111
+
112
+ export class Player {
113
+ static behaviorName = 'Player';
114
+ static defaultProps = { speed: 6, turnSpeed: 2.4, jumpSpeed: 8, camDistance: 9, camHeight: 7 };
115
+
116
+ constructor(props) {
117
+ this.props = props;
118
+ }
119
+
120
+ update(actor, scene, dt) {
121
+ const phys = scene.physics3d;
122
+ if (!phys?.ready()) return;
123
+ const t = actor.components.Transform;
124
+ // Cross-scene state (camera angle, score) lives on the runtime's
125
+ // persistent store so it survives `loadFromFile`; actor.runtime is
126
+ // rebuilt on every scene load.
127
+ const per = (scene.persistent ??= {});
128
+ const rt = actor.runtime;
129
+ if (per.camAngle === undefined) per.camAngle = Math.PI / 4;
130
+ if (per.score === undefined) per.score = 0;
131
+
132
+ const shape = actor.components.Shape;
133
+ const dims = { radius: shape?.radius ?? 0.35, height: shape?.height ?? 1.4 };
134
+ const grounded = applyHover(phys, actor, t, dims, dt);
135
+ const drive = readDrive(scene.keys, scene.moveInput);
136
+ per.camAngle += drive.turn * this.props.turnSpeed * dt;
137
+ rt.facing = Math.PI - per.camAngle;
138
+ const move =
139
+ drive.forward !== 0
140
+ ? {
141
+ x: Math.sin(per.camAngle) * drive.forward,
142
+ z: -Math.cos(per.camAngle) * drive.forward,
143
+ }
144
+ : null;
145
+ applyMove(phys, actor, move, this.props.speed, grounded, dt);
146
+ t.rotationY = rt.facing / DEG;
147
+
148
+ // Jump: Space on keyboard, or a short tap anywhere on the canvas (which
149
+ // works while another finger drives the drag stick). A tap is buffered
150
+ // briefly so one made just before touchdown still fires.
151
+ if (scene.moveInput?.consumeJump?.()) rt.jumpBuffer = 0.18;
152
+ else rt.jumpBuffer = Math.max(0, (rt.jumpBuffer ?? 0) - dt);
153
+ if ((keyDown(scene.keys, 'Space') || rt.jumpBuffer > 0) && grounded) {
154
+ rt.jumpBuffer = 0;
155
+ const vel = phys.getVelocity(actor);
156
+ phys.setVelocity(actor, { x: vel.x, y: this.props.jumpSpeed, z: vel.z });
157
+ }
158
+ // Fall faster than the rise: extra downward pull past the jump apex kills
159
+ // the floaty arc.
160
+ if (!grounded) {
161
+ const vel = phys.getVelocity(actor);
162
+ if (vel.y < 1) phys.applyImpulse(actor, { x: 0, y: -14 * phys.mass(actor) * dt, z: 0 });
163
+ }
164
+
165
+ updateCamera(scene, t, per, this.props, dt);
166
+ const remaining = scene.actorsWith('Pickup').length;
167
+ const total = per.score + remaining;
168
+ const score =
169
+ total === 0 ? '' : remaining === 0 ? '◆ all shards found' : `◆ ${per.score}/${total}`;
170
+ scene.hud = { score, look: findLookText(scene, actor, t, rt.facing) };
171
+ }
172
+ }
@@ -0,0 +1,75 @@
1
+ // The actor's visual: a primitive mesh with a PBR material, and the source of
2
+ // its collider dimensions. `art` names a `.pxart` (or image) file used as the
3
+ // albedo map, nearest-filtered so pixels stay crisp; `color` tints (or is the
4
+ // whole surface when there is no art -- a flat colour is a fine look here).
5
+ // `solid: true` is one of the two ways to make the collider solid (the other is
6
+ // a `Solid` behavior on the actor); without either the collider is a sensor
7
+ // that reports overlaps but doesn't block.
8
+ import React from 'react';
9
+ import { SelectField, TextField } from '@imports/castle.physics-2d/engine/ui';
10
+ import { AutoFields, overrideProps } from '@imports/castle.physics-2d/engine/autoInspector';
11
+
12
+ const KIND_OPTIONS = [
13
+ { value: 'box', label: 'Box' },
14
+ { value: 'sphere', label: 'Sphere' },
15
+ { value: 'capsule', label: 'Capsule' },
16
+ { value: 'cylinder', label: 'Cylinder' },
17
+ ];
18
+
19
+ function ShapeInspector({ component, setComponent, override }) {
20
+ const art = component.art;
21
+ return (
22
+ <>
23
+ <SelectField
24
+ label="Kind"
25
+ value={component.kind}
26
+ options={KIND_OPTIONS}
27
+ onChange={(kind) => setComponent({ kind })}
28
+ {...overrideProps(override, 'kind')}
29
+ />
30
+ {typeof art === 'object' && art !== null ? (
31
+ <TextField label="Art" value="(per-face art -- edit as JSON)" onChange={() => {}} />
32
+ ) : (
33
+ <TextField
34
+ label="Art"
35
+ value={art ?? ''}
36
+ onChange={(next) => setComponent({ art: next })}
37
+ {...overrideProps(override, 'art')}
38
+ />
39
+ )}
40
+ <AutoFields
41
+ defaultProps={Shape.defaultProps}
42
+ component={component}
43
+ setComponent={setComponent}
44
+ exclude={['kind', 'art', 'artRepeat']}
45
+ override={override}
46
+ />
47
+ </>
48
+ );
49
+ }
50
+
51
+ export class Shape {
52
+ static Inspector = ShapeInspector;
53
+ static behaviorName = 'Shape';
54
+ static defaultProps = {
55
+ kind: 'box', // 'box' | 'sphere' | 'capsule' | 'cylinder'
56
+ width: 1,
57
+ height: 1,
58
+ depth: 1,
59
+ radius: 0.5,
60
+ color: '#c7cfdd',
61
+ art: '',
62
+ artRepeat: null,
63
+ tint: '',
64
+ roughness: 0.7,
65
+ metalness: 0,
66
+ clearcoat: 0,
67
+ emissive: '#000000',
68
+ emissiveIntensity: 1,
69
+ solid: false,
70
+ };
71
+
72
+ constructor(props) {
73
+ this.props = props;
74
+ }
75
+ }
@@ -0,0 +1,11 @@
1
+ // Marker: this actor's collider blocks things. Without it (and without
2
+ // `Shape.solid`), colliders are sensors -- they overlap and report it, which is
3
+ // what pickups and trigger zones want.
4
+ export class Solid {
5
+ static behaviorName = 'Solid';
6
+ static defaultProps = {};
7
+
8
+ constructor(props) {
9
+ this.props = props;
10
+ }
11
+ }
@@ -0,0 +1,24 @@
1
+ // Every 3d actor needs a Transform: position and rotation (degrees) in world
2
+ // units, 1 unit = 1 meter. The physics system keeps it in sync with the actor's
3
+ // rapier body -- dynamic bodies write simulated results back here, fixed and
4
+ // kinematic bodies follow edits made here. The renderer reads it every frame,
5
+ // so Transform is the source of truth for where an actor is.
6
+ export class Transform {
7
+ static behaviorName = 'Transform';
8
+ static defaultProps = { x: 0, y: 0, z: 0, rotationX: 0, rotationY: 0, rotationZ: 0 };
9
+ // Position and rotation are always instance-local (like the 2d kit's Layout
10
+ // x/y): they never read as "overriding" a blueprint value in the inspector,
11
+ // and blueprint edits never move placed instances.
12
+ static propertyMeta = {
13
+ x: { inherit: false },
14
+ y: { inherit: false },
15
+ z: { inherit: false },
16
+ rotationX: { inherit: false },
17
+ rotationY: { inherit: false },
18
+ rotationZ: { inherit: false },
19
+ };
20
+
21
+ constructor(props) {
22
+ this.props = props;
23
+ }
24
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "Barrel",
3
+ "actors": [
4
+ {
5
+ "components": {
6
+ "Transform": { "y": 0.45 },
7
+ "Shape": { "kind": "cylinder", "radius": 0.4, "height": 0.9 },
8
+ "Model": { "file": "models/barrel.pxmodel", "yOffset": -0.45 },
9
+ "Body": { "type": "dynamic", "friction": 0.7 },
10
+ "Solid": {},
11
+ "Lookable": { "text": "a barrel. push it around.", "range": 3 }
12
+ }
13
+ }
14
+ ]
15
+ }
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "Crate",
3
+ "actors": [
4
+ {
5
+ "components": {
6
+ "Transform": {
7
+ "y": 0.5
8
+ },
9
+ "Shape": {
10
+ "kind": "box",
11
+ "width": 1,
12
+ "height": 1,
13
+ "depth": 1,
14
+ "art": "drawings/crate.pxart",
15
+ "roughness": 0.85
16
+ },
17
+ "Body": {
18
+ "type": "dynamic",
19
+ "friction": 0.8
20
+ },
21
+ "Solid": {},
22
+ "Lookable": {
23
+ "text": "a crate. push it around.",
24
+ "range": 3
25
+ }
26
+ }
27
+ }
28
+ ]
29
+ }
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "Door",
3
+ "actors": [
4
+ {
5
+ "components": {
6
+ "Transform": {
7
+ "y": 1.1
8
+ },
9
+ "Shape": {
10
+ "kind": "box",
11
+ "width": 2,
12
+ "height": 2.2,
13
+ "depth": 0.3,
14
+ "art": {
15
+ "front": "drawings/door.pxart",
16
+ "back": "drawings/door.pxart",
17
+ "sides": "drawings/door-edge.pxart",
18
+ "top": "drawings/door-edge.pxart"
19
+ },
20
+ "color": "#c9a17a",
21
+ "roughness": 0.8
22
+ },
23
+ "Body": {
24
+ "type": "kinematic"
25
+ },
26
+ "Solid": {},
27
+ "Door": {},
28
+ "Lookable": {
29
+ "text": "a door. it opens when someone comes close.",
30
+ "range": 5
31
+ }
32
+ }
33
+ }
34
+ ]
35
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "Gem",
3
+ "actors": [
4
+ {
5
+ "components": {
6
+ "Transform": {
7
+ "y": 0.7
8
+ },
9
+ "Shape": {
10
+ "kind": "sphere",
11
+ "radius": 0.28,
12
+ "color": "#0cf1ff",
13
+ "roughness": 0.25,
14
+ "metalness": 0.2,
15
+ "emissive": "#0cf1ff",
16
+ "emissiveIntensity": 0.5
17
+ },
18
+ "Pickup": {},
19
+ "Lookable": {
20
+ "text": "a shard. walk into it to collect it.",
21
+ "range": 3.5
22
+ }
23
+ }
24
+ }
25
+ ]
26
+ }