From 256f147568ee3bf5095895d307799e1d4fa9089b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=98=D0=9D?= Date: Sun, 31 May 2026 08:28:55 +0300 Subject: [PATCH] =?UTF-8?q?fix(engine):=20findOne(x).onTouch=20+=20findOne?= =?UTF-8?q?=20=D0=BD=D0=B0=20=D1=81=D1=82=D0=B0=D1=80=D1=82=D0=B5=20+=20?= =?UTF-8?q?=D0=BF=D0=B0=D1=80=D0=B8=D1=82=D0=B5=D1=82=20Instance-proxy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Портирование фикса из studio (фича-парность движков). Баг: стрелка-указатель game.fx.pointer не переключалась на следующую цель. - find/findOne/all раньше возвращали голую строку-ref → .onTouch невозможен. Приведены к студийному Instance-proxy (_getOrCreateInstance, coerces в строку через Symbol.toPrimitive → обратно совместимо со старым кодом). - Instance-proxy: + onTouch/onUntouch/onClick → inst.watchTouch{ref}. Worker: _instTouchHandlers + маршрут instTouch/instUntouch/instClick; _detectSnapshotDeltas для changed/destroying-событий. - GameRuntime: inst.watchTouch/watchClick → _watchedTouchRefs; routeInstEvent. - BabylonScene._detectTouchEvents: блок watched-объектов + _refToTarget; _touchState.clear() в enterPlayMode. - Первичный snapshot сцены в init (setInitialScene) → findOne на старте. Проверено на проде player.rublox.pro/333: стрелка переключается red-cube→blue-sphere→gold-chest, на финале удаляется. Co-Authored-By: Claude Opus 4.8 --- src/engine/BabylonScene.js | 49 +++++ src/engine/GameRuntime.js | 38 ++++ src/engine/ScriptSandbox.js | 6 + src/engine/ScriptSandboxWorker.js | 306 +++++++++++++++++++++++++++++- 4 files changed, 392 insertions(+), 7 deletions(-) diff --git a/src/engine/BabylonScene.js b/src/engine/BabylonScene.js index a650638..120131c 100644 --- a/src/engine/BabylonScene.js +++ b/src/engine/BabylonScene.js @@ -2874,12 +2874,59 @@ export class BabylonScene { } } + // 3) Касания объектов, на которые подписан ГЛОБАЛЬНЫЙ скрипт через + // findOne(x).onTouch(...) (rt._watchedTouchRefs). Объекты без скрипта + // и не триггеры — например цели туториала. Событие адресное (по ref). + const watched = rt._watchedTouchRefs; + if (watched && watched.size > 0) { + for (const ref of watched) { + const target = this._refToTarget(ref); + if (!target) continue; + const aabb = this._targetAABB(target); + if (!aabb) continue; + const key = 'w:' + ref; + seen.add(key); + const overlap = + px + phw > aabb.minX - EPS && px - phw < aabb.maxX + EPS && + py + phh > aabb.minY - EPS && py - phh < aabb.maxY + EPS && + pz + phd > aabb.minZ - EPS && pz - phd < aabb.maxZ + EPS; + const wasTouching = this._touchState.get(key); + if (overlap && !wasTouching) { + this._touchState.set(key, true); + rt.routeInstEvent(ref, 'instTouch', {}); + } else if (!overlap && wasTouching) { + this._touchState.set(key, false); + rt.routeInstEvent(ref, 'instUntouch', {}); + } + } + } + // Чистим устаревшие записи (удалённые скрипты/триггеры) for (const id of this._touchState.keys()) { if (!seen.has(id)) this._touchState.delete(id); } } + /** Ref-строка 'primitive:NN' | 'model:NN' → {kind,id} для _targetAABB. */ + _refToTarget(ref) { + if (typeof ref !== 'string') return null; + const colon = ref.indexOf(':'); + if (colon < 0) return null; + const kind = ref.slice(0, colon); + const rest = ref.slice(colon + 1); + if (kind === 'primitive') { + const id = this.gameRuntime?._resolvePrimitiveId + ? this.gameRuntime._resolvePrimitiveId(rest) + : (Number.isFinite(Number(rest)) ? Number(rest) : rest); + return { kind: 'primitive', id }; + } + if (kind === 'model') { + const n = Number(rest); + return { kind: 'model', id: Number.isFinite(n) ? n : rest }; + } + return null; + } + /** Получить мировой AABB target-объекта (для touch-детекции). */ _targetAABB(target) { if (!target) return null; @@ -5283,6 +5330,8 @@ export class BabylonScene { enterPlayMode() { if (this._isPlaying) return; this._isPlaying = true; + // Сброс состояния касаний — каждый прогон начинается «не касаясь». + if (this._touchState) this._touchState.clear(); // По умолчанию стандартный HUD видим в Play. // Скрипт может скрыть через game.hud.setVisible(false). this._setStdHudVisible(true); diff --git a/src/engine/GameRuntime.js b/src/engine/GameRuntime.js index c9d3bac..0af15d3 100644 --- a/src/engine/GameRuntime.js +++ b/src/engine/GameRuntime.js @@ -81,6 +81,11 @@ export class GameRuntime { modules[s.name] = s.code; } } + // Первичный snapshot сцены — собираем СИНХРОННО ДО запуска скриптов и + // передаём прямо в init. Иначе findOne() в синхронном теле скрипта + // (на старте) возвращает null → подписки obj.onTouch/find не работают. + let initialScene = null; + try { initialScene = this._buildSceneSnapshot(); } catch (e) { initialScene = null; } for (const s of scripts) { if (!s || typeof s.code !== 'string' || !s.code.trim()) { // eslint-disable-next-line no-console @@ -90,6 +95,7 @@ export class GameRuntime { const sb = new ScriptSandbox(s.code, s.target || null); sb.scriptId = s.id; sb.setModules(modules); + if (initialScene) sb.setInitialScene(initialScene); // Если target есть — передаём начальную позицию self до старта if (s.target) { const pos = this._collectSelfPosition(s.target); @@ -445,6 +451,8 @@ export class GameRuntime { this._objectData = {}; this._interactables = []; this._activeInteractRef = null; + this._watchedTouchRefs = null; + this._watchedClickRefs = null; this._roomState = {}; this._seenSessions = null; this._teams = new Map(); @@ -1135,6 +1143,16 @@ export class GameRuntime { } } + /** + * Адресное событие касания/клика КОНКРЕТНОГО объекта по его ref. + * Доставляется всем sandbox'ам как globalEvent с type='instTouch'|... + ref; + * worker матчит по ref на findOne(x).onTouch/onUntouch/onClick. + */ + routeInstEvent(ref, type, extra = {}) { + if (!ref || !type) return; + this.routeGlobalEvent(type, { ref, ...extra }); + } + /** * Уведомление: моб убит. Рассылается во все скрипты как 'mobKilled'. * Скрипт может подписаться через `game.onMobKilled(fn)`. @@ -1302,6 +1320,26 @@ export class GameRuntime { this._log(payload?.level || 'info', payload?.text || ''); return; } + // inst.watchTouch / inst.watchClick — скрипт подписался на касание/клик + // ПРОИЗВОЛЬНОГО объекта (findOne(x).onTouch/onClick). Движок начинает + // следить за AABB этого объекта в _detectTouchEvents и слать обратно + // instTouch/instUntouch (через routeInstEvent). + if (cmd === 'inst.watchTouch') { + const ref = payload && payload.ref; + if (typeof ref === 'string') { + if (!this._watchedTouchRefs) this._watchedTouchRefs = new Set(); + this._watchedTouchRefs.add(ref); + } + return; + } + if (cmd === 'inst.watchClick') { + const ref = payload && payload.ref; + if (typeof ref === 'string') { + if (!this._watchedClickRefs) this._watchedClickRefs = new Set(); + this._watchedClickRefs.add(ref); + } + return; + } if (cmd === 'player.teleport') { const player = this.scene3d?.player; if (player && player._pos && payload) { diff --git a/src/engine/ScriptSandbox.js b/src/engine/ScriptSandbox.js index e73b2c1..bcc522c 100644 --- a/src/engine/ScriptSandbox.js +++ b/src/engine/ScriptSandbox.js @@ -60,6 +60,7 @@ export class ScriptSandbox { target: this.target, selfPosition: this._initialSelfPosition || null, modules: this._modules || {}, + initialScene: this._initialScene || null, }, }); } @@ -69,6 +70,11 @@ export class ScriptSandbox { this._initialSelfPosition = p; } + /** Первичный snapshot сцены (до start) — чтобы findOne работал на старте. */ + setInitialScene(snap) { + this._initialScene = snap; + } + /** Установить код модулей для game.require — { 'имя': 'код' }. Вызывать до start(). */ setModules(modules) { this._modules = modules || {}; diff --git a/src/engine/ScriptSandboxWorker.js b/src/engine/ScriptSandboxWorker.js index e910319..1184a3d 100644 --- a/src/engine/ScriptSandboxWorker.js +++ b/src/engine/ScriptSandboxWorker.js @@ -91,6 +91,15 @@ let _selfTouchHandlers = []; let _selfUntouchHandlers = []; // Подписки self.onInteract — взаимодействие по клавише E (ProximityPrompt) let _selfInteractHandlers = []; +// Подписки на касание/клик ПРОИЗВОЛЬНОГО объекта (findOne(x).onTouch). +// ref → { touch:[fn], untouch:[fn], click:[fn] }. Движок следит за AABB этих +// объектов (cmd 'inst.watchTouch') и шлёт обратно instTouch/instUntouch/instClick. +const _instTouchHandlers = new Map(); +function _instHandlerBucket(ref) { + let b = _instTouchHandlers.get(ref); + if (!b) { b = { touch: [], untouch: [], click: [] }; _instTouchHandlers.set(ref, b); } + return b; +} // Зеркало всех GUI-элементов (синхронизируется main через cmd='guiSnapshot') let _guiIndex = []; // Задача 07: зеркало скинов (синхронизируется через 'skinsSnapshot'). @@ -269,6 +278,260 @@ let _terrainHM = null; // getData читает отсюда синхронно, setData шлёт команду в main. let _dataIndex = {}; +// ═══════════════════════════════════════════════════════════════════════ +// Instance-proxy (паритет со студией): game.scene.find/findOne/all возвращают +// Proxy с методами (onTouch/onUntouch/onClick, tween, move, changed.connect, +// position/name/parent/children и т.д.). Coerces в строку-ref через +// Symbol.toPrimitive/valueOf/toString, поэтому старый код (scene.setColor и +// т.п., принимавший строковый ref) продолжает работать без изменений. +const _instCache = new Map(); // ref → Instance proxy +const _instEvents = new Map(); // ref → { propChanged: [{prop,fn}], destroying: [fn] } +const _instLastValues = new Map(); // ref → { x, y, z, name } предыдущий snapshot + +function _safeCall2(fn, args, where) { + try { fn.apply(null, args); } + catch (err) { + _send('log', { + level: 'error', + text: 'Ошибка в ' + where + ': ' + (err && err.message ? err.message : err), + }); + } +} + +function _getOrCreateInstance(ref, kindHint) { + if (!ref || typeof ref !== 'string') return null; + if (_instCache.has(ref)) return _instCache.get(ref); + + const target = { + get ref() { return ref; }, + get kind() { + if (kindHint) return kindHint; + for (const b of _sceneIndex.blocks) if (b.ref === ref) return 'block'; + for (const m of _sceneIndex.models) if (m.ref === ref) return 'model'; + for (const p of _sceneIndex.primitives) if (p.ref === ref) return 'primitive'; + return 'unknown'; + }, + toString() { return ref; }, + }; + + const proxy = new Proxy(target, { + get(t, prop) { + if (prop === 'ref' || prop === 'kind') return t[prop]; + if (prop === 'toString') return t.toString; + if (prop === Symbol.toPrimitive) return () => ref; + if (prop === 'valueOf') return () => ref; + + // === Геттеры из snapshot === + if (prop === 'position') { + for (const arr of [_sceneIndex.blocks, _sceneIndex.models, _sceneIndex.primitives]) { + for (const o of arr) if (o.ref === ref) return { x: o.x, y: o.y, z: o.z }; + } + return null; + } + if (prop === 'name') { + for (const arr of [_sceneIndex.models, _sceneIndex.primitives]) { + for (const o of arr) if (o.ref === ref) return o.name || null; + } + return null; + } + if (prop === 'parent') { + const data = _dataIndex[ref]; + const parentRef = data && data.__parent; + return parentRef ? _getOrCreateInstance(parentRef) : null; + } + if (prop === 'children') { + const data = _dataIndex[ref]; + const ids = (data && data.__children) || []; + return ids.map(id => _getOrCreateInstance(id)).filter(Boolean); + } + if (prop === 'descendants') { + return () => { + const out = []; + const stack = [...((_dataIndex[ref] || {}).__children || [])]; + while (stack.length) { + const id = stack.pop(); + const inst = _getOrCreateInstance(id); + if (inst) { + out.push(inst); + const kids = (_dataIndex[id] || {}).__children || []; + for (const k of kids) stack.push(k); + } + } + return out; + }; + } + + // === События === + if (prop === 'changed') { + return { + connect(propName, fn) { + if (typeof fn !== 'function') return; + let evs = _instEvents.get(ref); + if (!evs) { evs = { propChanged: [], destroying: [] }; _instEvents.set(ref, evs); } + evs.propChanged.push({ prop: propName, fn }); + }, + }; + } + if (prop === 'destroying') { + return { + connect(fn) { + if (typeof fn !== 'function') return; + let evs = _instEvents.get(ref); + if (!evs) { evs = { propChanged: [], destroying: [] }; _instEvents.set(ref, evs); } + evs.destroying.push(fn); + }, + }; + } + + // === Методы === + if (prop === 'destroy' || prop === 'delete') return () => _send('scene.delete', { ref }); + if (prop === 'clone') return (offset) => _send('scene.clone', { ref, offset: offset || {} }); + if (prop === 'setAttribute') return (k, v) => _send('scene.setData', { ref, key: k, value: v }); + if (prop === 'getAttribute') return (k) => { + const data = _dataIndex[ref]; + return data ? data[k] : undefined; + }; + if (prop === 'hasTag') return (t) => { + const data = _dataIndex[ref]; + return Array.isArray(data && data.__tags) && data.__tags.includes(t); + }; + if (prop === 'addTag') return (t) => _send('scene.tag', { ref, tag: t }); + if (prop === 'removeTag') return (t) => _send('scene.untag', { ref, tag: t }); + if (prop === 'tween') return (props, opts) => game.tween(ref, props, opts); + if (prop === 'move') return (x, y, z) => _send('scene.move', { ref, x, y, z }); + if (prop === 'setLabel') return (text, o) => _send('scene.setLabel', { ref, text, opts: o || {} }); + if (prop === 'clearLabel') return () => _send('scene.clearLabel', { ref }); + + // === События касания/клика ПРОИЗВОЛЬНОГО объекта === + // findOne('coin').onTouch(fn) — fn() когда игрок коснулся объекта. + // Аналог Roblox part.Touched:Connect. Движок начинает следить за AABB + // объекта (inst.watchTouch) и шлёт instTouch на rising edge. + if (prop === 'onTouch') return (fn) => { + if (typeof fn !== 'function') return; + _instHandlerBucket(ref).touch.push(fn); + _send('inst.watchTouch', { ref }); + }; + if (prop === 'onUntouch') return (fn) => { + if (typeof fn !== 'function') return; + _instHandlerBucket(ref).untouch.push(fn); + _send('inst.watchTouch', { ref }); + }; + if (prop === 'onClick') return (fn) => { + if (typeof fn !== 'function') return; + _instHandlerBucket(ref).click.push(fn); + _send('inst.watchClick', { ref }); + }; + + return undefined; + }, + set(t, prop, value) { + if (prop === 'name') { + _send('inst.set', { ref, prop: 'name', value: String(value) }); + return true; + } + if (prop === 'parent') { + const parentRef = value && typeof value === 'object' + ? (value.ref || value.toString()) + : (value || null); + _send('inst.setParent', { ref, parentRef }); + return true; + } + if (prop === 'position') { + if (value && typeof value === 'object') { + const x = Number(value.x), y = Number(value.y), z = Number(value.z); + if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z)) { + _send('scene.move', { ref, x, y, z }); + } + } + return true; + } + if (prop === 'color') { + _send('scene.setColor', { ref, color: String(value) }); + return true; + } + if (prop === 'transparency' || prop === 'opacity') { + const v = Number(value); + if (Number.isFinite(v)) { + const op = prop === 'transparency' ? (1 - v) : v; + _send('scene.setOpacity', { ref, value: op }); + } + return true; + } + if (prop === 'visible') { + _send('scene.setVisible', { ref, visible: !!value }); + return true; + } + if (prop === 'canCollide') { + _send('scene.setCollide', { ref, collide: !!value }); + return true; + } + if (prop === 'material') { + _send('scene.setMaterial', { ref, name: String(value) }); + return true; + } + return true; + }, + }); + + _instCache.set(ref, proxy); + return proxy; +} + +/** Триггер событий изменения свойства (вызывается при дельте snapshot'а). */ +function _emitInstChange(ref, prop, newVal, oldVal) { + const evs = _instEvents.get(ref); + if (!evs) return; + for (const rec of evs.propChanged) { + if (rec.prop === prop) { + _safeCall2(rec.fn, [newVal, oldVal], 'inst.changed:' + prop); + } + } +} + +/** Триггер destroying — объект больше не в snapshot. */ +function _emitInstDestroying(ref) { + const evs = _instEvents.get(ref); + if (evs) { + for (const fn of evs.destroying) { + _safeCall2(fn, [], 'inst.destroying'); + } + } + _instEvents.delete(ref); + _instCache.delete(ref); + _instLastValues.delete(ref); +} + +/** + * Сравнение нового snapshot со старым — детект дельт для событий. + * Вызывается из обработчика 'sceneSnapshot' ПОСЛЕ обновления _sceneIndex. + */ +function _detectSnapshotDeltas() { + const live = new Set(); + for (const arr of [_sceneIndex.blocks, _sceneIndex.models, _sceneIndex.primitives]) { + for (const o of arr) { + live.add(o.ref); + if (!_instCache.has(o.ref)) continue; + const prev = _instLastValues.get(o.ref) || {}; + if (prev.x !== undefined && (prev.x !== o.x || prev.y !== o.y || prev.z !== o.z)) { + _emitInstChange(o.ref, 'position', + { x: o.x, y: o.y, z: o.z }, + { x: prev.x, y: prev.y, z: prev.z }); + } + if (prev.name !== undefined && prev.name !== o.name) { + _emitInstChange(o.ref, 'name', o.name, prev.name); + } + _instLastValues.set(o.ref, { x: o.x, y: o.y, z: o.z, name: o.name }); + } + } + for (const ref of [..._instCache.keys()]) { + if (live.has(ref)) continue; + const inst = _instCache.get(ref); + if (inst && inst.kind === 'primitive') { + _emitInstDestroying(ref); + } + } +} + // Модули (game.require). Код всех скриптов-модулей приходит при init. // _moduleCode — { 'имя': 'код модуля' } // _moduleCache — { 'имя': exports } — кеш исполненных модулей @@ -1404,15 +1667,23 @@ const game = { pivot: { x: Number(pivot.x), z: Number(pivot.z) }, }); }, - /** Найти объекты по name (для моделей/примитивов). Возвращает string[]. */ + /** + * Найти объекты по name. Возвращает массив Instance-прокси (паритет + * со студией). Instance coerces в строку-ref, поэтому код, принимавший + * строковый ref, продолжает работать. + */ find(name) { const out = []; const n = String(name || '').toLowerCase(); for (const m of _sceneIndex.models) { - if (m.name && String(m.name).toLowerCase() === n) out.push(m.ref); + if (m.name && String(m.name).toLowerCase() === n) { + out.push(_getOrCreateInstance(m.ref, 'model')); + } } for (const p of _sceneIndex.primitives) { - if (p.name && String(p.name).toLowerCase() === n) out.push(p.ref); + if (p.name && String(p.name).toLowerCase() === n) { + out.push(_getOrCreateInstance(p.ref, 'primitive')); + } } return out; }, @@ -1421,11 +1692,11 @@ const game = { const arr = this.find(name); return arr.length > 0 ? arr[0] : null; }, - /** Список ref'ов всех объектов заданного типа: 'block' | 'model' | 'primitive'. */ + /** Список Instance всех объектов заданного типа: 'block' | 'model' | 'primitive'. */ all(kind) { - if (kind === 'block') return _sceneIndex.blocks.map(b => b.ref); - if (kind === 'model') return _sceneIndex.models.map(m => m.ref); - if (kind === 'primitive') return _sceneIndex.primitives.map(p => p.ref); + if (kind === 'block') return _sceneIndex.blocks.map(b => _getOrCreateInstance(b.ref, 'block')); + if (kind === 'model') return _sceneIndex.models.map(m => _getOrCreateInstance(m.ref, 'model')); + if (kind === 'primitive') return _sceneIndex.primitives.map(p => _getOrCreateInstance(p.ref, 'primitive')); return []; }, /** @@ -3018,6 +3289,17 @@ self.onmessage = (e) => { if (payload && payload.modules && typeof payload.modules === 'object') { _moduleCode = payload.modules; } + // Первичный snapshot сцены — заполняем _sceneIndex ДО исполнения кода, + // чтобы findOne()/find() работали в синхронном теле скрипта на старте + // (иначе obj.onTouch(...) не подписывался — объект ещё «не существовал»). + if (payload && payload.initialScene && typeof payload.initialScene === 'object') { + const s = payload.initialScene; + _sceneIndex = { + blocks: s.blocks || [], + models: s.models || [], + primitives: s.primitives || [], + }; + } try { // exports передаём всегда — скрипт может быть и модулем (пишет в // exports), и обычным скриптом (игнорирует его). Без этого @@ -3166,6 +3448,13 @@ self.onmessage = (e) => { } } else if (t === 'playerTouch') { for (const fn of _globalTouchHandlers) _safeCall(fn, payload, 'onPlayerTouch'); + } else if (t === 'instTouch' || t === 'instUntouch' || t === 'instClick') { + // Касание/клик произвольного объекта (findOne(x).onTouch/onUntouch/onClick). + const b = _instTouchHandlers.get(payload && payload.ref); + if (b) { + const list = t === 'instTouch' ? b.touch : t === 'instUntouch' ? b.untouch : b.click; + for (const fn of list) _safeCall(fn, payload, 'inst.' + t); + } } else if (t === 'hpChange') { for (const fn of _hpChangeHandlers) _safeCall(fn, payload, 'onHpChange'); } else if (t === 'mobKilled') { @@ -3323,6 +3612,8 @@ self.onmessage = (e) => { models: payload.models || [], primitives: payload.primitives || [], }; + // детект дельт и эмит events для Instance (если кто-то подписан). + try { _detectSnapshotDeltas(); } catch (e) {} } } else if (cmd === 'guiSnapshot') { // payload: массив всех GUI-элементов (для game.gui.find/get/all) @@ -3385,6 +3676,7 @@ self.onmessage = (e) => { _selfTouchHandlers = []; _selfUntouchHandlers = []; _selfInteractHandlers = []; + _instTouchHandlers.clear(); _globalKeyDownHandlers = {}; _globalKeyUpHandlers = {}; _globalClickHandlers = [];