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,265 @@
1
+ // The `.pxmodel` part-based model format: parse / serialize, the three.js
2
+ // builder, and the walk animator. See docs/pxmodel-format.md. A model is a
3
+ // list of named primitive parts with hierarchy -- the 3d analog of `.pxart`,
4
+ // built to be written by hand or by an agent.
5
+ import * as THREE from 'three';
6
+ import { makeShapeMesh, makeMaterial, artRefSignature } from './materials';
7
+ import { buildMeshGeometry } from './meshops';
8
+
9
+ const DEG = Math.PI / 180;
10
+
11
+ export const PART_DEFAULTS = {
12
+ parent: '',
13
+ kind: 'box',
14
+ position: [0, 0, 0],
15
+ rotation: [0, 0, 0],
16
+ offset: [0, 0, 0],
17
+ size: [1, 1, 1],
18
+ radius: 0.5,
19
+ height: 1,
20
+ color: '#c7cfdd',
21
+ art: '',
22
+ artRepeat: null,
23
+ tint: '',
24
+ roughness: 0.7,
25
+ metalness: 0,
26
+ clearcoat: 0,
27
+ emissive: '#000000',
28
+ emissiveIntensity: 1,
29
+ };
30
+
31
+ const VEC3_KEYS = new Set(['position', 'rotation', 'offset', 'size']);
32
+
33
+ function normalizeVec3(value, fallback) {
34
+ if (!Array.isArray(value)) return [...fallback];
35
+ return [0, 1, 2].map((i) => (Number.isFinite(value[i]) ? value[i] : fallback[i]));
36
+ }
37
+
38
+ function normalizePart(raw, index) {
39
+ const part = { ...PART_DEFAULTS, ...(raw ?? {}) };
40
+ part.id = typeof part.id === 'string' && part.id ? part.id : `part-${index + 1}`;
41
+ for (const key of VEC3_KEYS) part[key] = normalizeVec3(part[key], PART_DEFAULTS[key]);
42
+ if (part.kind === 'mesh') {
43
+ part.vertices = Array.isArray(part.vertices)
44
+ ? part.vertices.map((v) => normalizeVec3(v, [0, 0, 0]))
45
+ : [];
46
+ part.faces = Array.isArray(part.faces)
47
+ ? part.faces
48
+ .map((face) =>
49
+ Array.isArray(face) ? face.filter((i) => Number.isInteger(i) && i >= 0) : []
50
+ )
51
+ .filter((face) => face.length >= 3)
52
+ : [];
53
+ }
54
+ return part;
55
+ }
56
+
57
+ // Parse model text into { name, parts } with defaults applied, or null when
58
+ // it isn't a model. Duplicate ids get suffixed; a parent ref that doesn't
59
+ // resolve (or cycles) is dropped to root rather than rejected.
60
+ export function parseModel(text) {
61
+ let json;
62
+ try {
63
+ json = JSON.parse(text);
64
+ } catch {
65
+ return null;
66
+ }
67
+ if (!json || typeof json !== 'object' || !Array.isArray(json.parts)) return null;
68
+ const parts = json.parts.map(normalizePart);
69
+ const seen = new Set();
70
+ for (const part of parts) {
71
+ while (seen.has(part.id)) part.id = `${part.id}-2`;
72
+ seen.add(part.id);
73
+ }
74
+ for (const part of parts) {
75
+ if (part.parent && !seen.has(part.parent)) part.parent = '';
76
+ }
77
+ for (const part of parts) {
78
+ const visited = new Set([part.id]);
79
+ let current = part;
80
+ while (current.parent) {
81
+ if (visited.has(current.parent)) {
82
+ part.parent = '';
83
+ break;
84
+ }
85
+ visited.add(current.parent);
86
+ current = parts.find((candidate) => candidate.id === current.parent) ?? { parent: '' };
87
+ }
88
+ }
89
+ return { name: typeof json.name === 'string' ? json.name : 'Model', parts };
90
+ }
91
+
92
+ const sameVec = (a, b) => a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
93
+
94
+ // Serialize sparsely: only fields that differ from the defaults, so files stay
95
+ // as small as what an author would write by hand.
96
+ export function serializeModel(model) {
97
+ const parts = model.parts.map((part) => {
98
+ const out = { id: part.id };
99
+ if (part.parent) out.parent = part.parent;
100
+ if (part.kind !== 'box') out.kind = part.kind;
101
+ for (const key of ['position', 'rotation', 'offset']) {
102
+ if (!sameVec(part[key], PART_DEFAULTS[key])) out[key] = part[key];
103
+ }
104
+ if (part.kind === 'mesh') {
105
+ out.vertices = part.vertices;
106
+ out.faces = part.faces;
107
+ } else if (part.kind === 'box') {
108
+ if (!sameVec(part.size, PART_DEFAULTS.size)) out.size = part.size;
109
+ } else {
110
+ if (part.radius !== PART_DEFAULTS.radius) out.radius = part.radius;
111
+ if (part.kind !== 'sphere' && part.height !== PART_DEFAULTS.height) out.height = part.height;
112
+ }
113
+ for (const key of [
114
+ 'color',
115
+ 'art',
116
+ 'artRepeat',
117
+ 'tint',
118
+ 'roughness',
119
+ 'metalness',
120
+ 'clearcoat',
121
+ 'emissive',
122
+ 'emissiveIntensity',
123
+ ]) {
124
+ const value = part[key];
125
+ const fallback = PART_DEFAULTS[key];
126
+ const differs = key === 'artRepeat' ? value != null : value !== fallback;
127
+ if (differs) out[key] = value;
128
+ }
129
+ return out;
130
+ });
131
+ return `${JSON.stringify({ format: 'pxmodel', name: model.name, parts }, null, 2)}\n`;
132
+ }
133
+
134
+ export const STARTER_MODEL_TEXT = serializeModel({
135
+ name: 'New Model',
136
+ parts: [normalizePart({ id: 'body', size: [1, 1, 1], position: [0, 0.5, 0] }, 0)],
137
+ });
138
+
139
+ // Bounded parse cache -- model text is re-seen constantly by signatures and
140
+ // rebuilds; parsing is cheap but not free.
141
+ const parseCache = new Map();
142
+ export function parseModelCached(text) {
143
+ if (typeof text !== 'string') return null;
144
+ const cached = parseCache.get(text);
145
+ if (cached !== undefined) return cached;
146
+ if (parseCache.size > 200) parseCache.clear();
147
+ const model = parseModel(text);
148
+ parseCache.set(text, model);
149
+ return model;
150
+ }
151
+
152
+ // A part expressed in the Shape behavior's vocabulary, so the mesh builder is
153
+ // shared with everything else.
154
+ function partAsShape(part) {
155
+ return {
156
+ kind: part.kind,
157
+ width: part.size[0],
158
+ height: part.kind === 'box' ? part.size[1] : part.height,
159
+ depth: part.size[2],
160
+ radius: part.kind === 'sphere' ? part.radius : part.radius,
161
+ color: part.color,
162
+ art: part.art,
163
+ artRepeat: part.artRepeat,
164
+ tint: part.tint,
165
+ roughness: part.roughness,
166
+ metalness: part.metalness,
167
+ clearcoat: part.clearcoat,
168
+ emissive: part.emissive,
169
+ emissiveIntensity: part.emissiveIntensity,
170
+ };
171
+ }
172
+
173
+ // Build the model as a THREE.Group: one group per part at its pivot (child
174
+ // groups nested under their parent's group), the mesh offset inside. The
175
+ // returned group's userData.modelParts maps part id -> group.
176
+ export function buildModelNode(model, sprites) {
177
+ const root = new THREE.Group();
178
+ const groups = new Map();
179
+ for (const part of model.parts) {
180
+ const group = new THREE.Group();
181
+ group.userData.partId = part.id;
182
+ group.position.set(part.position[0], part.position[1], part.position[2]);
183
+ group.rotation.set(part.rotation[0] * DEG, part.rotation[1] * DEG, part.rotation[2] * DEG);
184
+ let mesh;
185
+ if (part.kind === 'mesh') {
186
+ const { geometry, triFaces } = buildMeshGeometry(part);
187
+ mesh = new THREE.Mesh(geometry, makeMaterial(partAsShape(part), null));
188
+ mesh.castShadow = true;
189
+ mesh.receiveShadow = true;
190
+ mesh.userData.triFaces = triFaces;
191
+ } else {
192
+ mesh = makeShapeMesh(partAsShape(part), sprites);
193
+ }
194
+ mesh.userData.partMesh = part.id;
195
+ mesh.position.set(part.offset[0], part.offset[1], part.offset[2]);
196
+ group.add(mesh);
197
+ groups.set(part.id, group);
198
+ }
199
+ for (const part of model.parts) {
200
+ const parent = part.parent ? groups.get(part.parent) : null;
201
+ (parent ?? root).add(groups.get(part.id));
202
+ }
203
+ root.userData.modelParts = groups;
204
+ return root;
205
+ }
206
+
207
+ // Every art ref a model uses, for change signatures.
208
+ export function modelArtSignature(model, sprites) {
209
+ if (!model) return '';
210
+ const refs = [];
211
+ for (const part of model.parts) {
212
+ if (typeof part.art === 'string' && part.art) refs.push(part.art);
213
+ else if (part.art && typeof part.art === 'object') {
214
+ refs.push(...Object.values(part.art).filter((ref) => typeof ref === 'string'));
215
+ }
216
+ }
217
+ return refs.map((ref) => artRefSignature(ref, sprites)).join('|');
218
+ }
219
+
220
+ // Walk animation by part-id convention (legL/legR/armL/armR/torso), driven by
221
+ // observed motion: speed from position deltas, air from vertical speed, lean
222
+ // from yaw rate. dt of 0 (editor views) holds the neutral pose.
223
+ export function animateModelParts(entry, dt) {
224
+ const partGroups = entry.node.userData.modelParts;
225
+ if (!partGroups) return;
226
+ const parts = {
227
+ legL: partGroups.get('legL'),
228
+ legR: partGroups.get('legR'),
229
+ armL: partGroups.get('armL'),
230
+ armR: partGroups.get('armR'),
231
+ torso: partGroups.get('torso'),
232
+ };
233
+ if (!parts.legL && !parts.torso) return;
234
+ const anim = (entry.modelAnim ??= { phase: 0, swing: 0, air: 0, lean: 0, last: null });
235
+ const p = entry.node.position;
236
+ if (dt > 0 && anim.last) {
237
+ const speed = Math.hypot(p.x - anim.last.x, p.z - anim.last.z) / dt;
238
+ const verticalSpeed = (p.y - anim.last.y) / dt;
239
+ anim.swing += (Math.min(1, speed / 5) - anim.swing) * Math.min(1, dt * 10);
240
+ anim.air += ((Math.abs(verticalSpeed) > 1.2 ? 1 : 0) - anim.air) * Math.min(1, dt * 8);
241
+ anim.phase += Math.min(speed, 8) * 2.4 * dt;
242
+ const yaw = entry.node.rotation.y;
243
+ let yawDelta = yaw - (anim.lastYaw ?? yaw);
244
+ yawDelta = Math.atan2(Math.sin(yawDelta), Math.cos(yawDelta));
245
+ const leanTarget = Math.max(-0.13, Math.min(0.13, (yawDelta / dt) * 0.04));
246
+ anim.lean += (leanTarget - anim.lean) * Math.min(1, dt * 9);
247
+ anim.lastYaw = yaw;
248
+ }
249
+ anim.last = { x: p.x, y: p.y, z: p.z };
250
+
251
+ const swing = Math.sin(anim.phase) * 0.8 * anim.swing * (1 - anim.air);
252
+ if (parts.legL) parts.legL.rotation.x = swing + anim.air * -0.55;
253
+ if (parts.legR) parts.legR.rotation.x = -swing + anim.air * -0.3;
254
+ if (parts.armL) parts.armL.rotation.x = -swing * 0.75 + anim.air * -1.5;
255
+ if (parts.armR) parts.armR.rotation.x = swing * 0.75 + anim.air * -1.5;
256
+ if (parts.torso) {
257
+ const base =
258
+ parts.torso.userData.basePosition ??
259
+ (parts.torso.userData.basePosition = parts.torso.position.y);
260
+ parts.torso.position.y =
261
+ base + Math.abs(Math.sin(anim.phase)) * 0.05 * anim.swing * (1 - anim.air);
262
+ parts.torso.rotation.z = -anim.lean;
263
+ parts.torso.rotation.y = anim.lean * 0.4;
264
+ }
265
+ }
@@ -0,0 +1,137 @@
1
+ // 3d blueprint thumbnails for the imported hotbar / inspector chrome. The
2
+ // imported BlueprintThumbnail only knows how to draw `Sprite.file` art from
3
+ // the sprites map, so we render each blueprint template from a corner view
4
+ // into an offscreen canvas, wrap that canvas as an image-shaped art object,
5
+ // and register it in the sprites map under a virtual `@thumb/<path>` ref that
6
+ // gets injected as the template's `Sprite.file`. Renders are cached by the
7
+ // template's node signature; one shared WebGL renderer serves all thumbs.
8
+ import * as THREE from 'three';
9
+ import { makeShapeMesh, nodeSignature, disposeNode } from './materials';
10
+ import { parseModelCached, buildModelNode, modelArtSignature } from './pxmodel';
11
+ import { findBehaviorClass } from '@imports/castle.physics-2d/editors/behaviorRegistry';
12
+
13
+ export const THUMB_PREFIX = '@thumb/';
14
+ const THUMB_SIZE = 192;
15
+
16
+ let sharedRenderer = null;
17
+ let releaseTimer = 0;
18
+
19
+ function ensureRenderer() {
20
+ if (sharedRenderer) return sharedRenderer;
21
+ const canvas = document.createElement('canvas');
22
+ canvas.width = THUMB_SIZE;
23
+ canvas.height = THUMB_SIZE;
24
+ sharedRenderer = new THREE.WebGLRenderer({
25
+ canvas,
26
+ antialias: true,
27
+ alpha: true,
28
+ preserveDrawingBuffer: true,
29
+ });
30
+ sharedRenderer.setClearColor(0x000000, 0);
31
+ return sharedRenderer;
32
+ }
33
+
34
+ // Browsers cap LIVE WebGL contexts (~16); when the cap is hit they kill the
35
+ // oldest, which has white-screened an editor viewport. Thumbs render in
36
+ // bursts and then sit cached, so release this renderer's context shortly
37
+ // after the last render instead of holding a slot forever.
38
+ function releaseRendererSoon() {
39
+ window.clearTimeout(releaseTimer);
40
+ releaseTimer = window.setTimeout(() => {
41
+ if (!sharedRenderer) return;
42
+ sharedRenderer.forceContextLoss();
43
+ sharedRenderer.dispose();
44
+ sharedRenderer = null;
45
+ }, 2000);
46
+ }
47
+
48
+ function renderTemplate(template, sprites, files) {
49
+ const renderer = ensureRenderer();
50
+ const scene = new THREE.Scene();
51
+ // Match the main rig's exposure -- brighter than this blows the albedo out
52
+ // to white.
53
+ scene.add(new THREE.HemisphereLight(0xc8d0d8, 0x3a3530, 0.55 * Math.PI));
54
+ scene.add(new THREE.AmbientLight(0xffffff, 0.25));
55
+ const sun = new THREE.DirectionalLight(0xffffff, 0.9);
56
+ sun.position.set(3, 5, 2);
57
+ scene.add(sun);
58
+
59
+ const node = new THREE.Group();
60
+ const components = template.components ?? {};
61
+ // Templates are raw file data -- merge each behavior's defaultProps like the
62
+ // scene runtime would, so unspecified fields don't go undefined.
63
+ const merged = (name) => {
64
+ const defaults = findBehaviorClass(name)?.defaultProps ?? {};
65
+ return { ...defaults, ...(components[name] ?? {}) };
66
+ };
67
+ const model = components.Model?.file ? parseModelCached(files?.[components.Model.file]) : null;
68
+ if (model) node.add(buildModelNode(model, sprites));
69
+ else if (components.Shape) node.add(makeShapeMesh(merged('Shape'), sprites));
70
+ scene.add(node);
71
+
72
+ const bounds = new THREE.Box3().setFromObject(node);
73
+ const center = bounds.getCenter(new THREE.Vector3());
74
+ const radius = Math.max(0.2, bounds.getSize(new THREE.Vector3()).length() / 2);
75
+ const camera = new THREE.PerspectiveCamera(30, 1, 0.01, 100);
76
+ camera.position
77
+ .set(1, 0.75, 1)
78
+ .normalize()
79
+ .multiplyScalar(radius * 3.9)
80
+ .add(center);
81
+ camera.lookAt(center);
82
+
83
+ renderer.setSize(THUMB_SIZE, THUMB_SIZE, false);
84
+ renderer.render(scene, camera);
85
+
86
+ const out = document.createElement('canvas');
87
+ out.width = THUMB_SIZE;
88
+ out.height = THUMB_SIZE;
89
+ out.getContext('2d')?.drawImage(renderer.domElement, 0, 0);
90
+ disposeNode(node);
91
+ releaseRendererSoon();
92
+ return out;
93
+ }
94
+
95
+ const cache = new Map(); // blueprint path -> { sig, art }
96
+
97
+ // Image-shaped art object for one blueprint, rendered fresh only when its
98
+ // template (or a texture / model file it uses) changed.
99
+ export function thumbArtFor(path, template, sprites, files) {
100
+ const modelFile = template.components?.Model?.file;
101
+ const modelText = modelFile ? (files?.[modelFile] ?? '') : '';
102
+ const modelSig = modelText
103
+ ? `${modelText.length}:${modelArtSignature(parseModelCached(modelText), sprites)}:${modelText}`
104
+ : '';
105
+ const sig = nodeSignature({ components: template.components ?? {} }, sprites, modelSig);
106
+ const cached = cache.get(path);
107
+ if (cached && cached.sig === sig) return cached.art;
108
+ const canvas = renderTemplate(template, sprites, files);
109
+ const art = {
110
+ kind: 'image',
111
+ path,
112
+ url: `${THUMB_PREFIX}${path}`,
113
+ image: canvas,
114
+ ready: true,
115
+ failed: false,
116
+ resolution: { width: canvas.width, height: canvas.height },
117
+ frames: [{ durationMs: 0 }],
118
+ layers: [],
119
+ palette: [],
120
+ tags: [],
121
+ defaultTag: '',
122
+ defaultDurationMs: 0,
123
+ cornerRadius: 0,
124
+ };
125
+ cache.set(path, { sig, art });
126
+ return art;
127
+ }
128
+
129
+ // A template clone whose Sprite points at the virtual thumb ref, for handing
130
+ // to the imported BlueprintThumbnail.
131
+ export function withThumbRef(template, path) {
132
+ if (!template) return template;
133
+ return {
134
+ ...template,
135
+ components: { ...(template.components ?? {}), Sprite: { file: `${THUMB_PREFIX}${path}` } },
136
+ };
137
+ }
@@ -0,0 +1,216 @@
1
+ // The three.js side of the kit: renderer, lighting rig, ground, and per-actor
2
+ // nodes built from Shape components. Values follow the vase reference rig --
3
+ // hemisphere + ambient + one tuned shadow-casting directional, PCFSoft shadows,
4
+ // EffectComposer with RenderPass + OutputPass. Actor Transforms are the source
5
+ // of truth; this module only reads them.
6
+ import * as THREE from 'three';
7
+ import { EffectComposer, RenderPass, OutputPass } from 'three/examples/jsm/Addons.js';
8
+ import {
9
+ makeShapeMesh,
10
+ nodeSignature,
11
+ disposeNode,
12
+ resolveArtTexture,
13
+ artRefSignature,
14
+ } from './materials';
15
+ import { parseModelCached, buildModelNode, modelArtSignature, animateModelParts } from './pxmodel';
16
+
17
+ const GROUND_SIZE = 60;
18
+
19
+ export function makeWorld3D(canvas) {
20
+ const scene = new THREE.Scene();
21
+ const aspect = canvas.clientWidth / Math.max(1, canvas.clientHeight);
22
+ const camera = new THREE.PerspectiveCamera(50, aspect, 0.1, 1000);
23
+ camera.position.set(8, 8, 8);
24
+ camera.lookAt(0, 0, 0);
25
+
26
+ const renderer = new THREE.WebGLRenderer({
27
+ canvas,
28
+ antialias: true,
29
+ preserveDrawingBuffer: true, // screenshots read the canvas after the frame
30
+ });
31
+ // If the browser reclaims this context (live-context cap), ask for the
32
+ // automatic restore instead of staying a white canvas forever.
33
+ canvas.addEventListener('webglcontextlost', (event) => event.preventDefault());
34
+ renderer.setPixelRatio(window.devicePixelRatio);
35
+ renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
36
+ renderer.setClearColor(0x000000);
37
+ renderer.shadowMap.enabled = true;
38
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap;
39
+
40
+ addLights(scene);
41
+ const { ground, grid } = addGround(scene);
42
+
43
+ const composer = new EffectComposer(renderer);
44
+ composer.addPass(new RenderPass(scene, camera));
45
+ composer.addPass(new OutputPass());
46
+
47
+ // Collider wireframes from rapier's debugRender, toggled with `p`.
48
+ const debugLines = new THREE.LineSegments(
49
+ new THREE.BufferGeometry(),
50
+ new THREE.LineBasicMaterial({ vertexColors: true })
51
+ );
52
+ debugLines.visible = false;
53
+ debugLines.frustumCulled = false;
54
+ scene.add(debugLines);
55
+
56
+ return { scene, camera, renderer, composer, nodes: new Map(), debugLines, ground, grid };
57
+ }
58
+
59
+ export function updatePhysicsDebug(world, buffers) {
60
+ if (!buffers) return;
61
+ const geometry = new THREE.BufferGeometry();
62
+ geometry.setAttribute('position', new THREE.BufferAttribute(buffers.vertices, 3));
63
+ geometry.setAttribute('color', new THREE.BufferAttribute(buffers.colors, 4));
64
+ world.debugLines.geometry.dispose();
65
+ world.debugLines.geometry = geometry;
66
+ }
67
+
68
+ function addLights(scene) {
69
+ // The vase rig, lifted -- brighter overall than the raw vase values, with a
70
+ // stronger shadow term so shadows stay readable against the lift.
71
+ scene.add(new THREE.HemisphereLight(0xc8d0d8, 0x3a3530, 0.62 * Math.PI));
72
+ scene.add(new THREE.AmbientLight(0xffffff, 0.24));
73
+
74
+ const sun = new THREE.DirectionalLight(0xffffff, 1.15);
75
+ sun.position.set(6, 18, -6);
76
+ sun.castShadow = true;
77
+ sun.shadow.intensity = 0.65;
78
+ sun.shadow.mapSize.width = 2048;
79
+ sun.shadow.mapSize.height = 2048;
80
+ sun.shadow.camera.near = 0.5;
81
+ sun.shadow.camera.far = 50;
82
+ sun.shadow.camera.top = 85;
83
+ sun.shadow.camera.bottom = -35;
84
+ sun.shadow.camera.left = -75;
85
+ sun.shadow.camera.right = 75;
86
+ sun.shadow.normalBias = -0.14;
87
+ scene.add(sun);
88
+ }
89
+
90
+ function addGround(scene) {
91
+ const ground = new THREE.Mesh(
92
+ new THREE.PlaneGeometry(GROUND_SIZE, GROUND_SIZE),
93
+ new THREE.MeshStandardMaterial({ color: 0x22222b, dithering: true })
94
+ );
95
+ ground.rotation.x = -Math.PI / 2;
96
+ ground.position.y = -0.01;
97
+ ground.receiveShadow = true;
98
+ scene.add(ground);
99
+
100
+ const grid = new THREE.GridHelper(GROUND_SIZE, GROUND_SIZE, 0x505050, 0x505050);
101
+ grid.material.transparent = true;
102
+ grid.material.opacity = 0.35;
103
+ scene.add(grid);
104
+ return { ground, grid };
105
+ }
106
+
107
+ // A scene can dress the ground: `"ground": { "art": "drawings/floor.pxart",
108
+ // "tile": 2, "color": "#..." }` at the scene file's top level. `tile` is the
109
+ // world size of one texture repeat (meters). The line grid only shows on the
110
+ // untextured ground -- over art it reads as noise.
111
+ export function syncGround(world, setting, sprites) {
112
+ const sig = `${JSON.stringify(setting ?? null)}|${artRefSignature(setting?.art, sprites)}`;
113
+ if (sig === world.groundSig) return;
114
+ const tile = setting?.tile > 0 ? setting.tile : 2;
115
+ const repeat = [GROUND_SIZE / tile, GROUND_SIZE / tile];
116
+ const texture = setting?.art ? resolveArtTexture(setting.art, sprites, repeat) : null;
117
+ world.groundSig = sig;
118
+ const material = new THREE.MeshStandardMaterial({
119
+ color: new THREE.Color(setting?.color ?? (texture ? '#ffffff' : '#22222b')),
120
+ map: texture,
121
+ dithering: true,
122
+ });
123
+ world.ground.material.dispose();
124
+ world.ground.material = material;
125
+ world.grid.visible = !texture;
126
+ }
127
+
128
+ // The signature suffix for an actor's Model component: the model file's text
129
+ // plus the readiness/identity of any art it references.
130
+ function actorModelSig(actor, sprites, files) {
131
+ const file = actor.components.Model?.file;
132
+ if (!file) return '';
133
+ const text = files?.[file];
134
+ if (typeof text !== 'string') return 'missing';
135
+ return `${text.length}:${modelArtSignature(parseModelCached(text), sprites)}:${text}`;
136
+ }
137
+
138
+ // Build / refresh the node for one actor. Rebuilds only when what it looks
139
+ // like (Shape / Model / their art) changes. A Model replaces the Shape's own
140
+ // mesh with the .pxmodel rig; the Shape still sizes the physics collider.
141
+ function ensureActorNode(world, actor, sprites, files) {
142
+ const sig = nodeSignature(actor, sprites, actorModelSig(actor, sprites, files));
143
+ let entry = world.nodes.get(actor.id);
144
+ if (entry && entry.sig !== sig) {
145
+ world.scene.remove(entry.node);
146
+ disposeNode(entry.node);
147
+ entry = null;
148
+ }
149
+ if (!entry) {
150
+ const node = new THREE.Group();
151
+ node.userData.actorId = actor.id; // raycast hits map back to the actor
152
+ const modelComponent = actor.components.Model;
153
+ const shape = actor.components.Shape;
154
+ const model = modelComponent?.file ? parseModelCached(files?.[modelComponent.file]) : null;
155
+ if (model) {
156
+ const rig = buildModelNode(model, sprites);
157
+ rig.position.y = modelComponent.yOffset ?? 0;
158
+ const scale = modelComponent.scale ?? 1;
159
+ rig.scale.set(scale, scale, scale);
160
+ node.userData.modelParts = rig.userData.modelParts;
161
+ node.add(rig);
162
+ } else if (shape) {
163
+ node.add(makeShapeMesh(shape, sprites));
164
+ }
165
+ world.scene.add(node);
166
+ entry = { node, sig };
167
+ world.nodes.set(actor.id, entry);
168
+ }
169
+ return entry;
170
+ }
171
+
172
+ const DEG = Math.PI / 180;
173
+
174
+ // Copy actor Transforms into three nodes, creating and removing nodes as
175
+ // actors come and go. Dynamic bodies leave an exact quaternion in
176
+ // `actor.runtime.quat`; everything else rotates by the Transform's euler
177
+ // degrees.
178
+ export function syncActors(world, runtime, sprites, dt = 0) {
179
+ syncGround(world, runtime.data?.ground, sprites);
180
+ const seen = new Set();
181
+ for (const actor of runtime.getActors()) {
182
+ const t = actor.components.Transform;
183
+ if (!t) continue;
184
+ seen.add(actor.id);
185
+ const entry = ensureActorNode(world, actor, sprites, runtime.files);
186
+ const node = entry.node;
187
+ node.position.set(t.x, t.y, t.z);
188
+ const quat = actor.runtime?.quat;
189
+ if (quat) node.quaternion.set(quat.x, quat.y, quat.z, quat.w);
190
+ else node.rotation.set(t.rotationX * DEG, t.rotationY * DEG, t.rotationZ * DEG);
191
+ animateModelParts(entry, dt);
192
+ }
193
+ for (const [id, entry] of world.nodes) {
194
+ if (seen.has(id)) continue;
195
+ world.scene.remove(entry.node);
196
+ disposeNode(entry.node);
197
+ world.nodes.delete(id);
198
+ }
199
+ }
200
+
201
+ export function resizeWorld(world, width, height) {
202
+ world.renderer.setSize(width, height, false);
203
+ world.composer.setSize(width, height);
204
+ world.camera.aspect = width / Math.max(1, height);
205
+ world.camera.updateProjectionMatrix();
206
+ }
207
+
208
+ export function renderWorld(world) {
209
+ world.composer.render();
210
+ }
211
+
212
+ export function disposeWorld(world) {
213
+ for (const entry of world.nodes.values()) disposeNode(entry.node);
214
+ world.nodes.clear();
215
+ world.renderer.dispose();
216
+ }
@@ -3,32 +3,17 @@ import reactHooks from 'eslint-plugin-react-hooks';
3
3
 
