Merge pull request 'feat(10): ����� 3D-������� (attachFace) + �������-������� + ���� ������ 10' (#21) from feat/studs-material-09 into main
This commit is contained in:
commit
e337abcbbc
@ -1,331 +1,336 @@
|
|||||||
/**
|
/**
|
||||||
* docsGames.js — каталог 50 мини-игр-уроков вики Рублокса.
|
* docsGames.js — каталог 50 мини-игр-уроков вики Рублокса.
|
||||||
*
|
*
|
||||||
* Раздел K вики. Каждая игра — карточка: { id, num, title, stars, icon,
|
* Раздел K вики. Каждая игра — карточка: { id, num, title, stars, icon,
|
||||||
* desc, mechanics, ready }.
|
* desc, mechanics, ready }.
|
||||||
* - num — порядковый номер 1..50
|
* - num — порядковый номер 1..50
|
||||||
* - stars — сложность 1..3
|
* - stars — сложность 1..3
|
||||||
* - icon — имя SVG-иконки из docsIcons.jsx (НЕ эмодзи)
|
* - icon — имя SVG-иконки из docsIcons.jsx (НЕ эмодзи)
|
||||||
* - desc — что получится в игре, 1-2 предложения
|
* - desc — что получится в игре, 1-2 предложения
|
||||||
* - mechanics — список механик, которым учит игра
|
* - mechanics — список механик, которым учит игра
|
||||||
* - previewShot — (необязательно) имя файла из public/wiki/ для
|
* - previewShot — (необязательно) имя файла из public/wiki/ для
|
||||||
* превью на карточке. По умолчанию берётся
|
* превью на карточке. По умолчанию берётся
|
||||||
* lessonN-result.png; задаётся, когда другой кадр
|
* lessonN-result.png; задаётся, когда другой кадр
|
||||||
* урока смотрится на карточке лучше.
|
* урока смотрится на карточке лучше.
|
||||||
* - ready — есть ли подробный урок (пока у всех false — наполним позже)
|
* - ready — есть ли подробный урок (пока у всех false — наполним позже)
|
||||||
*
|
*
|
||||||
* Группы:
|
* Группы:
|
||||||
* 1-12 — самые простые
|
* 1-12 — самые простые
|
||||||
* 13-28 — простые с механиками
|
* 13-28 — простые с механиками
|
||||||
* 29-40 — средние
|
* 29-40 — средние
|
||||||
* 41-50 — сложные
|
* 41-50 — сложные
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const GAME_GROUPS = [
|
export const GAME_GROUPS = [
|
||||||
{ id: 'g1', title: 'Группа 1 — Самые простые', stars: 1,
|
{ id: 'g1', title: 'Группа 1 — Самые простые', stars: 1,
|
||||||
hint: 'Постановка объектов, базовые скрипты, события, простые твины.' },
|
hint: 'Постановка объектов, базовые скрипты, события, простые твины.' },
|
||||||
{ id: 'g2', title: 'Группа 2 — Простые с механиками', stars: 2,
|
{ id: 'g2', title: 'Группа 2 — Простые с механиками', stars: 2,
|
||||||
hint: 'NPC, инвентарь, теги, billboard, камера, констрейнты.' },
|
hint: 'NPC, инвентарь, теги, billboard, камера, констрейнты.' },
|
||||||
{ id: 'g3', title: 'Группа 3 — Средние', stars: 2,
|
{ id: 'g3', title: 'Группа 3 — Средние', stars: 2,
|
||||||
hint: 'Комбинации механик, GUI, состояние игры, NPC-логика.' },
|
hint: 'Комбинации механик, GUI, состояние игры, NPC-логика.' },
|
||||||
{ id: 'g4', title: 'Группа 4 — Сложные', stars: 3,
|
{ id: 'g4', title: 'Группа 4 — Сложные', stars: 3,
|
||||||
hint: 'Полные игры, мультиплеер, продвинутые системы.' },
|
hint: 'Полные игры, мультиплеер, продвинутые системы.' },
|
||||||
{ id: 'g5', title: 'Разбор готовых игр', stars: 2,
|
{ id: 'g5', title: 'Разбор готовых игр', stars: 2,
|
||||||
hint: 'Настоящие игры из студии по косточкам: камера и таблички, анимации интерфейса, модальные сцены и кастомные скины. У каждой — кнопка «Открыть оригинал в редакторе».' },
|
hint: 'Настоящие игры из студии по косточкам: камера и таблички, анимации интерфейса, модальные сцены и кастомные скины. У каждой — кнопка «Открыть оригинал в редакторе».' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const GAMES = [
|
export const GAMES = [
|
||||||
// ── Группа 1 — Самые простые ──────────────────────────────────
|
// ── Группа 1 — Самые простые ──────────────────────────────────
|
||||||
{ id: 'collect-coins', num: 1, group: 'g1', stars: 1, icon: 'coin',
|
{ id: 'collect-coins', num: 1, group: 'g1', stars: 1, icon: 'coin',
|
||||||
title: 'Собери монетки',
|
title: 'Собери монетки',
|
||||||
desc: 'Ходишь по уровню и собираешь жёлтые сферы, счёт растёт.',
|
desc: 'Ходишь по уровню и собираешь жёлтые сферы, счёт растёт.',
|
||||||
mechanics: ['события касания', 'счётчик очков', 'удаление объектов'],
|
mechanics: ['события касания', 'счётчик очков', 'удаление объектов'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'platform-jump', num: 2, group: 'g1', stars: 1, icon: 'jump',
|
{ id: 'platform-jump', num: 2, group: 'g1', stars: 1, icon: 'jump',
|
||||||
title: 'Прыгай по платформам',
|
title: 'Прыгай по платформам',
|
||||||
desc: 'Паркур из примитивов — допрыгай до финиша, не упав вниз.',
|
desc: 'Паркур из примитивов — допрыгай до финиша, не упав вниз.',
|
||||||
mechanics: ['примитивы-платформы', 'точка спавна', 'финиш'],
|
mechanics: ['примитивы-платформы', 'точка спавна', 'финиш'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'dont-fall', num: 3, group: 'g1', stars: 1, icon: 'hole',
|
{ id: 'dont-fall', num: 3, group: 'g1', stars: 1, icon: 'hole',
|
||||||
title: 'Не упади',
|
title: 'Не упади',
|
||||||
desc: 'Платформы исчезают под ногами — нужно всё время убегать.',
|
desc: 'Платформы исчезают под ногами — нужно всё время убегать.',
|
||||||
mechanics: ['таймеры', 'исчезновение объектов', 'onTouch'],
|
mechanics: ['таймеры', 'исчезновение объектов', 'onTouch'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'button-door', num: 4, group: 'g1', stars: 1, icon: 'door',
|
{ id: 'button-door', num: 4, group: 'g1', stars: 1, icon: 'door',
|
||||||
title: 'Кнопка-открывашка',
|
title: 'Кнопка-открывашка',
|
||||||
desc: 'Нажми кнопку клавишей E — и откроется дверь в следующую комнату.',
|
desc: 'Нажми кнопку клавишей E — и откроется дверь в следующую комнату.',
|
||||||
mechanics: ['ProximityPrompt (E)', 'твины', 'перемещение объектов'],
|
mechanics: ['ProximityPrompt (E)', 'твины', 'перемещение объектов'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'maze', num: 5, group: 'g1', stars: 1, icon: 'maze',
|
{ id: 'maze', num: 5, group: 'g1', stars: 1, icon: 'maze',
|
||||||
title: 'Лабиринт',
|
title: 'Лабиринт',
|
||||||
desc: 'Найди путь из стен-кубов от старта к выходу из лабиринта.',
|
desc: 'Найди путь из стен-кубов от старта к выходу из лабиринта.',
|
||||||
mechanics: ['постройка из блоков', 'спавн', 'триггер-финиш'],
|
mechanics: ['постройка из блоков', 'спавн', 'триггер-финиш'],
|
||||||
// на карточке лучше виден лабиринт сверху — берём второй скрин урока
|
// на карточке лучше виден лабиринт сверху — берём второй скрин урока
|
||||||
previewShot: 'lesson5-scene.png',
|
previewShot: 'lesson5-scene.png',
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'color-tiles', num: 6, group: 'g1', stars: 1, icon: 'palette',
|
{ id: 'color-tiles', num: 6, group: 'g1', stars: 1, icon: 'palette',
|
||||||
title: 'Цветные плитки',
|
title: 'Цветные плитки',
|
||||||
desc: 'Наступаешь на плитки на полу — и они меняют свой цвет.',
|
desc: 'Наступаешь на плитки на полу — и они меняют свой цвет.',
|
||||||
mechanics: ['onTouch', 'setColor', 'примитивы-плитки'],
|
mechanics: ['onTouch', 'setColor', 'примитивы-плитки'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'catch-falling', num: 7, group: 'g1', stars: 1, icon: 'box',
|
{ id: 'catch-falling', num: 7, group: 'g1', stars: 1, icon: 'box',
|
||||||
title: 'Поймай падающее',
|
title: 'Поймай падающее',
|
||||||
desc: 'С неба падают кубы — лови их, пока не упали на землю.',
|
desc: 'С неба падают кубы — лови их, пока не упали на землю.',
|
||||||
mechanics: ['спавн с таймером', 'физика падения', 'счёт'],
|
mechanics: ['спавн с таймером', 'физика падения', 'счёт'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'run-to-finish', num: 8, group: 'g1', stars: 1, icon: 'flag',
|
{ id: 'run-to-finish', num: 8, group: 'g1', stars: 1, icon: 'flag',
|
||||||
title: 'Беги к финишу',
|
title: 'Беги к финишу',
|
||||||
desc: 'Гонка на время: добеги до финишной черты как можно быстрее.',
|
desc: 'Гонка на время: добеги до финишной черты как можно быстрее.',
|
||||||
mechanics: ['таймер', 'триггер-финиш', 'game.ui'],
|
mechanics: ['таймер', 'триггер-финиш', 'game.ui'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'traffic-light', num: 9, group: 'g1', stars: 1, icon: 'light',
|
{ id: 'traffic-light', num: 9, group: 'g1', stars: 1, icon: 'light',
|
||||||
title: 'Светофор',
|
title: 'Светофор',
|
||||||
desc: 'Стой на красный, беги на зелёный — успей дойти до конца.',
|
desc: 'Стой на красный, беги на зелёный — успей дойти до конца.',
|
||||||
mechanics: ['лампы', 'таймеры', 'проверка движения'],
|
mechanics: ['лампы', 'таймеры', 'проверка движения'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'spring-jump', num: 10, group: 'g1', stars: 1, icon: 'spring',
|
{ id: 'spring-jump', num: 10, group: 'g1', stars: 1, icon: 'spring',
|
||||||
title: 'Прыжок-пружина',
|
title: 'Прыжок-пружина',
|
||||||
desc: 'Батуты подбрасывают тебя всё выше — допрыгни до верхней площадки.',
|
desc: 'Батуты подбрасывают тебя всё выше — допрыгни до верхней площадки.',
|
||||||
mechanics: ['пружины (spring)', 'boostJump', 'onTouch'],
|
mechanics: ['пружины (spring)', 'boostJump', 'onTouch'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'echo-room', num: 11, group: 'g1', stars: 1, icon: 'sound',
|
{ id: 'echo-room', num: 11, group: 'g1', stars: 1, icon: 'sound',
|
||||||
title: 'Эхо-комната',
|
title: 'Эхо-комната',
|
||||||
desc: 'Наступаешь на плитку — играет звук. Собери мелодию из шагов.',
|
desc: 'Наступаешь на плитку — играет звук. Собери мелодию из шагов.',
|
||||||
mechanics: ['game.sound', 'onTouch', '3D-звук'],
|
mechanics: ['game.sound', 'onTouch', '3D-звук'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'code-door', num: 12, group: 'g1', stars: 1, icon: 'keypad',
|
{ id: 'code-door', num: 12, group: 'g1', stars: 1, icon: 'keypad',
|
||||||
title: 'Дверь по коду',
|
title: 'Дверь по коду',
|
||||||
desc: 'Введи правильное число в поле ввода — и дверь откроется.',
|
desc: 'Введи правильное число в поле ввода — и дверь откроется.',
|
||||||
mechanics: ['GUI-поле ввода', 'onSubmit', 'твины'],
|
mechanics: ['GUI-поле ввода', 'onSubmit', 'твины'],
|
||||||
ready: false },
|
ready: false },
|
||||||
|
|
||||||
// ── Группа 2 — Простые с механиками ───────────────────────────
|
// ── Группа 2 — Простые с механиками ───────────────────────────
|
||||||
{ id: 'trader', num: 13, group: 'g2', stars: 2, icon: 'trader',
|
{ id: 'trader', num: 13, group: 'g2', stars: 2, icon: 'trader',
|
||||||
title: 'Торговец',
|
title: 'Торговец',
|
||||||
desc: 'Поговори с NPC-торговцем по клавише E и получи от него предмет.',
|
desc: 'Поговори с NPC-торговцем по клавише E и получи от него предмет.',
|
||||||
mechanics: ['NPC', 'диалоги', 'инвентарь'],
|
mechanics: ['NPC', 'диалоги', 'инвентарь'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'collect-by-tag', num: 14, group: 'g2', stars: 2, icon: 'star',
|
{ id: 'collect-by-tag', num: 14, group: 'g2', stars: 2, icon: 'star',
|
||||||
title: 'Собери по тегам',
|
title: 'Собери по тегам',
|
||||||
desc: 'Собери все объекты, помеченные тегом «звезда», на уровне.',
|
desc: 'Собери все объекты, помеченные тегом «звезда», на уровне.',
|
||||||
mechanics: ['теги', 'getTagged', 'счётчик'],
|
mechanics: ['теги', 'getTagged', 'счётчик'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'shooting-range', num: 15, group: 'g2', stars: 2, icon: 'crosshair',
|
{ id: 'shooting-range', num: 15, group: 'g2', stars: 2, icon: 'crosshair',
|
||||||
title: 'Тир',
|
title: 'Тир',
|
||||||
desc: 'Стреляй по мишеням лучом-raycast и считай выбитые очки.',
|
desc: 'Стреляй по мишеням лучом-raycast и считай выбитые очки.',
|
||||||
mechanics: ['raycast', 'onClick', 'счёт'],
|
mechanics: ['raycast', 'onClick', 'счёт'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'lava-floor', num: 16, group: 'g2', stars: 2, icon: 'lava',
|
{ id: 'lava-floor', num: 16, group: 'g2', stars: 2, icon: 'lava',
|
||||||
title: 'Лава-пол',
|
title: 'Лава-пол',
|
||||||
desc: 'Пол наносит урон — прыгай только по безопасным островкам.',
|
desc: 'Пол наносит урон — прыгай только по безопасным островкам.',
|
||||||
mechanics: ['урон по касанию', 'HP', 'платформы'],
|
mechanics: ['урон по касанию', 'HP', 'платформы'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'key-chest', num: 17, group: 'g2', stars: 2, icon: 'key',
|
{ id: 'key-chest', num: 17, group: 'g2', stars: 2, icon: 'key',
|
||||||
title: 'Ключ и сундук',
|
title: 'Ключ и сундук',
|
||||||
desc: 'Найди ключ на уровне, подбери его и открой запертый сундук.',
|
desc: 'Найди ключ на уровне, подбери его и открой запертый сундук.',
|
||||||
mechanics: ['инвентарь', 'has()', 'ProximityPrompt'],
|
mechanics: ['инвентарь', 'has()', 'ProximityPrompt'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'swing', num: 18, group: 'g2', stars: 2, icon: 'swing',
|
{ id: 'swing', num: 18, group: 'g2', stars: 2, icon: 'swing',
|
||||||
title: 'Качели',
|
title: 'Качели',
|
||||||
desc: 'Запрыгни на качели-констрейнт и катайся туда-сюда.',
|
desc: 'Запрыгни на качели-констрейнт и катайся туда-сюда.',
|
||||||
mechanics: ['констрейнт-петля (hinge)', 'физика'],
|
mechanics: ['констрейнт-петля (hinge)', 'физика'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'elevator', num: 19, group: 'g2', stars: 2, icon: 'elevator',
|
{ id: 'elevator', num: 19, group: 'g2', stars: 2, icon: 'elevator',
|
||||||
title: 'Лифт',
|
title: 'Лифт',
|
||||||
desc: 'Платформа-лифт возит тебя между этажами здания.',
|
desc: 'Платформа-лифт возит тебя между этажами здания.',
|
||||||
mechanics: ['твины', 'движущаяся платформа', 'триггеры'],
|
mechanics: ['твины', 'движущаяся платформа', 'триггеры'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'enemy-names', num: 20, group: 'g2', stars: 2, icon: 'tag',
|
{ id: 'enemy-names', num: 20, group: 'g2', stars: 2, icon: 'tag',
|
||||||
title: 'Имена над врагами',
|
title: 'Имена над врагами',
|
||||||
desc: 'У каждого врага над головой висит метка с именем и его HP.',
|
desc: 'У каждого врага над головой висит метка с именем и его HP.',
|
||||||
mechanics: ['billboard-метки', 'NPC', 'HP'],
|
mechanics: ['billboard-метки', 'NPC', 'HP'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'chaser', num: 21, group: 'g2', stars: 2, icon: 'chase',
|
{ id: 'chaser', num: 21, group: 'g2', stars: 2, icon: 'chase',
|
||||||
title: 'Преследователь',
|
title: 'Преследователь',
|
||||||
desc: 'NPC гонится за тобой по всему уровню — убегай и прячься.',
|
desc: 'NPC гонится за тобой по всему уровню — убегай и прячься.',
|
||||||
mechanics: ['NPC follow', 'distance', 'onTick'],
|
mechanics: ['NPC follow', 'distance', 'onTick'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'danger-zone', num: 22, group: 'g2', stars: 2, icon: 'warning',
|
{ id: 'danger-zone', num: 22, group: 'g2', stars: 2, icon: 'warning',
|
||||||
title: 'Зона опасности',
|
title: 'Зона опасности',
|
||||||
desc: 'Войдёшь в триггер-зону — начинаешь терять здоровье.',
|
desc: 'Войдёшь в триггер-зону — начинаешь терять здоровье.',
|
||||||
mechanics: ['триггер-зона', 'onTouch/onUntouch', 'урон'],
|
mechanics: ['триггер-зона', 'onTouch/onUntouch', 'урон'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'switches', num: 23, group: 'g2', stars: 2, icon: 'lever',
|
{ id: 'switches', num: 23, group: 'g2', stars: 2, icon: 'lever',
|
||||||
title: 'Переключатели',
|
title: 'Переключатели',
|
||||||
desc: 'Активируй три рычага в правильном порядке, чтобы пройти дальше.',
|
desc: 'Активируй три рычага в правильном порядке, чтобы пройти дальше.',
|
||||||
mechanics: ['ProximityPrompt', 'состояние', 'проверка порядка'],
|
mechanics: ['ProximityPrompt', 'состояние', 'проверка порядка'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'falling-bridge', num: 24, group: 'g2', stars: 2, icon: 'bridge',
|
{ id: 'falling-bridge', num: 24, group: 'g2', stars: 2, icon: 'bridge',
|
||||||
title: 'Падающий мост',
|
title: 'Падающий мост',
|
||||||
desc: 'Мост собран из исчезающих платформ — беги, пока он не рухнул.',
|
desc: 'Мост собран из исчезающих платформ — беги, пока он не рухнул.',
|
||||||
mechanics: ['таймеры', 'исчезновение', 'onTouch'],
|
mechanics: ['таймеры', 'исчезновение', 'onTouch'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'flyby-camera', num: 25, group: 'g2', stars: 2, icon: 'camera',
|
{ id: 'flyby-camera', num: 25, group: 'g2', stars: 2, icon: 'camera',
|
||||||
title: 'Камера-облёт',
|
title: 'Камера-облёт',
|
||||||
desc: 'При старте игры камера красиво облетает весь уровень.',
|
desc: 'При старте игры камера красиво облетает весь уровень.',
|
||||||
mechanics: ['game.camera.cutscene', 'onCutsceneDone'],
|
mechanics: ['game.camera.cutscene', 'onCutsceneDone'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'coin-magnet', num: 26, group: 'g2', stars: 2, icon: 'magnet',
|
{ id: 'coin-magnet', num: 26, group: 'g2', stars: 2, icon: 'magnet',
|
||||||
title: 'Магнит монет',
|
title: 'Магнит монет',
|
||||||
desc: 'Монеты сами летят к игроку, когда он подходит близко.',
|
desc: 'Монеты сами летят к игроку, когда он подходит близко.',
|
||||||
mechanics: ['твин к позиции', 'distance', 'onTick'],
|
mechanics: ['твин к позиции', 'distance', 'onTick'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'double-jump', num: 27, group: 'g2', stars: 2, icon: 'doubleArrow',
|
{ id: 'double-jump', num: 27, group: 'g2', stars: 2, icon: 'doubleArrow',
|
||||||
title: 'Двойной прыжок',
|
title: 'Двойной прыжок',
|
||||||
desc: 'Паркур, где не пройти без двойного прыжка в воздухе.',
|
desc: 'Паркур, где не пройти без двойного прыжка в воздухе.',
|
||||||
mechanics: ['setDoubleJump', 'платформы', 'финиш'],
|
mechanics: ['setDoubleJump', 'платформы', 'финиш'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'ghost-walls', num: 28, group: 'g2', stars: 2, icon: 'ghost',
|
{ id: 'ghost-walls', num: 28, group: 'g2', stars: 2, icon: 'ghost',
|
||||||
title: 'Призрачные стены',
|
title: 'Призрачные стены',
|
||||||
desc: 'Некоторые стены становятся проходимыми — найди секретный путь.',
|
desc: 'Некоторые стены становятся проходимыми — найди секретный путь.',
|
||||||
mechanics: ['passThrough', 'триггеры', 'секреты'],
|
mechanics: ['passThrough', 'триггеры', 'секреты'],
|
||||||
ready: false },
|
ready: false },
|
||||||
|
|
||||||
// ── Группа 3 — Средние ────────────────────────────────────────
|
// ── Группа 3 — Средние ────────────────────────────────────────
|
||||||
{ id: 'shop', num: 29, group: 'g3', stars: 2, icon: 'cart',
|
{ id: 'shop', num: 29, group: 'g3', stars: 2, icon: 'cart',
|
||||||
title: 'Магазин',
|
title: 'Магазин',
|
||||||
desc: 'GUI-список товаров — покупай предметы за собранные монеты.',
|
desc: 'GUI-список товаров — покупай предметы за собранные монеты.',
|
||||||
mechanics: ['GUI-список', 'экономика', 'инвентарь'],
|
mechanics: ['GUI-список', 'экономика', 'инвентарь'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'quest-tasks', num: 30, group: 'g3', stars: 2, icon: 'scroll',
|
{ id: 'quest-tasks', num: 30, group: 'g3', stars: 2, icon: 'scroll',
|
||||||
title: 'Квест с заданиями',
|
title: 'Квест с заданиями',
|
||||||
desc: 'NPC выдаёт цепочку заданий — выполни их все по очереди.',
|
desc: 'NPC выдаёт цепочку заданий — выполни их все по очереди.',
|
||||||
mechanics: ['NPC-диалоги', 'состояние квеста', 'события'],
|
mechanics: ['NPC-диалоги', 'состояние квеста', 'события'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'base-defense', num: 31, group: 'g3', stars: 2, icon: 'shield',
|
{ id: 'base-defense', num: 31, group: 'g3', stars: 2, icon: 'shield',
|
||||||
title: 'Защита базы',
|
title: 'Защита базы',
|
||||||
desc: 'Враги идут к твоей базе — останавливай их, пока не дошли.',
|
desc: 'Враги идут к твоей базе — останавливай их, пока не дошли.',
|
||||||
mechanics: ['NPC-волны', 'raycast', 'HP базы'],
|
mechanics: ['NPC-волны', 'raycast', 'HP базы'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'lap-race', num: 32, group: 'g3', stars: 2, icon: 'car',
|
{ id: 'lap-race', num: 32, group: 'g3', stars: 2, icon: 'car',
|
||||||
title: 'Гонка с кругами',
|
title: 'Гонка с кругами',
|
||||||
desc: 'Проедь несколько кругов через чекпоинты на время.',
|
desc: 'Проедь несколько кругов через чекпоинты на время.',
|
||||||
mechanics: ['чекпоинты', 'таймер', 'счётчик кругов'],
|
mechanics: ['чекпоинты', 'таймер', 'счётчик кругов'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'boss-platformer', num: 33, group: 'g3', stars: 2, icon: 'boss',
|
{ id: 'boss-platformer', num: 33, group: 'g3', stars: 2, icon: 'boss',
|
||||||
title: 'Платформер с боссом',
|
title: 'Платформер с боссом',
|
||||||
desc: 'Пройди паркур до NPC-босса и победи его в конце уровня.',
|
desc: 'Пройди паркур до NPC-босса и победи его в конце уровня.',
|
||||||
mechanics: ['паркур', 'NPC-босс', 'бой'],
|
mechanics: ['паркур', 'NPC-босс', 'бой'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'harvest', num: 34, group: 'g3', stars: 2, icon: 'plant',
|
{ id: 'harvest', num: 34, group: 'g3', stars: 2, icon: 'plant',
|
||||||
title: 'Сбор урожая',
|
title: 'Сбор урожая',
|
||||||
desc: 'Растения растут на грядках — собирай их вовремя, пока спелые.',
|
desc: 'Растения растут на грядках — собирай их вовремя, пока спелые.',
|
||||||
mechanics: ['твины роста', 'таймеры', 'счёт'],
|
mechanics: ['твины роста', 'таймеры', 'счёт'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'hide-from-npc', num: 35, group: 'g3', stars: 2, icon: 'hide',
|
{ id: 'hide-from-npc', num: 35, group: 'g3', stars: 2, icon: 'hide',
|
||||||
title: 'Прятки от NPC',
|
title: 'Прятки от NPC',
|
||||||
desc: 'NPC ищет тебя по уровню — прячься за объектами, чтобы не нашёл.',
|
desc: 'NPC ищет тебя по уровню — прячься за объектами, чтобы не нашёл.',
|
||||||
mechanics: ['NPC-логика', 'raycast видимости', 'distance'],
|
mechanics: ['NPC-логика', 'raycast видимости', 'distance'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'box-puzzle', num: 36, group: 'g3', stars: 2, icon: 'puzzle',
|
{ id: 'box-puzzle', num: 36, group: 'g3', stars: 2, icon: 'puzzle',
|
||||||
title: 'Головоломка с ящиками',
|
title: 'Головоломка с ящиками',
|
||||||
desc: 'Двигай ящики на кнопки-плиты, чтобы открыть запертую дверь.',
|
desc: 'Двигай ящики на кнопки-плиты, чтобы открыть запертую дверь.',
|
||||||
mechanics: ['физика толкания', 'кнопки-плиты', 'логика'],
|
mechanics: ['физика толкания', 'кнопки-плиты', 'логика'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'obstacle-course', num: 37, group: 'g3', stars: 2, icon: 'obstacle',
|
{ id: 'obstacle-course', num: 37, group: 'g3', stars: 2, icon: 'obstacle',
|
||||||
title: 'Полоса препятствий',
|
title: 'Полоса препятствий',
|
||||||
desc: 'Шипы, ямы и движущиеся платформы — пройди всё без смерти.',
|
desc: 'Шипы, ямы и движущиеся платформы — пройди всё без смерти.',
|
||||||
mechanics: ['шипы', 'движущиеся платформы', 'чекпоинты'],
|
mechanics: ['шипы', 'движущиеся платформы', 'чекпоинты'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'music-game', num: 38, group: 'g3', stars: 2, icon: 'music',
|
{ id: 'music-game', num: 38, group: 'g3', stars: 2, icon: 'music',
|
||||||
title: 'Музыкальная игра',
|
title: 'Музыкальная игра',
|
||||||
desc: 'Запомни и повтори последовательность звуков, которую сыграла игра.',
|
desc: 'Запомни и повтори последовательность звуков, которую сыграла игра.',
|
||||||
mechanics: ['game.sound', 'массивы', 'проверка ответа'],
|
mechanics: ['game.sound', 'массивы', 'проверка ответа'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'tower-build', num: 39, group: 'g3', stars: 2, icon: 'tower',
|
{ id: 'tower-build', num: 39, group: 'g3', stars: 2, icon: 'tower',
|
||||||
title: 'Башня — стройка',
|
title: 'Башня — стройка',
|
||||||
desc: 'Ставь блоки по подсказкам скрипта и построй высокую башню.',
|
desc: 'Ставь блоки по подсказкам скрипта и построй высокую башню.',
|
||||||
mechanics: ['спавн блоков', 'GUI-подсказки', 'счётчик'],
|
mechanics: ['спавн блоков', 'GUI-подсказки', 'счётчик'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'wave-survival', num: 40, group: 'g3', stars: 2, icon: 'zombie',
|
{ id: 'wave-survival', num: 40, group: 'g3', stars: 2, icon: 'zombie',
|
||||||
title: 'Выживание от волн',
|
title: 'Выживание от волн',
|
||||||
desc: 'Волны врагов нападают одна за другой — продержись N секунд.',
|
desc: 'Волны врагов нападают одна за другой — продержись N секунд.',
|
||||||
mechanics: ['NPC-волны', 'таймеры', 'HP'],
|
mechanics: ['NPC-волны', 'таймеры', 'HP'],
|
||||||
ready: false },
|
ready: false },
|
||||||
|
|
||||||
// ── Группа 4 — Сложные ────────────────────────────────────────
|
// ── Группа 4 — Сложные ────────────────────────────────────────
|
||||||
{ id: 'adventure-platformer', num: 41, group: 'g4', stars: 3, icon: 'map',
|
{ id: 'adventure-platformer', num: 41, group: 'g4', stars: 3, icon: 'map',
|
||||||
title: 'Платформер-приключение',
|
title: 'Платформер-приключение',
|
||||||
desc: 'Большой уровень с чекпоинтами, врагами и финишем-сокровищем.',
|
desc: 'Большой уровень с чекпоинтами, врагами и финишем-сокровищем.',
|
||||||
mechanics: ['большой уровень', 'чекпоинты', 'враги', 'финиш'],
|
mechanics: ['большой уровень', 'чекпоинты', 'враги', 'финиш'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'rpg-village', num: 42, group: 'g4', stars: 3, icon: 'village',
|
{ id: 'rpg-village', num: 42, group: 'g4', stars: 3, icon: 'village',
|
||||||
title: 'RPG-деревня',
|
title: 'RPG-деревня',
|
||||||
desc: 'Деревня с NPC, диалогами, квестами, инвентарём и торговлей.',
|
desc: 'Деревня с NPC, диалогами, квестами, инвентарём и торговлей.',
|
||||||
mechanics: ['NPC', 'квесты', 'инвентарь', 'экономика'],
|
mechanics: ['NPC', 'квесты', 'инвентарь', 'экономика'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'obstacle-race', num: 43, group: 'g4', stars: 3, icon: 'car',
|
{ id: 'obstacle-race', num: 43, group: 'g4', stars: 3, icon: 'car',
|
||||||
title: 'Гонка с препятствиями',
|
title: 'Гонка с препятствиями',
|
||||||
desc: 'Трасса с бустами скорости и ловушками — приди первым к финишу.',
|
desc: 'Трасса с бустами скорости и ловушками — приди первым к финишу.',
|
||||||
mechanics: ['бусты', 'ловушки', 'таймер', 'чекпоинты'],
|
mechanics: ['бусты', 'ловушки', 'таймер', 'чекпоинты'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'tower-defense', num: 44, group: 'g4', stars: 3, icon: 'castle',
|
{ id: 'tower-defense', num: 44, group: 'g4', stars: 3, icon: 'castle',
|
||||||
title: 'Tower Defense',
|
title: 'Tower Defense',
|
||||||
desc: 'Ставь башни вдоль дороги и отбивай волны наступающих врагов.',
|
desc: 'Ставь башни вдоль дороги и отбивай волны наступающих врагов.',
|
||||||
mechanics: ['GUI-меню', 'NPC-волны', 'башни', 'экономика'],
|
mechanics: ['GUI-меню', 'NPC-волны', 'башни', 'экономика'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'arena-shooter', num: 45, group: 'g4', stars: 3, icon: 'gun',
|
{ id: 'arena-shooter', num: 45, group: 'g4', stars: 3, icon: 'gun',
|
||||||
title: 'Стрелялка-арена',
|
title: 'Стрелялка-арена',
|
||||||
desc: 'Оружие, враги, очки и GUI-счёт — выживай на боевой арене.',
|
desc: 'Оружие, враги, очки и GUI-счёт — выживай на боевой арене.',
|
||||||
mechanics: ['оружие (Tool)', 'raycast', 'NPC', 'GUI-счёт'],
|
mechanics: ['оружие (Tool)', 'raycast', 'NPC', 'GUI-счёт'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'clicker', num: 46, group: 'g4', stars: 3, icon: 'click',
|
{ id: 'clicker', num: 46, group: 'g4', stars: 3, icon: 'click',
|
||||||
title: 'Кликер',
|
title: 'Кликер',
|
||||||
desc: 'GUI-игра: кликай по кнопке, копи очки и покупай улучшения.',
|
desc: 'GUI-игра: кликай по кнопке, копи очки и покупай улучшения.',
|
||||||
mechanics: ['GUI', 'game.save', 'экономика улучшений'],
|
mechanics: ['GUI', 'game.save', 'экономика улучшений'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'escape-quest', num: 47, group: 'g4', stars: 3, icon: 'door',
|
{ id: 'escape-quest', num: 47, group: 'g4', stars: 3, icon: 'door',
|
||||||
title: 'Квест-побег',
|
title: 'Квест-побег',
|
||||||
desc: 'Комната-головоломка: реши все загадки и найди выход.',
|
desc: 'Комната-головоломка: реши все загадки и найди выход.',
|
||||||
mechanics: ['головоломки', 'инвентарь', 'состояние', 'триггеры'],
|
mechanics: ['головоломки', 'инвентарь', 'состояние', 'триггеры'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'mp-tag', num: 48, group: 'g4', stars: 3, icon: 'chase',
|
{ id: 'mp-tag', num: 48, group: 'g4', stars: 3, icon: 'chase',
|
||||||
title: 'Мультиплеер: Салки',
|
title: 'Мультиплеер: Салки',
|
||||||
desc: 'Несколько игроков, один водящий — догони и осаль остальных.',
|
desc: 'Несколько игроков, один водящий — догони и осаль остальных.',
|
||||||
mechanics: ['мультиплеер', 'команды', 'game.room'],
|
mechanics: ['мультиплеер', 'команды', 'game.room'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'mp-race', num: 49, group: 'g4', stars: 3, icon: 'trophy',
|
{ id: 'mp-race', num: 49, group: 'g4', stars: 3, icon: 'trophy',
|
||||||
title: 'Мультиплеер: Гонка',
|
title: 'Мультиплеер: Гонка',
|
||||||
desc: 'Соревнование игроков на трассе с общим счётом комнаты.',
|
desc: 'Соревнование игроков на трассе с общим счётом комнаты.',
|
||||||
mechanics: ['мультиплеер', 'game.room', 'чекпоинты'],
|
mechanics: ['мультиплеер', 'game.room', 'чекпоинты'],
|
||||||
ready: false },
|
ready: false },
|
||||||
{ id: 'make-your-own', num: 50, group: 'g4', stars: 3, icon: 'sparkles',
|
{ id: 'make-your-own', num: 50, group: 'g4', stars: 3, icon: 'sparkles',
|
||||||
title: 'Своя игра',
|
title: 'Своя игра',
|
||||||
desc: 'Гайд: как придумать и собрать собственную игру с нуля.',
|
desc: 'Гайд: как придумать и собрать собственную игру с нуля.',
|
||||||
mechanics: ['проектирование игры', 'все механики вместе'],
|
mechanics: ['проектирование игры', 'все механики вместе'],
|
||||||
ready: false },
|
ready: false },
|
||||||
|
|
||||||
// ── Группа 5 — Разбор готовых игр ─────────────────────────────
|
// ── Группа 5 — Разбор готовых игр ─────────────────────────────
|
||||||
// Это НАСТОЯЩИЕ игры из студии. У карточек есть openProjectId —
|
// Это НАСТОЯЩИЕ игры из студии. У карточек есть openProjectId —
|
||||||
// кнопка открывает оригинал игры в редакторе (а не строит из билдера).
|
// кнопка открывает оригинал игры в редакторе (а не строит из билдера).
|
||||||
{ id: 'guide-dvor', num: 51, group: 'g5', stars: 1, icon: 'camera',
|
{ id: 'guide-dvor', num: 51, group: 'g5', stars: 1, icon: 'camera',
|
||||||
title: 'Двор с табличкой',
|
title: 'Двор с табличкой',
|
||||||
desc: 'Учимся крутить камеру мышкой как в Roblox и нажимать на 3D-таблички прямо в мире.',
|
desc: 'Учимся крутить камеру мышкой как в Roblox и нажимать на 3D-таблички прямо в мире.',
|
||||||
mechanics: ['камера и мышь', 'ПКМ-orbit и зум', 'Shift-Lock (L)', '3D-таблички'],
|
mechanics: ['камера и мышь', 'ПКМ-orbit и зум', 'Shift-Lock (L)', '3D-таблички'],
|
||||||
previewShot: 'guide-dvor-scene.png', openProjectId: 1991, ready: true },
|
previewShot: 'guide-dvor-scene.png', openProjectId: 1991, ready: true },
|
||||||
{ id: 'guide-vitrina', num: 52, group: 'g5', stars: 2, icon: 'palette',
|
{ id: 'guide-vitrina', num: 52, group: 'g5', stars: 2, icon: 'palette',
|
||||||
title: 'Витрина GUI',
|
title: 'Витрина GUI',
|
||||||
desc: 'Живые кнопки магазина: градиенты, пульсация, поворот и плавные твины при нажатии.',
|
desc: 'Живые кнопки магазина: градиенты, пульсация, поворот и плавные твины при нажатии.',
|
||||||
mechanics: ['GUI-кнопки', 'анимации (pulse/rotate)', 'твины', 'счётчик монет'],
|
mechanics: ['GUI-кнопки', 'анимации (pulse/rotate)', 'твины', 'счётчик монет'],
|
||||||
previewShot: 'guide-vitrina-scene.png', openProjectId: 1995, ready: true },
|
previewShot: 'guide-vitrina-scene.png', openProjectId: 1995, ready: true },
|
||||||
{ id: 'guide-sunduk', num: 53, group: 'g5', stars: 2, icon: 'scroll',
|
{ id: 'guide-sunduk', num: 53, group: 'g5', stars: 2, icon: 'scroll',
|
||||||
title: 'Тайна старого сундука',
|
title: 'Тайна старого сундука',
|
||||||
desc: 'Кат-сцены и диалоги: затемнение, прожектор на сундуке, выбор приза и финальная победа.',
|
desc: 'Кат-сцены и диалоги: затемнение, прожектор на сундуке, выбор приза и финальная победа.',
|
||||||
mechanics: ['game.modal', 'диалог по строкам', 'прожектор + камера', 'лутбокс'],
|
mechanics: ['game.modal', 'диалог по строкам', 'прожектор + камера', 'лутбокс'],
|
||||||
previewShot: 'guide-sunduk-scene.png', openProjectId: 2037, ready: true },
|
previewShot: 'guide-sunduk-scene.png', openProjectId: 2037, ready: true },
|
||||||
{ id: 'guide-zoo', num: 54, group: 'g5', stars: 2, icon: 'gamepad',
|
{ id: 'guide-zoo', num: 54, group: 'g5', stars: 2, icon: 'gamepad',
|
||||||
title: 'Парк животных',
|
title: 'Парк животных',
|
||||||
desc: 'Кастомные скины: герой превращается в пончик, машину, пришельца. Магазин скинов на B.',
|
desc: 'Кастомные скины: герой превращается в пончик, машину, пришельца. Магазин скинов на B.',
|
||||||
mechanics: ['game.player.setSkin', 'non-humanoid скины', 'магазин скинов', 'таблички'],
|
mechanics: ['game.player.setSkin', 'non-humanoid скины', 'магазин скинов', 'таблички'],
|
||||||
previewShot: 'guide-zoo-scene.png', openProjectId: 2046, ready: true },
|
previewShot: 'guide-zoo-scene.png', openProjectId: 2046, ready: true },
|
||||||
{ id: 'guide-strelka', num: 55, group: 'g5', stars: 1, icon: 'gamepad',
|
{ id: 'guide-strelka', num: 55, group: 'g5', stars: 1, icon: 'gamepad',
|
||||||
title: 'Туториал — собери монетки',
|
title: 'Туториал — собери монетки',
|
||||||
desc: '3D-стрелка-указатель «иди сюда»: дорожка из бегущих шевронов + парящий маркер над целью. Дошёл — стрелка прыгает на следующую.',
|
desc: '3D-стрелка-указатель «иди сюда»: дорожка из бегущих шевронов + парящий маркер над целью. Дошёл — стрелка прыгает на следующую.',
|
||||||
mechanics: ['game.fx.pointer', 'preset стрелки', 'setTarget/update', 'onTouch цели'],
|
mechanics: ['game.fx.pointer', 'preset стрелки', 'setTarget/update', 'onTouch цели'],
|
||||||
previewShot: 'guide-strelka-scene.png', openProjectId: 333, ready: true },
|
previewShot: 'guide-strelka-scene.png', openProjectId: 333, ready: true },
|
||||||
{ id: 'guide-lego', num: 56, group: 'g5', stars: 1, icon: 'cube',
|
{ id: 'guide-lego', num: 56, group: 'g5', stars: 1, icon: 'cube',
|
||||||
title: 'Лего-полигон — studs материал',
|
title: 'Лего-полигон — studs материал',
|
||||||
desc: 'Лего-кружки (studs) на блоках и примитивах любого цвета: зелёный пол, оранжевая стена, разноцветные кубы + готовый лего-сет (дерево, дом, машина).',
|
desc: 'Лего-кружки (studs) на блоках и примитивах любого цвета: зелёный пол, оранжевая стена, разноцветные кубы + готовый лего-сет (дерево, дом, машина).',
|
||||||
mechanics: ['material: studs', 'studs-block (цвет на блок)', 'тайлинг по размеру', 'лего-сет моделей'],
|
mechanics: ['material: studs', 'studs-block (цвет на блок)', 'тайлинг по размеру', 'лего-сет моделей'],
|
||||||
previewShot: 'guide-lego-scene.png', openProjectId: 0, ready: true },
|
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 <color>', 'game.format.money', 'obj.move/rotate', 'onClick объекта'],
|
||||||
|
previewShot: 'guide-dynamic-label-scene.png', openProjectId: 2261, ready: true },
|
||||||
|
];
|
||||||
|
|||||||
@ -418,6 +418,12 @@ const ICONS = {
|
|||||||
<path d="M12 13v3M9 20h6M10 20c0-1.5.5-2.5 2-2.5s2 1 2 2.5" {...S} />
|
<path d="M12 13v3M9 20h6M10 20c0-1.5.5-2.5 2-2.5s2 1 2 2.5" {...S} />
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
clock: () => (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="9" {...S} />
|
||||||
|
<path d="M12 7v5l3.5 2" {...S} />
|
||||||
|
</>
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function DocIcon({ name, size = 24, className = '' }) {
|
export default function DocIcon({ name, size = 24, className = '' }) {
|
||||||
|
|||||||
@ -7982,6 +7982,131 @@ game.scene.setColor('block:0,0,0', '#ff0000');`}</Code>
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
'guide-dynamic-label': {
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<h3 className="lessonH">Что получится</h3>
|
||||||
|
<p>
|
||||||
|
Часовая башня с <b>живыми надписями прямо в 3D</b> и <b>витриной-
|
||||||
|
лутбоксом</b>: над башней — <b>таймер обратного отсчёта</b> в
|
||||||
|
жёлто-синей рамке (как в Roblox); ряд из <b>трёх подиумов</b>, на
|
||||||
|
каждом парит и вращается предмет (меч, кубок, ключ), а перед ним —
|
||||||
|
<b> наклонная табличка-ценник</b> с названием; <b>счётчик монет</b>
|
||||||
|
«1 230 рубликов» с золотой монетой (клик +10); над зомби — <b>полоса
|
||||||
|
HP</b>. Все надписи обновляются сами, без мигания.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Shot src="guide-dynamic-label-play.png" wide
|
||||||
|
caption="Витрина-лутбокс: на подиумах вращаются предметы, перед каждым наклонная табличка с названием; слева счётчик монет, над башней таймер." />
|
||||||
|
|
||||||
|
<h3 className="lessonH">Чему научишься</h3>
|
||||||
|
<ul>
|
||||||
|
<li><b>scene.bindLabel(объект, fn, opts)</b> — привязать надпись к
|
||||||
|
функции: что вернёт функция, то и на плашке. Обновляется раз в
|
||||||
|
<code>interval</code> сек, текстура перерисовывается только
|
||||||
|
когда текст реально изменился (диф-чек);</li>
|
||||||
|
<li><b>scene.bindTimer(объект, времяКонца, opts)</b> — готовый
|
||||||
|
таймер обратного отсчёта с <code>onEnd</code>-колбэком;</li>
|
||||||
|
<li><b>attachFace</b> — прикрепить надпись ПЛОСКО к грани примитива
|
||||||
|
(<code>'front'/'top'/...</code>): наклоняешь сам примитив —
|
||||||
|
текст лежит в его плоскости и наклонён вместе с ним;</li>
|
||||||
|
<li><b>5 пресетов плашек</b> — <code>gameui</code>, <code>boss-hp</code>,
|
||||||
|
<code>reward</code> (золото), <code>warning</code>, <code>plain</code>;</li>
|
||||||
|
<li><b>richText</b> — разноцветные части: <code>{'<color=#hex>…</color>'}</code>;</li>
|
||||||
|
<li><b>game.format</b> — <code>time</code> (00:15:59),
|
||||||
|
<code>money</code> («1 334 рублика» — разделитель + склонение);</li>
|
||||||
|
<li><b>obj.move / obj.rotate</b> — двигать и вращать предмет (парение
|
||||||
|
и крутёж на подиуме);</li>
|
||||||
|
<li><b>надпись из инспектора</b> — раздел «Подпись над объектом»:
|
||||||
|
таймер/счётчик/HP без кода.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="lessonH">Шаг 1. Надпись из инспектора (без кода)</h3>
|
||||||
|
<Shot src="guide-dynamic-label-scene.png" wide
|
||||||
|
caption="Сцена в редакторе: часовая башня из studs, витрина с подиумами, антураж (сосны, камни, цветы)." />
|
||||||
|
<Step n="1">
|
||||||
|
Выдели объект → в инспекторе справа раздел
|
||||||
|
<b> «Подпись над объектом»</b> → включи галочку.
|
||||||
|
</Step>
|
||||||
|
<Step n="2">
|
||||||
|
В списке <b>«Связать с…»</b> выбери <b>Таймер</b>, длительность
|
||||||
|
(960 = 16 минут), формат <code>hh:mm:ss</code>, стиль <b>gameui</b>.
|
||||||
|
Жми Play — цифры пошли вниз.
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
<h3 className="lessonH">Шаг 2. Таблички-ценники: наклони примитив</h3>
|
||||||
|
<p>
|
||||||
|
Правильная логика — <b>наклоняешь сам примитив-планшет</b>, а текст
|
||||||
|
крепишь к его передней грани через <code>attachFace: 'front'</code>.
|
||||||
|
Текст ляжет ТОЧНО в плоскость планшета и наклонится вместе с ним —
|
||||||
|
как ценник на витрине. Размер планшета сделай чуть больше текста,
|
||||||
|
чтобы надпись не вылезала за края.
|
||||||
|
</p>
|
||||||
|
<ScriptKind kind="object" />
|
||||||
|
<Code>{`// Планшет наклонён в редакторе (поворот по 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 }));`}</Code>
|
||||||
|
|
||||||
|
<h3 className="lessonH">Шаг 3. Таймер, счётчик, HP</h3>
|
||||||
|
<Code>{`// Таймер над башней (на передней грани верхнего яруса):
|
||||||
|
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); });`}</Code>
|
||||||
|
<Note>
|
||||||
|
<b>findOne сразу на старте может вернуть null</b> — сцена приходит
|
||||||
|
чуть позже. Оберни поиск в <code>game.after(0.3, () => {'{ … }'})</code>.
|
||||||
|
Без <code>attachFace</code> плашка висит билбордом над верхом
|
||||||
|
объекта (как HP-полоса) — это удобно для NPC.
|
||||||
|
</Note>
|
||||||
|
|
||||||
|
<h3 className="lessonH">Почему это не тормозит</h3>
|
||||||
|
<p>
|
||||||
|
<b>Диф-чек:</b> <code>bindLabel</code> перерисовывает текстуру
|
||||||
|
только когда строка изменилась — таймер обновляется раз в секунду,
|
||||||
|
а не каждый кадр. Привязка <b>сама отменяется</b> при
|
||||||
|
<code> scene.delete</code> — утечек нет.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Try>
|
||||||
|
Сделай ещё один подиум со своим предметом из палитры моделей.
|
||||||
|
Наклони планшет под тем же углом, повесь название через
|
||||||
|
<code> attachFace: 'front'</code>, а предмет заставь парить и
|
||||||
|
крутиться через <code>obj.move</code> + <code>obj.rotate</code>.
|
||||||
|
</Try>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Есть ли готовый текст урока для игры с таким id. */
|
/** Есть ли готовый текст урока для игры с таким id. */
|
||||||
|
|||||||
@ -318,6 +318,8 @@ const InspectorPanel = ({
|
|||||||
const [localColor, setLocalColor] = useState('#888888');
|
const [localColor, setLocalColor] = useState('#888888');
|
||||||
const [localMaterial, setLocalMaterial] = useState('matte');
|
const [localMaterial, setLocalMaterial] = useState('matte');
|
||||||
const [localStudDensity, setLocalStudDensity] = useState(1); // плотность studs
|
const [localStudDensity, setLocalStudDensity] = useState(1); // плотность studs
|
||||||
|
// Подпись над объектом (задача 10).
|
||||||
|
const [localLabel, setLocalLabel] = useState(null); // { enabled, binding, params, preset, height }
|
||||||
const [localCanCollide, setLocalCanCollide] = useState(true);
|
const [localCanCollide, setLocalCanCollide] = useState(true);
|
||||||
const [localVisible, setLocalVisible] = useState(true);
|
const [localVisible, setLocalVisible] = useState(true);
|
||||||
const [localAnchored, setLocalAnchored] = useState(true);
|
const [localAnchored, setLocalAnchored] = useState(true);
|
||||||
@ -364,6 +366,7 @@ const InspectorPanel = ({
|
|||||||
setLocalColor(selection.color || '#888888');
|
setLocalColor(selection.color || '#888888');
|
||||||
setLocalMaterial(selection.material || 'matte');
|
setLocalMaterial(selection.material || 'matte');
|
||||||
setLocalStudDensity(selection.studDensity || 1);
|
setLocalStudDensity(selection.studDensity || 1);
|
||||||
|
setLocalLabel(selection.label || null);
|
||||||
setLocalCanCollide(selection.canCollide !== false);
|
setLocalCanCollide(selection.canCollide !== false);
|
||||||
setLocalVisible(selection.visible !== false);
|
setLocalVisible(selection.visible !== false);
|
||||||
setLocalAnchored(selection.anchored !== false);
|
setLocalAnchored(selection.anchored !== false);
|
||||||
@ -1789,6 +1792,100 @@ const InspectorPanel = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Подпись над объектом (задача 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 (
|
||||||
|
<div className={cl.section}>
|
||||||
|
<div className={cl.sectionTitle}><Icon name="type" size={12} /> Подпись над объектом</div>
|
||||||
|
<label style={{ display: 'flex', gap: 8, alignItems: 'center', cursor: 'pointer', padding: '2px 0' }}>
|
||||||
|
<input type="checkbox" checked={!!L.enabled}
|
||||||
|
onChange={(e) => applyLabel({ enabled: e.target.checked })} />
|
||||||
|
<span style={{ fontSize: 13 }}>Показывать подпись</span>
|
||||||
|
</label>
|
||||||
|
{L.enabled && (<>
|
||||||
|
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginTop: 6 }}>
|
||||||
|
<span style={{ fontSize: 12, flex: 1 }}>Связать с</span>
|
||||||
|
<select value={L.binding} onChange={(e) => applyLabel({ binding: e.target.value })} style={{ ...inp, flex: 1.5 }}>
|
||||||
|
<option value="static">Статический текст</option>
|
||||||
|
<option value="timer">Таймер</option>
|
||||||
|
<option value="save">Счётчик из save</option>
|
||||||
|
<option value="hp">HP</option>
|
||||||
|
<option value="formula">Своя формула</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{(L.binding === 'static' || L.binding === 'formula') && (
|
||||||
|
<input type="text" placeholder="Текст" value={L.params?.text || ''}
|
||||||
|
onChange={(e) => applyLabel({ params: { text: e.target.value } })}
|
||||||
|
style={{ ...inp, marginTop: 6 }} />
|
||||||
|
)}
|
||||||
|
{L.binding === 'timer' && (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginTop: 6 }}>
|
||||||
|
<label style={{ fontSize: 11, display: 'flex', flexDirection: 'column', gap: 2 }}>Секунд
|
||||||
|
<input type="number" value={L.params?.duration ?? 960}
|
||||||
|
onChange={(e) => applyLabel({ params: { duration: Number(e.target.value) } })} style={inp} /></label>
|
||||||
|
<label style={{ fontSize: 11, display: 'flex', flexDirection: 'column', gap: 2 }}>Формат
|
||||||
|
<select value={L.params?.format || 'mm:ss'} onChange={(e) => applyLabel({ params: { format: e.target.value } })} style={inp}>
|
||||||
|
<option value="mm:ss">мм:сс</option>
|
||||||
|
<option value="hh:mm:ss">чч:мм:сс</option>
|
||||||
|
<option value="auto">авто</option>
|
||||||
|
</select></label>
|
||||||
|
<input type="text" placeholder="Префикс" value={L.params?.prefix || ''}
|
||||||
|
onChange={(e) => applyLabel({ params: { prefix: e.target.value } })} style={{ ...inp, gridColumn: '1 / 3' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{L.binding === 'save' && (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginTop: 6 }}>
|
||||||
|
<input type="text" placeholder="Ключ (coins)" value={L.params?.key || ''}
|
||||||
|
onChange={(e) => applyLabel({ params: { key: e.target.value } })} style={inp} />
|
||||||
|
<input type="text" placeholder="Суффикс" value={L.params?.suffix || ''}
|
||||||
|
onChange={(e) => applyLabel({ params: { suffix: e.target.value } })} style={inp} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginTop: 6 }}>
|
||||||
|
<span style={{ fontSize: 12, flex: 1 }}>Стиль</span>
|
||||||
|
<select value={L.preset || 'gameui'} onChange={(e) => applyLabel({ preset: e.target.value })} style={{ ...inp, flex: 1.5 }}>
|
||||||
|
<option value="gameui">Игровой (синий/жёлтый)</option>
|
||||||
|
<option value="warning">Предупреждение</option>
|
||||||
|
<option value="reward">Награда (золото)</option>
|
||||||
|
<option value="boss-hp">Босс HP</option>
|
||||||
|
<option value="plain">Простой</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginTop: 6 }}>
|
||||||
|
<span style={{ fontSize: 12, flex: 1 }}>Крепление</span>
|
||||||
|
<select value={L.attachFace || 'over'}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
// 'over' — billboard над верхом (к камере); иначе — на грань.
|
||||||
|
applyLabel({ attachFace: v === 'over' ? null : v });
|
||||||
|
}}
|
||||||
|
style={{ ...inp, flex: 1.5 }}>
|
||||||
|
<option value="over">Над объектом (к камере)</option>
|
||||||
|
<option value="front">На грань: перёд</option>
|
||||||
|
<option value="back">На грань: зад</option>
|
||||||
|
<option value="left">На грань: лево</option>
|
||||||
|
<option value="right">На грань: право</option>
|
||||||
|
<option value="top">На грань: верх</option>
|
||||||
|
<option value="bottom">На грань: низ</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<label style={{ fontSize: 11, display: 'flex', flexDirection: 'column', gap: 2, marginTop: 6 }}>
|
||||||
|
{L.attachFace ? 'Отступ от грани' : 'Высота над объектом'}
|
||||||
|
<input type="number" step={L.attachFace ? 0.05 : 0.5}
|
||||||
|
value={L.height ?? (L.attachFace ? 0.05 : 2.5)}
|
||||||
|
onChange={(e) => applyLabel({ height: Number(e.target.value) })} style={inp} /></label>
|
||||||
|
</>)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Текстура — своя картинка на гранях примитива */}
|
{/* Текстура — своя картинка на гранях примитива */}
|
||||||
<div className={cl.section}>
|
<div className={cl.section}>
|
||||||
<div className={cl.sectionTitle}>
|
<div className={cl.sectionTitle}>
|
||||||
|
|||||||
@ -1510,6 +1510,13 @@ export class BabylonScene {
|
|||||||
if (this._touchDetectFrame >= 3) {
|
if (this._touchDetectFrame >= 3) {
|
||||||
this._touchDetectFrame = 0;
|
this._touchDetectFrame = 0;
|
||||||
this._detectTouchEvents();
|
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) {
|
if (this._isPlaying) {
|
||||||
// В Play-режиме ЛКМ — клик игрока в forward-направлении.
|
// В Play-режиме ЛКМ — клик игрока в forward-направлении.
|
||||||
// Pointer Lock — курсор всё равно в центре экрана.
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
// Обновляем pointer координаты для raycast и Gizmo
|
// Обновляем pointer координаты для raycast и Gizmo
|
||||||
@ -2986,7 +2996,7 @@ export class BabylonScene {
|
|||||||
* - в self-обработчики скриптов (routeEvent с target)
|
* - в self-обработчики скриптов (routeEvent с target)
|
||||||
* - в глобальные обработчики (game.onClick) с event.target
|
* - в глобальные обработчики (game.onClick) с event.target
|
||||||
*/
|
*/
|
||||||
_handlePlayClick() {
|
_handlePlayClick(clickX, clickY) {
|
||||||
if (!this._isPlaying) return;
|
if (!this._isPlaying) return;
|
||||||
|
|
||||||
// Мультиплеер-выстрел: если у сцены есть mpSync, шлём 'shoot' серверу.
|
// Мультиплеер-выстрел: если у сцены есть mpSync, шлём 'shoot' серверу.
|
||||||
@ -3014,6 +3024,29 @@ export class BabylonScene {
|
|||||||
if (target) {
|
if (target) {
|
||||||
this.gameRuntime.routeEvent(target, 'click', { point });
|
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 — всегда (даже если попали в пустоту)
|
// 2) Глобальный onClick — всегда (даже если попали в пустоту)
|
||||||
this.gameRuntime.routeGlobalEvent('click', { point, target });
|
this.gameRuntime.routeGlobalEvent('click', { point, target });
|
||||||
// 3) toolUse — если в активном слоте инвентаря есть инструмент/оружие.
|
// 3) toolUse — если в активном слоте инвентаря есть инструмент/оружие.
|
||||||
|
|||||||
@ -14,7 +14,7 @@
|
|||||||
* Этап 2.1: минимальный API — player.teleport, onTick, log.
|
* Этап 2.1: минимальный API — player.teleport, onTick, log.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Color3 } from '@babylonjs/core';
|
import { Color3, Vector3 } from '@babylonjs/core';
|
||||||
import { ScriptSandbox } from './ScriptSandbox';
|
import { ScriptSandbox } from './ScriptSandbox';
|
||||||
import { STORYS_addres } from '../../api/API';
|
import { STORYS_addres } from '../../api/API';
|
||||||
import { PhysicsWorld } from './PhysicsWorld';
|
import { PhysicsWorld } from './PhysicsWorld';
|
||||||
@ -77,6 +77,8 @@ export class GameRuntime {
|
|||||||
if (!Array.isArray(scripts) || scripts.length === 0) {
|
if (!Array.isArray(scripts) || scripts.length === 0) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.warn('[GameRuntime] start: no scripts to run');
|
console.warn('[GameRuntime] start: no scripts to run');
|
||||||
|
// Задача 10: подписи из инспектора (label) работают и БЕЗ скриптов.
|
||||||
|
this._setupLabelBindings();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Карта модулей для game.require — { имя_скрипта: код }.
|
// Карта модулей для game.require — { имя_скрипта: код }.
|
||||||
@ -180,6 +182,102 @@ export class GameRuntime {
|
|||||||
} else {
|
} else {
|
||||||
setTimeout(sendInitial, 16);
|
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. */
|
/** Задача 03: запустить пресеты анимаций для GUI-элементов в Play. */
|
||||||
@ -341,6 +439,7 @@ export class GameRuntime {
|
|||||||
this._cleanupSpawnedGui();
|
this._cleanupSpawnedGui();
|
||||||
// Убираем billboard-метки над объектами (game.scene.setLabel).
|
// Убираем billboard-метки над объектами (game.scene.setLabel).
|
||||||
try {
|
try {
|
||||||
|
this._stopLabelBindings(); // задача 10: остановить авто-биндинги подписей
|
||||||
if (this.scene3d?._labelManager) this.scene3d._labelManager.clearAll();
|
if (this.scene3d?._labelManager) this.scene3d._labelManager.clearAll();
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
// Phase 6.5: освобождаем физ-мир и его wasm-память.
|
// Phase 6.5: освобождаем физ-мир и его wasm-память.
|
||||||
@ -2494,14 +2593,29 @@ export class GameRuntime {
|
|||||||
this._applySelfMove(payload);
|
this._applySelfMove(payload);
|
||||||
return;
|
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') {
|
if (cmd === 'scene.rotate') {
|
||||||
try {
|
try {
|
||||||
const ry = Number(payload?.rotationY);
|
const ry = Number(payload?.rotationY);
|
||||||
if (!Number.isFinite(ry)) return;
|
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;
|
const pm = this.scene3d?.primitiveManager;
|
||||||
if (!pm) return;
|
const rid = (!isModel && pm) ? this._resolvePrimitiveId(payload?.id ?? payload?.ref) : null;
|
||||||
const rid = this._resolvePrimitiveId(payload?.id);
|
const data = (pm && rid != null) ? pm.instances.get(rid) : null;
|
||||||
const data = rid != null ? pm.instances.get(rid) : null;
|
|
||||||
if (data) {
|
if (data) {
|
||||||
data.rotationY = ry;
|
data.rotationY = ry;
|
||||||
if (data.mesh?.rotation) {
|
if (data.mesh?.rotation) {
|
||||||
@ -2511,9 +2625,28 @@ export class GameRuntime {
|
|||||||
data._worldMatrixFrozen = false;
|
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) {
|
} catch (e) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.warn('[GameRuntime] scene.rotate failed', e);
|
console.warn('[GameRuntime] scene.rotate failed', e);
|
||||||
@ -3555,6 +3688,10 @@ export class GameRuntime {
|
|||||||
} else if (kind === 'primitive') {
|
} else if (kind === 'primitive') {
|
||||||
this.scene3d?.primitiveManager?.removeInstance(Number(rest));
|
this.scene3d?.primitiveManager?.removeInstance(Number(rest));
|
||||||
}
|
}
|
||||||
|
// Задача 10: снять плашку удалённого объекта (иначе висит сиротой).
|
||||||
|
if (this.scene3d?._labelManager) {
|
||||||
|
try { this.scene3d._labelManager.clearLabel(ref); } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
// Удалили — снимаем mapping
|
// Удалили — снимаем mapping
|
||||||
for (const [k, v] of (this._localToReal || new Map()).entries()) {
|
for (const [k, v] of (this._localToReal || new Map()).entries()) {
|
||||||
if (v === ref) this._localToReal.delete(k);
|
if (v === ref) this._localToReal.delete(k);
|
||||||
@ -3684,6 +3821,23 @@ export class GameRuntime {
|
|||||||
return { blocks, models, primitives };
|
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) {
|
_applySelfMove(payload) {
|
||||||
if (!payload || !payload.target) return;
|
if (!payload || !payload.target) return;
|
||||||
const t = payload.target;
|
const t = payload.target;
|
||||||
|
|||||||
@ -1,80 +1,385 @@
|
|||||||
/**
|
/**
|
||||||
* LabelManager — billboard-метки (текст-плашки) над 3D-объектами.
|
* LabelManager — billboard-плашки (текст-надписи) над 3D-объектами.
|
||||||
*
|
*
|
||||||
* Используется для game.scene.setLabel(ref, text) — имена/HP над
|
* game.scene.setLabel(ref, text, opts) — имена/HP/таймеры/счётчики над
|
||||||
* персонажами, врагами, предметами. Метка всегда повёрнута лицом к камере
|
* персонажами, врагами, предметами. По умолчанию плашка повёрнута лицом к
|
||||||
* (billboardMode=7), всегда поверх геометрии (renderingGroupId=1).
|
* камере (billboardMode=7), всегда поверх геометрии (renderingGroupId=1).
|
||||||
*
|
*
|
||||||
* Метка привязывается к мешу объекта (parent) и висит над ним.
|
* Задача 10 — расширенные стили: фон/обводка/скругление (пресеты gameui/
|
||||||
|
* warning/reward/boss-hp/plain), обводка текста, richText (<color>/<b>/<size>),
|
||||||
|
* faceMode billboard|fixed, attachPoint, maxDistance.
|
||||||
|
*
|
||||||
|
* Плашка привязывается к мешу объекта (parent) и висит над ним.
|
||||||
*/
|
*/
|
||||||
import { DynamicTexture } from '@babylonjs/core/Materials/Textures/dynamicTexture';
|
import { DynamicTexture } from '@babylonjs/core/Materials/Textures/dynamicTexture';
|
||||||
import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial';
|
import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial';
|
||||||
import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
|
import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
|
||||||
import { Color3 } from '@babylonjs/core/Maths/math.color';
|
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 {
|
export class LabelManager {
|
||||||
constructor(scene) {
|
constructor(scene) {
|
||||||
this.scene = scene;
|
this.scene = scene;
|
||||||
// ref-строка объекта → { plane, tex, mat }
|
// ref-строка объекта → { plane, tex, mat, lastKey, opts }
|
||||||
this.labels = new Map();
|
this.labels = new Map();
|
||||||
|
this._playerMesh = null; // для maxDistance — задаётся из BabylonScene
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Дать ссылку на меш игрока (для maxDistance-скрытия). */
|
||||||
|
setPlayerMesh(mesh) { this._playerMesh = mesh; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Установить/обновить метку над объектом.
|
* Установить/обновить плашку над объектом.
|
||||||
* ref — ref-строка объекта (от scene.spawn / scene.find).
|
* ref — ref-строка объекта.
|
||||||
* anchorMesh — Babylon-меш объекта (метка крепится к нему).
|
* anchorMesh — Babylon-меш объекта (плашка крепится к нему).
|
||||||
* text — текст метки.
|
* text — текст (может содержать richText-теги если opts.richText).
|
||||||
* opts — { color: '#fff', height: 2.5 (м над объектом), size: 1 }
|
* opts — см. LABEL_PRESETS + { color, height, size, background,
|
||||||
|
* borderColor, borderWidth, cornerRadius, padding, textStroke,
|
||||||
|
* fontWeight, faceMode, rotationY, attachPoint, preset,
|
||||||
|
* richText, maxDistance }
|
||||||
*/
|
*/
|
||||||
setLabel(ref, anchorMesh, text, opts = {}) {
|
setLabel(ref, anchorMesh, text, opts = {}) {
|
||||||
if (!anchorMesh) return;
|
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 heightAbove = Number.isFinite(opts.height) ? opts.height : 2.5;
|
||||||
const sizeMul = Number.isFinite(opts.size) ? opts.size : 1;
|
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);
|
this.clearLabel(ref);
|
||||||
|
|
||||||
|
// Размер текстуры: чем больше текста — тем шире, чтобы не растягивать.
|
||||||
|
const fontPx = 120;
|
||||||
const W = 1024, H = 256;
|
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);
|
{ width: W, height: H }, this.scene, true);
|
||||||
tex.updateSamplingMode?.(3); // TRILINEAR
|
tex.updateSamplingMode?.(3); // TRILINEAR
|
||||||
tex.anisotropicFilteringLevel = 8;
|
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;
|
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}`,
|
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);
|
const mat = new StandardMaterial(`lblMat_${ref}`, this.scene);
|
||||||
mat.diffuseTexture = tex;
|
mat.diffuseTexture = tex;
|
||||||
mat.diffuseTexture.hasAlpha = true;
|
mat.diffuseTexture.hasAlpha = true;
|
||||||
mat.emissiveColor = new Color3(1, 1, 1);
|
mat.emissiveColor = new Color3(1, 1, 1);
|
||||||
|
mat.diffuseColor = new Color3(0, 0, 0);
|
||||||
mat.disableLighting = true;
|
mat.disableLighting = true;
|
||||||
|
// Геометрия двусторонняя (DOUBLESIDE) с прямыми backUVs — culling можно
|
||||||
|
// включить, дублей нет; текст читается с обеих сторон без зеркала.
|
||||||
mat.backFaceCulling = false;
|
mat.backFaceCulling = false;
|
||||||
mat.disableDepthWrite = true;
|
mat.disableDepthWrite = true;
|
||||||
|
mat.useAlphaFromDiffuseTexture = true;
|
||||||
plane.material = mat;
|
plane.material = mat;
|
||||||
plane.billboardMode = 7; // всегда лицом к камере
|
plane.renderingGroupId = 1;
|
||||||
plane.renderingGroupId = 1; // поверх геометрии
|
|
||||||
plane.isPickable = false;
|
plane.isPickable = false;
|
||||||
// Крепим к объекту: метка висит над ним и двигается вместе с ним.
|
|
||||||
plane.parent = anchorMesh;
|
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: парсим теги <color=#hex>...</color>, <b>...</b>, <size=N>...</size>.
|
||||||
|
* Рисуем сегментами по горизонтали, центрируя всю строку. Вложенность не
|
||||||
|
* поддерживается (на 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=...>
|
||||||
|
color = closing ? baseColor : m[3];
|
||||||
|
} else if (m[4]) { // <b>
|
||||||
|
bold = !closing;
|
||||||
|
} else if (m[6]) { // <size=N>
|
||||||
|
sizeMul = closing ? 1 : Math.max(0.4, Math.min(2, Number(m[7]) / 100));
|
||||||
|
}
|
||||||
|
// <i> игнорим визуально (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) {
|
clearLabel(ref) {
|
||||||
const rec = this.labels.get(ref);
|
const rec = this.labels.get(ref);
|
||||||
if (!rec) return;
|
if (!rec) return;
|
||||||
@ -84,7 +389,7 @@ export class LabelManager {
|
|||||||
this.labels.delete(ref);
|
this.labels.delete(ref);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Удалить все метки (при выходе из Play). */
|
/** Удалить все плашки (при выходе из Play). */
|
||||||
clearAll() {
|
clearAll() {
|
||||||
for (const ref of [...this.labels.keys()]) this.clearLabel(ref);
|
for (const ref of [...this.labels.keys()]) this.clearLabel(ref);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,12 +40,30 @@ function _getStudsTextures(scene) {
|
|||||||
let c = _studsTexCache.get(scene);
|
let c = _studsTexCache.get(scene);
|
||||||
if (!c) {
|
if (!c) {
|
||||||
const diffuse = new Texture(STUDS_DIFFUSE_URL, scene);
|
const diffuse = new Texture(STUDS_DIFFUSE_URL, scene);
|
||||||
const normal = new Texture(STUDS_NORMAL_URL, scene);
|
// base — uScale=1 (для cube/trigger: тайлинг через faceUV геометрии).
|
||||||
c = { diffuse, normal };
|
// tiled — кэш клонов по ключу 'u_v' для не-кубических форм, чтобы НЕ
|
||||||
|
// плодить по текстуре на каждый меш (был источник FPS-просадки: десятки
|
||||||
|
// клонов diffuse+normal = десятки GPU-ресурсов + дорогой bump на каждом).
|
||||||
|
c = { diffuse, tiled: new Map() };
|
||||||
_studsTexCache.set(scene, c);
|
_studsTexCache.set(scene, c);
|
||||||
}
|
}
|
||||||
return 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 по размеру меша. Чтобы кружки не
|
* Посчитать тайлинг (uScale/vScale) для studs по размеру меша. Чтобы кружки не
|
||||||
* растягивались: число кружков на грань = размер_грани / STUD_UNIT, делённое на
|
* растягивались: число кружков на грань = размер_грани / STUD_UNIT, делённое на
|
||||||
@ -180,6 +198,8 @@ export class PrimitiveManager {
|
|||||||
rotationX, rotationY, rotationZ,
|
rotationX, rotationY, rotationZ,
|
||||||
color, material, canCollide, visible, anchored, mass,
|
color, material, canCollide, visible, anchored, mass,
|
||||||
textureAsset, studDensity,
|
textureAsset, studDensity,
|
||||||
|
// Подпись над объектом (задача 10) — восстанавливается из project_data.
|
||||||
|
label: opts.label || null,
|
||||||
// locked — объект защищён от выделения/перемещения в редакторе
|
// locked — объект защищён от выделения/перемещения в редакторе
|
||||||
// (Фаза 5.11). На геймплей не влияет.
|
// (Фаза 5.11). На геймплей не влияет.
|
||||||
locked: opts.locked === true,
|
locked: opts.locked === true,
|
||||||
@ -519,22 +539,21 @@ export class PrimitiveManager {
|
|||||||
// Лего-материал: почти белая diffuse-текстура с лёгкими кружками
|
// Лего-материал: почти белая diffuse-текстура с лёгкими кружками
|
||||||
// умножается на цвет меша → цвет остаётся СОЧНЫМ (как в Roblox).
|
// умножается на цвет меша → цвет остаётся СОЧНЫМ (как в Roblox).
|
||||||
// emissive = доля цвета → цвет «светится», не тускнеет в тени.
|
// emissive = доля цвета → цвет «светится», не тускнеет в тени.
|
||||||
const tex = _getStudsTextures(this.scene);
|
// Объём студов запечён в diffuse v4 (baked-тени) — bumpTexture НЕ
|
||||||
const dt = tex.diffuse.clone();
|
// используем: normal-mapping удваивает стоимость шейдера на каждом
|
||||||
const nt = tex.normal.clone();
|
// меше и почти не виден на маленьких студах. Текстуры ШАРЯТСЯ
|
||||||
|
// (общая для cube, кэш-клон по тайлингу для форм) — без этого
|
||||||
|
// десятки клонов роняли FPS.
|
||||||
const dims = mesh._studsDims || { type: 'cube', sx: 1, sy: 1, sz: 1 };
|
const dims = mesh._studsDims || { type: 'cube', sx: 1, sy: 1, sz: 1 };
|
||||||
// Куб/триггер тайлятся через faceUV геометрии (uScale=1) — кружки
|
let dt;
|
||||||
// одного размера на всех гранях. Остальные формы — через uScale.
|
|
||||||
if (dims.type === 'cube' || dims.type === 'trigger') {
|
if (dims.type === 'cube' || dims.type === 'trigger') {
|
||||||
dt.uScale = nt.uScale = 1;
|
// uScale=1 — тайлинг через faceUV геометрии. Общая текстура.
|
||||||
dt.vScale = nt.vScale = 1;
|
dt = _getStudsTextures(this.scene).diffuse;
|
||||||
} else {
|
} else {
|
||||||
const tile = _studsTiling(dims.type, dims.sx, dims.sy, dims.sz, dims.density);
|
const tile = _studsTiling(dims.type, dims.sx, dims.sy, dims.sz, dims.density);
|
||||||
dt.uScale = nt.uScale = tile.u;
|
dt = _getStudsTiledTexture(this.scene, tile.u, tile.v);
|
||||||
dt.vScale = nt.vScale = tile.v;
|
|
||||||
}
|
}
|
||||||
mat.diffuseTexture = dt;
|
mat.diffuseTexture = dt;
|
||||||
mat.bumpTexture = nt;
|
|
||||||
const sc = Color3.FromHexString(color || '#cccccc');
|
const sc = Color3.FromHexString(color || '#cccccc');
|
||||||
mat.diffuseColor = sc;
|
mat.diffuseColor = sc;
|
||||||
// Сочность: подмешиваем цвет в emissive (45%) — Roblox-look,
|
// Сочность: подмешиваем цвет в 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();
|
this._notifyChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -917,6 +944,8 @@ export class PrimitiveManager {
|
|||||||
...(d.textureAsset ? { textureAsset: d.textureAsset } : {}),
|
...(d.textureAsset ? { textureAsset: d.textureAsset } : {}),
|
||||||
// Плотность studs (если не 1) — мелкие/крупные кружки.
|
// Плотность studs (если не 1) — мелкие/крупные кружки.
|
||||||
...(d.studDensity && d.studDensity !== 1 ? { studDensity: d.studDensity } : {}),
|
...(d.studDensity && d.studDensity !== 1 ? { studDensity: d.studDensity } : {}),
|
||||||
|
// Подпись над объектом (задача 10) — если включена.
|
||||||
|
...(d.label && d.label.enabled ? { label: d.label } : {}),
|
||||||
// Параметры лампы (только для type='light', иначе undefined)
|
// Параметры лампы (только для type='light', иначе undefined)
|
||||||
...(d.light ? { brightness: d.brightness, range: d.range } : {}),
|
...(d.light ? { brightness: d.brightness, range: d.range } : {}),
|
||||||
// Параметр эмиттера (только для type='emitter')
|
// Параметр эмиттера (только для type='emitter')
|
||||||
|
|||||||
@ -272,6 +272,10 @@ const _tweenCallbacks = {};
|
|||||||
// Тикаются в обработчике cmd='tick' по накоплению dt.
|
// Тикаются в обработчике cmd='tick' по накоплению dt.
|
||||||
let _timers = [];
|
let _timers = [];
|
||||||
let _timerSeq = 0;
|
let _timerSeq = 0;
|
||||||
|
// Биндинги лейблов (задача 10): ref → { timerId, lastText }. bindLabel/bindTimer
|
||||||
|
// создают повторяющийся таймер, который зовёт fn() и шлёт setLabel при изменении
|
||||||
|
// текста. Автоотменяются при scene.delete(ref) (через instTouch/destroying).
|
||||||
|
const _labelBindings = new Map();
|
||||||
// Зеркало всех объектов сцены (заполняется main через cmd='sceneSnapshot' каждый раз
|
// Зеркало всех объектов сцены (заполняется main через cmd='sceneSnapshot' каждый раз
|
||||||
// когда что-то меняется). Это позволяет game.scene.find/all/get работать синхронно.
|
// когда что-то меняется). Это позволяет game.scene.find/all/get работать синхронно.
|
||||||
// { blocks: [{ref, type, x, y, z, name?}], models: [...], primitives: [...] }
|
// { 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 === 'removeTag') return (t) => _send('scene.untag', { ref, tag: t });
|
||||||
if (prop === 'tween') return (props, opts) => game.tween(ref, props, opts);
|
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 });
|
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 === 'setLabel') return (text, o) => _send('scene.setLabel', { ref, text, opts: o || {} });
|
||||||
if (prop === 'clearLabel') return () => _send('scene.clearLabel', { ref });
|
if (prop === 'clearLabel') return () => _send('scene.clearLabel', { ref });
|
||||||
|
|
||||||
@ -594,6 +605,13 @@ function _emitInstDestroying(ref) {
|
|||||||
_instEvents.delete(ref);
|
_instEvents.delete(ref);
|
||||||
_instCache.delete(ref);
|
_instCache.delete(ref);
|
||||||
_instLastValues.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) {
|
clearLabel(ref) {
|
||||||
ref = _normRef(ref);
|
ref = _normRef(ref);
|
||||||
if (!ref) return;
|
if (!ref) return;
|
||||||
|
this.unbindLabel(ref);
|
||||||
_send('scene.clearLabel', { 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 = полностью невидимо.
|
* Прозрачность примитива: 1 = непрозрачно, 0 = полностью невидимо.
|
||||||
* Для плавного исчезновения используй game.tween(ref, {opacity: 0}, ...).
|
* Для плавного исчезновения используй game.tween(ref, {opacity: 0}, ...).
|
||||||
@ -3429,6 +3513,79 @@ const game = {
|
|||||||
return a + Math.random() * (b - a);
|
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).
|
* Аргументы могут быть {x,y,z} или ref-строкой (для ref берём позицию из sceneIndex).
|
||||||
@ -3971,6 +4128,7 @@ self.onmessage = (e) => {
|
|||||||
} else if (cmd === 'stop') {
|
} else if (cmd === 'stop') {
|
||||||
_tickHandlers = [];
|
_tickHandlers = [];
|
||||||
_timers = [];
|
_timers = [];
|
||||||
|
_labelBindings.clear();
|
||||||
_selfClickHandlers = [];
|
_selfClickHandlers = [];
|
||||||
_selfTouchHandlers = [];
|
_selfTouchHandlers = [];
|
||||||
_selfUntouchHandlers = [];
|
_selfUntouchHandlers = [];
|
||||||
|
|||||||
@ -128,6 +128,8 @@ export class SelectionManager {
|
|||||||
range: data.range,
|
range: data.range,
|
||||||
effect: data.effect,
|
effect: data.effect,
|
||||||
textureAsset: data.textureAsset || null,
|
textureAsset: data.textureAsset || null,
|
||||||
|
studDensity: data.studDensity || 1,
|
||||||
|
label: data.label || null, // подпись над объектом (задача 10)
|
||||||
locked: !!data.locked,
|
locked: !!data.locked,
|
||||||
mesh: data.mesh,
|
mesh: data.mesh,
|
||||||
rootMesh: data.mesh,
|
rootMesh: data.mesh,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user