fix(engine): findOne(x).onTouch + findOne �� ������ + ������� Instance-proxy #11

Merged
min merged 1 commits from fix/pointer-ontouch-findone into main 2026-05-31 06:53:49 +00:00
4 changed files with 392 additions and 7 deletions
Showing only changes of commit 256f147568 - Show all commits

View File

@ -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()) { for (const id of this._touchState.keys()) {
if (!seen.has(id)) this._touchState.delete(id); 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-детекции). */ /** Получить мировой AABB target-объекта (для touch-детекции). */
_targetAABB(target) { _targetAABB(target) {
if (!target) return null; if (!target) return null;
@ -5283,6 +5330,8 @@ export class BabylonScene {
enterPlayMode() { enterPlayMode() {
if (this._isPlaying) return; if (this._isPlaying) return;
this._isPlaying = true; this._isPlaying = true;
// Сброс состояния касаний — каждый прогон начинается «не касаясь».
if (this._touchState) this._touchState.clear();
// По умолчанию стандартный HUD видим в Play. // По умолчанию стандартный HUD видим в Play.
// Скрипт может скрыть через game.hud.setVisible(false). // Скрипт может скрыть через game.hud.setVisible(false).
this._setStdHudVisible(true); this._setStdHudVisible(true);

View File

@ -81,6 +81,11 @@ export class GameRuntime {
modules[s.name] = s.code; 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) { for (const s of scripts) {
if (!s || typeof s.code !== 'string' || !s.code.trim()) { if (!s || typeof s.code !== 'string' || !s.code.trim()) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
@ -90,6 +95,7 @@ export class GameRuntime {
const sb = new ScriptSandbox(s.code, s.target || null); const sb = new ScriptSandbox(s.code, s.target || null);
sb.scriptId = s.id; sb.scriptId = s.id;
sb.setModules(modules); sb.setModules(modules);
if (initialScene) sb.setInitialScene(initialScene);
// Если target есть — передаём начальную позицию self до старта // Если target есть — передаём начальную позицию self до старта
if (s.target) { if (s.target) {
const pos = this._collectSelfPosition(s.target); const pos = this._collectSelfPosition(s.target);
@ -445,6 +451,8 @@ export class GameRuntime {
this._objectData = {}; this._objectData = {};
this._interactables = []; this._interactables = [];
this._activeInteractRef = null; this._activeInteractRef = null;
this._watchedTouchRefs = null;
this._watchedClickRefs = null;
this._roomState = {}; this._roomState = {};
this._seenSessions = null; this._seenSessions = null;
this._teams = new Map(); 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'. * Уведомление: моб убит. Рассылается во все скрипты как 'mobKilled'.
* Скрипт может подписаться через `game.onMobKilled(fn)`. * Скрипт может подписаться через `game.onMobKilled(fn)`.
@ -1302,6 +1320,26 @@ export class GameRuntime {
this._log(payload?.level || 'info', payload?.text || ''); this._log(payload?.level || 'info', payload?.text || '');
return; 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') { if (cmd === 'player.teleport') {
const player = this.scene3d?.player; const player = this.scene3d?.player;
if (player && player._pos && payload) { if (player && player._pos && payload) {

View File

@ -60,6 +60,7 @@ export class ScriptSandbox {
target: this.target, target: this.target,
selfPosition: this._initialSelfPosition || null, selfPosition: this._initialSelfPosition || null,
modules: this._modules || {}, modules: this._modules || {},
initialScene: this._initialScene || null,
}, },
}); });
} }
@ -69,6 +70,11 @@ export class ScriptSandbox {
this._initialSelfPosition = p; this._initialSelfPosition = p;
} }
/** Первичный snapshot сцены (до start) — чтобы findOne работал на старте. */
setInitialScene(snap) {
this._initialScene = snap;
}
/** Установить код модулей для game.require — { 'имя': 'код' }. Вызывать до start(). */ /** Установить код модулей для game.require — { 'имя': 'код' }. Вызывать до start(). */
setModules(modules) { setModules(modules) {
this._modules = modules || {}; this._modules = modules || {};

View File

@ -91,6 +91,15 @@ let _selfTouchHandlers = [];
let _selfUntouchHandlers = []; let _selfUntouchHandlers = [];
// Подписки self.onInteract — взаимодействие по клавише E (ProximityPrompt) // Подписки self.onInteract — взаимодействие по клавише E (ProximityPrompt)
let _selfInteractHandlers = []; 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') // Зеркало всех GUI-элементов (синхронизируется main через cmd='guiSnapshot')
let _guiIndex = []; let _guiIndex = [];
// Задача 07: зеркало скинов (синхронизируется через 'skinsSnapshot'). // Задача 07: зеркало скинов (синхронизируется через 'skinsSnapshot').
@ -269,6 +278,260 @@ let _terrainHM = null;
// getData читает отсюда синхронно, setData шлёт команду в main. // getData читает отсюда синхронно, setData шлёт команду в main.
let _dataIndex = {}; 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. // Модули (game.require). Код всех скриптов-модулей приходит при init.
// _moduleCode — { 'имя': 'код модуля' } // _moduleCode — { 'имя': 'код модуля' }
// _moduleCache — { 'имя': exports } — кеш исполненных модулей // _moduleCache — { 'имя': exports } — кеш исполненных модулей
@ -1404,15 +1667,23 @@ const game = {
pivot: { x: Number(pivot.x), z: Number(pivot.z) }, pivot: { x: Number(pivot.x), z: Number(pivot.z) },
}); });
}, },
/** Найти объекты по name (для моделей/примитивов). Возвращает string[]. */ /**
* Найти объекты по name. Возвращает массив Instance-прокси (паритет
* со студией). Instance coerces в строку-ref, поэтому код, принимавший
* строковый ref, продолжает работать.
*/
find(name) { find(name) {
const out = []; const out = [];
const n = String(name || '').toLowerCase(); const n = String(name || '').toLowerCase();
for (const m of _sceneIndex.models) { 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) { 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; return out;
}, },
@ -1421,11 +1692,11 @@ const game = {
const arr = this.find(name); const arr = this.find(name);
return arr.length > 0 ? arr[0] : null; return arr.length > 0 ? arr[0] : null;
}, },
/** Список ref'ов всех объектов заданного типа: 'block' | 'model' | 'primitive'. */ /** Список Instance всех объектов заданного типа: 'block' | 'model' | 'primitive'. */
all(kind) { all(kind) {
if (kind === 'block') return _sceneIndex.blocks.map(b => b.ref); if (kind === 'block') return _sceneIndex.blocks.map(b => _getOrCreateInstance(b.ref, 'block'));
if (kind === 'model') return _sceneIndex.models.map(m => m.ref); if (kind === 'model') return _sceneIndex.models.map(m => _getOrCreateInstance(m.ref, 'model'));
if (kind === 'primitive') return _sceneIndex.primitives.map(p => p.ref); if (kind === 'primitive') return _sceneIndex.primitives.map(p => _getOrCreateInstance(p.ref, 'primitive'));
return []; return [];
}, },
/** /**
@ -3018,6 +3289,17 @@ self.onmessage = (e) => {
if (payload && payload.modules && typeof payload.modules === 'object') { if (payload && payload.modules && typeof payload.modules === 'object') {
_moduleCode = payload.modules; _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 { try {
// exports передаём всегда — скрипт может быть и модулем (пишет в // exports передаём всегда — скрипт может быть и модулем (пишет в
// exports), и обычным скриптом (игнорирует его). Без этого // exports), и обычным скриптом (игнорирует его). Без этого
@ -3166,6 +3448,13 @@ self.onmessage = (e) => {
} }
} else if (t === 'playerTouch') { } else if (t === 'playerTouch') {
for (const fn of _globalTouchHandlers) _safeCall(fn, payload, 'onPlayerTouch'); 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') { } else if (t === 'hpChange') {
for (const fn of _hpChangeHandlers) _safeCall(fn, payload, 'onHpChange'); for (const fn of _hpChangeHandlers) _safeCall(fn, payload, 'onHpChange');
} else if (t === 'mobKilled') { } else if (t === 'mobKilled') {
@ -3323,6 +3612,8 @@ self.onmessage = (e) => {
models: payload.models || [], models: payload.models || [],
primitives: payload.primitives || [], primitives: payload.primitives || [],
}; };
// детект дельт и эмит events для Instance (если кто-то подписан).
try { _detectSnapshotDeltas(); } catch (e) {}
} }
} else if (cmd === 'guiSnapshot') { } else if (cmd === 'guiSnapshot') {
// payload: массив всех GUI-элементов (для game.gui.find/get/all) // payload: массив всех GUI-элементов (для game.gui.find/get/all)
@ -3385,6 +3676,7 @@ self.onmessage = (e) => {
_selfTouchHandlers = []; _selfTouchHandlers = [];
_selfUntouchHandlers = []; _selfUntouchHandlers = [];
_selfInteractHandlers = []; _selfInteractHandlers = [];
_instTouchHandlers.clear();
_globalKeyDownHandlers = {}; _globalKeyDownHandlers = {};
_globalKeyUpHandlers = {}; _globalKeyUpHandlers = {};
_globalClickHandlers = []; _globalClickHandlers = [];