4
4
  export default [
5
5
  {
6
- ignores: ['node_modules/**', 'dist/**', '.castle/**', '*.config.js'],
6
+ ignores: ['node_modules/**', 'dist/**', '.castle/**', 'imports/**', '*.config.js'],
7
7
  },
8
8
  js.configs.recommended,
9
9
  {
10
- // Node-side kit tooling (e.g. the `npm run draw` art generator). Runs under
11
- // Node, not the browser, so it gets Node globals.
12
- files: ['scripts/**/*.{js,mjs}'],
13
- languageOptions: {
14
- ecmaVersion: 'latest',
15
- sourceType: 'module',
16
- globals: {
17
- process: 'readonly',
18
- Buffer: 'readonly',
19
- console: 'readonly',
20
- URL: 'readonly',
21
- setTimeout: 'readonly',
22
- clearTimeout: 'readonly',
23
- },
24
- },
25
- rules: {
26
- 'no-undef': 'off',
27
- 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
28
- },
29
- },
30
- {
31
- files: ['engine/**/*.{js,jsx}', 'editors/**/*.{js,jsx}', 'behaviors/**/*.{js,jsx}', '*.js', '*.jsx'],
10
+ files: [
11
+ 'engine3d/**/*.{js,jsx}',
12
+ 'systems/**/*.{js,jsx}',
13
+ 'behaviors/**/*.{js,jsx}',
14
+ '*.js',
15
+ '*.jsx',
16
+ ],
32
17
  languageOptions: {
33
18
  ecmaVersion: 'latest',
34
19
  sourceType: 'module',
@@ -45,16 +30,16 @@ export default [
45
30
  setTimeout: 'readonly',
46
31
  clearTimeout: 'readonly',
47
32
  URL: 'readonly',
33
+ URLSearchParams: 'readonly',
48
34
  fetch: 'readonly',
49
35
  navigator: 'readonly',
50
36
  location: 'readonly',
51
37
  HTMLCanvasElement: 'readonly',
52
38
  Image: 'readonly',
53
- FileReader: 'readonly',
39
+ ResizeObserver: 'readonly',
54
40
  KeyboardEvent: 'readonly',
55
41
  PointerEvent: 'readonly',
56
42
  MouseEvent: 'readonly',
57
- TouchEvent: 'readonly',
58
43
  structuredClone: 'readonly',
59
44
  },
60
45
  },