diff --git a/package.json b/package.json index d1a0fcdd..bca38f65 100644 --- a/package.json +++ b/package.json @@ -61,8 +61,8 @@ "optionalDependencies": { "@fails-components/webtransport": "^1.5.3", "@fails-components/webtransport-transport-http3-quiche": "^1.5.3", - "wireweave": "^0.3.0", - "nostr-tools": "^2.7.0" + "nostr-tools": "^2.7.0", + "wireweave": "^0.3.0" }, "devDependencies": { "@gltf-transform/cli": "^4.3.0", diff --git a/src/apps/AppCollisionTick.js b/src/apps/AppCollisionTick.js new file mode 100644 index 00000000..ff3a2289 --- /dev/null +++ b/src/apps/AppCollisionTick.js @@ -0,0 +1,52 @@ +function getColR(c) { + if (!c) return 0 + if (c._cachedRadius !== undefined) return c._cachedRadius + let r = 1 + if (c.type === 'sphere') r = c.radius || 1 + else if (c.type === 'capsule') r = Math.max(c.radius || 0.5, (c.height || 1) / 2) + else if (c.type === 'box') { const s = c.size, h = c.halfExtents; r = Array.isArray(s) ? Math.max(...s) : typeof s === 'number' ? s : Array.isArray(h) ? Math.max(...h) : 1 } + c._cachedRadius = r; return r +} + +export function tickCollisions(runtime, c) { + if (c.length === 0) return + for (let i = 0; i < c.length; i++) c[i]._cachedColR = getColR(c[i].collider) + if (c.length < 100) tickCollisionsBrute(runtime, c); else tickCollisionsGrid(runtime, c) +} + +function tickCollisionsBrute(runtime, c) { + for (let i = 0; i < c.length; i++) { + const a = c[i], ar = a._cachedColR, ax = a.position[0], ay = a.position[1], az = a.position[2] + for (let j = i + 1; j < c.length; j++) { + const b = c[j], dx = b.position[0]-ax, dy = b.position[1]-ay, dz = b.position[2]-az, rr = ar + b._cachedColR + if (dx*dx+dy*dy+dz*dz < rr*rr) { + runtime.fireEvent(a.id, 'onCollision', { id: b.id, position: b.position, velocity: b.velocity }) + runtime.fireEvent(b.id, 'onCollision', { id: a.id, position: a.position, velocity: a.velocity }) + } + } + } +} + +const _COL_CELL_SZ = 4 +function tickCollisionsGrid(runtime, c) { + const grid = runtime._colGrid, cells = runtime._colGridCells + grid.clear() + if ((++runtime._colPruneTick & 63) === 0 || cells.size > c.length * 4) { for (const k of cells.keys()) if (!grid.has(k)) cells.delete(k) } + for (let i = 0; i < c.length; i++) { + const e = c[i], key = Math.floor(e.position[0] / _COL_CELL_SZ) * 65536 + Math.floor(e.position[2] / _COL_CELL_SZ) + let cell = grid.get(key); if (!cell) { cell = cells.get(key); if (!cell) { cell = []; cells.set(key, cell) } else cell.length = 0; grid.set(key, cell) } + cell.push(e) + } + for (let i = 0; i < c.length; i++) { + const a = c[i], ar = a._cachedColR, ax = a.position[0], ay = a.position[1], az = a.position[2], acx = Math.floor(ax / _COL_CELL_SZ), acz = Math.floor(az / _COL_CELL_SZ) + for (let ddx = -1; ddx <= 1; ddx++) for (let ddz = -1; ddz <= 1; ddz++) { + const cell = grid.get((acx + ddx) * 65536 + (acz + ddz)) + if (!cell) continue + for (const b of cell) { + if (b.id <= a.id) continue + const dx = b.position[0]-ax, dy = b.position[1]-ay, dz = b.position[2]-az, rr = ar + b._cachedColR + if (dx*dx+dy*dy+dz*dz < rr*rr) { runtime.fireEvent(a.id, 'onCollision', { id: b.id, position: b.position, velocity: b.velocity }); runtime.fireEvent(b.id, 'onCollision', { id: a.id, position: a.position, velocity: a.velocity }) } + } + } + } +} diff --git a/src/apps/AppLoader.js b/src/apps/AppLoader.js index 79642580..313fd754 100644 --- a/src/apps/AppLoader.js +++ b/src/apps/AppLoader.js @@ -1,205 +1,100 @@ -const BLOCKED_PATTERNS = [ - 'process.exit', 'child_process', 'require(', '__proto__', - 'Object.prototype', 'globalThis', 'eval(', 'import(' -] +import { nodeModules } from './NodeModules.js' -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 -} +const BLOCKED_PATTERNS = ['process.exit', 'child_process', 'require(', '__proto__', 'Object.prototype', 'globalThis', 'eval(', 'import('] 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 nodeModules() 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 nodeModules(), 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); const loaded = await this.loadApp(name); if (loaded) 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 nodeModules() 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 - } + 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}`); 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 - } - } + 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 - } + const { resolve, pathToFileURL } = await nodeModules() + try { const absPath = resolve(filePath); const mod = await import(pathToFileURL(absPath).href + `?t=${Date.now()}`); return mod.default || mod } + catch (e) { console.error(`[AppLoader] eval error in "${filePath}": ${e.message}`); return null } } async watchAll() { - const { existsSync, watch, join, basename, extname } = await _nodeModules() + const { existsSync, watch, basename, extname } = await nodeModules() for (const dir of this._dirs) { - if (!existsSync(dir)) { - console.debug(`[AppLoader] skipping watch for missing directory: ${dir}`) - continue - } - try { - const ac = new AbortController() - const 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) - } - } - })() - } catch (e) { - console.error(`[AppLoader] watchAll error:`, e.message) - } + if (!existsSync(dir)) continue + const ac = new AbortController(); const 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('/'), 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) } + })() } } 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() + 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); console.log(`[AppLoader] queued hot reload ${name}`) } } + 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 + 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) - } + const url = URL.createObjectURL(new Blob([deps ? this._rewriteDeps(source, deps, revokes) : source], { type: 'application/javascript' })); 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 + const sub = typeof entry === 'string' ? { source: entry, deps: {} } : entry, subSource = this._rewriteDeps(sub.source, sub.deps || {}, revokes), blob = new Blob([subSource], { type: 'application/javascript' }), 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 - ) + 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..c7c32052 100644 --- a/src/apps/AppRuntime.js +++ b/src/apps/AppRuntime.js @@ -4,7 +4,7 @@ import { EventBus } from './EventBus.js' import { mulQuat, rotVec } from '../math.js' import { MSG } from '../protocol/MessageTypes.js' let _existsSync = null, _resolve = null -try { if (typeof process !== 'undefined' && process.versions?.node) { const fs = await import('node:fs'); const path = await import('node:path'); _existsSync = fs.existsSync; _resolve = path.resolve } } catch {} +try { if (typeof process !== 'undefined' && process.versions?.node) { const fs = await import('node:fs'), path = await import('node:path'); _existsSync = fs.existsSync; _resolve = path.resolve } } catch {} import { SpatialIndex } from '../spatial/Octree.js' import { mixinPhysics } from './AppRuntimePhysics.js' import { mixinTick } from './AppRuntimeTick.js' @@ -12,82 +12,54 @@ import { mixinTick } from './AppRuntimeTick.js' export class AppRuntime { constructor(c = {}) { this.entities = new Map(); this.apps = new Map(); this.contexts = new Map(); this._updateList = []; this._staticVersion = 0; this._dynamicEntityIds = new Set(); this._staticEntityIds = new Set() - this.gravity = c.gravity || [0, -9.81, 0] - this.currentTick = 0; this.deltaTime = 0; this.elapsed = 0 - this._playerManager = c.playerManager || null; this._physics = c.physics || null; this._physicsIntegration = c.physicsIntegration || null - this._connections = c.connections || null; this._stageLoader = c.stageLoader || null + this.gravity = c.gravity || [0, -9.81, 0]; this.currentTick = 0; this.deltaTime = 0; this.elapsed = 0 + this._playerManager = c.playerManager; this._physics = c.physics; this._physicsIntegration = c.physicsIntegration; this._connections = c.connections; this._stageLoader = c.stageLoader this._nextEntityId = 1; this._appDefs = new Map(); this._timers = new Map(); this._interactCooldowns = new Map(); this._respawnTimer = new Map() this._activeDynamicIds = new Set(); this._sleepingDynamicIds = new Set(); this._physicsBodyToEntityId = new Map(); this._suspendedEntityIds = new Set(); this._pendingTrimeshEntities = new Map() - this._physicsLODRadius = c.physicsRadius || 0; this._lagCompensator = c.lagCompensator || null + this._physicsLODRadius = c.physicsRadius || 0; this._lagCompensator = c.lagCompensator const serverTickRate = c.tickRate || 64, entityTickRate = c.entityTickRate || serverTickRate this._entityTickDivisor = Math.max(1, Math.round(serverTickRate / entityTickRate)); this._physicsLODInterval = Math.max(1, Math.round(serverTickRate / 2)) this._playerIndex = new SpatialIndex(); this._collisionEntities = []; this._interactableIds = new Set(); this._playerIndexIds = new Set() this._lastSyncMs = 0; this._lastRespawnMs = 0; this._lastSpatialMs = 0; this._lastCollisionMs = 0; this._lastInteractMs = 0 + this._colGrid = new Map(); this._colGridCells = new Map(); this._colPruneTick = 0 mixinPhysics(this); mixinTick(this); if (this._physics) this._registerPhysicsCallbacks() this._hotReload = new HotReloadQueue(this); this._eventBus = c.eventBus || new EventBus() - this._eventLog = c.eventLog||null; this._storage = c.storage||null; this._sdkRoot = c.sdkRoot||null + this._eventLog = c.eventLog; this._storage = c.storage; this._sdkRoot = c.sdkRoot 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) }) } resolveAssetPath(p) { - if (!p) return p - if (!_resolve) return p.startsWith('./') ? p.slice(1) : p + if (!p) return p; if (!_resolve) return p.startsWith('./') ? p.slice(1) : p const local = _resolve(p); if (_existsSync(local)) return local - if (this._sdkRoot) { const sdk=_resolve(this._sdkRoot,p); if (_existsSync(sdk)) { console.debug(`[SDK-DEFAULT] using bundled asset: ${p}`); return sdk } } + if (this._sdkRoot) { const sdk=_resolve(this._sdkRoot,p); if (_existsSync(sdk)) return sdk } return local } registerApp(name, appDef) { this._appDefs.set(name, appDef) } spawnEntity(id, config = {}) { - const entityId = id || `entity_${this._nextEntityId++}` - const spawnPos = config.position ? [...config.position] : [0, 0, 0] - const entity = { - id: entityId, model: config.model || null, - position: [...spawnPos], - rotation: config.rotation || [0, 0, 0, 1], - scale: config.scale ? [...config.scale] : [1, 1, 1], - velocity: [0, 0, 0], mass: 1, bodyType: 'static', collider: null, - parent: null, children: new Set(), - _appState: null, _appName: config.app || null, _config: config.config || null, custom: null, - _spawnPosition: spawnPos - } - this.entities.set(entityId, entity) - this._staticVersion++ - if (entity.bodyType !== 'static') this._dynamicEntityIds.add(entityId) - else this._staticEntityIds.add(entityId) + const entityId = id || `entity_${this._nextEntityId++}`, spawnPos = config.position ? [...config.position] : [0, 0, 0] + const entity = { id: entityId, model: config.model || null, position: [...spawnPos], rotation: config.rotation || [0, 0, 0, 1], scale: config.scale ? [...config.scale] : [1, 1, 1], velocity: [0, 0, 0], mass: 1, bodyType: 'static', collider: null, parent: null, children: new Set(), _appState: null, _appName: config.app || null, _config: config.config || null, custom: null, _spawnPosition: spawnPos } + this.entities.set(entityId, entity); this._staticVersion++ + if (entity.bodyType !== 'static') this._dynamicEntityIds.add(entityId); else this._staticEntityIds.add(entityId) this._log('entity_spawn', { id: entityId, config }, { sourceEntity: entityId }) - if (config.parent) { - const p = this.entities.get(config.parent) - if (p) { entity.parent = config.parent; p.children.add(entityId) } - } + if (config.parent) { const p = this.entities.get(config.parent); if (p) { entity.parent = config.parent; p.children.add(entityId) } } if (config.autoTrimesh && entity.model && this._physics) { entity.collider = { type: 'trimesh', model: entity.model } - this._physics.addStaticTrimeshAsync(this.resolveAssetPath(entity.model), 0, entity.position || [0,0,0], entity.scale || [1,1,1], entity.rotation || [0,0,0,1]) - .then(id => { entity._physicsBodyId = id }) - .catch(e => console.error(`[AppRuntime] Failed to create trimesh for ${entity.model}:`, e.message)) + this._physics.addStaticTrimeshAsync(this.resolveAssetPath(entity.model), 0, entity.position || [0,0,0], entity.scale || [1,1,1], entity.rotation || [0,0,0,1]).then(id => { entity._physicsBodyId = id }).catch(e => console.error(`[AppRuntime] Failed to create trimesh for ${entity.model}:`, e.message)) } if (config.app) this._attachApp(entityId, config.app).catch(e => console.error(`[AppRuntime] Failed to attach app ${config.app}:`, e.message)) - this._spatialInsert(entity) - return entity + this._spatialInsert(entity); return entity } async _attachApp(entityId, appName) { - const entity = this.entities.get(entityId), appDef = this._appDefs.get(appName) - if (!entity || !appDef) return - const ctx = new AppContext(entity, this) - this.contexts.set(entityId, ctx); this.apps.set(entityId, appDef) - await this._safeCall(appDef.server || appDef, 'setup', [ctx], `setup(${appName})`) - this._scheduleRebuild() - } - - _scheduleRebuild() { - if (this._rebuildScheduled) return - this._rebuildScheduled = true - setImmediate(() => { this._rebuildScheduled = false; this._rebuildUpdateList(); this._rebuildCollisionList() }) + const entity = this.entities.get(entityId), appDef = this._appDefs.get(appName); if (!entity || !appDef) return + const ctx = new AppContext(entity, this); this.contexts.set(entityId, ctx); this.apps.set(entityId, appDef) + await this._safeCall(appDef.server || appDef, 'setup', [ctx], `setup(${appName})`); this._scheduleRebuild() } + _scheduleRebuild() { if (this._rebuildScheduled) return; this._rebuildScheduled = true; setImmediate(() => { this._rebuildScheduled = false; this._rebuildUpdateList(); this._rebuildCollisionList() }) } async attachApp(entityId, appName) { await this._attachApp(entityId, appName) } async spawnWithApp(id, cfg = {}, app) { return await this.spawnEntity(id, { ...cfg, app }) } async attachAppToEntity(eid, app, cfg = {}) { const e = this.getEntity(eid); if (!e) return false; e._config = cfg; await this._attachApp(eid, app); return true } @@ -95,39 +67,21 @@ export class AppRuntime { getEntityWithApp(eid) { const e = this.entities.get(eid); return { entity: e, appName: e?._appName, hasApp: !!e?._appName } } detachApp(entityId) { - const appDef=this.apps.get(entityId), ctx=this.contexts.get(entityId) - if (ctx?._teardownChildren) ctx._teardownChildren() + const appDef=this.apps.get(entityId), ctx=this.contexts.get(entityId); if (ctx?._teardownChildren) ctx._teardownChildren() if (appDef && ctx) this._safeCall(appDef.server||appDef, 'teardown', [ctx], 'teardown') - this._eventBus.destroyScope(entityId); this.clearTimers(entityId); this.apps.delete(entityId); this.contexts.delete(entityId) - this._rebuildUpdateList(); this._rebuildCollisionList() - } - - _rebuildUpdateList() { - this._updateList = [] - for (const [id, ad] of this.apps) { const ctx=this.contexts.get(id); if (!ctx) continue; const s=ad.server||ad; if (typeof s.update==='function') this._updateList.push({id,update:s.update.bind(s),ctx}) } + this._eventBus.destroyScope(entityId); this.clearTimers(entityId); this.apps.delete(entityId); this.contexts.delete(entityId); this._rebuildUpdateList(); this._rebuildCollisionList() } - _rebuildCollisionList() { - this._collisionEntities = [] - for (const [id, ad] of this.apps) { const e=this.entities.get(id); if (!e) continue; const s=ad.server||ad; if (e.collider && typeof s.onCollision==='function') this._collisionEntities.push(e) } - } + _rebuildUpdateList() { this._updateList = []; for (const [id, ad] of this.apps) { const ctx=this.contexts.get(id); if (!ctx) continue; const s=ad.server||ad; if (typeof s.update==='function') this._updateList.push({id,update:s.update.bind(s),ctx}) } } + _rebuildCollisionList() { this._collisionEntities = []; for (const [id, ad] of this.apps) { const e=this.entities.get(id); if (!e) continue; const s=ad.server||ad; if (e.collider && typeof s.onCollision==='function') this._collisionEntities.push(e) } } destroyEntity(entityId) { const entity = this.entities.get(entityId); if (!entity) return - this._staticVersion++ - this._dynamicEntityIds.delete(entityId); this._staticEntityIds.delete(entityId) - this._activeDynamicIds.delete(entityId); this._sleepingDynamicIds.delete(entityId); this._suspendedEntityIds.delete(entityId) - this._interactableIds.delete(entityId) - if (entity._physicsBodyId !== undefined) { - this._physicsBodyToEntityId.delete(entity._physicsBodyId) - if (this._physics) this._physics.removeBody(entity._physicsBodyId) - entity._physicsBodyId = undefined - } - this._log('entity_destroy', { id: entityId }, { sourceEntity: entityId }) - for (const childId of [...entity.children]) this.destroyEntity(childId) + this._staticVersion++; this._dynamicEntityIds.delete(entityId); this._staticEntityIds.delete(entityId); this._activeDynamicIds.delete(entityId); this._sleepingDynamicIds.delete(entityId); this._suspendedEntityIds.delete(entityId); this._interactableIds.delete(entityId) + if (entity._physicsBodyId !== undefined) { this._physicsBodyToEntityId.delete(entity._physicsBodyId); if (this._physics) this._physics.removeBody(entity._physicsBodyId); entity._physicsBodyId = undefined } + this._log('entity_destroy', { id: entityId }, { sourceEntity: entityId }); for (const childId of [...entity.children]) this.destroyEntity(childId) if (entity.parent) { const p = this.entities.get(entity.parent); if (p) p.children.delete(entityId) } - this._eventBus.destroyScope(entityId) - this.detachApp(entityId); this._spatialRemove(entityId); this.entities.delete(entityId) + this._eventBus.destroyScope(entityId); this.detachApp(entityId); this._spatialRemove(entityId); this.entities.delete(entityId) } reparent(entityId, newParentId) { @@ -138,11 +92,9 @@ export class AppRuntime { getWorldTransform(entityId) { const e = this.entities.get(entityId); if (!e) return null - const local = { position: [...e.position], rotation: [...e.rotation], scale: [...e.scale] } - if (!e.parent) return local + const local = { position: [...e.position], rotation: [...e.rotation], scale: [...e.scale] }; if (!e.parent) return local const pt = this.getWorldTransform(e.parent); if (!pt) return local - const sp = [e.position[0]*pt.scale[0], e.position[1]*pt.scale[1], e.position[2]*pt.scale[2]] - const rp = rotVec(sp, pt.rotation) + const sp = [e.position[0]*pt.scale[0], e.position[1]*pt.scale[1], e.position[2]*pt.scale[2]], rp = rotVec(sp, pt.rotation) return { position: [pt.position[0]+rp[0], pt.position[1]+rp[1], pt.position[2]+rp[2]], rotation: mulQuat(pt.rotation, e.rotation), scale: [pt.scale[0]*e.scale[0], pt.scale[1]*e.scale[1], pt.scale[2]*e.scale[2]] } } @@ -153,11 +105,9 @@ export class AppRuntime { getSnapshotForPlayer(pos, r, skipStatic=false) { const e=[], rel=new Set(this.relevantEntities(pos,r)); for (const id of (skipStatic?this._dynamicEntityIds:this.entities.keys())) { const en=this.entities.get(id); if (en&&(rel.has(id)||en._appName==='environment')) e.push(this._encodeEntity(id,en)) } return this._snap(e) } getDynamicEntitiesRaw() { const o=[]; for (const id of this._activeDynamicIds) { const e=this.entities.get(id); if (e) o.push({ id, model:e.model, position:e.position, rotation:e.rotation, velocity:e.velocity, bodyType:e.bodyType, custom:e.custom, _isEnv:e._appName==='environment', _sleeping:false }) } for (const id of this._sleepingDynamicIds) o.push({ id, _sleeping:true }); for (const id of this._suspendedEntityIds) o.push({ id, _sleeping:true }); return o } getRelevantDynamicIds(pos, r) { return this.relevantEntities(pos, r) } - getSceneGraph() { const n=[]; for (const [id,e] of this.entities) if (!e.parent&&e._appName) n.push(this._buildNode(id,e)); return n } _buildNode(id, e) { const r1=v=>Math.round(v*10)/10; return { id, appName:e._appName, label:e._config?.label||e._appName||id, position:e.position?[r1(e.position[0]),r1(e.position[1]),r1(e.position[2])]:null, children:[...e.children].map(cid=>this._buildNode(cid,this.entities.get(cid))).filter(Boolean) } } - - queryEntities(f) { const r = []; for (const e of this.entities.values()) { if (!f || f(e)) r.push(e) } return r } + queryEntities(f) { const r = []; for (const e of this.entities.values()) if (!f || f(e)) r.push(e); return r } getEntity(id) { return this.entities.get(id) || null } fireEvent(eid, en, ...a) { const ad = this.apps.get(eid), c = this.contexts.get(eid); if (!ad || !c) return; this._log('app_event', { entityId: eid, event: en, args: a }, { sourceEntity: eid }); const s = ad.server || ad; if (s[en]) this._safeCall(s, en, [c, ...a], `${en}(${eid})`) } fireInteract(eid, p) { this.fireEvent(eid, 'onInteract', p) } diff --git a/src/apps/AppRuntimeTick.js b/src/apps/AppRuntimeTick.js index 00f8034e..f0e1b238 100644 --- a/src/apps/AppRuntimeTick.js +++ b/src/apps/AppRuntimeTick.js @@ -1,3 +1,5 @@ +import { tickCollisions } from './AppCollisionTick.js' + const _PROFILE = typeof process !== 'undefined' && !!process.env?.GM_PROFILE export function mixinTick(runtime) { @@ -12,43 +14,25 @@ export function mixinTick(runtime) { } this._tickTimers(dt) if (!_PROFILE) { - // Fast path: skip the ~6 per-tick performance.now() calls used only for the - // keyframe profile log (enable with GM_PROFILE env var to measure). - this._syncDynamicBodies() - const players = this.getPlayers() + this._syncDynamicBodies(); const players = this.getPlayers() if (tickNum % this._physicsLODInterval === 0) this._tickPhysicsLOD(players) - this._tickRespawn() - this._spatialSync(); this._syncPlayerIndex() - this._tickCollisions() - this._tickInteractables() + this._tickRespawn(); this._spatialSync(); this._syncPlayerIndex(); this._tickCollisions(); this._tickInteractables() return } - const _ts0 = performance.now() - this._syncDynamicBodies() - const players = this.getPlayers() + const _ts0 = performance.now(); this._syncDynamicBodies(); const players = this.getPlayers() if (tickNum % this._physicsLODInterval === 0) this._tickPhysicsLOD(players) this._lastSyncMs = performance.now() - _ts0 - const _ts1 = performance.now() - this._tickRespawn() - this._lastRespawnMs = performance.now() - _ts1 - const _ts2 = performance.now() - this._spatialSync() - this._syncPlayerIndex() - this._lastSpatialMs = performance.now() - _ts2 - const _ts3 = performance.now() - this._tickCollisions() - this._lastCollisionMs = performance.now() - _ts3 - const _ts4 = performance.now() - this._tickInteractables() - this._lastInteractMs = performance.now() - _ts4 + const _ts1 = performance.now(); this._tickRespawn(); this._lastRespawnMs = performance.now() - _ts1 + const _ts2 = performance.now(); this._spatialSync(); this._syncPlayerIndex(); this._lastSpatialMs = performance.now() - _ts2 + const _ts3 = performance.now(); this._tickCollisions(); this._lastCollisionMs = performance.now() - _ts3 + const _ts4 = performance.now(); this._tickInteractables(); this._lastInteractMs = performance.now() - _ts4 } runtime._tickTimers = function(dt) { for (const [eid, timers] of this._timers) { let writeIdx = 0 for (let i = 0; i < timers.length; i++) { - const t = timers[i] - t.remaining -= dt + const t = timers[i]; t.remaining -= dt if (t.remaining <= 0) { try { t.fn() } catch (e) { console.error(`[AppRuntime] timer(${eid}):`, e.message) }; if (t.repeat) { t.remaining = t.interval; timers[writeIdx++] = t } } else timers[writeIdx++] = t } @@ -57,72 +41,7 @@ export function mixinTick(runtime) { } } - runtime._colR = function(c) { - if (!c) return 0 - if (c._cachedRadius !== undefined) return c._cachedRadius - let r = 1 - if (c.type === 'sphere') r = c.radius || 1 - else if (c.type === 'capsule') r = Math.max(c.radius || 0.5, (c.height || 1) / 2) - else if (c.type === 'box') { const s = c.size, h = c.halfExtents; r = Array.isArray(s) ? Math.max(...s) : typeof s === 'number' ? s : Array.isArray(h) ? Math.max(...h) : 1 } - c._cachedRadius = r; return r - } - - const _colGrid = new Map() - const _colGridCells = new Map() - const _COL_GRID_THRESHOLD = 100 - const _COL_CELL_SZ = 4 - let _colPruneTick = 0 - - runtime._tickCollisions = function() { - const c = this._collisionEntities; if (c.length === 0) return - for (let i = 0; i < c.length; i++) c[i]._cachedColR = this._colR(c[i].collider) - if (c.length < _COL_GRID_THRESHOLD) this._tickCollisionsBrute(c); else this._tickCollisionsGrid(c) - } - - runtime._tickCollisionsBrute = function(c) { - for (let i = 0; i < c.length; i++) { - const a = c[i], ar = a._cachedColR, ax = a.position[0], ay = a.position[1], az = a.position[2] - for (let j = i + 1; j < c.length; j++) { - const b = c[j], dx = b.position[0]-ax, dy = b.position[1]-ay, dz = b.position[2]-az - const rr = ar + b._cachedColR - if (dx*dx+dy*dy+dz*dz < rr*rr) { - this.fireEvent(a.id, 'onCollision', { id: b.id, position: b.position, velocity: b.velocity }) - this.fireEvent(b.id, 'onCollision', { id: a.id, position: a.position, velocity: a.velocity }) - } - } - } - } - - runtime._tickCollisionsGrid = function(c) { - _colGrid.clear() - if ((++_colPruneTick & 63) === 0 || _colGridCells.size > c.length * 4) { - for (const k of _colGridCells.keys()) { if (!_colGrid.has(k)) _colGridCells.delete(k) } - } - for (let i = 0; i < c.length; i++) { - const e = c[i] - const key = Math.floor(e.position[0] / _COL_CELL_SZ) * 65536 + Math.floor(e.position[2] / _COL_CELL_SZ) - let cell = _colGrid.get(key) - if (!cell) { cell = _colGridCells.get(key); if (!cell) { cell = []; _colGridCells.set(key, cell) } else { cell.length = 0 }; _colGrid.set(key, cell) } - cell.push(e) - } - for (let i = 0; i < c.length; i++) { - const a = c[i], ar = a._cachedColR, ax = a.position[0], ay = a.position[1], az = a.position[2] - const acx = Math.floor(ax / _COL_CELL_SZ), acz = Math.floor(az / _COL_CELL_SZ) - for (let ddx = -1; ddx <= 1; ddx++) for (let ddz = -1; ddz <= 1; ddz++) { - const cell = _colGrid.get((acx + ddx) * 65536 + (acz + ddz)) - if (!cell) continue - for (const b of cell) { - if (b.id <= a.id) continue - const dx = b.position[0]-ax, dy = b.position[1]-ay, dz = b.position[2]-az - const rr = ar + b._cachedColR - if (dx*dx+dy*dy+dz*dz < rr*rr) { - this.fireEvent(a.id, 'onCollision', { id: b.id, position: b.position, velocity: b.velocity }) - this.fireEvent(b.id, 'onCollision', { id: a.id, position: a.position, velocity: a.velocity }) - } - } - } - } - } + runtime._tickCollisions = function() { tickCollisions(this, this._collisionEntities) } runtime._tickRespawn = function() { const now = Date.now() @@ -135,26 +54,18 @@ export function mixinTick(runtime) { const spawnPos = e._spawnPosition || [0, 20, 0] e.position[0] = spawnPos[0]; e.position[1] = spawnPos[1]; e.position[2] = spawnPos[2] e.velocity[0] = 0; e.velocity[1] = 0; e.velocity[2] = 0 - if (e._physicsBodyId !== undefined && this._physics) { - this._physics.setBodyPosition(e._physicsBodyId, spawnPos) - this._physics.setBodyVelocity(e._physicsBodyId, [0, 0, 0]) - } + if (e._physicsBodyId !== undefined && this._physics) { this._physics.setBodyPosition(e._physicsBodyId, spawnPos); this._physics.setBodyVelocity(e._physicsBodyId, [0, 0, 0]) } timer.startTime = now; timer.lastRespawn = now } - } else { - this._respawnTimer.delete(id) - } + } else this._respawnTimer.delete(id) } } let _interactPruneTick = 0 - runtime._tickInteractables = function() { if (this._interactableIds.size === 0) return const now = Date.now() - if ((++_interactPruneTick & 255) === 0 && this._interactCooldowns.size > 100) { - for (const [k, v] of this._interactCooldowns) { if (now - v > 10000) this._interactCooldowns.delete(k) } - } + if ((++_interactPruneTick & 255) === 0 && this._interactCooldowns.size > 100) { for (const [k, v] of this._interactCooldowns) if (now - v > 10000) this._interactCooldowns.delete(k) } const players = this.getPlayers() for (const id of this._interactableIds) { const e = this.entities.get(id); if (!e || !e._interactable) continue @@ -162,12 +73,9 @@ export function mixinTick(runtime) { const pp = p.state?.position; if (!pp) continue const dx = pp[0]-e.position[0], dy = pp[1]-e.position[1], dz = pp[2]-e.position[2] const ir = e._interactRadius; if (dx*dx+dy*dy+dz*dz > ir*ir) continue - const key = e.id + ':' + p.id - const last = this._interactCooldowns.get(key) || 0 - const cooldown = e._interactCooldown ?? 500 + const key = e.id + ':' + p.id, last = this._interactCooldowns.get(key) || 0, cooldown = e._interactCooldown ?? 500 if (p.lastInput?.interact && now - last > cooldown) { - this._interactCooldowns.set(key, now) - this.fireEvent(e.id, 'onInteract', p) + this._interactCooldowns.set(key, now); this.fireEvent(e.id, 'onInteract', p) const bus = this._eventBus.scope ? this._eventBus : null if (bus) bus.emit(`interact.${e.id}`, { player: p, entity: e }) } @@ -176,34 +84,22 @@ export function mixinTick(runtime) { } runtime._syncPlayerIndex = function() { - const players = this.getPlayers() - const ids = this._playerIndexIds - ids.clear() - for (const p of players) { - const pos = p.state?.position - if (pos) this._playerIndex.update(p.id, pos) - ids.add(p.id) - } + const players = this.getPlayers(), ids = this._playerIndexIds; ids.clear() + for (const p of players) { const pos = p.state?.position; if (pos) this._playerIndex.update(p.id, pos); ids.add(p.id) } if (this._playerIndex.size > players.length) { - const toRemove = [] - for (const id of this._playerIndex._entities.keys()) { - if (!ids.has(id)) toRemove.push(id) - } + const toRemove = []; for (const id of this._playerIndex._entities.keys()) if (!ids.has(id)) toRemove.push(id) for (const id of toRemove) this._playerIndex.remove(id) } } const _nearbyIdSet = new Set() - runtime.getNearbyPlayers = function(viewerPosition, radius, allPlayers) { if (!allPlayers || allPlayers.length === 0) return [] if (this._playerIndex.size === 0) { - const cx = viewerPosition[0], cy = viewerPosition[1], cz = viewerPosition[2] - const r2 = radius * radius + const cx = viewerPosition[0], cy = viewerPosition[1], cz = viewerPosition[2], r2 = radius * radius return allPlayers.filter(p => { const dx=p.position[0]-cx,dy=p.position[1]-cy,dz=p.position[2]-cz; return dx*dx+dy*dy+dz*dz<=r2 }) } - _nearbyIdSet.clear() - const ids = this._playerIndex.nearby(viewerPosition, radius) + _nearbyIdSet.clear(); const ids = this._playerIndex.nearby(viewerPosition, radius) for (let i = 0; i < ids.length; i++) _nearbyIdSet.add(ids[i]) return allPlayers.filter(p => _nearbyIdSet.has(p.id)) } diff --git a/src/apps/HotReloadQueue.js b/src/apps/HotReloadQueue.js index a99000fa..25208370 100644 --- a/src/apps/HotReloadQueue.js +++ b/src/apps/HotReloadQueue.js @@ -2,14 +2,10 @@ import { AppContext } from './AppContext.js' export class HotReloadQueue { constructor(runtime) { - this._runtime = runtime - this._queue = [] - this._inProgress = false + this._runtime = runtime; this._queue = []; this._inProgress = false } - enqueue(name, def, callback) { - this._queue.push({ name, def, callback }) - } + enqueue(name, def, callback) { this._queue.push({ name, def, callback }) } drain() { if (this._inProgress || this._queue.length === 0) return @@ -20,18 +16,10 @@ export class HotReloadQueue { try { this._execute(name, def) this._resetHeartbeats() - if (callback) { - try { callback(name, def) } catch (e) { - console.error(`[HotReloadQueue] callback error:`, e.message) - } - } - } catch (e) { - console.error(`[HotReloadQueue] hotReload(${name}) error:`, e.message) - } + if (callback) try { callback(name, def) } catch (e) { console.error(`[HotReloadQueue] callback error:`, e.message) } + } catch (e) { console.error(`[HotReloadQueue] hotReload(${name}) error:`, e.message) } } - } finally { - this._inProgress = false - } + } finally { this._inProgress = false } } _execute(name, def) { @@ -40,24 +28,20 @@ export class HotReloadQueue { for (const [eid, ent] of rt.entities) { if (ent._appName !== name) continue const old = rt.apps.get(eid), oldCtx = rt.contexts.get(eid) + const oldState = oldCtx ? { ...oldCtx.state } : {} if (old && oldCtx) rt._safeCall(old.server || old, 'teardown', [oldCtx], 'teardown') rt.clearTimers(eid) const ctx = new AppContext(ent, rt) - rt.contexts.set(eid, ctx) - rt.apps.set(eid, def) + if (Object.keys(oldState).length > 0) ctx.state = oldState + rt.contexts.set(eid, ctx); rt.apps.set(eid, def) rt._safeCall(def.server || def, 'setup', [ctx], `hotReload(${name})`) } } _resetHeartbeats() { - const conn = this._runtime._connections - if (!conn) return - for (const client of conn.clients.values()) { - client.lastHeartbeat = Date.now() - } + const conn = this._runtime._connections; if (!conn) return + for (const client of conn.clients.values()) client.lastHeartbeat = Date.now() } - get pending() { - return this._queue.length - } + get pending() { return this._queue.length } } diff --git a/src/apps/NodeModules.js b/src/apps/NodeModules.js new file mode 100644 index 00000000..ab1e8b24 --- /dev/null +++ b/src/apps/NodeModules.js @@ -0,0 +1,7 @@ +let _nm = null +export 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 +} diff --git a/src/netcode/EncoderUtils.js b/src/netcode/EncoderUtils.js new file mode 100644 index 00000000..1cb7fe66 --- /dev/null +++ b/src/netcode/EncoderUtils.js @@ -0,0 +1,30 @@ +import { packQuat, unpackQuat } from './QuatPacker.js' + +const Q1=100 +const VEL_ZERO = [0,0,0] +const SCALE_ONE = [1,1,1] + +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, 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 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) : '' } diff --git a/src/netcode/QuatPacker.js b/src/netcode/QuatPacker.js new file mode 100644 index 00000000..2053ed14 --- /dev/null +++ b/src/netcode/QuatPacker.js @@ -0,0 +1,28 @@ +const QSCALE = 511 * Math.SQRT2 +const QUAT_IDX = [[1,2,3],[0,2,3],[0,1,3],[0,1,2]] + +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 + let packed = maxIdx + for (let i = 0; i < 4; i++) { + if (i === maxIdx) continue + 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 = QUAT_IDX[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 +} diff --git a/src/netcode/SnapshotEncoder.js b/src/netcode/SnapshotEncoder.js index 56a11329..c5be2712 100644 --- a/src/netcode/SnapshotEncoder.js +++ b/src/netcode/SnapshotEncoder.js @@ -1,71 +1,13 @@ -const Q1=100 -const VEL_ZERO = [0,0,0] -const SCALE_ONE = [1,1,1] -const QSCALE = 511 * Math.SQRT2 -const QUAT_IDX = [[1,2,3],[0,2,3],[0,1,3],[0,1,2]] +import { packQuat, unpackQuat } from './QuatPacker.js' +import { encodePlayer, fillEntityEnc, buildEntityKey, custToStr } from './EncoderUtils.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 - let packed = maxIdx - for (let i = 0; i < 4; i++) { - if (i === maxIdx) continue - 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 = QUAT_IDX[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 encodeEntity(e) { return fillEntityEnc(e, new Array(15)) } 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 + entry.cust = cust; entry.k = buildEntityKey(entry.enc, entry.custStr); entry._dirty = false return entry.k } @@ -77,7 +19,6 @@ function buildEntry(e, id, prevCache, sleeping) { } const CLOSE2 = 20 * 20 - function applyEntry(id, entry, nextMap, entities, prevEntityMap, useDistTier, vx, vy, vz) { const k = resolveKey(entry) if (useDistTier && !entry.isEnv) { @@ -89,69 +30,45 @@ 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)) - 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 encodePlayersOnce(players) { 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 = [] + 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 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 enc = encodeEntity(e), prev = prevStaticMap.get(e.id), cust = enc[10] + const custStr = (prev && prev[1] === cust) ? prev[2] : custToStr(cust), 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) { @@ -162,7 +79,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) @@ -170,56 +86,40 @@ export class SnapshotEncoder { const useDistTier = seqNum !== undefined && viewerPos && seqNum % 2 !== 0 const relevantCount = Array.isArray(relevantIds) ? relevantIds.length : (relevantIds ? relevantIds.size : 0) const iterIds = (relevantIds && dynCache.size > relevantCount) ? relevantIds : null - const relevantLookup = (!iterIds && Array.isArray(relevantIds)) ? new Set(relevantIds) : null + const relevantLookup = (relevantIds && !iterIds) ? (Array.isArray(relevantIds) ? new Set(relevantIds) : relevantIds) : null if (iterIds) { for (const id of iterIds) { const entry = dynCache.get(id); if (entry) applyEntry(id, entry, nextMap, entities, prevEntityMap, useDistTier, vx, vy, vz) } for (const id of (dynCache._envIds || [])) { const entry = dynCache.get(id); if (entry) applyEntry(id, entry, nextMap, entities, prevEntityMap, false, 0, 0, 0) } } else { for (const [id, entry] of dynCache) { - if (!entry.isEnv && relevantIds && (relevantLookup ? !relevantLookup.has(id) : !relevantIds.has(id))) continue + if (!entry.isEnv && relevantLookup && !relevantLookup.has(id)) continue applyEntry(id, entry, nextMap, entities, prevEntityMap, useDistTier, vx, vy, vz) } } let removed = precomputedRemoved - if (!removed) { removed = []; for (const id of prevEntityMap.keys()) { if (!dynCache.has(id) && !(staticEntityIds && staticEntityIds.has(id))) removed.push(id) } } + if (!removed) { + removed = []; for (const id of prevEntityMap.keys()) { if (!nextMap.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 } } - static encodeDelta(snapshot, prevEntityMap, preEncodedPlayers, staticEntries, staticMap, staticIds) { - const players = preEncodedPlayers || (snapshot.players || []).map(encodePlayer) - const dynIds = new Set(), entities = [], nextMap = new Map() + const players = preEncodedPlayers || (snapshot.players || []).map(encodePlayer), dynIds = new Set(), entities = [], nextMap = new Map() if (staticEntries) for (const { enc } of staticEntries) entities.push(enc) for (const e of snapshot.entities || []) { if (e.bodyType === 'static' && staticEntries) continue const encoded = encodeEntity(e); dynIds.add(e.id) - const prev = prevEntityMap.get(e.id), cust = encoded[10] - const custStr = (prev && prev[1] === cust) ? prev[2] : custToStr(cust) - const k = buildEntityKey(encoded, custStr); nextMap.set(e.id, [k, cust, custStr]) - if (!prev || prev[0] !== k) entities.push(encoded) + const prev = prevEntityMap.get(e.id), cust = encoded[10], custStr = (prev && prev[1] === cust) ? prev[2] : custToStr(cust), k = buildEntityKey(encoded, custStr) + nextMap.set(e.id, [k, cust, custStr]); if (!prev || prev[0] !== k) entities.push(encoded) } - const removed = []; for (const id of prevEntityMap.keys()) { if (!dynIds.has(id) && !(staticIds && staticIds.has(id))) removed.push(id) } + const removed = []; for (const id of prevEntityMap.keys()) { if (!nextMap.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 encode(snapshot) { return { tick: snapshot.tick || 0, serverTime: snapshot.serverTime, players: (snapshot.players || []).map(encodePlayer), entities: (snapshot.entities || []).map(encodeEntity) } } 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 } - }) + 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/netcode/TickSystem.js b/src/netcode/TickSystem.js index ca0f4408..40a192be 100644 --- a/src/netcode/TickSystem.js +++ b/src/netcode/TickSystem.js @@ -1,123 +1,42 @@ const _isNode = typeof process !== 'undefined' && process.versions?.node const { createMachine, createActor } = await import(_isNode ? 'xstate' : '/node_modules/xstate/dist/xstate.esm.js') -const machine = createMachine({ - id: 'tick', - initial: 'stopped', - states: { - stopped: { on: { START: 'running' } }, - running: { on: { STOP: 'stopped', PAUSE: 'paused' } }, - paused: { on: { RESUME: 'running', STOP: 'stopped' } } - } -}) - -const DILATION_WINDOW = 60 -const DILATION_THRESHOLD = 0.85 -const DILATION_MIN = 0.1 -const DILATION_STEP = 0.05 +const machine = createMachine({ id: 'tick', initial: 'stopped', states: { stopped: { on: { START: 'running' } }, running: { on: { STOP: 'stopped', PAUSE: 'paused' } }, paused: { on: { RESUME: 'running', STOP: 'stopped' } } } }) +const DILATION_WINDOW = 60, DILATION_THRESHOLD = 0.85, DILATION_MIN = 0.1, DILATION_STEP = 0.05 export class TickSystem { constructor(tickRate = 128) { - this.tickRate = tickRate - this.tickDuration = 1000 / tickRate - this.currentTick = 0 - this.lastTickTime = 0 - this.callbacks = [] - this._actor = createActor(machine) - this._actor.start() - this._reloadResolve = null - this._tickInProgress = false - this.dilationFactor = 1.0 - this._dilationCallbacks = [] - this._tickBudgetMs = [] - this._tickBudgetSum = 0 + this.tickRate = tickRate; this.tickDuration = 1000 / tickRate; this.currentTick = 0; this.lastTickTime = 0; this.callbacks = []; this._actor = createActor(machine); this._actor.start(); this._reloadResolve = null; this._tickInProgress = false; this.dilationFactor = 1.0; this._dilationCallbacks = []; this._tickBudgetMs = []; this._tickBudgetSum = 0 } - get running() { return this._actor.getSnapshot().value === 'running' } - onDilation(cb) { this._dilationCallbacks.push(cb) } - _measureTick(budget) { - this._tickBudgetMs.push(budget) - this._tickBudgetSum += budget - if (this._tickBudgetMs.length > DILATION_WINDOW) { - this._tickBudgetSum -= this._tickBudgetMs.shift() - } + this._tickBudgetMs.push(budget); this._tickBudgetSum += budget; if (this._tickBudgetMs.length > DILATION_WINDOW) this._tickBudgetSum -= this._tickBudgetMs.shift() if (this._tickBudgetMs.length < DILATION_WINDOW) return - const avgMs = this._tickBudgetSum / DILATION_WINDOW - const load = avgMs / (this.tickDuration * this.dilationFactor) - if (load > DILATION_THRESHOLD && this.dilationFactor > DILATION_MIN) { - this.dilationFactor = Math.max(DILATION_MIN, +(this.dilationFactor - DILATION_STEP).toFixed(2)) - for (const cb of this._dilationCallbacks) try { cb(this.dilationFactor) } catch (_) {} - } else if (load < DILATION_THRESHOLD * 0.7 && this.dilationFactor < 1.0) { - this.dilationFactor = Math.min(1.0, +(this.dilationFactor + DILATION_STEP).toFixed(2)) - for (const cb of this._dilationCallbacks) try { cb(this.dilationFactor) } catch (_) {} - } + const load = (this._tickBudgetSum / DILATION_WINDOW) / (this.tickDuration * this.dilationFactor) + if (load > DILATION_THRESHOLD && this.dilationFactor > DILATION_MIN) { this.dilationFactor = Math.max(DILATION_MIN, +(this.dilationFactor - DILATION_STEP).toFixed(2)); for (const cb of this._dilationCallbacks) try { cb(this.dilationFactor) } catch (_) {} } + else if (load < DILATION_THRESHOLD * 0.7 && this.dilationFactor < 1.0) { this.dilationFactor = Math.min(1.0, +(this.dilationFactor + DILATION_STEP).toFixed(2)); for (const cb of this._dilationCallbacks) try { cb(this.dilationFactor) } catch (_) {} } } - - onTick(callback) { - this.callbacks.push(callback) - } - - start() { - if (this.running) return - this._actor.send({ type: 'START' }) - this.lastTickTime = Date.now() - this.loop() - } - + onTick(callback) { this.callbacks.push(callback) } + start() { if (this.running) return; this._actor.send({ type: 'START' }); this.lastTickTime = Date.now(); this.loop() } loop() { if (!this.running) return - const now = Date.now() - let elapsed = now - this.lastTickTime - let steps = 0 - const maxSteps = 4 - const isPaused = this._actor.getSnapshot().value === 'paused' - const dilatedDuration = this.tickDuration * this.dilationFactor - while (elapsed >= this.tickDuration && !isPaused && steps < maxSteps) { - this._tickInProgress = true - this.currentTick++ - this.lastTickTime += this.tickDuration - const t0 = performance.now() - for (const callback of this.callbacks) { - callback(this.currentTick, dilatedDuration / 1000) - } - this._measureTick(performance.now() - t0) - this._tickInProgress = false - if (this._reloadResolve) { - this._reloadResolve() - this._reloadResolve = null + try { + const now = Date.now(); let elapsed = now - this.lastTickTime, steps = 0, maxSteps = 4, isPaused = this._actor.getSnapshot().value === 'paused', dilatedDuration = this.tickDuration * this.dilationFactor + while (elapsed >= this.tickDuration && !isPaused && steps < maxSteps) { + this._tickInProgress = true; this.currentTick++; this.lastTickTime += this.tickDuration; const t0 = performance.now() + for (const callback of this.callbacks) { try { callback(this.currentTick, dilatedDuration / 1000) } catch (e) { console.error('[TickSystem] callback error:', e.message) } } + this._measureTick(performance.now() - t0); this._tickInProgress = false + if (this._reloadResolve) { this._reloadResolve(); this._reloadResolve = null } + elapsed = now - this.lastTickTime; steps++ } - elapsed = now - this.lastTickTime - steps++ - } - if (now - this.lastTickTime > this.tickDuration * maxSteps) { - this.lastTickTime = now - } - const gap = this.tickDuration - (Date.now() - this.lastTickTime) - if (gap > 2) setTimeout(() => this.loop(), 1) - else setImmediate(() => this.loop()) - } - - pauseForReload() { - this._actor.send({ type: 'PAUSE' }) - if (!this._tickInProgress) return Promise.resolve() - return new Promise(resolve => { this._reloadResolve = resolve }) - } - - resumeAfterReload() { - this._actor.send({ type: 'RESUME' }) - } - - stop() { - this._actor.send({ type: 'STOP' }) - } - - getTick() { - return this.currentTick - } - - getTickDuration() { - return (this.tickDuration * this.dilationFactor) / 1000 - } + if (now - this.lastTickTime > this.tickDuration * maxSteps) this.lastTickTime = now + } catch (e) { console.error('[TickSystem] critical loop error:', e.message) } + const gap = this.tickDuration - (Date.now() - this.lastTickTime); if (gap > 2) setTimeout(() => this.loop(), 1); else if (typeof setImmediate !== 'undefined') setImmediate(() => this.loop()); else setTimeout(() => this.loop(), 0) + } + pauseForReload() { this._actor.send({ type: 'PAUSE' }); if (!this._tickInProgress) return Promise.resolve(); return new Promise(resolve => { this._reloadResolve = resolve }) } + resumeAfterReload() { this._actor.send({ type: 'RESUME' }) } + stop() { this._actor.send({ type: 'STOP' }) } + getTick() { return this.currentTick } + getTickDuration() { return (this.tickDuration * this.dilationFactor) / 1000 } } diff --git a/src/physics/BodyManager.js b/src/physics/BodyManager.js new file mode 100644 index 00000000..afb663d2 --- /dev/null +++ b/src/physics/BodyManager.js @@ -0,0 +1,34 @@ +const LAYER_STATIC = 0, LAYER_DYNAMIC = 1 + +export class BodyManager { + constructor(J, bodyInterface, bodies, bodyMeta, bodyIds) { + this.J = J; this.bodyInterface = bodyInterface + this.bodies = bodies; this.bodyMeta = bodyMeta; this.bodyIds = bodyIds + } + + addBody(shape, position, motionType, layer, opts = {}) { + const J = this.J + 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 + } + + 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) + } +} diff --git a/src/physics/RaycastManager.js b/src/physics/RaycastManager.js new file mode 100644 index 00000000..c7985b71 --- /dev/null +++ b/src/physics/RaycastManager.js @@ -0,0 +1,27 @@ +export class RaycastManager { + constructor(J, jolt, physicsSystem, bodies) { + this.J = J; this.jolt = jolt; this.physicsSystem = physicsSystem; this.bodies = bodies + } + + raycast(origin, direction, maxDistance = 1000, excludeBodyId = null) { + if (!this.physicsSystem) return { hit: false, distance: maxDistance, body: null, position: null } + const J = this.J, LAYER_DYNAMIC = 1 + 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) + let result + if (col.HadHit()) { + const dist = col.get_mHit().mFraction * maxDistance + result = { hit: true, distance: dist, body: null, position: [origin[0]+dir[0]*dist, origin[1]+dir[1]*dist, origin[2]+dir[2]*dist] } + } else result = { 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 result + } +} diff --git a/src/physics/World.js b/src/physics/World.js index ae04931c..196244d8 100644 --- a/src/physics/World.js +++ b/src/physics/World.js @@ -1,232 +1,179 @@ -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) + this._bodyMgr = new BodyManager(J, this.bodyInterface, this.bodies, this.bodyMeta, this.bodyIds) + this._rayMgr = new RaycastManager(J, this.jolt, this.physicsSystem, this.bodies) + return this + } + + _addBody(shape, position, motionType, layer, opts = {}) { return this._bodyMgr.addBody(shape, position, motionType, layer, opts) } + removeBody(id) { this._bodyMgr.removeBody(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, 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 ptr = J.getPointer(heights.data()) + if (ptr) { J.HEAPF32.set(samples instanceof Float32Array ? samples : Float32Array.from(samples), 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 id = this._addBody(sr.Get(), 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 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, playerSnap = networkState.getSnapshot(), playerCount = players.length, snapGroups = Math.max(1, Math.ceil(playerCount / 50)), curGroup = tick % snapGroups + const activeStage = stageLoader ? stageLoader.getActiveStage() : null, 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(), { staticEntries, changedEntries, staticMap, staticChanged } = SnapshotEncoder.encodeStaticEntities(staticSnap.entities, isKeyframe ? new Map() : state.staticEntityMap) + 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) { + if (state.prevDynCache === null) state.prevDynCache = SnapshotEncoder.buildDynamicCache(appRuntime._activeDynamicIds, appRuntime._sleepingDynamicIds, appRuntime._suspendedEntityIds, appRuntime.entities, state.prevDynCache) + else SnapshotEncoder.refreshDynamicCache(state.prevDynCache, appRuntime._activeDynamicIds, appRuntime.entities) + dynCache = state.prevDynCache + } + const isNewPlayer = !playerEntityMaps.has(player.id), viewerPos = player.state.position, 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), prevMap = isNewPlayer ? new Map() : playerEntityMaps.get(player.id) + const relevantIds = isNewPlayer ? cached.relevantIds : getPlayerPriorityIds(player.id, cached.relevantIds, dynCache, viewerPos), { 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 combined = { tick: playerSnap.tick, players: playerSnap.players, entities: appRuntime.getSnapshot().entities, serverTime: serverNow } + const { encoded, entityMap } = SnapshotEncoder.encodeDelta(combined, (isKeyframe || state.broadcastEntityMap.size === 0) ? new Map() : state.broadcastEntityMap); 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 clearPriorityAccumulator(id) { _priorityAccumulators.delete(id) } diff --git a/src/sdk/TickHandler.js b/src/sdk/TickHandler.js index 0b765c7a..449716b0 100644 --- a/src/sdk/TickHandler.js +++ b/src/sdk/TickHandler.js @@ -1,206 +1,61 @@ -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 { SnapshotEncoder } from '../netcode/SnapshotEncoder.js' +import { applyMovement as _applyMovement, DEFAULT_MOVEMENT as _DEFAULT_MOVEMENT } from '../shared/movement.js' +import { applyPlayerCollisions } from '../netcode/CollisionSystem.js' +import { buildAndSendSnapshots, clearPriorityAccumulator } from './SnapshotHandler.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[1] = _lastSinHalf; 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], hasInput = inp && (inp.forward || inp.backward || inp.left || inp.right || inp.jump) + const isIdle = !hasInput && st.onGround && wishedVx * wishedVx + wishedVz * wishedVz < 1e-4, 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, movement = { ...(_movement?.DEFAULT_MOVEMENT || _DEFAULT_MOVEMENT), ...m } + const mvDeps = { playerManager, physicsIntegration, lagCompensator, networkState, applyMovement, movement }, 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); clearPriorityAccumulator(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 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} 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/test.js b/test.js deleted file mode 100644 index 23144aee..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(new URL('./apps/world/index.js', import.meta.url)) - 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)