From 76b2afd31273b56b02c93b8944170f04a8e8f770 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=98=D0=9D?= Date: Mon, 1 Jun 2026 21:15:54 +0300 Subject: [PATCH 1/5] =?UTF-8?q?feat(10):=20=D0=B6=D0=B8=D0=B2=D1=8B=D0=B5?= =?UTF-8?q?=203D-=D0=BD=D0=B0=D0=B4=D0=BF=D0=B8=D1=81=D0=B8=20(attachFace)?= =?UTF-8?q?=20+=20=D0=B2=D0=B8=D1=82=D1=80=D0=B8=D0=BD=D0=B0-=D0=BB=D1=83?= =?UTF-8?q?=D1=82=D0=B1=D0=BE=D0=BA=D1=81=20+=20=D0=B2=D0=B8=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Движок: LabelManager attachFace (текст плоско на грань примитива, FRONTSIDE без зеркала), tilt, 5 пресетов, richText; GameRuntime scene.move/scene.rotate для моделей и примитивов; ScriptSandboxWorker obj.move/obj.rotate в Instance- proxy; InspectorPanel настройки label. Вики: карточка #57 guide-dynamic-label (Часовая башня) + полная статья-урок с разбором attachFace/obj.move/format.money. Co-Authored-By: Claude Opus 4.8 --- src/community/docsGames.js | 667 ++++++++++++----------- src/community/docsIcons.jsx | 6 + src/community/docsLessons.jsx | 125 +++++ src/editor/InspectorPanel.jsx | 97 ++++ src/editor/engine/BabylonScene.js | 37 +- src/editor/engine/GameRuntime.js | 166 +++++- src/editor/engine/LabelManager.js | 373 +++++++++++-- src/editor/engine/PrimitiveManager.js | 53 +- src/editor/engine/ScriptSandboxWorker.js | 158 ++++++ src/editor/engine/SelectionManager.js | 2 + 10 files changed, 1299 insertions(+), 385 deletions(-) diff --git a/src/community/docsGames.js b/src/community/docsGames.js index d16b363..1c3317a 100644 --- a/src/community/docsGames.js +++ b/src/community/docsGames.js @@ -1,331 +1,336 @@ -/** - * docsGames.js — каталог 50 мини-игр-уроков вики Рублокса. - * - * Раздел K вики. Каждая игра — карточка: { id, num, title, stars, icon, - * desc, mechanics, ready }. - * - num — порядковый номер 1..50 - * - stars — сложность 1..3 - * - icon — имя SVG-иконки из docsIcons.jsx (НЕ эмодзи) - * - desc — что получится в игре, 1-2 предложения - * - mechanics — список механик, которым учит игра - * - previewShot — (необязательно) имя файла из public/wiki/ для - * превью на карточке. По умолчанию берётся - * lessonN-result.png; задаётся, когда другой кадр - * урока смотрится на карточке лучше. - * - ready — есть ли подробный урок (пока у всех false — наполним позже) - * - * Группы: - * 1-12 — самые простые - * 13-28 — простые с механиками - * 29-40 — средние - * 41-50 — сложные - */ - -export const GAME_GROUPS = [ - { id: 'g1', title: 'Группа 1 — Самые простые', stars: 1, - hint: 'Постановка объектов, базовые скрипты, события, простые твины.' }, - { id: 'g2', title: 'Группа 2 — Простые с механиками', stars: 2, - hint: 'NPC, инвентарь, теги, billboard, камера, констрейнты.' }, - { id: 'g3', title: 'Группа 3 — Средние', stars: 2, - hint: 'Комбинации механик, GUI, состояние игры, NPC-логика.' }, - { id: 'g4', title: 'Группа 4 — Сложные', stars: 3, - hint: 'Полные игры, мультиплеер, продвинутые системы.' }, - { id: 'g5', title: 'Разбор готовых игр', stars: 2, - hint: 'Настоящие игры из студии по косточкам: камера и таблички, анимации интерфейса, модальные сцены и кастомные скины. У каждой — кнопка «Открыть оригинал в редакторе».' }, -]; - -export const GAMES = [ - // ── Группа 1 — Самые простые ────────────────────────────────── - { id: 'collect-coins', num: 1, group: 'g1', stars: 1, icon: 'coin', - title: 'Собери монетки', - desc: 'Ходишь по уровню и собираешь жёлтые сферы, счёт растёт.', - mechanics: ['события касания', 'счётчик очков', 'удаление объектов'], - ready: false }, - { id: 'platform-jump', num: 2, group: 'g1', stars: 1, icon: 'jump', - title: 'Прыгай по платформам', - desc: 'Паркур из примитивов — допрыгай до финиша, не упав вниз.', - mechanics: ['примитивы-платформы', 'точка спавна', 'финиш'], - ready: false }, - { id: 'dont-fall', num: 3, group: 'g1', stars: 1, icon: 'hole', - title: 'Не упади', - desc: 'Платформы исчезают под ногами — нужно всё время убегать.', - mechanics: ['таймеры', 'исчезновение объектов', 'onTouch'], - ready: false }, - { id: 'button-door', num: 4, group: 'g1', stars: 1, icon: 'door', - title: 'Кнопка-открывашка', - desc: 'Нажми кнопку клавишей E — и откроется дверь в следующую комнату.', - mechanics: ['ProximityPrompt (E)', 'твины', 'перемещение объектов'], - ready: false }, - { id: 'maze', num: 5, group: 'g1', stars: 1, icon: 'maze', - title: 'Лабиринт', - desc: 'Найди путь из стен-кубов от старта к выходу из лабиринта.', - mechanics: ['постройка из блоков', 'спавн', 'триггер-финиш'], - // на карточке лучше виден лабиринт сверху — берём второй скрин урока - previewShot: 'lesson5-scene.png', - ready: false }, - { id: 'color-tiles', num: 6, group: 'g1', stars: 1, icon: 'palette', - title: 'Цветные плитки', - desc: 'Наступаешь на плитки на полу — и они меняют свой цвет.', - mechanics: ['onTouch', 'setColor', 'примитивы-плитки'], - ready: false }, - { id: 'catch-falling', num: 7, group: 'g1', stars: 1, icon: 'box', - title: 'Поймай падающее', - desc: 'С неба падают кубы — лови их, пока не упали на землю.', - mechanics: ['спавн с таймером', 'физика падения', 'счёт'], - ready: false }, - { id: 'run-to-finish', num: 8, group: 'g1', stars: 1, icon: 'flag', - title: 'Беги к финишу', - desc: 'Гонка на время: добеги до финишной черты как можно быстрее.', - mechanics: ['таймер', 'триггер-финиш', 'game.ui'], - ready: false }, - { id: 'traffic-light', num: 9, group: 'g1', stars: 1, icon: 'light', - title: 'Светофор', - desc: 'Стой на красный, беги на зелёный — успей дойти до конца.', - mechanics: ['лампы', 'таймеры', 'проверка движения'], - ready: false }, - { id: 'spring-jump', num: 10, group: 'g1', stars: 1, icon: 'spring', - title: 'Прыжок-пружина', - desc: 'Батуты подбрасывают тебя всё выше — допрыгни до верхней площадки.', - mechanics: ['пружины (spring)', 'boostJump', 'onTouch'], - ready: false }, - { id: 'echo-room', num: 11, group: 'g1', stars: 1, icon: 'sound', - title: 'Эхо-комната', - desc: 'Наступаешь на плитку — играет звук. Собери мелодию из шагов.', - mechanics: ['game.sound', 'onTouch', '3D-звук'], - ready: false }, - { id: 'code-door', num: 12, group: 'g1', stars: 1, icon: 'keypad', - title: 'Дверь по коду', - desc: 'Введи правильное число в поле ввода — и дверь откроется.', - mechanics: ['GUI-поле ввода', 'onSubmit', 'твины'], - ready: false }, - - // ── Группа 2 — Простые с механиками ─────────────────────────── - { id: 'trader', num: 13, group: 'g2', stars: 2, icon: 'trader', - title: 'Торговец', - desc: 'Поговори с NPC-торговцем по клавише E и получи от него предмет.', - mechanics: ['NPC', 'диалоги', 'инвентарь'], - ready: false }, - { id: 'collect-by-tag', num: 14, group: 'g2', stars: 2, icon: 'star', - title: 'Собери по тегам', - desc: 'Собери все объекты, помеченные тегом «звезда», на уровне.', - mechanics: ['теги', 'getTagged', 'счётчик'], - ready: false }, - { id: 'shooting-range', num: 15, group: 'g2', stars: 2, icon: 'crosshair', - title: 'Тир', - desc: 'Стреляй по мишеням лучом-raycast и считай выбитые очки.', - mechanics: ['raycast', 'onClick', 'счёт'], - ready: false }, - { id: 'lava-floor', num: 16, group: 'g2', stars: 2, icon: 'lava', - title: 'Лава-пол', - desc: 'Пол наносит урон — прыгай только по безопасным островкам.', - mechanics: ['урон по касанию', 'HP', 'платформы'], - ready: false }, - { id: 'key-chest', num: 17, group: 'g2', stars: 2, icon: 'key', - title: 'Ключ и сундук', - desc: 'Найди ключ на уровне, подбери его и открой запертый сундук.', - mechanics: ['инвентарь', 'has()', 'ProximityPrompt'], - ready: false }, - { id: 'swing', num: 18, group: 'g2', stars: 2, icon: 'swing', - title: 'Качели', - desc: 'Запрыгни на качели-констрейнт и катайся туда-сюда.', - mechanics: ['констрейнт-петля (hinge)', 'физика'], - ready: false }, - { id: 'elevator', num: 19, group: 'g2', stars: 2, icon: 'elevator', - title: 'Лифт', - desc: 'Платформа-лифт возит тебя между этажами здания.', - mechanics: ['твины', 'движущаяся платформа', 'триггеры'], - ready: false }, - { id: 'enemy-names', num: 20, group: 'g2', stars: 2, icon: 'tag', - title: 'Имена над врагами', - desc: 'У каждого врага над головой висит метка с именем и его HP.', - mechanics: ['billboard-метки', 'NPC', 'HP'], - ready: false }, - { id: 'chaser', num: 21, group: 'g2', stars: 2, icon: 'chase', - title: 'Преследователь', - desc: 'NPC гонится за тобой по всему уровню — убегай и прячься.', - mechanics: ['NPC follow', 'distance', 'onTick'], - ready: false }, - { id: 'danger-zone', num: 22, group: 'g2', stars: 2, icon: 'warning', - title: 'Зона опасности', - desc: 'Войдёшь в триггер-зону — начинаешь терять здоровье.', - mechanics: ['триггер-зона', 'onTouch/onUntouch', 'урон'], - ready: false }, - { id: 'switches', num: 23, group: 'g2', stars: 2, icon: 'lever', - title: 'Переключатели', - desc: 'Активируй три рычага в правильном порядке, чтобы пройти дальше.', - mechanics: ['ProximityPrompt', 'состояние', 'проверка порядка'], - ready: false }, - { id: 'falling-bridge', num: 24, group: 'g2', stars: 2, icon: 'bridge', - title: 'Падающий мост', - desc: 'Мост собран из исчезающих платформ — беги, пока он не рухнул.', - mechanics: ['таймеры', 'исчезновение', 'onTouch'], - ready: false }, - { id: 'flyby-camera', num: 25, group: 'g2', stars: 2, icon: 'camera', - title: 'Камера-облёт', - desc: 'При старте игры камера красиво облетает весь уровень.', - mechanics: ['game.camera.cutscene', 'onCutsceneDone'], - ready: false }, - { id: 'coin-magnet', num: 26, group: 'g2', stars: 2, icon: 'magnet', - title: 'Магнит монет', - desc: 'Монеты сами летят к игроку, когда он подходит близко.', - mechanics: ['твин к позиции', 'distance', 'onTick'], - ready: false }, - { id: 'double-jump', num: 27, group: 'g2', stars: 2, icon: 'doubleArrow', - title: 'Двойной прыжок', - desc: 'Паркур, где не пройти без двойного прыжка в воздухе.', - mechanics: ['setDoubleJump', 'платформы', 'финиш'], - ready: false }, - { id: 'ghost-walls', num: 28, group: 'g2', stars: 2, icon: 'ghost', - title: 'Призрачные стены', - desc: 'Некоторые стены становятся проходимыми — найди секретный путь.', - mechanics: ['passThrough', 'триггеры', 'секреты'], - ready: false }, - - // ── Группа 3 — Средние ──────────────────────────────────────── - { id: 'shop', num: 29, group: 'g3', stars: 2, icon: 'cart', - title: 'Магазин', - desc: 'GUI-список товаров — покупай предметы за собранные монеты.', - mechanics: ['GUI-список', 'экономика', 'инвентарь'], - ready: false }, - { id: 'quest-tasks', num: 30, group: 'g3', stars: 2, icon: 'scroll', - title: 'Квест с заданиями', - desc: 'NPC выдаёт цепочку заданий — выполни их все по очереди.', - mechanics: ['NPC-диалоги', 'состояние квеста', 'события'], - ready: false }, - { id: 'base-defense', num: 31, group: 'g3', stars: 2, icon: 'shield', - title: 'Защита базы', - desc: 'Враги идут к твоей базе — останавливай их, пока не дошли.', - mechanics: ['NPC-волны', 'raycast', 'HP базы'], - ready: false }, - { id: 'lap-race', num: 32, group: 'g3', stars: 2, icon: 'car', - title: 'Гонка с кругами', - desc: 'Проедь несколько кругов через чекпоинты на время.', - mechanics: ['чекпоинты', 'таймер', 'счётчик кругов'], - ready: false }, - { id: 'boss-platformer', num: 33, group: 'g3', stars: 2, icon: 'boss', - title: 'Платформер с боссом', - desc: 'Пройди паркур до NPC-босса и победи его в конце уровня.', - mechanics: ['паркур', 'NPC-босс', 'бой'], - ready: false }, - { id: 'harvest', num: 34, group: 'g3', stars: 2, icon: 'plant', - title: 'Сбор урожая', - desc: 'Растения растут на грядках — собирай их вовремя, пока спелые.', - mechanics: ['твины роста', 'таймеры', 'счёт'], - ready: false }, - { id: 'hide-from-npc', num: 35, group: 'g3', stars: 2, icon: 'hide', - title: 'Прятки от NPC', - desc: 'NPC ищет тебя по уровню — прячься за объектами, чтобы не нашёл.', - mechanics: ['NPC-логика', 'raycast видимости', 'distance'], - ready: false }, - { id: 'box-puzzle', num: 36, group: 'g3', stars: 2, icon: 'puzzle', - title: 'Головоломка с ящиками', - desc: 'Двигай ящики на кнопки-плиты, чтобы открыть запертую дверь.', - mechanics: ['физика толкания', 'кнопки-плиты', 'логика'], - ready: false }, - { id: 'obstacle-course', num: 37, group: 'g3', stars: 2, icon: 'obstacle', - title: 'Полоса препятствий', - desc: 'Шипы, ямы и движущиеся платформы — пройди всё без смерти.', - mechanics: ['шипы', 'движущиеся платформы', 'чекпоинты'], - ready: false }, - { id: 'music-game', num: 38, group: 'g3', stars: 2, icon: 'music', - title: 'Музыкальная игра', - desc: 'Запомни и повтори последовательность звуков, которую сыграла игра.', - mechanics: ['game.sound', 'массивы', 'проверка ответа'], - ready: false }, - { id: 'tower-build', num: 39, group: 'g3', stars: 2, icon: 'tower', - title: 'Башня — стройка', - desc: 'Ставь блоки по подсказкам скрипта и построй высокую башню.', - mechanics: ['спавн блоков', 'GUI-подсказки', 'счётчик'], - ready: false }, - { id: 'wave-survival', num: 40, group: 'g3', stars: 2, icon: 'zombie', - title: 'Выживание от волн', - desc: 'Волны врагов нападают одна за другой — продержись N секунд.', - mechanics: ['NPC-волны', 'таймеры', 'HP'], - ready: false }, - - // ── Группа 4 — Сложные ──────────────────────────────────────── - { id: 'adventure-platformer', num: 41, group: 'g4', stars: 3, icon: 'map', - title: 'Платформер-приключение', - desc: 'Большой уровень с чекпоинтами, врагами и финишем-сокровищем.', - mechanics: ['большой уровень', 'чекпоинты', 'враги', 'финиш'], - ready: false }, - { id: 'rpg-village', num: 42, group: 'g4', stars: 3, icon: 'village', - title: 'RPG-деревня', - desc: 'Деревня с NPC, диалогами, квестами, инвентарём и торговлей.', - mechanics: ['NPC', 'квесты', 'инвентарь', 'экономика'], - ready: false }, - { id: 'obstacle-race', num: 43, group: 'g4', stars: 3, icon: 'car', - title: 'Гонка с препятствиями', - desc: 'Трасса с бустами скорости и ловушками — приди первым к финишу.', - mechanics: ['бусты', 'ловушки', 'таймер', 'чекпоинты'], - ready: false }, - { id: 'tower-defense', num: 44, group: 'g4', stars: 3, icon: 'castle', - title: 'Tower Defense', - desc: 'Ставь башни вдоль дороги и отбивай волны наступающих врагов.', - mechanics: ['GUI-меню', 'NPC-волны', 'башни', 'экономика'], - ready: false }, - { id: 'arena-shooter', num: 45, group: 'g4', stars: 3, icon: 'gun', - title: 'Стрелялка-арена', - desc: 'Оружие, враги, очки и GUI-счёт — выживай на боевой арене.', - mechanics: ['оружие (Tool)', 'raycast', 'NPC', 'GUI-счёт'], - ready: false }, - { id: 'clicker', num: 46, group: 'g4', stars: 3, icon: 'click', - title: 'Кликер', - desc: 'GUI-игра: кликай по кнопке, копи очки и покупай улучшения.', - mechanics: ['GUI', 'game.save', 'экономика улучшений'], - ready: false }, - { id: 'escape-quest', num: 47, group: 'g4', stars: 3, icon: 'door', - title: 'Квест-побег', - desc: 'Комната-головоломка: реши все загадки и найди выход.', - mechanics: ['головоломки', 'инвентарь', 'состояние', 'триггеры'], - ready: false }, - { id: 'mp-tag', num: 48, group: 'g4', stars: 3, icon: 'chase', - title: 'Мультиплеер: Салки', - desc: 'Несколько игроков, один водящий — догони и осаль остальных.', - mechanics: ['мультиплеер', 'команды', 'game.room'], - ready: false }, - { id: 'mp-race', num: 49, group: 'g4', stars: 3, icon: 'trophy', - title: 'Мультиплеер: Гонка', - desc: 'Соревнование игроков на трассе с общим счётом комнаты.', - mechanics: ['мультиплеер', 'game.room', 'чекпоинты'], - ready: false }, - { id: 'make-your-own', num: 50, group: 'g4', stars: 3, icon: 'sparkles', - title: 'Своя игра', - desc: 'Гайд: как придумать и собрать собственную игру с нуля.', - mechanics: ['проектирование игры', 'все механики вместе'], - ready: false }, - - // ── Группа 5 — Разбор готовых игр ───────────────────────────── - // Это НАСТОЯЩИЕ игры из студии. У карточек есть openProjectId — - // кнопка открывает оригинал игры в редакторе (а не строит из билдера). - { id: 'guide-dvor', num: 51, group: 'g5', stars: 1, icon: 'camera', - title: 'Двор с табличкой', - desc: 'Учимся крутить камеру мышкой как в Roblox и нажимать на 3D-таблички прямо в мире.', - mechanics: ['камера и мышь', 'ПКМ-orbit и зум', 'Shift-Lock (L)', '3D-таблички'], - previewShot: 'guide-dvor-scene.png', openProjectId: 1991, ready: true }, - { id: 'guide-vitrina', num: 52, group: 'g5', stars: 2, icon: 'palette', - title: 'Витрина GUI', - desc: 'Живые кнопки магазина: градиенты, пульсация, поворот и плавные твины при нажатии.', - mechanics: ['GUI-кнопки', 'анимации (pulse/rotate)', 'твины', 'счётчик монет'], - previewShot: 'guide-vitrina-scene.png', openProjectId: 1995, ready: true }, - { id: 'guide-sunduk', num: 53, group: 'g5', stars: 2, icon: 'scroll', - title: 'Тайна старого сундука', - desc: 'Кат-сцены и диалоги: затемнение, прожектор на сундуке, выбор приза и финальная победа.', - mechanics: ['game.modal', 'диалог по строкам', 'прожектор + камера', 'лутбокс'], - previewShot: 'guide-sunduk-scene.png', openProjectId: 2037, ready: true }, - { id: 'guide-zoo', num: 54, group: 'g5', stars: 2, icon: 'gamepad', - title: 'Парк животных', - desc: 'Кастомные скины: герой превращается в пончик, машину, пришельца. Магазин скинов на B.', - mechanics: ['game.player.setSkin', 'non-humanoid скины', 'магазин скинов', 'таблички'], - previewShot: 'guide-zoo-scene.png', openProjectId: 2046, ready: true }, - { id: 'guide-strelka', num: 55, group: 'g5', stars: 1, icon: 'gamepad', - title: 'Туториал — собери монетки', - desc: '3D-стрелка-указатель «иди сюда»: дорожка из бегущих шевронов + парящий маркер над целью. Дошёл — стрелка прыгает на следующую.', - mechanics: ['game.fx.pointer', 'preset стрелки', 'setTarget/update', 'onTouch цели'], - previewShot: 'guide-strelka-scene.png', openProjectId: 333, ready: true }, - { id: 'guide-lego', num: 56, group: 'g5', stars: 1, icon: 'cube', - title: 'Лего-полигон — studs материал', - desc: 'Лего-кружки (studs) на блоках и примитивах любого цвета: зелёный пол, оранжевая стена, разноцветные кубы + готовый лего-сет (дерево, дом, машина).', - mechanics: ['material: studs', 'studs-block (цвет на блок)', 'тайлинг по размеру', 'лего-сет моделей'], - previewShot: 'guide-lego-scene.png', openProjectId: 0, ready: true }, -]; +/** + * docsGames.js — каталог 50 мини-игр-уроков вики Рублокса. + * + * Раздел K вики. Каждая игра — карточка: { id, num, title, stars, icon, + * desc, mechanics, ready }. + * - num — порядковый номер 1..50 + * - stars — сложность 1..3 + * - icon — имя SVG-иконки из docsIcons.jsx (НЕ эмодзи) + * - desc — что получится в игре, 1-2 предложения + * - mechanics — список механик, которым учит игра + * - previewShot — (необязательно) имя файла из public/wiki/ для + * превью на карточке. По умолчанию берётся + * lessonN-result.png; задаётся, когда другой кадр + * урока смотрится на карточке лучше. + * - ready — есть ли подробный урок (пока у всех false — наполним позже) + * + * Группы: + * 1-12 — самые простые + * 13-28 — простые с механиками + * 29-40 — средние + * 41-50 — сложные + */ + +export const GAME_GROUPS = [ + { id: 'g1', title: 'Группа 1 — Самые простые', stars: 1, + hint: 'Постановка объектов, базовые скрипты, события, простые твины.' }, + { id: 'g2', title: 'Группа 2 — Простые с механиками', stars: 2, + hint: 'NPC, инвентарь, теги, billboard, камера, констрейнты.' }, + { id: 'g3', title: 'Группа 3 — Средние', stars: 2, + hint: 'Комбинации механик, GUI, состояние игры, NPC-логика.' }, + { id: 'g4', title: 'Группа 4 — Сложные', stars: 3, + hint: 'Полные игры, мультиплеер, продвинутые системы.' }, + { id: 'g5', title: 'Разбор готовых игр', stars: 2, + hint: 'Настоящие игры из студии по косточкам: камера и таблички, анимации интерфейса, модальные сцены и кастомные скины. У каждой — кнопка «Открыть оригинал в редакторе».' }, +]; + +export const GAMES = [ + // ── Группа 1 — Самые простые ────────────────────────────────── + { id: 'collect-coins', num: 1, group: 'g1', stars: 1, icon: 'coin', + title: 'Собери монетки', + desc: 'Ходишь по уровню и собираешь жёлтые сферы, счёт растёт.', + mechanics: ['события касания', 'счётчик очков', 'удаление объектов'], + ready: false }, + { id: 'platform-jump', num: 2, group: 'g1', stars: 1, icon: 'jump', + title: 'Прыгай по платформам', + desc: 'Паркур из примитивов — допрыгай до финиша, не упав вниз.', + mechanics: ['примитивы-платформы', 'точка спавна', 'финиш'], + ready: false }, + { id: 'dont-fall', num: 3, group: 'g1', stars: 1, icon: 'hole', + title: 'Не упади', + desc: 'Платформы исчезают под ногами — нужно всё время убегать.', + mechanics: ['таймеры', 'исчезновение объектов', 'onTouch'], + ready: false }, + { id: 'button-door', num: 4, group: 'g1', stars: 1, icon: 'door', + title: 'Кнопка-открывашка', + desc: 'Нажми кнопку клавишей E — и откроется дверь в следующую комнату.', + mechanics: ['ProximityPrompt (E)', 'твины', 'перемещение объектов'], + ready: false }, + { id: 'maze', num: 5, group: 'g1', stars: 1, icon: 'maze', + title: 'Лабиринт', + desc: 'Найди путь из стен-кубов от старта к выходу из лабиринта.', + mechanics: ['постройка из блоков', 'спавн', 'триггер-финиш'], + // на карточке лучше виден лабиринт сверху — берём второй скрин урока + previewShot: 'lesson5-scene.png', + ready: false }, + { id: 'color-tiles', num: 6, group: 'g1', stars: 1, icon: 'palette', + title: 'Цветные плитки', + desc: 'Наступаешь на плитки на полу — и они меняют свой цвет.', + mechanics: ['onTouch', 'setColor', 'примитивы-плитки'], + ready: false }, + { id: 'catch-falling', num: 7, group: 'g1', stars: 1, icon: 'box', + title: 'Поймай падающее', + desc: 'С неба падают кубы — лови их, пока не упали на землю.', + mechanics: ['спавн с таймером', 'физика падения', 'счёт'], + ready: false }, + { id: 'run-to-finish', num: 8, group: 'g1', stars: 1, icon: 'flag', + title: 'Беги к финишу', + desc: 'Гонка на время: добеги до финишной черты как можно быстрее.', + mechanics: ['таймер', 'триггер-финиш', 'game.ui'], + ready: false }, + { id: 'traffic-light', num: 9, group: 'g1', stars: 1, icon: 'light', + title: 'Светофор', + desc: 'Стой на красный, беги на зелёный — успей дойти до конца.', + mechanics: ['лампы', 'таймеры', 'проверка движения'], + ready: false }, + { id: 'spring-jump', num: 10, group: 'g1', stars: 1, icon: 'spring', + title: 'Прыжок-пружина', + desc: 'Батуты подбрасывают тебя всё выше — допрыгни до верхней площадки.', + mechanics: ['пружины (spring)', 'boostJump', 'onTouch'], + ready: false }, + { id: 'echo-room', num: 11, group: 'g1', stars: 1, icon: 'sound', + title: 'Эхо-комната', + desc: 'Наступаешь на плитку — играет звук. Собери мелодию из шагов.', + mechanics: ['game.sound', 'onTouch', '3D-звук'], + ready: false }, + { id: 'code-door', num: 12, group: 'g1', stars: 1, icon: 'keypad', + title: 'Дверь по коду', + desc: 'Введи правильное число в поле ввода — и дверь откроется.', + mechanics: ['GUI-поле ввода', 'onSubmit', 'твины'], + ready: false }, + + // ── Группа 2 — Простые с механиками ─────────────────────────── + { id: 'trader', num: 13, group: 'g2', stars: 2, icon: 'trader', + title: 'Торговец', + desc: 'Поговори с NPC-торговцем по клавише E и получи от него предмет.', + mechanics: ['NPC', 'диалоги', 'инвентарь'], + ready: false }, + { id: 'collect-by-tag', num: 14, group: 'g2', stars: 2, icon: 'star', + title: 'Собери по тегам', + desc: 'Собери все объекты, помеченные тегом «звезда», на уровне.', + mechanics: ['теги', 'getTagged', 'счётчик'], + ready: false }, + { id: 'shooting-range', num: 15, group: 'g2', stars: 2, icon: 'crosshair', + title: 'Тир', + desc: 'Стреляй по мишеням лучом-raycast и считай выбитые очки.', + mechanics: ['raycast', 'onClick', 'счёт'], + ready: false }, + { id: 'lava-floor', num: 16, group: 'g2', stars: 2, icon: 'lava', + title: 'Лава-пол', + desc: 'Пол наносит урон — прыгай только по безопасным островкам.', + mechanics: ['урон по касанию', 'HP', 'платформы'], + ready: false }, + { id: 'key-chest', num: 17, group: 'g2', stars: 2, icon: 'key', + title: 'Ключ и сундук', + desc: 'Найди ключ на уровне, подбери его и открой запертый сундук.', + mechanics: ['инвентарь', 'has()', 'ProximityPrompt'], + ready: false }, + { id: 'swing', num: 18, group: 'g2', stars: 2, icon: 'swing', + title: 'Качели', + desc: 'Запрыгни на качели-констрейнт и катайся туда-сюда.', + mechanics: ['констрейнт-петля (hinge)', 'физика'], + ready: false }, + { id: 'elevator', num: 19, group: 'g2', stars: 2, icon: 'elevator', + title: 'Лифт', + desc: 'Платформа-лифт возит тебя между этажами здания.', + mechanics: ['твины', 'движущаяся платформа', 'триггеры'], + ready: false }, + { id: 'enemy-names', num: 20, group: 'g2', stars: 2, icon: 'tag', + title: 'Имена над врагами', + desc: 'У каждого врага над головой висит метка с именем и его HP.', + mechanics: ['billboard-метки', 'NPC', 'HP'], + ready: false }, + { id: 'chaser', num: 21, group: 'g2', stars: 2, icon: 'chase', + title: 'Преследователь', + desc: 'NPC гонится за тобой по всему уровню — убегай и прячься.', + mechanics: ['NPC follow', 'distance', 'onTick'], + ready: false }, + { id: 'danger-zone', num: 22, group: 'g2', stars: 2, icon: 'warning', + title: 'Зона опасности', + desc: 'Войдёшь в триггер-зону — начинаешь терять здоровье.', + mechanics: ['триггер-зона', 'onTouch/onUntouch', 'урон'], + ready: false }, + { id: 'switches', num: 23, group: 'g2', stars: 2, icon: 'lever', + title: 'Переключатели', + desc: 'Активируй три рычага в правильном порядке, чтобы пройти дальше.', + mechanics: ['ProximityPrompt', 'состояние', 'проверка порядка'], + ready: false }, + { id: 'falling-bridge', num: 24, group: 'g2', stars: 2, icon: 'bridge', + title: 'Падающий мост', + desc: 'Мост собран из исчезающих платформ — беги, пока он не рухнул.', + mechanics: ['таймеры', 'исчезновение', 'onTouch'], + ready: false }, + { id: 'flyby-camera', num: 25, group: 'g2', stars: 2, icon: 'camera', + title: 'Камера-облёт', + desc: 'При старте игры камера красиво облетает весь уровень.', + mechanics: ['game.camera.cutscene', 'onCutsceneDone'], + ready: false }, + { id: 'coin-magnet', num: 26, group: 'g2', stars: 2, icon: 'magnet', + title: 'Магнит монет', + desc: 'Монеты сами летят к игроку, когда он подходит близко.', + mechanics: ['твин к позиции', 'distance', 'onTick'], + ready: false }, + { id: 'double-jump', num: 27, group: 'g2', stars: 2, icon: 'doubleArrow', + title: 'Двойной прыжок', + desc: 'Паркур, где не пройти без двойного прыжка в воздухе.', + mechanics: ['setDoubleJump', 'платформы', 'финиш'], + ready: false }, + { id: 'ghost-walls', num: 28, group: 'g2', stars: 2, icon: 'ghost', + title: 'Призрачные стены', + desc: 'Некоторые стены становятся проходимыми — найди секретный путь.', + mechanics: ['passThrough', 'триггеры', 'секреты'], + ready: false }, + + // ── Группа 3 — Средние ──────────────────────────────────────── + { id: 'shop', num: 29, group: 'g3', stars: 2, icon: 'cart', + title: 'Магазин', + desc: 'GUI-список товаров — покупай предметы за собранные монеты.', + mechanics: ['GUI-список', 'экономика', 'инвентарь'], + ready: false }, + { id: 'quest-tasks', num: 30, group: 'g3', stars: 2, icon: 'scroll', + title: 'Квест с заданиями', + desc: 'NPC выдаёт цепочку заданий — выполни их все по очереди.', + mechanics: ['NPC-диалоги', 'состояние квеста', 'события'], + ready: false }, + { id: 'base-defense', num: 31, group: 'g3', stars: 2, icon: 'shield', + title: 'Защита базы', + desc: 'Враги идут к твоей базе — останавливай их, пока не дошли.', + mechanics: ['NPC-волны', 'raycast', 'HP базы'], + ready: false }, + { id: 'lap-race', num: 32, group: 'g3', stars: 2, icon: 'car', + title: 'Гонка с кругами', + desc: 'Проедь несколько кругов через чекпоинты на время.', + mechanics: ['чекпоинты', 'таймер', 'счётчик кругов'], + ready: false }, + { id: 'boss-platformer', num: 33, group: 'g3', stars: 2, icon: 'boss', + title: 'Платформер с боссом', + desc: 'Пройди паркур до NPC-босса и победи его в конце уровня.', + mechanics: ['паркур', 'NPC-босс', 'бой'], + ready: false }, + { id: 'harvest', num: 34, group: 'g3', stars: 2, icon: 'plant', + title: 'Сбор урожая', + desc: 'Растения растут на грядках — собирай их вовремя, пока спелые.', + mechanics: ['твины роста', 'таймеры', 'счёт'], + ready: false }, + { id: 'hide-from-npc', num: 35, group: 'g3', stars: 2, icon: 'hide', + title: 'Прятки от NPC', + desc: 'NPC ищет тебя по уровню — прячься за объектами, чтобы не нашёл.', + mechanics: ['NPC-логика', 'raycast видимости', 'distance'], + ready: false }, + { id: 'box-puzzle', num: 36, group: 'g3', stars: 2, icon: 'puzzle', + title: 'Головоломка с ящиками', + desc: 'Двигай ящики на кнопки-плиты, чтобы открыть запертую дверь.', + mechanics: ['физика толкания', 'кнопки-плиты', 'логика'], + ready: false }, + { id: 'obstacle-course', num: 37, group: 'g3', stars: 2, icon: 'obstacle', + title: 'Полоса препятствий', + desc: 'Шипы, ямы и движущиеся платформы — пройди всё без смерти.', + mechanics: ['шипы', 'движущиеся платформы', 'чекпоинты'], + ready: false }, + { id: 'music-game', num: 38, group: 'g3', stars: 2, icon: 'music', + title: 'Музыкальная игра', + desc: 'Запомни и повтори последовательность звуков, которую сыграла игра.', + mechanics: ['game.sound', 'массивы', 'проверка ответа'], + ready: false }, + { id: 'tower-build', num: 39, group: 'g3', stars: 2, icon: 'tower', + title: 'Башня — стройка', + desc: 'Ставь блоки по подсказкам скрипта и построй высокую башню.', + mechanics: ['спавн блоков', 'GUI-подсказки', 'счётчик'], + ready: false }, + { id: 'wave-survival', num: 40, group: 'g3', stars: 2, icon: 'zombie', + title: 'Выживание от волн', + desc: 'Волны врагов нападают одна за другой — продержись N секунд.', + mechanics: ['NPC-волны', 'таймеры', 'HP'], + ready: false }, + + // ── Группа 4 — Сложные ──────────────────────────────────────── + { id: 'adventure-platformer', num: 41, group: 'g4', stars: 3, icon: 'map', + title: 'Платформер-приключение', + desc: 'Большой уровень с чекпоинтами, врагами и финишем-сокровищем.', + mechanics: ['большой уровень', 'чекпоинты', 'враги', 'финиш'], + ready: false }, + { id: 'rpg-village', num: 42, group: 'g4', stars: 3, icon: 'village', + title: 'RPG-деревня', + desc: 'Деревня с NPC, диалогами, квестами, инвентарём и торговлей.', + mechanics: ['NPC', 'квесты', 'инвентарь', 'экономика'], + ready: false }, + { id: 'obstacle-race', num: 43, group: 'g4', stars: 3, icon: 'car', + title: 'Гонка с препятствиями', + desc: 'Трасса с бустами скорости и ловушками — приди первым к финишу.', + mechanics: ['бусты', 'ловушки', 'таймер', 'чекпоинты'], + ready: false }, + { id: 'tower-defense', num: 44, group: 'g4', stars: 3, icon: 'castle', + title: 'Tower Defense', + desc: 'Ставь башни вдоль дороги и отбивай волны наступающих врагов.', + mechanics: ['GUI-меню', 'NPC-волны', 'башни', 'экономика'], + ready: false }, + { id: 'arena-shooter', num: 45, group: 'g4', stars: 3, icon: 'gun', + title: 'Стрелялка-арена', + desc: 'Оружие, враги, очки и GUI-счёт — выживай на боевой арене.', + mechanics: ['оружие (Tool)', 'raycast', 'NPC', 'GUI-счёт'], + ready: false }, + { id: 'clicker', num: 46, group: 'g4', stars: 3, icon: 'click', + title: 'Кликер', + desc: 'GUI-игра: кликай по кнопке, копи очки и покупай улучшения.', + mechanics: ['GUI', 'game.save', 'экономика улучшений'], + ready: false }, + { id: 'escape-quest', num: 47, group: 'g4', stars: 3, icon: 'door', + title: 'Квест-побег', + desc: 'Комната-головоломка: реши все загадки и найди выход.', + mechanics: ['головоломки', 'инвентарь', 'состояние', 'триггеры'], + ready: false }, + { id: 'mp-tag', num: 48, group: 'g4', stars: 3, icon: 'chase', + title: 'Мультиплеер: Салки', + desc: 'Несколько игроков, один водящий — догони и осаль остальных.', + mechanics: ['мультиплеер', 'команды', 'game.room'], + ready: false }, + { id: 'mp-race', num: 49, group: 'g4', stars: 3, icon: 'trophy', + title: 'Мультиплеер: Гонка', + desc: 'Соревнование игроков на трассе с общим счётом комнаты.', + mechanics: ['мультиплеер', 'game.room', 'чекпоинты'], + ready: false }, + { id: 'make-your-own', num: 50, group: 'g4', stars: 3, icon: 'sparkles', + title: 'Своя игра', + desc: 'Гайд: как придумать и собрать собственную игру с нуля.', + mechanics: ['проектирование игры', 'все механики вместе'], + ready: false }, + + // ── Группа 5 — Разбор готовых игр ───────────────────────────── + // Это НАСТОЯЩИЕ игры из студии. У карточек есть openProjectId — + // кнопка открывает оригинал игры в редакторе (а не строит из билдера). + { id: 'guide-dvor', num: 51, group: 'g5', stars: 1, icon: 'camera', + title: 'Двор с табличкой', + desc: 'Учимся крутить камеру мышкой как в Roblox и нажимать на 3D-таблички прямо в мире.', + mechanics: ['камера и мышь', 'ПКМ-orbit и зум', 'Shift-Lock (L)', '3D-таблички'], + previewShot: 'guide-dvor-scene.png', openProjectId: 1991, ready: true }, + { id: 'guide-vitrina', num: 52, group: 'g5', stars: 2, icon: 'palette', + title: 'Витрина GUI', + desc: 'Живые кнопки магазина: градиенты, пульсация, поворот и плавные твины при нажатии.', + mechanics: ['GUI-кнопки', 'анимации (pulse/rotate)', 'твины', 'счётчик монет'], + previewShot: 'guide-vitrina-scene.png', openProjectId: 1995, ready: true }, + { id: 'guide-sunduk', num: 53, group: 'g5', stars: 2, icon: 'scroll', + title: 'Тайна старого сундука', + desc: 'Кат-сцены и диалоги: затемнение, прожектор на сундуке, выбор приза и финальная победа.', + mechanics: ['game.modal', 'диалог по строкам', 'прожектор + камера', 'лутбокс'], + previewShot: 'guide-sunduk-scene.png', openProjectId: 2037, ready: true }, + { id: 'guide-zoo', num: 54, group: 'g5', stars: 2, icon: 'gamepad', + title: 'Парк животных', + desc: 'Кастомные скины: герой превращается в пончик, машину, пришельца. Магазин скинов на B.', + mechanics: ['game.player.setSkin', 'non-humanoid скины', 'магазин скинов', 'таблички'], + previewShot: 'guide-zoo-scene.png', openProjectId: 2046, ready: true }, + { id: 'guide-strelka', num: 55, group: 'g5', stars: 1, icon: 'gamepad', + title: 'Туториал — собери монетки', + desc: '3D-стрелка-указатель «иди сюда»: дорожка из бегущих шевронов + парящий маркер над целью. Дошёл — стрелка прыгает на следующую.', + mechanics: ['game.fx.pointer', 'preset стрелки', 'setTarget/update', 'onTouch цели'], + previewShot: 'guide-strelka-scene.png', openProjectId: 333, ready: true }, + { id: 'guide-lego', num: 56, group: 'g5', stars: 1, icon: 'cube', + title: 'Лего-полигон — studs материал', + desc: 'Лего-кружки (studs) на блоках и примитивах любого цвета: зелёный пол, оранжевая стена, разноцветные кубы + готовый лего-сет (дерево, дом, машина).', + mechanics: ['material: studs', 'studs-block (цвет на блок)', 'тайлинг по размеру', 'лего-сет моделей'], + previewShot: 'guide-lego-scene.png', openProjectId: 2217, ready: true }, + { id: 'guide-dynamic-label', num: 57, group: 'g5', stars: 2, icon: 'clock', + title: 'Часовая башня — живые 3D-надписи', + desc: 'Живые 3D-надписи + витрина-лутбокс: таймер над башней, ряд подиумов с вращающимися предметами и наклонными табличками-ценниками, счётчик монет (клик +10), HP над зомби. Текст крепится плоско к грани наклонного примитива.', + mechanics: ['scene.bindLabel', 'scene.bindTimer', 'attachFace (текст на грани)', '5 пресетов (gameui/boss-hp/reward…)', 'richText ', 'game.format.money', 'obj.move/rotate', 'onClick объекта'], + previewShot: 'guide-dynamic-label-scene.png', openProjectId: 2261, ready: true }, +]; diff --git a/src/community/docsIcons.jsx b/src/community/docsIcons.jsx index f13ea89..d32a1b9 100644 --- a/src/community/docsIcons.jsx +++ b/src/community/docsIcons.jsx @@ -418,6 +418,12 @@ const ICONS = { ), + clock: () => ( + <> + + + + ), }; export default function DocIcon({ name, size = 24, className = '' }) { diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index 07d451f..c669aed 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -7982,6 +7982,131 @@ game.scene.setColor('block:0,0,0', '#ff0000');`} ), }, + 'guide-dynamic-label': { + body: ( + <> +

