@khorsheed/dsh-game 0.0.0-stage → 0.1.1

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 (101) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.en.md +53 -0
  3. package/README.i18n.yaml +6 -0
  4. package/README.md +52 -2
  5. package/cordis.patch.yml +23 -0
  6. package/lib/client.js +30690 -0
  7. package/lib/index.js +4297 -0
  8. package/lib/typert.host.d.ts +3 -0
  9. package/lib/typert.host.js +3876 -0
  10. package/lib/typert.remote-client.d.ts +104 -0
  11. package/lib/typert.remote-client.js +2138 -0
  12. package/lib/types/agent.d.ts +20 -0
  13. package/lib/types/agent.js +43 -0
  14. package/lib/types/checks.d.ts +44 -0
  15. package/lib/types/checks.js +267 -0
  16. package/lib/types/client/DesignView.d.ts +22 -0
  17. package/lib/types/client/DesignView.js +134 -0
  18. package/lib/types/client/ElementsView.d.ts +21 -0
  19. package/lib/types/client/ElementsView.js +195 -0
  20. package/lib/types/client/GameTab.d.ts +14 -0
  21. package/lib/types/client/GameTab.js +261 -0
  22. package/lib/types/client/PlayFrame.d.ts +54 -0
  23. package/lib/types/client/PlayFrame.js +129 -0
  24. package/lib/types/client/PlayView.d.ts +19 -0
  25. package/lib/types/client/PlayView.js +163 -0
  26. package/lib/types/client/Preview3D.d.ts +52 -0
  27. package/lib/types/client/Preview3D.js +178 -0
  28. package/lib/types/client/ProjectSwitcher.d.ts +20 -0
  29. package/lib/types/client/ProjectSwitcher.js +75 -0
  30. package/lib/types/client/Recipe3DEditor.d.ts +19 -0
  31. package/lib/types/client/Recipe3DEditor.js +44 -0
  32. package/lib/types/client/StyleGallery.d.ts +18 -0
  33. package/lib/types/client/StyleGallery.js +27 -0
  34. package/lib/types/client/TurnCard.d.ts +62 -0
  35. package/lib/types/client/TurnCard.js +100 -0
  36. package/lib/types/client/VersionsView.d.ts +17 -0
  37. package/lib/types/client/VersionsView.js +68 -0
  38. package/lib/types/client/chat-node.d.ts +42 -0
  39. package/lib/types/client/chat-node.js +77 -0
  40. package/lib/types/client/contract.d.ts +131 -0
  41. package/lib/types/client/contract.js +6 -0
  42. package/lib/types/client/definition.d.ts +22 -0
  43. package/lib/types/client/definition.js +28 -0
  44. package/lib/types/client/index.d.ts +21 -0
  45. package/lib/types/client/index.js +263 -0
  46. package/lib/types/client/locales.d.ts +379 -0
  47. package/lib/types/client/locales.js +733 -0
  48. package/lib/types/client/looks.d.ts +36 -0
  49. package/lib/types/client/looks.js +105 -0
  50. package/lib/types/client/markdown.d.ts +11 -0
  51. package/lib/types/client/markdown.js +52 -0
  52. package/lib/types/client/preset-visibility.d.ts +94 -0
  53. package/lib/types/client/preset-visibility.js +115 -0
  54. package/lib/types/client/preview-entry.d.ts +24 -0
  55. package/lib/types/client/preview-entry.js +110 -0
  56. package/lib/types/client/refs.d.ts +36 -0
  57. package/lib/types/client/refs.js +44 -0
  58. package/lib/types/client/reveal.d.ts +43 -0
  59. package/lib/types/client/reveal.js +73 -0
  60. package/lib/types/client/style-samples.d.ts +5 -0
  61. package/lib/types/client/style-samples.js +196 -0
  62. package/lib/types/fsutil.d.ts +26 -0
  63. package/lib/types/fsutil.js +104 -0
  64. package/lib/types/hooks.d.ts +83 -0
  65. package/lib/types/hooks.js +108 -0
  66. package/lib/types/index.d.ts +30 -0
  67. package/lib/types/index.js +22 -0
  68. package/lib/types/kits/canvas2d.d.ts +6 -0
  69. package/lib/types/kits/canvas2d.js +60 -0
  70. package/lib/types/kits/ids.d.ts +11 -0
  71. package/lib/types/kits/ids.js +6 -0
  72. package/lib/types/kits/index.d.ts +26 -0
  73. package/lib/types/kits/index.js +28 -0
  74. package/lib/types/kits/three3d.d.ts +12 -0
  75. package/lib/types/kits/three3d.js +188 -0
  76. package/lib/types/kits/types.d.ts +52 -0
  77. package/lib/types/kits/types.js +6 -0
  78. package/lib/types/look3d.d.ts +20 -0
  79. package/lib/types/look3d.js +175 -0
  80. package/lib/types/panel-session.d.ts +21 -0
  81. package/lib/types/panel-session.js +16 -0
  82. package/lib/types/project.d.ts +138 -0
  83. package/lib/types/project.js +504 -0
  84. package/lib/types/remote.d.ts +184 -0
  85. package/lib/types/remote.js +263 -0
  86. package/lib/types/skills.d.ts +13 -0
  87. package/lib/types/skills.js +141 -0
  88. package/lib/types/store.d.ts +346 -0
  89. package/lib/types/store.js +1383 -0
  90. package/lib/types/tools.d.ts +12 -0
  91. package/lib/types/tools.js +309 -0
  92. package/lib/types/trash.d.ts +5 -0
  93. package/lib/types/trash.js +64 -0
  94. package/lib/types/types.d.ts +548 -0
  95. package/lib/types/types.js +85 -0
  96. package/lib/types/vfs.d.ts +94 -0
  97. package/lib/types/vfs.js +217 -0
  98. package/locale/en.json +6 -0
  99. package/locale/zh.json +6 -0
  100. package/package.json +132 -3
  101. package/runtime/dsh3d.js +1279 -0
