Compare commits
15 Commits
c8a961815e
...
e48338376a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e48338376a | ||
|
|
ab11ac0b4e | ||
|
|
fbf7ef680b | ||
|
|
cb41ea0062 | ||
|
|
c31b1ed3d6 | ||
| 6dcbb8483a | |||
|
|
781d95e599 | ||
| 08786dbc2f | |||
|
|
76598c80ef | ||
| cf72526036 | |||
|
|
9fde464085 | ||
| 552b8719b1 | |||
|
|
c363acdf28 | ||
| 42a684ef4c | |||
| 91d3f48b80 |
@ -104,6 +104,123 @@ export const Shot = ({ src, caption, wide }) => (
|
|||||||
// DOCS — разделы вики A-J
|
// 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 = [
|
export const DOCS = [
|
||||||
// ════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════
|
||||||
// РАЗДЕЛ A — ОСНОВЫ
|
// РАЗДЕЛ A — ОСНОВЫ
|
||||||
@ -2029,6 +2146,167 @@ game.log('Игроков в комнате:', game.players.count());
|
|||||||
// когда новый игрок зашёл
|
// когда новый игрок зашёл
|
||||||
game.onPlayerJoin((p) => {
|
game.onPlayerJoin((p) => {
|
||||||
game.ui.showText(p.name + ' присоединился!', 2);
|
game.ui.showText(p.name + ' присоединился!', 2);
|
||||||
|
});`}</Code>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'leaderstats',
|
||||||
|
title: 'G7. Лидерборды и достижения',
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
<b>Лидерборд</b> — таблица очков игроков справа-сверху (как
|
||||||
|
в Roblox). Объяви стат и меняй значение:
|
||||||
|
</p>
|
||||||
|
<ScriptKind kind="global" />
|
||||||
|
<Code>{`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('Монеты');`}</Code>
|
||||||
|
<p>
|
||||||
|
<b>Достижения</b> — всплывающие ачивки с редкостью и звуком:
|
||||||
|
</p>
|
||||||
|
<Code>{`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);`}</Code>
|
||||||
|
<Note>
|
||||||
|
Лидерборд и достижения сохраняются в БД и подтягиваются при
|
||||||
|
следующем входе игрока.
|
||||||
|
</Note>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'damage-floaters',
|
||||||
|
title: 'G8. Облачка урона (damage floaters)',
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
Всплывающие <b>цифры урона</b> над врагом — как в RPG. Самый
|
||||||
|
простой способ — авто-режим (цифры над всеми мобами при уроне):
|
||||||
|
</p>
|
||||||
|
<ScriptKind kind="global" />
|
||||||
|
<Code>{`game.fx.autoMobFloaters(true);`}</Code>
|
||||||
|
<p>Ручной вызов в нужный момент:</p>
|
||||||
|
<Code>{`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`}</Code>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'items-inventory',
|
||||||
|
title: 'G9. Предметы и инвентарь с редкостями',
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
Полноценный инвентарь (сетка + хотбар, стаки, редкости).
|
||||||
|
Сначала опиши предметы, потом выдавай:
|
||||||
|
</p>
|
||||||
|
<ScriptKind kind="global" />
|
||||||
|
<Code>{`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); // стак`}</Code>
|
||||||
|
<p>Сбор предмета с земли (скрипт на предмете):</p>
|
||||||
|
<ScriptKind kind="object" on="собираемый предмет" />
|
||||||
|
<Code>{`game.self.onInteract(() => {
|
||||||
|
game.inventory.give('berry', 2);
|
||||||
|
game.self.delete();
|
||||||
|
}, { text: 'Собрать', key: 'e', distance: 3 });`}</Code>
|
||||||
|
<Note>
|
||||||
|
Редкости: common (серый), uncommon (зелёный), rare (голубой),
|
||||||
|
epic (фиолетовый), legendary (золотой). Окно инвентаря —
|
||||||
|
клавиша <kbd className="kbd">I</kbd>, drag-drop, ПКМ-меню.
|
||||||
|
</Note>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sky-environment',
|
||||||
|
title: 'G10. Небо, облака, туман, время суток',
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<p>Кастомное небо одной строкой — пресеты:</p>
|
||||||
|
<ScriptKind kind="global" />
|
||||||
|
<Code>{`game.scene.setSkybox({ preset: 'sunset' });
|
||||||
|
// пресеты: clear-summer-day | lowpoly-roblox | cloudy | sunset | starry-night | space`}</Code>
|
||||||
|
<p>Облака, туман и плавный переход:</p>
|
||||||
|
<Code>{`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 сек`}</Code>
|
||||||
|
<p>Простое управление цветом неба и временем суток:</p>
|
||||||
|
<Code>{`game.environment.setSkyColor('#0a1024'); // тёмное небо
|
||||||
|
game.environment.setTimeOfDay(0); // ночь (0..24)
|
||||||
|
game.environment.setTimeOfDay(12); // полдень`}</Code>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'modal-menu-loading',
|
||||||
|
title: 'G11. Диалоги, меню, экран загрузки',
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<p><b>Диалог NPC</b> построчно:</p>
|
||||||
|
<ScriptKind kind="global" />
|
||||||
|
<Code>{`game.modal.dialog('Староста', [
|
||||||
|
'Привет, путник!',
|
||||||
|
'Собери 10 монет и возвращайся.',
|
||||||
|
], () => game.ui.showText('Квест начат!', 2));`}</Code>
|
||||||
|
<p><b>Окно Да/Нет</b> и <b>лутбокс</b>:</p>
|
||||||
|
<Code>{`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));`}</Code>
|
||||||
|
<p><b>Экран загрузки</b> при переходе между уровнями:</p>
|
||||||
|
<Code>{`game.loading.show({
|
||||||
|
style: 'ken-burns',
|
||||||
|
placeName: 'Глава 2 — Шахта',
|
||||||
|
studioName: 'Моя студия',
|
||||||
|
duration: 2
|
||||||
|
});
|
||||||
|
game.loading.onHide(() => game.ui.showText('Добро пожаловать!', 2));`}</Code>
|
||||||
|
<Note>
|
||||||
|
Стартовый экран загрузки игры настраивается без кода —
|
||||||
|
см. раздел вики «Экран загрузки» (карточка в разборе игр) и
|
||||||
|
вкладку «Стартовый экран» в настройках проекта.
|
||||||
|
</Note>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vehicles-menu',
|
||||||
|
title: 'G12. Машины и главное меню',
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
<b>Машина</b>, на которой можно ездить (вход hold-F, WASD руль):
|
||||||
|
</p>
|
||||||
|
<ScriptKind kind="global" />
|
||||||
|
<Code>{`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));`}</Code>
|
||||||
|
<p><b>Главное меню</b> игры с живой камерой и кнопкой ИГРАТЬ:</p>
|
||||||
|
<Code>{`game.mainMenu.show({
|
||||||
|
title: 'МОЯ ИГРА',
|
||||||
|
camera: 'orbit',
|
||||||
|
playButtonText: 'ИГРАТЬ',
|
||||||
|
patchNotes: { title: 'Что нового', items: ['Добавлены машины', 'Новая карта'] },
|
||||||
|
onPlay: () => game.ui.showText('Поехали!', 2)
|
||||||
});`}</Code>
|
});`}</Code>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@ -2398,4 +2676,632 @@ game.onTick(() => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════
|
||||||
|
// РАЗДЕЛ — СКРИПТЫ: ГОТОВЫЕ РЕЦЕПТЫ
|
||||||
|
// ════════════════════════════════════════════════════
|
||||||
|
{
|
||||||
|
id: 'recipes',
|
||||||
|
icon: 'code',
|
||||||
|
title: 'Скрипты: рецепты',
|
||||||
|
summary: 'Готовые мини-скрипты, которые можно скопировать и вставить: килблок, сбор предметов, исчезновение и телепорт при касании, кнопки, таймеры, все свойства примитивов.',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: 'recipes-howto',
|
||||||
|
title: 'S1. Куда писать скрипты',
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<p>В Рублоксе есть <b>два вида скриптов</b>:</p>
|
||||||
|
<ul>
|
||||||
|
<li><b>Глобальный</b> — создаётся в иерархии в категории
|
||||||
|
<b> «Скрипты»</b> (кнопка «+»). Запускается один раз при
|
||||||
|
старте игры. В нём управляешь всей сценой через
|
||||||
|
<code> game.scene</code>, <code>game.player</code>, <code>game.ui</code>.</li>
|
||||||
|
<li><b>На объекте</b> — вешается прямо на примитив/модель/блок.
|
||||||
|
В нём работает <code>game.self</code> — это сам объект-носитель.
|
||||||
|
Удобно для «этот блок убивает», «этот сундук открывается».</li>
|
||||||
|
</ul>
|
||||||
|
<Note>
|
||||||
|
В рецептах ниже над каждым примером написано, <b>куда его
|
||||||
|
писать</b>. Просто скопируй код и вставь в нужный скрипт.
|
||||||
|
</Note>
|
||||||
|
<p>
|
||||||
|
Полезное: <code>game.log(...)</code> печатает в консоль (значок
|
||||||
|
«Консоль» внизу) — для отладки. <code>game.player.position</code>
|
||||||
|
— где сейчас игрок (<code>{'{x, y, z}'}</code>).
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'recipes-touch',
|
||||||
|
title: 'S2. Касание объекта (onTouch)',
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
Самое частое событие — <b>игрок коснулся объекта</b>. Вешаем
|
||||||
|
скрипт на объект и подписываемся через <code>game.self.onTouch</code>.
|
||||||
|
</p>
|
||||||
|
<ScriptKind kind="object" on="любой объект (плита, зона, предмет)" />
|
||||||
|
<Code>{`// Игрок наступил на объект — показать надпись и звук
|
||||||
|
game.self.onTouch(() => {
|
||||||
|
game.ui.showText('Ты коснулся плиты!', 2);
|
||||||
|
game.sound.play('click');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Когда игрок ушёл с объекта
|
||||||
|
game.self.onUntouch(() => {
|
||||||
|
game.ui.showText('Отошёл', 1);
|
||||||
|
});`}</Code>
|
||||||
|
<p>
|
||||||
|
Можно подписаться и на <b>чужой</b> объект из глобального
|
||||||
|
скрипта — найди его по имени:
|
||||||
|
</p>
|
||||||
|
<ScriptKind kind="global" />
|
||||||
|
<Code>{`const trap = game.scene.findOne('Ловушка');
|
||||||
|
trap.onTouch(() => game.player.damage(20));`}</Code>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'recipes-killblock',
|
||||||
|
title: 'S3. Килблок — урон и смерть при касании',
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
<b>Килблок</b> — объект, который наносит урон или мгновенно
|
||||||
|
убивает, когда игрок его коснулся (лава, шипы, кислота).
|
||||||
|
</p>
|
||||||
|
<ScriptKind kind="object" on="блок-ловушку (лава, шипы)" />
|
||||||
|
<Code>{`// Мгновенная смерть при касании
|
||||||
|
game.self.onTouch(() => {
|
||||||
|
game.player.kill();
|
||||||
|
game.ui.showText('💀 Ты сгорел в лаве!', 2);
|
||||||
|
});`}</Code>
|
||||||
|
<p>Если хочешь не убивать сразу, а наносить урон:</p>
|
||||||
|
<Code>{`// Урон 25 при касании (учитывает кадры неуязвимости)
|
||||||
|
game.self.onTouch(() => {
|
||||||
|
game.player.damage(25);
|
||||||
|
game.camera.shake(0.2, 0.3); // лёгкая тряска
|
||||||
|
});`}</Code>
|
||||||
|
<p>
|
||||||
|
<b>Постоянный урон</b>, пока игрок стоит в зоне (например,
|
||||||
|
ядовитое облако) — урон каждые 0.5 сек, пока касается:
|
||||||
|
</p>
|
||||||
|
<Code>{`let inside = false;
|
||||||
|
game.self.onTouch(() => { inside = true; });
|
||||||
|
game.self.onUntouch(() => { inside = false; });
|
||||||
|
game.every(0.5, () => {
|
||||||
|
if (inside) game.player.damage(5);
|
||||||
|
});`}</Code>
|
||||||
|
<Try>
|
||||||
|
Сделай красный неоновый куб, повесь на него скрипт смерти —
|
||||||
|
получится лава. Поставь его в проёме как преграду.
|
||||||
|
</Try>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'recipes-disappear',
|
||||||
|
title: 'S4. Исчезновение при касании (сбор монет)',
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
Предмет <b>исчезает</b>, когда игрок его коснулся — основа
|
||||||
|
сбора монеток, ключей, бонусов.
|
||||||
|
</p>
|
||||||
|
<ScriptKind kind="object" on="монетку / собираемый предмет" />
|
||||||
|
<Code>{`// Простое исчезновение + звук
|
||||||
|
game.self.onTouch(() => {
|
||||||
|
game.sound.play('coin');
|
||||||
|
game.self.delete();
|
||||||
|
});`}</Code>
|
||||||
|
<p>
|
||||||
|
Со <b>счётчиком</b>: предмет сообщает глобальному скрипту,
|
||||||
|
тот считает. На монетке:
|
||||||
|
</p>
|
||||||
|
<Code>{`game.self.onTouch(() => {
|
||||||
|
game.broadcast('coin'); // сообщить всем скриптам
|
||||||
|
game.self.delete();
|
||||||
|
});`}</Code>
|
||||||
|
<p>В глобальном скрипте — приём и счёт:</p>
|
||||||
|
<ScriptKind kind="global" />
|
||||||
|
<Code>{`let score = 0;
|
||||||
|
game.ui.score = 0;
|
||||||
|
game.onMessage('coin', () => {
|
||||||
|
score = score + 1;
|
||||||
|
game.ui.score = score; // обновить счётчик в углу
|
||||||
|
if (score >= 10) game.ui.showText('🏆 Собрал все!', 3);
|
||||||
|
});`}</Code>
|
||||||
|
<Note>
|
||||||
|
<b>Не</b> ставь счётчик на саму монетку — каждая монетка это
|
||||||
|
свой скрипт, они не видят переменные друг друга. Считай в
|
||||||
|
одном глобальном скрипте, монетки только шлют
|
||||||
|
<code> game.broadcast</code>.
|
||||||
|
</Note>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'recipes-teleport',
|
||||||
|
title: 'S5. Телепорт и смена позиции при касании',
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
При касании <b>переместить игрока</b> (портал) или
|
||||||
|
<b> сдвинуть сам объект</b> (движущаяся платформа).
|
||||||
|
</p>
|
||||||
|
<p><b>Портал</b> — телепорт игрока в точку:</p>
|
||||||
|
<ScriptKind kind="object" on="портал" />
|
||||||
|
<Code>{`game.self.onTouch(() => {
|
||||||
|
game.player.teleport(0, 20, 50); // x, y, z назначения
|
||||||
|
game.sound.play('win');
|
||||||
|
game.camera.shake(0.15, 0.2);
|
||||||
|
});`}</Code>
|
||||||
|
<p>
|
||||||
|
<b>Сдвинуть сам объект</b> при касании (например, опустить
|
||||||
|
мост). <code>game.self.move</code> ставит новую позицию:
|
||||||
|
</p>
|
||||||
|
<Code>{`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 м
|
||||||
|
});`}</Code>
|
||||||
|
<p>
|
||||||
|
<b>Плавно</b> сдвинуть — через <code>game.tween</code> (анимация):
|
||||||
|
</p>
|
||||||
|
<Code>{`// дверь уезжает вбок за 1 секунду
|
||||||
|
const p = game.self.position;
|
||||||
|
game.self.onTouch(() => {
|
||||||
|
game.tween(game.self.ref, { x: p.x + 4 }, { duration: 1, easing: 'ease' });
|
||||||
|
});`}</Code>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'recipes-primitive-props',
|
||||||
|
title: 'S6. Все свойства примитивов из скрипта',
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
Любой примитив можно <b>создать</b> и <b>менять</b> из
|
||||||
|
скрипта. Вот все свойства и как их задать.
|
||||||
|
</p>
|
||||||
|
<ScriptKind kind="global" />
|
||||||
|
<p><b>Создать примитив</b> со всеми свойствами:</p>
|
||||||
|
<Code>{`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)
|
||||||
|
});`}</Code>
|
||||||
|
<p>Типы примитивов для <code>spawn</code>:</p>
|
||||||
|
<Code>{`'cube' 'sphere' 'cylinder' 'cone' 'pyramid' 'torus' 'wedge' 'cornerwedge' 'plane'`}</Code>
|
||||||
|
<p><b>Менять свойства</b> уже существующего объекта:</p>
|
||||||
|
<Code>{`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 });`}</Code>
|
||||||
|
<p>
|
||||||
|
Удобнее — через <b>объект-прокси</b> (присваивание свойств):
|
||||||
|
</p>
|
||||||
|
<Code>{`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(); // удалить`}</Code>
|
||||||
|
<Note>
|
||||||
|
<b>Радианы:</b> поворот задаётся в радианах, не градусах.
|
||||||
|
90° = <code>Math.PI/2</code> ≈ 1.57, 180° = <code>Math.PI</code> ≈ 3.14.
|
||||||
|
</Note>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'recipes-anim',
|
||||||
|
title: 'S7. Движение, вращение, мигание (onTick и tween)',
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
<b>Вращающийся объект</b> (монета, портал) — крутим каждый
|
||||||
|
кадр через <code>game.onTick</code> (dt = время кадра):
|
||||||
|
</p>
|
||||||
|
<ScriptKind kind="object" on="вращающийся объект" />
|
||||||
|
<Code>{`let angle = 0;
|
||||||
|
game.onTick((dt) => {
|
||||||
|
angle = angle + dt * 2; // скорость вращения
|
||||||
|
game.self.rotateY(angle);
|
||||||
|
});`}</Code>
|
||||||
|
<p><b>Парение вверх-вниз</b> (плавно качается):</p>
|
||||||
|
<Code>{`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);
|
||||||
|
});`}</Code>
|
||||||
|
<p><b>Пульсация размера</b> через tween (бесконечно туда-обратно):</p>
|
||||||
|
<Code>{`game.tween(game.self.ref, { sy: 1.4 }, {
|
||||||
|
duration: 0.6, easing: 'ease', yoyo: true, repeat: -1
|
||||||
|
});`}</Code>
|
||||||
|
<p><b>Мигание цветом</b> каждые полсекунды:</p>
|
||||||
|
<Code>{`let on = false;
|
||||||
|
game.every(0.5, () => {
|
||||||
|
on = !on;
|
||||||
|
game.self.setColor(on ? '#ff0000' : '#330000');
|
||||||
|
});`}</Code>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'recipes-button-door',
|
||||||
|
title: 'S8. Кнопка по E и дверь',
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
<b>Взаимодействие по клавише E</b> (как в Roblox ProximityPrompt)
|
||||||
|
— через <code>game.self.onInteract</code>. Появляется подсказка
|
||||||
|
«[E] …» когда игрок рядом.
|
||||||
|
</p>
|
||||||
|
<ScriptKind kind="object" on="кнопку / рычаг / сундук" />
|
||||||
|
<Code>{`game.self.onInteract(() => {
|
||||||
|
game.ui.showText('Открыто!', 2);
|
||||||
|
game.broadcast('open-door');
|
||||||
|
}, { text: 'Открыть', key: 'e', distance: 4 });`}</Code>
|
||||||
|
<p>На двери — глобальный/объектный скрипт, который её открывает:</p>
|
||||||
|
<ScriptKind kind="object" on="дверь" />
|
||||||
|
<Code>{`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); // через неё можно пройти
|
||||||
|
});`}</Code>
|
||||||
|
<Note>
|
||||||
|
<code>holdDuration: 1</code> в опциях onInteract — держать E
|
||||||
|
1 секунду (для важных действий). <code>distance</code> —
|
||||||
|
с какого расстояния появляется подсказка.
|
||||||
|
</Note>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'recipes-gui-timer',
|
||||||
|
title: 'S9. Надписи на экране, таймер, кнопки GUI',
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<p><b>HUD-надписи</b> в углу и по центру:</p>
|
||||||
|
<ScriptKind kind="global" />
|
||||||
|
<Code>{`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'); // убрать метку`}</Code>
|
||||||
|
<p><b>Обратный отсчёт</b> и проигрыш по времени:</p>
|
||||||
|
<Code>{`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();
|
||||||
|
}
|
||||||
|
});`}</Code>
|
||||||
|
<p><b>Кнопка на экране</b> (GUI) и обработка клика:</p>
|
||||||
|
<Code>{`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);
|
||||||
|
});`}</Code>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'recipes-spawn-fall',
|
||||||
|
title: 'S10. Спавн, падение, проверка падения вниз',
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<p><b>Спавнить объекты с неба</b> каждую секунду (ловилка):</p>
|
||||||
|
<ScriptKind kind="global" />
|
||||||
|
<Code>{`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 сек
|
||||||
|
});
|
||||||
|
});`}</Code>
|
||||||
|
<p>
|
||||||
|
<b>Игрок упал вниз</b> (за карту) — вернуть на спавн. Проверяем
|
||||||
|
высоту каждый кадр:
|
||||||
|
</p>
|
||||||
|
<Code>{`game.onTick(() => {
|
||||||
|
if (game.player.position.y < -10) {
|
||||||
|
game.player.respawn();
|
||||||
|
game.ui.showText('Упал! Назад на старт.', 2);
|
||||||
|
}
|
||||||
|
});`}</Code>
|
||||||
|
<p><b>Финиш</b> — дошёл до зоны, победа:</p>
|
||||||
|
<ScriptKind kind="object" on="финишную плиту" />
|
||||||
|
<Code>{`game.self.onTouch(() => {
|
||||||
|
game.ui.showText('🏁 ПОБЕДА!', 4);
|
||||||
|
game.sound.play('win');
|
||||||
|
game.player.setInputBlocked(true); // заморозить управление
|
||||||
|
});`}</Code>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'recipes-npc-enemy',
|
||||||
|
title: 'S11. Враг, который идёт за игроком',
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
<b>NPC/враг</b>, который преследует игрока и наносит урон.
|
||||||
|
</p>
|
||||||
|
<ScriptKind kind="global" />
|
||||||
|
<Code>{`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 });
|
||||||
|
});`}</Code>
|
||||||
|
<p>Урон игроку, когда враг близко:</p>
|
||||||
|
<Code>{`game.every(0.5, () => {
|
||||||
|
const d = game.distance(enemy.position, game.player.position);
|
||||||
|
if (d < 2) game.player.damage(10);
|
||||||
|
});`}</Code>
|
||||||
|
<Note>
|
||||||
|
Облачка урона над всеми мобами одной строкой:
|
||||||
|
<code> game.fx.autoMobFloaters(true)</code>.
|
||||||
|
</Note>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'recipes-save',
|
||||||
|
title: 'S12. Сохранение прогресса и лидерборд',
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
<b>Лидерборд</b> (таблица очков справа) — объяви стат и
|
||||||
|
прибавляй:
|
||||||
|
</p>
|
||||||
|
<ScriptKind kind="global" />
|
||||||
|
<Code>{`game.leaderstats.define('Монеты', { initial: 0 });
|
||||||
|
// прибавить текущему игроку:
|
||||||
|
game.leaderstats.me.add('Монеты', 1);`}</Code>
|
||||||
|
<p>
|
||||||
|
<b>Сохранение между сессиями</b> (прогресс не теряется после
|
||||||
|
выхода):
|
||||||
|
</p>
|
||||||
|
<Code>{`// записать
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});`}</Code>
|
||||||
|
<Try>
|
||||||
|
Собери всё вместе: монетки шлют broadcast → глобальный скрипт
|
||||||
|
считает в leaderstats → раз в N монет сохраняет через
|
||||||
|
game.save. Получится игра с прогрессом как в настоящем Roblox.
|
||||||
|
</Try>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════
|
||||||
|
// РАЗДЕЛ — СОВМЕСТНОЕ РЕДАКТИРОВАНИЕ (Team Create)
|
||||||
|
// ════════════════════════════════════════════════════
|
||||||
|
{
|
||||||
|
id: 'collab',
|
||||||
|
icon: 'users',
|
||||||
|
title: 'Вместе с друзьями',
|
||||||
|
summary: 'Совместное редактирование игры в реальном времени: приглашай друзей, стройте сцену вдвоём, видьте курсоры и правки друг друга.',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: 'collab-what',
|
||||||
|
title: 'V1. Что такое совместное редактирование',
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
<b>Совместное редактирование (Team Create)</b> — это когда
|
||||||
|
одну игру в студии редактируют <b>несколько человек
|
||||||
|
одновременно</b>. Ты строишь дом, друг в это же время
|
||||||
|
ставит деревья — и каждый видит правки другого
|
||||||
|
<b> вживую</b>, без перезагрузки страницы.
|
||||||
|
</p>
|
||||||
|
<p>Что синхронизируется в реальном времени:</p>
|
||||||
|
<ul>
|
||||||
|
<li><b>Блоки</b> — поставил/удалил блок, друг сразу видит;</li>
|
||||||
|
<li><b>Примитивы</b> — добавил, подвинул, повернул, изменил
|
||||||
|
цвет или размер;</li>
|
||||||
|
<li><b>Модели</b> — добавил из Тулбокса или удалил;</li>
|
||||||
|
<li><b>Курсоры друзей</b> — где сейчас «мышка» каждого
|
||||||
|
соавтора (цветная точка с ником);</li>
|
||||||
|
<li><b>Кто онлайн</b> — список соавторов с цветными
|
||||||
|
аватарками вверху сцены.</li>
|
||||||
|
</ul>
|
||||||
|
<Shot src="collab-scene.png" wide
|
||||||
|
caption="Сцена при совместном редактировании: вверху — кто онлайн (аватарки «У» и «М», всего 2), на сцене — курсор соавтора «УтреннийРозмарин4633» (оранжевая точка с ником) и блок, который он только что поставил." />
|
||||||
|
<Note>
|
||||||
|
Это как Google Документы, только для 3D-игр. Удобно делать
|
||||||
|
игру в команде, помогать ученику или показывать, как что
|
||||||
|
устроено — прямо в его проекте.
|
||||||
|
</Note>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'collab-invite',
|
||||||
|
title: 'V2. Как пригласить друга',
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
Открой свою игру в студии и перейди на вкладку
|
||||||
|
<kbd className="kbd">Игра</kbd> в верхней панели. Там, в
|
||||||
|
группе <b>«Вместе»</b>, есть кнопка
|
||||||
|
<kbd className="kbd">Пригласить</kbd>.
|
||||||
|
</p>
|
||||||
|
<Shot src="collab-button.png" wide
|
||||||
|
caption="Вкладка «Игра» → группа «Вместе» → кнопка «Пригласить». Когда соавторы подключатся, на ней появится счётчик «Вместе (2)»." />
|
||||||
|
<Step n={1}>
|
||||||
|
Нажми <kbd className="kbd">Пригласить</kbd> — ссылка-приглашение
|
||||||
|
автоматически скопируется в буфер обмена (появится
|
||||||
|
уведомление).
|
||||||
|
</Step>
|
||||||
|
<Step n={2}>
|
||||||
|
Отправь эту ссылку другу (в чат, мессенджер — как угодно).
|
||||||
|
</Step>
|
||||||
|
<Step n={3}>
|
||||||
|
Друг открывает ссылку — и попадает в <b>ту же сцену</b>.
|
||||||
|
Теперь вы редактируете вместе. На кнопке появится счётчик:
|
||||||
|
<b> «Вместе (2)»</b>.
|
||||||
|
</Step>
|
||||||
|
<Note>
|
||||||
|
Приглашать может только <b>автор игры</b>. Ссылка действует
|
||||||
|
24 часа. Друг должен быть зарегистрирован на Рублоксе и
|
||||||
|
войти в свой аккаунт.
|
||||||
|
</Note>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'collab-how',
|
||||||
|
title: 'V3. Как это работает и правила',
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
<b>Блокировка объекта.</b> Пока один соавтор выделил объект
|
||||||
|
и двигает его — этот объект <b>заблокирован</b> для других
|
||||||
|
(никто другой не сможет его двигать одновременно). Так
|
||||||
|
правки не конфликтуют. Как только выделение снято — объект
|
||||||
|
снова свободен.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<b>Кто сохраняет.</b> Игру в базу сохраняет <b>автор</b>
|
||||||
|
(владелец проекта). Приглашённые друзья видят пометку
|
||||||
|
«Совместное редактирование» вместо кнопок Сохранить и
|
||||||
|
Опубликовать — это правильно: они помогают строить, а
|
||||||
|
управляет игрой автор.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<b>Цвета.</b> У каждого соавтора свой цвет — им подсвечены
|
||||||
|
его курсор и аватарка, чтобы было понятно, кто что делает.
|
||||||
|
</p>
|
||||||
|
<Note>
|
||||||
|
Если друг ненадолго потерял связь (вылетел интернет) — у
|
||||||
|
него есть несколько секунд, чтобы переподключиться и
|
||||||
|
продолжить с того же места.
|
||||||
|
</Note>
|
||||||
|
<p>
|
||||||
|
<b>Совет:</b> договоритесь заранее, кто какую часть карты
|
||||||
|
делает (например, один — здания, другой — ландшафт и
|
||||||
|
декор) — так работать вместе быстрее и без накладок.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════
|
||||||
|
// РАЗДЕЛ — КОНТЕКСТ ДЛЯ НЕЙРОНКИ (AI)
|
||||||
|
// ════════════════════════════════════════════════════
|
||||||
|
{
|
||||||
|
id: 'ai-context',
|
||||||
|
icon: 'lightbulb',
|
||||||
|
title: 'Контекст для нейронки',
|
||||||
|
summary: 'Готовый текст со всем API скриптов Рублокса. Скопируй его целиком, вставь в ChatGPT/нейросеть — и она будет писать тебе рабочие скрипты под твою задачу.',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: 'ai-howto',
|
||||||
|
title: 'AI1. Как писать скрипты с нейросетью',
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
Хочешь, чтобы скрипт написала <b>нейросеть</b> (ChatGPT,
|
||||||
|
DeepSeek, Claude и т.п.)? Проблема в том, что нейросеть <b>не
|
||||||
|
знает</b> устройство Рублокса — придумает несуществующие
|
||||||
|
команды. Решение простое:
|
||||||
|
</p>
|
||||||
|
<Step n={1}>
|
||||||
|
Открой статью <b>«AI2. Контекст — скопируй в нейросеть»</b> ниже.
|
||||||
|
</Step>
|
||||||
|
<Step n={2}>
|
||||||
|
<b>Выдели и скопируй весь текст</b> из серого блока (он
|
||||||
|
описывает все команды Рублокса).
|
||||||
|
</Step>
|
||||||
|
<Step n={3}>
|
||||||
|
Вставь его в нейросеть <b>первым сообщением</b>. Затем добавь
|
||||||
|
свою задачу, например: «Напиши скрипт: при касании синего куба
|
||||||
|
игрок прыгает высоко и играет звук».
|
||||||
|
</Step>
|
||||||
|
<Step n={4}>
|
||||||
|
Нейросеть выдаст готовый код. Скопируй его в скрипт в студии
|
||||||
|
(глобальный или на объекте — она подскажет куда).
|
||||||
|
</Step>
|
||||||
|
<Note>
|
||||||
|
Если нейросеть всё равно ошиблась в команде — скинь ей текст
|
||||||
|
ошибки из «Консоли» студии, она исправит. И всегда проверяй
|
||||||
|
результат запуском игры.
|
||||||
|
</Note>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ai-context-text',
|
||||||
|
title: 'AI2. Контекст — скопируй в нейросеть',
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
<b>Выдели весь текст ниже и скопируй</b> (Ctrl+A внутри блока
|
||||||
|
или мышью), затем вставь в нейросеть перед своим вопросом:
|
||||||
|
</p>
|
||||||
|
<Code>{AI_CONTEXT}</Code>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -373,4 +373,9 @@ export const GAMES = [
|
|||||||
desc: 'Полный инвентарь как в Minecraft/RPG: сетка 8×5 + хотбар 9, стаки, 5 редкостей (цвет рамки), перетаскивание мышью, ПКМ-меню, tooltip, сортировка. Собираешь предметы — стаки растут.',
|
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 + сортировка по редкости'],
|
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 },
|
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 },
|
||||||
];
|
];
|
||||||
|
|||||||
@ -21,6 +21,14 @@ const F = { fill: 'currentColor', stroke: 'none' };
|
|||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
// ── разделы вики ──────────────────────────────────────────
|
// ── разделы вики ──────────────────────────────────────────
|
||||||
|
users: () => (
|
||||||
|
<>
|
||||||
|
<circle cx="9" cy="8" r="3.2" {...S} />
|
||||||
|
<path d="M3.5 19c0-3 2.5-5 5.5-5s5.5 2 5.5 5" {...S} />
|
||||||
|
<circle cx="16.5" cy="9" r="2.4" {...S} />
|
||||||
|
<path d="M15 14.2c2.6.2 4.7 2 4.7 4.8" {...S} />
|
||||||
|
</>
|
||||||
|
),
|
||||||
rocket: () => (
|
rocket: () => (
|
||||||
<>
|
<>
|
||||||
<path d="M12 3c3.4 1.7 5.4 5 5.4 9l-2.2 2.3H8.8L6.6 12C6.6 8 8.6 4.7 12 3Z" {...S} />
|
<path d="M12 3c3.4 1.7 5.4 5 5.4 9l-2.2 2.3H8.8L6.6 12C6.6 8 8.6 4.7 12 3Z" {...S} />
|
||||||
|
|||||||
@ -8950,6 +8950,75 @@ game.self.onInteract(() => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
'guide-loadingscreen': {
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<h3 className="lessonH">Что получится</h3>
|
||||||
|
<p>
|
||||||
|
<b>Красивый экран загрузки игры</b> — то, что видит игрок при входе
|
||||||
|
в игру (после клика «Играть»), пока грузится сцена. Композиция как в
|
||||||
|
Roblox: размытый фон с медленным движением (<b>Ken Burns</b>),
|
||||||
|
<b> карточка-витрина</b> по центру, крупное <b>название места</b> и
|
||||||
|
<b> автор с verified-галочкой</b>, прогресс-бар и спиннер. Когда сцена
|
||||||
|
загрузилась — экран плавно исчезает.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Shot src="guide-loadingscreen-scene.png" wide
|
||||||
|
caption="Экран загрузки игры: размытый Ken-Burns фон, карточка-витрина, «Открыть фабрику», автор с verified-галочкой, прогресс и спиннер." />
|
||||||
|
|
||||||
|
<h3 className="lessonH">Шаг 1. Настроить в свойствах проекта</h3>
|
||||||
|
<p>
|
||||||
|
Без кода: <b>Настройки игры → вкладка «Стартовый экран входа (Ken Burns)»</b>.
|
||||||
|
Задай фон (размытое изображение игры), карточку, название места, имя
|
||||||
|
автора, галочку verified, стиль анимации и длительность. Этот экран
|
||||||
|
автоматически покажется игроку при заходе.
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li><b>Фон</b> — размытое изображение игры (или её обложка);</li>
|
||||||
|
<li><b>Карточка</b> — витрина по центру (необязательно);</li>
|
||||||
|
<li><b>Название места</b> + <b>автор</b> + <b>verified</b>;</li>
|
||||||
|
<li><b>Стиль:</b> Ken Burns / статичный / параллакс / частицы;</li>
|
||||||
|
<li><b>Длительность</b> и <b>прогресс-бар</b>.</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Если ничего не задано — экран всё равно красивый: берёт обложку,
|
||||||
|
название и автора игры автоматически.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="lessonH">Шаг 2. Переходы между мирами из скрипта</h3>
|
||||||
|
<p>Для смены главы/мира вызывай экран вручную:</p>
|
||||||
|
<ScriptKind kind="global" />
|
||||||
|
<Code>{`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' });
|
||||||
|
});`}</Code>
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
Стили: <b>Ken Burns</b> — медленный pan+zoom фона (классика Roblox);
|
||||||
|
<b> параллакс</b> — фон смещается за мышью; <b>частицы</b> — летящие
|
||||||
|
искры; <b>статичный</b> — без анимации. Verified-галочка — синий кружок
|
||||||
|
с белым чеком рядом с автором.
|
||||||
|
</Note>
|
||||||
|
|
||||||
|
<Try>
|
||||||
|
Открой настройки игры → «Стартовый экран», впиши название места и автора,
|
||||||
|
выбери стиль «Частицы» — запусти игру и посмотри, как экран загрузки
|
||||||
|
встречает игрока.
|
||||||
|
</Try>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Есть ли готовый текст урока для игры с таким id. */
|
/** Есть ли готовый текст урока для игры с таким id. */
|
||||||
|
|||||||
@ -50,9 +50,21 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
|
|||||||
const [loadingAccent, setLoadingAccent] = useState('#ffc020');
|
const [loadingAccent, setLoadingAccent] = useState('#ffc020');
|
||||||
const [loadingSpinner, setLoadingSpinner] = useState(true);
|
const [loadingSpinner, setLoadingSpinner] = useState(true);
|
||||||
const [loadingSkip, setLoadingSkip] = useState(false);
|
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 [error, setError] = useState('');
|
||||||
const fileInputRef = useRef(null);
|
const fileInputRef = useRef(null);
|
||||||
const logoInputRef = useRef(null);
|
const logoInputRef = useRef(null);
|
||||||
|
const lsBgInputRef = useRef(null);
|
||||||
|
const lsCoverInputRef = useRef(null);
|
||||||
|
|
||||||
// Заполняем поля ОДИН РАЗ при открытии модала.
|
// Заполняем поля ОДИН РАЗ при открытии модала.
|
||||||
// Не зависим от `initial` — родитель часто передаёт литерал-объект,
|
// Не зависим от `initial` — родитель часто передаёт литерал-объект,
|
||||||
@ -71,6 +83,16 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
|
|||||||
setLoadingAccent(ls.accentColor || '#ffc020');
|
setLoadingAccent(ls.accentColor || '#ffc020');
|
||||||
setLoadingSpinner(ls.defaultSpinner !== false);
|
setLoadingSpinner(ls.defaultSpinner !== false);
|
||||||
setLoadingSkip(!!ls.defaultSkipButton);
|
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(
|
setMaxPlayers(
|
||||||
typeof initial?.max_players === 'number'
|
typeof initial?.max_players === 'number'
|
||||||
? Math.max(2, Math.min(50, initial.max_players))
|
? Math.max(2, Math.min(50, initial.max_players))
|
||||||
@ -117,6 +139,17 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
|
|||||||
reader.readAsDataURL(file);
|
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) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const trimmedTitle = title.trim();
|
const trimmedTitle = title.trim();
|
||||||
@ -146,6 +179,16 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
|
|||||||
accentColor: loadingAccent || '#ffc020',
|
accentColor: loadingAccent || '#ffc020',
|
||||||
defaultSpinner: loadingSpinner,
|
defaultSpinner: loadingSpinner,
|
||||||
defaultSkipButton: loadingSkip,
|
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
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Стартовый экран — Ken Burns + название места (задача 05) */}
|
||||||
|
<div className={cl.field} style={{ borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: 14, marginTop: 4 }}>
|
||||||
|
<div className={cl.fieldLabel} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Icon name="loader" size={13} /> Стартовый экран входа (Ken Burns)
|
||||||
|
</div>
|
||||||
|
<div className={cl.fieldHint} style={{ marginBottom: 10 }}>
|
||||||
|
Что видит игрок при входе в игру: размытый фон с медленным движением (Ken Burns), карточка-витрина по центру, название места и автор.
|
||||||
|
</div>
|
||||||
|
<label className={cl.toggleRow} style={{ marginBottom: 10 }}>
|
||||||
|
<input type="checkbox" className={cl.toggle}
|
||||||
|
checked={lsEnabled} onChange={(e) => setLsEnabled(e.target.checked)} />
|
||||||
|
<div className={cl.toggleText}>
|
||||||
|
<div className={cl.toggleTitle}>Показывать стартовый экран</div>
|
||||||
|
<div className={cl.toggleHint}><span>Если выключено — игрок сразу попадает в 3D-сцену</span></div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{lsEnabled && (
|
||||||
|
<>
|
||||||
|
{/* Фон + карточка */}
|
||||||
|
<div style={{ display: 'flex', gap: 14, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 130, height: 74, borderRadius: 8, background: '#15192a',
|
||||||
|
border: '1px solid rgba(255,255,255,0.12)', overflow: 'hidden',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
backgroundImage: lsBackground ? `url(${lsBackground})` : 'none',
|
||||||
|
backgroundSize: 'cover', backgroundPosition: 'center',
|
||||||
|
}}>
|
||||||
|
{!lsBackground && <span style={{ color: '#5a6178', fontSize: 11 }}>фон (размытый)</span>}
|
||||||
|
</div>
|
||||||
|
<button type="button" className={cl.actionBtn} onClick={() => lsBgInputRef.current?.click()}>
|
||||||
|
<Icon name="folder" size={14} /> Фон
|
||||||
|
</button>
|
||||||
|
{lsBackground && (
|
||||||
|
<button type="button" className={cl.removeBtn} style={{ alignSelf: 'flex-start' }} onClick={() => setLsBackground('')}>
|
||||||
|
<Icon name="close" size={13} /> Убрать
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<input ref={lsBgInputRef} type="file" accept="image/png,image/jpeg,image/webp"
|
||||||
|
style={{ display: 'none' }} onChange={(e) => handleLsImage(e, setLsBackground)} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 74, height: 74, borderRadius: 12, background: '#15192a',
|
||||||
|
border: '1px solid rgba(255,255,255,0.12)', overflow: 'hidden',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
backgroundImage: lsCover ? `url(${lsCover})` : 'none',
|
||||||
|
backgroundSize: 'cover', backgroundPosition: 'center',
|
||||||
|
}}>
|
||||||
|
{!lsCover && <span style={{ color: '#5a6178', fontSize: 10, textAlign: 'center' }}>карточка</span>}
|
||||||
|
</div>
|
||||||
|
<button type="button" className={cl.actionBtn} onClick={() => lsCoverInputRef.current?.click()}>
|
||||||
|
<Icon name="folder" size={14} /> Карточка
|
||||||
|
</button>
|
||||||
|
{lsCover && (
|
||||||
|
<button type="button" className={cl.removeBtn} style={{ alignSelf: 'flex-start' }} onClick={() => setLsCover('')}>
|
||||||
|
<Icon name="close" size={13} /> Убрать
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<input ref={lsCoverInputRef} type="file" accept="image/png,image/jpeg,image/webp"
|
||||||
|
style={{ display: 'none' }} onChange={(e) => handleLsImage(e, setLsCover)} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, flex: 1, minWidth: 180 }}>
|
||||||
|
<input type="text" className={cl.input} placeholder="Название места (по умолчанию = название игры)"
|
||||||
|
value={lsPlaceName} maxLength={40}
|
||||||
|
onChange={(e) => setLsPlaceName(e.target.value)} />
|
||||||
|
<input type="text" className={cl.input} placeholder="Имя автора"
|
||||||
|
value={lsStudioName} maxLength={40}
|
||||||
|
onChange={(e) => setLsStudioName(e.target.value)} />
|
||||||
|
<label className={cl.toggleRow}>
|
||||||
|
<input type="checkbox" className={cl.toggle}
|
||||||
|
checked={lsVerified} onChange={(e) => setLsVerified(e.target.checked)} />
|
||||||
|
<div className={cl.toggleText}>
|
||||||
|
<div className={cl.toggleTitle}>Галочка verified</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Стиль + длительность + прогресс */}
|
||||||
|
<div style={{ display: 'flex', gap: 14, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
<span style={{ fontSize: 12, color: '#aab' }}>Стиль анимации</span>
|
||||||
|
<select className={cl.input} value={lsStyle} onChange={(e) => setLsStyle(e.target.value)}>
|
||||||
|
<option value="ken-burns">Ken Burns (плавный pan+zoom)</option>
|
||||||
|
<option value="static">Статичный фон</option>
|
||||||
|
<option value="parallax">Параллакс (по мыши)</option>
|
||||||
|
<option value="particles">Частицы (искры)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
<span style={{ fontSize: 12, color: '#aab' }}>Длительность: {Number(lsDuration).toFixed(1)} с</span>
|
||||||
|
<input type="range" min="1" max="10" step="0.5" value={lsDuration}
|
||||||
|
onChange={(e) => setLsDuration(Number(e.target.value))}
|
||||||
|
style={{ width: 160 }} />
|
||||||
|
</label>
|
||||||
|
<label className={cl.toggleRow}>
|
||||||
|
<input type="checkbox" className={cl.toggle}
|
||||||
|
checked={lsProgressBar} onChange={(e) => setLsProgressBar(e.target.checked)} />
|
||||||
|
<div className={cl.toggleText}>
|
||||||
|
<div className={cl.toggleTitle}>Прогресс-бар</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <div className={cl.error}><Icon name="warning" size={14} /> {error}</div>}
|
{error && <div className={cl.error}><Icon name="warning" size={14} /> {error}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -4,12 +4,15 @@ import { jwtDecode } from 'jwt-decode';
|
|||||||
import { useAuth } from '../auth/AuthContext.jsx';
|
import { useAuth } from '../auth/AuthContext.jsx';
|
||||||
import { useSanctions } from '../auth/SanctionsContext.jsx';
|
import { useSanctions } from '../auth/SanctionsContext.jsx';
|
||||||
import { BabylonScene } from './engine/BabylonScene';
|
import { BabylonScene } from './engine/BabylonScene';
|
||||||
|
import { StudioCollab } from './engine/StudioCollab';
|
||||||
|
import { CollabOverlay } from './engine/CollabOverlay';
|
||||||
import { BLOCK_TYPES, BLOCK_CATEGORIES, blockPreview, registerCustomBlockType } from './engine/BlockTypes';
|
import { BLOCK_TYPES, BLOCK_CATEGORIES, blockPreview, registerCustomBlockType } from './engine/BlockTypes';
|
||||||
import { MODEL_TYPES, MODEL_CATEGORIES, getModelType } from './engine/ModelTypes';
|
import { MODEL_TYPES, MODEL_CATEGORIES, getModelType } from './engine/ModelTypes';
|
||||||
import { getKit } from './engine/GameplayKits';
|
import { getKit } from './engine/GameplayKits';
|
||||||
import { PRIMITIVE_TYPES, PALETTE_PRIMITIVE_TYPES } from './engine/PrimitiveTypes';
|
import { PRIMITIVE_TYPES, PALETTE_PRIMITIVE_TYPES } from './engine/PrimitiveTypes';
|
||||||
import { getModelThumbnail } from './engine/ModelThumbnails';
|
import { getModelThumbnail } from './engine/ModelThumbnails';
|
||||||
import * as Kubikon3DApi from '../api/Kubikon3DService';
|
import * as Kubikon3DApi from '../api/Kubikon3DService';
|
||||||
|
import { REALTIME_HTTP } from '../api/API';
|
||||||
import GameSettingsModal from './GameSettingsModal';
|
import GameSettingsModal from './GameSettingsModal';
|
||||||
import SkinManagerModal from './SkinManagerModal';
|
import SkinManagerModal from './SkinManagerModal';
|
||||||
import PublishModal from './PublishModal';
|
import PublishModal from './PublishModal';
|
||||||
@ -464,6 +467,15 @@ const KubikonEditor = () => {
|
|||||||
|
|
||||||
const canvasRef = useRef(null);
|
const canvasRef = useRef(null);
|
||||||
const sceneRef = 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/перед уходом
|
// Флаш pending-debounce ScriptEditor. Зовём перед каждым doSave/перед уходом
|
||||||
// со страницы — иначе последние 600мс правок скрипта потеряются.
|
// со страницы — иначе последние 600мс правок скрипта потеряются.
|
||||||
const scriptEditorFlushRef = useRef(null);
|
const scriptEditorFlushRef = useRef(null);
|
||||||
@ -1010,6 +1022,14 @@ const KubikonEditor = () => {
|
|||||||
console.warn('[KubikonEditor] save: skip (load failed)');
|
console.warn('[KubikonEditor] save: skip (load failed)');
|
||||||
return;
|
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();
|
const userId = getCurrentUserId();
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
console.warn('[KubikonEditor] save: no userId');
|
console.warn('[KubikonEditor] save: no userId');
|
||||||
@ -1229,6 +1249,108 @@ const KubikonEditor = () => {
|
|||||||
}, AUTOSAVE_DEBOUNCE_MS);
|
}, AUTOSAVE_DEBOUNCE_MS);
|
||||||
}, [doSave]);
|
}, [doSave]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Team Create: подключиться к комнате совместного редактирования.
|
||||||
|
* Зовётся ПОСЛЕ загрузки сцены (scene готова). projectIdNum — числовой id.
|
||||||
|
* Подключаемся если: владелец проекта (всегда) ИЛИ есть ?collab=<token> в 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/<id>?collab=<token> и скопировать в буфер.
|
||||||
|
*/
|
||||||
|
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 + загрузка проекта (если редактируем существующий)
|
// Инициализация Babylon + загрузка проекта (если редактируем существующий)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// RACE FIX: пока isLoading=true (auth ещё грузится), компонент
|
// RACE FIX: пока isLoading=true (auth ещё грузится), компонент
|
||||||
@ -1608,6 +1730,9 @@ const KubikonEditor = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
console.log(`[KubikonEditor] setSceneLoading(false) — load finally (id=${id})`);
|
console.log(`[KubikonEditor] setSceneLoading(false) — load finally (id=${id})`);
|
||||||
setSceneLoading(false);
|
setSceneLoading(false);
|
||||||
|
// Team Create: после загрузки сцены — подключиться к комнате
|
||||||
|
// совместного редактирования (владелец или по ?collab-инвайту).
|
||||||
|
if (/^\d+$/.test(id)) initCollab(Number(id));
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
} else {
|
} else {
|
||||||
@ -1724,13 +1849,23 @@ const KubikonEditor = () => {
|
|||||||
clearTimeout(autoSaveTimerRef.current);
|
clearTimeout(autoSaveTimerRef.current);
|
||||||
autoSaveTimerRef.current = null;
|
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();
|
scene.dispose();
|
||||||
sceneRef.current = null;
|
sceneRef.current = null;
|
||||||
};
|
};
|
||||||
// isLoading в deps — без него эффект мог стрельнуть пока canvas
|
// isLoading в deps — без него эффект мог стрельнуть пока canvas
|
||||||
// ещё не в DOM (isLoading=true → компонент рендерит null) и больше
|
// ещё не в DOM (isLoading=true → компонент рендерит null) и больше
|
||||||
// не перезапускался → вечная "Загрузка проекта… 0%".
|
// не перезапускался → вечная "Загрузка проекта… 0%".
|
||||||
}, [isAuthenticated, isLoading, id, markDirty]);
|
}, [isAuthenticated, isLoading, id, markDirty, initCollab]);
|
||||||
|
|
||||||
// beforeunload — браузерный диалог нельзя кастомизировать (API запрещает).
|
// beforeunload — браузерный диалог нельзя кастомизировать (API запрещает).
|
||||||
// Auto-save срабатывает на каждое изменение через ~1.5 сек, поэтому риск
|
// Auto-save срабатывает на каждое изменение через ~1.5 сек, поэтому риск
|
||||||
@ -1948,6 +2083,19 @@ const KubikonEditor = () => {
|
|||||||
{saveStatus === 'error' && <><Icon name="warning" size={12} /> Ошибка</>}
|
{saveStatus === 'error' && <><Icon name="warning" size={12} /> Ошибка</>}
|
||||||
{saveStatus === 'idle' && '—'}
|
{saveStatus === 'idle' && '—'}
|
||||||
</span>
|
</span>
|
||||||
|
{/* Гость-соавтор (приглашённый по ссылке) НЕ может менять
|
||||||
|
настройки/сохранять/публиковать — это делает только владелец. */}
|
||||||
|
{isInvitedGuest ? (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
|
padding: '6px 12px', borderRadius: 8,
|
||||||
|
background: 'rgba(92,214,138,0.15)', color: '#5cd68a',
|
||||||
|
font: '600 12px system-ui',
|
||||||
|
}} title="Ты редактируешь вместе с автором. Сохраняет и публикует автор.">
|
||||||
|
<Icon name="users" size={13} /> Совместное редактирование
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
className={cl.toolbarBtn}
|
className={cl.toolbarBtn}
|
||||||
onClick={() => setSettingsModalOpen(true)}
|
onClick={() => setSettingsModalOpen(true)}
|
||||||
@ -1956,14 +2104,7 @@ const KubikonEditor = () => {
|
|||||||
>
|
>
|
||||||
<Icon name="settings" size={13} /> Настройки
|
<Icon name="settings" size={13} /> Настройки
|
||||||
</button>
|
</button>
|
||||||
<button
|
{/* Кнопка «Скины» переехала в TopRibbon → вкладка «Игра». */}
|
||||||
className={cl.toolbarBtn}
|
|
||||||
onClick={() => setSkinManagerOpen(true)}
|
|
||||||
title="Скины игрока: стартовый скин, магазин, рублики, свои .glb"
|
|
||||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}
|
|
||||||
>
|
|
||||||
<Icon name="user-square" size={13} /> Скины
|
|
||||||
</button>
|
|
||||||
<button className={cl.toolbarBtn} onClick={handleSave}><Icon name="save" size={13} /> Сохранить</button>
|
<button className={cl.toolbarBtn} onClick={handleSave}><Icon name="save" size={13} /> Сохранить</button>
|
||||||
<button
|
<button
|
||||||
className={cl.toolbarBtn}
|
className={cl.toolbarBtn}
|
||||||
@ -2003,6 +2144,8 @@ const KubikonEditor = () => {
|
|||||||
>
|
>
|
||||||
{(publishBan || isCantPublish) ? <><Icon name="hidden" size={13} /> Запрещено</> : <><Icon name="upload" size={13} /> Опубликовать</>}
|
{(publishBan || isCantPublish) ? <><Icon name="hidden" size={13} /> Запрещено</> : <><Icon name="upload" size={13} /> Опубликовать</>}
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{/* Кнопка баг-репорта в шапке — кликает по скрытой плавающей. */}
|
{/* Кнопка баг-репорта в шапке — кликает по скрытой плавающей. */}
|
||||||
<button
|
<button
|
||||||
onClick={() => document.querySelector('[data-kubikon-bug-btn]')?.click()}
|
onClick={() => document.querySelector('[data-kubikon-bug-btn]')?.click()}
|
||||||
@ -2146,6 +2289,10 @@ const KubikonEditor = () => {
|
|||||||
sceneRef.current?.setSpawnAtCamera();
|
sceneRef.current?.setSpawnAtCamera();
|
||||||
setSpawnEnabledUI(true);
|
setSpawnEnabledUI(true);
|
||||||
}}
|
}}
|
||||||
|
onSkins={() => setSkinManagerOpen(true)}
|
||||||
|
onInvite={handleInvite}
|
||||||
|
collabActive={collabActive}
|
||||||
|
collabPeers={collabPeers}
|
||||||
hasSelection={!!selection}
|
hasSelection={!!selection}
|
||||||
onDuplicate={() => sceneRef.current?.duplicateSelected()}
|
onDuplicate={() => sceneRef.current?.duplicateSelected()}
|
||||||
onAlignToFloor={() => sceneRef.current?.alignSelectedToFloor()}
|
onAlignToFloor={() => sceneRef.current?.alignSelectedToFloor()}
|
||||||
@ -2783,6 +2930,7 @@ const KubikonEditor = () => {
|
|||||||
className={cl.canvas}
|
className={cl.canvas}
|
||||||
style={{ visibility: activeTabId === 'scene' ? 'visible' : 'hidden' }}
|
style={{ visibility: activeTabId === 'scene' ? 'visible' : 'hidden' }}
|
||||||
/>
|
/>
|
||||||
|
{/* Кнопка «Пригласить» переехала в TopRibbon → вкладка «Игра» → группа «Вместе». */}
|
||||||
{isMaterialPreview && (
|
{isMaterialPreview && (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute', top: 10,
|
position: 'absolute', top: 10,
|
||||||
@ -4012,4 +4160,34 @@ const KubikonEditor = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Team Create: слать соавторам точку под мышью на сцене (raycast по pointermove).
|
||||||
|
* Throttle уже внутри collab.sendCursor. Также шлём позицию камеры.
|
||||||
|
*/
|
||||||
|
function _wireCursorTracking(scene, collab) {
|
||||||
|
try {
|
||||||
|
const canvas = scene.canvas;
|
||||||
|
if (!canvas) return;
|
||||||
|
const onMove = () => {
|
||||||
|
const bScene = scene.scene;
|
||||||
|
if (!bScene) return;
|
||||||
|
try {
|
||||||
|
const pick = bScene.pick(bScene.pointerX, bScene.pointerY);
|
||||||
|
if (pick && pick.hit && pick.pickedPoint) {
|
||||||
|
collab.sendCursor(pick.pickedPoint.x, pick.pickedPoint.y, pick.pickedPoint.z);
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
// камера (throttle внутри)
|
||||||
|
try {
|
||||||
|
const c = scene.camera;
|
||||||
|
if (c && c.position) collab.sendCamera(c.position.x, c.position.y, c.position.z);
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
};
|
||||||
|
canvas.addEventListener('pointermove', onMove);
|
||||||
|
// сохраним для снятия (необязательно — canvas живёт с редактором)
|
||||||
|
collab.__cursorHandler = onMove;
|
||||||
|
collab.__cursorCanvas = canvas;
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
export default KubikonEditor;
|
export default KubikonEditor;
|
||||||
|
|||||||
@ -233,6 +233,7 @@ const TopRibbon = (props) => {
|
|||||||
snap, onSnapChange,
|
snap, onSnapChange,
|
||||||
activeTool, onToolChange,
|
activeTool, onToolChange,
|
||||||
isPlaying, onPlayToggle, onSetSpawn,
|
isPlaying, onPlayToggle, onSetSpawn,
|
||||||
|
onSkins, onInvite, collabActive, collabPeers,
|
||||||
hasSelection,
|
hasSelection,
|
||||||
onDuplicate, onAlignToFloor, onDelete,
|
onDuplicate, onAlignToFloor, onDelete,
|
||||||
onClearScene,
|
onClearScene,
|
||||||
@ -431,6 +432,22 @@ const TopRibbon = (props) => {
|
|||||||
onClick={onSetSpawn}
|
onClick={onSetSpawn}
|
||||||
title="Поставить точку спавна там где смотрит камера"
|
title="Поставить точку спавна там где смотрит камера"
|
||||||
/>
|
/>
|
||||||
|
<RibbonBtn
|
||||||
|
iconName="user-square" label="Скины"
|
||||||
|
onClick={onSkins}
|
||||||
|
title="Скины игрока: стартовый скин, магазин, рублики, свои .glb"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Team Create — совместное редактирование. */}
|
||||||
|
<Group title="Вместе">
|
||||||
|
<RibbonBtn
|
||||||
|
iconName="users"
|
||||||
|
label={collabActive && collabPeers > 0 ? `Вместе (${collabPeers + 1})` : 'Пригласить'}
|
||||||
|
active={collabActive && collabPeers > 0}
|
||||||
|
onClick={onInvite}
|
||||||
|
title="Пригласить друга редактировать игру вместе (Team Create)"
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* «Окружение» (время суток / амбиент / музыка) и
|
{/* «Окружение» (время суток / амбиент / музыка) и
|
||||||
|
|||||||
@ -5942,7 +5942,7 @@ export class BabylonScene {
|
|||||||
this._updateSpawnMarker();
|
this._updateSpawnMarker();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Задача 12: конфиг экрана загрузки из настроек проекта (логотип/акцент/дефолты). */
|
/** Задача 12+05: конфиг экрана загрузки из настроек проекта. */
|
||||||
setLoadingConfig(cfg, thumbnail) {
|
setLoadingConfig(cfg, thumbnail) {
|
||||||
if (cfg && typeof cfg === 'object') {
|
if (cfg && typeof cfg === 'object') {
|
||||||
this._loadingConfig = {
|
this._loadingConfig = {
|
||||||
@ -5950,6 +5950,16 @@ export class BabylonScene {
|
|||||||
accentColor: cfg.accentColor || '#ffc020',
|
accentColor: cfg.accentColor || '#ffc020',
|
||||||
defaultSpinner: cfg.defaultSpinner !== false,
|
defaultSpinner: cfg.defaultSpinner !== false,
|
||||||
defaultSkipButton: !!cfg.defaultSkipButton,
|
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 {
|
} else {
|
||||||
this._loadingConfig = null;
|
this._loadingConfig = null;
|
||||||
@ -5957,6 +5967,34 @@ export class BabylonScene {
|
|||||||
if (thumbnail !== undefined) this._projectThumbnail = thumbnail || null;
|
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). */
|
/** Установить тип модели персонажа (для Play). */
|
||||||
setPlayerModelType(typeId) {
|
setPlayerModelType(typeId) {
|
||||||
if (!typeId) return;
|
if (!typeId) return;
|
||||||
@ -6074,6 +6112,9 @@ export class BabylonScene {
|
|||||||
// поэтому скрипты стартуем в следующем кадре.
|
// поэтому скрипты стартуем в следующем кадре.
|
||||||
this.gameRuntime = new GameRuntime(this);
|
this.gameRuntime = new GameRuntime(this);
|
||||||
try { this.modalManager?.attachRuntime?.(this.gameRuntime); } catch (e) {}
|
try { this.modalManager?.attachRuntime?.(this.gameRuntime); } catch (e) {}
|
||||||
|
// Задача 05: стартовый экран загрузки (Ken-Burns + название места),
|
||||||
|
// если настроен в проекте. Показываем поверх старта сцены.
|
||||||
|
try { this.showStartupLoadingScreen(); } catch (e) {}
|
||||||
// Аудио-менеджер (GD-музыка/SFX) — создаётся вместе с runtime.
|
// Аудио-менеджер (GD-музыка/SFX) — создаётся вместе с runtime.
|
||||||
// ВАЖНО: поле называется gameAudioManager чтобы не путать со штатным
|
// ВАЖНО: поле называется gameAudioManager чтобы не путать со штатным
|
||||||
// this.audioManager (AudioManager — ambient/music для всех проектов).
|
// this.audioManager (AudioManager — ambient/music для всех проектов).
|
||||||
@ -7646,12 +7687,22 @@ export class BabylonScene {
|
|||||||
coins: this._skinsConfig.coins || 0,
|
coins: this._skinsConfig.coins || 0,
|
||||||
customGlbs: this._skinsConfig.customGlbs || [],
|
customGlbs: this._skinsConfig.customGlbs || [],
|
||||||
} : undefined,
|
} : undefined,
|
||||||
// Задача 12: конфиг экрана загрузки (логотип/акцент/дефолты).
|
// Задача 12+05: конфиг экрана загрузки (логотип/акцент/дефолты + стартовый Ken-Burns).
|
||||||
loadingScreen: this._loadingConfig ? {
|
loadingScreen: this._loadingConfig ? {
|
||||||
logo: this._loadingConfig.logo || null,
|
logo: this._loadingConfig.logo || null,
|
||||||
accentColor: this._loadingConfig.accentColor || '#ffc020',
|
accentColor: this._loadingConfig.accentColor || '#ffc020',
|
||||||
defaultSpinner: this._loadingConfig.defaultSpinner !== false,
|
defaultSpinner: this._loadingConfig.defaultSpinner !== false,
|
||||||
defaultSkipButton: !!this._loadingConfig.defaultSkipButton,
|
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,
|
} : undefined,
|
||||||
// Задача 13: конфиг главного меню (passthrough — меню задаётся скриптом).
|
// Задача 13: конфиг главного меню (passthrough — меню задаётся скриптом).
|
||||||
mainMenu: this._mainMenuConfig || undefined,
|
mainMenu: this._mainMenuConfig || undefined,
|
||||||
@ -8120,15 +8171,9 @@ export class BabylonScene {
|
|||||||
} else {
|
} else {
|
||||||
this._skinsConfig = null;
|
this._skinsConfig = null;
|
||||||
}
|
}
|
||||||
// Задача 12: конфиг экрана загрузки.
|
// Задача 12+05: конфиг экрана загрузки (через setLoadingConfig — единый маппинг).
|
||||||
if (state.scene.loadingScreen && typeof state.scene.loadingScreen === 'object') {
|
if (state.scene.loadingScreen && typeof state.scene.loadingScreen === 'object') {
|
||||||
const ls = state.scene.loadingScreen;
|
this.setLoadingConfig(state.scene.loadingScreen);
|
||||||
this._loadingConfig = {
|
|
||||||
logo: ls.logo || null,
|
|
||||||
accentColor: ls.accentColor || '#ffc020',
|
|
||||||
defaultSpinner: ls.defaultSpinner !== false,
|
|
||||||
defaultSkipButton: !!ls.defaultSkipButton,
|
|
||||||
};
|
|
||||||
} else {
|
} else {
|
||||||
this._loadingConfig = null;
|
this._loadingConfig = null;
|
||||||
}
|
}
|
||||||
|
|||||||
180
src/editor/engine/CollabOverlay.js
Normal file
180
src/editor/engine/CollabOverlay.js
Normal file
@ -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 = '<span style="opacity:.8">👥 Соавторы:</span>';
|
||||||
|
|
||||||
|
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 = '<span style="opacity:.8">👥</span>';
|
||||||
|
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 += `<div class="kbnCollabAva" title="${escapeHtml(c.username)}${c.isHost ? ' (хост)' : ''}${c.me ? ' — вы' : ''}" style="background:${c.color};${ring}">${escapeHtml(initial)}</div>`;
|
||||||
|
}
|
||||||
|
html += `<span style="opacity:.7;margin-left:4px">${others.length}</span>`;
|
||||||
|
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]));
|
||||||
|
}
|
||||||
@ -1326,6 +1326,8 @@ export class GameRuntime {
|
|||||||
ls.setBridge(
|
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: 'loadingSkip', loadingId: id }); },
|
||||||
(id) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingComplete', 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;
|
this.scene3d.loadingScreen = ls;
|
||||||
}
|
}
|
||||||
@ -1879,9 +1881,9 @@ export class GameRuntime {
|
|||||||
const id = ls.show(payload.opts || {});
|
const id = ls.show(payload.opts || {});
|
||||||
// Вернуть worker'у real loadingId, чтобы хэндл (setProgress/close/колбэки)
|
// Вернуть worker'у real loadingId, чтобы хэндл (setProgress/close/колбэки)
|
||||||
// нашёл нужный экран по replyId → local→real маппингу.
|
// нашёл нужный экран по replyId → local→real маппингу.
|
||||||
if (payload.replyId != null) {
|
// replyId может отсутствовать (стартовый экран) — всё равно шлём
|
||||||
|
// loadingShown для game.loading.isVisible() (задача 05).
|
||||||
for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingShown', replyId: payload.replyId, loadingId: id });
|
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)); }
|
} catch (e) { this._log('error', 'loading.show: ' + (e?.message || e)); }
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@ -1889,6 +1891,7 @@ export class GameRuntime {
|
|||||||
if (cmd === 'loading.setProgress') { this.scene3d?.loadingScreen?.setProgress(payload?.value); return; }
|
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.setText') { this.scene3d?.loadingScreen?.setText(payload?.text); return; }
|
||||||
if (cmd === 'loading.setCover') { this.scene3d?.loadingScreen?.setCover(payload?.cover); 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; }
|
if (cmd === 'loading.close') { this.scene3d?.loadingScreen?.close(); return; }
|
||||||
|
|
||||||
// === Damage Floaters (задача 40) — всплывающие цифры урона ===
|
// === Damage Floaters (задача 40) — всплывающие цифры урона ===
|
||||||
|
|||||||
@ -35,7 +35,25 @@ function injectSpinnerCss() {
|
|||||||
style.textContent =
|
style.textContent =
|
||||||
'@keyframes kbn-ls-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}' +
|
'@keyframes kbn-ls-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}' +
|
||||||
'.kbn-ls-spinner{animation:kbn-ls-spin 0.9s linear infinite}' +
|
'.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);
|
document.head.appendChild(style);
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
@ -49,14 +67,17 @@ export class LoadingScreenOverlay {
|
|||||||
// Мост наружу (GameRuntime подписывает) — id-based колбэки.
|
// Мост наружу (GameRuntime подписывает) — id-based колбэки.
|
||||||
this._onSkipCb = null; // (id) => void
|
this._onSkipCb = null; // (id) => void
|
||||||
this._onCompleteCb = null; // (id) => void
|
this._onCompleteCb = null; // (id) => void
|
||||||
|
this._onHideCb = null; // () => void — задача 05 (game.loading.onHide)
|
||||||
|
this._parallaxHandler = null;
|
||||||
// DOM-ссылки активного экрана:
|
// DOM-ссылки активного экрана:
|
||||||
this._els = null;
|
this._els = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Колбэки-мост к GameRuntime (он рассылает globalEvent в sandbox'ы). */
|
/** Колбэки-мост к GameRuntime (он рассылает globalEvent в sandbox'ы). */
|
||||||
setBridge(onSkip, onComplete) {
|
setBridge(onSkip, onComplete, onHide) {
|
||||||
this._onSkipCb = onSkip;
|
this._onSkipCb = onSkip;
|
||||||
this._onCompleteCb = onComplete;
|
this._onCompleteCb = onComplete;
|
||||||
|
if (onHide) this._onHideCb = onHide;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Конфиг проекта (логотип/акцент по умолчанию) из BabylonScene._loadingConfig. */
|
/** Конфиг проекта (логотип/акцент по умолчанию) из BabylonScene._loadingConfig. */
|
||||||
@ -104,6 +125,15 @@ export class LoadingScreenOverlay {
|
|||||||
logoCornerRadius: opts.logoCornerRadius != null ? Number(opts.logoCornerRadius) : 12,
|
logoCornerRadius: opts.logoCornerRadius != null ? Number(opts.logoCornerRadius) : 12,
|
||||||
// Текст под картинкой
|
// Текст под картинкой
|
||||||
text: opts.text != null ? String(opts.text) : '',
|
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,
|
blockInput: opts.blockInput !== false,
|
||||||
pauseSimulation: opts.pauseSimulation !== false,
|
pauseSimulation: opts.pauseSimulation !== false,
|
||||||
@ -163,20 +193,107 @@ export class LoadingScreenOverlay {
|
|||||||
// (используем opacity всего root для fade, а bgOpacity — через rgba фон):
|
// (используем opacity всего root для fade, а bgOpacity — через rgba фон):
|
||||||
root.style.background = this._bgRgba(st.bgColor, st.bgOpacity);
|
root.style.background = this._bgRgba(st.bgColor, st.bgOpacity);
|
||||||
|
|
||||||
// --- Cover (картинка по центру) ---
|
// --- Фоновый слой (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);
|
const coverUrl = this._resolveCover(cover);
|
||||||
|
// Режим карточки места (задача 05): квадрат + название + автор под ней.
|
||||||
|
const hasPlaceCard = !!(st.placeName || st.studioName);
|
||||||
const coverImg = document.createElement('div');
|
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 =
|
coverImg.style.cssText =
|
||||||
'max-width:min(62vw,860px);width:62vw;aspect-ratio:16/9;border-radius:16px;' +
|
'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;' +
|
'box-shadow:0 18px 60px rgba(0,0,0,0.6);background-size:cover;background-position:center;' +
|
||||||
'background-color:#1a1f2b;margin-bottom:140px;';
|
'background-color:#1a1f2b;margin-bottom:140px;';
|
||||||
|
}
|
||||||
if (coverUrl) coverImg.style.backgroundImage = `url("${coverUrl}")`;
|
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');
|
const textEl = document.createElement('div');
|
||||||
|
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 =
|
textEl.style.cssText =
|
||||||
'position:absolute;left:50%;transform:translateX(-50%);bottom:170px;' +
|
'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);';
|
'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 || '';
|
textEl.textContent = st.text || '';
|
||||||
|
|
||||||
// --- Прогресс-бар ---
|
// --- Прогресс-бар ---
|
||||||
@ -245,8 +362,13 @@ export class LoadingScreenOverlay {
|
|||||||
spinWrap.appendChild(spinTxt);
|
spinWrap.appendChild(spinTxt);
|
||||||
spinWrap.appendChild(spinCircle);
|
spinWrap.appendChild(spinCircle);
|
||||||
|
|
||||||
root.appendChild(coverImg);
|
// Центральная композиция (карточка + название + автор + текст) — в content.
|
||||||
root.appendChild(textEl);
|
content.appendChild(coverImg);
|
||||||
|
content.appendChild(placeEl);
|
||||||
|
content.appendChild(studioRow);
|
||||||
|
content.appendChild(textEl);
|
||||||
|
root.appendChild(content);
|
||||||
|
// Нижние/угловые элементы — абсолютно на root (поверх фона, рядом с content).
|
||||||
root.appendChild(barWrap);
|
root.appendChild(barWrap);
|
||||||
root.appendChild(percent);
|
root.appendChild(percent);
|
||||||
root.appendChild(skipBtn);
|
root.appendChild(skipBtn);
|
||||||
@ -255,7 +377,19 @@ export class LoadingScreenOverlay {
|
|||||||
parent.appendChild(root);
|
parent.appendChild(root);
|
||||||
|
|
||||||
this.root = 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 =
|
||||||
|
'<svg width="18" height="18" viewBox="0 0 24 24" aria-label="verified">' +
|
||||||
|
'<circle cx="12" cy="12" r="11" fill="#3897f0"/>' +
|
||||||
|
'<path d="M7 12.5l3 3 7-7" fill="none" stroke="#fff" stroke-width="2.4" ' +
|
||||||
|
'stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||||
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** tick — fade-фазы, авто-duration, появление кнопки Пропустить. */
|
/** tick — fade-фазы, авто-duration, появление кнопки Пропустить. */
|
||||||
@ -329,6 +463,23 @@ export class LoadingScreenOverlay {
|
|||||||
if (url) this._els.coverImg.style.backgroundImage = `url("${url}")`;
|
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). */
|
/** Закрыть программно (с fadeOut). */
|
||||||
close() {
|
close() {
|
||||||
const st = this._st;
|
const st = this._st;
|
||||||
@ -361,6 +512,13 @@ export class LoadingScreenOverlay {
|
|||||||
if (st.blockInput) { try { this.s.player?.setInputBlocked?.(false); } catch { /* ignore */ } }
|
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 */ } }
|
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 */ } }
|
if (this.root) { try { this.root.remove(); } catch { /* ignore */ } }
|
||||||
this.root = null;
|
this.root = null;
|
||||||
this._els = null;
|
this._els = null;
|
||||||
|
|||||||
@ -70,6 +70,8 @@ let _toolUseHandlers = [];
|
|||||||
// При toolUse-событии воркер сначала вызывает per-tool колбэк, потом глобальные.
|
// При toolUse-событии воркер сначала вызывает per-tool колбэк, потом глобальные.
|
||||||
let _toolCallbacks = {}; // { 'custom:1': { activated: fn, equipped: fn, unequipped: fn } }
|
let _toolCallbacks = {}; // { 'custom:1': { activated: fn, equipped: fn, unequipped: fn } }
|
||||||
let _toolSeq = 0;
|
let _toolSeq = 0;
|
||||||
|
// Задача 05: зеркало видимости экрана загрузки (для game.loading.isVisible()).
|
||||||
|
let _loadingVisible = false;
|
||||||
// Мультиплеер (Фаза 4.3): снимок игроков комнаты { me, list }.
|
// Мультиплеер (Фаза 4.3): снимок игроков комнаты { me, list }.
|
||||||
let _players = { me: null, list: [] };
|
let _players = { me: null, list: [] };
|
||||||
// Общее состояние комнаты game.room.get/set — зеркало из main thread.
|
// Общее состояние комнаты game.room.get/set — зеркало из main thread.
|
||||||
@ -3032,6 +3034,7 @@ const game = {
|
|||||||
_localSeq: 0,
|
_localSeq: 0,
|
||||||
_localToReal: new Map(), // localId → реальный loadingId (приходит в loadingShown)
|
_localToReal: new Map(), // localId → реальный loadingId (приходит в loadingShown)
|
||||||
_handlers: new Map(), // localId → { onSkip:[], onComplete:[] }
|
_handlers: new Map(), // localId → { onSkip:[], onComplete:[] }
|
||||||
|
_onHide: [], // задача 05 — глобальные подписки на скрытие
|
||||||
show(opts) {
|
show(opts) {
|
||||||
opts = opts && typeof opts === 'object' ? opts : {};
|
opts = opts && typeof opts === 'object' ? opts : {};
|
||||||
const localId = ++this._localSeq;
|
const localId = ++this._localSeq;
|
||||||
@ -3050,11 +3053,27 @@ const game = {
|
|||||||
setProgress(v) { _send('loading.setProgress', { localId, value: Number(v) || 0 }); },
|
setProgress(v) { _send('loading.setProgress', { localId, value: Number(v) || 0 }); },
|
||||||
setText(t) { _send('loading.setText', { localId, text: String(t == null ? '' : t) }); },
|
setText(t) { _send('loading.setText', { localId, text: String(t == null ? '' : t) }); },
|
||||||
setCover(c) { _send('loading.setCover', { localId, cover: c }); },
|
setCover(c) { _send('loading.setCover', { localId, cover: c }); },
|
||||||
|
setBackground(b) { _send('loading.setBackground', { localId, background: b }); },
|
||||||
close() { _send('loading.close', { localId }); },
|
close() { _send('loading.close', { localId }); },
|
||||||
onSkip(fn) { if (typeof fn === 'function') self._handlers.get(localId)?.onSkip.push(fn); },
|
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); },
|
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). */
|
/** Типовой переход с фейковым прогрессом. Возвращает Promise (резолв по complete/skip). */
|
||||||
transition(opts) {
|
transition(opts) {
|
||||||
opts = opts && typeof opts === 'object' ? { ...opts } : {};
|
opts = opts && typeof opts === 'object' ? { ...opts } : {};
|
||||||
@ -4597,6 +4616,7 @@ self.onmessage = (e) => {
|
|||||||
} else if (t === 'loadingShown') {
|
} else if (t === 'loadingShown') {
|
||||||
// Задача 12: реальный loadingId от runtime — маппим local→real, чтобы
|
// Задача 12: реальный loadingId от runtime — маппим local→real, чтобы
|
||||||
// setProgress/close/колбэки нашли нужный экран.
|
// setProgress/close/колбэки нашли нужный экран.
|
||||||
|
_loadingVisible = true;
|
||||||
try {
|
try {
|
||||||
const lo = (typeof game !== 'undefined') && game.loading;
|
const lo = (typeof game !== 'undefined') && game.loading;
|
||||||
if (lo && payload && payload.replyId) {
|
if (lo && payload && payload.replyId) {
|
||||||
@ -4606,6 +4626,13 @@ self.onmessage = (e) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (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') {
|
} else if (t === 'loadingSkip' || t === 'loadingComplete') {
|
||||||
// Игрок нажал «Пропустить» (skip) или прогресс достиг 100% (complete).
|
// Игрок нажал «Пропустить» (skip) или прогресс достиг 100% (complete).
|
||||||
// Находим local по real loadingId и зовём соответствующие подписчики.
|
// Находим local по real loadingId и зовём соответствующие подписчики.
|
||||||
|
|||||||
502
src/editor/engine/StudioCollab.js
Normal file
502
src/editor/engine/StudioCollab.js
Normal file
@ -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<id>.
|
||||||
|
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 };
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user