diff --git a/src/apps/AppRuntime.js b/src/apps/AppRuntime.js index 9dc66263..cceb473e 100644 --- a/src/apps/AppRuntime.js +++ b/src/apps/AppRuntime.js @@ -1,188 +1,68 @@ -import { AppContext } from './AppContext.js' import { HotReloadQueue } from './HotReloadQueue.js' 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 {} import { SpatialIndex } from '../spatial/Octree.js' import { mixinPhysics } from './AppRuntimePhysics.js' import { mixinTick } from './AppRuntimeTick.js' +import { mixinAppLifeCycle } from './AppRuntimeLifeCycle.js' + +let _fs = null, _path = null +try { if (typeof process !== 'undefined' && process.versions?.node) { _fs = await import('node:fs'); _path = await import('node:path') } } catch {} 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.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._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._connections = c.connections || null; this._stageLoader = c.stageLoader || null; 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 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 - 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 + mixinPhysics(this); mixinTick(this); mixinAppLifeCycle(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._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 - 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 } } - return local + if (!p || !_path) return p + const local = _path.resolve(p); if (_fs.existsSync(local)) return local + if (this._sdkRoot) { const sdk=_path.resolve(this._sdkRoot,p); if (_fs.existsSync(sdk)) return sdk } + return p } 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) - 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.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)) - } - 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 - } - - 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() + const eid = id || `entity_${this._nextEntityId++}`, sp = config.position ? [...config.position] : [0, 0, 0] + const e = { id: eid, model: config.model || null, position: [...sp], rotation: config.rotation || [0, 0, 0, 1], scale: config.scale ? [...config.scale] : [1, 1, 1], velocity: [0, 0, 0], bodyType: 'static', parent: config.parent || null, children: new Set(), _appName: config.app || null, _config: config.config || null, _spawnPosition: sp } + this.entities.set(eid, e); this._staticVersion++; if (e.bodyType !== 'static') this._dynamicEntityIds.add(eid); else this._staticEntityIds.add(eid) + if (config.parent) { const p = this.entities.get(config.parent); if (p) p.children.add(eid) } + if (config.autoTrimesh && e.model && this._physics) this._physics.addStaticTrimeshAsync(this.resolveAssetPath(e.model), 0, e.position, e.scale, e.rotation).then(id => { e._physicsBodyId = id }) + if (config.app) this._attachApp(eid, config.app); this._spatialInsert(e); return e } - _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 } - async reattachAppToEntity(eid, app) { this.detachApp(eid); await this._attachApp(eid, app) } - 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() - 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() + destroyEntity(id) { + const e = this.entities.get(id); if (!e) return + this._staticVersion++; this._dynamicEntityIds.delete(id); this._staticEntityIds.delete(id); this._activeDynamicIds.delete(id); this._sleepingDynamicIds.delete(id); this._suspendedEntityIds.delete(id); this._interactableIds.delete(id) + if (e._physicsBodyId !== undefined) { this._physicsBodyToEntityId.delete(e._physicsBodyId); if (this._physics) this._physics.removeBody(e._physicsBodyId) } + for (const cid of [...e.children]) this.destroyEntity(cid) + if (e.parent) { const p = this.entities.get(e.parent); if (p) p.children.delete(id) } + this.detachApp(id); this._spatialRemove(id); this.entities.delete(id) } - _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) - 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) - } - - reparent(entityId, newParentId) { - const e = this.entities.get(entityId); if (!e) return - if (e.parent) { const old=this.entities.get(e.parent); if (old) old.children.delete(entityId) } - e.parent = null; if (newParentId) { const np=this.entities.get(newParentId); if (np) { e.parent=newParentId; np.children.add(entityId) } } - } - - 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 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) - 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]] } - } - - _encodeEntity(id, e) { const r=Array.isArray(e.rotation)?[...e.rotation]:[e.rotation.x||0,e.rotation.y||0,e.rotation.z||0,e.rotation.w||1]; return { id, model:e.model, position:[...e.position], rotation:r, scale:[...e.scale], velocity:[...(e.velocity||[0,0,0])], bodyType:e.bodyType, custom:e.custom||null, parent:e.parent||null } } - _snap(entities) { return { tick: this.currentTick, timestamp: Date.now(), entities } } - getSnapshot() { const e=[]; for (const [id,en] of this.entities) e.push(this._encodeEntity(id,en)); return this._snap(e) } - getStaticSnapshot() { const e=[]; for (const id of this._staticEntityIds) { const en=this.entities.get(id); if (en) e.push(this._encodeEntity(id,en)) } return this._snap(e) } - 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 } - 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) } - fireMessage(eid, m) { this.fireEvent(eid, 'onMessage', m) } - addTimer(e, d, fn, r) { if (!this._timers.has(e)) this._timers.set(e, []); this._timers.get(e).push({ remaining: d, fn, repeat: r, interval: d }) } - clearTimers(eid) { this._timers.delete(eid) } + getSnapshot() { const e=[]; for (const [id,en] of this.entities) e.push({ id, model:en.model, position:[...en.position], rotation:[...en.rotation], scale:[...en.scale], velocity:[...en.velocity], bodyType:en.bodyType, custom:en.custom||null, parent:en.parent||null }); return { tick: this.currentTick, timestamp: Date.now(), entities:e } } + getStaticSnapshot() { const e=[]; for (const id of this._staticEntityIds) { const en=this.entities.get(id); if (en) e.push({ id, model:en.model, position:[...en.position], rotation:[...en.rotation], scale:[...en.scale], velocity:[...en.velocity], bodyType:en.bodyType, custom:en.custom||null, parent:en.parent||null }) } return { tick: this.currentTick, timestamp: Date.now(), entities:e } } + fireEvent(eid, en, ...a) { const ad = this.apps.get(eid), c = this.contexts.get(eid); if (!ad || !c) return; const s = ad.server || ad; if (s[en]) this._safeCall(s, en, [c, ...a], `${en}(${eid})`) } + _log(type, data, meta = {}) { if (this._eventLog) this._eventLog.record(type, data, { ...meta, tick: this.currentTick }) } + _safeCall(o, m, a, l) { try { const r = o[m](...a); return r?.catch ? r.catch(e => console.error(`[AppRuntime] ${l}: ${e.message}`)) : Promise.resolve() } catch (e) { console.error(`[AppRuntime] ${l}: ${e.message}`); return Promise.reject(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) } } + _spatialInsert(e) { if (this._stageLoader) { const s=this._stageLoader.getActiveStage(); if (s && !s.hasEntity(e.id)) { s.entityIds.add(e.id); s.spatial.insert(e.id, e.position); if (e.bodyType==='static') s._staticIds.add(e.id) } } } + _spatialRemove(id) { if (this._stageLoader) { const s=this._stageLoader.getActiveStage(); if (s) { s.spatial.remove(id); s._staticIds.delete(id); s.entityIds.delete(id) } } } setPlayerManager(pm) { this._playerManager = pm } setStageLoader(sl) { this._stageLoader = sl } - getPlayers() { return this._playerManager ? this._playerManager.getConnectedPlayers() : [] } - getNearestPlayer(pos, r) { let n=null,md=r*r; for (const p of this.getPlayers()) { const pp=p.state?.position; if (!pp) continue; const dx=pp[0]-pos[0],dy=pp[1]-pos[1],dz=pp[2]-pos[2],d=dx*dx+dy*dy+dz*dz; if (d console.error(`[AppRuntime] ${l}: ${e.message}`)); return Promise.resolve() } - catch (e) { console.error(`[AppRuntime] ${l}: ${e.message}`); return Promise.reject(e) } - } + getRelevantDynamicIds(pos, r) { if (!this._stageLoader) return Array.from(this.entities.keys()); return this._stageLoader.getRelevantEntities(pos, r) } } diff --git a/src/apps/AppRuntimeLifeCycle.js b/src/apps/AppRuntimeLifeCycle.js new file mode 100644 index 00000000..06d16e3f --- /dev/null +++ b/src/apps/AppRuntimeLifeCycle.js @@ -0,0 +1,45 @@ +import { AppContext } from './AppContext.js' +import { mulQuat, rotVec } from '../math.js' + +export function mixinAppLifeCycle(runtime) { + runtime._attachApp = async (entityId, appName) => { + const entity = runtime.entities.get(entityId), appDef = runtime._appDefs.get(appName) + if (!entity || !appDef) return + const ctx = new AppContext(entity, runtime) + runtime.contexts.set(entityId, ctx); runtime.apps.set(entityId, appDef) + await runtime._safeCall(appDef.server || appDef, 'setup', [ctx], `setup(${appName})`) + runtime._scheduleRebuild() + } + + runtime._scheduleRebuild = () => { + if (runtime._rebuildScheduled) return + runtime._rebuildScheduled = true + setImmediate(() => { runtime._rebuildScheduled = false; runtime._rebuildUpdateList(); runtime._rebuildCollisionList() }) + } + + runtime.detachApp = (entityId) => { + const appDef=runtime.apps.get(entityId), ctx=runtime.contexts.get(entityId) + if (ctx?._teardownChildren) ctx._teardownChildren() + if (appDef && ctx) runtime._safeCall(appDef.server||appDef, 'teardown', [ctx], 'teardown') + runtime._eventBus.destroyScope(entityId); runtime.clearTimers(entityId); runtime.apps.delete(entityId); runtime.contexts.delete(entityId) + runtime._rebuildUpdateList(); runtime._rebuildCollisionList() + } + + runtime.getWorldTransform = (entityId) => { + const e = runtime.entities.get(entityId); if (!e) return null + const local = { position: [...e.position], rotation: [...e.rotation], scale: [...e.scale] } + if (!e.parent) return local + const pt = runtime.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) + 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]] } + } + + runtime.getEntity = (id) => runtime.entities.get(id) || null + runtime.clearTimers = (eid) => runtime._timers.delete(eid) + runtime.addTimer = (e, d, fn, r) => { if (!runtime._timers.has(e)) runtime._timers.set(e, []); runtime._timers.get(e).push({ remaining: d, fn, repeat: r, interval: d }) } + runtime.getPlayers = () => runtime._playerManager ? runtime._playerManager.getConnectedPlayers() : [] + runtime.broadcastToPlayers = (m) => { if (runtime._connections) runtime._connections.broadcast(10, m); else if (runtime._playerManager) runtime._playerManager.broadcast(m) } + runtime.sendToPlayer = (id, m) => { if (runtime._connections) runtime._connections.send(id, 10, m); else if (runtime._playerManager) runtime._playerManager.sendToPlayer(id, m) } + runtime._drainReloadQueue = () => runtime._hotReload.drain() +} diff --git a/src/physics/World.js b/src/physics/World.js index ae04931c..a8b88f7e 100644 --- a/src/physics/World.js +++ b/src/physics/World.js @@ -1,232 +1,100 @@ -import { extractMeshFromGLB, extractMeshFromGLBAsync } from './GLBLoader.js' -import { CharacterManager } from './CharacterManager.js' -import { buildConvexShape, buildTrimeshShape } from './ShapeBuilder.js' - -const LAYER_STATIC = 0, LAYER_DYNAMIC = 1, NUM_LAYERS = 2 -let joltInstance = null -async function getJolt() { - if (!joltInstance) { - const _isNode = typeof process !== 'undefined' && process.versions?.node - const { default: init } = await import(_isNode ? 'jolt-physics/wasm-compat' : '/node_modules/jolt-physics/dist/jolt-physics.wasm-compat.js') - joltInstance = await init() - } - return joltInstance -} - -export class PhysicsWorld { - constructor(config = {}) { - this.gravity = config.gravity || [0, -9.81, 0] - this.Jolt = null; this.jolt = null; this.physicsSystem = null; this.bodyInterface = null - this.bodies = new Map(); this.bodyMeta = new Map(); this.bodyIds = new Map() - this._objFilter = null; this._ovbp = null - this._shapeCache = new Map(); this._convexQueue = Promise.resolve() - this._tmpVec3 = null; this._tmpRVec3 = null - this._bulkOutP = null; this._bulkOutR = null; this._bulkOutLV = null; this._bulkOutAV = null - this._charMgr = new CharacterManager(this.gravity, config.crouchHalfHeight || 0.45) - } - - async init() { - const J = await getJolt(); this.Jolt = J - const objFilter = new J.ObjectLayerPairFilterTable(NUM_LAYERS) - objFilter.EnableCollision(LAYER_STATIC, LAYER_DYNAMIC); objFilter.EnableCollision(LAYER_DYNAMIC, LAYER_DYNAMIC) - const bpI = new J.BroadPhaseLayerInterfaceTable(NUM_LAYERS, 2) - bpI.MapObjectToBroadPhaseLayer(LAYER_STATIC, new J.BroadPhaseLayer(0)) - bpI.MapObjectToBroadPhaseLayer(LAYER_DYNAMIC, new J.BroadPhaseLayer(1)) - const ovbp = new J.ObjectVsBroadPhaseLayerFilterTable(bpI, 2, objFilter, NUM_LAYERS) - const settings = new J.JoltSettings() - settings.mObjectLayerPairFilter = objFilter; settings.mBroadPhaseLayerInterface = bpI - settings.mObjectVsBroadPhaseLayerFilter = ovbp - this._objFilter = objFilter; this._ovbp = ovbp - this.jolt = new J.JoltInterface(settings); J.destroy(settings) - this.physicsSystem = this.jolt.GetPhysicsSystem(); this.bodyInterface = this.physicsSystem.GetBodyInterface() - this._tmpVec3 = new J.Vec3(0, 0, 0); this._tmpRVec3 = new J.RVec3(0, 0, 0) - this._bulkOutP = new J.RVec3(0, 0, 0); this._bulkOutR = new J.Quat(0, 0, 0, 1) - this._bulkOutLV = new J.Vec3(0, 0, 0); this._bulkOutAV = new J.Vec3(0, 0, 0) - const [gx, gy, gz] = this.gravity - const gv = new J.Vec3(gx, gy, gz); this.physicsSystem.SetGravity(gv); J.destroy(gv) - this._heap32 = new Int32Array(J.HEAP8.buffer) - this._activationListener = new J.BodyActivationListenerJS() - this._activationListener.OnBodyActivated = (ptr) => { if (this.onBodyActivated) this.onBodyActivated(this._heap32[ptr >> 2]) } - this._activationListener.OnBodyDeactivated = (ptr) => { if (this.onBodyDeactivated) this.onBodyDeactivated(this._heap32[ptr >> 2]) } - this.physicsSystem.SetBodyActivationListener(this._activationListener) - this._charMgr.init(J, this.jolt, this.physicsSystem) - return this - } - - _addBody(shape, position, motionType, layer, opts = {}) { - const J = this.Jolt - const pos = new J.RVec3(position[0], position[1], position[2]) - const rot = opts.rotation ? new J.Quat(...opts.rotation) : new J.Quat(0, 0, 0, 1) - const cs = new J.BodyCreationSettings(shape, pos, rot, motionType, layer) - J.destroy(pos); J.destroy(rot) - if (opts.mass) { cs.mMassPropertiesOverride.mMass = opts.mass; cs.mOverrideMassProperties = J.EOverrideMassProperties_CalculateInertia } - if (opts.friction !== undefined) cs.mFriction = opts.friction - if (opts.restitution !== undefined) cs.mRestitution = opts.restitution - if (opts.linearDamping !== undefined) cs.mLinearDamping = opts.linearDamping - if (opts.angularDamping !== undefined) cs.mAngularDamping = opts.angularDamping - if (opts.linearCast) cs.mMotionQuality = J.EMotionQuality_LinearCast - const activate = motionType === J.EMotionType_Static ? J.EActivation_DontActivate : J.EActivation_Activate - const body = this.bodyInterface.CreateBody(cs); this.bodyInterface.AddBody(body.GetID(), activate) - J.destroy(cs) - const id = body.GetID().GetIndexAndSequenceNumber() - this.bodies.set(id, body); this.bodyMeta.set(id, opts.meta || {}); this.bodyIds.set(id, body.GetID()) - return id - } - - addStaticBox(halfExtents, position, rotation) { - const J = this.Jolt - const hv = new J.Vec3(halfExtents[0], halfExtents[1], halfExtents[2]) - const bs = new J.BoxShape(hv, 0.05, null); J.destroy(hv) - return this._addBody(bs, position, J.EMotionType_Static, LAYER_STATIC, { rotation, meta: { type: 'static', shape: 'box' } }) - } - - addBody(shapeType, params, position, motionType, opts = {}) { - const J = this.Jolt; let shape - if (shapeType === 'box') { const cr = Math.min(0.05, Math.min(params[0], params[1], params[2]) * 0.1); const bv = new J.Vec3(params[0], params[1], params[2]); shape = new J.BoxShape(bv, cr, null); J.destroy(bv) } - else if (shapeType === 'sphere') shape = new J.SphereShape(params) - else if (shapeType === 'capsule') shape = new J.CapsuleShape(params[1], params[0]) - else if (shapeType === 'convex') { - const { shape: cvxShape } = buildConvexShape(J, params, this._shapeCache, opts.shapeKey || null) - const mt = motionType === 'dynamic' ? J.EMotionType_Dynamic : motionType === 'kinematic' ? J.EMotionType_Kinematic : J.EMotionType_Static - return this._addBody(cvxShape, position, mt, motionType === 'static' ? LAYER_STATIC : LAYER_DYNAMIC, { ...opts, meta: { type: motionType, shape: shapeType } }) - } - else return null - const mt = motionType === 'dynamic' ? J.EMotionType_Dynamic : motionType === 'kinematic' ? J.EMotionType_Kinematic : J.EMotionType_Static - return this._addBody(shape, position, mt, motionType === 'static' ? LAYER_STATIC : LAYER_DYNAMIC, { ...opts, meta: { type: motionType, shape: shapeType } }) - } - - addConvexBodyAsync(params, position, motionType, opts = {}) { - const J = this.Jolt, cacheKey = opts.shapeKey || null - if (cacheKey && this._shapeCache.has(cacheKey)) { - const mt = motionType === 'dynamic' ? J.EMotionType_Dynamic : motionType === 'kinematic' ? J.EMotionType_Kinematic : J.EMotionType_Static - return Promise.resolve(this._addBody(this._shapeCache.get(cacheKey), position, mt, motionType === 'static' ? LAYER_STATIC : LAYER_DYNAMIC, { ...opts, meta: { type: motionType, shape: 'convex' } })) - } - const result = this._convexQueue.then(() => { - const { shape } = buildConvexShape(J, params, this._shapeCache, cacheKey) - const mt = motionType === 'dynamic' ? J.EMotionType_Dynamic : motionType === 'kinematic' ? J.EMotionType_Kinematic : J.EMotionType_Static - return this._addBody(shape, position, mt, motionType === 'static' ? LAYER_STATIC : LAYER_DYNAMIC, { ...opts, meta: { type: motionType, shape: 'convex' } }) - }) - this._convexQueue = result.then(() => {}, () => {}); return result - } - - async addStaticTrimeshAsync(glbPath, meshIndex = 0, position = [0, 0, 0], scale = [1, 1, 1], rotation = [0, 0, 0, 1]) { - const J = this.Jolt - const { shape, sr, triangleCount } = await buildTrimeshShape(J, glbPath, scale) - const id = this._addBody(shape, position, J.EMotionType_Static, LAYER_STATIC, { rotation, meta: { type: 'static', shape: 'trimesh', triangles: triangleCount } }) - J.destroy(sr); return id - } - - addHeightField(samples, sampleCount, scale, position) { - const J = this.Jolt - const settings = new J.HeightFieldShapeSettings() - const offset = new J.Vec3(0, 0, 0); settings.set_mOffset(offset); J.destroy(offset) - const sv = new J.Vec3(scale[0], scale[1], scale[2]); settings.set_mScale(sv); J.destroy(sv) - settings.set_mSampleCount(sampleCount) - // mBlockSize=2 ~halves Settings.Create cost vs default 4. Heightfield queries - // for a single CharacterVirtual are rare so query slowdown is acceptable. - if (typeof settings.set_mBlockSize === 'function') settings.set_mBlockSize(2) - const heights = settings.get_mHeightSamples() - heights.resize(samples.length) - let bulkOk = false - if (typeof heights.data === 'function' && typeof J.getPointer === 'function' && J.HEAPF32) { - const ref = heights.data() - const ptr = J.getPointer(ref) - if (ptr) { - const view = samples instanceof Float32Array ? samples : Float32Array.from(samples) - J.HEAPF32.set(view, ptr >> 2) - bulkOk = true - } - } - if (!bulkOk) { - heights.clear(); heights.reserve(samples.length) - for (let i = 0; i < samples.length; i++) heights.push_back(samples[i]) - } - const sr = settings.Create() - if (!sr.IsValid()) { console.error('[heightfield] shape invalid:', sr.GetError()); J.destroy(settings); J.destroy(sr); return null } - const shape = sr.Get() - const id = this._addBody(shape, position, J.EMotionType_Static, LAYER_STATIC, { meta: { type: 'static', shape: 'heightfield' } }) - J.destroy(settings); J.destroy(sr) - return id - } - - addStaticTrimeshFromData(entityId,v,ix,pos,rot=[0,0,0,1]){const J=this.Jolt,tc=ix.length/3,tl=new J.TriangleList(),f3=new J.Float3(0,0,0);tl.resize(tc);for(let t=0;t { if (this.onBodyActivated) this.onBodyActivated(this._heap32[ptr >> 2]) } + this._activationListener.OnBodyDeactivated = (ptr) => { if (this.onBodyDeactivated) this.onBodyDeactivated(this._heap32[ptr >> 2]) } + this.physicsSystem.SetBodyActivationListener(this._activationListener) + this._charMgr.init(J, this.jolt, this.physicsSystem); return this + } + + addPlayerCharacter(r, h, p, m) { return this._charMgr.addCharacter(r, h, p, m) } + setCharacterCrouch(id, v) { this._charMgr.setCrouch(id, v) } + updateCharacter(id, dt) { this._charMgr.update(id, dt) } + readCharacterPosition(id, out) { this._charMgr.readPosition(id, out) } + readCharacterVelocity(id, out) { this._charMgr.readVelocity(id, out) } + setCharacterVelocity(id, v) { this._charMgr.setVelocity(id, v) } + setCharacterPosition(id, p) { this._charMgr.setPosition(id, p) } + getCharacterGroundState(id) { return this._charMgr.getGroundState(id) } + removeCharacter(id) { this._charMgr.removeCharacter(id) } + + getBodyPosition(id) { const b = this.bodies.get(id); if (!b) return [0,0,0]; const p = this.bodyInterface.GetPosition(b.GetID()); const r=[p.GetX(),p.GetY(),p.GetZ()]; this.Jolt.destroy(p); return r } + setBodyPosition(id, p) { const b = this.bodies.get(id); if (!b) return; this._tmpRVec3.Set(p[0],p[1],p[2]); this.bodyInterface.SetPosition(b.GetID(), this._tmpRVec3, this.Jolt.EActivation_Activate) } + setBodyVelocity(id, v) { const b = this.bodies.get(id); if (!b) return; this._tmpVec3.Set(v[0],v[1],v[2]); this.bodyInterface.SetLinearVelocity(b.GetID(), this._tmpVec3) } + step(dt) { if (this.jolt) this.jolt.Step(dt, 2) } + + 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 + } + + raycast(origin, direction, maxDistance = 1000, excludeBodyId = null) { + if (!this.physicsSystem) return { hit: false, distance: maxDistance, body: null, position: null } + const J = this.Jolt; const len = Math.hypot(...direction) + const dir = len > 0 ? direction.map(v => v/len) : direction + const ray = new J.RRayCast(new J.RVec3(...origin), new J.Vec3(...dir.map(v => v*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 res = { hit: col.HadHit() } + if (res.hit) { const d = col.get_mHit().mFraction * maxDistance; res.distance = d; res.position = origin.map((v, i) => v + dir[i] * d) } + J.destroy(ray); J.destroy(rs); J.destroy(col); J.destroy(bp); J.destroy(ol); J.destroy(bf); J.destroy(sf) + return res + } + + destroy() { + if (!this.Jolt) return + this._charMgr.destroy(); for (const [id] of this.bodies) this.removeBody(id) + const J = this.Jolt; if (this.jolt) { J.destroy(this.jolt); this.jolt = null } + this.physicsSystem = null; this.bodyInterface = null + } +} diff --git a/src/physics/WorldBody.js b/src/physics/WorldBody.js new file mode 100644 index 00000000..88860907 --- /dev/null +++ b/src/physics/WorldBody.js @@ -0,0 +1,55 @@ +import { buildConvexShape, buildTrimeshShape } from './ShapeBuilder.js' + +export function mixinBodyInterface(world) { + world._addBody = (shape, position, motionType, layer, opts = {}) => { + const J = world.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 = world.bodyInterface.CreateBody(cs); world.bodyInterface.AddBody(body.GetID(), activate) + J.destroy(cs) + const id = body.GetID().GetIndexAndSequenceNumber() + world.bodies.set(id, body); world.bodyMeta.set(id, opts.meta || {}); world.bodyIds.set(id, body.GetID()) + return id + } + + world.addBody = (shapeType, params, position, motionType, opts = {}) => { + const J = world.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, world._shapeCache, opts.shapeKey || null) + const mt = motionType === 'dynamic' ? J.EMotionType_Dynamic : motionType === 'kinematic' ? J.EMotionType_Kinematic : J.EMotionType_Static + return world._addBody(cvxShape, position, mt, motionType === 'static' ? 0 : 1, { ...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 world._addBody(shape, position, mt, motionType === 'static' ? 0 : 1, { ...opts, meta: { type: motionType, shape: shapeType } }) + } + + world.syncDynamicBody = (bodyId, entity) => { + const b = world.bodies.get(bodyId); if (!b || !b.IsActive()) return false + const id = world.bodyIds.get(bodyId), bi = world.bodyInterface + bi.GetPositionAndRotation(id, world._bulkOutP, world._bulkOutR) + bi.GetLinearAndAngularVelocity(id, world._bulkOutLV, world._bulkOutAV) + entity.position[0] = world._bulkOutP.GetX(); entity.position[1] = world._bulkOutP.GetY(); entity.position[2] = world._bulkOutP.GetZ() + entity.rotation[0] = world._bulkOutR.GetX(); entity.rotation[1] = world._bulkOutR.GetY(); entity.rotation[2] = world._bulkOutR.GetZ(); entity.rotation[3] = world._bulkOutR.GetW() + entity.velocity[0] = world._bulkOutLV.GetX(); entity.velocity[1] = world._bulkOutLV.GetY(); entity.velocity[2] = world._bulkOutLV.GetZ() + return true + } + + world.removeBody = (id) => { + const b = world.bodies.get(id); if (!b) return + world.bodyInterface.RemoveBody(b.GetID()); world.bodyInterface.DestroyBody(b.GetID()) + world.bodies.delete(id); world.bodyMeta.delete(id); world.bodyIds.delete(id) + } +} diff --git a/src/sdk/SnapshotHandler.js b/src/sdk/SnapshotHandler.js new file mode 100644 index 00000000..65948777 --- /dev/null +++ b/src/sdk/SnapshotHandler.js @@ -0,0 +1,127 @@ +import { MSG } from '../protocol/MessageTypes.js' +import { SnapshotEncoder } from '../netcode/SnapshotEncoder.js' +import { pack } from '../protocol/msgpack.js' + +const SNAP_UNRELIABLE = true +const PRIORITY_ENTITY_BUDGET = 64 +const PRIORITY_DECAY = 0.02 + +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) { + 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) +} + +export 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) + 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 clearSnapshotPriorities(playerId) { + _priorityAccumulators.delete(playerId) +} diff --git a/src/sdk/TickHandler.js b/src/sdk/TickHandler.js index 0b765c7a..fc2b6f45 100644 --- a/src/sdk/TickHandler.js +++ b/src/sdk/TickHandler.js @@ -1,206 +1,85 @@ -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 { MSG } from '../protocol/MessageTypes.js' +import { applyMovement as _applyMovement, DEFAULT_MOVEMENT as _DEFAULT_MOVEMENT } from '../shared/movement.js' +import { applyPlayerCollisions } from '../netcode/CollisionSystem.js' +import { buildAndSendSnapshots, clearSnapshotPriorities } 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) + 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) + } +} + +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); clearSnapshotPriorities(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)} snap:${(t5-t4).toFixed(2)}(avg:${avgSnap}) | heap:${mb(mem.heapUsed)}MB rss:${mb(mem.rss)}MB`) } catch (_) {} + } + } +} diff --git a/src/sdk/server.js b/src/sdk/server.js index 2df5e765..e9bf4708 100644 --- a/src/sdk/server.js +++ b/src/sdk/server.js @@ -1,5 +1,4 @@ -import { join, dirname, resolve } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' +import { join, resolve } from 'node:path' import { existsSync, writeFileSync, mkdirSync } from 'node:fs' import { prewarm } from '../static/GLBTransformer.js' import { MSG } from '../protocol/MessageTypes.js' @@ -24,27 +23,16 @@ import { ReloadManager } from './ReloadManager.js' import { createReloadHandlers } from './ReloadHandlers.js' import { createServerAPI } from './ServerAPI.js' import { createConnectionHandlers } from './ServerHandlers.js' - -function buildUniquePathList(paths) { - const out = [], seen = new Set() - for (const p of paths) { const rp = resolve(p); if (!seen.has(rp)) { seen.add(rp); out.push(rp) } } - return out -} +import { fileURLToPath, pathToFileURL } from 'node:url' export function buildStaticDirs(sdkRoot, project, appsDirs) { - const dirs = [ + return [ { prefix: '/src/', dir: join(sdkRoot, 'src') }, ...appsDirs.map(dir => ({ prefix: '/apps/', dir })), { prefix: '/node_modules/', dir: join(sdkRoot, 'node_modules') }, { prefix: '/data/', dir: resolve(project, 'data') }, { prefix: '/', dir: join(sdkRoot, 'client') } ] - const localKit = 'C:\\dev\\anentrypoint-design' - if (existsSync(localKit)) { - dirs.push({ prefix: '/kit/', dir: localKit }) - console.log(`[server] linked local design kit: ${localKit} -> /kit/`) - } - return dirs } async function createServerDeps(config) { @@ -60,10 +48,8 @@ async function createServerDeps(config) { const inspector = new Inspector(), reloadManager = new ReloadManager() const resolvedSdkRoot = sdkRoot || join(dirname(fileURLToPath(import.meta.url)), '../..') const appRuntime = new AppRuntime({ gravity, playerManager, physics, physicsIntegration, connections, eventBus, eventLog, storage, sdkRoot: resolvedSdkRoot, physicsRadius: config.physicsRadius || 0, entityTickRate: config.entityTickRate, tickRate, lagCompensator }) - appRuntime.setPlayerManager(playerManager) - const appLoader = new AppLoader(appRuntime, { dirs: appsDirs }) - const stageLoader = new StageLoader(appRuntime) - appRuntime.setStageLoader(stageLoader) + appRuntime.setPlayerManager(playerManager); const appLoader = new AppLoader(appRuntime, { dirs: appsDirs }) + const stageLoader = new StageLoader(appRuntime); appRuntime.setStageLoader(stageLoader) const _ctxRef = { current: null } appLoader._onReloadCallback = (name, code) => { const trusted = !!_ctxRef.current?.currentWorldDef?.trustedApps?.includes(name) || undefined @@ -74,7 +60,8 @@ async function createServerDeps(config) { function wireServerHandlers(ctx) { const { networkState, playerManager, physicsIntegration, lagCompensator, physics, appRuntime, connections, movement, tickRate, stageLoader, eventLog, reloadManager, sdkRoot } = ctx - const worldConfigUrl = pathToFileURL(existsSync(resolve(process.cwd(), 'apps/world/index.js')) ? resolve(process.cwd(), 'apps/world/index.js') : join(sdkRoot, 'apps/world/index.js')).href + const worldConfigPath = resolve(process.cwd(), 'apps/world/index.js') + const worldConfigUrl = existsSync(worldConfigPath) ? pathToFileURL(worldConfigPath).href : pathToFileURL(join(sdkRoot, 'apps/world/index.js')).href const reloadHandlers = createReloadHandlers({ networkState, playerManager, physicsIntegration, lagCompensator, physics, appRuntime, connections, movement, tickRate, worldConfigPath: worldConfigUrl }) ctx.reloadHandlers = reloadHandlers ctx.setTickHandler(createTickHandler({ networkState, playerManager, physicsIntegration, lagCompensator, physics, appRuntime, connections, movement, stageLoader, eventLog, tickRate, getRelevanceRadius: () => ctx.currentWorldDef?.relevanceRadius || 0 })) @@ -90,21 +77,16 @@ function wireServerHandlers(ctx) { ]) reloadManager.addWatcher(id, path, reload) const clientReload = () => connections.broadcast(MSG.HOT_RELOAD, { timestamp: Date.now() }) for (const [id, path] of [ - ['client-app', sdk('client/app.js')], ['client-animation-sm', sdk('client/AnimationStateMachine.js')], ['client-animation-lib', sdk('client/AnimationLibrary.js')], ['client-camera', sdk('client/core/camera.js')], - ['client-input', sdk('src/client/InputHandler.js')], ['client-network', sdk('src/client/PhysicsNetworkClient.js')], - ['client-prediction', sdk('src/client/PredictionEngine.js')], ['client-reconciliation', sdk('src/client/ReconciliationEngine.js')], - ['client-index', sdk('src/index.client.js')] + ['client-app', sdk('client/app.js')], ['client-index', sdk('src/index.client.js')] ]) reloadManager.addWatcher(id, path, clientReload) } } export async function createServer(config = {}) { - const port = config.port || 3000, tickRate = config.tickRate || 128 - const movement = config.movement || {}, staticDirs = config.staticDirs || [] const deps = await createServerDeps(config) const ctx = { - config, port, tickRate, appsDirs: config.appsDirs || [], gravity: config.gravity || [0, -9.81, 0], - movement, staticDirs, ...deps, currentWorldDef: null, worldSpawnPoint: [0, 5, 0], + config, port: config.port || 3000, tickRate: config.tickRate || 128, appsDirs: config.appsDirs || [], gravity: config.gravity || [0, -9.81, 0], + movement: config.movement || {}, staticDirs: config.staticDirs || [], ...deps, currentWorldDef: null, worldSpawnPoint: [0, 5, 0], snapshotSeq: 0, httpServer: null, wss: null, wtServer: null, handlerState: { fn: null }, onTick: (tick, dt) => { if (ctx.handlerState.fn) ctx.handlerState.fn(tick, dt) }, @@ -114,50 +96,34 @@ export async function createServer(config = {}) { persist(runtime) { const placed = [] for (const [id, entity] of runtime.entities) { - if (!id.startsWith('placed-')) continue - placed.push({ id, model: entity.model, position: [...entity.position], rotation: [...entity.rotation], scale: [...entity.scale], config: { collider: entity.custom?._collider || 'none' } }) + if (id.startsWith('placed-')) placed.push({ id, model: entity.model, position: [...entity.position], rotation: [...entity.rotation], scale: [...entity.scale], config: { collider: entity.custom?._collider || 'none' } }) } - try { const dataDir = resolve(process.cwd(), 'data'); if (!existsSync(dataDir)) mkdirSync(dataDir, { recursive: true }); writeFileSync(resolve(dataDir, 'placed-models.json'), JSON.stringify(placed, null, 2)) } catch (e) { console.error('[placed-model] persist error:', e.message) } + const dataDir = resolve(process.cwd(), 'data'); if (!existsSync(dataDir)) mkdirSync(dataDir, { recursive: true }); writeFileSync(resolve(dataDir, 'placed-models.json'), JSON.stringify(placed, null, 2)) } } if (deps._ctxRef) deps._ctxRef.current = ctx wireServerHandlers(ctx) - deps.tickSystem.onDilation(factor => { - deps.connections.broadcast(MSG.TICK_DILATION, { factor }) - console.log(`[tick-dilation] factor=${factor}`) - }) + deps.tickSystem.onDilation(factor => deps.connections.broadcast(MSG.TICK_DILATION, { factor })) const api = createServerAPI(ctx) if (typeof globalThis.__DEBUG__ === 'undefined') globalThis.__DEBUG__ = {} - globalThis.__DEBUG__.server = api - return api + globalThis.__DEBUG__.server = api; return api } export async function boot(overrides = {}) { const SDK_ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..') - const PROJECT = process.cwd() - const worldName = process.env.WORLD || 'index' - const localWorld = resolve(PROJECT, `apps/world/${worldName}.js`) - const fallbackLocal = resolve(PROJECT, 'apps/world/index.js') - const worldPath = existsSync(localWorld) ? localWorld : existsSync(fallbackLocal) ? fallbackLocal : resolve(SDK_ROOT, 'apps/world/index.js') - if (worldName !== 'index') console.log(`[boot] using world: ${worldName}`) - if (!existsSync(worldPath)) console.log('[boot] no world found, using bundled SDK defaults') + const worldPath = existsSync(resolve(process.cwd(), 'apps/world/index.js')) ? resolve(process.cwd(), 'apps/world/index.js') : join(SDK_ROOT, 'apps/world/index.js') const worldDef = (await import(pathToFileURL(worldPath).href + `?t=${Date.now()}`)).default || {} - const localApps = resolve(PROJECT, 'apps'), sdkApps = join(SDK_ROOT, 'apps') - const appsDirs = buildUniquePathList(existsSync(localApps) ? [localApps, sdkApps] : [sdkApps]) - console.debug(`[boot] loading from: ${appsDirs.join(', ')}`) + const appsDirs = [resolve(process.cwd(), 'apps'), join(SDK_ROOT, 'apps')].filter(existsSync) const config = { port: parseInt(process.env.PORT || String(worldDef.port || 3000), 10), tickRate: worldDef.tickRate || 128, appsDirs, sdkRoot: SDK_ROOT, gravity: worldDef.gravity, movement: worldDef.movement, playerConfig: worldDef.player, physicsRadius: worldDef.physicsRadius || 0, entityTickRate: worldDef.entityTickRate, - staticDirs: buildStaticDirs(SDK_ROOT, PROJECT, appsDirs), - ...overrides + staticDirs: buildStaticDirs(SDK_ROOT, process.cwd(), appsDirs), ...overrides } - const server = await createServer(config) - await server.loadWorld(worldDef) - server.on('playerJoin', () => {}); server.on('playerLeave', () => {}) + const server = await createServer(config); await server.loadWorld(worldDef) await prewarm(appsDirs).catch(e => console.error('[prewarm] error:', e)) const info = await server.start() - console.log(`[server] http://localhost:${info.port} @ ${info.tickRate} TPS`) - return server + console.log(`[server] http://localhost:${info.port} @ ${info.tickRate} TPS`); return server } +function dirname(p) { return resolve(p, '..') }