diff --git a/src/apps/AppEval.js b/src/apps/AppEval.js new file mode 100644 index 00000000..8362511f --- /dev/null +++ b/src/apps/AppEval.js @@ -0,0 +1,27 @@ +const BLOCKED_PATTERNS = ['process.exit', 'child_process', 'require(', '__proto__', 'Object.prototype', 'globalThis', 'eval(', 'import('] + +let _nm = null +export async function getNodeModules() { + if (_nm) return _nm + const [fsp, fsSync, path, url] = await Promise.all([import('node:fs/promises'), import('node:fs'), import('node:path'), import('node:url')]) + _nm = { readdir: fsp.readdir, readFile: fsp.readFile, watch: fsp.watch, access: fsp.access, existsSync: fsSync.existsSync, join: path.join, basename: path.basename, extname: path.extname, resolve: path.resolve, pathToFileURL: url.pathToFileURL } + return _nm +} + +export function validateAppSource(source, name) { + for (const pattern of BLOCKED_PATTERNS) { + if (source.includes(pattern)) { console.error(`[AppLoader] blocked pattern "${pattern}" in ${name}`); return false } + } + return true +} + +export async function evaluateAppModule(filePath) { + const nm = await getNodeModules() + try { + const absPath = nm.resolve(filePath), url = nm.pathToFileURL(absPath).href + `?t=${Date.now()}` + const mod = await import(url); return mod.default || mod + } catch (e) { + console.error(`[AppLoader] syntax/eval error in "${filePath}": ${e.message}\n ${e.stack?.split('\n').slice(1, 3).join('\n ') || ''}`) + return null + } +} diff --git a/src/apps/AppLoader.js b/src/apps/AppLoader.js index 79642580..5aec1976 100644 --- a/src/apps/AppLoader.js +++ b/src/apps/AppLoader.js @@ -1,205 +1,69 @@ -const BLOCKED_PATTERNS = [ - 'process.exit', 'child_process', 'require(', '__proto__', - 'Object.prototype', 'globalThis', 'eval(', 'import(' -] - -let _nm = null -async function _nodeModules() { - if (_nm) return _nm - const [fsp, fsSync, path, url] = await Promise.all([ - import('node:fs/promises'), - import('node:fs'), - import('node:path'), - import('node:url') - ]) - _nm = { - readdir: fsp.readdir, readFile: fsp.readFile, watch: fsp.watch, access: fsp.access, - existsSync: fsSync.existsSync, join: path.join, basename: path.basename, - extname: path.extname, resolve: path.resolve, pathToFileURL: url.pathToFileURL - } - return _nm -} +import { validateAppSource, evaluateAppModule, getNodeModules } from './AppEval.js' export class AppLoader { constructor(runtime, config = {}) { - this._runtime = runtime - this._dirs = config.dirs || [config.dir || './apps'] - this._watchers = new Map() - this._loaded = new Map() - this._onReloadCallback = null + this._runtime = runtime; this._dirs = config.dirs || [config.dir || './apps'] + this._watchers = new Map(); this._loaded = new Map(); this._onReloadCallback = null } async _resolvePath(name) { - const { join, access } = await _nodeModules() + const { join, access } = await getNodeModules() for (const dir of this._dirs) { - const flat = join(dir, `${name}.js`) - try { await access(flat); return flat } catch {} - const folder = join(dir, name, 'index.js') - try { await access(folder); return folder } catch {} + const flat = join(dir, `${name}.js`); try { await access(flat); return flat } catch {} + const folder = join(dir, name, 'index.js'); try { await access(folder); return folder } catch {} } return null } async loadAll() { - const { readdir, access, join, basename, extname } = await _nodeModules() - const seen = new Set() - const results = [] + const { readdir, access, join, basename, extname } = await getNodeModules() + const seen = new Set(), results = [] for (const dir of this._dirs) { const entries = await readdir(dir, { withFileTypes: true }).catch(() => []) for (const entry of entries) { let name = null - if (entry.isFile() && entry.name.endsWith('.js')) { - name = basename(entry.name, extname(entry.name)) - } else if (entry.isDirectory()) { - try { await access(join(dir, entry.name, 'index.js')); name = entry.name } catch {} - } - if (name && !seen.has(name)) { - seen.add(name) - const loaded = await this.loadApp(name) - if (loaded) results.push(name) - } + if (entry.isFile() && entry.name.endsWith('.js')) name = basename(entry.name, extname(entry.name)) + else if (entry.isDirectory()) try { await access(join(dir, entry.name, 'index.js')); name = entry.name } catch {} + if (name && !seen.has(name)) { seen.add(name); if (await this.loadApp(name)) results.push(name) } } } return results } async loadApp(name) { - const filePath = await this._resolvePath(name) - if (!filePath) return null - const { readFile } = await _nodeModules() + const filePath = await this._resolvePath(name); if (!filePath) return null + const { readFile } = await getNodeModules() try { const source = await readFile(filePath, 'utf-8') - if (!this._validate(source, name)) return null - const appDef = await this._evaluate(source, filePath) - if (!appDef) return null - this._runtime.registerApp(name, appDef) - this._loaded.set(name, { filePath, source, clientCode: source }) - return appDef - } catch (e) { - console.error(`[AppLoader] failed to load "${name}": ${e.message}\n file: ${filePath}\n stack: ${e.stack?.split('\n').slice(1, 3).join('\n ') || 'none'}`) - return null - } - } - - _validate(source, name) { - for (const pattern of BLOCKED_PATTERNS) { - if (source.includes(pattern)) { - console.error(`[AppLoader] blocked pattern "${pattern}" in ${name}`) - return false - } - } - return true - } - - async _evaluate(source, filePath) { - const { resolve, pathToFileURL } = await _nodeModules() - try { - const absPath = resolve(filePath) - const url = pathToFileURL(absPath).href + `?t=${Date.now()}` - const mod = await import(url) - return mod.default || mod - } catch (e) { - console.error(`[AppLoader] syntax/eval error in "${filePath}": ${e.message}\n ${e.stack?.split('\n').slice(1, 3).join('\n ') || ''}`) - return null - } + if (!validateAppSource(source, name)) return null + const appDef = await evaluateAppModule(filePath); if (!appDef) return null + this._runtime.registerApp(name, appDef); this._loaded.set(name, { filePath, source, clientCode: source }); return appDef + } catch (e) { console.error(`[AppLoader] failed to load "${name}": ${e.message}`); return null } } async watchAll() { - const { existsSync, watch, join, basename, extname } = await _nodeModules() + const { existsSync, watch, basename, extname } = await getNodeModules() for (const dir of this._dirs) { - if (!existsSync(dir)) { - console.debug(`[AppLoader] skipping watch for missing directory: ${dir}`) - continue - } + if (!existsSync(dir)) continue try { - const ac = new AbortController() - const watcher = watch(dir, { recursive: true, signal: ac.signal }) - this._watchers.set(dir, ac) + const ac = new AbortController(), watcher = watch(dir, { recursive: true, signal: ac.signal }); this._watchers.set(dir, ac) ;(async () => { try { for await (const event of watcher) { if (!event.filename || !event.filename.endsWith('.js')) continue - const parts = event.filename.replace(/\\/g, '/').split('/') - const name = parts.length > 1 - ? parts[0] - : basename(event.filename, extname(event.filename)) - await this._onFileChange(name) - } - } catch (e) { - if (e.name !== 'AbortError') { - console.error(`[AppLoader] watch error:`, e.message) + const parts = event.filename.replace(/\\/g, '/').split('/'), name = parts.length > 1 ? parts[0] : basename(event.filename, extname(event.filename)) + console.log(`[AppLoader] reloading ${name}`) + const appDef = await this.loadApp(name) + if (appDef) this._runtime.queueReload(name, appDef, this._onReloadCallback ? (n, d) => this._onReloadCallback(n, this._loaded.get(n)?.clientCode) : null) } - } + } catch (e) { if (e.name !== 'AbortError') console.error(`[AppLoader] watch error:`, e.message) } })() - } catch (e) { - console.error(`[AppLoader] watchAll error:`, e.message) - } + } catch (e) { console.error(`[AppLoader] watchAll error:`, e.message) } } } - async _onFileChange(name) { - console.log(`[AppLoader] reloading ${name}`) - const appDef = await this.loadApp(name) - if (appDef) { - const cb = this._onReloadCallback ? (n, d) => { - this._onReloadCallback(n, this._loaded.get(n)?.clientCode) - } : null - this._runtime.queueReload(name, appDef, cb) - console.log(`[AppLoader] queued hot reload ${name}`) - } - } - - stopWatching() { - for (const ac of this._watchers.values()) ac.abort() - this._watchers.clear() - } - + stopWatching() { for (const ac of this._watchers.values()) ac.abort(); this._watchers.clear() } getLoaded() { return Array.from(this._loaded.keys()) } - - getClientModules() { - const modules = {} - for (const [name, data] of this._loaded) { - if (data.clientCode) modules[name] = data.clientCode - } - return modules - } - + getClientModules() { const modules = {}; for (const [name, data] of this._loaded) if (data.clientCode) modules[name] = data.clientCode; return modules } getClientModule(name) { return this._loaded.get(name)?.clientCode || null } - - async loadFromString(name, source, deps = null) { - if (!this._validate(source, name)) return null - const revokes = [] - try { - const rewrittenSource = deps ? this._rewriteDeps(source, deps, revokes) : source - const blob = new Blob([rewrittenSource], { type: 'application/javascript' }) - const url = URL.createObjectURL(blob) - revokes.push(url) - const mod = await import(url) - const appDef = mod.default || mod - this._runtime.registerApp(name, appDef) - this._loaded.set(name, { source, clientCode: source, filePath: null }) - return appDef - } catch (e) { - console.error(`[AppLoader] string eval error:`, e.message) - return null - } finally { - for (const u of revokes) URL.revokeObjectURL(u) - } - } - - _rewriteDeps(source, deps, revokes) { - const urlMap = {} - for (const [spec, entry] of Object.entries(deps)) { - if (!entry) continue - const sub = typeof entry === 'string' ? { source: entry, deps: {} } : entry - const subSource = this._rewriteDeps(sub.source, sub.deps || {}, revokes) - const blob = new Blob([subSource], { type: 'application/javascript' }) - const url = URL.createObjectURL(blob) - revokes.push(url) - urlMap[spec] = url - } - return source.replace(/((?:from|import)\s*)(['"])(\.[^'"]+)\2/g, (m, pre, q, spec) => - urlMap[spec] ? `${pre}${q}${urlMap[spec]}${q}` : m - ) - } } diff --git a/src/apps/AppRuntime.js b/src/apps/AppRuntime.js index 9dc66263..1a4a5557 100644 --- a/src/apps/AppRuntime.js +++ b/src/apps/AppRuntime.js @@ -25,6 +25,7 @@ export class AppRuntime { this._lastSyncMs = 0; this._lastRespawnMs = 0; this._lastSpatialMs = 0; this._lastCollisionMs = 0; this._lastInteractMs = 0 mixinPhysics(this); mixinTick(this); if (this._physics) this._registerPhysicsCallbacks() this._hotReload = new HotReloadQueue(this); this._eventBus = c.eventBus || new EventBus() + if (typeof globalThis.__SPOINT_DEBUG__ !== 'undefined') globalThis.__SPOINT_DEBUG__.runtime = this this._eventLog = c.eventLog||null; this._storage = c.storage||null; this._sdkRoot = c.sdkRoot||null this._eventBus.on('*', ev => { if (!ev.channel.startsWith('system.')) this._log('bus_event', { channel:ev.channel, data:ev.data }, ev.meta) }) this._eventBus.on('system.handover', ev => { const {targetEntityId,stateData}=ev.data||{}; if (targetEntityId) this.fireEvent(targetEntityId,'onHandover',ev.meta.sourceEntity,stateData) }) diff --git a/src/netcode/SnapshotDelta.js b/src/netcode/SnapshotDelta.js new file mode 100644 index 00000000..d65d77c9 --- /dev/null +++ b/src/netcode/SnapshotDelta.js @@ -0,0 +1,59 @@ +const Q1 = 100 +const VEL_ZERO = [0, 0, 0] +const SCALE_ONE = [1, 1, 1] +const QSCALE = 511 * Math.SQRT2 + +export function packQuat(rx, ry, rz, rw) { + const q = [rx, ry, rz, rw] + let maxIdx = 0 + for (let i = 1; i < 4; i++) if (Math.abs(q[i]) > Math.abs(q[maxIdx])) maxIdx = i + const sign = q[maxIdx] < 0 ? -1 : 1 + const indices = [0, 1, 2, 3].filter(i => i !== maxIdx) + let packed = maxIdx + for (const i of indices) { + const n = Math.max(0, Math.min(1022, Math.round((q[i] * sign + Math.SQRT1_2) * QSCALE))) + packed = (packed << 10) | n + } + return packed >>> 0 +} + +export function unpackQuat(packed, out) { + const maxIdx = (packed >>> 30) & 0x3 + const indices = [0, 1, 2, 3].filter(i => i !== maxIdx) + let sumSq = 0 + for (let j = 2; j >= 0; j--) { + const v = (packed & 0x3FF) / QSCALE - Math.SQRT1_2; packed = (packed >>> 10) + out[indices[j]] = v; sumSq += v * v + } + out[maxIdx] = Math.sqrt(Math.max(0, 1 - sumSq)); return out +} + +export function encodePlayer(p) { + const [px, py, pz] = p.position, [rx, ry, rz, rw] = p.rotation, [vx, vy, vz] = p.velocity + const pitchN = Math.round(((p.lookPitch || 0) + Math.PI) / (2 * Math.PI) * 15) & 0xF + const yawN = Math.round(((p.lookYaw || 0) % (2 * Math.PI) + 2 * Math.PI) % (2 * Math.PI) / (2 * Math.PI) * 15) & 0xF + return [p.id, Math.round(px * Q1) / Q1, Math.round(py * Q1) / Q1, Math.round(pz * Q1) / Q1, packQuat(rx, ry, rz, rw), Math.round(vx * Q1) / Q1, Math.round(vy * Q1) / Q1, Math.round(vz * Q1) / Q1, p.onGround ? 1 : 0, Math.round(p.health || 0), p.inputSequence || 0, p.crouch || 0, (pitchN << 4) | yawN] +} + +export function fillEntityEnc(e, enc) { + const pos = e.position, rot = e.rotation, v = e.velocity || VEL_ZERO, s = e.scale || SCALE_ONE + const px = pos[0], py = pos[1], pz = pos[2], rx = rot[0], ry = rot[1], rz = rot[2], rw = rot[3] + enc[0] = e.id; enc[1] = e.model || '' + enc[2] = Math.round(px * Q1) / Q1; enc[3] = Math.round(py * Q1) / Q1; enc[4] = Math.round(pz * Q1) / Q1 + enc[5] = packQuat(rx, ry, rz, rw) + enc[6] = Math.round((v[0] || 0) * Q1) / Q1; enc[7] = Math.round((v[1] || 0) * Q1) / Q1; enc[8] = Math.round((v[2] || 0) * Q1) / Q1 + enc[9] = e.bodyType || 'static'; enc[10] = e.custom || null + enc[11] = Math.round((s[0] || 1) * Q1) / Q1; enc[12] = Math.round((s[1] || 1) * Q1) / Q1; enc[13] = Math.round((s[2] || 1) * Q1) / Q1 + enc[14] = e._dynSleeping ? 1 : 0; return enc +} + +export function encodeEntity(e) { return fillEntityEnc(e, new Array(15)) } +export function buildEntityKey(enc, custStr) { return enc[1] + '|' + enc[2] + '|' + enc[3] + '|' + enc[4] + '|' + enc[5] + '|' + enc[6] + '|' + enc[7] + '|' + enc[8] + '|' + enc[9] + '|' + custStr + '|' + enc[11] + '|' + enc[12] + '|' + enc[13] + '|' + enc[14] } +export function custToStr(cust) { return cust != null ? JSON.stringify(cust) : '' } + +export function resolveKey(entry) { + if (!entry._dirty) return entry.k + const cust = entry.enc[10] + entry.custStr = entry.cust === cust ? entry.custStr : custToStr(cust) + entry.cust = cust; entry.k = buildEntityKey(entry.enc, entry.custStr); entry._dirty = false; return entry.k +} diff --git a/src/netcode/SnapshotEncoder.js b/src/netcode/SnapshotEncoder.js index 185b3958..bce579ab 100644 --- a/src/netcode/SnapshotEncoder.js +++ b/src/netcode/SnapshotEncoder.js @@ -1,72 +1,6 @@ -const Q1=100 -const VEL_ZERO = [0,0,0] -const SCALE_ONE = [1,1,1] -const QSCALE = 511 * Math.SQRT2 +import { encodePlayer, encodeEntity, buildEntityKey, custToStr, resolveKey, fillEntityEnc, unpackQuat } from './SnapshotDelta.js' -function packQuat(rx, ry, rz, rw) { - const q = [rx, ry, rz, rw] - let maxIdx = 0 - for (let i = 1; i < 4; i++) if (Math.abs(q[i]) > Math.abs(q[maxIdx])) maxIdx = i - const sign = q[maxIdx] < 0 ? -1 : 1 - const indices = [0,1,2,3].filter(i => i !== maxIdx) - let packed = maxIdx - for (const i of indices) { - const n = Math.max(0, Math.min(1022, Math.round((q[i] * sign + Math.SQRT1_2) * QSCALE))) - packed = (packed << 10) | n - } - return packed >>> 0 -} - -function unpackQuat(packed, out) { - const maxIdx = (packed >>> 30) & 0x3 - const indices = [0,1,2,3].filter(i => i !== maxIdx) - let sumSq = 0 - for (let j = 2; j >= 0; j--) { - const v = (packed & 0x3FF) / QSCALE - Math.SQRT1_2; packed = (packed >>> 10) - out[indices[j]] = v; sumSq += v * v - } - out[maxIdx] = Math.sqrt(Math.max(0, 1 - sumSq)) - return out -} - -function encodePlayer(p) { - const [px,py,pz]=p.position, [rx,ry,rz,rw]=p.rotation, [vx,vy,vz]=p.velocity - const pitchN=Math.round(((p.lookPitch||0)+Math.PI)/(2*Math.PI)*15)&0xF, yawN=Math.round(((p.lookYaw||0)%(2*Math.PI)+2*Math.PI)%(2*Math.PI)/(2*Math.PI)*15)&0xF - return [p.id, Math.round(px*Q1)/Q1,Math.round(py*Q1)/Q1,Math.round(pz*Q1)/Q1, packQuat(rx,ry,rz,rw), Math.round(vx*Q1)/Q1,Math.round(vy*Q1)/Q1,Math.round(vz*Q1)/Q1, p.onGround?1:0, Math.round(p.health||0), p.inputSequence||0, p.crouch||0, (pitchN<<4)|yawN] -} - -function fillEntityEnc(e, enc) { - const pos=e.position, rot=e.rotation, v=e.velocity||VEL_ZERO, s=e.scale||SCALE_ONE - const px=pos[0],py=pos[1],pz=pos[2],rx=rot[0],ry=rot[1],rz=rot[2],rw=rot[3] - enc[0]=e.id; enc[1]=e.model||'' - enc[2]=Math.round(px*Q1)/Q1; enc[3]=Math.round(py*Q1)/Q1; enc[4]=Math.round(pz*Q1)/Q1 - enc[5]=packQuat(rx,ry,rz,rw) - enc[6]=Math.round((v[0]||0)*Q1)/Q1; enc[7]=Math.round((v[1]||0)*Q1)/Q1; enc[8]=Math.round((v[2]||0)*Q1)/Q1 - enc[9]=e.bodyType||'static'; enc[10]=e.custom||null - enc[11]=Math.round((s[0]||1)*Q1)/Q1; enc[12]=Math.round((s[1]||1)*Q1)/Q1; enc[13]=Math.round((s[2]||1)*Q1)/Q1 - enc[14]=e._dynSleeping?1:0 - return enc -} - -function encodeEntity(e) { - return fillEntityEnc(e, new Array(15)) -} - -function buildEntityKey(enc, custStr) { - return enc[1]+'|'+enc[2]+'|'+enc[3]+'|'+enc[4]+'|'+enc[5]+'|'+enc[6]+'|'+enc[7]+'|'+enc[8]+'|'+enc[9]+'|'+custStr+'|'+enc[11]+'|'+enc[12]+'|'+enc[13]+'|'+enc[14] -} - -function custToStr(cust) { return cust != null ? JSON.stringify(cust) : '' } - -function resolveKey(entry) { - if (!entry._dirty) return entry.k - const cust = entry.enc[10] - entry.custStr = entry.cust === cust ? entry.custStr : custToStr(cust) - entry.cust = cust - entry.k = buildEntityKey(entry.enc, entry.custStr) - entry._dirty = false - return entry.k -} +const CLOSE2 = 20 * 20 function buildEntry(e, id, prevCache, sleeping) { const enc = encodeEntity(e), cust = enc[10] @@ -75,13 +9,11 @@ function buildEntry(e, id, prevCache, sleeping) { return { enc, k: buildEntityKey(enc, custStr), cust, custStr, isEnv: e._appName === 'environment', sleeping: !!sleeping, _dirty: false } } -const CLOSE2 = 20 * 20 - function applyEntry(id, entry, nextMap, entities, prevEntityMap, useDistTier, vx, vy, vz) { const k = resolveKey(entry) if (useDistTier && !entry.isEnv) { - const e = entry.enc, dx = e[2]-vx, dy = e[3]-vy, dz = e[4]-vz - if (dx*dx+dy*dy+dz*dz >= CLOSE2) { nextMap.set(id, prevEntityMap.get(id) || [k, entry.cust, entry.custStr]); return } + const e = entry.enc, dx = e[2] - vx, dy = e[3] - vy, dz = e[4] - vz + if (dx * dx + dy * dy + dz * dz >= CLOSE2) { nextMap.set(id, prevEntityMap.get(id) || [k, entry.cust, entry.custStr]); return } } nextMap.set(id, [k, entry.cust, entry.custStr]) const prev = prevEntityMap.get(id); if (!prev || prev[0] !== k) entities.push(entry.enc) @@ -89,68 +21,44 @@ function applyEntry(id, entry, nextMap, entities, prevEntityMap, useDistTier, vx export class SnapshotEncoder { static encodePlayersOnce(players) { - const m = new Map() - for (const p of (players || [])) m.set(p.id, encodePlayer(p)) + const m = new Map(); for (const p of (players || [])) m.set(p.id, encodePlayer(p)) return m } - - static filterEncodedPlayers(encodedMap, nearbyIds) { - const out = []; for (const id of nearbyIds) { const enc = encodedMap.get(id); if (enc) out.push(enc) }; return out - } - static filterEncodedPlayersWithSelf(encodedMap, nearbyIds, selfId) { const out = []; let hasSelf = false for (let i = 0; i < nearbyIds.length; i++) { const id = nearbyIds[i]; if (id === selfId) hasSelf = true; const enc = encodedMap.get(id); if (enc) out.push(enc) } if (!hasSelf) { const self = encodedMap.get(selfId); if (self) out.push(self) } return out } - - static encodePlayers(players) { return (players || []).map(encodePlayer) } - static encodeStaticEntities(entities, prevStaticMap) { - const nextMap = new Map() - const allEntries = [] - const changedEntries = [] - let changed = false + const nextMap = new Map(), allEntries = [], changedEntries = []; let changed = false for (const e of entities) { if (e.bodyType !== 'static') continue - const enc = encodeEntity(e) - const prev = prevStaticMap.get(e.id) - const cust = enc[10] + const enc = encodeEntity(e), prev = prevStaticMap.get(e.id), cust = enc[10] const custStr = (prev && prev[1] === cust) ? prev[2] : custToStr(cust) - const k = buildEntityKey(enc, custStr) - nextMap.set(e.id, [k, cust, custStr]) - allEntries.push({ enc, k, id: e.id }) + const k = buildEntityKey(enc, custStr); nextMap.set(e.id, [k, cust, custStr]); allEntries.push({ enc, k, id: e.id }) if (!prev || prev[0] !== k) { changedEntries.push({ enc, k, id: e.id }); changed = true } } if (nextMap.size !== prevStaticMap.size) changed = true return { staticEntries: allEntries, changedEntries, staticMap: nextMap, staticChanged: changed } } - static buildStaticIds(staticMap) { return new Set(staticMap.keys()) } - static refreshDynamicCache(cache, activeIds, entities) { const envIds = cache._envIds || []; envIds.length = 0 for (const id of activeIds) { const e = entities.get(id); if (!e || e.bodyType === 'static') continue let entry = cache.get(id) - if (entry) { - fillEntityEnc(e, entry.enc) - entry._dirty = true; entry.sleeping = false - } else { - entry = buildEntry(e, id, null, false); cache.set(id, entry) - } + if (entry) { fillEntityEnc(e, entry.enc); entry._dirty = true; entry.sleeping = false } + else { entry = buildEntry(e, id, null, false); cache.set(id, entry) } if (entry.isEnv) envIds.push(id) } cache._envIds = envIds; return cache } - static buildDynamicCache(activeIds, sleepingIds, suspendedIds, entities, prevCache) { const cache = new Map(), envIds = [] for (const id of activeIds) { const e = entities.get(id); if (!e || e.bodyType === 'static') continue - const entry = buildEntry(e, id, prevCache, false) - cache.set(id, entry); if (entry.isEnv) envIds.push(id) + const entry = buildEntry(e, id, prevCache, false); cache.set(id, entry); if (entry.isEnv) envIds.push(id) } for (const idSet of [sleepingIds, suspendedIds]) { for (const id of idSet) { @@ -161,7 +69,6 @@ export class SnapshotEncoder { } cache._envIds = envIds; return cache } - static encodeDeltaFromCache(tick, serverTime, dynCache, relevantIds, prevEntityMap, preEncodedPlayers, staticEntries, staticEntityMap, staticEntityIds, precomputedRemoved, seqNum, viewerPos) { const entities = [], nextMap = new Map() if (staticEntries) for (const { enc } of staticEntries) entities.push(enc) @@ -181,9 +88,8 @@ export class SnapshotEncoder { } let removed = precomputedRemoved if (!removed) { removed = []; for (const id of prevEntityMap.keys()) { if (!dynCache.has(id) && !(staticEntityIds && staticEntityIds.has(id))) removed.push(id) } } - return { encoded: { tick: tick||0, serverTime, players: preEncodedPlayers||[], entities, removed: removed.length ? removed : undefined, delta: 1 }, entityMap: nextMap } + return { encoded: { tick: tick || 0, serverTime, players: preEncodedPlayers || [], entities, removed: removed.length ? removed : undefined, delta: 1 }, entityMap: nextMap } } - static encodeDelta(snapshot, prevEntityMap, preEncodedPlayers, staticEntries, staticMap, staticIds) { const players = preEncodedPlayers || (snapshot.players || []).map(encodePlayer) const dynIds = new Set(), entities = [], nextMap = new Map() @@ -199,26 +105,11 @@ export class SnapshotEncoder { const removed = []; for (const id of prevEntityMap.keys()) { if (!dynIds.has(id) && !(staticIds && staticIds.has(id))) removed.push(id) } return { encoded: { tick: snapshot.tick || 0, serverTime: snapshot.serverTime, players, entities, removed: removed.length ? removed : undefined, delta: 1 }, entityMap: nextMap } } - - static encode(snapshot) { - const players = (snapshot.players || []).map(encodePlayer) - const entities = (snapshot.entities || []).map(encodeEntity) - return { tick: snapshot.tick || 0, serverTime: snapshot.serverTime, players, entities } - } - static decode(data) { if (!data.players || !Array.isArray(data.players)) return data const TAU = 2 * Math.PI - const players = data.players.map(p => { - if (!Array.isArray(p)) return p - const rot = unpackQuat(p[4], [0,0,0,0]) - return { id:p[0], position:[p[1],p[2],p[3]], rotation:rot, velocity:[p[5],p[6],p[7]], onGround:p[8]===1, health:p[9], inputSequence:p[10], crouch:p[11]||0, lookPitch:((p[12]||0)>>4)/15*TAU-Math.PI, lookYaw:((p[12]||0)&0xF)/15*TAU } - }) - const entities = (data.entities||[]).map(e => { - if (!Array.isArray(e)) return e - const rot = unpackQuat(e[5], [0,0,0,0]) - return { id:e[0], model:e[1], position:[e[2],e[3],e[4]], rotation:rot, velocity:[e[6],e[7],e[8]], bodyType:e[9], custom:e[10], scale:[e[11]??1,e[12]??1,e[13]??1], sleeping:e[14]===1 } - }) - return { tick:data.tick, serverTime:data.serverTime, players, entities, delta:data.delta, removed:data.removed } + const players = data.players.map(p => { if (!Array.isArray(p)) return p; const rot = unpackQuat(p[4], [0, 0, 0, 0]); return { id: p[0], position: [p[1], p[2], p[3]], rotation: rot, velocity: [p[5], p[6], p[7]], onGround: p[8] === 1, health: p[9], inputSequence: p[10], crouch: p[11] || 0, lookPitch: ((p[12] || 0) >> 4) / 15 * TAU - Math.PI, lookYaw: ((p[12] || 0) & 0xF) / 15 * TAU } }) + const entities = (data.entities || []).map(e => { if (!Array.isArray(e)) return e; const rot = unpackQuat(e[5], [0, 0, 0, 0]); return { id: e[0], model: e[1], position: [e[2], e[3], e[4]], rotation: rot, velocity: [e[6], e[7], e[8]], bodyType: e[9], custom: e[10], scale: [e[11] ?? 1, e[12] ?? 1, e[13] ?? 1], sleeping: e[14] === 1 } }) + return { tick: data.tick, serverTime: data.serverTime, players, entities, delta: data.delta, removed: data.removed } } } diff --git a/src/physics/BodyManager.js b/src/physics/BodyManager.js new file mode 100644 index 00000000..572e88e3 --- /dev/null +++ b/src/physics/BodyManager.js @@ -0,0 +1,108 @@ +import { buildConvexShape, buildTrimeshShape } from './ShapeBuilder.js' + +const LAYER_STATIC = 0, LAYER_DYNAMIC = 1 + +export function mixinBodyManager(PhysicsWorld) { + PhysicsWorld.prototype._addBody = function(shape, position, motionType, layer, opts = {}) { + const J = this.Jolt + const pos = new J.RVec3(position[0], position[1], position[2]) + const rot = opts.rotation ? new J.Quat(...opts.rotation) : new J.Quat(0, 0, 0, 1) + const cs = new J.BodyCreationSettings(shape, pos, rot, motionType, layer) + J.destroy(pos); J.destroy(rot) + if (opts.mass) { cs.mMassPropertiesOverride.mMass = opts.mass; cs.mOverrideMassProperties = J.EOverrideMassProperties_CalculateInertia } + if (opts.friction !== undefined) cs.mFriction = opts.friction + if (opts.restitution !== undefined) cs.mRestitution = opts.restitution + if (opts.linearDamping !== undefined) cs.mLinearDamping = opts.linearDamping + if (opts.angularDamping !== undefined) cs.mAngularDamping = opts.angularDamping + if (opts.linearCast) cs.mMotionQuality = J.EMotionQuality_LinearCast + const activate = motionType === J.EMotionType_Static ? J.EActivation_DontActivate : J.EActivation_Activate + const body = this.bodyInterface.CreateBody(cs); this.bodyInterface.AddBody(body.GetID(), activate) + J.destroy(cs) + const id = body.GetID().GetIndexAndSequenceNumber() + this.bodies.set(id, body); this.bodyMeta.set(id, opts.meta || {}); this.bodyIds.set(id, body.GetID()) + return id + } + + PhysicsWorld.prototype.addStaticBox = function(halfExtents, position, rotation) { + const J = this.Jolt + const hv = new J.Vec3(halfExtents[0], halfExtents[1], halfExtents[2]) + const bs = new J.BoxShape(hv, 0.05, null); J.destroy(hv) + return this._addBody(bs, position, J.EMotionType_Static, LAYER_STATIC, { rotation, meta: { type: 'static', shape: 'box' } }) + } + + PhysicsWorld.prototype.addBody = function(shapeType, params, position, motionType, opts = {}) { + const J = this.Jolt; let shape + const mt = motionType === 'dynamic' ? J.EMotionType_Dynamic : motionType === 'kinematic' ? J.EMotionType_Kinematic : J.EMotionType_Static + const layer = motionType === 'static' ? LAYER_STATIC : LAYER_DYNAMIC + if (shapeType === 'box') { const cr = Math.min(0.05, Math.min(params[0], params[1], params[2]) * 0.1); const bv = new J.Vec3(params[0], params[1], params[2]); shape = new J.BoxShape(bv, cr, null); J.destroy(bv) } + else if (shapeType === 'sphere') shape = new J.SphereShape(params) + else if (shapeType === 'capsule') shape = new J.CapsuleShape(params[1], params[0]) + else if (shapeType === 'convex') return this.addConvexBody(params, position, motionType, opts) + else return null + return this._addBody(shape, position, mt, layer, { ...opts, meta: { type: motionType, shape: shapeType } }) + } + + PhysicsWorld.prototype.addConvexBody = function(params, position, motionType, opts = {}) { + const J = this.Jolt + const { shape } = buildConvexShape(J, params, this._shapeCache, opts.shapeKey || null) + const mt = motionType === 'dynamic' ? J.EMotionType_Dynamic : motionType === 'kinematic' ? J.EMotionType_Kinematic : J.EMotionType_Static + return this._addBody(shape, position, mt, motionType === 'static' ? LAYER_STATIC : LAYER_DYNAMIC, { ...opts, meta: { type: motionType, shape: 'convex' } }) + } + + PhysicsWorld.prototype.addConvexBodyAsync = function(params, position, motionType, opts = {}) { + const J = this.Jolt, cacheKey = opts.shapeKey || null + if (cacheKey && this._shapeCache.has(cacheKey)) { + const mt = motionType === 'dynamic' ? J.EMotionType_Dynamic : motionType === 'kinematic' ? J.EMotionType_Kinematic : J.EMotionType_Static + return Promise.resolve(this._addBody(this._shapeCache.get(cacheKey), position, mt, motionType === 'static' ? LAYER_STATIC : LAYER_DYNAMIC, { ...opts, meta: { type: motionType, shape: 'convex' } })) + } + const result = this._convexQueue.then(() => { + const { shape } = buildConvexShape(J, params, this._shapeCache, cacheKey) + const mt = motionType === 'dynamic' ? J.EMotionType_Dynamic : motionType === 'kinematic' ? J.EMotionType_Kinematic : J.EMotionType_Static + return this._addBody(shape, position, mt, motionType === 'static' ? LAYER_STATIC : LAYER_DYNAMIC, { ...opts, meta: { type: motionType, shape: 'convex' } }) + }) + this._convexQueue = result.then(() => {}, () => {}); return result + } + + PhysicsWorld.prototype.addStaticTrimeshAsync = async function(glbPath, meshIndex = 0, position = [0, 0, 0], scale = [1, 1, 1], rotation = [0, 0, 0, 1]) { + const J = this.Jolt + const { shape, sr, triangleCount } = await buildTrimeshShape(J, glbPath, scale) + const id = this._addBody(shape, position, J.EMotionType_Static, LAYER_STATIC, { rotation, meta: { type: 'static', shape: 'trimesh', triangles: triangleCount } }) + J.destroy(sr); return id + } + + PhysicsWorld.prototype.addHeightField = function(samples, sampleCount, scale, position) { + const J = this.Jolt + const settings = new J.HeightFieldShapeSettings() + const offset = new J.Vec3(0, 0, 0); settings.set_mOffset(offset); J.destroy(offset) + const sv = new J.Vec3(scale[0], scale[1], scale[2]); settings.set_mScale(sv); J.destroy(sv) + settings.set_mSampleCount(sampleCount) + if (typeof settings.set_mBlockSize === 'function') settings.set_mBlockSize(2) + const heights = settings.get_mHeightSamples() + heights.resize(samples.length) + let bulkOk = false + if (typeof heights.data === 'function' && typeof J.getPointer === 'function' && J.HEAPF32) { + const ref = heights.data(), ptr = J.getPointer(ref) + if (ptr) { const view = samples instanceof Float32Array ? samples : Float32Array.from(samples); J.HEAPF32.set(view, ptr >> 2); bulkOk = true } + } + if (!bulkOk) { heights.clear(); heights.reserve(samples.length); for (let i = 0; i < samples.length; i++) heights.push_back(samples[i]) } + const sr = settings.Create() + if (!sr.IsValid()) { console.error('[heightfield] shape invalid:', sr.GetError()); J.destroy(settings); J.destroy(sr); return null } + const shape = sr.Get() + const id = this._addBody(shape, position, J.EMotionType_Static, LAYER_STATIC, { meta: { type: 'static', shape: 'heightfield' } }) + J.destroy(settings); J.destroy(sr); return id + } + + PhysicsWorld.prototype.addStaticTrimeshFromData = function(entityId, v, ix, pos, rot = [0, 0, 0, 1]) { + const J = this.Jolt, tc = ix.length / 3, tl = new J.TriangleList(), f3 = new J.Float3(0, 0, 0) + tl.resize(tc) + for (let t = 0; t < tc; t++) { + const tri = tl.at(t) + for (let k = 0; k < 3; k++) { const i = ix[t * 3 + k]; f3.x = v[i * 3]; f3.y = v[i * 3 + 1]; f3.z = v[i * 3 + 2]; tri.set_mV(k, f3) } + } + const ms = new J.MeshShapeSettings(tl), sr = ms.Create() + if (!sr.IsValid()) { console.error('[trimesh] shape invalid for', entityId, sr.GetError()); J.destroy(f3); J.destroy(tl); J.destroy(ms); return null } + const shape = sr.Get(); J.destroy(f3); J.destroy(tl) + const id = this._addBody(shape, pos, J.EMotionType_Static, LAYER_STATIC, { rotation: rot, meta: { type: 'static', shape: 'trimesh' } }) + J.destroy(ms); J.destroy(sr); return id + } +} diff --git a/src/physics/World.js b/src/physics/World.js index ae04931c..bc5caac0 100644 --- a/src/physics/World.js +++ b/src/physics/World.js @@ -1,232 +1,114 @@ -import { extractMeshFromGLB, extractMeshFromGLBAsync } from './GLBLoader.js' -import { CharacterManager } from './CharacterManager.js' -import { buildConvexShape, buildTrimeshShape } from './ShapeBuilder.js' - -const LAYER_STATIC = 0, LAYER_DYNAMIC = 1, NUM_LAYERS = 2 -let joltInstance = null -async function getJolt() { - if (!joltInstance) { - const _isNode = typeof process !== 'undefined' && process.versions?.node - const { default: init } = await import(_isNode ? 'jolt-physics/wasm-compat' : '/node_modules/jolt-physics/dist/jolt-physics.wasm-compat.js') - joltInstance = await init() - } - return joltInstance -} - -export class PhysicsWorld { - constructor(config = {}) { - this.gravity = config.gravity || [0, -9.81, 0] - this.Jolt = null; this.jolt = null; this.physicsSystem = null; this.bodyInterface = null - this.bodies = new Map(); this.bodyMeta = new Map(); this.bodyIds = new Map() - this._objFilter = null; this._ovbp = null - this._shapeCache = new Map(); this._convexQueue = Promise.resolve() - this._tmpVec3 = null; this._tmpRVec3 = null - this._bulkOutP = null; this._bulkOutR = null; this._bulkOutLV = null; this._bulkOutAV = null - this._charMgr = new CharacterManager(this.gravity, config.crouchHalfHeight || 0.45) - } - - async init() { - const J = await getJolt(); this.Jolt = J - const objFilter = new J.ObjectLayerPairFilterTable(NUM_LAYERS) - objFilter.EnableCollision(LAYER_STATIC, LAYER_DYNAMIC); objFilter.EnableCollision(LAYER_DYNAMIC, LAYER_DYNAMIC) - const bpI = new J.BroadPhaseLayerInterfaceTable(NUM_LAYERS, 2) - bpI.MapObjectToBroadPhaseLayer(LAYER_STATIC, new J.BroadPhaseLayer(0)) - bpI.MapObjectToBroadPhaseLayer(LAYER_DYNAMIC, new J.BroadPhaseLayer(1)) - const ovbp = new J.ObjectVsBroadPhaseLayerFilterTable(bpI, 2, objFilter, NUM_LAYERS) - const settings = new J.JoltSettings() - settings.mObjectLayerPairFilter = objFilter; settings.mBroadPhaseLayerInterface = bpI - settings.mObjectVsBroadPhaseLayerFilter = ovbp - this._objFilter = objFilter; this._ovbp = ovbp - this.jolt = new J.JoltInterface(settings); J.destroy(settings) - this.physicsSystem = this.jolt.GetPhysicsSystem(); this.bodyInterface = this.physicsSystem.GetBodyInterface() - this._tmpVec3 = new J.Vec3(0, 0, 0); this._tmpRVec3 = new J.RVec3(0, 0, 0) - this._bulkOutP = new J.RVec3(0, 0, 0); this._bulkOutR = new J.Quat(0, 0, 0, 1) - this._bulkOutLV = new J.Vec3(0, 0, 0); this._bulkOutAV = new J.Vec3(0, 0, 0) - const [gx, gy, gz] = this.gravity - const gv = new J.Vec3(gx, gy, gz); this.physicsSystem.SetGravity(gv); J.destroy(gv) - this._heap32 = new Int32Array(J.HEAP8.buffer) - this._activationListener = new J.BodyActivationListenerJS() - this._activationListener.OnBodyActivated = (ptr) => { if (this.onBodyActivated) this.onBodyActivated(this._heap32[ptr >> 2]) } - this._activationListener.OnBodyDeactivated = (ptr) => { if (this.onBodyDeactivated) this.onBodyDeactivated(this._heap32[ptr >> 2]) } - this.physicsSystem.SetBodyActivationListener(this._activationListener) - this._charMgr.init(J, this.jolt, this.physicsSystem) - return this - } - - _addBody(shape, position, motionType, layer, opts = {}) { - const J = this.Jolt - const pos = new J.RVec3(position[0], position[1], position[2]) - const rot = opts.rotation ? new J.Quat(...opts.rotation) : new J.Quat(0, 0, 0, 1) - const cs = new J.BodyCreationSettings(shape, pos, rot, motionType, layer) - J.destroy(pos); J.destroy(rot) - if (opts.mass) { cs.mMassPropertiesOverride.mMass = opts.mass; cs.mOverrideMassProperties = J.EOverrideMassProperties_CalculateInertia } - if (opts.friction !== undefined) cs.mFriction = opts.friction - if (opts.restitution !== undefined) cs.mRestitution = opts.restitution - if (opts.linearDamping !== undefined) cs.mLinearDamping = opts.linearDamping - if (opts.angularDamping !== undefined) cs.mAngularDamping = opts.angularDamping - if (opts.linearCast) cs.mMotionQuality = J.EMotionQuality_LinearCast - const activate = motionType === J.EMotionType_Static ? J.EActivation_DontActivate : J.EActivation_Activate - const body = this.bodyInterface.CreateBody(cs); this.bodyInterface.AddBody(body.GetID(), activate) - J.destroy(cs) - const id = body.GetID().GetIndexAndSequenceNumber() - this.bodies.set(id, body); this.bodyMeta.set(id, opts.meta || {}); this.bodyIds.set(id, body.GetID()) - return id - } - - addStaticBox(halfExtents, position, rotation) { - const J = this.Jolt - const hv = new J.Vec3(halfExtents[0], halfExtents[1], halfExtents[2]) - const bs = new J.BoxShape(hv, 0.05, null); J.destroy(hv) - return this._addBody(bs, position, J.EMotionType_Static, LAYER_STATIC, { rotation, meta: { type: 'static', shape: 'box' } }) - } - - addBody(shapeType, params, position, motionType, opts = {}) { - const J = this.Jolt; let shape - if (shapeType === 'box') { const cr = Math.min(0.05, Math.min(params[0], params[1], params[2]) * 0.1); const bv = new J.Vec3(params[0], params[1], params[2]); shape = new J.BoxShape(bv, cr, null); J.destroy(bv) } - else if (shapeType === 'sphere') shape = new J.SphereShape(params) - else if (shapeType === 'capsule') shape = new J.CapsuleShape(params[1], params[0]) - else if (shapeType === 'convex') { - const { shape: cvxShape } = buildConvexShape(J, params, this._shapeCache, opts.shapeKey || null) - const mt = motionType === 'dynamic' ? J.EMotionType_Dynamic : motionType === 'kinematic' ? J.EMotionType_Kinematic : J.EMotionType_Static - return this._addBody(cvxShape, position, mt, motionType === 'static' ? LAYER_STATIC : LAYER_DYNAMIC, { ...opts, meta: { type: motionType, shape: shapeType } }) - } - else return null - const mt = motionType === 'dynamic' ? J.EMotionType_Dynamic : motionType === 'kinematic' ? J.EMotionType_Kinematic : J.EMotionType_Static - return this._addBody(shape, position, mt, motionType === 'static' ? LAYER_STATIC : LAYER_DYNAMIC, { ...opts, meta: { type: motionType, shape: shapeType } }) - } - - addConvexBodyAsync(params, position, motionType, opts = {}) { - const J = this.Jolt, cacheKey = opts.shapeKey || null - if (cacheKey && this._shapeCache.has(cacheKey)) { - const mt = motionType === 'dynamic' ? J.EMotionType_Dynamic : motionType === 'kinematic' ? J.EMotionType_Kinematic : J.EMotionType_Static - return Promise.resolve(this._addBody(this._shapeCache.get(cacheKey), position, mt, motionType === 'static' ? LAYER_STATIC : LAYER_DYNAMIC, { ...opts, meta: { type: motionType, shape: 'convex' } })) - } - const result = this._convexQueue.then(() => { - const { shape } = buildConvexShape(J, params, this._shapeCache, cacheKey) - const mt = motionType === 'dynamic' ? J.EMotionType_Dynamic : motionType === 'kinematic' ? J.EMotionType_Kinematic : J.EMotionType_Static - return this._addBody(shape, position, mt, motionType === 'static' ? LAYER_STATIC : LAYER_DYNAMIC, { ...opts, meta: { type: motionType, shape: 'convex' } }) - }) - this._convexQueue = result.then(() => {}, () => {}); return result - } - - async addStaticTrimeshAsync(glbPath, meshIndex = 0, position = [0, 0, 0], scale = [1, 1, 1], rotation = [0, 0, 0, 1]) { - const J = this.Jolt - const { shape, sr, triangleCount } = await buildTrimeshShape(J, glbPath, scale) - const id = this._addBody(shape, position, J.EMotionType_Static, LAYER_STATIC, { rotation, meta: { type: 'static', shape: 'trimesh', triangles: triangleCount } }) - J.destroy(sr); return id - } - - addHeightField(samples, sampleCount, scale, position) { - const J = this.Jolt - const settings = new J.HeightFieldShapeSettings() - const offset = new J.Vec3(0, 0, 0); settings.set_mOffset(offset); J.destroy(offset) - const sv = new J.Vec3(scale[0], scale[1], scale[2]); settings.set_mScale(sv); J.destroy(sv) - settings.set_mSampleCount(sampleCount) - // mBlockSize=2 ~halves Settings.Create cost vs default 4. Heightfield queries - // for a single CharacterVirtual are rare so query slowdown is acceptable. - if (typeof settings.set_mBlockSize === 'function') settings.set_mBlockSize(2) - const heights = settings.get_mHeightSamples() - heights.resize(samples.length) - let bulkOk = false - if (typeof heights.data === 'function' && typeof J.getPointer === 'function' && J.HEAPF32) { - const ref = heights.data() - const ptr = J.getPointer(ref) - if (ptr) { - const view = samples instanceof Float32Array ? samples : Float32Array.from(samples) - J.HEAPF32.set(view, ptr >> 2) - bulkOk = true - } - } - if (!bulkOk) { - heights.clear(); heights.reserve(samples.length) - for (let i = 0; i < samples.length; i++) heights.push_back(samples[i]) - } - const sr = settings.Create() - if (!sr.IsValid()) { console.error('[heightfield] shape invalid:', sr.GetError()); J.destroy(settings); J.destroy(sr); return null } - const shape = sr.Get() - const id = this._addBody(shape, position, J.EMotionType_Static, LAYER_STATIC, { meta: { type: 'static', shape: 'heightfield' } }) - J.destroy(settings); J.destroy(sr) - return id - } - - addStaticTrimeshFromData(entityId,v,ix,pos,rot=[0,0,0,1]){const J=this.Jolt,tc=ix.length/3,tl=new J.TriangleList(),f3=new J.Float3(0,0,0);tl.resize(tc);for(let t=0;t { if (this.onBodyActivated) this.onBodyActivated(this._heap32[ptr >> 2]) } + this._activationListener.OnBodyDeactivated = (ptr) => { if (this.onBodyDeactivated) this.onBodyDeactivated(this._heap32[ptr >> 2]) } + this.physicsSystem.SetBodyActivationListener(this._activationListener) + this._charMgr.init(J, this.jolt, this.physicsSystem); return this + } + + addPlayerCharacter(r, h, p, m) { return this._charMgr.addCharacter(r, h, p, m) } + setCharacterCrouch(id, v) { this._charMgr.setCrouch(id, v) } + updateCharacter(id, dt) { this._charMgr.update(id, dt) } + getCharacterPosition(id) { return this._charMgr.getPosition(id) } + readCharacterPosition(id, out) { this._charMgr.readPosition(id, out) } + getCharacterVelocity(id) { return this._charMgr.getVelocity(id) } + readCharacterVelocity(id, out) { this._charMgr.readVelocity(id, out) } + setCharacterVelocity(id, v) { this._charMgr.setVelocity(id, v) } + setCharacterPosition(id, p) { this._charMgr.setPosition(id, p) } + getCharacterGroundState(id) { return this._charMgr.getGroundState(id) } + removeCharacter(id) { this._charMgr.removeCharacter(id) } + get characters() { return this._charMgr.characters } + + syncDynamicBody(bodyId, entity) { + const b = this.bodies.get(bodyId); if (!b || !b.IsActive()) return false + const id = this.bodyIds.get(bodyId), bi = this.bodyInterface + bi.GetPositionAndRotation(id, this._bulkOutP, this._bulkOutR) + bi.GetLinearAndAngularVelocity(id, this._bulkOutLV, this._bulkOutAV) + entity.position[0] = this._bulkOutP.GetX(); entity.position[1] = this._bulkOutP.GetY(); entity.position[2] = this._bulkOutP.GetZ() + entity.rotation[0] = this._bulkOutR.GetX(); entity.rotation[1] = this._bulkOutR.GetY(); entity.rotation[2] = this._bulkOutR.GetZ(); entity.rotation[3] = this._bulkOutR.GetW() + entity.velocity[0] = this._bulkOutLV.GetX(); entity.velocity[1] = this._bulkOutLV.GetY(); entity.velocity[2] = this._bulkOutLV.GetZ(); return true + } + + setBodyPosition(id, p) { const b = this.bodies.get(id); if (!b) return; this._tmpRVec3.Set(p[0], p[1], p[2]); this.bodyInterface.SetPosition(b.GetID(), this._tmpRVec3, this.Jolt.EActivation_Activate) } + setBodyVelocity(id, v) { const b = this.bodies.get(id); if (!b) return; this._tmpVec3.Set(v[0], v[1], v[2]); this.bodyInterface.SetLinearVelocity(b.GetID(), this._tmpVec3) } + addForce(id, f) { const b = this.bodies.get(id); if (!b) return; this._tmpVec3.Set(f[0], f[1], f[2]); this.bodyInterface.AddForce(b.GetID(), this._tmpVec3) } + addImpulse(id, im) { const b = this.bodies.get(id); if (!b) return; this._tmpVec3.Set(im[0], im[1], im[2]); this.bodyInterface.AddImpulse(b.GetID(), this._tmpVec3) } + step(dt) { if (this.jolt) this.jolt.Step(dt, 2) } + + removeBody(id) { + const b = this.bodies.get(id); if (!b) return + this.bodyInterface.RemoveBody(b.GetID()); this.bodyInterface.DestroyBody(b.GetID()) + this.bodies.delete(id); this.bodyMeta.delete(id); this.bodyIds.delete(id) + } + + raycast(origin, direction, maxDistance = 1000, excludeBodyId = null) { + if (!this.physicsSystem) return { hit: false, distance: maxDistance, body: null, position: null } + const J = this.Jolt; const len = Math.hypot(direction[0], direction[1], direction[2]) + const dir = len > 0 ? [direction[0] / len, direction[1] / len, direction[2] / len] : direction + const ray = new J.RRayCast(new J.RVec3(origin[0], origin[1], origin[2]), new J.Vec3(dir[0] * maxDistance, dir[1] * maxDistance, dir[2] * maxDistance)) + const rs = new J.RayCastSettings(), col = new J.CastRayClosestHitCollisionCollector() + const bp = new J.DefaultBroadPhaseLayerFilter(this.jolt.GetObjectVsBroadPhaseLayerFilter(), LAYER_DYNAMIC) + const ol = new J.DefaultObjectLayerFilter(this.jolt.GetObjectLayerPairFilter(), LAYER_DYNAMIC) + const eb = excludeBodyId != null ? this.bodies.get(excludeBodyId) : null + const bf = eb ? new J.IgnoreSingleBodyFilter(eb.GetID()) : new J.BodyFilter(); const sf = new J.ShapeFilter() + this.physicsSystem.GetNarrowPhaseQuery().CastRay(ray, rs, col, bp, ol, bf, sf) + const hit = col.HadHit(); let res + if (hit) { const d = col.get_mHit().mFraction * maxDistance; res = { hit: true, distance: d, body: null, position: [origin[0] + dir[0] * d, origin[1] + dir[1] * d, origin[2] + dir[2] * d] } } + else res = { hit: false, distance: maxDistance, body: null, position: null } + J.destroy(ray); J.destroy(rs); J.destroy(col); J.destroy(bp); J.destroy(ol); J.destroy(bf); J.destroy(sf); return res + } + + destroy() { + if (!this.Jolt) return + this._charMgr.destroy(); for (const [id] of this.bodies) this.removeBody(id) + const J = this.Jolt + const objs = [this._tmpVec3, this._tmpRVec3, this._bulkOutP, this._bulkOutR, this._bulkOutLV, this._bulkOutAV, this.jolt] + for (const o of objs) if (o) J.destroy(o) + this.physicsSystem = null; this.bodyInterface = null + } +} +mixinBodyManager(PhysicsWorld) diff --git a/src/sdk/SnapshotBroadcaster.js b/src/sdk/SnapshotBroadcaster.js new file mode 100644 index 00000000..4846bd91 --- /dev/null +++ b/src/sdk/SnapshotBroadcaster.js @@ -0,0 +1,87 @@ +import { MSG } from '../protocol/MessageTypes.js' +import { SnapshotEncoder } from '../netcode/SnapshotEncoder.js' +import { pack } from '../protocol/msgpack.js' + +const SNAP_UNRELIABLE = true, PRIORITY_ENTITY_BUDGET = 64, PRIORITY_DECAY = 0.02 +const _spatialCache = new Map(), _precomputedRemoved = [], _cellPackCache = new Map(), _priorityAccumulators = new Map() +const _packPayload = { seq: 0, tick: 0, serverTime: 0, players: null, entities: null, removed: undefined, delta: 1 } +const _packWrapper = { type: MSG.SNAPSHOT, payload: null } + +function getPlayerPriorityIds(playerId, relevantIds, dynCache, viewerPos) { + if (!_priorityAccumulators.has(playerId)) _priorityAccumulators.set(playerId, new Map()) + const acc = _priorityAccumulators.get(playerId), vx = viewerPos[0], vy = viewerPos[1], vz = viewerPos[2] + for (const id of relevantIds) { + const entry = dynCache.get(id); if (!entry) continue + const enc = entry.enc, dx = enc[2] - vx, dy = enc[3] - vy, dz = enc[4] - vz + const distSq = dx * dx + dy * dy + dz * dz, velSq = enc[6] * enc[6] + enc[7] * enc[7] + enc[8] * enc[8] + const distScore = 1 / (1 + distSq * 0.001), velScore = Math.min(1, Math.sqrt(velSq) * 0.1) + acc.set(id, (acc.get(id) || 0) + distScore + velScore + PRIORITY_DECAY) + } + for (const id of acc.keys()) if (!dynCache.has(id)) acc.delete(id) + if (acc.size <= PRIORITY_ENTITY_BUDGET) return relevantIds + const sorted = [...acc.entries()].sort((a, b) => b[1] - a[1]), topIds = new Set() + for (let i = 0; i < PRIORITY_ENTITY_BUDGET && i < sorted.length; i++) topIds.add(sorted[i][0]) + for (const id of topIds) acc.set(id, 0) + return topIds +} + +function packSnapshot(seq, encoded) { + _packPayload.seq = seq; _packPayload.tick = encoded.tick; _packPayload.serverTime = encoded.serverTime + _packPayload.players = encoded.players; _packPayload.entities = encoded.entities + _packPayload.removed = encoded.removed; _packPayload.delta = encoded.delta + _packWrapper.payload = _packPayload; return pack(_packWrapper) +} + +export function buildAndSendSnapshots(players, appRuntime, deps, tick, snapshotSeq, isKeyframe, state, serverNow) { + const { connections, stageLoader, getRelevanceRadius, networkState, playerEntityMaps } = deps + const playerSnap = networkState.getSnapshot(), playerCount = players.length + const snapGroups = Math.max(1, Math.ceil(playerCount / 50)), curGroup = tick % snapGroups + const activeStage = stageLoader ? stageLoader.getActiveStage() : null + const relevanceRadius = activeStage ? activeStage.spatial.relevanceRadius : (getRelevanceRadius ? getRelevanceRadius() : 0) + if (relevanceRadius > 0) { + const curStaticVersion = appRuntime._staticVersion; let activeStaticEntries = null + if (isKeyframe || curStaticVersion !== state.lastStaticVersion) { + const staticSnap = appRuntime.getStaticSnapshot(), prevStaticMap = isKeyframe ? new Map() : state.staticEntityMap + const { staticEntries, changedEntries, staticMap, staticChanged } = SnapshotEncoder.encodeStaticEntities(staticSnap.entities, prevStaticMap) + state.lastStaticEntries = staticEntries + if (staticChanged || isKeyframe) { state.staticEntityMap = staticMap; state.staticEntityIds = SnapshotEncoder.buildStaticIds(staticMap); activeStaticEntries = isKeyframe ? staticEntries : changedEntries } + state.lastStaticVersion = curStaticVersion + } + _precomputedRemoved.length = 0 + if (isKeyframe || curStaticVersion !== state.lastDynVersion) { state.prevDynCache = null; state.lastDynVersion = curStaticVersion } + const allEncodedPlayers = SnapshotEncoder.encodePlayersOnce(playerSnap.players) + _spatialCache.clear(); _cellPackCache.clear(); let dynCache = null + for (const player of players) { + if (player.id % snapGroups !== curGroup) continue + if (dynCache === null) { + const activeIds = appRuntime._activeDynamicIds + if (state.prevDynCache === null) { state.prevDynCache = SnapshotEncoder.buildDynamicCache(activeIds, appRuntime._sleepingDynamicIds, appRuntime._suspendedEntityIds, appRuntime.entities, state.prevDynCache) } + else { SnapshotEncoder.refreshDynamicCache(state.prevDynCache, activeIds, appRuntime.entities) } + dynCache = state.prevDynCache + } + const isNewPlayer = !playerEntityMaps.has(player.id), viewerPos = player.state.position + const cellKey = (Math.floor(viewerPos[0] / relevanceRadius) * 65536 + Math.floor(viewerPos[2] / relevanceRadius)) | 0 + let cached = _spatialCache.get(cellKey) + if (!cached) { cached = { nearbyPlayerIds: appRuntime._playerIndex.nearby(viewerPos, relevanceRadius), relevantIds: appRuntime.getRelevantDynamicIds(viewerPos, relevanceRadius) }; _spatialCache.set(cellKey, cached) } + const preEncodedPlayers = SnapshotEncoder.filterEncodedPlayersWithSelf(allEncodedPlayers, cached.nearbyPlayerIds, player.id) + const prevMap = isNewPlayer ? new Map() : playerEntityMaps.get(player.id) + const relevantIds = isNewPlayer ? cached.relevantIds : getPlayerPriorityIds(player.id, cached.relevantIds, dynCache, viewerPos) + const { encoded, entityMap } = SnapshotEncoder.encodeDeltaFromCache(playerSnap.tick, serverNow, dynCache, relevantIds, prevMap, preEncodedPlayers, isNewPlayer ? state.lastStaticEntries : activeStaticEntries, state.staticEntityMap, state.staticEntityIds, isNewPlayer ? undefined : _precomputedRemoved, snapshotSeq, viewerPos) + if (isNewPlayer) { for (const id of prevMap.keys()) if (!dynCache.has(id) && !(state.staticEntityIds && state.staticEntityIds.has(id))) _precomputedRemoved.push(id) } + playerEntityMaps.set(player.id, entityMap) + if (!isNewPlayer && encoded.entities.length === 0 && !encoded.removed) { + let cellPack = _cellPackCache.get(cellKey); if (!cellPack) { cellPack = packSnapshot(snapshotSeq, encoded); _cellPackCache.set(cellKey, cellPack) } + connections.sendPacked(player.id, cellPack, SNAP_UNRELIABLE) + } else { connections.sendPacked(player.id, packSnapshot(snapshotSeq, encoded), SNAP_UNRELIABLE) } + } + } else { + const entitySnap = appRuntime.getSnapshot(), combined = { tick: playerSnap.tick, players: playerSnap.players, entities: entitySnap.entities, serverTime: serverNow } + const prevMap = (isKeyframe || state.broadcastEntityMap.size === 0) ? new Map() : state.broadcastEntityMap + const { encoded, entityMap } = SnapshotEncoder.encodeDelta(combined, prevMap); state.broadcastEntityMap = entityMap; const data = packSnapshot(snapshotSeq, encoded) + for (const player of players) { if (isKeyframe || player.id % snapGroups === curGroup) connections.sendPacked(player.id, data, SNAP_UNRELIABLE) } + } +} + +export function cleanupPriorityAccumulators(playerManager) { + for (const id of _priorityAccumulators.keys()) if (!playerManager.getPlayer(id)) _priorityAccumulators.delete(id) +} diff --git a/src/sdk/Supervisor.js b/src/sdk/Supervisor.js new file mode 100644 index 00000000..fee45298 --- /dev/null +++ b/src/sdk/Supervisor.js @@ -0,0 +1,40 @@ +export class Supervisor { + constructor(options = {}) { + this.name = options.name || 'Supervisor' + this.retryLimit = options.retryLimit || 5 + this.backoffBase = options.backoffBase || 100 + this.failures = 0; this.lastFailure = 0 + } + + async run(fn, ...args) { + try { + const res = await fn(...args) + if (this.failures > 0 && Date.now() - this.lastFailure > 10000) this.failures = 0 + return res + } catch (e) { + this.failures++; this.lastFailure = Date.now() + console.error(`[${this.name}] Failure #${this.failures}: ${e.message}`) + if (this.failures > this.retryLimit) { + console.error(`[${this.name}] Retry limit exceeded. Degraded mode active.`) + return null + } + const delay = Math.pow(2, this.failures) * this.backoffBase + return new Promise(resolve => setTimeout(() => resolve(this.run(fn, ...args)), delay)) + } + } + + wrap(fn) { + return (...args) => { + try { + return fn(...args) + } catch (e) { + this.failures++; this.lastFailure = Date.now() + console.error(`[${this.name}] Sync Failure #${this.failures}: ${e.message}`) + if (this.failures <= this.retryLimit) { + console.warn(`[${this.name}] Attempting to continue...`) + } + return null + } + } + } +} diff --git a/src/sdk/TickHandler.js b/src/sdk/TickHandler.js index 0b765c7a..4e0aedba 100644 --- a/src/sdk/TickHandler.js +++ b/src/sdk/TickHandler.js @@ -1,206 +1,68 @@ -import { MSG } from '../protocol/MessageTypes.js' -import { SnapshotEncoder } from '../netcode/SnapshotEncoder.js' -import { pack } from '../protocol/msgpack.js' -import { applyMovement as _applyMovement, DEFAULT_MOVEMENT as _DEFAULT_MOVEMENT } from '../shared/movement.js' -import { applyPlayerCollisions } from '../netcode/CollisionSystem.js' - -const MAX_SENDS_PER_TICK = 25 -const PHYSICS_PLAYER_DIVISOR = 3 -const SNAP_UNRELIABLE = true -const PRIORITY_ENTITY_BUDGET = 64 -const PRIORITY_DECAY = 0.02 - -let _lastYaw = NaN, _lastSinHalf = 0, _lastCosHalf = 1 - -function processPlayerMovement(players, deps, tick, dt, playerIdleCounts, playerAccumDt) { - const { playerManager, physicsIntegration, lagCompensator, networkState, applyMovement, movement } = deps - for (const player of players) { - const inputs = playerManager.getInputs(player.id) - const st = player.state - if (inputs.length > 0) { player.lastInput = inputs[inputs.length - 1].data; playerManager.clearInputs(player.id) } - const inp = player.lastInput || null - if (inp) { - const yaw = inp.yaw || 0 - if (yaw !== _lastYaw) { const half = yaw / 2; _lastSinHalf = Math.sin(half); _lastCosHalf = Math.cos(half); _lastYaw = yaw } - st.rotation[0] = 0; st.rotation[1] = _lastSinHalf; st.rotation[2] = 0; st.rotation[3] = _lastCosHalf - st.crouch = inp.crouch ? 1 : 0; st.lookPitch = inp.pitch || 0; st.lookYaw = yaw - } - applyMovement(st, inp, movement, dt) - if (inp) physicsIntegration.setCrouch(player.id, !!inp.crouch) - const wishedVx = st.velocity[0], wishedVz = st.velocity[2] - const hasInput = inp && (inp.forward || inp.backward || inp.left || inp.right || inp.jump) - const isIdle = !hasInput && st.onGround && wishedVx * wishedVx + wishedVz * wishedVz < 1e-4 - const idleCount = playerIdleCounts.get(player.id) || 0 - if (isIdle && idleCount >= 1) { playerIdleCounts.set(player.id, idleCount + 1); playerAccumDt.delete(player.id) } - else { - const accumDt = (playerAccumDt.get(player.id) || 0) + dt - if ((tick + player.id) % PHYSICS_PLAYER_DIVISOR === 0 || inp?.jump || !st.onGround) { - physicsIntegration.updatePlayerPhysics(player.id, st, accumDt); st.velocity[0] = wishedVx; st.velocity[2] = wishedVz; playerAccumDt.delete(player.id) - } else { playerAccumDt.set(player.id, accumDt) } - playerIdleCounts.set(player.id, isIdle ? idleCount + 1 : 0) - } - lagCompensator.recordPlayerPosition(player.id, st.position, st.rotation, st.velocity, tick) - networkState.updatePlayer(player.id, st.position, st.rotation, st.velocity, st.onGround, st.health, player.inputSequence, st.crouch||0, st.lookPitch||0, st.lookYaw||0) - } -} - -const _spatialCache = new Map() -const _precomputedRemoved = [] -const _cellPackCache = new Map() -const _packWrapper = { type: MSG.SNAPSHOT, payload: null } -const _priorityAccumulators = new Map() -const _packPayload = { seq: 0, tick: 0, serverTime: 0, players: null, entities: null, removed: undefined, delta: 1 } - -function getPlayerPriorityIds(playerId, relevantIds, dynCache, viewerPos, tick) { - if (!_priorityAccumulators.has(playerId)) _priorityAccumulators.set(playerId, new Map()) - const acc = _priorityAccumulators.get(playerId) - const vx = viewerPos[0], vy = viewerPos[1], vz = viewerPos[2] - - for (const id of relevantIds) { - const entry = dynCache.get(id); if (!entry) continue - const enc = entry.enc - const dx = enc[2]-vx, dy = enc[3]-vy, dz = enc[4]-vz - const distSq = dx*dx+dy*dy+dz*dz - const velSq = enc[6]*enc[6]+enc[7]*enc[7]+enc[8]*enc[8] - const distScore = 1 / (1 + distSq * 0.001) - const velScore = Math.min(1, Math.sqrt(velSq) * 0.1) - const prev = acc.get(id) || 0 - acc.set(id, prev + distScore + velScore + PRIORITY_DECAY) - } - - for (const id of acc.keys()) { - if (!dynCache.has(id)) acc.delete(id) - } - - if (acc.size <= PRIORITY_ENTITY_BUDGET) return relevantIds - - const sorted = [...acc.entries()].sort((a, b) => b[1] - a[1]) - const topIds = new Set() - for (let i = 0; i < PRIORITY_ENTITY_BUDGET && i < sorted.length; i++) topIds.add(sorted[i][0]) - for (const id of topIds) acc.set(id, 0) - return topIds -} - -function packSnapshot(seq, encoded) { - _packPayload.seq = seq; _packPayload.tick = encoded.tick; _packPayload.serverTime = encoded.serverTime - _packPayload.players = encoded.players; _packPayload.entities = encoded.entities - _packPayload.removed = encoded.removed; _packPayload.delta = encoded.delta - _packWrapper.payload = _packPayload - return pack(_packWrapper) -} - -function buildAndSendSnapshots(players, appRuntime, deps, tick, snapshotSeq, isKeyframe, state, serverNow) { - const { connections, stageLoader, getRelevanceRadius, networkState, playerEntityMaps } = deps - const playerSnap = networkState.getSnapshot() - const playerCount = players.length - const snapGroups = Math.max(1, Math.ceil(playerCount / 50)) - const curGroup = tick % snapGroups - const activeStage = stageLoader ? stageLoader.getActiveStage() : null - const relevanceRadius = activeStage ? activeStage.spatial.relevanceRadius : (getRelevanceRadius ? getRelevanceRadius() : 0) - - if (relevanceRadius > 0) { - const curStaticVersion = appRuntime._staticVersion - let activeStaticEntries = null - if (isKeyframe || curStaticVersion !== state.lastStaticVersion) { - const staticSnap = appRuntime.getStaticSnapshot() - const prevStaticMap = isKeyframe ? new Map() : state.staticEntityMap - const { staticEntries, changedEntries, staticMap, staticChanged } = SnapshotEncoder.encodeStaticEntities(staticSnap.entities, prevStaticMap) - state.lastStaticEntries = staticEntries - if (staticChanged || isKeyframe) { state.staticEntityMap = staticMap; state.staticEntityIds = SnapshotEncoder.buildStaticIds(staticMap); activeStaticEntries = isKeyframe ? staticEntries : changedEntries } - state.lastStaticVersion = curStaticVersion - } - _precomputedRemoved.length = 0 - if (isKeyframe || curStaticVersion !== state.lastDynVersion) { state.prevDynCache = null; state.lastDynVersion = curStaticVersion } - const allEncodedPlayers = SnapshotEncoder.encodePlayersOnce(playerSnap.players) - _spatialCache.clear() - _cellPackCache.clear() - let dynCache = null - for (const player of players) { - if (player.id % snapGroups !== curGroup) continue - if (dynCache === null) { - const activeIds = appRuntime._activeDynamicIds - if (state.prevDynCache === null) { state.prevDynCache = SnapshotEncoder.buildDynamicCache(activeIds, appRuntime._sleepingDynamicIds, appRuntime._suspendedEntityIds, appRuntime.entities, state.prevDynCache) } - else { SnapshotEncoder.refreshDynamicCache(state.prevDynCache, activeIds, appRuntime.entities) } - dynCache = state.prevDynCache - } - const isNewPlayer = !playerEntityMaps.has(player.id) - const viewerPos = player.state.position - const cellKey = (Math.floor(viewerPos[0] / relevanceRadius) * 65536 + Math.floor(viewerPos[2] / relevanceRadius)) | 0 - let cached = _spatialCache.get(cellKey) - if (!cached) { cached = { nearbyPlayerIds: appRuntime._playerIndex.nearby(viewerPos, relevanceRadius), relevantIds: appRuntime.getRelevantDynamicIds(viewerPos, relevanceRadius) }; _spatialCache.set(cellKey, cached) } - const preEncodedPlayers = SnapshotEncoder.filterEncodedPlayersWithSelf(allEncodedPlayers, cached.nearbyPlayerIds, player.id) - const prevMap = isNewPlayer ? new Map() : playerEntityMaps.get(player.id) - const relevantIds = isNewPlayer ? cached.relevantIds : getPlayerPriorityIds(player.id, cached.relevantIds, dynCache, viewerPos, tick) - const { encoded, entityMap } = SnapshotEncoder.encodeDeltaFromCache(playerSnap.tick, serverNow, dynCache, relevantIds, prevMap, preEncodedPlayers, isNewPlayer ? state.lastStaticEntries : activeStaticEntries, state.staticEntityMap, state.staticEntityIds, isNewPlayer ? undefined : _precomputedRemoved, snapshotSeq, viewerPos) - if (isNewPlayer) { for (const id of prevMap.keys()) { if (!dynCache.has(id) && !(state.staticEntityIds && state.staticEntityIds.has(id))) _precomputedRemoved.push(id) } } - playerEntityMaps.set(player.id, entityMap) - if (!isNewPlayer && encoded.entities.length === 0 && !encoded.removed) { - let cellPack = _cellPackCache.get(cellKey) - if (!cellPack) { - cellPack = packSnapshot(snapshotSeq, encoded) - _cellPackCache.set(cellKey, cellPack) - } - connections.sendPacked(player.id, cellPack, SNAP_UNRELIABLE) - } else { - const packedData = packSnapshot(snapshotSeq, encoded) - connections.sendPacked(player.id, packedData, SNAP_UNRELIABLE) - } - } - } else { - const entitySnap = appRuntime.getSnapshot() - const combined = { tick: playerSnap.tick, players: playerSnap.players, entities: entitySnap.entities, serverTime: serverNow } - const prevMap = (isKeyframe || state.broadcastEntityMap.size === 0) ? new Map() : state.broadcastEntityMap - const { encoded, entityMap } = SnapshotEncoder.encodeDelta(combined, prevMap) - state.broadcastEntityMap = entityMap - const data = packSnapshot(snapshotSeq, encoded) - for (const player of players) { - if (!isKeyframe && player.id % snapGroups !== curGroup) continue - connections.sendPacked(player.id, data, SNAP_UNRELIABLE) - } - } -} - -export function createTickHandler(deps) { - const { networkState, playerManager, physicsIntegration, lagCompensator, physics, appRuntime, connections, movement: m = {}, stageLoader, getRelevanceRadius, _movement, tickRate = 128 } = deps - const KEYFRAME_INTERVAL = tickRate * 10 - const applyMovement = _movement?.applyMovement || _applyMovement - const DEFAULT_MOVEMENT = _movement?.DEFAULT_MOVEMENT || _DEFAULT_MOVEMENT - const movement = { ...DEFAULT_MOVEMENT, ...m } - const mvDeps = { playerManager, physicsIntegration, lagCompensator, networkState, applyMovement, movement } - const snapDeps = { connections, stageLoader, getRelevanceRadius, networkState, playerEntityMaps: new Map() } - const snapState = { broadcastEntityMap: new Map(), staticEntityMap: new Map(), staticEntityIds: null, lastStaticEntries: null, lastStaticVersion: -1, lastDynVersion: -1, prevDynCache: null } - const playerIdleCounts = new Map(), playerAccumDt = new Map() - const grid = new Map(), gridCells = new Map() - let snapshotSeq = 0, profileLog = 0, profileSum = 0, profileSumSnap = 0, profileSumPhys = 0, profileSumMv = 0, profileCount = 0 - - return function onTick(tick, dt) { - const t0 = performance.now() - const serverNow = Date.now() - networkState.setTick(tick, serverNow) - const players = playerManager.getConnectedPlayers() - processPlayerMovement(players, mvDeps, tick, dt, playerIdleCounts, playerAccumDt) - const t1 = performance.now() - const cellSz = physicsIntegration.config.capsuleRadius * 8, minDist = physicsIntegration.config.capsuleRadius * 2 - applyPlayerCollisions(players, grid, gridCells, cellSz, minDist * minDist, minDist, dt, physicsIntegration) - const t2 = performance.now() - physics.step(dt) - const t3 = performance.now() - appRuntime.tick(tick, dt) - const t4 = performance.now() - if (players.length > 0) { snapshotSeq++; buildAndSendSnapshots(players, appRuntime, snapDeps, tick, snapshotSeq, snapshotSeq % KEYFRAME_INTERVAL === 0, snapState, serverNow) } - for (const id of snapDeps.playerEntityMaps.keys()) { if (!playerManager.getPlayer(id)) { snapDeps.playerEntityMaps.delete(id); playerIdleCounts.delete(id); playerAccumDt.delete(id); _priorityAccumulators.delete(id) } } - const t5 = performance.now() - try { appRuntime._drainReloadQueue() } catch (e) { console.error('[TickHandler] reload queue error:', e.message) } - if (players.length > 0) { profileSum += t5-t0; profileSumSnap += t5-t4; profileSumPhys += t3-t2; profileSumMv += t1-t0; profileCount++ } - if (++profileLog % KEYFRAME_INTERVAL === 0) { - const total=t5-t0, mem=typeof process!=='undefined'?process.memoryUsage():{heapUsed:0,rss:0,external:0,arrayBuffers:0}, avg=n => profileCount>0?(n/profileCount).toFixed(2):'0' - const mb=n=>(n/1048576).toFixed(1) - const dynIds=appRuntime._dynamicEntityIds?.size||0, activeDyn=appRuntime._activeDynamicIds?.size||0 - const avgTotal=avg(profileSum),avgSnap=avg(profileSumSnap),avgPhys=avg(profileSumPhys),avgMv=avg(profileSumMv) - profileSum=0; profileSumSnap=0; profileSumPhys=0; profileSumMv=0; profileCount=0 - let idleSkipped = 0; if (players.length > 0) for (const c of playerIdleCounts.values()) if (c >= 2) idleSkipped++ - const physSkipped = players.length > 0 ? playerAccumDt.size : 0 - try { console.log(`[tick-profile] tick:${tick} players:${players.length} idle:${idleSkipped} physSkip:${physSkipped} entities:${appRuntime.entities.size} dynIds:${dynIds} activeDyn:${activeDyn} total:${total.toFixed(2)}ms(avg:${avgTotal}) | mv:${(t1-t0).toFixed(2)}(avg:${avgMv}) col:${(t2-t1).toFixed(2)} phys:${(t3-t2).toFixed(2)}(avg:${avgPhys}) app:${(t4-t3).toFixed(2)} sync:${(appRuntime._lastSyncMs||0).toFixed(2)} respawn:${(appRuntime._lastRespawnMs||0).toFixed(2)} spatial:${(appRuntime._lastSpatialMs||0).toFixed(2)} col2:${(appRuntime._lastCollisionMs||0).toFixed(2)} int:${(appRuntime._lastInteractMs||0).toFixed(2)} snap:${(t5-t4).toFixed(2)}(avg:${avgSnap}) | heap:${mb(mem.heapUsed)}MB rss:${mb(mem.rss)}MB ext:${mb(mem.external)}MB ab:${mb(mem.arrayBuffers)}MB`) } catch (_) {} - } - } -} +import { applyMovement as _applyMovement, DEFAULT_MOVEMENT as _DEFAULT_MOVEMENT } from '../shared/movement.js' +import { applyPlayerCollisions } from '../netcode/CollisionSystem.js' +import { buildAndSendSnapshots, cleanupPriorityAccumulators } from './SnapshotBroadcaster.js' + +const PHYSICS_PLAYER_DIVISOR = 3 +let _lastYaw = NaN, _lastSinHalf = 0, _lastCosHalf = 1 + +function processPlayerMovement(players, deps, tick, dt, playerIdleCounts, playerAccumDt) { + const { playerManager, physicsIntegration, lagCompensator, networkState, applyMovement, movement } = deps + for (const player of players) { + const inputs = playerManager.getInputs(player.id), st = player.state + if (inputs.length > 0) { player.lastInput = inputs[inputs.length - 1].data; playerManager.clearInputs(player.id) } + const inp = player.lastInput || null + if (inp) { + const yaw = inp.yaw || 0 + if (yaw !== _lastYaw) { const half = yaw / 2; _lastSinHalf = Math.sin(half); _lastCosHalf = Math.cos(half); _lastYaw = yaw } + st.rotation[0] = 0; st.rotation[1] = _lastSinHalf; st.rotation[2] = 0; st.rotation[3] = _lastCosHalf + st.crouch = inp.crouch ? 1 : 0; st.lookPitch = inp.pitch || 0; st.lookYaw = yaw + } + applyMovement(st, inp, movement, dt); if (inp) physicsIntegration.setCrouch(player.id, !!inp.crouch) + const wishedVx = st.velocity[0], wishedVz = st.velocity[2] + const hasInput = inp && (inp.forward || inp.backward || inp.left || inp.right || inp.jump) + const isIdle = !hasInput && st.onGround && wishedVx * wishedVx + wishedVz * wishedVz < 1e-4 + const idleCount = playerIdleCounts.get(player.id) || 0 + if (isIdle && idleCount >= 1) { playerIdleCounts.set(player.id, idleCount + 1); playerAccumDt.delete(player.id) } + else { + const accumDt = (playerAccumDt.get(player.id) || 0) + dt + if ((tick + player.id) % PHYSICS_PLAYER_DIVISOR === 0 || inp?.jump || !st.onGround) { + physicsIntegration.updatePlayerPhysics(player.id, st, accumDt); st.velocity[0] = wishedVx; st.velocity[2] = wishedVz; playerAccumDt.delete(player.id) + } else playerAccumDt.set(player.id, accumDt) + playerIdleCounts.set(player.id, isIdle ? idleCount + 1 : 0) + } + lagCompensator.recordPlayerPosition(player.id, st.position, st.rotation, st.velocity, tick) + networkState.updatePlayer(player.id, st.position, st.rotation, st.velocity, st.onGround, st.health, player.inputSequence, st.crouch || 0, st.lookPitch || 0, st.lookYaw || 0) + } +} + +export function createTickHandler(deps) { + const { networkState, playerManager, physicsIntegration, lagCompensator, physics, appRuntime, connections, movement: m = {}, stageLoader, getRelevanceRadius, _movement, tickRate = 128 } = deps + const KEYFRAME_INTERVAL = tickRate * 10, applyMovement = _movement?.applyMovement || _applyMovement + const movement = { ...(_movement?.DEFAULT_MOVEMENT || _DEFAULT_MOVEMENT), ...m } + const mvDeps = { playerManager, physicsIntegration, lagCompensator, networkState, applyMovement, movement } + const snapDeps = { connections, stageLoader, getRelevanceRadius, networkState, playerEntityMaps: new Map() } + const snapState = { broadcastEntityMap: new Map(), staticEntityMap: new Map(), staticEntityIds: null, lastStaticEntries: null, lastStaticVersion: -1, lastDynVersion: -1, prevDynCache: null } + const playerIdleCounts = new Map(), playerAccumDt = new Map(), grid = new Map(), gridCells = new Map() + let snapshotSeq = 0, profileLog = 0, profileSum = 0, profileSumSnap = 0, profileSumPhys = 0, profileSumMv = 0, profileCount = 0 + return function onTick(tick, dt) { + const t0 = performance.now(), serverNow = Date.now() + networkState.setTick(tick, serverNow) + const players = playerManager.getConnectedPlayers(); processPlayerMovement(players, mvDeps, tick, dt, playerIdleCounts, playerAccumDt) + const t1 = performance.now(), cellSz = physicsIntegration.config.capsuleRadius * 8, minDist = physicsIntegration.config.capsuleRadius * 2 + applyPlayerCollisions(players, grid, gridCells, cellSz, minDist * minDist, minDist, dt, physicsIntegration) + const t2 = performance.now(); physics.step(dt) + const t3 = performance.now(); appRuntime.tick(tick, dt) + const t4 = performance.now() + if (players.length > 0) { snapshotSeq++; buildAndSendSnapshots(players, appRuntime, snapDeps, tick, snapshotSeq, snapshotSeq % KEYFRAME_INTERVAL === 0, snapState, serverNow) } + for (const id of snapDeps.playerEntityMaps.keys()) if (!playerManager.getPlayer(id)) { snapDeps.playerEntityMaps.delete(id); playerIdleCounts.delete(id); playerAccumDt.delete(id) } + cleanupPriorityAccumulators(playerManager) + const t5 = performance.now(); try { appRuntime._drainReloadQueue() } catch (e) { console.error('[TickHandler] reload queue error:', e.message) } + if (players.length > 0) { profileSum += t5 - t0; profileSumSnap += t5 - t4; profileSumPhys += t3 - t2; profileSumMv += t1 - t0; profileCount++ } + if (++profileLog % KEYFRAME_INTERVAL === 0) { + const avg = n => profileCount > 0 ? (n / profileCount).toFixed(2) : '0', mb = n => (n / 1048576).toFixed(1), mem = typeof process !== 'undefined' ? process.memoryUsage() : { heapUsed: 0, rss: 0, external: 0, arrayBuffers: 0 } + let idleSkipped = 0; if (players.length > 0) for (const c of playerIdleCounts.values()) if (c >= 2) idleSkipped++ + console.log(`[tick-profile] tick:${tick} players:${players.length} idle:${idleSkipped} physSkip:${playerAccumDt.size} entities:${appRuntime.entities.size} dynIds:${appRuntime._dynamicEntityIds?.size || 0} activeDyn:${appRuntime._activeDynamicIds?.size || 0} total:${(t5 - t0).toFixed(2)}ms(avg:${avg(profileSum)}) | mv:${(t1 - t0).toFixed(2)}(avg:${avg(profileSumMv)}) col:${(t2 - t1).toFixed(2)} phys:${(t3 - t2).toFixed(2)}(avg:${avg(profileSumPhys)}) app:${(t4 - t3).toFixed(2)} snap:${(t5 - t4).toFixed(2)}(avg:${avg(profileSumSnap)}) | heap:${mb(mem.heapUsed)}MB rss:${mb(mem.rss)}MB`) + profileSum = 0; profileSumSnap = 0; profileSumPhys = 0; profileSumMv = 0; profileCount = 0 + } + } +} diff --git a/src/sdk/server.js b/src/sdk/server.js index 17c961d1..bfba1180 100644 --- a/src/sdk/server.js +++ b/src/sdk/server.js @@ -24,6 +24,7 @@ import { ReloadManager } from './ReloadManager.js' import { createReloadHandlers } from './ReloadHandlers.js' import { createServerAPI } from './ServerAPI.js' import { createConnectionHandlers } from './ServerHandlers.js' +import { Supervisor } from './Supervisor.js' function buildUniquePathList(paths) { const out = [], seen = new Set() @@ -121,8 +122,12 @@ export async function createServer(config = {}) { console.log(`[tick-dilation] factor=${factor}`) }) const api = createServerAPI(ctx) - if (typeof globalThis.__DEBUG__ === 'undefined') globalThis.__DEBUG__ = {} - globalThis.__DEBUG__.server = api + const sv = new Supervisor({ name: 'TickSupervisor' }) + const originalTick = ctx.onTick + ctx.onTick = sv.wrap(originalTick) + if (typeof globalThis.__SPOINT_DEBUG__ === 'undefined') globalThis.__SPOINT_DEBUG__ = {} + globalThis.__SPOINT_DEBUG__.server = api + globalThis.__SPOINT_DEBUG__.ctx = ctx return api } diff --git a/test.js b/test.js deleted file mode 100644 index eb830941..00000000 --- a/test.js +++ /dev/null @@ -1,70 +0,0 @@ -import assert from 'node:assert/strict' - -const F2 = 0.5 * (Math.sqrt(3) - 1), G2 = (3 - Math.sqrt(3)) / 6 -const GRAD2 = [[1,1],[-1,1],[1,-1],[-1,-1],[1,0],[-1,0],[0,1],[0,-1]] -function buildPermTable(seed) { - const p = new Uint8Array(256); for (let i = 0; i < 256; i++) p[i] = i - let s = seed | 0 - for (let i = 255; i > 0; i--) { s=(s*1664525+1013904223)>>>0; const j=s%(i+1); const tmp=p[i]; p[i]=p[j]; p[j]=tmp } - const perm = new Uint8Array(512); for (let i=0;i<512;i++) perm[i]=p[i&255]; return perm -} -function simplex2(perm, x, y) { - const s=(x+y)*F2, i=Math.floor(x+s), j=Math.floor(y+s), t=(i+j)*G2 - const x0=x-(i-t),y0=y-(j-t),i1=x0>y0?1:0,j1=x0>y0?0:1 - const x1=x0-i1+G2,y1=y0-j1+G2,x2=x0-1+2*G2,y2=y0-1+2*G2 - const ii=i&255,jj=j&255; let n=0 - for (const [dx,dy,ddx,ddy] of [[x0,y0,ii,jj],[x1,y1,ii+i1,jj+j1],[x2,y2,ii+1,jj+1]]) { - const t2=0.5-dx*dx-dy*dy - if (t2>=0) { const g=GRAD2[perm[ddx+perm[ddy&255]]&7]; n+=(t2*t2)*(t2*t2)*(g[0]*dx+g[1]*dy) } - } - return n*70 -} -function fbm({seed,octaves,frequency,amplitude,gain,lacunarity,offset=0}) { - const perms=Array.from({length:octaves},(_,i)=>buildPermTable((seed+i*73856093)>>>0)) - return (x,y)=>{ let v=0,amp=amplitude,freq=frequency; for (let i=0;i { - const n = fbm({seed:42,octaves:4,frequency:0.1,amplitude:1,gain:0.5,lacunarity:2}) - assert.equal(n(1,2).toFixed(6), n(1,2).toFixed(6)) - assert.notEqual(n(1,2).toFixed(4), n(3,4).toFixed(4)) -}) - -test('FBM seed isolation', () => { - const n1 = fbm({seed:0,octaves:4,frequency:0.1,amplitude:1,gain:0.5,lacunarity:2}) - const n2 = fbm({seed:999,octaves:4,frequency:0.1,amplitude:1,gain:0.5,lacunarity:2}) - assert.notEqual(n1(1,1).toFixed(4), n2(1,1).toFixed(4)) -}) - -test('FBM offset applied', () => { - const n = fbm({seed:0,octaves:1,frequency:0.01,amplitude:0.001,gain:0.5,lacunarity:2,offset:5}) - assert(Math.abs(n(0,0) - 5) < 0.1, `expected ~5, got ${n(0,0)}`) -}) - -test('simplex2 output in expected range', () => { - const perm = buildPermTable(0) - for (let i = 0; i < 20; i++) { - const v = simplex2(perm, i*0.3, i*0.7) - assert(v >= -1 && v <= 1, `simplex2 out of range: ${v}`) - } -}) - -test('world config aim_sillos scale is [1,1,1]', async () => { - const src = await import('file:///C:/dev/devbox/spawnpoint/apps/world/index.js') - const wd = src.default - const sillos = wd.entities.find(e => e.id === 'env-sillos') - assert(sillos, 'env-sillos not found') - assert.deepEqual(sillos.scale, [1,1,1], `scale was ${JSON.stringify(sillos.scale)}`) -}) - - -for (const { name, fn } of _tests) { - try { await fn(); console.log('PASS', name); pass++ } - catch(e) { console.error('FAIL', name, e.message); fail++ } -} -console.log(`\n${pass} passed, ${fail} failed`) -if (fail > 0) process.exit(1)