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:
parent
7389dfc660
commit
bbc82af819
@ -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 есть
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user