From bbc82af819e08e960d55e0e92862767bb5420559 Mon Sep 17 00:00:00 2001 From: min Date: Wed, 10 Jun 2026 00:09:49 +0300 Subject: [PATCH] =?UTF-8?q?feat(player):=20=D1=81=D0=B8=D0=BD=D1=85=D1=80?= =?UTF-8?q?=D0=BE=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20JS-API=20+?= =?UTF-8?q?=20BabylonScene.=5FmeshToTarget(npc)=20+=20GUI=20cmd-handlers?= =?UTF-8?q?=20(=D0=A4=D0=B0=D0=B7=D0=B0=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/engine/BabylonScene.js | 25 +++- src/engine/ScriptSandboxWorker.js | 216 +++++++++++++++++++++++++++++- 2 files changed, 238 insertions(+), 3 deletions(-) diff --git a/src/engine/BabylonScene.js b/src/engine/BabylonScene.js index d9df2bf..456f60e 100644 --- a/src/engine/BabylonScene.js +++ b/src/engine/BabylonScene.js @@ -2828,6 +2828,7 @@ export class BabylonScene { if (md.isBlock) { 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.isPrimitive) return { kind: 'primitive', id: md.primitiveId }; 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 point = pick?.point ? { x: pick.point.x, y: pick.point.y, z: pick.point.z } : null; // 1) Self-onClick — только если target есть diff --git a/src/engine/ScriptSandboxWorker.js b/src/engine/ScriptSandboxWorker.js index a2da5b4..4627c80 100644 --- a/src/engine/ScriptSandboxWorker.js +++ b/src/engine/ScriptSandboxWorker.js @@ -117,6 +117,13 @@ let _unlockedSkins = []; let _currentSkin = null; let _skinChangeHandlers = []; 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) let _guiClickHandlers = {}; // Подписки game.gui.onSubmit(id, fn) — ввод в TextBox завершён (Enter) @@ -669,6 +676,50 @@ function _buildSelfApi() { _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() { _send('self.delete', { target: _target }); }, @@ -1101,6 +1152,18 @@ const game = { * game.player.giveTool('blaster-blaster-a', { equip: true }); */ 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; opts = opts || {}; 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 }); */ 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 || {}; const id = ++_tweenSeq; if (typeof opts.onDone === 'function') _tweenCallbacks[id] = opts.onDone; @@ -1326,6 +1390,32 @@ const game = { if (!sessionId) return; _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 игрока (получение урона / лечение / смерть). * fn(event) где event = { hp, maxHp, source, damaged, delta }. @@ -2741,7 +2831,96 @@ const game = { 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 — мультиплеер). * В одиночной игре (редактор) — только локальный игрок. @@ -3791,6 +3970,18 @@ self.onmessage = (e) => { const t = payload?.type; if (t === 'click') { 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') { for (const fn of _mouseMoveHandlers) { try { fn(payload.x, payload.y); } @@ -3845,13 +4036,34 @@ self.onmessage = (e) => { for (const fn of arr) _safeCall(fn, ev, 'npc.onDeath'); } } else if (t === 'toolUse') { - // payload: { tool: {kind, modelTypeId, name}, point, target } + // payload: { tool: {kind, modelTypeId, name, customToolId?}, point, target } const ev = { tool: payload.tool || null, point: payload.point || 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'); + } 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') { // Катсцена камеры завершилась (Фаза 5.7). for (const fn of _cutsceneDoneHandlers) _safeCall(fn, undefined, 'onCutsceneDone');