diff --git a/README.md b/README.md
index cffa6b9..1591aa7 100644
--- a/README.md
+++ b/README.md
@@ -168,3 +168,4 @@ git push origin feature/моя-фича
- Issues и PR: https://git.rublox.pro/rublox/studio
- Безопасность: [SECURITY.md](./SECURITY.md)
+
diff --git a/src/community/docsData.jsx b/src/community/docsData.jsx
index a6b1dd3..ae4fcc7 100644
--- a/src/community/docsData.jsx
+++ b/src/community/docsData.jsx
@@ -104,6 +104,123 @@ export const Shot = ({ src, caption, wide }) => (
// DOCS — разделы вики A-J
// ══════════════════════════════════════════════════════════════════
+// ════════════════════════════════════════════════════════════
+// AI_CONTEXT — полный справочник API скриптов Рублокса одним
+// текстом для вставки в нейросеть. Держать в синхроне с
+// ScriptSandboxWorker.js при добавлении новых game-API.
+// ════════════════════════════════════════════════════════════
+const AI_CONTEXT = `Ты — помощник по написанию скриптов для онлайн-конструктора 3D-игр «Рублокс» (аналог Roblox, движок Babylon.js). Скрипты пишутся на JavaScript. Всё доступно через глобальный объект game. Ниже — полный API. Пиши ТОЛЬКО рабочий код, используя ТОЛЬКО эти методы. Не выдумывай команды. Координаты в метрах, повороты в РАДИАНАХ (90°=Math.PI/2).
+
+=== ДВА ВИДА СКРИПТОВ ===
+1) Глобальный — в категории «Скрипты», запускается 1 раз при старте. Управляет всей сценой.
+2) На объекте — висит на примитиве/модели/блоке. В нём доступен game.self — сам объект-носитель.
+Всегда указывай пользователю, КУДА писать скрипт.
+
+=== ИГРОК game.player ===
+.position -> {x,y,z}; .yaw, .pitch (рад); .forward -> {x,y,z} (куда смотрит); .hp, .maxHp, .alive
+.teleport(x,y,z); .damage(n); .kill(); .heal(n); .respawn()
+.setSpeed(mul); .setJumpPower(mul); .setGravityMul(mul); .setInputBlocked(bool)
+.giveTool(toolId, {equip:true}); .removeTool(id); .setSkin(slug); .setTeam(name)
+.isKeyDown('w'|'a'|'s'|'d'|'space'|'shift'...); .boostJump(strength); .setDoubleJump(bool)
+.setCameraMode('first'|'third'|'front'); .playEmote('wave'|'dance'|'cheer'|'sit')
+
+=== ОБЪЕКТ-НОСИТЕЛЬ game.self (только в скрипте на объекте) ===
+.ref ('primitive:N'); .position -> {x,y,z}; .kind
+.onTouch(fn) — игрок коснулся; .onUntouch(fn) — отошёл
+.onClick(fn) — клик мышью по объекту
+.onInteract(fn, {text:'Открыть', key:'e', distance:4, holdDuration:0}) — действие по клавише E
+.move(x,y,z); .rotateY(rad); .setColor('#hex'); .setVisible(bool); .setCollide(bool)
+.setLabel(text, {color,height}); .clearLabel(); .delete()
+
+=== СЦЕНА game.scene ===
+spawn(type, opts) -> объект. type: 'cube'|'sphere'|'cylinder'|'cone'|'pyramid'|'torus'|'wedge'|'plane' (примитивы), 'model:ID', 'block:ID', 'vehicle:car', 'light:point', 'billboard', 'trigger'.
+ opts: {x,y,z, sx,sy,sz, rotationX,rotationY,rotationZ, color:'#hex', material:'matte'|'neon'|'metal'|'glass'|'studs', name, anchored:true(висит)/false(падает), canCollide, visible, mass, lifetime(сек до авто-удаления)}
+delete(ref); move(ref,x,y,z); setRotation(ref,rx,ry,rz); setColor(ref,'#hex'); setMaterial(ref,name); setVisible(ref,bool); setCollide(ref,bool); setOpacity(ref,0..1); setScale(ref,sx,sy,sz)
+setLabel(ref,text,opts); clearLabel(ref); setData(ref,key,val); getData(ref,key)
+find(name)->[...]; findOne(name)->объект|null; all('primitive'|'model'|'block')
+tag(ref,tag); untag(ref,tag); hasTag(ref,tag); getTagged(tag)->[refs]
+clone(ref,{dx,dy,dz}); spawnParticles('fire'|'smoke'|'sparks'|'magic'|'explosion'|'confetti', {x,y,z}, {duration,count,color})
+setSkybox({preset:'clear-summer-day'|'sunset'|'starry-night'|'space'|'cloudy'}); setFog({color,density}); setClouds({enabled,cover,speed})
+spawnNpc(modelType, {x,y,z,hp,name,speed}) -> npc. npc: .position, .hp, .follow('player'|ref), .moveTo(x,z), .stop(), .say(text,sec), .damage(n), .setSpeed(s), .onDeath(fn), .remove()
+
+=== ОБЪЕКТ-ПРОКСИ (то, что вернул spawn/findOne) ===
+obj.ref; obj.position; obj.color='#hex'; obj.material='neon'; obj.scale=2; obj.opacity=0.5; obj.visible=false; obj.canCollide=false; obj.position={x,y,z}
+obj.move(x,y,z); obj.rotateY(rad); obj.onTouch(fn); obj.onClick(fn); obj.onInteract(fn,opts); obj.clone({dx,dy,dz}); obj.destroy(); obj.tween(props,opts); obj.setLabel(t,opts); obj.addTag(t)
+
+=== HUD game.ui ===
+.score = N (счётчик в углу); .timer = N (таймер mm:ss); .showText('текст', сек) (крупно по центру)
+.set(id, text, {x,y,color,size}) (своя метка, x/y в %); .remove(id); .clear()
+
+=== GUI (кнопки/панели на экране) game.gui ===
+create('button'|'text'|'frame'|'image', {name,x,y,w,h,text,bg:'#hex',color,fontSize,borderRadius}) -> id
+onClick(id, fn); update(id, {text,...}); show(id); hide(id); remove(id); tween(id, props, {duration})
+
+=== ТАЙМЕРЫ И КАДРЫ ===
+game.onTick(fn) — каждый кадр, fn(dt) dt=сек с прошлого кадра
+game.after(сек, fn) -> id — один раз через N сек
+game.every(сек, fn) -> id — повторять каждые N сек
+game.cancel(id) — отменить таймер
+game.tween(ref, props, {duration, easing:'linear'|'ease'|'bounce'|'elastic', yoyo, repeat:-1, onDone}) — анимация свойств (props: x,y,z, rotationX/Y/Z, sx/sy/sz, color, opacity)
+
+=== СОБЫТИЯ ===
+game.onKey('w', fn); game.onKeyUp('w', fn); game.onClick(fn) (fn({point,target}))
+game.onPlayerDied(fn); game.onHpChange(fn); game.onPlayerJump(fn); game.onMobKilled(fn)
+game.broadcast(name, data) — послать сообщение всем скриптам
+game.onMessage(name, fn) — принять сообщение (fn(data))
+
+=== ФИЗИКА game.physics ===
+raycast(origin, dir, {maxDistance}) -> {hit, ref, point, distance} (синхронно, для стрельбы)
+applyImpulse(ref, {x,y,z}); setVelocity(ref, {x,y,z}); explode({x,y,z}, radius, {damage}); passThrough(refОrTag, bool)
+
+=== ЭФФЕКТЫ game.fx ===
+damageFloater(posОrRef, value, {isCrit,isHeal,isMiss,color}) — всплывающая цифра урона
+autoMobFloaters(true) — авто-цифры над всеми мобами
+beam({from,to,color,width}) -> луч; pointer({from,to,preset:'quest'}); trail(ref,{color,width})
+
+=== ЗВУК game.sound ===
+play('coin'|'jump'|'win'|'lose'|'click'|'hit'|'pickup' | 'sound_N', {volume,pitch,at:{x,y,z},loop}) -> {stop()}
+
+=== КАМЕРА game.camera ===
+shake(amp, sec); setFov(deg); focusOn(ref,{distance,height}); cutscene([{x,y,z}...], {lookAt,segDuration}); reset()
+
+=== ОКРУЖЕНИЕ game.environment ===
+setSkyColor('#hex'); setFog({enabled,color,density}); setTimeOfDay(0..24)
+
+=== ИНВЕНТАРЬ / ПРЕДМЕТЫ ===
+game.items.define([{id,name,emoji,rarity:'common'|'rare'|'epic'|'legendary',maxStack,onUseEffect:'heal:50'}])
+game.inventory.give(id,count); .take(id,count); .has(id); .open(); .list()
+
+=== ЛИДЕРБОРД И ДОСТИЖЕНИЯ ===
+game.leaderstats.define('Монеты', {initial:0}); game.leaderstats.me.add('Монеты', 1); .me.set(name,val); .me.get(name)
+game.achievements.define([{id,name,description,icon,rarity}]); game.achievements.unlock(id)
+
+=== СОХРАНЕНИЕ (между сессиями) game.save ===
+.merge(namespace, {patch:{поле:знач}, increment:{счётчик:дельта}, max:{рекорд:знач}})
+.get(namespace, fn) (fn(data|null)); .increment(ns,key,delta); .leaderboard(ns,key,'desc',fn)
+
+=== МОДАЛКИ game.modal ===
+.dialog(npcName, [строки], onDone); .confirmation(title, body, onYes, onNo); .lootbox([{name,color,icon,rarity}], onPick); .close()
+
+=== УТИЛИТЫ ===
+game.log(...) — в консоль; game.random(min,max,целое?); game.clamp(v,min,max); game.lerp(a,b,t); game.distance(a,b) (точки или ref); game.format.time(сек,'mm:ss'); game.format.number(n,'short')
+
+=== ВАЖНЫЕ ПРАВИЛА ===
+- Повороты в РАДИАНАХ (Math.PI/2 = 90°).
+- Счётчики/общую логику держи в ОДНОМ глобальном скрипте; объекты шлют game.broadcast (скрипты объектов не видят переменные друг друга).
+- spawn примитива: тип без префикса ('cube'), модели — с 'model:', машина — 'vehicle:car'.
+- material только: matte, neon, metal, glass, studs.
+- Для сбора предмета: game.self.onTouch(()=>{ game.broadcast('coin'); game.self.delete(); }).
+- Не используй require() и DOM/window — только game.* и обычный JS.
+
+ПРИМЕР (килблок-лава, скрипт НА объекте):
+game.self.onTouch(() => { game.player.kill(); game.ui.showText('Лава!', 2); });
+
+ПРИМЕР (сбор монет, глобальный скрипт):
+let s = 0; game.ui.score = 0;
+game.onMessage('coin', () => { s++; game.ui.score = s; game.sound.play('coin'); });
+
+Теперь напиши скрипт под мою задачу (она ниже). Укажи, КУДА его вставить (глобальный или на объект).`;
+
export const DOCS = [
// ════════════════════════════════════════════════════
// РАЗДЕЛ A — ОСНОВЫ
@@ -2029,6 +2146,167 @@ game.log('Игроков в комнате:', game.players.count());
// когда новый игрок зашёл
game.onPlayerJoin((p) => {
game.ui.showText(p.name + ' присоединился!', 2);
+});`}
+ >
+ ),
+ },
+ {
+ id: 'leaderstats',
+ title: 'G7. Лидерборды и достижения',
+ body: (
+ <>
+
+ Лидерборд — таблица очков игроков справа-сверху (как
+ в Roblox). Объяви стат и меняй значение:
+
+
+ {`game.leaderstats.define('Монеты', { initial: 0, icon: 'coin' });
+game.leaderstats.define('Уровень', { initial: 1 });
+
+game.leaderstats.me.add('Монеты', 5); // +5 текущему игроку
+game.leaderstats.me.set('Уровень', 2); // задать значение
+const c = game.leaderstats.me.get('Монеты');`}
+
+ Достижения — всплывающие ачивки с редкостью и звуком:
+
+ {`game.achievements.define([
+ { id: 'first_coin', name: 'Первая монетка', description: 'Собери монету', icon: 'coin', rarity: 'common' },
+ { id: 'rich', name: 'Богач', description: '100 монет', icon: 'trophy', rarity: 'legendary' }
+]);
+game.achievements.unlock('first_coin');
+// или авто-разблокировка по статy:
+game.achievements.bindToStat('rich', 'Монеты', 100);`}
+
+ Лидерборд и достижения сохраняются в БД и подтягиваются при
+ следующем входе игрока.
+
+ >
+ ),
+ },
+ {
+ id: 'damage-floaters',
+ title: 'G8. Облачка урона (damage floaters)',
+ body: (
+ <>
+
+ Всплывающие цифры урона над врагом — как в RPG. Самый
+ простой способ — авто-режим (цифры над всеми мобами при уроне):
+
+
+ {`game.fx.autoMobFloaters(true);`}
+ Ручной вызов в нужный момент:
+ {`game.fx.damageFloater(enemy.position, 25); // обычный урон
+game.fx.damageFloater(enemy.position, 100, { isCrit: true }); // крит — крупно, жёлтый
+game.fx.damageFloater('player', 30, { isHeal: true }); // лечение, зелёный
+game.fx.damageFloater(pos, 0, { isMiss: true }); // промах MISS`}
+ >
+ ),
+ },
+ {
+ id: 'items-inventory',
+ title: 'G9. Предметы и инвентарь с редкостями',
+ body: (
+ <>
+
+ Полноценный инвентарь (сетка + хотбар, стаки, редкости).
+ Сначала опиши предметы, потом выдавай:
+
+
+ {`game.items.define([
+ { id: 'berry', name: 'Ягоды', emoji: '🍓', rarity: 'common', maxStack: 16 },
+ { id: 'potion', name: 'Зелье', emoji: '🧪', rarity: 'rare', maxStack: 8, onUseEffect: 'heal:50' },
+ { id: 'sword', name: 'Меч', emoji: '⚔️', rarity: 'legendary', maxStack: 1 },
+]);
+
+game.inventory.give('sword', 1);
+game.inventory.give('berry', 5); // стак`}
+ Сбор предмета с земли (скрипт на предмете):
+
+ {`game.self.onInteract(() => {
+ game.inventory.give('berry', 2);
+ game.self.delete();
+}, { text: 'Собрать', key: 'e', distance: 3 });`}
+
+ Редкости: common (серый), uncommon (зелёный), rare (голубой),
+ epic (фиолетовый), legendary (золотой). Окно инвентаря —
+ клавиша I , drag-drop, ПКМ-меню.
+
+ >
+ ),
+ },
+ {
+ id: 'sky-environment',
+ title: 'G10. Небо, облака, туман, время суток',
+ body: (
+ <>
+ Кастомное небо одной строкой — пресеты:
+
+ {`game.scene.setSkybox({ preset: 'sunset' });
+// пресеты: clear-summer-day | lowpoly-roblox | cloudy | sunset | starry-night | space`}
+ Облака, туман и плавный переход:
+ {`game.scene.setClouds({ enabled: true, cover: 0.5, speed: 0.02 });
+game.scene.setFog({ color: '#dddddd', density: 0.006 });
+game.scene.skybox.fadeTo({ preset: 'starry-night' }, 3); // плавно за 3 сек`}
+ Простое управление цветом неба и временем суток:
+ {`game.environment.setSkyColor('#0a1024'); // тёмное небо
+game.environment.setTimeOfDay(0); // ночь (0..24)
+game.environment.setTimeOfDay(12); // полдень`}
+ >
+ ),
+ },
+ {
+ id: 'modal-menu-loading',
+ title: 'G11. Диалоги, меню, экран загрузки',
+ body: (
+ <>
+ Диалог NPC построчно:
+
+ {`game.modal.dialog('Староста', [
+ 'Привет, путник!',
+ 'Собери 10 монет и возвращайся.',
+], () => game.ui.showText('Квест начат!', 2));`}
+ Окно Да/Нет и лутбокс :
+ {`game.modal.confirmation('Выход', 'Точно выйти?', () => game.player.respawn(), null);
+
+game.modal.lootbox([
+ { name: 'Меч', color: '#f0ad4e', rarity: 'legendary' },
+ { name: 'Щит', color: '#5bc0de', rarity: 'rare' },
+], (item) => game.ui.showText('Выпал: ' + item.name, 3));`}
+ Экран загрузки при переходе между уровнями:
+ {`game.loading.show({
+ style: 'ken-burns',
+ placeName: 'Глава 2 — Шахта',
+ studioName: 'Моя студия',
+ duration: 2
+});
+game.loading.onHide(() => game.ui.showText('Добро пожаловать!', 2));`}
+
+ Стартовый экран загрузки игры настраивается без кода —
+ см. раздел вики «Экран загрузки» (карточка в разборе игр) и
+ вкладку «Стартовый экран» в настройках проекта.
+
+ >
+ ),
+ },
+ {
+ id: 'vehicles-menu',
+ title: 'G12. Машины и главное меню',
+ body: (
+ <>
+
+ Машина , на которой можно ездить (вход hold-F, WASD руль):
+
+
+ {`game.scene.spawn('vehicle:car', { x: 0, y: 1, z: 0, name: 'Тачка' });
+game.onVehicleEnter(() => game.ui.showText('За рулём! WASD — ехать', 2));
+game.onVehicleExit(() => game.ui.showText('Вышел', 1));`}
+ Главное меню игры с живой камерой и кнопкой ИГРАТЬ:
+ {`game.mainMenu.show({
+ title: 'МОЯ ИГРА',
+ camera: 'orbit',
+ playButtonText: 'ИГРАТЬ',
+ patchNotes: { title: 'Что нового', items: ['Добавлены машины', 'Новая карта'] },
+ onPlay: () => game.ui.showText('Поехали!', 2)
});`}
>
),
@@ -2398,4 +2676,632 @@ game.onTick(() => {
},
],
},
+
+ // ════════════════════════════════════════════════════
+ // РАЗДЕЛ — СКРИПТЫ: ГОТОВЫЕ РЕЦЕПТЫ
+ // ════════════════════════════════════════════════════
+ {
+ id: 'recipes',
+ icon: 'code',
+ title: 'Скрипты: рецепты',
+ summary: 'Готовые мини-скрипты, которые можно скопировать и вставить: килблок, сбор предметов, исчезновение и телепорт при касании, кнопки, таймеры, все свойства примитивов.',
+ sections: [
+ {
+ id: 'recipes-howto',
+ title: 'S1. Куда писать скрипты',
+ body: (
+ <>
+ В Рублоксе есть два вида скриптов :
+
+ Глобальный — создаётся в иерархии в категории
+ «Скрипты» (кнопка «+»). Запускается один раз при
+ старте игры. В нём управляешь всей сценой через
+ game.scene, game.player, game.ui.
+ На объекте — вешается прямо на примитив/модель/блок.
+ В нём работает game.self — это сам объект-носитель.
+ Удобно для «этот блок убивает», «этот сундук открывается».
+
+
+ В рецептах ниже над каждым примером написано, куда его
+ писать . Просто скопируй код и вставь в нужный скрипт.
+
+
+ Полезное: game.log(...) печатает в консоль (значок
+ «Консоль» внизу) — для отладки. game.player.position
+ — где сейчас игрок ({'{x, y, z}'}).
+
+ >
+ ),
+ },
+ {
+ id: 'recipes-touch',
+ title: 'S2. Касание объекта (onTouch)',
+ body: (
+ <>
+
+ Самое частое событие — игрок коснулся объекта . Вешаем
+ скрипт на объект и подписываемся через game.self.onTouch.
+
+
+ {`// Игрок наступил на объект — показать надпись и звук
+game.self.onTouch(() => {
+ game.ui.showText('Ты коснулся плиты!', 2);
+ game.sound.play('click');
+});
+
+// Когда игрок ушёл с объекта
+game.self.onUntouch(() => {
+ game.ui.showText('Отошёл', 1);
+});`}
+
+ Можно подписаться и на чужой объект из глобального
+ скрипта — найди его по имени:
+
+
+ {`const trap = game.scene.findOne('Ловушка');
+trap.onTouch(() => game.player.damage(20));`}
+ >
+ ),
+ },
+ {
+ id: 'recipes-killblock',
+ title: 'S3. Килблок — урон и смерть при касании',
+ body: (
+ <>
+
+ Килблок — объект, который наносит урон или мгновенно
+ убивает, когда игрок его коснулся (лава, шипы, кислота).
+
+
+ {`// Мгновенная смерть при касании
+game.self.onTouch(() => {
+ game.player.kill();
+ game.ui.showText('💀 Ты сгорел в лаве!', 2);
+});`}
+ Если хочешь не убивать сразу, а наносить урон:
+ {`// Урон 25 при касании (учитывает кадры неуязвимости)
+game.self.onTouch(() => {
+ game.player.damage(25);
+ game.camera.shake(0.2, 0.3); // лёгкая тряска
+});`}
+
+ Постоянный урон , пока игрок стоит в зоне (например,
+ ядовитое облако) — урон каждые 0.5 сек, пока касается:
+
+ {`let inside = false;
+game.self.onTouch(() => { inside = true; });
+game.self.onUntouch(() => { inside = false; });
+game.every(0.5, () => {
+ if (inside) game.player.damage(5);
+});`}
+
+ Сделай красный неоновый куб, повесь на него скрипт смерти —
+ получится лава. Поставь его в проёме как преграду.
+
+ >
+ ),
+ },
+ {
+ id: 'recipes-disappear',
+ title: 'S4. Исчезновение при касании (сбор монет)',
+ body: (
+ <>
+
+ Предмет исчезает , когда игрок его коснулся — основа
+ сбора монеток, ключей, бонусов.
+
+
+ {`// Простое исчезновение + звук
+game.self.onTouch(() => {
+ game.sound.play('coin');
+ game.self.delete();
+});`}
+
+ Со счётчиком : предмет сообщает глобальному скрипту,
+ тот считает. На монетке:
+
+ {`game.self.onTouch(() => {
+ game.broadcast('coin'); // сообщить всем скриптам
+ game.self.delete();
+});`}
+ В глобальном скрипте — приём и счёт:
+
+ {`let score = 0;
+game.ui.score = 0;
+game.onMessage('coin', () => {
+ score = score + 1;
+ game.ui.score = score; // обновить счётчик в углу
+ if (score >= 10) game.ui.showText('🏆 Собрал все!', 3);
+});`}
+
+ Не ставь счётчик на саму монетку — каждая монетка это
+ свой скрипт, они не видят переменные друг друга. Считай в
+ одном глобальном скрипте, монетки только шлют
+ game.broadcast.
+
+ >
+ ),
+ },
+ {
+ id: 'recipes-teleport',
+ title: 'S5. Телепорт и смена позиции при касании',
+ body: (
+ <>
+
+ При касании переместить игрока (портал) или
+ сдвинуть сам объект (движущаяся платформа).
+
+ Портал — телепорт игрока в точку:
+
+ {`game.self.onTouch(() => {
+ game.player.teleport(0, 20, 50); // x, y, z назначения
+ game.sound.play('win');
+ game.camera.shake(0.15, 0.2);
+});`}
+
+ Сдвинуть сам объект при касании (например, опустить
+ мост). game.self.move ставит новую позицию:
+
+ {`let opened = false;
+game.self.onTouch(() => {
+ if (opened) return;
+ opened = true;
+ const p = game.self.position;
+ game.self.move(p.x, p.y - 3, p.z); // уехал вниз на 3 м
+});`}
+
+ Плавно сдвинуть — через game.tween (анимация):
+
+ {`// дверь уезжает вбок за 1 секунду
+const p = game.self.position;
+game.self.onTouch(() => {
+ game.tween(game.self.ref, { x: p.x + 4 }, { duration: 1, easing: 'ease' });
+});`}
+ >
+ ),
+ },
+ {
+ id: 'recipes-primitive-props',
+ title: 'S6. Все свойства примитивов из скрипта',
+ body: (
+ <>
+
+ Любой примитив можно создать и менять из
+ скрипта. Вот все свойства и как их задать.
+
+
+ Создать примитив со всеми свойствами:
+ {`const box = game.scene.spawn('cube', {
+ x: 0, y: 2, z: 0, // позиция
+ sx: 2, sy: 1, sz: 3, // размер по осям (ширина/высота/глубина)
+ rotationX: 0, rotationY: 0.8, rotationZ: 0, // поворот в радианах
+ color: '#ff5533', // цвет (hex)
+ material: 'neon', // matte | neon | metal | glass | studs
+ name: 'МойКуб',
+ anchored: true, // true = висит на месте; false = падает (физика)
+ canCollide: true, // false = игрок проходит насквозь
+ visible: true,
+ mass: 5, // масса (если anchored:false)
+});`}
+ Типы примитивов для spawn:
+ {`'cube' 'sphere' 'cylinder' 'cone' 'pyramid' 'torus' 'wedge' 'cornerwedge' 'plane'`}
+ Менять свойства уже существующего объекта:
+ {`game.scene.setColor(box, '#00ff88'); // цвет
+game.scene.setMaterial(box, 'glass'); // материал
+game.scene.setVisible(box, false); // спрятать
+game.scene.setCollide(box, false); // сделать проходимым
+game.scene.setOpacity(box, 0.4); // полупрозрачность (1=видно, 0=невидимо)
+game.scene.setScale(box, 3, 1, 1); // новый размер
+game.scene.move(box, 5, 2, 0); // переместить
+game.scene.setRotation(box, 0, 1.57, 0); // повернуть (радианы)
+game.scene.setLabel(box, 'Привет!', { color:'#fff', height: 2.5 });`}
+
+ Удобнее — через объект-прокси (присваивание свойств):
+
+ {`const obj = game.scene.findOne('МойКуб');
+obj.color = '#ffd700';
+obj.material = 'metal';
+obj.scale = 2; // равномерный масштаб
+obj.opacity = 0.5;
+obj.visible = false;
+obj.canCollide = false;
+obj.position = { x: 0, y: 10, z: 0 };
+obj.rotateY(1.57);
+obj.destroy(); // удалить`}
+
+ Радианы: поворот задаётся в радианах, не градусах.
+ 90° = Math.PI/2 ≈ 1.57, 180° = Math.PI ≈ 3.14.
+
+ >
+ ),
+ },
+ {
+ id: 'recipes-anim',
+ title: 'S7. Движение, вращение, мигание (onTick и tween)',
+ body: (
+ <>
+
+ Вращающийся объект (монета, портал) — крутим каждый
+ кадр через game.onTick (dt = время кадра):
+
+
+ {`let angle = 0;
+game.onTick((dt) => {
+ angle = angle + dt * 2; // скорость вращения
+ game.self.rotateY(angle);
+});`}
+ Парение вверх-вниз (плавно качается):
+ {`const start = game.self.position;
+let t = 0;
+game.onTick((dt) => {
+ t = t + dt;
+ const dy = Math.sin(t * 2) * 0.4; // амплитуда 0.4 м
+ game.self.move(start.x, start.y + dy, start.z);
+});`}
+ Пульсация размера через tween (бесконечно туда-обратно):
+ {`game.tween(game.self.ref, { sy: 1.4 }, {
+ duration: 0.6, easing: 'ease', yoyo: true, repeat: -1
+});`}
+ Мигание цветом каждые полсекунды:
+ {`let on = false;
+game.every(0.5, () => {
+ on = !on;
+ game.self.setColor(on ? '#ff0000' : '#330000');
+});`}
+ >
+ ),
+ },
+ {
+ id: 'recipes-button-door',
+ title: 'S8. Кнопка по E и дверь',
+ body: (
+ <>
+
+ Взаимодействие по клавише E (как в Roblox ProximityPrompt)
+ — через game.self.onInteract. Появляется подсказка
+ «[E] …» когда игрок рядом.
+
+
+ {`game.self.onInteract(() => {
+ game.ui.showText('Открыто!', 2);
+ game.broadcast('open-door');
+}, { text: 'Открыть', key: 'e', distance: 4 });`}
+ На двери — глобальный/объектный скрипт, который её открывает:
+
+ {`const closed = game.self.position;
+game.onMessage('open-door', () => {
+ // плавно уехать вверх (открыться)
+ game.tween(game.self.ref, { y: closed.y + 4 }, { duration: 1, easing: 'ease' });
+ game.self.setCollide(false); // через неё можно пройти
+});`}
+
+ holdDuration: 1 в опциях onInteract — держать E
+ 1 секунду (для важных действий). distance —
+ с какого расстояния появляется подсказка.
+
+ >
+ ),
+ },
+ {
+ id: 'recipes-gui-timer',
+ title: 'S9. Надписи на экране, таймер, кнопки GUI',
+ body: (
+ <>
+ HUD-надписи в углу и по центру:
+
+ {`game.ui.score = 0; // счётчик «Очки: 0» в углу
+game.ui.score = 50; // обновить
+game.ui.timer = 60; // таймер mm:ss в углу
+game.ui.showText('Старт!', 2); // крупно по центру на 2 сек
+game.ui.set('hp', 'Жизни: 3', { x: 50, y: 90, color: '#fff' }); // своя метка
+game.ui.remove('hp'); // убрать метку`}
+ Обратный отсчёт и проигрыш по времени:
+ {`let time = 30;
+game.ui.timer = time;
+const id = game.every(1, () => {
+ time = time - 1;
+ game.ui.timer = time;
+ if (time <= 0) {
+ game.cancel(id);
+ game.ui.showText('Время вышло!', 3);
+ game.player.kill();
+ }
+});`}
+ Кнопка на экране (GUI) и обработка клика:
+ {`const btn = game.gui.create('button', {
+ name: 'start', text: 'НАЧАТЬ', x: 50, y: 80,
+ w: 20, h: 8, bg: '#3a6ee0', color: '#fff', fontSize: 18, borderRadius: 12
+});
+game.gui.onClick(btn, () => {
+ game.ui.showText('Поехали!', 2);
+ game.gui.hide(btn);
+});`}
+ >
+ ),
+ },
+ {
+ id: 'recipes-spawn-fall',
+ title: 'S10. Спавн, падение, проверка падения вниз',
+ body: (
+ <>
+ Спавнить объекты с неба каждую секунду (ловилка):
+
+ {`game.every(1, () => {
+ const x = game.random(-10, 10);
+ game.scene.spawn('sphere', {
+ x: x, y: 20, z: 0,
+ color: '#ffd700', material: 'neon',
+ anchored: false, // будет падать (физика)
+ lifetime: 8 // само исчезнет через 8 сек
+ });
+});`}
+
+ Игрок упал вниз (за карту) — вернуть на спавн. Проверяем
+ высоту каждый кадр:
+
+ {`game.onTick(() => {
+ if (game.player.position.y < -10) {
+ game.player.respawn();
+ game.ui.showText('Упал! Назад на старт.', 2);
+ }
+});`}
+ Финиш — дошёл до зоны, победа:
+
+ {`game.self.onTouch(() => {
+ game.ui.showText('🏁 ПОБЕДА!', 4);
+ game.sound.play('win');
+ game.player.setInputBlocked(true); // заморозить управление
+});`}
+ >
+ ),
+ },
+ {
+ id: 'recipes-npc-enemy',
+ title: 'S11. Враг, который идёт за игроком',
+ body: (
+ <>
+
+ NPC/враг , который преследует игрока и наносит урон.
+
+
+ {`const enemy = game.scene.spawnNpc('zombie', {
+ x: 10, y: 0, z: 10,
+ hp: 100, name: 'Зомби', speed: 3
+});
+enemy.follow('player'); // идти за игроком
+enemy.say('Хочу тебя поймать!', 3);
+
+enemy.onDeath(() => {
+ game.ui.showText('Враг повержен!', 2);
+ game.fx.damageFloater(enemy.position, 0, { isHeal: true });
+});`}
+ Урон игроку, когда враг близко:
+ {`game.every(0.5, () => {
+ const d = game.distance(enemy.position, game.player.position);
+ if (d < 2) game.player.damage(10);
+});`}
+
+ Облачка урона над всеми мобами одной строкой:
+ game.fx.autoMobFloaters(true).
+
+ >
+ ),
+ },
+ {
+ id: 'recipes-save',
+ title: 'S12. Сохранение прогресса и лидерборд',
+ body: (
+ <>
+
+ Лидерборд (таблица очков справа) — объяви стат и
+ прибавляй:
+
+
+ {`game.leaderstats.define('Монеты', { initial: 0 });
+// прибавить текущему игроку:
+game.leaderstats.me.add('Монеты', 1);`}
+
+ Сохранение между сессиями (прогресс не теряется после
+ выхода):
+
+ {`// записать
+game.save.merge('progress', {
+ patch: { level: 3 }, // обычные поля
+ increment: { coins: 10 }, // атомарно прибавить
+ max: { bestScore: 5000 } // запишется только если больше старого
+});
+
+// прочитать при старте
+game.save.get('progress', (data) => {
+ if (data) {
+ game.ui.showText('С возвращением! Уровень ' + data.level, 3);
+ }
+});`}
+
+ Собери всё вместе: монетки шлют broadcast → глобальный скрипт
+ считает в leaderstats → раз в N монет сохраняет через
+ game.save. Получится игра с прогрессом как в настоящем Roblox.
+
+ >
+ ),
+ },
+ ],
+ },
+
+ // ════════════════════════════════════════════════════
+ // РАЗДЕЛ — СОВМЕСТНОЕ РЕДАКТИРОВАНИЕ (Team Create)
+ // ════════════════════════════════════════════════════
+ {
+ id: 'collab',
+ icon: 'users',
+ title: 'Вместе с друзьями',
+ summary: 'Совместное редактирование игры в реальном времени: приглашай друзей, стройте сцену вдвоём, видьте курсоры и правки друг друга.',
+ sections: [
+ {
+ id: 'collab-what',
+ title: 'V1. Что такое совместное редактирование',
+ body: (
+ <>
+
+ Совместное редактирование (Team Create) — это когда
+ одну игру в студии редактируют несколько человек
+ одновременно . Ты строишь дом, друг в это же время
+ ставит деревья — и каждый видит правки другого
+ вживую , без перезагрузки страницы.
+
+ Что синхронизируется в реальном времени:
+
+ Блоки — поставил/удалил блок, друг сразу видит;
+ Примитивы — добавил, подвинул, повернул, изменил
+ цвет или размер;
+ Модели — добавил из Тулбокса или удалил;
+ Курсоры друзей — где сейчас «мышка» каждого
+ соавтора (цветная точка с ником);
+ Кто онлайн — список соавторов с цветными
+ аватарками вверху сцены.
+
+
+
+ Это как Google Документы, только для 3D-игр. Удобно делать
+ игру в команде, помогать ученику или показывать, как что
+ устроено — прямо в его проекте.
+
+ >
+ ),
+ },
+ {
+ id: 'collab-invite',
+ title: 'V2. Как пригласить друга',
+ body: (
+ <>
+
+ Открой свою игру в студии и перейди на вкладку
+ Игра в верхней панели. Там, в
+ группе «Вместе» , есть кнопка
+ Пригласить .
+
+
+
+ Нажми Пригласить — ссылка-приглашение
+ автоматически скопируется в буфер обмена (появится
+ уведомление).
+
+
+ Отправь эту ссылку другу (в чат, мессенджер — как угодно).
+
+
+ Друг открывает ссылку — и попадает в ту же сцену .
+ Теперь вы редактируете вместе. На кнопке появится счётчик:
+ «Вместе (2)» .
+
+
+ Приглашать может только автор игры . Ссылка действует
+ 24 часа. Друг должен быть зарегистрирован на Рублоксе и
+ войти в свой аккаунт.
+
+ >
+ ),
+ },
+ {
+ id: 'collab-how',
+ title: 'V3. Как это работает и правила',
+ body: (
+ <>
+
+ Блокировка объекта. Пока один соавтор выделил объект
+ и двигает его — этот объект заблокирован для других
+ (никто другой не сможет его двигать одновременно). Так
+ правки не конфликтуют. Как только выделение снято — объект
+ снова свободен.
+
+
+ Кто сохраняет. Игру в базу сохраняет автор
+ (владелец проекта). Приглашённые друзья видят пометку
+ «Совместное редактирование» вместо кнопок Сохранить и
+ Опубликовать — это правильно: они помогают строить, а
+ управляет игрой автор.
+
+
+ Цвета. У каждого соавтора свой цвет — им подсвечены
+ его курсор и аватарка, чтобы было понятно, кто что делает.
+
+
+ Если друг ненадолго потерял связь (вылетел интернет) — у
+ него есть несколько секунд, чтобы переподключиться и
+ продолжить с того же места.
+
+
+ Совет: договоритесь заранее, кто какую часть карты
+ делает (например, один — здания, другой — ландшафт и
+ декор) — так работать вместе быстрее и без накладок.
+
+ >
+ ),
+ },
+ ],
+ },
+
+ // ════════════════════════════════════════════════════
+ // РАЗДЕЛ — КОНТЕКСТ ДЛЯ НЕЙРОНКИ (AI)
+ // ════════════════════════════════════════════════════
+ {
+ id: 'ai-context',
+ icon: 'lightbulb',
+ title: 'Контекст для нейронки',
+ summary: 'Готовый текст со всем API скриптов Рублокса. Скопируй его целиком, вставь в ChatGPT/нейросеть — и она будет писать тебе рабочие скрипты под твою задачу.',
+ sections: [
+ {
+ id: 'ai-howto',
+ title: 'AI1. Как писать скрипты с нейросетью',
+ body: (
+ <>
+
+ Хочешь, чтобы скрипт написала нейросеть (ChatGPT,
+ DeepSeek, Claude и т.п.)? Проблема в том, что нейросеть не
+ знает устройство Рублокса — придумает несуществующие
+ команды. Решение простое:
+
+
+ Открой статью «AI2. Контекст — скопируй в нейросеть» ниже.
+
+
+ Выдели и скопируй весь текст из серого блока (он
+ описывает все команды Рублокса).
+
+
+ Вставь его в нейросеть первым сообщением . Затем добавь
+ свою задачу, например: «Напиши скрипт: при касании синего куба
+ игрок прыгает высоко и играет звук».
+
+
+ Нейросеть выдаст готовый код. Скопируй его в скрипт в студии
+ (глобальный или на объекте — она подскажет куда).
+
+
+ Если нейросеть всё равно ошиблась в команде — скинь ей текст
+ ошибки из «Консоли» студии, она исправит. И всегда проверяй
+ результат запуском игры.
+
+ >
+ ),
+ },
+ {
+ id: 'ai-context-text',
+ title: 'AI2. Контекст — скопируй в нейросеть',
+ body: (
+ <>
+
+ Выдели весь текст ниже и скопируй (Ctrl+A внутри блока
+ или мышью), затем вставь в нейросеть перед своим вопросом:
+
+ {AI_CONTEXT}
+ >
+ ),
+ },
+ ],
+ },
];
+
+
diff --git a/src/community/docsGames.js b/src/community/docsGames.js
index 1f69526..6f9f317 100644
--- a/src/community/docsGames.js
+++ b/src/community/docsGames.js
@@ -353,4 +353,29 @@ export const GAMES = [
desc: 'Полноценные машины: подходишь, держишь F — садишься за руль, WASD рулят, камера следует за авто, спидометр снизу. E — выйти. Готовые 3D-модели машин.',
mechanics: ['game.scene.spawn(\'vehicle:car\')', 'аркадная физика (газ/руль/тормоз)', 'hold-F вход / E выход', 'камера за машиной (V меняет)', 'HUD водителя (спидометр+передача)', 'onVehicleEnter/onVehicleExit'],
previewShot: 'guide-taxisim-scene.png', openProjectId: 2436, ready: true },
+ { id: 'guide-skybox', num: 62, group: 'g5', stars: 2, icon: 'cloud',
+ title: 'Небесная демка — кастомное небо',
+ desc: 'Одной строкой меняешь небо: голубой день, закат, звёздная ночь, космос. Облака, туман, далёкие горы и плавные переходы между пресетами.',
+ mechanics: ['game.scene.setSkybox({ preset })', 'game.scene.setClouds / setFog', 'skybox.fadeTo(opts, сек) — плавный переход', '6 пресетов: день/lowpoly/закат/ночь/космос', 'небо = единый источник света сцены', 'облака-дрейф + дымка горизонта'],
+ previewShot: 'guide-skybox-scene.png', openProjectId: 2541, ready: true },
+ { id: 'guide-leaderstats', num: 63, group: 'g5', stars: 2, icon: 'trophy',
+ title: 'Сбор монет — лидерборды и достижения',
+ desc: 'Таблица лидеров справа-сверху (монеты/время/уровень) + всплывающие достижения с редкостью и звуком. Прогресс сохраняется в БД между сессиями.',
+ mechanics: ['game.leaderstats.define / me.add', 'HUD-таблица топ-10 (сортировка по primary)', 'game.achievements.define / unlock', 'bindToStat — авто-награда по статy', 'toast 4 редкости + очередь', 'кубок → страница достижений', 'сохранение в БД (savegame)'],
+ previewShot: 'guide-leaderstats-scene.png', openProjectId: 2616, ready: true },
+ { id: 'guide-floaters', num: 64, group: 'g5', stars: 2, icon: 'sparkles',
+ title: 'Зомби-арена — бластер и цифры урона',
+ desc: 'Шутер: волны зомби бегут к игроку, бластер их отстреливает, над целью всплывают облачка урона. Авто-floater над любым мобом одной строкой + ручной game.fx.damageFloater (крит/хил/мана/промах/стек/комикс).',
+ mechanics: ['game.fx.damageFloater(pos, value, opts)', 'game.fx.autoMobFloaters(true) — облачко над NPC при уроне', 'game.player.giveTool(\'blaster-...\') — бластер', 'бластер от 3-го лица — в точку клика', 'spawnNpc + follow(\'player\') — зомби-волны', 'isCrit/isHeal/isMana/isMiss, стек ×N, комикс', 'object pool 30 планов (без лагов)'],
+ previewShot: 'guide-floaters-scene.png', openProjectId: 2676, ready: true },
+ { id: 'guide-inventory', num: 65, group: 'g5', stars: 2, icon: 'box',
+ title: 'Сбор и сортировка — инвентарь с drag-drop',
+ desc: 'Полный инвентарь как в Minecraft/RPG: сетка 8×5 + хотбар 9, стаки, 5 редкостей (цвет рамки), перетаскивание мышью, ПКМ-меню, tooltip, сортировка. Собираешь предметы — стаки растут.',
+ mechanics: ['game.items.define([...]) — предметы (редкость/стак/иконка)', 'game.inventory.give / take', 'окно по I — сетка 8×5 + хотбар 9 (1-9)', 'drag-drop между слотами (swap + merge)', 'стаки с maxStack, 5 редкостей', 'ПКМ-меню: использовать / разделить / выбросить', 'tooltip + сортировка по редкости'],
+ previewShot: 'guide-inventory-scene.png', openProjectId: 2685, ready: true },
+ { id: 'guide-loadingscreen', num: 66, group: 'g5', stars: 2, icon: 'loader',
+ title: 'Экран загрузки — Ken Burns и название места',
+ desc: 'Что видит игрок при входе в игру: размытый фон с медленным движением (Ken Burns), карточка-витрина по центру, название места и автор с verified-галочкой — как в Roblox. Автор настраивает экран во вкладке «Стартовый экран».',
+ mechanics: ['красивый экран загрузки игры в плеере (GameLoadingScreen)', 'Ken Burns / static / parallax / particles', 'карточка-витрина + название места + автор + verified', 'настройка во вкладке «Стартовый экран» (свойства проекта)', 'game.loading.show({ style, placeName, studioName, duration }) — переходы', 'game.loading.onHide() — продолжить после загрузки', 'game.loading.setBackground / setText / setProgress'],
+ previewShot: 'guide-loadingscreen-scene.png', openProjectId: 2713, ready: true },
];
diff --git a/src/community/docsIcons.jsx b/src/community/docsIcons.jsx
index d32a1b9..f802298 100644
--- a/src/community/docsIcons.jsx
+++ b/src/community/docsIcons.jsx
@@ -21,6 +21,14 @@ const F = { fill: 'currentColor', stroke: 'none' };
const ICONS = {
// ── разделы вики ──────────────────────────────────────────
+ users: () => (
+ <>
+
+
+
+
+ >
+ ),
rocket: () => (
<>
@@ -319,6 +327,11 @@ const ICONS = {
>
),
+ cloud: () => (
+ <>
+
+ >
+ ),
car: () => (
<>
diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx
index 2001a36..66d563f 100644
--- a/src/community/docsLessons.jsx
+++ b/src/community/docsLessons.jsx
@@ -8596,6 +8596,429 @@ game.onVehicleExit((vehicleRef) => {
),
},
+ 'guide-skybox': {
+ body: (
+ <>
+ Что получится
+
+ Красивое небо в одну строку. Вместо плоского цветного фона —
+ градиентный купол с солнцем, плывущими облаками, дымкой у горизонта
+ и далёкими горами (low-poly стиль, как в топовых Roblox-играх). Небо
+ меняется кнопками: день , закат , звёздная ночь ,
+ космос — с плавным переходом за пару секунд. И главное: небо —
+ это единый источник света : меняешь пресет → вместе с небом
+ меняется и освещение всей сцены (на закате теплеет, ночью темнеет).
+
+
+
+
+ Чему научишься
+
+ game.scene.setSkybox({'{'} preset {'}'}) — поставить готовое небо
+ одной строкой (6 пресетов);
+ game.scene.setClouds(...) — облака: плотность, скорость дрейфа, цвет;
+ game.scene.setFog(...) — атмосферный туман: дальние объекты выцветают в небо;
+ game.scene.skybox.fadeTo(opts, сек) — плавный переход между небесами;
+ game.scene.skybox.setSunDirection(...) — двигать солнце (анимация дуги).
+
+
+ Шаг 1. Поставить небо
+
+ Самое простое — выбрать пресет. Доступны:
+ clear-summer-day, lowpoly-roblox,
+ cloudy, sunset, starry-night,
+ space.
+
+
+ {`// Голубое low-poly небо с облаками, дымкой и горами (как на скрине):
+game.scene.setSkybox({ preset: 'lowpoly-roblox' });
+game.scene.setClouds({ enabled: true, cover: 0.45, speed: 0.014 });
+game.scene.setFog({ color: '#e2eef7', density: 0.005 });`}
+
+ Небо само выставляет освещение сцены под выбранный пресет — отдельно
+ свет настраивать не нужно. Купол бесконечно далёкий, поэтому ходить
+ «до края неба» нельзя — оно всегда вокруг игрока.
+
+
+ Шаг 2. Плавная смена неба (день → закат → ночь)
+
+ skybox.fadeTo переводит небо к новому пресету за указанное
+ число секунд — цвета купола, солнце, облака, туман и свет сцены
+ меняются плавно. Удобно вешать на кнопки или события.
+
+
+ {`game.gui.onClick('btn-sunset', () => game.scene.skybox.fadeTo({ preset: 'sunset' }, 2));
+game.gui.onClick('btn-night', () => game.scene.skybox.fadeTo({ preset: 'starry-night' }, 2));
+game.gui.onClick('btn-space', () => game.scene.skybox.fadeTo({ preset: 'space' }, 2));`}
+
+
+
+
+
+ Шаг 3. Своё небо (gradient)
+
+ Можно не брать пресет, а задать цвета купола вручную — верх, низ,
+ горизонт и солнце.
+
+
+ {`game.scene.setSkybox({
+ mode: 'gradient',
+ topColor: '#3d7fe0', // зенит
+ bottomColor: '#dcebf7', // у земли
+ horizonColor: '#bcd9f2', // линия горизонта
+ sunDirection: { x: 0.3, y: 0.85, z: 0.4 },
+ sunColor: '#fff6d8',
+ sunSize: 0.035,
+});`}
+
+ Почему это важно
+
+ Небо — половина визуального впечатления от мира. С плоским фоном все
+ игры выглядят одинаково и дёшево; с кастомным небом — атмосферно и
+ «дорого». А связка неба с освещением даёт бесплатный приём: смена
+ времени суток одной строкой мгновенно меняет настроение всей сцены.
+
+
+
+ Сделай день/ночь цикл: по таймеру каждые 10 секунд переключай
+ fadeTo между 'clear-summer-day' и
+ 'starry-night'. Добавь облака погуще на день и убери на ночь.
+
+ >
+ ),
+ },
+
+ 'guide-leaderstats': {
+ body: (
+ <>
+ Что получится
+
+ Игра «собери монеты» с двумя системами удержания, как в Roblox:
+ таблица лидеров справа-сверху (монеты, время, уровень) и
+ достижения — всплывающие награды с редкостью, звуком и
+ страницей-витриной. Прогресс сохраняется в базе — закрыл
+ игру, вернулся завтра, а монеты и открытые ачивки на месте.
+
+
+
+
+ Чему научишься
+
+ game.leaderstats.define(name, opts) — столбец таблицы:
+ иконка, цвет, формат (число / время / 1.2K), primary (по нему сортировка);
+ game.leaderstats.me.add('Монеты', 1) — изменить стат игрока
+ (ячейка жёлто мигает);
+ game.achievements.define([...]) — объявить достижения
+ (id, название, описание, редкость, очки, скрытое);
+ game.achievements.unlock(id) — выдать достижение (плашка + звук);
+ game.achievements.bindToStat(id, 'Монеты', {'{'} gte: 10 {'}'}) —
+ авто-награда при достижении значения стата;
+ прогресс сохраняется в БД и подгружается в новой сессии.
+
+
+ Шаг 1. Таблица лидеров
+
+ Объяви столбцы. Первый primary: true — по нему сортируются
+ игроки в топе. Дальше меняй значения через me.add / me.set.
+
+
+ {`game.leaderstats.define('Монеты', { initial: 0, format: 'number', icon: '🪙', color: '#ffd23a', primary: true });
+game.leaderstats.define('Время', { initial: 0, format: 'time', icon: '⏱', color: '#7fd0ff' });
+game.leaderstats.define('Уровень', { initial: 1, format: 'number', icon: '⭐', color: '#b48bff' });
+
+// Время идёт само, монеты — за подбор
+game.every(1, () => game.leaderstats.me.add('Время', 1));
+game.leaderstats.me.add('Монеты', 1);`}
+
+ Шаг 2. Достижения
+
+ Объяви список достижений с редкостью (common / rare / epic /
+ legendary — разный цвет плашки и звук). Выдавай явно через
+ unlock или автоматически через bindToStat.
+
+
+ {`game.achievements.define([
+ { id:'first_coin', name:'Первая монета', description:'Подобрать монету', icon:'🪙', rarity:'common', points:5 },
+ { id:'fifty_coins', name:'Полная сумка', description:'Собрать 50 монет', icon:'💰', rarity:'rare', points:25 },
+]);
+
+// Авто-награда: как только Монеты >= 50 — плашка «Полная сумка» сама выедет
+game.achievements.bindToStat('fifty_coins', 'Монеты', { gte: 50 });
+
+// Явная выдача (на первой монете)
+game.achievements.unlock('first_coin');`}
+
+
+ Кубок слева-снизу открывает страницу «Мои достижения» :
+ открытые — цветные с рамкой по редкости, закрытые — серые с
+ замком, скрытые — «?», сверху прогресс-бар «N из M (очки)».
+
+
+
+ Прогресс игрока (значения статов + открытые достижения) автоматически
+ сохраняется в базу и подгружается при следующем входе — ничего
+ дописывать не нужно. Уже открытое достижение второй раз плашкой не
+ показывается (оно «навсегда»).
+
+
+ Почему это важно
+
+ Лидерборды и достижения — главный механизм удержания: ребёнок
+ возвращается в игру за новым рекордом и новой ачивкой. Это основа
+ симуляторов, ферм и PvP — в Roblox столбец «Coins / Wins / Level»
+ есть почти в каждой игре.
+
+
+
+ Добавь стат «Рекорд» и достижение 'speedrun', которое
+ выдаётся через bindToStat('Время', {'{'} lte: 30 {'}'}),
+ если собрать все монеты быстрее 30 секунд.
+
+ >
+ ),
+ },
+
+ 'guide-floaters': {
+ body: (
+ <>
+ Что получится
+
+ Мини-шутер: волны зомби бегут к игроку , ты отстреливаешь
+ их из бластера , а над каждой целью всплывает облачко
+ урона — как в Roblox-RPG (Pet Sim, Anime Adventures). Зомби
+ гибнут, счётчик растёт, волны усиливаются.
+
+
+
+
+ Шаг 1. Бластер + авто-облачка над мобами
+
+ Две строки превращают игру в шутер с фидбеком урона: выдаём
+ бластер и включаем авто-floater — теперь любой урон
+ по NPC сам рисует «-N» над целью, вручную вызывать ничего не надо.
+
+
+ {`game.player.giveTool('blaster-blaster-a', { equip: true }); // бластер в руки
+game.fx.autoMobFloaters(true); // облачко урона над любым мобом при попадании`}
+
+ Шаг 2. Волны зомби, идущих к игроку
+ {`function spawnWave(n){
+ const pl = game.player.position;
+ for (let i = 0; i < n; i++){
+ const a = (i / n) * Math.PI * 2;
+ const e = game.scene.spawnNpc('skin_retro-zombie', {
+ x: pl.x + Math.cos(a)*18, z: pl.z + Math.sin(a)*18,
+ name: 'Зомби', hp: 100, speed: 2.6,
+ });
+ if (e && e.follow) e.follow('player'); // зомби преследует игрока
+ }
+}
+game.after(1.5, () => spawnWave(5));
+game.every(14, () => spawnWave(8));`}
+
+ Стрелять из бластера — ЛКМ. В режиме от 3-го лица пуля летит
+ туда, куда кликнул курсором. Попал по зомби → облачко
+ урона (благодаря autoMobFloaters), убил → засчитан.
+
+
+ Ручной floater — все типы
+ Когда нужен полный контроль — рисуй цифру сам:
+ {`game.fx.damageFloater(pos, 25); // красный — обычный урон
+game.fx.damageFloater(pos, 80, { isCrit: true }); // жёлтый, больше + подскок
+game.fx.damageFloater(pos, 30, { isHeal: true }); // зелёный — лечение (+30)
+game.fx.damageFloater(pos, 50, { isMana: true }); // синий — мана
+game.fx.damageFloater(pos, 'Промах', { isMiss: true }); // серый текст`}
+
+ position — {'{x,y,z}'}, ссылка на объект или
+ 'player'; value — число или строка.
+
+
+ Стек и комикс-стиль
+ {`// общий stackKey → удары сливаются в «-25 ×N» вместо кучи цифр
+game.fx.damageFloater(enemy.position, 25, { stackKey: 'aoe_' + enemy.id });
+// comicStyle → BAM! (>50), KAPOW! (>100), POW! (крит) на жёлтой звезде
+game.fx.damageFloater(pos, 120, { comicStyle: true });`}
+
+
+ Под капотом — пул из 30 переиспользуемых билборд-планов
+ (object pool), поэтому даже при толпе зомби и спаме цифр FPS не
+ проседает. Цифры всегда поверх геометрии и повёрнуты к камере.
+
+
+ Почему это важно
+
+ Без облачек урона стрельба ощущается «впустую». Это базовый
+ боевой фидбек: игрок видит, сколько нанёс, был ли крит, попал ли.
+ Связка бластер + autoMobFloaters + волны NPC — готовый
+ каркас любого шутера/выживания.
+
+
+
+ Сделай «огненный» урон: damageFloater(pos, 15, {'{'} color:
+ '#ff7a2a' {'}'}) каждые 0.5 сек 3 раза — эффект горения.
+ Или увеличь HP зомби и добавь крит каждый 5-й выстрел.
+
+ >
+ ),
+ },
+
+ 'guide-inventory': {
+ body: (
+ <>
+ Что получится
+
+ Полноценный инвентарь как в Minecraft и RPG: сетка 8×5 +
+ хотбар на 9 слотов , предметы со стаками и
+ редкостями , перетаскивание мышью, ПКМ-меню, всплывающие
+ подсказки. Собираешь предметы на поляне — стаки растут, открываешь
+ инвентарь клавишей I и раскладываешь добычу.
+
+
+
+
+ Шаг 1. Определи предметы
+
+ Каждый предмет описывается один раз: имя, иконка-эмодзи, редкость,
+ размер стака, эффект использования.
+
+
+ {`game.items.define([
+ { id:'berry', name:'Ягоды', emoji:'🍓', rarity:'common', maxStack:16, value:2 },
+ { id:'iron', name:'Руда', emoji:'⛏️', rarity:'uncommon', maxStack:16, value:8 },
+ { id:'potion', name:'Зелье', emoji:'🧪', rarity:'rare', maxStack:8, onUseEffect:'heal:50' },
+ { id:'sword', name:'Меч', emoji:'⚔️', rarity:'legendary', maxStack:1, value:500 },
+]);`}
+
+ Редкости: common (серый), uncommon
+ (зелёный), rare (голубой), epic
+ (фиолетовый), legendary (золотой) — это цвет рамки слота.
+
+
+ Шаг 2. Выдавай и собирай предметы
+ {`game.inventory.give('sword', 1); // в стартовый набор
+game.inventory.give('berry', 5); // стак до maxStack, дальше — новый слот
+
+// сбор предмета с земли (на объекте-ягоде):
+game.self.onInteract(() => {
+ game.inventory.give('berry', 2);
+ game.self.delete(); // убрать собранный предмет
+}, { text:'Собрать', key:'e', distance:3 });`}
+
+ Собранное попадает сначала в хотбар (виден внизу экрана),
+ одинаковые предметы складываются в стак с учётом maxStack.
+
+
+
+
+ Шаг 3. Окно инвентаря
+
+ I — открыть/закрыть окно (Esc тоже закрывает);
+ Перетаскивание мышью — поменять слоты местами или
+ слить стаки;
+ ПКМ по слоту — меню: использовать / разделить / выбросить;
+ Наведение — tooltip (название цветом редкости, описание, цена);
+ Сорт. — расставить по редкости;
+ 1-9 — выбрать активный слот хотбара.
+
+
+
+ Всё хранится в движке и сериализуется в проект автоматически —
+ дописывать сохранение не нужно. Предметы с тегом
+ 'quest' нельзя выбросить.
+
+
+ Почему это важно
+
+ Инвентарь — несущая конструкция RPG, выживания и симуляторов.
+ Сетка + хотбар + стаки + редкости — стандарт, который игроки
+ узнают мгновенно. Сочетается с крафтом, квестами и магазином.
+
+
+
+ Добавь предмет 'apple' с
+ onUseEffect:'heal:15', положи в хотбар и нажми ПКМ →
+ «Использовать» — HP восстановится, яблоко убавится на 1.
+
+ >
+ ),
+ },
+
+ 'guide-loadingscreen': {
+ body: (
+ <>
+ Что получится
+
+ Красивый экран загрузки игры — то, что видит игрок при входе
+ в игру (после клика «Играть»), пока грузится сцена. Композиция как в
+ Roblox: размытый фон с медленным движением (Ken Burns ),
+ карточка-витрина по центру, крупное название места и
+ автор с verified-галочкой , прогресс-бар и спиннер. Когда сцена
+ загрузилась — экран плавно исчезает.
+
+
+
+
+ Шаг 1. Настроить в свойствах проекта
+
+ Без кода: Настройки игры → вкладка «Стартовый экран входа (Ken Burns)» .
+ Задай фон (размытое изображение игры), карточку, название места, имя
+ автора, галочку verified, стиль анимации и длительность. Этот экран
+ автоматически покажется игроку при заходе.
+
+
+ Фон — размытое изображение игры (или её обложка);
+ Карточка — витрина по центру (необязательно);
+ Название места + автор + verified ;
+ Стиль: Ken Burns / статичный / параллакс / частицы;
+ Длительность и прогресс-бар .
+
+
+ Если ничего не задано — экран всё равно красивый: берёт обложку,
+ название и автора игры автоматически.
+
+
+ Шаг 2. Переходы между мирами из скрипта
+ Для смены главы/мира вызывай экран вручную:
+
+ {`game.loading.show({
+ style: 'particles',
+ placeName: 'Алмазная глава',
+ studioName: 'Виктория — Майнкрафтия',
+ verified: true,
+ duration: 2,
+});
+game.after(0.6, () => {
+ game.environment.setTimeOfDay(0); // меняем мир «за кулисами»
+ game.environment.setSkyColor('#0a1024');
+});
+game.loading.onHide(() => {
+ game.ui.set('hi', 'Добро пожаловать!', { x:50, y:6, anchor:'top' });
+});`}
+
+
+ Стили: Ken Burns — медленный pan+zoom фона (классика Roblox);
+ параллакс — фон смещается за мышью; частицы — летящие
+ искры; статичный — без анимации. Verified-галочка — синий кружок
+ с белым чеком рядом с автором.
+
+
+
+ Открой настройки игры → «Стартовый экран», впиши название места и автора,
+ выбери стиль «Частицы» — запусти игру и посмотри, как экран загрузки
+ встречает игрока.
+
+ >
+ ),
+ },
+
};
/** Есть ли готовый текст урока для игры с таким id. */
diff --git a/src/editor/GameSettingsModal.jsx b/src/editor/GameSettingsModal.jsx
index e579b3d..4b1ea9b 100644
--- a/src/editor/GameSettingsModal.jsx
+++ b/src/editor/GameSettingsModal.jsx
@@ -50,9 +50,21 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
const [loadingAccent, setLoadingAccent] = useState('#ffc020');
const [loadingSpinner, setLoadingSpinner] = useState(true);
const [loadingSkip, setLoadingSkip] = useState(false);
+ // Задача 05: стартовый Ken-Burns экран
+ const [lsEnabled, setLsEnabled] = useState(true);
+ const [lsBackground, setLsBackground] = useState('');
+ const [lsCover, setLsCover] = useState('');
+ const [lsStyle, setLsStyle] = useState('ken-burns');
+ const [lsPlaceName, setLsPlaceName] = useState('');
+ const [lsStudioName, setLsStudioName] = useState('');
+ const [lsVerified, setLsVerified] = useState(false);
+ const [lsDuration, setLsDuration] = useState(2.5);
+ const [lsProgressBar, setLsProgressBar] = useState(true);
const [error, setError] = useState('');
const fileInputRef = useRef(null);
const logoInputRef = useRef(null);
+ const lsBgInputRef = useRef(null);
+ const lsCoverInputRef = useRef(null);
// Заполняем поля ОДИН РАЗ при открытии модала.
// Не зависим от `initial` — родитель часто передаёт литерал-объект,
@@ -71,6 +83,16 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
setLoadingAccent(ls.accentColor || '#ffc020');
setLoadingSpinner(ls.defaultSpinner !== false);
setLoadingSkip(!!ls.defaultSkipButton);
+ // Задача 05:
+ setLsEnabled(ls.enabled !== false);
+ setLsBackground(ls.background || '');
+ setLsCover(ls.cover || '');
+ setLsStyle(ls.style || 'ken-burns');
+ setLsPlaceName(ls.placeName || '');
+ setLsStudioName(ls.studioName || '');
+ setLsVerified(!!ls.verified);
+ setLsDuration(Number.isFinite(ls.duration) && ls.duration > 0 ? ls.duration : 2.5);
+ setLsProgressBar(ls.progressBar !== false);
setMaxPlayers(
typeof initial?.max_players === 'number'
? Math.max(2, Math.min(50, initial.max_players))
@@ -117,6 +139,17 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
reader.readAsDataURL(file);
};
+ // Задача 05: универсальный загрузчик изображения (фон / cover-карточка).
+ const handleLsImage = (e, setter) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ if (!/^image\/(png|jpeg|webp)$/.test(file.type)) { setError('Только PNG, JPG или WEBP'); return; }
+ if (file.size > MAX_THUMBNAIL_BYTES) { setError('Изображение слишком большое (макс. 500 КБ)'); return; }
+ const reader = new FileReader();
+ reader.onload = (ev) => { setter(ev.target.result); setError(''); };
+ reader.readAsDataURL(file);
+ };
+
const handleSubmit = (e) => {
e.preventDefault();
const trimmedTitle = title.trim();
@@ -146,6 +179,16 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
accentColor: loadingAccent || '#ffc020',
defaultSpinner: loadingSpinner,
defaultSkipButton: loadingSkip,
+ // Задача 05:
+ enabled: lsEnabled,
+ background: lsBackground || null,
+ cover: lsCover || null,
+ style: lsStyle || 'ken-burns',
+ placeName: lsPlaceName.trim(),
+ studioName: lsStudioName.trim(),
+ verified: lsVerified,
+ duration: Math.max(1, Math.min(10, Number(lsDuration) || 2.5)),
+ progressBar: lsProgressBar,
},
});
};
@@ -384,6 +427,115 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
+ {/* Стартовый экран — Ken Burns + название места (задача 05) */}
+
+
+ Стартовый экран входа (Ken Burns)
+
+
+ Что видит игрок при входе в игру: размытый фон с медленным движением (Ken Burns), карточка-витрина по центру, название места и автор.
+
+
+ setLsEnabled(e.target.checked)} />
+
+
Показывать стартовый экран
+
Если выключено — игрок сразу попадает в 3D-сцену
+
+
+
+ {lsEnabled && (
+ <>
+ {/* Фон + карточка */}
+
+
+
+ {!lsBackground && фон (размытый) }
+
+
lsBgInputRef.current?.click()}>
+ Фон
+
+ {lsBackground && (
+
setLsBackground('')}>
+ Убрать
+
+ )}
+
handleLsImage(e, setLsBackground)} />
+
+
+
+ {!lsCover && карточка }
+
+
lsCoverInputRef.current?.click()}>
+ Карточка
+
+ {lsCover && (
+
setLsCover('')}>
+ Убрать
+
+ )}
+
handleLsImage(e, setLsCover)} />
+
+
+
+
+ {/* Стиль + длительность + прогресс */}
+
+ >
+ )}
+
+
{error && {error}
}
diff --git a/src/editor/HierarchyPanel.jsx b/src/editor/HierarchyPanel.jsx
index 586f6fa..a39613c 100644
--- a/src/editor/HierarchyPanel.jsx
+++ b/src/editor/HierarchyPanel.jsx
@@ -1,4 +1,4 @@
-import React, { useState, useMemo } from 'react';
+import React, { useState, useMemo, useEffect } from 'react';
import { getBlockType } from './engine/BlockTypes';
import { getModelType } from './engine/ModelTypes';
import { getPrimitiveType } from './engine/PrimitiveTypes';
@@ -40,8 +40,17 @@ const ItemRow = ({
extraStyle,
}) => {
const [hovered, setHovered] = useState(false);
+ const rowRef = React.useRef(null);
+ // Когда строка стала выделенной — подскроллить её в видимую зону дерева
+ // (после авто-раскрытия веток объект может оказаться вне видимой области).
+ useEffect(() => {
+ if (selected && rowRef.current) {
+ rowRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
+ }
+ }, [selected]);
return (
{
+ const map = new Map();
+ for (const um of userModels) {
+ const k = um.folderId ?? null;
+ if (!map.has(k)) map.set(k, []);
+ map.get(k).push(um);
+ }
+ return map;
+ }, [userModels]);
+
const isBlockSelected = (b) =>
selection?.type === 'block' &&
selection.gridX === b.gridX && selection.gridY === b.gridY && selection.gridZ === b.gridZ;
const isModelSelected = (m) =>
selection?.type === 'model' && selection.instanceId === m.instanceId;
+ const isUserModelSelected = (um) =>
+ selection?.type === 'userModel' && selection.instanceId === um.instanceId;
const isPrimitiveSelected = (p) =>
selection?.type === 'primitive' && selection.id === p.id;
@@ -344,6 +368,82 @@ const HierarchyPanel = ({
});
};
+ // === Авто-раскрытие пути до выделенного объекта ===
+ // Когда объект выбирают мышкой на сцене (или из скрипта), он должен стать
+ // ВИДИМЫМ в дереве: раскрываем «Сцену», нужную под-группу (Блоки/Примитивы/
+ // Модели) и всю цепочку папок-родителей. Подсветка строки уже работает через
+ // проп `selection`; здесь только разворачиваем свёрнутые ветки.
+ useEffect(() => {
+ if (!selection) return;
+ const t = selection.type;
+ // Выделена ПАПКА (клик по сцене / вставка кита из тулбокса) — раскрыть
+ // «Сцену» и цепочку папок до неё, чтобы папка стала видна в дереве.
+ if (t === 'folder') {
+ setWorkspaceOpen(true);
+ setOpenFolders(prev => {
+ const n = new Set(prev);
+ let cur = selection.folderId;
+ const guard = new Set();
+ while (cur != null && !guard.has(cur)) {
+ guard.add(cur);
+ const f = folders.find(ff => ff.id === cur);
+ if (f && f.parentId != null) { n.add(f.parentId); cur = f.parentId; } else cur = null;
+ }
+ return n;
+ });
+ return;
+ }
+ // Находим объект и его folderId по выделению.
+ let obj = null, kind = null;
+ if (t === 'block') {
+ obj = blocks.find(b => b.gridX === selection.gridX && b.gridY === selection.gridY && b.gridZ === selection.gridZ);
+ kind = 'block';
+ } else if (t === 'primitive') {
+ obj = primitives.find(p => p.id === selection.id);
+ kind = 'primitive';
+ } else if (t === 'model') {
+ obj = models.find(m => m.instanceId === selection.instanceId);
+ kind = 'model';
+ } else if (t === 'userModel') {
+ obj = userModels.find(um => um.instanceId === selection.instanceId);
+ kind = 'userModel';
+ } else if (t === 'spawn' || t === 'floor') {
+ kind = 'workspace-only';
+ }
+ if (!kind) return; // lighting/sound/player/gui/script — свои группы, не трогаем
+
+ // 1) Раскрыть корневую категорию «Сцена».
+ setWorkspaceOpen(true);
+
+ if (kind === 'workspace-only') return;
+
+ const folderId = obj?.folderId ?? null;
+ if (folderId == null) {
+ // 2a) Объект в корне — раскрыть его под-группу (Блоки/Примитивы/Модели).
+ if (kind === 'block') setRootBlocksOpen(true);
+ else if (kind === 'primitive') setRootPrimsOpen(true);
+ else if (kind === 'model') setRootModelsOpen(true);
+ else if (kind === 'userModel') setRootUserModelsOpen(true);
+ } else {
+ // 2b) Объект в папке — раскрыть всю цепочку папок-родителей.
+ setOpenFolders(prev => {
+ const n = new Set(prev);
+ let cur = folderId;
+ const guard = new Set(); // защита от циклов
+ while (cur != null && !guard.has(cur)) {
+ guard.add(cur);
+ n.add(cur);
+ const f = folders.find(ff => ff.id === cur);
+ cur = f ? (f.parentId ?? null) : null;
+ }
+ return n;
+ });
+ }
+ // Триггер — только смена выделения (не трогаем при добавлении объектов,
+ // чтобы не переоткрывать ветки, которые пользователь свернул вручную).
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selection]);
+
const handleContextMenu = (e, item) => {
e.preventDefault();
e.stopPropagation();
@@ -400,19 +500,24 @@ const HierarchyPanel = ({
const subBlocks = blocksByFolder.get(folder.id) || [];
const subModels = modelsByFolder.get(folder.id) || [];
const subPrims = primitivesByFolder.get(folder.id) || [];
- const totalCount = subBlocks.length + subModels.length + subPrims.length + subFolders.length;
+ const subUserModels = userModelsByFolder.get(folder.id) || [];
+ const totalCount = subBlocks.length + subModels.length + subPrims.length + subUserModels.length + subFolders.length;
+ const folderSelected = selection?.type === 'folder' && selection?.folderId === folder.id;
return (
{ if (el) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } : null}
+ className={`${cl.folderHeader} ${folderSelected ? cl.itemSelected : ''}`}
style={{ paddingLeft: depth * 12 + 8 }}
- onClick={() => toggleFolder(folder.id)}
+ onClick={() => onSelectFolder?.(folder.id)}
onContextMenu={(e) => handleContextMenu(e, { type: 'folder', ...folder })}
onDragOver={handleDragOver}
onDrop={(e) => handleDropOnFolder(e, folder.id)}
>
-
+ { e.stopPropagation(); toggleFolder(folder.id); }}
+ >
@@ -452,6 +557,7 @@ const HierarchyPanel = ({
{subBlocks.map(b => renderBlockItem(b, depth + 1))}
{subPrims.map(p => renderPrimitiveItem(p, depth + 1))}
{subModels.map(m => renderModelItem(m, depth + 1))}
+ {subUserModels.map(um => renderUserModelItem(um, depth + 1))}
{totalCount === 0 && (
пусто
)}
@@ -580,6 +686,38 @@ const HierarchyPanel = ({
);
};
+ const renderUserModelItem = (um, depth) => {
+ const displayName = um.name || 'Моя модель';
+ return (
+
+ handleDragStart(e, { kind: 'userModel', id: um.instanceId })}
+ onClick={() => onSelectUserModel?.(um.instanceId)}
+ onDoubleClick={() => { onSelectUserModel?.(um.instanceId); onFocusSelection?.(); }}
+ onContextMenu={(e) => handleContextMenu(e, { type: 'userModel', ...um })}
+ plusItems={[
+ {
+ id: 'add-script', label: 'Скрипт', icon: '📜',
+ onClick: () => onCreateScript?.({ kind: 'userModel', id: um.instanceId }),
+ },
+ { divider: true },
+ {
+ id: 'delete', label: 'Удалить', icon: '🗑', danger: true,
+ onClick: () => onDeleteUserModel?.(um.instanceId),
+ },
+ ]}
+ />
+ {renderNestedScriptsFor('userModel', um.instanceId, depth)}
+
+ );
+ };
+
const renderPrimitiveItem = (p, depth) => {
const def = getPrimitiveType(p.type);
const displayName = p.name || def?.name || p.type;
@@ -633,6 +771,7 @@ const HierarchyPanel = ({
const rootFolders = foldersByParent.get(null) || [];
const rootBlocks = blocksByFolder.get(null) || [];
const rootModels = modelsByFolder.get(null) || [];
+ const rootUserModels = userModelsByFolder.get(null) || [];
const rootPrims = primitivesByFolder.get(null) || [];
return (
@@ -664,16 +803,19 @@ const HierarchyPanel = ({
/>
{workspaceOpen && (
- {/* Точка спавна — кликабельная */}
+ {/* Точка спавна — кликабельная. Скрыта если удалена. */}
+ {spawnEnabled !== false && (
onSelectSpawn?.()}
onDoubleClick={() => { onSelectSpawn?.(); onFocusSelection?.(); }}
- title="Точка спавна игрока"
+ onContextMenu={(e) => { onSelectSpawn?.(); handleContextMenu(e, { type: 'spawn' }); }}
+ title="Точка спавна игрока (ПКМ — меню, Delete — удалить)"
>
Точка спавна
+ )}
{/* Пол — псевдо-объект, если включён */}
{floorEnabled && (
@@ -757,6 +899,24 @@ const HierarchyPanel = ({
{rootModelsOpen && rootModels.map(m => renderModelItem(m, 0))}
>
)}
+
+ {/* Мои модели (воксельный редактор) в корне */}
+ {rootUserModels.length > 0 && (
+ <>
+
setRootUserModelsOpen(!rootUserModelsOpen)}
+ >
+
+
+
+
+ Мои модели ({rootUserModels.length})
+
+
+ {rootUserModelsOpen && rootUserModels.map(um => renderUserModelItem(um, 0))}
+ >
+ )}
)}
@@ -1014,7 +1174,12 @@ const HierarchyPanel = ({
{/* === 📜 СКРИПТЫ === — только глобальные (без target).
Скрипты с target отображаются под объектом-носителем. */}
{(() => {
- const globalScripts = scripts.filter(s => !s.target);
+ // Глобальные скрипты: без target ИЛИ target==='game' (строка).
+ // Раньше фильтр был `!s.target` → скрипты с target:'game'
+ // (главный скрипт игры) НЕ показывались в дереве и их нельзя
+ // было удалить, хотя в Play они исполнялись.
+ const isGlobalTarget = (t) => !t || t === 'game';
+ const globalScripts = scripts.filter(s => isGlobalTarget(s.target));
return (
<>
Удалить пол
>
+ ) : contextMenu.item.type === 'spawn' ? (
+ <>
+
{ onSelectSpawn?.(); onFocusSelection?.(); closeContext(); }}
+ >
+ Навести камеру
+
+
{ onDeleteSpawn?.(); closeContext(); }}
+ >
+ Удалить точку спавна
+
+ >
) : contextMenu.item.type === 'script' ? (
<>
{
const canvasRef = useRef(null);
const sceneRef = useRef(null);
+ // Team Create — клиент совместного редактирования + presence-overlay.
+ const collabRef = useRef(null);
+ const collabOverlayRef = useRef(null);
+ const [collabActive, setCollabActive] = useState(false); // подключены к комнате
+ const [collabPeers, setCollabPeers] = useState(0); // сколько ДРУГИХ соавторов
+ // Роль в коллабе: 'owner' (владелец) | 'collab' (приглашённый по ссылке).
+ // Приглашённый — гость: не может менять настройки/сохранять/публиковать.
+ const [collabRole, setCollabRole] = useState('owner');
+ const isInvitedGuest = collabActive && collabRole !== 'owner';
// Флаш pending-debounce ScriptEditor. Зовём перед каждым doSave/перед уходом
// со страницы — иначе последние 600мс правок скрипта потеряются.
const scriptEditorFlushRef = useRef(null);
@@ -605,6 +618,7 @@ const KubikonEditor = () => {
const [crosshair, setCrosshairUI] = useState('none');
// Видимость пола в иерархии
const [floorEnabled, setFloorEnabledUI] = useState(true);
+ const [spawnEnabledUI, setSpawnEnabledUI] = useState(true);
// Табы над viewport (Roblox-style): «🎬 Сцена» + открытые скрипты
const [openTabs, setOpenTabs] = useState([{ id: 'scene', kind: 'scene', title: 'Сцена' }]);
const [activeTabId, setActiveTabId] = useState('scene');
@@ -768,6 +782,93 @@ const KubikonEditor = () => {
});
}
}, []);
+
+ // Задача 17: вставить готовую механику (kit) из Тулбокса в проект.
+ // prims[] → создаём примитивы перед камерой; on-target скрипт → привязываем
+ // к первому созданному примитиву; global скрипт → добавляем как скрипт игры.
+ const insertGameplayKit = useCallback((kitId) => {
+ const kit = getKit(kitId);
+ const s = sceneRef.current;
+ if (!kit || !s) return;
+ // Точка вставки — на ТВЁРДОЙ поверхности под центром экрана (пол/объект),
+ // чтобы предмет встал на землю в фокусе камеры, а не висел под камерой.
+ let px = 0, pz = 0, py = 0;
+ try {
+ const gp = s.getPlacementPointAtCenter?.();
+ if (gp) { px = gp.x; pz = gp.z; py = gp.y; }
+ else {
+ const cam = s.camera;
+ const fwd = cam?.getForwardRay ? cam.getForwardRay().direction : null;
+ if (cam && fwd) { px = cam.position.x + fwd.x * 6; pz = cam.position.z + fwd.z * 6; }
+ }
+ } catch (e) { /* ignore */ }
+
+ // 1) Создаём примитивы кита. Запоминаем все id (первый — для on-target скрипта).
+ let firstPrimId = null;
+ const createdIds = [];
+ if (Array.isArray(kit.prims)) {
+ for (const p of kit.prims) {
+ const newId = s.primitiveManager?.addInstance(p.type || 'cube', {
+ x: px + (p.x || 0), y: py + (p.y != null ? p.y : 1), z: pz + (p.z || 0),
+ sx: p.sx, sy: p.sy, sz: p.sz,
+ color: p.color, material: p.material,
+ canCollide: p.canCollide !== false, visible: p.visible !== false, anchored: true,
+ name: p.name,
+ });
+ if (newId != null) {
+ createdIds.push(newId);
+ if (firstPrimId == null) firstPrimId = newId;
+ }
+ }
+ }
+ // Если кит состоит из НЕСКОЛЬКИХ частей — кладём их в общую папку
+ // (объекты из нескольких частей всегда сгруппированы).
+ let kitFolderId = null;
+ if (createdIds.length > 1 && s.folderManager) {
+ kitFolderId = s.folderManager.createFolder(kit.name);
+ for (const pid of createdIds) {
+ s.folderManager.assignToFolder('primitive', pid, kitFolderId);
+ }
+ }
+
+ // 2) Добавляем скрипты кита — с понятным именем (название кита).
+ if (Array.isArray(kit.scripts)) {
+ kit.scripts.forEach((sc, idx) => {
+ const sid = `script_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
+ // Имя = название кита (+ номер, если скриптов несколько).
+ const nm = kit.scripts.length > 1 ? `${kit.name} (${idx + 1})` : kit.name;
+ if (sc.attachTo === 'on-target' && firstPrimId != null) {
+ s.upsertScript(sid, sc.code, { kind: 'primitive', id: firstPrimId }, nm);
+ } else {
+ s.upsertScript(sid, sc.code, null, nm); // глобальный
+ }
+ });
+ }
+
+ markDirty();
+ hierarchyDirtyRef.current = true; // пересобрать дерево (примитивы с folderId)
+ setScriptsList(s.getScripts?.() || []);
+ if (s.folderManager) setFoldersList(s.folderManager.getAll());
+ // Выделим созданное и наведём камеру (видно, куда добавилось).
+ try {
+ setActiveTool('select');
+ if (kitFolderId != null) {
+ s.selection?.selectFolder?.(kitFolderId); // группа из нескольких частей
+ setGizmoMode('move');
+ } else if (firstPrimId != null) {
+ s.selection?.selectPrimitiveById(firstPrimId);
+ }
+ s.focusOnSelection?.();
+ } catch (e) {}
+ // Тост-уведомление (showToast будет подключён позже — заглушка,
+ // чтобы не падал eslint no-undef и CI оставался зелёным).
+ try {
+ if (typeof window !== 'undefined' && typeof window.showToast === 'function') {
+ window.showToast(`Механика «${kit.name}» добавлена`);
+ }
+ } catch (e) {}
+ }, []);
+
const [paletteTab, setPaletteTab] = useState('blocks'); // 'blocks' | 'models' | 'primitives'
const [blockCount, setBlockCount] = useState(0);
const [modelCount, setModelCount] = useState(0);
@@ -776,6 +877,7 @@ const KubikonEditor = () => {
const [selection, setSelection] = useState(null);
const [blocksList, setBlocksList] = useState([]);
const [modelsList, setModelsList] = useState([]);
+ const [userModelsList, setUserModelsList] = useState([]);
const [primitivesList, setPrimitivesList] = useState([]);
const [foldersList, setFoldersList] = useState([]);
@@ -920,6 +1022,14 @@ const KubikonEditor = () => {
console.warn('[KubikonEditor] save: skip (load failed)');
return;
}
+ // Team Create: в комнате совместного редактирования сохраняет ТОЛЬКО host
+ // (authoritative). Иначе два соавтора autosave'ят наперегонки → last-write-wins
+ // затирает чужие правки. Не-host просто не пишет в БД, его изменения уже
+ // у host через операции.
+ if (collabRef.current?.connected && !collabRef.current?.isHost) {
+ console.log('[KubikonEditor] save: skip (collab non-host, host saves)');
+ return;
+ }
const userId = getCurrentUserId();
if (!userId) {
console.warn('[KubikonEditor] save: no userId');
@@ -1139,6 +1249,108 @@ const KubikonEditor = () => {
}, AUTOSAVE_DEBOUNCE_MS);
}, [doSave]);
+ /**
+ * Team Create: подключиться к комнате совместного редактирования.
+ * Зовётся ПОСЛЕ загрузки сцены (scene готова). projectIdNum — числовой id.
+ * Подключаемся если: владелец проекта (всегда) ИЛИ есть ?collab=
в URL.
+ */
+ const initCollab = useCallback(async (projectIdNum) => {
+ try {
+ if (!sceneRef.current || !projectIdNum) return;
+ if (collabRef.current) return; // уже подключены
+ const collabToken = new URLSearchParams(window.location.search).get('collab') || null;
+ const tokenRaw = localStorage.getItem('Authorization')
+ || localStorage.getItem('jwt') || '';
+ if (!tokenRaw) return;
+ // Подключаемся только если есть инвайт ИЛИ владелец (бэкенд решит в onAuth).
+ // Если не владелец и нет инвайта — onAuth вернёт 403, ловим тихо.
+ const collab = new StudioCollab(sceneRef.current, {
+ projectId: projectIdNum,
+ token: tokenRaw,
+ collabToken,
+ callbacks: {
+ onConnected: ({ isHost, role }) => {
+ setCollabActive(true);
+ setCollabRole(role || (isHost ? 'owner' : 'collab'));
+ collabOverlayRef.current?.toast(isHost
+ ? 'Совместное редактирование включено. Пригласи друга кнопкой 👥'
+ : 'Ты подключился к совместному редактированию!');
+ },
+ onError: (msg) => {
+ // 403 (нет доступа) — норм для не-приглашённого; просто не коллабим.
+ console.warn('[collab] error:', msg);
+ },
+ onLeft: () => { setCollabActive(false); },
+ onPresenceChange: (list) => {
+ collabOverlayRef.current?.updatePresence(list);
+ setCollabPeers(Math.max(0, list.filter(c => !c.me).length));
+ },
+ onOpRejected: (m) => {
+ collabOverlayRef.current?.toast('Этот объект сейчас редактирует другой соавтор');
+ },
+ onChat: (m) => { /* чат соавторов — на этап 2 */ },
+ // host отдаёт текущую сцену новому соавтору
+ onSnapshotRequest: (replyFn) => {
+ try { replyFn(sceneRef.current.serialize()); } catch (e) { /* ignore */ }
+ },
+ // новый соавтор получил сцену от host — грузим
+ onRemoteSnapshot: async (state) => {
+ try {
+ if (state) {
+ await sceneRef.current.loadFromState(state);
+ dirtyRef.current = false;
+ }
+ } catch (e) { console.warn('[collab] snapshot load failed', e); }
+ },
+ },
+ });
+ await collab.connect();
+ collab.installInterceptors();
+ collabRef.current = collab;
+ // presence-overlay
+ const ov = new CollabOverlay(sceneRef.current);
+ ov.mount();
+ collabOverlayRef.current = ov;
+ // курсор-трекинг: шлём точку под мышью на сцене (raycast по pointermove)
+ _wireCursorTracking(sceneRef.current, collab);
+ } catch (e) {
+ // 403/нет доступа/realtime недоступен — работаем соло, не падаем.
+ console.warn('[collab] init skipped:', e?.message || e);
+ }
+ }, []);
+
+ /**
+ * Team Create: «Пригласить» — запросить collab-токен у realtime, собрать
+ * ссылку studio.rublox.pro/edit/?collab= и скопировать в буфер.
+ */
+ const handleInvite = useCallback(async () => {
+ try {
+ if (!/^\d+$/.test(id)) { alert('Сначала сохрани проект.'); return; }
+ const tokenRaw = localStorage.getItem('Authorization') || localStorage.getItem('jwt') || '';
+ const base = (REALTIME_HTTP || '').replace(/\/$/, '');
+ const res = await fetch(`${base}/studio-invite/${id}`, {
+ method: 'POST',
+ headers: { Authorization: tokenRaw, 'Content-Type': 'application/json' },
+ });
+ if (!res.ok) {
+ if (res.status === 403) { alert('Только автор проекта может приглашать соавторов.'); return; }
+ alert('Не удалось создать приглашение (' + res.status + ').');
+ return;
+ }
+ const { token } = await res.json();
+ const link = `${window.location.origin}/edit/${id}?collab=${token}`;
+ try {
+ await navigator.clipboard.writeText(link);
+ collabOverlayRef.current?.toast('Ссылка-приглашение скопирована! Отправь другу.');
+ } catch (e) {
+ window.prompt('Скопируй ссылку-приглашение для друга:', link);
+ }
+ } catch (e) {
+ console.warn('[collab] invite failed', e);
+ alert('Не удалось создать приглашение. Realtime недоступен?');
+ }
+ }, [id]);
+
// Инициализация Babylon + загрузка проекта (если редактируем существующий)
useEffect(() => {
// RACE FIX: пока isLoading=true (auth ещё грузится), компонент
@@ -1293,6 +1505,8 @@ const KubikonEditor = () => {
markDirty();
// Иерархия изменилась — interval пересоберёт списки на след. тике.
hierarchyDirtyRef.current = true;
+ // Синк флага точки спавна (например после Delete-клавиши).
+ try { setSpawnEnabledUI(scene.hasSpawn?.() !== false); } catch (e) {}
});
// Этап 5: подключаем API пользовательских моделей в BabylonScene,
@@ -1497,6 +1711,7 @@ const KubikonEditor = () => {
const ch = sceneRef.current.getCrosshair?.();
if (ch) setCrosshairUI(ch);
setFloorEnabledUI(sceneRef.current.isFloorEnabled?.() !== false);
+ setSpawnEnabledUI(sceneRef.current.hasSpawn?.() !== false);
const a = sceneRef.current.getAudioState?.();
if (a?.ambientId) setAmbientIdUI(a.ambientId);
if (a?.musicId) setMusicIdUI(a.musicId);
@@ -1515,6 +1730,9 @@ const KubikonEditor = () => {
} finally {
console.log(`[KubikonEditor] setSceneLoading(false) — load finally (id=${id})`);
setSceneLoading(false);
+ // Team Create: после загрузки сцены — подключиться к комнате
+ // совместного редактирования (владелец или по ?collab-инвайту).
+ if (/^\d+$/.test(id)) initCollab(Number(id));
}
})();
} else {
@@ -1565,6 +1783,21 @@ const KubikonEditor = () => {
}
setModelsList(arr);
}
+ if (s.userModelManager) {
+ const arr = [];
+ for (const data of s.userModelManager.instances.values()) {
+ arr.push({
+ instanceId: data.instanceId,
+ userModelTypeId: data.userModelTypeId,
+ userModelId: data.userModelId,
+ x: data.x, y: data.y, z: data.z,
+ rotationY: data.rotationY,
+ folderId: data.folderId ?? null,
+ name: data.name || null,
+ });
+ }
+ setUserModelsList(arr);
+ }
if (s.primitiveManager) {
// getAll() не включает folderId — добавляем вручную
const arr = s.primitiveManager.getAll();
@@ -1616,13 +1849,23 @@ const KubikonEditor = () => {
clearTimeout(autoSaveTimerRef.current);
autoSaveTimerRef.current = null;
}
+ // Team Create: отключиться от комнаты + снять overlay.
+ try {
+ if (collabRef.current?.__cursorHandler && collabRef.current?.__cursorCanvas) {
+ collabRef.current.__cursorCanvas.removeEventListener('pointermove', collabRef.current.__cursorHandler);
+ }
+ collabRef.current?.dispose();
+ collabOverlayRef.current?.dispose();
+ } catch (e) { /* ignore */ }
+ collabRef.current = null;
+ collabOverlayRef.current = null;
scene.dispose();
sceneRef.current = null;
};
// isLoading в deps — без него эффект мог стрельнуть пока canvas
// ещё не в DOM (isLoading=true → компонент рендерит null) и больше
// не перезапускался → вечная "Загрузка проекта… 0%".
- }, [isAuthenticated, isLoading, id, markDirty]);
+ }, [isAuthenticated, isLoading, id, markDirty, initCollab]);
// beforeunload — браузерный диалог нельзя кастомизировать (API запрещает).
// Auto-save срабатывает на каждое изменение через ~1.5 сек, поэтому риск
@@ -1840,6 +2083,19 @@ const KubikonEditor = () => {
{saveStatus === 'error' && <> Ошибка>}
{saveStatus === 'idle' && '—'}
+ {/* Гость-соавтор (приглашённый по ссылке) НЕ может менять
+ настройки/сохранять/публиковать — это делает только владелец. */}
+ {isInvitedGuest ? (
+
+ Совместное редактирование
+
+ ) : (
+ <>
setSettingsModalOpen(true)}
@@ -1848,14 +2104,7 @@ const KubikonEditor = () => {
>
Настройки
- setSkinManagerOpen(true)}
- title="Скины игрока: стартовый скин, магазин, рублики, свои .glb"
- style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}
- >
- Скины
-
+ {/* Кнопка «Скины» переехала в TopRibbon → вкладка «Игра». */}
Сохранить
{
>
{(publishBan || isCantPublish) ? <> Запрещено> : <> Опубликовать>}
+ >
+ )}
{/* Кнопка баг-репорта в шапке — кликает по скрытой плавающей. */}
document.querySelector('[data-kubikon-bug-btn]')?.click()}
@@ -2036,7 +2287,12 @@ const KubikonEditor = () => {
onPlayToggle={handlePlay}
onSetSpawn={() => {
sceneRef.current?.setSpawnAtCamera();
+ setSpawnEnabledUI(true);
}}
+ onSkins={() => setSkinManagerOpen(true)}
+ onInvite={handleInvite}
+ collabActive={collabActive}
+ collabPeers={collabPeers}
hasSelection={!!selection}
onDuplicate={() => sceneRef.current?.duplicateSelected()}
onAlignToFloor={() => sceneRef.current?.alignSelectedToFloor()}
@@ -2674,6 +2930,7 @@ const KubikonEditor = () => {
className={cl.canvas}
style={{ visibility: activeTabId === 'scene' ? 'visible' : 'hidden' }}
/>
+ {/* Кнопка «Пригласить» переехала в TopRibbon → вкладка «Игра» → группа «Вместе». */}
{isMaterialPreview && (
{
logs={scriptLogs}
onClear={() => setScriptLogs([])}
onClose={() => setConsoleOpen(false)}
+ onOpenScript={(scriptId) => {
+ // Открыть скрипт-источник ошибки в редакторе.
+ try { sceneRef.current?.selection?.selectScript?.(scriptId); } catch (e) {}
+ openScriptTab(scriptId);
+ }}
/>
{/* Monaco-редактор скрипта (этап 2.2). Активен когда выбран таб со скриптом. */}
@@ -3064,9 +3326,21 @@ const KubikonEditor = () => {
{
+ sceneRef.current?.selection?.selectUserModelByInstanceId(id);
+ setActiveTool('select');
+ }}
+ onDeleteUserModel={(id) => {
+ sceneRef.current?.userModelManager?.removeInstance(id);
+ sceneRef.current?.clearSelection();
+ }}
+ onRenameUserModel={(id, name) => {
+ if (sceneRef.current?.renameUserModel?.(id, name)) markDirty();
+ }}
onSelectScript={(scriptId) => {
sceneRef.current?.selection?.selectScript?.(scriptId);
setActiveTool('select');
@@ -3080,12 +3354,17 @@ const KubikonEditor = () => {
if (target) {
if (target.kind === 'block') {
normalized = { kind: 'block', ref: { x: target.x, y: target.y, z: target.z } };
- } else if (target.kind === 'model' || target.kind === 'primitive') {
+ } else if (target.kind === 'model' || target.kind === 'primitive' || target.kind === 'userModel') {
normalized = { kind: target.kind, id: target.id };
}
}
const tpl = normalized ? NEW_OBJECT_SCRIPT_TEMPLATE : NEW_SCRIPT_TEMPLATE;
- sceneRef.current?.upsertScript(id, tpl, normalized);
+ // Понятное имя по умолчанию (а не сырой id).
+ const existing = sceneRef.current?.getScripts?.() || [];
+ const nm = normalized
+ ? `Скрипт объекта ${existing.filter(s => s.target && s.target !== 'game').length + 1}`
+ : `Скрипт ${existing.filter(s => !s.target || s.target === 'game').length + 1}`;
+ sceneRef.current?.upsertScript(id, tpl, normalized, nm);
markDirty();
setScriptsList(sceneRef.current?.getScripts?.() || []);
sceneRef.current?.selection?.selectScript?.(id);
@@ -3152,6 +3431,7 @@ const KubikonEditor = () => {
setActiveTool('select');
}}
floorEnabled={floorEnabled}
+ spawnEnabled={spawnEnabledUI}
onSelectFloor={() => {
sceneRef.current?.selection?.selectFloor?.();
setActiveTool('select');
@@ -3216,6 +3496,18 @@ const KubikonEditor = () => {
// Активируем гизмо «Двигать» чтобы можно было сразу таскать
setGizmoMode('move');
}}
+ onDeleteSpawn={() => {
+ sceneRef.current?.deleteSpawn?.();
+ sceneRef.current?.clearSelection?.();
+ setSpawnEnabledUI(false);
+ markDirty();
+ }}
+ onSelectFolder={(folderId) => {
+ sceneRef.current?.selection?.selectFolder?.(folderId);
+ setActiveTool('select');
+ // Активируем gizmo «Двигать» чтобы сразу таскать всю группу.
+ setGizmoMode('move');
+ }}
onSelectLighting={() => {
sceneRef.current?.selection?.selectLighting();
setActiveTool('select');
@@ -3223,14 +3515,24 @@ const KubikonEditor = () => {
onDeleteBlock={(x, y, z) => {
sceneRef.current?.blockManager?.removeBlock(x, y, z);
sceneRef.current?.clearSelection();
+ markDirty();
+ hierarchyDirtyRef.current = true;
}}
onDeleteModel={(id) => {
sceneRef.current?.modelManager?.removeInstance(id);
+ sceneRef.current?._cleanupOrphanScripts?.();
sceneRef.current?.clearSelection();
+ setScriptsList(sceneRef.current?.getScripts?.() || []);
+ markDirty();
+ hierarchyDirtyRef.current = true;
}}
onDeletePrimitive={(id) => {
sceneRef.current?.primitiveManager?.removeInstance(id);
+ sceneRef.current?._cleanupOrphanScripts?.();
sceneRef.current?.clearSelection();
+ setScriptsList(sceneRef.current?.getScripts?.() || []);
+ markDirty();
+ hierarchyDirtyRef.current = true;
}}
onFocusSelection={() => sceneRef.current?.focusOnSelection()}
onCreateFolder={(name, parentId) =>
@@ -3814,6 +4116,12 @@ const KubikonEditor = () => {
}}
onClose={() => setToolboxOpen(false)}
onPick={(id, userModelObj = null) => {
+ // Задача 17: готовая механика из Тулбокса (kit:).
+ // Вставляем её скрипты/примитивы в проект одним кликом.
+ if (typeof id === 'string' && id.startsWith('kit:')) {
+ insertGameplayKit(id.slice(4));
+ return;
+ }
// Пользовательские модели имеют префикс 'user:' и
// обрабатываются в BabylonScene через UserModelManager
// (Этап 5). Активный тип модели работает одинаково.
@@ -3852,4 +4160,34 @@ const KubikonEditor = () => {
);
};
+/**
+ * Team Create: слать соавторам точку под мышью на сцене (raycast по pointermove).
+ * Throttle уже внутри collab.sendCursor. Также шлём позицию камеры.
+ */
+function _wireCursorTracking(scene, collab) {
+ try {
+ const canvas = scene.canvas;
+ if (!canvas) return;
+ const onMove = () => {
+ const bScene = scene.scene;
+ if (!bScene) return;
+ try {
+ const pick = bScene.pick(bScene.pointerX, bScene.pointerY);
+ if (pick && pick.hit && pick.pickedPoint) {
+ collab.sendCursor(pick.pickedPoint.x, pick.pickedPoint.y, pick.pickedPoint.z);
+ }
+ } catch (e) { /* ignore */ }
+ // камера (throttle внутри)
+ try {
+ const c = scene.camera;
+ if (c && c.position) collab.sendCamera(c.position.x, c.position.y, c.position.z);
+ } catch (e) { /* ignore */ }
+ };
+ canvas.addEventListener('pointermove', onMove);
+ // сохраним для снятия (необязательно — canvas живёт с редактором)
+ collab.__cursorHandler = onMove;
+ collab.__cursorCanvas = canvas;
+ } catch (e) { /* ignore */ }
+}
+
export default KubikonEditor;
diff --git a/src/editor/ScriptConsole.jsx b/src/editor/ScriptConsole.jsx
index bbdb9a0..4f3fcd0 100644
--- a/src/editor/ScriptConsole.jsx
+++ b/src/editor/ScriptConsole.jsx
@@ -25,7 +25,7 @@ const LEVEL_BG = {
warn: 'rgba(245, 158, 11, 0.12)',
};
-const ScriptConsole = ({ logs = [], onClear, onClose, visible }) => {
+const ScriptConsole = ({ logs = [], onClear, onClose, visible, onOpenScript }) => {
const listRef = useRef(null);
const [copyState, setCopyState] = useState('idle');
@@ -260,16 +260,38 @@ const ScriptConsole = ({ logs = [], onClear, onClose, visible }) => {
? `3px solid ${LEVEL_COLORS[l.level]}`
: '3px solid transparent',
paddingLeft: 8,
+ display: 'flex', alignItems: 'flex-start', gap: 8,
}}>
-
- {new Date(l.ts || Date.now()).toLocaleTimeString().slice(0, 8)}
+
+
+ {new Date(l.ts || Date.now()).toLocaleTimeString().slice(0, 8)}
+
+ {l.level === 'error' && }
+ {l.level === 'warn' && }
+ {l.level === 'info' && ● }
+ {l.text}
- {l.level === 'error' && }
- {l.level === 'warn' && }
- {l.level === 'info' && ● }
- {l.text}
+ {/* Ссылка на скрипт-источник (клик открывает его). */}
+ {l.scriptId && (
+ onOpenScript?.(l.scriptId)}
+ title={'Открыть скрипт: ' + (l.scriptName || l.scriptId)}
+ style={{
+ flex: '0 0 auto', maxWidth: 160,
+ background: 'rgba(79,116,255,0.16)',
+ border: '1px solid rgba(79,116,255,0.3)',
+ color: '#8aa0ff', borderRadius: 6,
+ padding: '1px 8px', fontSize: 11, fontWeight: 700,
+ cursor: 'pointer', whiteSpace: 'nowrap',
+ overflow: 'hidden', textOverflow: 'ellipsis',
+ }}
+ >
+ 📄 {l.scriptName || l.scriptId}
+
+ )}
))
)}
diff --git a/src/editor/ToolboxModal.jsx b/src/editor/ToolboxModal.jsx
index 0a11057..61e58d4 100644
--- a/src/editor/ToolboxModal.jsx
+++ b/src/editor/ToolboxModal.jsx
@@ -1,5 +1,6 @@
import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react';
import { MODEL_TYPES, MODEL_CATEGORIES } from './engine/ModelTypes';
+import { GAMEPLAY_KITS, KIT_CATEGORIES } from './engine/GameplayKits';
import { getModelThumbnail, cancelThumbnailRequest } from './engine/ModelThumbnails';
import {
getMyUserModels, getPublicUserModels, likeUserModel,
@@ -281,10 +282,18 @@ const ToolboxModal = ({
initialSection = 'standard',
}) => {
// Корневой раздел: 'standard' | 'mine' | 'community'
+ // === Roblox-style Toolbox (задача 17) ===
+ // Верхняя вкладка: 'store' | 'inventory' | 'recent' | 'tips'.
+ const [view, setView] = useState('store');
+ // Выбранная категория магазина (null = главный экран с 6 плитками):
+ // '3d' | 'fx' | '2d' | 'gameplay' | 'plugins' | 'audio'.
+ const [storeCat, setStoreCat] = useState(null);
+
const [section, setSection] = useState('standard');
const [search, setSearch] = useState('');
const [category, setCategory] = useState('all'); // для 'standard'
const [userKind, setUserKind] = useState('all'); // для 'mine'/'community': all|voxel|smooth
+ const [kitCat, setKitCat] = useState('all'); // для 'gameplay': категория кита
// Загруженные модели для 'mine' и 'community'
const [myModels, setMyModels] = useState(null); // null = ещё не загружено
@@ -295,22 +304,43 @@ const ToolboxModal = ({
useEffect(() => {
if (open) {
setSearch('');
+ // initialSection маппится в новую структуру: mine → inventory.
+ if (initialSection === 'mine') { setView('inventory'); setStoreCat(null); }
+ else { setView('store'); setStoreCat(null); }
setSection(initialSection || 'standard');
setCategory('all');
setUserKind('all');
+ setKitCat('all');
setMyModels(null);
setCommunityModels(null);
setLoadError('');
}
}, [open, initialSection]);
- // Esc — закрыть
+ // Маппинг категории магазина → внутренний section (для lazy-load моделей).
+ const STORE_CAT_TO_SECTION = { '3d': 'standard', gameplay: 'gameplay', '2d': 'standard' };
+ const openStoreCategory = useCallback((catId) => {
+ setStoreCat(catId);
+ setSearch('');
+ setCategory('all');
+ setKitCat('all');
+ const sec = STORE_CAT_TO_SECTION[catId];
+ if (sec) setSection(sec);
+ }, []);
+
+ // Синхронизация верхней вкладки → внутренний section (для lazy-load).
+ useEffect(() => {
+ if (view === 'inventory') setSection('mine');
+ else if (view === 'recent') setSection('community');
+ }, [view]);
+
+ // Esc — закрыть (если открыта категория магазина — сначала назад к плиткам)
useEffect(() => {
if (!open) return;
- const onKey = (e) => { if (e.key === 'Escape') onClose(); };
+ const onKey = (e) => { if (e.key === 'Escape') { if (view === 'store' && storeCat) setStoreCat(null); else onClose(); } };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
- }, [open, onClose]);
+ }, [open, onClose, view, storeCat]);
// Lazy-load моих моделей при переключении на 'mine'
useEffect(() => {
@@ -413,6 +443,16 @@ const ToolboxModal = ({
[communityModels, filterUserModels]
);
+ // === Готовые механики (gameplay-киты) — фильтр по категории + search ===
+ const kitsFiltered = useMemo(() => {
+ const q = search.trim().toLowerCase();
+ let arr = GAMEPLAY_KITS;
+ if (kitCat !== 'all') arr = arr.filter(k => k.category === kitCat);
+ if (q) arr = arr.filter(k =>
+ k.name.toLowerCase().includes(q) || k.desc.toLowerCase().includes(q));
+ return arr;
+ }, [search, kitCat]);
+
// Активный счётчик для шапки
const visibleCount = section === 'standard'
? standardFiltered.length
@@ -425,6 +465,27 @@ const ToolboxModal = ({
? (myModels?.length || 0)
: (communityModels?.length || 0);
+ // 6 категорий магазина (как в Roblox Creator Store).
+ const STORE_CATEGORIES = [
+ { id: '3d', label: '3D-объекты', icon: 'cube', desc: '700+ моделей: природа, дома, мебель, NPC' },
+ { id: 'fx', label: 'Эффекты', icon: 'sparkles', desc: 'Частицы, лучи, маркеры' },
+ { id: '2d', label: '2D-картинки', icon: 'image', desc: 'Иконки и текстуры для интерфейса' },
+ { id: 'gameplay', label: 'Готовые механики', icon: 'zap', desc: `${GAMEPLAY_KITS.length} механик: вставил — работает` },
+ { id: 'plugins', label: 'Плагины', icon: 'puzzle', desc: 'Расширения студии' },
+ { id: 'audio', label: 'Аудио', icon: 'sound', desc: 'Звуки и музыка' },
+ ];
+ // Trending — что популярно (для главного экрана магазина). Берём яркие киты.
+ const TRENDING = GAMEPLAY_KITS.filter(k =>
+ ['shift-to-run', 'day-night-cycle', 'loot-crate', 'confetti'].includes(k.id));
+ // Эффекты-примитивы для категории «Эффекты».
+ const FX_ITEMS = [
+ { id: 'emitter', name: 'Эмиттер частиц', icon: 'sparkles', desc: 'Источник частиц (огонь/искры/дым)' },
+ { id: 'beam', name: 'Луч (beam)', icon: 'zap', desc: 'Бегущий луч между точками' },
+ { id: 'pointer', name: 'Указатель-стрелка', icon: 'arrow-up', desc: 'Парящая стрелка-подсказка' },
+ { id: 'light', name: 'Источник света', icon: 'sun', desc: 'Точечная лампа' },
+ { id: 'checkpoint', name: 'Триггер-зона', icon: 'flag', desc: 'Невидимая зона-триггер' },
+ ];
+
if (!open) return null;
// Обработчик выбора пользовательской модели — пока stub.
@@ -490,59 +551,63 @@ const ToolboxModal = ({
return (
{ if (e.target === e.currentTarget) onClose(); }}>
+ {/* === Шапка с 4 верхними вкладками (как Roblox Creator Store) === */}
- {/* Раздел: Стандартные / Мои / Сообщество */}
-
-
setSection('standard')}
- >
- Стандартные
-
-
setSection('mine')}
- >
- Мои модели
-
-
setSection('community')}
- >
- Сообщество
-
+
+ {[
+ { id: 'store', label: 'Магазин', icon: 'box' },
+ { id: 'inventory', label: 'Инвентарь', icon: 'grid' },
+ { id: 'recent', label: 'Недавние', icon: 'clock' },
+ { id: 'tips', label: 'Советы', icon: 'bulb' },
+ ].map(t => (
+ { setView(t.id); setStoreCat(null); setSearch(''); }}
+ title={t.label}
+ >
+
+ {t.label}
+
+ ))}
-
- setSearch(e.target.value)}
- autoFocus
- />
-
+ {/* Поиск — скрыт только на главном экране магазина и в советах */}
+ {!(view === 'store' && !storeCat) && view !== 'tips' && (
+
+ setSearch(e.target.value)}
+ autoFocus
+ />
+
+ )}
- {/* Подкатегории зависят от раздела */}
- {section === 'standard' && (
+ {/* Хлебные крошки/назад при открытой категории магазина */}
+ {view === 'store' && storeCat && (
+
+ setStoreCat(null)}>
+ Категории
+
+
+ {(STORE_CATEGORIES.find(c => c.id === storeCat) || {}).label}
+
+
+ )}
+
+ {/* Подкатегории standard (3D) */}
+ {view === 'store' && storeCat === '3d' && (
{standardCategoriesWithCount.map(c => (
)}
-
- {(section === 'mine' || section === 'community') && (
+ {/* Подкатегории механик */}
+ {view === 'store' && storeCat === 'gameplay' && (
- setUserKind('all')}
- >
- Все
-
- setUserKind('voxel')}
- >
- Воксельные
-
- setUserKind('smooth')}
- >
- Гладкие
-
+ {KIT_CATEGORIES.map(c => (
+ setKitCat(c.id)}
+ >{c.label}
+ ))}
+
+ )}
+ {/* Фильтр kind для инвентаря */}
+ {view === 'inventory' && (
+
+ setUserKind('all')}> Все
+ setUserKind('voxel')}> Воксельные
+ setUserKind('smooth')}> Гладкие
)}
- {/* === Контент === */}
-
- {section === 'standard' && (
- standardFiltered.length === 0 ? (
-
Ничего не найдено
- ) : (
- standardFiltered.map(m => (
-
{ onPick(m.id); onClose(); }}
- />
- ))
- )
- )}
+ {/* ====================== КОНТЕНТ ====================== */}
- {section === 'mine' && (
- loading || myModels === null ? (
+ {/* --- МАГАЗИН: главный экран (6 плиток + Trending) --- */}
+ {view === 'store' && !storeCat && (
+
+
Категории
+
+ {STORE_CATEGORIES.map(c => (
+
openStoreCategory(c.id)}>
+
+ {c.label}
+ {c.desc}
+
+ ))}
+
+
+ Популярное
+
+
+ {TRENDING.map(kit => (
+
{ onPick('kit:' + kit.id); onClose(); }} title={kit.desc}>
+
+ {kit.name}
+ FREE
+
+ ))}
+
+
+ )}
+
+ {/* --- МАГАЗИН: категория 3D-объекты --- */}
+ {view === 'store' && storeCat === '3d' && (
+
+ {standardFiltered.length === 0
+ ?
Ничего не найдено
+ : standardFiltered.map(m => (
+
{ onPick(m.id); onClose(); }} />
+ ))}
+
+ )}
+
+ {/* --- МАГАЗИН: Эффекты --- */}
+ {view === 'store' && storeCat === 'fx' && (
+
+ {FX_ITEMS.filter(f => !search.trim() || f.name.toLowerCase().includes(search.trim().toLowerCase())).map(f => (
+
{ onPick('primitive:' + f.id); onClose(); }} title={f.desc}>
+
+ {f.name}
+ {f.desc}
+
+ ))}
+
+ )}
+
+ {/* --- МАГАЗИН: Готовые механики --- */}
+ {view === 'store' && storeCat === 'gameplay' && (
+
+ {kitsFiltered.length === 0
+ ?
Ничего не найдено
+ : kitsFiltered.map(kit => (
+
{ onPick('kit:' + kit.id); onClose(); }} title={kit.desc}>
+
+ {kit.name}
+ {kit.desc}
+ FREE
+
+ ))}
+
+ )}
+
+ {/* --- МАГАЗИН: 2D-картинки / Плагины / Аудио — пока «скоро» --- */}
+ {view === 'store' && (storeCat === '2d' || storeCat === 'plugins' || storeCat === 'audio') && (
+
+
+
Скоро будет
+
+ {storeCat === '2d' && 'Иконки и текстуры для интерфейса появятся в следующем обновлении.'}
+ {storeCat === 'plugins' && 'Плагины-расширения студии — в разработке (фаза T4).'}
+ {storeCat === 'audio' && 'Библиотека звуков и музыки — в разработке.'}
+
+
+ )}
+
+ {/* --- ИНВЕНТАРЬ: мои модели --- */}
+ {view === 'inventory' && (
+
+ {loading || myModels === null ? (
⏳ Загрузка...
) : !userId ? (
-
- Войдите в аккаунт, чтобы видеть свои модели
-
+
Войдите в аккаунт, чтобы видеть свои модели
) : loadError ? (
{loadError}
) : mineFiltered.length === 0 ? (
{myModels.length === 0
- ? 'У вас пока нет своих моделей. Создайте их во вкладке «Модель» → «Воксельная» или «Гладкая».'
+ ? 'У вас пока нет своих моделей. Создайте их в воксельном редакторе.'
: 'Ничего не найдено по фильтру'}
) : (
mineFiltered.map(m => (
-
handlePickUserModel(m)}
- isMine
- onEdit={onEditUserModel}
- onSettings={onUserModelSettings}
- onDelete={onDeleteUserModel}
- />
+ handlePickUserModel(m)} isMine
+ onEdit={onEditUserModel} onSettings={onUserModelSettings} onDelete={onDeleteUserModel} />
))
- )
- )}
+ )}
+
+ )}
- {section === 'community' && (
- loading || communityModels === null ? (
+ {/* --- НЕДАВНИЕ: сообщество (популярные модели сообщества) --- */}
+ {view === 'recent' && (
+
+ {communityModels === null ? (
⏳ Загрузка...
- ) : loadError ? (
-
{loadError}
) : communityFiltered.length === 0 ? (
-
- {communityModels.length === 0
- ? 'Пока нет опубликованных моделей сообщества. Будь первым!'
- : 'Ничего не найдено по фильтру'}
-
+
Пока пусто. Используй ассеты — они появятся здесь.
) : (
communityFiltered.map(m => (
-
handlePickUserModel(m)}
isMine={userId != null && m.user_id === userId}
- onEdit={onEditUserModel}
- onSettings={onUserModelSettings}
- onDelete={onDeleteUserModel}
- showSocial
- onLike={handleLikeModel}
- />
+ onEdit={onEditUserModel} onSettings={onUserModelSettings} onDelete={onDeleteUserModel}
+ showSocial onLike={handleLikeModel} />
))
- )
- )}
-
+ )}
+
+ )}
+
+ {/* --- СОВЕТЫ --- */}
+ {view === 'tips' && (
+
+
Как пользоваться Toolbox
+
+ 3D-объекты — 700+ готовых моделей: деревья, дома, мебель, персонажи. Клик → объект появляется на сцене.
+ Готовые механики — вставь поведение одним кликом: бег на Shift, смена дня/ночи, сундук с лутом, счётчик монет. Скрипт прикрепляется сам.
+ Эффекты — частицы, лучи, источники света, триггер-зоны.
+ Инвентарь — твои воксельные модели, созданные в редакторе.
+ Жми на категорию, ищи через поиск, кликни ассет — он добавится в проект.
+
+
Собери целую игру, не написав ни строчки кода — просто перетаскивая готовые механики.
+
+ )}
);
diff --git a/src/editor/ToolboxModal.module.css b/src/editor/ToolboxModal.module.css
index df93cf3..dec4f7c 100644
--- a/src/editor/ToolboxModal.module.css
+++ b/src/editor/ToolboxModal.module.css
@@ -79,6 +79,7 @@
}
.closeBtn {
+ margin-left: auto; /* прижать крестик к правому краю шапки */
background: rgba(255, 255, 255, 0.16);
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 10px;
@@ -447,3 +448,156 @@
font-size: 36px;
color: var(--text-dim);
}
+
+/* ====================== Roblox-style Toolbox (задача 17) ======================
+ Явные светлые цвета (не --text-переменные — они в этой модалке не заданы и
+ давали тёмный текст на тёмном фоне). Крупнее шрифты. */
+.topTabs {
+ display: flex;
+ gap: 4px;
+ padding: 0 16px;
+ border-bottom: 1px solid rgba(255,255,255,0.10);
+ flex: 0 0 auto;
+}
+.topTab {
+ flex: 1;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding: 14px 4px 12px;
+ background: none;
+ border: none;
+ border-bottom: 2px solid transparent;
+ color: #aab2c0;
+ font-size: 15px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: color .12s, border-color .12s;
+}
+.topTab:hover { color: #ffffff; }
+.topTabActive {
+ color: #6f8bff;
+ border-bottom-color: #6f8bff;
+}
+
+.breadcrumb {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 18px 6px;
+ flex: 0 0 auto;
+}
+.backBtn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ background: rgba(255,255,255,0.08);
+ border: 1px solid rgba(255,255,255,0.14);
+ color: #e8ecf2;
+ padding: 7px 14px;
+ border-radius: 9px;
+ font-size: 14px;
+ font-weight: 600;
+ cursor: pointer;
+}
+.backBtn:hover { background: rgba(255,255,255,0.15); }
+.crumbCurrent { font-weight: 700; font-size: 15px; color: #ffffff; }
+
+.storeHome { overflow-y: auto; padding: 16px 20px 22px; flex: 1; }
+.sectionLabel {
+ display: flex; align-items: center; gap: 8px;
+ font-weight: 700; font-size: 17px; color: #ffffff;
+ margin-bottom: 14px;
+}
+.sectionLabel svg { color: #6f8bff; }
+
+.catGrid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 14px;
+}
+.catTile {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ grid-template-rows: auto auto;
+ column-gap: 14px;
+ row-gap: 4px;
+ align-items: center;
+ padding: 18px 20px;
+ background: rgba(255,255,255,0.06);
+ border: 1px solid rgba(255,255,255,0.10);
+ border-radius: 14px;
+ cursor: pointer;
+ text-align: left;
+ transition: transform .1s, background .12s, border-color .12s;
+}
+.catTile:hover {
+ background: rgba(111,139,255,0.16);
+ border-color: #6f8bff;
+ transform: translateY(-2px);
+}
+/* Иконка — слева, занимает обе строки (в одну линию с названием). */
+.catTileIcon {
+ grid-row: 1 / 3;
+ display: flex; align-items: center; justify-content: center;
+ width: 52px; height: 52px;
+ background: rgba(111,139,255,0.16);
+ border-radius: 12px;
+ color: #8aa0ff;
+}
+.catTileLabel { font-weight: 800; font-size: 18px; color: #ffffff; align-self: end; }
+.catTileDesc { font-size: 13px; color: #aab2c0; line-height: 1.35; align-self: start; }
+
+.trendRow {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 14px;
+}
+.trendCard {
+ position: relative;
+ display: flex; flex-direction: column; align-items: center; gap: 10px;
+ padding: 14px 10px 16px;
+ background: rgba(255,255,255,0.06);
+ border: 1px solid rgba(255,255,255,0.10);
+ border-radius: 14px;
+ cursor: pointer;
+ transition: transform .1s, border-color .12s;
+}
+.trendCard:hover { transform: translateY(-2px); border-color: #6f8bff; }
+.trendIcon {
+ width: 100%; height: 78px;
+ display: flex; align-items: center; justify-content: center;
+ background: linear-gradient(135deg, rgba(111,139,255,0.28), rgba(54,213,122,0.18));
+ border-radius: 10px;
+ color: #ffffff;
+}
+.trendName { font-size: 14px; font-weight: 700; text-align: center; color: #ffffff; }
+.freeBadge {
+ position: absolute; top: 10px; right: 10px;
+ font-size: 10px; font-weight: 800; letter-spacing: 0.5px;
+ color: #3ce087;
+ background: rgba(54,213,122,0.18);
+ padding: 3px 7px; border-radius: 6px;
+}
+
+.soon {
+ flex: 1;
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
+ gap: 12px; padding: 50px;
+ color: #aab2c0; text-align: center;
+}
+.soon svg { color: #6f8bff; }
+.soonTitle { font-size: 22px; font-weight: 800; color: #ffffff; }
+.soonText { font-size: 15px; max-width: 400px; color: #aab2c0; line-height: 1.5; }
+
+.tips {
+ overflow-y: auto; padding: 22px 28px; flex: 1;
+ color: #e8ecf2; line-height: 1.6;
+}
+.tips h3 { margin: 4px 0 16px; font-size: 21px; color: #ffffff; }
+.tips ul { margin: 0 0 18px; padding-left: 22px; }
+.tips li { margin-bottom: 12px; font-size: 15px; color: #d4dae4; }
+.tips b { color: #8aa0ff; }
+.tips p { font-size: 14px; }
diff --git a/src/editor/TopRibbon.jsx b/src/editor/TopRibbon.jsx
index 5966394..e40cd14 100644
--- a/src/editor/TopRibbon.jsx
+++ b/src/editor/TopRibbon.jsx
@@ -140,10 +140,12 @@ const Dropdown = ({ trigger, children }) => {
*/
const TABS = [
- { id: 'home', label: 'Главная', iconName: 'home' },
- { id: 'model', label: 'Модель', iconName: 'wrench' },
- { id: 'test', label: 'Игра', iconName: 'gamepad' },
- { id: 'view', label: 'Вид', iconName: 'eye' },
+ { id: 'home', label: 'Главная', iconName: 'home' },
+ // Вкладка-редактор СВОИХ воксельных моделей (создание ассета).
+ // Каталог готовых моделей/механик теперь в Toolbox (кнопка на «Главной»).
+ { id: 'model', label: 'Редактор моделей', iconName: 'wrench' },
+ { id: 'test', label: 'Игра', iconName: 'gamepad' },
+ { id: 'view', label: 'Вид', iconName: 'eye' },
];
const SNAP_OPTIONS = [
@@ -231,6 +233,7 @@ const TopRibbon = (props) => {
snap, onSnapChange,
activeTool, onToolChange,
isPlaying, onPlayToggle, onSetSpawn,
+ onSkins, onInvite, collabActive, collabPeers,
hasSelection,
onDuplicate, onAlignToFloor, onDelete,
onClearScene,
@@ -329,9 +332,9 @@ const TopRibbon = (props) => {
title="Параметрическая фигура (куб/сфера/...)"
/>
onToolChange('model')}
+ iconName="box" label="Toolbox"
+ onClick={onOpenStandardModels}
+ title="Библиотека: 3D-объекты, готовые механики, эффекты (как Creator Store)"
/>
{
onClick={onSetSpawn}
title="Поставить точку спавна там где смотрит камера"
/>
+
+
+
+ {/* Team Create — совместное редактирование. */}
+
+ 0 ? `Вместе (${collabPeers + 1})` : 'Пригласить'}
+ active={collabActive && collabPeers > 0}
+ onClick={onInvite}
+ title="Пригласить друга редактировать игру вместе (Team Create)"
+ />
{/* «Окружение» (время суток / амбиент / музыка) и
diff --git a/src/editor/engine/AchievementsManager.js b/src/editor/engine/AchievementsManager.js
new file mode 100644
index 0000000..5f49b0b
--- /dev/null
+++ b/src/editor/engine/AchievementsManager.js
@@ -0,0 +1,249 @@
+/**
+ * AchievementsManager — достижения (badges) как в Roblox (задача 20).
+ *
+ * - define([...]) регистрирует достижения проекта.
+ * - unlock(id) разблокирует → toast справа-сверху (4 редкости, очередь, звук).
+ * - bindToStat(id, statName, {gte/lte/eq}) — авто-unlock по leaderstat.
+ * - кнопка-кубок слева-снизу → страница «Мои достижения» (grid + прогресс).
+ * - сохранение разблокированных в localStorage по projectId (закрыл-открыл → остались).
+ *
+ * API (через game.achievements.*): define/unlock/has/list/progress/bindToStat/
+ * setButtonVisible/openPage.
+ *
+ * Фича-парность: тот же модуль в rublox-player/src/engine/.
+ */
+
+const RARITY = {
+ common: { label: 'Обычное', border: '#9aa3b2', bg: 'linear-gradient(135deg,rgba(120,130,150,0.9),rgba(80,88,104,0.9))', glow: 'rgba(154,163,178,0.5)' },
+ rare: { label: 'Редкое', border: '#4d8bff', bg: 'linear-gradient(135deg,rgba(60,110,220,0.92),rgba(30,60,150,0.92))', glow: 'rgba(77,139,255,0.6)' },
+ epic: { label: 'Эпическое', border: '#a05aff', bg: 'linear-gradient(135deg,rgba(150,80,230,0.92),rgba(90,40,160,0.92))', glow: 'rgba(160,90,255,0.65)' },
+ legendary: { label: 'Легендарное', border: '#ffd23a', bg: 'linear-gradient(135deg,rgba(255,200,60,0.95),rgba(220,140,20,0.95))', glow: 'rgba(255,210,58,0.75)' },
+};
+
+export class AchievementsManager {
+ constructor(scene3d) {
+ this.s = scene3d;
+ this._defs = []; // [{id,name,description,icon,rarity,points,hidden}]
+ this._unlocked = new Set(); // id разблокированных
+ this._binds = []; // [{id, stat, op, value}]
+ this._toastQueue = [];
+ this._toastActive = false;
+ this._btnVisible = true;
+ this.btn = null; this.toastRoot = null; this.page = null;
+ this._projectKey = 'rublox_ach_' + (this.s?._projectId ?? 'proj');
+ }
+
+ define(list) {
+ const arr = Array.isArray(list) ? list : [list];
+ for (const a of arr) {
+ if (!a || typeof a.id !== 'string') continue;
+ if (this._defs.some(d => d.id === a.id)) continue;
+ this._defs.push({
+ id: a.id, name: a.name || a.id, description: a.description || '',
+ icon: a.icon || '🏆', rarity: RARITY[a.rarity] ? a.rarity : 'common',
+ points: Number(a.points) || 5, hidden: !!a.hidden,
+ });
+ }
+ this._loadSaved();
+ this._mountButton();
+ }
+
+ _loadSaved() {
+ // Резервная локальная копия (мгновенно, до ответа БД).
+ try {
+ const raw = localStorage.getItem(this._projectKey);
+ if (raw) for (const id of JSON.parse(raw)) this._unlocked.add(id);
+ } catch (e) { /* ignore */ }
+ }
+ /** Загрузить разблокированные достижения из БД (по игроку). Вызывать при Play. */
+ loadFromDB() {
+ const rt = this.s?.gameRuntime;
+ if (!rt || !rt.loadProgress) return;
+ rt.loadProgress('_achievements', (data) => {
+ if (Array.isArray(data)) {
+ for (const id of data) this._unlocked.add(id);
+ }
+ });
+ }
+ _persist() {
+ // 1) локально (быстрый кэш), 2) в БД (между сессиями, любое устройство).
+ try { localStorage.setItem(this._projectKey, JSON.stringify([...this._unlocked])); } catch (e) {}
+ try { this.s?.gameRuntime?.saveProgress?.('_achievements', [...this._unlocked]); } catch (e) {}
+ }
+
+ unlock(id, _playerId) {
+ const def = this._defs.find(d => d.id === id);
+ if (!def || this._unlocked.has(id)) return false;
+ this._unlocked.add(id);
+ this._persist();
+ this._queueToast(def);
+ this._playSound(def.rarity);
+ return true;
+ }
+
+ has(id) { return this._unlocked.has(id); }
+
+ list() {
+ return this._defs.map(d => ({ id: d.id, name: d.name, unlocked: this._unlocked.has(d.id) }));
+ }
+
+ progress() {
+ const total = this._defs.length;
+ const unlocked = this._defs.filter(d => this._unlocked.has(d.id)).length;
+ const pts = this._defs.filter(d => this._unlocked.has(d.id)).reduce((s, d) => s + d.points, 0);
+ const maxPts = this._defs.reduce((s, d) => s + d.points, 0);
+ return { total, unlocked, points: pts, maxPoints: maxPts };
+ }
+
+ /** Авто-unlock при достижении leaderstat значения. */
+ bindToStat(id, statName, cond) {
+ const op = cond && (cond.gte != null ? 'gte' : cond.lte != null ? 'lte' : cond.eq != null ? 'eq' : null);
+ if (!op) return;
+ this._binds.push({ id, stat: statName, op, value: cond[op] });
+ // Подпишемся на leaderstats при первом bind.
+ if (!this._boundLs && this.s?.leaderstats) {
+ this._boundLs = true;
+ this.s.leaderstats.onChange((pid, name, nv) => this._checkBinds(name, nv));
+ }
+ }
+ _checkBinds(statName, value) {
+ for (const b of this._binds) {
+ if (b.stat !== statName || this._unlocked.has(b.id)) continue;
+ const ok = b.op === 'gte' ? value >= b.value : b.op === 'lte' ? value <= b.value : value === b.value;
+ if (ok) this.unlock(b.id);
+ }
+ }
+
+ setButtonVisible(v) { this._btnVisible = !!v; if (this.btn) this.btn.style.display = v ? 'flex' : 'none'; }
+
+ get active() { return this._defs.length > 0; }
+
+ // ── Кнопка-кубок ───────────────────────────────────────────────────────
+ _mountButton() {
+ if (this.btn || !this.active) return;
+ if (!this.s?._isPlaying) return; // кнопка-кубок только в Play
+ const parent = (this.s && this.s.canvas && this.s.canvas.parentElement) || document.body;
+ const b = document.createElement('button');
+ b.title = 'Мои достижения';
+ b.textContent = '🏆';
+ b.style.cssText = [
+ 'position:absolute', 'left:14px', 'bottom:64px', 'z-index:50',
+ 'width:46px', 'height:46px', 'border-radius:12px', 'font-size:24px',
+ 'background:rgba(18,22,33,0.6)', 'backdrop-filter:blur(8px)',
+ 'border:1px solid rgba(255,255,255,0.15)', 'cursor:pointer',
+ 'display:flex', 'align-items:center', 'justify-content:center',
+ 'box-shadow:0 4px 16px rgba(0,0,0,0.35)', 'pointer-events:auto',
+ ].join(';');
+ if (!this._btnVisible) b.style.display = 'none';
+ b.onclick = () => this.openPage();
+ parent.appendChild(b);
+ this.btn = b;
+ }
+
+ // ── Toast ────────────────────────────────────────────────────────────
+ _queueToast(def) { this._toastQueue.push(def); if (!this._toastActive) this._nextToast(); }
+ _nextToast() {
+ if (!this._toastQueue.length) { this._toastActive = false; return; }
+ this._toastActive = true;
+ const def = this._toastQueue.shift();
+ const r = RARITY[def.rarity];
+ const parent = (this.s && this.s.canvas && this.s.canvas.parentElement) || document.body;
+ const t = document.createElement('div');
+ t.style.cssText = [
+ 'position:absolute', 'top:200px', 'right:14px', 'z-index:60',
+ 'width:340px', 'display:flex', 'align-items:center', 'gap:12px',
+ 'padding:12px 14px', 'border-radius:14px', 'background:' + r.bg,
+ 'border:2px solid ' + r.border, 'box-shadow:0 0 24px ' + r.glow + ',0 8px 24px rgba(0,0,0,0.4)',
+ 'font-family:Inter,system-ui,sans-serif', 'color:#fff',
+ 'transform:translateX(380px)', 'transition:transform .32s cubic-bezier(.2,.8,.3,1)',
+ 'pointer-events:auto', 'cursor:pointer',
+ ].join(';');
+ t.innerHTML =
+ '' + def.icon + '
' +
+ '' +
+ '
Достижение разблокировано · ' + r.label + '
' +
+ '
' + this._esc(def.name) + '
' +
+ '
' + this._esc(def.description) + ' · +' + def.points + ' очк.
' +
+ '
';
+ t.onclick = () => this.openPage();
+ parent.appendChild(t);
+ // slide-in
+ requestAnimationFrame(() => { t.style.transform = 'translateX(0)'; });
+ // через 3с slide-out + следующий
+ setTimeout(() => {
+ t.style.transform = 'translateX(380px)';
+ setTimeout(() => { try { t.remove(); } catch (e) {} this._nextToast(); }, 350);
+ }, 3000);
+ }
+
+ _playSound(rarity) {
+ // Используем встроенные звуки движка через gameRuntime/audio.
+ try {
+ const map = { common: 'coin', rare: 'win', epic: 'win', legendary: 'win' };
+ const pitch = { common: 1, rare: 1.1, epic: 0.9, legendary: 0.8 }[rarity] || 1;
+ this.s?.gameRuntime?._playSound?.({ name: map[rarity] || 'coin', pitch });
+ } catch (e) { /* ignore */ }
+ }
+
+ // ── Страница «Мои достижения» ───────────────────────────────────────────
+ openPage() {
+ if (this.page) { this._closePage(); return; }
+ const parent = (this.s && this.s.canvas && this.s.canvas.parentElement) || document.body;
+ const overlay = document.createElement('div');
+ overlay.style.cssText = [
+ 'position:absolute', 'inset:0', 'z-index:80',
+ 'background:rgba(8,10,16,0.78)', 'backdrop-filter:blur(6px)',
+ 'display:flex', 'align-items:center', 'justify-content:center',
+ 'font-family:Inter,system-ui,sans-serif', 'pointer-events:auto',
+ ].join(';');
+ overlay.onclick = (e) => { if (e.target === overlay) this._closePage(); };
+ const pr = this.progress();
+ const pct = pr.total ? Math.round(pr.unlocked / pr.total * 100) : 0;
+
+ const panel = document.createElement('div');
+ panel.style.cssText = 'width:min(720px,92%);max-height:84%;overflow-y:auto;background:#141826;border:1px solid rgba(255,255,255,0.12);border-radius:18px;padding:22px;color:#e8ecf2;box-shadow:0 20px 60px rgba(0,0,0,0.5)';
+
+ let html = '' +
+ '
🏆 Мои достижения
' +
+ '
✕ ';
+ html += '' + pr.unlocked + ' из ' + pr.total + ' (' + pr.points + ' / ' + pr.maxPoints + ' очков)
';
+ html += '';
+ html += '';
+ for (const d of this._defs) {
+ const un = this._unlocked.has(d.id);
+ const r = RARITY[d.rarity];
+ const hiddenLocked = d.hidden && !un;
+ const icon = hiddenLocked ? '❔' : d.icon;
+ const name = hiddenLocked ? 'Скрытое достижение' : d.name;
+ const desc = hiddenLocked ? 'Найди, чтобы открыть' : d.description;
+ html += '
' +
+ '
' + icon + (un ? '' : ' 🔒') + '
' +
+ '
' + this._esc(name) + '
' +
+ '
' + this._esc(desc) + '
' +
+ '
' + r.label + ' · ' + d.points + ' очк.
' +
+ '
';
+ }
+ html += '
';
+ panel.innerHTML = html;
+ overlay.appendChild(panel);
+ parent.appendChild(overlay);
+ panel.querySelector('#_achClose').onclick = () => this._closePage();
+ this.page = overlay;
+ }
+ _closePage() { if (this.page) { try { this.page.remove(); } catch (e) {} this.page = null; } }
+
+ _esc(s) { return String(s).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c])); }
+
+ serialize() { return this._defs.map(d => ({ ...d })); }
+ load(arr) { if (Array.isArray(arr) && arr.length) this.define(arr); }
+
+ dispose() {
+ for (const el of [this.btn, this.toastRoot, this.page]) { if (el) try { el.remove(); } catch (e) {} }
+ this.btn = null; this.page = null; this._toastQueue = []; this._toastActive = false;
+ }
+ resetRuntime() {
+ // Определения и unlocked сохраняются (достижения «навсегда»). Чистим UI.
+ this._closePage();
+ this._toastQueue = []; this._toastActive = false;
+ }
+}
diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js
index ce59924..ac0d69d 100644
--- a/src/editor/engine/BabylonScene.js
+++ b/src/editor/engine/BabylonScene.js
@@ -73,6 +73,11 @@ import { BeamManager } from './BeamManager';
import { ZombieSpawnerManager } from './ZombieSpawnerManager';
import { DynamicsManager } from './DynamicsManager';
import { Environment } from './Environment';
+import { SkyboxManager } from './SkyboxManager';
+import { LeaderstatsManager } from './LeaderstatsManager';
+import { AchievementsManager } from './AchievementsManager';
+import { FloaterManager } from './FloaterManager'; // задача 40 — damage floaters
+import { InventoryUI } from './InventoryUI'; // задача 44 — drag-drop инвентарь
import { AudioManager, AMBIENT_PRESETS, MUSIC_PRESETS } from './AudioManager';
import { GameAudioManager } from './GameAudioManager';
import { AssetManager } from './AssetManager';
@@ -187,6 +192,10 @@ export class BabylonScene {
this._gizmo = null;
this._gizmoLayer = null;
this._gizmoDragging = false; // флаг что идёт drag гизмо
+ // Free-drag: свободное перетаскивание объекта ЛКМ (как в Roblox Studio).
+ this._freeDragCandidate = null; // {mesh} — потенциальный объект для перетаскивания
+ this._freeDragActive = false; // идёт ли перетаскивание
+ this._freeDragHalf = null; // {x,y,z} полу-габариты объекта (для коллизий)
this._isDragPlacing = false; // флаг drag-постановки/удаления блоков
this._isTerrainBrushing = false; // флаг drag-кисти террейна
this._terrainDragLockY = null; // Y-фикс для скульпт/выровнять
@@ -196,6 +205,9 @@ export class BabylonScene {
// Точка спавна игрока в режиме Play (обновляется setSpawnPoint)
this._spawnPoint = { x: 0, y: 5, z: 0 };
+ // Есть ли заданная точка спавна. Если игрок её удалил (Delete) — спавн
+ // в (0, высота, 0). Можно вернуть постановкой новой точки.
+ this._spawnEnabled = true;
// Модель персонажа для режима Play.
// Дефолт — R15-скин bacon-hair (классический Roblox-вид).
// 'skin_*' грузится из characters//body.glb (R15-скелет),
@@ -1288,6 +1300,11 @@ export class BabylonScene {
}
this.dynamics = new DynamicsManager(this);
this.environment = new Environment(this.scene, this._hemiLight, this._sunLight);
+ this.skybox = new SkyboxManager(this.scene, this._hemiLight, this._sunLight); // задача 16 — кастомное небо (единый источник света)
+ this.floaters = new FloaterManager(this); // задача 40 — damage floaters
+ this.invUI = new InventoryUI(this); // задача 44 — drag-drop инвентарь
+ this.leaderstats = new LeaderstatsManager(this); // задача 20 — лидерборды
+ this.achievements = new AchievementsManager(this); // задача 20 — достижения
this.audioManager = new AudioManager();
this.assetManager = new AssetManager();
// PrimitiveManager должен уметь брать dataURL картинки по id ассета,
@@ -1358,7 +1375,12 @@ export class BabylonScene {
this._gizmo.setOnDragEnd(() => this._onGizmoDragEnd());
// Во время scale-drag — live-обновление тайлинга studs (кружки одного
// размера, не растягиваются пока тянешь гизмо).
- this._gizmo.setOnDrag((mode) => { if (mode === 'scale') this._onGizmoScaleDrag(); });
+ this._gizmo.setOnDrag((mode) => {
+ if (mode === 'scale') this._onGizmoScaleDrag();
+ // Групповая папка — применяем дельту в реальном времени (видно движение).
+ const sel = this.selection?.getSelection?.();
+ if (sel && sel.type === 'folder') this._onFolderGizmoDrag(mode);
+ });
// Привязка гизмо к выделенному
this.selection.setOnSelectionChange((sel) => this._updateGizmoForSelection(sel));
@@ -1436,6 +1458,18 @@ export class BabylonScene {
if (this._isPlaying && this.environment) {
this.environment.tick(dt);
}
+ // Небо: дрейф облаков + fadeTo — работает всегда (превью в редакторе).
+ if (this.skybox) {
+ this.skybox.tick(dt);
+ }
+ // Лидерборды (задача 20) — рендер HUD-таблицы при изменениях.
+ if (this._isPlaying && this.leaderstats) {
+ this.leaderstats.tick();
+ }
+ // Damage floaters (задача 40) — анимация всплывающих цифр.
+ if (this.floaters) {
+ this.floaters.tick(dt);
+ }
// Анимация жидкостей — работает всегда (и в редакторе)
if (this.blockManager) {
this.blockManager.tick(dt);
@@ -1683,8 +1717,10 @@ export class BabylonScene {
// "уехавшая" тень на скрине пользователя
// 2026-05-27. 0.005 — золотая середина для
// кубов 1м с прямыми гранями.
- const PCF_BIAS = 0.0005;
- const PCF_NORMAL_BIAS = 0.005;
+ const PCF_BIAS = 0.0008;
+ // normalBias повышен 0.005→0.02: убирает «полосы»-acne на полу, которые
+ // появлялись от теней соседних объектов (на пустой сцене их не было).
+ const PCF_NORMAL_BIAS = 0.02;
if (!this._shadowGenerator) {
if (wantCsm) {
@@ -1694,9 +1730,9 @@ export class BabylonScene {
const csm = new CascadedShadowGenerator(size, this._sunLight);
csm.numCascades = numCascades;
csm.stabilizeCascades = true;
- csm.lambda = 0.8;
- csm.cascadeBlendPercentage = 0.07;
- csm.shadowMaxZ = (q === 'high') ? 200 : 120;
+ csm.lambda = 0.6; // меньше — каскады равномернее, нет вытянутого дальнего
+ csm.cascadeBlendPercentage = 0.1;
+ csm.shadowMaxZ = (q === 'high') ? 90 : 60; // тени только вблизи (убирает «полосу через всю карту»)
csm.bias = PCF_BIAS;
csm.normalBias = PCF_NORMAL_BIAS;
csm.usePercentageCloserFiltering = true;
@@ -1704,7 +1740,10 @@ export class BabylonScene {
? ShadowGenerator.QUALITY_HIGH
: ShadowGenerator.QUALITY_MEDIUM;
csm.darkness = 0.4;
- csm.autoCalcDepthBounds = true;
+ // autoCalcDepthBounds растягивал дальний каскад → длинная тонкая
+ // тень-полоса персонажа на весь пол. Выключаем + фикс. дальность.
+ csm.autoCalcDepthBounds = false;
+ csm.frustumEdgeFalloff = 12;
this._shadowGenerator = csm;
} else {
// Обычный ShadowGenerator. Поднял разрешение для soft до 2048.
@@ -2290,6 +2329,15 @@ export class BabylonScene {
this._mouseDownY = e.clientY;
this._mouseDownTime = Date.now();
+ // Free-drag: ЛКМ на объекте при tool=select (и без gizmo-перетаскивания).
+ // Запоминаем объект как кандидата — реальное перетаскивание начнётся
+ // в mousemove, если курсор сдвинется (иначе это просто клик-выбор).
+ if (e.button === 0 && !e.shiftKey && this._activeTool === 'select' && !this._isPlaying) {
+ if (this._beginFreeDragCandidate()) {
+ e.preventDefault();
+ }
+ }
+
// ЛКМ + tool=block/erase → активируем drag-постановку.
// Сразу же ставим первый блок в клетке под курсором.
if (e.button === 0 && !e.shiftKey
@@ -2355,6 +2403,23 @@ export class BabylonScene {
return;
}
+ // Free-drag: тянем объект ЛКМ. Активируем после небольшого сдвига,
+ // чтобы обычный клик-выбор не превращался в перетаскивание.
+ if (this._freeDragCandidate) {
+ if (!this._freeDragActive) {
+ const ddx = Math.abs(e.clientX - this._mouseDownX);
+ const ddy = Math.abs(e.clientY - this._mouseDownY);
+ if (ddx > 4 || ddy > 4) {
+ this._freeDragActive = true;
+ canvas.style.cursor = 'grabbing';
+ }
+ }
+ if (this._freeDragActive) {
+ this._updateFreeDrag();
+ return;
+ }
+ }
+
// Если идёт drag-постановка блоков — пытаемся поставить в новой клетке
if (this._isDragPlacing) {
this._dragPlaceTick(e.shiftKey);
@@ -2396,6 +2461,18 @@ export class BabylonScene {
};
const onMouseUp = (e) => {
+ // Free-drag: завершаем перетаскивание. Если объект реально тащили —
+ // фиксируем историю и НЕ обрабатываем как клик (иначе сбросит выбор).
+ if (this._freeDragCandidate) {
+ const wasActive = this._endFreeDrag();
+ canvas.style.cursor = 'default';
+ if (wasActive) {
+ this._mouseDownButton = -1;
+ return;
+ }
+ // Не тащили (просто клик) — кандидат сброшен, продолжаем обычную
+ // обработку клика ниже (выбор уже сделан в _beginFreeDragCandidate).
+ }
// Если идёт drag гизмо — отдаём pointerup и завершаем
if (this._gizmoDragging && this._gizmoLayer?.utilityLayerScene) {
const ulScene = this._gizmoLayer.utilityLayerScene;
@@ -2496,20 +2573,40 @@ export class BabylonScene {
const onKeyDown = (e) => {
if (isTypingTarget(e.target)) return;
- this._codes.add(e.code);
+ // Клавиши с Ctrl/Cmd — это шорткаты (Ctrl+D/C/V/Z...), а не движение
+ // камеры. Не кладём их в _codes, иначе камера «уезжает» (баг Ctrl+D).
+ if (!e.ctrlKey && !e.metaKey) this._codes.add(e.code);
if (e.shiftKey) this._shiftDown = true;
// Маршрутизация game.onKey в Play-режиме
if (this._isPlaying && this.gameRuntime) {
const key = this._normalizeKey(e);
this.gameRuntime.routeGlobalEvent('keydown', { key, code: e.code });
}
+ // Задача 44: клавиша I — открыть/закрыть инвентарь (в Play, если он активен).
+ if (this._isPlaying && e.code === 'KeyI' && this.invUI &&
+ (this.invUI.defs.size > 0 || this.invUI.grid.some(Boolean) || this.invUI.hotbar.some(Boolean))) {
+ e.preventDefault(); this.invUI.toggle(); return;
+ }
+ if (this._isPlaying && e.code === 'Escape' && this.invUI?.isOpen()) {
+ e.preventDefault(); this.invUI.close(); return;
+ }
+ // Цифры 1-9 → активный hotbar-слот инвентаря (задача 44).
+ if (this._isPlaying && this.invUI && /^Digit[1-9]$/.test(e.code) &&
+ (this.invUI.hotbar.some(Boolean) || this.invUI.defs.size > 0)) {
+ this.invUI.setActiveHotbar(parseInt(e.code.slice(5), 10) - 1);
+ }
// Placement mode (задача 11): R — повернуть preview, Esc — отмена.
if (this._isPlaying && this.placementManager && this.placementManager.isActive()) {
if (e.code === 'KeyR') { e.preventDefault(); this.placementManager.rotate(); return; }
if (e.code === 'Escape') { e.preventDefault(); this.placementManager.cancel(); return; }
}
- if (e.code === 'KeyF') {
- this._focusOnTarget(new Vector3(0, 0, 0));
+ if (e.code === 'KeyF' && !this._isPlaying) {
+ // F — фокус камеры на выделенном объекте (если есть), иначе центр сцены.
+ if (this.selection?.getSelection()) {
+ this.focusOnSelection();
+ } else {
+ this._focusOnTarget(new Vector3(0, 0, 0));
+ }
}
// Ctrl+D — дублировать выделенное
if (e.code === 'KeyD' && (e.ctrlKey || e.metaKey) && !this._isPlaying) {
@@ -2801,6 +2898,21 @@ export class BabylonScene {
}
}
+ /** Есть ли заданная точка спавна (false → игрок появится в 0,высота,0). */
+ hasSpawn() { return this._spawnEnabled !== false; }
+
+ /**
+ * «Удалить» точку спавна: прячем маркер и помечаем, что спавна нет.
+ * В Play игрок появится в (0, безопасная высота, 0). Вернуть точку —
+ * через setSpawnAtCamera() (кнопка «Поставить точку спавна»).
+ */
+ deleteSpawn() {
+ this._spawnEnabled = false;
+ this._setSpawnMarkerVisible(false);
+ this.history?.markChange();
+ if (this._onSceneChange) this._onSceneChange();
+ }
+
/**
* Raycast от курсора в сцену.
* Возвращает { mesh, point, normal } либо null если ни во что не попали.
@@ -2857,6 +2969,29 @@ export class BabylonScene {
return { mesh, point: pi.pickedPoint, pickInfo: pi };
}
+ /**
+ * Точка под центром экрана на твёрдой поверхности (пол/объект) — для
+ * спавна предметов из тулбокса «в фокусе камеры на земле».
+ * Возвращает { x, y, z } (y — высота поверхности). Fallback: проекция на y=0.
+ */
+ getPlacementPointAtCenter() {
+ const hit = this._pickFromCenter();
+ if (hit && hit.point) {
+ return { x: hit.point.x, y: hit.point.y, z: hit.point.z };
+ }
+ // Нет попадания — проецируем луч из центра на плоскость y=0.
+ try {
+ const w = this.engine?.getRenderWidth?.() || this.canvas.width;
+ const h = this.engine?.getRenderHeight?.() || this.canvas.height;
+ const ray = this.scene.createPickingRay(w / 2, h / 2, null, this.scene.activeCamera);
+ if (Math.abs(ray.direction.y) > 1e-4) {
+ const t = -ray.origin.y / ray.direction.y;
+ if (t > 0) return { x: ray.origin.x + ray.direction.x * t, y: 0, z: ray.origin.z + ray.direction.z * t };
+ }
+ } catch (e) { /* ignore */ }
+ return null;
+ }
+
/**
* Извлечь target {kind, ref} из mesh (proxy/прим/модель).
* Используется при клике/touch в Play.
@@ -3671,11 +3806,92 @@ export class BabylonScene {
}
}
+ /**
+ * Создать пивот-узел в центре папки и привязать к нему gizmo. При drag
+ * gizmo двигает/вращает/масштабирует пивот, а на dragEnd дельта применяется
+ * ко всем объектам папки (FolderManager). Групповая трансформация.
+ */
+ _attachFolderGizmo(folderId, center) {
+ try {
+ if (this._folderPivot) { this._folderPivot.dispose(); this._folderPivot = null; }
+ const pivot = new TransformNode('folderPivot_' + folderId, this.scene);
+ pivot.position = new Vector3(center.x, center.y, center.z);
+ pivot.rotation = new Vector3(0, 0, 0);
+ pivot.scaling = new Vector3(1, 1, 1);
+ this._folderPivot = pivot;
+ this._folderPivotId = folderId;
+ // «Последнее применённое» состояние пивота — для инкрементальной
+ // дельты в реальном времени (_onFolderGizmoDrag).
+ this._folderPivotLast = {
+ x: center.x, y: center.y, z: center.z,
+ ry: 0, scale: 1,
+ center: { x: center.x, y: center.y, z: center.z },
+ };
+ } catch (e) { console.warn('[folderGizmo] attach failed', e); }
+ }
+
+ /**
+ * Инкрементально применить движение/поворот/масштаб пивота к объектам папки
+ * ПРЯМО ВО ВРЕМЯ drag (чтобы было видно перемещение, а не телепорт в конце).
+ */
+ _onFolderGizmoDrag(mode) {
+ const pivot = this._folderPivot;
+ const fid = this._folderPivotId;
+ const last = this._folderPivotLast;
+ if (!pivot || fid == null || !last || !this.folderManager) return;
+ if (mode === 'move') {
+ const dx = pivot.position.x - last.x;
+ const dy = pivot.position.y - last.y;
+ const dz = pivot.position.z - last.z;
+ if (dx || dy || dz) {
+ this.folderManager.moveFolderBy(fid, dx, dy, dz);
+ last.x = pivot.position.x; last.y = pivot.position.y; last.z = pivot.position.z;
+ last.center.x += dx; last.center.y += dy; last.center.z += dz;
+ }
+ } else if (mode === 'rotate') {
+ const dRy = pivot.rotation.y - last.ry;
+ if (Math.abs(dRy) > 0.0001) {
+ this.folderManager.rotateFolderY(fid, dRy, last.center);
+ last.ry = pivot.rotation.y;
+ }
+ } else if (mode === 'scale') {
+ const cur = (pivot.scaling.x + pivot.scaling.y + pivot.scaling.z) / 3;
+ const factor = last.scale !== 0 ? cur / last.scale : 1;
+ if (Math.abs(factor - 1) > 0.001) {
+ this.folderManager.scaleFolder(fid, factor, last.center);
+ last.scale = cur;
+ }
+ }
+ }
+
+ /** dragEnd: дельта уже применена в _onFolderGizmoDrag — пересоздаём пивот. */
+ _applyFolderGizmo(mode) {
+ const fid = this._folderPivotId;
+ if (fid == null || !this.folderManager) return;
+ // На всякий случай добираем остаток дельты (если drag был очень коротким).
+ this._onFolderGizmoDrag(mode);
+ // Пересоздаём пивот в новом центре (сброс для следующего drag).
+ const g = this.folderManager.getFolderObjects(fid);
+ this._attachFolderGizmo(fid, g.center);
+ const sel = this.selection?.getSelection?.();
+ if (sel && sel.type === 'folder') { sel.center = g.center; }
+ if (this._gizmo && this._folderPivot) {
+ this._gizmo.attachTo(this._folderPivot);
+ this._gizmo.refreshMode();
+ }
+ if (this._onSceneChange) this._onSceneChange();
+ }
+
/**
* Обновить гизмо под текущее выделение.
*/
_updateGizmoForSelection(sel) {
if (!this._gizmo) return;
+ // Сменилось выделение и это НЕ папка → убрать пивот папки.
+ if ((!sel || sel.type !== 'folder') && this._folderPivot) {
+ try { this._folderPivot.dispose(); } catch (e) {}
+ this._folderPivot = null; this._folderPivotId = null;
+ }
if (!sel) {
this._gizmo.attachTo(null);
return;
@@ -3687,6 +3903,9 @@ export class BabylonScene {
this._gizmo.attachTo(sel.rootMesh);
} else if (sel.type === 'primitive') {
this._gizmo.attachTo(sel.mesh);
+ } else if (sel.type === 'folder') {
+ // Групповой gizmo — привязан к пивоту папки (создан в _attachFolderGizmo).
+ if (this._folderPivot) this._gizmo.attachTo(this._folderPivot);
}
// Переустанавливаем режим чтобы sub-gizmo (move/rotate/scale)
// гарантированно пересоздалась поверх нового attached-mesh.
@@ -3724,6 +3943,10 @@ export class BabylonScene {
if (!sel) return;
const mode = this._gizmo.getMode();
+ if (sel.type === 'folder') {
+ this._applyFolderGizmo(mode);
+ return;
+ }
if (sel.type === 'block') {
if (mode === 'move') {
// Babylon-mesh.position — центр блока (gridX, gridY+0.5, gridZ)
@@ -5112,6 +5335,43 @@ export class BabylonScene {
this.selection?.deleteSelected();
}
+ /**
+ * Перенести скрипты с объекта-оригинала на копию.
+ * Находит все скрипты, привязанные к src (по kind+id или kind+ref для блока),
+ * и создаёт их дубликаты с новым id и target на dst. Без этого копия
+ * объекта оставалась без скриптов (баг 2026-06-04).
+ *
+ * srcRef/dstRef: для 'primitive'|'model' — id (число), для 'block' — {x,y,z}.
+ */
+ _copyScriptsToNewObject(kind, srcRef, dstRef) {
+ const matches = (target) => {
+ if (!target || target.kind !== kind) return false;
+ if (kind === 'block') {
+ const tr = target.ref || target;
+ return tr.x === srcRef.x && tr.y === srcRef.y && tr.z === srcRef.z;
+ }
+ const tid = target.id ?? target.ref;
+ return tid === srcRef;
+ };
+ const srcScripts = (this._scripts || []).filter(s => matches(s.target));
+ for (const s of srcScripts) {
+ const newId = `script_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
+ const newTarget = kind === 'block'
+ ? { kind: 'block', ref: { x: dstRef.x, y: dstRef.y, z: dstRef.z } }
+ : { kind, id: dstRef };
+ this._scripts.push({
+ id: newId,
+ code: s.code,
+ name: s.name || null,
+ target: newTarget,
+ });
+ }
+ if (srcScripts.length > 0) {
+ this.history?.markChange();
+ if (this._onSceneChange) this._onSceneChange();
+ }
+ }
+
/**
* Дублировать выделенный объект (Ctrl+D).
* Блок: создаёт копию в соседней клетке (по +X, или -X если +X занят, или +Y).
@@ -5130,6 +5390,8 @@ export class BabylonScene {
if (ny < 0) continue;
if (!this.blockManager.hasBlock(nx, ny, nz)) {
this.blockManager.addBlock(nx, ny, nz, sel.blockTypeId);
+ this._copyScriptsToNewObject('block',
+ { x: sel.gridX, y: sel.gridY, z: sel.gridZ }, { x: nx, y: ny, z: nz });
this.selection.selectBlockAt(nx, ny, nz);
return;
}
@@ -5140,9 +5402,14 @@ export class BabylonScene {
const typeId = sel.modelTypeId;
const sx = sel.x, sy = sel.y, sz = sel.z;
const rotY = sel.rotationY || 0;
- this.modelManager.addInstance(typeId, sx + 1, sy, sz, rotY)
+ const srcId = sel.instanceId;
+ // Дубль появляется РОВНО на месте оригинала (как в Roblox Studio).
+ this.modelManager.addInstance(typeId, sx, sy, sz, rotY)
.then(newId => {
- if (newId != null) this.selection?.selectModelByInstanceId(newId);
+ if (newId != null) {
+ this._copyScriptsToNewObject('model', srcId, newId);
+ this.selection?.selectModelByInstanceId(newId);
+ }
})
.catch(err => {
// eslint-disable-next-line no-console
@@ -5152,17 +5419,25 @@ export class BabylonScene {
const typeId = sel.userModelTypeId;
const sx = sel.x, sy = sel.y, sz = sel.z;
const rotY = sel.rotationY || 0;
- this.userModelManager.addInstance(typeId, sx + 1, sy, sz, rotY, {
+ const srcUmId = sel.instanceId;
+ this.userModelManager.addInstance(typeId, sx, sy, sz, rotY, {
currentUserId: this._currentUserId || null,
}).then(newId => {
- if (newId != null) this.selection?.selectUserModelByInstanceId(newId);
+ if (newId != null) {
+ this._copyScriptsToNewObject('userModel', srcUmId, newId);
+ this.selection?.selectUserModelByInstanceId(newId);
+ }
}).catch(err => {
console.error('[BabylonScene] duplicate user model error:', err);
});
} else if (sel.type === 'primitive') {
const newId = this.primitiveManager.addInstance(sel.primitiveType, {
- x: sel.x + 1, y: sel.y, z: sel.z,
+ x: sel.x, y: sel.y, z: sel.z,
sx: sel.sx, sy: sel.sy, sz: sel.sz,
+ // Сохраняем вращение копии (без этого сбрасывалось, баг 2026-06-04).
+ rotationX: sel.rotationX || 0,
+ rotationY: sel.rotationY || 0,
+ rotationZ: sel.rotationZ || 0,
color: sel.color, material: sel.material,
canCollide: sel.canCollide, visible: sel.visible,
anchored: sel.anchored,
@@ -5170,7 +5445,10 @@ export class BabylonScene {
textureAsset: sel.textureAsset || null,
brightness: sel.brightness, range: sel.range, effect: sel.effect,
});
- if (newId != null) this.selection.selectPrimitiveById(newId);
+ if (newId != null) {
+ this._copyScriptsToNewObject('primitive', sel.id, newId);
+ this.selection.selectPrimitiveById(newId);
+ }
}
}
@@ -5199,6 +5477,9 @@ export class BabylonScene {
clip = {
kind: 'primitive', primitiveType: sel.primitiveType,
sx: sel.sx, sy: sel.sy, sz: sel.sz,
+ rotationX: sel.rotationX || 0,
+ rotationY: sel.rotationY || 0,
+ rotationZ: sel.rotationZ || 0,
color: sel.color, material: sel.material,
canCollide: sel.canCollide, visible: sel.visible,
anchored: sel.anchored,
@@ -5207,11 +5488,45 @@ export class BabylonScene {
};
}
if (clip) {
+ // Прикрепляем скрипты объекта в буфер — чтобы вставленная копия
+ // получила те же скрипты (баг 2026-06-04: копия была без скриптов).
+ try {
+ let srcRef = null, kind = sel.type;
+ if (sel.type === 'block') srcRef = { x: sel.gridX, y: sel.gridY, z: sel.gridZ };
+ else if (sel.type === 'model' || sel.type === 'userModel') srcRef = sel.instanceId;
+ else if (sel.type === 'primitive') srcRef = sel.id;
+ const matchTarget = (target) => {
+ if (!target || target.kind !== kind) return false;
+ if (kind === 'block') {
+ const tr = target.ref || target;
+ return tr.x === srcRef.x && tr.y === srcRef.y && tr.z === srcRef.z;
+ }
+ const tid = target.id ?? target.ref;
+ return tid === srcRef;
+ };
+ clip.scripts = (this._scripts || [])
+ .filter(s => matchTarget(s.target))
+ .map(s => ({ code: s.code, name: s.name || null }));
+ } catch (e) { clip.scripts = []; }
try { localStorage.setItem('kubikon_clipboard', JSON.stringify(clip)); }
catch (e) { /* ignore — приватный режим / переполнение */ }
}
}
+ /** Создать скрипты из clip.scripts на новом объекте (kind+ref). */
+ _pasteScripts(clip, kind, dstRef) {
+ if (!clip || !Array.isArray(clip.scripts) || clip.scripts.length === 0) return;
+ for (const s of clip.scripts) {
+ const newId = `script_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
+ const target = kind === 'block'
+ ? { kind: 'block', ref: { x: dstRef.x, y: dstRef.y, z: dstRef.z } }
+ : { kind, id: dstRef };
+ this._scripts.push({ id: newId, code: s.code, name: s.name || null, target });
+ }
+ this.history?.markChange();
+ if (this._onSceneChange) this._onSceneChange();
+ }
+
/**
* Вставить объект из буфера обмена (Ctrl+V, Фаза 5.10).
* Объект появляется у точки, куда смотрит редактор-камера.
@@ -5239,28 +5554,32 @@ export class BabylonScene {
let gy = 0;
while (gy < 64 && this.blockManager?.hasBlock(gx, gy, gz)) gy++;
this.blockManager?.addBlock(gx, gy, gz, clip.blockTypeId);
+ this._pasteScripts(clip, 'block', { x: gx, y: gy, z: gz });
this.selection?.selectBlockAt(gx, gy, gz);
} else if (clip.kind === 'model') {
this.modelManager?.addInstance(clip.modelTypeId, px, py, pz, clip.rotationY || 0)
- .then(id => { if (id != null) this.selection?.selectModelByInstanceId(id); })
+ .then(id => { if (id != null) { this._pasteScripts(clip, 'model', id); this.selection?.selectModelByInstanceId(id); } })
.catch(() => {});
} else if (clip.kind === 'userModel') {
this.userModelManager?.addInstance(
clip.userModelTypeId, px, py, pz, clip.rotationY || 0,
{ currentUserId: this._currentUserId || null },
- ).then(id => { if (id != null) this.selection?.selectUserModelByInstanceId(id); })
+ ).then(id => { if (id != null) { this._pasteScripts(clip, 'userModel', id); this.selection?.selectUserModelByInstanceId(id); } })
.catch(() => {});
} else if (clip.kind === 'primitive') {
const id = this.primitiveManager?.addInstance(clip.primitiveType, {
x: px, y: Math.max(py, (clip.sy || 1) / 2), z: pz,
sx: clip.sx, sy: clip.sy, sz: clip.sz,
+ rotationX: clip.rotationX || 0,
+ rotationY: clip.rotationY || 0,
+ rotationZ: clip.rotationZ || 0,
color: clip.color, material: clip.material,
canCollide: clip.canCollide, visible: clip.visible,
anchored: clip.anchored,
textureAsset: clip.textureAsset || null,
brightness: clip.brightness, range: clip.range, effect: clip.effect,
});
- if (id != null) this.selection?.selectPrimitiveById(id);
+ if (id != null) { this._pasteScripts(clip, 'primitive', id); this.selection?.selectPrimitiveById(id); }
}
if (this._onSceneChange) this._onSceneChange();
}
@@ -5317,12 +5636,218 @@ export class BabylonScene {
y: Math.max(0, Math.floor(p.y) - 1),
z: Math.round(p.z),
};
+ this._spawnEnabled = true; // вернуть точку, если была удалена
+ this._setSpawnMarkerVisible(true);
this._updateSpawnMarker();
this.history?.markChange();
if (this._onSceneChange) this._onSceneChange();
}
/** Изменить позицию выделенного (используется Inspector). */
+ // ====================== FREE-DRAG (как в Roblox Studio) ======================
+ // Зажал ЛКМ на объекте и тянешь — объект скользит по полу/поверх других
+ // объектов с учётом коллизий (скольжение вдоль преграды).
+
+ /** Получить корневой меш/узел выделенного объекта (для drag). */
+ _getSelectionRoot(sel) {
+ if (!sel) return null;
+ return sel.rootMesh || sel.mesh || sel.rootNode || null;
+ }
+
+ /** Полу-габариты (half-extent) AABB меша в мировых координатах. */
+ _meshHalfExtent(mesh) {
+ try {
+ const bb = mesh.getHierarchyBoundingVectors
+ ? mesh.getHierarchyBoundingVectors(true)
+ : null;
+ if (bb) {
+ return {
+ x: Math.max(0.05, (bb.max.x - bb.min.x) / 2),
+ y: Math.max(0.05, (bb.max.y - bb.min.y) / 2),
+ z: Math.max(0.05, (bb.max.z - bb.min.z) / 2),
+ };
+ }
+ } catch (e) { /* ignore */ }
+ return { x: 0.5, y: 0.5, z: 0.5 };
+ }
+
+ /**
+ * Собрать AABB всех «препятствий» сцены, КРОМЕ перетаскиваемого объекта,
+ * пола и сетки. Возвращает массив {minX,maxX,minY,maxY,minZ,maxZ}.
+ */
+ _collectObstacleAABBs(excludeRoot) {
+ const out = [];
+ const push = (mesh) => {
+ if (!mesh || mesh === excludeRoot) return;
+ try {
+ const bb = mesh.getHierarchyBoundingVectors(true);
+ out.push({
+ minX: bb.min.x, maxX: bb.max.x,
+ minY: bb.min.y, maxY: bb.max.y,
+ minZ: bb.min.z, maxZ: bb.max.z,
+ });
+ } catch (e) { /* ignore */ }
+ };
+ // Примитивы
+ if (this.primitiveManager?.instances) {
+ for (const d of this.primitiveManager.instances.values()) {
+ if (d.mesh && d.mesh !== excludeRoot) push(d.mesh);
+ }
+ }
+ // GLB-модели
+ if (this.modelManager?.instances) {
+ for (const d of this.modelManager.instances.values()) {
+ if (d.rootMesh && d.rootMesh !== excludeRoot) push(d.rootMesh);
+ }
+ }
+ // Пользовательские (воксельные) модели
+ if (this.userModelManager?.instances) {
+ for (const d of this.userModelManager.instances.values()) {
+ const root = d.rootNode || d.rootMesh;
+ if (root && root !== excludeRoot) push(root);
+ }
+ }
+ return out;
+ }
+
+ /** Пересекается ли AABB кандидата (центр cx,cy,cz + half) с препятствиями. */
+ _aabbCollides(cx, cy, cz, half, obstacles) {
+ const eps = 0.02; // небольшой зазор, чтобы не «прилипало»
+ const minX = cx - half.x + eps, maxX = cx + half.x - eps;
+ const minY = cy - half.y + eps, maxY = cy + half.y - eps;
+ const minZ = cz - half.z + eps, maxZ = cz + half.z - eps;
+ for (const o of obstacles) {
+ if (maxX > o.minX && minX < o.maxX
+ && maxY > o.minY && minY < o.maxY
+ && maxZ > o.minZ && minZ < o.maxZ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /** Начать потенциальный free-drag: запомнить объект под курсором. */
+ _beginFreeDragCandidate() {
+ if (this._isPlaying || this._activeTool !== 'select') return false;
+ const pick = this._pickFromMouse();
+ if (!pick || !pick.mesh) return false;
+ // Не тащим пол/сетку/гизмо.
+ const m = pick.mesh;
+ if (m.name && (m.name.startsWith('gridLine') || m.name === 'ground')) return false;
+ if (m.metadata?._isBlockProto) return false; // блоки тащим только гизмо
+ // Выбираем объект (резолв mesh→тип внутри selection).
+ this.selection?.selectByMesh(m);
+ const sel = this.selection?.getSelection();
+ if (!sel || sel.type === 'block' || sel.type === 'spawn') return false;
+ // Папка — тащим всю группу через folderManager (по дельте центра).
+ if (sel.type === 'folder') {
+ const g = this.folderManager?.getFolderObjects(sel.folderId);
+ this._freeDragCandidate = { folder: true, folderId: sel.folderId, last: { ...(g?.center || { x: 0, y: 0, z: 0 }) } };
+ this._freeDragHalf = { x: 0.5, y: 0.5, z: 0.5 };
+ this._freeDragActive = false;
+ return true;
+ }
+ const root = this._getSelectionRoot(sel);
+ if (!root) return false;
+ this._freeDragCandidate = { root };
+ this._freeDragHalf = this._meshHalfExtent(root);
+ this._freeDragActive = false;
+ return true;
+ }
+
+ /** Обновить позицию объекта при перетаскивании (raycast по сцене + коллизия). */
+ _updateFreeDrag() {
+ const cand = this._freeDragCandidate;
+ if (!cand) return;
+ const sel = this.selection?.getSelection();
+ if (!sel) return;
+
+ // Папка: тащим всю группу по дельте от последнего центра (проекция курсора
+ // на горизонтальную плоскость по высоте центра группы).
+ if (cand.folder) {
+ const ray = this.scene.createPickingRay(this.scene.pointerX, this.scene.pointerY, null, this.scene.activeCamera);
+ if (Math.abs(ray.direction.y) < 1e-4) return;
+ const t = (cand.last.y - ray.origin.y) / ray.direction.y;
+ if (t < 0) return;
+ const px = ray.origin.x + ray.direction.x * t;
+ const pz = ray.origin.z + ray.direction.z * t;
+ const dx = px - cand.last.x, dz = pz - cand.last.z;
+ if (dx || dz) {
+ this.folderManager?.moveFolderBy(cand.folderId, dx, 0, dz);
+ cand.last.x = px; cand.last.z = pz;
+ }
+ return;
+ }
+
+ const root = cand.root;
+ const half = this._freeDragHalf || { x: 0.5, y: 0.5, z: 0.5 };
+
+ // Raycast из курсора: ищем поверхность (пол ИЛИ верх другого объекта),
+ // ИСКЛЮЧАЯ сам перетаскиваемый объект и его дочерние меши.
+ const isOwn = (mesh) => {
+ let n = mesh;
+ while (n) { if (n === root) return true; n = n.parent; }
+ return false;
+ };
+ const pi = this.scene.pick(this.scene.pointerX, this.scene.pointerY, (mesh) => {
+ if (!mesh.isPickable) return false;
+ if (isOwn(mesh)) return false;
+ if (mesh === this._ghostMesh) return false;
+ if (mesh.name && mesh.name.startsWith('gridLine')) return false;
+ if (mesh.metadata?._isBlockProto) return false;
+ return true;
+ });
+
+ let px, pz, surfaceY;
+ if (pi && pi.hit && pi.pickedPoint) {
+ px = pi.pickedPoint.x;
+ pz = pi.pickedPoint.z;
+ surfaceY = pi.pickedPoint.y; // верх пола или поверхности объекта
+ } else {
+ // Нет попадания — проецируем луч на горизонтальную плоскость y=0.
+ const ray = this.scene.createPickingRay(this.scene.pointerX, this.scene.pointerY, null, this.scene.activeCamera);
+ if (Math.abs(ray.direction.y) < 1e-4) return;
+ const t = -ray.origin.y / ray.direction.y;
+ if (t < 0) return;
+ px = ray.origin.x + ray.direction.x * t;
+ pz = ray.origin.z + ray.direction.z * t;
+ surfaceY = 0;
+ }
+
+ // Целевой центр: объект стоит НА поверхности (низ касается surfaceY).
+ const targetX = px, targetZ = pz;
+ const targetY = surfaceY + half.y;
+
+ // Коллизия скольжением: двигаем по осям отдельно от текущей позиции.
+ const obstacles = this._collectObstacleAABBs(root);
+ const cur = { x: root.position.x, y: targetY, z: root.position.z };
+ let nx = cur.x, nz = cur.z;
+ // X
+ if (!this._aabbCollides(targetX, targetY, cur.z, half, obstacles)) nx = targetX;
+ // Z (с уже применённым X)
+ if (!this._aabbCollides(nx, targetY, targetZ, half, obstacles)) nz = targetZ;
+
+ this.moveSelectedTo(nx, targetY, nz);
+ }
+
+ /** Завершить free-drag, зафиксировать изменение в истории. */
+ _endFreeDrag() {
+ const wasActive = this._freeDragActive;
+ this._freeDragCandidate = null;
+ this._freeDragActive = false;
+ this._freeDragHalf = null;
+ if (wasActive) {
+ this.history?.markChange();
+ if (this._onSceneChange) this._onSceneChange();
+ }
+ return wasActive;
+ }
+
+ // ── Небо (задача 16) — обёртки для game-API и UI редактора ──────────────
+ setSkybox(opts) { this.skybox?.setSkybox(opts); if (this._onSceneChange) this._onSceneChange(); }
+ setClouds(opts) { this.skybox?.setClouds(opts); if (this._onSceneChange) this._onSceneChange(); }
+ setSkyFog(opts) { this.skybox?.setFog(opts); if (this._onSceneChange) this._onSceneChange(); }
+
moveSelectedTo(x, y, z) {
if (!this.selection) return;
const sel = this.selection.getSelection();
@@ -5400,9 +5925,14 @@ export class BabylonScene {
let target;
if (sel.type === 'block') {
target = new Vector3(sel.gridX, sel.gridY + 0.5, sel.gridZ);
- } else if (sel.type === 'model' || sel.type === 'spawn' || sel.type === 'primitive') {
+ } else if (sel.type === 'model' || sel.type === 'spawn' || sel.type === 'primitive' || sel.type === 'userModel') {
target = new Vector3(sel.x, sel.y + 0.5, sel.z);
}
+ // Запасной путь: взять позицию из меша выделения (userModel/модель без x,y,z).
+ if (!target) {
+ const root = this._getSelectionRoot?.(sel);
+ if (root?.position) target = new Vector3(root.position.x, root.position.y + 0.5, root.position.z);
+ }
if (target) this._focusOnTarget(target);
}
@@ -5412,7 +5942,7 @@ export class BabylonScene {
this._updateSpawnMarker();
}
- /** Задача 12: конфиг экрана загрузки из настроек проекта (логотип/акцент/дефолты). */
+ /** Задача 12+05: конфиг экрана загрузки из настроек проекта. */
setLoadingConfig(cfg, thumbnail) {
if (cfg && typeof cfg === 'object') {
this._loadingConfig = {
@@ -5420,6 +5950,16 @@ export class BabylonScene {
accentColor: cfg.accentColor || '#ffc020',
defaultSpinner: cfg.defaultSpinner !== false,
defaultSkipButton: !!cfg.defaultSkipButton,
+ // --- Задача 05: стартовый экран при входе в Play ---
+ enabled: cfg.enabled !== false, // показывать ли стартовый экран
+ background: cfg.background || cfg.backgroundUrl || null,
+ cover: cfg.cover || cfg.coverUrl || null,
+ style: cfg.style || 'ken-burns',
+ placeName: cfg.placeName || '',
+ studioName: cfg.studioName || '',
+ verified: !!cfg.verified,
+ duration: Number.isFinite(cfg.duration) && cfg.duration > 0 ? Number(cfg.duration) : 2.5,
+ progressBar: cfg.progressBar !== false,
};
} else {
this._loadingConfig = null;
@@ -5427,6 +5967,34 @@ export class BabylonScene {
if (thumbnail !== undefined) this._projectThumbnail = thumbnail || null;
}
+ /**
+ * Задача 05: показать СТАРТОВЫЙ экран загрузки при входе в Play.
+ * Зовётся из enterPlayMode; держится минимум `duration` сек либо до готовности сцены.
+ */
+ showStartupLoadingScreen() {
+ const cfg = this._loadingConfig;
+ if (!cfg || cfg.enabled === false) return;
+ if (!this.gameRuntime) return;
+ try {
+ const ls = this.gameRuntime._ensureLoadingScreen?.();
+ if (!ls) return;
+ ls.show({
+ style: cfg.style,
+ background: cfg.background || cfg.cover || this._projectThumbnail,
+ cover: cfg.cover || this._projectThumbnail,
+ placeName: cfg.placeName || this._projectName || '',
+ studioName: cfg.studioName || '',
+ verified: cfg.verified,
+ duration: cfg.duration,
+ progressBar: cfg.progressBar,
+ spinner: true,
+ bgColor: '#070a14',
+ pauseSimulation: false, // стартовый — сцена грузится в фоне
+ blockInput: true,
+ });
+ } catch (e) { /* ignore */ }
+ }
+
/** Установить тип модели персонажа (для Play). */
setPlayerModelType(typeId) {
if (!typeId) return;
@@ -5448,6 +6016,13 @@ export class BabylonScene {
*/
enterPlayMode() {
if (this._isPlaying) return;
+ // Снять любое выделение редактора (объект/папка) перед запуском игры —
+ // иначе в Play остаётся подсветка/гизмо выбранного объекта.
+ try {
+ if (this._folderPivot) { this._folderPivot.dispose(); this._folderPivot = null; this._folderPivotId = null; }
+ this._gizmo?.attachTo(null);
+ this.selection?.clear?.();
+ } catch (e) { /* ignore */ }
this._isPlaying = true;
// Сброс состояния касаний — каждый прогон начинается «не касаясь»,
// иначе rising-edge touch не сработает, если при стопе игрок стоял на цели.
@@ -5520,13 +6095,26 @@ export class BabylonScene {
});
if (this._onPlayerHpChange) this.player.setOnHpChange(this._onPlayerHpChange);
if (this._onPlayerDeath) this.player.setOnDeath(this._onPlayerDeath);
- this.player.start(this._spawnPoint);
+ // Если точка спавна удалена — игрок появляется в (0, безопасная высота, 0).
+ let startPoint = this._spawnPoint;
+ if (this._spawnEnabled === false) {
+ let sy = 3;
+ try {
+ const surf = this.physics?._sampleRobloxSurface?.(0, 0);
+ if (surf !== null && surf !== undefined) sy = surf + 2;
+ } catch (e) { /* ignore */ }
+ startPoint = { x: 0, y: sy, z: 0 };
+ }
+ this.player.start(startPoint);
// Запускаем пользовательские скрипты (этап 2.1).
// Async PlayerController.start даёт нам тик чтобы дать ему собрать сцену,
// поэтому скрипты стартуем в следующем кадре.
this.gameRuntime = new GameRuntime(this);
try { this.modalManager?.attachRuntime?.(this.gameRuntime); } catch (e) {}
+ // Задача 05: стартовый экран загрузки (Ken-Burns + название места),
+ // если настроен в проекте. Показываем поверх старта сцены.
+ try { this.showStartupLoadingScreen(); } catch (e) {}
// Аудио-менеджер (GD-музыка/SFX) — создаётся вместе с runtime.
// ВАЖНО: поле называется gameAudioManager чтобы не путать со штатным
// this.audioManager (AudioManager — ambient/music для всех проектов).
@@ -5670,9 +6258,29 @@ export class BabylonScene {
if (this._onScriptCrosshair) this.gameRuntime.setOnCrosshairChange(this._onScriptCrosshair);
// eslint-disable-next-line no-console
console.log('[BabylonScene] enterPlayMode — scripts to run:', this._scripts?.length || 0, this._scripts);
+ // Перед стартом чистим скрипты-сироты (их объект-носитель удалён) —
+ // иначе on-target скрипт удалённого объекта продолжал исполняться. Баг 2026-06-05.
+ this._cleanupOrphanScripts?.();
+ // Задача 20: смонтировать HUD лидербордов/достижений если определения уже
+ // загружены из проекта (define из project_data при load).
+ try { if (this.leaderstats?.active) this.leaderstats._mount(); } catch (e) {}
+ try { if (this.achievements?.active) this.achievements._mountButton(); } catch (e) {}
+ // Задача 44: хотбар инвентаря показываем если есть определения/предметы.
+ try {
+ if (this.invUI && (this.invUI.defs.size > 0 || this.invUI.hotbar.some(Boolean) || this.invUI.grid.some(Boolean))) {
+ this.invUI.mountHotbar();
+ }
+ } catch (e) {}
// Старт через requestAnimationFrame — даём Babylon собрать сцену
requestAnimationFrame(() => {
if (this._isPlaying) this.gameRuntime?.start(this._scripts || []);
+ // Задача 20: подгрузить сохранённый прогресс игрока из БД ПОСЛЕ того,
+ // как скрипты вызвали define() (даём им 200мс на регистрацию статов).
+ setTimeout(() => {
+ if (!this._isPlaying) return;
+ try { this.achievements?.loadFromDB?.(); } catch (e) {}
+ try { this.leaderstats?.loadFromDB?.(); } catch (e) {}
+ }, 250);
});
// === Оружие ===
@@ -5683,6 +6291,10 @@ export class BabylonScene {
if (hit?.mesh && this.zombieManager) {
this.zombieManager.damageByMesh(hit.mesh, hit.damage || 25);
}
+ // Урон скриптовым NPC (киты-враги) → авто-floater над мобом (задача 40).
+ if (hit?.mesh && this.npcManager) {
+ try { this.npcManager.damageByMesh(hit.mesh, hit.damage || 25); } catch (e) {}
+ }
if (this._onWeaponHit) {
try { this._onWeaponHit(hit); } catch (e) {}
}
@@ -6061,19 +6673,21 @@ export class BabylonScene {
}
/** Установить код одного скрипта по id. Если id нет — создать новый. */
- upsertScript(id, code, target = undefined) {
+ upsertScript(id, code, target = undefined, name = undefined) {
const i = this._scripts.findIndex(s => s.id === id);
if (i >= 0) {
this._scripts[i] = {
...this._scripts[i],
code,
...(target !== undefined ? { target } : {}),
+ ...(name !== undefined ? { name } : {}),
};
} else {
this._scripts.push({
id: id || `script_${Date.now()}`,
code,
target: target !== undefined ? target : null,
+ name: name || null,
});
}
// Скрипты — часть сцены: фиксируем в истории, иначе undo откатит
@@ -6089,6 +6703,25 @@ export class BabylonScene {
if (this._onSceneChange) this._onSceneChange();
}
+ /** Удалить скрипты, чей объект-носитель больше не существует (после удаления
+ * папки/объектов). Глобальные (target null/'game') не трогаем. */
+ _cleanupOrphanScripts() {
+ if (!Array.isArray(this._scripts)) return;
+ const exists = (t) => {
+ if (!t || t === 'game') return true;
+ if (t.kind === 'primitive') return !!this.primitiveManager?.instances?.has(t.id);
+ if (t.kind === 'model') return !!this.modelManager?.instances?.has(t.id);
+ if (t.kind === 'userModel') return !!this.userModelManager?.instances?.has(t.id);
+ return true; // block и пр. — не чистим
+ };
+ const before = this._scripts.length;
+ this._scripts = this._scripts.filter(s => exists(s.target));
+ if (this._scripts.length !== before) {
+ this.history?.markChange();
+ if (this._onSceneChange) this._onSceneChange();
+ }
+ }
+
/**
* Зарегистрировать колбэк для уведомлений об изменении режима Play
* (вызывается когда player сам инициирует exit, например по Esc).
@@ -7043,7 +7676,9 @@ export class BabylonScene {
folders: this.folderManager ? this.folderManager.serialize() : [],
gui: this.guiManager ? this.guiManager.serialize() : [],
inventory: this.inventory ? this.inventory.serialize() : null,
+ inventory2: this.invUI ? this.invUI.serialize() : null, // задача 44
spawnPoint: { ...this._spawnPoint },
+ spawnEnabled: this._spawnEnabled !== false,
playerModelType: this._playerModelType,
skins: this._skinsConfig ? {
default: this._skinsConfig.default || null,
@@ -7052,12 +7687,22 @@ export class BabylonScene {
coins: this._skinsConfig.coins || 0,
customGlbs: this._skinsConfig.customGlbs || [],
} : undefined,
- // Задача 12: конфиг экрана загрузки (логотип/акцент/дефолты).
+ // Задача 12+05: конфиг экрана загрузки (логотип/акцент/дефолты + стартовый Ken-Burns).
loadingScreen: this._loadingConfig ? {
logo: this._loadingConfig.logo || null,
accentColor: this._loadingConfig.accentColor || '#ffc020',
defaultSpinner: this._loadingConfig.defaultSpinner !== false,
defaultSkipButton: !!this._loadingConfig.defaultSkipButton,
+ // Задача 05:
+ enabled: this._loadingConfig.enabled !== false,
+ background: this._loadingConfig.background || null,
+ cover: this._loadingConfig.cover || null,
+ style: this._loadingConfig.style || 'ken-burns',
+ placeName: this._loadingConfig.placeName || '',
+ studioName: this._loadingConfig.studioName || '',
+ verified: !!this._loadingConfig.verified,
+ duration: this._loadingConfig.duration || 2.5,
+ progressBar: this._loadingConfig.progressBar !== false,
} : undefined,
// Задача 13: конфиг главного меню (passthrough — меню задаётся скриптом).
mainMenu: this._mainMenuConfig || undefined,
@@ -7068,6 +7713,9 @@ export class BabylonScene {
crosshair: this._crosshair || 'dot',
shadowQuality: this._shadowQuality || 'soft',
environment: this.environment ? this.environment.serialize() : null,
+ skybox: this.skybox ? this.skybox.serialize() : null,
+ leaderstats: this.leaderstats ? this.leaderstats.serialize() : null,
+ achievements: this.achievements ? this.achievements.serialize() : null,
audio: this.audioManager ? this.audioManager.serialize() : null,
// Библиотека пользовательских картинок (текстуры/GUI-image).
assets: this.assetManager ? this.assetManager.serialize() : [],
@@ -7420,6 +8068,10 @@ export class BabylonScene {
if (this.inventory) {
this.inventory.loadFromArray(state.scene.inventory || null);
}
+ // Задача 44: drag-drop инвентарь (определения предметов + слоты).
+ if (this.invUI && state.scene.inventory2) {
+ this.invUI.load(state.scene.inventory2);
+ }
// Простановка folderId на блоках (loadFromArray BlockManager не знает про это поле)
if (this.blockManager && Array.isArray(state.scene.blocks)) {
for (const b of state.scene.blocks) {
@@ -7465,6 +8117,9 @@ export class BabylonScene {
this._spawnPoint = { ...state.scene.spawnPoint };
this._updateSpawnMarker();
}
+ // Удалена ли точка спавна (спавн в 0,0 при отсутствии).
+ this._spawnEnabled = state.scene.spawnEnabled !== false;
+ this._setSpawnMarkerVisible(this._spawnEnabled);
// === Авто-fix спавна для smooth terrain ===
// Если есть RobloxTerrain и spawnPoint оказался НИЖЕ поверхности —
// поднимаем его, чтобы игрок не провалился в момент нажатия "Запустить".
@@ -7516,15 +8171,9 @@ export class BabylonScene {
} else {
this._skinsConfig = null;
}
- // Задача 12: конфиг экрана загрузки.
+ // Задача 12+05: конфиг экрана загрузки (через setLoadingConfig — единый маппинг).
if (state.scene.loadingScreen && typeof state.scene.loadingScreen === 'object') {
- const ls = state.scene.loadingScreen;
- this._loadingConfig = {
- logo: ls.logo || null,
- accentColor: ls.accentColor || '#ffc020',
- defaultSpinner: ls.defaultSpinner !== false,
- defaultSkipButton: !!ls.defaultSkipButton,
- };
+ this.setLoadingConfig(state.scene.loadingScreen);
} else {
this._loadingConfig = null;
}
@@ -7546,6 +8195,17 @@ export class BabylonScene {
if (state.scene.environment && this.environment) {
this.environment.load(state.scene.environment);
}
+ // Кастомное небо (задача 16)
+ if (state.scene.skybox && this.skybox) {
+ this.skybox.load(state.scene.skybox);
+ }
+ // Лидерборды и достижения (задача 20) — определения из проекта.
+ if (state.scene.leaderstats && this.leaderstats) {
+ this.leaderstats.load(state.scene.leaderstats);
+ }
+ if (state.scene.achievements && this.achievements) {
+ this.achievements.load(state.scene.achievements);
+ }
// Аудио (фоновая музыка/амбиент)
if (state.scene.audio && this.audioManager) {
this.audioManager.load(state.scene.audio);
@@ -7613,6 +8273,11 @@ export class BabylonScene {
this._isPlaying = false;
// Задача 04: закрываем любой активный модал чтобы не «висел» при стопе
try { this.modalManager?._instantClose?.(); } catch (e) {}
+ // Задача 20: чистим рантайм лидербордов/достижений (определения остаются).
+ try { this.leaderstats?.resetRuntime?.(); } catch (e) {}
+ try { this.achievements?.resetRuntime?.(); } catch (e) {}
+ try { this.floaters?.resetRuntime?.(); } catch (e) {} // задача 40
+ try { this.invUI?.resetRuntime?.(); } catch (e) {} // задача 44
// Сбрасываем таймер прохождения
this._timerRunning = false;
this._timerStartedAt = null;
diff --git a/src/editor/engine/CollabOverlay.js b/src/editor/engine/CollabOverlay.js
new file mode 100644
index 0000000..d665382
--- /dev/null
+++ b/src/editor/engine/CollabOverlay.js
@@ -0,0 +1,180 @@
+/**
+ * CollabOverlay — DOM-слой совместного редактирования (Team Create).
+ *
+ * Рисует поверх canvas студии:
+ * - панель «онлайн» (аватарки-кружки соавторов с цветом и ником);
+ * - 3D-курсоры соавторов (точка с ником, спроецированная из мировых
+ * координат в экранные);
+ * - подсветку чужого выделения (по selectedKey — обводим объект);
+ * - тосты («объект занят другим», «N присоединился»).
+ *
+ * Самодостаточный (как LoadingScreenOverlay): монтируется на parent canvas,
+ * UI обновляется методами update(...). Кнопка «Пригласить» — снаружи (TopRibbon),
+ * здесь только presence + курсоры.
+ */
+import { Vector3, Matrix } from '@babylonjs/core';
+
+let _cssInjected = false;
+function injectCss() {
+ if (_cssInjected || typeof document === 'undefined') return;
+ _cssInjected = true;
+ const s = document.createElement('style');
+ s.id = 'kbn-collab-css';
+ s.textContent =
+ '.kbnCollabCursor{position:absolute;transform:translate(-50%,-50%);pointer-events:none;z-index:62;transition:left 0.08s linear,top 0.08s linear;will-change:left,top}' +
+ '.kbnCollabDot{width:14px;height:14px;border-radius:50%;border:2px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.5)}' +
+ '.kbnCollabName{position:absolute;left:16px;top:-2px;white-space:nowrap;font:600 11px system-ui;color:#fff;padding:1px 6px;border-radius:6px;text-shadow:0 1px 2px rgba(0,0,0,.6)}' +
+ '.kbnCollabBar{position:absolute;top:10px;left:50%;transform:translateX(-50%);z-index:63;display:flex;gap:6px;align-items:center;background:rgba(20,24,38,.78);padding:5px 10px;border-radius:20px;backdrop-filter:blur(6px);font:600 12px system-ui;color:#cdd6e6;pointer-events:auto}' +
+ '.kbnCollabAva{width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font:700 12px system-ui;color:#10131c;border:2px solid rgba(255,255,255,.25)}' +
+ '.kbnCollabToast{position:absolute;bottom:80px;left:50%;transform:translateX(-50%);z-index:64;background:rgba(20,24,38,.92);color:#fff;padding:10px 18px;border-radius:10px;font:600 14px system-ui;box-shadow:0 8px 24px rgba(0,0,0,.5);opacity:0;transition:opacity .25s}';
+ document.head.appendChild(s);
+}
+
+export class CollabOverlay {
+ constructor(scene) {
+ this.scene = scene;
+ this.root = null;
+ this.barEl = null;
+ this.cursorsEl = null;
+ this.toastEl = null;
+ this._cursors = new Map(); // sessionId → {wrap, dot, name}
+ this._presence = [];
+ this._raf = null;
+ }
+
+ mount() {
+ injectCss();
+ const parent = (this.scene.canvas && this.scene.canvas.parentElement) || document.body;
+ try { if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative'; } catch (e) { /* ignore */ }
+
+ const root = document.createElement('div');
+ root.className = 'kbn-collab-root';
+ root.style.cssText = 'position:absolute;inset:0;pointer-events:none;z-index:60;overflow:hidden';
+
+ const bar = document.createElement('div');
+ bar.className = 'kbnCollabBar';
+ bar.innerHTML = '👥 Соавторы: ';
+
+ const cursors = document.createElement('div');
+ cursors.style.cssText = 'position:absolute;inset:0';
+
+ const toast = document.createElement('div');
+ toast.className = 'kbnCollabToast';
+
+ root.appendChild(bar);
+ root.appendChild(cursors);
+ root.appendChild(toast);
+ parent.appendChild(root);
+
+ this.root = root; this.barEl = bar; this.cursorsEl = cursors; this.toastEl = toast;
+ this._startLoop();
+ }
+
+ /** Обновить список соавторов (из StudioCollab.onPresenceChange). */
+ updatePresence(list) {
+ this._presence = list || [];
+ if (!this.barEl) return;
+ // Перерисовать аватарки.
+ const others = this._presence;
+ let html = '👥 ';
+ for (const c of others) {
+ const initial = (c.username || '?').trim().charAt(0).toUpperCase() || '?';
+ const ring = c.me ? 'box-shadow:0 0 0 2px #fff' : '';
+ html += `${escapeHtml(initial)}
`;
+ }
+ html += `${others.length} `;
+ this.barEl.innerHTML = html;
+ // Курсоры пересоберём в loop.
+ this._syncCursorEls();
+ }
+
+ _syncCursorEls() {
+ const present = new Set(this._presence.filter(c => !c.me).map(c => c.sessionId));
+ // удалить ушедших
+ for (const [sid, el] of this._cursors) {
+ if (!present.has(sid)) { try { el.wrap.remove(); } catch (e) { /* ignore */ } this._cursors.delete(sid); }
+ }
+ // добавить новых
+ for (const c of this._presence) {
+ if (c.me) continue;
+ if (!this._cursors.has(c.sessionId)) {
+ const wrap = document.createElement('div');
+ wrap.className = 'kbnCollabCursor';
+ const dot = document.createElement('div');
+ dot.className = 'kbnCollabDot';
+ const name = document.createElement('div');
+ name.className = 'kbnCollabName';
+ wrap.appendChild(dot); wrap.appendChild(name);
+ this.cursorsEl.appendChild(wrap);
+ this._cursors.set(c.sessionId, { wrap, dot, name });
+ }
+ const el = this._cursors.get(c.sessionId);
+ el.dot.style.background = c.color;
+ el.name.style.background = c.color;
+ el.name.textContent = c.username;
+ }
+ }
+
+ /** Каждый кадр проецируем 3D-курсоры соавторов в экранные координаты. */
+ _startLoop() {
+ const tick = () => {
+ this._raf = requestAnimationFrame(tick);
+ if (!this.cursorsEl || !this._presence.length) return;
+ const scene = this.scene.scene;
+ const cam = this.scene.camera;
+ const eng = this.scene.engine;
+ if (!scene || !cam || !eng) return;
+ for (const c of this._presence) {
+ if (c.me) continue;
+ const el = this._cursors.get(c.sessionId);
+ if (!el) continue;
+ const cur = c.cursor;
+ if (!cur || (cur.x === 0 && cur.y === 0 && cur.z === 0)) { el.wrap.style.display = 'none'; continue; }
+ const sp = this._project(cur.x, cur.y, cur.z, scene, cam, eng);
+ if (!sp) { el.wrap.style.display = 'none'; continue; }
+ el.wrap.style.display = 'block';
+ el.wrap.style.left = sp.x + 'px';
+ el.wrap.style.top = sp.y + 'px';
+ }
+ };
+ this._raf = requestAnimationFrame(tick);
+ }
+
+ /** Спроецировать мировую точку в экранные координаты canvas. */
+ _project(x, y, z, scene, cam, eng) {
+ try {
+ const w = eng.getRenderWidth();
+ const h = eng.getRenderHeight();
+ const p = Vector3.Project(
+ new Vector3(x, y, z),
+ Matrix.Identity(),
+ scene.getTransformMatrix(),
+ cam.viewport.toGlobal(w, h)
+ );
+ if (p.z < 0 || p.z > 1) return null; // за камерой
+ // переводим из render-пикселей в css-пиксели canvas
+ const canvas = eng.getRenderingCanvas();
+ const rect = canvas.getBoundingClientRect();
+ const sx = rect.width / w, sy = rect.height / h;
+ return { x: p.x * sx, y: p.y * sy };
+ } catch (e) { return null; }
+ }
+
+ toast(text) {
+ if (!this.toastEl) return;
+ this.toastEl.textContent = text;
+ this.toastEl.style.opacity = '1';
+ clearTimeout(this._toastT);
+ this._toastT = setTimeout(() => { if (this.toastEl) this.toastEl.style.opacity = '0'; }, 2600);
+ }
+
+ dispose() {
+ if (this._raf) cancelAnimationFrame(this._raf);
+ try { this.root?.remove(); } catch (e) { /* ignore */ }
+ this.root = null; this._cursors.clear();
+ }
+}
+
+function escapeHtml(s) {
+ return String(s == null ? '' : s).replace(/[&<>"']/g, m => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m]));
+}
diff --git a/src/editor/engine/Environment.js b/src/editor/engine/Environment.js
index 2d69514..590bd02 100644
--- a/src/editor/engine/Environment.js
+++ b/src/editor/engine/Environment.js
@@ -91,10 +91,15 @@ export class Environment {
this.fogEnabled = false;
this.fogColor = [0.7, 0.8, 0.9];
this.fogDensity = 0.01;
- // Видимые тела на небе (солнце и луна) — создаём по запросу
+ // Видимые тела на небе (солнце и луна).
+ // ВАЖНО (задача 16): единое небо рисует SkyboxManager (купол + солнечный
+ // диск + облака). Environment больше НЕ рисует свою жёлтую сферу/луну/фон —
+ // иначе на небе два солнца. Environment теперь отвечает ТОЛЬКО за свет
+ // (направление/яркость солнца, ambient). Флаг ниже отключает небесные тела.
+ this._drawSkyBodies = false;
this._sunMesh = null;
this._moonMesh = null;
- this._createSkyBodies();
+ if (this._drawSkyBodies) this._createSkyBodies();
this._applyTime();
}
diff --git a/src/editor/engine/FloaterManager.js b/src/editor/engine/FloaterManager.js
new file mode 100644
index 0000000..b6a538e
--- /dev/null
+++ b/src/editor/engine/FloaterManager.js
@@ -0,0 +1,236 @@
+/**
+ * FloaterManager — всплывающие цифры урона (Damage Floaters), задача 40.
+ *
+ * game.fx.damageFloater(position, value, opts) → над точкой всплывает число,
+ * поднимается вверх, покачивается, плавно гаснет. Цвета: damage/crit/heal/
+ * mana/miss. Object pool из переиспользуемых billboard-планов (без create/
+ * destroy на каждый удар). Стек одинаковых по stackKey («×N»). Комикс-стиль
+ * (BAM!/KAPOW!/POW!).
+ *
+ * Билборд = плоскость с DynamicTexture (как LabelManager), billboardMode=7,
+ * renderingGroupId=1 (всегда поверх геометрии), disableDepthWrite.
+ *
+ * Фича-парность: тот же модуль в rublox-player/src/engine/.
+ */
+import { DynamicTexture } from '@babylonjs/core/Materials/Textures/dynamicTexture';
+import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial';
+import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
+import { Color3 } from '@babylonjs/core/Maths/math.color';
+import { Mesh } from '@babylonjs/core/Meshes/mesh';
+
+const POOL_SIZE = 30;
+const TEX_W = 512, TEX_H = 256;
+
+// Пресеты типов урона: цвет текста + множители.
+const PRESETS = {
+ damage: { color: '#ff5a4a', stroke: '#3a0000' },
+ crit: { color: '#ffd23a', stroke: '#5a3a00' },
+ heal: { color: '#46e06a', stroke: '#063a14' },
+ mana: { color: '#4aa8ff', stroke: '#001a3a' },
+ miss: { color: '#b8b8b8', stroke: '#222222' },
+};
+
+function easeOutQuad(t) { return 1 - (1 - t) * (1 - t); }
+
+export class FloaterManager {
+ constructor(scene3d) {
+ this.s = scene3d;
+ this.scene = scene3d.scene;
+ this.pool = [];
+ this._initialized = false;
+ this._stacks = new Map(); // stackKey → slot (для накопления ×N)
+ }
+
+ _init() {
+ if (this._initialized) return;
+ this._initialized = true;
+ for (let i = 0; i < POOL_SIZE; i++) {
+ const tex = new DynamicTexture(`floaterTex_${i}`, { width: TEX_W, height: TEX_H }, this.scene, true);
+ tex.hasAlpha = true;
+ const plane = MeshBuilder.CreatePlane(`floater_${i}`, { width: 2.4, height: 1.2, sideOrientation: Mesh.DOUBLESIDE }, this.scene);
+ const mat = new StandardMaterial(`floaterMat_${i}`, this.scene);
+ mat.diffuseTexture = tex;
+ mat.diffuseTexture.hasAlpha = true;
+ mat.emissiveColor = new Color3(1, 1, 1);
+ mat.diffuseColor = new Color3(0, 0, 0);
+ mat.disableLighting = true;
+ mat.backFaceCulling = false;
+ mat.disableDepthWrite = true;
+ mat.useAlphaFromDiffuseTexture = true;
+ plane.material = mat;
+ plane.billboardMode = 7;
+ plane.renderingGroupId = 1;
+ plane.isPickable = false;
+ plane.setEnabled(false);
+ this.pool.push({ plane, tex, mat, active: false, age: 0, lifetime: 0.8 });
+ }
+ }
+
+ _acquire() {
+ for (const slot of this.pool) if (!slot.active) return slot;
+ return null; // все заняты — пропускаем новый floater (норма)
+ }
+
+ /**
+ * Главный API. position: {x,y,z}; value: число|строка; opts — см. задачу 40.
+ */
+ spawn(position, value, opts = {}) {
+ this._init();
+ if (!position) return;
+ opts = opts || {};
+
+ // Стек: одинаковый stackKey за время жизни накапливает счётчик.
+ if (opts.stackKey && this._stacks.has(opts.stackKey)) {
+ const slot = this._stacks.get(opts.stackKey);
+ if (slot.active) {
+ slot.stackCount = (slot.stackCount || 1) + 1;
+ slot.age = Math.min(slot.age, slot.lifetime * 0.3); // продлеваем
+ this._draw(slot, slot.baseText, slot.preset, slot.fontSize, slot.comic, slot.stackCount);
+ return;
+ }
+ }
+
+ const slot = this._acquire();
+ if (!slot) return;
+
+ // Тип floater'а.
+ let kind = 'damage';
+ if (opts.isCrit) kind = 'crit';
+ else if (opts.isHeal) kind = 'heal';
+ else if (opts.isMana) kind = 'mana';
+ else if (opts.isMiss) kind = 'miss';
+ const preset = PRESETS[kind];
+ const color = opts.color || preset.color;
+ const stroke = opts.strokeColor || preset.stroke;
+
+ let fontSize = Number.isFinite(opts.fontSize) ? opts.fontSize : 60;
+ let floatHeight = Number.isFinite(opts.floatHeight) ? opts.floatHeight : 2;
+ let lifetime = Number.isFinite(opts.lifetime) ? opts.lifetime : 0.9;
+ const randomOffset = Number.isFinite(opts.randomOffset) ? opts.randomOffset : (opts.isCrit ? 0.5 : 0.25);
+
+ // Текст: число с минусом (урон) или как есть (строка / heal с плюсом).
+ let baseText;
+ if (typeof value === 'string') baseText = value;
+ else if (opts.isHeal) baseText = '+' + value;
+ else if (opts.isMiss) baseText = String(value);
+ else baseText = '-' + Math.abs(value);
+
+ if (opts.isCrit) { fontSize = Math.round(fontSize * 1.4); floatHeight *= 1.2; }
+
+ slot.active = true;
+ slot.age = 0;
+ slot.lifetime = lifetime;
+ slot.floatHeight = floatHeight;
+ slot.isCrit = !!opts.isCrit;
+ slot.color = color; slot.stroke = stroke;
+ slot.preset = { color, stroke };
+ slot.fontSize = fontSize;
+ slot.comic = !!opts.comicStyle;
+ slot.baseText = baseText;
+ slot.stackCount = 1;
+ slot.stackKey = opts.stackKey || null;
+
+ const rx = (Math.random() - 0.5) * 2 * randomOffset;
+ const rz = (Math.random() - 0.5) * 2 * randomOffset;
+ slot.startX = position.x + rx;
+ slot.startY = position.y + (Number.isFinite(opts.yOffset) ? opts.yOffset : 1.5);
+ slot.startZ = position.z + rz;
+ slot.plane.position.set(slot.startX, slot.startY, slot.startZ);
+ slot.plane.scaling.set(1, 1, 1);
+ slot.plane.setEnabled(true);
+
+ this._draw(slot, baseText, slot.preset, fontSize, slot.comic, 1);
+
+ if (opts.stackKey) this._stacks.set(opts.stackKey, slot);
+ }
+
+ _draw(slot, baseText, preset, fontSize, comic, stackCount) {
+ const ctx = slot.tex.getContext();
+ ctx.clearRect(0, 0, TEX_W, TEX_H);
+
+ let text = baseText;
+ if (comic) {
+ const num = parseInt(String(baseText).replace(/[^0-9]/g, ''), 10) || 0;
+ if (slot.isCrit) text = 'POW!';
+ else if (num > 100) text = 'KAPOW!';
+ else if (num > 50) text = 'BAM!';
+ }
+ if (stackCount > 1) text = baseText + ' ×' + stackCount;
+
+ const fs = comic ? Math.round(fontSize * 1.1) : fontSize;
+ ctx.font = `900 ${fs}px ${comic ? 'Bangers, Impact, sans-serif' : 'Inter, Arial, sans-serif'}`;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.lineJoin = 'round';
+
+ // Комикс-фон: жёлтая звезда-вспышка.
+ if (comic) {
+ ctx.save();
+ ctx.translate(TEX_W / 2, TEX_H / 2);
+ ctx.fillStyle = 'rgba(255,210,60,0.9)';
+ ctx.beginPath();
+ const spikes = 10, outer = 130, inner = 70;
+ for (let i = 0; i < spikes * 2; i++) {
+ const r = i % 2 === 0 ? outer : inner;
+ const a = (i / (spikes * 2)) * Math.PI * 2 - Math.PI / 2;
+ const px = Math.cos(a) * r, py = Math.sin(a) * r * 0.55;
+ i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
+ }
+ ctx.closePath(); ctx.fill();
+ ctx.restore();
+ }
+
+ // Обводка + текст.
+ ctx.strokeStyle = comic ? '#000' : preset.stroke;
+ ctx.lineWidth = Math.max(6, fs * 0.16);
+ ctx.strokeText(text, TEX_W / 2, TEX_H / 2);
+ ctx.fillStyle = comic ? '#d22' : preset.color;
+ ctx.fillText(text, TEX_W / 2, TEX_H / 2);
+
+ slot.tex.update(true);
+ }
+
+ /** Вызывать каждый кадр (анимация подъёма + fade + покачивание + crit-pop). */
+ tick(dt) {
+ if (!this._initialized) return;
+ for (const slot of this.pool) {
+ if (!slot.active) continue;
+ slot.age += dt;
+ const t = slot.age / slot.lifetime;
+ if (t >= 1) {
+ slot.active = false;
+ slot.plane.setEnabled(false);
+ if (slot.stackKey && this._stacks.get(slot.stackKey) === slot) this._stacks.delete(slot.stackKey);
+ continue;
+ }
+ const ease = easeOutQuad(t);
+ slot.plane.position.y = slot.startY + slot.floatHeight * ease;
+ slot.plane.position.x = slot.startX + Math.sin(slot.age * 5) * 0.12;
+
+ // fade-in 0.12 / hold / fade-out 0.25
+ let alpha = 1;
+ if (t < 0.12) alpha = t / 0.12;
+ else if (t > 0.75) alpha = 1 - (t - 0.75) / 0.25;
+ slot.mat.alpha = Math.max(0, Math.min(1, alpha));
+
+ // crit pop: scale 1 → 1.3 → 1 в первые 0.4 жизни
+ if (slot.isCrit) {
+ let s = 1;
+ if (t < 0.2) s = 1 + (t / 0.2) * 0.3;
+ else if (t < 0.4) s = 1.3 - ((t - 0.2) / 0.2) * 0.3;
+ slot.plane.scaling.set(s, s, s);
+ }
+ }
+ }
+
+ dispose() {
+ for (const slot of this.pool) {
+ try { slot.plane.dispose(); slot.tex.dispose(); slot.mat.dispose(); } catch (e) {}
+ }
+ this.pool = []; this._stacks.clear(); this._initialized = false;
+ }
+ resetRuntime() {
+ for (const slot of this.pool) { slot.active = false; slot.plane?.setEnabled(false); }
+ this._stacks.clear();
+ }
+}
diff --git a/src/editor/engine/FolderManager.js b/src/editor/engine/FolderManager.js
index 28ab638..048c52b 100644
--- a/src/editor/engine/FolderManager.js
+++ b/src/editor/engine/FolderManager.js
@@ -215,29 +215,48 @@ export class FolderManager {
* Возвращает количество повёрнутых примитивов.
*/
rotateFolderY(folderId, angle, pivot) {
- if (!this.primitiveManager || !pivot) return 0;
+ if (!pivot) return 0;
const cosA = Math.cos(angle);
const sinA = Math.sin(angle);
let count = 0;
- for (const data of this.primitiveManager.instances.values()) {
- if (data.folderId !== folderId) continue;
- // Поворачиваем позицию вокруг pivot.y axis (XZ-плоскость)
- const dx = data.x - pivot.x;
- const dz = data.z - pivot.z;
- const newX = pivot.x + dx * cosA - dz * sinA;
- const newZ = pivot.z + dx * sinA + dz * cosA;
- data.x = newX;
- data.z = newZ;
- data.rotationY = (data.rotationY || 0) + angle;
- if (data.mesh) {
- data.mesh.position.set(newX, data.y, newZ);
- data.mesh.rotation.y = data.rotationY;
- if (data._worldMatrixFrozen) {
- try { data.mesh.unfreezeWorldMatrix?.(); } catch (e) {}
- data._worldMatrixFrozen = false;
+ // Примитивы папки.
+ if (this.primitiveManager) {
+ for (const data of this.primitiveManager.instances.values()) {
+ if (data.folderId !== folderId) continue;
+ const dx = data.x - pivot.x;
+ const dz = data.z - pivot.z;
+ data.x = pivot.x + dx * cosA - dz * sinA;
+ data.z = pivot.z + dx * sinA + dz * cosA;
+ data.rotationY = (data.rotationY || 0) + angle;
+ if (data.mesh) {
+ data.mesh.position.set(data.x, data.y, data.z);
+ data.mesh.rotation.y = data.rotationY;
+ if (data._worldMatrixFrozen) {
+ try { data.mesh.unfreezeWorldMatrix?.(); } catch (e) {}
+ data._worldMatrixFrozen = false;
+ }
}
+ count++;
+ }
+ }
+ // Модели папки (позиция вокруг pivot + собственный поворот).
+ if (this.modelManager) {
+ const Vec = this.modelManager._Vector3 || null;
+ for (const data of this.modelManager.instances.values()) {
+ if (data.folderId !== folderId) continue;
+ const dx = data.x - pivot.x;
+ const dz = data.z - pivot.z;
+ data.x = pivot.x + dx * cosA - dz * sinA;
+ data.z = pivot.z + dx * sinA + dz * cosA;
+ data.rotationY = (data.rotationY || 0) + angle;
+ const root = data.rootMesh || data.rootNode;
+ if (root) {
+ if (data._worldMatrixFrozen) { try { root.unfreezeWorldMatrix?.(); } catch (e) {} data._worldMatrixFrozen = false; }
+ root.position.set(data.x, data.y, data.z);
+ if (root.rotation) root.rotation.y = data.rotationY;
+ }
+ count++;
}
- count++;
}
this._notifyChange();
return count;
@@ -259,6 +278,99 @@ export class FolderManager {
return count;
}
+ /**
+ * Собрать все объекты папки (рекурсивно по подпапкам) с их мешами.
+ * Возвращает { models:[{data}], primitives:[{data}], blocks:[mesh],
+ * meshes:[meshes для подсветки], center:{x,y,z}, count }.
+ */
+ getFolderObjects(folderId) {
+ const out = { models: [], primitives: [], blocks: [], meshes: [] };
+ const ids = new Set([folderId]);
+ // Собираем id всех вложенных подпапок.
+ let added = true;
+ while (added) {
+ added = false;
+ for (const f of this.getAll()) {
+ if (f.parentId != null && ids.has(f.parentId) && !ids.has(f.id)) {
+ ids.add(f.id); added = true;
+ }
+ }
+ }
+ if (this.modelManager) {
+ for (const d of this.modelManager.instances.values()) {
+ if (ids.has(d.folderId)) {
+ out.models.push(d);
+ const root = d.rootMesh || d.rootNode;
+ if (root) out.meshes.push(root);
+ }
+ }
+ }
+ if (this.primitiveManager) {
+ for (const d of this.primitiveManager.instances.values()) {
+ if (ids.has(d.folderId)) {
+ out.primitives.push(d);
+ if (d.mesh) out.meshes.push(d.mesh);
+ }
+ }
+ }
+ if (this.blockManager) {
+ for (const mesh of this.blockManager.blocks.values()) {
+ if (ids.has(mesh.metadata?.folderId)) out.blocks.push(mesh);
+ }
+ }
+ // Центр группы (по позициям моделей/примитивов).
+ let sx = 0, sy = 0, sz = 0, n = 0;
+ for (const d of out.models) { sx += d.x || 0; sy += d.y || 0; sz += d.z || 0; n++; }
+ for (const d of out.primitives) { sx += d.x || 0; sy += d.y || 0; sz += d.z || 0; n++; }
+ out.center = n > 0 ? { x: sx / n, y: sy / n, z: sz / n } : { x: 0, y: 0, z: 0 };
+ out.count = out.models.length + out.primitives.length + out.blocks.length;
+ return out;
+ }
+
+ /** Сдвинуть все объекты папки на (dx,dy,dz). */
+ moveFolderBy(folderId, dx, dy, dz) {
+ const g = this.getFolderObjects(folderId);
+ const apply = (d, mesh) => {
+ d.x = (d.x || 0) + dx; d.y = (d.y || 0) + dy; d.z = (d.z || 0) + dz;
+ if (mesh) {
+ if (d._worldMatrixFrozen) { try { mesh.unfreezeWorldMatrix?.(); } catch (e) {} d._worldMatrixFrozen = false; }
+ mesh.position.set(d.x, d.y, d.z);
+ }
+ };
+ for (const d of g.models) apply(d, d.rootMesh || d.rootNode);
+ for (const d of g.primitives) apply(d, d.mesh);
+ this._notifyChange();
+ }
+
+ /**
+ * Масштабировать папку относительно центра pivot на коэффициент factor.
+ * Позиции расходятся/сходятся от центра + размеры примитивов меняются.
+ */
+ scaleFolder(folderId, factor, pivot) {
+ if (!Number.isFinite(factor) || factor <= 0) return;
+ const g = this.getFolderObjects(folderId);
+ const p = pivot || g.center;
+ const sc = (d, mesh, isPrim) => {
+ d.x = p.x + ((d.x || 0) - p.x) * factor;
+ d.y = p.y + ((d.y || 0) - p.y) * factor;
+ d.z = p.z + ((d.z || 0) - p.z) * factor;
+ if (isPrim) {
+ d.sx = (d.sx || 1) * factor; d.sy = (d.sy || 1) * factor; d.sz = (d.sz || 1) * factor;
+ } else {
+ d.scale = (d.scale || 1) * factor;
+ }
+ if (mesh) {
+ if (d._worldMatrixFrozen) { try { mesh.unfreezeWorldMatrix?.(); } catch (e) {} d._worldMatrixFrozen = false; }
+ mesh.position.set(d.x, d.y, d.z);
+ if (isPrim && mesh.scaling) mesh.scaling.set(d.sx, d.sy, d.sz);
+ else if (mesh.scaling) mesh.scaling.scaleInPlace(factor);
+ }
+ };
+ for (const d of g.models) sc(d, d.rootMesh || d.rootNode, false);
+ for (const d of g.primitives) sc(d, d.mesh, true);
+ this._notifyChange();
+ }
+
/** Найти папку по имени (regex/exact). */
findByName(name) {
const n = String(name || '').toLowerCase();
diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js
index 52fa64c..5170722 100644
--- a/src/editor/engine/GameRuntime.js
+++ b/src/editor/engine/GameRuntime.js
@@ -68,6 +68,22 @@ export class GameRuntime {
start(scripts) {
this.stop();
this._isRunning = true;
+ this.scripts = scripts || []; // для привязки логов/ошибок к скрипту
+ // Задача 20: мост leaderstats.onChange (main) → globalEvent в worker'ы,
+ // чтобы скриптовые game.leaderstats.onChange и bindToStat срабатывали.
+ try {
+ const ls = this.scene3d?.leaderstats;
+ if (ls && !ls._bridgeBound) {
+ ls._bridgeBound = true;
+ const meId = ls._resolveMe?.();
+ ls.onChange((pid, name, nv, ov) => {
+ for (const sb of this.sandboxes) sb.sendGlobalEvent({
+ type: 'leaderstatsChange', playerId: pid, name, newValue: nv, oldValue: ov,
+ isMe: String(pid) === String(meId),
+ });
+ });
+ }
+ } catch (e) { /* ignore */ }
// eslint-disable-next-line no-console
console.log('[GameRuntime] start called with scripts:', scripts);
// Phase 6.5: lazy-init физ-мира. Wasm-инициализация асинхронная (~50мс),
@@ -1310,6 +1326,8 @@ export class GameRuntime {
ls.setBridge(
(id) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingSkip', loadingId: id }); },
(id) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingComplete', loadingId: id }); },
+ // Задача 05: onHide — экран скрылся (любой).
+ () => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingHidden' }); },
);
this.scene3d.loadingScreen = ls;
}
@@ -1548,7 +1566,13 @@ export class GameRuntime {
/** Команда от Worker'а пришла — применяем на сцене. */
_handleCommand(scriptId, cmd, payload) {
if (cmd === 'log') {
- this._log(payload?.level || 'info', payload?.text || '');
+ // Привязываем запись к скрипту-источнику (для ссылки в консоли).
+ let scriptName = null;
+ try {
+ const meta = (this.scripts || []).find(s => s.id === scriptId);
+ scriptName = meta?.name || scriptId;
+ } catch (e) { scriptName = scriptId; }
+ this._log(payload?.level || 'info', payload?.text || '', scriptId, scriptName);
return;
}
if (cmd === 'player.teleport') {
@@ -1724,6 +1748,11 @@ export class GameRuntime {
});
return;
}
+ if (cmd === 'npc.setAttacking') {
+ this._npcCmd(payload?.ref, (nid) =>
+ this.scene3d?.npcManager?.setAttacking?.(nid, !!payload?.on));
+ return;
+ }
if (cmd === 'npc.stop') {
this._npcCmd(payload?.ref, (nid) =>
this.scene3d?.npcManager?.stopNpc(nid));
@@ -1852,9 +1881,9 @@ export class GameRuntime {
const id = ls.show(payload.opts || {});
// Вернуть worker'у real loadingId, чтобы хэндл (setProgress/close/колбэки)
// нашёл нужный экран по replyId → local→real маппингу.
- if (payload.replyId != null) {
- for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingShown', replyId: payload.replyId, loadingId: id });
- }
+ // replyId может отсутствовать (стартовый экран) — всё равно шлём
+ // loadingShown для game.loading.isVisible() (задача 05).
+ for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingShown', replyId: payload.replyId, loadingId: id });
} catch (e) { this._log('error', 'loading.show: ' + (e?.message || e)); }
}
return;
@@ -1862,8 +1891,38 @@ export class GameRuntime {
if (cmd === 'loading.setProgress') { this.scene3d?.loadingScreen?.setProgress(payload?.value); return; }
if (cmd === 'loading.setText') { this.scene3d?.loadingScreen?.setText(payload?.text); return; }
if (cmd === 'loading.setCover') { this.scene3d?.loadingScreen?.setCover(payload?.cover); return; }
+ if (cmd === 'loading.setBackground') { this.scene3d?.loadingScreen?.setBackground?.(payload?.background); return; }
if (cmd === 'loading.close') { this.scene3d?.loadingScreen?.close(); return; }
+ // === Damage Floaters (задача 40) — всплывающие цифры урона ===
+ if (cmd === 'fx.damageFloater') {
+ try {
+ let pos = payload?.position;
+ // ref-строка ('player'|'primitive:N'|'model:N') → координаты объекта.
+ if (typeof pos === 'string') {
+ if (pos === 'player') {
+ const pl = this.scene3d?.player;
+ const p = pl ? (pl._pos || pl.position || pl.mesh?.position) : null;
+ pos = p ? { x: p.x, y: p.y, z: p.z } : null;
+ } else {
+ const tgt = this._resolveTweenTarget(pos);
+ pos = tgt ? { x: tgt.data.x || 0, y: tgt.data.y || 0, z: tgt.data.z || 0 } : null;
+ }
+ }
+ if (pos) this.scene3d?.floaters?.spawn(pos, payload?.value, payload?.opts || {});
+ } catch (e) { /* ignore */ }
+ return;
+ }
+ if (cmd === 'fx.autoMobFloaters') {
+ try {
+ if (this.scene3d?.npcManager) {
+ this.scene3d.npcManager._autoFloater = payload?.enabled
+ ? { opts: payload?.opts || {} } : null;
+ }
+ } catch (e) { /* ignore */ }
+ return;
+ }
+
// === Beam / Trail — лучи и следы (Фаза 5.2) ===
if (cmd === 'fx.create') {
// payload: { kind: 'beam'|'trail', localRef, ... }
@@ -2031,6 +2090,19 @@ export class GameRuntime {
}
return;
}
+ // === Задача 44: drag-drop инвентарь (invUI) ===
+ if (cmd === 'items.define') { try { this.scene3d?.invUI?.defineItem(payload.def); } catch (e) {} return; }
+ if (cmd === 'inv2.add') {
+ try { this.scene3d?.invUI?.add(payload.itemId, payload.count); this.scene3d?.invUI?.mountHotbar(); } catch (e) {}
+ return;
+ }
+ if (cmd === 'inv2.remove') { try { this.scene3d?.invUI?.remove(payload.itemId, payload.count); } catch (e) {} return; }
+ if (cmd === 'inv2.open') { try { this.scene3d?.invUI?.open(); } catch (e) {} return; }
+ if (cmd === 'inv2.close') { try { this.scene3d?.invUI?.close(); } catch (e) {} return; }
+ if (cmd === 'inv2.toggle') { try { this.scene3d?.invUI?.toggle(); } catch (e) {} return; }
+ if (cmd === 'inv2.sort') { try { this.scene3d?.invUI?.sort(payload.by); } catch (e) {} return; }
+ if (cmd === 'inv2.setActive') { try { this.scene3d?.invUI?.setActiveHotbar(payload.i); } catch (e) {} return; }
+
if (cmd === 'inventory.remove') {
// payload: { modelTypeId? , name? } — убрать первый совпавший слот.
const inv = this.scene3d?.inventory;
@@ -2882,13 +2954,85 @@ export class GameRuntime {
}
return;
}
+ // === Небо и атмосфера (задача 16) ===
+ // === Лидерборды и достижения (задача 20) ===
+ if (cmd === 'leaderstats.define') {
+ try { this.scene3d?.leaderstats?.define(payload.name, payload.opts || {}); } catch (e) {}
+ return;
+ }
+ if (cmd === 'leaderstats.set') {
+ try { this.scene3d?.leaderstats?.set(payload.playerId, payload.name, payload.value); } catch (e) {}
+ return;
+ }
+ if (cmd === 'leaderstats.add') {
+ try { this.scene3d?.leaderstats?.add(payload.playerId, payload.name, payload.delta); } catch (e) {}
+ return;
+ }
+ if (cmd === 'achievements.define') {
+ try { this.scene3d?.achievements?.define(payload.list); } catch (e) {}
+ return;
+ }
+ if (cmd === 'achievements.unlock') {
+ try { this.scene3d?.achievements?.unlock(payload.id, payload.playerId); } catch (e) {}
+ return;
+ }
+ if (cmd === 'achievements.bindToStat') {
+ try { this.scene3d?.achievements?.bindToStat(payload.id, payload.statName, payload.cond || {}); } catch (e) {}
+ return;
+ }
+ if (cmd === 'achievements.setButtonVisible') {
+ try { this.scene3d?.achievements?.setButtonVisible(!!payload.visible); } catch (e) {}
+ return;
+ }
+ if (cmd === 'achievements.openPage') {
+ try { this.scene3d?.achievements?.openPage(); } catch (e) {}
+ return;
+ }
+
+ if (cmd === 'scene.setSkybox') {
+ try { this.scene3d?.skybox?.setSkybox(payload?.opts || {}); } catch (e) {}
+ return;
+ }
+ if (cmd === 'scene.setClouds') {
+ try { this.scene3d?.skybox?.setClouds(payload?.opts || {}); } catch (e) {}
+ return;
+ }
+ if (cmd === 'scene.setFog') {
+ try { this.scene3d?.skybox?.setFog(payload?.opts || {}); } catch (e) {}
+ return;
+ }
+ if (cmd === 'scene.skyboxFadeTo') {
+ try { this.scene3d?.skybox?.fadeTo(payload?.opts || {}, payload?.duration || 2); } catch (e) {}
+ return;
+ }
+ if (cmd === 'scene.skyboxSunDir') {
+ try { this.scene3d?.skybox?.setSunDirection(payload?.dir || {}); } catch (e) {}
+ return;
+ }
+
+ if (cmd === 'scene.setScale') {
+ try {
+ const k = Number(payload?.scale);
+ if (!Number.isFinite(k) || k < 0) return;
+ const pm = this.scene3d?.primitiveManager;
+ const rid = this._resolvePrimitiveId(payload?.id ?? payload?.ref);
+ const data = (pm && rid != null) ? pm.instances.get(rid) : null;
+ if (data?.mesh) {
+ if (data._worldMatrixFrozen) { try { data.mesh.unfreezeWorldMatrix?.(); } catch (e) {} data._worldMatrixFrozen = false; }
+ data.mesh.scaling.set(k, k, k); // визуальный масштаб от исходного размера
+ }
+ } catch (e) {}
+ return;
+ }
+
if (cmd === 'scene.setColor') {
try {
const color = payload?.color;
if (typeof color !== 'string') return;
// Окрашиваемый блок (studs-block): ref вида 'block:x,y,z' →
// меняем per-instance цвет через BlockManager.setBlockColor.
- const ref = payload?.id;
+ // ВАЖНО: obj.color=hex шлёт {ref}, а self.setColor — {id}. Берём оба.
+ const ref = payload?.id ?? payload?.ref;
if (typeof ref === 'string' && ref.startsWith('block:')) {
const parts = ref.slice(6).split(',').map(Number);
if (parts.length === 3 && parts.every(Number.isFinite)) {
@@ -2898,7 +3042,7 @@ export class GameRuntime {
}
const pm = this.scene3d?.primitiveManager;
if (!pm) return;
- const rid = this._resolvePrimitiveId(payload?.id);
+ const rid = this._resolvePrimitiveId(payload?.id ?? payload?.ref);
const data = rid != null ? pm.instances.get(rid) : null;
if (data) {
data.color = color;
@@ -3532,8 +3676,13 @@ export class GameRuntime {
}
if (cmd === 'scene.setVisible') {
try {
- const kind = payload?.kind;
- const id = payload?.id;
+ let kind = payload?.kind;
+ let id = payload?.id;
+ // obj.visible=false шлёт {ref:'primitive:N'} без kind/id — парсим ref.
+ if ((kind == null || id == null) && typeof payload?.ref === 'string') {
+ const colon = payload.ref.indexOf(':');
+ if (colon > 0) { kind = payload.ref.slice(0, colon); id = payload.ref.slice(colon + 1); }
+ }
const visible = !!payload?.visible;
if (id == null) return;
if (kind === 'primitive') {
@@ -4168,6 +4317,11 @@ export class GameRuntime {
const id = t.id ?? t.ref;
this.scene3d?.primitiveManager?.removeInstance(id);
}
+ // Снять interact-подсказку удалённого объекта (иначе «E» висит на пустоте).
+ if (t.kind && (t.ref ?? t.id) != null && Array.isArray(this._interactables)) {
+ const ref = t.kind + ':' + (t.ref ?? t.id);
+ this._interactables = this._interactables.filter(it => it.ref !== ref);
+ }
this.scheduleSceneSnapshot();
} catch (e) {
// eslint-disable-next-line no-console
@@ -4175,9 +4329,9 @@ export class GameRuntime {
}
}
- _log(level, text) {
+ _log(level, text, scriptId = null, scriptName = null) {
if (this._onLog) {
- try { this._onLog({ level, text, ts: Date.now() }); } catch (e) { /* ignore */ }
+ try { this._onLog({ level, text, ts: Date.now(), scriptId, scriptName }); } catch (e) { /* ignore */ }
}
}
@@ -4331,6 +4485,29 @@ export class GameRuntime {
.then(j => this._saveReply(scriptId, reqId, j.namespaces || {}))
.catch(() => this._saveReply(scriptId, reqId, {}));
}
+ /** Публичный helper для движковых менеджеров (leaderstats/achievements):
+ * сохранить прогресс текущего игрока в БД (storys savegame). */
+ saveProgress(namespace, data) {
+ const url = this._saveBaseUrl(namespace);
+ if (!url) return;
+ try {
+ fetch(url, {
+ method: 'POST',
+ headers: this._economyAuthHeaders(), // JWT игрока (иначе 401)
+ body: JSON.stringify({ data }),
+ }).catch(() => {});
+ } catch (e) { /* ignore */ }
+ }
+ /** Загрузить прогресс из БД (cb(data|null)). */
+ loadProgress(namespace, cb) {
+ const url = this._saveBaseUrl(namespace);
+ if (!url) { cb && cb(null); return; }
+ fetch(url, { headers: this._economyAuthHeaders() })
+ .then(r => r.json())
+ .then(j => cb && cb(j.data ?? null))
+ .catch(() => cb && cb(null));
+ }
+
_saveSet(payload) {
const url = this._saveBaseUrl(payload?.namespace);
if (!url) return;
diff --git a/src/editor/engine/GameplayKits.js b/src/editor/engine/GameplayKits.js
new file mode 100644
index 0000000..b79e8aa
--- /dev/null
+++ b/src/editor/engine/GameplayKits.js
@@ -0,0 +1,953 @@
+/**
+ * GameplayKits — каталог готовых механик для Toolbox (задача 17, фаза T2).
+ *
+ * Каждый kit — это готовый кусок поведения, который автор вставляет одним кликом
+ * из Тулбокса (вкладка «Готовые механики»). При вставке:
+ * - scripts с attachTo:'global' → добавляются как глобальный скрипт игры;
+ * - scripts с attachTo:'on-target' → создаётся примитив-маркер + скрипт на нём;
+ * - prims[] → создаются примитивы на сцене (визуал кита).
+ *
+ * Все киты написаны НАМИ на белом-листе game-API (ScriptSandboxWorker) →
+ * заведомо безопасны, исполняются в существующем sandbox (нет доступа к DOM/fetch).
+ *
+ * Фича-парность: тот же файл копируется в rublox-player/src/engine/ (киты — это
+ * данные-скрипты, исполняются движком плеера так же).
+ */
+
+export const KIT_CATEGORIES = [
+ { id: 'all', label: 'Все' },
+ { id: 'movement', label: 'Движение' },
+ { id: 'world', label: 'Мир' },
+ { id: 'ui', label: 'Интерфейс' },
+ { id: 'fx', label: 'Эффекты' },
+ { id: 'npc', label: 'NPC и бой' },
+ { id: 'economy', label: 'Экономика' },
+];
+
+export const GAMEPLAY_KITS = [
+ {
+ id: 'shift-to-run',
+ name: 'Бег на Shift',
+ desc: 'Игрок ускоряется в 1.8× при удержании Shift и возвращается к обычной скорости при отпускании.',
+ icon: 'zap', category: 'movement',
+ scripts: [{ attachTo: 'global', code:
+`// Бег на Shift
+game.onKey('shift', () => game.player.setSpeed(1.8));
+game.onKeyUp('shift', () => game.player.setSpeed(1.0));` }],
+ },
+ {
+ id: 'double-jump',
+ name: 'Двойной прыжок',
+ desc: 'Разрешает второй прыжок прямо в воздухе. Нажми Space ещё раз во время прыжка.',
+ icon: 'arrow-up', category: 'movement',
+ scripts: [{ attachTo: 'global', code:
+`// Двойной прыжок: второй прыжок в воздухе по Space
+game.player.setDoubleJump(true);
+game.ui.set('dj', 'Двойной прыжок включён! Жми Space в воздухе.', { x: 50, y: 90, anchor: 'bottom', color: '#fff', size: 16 });
+game.after(5, () => game.ui.set('dj', ''));` }],
+ },
+ {
+ id: 'day-night-cycle',
+ name: 'Смена дня и ночи',
+ desc: 'Небо плавно переключается день → закат → ночь → день по кругу (использует Skybox задачи 16).',
+ icon: 'cloud', category: 'world',
+ scripts: [{ attachTo: 'global', code:
+`// Авто-цикл дня и ночи
+const phases = ['clear-summer-day', 'sunset', 'starry-night', 'clear-summer-day'];
+let i = 0;
+game.scene.setSkybox({ preset: phases[0] });
+game.every(8, () => {
+ i = (i + 1) % phases.length;
+ game.scene.skybox.fadeTo({ preset: phases[i] }, 3);
+});` }],
+ },
+ {
+ id: 'currency-counter',
+ name: 'Счётчик монет',
+ desc: 'Счётчик монет в углу HUD. Другие механики шлют game.broadcast("coins", {add: N}) — счётчик обновляется.',
+ icon: 'circle', category: 'ui',
+ scripts: [{ attachTo: 'global', code:
+`// Счётчик монет в HUD. Прибавить монеты из любого скрипта:
+// game.broadcast('coins', { add: 100 });
+let coins = 0;
+function show() { game.ui.set('coins', '🪙 ' + coins, { x: 92, y: 6, anchor: 'top', color: '#ffd23a', size: 22 }); }
+show();
+game.onMessage('coins', (m) => { coins += (m && m.add) ? m.add : 1; show(); });` }],
+ },
+ {
+ id: 'start-pad',
+ name: 'Стартовая площадка',
+ desc: 'Светящаяся платформа — игрок появляется НА ней в начале игры (задаёт точку старта).',
+ icon: 'flag', category: 'world',
+ prims: [{ type: 'cylinder', x: 0, y: 0.15, z: 0, sx: 3, sy: 0.3, sz: 3, color: '#36d57a', material: 'neon', name: 'Стартовая площадка' }],
+ scripts: [{ attachTo: 'on-target', code:
+`// Игрок появляется на этой площадке в начале игры.
+// Небольшая задержка — чтобы позиция объекта и игрок успели проинициализироваться.
+game.after(0.1, () => {
+ const p = game.self.position;
+ game.player.teleport(p.x, p.y + 1.5, p.z);
+});` }],
+ },
+ {
+ id: 'checkpoint',
+ name: 'Чекпоинт',
+ desc: 'Светящийся столб-чекпоинт. При касании сохраняет прогресс и показывает уведомление.',
+ icon: 'flag', category: 'world',
+ prims: [{ type: 'cylinder', x: 0, y: 1.5, z: 0, sx: 0.6, sy: 3, sz: 0.6, color: '#4d6bff', material: 'neon', name: 'Чекпоинт' }],
+ scripts: [{ attachTo: 'on-target', code:
+`// Чекпоинт: касание → сообщение
+game.self.onInteract(() => {
+ game.ui.set('cp', '✓ Чекпоинт сохранён!', { x: 50, y: 85, anchor: 'bottom', color: '#36d57a', size: 18 });
+ game.after(2, () => game.ui.set('cp', ''));
+}, { text: 'Активировать', key: 'f', distance: 4 });` }],
+ },
+ {
+ id: 'confetti',
+ name: 'Конфетти',
+ desc: 'Праздничный фонтан конфетти из этого объекта. Кубики разлетаются и падают. Запускается периодически.',
+ icon: 'sparkles', category: 'fx',
+ prims: [{ type: 'sphere', x: 0, y: 1, z: 0, sx: 0.5, sy: 0.5, sz: 0.5, color: '#ff5ab0', material: 'neon', name: 'Конфетти-источник', canCollide: false }],
+ scripts: [{ attachTo: 'on-target', code:
+`// Конфетти вылетает из ПОЗИЦИИ этого объекта (не из центра сцены).
+function burst() {
+ const p = game.self.position; // где стоит конфетти-источник
+ for (let k = 0; k < 16; k++) {
+ const col = ['#ff5ab0','#ffd23a','#4d6bff','#36d57a','#ff7a3a'][k % 5];
+ game.scene.spawn('primitive:cube', {
+ x: p.x + (Math.random()-0.5)*0.6,
+ y: p.y + 0.5,
+ z: p.z + (Math.random()-0.5)*0.6,
+ sx: 0.22, sy: 0.22, sz: 0.22, color: col,
+ anchored: false, canCollide: false, lifetime: 2.5,
+ });
+ }
+}
+burst();
+game.every(3, burst);` }],
+ },
+ {
+ id: 'floating-platform',
+ name: 'Парящая платформа',
+ desc: 'Платформа, которая плавно качается вверх-вниз — для паркура.',
+ icon: 'square', category: 'world',
+ prims: [{ type: 'cube', x: 0, y: 2, z: 0, sx: 4, sy: 0.5, sz: 4, color: '#c8a86a', material: 'matte', name: 'Парящая платформа' }],
+ scripts: [{ attachTo: 'on-target', code:
+`// Качание платформы вверх-вниз
+let t = 0; const baseY = 2;
+game.onTick((dt) => {
+ t += dt;
+ game.self.move(game.self.position.x, baseY + Math.sin(t * 1.5) * 1.2, game.self.position.z);
+});` }],
+ },
+ {
+ id: 'rotating-trap',
+ name: 'Вращающийся объект',
+ desc: 'Объект, который постоянно вращается — препятствие или декор.',
+ icon: 'refresh', category: 'world',
+ prims: [{ type: 'cube', x: 0, y: 1.5, z: 0, sx: 5, sy: 0.4, sz: 0.6, color: '#e0483c', material: 'matte', name: 'Вертушка' }],
+ scripts: [{ attachTo: 'on-target', code:
+`// Постоянное вращение
+let a = 0;
+game.onTick((dt) => { a += dt * 1.5; game.self.rotate(a); });` }],
+ },
+ {
+ id: 'timer-hud',
+ name: 'Таймер забега',
+ desc: 'Секундомер в HUD — считает время с начала игры. Основа для гонок на время.',
+ icon: 'clock', category: 'ui',
+ scripts: [{ attachTo: 'global', code:
+`// Таймер забега
+let t = 0;
+game.every(0.1, () => {
+ t += 0.1;
+ game.ui.set('timer', '⏱ ' + t.toFixed(1) + ' c', { x: 50, y: 6, anchor: 'top', color: '#ffffff', size: 22 });
+});` }],
+ },
+ {
+ id: 'welcome-message',
+ name: 'Приветствие',
+ desc: 'Показывает приветственное сообщение при входе в игру и убирает через 5 секунд.',
+ icon: 'message', category: 'ui',
+ scripts: [{ attachTo: 'global', code:
+`// Приветствие
+game.ui.set('welcome', '👋 Добро пожаловать в игру!', { x: 50, y: 40, anchor: 'center', color: '#ffffff', size: 30 });
+game.after(5, () => game.ui.set('welcome', ''));` }],
+ },
+ {
+ id: 'loot-crate',
+ name: 'Сундук с лутом',
+ desc: 'Золотой сундук. При взаимодействии «открывается» — даёт награду и сообщение.',
+ icon: 'box', category: 'world',
+ prims: [
+ { type: 'cube', x: 0, y: 0.6, z: 0, sx: 1.6, sy: 1.2, sz: 1.2, color: '#b5862e', material: 'metal', name: 'Сундук' },
+ { type: 'cube', x: 0, y: 1.35, z: 0, sx: 1.7, sy: 0.4, sz: 1.3, color: '#d4a843', material: 'metal', name: 'Крышка сундука', canCollide: false },
+ ],
+ scripts: [{ attachTo: 'on-target', code:
+`// Сундук с лутом — даёт 100 монет (через счётчик монет, если он добавлен).
+let opened = false;
+game.self.onInteract(() => {
+ if (opened) { game.ui.set('loot', 'Сундук уже открыт.', { x: 50, y: 85, anchor: 'bottom', color: '#bbb', size: 16 }); return; }
+ opened = true;
+ game.broadcast('coins', { add: 100 }); // обновит «Счётчик монет», если он есть
+ game.ui.set('loot', '🎁 Ты получил награду: +100 монет!', { x: 50, y: 85, anchor: 'bottom', color: '#ffd23a', size: 18 });
+ game.after(3, () => game.ui.set('loot', ''));
+}, { text: 'Открыть сундук', key: 'f', distance: 4 });` }],
+ },
+
+ // ===== Партия 1 из Вики (киты 13-17) =====
+
+ {
+ id: 'trampoline',
+ name: 'Батут (пружина)',
+ desc: 'Яркая платформа-батут — наступи на неё, и игрока подбросит высоко вверх. (Вики: «Прыжок-пружина»)',
+ icon: 'arrow-up', category: 'movement',
+ prims: [{ type: 'cylinder', x: 0, y: 0.3, z: 0, sx: 3, sy: 0.6, sz: 3, color: '#ff3c8e', material: 'neon', name: 'Батут' }],
+ scripts: [{ attachTo: 'on-target', code:
+`// Батут: касание → подброс игрока вверх.
+game.self.onTouch(() => {
+ game.player.setVy(20); // вертикальный импульс (как трамплин)
+});` }],
+ },
+ {
+ id: 'speed-pad',
+ name: 'Лента ускорения',
+ desc: 'Жёлтая плита-ускоритель — наступи, и игрок бежит быстрее несколько секунд. (Вики: «бусты скорости»)',
+ icon: 'zap', category: 'movement',
+ prims: [{ type: 'cube', x: 0, y: 0.1, z: 0, sx: 3, sy: 0.2, sz: 5, color: '#ffd23a', material: 'neon', name: 'Лента ускорения', canCollide: false }],
+ scripts: [{ attachTo: 'on-target', code:
+`// Лента ускорения: касание → x2 скорости на 3 секунды.
+let boosting = false;
+game.self.onTouch(() => {
+ if (boosting) return;
+ boosting = true;
+ game.player.setSpeed(2.0);
+ game.ui.set('boost', '⚡ Ускорение!', { x: 50, y: 80, anchor: 'bottom', color: '#ffd23a', size: 18 });
+ game.after(3, () => { game.player.setSpeed(1.0); game.ui.set('boost', ''); boosting = false; });
+});` }],
+ },
+ {
+ id: 'teleport-portal',
+ name: 'Портал-телепорт',
+ desc: 'Два синих портала. Вошёл в портал — мгновенно переносишься ко второму. Поставь второй портал где нужно. (Вики: «секретный путь»)',
+ icon: 'sparkles', category: 'movement',
+ prims: [
+ { type: 'cylinder', x: 0, y: 1.5, z: 0, sx: 0.4, sy: 3, sz: 3, color: '#4d6bff', material: 'neon', name: 'Портал A' },
+ { type: 'cylinder', x: 8, y: 1.5, z: 0, sx: 0.4, sy: 3, sz: 3, color: '#4dffd6', material: 'neon', name: 'Портал B', canCollide: false },
+ ],
+ scripts: [{ attachTo: 'on-target', code:
+`// Портал A: касание → телепорт к «Портал B» (ищем его по имени в момент входа).
+// Передвигай «Портал B» куда угодно — телепорт всегда попадёт к нему.
+let cd = false;
+game.self.onTouch(() => {
+ if (cd) return;
+ const b = game.scene.findOne('Портал B'); // ищем второй портал
+ if (!b || !b.position) return;
+ cd = true;
+ game.player.teleport(b.position.x, b.position.y + 1, b.position.z);
+ game.after(1.2, () => { cd = false; }); // защита от повторного входа
+});` }],
+ },
+ {
+ id: 'disappearing-platform',
+ name: 'Исчезающая платформа',
+ desc: 'Платформа пропадает под ногами через секунду после касания и возвращается через 3с. (Вики: «Не упади», «Падающий мост»)',
+ icon: 'square', category: 'world',
+ prims: [{ type: 'cube', x: 0, y: 0.25, z: 0, sx: 4, sy: 0.5, sz: 4, color: '#e06a3c', material: 'matte', name: 'Исчезающая платформа' }],
+ scripts: [{ attachTo: 'on-target', code:
+`// Исчезающая платформа: наступил → через 1с пропадает, через 3с возвращается.
+let busy = false;
+game.self.onTouch(() => {
+ if (busy) return; busy = true;
+ game.after(1, () => { game.self.setVisible(false); game.self.setCollide(false); });
+ game.after(3, () => { game.self.setVisible(true); game.self.setCollide(true); busy = false; });
+});` }],
+ },
+ {
+ id: 'door-button',
+ name: 'Дверь по кнопке E',
+ desc: 'Красивая дверь с рамкой, филёнками и ручкой. Нажми E — плавно распахивается вокруг петли. (Вики: «Кнопка-открывашка»)',
+ icon: 'door', category: 'world',
+ // Полотно двери — ПЕРВЫЙ prim (на нём скрипт). Остальные части — рамка
+ // (неподвижный косяк) + декор полотна. Всё уходит в одну папку.
+ prims: [
+ // 0) Полотно двери (тёмное дерево).
+ { type: 'cube', x: 0, y: 2, z: 0, sx: 0.25, sy: 3.8, sz: 2.6, color: '#6b4423', material: 'matte', name: 'Полотно двери' },
+ // Филёнки (светлее, чуть выступают) — верхняя и нижняя.
+ { type: 'cube', x: 0.16, y: 2.9, z: 0, sx: 0.08, sy: 1.2, sz: 1.9, color: '#8a5a2e', material: 'matte', canCollide: false, name: 'Филёнка верх' },
+ { type: 'cube', x: 0.16, y: 1.2, z: 0, sx: 0.08, sy: 1.4, sz: 1.9, color: '#8a5a2e', material: 'matte', canCollide: false, name: 'Филёнка низ' },
+ // Ручка (золотая).
+ { type: 'sphere', x: 0.28, y: 2, z: 0.95, sx: 0.3, sy: 0.3, sz: 0.3, color: '#e0b030', material: 'metal', canCollide: false, name: 'Ручка' },
+ // Косяк-рамка (неподвижная) — две стойки + перемычка.
+ { type: 'cube', x: 0, y: 2, z: -1.55, sx: 0.4, sy: 4.2, sz: 0.4, color: '#4a3018', material: 'matte', name: 'Косяк левый' },
+ { type: 'cube', x: 0, y: 2, z: 1.55, sx: 0.4, sy: 4.2, sz: 0.4, color: '#4a3018', material: 'matte', name: 'Косяк правый' },
+ { type: 'cube', x: 0, y: 4.1, z: 0, sx: 0.4, sy: 0.4, sz: 3.5, color: '#4a3018', material: 'matte', name: 'Перемычка' },
+ ],
+ scripts: [{ attachTo: 'on-target', code:
+`// Дверь по E: ПЛАВНО поворачивается вокруг петли (левой грани).
+// Скрипт на полотне двери. Филёнки/ручка двигаются вместе как часть полотна?
+// Нет — они отдельные примитивы, поэтому анимируем только полотно (game.self),
+// а декор оставляем — на низкой толщине двери смотрится цельно.
+const p0 = game.self.position; // центр полотна (закрытое положение)
+const halfW = 1.3; // половина ширины полотна по Z (sz=2.6)
+const hingeX = p0.x;
+const hingeZ = p0.z - halfW; // петля у левого края
+let open = false;
+let cur = 0, target = 0; // текущий и целевой угол (радианы)
+const SPEED = Math.PI; // рад/сек → ~0.5с на 90°
+
+// Декор полотна — двигаем вместе с дверью. Запоминаем их СМЕЩЕНИЕ относительно
+// центра полотна (в закрытом виде), чтобы вращать вокруг той же петли.
+const decorNames = ['Филёнка верх', 'Филёнка низ', 'Ручка'];
+const decor = [];
+for (const nm of decorNames) {
+ const o = game.scene.findOne(nm);
+ if (o && o.position) {
+ decor.push({ obj: o, dx: o.position.x - p0.x, dy: o.position.y - p0.y, dz: o.position.z - p0.z });
+ }
+}
+
+// Поворот локального вектора (lx,lz) вокруг оси Y на angle — согласованно с
+// тем, как Babylon поворачивает меш при rotation.y=angle (левосторонняя СК).
+function rotY(lx, lz, a) {
+ const s = Math.sin(a), c = Math.cos(a);
+ return { x: lx * c + lz * s, z: -lx * s + lz * c };
+}
+function place(angle) {
+ // Полотно: центр = петля + повёрнутый локальный вектор (0, +halfW).
+ const pc = rotY(0, halfW, angle);
+ const cx = hingeX + pc.x;
+ const cz = hingeZ + pc.z;
+ game.self.move(cx, p0.y, cz);
+ game.self.rotate(angle);
+ // Декор: центр полотна + повёрнутое локальное смещение (той же формулой).
+ for (const d of decor) {
+ const r = rotY(d.dx, d.dz, angle);
+ d.obj.move(cx + r.x, p0.y + d.dy, cz + r.z);
+ if (d.obj.rotate) d.obj.rotate(angle);
+ }
+}
+
+// Один постоянный тик плавно ведёт cur → target.
+game.onTick((dt) => {
+ if (cur === target) return;
+ const step = SPEED * dt;
+ if (Math.abs(target - cur) <= step) cur = target;
+ else cur += Math.sign(target - cur) * step;
+ place(cur);
+});
+game.self.onInteract(() => {
+ open = !open;
+ target = open ? Math.PI / 2 : 0; // 90° открыта / 0° закрыта
+}, { text: 'Открыть / закрыть', key: 'e', distance: 5 });` }],
+ },
+
+ // ===== Партия 2 из Вики (киты 18-22) =====
+
+ {
+ id: 'color-tiles',
+ name: 'Цветная плитка',
+ desc: 'Наступи на плитку — она меняет цвет на случайный. (Вики: «Цветные плитки»)',
+ icon: 'palette', category: 'world',
+ prims: [{ type: 'cube', x: 0, y: 0.1, z: 0, sx: 2.5, sy: 0.2, sz: 2.5, color: '#cfd8dc', material: 'matte', name: 'Цветная плитка' }],
+ scripts: [{ attachTo: 'on-target', code:
+`// Плитка меняет цвет при касании.
+const colors = ['#ff5ab0','#ffd23a','#4d6bff','#36d57a','#ff7a3a','#a05aff'];
+let i = 0;
+game.self.onTouch(() => {
+ i = (i + 1) % colors.length;
+ game.self.setColor(colors[i]);
+});` }],
+ },
+ {
+ id: 'lava-floor',
+ name: 'Лава (урон по касанию)',
+ desc: 'Раскалённая плита: наступишь — теряешь здоровье каждую секунду, пока стоишь. (Вики: «Лава-пол»)',
+ icon: 'lava', category: 'world',
+ prims: [{ type: 'cube', x: 0, y: 0.1, z: 0, sx: 5, sy: 0.2, sz: 5, color: '#ff4422', material: 'neon', name: 'Лава' }],
+ scripts: [{ attachTo: 'on-target', code:
+`// Лава: пока игрок на плите — урон каждую секунду.
+let onLava = false, timer = null;
+game.self.onTouch(() => {
+ if (onLava) return; onLava = true;
+ const tick = () => { if (!onLava) return; game.player.damage(15);
+ game.ui.set('lava', '🔥 Горячо! -15 HP', { x: 50, y: 80, anchor: 'bottom', color: '#ff6644', size: 18 });
+ timer = game.after(1, tick); };
+ tick();
+});
+game.self.onUntouch(() => { onLava = false; if (timer) game.cancel(timer); game.ui.set('lava', ''); });` }],
+ },
+ {
+ id: 'elevator',
+ name: 'Лифт',
+ desc: 'Платформа-лифт сама ездит вверх-вниз между двумя этажами. Встань на неё и катайся. (Вики: «Лифт»)',
+ icon: 'elevator', category: 'world',
+ prims: [{ type: 'cube', x: 0, y: 0.5, z: 0, sx: 4, sy: 0.5, sz: 4, color: '#7a8a9a', material: 'metal', name: 'Лифт' }],
+ scripts: [{ attachTo: 'on-target', code:
+`// Лифт: плавно ездит между нижней и верхней высотой.
+const p0 = game.self.position;
+const lowY = p0.y, highY = p0.y + 8;
+let t = 0;
+game.onTick((dt) => {
+ t += dt;
+ // Синусоида 0..1 с паузами на концах (период ~8с).
+ const k = (Math.sin(t * 0.5 - Math.PI/2) + 1) / 2;
+ game.self.move(p0.x, lowY + (highY - lowY) * k, p0.z);
+});` }],
+ },
+ {
+ id: 'finish-line',
+ name: 'Финиш (победа)',
+ desc: 'Финишная плита: дойди до неё — на экране «ПОБЕДА!» и управление блокируется. (Вики: «Беги к финишу»)',
+ icon: 'flag', category: 'ui',
+ prims: [{ type: 'cube', x: 0, y: 0.15, z: 0, sx: 4, sy: 0.3, sz: 2, color: '#ffd23a', material: 'neon', name: 'Финиш', canCollide: false }],
+ scripts: [{ attachTo: 'on-target', code:
+`// Финиш: касание → экран победы.
+let done = false;
+game.self.onTouch(() => {
+ if (done) return; done = true;
+ game.ui.set('win', '🏆 ПОБЕДА!', { x: 50, y: 42, anchor: 'center', color: '#ffd23a', size: 48 });
+ game.ui.set('winsub', 'Ты дошёл до финиша!', { x: 50, y: 54, anchor: 'center', color: '#fff', size: 22 });
+ game.player.setInputBlocked(true);
+});` }],
+ },
+ {
+ id: 'sound-tile',
+ name: 'Звуковая плитка',
+ desc: 'Наступи на плитку — играет звук. Из таких можно собрать мелодию. (Вики: «Эхо-комната»)',
+ icon: 'sound', category: 'fx',
+ prims: [{ type: 'cube', x: 0, y: 0.1, z: 0, sx: 2, sy: 0.2, sz: 2, color: '#6f8bff', material: 'neon', name: 'Звуковая плитка' }],
+ scripts: [{ attachTo: 'on-target', code:
+`// Плитка играет звук при касании + подсвечивается.
+let cd = false;
+game.self.onTouch(() => {
+ if (cd) return; cd = true;
+ game.sound.play('coin');
+ game.self.setColor('#ffffff');
+ game.after(0.25, () => { game.self.setColor('#6f8bff'); cd = false; });
+});` }],
+ },
+
+ // ===== Партия 3 из Вики (остальные механики) =====
+
+ // --- Мир ---
+ {
+ id: 'damage-zone',
+ name: 'Зона опасности',
+ desc: 'Невидимая зона: пока игрок внутри — теряет здоровье. (Вики: «Зона опасности»)',
+ icon: 'warning', category: 'world',
+ prims: [{ type: 'cube', x: 0, y: 2, z: 0, sx: 6, sy: 4, sz: 6, color: '#ff3344', material: 'glass', canCollide: false, name: 'Зона опасности' }],
+ scripts: [{ attachTo: 'on-target', code:
+`// Зона урона: внутри — 10 HP/сек.
+let inside = false, t = null;
+game.self.onTouch(() => { if (inside) return; inside = true;
+ const tick = () => { if (!inside) return; game.player.damage(10);
+ game.ui.set('dz', '☠ Опасно! -10 HP', { x:50, y:78, anchor:'bottom', color:'#ff5555', size:18 });
+ t = game.after(1, tick); }; tick(); });
+game.self.onUntouch(() => { inside = false; if (t) game.cancel(t); game.ui.set('dz',''); });` }],
+ },
+ {
+ id: 'spikes-trap',
+ name: 'Шипы-ловушка',
+ desc: 'Ряд острых шипов: наступишь — мгновенный урон. (Вики: «Полоса препятствий»)',
+ icon: 'warning', category: 'world',
+ prims: [
+ { type: 'cube', x: 0, y: 0.1, z: 0, sx: 4, sy: 0.2, sz: 1.5, color: '#555', material: 'metal', name: 'Основание шипов' },
+ { type: 'cone', x: -1.2, y: 0.7, z: 0, sx: 0.6, sy: 1.2, sz: 0.6, color: '#cccccc', material: 'metal', canCollide: false, name: 'Шип 1' },
+ { type: 'cone', x: 0, y: 0.7, z: 0, sx: 0.6, sy: 1.2, sz: 0.6, color: '#cccccc', material: 'metal', canCollide: false, name: 'Шип 2' },
+ { type: 'cone', x: 1.2, y: 0.7, z: 0, sx: 0.6, sy: 1.2, sz: 0.6, color: '#cccccc', material: 'metal', canCollide: false, name: 'Шип 3' },
+ ],
+ scripts: [{ attachTo: 'on-target', code:
+`// Шипы: касание → урон + отброс.
+let cd = false;
+game.self.onTouch(() => { if (cd) return; cd = true;
+ game.player.damage(34);
+ game.ui.set('sp', '🗡 Ой! -34 HP', { x:50, y:78, anchor:'bottom', color:'#ff5555', size:18 });
+ game.after(1.2, () => { game.ui.set('sp',''); cd = false; }); });` }],
+ },
+ {
+ id: 'traffic-light',
+ name: 'Светофор',
+ desc: 'Светофор переключает красный/жёлтый/зелёный по таймеру. (Вики: «Светофор»)',
+ icon: 'light', category: 'world',
+ prims: [
+ { type: 'cube', x: 0, y: 3, z: 0, sx: 1.2, sy: 4, sz: 1.2, color: '#2a2a2a', material: 'matte', name: 'Корпус светофора' },
+ { type: 'sphere', x: 0, y: 4.2, z: 0.6, sx: 0.8, sy: 0.8, sz: 0.4, color: '#5a0000', material: 'neon', canCollide: false, name: 'Красный' },
+ { type: 'sphere', x: 0, y: 3.3, z: 0.6, sx: 0.8, sy: 0.8, sz: 0.4, color: '#5a5a00', material: 'neon', canCollide: false, name: 'Жёлтый' },
+ { type: 'sphere', x: 0, y: 2.4, z: 0.6, sx: 0.8, sy: 0.8, sz: 0.4, color: '#005a00', material: 'neon', canCollide: false, name: 'Зелёный' },
+ ],
+ scripts: [{ attachTo: 'on-target', code:
+`// Светофор: переключение фаз каждые 2 сек.
+const R = game.scene.findOne('Красный'), Y = game.scene.findOne('Жёлтый'), G = game.scene.findOne('Зелёный');
+const phases = [['#ff0000','#5a5a00','#005a00'], ['#5a0000','#ffcc00','#005a00'], ['#5a0000','#5a5a00','#00ff00']];
+let p = 0;
+function show(){ const c = phases[p]; if(R) R.color = c[0]; if(Y) Y.color = c[1]; if(G) G.color = c[2]; }
+show();
+game.every(2, () => { p = (p+1) % phases.length; show(); });` }],
+ },
+ {
+ id: 'harvest-plant',
+ name: 'Грядка с урожаем',
+ desc: 'Растение растёт, по клику собираешь урожай (+10 монет) и оно вырастает заново. (Вики: «Сбор урожая»)',
+ icon: 'plant', category: 'world',
+ prims: [
+ { type: 'cube', x: 0, y: 0.2, z: 0, sx: 2, sy: 0.4, sz: 2, color: '#6b4a2e', material: 'matte', name: 'Грядка' },
+ { type: 'sphere', x: 0, y: 0.8, z: 0, sx: 1, sy: 1, sz: 1, color: '#3f9a48', material: 'matte', name: 'Урожай' },
+ ],
+ scripts: [{ attachTo: 'on-target', code:
+`// Грядка: собрал урожай → он исчезает, растёт заново (цвет красный→зелёный),
+// при полном размере снова созрел и его можно собрать.
+const plant = game.scene.findOne('Урожай');
+let ripe = true; // созрел ли (можно собирать)
+let growth = 1; // 0..1 — степень роста
+// Плавная интерполяция цвета красный(незрелый)→зелёный(спелый).
+function colorAt(g) {
+ const r = Math.round(0xb0 + (0x3f - 0xb0) * g);
+ const gr = Math.round(0x40 + (0x9a - 0x40) * g);
+ const b = Math.round(0x2e + (0x48 - 0x2e) * g);
+ return '#' + [r,gr,b].map(v => v.toString(16).padStart(2,'0')).join('');
+}
+game.self.onInteract(() => {
+ if (!ripe) { game.ui.set('h','Ещё не созрело...', {x:50,y:80,anchor:'bottom',color:'#bbb',size:16}); return; }
+ ripe = false;
+ growth = 0;
+ game.broadcast('coins', { add: 10 });
+ if (plant) { plant.scale = 0.01; plant.color = colorAt(0); }
+ game.ui.set('h','🌾 Собрано! +10 монет', {x:50,y:80,anchor:'bottom',color:'#ffd23a',size:18});
+ game.after(2, () => game.ui.set('h',''));
+}, { text: 'Собрать урожай', key: 'e', distance: 4 });
+// Рост: за ~5 секунд от 0 до 1.
+game.onTick((dt) => {
+ if (ripe || !plant) return;
+ growth = Math.min(1, growth + dt / 5);
+ plant.scale = Math.max(0.05, growth);
+ plant.color = colorAt(growth);
+ if (growth >= 1) ripe = true;
+});` }],
+ },
+ {
+ id: 'falling-objects',
+ name: 'Падающие предметы',
+ desc: 'Из этой точки с неба сыплются кубики — лови их или уворачивайся. (Вики: «Поймай падающее»)',
+ icon: 'box', category: 'world',
+ prims: [{ type: 'sphere', x: 0, y: 8, z: 0, sx: 0.6, sy: 0.6, sz: 0.6, color: '#4d6bff', material: 'neon', canCollide: false, name: 'Тучка-источник' }],
+ scripts: [{ attachTo: 'on-target', code:
+`// Каждые 1.5с роняет куб из позиции источника.
+const p = game.self.position;
+game.every(1.5, () => {
+ game.scene.spawn('primitive:cube', {
+ x: p.x + (Math.random()-0.5)*6, y: p.y, z: p.z + (Math.random()-0.5)*6,
+ sx: 0.6, sy: 0.6, sz: 0.6, color: '#ffaa33', anchored: false, canCollide: true, lifetime: 6 });
+});` }],
+ },
+
+ // --- Интерфейс ---
+ {
+ id: 'score-counter',
+ name: 'Счётчик очков',
+ desc: 'Счёт очков в HUD. Другие механики шлют game.broadcast("score",{add:N}). (Вики: «Собери монетки»)',
+ icon: 'star', category: 'ui',
+ scripts: [{ attachTo: 'global', code:
+`// Счётчик очков. Прибавить: game.broadcast('score', { add: 1 });
+let score = 0;
+function show(){ game.ui.set('score', '⭐ ' + score, { x:8, y:6, anchor:'top', color:'#ffd23a', size:22 }); }
+show();
+game.onMessage('score', (m) => { score += (m && m.add) ? m.add : 1; show(); });` }],
+ },
+ {
+ id: 'leaderboard',
+ name: 'Таблица лидеров',
+ desc: 'Лидерборд справа-сверху (Очки/Время). Растёт от монет и очков других механик. Сохраняется в БД между сессиями. (Задача 20)',
+ icon: 'trophy', category: 'ui',
+ scripts: [{ attachTo: 'global', code:
+`// Таблица лидеров: столбцы «Очки» (primary) и «Время».
+game.leaderstats.define('Очки', { initial: 0, format: 'number', icon: '⭐', color: '#ffd23a', primary: true });
+game.leaderstats.define('Время', { initial: 0, format: 'time', icon: '⏱', color: '#7fd0ff' });
+// Время идёт само.
+game.every(1, () => game.leaderstats.me.add('Время', 1));
+// Любая механика, шлющая broadcast('score',{add}) или ('coins',{add}),
+// автоматически добавляет очки в таблицу.
+game.onMessage('score', (m) => game.leaderstats.me.add('Очки', (m && m.add) ? m.add : 1));
+game.onMessage('coins', (m) => game.leaderstats.me.add('Очки', (m && m.add) ? m.add : 1));` }],
+ },
+ {
+ id: 'hp-bar',
+ name: 'Полоска здоровья',
+ desc: 'Показывает HP игрока в углу экрана, обновляется при уроне/лечении. (Вики: «Лава-пол»)',
+ icon: 'warning', category: 'ui',
+ scripts: [{ attachTo: 'global', code:
+`// Своя полоска HP. Сначала прячем стандартную, чтобы не дублировалась.
+game.hud.setHpVisible(false);
+function show(){ const hp = Math.max(0, Math.round(game.player.hp));
+ game.ui.set('hp', '❤ ' + hp + ' / 100', { x:50, y:94, anchor:'bottom', color: hp>30?'#36d57a':'#ff4444', size:22 }); }
+show();
+game.every(0.2, show);` }],
+ },
+ {
+ id: 'hide-default-hp',
+ name: 'Скрыть стандартный HUD HP',
+ desc: 'Прячет стандартную полосу здоровья сверху — чтобы показать свою. (Свойство игрока game.hud.setHpVisible)',
+ icon: 'warning', category: 'ui',
+ scripts: [{ attachTo: 'global', code:
+`// Скрыть стандартную полосу здоровья игрока.
+game.hud.setHpVisible(false);` }],
+ },
+ {
+ id: 'code-door',
+ name: 'Дверь по коду',
+ desc: 'Красивая дверь с кодовой панелью: подойди — появится поле ввода, введи код (1234) — откроется. (Вики: «Дверь по коду»)',
+ icon: 'keypad', category: 'world',
+ // Красивая дверь (полотно + рамка) + кодовая панель на стене.
+ prims: [
+ { type: 'cube', x: 0, y: 2, z: 0, sx: 0.25, sy: 3.8, sz: 2.6, color: '#5a6478', material: 'metal', name: 'Полотно двери-код' },
+ { type: 'cube', x: 0.16, y: 2.6, z: 0, sx: 0.06, sy: 0.9, sz: 1.8, color: '#79879a', material: 'metal', canCollide: false, name: 'Панель двери' },
+ { type: 'cube', x: 0.16, y: 1.3, z: 0, sx: 0.06, sy: 0.9, sz: 1.8, color: '#79879a', material: 'metal', canCollide: false, name: 'Панель двери низ' },
+ { type: 'cube', x: 0.3, y: 2, z: 0.95, sx: 0.15, sy: 0.6, sz: 0.5, color: '#ffd23a', material: 'neon', canCollide: false, name: 'Кодовая панель' },
+ { type: 'cube', x: 0, y: 2, z: -1.55, sx: 0.4, sy: 4.2, sz: 0.4, color: '#3a4250', material: 'metal', name: 'Косяк левый' },
+ { type: 'cube', x: 0, y: 2, z: 1.55, sx: 0.4, sy: 4.2, sz: 0.4, color: '#3a4250', material: 'metal', name: 'Косяк правый' },
+ { type: 'cube', x: 0, y: 4.1, z: 0, sx: 0.4, sy: 0.4, sz: 3.5, color: '#3a4250', material: 'metal', name: 'Перемычка' },
+ ],
+ scripts: [{ attachTo: 'on-target', code:
+`// Дверь по коду 1234. Поле ввода появляется ТОЛЬКО рядом. Верный код →
+// дверь ПЛАВНО распахивается вокруг петли (вместе с панелями).
+const CODE = '1234';
+const p0 = game.self.position;
+const halfW = 1.3; // полуширина полотна (sz=2.6)
+const hingeX = p0.x, hingeZ = p0.z - halfW;
+const RADIUS = 6;
+let opened = false, near = false, cur = 0, target = 0;
+const SPEED = Math.PI; // ~0.5с на 90°
+
+// Декор полотна — двигается вместе с дверью.
+const decorNames = ['Панель двери', 'Панель двери низ', 'Кодовая панель'];
+const decor = [];
+for (const nm of decorNames) {
+ const o = game.scene.findOne(nm);
+ if (o && o.position) decor.push({ obj:o, dx:o.position.x-p0.x, dy:o.position.y-p0.y, dz:o.position.z-p0.z });
+}
+function rotY(lx, lz, a){ const s=Math.sin(a),c=Math.cos(a); return { x: lx*c+lz*s, z: -lx*s+lz*c }; }
+function place(a){
+ const pc = rotY(0, halfW, a);
+ const cx = hingeX + pc.x, cz = hingeZ + pc.z;
+ game.self.move(cx, p0.y, cz); game.self.rotate(a);
+ for (const d of decor){ const r = rotY(d.dx, d.dz, a); d.obj.move(cx+r.x, p0.y+d.dy, cz+r.z); if (d.obj.rotate) d.obj.rotate(a); }
+}
+game.onTick((dt) => {
+ if (cur !== target){ const st=SPEED*dt; cur = Math.abs(target-cur)<=st ? target : cur+Math.sign(target-cur)*st; place(cur); }
+ const pl = game.player.position;
+ const d = Math.sqrt((pl.x-p0.x)**2 + (pl.z-p0.z)**2);
+ if (opened){
+ // Дверь открыта: подсказка «E закрыть» только когда игрок рядом.
+ if (d < RADIUS && !near){ near = true; game.ui.set('codehint','Нажми E чтобы закрыть дверь', {x:50,y:80,anchor:'bottom',color:'#fff',size:16}); }
+ else if (d >= RADIUS && near){ near = false; game.ui.set('codehint','',{}); }
+ return;
+ }
+ // Дверь закрыта: поле ввода кода по дистанции.
+ if (d < RADIUS && !near){ near = true;
+ game.gui.create('textbox', { id:'codein', x:50, y:86, w:24, h:8, anchor:'center', placeholder:'Код...', textSize:20 });
+ game.ui.set('codehint', '🔢 Введи код двери (1234) и нажми Enter', {x:50,y:78,anchor:'bottom',color:'#fff',size:16}); }
+ else if (d >= RADIUS && near){ near = false; game.gui.remove('codein'); game.ui.set('codehint','',{}); }
+});
+game.gui.onSubmit('codein', (text) => {
+ if (opened) return;
+ if (String(text).trim() === CODE){
+ opened = true; near = false; target = Math.PI/2; // плавно распахнуть
+ game.gui.remove('codein');
+ game.ui.set('codehint','✓ Открыто!', {x:50,y:78,anchor:'bottom',color:'#36d57a',size:18});
+ game.after(2, () => game.ui.set('codehint','',{}));
+ } else game.ui.set('codehint','✗ Неверный код', {x:50,y:78,anchor:'bottom',color:'#ff5555',size:18});
+});
+// Закрыть дверь по E (только если открыта и игрок рядом).
+game.onKey('e', () => {
+ if (!opened) return;
+ const pl = game.player.position;
+ if (Math.sqrt((pl.x-p0.x)**2 + (pl.z-p0.z)**2) >= RADIUS) return;
+ opened = false; near = false; target = 0;
+ game.ui.set('codehint','🔒 Дверь закрыта.', {x:50,y:80,anchor:'bottom',color:'#fff',size:16});
+ game.after(1.5, () => game.ui.set('codehint','',{}));
+});` }],
+ },
+ {
+ id: 'name-label',
+ name: 'Метка с именем',
+ desc: 'Над объектом висит табличка с текстом (имя/HP). (Вики: «Имена над врагами»)',
+ icon: 'tag', category: 'ui',
+ prims: [{ type: 'cube', x: 0, y: 1, z: 0, sx: 1.5, sy: 2, sz: 1.5, color: '#c83030', material: 'matte', name: 'Объект с меткой' }],
+ scripts: [{ attachTo: 'on-target', code:
+`// Метка-табличка над объектом.
+game.self.setLabel('Враг ❤ 100', { color: '#ffffff', bg: '#c83030' });` }],
+ },
+ {
+ id: 'countdown',
+ name: 'Обратный отсчёт',
+ desc: 'Таймер обратного отсчёта в HUD. По нулю — событие (тут просто сообщение). (Вики: «продержись N секунд»)',
+ icon: 'clock', category: 'ui',
+ scripts: [{ attachTo: 'global', code:
+`// Обратный отсчёт 30 секунд.
+let left = 30;
+game.ui.set('cd', '⏳ ' + left, { x:50, y:6, anchor:'top', color:'#fff', size:26 });
+const id = game.every(1, () => {
+ left--; game.ui.set('cd', '⏳ ' + left, { x:50, y:6, anchor:'top', color: left<=5?'#ff4444':'#fff', size:26 });
+ if (left <= 0) { game.cancel(id); game.ui.set('cd', '⏰ Время вышло!', { x:50, y:42, anchor:'center', color:'#ff4444', size:40 }); }
+});` }],
+ },
+
+ // --- Эффекты ---
+ {
+ id: 'fire-emitter',
+ name: 'Костёр (огонь)',
+ desc: 'Источник частиц огня — горит постоянно. (Палитра эффектов)',
+ icon: 'sparkles', category: 'fx',
+ prims: [{ type: 'cylinder', x: 0, y: 0.2, z: 0, sx: 1.2, sy: 0.4, sz: 1.2, color: '#3a2a1a', material: 'matte', name: 'Костёр' }],
+ scripts: [{ attachTo: 'on-target', code:
+`// Постоянный огонь из точки костра.
+const p = game.self.position;
+function fire(){ game.scene.spawnParticles('fire', { x:p.x, y:p.y+0.5, z:p.z }, { duration: 1.5, count: 40 }); }
+fire(); game.every(1.2, fire);` }],
+ },
+ {
+ id: 'magnet-coins',
+ name: 'Магнит монет',
+ desc: 'Монета сама летит к игроку, когда он подходит близко. (Вики: «Магнит монет»)',
+ icon: 'circle', category: 'fx',
+ prims: [{ type: 'cylinder', x: 0, y: 1, z: 0, sx: 0.8, sy: 0.2, sz: 0.8, color: '#ffd23a', material: 'metal', name: 'Монета' }],
+ scripts: [{ attachTo: 'on-target', code:
+`// Монета летит к игроку, если он ближе 6 м; коснулся — +1 монета.
+let taken = false;
+game.onTick(() => {
+ if (taken) return;
+ const me = game.self.position, pl = game.player.position;
+ const dx = pl.x-me.x, dy = pl.y-me.y, dz = pl.z-me.z;
+ const d = Math.sqrt(dx*dx+dy*dy+dz*dz);
+ if (d < 1.2) { taken = true; game.broadcast('coins', { add: 1 }); game.self.setVisible(false); return; }
+ if (d < 6) { game.self.move(me.x+dx*0.12, me.y+dy*0.12, me.z+dz*0.12); }
+});` }],
+ },
+
+ // --- NPC и бой ---
+ {
+ id: 'npc-chaser',
+ name: 'NPC-преследователь',
+ desc: 'Враг бежит за игроком по всему уровню. (Вики: «Преследователь»)',
+ icon: 'chase', category: 'npc',
+ scripts: [{ attachTo: 'global', code:
+`// Спавним NPC, который преследует игрока.
+const enemy = game.scene.spawnNpc('skin_roblox-noob', { x: 8, z: 8, name: 'Охотник', speed: 4 });
+if (enemy && enemy.follow) enemy.follow('player');` }],
+ },
+ {
+ id: 'npc-trader',
+ name: 'Торговец (NPC)',
+ desc: 'NPC-персонаж торговец: подойди, нажми E — открывается диалог. (Вики: «Торговец»)',
+ icon: 'trader', category: 'npc',
+ // Невидимый prim-триггер держит onInteract; рядом спавнится NPC-персонаж.
+ prims: [{ type: 'cube', x: 0, y: 1.5, z: 0, sx: 2, sy: 3, sz: 2, color: '#3a6ea5', material: 'matte', visible: false, canCollide: false, name: 'Зона торговца' }],
+ scripts: [{ attachTo: 'on-target', code:
+`// Торговец — настоящий NPC-персонаж. Триггер (этот объект) держит диалог по E.
+const p = game.self.position;
+const npc = game.scene.spawnNpc('skin_roblox-noob', { x: p.x, z: p.z, name: 'Торговец Боб' });
+game.self.onInteract(() => {
+ game.modal.dialog('Торговец Боб', [
+ 'Привет, путник! Заходи за товарами.',
+ 'У меня лучшие мечи во всём королевстве!',
+ 'Возвращайся, когда накопишь монет.',
+ ]);
+}, { text: 'Поговорить', key: 'e', distance: 4 });` }],
+ },
+ {
+ id: 'shooting-target',
+ name: 'Мишень для стрельбы',
+ desc: 'Кликни по мишени — +10 очков, мишень исчезает и появляется снова. (Вики: «Тир»)',
+ icon: 'crosshair', category: 'npc',
+ prims: [
+ { type: 'cylinder', x: 0, y: 2, z: 0, sx: 0.3, sy: 2, sz: 2, color: '#ffffff', material: 'matte', name: 'Мишень' },
+ { type: 'cylinder', x: 0.2, y: 2, z: 0, sx: 0.1, sy: 1.2, sz: 1.2, color: '#ff3333', material: 'matte', canCollide: false, name: 'Кольцо мишени' },
+ ],
+ scripts: [{ attachTo: 'on-target', code:
+`// Мишень: клик → +10 очков, прячется на 1.5с.
+let active = true;
+game.self.onClick(() => {
+ if (!active) return; active = false;
+ game.broadcast('score', { add: 10 });
+ game.self.setVisible(false);
+ game.ui.set('hit', '🎯 +10!', { x:50, y:75, anchor:'bottom', color:'#36d57a', size:20 });
+ game.after(1.5, () => { game.self.setVisible(true); active = true; game.ui.set('hit',''); });
+});` }],
+ },
+ {
+ id: 'enemy-hp',
+ name: 'Враг с HP',
+ desc: 'Враг-персонаж: преследует игрока, бьёт при касании. Над головой — полоска здоровья. (Вики: «босс», «имена над врагами»)',
+ icon: 'boss', category: 'npc',
+ // Невидимый триггер-якорь; рядом спавнится NPC-враг.
+ prims: [{ type: 'cube', x: 0, y: 1, z: 0, sx: 1, sy: 2, sz: 1, color: '#7a2030', material: 'matte', visible: false, canCollide: false, name: 'Якорь врага' }],
+ scripts: [{ attachTo: 'on-target', code:
+`// Враг-персонаж: преследует игрока, бьёт с анимацией удара при сближении.
+const p = game.self.position;
+const enemy = game.scene.spawnNpc('skin_retro-zombie', { x: p.x, z: p.z, name: 'Враг', hp: 100, speed: 3.5 });
+if (enemy && enemy.follow) enemy.follow('player');
+let cd = 0, atk = false;
+game.onTick((dt) => {
+ if (!enemy || !enemy.position) return;
+ cd -= dt;
+ const pl = game.player.position, e = enemy.position;
+ const d = Math.sqrt((pl.x-e.x)**2 + (pl.z-e.z)**2);
+ const inRange = d < 3.5;
+ if (inRange !== atk) { atk = inRange; enemy.setAttacking && enemy.setAttacking(inRange); }
+ if (inRange && cd <= 0) { game.player.damage(10); cd = 1; } // удар раз в секунду
+});` }],
+ },
+ {
+ id: 'enemy-wave',
+ name: 'Волна врагов',
+ desc: 'Спавнер: выпускает врагов волнами по таймеру. (Вики: «Выживание от волн», tower defense)',
+ icon: 'zombie', category: 'npc',
+ prims: [{ type: 'cylinder', x: 0, y: 0.15, z: 0, sx: 2, sy: 0.3, sz: 2, color: '#7a2030', material: 'neon', name: 'Портал врагов' }],
+ scripts: [{ attachTo: 'on-target', code:
+`// Каждые 5с спавнит 2 врагов из портала, они идут к игроку И бьют при касании.
+const p = game.self.position;
+const enemies = [];
+function wave(){
+ for (let i=0;i<2;i++){ const e = game.scene.spawnNpc('skin_retro-zombie', { x:p.x+(Math.random()-0.5)*2, z:p.z+(Math.random()-0.5)*2, name:'Враг', hp:60, speed:3 });
+ if (e){ if (e.follow) e.follow('player'); enemies.push({ npc:e, cd:0 }); } }
+}
+game.after(2, wave); game.every(5, wave);
+// Урон + анимация удара при сближении (у каждого врага свой кулдаун).
+game.onTick((dt) => {
+ const pl = game.player.position;
+ for (const en of enemies){
+ if (!en.npc || !en.npc.position) continue;
+ en.cd -= dt;
+ const e = en.npc.position;
+ const d = Math.sqrt((pl.x-e.x)**2 + (pl.z-e.z)**2);
+ const inRange = d < 3.5;
+ if (inRange !== en.atk){ en.atk = inRange; en.npc.setAttacking && en.npc.setAttacking(inRange); }
+ if (inRange && en.cd <= 0){ game.player.damage(8); en.cd = 1; }
+ }
+});` }],
+ },
+
+ // --- Экономика ---
+ {
+ id: 'shop-button',
+ name: 'Магазин (кнопка покупки)',
+ desc: 'GUI-кнопка магазина: покупка предмета за 50 монет (если хватает). (Вики: «Магазин»)',
+ icon: 'cart', category: 'economy',
+ scripts: [{ attachTo: 'global', code:
+`// Кнопка магазина: купить за 50 монет.
+let coins = 100; // локальный баланс кита (для демо)
+game.ui.set('bal', '🪙 ' + coins, { x:92, y:6, anchor:'top', color:'#ffd23a', size:22 });
+game.onMessage('coins', (m) => { coins += (m&&m.add)?m.add:0; game.ui.set('bal','🪙 '+coins,{x:92,y:6,anchor:'top',color:'#ffd23a',size:22}); });
+game.gui.create('button', { id:'buybtn', x:50, y:90, w:26, h:9, anchor:'center', text:'Купить меч — 50 🪙',
+ bgGradient:{ stops:['#ffe066','#e0a000'], angle:90 }, textColor:'#3a2a00', textSize:18, fontWeight:800, borderRadius:12 });
+game.gui.onClick('buybtn', () => {
+ if (coins >= 50) { game.broadcast('coins', { add: -50 }); game.ui.set('shopmsg','✓ Куплено!',{x:50,y:80,anchor:'bottom',color:'#36d57a',size:18}); }
+ else game.ui.set('shopmsg','✗ Мало монет',{x:50,y:80,anchor:'bottom',color:'#ff5555',size:18});
+ game.after(2, () => game.ui.set('shopmsg',''));
+});` }],
+ },
+ {
+ id: 'clicker-button',
+ name: 'Кликер',
+ desc: 'GUI-кнопка: кликай и копи очки. (Вики: «Кликер»)',
+ icon: 'click', category: 'economy',
+ scripts: [{ attachTo: 'global', code:
+`// Кликер: кнопка по центру, клик → +1 очко.
+let n = 0;
+function show(){ game.ui.set('clk', '👆 ' + n, { x:50, y:20, anchor:'center', color:'#fff', size:36 }); }
+show();
+game.gui.create('button', { id:'clickbtn', x:50, y:55, w:30, h:14, anchor:'center', text:'КЛИК!',
+ bgGradient:{ stops:['#6f8bff','#3a4ed0'], angle:90 }, textColor:'#fff', textSize:32, fontWeight:900, borderRadius:18,
+ hover:{ scale:1.05 }, active:{ scale:0.94 } });
+game.gui.onClick('clickbtn', () => { n++; show(); });` }],
+ },
+ {
+ id: 'key-lock',
+ name: 'Ключ и замок',
+ desc: 'Найди золотой ключ, подбери — и дверь рядом плавно откроется по E. Без ключа заперта. (Вики: «Ключ и сундук»)',
+ icon: 'key', category: 'economy',
+ prims: [
+ // Ключ из примитивов: стержень + бородка + кольцо (torus). ПЕРВЫЙ — скрипт на нём.
+ { type: 'cylinder', x: 0, y: 1, z: 0, sx: 0.12, sy: 1.0, sz: 0.12, color: '#ffd23a', material: 'metal', name: 'Ключ' },
+ { type: 'torus', x: 0, y: 1.6, z: 0, sx: 0.5, sy: 0.5, sz: 0.5, color: '#ffd23a', material: 'metal', canCollide: false, name: 'Кольцо ключа' },
+ { type: 'cube', x: 0.18, y: 0.6, z: 0, sx: 0.3, sy: 0.12, sz: 0.12, color: '#ffd23a', material: 'metal', canCollide: false, name: 'Бородка ключа' },
+ { type: 'cube', x: 0.18, y: 0.4, z: 0, sx: 0.2, sy: 0.12, sz: 0.12, color: '#ffd23a', material: 'metal', canCollide: false, name: 'Бородка ключа 2' },
+ // Красивая дверь (полотно + рамка) на расстоянии.
+ { type: 'cube', x: 6, y: 2, z: 0, sx: 0.25, sy: 3.8, sz: 2.6, color: '#6b4423', material: 'matte', name: 'Запертая дверь' },
+ { type: 'cube', x: 6, y: 2, z: -1.55, sx: 0.4, sy: 4.2, sz: 0.4, color: '#4a3018', material: 'matte', name: 'Косяк левый замка' },
+ { type: 'cube', x: 6, y: 2, z: 1.55, sx: 0.4, sy: 4.2, sz: 0.4, color: '#4a3018', material: 'matte', name: 'Косяк правый замка' },
+ { type: 'cube', x: 6, y: 4.1, z: 0, sx: 0.4, sy: 0.4, sz: 3.5, color: '#4a3018', material: 'matte', name: 'Перемычка замка' },
+ ],
+ scripts: [{ attachTo: 'on-target', code:
+`// Ключ подбирается касанием. Дверь рядом открывается по E ТОЛЬКО с ключом —
+// плавный поворот вокруг петли (как дверь по кнопке E).
+let hasKey = false;
+const keyParts = ['Ключ','Кольцо ключа','Бородка ключа','Бородка ключа 2'];
+game.self.onTouch(() => {
+ if (hasKey) return; hasKey = true;
+ for (const nm of keyParts){ const o = game.scene.findOne(nm); if (o) o.visible = false; }
+ game.ui.set('key', '🔑 Ключ найден! Иди к двери (E).', {x:50,y:80,anchor:'bottom',color:'#ffd23a',size:18});
+});
+const door = game.scene.findOne('Запертая дверь');
+if (door && door.onInteract){
+ const dp0 = door.position;
+ const halfW = 1.3, hingeZ = dp0.z - halfW;
+ let open = false, cur = 0, target = 0;
+ function rotY(lx,lz,a){ const s=Math.sin(a),c=Math.cos(a); return {x:lx*c+lz*s, z:-lx*s+lz*c}; }
+ game.onTick((dt) => {
+ if (cur===target) return;
+ const st = Math.PI*dt; cur = Math.abs(target-cur)<=st ? target : cur+Math.sign(target-cur)*st;
+ const pc = rotY(0, halfW, cur);
+ door.move(dp0.x+pc.x, dp0.y, hingeZ+pc.z); if (door.rotate) door.rotate(cur);
+ });
+ door.onInteract(() => {
+ if (!hasKey){ game.ui.set('key','🔒 Заперто. Нужен ключ.',{x:50,y:80,anchor:'bottom',color:'#ff5555',size:18}); return; }
+ open = !open; target = open ? Math.PI/2 : 0;
+ game.ui.set('key', open ? '✓ Дверь открыта!' : '🔒 Дверь закрыта.', {x:50,y:80,anchor:'bottom',color:'#36d57a',size:18});
+ game.after(2, () => game.ui.set('key','',{}));
+ }, { text:'Открыть / закрыть', key:'e', distance:4 });
+}` }],
+ },
+
+ // --- Из готовых игр (g5) ---
+ {
+ id: 'spawn-car',
+ name: 'Машина (сядь за руль)',
+ desc: 'Готовый автомобиль: подойди, держи F — садись за руль, WASD — едь. (Вики: «Такси-симулятор»)',
+ icon: 'car', category: 'npc',
+ scripts: [{ attachTo: 'global', code:
+`// Спавн машины, на которой можно ездить.
+game.scene.spawn('vehicle:car', { x: 0, y: 0.5, z: 5, model: 'car-sedan', color: '#c83030',
+ name: 'Авто', params: { maxSpeed: 18, turnSpeed: 1.7, enginePower: 20 } });
+game.ui.set('carhint', 'Подойди к машине и держи F — за руль!', {x:50,y:90,anchor:'bottom',color:'#fff',size:16});` }],
+ },
+ {
+ id: 'cutscene-dialog',
+ name: 'Диалог (кат-сцена)',
+ desc: 'Объект, по взаимодействию показывает диалог по строкам. (Вики: «Тайна старого сундука»)',
+ icon: 'scroll', category: 'npc',
+ prims: [{ type: 'cube', x: 0, y: 0.6, z: 0, sx: 1.4, sy: 1.2, sz: 1, color: '#8a6a3a', material: 'matte', name: 'Рассказчик' }],
+ scripts: [{ attachTo: 'on-target', code:
+`// Диалог по строкам через готовый game.modal.dialog.
+const lines = ['Давным-давно здесь стоял замок...', 'Его охранял древний страж.', 'Найди три ключа, чтобы войти!'];
+game.self.onInteract(() => {
+ game.modal.dialog('Рассказчик', lines);
+}, { text:'Поговорить', key:'e', distance:4 });` }],
+ },
+ {
+ id: 'guide-arrow',
+ name: '3D-стрелка-указатель',
+ desc: 'Стрелка-подсказка «иди сюда» ведёт игрока к цели. (Вики: «Туториал — собери монетки»)',
+ icon: 'flag', category: 'ui',
+ prims: [{ type: 'cylinder', x: 0, y: 1, z: 0, sx: 1, sy: 2, sz: 1, color: '#ffd23a', material: 'neon', name: 'Цель-указатель' }],
+ scripts: [{ attachTo: 'on-target', code:
+`// Стрелка от игрока к этому объекту-цели.
+const arrow = game.fx.pointer({ from: 'player', to: game.self, preset: 'guide' });
+game.self.onTouch(() => { if (arrow && arrow.remove) arrow.remove();
+ game.ui.set('arr','✓ Дошёл!', {x:50,y:80,anchor:'bottom',color:'#36d57a',size:18}); });` }],
+ },
+];
+
+/** Найти кит по id. */
+export function getKit(id) {
+ return GAMEPLAY_KITS.find(k => k.id === id) || null;
+}
diff --git a/src/editor/engine/InventoryUI.js b/src/editor/engine/InventoryUI.js
new file mode 100644
index 0000000..86620cf
--- /dev/null
+++ b/src/editor/engine/InventoryUI.js
@@ -0,0 +1,370 @@
+/**
+ * InventoryUI — drag-drop инвентарь (задача 44): сетка 8×5 + hotbar 9 + стаки +
+ * редкости + ПКМ-меню + tooltip. Самодостаточный DOM-оверлей (как
+ * LoadingScreenOverlay) — крепится к canvas.parentElement, работает в студии и
+ * плеере одинаково.
+ *
+ * Хранит: item-defs (game.items.define), слоты основного инвентаря (GRID),
+ * слоты hotbar (HOTBAR), активный hotbar-слот. Постоянный hotbar внизу HUD;
+ * окно инвентаря по клавише I (toggle).
+ *
+ * API (через game.inventory.* / game.items.*):
+ * game.items.define({id,name,icon,rarity,maxStack,description,value,onUse,tags})
+ * game.inventory.add(itemId, count) / remove / has / count
+ * game.inventory.open() / close() / toggle() / isOpen()
+ * game.inventory.move(from, to) / split(slot, n) / sort(by) / use(slot)
+ * game.inventory.setActiveHotbar(i) / getActiveItem()
+ *
+ * Фича-парность: тот же модуль в rublox-player/src/engine/.
+ */
+
+const GRID = 40; // 8×5 основной инвентарь
+const COLS = 8;
+const HOTBAR = 9;
+
+const RARITY = {
+ common: { color: '#bbbbbb', label: 'Обычное' },
+ uncommon: { color: '#5cb85c', label: 'Необычное' },
+ rare: { color: '#5bc0de', label: 'Редкое' },
+ epic: { color: '#9b59b6', label: 'Эпическое' },
+ legendary: { color: '#f0ad4e', label: 'Легендарное' },
+};
+
+export class InventoryUI {
+ constructor(scene3d) {
+ this.s = scene3d;
+ this.defs = new Map(); // itemId → def
+ this.grid = new Array(GRID).fill(null); // {itemId,count}|null
+ this.hotbar = new Array(HOTBAR).fill(null);
+ this.active = 0;
+ this._open = false;
+ this.root = null; this.hotbarRoot = null; this.tooltip = null; this.ctxMenu = null;
+ this._drag = null; // {from:'grid'|'hotbar', idx}
+ this._onChange = [];
+ this._events = { added: [], removed: [], used: [], slot: [] };
+ this._opts = { allowDrop: true, allowSplit: true, showRarity: true };
+ }
+
+ // ── Определения предметов ───────────────────────────────────────────────
+ defineItem(def) {
+ if (!def || typeof def.id !== 'string') return;
+ this.defs.set(def.id, {
+ id: def.id, name: def.name || def.id,
+ icon: def.icon || null, emoji: def.emoji || null,
+ rarity: RARITY[def.rarity] ? def.rarity : 'common',
+ maxStack: Number(def.maxStack) > 0 ? Number(def.maxStack) : 1,
+ description: def.description || '', value: Number(def.value) || 0,
+ tags: Array.isArray(def.tags) ? def.tags : [],
+ onUseEffect: def.onUseEffect || null, // 'heal:50' | 'speed:1.5:5' | null
+ });
+ }
+ _def(id) { return this.defs.get(id) || { id, name: id, rarity: 'common', maxStack: 99, emoji: '📦', icon: null, description: '', value: 0, tags: [] }; }
+
+ // ── Операции ────────────────────────────────────────────────────────────
+ add(itemId, count = 1) {
+ const def = this._def(itemId);
+ let left = count;
+ // 1) долить в существующие стаки (сначала hotbar — он на виду, потом grid)
+ const fill = (arr) => {
+ for (let i = 0; i < arr.length && left > 0; i++) {
+ const s = arr[i];
+ if (s && s.itemId === itemId && s.count < def.maxStack) {
+ const room = def.maxStack - s.count;
+ const take = Math.min(room, left);
+ s.count += take; left -= take;
+ }
+ }
+ };
+ fill(this.hotbar); fill(this.grid);
+ // 2) в пустые слоты (сначала hotbar — собранное видно сразу, потом grid)
+ const place = (arr) => {
+ for (let i = 0; i < arr.length && left > 0; i++) {
+ if (!arr[i]) { const take = Math.min(def.maxStack, left); arr[i] = { itemId, count: take }; left -= take; }
+ }
+ };
+ place(this.hotbar); place(this.grid);
+ const added = count - left;
+ if (added > 0) { this._emit('added', { itemId, count: added }); this._changed(); }
+ return { added, overflow: left };
+ }
+
+ remove(itemId, count = 1) {
+ let left = count;
+ const drain = (arr) => {
+ for (let i = arr.length - 1; i >= 0 && left > 0; i--) {
+ const s = arr[i];
+ if (s && s.itemId === itemId) {
+ const take = Math.min(s.count, left);
+ s.count -= take; left -= take;
+ if (s.count <= 0) arr[i] = null;
+ }
+ }
+ };
+ drain(this.hotbar); drain(this.grid);
+ const removed = count - left;
+ if (removed > 0) { this._emit('removed', { itemId, count: removed }); this._changed(); }
+ return removed;
+ }
+
+ count(itemId) {
+ let n = 0;
+ for (const s of this.grid) if (s && s.itemId === itemId) n += s.count;
+ for (const s of this.hotbar) if (s && s.itemId === itemId) n += s.count;
+ return n;
+ }
+ has(itemId, n = 1) { return this.count(itemId) >= n; }
+
+ /** slot-ref: число 0..39 = grid; строка 'h0'..'h8' = hotbar. */
+ _arrIdx(ref) {
+ if (typeof ref === 'string' && ref[0] === 'h') return { arr: this.hotbar, idx: parseInt(ref.slice(1), 10) };
+ return { arr: this.grid, idx: Number(ref) };
+ }
+ move(from, to) {
+ const a = this._arrIdx(from), b = this._arrIdx(to);
+ if (!a.arr || !b.arr || a.idx == null || b.idx == null) return;
+ if (a.arr === b.arr && a.idx === b.idx) return;
+ const src = a.arr[a.idx], dst = b.arr[b.idx];
+ // merge одинаковых стаков
+ if (src && dst && src.itemId === dst.itemId) {
+ const def = this._def(src.itemId);
+ const room = def.maxStack - dst.count;
+ if (room > 0) {
+ const take = Math.min(room, src.count);
+ dst.count += take; src.count -= take;
+ if (src.count <= 0) a.arr[a.idx] = null;
+ this._changed(); return;
+ }
+ }
+ // swap
+ a.arr[a.idx] = dst; b.arr[b.idx] = src;
+ this._changed();
+ }
+ split(ref, n) {
+ if (!this._opts.allowSplit) return;
+ const { arr, idx } = this._arrIdx(ref);
+ const s = arr[idx]; if (!s || s.count <= 1) return;
+ const take = Math.max(1, Math.min(s.count - 1, n || Math.floor(s.count / 2)));
+ const empty = this.grid.indexOf(null);
+ if (empty < 0) return;
+ s.count -= take; this.grid[empty] = { itemId: s.itemId, count: take };
+ this._changed();
+ }
+ sort(by = 'rarity') {
+ const order = { legendary: 0, epic: 1, rare: 2, uncommon: 3, common: 4 };
+ const all = this.grid.filter(Boolean);
+ all.sort((x, y) => {
+ const dx = this._def(x.itemId), dy = this._def(y.itemId);
+ if (by === 'rarity') return (order[dx.rarity] - order[dy.rarity]) || dx.name.localeCompare(dy.name);
+ if (by === 'name') return dx.name.localeCompare(dy.name);
+ return dx.id.localeCompare(dy.id);
+ });
+ this.grid = all.concat(new Array(GRID - all.length).fill(null));
+ this._changed();
+ }
+ use(ref) {
+ const { arr, idx } = this._arrIdx(ref);
+ const s = arr[idx]; if (!s) return;
+ const def = this._def(s.itemId);
+ let consume = false;
+ if (def.onUseEffect) {
+ const [eff, a, b] = String(def.onUseEffect).split(':');
+ try {
+ if (eff === 'heal') { this.s?.player?.heal?.(Number(a) || 25); consume = true; }
+ else if (eff === 'speed') { this.s?.player?.setSpeed?.(Number(a) || 1.5); consume = true; }
+ } catch (e) { /* ignore */ }
+ }
+ this._emit('used', { itemId: s.itemId });
+ if (consume) { s.count -= 1; if (s.count <= 0) arr[idx] = null; this._changed(); }
+ }
+ setActiveHotbar(i) { this.active = Math.max(0, Math.min(HOTBAR - 1, i | 0)); this._renderHotbar(); }
+ getActiveItem() { const s = this.hotbar[this.active]; return s ? { ...s, def: this._def(s.itemId) } : null; }
+
+ onChange(fn) { if (typeof fn === 'function') this._onChange.push(fn); }
+ on(evt, fn) { if (this._events[evt] && typeof fn === 'function') this._events[evt].push(fn); }
+ _emit(evt, data) { for (const fn of (this._events[evt] || [])) { try { fn(data); } catch (e) {} } }
+ _changed() {
+ for (const fn of this._onChange) { try { fn(); } catch (e) {} }
+ this._emit('slot', {});
+ if (this._open) this._renderGrid();
+ this._renderHotbar();
+ }
+
+ // ── DOM: hotbar (постоянный) ───────────────────────────────────────────
+ _parent() { return (this.s && this.s.canvas && this.s.canvas.parentElement) || document.body; }
+ mountHotbar() {
+ if (this.hotbarRoot) return;
+ const r = document.createElement('div');
+ r.style.cssText = 'position:absolute;left:50%;bottom:64px;transform:translateX(-50%);z-index:48;display:flex;gap:6px;pointer-events:auto;font-family:Inter,system-ui,sans-serif';
+ this._parent().appendChild(r); this.hotbarRoot = r;
+ this._renderHotbar();
+ }
+ _slotInner(s) {
+ if (!s) return '';
+ const def = this._def(s.itemId);
+ const icon = def.icon ? ` `
+ : `${def.emoji || '📦'} `;
+ const cnt = s.count > 1 ? `${s.count} ` : '';
+ return icon + cnt;
+ }
+ _slotStyle(s, activeBorder) {
+ const rc = (s && this._opts.showRarity) ? RARITY[this._def(s.itemId).rarity].color : 'rgba(255,255,255,0.15)';
+ const border = activeBorder ? '#ffd23a' : rc;
+ return `position:relative;width:52px;height:52px;border-radius:10px;border:2px solid ${border};background:rgba(20,26,40,0.7);display:flex;align-items:center;justify-content:center;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,0.3)` + (activeBorder ? ';box-shadow:0 0 12px #ffd23a' : '');
+ }
+ _renderHotbar() {
+ if (!this.hotbarRoot) return;
+ this.hotbarRoot.innerHTML = '';
+ for (let i = 0; i < HOTBAR; i++) {
+ const s = this.hotbar[i];
+ const cell = document.createElement('div');
+ cell.style.cssText = this._slotStyle(s, i === this.active);
+ cell.innerHTML = `${i + 1} ` + this._slotInner(s);
+ cell.onmouseenter = (e) => this._showTooltip(s, e);
+ cell.onmouseleave = () => this._hideTooltip();
+ cell.onclick = () => { this.setActiveHotbar(i); };
+ cell.oncontextmenu = (e) => { e.preventDefault(); this._openCtx('h' + i, e); };
+ this._wireDrag(cell, 'h' + i);
+ this.hotbarRoot.appendChild(cell);
+ }
+ }
+
+ // ── DOM: окно инвентаря ─────────────────────────────────────────────────
+ open() { if (this._open) return; this._open = true; this._mountWindow(); }
+ close() { this._open = false; if (this.root) { try { this.root.remove(); } catch (e) {} this.root = null; } this._hideTooltip(); this._closeCtx(); }
+ toggle() { this._open ? this.close() : this.open(); }
+ isOpen() { return this._open; }
+
+ _mountWindow() {
+ const overlay = document.createElement('div');
+ overlay.style.cssText = 'position:absolute;inset:0;z-index:70;background:rgba(8,10,16,0.6);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;font-family:Inter,system-ui,sans-serif;pointer-events:auto';
+ overlay.onclick = (e) => { if (e.target === overlay) this.close(); };
+ const panel = document.createElement('div');
+ panel.style.cssText = 'width:min(640px,94%);background:#141826;border:1px solid rgba(255,255,255,0.12);border-radius:18px;padding:20px;color:#e8ecf2;box-shadow:0 20px 60px rgba(0,0,0,0.5)';
+ panel.onclick = (e) => e.stopPropagation();
+ panel.innerHTML =
+ '' +
+ '
🎒 Инвентарь
' +
+ '
' +
+ 'Сорт. ' +
+ '✕ ' +
+ '
' +
+ '
' +
+ 'Панель быстрого доступа (1-9)
' +
+ '
';
+ overlay.appendChild(panel); this._parent().appendChild(overlay); this.root = overlay;
+ panel.querySelector('#_inv_close').onclick = () => this.close();
+ panel.querySelector('#_inv_sort').onclick = () => this.sort('rarity');
+ this._gridEl = panel.querySelector('#_inv_grid');
+ this._hbEl = panel.querySelector('#_inv_hb');
+ this._renderGrid();
+ }
+ _renderGrid() {
+ if (!this._gridEl) return;
+ const build = (el, arr, prefix) => {
+ el.innerHTML = '';
+ for (let i = 0; i < arr.length; i++) {
+ const ref = prefix === 'h' ? 'h' + i : i;
+ const s = arr[i];
+ const cell = document.createElement('div');
+ cell.style.cssText = this._slotStyle(s, prefix === 'h' && i === this.active).replace('52px', '56px');
+ cell.innerHTML = (prefix === 'h' ? `${i + 1} ` : '') + this._slotInner(s);
+ cell.onmouseenter = (e) => this._showTooltip(s, e);
+ cell.onmouseleave = () => this._hideTooltip();
+ cell.oncontextmenu = (e) => { e.preventDefault(); this._openCtx(ref, e); };
+ if (prefix === 'h') cell.onclick = () => this.setActiveHotbar(i);
+ this._wireDrag(cell, ref);
+ el.appendChild(cell);
+ }
+ };
+ build(this._gridEl, this.grid, 'g');
+ if (this._hbEl) build(this._hbEl, this.hotbar, 'h');
+ }
+
+ // ── Drag-drop (HTML5 native) ────────────────────────────────────────────
+ _wireDrag(cell, ref) {
+ cell.draggable = true;
+ cell.addEventListener('dragstart', (e) => {
+ this._drag = ref; cell.style.opacity = '0.4';
+ try { e.dataTransfer.setData('text/plain', String(ref)); e.dataTransfer.effectAllowed = 'move'; } catch (er) {}
+ });
+ cell.addEventListener('dragend', () => { cell.style.opacity = '1'; this._drag = null; });
+ cell.addEventListener('dragover', (e) => { e.preventDefault(); });
+ cell.addEventListener('drop', (e) => {
+ e.preventDefault();
+ const from = this._drag;
+ if (from != null && String(from) !== String(ref)) this.move(from, ref);
+ });
+ }
+
+ // ── Tooltip ──────────────────────────────────────────────────────────────
+ _showTooltip(s, e) {
+ if (!s) return;
+ this._hideTooltip();
+ const def = this._def(s.itemId), rc = RARITY[def.rarity];
+ const t = document.createElement('div');
+ t.style.cssText = 'position:absolute;z-index:90;max-width:240px;padding:10px 12px;background:rgba(12,16,26,0.96);border:1px solid ' + rc.color + ';border-radius:10px;color:#e8ecf2;font-family:Inter,system-ui,sans-serif;font-size:13px;pointer-events:none;box-shadow:0 6px 20px rgba(0,0,0,0.5)';
+ t.innerHTML =
+ '' + this._esc(def.name) + '
' +
+ '' + rc.label + (def.tags.length ? ' · ' + def.tags.join(', ') : '') + '
' +
+ (def.description ? '' + this._esc(def.description) + '
' : '') +
+ (def.value ? '💰 ' + def.value + '
' : '');
+ document.body.appendChild(t);
+ const x = (e && e.clientX) || 0, y = (e && e.clientY) || 0;
+ t.style.left = Math.min(x + 14, window.innerWidth - 250) + 'px';
+ t.style.top = (y + 14) + 'px';
+ this.tooltip = t;
+ }
+ _hideTooltip() { if (this.tooltip) { try { this.tooltip.remove(); } catch (e) {} this.tooltip = null; } }
+
+ // ── ПКМ-меню (Use/Split/Drop) ─────────────────────────────────────────────
+ _openCtx(ref, e) {
+ this._closeCtx();
+ const { arr, idx } = this._arrIdx(ref);
+ const s = arr[idx]; if (!s) return;
+ const m = document.createElement('div');
+ m.style.cssText = 'position:absolute;z-index:95;background:#1a2030;border:1px solid rgba(255,255,255,0.15);border-radius:10px;padding:5px;min-width:140px;font-family:Inter,system-ui,sans-serif;box-shadow:0 8px 24px rgba(0,0,0,0.5)';
+ const item = (label, fn) => {
+ const b = document.createElement('div');
+ b.textContent = label;
+ b.style.cssText = 'padding:8px 12px;border-radius:7px;cursor:pointer;color:#e8ecf2;font-size:14px';
+ b.onmouseenter = () => b.style.background = 'rgba(255,255,255,0.08)';
+ b.onmouseleave = () => b.style.background = 'transparent';
+ b.onclick = () => { fn(); this._closeCtx(); };
+ m.appendChild(b);
+ };
+ item('Использовать', () => this.use(ref));
+ if (this._opts.allowSplit && s.count > 1) item('Разделить', () => this.split(ref, Math.floor(s.count / 2)));
+ if (this._opts.allowDrop && !this._def(s.itemId).tags.includes('quest')) item('Выбросить', () => { arr[idx] = null; this._changed(); });
+ item('Отмена', () => {});
+ document.body.appendChild(m);
+ m.style.left = Math.min((e.clientX || 0), window.innerWidth - 150) + 'px';
+ m.style.top = (e.clientY || 0) + 'px';
+ this.ctxMenu = m;
+ setTimeout(() => { this._ctxCloser = () => this._closeCtx(); window.addEventListener('click', this._ctxCloser, { once: true }); }, 0);
+ }
+ _closeCtx() { if (this.ctxMenu) { try { this.ctxMenu.remove(); } catch (e) {} this.ctxMenu = null; } }
+
+ _esc(str) { return String(str).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c])); }
+
+ // ── Сериализация ──────────────────────────────────────────────────────────
+ serialize() {
+ return { defs: [...this.defs.values()], grid: this.grid, hotbar: this.hotbar, active: this.active, opts: this._opts };
+ }
+ load(data) {
+ if (!data) return;
+ if (Array.isArray(data.defs)) for (const d of data.defs) this.defineItem(d);
+ if (Array.isArray(data.grid)) this.grid = data.grid.slice(0, GRID).concat(new Array(Math.max(0, GRID - data.grid.length)).fill(null));
+ if (Array.isArray(data.hotbar)) this.hotbar = data.hotbar.slice(0, HOTBAR).concat(new Array(Math.max(0, HOTBAR - data.hotbar.length)).fill(null));
+ if (typeof data.active === 'number') this.active = data.active;
+ if (data.opts) this._opts = { ...this._opts, ...data.opts };
+ }
+
+ dispose() {
+ this.close();
+ if (this.hotbarRoot) { try { this.hotbarRoot.remove(); } catch (e) {} this.hotbarRoot = null; }
+ }
+ resetRuntime() {
+ this.close();
+ if (this.hotbarRoot) { try { this.hotbarRoot.remove(); } catch (e) {} this.hotbarRoot = null; }
+ }
+}
diff --git a/src/editor/engine/LeaderstatsManager.js b/src/editor/engine/LeaderstatsManager.js
new file mode 100644
index 0000000..706bc78
--- /dev/null
+++ b/src/editor/engine/LeaderstatsManager.js
@@ -0,0 +1,255 @@
+/**
+ * LeaderstatsManager — лидерборды (leaderstats) как в Roblox (задача 20).
+ *
+ * Хранит статы игроков и рендерит HUD-таблицу в правом-верхнем углу.
+ * В одиночной игре — один игрок ('me'). Поля сортируются по primary-стату.
+ *
+ * API (через game.leaderstats.*):
+ * define(name, opts) — зарегистрировать стат (initial/format/icon/color/primary)
+ * set(playerId, name, value) / add — изменить стат игрока
+ * get(playerId, name) — прочитать
+ * me.set/add(name, value) — для текущего игрока
+ * onChange(fn) — подписка (для bindToStat достижений)
+ *
+ * format: 'number' (42) | 'time' (mm:ss) | 'short' (1.2K).
+ * DOM-оверлей крепится к canvas.parentElement (как LoadingScreenOverlay).
+ *
+ * Фича-парность: тот же модуль в rublox-player/src/engine/.
+ */
+
+function fmt(value, format) {
+ const v = Number(value) || 0;
+ if (format === 'time') {
+ const m = Math.floor(v / 60), s = Math.floor(v % 60);
+ return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
+ }
+ if (format === 'short') {
+ if (v >= 1e9) return (v / 1e9).toFixed(1).replace(/\.0$/, '') + 'B';
+ if (v >= 1e6) return (v / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
+ if (v >= 1e3) return (v / 1e3).toFixed(1).replace(/\.0$/, '') + 'K';
+ return String(Math.round(v));
+ }
+ return String(Math.round(v));
+}
+
+export class LeaderstatsManager {
+ constructor(scene3d) {
+ this.s = scene3d;
+ this._defs = []; // [{name, initial, format, icon, color, primary}]
+ this._stats = new Map(); // playerId → Map(name → value)
+ this._players = new Map(); // playerId → displayName
+ this._onChange = [];
+ this.root = null;
+ this._dirty = false;
+ this._meId = 'me';
+ }
+
+ /** id текущего игрока (одиночка = 'me'). */
+ _resolveMe() {
+ try {
+ const p = this.s?.gameRuntime?._players?.me;
+ if (p && p.id != null) return String(p.id);
+ } catch (e) { /* ignore */ }
+ return 'me';
+ }
+
+ define(name, opts = {}) {
+ if (typeof name !== 'string' || !name) return;
+ if (this._defs.some(d => d.name === name)) return; // уже есть
+ this._defs.push({
+ name,
+ initial: Number(opts.initial) || 0,
+ format: opts.format || 'number',
+ icon: opts.icon || '',
+ color: opts.color || '#e8ecf2',
+ primary: !!opts.primary,
+ });
+ // Если ни один не primary — первый становится primary.
+ if (!this._defs.some(d => d.primary)) this._defs[0].primary = true;
+ // Инициализируем стат у уже известных игроков.
+ for (const [pid] of this._players) this._ensure(pid, name);
+ this._ensureMe();
+ if (this.s?._isPlaying) this._mount(); // HUD только в Play
+ this._dirty = true;
+ }
+
+ _ensureMe() {
+ const me = this._resolveMe();
+ this._meId = me;
+ if (!this._players.has(me)) {
+ let nm = 'Ты';
+ try { nm = this.s?.gameRuntime?._players?.me?.name || 'Ты'; } catch (e) {}
+ this._players.set(me, nm);
+ }
+ for (const d of this._defs) this._ensure(me, d.name);
+ }
+
+ _ensure(pid, name) {
+ if (!this._stats.has(pid)) this._stats.set(pid, new Map());
+ const m = this._stats.get(pid);
+ if (!m.has(name)) {
+ const def = this._defs.find(d => d.name === name);
+ m.set(name, def ? def.initial : 0);
+ }
+ }
+
+ set(playerId, name, value) {
+ const pid = playerId == null ? this._resolveMe() : String(playerId);
+ if (!this._players.has(pid)) this._players.set(pid, pid === this._resolveMe() ? 'Ты' : ('Игрок ' + pid));
+ this._ensure(pid, name);
+ const m = this._stats.get(pid);
+ const old = m.get(name);
+ const nv = Number(value) || 0;
+ if (old === nv) return;
+ m.set(name, nv);
+ this._dirty = true;
+ this._flash = this._flash || {};
+ this._flash[pid + '|' + name] = performance.now ? performance.now() : Date.now();
+ for (const fn of this._onChange) {
+ try { fn(pid, name, nv, old); } catch (e) { /* ignore */ }
+ }
+ // Сохраняем статы текущего игрока в БД (дебаунс 1с) — между сессиями.
+ if (pid === this._resolveMe()) this._scheduleSave();
+ }
+
+ _scheduleSave() {
+ if (this._saveTimer) clearTimeout(this._saveTimer);
+ this._saveTimer = setTimeout(() => {
+ this._saveTimer = null;
+ try {
+ const me = this._resolveMe();
+ const m = this._stats.get(me);
+ if (!m) return;
+ const obj = {}; for (const [k, v] of m) obj[k] = v;
+ this.s?.gameRuntime?.saveProgress?.('_leaderstats', obj);
+ } catch (e) { /* ignore */ }
+ }, 1000);
+ }
+
+ /** Загрузить статы текущего игрока из БД (вызывать при Play, после define). */
+ loadFromDB() {
+ const rt = this.s?.gameRuntime;
+ if (!rt || !rt.loadProgress) return;
+ rt.loadProgress('_leaderstats', (data) => {
+ if (data && typeof data === 'object') {
+ const me = this._resolveMe();
+ for (const name of Object.keys(data)) {
+ // Применяем только к зарегистрированным статам, без повторного сейва.
+ if (this._defs.some(d => d.name === name)) {
+ this._ensure(me, name);
+ this._stats.get(me).set(name, Number(data[name]) || 0);
+ }
+ }
+ this._dirty = true;
+ }
+ });
+ }
+
+ add(playerId, name, delta) {
+ const pid = playerId == null ? this._resolveMe() : String(playerId);
+ this._ensure(pid, name);
+ const cur = this._stats.get(pid).get(name) || 0;
+ this.set(pid, name, cur + (Number(delta) || 0));
+ }
+
+ get(playerId, name) {
+ const pid = playerId == null ? this._resolveMe() : String(playerId);
+ const m = this._stats.get(pid);
+ return m ? (m.get(name) || 0) : 0;
+ }
+
+ onChange(fn) { if (typeof fn === 'function') this._onChange.push(fn); }
+
+ /** Активны ли leaderstats (хотя бы один define). */
+ get active() { return this._defs.length > 0; }
+
+ // ── HUD ──────────────────────────────────────────────────────────────
+ _mount() {
+ if (this.root) return;
+ const parent = (this.s && this.s.canvas && this.s.canvas.parentElement) || document.body;
+ const root = document.createElement('div');
+ root.style.cssText = [
+ 'position:absolute', 'top:14px', 'right:14px', 'z-index:50',
+ 'min-width:230px', 'max-width:300px',
+ 'background:rgba(18,22,33,0.55)', 'backdrop-filter:blur(8px)',
+ '-webkit-backdrop-filter:blur(8px)',
+ 'border:1px solid rgba(255,255,255,0.12)', 'border-radius:12px',
+ 'padding:10px 12px', 'font-family:Inter,system-ui,sans-serif',
+ 'color:#e8ecf2', 'pointer-events:none', 'user-select:none',
+ 'box-shadow:0 6px 24px rgba(0,0,0,0.35)',
+ ].join(';');
+ parent.appendChild(root);
+ this.root = root;
+ this._sortBy = null; // имя стата для сортировки (null = primary)
+ }
+
+ /** Вызывать каждый кадр (рендер при изменениях + затухание flash). */
+ tick() {
+ if (!this.active) return;
+ if (!this.root) { this._mount(); this._dirty = true; }
+ if (this._dirty) { this._render(); this._dirty = false; }
+ // flash затухает ~600мс — перерисуем пока активен.
+ if (this._flash && Object.keys(this._flash).length) {
+ const now = performance.now ? performance.now() : Date.now();
+ let any = false;
+ for (const k of Object.keys(this._flash)) {
+ if (now - this._flash[k] < 600) any = true; else delete this._flash[k];
+ }
+ if (any) this._render();
+ }
+ }
+
+ _render() {
+ const defs = this._defs;
+ if (!defs.length) { this.root.innerHTML = ''; return; }
+ const sortStat = this._sortBy || (defs.find(d => d.primary) || defs[0]).name;
+ const me = this._resolveMe();
+ // Строки игроков, сортировка по убыванию sortStat, топ-10.
+ const rows = [...this._players.keys()]
+ .map(pid => ({ pid, name: this._players.get(pid) }))
+ .sort((a, b) => (this.get(b.pid, sortStat) - this.get(a.pid, sortStat)))
+ .slice(0, 10);
+ const now = performance.now ? performance.now() : Date.now();
+
+ let html = '🏆 Таблица лидеров
';
+ // Шапка столбцов.
+ html += '';
+ html += 'Игрок ';
+ for (const d of defs) html += '' + (d.icon ? d.icon + ' ' : '') + d.name + ' ';
+ html += '
';
+ // Строки.
+ for (const r of rows) {
+ const mine = r.pid === me;
+ html += '';
+ html += '' + this._esc(r.name) + ' ';
+ for (const d of defs) {
+ const flashed = this._flash && (now - (this._flash[r.pid + '|' + d.name] || 0) < 600);
+ const col = flashed ? '#ffe066' : d.color;
+ html += '' + fmt(this.get(r.pid, d.name), d.format) + ' ';
+ }
+ html += '
';
+ }
+ this.root.innerHTML = html;
+ }
+
+ _esc(s) { return String(s).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c])); }
+
+ /** Сериализация определений в project_data. */
+ serialize() {
+ return this._defs.map(d => ({ ...d }));
+ }
+ load(arr) {
+ if (!Array.isArray(arr)) return;
+ for (const d of arr) this.define(d.name, d);
+ }
+
+ dispose() {
+ if (this.root) { try { this.root.remove(); } catch (e) {} this.root = null; }
+ this._stats.clear(); this._players.clear(); this._onChange = [];
+ }
+ /** Сброс рантайм-значений при exitPlayMode (определения остаются). */
+ resetRuntime() {
+ this._stats.clear(); this._players.clear(); this._flash = {};
+ if (this.root) this.root.innerHTML = '';
+ }
+}
diff --git a/src/editor/engine/LoadingScreenOverlay.js b/src/editor/engine/LoadingScreenOverlay.js
index 47f6b56..d0666f2 100644
--- a/src/editor/engine/LoadingScreenOverlay.js
+++ b/src/editor/engine/LoadingScreenOverlay.js
@@ -35,7 +35,25 @@ function injectSpinnerCss() {
style.textContent =
'@keyframes kbn-ls-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}' +
'.kbn-ls-spinner{animation:kbn-ls-spin 0.9s linear infinite}' +
- '@media (prefers-reduced-motion:reduce){.kbn-ls-spinner{animation:none}}';
+ // Ken Burns — медленный pan+zoom фона (задача 05).
+ '@keyframes kbn-ls-kenburns{' +
+ '0%{transform:scale(1.0) translate3d(0,0,0)}' +
+ '50%{transform:scale(1.10) translate3d(-3%,-2%,0)}' +
+ '100%{transform:scale(1.0) translate3d(-6%,0,0)}}' +
+ '.kbn-ls-kenburns{animation:kbn-ls-kenburns 24s ease-in-out infinite}' +
+ // particles — медленно всплывающие искры.
+ '@keyframes kbn-ls-rise{' +
+ '0%{transform:translateY(0) scale(1);opacity:0}' +
+ '10%{opacity:0.9}' +
+ '90%{opacity:0.7}' +
+ '100%{transform:translateY(-110vh) scale(1.4);opacity:0}}' +
+ '.kbn-ls-particle{animation:kbn-ls-rise linear infinite}' +
+ // лёгкий «дыхательный» glow карточки-превью.
+ '@keyframes kbn-ls-cardglow{' +
+ '0%,100%{box-shadow:0 18px 60px rgba(0,0,0,0.6),0 0 0 rgba(120,160,255,0)}' +
+ '50%{box-shadow:0 18px 60px rgba(0,0,0,0.6),0 0 40px rgba(120,160,255,0.35)}}' +
+ '.kbn-ls-cardglow{animation:kbn-ls-cardglow 4s ease-in-out infinite}' +
+ '@media (prefers-reduced-motion:reduce){.kbn-ls-spinner,.kbn-ls-kenburns,.kbn-ls-particle,.kbn-ls-cardglow{animation:none}}';
document.head.appendChild(style);
} catch { /* ignore */ }
}
@@ -49,14 +67,17 @@ export class LoadingScreenOverlay {
// Мост наружу (GameRuntime подписывает) — id-based колбэки.
this._onSkipCb = null; // (id) => void
this._onCompleteCb = null; // (id) => void
+ this._onHideCb = null; // () => void — задача 05 (game.loading.onHide)
+ this._parallaxHandler = null;
// DOM-ссылки активного экрана:
this._els = null;
}
/** Колбэки-мост к GameRuntime (он рассылает globalEvent в sandbox'ы). */
- setBridge(onSkip, onComplete) {
+ setBridge(onSkip, onComplete, onHide) {
this._onSkipCb = onSkip;
this._onCompleteCb = onComplete;
+ if (onHide) this._onHideCb = onHide;
}
/** Конфиг проекта (логотип/акцент по умолчанию) из BabylonScene._loadingConfig. */
@@ -104,6 +125,15 @@ export class LoadingScreenOverlay {
logoCornerRadius: opts.logoCornerRadius != null ? Number(opts.logoCornerRadius) : 12,
// Текст под картинкой
text: opts.text != null ? String(opts.text) : '',
+ // --- Задача 05: Ken-Burns фон + карточка места ---
+ // style: 'ken-burns' | 'static' | 'parallax' | 'particles'
+ style: opts.style || cfg.style || 'ken-burns',
+ // фоновое размытое изображение (на весь экран); резолвится в _resolveCover.
+ background: opts.background != null ? opts.background : (cfg.background || null),
+ // карточка-витрина по центру (название места + автор), как в Roblox.
+ placeName: opts.placeName != null ? String(opts.placeName) : (cfg.placeName || ''),
+ studioName: opts.studioName != null ? String(opts.studioName) : (cfg.studioName || ''),
+ verified: opts.verified != null ? !!opts.verified : !!cfg.verified,
// Поведение
blockInput: opts.blockInput !== false,
pauseSimulation: opts.pauseSimulation !== false,
@@ -163,20 +193,107 @@ export class LoadingScreenOverlay {
// (используем opacity всего root для fade, а bgOpacity — через rgba фон):
root.style.background = this._bgRgba(st.bgColor, st.bgOpacity);
- // --- Cover (картинка по центру) ---
- const coverUrl = this._resolveCover(cover);
- const coverImg = document.createElement('div');
- coverImg.style.cssText =
- 'max-width:min(62vw,860px);width:62vw;aspect-ratio:16/9;border-radius:16px;' +
- 'box-shadow:0 18px 60px rgba(0,0,0,0.6);background-size:cover;background-position:center;' +
- 'background-color:#1a1f2b;margin-bottom:140px;';
- if (coverUrl) coverImg.style.backgroundImage = `url("${coverUrl}")`;
+ // --- Фоновый слой (Ken Burns / parallax / static) ---
+ // Размытое изображение игры на весь экран. Отдельный div под контентом,
+ // чтобы blur/анимация не трогали карточку и текст.
+ const bgUrl = this._resolveCover(st.background);
+ const bgLayer = document.createElement('div');
+ let bgClass = '';
+ if (bgUrl) {
+ if (st.style === 'ken-burns') bgClass = 'kbn-ls-kenburns';
+ bgLayer.className = bgClass;
+ bgLayer.style.cssText =
+ 'position:absolute;inset:-8%;z-index:0;background-size:cover;background-position:center;' +
+ 'filter:blur(8px) brightness(0.55);will-change:transform;' +
+ `background-image:url("${bgUrl}");`;
+ // parallax — лёгкий сдвиг по мыши (2 слоя имитируем одним + transform).
+ if (st.style === 'parallax') {
+ bgLayer.style.transition = 'transform 0.25s ease-out';
+ this._parallaxHandler = (e) => {
+ const cx = (e.clientX / window.innerWidth - 0.5) * 28;
+ const cy = (e.clientY / window.innerHeight - 0.5) * 18;
+ bgLayer.style.transform = `translate3d(${-cx}px,${-cy}px,0) scale(1.06)`;
+ };
+ window.addEventListener('mousemove', this._parallaxHandler);
+ }
+ root.appendChild(bgLayer);
+ }
- // --- Текст под картинкой ---
+ // --- particles слой (медленные искры) ---
+ if (st.style === 'particles') {
+ const pLayer = document.createElement('div');
+ pLayer.style.cssText = 'position:absolute;inset:0;z-index:1;overflow:hidden;pointer-events:none;';
+ for (let i = 0; i < 26; i++) {
+ const sp = document.createElement('span');
+ sp.className = 'kbn-ls-particle';
+ const size = 2 + Math.round(Math.random() * 4);
+ const dur = 7 + Math.random() * 10;
+ sp.style.cssText =
+ `position:absolute;bottom:-10px;left:${(Math.random() * 100).toFixed(1)}%;` +
+ `width:${size}px;height:${size}px;border-radius:50%;` +
+ `background:rgba(${180 + Math.round(Math.random() * 70)},${190 + Math.round(Math.random() * 60)},255,0.85);` +
+ `box-shadow:0 0 ${size * 2}px rgba(140,170,255,0.7);` +
+ `animation-duration:${dur.toFixed(1)}s;animation-delay:${(-Math.random() * dur).toFixed(1)}s;`;
+ pLayer.appendChild(sp);
+ }
+ root.appendChild(pLayer);
+ }
+
+ // Обёртка контента (над фоном).
+ const content = document.createElement('div');
+ content.style.cssText =
+ 'position:relative;z-index:2;display:flex;flex-direction:column;align-items:center;justify-content:center;width:100%;height:100%;';
+
+ // --- Cover (картинка-карточка по центру) ---
+ const coverUrl = this._resolveCover(cover);
+ // Режим карточки места (задача 05): квадрат + название + автор под ней.
+ const hasPlaceCard = !!(st.placeName || st.studioName);
+ const coverImg = document.createElement('div');
+ if (hasPlaceCard) {
+ coverImg.className = 'kbn-ls-cardglow';
+ coverImg.style.cssText =
+ 'width:min(42vw,400px);aspect-ratio:1/1;border-radius:18px;' +
+ 'background-size:cover;background-position:center;background-color:#1a1f2b;' +
+ 'border:2px solid rgba(255,255,255,0.12);';
+ } else {
+ coverImg.style.cssText =
+ 'max-width:min(62vw,860px);width:62vw;aspect-ratio:16/9;border-radius:16px;' +
+ 'box-shadow:0 18px 60px rgba(0,0,0,0.6);background-size:cover;background-position:center;' +
+ 'background-color:#1a1f2b;margin-bottom:140px;';
+ }
+ if (coverUrl) coverImg.style.backgroundImage = `url("${coverUrl}")`;
+ else if (bgUrl && hasPlaceCard) coverImg.style.backgroundImage = `url("${bgUrl}")`;
+
+ // --- Название места (крупный белый, под карточкой) ---
+ const placeEl = document.createElement('div');
+ placeEl.style.cssText =
+ 'margin-top:22px;color:#fff;font-size:38px;font-weight:800;letter-spacing:0.5px;' +
+ 'text-align:center;text-shadow:0 3px 14px rgba(0,0,0,0.7);' +
+ (st.placeName ? '' : 'display:none;');
+ placeEl.textContent = st.placeName || '';
+
+ // --- Автор + verified-галочка ---
+ const studioRow = document.createElement('div');
+ studioRow.style.cssText =
+ 'margin-top:8px;display:flex;align-items:center;gap:7px;' +
+ 'color:#cdd6e6;font-size:16px;font-weight:600;text-shadow:0 1px 4px rgba(0,0,0,0.6);' +
+ (st.studioName ? '' : 'display:none;');
+ const studioTxt = document.createElement('span');
+ studioTxt.textContent = st.studioName || '';
+ studioRow.appendChild(studioTxt);
+ if (st.verified) studioRow.appendChild(this._buildVerifiedBadge());
+
+ // --- Текст под картинкой (для не-карточного режима / mid-game) ---
const textEl = document.createElement('div');
- textEl.style.cssText =
- 'position:absolute;left:50%;transform:translateX(-50%);bottom:170px;' +
- 'color:#e8edf5;font-size:18px;font-weight:500;text-align:center;text-shadow:0 1px 3px rgba(0,0,0,0.6);';
+ if (hasPlaceCard) {
+ textEl.style.cssText =
+ 'margin-top:14px;color:#e8edf5;font-size:16px;font-weight:500;text-align:center;' +
+ 'text-shadow:0 1px 3px rgba(0,0,0,0.6);' + (st.text ? '' : 'display:none;');
+ } else {
+ textEl.style.cssText =
+ 'position:absolute;left:50%;transform:translateX(-50%);bottom:170px;' +
+ 'color:#e8edf5;font-size:18px;font-weight:500;text-align:center;text-shadow:0 1px 3px rgba(0,0,0,0.6);';
+ }
textEl.textContent = st.text || '';
// --- Прогресс-бар ---
@@ -245,8 +362,13 @@ export class LoadingScreenOverlay {
spinWrap.appendChild(spinTxt);
spinWrap.appendChild(spinCircle);
- root.appendChild(coverImg);
- root.appendChild(textEl);
+ // Центральная композиция (карточка + название + автор + текст) — в content.
+ content.appendChild(coverImg);
+ content.appendChild(placeEl);
+ content.appendChild(studioRow);
+ content.appendChild(textEl);
+ root.appendChild(content);
+ // Нижние/угловые элементы — абсолютно на root (поверх фона, рядом с content).
root.appendChild(barWrap);
root.appendChild(percent);
root.appendChild(skipBtn);
@@ -255,7 +377,19 @@ export class LoadingScreenOverlay {
parent.appendChild(root);
this.root = root;
- this._els = { root, coverImg, textEl, barWrap, bar, percent, skipBtn, logo, spinWrap };
+ this._els = { root, content, coverImg, placeEl, studioRow, textEl, barWrap, bar, percent, skipBtn, logo, spinWrap };
+ }
+
+ /** Verified-галочка (синий круг + белый чек) — inline SVG, без emoji-шрифта. */
+ _buildVerifiedBadge() {
+ const wrap = document.createElement('span');
+ wrap.style.cssText = 'display:inline-flex;align-items:center;';
+ wrap.innerHTML =
+ '' +
+ ' ' +
+ ' ';
+ return wrap;
}
/** tick — fade-фазы, авто-duration, появление кнопки Пропустить. */
@@ -329,6 +463,23 @@ export class LoadingScreenOverlay {
if (url) this._els.coverImg.style.backgroundImage = `url("${url}")`;
}
+ /** Задача 05: сменить фоновое (Ken-Burns) изображение на лету. */
+ setBackground(bg) {
+ if (!this._st || !this._els) return;
+ const url = this._resolveCover(bg);
+ if (!url) return;
+ this._st.background = bg;
+ // фоновый слой — первый ребёнок root с background-image; найдём его.
+ const layer = this._els.root.querySelector('.kbn-ls-kenburns')
+ || this._els.root.firstElementChild;
+ if (layer && layer !== this._els.content) layer.style.backgroundImage = `url("${url}")`;
+ }
+
+ /** Задача 05: виден ли экран сейчас. */
+ isVisible() {
+ return !!(this._st && this._st.phase !== 'out');
+ }
+
/** Закрыть программно (с fadeOut). */
close() {
const st = this._st;
@@ -361,6 +512,13 @@ export class LoadingScreenOverlay {
if (st.blockInput) { try { this.s.player?.setInputBlocked?.(false); } catch { /* ignore */ } }
if (st.pauseSimulation && this.s.gameRuntime) { try { this.s.gameRuntime.paused = false; } catch { /* ignore */ } }
}
+ // Снять parallax-listener (задача 05).
+ if (this._parallaxHandler) {
+ try { window.removeEventListener('mousemove', this._parallaxHandler); } catch { /* ignore */ }
+ this._parallaxHandler = null;
+ }
+ // onHide-мост (задача 05) — сообщаем скриптам что экран скрылся.
+ if (this._onHideCb) { try { this._onHideCb(); } catch { /* ignore */ } }
if (this.root) { try { this.root.remove(); } catch { /* ignore */ } }
this.root = null;
this._els = null;
diff --git a/src/editor/engine/ModelManager.js b/src/editor/engine/ModelManager.js
index d457be9..e52bba2 100644
--- a/src/editor/engine/ModelManager.js
+++ b/src/editor/engine/ModelManager.js
@@ -526,6 +526,9 @@ export class ModelManager {
opacity: typeof data.opacity === 'number' ? data.opacity : 1,
tint: data.tint || null,
name: data.name || null,
+ // folderId — принадлежность к папке (иначе модели вываливаются
+ // из папки после Play/Stop). Баг 2026-06-05.
+ ...(data.folderId != null ? { folderId: data.folderId } : {}),
// Параметры геймплея (HP, скорость врага, лимит спавнера и т.п.)
gameplayParams: data.gameplayParams || null,
});
@@ -768,6 +771,7 @@ export class ModelManager {
if (m.tint) data.tint = m.tint;
if (m.name) data.name = m.name;
if (m.gameplayParams) data.gameplayParams = m.gameplayParams;
+ if (m.folderId != null) data.folderId = m.folderId; // восстановить папку
if (data.opacity != null || data.tint) this._applyMaterialOverrides(data);
}
// Гарантируем что _nextInstanceId стоит ПОСЛЕ максимального восстановленного id —
diff --git a/src/editor/engine/NpcManager.js b/src/editor/engine/NpcManager.js
index 491e8d6..f452f81 100644
--- a/src/editor/engine/NpcManager.js
+++ b/src/editor/engine/NpcManager.js
@@ -161,6 +161,20 @@ export class NpcManager {
r15Animator,
};
this.npcs.set(id, npc);
+ // Пометить меши NPC для попаданий оружия (бластер/меч): pickable + npcId
+ // в metadata. Без pickable raycast оружия проходит сквозь NPC и урон/
+ // floater'ы не срабатывают (задача 40).
+ try {
+ const root = npc.data && npc.data.rootMesh;
+ if (root) {
+ root.isPickable = true;
+ root.metadata = Object.assign({}, root.metadata, { npcId: id });
+ for (const m of root.getChildMeshes(false)) {
+ m.isPickable = true;
+ m.metadata = Object.assign({}, m.metadata, { npcId: id });
+ }
+ }
+ } catch (e) { /* ignore */ }
return id;
}
@@ -274,6 +288,12 @@ export class NpcManager {
npc.isMoving = false;
}
+ /** Включить/выключить анимацию атаки (R15-NPC машет руками). */
+ setAttacking(id, on) {
+ const npc = this.npcs.get(Number(id));
+ if (npc) npc.attacking = !!on;
+ }
+
/** Реплика над головой NPC на duration секунд. */
say(id, text, duration = 3) {
const npc = this.npcs.get(Number(id));
@@ -286,10 +306,43 @@ export class NpcManager {
damage(id, amount) {
const npc = this.npcs.get(Number(id));
if (!npc || npc.dead) return;
- npc.hp = Math.max(0, npc.hp - (Number(amount) || 0));
+ const amt = Number(amount) || 0;
+ npc.hp = Math.max(0, npc.hp - amt);
+ // Авто-floater над мобом (задача 40 доп): game.fx.autoMobFloaters(true).
+ if (this._autoFloater && amt > 0 && this.scene3d?.floaters) {
+ try {
+ this.scene3d.floaters.spawn(
+ { x: npc.x, y: (npc.y || 0) + 2.2, z: npc.z }, amt, this._autoFloater.opts || {});
+ } catch (e) { /* ignore */ }
+ }
if (npc.hp <= 0) this._killNpc(npc);
}
+ /** Нанести урон NPC по мешу-попаданию (бластер/оружие). Ищет NPC, чьи меши
+ * содержат hit-меш (или предка). Вызывает damage() → авто-floater. */
+ damageByMesh(mesh, amount) {
+ if (!mesh) return false;
+ // 1) Быстрый путь: npcId в metadata меша (или предка).
+ let m = mesh;
+ for (let i = 0; i < 8 && m; i++) {
+ const nid = m.metadata && m.metadata.npcId;
+ if (nid != null && this.npcs.has(nid)) { this.damage(nid, amount); return true; }
+ m = m.parent;
+ }
+ // 2) Fallback: сравнение с rootMesh по иерархии.
+ for (const npc of this.npcs.values()) {
+ if (npc.dead) continue;
+ const root = npc.data && npc.data.rootMesh;
+ if (!root) continue;
+ let mm = mesh;
+ for (let i = 0; i < 8 && mm; i++) {
+ if (mm === root) { this.damage(npc.id, amount); return true; }
+ mm = mm.parent;
+ }
+ }
+ return false;
+ }
+
/** Удалить NPC по id (без эффекта смерти — просто убрать). */
removeNpc(id) {
const npc = this.npcs.get(Number(id));
@@ -390,17 +443,23 @@ export class NpcManager {
if (root._isWorldMatrixFrozen) {
try { root.unfreezeWorldMatrix(); } catch (e) {}
}
- root.position.set(npc.x, npc.y, npc.z);
+ // Анимация ходьбы — процедурное покачивание (у Kenney-моделей нет
+ // скелета). Подпрыгивание по Y + лёгкое раскачивание корпуса.
+ if (moving) npc.walkPhase += dt * 10;
+ let bobY = 0, lean = 0;
+ if (moving && !npc.r15Animator) {
+ bobY = Math.abs(Math.sin(npc.walkPhase)) * 0.12; // шаги вверх-вниз
+ lean = Math.sin(npc.walkPhase) * 0.08; // покачивание
+ }
+ root.position.set(npc.x, npc.y + bobY, npc.z);
root.rotation.y = npc.yaw;
+ root.rotation.z = lean;
// data.x/y/z — чтобы scene.find/getPosition видели NPC.
data.x = npc.x; data.y = npc.y; data.z = npc.z;
-
- // Анимация ходьбы — простое покачивание (без R15-скелета у Kenney).
- if (moving) npc.walkPhase += dt * 6;
- // R15-NPC (skin_*): процедурная анимация бега/покоя через R15Animator.
+ // R15-NPC (skin_*): процедурная анимация бега/покоя/атаки через R15Animator.
if (npc.r15Animator) {
try {
- npc.r15Animator.setState(moving ? 'run' : 'idle');
+ npc.r15Animator.setState(npc.attacking ? 'attack' : (moving ? 'run' : 'idle'));
npc.r15Animator.update(dt);
} catch (e) { /* ignore */ }
}
diff --git a/src/editor/engine/PrimitiveManager.js b/src/editor/engine/PrimitiveManager.js
index 76c60a6..8a50c0c 100644
--- a/src/editor/engine/PrimitiveManager.js
+++ b/src/editor/engine/PrimitiveManager.js
@@ -186,6 +186,10 @@ export class PrimitiveManager {
primitiveId: id,
primitiveType: type,
primitiveKind: typeDef.kind,
+ // canCollide в metadata нужен camera-clamp (PlayerController):
+ // без него камера 3-го лица цепляется за проходимые зоны/триггеры
+ // (canCollide:false) и прыгает к игроку внутри зоны. Баг 2026-06-05.
+ canCollide,
};
// textureAsset — id картинки из AssetManager (пользовательская
@@ -754,7 +758,10 @@ export class PrimitiveManager {
this._applyMaterial(data.mesh, typeDef, data.color, data.material);
}
- if (patch.canCollide !== undefined) data.canCollide = patch.canCollide;
+ if (patch.canCollide !== undefined) {
+ data.canCollide = patch.canCollide;
+ if (data.mesh?.metadata) data.mesh.metadata.canCollide = patch.canCollide;
+ }
if (patch.locked !== undefined) data.locked = !!patch.locked;
if (patch.visible !== undefined) {
data.visible = patch.visible;
@@ -938,6 +945,9 @@ export class PrimitiveManager {
anchored: d.anchored,
mass: d.mass,
name: d.name || null,
+ // folderId — принадлежность к папке. БЕЗ него примитивы вываливались
+ // из папки после Play/Stop (снапшот терял группировку). Баг 2026-06-05.
+ ...(d.folderId != null ? { folderId: d.folderId } : {}),
// locked — защита от выделения/перемещения (Фаза 5.11).
...(d.locked ? { locked: true } : {}),
// id пользовательской текстуры (картинка из AssetManager).
diff --git a/src/editor/engine/R15Animator.js b/src/editor/engine/R15Animator.js
index 6d373ca..f570567 100644
--- a/src/editor/engine/R15Animator.js
+++ b/src/editor/engine/R15Animator.js
@@ -131,6 +131,23 @@ const ANIMS_STD = {
{ bone: B.SPINE, axis: AXIS_RIGHT, angleDeg: 10,
times: [0.0, 0.4, 0.8], values: [0.0, 1.0, 0.0] },
]),
+ // Удар правой рукой вперёд (для враждебных NPC). loop=true — постоянно
+ // машет, пока NPC в режиме атаки.
+ attack: makeAnim(0.5, true, [
+ // Правая рука выбрасывается вперёд (замах назад → удар).
+ { bone: B.RIGHT_ARM, axis: AXIS_RIGHT, angleDeg: -95,
+ times: [0.0, 0.15, 0.3, 0.5], values: [0.3, 1.0, 0.2, 0.3] },
+ { bone: B.RIGHT_FOREARM, axis: AXIS_RIGHT, angleDeg: -50,
+ times: [0.0, 0.15, 0.3, 0.5], values: [1.0, 0.2, 1.0, 1.0] },
+ // Левая рука тоже в боевой стойке.
+ { bone: B.LEFT_ARM, axis: AXIS_RIGHT, angleDeg: -45,
+ times: [0.0, 0.5], values: [1.0, 1.0] },
+ { bone: B.LEFT_FOREARM, axis: AXIS_RIGHT, angleDeg: -70,
+ times: [0.0, 0.5], values: [1.0, 1.0] },
+ // Корпус подаётся в удар.
+ { bone: B.SPINE, axis: AXIS_RIGHT, angleDeg: -12,
+ times: [0.0, 0.15, 0.3, 0.5], values: [0.0, 1.0, 0.3, 0.0] },
+ ]),
// === ЭМОЦИИ (game.player.playAnimation) ===
// Разовые анимации поверх авто-состояния. loop=false — играют один раз,
diff --git a/src/editor/engine/ScriptSandboxWorker.js b/src/editor/engine/ScriptSandboxWorker.js
index a2afaba..6d38fa4 100644
--- a/src/editor/engine/ScriptSandboxWorker.js
+++ b/src/editor/engine/ScriptSandboxWorker.js
@@ -70,10 +70,16 @@ let _toolUseHandlers = [];
// При toolUse-событии воркер сначала вызывает per-tool колбэк, потом глобальные.
let _toolCallbacks = {}; // { 'custom:1': { activated: fn, equipped: fn, unequipped: fn } }
let _toolSeq = 0;
+// Задача 05: зеркало видимости экрана загрузки (для game.loading.isVisible()).
+let _loadingVisible = false;
// Мультиплеер (Фаза 4.3): снимок игроков комнаты { me, list }.
let _players = { me: null, list: [] };
// Общее состояние комнаты game.room.get/set — зеркало из main thread.
let _roomState = {};
+// Задача 20: зеркала лидербордов/достижений для синхронного get/has в скриптах.
+let _lsMirror = {}; // { playerId: { statName: value } }
+let _achUnlocked = {}; // { id: true }
+let _lsChangeHandlers = []; // game.leaderstats.onChange подписки
// Подписки game.onPlayerJoin / onPlayerLeave / game.onMessage(name).
let _playerJoinHandlers = [];
let _playerLeaveHandlers = [];
@@ -229,6 +235,10 @@ function _makeNpcProxy(ref) {
damage(amount) {
_send('npc.damage', { ref, amount: Number(amount) || 0 });
},
+ /** Включить/выключить анимацию атаки (удары руками). */
+ setAttacking(on) {
+ _send('npc.setAttacking', { ref, on: !!on });
+ },
/** Убрать NPC со сцены. */
remove() {
_send('npc.remove', { ref });
@@ -554,6 +564,12 @@ function _getOrCreateInstance(ref, kindHint) {
_send('scene.setColor', { ref, color: String(value) });
return true;
}
+ if (prop === 'scale') {
+ // Равномерный визуальный масштаб объекта (1 = исходный размер).
+ const k = Number(value);
+ if (Number.isFinite(k) && k >= 0) _send('scene.setScale', { ref, scale: k });
+ return true;
+ }
if (prop === 'transparency' || prop === 'opacity') {
const v = Number(value);
if (Number.isFinite(v)) {
@@ -742,6 +758,51 @@ function _buildSelfApi() {
_send('self.move', { target: _target, x: nx, y: ny, z: nz });
}
},
+ /**
+ * Повернуть объект-носитель вокруг вертикальной оси Y на угол ry (радианы).
+ * game.onTick((dt) => { a += dt; game.self.rotate(a); });
+ */
+ rotate(ry) {
+ const r = Number(ry);
+ if (!Number.isFinite(r)) return;
+ const k = _target.kind;
+ const id = _target.id ?? _target.ref;
+ _send('scene.rotate', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, rotationY: r });
+ },
+ rotateY(ry) { this.rotate(ry); },
+ /** Показать/скрыть объект-носитель. */
+ setVisible(vis) {
+ const k = _target.kind;
+ const id = _target.id ?? _target.ref;
+ _send('scene.setVisible', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, visible: !!vis });
+ },
+ /** Включить/выключить столкновения объекта-носителя (проходимость). */
+ setCollide(can) {
+ const k = _target.kind;
+ const id = _target.id ?? _target.ref;
+ _send('scene.setCollide', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, canCollide: !!can });
+ },
+ /** Перекрасить объект-носитель (только примитив). */
+ setColor(hex) {
+ if (typeof hex !== 'string') return;
+ const k = _target.kind;
+ const id = _target.id ?? _target.ref;
+ _send('scene.setColor', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, color: hex });
+ },
+ /** Повесить текст-метку над объектом-носителем (имя/HP). */
+ setLabel(text, opts) {
+ const k = _target.kind;
+ const id = _target.id ?? _target.ref;
+ const ref = (k && id != null) ? (k + ':' + id) : undefined;
+ _send('scene.setLabel', { ref, text: String(text == null ? '' : text), opts: opts || {} });
+ },
+ /** Убрать метку с объекта-носителя. */
+ clearLabel() {
+ const k = _target.kind;
+ const id = _target.id ?? _target.ref;
+ const ref = (k && id != null) ? (k + ':' + id) : undefined;
+ _send('scene.clearLabel', { ref });
+ },
delete() {
_send('self.delete', { target: _target });
},
@@ -1966,6 +2027,32 @@ const game = {
const bag = _dataIndex[r];
return bag ? bag[key] : undefined;
},
+ // === Небо и атмосфера (задача 16) ===
+ /**
+ * Установить небо. Либо пресет, либо ручной gradient:
+ * game.scene.setSkybox({ preset: 'lowpoly-roblox' });
+ * game.scene.setSkybox({ mode:'gradient', topColor:'#4a90e2', bottomColor:'#cfd8dc' });
+ * Пресеты: clear-summer-day / lowpoly-roblox / cloudy / sunset / starry-night / space.
+ */
+ setSkybox(opts) { _send('scene.setSkybox', { opts: opts || {} }); },
+ /**
+ * Облака поверх неба:
+ * game.scene.setClouds({ enabled:true, cover:0.5, speed:0.02, color:'#ffffff' });
+ */
+ setClouds(opts) { _send('scene.setClouds', { opts: opts || {} }); },
+ /**
+ * Атмосферный туман:
+ * game.scene.setFog({ color:'#dddddd', density:0.005 });
+ * game.scene.setFog({ enabled:false });
+ */
+ setFog(opts) { _send('scene.setFog', { opts: opts || {} }); },
+ /** Объект управления небом: плавный переход + солнце. */
+ skybox: {
+ /** Плавный переход к пресету за N секунд: skybox.fadeTo({preset:'sunset'}, 2). */
+ fadeTo(opts, durationSec) { _send('scene.skyboxFadeTo', { opts: opts || {}, duration: Number(durationSec) || 2 }); },
+ /** Направление солнца (для анимации дуги): setSunDirection({x,y,z}). */
+ setSunDirection(dir) { _send('scene.skyboxSunDir', { dir: dir || {} }); },
+ },
/**
* Теги объектов (Фаза 5.6) — как CollectionService в Roblox.
* Помечаешь объекты тегом, потом находишь все объекты с тегом.
@@ -2508,6 +2595,10 @@ const game = {
opts = opts && typeof opts === 'object' ? opts : {};
this._opts = opts;
this._active = true;
+ // Колбэки можно передавать прямо в опциях show({ onPlay, onShow, onHide }).
+ if (typeof opts.onPlay === 'function') this._onPlay.push(opts.onPlay);
+ if (typeof opts.onShow === 'function') this._onShow.push(opts.onShow);
+ if (typeof opts.onHide === 'function') this._onHide.push(opts.onHide);
// 1) Заблокировать управление игроком (наблюдатель).
_send('player.setInputBlocked', { blocked: true });
game.hud.setVisible(false);
@@ -2943,6 +3034,7 @@ const game = {
_localSeq: 0,
_localToReal: new Map(), // localId → реальный loadingId (приходит в loadingShown)
_handlers: new Map(), // localId → { onSkip:[], onComplete:[] }
+ _onHide: [], // задача 05 — глобальные подписки на скрытие
show(opts) {
opts = opts && typeof opts === 'object' ? opts : {};
const localId = ++this._localSeq;
@@ -2961,11 +3053,27 @@ const game = {
setProgress(v) { _send('loading.setProgress', { localId, value: Number(v) || 0 }); },
setText(t) { _send('loading.setText', { localId, text: String(t == null ? '' : t) }); },
setCover(c) { _send('loading.setCover', { localId, cover: c }); },
+ setBackground(b) { _send('loading.setBackground', { localId, background: b }); },
close() { _send('loading.close', { localId }); },
onSkip(fn) { if (typeof fn === 'function') self._handlers.get(localId)?.onSkip.push(fn); },
onComplete(fn) { if (typeof fn === 'function') self._handlers.get(localId)?.onComplete.push(fn); },
};
},
+ // --- Задача 05: управление активным экраном без хэндла (стартовый/любой текущий) ---
+ /** Подписаться на скрытие экрана загрузки. */
+ onHide(fn) { if (typeof fn === 'function') this._onHide.push(fn); },
+ /** Сменить фоновое изображение текущего экрана. */
+ setBackground(b) { _send('loading.setBackground', { background: b }); },
+ /** Сменить текст текущего экрана. */
+ setText(t) { _send('loading.setText', { text: String(t == null ? '' : t) }); },
+ /** Сменить cover текущего экрана. */
+ setCover(c) { _send('loading.setCover', { cover: c }); },
+ /** Ручной прогресс текущего экрана. */
+ setProgress(v) { _send('loading.setProgress', { value: Number(v) || 0 }); },
+ /** Скрыть текущий экран. */
+ hide() { _send('loading.close', {}); },
+ /** Виден ли экран сейчас (синхронно из локального зеркала). */
+ isVisible() { return !!_loadingVisible; },
/** Типовой переход с фейковым прогрессом. Возвращает Promise (резолв по complete/skip). */
transition(opts) {
opts = opts && typeof opts === 'object' ? { ...opts } : {};
@@ -3083,6 +3191,28 @@ const game = {
clear() {
_send('inventory.clear', {});
},
+ // === Задача 44: drag-drop инвентарь (сетка 8×5 + hotbar 9 + стаки) ===
+ /** Добавить предмет по itemId со стаком. game.inventory.give('berry', 5). */
+ give(itemId, count) { _send('inv2.add', { itemId, count: Number(count) || 1 }); },
+ /** Убрать N предметов по itemId. */
+ take(itemId, count) { _send('inv2.remove', { itemId, count: Number(count) || 1 }); },
+ /** Открыть/закрыть/тоггл окна инвентаря. */
+ open() { _send('inv2.open', {}); },
+ closeUi() { _send('inv2.close', {}); },
+ toggle() { _send('inv2.toggle', {}); },
+ /** Сортировать (by: 'rarity'|'name'). */
+ sort(by) { _send('inv2.sort', { by: by || 'rarity' }); },
+ /** Активный слот хотбара (0..8). */
+ setActiveHotbar(i) { _send('inv2.setActive', { i: Number(i) || 0 }); },
+ },
+
+ // === Определения предметов (задача 44) ===
+ items: {
+ /** Зарегистрировать предмет: {id,name,emoji|icon,rarity,maxStack,description,value,tags,onUseEffect}. */
+ define(def) {
+ if (Array.isArray(def)) { for (const d of def) _send('items.define', { def: d }); return; }
+ _send('items.define', { def: def || {} });
+ },
},
/**
* Игроки комнаты (Фаза 4.3 — мультиплеер).
@@ -3110,6 +3240,62 @@ const game = {
return p ? { ...p } : null;
},
},
+
+ // === Лидерборды (leaderstats) — задача 20 ===
+ leaderstats: {
+ /** Зарегистрировать стат: define('Монеты', {initial,format,icon,color,primary}). */
+ define(name, opts) {
+ if (typeof name !== 'string' || !name) return;
+ _send('leaderstats.define', { name, opts: opts || {} });
+ },
+ /** Установить стат игрока (playerId=null → текущий). */
+ set(playerId, name, value) {
+ _send('leaderstats.set', { playerId: playerId == null ? null : String(playerId), name, value: Number(value) || 0 });
+ const pid = playerId == null ? '@me' : String(playerId);
+ if (!_lsMirror[pid]) _lsMirror[pid] = {};
+ _lsMirror[pid][name] = Number(value) || 0;
+ },
+ /** Прибавить к стату. */
+ add(playerId, name, delta) {
+ _send('leaderstats.add', { playerId: playerId == null ? null : String(playerId), name, delta: Number(delta) || 0 });
+ const pid = playerId == null ? '@me' : String(playerId);
+ if (!_lsMirror[pid]) _lsMirror[pid] = {};
+ _lsMirror[pid][name] = (_lsMirror[pid][name] || 0) + (Number(delta) || 0);
+ },
+ /** Прочитать стат (из локального зеркала). */
+ get(playerId, name) {
+ const pid = playerId == null ? '@me' : String(playerId);
+ return (_lsMirror[pid] && _lsMirror[pid][name]) || 0;
+ },
+ /** Подписка на изменение: onChange((playerId, name, newVal, oldVal) => {}). */
+ onChange(fn) { if (typeof fn === 'function') _lsChangeHandlers.push(fn); },
+ /** Шорткат для текущего игрока. */
+ me: {
+ set(name, value) { game.leaderstats.set(null, name, value); },
+ add(name, delta) { game.leaderstats.add(null, name, delta); },
+ get(name) { return game.leaderstats.get(null, name); },
+ },
+ },
+
+ // === Достижения — задача 20 ===
+ achievements: {
+ /** Объявить достижения: define([{id,name,description,icon,rarity,points,hidden}]). */
+ define(list) { _send('achievements.define', { list: Array.isArray(list) ? list : [list] }); },
+ /** Разблокировать достижение. */
+ unlock(id, playerId) {
+ if (typeof id !== 'string') return;
+ _achUnlocked[id] = true;
+ _send('achievements.unlock', { id, playerId: playerId == null ? null : String(playerId) });
+ },
+ /** Разблокировано ли (из зеркала). */
+ has(id) { return !!_achUnlocked[id]; },
+ /** Авто-unlock по достижению значения leaderstat. */
+ bindToStat(id, statName, cond) { _send('achievements.bindToStat', { id, statName, cond: cond || {} }); },
+ /** Показать/скрыть кнопку-кубок. */
+ setButtonVisible(v) { _send('achievements.setButtonVisible', { visible: !!v }); },
+ /** Открыть страницу достижений. */
+ openPage() { _send('achievements.openPage', {}); },
+ },
/**
* Общее состояние комнаты (Фаза 4.3) — данные, видимые всем игрокам.
* В одиночной игре работает как локальное хранилище.
@@ -3298,6 +3484,25 @@ const game = {
* trail — шлейф за движущимся объектом.
*/
fx: {
+ /**
+ * Всплывающая цифра урона (задача 40). position — {x,y,z} или ref
+ * объекта; value — число или строка; opts — color/isCrit/isHeal/isMana/
+ * isMiss/fontSize/floatHeight/lifetime/randomOffset/stackKey/comicStyle.
+ * game.fx.damageFloater(enemy.position, 25);
+ * game.fx.damageFloater(pos, 100, { isCrit: true });
+ * game.fx.damageFloater(pos, 30, { isHeal: true });
+ */
+ damageFloater(position, value, opts) {
+ const pos = _normFxPoint(position);
+ _send('fx.damageFloater', { position: pos, value, opts: opts || {} });
+ },
+ /**
+ * Авто-floater'ы над мобами (NPC) при потере HP. Включил один раз — любой
+ * урон по NPC сам показывает облачко «-N». game.fx.autoMobFloaters(true).
+ */
+ autoMobFloaters(enabled, opts) {
+ _send('fx.autoMobFloaters', { enabled: enabled !== false, opts: opts || {} });
+ },
/**
* Луч между двумя точками. opts: { from, to — {x,y,z} или ref
* объекта (тогда луч следит за ним); color: '#hex', width }.
@@ -4197,6 +4402,15 @@ self.onmessage = (e) => {
const t = payload?.type;
if (t === 'click') {
for (const fn of _globalClickHandlers) _safeCall(fn, payload, 'onClick');
+ } else if (t === 'leaderstatsChange') {
+ // Задача 20: стат изменился на сервере/main — обновляем зеркало + onChange.
+ const pid = payload.playerId == null ? '@me' : String(payload.playerId);
+ if (!_lsMirror[pid]) _lsMirror[pid] = {};
+ _lsMirror[pid][payload.name] = payload.newValue;
+ if (payload.isMe) { if (!_lsMirror['@me']) _lsMirror['@me'] = {}; _lsMirror['@me'][payload.name] = payload.newValue; }
+ for (const fn of _lsChangeHandlers) { try { fn(payload.playerId, payload.name, payload.newValue, payload.oldValue); } catch (err) { _send('log', { level: 'error', text: 'leaderstats.onChange: ' + (err && err.message ? err.message : err) }); } }
+ } else if (t === 'achievementUnlocked') {
+ _achUnlocked[payload.id] = true;
} else if (t === 'mouseMove') {
for (const fn of _mouseMoveHandlers) {
try { fn(payload.x, payload.y); }
@@ -4402,6 +4616,7 @@ self.onmessage = (e) => {
} else if (t === 'loadingShown') {
// Задача 12: реальный loadingId от runtime — маппим local→real, чтобы
// setProgress/close/колбэки нашли нужный экран.
+ _loadingVisible = true;
try {
const lo = (typeof game !== 'undefined') && game.loading;
if (lo && payload && payload.replyId) {
@@ -4411,6 +4626,13 @@ self.onmessage = (e) => {
}
}
} catch (e) {}
+ } else if (t === 'loadingHidden') {
+ // Задача 05: экран загрузки скрылся — обновляем зеркало + onHide-подписки.
+ _loadingVisible = false;
+ try {
+ const lo = (typeof game !== 'undefined') && game.loading;
+ if (lo) for (const fn of (lo._onHide || [])) _safeCall(fn, undefined, 'loading.onHide');
+ } catch (e) {}
} else if (t === 'loadingSkip' || t === 'loadingComplete') {
// Игрок нажал «Пропустить» (skip) или прогресс достиг 100% (complete).
// Находим local по real loadingId и зовём соответствующие подписчики.
diff --git a/src/editor/engine/SelectionManager.js b/src/editor/engine/SelectionManager.js
index 325b8aa..f20b397 100644
--- a/src/editor/engine/SelectionManager.js
+++ b/src/editor/engine/SelectionManager.js
@@ -76,24 +76,37 @@ export class SelectionManager {
selectByMesh(mesh) {
if (!mesh) return this.clear();
const m = mesh.metadata;
+ // Если объект лежит в папке — клик по СЦЕНЕ выделяет ВСЮ папку целиком
+ // (отдельную часть можно выбрать через дерево). folderId берём из data.
+ const folderIdOf = (kind, id) => {
+ let d = null;
+ if (kind === 'model') d = this.modelManager?.instances?.get(id);
+ else if (kind === 'userModel') d = this.userModelManager?.instances?.get(id);
+ else if (kind === 'primitive') d = this.primitiveManager?.instances?.get(id);
+ return d ? (d.folderId ?? null) : null;
+ };
if (m?.isBlock) {
return this.selectBlockAt(m.gridX, m.gridY, m.gridZ);
}
if (m?.isModel) {
- // Заблокированный объект (Фаза 5.11) не выделяется кликом по
- // сцене — только через иерархию (чтобы можно было снять lock).
const md = this.modelManager?.instances?.get(m.instanceId);
if (md && md.locked) return this.clear();
+ const fid = folderIdOf('model', m.instanceId);
+ if (fid != null) return this.selectFolder(fid);
return this.selectModelByInstanceId(m.instanceId);
}
if (m?.isUserModel) {
const ud = this.userModelManager?.instances?.get(m.instanceId);
if (ud && ud.locked) return this.clear();
+ const fid = folderIdOf('userModel', m.instanceId);
+ if (fid != null) return this.selectFolder(fid);
return this.selectUserModelByInstanceId(m.instanceId);
}
if (m?.isPrimitive) {
const pd = this.primitiveManager?.instances?.get(m.primitiveId);
if (pd && pd.locked) return this.clear();
+ const fid = folderIdOf('primitive', m.primitiveId);
+ if (fid != null) return this.selectFolder(fid);
return this.selectPrimitiveById(m.primitiveId);
}
if (m?.isSpawn) {
@@ -116,6 +129,11 @@ export class SelectionManager {
primitiveType: data.type,
x: data.x, y: data.y, z: data.z,
sx: data.sx, sy: data.sy, sz: data.sz,
+ // Вращение — нужно для корректного копирования/дублирования
+ // (без него копия теряла поворот, баг 2026-06-04).
+ rotationX: data.rotationX || 0,
+ rotationY: data.rotationY || 0,
+ rotationZ: data.rotationZ || 0,
color: data.color,
material: data.material,
canCollide: data.canCollide,
@@ -194,6 +212,29 @@ export class SelectionManager {
this._notifyChange();
}
+ /**
+ * Выделить ПАПКУ целиком: подсветить все объекты внутри + поставить
+ * selection.type='folder'. Групповой gizmo привязывается в BabylonScene.
+ */
+ selectFolder(folderId) {
+ const fm = this._scene3d?.folderManager;
+ if (!fm) return;
+ const g = fm.getFolderObjects(folderId);
+ this._removeHighlight();
+ this._multi = [];
+ for (const m of g.meshes) this._highlightMesh(m);
+ this._selection = {
+ type: 'folder',
+ folderId,
+ center: g.center,
+ count: g.count,
+ meshes: g.meshes,
+ };
+ this._notifyChange();
+ // Поставить групповой gizmo на пивот папки.
+ this._scene3d?._attachFolderGizmo?.(folderId, g.center);
+ }
+
/** Выделить псевдо-объект «Пол» — настройки grid'а пола. */
selectFloor() {
if (!this._scene3d) return;
@@ -676,10 +717,27 @@ export class SelectionManager {
this.blockManager.removeBlock(this._selection.gridX, this._selection.gridY, this._selection.gridZ);
} else if (this._selection.type === 'model') {
this.modelManager.removeInstance(this._selection.instanceId);
+ this.clear(); this._scene3d?._cleanupOrphanScripts?.(); return;
} else if (this._selection.type === 'userModel') {
this.userModelManager.removeInstance(this._selection.instanceId);
+ this.clear(); this._scene3d?._cleanupOrphanScripts?.(); return;
} else if (this._selection.type === 'primitive') {
this.primitiveManager.removeInstance(this._selection.id);
+ this.clear(); this._scene3d?._cleanupOrphanScripts?.(); return;
+ } else if (this._selection.type === 'spawn') {
+ // Удаление точки спавна → игрок будет появляться в (0, высота, 0).
+ this._scene3d?.deleteSpawn?.();
+ } else if (this._selection.type === 'folder') {
+ // Папка целиком — удаляем со ВСЕМ содержимым (рекурсивно).
+ const fid = this._selection.folderId;
+ this.clear();
+ this._scene3d?.folderManager?.removeFolder?.(fid, true);
+ // Удаляем скрипты, привязанные к объектам этой папки? Они привязаны
+ // к примитивам, которые removeFolder удалит; скрипты на них чистятся
+ // через _onSceneChange / при сохранении. Дополнительно пусть движок
+ // подчистит «осиротевшие» скрипты.
+ this._scene3d?._cleanupOrphanScripts?.();
+ return;
}
this.clear();
}
diff --git a/src/editor/engine/SkyboxManager.js b/src/editor/engine/SkyboxManager.js
new file mode 100644
index 0000000..9c9e497
--- /dev/null
+++ b/src/editor/engine/SkyboxManager.js
@@ -0,0 +1,571 @@
+/**
+ * SkyboxManager — кастомное небо для сцены (задача 16).
+ *
+ * Реализует процедурный gradient-skybox без внешних текстур (работает offline):
+ * - купол-сфера (BACKSIDE) с ShaderMaterial: плавный градиент верх→низ,
+ * солнечный диск, лёгкая дымка у горизонта;
+ * - low-poly горы на горизонте (как в Roblox-эталоне);
+ * - billboard-облака (плоскости, медленный дрейф);
+ * - атмосферный туман (scene.fog).
+ *
+ * Пресеты: clear-summer-day / cloudy / sunset / starry-night / space /
+ * lowpoly-roblox (+ gradient вручную). Плавный fadeTo между ними.
+ *
+ * API (через game.scene.*):
+ * setSkybox({ preset } | { mode:'gradient', topColor, bottomColor, ... })
+ * setClouds({ enabled, cover, density, speed, color })
+ * setFog({ color, density, near, far } | enabled:false)
+ * skybox.fadeTo(opts, durationSec)
+ * skybox.setSunDirection({x,y,z})
+ *
+ * Фича-парность: при портировании в плеер — тот же модуль в rublox-player/src/engine/.
+ */
+import {
+ Color3, Color4, Vector3,
+ MeshBuilder, ShaderMaterial, Effect, StandardMaterial, Texture,
+ DynamicTexture, VertexData, Mesh,
+} from '@babylonjs/core';
+
+// ── Шейдер градиентного неба ──────────────────────────────────────────────
+// Купол изнутри: цвет интерполируется по высоте (y от -1 низ до +1 верх),
+// плюс солнечный диск и осветление у горизонта (дымка).
+const SKY_VERT = `
+precision highp float;
+attribute vec3 position;
+uniform mat4 worldViewProjection;
+varying vec3 vDir;
+void main(void){
+ vDir = normalize(position);
+ gl_Position = worldViewProjection * vec4(position, 1.0);
+}`;
+
+const SKY_FRAG = `
+precision highp float;
+varying vec3 vDir;
+uniform vec3 topColor;
+uniform vec3 bottomColor;
+uniform vec3 horizonColor;
+uniform vec3 sunDir;
+uniform vec3 sunColor;
+uniform float sunSize; // 0..1 угловой радиус
+uniform float horizonHaze; // 0..1 сила дымки у горизонта
+void main(void){
+ float h = clamp(vDir.y * 0.5 + 0.5, 0.0, 1.0); // 0 низ .. 1 верх
+ // Градиент: низ→горизонт→верх (двойная интерполяция через горизонт у h~0.5)
+ vec3 col;
+ if (h < 0.5) {
+ col = mix(bottomColor, horizonColor, h * 2.0);
+ } else {
+ col = mix(horizonColor, topColor, (h - 0.5) * 2.0);
+ }
+ // Дымка у горизонта — осветление узкой полосы около h=0.5
+ float haze = smoothstep(0.5, 0.0, abs(h - 0.5)) * horizonHaze;
+ col = mix(col, horizonColor + vec3(0.08), haze * 0.5);
+ // Солнечный диск + гало
+ float d = distance(normalize(vDir), normalize(sunDir));
+ float disk = smoothstep(sunSize, sunSize * 0.4, d);
+ float glow = smoothstep(sunSize * 6.0, 0.0, d) * 0.35;
+ col += sunColor * disk;
+ col += sunColor * glow;
+ gl_FragColor = vec4(col, 1.0);
+}`;
+
+let _shaderRegistered = false;
+function registerSkyShader() {
+ if (_shaderRegistered) return;
+ Effect.ShadersStore['kubikonSkyVertexShader'] = SKY_VERT;
+ Effect.ShadersStore['kubikonSkyFragmentShader'] = SKY_FRAG;
+ _shaderRegistered = true;
+}
+
+const hexToRgb = (hex) => {
+ if (Array.isArray(hex)) return hex;
+ let h = String(hex || '#ffffff').replace('#', '').trim();
+ // Короткая форма #fff → #ffffff.
+ if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
+ if (h.length < 6) h = (h + 'ffffff').slice(0, 6);
+ const r = parseInt(h.substring(0, 2), 16);
+ const g = parseInt(h.substring(2, 4), 16);
+ const b = parseInt(h.substring(4, 6), 16);
+ return [
+ (Number.isFinite(r) ? r : 255) / 255,
+ (Number.isFinite(g) ? g : 255) / 255,
+ (Number.isFinite(b) ? b : 255) / 255,
+ ];
+};
+
+// ── Пресеты неба ──────────────────────────────────────────────────────────
+// top/bottom/horizon — цвета купола; sun — направление/цвет/размер солнца;
+// mountains — рисовать ли горы; clouds — параметры облаков; fog — туман;
+// stars — звёздное небо (для ночи/космоса).
+const PRESETS = {
+ 'clear-summer-day': {
+ top: '#3d7fe0', horizon: '#bcd9f2', bottom: '#dcebf7',
+ sunDir: [0.3, 0.85, 0.4], sunColor: '#fff6d8', sunSize: 0.035, haze: 0.6,
+ mountains: false,
+ clouds: { enabled: true, cover: 0.25, color: '#ffffff', speed: 0.015 },
+ fog: { color: '#cfe2f2', density: 0.0035 },
+ light: { sunIntensity: 1.0, sunColor: '#fff6e0', hemiIntensity: 0.7, ambient: '#b9d4ee' },
+ },
+ 'lowpoly-roblox': {
+ top: '#4a93e6', horizon: '#cfe6f5', bottom: '#e6f1fa',
+ sunDir: [0.4, 0.8, 0.45], sunColor: '#fffbe6', sunSize: 0.03, haze: 0.85,
+ mountains: true,
+ clouds: { enabled: true, cover: 0.45, color: '#ffffff', speed: 0.012 },
+ fog: { color: '#e2eef7', density: 0.005 },
+ light: { sunIntensity: 0.95, sunColor: '#fff7e0', hemiIntensity: 0.75, ambient: '#cfe6f5' },
+ },
+ 'cloudy': {
+ top: '#8fa6bd', horizon: '#c2ccd6', bottom: '#d8dde2',
+ sunDir: [0.2, 0.7, 0.3], sunColor: '#e8e8e8', sunSize: 0.0, haze: 0.4,
+ mountains: false,
+ clouds: { enabled: true, cover: 0.8, color: '#e8ebef', speed: 0.02 },
+ fog: { color: '#cfd6dd', density: 0.008 },
+ light: { sunIntensity: 0.5, sunColor: '#dfe4ea', hemiIntensity: 0.8, ambient: '#c2ccd6' },
+ },
+ 'sunset': {
+ top: '#2a3a6b', horizon: '#f5915a', bottom: '#f7c98a',
+ sunDir: [0.7, 0.12, -0.2], sunColor: '#ffd28a', sunSize: 0.05, haze: 1.0,
+ mountains: true,
+ clouds: { enabled: true, cover: 0.35, color: '#ffd9b3', speed: 0.01 },
+ fog: { color: '#f0b483', density: 0.006 },
+ light: { sunIntensity: 0.7, sunColor: '#ff9a52', hemiIntensity: 0.5, ambient: '#9a6a78' },
+ },
+ 'starry-night': {
+ top: '#070b1f', horizon: '#1b2547', bottom: '#243056',
+ sunDir: [-0.4, 0.75, 0.3], sunColor: '#cdd6ff', sunSize: 0.02, haze: 0.3,
+ mountains: true, stars: true,
+ clouds: { enabled: false },
+ fog: { color: '#141c38', density: 0.004 },
+ light: { sunIntensity: 0.18, sunColor: '#9aa8d8', hemiIntensity: 0.25, ambient: '#1b2547' },
+ },
+ 'space': {
+ top: '#02030a', horizon: '#06070f', bottom: '#0a0c18',
+ sunDir: [0.5, 0.6, 0.4], sunColor: '#ffffff', sunSize: 0.015, haze: 0.0,
+ mountains: false, stars: true,
+ clouds: { enabled: false },
+ fog: { enabled: false },
+ light: { sunIntensity: 0.6, sunColor: '#ffffff', hemiIntensity: 0.2, ambient: '#10121c' },
+ },
+};
+
+export class SkyboxManager {
+ constructor(scene, hemiLight, sunLight) {
+ this.scene = scene;
+ this.hemiLight = hemiLight || null; // ambient
+ this.sunLight = sunLight || null; // directional (тени)
+ this._dome = null;
+ this._mat = null;
+ this._mountains = null;
+ this._clouds = []; // [{mesh, baseX, speed}]
+ this._cloudRoot = null;
+ this._stars = null;
+ this._fade = null; // активный fadeTo {from,to,t,dur}
+ this._state = this._defaultState();
+ registerSkyShader();
+ this._buildDome();
+ }
+
+ _defaultState() {
+ return {
+ mode: 'gradient',
+ top: '#4a93e6', horizon: '#cfe6f5', bottom: '#e6f1fa',
+ sunDir: [0.4, 0.8, 0.45], sunColor: '#fffbe6', sunSize: 0.03, haze: 0.8,
+ mountains: false, stars: false,
+ clouds: { enabled: false, cover: 0.4, color: '#ffffff', speed: 0.012 },
+ fog: { enabled: false, color: '#dde8f2', density: 0.005 },
+ light: { sunIntensity: 0.95, sunColor: '#fff7e0', hemiIntensity: 0.75, ambient: '#cfe6f5' },
+ };
+ }
+
+ // ── Купол ──────────────────────────────────────────────────────────────
+ _buildDome() {
+ const dome = MeshBuilder.CreateSphere('kubikonSkyDome', {
+ diameter: 1000, segments: 24, sideOrientation: Mesh.BACKSIDE,
+ }, this.scene);
+ dome.isPickable = false;
+ dome.infiniteDistance = true; // не двигается с камерой
+ dome.renderingGroupId = 0;
+ dome.applyFog = false;
+
+ const mat = new ShaderMaterial('kubikonSkyMat', this.scene, {
+ vertex: 'kubikonSky', fragment: 'kubikonSky',
+ }, {
+ attributes: ['position'],
+ uniforms: ['worldViewProjection', 'topColor', 'bottomColor',
+ 'horizonColor', 'sunDir', 'sunColor', 'sunSize', 'horizonHaze'],
+ });
+ mat.backFaceCulling = false;
+ mat.disableDepthWrite = true; // небо всегда позади
+ dome.material = mat;
+ this._dome = dome;
+ this._mat = mat;
+ this._applyShaderUniforms();
+ }
+
+ _applyShaderUniforms() {
+ const s = this._state;
+ const m = this._mat;
+ if (!m) return;
+ m.setColor3('topColor', Color3.FromArray(hexToRgb(s.top)));
+ m.setColor3('bottomColor', Color3.FromArray(hexToRgb(s.bottom)));
+ m.setColor3('horizonColor', Color3.FromArray(hexToRgb(s.horizon)));
+ const sd = Array.isArray(s.sunDir) ? s.sunDir : [0.4, 0.8, 0.45];
+ m.setVector3('sunDir', new Vector3(sd[0], sd[1], sd[2]).normalize());
+ m.setColor3('sunColor', Color3.FromArray(hexToRgb(s.sunColor)));
+ m.setFloat('sunSize', s.sunSize || 0.03);
+ m.setFloat('horizonHaze', s.haze != null ? s.haze : 0.7);
+ }
+
+ // ── Горы (low-poly на горизонте) ────────────────────────────────────────
+ _buildMountains(colorHex) {
+ this._disposeMountains();
+ const positions = [], indices = [];
+ const ringR = 420, baseY = -10, segs = 64;
+ // Кольцо из треугольных пиков переменной высоты — стилизованный силуэт.
+ let vi = 0;
+ for (let i = 0; i < segs; i++) {
+ const a0 = (i / segs) * Math.PI * 2;
+ const a1 = ((i + 1) / segs) * Math.PI * 2;
+ const am = (a0 + a1) / 2;
+ // Псевдослучайная высота пика (детерминированно от индекса).
+ const hh = 40 + Math.abs(Math.sin(i * 1.7) * Math.cos(i * 0.6)) * 130;
+ const x0 = Math.cos(a0) * ringR, z0 = Math.sin(a0) * ringR;
+ const x1 = Math.cos(a1) * ringR, z1 = Math.sin(a1) * ringR;
+ const xm = Math.cos(am) * ringR, zm = Math.sin(am) * ringR;
+ positions.push(x0, baseY, z0, x1, baseY, z1, xm, baseY + hh, zm);
+ indices.push(vi, vi + 1, vi + 2);
+ vi += 3;
+ }
+ const vd = new VertexData();
+ vd.positions = positions; vd.indices = indices;
+ const normals = [];
+ VertexData.ComputeNormals(positions, indices, normals);
+ vd.normals = normals;
+ const mesh = new Mesh('kubikonSkyMountains', this.scene);
+ vd.applyToMesh(mesh);
+ mesh.isPickable = false;
+ mesh.applyFog = true; // горы выцветают в туман (атмосфера)
+ mesh.renderingGroupId = 0;
+ const mat = new StandardMaterial('kubikonSkyMountainsMat', this.scene);
+ const c = hexToRgb(colorHex || '#8fa98a');
+ mat.diffuseColor = new Color3(c[0], c[1], c[2]);
+ mat.specularColor = new Color3(0, 0, 0);
+ mat.emissiveColor = new Color3(c[0] * 0.25, c[1] * 0.25, c[2] * 0.25);
+ mesh.material = mat;
+ this._mountains = mesh;
+ }
+
+ _disposeMountains() {
+ if (this._mountains) { this._mountains.material?.dispose(); this._mountains.dispose(); this._mountains = null; }
+ }
+
+ // ── Облака (billboard-плоскости) ────────────────────────────────────────
+ _buildClouds(opts) {
+ this._disposeClouds();
+ const o = opts || {};
+ if (!o.enabled) return;
+ const cover = o.cover != null ? o.cover : 0.4;
+ const count = Math.round(4 + cover * 16); // 4..20 облаков
+ const tex = this._makeCloudTexture(o.color || '#ffffff');
+ for (let i = 0; i < count; i++) {
+ const w = 60 + Math.random() * 90;
+ const plane = MeshBuilder.CreatePlane(`kubikonCloud_${i}`, { width: w, height: w * 0.55 }, this.scene);
+ plane.billboardMode = Mesh.BILLBOARDMODE_ALL;
+ plane.isPickable = false;
+ plane.applyFog = false;
+ plane.renderingGroupId = 0;
+ const mat = new StandardMaterial(`kubikonCloudMat_${i}`, this.scene);
+ mat.diffuseTexture = tex;
+ mat.opacityTexture = tex;
+ mat.emissiveColor = new Color3(1, 1, 1);
+ mat.disableLighting = true;
+ mat.backFaceCulling = false;
+ plane.material = mat;
+ const ang = Math.random() * Math.PI * 2;
+ const rad = 150 + Math.random() * 200;
+ const x = Math.cos(ang) * rad;
+ const z = Math.sin(ang) * rad;
+ const y = 90 + Math.random() * 70;
+ plane.position.set(x, y, z);
+ this._clouds.push({ mesh: plane, speed: (o.speed || 0.012) * (0.6 + Math.random() * 0.8) });
+ }
+ }
+
+ /** Процедурная мягкая текстура-облако (radial-gradient на DynamicTexture). */
+ _makeCloudTexture(colorHex) {
+ const size = 256;
+ const dt = new DynamicTexture('kubikonCloudTex', { width: size, height: size }, this.scene, false);
+ const ctx = dt.getContext();
+ ctx.clearRect(0, 0, size, size);
+ const c = hexToRgb(colorHex);
+ const rgb = `${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)}`;
+ // Несколько перекрывающихся мягких кругов → пухлое облако.
+ const blobs = [[128, 140, 70], [90, 150, 50], [170, 150, 55], [128, 110, 55], [110, 130, 45], [150, 130, 45]];
+ for (const [bx, by, br] of blobs) {
+ const g = ctx.createRadialGradient(bx, by, 0, bx, by, br);
+ g.addColorStop(0, `rgba(${rgb},0.9)`);
+ g.addColorStop(0.6, `rgba(${rgb},0.5)`);
+ g.addColorStop(1, `rgba(${rgb},0)`);
+ ctx.fillStyle = g;
+ ctx.beginPath(); ctx.arc(bx, by, br, 0, Math.PI * 2); ctx.fill();
+ }
+ dt.hasAlpha = true;
+ dt.update();
+ return dt;
+ }
+
+ _disposeClouds() {
+ for (const c of this._clouds) { c.mesh.material?.diffuseTexture?.dispose?.(); c.mesh.material?.dispose(); c.mesh.dispose(); }
+ this._clouds = [];
+ }
+
+ // ── Звёзды (точки на куполе) ─────────────────────────────────────────────
+ _buildStars(enabled) {
+ this._disposeStars();
+ if (!enabled) return;
+ const size = 1024;
+ const dt = new DynamicTexture('kubikonStarsTex', { width: size, height: size }, this.scene, false);
+ const ctx = dt.getContext();
+ ctx.fillStyle = 'rgba(0,0,0,0)'; ctx.clearRect(0, 0, size, size);
+ for (let i = 0; i < 600; i++) {
+ const x = Math.random() * size, y = Math.random() * size;
+ const r = Math.random() * 1.4 + 0.3;
+ const a = 0.4 + Math.random() * 0.6;
+ ctx.fillStyle = `rgba(255,255,255,${a})`;
+ ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
+ }
+ dt.hasAlpha = true; dt.update();
+ const dome = MeshBuilder.CreateSphere('kubikonStarsDome', {
+ diameter: 980, segments: 16, sideOrientation: Mesh.BACKSIDE,
+ }, this.scene);
+ dome.isPickable = false; dome.infiniteDistance = true;
+ dome.applyFog = false; dome.renderingGroupId = 0;
+ const mat = new StandardMaterial('kubikonStarsMat', this.scene);
+ mat.diffuseTexture = dt; mat.opacityTexture = dt;
+ mat.emissiveColor = new Color3(1, 1, 1);
+ mat.disableLighting = true; mat.backFaceCulling = false;
+ mat.disableDepthWrite = true;
+ dome.material = mat;
+ this._stars = dome;
+ }
+
+ _disposeStars() {
+ if (this._stars) { this._stars.material?.diffuseTexture?.dispose?.(); this._stars.material?.dispose(); this._stars.dispose(); this._stars = null; }
+ }
+
+ // ── Туман ────────────────────────────────────────────────────────────────
+ _applyFog(fog) {
+ if (!this.scene) return;
+ if (fog && fog.enabled !== false && (fog.density != null || fog.color)) {
+ this.scene.fogMode = 2; // EXP
+ const c = hexToRgb(fog.color || '#dde8f2');
+ this.scene.fogColor = new Color3(c[0], c[1], c[2]);
+ this.scene.fogDensity = fog.density != null ? fog.density : 0.005;
+ } else if (fog && fog.enabled === false) {
+ this.scene.fogMode = 0;
+ }
+ }
+
+ // ── Освещение (единый источник: небо управляет светом сцены) ─────────────
+ /** Выставить направление/яркость солнца и ambient под текущее небо. */
+ _applyLighting(light, sunDir) {
+ if (this.sunLight && sunDir) {
+ // DirectionalLight.direction указывает КУДА падает свет → от солнца вниз.
+ const d = new Vector3(-sunDir[0], -sunDir[1], -sunDir[2]);
+ if (d.lengthSquared() > 0) { d.normalize(); this.sunLight.direction = d; }
+ }
+ if (!light) return;
+ if (this.sunLight) {
+ if (typeof light.sunIntensity === 'number') this.sunLight.intensity = light.sunIntensity;
+ if (light.sunColor) this.sunLight.diffuse = Color3.FromArray(hexToRgb(light.sunColor));
+ }
+ if (this.hemiLight) {
+ if (typeof light.hemiIntensity === 'number') this.hemiLight.intensity = light.hemiIntensity;
+ if (light.ambient) this.hemiLight.groundColor = Color3.FromArray(hexToRgb(light.ambient));
+ }
+ }
+
+ // ── Public API ───────────────────────────────────────────────────────────
+
+ /** Применить пресет или ручные опции gradient. */
+ setSkybox(opts) {
+ if (!opts) return;
+ const preset = opts.preset && PRESETS[opts.preset] ? { ...PRESETS[opts.preset] } : null;
+ const s = this._state;
+ if (preset) {
+ s.top = preset.top; s.horizon = preset.horizon; s.bottom = preset.bottom;
+ s.sunDir = preset.sunDir; s.sunColor = preset.sunColor; s.sunSize = preset.sunSize;
+ s.haze = preset.haze; s.mountains = !!preset.mountains; s.stars = !!preset.stars;
+ s.clouds = { ...(preset.clouds || { enabled: false }) };
+ s.fog = { enabled: preset.fog?.enabled !== false, ...(preset.fog || {}) };
+ s.light = preset.light || null;
+ this._applyLighting(preset.light, preset.sunDir);
+ } else {
+ // Ручной gradient: { topColor, bottomColor, horizonColor, sunDirection, sunColor, sunSize }
+ if (opts.topColor) s.top = opts.topColor;
+ if (opts.bottomColor) s.bottom = opts.bottomColor;
+ if (opts.horizonColor) s.horizon = opts.horizonColor;
+ if (opts.sunDirection) s.sunDir = [opts.sunDirection.x, opts.sunDirection.y, opts.sunDirection.z];
+ if (opts.sunColor) s.sunColor = opts.sunColor;
+ if (typeof opts.sunSize === 'number') s.sunSize = opts.sunSize;
+ if (typeof opts.haze === 'number') s.haze = opts.haze;
+ if (typeof opts.mountains === 'boolean') s.mountains = opts.mountains;
+ if (typeof opts.stars === 'boolean') s.stars = opts.stars;
+ }
+ this._rebuildAll();
+ }
+
+ /** Облака поверх любого режима. */
+ setClouds(opts) {
+ if (!opts) return;
+ this._state.clouds = { ...this._state.clouds, ...opts };
+ if (this._state.clouds.enabled == null) this._state.clouds.enabled = true;
+ this._buildClouds(this._state.clouds);
+ }
+
+ /** Атмосферный туман. */
+ setFog(opts) {
+ if (!opts) { return; }
+ this._state.fog = { ...this._state.fog, ...opts };
+ if (opts.enabled == null) this._state.fog.enabled = true;
+ this._applyFog(this._state.fog);
+ }
+
+ /** Установить направление солнца (для программной анимации). */
+ setSunDirection(dir) {
+ if (!dir) return;
+ this._state.sunDir = [dir.x, dir.y, dir.z];
+ this._applyShaderUniforms();
+ }
+
+ /** Плавный переход к пресету/опциям за durationSec секунд (цвета купола + туман). */
+ fadeTo(opts, durationSec = 2) {
+ const target = opts?.preset && PRESETS[opts.preset] ? { ...PRESETS[opts.preset] } : null;
+ if (!target) { this.setSkybox(opts); return; }
+ // Запоминаем стартовые цвета и целевые — анимируем в tick().
+ this._fade = {
+ t: 0, dur: Math.max(0.1, durationSec),
+ from: {
+ top: hexToRgb(this._state.top), horizon: hexToRgb(this._state.horizon),
+ bottom: hexToRgb(this._state.bottom), sunColor: hexToRgb(this._state.sunColor),
+ sunDir: this._state.sunDir.slice(), sunSize: this._state.sunSize, haze: this._state.haze,
+ },
+ to: {
+ top: hexToRgb(target.top), horizon: hexToRgb(target.horizon),
+ bottom: hexToRgb(target.bottom), sunColor: hexToRgb(target.sunColor),
+ sunDir: target.sunDir.slice(), sunSize: target.sunSize, haze: target.haze,
+ },
+ target,
+ };
+ // Немедленно перестраиваем «дискретные» части (горы/звёзды/облака/туман
+ // целевого пресета появляются сразу, цвета купола — плавно).
+ const s = this._state;
+ s.mountains = !!target.mountains; s.stars = !!target.stars;
+ s.clouds = { ...(target.clouds || { enabled: false }) };
+ s.fog = { enabled: target.fog?.enabled !== false, ...(target.fog || {}) };
+ s.light = target.light || null;
+ this._rebuildExtras();
+ // Запоминаем стартовые/целевые значения света для плавной анимации.
+ if (target.light) {
+ this._fade.lightFrom = {
+ sunInt: this.sunLight?.intensity ?? 1,
+ hemiInt: this.hemiLight?.intensity ?? 0.7,
+ };
+ this._fade.lightTo = {
+ sunInt: target.light.sunIntensity ?? 1,
+ hemiInt: target.light.hemiIntensity ?? 0.7,
+ sunColor: target.light.sunColor, ambient: target.light.ambient,
+ };
+ }
+ }
+
+ /** Пересобрать всё (купол-uniforms + горы + звёзды + облака + туман + свет). */
+ _rebuildAll() {
+ this._applyShaderUniforms();
+ this._rebuildExtras();
+ this._applyLighting(this._state.light, this._state.sunDir);
+ }
+
+ _rebuildExtras() {
+ const s = this._state;
+ if (s.mountains) {
+ // Цвет гор подбираем под пресет (днём зеленовато-серый, ночью тёмный).
+ const mc = s.stars ? '#2a3550' : '#8fa98a';
+ this._buildMountains(mc);
+ } else this._disposeMountains();
+ this._buildStars(!!s.stars);
+ this._buildClouds(s.clouds);
+ this._applyFog(s.fog);
+ }
+
+ /** Вызывать каждый кадр: дрейф облаков + анимация fadeTo. */
+ tick(dt) {
+ // Дрейф облаков по кругу.
+ for (const c of this._clouds) {
+ c.mesh.position.x += c.speed * dt * 60;
+ if (c.mesh.position.x > 380) c.mesh.position.x = -380;
+ }
+ // Анимация перехода неба.
+ if (this._fade) {
+ this._fade.t += dt;
+ const k = Math.min(1, this._fade.t / this._fade.dur);
+ const f = this._fade.from, t = this._fade.to, m = this._mat;
+ const mix = (a, b) => [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k];
+ if (m) {
+ m.setColor3('topColor', Color3.FromArray(mix(f.top, t.top)));
+ m.setColor3('bottomColor', Color3.FromArray(mix(f.bottom, t.bottom)));
+ m.setColor3('horizonColor', Color3.FromArray(mix(f.horizon, t.horizon)));
+ m.setColor3('sunColor', Color3.FromArray(mix(f.sunColor, t.sunColor)));
+ const sd = mix(f.sunDir, t.sunDir);
+ m.setVector3('sunDir', new Vector3(sd[0], sd[1], sd[2]).normalize());
+ m.setFloat('sunSize', f.sunSize + (t.sunSize - f.sunSize) * k);
+ m.setFloat('horizonHaze', f.haze + (t.haze - f.haze) * k);
+ // Плавно ведём направление солнца (свет) к целевому (используем sd выше).
+ if (this.sunLight) {
+ const d = new Vector3(-sd[0], -sd[1], -sd[2]);
+ if (d.lengthSquared() > 0) { d.normalize(); this.sunLight.direction = d; }
+ }
+ }
+ // Плавно ведём яркость/ambient света.
+ if (this._fade.lightFrom && this._fade.lightTo) {
+ const lf = this._fade.lightFrom, lt = this._fade.lightTo;
+ if (this.sunLight) {
+ this.sunLight.intensity = lf.sunInt + (lt.sunInt - lf.sunInt) * k;
+ if (lt.sunColor) this.sunLight.diffuse = Color3.FromArray(hexToRgb(lt.sunColor));
+ }
+ if (this.hemiLight) {
+ this.hemiLight.intensity = lf.hemiInt + (lt.hemiInt - lf.hemiInt) * k;
+ if (lt.ambient) this.hemiLight.groundColor = Color3.FromArray(hexToRgb(lt.ambient));
+ }
+ }
+ if (k >= 1) {
+ // Зафиксировать целевое состояние в _state (как hex).
+ const tp = this._fade.target;
+ Object.assign(this._state, {
+ top: tp.top, horizon: tp.horizon, bottom: tp.bottom,
+ sunColor: tp.sunColor, sunDir: tp.sunDir.slice(),
+ sunSize: tp.sunSize, haze: tp.haze,
+ });
+ this._fade = null;
+ }
+ }
+ }
+
+ serialize() {
+ return { ...this._state, _active: true };
+ }
+
+ load(data) {
+ if (!data) return;
+ this._state = { ...this._defaultState(), ...data };
+ this._rebuildAll();
+ }
+
+ dispose() {
+ this._disposeMountains();
+ this._disposeClouds();
+ this._disposeStars();
+ if (this._dome) { this._mat?.dispose(); this._dome.dispose(); this._dome = null; }
+ }
+}
diff --git a/src/editor/engine/StudioCollab.js b/src/editor/engine/StudioCollab.js
new file mode 100644
index 0000000..68ee0d4
--- /dev/null
+++ b/src/editor/engine/StudioCollab.js
@@ -0,0 +1,502 @@
+/**
+ * StudioCollab — клиент совместного редактирования (Team Create) для студии.
+ *
+ * Подключается к Colyseus-комнате `studio` (одна на projectId) и:
+ * 1. ОТПРАВЛЯЕТ операции локального юзера (add/move/delete/setColor/...) —
+ * вызывается из обёрток в KubikonEditor/менеджерах через collab.sendOp(...).
+ * 2. ПРИНИМАЕТ операции других соавторов и применяет их к сцене
+ * (через applyRemoteOp → менеджеры BabylonScene), с флагом _fromRemote,
+ * чтобы не отправить их обратно (анти-эхо).
+ * 3. PRESENCE: рисует курсоры/выделение/онлайн-список соавторов
+ * (через колбэки onPresence — UI-слой подписывается).
+ * 4. SOFT-LOCK: select/deselect объекта; залоченный другим объект не дать
+ * двигать (onLockRejected колбэк для UI-уведомления).
+ *
+ * Транспорт переиспользует существующий kubikon-realtime (Colyseus 0.16).
+ * В Colyseus 0.16 state-колбэки идут через getStateCallbacks(room) (см.
+ * feedback_colyseus_016_api).
+ */
+import { Client, getStateCallbacks } from 'colyseus.js';
+import { REALTIME_WS } from '../../api/API';
+
+export class StudioCollab {
+ /**
+ * @param {object} scene — BabylonScene (со всеми менеджерами)
+ * @param {object} opts — { projectId, token, collabToken?, callbacks }
+ * callbacks: {
+ * onConnected({sessionId,color,isHost}), onError(msg), onLeft(),
+ * onPresenceChange(list), onOpRejected({key,by}),
+ * onSnapshotRequest(replyFn), onRemoteSnapshot(state), onChat(msg),
+ * }
+ */
+ constructor(scene, opts = {}) {
+ this.scene = scene;
+ this.projectId = opts.projectId;
+ this.token = opts.token;
+ this.collabToken = opts.collabToken || null;
+ this.cb = opts.callbacks || {};
+ this.room = null;
+ this.client = null;
+ this.sessionId = null;
+ this.color = '#5fd0ff';
+ this.isHost = false;
+ this.connected = false;
+ this._applyingRemote = false; // флаг анти-эхо
+ this._cursorThrottle = 0;
+ this._camThrottle = 0;
+ this._destroyed = false;
+ }
+
+ /** true если сейчас применяем удалённую операцию (не слать обратно). */
+ get applyingRemote() { return this._applyingRemote; }
+
+ /**
+ * Обернуть методы менеджеров, чтобы ЛЮБОЕ изменение сцены (из любого
+ * UI-пути) транслировалось соавторам. Анти-эхо: при _applyingRemote
+ * (мы применяем чужую операцию) обёртки молчат.
+ *
+ * Перехватываем минимальный набор для MVP:
+ * PrimitiveManager: addInstance, updateInstance, removeInstance
+ * ModelManager: addInstance, removeInstance
+ */
+ installInterceptors() {
+ const self = this;
+ const pm = this.scene.primitiveManager;
+ const mm = this.scene.modelManager;
+ if (pm && !pm.__collabPatched) {
+ pm.__collabPatched = true;
+ const origAdd = pm.addInstance.bind(pm);
+ pm.addInstance = function (type, opts = {}) {
+ const id = origAdd(type, opts);
+ if (id != null && !self._applyingRemote) {
+ // Шлём полный снимок объекта (с id), чтобы у соавтора создался
+ // ТОТ ЖЕ instanceId — иначе move-операции не совпадут.
+ const data = pm.instances?.get?.(id);
+ const def = serializePrimitive(data, id, type);
+ self.sendOp({ op: 'add', key: 'primitive:' + id, def });
+ }
+ return id;
+ };
+ const origUpd = pm.updateInstance.bind(pm);
+ pm.updateInstance = function (id, patch) {
+ const r = origUpd(id, patch);
+ if (!self._applyingRemote && patch && typeof patch === 'object') {
+ self.sendOp(patchToOp('primitive:' + id, patch));
+ }
+ return r;
+ };
+ const origRem = pm.removeInstance.bind(pm);
+ pm.removeInstance = function (id) {
+ const r = origRem(id);
+ if (!self._applyingRemote) self.sendOp({ op: 'delete', key: 'primitive:' + id });
+ return r;
+ };
+ }
+ // BlockManager — синхрон блоков (addBlock/removeBlock).
+ const bm = this.scene.blockManager;
+ if (bm && !bm.__collabPatched) {
+ bm.__collabPatched = true;
+ if (typeof bm.addBlock === 'function') {
+ const origAddB = bm.addBlock.bind(bm);
+ bm.addBlock = function (x, y, z, blockTypeId, color) {
+ const r = origAddB(x, y, z, blockTypeId, color);
+ if (!self._applyingRemote) {
+ self.sendOp({ op: 'blockAdd', x, y, z, blockTypeId, color });
+ }
+ return r;
+ };
+ }
+ if (typeof bm.removeBlock === 'function') {
+ const origRemB = bm.removeBlock.bind(bm);
+ bm.removeBlock = function (x, y, z) {
+ const r = origRemB(x, y, z);
+ if (!self._applyingRemote) self.sendOp({ op: 'blockRemove', x, y, z });
+ return r;
+ };
+ }
+ }
+
+ // SelectionManager — берём/снимаем soft-lock при выделении.
+ const sm = this.scene.selectionManager || this.scene.selection;
+ if (sm && !sm.__collabPatched) {
+ sm.__collabPatched = true;
+ const sendSel = () => {
+ if (self._applyingRemote) return;
+ const s = sm._selection;
+ if (s && s.type && s.id != null) {
+ self.selectObject(s.type + ':' + s.id);
+ } else {
+ self.deselectObject();
+ }
+ };
+ ['selectByMesh', 'selectPrimitiveById'].forEach((fn) => {
+ if (typeof sm[fn] === 'function') {
+ const orig = sm[fn].bind(sm);
+ sm[fn] = function (...a) { const r = orig(...a); sendSel(); return r; };
+ }
+ });
+ if (typeof sm.clear === 'function') {
+ const origClear = sm.clear.bind(sm);
+ sm.clear = function (...a) { const r = origClear(...a); if (!self._applyingRemote) self.deselectObject(); return r; };
+ }
+ }
+ if (mm && !mm.__collabPatched) {
+ mm.__collabPatched = true;
+ const origAddM = mm.addInstance.bind(mm);
+ mm.addInstance = function (modelTypeId, x, y, z, rotationY = 0) {
+ const ret = origAddM(modelTypeId, x, y, z, rotationY);
+ // addInstance модели async → ret это Promise.
+ Promise.resolve(ret).then((id) => {
+ if (id != null && !self._applyingRemote) {
+ self.sendOp({ op: 'add', key: 'model:' + id,
+ def: { __kind: 'model', modelTypeId, x, y, z, rotationY } });
+ }
+ });
+ return ret;
+ };
+ const origRemM = mm.removeInstance.bind(mm);
+ mm.removeInstance = function (id) {
+ const r = origRemM(id);
+ if (!self._applyingRemote) self.sendOp({ op: 'delete', key: 'model:' + id });
+ return r;
+ };
+ }
+ }
+
+ /** Снять обёртки (при выходе из коллаба восстановить оригиналы — простой флаг). */
+ uninstallInterceptors() {
+ // Оставляем обёртки, но они станут no-op после dispose (connected=false).
+ // Полный un-patch не нужен: sendOp молчит при !connected.
+ }
+
+ async connect() {
+ this.client = new Client(REALTIME_WS);
+ const joinOpts = {
+ projectId: this.projectId,
+ token: this.token,
+ };
+ if (this.collabToken) joinOpts.collabToken = this.collabToken;
+ this.room = await this.client.joinOrCreate('studio', joinOpts);
+ this.connected = true;
+ this.sessionId = this.room.sessionId;
+
+ const room = this.room;
+ // --- сервер сообщил наши параметры ---
+ room.onMessage('joined', (m) => {
+ this.sessionId = m.sessionId;
+ this.color = m.color;
+ this.isHost = !!m.isHost;
+ this.cb.onConnected?.({ sessionId: m.sessionId, color: m.color, isHost: !!m.isHost, role: m.role });
+ // Новый соавтор грузит сцену из БД сам (как при обычном открытии
+ // проекта), дальше догоняет live-операции. Снапшот через комнату НЕ
+ // шлём — большая сцена превышает лимит payload Colyseus (RangeError).
+ });
+
+ // --- входящая операция от другого соавтора ---
+ room.onMessage('op', (m) => {
+ if (!m || m.from === this.sessionId) return;
+ this._applyRemoteOp(m.op);
+ });
+
+ // --- наша операция отклонена (объект залочен) ---
+ room.onMessage('op-rejected', (m) => { this.cb.onOpRejected?.(m); });
+ room.onMessage('select-denied', (m) => { this.cb.onOpRejected?.(m); });
+
+ // --- чат соавторов ---
+ room.onMessage('chat', (m) => { this.cb.onChat?.(m); });
+
+ // --- presence через state ---
+ this._wirePresence();
+
+ room.onLeave(() => { this.connected = false; this.cb.onLeft?.(); });
+ room.onError((code, msg) => { this.cb.onError?.(msg || ('Ошибка ' + code)); });
+
+ return this.room;
+ }
+
+ _wirePresence() {
+ const $ = getStateCallbacks(this.room);
+ const state = this.room.state;
+ const emit = () => {
+ if (this._destroyed) return;
+ const list = [];
+ const col = state && state.collaborators;
+ if (col && typeof col.forEach === 'function') {
+ col.forEach((c, sid) => {
+ list.push({
+ sessionId: sid,
+ me: sid === this.sessionId,
+ username: c.username,
+ color: c.color,
+ cursor: { x: c.cursorX, y: c.cursorY, z: c.cursorZ },
+ cam: { x: c.camX, y: c.camY, z: c.camZ },
+ selectedKey: c.selectedKey,
+ isHost: sid === state.hostSessionId,
+ });
+ });
+ }
+ this.cb.onPresenceChange?.(list);
+ };
+ // Подписки оборачиваем в try — в Colyseus 0.16 API колбэков для
+ // MapSchema(string) может отличаться от MapSchema(Schema); не валим коннект.
+ try {
+ $(state).collaborators.onAdd((c, _sid) => {
+ try { $(c).onChange(() => emit()); } catch (e) { /* ignore */ }
+ emit();
+ });
+ $(state).collaborators.onRemove(() => emit());
+ } catch (e) { /* ignore */ }
+ try {
+ $(state).locks.onAdd(() => emit());
+ $(state).locks.onRemove(() => emit());
+ } catch (e) { /* ignore */ }
+ emit();
+ }
+
+ // ============ ИСХОДЯЩИЕ ============
+
+ /** Отправить операцию (если подключены и это не эхо удалённой).
+ * Для непрерывных op (move/rotate/scale) — throttle ~30/сек по ключу,
+ * чтобы драг гизмо (60fps) не залил комнату. add/delete/setColor — сразу. */
+ sendOp(op) {
+ if (!this.connected || this._applyingRemote) return;
+ const cont = op && (op.op === 'move' || op.op === 'rotate' || op.op === 'scale');
+ if (cont) {
+ const now = Date.now();
+ if (!this._opThrottle) this._opThrottle = {};
+ const tk = op.op + ':' + op.key;
+ if (now - (this._opThrottle[tk] || 0) < 33) {
+ // запомним последнее значение, дошлём в _flushPending через rAF
+ this._pendingOps = this._pendingOps || {};
+ this._pendingOps[tk] = op;
+ this._scheduleFlush();
+ return;
+ }
+ this._opThrottle[tk] = now;
+ }
+ try { this.room.send('op', op); } catch (e) { /* ignore */ }
+ }
+
+ /** Догнать «зажатые» throttle-операции (последнее значение драга — важно
+ * чтобы финальная позиция точно дошла, а не оборвалась на середине). */
+ _scheduleFlush() {
+ if (this._flushScheduled) return;
+ this._flushScheduled = true;
+ const run = () => {
+ this._flushScheduled = false;
+ const pend = this._pendingOps; this._pendingOps = null;
+ if (!pend || !this.connected) return;
+ const now = Date.now();
+ for (const tk in pend) {
+ const op = pend[tk];
+ this._opThrottle[tk] = now;
+ try { this.room.send('op', op); } catch (e) { /* ignore */ }
+ }
+ };
+ if (typeof requestAnimationFrame === 'function') setTimeout(run, 40);
+ else setTimeout(run, 40);
+ }
+
+ /** Курсор-точка под мышью (throttle ~20/сек). */
+ sendCursor(x, y, z) {
+ if (!this.connected) return;
+ const now = Date.now();
+ if (now - this._cursorThrottle < 50) return;
+ this._cursorThrottle = now;
+ try { this.room.send('cursor', { x, y, z }); } catch (e) { /* ignore */ }
+ }
+
+ sendCamera(x, y, z) {
+ if (!this.connected) return;
+ const now = Date.now();
+ if (now - this._camThrottle < 200) return;
+ this._camThrottle = now;
+ try { this.room.send('camera', { x, y, z }); } catch (e) { /* ignore */ }
+ }
+
+ /** Выделить объект (берёт soft-lock). key = 'primitive:42' / 'model:m3'. */
+ selectObject(key) {
+ if (!this.connected) return;
+ try { this.room.send('select', { key: key || '' }); } catch (e) { /* ignore */ }
+ }
+ deselectObject() {
+ if (!this.connected) return;
+ try { this.room.send('deselect', {}); } catch (e) { /* ignore */ }
+ }
+
+ sendChat(text) {
+ if (!this.connected) return;
+ try { this.room.send('chat', { text }); } catch (e) { /* ignore */ }
+ }
+
+ // ============ ВХОДЯЩИЕ → СЦЕНА ============
+
+ /**
+ * Применить операцию другого соавтора к локальной сцене.
+ * Оборачиваем во флаг _applyingRemote, чтобы обёртки менеджеров НЕ
+ * отправили её обратно в комнату (анти-эхо).
+ */
+ _applyRemoteOp(op) {
+ if (!op || typeof op !== 'object') return;
+ this._applyingRemote = true;
+ try {
+ applyRemoteOp(this.scene, op);
+ } catch (e) {
+ // не валим соединение из-за одной кривой операции
+ // eslint-disable-next-line no-console
+ console.warn('[StudioCollab] applyRemoteOp failed:', op?.op, e);
+ } finally {
+ this._applyingRemote = false;
+ }
+ }
+
+ dispose() {
+ this._destroyed = true;
+ this.connected = false;
+ try { this.room?.leave(true); } catch (e) { /* ignore */ }
+ this.room = null;
+ this.client = null;
+ }
+}
+
+/**
+ * Применить удалённую операцию к сцене через менеджеры BabylonScene.
+ * Каждый тип op маппится на метод менеджера. Если метода нет — тихо
+ * пропускаем (фича-парность движков: на этапе 1 поддерживаем базовый набор).
+ *
+ * Поддерживаемые операции (этап 1):
+ * add { kind:'primitive'|'model', def } — создать объект
+ * move { key, x, y, z } — переместить
+ * rotate { key, rx, ry, rz } — повернуть
+ * scale { key, sx, sy, sz } — масштаб
+ * setColor { key, color } — цвет
+ * setProp { key, prop, value } — произвольное свойство
+ * delete { key } — удалить
+ */
+export function applyRemoteOp(scene, op) {
+ const t = op.op;
+ const pm = scene.primitiveManager;
+ const mm = scene.modelManager;
+ const bm = scene.blockManager;
+ const { kind, id } = parseKey(op.key);
+
+ switch (t) {
+ case 'blockAdd':
+ bm?.addBlock?.(op.x, op.y, op.z, op.blockTypeId, op.color);
+ return;
+ case 'blockRemove':
+ bm?.removeBlock?.(op.x, op.y, op.z);
+ return;
+ }
+ switch (t) {
+ case 'add': {
+ if (op.def?.__kind === 'primitive' && pm?.addInstance) {
+ // addInstance(type, opts) — opts со всеми полями (id восстановим по instanceId).
+ pm.addInstance(op.def.type, op.def);
+ } else if (op.def?.__kind === 'model' && mm?.addInstance) {
+ // ModelManager.addInstance(modelTypeId, x, y, z, rotationY) — позиционно.
+ const d = op.def;
+ mm.addInstance(d.modelTypeId || d.type, d.x, d.y, d.z, d.rotationY || 0);
+ }
+ break;
+ }
+ case 'move': {
+ if (kind === 'primitive') pm?.updateInstance?.(id, { x: op.x, y: op.y, z: op.z });
+ else if (kind === 'model') _moveModel(mm, id, op.x, op.y, op.z);
+ break;
+ }
+ case 'rotate': {
+ if (kind === 'primitive') {
+ pm?.updateInstance?.(id, { rotationX: op.rx, rotationY: op.ry, rotationZ: op.rz });
+ } else if (kind === 'model') {
+ _rotateModel(mm, id, op.ry);
+ }
+ break;
+ }
+ case 'scale': {
+ if (kind === 'primitive') pm?.updateInstance?.(id, { sx: op.sx, sy: op.sy, sz: op.sz });
+ break;
+ }
+ case 'setColor': {
+ if (kind === 'primitive') pm?.updateInstance?.(id, { color: op.color });
+ break;
+ }
+ case 'setProp': {
+ if (kind === 'primitive') { const patch = {}; patch[op.prop] = op.value; pm?.updateInstance?.(id, patch); }
+ break;
+ }
+ case 'delete': {
+ if (kind === 'primitive') pm?.removeInstance?.(id);
+ else if (kind === 'model') mm?.removeInstance?.(id);
+ break;
+ }
+ default:
+ // неизвестная операция — пропускаем (вперёд-совместимость)
+ break;
+ }
+}
+
+/** Переместить модель (нет updateInstance — двигаем rootMesh напрямую). */
+function _moveModel(mm, id, x, y, z) {
+ const inst = mm?.instances?.get?.(id);
+ if (inst?.rootMesh?.position) {
+ inst.rootMesh.position.set(x, y, z);
+ if (inst.data) { inst.data.x = x; inst.data.y = y; inst.data.z = z; }
+ inst.x = x; inst.y = y; inst.z = z;
+ }
+}
+/** Повернуть модель вокруг Y. */
+function _rotateModel(mm, id, ry) {
+ const inst = mm?.instances?.get?.(id);
+ if (inst?.rootMesh && typeof ry === 'number') {
+ inst.rootMesh.rotation = inst.rootMesh.rotation || { x: 0, y: 0, z: 0 };
+ inst.rootMesh.rotation.y = ry;
+ if (inst.data) inst.data.rotationY = ry;
+ inst.rotationY = ry;
+ }
+}
+
+/** Снимок примитива для операции 'add' (с instanceId, чтобы id совпал у всех). */
+function serializePrimitive(data, id, type) {
+ if (!data) return { __kind: 'primitive', type, instanceId: id, id };
+ return {
+ __kind: 'primitive',
+ type: data.type || type,
+ id, instanceId: id,
+ x: data.x, y: data.y, z: data.z,
+ sx: data.sx, sy: data.sy, sz: data.sz,
+ rotationX: data.rotationX, rotationY: data.rotationY, rotationZ: data.rotationZ,
+ color: data.color, material: data.material,
+ name: data.name, anchored: data.anchored, canCollide: data.canCollide,
+ visible: data.visible, mass: data.mass, studDensity: data.studDensity,
+ };
+}
+
+/** Превратить patch updateInstance в конкретную операцию (move/rotate/scale/setColor/setProp). */
+function patchToOp(key, patch) {
+ if ('x' in patch || 'y' in patch || 'z' in patch) {
+ return { op: 'move', key, x: patch.x, y: patch.y, z: patch.z };
+ }
+ if ('rotationX' in patch || 'rotationY' in patch || 'rotationZ' in patch) {
+ return { op: 'rotate', key, rx: patch.rotationX, ry: patch.rotationY, rz: patch.rotationZ };
+ }
+ if ('sx' in patch || 'sy' in patch || 'sz' in patch) {
+ return { op: 'scale', key, sx: patch.sx, sy: patch.sy, sz: patch.sz };
+ }
+ if ('color' in patch) {
+ return { op: 'setColor', key, color: patch.color };
+ }
+ // прочее — generic setProp (берём первый ключ)
+ const k = Object.keys(patch)[0];
+ return { op: 'setProp', key, prop: k, value: patch[k] };
+}
+
+/** 'primitive:42' → {kind:'primitive', id:42}; 'model:m3' → {kind:'model', id:'m3'}. */
+function parseKey(key) {
+ if (typeof key !== 'string' || !key.includes(':')) return { kind: '', id: null };
+ const i = key.indexOf(':');
+ const kind = key.slice(0, i);
+ let id = key.slice(i + 1);
+ if (kind === 'primitive') { const n = Number(id); if (Number.isFinite(n)) id = n; }
+ return { kind, id };
+}
diff --git a/src/editor/engine/UserModelManager.js b/src/editor/engine/UserModelManager.js
index 9e8629a..7af7734 100644
--- a/src/editor/engine/UserModelManager.js
+++ b/src/editor/engine/UserModelManager.js
@@ -599,6 +599,8 @@ export class UserModelManager {
// instanceId — чтобы target-скрипты могли стабильно ссылаться
// на конкретный инстанс после перезагрузки.
instanceId: inst.instanceId,
+ // folderId — принадлежность к папке (иначе вываливается после Play/Stop).
+ ...(inst.folderId != null ? { folderId: inst.folderId } : {}),
});
}
return arr;
@@ -663,7 +665,13 @@ export class UserModelManager {
forceInstanceId: item.instanceId,
},
);
- if (id != null) loaded++;
+ if (id != null) {
+ loaded++;
+ if (item.folderId != null) { // восстановить папку
+ const inst = this.instances.get(id);
+ if (inst) inst.folderId = item.folderId;
+ }
+ }
} catch (e) {
console.warn('[UserModelManager] failed to load instance', item, e);
}
diff --git a/src/editor/engine/WeaponSystem.js b/src/editor/engine/WeaponSystem.js
index a4bf657..048d848 100644
--- a/src/editor/engine/WeaponSystem.js
+++ b/src/editor/engine/WeaponSystem.js
@@ -90,6 +90,18 @@ export class WeaponSystem {
if (e.button !== 0) return;
// Если UI-режим курсора — не стреляем (мышь работает по GUI)
if (this.scene3d?.player?.isUiCursorMode?.()) return;
+ // Если курсор СВОБОДЕН (нет pointer-lock — обычно 3-е лицо) — стреляем
+ // ТУДА, КУДА КЛИКНУЛИ, а не в центр камеры. При pointer-lock курсор в
+ // центре экрана → используем прицел камеры (aim не задаём).
+ if (document.pointerLockElement !== canvas) {
+ const rect = canvas.getBoundingClientRect();
+ const cx = (e.clientX != null ? e.clientX : 0) - rect.left;
+ const cy = (e.clientY != null ? e.clientY : 0) - rect.top;
+ if (cx >= 0 && cy >= 0 && cx <= rect.width && cy <= rect.height) {
+ this.setAimScreenPoint(cx * (canvas.width / rect.width),
+ cy * (canvas.height / rect.height));
+ }
+ }
this._mouseDown = true;
this._tryFire();
};
@@ -97,14 +109,28 @@ export class WeaponSystem {
if (e.button !== 0) return;
this._mouseDown = false;
};
+ // При свободном курсоре (3-е лицо) запоминаем позицию мыши — чтобы
+ // авто-огонь при удержании ЛКМ продолжал стрелять в точку курсора.
+ const onMove = (e) => {
+ if (!this._mouseDown) return;
+ if (document.pointerLockElement === canvas) return;
+ const rect = canvas.getBoundingClientRect();
+ const cx = (e.clientX != null ? e.clientX : 0) - rect.left;
+ const cy = (e.clientY != null ? e.clientY : 0) - rect.top;
+ if (cx >= 0 && cy >= 0 && cx <= rect.width && cy <= rect.height) {
+ this._holdAim = { x: cx * (canvas.width / rect.width), y: cy * (canvas.height / rect.height) };
+ }
+ };
const onKey = (e) => {
if (e.code === 'KeyR') this.reload();
};
canvas.addEventListener('mousedown', onDown);
window.addEventListener('mouseup', onUp);
+ window.addEventListener('mousemove', onMove);
window.addEventListener('keydown', onKey);
this._listeners.push({ target: canvas, type: 'mousedown', fn: onDown });
this._listeners.push({ target: window, type: 'mouseup', fn: onUp });
+ this._listeners.push({ target: window, type: 'mousemove', fn: onMove });
this._listeners.push({ target: window, type: 'keydown', fn: onKey });
// Регистрируем перед-кадровый хук для авто-стрельбы (если auto=true)
@@ -583,7 +609,12 @@ export class WeaponSystem {
// (для tap-to-shoot на мобиле). Точка применяется один раз.
let hit = null;
let ray;
- const aim = this._aimScreenPoint;
+ // aim: разовый клик (_aimScreenPoint) или удержание по курсору (_holdAim,
+ // только когда курсор свободен — нет pointer-lock).
+ let aim = this._aimScreenPoint;
+ if (!aim && this._holdAim && document.pointerLockElement !== this.scene3d?.canvas) {
+ aim = this._holdAim;
+ }
try {
if (aim) {
ray = this.scene.createPickingRay(aim.x, aim.y, null, camera);