@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.
- package/CHANGELOG.md +25 -0
- package/README.en.md +53 -0
- package/README.i18n.yaml +6 -0
- package/README.md +52 -2
- package/cordis.patch.yml +23 -0
- package/lib/client.js +30690 -0
- package/lib/index.js +4297 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +3876 -0
- package/lib/typert.remote-client.d.ts +104 -0
- package/lib/typert.remote-client.js +2138 -0
- package/lib/types/agent.d.ts +20 -0
- package/lib/types/agent.js +43 -0
- package/lib/types/checks.d.ts +44 -0
- package/lib/types/checks.js +267 -0
- package/lib/types/client/DesignView.d.ts +22 -0
- package/lib/types/client/DesignView.js +134 -0
- package/lib/types/client/ElementsView.d.ts +21 -0
- package/lib/types/client/ElementsView.js +195 -0
- package/lib/types/client/GameTab.d.ts +14 -0
- package/lib/types/client/GameTab.js +261 -0
- package/lib/types/client/PlayFrame.d.ts +54 -0
- package/lib/types/client/PlayFrame.js +129 -0
- package/lib/types/client/PlayView.d.ts +19 -0
- package/lib/types/client/PlayView.js +163 -0
- package/lib/types/client/Preview3D.d.ts +52 -0
- package/lib/types/client/Preview3D.js +178 -0
- package/lib/types/client/ProjectSwitcher.d.ts +20 -0
- package/lib/types/client/ProjectSwitcher.js +75 -0
- package/lib/types/client/Recipe3DEditor.d.ts +19 -0
- package/lib/types/client/Recipe3DEditor.js +44 -0
- package/lib/types/client/StyleGallery.d.ts +18 -0
- package/lib/types/client/StyleGallery.js +27 -0
- package/lib/types/client/TurnCard.d.ts +62 -0
- package/lib/types/client/TurnCard.js +100 -0
- package/lib/types/client/VersionsView.d.ts +17 -0
- package/lib/types/client/VersionsView.js +68 -0
- package/lib/types/client/chat-node.d.ts +42 -0
- package/lib/types/client/chat-node.js +77 -0
- package/lib/types/client/contract.d.ts +131 -0
- package/lib/types/client/contract.js +6 -0
- package/lib/types/client/definition.d.ts +22 -0
- package/lib/types/client/definition.js +28 -0
- package/lib/types/client/index.d.ts +21 -0
- package/lib/types/client/index.js +263 -0
- package/lib/types/client/locales.d.ts +379 -0
- package/lib/types/client/locales.js +733 -0
- package/lib/types/client/looks.d.ts +36 -0
- package/lib/types/client/looks.js +105 -0
- package/lib/types/client/markdown.d.ts +11 -0
- package/lib/types/client/markdown.js +52 -0
- package/lib/types/client/preset-visibility.d.ts +94 -0
- package/lib/types/client/preset-visibility.js +115 -0
- package/lib/types/client/preview-entry.d.ts +24 -0
- package/lib/types/client/preview-entry.js +110 -0
- package/lib/types/client/refs.d.ts +36 -0
- package/lib/types/client/refs.js +44 -0
- package/lib/types/client/reveal.d.ts +43 -0
- package/lib/types/client/reveal.js +73 -0
- package/lib/types/client/style-samples.d.ts +5 -0
- package/lib/types/client/style-samples.js +196 -0
- package/lib/types/fsutil.d.ts +26 -0
- package/lib/types/fsutil.js +104 -0
- package/lib/types/hooks.d.ts +83 -0
- package/lib/types/hooks.js +108 -0
- package/lib/types/index.d.ts +30 -0
- package/lib/types/index.js +22 -0
- package/lib/types/kits/canvas2d.d.ts +6 -0
- package/lib/types/kits/canvas2d.js +60 -0
- package/lib/types/kits/ids.d.ts +11 -0
- package/lib/types/kits/ids.js +6 -0
- package/lib/types/kits/index.d.ts +26 -0
- package/lib/types/kits/index.js +28 -0
- package/lib/types/kits/three3d.d.ts +12 -0
- package/lib/types/kits/three3d.js +188 -0
- package/lib/types/kits/types.d.ts +52 -0
- package/lib/types/kits/types.js +6 -0
- package/lib/types/look3d.d.ts +20 -0
- package/lib/types/look3d.js +175 -0
- package/lib/types/panel-session.d.ts +21 -0
- package/lib/types/panel-session.js +16 -0
- package/lib/types/project.d.ts +138 -0
- package/lib/types/project.js +504 -0
- package/lib/types/remote.d.ts +184 -0
- package/lib/types/remote.js +263 -0
- package/lib/types/skills.d.ts +13 -0
- package/lib/types/skills.js +141 -0
- package/lib/types/store.d.ts +346 -0
- package/lib/types/store.js +1383 -0
- package/lib/types/tools.d.ts +12 -0
- package/lib/types/tools.js +309 -0
- package/lib/types/trash.d.ts +5 -0
- package/lib/types/trash.js +64 -0
- package/lib/types/types.d.ts +548 -0
- package/lib/types/types.js +85 -0
- package/lib/types/vfs.d.ts +94 -0
- package/lib/types/vfs.js +217 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +132 -3
- package/runtime/dsh3d.js +1279 -0
package/runtime/dsh3d.js
ADDED
|
@@ -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
|
+
}
|