feat(player): синхронизация JS-API + BabylonScene._meshToTarget(npc) + GUI cmd-handlers (Фаза 3)

- ScriptSandboxWorker: добавлены отсутствующие методы game.self.* (rotate, rotateY,
  setVisible, setCollide, setColor, setLabel, clearLabel) — критично для GUI-карточек
  и интерактивных объектов сцены.
- Добавлены namespace'ы game.remote (RemoteEvent), game.tools (custom Tool.create),
  game.items.define, game.leaderstats (define/set/add/get/onChange/me-shortcut),
  game.achievements (define/unlock/has/bindToStat/setButtonVisible/openPage).
- inventory: добавлены inv2-методы (give/take/open/closeUi/toggle/sort/setActiveHotbar).
- giveTool теперь принимает Tool-объект из tools.create (поле customToolId).
- Роутинг globalEvent: добавлены leaderstatsChange, achievementUnlocked, toolEquipped,
  toolUnequipped, remoteEvent; toolUse теперь вызывает per-tool onActivated.
- tween() нормализует ref через _normRef — теперь принимает не только строку,
  но и объект из scene.spawn/find.
- BabylonScene._meshToTarget: добавлен случай md.npcId != null → kind='npc'.
- BabylonScene._handlePlayClick: в 3-м лице (без pointer-lock) клик теперь
  пикает по реальным координатам мыши, а не из центра экрана. Это чинит
  клики по GUI/3D-карточкам и интерактивным объектам в третьем лице.

Не тронуты старые worker-файлы (roblox-shim.js и т.п.) — снос будет позже.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
min 2026-06-10 00:09:49 +03:00
parent 7389dfc660
commit bbc82af819
2 changed files with 238 additions and 3 deletions

View File

@ -2828,6 +2828,7 @@ export class BabylonScene {
if (md.isBlock) { if (md.isBlock) {
return { kind: 'block', ref: { x: md.gridX, y: md.gridY, z: md.gridZ } }; return { kind: 'block', ref: { x: md.gridX, y: md.gridY, z: md.gridZ } };
} }
if (md.npcId != null) return { kind: 'npc', id: md.npcId };
if (md.isModel) return { kind: 'model', id: md.instanceId }; if (md.isModel) return { kind: 'model', id: md.instanceId };
if (md.isPrimitive) return { kind: 'primitive', id: md.primitiveId }; if (md.isPrimitive) return { kind: 'primitive', id: md.primitiveId };
return null; return null;
@ -3069,7 +3070,29 @@ export class BabylonScene {
} }
} }
const pick = this._pickFromCenter(); // В pointer-lock (1-е лицо) курсор скрыт в центре — пикаем центром.
// В 3-м лице (свободный курсор) — пикаем по реальным координатам клика.
const locked = (document.pointerLockElement === this.canvas);
let pick;
if (!locked && Number.isFinite(clickX) && Number.isFinite(clickY)) {
const pi = this.scene.pick(clickX, clickY, (mesh) => {
if (!mesh.isPickable) return false;
if (mesh.name && mesh.name.startsWith('gridLine')) return false;
return true;
});
if (pi?.hit) {
let m = pi.pickedMesh;
if (m?.metadata?._isBlockProto && this.blockManager) {
const proxy = this.blockManager.findProxyByPickInfo?.(pi);
if (proxy) m = proxy;
}
pick = { mesh: m, point: pi.pickedPoint, pickInfo: pi };
} else {
pick = null;
}
} else {
pick = this._pickFromCenter();
}
const target = pick?.mesh ? this._meshToTarget(pick.mesh) : null; const target = pick?.mesh ? this._meshToTarget(pick.mesh) : null;
const point = pick?.point ? { x: pick.point.x, y: pick.point.y, z: pick.point.z } : null; const point = pick?.point ? { x: pick.point.x, y: pick.point.y, z: pick.point.z } : null;
// 1) Self-onClick — только если target есть // 1) Self-onClick — только если target есть

View File