Что получится

+

+ Часовая башня с живыми надписями прямо в 3D и витриной- + лутбоксом: над башней — таймер обратного отсчёта в + жёлто-синей рамке (как в Roblox); ряд из трёх подиумов, на + каждом парит и вращается предмет (меч, кубок, ключ), а перед ним — + наклонная табличка-ценник с названием; счётчик монет + «1 230 рубликов» с золотой монетой (клик +10); над зомби — полоса + HP. Все надписи обновляются сами, без мигания. +

+ + + +

Чему научишься

+
    +
  • scene.bindLabel(объект, fn, opts) — привязать надпись к + функции: что вернёт функция, то и на плашке. Обновляется раз в + interval сек, текстура перерисовывается только + когда текст реально изменился (диф-чек);
  • +
  • scene.bindTimer(объект, времяКонца, opts) — готовый + таймер обратного отсчёта с onEnd-колбэком;
  • +
  • attachFace — прикрепить надпись ПЛОСКО к грани примитива + ('front'/'top'/...): наклоняешь сам примитив — + текст лежит в его плоскости и наклонён вместе с ним;
  • +
  • 5 пресетов плашекgameui, boss-hp, + reward (золото), warning, plain;
  • +
  • richText — разноцветные части: {'…'};
  • +
  • game.formattime (00:15:59), + money («1 334 рублика» — разделитель + склонение);
  • +
  • obj.move / obj.rotate — двигать и вращать предмет (парение + и крутёж на подиуме);
  • +
  • надпись из инспектора — раздел «Подпись над объектом»: + таймер/счётчик/HP без кода.
  • +
