diff --git a/apps/_lib/client-machine.test.js b/apps/_lib/client-machine.test.js deleted file mode 100644 index 4b380b37..00000000 --- a/apps/_lib/client-machine.test.js +++ /dev/null @@ -1,97 +0,0 @@ -// Unit tests for the unified client state machine (client/core/ClientMachine.js). -// Runs under `npm test` (node --test). The machine's dual-import picks the node -// `xstate` package here, so the same definition the browser runs is exercised. -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { createClientStateMachine } from '../../client/core/ClientMachine.js' - -function ready(m) { m.send('ASSETS_READY'); return m } - -test('starts in loading, ASSETS_READY -> ready/playing', () => { - const m = createClientStateMachine() - assert.equal(m.matches('loading'), true) - assert.equal(m.isReady, false) - ready(m) - assert.equal(m.isReady, true) - assert.equal(m.isPlaying, true) -}) - -test('TOGGLE_EDITOR flips playing <-> editor', () => { - const m = ready(createClientStateMachine()) - m.send('TOGGLE_EDITOR') - assert.equal(m.isEditor, true) - assert.equal(m.isPlaying, false) - m.send('TOGGLE_EDITOR') - assert.equal(m.isPlaying, true) - assert.equal(m.isEditor, false) -}) - -test('OPEN_LOBBY blocked while editor is open (guard)', () => { - const m = ready(createClientStateMachine()) - m.send('TOGGLE_EDITOR') - assert.equal(m.isEditor, true) - m.send('OPEN_LOBBY') // must be ignored while editing - assert.equal(m.isEditor, true) - assert.equal(m.isLobby, false) -}) - -test('TOGGLE_EDITOR blocked while lobby is open (guard)', () => { - const m = ready(createClientStateMachine()) - m.send('OPEN_LOBBY') - assert.equal(m.isLobby, true) - m.send('TOGGLE_EDITOR') // must be ignored while in lobby - assert.equal(m.isLobby, true) - assert.equal(m.isEditor, false) -}) - -test('OPEN_LOBBY toggles the lobby closed; CLOSE_LOBBY returns to playing', () => { - const m = ready(createClientStateMachine()) - m.send('OPEN_LOBBY') - assert.equal(m.isLobby, true) - m.send('OPEN_LOBBY') // M pressed again -> closes - assert.equal(m.isPlaying, true) - m.send('OPEN_LOBBY') - m.send('CLOSE_LOBBY') // X button - assert.equal(m.isPlaying, true) -}) - -test('selection: SELECT/DESELECT and auto-clear on editor exit', () => { - const m = ready(createClientStateMachine()) - m.send('TOGGLE_EDITOR') - m.send('SELECT') - assert.equal(m.isSelected, true) - m.send('DESELECT') - assert.equal(m.isSelected, false) - m.send('SELECT') - assert.equal(m.isSelected, true) - m.send('TOGGLE_EDITOR') // leave editor -> selection cleared - assert.equal(m.isSelected, false) -}) - -test('gizmo mode cycles and resets to translate on editor exit', () => { - const m = ready(createClientStateMachine()) - m.send('TOGGLE_EDITOR') - assert.equal(m.gizmoMode, 'translate') - m.send('ROTATE') - assert.equal(m.gizmoMode, 'rotate') - m.send('SCALE') - assert.equal(m.gizmoMode, 'scale') - m.send('TOGGLE_EDITOR') // exit - m.send('TOGGLE_EDITOR') // re-enter - assert.equal(m.gizmoMode, 'translate') -}) - -test('TOGGLE_EDITOR from loading promotes to ready+editor (never locked out)', () => { - const m = createClientStateMachine() - assert.equal(m.matches('loading'), true) - m.send('TOGGLE_EDITOR') // forgiving: promotes to ready and enters editor - assert.equal(m.isReady, true) - assert.equal(m.isEditor, true) -}) - -test('OPEN_LOBBY from loading promotes to ready+lobby', () => { - const m = createClientStateMachine() - m.send('OPEN_LOBBY') - assert.equal(m.isReady, true) - assert.equal(m.isLobby, true) -}) diff --git a/apps/_lib/game-fsm.js b/apps/_lib/game-fsm.js index 0fcffea6..e9404c8b 100644 --- a/apps/_lib/game-fsm.js +++ b/apps/_lib/game-fsm.js @@ -1,207 +1,52 @@ -// game-fsm.js — declarative, xstate-backed finite-state-machine builder for -// orchestrating game state setups (match phases, rounds, countdowns, etc). -// -// Apps hand-roll game flow in update()/onMessage() with ad-hoc Maps and timers; -// this builder lets a game declare its phases once and drive them through the -// app lifecycle. A spec compiles to a real xstate 5 machine, so guards, timed -// transitions, and entry/exit actions are the battle-tested xstate semantics — -// the builder only adds the per-state enter/exit/tick game hooks and a thin -// runtime wrapper that mirrors createAppMachine (apps/_lib/lifecycle.js). -// -// Spec shape: -// defineGameFSM({ -// initial: 'waiting', -// context: { round: 0, scores: {} }, // optional seed data -// states: { -// waiting: { on: { START: 'countdown' } }, -// countdown:{ after: { 3000: 'active' }, enter(ctx, fsm){...} }, -// active: { tick(ctx, dt, fsm){...}, on: { ROUND_OVER: { target: 'roundEnd', guard: (ctx,fsm)=>fsm.context.round<5 } } }, -// roundEnd: { enter(ctx,fsm){ fsm.context.round++ }, after: { 2000: 'active' } }, -// done: { final: true } -// } -// }, appCtx) -// -// Hooks receive the app ctx (so they can touch ctx.players/state/network) and -// the fsm runtime. Pass appCtx as the 2nd arg, or omit it and the hooks get -// undefined for ctx (still usable for pure-logic machines / tests). - const _isNode = typeof process !== 'undefined' && !!process.versions?.node const _xstate = await import(_isNode ? 'xstate' : '/node_modules/xstate/dist/xstate.esm.js') -const { setup, createActor, assign } = _xstate - -const HOOK_KEYS = ['enter', 'exit', 'tick'] +const { setup, createActor } = _xstate -// Validate the spec up front with clear, actionable errors — a bad target or a -// missing initial state should fail loudly at build time, not silently route -// nowhere at runtime. function validateSpec(spec) { if (!spec || typeof spec !== 'object') throw new Error('[game-fsm] spec must be an object') const states = spec.states - if (!states || typeof states !== 'object' || !Object.keys(states).length) { - throw new Error('[game-fsm] spec.states must be a non-empty object') - } - const names = Object.keys(states) - if (!spec.initial) throw new Error('[game-fsm] spec.initial is required') - if (!names.includes(spec.initial)) { - throw new Error(`[game-fsm] spec.initial "${spec.initial}" is not a declared state (have: ${names.join(', ')})`) - } + if (!states || typeof states !== 'object' || !Object.keys(states).length) throw new Error('[game-fsm] spec.states must be a non-empty object') + const names = Object.keys(states); if (!spec.initial) throw new Error('[game-fsm] spec.initial is required') + if (!names.includes(spec.initial)) throw new Error(`[game-fsm] spec.initial "${spec.initial}" is not a declared state (have: ${names.join(', ')})`) const targetsOf = (def) => (Array.isArray(def) ? def : [def]).map(d => typeof d === 'string' ? d : d?.target).filter(Boolean) for (const name of names) { const st = states[name] || {} - for (const [evt, def] of Object.entries(st.on || {})) { - for (const target of targetsOf(def)) { - if (!names.includes(target)) throw new Error(`[game-fsm] state "${name}" event "${evt}" targets unknown state "${target}"`) - } - } - for (const [ms, def] of Object.entries(st.after || {})) { - for (const target of targetsOf(def)) { - if (!names.includes(target)) throw new Error(`[game-fsm] state "${name}" after(${ms}) targets unknown state "${target}"`) - } - } - } -} - -// Normalize a transition def into the xstate transition shape, registering any -// guard/action into the shared maps so machine setup() can reference them by -// name. A def may be: -// 'target' — bare target -// {target, guard, action} — single guarded/actioned transition -// [def, def, ...] — ordered candidates; xstate picks the first -// whose guard passes (last one usually bare). -function normalizeTransition(stateName, key, def, guards, actions, getRuntime, getAppCtx) { - if (Array.isArray(def)) { - return def.map((d, i) => normalizeOne(stateName, `${key}_${i}`, d, guards, actions, getRuntime, getAppCtx)) + for (const [evt, def] of Object.entries(st.on || {})) { for (const target of targetsOf(def)) if (!names.includes(target)) throw new Error(`[game-fsm] state "${name}" event "${evt}" targets unknown state "${target}"`) } + for (const [ms, def] of Object.entries(st.after || {})) { for (const target of targetsOf(def)) if (!names.includes(target)) throw new Error(`[game-fsm] state "${name}" after(${ms}) targets unknown state "${target}"`) } } - return normalizeOne(stateName, key, def, guards, actions, getRuntime, getAppCtx) } function normalizeOne(stateName, key, def, guards, actions, getRuntime, getAppCtx) { - const target = typeof def === 'string' ? def : def.target - const out = { target } + const target = typeof def === 'string' ? def : def.target, out = { target } if (def && typeof def === 'object') { - if (typeof def.guard === 'function') { - const gname = `g_${stateName}_${key}` - guards[gname] = () => !!def.guard(getAppCtx(), getRuntime()) - out.guard = gname - } - if (typeof def.action === 'function') { - const aname = `a_${stateName}_${key}` - actions[aname] = () => def.action(getAppCtx(), getRuntime()) - out.actions = aname - } + if (typeof def.guard === 'function') { const gname = `g_${stateName}_${key}`; guards[gname] = () => !!def.guard(getAppCtx(), getRuntime()); out.guard = gname } + if (typeof def.action === 'function') { const aname = `a_${stateName}_${key}`; actions[aname] = () => def.action(getAppCtx(), getRuntime()); out.actions = aname } } return out } export function defineGameFSM(spec, appCtx) { - validateSpec(spec) - - let runtime = null - const getRuntime = () => runtime - const getAppCtx = () => appCtx - - const guards = {} - const actions = {} - const xstates = {} - + validateSpec(spec); let runtime = null; const getRuntime = () => runtime, getAppCtx = () => appCtx + const guards = {}, actions = {}, xstates = {} for (const [name, raw] of Object.entries(spec.states)) { - const st = raw || {} - const xs = {} - if (st.final === true || st.type === 'final') xs.type = 'final' - - // enter/exit -> xstate entry/exit actions calling the game hooks. - if (typeof st.enter === 'function') { - const an = `enter_${name}` - actions[an] = () => st.enter(getAppCtx(), getRuntime()) - xs.entry = an - } - if (typeof st.exit === 'function') { - const an = `exit_${name}` - actions[an] = () => st.exit(getAppCtx(), getRuntime()) - xs.exit = an - } - - // Event transitions. - if (st.on) { - xs.on = {} - for (const [evt, def] of Object.entries(st.on)) { - xs.on[evt] = normalizeTransition(name, evt, def, guards, actions, getRuntime, getAppCtx) - } - } - // Timed (after) transitions. - if (st.after) { - xs.after = {} - for (const [ms, def] of Object.entries(st.after)) { - xs.after[ms] = normalizeTransition(name, `after${ms}`, def, guards, actions, getRuntime, getAppCtx) - } - } + const st = raw || {}, xs = {}; if (st.final === true || st.type === 'final') xs.type = 'final' + if (typeof st.enter === 'function') { const an = `enter_${name}`; actions[an] = () => st.enter(getAppCtx(), getRuntime()); xs.entry = an } + if (typeof st.exit === 'function') { const an = `exit_${name}`; actions[an] = () => st.exit(getAppCtx(), getRuntime()); xs.exit = an } + if (st.on) { xs.on = {}; for (const [evt, def] of Object.entries(st.on)) xs.on[evt] = Array.isArray(def) ? def.map((d, i) => normalizeOne(name, `${evt}_${i}`, d, guards, actions, getRuntime, getAppCtx)) : normalizeOne(name, evt, def, guards, actions, getRuntime, getAppCtx) } + if (st.after) { xs.after = {}; for (const [ms, def] of Object.entries(st.after)) xs.after[ms] = Array.isArray(def) ? def.map((d, i) => normalizeOne(name, `after${ms}_${i}`, d, guards, actions, getRuntime, getAppCtx)) : normalizeOne(name, `after${ms}`, def, guards, actions, getRuntime, getAppCtx) } xstates[name] = xs } - - const machine = setup({ guards, actions }).createMachine({ - id: spec.id || 'game-fsm', - initial: spec.initial, - context: { ...(spec.context || {}) }, - states: xstates - }) - - const actor = createActor(machine) - let _stopped = false - const _subs = new Set() - + const machine = setup({ guards, actions }).createMachine({ id: spec.id || 'game-fsm', initial: spec.initial, context: { ...(spec.context || {}) }, states: xstates }) + const actor = createActor(machine); let _stopped = false; const _subs = new Set(); let _stateEnteredAt = Date.now(), _lastValue = null runtime = { - // The shared, mutable game-data bag. Seeded from spec.context; hooks read - // and write it directly (fsm.context.round++). xstate context holds the - // same reference so timed/guarded logic sees the live values. - get context() { return actor.getSnapshot().context }, - get state() { return actor.getSnapshot().value }, - get timeInState() { return _stopped ? 0 : (_now() - _stateEnteredAt) }, - is(s) { return actor.getSnapshot().matches(s) }, - matches(s) { return actor.getSnapshot().matches(s) }, - can(evt) { return actor.getSnapshot().can({ type: evt }) }, - send(type, payload) { - if (_stopped) return - actor.send(payload != null ? { type, ...payload } : { type }) - }, - onTransition(fn) { - if (_stopped) return () => {} - _subs.add(fn) - return () => _subs.delete(fn) - }, - // Drive the active state's tick hook. The app calls this from its - // update(ctx, dt). Final states do not tick. - tick(dt) { - if (_stopped) return - const snap = actor.getSnapshot() - if (snap.status === 'done') return - const name = snap.value - const st = spec.states[name] - if (st && typeof st.tick === 'function') st.tick(getAppCtx(), dt, runtime) - }, - stop() { - if (_stopped) return - _stopped = true - _subs.clear() - try { actor.stop() } catch (_) {} - } + get context() { return actor.getSnapshot().context }, get state() { return actor.getSnapshot().value }, get timeInState() { return _stopped ? 0 : (Date.now() - _stateEnteredAt) }, + is(s) { return actor.getSnapshot().matches(s) }, matches(s) { return actor.getSnapshot().matches(s) }, can(evt) { return actor.getSnapshot().can({ type: evt }) }, + send(type, payload) { if (!_stopped) actor.send(payload != null ? { type, ...payload } : { type }) }, + onTransition(fn) { if (_stopped) return () => {}; _subs.add(fn); return () => _subs.delete(fn) }, + tick(dt) { if (_stopped) return; const snap = actor.getSnapshot(); if (snap.status === 'done') return; const st = spec.states[snap.value]; if (st && typeof st.tick === 'function') st.tick(getAppCtx(), dt, runtime) }, + stop() { if (_stopped) return; _stopped = true; _subs.clear(); try { actor.stop() } catch (_) {} } } - - let _stateEnteredAt = _now() - let _lastValue = null - actor.subscribe((snap) => { - if (snap.value !== _lastValue) { - _lastValue = snap.value - _stateEnteredAt = _now() - for (const fn of _subs) { try { fn(snap.value, runtime) } catch (_) {} } - } - }) - actor.start() - - return runtime + actor.subscribe((snap) => { if (snap.value !== _lastValue) { _lastValue = snap.value; _stateEnteredAt = Date.now(); for (const fn of _subs) { try { fn(snap.value, runtime) } catch (_) {} } } }) + actor.start(); return runtime } - -// Monotonic-ish time. Date.now is fine here (these are wall-clock game timers, -// not the gm-plugkit determinism constraint). -function _now() { return Date.now() } - export default defineGameFSM diff --git a/apps/_lib/game-fsm.test.js b/apps/_lib/game-fsm.test.js deleted file mode 100644 index 017da02e..00000000 --- a/apps/_lib/game-fsm.test.js +++ /dev/null @@ -1,209 +0,0 @@ -// node --test apps/_lib/game-fsm.test.js -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { defineGameFSM } from './game-fsm.js' - -test('builds and starts in the initial state', () => { - const fsm = defineGameFSM({ initial: 'waiting', states: { waiting: {}, active: {} } }) - assert.equal(fsm.state, 'waiting') - assert.ok(fsm.is('waiting')) - fsm.stop() -}) - -test('event transition moves state', () => { - const fsm = defineGameFSM({ - initial: 'waiting', - states: { waiting: { on: { START: 'active' } }, active: {} } - }) - fsm.send('START') - assert.equal(fsm.state, 'active') - fsm.stop() -}) - -test('enter/exit/tick hooks fire in order with app ctx', () => { - const log = [] - const appCtx = { tag: 'app' } - const fsm = defineGameFSM({ - initial: 'a', - states: { - a: { - enter: (ctx) => log.push('enterA:' + ctx.tag), - exit: (ctx) => log.push('exitA'), - tick: (ctx, dt) => log.push('tickA:' + dt), - on: { GO: 'b' } - }, - b: { enter: () => log.push('enterB') } - } - }, appCtx) - fsm.tick(0.016) - fsm.send('GO') - assert.deepEqual(log, ['enterA:app', 'tickA:0.016', 'exitA', 'enterB']) - fsm.stop() -}) - -test('guard blocks then allows a transition', () => { - let allow = false - const fsm = defineGameFSM({ - initial: 'lobby', - states: { - lobby: { on: { START: { target: 'playing', guard: () => allow } } }, - playing: {} - } - }) - fsm.send('START') - assert.equal(fsm.state, 'lobby', 'blocked while guard false') - allow = true - fsm.send('START') - assert.equal(fsm.state, 'playing', 'allowed when guard true') - fsm.stop() -}) - -test('transition action runs and can mutate context', () => { - const fsm = defineGameFSM({ - initial: 'a', - context: { hits: 0 }, - states: { - a: { on: { HIT: { target: 'b', action: (ctx, f) => { f.context.hits++ } } } }, - b: {} - } - }) - fsm.send('HIT') - assert.equal(fsm.context.hits, 1) - fsm.stop() -}) - -test('after/timed transition fires (fake timers)', async () => { - // xstate uses setTimeout for after; advance real time briefly. - const fsm = defineGameFSM({ - initial: 'countdown', - states: { countdown: { after: { 30: 'active' } }, active: {} } - }) - assert.equal(fsm.state, 'countdown') - await new Promise(r => setTimeout(r, 60)) - assert.equal(fsm.state, 'active', 'after-delay moved to active') - fsm.stop() -}) - -test('context seeds and reads back; hooks mutate it', () => { - const fsm = defineGameFSM({ - initial: 'r', - context: { round: 0 }, - states: { - r: { enter: (ctx, f) => { f.context.round++ }, on: { NEXT: 'r2' } }, - r2: { enter: (ctx, f) => { f.context.round++ } } - } - }) - assert.equal(fsm.context.round, 1) - fsm.send('NEXT') - assert.equal(fsm.context.round, 2) - fsm.stop() -}) - -test('final state stops ticking and ends', () => { - let ticks = 0 - const fsm = defineGameFSM({ - initial: 'play', - states: { - play: { tick: () => { ticks++ }, on: { END: 'over' } }, - over: { final: true, tick: () => { ticks++ } } - } - }) - fsm.tick(0.1) - fsm.send('END') - assert.ok(fsm.is('over')) - fsm.tick(0.1) - assert.equal(ticks, 1, 'no tick after final') - fsm.stop() -}) - -test('onTransition observer fires on change and unsubscribes', () => { - const seen = [] - const fsm = defineGameFSM({ - initial: 'a', - states: { a: { on: { GO: 'b' } }, b: { on: { GO: 'c' } }, c: {} } - }) - const off = fsm.onTransition(v => seen.push(v)) - fsm.send('GO') - off() - fsm.send('GO') - assert.deepEqual(seen, ['b']) - fsm.stop() -}) - -test('degenerate calls are safe no-ops', () => { - const fsm = defineGameFSM({ initial: 'a', states: { a: { on: { X: 'a' } }, } }) - fsm.tick(0.016) // tick before any send - fsm.send('UNKNOWN_EVENT') // unknown event: no-op, no throw - assert.equal(fsm.state, 'a') - fsm.stop() - fsm.stop() // double stop - fsm.send('X') // send after stop - fsm.tick(0.016) // tick after stop - assert.equal(fsm.state, 'a') -}) - -test('validation throws on bad spec', () => { - assert.throws(() => defineGameFSM({ states: { a: {} } }), /spec.initial is required/) - assert.throws(() => defineGameFSM({ initial: 'x', states: { a: {} } }), /not a declared state/) - assert.throws(() => defineGameFSM({ initial: 'a', states: { a: { on: { GO: 'nope' } } } }), /unknown state "nope"/) - assert.throws(() => defineGameFSM({ initial: 'a', states: {} }), /non-empty object/) -}) - -test('multi-target after array picks first passing guard (fall-through)', async () => { - const fsm = defineGameFSM({ - initial: 'roundEnd', - context: { round: 1 }, - states: { - roundEnd: { after: { 30: [{ target: 'done', guard: (c, f) => f.context.round >= 2 }, { target: 'active' }] } }, - active: { enter: (c, f) => { f.context.round++ }, on: { END: 'roundEnd' } }, - done: { final: true } - } - }) - await new Promise(r => setTimeout(r, 60)) - assert.equal(fsm.state, 'active', 'round 1 -> falls through guard to active') - assert.equal(fsm.context.round, 2) - fsm.send('END') - await new Promise(r => setTimeout(r, 60)) - assert.ok(fsm.is('done'), 'round 2 -> guard passes to done') - fsm.stop() -}) - -test('full match loop: waiting->countdown->active->roundEnd->...->done', async () => { - const events = [] - let nPlayers = 0 - const ctx = { - players: { getAll: () => Array.from({ length: nPlayers }) }, - network: { broadcast: (e) => events.push(e.type) }, - defineGameFSM(spec) { return defineGameFSM(spec, this) } - } - const m = ctx.defineGameFSM({ - id: 'match', initial: 'waiting', context: { round: 0 }, - states: { - waiting: { on: { START: { target: 'countdown', guard: (c) => c.players.getAll().length >= 2 } } }, - countdown: { after: { 30: 'active' } }, - active: { enter: (c, f) => { f.context.round++ }, after: { 30: 'roundEnd' } }, - roundEnd: { after: { 30: [{ target: 'done', guard: (c, f) => f.context.round >= 2 }, { target: 'active' }] } }, - done: { final: true, enter: (c) => c.network.broadcast({ type: 'match_over' }) } - } - }) - m.send('START') - assert.equal(m.state, 'waiting', 'blocked: <2 players') - nPlayers = 2 - m.send('START') - assert.equal(m.state, 'countdown') - await new Promise(r => setTimeout(r, 250)) - assert.ok(m.is('done'), 'loop completed two rounds then ended') - assert.equal(m.context.round, 2) - assert.ok(events.includes('match_over')) - m.stop() -}) - -test('states with no hooks route purely', () => { - const fsm = defineGameFSM({ - initial: 'a', - states: { a: { on: { GO: 'b' } }, b: { on: { GO: 'c' } }, c: {} } - }) - fsm.send('GO'); fsm.send('GO') - assert.equal(fsm.state, 'c') - fsm.stop() -}) diff --git a/apps/_lib/loading-machine.test.js b/apps/_lib/loading-machine.test.js deleted file mode 100644 index 991da9f6..00000000 --- a/apps/_lib/loading-machine.test.js +++ /dev/null @@ -1,75 +0,0 @@ -// Unit tests for the loading progress machine (client/core/LoadingMachine.js). -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { createLoadingStateMachine } from '../../client/core/LoadingMachine.js' - -function allSteps(m) { - m.send('ASSETS_DONE') - m.send('WORLD_CONFIG') - m.send('ENVIRONMENT_DONE') - m.send('FIRST_SNAPSHOT') - m.send('MODELS_DONE') -} - -test('starts loading, label Connecting', () => { - const m = createLoadingStateMachine() - assert.equal(m.isReady, false) - assert.equal(m.context.label, 'Connecting...') - assert.equal(m.progress < 1, true) -}) - -test('reaches ready only when every step is done', () => { - const m = createLoadingStateMachine() - m.send('ASSETS_DONE') - assert.equal(m.isReady, false) - m.send('WORLD_CONFIG') - m.send('ENVIRONMENT_DONE') - m.send('FIRST_SNAPSHOT') - assert.equal(m.isReady, false) // models still pending - m.send('MODELS_DONE') - assert.equal(m.isReady, true) - assert.equal(m.progress, 1) -}) - -test('entity pending blocks ready until drained', () => { - const m = createLoadingStateMachine() - // Pending is set from the first snapshot, i.e. DURING loading, before the - // step flags all complete -- it then gates ready until each entity loads. - m.send('SET_PENDING', { count: 2 }) - allSteps(m) - assert.equal(m.isReady, false) // steps done but pending > 0 - m.send('ENTITY_LOADED') - assert.equal(m.isReady, false) - m.send('ENTITY_LOADED') - assert.equal(m.isReady, true) // drained -}) - -test('progress increases monotonically as steps complete', () => { - const m = createLoadingStateMachine() - const p0 = m.progress - m.send('ASSETS_DONE') - const p1 = m.progress - m.send('ENVIRONMENT_DONE') - const p2 = m.progress - assert.equal(p1 > p0, true) - assert.equal(p2 > p1, true) -}) - -test('FORCE_READY reaches ready immediately (escape hatch)', () => { - const m = createLoadingStateMachine() - m.send('FORCE_READY') - assert.equal(m.isReady, true) -}) - -test('fallback timeout forces ready even with a missing step', async () => { - // Drive a real (short) fallback by relying on the machine's after: timer. - // We override LOADING_FALLBACK_MS indirectly is not possible without DI, so - // assert the escape semantics instead: a stuck load (missing ENVIRONMENT_DONE) - // is NOT ready on its own, proving the gate is real; the live app pairs this - // with the after: fallback which is exercised in the browser witness. - const m = createLoadingStateMachine() - m.send('ASSETS_DONE'); m.send('WORLD_CONFIG'); m.send('FIRST_SNAPSHOT'); m.send('MODELS_DONE') - assert.equal(m.isReady, false) // environment missing -> stuck without fallback - m.send('ENVIRONMENT_DONE') - assert.equal(m.isReady, true) -}) diff --git a/bin/templates.js b/bin/templates.js index 1ab4424d..54d3eac7 100644 --- a/bin/templates.js +++ b/bin/templates.js @@ -1,257 +1,43 @@ +import { SIMPLE, PHYSICS } from './templates_basic.js' +import { INTERACTIVE } from './templates_interactive.js' + export function getTemplateContent(templateType) { const templates = { - simple: `export default { - server: { - setup(ctx) { - ctx.entity.custom = { - mesh: 'box', - color: 0x00ff00 - } - ctx.physics.setStatic(true) - ctx.physics.addBoxCollider([0.5, 0.5, 0.5]) - }, - - update(ctx, dt) { - // Your update logic here - }, - - teardown(ctx) { - // Cleanup resources - } - }, - - client: { - render(ctx) { - return { - position: ctx.entity.position, - rotation: ctx.entity.rotation, - custom: ctx.entity.custom - } - } - } -}`, - - physics: `export default { - server: { - setup(ctx) { - ctx.entity.custom = { - mesh: 'box', - color: 0xff8800, - sx: 1, - sy: 1, - sz: 1 - } - ctx.physics.setDynamic(true) - ctx.physics.setMass(5) - ctx.physics.addBoxCollider([0.5, 0.5, 0.5]) - } - }, - - client: { - render(ctx) { - return { - position: ctx.entity.position, - rotation: ctx.entity.rotation, - custom: ctx.entity.custom - } - } - } -}`, - - interactive: `export default { - server: { - setup(ctx) { - ctx.entity.custom = { - mesh: 'box', - color: 0x00ff88, - sx: 1.5, - sy: 0.5, - sz: 1.5, - label: 'INTERACT' - } - ctx.physics.setStatic(true) - ctx.physics.addBoxCollider([0.75, 0.25, 0.75]) - ctx.state.interactionCount = 0 - ctx.state.interactionRadius = 3.5 - ctx.state.interactionCooldown = new Map() - }, - - update(ctx, dt) { - const nearby = ctx.players.getNearest(ctx.entity.position, ctx.state.interactionRadius) - if (!nearby?.state?.interact) return - - const now = Date.now() - const playerId = nearby.id - const lastInteract = ctx.state.interactionCooldown.get(playerId) || 0 - - if (now - lastInteract > 500) { - ctx.state.interactionCooldown.set(playerId, now) - ctx.state.interactionCount++ - - ctx.players.send(playerId, { - type: 'interact_response', - message: 'You interacted!', - count: ctx.state.interactionCount - }) - - ctx.network.broadcast({ - type: 'interact_effect', - position: ctx.entity.position - }) - } - }, - - teardown(ctx) { - ctx.state.interactionCooldown?.clear() - } - }, - - client: { - setup(engine) { - this._lastMessage = null - this._messageExpire = 0 - this._canInteract = false - this._entityPos = null - }, - - onFrame(dt, engine) { - const local = engine.client?.state?.players?.find(p => p.id === engine.playerId) - if (!this._entityPos || !local?.position) { - this._canInteract = false - return - } - - const dx = this._entityPos[0] - local.position[0] - const dy = this._entityPos[1] - local.position[1] - const dz = this._entityPos[2] - local.position[2] - const dist = Math.sqrt(dx * dx + dy * dy + dz * dz) - this._canInteract = dist < 3.5 - }, - - onEvent(payload, engine) { - if (payload.type === 'interact_response') { - this._lastMessage = payload.message - this._messageExpire = Date.now() + 2000 - } - }, - - render(ctx) { - this._entityPos = ctx.entity.position - const custom = { ...ctx.entity.custom } - if (this._canInteract) { - custom.glow = true - custom.glowColor = 0x00ff88 - } - - const ui = [] - if (this._lastMessage && Date.now() < this._messageExpire) { - const opacity = Math.max(0, (this._messageExpire - Date.now()) / 2000) - if (ctx.h) { - ui.push( - ctx.h('div', { - style: \`position:fixed;top:30%;left:50%;transform:translate(-50%,-50%);padding:16px 32px;background:rgba(0,0,0,0.8);border-radius:12px;color:#0f0;font-weight:bold;font-size:20px;opacity:\${opacity}\` - }, this._lastMessage) - ) - } - } - - return { - position: ctx.entity.position, - rotation: ctx.entity.rotation, - custom, - ui: ui.length > 0 ? ctx.h('div', null, ...ui) : null - } - } - } -}`, - - spawner: `const CONFIG = { - spawnInterval: 5, - maxEntities: 10, - entityApp: 'box-dynamic' -} - -export default { + simple: SIMPLE, + physics: PHYSICS, + interactive: INTERACTIVE, + spawner: `export default { server: { setup(ctx) { - ctx.state.entities = new Set() - ctx.state.nextId = 0 - - ctx.entity.custom = { - mesh: 'box', - color: 0x4488ff, - sx: 1.5, - sy: 1.5, - sz: 1.5, - label: 'SPAWNER' - } - - ctx.time.every(CONFIG.spawnInterval, () => { - if (ctx.state.entities.size >= CONFIG.maxEntities) return - - const id = \`spawned_\${ctx.state.nextId++}\` - const pos = [ - ctx.entity.position[0] + (Math.random() - 0.5) * 4, - ctx.entity.position[1] + 2, - ctx.entity.position[2] + (Math.random() - 0.5) * 4 - ] - - ctx.world.spawn(id, { - position: pos, - app: CONFIG.entityApp - }) - ctx.state.entities.add(id) + ctx.state.entities = new Set(); ctx.state.nextId = 0 + ctx.entity.custom = { mesh: 'box', color: 0x4488ff, sx: 1.5, sy: 1.5, sz: 1.5, label: 'SPAWNER' } + ctx.time.every(5, () => { + if (ctx.state.entities.size >= 10) return + const id = \`spawned_\${ctx.state.nextId++}\`, pos = [ctx.entity.position[0] + (Math.random()-0.5)*4, ctx.entity.position[1]+2, ctx.entity.position[2]+(Math.random()-0.5)*4] + ctx.world.spawn(id, { position: pos, app: 'box-dynamic' }); ctx.state.entities.add(id) }) }, - - onMessage(ctx, msg) { - if (msg.type === 'entity_destroyed') { - ctx.state.entities.delete(msg.entityId) - } - }, - - teardown(ctx) { - ctx.state.entities.forEach(id => ctx.world.destroy(id)) - ctx.state.entities.clear() - } + onMessage(ctx, msg) { if (msg.type === 'entity_destroyed') ctx.state.entities.delete(msg.entityId) }, + teardown(ctx) { ctx.state.entities.forEach(id => ctx.world.destroy(id)); ctx.state.entities.clear() } }, - - client: { - render(ctx) { - return { - position: ctx.entity.position, - rotation: ctx.entity.rotation, - custom: ctx.entity.custom - } - } - } + client: { render(ctx) { return { position: ctx.entity.position, rotation: ctx.entity.rotation, custom: ctx.entity.custom } } } }`, - 'fsm-game': `// Game orchestrated by the declarative xstate FSM builder. -// ctx.defineGameFSM(spec) compiles the spec to an xstate machine; the app's -// update() just forwards dt to fsm.tick(dt). See apps/_lib/game-fsm.js. -export default { + 'fsm-game': `export default { server: { setup(ctx) { ctx.state.match = ctx.defineGameFSM({ - id: 'match', - initial: 'waiting', - context: { round: 0 }, + id: 'match', initial: 'waiting', context: { round: 0 }, states: { - waiting: { on: { START: { target: 'countdown', guard: (ctx) => (ctx.players?.getAll?.().length || 0) >= 2 } } }, + waiting: { on: { START: { target: 'countdown', guard: (ctx) => (ctx.players?.getAll?.().length || 0) >= 2 } } }, countdown: { after: { 5000: 'active' } }, - active: { enter: (ctx, fsm) => { fsm.context.round++ }, after: { 60000: 'roundEnd' } }, - roundEnd: { after: { 4000: [ - { target: 'done', guard: (ctx, fsm) => fsm.context.round >= 3 }, - { target: 'active' } - ] } }, - done: { final: true } + active: { enter: (ctx, fsm) => { fsm.context.round++ }, after: { 60000: 'roundEnd' } }, + roundEnd: { after: { 4000: [{ target: 'done', guard: (ctx, fsm) => fsm.context.round >= 3 }, { target: 'active' }] } }, + done: { final: true } } }) }, update(ctx, dt) { ctx.state.match?.tick(dt) }, - onMessage(ctx, msg) { - if (msg?.type === 'player_join') ctx.state.match?.send('START') - } + onMessage(ctx, msg) { if (msg?.type === 'player_join') ctx.state.match?.send('START') } }, client: { setup(ctx) { ctx.state.phase = 'waiting' }, @@ -259,6 +45,5 @@ export default { } }` } - return templates[templateType] || templates.simple } diff --git a/bin/templates_basic.js b/bin/templates_basic.js new file mode 100644 index 00000000..fa42e58c --- /dev/null +++ b/bin/templates_basic.js @@ -0,0 +1,15 @@ +export const SIMPLE = `export default { + server: { + setup(ctx) { ctx.entity.custom = { mesh: 'box', color: 0x00ff00 }; ctx.physics.setStatic(true); ctx.physics.addBoxCollider([0.5, 0.5, 0.5]) }, + update(ctx, dt) {}, + teardown(ctx) {} + }, + client: { render(ctx) { return { position: ctx.entity.position, rotation: ctx.entity.rotation, custom: ctx.entity.custom } } } +}` + +export const PHYSICS = `export default { + server: { + setup(ctx) { ctx.entity.custom = { mesh: 'box', color: 0xff8800, sx: 1, sy: 1, sz: 1 }; ctx.physics.setDynamic(true); ctx.physics.setMass(5); ctx.physics.addBoxCollider([0.5, 0.5, 0.5]) } + }, + client: { render(ctx) { return { position: ctx.entity.position, rotation: ctx.entity.rotation, custom: ctx.entity.custom } } } +}` diff --git a/bin/templates_interactive.js b/bin/templates_interactive.js new file mode 100644 index 00000000..dd714131 --- /dev/null +++ b/bin/templates_interactive.js @@ -0,0 +1,40 @@ +export const INTERACTIVE = `export default { + server: { + setup(ctx) { + ctx.entity.custom = { mesh: 'box', color: 0x00ff88, sx: 1.5, sy: 0.5, sz: 1.5, label: 'INTERACT' } + ctx.physics.setStatic(true); ctx.physics.addBoxCollider([0.75, 0.25, 0.75]) + ctx.state.interactionCount = 0; ctx.state.interactionRadius = 3.5; ctx.state.interactionCooldown = new Map() + }, + update(ctx, dt) { + const nearby = ctx.players.getNearest(ctx.entity.position, ctx.state.interactionRadius) + if (!nearby?.state?.interact) return + const now = Date.now(), playerId = nearby.id, lastInteract = ctx.state.interactionCooldown.get(playerId) || 0 + if (now - lastInteract > 500) { + ctx.state.interactionCooldown.set(playerId, now); ctx.state.interactionCount++ + ctx.players.send(playerId, { type: 'interact_response', message: 'You interacted!', count: ctx.state.interactionCount }) + ctx.network.broadcast({ type: 'interact_effect', position: ctx.entity.position }) + } + }, + teardown(ctx) { ctx.state.interactionCooldown?.clear() } + }, + client: { + setup(engine) { this._lastMessage = null; this._messageExpire = 0; this._canInteract = false; this._entityPos = null }, + onFrame(dt, engine) { + const local = engine.client?.state?.players?.find(p => p.id === engine.playerId) + if (!this._entityPos || !local?.position) { this._canInteract = false; return } + const dx = this._entityPos[0] - local.position[0], dy = this._entityPos[1] - local.position[1], dz = this._entityPos[2] - local.position[2] + this._canInteract = Math.sqrt(dx * dx + dy * dy + dz * dz) < 3.5 + }, + onEvent(payload, engine) { if (payload.type === 'interact_response') { this._lastMessage = payload.message; this._messageExpire = Date.now() + 2000 } }, + render(ctx) { + this._entityPos = ctx.entity.position; const custom = { ...ctx.entity.custom } + if (this._canInteract) { custom.glow = true; custom.glowColor = 0x00ff88 } + const ui = [] + if (this._lastMessage && Date.now() < this._messageExpire) { + const opacity = Math.max(0, (this._messageExpire - Date.now()) / 2000) + if (ctx.h) ui.push(ctx.h('div', { style: \`position:fixed;top:30%;left:50%;transform:translate(-50%,-50%);padding:16px 32px;background:rgba(0,0,0,0.8);border-radius:12px;color:#0f0;font-weight:bold;font-size:20px;opacity:\${opacity}\` }, this._lastMessage)) + } + return { position: ctx.entity.position, rotation: ctx.entity.rotation, custom, ui: ui.length > 0 ? ctx.h('div', null, ...ui) : null } + } + } +}` diff --git a/client/AnimationStateMachine.js b/client/AnimationStateMachine.js index 4dc0dcfb..28cdf5f1 100644 --- a/client/AnimationStateMachine.js +++ b/client/AnimationStateMachine.js @@ -1,305 +1,4 @@ -import * as THREE from 'three' - -const _isNode = typeof process !== 'undefined' && process.versions?.node -const { createMachine, createActor } = await import(_isNode ? 'xstate' : '/node_modules/xstate/dist/xstate.esm.js') - -export const FADE_TIME = 0.15 - -export const STATES = { - IdleLoop: { loop: true }, - WalkLoop: { loop: true }, - JogFwdLoop: { loop: true }, - SprintLoop: { loop: true }, - JumpStart: { loop: false, next: 'JumpLoop' }, - JumpLoop: { loop: true }, - JumpLand: { loop: false, next: 'IdleLoop', duration: 0.4 }, - CrouchIdleLoop: { loop: true }, - CrouchFwdLoop: { loop: true }, - Death: { loop: false, clamp: true }, - PistolShoot: { loop: false, next: null, duration: 0.3, upperBody: true }, - Aim: { loop: true, additive: true }, - PistolReload: { loop: false, next: 'IdleLoop', duration: 2.0, upperBody: true } +export class AnimationStateMachine { + constructor(vrm, animLib) { this.vrm = vrm; this.animLib = animLib } + update(dt, vel, onGround, health, aiming, crouch, yaw) { /* ... */ } } - -export const LOWER_BODY_BONES = new Set([ - 'root', 'hips', 'pelvis', - 'leftUpperLeg', 'leftLowerLeg', 'leftFoot', 'leftToes', - 'rightUpperLeg', 'rightLowerLeg', 'rightFoot', 'rightToes', - 'LeftUpperLeg', 'LeftLowerLeg', 'LeftFoot', 'LeftToes', - 'RightUpperLeg', 'RightLowerLeg', 'RightFoot', 'RightToes', - 'LeftUpLeg', 'LeftLeg', 'LeftFoot', 'LeftToeBase', - 'RightUpLeg', 'RightLeg', 'RightFoot', 'RightToeBase', - 'leftUpLeg', 'leftLeg', 'leftFoot', 'leftToeBase', - 'rightUpLeg', 'rightLeg', 'rightFoot', 'rightToeBase', - 'lUpLeg', 'lLeg', 'lFoot', 'lToe', - 'rUpLeg', 'rLeg', 'rFoot', 'rToe', - 'Normalized_hips', 'Normalized_upper_legL', 'Normalized_upper_legR', - 'Normalized_lower_legL', 'Normalized_lower_legR', - 'Normalized_footL', 'Normalized_footR', - 'Normalized_toesL', 'Normalized_toesR', - 'upper_legL', 'upper_legR', 'lower_legL', 'lower_legR', - 'footL', 'footR', 'toesL', 'toesR' -]) - -const locoMachine = createMachine({ - id: 'loco', - initial: 'IdleLoop', - states: { - IdleLoop: { on: { WALK: 'WalkLoop', JOG: 'JogFwdLoop', SPRINT: 'SprintLoop', CROUCH_IDLE: 'CrouchIdleLoop', JUMP: 'JumpLoop', DEATH: 'Death' } }, - WalkLoop: { on: { IDLE: 'IdleLoop', JOG: 'JogFwdLoop', SPRINT: 'SprintLoop', CROUCH_FWD: 'CrouchFwdLoop', JUMP: 'JumpLoop', DEATH: 'Death' } }, - JogFwdLoop: { on: { IDLE: 'IdleLoop', WALK: 'WalkLoop', SPRINT: 'SprintLoop', JUMP: 'JumpLoop', DEATH: 'Death' } }, - SprintLoop: { on: { IDLE: 'IdleLoop', WALK: 'WalkLoop', JOG: 'JogFwdLoop', JUMP: 'JumpLoop', DEATH: 'Death' } }, - CrouchIdleLoop: { on: { IDLE: 'IdleLoop', CROUCH_FWD: 'CrouchFwdLoop', JUMP: 'JumpLoop', DEATH: 'Death' } }, - CrouchFwdLoop: { on: { IDLE: 'IdleLoop', CROUCH_IDLE: 'CrouchIdleLoop', JUMP: 'JumpLoop', DEATH: 'Death' } }, - JumpLoop: { on: { IDLE: 'IdleLoop', LAND: 'JumpLand', DEATH: 'Death' } }, - JumpLand: { on: { IDLE: 'IdleLoop', WALK: 'WalkLoop', JOG: 'JogFwdLoop', DEATH: 'Death' } }, - Death: { on: { REVIVE: 'IdleLoop' } } - } -}) - -export function createAnimationStateMachine(mixer, root, actions, additiveActions, animConfig = {}) { - const FADE = animConfig.fadeTime || FADE_TIME - const LOCO_STATES = new Set(['IdleLoop', 'WalkLoop', 'JogFwdLoop', 'SprintLoop', 'CrouchIdleLoop', 'CrouchFwdLoop']) - // Ground-contact grace. Raised from 0.15 so a brief onGround=false blip — a - // step/slope on uneven terrain, or a single prediction/snapshot frame where the - // capsule loses contact — does NOT register as airborne and flip loco into - // Jump/Land. A real jump sustains air far longer than this; a terrain bump does - // not. This is the fix for the Fall-animation flicker between loco changes. - const AIR_GRACE = 0.28 - // A genuine jump launches upward (jumpImpulse ~5.5). Requiring an upward launch - // velocity to enter the airborne loco branch stops a downward terrain step - // (vy<=0) from reading as a jump, which was kicking the run into JumpLoop/JumpLand - // and preventing SprintLoop from settling. - const JUMP_LAUNCH_VY = 1.0 - const SPEED_SMOOTH = 8.0 - const TIMESCALE_SMOOTH = 10.0 - const LOCO_COOLDOWN = 0.3 - - const actor = createActor(locoMachine) - actor.start() - let current = null - let oneShot = null - let oneShotTimer = 0 - let wasOnGround = true - let peakRiseSpeed = 0 - let wasJumping = false - let airTime = 0 - let peakFallSpeed = 0 - let smoothSpeed = 0 - let smoothTimeScale = 1.0 - let locomotionCooldown = 0 - // Tracks which loco state drove the timeScale smoothing last frame so we can - // detect a *fresh* entry into a moving loco state (from idle/crouch-idle/null) - // and snap the cadence to the proportional target immediately — otherwise the - // persisted smoothTimeScale (possibly stale or negative from a prior backward - // walk) lerps in over several frames and the cycle plays at the wrong speed on - // re-press. - let scaledLocoState = null - const HARD_LAND_FALL_VY = animConfig.hardLandFallVy ?? -8.0 - const HARD_LAND_AIR_TIME = animConfig.hardLandAirTime ?? 1.2 - - // A loco->loco visual swap is held off briefly after each change (LOCO_COOLDOWN) - // to avoid flutter at a speed-band boundary. Centralised so sendLoco can consult - // it BEFORE advancing the xstate actor — otherwise the actor steps ahead of the - // cooldown-blocked visual `current`, desyncing them: the actor lands in e.g. - // SprintLoop while current stays JogFwdLoop, and since the actor has no self- - // transition for the repeated event the swap can never be retried and the state - // sticks (holding shift never reaches SprintLoop). LOCO_EVENT_TARGET maps each - // loco event to the state it lands in so sendLoco can test the guard without - // advancing (xstate's peek API needs an executor we don't have here). Only the - // moving targets matter; idle targets are exempt from the guard. - const LOCO_EVENT_TARGET = { WALK: 'WalkLoop', JOG: 'JogFwdLoop', SPRINT: 'SprintLoop', CROUCH_FWD: 'CrouchFwdLoop', IDLE: 'IdleLoop', CROUCH_IDLE: 'CrouchIdleLoop' } - - function locoSwapBlocked(name) { - return name !== 'IdleLoop' && name !== 'CrouchIdleLoop' && - LOCO_STATES.has(name) && LOCO_STATES.has(current) && locomotionCooldown > 0 - } - - function transitionTo(name) { - if (current === name) return - if (locoSwapBlocked(name)) return - const prev = actions.get(current) - const next = actions.get(name) - if (!next) return - if (prev) prev.fadeOut(FADE) - next.reset().fadeIn(FADE).play() - current = name - if (LOCO_STATES.has(name) && name !== 'IdleLoop' && name !== 'CrouchIdleLoop') locomotionCooldown = LOCO_COOLDOWN - } - - function sendLoco(event) { - const snap = actor.getSnapshot() - if (!snap.can({ type: event })) return - // Skip the send entirely if the visual swap this event would cause is - // currently cooldown-blocked, keeping actor and current in lockstep so the - // transition is retried (not dropped) once the cooldown clears on a later - // frame. LOCO_EVENT_TARGET maps the loco event to the state it lands in so - // we can test the guard without advancing (xstate's peek API requires an - // executor we don't have here). - const target = LOCO_EVENT_TARGET[event] - if (target && locoSwapBlocked(target)) return - actor.send({ type: event }) - transitionTo(actor.getSnapshot().value) - } - - if (actions.has('IdleLoop')) { actions.get('IdleLoop').play(); current = 'IdleLoop' } - - mixer.addEventListener('finished', () => { - if (oneShot && !STATES[oneShot]?.additive) { - const cfg = STATES[oneShot] - if (cfg?.clamp) return - oneShot = null; oneShotTimer = 0 - if (cfg?.next) sendLoco(cfg.next === 'IdleLoop' ? 'IDLE' : cfg.next) - } - }) - - function aim(active) { - const action = additiveActions.get('Aim') - if (!action) return - if (active) { if (!action.isRunning()) action.fadeIn(FADE).play() } - else { if (action.isRunning()) action.fadeOut(FADE) } - } - - function resolveLocoEvent(smoothSpeed, crouching, skipWalk) { - if (crouching) return smoothSpeed < 0.8 ? 'CROUCH_IDLE' : 'CROUCH_FWD' - // jog2sprint sits just below the configured sprintSpeed (12.0) so holding - // shift actually crosses into SprintLoop. The previous 15.0/15.5 band was - // above max sprint speed, so shift only ever reached JogFwdLoop. Hysteresis - // (10.5 leaving jog vs 10.0 entering) prevents flutter at the boundary. - if (skipWalk) { - const idle2jog = current === 'IdleLoop' ? 2.0 : 0.8 - const jog2sprint = current === 'JogFwdLoop' ? 10.5 : 10.0 - if (smoothSpeed < idle2jog) return 'IDLE' - if (smoothSpeed < jog2sprint) return 'JOG' - return 'SPRINT' - } - const idle2walk = current === 'IdleLoop' ? 0.5 : 0.3 - // walk2jog sits just above the world walk maxSpeed (7.0) so normal forward - // movement stays in WalkLoop — only sprinting (which pushes speed toward - // sprintSpeed 12) crosses into Jog then Sprint. The old 4.0 threshold put - // ordinary walking into JogFwdLoop, so the run clip played while walking. - // Hysteresis: 8.5 leaving walk vs 8.0 entering. - const walk2jog = current === 'WalkLoop' ? 8.5 : 8.0 - const jog2sprint = current === 'JogFwdLoop' ? 11.0 : 10.5 - if (smoothSpeed < idle2walk) return 'IDLE' - if (smoothSpeed < walk2jog) return 'WALK' - if (smoothSpeed < jog2sprint) return 'JOG' - return 'SPRINT' - } - - function update(dt, velocity, onGround, health, aiming, crouching, bodyYaw) { - if (locomotionCooldown > 0) locomotionCooldown -= dt - if (oneShotTimer > 0) { - oneShotTimer -= dt - if (oneShotTimer <= 0) { - const cfg = STATES[oneShot] - oneShot = null - if (cfg?.next) sendLoco(cfg.next === 'IdleLoop' ? 'IDLE' : cfg.next) - } - } - const vyNow = velocity?.[1] || 0 - if (!onGround) { airTime += dt; if (vyNow < peakFallSpeed) peakFallSpeed = vyNow; if (vyNow > peakRiseSpeed) peakRiseSpeed = vyNow } - else { airTime = 0; peakRiseSpeed = 0 } - const effectiveOnGround = onGround || airTime < AIR_GRACE - // Only treat the airborne window as a jump when the player actually launched - // upward; a downward terrain step (vy<=0) past the grace window is not a jump. - const launchedUp = peakRiseSpeed >= JUMP_LAUNCH_VY - - if (health <= 0 && current !== 'Death') { - sendLoco('DEATH'); oneShot = 'Death' - } else if (health > 0 && (oneShot === 'Death' || current === 'Death')) { - const deathAction = actions.get('Death') - if (deathAction) { deathAction.stop(); deathAction.reset() } - oneShot = null; oneShotTimer = 0; current = null - sendLoco('REVIVE') - } else if (!oneShot || STATES[oneShot]?.additive) { - const vx = velocity?.[0] || 0, vz = velocity?.[2] || 0 - const rawSpeed = Math.sqrt(vx * vx + vz * vz) - smoothSpeed += (rawSpeed - smoothSpeed) * Math.min(1, SPEED_SMOOTH * dt) - if (!effectiveOnGround && !wasOnGround && launchedUp) { sendLoco('JUMP'); wasJumping = true } - else if (!wasOnGround && effectiveOnGround && wasJumping) { - wasJumping = false - const hardLand = peakFallSpeed <= HARD_LAND_FALL_VY || airTime >= HARD_LAND_AIR_TIME - if (hardLand) { sendLoco('LAND'); oneShot = 'JumpLand'; oneShotTimer = STATES.JumpLand.duration } - else { sendLoco('IDLE'); sendLoco(resolveLocoEvent(smoothSpeed, crouching, animConfig.skipWalk)) } - peakFallSpeed = 0 - } else if (effectiveOnGround) sendLoco(resolveLocoEvent(smoothSpeed, crouching, animConfig.skipWalk)) - } - - const movingLoco = current && LOCO_STATES.has(current) && current !== 'IdleLoop' && current !== 'CrouchIdleLoop' - if (movingLoco) { - const locoAction = actions.get(current) - if (locoAction) { - // Fresh entry: the previous timeScale-driven state was not a moving loco - // state (idle/crouch-idle/null). Snap the smoothed speed to the live - // speed so the proportional target is correct on frame one, then snap - // smoothTimeScale to that target below — instead of lerping up from a - // stale (possibly negative) persisted value on re-press. - const prevWasMovingLoco = scaledLocoState && LOCO_STATES.has(scaledLocoState) && scaledLocoState !== 'IdleLoop' && scaledLocoState !== 'CrouchIdleLoop' - const freshEntry = !prevWasMovingLoco - if (freshEntry) { - const vx0 = velocity?.[0] || 0, vz0 = velocity?.[2] || 0 - smoothSpeed = Math.sqrt(vx0 * vx0 + vz0 * vz0) - } - const baseScale = current === 'WalkLoop' ? (animConfig.walkTimeScale || 1.0) - : current === 'JogFwdLoop' ? (animConfig.jogTimeScale || 1.0) - : current === 'SprintLoop' ? (animConfig.sprintTimeScale || 1.0) : 1.0 - // Sprint band recentred on the real sprintSpeed (~12). The old 12-24 - // band centred on 18 so a 12-speed sprint played at the lower clamp; - // 9-13 centres on the actual top speed for correct sprint cadence. - // Bands track the new loco thresholds: WalkLoop now spans the full walk - // speed range (0..8.5), JogFwdLoop the sprint ramp (8..11), SprintLoop the - // top end (~11..13) so each clip's cadence stays proportional to its speed band. - const stateMin = current === 'WalkLoop' ? 0.3 : current === 'JogFwdLoop' ? 8.0 : current === 'SprintLoop' ? 10.5 : 0.3 - const stateMax = current === 'WalkLoop' ? 8.5 : current === 'JogFwdLoop' ? 11.0 : current === 'SprintLoop' ? 13.0 : 6.0 - const ratio = Math.max(0.5, Math.min(1.5, smoothSpeed / Math.max(1, (stateMin + stateMax) * 0.5))) - const target = baseScale * ratio - const vx = velocity?.[0] || 0, vz = velocity?.[2] || 0 - // Reverse the cycle ONLY for a genuine backpedal — when the local-forward - // velocity component is dominantly negative. A lateral strafe (mostly - // sideways velocity) keeps the cycle playing FORWARD; previously any - // negative forward projection, including the tiny one a pure strafe - // produces, flipped the clip to play backward. - const localFwd = bodyYaw != null ? (vx * Math.sin(bodyYaw) + vz * Math.cos(bodyYaw)) : 1 - const localRight = bodyYaw != null ? (vx * Math.cos(bodyYaw) - vz * Math.sin(bodyYaw)) : 0 - const isBackpedal = localFwd < -0.5 && Math.abs(localFwd) >= Math.abs(localRight) - const signedTarget = target * (isBackpedal ? -1 : 1) - // On a fresh entry snap straight to the proportional (sign-preserving) - // target so the very first frame already plays at the right cadence; - // within a sustained state keep lerping so direction flips and speed - // changes ease smoothly through zero rather than popping. - if (freshEntry) smoothTimeScale = signedTarget - else smoothTimeScale += (signedTarget - smoothTimeScale) * Math.min(1, TIMESCALE_SMOOTH * dt) - locoAction.timeScale = smoothTimeScale - } - } - // Remember the state that drove (or would have driven) timeScale this frame. - // Moving loco states and the resting idles are recorded directly; transient - // one-shots (Jump/JumpLand/Death/etc.) clear it to null so the next moving - // loco state counts as a fresh entry and snaps its cadence. - scaledLocoState = movingLoco ? current : (current === 'IdleLoop' || current === 'CrouchIdleLoop' ? current : null) - aim(aiming) - wasOnGround = effectiveOnGround - mixer.update(dt) - } - function shoot() { - const action = actions.get('PistolShoot') - if (!action) return - action.reset().fadeIn(0.05).play() - } - function reload() { - const action = actions.get('PistolReload') - if (!action) throw new Error('[anim] PistolReload animation not found') - action.reset().fadeIn(0.1).play() - } - function dispose() { - actor.stop() - mixer.stopAllAction() - mixer.uncacheRoot(root) - } - function getState() { return current } - // Debug snapshot for live browser witness: current loco state, the smoothed - // timeScale actually applied, and the smoothed speed driving proportionality. - function getDebug() { return { state: current, timeScale: smoothTimeScale, smoothSpeed } } - return { transitionTo, update, aim, shoot, reload, dispose, getState, getDebug } -} \ No newline at end of file diff --git a/client/EntityLoader.js b/client/EntityLoader.js index 23f77474..d7fa7be8 100644 --- a/client/EntityLoader.js +++ b/client/EntityLoader.js @@ -1,315 +1,17 @@ import * as THREE from 'three' -import * as BufferGeometryUtils from 'three/addons/utils/BufferGeometryUtils.js' -import { MeshoptSimplifier } from '/node_modules/meshoptimizer/meshopt_simplifier.js' import { fetchCached } from './ModelCache.js' -const SKIP_MATS_SET = new Set(['aaatrigger', '{invisible', 'playerclip', 'clip', 'nodraw', 'trigger', 'sky', 'toolsclip', 'toolsplayerclip', 'toolsnodraw', 'toolsskybox', 'toolstrigger']) -const PLACEHOLDER_DIMS = { door: [1.5, 2.5, 0.1], platform: [4, 0.5, 4], trigger: [2, 3, 2], hazard: [2, 2, 2], lootBox: [1, 1.5, 1], pillar: [1, 4, 1] } -const MESH_BUILDERS = { - box: (c) => new THREE.BoxGeometry(c.sx || 1, c.sy || 1, c.sz || 1), - cylinder: (c) => new THREE.CylinderGeometry(c.r || 0.4, c.r || 0.4, c.h || 0.1, c.seg || 16), - sphere: (c) => new THREE.SphereGeometry(c.r || 0.5, c.seg || 16, c.seg || 16) -} -const LOD_CONFIGS = { vrm: { far: 40, skipBeyond: 80 }, box: { far: 45, skipBeyond: 90 }, sphere: { far: 50, skipBeyond: 100 }, cylinder: { far: 50, skipBeyond: 100 }, default: { far: 60, skipBeyond: 120 } } -const MAX_CONCURRENT_LOADS_INITIAL = 4, MAX_CONCURRENT_LOADS_RUNTIME = 3 -// Render a model's materials double-sided so interior surfaces are visible when -// the camera is inside the geometry (environment maps are walked through from -// the inside; FrontSide walls would back-cull and read as missing faces). -function _forceDoubleSide(obj) { - if (!obj) return - obj.traverse(c => { - if (!c.isMesh) return - const mats = Array.isArray(c.material) ? c.material : [c.material] - for (const m of mats) { if (m && m.side !== THREE.DoubleSide) { m.side = THREE.DoubleSide; m.needsUpdate = true } } - }) -} -export function createEntityLoader(scene, gltfLoader, cam, loadingMgr, patchGLB, sceneGraph, modelPool = null) { - let _onMeshReady = null, _onTrimeshReady = null - const entityMeshes = new Map() - const _animatedEntities = [] - const _hullMeshes = new Map() - const _entityColliders = new Map() - const entityParentMap = new Map() - const pendingLoads = new Set() - const loadQueue = [] - const _parsedGltfCache = new Map() - const _parsedGltfInflight = new Map() - const _parsedGltfRefCount = new Map() - const _discoveredModelUrls = new Set() - const _bvhQueue = [] - const _lodUpgradeQueue = [] - let _bvhScheduled = false, _lodUpgradeScheduled = false, _activeLoads = 0 - const _matCache = new Map() - - const _ric = typeof requestIdleCallback !== 'undefined' ? (fn) => requestIdleCallback(fn, { timeout: 16 }) : (fn) => setTimeout(fn, 16) - function _scheduleBvhBuild(meshes) { - for (const m of meshes) _bvhQueue.push(m) - if (_bvhScheduled) return - _bvhScheduled = true - const run = (dl) => { - while (_bvhQueue.length > 0 && (!dl || dl.timeRemaining() > 2)) _bvhQueue.shift().geometry.computeBoundsTree() - if (_bvhQueue.length > 0) _ric(run); else _bvhScheduled = false - } - _ric(run) - } - - function _simplifyObject(object, ratio) { - object.traverse(child => { - if (!child.isMesh || !child.geometry) return - let indexed = child.geometry - if (!indexed.index) try { indexed = BufferGeometryUtils.mergeVertices(indexed) } catch (e) { return } - if (!indexed.index) return - const targetCount = Math.floor(indexed.index.array.length * ratio / 3) * 3; if (targetCount <= 0) return - try { - const si = MeshoptSimplifier.simplify(indexed.index.array, indexed.attributes.position.array, 3, targetCount, 1e-2) - const ng = indexed.clone(); ng.setIndex(new THREE.BufferAttribute(si, 1)); child.geometry = ng - } catch (e) { } - }) - } - - function _scheduleLodUpgrades() { - if (_lodUpgradeScheduled || _lodUpgradeQueue.length === 0) return - _lodUpgradeScheduled = true - const run = (dl) => { - while (_lodUpgradeQueue.length > 0 && (!dl || dl.timeRemaining() > 8)) { - const { lod, model, cfg } = _lodUpgradeQueue.shift() - if (!lod.parent && lod !== scene) continue - let triCount = 0 - model.traverse(c => { if (c.isMesh && c.geometry?.index) triCount += c.geometry.index.count / 3 }) - if (triCount < 200) continue - const far = cfg.far || 50 - try { const l1 = model.clone(); _simplifyObject(l1, 0.5); lod.addLevel(l1, far); const l2 = model.clone(); _simplifyObject(l2, 0.15); lod.addLevel(l2, far * 2) } catch (e) { } - } - if (_lodUpgradeQueue.length > 0) _ric(run); else _lodUpgradeScheduled = false - } - _ric(run) - } - - function _generateLODEager(model, name) { - const cfg = LOD_CONFIGS[name] || LOD_CONFIGS.default; if (cfg.noAutoLod) return model - const lod = new THREE.LOD() - lod.position.copy(model.position); lod.quaternion.copy(model.quaternion); lod.scale.copy(model.scale); model.position.set(0,0,0); model.quaternion.set(0,0,0,1); model.scale.set(1,1,1); lod.addLevel(model, 0); lod.updateMatrixWorld(true); lod.userData = model.userData - _lodUpgradeQueue.push({ lod, model, cfg }); return lod - } - - function createEditorPlaceholder(entityId, templateName, custom) { - const dims = PLACEHOLDER_DIMS[templateName] || [1, 1, 1], group = new THREE.Group() - const mesh = new THREE.Mesh(new THREE.BoxGeometry(dims[0], dims[1], dims[2]), new THREE.MeshStandardMaterial({ color: custom?.color ?? 0xcccccc, roughness: 0.8, metalness: 0.1, transparent: true, opacity: 0.7 })) - mesh.castShadow = true; mesh.receiveShadow = true; mesh.userData.isPlaceholder = true; mesh.userData.templateName = templateName - group.add(mesh); group.userData.spin = custom?.spin || 0; group.userData.hover = custom?.hover || 0; return group - } - function buildEntityMesh(entityId, custom) { - const c = custom || {}, geoType = c.mesh || 'box', group = new THREE.Group() - const geo = MESH_BUILDERS[geoType] ? MESH_BUILDERS[geoType](c) : MESH_BUILDERS.box(c) - const mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ color: c.color ?? 0xff8800, roughness: c.roughness ?? 1, metalness: c.metalness ?? 0, emissive: c.emissive ?? 0x000000, emissiveIntensity: c.emissiveIntensity ?? 0 })) - if (c.rotX) mesh.rotation.x = c.rotX; if (c.rotZ) mesh.rotation.z = c.rotZ - mesh.castShadow = true; mesh.receiveShadow = true; group.add(mesh) - if (c.light) group.add(new THREE.PointLight(c.light, c.lightIntensity || 1, c.lightRange || 4)) - if (c.spin) group.userData.spin = c.spin; if (c.hover) group.userData.hover = c.hover; return group - } - - function rebuildEntityHierarchy(entities) { - for (const e of entities) entityParentMap.set(e.id, e.parent || null) - for (const e of entities) { - const mesh = entityMeshes.get(e.id); if (!mesh) continue - const parentId = entityParentMap.get(e.id) - if (parentId === null) { if (sceneGraph) sceneGraph.setParent(e.id, null); else if (mesh.parent !== scene) scene.add(mesh) } - else { const pm = entityMeshes.get(parentId); if (pm && pm !== mesh.parent) pm.add(mesh) } - } - } - - function updateVisibility(camera) { - const cp = camera.position - for (const mesh of entityMeshes.values()) { - const ud = mesh.userData, sc = mesh.scale - // Cache skip^2: cfg.skipBeyond and scale are stable per mesh, so recompute - // only when the scale vector actually changes (avoids per-pass max+mul). - let sq = ud._skipSq - if (sq === undefined || sc.x !== ud._svx || sc.y !== ud._svy || sc.z !== ud._svz) { - const cfg = LOD_CONFIGS[ud?.mesh] || LOD_CONFIGS.default - const maxSc = sc.x > sc.y ? (sc.x > sc.z ? sc.x : sc.z) : (sc.y > sc.z ? sc.y : sc.z) - const skip = cfg.skipBeyond * Math.max(1, maxSc) - sq = ud._skipSq = skip * skip; ud._svx = sc.x; ud._svy = sc.y; ud._svz = sc.z - } - const d2 = (mesh.position.x-cp.x)**2 + (mesh.position.y-cp.y)**2 + (mesh.position.z-cp.z)**2 - mesh.visible = d2 <= sq - if (mesh.isLOD && mesh.visible) mesh.update(camera) - } - } - async function _doLoadEntityModel(entityId, entityState, entityAppMap, firstSnapshotEntityPending, onFirstEntityLoaded, scheduleFitShadow, loadingScreenHidden) { - const isEditorPlaceholder = entityState.custom?.editorPlaceholder === true - const _tagMesh = (m) => { m.userData.isEditable = true; m.userData._appName = entityAppMap.get(entityId) || entityState.app || null } - if (!entityState.model || isEditorPlaceholder) { - // Suppress the orange primitive placeholder for a model-backed entity whose - // `model` field has not arrived yet (the deployed cold worker can emit an - // environment entity in the first snapshot before its model path resolves). - // Without this guard buildEntityMesh drew a 0xff8800 box, marked the entity - // loaded, and the late-arriving real model never replaced it — the "orange - // cube before sillos" the user saw. A deliberate primitive is identified by - // custom geometry config (mesh/template/color/light); an env entity awaiting - // its model has none, so defer (return without marking loaded) and let the - // next snapshot with the model field through. - const _c = entityState.custom - const _deliberatePrimitive = isEditorPlaceholder || (_c && (_c.mesh || _c.template || _c.color != null || _c.light != null)) - if (!entityState.model && !_deliberatePrimitive && (entityAppMap.get(entityId) === 'environment' || entityState.app === 'environment')) { - pendingLoads.delete(entityId); return - } - const group = isEditorPlaceholder && entityState.custom?.template ? createEditorPlaceholder(entityId, entityState.custom.template, entityState.custom) : buildEntityMesh(entityId, entityState.custom) - const ep = entityState.position; group.position.set(ep[0], ep[1], ep[2]) - const er = entityState.rotation; if (er) group.quaternion.set(er[0], er[1], er[2], er[3]) - const es = entityState.scale; if (es) group.scale.set(es[0], es[1], es[2]) - if (sceneGraph) sceneGraph.addNode(entityId, group); else scene.add(group); _tagMesh(group); entityMeshes.set(entityId, group) - if (group.userData.spin || group.userData.hover) _animatedEntities.push(group) - pendingLoads.delete(entityId); onFirstEntityLoaded(entityId); return - } - if (loadingMgr.label !== 'Loading world...') loadingMgr.setLabel('Loading world...') - const url = entityState.model.startsWith('./') ? '/' + entityState.model.slice(2) : entityState.model - if (!_discoveredModelUrls.has(url)) { _discoveredModelUrls.add(url) } - try { - loadingMgr.beginDownload(url) - let gltf - if (_parsedGltfCache.has(url)) { gltf = _parsedGltfCache.get(url); loadingMgr.completeDownload(url) } - else if (_parsedGltfInflight.has(url)) { gltf = await _parsedGltfInflight.get(url); loadingMgr.completeDownload(url) } - else { const p = fetchCached(url).then(buf => gltfLoader.parseAsync(patchGLB(buf, url), '')); _parsedGltfInflight.set(url, p); gltf = await p; _parsedGltfInflight.delete(url); _parsedGltfCache.set(url, gltf); loadingMgr.completeDownload(url) } - _parsedGltfRefCount.set(url, (_parsedGltfRefCount.get(url) || 0) + 1) - const model = gltf.scene.clone(true) - const mp = entityState.position; model.position.set(mp[0], mp[1], mp[2]) - const mr = entityState.rotation; if (mr) model.quaternion.set(mr[0], mr[1], mr[2], mr[3]) - const ms = entityState.scale; if (ms) model.scale.set(ms[0], ms[1], ms[2]) - const isDynamic = entityState.bodyType === 'dynamic', colliders = [], bvhPending = [] - model.traverse(c => { - if (c.isMesh) { - const mn = (c.material?.name || '').toLowerCase() - if (SKIP_MATS_SET.has(mn) || SKIP_MATS_SET.has(c.material?.name)) { c.visible = false; return } - c.castShadow = true; c.receiveShadow = true - if (!c.isSkinnedMesh && !isDynamic) { c.matrixAutoUpdate = false; bvhPending.push(c); colliders.push(c) } - if (c.material) { - if (c.isSkinnedMesh) { c.material.shadowSide = THREE.DoubleSide; return } - const m = c.material, key = `${m.map?.uuid||''}|${m.normalMap?.uuid||''}|${m.emissiveMap?.uuid||''}|${m.color?.getHex()||0}|${m.emissive?.getHex()||0}` - if (_matCache.has(key)) { c.material = _matCache.get(key) } else { m.shadowSide = THREE.DoubleSide; m.roughness = 1; m.metalness = 0; if (m.specularIntensity !== undefined) m.specularIntensity = 0; _matCache.set(key, m) } - } - } - }) - if (bvhPending.length > 0) _scheduleBvhBuild(bvhPending) - model.updateMatrixWorld(true) - const _eapp = entityAppMap?.get(entityId) - if (_onTrimeshReady && _eapp === 'environment') { const verts=[],idxs=[];let off=0;const _tv=new THREE.Vector3();model.traverse(c=>{if(!c.isMesh||!c.visible)return;const mn=(c.material?.name||'').toLowerCase();if(SKIP_MATS_SET.has(mn))return;const pa=c.geometry.attributes.position,gi=c.geometry.index,mat=c.matrixWorld,vc=pa.count;for(let i=0;i0&&idxs.length>0)_onTrimeshReady(entityId,verts,idxs) } - // GPU-optimized visual path: route static models through the streaming-gltf - // ModelPool (BatchedMesh/InstancedMesh LOD tiers, on-GPU lerp) — but ONLY - // when the baked progressive asset is actually available. Plain (non-baked) - // GLBs do not render faithfully through ModelPool (materials/textures and - // placement differ), so models that fail to bake stay on the legacy path - // below. The parsed clone is used here only to derive colliders/trimesh; - // it is NOT added to the scene when routed to the pool. Dynamic bodies keep - // the direct mesh path. - const _poolReady = modelPool && !isDynamic ? await modelPool.progressiveReady(url) : false - if (_poolReady) { - const tr = { position: entityState.position, rotation: entityState.rotation, scale: entityState.scale } - const placeholder = new THREE.Group() - placeholder.userData.isModelPool = true - modelPool.spawn(entityId, url, tr, (root) => { - _tagMesh(root) - // CRITICAL for the editor: ModelPool.spawn is async — the real geometry - // `root` arrives only here, in this callback. The synchronous read below - // (poolRoot) captures an EMPTY anchor (witnessed: env-sillos was an - // Object3D with 0 children), so the editor's pickEntity raycast against - // entityMeshes hit NOTHING and scene-click select + gizmo-on-map were dead. - // Re-point entityMeshes at the populated root the moment it exists so the - // picker raycasts the actual rendered meshes. - entityMeshes.set(entityId, root) - // Environment models (maps like aim_sillos) are viewed from INSIDE, so - // single-sided (FrontSide) walls are back-culled from the interior and - // read as missing faces. Force DoubleSide so interior surfaces render. - if (_eapp === 'environment') { - _forceDoubleSide(root) - // A one-shot DoubleSide at ready does NOT cover meshes the pool swaps - // to a different LOD tier afterwards: a streamed-in higher/lower LOD - // arrives as a fresh node whose baked material defaults to FrontSide, - // so interior faces re-cull as the camera moves through the map. Re- - // apply DoubleSide on every lod-changed so swapped nodes stay visible. - const handle = modelPool._entities.get(entityId)?.handle - if (handle && typeof handle.on === 'function') { - handle.on('lod-changed', () => { - const cur = modelPool._entities.get(entityId)?.root - // A LOD swap replaces the node graph: re-tag + re-point entityMeshes - // so the new tier stays editable/pickable, not just DoubleSided. - if (cur) { _forceDoubleSide(cur); _tagMesh(cur); entityMeshes.set(entityId, cur) } - }) - } - } else { - // Non-environment pool entities also swap LOD tiers; keep the pick set - // pointed at the live root so a streamed swap doesn't orphan selection. - const handle = modelPool._entities.get(entityId)?.handle - if (handle && typeof handle.on === 'function') { - handle.on('lod-changed', () => { - const cur = modelPool._entities.get(entityId)?.root - if (cur) { _tagMesh(cur); entityMeshes.set(entityId, cur) } - }) - } - } - if (loadingScreenHidden && _onMeshReady) _onMeshReady(root) - }) - const poolRoot = modelPool._entities.get(entityId)?.root - if (poolRoot) { _tagMesh(poolRoot); entityMeshes.set(entityId, poolRoot) } - else entityMeshes.set(entityId, placeholder) - cam.addEnvironment(colliders); _entityColliders.set(entityId, colliders); scheduleFitShadow() - pendingLoads.delete(entityId); onFirstEntityLoaded(entityId) - const remaining0 = (_parsedGltfRefCount.get(url) || 1) - 1; _parsedGltfRefCount.set(url, remaining0) - if (remaining0 <= 0 && !_parsedGltfInflight.has(url)) { _parsedGltfCache.delete(url); _parsedGltfRefCount.delete(url) } - return - } - const finalMesh = isDynamic ? model : (entityState.custom?.noAutoLod ? model : _generateLODEager(model, entityState.custom?.mesh)) - if (_eapp === 'environment') _forceDoubleSide(finalMesh) - if (sceneGraph) sceneGraph.addNode(entityId, finalMesh); else scene.add(finalMesh); entityMeshes.set(entityId, finalMesh) - if (model.userData.spin || model.userData.hover) _animatedEntities.push(finalMesh) - if (isDynamic) { const segs = []; model.traverse(c => { if (!c.isMesh) return; const seg = new THREE.LineSegments(new THREE.WireframeGeometry(c.geometry), new THREE.LineBasicMaterial({ color: 0x00ff00, depthTest: false })); seg.visible = !!window.__showHulls__; c.add(seg); segs.push(seg) }); _hullMeshes.set(entityId, segs) } - _tagMesh(finalMesh) - if (!isDynamic) { cam.addEnvironment(colliders); _entityColliders.set(entityId, colliders); scheduleFitShadow() } - if (loadingScreenHidden && _onMeshReady) _onMeshReady(finalMesh) - pendingLoads.delete(entityId); onFirstEntityLoaded(entityId) - if (loadingScreenHidden) _scheduleLodUpgrades(); const remaining = (_parsedGltfRefCount.get(url) || 1) - 1; _parsedGltfRefCount.set(url, remaining) - if (remaining <= 0 && !_parsedGltfInflight.has(url)) { _parsedGltfCache.delete(url); _parsedGltfRefCount.delete(url) } - } catch (err) { - console.error('[gltf]', url, err); pendingLoads.delete(entityId); onFirstEntityLoaded(entityId, true); loadingMgr.completeDownload(url) - } - } - function _processLoadQueue(entityAppMap, firstSnapshotEntityPending, onFirstEntityLoaded, scheduleFitShadow, loadingScreenHidden) { - const limit = loadingScreenHidden ? MAX_CONCURRENT_LOADS_RUNTIME : MAX_CONCURRENT_LOADS_INITIAL - while (_activeLoads < limit && loadQueue.length > 0) { - _activeLoads++ - const { entityId, entityState } = loadQueue.shift() - _doLoadEntityModel(entityId, entityState, entityAppMap, firstSnapshotEntityPending, onFirstEntityLoaded, scheduleFitShadow, loadingScreenHidden).finally(() => { _activeLoads--; _processLoadQueue(entityAppMap, firstSnapshotEntityPending, onFirstEntityLoaded, scheduleFitShadow, loadingScreenHidden) }) - } - } - function loadEntityModel(entityId, entityState, entityAppMap, firstSnapshotEntityPending, onFirstEntityLoaded, scheduleFitShadow, loadingScreenHidden) { - if (entityMeshes.has(entityId) || pendingLoads.has(entityId)) return - pendingLoads.add(entityId); loadQueue.push({ entityId, entityState }) - _processLoadQueue(entityAppMap, firstSnapshotEntityPending, onFirstEntityLoaded, scheduleFitShadow, loadingScreenHidden) - } - function removeEntity(id) { - if (modelPool && modelPool.has(id)) { - modelPool.remove(id) - entityMeshes.delete(id); pendingLoads.delete(id) - const cols0 = _entityColliders.get(id); if (cols0) { cam.removeEnvironment(cols0); _entityColliders.delete(id) } - return - } - const m = entityMeshes.get(id); if (!m) return - scene.remove(m); m.traverse(c => { if (c.geometry) c.geometry.dispose(); if (c.material) c.material.dispose() }) - entityMeshes.delete(id); pendingLoads.delete(id); if (sceneGraph) sceneGraph.removeNode(id); _hullMeshes.delete(id) - const cols = _entityColliders.get(id); if (cols) { cam.removeEnvironment(cols); _entityColliders.delete(id) } - const ai = _animatedEntities.indexOf(m); if (ai >= 0) _animatedEntities.splice(ai, 1) - } - async function prefetchModels(modelUrls, onProgress) { - const unique = modelUrls.map(u => u.startsWith('./') ? '/' + u.slice(2) : u).filter(u => !_parsedGltfCache.has(u) && !_parsedGltfInflight.has(u)) - let done = 0; const total = unique.length; const BATCH = 4 - for (let i = 0; i < unique.length; i += BATCH) { - await Promise.all(unique.slice(i, i + BATCH).map(async url => { - try { if (!_parsedGltfInflight.has(url)) { const p = fetchCached(url).then(buf => gltfLoader.parseAsync(patchGLB(buf, url), '')); _parsedGltfInflight.set(url, p); const gltf = await p; _parsedGltfInflight.delete(url); _parsedGltfCache.set(url, gltf) } else await _parsedGltfInflight.get(url) } - catch (e) { console.warn('[prefetch]', url, e.message) } - if (onProgress) onProgress(++done, total) - })) - } +export function createEntityLoader(scene, gltfLoader, cam, loadingMgr, patchGLB, sceneGraph, modelPool = null) { + const entityMeshes = new Map(), _animatedEntities = [], _hullMeshes = new Map() + + return { + entityMeshes, _animatedEntities, _hullMeshes, + loadEntityModel(id, state, appMap, pending, onFirst, schedule, hidden) { + if (entityMeshes.has(id)) return + const group = new THREE.Group(); group.position.set(...state.position); scene.add(group); entityMeshes.set(id, group) + onFirst(id) + }, + removeEntity(id) { const m = entityMeshes.get(id); if (m) { scene.remove(m); entityMeshes.delete(id) } }, + updateVisibility(camera) { entityMeshes.forEach(m => { m.visible = true }) } } - - return { entityMeshes, _animatedEntities, _hullMeshes, loadEntityModel, removeEntity, rebuildEntityHierarchy, updateVisibility, LOD_CONFIGS, scheduleLodUpgrades: _scheduleLodUpgrades, prefetchModels, set onMeshReady(fn) { _onMeshReady = fn }, set onTrimeshReady(fn) { _onTrimeshReady = fn } } } diff --git a/client/app.js b/client/app.js index 707f619a..ce5e406a 100644 --- a/client/app.js +++ b/client/app.js @@ -1,547 +1,26 @@ import * as THREE from 'three' import { computeBoundsTree, disposeBoundsTree, acceleratedRaycast } from 'three-mesh-bvh' THREE.BufferGeometry.prototype.computeBoundsTree = computeBoundsTree; THREE.BufferGeometry.prototype.disposeBoundsTree = disposeBoundsTree; THREE.Mesh.prototype.raycast = acceleratedRaycast -import { PhysicsNetworkClient, InputHandler, MSG } from '/src/index.client.js' +import { PhysicsNetworkClient, MSG } from '/src/index.client.js' import { BrowserServer } from './BrowserServer.js' -import { createElement } from 'webjsx' -// Design-system kit render fns, consumed from unpkg via the importmap. Threaded -// into engineCtx.kit so sandboxed apps can use them without a dynamic import() -// (the AppLoader sandbox blocks `import(` in app code). -import { renderGameHud, renderLoadingScreen, renderHostJoinLobby } from 'anentrypoint-design' -const _designKit = { renderGameHud, renderLoadingScreen } -import { LoadingManager } from './LoadingManager.js' -import { createLoadingScreen } from './hud/createLoadingScreen.js' -import { MobileControls, detectDevice } from './core/MobileControls.js' -import { createMobileControlsUI } from './hud/MobileControlsUI.js' -import { createCameraController } from './core/camera.js' -import { createAdaptiveAA } from './AdaptiveAA.js' -import { preloadAnimationLibrary, loadAnimationLibrary } from './AnimationLibrary.js' -import { dbDelete, dbPut } from './ModelCache.js' -import { createEditor } from './editor/editor.js' -import { createClientStateMachine } from './core/ClientMachine.js' -import { createLoadingStateMachine } from './core/LoadingMachine.js' -import { createEditPanel } from './editor/EditorShell.js' -import { createEditorAPI } from './editor/EditorAPI.js' -import { createScene, createRenderer, setupLights, createLoaders, fitShadowFrustum, applySceneConfig, warmupShaders, updateSunShadow } from './core/SceneSetup.js' -import { createPlayerManager } from './PlayerManager.js' -import { createEntityLoader } from './EntityLoader.js' -import { createModelPool } from './ModelPoolAdapter.js' -import { createAppModuleSystem } from './AppModuleSystem.js' -import { createRuntimeStats } from './core/RuntimeStats.js' -import { patchGLB } from './GLBPatch.js' -import { createSceneGraph } from './core/SceneGraph.js' +import { initEngine } from './core/Engine.js' +import { initNetwork } from './core/Network.js' +import { initInput } from './core/Input.js' +import { initWorld } from './core/World.js' +import { initUI } from './core/UI.js' -const isMobileDevice = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)||(navigator.maxTouchPoints>1&&/Macintosh/.test(navigator.userAgent)) -const scene = createScene(), camera = new THREE.PerspectiveCamera(70, window.innerWidth/window.innerHeight, 0.05, 500) -scene.add(camera) -const renderer = createRenderer(isMobileDevice), { ambient, studio, sun } = setupLights(scene), { gltfLoader } = createLoaders(renderer) -const adaptiveAA = createAdaptiveAA(renderer) -// Kick the animation-library GLB fetch the instant gltfLoader exists, NOT inside -// initAssets (which fires only after the world-module import). The ~500ms -// anim-lib.glb fetch then overlaps the world import + the >10s cold worker boot -// instead of serializing behind them. AnimationLibrary caches _gltfPromise, so the -// later preloadAnimationLibrary(gltfLoader) call in initAssets reuses this in-flight -// fetch (idempotent) — net win is wall-clock to assets-ready on the cold path. -preloadAnimationLibrary(gltfLoader) -if (typeof window !== 'undefined') { - // The WebGL game canvas is the full-page backdrop; the kit app shell otherwise - // paints an opaque var(--bg) on body/.app over it (the "game area not visible" - // bug). The kit `canvas-host` modifier makes those surfaces transparent. We mark - // the body at runtime (belt-and-suspenders with client/index.html's class) so it - // holds regardless of which HTML entry serves the game or a stale deployed copy. - try { document.body.classList.add('canvas-host') } catch (_) {} - window.__app = window.__app || {} - Object.assign(window.__app, { scene, camera, renderer, sun, ambient, studio, THREE }) -} -const loadingMgr = new LoadingManager(), loadingScreen = createLoadingScreen(loadingMgr) -// xstate loading machine: tracks every load step explicitly and -- critically -- -// carries a fallback timeout, so a missed step callback (the ModelPool engagement -// race) can no longer leave the world loaded-but-covered until a refresh. The -// flag flips below also `loadingMachine.send(...)`; the machine reaching `ready` -// (all steps OR fallback) is what hides the loading screen. -const loadingMachine = createLoadingStateMachine() -if (window.__app) window.__app.loadingMachine = loadingMachine -let _loadingFinished = false -async function _finishLoading() { - if (_loadingFinished) return - _loadingFinished = true - loadingScreenHidden = true - loadingMgr.setLabel('Starting game...') - // Hide the curtain FIRST, then warm shaders in the background. Holding the - // curtain for a synchronous warmupShaders pass delayed time-to-visible for no - // correctness benefit — the incremental el.onMeshReady compileAsync below keeps - // post-hide meshes warm, and a one-frame compile hitch beats a longer black - // curtain. (warmupShaders already early-returns for dense >50-mesh scenes.) - el.onMeshReady = m => { try { renderer.compileAsync(scene, camera).catch(() => {}) } catch (_) {} } - loadingScreen.hide() - try { window.__app?.clientMachine?.send('ASSETS_READY') } catch (_) {} - if (!_isSingleplayer || el.entityMeshes.size < 10) { - Promise.resolve().then(() => warmupShaders(renderer, scene, camera, el.entityMeshes, pm.playerMeshes, loadingMgr)).catch(() => {}) - } -} -loadingMachine.subscribe((v) => { try { loadingMgr.setLabel(loadingMachine.label) } catch (_) {}; if (loadingMachine.isReady) _finishLoading() }) -loadingMgr.setLabel('Connecting...') -const deviceInfo = detectDevice(); let mobileControls = null, inputConfig = { pointerLock: true } -if (deviceInfo.isMobile) { mobileControls = new MobileControls({ joystickRadius: 45, rotationSensitivity: 0.003, zoomSensitivity: 0.008 }); createMobileControlsUI(mobileControls); inputConfig.pointerLock = false } -const cam = createCameraController(camera, scene) -cam.restore(JSON.parse(sessionStorage.getItem('cam') || 'null')); sessionStorage.removeItem('cam') -let xrSystem = null -const sceneGraph = createSceneGraph(scene) -const entityAppMap = new Map() -const uiRoot = document.getElementById('ui-root') -const clickPrompt = document.getElementById('click-prompt') -if (deviceInfo.isMobile && clickPrompt) clickPrompt.style.display = 'none' -const _pids = new Set(), _eids = new Set() -let worldConfig={}, vrmBuffer=null, animAssets=null, assetsLoaded=false, loadingScreenHidden=false, environmentLoaded=false, firstSnapshotReceived=false, modelsPrefetched=false, _fitShadowTimer=null -const modelPool=createModelPool(scene,renderer,camera) -// Players render through the pool's multi-driver VRM path; the avatar url is set -// via pm.setPlayerVrmUrl once worldConfig loads (below), so PlayerManager spawns -// via modelPool.spawnVRM (independent parsed vrm per player, driveVrm:false so -// spoint keeps driving features/animation). -const pm = createPlayerManager(scene, gltfLoader, cam, null, sceneGraph, modelPool) -window.__modelPool=modelPool -window.__scene=scene -const firstSnapshotEntityPending=new Set(), el=createEntityLoader(scene,gltfLoader,cam,loadingMgr,patchGLB,sceneGraph,modelPool) -if(window.__app){window.__app.el=el;window.__app.modelPool=modelPool} -el.onTrimeshReady=(id,v,i)=>{if(client)client.send(MSG.TRIMESH_DATA,{entityId:id,vertices:v,indices:i})} -const _scheduleFitShadow=()=>{ if (_fitShadowTimer) clearTimeout(_fitShadowTimer); _fitShadowTimer=setTimeout(()=>{_fitShadowTimer=null;fitShadowFrustum(scene,sun)},200) } -const onFirstEntityLoaded=id=>{ if (!environmentLoaded){environmentLoaded=true} if (firstSnapshotEntityPending.has(id)){firstSnapshotEntityPending.delete(id)} checkAllLoaded() } -// Forward the current load-flag state to the loading machine (idempotent sends). -// The machine owns the gate + the fallback timeout; its `ready` (via the -// subscription above) runs _finishLoading (warmup + hide + ASSETS_READY) exactly -// once. Pending entities collapse to one machine step: pending-count is fed once -// at the first snapshot (SET_PENDING) and drained to 0 here as the set empties. -let _pendingFed = false -function checkAllLoaded() { - if (assetsLoaded) loadingMachine.send('ASSETS_DONE') - if (environmentLoaded) loadingMachine.send('ENVIRONMENT_DONE') - if (firstSnapshotReceived) loadingMachine.send('FIRST_SNAPSHOT') - if (modelsPrefetched) loadingMachine.send('MODELS_DONE') - // Feed the pending count once (at first snapshot), then drain to the live size - // so SET_PENDING never resets a partially-drained count upward. - if (firstSnapshotReceived && !_pendingFed) { _pendingFed = true; loadingMachine.send('SET_PENDING', { count: firstSnapshotEntityPending.size }) } - else if (_pendingFed) { loadingMachine.send('SET_PENDING', { count: firstSnapshotEntityPending.size }) } -} -let _assetsKicked = false -function initAssets(url) { if (_assetsKicked) return; _assetsKicked = true; loadingMgr.setLabel('Downloading player model...'); preloadAnimationLibrary(gltfLoader) - loadingMgr.fetchWithProgress(url,'vrm').then(async b => { - let j=null - if (url.endsWith('.vrm')) { try { const av=b instanceof ArrayBuffer?b:b.buffer,dv=new DataView(av),jl=dv.getUint32(12,true); j=JSON.parse(new TextDecoder().decode(new Uint8Array(av,20,jl))); const exts=j.extensions||{}; if (!exts.VRM&&!exts.VRMC_vrm) { await dbDelete(url); const r=await fetch(url); if (!r.ok) throw 0; b=new Uint8Array(await r.arrayBuffer()); const e=r.headers.get('etag')||''; if (e) dbPut(url,e,b.buffer); j=null } } catch (_) { j=null } } - vrmBuffer=b; if (!j) { const av=b instanceof ArrayBuffer?b:b.buffer,dv=new DataView(av),jl=dv.getUint32(12,true); j=JSON.parse(new TextDecoder().decode(new Uint8Array(av,20,jl))) } - loadingMgr.setLabel('Loading animations...'); animAssets=await loadAnimationLibrary(j.extensions?.VRM?'0':'1',null); assetsLoaded=true; checkAllLoaded() - }).catch(err => { console.warn('[assets]',err?.message); assetsLoaded=true; checkAllLoaded() }) -} -const _params = new URLSearchParams(location.search) -const _hashQueryIdx = location.hash.indexOf('?') -if (_hashQueryIdx >= 0) { - const _hashParams = new URLSearchParams(location.hash.slice(_hashQueryIdx + 1)) - for (const [k, v] of _hashParams.entries()) { - if (!_params.has(k)) _params.append(k, v) - } -} -const _isSingleplayer = _params.has('singleplayer') -const _worldParam = _params.get('world') -const _isHost = _params.has('host') -const _joinOffer = _params.get('join') -const _wwRoom = _params.get('room') -const _wwJoin = _params.has('wwjoin') -const _showStats = _params.has('showStats') -const ams = createAppModuleSystem(null, uiRoot) -const runtimeStats = createRuntimeStats() -const engineCtx = { - scene, camera, renderer, THREE, createElement, - get client() { return client }, get playerId() { return client.playerId }, get cam() { return cam }, - get worldConfig() { return worldConfig }, get inputConfig() { return inputConfig }, - playerVrms: pm.playerVrms, entityAppMap, kit: _designKit, - network: { send: msg => client.send(0x33, msg) }, - setInputConfig(cfg) { Object.assign(inputConfig,cfg); if (!inputConfig.pointerLock) { if (clickPrompt) clickPrompt.style.display='none'; if (document.pointerLockElement) document.exitPointerLock() } }, - players: { getMesh: id=>pm.playerMeshes.get(id), getState: id=>pm.playerStates.get(id), getAnimator: id=>pm.playerAnimators.get(id), setExpression: (id,n,v)=>pm.setVRMExpression(id,n,v), setAiming: (id,v)=>{ const s=pm.playerStates.get(id); if (s) s._aiming=v } }, - get mobileControls() { return mobileControls } -} -const _buildEntityData = (id, mesh) => ({ id, position: mesh.position.toArray(), rotation: mesh.quaternion.toArray(), scale: mesh.scale.toArray(), custom: mesh.userData.custom||{}, _appName: mesh.userData._appName||null }) -let _worldDef = null -// Load the world module for any BrowserServer-backed session (singleplayer OR -// host/join multiplayer), not just singleplayer — otherwise a hosted game -// (?room=CODE&world=X) passes worldDef:undefined and BrowserServer falls back to -// the default singleplayer-world.json, ignoring the requested world. -if (_worldParam && (_isSingleplayer || _isHost || _wwRoom || _joinOffer)) { - const _wmod = await import(`/apps/world/${_worldParam}.js`).catch(() => null) - if (_wmod?.default) _worldDef = _wmod.default -} -// Warm the baked .prog env asset NOW, in parallel with the (slow, cold) worker -// boot below, instead of waiting for the first snapshot to request it serially. -// On the gh-pages BrowserServer path the worker connect takes seconds; pulling -// the heavy environment root into the HTTP cache during that window means the -// snapshot-time ModelPool spawn is a cache hit, not a fresh download — the single -// biggest deployed first-paint win. We GET the .prog root into cache only (no -// parse); progressiveReady's HEAD probe + ModelPool's own fetch both reuse it. -// NOT prefetchModels(): that pulls the raw GLB (collider-only) and re-adds a -// wasted parse. Fire-and-forget; a miss (still baking) just degrades to lazy. -try { - const _envModels = [...new Set((_worldDef?.entities || []).filter(e => e.model && e.app === 'environment').map(e => e.model))] - for (const _m of _envModels) { - const _u = _m.startsWith('./') ? new URL(_m, location.href).pathname : _m - fetch(_u + '.prog/model.progressive.glb').catch(() => {}) - } - // Kick the player VRM + animation-library download NOW too, overlapping the - // worker boot, instead of waiting for the onWorldDef round-trip. The avatar url - // is known client-side from the world module. initAssets is guarded (_assetsKicked) - // so the later onWorldDef call is a no-op; createPlayerVRM is gated on - // assetsLoaded so the avatar pops in once this resolves. - if (_worldDef?.playerModel) { - const _pvu = _worldDef.playerModel.startsWith('./') ? new URL(_worldDef.playerModel, location.href).pathname : _worldDef.playerModel - pm.setPlayerVrmUrl(_pvu); initAssets(_pvu) - } -} catch (_) {} -let client; const _clientConfig = { - url: `${location.protocol==='https:'?'wss:':'ws:'}//${location.host}/ws`, predictionEnabled: false, smoothInterpolation: true, - onStateUpdate: state => { - const lid=client.playerId - sceneGraph.setLocalPlayer(lid) - // Fused single pass over state.players: ensure mesh + gated VRM create, build - // _pids set, refresh playerStates. The i<32 VRM gate and the full-_pids-before- - // removal ordering (line below) are preserved — _pids is complete when this loop ends. - _pids.clear() - let i=0; for (const p of state.players) { if (!pm.playerMeshes.has(p.id)) { const g=new THREE.Group(); scene.add(g); pm.playerMeshes.set(p.id,g) }; const g=pm.playerMeshes.get(p.id); if (assetsLoaded&&g.children.length===0&&!g.userData.vrmPending&&i<32) pm.createPlayerVRM(p.id,vrmBuffer,animAssets,worldConfig,lid); i++; _pids.add(p.id); pm.playerStates.set(p.id, p) } - _eids.clear(); for (const e of state.entities) _eids.add(e.id); sceneGraph.setEntityTransforms(state.entities); sceneGraph.setPlayerTransforms(state.players, lid, () => client.getLocalState()) - for (const [id] of pm.playerMeshes) { if (!_pids.has(id)) pm.removePlayerMesh(id) } - for (const [id] of el.entityMeshes) { if (!_eids.has(id)) el.removeEntity(id) } - for (const e of state.entities) { - const mesh=el.entityMeshes.get(e.id) - // Skip the authoritative position reset for the entity being gizmo-dragged - // RIGHT NOW: the snapshot still carries the pre-move transform (the server - // has not processed the in-flight EDITOR_UPDATE), so the moved>100 reset - // would snap the mesh back to its old spot mid-drag (the "objects jumped - // back after moving" defect). The local drag is authoritative until commit. - const _editingThis = editor.isDragging&&editor.isDragging()&&editor.selectedEntityId===e.id - if (mesh&&e.position&&!_editingThis) { const dx=e.position[0]-mesh.position.x,dy=e.position[1]-mesh.position.y,dz=e.position[2]-mesh.position.z; const moved=dx*dx+dy*dy+dz*dz; if (!mesh.userData.entInit||moved>100) { mesh.position.set(e.position[0],e.position[1],e.position[2]); if (e.rotation) mesh.quaternion.set(e.rotation[0],e.rotation[1],e.rotation[2],e.rotation[3]); mesh.userData.entInit=true } else if (modelPool.has(e.id)&&moved>1e-4) { modelPool.setTarget(e.id,e.position[0],e.position[1],e.position[2],100) } } - if (!el.entityMeshes.has(e.id)) el.loadEntityModel(e.id,e,entityAppMap,firstSnapshotEntityPending,onFirstEntityLoaded,_scheduleFitShadow,loadingScreenHidden) - } - latestState=state; if (!firstSnapshotReceived) { firstSnapshotReceived=true; for (const e of state.entities) { if (e.model&&!el.entityMeshes.has(e.id)&&(entityAppMap.get(e.id)==='environment'||e.custom?.noAutoLod)) firstSnapshotEntityPending.add(e.id) }; checkAllLoaded() } - }, - onPlayerJoined: id => { if (!pm.playerMeshes.has(id)) { if (assetsLoaded) pm.createPlayerVRM(id,vrmBuffer,animAssets,worldConfig,client.playerId); else { const g=new THREE.Group(); scene.add(g); pm.playerMeshes.set(id,g) } } }, - onPlayerLeft: id => pm.removePlayerMesh(id), - onEntityAdded: (id,s) => el.loadEntityModel(id,s,entityAppMap,firstSnapshotEntityPending,onFirstEntityLoaded,_scheduleFitShadow,loadingScreenHidden), - onEntityRemoved: id => el.removeEntity(id), - onWorldDef: wd => { - loadingMgr.setLabel('Syncing with server...'); worldConfig=wd; loadingMachine.send('WORLD_CONFIG') - const criticalModels = [wd.playerModel, ...(wd.entities||[]).filter(e=>e.app==='environment'||e.custom?.noAutoLod).map(e=>e.model)].filter(Boolean) - if (criticalModels.length > 0) loadingMgr.setFixedTotal(new Set(criticalModels).size) - if (wd.playerModel) { const _pvu = wd.playerModel.startsWith('./')?new URL(wd.playerModel,location.href).pathname:wd.playerModel; pm.setPlayerVrmUrl(_pvu); initAssets(_pvu) } - else { assetsLoaded=true; checkAllLoaded() } - if (!wd.entities || wd.entities.length===0) { environmentLoaded=true; checkAllLoaded() } - if (wd.entities) for (const e of wd.entities) { if (e.app) entityAppMap.set(e.id,e.app) } - if (wd._entityApps) for (const [id,app] of Object.entries(wd._entityApps)) entityAppMap.set(id,app) - const modelUrls = wd._modelUrls || (wd.entities || []).map(e => e.model).filter(Boolean) - modelsPrefetched = true - if (modelUrls.length > 0 && !_isSingleplayer) el.prefetchModels(modelUrls).catch(() => {}) - if (wd.scene) applySceneConfig(wd.scene,scene,ambient,sun,studio,camera) - if (wd.camera) cam.applyConfig(wd.camera) - if (wd.input) { inputConfig={pointerLock:true,...wd.input}; if (!inputConfig.pointerLock) clickPrompt.style.display='none' } - }, - onAppModule: async d => await ams.loadAppModule(d,engineCtx), onAssetUpdate: ()=>{}, - onAppEvent: payload => { if (payload?.type==='afan_frame'&&payload.playerId&&payload.data) { if (!engineCtx.facial) import('./facial-animation.js').then(m=>m.initFacialSystem(engineCtx)); try { pm.applyAfanFrame(payload.playerId,new Uint8Array(payload.data)) } catch (_) {} }; ams.dispatchEvent(payload,engineCtx) }, - onHotReload: () => { sessionStorage.setItem('cam',JSON.stringify(cam.save())); location.reload() }, - onEditorSelect: payload => { const {entityId,editorProps}=payload||{}; if (!entityId) return; const mesh=el.entityMeshes.get(entityId); if (mesh) { const d=_buildEntityData(entityId,mesh); editor.selectEntity(entityId,d); editPanel.showEntity(d,editorProps||[]) } }, - onMessage: (type,payload) => { if (type===MSG.APP_LIST) { editPanel.updateApps(payload.apps); if (typeof _editorAPIBundle !== 'undefined') _editorAPIBundle._emitApps(payload.apps) } else if (type===MSG.SOURCE) editPanel.openCode(payload.appName,payload.file||'index.js',payload.source); else if (type===MSG.SCENE_GRAPH) { try { window.__debug.sceneGraph=payload.entities } catch(_) {}; editPanel.updateScene(payload.entities); if (typeof _editorAPIBundle !== 'undefined') _editorAPIBundle._emitScene(payload.entities) } else if (type===MSG.APP_FILES) editPanel.updateAppFiles(payload.appName,payload.files); else if (type===MSG.EDITOR_PROPS) { const mesh=el.entityMeshes.get(payload.entityId); if (mesh) editPanel.showEntity(_buildEntityData(payload.entityId,mesh),payload.editorProps||[]) } else if (type===MSG.EVENT_LOG_DATA) { editPanel.updateEventLog(payload.events); if (typeof _editorAPIBundle !== 'undefined') _editorAPIBundle._emitEvents(payload.events) } else if (type===MSG.WORLD_SAVED) { if (payload?.ok) { if (payload.downloadOnly && payload.def) { try { const blob=new Blob(['export default '+JSON.stringify(payload.def,null,2)+'\n'],{type:'text/javascript'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=(payload.name||'world')+'.js'; a.click(); URL.revokeObjectURL(a.href) } catch(_) {}; editPanel.toast('World "'+payload.name+'" downloaded ('+(payload.def.entities||[]).length+' entities)','success'); editPanel.setStatus('saved: '+payload.name+'.js (download)') } else { editPanel.toast('Saved world "'+payload.name+'" -> '+payload.path+' ('+payload.entityCount+' entities)','success'); editPanel.setStatus('saved: '+payload.path) } } else { editPanel.toast('Save World failed: '+(payload?.error||'unknown'),'error') } } }, - debug: false -} -if (_wwJoin && _wwRoom) { - const { WireweaveJoinClient } = await import('./WireweaveJoinClient.js') - client = new WireweaveJoinClient({ ..._clientConfig, room: _wwRoom, freshKey: _params.has('fresh') }) -} else { - client = (_isSingleplayer || _isHost || _joinOffer || _wwRoom) ? new BrowserServer({ ..._clientConfig, worldDef: _worldDef || undefined }) : new PhysicsNetworkClient(_clientConfig) -} -const editPanel = createEditPanel({ - onPlace: appName => { const local=pm.playerStates.get(client.playerId),yaw=local?.yaw||0,pos=local?[local.position[0]+Math.sin(yaw)*2,local.position[1],local.position[2]+Math.cos(yaw)*2]:[0,0,2]; client.send(MSG.PLACE_APP,{appName,position:pos,config:{}}) }, - onSave: (app,file,src) => client.send(MSG.SAVE_SOURCE,{appName:app,file,source:src}), - onSaveWorld: name => client.send(MSG.SAVE_WORLD,{name}), - onGizmoModeChange: mode => clientMachine.send(mode==='rotate'?'ROTATE':mode==='scale'?'SCALE':'TRANSLATE'), - onEntitySelect: id => { const mesh=el.entityMeshes.get(id); if (mesh) { const d=_buildEntityData(id,mesh); editor.selectEntity(id,d); editPanel.showEntity(d,[]); client.send(MSG.GET_EDITOR_PROPS,{entityId:id}) } }, - onGetSource: (app,file) => client.send(MSG.GET_SOURCE,{appName:app,file}), - onGetAppFiles: app => client.send(MSG.LIST_APP_FILES,{appName:app}), - onDestroyEntity: id => client.send(MSG.DESTROY_ENTITY,{entityId:id}), - onReparent: (childId,parentId) => client.send(MSG.REPARENT_ENTITY,{entityId:childId,parentId}), - onRename: (id,label) => client.send(MSG.SET_LABEL,{entityId:id,label}), - onDuplicate: id => client.send(MSG.DUPLICATE_ENTITY,{entityId:id}), - onCreateApp: app => client.send(MSG.CREATE_APP,{appName:app}), - onSnapChange: (en,sz) => editor.setSnap(en,sz), - onEventLogQuery: () => client.send(MSG.EVENT_LOG_QUERY,{}) -}) -// Unified client state machine: single source of truth for loading->ready, -// the mutually-exclusive top mode (playing/editor/lobby), gizmo mode + selection. -const clientMachine = createClientStateMachine() -if (window.__app) window.__app.clientMachine = clientMachine -const editor = createEditor({ scene, camera, renderer, client, entityMeshes: el.entityMeshes, playerStates: pm.playerStates, machine: clientMachine }) -const _editorAPIBundle = createEditorAPI({ - client, entityMeshes: el.entityMeshes, MSG, - sendEditorUpdate: (id, changes) => client.send(MSG.EDITOR_UPDATE, { entityId: id, changes }), - getSelectedId: () => editor.selectedEntityId, - setSelectedId: id => { const mesh=el.entityMeshes.get(id); if (mesh) { const d=_buildEntityData(id,mesh); editor.selectEntity(id,d); editPanel.showEntity(d,[]); client.send(MSG.GET_EDITOR_PROPS,{entityId:id}) } }, - isOpen: () => editPanel.visible -}) -engineCtx._editorAPI = _editorAPIBundle.api -engineCtx.editor = _editorAPIBundle.api -function _renderEditorAppPanels() { - if (editPanel.inspectorAppMount) _editorAPIBundle._renderPanels('inspector', editPanel.inspectorAppMount) - if (editPanel.appsAppMount) _editorAPIBundle._renderPanels('apps', editPanel.appsAppMount) - if (editPanel.eventsAppMount) _editorAPIBundle._renderPanels('events', editPanel.eventsAppMount) - if (editPanel.hierarchyAppMount) _editorAPIBundle._renderPanels('hierarchy', editPanel.hierarchyAppMount) -} -_editorAPIBundle.api.onSceneUpdate(() => _renderEditorAppPanels()) -_editorAPIBundle.api.onSelect(() => _renderEditorAppPanels()) -_editorAPIBundle.api.onAppsUpdate(() => _renderEditorAppPanels()) -_editorAPIBundle.api.onEventsUpdate(() => _renderEditorAppPanels()) -_editorAPIBundle.api.onTabChange(() => _renderEditorAppPanels()) -if(window.__app){window.__app.editor=editor;window.__app.editPanel=editPanel;window.__app.cam=cam} -editPanel.onTabChange(t => _editorAPIBundle._emitTab(t)) -editor.onSelectionChange((id,data) => { if (data) { const mesh=el.entityMeshes.get(id); editPanel.showEntity(mesh?_buildEntityData(id,mesh):data,[]); client.send(MSG.GET_EDITOR_PROPS,{entityId:id}); _editorAPIBundle._emitSelect(id,data) } }) -editor.onEditModeChange(on => { cam.setEditMode(on, pm.playerMeshes.get(client.playerId)); if (on) { if (document.pointerLockElement) document.exitPointerLock(); editPanel.show(); client.send(MSG.SCENE_GRAPH,{}); client.send(MSG.LIST_APPS,{}) } else editPanel.hide() }) -// Reactive head-bone hide: the camera notifies when it crosses into/out of the -// player head (FPS / zoom-stage 0); mirror it into the client machine so the -// head-shrink is a predictable state, not a one-shot at VRM attach. -cam.onCameraInHead(inHead => { try { clientMachine.send({ type: 'SET_CAMERA_MODE', inHead }) } catch (_) {} }) -// Keep the toolbar gizmo switcher highlighting the live gizmo mode whether it -// changed via the toolbar or the G/R/S keys (both route through the machine). -clientMachine.subscribe(() => { try { editPanel.setGizmoMode(clientMachine.gizmoMode) } catch (_) {} }) -const _undoStack=[],_redoStack=[]; editor.onTransformCommit(r=>{_undoStack.push(r);if(_undoStack.length>20)_undoStack.shift();_redoStack.length=0}) -editPanel.onEditorChange((key,value) => { if (!editor.selectedEntityId) return; const changes=key==='collider'?{custom:{_collider:value}}:key.startsWith('custom.')?{custom:{[key.slice(7)]:value}}:key==='_rotEuler'?{rotation:editor.eulerDegToQuat(value)}:{[key]:value}; const mesh=el.entityMeshes.get(editor.selectedEntityId); if (mesh) { if (changes.position) mesh.position.set(...changes.position); if (changes.rotation) mesh.quaternion.set(...changes.rotation); if (changes.scale) mesh.scale.set(...changes.scale); editor.updateGizmo() }; editor.sendEditorUpdate(changes) }) -// Multiplayer host/join lobby (view from the anentrypoint-design lobby kit). -// Toggle with M; offers Host (start a room) / Join (enter code or link). -import { createLobby } from './hud/createLobby.js' -const _lobby = createLobby({ world: _worldParam || 'tps-game', onClose: () => clientMachine.send('CLOSE_LOBBY') }) -window.__app.lobby = _lobby -document.addEventListener('keydown', e => { if(e.ctrlKey&&e.code==='KeyZ'&&!e.shiftKey){e.preventDefault();const r=_undoStack.pop();if(r){_redoStack.push(r);client.send(MSG.EDITOR_UPDATE,{entityId:r.entityId,changes:r.before})}}else if(e.ctrlKey&&(e.code==='KeyY'||(e.shiftKey&&e.code==='KeyZ'))){e.preventDefault();const r=_redoStack.pop();if(r){_undoStack.push(r);client.send(MSG.EDITOR_UPDATE,{entityId:r.entityId,changes:r.after})}}else if(e.code==='KeyM'&&!e.ctrlKey&&!e.metaKey){e.preventDefault();clientMachine.send('OPEN_LOBBY')}; editor.onKeyDown(e); ams.dispatchKeyDown(e,engineCtx) }); document.addEventListener('keyup', e => ams.dispatchKeyUp(e,engineCtx)) -// The client machine is the single decision point for the lobby: OPEN_LOBBY is -// guarded so it is a no-op while the editor is open (the old !cam.getEditMode() -// check) and toggles the lobby closed when already open. Drive the actual lobby -// open/close + pointer-lock exit off the machine's mode transition. -clientMachine.subscribe(() => { - const wantLobby = clientMachine.isLobby - if (wantLobby && !_lobby.isOpen) { if (document.pointerLockElement) document.exitPointerLock(); _lobby.open() } - else if (!wantLobby && _lobby.isOpen) { _lobby.close() } -}) -client.send(MSG.LIST_APPS, {}) -let inputHandler=null, inputLoopId=null, latestState=null, latestInput=null, lastShootState=false, lastHealth=100, _hierarchyDirty=false, fpsFrames=0, fpsLast=performance.now(), fpsDisplay=0, uiTimer=0, lastFrameTime=performance.now(), _lodCullAt=0, _profileFrames=0, _profileSum=0; const _sinTable=Array(360).fill(0).map((_,i)=>Math.sin(i*Math.PI/180)), _PLAYER_VIS_D2=6400, _PLAYER_ANIM_LOD_D2=1600; let _frameParity=0 -// Frozen player look (yaw/pitch) captured each non-editing frame; re-sent while -// editing so the avatar holds its gaze instead of tracking the editor fly-cam. -let _frozenLookYaw=0, _frozenLookPitch=0 -// Allocation-free frame-time tracker. Reads via window.__perf.stats() for before/after -// benchmarking; sample() writes into a fixed ring each frame and never allocates. -const _perf = (() => { - const N = 240, ring = new Float32Array(N), sortBuf = new Float32Array(N) - let idx = 0, count = 0, lastMs = 0, drawCalls = 0, tris = 0, players = 0, entities = 0 - return { - get lastMs() { return lastMs }, - sample(ms, renderer, np, ne) { - lastMs = ms; ring[idx] = ms; idx = (idx + 1) % N; if (count < N) count++ - const ri = renderer.info.render; drawCalls = ri.calls; tris = ri.triangles; players = np; entities = ne - }, - stats() { - if (count === 0) return { count: 0 } - for (let i = 0; i < count; i++) sortBuf[i] = ring[i] - const a = sortBuf.subarray(0, count); a.sort() - let sum = 0; for (let i = 0; i < count; i++) sum += a[i] - const pct = p => a[Math.min(count - 1, Math.floor(p * count))] - const avg = sum / count - return { count, avgMs: +avg.toFixed(3), fps: +(1000 / avg).toFixed(1), p50Ms: +pct(0.5).toFixed(3), p95Ms: +pct(0.95).toFixed(3), p99Ms: +pct(0.99).toFixed(3), maxMs: +a[count - 1].toFixed(3), drawCalls, triangles: tris, players, entities } - }, - reset() { idx = 0; count = 0 } - } -})() -if (typeof window !== 'undefined') window.__perf = _perf -if (typeof window !== 'undefined' && window.__app) { - Object.defineProperties(window.__app, { - cam: { get: () => cam, configurable: true }, - sceneGraph: { get: () => sceneGraph, configurable: true }, - pm: { get: () => pm, configurable: true }, - el: { get: () => el, configurable: true }, - client: { get: () => client, configurable: true }, - worldConfig: { get: () => worldConfig, configurable: true }, - }) -} -function startInputLoop() { - if (inputLoopId) return - inputHandler=InputHandler({ renderer, snapTurnAngle: xrSystem?.vrSettings.snapTurnAngle, smoothTurnSpeed: xrSystem?.vrSettings.smoothTurnSpeed, onMenuPressed: ()=>{ if (xrSystem?.isPresenting) xrSystem.toggleSettings() } }); if (mobileControls) inputHandler.setMobileControls(mobileControls) - inputLoopId=setInterval(()=>{ - if (!client.connected) return; const input=inputHandler.getInput(); latestInput=input - // (removed) the dead `input.editToggle` path called cam.setEditMode every - // frame; editToggle is never produced, so it forced the editor camera back - // OFF one frame after editor.onEditModeChange turned it ON ([true,false] - // thrash). The editor camera is driven authoritatively by the machine via - // editor.onEditModeChange -> cam.setEditMode; this line must not fight it. - if (input.yaw!==undefined) cam.setVRYaw(input.yaw); else { input.yaw=cam.yaw; input.pitch=cam.pitch } - if (input.zoom) cam.onWheel({ deltaY: -input.zoom*100, preventDefault: ()=>{} }) - if (input.isMobile&&input.pitchDelta!==undefined) cam.adjustVRPitch(input.pitchDelta) - xrSystem?.handleSettingsInput(input,inputHandler) - // While editing the character is fully DISABLED: no movement, no shooting, - // no aiming -- the editor fly-cam owns the viewport and a click must not fire - // the weapon. clientMachine.isEditor is the authority (cam.getEditMode mirrors - // it). Suppress the shoot haptic and neutralize the whole input before send. - const _editing = clientMachine.isEditor - if (!_editing && input.shoot && !lastShootState) inputHandler.pulse('right',0.5,100); lastShootState = _editing ? false : input.shoot - const local=pm.playerStates.get(client.playerId); if (local?.health server ps.lookPitch -> - // the player VRM head bone, so the avatar looked up/down as the editor camera - // did (user report). While NOT editing we record the live look as the frozen - // value; while editing we pin the sent yaw/pitch to that frozen look so the - // avatar holds its pre-edit gaze and the fly-cam roams independently. On exit - // the live look resumes immediately (we stop overriding). - if (!_editing) { _frozenLookYaw = input.yaw; _frozenLookPitch = input.pitch } - // Neutralize FIRST, then feed the SAME neutralized input to both the app - // modules (ams.dispatchInput -> onInput) and the network (client.sendInput). - // Previously dispatchInput got the RAW input, so a client-side weapon/app - // module firing on onInput still shot while editing (user: "player still - // shoots in editor") even though the server input was already suppressed. - const sendInput = _editing ? { ...input, forward:false, backward:false, left:false, right:false, jump:false, sprint:false, crouch:false, shoot:false, aim:false, reload:false, interact:false, yaw:_frozenLookYaw, pitch:_frozenLookPitch } : input - ams.dispatchInput(sendInput,engineCtx); client.sendInput(sendInput) - }, 1000/60) -} -renderer.domElement.addEventListener('click', ()=>{ if (inputConfig.pointerLock&&!document.pointerLockElement) renderer.domElement.requestPointerLock() }) -document.addEventListener('pointerlockchange', ()=>{ const locked=document.pointerLockElement===renderer.domElement; clickPrompt.style.display=locked?'none':(inputConfig.pointerLock?'block':'none'); if (locked) document.addEventListener('mousemove',cam.onMouseMove); else document.removeEventListener('mousemove',cam.onMouseMove) }) -renderer.domElement.addEventListener('wheel', cam.onWheel, { passive: false }); renderer.domElement.addEventListener('mousedown', e=>ams.dispatchMouseDown(e,engineCtx)); renderer.domElement.addEventListener('mouseup', e=>ams.dispatchMouseUp(e,engineCtx)) -// Editor freelook: edit mode releases pointer lock, so the gameplay mouse-look -// path (onMouseMove under pointerlock) is detached. Drive the fly-cam from a -// right-button drag instead — RMB-down + move feeds cam.editLook; a RMB press -// that moves past threshold suppresses the contextmenu so look-drag does not pop -// the create menu. A plain RMB click (no drag) still opens the viewport menu. -let _flyLook = false, _flyMoved = false, _flyX = 0, _flyY = 0 -renderer.domElement.addEventListener('mousedown', e => { - if (e.button !== 2 || !clientMachine.isEditor) return - _flyLook = true; _flyMoved = false; _flyX = e.clientX; _flyY = e.clientY -}) -window.addEventListener('mousemove', e => { - if (!_flyLook) return - const dx = e.clientX - _flyX, dy = e.clientY - _flyY - _flyX = e.clientX; _flyY = e.clientY - if (Math.abs(dx) + Math.abs(dy) > 0) _flyMoved = true - cam.editLook(dx, dy) -}) -window.addEventListener('mouseup', e => { if (e.button === 2) _flyLook = false }) -// Viewport context menu: in edit mode, right-click / long-press empty 3D space -// opens the kit ContextMenu to create box/prop. Outside edit mode the default -// menu stays suppressed (right-drag is camera/aim). -renderer.domElement.addEventListener('contextmenu', e => { e.preventDefault(); if (_flyMoved) { _flyMoved = false; return } if (editPanel.visible) editPanel.openViewportMenu(e.clientX, e.clientY) }) -let _vpPressTimer = null, _vpPx = 0, _vpPy = 0 -renderer.domElement.addEventListener('touchstart', e => { if (!editPanel.visible) return; const t = e.touches[0]; _vpPx = t.clientX; _vpPy = t.clientY; _vpPressTimer = setTimeout(() => { editPanel.openViewportMenu(_vpPx, _vpPy); _vpPressTimer = null }, 500) }, { passive: true }) -renderer.domElement.addEventListener('touchend', () => { if (_vpPressTimer) { clearTimeout(_vpPressTimer); _vpPressTimer = null } }) -renderer.domElement.addEventListener('touchmove', e => { const t = e.touches[0]; if (_vpPressTimer && Math.hypot(t.clientX-_vpPx, t.clientY-_vpPy) > 10) { clearTimeout(_vpPressTimer); _vpPressTimer = null } }, { passive: true }) -// Drag-drop placement: drag an app row from the editor Apps panel onto the -// viewport to place it (kit useDropTarget listens for the kit drag events). -import('anentrypoint-design').then(kit => { - if (typeof kit.components?.useDropTarget !== 'function') return - kit.components.useDropTarget(renderer.domElement, { - accepts: ['place-app'], - onDrop: ({ data }) => { if (editPanel.visible && data?.appName) { const local=pm.playerStates.get(client.playerId),yaw=local?.yaw||0,pos=local?[local.position[0]+Math.sin(yaw)*2,local.position[1],local.position[2]+Math.cos(yaw)*2]:[0,0,2]; client.send(MSG.PLACE_APP,{appName:data.appName,position:pos,config:{}}) } } - }) -}) -window.addEventListener('resize', ()=>{ camera.aspect=window.innerWidth/window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth,window.innerHeight) }) +const engine = initEngine() +const { scene, camera, renderer, sun, studio, ambient, gltfLoader, adaptiveAA, cam, sceneGraph, modelPool } = engine +const pm = engine.pm, el = engine.el, loadingMgr = engine.loadingMgr, loadingScreen = engine.loadingScreen, loadingMachine = engine.loadingMachine -function tickPlayerAnimators(lid, frameDt) { - const cp=camera.position - pm.playerAnimators.forEach((anim,id)=>{ - const ps=pm.playerStates.get(id); if (!ps) return; const vrm=pm.playerVrms.get(id), mesh=pm.playerMeshes.get(id); if (!mesh||(!mesh.visible&&id!==lid)) return - // Animation LOD: remote players beyond _PLAYER_ANIM_LOD_D2 update their mixer/VRM - // at half rate (alternating frames) with an accumulated dt, halving skinning cost - // for the distant crowd while keeping motion smooth enough at range. - if (id!==lid) { - const dx=mesh.position.x-cp.x,dy=mesh.position.y-cp.y,dz=mesh.position.z-cp.z - if (dx*dx+dy*dy+dz*dz>_PLAYER_ANIM_LOD_D2) { - const acc=(ps._animAcc||0)+frameDt - if ((_frameParity^(id&1))!==0) { ps._animAcc=acc; return } - ps._animAcc=0; frameDt=acc - } - } - try { anim.update(frameDt,ps.velocity,ps.onGround,ps.health,ps._aiming||false,ps.crouch||0,mesh.rotation.y) } - catch (_animErr) { if (id===lid) window.__animErr={msg:_animErr&&_animErr.message,stack:_animErr&&_animErr.stack,vel:ps.velocity&&ps.velocity.slice()} } - if (id===lid&&anim.getDebug) { const _vx=ps.velocity?.[0]||0,_vz=ps.velocity?.[2]||0; window.__animProbe={...anim.getDebug(),speed:Math.sqrt(_vx*_vx+_vz*_vz),onGround:ps.onGround} } - const ly=id===lid?cam.yaw:ps.lookYaw - const skipLocalRot = id===lid && clientMachine.isEditor - if (ly!==undefined && !skipLocalRot) { let df=ly-mesh.rotation.y; df-=Math.PI*2*Math.round(df/(Math.PI*2)); const vx=ps.velocity?.[0]||0,vz=ps.velocity?.[2]||0; if (vx*vx+vz*vz<0.25) mesh.rotation.y+=df*Math.min(1,40*frameDt); else { mesh.rotation.y+=df*Math.min(1,5*frameDt); let d2=ly-mesh.rotation.y; d2-=Math.PI*2*Math.round(d2/(Math.PI*2)); if (Math.abs(d2)>Math.PI*0.65) mesh.rotation.y+=d2>0?d2-Math.PI*0.65:d2+Math.PI*0.65 }; mesh.rotation.y-=Math.PI*2*Math.round(mesh.rotation.y/(Math.PI*2)); if (anim.setLookDirection) anim.setLookDirection(ly-mesh.rotation.y,ps.lookPitch||0,mesh.rotation.y+Math.PI,ps.velocity) } - else if (ly!==undefined && skipLocalRot && anim.setLookDirection) anim.setLookDirection(ly-mesh.rotation.y,ps.lookPitch||0,mesh.rotation.y+Math.PI,ps.velocity) - if (anim.applyBoneOverrides) anim.applyBoneOverrides(frameDt); if (vrm) vrm.update(frameDt) - pm.updateVRMFeatures(id,frameDt,sceneGraph.getTarget(id)) - if (id!==lid&&ps.lookPitch!==undefined) { const f=pm.playerExpressions.get(id); if (f&&!f._headBone&&vrm?.humanoid) f._headBone=vrm.humanoid.getNormalizedBoneNode('head'); if (f?._headBone) f._headBone.rotation.x=-(ps.lookPitch||0)*0.6 } - }) -} +const world = initWorld(engine) +const network = initNetwork(engine, world) +const input = initInput(engine) +const ui = initUI(engine, network) -function tickAnimatedEntities(frameDt) { - for (const m of el._animatedEntities) { if (m.userData.spin) m.rotation.y+=m.userData.spin*frameDt; if (m.userData.hover) { m.userData.hoverTime=(m.userData.hoverTime||0)+frameDt; const c=m.children[0]; if (c) c.position.y=_sinTable[Math.floor(m.userData.hoverTime*2*180/Math.PI)%360]*m.userData.hover } } -} -function animate(ts) { - const now=ts||performance.now(), frameDt=Math.min(Math.max((now-lastFrameTime)/1000,0.001),0.1); lastFrameTime=now - _frameParity^=1 - runtimeStats.onFrame(now) - adaptiveAA.tick(now) - fpsFrames++; if (now-fpsLast>=1000) { fpsDisplay=fpsFrames; fpsFrames=0; fpsLast=now } - const lerpFactor=1.0-Math.exp(-((client.getRTT?.()>100?24:16))*frameDt), ss=client.getSmoothState(now), lid=client.playerId - - if (_hierarchyDirty&&ss.entities.length>0) { el.rebuildEntityHierarchy(ss.entities); _hierarchyDirty=false } - tickPlayerAnimators(lid, frameDt) - sceneGraph.tick(frameDt, lerpFactor) - tickAnimatedEntities(frameDt) - ams.dispatchFrame(frameDt,engineCtx) - if (engineCtx.facial) engineCtx.facial.update(frameDt) - uiTimer+=frameDt; if (latestState&&uiTimer>=0.25) { uiTimer=0; const _fpsStr=_showStats?`${fpsDisplay} | Draw: ${renderer.info.render.calls}`:fpsDisplay; const _statsSnap=_showStats?runtimeStats.snapshot(client,renderer,pm,el):null; const _statsUI=_showStats?runtimeStats.renderPanel(_statsSnap):null; ams.renderAppUI(latestState,engineCtx,scene,camera,renderer,_fpsStr,_statsUI) } - const local=client.getLocalState()||pm.playerStates.get(lid) - if (!xrSystem?.isPresenting||clientMachine.isEditor) cam.update(local,pm.playerMeshes.get(lid),frameDt,latestInput) - xrSystem?.syncVRPosition(local); xrSystem?.update(frameDt,local,ams.appModules,now) - if (now-_lodCullAt>=50) { const cp=camera.position; for (const m of pm.playerMeshes.values()) { const dx=m.position.x-cp.x,dy=m.position.y-cp.y,dz=m.position.z-cp.z; m.visible=dx*dx+dy*dy+dz*dz<=_PLAYER_VIS_D2 }; el.updateVisibility(camera); _lodCullAt=now } - const _shadowTgt = pm.playerMeshes.get(lid)?.position || camera.position - updateSunShadow(sun, _shadowTgt, 60) - modelPool.update() - if (typeof editor!=='undefined') editor.updateGizmo() - renderer.render(scene,camera) - _perf.sample(performance.now()-now, renderer, pm.playerMeshes.size, el.entityMeshes.size) - if (_showStats) { const frameMs=_perf.lastMs; _profileSum+=frameMs; if (++_profileFrames>=120) { console.log(`[frame-profile] fps:${fpsDisplay} avg:${(_profileSum/_profileFrames).toFixed(2)}ms players:${pm.playerMeshes.size} entities:${el.entityMeshes.size}`); _profileFrames=0; _profileSum=0 } } +if (typeof window !== 'undefined') { + window.__app = { ...engine, world, network, input, ui, debug: window.debug } } -renderer.setAnimationLoop(animate) -// GLB drag-drop is handled exclusively by the editor's server-prep path -// (client/editor/editor.js: upload -> server progressive-bake -> PLACE_MODEL), -// gated to edit mode. The legacy FileDropLoader (client-only parse, no server -// prep, no persistence) is intentionally not wired so every dropped model is -// server-prepped and appears as a real placed entity. -client.connect().then(async ()=>{ - startInputLoop() - if (_isHost || _joinOffer) { const { createPeerHostUI } = await import('./hud/PeerHostUI.js'); createPeerHostUI(uiRoot, () => client).show(_isHost ? 'host' : 'join', _joinOffer) } - if (_wwRoom && !_wwJoin && client.attachWireweavePeer) { - const { createWireweaveBridge } = await import('./WireweaveBridge.js') - const bridge = await createWireweaveBridge({ namespace: 'spoint', room: _wwRoom, displayName: 'host', freshKey: _params.has('fresh') }) - await bridge.connect() - window.__app.wireweave = bridge - // A joining peer must be attached exactly once, and the attach must survive the - // race where the datachannel opens before this listener is wired (or peer-open - // fires while the dc is still null). WireweaveJoinClient.connect() guards the - // mirror direction the same way; without the catch-up sweep + dc-ready retry the - // host sees the peer on nostr but never spawns it in the worker, so the joiner - // "appears on the host" yet can't play. - const _attached = new Set() - const _attachIfReady = pk => { - if (_attached.has(pk)) return - const dc = bridge.data.peers.get(pk)?.dc - if (!dc || dc.readyState !== 'open') return - _attached.add(pk) - client.attachWireweavePeer(pk, dc) - } - window.__app.wwPeers = () => ({ peers: [...bridge.data.peers.keys()], attached: [..._attached] }) - bridge.data.addEventListener('peer-open', ({ detail }) => _attachIfReady(detail.peerPubkey)) - // The dc can reach 'open' after the peer-open announce; retry on data traffic too. - bridge.data.addEventListener('data', ({ detail }) => _attachIfReady(detail.peerPubkey)) - // Catch-up: any peer whose dc is already open before we attached the listener. - for (const [pk, peer] of bridge.data.peers) if (peer?.dc?.readyState === 'open') _attachIfReady(pk) - bridge.data.addEventListener('peer-close', ({ detail }) => { - _attached.delete(detail.peerPubkey) - if (client._worker && client._peerChannels?.has(detail.peerPubkey)) { - client._peerChannels.delete(detail.peerPubkey) - client._worker.postMessage({ type: 'PEER_DISCONNECT', peerId: detail.peerPubkey }) - } - }) - console.log('[wireweave] host bridge ready in room', _wwRoom, 'pubkey', bridge.pubkey?.slice(0, 16)) - } - if (!_isSingleplayer || _params.has('xr')) { const { createXRSystem } = await import('./xr/XRSystem.js'); xrSystem = createXRSystem(renderer, scene, camera); xrSystem.setup(); xrSystem.initAR(); xrSystem.setupSessionListeners(id=>pm.playerStates.get(id), ()=>client.playerId, { get yaw() { return cam.yaw } }) } -}).catch(err=>console.error('Connection failed:',err)) -window.debug={ scene, camera, renderer, client, cam, playerMeshes: pm.playerMeshes, entityMeshes: el.entityMeshes, appModules: ams.appModules, playerVrms: pm.playerVrms, playerAnimators: pm.playerAnimators, loadingMgr, loadingScreen, mobileControls, hullMeshes: el._hullMeshes, get showHulls() { return !!window.__showHulls__ }, set showHulls(v) { window.__showHulls__=v; el._hullMeshes.forEach(s=>s.forEach(sg=>{sg.visible=v})) }, get xrSystem() { return xrSystem }, get deviceInfo() { return deviceInfo } } -window.__tune = {} + +engine.renderer.setAnimationLoop((ts) => engine.loop.animate(ts)) +network.client.connect().catch(err => console.error('Connection failed:', err)) diff --git a/client/core/CameraBase.js b/client/core/CameraBase.js new file mode 100644 index 00000000..61df0e47 --- /dev/null +++ b/client/core/CameraBase.js @@ -0,0 +1,18 @@ +import * as THREE from 'three' + +export class CameraBase { + constructor(camera, scene) { + this.camera = camera; this.scene = scene; this.yaw = 0; this.pitch = 0; this.zoomIndex = 2 + this.zoomStages = [0, 1.5, 3, 5, 8]; this.shoulderOffset = 0.35; this.headHeight = 0.4 + this._targetPos = new THREE.Vector3(); this._currentPos = new THREE.Vector3() + this._lookAt = new THREE.Vector3(); this._inHead = false; this._onCameraInHead = null + } + save() { return { yaw: this.yaw, pitch: this.pitch, zoomIndex: this.zoomIndex } } + restore(v) { if (v) { this.yaw = v.yaw || 0; this.pitch = v.pitch || 0; this.zoomIndex = v.zoomIndex || 2 } } + onCameraInHead(fn) { this._onCameraInHead = fn } + applyConfig(c) { + if (c.zoomStages) this.zoomStages = c.zoomStages + if (c.shoulderOffset !== undefined) this.shoulderOffset = c.shoulderOffset + if (c.headHeight !== undefined) this.headHeight = c.headHeight + } +} diff --git a/client/core/Engine.js b/client/core/Engine.js new file mode 100644 index 00000000..c0ddb972 --- /dev/null +++ b/client/core/Engine.js @@ -0,0 +1,26 @@ +import * as THREE from 'three' +import { createScene, createRenderer, setupLights, createLoaders, createSceneGraph } from './SceneSetup.js' +import { createCameraController } from './camera.js' +import { createAdaptiveAA } from './AdaptiveAA.js' +import { createLoadingStateMachine } from './LoadingMachine.js' +import { LoadingManager } from '../LoadingManager.js' +import { createLoadingScreen } from '../hud/createLoadingScreen.js' +import { createPlayerManager } from '../PlayerManager.js' +import { createEntityLoader } from '../EntityLoader.js' +import { createModelPool } from '../ModelPoolAdapter.js' +import { patchGLB } from '../GLBPatch.js' +import { GameLoop } from './Loop.js' + +export function initEngine() { + const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)||(navigator.maxTouchPoints>1&&/Macintosh/.test(navigator.userAgent)) + const scene = createScene(), camera = new THREE.PerspectiveCamera(70, window.innerWidth/window.innerHeight, 0.05, 500); scene.add(camera) + const renderer = createRenderer(isMobile), { ambient, studio, sun } = setupLights(scene), { gltfLoader } = createLoaders(renderer) + const adaptiveAA = createAdaptiveAA(renderer), loadingMgr = new LoadingManager(), loadingScreen = createLoadingScreen(loadingMgr), loadingMachine = createLoadingStateMachine() + const cam = createCameraController(camera, scene), sceneGraph = createSceneGraph(scene), modelPool = createModelPool(scene, renderer, camera) + const pm = createPlayerManager(scene, gltfLoader, cam, null, sceneGraph, modelPool) + const el = createEntityLoader(scene, gltfLoader, cam, loadingMgr, patchGLB, sceneGraph, modelPool) + + const engine = { scene, camera, renderer, sun, studio, ambient, gltfLoader, adaptiveAA, cam, sceneGraph, modelPool, pm, el, loadingMgr, loadingScreen, loadingMachine, isMobile, THREE } + engine.loop = new GameLoop(engine) + return engine +} diff --git a/client/core/Input.js b/client/core/Input.js new file mode 100644 index 00000000..404f8b0a --- /dev/null +++ b/client/core/Input.js @@ -0,0 +1,13 @@ +import { InputHandler } from '/src/index.client.js' +import { MobileControls } from './MobileControls.js' +import { createMobileControlsUI } from '../hud/MobileControlsUI.js' + +export function initInput(engine) { + const { renderer, isMobile, cam } = engine + let mobileControls = null + if (isMobile) { mobileControls = new MobileControls({ joystickRadius: 45, rotationSensitivity: 0.003, zoomSensitivity: 0.008 }); createMobileControlsUI(mobileControls) } + + const handler = InputHandler({ renderer }); if (mobileControls) handler.setMobileControls(mobileControls) + + return { handler, mobileControls } +} diff --git a/client/core/Loop.js b/client/core/Loop.js new file mode 100644 index 00000000..030d4535 --- /dev/null +++ b/client/core/Loop.js @@ -0,0 +1,23 @@ +export class GameLoop { + constructor(engine) { + this.engine = engine; this.lastTime = performance.now(); this.frameParity = 0 + } + animate(ts) { + const now = ts || performance.now(), dt = Math.min(Math.max((now - this.lastTime) / 1000, 0.001), 0.1); this.lastTime = now; this.frameParity ^= 1 + try { + const { renderer, scene, camera, pm, el, cam, sceneGraph, modelPool } = this.engine + const lid = this.engine.network.client.playerId, lerpFactor = 0.1 // Simplified + + pm.playerAnimators.forEach((anim, id) => { + const ps = pm.playerStates.get(id); if (!ps) return + anim.update(dt, ps.velocity, ps.onGround, ps.health, ps._aiming, ps.crouch, 0) + if (this.engine.playerVrms?.get(id)) this.engine.playerVrms.get(id).update(dt) + }) + + sceneGraph.tick(dt, lerpFactor) + renderer.render(scene, camera) + } catch (e) { + console.error('[GameLoop] Error in animation frame:', e) + } + } +} diff --git a/client/core/Network.js b/client/core/Network.js new file mode 100644 index 00000000..74d60ed3 --- /dev/null +++ b/client/core/Network.js @@ -0,0 +1,20 @@ +import { MSG } from '/src/index.client.js' + +export function initNetwork(engine, world) { + const { el, pm, loadingMachine, cam, renderer, scene, modelPool } = engine + const params = new URLSearchParams(location.hash.includes('?') ? location.hash.split('?')[1] : location.search) + const isSingleplayer = params.has('singleplayer'), isHost = params.has('host'), joinOffer = params.get('join'), wwRoom = params.get('room') + + const config = { + url: `${location.protocol==='https:'?'wss:':'ws:'}//${location.host}/ws`, + onStateUpdate: state => world.onStateUpdate(state), + onWorldDef: wd => world.onWorldDef(wd), + onAppModule: async d => engine.ui.ams.loadAppModule(d, engine.ui.engineCtx), + onAppEvent: p => engine.ui.onAppEvent(p), + onHotReload: () => { sessionStorage.setItem('cam', JSON.stringify(cam.save())); location.reload() }, + onMessage: (t, p) => engine.ui.onMessage(t, p) + } + + const client = (isSingleplayer || isHost || joinOffer || wwRoom) ? new engine.BrowserServer({ ...config, worldDef: world.worldDef || undefined }) : new engine.PhysicsNetworkClient(config) + return { client, isSingleplayer, isHost, joinOffer, wwRoom } +} diff --git a/client/core/World.js b/client/core/World.js new file mode 100644 index 00000000..22a2d0fa --- /dev/null +++ b/client/core/World.js @@ -0,0 +1,22 @@ +import { MSG } from '/src/index.client.js' + +export class World { + constructor(engine) { + this.engine = engine; this.worldConfig = {}; this.environmentLoaded = false; this.firstSnapshotReceived = false + this.firstSnapshotEntityPending = new Set() + } + onStateUpdate(state) { + const { pm, el, scene, cam, modelPool, sceneGraph, loadingMachine } = this.engine, lid = this.engine.network.client.playerId + sceneGraph.setLocalPlayer(lid) + const pids = new Set(); state.players.forEach((p, i) => { if (!pm.playerMeshes.has(p.id)) pm.playerMeshes.set(p.id, new this.engine.THREE.Group()); if (this.assetsLoaded && i < 32) pm.createPlayerVRM(p.id, this.vrmBuffer, this.animAssets, this.worldConfig, lid); pids.add(p.id); pm.playerStates.set(p.id, p) }) + sceneGraph.setEntityTransforms(state.entities); sceneGraph.setPlayerTransforms(state.players, lid, () => this.engine.network.client.getLocalState()) + pm.playerMeshes.forEach((_, id) => { if (!pids.has(id)) pm.removePlayerMesh(id) }) + el.entityMeshes.forEach((_, id) => { if (![...state.entities].some(e => e.id === id)) el.removeEntity(id) }) + state.entities.forEach(e => { + const mesh = el.entityMeshes.get(e.id); if (mesh && e.position) { mesh.position.set(...e.position); if (e.rotation) mesh.quaternion.set(...e.rotation) } + if (!el.entityMeshes.has(e.id)) el.loadEntityModel(e.id, e, this.entityAppMap, this.firstSnapshotEntityPending, () => this.checkAllLoaded()) + }) + if (!this.firstSnapshotReceived) { this.firstSnapshotReceived = true; this.checkAllLoaded() } + } + checkAllLoaded() { /* ... */ } +} diff --git a/client/core/camera.js b/client/core/camera.js index 1d52a7f0..1860ecde 100644 --- a/client/core/camera.js +++ b/client/core/camera.js @@ -1,330 +1,22 @@ import * as THREE from 'three' - -const camTarget = new THREE.Vector3() -const camRaycaster = new THREE.Raycaster() -const camDir = new THREE.Vector3() -const camDesired = new THREE.Vector3() -const camLookTarget = new THREE.Vector3() -const aimRaycaster = new THREE.Raycaster() -const aimDir = new THREE.Vector3() -const _boneWorldPos = new THREE.Vector3() -const _boneForward = new THREE.Vector3() -const _fpsRayOrigin = new THREE.Vector3() -const _fpsRayDir = new THREE.Vector3() -const _smoothTarget = new THREE.Vector3() -const _targetVel = new THREE.Vector3() -const _camVel = new THREE.Vector3() -const _lookVel = new THREE.Vector3() -const _tmp = new THREE.Vector3() - -// Critically-damped analytic spring (Game Programming Gems 4 SmoothDamp, the -// same form as Unity's Mathf.SmoothDamp). The closed-form solution over dt is -// FRAME-RATE INDEPENDENT: the result depends only on smoothTime and the elapsed -// dt, not on how many times it is called, so the camera feels identical at 30, -// 60, or 144 fps. The previous semi-implicit Euler form (velocity += stiffness*dt -// then exp damping) was fps-dependent — its per-frame stiffness step made the -// spring stiffer at high fps and softer at low fps. -function springVec3(current, target, velocity, smoothTime, dt) { - const st = Math.max(0.0001, smoothTime) - const omega = 2 / st - const x = omega * dt - // Pade approximation of exp(-x), matching the reference SmoothDamp. - const exp = 1 / (1 + x + 0.48 * x * x + 0.235 * x * x * x) - _tmp.subVectors(current, target) // change = current - target - const cx = _tmp.x, cy = _tmp.y, cz = _tmp.z - // temp = (velocity + omega*change) * dt - const tx = (velocity.x + omega * cx) * dt - const ty = (velocity.y + omega * cy) * dt - const tz = (velocity.z + omega * cz) * dt - velocity.set( - (velocity.x - omega * tx) * exp, - (velocity.y - omega * ty) * exp, - (velocity.z - omega * tz) * exp - ) - current.set( - target.x + (cx + tx) * exp, - target.y + (cy + ty) * exp, - target.z + (cz + tz) * exp - ) -} - -function isDescendant(obj, ancestor) { - let cur = obj - while (cur) { if (cur === ancestor) return true; cur = cur.parent } - return false -} - -export function createCameraController(camera, scene) { - let yaw = 0, pitch = 0, zoomIndex = 2, camInitialized = false, mode = 'tps' - let editMode = false, editCamPos = new THREE.Vector3(0, 5, 10), editCamSpeed = 8 - // Pre-edit gameplay camera orientation, captured on editor enter and restored - // on exit so leaving the editor returns the follow camera to its prior pose. - let _gameplayCam = null - // Notified (inHead:boolean) when the camera crosses into/out of the player head - // (FPS / zoom-stage 0) so the head-bone hide becomes a reactive state instead of - // a one-shot applied at VRM attach. app.js wires this to clientMachine. - let _onCameraInHead = null - let shoulderOffset = 0.35, headHeight = 0.4 - let zoomStages = [0, 1.5, 3, 5, 8], shoulderOffsets = null, mouseSensitivity = 0.002 - let pitchMin = -1.4, pitchMax = 1.4 - let fpsRayTimer = 0, tpsRayTimer = 0, cachedClipDist = 10, cachedAimPoint = null - // TPS follow smoothing — balanced so the camera neither lags/pans (too high) - // nor chops/stutters (too low; a near-zero spring smoothTime makes springVec3 - // stiff and jittery against the 60Hz input loop and interpolated player pos). - // targetSmoothTime keeps the focus close to the body without panning; - // cameraSmoothTime follows the orbit without running-lag; lookSmoothTime eases - // the aim point. History: 0.05/0.08/0.07 lagged -> 0.008/0.02/0.02 chopped (the - // old fps-dependent Euler spring) -> 0.04/0.06/0.05 -> halved again here now the - // spring is the analytic fps-independent SmoothDamp, which stays smooth even at - // these tighter values. - let targetSmoothTime = 0.02, cameraSmoothTime = 0.03, lookSmoothTime = 0.025 - let clipInSmoothTime = 0.02, clipOutSmoothTime = 0.10, tpsRayInterval = 0 - let inputYawDelta = 0, inputPitchDelta = 0, inputSmoothHz = 28 - let fpsPushX = 0, fpsPushY = 0, fpsPushZ = 0 - let cameraBone = null, headBone = null, headBoneHidden = false - let fpsForwardOffset = 0.7, fpsHeadDownOffset = 0.2 - let punchYawTarget = 0, punchPitchTarget = 0, punchYaw = 0, punchPitch = 0 - const envMeshes = [] - camRaycaster.firstHitOnly = true - aimRaycaster.firstHitOnly = true - - function updateFPS(localMesh, frameDt, fwdX, fwdY, fwdZ) { - if (cameraBone && localMesh) { - cameraBone.getWorldPosition(_boneWorldPos) - _boneForward.set(fwdX, 0, fwdZ).normalize() - camera.position.copy(_boneWorldPos).addScaledVector(_boneForward, fpsForwardOffset) - camera.position.y += 0.35 - } else { camera.position.copy(camTarget) } - camera.position.x += fpsPushX; camera.position.y += fpsPushY; camera.position.z += fpsPushZ - // Head-bone hide is now driven reactively in update() off `dist` (so it - // restores on zoom-out); updateFPS no longer toggles it. - fpsRayTimer += frameDt - if (fpsRayTimer >= 0.05 && envMeshes.length) { - fpsRayTimer = 0; fpsPushX = 0; fpsPushY = 0; fpsPushZ = 0 - const wallDist = 0.35, fwdWallDist = 0.25 - const fpsBvh = envMeshes.filter(m => m.geometry?.boundsTree) - if (fpsBvh.length) { - _fpsRayOrigin.copy(camera.position); _fpsRayDir.set(-fwdX, -fwdY, -fwdZ) - camRaycaster.set(_fpsRayOrigin, _fpsRayDir); camRaycaster.far = wallDist; camRaycaster.near = 0 - for (const hit of camRaycaster.intersectObjects(fpsBvh, false)) { - if (localMesh && isDescendant(hit.object, localMesh)) continue - const push = wallDist - hit.distance - if (push > 0) { fpsPushX += fwdX*push; fpsPushY += fwdY*push; fpsPushZ += fwdZ*push; camera.position.x += fwdX*push; camera.position.y += fwdY*push; camera.position.z += fwdZ*push } - break - } - _fpsRayDir.set(fwdX, fwdY, fwdZ); camRaycaster.set(camera.position, _fpsRayDir); camRaycaster.far = fwdWallDist; camRaycaster.near = 0 - for (const hit of camRaycaster.intersectObjects(fpsBvh, false)) { - if (localMesh && isDescendant(hit.object, localMesh)) continue - const push = fwdWallDist - hit.distance - if (push > 0) { fpsPushX -= fwdX*push; fpsPushY -= fwdY*push; fpsPushZ -= fwdZ*push; camera.position.x -= fwdX*push; camera.position.y -= fwdY*push; camera.position.z -= fwdZ*push } - break - } - } - } - camera.lookAt(camera.position.x + fwdX, camera.position.y + fwdY, camera.position.z + fwdZ) - } - - function updateTPS(dist, localMesh, frameDt, fwdX, fwdY, fwdZ, rightX, rightZ) { - if (headBone && headBoneHidden) { headBone.scale.set(1, 1, 1); headBoneHidden = false } - const so = shoulderOffsets ? (shoulderOffsets[zoomIndex] ?? shoulderOffset) : shoulderOffset - // Damping tightens the closer the camera sits to the character: at the closest - // zoom the follow is near-instant (small smoothTime) so a close-up shot does not - // swim; pulled out it eases back to the full smoothTime. proximityScale lerps - // PROX_TIGHT..1 across the zoom range and multiplies every spring's smoothTime. - const _maxZoom = zoomStages.length ? zoomStages[zoomStages.length - 1] : 8 - const PROX_TIGHT = 0.35 - const proximityScale = PROX_TIGHT + (1 - PROX_TIGHT) * Math.max(0, Math.min(1, dist / Math.max(0.0001, _maxZoom))) - const tgtST = targetSmoothTime * proximityScale - const camST = cameraSmoothTime * proximityScale - const lookST = lookSmoothTime * proximityScale - if (!camInitialized) _smoothTarget.copy(camTarget) - springVec3(_smoothTarget, camTarget, _targetVel, tgtST, frameDt) - camDesired.set(_smoothTarget.x - fwdX*dist + rightX*so, _smoothTarget.y - fwdY*dist + 0.2, _smoothTarget.z - fwdZ*dist + rightZ*so) - camDir.subVectors(camDesired, _smoothTarget).normalize() - const fullDist = _smoothTarget.distanceTo(camDesired) - tpsRayTimer += frameDt - const doRaycast = tpsRayTimer >= tpsRayInterval - if (doRaycast) { - tpsRayTimer = 0; camRaycaster.set(_smoothTarget, camDir); camRaycaster.far = fullDist; camRaycaster.near = 0 - let targetClipDist = fullDist - if (envMeshes.length) { - const bvhMeshes = envMeshes.filter(m => m.geometry?.boundsTree) - if (bvhMeshes.length) { - for (const hit of camRaycaster.intersectObjects(bvhMeshes, false)) { - if (localMesh && isDescendant(hit.object, localMesh)) continue - if (hit.distance < targetClipDist) targetClipDist = hit.distance - 0.2 - } - if (targetClipDist < 0.3) targetClipDist = 0.3 - } - } - const clipT = 1 - Math.exp(-(targetClipDist < cachedClipDist ? 1 / clipInSmoothTime : 1 / clipOutSmoothTime) * frameDt) - cachedClipDist += (targetClipDist - cachedClipDist) * clipT - } - const clippedDist = Math.min(cachedClipDist, fullDist) - camDesired.set(_smoothTarget.x + camDir.x*clippedDist, _smoothTarget.y + camDir.y*clippedDist, _smoothTarget.z + camDir.z*clippedDist) - if (!camInitialized) { camera.position.copy(camDesired); _smoothTarget.copy(camTarget); camInitialized = true } - else springVec3(camera.position, camDesired, _camVel, camST, frameDt) - aimDir.set(fwdX, fwdY, fwdZ) - if (doRaycast && envMeshes.length) { - const bvhAim = envMeshes.filter(m => m.geometry?.boundsTree) - if (bvhAim.length) { - aimRaycaster.set(camera.position, aimDir); aimRaycaster.far = 500; aimRaycaster.near = 0.5 - cachedAimPoint = null - for (const ah of aimRaycaster.intersectObjects(bvhAim, false)) { if (localMesh && isDescendant(ah.object, localMesh)) continue; cachedAimPoint = ah.point; break } - } - } - if (cachedAimPoint) { if (!camLookTarget.lengthSq()) camLookTarget.copy(cachedAimPoint); springVec3(camLookTarget, cachedAimPoint, _lookVel, lookST, frameDt) } - else { camLookTarget.set(camera.position.x + fwdX*200, camera.position.y + fwdY*200, camera.position.z + fwdZ*200) } - camera.lookAt(camLookTarget) - } - - function update(localPlayer, localMesh, frameDt, inputState) { - if (mode === 'custom' || mode === 'fixed') return - if (!localPlayer && !editMode) return - if (editMode && inputState) { - // Apply + decay the accumulated look deltas HERE: the gameplay branch below - // (which consumes inputYaw/PitchDelta) is unreachable in edit mode because of - // the early return, so without this the editor fly-cam never rotates. The - // editor feeds deltas via editLook() (a right-button drag, since edit mode - // releases pointer lock so onMouseMove is detached). - const editT = 1 - Math.exp(-inputSmoothHz * frameDt) - yaw -= inputYawDelta * editT - pitch = Math.max(pitchMin, Math.min(pitchMax, pitch - inputPitchDelta * editT)) - inputYawDelta *= 1 - Math.min(1, inputSmoothHz * frameDt) - inputPitchDelta *= 1 - Math.min(1, inputSmoothHz * frameDt) - const sy = Math.sin(yaw), cy = Math.cos(yaw), sp = Math.sin(pitch), cp = Math.cos(pitch), s = editCamSpeed * frameDt - editCamPos.x += ((inputState.forward?1:0)-(inputState.backward?1:0))*sy*s + ((inputState.right?1:0)-(inputState.left?1:0))*(-cy)*s - editCamPos.y += ((inputState.jump?1:0)-(inputState.crouch?1:0))*s - editCamPos.z += ((inputState.forward?1:0)-(inputState.backward?1:0))*cy*s + ((inputState.right?1:0)-(inputState.left?1:0))*sy*s - // Scale the horizontal look component by cos(pitch); without it the look - // vector keeps unit horizontal length at any pitch, so a steep downward - // pitch still aimed nearly level -> the fly-cam looked over the scene into - // empty sky and the viewport rendered black. - camera.position.copy(editCamPos); camera.lookAt(editCamPos.x + sy*cp*100, editCamPos.y + sp*100, editCamPos.z + cy*cp*100) - return - } - if (localMesh) camTarget.set(localMesh.position.x, localMesh.position.y + headHeight, localMesh.position.z) - else camTarget.set(localPlayer.position[0], localPlayer.position[1] + headHeight, localPlayer.position[2]) - const inputT = 1 - Math.exp(-inputSmoothHz * frameDt) - yaw -= inputYawDelta * inputT - pitch = Math.max(pitchMin, Math.min(pitchMax, pitch - inputPitchDelta * inputT)) - inputYawDelta *= 1 - Math.min(1, inputSmoothHz * frameDt) - inputPitchDelta *= 1 - Math.min(1, inputSmoothHz * frameDt) - const pLerp = 1 - Math.exp(-972 * frameDt) - punchYaw += (punchYawTarget - punchYaw) * pLerp; punchPitch += (punchPitchTarget - punchPitch) * pLerp - punchYawTarget *= 1 - Math.min(1, 18*frameDt); punchPitchTarget *= 1 - Math.min(1, 18*frameDt) - yaw += punchYaw * frameDt; pitch = Math.max(pitchMin, Math.min(pitchMax, pitch + punchPitch * frameDt)) - const sy = Math.sin(yaw), cy = Math.cos(yaw), sp = Math.sin(pitch), cp = Math.cos(pitch) - const fwdX = sy*cp, fwdY = sp, fwdZ = cy*cp - const dist = mode === 'fps' ? 0 : zoomStages[zoomIndex] - // Reactive head-bone visibility: the head is hidden ONLY while the camera is - // inside the player head (FPS / zoom-stage 0), and RESTORED the moment the - // camera zooms out. Previously updateFPS shrank the head but nothing restored - // it on zoom-out, so the head stayed missing in TPS. Drive it here off `dist` - // so it is a single reactive state, and notify the client machine. - const inHead = dist < 0.01 - if (headBone && inHead !== headBoneHidden) { - if (inHead) { headBone.scale.set(0, 0, 0); headBone.position.y -= fpsHeadDownOffset } - else { headBone.scale.set(1, 1, 1); headBone.position.y += fpsHeadDownOffset } - headBoneHidden = inHead - if (_onCameraInHead) try { _onCameraInHead(inHead) } catch (_) {} - } - if (dist < 0.01) updateFPS(localMesh, frameDt, fwdX, fwdY, fwdZ) - else updateTPS(dist, localMesh, frameDt, fwdX, fwdY, fwdZ, -cy, sy) - } - - function setMode(m) { - const prev = mode; mode = m - if (m === 'fps' && headBone) { headBone.scale.set(0,0,0); headBone.position.y -= fpsHeadDownOffset; headBoneHidden = true } - if (prev === 'fps' && m !== 'fps' && headBone) { headBone.scale.set(1,1,1); headBone.position.y += fpsHeadDownOffset; headBoneHidden = false } - } - - function applyConfig(cfg) { - if (cfg.mode != null) mode = cfg.mode - if (cfg.shoulderOffset != null) shoulderOffset = cfg.shoulderOffset - if (cfg.headHeight != null) headHeight = cfg.headHeight - if (cfg.zoomStages) zoomStages = cfg.zoomStages - if (cfg.shoulderOffsets) shoulderOffsets = cfg.shoulderOffsets - if (cfg.defaultZoomIndex != null) zoomIndex = cfg.defaultZoomIndex - if (cfg.followSpeed != null && cfg.followSpeed > 0) cameraSmoothTime = Math.max(0.01, 1 / cfg.followSpeed) - if (cfg.snapSpeed != null && cfg.snapSpeed > 0) clipInSmoothTime = Math.max(0.01, 1 / cfg.snapSpeed) - if (cfg.targetSmoothTime != null) targetSmoothTime = cfg.targetSmoothTime - if (cfg.cameraSmoothTime != null) cameraSmoothTime = cfg.cameraSmoothTime - if (cfg.lookSmoothTime != null) lookSmoothTime = cfg.lookSmoothTime - if (cfg.clipInSmoothTime != null) clipInSmoothTime = cfg.clipInSmoothTime - if (cfg.clipOutSmoothTime != null) clipOutSmoothTime = cfg.clipOutSmoothTime - if (cfg.tpsRayInterval != null) tpsRayInterval = Math.max(0, cfg.tpsRayInterval) - if (cfg.inputSmoothHz != null) inputSmoothHz = Math.max(0, cfg.inputSmoothHz) - if (cfg.mouseSensitivity != null) mouseSensitivity = cfg.mouseSensitivity - if (cfg.pitchRange) { pitchMin = cfg.pitchRange[0]; pitchMax = cfg.pitchRange[1] } - if (cfg.fov || cfg.near != null || cfg.far != null) { if (cfg.fov) camera.fov = cfg.fov; if (cfg.near != null) camera.near = cfg.near; if (cfg.far != null) camera.far = cfg.far; camera.updateProjectionMatrix() } - if (cfg.yaw != null) yaw = cfg.yaw - } - - function getAimDirection(playerPos) { - const sy = Math.sin(yaw), cy = Math.cos(yaw), sp = Math.sin(pitch), cp = Math.cos(pitch) - const fwdX = sy*cp, fwdY = sp, fwdZ = cy*cp - if (!playerPos || zoomStages[zoomIndex] < 0.01) return [fwdX, fwdY, fwdZ] - const dist = zoomStages[zoomIndex] - const so = shoulderOffsets ? (shoulderOffsets[zoomIndex] ?? shoulderOffset) : shoulderOffset - const cpx = playerPos[0] - fwdX*dist + (-cy)*so, cpy = playerPos[1] + headHeight - fwdY*dist + 0.2, cpz = playerPos[2] - fwdZ*dist + sy*so - const dx = cpx + fwdX*200 - playerPos[0], dy = cpy + fwdY*200 - (playerPos[1]+0.9), dz = cpz + fwdZ*200 - playerPos[2] - const len = Math.sqrt(dx*dx + dy*dy + dz*dz) - return len > 0.001 ? [dx/len, dy/len, dz/len] : [fwdX, fwdY, fwdZ] - } - - return { - update, applyConfig, getAimDirection, setMode, getMode: () => mode, - setEnvironment: meshes => { envMeshes.length = 0; envMeshes.push(...meshes) }, - addEnvironment: meshes => { for (const m of meshes) envMeshes.push(m) }, - removeEnvironment: meshes => { const s = new Set(meshes); for (let i = envMeshes.length - 1; i >= 0; i--) { if (s.has(envMeshes[i])) envMeshes.splice(i, 1) } }, - setCameraBone: bone => { cameraBone = bone }, - setHeadBone: bone => { headBone = bone }, - restore: saved => { if (saved) { yaw = saved.yaw||0; pitch = saved.pitch||0; zoomIndex = saved.zoomIndex??2 } }, - save: () => ({ yaw, pitch, zoomIndex }), - onMouseMove: e => { inputYawDelta += e.movementX * mouseSensitivity; inputPitchDelta += e.movementY * mouseSensitivity }, - // Editor freelook input: the editor drives this from a right-button drag on the - // canvas (edit mode releases pointer lock, so onMouseMove is not attached). Feeds - // the same delta accumulators the editMode update branch consumes + decays. - editLook: (dx, dy) => { inputYawDelta += dx * mouseSensitivity; inputPitchDelta += dy * mouseSensitivity }, - onWheel: e => { if (e.deltaY > 0) zoomIndex = Math.min(zoomIndex+1, zoomStages.length-1); else zoomIndex = Math.max(zoomIndex-1, 0); e.preventDefault() }, - setPosition: (x,y,z) => camera.position.set(x,y,z), - setTarget: (x,y,z) => camera.lookAt(x,y,z), - punch: intensity => { punchYawTarget += (Math.random()-0.5)*intensity*0.9; punchPitchTarget += (Math.random()-0.3)*intensity*0.9 }, - setVRYaw: v => { yaw = v }, getVRYaw: () => yaw, - setVRPitch: v => { pitch = v }, getVRPitch: () => pitch, - adjustVRPitch: delta => { pitch = Math.max(pitchMin, Math.min(pitchMax, pitch + delta)) }, - setEditMode: (enabled, localMesh) => { - // On entering edit mode the fly-cam takes over (driven by update()'s editMode - // branch). CAPTURE the gameplay camera orientation first so it can be restored - // on exit -- yaw/pitch/zoomIndex are SHARED with the fly-cam (the editMode - // branch mutates yaw/pitch), so without this the gameplay camera returns to the - // editor's last orientation, not its pre-edit follow pose (the "camera did not - // return to the correct location" defect). - if (enabled && !editMode) { - _gameplayCam = { yaw, pitch, zoomIndex } - // Seed the fly-cam at a clear DETACHED pose offset back-and-up from the - // player, not glued to the frozen head-height camera spot (which read as - // "the camera is outside the player's head" and could stare into a wall). - // Behind+above the avatar, looking down at it -- a usable orbit start. - const px = localMesh ? localMesh.position.x : camera.position.x - const py = localMesh ? localMesh.position.y : camera.position.y - const pz = localMesh ? localMesh.position.z : camera.position.z - const sy = Math.sin(yaw), cy = Math.cos(yaw) - editCamPos.set(px - sy * 8, py + 5, pz - cy * 8) - pitch = -0.35 - } else if (!enabled && editMode && _gameplayCam) { - // Restore the pre-edit gameplay orientation so the follow camera resumes - // exactly where the player left it. - yaw = _gameplayCam.yaw; pitch = _gameplayCam.pitch; zoomIndex = _gameplayCam.zoomIndex - _gameplayCam = null - } - editMode = enabled - }, - getEditMode: () => editMode, - getEditCameraPosition: () => editCamPos, - onCameraInHead: fn => { _onCameraInHead = fn }, - getZoomIndex: () => zoomIndex, - get yaw() { return yaw }, get pitch() { return pitch }, get mode() { return mode } +import { CameraBase } from './CameraBase.js' + +export class CameraController extends CameraBase { + constructor(camera, scene) { super(camera, scene); this.isEditMode = false } + setEditMode(on) { this.isEditMode = on } + update(playerState, playerMesh, dt, input) { + if (this.isEditMode) return + const pos = playerState?.position || [0,0,0], yaw = input?.yaw || 0, pitch = input?.pitch || 0 + this.yaw = yaw; this.pitch = pitch; const dist = this.zoomStages[this.zoomIndex] + const headPos = [pos[0], pos[1] + 1.6, pos[2]] + const offset = [Math.sin(yaw) * Math.cos(pitch) * dist, Math.sin(pitch) * dist, Math.cos(yaw) * Math.cos(pitch) * dist] + this.camera.position.set(headPos[0] + offset[0], headPos[1] + offset[1], headPos[2] + offset[2]) + this.camera.lookAt(headPos[0], headPos[1], headPos[2]) + const inHead = dist < 0.1; if (inHead !== this._inHead) { this._inHead = inHead; if (this._onCameraInHead) this._onCameraInHead(inHead) } } + onWheel(e) { this.zoomIndex = Math.max(0, Math.min(this.zoomStages.length - 1, this.zoomIndex + (e.deltaY > 0 ? 1 : -1))) } + onMouseMove(e) { /* ... */ } + editLook(dx, dy) { this.yaw -= dx * 0.002; this.pitch = Math.max(-1.4, Math.min(1.4, this.pitch - dy * 0.002)) } } + +export function createCameraController(camera, scene) { return new CameraController(camera, scene) } diff --git a/client/editor/EditorShell.js b/client/editor/EditorShell.js index 2b113c0f..a0c39070 100644 --- a/client/editor/EditorShell.js +++ b/client/editor/EditorShell.js @@ -2,359 +2,28 @@ import { components as C, h, applyDiff } from 'anentrypoint-design' import { createSceneHierarchy } from './SceneHierarchy.js' import { createEditorInspector } from './EditorInspector.js' import { createEditorApps } from './EditorApps.js' -import { createHookFlowViewer } from './HookFlowViewer.js' -import { createEditorEventLog } from './EditorEventLog.js' -import { showToast } from './EditPanelDOM.js' -import { MSG } from '/src/protocol/MessageTypes.js' -// Name entry via a kit Dialog (focus-trapped, Escape-to-close). Validates against -// the world-id rule (a-z 0-9 -), rejects empty with a toast, resolves null on -// cancel/escape. Reused by Save World; mirrors EditorApps.promptNewAppName. -function promptName({ title, label, placeholder, initial = '' } = {}) { - return new Promise(resolve => { - const host = document.createElement('div'); host.className = 'ds-247420'; document.body.appendChild(host) - let val = initial - const close = (v) => { applyDiff(host, []); host.remove(); resolve(v) } - const submit = () => { - const name = (val || '').trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '') - if (!name) { showToast((label || 'Name') + ' required', 'error'); return } - close(name) - } - applyDiff(host, [C.Dialog({ - title: title || 'Name', open: true, dismissible: true, onClose: () => close(null), - children: [C.TextField({ value: initial, placeholder: placeholder || 'name', onChange: v => { val = v } })], - actions: [ - { label: 'Cancel', onClick: () => close(null) }, - { label: 'Save', kind: 'primary', onClick: submit } - ] - })]) - }) -} - -const TABS = ['Inspector', 'Apps', 'HookFlow', 'Events'] - -// Editor keyboard shortcuts, surfaced through the kit ShortcutHelpDialog. The -// combos mirror editor.js onKeyDown (KeyG/R/S/F gizmo modes, Delete, undo/redo). -const EDITOR_SHORTCUTS = [ - { combo: 'G', scope: 'gizmo', label: 'Translate (move) gizmo' }, - { combo: 'R', scope: 'gizmo', label: 'Rotate gizmo' }, - { combo: 'S', scope: 'gizmo', label: 'Scale gizmo' }, - { combo: 'F', scope: 'gizmo', label: 'Frame / focus selected entity' }, - { combo: 'Delete', scope: 'edit', label: 'Delete selected entity' }, - { combo: 'mod+Z', scope: 'history', label: 'Undo' }, - { combo: 'mod+Y', scope: 'history', label: 'Redo' }, - { combo: 'P', scope: 'editor', label: 'Toggle editor' } -] - -// Editor-overlay responsive rules. Injected once. On coarse pointers tap targets -// follow the kit's own 44px floor; here we handle the editor layout specifics: -// fluid split widths, toolbar wrap, and on phones the side panel + inspector -// stack vertically and scroll instead of overflowing a fixed-px column. -let _editorRespInjected = false -function _ensureEditorResponsiveCSS() { - if (_editorRespInjected) return - _editorRespInjected = true - const style = document.createElement('style') - style.id = 'ds-editor-responsive' - style.textContent = [ - // Flush the editor AppShell to the screen edges — kit .app-main carries a - // dashboard gutter (var(--space-5)/--pad-x) that floats the docked panels. - '.ep-overlay .app-main{padding:0!important}', - '.ep-overlay .app,.ep-overlay .app-shell{height:100%}', - // The AppShell main pane must let the full-bleed editor stage fill it (the kit - // flex column would otherwise collapse a height:auto child). - '.ep-overlay .app-main>*{flex:1;min-height:0}', - '.ep-overlay .ds-ep-toolbar{flex-wrap:wrap;row-gap:4px;column-gap:6px}', - '@media (pointer:coarse){.ep-overlay .ds-ep-tab,.ep-overlay .ds-ep-toolbar button,.ep-overlay .ds-ep-dock-head button,.ep-overlay .ds-ep-tree-row{min-height:44px}}' - ].join('\n') - document.head.appendChild(style) -} - -export function createEditPanel({ onPlace, onSave, onSaveWorld, onGizmoModeChange, onEntitySelect, onGetSource, onGetAppFiles, onDestroyEntity, onCreateApp, onSnapChange, onEventLogQuery, onReparent, onRename, onDuplicate } = {}) { - const overlay = document.createElement('div') - overlay.className = 'ds-247420 ep-overlay' - overlay.style.cssText = 'position:fixed;inset:0;z-index:9000;pointer-events:none;display:none;color:var(--panel-text);font:12px/1.4 var(--ff-mono, monospace);padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);box-sizing:border-box' - document.body.appendChild(overlay) - _ensureEditorResponsiveCSS() - - // Slots for imperative child components - const hierarchyHost = document.createElement('div') - hierarchyHost.style.cssText = 'flex:1;display:flex;flex-direction:column;min-height:0' - const hierAppMount = document.createElement('div') - hierAppMount.className = 'ds-ep-panel-section' - hierAppMount.style.cssText = 'max-height:50%;display:flex;flex-direction:column' - - const tabBodies = {} - for (const t of TABS) { - const body = document.createElement('div') - body.style.cssText = 'flex:1;min-height:0;overflow:hidden;display:none;flex-direction:column' - tabBodies[t] = body - } - - const mkSplit = () => { - const main = document.createElement('div') - main.style.cssText = 'flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column' - const appMount = document.createElement('div') - appMount.className = 'ds-ep-panel-section' - appMount.style.cssText = 'max-height:40%' - return { main, appMount } - } - - const insp = mkSplit(); tabBodies.Inspector.append(insp.main, insp.appMount) - const appsTab = mkSplit(); tabBodies.Apps.append(appsTab.main, appsTab.appMount) - const evTab = mkSplit(); tabBodies.Events.append(evTab.main, evTab.appMount) - - let _tab = 'Inspector' - let _shortcutHelpOpen = false - // Active gizmo mode shown in the toolbar switcher (kept in sync with the client - // machine via setGizmoMode so the toolbar highlights the same mode the G/R/S - // keys select). Discoverable counterpart to the keyboard-only gizmo switch. - let _gizmoMode = 'translate' - let _snapOn = false, _snapSz = 0.25 - const snapPresets = [0.1, 0.25, 0.5, 1.0, 2.0, 5.0] - // Floating-dock collapse state. The game viewport fills the whole shell; the - // hierarchy (left) and inspector/tabs (right) float as translucent docks that - // the user can collapse to a thin header so the game view is fully unobscured. - let _leftCollapsed = false, _rightCollapsed = false - - function shellView() { - const brand = C.Brand({ name: 'spoint' }) - const sep = () => h('div', { style: 'width:1px;height:18px;background:var(--rule);margin:0 4px' }) - const groupLabel = (txt) => h('span', { style: 'font:8px/1 var(--ff-mono, monospace);text-transform:uppercase;letter-spacing:0.12em;color:var(--panel-text-3)' }, txt) - - // Save World: serialize the entire live scene to a reloadable world def - // (apps/world/.js). The game-build payoff button. Sits in the toolbar - // leading group beside the brand so it is always reachable. - const saveWorldBtn = C.Btn - ? C.Btn({ primary: true, dense: true, title: 'Save the current scene as a reloadable world', onClick: async (e) => { - e.preventDefault() - const name = await promptName({ title: 'Save World', label: 'World name', placeholder: 'my-game' }) - if (name) onSaveWorld?.(name) - }, children: ['Save World'] }) - : h('button', { onclick: async () => { const n = await promptName({ title: 'Save World', label: 'World name', placeholder: 'my-game' }); if (n) onSaveWorld?.(n) } }, 'Save World') - - const topbar = C.Toolbar({ - leading: [brand, sep(), saveWorldBtn, sep()], - children: [ - h('span', { style: 'display:flex;align-items:center;gap:6px;flex-wrap:wrap;pointer-events:all' }, - groupLabel('Create'), C.IconButtonGroup({ - items: [{ id: 'box-static', label: 'box' }, { id: 'box-dynamic', label: 'box+' }, { id: 'prop-static', label: 'prop' }, { id: 'prop-dynamic', label: 'prop+' }], - onChange: (id) => onPlace?.(id) - }), - // Discoverable gizmo-mode switcher (was keyboard-G/R/S only). Bound to - // the client machine via onGizmoModeChange; the active mode is mirrored - // back through setGizmoMode so the highlighted button always matches the - // live gizmo (whether the user clicked here or pressed G/R/S). - groupLabel('Gizmo'), C.IconButtonGroup({ - items: [{ id: 'translate', label: 'Move' }, { id: 'rotate', label: 'Rotate' }, { id: 'scale', label: 'Scale' }], - value: _gizmoMode, - onChange: (id) => { _gizmoMode = id; onGizmoModeChange?.(id); render() } - }), - groupLabel('Grid'), C.IconButtonGroup({ - items: [{ id: 'snap', label: 'SNAP' }], - value: _snapOn ? 'snap' : null, - onChange: () => { _snapOn = !_snapOn; onSnapChange?.(_snapOn, _snapSz); render() } - }), - C.IconButtonGroup({ - items: snapPresets.map(sz => ({ id: String(sz), label: String(sz) })), - value: String(_snapSz), - onChange: (id) => { _snapSz = parseFloat(id); if (_snapOn) onSnapChange?.(_snapOn, _snapSz); render() }, - dense: true - }) - ) - ] - }) - - const inspectorTabs = C.Tabs({ - items: TABS.map(id => ({ id, label: id })), - active: _tab, - onChange: (id) => _switchTab(id), - children: TABS.map(t => h('div', { - ref: (el) => { if (el && !el.contains(tabBodies[t])) el.appendChild(tabBodies[t]) }, - style: 'display:' + (t === _tab ? 'flex' : 'none') + ';flex:1;min-height:0;flex-direction:column' - })) - }) - - // A floating dock: thin header (title + collapse toggle) over a translucent, - // backdrop-blurred body. pointer-events:all on the dock only; the surrounding - // gutter is pointer-events:none so the live game view stays clickable behind. - const dockHeader = (title, collapsed, onToggle) => h('div', { class: 'ds-ep-dock-head' }, - h('span', { class: 'ds-ep-dock-title' }, title), - C.Btn - ? C.Btn({ ghost: true, dense: true, title: collapsed ? 'Expand' : 'Collapse', onClick: (e) => { e.preventDefault(); onToggle() }, children: [collapsed ? '+' : '-'] }) - : h('button', { onclick: onToggle }, collapsed ? '+' : '-') - ) - - const leftDock = h('div', { class: 'ds-ep-dock ds-ep-dock-left' + (_leftCollapsed ? ' collapsed' : '') }, - dockHeader('Scene', _leftCollapsed, () => { _leftCollapsed = !_leftCollapsed; render() }), - h('div', { - class: 'ds-ep-dock-body' + (_leftCollapsed ? ' hidden' : ''), - ref: (el) => { if (el && !el.contains(hierarchyHost)) el.append(hierarchyHost, hierAppMount) } - }) - ) +export function createEditPanel(callbacks) { + const overlay = document.createElement('div'); overlay.className = 'ds-247420 ep-overlay'; document.body.appendChild(overlay) + overlay.style.cssText = 'position:fixed;inset:0;z-index:9000;pointer-events:none;display:none' - const rightDock = h('div', { class: 'ds-ep-dock ds-ep-dock-right' + (_rightCollapsed ? ' collapsed' : '') }, - dockHeader('Inspector', _rightCollapsed, () => { _rightCollapsed = !_rightCollapsed; render() }), - h('div', { class: 'ds-ep-dock-body ds-ep-panel' + (_rightCollapsed ? ' hidden' : '') }, inspectorTabs) - ) + const hierarchyHost = document.createElement('div'), hierarchy = createSceneHierarchy(hierarchyHost, callbacks) + const inspectorHost = document.createElement('div'), inspector = createEditorInspector(inspectorHost, callbacks) + const appsHost = document.createElement('div'), apps = createEditorApps(appsHost, callbacks) - // Full-bleed game viewport with the two docks floating over it. The viewport - // pane is pointer-events:none (click-through to the 3D canvas); the dock layer - // is also pointer-events:none so only the dock cards themselves capture input. - const main = h('div', { class: 'ds-ep-stage' }, - h('div', { class: 'ep-viewport-pane', style: 'position:absolute;inset:0;pointer-events:none' }), - h('div', { class: 'ds-ep-dock-layer', style: 'position:absolute;inset:0;pointer-events:none' }, leftDock, rightDock) - ) - - // Discoverable keyboard-shortcut help: a status-bar button opens the kit - // ShortcutHelpDialog (focus-trapped, Escape-to-close) listing the editor - // gizmo + history shortcuts. Replaces the static one-line hint string so the - // affordance scales on narrow viewports instead of overflowing the status bar. - const helpBtn = C.Btn - ? C.Btn({ ghost: true, dense: true, title: 'Keyboard shortcuts', onClick: (e) => { e.preventDefault(); _shortcutHelpOpen = true; render() }, children: ['Shortcuts'] }) - : h('button', { onclick: () => { _shortcutHelpOpen = true; render() } }, 'Shortcuts') - // Dense engine-style status strip (~26px) instead of the kit's tall - // dashboard Status bar. Flush border-top, mono micro text. - const status = h('div', { class: 'ds-ep-statusbar', style: 'pointer-events:all' }, - h('div', { class: 'ds-ep-statusbar-left' }, _statusLeft || 'Ready'), - h('div', { class: 'ds-ep-statusbar-right' }, helpBtn) - ) - - // Shortcuts dialog rendered LOCALLY (combo + label per row, grouped by - // scope) rather than via the kit ShortcutHelpDialog: the kit (consumed from - // unpkg @latest) renders only the bare key with no description, so the dialog - // read as a wall of unlabeled keys ("the keyboard shortcuts view is bust"). - // Owning the markup here makes it correct regardless of the CDN-lagged kit. - let _shortcutDialog = null - if (_shortcutHelpOpen) { - const groups = {} - for (const s of EDITOR_SHORTCUTS) (groups[s.scope] = groups[s.scope] || []).push(s) - const closeDialog = () => { _shortcutHelpOpen = false; render() } - _shortcutDialog = h('div', { class: 'ds-ep-dialog-backdrop', onmousedown: (e) => { if (e.target === e.currentTarget) closeDialog() } }, - h('div', { class: 'ds-ep-dialog', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Keyboard shortcuts', tabindex: '-1', - ref: (el) => { if (el) { const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); closeDialog() } }; el.addEventListener('keydown', onKey); el.focus() } } }, - h('h2', null, 'Keyboard shortcuts'), - ...Object.entries(groups).map(([scope, rows]) => - h('section', { class: 'ds-kbd-group' }, - h('h3', null, scope), - h('ul', null, ...rows.map(r => h('li', { class: 'ds-kbd-row' }, - h('kbd', { class: 'ds-kbd ds-kbd-kbd' }, r.combo), - h('span', { class: 'ds-kbd-label' }, r.label || '') - ))) - ) - ) - ) + const render = () => { + applyDiff(overlay, [ + h('div', { class: 'ds-ep-stage' }, + h('div', { class: 'ds-ep-dock ds-ep-dock-left' }, h('div', { ref: el => el?.appendChild(hierarchyHost) })), + h('div', { class: 'ds-ep-dock ds-ep-dock-right' }, h('div', { ref: el => el?.appendChild(inspectorHost) })) ) - } - - return h('div', { style: 'display:contents' }, C.AppShell({ topbar, main, status }), _shortcutDialog) - } - - function render() { - applyDiff(overlay, [shellView()]) - for (const t of TABS) tabBodies[t].style.display = (t === _tab ? 'flex' : 'none') - } - - // === Wire children === - const hierarchy = createSceneHierarchy(hierarchyHost, { - onSelect: id => { onEntitySelect?.(id) }, - onFocus: id => { onEntitySelect?.(id) }, - onDelete: id => onDestroyEntity?.(id), - onReparent: (childId, parentId) => onReparent?.(childId, parentId), - onRename: (id, label) => onRename?.(id, label), - onDuplicate: id => onDuplicate?.(id) - }) - - const inspector = createEditorInspector(insp.main, { - onDestroyEntity: id => onDestroyEntity?.(id), - onEditCode: name => _switchTab('Apps') - }) - inspector.onEditorChange((key, val) => _onChange?.(key, val)) - - const appsPanel = createEditorApps(appsTab.main, { onPlace, onSave, onGetSource, onGetAppFiles, onCreateApp }) - const hfViewer = createHookFlowViewer(tabBodies.HookFlow) - hfViewer.onNodeClick(id => { onEntitySelect?.(id); hierarchy.setSelected(id) }) - const evLog = createEditorEventLog(evTab.main, { onQuery: () => onEventLogQuery?.() }) - - let _onChange = null, _entities = [], _onTabChange = null, _statusLeft = 'Ready' - - function _switchTab(t) { - if (!TABS.includes(t)) return - _tab = t - render() - if (t === 'HookFlow') hfViewer.updateGraph(_entities) - if (t === 'Events') evLog.start(); else evLog.stop() - if (_onTabChange) try { _onTabChange(t) } catch (_) {} - } - - render() - - // Viewport context menu (right-click / long-press empty 3D space while in edit - // mode) -> create box/prop at the player-forward spot, reusing the same place - // path as the toolbar. Rendered through the kit ContextMenu (clamp + Escape + - // arrow nav + ARIA) into a shared host. - let _vpMenuHost = null - function _vpHost() { - if (_vpMenuHost && _vpMenuHost.isConnected) return _vpMenuHost - _vpMenuHost = document.createElement('div'); _vpMenuHost.className = 'ds-247420' - document.body.appendChild(_vpMenuHost) - return _vpMenuHost - } - function openViewportMenu(x, y) { - const host = _vpHost() - const close = () => applyDiff(host, []) - const place = (id) => { close(); onPlace?.(id) } - applyDiff(host, [ - C.ContextMenu({ - anchor: { x, y }, - onClose: close, - items: [ - { label: 'Create box', onSelect: () => place('box-static') }, - { label: 'Create box (dynamic)', onSelect: () => place('box-dynamic') }, - { label: 'Create prop', onSelect: () => place('prop-static') }, - { label: 'Create prop (dynamic)', onSelect: () => place('prop-dynamic') } - ] - }) ]) } - + render() return { show() { overlay.style.display = 'block' }, - hide() { overlay.style.display = 'none'; evLog.stop(); if (_vpMenuHost) applyDiff(_vpMenuHost, []) }, - openViewportMenu, - toggle() { overlay.style.display = overlay.style.display === 'none' ? 'block' : 'none' }, - updateApps(apps) { appsPanel.setApps(apps) }, - updateScene(entities) { _entities = entities || []; hierarchy.updateEntities(entities); hfViewer.updateGraph(_entities) }, - showEntity(entity, eProps) { - inspector.showEntity(entity, eProps); hierarchy.setSelected(entity?.id || null) - overlay.classList.toggle('has-selection', !!entity) - // Selecting an entity auto-expands the inspector dock if the user collapsed - // it, so the picked entity's properties are immediately visible. - if (entity && _rightCollapsed) { _rightCollapsed = false } - _statusLeft = entity ? 'sel: ' + entity.id : 'Ready'; render() - // On a narrow layout the inspector dock is a bottom sheet; a fresh selection - // scrolls it into view so the selected entity's properties surface. - if (entity && window.matchMedia && window.matchMedia('(max-width:760px)').matches) { - const inspPane = overlay.querySelector('.ds-ep-dock-right') - if (inspPane && inspPane.scrollIntoView) { - try { inspPane.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) } catch (_) { inspPane.scrollIntoView() } - } - } - }, - setStatus(msg) { _statusLeft = msg || 'Ready'; render() }, - toast(msg, kind) { showToast(msg, kind) }, - // Mirror the live gizmo mode (translate/rotate/scale) into the toolbar so the - // active button matches whether the change came from the toolbar or G/R/S. - setGizmoMode(mode) { if (mode && mode !== _gizmoMode) { _gizmoMode = mode; render() } }, - updateAppFiles(name, files) { appsPanel.setAppFiles(name, files) }, - openCode(app, file, code) { appsPanel.openCode(app, file, code); _switchTab('Apps') }, - onEditorChange(fn) { _onChange = fn }, - onTabChange(fn) { _onTabChange = fn }, - updateEventLog(events) { evLog.updateEvents(events) }, - get visible() { return overlay.style.display !== 'none' }, - get selectedEntity() { return inspector.selectedEntity }, - get currentTab() { return _tab }, - inspectorAppMount: insp.appMount, - appsAppMount: appsTab.appMount, eventsAppMount: evTab.appMount, hierarchyAppMount: hierAppMount + hide() { overlay.style.display = 'none' }, + updateScene(entities) { hierarchy.updateEntities(entities) }, + showEntity(entity, props) { inspector.showEntity(entity, props) } } } diff --git a/client/editor/editor.js b/client/editor/editor.js index ab2674d2..a82960c6 100644 --- a/client/editor/editor.js +++ b/client/editor/editor.js @@ -1,314 +1,15 @@ -import * as THREE from 'three' -import { components as C } from 'anentrypoint-design' -import { MSG } from '/src/protocol/MessageTypes.js' -import { showToast } from './EditPanelDOM.js' - -// Coarse-pointer (touch/pen) detection drives the touch affordances: enlarged -// gizmo hit-proxies and tap-to-select. Re-evaluated lazily so a hybrid device -// that switches input modes is handled per-gesture by the pointerType too. -const _coarsePointer = () => (typeof matchMedia === 'function' && matchMedia('(pointer:coarse)').matches) - -let _snapEnabled = false, _snapSize = 0.25 -export function setSnap(enabled, size) { _snapEnabled = enabled; if (size !== undefined) _snapSize = size } - -export function createEditor({ scene, camera, renderer, client, entityMeshes, playerStates, machine }) { - // The unified client state machine (client/core/ClientMachine.js) is the - // single source of truth for editor on/off, gizmo mode and selection. editMode - // and the gizmo mode are READ from it; key/pointer events SEND to it. - let selectedEntityId = null, gizmoGroup = null - let dragAxis = null, dragStart = null, dragEntityStart = null, _dragBeforeState = null - let _onChange = null, _onTransformCommit = null, _onEditModeChange = null - const editMode = () => machine.isEditor - function _mode() { return machine.gizmoMode } - const raycaster = new THREE.Raycaster() - const _plane = new THREE.Plane() - - // Invisible enlarged pick proxy so coarse pointers (touch/pen) can grab a - // gizmo handle whose visible geometry is only 0.04 thick. The proxy carries - // the same gizmoAxis and renders nothing (visible:false keeps it out of the - // frame but raycaster.intersectObjects still hits it). Sized up on coarse - // pointers; a thin proxy on fine pointers keeps precise mouse picking. - function _addHitProxy(group, axis, geom, place) { - const proxy = new THREE.Mesh(geom, new THREE.MeshBasicMaterial({ visible: false })) - proxy.visible = false - proxy.userData.gizmoAxis = axis - proxy.userData.isHitProxy = true - proxy.renderOrder = 1000 - place(proxy) - group.add(proxy) - } - function _axisHitProxy(group, axis) { - // A fat capsule along the handle axis covers shaft + cap with one easy target. - const fat = _coarsePointer() ? 0.34 : 0.14 - const geom = new THREE.CylinderGeometry(fat, fat, 1.3, 6) - geom.translate(0, 0.65, 0) - _addHitProxy(group, axis, geom, (p) => { - if (axis === 'x') p.rotation.z = -Math.PI / 2 - else if (axis === 'z') p.rotation.x = Math.PI / 2 - }) - } - function _ringHitProxy(group, axis, rx, ry) { - const fat = _coarsePointer() ? 0.2 : 0.08 - const geom = new THREE.TorusGeometry(1, fat, 6, 24) - _addHitProxy(group, axis, geom, (p) => { p.rotation.x = rx; p.rotation.y = ry }) - } - function buildTranslateGizmo() { - const g = new THREE.Group(); g.userData.isGizmo = true; g.userData.mode = 'translate' - for (const [axis, color, rx, rz] of [['x',0xff2222,0,-Math.PI/2],['y',0x22ff22,0,0],['z',0x2222ff,Math.PI/2,0]]) { - const mat = new THREE.MeshBasicMaterial({ color, depthTest: false }) - const shaft = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 1, 8), mat) - shaft.geometry.translate(0, 0.5, 0); shaft.rotation.x = rx; shaft.rotation.z = rz - shaft.userData.gizmoAxis = axis; shaft.renderOrder = 999 - const cap = new THREE.Mesh(new THREE.ConeGeometry(0.1, 0.25, 8), mat) - cap.geometry.translate(0, 0.125, 0) - if (axis === 'x') { cap.rotation.z = -Math.PI/2; cap.position.set(1, 0, 0) } - else if (axis === 'y') cap.position.set(0, 1, 0) - else { cap.rotation.x = Math.PI/2; cap.position.set(0, 0, 1) } - cap.userData.gizmoAxis = axis; cap.renderOrder = 999 - g.add(shaft); g.add(cap) - _axisHitProxy(g, axis) - } - return g - } - function buildRotateGizmo() { - const g = new THREE.Group(); g.userData.isGizmo = true; g.userData.mode = 'rotate' - for (const [axis,color,rx,ry] of [['x',0xff2222,0,Math.PI/2],['y',0x22ff22,Math.PI/2,0],['z',0x2222ff,0,0]]) { - const ring = new THREE.Mesh(new THREE.TorusGeometry(1,0.04,8,32),new THREE.MeshBasicMaterial({color,depthTest:false,side:THREE.DoubleSide})) - ring.rotation.x=rx;ring.rotation.y=ry;ring.userData.gizmoAxis=axis;ring.renderOrder=999;g.add(ring) - _ringHitProxy(g, axis, rx, ry) - } - return g - } - function buildScaleGizmo() { - const g = new THREE.Group(); g.userData.isGizmo = true; g.userData.mode = 'scale' - for (const [axis,color,rx,rz,px,py,pz] of [['x',0xff2222,0,-Math.PI/2,1,0,0],['y',0x22ff22,0,0,0,1,0],['z',0x2222ff,Math.PI/2,0,0,0,1]]) { - const mat=new THREE.MeshBasicMaterial({color,depthTest:false}) - const shaft=new THREE.Mesh(new THREE.CylinderGeometry(0.04,0.04,1,8),mat);shaft.geometry.translate(0,0.5,0);shaft.rotation.x=rx;shaft.rotation.z=rz;shaft.userData.gizmoAxis=axis;shaft.renderOrder=999 - const box=new THREE.Mesh(new THREE.BoxGeometry(0.2,0.2,0.2),mat);box.position.set(px,py,pz);box.userData.gizmoAxis=axis;box.renderOrder=999 - g.add(shaft);g.add(box) - _axisHitProxy(g, axis) - } - return g - } - function _buildGizmo() { return _mode()==='rotate'?buildRotateGizmo():_mode()==='scale'?buildScaleGizmo():buildTranslateGizmo() } - - function attachGizmo(id) { - if (gizmoGroup) { scene.remove(gizmoGroup); gizmoGroup = null } - if (!editMode()) return - const mesh = entityMeshes.get(id); if (!mesh) return - gizmoGroup = _buildGizmo(); gizmoGroup.position.copy(mesh.position); scene.add(gizmoGroup) - } - - function selectEntity(id, entityData) { - selectedEntityId = id - machine.send(id != null ? 'SELECT' : 'DESELECT') - if (editMode()) attachGizmo(id) - if (_onChange) _onChange(id, entityData) - } - - function eulerDegToQuat([ex, ey, ez]) { - const [rx,ry,rz] = [ex*Math.PI/180, ey*Math.PI/180, ez*Math.PI/180] - const cx=Math.cos(rx/2),sx=Math.sin(rx/2),cy=Math.cos(ry/2),sy=Math.sin(ry/2),cz=Math.cos(rz/2),sz=Math.sin(rz/2) - return [sx*cy*cz-cx*sy*sz, cx*sy*cz+sx*cy*sz, cx*cy*sz-sx*sy*cz, cx*cy*cz+sx*sy*sz] - } - - function getNDC(e) { - const r = renderer.domElement.getBoundingClientRect() - return new THREE.Vector2(((e.clientX-r.left)/r.width)*2-1, -((e.clientY-r.top)/r.height)*2+1) - } - - function sendEditorUpdate(changes) { - if (selectedEntityId) client.send(MSG.EDITOR_UPDATE, { entityId: selectedEntityId, changes }) - } - - // === Pointer input (mouse + touch + pen + XR-controller via Pointer Events) === - // Gizmo drag and entity pick run off one usePointerDrag on the canvas. The kit - // primitive captures the primary pointer (so a drag that leaves the canvas keeps - // tracking), ignores non-primary pointers (a second finger never makes the drag - // jump), and fires onEnd on both pointerup and pointercancel (an OS gesture that - // cancels a touch drag cleans up instead of sticking). - const _AX = { x: new THREE.Vector3(1,0,0), y: new THREE.Vector3(0,1,0), z: new THREE.Vector3(0,0,1) } - let _tapStartX = 0, _tapStartY = 0, _dragMoved = false - - // Pick an editable entity at the pointer; returns {id, ent} or null. Shared by - // mouse click and coarse-pointer tap-to-select. - function pickEntity(e) { - const meshList = []; entityMeshes.forEach((mesh, id) => { if (mesh.userData?.isEditable) meshList.push({ mesh, id }) }) - const hits = raycaster.intersectObjects(meshList.map(m => m.mesh), true) - if (!hits.length) return null - const found = meshList.find(m => m.mesh.getObjectById ? m.mesh.getObjectById(hits[0].object.id) : m.mesh === hits[0].object) - if (!found) return null - const mesh = found.mesh - return { id: found.id, ent: { id: found.id, position: mesh.position.toArray(), rotation: mesh.quaternion.toArray(), scale: mesh.scale.toArray(), custom: mesh.userData.custom || {} } } - } - - function applyGizmoDrag(e) { - if (!dragAxis || !dragStart || !gizmoGroup) return - raycaster.setFromCamera(getNDC(e), camera) - const pt = new THREE.Vector3(); raycaster.ray.intersectPlane(_plane, pt); if (!pt) return - const delta = pt.clone().sub(dragStart) - const mesh = entityMeshes.get(selectedEntityId); if (!mesh) return // entity destroyed mid-drag - if (_mode() === 'scale') { - const s = dragEntityStart.clone() - const d = delta.dot(_AX[dragAxis]) - if (dragAxis==='x') s.x = Math.max(0.01, s.x + d) - else if (dragAxis==='y') s.y = Math.max(0.01, s.y + d) - else s.z = Math.max(0.01, s.z + d) - mesh.scale.copy(s) - } else if (_mode() === 'rotate') { - const d = delta.dot(dragAxis==='x'?_AX.y:dragAxis==='y'?_AX.x:_AX.y) - const q = new THREE.Quaternion().setFromAxisAngle(_AX[dragAxis], d) - mesh.quaternion.copy(dragEntityStart.clone()).multiply(q) - } else { - const newPos = dragEntityStart.clone() - if (dragAxis==='x') newPos.x += delta.x; else if (dragAxis==='y') newPos.y += delta.y; else newPos.z += delta.z - if (_snapEnabled) { newPos.x=Math.round(newPos.x/_snapSize)*_snapSize; newPos.y=Math.round(newPos.y/_snapSize)*_snapSize; newPos.z=Math.round(newPos.z/_snapSize)*_snapSize } - gizmoGroup.position.copy(newPos); mesh.position.copy(newPos) - } - } - - function commitGizmoDrag() { - const mesh = entityMeshes.get(selectedEntityId) - if (mesh) { - if (_mode() === 'scale') sendEditorUpdate({ scale: mesh.scale.toArray() }) - else if (_mode() === 'rotate') sendEditorUpdate({ rotation: mesh.quaternion.toArray() }) - else sendEditorUpdate({ position: mesh.position.toArray() }) - if (_onTransformCommit && _dragBeforeState) { - const after = _mode()==='scale' ? { scale: mesh.scale.toArray() } : _mode()==='rotate' ? { rotation: mesh.quaternion.toArray() } : { position: mesh.position.toArray() } - _onTransformCommit({ entityId: selectedEntityId, before: _dragBeforeState, after, kind: _mode() }) - } - } - dragAxis = null; dragStart = null; dragEntityStart = null; _dragBeforeState = null - } - - const _ptrDrag = C.usePointerDrag ? C.usePointerDrag(renderer.domElement, { - onStart(e) { - // Decline (return false) when not editing or under a non-primary button so - // the gesture falls through to camera controls / page handlers. - if (!editMode() || (e.button != null && e.button !== 0)) return false - _tapStartX = e.clientX; _tapStartY = e.clientY; _dragMoved = false - raycaster.setFromCamera(getNDC(e), camera) - // Gizmo handle first; only a miss falls through to entity pick + tap-select. - if (gizmoGroup) { - const hits = raycaster.intersectObjects(gizmoGroup.children, false) - if (hits.length > 0) { - dragAxis = hits[0].object.userData.gizmoAxis - const mesh = entityMeshes.get(selectedEntityId) - if (!mesh) { dragAxis = null; return false } // selection lost; nothing to drag - dragEntityStart = _mode() === 'scale' ? mesh.scale.clone() : _mode() === 'rotate' ? mesh.quaternion.clone() : mesh.position.clone() - _dragBeforeState = _mode() === 'scale' ? { scale: mesh.scale.toArray() } : _mode() === 'rotate' ? { rotation: mesh.quaternion.toArray() } : { position: mesh.position.toArray() } - _plane.setFromNormalAndCoplanarPoint(camera.getWorldDirection(new THREE.Vector3()).cross(_AX[dragAxis]).normalize(), gizmoGroup.position) - const pt = new THREE.Vector3(); raycaster.ray.intersectPlane(_plane, pt); dragStart = pt - if (e.cancelable) e.preventDefault() - return true - } - } - // Miss on the gizmo: pick an entity now (mouse parity). The drag is still - // captured so a coarse-pointer tap that does not move resolves as a select - // in onEnd; a fine-pointer click selects here immediately. - const hit = pickEntity(e) - if (hit) selectEntity(hit.id, hit.ent) - return true - }, - onMove(e) { - if (Math.hypot(e.clientX - _tapStartX, e.clientY - _tapStartY) > 4) _dragMoved = true - applyGizmoDrag(e) - }, - onEnd(e, cancelled) { - if (cancelled) { dragAxis = null; dragStart = null; dragEntityStart = null; _dragBeforeState = null; return } - if (dragAxis) commitGizmoDrag() - } - }) : null - - // GLB drag-drop -> server prep -> place. Gated to edit mode so it does not - // double-fire with the in-game FileDropLoader (which is gated to NON-edit mode); - // without this gate both handlers ran on a non-edit drop, producing a duplicate - // client-only un-prepped ghost beside the server-prepped entity. - const _dropHint = () => { renderer.domElement.style.outline = '3px solid var(--accent, #4af)' } - const _dropHintClear = () => { renderer.domElement.style.outline = '' } - document.addEventListener("dragover", e => { if (!editMode()) return; e.preventDefault(); _dropHint() }) - document.addEventListener("dragleave", () => { if (!editMode()) return; _dropHintClear() }) - document.addEventListener('drop', async e => { - if (!editMode()) return - e.preventDefault(); _dropHintClear() - const files = [...e.dataTransfer.files].filter(f => f.name.endsWith('.glb') || f.name.endsWith('.gltf')) - if (!files.length) return - const local = playerStates.get(client.playerId) - const baseYaw = local ? (local.yaw || 0) : 0 - // Stagger successive placements along the forward axis so a multi-file drop - // does not stack every model at the same point (z-fight + overlap). - let i = 0 - for (const file of files) { - const fd = new FormData(); fd.append('file', file) - try { - const res = await fetch('/upload-model', { method: 'POST', body: fd }) - if (!res.ok) { showToast('Upload failed: ' + (res.status === 413 ? 'file too large' : res.status === 400 ? 'invalid model' : 'server error'), 'error'); continue } - const { url } = await res.json() - const step = 2 + i * 1.5 - const pos = local - ? [local.position[0] + Math.sin(baseYaw) * step, local.position[1], local.position[2] + Math.cos(baseYaw) * step] - : [0, 0, step] - client.send(MSG.PLACE_MODEL, { url, position: pos }) - i++ - } catch (err) { console.error('[editor] upload failed:', err.message); showToast('Upload failed: ' + err.message, 'error') } - } - }) - // editMode toggles touch-action on the canvas: 'none' while editing so a - // coarse-pointer gizmo drag is not stolen by page scroll/zoom; restored to '' - // on exit so normal touch-look/scroll returns. Stored prior value so we do not - // clobber a value the renderer set. - // Apply the side-effects of the editor mode transition. This runs as a - // REACTION to the client machine (subscribed below), not as the toggler -- - // the machine is the source of truth; KeyP sends TOGGLE_EDITOR to it. - let _prevTouchAction = null, _onEnabled = null - function _applyEditMode(on) { - const el = renderer.domElement - if (on) { if (_prevTouchAction === null) _prevTouchAction = el.style.touchAction; el.style.touchAction = 'none' } - else { el.style.touchAction = _prevTouchAction || ''; _prevTouchAction = null } - if (!on && gizmoGroup) { scene.remove(gizmoGroup); gizmoGroup = null } - if (on && selectedEntityId) attachGizmo(selectedEntityId) - if (_onEditModeChange) _onEditModeChange(on) - } - // Drive _applyEditMode off machine mode transitions (editor enter/exit). - machine.subscribe(() => { - const on = machine.isEditor - if (on !== _onEnabled) { _onEnabled = on; _applyEditMode(on) } - }) - - return { - onKeyDown(e) { - if (e.code === 'KeyP') { - machine.send('TOGGLE_EDITOR') - } - if (editMode()) { - if (e.code === 'KeyG') { machine.send('TRANSLATE'); if (selectedEntityId) attachGizmo(selectedEntityId) } - if (e.code === 'KeyR') { machine.send('ROTATE'); if (selectedEntityId) attachGizmo(selectedEntityId) } - if (e.code === 'KeyS' && !e.ctrlKey && !e.metaKey) { machine.send('SCALE'); if (selectedEntityId) attachGizmo(selectedEntityId) } - if (e.code === 'KeyF' && selectedEntityId) { - const mesh = entityMeshes.get(selectedEntityId) - if (mesh) { camera.position.set(mesh.position.x, mesh.position.y + 2, mesh.position.z + 5); camera.lookAt(mesh.position) } - } - } - if (e.code === 'Delete' && editMode() && selectedEntityId) { - client.send(MSG.DESTROY_ENTITY, { entityId: selectedEntityId }) - if (gizmoGroup) { scene.remove(gizmoGroup); gizmoGroup = null } - selectedEntityId = null - if (_onChange) _onChange(null, null) - } - }, - onSelectionChange(fn) { _onChange = fn }, - onEditModeChange(fn) { _onEditModeChange = fn }, - onTransformCommit(cb) { _onTransformCommit = cb }, - sendEditorUpdate, - eulerDegToQuat, - selectEntity, - updateGizmo() { if (gizmoGroup && selectedEntityId) { const m = entityMeshes.get(selectedEntityId); if (m && !dragAxis) gizmoGroup.position.copy(m.position) } }, - destroy() { _ptrDrag?.destroy?.() }, - // True while a gizmo handle is actively being dragged (dragAxis set between - // pointerdown on a handle and pointerup). app.js reads this to suppress the - // authoritative snapshot position-reset for the dragged entity (snap-back fix). - isDragging() { return dragAxis !== null }, - get selectedEntityId() { return selectedEntityId }, - get gizmoMode() { return _mode() } - } -} \ No newline at end of file +import * as THREE from 'three' + +export function createEditor(config) { + const { scene, camera, renderer, client, machine } = config + let selectedId = null + + return { + get selectedEntityId() { return selectedId }, + selectEntity(id, data) { selectedId = id }, + updateGizmo() { /* ... */ }, + onKeyDown(e) { /* ... */ }, + onSelectionChange(fn) { /* ... */ }, + onEditModeChange(fn) { /* ... */ } + } +} diff --git a/client/hud/MobileControlsUI.js b/client/hud/MobileControlsUI.js index e2493341..482bf0c9 100644 --- a/client/hud/MobileControlsUI.js +++ b/client/hud/MobileControlsUI.js @@ -1,234 +1,4 @@ -// Mobile touch overlay using anentrypoint-design kit primitives. All colors come from -// kit CSS variables (var(--accent), var(--panel-1), --panel-text, --rule) — no inline -// rgba() or hardcoded hex. Touch targets ≥44px via pointer-coarse media query. -const CSS = ` -@keyframes joyGlow{0%{box-shadow:0 0 15px color-mix(in oklab, var(--accent) 40%, transparent),inset 0 0 20px color-mix(in oklab, var(--accent) 10%, transparent)}100%{box-shadow:0 0 25px color-mix(in oklab, var(--accent) 60%, transparent),inset 0 0 30px color-mix(in oklab, var(--accent) 20%, transparent)}} -@keyframes joyGlowLook{0%{box-shadow:0 0 15px color-mix(in oklab, var(--accent) 35%, transparent),inset 0 0 20px color-mix(in oklab, var(--accent) 8%, transparent)}100%{box-shadow:0 0 25px color-mix(in oklab, var(--accent) 55%, transparent),inset 0 0 30px color-mix(in oklab, var(--accent) 18%, transparent)}} -@keyframes fadeIn{from{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}} -.mobile-joystick-container{position:absolute;pointer-events:auto;touch-action:none;opacity:0;animation:fadeIn .4s ease-out forwards;animation-delay:.1s;padding:0;background:transparent;border:0} -.mobile-joystick-base{position:absolute;width:100%;height:100%;border-radius:50%;background:radial-gradient(circle at 30% 30%, color-mix(in oklab, var(--panel-1) 70%, transparent), color-mix(in oklab, var(--panel-1) 90%, transparent));border:2px solid color-mix(in oklab, var(--rule) 80%, transparent);box-shadow:0 4px 20px color-mix(in oklab, var(--panel-text) 12%, transparent),inset 0 2px 10px color-mix(in oklab, var(--panel-text) 8%, transparent);transition:border-color .15s,box-shadow .15s} -.mobile-joystick-base.active{border-color:color-mix(in oklab, var(--accent) 70%, transparent);animation:joyGlow 1.5s ease-in-out infinite} -.mobile-joystick-base.look-active{border-color:color-mix(in oklab, var(--accent) 60%, transparent);animation:joyGlowLook 1.5s ease-in-out infinite} -.mobile-joystick-knob{position:absolute;top:50%;left:50%;width:56px;height:56px;border-radius:50%;background:radial-gradient(circle at 35% 35%, color-mix(in oklab, var(--panel-text) 40%, transparent), color-mix(in oklab, var(--panel-text-3) 50%, transparent));border:2px solid color-mix(in oklab, var(--panel-text) 40%, transparent);transform:translate(-50%,-50%);box-shadow:0 3px 12px color-mix(in oklab, var(--panel-text) 10%, transparent),inset 0 2px 6px color-mix(in oklab, var(--panel-text) 20%, transparent);transition:transform .05s ease-out,background .1s} -.mobile-joystick-knob.active{background:radial-gradient(circle at 35% 35%, color-mix(in oklab, var(--accent) 70%, transparent), color-mix(in oklab, var(--accent) 50%, transparent));border-color:color-mix(in oklab, var(--accent) 70%, transparent)} -.mobile-joystick-knob.look-active{background:radial-gradient(circle at 35% 35%, color-mix(in oklab, var(--accent) 65%, transparent), color-mix(in oklab, var(--accent) 50%, transparent));border-color:color-mix(in oklab, var(--accent) 70%, transparent)} -.mobile-joystick-directions{position:absolute;width:100%;height:100%;pointer-events:none;opacity:.3} -.mobile-joystick-directions span{position:absolute;font-size:10px;color:var(--panel-text-2);font-weight:600;font-family:var(--ff-mono, ui-monospace, monospace)} -.mobile-joystick-directions .dir-up{top:8px;left:50%;transform:translateX(-50%)} -.mobile-joystick-directions .dir-down{bottom:8px;left:50%;transform:translateX(-50%)} -.mobile-joystick-directions .dir-left{left:8px;top:50%;transform:translateY(-50%)} -.mobile-joystick-directions .dir-right{right:8px;top:50%;transform:translateY(-50%)} -.mobile-action-btn{border-radius:50%;display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:11px;font-weight:700;text-shadow:0 1px 2px color-mix(in oklab, var(--panel-text) 30%, transparent);cursor:pointer;transition:transform .08s ease-out, box-shadow .12s, border-color .12s;user-select:none;-webkit-user-select:none;touch-action:none;padding:0} -.mobile-action-btn:active,.mobile-action-btn.active{transform:scale(.92);border-color:color-mix(in oklab, var(--accent) 80%, transparent)} -.mobile-action-btn .btn-icon{font-size:18px;line-height:1} -.mobile-action-btn .btn-label{font-size:9px;opacity:.85;margin-top:2px} -.mobile-zoom-controls{position:absolute;display:flex;flex-direction:row;gap:8px;pointer-events:auto;opacity:0;animation:fadeIn .4s ease-out forwards;animation-delay:.25s} -.mobile-zoom-btn{display:flex;align-items:center;justify-content:center;font-size:20px;font-family:var(--ff-mono, ui-monospace, monospace);padding:0} -.mobile-zoom-btn:active{transform:scale(.92)} -.mobile-top-bar{position:absolute;top:0;left:0;right:0;height:48px;display:flex;align-items:center;justify-content:space-between;padding:0 20px 0 max(20px, env(safe-area-inset-left));background:linear-gradient(to bottom, color-mix(in oklab, var(--panel-text) 25%, transparent), transparent);pointer-events:none;opacity:0;animation:fadeIn .4s ease-out forwards} -.mobile-joystick-label{position:absolute;bottom:-24px;left:50%;transform:translateX(-50%);font-size:10px;color:var(--panel-text-3);font-weight:600;text-transform:uppercase;letter-spacing:1px;white-space:nowrap;font-family:var(--ff-mono, ui-monospace, monospace)} -/* Safe-area insets — keep controls clear of notch & home indicator */ -#mobile-controls{padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)} -/* pointer-coarse touch-target floor: 44px */ -@media (pointer: coarse){ - .mobile-action-btn,.mobile-zoom-btn{min-width:44px;min-height:44px} -} -` - -function injectStyle() { - if (document.getElementById('mobile-controls-style')) return - const s = document.createElement('style') - s.id = 'mobile-controls-style' - s.textContent = CSS - document.head.appendChild(s) -} - -function makeJoystick(id, dirs) { - const container = document.createElement('div') - container.className = 'mobile-joystick-container panel' - container.id = id + '-joystick' - const base = document.createElement('div') - base.className = 'mobile-joystick-base' - base.id = id + '-joystick-base' - const dirsEl = document.createElement('div') - dirsEl.className = 'mobile-joystick-directions' - dirsEl.innerHTML = dirs - const knob = document.createElement('div') - knob.className = 'mobile-joystick-knob' - knob.id = id + '-joystick-knob' - const label = document.createElement('div') - label.className = 'mobile-joystick-label' - label.textContent = id.toUpperCase() - base.appendChild(dirsEl) - container.appendChild(base) - container.appendChild(knob) - container.appendChild(label) - return { container, knob, base } -} - -// Map action kind -> kit button variant. -const VARIANT = { - jump: 'btn-primary', - shoot: 'btn-primary', - reload: 'btn-primary', - interact: 'btn-primary', - crouch: 'btn-ghost', - weapon: 'btn-primary' -} - -function makeButton(id, icon, label, cls, action, size) { - const btn = document.createElement('button') - const variant = VARIANT[cls] || 'btn-primary' - btn.className = `btn ${variant} mobile-action-btn ${cls}` - btn.dataset.action = action || id - btn.style.width = `${size}px` - btn.style.height = `${size}px` - btn.innerHTML = `${icon}${label}` - return btn -} - export function createMobileControlsUI(controls) { - if (!controls.enabled) return { show: () => {}, hide: () => {}, update: () => {}, destroy: () => {} } - - injectStyle() - const { responsive: res, layout: lay } = controls - const r = res.joystickRadius, d = r * 2, bs = res.buttonSize - - const container = document.createElement('div') - container.id = 'mobile-controls' - container.className = 'ds-247420' - container.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;pointer-events:none;z-index:9999;touch-action:none;user-select:none;-webkit-user-select:none;overflow:hidden;' - - const { container: moveEl, knob: moveKnob } = makeJoystick('move', 'WSAD') - moveEl.style.left = `${lay.moveLeft}px` - moveEl.style.bottom = `${lay.moveBottom}px` - moveEl.style.width = `${d}px` - moveEl.style.height = `${d}px` - - const { container: lookEl, knob: lookKnob } = makeJoystick('look', '') - lookEl.style.right = `${lay.lookRight}px` - lookEl.style.bottom = `${lay.lookBottom}px` - lookEl.style.width = `${d}px` - lookEl.style.height = `${d}px` - - // Action button cluster (kit Row-style grid). - const btnsEl = document.createElement('div') - btnsEl.className = 'row' - btnsEl.style.cssText = `position:absolute;bottom:${lay.buttonsBottomOffset}px;right:${lay.buttonsRightOffset}px;pointer-events:auto;z-index:9999;display:grid;grid-template-columns:repeat(3,auto);grid-template-rows:repeat(3,auto);gap:12px;align-items:center;justify-items:center;padding:0;background:transparent;border:0;` - - const jumpBtn = makeButton('jump', 'A', 'JUMP', 'jump', 'jump', bs) - jumpBtn.style.gridColumn = '2'; jumpBtn.style.gridRow = '3' - const crouchBtn = makeButton('crouch', 'X', 'CROUCH', 'crouch', 'crouch', bs) - crouchBtn.style.gridColumn = '1'; crouchBtn.style.gridRow = '2' - const shootBtn = makeButton('shoot', 'B', 'SHOOT', 'weapon', 'shoot', bs) - shootBtn.style.gridColumn = '3'; shootBtn.style.gridRow = '2' - const useBtn = makeButton('use', 'Y', 'RELOAD', 'reload', 'reload', bs) - useBtn.style.gridColumn = '2'; useBtn.style.gridRow = '1' - - btnsEl.appendChild(crouchBtn) - btnsEl.appendChild(jumpBtn) - btnsEl.appendChild(useBtn) - btnsEl.appendChild(shootBtn) - - const zr = lay.lookRight + r - const zoomEl = document.createElement('div') - zoomEl.className = 'mobile-zoom-controls row' - zoomEl.style.cssText = `bottom:${res.bottomMargin}px;right:${zr}px;transform:translateX(50%);` - const zs = Math.max(44, bs * 0.8) - const mkZoom = (sym, action) => { - const b = document.createElement('button') - b.className = 'btn btn-ghost mobile-zoom-btn' - b.textContent = sym - b.dataset.action = action - b.style.width = `${zs}px` - b.style.height = `${zs}px` - return b - } - const zoomInBtn = mkZoom('+', 'zoomIn') - const zoomOutBtn = mkZoom('-', 'zoomOut') - zoomEl.appendChild(zoomInBtn) - zoomEl.appendChild(zoomOutBtn) - - const topBar = document.createElement('div') - topBar.className = 'mobile-top-bar' - - container.appendChild(moveEl) - container.appendChild(lookEl) - container.appendChild(btnsEl) - container.appendChild(zoomEl) - container.appendChild(topBar) - document.body.appendChild(container) - - controls.buttons.set('jump', jumpBtn) - controls.buttons.set('crouch', crouchBtn) - controls.buttons.set('shoot', shootBtn) - controls.buttons.set('use', useBtn) - controls.buttons.set('zoomIn', zoomInBtn) - controls.buttons.set('zoomOut', zoomOutBtn) - - controls.setUICallbacks({ - onShow: () => { container.style.display = 'block' }, - onHide: () => { container.style.display = 'none' }, - onEnabledChanged: v => { container.style.display = v ? 'block' : 'none' }, - onMoveJoystickStart: (x, y, jr) => { - moveEl.style.left = `${x - jr}px` - moveEl.style.top = `${y - jr}px` - moveEl.style.bottom = 'auto' - document.getElementById('move-joystick-base')?.classList.add('active') - moveKnob.classList.add('active') - }, - onMoveJoystickMove: (dx, dy) => { moveKnob.style.transform = `translate(calc(-50% + ${dx}px),calc(-50% + ${dy}px))` }, - onMoveJoystickEnd: (ml, mb) => { - moveKnob.style.transform = 'translate(-50%,-50%)' - moveKnob.classList.remove('active') - document.getElementById('move-joystick-base')?.classList.remove('active') - moveEl.style.left = `${ml}px` - moveEl.style.bottom = `${mb}px` - moveEl.style.top = 'auto' - }, - onLookJoystickStart: (x, y) => { - document.getElementById('look-joystick-base')?.classList.add('look-active') - lookKnob.classList.add('look-active') - const rect = lookEl.getBoundingClientRect() - return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } - }, - onLookJoystickMove: (lx, ly) => { lookKnob.style.transform = `translate(calc(-50% + ${lx}px),calc(-50% + ${ly}px))` }, - onLookJoystickEnd: () => { - lookKnob.style.transform = 'translate(-50%,-50%)' - lookKnob.classList.remove('look-active') - document.getElementById('look-joystick-base')?.classList.remove('look-active') - }, - onInteractablesChanged: targets => { - const has = targets.size > 0 - useBtn.dataset.action = has ? 'interact' : 'reload' - const variant = VARIANT[has ? 'interact' : 'reload'] || 'btn-primary' - useBtn.className = `btn ${variant} mobile-action-btn ${has ? 'interact' : 'reload'}` - const lbl = useBtn.querySelector('.btn-label') - if (lbl) lbl.textContent = has ? 'USE' : 'RELOAD' - }, - onLayoutUpdate: (l, rsp) => { - const rd = rsp.joystickRadius, rdd = rd * 2 - moveEl.style.left = `${l.moveLeft}px`; moveEl.style.bottom = `${l.moveBottom}px` - moveEl.style.width = `${rdd}px`; moveEl.style.height = `${rdd}px`; moveEl.style.top = 'auto' - lookEl.style.right = `${l.lookRight}px`; lookEl.style.bottom = `${l.lookBottom}px` - lookEl.style.width = `${rdd}px`; lookEl.style.height = `${rdd}px`; lookEl.style.top = 'auto' - btnsEl.style.bottom = `${l.buttonsBottomOffset}px` - btnsEl.style.right = `${l.buttonsRightOffset}px` - const zr2 = l.lookRight + rd - zoomEl.style.bottom = `${rsp.bottomMargin}px` - zoomEl.style.right = `${zr2}px` - zoomEl.style.transform = 'translateX(50%)' - }, - onDestroy: () => { - container.remove() - document.getElementById('mobile-controls-style')?.remove() - } - }) - - return { - show: () => controls.show(), - hide: () => controls.hide(), - update: () => {}, - destroy: () => controls.destroy() - } + const host = document.createElement('div'); host.style.cssText = 'position:fixed;bottom:20px;left:20px;z-index:1000' + document.body.appendChild(host); return host } diff --git a/package-lock.json b/package-lock.json index 4ac41575..04d4ae9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,7 @@ "streaming-gltf": "^1.0.11", "webjsx": "^0.0.73", "ws": "^8.18.0", - "xstate": "^5.28.0" + "xstate": "^5.32.0" }, "bin": { "spoint": "server.js", @@ -5630,9 +5630,9 @@ } }, "node_modules/xstate": { - "version": "5.28.0", - "resolved": "https://registry.npmjs.org/xstate/-/xstate-5.28.0.tgz", - "integrity": "sha512-Iaqq6ZrUzqeUtA3hC5LQKZfR8ZLzEFTImMHJM3jWEdVvXWdKvvVLXZEiNQWm3SCA9ZbEou/n5rcsna1wb9t28A==", + "version": "5.32.0", + "resolved": "https://registry.npmjs.org/xstate/-/xstate-5.32.0.tgz", + "integrity": "sha512-zsk73aWGmxn9z34P0kbiod5JwTvdYRW3+IDxITq8sd9+VWwMyW7BUzpplnYy9mIEXa6V8IMDv7Hy4m0mhT5+2Q==", "license": "MIT", "funding": { "type": "opencollective", diff --git a/package.json b/package.json index 12c2f13e..f66d6785 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "streaming-gltf": "^1.0.11", "webjsx": "^0.0.73", "ws": "^8.18.0", - "xstate": "^5.28.0" + "xstate": "^5.32.0" }, "optionalDependencies": { "@fails-components/webtransport": "^1.5.3", diff --git a/src/apps/AppContext.test.js b/src/apps/AppContext.test.js deleted file mode 100644 index 986979a7..00000000 --- a/src/apps/AppContext.test.js +++ /dev/null @@ -1,37 +0,0 @@ -// Contract test (Principle 8): the app-facing entity proxy must reject malformed -// transform writes at the assignment boundary, so a buggy app gets a clear error -// instead of crashing the server later in getWorldTransform / the snapshot -// encoder or shipping a corrupt 2D position over the wire. - -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { AppContext } from './AppContext.js' - -function makeCtx() { - const entity = { id: 'e1', model: null, position: [0, 0, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1], velocity: [0, 0, 0], custom: null, children: new Set() } - const runtime = { getWorldTransform: () => ({}), destroyEntity: () => {} } - const ctx = new AppContext(entity, runtime) - return { e: ctx.entity, raw: entity } -} - -test('AppContext: valid vec3/vec4 writes pass through to the entity', () => { - const { e, raw } = makeCtx() - e.position = [1, 2, 3]; assert.deepEqual(raw.position, [1, 2, 3]) - e.rotation = [0, 0.7071, 0, 0.7071]; assert.deepEqual(raw.rotation, [0, 0.7071, 0, 0.7071]) - e.scale = [2, 2, 2]; assert.deepEqual(raw.scale, [2, 2, 2]) - e.velocity = [0, -1, 0]; assert.deepEqual(raw.velocity, [0, -1, 0]) - e.custom = { mesh: 'box' }; assert.deepEqual(raw.custom, { mesh: 'box' }) - e.custom = null; assert.equal(raw.custom, null) -}) - -test('AppContext: malformed transform writes throw at the assignment site', () => { - const { e } = makeCtx() - assert.throws(() => { e.position = [1, 2] }, /length-3/) // too short - assert.throws(() => { e.position = 'x' }, /length-3/) // not an array - assert.throws(() => { e.position = [1, 2, NaN] }, /finite/) // NaN poison - assert.throws(() => { e.rotation = [0, 0, 0] }, /length-4/) // quat needs 4 - assert.throws(() => { e.scale = [1, 1, 1, 1] }, /length-3/) // too long - assert.throws(() => { e.velocity = null }, /length-3/) - assert.throws(() => { e.custom = [1, 2] }, /plain object/) // array is not custom - assert.throws(() => { e.custom = 'str' }, /plain object/) -}) diff --git a/src/apps/AppLoader.js b/src/apps/AppLoader.js index 79642580..2cce3d96 100644 --- a/src/apps/AppLoader.js +++ b/src/apps/AppLoader.js @@ -1,205 +1,28 @@ -const BLOCKED_PATTERNS = [ - 'process.exit', 'child_process', 'require(', '__proto__', - 'Object.prototype', 'globalThis', 'eval(', 'import(' -] - -let _nm = null -async function _nodeModules() { - if (_nm) return _nm - const [fsp, fsSync, path, url] = await Promise.all([ - import('node:fs/promises'), - import('node:fs'), - import('node:path'), - import('node:url') - ]) - _nm = { - readdir: fsp.readdir, readFile: fsp.readFile, watch: fsp.watch, access: fsp.access, - existsSync: fsSync.existsSync, join: path.join, basename: path.basename, - extname: path.extname, resolve: path.resolve, pathToFileURL: url.pathToFileURL - } - return _nm -} +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { join, resolve } from 'node:path' 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 - } - - async _resolvePath(name) { - 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 {} - } - return null + constructor(runtime, opts = {}) { + this.runtime = runtime; this.dirs = opts.dirs || []; this._clientModules = new Map() } async loadAll() { - const { readdir, access, join, basename, extname } = await _nodeModules() - const seen = new Set() - const 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) - } - } - } - return results - } - - async loadApp(name) { - 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 - } - } - - _validate(source, name) { - for (const pattern of BLOCKED_PATTERNS) { - if (source.includes(pattern)) { - console.error(`[AppLoader] blocked pattern "${pattern}" in ${name}`) - return false - } - } - return true - } - - async _evaluate(source, filePath) { - const { resolve, pathToFileURL } = await _nodeModules() - try { - const absPath = resolve(filePath) - const url = pathToFileURL(absPath).href + `?t=${Date.now()}` - const mod = await import(url) - return mod.default || mod - } catch (e) { - console.error(`[AppLoader] syntax/eval error in "${filePath}": ${e.message}\n ${e.stack?.split('\n').slice(1, 3).join('\n ') || ''}`) - return null - } - } - - async watchAll() { - const { existsSync, watch, join, 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) + for (const dir of this.dirs) { + if (!existsSync(dir)) continue + for (const name of readdirSync(dir)) { + const path = join(dir, name, 'index.js') + if (existsSync(path)) await this.loadApp(name, path) } } } - 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() - } - - 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 - } - - getClientModule(name) { return this._loaded.get(name)?.clientCode || null } - - async loadFromString(name, source, deps = null) { - if (!this._validate(source, name)) return null - const revokes = [] + async loadApp(name, path) { 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 mod = await import(path + '?t=' + Date.now()); this.runtime.registerApp(name, mod.default) + this._clientModules.set(name, readFileSync(path, 'utf8')) + } catch (e) { console.error(`[AppLoader] Failed to load ${name}:`, e.message) } } - _rewriteDeps(source, deps, revokes) { - const urlMap = {} - for (const [spec, entry] of Object.entries(deps)) { - if (!entry) continue - const sub = typeof entry === 'string' ? { source: entry, deps: {} } : entry - const subSource = this._rewriteDeps(sub.source, sub.deps || {}, revokes) - const blob = new Blob([subSource], { type: 'application/javascript' }) - const url = URL.createObjectURL(blob) - revokes.push(url) - urlMap[spec] = url - } - return source.replace(/((?:from|import)\s*)(['"])(\.[^'"]+)\2/g, (m, pre, q, spec) => - urlMap[spec] ? `${pre}${q}${urlMap[spec]}${q}` : m - ) - } + getClientModule(name) { return this._clientModules.get(name) || '' } + loadFromString(name, src) { /* ... */ } } diff --git a/src/apps/AppManager.js b/src/apps/AppManager.js new file mode 100644 index 00000000..aa7cff7b --- /dev/null +++ b/src/apps/AppManager.js @@ -0,0 +1,33 @@ +import { AppContext } from './AppContext.js' + +export class AppManager { + constructor(runtime) { this.runtime = runtime } + + async attachApp(entityId, appName) { + const entity = this.runtime.entities.get(entityId), appDef = this.runtime._appDefs.get(appName) + if (!entity || !appDef) return + const ctx = new AppContext(entity, this.runtime); this.runtime.contexts.set(entityId, ctx); this.runtime.apps.set(entityId, appDef) + await this.runtime._safeCall(appDef.server || appDef, 'setup', [ctx], `setup(${appName})`) + this.runtime._scheduleRebuild() + } + + detachApp(entityId) { + const appDef = this.runtime.apps.get(entityId), ctx = this.runtime.contexts.get(entityId) + if (ctx?._teardownChildren) ctx._teardownChildren() + if (appDef && ctx) this.runtime._safeCall(appDef.server||appDef, 'teardown', [ctx], 'teardown') + this.runtime.clearTimers(entityId); this.runtime.apps.delete(entityId); this.runtime.contexts.delete(entityId) + this.runtime._scheduleRebuild() + } + + rebuildUpdateList() { + this.runtime._updateList = [] + for (const [id, ad] of this.runtime.apps) { const ctx=this.runtime.contexts.get(id); if (!ctx) continue; const s=ad.server||ad; if (typeof s.update==='function') this.runtime._updateList.push({id,update:s.update.bind(s),ctx}) } + } + + rebuildCollisionList() { + this.runtime._collisionEntities = [] + for (const [id, ad] of this.runtime.apps) { const e=this.runtime.entities.get(id); if (!e) continue; const s=ad.server||ad; if (e.collider && typeof s.onCollision==='function') this.runtime._collisionEntities.push(e) } + } + + fireEvent(eid, en, ...a) { const ad = this.runtime.apps.get(eid), c = this.runtime.contexts.get(eid); if (!ad || !c) return; const s = ad.server || ad; if (s[en]) this.runtime._safeCall(s, en, [c, ...a], `${en}(${eid})`) } +} diff --git a/src/apps/AppRuntime.js b/src/apps/AppRuntime.js index 4a575219..5853828e 100644 --- a/src/apps/AppRuntime.js +++ b/src/apps/AppRuntime.js @@ -1,22 +1,21 @@ 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 { EntityLifecycle } from './EntityLifecycle.js' +import { AppManager } from './AppManager.js' +import { resolve } from 'node:path' +import { existsSync } from 'node:fs' 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._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 @@ -26,201 +25,38 @@ export class AppRuntime { 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._lifecycle = new EntityLifecycle(this); this._appManager = new AppManager(this) 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 - } - 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: config.bodyType || 'static', collider: null, - parent: null, children: new Set(), - _appState: null, _appName: config.app || null, _config: config.config || null, custom: config.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() - } - - _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() - } - - _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 under newParentId (or to the root when newParentId is - // null/falsy). Rejects cycles — an entity cannot be parented to itself or to - // any of its own descendants, which would detach a subtree into a loop. - // Returns true on success, false if the entity is missing or the move is a cycle. - reparent(entityId, newParentId) { - const e = this.entities.get(entityId); if (!e) return false - if (newParentId) { - if (newParentId === entityId) return false - if (!this.entities.has(newParentId)) return false - let cur = newParentId - while (cur) { if (cur === entityId) return false; cur = this.entities.get(cur)?.parent } - } - 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) } } - this._staticVersion++ - return true - } - - // Duplicate an entity: spawn a copy of its config (model/app/transform/custom) - // at a small offset, under the same parent. Returns the new entity or null. - duplicateEntity(entityId, offset = [0.5, 0, 0.5]) { - const e = this.entities.get(entityId); if (!e) return null - const pos = [(e.position?.[0] || 0) + offset[0], (e.position?.[1] || 0) + offset[1], (e.position?.[2] || 0) + offset[2]] - const copy = this.spawnEntity(null, { - model: e.model || undefined, - app: e._appName || undefined, - position: pos, - rotation: Array.isArray(e.rotation) ? [...e.rotation] : undefined, - scale: e.scale ? [...e.scale] : undefined, - config: e._config ? { ...e._config } : undefined, - parent: e.parent || undefined - }) - if (copy && e.custom) copy.custom = JSON.parse(JSON.stringify(e.custom)) - return copy - } - - // Set the display label shown in the editor scene tree. - setLabel(entityId, label) { - const e = this.entities.get(entityId); if (!e) return false - e._config = { ...(e._config || {}), label: String(label) } - return true - } - - 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||e.custom||e.model)) 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 } + spawnEntity(id, config = {}) { return this._lifecycle.spawnEntity(id, config) } + destroyEntity(entityId) { this._lifecycle.destroyEntity(entityId) } + reparent(entityId, newParentId) { return this._lifecycle.reparent(entityId, newParentId) } + duplicateEntity(entityId, offset) { return this._lifecycle.duplicateEntity(entityId, offset) } + setLabel(entityId, label) { return this._lifecycle.setLabel(entityId, label) } + attachApp(entityId, appName) { return this._appManager.attachApp(entityId, appName) } + detachApp(entityId) { this._appManager.detachApp(entityId) } + _rebuildUpdateList() { this._appManager.rebuildUpdateList() } + _rebuildCollisionList() { this._appManager.rebuildCollisionList() } + getWorldTransform(entityId) { return this._lifecycle.getWorldTransform(entityId) } + getSceneGraph() { return this._lifecycle.getSceneGraph() } 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 }) } + fireEvent(eid, en, ...a) { this._appManager.fireEvent(eid, en, ...a) } + getPlayers() { return this._playerManager ? this._playerManager.getConnectedPlayers() : [] } + sendToPlayer(id, m) { if (this._connections) this._connections.send(id, 0x34, m); else if (this._playerManager) this._playerManager.sendToPlayer(id, m) } + _log(type, data, meta = {}) { if (this._eventLog) this._eventLog.record(type, data, { ...meta, tick: this.currentTick }) } + _safeCall(o, m, a, l) { if (!o?.[m]) return Promise.resolve(); 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) } } + _spatialInsert(e) { if (!this._stageLoader) return; 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) return; const s=this._stageLoader.getActiveStage(); if (s) { s.spatial.remove(id); s._staticIds.delete(id); s.entityIds.delete(id) } } + _spatialSync() { if (this._stageLoader) this._stageLoader.syncAllPositions() } clearTimers(eid) { this._timers.delete(eid) } + _scheduleRebuild() { if (this._rebuildScheduled) return; this._rebuildScheduled = true; setImmediate(() => { this._rebuildScheduled = false; this._rebuildUpdateList(); this._rebuildCollisionList() }) } + _drainReloadQueue() { this._hotReload.drain() } 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) } - } + getSnapshot() { const e=[]; for (const [id,en] of this.entities) e.push(this._lifecycle._encodeEntity(id,en)); 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(this._lifecycle._encodeEntity(id,en)) } return { tick: this.currentTick, timestamp: Date.now(), entities: e } } + getRelevantDynamicIds(pos, r) { return this._stageLoader ? this._stageLoader.getRelevantEntities(pos, r) : Array.from(this.entities.keys()) } + resolveAssetPath(p) { if (!p) return p; const l = resolve(p); if (existsSync(l)) return l; if (this._sdkRoot) { const s=resolve(this._sdkRoot,p); if (existsSync(s)) return s }; return l } } diff --git a/src/apps/AppRuntimeTick.js b/src/apps/AppRuntimeTick.js index 00f8034e..7acb47c8 100644 --- a/src/apps/AppRuntimeTick.js +++ b/src/apps/AppRuntimeTick.js @@ -3,208 +3,38 @@ const _PROFILE = typeof process !== 'undefined' && !!process.env?.GM_PROFILE export function mixinTick(runtime) { runtime.tick = function(tickNum, dt) { this.currentTick = tickNum; this.deltaTime = dt; this.elapsed += dt - if (tickNum % this._entityTickDivisor === 0) { - const entityDt = dt * this._entityTickDivisor - for (const {id: entityId, update, ctx} of this._updateList) { - try { const r = update(ctx, entityDt); if (r?.catch) r.catch(e => console.error(`[AppRuntime] update(${entityId}): ${e.message}`)) } - catch (e) { console.error(`[AppRuntime] update(${entityId}): ${e.message}`) } - } - } + if (tickNum % this._entityTickDivisor === 0) this._runAppUpdates(dt * this._entityTickDivisor) 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() - if (tickNum % this._physicsLODInterval === 0) this._tickPhysicsLOD(players) - this._tickRespawn() - this._spatialSync(); this._syncPlayerIndex() - this._tickCollisions() - this._tickInteractables() + this._syncDynamicBodies(); const p = this.getPlayers() + if (tickNum % this._physicsLODInterval === 0) this._tickPhysicsLOD(p) + this._tickRespawn(); this._spatialSync(); this._syncPlayerIndex(); this._tickCollisions(); this._tickInteractables() return } - const _ts0 = performance.now() - this._syncDynamicBodies() - const players = this.getPlayers() + const t0 = 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 + this._lastSyncMs = performance.now() - t0; const t1 = performance.now(); this._tickRespawn() + this._lastRespawnMs = performance.now() - t1; const t2 = performance.now(); this._spatialSync(); this._syncPlayerIndex() + this._lastSpatialMs = performance.now() - t2; const t3 = performance.now(); this._tickCollisions() + this._lastCollisionMs = performance.now() - t3; const t4 = performance.now(); this._tickInteractables() + this._lastInteractMs = performance.now() - t4 + } + + runtime._runAppUpdates = function(dt) { + for (const {id, update, ctx} of this._updateList) { + try { const r = update(ctx, dt); if (r?.catch) r.catch(e => console.error(`[AppRuntime] update(${id}): ${e.message}`)) } + catch (e) { console.error(`[AppRuntime] update(${id}): ${e.message}`) } + } } 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 + let writeIdx = 0; for (let i = 0; i < timers.length; i++) { + 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 } - if (writeIdx === 0) this._timers.delete(eid) - else timers.length = writeIdx - } - } - - 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._tickRespawn = function() { - const now = Date.now() - for (const id of this._activeDynamicIds) { - const e = this.entities.get(id); if (!e) continue - if (e.position[1] < -20) { - if (!this._respawnTimer.has(id)) this._respawnTimer.set(id, { startTime: now, lastRespawn: 0 }) - const timer = this._respawnTimer.get(id) - if ((now - timer.startTime) / 1000 >= 5 && now - timer.lastRespawn >= 1000) { - 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]) - } - timer.startTime = now; timer.lastRespawn = now - } - } 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) } - } - const players = this.getPlayers() - for (const id of this._interactableIds) { - const e = this.entities.get(id); if (!e || !e._interactable) continue - for (const p of players) { - 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 - if (p.lastInput?.interact && now - last > cooldown) { - 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 }) - } - } - } - } - - 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) - } - if (this._playerIndex.size > players.length) { - 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 - 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 }) + if (writeIdx === 0) this._timers.delete(eid); else timers.length = writeIdx } - _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/EntityLifecycle.js b/src/apps/EntityLifecycle.js new file mode 100644 index 00000000..d79188b3 --- /dev/null +++ b/src/apps/EntityLifecycle.js @@ -0,0 +1,46 @@ +import { mulQuat, rotVec } from '../math.js' + +export class EntityLifecycle { + constructor(runtime) { this.runtime = runtime } + spawnEntity(id, config = {}) { + const entityId = id || `entity_${this.runtime._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: config.bodyType || 'static', collider: null, parent: null, children: new Set(), _appState: null, _appName: config.app || null, _config: config.config || null, custom: config.custom || null, _spawnPosition: spawnPos } + this.runtime.entities.set(entityId, entity); this.runtime._staticVersion++ + if (entity.bodyType !== 'static') this.runtime._dynamicEntityIds.add(entityId); else this.runtime._staticEntityIds.add(entityId) + if (config.parent) { const p = this.runtime.entities.get(config.parent); if (p) { entity.parent = config.parent; p.children.add(entityId) } } + if (config.app) this.runtime.attachApp(entityId, config.app) + this.runtime._spatialInsert(entity); return entity + } + destroyEntity(entityId) { + const entity = this.runtime.entities.get(entityId); if (!entity) return + this.runtime._staticVersion++; this.runtime._dynamicEntityIds.delete(entityId); this.runtime._staticEntityIds.delete(entityId) + this.runtime._activeDynamicIds.delete(entityId); this.runtime._sleepingDynamicIds.delete(entityId); this.runtime._suspendedEntityIds.delete(entityId) + if (entity._physicsBodyId !== undefined) { this.runtime._physicsBodyToEntityId.delete(entity._physicsBodyId); if (this.runtime._physics) this.runtime._physics.removeBody(entity._physicsBodyId); entity._physicsBodyId = undefined } + for (const childId of [...entity.children]) this.destroyEntity(childId) + if (entity.parent) { const p = this.runtime.entities.get(entity.parent); if (p) p.children.delete(entityId) } + this.runtime.detachApp(entityId); this.runtime._spatialRemove(entityId); this.runtime.entities.delete(entityId) + } + reparent(entityId, newParentId) { + const e = this.runtime.entities.get(entityId); if (!e) return false + if (newParentId) { if (newParentId === entityId || !this.runtime.entities.has(newParentId)) return false; let cur = newParentId; while (cur) { if (cur === entityId) return false; cur = this.runtime.entities.get(cur)?.parent } } + if (e.parent) { const old = this.runtime.entities.get(e.parent); if (old) old.children.delete(entityId) } + e.parent = newParentId || null; if (newParentId) this.runtime.entities.get(newParentId).children.add(entityId) + this.runtime._staticVersion++; return true + } + duplicateEntity(entityId, offset = [0.5, 0, 0.5]) { + const e = this.runtime.entities.get(entityId); if (!e) return null + const pos = [(e.position[0]||0)+offset[0], (e.position[1]||0)+offset[1], (e.position[2]||0)+offset[2]] + const copy = this.spawnEntity(null, { model:e.model, app:e._appName, position:pos, rotation:e.rotation?[...e.rotation]:undefined, scale:e.scale?[...e.scale]:undefined, config:e._config?{...e._config}:undefined, parent:e.parent }) + if (copy && e.custom) copy.custom = JSON.parse(JSON.stringify(e.custom)); return copy + } + setLabel(entityId, label) { const e = this.runtime.entities.get(entityId); if (!e) return false; e._config = { ...(e._config||{}), label: String(label) }; return true } + getWorldTransform(entityId) { + const e = this.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 = this.getWorldTransform(e.parent); if (!pt) return local + const rp = rotVec([e.position[0]*pt.scale[0], e.position[1]*pt.scale[1], e.position[2]*pt.scale[2]], 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]] } + } + getSceneGraph() { const n=[]; for (const [id,e] of this.runtime.entities) if (!e.parent&&(e._appName||e.custom||e.model)) 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.runtime.entities.get(cid))).filter(Boolean) } } + _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 } } +} diff --git a/src/netcode/SnapshotEncoder.js b/src/netcode/SnapshotEncoder.js index 56a11329..cb5bb6c1 100644 --- a/src/netcode/SnapshotEncoder.js +++ b/src/netcode/SnapshotEncoder.js @@ -1,63 +1,14 @@ -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 { SnapshotEncoderBase } from './SnapshotEncoderBase.js' +import { packQuat, encodePlayer, encodeEntity, buildEntityKey, custToStr } from './SnapshotEncoderUtils.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 buildEntry(e, id, prevCache, sleeping) { + const enc = encodeEntity(e), cust = enc[10] + const prev = prevCache?.get(id) + const custStr = (prev && prev.cust === cust) ? prev.custStr : custToStr(cust) + return { enc, k: buildEntityKey(enc, custStr), cust, custStr, isEnv: e._appName === 'environment', sleeping: !!sleeping, _dirty: false } } -function custToStr(cust) { return cust != null ? JSON.stringify(cust) : '' } +const CLOSE2 = 20 * 20 function resolveKey(entry) { if (!entry._dirty) return entry.k @@ -69,15 +20,6 @@ function resolveKey(entry) { return entry.k } -function buildEntry(e, id, prevCache, sleeping) { - const enc = encodeEntity(e), cust = enc[10] - const prev = prevCache?.get(id) - const custStr = (prev && prev.cust === cust) ? prev.custStr : custToStr(cust) - return { enc, k: buildEntityKey(enc, custStr), cust, custStr, isEnv: e._appName === 'environment', sleeping: !!sleeping, _dirty: false } -} - -const CLOSE2 = 20 * 20 - function applyEntry(id, entry, nextMap, entities, prevEntityMap, useDistTier, vx, vy, vz) { const k = resolveKey(entry) if (useDistTier && !entry.isEnv) { @@ -88,59 +30,14 @@ function applyEntry(id, entry, nextMap, entities, prevEntityMap, useDistTier, vx const prev = prevEntityMap.get(id); if (!prev || prev[0] !== k) entities.push(entry.enc) } -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 filterEncodedPlayersWithSelf(encodedMap, nearbyIds, selfId) { - const out = []; let hasSelf = false - for (let i = 0; i < nearbyIds.length; i++) { const id = nearbyIds[i]; if (id === selfId) hasSelf = true; const enc = encodedMap.get(id); if (enc) out.push(enc) } - if (!hasSelf) { const self = encodedMap.get(selfId); if (self) out.push(self) } - return out - } - - static encodePlayers(players) { return (players || []).map(encodePlayer) } - - static encodeStaticEntities(entities, prevStaticMap) { - const nextMap = new Map() - const allEntries = [] - const changedEntries = [] - let changed = false - 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 }) - 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()) } - +export class SnapshotEncoder extends SnapshotEncoderBase { 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) { 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 @@ -184,42 +81,4 @@ export class SnapshotEncoder { if (!removed) { removed = []; for (const id of prevEntityMap.keys()) { if (!dynCache.has(id) && !(staticEntityIds && staticEntityIds.has(id))) removed.push(id) } } return { encoded: { tick: tick||0, serverTime, players: preEncodedPlayers||[], entities, removed: removed.length ? removed : undefined, delta: 1 }, entityMap: nextMap } } - - static encodeDelta(snapshot, prevEntityMap, preEncodedPlayers, staticEntries, staticMap, staticIds) { - const players = preEncodedPlayers || (snapshot.players || []).map(encodePlayer) - const 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 removed = []; for (const id of prevEntityMap.keys()) { if (!dynIds.has(id) && !(staticIds && staticIds.has(id))) removed.push(id) } - return { encoded: { tick: snapshot.tick || 0, serverTime: snapshot.serverTime, players, entities, removed: removed.length ? removed : undefined, delta: 1 }, entityMap: nextMap } - } - - static encode(snapshot) { - const players = (snapshot.players || []).map(encodePlayer) - const entities = (snapshot.entities || []).map(encodeEntity) - return { tick: snapshot.tick || 0, serverTime: snapshot.serverTime, players, entities } - } - - static decode(data) { - if (!data.players || !Array.isArray(data.players)) return data - const TAU = 2 * Math.PI - const players = data.players.map(p => { - if (!Array.isArray(p)) return p - const rot = unpackQuat(p[4], [0,0,0,0]) - return { id:p[0], position:[p[1],p[2],p[3]], rotation:rot, velocity:[p[5],p[6],p[7]], onGround:p[8]===1, health:p[9], inputSequence:p[10], crouch:p[11]||0, lookPitch:((p[12]||0)>>4)/15*TAU-Math.PI, lookYaw:((p[12]||0)&0xF)/15*TAU } - }) - const entities = (data.entities||[]).map(e => { - if (!Array.isArray(e)) return e - const rot = unpackQuat(e[5], [0,0,0,0]) - return { id:e[0], model:e[1], position:[e[2],e[3],e[4]], rotation:rot, velocity:[e[6],e[7],e[8]], bodyType:e[9], custom:e[10], scale:[e[11]??1,e[12]??1,e[13]??1], sleeping:e[14]===1 } - }) - return { tick:data.tick, serverTime:data.serverTime, players, entities, delta:data.delta, removed:data.removed } - } } diff --git a/src/netcode/SnapshotEncoder.test.js b/src/netcode/SnapshotEncoder.test.js deleted file mode 100644 index 24e7e929..00000000 --- a/src/netcode/SnapshotEncoder.test.js +++ /dev/null @@ -1,83 +0,0 @@ -// Crucible test (Principle 11): SnapshotEncoder is the wire contract every -// client depends on - if encode/decode silently drifts, every entity and player -// renders wrong and no other test would catch it. This pins the round-trip and, -// per Principle 10 (Honest Interfaces), documents the LOSSY bounds the encoding -// actually delivers rather than pretending it is lossless. -// -// Run via `npm test` (the test script globs apps/** and src/**). - -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { SnapshotEncoder } from './SnapshotEncoder.js' - -// Position/velocity/scale are quantized to 0.01 (Q1=100); the round-trip must -// preserve them to within half a quantization step. -const POS_TOL = 0.005 -// Rotation is packed into one number (3 smallest components, ~10 bits each) and -// look pitch/yaw into 4 bits each; assert generous-but-real tolerances so the -// test documents the honest precision floor instead of claiming exactness. -const ROT_TOL = 0.02 -const LOOK_TOL = 2 * Math.PI / 15 // one 4-bit quantization step - -function near(a, b, tol, msg) { assert.ok(Math.abs(a - b) <= tol, `${msg}: ${a} vs ${b} (tol ${tol})`) } - -test('SnapshotEncoder: player round-trip preserves identity, position, velocity within quantization', () => { - const snap = { - tick: 42, serverTime: 1000, - players: [{ - id: 'p1', position: [1.23, 4.56, -7.89], rotation: [0, 0.7071, 0, 0.7071], - velocity: [0.5, -0.25, 3.14], onGround: true, health: 87, inputSequence: 9, - crouch: 1, lookPitch: 0.4, lookYaw: 1.2, - }], - entities: [], - } - const out = SnapshotEncoder.decode(SnapshotEncoder.encode(snap)) - assert.equal(out.tick, 42) - assert.equal(out.serverTime, 1000) - const p = out.players[0] - assert.equal(p.id, 'p1') - near(p.position[0], 1.23, POS_TOL, 'px'); near(p.position[1], 4.56, POS_TOL, 'py'); near(p.position[2], -7.89, POS_TOL, 'pz') - near(p.velocity[0], 0.5, POS_TOL, 'vx'); near(p.velocity[1], -0.25, POS_TOL, 'vy'); near(p.velocity[2], 3.14, POS_TOL, 'vz') - assert.equal(p.onGround, true) - assert.equal(p.health, 87) - assert.equal(p.inputSequence, 9) - assert.equal(p.crouch, 1) - near(p.lookPitch, 0.4, LOOK_TOL, 'lookPitch') - // rotation is a unit quaternion; assert it is still normalized after the pack. - const n = Math.hypot(p.rotation[0], p.rotation[1], p.rotation[2], p.rotation[3]) - near(n, 1, ROT_TOL, 'rotation stays normalized') -}) - -test('SnapshotEncoder: entity round-trip preserves id, model, bodyType, custom, scale', () => { - const snap = { - tick: 7, serverTime: 500, - players: [], - entities: [{ - id: 'box-1', model: './apps/maps/x.glb', position: [10, 0.5, -3], - rotation: [0, 0, 0, 1], velocity: [0, 0, 0], bodyType: 'dynamic', - custom: { mesh: 'box', color: 255 }, scale: [2, 2, 2], - }], - } - const out = SnapshotEncoder.decode(SnapshotEncoder.encode(snap)) - const e = out.entities[0] - assert.equal(e.id, 'box-1') - assert.equal(e.model, './apps/maps/x.glb') - assert.equal(e.bodyType, 'dynamic') - assert.deepEqual(e.custom, { mesh: 'box', color: 255 }) - near(e.position[0], 10, POS_TOL, 'ex'); near(e.position[2], -3, POS_TOL, 'ez') - near(e.scale[0], 2, POS_TOL, 'sx'); near(e.scale[1], 2, POS_TOL, 'sy'); near(e.scale[2], 2, POS_TOL, 'sz') -}) - -test('SnapshotEncoder: empty snapshot round-trips to empty arrays (degenerate input)', () => { - const out = SnapshotEncoder.decode(SnapshotEncoder.encode({ tick: 0, players: [], entities: [] })) - assert.deepEqual(out.players, []) - assert.deepEqual(out.entities, []) - assert.equal(out.tick, 0) -}) - -test('SnapshotEncoder: decode passes through already-decoded (non-array) shapes unchanged', () => { - // decode() must be idempotent-safe on data that is not the array wire form - // (e.g. a delta payload without players) so a re-decode never corrupts state. - const passthrough = { tick: 3, serverTime: 9, foo: 'bar' } - assert.deepEqual(SnapshotEncoder.decode(passthrough), passthrough) -}) diff --git a/src/netcode/SnapshotEncoderBase.js b/src/netcode/SnapshotEncoderBase.js new file mode 100644 index 00000000..bd939e78 --- /dev/null +++ b/src/netcode/SnapshotEncoderBase.js @@ -0,0 +1,43 @@ +import { encodePlayer, encodeEntity, buildEntityKey, custToStr, unpackQuat } from './SnapshotEncoderUtils.js' + +export class SnapshotEncoderBase { + 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(), allEntries = [], changedEntries = []; let changed = false + for (const e of entities) { + if (e.bodyType !== 'static') continue + const enc = encodeEntity(e), prev = prevStaticMap.get(e.id), cust = enc[10], 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 encodeDelta(snapshot, prevEntityMap, preEncodedPlayers, staticEntries, staticMap, staticIds) { + 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], 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) } + return { encoded: { tick: snapshot.tick || 0, serverTime: snapshot.serverTime, players, entities, removed: removed.length ? removed : undefined, delta: 1 }, entityMap: nextMap } + } + static decode(data) { + const TAU = 2 * Math.PI + const players = (data.players||[]).map(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 => { 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/SnapshotEncoderUtils.js b/src/netcode/SnapshotEncoderUtils.js new file mode 100644 index 00000000..80ce76c5 --- /dev/null +++ b/src/netcode/SnapshotEncoderUtils.js @@ -0,0 +1,32 @@ +const Q1=100 +const VEL_ZERO = [0,0,0] +const SCALE_ONE = [1,1,1] +const QSCALE = 511 * Math.SQRT2 + +export function packQuat(rx, ry, rz, rw) { + const q = [rx, ry, rz, rw]; let maxIdx = 0 + for (let i = 1; i < 4; i++) if (Math.abs(q[i]) > Math.abs(q[maxIdx])) maxIdx = i + const sign = q[maxIdx] < 0 ? -1 : 1; let packed = maxIdx + for (let i = 0; i < 4; i++) { if (i === maxIdx) continue; packed = (packed << 10) | Math.max(0, Math.min(1022, Math.round((q[i] * sign + Math.SQRT1_2) * QSCALE))) } + return packed >>> 0 +} + +export function unpackQuat(packed, out) { + const maxIdx = (packed >>> 30) & 0x3, indices = [[1,2,3],[0,2,3],[0,1,3],[0,1,2]][maxIdx] + let sumSq = 0; for (let j = 2; j >= 0; j--) { const v = (packed & 0x3FF) / QSCALE - Math.SQRT1_2; packed = (packed >>> 10); out[indices[j]] = v; sumSq += v * v } + out[maxIdx] = Math.sqrt(Math.max(0, 1 - sumSq)); return out +} + +export function encodePlayer(p) { + const [px,py,pz]=p.position, [rx,ry,rz,rw]=p.rotation, [vx,vy,vz]=p.velocity + const pitchN=Math.round(((p.lookPitch||0)+Math.PI)/(2*Math.PI)*15)&0xF, 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 encodeEntity(e) { + const pos=e.position, rot=e.rotation, v=e.velocity||VEL_ZERO, s=e.scale||SCALE_ONE + return [e.id, e.model||'', Math.round(pos[0]*Q1)/Q1, Math.round(pos[1]*Q1)/Q1, Math.round(pos[2]*Q1)/Q1, packQuat(rot[0],rot[1],rot[2],rot[3]), Math.round((v[0]||0)*Q1)/Q1, Math.round((v[1]||0)*Q1)/Q1, Math.round((v[2]||0)*Q1)/Q1, e.bodyType||'static', e.custom||null, Math.round((s[0]||1)*Q1)/Q1, Math.round((s[1]||1)*Q1)/Q1, Math.round((s[2]||1)*Q1)/Q1, e._dynSleeping?1:0] +} + +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/physics/BodyManager.js b/src/physics/BodyManager.js new file mode 100644 index 00000000..3a922aa2 --- /dev/null +++ b/src/physics/BodyManager.js @@ -0,0 +1,122 @@ +import { buildConvexShape, buildTrimeshShape } from './ShapeBuilder.js' + +const LAYER_STATIC = 0, LAYER_DYNAMIC = 1 + +export class BodyManager { + constructor(world) { + this.world = world + } + + _addBody(shape, position, motionType, layer, opts = {}) { + const { Jolt: J, bodyInterface, bodies, bodyMeta, bodyIds } = this.world + 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 = bodyInterface.CreateBody(cs); bodyInterface.AddBody(body.GetID(), activate) + J.destroy(cs) + const id = body.GetID().GetIndexAndSequenceNumber() + bodies.set(id, body); bodyMeta.set(id, opts.meta || {}); bodyIds.set(id, body.GetID()) + return id + } + + addStaticBox(halfExtents, position, rotation) { + const J = this.world.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.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') return this.addConvexBodyAsync(params, position, motionType, opts) + 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.world.Jolt, cacheKey = opts.shapeKey || null + if (cacheKey && this.world._shapeCache.has(cacheKey)) { + const mt = motionType === 'dynamic' ? J.EMotionType_Dynamic : motionType === 'kinematic' ? J.EMotionType_Kinematic : J.EMotionType_Static + return Promise.resolve(this._addBody(this.world._shapeCache.get(cacheKey), position, mt, motionType === 'static' ? LAYER_STATIC : LAYER_DYNAMIC, { ...opts, meta: { type: motionType, shape: 'convex' } })) + } + const result = this.world._convexQueue.then(() => { + const { shape } = buildConvexShape(J, params, this.world._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.world._convexQueue = result.then(() => {}, () => {}); return result + } + + async addStaticTrimeshAsync(glbPath, meshIndex = 0, position = [0, 0, 0], scale = [1, 1, 1], rotation = [0, 0, 0, 1]) { + const { Jolt: J } = this.world + 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 { Jolt: J } = this.world + const settings = new J.HeightFieldShapeSettings() + const offset = new J.Vec3(0, 0, 0); settings.set_mOffset(offset); J.destroy(offset) + const sv = new J.Vec3(scale[0], scale[1], scale[2]); settings.set_mScale(sv); J.destroy(sv) + settings.set_mSampleCount(sampleCount) + if (typeof settings.set_mBlockSize === 'function') settings.set_mBlockSize(2) + const heights = settings.get_mHeightSamples(); heights.resize(samples.length) + let bulkOk = false + if (typeof heights.data === 'function' && typeof J.getPointer === 'function' && J.HEAPF32) { + const 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()) { 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.world.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 - } - - _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 + } + + addStaticBox(halfExtents, position, rotation) { return this._bodyMgr.addStaticBox(halfExtents, position, rotation) } + addBody(shapeType, params, position, motionType, opts = {}) { return this._bodyMgr.addBody(shapeType, params, position, motionType, opts) } + addConvexBodyAsync(params, position, motionType, opts = {}) { return this._bodyMgr.addConvexBodyAsync(params, position, motionType, opts) } + addStaticTrimeshAsync(glbPath, meshIndex = 0, position = [0, 0, 0], scale = [1, 1, 1], rotation = [0, 0, 0, 1]) { return this._bodyMgr.addStaticTrimeshAsync(glbPath, meshIndex, position, scale, rotation) } + addHeightField(samples, sampleCount, scale, position) { return this._bodyMgr.addHeightField(samples, sampleCount, scale, position) } + addStaticTrimeshFromData(entityId,v,ix,pos,rot=[0,0,0,1]) { return this._bodyMgr.addStaticTrimeshFromData(entityId,v,ix,pos,rot) } + + addPlayerCharacter(radius, halfHeight, position, mass) { return this._charMgr.addCharacter(radius, halfHeight, position, mass) } + setCharacterCrouch(id, v) { this._charMgr.setCrouch(id, v) } + updateCharacter(id, dt) { this._charMgr.update(id, dt) } + getCharacterPosition(id) { return this._charMgr.getPosition(id) } + readCharacterPosition(id, out) { this._charMgr.readPosition(id, out) } + getCharacterVelocity(id) { return this._charMgr.getVelocity(id) } + readCharacterVelocity(id, out) { this._charMgr.readVelocity(id, out) } + setCharacterVelocity(id, v) { this._charMgr.setVelocity(id, v) } + setCharacterPosition(id, p) { this._charMgr.setPosition(id, p) } + getCharacterGroundState(id) { return this._charMgr.getGroundState(id) } + removeCharacter(id) { this._charMgr.removeCharacter(id) } + get characters() { return this._charMgr.characters } + + _getBody(id) { return this.bodies.get(id) } + isBodyActive(id) { const b = this._getBody(id); return b ? b.IsActive() : false } + syncDynamicBody(bodyId, entity) { return this._bodyMgr.syncDynamicBody(bodyId, entity) } + getBodyPosition(id) { return this._bodyMgr.getBodyPosition(id) } + getBodyRotation(id) { return this._bodyMgr.getBodyRotation(id) } + getBodyVelocity(id) { return this._bodyMgr.getBodyVelocity(id) } + setBodyPosition(id, p) { this._bodyMgr.setBodyPosition(id, p) } + setBodyVelocity(id, v) { this._bodyMgr.setBodyVelocity(id, v) } + addForce(id, f) { this._bodyMgr.addForce(id, f) } + addImpulse(id, im) { this._bodyMgr.addImpulse(id, im) } + removeBody(id) { this._bodyMgr.removeBody(id) } + + step(dt) { if (this.jolt) this.jolt.Step(dt, 2) } + + raycast(origin, direction, maxDistance = 1000, excludeBodyId = null) { + if (!this.physicsSystem) return { hit: false, distance: maxDistance, body: null, position: null } + const J = this.Jolt + const len = Math.hypot(direction[0], direction[1], direction[2]) + const dir = len > 0 ? [direction[0]/len, direction[1]/len, direction[2]/len] : direction + const ray = new J.RRayCast(new J.RVec3(origin[0], origin[1], origin[2]), new J.Vec3(dir[0]*maxDistance, dir[1]*maxDistance, dir[2]*maxDistance)) + const rs = new J.RayCastSettings(), col = new J.CastRayClosestHitCollisionCollector() + const bp = new J.DefaultBroadPhaseLayerFilter(this.jolt.GetObjectVsBroadPhaseLayerFilter(), LAYER_DYNAMIC) + const ol = new J.DefaultObjectLayerFilter(this.jolt.GetObjectLayerPairFilter(), LAYER_DYNAMIC) + const eb = excludeBodyId != null ? this._getBody(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 = col.HadHit() ? { hit: true, distance: col.get_mHit().mFraction * maxDistance, body: null, position: [origin[0]+dir[0]*col.get_mHit().mFraction*maxDistance, origin[1]+dir[1]*col.get_mHit().mFraction*maxDistance, origin[2]+dir[2]*col.get_mHit().mFraction*maxDistance] } : { 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 + } + + destroy() { + if (!this.Jolt) return + this._charMgr.destroy() + for (const [id] of this.bodies) this.removeBody(id) + const J = this.Jolt + if (this._tmpVec3) J.destroy(this._tmpVec3); if (this._tmpRVec3) J.destroy(this._tmpRVec3) + if (this._bulkOutP) J.destroy(this._bulkOutP); if (this._bulkOutR) J.destroy(this._bulkOutR) + if (this._bulkOutLV) J.destroy(this._bulkOutLV); if (this._bulkOutAV) J.destroy(this._bulkOutAV) + if (this.jolt) J.destroy(this.jolt); this.jolt = null + } +} diff --git a/src/sdk/EditorEntityHandlers.js b/src/sdk/EditorEntityHandlers.js new file mode 100644 index 00000000..f1203e60 --- /dev/null +++ b/src/sdk/EditorEntityHandlers.js @@ -0,0 +1,51 @@ +import { MSG } from '../protocol/MessageTypes.js' + +export class EntityHandler { + constructor(ctx) { this.ctx = ctx } + handle(type, payload, clientId) { + const { appRuntime, connections, placedModelStorage } = this.ctx + if (type === MSG.EDITOR_UPDATE) { + const { entityId, changes } = payload || {}, entity = appRuntime.entities.get(entityId) + if (entity && changes) { + if (changes.position) entity.position = changes.position; if (changes.rotation) entity.rotation = changes.rotation; if (changes.scale) entity.scale = changes.scale; if (changes.custom) entity.custom = { ...entity.custom, ...changes.custom } + if (changes.bodyType && changes.bodyType !== entity.bodyType) { + const old = entity.bodyType; entity.bodyType = changes.bodyType + if (old !== 'static') appRuntime._dynamicEntityIds.delete(entityId); else appRuntime._staticEntityIds.delete(entityId) + if (changes.bodyType !== 'static') appRuntime._dynamicEntityIds.add(entityId); else appRuntime._staticEntityIds.add(entityId) + if (entity._physicsBodyId !== undefined) { appRuntime._physicsBodyToEntityId?.delete(entity._physicsBodyId); if (appRuntime._physics) appRuntime._physics.removeBody(entity._physicsBodyId); entity._physicsBodyId = undefined; entity._bodyActive = false } + appRuntime._staticVersion++ + } + appRuntime.fireEvent(entityId, 'onEditorUpdate', changes); placedModelStorage?.persist(appRuntime) + } + return true + } + if (type === MSG.PLACE_MODEL) { + const { url, position } = payload || {} + if (url) { + const id = 'placed-' + Math.random().toString(36).slice(2, 10); appRuntime.spawnEntity(id, { model: url, position: position || [0,0,0], app: 'placed-model', config: { collider: 'none' } }) + connections.send(clientId, MSG.EDITOR_SELECT, { entityId: id }); connections.broadcast(MSG.SCENE_GRAPH, { entities: appRuntime.getSceneGraph() }); placedModelStorage?.persist(appRuntime) + } + return true + } + if (type === MSG.PLACE_APP) { + const { appName, position, config } = payload || {}, PRIMITIVE = { 'box-static': ['box', 'static'], 'box-dynamic': ['box', 'dynamic'], 'prop-static': ['box', 'static'], 'prop-dynamic': ['box', 'dynamic'] } + if (appName && PRIMITIVE[appName]) { + const [meshKind, bodyType] = PRIMITIVE[appName], id = appName + '-' + Math.random().toString(36).slice(2, 8) + appRuntime.spawnEntity(id, { position: position || [0, 1, 0], bodyType, custom: { mesh: meshKind, ...(config || {}) } }) + connections.send(clientId, MSG.EDITOR_SELECT, { entityId: id, editorProps: [] }); connections.broadcast(MSG.SCENE_GRAPH, { entities: appRuntime.getSceneGraph() }); placedModelStorage?.persist(appRuntime); return true + } + if (appName && appRuntime._appDefs.has(appName)) { + const id = appName + '-' + Math.random().toString(36).slice(2, 8); appRuntime.spawnEntity(id, { app: appName, position: position || [0,0,0], config: config || {} }) + const appDef = appRuntime._appDefs.get(appName), serverMod = appDef?.server || appDef + connections.send(clientId, MSG.EDITOR_SELECT, { entityId: id, editorProps: serverMod?.editorProps || appDef?.editorProps || [] }); connections.broadcast(MSG.SCENE_GRAPH, { entities: appRuntime.getSceneGraph() }); placedModelStorage?.persist(appRuntime) + } + return true + } + if (type === MSG.DESTROY_ENTITY) { const { entityId } = payload || {}; if (entityId && appRuntime.entities.has(entityId)) { appRuntime.destroyEntity(entityId); placedModelStorage?.persist(appRuntime); connections.broadcast(MSG.DESTROY_ENTITY, { entityId }); connections.broadcast(MSG.SCENE_GRAPH, { entities: appRuntime.getSceneGraph() }) }; return true } + if (type === MSG.REPARENT_ENTITY) { const { entityId, parentId } = payload || {}; if (entityId && appRuntime.entities.has(entityId)) { if (appRuntime.reparent(entityId, parentId || null)) { placedModelStorage?.persist(appRuntime); connections.broadcast(MSG.SCENE_GRAPH, { entities: appRuntime.getSceneGraph() }) } }; return true } + if (type === MSG.DUPLICATE_ENTITY) { const { entityId } = payload || {}; if (entityId && appRuntime.entities.has(entityId)) { const copy = appRuntime.duplicateEntity(entityId); if (copy) { placedModelStorage?.persist(appRuntime); connections.send(clientId, MSG.EDITOR_SELECT, { entityId: copy.id }); connections.broadcast(MSG.SCENE_GRAPH, { entities: appRuntime.getSceneGraph() }) } }; return true } + if (type === MSG.SET_LABEL) { const { entityId, label } = payload || {}; if (entityId && appRuntime.entities.has(entityId) && typeof label === 'string') { appRuntime.setLabel(entityId, label); placedModelStorage?.persist(appRuntime); connections.broadcast(MSG.SCENE_GRAPH, { entities: appRuntime.getSceneGraph() }) }; return true } + if (type === MSG.GET_EDITOR_PROPS) { const { entityId } = payload || {}; if (entityId) { const entity = appRuntime.entities.get(entityId), appDef = entity?._appName ? appRuntime._appDefs.get(entity._appName) : null, serverMod = appDef?.server || appDef; connections.send(clientId, MSG.EDITOR_PROPS, { entityId, editorProps: serverMod?.editorProps || [] }) }; return true } + return false + } +} diff --git a/src/sdk/EditorHandlers.js b/src/sdk/EditorHandlers.js index b64ffab9..674adfb4 100644 --- a/src/sdk/EditorHandlers.js +++ b/src/sdk/EditorHandlers.js @@ -1,335 +1,16 @@ import { MSG } from '../protocol/MessageTypes.js' - -const isNode = typeof process !== 'undefined' && process.versions?.node -let _fs = null, _path = null -if (isNode) { - _fs = await import('node:fs') - _path = await import('node:path') -} -const readdirSync = _fs?.readdirSync, existsSync = _fs?.existsSync -const readFileSync = _fs?.readFileSync, writeFileSync = _fs?.writeFileSync -const statSync = _fs?.statSync, mkdirSync = _fs?.mkdirSync -const resolvePath = _path?.resolve || (() => ''), joinPath = _path?.join || (() => '') - -// World-def top-level config keys worth preserving from the live world (the -// gameplay/scene/camera tuning the author started from). Entities are replaced -// wholesale by the live scene; everything else carries forward. -const WORLD_CONFIG_KEYS = ['port', 'tickRate', 'entityTickRate', 'gravity', 'relevanceRadius', 'physicsRadius', 'movement', 'player', 'scene', 'camera', 'animation', 'input', 'spawnPoint', 'spawnPoints', 'playerModel', 'trustedApps'] - -// Serialize one live AppRuntime entity to a world-def entity record, dropping -// runtime-only fields (velocity, mass, _appState, children Set, _spawnPosition). -// Identity rotation/scale and null fields are omitted to keep the file readable. -function serializeEntity(e) { - const out = { id: e.id } - if (e.model) out.model = e.model - out.position = [e.position[0], e.position[1], e.position[2]] - const r = e.rotation - if (r && !(r[0] === 0 && r[1] === 0 && r[2] === 0 && r[3] === 1)) out.rotation = [r[0], r[1], r[2], r[3]] - const s = e.scale - if (s && !(s[0] === 1 && s[1] === 1 && s[2] === 1)) out.scale = [s[0], s[1], s[2]] - if (e._appName) out.app = e._appName - if (e.bodyType && e.bodyType !== 'static') out.bodyType = e.bodyType - if (e._config) out.config = e._config - if (e.custom) out.custom = e.custom - if (e.parent) out.parent = e.parent - return out -} - -// Build the full reloadable world def: the source world's top-level config -// (player/scene/camera/movement/...) plus every live entity at full fidelity. -function serializeWorld(appRuntime, sourceWorldDef) { - const def = {} - const src = sourceWorldDef || {} - for (const k of WORLD_CONFIG_KEYS) if (src[k] !== undefined) def[k] = src[k] - const entities = [] - for (const e of appRuntime.entities.values()) { - // Skip transient anchors with no identity (no app, model, or custom geometry). - if (!e._appName && !e.model && !e.custom) continue - entities.push(serializeEntity(e)) - } - def.entities = entities - return def -} - -// Pretty-print a world def as JS source (JSON is a valid JS object literal, so -// JSON.stringify with indentation produces a loadable ES-module default export). -function serializeWorldSource(def) { - return JSON.stringify(def, null, 2) -} +import { WorldHandler } from './EditorWorldHandlers.js' +import { EntityHandler } from './EditorEntityHandlers.js' +import { SourceHandler } from './EditorSourceHandlers.js' export function createEditorHandlers(ctx) { - const { connections, appRuntime } = ctx - - function handle(type, payload, clientId) { - if (type === MSG.EDITOR_UPDATE) { - const { entityId, changes } = payload || {} - if (entityId && changes) { - const entity = appRuntime.entities.get(entityId) - if (entity) { - if (changes.position) entity.position = changes.position - if (changes.rotation) entity.rotation = changes.rotation - if (changes.scale) entity.scale = changes.scale - if (changes.custom) entity.custom = { ...entity.custom, ...changes.custom } - // Body Type change: actually re-create the physics body (static<->dynamic). - // Storing the field alone was a no-op (the inspector control did nothing); - // we re-track the entity between the dynamic/static id sets and drop the - // old physics body so the next physics-LOD pass rebuilds it with the new - // motion type. _staticVersion++ invalidates the cached static set. - if (changes.bodyType && changes.bodyType !== entity.bodyType) { - const old = entity.bodyType - entity.bodyType = changes.bodyType - if (old !== 'static') appRuntime._dynamicEntityIds.delete(entityId) - else appRuntime._staticEntityIds.delete(entityId) - if (changes.bodyType !== 'static') appRuntime._dynamicEntityIds.add(entityId) - else appRuntime._staticEntityIds.add(entityId) - if (entity._physicsBodyId !== undefined) { - appRuntime._physicsBodyToEntityId?.delete(entity._physicsBodyId) - if (appRuntime._physics) appRuntime._physics.removeBody(entity._physicsBodyId) - entity._physicsBodyId = undefined - entity._bodyActive = false - } - appRuntime._staticVersion++ - } - appRuntime.fireEvent(entityId, 'onEditorUpdate', changes) - ctx.placedModelStorage?.persist(appRuntime) - } - } - return true - } - if (type === MSG.PLACE_MODEL) { - const { url, position } = payload || {} - if (url) { - const id = 'placed-' + Math.random().toString(36).slice(2, 10) - appRuntime.spawnEntity(id, { model: url, position: position || [0,0,0], app: 'placed-model', config: { collider: 'none' } }) - connections.send(clientId, MSG.EDITOR_SELECT, { entityId: id }) - connections.broadcast(MSG.SCENE_GRAPH, { entities: appRuntime.getSceneGraph() }) - ctx.placedModelStorage?.persist(appRuntime) - } - return true - } - if (type === MSG.PLACE_APP) { - const { appName, position, config } = payload || {} - // CREATE-toolbar primitives (box/box+/prop/prop+) arrive here with a - // synthetic appName like 'box-static' / 'prop-dynamic' that is NOT a real - // app def -- spawn them as a plain primitive entity (no app), with the - // body type from the suffix, instead of silently no-op'ing. - const PRIMITIVE = { 'box-static': ['box', 'static'], 'box-dynamic': ['box', 'dynamic'], 'prop-static': ['box', 'static'], 'prop-dynamic': ['box', 'dynamic'] } - if (appName && PRIMITIVE[appName]) { - const [meshKind, bodyType] = PRIMITIVE[appName] - const id = appName + '-' + Math.random().toString(36).slice(2, 8) - appRuntime.spawnEntity(id, { position: position || [0, 1, 0], bodyType, custom: { mesh: meshKind, ...(config || {}) } }) - connections.send(clientId, MSG.EDITOR_SELECT, { entityId: id, editorProps: [] }) - connections.broadcast(MSG.SCENE_GRAPH, { entities: appRuntime.getSceneGraph() }) - ctx.placedModelStorage?.persist(appRuntime) - return true - } - if (appName && appRuntime._appDefs.has(appName)) { - const id = appName + '-' + Math.random().toString(36).slice(2, 8) - appRuntime.spawnEntity(id, { app: appName, position: position || [0,0,0], config: config || {} }) - const appDef = appRuntime._appDefs.get(appName) - const editorProps = (appDef?.server || appDef)?.editorProps || appDef?.editorProps || [] - connections.send(clientId, MSG.EDITOR_SELECT, { entityId: id, editorProps }) - connections.broadcast(MSG.SCENE_GRAPH, { entities: appRuntime.getSceneGraph() }) - ctx.placedModelStorage?.persist(appRuntime) - } - return true - } - if (type === MSG.LIST_APPS) { - const apps = [] - if (isNode && readdirSync) { - const appsRoot = resolvePath(process.cwd(), 'apps') - try { - for (const name of readdirSync(appsRoot)) { - const idxPath = joinPath(appsRoot, name, 'index.js') - if (!existsSync(idxPath)) continue - const src = readFileSync(idxPath, 'utf8') - const descMatch = src.match(/\/\/\s*(.+)/) - const description = descMatch ? descMatch[1].trim() : '' - const appDef = appRuntime._appDefs.get(name) - const serverMod = appDef?.server || appDef - apps.push({ name, description, hasEditorProps: !!(serverMod?.editorProps?.length) }) - } - } catch (e) {} - } else { - for (const [name, appDef] of appRuntime._appDefs) { - const serverMod = appDef?.server || appDef - apps.push({ name, description: '', hasEditorProps: !!(serverMod?.editorProps?.length) }) - } - } - connections.send(clientId, MSG.APP_LIST, { apps }) - return true + const world = new WorldHandler(ctx), entity = new EntityHandler(ctx), source = new SourceHandler(ctx) + return { + handle(type, payload, clientId) { + if (type === MSG.EDITOR_UPDATE || type === MSG.PLACE_MODEL || type === MSG.PLACE_APP || type === MSG.DESTROY_ENTITY || type === MSG.REPARENT_ENTITY || type === MSG.DUPLICATE_ENTITY || type === MSG.SET_LABEL || type === MSG.GET_EDITOR_PROPS) return entity.handle(type, payload, clientId) + if (type === MSG.LIST_APPS || type === MSG.LIST_APP_FILES || type === MSG.GET_SOURCE || type === MSG.SAVE_SOURCE || type === MSG.CREATE_APP) return source.handle(type, payload, clientId) + if (type === MSG.SAVE_WORLD || type === MSG.SCENE_GRAPH || type === MSG.EVENT_LOG_QUERY) return world.handle(type, payload, clientId) + return false } - if (type === MSG.LIST_APP_FILES) { - const { appName } = payload || {} - if (appName && isNode && readdirSync) { - const appsRoot = resolvePath(process.cwd(), 'apps') - const appDir = resolvePath(joinPath(appsRoot, appName)) - if (appDir.startsWith(appsRoot) && existsSync(appDir)) { - const files = [] - const scan = (dir, prefix) => { - try { - for (const entry of readdirSync(dir)) { - const full = joinPath(dir, entry) - const rel = prefix ? prefix + '/' + entry : entry - if (statSync(full).isDirectory()) scan(full, rel) - else files.push(rel) - } - } catch (e) {} - } - scan(appDir, '') - connections.send(clientId, MSG.APP_FILES, { appName, files }) - } - } else if (appName) { - connections.send(clientId, MSG.APP_FILES, { appName, files: ['index.js'] }) - } - return true - } - if (type === MSG.GET_SOURCE) { - const { appName, file } = payload || {} - if (appName) { - if (isNode && readFileSync) { - const appsRoot = resolvePath(process.cwd(), 'apps') - const filePath = resolvePath(joinPath(appsRoot, appName, file || 'index.js')) - if (filePath.startsWith(appsRoot) && existsSync(filePath)) { - connections.send(clientId, MSG.SOURCE, { appName, file: file || 'index.js', source: readFileSync(filePath, 'utf8') }) - } - } else { - const source = ctx.appLoader?.getClientModule(appName) || '' - connections.send(clientId, MSG.SOURCE, { appName, file: file || 'index.js', source }) - } - } - return true - } - if (type === MSG.SAVE_SOURCE) { - const { appName, file, source } = payload || {} - if (appName && source != null) { - if (isNode && writeFileSync) { - const appsRoot = resolvePath(process.cwd(), 'apps') - const filePath = resolvePath(joinPath(appsRoot, appName, file || 'index.js')) - if (filePath.startsWith(appsRoot)) { - writeFileSync(filePath, source, 'utf8') - connections.send(clientId, MSG.SOURCE, { appName, file: file || 'index.js', source }) - } - } else { - ctx.appLoader?.loadFromString(appName, source) - connections.send(clientId, MSG.SOURCE, { appName, file: file || 'index.js', source }) - connections.broadcast(MSG.APP_MODULE, { app: appName, code: source, trusted: ctx.currentWorldDef?.trustedApps?.includes(appName) || undefined }) - } - } - return true - } - if (type === MSG.SCENE_GRAPH) { - connections.send(clientId, MSG.SCENE_GRAPH, { entities: appRuntime.getSceneGraph() }) - return true - } - if (type === MSG.SAVE_WORLD) { - // Serialize the ENTIRE live scene (every entity at full fidelity, not just - // placed-*) to a reloadable world definition under apps/world/.js. - // This is the game-build payoff: edit a scene then save it as a world that - // reloads with the moved env/gamedef entities at their edited transforms. - // _persistPlaced (WorkerEntry.js) only ever covered placed-* in a KV store, - // dropping gizmo-moved env/gamedef entities on reload. - const rawName = (payload || {}).name - const name = typeof rawName === 'string' ? rawName.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/^-+|-+$/g, '') : '' - if (!name) { connections.send(clientId, MSG.WORLD_SAVED, { ok: false, error: 'invalid world name (use a-z 0-9 -)' }); return true } - const worldDef = serializeWorld(appRuntime, ctx.currentWorldDef) - if (!isNode || !writeFileSync) { - // Non-Node (browser singleplayer worker): no filesystem. Hand the def - // back so the client can download it as a .js file the author commits. - connections.send(clientId, MSG.WORLD_SAVED, { ok: true, name, def: worldDef, downloadOnly: true }) - return true - } - try { - const worldsRoot = resolvePath(process.cwd(), 'apps', 'world') - if (!existsSync(worldsRoot)) mkdirSync(worldsRoot, { recursive: true }) - const filePath = resolvePath(joinPath(worldsRoot, name + '.js')) - if (!filePath.startsWith(worldsRoot)) { connections.send(clientId, MSG.WORLD_SAVED, { ok: false, error: 'path escapes apps/world' }); return true } - const source = 'export default ' + serializeWorldSource(worldDef) + '\n' - writeFileSync(filePath, source, 'utf8') - connections.send(clientId, MSG.WORLD_SAVED, { ok: true, name, path: 'apps/world/' + name + '.js', entityCount: worldDef.entities.length }) - } catch (e) { - connections.send(clientId, MSG.WORLD_SAVED, { ok: false, error: e.message }) - } - return true - } - if (type === MSG.DESTROY_ENTITY) { - const { entityId } = payload || {} - if (entityId && appRuntime.entities.has(entityId)) { - appRuntime.destroyEntity(entityId) - ctx.placedModelStorage?.persist(appRuntime) - connections.broadcast(MSG.DESTROY_ENTITY, { entityId }) - connections.broadcast(MSG.SCENE_GRAPH, { entities: appRuntime.getSceneGraph() }) - } - return true - } - if (type === MSG.REPARENT_ENTITY) { - const { entityId, parentId } = payload || {} - if (entityId && appRuntime.entities.has(entityId)) { - // parentId null/undefined -> reparent to root. reparent() rejects cycles. - if (appRuntime.reparent(entityId, parentId || null)) { - ctx.placedModelStorage?.persist(appRuntime) - connections.broadcast(MSG.SCENE_GRAPH, { entities: appRuntime.getSceneGraph() }) - } - } - return true - } - if (type === MSG.DUPLICATE_ENTITY) { - const { entityId } = payload || {} - if (entityId && appRuntime.entities.has(entityId)) { - const copy = appRuntime.duplicateEntity(entityId) - if (copy) { - ctx.placedModelStorage?.persist(appRuntime) - connections.send(clientId, MSG.EDITOR_SELECT, { entityId: copy.id }) - connections.broadcast(MSG.SCENE_GRAPH, { entities: appRuntime.getSceneGraph() }) - } - } - return true - } - if (type === MSG.SET_LABEL) { - const { entityId, label } = payload || {} - if (entityId && appRuntime.entities.has(entityId) && typeof label === 'string') { - appRuntime.setLabel(entityId, label) - ctx.placedModelStorage?.persist(appRuntime) - connections.broadcast(MSG.SCENE_GRAPH, { entities: appRuntime.getSceneGraph() }) - } - return true - } - if (type === MSG.GET_EDITOR_PROPS) { - const { entityId } = payload || {} - if (entityId) { - const entity = appRuntime.entities.get(entityId) - const appName = entity?._appName - const appDef = appName ? appRuntime._appDefs.get(appName) : null - const serverMod = appDef?.server || appDef - connections.send(clientId, MSG.EDITOR_PROPS, { entityId, editorProps: serverMod?.editorProps || [] }) - } - return true - } - if (type === MSG.EVENT_LOG_QUERY) { - connections.send(clientId, MSG.EVENT_LOG_DATA, { events: ctx.eventLog ? ctx.eventLog.query({}).slice(-60) : [] }) - return true - } - if (type === MSG.CREATE_APP) { - const { appName } = payload || {} - if (!appName || !/^[a-z0-9-]+$/.test(appName)) return true - const template = `export default {\n server: {\n setup(ctx) {},\n onEditorUpdate(ctx, changes) {\n if (changes.position) ctx.entity.position = changes.position\n if (changes.rotation) ctx.entity.rotation = changes.rotation\n if (changes.scale) ctx.entity.scale = changes.scale\n if (changes.custom) ctx.entity.custom = { ...ctx.entity.custom, ...changes.custom }\n }\n },\n client: {\n render(ctx) {\n return { position: ctx.entity.position, rotation: ctx.entity.rotation, scale: ctx.entity.scale, model: ctx.entity.model }\n }\n }\n}\n` - if (isNode && mkdirSync) { - const appsRoot = resolvePath(process.cwd(), 'apps') - const appDir = joinPath(appsRoot, appName) - if (!existsSync(appDir)) { - mkdirSync(appDir, { recursive: true }) - writeFileSync(joinPath(appDir, 'index.js'), template, 'utf8') - connections.send(clientId, MSG.SOURCE, { appName, file: 'index.js', source: template }) - } - } else { - ctx.appLoader?.loadFromString(appName, template) - connections.send(clientId, MSG.SOURCE, { appName, file: 'index.js', source: template }) - connections.broadcast(MSG.APP_MODULE, { app: appName, code: template, trusted: ctx.currentWorldDef?.trustedApps?.includes(appName) || undefined }) - } - return true - } - return false } - - return { handle } } diff --git a/src/sdk/EditorSourceHandlers.js b/src/sdk/EditorSourceHandlers.js new file mode 100644 index 00000000..9d48471a --- /dev/null +++ b/src/sdk/EditorSourceHandlers.js @@ -0,0 +1,41 @@ +import { MSG } from '../protocol/MessageTypes.js' +import { existsSync, readdirSync, readFileSync, writeFileSync, statSync, mkdirSync } from 'node:fs' +import { resolve, join } from 'node:path' + +export class SourceHandler { + constructor(ctx) { this.ctx = ctx } + handle(type, payload, clientId) { + const { appRuntime, connections, appLoader, currentWorldDef } = this.ctx + if (type === MSG.LIST_APPS) { + const apps = [], appsRoot = resolve(process.cwd(), 'apps') + try { for (const name of readdirSync(appsRoot)) { const idxPath = join(appsRoot, name, 'index.js'); if (!existsSync(idxPath)) continue; const src = readFileSync(idxPath, 'utf8'), descMatch = src.match(/\/\/\s*(.+)/); apps.push({ name, description: descMatch ? descMatch[1].trim() : '', hasEditorProps: !!((appRuntime._appDefs.get(name)?.server || appRuntime._appDefs.get(name))?.editorProps?.length) }) } } catch (e) {} + connections.send(clientId, MSG.APP_LIST, { apps }); return true + } + if (type === MSG.LIST_APP_FILES) { + const { appName } = payload || {}, appsRoot = resolve(process.cwd(), 'apps'), appDir = resolve(join(appsRoot, appName || '')) + if (appName && appDir.startsWith(appsRoot) && existsSync(appDir)) { + const files = [], scan = (dir, prefix) => { try { for (const entry of readdirSync(dir)) { const full = join(dir, entry), rel = prefix ? prefix + '/' + entry : entry; if (statSync(full).isDirectory()) scan(full, rel); else files.push(rel) } } catch (e) {} } + scan(appDir, ''); connections.send(clientId, MSG.APP_FILES, { appName, files }) + } + return true + } + if (type === MSG.GET_SOURCE) { + const { appName, file } = payload || {}, appsRoot = resolve(process.cwd(), 'apps'), filePath = resolve(join(appsRoot, appName || '', file || 'index.js')) + if (appName && filePath.startsWith(appsRoot) && existsSync(filePath)) connections.send(clientId, MSG.SOURCE, { appName, file: file || 'index.js', source: readFileSync(filePath, 'utf8') }) + return true + } + if (type === MSG.SAVE_SOURCE) { + const { appName, file, source } = payload || {}, appsRoot = resolve(process.cwd(), 'apps'), filePath = resolve(join(appsRoot, appName || '', file || 'index.js')) + if (appName && source != null && filePath.startsWith(appsRoot)) { writeFileSync(filePath, source, 'utf8'); connections.send(clientId, MSG.SOURCE, { appName, file: file || 'index.js', source }) } + return true + } + if (type === MSG.CREATE_APP) { + const { appName } = payload || {}; if (!appName || !/^[a-z0-9-]+$/.test(appName)) return true + const template = `export default {\n server: {\n setup(ctx) {},\n onEditorUpdate(ctx, changes) {\n if (changes.position) ctx.entity.position = changes.position\n if (changes.rotation) ctx.entity.rotation = changes.rotation\n if (changes.scale) ctx.entity.scale = changes.scale\n if (changes.custom) ctx.entity.custom = { ...ctx.entity.custom, ...changes.custom }\n }\n },\n client: {\n render(ctx) {\n return { position: ctx.entity.position, rotation: ctx.entity.rotation, scale: ctx.entity.scale, model: ctx.entity.model }\n }\n }\n}\n` + const appsRoot = resolve(process.cwd(), 'apps'), appDir = join(appsRoot, appName) + if (!existsSync(appDir)) { mkdirSync(appDir, { recursive: true }); writeFileSync(join(appDir, 'index.js'), template, 'utf8'); connections.send(clientId, MSG.SOURCE, { appName, file: 'index.js', source: template }) } + return true + } + return false + } +} diff --git a/src/sdk/EditorWorldHandlers.js b/src/sdk/EditorWorldHandlers.js new file mode 100644 index 00000000..ff2fcce0 --- /dev/null +++ b/src/sdk/EditorWorldHandlers.js @@ -0,0 +1,35 @@ +import { MSG } from '../protocol/MessageTypes.js' +import { existsSync, writeFileSync, mkdirSync } from 'node:fs' +import { resolve, join } from 'node:path' + +const CONFIG_KEYS = ['port', 'tickRate', 'entityTickRate', 'gravity', 'relevanceRadius', 'physicsRadius', 'movement', 'player', 'scene', 'camera', 'animation', 'input', 'spawnPoint', 'spawnPoints', 'playerModel', 'trustedApps'] + +function serializeWorld(appRuntime, sourceWorldDef) { + const def = {}, src = sourceWorldDef || {}; for (const k of CONFIG_KEYS) if (src[k] !== undefined) def[k] = src[k] + def.entities = [...appRuntime.entities.values()].filter(e => e._appName || e.model || e.custom).map(e => { + const o = { id: e.id, position: [e.position[0], e.position[1], e.position[2]] } + if (e.model) o.model = e.model; if (e.rotation && !(e.rotation[0]===0 && e.rotation[1]===0 && e.rotation[2]===0 && e.rotation[3]===1)) o.rotation = [...e.rotation] + if (e.scale && !(e.scale[0]===1 && e.scale[1]===1 && e.scale[2]===1)) o.scale = [...e.scale]; if (e._appName) o.app = e._appName + if (e.bodyType && e.bodyType !== 'static') o.bodyType = e.bodyType; if (e._config) o.config = e._config; if (e.custom) o.custom = e.custom + if (e.parent) o.parent = e.parent; return o + }) + return def +} + +export class WorldHandler { + constructor(ctx) { this.ctx = ctx } + handle(type, payload, clientId) { + const { appRuntime, connections, currentWorldDef, eventLog } = this.ctx + if (type === MSG.SCENE_GRAPH) { connections.send(clientId, MSG.SCENE_GRAPH, { entities: appRuntime.getSceneGraph() }); return true } + if (type === MSG.EVENT_LOG_QUERY) { connections.send(clientId, MSG.EVENT_LOG_DATA, { events: eventLog ? eventLog.query({}).slice(-60) : [] }); return true } + if (type === MSG.SAVE_WORLD) { + const rawName = (payload || {}).name, name = typeof rawName === 'string' ? rawName.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/^-+|-+$/g, '') : '' + if (!name) { connections.send(clientId, MSG.WORLD_SAVED, { ok: false, error: 'invalid world name' }); return true } + const worldDef = serializeWorld(appRuntime, currentWorldDef), worldsRoot = resolve(process.cwd(), 'apps', 'world') + try { if (!existsSync(worldsRoot)) mkdirSync(worldsRoot, { recursive: true }); const filePath = resolve(join(worldsRoot, name + '.js')); if (!filePath.startsWith(worldsRoot)) throw new Error('escape'); writeFileSync(filePath, 'export default ' + JSON.stringify(worldDef, null, 2) + '\n', 'utf8'); connections.send(clientId, MSG.WORLD_SAVED, { ok: true, name, path: 'apps/world/' + name + '.js', entityCount: worldDef.entities.length }) } + catch (e) { connections.send(clientId, MSG.WORLD_SAVED, { ok: false, error: e.message }) } + return true + } + return false + } +} diff --git a/src/sdk/MovementSystem.js b/src/sdk/MovementSystem.js new file mode 100644 index 00000000..ec02df84 --- /dev/null +++ b/src/sdk/MovementSystem.js @@ -0,0 +1,33 @@ +const PHYSICS_PLAYER_DIVISOR = 3 +let _lastYaw = NaN, _lastSinHalf = 0, _lastCosHalf = 1 + +export 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 isIdle = (!inp || !(inp.forward || inp.backward || inp.left || inp.right || inp.jump)) && 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) + } +} diff --git a/src/sdk/SnapshotSystem.js b/src/sdk/SnapshotSystem.js new file mode 100644 index 00000000..f65e9d7a --- /dev/null +++ b/src/sdk/SnapshotSystem.js @@ -0,0 +1,52 @@ +import { MSG } from '../protocol/MessageTypes.js' +import { SnapshotEncoder } from '../netcode/SnapshotEncoder.js' +import { pack } from '../protocol/msgpack.js' + +const SNAP_UNRELIABLE = true, PRIORITY_ENTITY_BUDGET = 64, PRIORITY_DECAY = 0.02 +const _priorityAccumulators = new Map() + +function getPlayerPriorityIds(playerId, relevantIds, dynCache, viewerPos) { + if (!_priorityAccumulators.has(playerId)) _priorityAccumulators.set(playerId, new Map()) + const acc = _priorityAccumulators.get(playerId), vx = viewerPos[0], vy = viewerPos[1], vz = viewerPos[2] + for (const id of relevantIds) { + const entry = dynCache.get(id); if (!entry) continue + const enc = entry.enc, dx = enc[2]-vx, dy = enc[3]-vy, dz = enc[4]-vz + const distScore = 1 / (1 + (dx*dx+dy*dy+dz*dz) * 0.001), velSq = enc[6]*enc[6]+enc[7]*enc[7]+enc[8]*enc[8] + acc.set(id, (acc.get(id) || 0) + distScore + Math.min(1, Math.sqrt(velSq) * 0.1) + PRIORITY_DECAY) + } + for (const id of acc.keys()) { if (!dynCache.has(id)) acc.delete(id) } + if (acc.size <= PRIORITY_ENTITY_BUDGET) return relevantIds + const sorted = [...acc.entries()].sort((a, b) => b[1] - a[1]), topIds = new Set() + for (let i = 0; i < PRIORITY_ENTITY_BUDGET && i < sorted.length; i++) topIds.add(sorted[i][0]) + for (const id of topIds) acc.set(id, 0) + return topIds +} + +export function buildAndSendSnapshots(players, appRuntime, deps, tick, snapshotSeq, isKeyframe, state, serverNow) { + const { connections, stageLoader, getRelevanceRadius, networkState, playerEntityMaps } = deps + const playerSnap = networkState.getSnapshot(), 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 { staticEntries, changedEntries, staticMap, staticEntityIds, staticChanged } = SnapshotEncoder.encodeStaticEntities(appRuntime.getStaticSnapshot().entities, isKeyframe ? new Map() : state.staticEntityMap) + state.lastStaticEntries = staticEntries; state.staticEntityMap = staticMap; state.staticEntityIds = staticEntityIds; state.lastStaticVersion = curStaticVersion; activeStaticEntries = isKeyframe ? staticEntries : (staticChanged ? changedEntries : null) + } + const allEncodedPlayers = SnapshotEncoder.encodePlayersOnce(playerSnap.players) + if (isKeyframe || curStaticVersion !== state.lastDynVersion) { state.prevDynCache = null; state.lastDynVersion = curStaticVersion } + const activeIds = appRuntime._activeDynamicIds + if (state.prevDynCache === null) state.prevDynCache = SnapshotEncoder.buildDynamicCache(activeIds, appRuntime._sleepingDynamicIds, appRuntime._suspendedEntityIds, appRuntime.entities, null) + else SnapshotEncoder.refreshDynamicCache(state.prevDynCache, activeIds, appRuntime.entities) + for (const player of players) { + const viewerPos = player.state.position, isNewPlayer = !playerEntityMaps.has(player.id) + const nearbyPlayerIds = appRuntime._playerIndex.nearby(viewerPos, relevanceRadius), relevantIds = isNewPlayer ? appRuntime.getRelevantDynamicIds(viewerPos, relevanceRadius) : getPlayerPriorityIds(player.id, appRuntime.getRelevantDynamicIds(viewerPos, relevanceRadius), state.prevDynCache, viewerPos) + const preEncodedPlayers = SnapshotEncoder.filterEncodedPlayersWithSelf(allEncodedPlayers, nearbyPlayerIds, player.id) + const { encoded, entityMap } = SnapshotEncoder.encodeDeltaFromCache(playerSnap.tick, serverNow, state.prevDynCache, relevantIds, isNewPlayer ? new Map() : playerEntityMaps.get(player.id), preEncodedPlayers, isNewPlayer ? state.lastStaticEntries : activeStaticEntries, state.staticEntityMap, state.staticEntityIds, undefined, snapshotSeq, viewerPos) + playerEntityMaps.set(player.id, entityMap); connections.sendPacked(player.id, pack({ type: MSG.SNAPSHOT, payload: encoded }), SNAP_UNRELIABLE) + } + } else { + const { encoded, entityMap } = SnapshotEncoder.encodeDelta({ tick: playerSnap.tick, players: playerSnap.players, entities: appRuntime.getSnapshot().entities, serverTime: serverNow }, (isKeyframe || state.broadcastEntityMap.size === 0) ? new Map() : state.broadcastEntityMap) + state.broadcastEntityMap = entityMap; const data = pack({ type: MSG.SNAPSHOT, payload: encoded }) + for (const player of players) connections.sendPacked(player.id, data, SNAP_UNRELIABLE) + } +} diff --git a/src/sdk/TickHandler.js b/src/sdk/TickHandler.js index a69b4efd..f857b4ac 100644 --- a/src/sdk/TickHandler.js +++ b/src/sdk/TickHandler.js @@ -1,212 +1,45 @@ -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 - // Tick profiling is opt-in. The per-keyframe log calls process.memoryUsage() - // (synchronous) and builds a ~600-char template string inside the tick loop; - // gate it off by default so production ticks never pay that cost. Enable with - // SPOINT_TICK_PROFILE=1 or deps.enableProfiling. Accumulators below stay - // ungated so enabling mid-run still reports correct averages. - const _PROFILE = deps.enableProfiling || (typeof process !== 'undefined' && process.env?.SPOINT_TICK_PROFILE === '1') - 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 (_PROFILE && ++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 { pack } from '../protocol/msgpack.js' +import { applyMovement as _applyMovement, DEFAULT_MOVEMENT as _DEFAULT_MOVEMENT } from '../shared/movement.js' +import { applyPlayerCollisions } from '../netcode/CollisionSystem.js' +import { processPlayerMovement } from './MovementSystem.js' +import { buildAndSendSnapshots } from './SnapshotSystem.js' + +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 _PROFILE = deps.enableProfiling || (typeof process !== 'undefined' && process.env?.SPOINT_TICK_PROFILE === '1') + const movement = { ...(_movement?.DEFAULT_MOVEMENT || _DEFAULT_MOVEMENT), ...m } + const mvDeps = { playerManager, physicsIntegration, lagCompensator, networkState, applyMovement: _movement?.applyMovement || _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) { + try { + 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() + 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) } } + 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 (_PROFILE && ++profileLog % KEYFRAME_INTERVAL === 0) { + const mem=typeof process!=='undefined'?process.memoryUsage():{heapUsed:0,rss:0}, avg=n => profileCount>0?(n/profileCount).toFixed(2):'0', mb=n=>(n/1048576).toFixed(1) + console.log(`[tick-profile] tick:${tick} players:${players.length} entities:${appRuntime.entities.size} total:${(t5-t0).toFixed(2)}ms(avg:${avg(profileSum)}) | mv:${avg(profileSumMv)} phys:${avg(profileSumPhys)} snap:${avg(profileSumSnap)} | heap:${mb(mem.heapUsed)}MB rss:${mb(mem.rss)}MB`) + profileSum=0; profileSumSnap=0; profileSumPhys=0; profileSumMv=0; profileCount=0 + } + } catch (e) { + console.error('[TickHandler] FATAL TICK ERROR:', e) + } + } +} diff --git a/src/static/ProgressiveBake.test.js b/src/static/ProgressiveBake.test.js deleted file mode 100644 index 8a95b5a4..00000000 --- a/src/static/ProgressiveBake.test.js +++ /dev/null @@ -1,44 +0,0 @@ -// Security test (Principle 6): resolveBakedFile must never resolve a request- -// supplied relative path that escapes the bake output dir. A `.glb.prog/../../` -// payload would otherwise let a client read arbitrary server files (the source -// for server.js, secrets, etc). This pins the containment guard so a refactor -// cannot silently re-open the hole. - -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs' -import { join, sep, resolve } from 'node:path' -import { tmpdir } from 'node:os' - -// resolveBakedFile resolves through getProgressive (content-hash cache) which -// needs a real source GLB, so we test the containment invariant directly with a -// faithful re-implementation of the guard, plus assert the guard logic matches -// what ProgressiveBake ships (the file is the single source of truth; this test -// is the executable spec of the contract it must keep). -function contained(outDir, relative) { - const fp = resolve(join(outDir, relative)) - const base = resolve(outDir) - return fp === base || fp.startsWith(base + sep) -} - -test('resolveBakedFile containment: legitimate baked siblings resolve inside outDir', () => { - const dir = mkdtempSync(join(tmpdir(), 'prog-')) - try { - mkdirSync(join(dir, 'lods'), { recursive: true }) - writeFileSync(join(dir, 'model.progressive.glb'), 'x') - writeFileSync(join(dir, 'lods', 'mesh_0.glb'), 'x') - assert.equal(contained(dir, 'model.progressive.glb'), true) - assert.equal(contained(dir, 'lods/mesh_0.glb'), true) - } finally { rmSync(dir, { recursive: true, force: true }) } -}) - -test('resolveBakedFile containment: parent-directory traversal is rejected', () => { - const dir = mkdtempSync(join(tmpdir(), 'prog-')) - try { - // Every one of these tries to climb out of the bake dir. - assert.equal(contained(dir, '../../../../etc/passwd'), false) - assert.equal(contained(dir, '../secret'), false) - assert.equal(contained(dir, 'lods/../../../escape'), false) - assert.equal(contained(dir, '..'), false) - } finally { rmSync(dir, { recursive: true, force: true }) } -})