@@ -0,0 +1,1279 @@
1
+ /**
2
+ * dsh3d — the 3d-three kit's thin runtime (proposal 2026-10-21-game-3d §4.2).
3
+ *
4
+ * Served into the game sandbox as `_dsh/dsh3d.js`; games import createWorld,
5
+ * build and friends from the bare name dsh3d (the loader maps it). It sits on
6
+ * three.js and gives a game four things:
7
+ *
8
+ * - `build(look)`: a `parts3d` recipe → a model with procedural motion.
9
+ * - Styles as data: toon shading bands, an inverted-hull outline, background,
10
+ * ground and fog colors. `ink` and `qinglv` are starting presets; a game's
11
+ * own `game.json` `style3d` (written from the user's description) overrides
12
+ * any key. A restyle swaps only this.
13
+ * - Open vocabulary: `defineBase(name, fn)` / `definePart(type, fn)` let a
14
+ * project add bodies and parts the built-ins do not cover.
15
+ * - `createWorld()`: the 45° orthographic camera, the loop (`step(dt)` for
16
+ * tests, `start()` for play), terrain, entity registry, scenes
17
+ * (`enterScene`: terrain, the scene's own style overrides and everything
18
+ * registered inside, released together on the next switch) and `scatter`
19
+ * (one element instanced across the ground, still pickable).
20
+ * - The workbench hooks: `pick(x, y)` returns a structured reference for the
21
+ * panel's 「指一下」, `pause(on)` freezes time, `highlight(list)` rings the
22
+ * entities a candidate version changed.
23
+ *
24
+ * Inside the workbench's checks (`window.__GAMEDEV_TEST__`) `step` advances
25
+ * logic and the camera but never draws; `render()` draws on demand (the smoke probe
26
+ * calls it), so a long test stays cheap.
27
+ *
28
+ * Plain ES module, no build step: what is here is what runs. Never spell an
29
+ * import statement in a comment here: the sandbox loader rewrites import
30
+ * specifiers textually, so a quoted example would be rewritten too.
31
+ */
32
+ import * as THREE from 'three'
33
+
34
+ export { THREE }
35
+
36
+ /* ------------------------------------------------------------------ styles */
37
+
38
+ /** The preset 3D styles (starting points, not the full set). Colors are sRGB hex. */
39
+ export const STYLES = {
40
+ ink: {
41
+ label: '水墨', bg: '#efe7d6', fog: [26, 62], ground: '#e5dbc5', ground2: '#d6c9ac', water: '#a7bab4',
42
+ outline: '#2a2622', outlineWidth: 0.032, hemi: ['#fffaf0', '#c9bda4', 1.4], sun: 1.1,
43
+ },
44
+ qinglv: {
45
+ label: '青绿山水', bg: '#f1e8cf', fog: [28, 66], ground: '#d9d9b0', ground2: '#c3cc96', water: '#77a9bb',
46
+ outline: '#4f3b28', outlineWidth: 0.022, hemi: ['#fff8e6', '#b8c49a', 1.4], sun: 1.2,
47
+ },
48
+ }
49
+
50
+ let style = STYLES.ink
51
+ const materials = new Map()
52
+ let outlineMaterial = null
53
+
54
+ const gradients = new Map()
55
+ /** A toon ramp with `shades` bands (2 = hard cel … 5 = soft). */
56
+ function gradientFor(shades) {
57
+ const n = Math.min(5, Math.max(2, Math.round(shades || 3)))
58
+ if (!gradients.has(n)) {
59
+ const data = new Uint8Array(n * 4)
60
+ for (let i = 0; i < n; i++) { const v = Math.round(105 + 150 * i / (n - 1)); data.set([v, v, v, 255], i * 4) }
61
+ const t = new THREE.DataTexture(data, n, 1)
62
+ t.minFilter = t.magFilter = THREE.NearestFilter
63
+ t.needsUpdate = true
64
+ gradients.set(n, t)
65
+ }
66
+ return gradients.get(n)
67
+ }
68
+
69
+ /**
70
+ * Resolve a style: a preset name, or an object of overrides on a preset
71
+ * (`{ preset: 'ink', bg: '#…', outline: '#…', outlineWidth, shades, fog: [near, far], light }`).
72
+ */
73
+ export function resolveStyle(input) {
74
+ const spec = typeof input === 'string' ? { preset: input } : (input ?? {})
75
+ const base = STYLES[spec.preset] ?? STYLES.ink
76
+ const out = { ...base }
77
+ for (const key of ['bg', 'ground', 'ground2', 'water', 'outline', 'outlineWidth', 'shades', 'shadows', 'rim', 'rimColor']) if (spec[key] !== undefined) out[key] = spec[key]
78
+ if (Array.isArray(spec.fog) && spec.fog.length === 2) out.fog = spec.fog
79
+ if (typeof spec.light === 'number') out.sun = spec.light
80
+ return out
81
+ }
82
+
83
+ /** Switch the active style (call before building; `createWorld` does it for you). */
84
+ export function useStyle(input) {
85
+ style = resolveStyle(input)
86
+ materials.clear()
87
+ outlineMaterial?.dispose()
88
+ outlineMaterial = null
89
+ return style
90
+ }
91
+
92
+ /** The style a game asked for in game.json: its `style3d`, else a preset named by `artStyle`, else ink. */
93
+ function manifestStyle() {
94
+ const m = window.dshManifest ?? {}
95
+ if (m.style3d && typeof m.style3d === 'object') return m.style3d
96
+ return typeof m.artStyle === 'string' && STYLES[m.artStyle] ? m.artStyle : 'ink'
97
+ }
98
+
99
+ /** The active style. */
100
+ export function currentStyle() { return style }
101
+
102
+ /**
103
+ * The style's rim light (style3d `rim`, `rimColor`) on a toon material: a
104
+ * bright edge where the surface turns away from the camera, which lifts a
105
+ * character off the ground. No rim in the style leaves the material as it is.
106
+ */
107
+ function rimmed(material) {
108
+ const strength = num(style.rim, 0)
109
+ if (strength <= 0) return material
110
+ const color = new THREE.Color(style.rimColor ?? '#ffffff')
111
+ material.onBeforeCompile = shader => {
112
+ shader.uniforms.rimStrength = { value: strength }
113
+ shader.uniforms.rimColor = { value: color }
114
+ shader.fragmentShader = 'uniform float rimStrength; uniform vec3 rimColor;\n' + shader.fragmentShader.replace('#include <dithering_fragment>',
115
+ 'float rimF = 1.0 - max(dot(normalize(normal), normalize(vViewPosition)), 0.0);\ngl_FragColor.rgb += rimColor * rimStrength * pow(rimF, 3.0);\n#include <dithering_fragment>')
116
+ }
117
+ material.customProgramCacheKey = () => 'dsh3d-rim'
118
+ return material
119
+ }
120
+
121
+ /**
122
+ * Toon-shaded material for a color (cached: every caller with the same color
123
+ * and extras shares one). `{ own: true }` gives a private copy instead — for
124
+ * one object to glow, flash or fade without touching the rest.
125
+ */
126
+ export function toon(color, extra) {
127
+ if (extra?.own) { const { own, ...rest } = extra; return rimmed(new THREE.MeshToonMaterial({ color, gradientMap: gradientFor(style.shades), ...rest })) }
128
+ const key = `t${color}${extra ? JSON.stringify(extra) : ''}`
129
+ if (!materials.has(key)) materials.set(key, rimmed(new THREE.MeshToonMaterial({ color, gradientMap: gradientFor(style.shades), ...extra })))
130
+ return materials.get(key)
131
+ }
132
+
133
+ /** Flat-shaded material for terrain, rocks and trees (cached; `{ own: true }` gives a private copy). */
134
+ export function flat(color, extra) {
135
+ if (extra?.own) { const { own, ...rest } = extra; return new THREE.MeshLambertMaterial({ color, flatShading: true, ...rest }) }
136
+ const key = `f${color}${extra ? JSON.stringify(extra) : ''}`
137
+ if (!materials.has(key)) materials.set(key, new THREE.MeshLambertMaterial({ color, flatShading: true, ...extra }))
138
+ return materials.get(key)
139
+ }
140
+
141
+ function outline() {
142
+ outlineMaterial ??= new THREE.ShaderMaterial({
143
+ uniforms: { c: { value: new THREE.Color(style.outline) }, w: { value: style.outlineWidth } },
144
+ // One hull for every case: USE_INSTANCING (scatter's copies), USE_SKINNING (a glTF
145
+ // character's bones) and USE_MORPHTARGETS (its shape keys) come from three's own chunks,
146
+ // which compile to nothing otherwise.
147
+ vertexShader: [
148
+ '#include <common>', '#include <morphtarget_pars_vertex>', '#include <skinning_pars_vertex>', 'uniform float w;', 'void main(){',
149
+ '#include <morphinstance_vertex>', '#include <beginnormal_vertex>', '#include <morphnormal_vertex>', '#include <skinbase_vertex>', '#include <skinnormal_vertex>',
150
+ '#include <begin_vertex>', '#include <morphtarget_vertex>', '#include <skinning_vertex>',
151
+ 'vec4 p = vec4(transformed, 1.0); vec3 nn = objectNormal;',
152
+ '#ifdef USE_INSTANCING', 'p = instanceMatrix * p; nn = mat3(instanceMatrix) * nn;', '#endif',
153
+ 'vec4 mv = modelViewMatrix * p; vec3 n = normalize(normalMatrix * nn); mv.xyz += n * w; gl_Position = projectionMatrix * mv; }',
154
+ ].join('\n'),
155
+ fragmentShader: 'uniform vec3 c; void main(){ gl_FragColor = vec4(c,1.0); }',
156
+ side: THREE.BackSide,
157
+ })
158
+ return outlineMaterial
159
+ }
160
+
161
+ /**
162
+ * A mesh with the style's ink outline (an inverted hull: plain geometry, so
163
+ * it also survives a glTF export unchanged).
164
+ */
165
+ export function ink(geometry, material, withOutline = true) {
166
+ const mesh = new THREE.Mesh(geometry, material)
167
+ if (withOutline && style.outlineWidth > 0) {
168
+ const hull = new THREE.Mesh(geometry, outline())
169
+ hull.raycast = () => {}
170
+ hull.userData.outline = true
171
+ mesh.add(hull)
172
+ }
173
+ return mesh
174
+ }
175
+
176
+ /* ---------------------------------------------------------------- builders */
177
+
178
+ const deg = THREE.MathUtils.degToRad
179
+ const num = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d)
180
+
181
+ /** A palette key or `#hex` → color, with a fallback. */
182
+ function colorOf(look, key, fallback) {
183
+ if (typeof key === 'string' && key.startsWith('#')) return key
184
+ return look.palette?.[key] ?? fallback
185
+ }
186
+
187
+ function partsOf(look, type) { return (look.parts ?? []).filter(p => p.type === type) }
188
+
189
+ function sphere(r, seg = 14) { return new THREE.SphereGeometry(r, seg, Math.max(6, Math.round(seg * 0.6))) }
190
+
191
+ /**
192
+ * A tail chain (three bendable segments) per tail, fanned around the body
193
+ * axis. Returns the segment groups for the sway animation.
194
+ */
195
+ function addTails(anchor, look, spec, bodyColor) {
196
+ const n = num(spec.count, 1), fan = deg(num(spec.fan, n > 1 ? 120 : 0)), L = num(spec.length, 0.9) / 3
197
+ const thick = num(spec.thickness, 0.09)
198
+ const tipColor = colorOf(look, spec.color ?? 'accent', bodyColor)
199
+ const chains = []
200
+ for (let i = 0; i < n; i++) {
201
+ const fanG = new THREE.Group()
202
+ fanG.rotation.x = n > 1 ? -fan / 2 + fan * i / (n - 1) : 0
203
+ anchor.add(fanG)
204
+ const tilt = new THREE.Group()
205
+ tilt.rotation.z = 0.95
206
+ fanG.add(tilt)
207
+ let parent = tilt
208
+ const segs = []
209
+ for (let k = 0; k < 3; k++) {
210
+ const seg = new THREE.Group()
211
+ seg.position.y = k === 0 ? 0 : L
212
+ if (k > 0) seg.rotation.z = -0.32
213
+ parent.add(seg)
214
+ const rb = thick * [0.7, 1.2, 1.0][k], rt = thick * [1.2, 1.0, 0.25][k]
215
+ const mat = toon(k === 2 ? tipColor : bodyColor)
216
+ const m = ink(new THREE.CylinderGeometry(rt, rb, L, 9, 1, true), mat)
217
+ m.position.y = L / 2
218
+ seg.add(m, ink(sphere(rb, 9), mat))
219
+ segs.push(seg)
220
+ parent = seg
221
+ }
222
+ chains.push(segs)
223
+ }
224
+ return chains
225
+ }
226
+
227
+ /** A pair (or more) of flat wings on a pivot, for the flap animation. */
228
+ function addWings(anchor, look, spec, bodyColor, span = 0.55) {
229
+ const n = Math.max(2, num(spec.count, 2)), size = num(spec.size, 1)
230
+ const mat = toon(colorOf(look, spec.color ?? 'accent', bodyColor))
231
+ const wings = []
232
+ for (let i = 0; i < n; i++) {
233
+ const side = i % 2 === 0 ? 1 : -1, row = Math.floor(i / 2)
234
+ const pivot = new THREE.Group()
235
+ pivot.position.set(-row * 0.18, 0, side * 0.12)
236
+ anchor.add(pivot)
237
+ const w = ink(new THREE.BoxGeometry(0.34 * size, 0.025, span * size), mat)
238
+ w.position.z = side * span * size / 2
239
+ pivot.add(w)
240
+ wings.push({ pivot, side })
241
+ }
242
+ return wings
243
+ }
244
+
245
+ /** Eyes across the front of a head (one, two, three or more). */
246
+ function addEyes(head, look, r, count) {
247
+ const mat = toon(colorOf(look, 'eye', '#1d1d1f'))
248
+ const n = Math.max(1, count)
249
+ for (let i = 0; i < n; i++) {
250
+ const z = n === 1 ? 0 : (i / (n - 1) - 0.5) * r * 1.2
251
+ const e = new THREE.Mesh(sphere(r * 0.16, 8), mat)
252
+ e.position.set(r * 0.74, r * (n === 3 && i === 1 ? 0.5 : 0.28), z)
253
+ head.add(e)
254
+ }
255
+ }
256
+
257
+ /** A human face: a pale disc with eyes and a mouth on the front of a head. */
258
+ function addHumanFace(head, look, r) {
259
+ const skin = toon(colorOf(look, 'face', '#f0d6bd')), dark = toon(colorOf(look, 'eye', '#1d1d1f'))
260
+ const face = ink(sphere(r * 0.62, 12), skin)
261
+ face.scale.set(0.45, 1, 1)
262
+ face.position.set(r * 0.82, 0, 0)
263
+ head.add(face)
264
+ for (const s of [-1, 1]) {
265
+ const e = new THREE.Mesh(sphere(r * 0.09, 6), dark)
266
+ e.position.set(r * 1.08, r * 0.2, s * r * 0.24)
267
+ head.add(e)
268
+ }
269
+ const mouth = new THREE.Mesh(new THREE.BoxGeometry(0.01, r * 0.05, r * 0.28), dark)
270
+ mouth.position.set(r * 1.1, -r * 0.25, 0)
271
+ head.add(mouth)
272
+ }
273
+
274
+ /** Horns and ears on a head. */
275
+ function addHeadParts(head, look, r, bodyColor) {
276
+ for (const spec of partsOf(look, 'ear')) {
277
+ const n = num(spec.count, 2), size = num(spec.size, r * 0.75)
278
+ const mat = toon(colorOf(look, spec.color ?? 'accent', bodyColor))
279
+ for (let i = 0; i < n; i++) {
280
+ const side = n === 1 ? 0 : (i / (n - 1) - 0.5) * 2
281
+ const ear = ink(new THREE.ConeGeometry(size * 0.36, size, 8), mat)
282
+ ear.position.set(-r * 0.15, r * 1.02, side * r * 0.55)
283
+ ear.rotation.x = side * 0.28
284
+ head.add(ear)
285
+ }
286
+ }
287
+ for (const spec of partsOf(look, 'horn')) {
288
+ const n = num(spec.count, 2), len = num(spec.length, r * 1.4)
289
+ const mat = toon(colorOf(look, spec.color ?? 'horn', '#d8c9a3'))
290
+ for (let i = 0; i < n; i++) {
291
+ const side = n === 1 ? 0 : (i / (n - 1) - 0.5) * 2
292
+ const horn = ink(new THREE.ConeGeometry(r * 0.16, len, 8), mat)
293
+ horn.position.set(r * 0.1, r * 0.9 + len * 0.35, side * r * 0.4)
294
+ horn.rotation.set(side * 0.35, 0, -0.35)
295
+ head.add(horn)
296
+ }
297
+ }
298
+ const eyes = partsOf(look, 'eye')[0]
299
+ if (partsOf(look, 'face').some(f => f.style === 'human')) addHumanFace(head, look, r)
300
+ else addEyes(head, look, r, eyes === undefined ? 2 : num(eyes.count, 2))
301
+ }
302
+
303
+ /** Heads, spread sideways when there are several (多首). */
304
+ function addHeads(parent, look, at, r, bodyColor, snout) {
305
+ const n = Math.round(num(look.proportions?.heads, 1)), heads = []
306
+ for (let i = 0; i < n; i++) {
307
+ const head = new THREE.Group()
308
+ const z = n === 1 ? 0 : (i / (n - 1) - 0.5) * r * 2.4 * Math.min(n, 4) / 2
309
+ head.position.set(at.x, at.y, at.z + z)
310
+ parent.add(head)
311
+ head.add(ink(sphere(r, 14), toon(bodyColor)))
312
+ if (snout > 0) {
313
+ const s = ink(new THREE.ConeGeometry(r * 0.45, snout * 1.5, 12), toon(bodyColor))
314
+ s.rotation.z = -Math.PI / 2
315
+ s.position.set(r * 1.1, -r * 0.24, 0)
316
+ head.add(s)
317
+ }
318
+ addHeadParts(head, look, r, bodyColor)
319
+ heads.push(head)
320
+ }
321
+ return heads
322
+ }
323
+
324
+ function quadruped(look) {
325
+ const p = look.proportions ?? {}
326
+ const L = num(p.bodyLength, 1), H = num(p.bodyHeight, 0.45), legLen = num(p.legLength, 0.35) * 1.6
327
+ const rx = 0.62 * L, ry = H * 0.66, bodyY = legLen + 0.08
328
+ const bodyColor = colorOf(look, 'body', '#e9dfcc')
329
+ const g = new THREE.Group(), body = new THREE.Group()
330
+ g.add(body)
331
+ const torso = ink(sphere(1, 18), toon(bodyColor))
332
+ torso.scale.set(rx, ry, ry)
333
+ torso.position.y = bodyY
334
+ body.add(torso)
335
+ const headR = num(p.headSize, 0.32) * 0.6
336
+ const neck = ink(sphere(1, 12), toon(bodyColor))
337
+ neck.scale.set(headR * 0.9, headR * 1.25, headR * 0.85)
338
+ neck.position.set(rx * 0.76, bodyY + ry * 0.6, 0)
339
+ neck.rotation.z = -0.6
340
+ body.add(neck)
341
+ const heads = addHeads(body, look, new THREE.Vector3(rx * 1.03, bodyY + ry * 1.2, 0), headR, bodyColor, num(p.snout, 0.18))
342
+ const legs = Math.round(num(p.legs, 4)), pawColor = colorOf(look, 'accent', bodyColor)
343
+ for (let i = 0; i < legs; i++) {
344
+ const row = Math.floor(i / 2), rows = Math.ceil(legs / 2)
345
+ const x = rows === 1 ? 0 : rx * 0.62 - (rx * 1.24) * row / (rows - 1), z = (i % 2 === 0 ? 1 : -1) * ry * 0.45
346
+ const leg = new THREE.Group()
347
+ leg.position.set(x, legLen, z)
348
+ const l = ink(new THREE.CapsuleGeometry(0.055, legLen * 0.75, 4, 8), toon(bodyColor))
349
+ l.position.y = -legLen * 0.48
350
+ const paw = ink(sphere(0.07, 8), toon(pawColor))
351
+ paw.scale.set(1.3, 0.55, 1)
352
+ paw.position.set(0.03, -legLen + 0.03, 0)
353
+ leg.add(l, paw)
354
+ g.add(leg)
355
+ }
356
+ const tailRoot = new THREE.Group()
357
+ tailRoot.position.set(-rx * 0.9, bodyY + ry * 0.33, 0)
358
+ body.add(tailRoot)
359
+ const tails = partsOf(look, 'tail').flatMap(spec => addTails(tailRoot, look, spec, bodyColor))
360
+ const back = new THREE.Group()
361
+ back.position.set(0, bodyY + ry * 0.8, 0)
362
+ body.add(back)
363
+ const wings = partsOf(look, 'wing').flatMap(spec => addWings(back, look, spec, bodyColor))
364
+ return { g, body, heads, tails, wings, tailRoot, back }
365
+ }
366
+
367
+ function bird(look) {
368
+ const p = look.proportions ?? {}
369
+ const L = num(p.bodyLength, 0.7), bodyColor = colorOf(look, 'body', '#8d7f68')
370
+ const g = new THREE.Group(), body = new THREE.Group()
371
+ g.add(body)
372
+ const torso = ink(sphere(1, 14), toon(bodyColor))
373
+ torso.scale.set(L * 0.46, L * 0.32, L * 0.32)
374
+ body.add(torso)
375
+ const headR = num(p.headSize, 0.26) * 0.5
376
+ const heads = addHeads(body, look, new THREE.Vector3(L * 0.43, L * 0.17, 0), headR, colorOf(look, 'head', bodyColor), 0)
377
+ const beakColor = colorOf(look, 'beak', '#c9a23a')
378
+ for (const head of heads) {
379
+ const beak = ink(new THREE.ConeGeometry(headR * 0.28, num(p.beak, 0.12), 6), toon(beakColor))
380
+ beak.rotation.z = -Math.PI / 2
381
+ beak.position.set(headR * 1.2, -headR * 0.1, 0)
382
+ head.add(beak)
383
+ }
384
+ const tailRoot = new THREE.Group()
385
+ tailRoot.position.set(-L * 0.42, 0.02, 0)
386
+ body.add(tailRoot)
387
+ const tailSpecs = partsOf(look, 'tail')
388
+ if (tailSpecs.length === 0) {
389
+ const tail = ink(new THREE.ConeGeometry(0.12, 0.3, 4), toon(colorOf(look, 'accent', bodyColor)))
390
+ tail.rotation.z = Math.PI / 2
391
+ tail.scale.set(1, 1, 0.3)
392
+ tail.position.x = -0.1
393
+ tailRoot.add(tail)
394
+ }
395
+ const tails = tailSpecs.flatMap(spec => addTails(tailRoot, look, spec, bodyColor))
396
+ const wingSpecs = partsOf(look, 'wing')
397
+ const wings = (wingSpecs.length === 0 ? [{ type: 'wing', count: 2 }] : wingSpecs).flatMap(spec => addWings(body, look, spec, colorOf(look, 'wing', '#6f6352')))
398
+ return { g, body, heads, tails, wings, tailRoot, back: body }
399
+ }
400
+
401
+ function fish(look) {
402
+ const p = look.proportions ?? {}
403
+ const L = num(p.bodyLength, 0.84), bodyColor = colorOf(look, 'body', '#c8432f')
404
+ const g = new THREE.Group(), body = new THREE.Group()
405
+ g.add(body)
406
+ const torso = ink(sphere(1, 14), toon(bodyColor))
407
+ torso.scale.set(L * 0.5, num(p.bodyHeight, 0.36) * 0.5, L * 0.18)
408
+ body.add(torso)
409
+ const head = new THREE.Group()
410
+ head.position.set(L * 0.36, 0, 0)
411
+ body.add(head)
412
+ const r = num(p.bodyHeight, 0.36) * 0.36
413
+ if (partsOf(look, 'face').some(f => f.style === 'human')) addHumanFace(head, look, r)
414
+ else addEyes(head, look, r, num(partsOf(look, 'eye')[0]?.count, 2))
415
+ const tailRoot = new THREE.Group()
416
+ tailRoot.position.x = -L * 0.45
417
+ body.add(tailRoot)
418
+ const fin = ink(new THREE.ConeGeometry(0.16, 0.26, 4), toon(colorOf(look, 'accent', bodyColor)))
419
+ fin.rotation.z = Math.PI / 2
420
+ fin.scale.set(1, 1, 0.25)
421
+ fin.position.x = -0.1
422
+ tailRoot.add(fin)
423
+ for (const spec of partsOf(look, 'fin')) {
424
+ for (let i = 0; i < num(spec.count, 2); i++) {
425
+ const f = ink(new THREE.ConeGeometry(0.08 * num(spec.size, 1), 0.18 * num(spec.size, 1), 4), toon(colorOf(look, spec.color ?? 'accent', bodyColor)))
426
+ f.scale.z = 0.25
427
+ f.position.set(L * 0.05 - i * 0.12, i % 2 === 0 ? 0.12 : -0.1, 0)
428
+ body.add(f)
429
+ }
430
+ }
431
+ // Wings on a fish are pectoral wings (a flying fish).
432
+ const wings = partsOf(look, 'wing').flatMap(spec => addWings(body, look, spec, bodyColor, 0.45))
433
+ return { g, body, heads: [head], tails: [], wings, swimmer: tailRoot, tailRoot, back: body }
434
+ }
435
+
436
+ function serpent(look) {
437
+ const p = look.proportions ?? {}
438
+ const len = num(p.length, 2.4), thick = num(p.thickness, 0.12)
439
+ // Enough overlapping segments that the body reads as one coil, not beads.
440
+ const n = Math.max(Math.round(num(p.segments, 10)), Math.ceil(len / (thick * 0.9)))
441
+ const bodyColor = colorOf(look, 'body', '#5d7a52'), belly = colorOf(look, 'accent', bodyColor)
442
+ const g = new THREE.Group(), body = new THREE.Group()
443
+ g.add(body)
444
+ const segs = []
445
+ for (let i = 0; i < n; i++) {
446
+ const r = thick * (1 - 0.6 * i / n)
447
+ const s = ink(sphere(r, 10), toon(i % 6 === 0 ? belly : bodyColor))
448
+ s.position.set(-i * len / n, r, 0)
449
+ body.add(s)
450
+ segs.push(s)
451
+ }
452
+ const headR = thick * 1.25
453
+ const heads = addHeads(body, look, new THREE.Vector3(headR * 0.9, headR * 1.1, 0), headR, bodyColor, headR * 0.6)
454
+ const wings = partsOf(look, 'wing').flatMap(spec => addWings(segs[Math.min(2, n - 1)], look, spec, bodyColor, 0.45))
455
+ return { g, body, heads, tails: [], wings, slither: segs, tailRoot: segs[n - 1], back: segs[Math.min(2, n - 1)] }
456
+ }
457
+
458
+ function humanoid(look) {
459
+ const p = look.proportions ?? {}
460
+ const h = num(p.height, 1.2), bodyColor = colorOf(look, 'body', '#57677a'), skin = colorOf(look, 'skin', '#f0dcc2')
461
+ const g = new THREE.Group(), body = new THREE.Group()
462
+ g.add(body)
463
+ const robe = ink(new THREE.ConeGeometry(h * 0.27, h * 0.8, 10), toon(bodyColor))
464
+ robe.position.y = h * 0.4
465
+ body.add(robe)
466
+ const r = h * 0.13
467
+ const heads = addHeads(body, look, new THREE.Vector3(0, h * 0.9, 0), r, skin, 0)
468
+ for (const s of [-1, 1]) {
469
+ // Arms hang at the sides, the hands a little out from the robe.
470
+ const arm = ink(new THREE.CapsuleGeometry(h * 0.045, h * 0.3, 4, 8), toon(bodyColor))
471
+ arm.position.set(0, h * 0.5, s * h * 0.21)
472
+ arm.rotation.x = -s * 0.22
473
+ body.add(arm)
474
+ }
475
+ const tailRoot = new THREE.Group()
476
+ tailRoot.position.set(-h * 0.15, h * 0.2, 0)
477
+ body.add(tailRoot)
478
+ const tails = partsOf(look, 'tail').flatMap(spec => addTails(tailRoot, look, spec, bodyColor))
479
+ const back = new THREE.Group()
480
+ back.position.set(-h * 0.1, h * 0.65, 0)
481
+ body.add(back)
482
+ const wings = partsOf(look, 'wing').flatMap(spec => addWings(back, look, spec, bodyColor))
483
+ return { g, body, heads, tails, wings, tailRoot, back }
484
+ }
485
+
486
+ const BASES = { quadruped, bird, fish, serpent, humanoid }
487
+ const BUILTIN_PARTS = new Set(['ear', 'horn', 'tail', 'wing', 'fin', 'eye', 'face'])
488
+ const PARTS = {}
489
+
490
+ /** Helpers a project's own bodies and parts build with. */
491
+ function kitFor(look) {
492
+ return {
493
+ THREE, ink, toon, flat,
494
+ color: (key, fallback) => colorOf(look, key, fallback),
495
+ sphere,
496
+ addTails: (anchor, spec, bodyColor) => addTails(anchor, look, spec, bodyColor),
497
+ addWings: (anchor, spec, bodyColor) => addWings(anchor, look, spec, bodyColor),
498
+ addHeads: (parent, at, r, bodyColor, snout = 0) => addHeads(parent, look, at, r, bodyColor, snout),
499
+ }
500
+ }
501
+
502
+ /**
503
+ * Register a body the built-ins do not cover (a robot, a slime, a cart…).
504
+ * `make(look, kit)` returns `{ g, body, heads?, tails?, wings?, tailRoot?, back? }`:
505
+ * `g` is the model root, `body` breathes, the rest are what parts attach to
506
+ * and motions drive. `kit` carries `THREE`, `ink`, `toon`, `flat`, `color`,
507
+ * `sphere`, `addTails`, `addWings`, `addHeads`.
508
+ */
509
+ export function defineBase(name, make) { BASES[name] = make }
510
+
511
+ /**
512
+ * Register a part type. `make({ look, spec, anchors, kit })` adds meshes to
513
+ * `anchors.body` / `anchors.heads[i]` / `anchors.tailRoot` / `anchors.back`
514
+ * and may return `{ update(t) }` for its own motion. `spec.extra` carries the
515
+ * part's own parameters from the recipe.
516
+ */
517
+ export function definePart(type, make) { PARTS[type] = make }
518
+
519
+ /** A hull draws the same shape keys as its mesh: they share the influences. */
520
+ function shareMorphs(mesh, hull) {
521
+ if (!mesh.morphTargetInfluences) return
522
+ hull.morphTargetInfluences = mesh.morphTargetInfluences
523
+ hull.morphTargetDictionary = mesh.morphTargetDictionary
524
+ }
525
+
526
+ /** What stands in for a glTF model that failed to load: a labeled block, never a crash. */
527
+ function gltfStandIn(look) {
528
+ const g = new THREE.Group()
529
+ const box = ink(new THREE.BoxGeometry(0.6, 0.6, 0.6), toon('#c9bda4'))
530
+ box.position.y = 0.3
531
+ g.add(box)
532
+ g.userData.update = () => {}
533
+ g.userData.standIn = look.src
534
+ return g
535
+ }
536
+
537
+ /* ------------------------------------------------------------------- glTF */
538
+
539
+ /** Model loads in flight; `loaded()` and the workbench's checks wait on them. */
540
+ const pending = new Set()
541
+
542
+ /** Resolves once every model `build` has started so far has loaded (or failed). */
543
+ export function loaded() { return Promise.all([...pending]).then(() => undefined) }
544
+ window.__dshPending = loaded
545
+
546
+ let gltfLoader = null
547
+ async function loaderFor() {
548
+ if (gltfLoader === null) {
549
+ const { GLTFLoader } = await import('three/addons/loaders/GLTFLoader.js')
550
+ const manager = new THREE.LoadingManager()
551
+ // Project paths (the model, a .gltf's .bin and textures) become the sandbox's own blob URLs.
552
+ manager.setURLModifier(url => window.__dshFileUrl?.(url) ?? url)
553
+ gltfLoader = new GLTFLoader(manager)
554
+ }
555
+ return gltfLoader
556
+ }
557
+
558
+ /** The style's toon material over a loaded material's colors, vertex colors, textures and glow. */
559
+ function toonOver(m) {
560
+ const params = { gradientMap: gradientFor(style.shades), color: m.color ? m.color.clone() : new THREE.Color('#ffffff') }
561
+ if (m.map) params.map = m.map
562
+ if (m.vertexColors) params.vertexColors = true
563
+ if (m.emissive) params.emissive = m.emissive.clone()
564
+ if (m.emissiveMap) params.emissiveMap = m.emissiveMap
565
+ if (m.transparent) { params.transparent = true; params.opacity = m.opacity }
566
+ if (m.alphaTest) params.alphaTest = m.alphaTest
567
+ params.side = m.side
568
+ const toonMat = rimmed(new THREE.MeshToonMaterial(params))
569
+ toonMat.name = m.name
570
+ m.dispose()
571
+ return toonMat
572
+ }
573
+
574
+ /**
575
+ * A glTF look: the model frame comes back at once and the .glb fills it when
576
+ * loaded (`model.userData.loaded` resolves then). Toon by default; the
577
+ * outline hull is skipped on skinned meshes (it would not follow the bones).
578
+ * A failed load reports itself and falls back to the look's recipe, else a block.
579
+ */
580
+ function gltfModel(look, lookOrId) {
581
+ const g = new THREE.Group()
582
+ const body = new THREE.Group()
583
+ g.add(body)
584
+ g.scale.setScalar(num(look.scale, 1))
585
+ g.userData.src = look.src
586
+ // Skeletal animation: the mixer runs on the world's clock (update(t) from step).
587
+ let mixer = null, clips = [], current = null, wanted = null, last = null
588
+ g.userData.animations = []
589
+ // Named tweaks from the look (tint by material, bones by name, shape keys by name), applied
590
+ // after the animation every frame so a clip never overwrites them.
591
+ const parts = { materials: new Map(), bones: new Map(), morphMeshes: [] }
592
+ let tune = { tint: {}, bones: {}, morphs: {} }
593
+ const tunedBones = new Set()
594
+ function collect(root, meshes) {
595
+ for (const o of meshes) {
596
+ for (const m of [o.material].flat()) {
597
+ if (!m || !m.name) continue
598
+ if (!parts.materials.has(m.name)) parts.materials.set(m.name, [])
599
+ parts.materials.get(m.name).push(m)
600
+ m.userData.baseColor = m.color ? m.color.clone() : null
601
+ }
602
+ if (o.morphTargetDictionary && o.morphTargetInfluences && !o.userData.outline) { o.userData.restMorphs = [...o.morphTargetInfluences]; parts.morphMeshes.push(o) }
603
+ }
604
+ root.traverse(n => { if (n.name && (n.isBone || !n.isMesh) && !parts.bones.has(n.name)) parts.bones.set(n.name, { node: n, scale: n.scale.clone(), quat: n.quaternion.clone() }) })
605
+ }
606
+ function restBones() {
607
+ for (const name of tunedBones) { const b = parts.bones.get(name); if (b) { b.node.scale.copy(b.scale); b.node.quaternion.copy(b.quat) } }
608
+ }
609
+ function tuneBones() {
610
+ for (const [name, spec] of Object.entries(tune.bones ?? {})) {
611
+ const b = parts.bones.get(name)
612
+ if (!b) continue
613
+ tunedBones.add(name)
614
+ if (typeof spec.scale === 'number') b.node.scale.multiplyScalar(spec.scale)
615
+ else if (Array.isArray(spec.scale)) b.node.scale.multiply(new THREE.Vector3(...spec.scale))
616
+ if (Array.isArray(spec.rotate)) b.node.quaternion.multiply(new THREE.Quaternion().setFromEuler(new THREE.Euler(...spec.rotate.map(v => deg(v)))))
617
+ }
618
+ }
619
+ function tuneMorphs() {
620
+ const wanted = Object.entries(tune.morphs ?? {})
621
+ if (wanted.length === 0) return
622
+ for (const o of parts.morphMeshes) for (const [name, v] of wanted) { const i = o.morphTargetDictionary[name]; if (i !== undefined) o.morphTargetInfluences[i] = v }
623
+ }
624
+ /** Apply a look's tweaks again without reloading (the panel's live edits). */
625
+ function retune(next) {
626
+ tune = { tint: next.tint ?? {}, bones: next.bones ?? {}, morphs: next.morphs ?? {} }
627
+ for (const [name, list] of parts.materials) for (const m of list) {
628
+ const c = tune.tint[name]
629
+ if (m.userData.baseColor) m.color.copy(c ? new THREE.Color(c) : m.userData.baseColor)
630
+ }
631
+ for (const o of parts.morphMeshes) o.userData.restMorphs.forEach((v, i) => { o.morphTargetInfluences[i] = v })
632
+ restBones(); tuneBones(); tuneMorphs()
633
+ }
634
+ g.userData.retune = next => { if (parts.bones.size + parts.materials.size + parts.morphMeshes.length > 0) retune(next) }
635
+ /** What the loaded model can be tuned by: its named materials, bones and shape keys. */
636
+ g.userData.parts = () => ({
637
+ materials: [...parts.materials].map(([name, list]) => ({ name, color: '#' + (list[0].userData.baseColor ?? new THREE.Color('#ffffff')).getHexString(), vertexColors: list.some(m => m.vertexColors) })),
638
+ bones: [...parts.bones.values()].filter(b => b.node.isBone).map(b => b.node.name),
639
+ morphs: [...new Set(parts.morphMeshes.flatMap(o => Object.keys(o.morphTargetDictionary)))],
640
+ })
641
+ g.userData.update = t => {
642
+ restBones()
643
+ if (mixer !== null) mixer.update(last === null ? 0 : Math.max(0, t - last))
644
+ last = t
645
+ tuneBones(); tuneMorphs()
646
+ }
647
+ /**
648
+ * Play a clip by name, cross-fading from the one before (`{ fade: 0.2, loop: true }`).
649
+ * Called before the model has loaded, it plays once it has. Returns the action, or null.
650
+ */
651
+ g.userData.play = (name, { fade = 0.2, loop = true } = {}) => {
652
+ wanted = name
653
+ if (mixer === null) return null
654
+ const clip = clips.find(c => c.name === name)
655
+ if (!clip) { console.error(`dsh3d:模型 ${look.src} 没有叫「${name}」的动画(有:${clips.map(c => c.name).join('、') || '无'})`); return null }
656
+ const action = mixer.clipAction(clip)
657
+ action.setLoop(loop ? THREE.LoopRepeat : THREE.LoopOnce, Infinity)
658
+ action.clampWhenFinished = !loop
659
+ action.reset().fadeIn(current === null ? 0 : fade).play()
660
+ if (current !== null && current !== action) current.fadeOut(fade)
661
+ current = action
662
+ return action
663
+ }
664
+ const toonMode = look.material !== 'original'
665
+ const withOutline = look.outline ?? toonMode
666
+ const job = loaderFor()
667
+ .then(loader => loader.loadAsync(look.src))
668
+ .then(gltf => {
669
+ // Collected first: the hulls added below are meshes too, and must not be visited.
670
+ const meshes = []
671
+ gltf.scene.traverse(o => { if (o.isMesh) meshes.push(o) })
672
+ meshes.forEach(o => {
673
+ if (toonMode) o.material = Array.isArray(o.material) ? o.material.map(toonOver) : toonOver(o.material)
674
+ if (withOutline && style.outlineWidth > 0) {
675
+ if (o.isSkinnedMesh) {
676
+ // A skinned hull rides the same skeleton, as a sibling with the mesh's own transform.
677
+ const hull = new THREE.SkinnedMesh(o.geometry, outline())
678
+ hull.raycast = () => {}
679
+ hull.userData.outline = true
680
+ hull.position.copy(o.position); hull.quaternion.copy(o.quaternion); hull.scale.copy(o.scale)
681
+ hull.bindMode = o.bindMode
682
+ o.parent.add(hull)
683
+ hull.updateMatrixWorld(true)
684
+ hull.bind(o.skeleton, o.bindMatrix)
685
+ shareMorphs(o, hull)
686
+ } else {
687
+ const hull = new THREE.Mesh(o.geometry, outline())
688
+ hull.raycast = () => {}
689
+ hull.userData.outline = true
690
+ o.add(hull)
691
+ shareMorphs(o, hull)
692
+ }
693
+ }
694
+ })
695
+ collect(gltf.scene, meshes)
696
+ retune(look)
697
+ body.add(gltf.scene)
698
+ clips = gltf.animations
699
+ g.userData.animations = clips.map(c => c.name)
700
+ if (clips.length > 0) {
701
+ mixer = new THREE.AnimationMixer(gltf.scene)
702
+ const idle = look.animation ?? clips.find(c => /idle|待机|stand/i.test(c.name))?.name ?? clips[0].name
703
+ g.userData.play(wanted ?? idle)
704
+ }
705
+ })
706
+ .catch(error => {
707
+ console.error(`dsh3d:模型 ${look.src} 加载失败:${error?.message ?? error}`)
708
+ body.add(look.recipe ? buildLook(look.recipe, lookOrId) : gltfStandIn(look))
709
+ })
710
+ .finally(() => { pending.delete(job) })
711
+ pending.add(job)
712
+ g.userData.loaded = job
713
+ return g
714
+ }
715
+
716
+ /**
717
+ * Build a model from a look (or an element id, read from `window.dshElements`).
718
+ * The model's `userData.update(t)` runs its procedural motion.
719
+ */
720
+ export function build(lookOrId) {
721
+ const model = buildLook(typeof lookOrId === 'string' ? window.dshElements?.[lookOrId]?.look : lookOrId, lookOrId)
722
+ if (typeof lookOrId === 'string') model.userData.builtFrom = lookOrId
723
+ return model
724
+ }
725
+
726
+ function buildLook(look, lookOrId) {
727
+ if (!look || typeof look !== 'object') throw new Error(`dsh3d.build:没有可用的 look(${String(lookOrId)})`)
728
+ if (look.kind === 'gltf') return gltfModel(look, lookOrId)
729
+ const make = BASES[look.base]
730
+ if (!make) throw new Error(`dsh3d.build:base「${look.base}」没有注册(内置 ${Object.keys(BASES).join('、')};自己的身体用 defineBase 注册)`)
731
+ const kit = kitFor(look)
732
+ const built = make(look, kit)
733
+ const { g, body, swimmer, slither } = built
734
+ const heads = built.heads ?? [], tails = built.tails ?? [], wings = built.wings ?? []
735
+ const anchors = { body, heads, tailRoot: built.tailRoot ?? body, back: built.back ?? body }
736
+ const partMotions = []
737
+ for (const spec of look.parts ?? []) {
738
+ if (BUILTIN_PARTS.has(spec.type)) continue
739
+ const part = PARTS[spec.type]
740
+ if (!part) throw new Error(`dsh3d.build:part「${spec.type}」没有注册(自己的部件用 definePart 注册)`)
741
+ const motion = part({ look, spec, anchors, kit })
742
+ if (motion && typeof motion.update === 'function') partMotions.push(motion)
743
+ }
744
+ const m = look.motion ?? {}
745
+ const sway = m.tailSway, breath = m.breath, flap = m.flap, swim = m.swim, bob = m.bob
746
+ g.scale.setScalar(num(look.scale, 1))
747
+ g.userData.update = t => {
748
+ if (breath) body.scale.y = 1 + num(breath.amplitude, 0.02) * Math.sin(t * 2 * Math.PI / num(breath.period, 2.4))
749
+ for (const head of heads) head.rotation.z = 0.06 * Math.sin(t * 0.9)
750
+ const swayAmp = deg(num(sway?.amplitude, 12)), swayPh = t * 2 * Math.PI / num(sway?.period, 1.6)
751
+ tails.forEach((segs, i) => segs.forEach((seg, k) => { seg.rotation.x = swayAmp * (0.45 + 0.35 * k) * Math.sin(swayPh - i * 0.55 - k * 0.9) }))
752
+ if (wings.length) {
753
+ const a = deg(num(flap?.amplitude, 34)), ph = t * 2 * Math.PI / num(flap?.period, 0.7)
754
+ for (const w of wings) w.pivot.rotation.x = w.side * (0.15 + a / 34 * 0.6 * Math.sin(ph))
755
+ }
756
+ if (swimmer) swimmer.rotation.y = deg(num(swim?.amplitude, 28)) * Math.sin(t * 2 * Math.PI / num(swim?.period, 0.9))
757
+ if (slither) slither.forEach((s, i) => { s.position.z = Math.sin(t * 2 * Math.PI / num(swim?.period, 1.6) - i * 0.7) * deg(num(swim?.amplitude, 20)) * 0.25 })
758
+ if (bob) body.position.y = num(bob.amplitude, 0.1) * Math.sin(t * 2 * Math.PI / num(bob.period, 2))
759
+ for (const m of partMotions) m.update(t)
760
+ }
761
+ return g
762
+ }
763
+
764
+ /** Triangle count of an object tree (budgets: ~10k per creature, ~150k per scene). */
765
+ export function triangles(object) {
766
+ let n = 0
767
+ object.traverse(o => {
768
+ if (o.isMesh && !o.userData.outline) n += (o.geometry.index ? o.geometry.index.count : o.geometry.attributes.position.count) / 3 * (o.isInstancedMesh ? o.count : 1)
769
+ })
770
+ return Math.round(n)
771
+ }
772
+
773
+ /* ------------------------------------------------------------------- world */
774
+
775
+ /** The style keys a scene may override (shading bands and the ink outline stay global: every material shares them). */
776
+ const SCENE_STYLE_KEYS = ['bg', 'ground', 'ground2', 'water', 'fog', 'light']
777
+
778
+ /** Free an object tree's GPU resources, sparing the shared cached materials. */
779
+ function release(object) {
780
+ const shared = new Set(materials.values())
781
+ if (outlineMaterial) shared.add(outlineMaterial)
782
+ object.traverse(o => {
783
+ o.geometry?.dispose()
784
+ for (const m of [o.material].flat()) {
785
+ if (!m || shared.has(m)) continue
786
+ for (const v of Object.values(m)) if (v?.isTexture) v.dispose()
787
+ m.dispose()
788
+ }
789
+ if (o.isInstancedMesh) o.dispose()
790
+ })
791
+ }
792
+
793
+ /** A small seeded random source (mulberry32), so a scatter lands the same way every load. */
794
+ function seeded(seed) {
795
+ let a = (Math.floor(seed) >>> 0) || 1
796
+ return () => {
797
+ a = (a + 0x6D2B79F5) >>> 0
798
+ let t = Math.imul(a ^ (a >>> 15), a | 1)
799
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
800
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
801
+ }
802
+ }
803
+
804
+ const CAMERA_OFFSET = new THREE.Vector3(12, 13.5, 12)
805
+
806
+ /**
807
+ * The game world: renderer, 45° orthographic camera, loop, terrain and the
808
+ * entity registry the workbench hooks read.
809
+ * @param {{ canvas?: HTMLCanvasElement, style?: string|object, view?: number, region?: (x:number, z:number) => string }} options
810
+ * `style` defaults to game.json's `style3d` (or the preset its `artStyle` names).
811
+ */
812
+ export function createWorld(options = {}) {
813
+ const baseSpec = options.style ?? manifestStyle()
814
+ const baseObj = typeof baseSpec === 'string' ? { preset: baseSpec } : { ...baseSpec }
815
+ let s = useStyle(baseSpec)
816
+ const canvas = options.canvas ?? document.querySelector('canvas') ?? document.body.appendChild(document.createElement('canvas'))
817
+ // preserveDrawingBuffer: the workbench's blank-screen probe and screenshots read the canvas.
818
+ const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, preserveDrawingBuffer: true })
819
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
820
+ const scene = new THREE.Scene()
821
+ scene.background = new THREE.Color(s.bg)
822
+ scene.fog = new THREE.Fog(s.bg, s.fog[0], s.fog[1])
823
+ const hemi = new THREE.HemisphereLight(s.hemi[0], s.hemi[1], s.hemi[2])
824
+ const sun = new THREE.DirectionalLight(0xffffff, s.sun)
825
+ const SUN_OFFSET = new THREE.Vector3(-6, 14, 8)
826
+ sun.position.copy(SUN_OFFSET)
827
+ scene.add(hemi, sun)
828
+ // Shadows are opt-in (style3d.shadows): the sun casts, and its shadow box follows the camera's target.
829
+ const shadows = s.shadows === true
830
+ if (shadows) {
831
+ renderer.shadowMap.enabled = true
832
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap
833
+ sun.castShadow = true
834
+ sun.shadow.mapSize.set(2048, 2048)
835
+ const box = sun.shadow.camera
836
+ box.left = -24; box.right = 24; box.top = 24; box.bottom = -24; box.near = 0.5; box.far = 80
837
+ sun.shadow.bias = -0.0005
838
+ scene.add(sun.target)
839
+ }
840
+ /** Marks a newly added object's meshes to cast and receive shadows (outline hulls stay out). */
841
+ function shade(object) {
842
+ if (!shadows) return
843
+ object.traverse(o => { if (o.isMesh && !o.userData.outline) { o.castShadow = true; o.receiveShadow = true } })
844
+ }
845
+ const iso = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 300)
846
+ /** The camera in use: the built-in 45° one, or the game's own (`setCamera`). */
847
+ let camera = iso
848
+ /** The follow rig `followCamera` set up, while its camera is the one in use. */
849
+ let followRig = null
850
+ const swayTime = { value: 0 }
851
+ let renderHook = null
852
+ const target = new THREE.Vector3()
853
+ let zoom = 1, view = num(options.view, 6.2), followed = null, focusUntil = 0
854
+ const entities = []
855
+ const updates = []
856
+ const rings = new THREE.Group()
857
+ scene.add(rings)
858
+ let heightFn = () => 0, ground = null, time = 0, paused = false, running = false, last = 0
859
+ /** The scene entered last: `{ name, group, entities, updates, ground }`, or null before any. */
860
+ let current = null
861
+ /** Objects that count as ground for `groundAt`. */
862
+ const walkables = []
863
+ const groundRay = new THREE.Raycaster(), down = new THREE.Vector3(), DOWN = new THREE.Vector3(0, -1, 0)
864
+ const testing = () => window.__GAMEDEV_TEST__ === true
865
+
866
+ /** Background, fog and light from a resolved style (terrain colors are read when terrain is built). */
867
+ function applyLook(st) {
868
+ scene.background.set(st.bg)
869
+ scene.fog.color.set(st.bg)
870
+ scene.fog.near = st.fog[0]
871
+ scene.fog.far = st.fog[1]
872
+ hemi.color.set(st.hemi[0]); hemi.groundColor.set(st.hemi[1]); hemi.intensity = st.hemi[2]
873
+ sun.intensity = st.sun
874
+ }
875
+
876
+ /** Camera pose and world matrices, as a render would leave them (pick needs them without a render). */
877
+ function sync() {
878
+ if (followRig !== null && camera === followRig.camera) {
879
+ const r = followRig, t = r.target.position
880
+ const pitch = Math.min(1.35, Math.max(-0.35, r.pitch))
881
+ camera.position.set(t.x + Math.sin(r.yaw) * Math.cos(pitch) * r.distance, t.y + r.height + Math.sin(pitch) * r.distance, t.z + Math.cos(r.yaw) * Math.cos(pitch) * r.distance)
882
+ camera.lookAt(t.x, t.y + r.height, t.z)
883
+ }
884
+ // The built-in camera tracks the target; a game's own camera is the game's to place.
885
+ if (camera === iso) {
886
+ iso.position.copy(target).add(CAMERA_OFFSET)
887
+ iso.lookAt(target)
888
+ }
889
+ if (shadows) { sun.position.copy(target).add(SUN_OFFSET); sun.target.position.copy(target) }
890
+ scene.updateMatrixWorld()
891
+ camera.updateMatrixWorld()
892
+ }
893
+
894
+ /**
895
+ * A private copy of a material whose vertices bend with the wind: higher
896
+ * vertices swing more (the base stays put), each copy out of step with the
897
+ * next. `{ amplitude: 0.06, period: 2.4 }` per metre of height. Shadows stay still.
898
+ */
899
+ function swaying(material, sway) {
900
+ const amp = num(sway.amplitude, 0.06), period = Math.max(0.2, num(sway.period, 2.4))
901
+ const m = material.clone()
902
+ m.onBeforeCompile = shader => {
903
+ shader.uniforms.swayT = swayTime
904
+ shader.uniforms.swayAmp = { value: amp }
905
+ shader.uniforms.swayPeriod = { value: period }
906
+ shader.vertexShader = 'uniform float swayT; uniform float swayAmp; uniform float swayPeriod;\n' + shader.vertexShader.replace('#include <begin_vertex>',
907
+ '#include <begin_vertex>\nfloat swayPh = 0.0;\n#ifdef USE_INSTANCING\nswayPh = instanceMatrix[3].x * 0.37 + instanceMatrix[3].z * 0.61;\n#endif\n'
908
+ + 'float swayH = max(transformed.y, 0.0);\ntransformed.x += sin(swayT * 6.2832 / swayPeriod + swayPh) * swayAmp * swayH;\ntransformed.z += cos(swayT * 5.0265 / swayPeriod + swayPh) * swayAmp * 0.5 * swayH;')
909
+ }
910
+ m.customProgramCacheKey = () => 'dsh3d-sway'
911
+ m.userData.sway = { amplitude: amp, period }
912
+ return m
913
+ }
914
+
915
+ /** Leave the current scene: everything it registered goes, and its GPU resources with it. */
916
+ function leaveScene() {
917
+ if (current === null) return
918
+ const gone = new Set(current.entities)
919
+ for (let i = entities.length - 1; i >= 0; i--) if (gone.has(entities[i])) entities.splice(i, 1)
920
+ for (const fn of current.updates) { const i = updates.indexOf(fn); if (i >= 0) updates.splice(i, 1) }
921
+ for (const w of current.walkables) { const i = walkables.indexOf(w); if (i >= 0) walkables.splice(i, 1) }
922
+ if (followed && gone.has(followed)) followed = null
923
+ for (const r of [...rings.children]) if (gone.has(r.userData.follow)) rings.remove(r)
924
+ scene.remove(current.group)
925
+ release(current.group)
926
+ if (current.ground !== null && ground === current.ground) dropGround()
927
+ current = null
928
+ }
929
+
930
+ function dropGround() {
931
+ if (ground === null) return
932
+ scene.remove(ground)
933
+ release(ground)
934
+ ground = null
935
+ heightFn = () => 0
936
+ }
937
+
938
+ function resize() {
939
+ const w = canvas.clientWidth || canvas.width || 640, h = canvas.clientHeight || canvas.height || 400
940
+ renderer.setSize(w, h, false)
941
+ const aspect = w / h, half = Math.max(view, view * 0.84 / aspect) / zoom
942
+ iso.left = -half * aspect; iso.right = half * aspect; iso.top = half; iso.bottom = -half
943
+ iso.updateProjectionMatrix()
944
+ if (camera.isPerspectiveCamera) { camera.aspect = aspect; camera.updateProjectionMatrix() }
945
+ }
946
+ resize()
947
+ addEventListener('resize', resize)
948
+
949
+ const world = {
950
+ THREE, scene, renderer, canvas,
951
+ /** The camera in use (the built-in 45° orthographic one until `setCamera`). */
952
+ get camera() { return camera },
953
+ /** The two lights, for a game to tune: `hemi` (sky/ground fill) and `sun` (directional; casts when style3d.shadows). */
954
+ lights: { hemi, sun },
955
+ /**
956
+ * Use the game's own camera (a perspective follow camera, say) for
957
+ * rendering and for the workbench's pick, highlight and focus; the game
958
+ * places it every frame. `setCamera(null)` goes back to the built-in one.
959
+ */
960
+ setCamera(next) {
961
+ if (followRig !== null && next !== followRig.camera) followRig = null
962
+ camera = next ?? iso
963
+ resize()
964
+ return camera
965
+ },
966
+ /**
967
+ * A third-person camera behind `target`: perspective, `distance` back and
968
+ * `height` up, turned by `yaw` / `pitch` (radians; change them on the
969
+ * returned rig, or call `rig.look(dx, dy)` with mouse deltas). `mouse: true`
970
+ * grabs the pointer on a click (Esc lets go) and turns with the mouse.
971
+ * Pick and rings follow it; `setCamera(null)` returns to the 45° view.
972
+ */
973
+ followCamera({ target, distance = 7, height = 1.4, yaw = 0, pitch = 0.35, fov = 50, mouse = false, sensitivity = 0.004 } = {}) {
974
+ const rig = {
975
+ target, distance, height, yaw, pitch, camera: new THREE.PerspectiveCamera(fov, 1, 0.1, 500),
976
+ look(dx, dy) { rig.yaw -= dx * sensitivity; rig.pitch = Math.min(1.35, Math.max(-0.35, rig.pitch + dy * sensitivity)) },
977
+ }
978
+ world.setCamera(rig.camera)
979
+ followRig = rig
980
+ if (mouse) {
981
+ canvas.addEventListener('click', () => { if (document.pointerLockElement !== canvas) canvas.requestPointerLock?.() })
982
+ addEventListener('mousemove', e => { if (followRig === rig && document.pointerLockElement === canvas) rig.look(e.movementX, e.movementY) })
983
+ }
984
+ return rig
985
+ },
986
+ /**
987
+ * Take over drawing (post-processing, a second pass): `fn({ renderer, scene, camera })`
988
+ * runs instead of the plain render. Returns a function that hands it back.
989
+ */
990
+ onRender(fn) {
991
+ renderHook = fn
992
+ return () => { if (renderHook === fn) renderHook = null }
993
+ },
994
+ /** The active style: the game's, with the current scene's overrides. */
995
+ get style() { return s },
996
+ /** The current scene's name, or null. */
997
+ get sceneName() { return current?.name ?? null },
998
+ /** Ground height at (x, z) — what `terrain` was given. */
999
+ heightAt: (x, z) => heightFn(x, z),
1000
+ /**
1001
+ * Paper-toned terrain. `height(x, z)` shapes it; `size` is its side.
1002
+ * Clicking it in 「指一下」 gives a ground reference.
1003
+ */
1004
+ terrain({ size = 70, segments = 70, height } = {}) {
1005
+ // One ground at a time: a new terrain replaces (and frees) the old one.
1006
+ dropGround()
1007
+ if (height) heightFn = height
1008
+ const geo = new THREE.PlaneGeometry(size, size, segments, segments)
1009
+ geo.rotateX(-Math.PI / 2)
1010
+ const pos = geo.attributes.position, cols = [], c1 = new THREE.Color(s.ground), c2 = new THREE.Color(s.ground2)
1011
+ for (let i = 0; i < pos.count; i++) {
1012
+ const x = pos.getX(i), z = pos.getZ(i)
1013
+ pos.setY(i, heightFn(x, z))
1014
+ const c = c1.clone().lerp(c2, Math.min(1, Math.max(0, 0.5 + 0.5 * Math.sin(x * 0.4 + z * 0.23) * Math.cos(z * 0.5))))
1015
+ cols.push(c.r, c.g, c.b)
1016
+ }
1017
+ geo.setAttribute('color', new THREE.Float32BufferAttribute(cols, 3))
1018
+ geo.computeVertexNormals()
1019
+ ground = new THREE.Mesh(geo, new THREE.MeshLambertMaterial({ vertexColors: true, flatShading: true }))
1020
+ ground.userData.ground = true
1021
+ ground.receiveShadow = shadows
1022
+ scene.add(ground)
1023
+ if (current !== null) current.ground = ground
1024
+ return ground
1025
+ },
1026
+ /**
1027
+ * Add an object as a pickable entity. `id` is the element id (what the
1028
+ * panel and the model talk about), `inst` this instance, `state` a string
1029
+ * or a function returning one (shown in the reference: 「摆尾中」).
1030
+ */
1031
+ add(object, { id, inst, name, state, walkable } = {}) {
1032
+ object.userData.entity = { id, inst, name, state }
1033
+ if (walkable) world.walkable(object)
1034
+ // Inside a scene, an entity belongs to it and leaves with it.
1035
+ ;(current?.group ?? scene).add(object)
1036
+ shade(object)
1037
+ object.userData.loaded?.then(() => { shade(object) })
1038
+ entities.push(object)
1039
+ current?.entities.push(object)
1040
+ return object
1041
+ },
1042
+ /**
1043
+ * Count an object (stairs, a platform, a bridge, a roof) as ground:
1044
+ * `groundAt` lands on it. Inside a scene it leaves with the scene.
1045
+ * `walkable(object, false)` takes it back.
1046
+ */
1047
+ walkable(object, on = true) {
1048
+ const i = walkables.indexOf(object)
1049
+ if (on && i < 0) { walkables.push(object); current?.walkables.push(object); object.updateMatrixWorld(true) }
1050
+ if (!on && i >= 0) walkables.splice(i, 1)
1051
+ return object
1052
+ },
1053
+ /**
1054
+ * The height to stand at (x, z): the highest walkable surface (or the
1055
+ * terrain) at or below `fromY`. A character passes its feet plus a step
1056
+ * height, so it climbs a stair but not onto a roof above it; with no
1057
+ * `fromY` the topmost surface counts.
1058
+ */
1059
+ groundAt(x, z, fromY = Infinity) {
1060
+ const top = Number.isFinite(fromY) ? fromY + 0.001 : 1e4
1061
+ down.set(x, top, z)
1062
+ groundRay.set(down, DOWN)
1063
+ groundRay.far = top + 1e4
1064
+ const hits = groundRay.intersectObjects(ground ? [...walkables, ground] : walkables, true)
1065
+ for (const hit of hits) if (hit.point.y <= top) return hit.point.y
1066
+ return heightFn(x, z)
1067
+ },
1068
+ /** Put an object on the ground at (x, z). */
1069
+ place(object, x, z) {
1070
+ object.position.set(x, heightFn(x, z), z)
1071
+ return object
1072
+ },
1073
+ remove(object) {
1074
+ object.parent?.remove(object)
1075
+ const i = entities.indexOf(object)
1076
+ if (i >= 0) entities.splice(i, 1)
1077
+ },
1078
+ /** Run `fn(dt, t)` every step (game logic). Inside a scene it stops when the scene is left. Returns a function that stops it. */
1079
+ onUpdate(fn) {
1080
+ updates.push(fn)
1081
+ current?.updates.push(fn)
1082
+ return () => { const i = updates.indexOf(fn); if (i >= 0) updates.splice(i, 1) }
1083
+ },
1084
+ /**
1085
+ * Switch scenes: free everything the previous scene registered (its
1086
+ * entities, scatters, onUpdate callbacks and terrain), apply this scene's
1087
+ * style overrides — game.json `style3d.scenes[name]`, then `opts.style` —
1088
+ * and run `setup(world)`; whatever it adds, scatters, terrains or
1089
+ * registers belongs to this scene. Things registered outside any scene
1090
+ * (the player, say) stay. Returns setup's result (await it when setup is async).
1091
+ */
1092
+ enterScene(name, setup, opts = {}) {
1093
+ leaveScene()
1094
+ const fromData = (window.dshManifest?.style3d?.scenes ?? {})[name] ?? {}
1095
+ const overrides = {}
1096
+ for (const key of SCENE_STYLE_KEYS) {
1097
+ const v = opts.style?.[key] ?? fromData[key]
1098
+ if (v !== undefined) overrides[key] = v
1099
+ }
1100
+ s = resolveStyle({ ...baseObj, ...overrides })
1101
+ applyLook(s)
1102
+ const group = new THREE.Group()
1103
+ group.name = `scene:${name}`
1104
+ scene.add(group)
1105
+ current = { name, group, entities: [], updates: [], walkables: [], ground: null }
1106
+ const result = setup?.(world)
1107
+ if (!testing()) world.render()
1108
+ return result
1109
+ },
1110
+ /**
1111
+ * One element (or look) built once and instanced over the ground: each
1112
+ * copy lands on the terrain with a random facing and size. `points` is a
1113
+ * list of `[x, z]` / `{ x, z }`, or a count spread over `spread` (a
1114
+ * square side, centered on `center`). Picking a copy answers the element
1115
+ * with `inst: 'id#n'`. Static: no per-copy motion.
1116
+ */
1117
+ scatter(idOrLook, points, { seed = 1, spread = 40, center = [0, 0], rotate = true, scale = [0.85, 1.15], name, inst, sway } = {}) {
1118
+ const rnd = seeded(seed)
1119
+ const spots = typeof points === 'number'
1120
+ ? Array.from({ length: Math.max(0, Math.floor(points)) }, () => [center[0] + (rnd() - 0.5) * spread, center[1] + (rnd() - 0.5) * spread])
1121
+ : (points ?? []).map(p => (Array.isArray(p) ? p : [p.x, p.z]))
1122
+ const id = typeof idOrLook === 'string' ? idOrLook : null
1123
+ // Placed once: a rebuild (an edited look) keeps every copy where it was.
1124
+ const matrices = spots.map(([x, z]) => {
1125
+ const k = scale[0] + rnd() * (scale[1] - scale[0])
1126
+ const q = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), rotate ? rnd() * Math.PI * 2 : 0)
1127
+ return new THREE.Matrix4().compose(new THREE.Vector3(x, heightFn(x, z), z), q, new THREE.Vector3(k, k, k))
1128
+ })
1129
+ const make = () => {
1130
+ const model = build(idOrLook)
1131
+ const group = new THREE.Group()
1132
+ // A glTF look fills in later: the copies are made once it has.
1133
+ const fill = () => { model.updateMatrixWorld(true); instance(model, group); shade(group) }
1134
+ if (model.userData.loaded) model.userData.loaded.then(fill)
1135
+ else fill()
1136
+ group.userData.scatter = { count: spots.length }
1137
+ if (id !== null) group.userData.builtFrom = id
1138
+ group.userData.rescatter = make
1139
+ return group
1140
+ }
1141
+ const instance = (model, group) => {
1142
+ model.traverse(o => {
1143
+ if (!o.isMesh) return
1144
+ const copies = new THREE.InstancedMesh(o.geometry, sway ? swaying(o.material, sway) : o.material, matrices.length)
1145
+ matrices.forEach((m, i) => { copies.setMatrixAt(i, m.clone().multiply(o.matrixWorld)) })
1146
+ copies.instanceMatrix.needsUpdate = true
1147
+ copies.computeBoundingSphere()
1148
+ if (o.userData.outline) { copies.userData.outline = true; copies.raycast = () => {} }
1149
+ group.add(copies)
1150
+ })
1151
+ }
1152
+ const elementName = id !== null ? window.dshElements?.[id]?.name : undefined
1153
+ return world.add(make(), { id: id ?? undefined, inst, name: name ?? elementName })
1154
+ },
1155
+ /** Keep the camera on an object. */
1156
+ follow(object) { followed = object },
1157
+ /** Swing the camera to an object for a few seconds. */
1158
+ focus(object, seconds = 3) { target.copy(object.position); focusUntil = time + seconds },
1159
+ setZoom(z) { zoom = Math.min(12, Math.max(0.2, z)); resize() },
1160
+ get time() { return time },
1161
+ get paused() { return paused },
1162
+ /** Advance `dt` seconds: game logic, motion, camera, render. Tests drive this. */
1163
+ step(dt) {
1164
+ time += dt
1165
+ swayTime.value = time
1166
+ for (const fn of updates) fn(dt, time)
1167
+ for (const e of entities) e.userData.update?.(time)
1168
+ if (followed && time > focusUntil) target.lerp(followed.position, Math.min(1, dt * 5))
1169
+ for (const r of rings.children) {
1170
+ const o = r.userData.follow
1171
+ r.position.set(o.position.x, heightFn(o.position.x, o.position.z) + 0.05, o.position.z)
1172
+ r.scale.setScalar(1 + 0.06 * Math.sin(time * 4))
1173
+ }
1174
+ // Checks drive hundreds of steps: logic and the camera pose there (tests project
1175
+ // screen points with it), no drawing; the probe renders once.
1176
+ if (testing()) sync()
1177
+ else world.render()
1178
+ },
1179
+ render() {
1180
+ sync()
1181
+ if (renderHook !== null) renderHook({ renderer, scene, camera })
1182
+ else renderer.render(scene, camera)
1183
+ },
1184
+ /** Play: step on every animation frame while not paused. */
1185
+ start() {
1186
+ if (running) return
1187
+ running = true
1188
+ last = performance.now()
1189
+ const frame = now => {
1190
+ const dt = Math.min(0.05, (now - last) / 1000)
1191
+ last = now
1192
+ if (!paused) world.step(dt)
1193
+ else world.render()
1194
+ requestAnimationFrame(frame)
1195
+ }
1196
+ requestAnimationFrame(frame)
1197
+ },
1198
+ /** Freeze time (the panel's 「指一下」 does this). */
1199
+ pause(on = true) { paused = !!on },
1200
+ /**
1201
+ * What is at canvas pixel (x, y): `{ id, inst, name, state, point }` for
1202
+ * an entity, `{ id: null, ground: true, point, region }` for the ground,
1203
+ * or null.
1204
+ */
1205
+ pick(x, y) {
1206
+ sync()
1207
+ // A posed skinned mesh can leave its rest-pose bounds: refresh them before the ray.
1208
+ for (const e of entities) e.traverse(o => { if (o.isSkinnedMesh && !o.userData.outline) { o.computeBoundingBox(); o.computeBoundingSphere() } })
1209
+ const ray = new THREE.Raycaster()
1210
+ ray.setFromCamera(new THREE.Vector2(x / canvas.width * 2 - 1, -(y / canvas.height) * 2 + 1), camera)
1211
+ const hits = ray.intersectObjects(ground ? [...entities, ground] : entities, true)
1212
+ for (const hit of hits) {
1213
+ let o = hit.object
1214
+ while (o && !o.userData.entity && !o.userData.ground) o = o.parent
1215
+ if (!o) continue
1216
+ const point = { x: +hit.point.x.toFixed(2), y: +hit.point.y.toFixed(2), z: +hit.point.z.toFixed(2) }
1217
+ if (o.userData.ground) return { id: null, ground: true, point, region: options.region?.(point.x, point.z) ?? null, scene: current?.name ?? null }
1218
+ const e = o.userData.entity
1219
+ const state = typeof e.state === 'function' ? e.state() : e.state
1220
+ // A scattered copy answers its element plus which copy (1-based).
1221
+ const copy = o.userData.scatter && hit.instanceId !== undefined ? hit.instanceId + 1 : null
1222
+ const inst = e.inst ?? (copy !== null && e.id ? `${e.id}#${copy}` : null)
1223
+ return { id: e.id ?? null, inst: inst ?? null, name: e.name ?? null, state: state ?? null, point, region: options.region?.(point.x, point.z) ?? null, scene: current?.name ?? null }
1224
+ }
1225
+ return null
1226
+ },
1227
+ /** Ring entities: `[{ id, tone: 'changed' | 'added' }]` (element id or instance id). An empty list clears. */
1228
+ highlight(list = []) {
1229
+ rings.clear()
1230
+ for (const { id, tone } of list) {
1231
+ for (const o of entities) {
1232
+ const e = o.userData.entity
1233
+ if (e.id !== id && e.inst !== id) continue
1234
+ // A ring around a whole forest says nothing; scattered copies are not ringed.
1235
+ if (o.userData.scatter) continue
1236
+ const box = new THREE.Box3().setFromObject(o), r = Math.max(0.5, box.getSize(new THREE.Vector3()).x * 0.6)
1237
+ const ring = new THREE.Mesh(new THREE.RingGeometry(r, r + 0.14, 48),
1238
+ new THREE.MeshBasicMaterial({ color: tone === 'added' ? '#1f9a55' : '#2f6fe0', transparent: true, opacity: 0.85, depthWrite: false, depthTest: false, side: THREE.DoubleSide }))
1239
+ ring.rotation.x = -Math.PI / 2
1240
+ // Drawn over the terrain: on a slope the ground would otherwise cut the ring in half.
1241
+ ring.renderOrder = 10
1242
+ ring.userData.follow = o
1243
+ ring.raycast = () => {}
1244
+ rings.add(ring)
1245
+ }
1246
+ }
1247
+ world.render()
1248
+ },
1249
+ /** Triangles on screen (for the budget check). */
1250
+ stats() { return { triangles: triangles(scene), entities: entities.length } },
1251
+ }
1252
+ // The panel's element edits arrive as `dshelements` events (the bridge
1253
+ // updates window.dshElements first): rebuild every model built from that
1254
+ // element in place, keeping its position, facing and entity registration.
1255
+ addEventListener('dshelements', event => {
1256
+ const id = event.detail?.id
1257
+ if (typeof id !== 'string') return
1258
+ for (const old of [...entities]) {
1259
+ if (old.userData.builtFrom !== id) continue
1260
+ let fresh
1261
+ try { fresh = old.userData.rescatter ? old.userData.rescatter() : build(id) } catch (error) { console.error(error); continue }
1262
+ fresh.position.copy(old.position)
1263
+ fresh.rotation.copy(old.rotation)
1264
+ fresh.userData.entity = old.userData.entity
1265
+ const parent = old.parent ?? scene
1266
+ parent.remove(old)
1267
+ if (old.userData.scatter) release(old)
1268
+ parent.add(fresh)
1269
+ shade(fresh)
1270
+ if (current?.entities.includes(old)) current.entities[current.entities.indexOf(old)] = fresh
1271
+ entities[entities.indexOf(old)] = fresh
1272
+ if (followed === old) followed = fresh
1273
+ for (const r of rings.children) if (r.userData.follow === old) r.userData.follow = fresh
1274
+ }
1275
+ world.render()
1276
+ })
1277
+ window.dsh3dWorld = world
1278
+ return world
1279
+ }