+ +

Шаг 1. Надпись из инспектора (без кода)

+ + + Выдели объект → в инспекторе справа раздел + «Подпись над объектом» → включи галочку. + + + В списке «Связать с…» выбери Таймер, длительность + (960 = 16 минут), формат hh:mm:ss, стиль gameui. + Жми Play — цифры пошли вниз. + + +

Шаг 2. Таблички-ценники: наклони примитив

+

+ Правильная логика — наклоняешь сам примитив-планшет, а текст + крепишь к его передней грани через attachFace: 'front'. + Текст ляжет ТОЧНО в плоскость планшета и наклонится вместе с ним — + как ценник на витрине. Размер планшета сделай чуть больше текста, + чтобы надпись не вылезала за края. +

+ + {`// Планшет наклонён в редакторе (поворот по X ≈ -29°, верх назад). +// Табличка лежит ПЛОСКО на его передней грани (БЕЗ отдельного наклона): +const plate = game.scene.findOne('Планшет1'); +game.scene.bindLabel(plate, () => 'Золотой кубок', + { preset: 'gameui', size: 0.8, attachFace: 'front' }); + +// Предмет на подиуме — парит и вращается: +const obj = game.scene.findOne('Предмет1'); +const x = obj.position.x, z = obj.position.z; +let t = 0; +game.onTick((dt) => { + t += dt; + obj.move(x, 1.9 + Math.sin(t * 2) * 0.18, z); // парение + obj.rotate(t * 1.2); // вращение вокруг Y +}); + +// Клик по предмету — "взять": +obj.onClick(() => game.ui.set('grab', 'Ты взял: Золотой кубок!', + { x: 50, y: 14, anchor: 'top', color: '#ffd23a', size: 22 }));`} + +