@ -117,6 +117,13 @@ let _unlockedSkins = [];
let _currentSkin = null; let _currentSkin = null;
let _skinChangeHandlers = []; let _skinChangeHandlers = [];
let _skinCoins = 0; // локальная валюта магазина скинов (рублики проекта) let _skinCoins = 0; // локальная валюта магазина скинов (рублики проекта)
// Phase 6.4 / задача 20: custom tools, leaderstats, achievements, remote events
let _toolSeq = 0;
let _toolCallbacks = {}; // toolId → { activated, equipped, unequipped }
let _lsMirror = {}; // playerId('@me'|sid) → { statName: value }
let _lsChangeHandlers = [];
let _achUnlocked = {}; // id → true
let _remoteHandlers = {}; // remoteName → [fn]
// Подписки game.gui.onClick(id, fn) // Подписки game.gui.onClick(id, fn)
let _guiClickHandlers = {}; let _guiClickHandlers = {};
// Подписки game.gui.onSubmit(id, fn) — ввод в TextBox завершён (Enter) // Подписки game.gui.onSubmit(id, fn) — ввод в TextBox завершён (Enter)
@ -669,6 +676,50 @@ function _buildSelfApi() {
_send('self.move', { target: _target, x: nx, y: ny, z: nz }); _send('self.move', { target: _target, x: nx, y: ny, z: nz });
} }
}, },
/**
* Повернуть объект-носитель вокруг вертикальной оси Y на угол ry (радианы).
*/
rotate(ry) {
const r = Number(ry);
if (!Number.isFinite(r)) return;
const k = _target.kind;
const id = _target.id ?? _target.ref;
_send('scene.rotate', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, rotationY: r });
},
rotateY(ry) { this.rotate(ry); },
/** Показать/скрыть объект-носитель. */
setVisible(vis) {
const k = _target.kind;
const id = _target.id ?? _target.ref;
_send('scene.setVisible', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, visible: !!vis });
},
/** Включить/выключить столкновения объекта-носителя (проходимость). */
setCollide(can) {
const k = _target.kind;
const id = _target.id ?? _target.ref;
_send('scene.setCollide', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, canCollide: !!can });
},
/** Перекрасить объект-носитель (только примитив). */
setColor(hex) {
if (typeof hex !== 'string') return;
const k = _target.kind;
const id = _target.id ?? _target.ref;
_send('scene.setColor', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, color: hex });
},
/** Повесить текст-метку над объектом-носителем (имя/HP). */
setLabel(text, opts) {
const k = _target.kind;
const id = _target.id ?? _target.ref;
const ref = (k && id != null) ? (k + ':' + id) : undefined;
_send('scene.setLabel', { ref, text: String(text == null ? '' : text), opts: opts || {} });
},
/** Убрать метку с объекта-носителя. */
clearLabel() {
const k = _target.kind;
const id = _target.id ?? _target.ref;
const ref = (k && id != null) ? (k + ':' + id) : undefined;
_send('scene.clearLabel', { ref });
},
delete() { delete() {
_send('self.delete', { target: _target }); _send('self.delete', { target: _target });
}, },
@ -1101,6 +1152,18 @@ const game = {
* game.player.giveTool('blaster-blaster-a', { equip: true }); * game.player.giveTool('blaster-blaster-a', { equip: true });
*/ */
giveTool(toolType, opts) { giveTool(toolType, opts) {
// Phase 6.4: принимаем и Tool-объект (из game.tools.create), и строку.
if (toolType && typeof toolType === 'object' && toolType.id) {
_send('inventory.give', {
kind: toolType.kind || 'tool',
modelTypeId: toolType.modelTypeId || null,
name: toolType.name,
customToolId: toolType.id,
params: {},
equip: opts?.equip === true,
});
return;
}
if (typeof toolType !== 'string' || !toolType) return; if (typeof toolType !== 'string' || !toolType) return;
opts = opts || {}; opts = opts || {};
const isBlaster = toolType.indexOf('blaster') === 0; const isBlaster = toolType.indexOf('blaster') === 0;
@ -1215,7 +1278,8 @@ const game = {
* game.tween(coin, { sy: 1.4 }, { duration: 0.5, yoyo: true, repeat: -1 }); * game.tween(coin, { sy: 1.4 }, { duration: 0.5, yoyo: true, repeat: -1 });
*/ */
tween(ref, props, opts) { tween(ref, props, opts) {
if (typeof ref !== 'string' || !props || typeof props !== 'object') return null; ref = _normRef(ref);
if (!ref || !props || typeof props !== 'object') return null;
opts = opts || {}; opts = opts || {};
const id = ++_tweenSeq; const id = ++_tweenSeq;
if (typeof opts.onDone === 'function') _tweenCallbacks[id] = opts.onDone; if (typeof opts.onDone === 'function') _tweenCallbacks[id] = opts.onDone;
@ -1326,6 +1390,32 @@ const game = {
if (!sessionId) return; if (!sessionId) return;
_send('mp.sendTo', { sessionId, name, data }); _send('mp.sendTo', { sessionId, name, data });
}, },
/**
* Phase 6.6: RemoteEvent именованные сетевые события (как в Roblox).
* const ev = game.remote.create('PlayerShoot');
* ev.fireAllClients({ x: 10, y: 5 });
* ev.on(({ from, data }) => { ... });
*/
remote: {
create(name) {
const evName = String(name || '');
return {
get name() { return evName; },
fireAllClients(data) { _send('mp.remoteFire', { name: evName, target: 'all', data }); },
fireOthers(data) { _send('mp.remoteFire', { name: evName, target: 'others', data }); },
fireClient(player, data) {
const sid = typeof player === 'string' ? player : (player && player.sessionId);
if (!sid) return;
_send('mp.remoteFire', { name: evName, target: sid, data });
},
on(fn) {
if (typeof fn !== 'function') return;
(_remoteHandlers[evName] = _remoteHandlers[evName] || []).push(fn);
},
};
},
},
/** /**
* Подписаться на изменение HP игрока (получение урона / лечение / смерть). * Подписаться на изменение HP игрока (получение урона / лечение / смерть).
* fn(event) где event = { hp, maxHp, source, damaged, delta }. * fn(event) где event = { hp, maxHp, source, damaged, delta }.
@ -2741,7 +2831,96 @@ const game = {
clear() { clear() {
_send('inventory.clear', {}); _send('inventory.clear', {});
}, },
// === Задача 44: drag-drop инвентарь (сетка 8×5 + hotbar 9 + стаки) ===
give(itemId, count) { _send('inv2.add', { itemId, count: Number(count) || 1 }); },
take(itemId, count) { _send('inv2.remove', { itemId, count: Number(count) || 1 }); },
open() { _send('inv2.open', {}); },
closeUi() { _send('inv2.close', {}); },
toggle() { _send('inv2.toggle', {}); },
sort(by) { _send('inv2.sort', { by: by || 'rarity' }); },
setActiveHotbar(i) { _send('inv2.setActive', { i: Number(i) || 0 }); },
}, },
// === Phase 6.4: пользовательские tools (как Roblox Tool) ===
tools: {
create(name, opts) {
opts = opts || {};
_toolSeq++;
const toolId = 'custom:' + _toolSeq;
_toolCallbacks[toolId] = {};
const tool = {
get id() { return toolId; },
get name() { return String(name || ('Tool ' + _toolSeq)); },
get modelTypeId() { return opts.model || null; },
get kind() { return opts.kind || 'tool'; },
onActivated(fn) { if (typeof fn === 'function') _toolCallbacks[toolId].activated = fn; },
onEquipped(fn) { if (typeof fn === 'function') _toolCallbacks[toolId].equipped = fn; },
onUnequipped(fn) { if (typeof fn === 'function') _toolCallbacks[toolId].unequipped = fn; },
dropAt(pos) {
if (!pos || typeof pos !== 'object') return;
_send('tools.drop', {
toolId, name: String(name), model: opts.model || null,
params: opts.params || {},
x: Number(pos.x) || 0, y: Number(pos.y) || 0, z: Number(pos.z) || 0,
});
},
};
return tool;
},
},
// === Определения предметов (задача 44) ===
items: {
define(def) {
if (Array.isArray(def)) { for (const d of def) _send('items.define', { def: d }); return; }
_send('items.define', { def: def || {} });
},
},
// === Лидерборды (leaderstats) — задача 20 ===
leaderstats: {
define(name, opts) {
if (typeof name !== 'string' || !name) return;
_send('leaderstats.define', { name, opts: opts || {} });
},
set(playerId, name, value) {
_send('leaderstats.set', { playerId: playerId == null ? null : String(playerId), name, value: Number(value) || 0 });
const pid = playerId == null ? '@me' : String(playerId);
if (!_lsMirror[pid]) _lsMirror[pid] = {};
_lsMirror[pid][name] = Number(value) || 0;
},
add(playerId, name, delta) {
_send('leaderstats.add', { playerId: playerId == null ? null : String(playerId), name, delta: Number(delta) || 0 });
const pid = playerId == null ? '@me' : String(playerId);
if (!_lsMirror[pid]) _lsMirror[pid] = {};
_lsMirror[pid][name] = (_lsMirror[pid][name] || 0) + (Number(delta) || 0);
},
get(playerId, name) {
const pid = playerId == null ? '@me' : String(playerId);
return (_lsMirror[pid] && _lsMirror[pid][name]) || 0;
},
onChange(fn) { if (typeof fn === 'function') _lsChangeHandlers.push(fn); },
me: {
set(name, value) { game.leaderstats.set(null, name, value); },
add(name, delta) { game.leaderstats.add(null, name, delta); },
get(name) { return game.leaderstats.get(null, name); },
},
},
// === Достижения — задача 20 ===
achievements: {
define(list) { _send('achievements.define', { list: Array.isArray(list) ? list : [list] }); },
unlock(id, playerId) {
if (typeof id !== 'string') return;
_achUnlocked[id] = true;
_send('achievements.unlock', { id, playerId: playerId == null ? null : String(playerId) });
},
has(id) { return !!_achUnlocked[id]; },
bindToStat(id, statName, cond) { _send('achievements.bindToStat', { id, statName, cond: cond || {} }); },
setButtonVisible(v) { _send('achievements.setButtonVisible', { visible: !!v }); },
openPage() { _send('achievements.openPage', {}); },
},
/** /**
* Игроки комнаты (Фаза 4.3 мультиплеер). * Игроки комнаты (Фаза 4.3 мультиплеер).
* В одиночной игре (редактор) только локальный игрок. * В одиночной игре (редактор) только локальный игрок.
@ -3791,6 +3970,18 @@ self.onmessage = (e) => {
const t = payload?.type; const t = payload?.type;
if (t === 'click') { if (t === 'click') {
for (const fn of _globalClickHandlers) _safeCall(fn, payload, 'onClick'); for (const fn of _globalClickHandlers) _safeCall(fn, payload, 'onClick');
} else if (t === 'leaderstatsChange') {
// Задача 20: стат изменился на сервере/main — обновляем зеркало + onChange.
const pid = payload.playerId == null ? '@me' : String(payload.playerId);
if (!_lsMirror[pid]) _lsMirror[pid] = {};
_lsMirror[pid][payload.name] = payload.newValue;
if (payload.isMe) { if (!_lsMirror['@me']) _lsMirror['@me'] = {}; _lsMirror['@me'][payload.name] = payload.newValue; }
for (const fn of _lsChangeHandlers) {
try { fn(payload.playerId, payload.name, payload.newValue, payload.oldValue); }
catch (err) { _send('log', { level: 'error', text: 'leaderstats.onChange: ' + (err && err.message ? err.message : err) }); }
}
} else if (t === 'achievementUnlocked') {
_achUnlocked[payload.id] = true;
} else if (t === 'mouseMove') { } else if (t === 'mouseMove') {
for (const fn of _mouseMoveHandlers) { for (const fn of _mouseMoveHandlers) {
try { fn(payload.x, payload.y); } try { fn(payload.x, payload.y); }
@ -3845,13 +4036,34 @@ self.onmessage = (e) => {
for (const fn of arr) _safeCall(fn, ev, 'npc.onDeath'); for (const fn of arr) _safeCall(fn, ev, 'npc.onDeath');
} }
} else if (t === 'toolUse') { } else if (t === 'toolUse') {
// payload: { tool: {kind, modelTypeId, name}, point, target } // payload: { tool: {kind, modelTypeId, name, customToolId?}, point, target }
const ev = { const ev = {
tool: payload.tool || null, tool: payload.tool || null,
point: payload.point || null, point: payload.point || null,
target: payload.target || null, target: payload.target || null,
}; };
// Phase 6.4: per-tool callback из game.tools.create -> onActivated.
const customId = payload.tool && payload.tool.customToolId;
if (customId && _toolCallbacks[customId] && _toolCallbacks[customId].activated) {
_safeCall(_toolCallbacks[customId].activated, ev, 'tool.onActivated:' + customId);
}
for (const fn of _toolUseHandlers) _safeCall(fn, ev, 'onToolUse'); for (const fn of _toolUseHandlers) _safeCall(fn, ev, 'onToolUse');
} else if (t === 'toolEquipped') {
const customId = payload && payload.tool && payload.tool.customToolId;
if (customId && _toolCallbacks[customId] && _toolCallbacks[customId].equipped) {
_safeCall(_toolCallbacks[customId].equipped, payload, 'tool.onEquipped:' + customId);
}
} else if (t === 'toolUnequipped') {
const customId = payload && payload.tool && payload.tool.customToolId;
if (customId && _toolCallbacks[customId] && _toolCallbacks[customId].unequipped) {
_safeCall(_toolCallbacks[customId].unequipped, payload, 'tool.onUnequipped:' + customId);
}
} else if (t === 'remoteEvent') {
// Phase 6.6: RemoteEvent от сервера. payload: { from, name, data }
const arr = _remoteHandlers[payload.name] || [];
for (const fn of arr) {
_safeCall(fn, { from: payload.from, data: payload.data }, 'remote.on:' + payload.name);
}
} else if (t === 'cutsceneDone') { } else if (t === 'cutsceneDone') {
// Катсцена камеры завершилась (Фаза 5.7). // Катсцена камеры завершилась (Фаза 5.7).
for (const fn of _cutsceneDoneHandlers) _safeCall(fn, undefined, 'onCutsceneDone'); for (const fn of _cutsceneDoneHandlers) _safeCall(fn, undefined, 'onCutsceneDone');