fix(engine): findOne(x).onTouch + findOne �� ������ (�������-��������� �������������) #19

Merged
min merged 1 commits from fix/pointer-ontouch-findone into main 2026-05-31 06:53:47 +00:00
4 changed files with 153 additions and 0 deletions

View File

@ -2887,12 +2887,65 @@ export class BabylonScene {
} }
} }
// 3) Касания объектов, на которые подписан ГЛОБАЛЬНЫЙ скрипт через
// findOne(x).onTouch(...) (rt._watchedTouchRefs). Это объекты без
// собственного скрипта-target и не триггеры — например цели туториала.
// Ключ touchState = 'w:'+ref, событие — адресное (routeInstEvent по ref).
const watched = rt._watchedTouchRefs;
if (watched && watched.size > 0) {
for (const ref of watched) {
const target = this._refToTarget(ref);
if (!target) continue;
// Не дублируем, если на этот же объект уже висит target-скрипт
// (он обработан в блоке 1 и сам отправит touch своему скрипту —
// но адресное instTouch всё равно нужно глобальному подписчику,
// поэтому НЕ пропускаем, просто используем отдельный ключ 'w:').
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') {
// через runtime — корректно разрешает и local-ref, и числовой id
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;
@ -5258,6 +5311,9 @@ export class BabylonScene {
enterPlayMode() { enterPlayMode() {
if (this._isPlaying) return; if (this._isPlaying) return;
this._isPlaying = true; this._isPlaying = true;
// Сброс состояния касаний — каждый прогон начинается «не касаясь»,
// иначе rising-edge touch не сработает, если при стопе игрок стоял на цели.
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

@ -87,6 +87,13 @@ export class GameRuntime {
modules[s.name] = s.code; modules[s.name] = s.code;
} }
} }
// Первичный snapshot сцены — собираем СИНХРОННО ДО запуска скриптов и
// передаём прямо в init. Иначе findOne() в синхронном теле скрипта
// (на старте) возвращает null, т.к. snapshot раньше слался лишь в rAF
// ПОСЛЕ init → подписки 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
@ -96,6 +103,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);
@ -347,6 +355,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();
@ -1142,6 +1152,17 @@ export class GameRuntime {
} }
} }
/**
* Адресное событие касания/клика КОНКРЕТНОГО объекта по его ref.
* Доставляется всем sandbox'ам как globalEvent с type='instTouch'|... + ref;
* worker матчит по ref на findOne(x).onTouch/onUntouch/onClick.
* type: 'instTouch' | 'instUntouch' | 'instClick'.
*/
routeInstEvent(ref, type, extra = {}) {
if (!ref || !type) return;
this.routeGlobalEvent(type, { ref, ...extra });
}
/** /**
* Уведомление: моб убит. Рассылается во все скрипты как 'mobKilled'. * Уведомление: моб убит. Рассылается во все скрипты как 'mobKilled'.
* Скрипт может подписаться через `game.onMobKilled(fn)`. * Скрипт может подписаться через `game.onMobKilled(fn)`.
@ -2694,6 +2715,26 @@ export class GameRuntime {
return; return;
} }
// === Phase 6.2: Instance-модель === // === Phase 6.2: Instance-модель ===
// inst.watchTouch / inst.watchClick — скрипт подписался на касание/клик
// ПРОИЗВОЛЬНОГО объекта (findOne(x).onTouch/onClick). Движок начинает
// следить за AABB этого объекта в _detectTouchEvents и слать обратно
// instTouch/instUntouch (через routeInstEvent). Клик — через picking.
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;
}
// inst.set — изменить простое свойство Instance (name). // inst.set — изменить простое свойство Instance (name).
// Сложные свойства (color/visible/...) идут через scene.set* и реализованы выше. // Сложные свойства (color/visible/...) идут через scene.set* и реализованы выше.
if (cmd === 'inst.set') { if (cmd === 'inst.set') {

View File

@ -53,6 +53,8 @@ export class ScriptSandbox {
}); });
}; };
// Передаём код + target (если есть). Worker внутри сам выполнит его в new Function(game, code). // Передаём код + target (если есть). Worker внутри сам выполнит его в new Function(game, code).
// initialScene — первичный snapshot сцены, чтобы findOne() работал
// в синхронном теле скрипта на старте (до прихода sceneSnapshot в rAF).
this.worker.postMessage({ this.worker.postMessage({
cmd: 'init', cmd: 'init',
payload: { payload: {
@ -60,6 +62,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 +72,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

@ -97,6 +97,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').
@ -467,6 +476,26 @@ function _getOrCreateInstance(ref, kindHint) {
if (prop === 'setLabel') return (text, o) => _send('scene.setLabel', { ref, text, opts: o || {} }); if (prop === 'setLabel') return (text, o) => _send('scene.setLabel', { ref, text, opts: o || {} });
if (prop === 'clearLabel') return () => _send('scene.clearLabel', { ref }); 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; return undefined;
}, },
set(t, prop, value) { set(t, prop, value) {
@ -3538,6 +3567,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 {
const exportsObj = {}; const exportsObj = {};
const userFn = new Function('game', 'exports', '"use strict";\\n' + payload.code); const userFn = new Function('game', 'exports', '"use strict";\\n' + payload.code);
@ -3683,6 +3723,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') {
@ -3927,6 +3974,7 @@ self.onmessage = (e) => {
_selfTouchHandlers = []; _selfTouchHandlers = [];
_selfUntouchHandlers = []; _selfUntouchHandlers = [];
_selfInteractHandlers = []; _selfInteractHandlers = [];
_instTouchHandlers.clear();
_globalKeyDownHandlers = {}; _globalKeyDownHandlers = {};
_globalKeyUpHandlers = {}; _globalKeyUpHandlers = {};
_globalClickHandlers = []; _globalClickHandlers = [];