Шаг 3. Таймер, счётчик, HP

+ {`// Таймер над башней (на передней грани верхнего яруса): +const endTs = Date.now() + 16 * 60 * 1000; +game.scene.bindTimer(game.scene.findOne('ВерхБашни'), endTs, { + prefix: 'Сбросится через ', format: 'hh:mm:ss', preset: 'gameui', attachFace: 'front', +}); + +// Счётчик монет (формат с разделителем и склонением), клик +10: +const plate = game.scene.findOne('ПланшетМонет'); +let coins = 1230; +game.scene.bindLabel(plate, () => game.format.money(coins), + { preset: 'reward', attachFace: 'front' }); +game.scene.findOne('МонетаСчёт').onClick(() => { coins += 10; }); + +// HP над зомби — billboard (всегда к камере), меняется по клику: +const zombie = game.scene.findOne('Зомби'); +let hp = 100; +game.scene.bindLabel(zombie, () => 'Зомби HP: ' + hp + '/100', { preset: 'boss-hp' }); +zombie.onClick(() => { hp = Math.max(0, hp - 10); });`} + + findOne сразу на старте может вернуть null — сцена приходит + чуть позже. Оберни поиск в game.after(0.3, () => {'{ … }'}). + Без attachFace плашка висит билбордом над верхом + объекта (как HP-полоса) — это удобно для NPC. + + +

Почему это не тормозит

+

+ Диф-чек: bindLabel перерисовывает текстуру + только когда строка изменилась — таймер обновляется раз в секунду, + а не каждый кадр. Привязка сама отменяется при + scene.delete — утечек нет. +

