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), карточка-витрина по центру, название места и автор. +
+ + + {lsEnabled && ( + <> + {/* Фон + карточка */} +
+
+
+ {!lsBackground && фон (размытый)} +
+ + {lsBackground && ( + + )} + handleLsImage(e, setLsBackground)} /> +
+
+
+ {!lsCover && карточка} +
+ + {lsCover && ( + + )} + handleLsImage(e, setLsCover)} /> +
+
+ setLsPlaceName(e.target.value)} /> + setLsStudioName(e.target.value)} /> + +
+
+ + {/* Стиль + длительность + прогресс */} +
+ + + +
+ + )} +
+ {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 ? ( + + Совместное редактирование + + ) : ( + <> - + {/* Кнопка «Скины» переехала в TopRibbon → вкладка «Игра». */} + + )} {/* Кнопка баг-репорта в шапке — кликает по скрытой плавающей. */} + )}
)) )} 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) === */}

- Тулбокс — библиотека объектов + Toolbox

-
- {section === 'standard' - ? `Показано ${visibleCount} из ${totalForSection}` - : (myModels === null && section === 'mine') || (communityModels === null && section === 'community') - ? '...' - : `${visibleCount} из ${totalForSection}`} -
- {/* Раздел: Стандартные / Мои / Сообщество */} -
- - - +
+ {[ + { id: 'store', label: 'Магазин', icon: 'box' }, + { id: 'inventory', label: 'Инвентарь', icon: 'grid' }, + { id: 'recent', label: 'Недавние', icon: 'clock' }, + { id: 'tips', label: 'Советы', icon: 'bulb' }, + ].map(t => ( + + ))}
-
- setSearch(e.target.value)} - autoFocus - /> -
+ {/* Поиск — скрыт только на главном экране магазина и в советах */} + {!(view === 'store' && !storeCat) && view !== 'tips' && ( +
+ setSearch(e.target.value)} + autoFocus + /> +
+ )} - {/* Подкатегории зависят от раздела */} - {section === 'standard' && ( + {/* Хлебные крошки/назад при открытой категории магазина */} + {view === 'store' && storeCat && ( +
+ + + {(STORE_CATEGORIES.find(c => c.id === storeCat) || {}).label} + +
+ )} + + {/* Подкатегории standard (3D) */} + {view === 'store' && storeCat === '3d' && (
{standardCategoriesWithCount.map(c => ( - - + {KIT_CATEGORIES.map(c => ( + + ))} +
+ )} + {/* Фильтр kind для инвентаря */} + {view === 'inventory' && ( +
+ + +
)} - {/* === Контент === */} -
- {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 => ( + + ))} +
+
+ Популярное +
+
+ {TRENDING.map(kit => ( + + ))} +
+
+ )} + + {/* --- МАГАЗИН: категория 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 => ( + + ))} +
+ )} + + {/* --- МАГАЗИН: Готовые механики --- */} + {view === 'store' && storeCat === 'gameplay' && ( +
+ {kitsFiltered.length === 0 + ?
Ничего не найдено
+ : kitsFiltered.map(kit => ( + + ))} +
+ )} + + {/* --- МАГАЗИН: 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);