spoint 0.1.638 → 0.1.640
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/client/core/camera.js +55 -50
- package/client/editor/SceneHierarchy.js +169 -149
- package/package.json +1 -1
- package/src/terrain/ColliderStreamer.js +55 -52
package/client/core/camera.js
CHANGED
|
@@ -268,28 +268,38 @@ export function createCameraController(camera, scene) {
|
|
|
268
268
|
inputPitchDelta *= decay
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
+
// must apply+decay look deltas here: the gameplay branch in update() is unreachable in edit mode (early return), so this is the only place the fly-cam rotates
|
|
272
|
+
function updateEditFlyCam(frameDt, inputState) {
|
|
273
|
+
applyLookInput(frameDt)
|
|
274
|
+
const sy = Math.sin(yaw), cy = Math.cos(yaw), sp = Math.sin(pitch), cp = Math.cos(pitch)
|
|
275
|
+
const fwd = (inputState.forward?1:0)-(inputState.backward?1:0)
|
|
276
|
+
const strafe = (inputState.right?1:0)-(inputState.left?1:0)
|
|
277
|
+
const up = (inputState.jump?1:0)-(inputState.crouch?1:0)
|
|
278
|
+
const moving = (fwd || strafe || up) ? 1 : 0
|
|
279
|
+
editBoost = moving ? Math.min(editBoostMax, editBoost + editBoostRate * frameDt) : 1
|
|
280
|
+
const altMul = _editAltitudeSpeedMul(editCamPos.x, editCamPos.y, editCamPos.z)
|
|
281
|
+
const maxV = editCamSpeed * editBoost * altMul
|
|
282
|
+
const wishX = (fwd*sy + strafe*(-cy)) * maxV, wishY = up * maxV, wishZ = (fwd*cy + strafe*sy) * maxV
|
|
283
|
+
const aT = 1 - Math.exp(-editAccelHz * frameDt)
|
|
284
|
+
editVel.x += (wishX - editVel.x) * aT; editVel.y += (wishY - editVel.y) * aT; editVel.z += (wishZ - editVel.z) * aT
|
|
285
|
+
editCamPos.x += editVel.x * frameDt; editCamPos.y += editVel.y * frameDt; editCamPos.z += editVel.z * frameDt
|
|
286
|
+
// must scale horizontal look by cos(pitch), or a steep downward pitch still aims level (fly-cam looks into empty sky)
|
|
287
|
+
camera.position.copy(editCamPos); camera.lookAt(editCamPos.x + sy*cp*100, editCamPos.y + sp*100, editCamPos.z + cy*cp*100)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// head hidden only while camera is inside the player head (dist<0.01), restored the moment it zooms out
|
|
291
|
+
function updateHeadBoneVisibility(inHead) {
|
|
292
|
+
if (!headBone || inHead === headBoneHidden) return
|
|
293
|
+
if (inHead) { headBone.scale.set(0, 0, 0); headBone.position.y -= fpsHeadDownOffset }
|
|
294
|
+
else { headBone.scale.set(1, 1, 1); headBone.position.y += fpsHeadDownOffset }
|
|
295
|
+
headBoneHidden = inHead
|
|
296
|
+
if (_onCameraInHead) try { _onCameraInHead(inHead) } catch (_) {}
|
|
297
|
+
}
|
|
298
|
+
|
|
271
299
|
function update(localPlayer, localMesh, frameDt, inputState) {
|
|
272
300
|
if (mode === 'custom' || mode === 'fixed') return
|
|
273
301
|
if (!localPlayer && !editMode) return
|
|
274
|
-
if (editMode && inputState) {
|
|
275
|
-
// must apply+decay look deltas here: the gameplay branch below is unreachable in edit mode (early return), so this is the only place the fly-cam rotates
|
|
276
|
-
applyLookInput(frameDt)
|
|
277
|
-
const sy = Math.sin(yaw), cy = Math.cos(yaw), sp = Math.sin(pitch), cp = Math.cos(pitch)
|
|
278
|
-
const fwd = (inputState.forward?1:0)-(inputState.backward?1:0)
|
|
279
|
-
const strafe = (inputState.right?1:0)-(inputState.left?1:0)
|
|
280
|
-
const up = (inputState.jump?1:0)-(inputState.crouch?1:0)
|
|
281
|
-
const moving = (fwd || strafe || up) ? 1 : 0
|
|
282
|
-
editBoost = moving ? Math.min(editBoostMax, editBoost + editBoostRate * frameDt) : 1
|
|
283
|
-
const altMul = _editAltitudeSpeedMul(editCamPos.x, editCamPos.y, editCamPos.z)
|
|
284
|
-
const maxV = editCamSpeed * editBoost * altMul
|
|
285
|
-
const wishX = (fwd*sy + strafe*(-cy)) * maxV, wishY = up * maxV, wishZ = (fwd*cy + strafe*sy) * maxV
|
|
286
|
-
const aT = 1 - Math.exp(-editAccelHz * frameDt)
|
|
287
|
-
editVel.x += (wishX - editVel.x) * aT; editVel.y += (wishY - editVel.y) * aT; editVel.z += (wishZ - editVel.z) * aT
|
|
288
|
-
editCamPos.x += editVel.x * frameDt; editCamPos.y += editVel.y * frameDt; editCamPos.z += editVel.z * frameDt
|
|
289
|
-
// must scale horizontal look by cos(pitch), or a steep downward pitch still aims level (fly-cam looks into empty sky)
|
|
290
|
-
camera.position.copy(editCamPos); camera.lookAt(editCamPos.x + sy*cp*100, editCamPos.y + sp*100, editCamPos.z + cy*cp*100)
|
|
291
|
-
return
|
|
292
|
-
}
|
|
302
|
+
if (editMode && inputState) { updateEditFlyCam(frameDt, inputState); return }
|
|
293
303
|
if (localMesh) camTarget.set(localMesh.position.x, localMesh.position.y + headHeight, localMesh.position.z)
|
|
294
304
|
else camTarget.set(localPlayer.position[0], localPlayer.position[1] + headHeight, localPlayer.position[2])
|
|
295
305
|
applyLookInput(frameDt)
|
|
@@ -300,14 +310,7 @@ export function createCameraController(camera, scene) {
|
|
|
300
310
|
const sy = Math.sin(yaw), cy = Math.cos(yaw), sp = Math.sin(pitch), cp = Math.cos(pitch)
|
|
301
311
|
const fwdX = sy*cp, fwdY = sp, fwdZ = cy*cp
|
|
302
312
|
const dist = mode === 'fps' ? 0 : zoomStages[zoomIndex]
|
|
303
|
-
|
|
304
|
-
const inHead = dist < 0.01
|
|
305
|
-
if (headBone && inHead !== headBoneHidden) {
|
|
306
|
-
if (inHead) { headBone.scale.set(0, 0, 0); headBone.position.y -= fpsHeadDownOffset }
|
|
307
|
-
else { headBone.scale.set(1, 1, 1); headBone.position.y += fpsHeadDownOffset }
|
|
308
|
-
headBoneHidden = inHead
|
|
309
|
-
if (_onCameraInHead) try { _onCameraInHead(inHead) } catch (_) {}
|
|
310
|
-
}
|
|
313
|
+
updateHeadBoneVisibility(dist < 0.01)
|
|
311
314
|
if (dist < 0.01) updateFPS(localMesh, frameDt, fwdX, fwdY, fwdZ)
|
|
312
315
|
else updateTPS(dist, localMesh, frameDt, fwdX, fwdY, fwdZ, -cy, sy)
|
|
313
316
|
}
|
|
@@ -353,6 +356,30 @@ export function createCameraController(camera, scene) {
|
|
|
353
356
|
return len > 0.001 ? [dx/len, dy/len, dz/len] : [fwdX, fwdY, fwdZ]
|
|
354
357
|
}
|
|
355
358
|
|
|
359
|
+
// On entering edit mode the fly-cam takes over (driven by update()'s editMode
|
|
360
|
+
// branch). CAPTURE the gameplay camera orientation first so it can be restored
|
|
361
|
+
// on exit -- yaw/pitch/zoomIndex are SHARED with the fly-cam (the editMode
|
|
362
|
+
// branch mutates yaw/pitch), so without this the gameplay camera returns to the
|
|
363
|
+
// editor's last orientation, not its pre-edit follow pose (the "camera did not
|
|
364
|
+
// return to the correct location" defect).
|
|
365
|
+
function setEditMode(enabled, localMesh) {
|
|
366
|
+
if (enabled && !editMode) {
|
|
367
|
+
_gameplayCam = { yaw, pitch, zoomIndex }
|
|
368
|
+
// seed the fly-cam offset back-and-up from the player (not glued to the head-height spot, which could stare into a wall)
|
|
369
|
+
const px = localMesh ? localMesh.position.x : camera.position.x
|
|
370
|
+
const py = localMesh ? localMesh.position.y : camera.position.y
|
|
371
|
+
const pz = localMesh ? localMesh.position.z : camera.position.z
|
|
372
|
+
const sy = Math.sin(yaw), cy = Math.cos(yaw)
|
|
373
|
+
editCamPos.set(px - sy * 8, py + 5, pz - cy * 8)
|
|
374
|
+
pitch = -0.35
|
|
375
|
+
editVel.set(0, 0, 0); editBoost = 1
|
|
376
|
+
} else if (!enabled && editMode && _gameplayCam) {
|
|
377
|
+
yaw = _gameplayCam.yaw; pitch = _gameplayCam.pitch; zoomIndex = _gameplayCam.zoomIndex
|
|
378
|
+
_gameplayCam = null
|
|
379
|
+
}
|
|
380
|
+
editMode = enabled
|
|
381
|
+
}
|
|
382
|
+
|
|
356
383
|
return {
|
|
357
384
|
update, applyConfig, getAimDirection, setMode, getMode: () => mode,
|
|
358
385
|
setEnvironment: meshes => { envMeshes.length = 0; envMeshes.push(...meshes); _bvhDirty = true },
|
|
@@ -385,29 +412,7 @@ export function createCameraController(camera, scene) {
|
|
|
385
412
|
setVRYaw: v => { yaw = v }, getVRYaw: () => yaw,
|
|
386
413
|
setVRPitch: v => { pitch = v }, getVRPitch: () => pitch,
|
|
387
414
|
adjustVRPitch: delta => { pitch = Math.max(pitchMin, Math.min(pitchMax, pitch + delta)) },
|
|
388
|
-
setEditMode
|
|
389
|
-
// On entering edit mode the fly-cam takes over (driven by update()'s editMode
|
|
390
|
-
// branch). CAPTURE the gameplay camera orientation first so it can be restored
|
|
391
|
-
// on exit -- yaw/pitch/zoomIndex are SHARED with the fly-cam (the editMode
|
|
392
|
-
// branch mutates yaw/pitch), so without this the gameplay camera returns to the
|
|
393
|
-
// editor's last orientation, not its pre-edit follow pose (the "camera did not
|
|
394
|
-
// return to the correct location" defect).
|
|
395
|
-
if (enabled && !editMode) {
|
|
396
|
-
_gameplayCam = { yaw, pitch, zoomIndex }
|
|
397
|
-
// seed the fly-cam offset back-and-up from the player (not glued to the head-height spot, which could stare into a wall)
|
|
398
|
-
const px = localMesh ? localMesh.position.x : camera.position.x
|
|
399
|
-
const py = localMesh ? localMesh.position.y : camera.position.y
|
|
400
|
-
const pz = localMesh ? localMesh.position.z : camera.position.z
|
|
401
|
-
const sy = Math.sin(yaw), cy = Math.cos(yaw)
|
|
402
|
-
editCamPos.set(px - sy * 8, py + 5, pz - cy * 8)
|
|
403
|
-
pitch = -0.35
|
|
404
|
-
editVel.set(0, 0, 0); editBoost = 1
|
|
405
|
-
} else if (!enabled && editMode && _gameplayCam) {
|
|
406
|
-
yaw = _gameplayCam.yaw; pitch = _gameplayCam.pitch; zoomIndex = _gameplayCam.zoomIndex
|
|
407
|
-
_gameplayCam = null
|
|
408
|
-
}
|
|
409
|
-
editMode = enabled
|
|
410
|
-
},
|
|
415
|
+
setEditMode,
|
|
411
416
|
getEditMode: () => editMode,
|
|
412
417
|
getEditCameraPosition: () => editCamPos,
|
|
413
418
|
setEditCameraPosition: (x, y, z) => { editCamPos.set(x, y, z); editVel.set(0, 0, 0); editBoost = 1; _lastAltSampleX = Infinity; _lastAltSampleZ = Infinity },
|
|
@@ -30,6 +30,70 @@ function attachContextMenu(el, getItems) {
|
|
|
30
30
|
return C.useContextMenu(el, null, ({ x, y }) => openContextMenu(x, y, getItems()))
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
// Shared node classification, reused by both the tree-row icon glyph and the search `type:` scope filter.
|
|
34
|
+
function classifyNode(node) {
|
|
35
|
+
if (node.model) return 'model'
|
|
36
|
+
if (node.custom && node.custom.mesh) return 'primitive'
|
|
37
|
+
if (node._appName || node.appName) return 'app'
|
|
38
|
+
return 'other'
|
|
39
|
+
}
|
|
40
|
+
const _typeGlyph = { model: 'M', primitive: 'P', app: 'A', other: 'o' }
|
|
41
|
+
|
|
42
|
+
function nodeMatchesQuery(node, q) {
|
|
43
|
+
const id = (node.id || '').toLowerCase(), app = (node._appName || node.appName || node.label || '').toLowerCase()
|
|
44
|
+
let typeFilter = null
|
|
45
|
+
const m = /^type:(\w+)\s*/.exec(q)
|
|
46
|
+
if (m) { typeFilter = m[1]; q = q.slice(m[0].length) }
|
|
47
|
+
if (typeFilter && classifyNode(node) !== typeFilter) return false
|
|
48
|
+
if (!q) return true
|
|
49
|
+
return id.includes(q) || app.includes(q)
|
|
50
|
+
}
|
|
51
|
+
function subtreeMatchesQuery(node, q) {
|
|
52
|
+
if (!q) return true
|
|
53
|
+
if (nodeMatchesQuery(node, q)) return true
|
|
54
|
+
return (node.children || []).some(child => subtreeMatchesQuery(child, q))
|
|
55
|
+
}
|
|
56
|
+
function flattenTree(nodes, depth, q, seen, expanded, out) {
|
|
57
|
+
for (const node of nodes || []) {
|
|
58
|
+
if (q && !subtreeMatchesQuery(node, q)) continue
|
|
59
|
+
if (!seen.has(node.id)) { seen.add(node.id); expanded.add(node.id) }
|
|
60
|
+
const kids = node.children || []
|
|
61
|
+
const hasKids = kids.length > 0
|
|
62
|
+
const isExpanded = q ? true : expanded.has(node.id)
|
|
63
|
+
out.push({ node, depth, hasKids, expanded: isExpanded })
|
|
64
|
+
if (hasKids && isExpanded) flattenTree(kids, depth + 1, q, seen, expanded, out)
|
|
65
|
+
}
|
|
66
|
+
return out
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Drop an id from a batch drag if it is an ancestor of another id in the same batch -- reparenting an ancestor
|
|
70
|
+
// under its own descendant target (or moving it while the descendant also moves) is structurally impossible.
|
|
71
|
+
function isAncestorOf(parentOf, candidateId, descendantId) {
|
|
72
|
+
let p = parentOf.get(descendantId)
|
|
73
|
+
while (p != null) { if (p === candidateId) return true; p = parentOf.get(p) }
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
76
|
+
function dedupeNonAncestors(parentOf, ids) {
|
|
77
|
+
if (ids.length < 2) return ids
|
|
78
|
+
return ids.filter(id => !ids.some(other => other !== id && isAncestorOf(parentOf, id, other)))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function indexParents(nodes, parentId, parentOf) {
|
|
82
|
+
for (const n of nodes || []) { parentOf.set(n.id, parentId); indexParents(n.children, n.id, parentOf) }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Above VIRTUALIZE_THRESHOLD, only viewport+overscan rows render as real TreeItems; the rest become 2 sized spacer divs.
|
|
86
|
+
function sliceVirtualWindow(rows, scrollTop, viewportH, rowHeight, overscan) {
|
|
87
|
+
const visibleCount = Math.max(1, Math.ceil((viewportH || 400) / rowHeight))
|
|
88
|
+
const startIdx = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan)
|
|
89
|
+
const endIdx = Math.min(rows.length, startIdx + visibleCount + overscan * 2)
|
|
90
|
+
return {
|
|
91
|
+
spacerTop: startIdx * rowHeight,
|
|
92
|
+
spacerBottom: (rows.length - endIdx) * rowHeight,
|
|
93
|
+
renderedRows: rows.slice(startIdx, endIdx)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
33
97
|
export function createSceneHierarchy(container, { onSelect, onFocus, onDelete, onDuplicate, onRename, onReparent, onLockChange, onHiddenChange } = {}) {
|
|
34
98
|
let _ents = [], _q = '', _sel = null
|
|
35
99
|
const _expanded = new Set()
|
|
@@ -42,42 +106,6 @@ export function createSceneHierarchy(container, { onSelect, onFocus, onDelete, o
|
|
|
42
106
|
const _locked = new Set()
|
|
43
107
|
const _hiddenInEditor = new Set()
|
|
44
108
|
|
|
45
|
-
// Shared node classification, reused by both the tree-row icon glyph and the search `type:` scope filter.
|
|
46
|
-
function classify(node) {
|
|
47
|
-
if (node.model) return 'model'
|
|
48
|
-
if (node.custom && node.custom.mesh) return 'primitive'
|
|
49
|
-
if (node._appName || node.appName) return 'app'
|
|
50
|
-
return 'other'
|
|
51
|
-
}
|
|
52
|
-
const _typeGlyph = { model: 'M', primitive: 'P', app: 'A', other: 'o' }
|
|
53
|
-
|
|
54
|
-
function matches(node) {
|
|
55
|
-
const id = (node.id || '').toLowerCase(), app = (node._appName || node.appName || node.label || '').toLowerCase()
|
|
56
|
-
let q = _q, typeFilter = null
|
|
57
|
-
const m = /^type:(\w+)\s*/.exec(q)
|
|
58
|
-
if (m) { typeFilter = m[1]; q = q.slice(m[0].length) }
|
|
59
|
-
if (typeFilter && classify(node) !== typeFilter) return false
|
|
60
|
-
if (!q) return true
|
|
61
|
-
return id.includes(q) || app.includes(q)
|
|
62
|
-
}
|
|
63
|
-
function subtreeMatches(node) {
|
|
64
|
-
if (!_q) return true
|
|
65
|
-
if (matches(node)) return true
|
|
66
|
-
return (node.children || []).some(subtreeMatches)
|
|
67
|
-
}
|
|
68
|
-
function flatten(nodes, depth, out) {
|
|
69
|
-
for (const node of nodes || []) {
|
|
70
|
-
if (_q && !subtreeMatches(node)) continue
|
|
71
|
-
if (!_seen.has(node.id)) { _seen.add(node.id); _expanded.add(node.id) }
|
|
72
|
-
const kids = node.children || []
|
|
73
|
-
const hasKids = kids.length > 0
|
|
74
|
-
const expanded = _q ? true : _expanded.has(node.id)
|
|
75
|
-
out.push({ node, depth, hasKids, expanded })
|
|
76
|
-
if (hasKids && expanded) flatten(kids, depth + 1, out)
|
|
77
|
-
}
|
|
78
|
-
return out
|
|
79
|
-
}
|
|
80
|
-
|
|
81
109
|
container.classList.add('ds-ep-panel')
|
|
82
110
|
container.style.cssText = 'display:flex;flex-direction:column;height:100%;min-height:0'
|
|
83
111
|
|
|
@@ -95,18 +123,6 @@ export function createSceneHierarchy(container, { onSelect, onFocus, onDelete, o
|
|
|
95
123
|
return all.has(node.id) && all.size > 1 ? [...all] : [node.id]
|
|
96
124
|
}
|
|
97
125
|
|
|
98
|
-
// Drop an id from a batch drag if it is an ancestor of another id in the same batch -- reparenting an ancestor
|
|
99
|
-
// under its own descendant target (or moving it while the descendant also moves) is structurally impossible.
|
|
100
|
-
function _isAncestorOf(candidateId, descendantId) {
|
|
101
|
-
let p = _parentOf.get(descendantId)
|
|
102
|
-
while (p != null) { if (p === candidateId) return true; p = _parentOf.get(p) }
|
|
103
|
-
return false
|
|
104
|
-
}
|
|
105
|
-
function _dedupeNonAncestors(ids) {
|
|
106
|
-
if (ids.length < 2) return ids
|
|
107
|
-
return ids.filter(id => !ids.some(other => other !== id && _isAncestorOf(id, other)))
|
|
108
|
-
}
|
|
109
|
-
|
|
110
126
|
function nodeMenuItems(node) {
|
|
111
127
|
const hasParent = !!_parentOf.get(node.id)
|
|
112
128
|
const ids = _bulkIds(node)
|
|
@@ -144,6 +160,72 @@ export function createSceneHierarchy(container, { onSelect, onFocus, onDelete, o
|
|
|
144
160
|
]
|
|
145
161
|
}
|
|
146
162
|
|
|
163
|
+
function attachRowKebab(el, node) {
|
|
164
|
+
if (el.querySelector('[data-kebab]')) return
|
|
165
|
+
const kebab = document.createElement('button')
|
|
166
|
+
kebab.setAttribute('data-kebab', '')
|
|
167
|
+
kebab.textContent = '...'
|
|
168
|
+
kebab.title = 'More actions'
|
|
169
|
+
kebab.style.cssText = 'position:absolute;right:4px;top:50%;transform:translateY(-50%);background:transparent;border:none;color:rgba(255,255,255,0.4);cursor:pointer;font:11px monospace;padding:2px 6px;opacity:0;transition:opacity 0.1s'
|
|
170
|
+
kebab.addEventListener('click', (e) => {
|
|
171
|
+
e.preventDefault(); e.stopPropagation()
|
|
172
|
+
const rect = kebab.getBoundingClientRect()
|
|
173
|
+
openContextMenu(rect.left, rect.bottom, nodeMenuItems(node))
|
|
174
|
+
})
|
|
175
|
+
if (getComputedStyle(el).position === 'static') el.style.position = 'relative'
|
|
176
|
+
el.appendChild(kebab)
|
|
177
|
+
el.addEventListener('mouseenter', () => { kebab.style.opacity = '1' })
|
|
178
|
+
el.addEventListener('mouseleave', () => { kebab.style.opacity = '0' })
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Top/bottom third of a row = sibling-of-target reparent; middle third = child. children is an
|
|
182
|
+
// unordered Set server-side, so this only picks the parent, not sibling order.
|
|
183
|
+
function attachRowDragDrop(el, node, id) {
|
|
184
|
+
// If this row is part of the current multi-selection, drag the whole set as one batch; otherwise single-id drag (existing behavior).
|
|
185
|
+
const _dragIds = _bulkIds(node)
|
|
186
|
+
const drag = C.useDraggable(el, { data: _dragIds.length > 1 ? { id, ids: _dragIds } : { id }, kind: 'scene-node' })
|
|
187
|
+
let _indicator = null
|
|
188
|
+
const _clearIndicator = () => { if (_indicator) { _indicator.remove(); _indicator = null } }
|
|
189
|
+
const _showIndicator = (edge) => {
|
|
190
|
+
_clearIndicator()
|
|
191
|
+
_indicator = document.createElement('div')
|
|
192
|
+
_indicator.className = 'ds-ep-tree-drop-indicator'
|
|
193
|
+
_indicator.style.cssText = `position:absolute;left:0;right:0;height:2px;background:var(--accent,#4af);pointer-events:none;z-index:10;${edge === 'top' ? 'top:-1px' : 'bottom:-1px'}`
|
|
194
|
+
if (getComputedStyle(el).position === 'static') el.style.position = 'relative'
|
|
195
|
+
el.appendChild(_indicator)
|
|
196
|
+
}
|
|
197
|
+
const drop = C.useDropTarget(el, {
|
|
198
|
+
accepts: ['scene-node'],
|
|
199
|
+
onDragOver: (payload) => {
|
|
200
|
+
const clientY = payload?.pointerEvent?.clientY
|
|
201
|
+
if (clientY == null) { _clearIndicator(); return }
|
|
202
|
+
const r = el.getBoundingClientRect()
|
|
203
|
+
const frac = (clientY - r.top) / r.height
|
|
204
|
+
if (frac < 0.25) _showIndicator('top')
|
|
205
|
+
else if (frac > 0.75) _showIndicator('bottom')
|
|
206
|
+
else _clearIndicator()
|
|
207
|
+
},
|
|
208
|
+
onDragLeave: _clearIndicator,
|
|
209
|
+
onDrop: ({ data, pointerEvent }) => {
|
|
210
|
+
_clearIndicator()
|
|
211
|
+
if (!data?.id) return
|
|
212
|
+
const dragIds = dedupeNonAncestors(_parentOf, data.ids && data.ids.length > 1 ? data.ids : [data.id])
|
|
213
|
+
if (dragIds.includes(id)) return
|
|
214
|
+
const r = el.getBoundingClientRect()
|
|
215
|
+
const frac = pointerEvent ? (pointerEvent.clientY - r.top) / r.height : 0.5
|
|
216
|
+
if (frac < 0.25 || frac > 0.75) {
|
|
217
|
+
const siblingParent = _parentOf.get(id) || null
|
|
218
|
+
dragIds.forEach(dragId => onReparent(dragId, siblingParent))
|
|
219
|
+
showToast((dragIds.length > 1 ? `Moved ${dragIds.length} entities` : 'Moved ' + dragIds[0]) + ' to ' + (siblingParent ? 'be a sibling of ' + id : 'root'))
|
|
220
|
+
} else {
|
|
221
|
+
dragIds.forEach(dragId => onReparent(dragId, id))
|
|
222
|
+
showToast((dragIds.length > 1 ? `Reparented ${dragIds.length} entities` : 'Reparented ' + dragIds[0]) + ' -> ' + id)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
})
|
|
226
|
+
_menuTeardowns.push(() => { drag.destroy(); drop.destroy(); _clearIndicator() })
|
|
227
|
+
}
|
|
228
|
+
|
|
147
229
|
function attachRowBehaviors(rows) {
|
|
148
230
|
clearItemMenus()
|
|
149
231
|
const els = container.querySelectorAll('.ds-ep-tree-item[data-eid]')
|
|
@@ -160,68 +242,8 @@ export function createSceneHierarchy(container, { onSelect, onFocus, onDelete, o
|
|
|
160
242
|
// (immune to vdom node replacement between mousedown and click).
|
|
161
243
|
}
|
|
162
244
|
_menuTeardowns.push(attachContextMenu(el, () => nodeMenuItems(node)))
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
kebab.setAttribute('data-kebab', '')
|
|
166
|
-
kebab.textContent = '...'
|
|
167
|
-
kebab.title = 'More actions'
|
|
168
|
-
kebab.style.cssText = 'position:absolute;right:4px;top:50%;transform:translateY(-50%);background:transparent;border:none;color:rgba(255,255,255,0.4);cursor:pointer;font:11px monospace;padding:2px 6px;opacity:0;transition:opacity 0.1s'
|
|
169
|
-
kebab.addEventListener('click', (e) => {
|
|
170
|
-
e.preventDefault(); e.stopPropagation()
|
|
171
|
-
const rect = kebab.getBoundingClientRect()
|
|
172
|
-
openContextMenu(rect.left, rect.bottom, nodeMenuItems(node))
|
|
173
|
-
})
|
|
174
|
-
if (getComputedStyle(el).position === 'static') el.style.position = 'relative'
|
|
175
|
-
el.appendChild(kebab)
|
|
176
|
-
el.addEventListener('mouseenter', () => { kebab.style.opacity = '1' })
|
|
177
|
-
el.addEventListener('mouseleave', () => { kebab.style.opacity = '0' })
|
|
178
|
-
}
|
|
179
|
-
if (onReparent && typeof C.useDraggable === 'function') {
|
|
180
|
-
// If this row is part of the current multi-selection, drag the whole set as one batch; otherwise single-id drag (existing behavior).
|
|
181
|
-
const _dragIds = _bulkIds(node)
|
|
182
|
-
const drag = C.useDraggable(el, { data: _dragIds.length > 1 ? { id, ids: _dragIds } : { id }, kind: 'scene-node' })
|
|
183
|
-
// Top/bottom third of a row = sibling-of-target reparent; middle third = child. children is an unordered Set server-side, so this only picks the parent, not sibling order.
|
|
184
|
-
let _indicator = null
|
|
185
|
-
const _clearIndicator = () => { if (_indicator) { _indicator.remove(); _indicator = null } }
|
|
186
|
-
const _showIndicator = (edge) => {
|
|
187
|
-
_clearIndicator()
|
|
188
|
-
_indicator = document.createElement('div')
|
|
189
|
-
_indicator.className = 'ds-ep-tree-drop-indicator'
|
|
190
|
-
_indicator.style.cssText = `position:absolute;left:0;right:0;height:2px;background:var(--accent,#4af);pointer-events:none;z-index:10;${edge === 'top' ? 'top:-1px' : 'bottom:-1px'}`
|
|
191
|
-
if (getComputedStyle(el).position === 'static') el.style.position = 'relative'
|
|
192
|
-
el.appendChild(_indicator)
|
|
193
|
-
}
|
|
194
|
-
const drop = C.useDropTarget(el, {
|
|
195
|
-
accepts: ['scene-node'],
|
|
196
|
-
onDragOver: (payload) => {
|
|
197
|
-
const clientY = payload?.pointerEvent?.clientY
|
|
198
|
-
if (clientY == null) { _clearIndicator(); return }
|
|
199
|
-
const r = el.getBoundingClientRect()
|
|
200
|
-
const frac = (clientY - r.top) / r.height
|
|
201
|
-
if (frac < 0.25) _showIndicator('top')
|
|
202
|
-
else if (frac > 0.75) _showIndicator('bottom')
|
|
203
|
-
else _clearIndicator()
|
|
204
|
-
},
|
|
205
|
-
onDragLeave: _clearIndicator,
|
|
206
|
-
onDrop: ({ data, pointerEvent }) => {
|
|
207
|
-
_clearIndicator()
|
|
208
|
-
if (!data?.id) return
|
|
209
|
-
const dragIds = _dedupeNonAncestors(data.ids && data.ids.length > 1 ? data.ids : [data.id])
|
|
210
|
-
if (dragIds.includes(id)) return
|
|
211
|
-
const r = el.getBoundingClientRect()
|
|
212
|
-
const frac = pointerEvent ? (pointerEvent.clientY - r.top) / r.height : 0.5
|
|
213
|
-
if (frac < 0.25 || frac > 0.75) {
|
|
214
|
-
const siblingParent = _parentOf.get(id) || null
|
|
215
|
-
dragIds.forEach(dragId => onReparent(dragId, siblingParent))
|
|
216
|
-
showToast((dragIds.length > 1 ? `Moved ${dragIds.length} entities` : 'Moved ' + dragIds[0]) + ' to ' + (siblingParent ? 'be a sibling of ' + id : 'root'))
|
|
217
|
-
} else {
|
|
218
|
-
dragIds.forEach(dragId => onReparent(dragId, id))
|
|
219
|
-
showToast((dragIds.length > 1 ? `Reparented ${dragIds.length} entities` : 'Reparented ' + dragIds[0]) + ' -> ' + id)
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
})
|
|
223
|
-
_menuTeardowns.push(() => { drag.destroy(); drop.destroy(); _clearIndicator() })
|
|
224
|
-
}
|
|
245
|
+
attachRowKebab(el, node)
|
|
246
|
+
if (onReparent && typeof C.useDraggable === 'function') attachRowDragDrop(el, node, id)
|
|
225
247
|
})
|
|
226
248
|
}
|
|
227
249
|
|
|
@@ -235,7 +257,7 @@ export function createSceneHierarchy(container, { onSelect, onFocus, onDelete, o
|
|
|
235
257
|
const { node, depth, hasKids, expanded } = row
|
|
236
258
|
const _label = node.label || node._appName || node.appName || node.id
|
|
237
259
|
const _appTag = node._appName || node.appName || ''
|
|
238
|
-
const _glyph = node.light ? 'L' : _typeGlyph[
|
|
260
|
+
const _glyph = node.light ? 'L' : _typeGlyph[classifyNode(node)]
|
|
239
261
|
const _flags = (_locked.has(node.id) ? ' 🔒' : '') + (_hiddenInEditor.has(node.id) ? ' 👁🗨' : '')
|
|
240
262
|
return C.TreeItem({
|
|
241
263
|
label: '[' + _glyph + '] ' + _label + _flags,
|
|
@@ -248,20 +270,34 @@ export function createSceneHierarchy(container, { onSelect, onFocus, onDelete, o
|
|
|
248
270
|
})
|
|
249
271
|
}
|
|
250
272
|
|
|
273
|
+
function bindPanelBodyRef(el, virtualized) {
|
|
274
|
+
if (!el) return
|
|
275
|
+
_viewportH = el.clientHeight
|
|
276
|
+
if (virtualized && !el._dsScrollListener) {
|
|
277
|
+
el._dsScrollListener = true
|
|
278
|
+
el.addEventListener('scroll', () => { _scrollTop = el.scrollTop; _viewportH = el.clientHeight; render() }, { passive: true })
|
|
279
|
+
}
|
|
280
|
+
if (el._dsRootDrop || !onReparent || typeof C.useDropTarget !== 'function') return
|
|
281
|
+
el._dsRootDrop = true
|
|
282
|
+
const d = C.useDropTarget(el, { accepts: ['scene-node'], onDrop: ({ data }) => {
|
|
283
|
+
if (!data?.id) return
|
|
284
|
+
const dragIds = dedupeNonAncestors(_parentOf, data.ids && data.ids.length > 1 ? data.ids : [data.id]).filter(id => _parentOf.get(id))
|
|
285
|
+
if (!dragIds.length) return
|
|
286
|
+
dragIds.forEach(id => onReparent(id, null))
|
|
287
|
+
showToast(dragIds.length > 1 ? `Unparented ${dragIds.length} entities` : 'Unparented ' + dragIds[0])
|
|
288
|
+
} })
|
|
289
|
+
el._dsRootDropDestroy = d.destroy
|
|
290
|
+
}
|
|
291
|
+
|
|
251
292
|
let _lastRows = []
|
|
252
293
|
function render() {
|
|
253
|
-
const rows =
|
|
294
|
+
const rows = flattenTree(_ents, 0, _q, _seen, _expanded, [])
|
|
254
295
|
_lastRows = rows
|
|
255
296
|
|
|
256
297
|
const virtualized = rows.length > VIRTUALIZE_THRESHOLD
|
|
257
298
|
let treeChildren, spacerTop = 0, spacerBottom = 0, renderedRows = rows
|
|
258
299
|
if (virtualized) {
|
|
259
|
-
|
|
260
|
-
const startIdx = Math.max(0, Math.floor(_scrollTop / ROW_HEIGHT) - OVERSCAN)
|
|
261
|
-
const endIdx = Math.min(rows.length, startIdx + visibleCount + OVERSCAN * 2)
|
|
262
|
-
spacerTop = startIdx * ROW_HEIGHT
|
|
263
|
-
spacerBottom = (rows.length - endIdx) * ROW_HEIGHT
|
|
264
|
-
renderedRows = rows.slice(startIdx, endIdx)
|
|
300
|
+
;({ spacerTop, spacerBottom, renderedRows } = sliceVirtualWindow(rows, _scrollTop, _viewportH, ROW_HEIGHT, OVERSCAN))
|
|
265
301
|
treeChildren = renderedRows.map(_rowEl)
|
|
266
302
|
} else {
|
|
267
303
|
treeChildren = rows.map(_rowEl)
|
|
@@ -273,24 +309,7 @@ export function createSceneHierarchy(container, { onSelect, onFocus, onDelete, o
|
|
|
273
309
|
: h('div', {
|
|
274
310
|
class: 'ds-ep-panel-body flush', style: 'flex:1;min-height:0;overflow-y:auto',
|
|
275
311
|
// Drop onto the panel background (not a row) = unparent to root.
|
|
276
|
-
ref: (el) =>
|
|
277
|
-
if (!el) return
|
|
278
|
-
_viewportH = el.clientHeight
|
|
279
|
-
if (virtualized && !el._dsScrollListener) {
|
|
280
|
-
el._dsScrollListener = true
|
|
281
|
-
el.addEventListener('scroll', () => { _scrollTop = el.scrollTop; _viewportH = el.clientHeight; render() }, { passive: true })
|
|
282
|
-
}
|
|
283
|
-
if (el._dsRootDrop || !onReparent || typeof C.useDropTarget !== 'function') return
|
|
284
|
-
el._dsRootDrop = true
|
|
285
|
-
const d = C.useDropTarget(el, { accepts: ['scene-node'], onDrop: ({ data }) => {
|
|
286
|
-
if (!data?.id) return
|
|
287
|
-
const dragIds = _dedupeNonAncestors(data.ids && data.ids.length > 1 ? data.ids : [data.id]).filter(id => _parentOf.get(id))
|
|
288
|
-
if (!dragIds.length) return
|
|
289
|
-
dragIds.forEach(id => onReparent(id, null))
|
|
290
|
-
showToast(dragIds.length > 1 ? `Unparented ${dragIds.length} entities` : 'Unparented ' + dragIds[0])
|
|
291
|
-
} })
|
|
292
|
-
el._dsRootDropDestroy = d.destroy
|
|
293
|
-
}
|
|
312
|
+
ref: (el) => bindPanelBodyRef(el, virtualized)
|
|
294
313
|
},
|
|
295
314
|
virtualized
|
|
296
315
|
? h('div', null,
|
|
@@ -314,19 +333,16 @@ export function createSceneHierarchy(container, { onSelect, onFocus, onDelete, o
|
|
|
314
333
|
}
|
|
315
334
|
|
|
316
335
|
const _parentOf = new Map()
|
|
317
|
-
function indexParents(nodes, parentId) {
|
|
318
|
-
for (const n of nodes || []) { _parentOf.set(n.id, parentId); indexParents(n.children, n.id) }
|
|
319
|
-
}
|
|
320
336
|
|
|
321
337
|
container.tabIndex = 0
|
|
322
338
|
|
|
323
|
-
// Capture-phase delegated
|
|
324
|
-
//
|
|
325
|
-
//
|
|
339
|
+
// Capture-phase delegated multi-select handler, immune to vdom node replacement between
|
|
340
|
+
// mousedown and mouseup (applyDiff re-renders the entire tree, which can swap the DOM node
|
|
341
|
+
// under an in-progress pointer interaction -- see the row
|
|
326
342
|
// scene-hierarchy-click-races-vdom-rerender-lost-click). The capture phase fires on the
|
|
327
343
|
// STABLE container before the event reaches any individual row element, so the data-eid
|
|
328
344
|
// lookup always succeeds regardless of whether the row was re-rendered mid-click.
|
|
329
|
-
|
|
345
|
+
function onContainerMouseDown(e) {
|
|
330
346
|
if (!e.ctrlKey && !e.metaKey && !e.shiftKey) {
|
|
331
347
|
if (_multiSel.size) { _multiSel.clear(); render() }
|
|
332
348
|
return
|
|
@@ -336,9 +352,9 @@ export function createSceneHierarchy(container, { onSelect, onFocus, onDelete, o
|
|
|
336
352
|
const id = item.getAttribute('data-eid')
|
|
337
353
|
if (_multiSel.has(id)) _multiSel.delete(id); else _multiSel.add(id)
|
|
338
354
|
render()
|
|
339
|
-
}
|
|
355
|
+
}
|
|
340
356
|
|
|
341
|
-
|
|
357
|
+
function onContainerClick(e) {
|
|
342
358
|
const item = e.target.closest('.ds-ep-tree-item[data-eid]')
|
|
343
359
|
if (!item) return
|
|
344
360
|
const id = item.getAttribute('data-eid')
|
|
@@ -348,9 +364,9 @@ export function createSceneHierarchy(container, { onSelect, onFocus, onDelete, o
|
|
|
348
364
|
_sel = id
|
|
349
365
|
onSelect?.(id)
|
|
350
366
|
render()
|
|
351
|
-
}
|
|
367
|
+
}
|
|
352
368
|
|
|
353
|
-
|
|
369
|
+
function onContainerKeyDown(e) {
|
|
354
370
|
if (!_lastRows.length) return
|
|
355
371
|
const idx = _sel ? _lastRows.findIndex(r => r.node.id === _sel) : -1
|
|
356
372
|
if (e.key === 'ArrowDown') {
|
|
@@ -379,11 +395,15 @@ export function createSceneHierarchy(container, { onSelect, onFocus, onDelete, o
|
|
|
379
395
|
if (ok) { onDelete(id); showToast('Deleted ' + id) }
|
|
380
396
|
})
|
|
381
397
|
}
|
|
382
|
-
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
container.addEventListener('mousedown', onContainerMouseDown, true)
|
|
401
|
+
container.addEventListener('click', onContainerClick, true)
|
|
402
|
+
container.addEventListener('keydown', onContainerKeyDown)
|
|
383
403
|
|
|
384
404
|
render()
|
|
385
405
|
return {
|
|
386
|
-
updateEntities(ents) { _ents = ents || []; _parentOf.clear(); indexParents(_ents, null); render() },
|
|
406
|
+
updateEntities(ents) { _ents = ents || []; _parentOf.clear(); indexParents(_ents, null, _parentOf); render() },
|
|
387
407
|
setSelected(id) { _sel = id; render() },
|
|
388
408
|
get selectedId() { return _sel },
|
|
389
409
|
// Client-side-only lock/hidden-in-editor sets, read by editor.js (pick-gating) and app.js (editor-overlay
|
package/package.json
CHANGED
|
@@ -34,6 +34,55 @@
|
|
|
34
34
|
|
|
35
35
|
const _now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now())
|
|
36
36
|
|
|
37
|
+
// Rough per-shape resident-body byte estimate: dominated by the args payload (convex hull point
|
|
38
|
+
// arrays are the only variable-size shape here; box/capsule args are a handful of floats) plus a
|
|
39
|
+
// fixed per-body/broadphase-node overhead so even tiny shapes count for something under the budget.
|
|
40
|
+
const _BODY_OVERHEAD_BYTES = 256
|
|
41
|
+
function estimateBodyBytes(a) {
|
|
42
|
+
if (!a) return _BODY_OVERHEAD_BYTES
|
|
43
|
+
const args = a.args
|
|
44
|
+
let n = 0
|
|
45
|
+
if (args && typeof args.byteLength === 'number') n = args.byteLength
|
|
46
|
+
else if (Array.isArray(args)) n = args.length * 4
|
|
47
|
+
return _BODY_OVERHEAD_BYTES + n
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Chunk placements memoized per (cx,cz) for world lifetime: classify is expensive and the ring is walked twice per rebuild.
|
|
51
|
+
const chunkKey = (cx, cz) => ((cx & 0x3fffff) * 0x400000) + (cz & 0x3fffff)
|
|
52
|
+
|
|
53
|
+
// Greedy same-pass clustering: merges centers within mergeRadius of an already-picked center so their
|
|
54
|
+
// rings aren't walked twice, then caps the result to maxCenters (nearest-to-existing-pick-order, i.e.
|
|
55
|
+
// whichever centers getCenters() returns first win a slot -- callers should return closer/more-recent
|
|
56
|
+
// players first if they want to bias which players keep their ring under a maxCenters squeeze).
|
|
57
|
+
function clusterCenters(centers, mergeRadius, maxCenters) {
|
|
58
|
+
const picked = []
|
|
59
|
+
for (const c of centers) {
|
|
60
|
+
let merged = false
|
|
61
|
+
for (const p of picked) { if (Math.hypot(c[0] - p[0], c[1] - p[1]) <= mergeRadius) { merged = true; break } }
|
|
62
|
+
if (!merged) picked.push(c)
|
|
63
|
+
if (picked.length >= maxCenters) break
|
|
64
|
+
}
|
|
65
|
+
return picked
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// A rebuild is due if ANY center moved more than the hysteresis threshold from its previous ring
|
|
69
|
+
// position, OR the player POPULATION changed (a new player joined with no ring yet, or one left) --
|
|
70
|
+
// matching centers positionally (not by count alone) so a same-size swap (player A left, player B
|
|
71
|
+
// joined at a different spot in the same tick) still triggers, since neither raw count nor "closest
|
|
72
|
+
// pair distance" alone would catch that combination reliably.
|
|
73
|
+
function ringMoved(centers, curCenters, moveThreshold) {
|
|
74
|
+
if (curCenters.length !== centers.length) return true
|
|
75
|
+
for (let i = 0; i < centers.length; i++) {
|
|
76
|
+
let nearest = Infinity
|
|
77
|
+
for (let j = 0; j < curCenters.length; j++) {
|
|
78
|
+
const d = Math.hypot(centers[i][0] - curCenters[j][0], centers[i][1] - curCenters[j][1])
|
|
79
|
+
if (d < nearest) nearest = d
|
|
80
|
+
}
|
|
81
|
+
if (nearest > moveThreshold) return true
|
|
82
|
+
}
|
|
83
|
+
return false
|
|
84
|
+
}
|
|
85
|
+
|
|
37
86
|
export function createColliderStreamer(spec = {}) {
|
|
38
87
|
const physics = spec.physics
|
|
39
88
|
// getCenters (preferred, multi-player) wins over getCenter (legacy single-center shim) when both are given.
|
|
@@ -94,23 +143,9 @@ export function createColliderStreamer(spec = {}) {
|
|
|
94
143
|
const b = _lru.get(placementId)
|
|
95
144
|
if (b !== undefined) { _residentBytes -= b; _lru.delete(placementId) }
|
|
96
145
|
}
|
|
97
|
-
// Rough per-shape resident-body byte estimate: dominated by the args payload (convex hull point
|
|
98
|
-
// arrays are the only variable-size shape here; box/capsule args are a handful of floats) plus a
|
|
99
|
-
// fixed per-body/broadphase-node overhead so even tiny shapes count for something under the budget.
|
|
100
|
-
const _BODY_OVERHEAD_BYTES = 256
|
|
101
|
-
function _estimateBytes(a) {
|
|
102
|
-
if (!a) return _BODY_OVERHEAD_BYTES
|
|
103
|
-
const args = a.args
|
|
104
|
-
let n = 0
|
|
105
|
-
if (args && typeof args.byteLength === 'number') n = args.byteLength
|
|
106
|
-
else if (Array.isArray(args)) n = args.length * 4
|
|
107
|
-
return _BODY_OVERHEAD_BYTES + n
|
|
108
|
-
}
|
|
109
146
|
let curCenter = null, rebuilding = false, disposed = false, _timer = null, rebuildCount = 0
|
|
110
147
|
let curCenters = [] // last-used ring centers, for diagnostics/tests
|
|
111
148
|
|
|
112
|
-
// Chunk placements memoized per (cx,cz) for world lifetime: classify is expensive and the ring is walked twice per rebuild.
|
|
113
|
-
const _chunkKey = (cx, cz) => ((cx & 0x3fffff) * 0x400000) + (cz & 0x3fffff)
|
|
114
149
|
const _CHUNK_CACHE_CAP = 4096
|
|
115
150
|
const _chunkCache = new Map()
|
|
116
151
|
function _chunkCacheGet(k) {
|
|
@@ -130,7 +165,7 @@ export function createColliderStreamer(spec = {}) {
|
|
|
130
165
|
// unbudgeted=true: initial pre-gameplay start() build, computes the whole ring in one pass.
|
|
131
166
|
function _beginBudget(unbudgeted) { _budgetDeadline = _now() + COMPUTE_BUDGET_MS; _newThisPass = 0; _deferred = false; _budgetOff = !!unbudgeted }
|
|
132
167
|
function chunkPlacements(cx, cz) {
|
|
133
|
-
const k =
|
|
168
|
+
const k = chunkKey(cx, cz)
|
|
134
169
|
let v = _chunkCacheGet(k)
|
|
135
170
|
if (v) return v
|
|
136
171
|
if (!_budgetOff && (_newThisPass >= MAX_NEW_CHUNKS_PER_PASS || _now() >= _budgetDeadline)) { _deferred = true; return _EMPTY }
|
|
@@ -140,21 +175,6 @@ export function createColliderStreamer(spec = {}) {
|
|
|
140
175
|
|
|
141
176
|
const radiusSq = radius * radius, keepRadiusSq = keepRadius * keepRadius
|
|
142
177
|
|
|
143
|
-
// Greedy same-pass clustering: merges centers within mergeRadius of an already-picked center so their
|
|
144
|
-
// rings aren't walked twice, then caps the result to maxCenters (nearest-to-existing-pick-order, i.e.
|
|
145
|
-
// whichever centers getCenters() returns first win a slot -- callers should return closer/more-recent
|
|
146
|
-
// players first if they want to bias which players keep their ring under a maxCenters squeeze).
|
|
147
|
-
function _clusterCenters(centers) {
|
|
148
|
-
const picked = []
|
|
149
|
-
for (const c of centers) {
|
|
150
|
-
let merged = false
|
|
151
|
-
for (const p of picked) { if (Math.hypot(c[0] - p[0], c[1] - p[1]) <= mergeRadius) { merged = true; break } }
|
|
152
|
-
if (!merged) picked.push(c)
|
|
153
|
-
if (picked.length >= maxCenters) break
|
|
154
|
-
}
|
|
155
|
-
return picked
|
|
156
|
-
}
|
|
157
|
-
|
|
158
178
|
// Ring walk around ONE center; classifies each candidate into desired/keep from one distance calc.
|
|
159
179
|
// Returns raw per-center results (not yet capped) -- capping happens after the union across all
|
|
160
180
|
// centers in classifyRings below, so a candidate close to center B doesn't lose its cap slot just
|
|
@@ -202,7 +222,7 @@ export function createColliderStreamer(spec = {}) {
|
|
|
202
222
|
function scheduleAdd(p) {
|
|
203
223
|
const a = bodyArgs(p); if (!a) return
|
|
204
224
|
const placementId = p[idField]
|
|
205
|
-
_touch(placementId,
|
|
225
|
+
_touch(placementId, estimateBodyBytes(a))
|
|
206
226
|
if (_useQueue) {
|
|
207
227
|
live.set(placementId, _PENDING)
|
|
208
228
|
physics.enqueueAdd(a.shape, a.args, a.position, 'static', { rotation: a.rotation, shapeKey: a.shapeKey }, (id) => {
|
|
@@ -297,7 +317,7 @@ export function createColliderStreamer(spec = {}) {
|
|
|
297
317
|
addDeadline = _now() + ADD_BUDGET_MS
|
|
298
318
|
}
|
|
299
319
|
} else {
|
|
300
|
-
_touch(p[idField], _lru.get(p[idField]) ??
|
|
320
|
+
_touch(p[idField], _lru.get(p[idField]) ?? estimateBodyBytes(bodyArgs(p))) // re-affirm LRU recency for a still-desired survivor
|
|
301
321
|
}
|
|
302
322
|
}
|
|
303
323
|
// skip remove pass if budget-deferred: an incomplete keep set would wrongly drop in-range bodies; curCenters stays stale so _check re-converges.
|
|
@@ -328,30 +348,13 @@ export function createColliderStreamer(spec = {}) {
|
|
|
328
348
|
if (disposed) return
|
|
329
349
|
_timer = setTimeout(_check, deferredRetry ? 16 : intervalMs)
|
|
330
350
|
}
|
|
331
|
-
// A rebuild is due if ANY center moved more than the hysteresis threshold from its previous ring
|
|
332
|
-
// position, OR the player POPULATION changed (a new player joined with no ring yet, or one left) --
|
|
333
|
-
// matching centers positionally (not by count alone) so a same-size swap (player A left, player B
|
|
334
|
-
// joined at a different spot in the same tick) still triggers, since neither raw count nor "closest
|
|
335
|
-
// pair distance" alone would catch that combination reliably.
|
|
336
|
-
function _ringMoved(centers) {
|
|
337
|
-
if (curCenters.length !== centers.length) return true
|
|
338
|
-
for (let i = 0; i < centers.length; i++) {
|
|
339
|
-
let nearest = Infinity
|
|
340
|
-
for (let j = 0; j < curCenters.length; j++) {
|
|
341
|
-
const d = Math.hypot(centers[i][0] - curCenters[j][0], centers[i][1] - curCenters[j][1])
|
|
342
|
-
if (d < nearest) nearest = d
|
|
343
|
-
}
|
|
344
|
-
if (nearest > radius * rebuildAt) return true
|
|
345
|
-
}
|
|
346
|
-
return false
|
|
347
|
-
}
|
|
348
351
|
function _check() {
|
|
349
352
|
if (disposed) return
|
|
350
353
|
try {
|
|
351
354
|
const raw = getCenters()
|
|
352
355
|
if (raw.length && !rebuilding) {
|
|
353
|
-
const centers =
|
|
354
|
-
if (!curCenters.length ||
|
|
356
|
+
const centers = clusterCenters(raw, mergeRadius, maxCenters)
|
|
357
|
+
if (!curCenters.length || ringMoved(centers, curCenters, radius * rebuildAt)) {
|
|
355
358
|
_rebuildMulti(centers).then(d => _scheduleNext(!!d)).catch(() => _scheduleNext(false))
|
|
356
359
|
return
|
|
357
360
|
}
|
|
@@ -364,7 +367,7 @@ export function createColliderStreamer(spec = {}) {
|
|
|
364
367
|
if (disposed) return
|
|
365
368
|
if (typeof spec.prewarm === 'function' && typeof physics.preallocatePool === 'function') spec.prewarm(physics, cap)
|
|
366
369
|
const raw = getCenters()
|
|
367
|
-
const centers = raw.length ?
|
|
370
|
+
const centers = raw.length ? clusterCenters(raw, mergeRadius, maxCenters) : [[0, 0]]
|
|
368
371
|
await _rebuildMulti(centers, true)
|
|
369
372
|
setColliderIds(_liveIds)
|
|
370
373
|
_timer = setTimeout(_check, intervalMs)
|