+ + + Сделай ещё один подиум со своим предметом из палитры моделей. + Наклони планшет под тем же углом, повесь название через + attachFace: 'front', а предмет заставь парить и + крутиться через obj.move + obj.rotate. + + + ), + }, + }; /** Есть ли готовый текст урока для игры с таким id. */ diff --git a/src/editor/InspectorPanel.jsx b/src/editor/InspectorPanel.jsx index 28d07f5..35d68b1 100644 --- a/src/editor/InspectorPanel.jsx +++ b/src/editor/InspectorPanel.jsx @@ -318,6 +318,8 @@ const InspectorPanel = ({ const [localColor, setLocalColor] = useState('#888888'); const [localMaterial, setLocalMaterial] = useState('matte'); const [localStudDensity, setLocalStudDensity] = useState(1); // плотность studs + // Подпись над объектом (задача 10). + const [localLabel, setLocalLabel] = useState(null); // { enabled, binding, params, preset, height } const [localCanCollide, setLocalCanCollide] = useState(true); const [localVisible, setLocalVisible] = useState(true); const [localAnchored, setLocalAnchored] = useState(true); @@ -364,6 +366,7 @@ const InspectorPanel = ({ setLocalColor(selection.color || '#888888'); setLocalMaterial(selection.material || 'matte'); setLocalStudDensity(selection.studDensity || 1); + setLocalLabel(selection.label || null); setLocalCanCollide(selection.canCollide !== false); setLocalVisible(selection.visible !== false); setLocalAnchored(selection.anchored !== false); @@ -1789,6 +1792,100 @@ const InspectorPanel = ({ )} + {/* Подпись над объектом (задача 10) */} + {(() => { + const L = localLabel || { enabled: false, binding: 'static', params: {}, preset: 'gameui', height: 2.5 }; + const applyLabel = (patch) => { + const next = { ...L, ...patch, params: { ...(L.params || {}), ...(patch.params || {}) } }; + setLocalLabel(next); + onSetPrimitiveProps?.({ label: next }); + }; + const inp = { background: 'var(--bg-input,#2a1f15)', color: 'var(--text-primary,#f0e6d8)', border: '1px solid var(--border,#5a4a3a)', borderRadius: 4, padding: '3px 6px', fontSize: 12, width: '100%' }; + return ( +
+
Подпись над объектом
+ + {L.enabled && (<> +
+ Связать с + +
+ {(L.binding === 'static' || L.binding === 'formula') && ( + applyLabel({ params: { text: e.target.value } })} + style={{ ...inp, marginTop: 6 }} /> + )} + {L.binding === 'timer' && ( +
+ + + applyLabel({ params: { prefix: e.target.value } })} style={{ ...inp, gridColumn: '1 / 3' }} /> +
+ )} + {L.binding === 'save' && ( +
+ applyLabel({ params: { key: e.target.value } })} style={inp} /> + applyLabel({ params: { suffix: e.target.value } })} style={inp} /> +
+ )} +
+ Стиль + +
+
+ Крепление + +
+ + )} +
+ ); + })()} + {/* Текстура — своя картинка на гранях примитива */}
diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index 75420f8..e278908 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -1510,6 +1510,13 @@ export class BabylonScene { if (this._touchDetectFrame >= 3) { this._touchDetectFrame = 0; this._detectTouchEvents(); + // Задача 10: maxDistance-скрытие плашек (раз в 3 кадра). + if (this._labelManager) { + if (this.player?._modelRoot && !this._labelManager._playerMesh) { + this._labelManager.setPlayerMesh(this.player._modelRoot); + } + try { this._labelManager.update(); } catch (e) { /* ignore */ } + } } } } @@ -2193,7 +2200,10 @@ export class BabylonScene { if (this._isPlaying) { // В Play-режиме ЛКМ — клик игрока в forward-направлении. // Pointer Lock — курсор всё равно в центре экрана. - if (e.button === 0) this._handlePlayClick(); + if (e.button === 0) { + const r = canvas.getBoundingClientRect(); + this._handlePlayClick(e.clientX - r.left, e.clientY - r.top); + } return; } // Обновляем pointer координаты для raycast и Gizmo @@ -2986,7 +2996,7 @@ export class BabylonScene { * - в self-обработчики скриптов (routeEvent с target) * - в глобальные обработчики (game.onClick) с event.target */ - _handlePlayClick() { + _handlePlayClick(clickX, clickY) { if (!this._isPlaying) return; // Мультиплеер-выстрел: если у сцены есть mpSync, шлём 'shoot' серверу. @@ -3014,6 +3024,29 @@ export class BabylonScene { if (target) { this.gameRuntime.routeEvent(target, 'click', { point }); } + // 1b) findOne(x).onClick(fn) — адресный клик по объекту (задача 8/10). + // В pointer-lock курсор в центре → пикаем центром (target). + // В свободном курсоре (third) → пикаем по реальным координатам клика. + const wc = this.gameRuntime._watchedClickRefs; + if (wc && wc.size > 0) { + const locked = (document.pointerLockElement === this.canvas); + let clTarget = target, clPoint = point; + if (!locked && Number.isFinite(clickX) && Number.isFinite(clickY)) { + const cp = this.scene.pick(clickX, clickY, (m) => m && m.metadata + && (m.metadata.isModel || m.metadata.isPrimitive || m.metadata.isBlock)); + if (cp && cp.hit && cp.pickedMesh) { + clTarget = this._meshToTarget(cp.pickedMesh); + clPoint = cp.pickedPoint ? { x: cp.pickedPoint.x, y: cp.pickedPoint.y, z: cp.pickedPoint.z } : point; + } + } + if (clTarget) { + let ref = null; + if (clTarget.kind === 'primitive') ref = 'primitive:' + clTarget.id; + else if (clTarget.kind === 'model') ref = 'model:' + clTarget.id; + else if (clTarget.kind === 'block' && clTarget.ref) ref = 'block:' + clTarget.ref.x + ',' + clTarget.ref.y + ',' + clTarget.ref.z; + if (ref && wc.has(ref)) this.gameRuntime.routeInstEvent(ref, 'instClick', { point: clPoint }); + } + } // 2) Глобальный onClick — всегда (даже если попали в пустоту) this.gameRuntime.routeGlobalEvent('click', { point, target }); // 3) toolUse — если в активном слоте инвентаря есть инструмент/оружие. diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 2e9753a..0a515a2 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -14,7 +14,7 @@ * Этап 2.1: минимальный API — player.teleport, onTick, log. */ -import { Color3 } from '@babylonjs/core'; +import { Color3, Vector3 } from '@babylonjs/core'; import { ScriptSandbox } from './ScriptSandbox'; import { STORYS_addres } from '../../api/API'; import { PhysicsWorld } from './PhysicsWorld'; @@ -77,6 +77,8 @@ export class GameRuntime { if (!Array.isArray(scripts) || scripts.length === 0) { // eslint-disable-next-line no-console console.warn('[GameRuntime] start: no scripts to run'); + // Задача 10: подписи из инспектора (label) работают и БЕЗ скриптов. + this._setupLabelBindings(); return; } // Карта модулей для game.require — { имя_скрипта: код }. @@ -180,6 +182,102 @@ export class GameRuntime { } else { setTimeout(sendInitial, 16); } + // Задача 10: авто-биндинг подписей (label) из project_data — без скрипта. + this._setupLabelBindings(); + } + + /** + * Задача 10: для всех примитивов с data.label.enabled создаём плашку и + * автообновление (timer/save/hp/static/formula) — это «таймер двумя кликами» + * из инспектора, без написания скрипта. Обновление раз в секунду из main. + */ + _setupLabelBindings() { + this._stopLabelBindings(); + const pm = this.scene3d?.primitiveManager; + if (!pm) return; + const binds = []; + for (const data of pm.instances.values()) { + const L = data.label; + if (!L || !L.enabled) continue; + binds.push({ id: data.id, label: L, lastText: null }); + } + if (binds.length === 0) return; + // Лениво создаём LabelManager. + if (!this.scene3d._labelManager) { + this.scene3d._labelManager = new LabelManager(this.scene3d.scene); + } + this._labelBinds = binds; + const tick = () => { + if (!this._isRunning) return; + const lm = this.scene3d?._labelManager; + const pmm = this.scene3d?.primitiveManager; + if (!lm || !pmm) return; + for (const b of this._labelBinds) { + const data = pmm.instances.get(b.id); + if (!data || !data.mesh) continue; + const text = this._computeLabelText(b.label, data); + if (text === b.lastText) continue; + b.lastText = text; + const L = b.label; + const opts = { + preset: L.preset || 'gameui', + height: L.height ?? 2.5, + richText: !!L.richText, + // Крепление на грань (attachFace) — плашка = часть постройки, + // движется/вращается с объектом; иначе billboard над верхом. + attachFace: L.face || L.attachFace || null, + attachPoint: L.attachPoint || null, + faceMode: L.faceMode || ((L.face || L.attachFace) ? 'fixed' : null), + ...(L.style || {}), + }; + lm.setLabel('primitive:' + b.id, data.mesh, text, opts); + } + }; + tick(); + this._labelBindTimer = setInterval(tick, 1000); + } + + /** Вычислить текст подписи по типу биндинга. */ + _computeLabelText(L, data) { + const p = L.params || {}; + const fmt = (sec, f) => { + sec = Math.max(0, Math.floor(sec)); + const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60; + const p2 = (n) => String(n).padStart(2, '0'); + if (f === 'hh:mm:ss') return `${p2(h)}:${p2(m)}:${p2(s)}`; + if (f === 'mm:ss') return `${p2(Math.floor(sec / 60))}:${p2(s)}`; + if (h > 0) return `${h}ч ${m}м`; + if (m > 0) return `${m}м ${s}с`; + return `${s}с`; + }; + switch (L.binding) { + case 'timer': { + // params.duration сек; отсчёт от старта Play. params._end запоминаем. + if (!L._endTs) L._endTs = Date.now() + (Number(p.duration) || 60) * 1000; + const sec = (L._endTs - Date.now()) / 1000; + return (p.prefix || '') + fmt(sec, p.format || 'mm:ss') + (p.suffix || ''); + } + case 'save': { + // Локальный кеш save (обновляется при save.set в Worker → через snapshot). + const v = (this._saveCache && this._saveCache[p.key]) ?? p.default ?? 0; + return (p.prefix || '') + v + (p.suffix || ''); + } + case 'hp': { + const hp = Math.round(data.hp ?? data.maxHp ?? 100); + const max = Math.round(data.maxHp ?? 100); + return (p.prefix || 'HP: ') + hp + '/' + max; + } + case 'formula': + return String(p.text || ''); + case 'static': + default: + return String(p.text || L.text || ''); + } + } + + _stopLabelBindings() { + if (this._labelBindTimer) { clearInterval(this._labelBindTimer); this._labelBindTimer = null; } + this._labelBinds = null; } /** Задача 03: запустить пресеты анимаций для GUI-элементов в Play. */ @@ -341,6 +439,7 @@ export class GameRuntime { this._cleanupSpawnedGui(); // Убираем billboard-метки над объектами (game.scene.setLabel). try { + this._stopLabelBindings(); // задача 10: остановить авто-биндинги подписей if (this.scene3d?._labelManager) this.scene3d._labelManager.clearAll(); } catch (e) { /* ignore */ } // Phase 6.5: освобождаем физ-мир и его wasm-память. @@ -2494,14 +2593,29 @@ export class GameRuntime { this._applySelfMove(payload); return; } + // scene.move {ref, x, y, z} — переместить объект (примитив/модель/userModel) + // из obj.move()/game.scene.move(). Без этого obj.move крашился «unknown cmd». + if (cmd === 'scene.move') { + try { + const target = this._refStrToTarget(payload?.ref); + if (target) this._applySelfMove({ target, x: payload.x, y: payload.y, z: payload.z }); + } catch (e) { + console.warn('[GameRuntime] scene.move failed', e); + } + return; + } if (cmd === 'scene.rotate') { try { const ry = Number(payload?.rotationY); if (!Number.isFinite(ry)) return; + // kind СНАЧАЛА — иначе _resolvePrimitiveId('model:1')→1 совпадёт с + // чужим примитивом id=1 (баг: модель витрины не крутилась). + const isModel = payload?.kind === 'model' + || (typeof payload?.ref === 'string' && payload.ref.indexOf('model:') === 0); + // 1) Примитив — только если НЕ модель. const pm = this.scene3d?.primitiveManager; - if (!pm) return; - const rid = this._resolvePrimitiveId(payload?.id); - const data = rid != null ? pm.instances.get(rid) : null; + const rid = (!isModel && pm) ? this._resolvePrimitiveId(payload?.id ?? payload?.ref) : null; + const data = (pm && rid != null) ? pm.instances.get(rid) : null; if (data) { data.rotationY = ry; if (data.mesh?.rotation) { @@ -2511,9 +2625,28 @@ export class GameRuntime { data._worldMatrixFrozen = false; } } + return; + } + // 2) Модель — вращаем rootMesh (rotation = новый Vector3 после unfreeze). + if (isModel && this.scene3d?.modelManager) { + const mm = this.scene3d.modelManager; + let mid = payload?.id; + if (mid == null && typeof payload?.ref === 'string') mid = payload.ref.slice(payload.ref.indexOf(':') + 1); + let md = mm.instances.get(mid); + if (!md && typeof mid === 'string') { const n = Number(mid); if (Number.isFinite(n)) md = mm.instances.get(n); } + if (md) { + md.rotationY = ry; + const root = md.rootMesh || md.rootNode; + if (root) { + if (md._worldMatrixFrozen) { + try { root.unfreezeWorldMatrix?.(); } catch (e) {} + if (Array.isArray(md.meshes)) for (const m of md.meshes) { try { m?.unfreezeWorldMatrix?.(); } catch (e) {} } + md._worldMatrixFrozen = false; + } + root.rotation = new Vector3(0, ry, 0); + } + } } - // snapshot не шлём — объект всё ещё findOne'ится по тому же ref'у, - // только rotationY обновился, для скрипта это прозрачно. } catch (e) { // eslint-disable-next-line no-console console.warn('[GameRuntime] scene.rotate failed', e); @@ -3555,6 +3688,10 @@ export class GameRuntime { } else if (kind === 'primitive') { this.scene3d?.primitiveManager?.removeInstance(Number(rest)); } + // Задача 10: снять плашку удалённого объекта (иначе висит сиротой). + if (this.scene3d?._labelManager) { + try { this.scene3d._labelManager.clearLabel(ref); } catch (e) { /* ignore */ } + } // Удалили — снимаем mapping for (const [k, v] of (this._localToReal || new Map()).entries()) { if (v === ref) this._localToReal.delete(k); @@ -3684,6 +3821,23 @@ export class GameRuntime { return { blocks, models, primitives }; } + // Разобрать ref-строку ('primitive:N' / 'model:N' / 'block:x,y,z') в target + // {kind, id|ref} для переиспользования _applySelfMove (scene.move). + _refStrToTarget(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 === 'block') { + const p = rest.split(',').map(Number); + if (p.length === 3 && p.every(Number.isFinite)) return { kind: 'block', ref: { x: p[0], y: p[1], z: p[2] } }; + return null; + } + if (kind === 'primitive' || kind === 'model' || kind === 'userModel') return { kind, id: rest, ref: rest }; + return null; + } + _applySelfMove(payload) { if (!payload || !payload.target) return; const t = payload.target; diff --git a/src/editor/engine/LabelManager.js b/src/editor/engine/LabelManager.js index 39e196a..5ce9783 100644 --- a/src/editor/engine/LabelManager.js +++ b/src/editor/engine/LabelManager.js @@ -1,80 +1,385 @@ /** - * LabelManager — billboard-метки (текст-плашки) над 3D-объектами. + * LabelManager — billboard-плашки (текст-надписи) над 3D-объектами. * - * Используется для game.scene.setLabel(ref, text) — имена/HP над - * персонажами, врагами, предметами. Метка всегда повёрнута лицом к камере - * (billboardMode=7), всегда поверх геометрии (renderingGroupId=1). + * game.scene.setLabel(ref, text, opts) — имена/HP/таймеры/счётчики над + * персонажами, врагами, предметами. По умолчанию плашка повёрнута лицом к + * камере (billboardMode=7), всегда поверх геометрии (renderingGroupId=1). * - * Метка привязывается к мешу объекта (parent) и висит над ним. + * Задача 10 — расширенные стили: фон/обводка/скругление (пресеты gameui/ + * warning/reward/boss-hp/plain), обводка текста, richText (//), + * faceMode billboard|fixed, attachPoint, maxDistance. + * + * Плашка привязывается к мешу объекта (parent) и висит над ним. */ import { DynamicTexture } from '@babylonjs/core/Materials/Textures/dynamicTexture'; import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial'; import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder'; import { Color3 } from '@babylonjs/core/Maths/math.color'; +import { Mesh } from '@babylonjs/core/Meshes/mesh'; + +// === Пресеты стилей плашки (фон/обводка/текст) === +// gameui — жёлтая обводка + тёмно-синий фон + белый текст (как в Roblox-UI). +export const LABEL_PRESETS = { + plain: { + background: null, borderColor: null, borderWidth: 0, cornerRadius: 0, + color: '#ffffff', textStroke: { color: '#000', width: 8 }, + }, + gameui: { + background: '#1a3a8a', borderColor: '#f5c020', borderWidth: 10, cornerRadius: 28, + color: '#ffffff', textStroke: { color: '#0a1430', width: 6 }, + }, + warning: { + background: '#b01e1e', borderColor: '#ffce3a', borderWidth: 10, cornerRadius: 28, + color: '#ffffff', textStroke: { color: '#000', width: 6 }, + }, + reward: { + background: '#caa018', borderColor: '#fff0a0', borderWidth: 10, cornerRadius: 28, + color: '#fff8e0', textStroke: { color: '#6b4e00', width: 6 }, + gradient: ['#f7d34a', '#c98a18'], // золотой градиент фона + }, + 'boss-hp': { + background: '#3a0a0a', borderColor: '#ff4040', borderWidth: 8, cornerRadius: 20, + color: '#ffd0d0', textStroke: { color: '#000', width: 6 }, + gradient: ['#8a1414', '#3a0a0a'], + }, +}; export class LabelManager { constructor(scene) { this.scene = scene; - // ref-строка объекта → { plane, tex, mat } + // ref-строка объекта → { plane, tex, mat, lastKey, opts } this.labels = new Map(); + this._playerMesh = null; // для maxDistance — задаётся из BabylonScene } + /** Дать ссылку на меш игрока (для maxDistance-скрытия). */ + setPlayerMesh(mesh) { this._playerMesh = mesh; } + /** - * Установить/обновить метку над объектом. - * ref — ref-строка объекта (от scene.spawn / scene.find). - * anchorMesh — Babylon-меш объекта (метка крепится к нему). - * text — текст метки. - * opts — { color: '#fff', height: 2.5 (м над объектом), size: 1 } + * Установить/обновить плашку над объектом. + * ref — ref-строка объекта. + * anchorMesh — Babylon-меш объекта (плашка крепится к нему). + * text — текст (может содержать richText-теги если opts.richText). + * opts — см. LABEL_PRESETS + { color, height, size, background, + * borderColor, borderWidth, cornerRadius, padding, textStroke, + * fontWeight, faceMode, rotationY, attachPoint, preset, + * richText, maxDistance } */ setLabel(ref, anchorMesh, text, opts = {}) { if (!anchorMesh) return; - const color = opts.color || '#ffffff'; + text = String(text == null ? '' : text); + + // Пресет → база, поверх — явные opts. + const preset = opts.preset && LABEL_PRESETS[opts.preset] ? LABEL_PRESETS[opts.preset] : null; + const st = { ...(preset || {}), ...opts }; + const color = st.color || '#ffffff'; const heightAbove = Number.isFinite(opts.height) ? opts.height : 2.5; const sizeMul = Number.isFinite(opts.size) ? opts.size : 1; + const richText = !!opts.richText; + + // Диф-чек: если ничего не поменялось — не пересоздаём (важно для bindLabel). + const styleKey = JSON.stringify({ text, color, preset: opts.preset, bg: st.background, + bc: st.borderColor, bw: st.borderWidth, cr: st.cornerRadius, rich: richText, + fw: st.fontWeight, h: heightAbove, sz: sizeMul, fm: st.faceMode, ry: st.rotationY, + af: st.attachFace, tl: st.tilt, ap: typeof st.attachPoint === 'string' ? st.attachPoint : null }); + const existing = this.labels.get(ref); + if (existing && existing.lastKey === styleKey && existing.plane.parent === anchorMesh) { + return; // ничего не изменилось + } + + // Меняется только текст (тот же стиль/размер) → перерисуем canvas без + // пересоздания меша (дешевле). Иначе — полное пересоздание. + const sameStruct = existing && existing.styleStruct === this._structKey(st, richText, heightAbove, sizeMul); + if (sameStruct) { + this._drawCanvas(existing.tex, text, color, st, richText); + existing.tex.update(true); + existing.lastKey = styleKey; + existing.lastText = text; + return; + } - // Если метка уже есть — пересоздаём (текст/цвет могли измениться). this.clearLabel(ref); + // Размер текстуры: чем больше текста — тем шире, чтобы не растягивать. + const fontPx = 120; const W = 1024, H = 256; - const tex = new DynamicTexture(`lblTex_${ref}_${Date.now()}`, + const tex = new DynamicTexture(`lblTex_${ref}_${this._uid()}`, { width: W, height: H }, this.scene, true); tex.updateSamplingMode?.(3); // TRILINEAR tex.anisotropicFilteringLevel = 8; - const ctx = tex.getContext(); - ctx.clearRect(0, 0, W, H); - ctx.font = 'bold 120px "Roboto Condensed", "Segoe UI", sans-serif'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.lineWidth = 16; - ctx.lineJoin = 'round'; - ctx.strokeStyle = '#000'; - ctx.strokeText(String(text), W / 2, H / 2); - ctx.fillStyle = color; - ctx.fillText(String(text), W / 2, H / 2); - tex.update(true); tex.hasAlpha = true; + this._drawCanvas(tex, text, color, st, richText); + tex.update(true); + // Соотношение плашки — 4:1 (1024×256). Крупная и читаемая (как в Roblox). + // ОДНОСТОРОННЯЯ плоскость (FRONTSIDE): лицо = нормаль +Z. Мы всегда + // разворачиваем плашку лицом к наблюдателю снаружи грани, поэтому текст + // читается прямо. DOUBLESIDE НЕ используем — задняя грань зеркалит UV + // (баг: «Ключ от тайника» наоборот). backFaceCulling выключен только + // чтобы плашка не пропадала при взгляде сзади (без отражённого текста). const plane = MeshBuilder.CreatePlane(`lbl_${ref}`, - { width: 2.2 * sizeMul, height: 0.55 * sizeMul }, this.scene); + { width: 3.4 * sizeMul, height: 0.85 * sizeMul, + sideOrientation: Mesh.FRONTSIDE }, this.scene); const mat = new StandardMaterial(`lblMat_${ref}`, this.scene); mat.diffuseTexture = tex; mat.diffuseTexture.hasAlpha = true; mat.emissiveColor = new Color3(1, 1, 1); + mat.diffuseColor = new Color3(0, 0, 0); mat.disableLighting = true; + // Геометрия двусторонняя (DOUBLESIDE) с прямыми backUVs — culling можно + // включить, дублей нет; текст читается с обеих сторон без зеркала. mat.backFaceCulling = false; mat.disableDepthWrite = true; + mat.useAlphaFromDiffuseTexture = true; plane.material = mat; - plane.billboardMode = 7; // всегда лицом к камере - plane.renderingGroupId = 1; // поверх геометрии + plane.renderingGroupId = 1; plane.isPickable = false; - // Крепим к объекту: метка висит над ним и двигается вместе с ним. plane.parent = anchorMesh; - plane.position.set(0, heightAbove, 0); - this.labels.set(ref, { plane, tex, mat }); + // Полуразмеры объекта по каждой оси (для крепления над верхом ИЛИ на + // грань). Берём bounding box в ЛОКАЛЬНЫХ координатах меша (min/max), чтобы + // позиция плашки-ребёнка была верной при любом масштабе/вращении родителя. + let halfX = 0.5, halfY = 0.5, halfZ = 0.5; + try { + const bb = anchorMesh.getBoundingInfo?.().boundingBox; + if (bb && bb.minimum && bb.maximum) { + halfX = (bb.maximum.x - bb.minimum.x) / 2; + halfY = (bb.maximum.y - bb.minimum.y) / 2; + halfZ = (bb.maximum.z - bb.minimum.z) / 2; + } else if (anchorMesh.scaling) { + halfX = Math.abs(anchorMesh.scaling.x) / 2; + halfY = Math.abs(anchorMesh.scaling.y) / 2; + halfZ = Math.abs(anchorMesh.scaling.z) / 2; + } + } catch (e) { /* ignore */ } + const halfH = halfY; + const halfPlane = 0.45 * sizeMul; // полувысота самой плашки (3.4×0.85) + + // attachFace — ПРИКРЕПИТЬ плашку НА ГРАНЬ объекта (как табличка на + // стене/полу): плашка лежит В ПЛОСКОСТИ грани, фиксированной ориентации, + // и перемещается/вращается ВМЕСТЕ с объектом (она его ребёнок). Это + // Roblox-style «надпись = часть постройки» (в отличие от billboard над + // верхом). Значения: 'top'|'bottom'|'front'|'back'|'left'|'right' + // (или сырые '+y'/'-y'/'+z'/'-z'/'+x'/'-x'). + const FACE = { top: '+y', bottom: '-y', front: '+z', back: '-z', + right: '+x', left: '-x' }; + let face = st.attachFace; + if (face && FACE[face]) face = FACE[face]; + + if (face) { + // На грань — всегда фиксированная ориентация (не billboard), иначе + // «связки с примитивом» не будет (плашка крутилась бы к камере). + plane.billboardMode = 0; + const gap = Number.isFinite(opts.height) ? opts.height : 0.05; + // ВАЖНО: Babylon CreatePlane (FRONTSIDE) лицевой стороной (где UV/текст + // не зеркалятся) смотрит в −Z. Поэтому чтобы ЛИЦО таблички смотрело + // НАРУЖУ выбранной грани, поворачиваем плоскость так, чтобы её −Z + // совпал с внешней нормалью грани. tiltSign — знак наклона tilt с + // учётом того, что для грани +z плоскость развёрнута на π. + let tiltSign = 1; + if (face === '+z') { plane.position.set(0, 0, halfZ + gap); plane.rotation.set(0, Math.PI, 0); tiltSign = -1; } + else if (face === '-z') { plane.position.set(0, 0, -(halfZ + gap)); plane.rotation.set(0, 0, 0); } + else if (face === '+x') { plane.position.set( halfX + gap, 0, 0); plane.rotation.set(0, -Math.PI / 2, 0); } + else if (face === '-x') { plane.position.set(-(halfX + gap), 0, 0); plane.rotation.set(0, Math.PI / 2, 0); } + else if (face === '+y') { plane.position.set(0, halfY + gap, 0); plane.rotation.set( Math.PI / 2, 0, 0); } + else if (face === '-y') { plane.position.set(0, -(halfY + gap), 0); plane.rotation.set(-Math.PI / 2, 0, 0); } + if (Number.isFinite(st.rotationY)) plane.rotation.y += st.rotationY; + // tilt — наклон таблички вокруг локальной X (ценник под ~45°, как на + // витрине Roblox). Знак нормализуем (tiltSign), чтобы «верх назад» был + // одинаковым для всех граней. Отрицательный tilt = верх отклоняется + // назад (от наблюдателя), как пюпитр. + if (Number.isFinite(st.tilt)) plane.rotation.x += st.tilt * tiltSign; + } else { + // faceMode: 'fixed' — фиксированная ориентация (вращается с объектом), + // но позиционируется как обычная плашка (над верхом/центром/низом). + if (st.faceMode === 'fixed') { + plane.billboardMode = 0; + if (Number.isFinite(st.rotationY)) plane.rotation.y = st.rotationY; + } else { + plane.billboardMode = 7; // всегда лицом к камере + } + // attachPoint: 'top'(default) — над верхом + небольшой зазор (height); + // 'center' — по центру; 'bottom' — у низа; {x,y,z} — явно. + const gap = Number.isFinite(opts.height) ? opts.height : 0.6; + let py = halfH + gap + halfPlane; // верх объекта + зазор + полувысота плашки + if (st.attachPoint === 'center') py = 0; + else if (st.attachPoint === 'bottom') py = -(halfH + gap); + else if (st.attachPoint && typeof st.attachPoint === 'object') { + plane.position.set(st.attachPoint.x || 0, st.attachPoint.y || 0, st.attachPoint.z || 0); + py = null; + } + if (py !== null) plane.position.set(0, py, 0); + } + + this.labels.set(ref, { + plane, tex, mat, + lastKey: styleKey, + lastText: text, + styleStruct: this._structKey(st, richText, heightAbove, sizeMul), + maxDistance: Number.isFinite(opts.maxDistance) ? opts.maxDistance : null, + }); } - /** Убрать метку с объекта. */ + /** Ключ «структуры» (всё кроме текста) — для решения перерисовать ли canvas. */ + _structKey(st, richText, h, sz) { + return JSON.stringify({ p: st.preset, bg: st.background, bc: st.borderColor, + bw: st.borderWidth, cr: st.cornerRadius, rich: richText, fw: st.fontWeight, + grad: st.gradient, ts: st.textStroke, h, sz, fm: st.faceMode, + af: st.attachFace, tl: st.tilt, ap: typeof st.attachPoint === 'string' ? st.attachPoint : null }); + } + + _uid() { this._seq = (this._seq || 0) + 1; return this._seq; } + + /** + * Нарисовать плашку на canvas DynamicTexture. + * Фон (roundRect + gradient/fill) → обводка border → текст (с обводкой). + */ + _drawCanvas(tex, text, color, st, richText) { + const W = 1024, H = 256; + const ctx = tex.getContext(); + ctx.clearRect(0, 0, W, H); + + const hasBg = !!st.background || (Array.isArray(st.gradient) && st.gradient.length === 2); + const pad = Number.isFinite(st.padding) ? st.padding : 28; + const cr = Number.isFinite(st.cornerRadius) ? st.cornerRadius : 0; + const bw = Number.isFinite(st.borderWidth) ? st.borderWidth : 0; + + const weight = st.fontWeight || 700; + const innerPad = (hasBg ? (bw + pad) : 24); // отступ от краёв (под рамку) + const maxTextW = W - innerPad * 2; + // Auto-fit: подбираем размер шрифта, чтобы текст влез по ширине (не обрезался). + let fontPx = 120; + if (!richText) { + ctx.font = `${weight} ${fontPx}px "Roboto Condensed", "Segoe UI", sans-serif`; + const tw = ctx.measureText(text).width; + if (tw > maxTextW) fontPx = Math.max(40, Math.floor(fontPx * maxTextW / tw)); + } + ctx.font = `${weight} ${fontPx}px "Roboto Condensed", "Segoe UI", sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + // === Фон-плашка === + if (hasBg) { + const m = bw / 2 + 4; // отступ рамки от края текстуры + const x = m, y = m, w = W - m * 2, h = H - m * 2; + this._roundRectPath(ctx, x, y, w, h, cr); + if (Array.isArray(st.gradient) && st.gradient.length === 2) { + const g = ctx.createLinearGradient(0, y, 0, y + h); + g.addColorStop(0, st.gradient[0]); + g.addColorStop(1, st.gradient[1]); + ctx.fillStyle = g; + } else { + ctx.fillStyle = st.background; + } + ctx.globalAlpha = Number.isFinite(st.backgroundOpacity) ? st.backgroundOpacity : 0.92; + ctx.fill(); + ctx.globalAlpha = 1; + if (bw > 0 && st.borderColor) { + ctx.lineWidth = bw; + ctx.strokeStyle = st.borderColor; + ctx.stroke(); + } + } + + // === Текст === + const ts = st.textStroke || { color: '#000', width: hasBg ? 6 : 10 }; + if (richText) { + this._drawRichText(ctx, text, color, ts, W, H); + } else { + if (ts && ts.width > 0) { + ctx.lineWidth = ts.width; + ctx.lineJoin = 'round'; + ctx.strokeStyle = ts.color || '#000'; + ctx.strokeText(text, W / 2, H / 2 + 4); + } + ctx.fillStyle = color; + ctx.fillText(text, W / 2, H / 2 + 4); + } + } + + /** Путь скруглённого прямоугольника (roundRect не везде есть). */ + _roundRectPath(ctx, x, y, w, h, r) { + r = Math.min(r, w / 2, h / 2); + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); + } + + /** + * RichText: парсим теги ..., ..., .... + * Рисуем сегментами по горизонтали, центрируя всю строку. Вложенность не + * поддерживается (на MVP) — берём последний открытый тег каждого типа. + */ + _drawRichText(ctx, text, baseColor, ts, W, H) { + const segs = this._parseRich(text, baseColor); + const fontPx = 120; + // Замер ширины каждого сегмента в его размере. + let total = 0; + for (const s of segs) { + ctx.font = `${s.bold ? 800 : 700} ${Math.round(fontPx * s.sizeMul)}px "Roboto Condensed","Segoe UI",sans-serif`; + s.w = ctx.measureText(s.text).width; + total += s.w; + } + let x = (W - total) / 2; + for (const s of segs) { + ctx.font = `${s.bold ? 800 : 700} ${Math.round(fontPx * s.sizeMul)}px "Roboto Condensed","Segoe UI",sans-serif`; + ctx.textAlign = 'left'; + if (ts && ts.width > 0) { + ctx.lineWidth = ts.width; + ctx.lineJoin = 'round'; + ctx.strokeStyle = ts.color || '#000'; + ctx.strokeText(s.text, x, H / 2 + 4); + } + ctx.fillStyle = s.color; + ctx.fillText(s.text, x, H / 2 + 4); + x += s.w; + } + ctx.textAlign = 'center'; + } + + /** Простой парсер richText → [{text, color, bold, sizeMul}]. */ + _parseRich(text, baseColor) { + const segs = []; + let color = baseColor, bold = false, sizeMul = 1; + // Разбиваем по тегам (открывающим/закрывающим). + const re = /<(\/?)(?:(color)=([#0-9a-fA-F]+)|(b)|(i)|(size)=(\d+))>|([^<]+)/g; + let m; + while ((m = re.exec(text)) !== null) { + const closing = m[1] === '/'; + if (m[8] != null) { + // текстовый кусок + if (m[8]) segs.push({ text: m[8], color, bold, sizeMul }); + } else if (m[2]) { // + color = closing ? baseColor : m[3]; + } else if (m[4]) { // + bold = !closing; + } else if (m[6]) { // + sizeMul = closing ? 1 : Math.max(0.4, Math.min(2, Number(m[7]) / 100)); + } + // игнорим визуально (italic в canvas через font-style — опускаем на MVP) + } + if (segs.length === 0) segs.push({ text: '', color: baseColor, bold: false, sizeMul: 1 }); + return segs; + } + + /** Каждый кадр: maxDistance-скрытие плашек дальше порога от игрока. */ + update() { + if (!this._playerMesh) return; + const pp = this._playerMesh.position; + for (const rec of this.labels.values()) { + if (rec.maxDistance == null) continue; + const ap = rec.plane.getAbsolutePosition(); + const dx = ap.x - pp.x, dy = ap.y - pp.y, dz = ap.z - pp.z; + const far = (dx * dx + dy * dy + dz * dz) > rec.maxDistance * rec.maxDistance; + rec.plane.setEnabled(!far); + } + } + + /** Убрать плашку с объекта. */ clearLabel(ref) { const rec = this.labels.get(ref); if (!rec) return; @@ -84,7 +389,7 @@ export class LabelManager { this.labels.delete(ref); } - /** Удалить все метки (при выходе из Play). */ + /** Удалить все плашки (при выходе из Play). */ clearAll() { for (const ref of [...this.labels.keys()]) this.clearLabel(ref); } diff --git a/src/editor/engine/PrimitiveManager.js b/src/editor/engine/PrimitiveManager.js index b666569..76c60a6 100644 --- a/src/editor/engine/PrimitiveManager.js +++ b/src/editor/engine/PrimitiveManager.js @@ -40,12 +40,30 @@ function _getStudsTextures(scene) { let c = _studsTexCache.get(scene); if (!c) { const diffuse = new Texture(STUDS_DIFFUSE_URL, scene); - const normal = new Texture(STUDS_NORMAL_URL, scene); - c = { diffuse, normal }; + // base — uScale=1 (для cube/trigger: тайлинг через faceUV геометрии). + // tiled — кэш клонов по ключу 'u_v' для не-кубических форм, чтобы НЕ + // плодить по текстуре на каждый меш (был источник FPS-просадки: десятки + // клонов diffuse+normal = десятки GPU-ресурсов + дорогой bump на каждом). + c = { diffuse, tiled: new Map() }; _studsTexCache.set(scene, c); } return c; } +// Общая diffuse-текстура с заданным тайлингом (u,v). Клон создаётся ОДИН раз +// на пару (u,v) и переиспользуется всеми мешами с тем же тайлингом. +function _getStudsTiledTexture(scene, u, v) { + const c = _getStudsTextures(scene); + // округляем до 0.05 — близкие тайлинги шарят одну текстуру + const ru = Math.round(u * 20) / 20, rv = Math.round(v * 20) / 20; + const key = ru + '_' + rv; + let t = c.tiled.get(key); + if (!t) { + t = c.diffuse.clone(); + t.uScale = ru; t.vScale = rv; + c.tiled.set(key, t); + } + return t; +} /** * Посчитать тайлинг (uScale/vScale) для studs по размеру меша. Чтобы кружки не * растягивались: число кружков на грань = размер_грани / STUD_UNIT, делённое на @@ -180,6 +198,8 @@ export class PrimitiveManager { rotationX, rotationY, rotationZ, color, material, canCollide, visible, anchored, mass, textureAsset, studDensity, + // Подпись над объектом (задача 10) — восстанавливается из project_data. + label: opts.label || null, // locked — объект защищён от выделения/перемещения в редакторе // (Фаза 5.11). На геймплей не влияет. locked: opts.locked === true, @@ -519,22 +539,21 @@ export class PrimitiveManager { // Лего-материал: почти белая diffuse-текстура с лёгкими кружками // умножается на цвет меша → цвет остаётся СОЧНЫМ (как в Roblox). // emissive = доля цвета → цвет «светится», не тускнеет в тени. - const tex = _getStudsTextures(this.scene); - const dt = tex.diffuse.clone(); - const nt = tex.normal.clone(); + // Объём студов запечён в diffuse v4 (baked-тени) — bumpTexture НЕ + // используем: normal-mapping удваивает стоимость шейдера на каждом + // меше и почти не виден на маленьких студах. Текстуры ШАРЯТСЯ + // (общая для cube, кэш-клон по тайлингу для форм) — без этого + // десятки клонов роняли FPS. const dims = mesh._studsDims || { type: 'cube', sx: 1, sy: 1, sz: 1 }; - // Куб/триггер тайлятся через faceUV геометрии (uScale=1) — кружки - // одного размера на всех гранях. Остальные формы — через uScale. + let dt; if (dims.type === 'cube' || dims.type === 'trigger') { - dt.uScale = nt.uScale = 1; - dt.vScale = nt.vScale = 1; + // uScale=1 — тайлинг через faceUV геометрии. Общая текстура. + dt = _getStudsTextures(this.scene).diffuse; } else { const tile = _studsTiling(dims.type, dims.sx, dims.sy, dims.sz, dims.density); - dt.uScale = nt.uScale = tile.u; - dt.vScale = nt.vScale = tile.v; + dt = _getStudsTiledTexture(this.scene, tile.u, tile.v); } mat.diffuseTexture = dt; - mat.bumpTexture = nt; const sc = Color3.FromHexString(color || '#cccccc'); mat.diffuseColor = sc; // Сочность: подмешиваем цвет в emissive (45%) — Roblox-look, @@ -829,6 +848,14 @@ export class PrimitiveManager { } } + // Задача 10: подпись над объектом (label) — редактируется в инспекторе, + // сериализуется, при Play создаётся биндинг без скрипта. + // data.label = { enabled, binding:'static'|'timer'|'save'|'hp'|'formula', + // params:{...}, preset, height }. + if (patch.label !== undefined) { + data.label = patch.label || null; + } + this._notifyChange(); } @@ -917,6 +944,8 @@ export class PrimitiveManager { ...(d.textureAsset ? { textureAsset: d.textureAsset } : {}), // Плотность studs (если не 1) — мелкие/крупные кружки. ...(d.studDensity && d.studDensity !== 1 ? { studDensity: d.studDensity } : {}), + // Подпись над объектом (задача 10) — если включена. + ...(d.label && d.label.enabled ? { label: d.label } : {}), // Параметры лампы (только для type='light', иначе undefined) ...(d.light ? { brightness: d.brightness, range: d.range } : {}), // Параметр эмиттера (только для type='emitter') diff --git a/src/editor/engine/ScriptSandboxWorker.js b/src/editor/engine/ScriptSandboxWorker.js index ab0ceff..6cfc444 100644 --- a/src/editor/engine/ScriptSandboxWorker.js +++ b/src/editor/engine/ScriptSandboxWorker.js @@ -272,6 +272,10 @@ const _tweenCallbacks = {}; // Тикаются в обработчике cmd='tick' по накоплению dt. let _timers = []; let _timerSeq = 0; +// Биндинги лейблов (задача 10): ref → { timerId, lastText }. bindLabel/bindTimer +// создают повторяющийся таймер, который зовёт fn() и шлёт setLabel при изменении +// текста. Автоотменяются при scene.delete(ref) (через instTouch/destroying). +const _labelBindings = new Map(); // Зеркало всех объектов сцены (заполняется main через cmd='sceneSnapshot' каждый раз // когда что-то меняется). Это позволяет game.scene.find/all/get работать синхронно. // { blocks: [{ref, type, x, y, z, name?}], models: [...], primitives: [...] } @@ -473,6 +477,13 @@ function _getOrCreateInstance(ref, kindHint) { 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 }); + // obj.rotate(ry) — поворот объекта вокруг Y (рад). Для примитивов И + // моделей (kind берётся из ref). Нужно вращающимся предметам витрины. + if (prop === 'rotate' || prop === 'rotateY') return (ry) => { + const colon = ref.indexOf(':'); + if (colon < 0) return; + _send('scene.rotate', { ref, kind: ref.slice(0, colon), id: ref.slice(colon + 1), rotationY: Number(ry) || 0 }); + }; if (prop === 'setLabel') return (text, o) => _send('scene.setLabel', { ref, text, opts: o || {} }); if (prop === 'clearLabel') return () => _send('scene.clearLabel', { ref }); @@ -594,6 +605,13 @@ function _emitInstDestroying(ref) { _instEvents.delete(ref); _instCache.delete(ref); _instLastValues.delete(ref); + // Задача 10: снять биндинг лейбла удалённого объекта (иначе таймер утечёт). + const b = _labelBindings.get(ref); + if (b) { + const i = _timers.findIndex(t => t.id === b.timerId); + if (i >= 0) _timers.splice(i, 1); + _labelBindings.delete(ref); + } } /** @@ -1679,8 +1697,74 @@ const game = { clearLabel(ref) { ref = _normRef(ref); if (!ref) return; + this.unbindLabel(ref); _send('scene.clearLabel', { ref }); }, + /** + * Привязать плашку к функции — текст обновляется автоматически раз в + * interval секунд (по умолчанию 0.5). setLabel шлётся только при + * ИЗМЕНЕНИИ текста (диф-чек). Биндинг автоотменяется при clearLabel/ + * scene.delete(ref). Возвращает ref (для unbindLabel). + * game.scene.bindLabel(chest, () => game.save_cache.coins + ' монет', + * { preset: 'gameui', interval: 0.5 }); + */ + bindLabel(ref, fn, opts) { + ref = _normRef(ref); + if (!ref || typeof fn !== 'function') return null; + opts = opts || {}; + this.unbindLabel(ref); // снять прошлый биндинг этого объекта + const interval = Number.isFinite(opts.interval) && opts.interval > 0 ? opts.interval : 0.5; + const sceneApi = this; + const tick = () => { + let txt; + try { txt = String(fn() == null ? '' : fn()); } + catch (e) { return; } + const b = _labelBindings.get(ref); + if (b && b.lastText === txt) return; // диф-чек: текст не изменился + if (b) b.lastText = txt; + _send('scene.setLabel', { ref, text: txt, opts }); + }; + const id = ++_timerSeq; + _timers.push({ id, fn: tick, delay: interval, elapsed: interval, repeat: true }); + _labelBindings.set(ref, { timerId: id, lastText: null }); + tick(); // сразу первый рендер (не ждать interval) + return ref; + }, + /** Снять биндинг плашки (остановить автообновление). */ + unbindLabel(ref) { + ref = _normRef(ref); + if (!ref) return; + const b = _labelBindings.get(ref); + if (!b) return; + const i = _timers.findIndex(t => t.id === b.timerId); + if (i >= 0) _timers.splice(i, 1); + _labelBindings.delete(ref); + }, + /** + * Плашка-таймер обратного отсчёта до endTs (мс epoch). Формат hh:mm:ss/ + * mm:ss/auto. onEnd зовётся при достижении 0. + * game.scene.bindTimer(tower, Date.now()+16*60*1000, + * { format:'mm:ss', prefix:'Сбросится через ', preset:'gameui', + * onEnd: () => game.log('сброс!') }); + */ + bindTimer(ref, endTs, opts) { + ref = _normRef(ref); + if (!ref) return null; + opts = opts || {}; + const end = Number(endTs); + const fmt = opts.format || 'auto'; + const prefix = opts.prefix || ''; + const suffix = opts.suffix || ''; + let ended = false; + return this.bindLabel(ref, () => { + const sec = Math.max(0, Math.floor((end - Date.now()) / 1000)); + if (sec <= 0 && !ended) { + ended = true; + if (typeof opts.onEnd === 'function') { try { opts.onEnd(); } catch (e) {} } + } + return prefix + game.format.time(sec, fmt) + suffix; + }, { interval: opts.interval || 1, ...opts }); + }, /** * Прозрачность примитива: 1 = непрозрачно, 0 = полностью невидимо. * Для плавного исчезновения используй game.tween(ref, {opacity: 0}, ...). @@ -3429,6 +3513,79 @@ const game = { return a + Math.random() * (b - a); }, + /** + * Хелперы форматирования (задача 10) — для счётчиков/таймеров в плашках. + * game.format.time(959, 'mm:ss') → "15:59" + * game.format.time(57555, 'hh:mm:ss') → "15:59:15" + * game.format.time(959, 'auto') → "15м 59с" + * game.format.number(1234567, 'short')→ "1.2M" + * game.format.number(1234567, 'comma')→ "1 234 567" + * game.format.number(0.42, 'percent') → "42%" + * game.format.money(199) → "199 рубликов" + * game.format.duration(3600) → "1 час" + */ + format: { + time(seconds, fmt) { + let sec = Math.max(0, Math.floor(Number(seconds) || 0)); + const h = Math.floor(sec / 3600); + const m = Math.floor((sec % 3600) / 60); + const s = sec % 60; + const p2 = (n) => String(n).padStart(2, '0'); + if (fmt === 'hh:mm:ss') return p2(h) + ':' + p2(m) + ':' + p2(s); + if (fmt === 'mm:ss') { + const tm = Math.floor(sec / 60); + return p2(tm) + ':' + p2(s); + } + // auto + if (h > 0) return h + 'ч ' + m + 'м'; + if (m > 0) return m + 'м ' + s + 'с'; + return s + 'с'; + }, + number(n, fmt) { + n = Number(n) || 0; + if (fmt === 'percent') return Math.round(n * 100) + '%'; + if (fmt === 'short') { + const abs = Math.abs(n); + if (abs >= 1e9) return (n / 1e9).toFixed(1).replace('.0', '') + 'B'; + if (abs >= 1e6) return (n / 1e6).toFixed(1).replace('.0', '') + 'M'; + if (abs >= 1e3) return (n / 1e3).toFixed(1).replace('.0', '') + 'K'; + return String(Math.round(n)); + } + // comma — пробелы-разделители тысяч (русский стиль), без regex. + const str = String(Math.abs(Math.round(n))); + let out = ''; + for (let i = 0; i < str.length; i++) { + if (i > 0 && (str.length - i) % 3 === 0) out += ' '; + out += str[i]; + } + return (n < 0 ? '-' : '') + out; + }, + money(amount, unit) { + const num = this.number(amount, 'comma'); + const u = (unit === 'rubles' || unit === undefined) + ? this._plural(Math.round(Number(amount) || 0), 'рублик', 'рублика', 'рубликов') + : unit; + return num + ' ' + u; + }, + duration(seconds) { + let sec = Math.max(0, Math.floor(Number(seconds) || 0)); + const h = Math.floor(sec / 3600); + const m = Math.floor((sec % 3600) / 60); + if (h > 0) return h + ' ' + this._plural(h, 'час', 'часа', 'часов'); + if (m > 0) return m + ' ' + this._plural(m, 'минута', 'минуты', 'минут'); + return sec + ' ' + this._plural(sec, 'секунда', 'секунды', 'секунд'); + }, + // Русское склонение числительных (1 рублик / 2 рублика / 5 рубликов). + _plural(n, one, few, many) { + n = Math.abs(n) % 100; + const n1 = n % 10; + if (n > 10 && n < 20) return many; + if (n1 > 1 && n1 < 5) return few; + if (n1 === 1) return one; + return many; + }, + }, + /** * Расстояние между двумя точками или объектами. * Аргументы могут быть {x,y,z} или ref-строкой (для ref берём позицию из sceneIndex). @@ -3971,6 +4128,7 @@ self.onmessage = (e) => { } else if (cmd === 'stop') { _tickHandlers = []; _timers = []; + _labelBindings.clear(); _selfClickHandlers = []; _selfTouchHandlers = []; _selfUntouchHandlers = []; diff --git a/src/editor/engine/SelectionManager.js b/src/editor/engine/SelectionManager.js index d1a19df..325b8aa 100644 --- a/src/editor/engine/SelectionManager.js +++ b/src/editor/engine/SelectionManager.js @@ -128,6 +128,8 @@ export class SelectionManager { range: data.range, effect: data.effect, textureAsset: data.textureAsset || null, + studDensity: data.studDensity || 1, + label: data.label || null, // подпись над объектом (задача 10) locked: !!data.locked, mesh: data.mesh, rootMesh: data.mesh, -- 2.47.2 From f928cd82bdd4d9dc400e88a137ab2039758ff188 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=98=D0=9D?= Date: Mon, 1 Jun 2026 21:41:14 +0300 Subject: [PATCH 2/5] =?UTF-8?q?ci:=20re-run=20=E2=80=94=20=D0=B4=D0=B6?= =?UTF-8?q?=D0=BE=D0=B1=D1=8B=20#55=20=D0=BF=D1=80=D0=B5=D1=80=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=BF=D1=80=D0=B8=20=D1=87=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D0=BA=D0=B5=20=D0=B4=D0=B8=D1=81=D0=BA=D0=B0=20runner=20(?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B0=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=B4=D0=B0;=20=D0=BB=D0=BE=D0=BA=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20build=20=D0=B7=D0=B5=D0=BB=D1=91=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 -- 2.47.2 From dfa64001f2e110edf49f73a8e5710d4d5a14e378 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=98=D0=9D?= Date: Mon, 1 Jun 2026 21:44:22 +0300 Subject: [PATCH 3/5] =?UTF-8?q?ci:=20re-run=20=D0=BF=D0=BE=D1=81=D0=BB?= =?UTF-8?q?=D0=B5=20restart=20runner=20(=D0=BA=D1=8D=D1=88=20actions=20/va?= =?UTF-8?q?r/run/act=20=D0=B1=D1=8B=D0=BB=20=D1=81=D0=BD=D0=B5=D1=81=D1=91?= =?UTF-8?q?=D0=BD=20=D1=87=D0=B8=D1=81=D1=82=D0=BA=D0=BE=D0=B9=20=D0=B4?= =?UTF-8?q?=D0=B8=D1=81=D0=BA=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 -- 2.47.2 From 16f816a015d54c9475829c951ab35afd31938b4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=98=D0=9D?= Date: Mon, 1 Jun 2026 21:53:49 +0300 Subject: [PATCH 4/5] =?UTF-8?q?ci:=20=D1=81=D0=B2=D0=B5=D0=B6=D0=B8=D0=B9?= =?UTF-8?q?=20sha=20=E2=80=94=20Gitea=20=D0=BD=D0=B5=20=D0=BE=D0=B1=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=B8=D0=BB=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81?= =?UTF-8?q?=20Lint=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D1=80=D0=B5=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D1=8F=20(=D0=BB=D0=BE=D0=B3=20job=3Dsucceeded,=20?= =?UTF-8?q?=D0=BD=D0=BE=20commit-status=20=D0=B7=D0=B0=D1=81=D1=82=D1=80?= =?UTF-8?q?=D1=8F=D0=BB=20=D0=BD=D0=B0=20failure)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 -- 2.47.2 From 3422ca2a037a8c54e2316bfa2ba3c64495655c7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=98=D0=9D?= Date: Mon, 1 Jun 2026 21:59:28 +0300 Subject: [PATCH 5/5] =?UTF-8?q?ci:=20re-run=20=D0=BF=D0=BE=D1=81=D0=BB?= =?UTF-8?q?=D0=B5=20=D0=BE=D1=87=D0=B8=D1=81=D1=82=D0=BA=D0=B8=20=D0=B1?= =?UTF-8?q?=D0=B8=D1=82=D0=BE=D0=B3=D0=BE=20=D0=BA=D1=8D=D1=88=D0=B0=20act?= =?UTF-8?q?ions=20runner=20(/root/.cache/act=20non-fast-forward=20update?= =?UTF-8?q?=20=D0=BB=D0=BE=D0=BC=D0=B0=D0=BB=20checkout=20dist)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 -- 2.47.2