Compare commits
70 Commits
6dcbb8483a
...
70731dae31
| Author | SHA1 | Date | |
|---|---|---|---|
| 70731dae31 | |||
|
|
6943e93818 | ||
|
|
e48338376a | ||
|
|
ab11ac0b4e | ||
|
|
fbf7ef680b | ||
|
|
cb41ea0062 | ||
|
|
c31b1ed3d6 | ||
| c8a961815e | |||
|
|
9f2cca1a49 | ||
| f46e6f0102 | |||
|
|
e15dc56de3 | ||
| c62073f7f8 | |||
|
|
661ff60bdf | ||
|
|
42f1334908 | ||
| b1fbc3790e | |||
| 2645337bdd | |||
|
|
48e2e83ef7 | ||
|
|
c20ac56895 | ||
|
|
931d53b4d9 | ||
|
|
e4fdd91b12 | ||
|
|
854074bfa2 | ||
|
|
c93070170b | ||
|
|
458b6c3b59 | ||
|
|
f8f0d976ef | ||
|
|
6c0c3dc26e | ||
|
|
e477d652f6 | ||
|
|
ba90bf5c7d | ||
|
|
1c976ee870 | ||
|
|
7bb789f1af | ||
|
|
ce6e69a2e8 | ||
|
|
33cd435d06 | ||
|
|
c9498b086e | ||
|
|
5d49cd9eeb | ||
|
|
cf34f9cdb6 | ||
|
|
c4d184257b | ||
|
|
26e6306f6e | ||
|
|
9903719f9d | ||
|
|
fe8b6b5b38 | ||
|
|
3bf1e77230 | ||
|
|
045f892aaa | ||
|
|
018fce474b | ||
|
|
6ece149924 | ||
|
|
2d669a3ff3 | ||
|
|
46414d874b | ||
|
|
bf93219266 | ||
|
|
ed7310a532 | ||
|
|
f270854795 | ||
|
|
6938f83a3c | ||
|
|
0e4fa89f40 | ||
|
|
9a58c34303 | ||
|
|
32cbb7bbe9 | ||
|
|
4906c82792 | ||
|
|
cfc79f325f | ||
|
|
6b857636c3 | ||
|
|
c7b5f3645d | ||
|
|
8b887e866a | ||
|
|
4c8f8c99cb | ||
|
|
7fc4ee94f6 | ||
|
|
471af1cdeb | ||
|
|
7242e80602 | ||
|
|
df1647019d | ||
|
|
781c3cf945 | ||
|
|
4284fef704 | ||
|
|
b774f92d40 | ||
|
|
5e1a0edf9b | ||
|
|
d62739d709 | ||
|
|
8a7ab9aadf | ||
|
|
a4881ee5ce | ||
|
|
71536668f2 | ||
|
|
75e83a9f3b |
@ -168,3 +168,4 @@ git push origin feature/моя-фича
|
|||||||
- Issues и PR: https://git.rublox.pro/rublox/studio
|
- Issues и PR: https://git.rublox.pro/rublox/studio
|
||||||
- Безопасность: [SECURITY.md](./SECURITY.md)
|
- Безопасность: [SECURITY.md](./SECURITY.md)
|
||||||
|
|
||||||
|
<!-- e2e-test 2026-06-07: проверка workflow разработчиков после восстановления -->
|
||||||
|
|||||||
@ -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>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -353,4 +353,29 @@ export const GAMES = [
|
|||||||
desc: 'Полноценные машины: подходишь, держишь F — садишься за руль, WASD рулят, камера следует за авто, спидометр снизу. E — выйти. Готовые 3D-модели машин.',
|
desc: 'Полноценные машины: подходишь, держишь F — садишься за руль, WASD рулят, камера следует за авто, спидометр снизу. E — выйти. Готовые 3D-модели машин.',
|
||||||
mechanics: ['game.scene.spawn(\'vehicle:car\')', 'аркадная физика (газ/руль/тормоз)', 'hold-F вход / E выход', 'камера за машиной (V меняет)', 'HUD водителя (спидометр+передача)', 'onVehicleEnter/onVehicleExit'],
|
mechanics: ['game.scene.spawn(\'vehicle:car\')', 'аркадная физика (газ/руль/тормоз)', 'hold-F вход / E выход', 'камера за машиной (V меняет)', 'HUD водителя (спидометр+передача)', 'onVehicleEnter/onVehicleExit'],
|
||||||
previewShot: 'guide-taxisim-scene.png', openProjectId: 2436, ready: true },
|
previewShot: 'guide-taxisim-scene.png', openProjectId: 2436, ready: true },
|
||||||
|
{ id: 'guide-skybox', num: 62, group: 'g5', stars: 2, icon: 'cloud',
|
||||||
|
title: 'Небесная демка — кастомное небо',
|
||||||
|
desc: 'Одной строкой меняешь небо: голубой день, закат, звёздная ночь, космос. Облака, туман, далёкие горы и плавные переходы между пресетами.',
|
||||||
|
mechanics: ['game.scene.setSkybox({ preset })', 'game.scene.setClouds / setFog', 'skybox.fadeTo(opts, сек) — плавный переход', '6 пресетов: день/lowpoly/закат/ночь/космос', 'небо = единый источник света сцены', 'облака-дрейф + дымка горизонта'],
|
||||||
|
previewShot: 'guide-skybox-scene.png', openProjectId: 2541, ready: true },
|
||||||
|
{ id: 'guide-leaderstats', num: 63, group: 'g5', stars: 2, icon: 'trophy',
|
||||||
|
title: 'Сбор монет — лидерборды и достижения',
|
||||||
|
desc: 'Таблица лидеров справа-сверху (монеты/время/уровень) + всплывающие достижения с редкостью и звуком. Прогресс сохраняется в БД между сессиями.',
|
||||||
|
mechanics: ['game.leaderstats.define / me.add', 'HUD-таблица топ-10 (сортировка по primary)', 'game.achievements.define / unlock', 'bindToStat — авто-награда по статy', 'toast 4 редкости + очередь', 'кубок → страница достижений', 'сохранение в БД (savegame)'],
|
||||||
|
previewShot: 'guide-leaderstats-scene.png', openProjectId: 2616, ready: true },
|
||||||
|
{ id: 'guide-floaters', num: 64, group: 'g5', stars: 2, icon: 'sparkles',
|
||||||
|
title: 'Зомби-арена — бластер и цифры урона',
|
||||||
|
desc: 'Шутер: волны зомби бегут к игроку, бластер их отстреливает, над целью всплывают облачка урона. Авто-floater над любым мобом одной строкой + ручной game.fx.damageFloater (крит/хил/мана/промах/стек/комикс).',
|
||||||
|
mechanics: ['game.fx.damageFloater(pos, value, opts)', 'game.fx.autoMobFloaters(true) — облачко над NPC при уроне', 'game.player.giveTool(\'blaster-...\') — бластер', 'бластер от 3-го лица — в точку клика', 'spawnNpc + follow(\'player\') — зомби-волны', 'isCrit/isHeal/isMana/isMiss, стек ×N, комикс', 'object pool 30 планов (без лагов)'],
|
||||||
|
previewShot: 'guide-floaters-scene.png', openProjectId: 2676, ready: true },
|
||||||
|
{ id: 'guide-inventory', num: 65, group: 'g5', stars: 2, icon: 'box',
|
||||||
|
title: 'Сбор и сортировка — инвентарь с drag-drop',
|
||||||
|
desc: 'Полный инвентарь как в Minecraft/RPG: сетка 8×5 + хотбар 9, стаки, 5 редкостей (цвет рамки), перетаскивание мышью, ПКМ-меню, tooltip, сортировка. Собираешь предметы — стаки растут.',
|
||||||
|
mechanics: ['game.items.define([...]) — предметы (редкость/стак/иконка)', 'game.inventory.give / take', 'окно по I — сетка 8×5 + хотбар 9 (1-9)', 'drag-drop между слотами (swap + merge)', 'стаки с maxStack, 5 редкостей', 'ПКМ-меню: использовать / разделить / выбросить', 'tooltip + сортировка по редкости'],
|
||||||
|
previewShot: 'guide-inventory-scene.png', openProjectId: 2685, ready: true },
|
||||||
|
{ id: 'guide-loadingscreen', num: 66, group: 'g5', stars: 2, icon: 'loader',
|
||||||
|
title: 'Экран загрузки — Ken Burns и название места',
|
||||||
|
desc: 'Что видит игрок при входе в игру: размытый фон с медленным движением (Ken Burns), карточка-витрина по центру, название места и автор с verified-галочкой — как в Roblox. Автор настраивает экран во вкладке «Стартовый экран».',
|
||||||
|
mechanics: ['красивый экран загрузки игры в плеере (GameLoadingScreen)', 'Ken Burns / static / parallax / particles', 'карточка-витрина + название места + автор + verified', 'настройка во вкладке «Стартовый экран» (свойства проекта)', 'game.loading.show({ style, placeName, studioName, duration }) — переходы', 'game.loading.onHide() — продолжить после загрузки', 'game.loading.setBackground / setText / setProgress'],
|
||||||
|
previewShot: 'guide-loadingscreen-scene.png', openProjectId: 2713, ready: true },
|
||||||
];
|
];
|
||||||
|
|||||||
@ -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} />
|
||||||
@ -319,6 +327,11 @@ const ICONS = {
|
|||||||
<path d="M9 12l2 2 4-4" {...S} />
|
<path d="M9 12l2 2 4-4" {...S} />
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
cloud: () => (
|
||||||
|
<>
|
||||||
|
<path d="M7 18a4 4 0 0 1 0-8 5 5 0 0 1 9.6-1.3A3.5 3.5 0 0 1 17.5 18H7z" {...S} />
|
||||||
|
</>
|
||||||
|
),
|
||||||
car: () => (
|
car: () => (
|
||||||
<>
|
<>
|
||||||
<path d="M3 14l1.8-5a2 2 0 0 1 1.9-1.4h8.6A2 2 0 0 1 19 9l1.8 5M3 14h18M3 14v3.5h2.5V14M21 14v3.5h-2.5V14" {...S} />
|
<path d="M3 14l1.8-5a2 2 0 0 1 1.9-1.4h8.6A2 2 0 0 1 19 9l1.8 5M3 14h18M3 14v3.5h2.5V14M21 14v3.5h-2.5V14" {...S} />
|
||||||
|
|||||||
@ -8596,6 +8596,429 @@ game.onVehicleExit((vehicleRef) => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
'guide-skybox': {
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<h3 className="lessonH">Что получится</h3>
|
||||||
|
<p>
|
||||||
|
<b>Красивое небо в одну строку.</b> Вместо плоского цветного фона —
|
||||||
|
градиентный купол с солнцем, плывущими облаками, дымкой у горизонта
|
||||||
|
и далёкими горами (low-poly стиль, как в топовых Roblox-играх). Небо
|
||||||
|
меняется кнопками: <b>день</b>, <b>закат</b>, <b>звёздная ночь</b>,
|
||||||
|
<b> космос</b> — с плавным переходом за пару секунд. И главное: небо —
|
||||||
|
это <b>единый источник света</b>: меняешь пресет → вместе с небом
|
||||||
|
меняется и освещение всей сцены (на закате теплеет, ночью темнеет).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Shot src="guide-skybox-scene.png" wide
|
||||||
|
caption="Дневное небо (пресет «Lowpoly»): голубой градиент, облака, дымка у горизонта, далёкие горы — town выглядит живым." />
|
||||||
|
|
||||||
|
<h3 className="lessonH">Чему научишься</h3>
|
||||||
|
<ul>
|
||||||
|
<li><b>game.scene.setSkybox({'{'} preset {'}'})</b> — поставить готовое небо
|
||||||
|
одной строкой (6 пресетов);</li>
|
||||||
|
<li><b>game.scene.setClouds(...)</b> — облака: плотность, скорость дрейфа, цвет;</li>
|
||||||
|
<li><b>game.scene.setFog(...)</b> — атмосферный туман: дальние объекты выцветают в небо;</li>
|
||||||
|
<li><b>game.scene.skybox.fadeTo(opts, сек)</b> — плавный переход между небесами;</li>
|
||||||
|
<li><b>game.scene.skybox.setSunDirection(...)</b> — двигать солнце (анимация дуги).</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="lessonH">Шаг 1. Поставить небо</h3>
|
||||||
|
<p>
|
||||||
|
Самое простое — выбрать пресет. Доступны:
|
||||||
|
<code>clear-summer-day</code>, <code>lowpoly-roblox</code>,
|
||||||
|
<code>cloudy</code>, <code>sunset</code>, <code>starry-night</code>,
|
||||||
|
<code>space</code>.
|
||||||
|
</p>
|
||||||
|
<ScriptKind kind="global" />
|
||||||
|
<Code>{`// Голубое low-poly небо с облаками, дымкой и горами (как на скрине):
|
||||||
|
game.scene.setSkybox({ preset: 'lowpoly-roblox' });
|
||||||
|
game.scene.setClouds({ enabled: true, cover: 0.45, speed: 0.014 });
|
||||||
|
game.scene.setFog({ color: '#e2eef7', density: 0.005 });`}</Code>
|
||||||
|
<Note>
|
||||||
|
Небо само выставляет освещение сцены под выбранный пресет — отдельно
|
||||||
|
свет настраивать не нужно. Купол бесконечно далёкий, поэтому ходить
|
||||||
|
«до края неба» нельзя — оно всегда вокруг игрока.
|
||||||
|
</Note>
|
||||||
|
|
||||||
|
<h3 className="lessonH">Шаг 2. Плавная смена неба (день → закат → ночь)</h3>
|
||||||
|
<p>
|
||||||
|
<code>skybox.fadeTo</code> переводит небо к новому пресету за указанное
|
||||||
|
число секунд — цвета купола, солнце, облака, туман и свет сцены
|
||||||
|
меняются плавно. Удобно вешать на кнопки или события.
|
||||||
|
</p>
|
||||||
|
<ScriptKind kind="global" />
|
||||||
|
<Code>{`game.gui.onClick('btn-sunset', () => game.scene.skybox.fadeTo({ preset: 'sunset' }, 2));
|
||||||
|
game.gui.onClick('btn-night', () => game.scene.skybox.fadeTo({ preset: 'starry-night' }, 2));
|
||||||
|
game.gui.onClick('btn-space', () => game.scene.skybox.fadeTo({ preset: 'space' }, 2));`}</Code>
|
||||||
|
|
||||||
|
<Shot src="guide-skybox-night.png" wide
|
||||||
|
caption="Пресет «Звёздная ночь»: тёмно-синий купол со звёздами, свет сцены приглушён — town погружается в ночь. Переход плавный (fadeTo)." />
|
||||||
|
|
||||||
|
<Shot src="guide-skybox-space.png" wide
|
||||||
|
caption="Пресет «Космос»: почти чёрное звёздное небо, туман отключён — дальние объекты видны чётко." />
|
||||||
|
|
||||||
|
<h3 className="lessonH">Шаг 3. Своё небо (gradient)</h3>
|
||||||
|
<p>
|
||||||
|
Можно не брать пресет, а задать цвета купола вручную — верх, низ,
|
||||||
|
горизонт и солнце.
|
||||||
|
</p>
|
||||||
|
<ScriptKind kind="global" />
|
||||||
|
<Code>{`game.scene.setSkybox({
|
||||||
|
mode: 'gradient',
|
||||||
|
topColor: '#3d7fe0', // зенит
|
||||||
|
bottomColor: '#dcebf7', // у земли
|
||||||
|
horizonColor: '#bcd9f2', // линия горизонта
|
||||||
|
sunDirection: { x: 0.3, y: 0.85, z: 0.4 },
|
||||||
|
sunColor: '#fff6d8',
|
||||||
|
sunSize: 0.035,
|
||||||
|
});`}</Code>
|
||||||
|
|
||||||
|
<h3 className="lessonH">Почему это важно</h3>
|
||||||
|
<p>
|
||||||
|
Небо — половина визуального впечатления от мира. С плоским фоном все
|
||||||
|
игры выглядят одинаково и дёшево; с кастомным небом — атмосферно и
|
||||||
|
«дорого». А связка неба с освещением даёт бесплатный приём: смена
|
||||||
|
времени суток одной строкой мгновенно меняет настроение всей сцены.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Try>
|
||||||
|
Сделай день/ночь цикл: по таймеру каждые 10 секунд переключай
|
||||||
|
<code>fadeTo</code> между <code>'clear-summer-day'</code> и
|
||||||
|
<code>'starry-night'</code>. Добавь облака погуще на день и убери на ночь.
|
||||||
|
</Try>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
'guide-leaderstats': {
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<h3 className="lessonH">Что получится</h3>
|
||||||
|
<p>
|
||||||
|
Игра «собери монеты» с двумя системами удержания, как в Roblox:
|
||||||
|
<b> таблица лидеров</b> справа-сверху (монеты, время, уровень) и
|
||||||
|
<b> достижения</b> — всплывающие награды с редкостью, звуком и
|
||||||
|
страницей-витриной. Прогресс <b>сохраняется в базе</b> — закрыл
|
||||||
|
игру, вернулся завтра, а монеты и открытые ачивки на месте.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Shot src="guide-leaderstats-play.png" wide
|
||||||
|
caption="Таблица лидеров справа-сверху + всплывающая плашка достижения «Терпеливый» при игре. Слева-снизу — кубок, открывающий страницу всех достижений." />
|
||||||
|
|
||||||
|
<h3 className="lessonH">Чему научишься</h3>
|
||||||
|
<ul>
|
||||||
|
<li><b>game.leaderstats.define(name, opts)</b> — столбец таблицы:
|
||||||
|
иконка, цвет, формат (число / время / 1.2K), primary (по нему сортировка);</li>
|
||||||
|
<li><b>game.leaderstats.me.add('Монеты', 1)</b> — изменить стат игрока
|
||||||
|
(ячейка жёлто мигает);</li>
|
||||||
|
<li><b>game.achievements.define([...])</b> — объявить достижения
|
||||||
|
(id, название, описание, редкость, очки, скрытое);</li>
|
||||||
|
<li><b>game.achievements.unlock(id)</b> — выдать достижение (плашка + звук);</li>
|
||||||
|
<li><b>game.achievements.bindToStat(id, 'Монеты', {'{'} gte: 10 {'}'})</b> —
|
||||||
|
авто-награда при достижении значения стата;</li>
|
||||||
|
<li>прогресс сохраняется в БД и подгружается в новой сессии.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="lessonH">Шаг 1. Таблица лидеров</h3>
|
||||||
|
<p>
|
||||||
|
Объяви столбцы. Первый <code>primary: true</code> — по нему сортируются
|
||||||
|
игроки в топе. Дальше меняй значения через <code>me.add / me.set</code>.
|
||||||
|
</p>
|
||||||
|
<ScriptKind kind="global" />
|
||||||
|
<Code>{`game.leaderstats.define('Монеты', { initial: 0, format: 'number', icon: '🪙', color: '#ffd23a', primary: true });
|
||||||
|
game.leaderstats.define('Время', { initial: 0, format: 'time', icon: '⏱', color: '#7fd0ff' });
|
||||||
|
game.leaderstats.define('Уровень', { initial: 1, format: 'number', icon: '⭐', color: '#b48bff' });
|
||||||
|
|
||||||
|
// Время идёт само, монеты — за подбор
|
||||||
|
game.every(1, () => game.leaderstats.me.add('Время', 1));
|
||||||
|
game.leaderstats.me.add('Монеты', 1);`}</Code>
|
||||||
|
|
||||||
|
<h3 className="lessonH">Шаг 2. Достижения</h3>
|
||||||
|
<p>
|
||||||
|
Объяви список достижений с редкостью (<code>common / rare / epic /
|
||||||
|
legendary</code> — разный цвет плашки и звук). Выдавай явно через
|
||||||
|
<code>unlock</code> или автоматически через <code>bindToStat</code>.
|
||||||
|
</p>
|
||||||
|
<ScriptKind kind="global" />
|
||||||
|
<Code>{`game.achievements.define([
|
||||||
|
{ id:'first_coin', name:'Первая монета', description:'Подобрать монету', icon:'🪙', rarity:'common', points:5 },
|
||||||
|
{ id:'fifty_coins', name:'Полная сумка', description:'Собрать 50 монет', icon:'💰', rarity:'rare', points:25 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Авто-награда: как только Монеты >= 50 — плашка «Полная сумка» сама выедет
|
||||||
|
game.achievements.bindToStat('fifty_coins', 'Монеты', { gte: 50 });
|
||||||
|
|
||||||
|
// Явная выдача (на первой монете)
|
||||||
|
game.achievements.unlock('first_coin');`}</Code>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Кубок слева-снизу открывает страницу <b>«Мои достижения»</b>:
|
||||||
|
открытые — цветные с рамкой по редкости, закрытые — серые с
|
||||||
|
замком, скрытые — «?», сверху прогресс-бар «N из M (очки)».
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
Прогресс игрока (значения статов + открытые достижения) автоматически
|
||||||
|
сохраняется в базу и подгружается при следующем входе — ничего
|
||||||
|
дописывать не нужно. Уже открытое достижение второй раз плашкой не
|
||||||
|
показывается (оно «навсегда»).
|
||||||
|
</Note>
|
||||||
|
|
||||||
|
<h3 className="lessonH">Почему это важно</h3>
|
||||||
|
<p>
|
||||||
|
Лидерборды и достижения — главный механизм удержания: ребёнок
|
||||||
|
возвращается в игру за новым рекордом и новой ачивкой. Это основа
|
||||||
|
симуляторов, ферм и PvP — в Roblox столбец «Coins / Wins / Level»
|
||||||
|
есть почти в каждой игре.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Try>
|
||||||
|
Добавь стат «Рекорд» и достижение <code>'speedrun'</code>, которое
|
||||||
|
выдаётся через <code>bindToStat('Время', {'{'} lte: 30 {'}'})</code>,
|
||||||
|
если собрать все монеты быстрее 30 секунд.
|
||||||
|
</Try>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
'guide-floaters': {
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<h3 className="lessonH">Что получится</h3>
|
||||||
|
<p>
|
||||||
|
Мини-шутер: волны <b>зомби бегут к игроку</b>, ты отстреливаешь
|
||||||
|
их из <b>бластера</b>, а над каждой целью всплывает <b>облачко
|
||||||
|
урона</b> — как в Roblox-RPG (Pet Sim, Anime Adventures). Зомби
|
||||||
|
гибнут, счётчик растёт, волны усиливаются.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Shot src="guide-floaters-scene.png" wide
|
||||||
|
caption="Зомби сбегаются к игроку, бластер стреляет — над целью всплывают красные облачка урона «-25»." />
|
||||||
|
|
||||||
|
<h3 className="lessonH">Шаг 1. Бластер + авто-облачка над мобами</h3>
|
||||||
|
<p>
|
||||||
|
Две строки превращают игру в шутер с фидбеком урона: выдаём
|
||||||
|
бластер и включаем <b>авто-floater</b> — теперь <i>любой</i> урон
|
||||||
|
по NPC сам рисует «-N» над целью, вручную вызывать ничего не надо.
|
||||||
|
</p>
|
||||||
|
<ScriptKind kind="global" />
|
||||||
|
<Code>{`game.player.giveTool('blaster-blaster-a', { equip: true }); // бластер в руки
|
||||||
|
game.fx.autoMobFloaters(true); // облачко урона над любым мобом при попадании`}</Code>
|
||||||
|
|
||||||
|
<h3 className="lessonH">Шаг 2. Волны зомби, идущих к игроку</h3>
|
||||||
|
<Code>{`function spawnWave(n){
|
||||||
|
const pl = game.player.position;
|
||||||
|
for (let i = 0; i < n; i++){
|
||||||
|
const a = (i / n) * Math.PI * 2;
|
||||||
|
const e = game.scene.spawnNpc('skin_retro-zombie', {
|
||||||
|
x: pl.x + Math.cos(a)*18, z: pl.z + Math.sin(a)*18,
|
||||||
|
name: 'Зомби', hp: 100, speed: 2.6,
|
||||||
|
});
|
||||||
|
if (e && e.follow) e.follow('player'); // зомби преследует игрока
|
||||||
|
}
|
||||||
|
}
|
||||||
|
game.after(1.5, () => spawnWave(5));
|
||||||
|
game.every(14, () => spawnWave(8));`}</Code>
|
||||||
|
<p>
|
||||||
|
Стрелять из бластера — ЛКМ. В режиме от 3-го лица пуля летит
|
||||||
|
<b> туда, куда кликнул</b> курсором. Попал по зомби → облачко
|
||||||
|
урона (благодаря <code>autoMobFloaters</code>), убил → засчитан.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="lessonH">Ручной floater — все типы</h3>
|
||||||
|
<p>Когда нужен полный контроль — рисуй цифру сам:</p>
|
||||||
|
<Code>{`game.fx.damageFloater(pos, 25); // красный — обычный урон
|
||||||
|
game.fx.damageFloater(pos, 80, { isCrit: true }); // жёлтый, больше + подскок
|
||||||
|
game.fx.damageFloater(pos, 30, { isHeal: true }); // зелёный — лечение (+30)
|
||||||
|
game.fx.damageFloater(pos, 50, { isMana: true }); // синий — мана
|
||||||
|
game.fx.damageFloater(pos, 'Промах', { isMiss: true }); // серый текст`}</Code>
|
||||||
|
<p>
|
||||||
|
<b>position</b> — <code>{'{x,y,z}'}</code>, ссылка на объект или
|
||||||
|
<code>'player'</code>; <b>value</b> — число или строка.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="lessonH">Стек и комикс-стиль</h3>
|
||||||
|
<Code>{`// общий stackKey → удары сливаются в «-25 ×N» вместо кучи цифр
|
||||||
|
game.fx.damageFloater(enemy.position, 25, { stackKey: 'aoe_' + enemy.id });
|
||||||
|
// comicStyle → BAM! (>50), KAPOW! (>100), POW! (крит) на жёлтой звезде
|
||||||
|
game.fx.damageFloater(pos, 120, { comicStyle: true });`}</Code>
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
Под капотом — пул из 30 переиспользуемых билборд-планов
|
||||||
|
(object pool), поэтому даже при толпе зомби и спаме цифр FPS не
|
||||||
|
проседает. Цифры всегда поверх геометрии и повёрнуты к камере.
|
||||||
|
</Note>
|
||||||
|
|
||||||
|
<h3 className="lessonH">Почему это важно</h3>
|
||||||
|
<p>
|
||||||
|
Без облачек урона стрельба ощущается «впустую». Это базовый
|
||||||
|
боевой фидбек: игрок видит, сколько нанёс, был ли крит, попал ли.
|
||||||
|
Связка <code>бластер + autoMobFloaters + волны NPC</code> — готовый
|
||||||
|
каркас любого шутера/выживания.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Try>
|
||||||
|
Сделай «огненный» урон: <code>damageFloater(pos, 15, {'{'} color:
|
||||||
|
'#ff7a2a' {'}'})</code> каждые 0.5 сек 3 раза — эффект горения.
|
||||||
|
Или увеличь HP зомби и добавь крит каждый 5-й выстрел.
|
||||||
|
</Try>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
'guide-inventory': {
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<h3 className="lessonH">Что получится</h3>
|
||||||
|
<p>
|
||||||
|
Полноценный инвентарь как в Minecraft и RPG: <b>сетка 8×5</b> +
|
||||||
|
<b> хотбар на 9 слотов</b>, предметы со <b>стаками</b> и
|
||||||
|
<b> редкостями</b>, перетаскивание мышью, ПКМ-меню, всплывающие
|
||||||
|
подсказки. Собираешь предметы на поляне — стаки растут, открываешь
|
||||||
|
инвентарь клавишей <b>I</b> и раскладываешь добычу.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Shot src="guide-inventory-scene.png" wide
|
||||||
|
caption="Окно инвентаря (I): сетка 8×5 + хотбар 9. Стаки (×12 ягод), цвет рамки = редкость (голубая rare, зелёная uncommon)." />
|
||||||
|
|
||||||
|
<h3 className="lessonH">Шаг 1. Определи предметы</h3>
|
||||||
|
<p>
|
||||||
|
Каждый предмет описывается один раз: имя, иконка-эмодзи, редкость,
|
||||||
|
размер стака, эффект использования.
|
||||||
|
</p>
|
||||||
|
<ScriptKind kind="global" />
|
||||||
|
<Code>{`game.items.define([
|
||||||
|
{ id:'berry', name:'Ягоды', emoji:'🍓', rarity:'common', maxStack:16, value:2 },
|
||||||
|
{ id:'iron', name:'Руда', emoji:'⛏️', rarity:'uncommon', maxStack:16, value:8 },
|
||||||
|
{ id:'potion', name:'Зелье', emoji:'🧪', rarity:'rare', maxStack:8, onUseEffect:'heal:50' },
|
||||||
|
{ id:'sword', name:'Меч', emoji:'⚔️', rarity:'legendary', maxStack:1, value:500 },
|
||||||
|
]);`}</Code>
|
||||||
|
<p>
|
||||||
|
Редкости: <code>common</code> (серый), <code>uncommon</code>
|
||||||
|
(зелёный), <code>rare</code> (голубой), <code>epic</code>
|
||||||
|
(фиолетовый), <code>legendary</code> (золотой) — это цвет рамки слота.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="lessonH">Шаг 2. Выдавай и собирай предметы</h3>
|
||||||
|
<Code>{`game.inventory.give('sword', 1); // в стартовый набор
|
||||||
|
game.inventory.give('berry', 5); // стак до maxStack, дальше — новый слот
|
||||||
|
|
||||||
|
// сбор предмета с земли (на объекте-ягоде):
|
||||||
|
game.self.onInteract(() => {
|
||||||
|
game.inventory.give('berry', 2);
|
||||||
|
game.self.delete(); // убрать собранный предмет
|
||||||
|
}, { text:'Собрать', key:'e', distance:3 });`}</Code>
|
||||||
|
<p>
|
||||||
|
Собранное попадает <b>сначала в хотбар</b> (виден внизу экрана),
|
||||||
|
одинаковые предметы складываются в стак с учётом <code>maxStack</code>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Shot src="guide-inventory-play.png" wide
|
||||||
|
caption="Хотбар внизу наполняется собранным (меч, яблоки ×3, зелье). Подсказка «E Собрать» у ближайшего предмета." />
|
||||||
|
|
||||||
|
<h3 className="lessonH">Шаг 3. Окно инвентаря</h3>
|
||||||
|
<ul>
|
||||||
|
<li><b>I</b> — открыть/закрыть окно (Esc тоже закрывает);</li>
|
||||||
|
<li><b>Перетаскивание</b> мышью — поменять слоты местами или
|
||||||
|
слить стаки;</li>
|
||||||
|
<li><b>ПКМ</b> по слоту — меню: использовать / разделить / выбросить;</li>
|
||||||
|
<li><b>Наведение</b> — tooltip (название цветом редкости, описание, цена);</li>
|
||||||
|
<li><b>Сорт.</b> — расставить по редкости;</li>
|
||||||
|
<li><b>1-9</b> — выбрать активный слот хотбара.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
Всё хранится в движке и сериализуется в проект автоматически —
|
||||||
|
дописывать сохранение не нужно. Предметы с тегом
|
||||||
|
<code>'quest'</code> нельзя выбросить.
|
||||||
|
</Note>
|
||||||
|
|
||||||
|
<h3 className="lessonH">Почему это важно</h3>
|
||||||
|
<p>
|
||||||
|
Инвентарь — несущая конструкция RPG, выживания и симуляторов.
|
||||||
|
Сетка + хотбар + стаки + редкости — стандарт, который игроки
|
||||||
|
узнают мгновенно. Сочетается с крафтом, квестами и магазином.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Try>
|
||||||
|
Добавь предмет <code>'apple'</code> с
|
||||||
|
<code> onUseEffect:'heal:15'</code>, положи в хотбар и нажми ПКМ →
|
||||||
|
«Использовать» — HP восстановится, яблоко убавится на 1.
|
||||||
|
</Try>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
'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>
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo, useEffect } from 'react';
|
||||||
import { getBlockType } from './engine/BlockTypes';
|
import { getBlockType } from './engine/BlockTypes';
|
||||||
import { getModelType } from './engine/ModelTypes';
|
import { getModelType } from './engine/ModelTypes';
|
||||||
import { getPrimitiveType } from './engine/PrimitiveTypes';
|
import { getPrimitiveType } from './engine/PrimitiveTypes';
|
||||||
@ -40,8 +40,17 @@ const ItemRow = ({
|
|||||||
extraStyle,
|
extraStyle,
|
||||||
}) => {
|
}) => {
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
|
const rowRef = React.useRef(null);
|
||||||
|
// Когда строка стала выделенной — подскроллить её в видимую зону дерева
|
||||||
|
// (после авто-раскрытия веток объект может оказаться вне видимой области).
|
||||||
|
useEffect(() => {
|
||||||
|
if (selected && rowRef.current) {
|
||||||
|
rowRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}, [selected]);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={rowRef}
|
||||||
className={`item ${selected ? 'item-selected' : ''}`}
|
className={`item ${selected ? 'item-selected' : ''}`}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@ -226,13 +235,15 @@ function targetMatches(target, kind, ref) {
|
|||||||
* onAssignToFolder(kind, ref, folderId) — переместить объект/папку в папку
|
* onAssignToFolder(kind, ref, folderId) — переместить объект/папку в папку
|
||||||
*/
|
*/
|
||||||
const HierarchyPanel = ({
|
const HierarchyPanel = ({
|
||||||
blocks, models, primitives = [], folders = [],
|
blocks, models, primitives = [], userModels = [], folders = [],
|
||||||
selection,
|
selection,
|
||||||
|
onSelectUserModel, onDeleteUserModel, onRenameUserModel,
|
||||||
onSelectBlock, onSelectModel, onSelectPrimitive, onSelectSpawn, onSelectLighting,
|
onSelectBlock, onSelectModel, onSelectPrimitive, onSelectSpawn, onSelectLighting,
|
||||||
onSelectSound, onSelectPlayer, onSelectPlayerProps, onSelectFloor,
|
onSelectSound, onSelectPlayer, onSelectPlayerProps, onSelectFloor,
|
||||||
guiElements = [], onSelectGui, onCreateGui, onDeleteGui, onRenameGui, onMoveGuiZ, onSetGuiParent,
|
guiElements = [], onSelectGui, onCreateGui, onDeleteGui, onRenameGui, onMoveGuiZ, onSetGuiParent,
|
||||||
guiOverlayHidden = false, onToggleGuiOverlay,
|
guiOverlayHidden = false, onToggleGuiOverlay,
|
||||||
floorEnabled = true, onCreateFloor, onDeleteFloor,
|
floorEnabled = true, onCreateFloor, onDeleteFloor, onDeleteSpawn, spawnEnabled = true,
|
||||||
|
onSelectFolder,
|
||||||
scripts = [], onSelectScript, onCreateScript, onDeleteScript,
|
scripts = [], onSelectScript, onCreateScript, onDeleteScript,
|
||||||
onRenameModel, onRenamePrimitive, onRenameScript,
|
onRenameModel, onRenamePrimitive, onRenameScript,
|
||||||
/**
|
/**
|
||||||
@ -253,6 +264,7 @@ const HierarchyPanel = ({
|
|||||||
const [rootBlocksOpen, setRootBlocksOpen] = useState(false);
|
const [rootBlocksOpen, setRootBlocksOpen] = useState(false);
|
||||||
const [rootPrimsOpen, setRootPrimsOpen] = useState(false);
|
const [rootPrimsOpen, setRootPrimsOpen] = useState(false);
|
||||||
const [rootModelsOpen, setRootModelsOpen] = useState(false);
|
const [rootModelsOpen, setRootModelsOpen] = useState(false);
|
||||||
|
const [rootUserModelsOpen, setRootUserModelsOpen] = useState(false);
|
||||||
// Главные «service»-категории (Roblox-style). По умолчанию свёрнуты.
|
// Главные «service»-категории (Roblox-style). По умолчанию свёрнуты.
|
||||||
const [workspaceOpen, setWorkspaceOpen] = useState(false);
|
const [workspaceOpen, setWorkspaceOpen] = useState(false);
|
||||||
const [lightingOpen, setLightingOpen] = useState(false);
|
const [lightingOpen, setLightingOpen] = useState(false);
|
||||||
@ -328,11 +340,23 @@ const HierarchyPanel = ({
|
|||||||
return map;
|
return map;
|
||||||
}, [primitives]);
|
}, [primitives]);
|
||||||
|
|
||||||
|
const userModelsByFolder = useMemo(() => {
|
||||||
|
const map = new Map();
|
||||||
|
for (const um of userModels) {
|
||||||
|
const k = um.folderId ?? null;
|
||||||
|
if (!map.has(k)) map.set(k, []);
|
||||||
|
map.get(k).push(um);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [userModels]);
|
||||||
|
|
||||||
const isBlockSelected = (b) =>
|
const isBlockSelected = (b) =>
|
||||||
selection?.type === 'block' &&
|
selection?.type === 'block' &&
|
||||||
selection.gridX === b.gridX && selection.gridY === b.gridY && selection.gridZ === b.gridZ;
|
selection.gridX === b.gridX && selection.gridY === b.gridY && selection.gridZ === b.gridZ;
|
||||||
const isModelSelected = (m) =>
|
const isModelSelected = (m) =>
|
||||||
selection?.type === 'model' && selection.instanceId === m.instanceId;
|
selection?.type === 'model' && selection.instanceId === m.instanceId;
|
||||||
|
const isUserModelSelected = (um) =>
|
||||||
|
selection?.type === 'userModel' && selection.instanceId === um.instanceId;
|
||||||
const isPrimitiveSelected = (p) =>
|
const isPrimitiveSelected = (p) =>
|
||||||
selection?.type === 'primitive' && selection.id === p.id;
|
selection?.type === 'primitive' && selection.id === p.id;
|
||||||
|
|
||||||
@ -344,6 +368,82 @@ const HierarchyPanel = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// === Авто-раскрытие пути до выделенного объекта ===
|
||||||
|
// Когда объект выбирают мышкой на сцене (или из скрипта), он должен стать
|
||||||
|
// ВИДИМЫМ в дереве: раскрываем «Сцену», нужную под-группу (Блоки/Примитивы/
|
||||||
|
// Модели) и всю цепочку папок-родителей. Подсветка строки уже работает через
|
||||||
|
// проп `selection`; здесь только разворачиваем свёрнутые ветки.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selection) return;
|
||||||
|
const t = selection.type;
|
||||||
|
// Выделена ПАПКА (клик по сцене / вставка кита из тулбокса) — раскрыть
|
||||||
|
// «Сцену» и цепочку папок до неё, чтобы папка стала видна в дереве.
|
||||||
|
if (t === 'folder') {
|
||||||
|
setWorkspaceOpen(true);
|
||||||
|
setOpenFolders(prev => {
|
||||||
|
const n = new Set(prev);
|
||||||
|
let cur = selection.folderId;
|
||||||
|
const guard = new Set();
|
||||||
|
while (cur != null && !guard.has(cur)) {
|
||||||
|
guard.add(cur);
|
||||||
|
const f = folders.find(ff => ff.id === cur);
|
||||||
|
if (f && f.parentId != null) { n.add(f.parentId); cur = f.parentId; } else cur = null;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Находим объект и его folderId по выделению.
|
||||||
|
let obj = null, kind = null;
|
||||||
|
if (t === 'block') {
|
||||||
|
obj = blocks.find(b => b.gridX === selection.gridX && b.gridY === selection.gridY && b.gridZ === selection.gridZ);
|
||||||
|
kind = 'block';
|
||||||
|
} else if (t === 'primitive') {
|
||||||
|
obj = primitives.find(p => p.id === selection.id);
|
||||||
|
kind = 'primitive';
|
||||||
|
} else if (t === 'model') {
|
||||||
|
obj = models.find(m => m.instanceId === selection.instanceId);
|
||||||
|
kind = 'model';
|
||||||
|
} else if (t === 'userModel') {
|
||||||
|
obj = userModels.find(um => um.instanceId === selection.instanceId);
|
||||||
|
kind = 'userModel';
|
||||||
|
} else if (t === 'spawn' || t === 'floor') {
|
||||||
|
kind = 'workspace-only';
|
||||||
|
}
|
||||||
|
if (!kind) return; // lighting/sound/player/gui/script — свои группы, не трогаем
|
||||||
|
|
||||||
|
// 1) Раскрыть корневую категорию «Сцена».
|
||||||
|
setWorkspaceOpen(true);
|
||||||
|
|
||||||
|
if (kind === 'workspace-only') return;
|
||||||
|
|
||||||
|
const folderId = obj?.folderId ?? null;
|
||||||
|
if (folderId == null) {
|
||||||
|
// 2a) Объект в корне — раскрыть его под-группу (Блоки/Примитивы/Модели).
|
||||||
|
if (kind === 'block') setRootBlocksOpen(true);
|
||||||
|
else if (kind === 'primitive') setRootPrimsOpen(true);
|
||||||
|
else if (kind === 'model') setRootModelsOpen(true);
|
||||||
|
else if (kind === 'userModel') setRootUserModelsOpen(true);
|
||||||
|
} else {
|
||||||
|
// 2b) Объект в папке — раскрыть всю цепочку папок-родителей.
|
||||||
|
setOpenFolders(prev => {
|
||||||
|
const n = new Set(prev);
|
||||||
|
let cur = folderId;
|
||||||
|
const guard = new Set(); // защита от циклов
|
||||||
|
while (cur != null && !guard.has(cur)) {
|
||||||
|
guard.add(cur);
|
||||||
|
n.add(cur);
|
||||||
|
const f = folders.find(ff => ff.id === cur);
|
||||||
|
cur = f ? (f.parentId ?? null) : null;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Триггер — только смена выделения (не трогаем при добавлении объектов,
|
||||||
|
// чтобы не переоткрывать ветки, которые пользователь свернул вручную).
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [selection]);
|
||||||
|
|
||||||
const handleContextMenu = (e, item) => {
|
const handleContextMenu = (e, item) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@ -400,19 +500,24 @@ const HierarchyPanel = ({
|
|||||||
const subBlocks = blocksByFolder.get(folder.id) || [];
|
const subBlocks = blocksByFolder.get(folder.id) || [];
|
||||||
const subModels = modelsByFolder.get(folder.id) || [];
|
const subModels = modelsByFolder.get(folder.id) || [];
|
||||||
const subPrims = primitivesByFolder.get(folder.id) || [];
|
const subPrims = primitivesByFolder.get(folder.id) || [];
|
||||||
const totalCount = subBlocks.length + subModels.length + subPrims.length + subFolders.length;
|
const subUserModels = userModelsByFolder.get(folder.id) || [];
|
||||||
|
const totalCount = subBlocks.length + subModels.length + subPrims.length + subUserModels.length + subFolders.length;
|
||||||
|
|
||||||
|
const folderSelected = selection?.type === 'folder' && selection?.folderId === folder.id;
|
||||||
return (
|
return (
|
||||||
<div key={`folder-${folder.id}`} className={cl.folderWrap}>
|
<div key={`folder-${folder.id}`} className={cl.folderWrap}>
|
||||||
<div
|
<div
|
||||||
className={cl.folderHeader}
|
ref={folderSelected ? (el) => { if (el) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } : null}
|
||||||
|
className={`${cl.folderHeader} ${folderSelected ? cl.itemSelected : ''}`}
|
||||||
style={{ paddingLeft: depth * 12 + 8 }}
|
style={{ paddingLeft: depth * 12 + 8 }}
|
||||||
onClick={() => toggleFolder(folder.id)}
|
onClick={() => onSelectFolder?.(folder.id)}
|
||||||
onContextMenu={(e) => handleContextMenu(e, { type: 'folder', ...folder })}
|
onContextMenu={(e) => handleContextMenu(e, { type: 'folder', ...folder })}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDrop={(e) => handleDropOnFolder(e, folder.id)}
|
onDrop={(e) => handleDropOnFolder(e, folder.id)}
|
||||||
>
|
>
|
||||||
<span className={cl.chevron} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<span className={cl.chevron} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||||
|
onClick={(e) => { e.stopPropagation(); toggleFolder(folder.id); }}
|
||||||
|
>
|
||||||
<Icon name={isOpen ? 'chevronDown' : 'chevronRight'} size={10} strokeWidth={2.5} />
|
<Icon name={isOpen ? 'chevronDown' : 'chevronRight'} size={10} strokeWidth={2.5} />
|
||||||
</span>
|
</span>
|
||||||
<span className={cl.folderIcon} style={{ display: 'flex', alignItems: 'center' }}>
|
<span className={cl.folderIcon} style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
@ -452,6 +557,7 @@ const HierarchyPanel = ({
|
|||||||
{subBlocks.map(b => renderBlockItem(b, depth + 1))}
|
{subBlocks.map(b => renderBlockItem(b, depth + 1))}
|
||||||
{subPrims.map(p => renderPrimitiveItem(p, depth + 1))}
|
{subPrims.map(p => renderPrimitiveItem(p, depth + 1))}
|
||||||
{subModels.map(m => renderModelItem(m, depth + 1))}
|
{subModels.map(m => renderModelItem(m, depth + 1))}
|
||||||
|
{subUserModels.map(um => renderUserModelItem(um, depth + 1))}
|
||||||
{totalCount === 0 && (
|
{totalCount === 0 && (
|
||||||
<div className={cl.empty} style={{ paddingLeft: (depth + 1) * 12 + 8 }}>пусто</div>
|
<div className={cl.empty} style={{ paddingLeft: (depth + 1) * 12 + 8 }}>пусто</div>
|
||||||
)}
|
)}
|
||||||
@ -580,6 +686,38 @@ const HierarchyPanel = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderUserModelItem = (um, depth) => {
|
||||||
|
const displayName = um.name || 'Моя модель';
|
||||||
|
return (
|
||||||
|
<React.Fragment key={`um-${um.instanceId}`}>
|
||||||
|
<ItemRow
|
||||||
|
icon="🧊"
|
||||||
|
label={`${displayName} (${um.x.toFixed(1)}, ${um.y.toFixed(1)}, ${um.z.toFixed(1)})`}
|
||||||
|
title={`${displayName} — пользовательская модель (id: ${um.instanceId})`}
|
||||||
|
depth={depth}
|
||||||
|
selected={isUserModelSelected(um)}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => handleDragStart(e, { kind: 'userModel', id: um.instanceId })}
|
||||||
|
onClick={() => onSelectUserModel?.(um.instanceId)}
|
||||||
|
onDoubleClick={() => { onSelectUserModel?.(um.instanceId); onFocusSelection?.(); }}
|
||||||
|
onContextMenu={(e) => handleContextMenu(e, { type: 'userModel', ...um })}
|
||||||
|
plusItems={[
|
||||||
|
{
|
||||||
|
id: 'add-script', label: 'Скрипт', icon: '📜',
|
||||||
|
onClick: () => onCreateScript?.({ kind: 'userModel', id: um.instanceId }),
|
||||||
|
},
|
||||||
|
{ divider: true },
|
||||||
|
{
|
||||||
|
id: 'delete', label: 'Удалить', icon: '🗑', danger: true,
|
||||||
|
onClick: () => onDeleteUserModel?.(um.instanceId),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{renderNestedScriptsFor('userModel', um.instanceId, depth)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const renderPrimitiveItem = (p, depth) => {
|
const renderPrimitiveItem = (p, depth) => {
|
||||||
const def = getPrimitiveType(p.type);
|
const def = getPrimitiveType(p.type);
|
||||||
const displayName = p.name || def?.name || p.type;
|
const displayName = p.name || def?.name || p.type;
|
||||||
@ -633,6 +771,7 @@ const HierarchyPanel = ({
|
|||||||
const rootFolders = foldersByParent.get(null) || [];
|
const rootFolders = foldersByParent.get(null) || [];
|
||||||
const rootBlocks = blocksByFolder.get(null) || [];
|
const rootBlocks = blocksByFolder.get(null) || [];
|
||||||
const rootModels = modelsByFolder.get(null) || [];
|
const rootModels = modelsByFolder.get(null) || [];
|
||||||
|
const rootUserModels = userModelsByFolder.get(null) || [];
|
||||||
const rootPrims = primitivesByFolder.get(null) || [];
|
const rootPrims = primitivesByFolder.get(null) || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -664,16 +803,19 @@ const HierarchyPanel = ({
|
|||||||
/>
|
/>
|
||||||
{workspaceOpen && (
|
{workspaceOpen && (
|
||||||
<div style={{ paddingLeft: 8 }}>
|
<div style={{ paddingLeft: 8 }}>
|
||||||
{/* Точка спавна — кликабельная */}
|
{/* Точка спавна — кликабельная. Скрыта если удалена. */}
|
||||||
|
{spawnEnabled !== false && (
|
||||||
<div
|
<div
|
||||||
className={`${cl.item} ${selection?.type === 'spawn' ? cl.itemSelected : ''}`}
|
className={`${cl.item} ${selection?.type === 'spawn' ? cl.itemSelected : ''}`}
|
||||||
onClick={() => onSelectSpawn?.()}
|
onClick={() => onSelectSpawn?.()}
|
||||||
onDoubleClick={() => { onSelectSpawn?.(); onFocusSelection?.(); }}
|
onDoubleClick={() => { onSelectSpawn?.(); onFocusSelection?.(); }}
|
||||||
title="Точка спавна игрока"
|
onContextMenu={(e) => { onSelectSpawn?.(); handleContextMenu(e, { type: 'spawn' }); }}
|
||||||
|
title="Точка спавна игрока (ПКМ — меню, Delete — удалить)"
|
||||||
>
|
>
|
||||||
<span className={cl.itemIcon} style={{ display: 'inline-flex' }}><Icon name="flag" size={14} /></span>
|
<span className={cl.itemIcon} style={{ display: 'inline-flex' }}><Icon name="flag" size={14} /></span>
|
||||||
<span className={cl.itemLabel}>Точка спавна</span>
|
<span className={cl.itemLabel}>Точка спавна</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Пол — псевдо-объект, если включён */}
|
{/* Пол — псевдо-объект, если включён */}
|
||||||
{floorEnabled && (
|
{floorEnabled && (
|
||||||
@ -757,6 +899,24 @@ const HierarchyPanel = ({
|
|||||||
{rootModelsOpen && rootModels.map(m => renderModelItem(m, 0))}
|
{rootModelsOpen && rootModels.map(m => renderModelItem(m, 0))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Мои модели (воксельный редактор) в корне */}
|
||||||
|
{rootUserModels.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={cl.groupHeader}
|
||||||
|
onClick={() => setRootUserModelsOpen(!rootUserModelsOpen)}
|
||||||
|
>
|
||||||
|
<span className={cl.chevron} style={{ display: 'inline-flex', alignItems: 'center' }}>
|
||||||
|
<Icon name={rootUserModelsOpen ? 'chevronDown' : 'chevronRight'} size={10} strokeWidth={2.5} />
|
||||||
|
</span>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Icon name="cube" size={13} /> Мои модели ({rootUserModels.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{rootUserModelsOpen && rootUserModels.map(um => renderUserModelItem(um, 0))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -1014,7 +1174,12 @@ const HierarchyPanel = ({
|
|||||||
{/* === 📜 СКРИПТЫ === — только глобальные (без target).
|
{/* === 📜 СКРИПТЫ === — только глобальные (без target).
|
||||||
Скрипты с target отображаются под объектом-носителем. */}
|
Скрипты с target отображаются под объектом-носителем. */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const globalScripts = scripts.filter(s => !s.target);
|
// Глобальные скрипты: без target ИЛИ target==='game' (строка).
|
||||||
|
// Раньше фильтр был `!s.target` → скрипты с target:'game'
|
||||||
|
// (главный скрипт игры) НЕ показывались в дереве и их нельзя
|
||||||
|
// было удалить, хотя в Play они исполнялись.
|
||||||
|
const isGlobalTarget = (t) => !t || t === 'game';
|
||||||
|
const globalScripts = scripts.filter(s => isGlobalTarget(s.target));
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<GroupRow
|
<GroupRow
|
||||||
@ -1173,6 +1338,21 @@ const HierarchyPanel = ({
|
|||||||
<Icon name="delete" size={13} /> Удалить пол
|
<Icon name="delete" size={13} /> Удалить пол
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
) : contextMenu.item.type === 'spawn' ? (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={cl.contextItem}
|
||||||
|
onClick={() => { onSelectSpawn?.(); onFocusSelection?.(); closeContext(); }}
|
||||||
|
>
|
||||||
|
<Icon name="target" size={13} /> Навести камеру
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`${cl.contextItem} ${cl.contextDanger}`}
|
||||||
|
onClick={() => { onDeleteSpawn?.(); closeContext(); }}
|
||||||
|
>
|
||||||
|
<Icon name="delete" size={13} /> Удалить точку спавна
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
) : contextMenu.item.type === 'script' ? (
|
) : contextMenu.item.type === 'script' ? (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -4,11 +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 { 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';
|
||||||
@ -463,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);
|
||||||
@ -605,6 +618,7 @@ const KubikonEditor = () => {
|
|||||||
const [crosshair, setCrosshairUI] = useState('none');
|
const [crosshair, setCrosshairUI] = useState('none');
|
||||||
// Видимость пола в иерархии
|
// Видимость пола в иерархии
|
||||||
const [floorEnabled, setFloorEnabledUI] = useState(true);
|
const [floorEnabled, setFloorEnabledUI] = useState(true);
|
||||||
|
const [spawnEnabledUI, setSpawnEnabledUI] = useState(true);
|
||||||
// Табы над viewport (Roblox-style): «🎬 Сцена» + открытые скрипты
|
// Табы над viewport (Roblox-style): «🎬 Сцена» + открытые скрипты
|
||||||
const [openTabs, setOpenTabs] = useState([{ id: 'scene', kind: 'scene', title: 'Сцена' }]);
|
const [openTabs, setOpenTabs] = useState([{ id: 'scene', kind: 'scene', title: 'Сцена' }]);
|
||||||
const [activeTabId, setActiveTabId] = useState('scene');
|
const [activeTabId, setActiveTabId] = useState('scene');
|
||||||
@ -768,6 +782,93 @@ const KubikonEditor = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Задача 17: вставить готовую механику (kit) из Тулбокса в проект.
|
||||||
|
// prims[] → создаём примитивы перед камерой; on-target скрипт → привязываем
|
||||||
|
// к первому созданному примитиву; global скрипт → добавляем как скрипт игры.
|
||||||
|
const insertGameplayKit = useCallback((kitId) => {
|
||||||
|
const kit = getKit(kitId);
|
||||||
|
const s = sceneRef.current;
|
||||||
|
if (!kit || !s) return;
|
||||||
|
// Точка вставки — на ТВЁРДОЙ поверхности под центром экрана (пол/объект),
|
||||||
|
// чтобы предмет встал на землю в фокусе камеры, а не висел под камерой.
|
||||||
|
let px = 0, pz = 0, py = 0;
|
||||||
|
try {
|
||||||
|
const gp = s.getPlacementPointAtCenter?.();
|
||||||
|
if (gp) { px = gp.x; pz = gp.z; py = gp.y; }
|
||||||
|
else {
|
||||||
|
const cam = s.camera;
|
||||||
|
const fwd = cam?.getForwardRay ? cam.getForwardRay().direction : null;
|
||||||
|
if (cam && fwd) { px = cam.position.x + fwd.x * 6; pz = cam.position.z + fwd.z * 6; }
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
|
||||||
|
// 1) Создаём примитивы кита. Запоминаем все id (первый — для on-target скрипта).
|
||||||
|
let firstPrimId = null;
|
||||||
|
const createdIds = [];
|
||||||
|
if (Array.isArray(kit.prims)) {
|
||||||
|
for (const p of kit.prims) {
|
||||||
|
const newId = s.primitiveManager?.addInstance(p.type || 'cube', {
|
||||||
|
x: px + (p.x || 0), y: py + (p.y != null ? p.y : 1), z: pz + (p.z || 0),
|
||||||
|
sx: p.sx, sy: p.sy, sz: p.sz,
|
||||||
|
color: p.color, material: p.material,
|
||||||
|
canCollide: p.canCollide !== false, visible: p.visible !== false, anchored: true,
|
||||||
|
name: p.name,
|
||||||
|
});
|
||||||
|
if (newId != null) {
|
||||||
|
createdIds.push(newId);
|
||||||
|
if (firstPrimId == null) firstPrimId = newId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Если кит состоит из НЕСКОЛЬКИХ частей — кладём их в общую папку
|
||||||
|
// (объекты из нескольких частей всегда сгруппированы).
|
||||||
|
let kitFolderId = null;
|
||||||
|
if (createdIds.length > 1 && s.folderManager) {
|
||||||
|
kitFolderId = s.folderManager.createFolder(kit.name);
|
||||||
|
for (const pid of createdIds) {
|
||||||
|
s.folderManager.assignToFolder('primitive', pid, kitFolderId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Добавляем скрипты кита — с понятным именем (название кита).
|
||||||
|
if (Array.isArray(kit.scripts)) {
|
||||||
|
kit.scripts.forEach((sc, idx) => {
|
||||||
|
const sid = `script_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
||||||
|
// Имя = название кита (+ номер, если скриптов несколько).
|
||||||
|
const nm = kit.scripts.length > 1 ? `${kit.name} (${idx + 1})` : kit.name;
|
||||||
|
if (sc.attachTo === 'on-target' && firstPrimId != null) {
|
||||||
|
s.upsertScript(sid, sc.code, { kind: 'primitive', id: firstPrimId }, nm);
|
||||||
|
} else {
|
||||||
|
s.upsertScript(sid, sc.code, null, nm); // глобальный
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
markDirty();
|
||||||
|
hierarchyDirtyRef.current = true; // пересобрать дерево (примитивы с folderId)
|
||||||
|
setScriptsList(s.getScripts?.() || []);
|
||||||
|
if (s.folderManager) setFoldersList(s.folderManager.getAll());
|
||||||
|
// Выделим созданное и наведём камеру (видно, куда добавилось).
|
||||||
|
try {
|
||||||
|
setActiveTool('select');
|
||||||
|
if (kitFolderId != null) {
|
||||||
|
s.selection?.selectFolder?.(kitFolderId); // группа из нескольких частей
|
||||||
|
setGizmoMode('move');
|
||||||
|
} else if (firstPrimId != null) {
|
||||||
|
s.selection?.selectPrimitiveById(firstPrimId);
|
||||||
|
}
|
||||||
|
s.focusOnSelection?.();
|
||||||
|
} catch (e) {}
|
||||||
|
// Тост-уведомление (showToast будет подключён позже — заглушка,
|
||||||
|
// чтобы не падал eslint no-undef и CI оставался зелёным).
|
||||||
|
try {
|
||||||
|
if (typeof window !== 'undefined' && typeof window.showToast === 'function') {
|
||||||
|
window.showToast(`Механика «${kit.name}» добавлена`);
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [paletteTab, setPaletteTab] = useState('blocks'); // 'blocks' | 'models' | 'primitives'
|
const [paletteTab, setPaletteTab] = useState('blocks'); // 'blocks' | 'models' | 'primitives'
|
||||||
const [blockCount, setBlockCount] = useState(0);
|
const [blockCount, setBlockCount] = useState(0);
|
||||||
const [modelCount, setModelCount] = useState(0);
|
const [modelCount, setModelCount] = useState(0);
|
||||||
@ -776,6 +877,7 @@ const KubikonEditor = () => {
|
|||||||
const [selection, setSelection] = useState(null);
|
const [selection, setSelection] = useState(null);
|
||||||
const [blocksList, setBlocksList] = useState([]);
|
const [blocksList, setBlocksList] = useState([]);
|
||||||
const [modelsList, setModelsList] = useState([]);
|
const [modelsList, setModelsList] = useState([]);
|
||||||
|
const [userModelsList, setUserModelsList] = useState([]);
|
||||||
const [primitivesList, setPrimitivesList] = useState([]);
|
const [primitivesList, setPrimitivesList] = useState([]);
|
||||||
const [foldersList, setFoldersList] = useState([]);
|
const [foldersList, setFoldersList] = useState([]);
|
||||||
|
|
||||||
@ -920,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');
|
||||||
@ -1139,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 ещё грузится), компонент
|
||||||
@ -1293,6 +1505,8 @@ const KubikonEditor = () => {
|
|||||||
markDirty();
|
markDirty();
|
||||||
// Иерархия изменилась — interval пересоберёт списки на след. тике.
|
// Иерархия изменилась — interval пересоберёт списки на след. тике.
|
||||||
hierarchyDirtyRef.current = true;
|
hierarchyDirtyRef.current = true;
|
||||||
|
// Синк флага точки спавна (например после Delete-клавиши).
|
||||||
|
try { setSpawnEnabledUI(scene.hasSpawn?.() !== false); } catch (e) {}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Этап 5: подключаем API пользовательских моделей в BabylonScene,
|
// Этап 5: подключаем API пользовательских моделей в BabylonScene,
|
||||||
@ -1497,6 +1711,7 @@ const KubikonEditor = () => {
|
|||||||
const ch = sceneRef.current.getCrosshair?.();
|
const ch = sceneRef.current.getCrosshair?.();
|
||||||
if (ch) setCrosshairUI(ch);
|
if (ch) setCrosshairUI(ch);
|
||||||
setFloorEnabledUI(sceneRef.current.isFloorEnabled?.() !== false);
|
setFloorEnabledUI(sceneRef.current.isFloorEnabled?.() !== false);
|
||||||
|
setSpawnEnabledUI(sceneRef.current.hasSpawn?.() !== false);
|
||||||
const a = sceneRef.current.getAudioState?.();
|
const a = sceneRef.current.getAudioState?.();
|
||||||
if (a?.ambientId) setAmbientIdUI(a.ambientId);
|
if (a?.ambientId) setAmbientIdUI(a.ambientId);
|
||||||
if (a?.musicId) setMusicIdUI(a.musicId);
|
if (a?.musicId) setMusicIdUI(a.musicId);
|
||||||
@ -1515,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 {
|
||||||
@ -1565,6 +1783,21 @@ const KubikonEditor = () => {
|
|||||||
}
|
}
|
||||||
setModelsList(arr);
|
setModelsList(arr);
|
||||||
}
|
}
|
||||||
|
if (s.userModelManager) {
|
||||||
|
const arr = [];
|
||||||
|
for (const data of s.userModelManager.instances.values()) {
|
||||||
|
arr.push({
|
||||||
|
instanceId: data.instanceId,
|
||||||
|
userModelTypeId: data.userModelTypeId,
|
||||||
|
userModelId: data.userModelId,
|
||||||
|
x: data.x, y: data.y, z: data.z,
|
||||||
|
rotationY: data.rotationY,
|
||||||
|
folderId: data.folderId ?? null,
|
||||||
|
name: data.name || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setUserModelsList(arr);
|
||||||
|
}
|
||||||
if (s.primitiveManager) {
|
if (s.primitiveManager) {
|
||||||
// getAll() не включает folderId — добавляем вручную
|
// getAll() не включает folderId — добавляем вручную
|
||||||
const arr = s.primitiveManager.getAll();
|
const arr = s.primitiveManager.getAll();
|
||||||
@ -1616,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 сек, поэтому риск
|
||||||
@ -1840,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)}
|
||||||
@ -1848,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}
|
||||||
@ -1895,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()}
|
||||||
@ -2036,7 +2287,12 @@ const KubikonEditor = () => {
|
|||||||
onPlayToggle={handlePlay}
|
onPlayToggle={handlePlay}
|
||||||
onSetSpawn={() => {
|
onSetSpawn={() => {
|
||||||
sceneRef.current?.setSpawnAtCamera();
|
sceneRef.current?.setSpawnAtCamera();
|
||||||
|
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()}
|
||||||
@ -2674,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,
|
||||||
@ -3020,6 +3277,11 @@ const KubikonEditor = () => {
|
|||||||
logs={scriptLogs}
|
logs={scriptLogs}
|
||||||
onClear={() => setScriptLogs([])}
|
onClear={() => setScriptLogs([])}
|
||||||
onClose={() => setConsoleOpen(false)}
|
onClose={() => setConsoleOpen(false)}
|
||||||
|
onOpenScript={(scriptId) => {
|
||||||
|
// Открыть скрипт-источник ошибки в редакторе.
|
||||||
|
try { sceneRef.current?.selection?.selectScript?.(scriptId); } catch (e) {}
|
||||||
|
openScriptTab(scriptId);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Monaco-редактор скрипта (этап 2.2). Активен когда выбран таб со скриптом. */}
|
{/* Monaco-редактор скрипта (этап 2.2). Активен когда выбран таб со скриптом. */}
|
||||||
@ -3064,9 +3326,21 @@ const KubikonEditor = () => {
|
|||||||
<HierarchyPanel
|
<HierarchyPanel
|
||||||
blocks={blocksList}
|
blocks={blocksList}
|
||||||
models={modelsList}
|
models={modelsList}
|
||||||
|
userModels={userModelsList}
|
||||||
primitives={primitivesList}
|
primitives={primitivesList}
|
||||||
folders={foldersList}
|
folders={foldersList}
|
||||||
scripts={scriptsList}
|
scripts={scriptsList}
|
||||||
|
onSelectUserModel={(id) => {
|
||||||
|
sceneRef.current?.selection?.selectUserModelByInstanceId(id);
|
||||||
|
setActiveTool('select');
|
||||||
|
}}
|
||||||
|
onDeleteUserModel={(id) => {
|
||||||
|
sceneRef.current?.userModelManager?.removeInstance(id);
|
||||||
|
sceneRef.current?.clearSelection();
|
||||||
|
}}
|
||||||
|
onRenameUserModel={(id, name) => {
|
||||||
|
if (sceneRef.current?.renameUserModel?.(id, name)) markDirty();
|
||||||
|
}}
|
||||||
onSelectScript={(scriptId) => {
|
onSelectScript={(scriptId) => {
|
||||||
sceneRef.current?.selection?.selectScript?.(scriptId);
|
sceneRef.current?.selection?.selectScript?.(scriptId);
|
||||||
setActiveTool('select');
|
setActiveTool('select');
|
||||||
@ -3080,12 +3354,17 @@ const KubikonEditor = () => {
|
|||||||
if (target) {
|
if (target) {
|
||||||
if (target.kind === 'block') {
|
if (target.kind === 'block') {
|
||||||
normalized = { kind: 'block', ref: { x: target.x, y: target.y, z: target.z } };
|
normalized = { kind: 'block', ref: { x: target.x, y: target.y, z: target.z } };
|
||||||
} else if (target.kind === 'model' || target.kind === 'primitive') {
|
} else if (target.kind === 'model' || target.kind === 'primitive' || target.kind === 'userModel') {
|
||||||
normalized = { kind: target.kind, id: target.id };
|
normalized = { kind: target.kind, id: target.id };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const tpl = normalized ? NEW_OBJECT_SCRIPT_TEMPLATE : NEW_SCRIPT_TEMPLATE;
|
const tpl = normalized ? NEW_OBJECT_SCRIPT_TEMPLATE : NEW_SCRIPT_TEMPLATE;
|
||||||
sceneRef.current?.upsertScript(id, tpl, normalized);
|
// Понятное имя по умолчанию (а не сырой id).
|
||||||
|
const existing = sceneRef.current?.getScripts?.() || [];
|
||||||
|
const nm = normalized
|
||||||
|
? `Скрипт объекта ${existing.filter(s => s.target && s.target !== 'game').length + 1}`
|
||||||
|
: `Скрипт ${existing.filter(s => !s.target || s.target === 'game').length + 1}`;
|
||||||
|
sceneRef.current?.upsertScript(id, tpl, normalized, nm);
|
||||||
markDirty();
|
markDirty();
|
||||||
setScriptsList(sceneRef.current?.getScripts?.() || []);
|
setScriptsList(sceneRef.current?.getScripts?.() || []);
|
||||||
sceneRef.current?.selection?.selectScript?.(id);
|
sceneRef.current?.selection?.selectScript?.(id);
|
||||||
@ -3152,6 +3431,7 @@ const KubikonEditor = () => {
|
|||||||
setActiveTool('select');
|
setActiveTool('select');
|
||||||
}}
|
}}
|
||||||
floorEnabled={floorEnabled}
|
floorEnabled={floorEnabled}
|
||||||
|
spawnEnabled={spawnEnabledUI}
|
||||||
onSelectFloor={() => {
|
onSelectFloor={() => {
|
||||||
sceneRef.current?.selection?.selectFloor?.();
|
sceneRef.current?.selection?.selectFloor?.();
|
||||||
setActiveTool('select');
|
setActiveTool('select');
|
||||||
@ -3216,6 +3496,18 @@ const KubikonEditor = () => {
|
|||||||
// Активируем гизмо «Двигать» чтобы можно было сразу таскать
|
// Активируем гизмо «Двигать» чтобы можно было сразу таскать
|
||||||
setGizmoMode('move');
|
setGizmoMode('move');
|
||||||
}}
|
}}
|
||||||
|
onDeleteSpawn={() => {
|
||||||
|
sceneRef.current?.deleteSpawn?.();
|
||||||
|
sceneRef.current?.clearSelection?.();
|
||||||
|
setSpawnEnabledUI(false);
|
||||||
|
markDirty();
|
||||||
|
}}
|
||||||
|
onSelectFolder={(folderId) => {
|
||||||
|
sceneRef.current?.selection?.selectFolder?.(folderId);
|
||||||
|
setActiveTool('select');
|
||||||
|
// Активируем gizmo «Двигать» чтобы сразу таскать всю группу.
|
||||||
|
setGizmoMode('move');
|
||||||
|
}}
|
||||||
onSelectLighting={() => {
|
onSelectLighting={() => {
|
||||||
sceneRef.current?.selection?.selectLighting();
|
sceneRef.current?.selection?.selectLighting();
|
||||||
setActiveTool('select');
|
setActiveTool('select');
|
||||||
@ -3223,14 +3515,24 @@ const KubikonEditor = () => {
|
|||||||
onDeleteBlock={(x, y, z) => {
|
onDeleteBlock={(x, y, z) => {
|
||||||
sceneRef.current?.blockManager?.removeBlock(x, y, z);
|
sceneRef.current?.blockManager?.removeBlock(x, y, z);
|
||||||
sceneRef.current?.clearSelection();
|
sceneRef.current?.clearSelection();
|
||||||
|
markDirty();
|
||||||
|
hierarchyDirtyRef.current = true;
|
||||||
}}
|
}}
|
||||||
onDeleteModel={(id) => {
|
onDeleteModel={(id) => {
|
||||||
sceneRef.current?.modelManager?.removeInstance(id);
|
sceneRef.current?.modelManager?.removeInstance(id);
|
||||||
|
sceneRef.current?._cleanupOrphanScripts?.();
|
||||||
sceneRef.current?.clearSelection();
|
sceneRef.current?.clearSelection();
|
||||||
|
setScriptsList(sceneRef.current?.getScripts?.() || []);
|
||||||
|
markDirty();
|
||||||
|
hierarchyDirtyRef.current = true;
|
||||||
}}
|
}}
|
||||||
onDeletePrimitive={(id) => {
|
onDeletePrimitive={(id) => {
|
||||||
sceneRef.current?.primitiveManager?.removeInstance(id);
|
sceneRef.current?.primitiveManager?.removeInstance(id);
|
||||||
|
sceneRef.current?._cleanupOrphanScripts?.();
|
||||||
sceneRef.current?.clearSelection();
|
sceneRef.current?.clearSelection();
|
||||||
|
setScriptsList(sceneRef.current?.getScripts?.() || []);
|
||||||
|
markDirty();
|
||||||
|
hierarchyDirtyRef.current = true;
|
||||||
}}
|
}}
|
||||||
onFocusSelection={() => sceneRef.current?.focusOnSelection()}
|
onFocusSelection={() => sceneRef.current?.focusOnSelection()}
|
||||||
onCreateFolder={(name, parentId) =>
|
onCreateFolder={(name, parentId) =>
|
||||||
@ -3814,6 +4116,12 @@ const KubikonEditor = () => {
|
|||||||
}}
|
}}
|
||||||
onClose={() => setToolboxOpen(false)}
|
onClose={() => setToolboxOpen(false)}
|
||||||
onPick={(id, userModelObj = null) => {
|
onPick={(id, userModelObj = null) => {
|
||||||
|
// Задача 17: готовая механика из Тулбокса (kit:<id>).
|
||||||
|
// Вставляем её скрипты/примитивы в проект одним кликом.
|
||||||
|
if (typeof id === 'string' && id.startsWith('kit:')) {
|
||||||
|
insertGameplayKit(id.slice(4));
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Пользовательские модели имеют префикс 'user:<id>' и
|
// Пользовательские модели имеют префикс 'user:<id>' и
|
||||||
// обрабатываются в BabylonScene через UserModelManager
|
// обрабатываются в BabylonScene через UserModelManager
|
||||||
// (Этап 5). Активный тип модели работает одинаково.
|
// (Этап 5). Активный тип модели работает одинаково.
|
||||||
@ -3852,4 +4160,34 @@ const KubikonEditor = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Team Create: слать соавторам точку под мышью на сцене (raycast по pointermove).
|
||||||
|
* Throttle уже внутри collab.sendCursor. Также шлём позицию камеры.
|
||||||
|
*/
|
||||||
|
function _wireCursorTracking(scene, collab) {
|
||||||
|
try {
|
||||||
|
const canvas = scene.canvas;
|
||||||
|
if (!canvas) return;
|
||||||
|
const onMove = () => {
|
||||||
|
const bScene = scene.scene;
|
||||||
|
if (!bScene) return;
|
||||||
|
try {
|
||||||
|
const pick = bScene.pick(bScene.pointerX, bScene.pointerY);
|
||||||
|
if (pick && pick.hit && pick.pickedPoint) {
|
||||||
|
collab.sendCursor(pick.pickedPoint.x, pick.pickedPoint.y, pick.pickedPoint.z);
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
// камера (throttle внутри)
|
||||||
|
try {
|
||||||
|
const c = scene.camera;
|
||||||
|
if (c && c.position) collab.sendCamera(c.position.x, c.position.y, c.position.z);
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
};
|
||||||
|
canvas.addEventListener('pointermove', onMove);
|
||||||
|
// сохраним для снятия (необязательно — canvas живёт с редактором)
|
||||||
|
collab.__cursorHandler = onMove;
|
||||||
|
collab.__cursorCanvas = canvas;
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
export default KubikonEditor;
|
export default KubikonEditor;
|
||||||
|
|||||||
@ -25,7 +25,7 @@ const LEVEL_BG = {
|
|||||||
warn: 'rgba(245, 158, 11, 0.12)',
|
warn: 'rgba(245, 158, 11, 0.12)',
|
||||||
};
|
};
|
||||||
|
|
||||||
const ScriptConsole = ({ logs = [], onClear, onClose, visible }) => {
|
const ScriptConsole = ({ logs = [], onClear, onClose, visible, onOpenScript }) => {
|
||||||
const listRef = useRef(null);
|
const listRef = useRef(null);
|
||||||
const [copyState, setCopyState] = useState('idle');
|
const [copyState, setCopyState] = useState('idle');
|
||||||
|
|
||||||
@ -260,7 +260,9 @@ const ScriptConsole = ({ logs = [], onClear, onClose, visible }) => {
|
|||||||
? `3px solid ${LEVEL_COLORS[l.level]}`
|
? `3px solid ${LEVEL_COLORS[l.level]}`
|
||||||
: '3px solid transparent',
|
: '3px solid transparent',
|
||||||
paddingLeft: 8,
|
paddingLeft: 8,
|
||||||
|
display: 'flex', alignItems: 'flex-start', gap: 8,
|
||||||
}}>
|
}}>
|
||||||
|
<span style={{ flex: 1, minWidth: 0 }}>
|
||||||
<span style={{
|
<span style={{
|
||||||
color: '#94a3b8', marginRight: 10, fontWeight: 700,
|
color: '#94a3b8', marginRight: 10, fontWeight: 700,
|
||||||
}}>
|
}}>
|
||||||
@ -270,6 +272,26 @@ const ScriptConsole = ({ logs = [], onClear, onClose, visible }) => {
|
|||||||
{l.level === 'warn' && <span><Icon name="warning" size={14} /></span>}
|
{l.level === 'warn' && <span><Icon name="warning" size={14} /></span>}
|
||||||
{l.level === 'info' && <span style={{ opacity: 0.7 }}>● </span>}
|
{l.level === 'info' && <span style={{ opacity: 0.7 }}>● </span>}
|
||||||
{l.text}
|
{l.text}
|
||||||
|
</span>
|
||||||
|
{/* Ссылка на скрипт-источник (клик открывает его). */}
|
||||||
|
{l.scriptId && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenScript?.(l.scriptId)}
|
||||||
|
title={'Открыть скрипт: ' + (l.scriptName || l.scriptId)}
|
||||||
|
style={{
|
||||||
|
flex: '0 0 auto', maxWidth: 160,
|
||||||
|
background: 'rgba(79,116,255,0.16)',
|
||||||
|
border: '1px solid rgba(79,116,255,0.3)',
|
||||||
|
color: '#8aa0ff', borderRadius: 6,
|
||||||
|
padding: '1px 8px', fontSize: 11, fontWeight: 700,
|
||||||
|
cursor: 'pointer', whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden', textOverflow: 'ellipsis',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📄 {l.scriptName || l.scriptId}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react';
|
||||||
import { MODEL_TYPES, MODEL_CATEGORIES } from './engine/ModelTypes';
|
import { MODEL_TYPES, MODEL_CATEGORIES } from './engine/ModelTypes';
|
||||||
|
import { GAMEPLAY_KITS, KIT_CATEGORIES } from './engine/GameplayKits';
|
||||||
import { getModelThumbnail, cancelThumbnailRequest } from './engine/ModelThumbnails';
|
import { getModelThumbnail, cancelThumbnailRequest } from './engine/ModelThumbnails';
|
||||||
import {
|
import {
|
||||||
getMyUserModels, getPublicUserModels, likeUserModel,
|
getMyUserModels, getPublicUserModels, likeUserModel,
|
||||||
@ -281,10 +282,18 @@ const ToolboxModal = ({
|
|||||||
initialSection = 'standard',
|
initialSection = 'standard',
|
||||||
}) => {
|
}) => {
|
||||||
// Корневой раздел: 'standard' | 'mine' | 'community'
|
// Корневой раздел: 'standard' | 'mine' | 'community'
|
||||||
|
// === Roblox-style Toolbox (задача 17) ===
|
||||||
|
// Верхняя вкладка: 'store' | 'inventory' | 'recent' | 'tips'.
|
||||||
|
const [view, setView] = useState('store');
|
||||||
|
// Выбранная категория магазина (null = главный экран с 6 плитками):
|
||||||
|
// '3d' | 'fx' | '2d' | 'gameplay' | 'plugins' | 'audio'.
|
||||||
|
const [storeCat, setStoreCat] = useState(null);
|
||||||
|
|
||||||
const [section, setSection] = useState('standard');
|
const [section, setSection] = useState('standard');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [category, setCategory] = useState('all'); // для 'standard'
|
const [category, setCategory] = useState('all'); // для 'standard'
|
||||||
const [userKind, setUserKind] = useState('all'); // для 'mine'/'community': all|voxel|smooth
|
const [userKind, setUserKind] = useState('all'); // для 'mine'/'community': all|voxel|smooth
|
||||||
|
const [kitCat, setKitCat] = useState('all'); // для 'gameplay': категория кита
|
||||||
|
|
||||||
// Загруженные модели для 'mine' и 'community'
|
// Загруженные модели для 'mine' и 'community'
|
||||||
const [myModels, setMyModels] = useState(null); // null = ещё не загружено
|
const [myModels, setMyModels] = useState(null); // null = ещё не загружено
|
||||||
@ -295,22 +304,43 @@ const ToolboxModal = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setSearch('');
|
setSearch('');
|
||||||
|
// initialSection маппится в новую структуру: mine → inventory.
|
||||||
|
if (initialSection === 'mine') { setView('inventory'); setStoreCat(null); }
|
||||||
|
else { setView('store'); setStoreCat(null); }
|
||||||
setSection(initialSection || 'standard');
|
setSection(initialSection || 'standard');
|
||||||
setCategory('all');
|
setCategory('all');
|
||||||
setUserKind('all');
|
setUserKind('all');
|
||||||
|
setKitCat('all');
|
||||||
setMyModels(null);
|
setMyModels(null);
|
||||||
setCommunityModels(null);
|
setCommunityModels(null);
|
||||||
setLoadError('');
|
setLoadError('');
|
||||||
}
|
}
|
||||||
}, [open, initialSection]);
|
}, [open, initialSection]);
|
||||||
|
|
||||||
// Esc — закрыть
|
// Маппинг категории магазина → внутренний section (для lazy-load моделей).
|
||||||
|
const STORE_CAT_TO_SECTION = { '3d': 'standard', gameplay: 'gameplay', '2d': 'standard' };
|
||||||
|
const openStoreCategory = useCallback((catId) => {
|
||||||
|
setStoreCat(catId);
|
||||||
|
setSearch('');
|
||||||
|
setCategory('all');
|
||||||
|
setKitCat('all');
|
||||||
|
const sec = STORE_CAT_TO_SECTION[catId];
|
||||||
|
if (sec) setSection(sec);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Синхронизация верхней вкладки → внутренний section (для lazy-load).
|
||||||
|
useEffect(() => {
|
||||||
|
if (view === 'inventory') setSection('mine');
|
||||||
|
else if (view === 'recent') setSection('community');
|
||||||
|
}, [view]);
|
||||||
|
|
||||||
|
// Esc — закрыть (если открыта категория магазина — сначала назад к плиткам)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
|
const onKey = (e) => { if (e.key === 'Escape') { if (view === 'store' && storeCat) setStoreCat(null); else onClose(); } };
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
return () => window.removeEventListener('keydown', onKey);
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
}, [open, onClose]);
|
}, [open, onClose, view, storeCat]);
|
||||||
|
|
||||||
// Lazy-load моих моделей при переключении на 'mine'
|
// Lazy-load моих моделей при переключении на 'mine'
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -413,6 +443,16 @@ const ToolboxModal = ({
|
|||||||
[communityModels, filterUserModels]
|
[communityModels, filterUserModels]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// === Готовые механики (gameplay-киты) — фильтр по категории + search ===
|
||||||
|
const kitsFiltered = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
let arr = GAMEPLAY_KITS;
|
||||||
|
if (kitCat !== 'all') arr = arr.filter(k => k.category === kitCat);
|
||||||
|
if (q) arr = arr.filter(k =>
|
||||||
|
k.name.toLowerCase().includes(q) || k.desc.toLowerCase().includes(q));
|
||||||
|
return arr;
|
||||||
|
}, [search, kitCat]);
|
||||||
|
|
||||||
// Активный счётчик для шапки
|
// Активный счётчик для шапки
|
||||||
const visibleCount = section === 'standard'
|
const visibleCount = section === 'standard'
|
||||||
? standardFiltered.length
|
? standardFiltered.length
|
||||||
@ -425,6 +465,27 @@ const ToolboxModal = ({
|
|||||||
? (myModels?.length || 0)
|
? (myModels?.length || 0)
|
||||||
: (communityModels?.length || 0);
|
: (communityModels?.length || 0);
|
||||||
|
|
||||||
|
// 6 категорий магазина (как в Roblox Creator Store).
|
||||||
|
const STORE_CATEGORIES = [
|
||||||
|
{ id: '3d', label: '3D-объекты', icon: 'cube', desc: '700+ моделей: природа, дома, мебель, NPC' },
|
||||||
|
{ id: 'fx', label: 'Эффекты', icon: 'sparkles', desc: 'Частицы, лучи, маркеры' },
|
||||||
|
{ id: '2d', label: '2D-картинки', icon: 'image', desc: 'Иконки и текстуры для интерфейса' },
|
||||||
|
{ id: 'gameplay', label: 'Готовые механики', icon: 'zap', desc: `${GAMEPLAY_KITS.length} механик: вставил — работает` },
|
||||||
|
{ id: 'plugins', label: 'Плагины', icon: 'puzzle', desc: 'Расширения студии' },
|
||||||
|
{ id: 'audio', label: 'Аудио', icon: 'sound', desc: 'Звуки и музыка' },
|
||||||
|
];
|
||||||
|
// Trending — что популярно (для главного экрана магазина). Берём яркие киты.
|
||||||
|
const TRENDING = GAMEPLAY_KITS.filter(k =>
|
||||||
|
['shift-to-run', 'day-night-cycle', 'loot-crate', 'confetti'].includes(k.id));
|
||||||
|
// Эффекты-примитивы для категории «Эффекты».
|
||||||
|
const FX_ITEMS = [
|
||||||
|
{ id: 'emitter', name: 'Эмиттер частиц', icon: 'sparkles', desc: 'Источник частиц (огонь/искры/дым)' },
|
||||||
|
{ id: 'beam', name: 'Луч (beam)', icon: 'zap', desc: 'Бегущий луч между точками' },
|
||||||
|
{ id: 'pointer', name: 'Указатель-стрелка', icon: 'arrow-up', desc: 'Парящая стрелка-подсказка' },
|
||||||
|
{ id: 'light', name: 'Источник света', icon: 'sun', desc: 'Точечная лампа' },
|
||||||
|
{ id: 'checkpoint', name: 'Триггер-зона', icon: 'flag', desc: 'Невидимая зона-триггер' },
|
||||||
|
];
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
// Обработчик выбора пользовательской модели — пока stub.
|
// Обработчик выбора пользовательской модели — пока stub.
|
||||||
@ -490,59 +551,63 @@ const ToolboxModal = ({
|
|||||||
return (
|
return (
|
||||||
<div className={cl.overlay} onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
<div className={cl.overlay} onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||||
<div className={cl.modal}>
|
<div className={cl.modal}>
|
||||||
|
{/* === Шапка с 4 верхними вкладками (как Roblox Creator Store) === */}
|
||||||
<header className={cl.header}>
|
<header className={cl.header}>
|
||||||
<h2 className={cl.title} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<h2 className={cl.title} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<Icon name="box" size={20} /> Тулбокс — библиотека объектов
|
<Icon name="box" size={20} /> Toolbox
|
||||||
</h2>
|
</h2>
|
||||||
<div className={cl.headerInfo}>
|
|
||||||
{section === 'standard'
|
|
||||||
? `Показано ${visibleCount} из ${totalForSection}`
|
|
||||||
: (myModels === null && section === 'mine') || (communityModels === null && section === 'community')
|
|
||||||
? '...'
|
|
||||||
: `${visibleCount} из ${totalForSection}`}
|
|
||||||
</div>
|
|
||||||
<button className={cl.closeBtn} onClick={onClose} title="Закрыть (Esc)">
|
<button className={cl.closeBtn} onClick={onClose} title="Закрыть (Esc)">
|
||||||
<Icon name="close" size={14} />
|
<Icon name="close" size={14} />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Раздел: Стандартные / Мои / Сообщество */}
|
<div className={cl.topTabs}>
|
||||||
<div className={cl.sectionTabs}>
|
{[
|
||||||
|
{ id: 'store', label: 'Магазин', icon: 'box' },
|
||||||
|
{ id: 'inventory', label: 'Инвентарь', icon: 'grid' },
|
||||||
|
{ id: 'recent', label: 'Недавние', icon: 'clock' },
|
||||||
|
{ id: 'tips', label: 'Советы', icon: 'bulb' },
|
||||||
|
].map(t => (
|
||||||
<button
|
<button
|
||||||
className={`${cl.sectionTab} ${section === 'standard' ? cl.sectionTabActive : ''}`}
|
key={t.id}
|
||||||
onClick={() => setSection('standard')}
|
className={`${cl.topTab} ${view === t.id ? cl.topTabActive : ''}`}
|
||||||
|
onClick={() => { setView(t.id); setStoreCat(null); setSearch(''); }}
|
||||||
|
title={t.label}
|
||||||
>
|
>
|
||||||
<Icon name="library" size={15} /> Стандартные
|
<Icon name={t.icon} size={18} />
|
||||||
</button>
|
<span>{t.label}</span>
|
||||||
<button
|
|
||||||
className={`${cl.sectionTab} ${section === 'mine' ? cl.sectionTabActive : ''}`}
|
|
||||||
onClick={() => setSection('mine')}
|
|
||||||
>
|
|
||||||
<Icon name="user" size={15} /> Мои модели
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`${cl.sectionTab} ${section === 'community' ? cl.sectionTabActive : ''}`}
|
|
||||||
onClick={() => setSection('community')}
|
|
||||||
>
|
|
||||||
<Icon name="globe" size={15} /> Сообщество
|
|
||||||
</button>
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Поиск — скрыт только на главном экране магазина и в советах */}
|
||||||
|
{!(view === 'store' && !storeCat) && view !== 'tips' && (
|
||||||
<div className={cl.searchBar}>
|
<div className={cl.searchBar}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className={cl.searchInput}
|
className={cl.searchInput}
|
||||||
placeholder={section === 'standard'
|
placeholder="Поиск по названию..."
|
||||||
? 'Поиск по названию, категории...'
|
|
||||||
: 'Поиск по названию модели или автору...'}
|
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Подкатегории зависят от раздела */}
|
{/* Хлебные крошки/назад при открытой категории магазина */}
|
||||||
{section === 'standard' && (
|
{view === 'store' && storeCat && (
|
||||||
|
<div className={cl.breadcrumb}>
|
||||||
|
<button className={cl.backBtn} onClick={() => setStoreCat(null)}>
|
||||||
|
<Icon name="arrow-left" size={14} /> Категории
|
||||||
|
</button>
|
||||||
|
<span className={cl.crumbCurrent}>
|
||||||
|
{(STORE_CATEGORIES.find(c => c.id === storeCat) || {}).label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Подкатегории standard (3D) */}
|
||||||
|
{view === 'store' && storeCat === '3d' && (
|
||||||
<div className={cl.categoryTabs}>
|
<div className={cl.categoryTabs}>
|
||||||
{standardCategoriesWithCount.map(c => (
|
{standardCategoriesWithCount.map(c => (
|
||||||
<button
|
<button
|
||||||
@ -557,107 +622,180 @@ const ToolboxModal = ({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Подкатегории механик */}
|
||||||
{(section === 'mine' || section === 'community') && (
|
{view === 'store' && storeCat === 'gameplay' && (
|
||||||
<div className={cl.categoryTabs}>
|
<div className={cl.categoryTabs}>
|
||||||
|
{KIT_CATEGORIES.map(c => (
|
||||||
<button
|
<button
|
||||||
className={`${cl.categoryTab} ${userKind === 'all' ? cl.categoryTabActive : ''}`}
|
key={c.id}
|
||||||
onClick={() => setUserKind('all')}
|
className={`${cl.categoryTab} ${kitCat === c.id ? cl.categoryTabActive : ''}`}
|
||||||
>
|
onClick={() => setKitCat(c.id)}
|
||||||
<Icon name="library" size={14} /> Все
|
>{c.label}</button>
|
||||||
</button>
|
))}
|
||||||
<button
|
</div>
|
||||||
className={`${cl.categoryTab} ${userKind === 'voxel' ? cl.categoryTabActive : ''}`}
|
)}
|
||||||
onClick={() => setUserKind('voxel')}
|
{/* Фильтр kind для инвентаря */}
|
||||||
>
|
{view === 'inventory' && (
|
||||||
<Icon name="square" size={14} /> Воксельные
|
<div className={cl.categoryTabs}>
|
||||||
</button>
|
<button className={`${cl.categoryTab} ${userKind === 'all' ? cl.categoryTabActive : ''}`} onClick={() => setUserKind('all')}><Icon name="library" size={14} /> Все</button>
|
||||||
<button
|
<button className={`${cl.categoryTab} ${userKind === 'voxel' ? cl.categoryTabActive : ''}`} onClick={() => setUserKind('voxel')}><Icon name="square" size={14} /> Воксельные</button>
|
||||||
className={`${cl.categoryTab} ${userKind === 'smooth' ? cl.categoryTabActive : ''}`}
|
<button className={`${cl.categoryTab} ${userKind === 'smooth' ? cl.categoryTabActive : ''}`} onClick={() => setUserKind('smooth')}><Icon name="sphere" size={14} /> Гладкие</button>
|
||||||
onClick={() => setUserKind('smooth')}
|
|
||||||
>
|
|
||||||
<Icon name="sphere" size={14} /> Гладкие
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* === Контент === */}
|
{/* ====================== КОНТЕНТ ====================== */}
|
||||||
<div className={cl.grid}>
|
|
||||||
{section === 'standard' && (
|
{/* --- МАГАЗИН: главный экран (6 плиток + Trending) --- */}
|
||||||
standardFiltered.length === 0 ? (
|
{view === 'store' && !storeCat && (
|
||||||
<div className={cl.empty}>Ничего не найдено</div>
|
<div className={cl.storeHome}>
|
||||||
) : (
|
<div className={cl.sectionLabel}>Категории</div>
|
||||||
standardFiltered.map(m => (
|
<div className={cl.catGrid}>
|
||||||
<ThumbCard
|
{STORE_CATEGORIES.map(c => (
|
||||||
key={m.id}
|
<button key={c.id} className={cl.catTile} onClick={() => openStoreCategory(c.id)}>
|
||||||
model={m}
|
<div className={cl.catTileIcon}><Icon name={c.icon} size={26} /></div>
|
||||||
active={activeId === m.id}
|
<div className={cl.catTileLabel}>{c.label}</div>
|
||||||
onPick={() => { onPick(m.id); onClose(); }}
|
<div className={cl.catTileDesc}>{c.desc}</div>
|
||||||
/>
|
</button>
|
||||||
))
|
))}
|
||||||
)
|
</div>
|
||||||
|
<div className={cl.sectionLabel} style={{ marginTop: 18 }}>
|
||||||
|
<Icon name="trending" size={15} /> Популярное
|
||||||
|
</div>
|
||||||
|
<div className={cl.trendRow}>
|
||||||
|
{TRENDING.map(kit => (
|
||||||
|
<button key={kit.id} className={cl.trendCard}
|
||||||
|
onClick={() => { onPick('kit:' + kit.id); onClose(); }} title={kit.desc}>
|
||||||
|
<div className={cl.trendIcon}><Icon name={kit.icon || 'zap'} size={28} /></div>
|
||||||
|
<div className={cl.trendName}>{kit.name}</div>
|
||||||
|
<div className={cl.freeBadge}>FREE</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{section === 'mine' && (
|
{/* --- МАГАЗИН: категория 3D-объекты --- */}
|
||||||
loading || myModels === null ? (
|
{view === 'store' && storeCat === '3d' && (
|
||||||
|
<div className={cl.grid}>
|
||||||
|
{standardFiltered.length === 0
|
||||||
|
? <div className={cl.empty}>Ничего не найдено</div>
|
||||||
|
: standardFiltered.map(m => (
|
||||||
|
<ThumbCard key={m.id} model={m} active={activeId === m.id}
|
||||||
|
onPick={() => { onPick(m.id); onClose(); }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* --- МАГАЗИН: Эффекты --- */}
|
||||||
|
{view === 'store' && storeCat === 'fx' && (
|
||||||
|
<div className={cl.grid}>
|
||||||
|
{FX_ITEMS.filter(f => !search.trim() || f.name.toLowerCase().includes(search.trim().toLowerCase())).map(f => (
|
||||||
|
<button key={f.id} type="button" className={cl.card} style={{ textAlign: 'left' }}
|
||||||
|
onClick={() => { onPick('primitive:' + f.id); onClose(); }} title={f.desc}>
|
||||||
|
<div className={cl.cardIconWrap}>
|
||||||
|
<div className={cl.cardIconPlaceholder} style={{ background: 'linear-gradient(135deg, rgba(255,90,176,0.22), rgba(255,210,58,0.18))' }}>
|
||||||
|
<Icon name={f.icon} size={30} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={cl.cardName}>{f.name}</div>
|
||||||
|
<div className={cl.cardCat} style={{ fontSize: 11, opacity: 0.8 }}>{f.desc}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* --- МАГАЗИН: Готовые механики --- */}
|
||||||
|
{view === 'store' && storeCat === 'gameplay' && (
|
||||||
|
<div className={cl.grid}>
|
||||||
|
{kitsFiltered.length === 0
|
||||||
|
? <div className={cl.empty}>Ничего не найдено</div>
|
||||||
|
: kitsFiltered.map(kit => (
|
||||||
|
<button key={kit.id} type="button" className={cl.card} style={{ textAlign: 'left' }}
|
||||||
|
onClick={() => { onPick('kit:' + kit.id); onClose(); }} title={kit.desc}>
|
||||||
|
<div className={cl.cardIconWrap}>
|
||||||
|
<div className={cl.cardIconPlaceholder} style={{ background: 'linear-gradient(135deg, rgba(77,107,255,0.25), rgba(54,213,122,0.18))' }}>
|
||||||
|
<Icon name={kit.icon || 'zap'} size={30} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={cl.cardName}>{kit.name}</div>
|
||||||
|
<div className={cl.cardCat} style={{ fontSize: 11, opacity: 0.8, lineHeight: 1.3, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{kit.desc}</div>
|
||||||
|
<div className={cl.freeBadge}>FREE</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* --- МАГАЗИН: 2D-картинки / Плагины / Аудио — пока «скоро» --- */}
|
||||||
|
{view === 'store' && (storeCat === '2d' || storeCat === 'plugins' || storeCat === 'audio') && (
|
||||||
|
<div className={cl.soon}>
|
||||||
|
<Icon name={storeCat === 'audio' ? 'sound' : storeCat === '2d' ? 'image' : 'puzzle'} size={42} />
|
||||||
|
<div className={cl.soonTitle}>Скоро будет</div>
|
||||||
|
<div className={cl.soonText}>
|
||||||
|
{storeCat === '2d' && 'Иконки и текстуры для интерфейса появятся в следующем обновлении.'}
|
||||||
|
{storeCat === 'plugins' && 'Плагины-расширения студии — в разработке (фаза T4).'}
|
||||||
|
{storeCat === 'audio' && 'Библиотека звуков и музыки — в разработке.'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* --- ИНВЕНТАРЬ: мои модели --- */}
|
||||||
|
{view === 'inventory' && (
|
||||||
|
<div className={cl.grid}>
|
||||||
|
{loading || myModels === null ? (
|
||||||
<div className={cl.empty}>⏳ Загрузка...</div>
|
<div className={cl.empty}>⏳ Загрузка...</div>
|
||||||
) : !userId ? (
|
) : !userId ? (
|
||||||
<div className={cl.empty}>
|
<div className={cl.empty}>Войдите в аккаунт, чтобы видеть свои модели</div>
|
||||||
Войдите в аккаунт, чтобы видеть свои модели
|
|
||||||
</div>
|
|
||||||
) : loadError ? (
|
) : loadError ? (
|
||||||
<div className={cl.empty}>{loadError}</div>
|
<div className={cl.empty}>{loadError}</div>
|
||||||
) : mineFiltered.length === 0 ? (
|
) : mineFiltered.length === 0 ? (
|
||||||
<div className={cl.empty}>
|
<div className={cl.empty}>
|
||||||
{myModels.length === 0
|
{myModels.length === 0
|
||||||
? 'У вас пока нет своих моделей. Создайте их во вкладке «Модель» → «Воксельная» или «Гладкая».'
|
? 'У вас пока нет своих моделей. Создайте их в воксельном редакторе.'
|
||||||
: 'Ничего не найдено по фильтру'}
|
: 'Ничего не найдено по фильтру'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
mineFiltered.map(m => (
|
mineFiltered.map(m => (
|
||||||
<UserModelCard
|
<UserModelCard key={m.id} model={m} active={activeId === userModelKey(m)}
|
||||||
key={m.id}
|
onPick={() => handlePickUserModel(m)} isMine
|
||||||
model={m}
|
onEdit={onEditUserModel} onSettings={onUserModelSettings} onDelete={onDeleteUserModel} />
|
||||||
active={activeId === userModelKey(m)}
|
|
||||||
onPick={() => handlePickUserModel(m)}
|
|
||||||
isMine
|
|
||||||
onEdit={onEditUserModel}
|
|
||||||
onSettings={onUserModelSettings}
|
|
||||||
onDelete={onDeleteUserModel}
|
|
||||||
/>
|
|
||||||
))
|
))
|
||||||
)
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{section === 'community' && (
|
{/* --- НЕДАВНИЕ: сообщество (популярные модели сообщества) --- */}
|
||||||
loading || communityModels === null ? (
|
{view === 'recent' && (
|
||||||
|
<div className={cl.grid}>
|
||||||
|
{communityModels === null ? (
|
||||||
<div className={cl.empty}>⏳ Загрузка...</div>
|
<div className={cl.empty}>⏳ Загрузка...</div>
|
||||||
) : loadError ? (
|
|
||||||
<div className={cl.empty}>{loadError}</div>
|
|
||||||
) : communityFiltered.length === 0 ? (
|
) : communityFiltered.length === 0 ? (
|
||||||
<div className={cl.empty}>
|
<div className={cl.empty}>Пока пусто. Используй ассеты — они появятся здесь.</div>
|
||||||
{communityModels.length === 0
|
|
||||||
? 'Пока нет опубликованных моделей сообщества. Будь первым!'
|
|
||||||
: 'Ничего не найдено по фильтру'}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
communityFiltered.map(m => (
|
communityFiltered.map(m => (
|
||||||
<UserModelCard
|
<UserModelCard key={m.id} model={m} active={activeId === userModelKey(m)}
|
||||||
key={m.id}
|
|
||||||
model={m}
|
|
||||||
active={activeId === userModelKey(m)}
|
|
||||||
onPick={() => handlePickUserModel(m)}
|
onPick={() => handlePickUserModel(m)}
|
||||||
isMine={userId != null && m.user_id === userId}
|
isMine={userId != null && m.user_id === userId}
|
||||||
onEdit={onEditUserModel}
|
onEdit={onEditUserModel} onSettings={onUserModelSettings} onDelete={onDeleteUserModel}
|
||||||
onSettings={onUserModelSettings}
|
showSocial onLike={handleLikeModel} />
|
||||||
onDelete={onDeleteUserModel}
|
|
||||||
showSocial
|
|
||||||
onLike={handleLikeModel}
|
|
||||||
/>
|
|
||||||
))
|
))
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* --- СОВЕТЫ --- */}
|
||||||
|
{view === 'tips' && (
|
||||||
|
<div className={cl.tips}>
|
||||||
|
<h3>Как пользоваться Toolbox</h3>
|
||||||
|
<ul>
|
||||||
|
<li><b>3D-объекты</b> — 700+ готовых моделей: деревья, дома, мебель, персонажи. Клик → объект появляется на сцене.</li>
|
||||||
|
<li><b>Готовые механики</b> — вставь поведение одним кликом: бег на Shift, смена дня/ночи, сундук с лутом, счётчик монет. Скрипт прикрепляется сам.</li>
|
||||||
|
<li><b>Эффекты</b> — частицы, лучи, источники света, триггер-зоны.</li>
|
||||||
|
<li><b>Инвентарь</b> — твои воксельные модели, созданные в редакторе.</li>
|
||||||
|
<li>Жми на категорию, ищи через поиск, кликни ассет — он добавится в проект.</li>
|
||||||
|
</ul>
|
||||||
|
<p style={{ opacity: 0.7 }}>Собери целую игру, не написав ни строчки кода — просто перетаскивая готовые механики.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -79,6 +79,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.closeBtn {
|
.closeBtn {
|
||||||
|
margin-left: auto; /* прижать крестик к правому краю шапки */
|
||||||
background: rgba(255, 255, 255, 0.16);
|
background: rgba(255, 255, 255, 0.16);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@ -447,3 +448,156 @@
|
|||||||
font-size: 36px;
|
font-size: 36px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ====================== Roblox-style Toolbox (задача 17) ======================
|
||||||
|
Явные светлые цвета (не --text-переменные — они в этой модалке не заданы и
|
||||||
|
давали тёмный текст на тёмном фоне). Крупнее шрифты. */
|
||||||
|
.topTabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.10);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.topTab {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px 4px 12px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: #aab2c0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color .12s, border-color .12s;
|
||||||
|
}
|
||||||
|
.topTab:hover { color: #ffffff; }
|
||||||
|
.topTabActive {
|
||||||
|
color: #6f8bff;
|
||||||
|
border-bottom-color: #6f8bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 18px 6px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.backBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
border: 1px solid rgba(255,255,255,0.14);
|
||||||
|
color: #e8ecf2;
|
||||||
|
padding: 7px 14px;
|
||||||
|
border-radius: 9px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.backBtn:hover { background: rgba(255,255,255,0.15); }
|
||||||
|
.crumbCurrent { font-weight: 700; font-size: 15px; color: #ffffff; }
|
||||||
|
|
||||||
|
.storeHome { overflow-y: auto; padding: 16px 20px 22px; flex: 1; }
|
||||||
|
.sectionLabel {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
font-weight: 700; font-size: 17px; color: #ffffff;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.sectionLabel svg { color: #6f8bff; }
|
||||||
|
|
||||||
|
.catGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.catTile {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
column-gap: 14px;
|
||||||
|
row-gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 18px 20px;
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
border: 1px solid rgba(255,255,255,0.10);
|
||||||
|
border-radius: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: transform .1s, background .12s, border-color .12s;
|
||||||
|
}
|
||||||
|
.catTile:hover {
|
||||||
|
background: rgba(111,139,255,0.16);
|
||||||
|
border-color: #6f8bff;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
/* Иконка — слева, занимает обе строки (в одну линию с названием). */
|
||||||
|
.catTileIcon {
|
||||||
|
grid-row: 1 / 3;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 52px; height: 52px;
|
||||||
|
background: rgba(111,139,255,0.16);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #8aa0ff;
|
||||||
|
}
|
||||||
|
.catTileLabel { font-weight: 800; font-size: 18px; color: #ffffff; align-self: end; }
|
||||||
|
.catTileDesc { font-size: 13px; color: #aab2c0; line-height: 1.35; align-self: start; }
|
||||||
|
|
||||||
|
.trendRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.trendCard {
|
||||||
|
position: relative;
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 10px;
|
||||||
|
padding: 14px 10px 16px;
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
border: 1px solid rgba(255,255,255,0.10);
|
||||||
|
border-radius: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform .1s, border-color .12s;
|
||||||
|
}
|
||||||
|
.trendCard:hover { transform: translateY(-2px); border-color: #6f8bff; }
|
||||||
|
.trendIcon {
|
||||||
|
width: 100%; height: 78px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: linear-gradient(135deg, rgba(111,139,255,0.28), rgba(54,213,122,0.18));
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.trendName { font-size: 14px; font-weight: 700; text-align: center; color: #ffffff; }
|
||||||
|
.freeBadge {
|
||||||
|
position: absolute; top: 10px; right: 10px;
|
||||||
|
font-size: 10px; font-weight: 800; letter-spacing: 0.5px;
|
||||||
|
color: #3ce087;
|
||||||
|
background: rgba(54,213,122,0.18);
|
||||||
|
padding: 3px 7px; border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soon {
|
||||||
|
flex: 1;
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
gap: 12px; padding: 50px;
|
||||||
|
color: #aab2c0; text-align: center;
|
||||||
|
}
|
||||||
|
.soon svg { color: #6f8bff; }
|
||||||
|
.soonTitle { font-size: 22px; font-weight: 800; color: #ffffff; }
|
||||||
|
.soonText { font-size: 15px; max-width: 400px; color: #aab2c0; line-height: 1.5; }
|
||||||
|
|
||||||
|
.tips {
|
||||||
|
overflow-y: auto; padding: 22px 28px; flex: 1;
|
||||||
|
color: #e8ecf2; line-height: 1.6;
|
||||||
|
}
|
||||||
|
.tips h3 { margin: 4px 0 16px; font-size: 21px; color: #ffffff; }
|
||||||
|
.tips ul { margin: 0 0 18px; padding-left: 22px; }
|
||||||
|
.tips li { margin-bottom: 12px; font-size: 15px; color: #d4dae4; }
|
||||||
|
.tips b { color: #8aa0ff; }
|
||||||
|
.tips p { font-size: 14px; }
|
||||||
|
|||||||
@ -141,7 +141,9 @@ const Dropdown = ({ trigger, children }) => {
|
|||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ id: 'home', label: 'Главная', iconName: 'home' },
|
{ id: 'home', label: 'Главная', iconName: 'home' },
|
||||||
{ id: 'model', label: 'Модель', iconName: 'wrench' },
|
// Вкладка-редактор СВОИХ воксельных моделей (создание ассета).
|
||||||
|
// Каталог готовых моделей/механик теперь в Toolbox (кнопка на «Главной»).
|
||||||
|
{ id: 'model', label: 'Редактор моделей', iconName: 'wrench' },
|
||||||
{ id: 'test', label: 'Игра', iconName: 'gamepad' },
|
{ id: 'test', label: 'Игра', iconName: 'gamepad' },
|
||||||
{ id: 'view', label: 'Вид', iconName: 'eye' },
|
{ id: 'view', label: 'Вид', iconName: 'eye' },
|
||||||
];
|
];
|
||||||
@ -231,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,
|
||||||
@ -329,9 +332,9 @@ const TopRibbon = (props) => {
|
|||||||
title="Параметрическая фигура (куб/сфера/...)"
|
title="Параметрическая фигура (куб/сфера/...)"
|
||||||
/>
|
/>
|
||||||
<RibbonBtn
|
<RibbonBtn
|
||||||
iconName="trees" label="Модель"
|
iconName="box" label="Toolbox"
|
||||||
active={activeTool === 'model'}
|
onClick={onOpenStandardModels}
|
||||||
onClick={() => onToolChange('model')}
|
title="Библиотека: 3D-объекты, готовые механики, эффекты (как Creator Store)"
|
||||||
/>
|
/>
|
||||||
<RibbonBtn
|
<RibbonBtn
|
||||||
iconName="image" label="Интерфейс"
|
iconName="image" label="Интерфейс"
|
||||||
@ -429,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>
|
||||||
|
|
||||||
{/* «Окружение» (время суток / амбиент / музыка) и
|
{/* «Окружение» (время суток / амбиент / музыка) и
|
||||||
|
|||||||
249
src/editor/engine/AchievementsManager.js
Normal file
249
src/editor/engine/AchievementsManager.js
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
/**
|
||||||
|
* AchievementsManager — достижения (badges) как в Roblox (задача 20).
|
||||||
|
*
|
||||||
|
* - define([...]) регистрирует достижения проекта.
|
||||||
|
* - unlock(id) разблокирует → toast справа-сверху (4 редкости, очередь, звук).
|
||||||
|
* - bindToStat(id, statName, {gte/lte/eq}) — авто-unlock по leaderstat.
|
||||||
|
* - кнопка-кубок слева-снизу → страница «Мои достижения» (grid + прогресс).
|
||||||
|
* - сохранение разблокированных в localStorage по projectId (закрыл-открыл → остались).
|
||||||
|
*
|
||||||
|
* API (через game.achievements.*): define/unlock/has/list/progress/bindToStat/
|
||||||
|
* setButtonVisible/openPage.
|
||||||
|
*
|
||||||
|
* Фича-парность: тот же модуль в rublox-player/src/engine/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const RARITY = {
|
||||||
|
common: { label: 'Обычное', border: '#9aa3b2', bg: 'linear-gradient(135deg,rgba(120,130,150,0.9),rgba(80,88,104,0.9))', glow: 'rgba(154,163,178,0.5)' },
|
||||||
|
rare: { label: 'Редкое', border: '#4d8bff', bg: 'linear-gradient(135deg,rgba(60,110,220,0.92),rgba(30,60,150,0.92))', glow: 'rgba(77,139,255,0.6)' },
|
||||||
|
epic: { label: 'Эпическое', border: '#a05aff', bg: 'linear-gradient(135deg,rgba(150,80,230,0.92),rgba(90,40,160,0.92))', glow: 'rgba(160,90,255,0.65)' },
|
||||||
|
legendary: { label: 'Легендарное', border: '#ffd23a', bg: 'linear-gradient(135deg,rgba(255,200,60,0.95),rgba(220,140,20,0.95))', glow: 'rgba(255,210,58,0.75)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export class AchievementsManager {
|
||||||
|
constructor(scene3d) {
|
||||||
|
this.s = scene3d;
|
||||||
|
this._defs = []; // [{id,name,description,icon,rarity,points,hidden}]
|
||||||
|
this._unlocked = new Set(); // id разблокированных
|
||||||
|
this._binds = []; // [{id, stat, op, value}]
|
||||||
|
this._toastQueue = [];
|
||||||
|
this._toastActive = false;
|
||||||
|
this._btnVisible = true;
|
||||||
|
this.btn = null; this.toastRoot = null; this.page = null;
|
||||||
|
this._projectKey = 'rublox_ach_' + (this.s?._projectId ?? 'proj');
|
||||||
|
}
|
||||||
|
|
||||||
|
define(list) {
|
||||||
|
const arr = Array.isArray(list) ? list : [list];
|
||||||
|
for (const a of arr) {
|
||||||
|
if (!a || typeof a.id !== 'string') continue;
|
||||||
|
if (this._defs.some(d => d.id === a.id)) continue;
|
||||||
|
this._defs.push({
|
||||||
|
id: a.id, name: a.name || a.id, description: a.description || '',
|
||||||
|
icon: a.icon || '🏆', rarity: RARITY[a.rarity] ? a.rarity : 'common',
|
||||||
|
points: Number(a.points) || 5, hidden: !!a.hidden,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this._loadSaved();
|
||||||
|
this._mountButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
_loadSaved() {
|
||||||
|
// Резервная локальная копия (мгновенно, до ответа БД).
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(this._projectKey);
|
||||||
|
if (raw) for (const id of JSON.parse(raw)) this._unlocked.add(id);
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
/** Загрузить разблокированные достижения из БД (по игроку). Вызывать при Play. */
|
||||||
|
loadFromDB() {
|
||||||
|
const rt = this.s?.gameRuntime;
|
||||||
|
if (!rt || !rt.loadProgress) return;
|
||||||
|
rt.loadProgress('_achievements', (data) => {
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
for (const id of data) this._unlocked.add(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_persist() {
|
||||||
|
// 1) локально (быстрый кэш), 2) в БД (между сессиями, любое устройство).
|
||||||
|
try { localStorage.setItem(this._projectKey, JSON.stringify([...this._unlocked])); } catch (e) {}
|
||||||
|
try { this.s?.gameRuntime?.saveProgress?.('_achievements', [...this._unlocked]); } catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
unlock(id, _playerId) {
|
||||||
|
const def = this._defs.find(d => d.id === id);
|
||||||
|
if (!def || this._unlocked.has(id)) return false;
|
||||||
|
this._unlocked.add(id);
|
||||||
|
this._persist();
|
||||||
|
this._queueToast(def);
|
||||||
|
this._playSound(def.rarity);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
has(id) { return this._unlocked.has(id); }
|
||||||
|
|
||||||
|
list() {
|
||||||
|
return this._defs.map(d => ({ id: d.id, name: d.name, unlocked: this._unlocked.has(d.id) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
progress() {
|
||||||
|
const total = this._defs.length;
|
||||||
|
const unlocked = this._defs.filter(d => this._unlocked.has(d.id)).length;
|
||||||
|
const pts = this._defs.filter(d => this._unlocked.has(d.id)).reduce((s, d) => s + d.points, 0);
|
||||||
|
const maxPts = this._defs.reduce((s, d) => s + d.points, 0);
|
||||||
|
return { total, unlocked, points: pts, maxPoints: maxPts };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Авто-unlock при достижении leaderstat значения. */
|
||||||
|
bindToStat(id, statName, cond) {
|
||||||
|
const op = cond && (cond.gte != null ? 'gte' : cond.lte != null ? 'lte' : cond.eq != null ? 'eq' : null);
|
||||||
|
if (!op) return;
|
||||||
|
this._binds.push({ id, stat: statName, op, value: cond[op] });
|
||||||
|
// Подпишемся на leaderstats при первом bind.
|
||||||
|
if (!this._boundLs && this.s?.leaderstats) {
|
||||||
|
this._boundLs = true;
|
||||||
|
this.s.leaderstats.onChange((pid, name, nv) => this._checkBinds(name, nv));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_checkBinds(statName, value) {
|
||||||
|
for (const b of this._binds) {
|
||||||
|
if (b.stat !== statName || this._unlocked.has(b.id)) continue;
|
||||||
|
const ok = b.op === 'gte' ? value >= b.value : b.op === 'lte' ? value <= b.value : value === b.value;
|
||||||
|
if (ok) this.unlock(b.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setButtonVisible(v) { this._btnVisible = !!v; if (this.btn) this.btn.style.display = v ? 'flex' : 'none'; }
|
||||||
|
|
||||||
|
get active() { return this._defs.length > 0; }
|
||||||
|
|
||||||
|
// ── Кнопка-кубок ───────────────────────────────────────────────────────
|
||||||
|
_mountButton() {
|
||||||
|
if (this.btn || !this.active) return;
|
||||||
|
if (!this.s?._isPlaying) return; // кнопка-кубок только в Play
|
||||||
|
const parent = (this.s && this.s.canvas && this.s.canvas.parentElement) || document.body;
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.title = 'Мои достижения';
|
||||||
|
b.textContent = '🏆';
|
||||||
|
b.style.cssText = [
|
||||||
|
'position:absolute', 'left:14px', 'bottom:64px', 'z-index:50',
|
||||||
|
'width:46px', 'height:46px', 'border-radius:12px', 'font-size:24px',
|
||||||
|
'background:rgba(18,22,33,0.6)', 'backdrop-filter:blur(8px)',
|
||||||
|
'border:1px solid rgba(255,255,255,0.15)', 'cursor:pointer',
|
||||||
|
'display:flex', 'align-items:center', 'justify-content:center',
|
||||||
|
'box-shadow:0 4px 16px rgba(0,0,0,0.35)', 'pointer-events:auto',
|
||||||
|
].join(';');
|
||||||
|
if (!this._btnVisible) b.style.display = 'none';
|
||||||
|
b.onclick = () => this.openPage();
|
||||||
|
parent.appendChild(b);
|
||||||
|
this.btn = b;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Toast ────────────────────────────────────────────────────────────
|
||||||
|
_queueToast(def) { this._toastQueue.push(def); if (!this._toastActive) this._nextToast(); }
|
||||||
|
_nextToast() {
|
||||||
|
if (!this._toastQueue.length) { this._toastActive = false; return; }
|
||||||
|
this._toastActive = true;
|
||||||
|
const def = this._toastQueue.shift();
|
||||||
|
const r = RARITY[def.rarity];
|
||||||
|
const parent = (this.s && this.s.canvas && this.s.canvas.parentElement) || document.body;
|
||||||
|
const t = document.createElement('div');
|
||||||
|
t.style.cssText = [
|
||||||
|
'position:absolute', 'top:200px', 'right:14px', 'z-index:60',
|
||||||
|
'width:340px', 'display:flex', 'align-items:center', 'gap:12px',
|
||||||
|
'padding:12px 14px', 'border-radius:14px', 'background:' + r.bg,
|
||||||
|
'border:2px solid ' + r.border, 'box-shadow:0 0 24px ' + r.glow + ',0 8px 24px rgba(0,0,0,0.4)',
|
||||||
|
'font-family:Inter,system-ui,sans-serif', 'color:#fff',
|
||||||
|
'transform:translateX(380px)', 'transition:transform .32s cubic-bezier(.2,.8,.3,1)',
|
||||||
|
'pointer-events:auto', 'cursor:pointer',
|
||||||
|
].join(';');
|
||||||
|
t.innerHTML =
|
||||||
|
'<div style="font-size:42px;flex:0 0 auto;filter:drop-shadow(0 2px 4px rgba(0,0,0,0.4))">' + def.icon + '</div>' +
|
||||||
|
'<div style="flex:1;min-width:0">' +
|
||||||
|
'<div style="font-size:11px;opacity:0.85;font-weight:700;text-transform:uppercase;letter-spacing:0.5px">Достижение разблокировано · ' + r.label + '</div>' +
|
||||||
|
'<div style="font-size:17px;font-weight:800;margin:1px 0">' + this._esc(def.name) + '</div>' +
|
||||||
|
'<div style="font-size:12px;opacity:0.9">' + this._esc(def.description) + ' · +' + def.points + ' очк.</div>' +
|
||||||
|
'</div>';
|
||||||
|
t.onclick = () => this.openPage();
|
||||||
|
parent.appendChild(t);
|
||||||
|
// slide-in
|
||||||
|
requestAnimationFrame(() => { t.style.transform = 'translateX(0)'; });
|
||||||
|
// через 3с slide-out + следующий
|
||||||
|
setTimeout(() => {
|
||||||
|
t.style.transform = 'translateX(380px)';
|
||||||
|
setTimeout(() => { try { t.remove(); } catch (e) {} this._nextToast(); }, 350);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
_playSound(rarity) {
|
||||||
|
// Используем встроенные звуки движка через gameRuntime/audio.
|
||||||
|
try {
|
||||||
|
const map = { common: 'coin', rare: 'win', epic: 'win', legendary: 'win' };
|
||||||
|
const pitch = { common: 1, rare: 1.1, epic: 0.9, legendary: 0.8 }[rarity] || 1;
|
||||||
|
this.s?.gameRuntime?._playSound?.({ name: map[rarity] || 'coin', pitch });
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Страница «Мои достижения» ───────────────────────────────────────────
|
||||||
|
openPage() {
|
||||||
|
if (this.page) { this._closePage(); return; }
|
||||||
|
const parent = (this.s && this.s.canvas && this.s.canvas.parentElement) || document.body;
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.style.cssText = [
|
||||||
|
'position:absolute', 'inset:0', 'z-index:80',
|
||||||
|
'background:rgba(8,10,16,0.78)', 'backdrop-filter:blur(6px)',
|
||||||
|
'display:flex', 'align-items:center', 'justify-content:center',
|
||||||
|
'font-family:Inter,system-ui,sans-serif', 'pointer-events:auto',
|
||||||
|
].join(';');
|
||||||
|
overlay.onclick = (e) => { if (e.target === overlay) this._closePage(); };
|
||||||
|
const pr = this.progress();
|
||||||
|
const pct = pr.total ? Math.round(pr.unlocked / pr.total * 100) : 0;
|
||||||
|
|
||||||
|
const panel = document.createElement('div');
|
||||||
|
panel.style.cssText = 'width:min(720px,92%);max-height:84%;overflow-y:auto;background:#141826;border:1px solid rgba(255,255,255,0.12);border-radius:18px;padding:22px;color:#e8ecf2;box-shadow:0 20px 60px rgba(0,0,0,0.5)';
|
||||||
|
|
||||||
|
let html = '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">' +
|
||||||
|
'<div style="font-size:22px;font-weight:800">🏆 Мои достижения</div>' +
|
||||||
|
'<button id="_achClose" style="width:34px;height:34px;border-radius:9px;background:rgba(255,255,255,0.1);border:1px solid rgba(255,255,255,0.15);color:#fff;font-size:18px;cursor:pointer">✕</button></div>';
|
||||||
|
html += '<div style="font-size:14px;color:#9aa3b2;margin-bottom:6px">' + pr.unlocked + ' из ' + pr.total + ' (' + pr.points + ' / ' + pr.maxPoints + ' очков)</div>';
|
||||||
|
html += '<div style="height:8px;background:rgba(255,255,255,0.1);border-radius:6px;margin-bottom:18px;overflow:hidden"><div style="height:100%;width:' + pct + '%;background:linear-gradient(90deg,#ffd23a,#ff9a3a);border-radius:6px"></div></div>';
|
||||||
|
html += '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:12px">';
|
||||||
|
for (const d of this._defs) {
|
||||||
|
const un = this._unlocked.has(d.id);
|
||||||
|
const r = RARITY[d.rarity];
|
||||||
|
const hiddenLocked = d.hidden && !un;
|
||||||
|
const icon = hiddenLocked ? '❔' : d.icon;
|
||||||
|
const name = hiddenLocked ? 'Скрытое достижение' : d.name;
|
||||||
|
const desc = hiddenLocked ? 'Найди, чтобы открыть' : d.description;
|
||||||
|
html += '<div style="background:rgba(255,255,255,0.04);border:2px solid ' + (un ? r.border : 'rgba(255,255,255,0.08)') + ';border-radius:14px;padding:14px 10px;text-align:center;' + (un ? '' : 'opacity:0.55;') + '">' +
|
||||||
|
'<div style="font-size:44px;margin-bottom:6px;' + (un ? '' : 'filter:grayscale(1);') + '">' + icon + (un ? '' : ' 🔒') + '</div>' +
|
||||||
|
'<div style="font-size:14px;font-weight:800">' + this._esc(name) + '</div>' +
|
||||||
|
'<div style="font-size:11px;color:#9aa3b2;margin-top:3px;line-height:1.3">' + this._esc(desc) + '</div>' +
|
||||||
|
'<div style="font-size:10px;font-weight:700;margin-top:6px;color:' + r.border + '">' + r.label + ' · ' + d.points + ' очк.</div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
panel.innerHTML = html;
|
||||||
|
overlay.appendChild(panel);
|
||||||
|
parent.appendChild(overlay);
|
||||||
|
panel.querySelector('#_achClose').onclick = () => this._closePage();
|
||||||
|
this.page = overlay;
|
||||||
|
}
|
||||||
|
_closePage() { if (this.page) { try { this.page.remove(); } catch (e) {} this.page = null; } }
|
||||||
|
|
||||||
|
_esc(s) { return String(s).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c])); }
|
||||||
|
|
||||||
|
serialize() { return this._defs.map(d => ({ ...d })); }
|
||||||
|
load(arr) { if (Array.isArray(arr) && arr.length) this.define(arr); }
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
for (const el of [this.btn, this.toastRoot, this.page]) { if (el) try { el.remove(); } catch (e) {} }
|
||||||
|
this.btn = null; this.page = null; this._toastQueue = []; this._toastActive = false;
|
||||||
|
}
|
||||||
|
resetRuntime() {
|
||||||
|
// Определения и unlocked сохраняются (достижения «навсегда»). Чистим UI.
|
||||||
|
this._closePage();
|
||||||
|
this._toastQueue = []; this._toastActive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
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]));
|
||||||
|
}
|
||||||
@ -91,10 +91,15 @@ export class Environment {
|
|||||||
this.fogEnabled = false;
|
this.fogEnabled = false;
|
||||||
this.fogColor = [0.7, 0.8, 0.9];
|
this.fogColor = [0.7, 0.8, 0.9];
|
||||||
this.fogDensity = 0.01;
|
this.fogDensity = 0.01;
|
||||||
// Видимые тела на небе (солнце и луна) — создаём по запросу
|
// Видимые тела на небе (солнце и луна).
|
||||||
|
// ВАЖНО (задача 16): единое небо рисует SkyboxManager (купол + солнечный
|
||||||
|
// диск + облака). Environment больше НЕ рисует свою жёлтую сферу/луну/фон —
|
||||||
|
// иначе на небе два солнца. Environment теперь отвечает ТОЛЬКО за свет
|
||||||
|
// (направление/яркость солнца, ambient). Флаг ниже отключает небесные тела.
|
||||||
|
this._drawSkyBodies = false;
|
||||||
this._sunMesh = null;
|
this._sunMesh = null;
|
||||||
this._moonMesh = null;
|
this._moonMesh = null;
|
||||||
this._createSkyBodies();
|
if (this._drawSkyBodies) this._createSkyBodies();
|
||||||
this._applyTime();
|
this._applyTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
236
src/editor/engine/FloaterManager.js
Normal file
236
src/editor/engine/FloaterManager.js
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
/**
|
||||||
|
* FloaterManager — всплывающие цифры урона (Damage Floaters), задача 40.
|
||||||
|
*
|
||||||
|
* game.fx.damageFloater(position, value, opts) → над точкой всплывает число,
|
||||||
|
* поднимается вверх, покачивается, плавно гаснет. Цвета: damage/crit/heal/
|
||||||
|
* mana/miss. Object pool из переиспользуемых billboard-планов (без create/
|
||||||
|
* destroy на каждый удар). Стек одинаковых по stackKey («×N»). Комикс-стиль
|
||||||
|
* (BAM!/KAPOW!/POW!).
|
||||||
|
*
|
||||||
|
* Билборд = плоскость с DynamicTexture (как LabelManager), billboardMode=7,
|
||||||
|
* renderingGroupId=1 (всегда поверх геометрии), disableDepthWrite.
|
||||||
|
*
|
||||||
|
* Фича-парность: тот же модуль в rublox-player/src/engine/.
|
||||||
|
*/
|
||||||
|
import { DynamicTexture } from '@babylonjs/core/Materials/Textures/dynamicTexture';
|
||||||
|
import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial';
|
||||||
|
import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
|
||||||
|
import { Color3 } from '@babylonjs/core/Maths/math.color';
|
||||||
|
import { Mesh } from '@babylonjs/core/Meshes/mesh';
|
||||||
|
|
||||||
|
const POOL_SIZE = 30;
|
||||||
|
const TEX_W = 512, TEX_H = 256;
|
||||||
|
|
||||||
|
// Пресеты типов урона: цвет текста + множители.
|
||||||
|
const PRESETS = {
|
||||||
|
damage: { color: '#ff5a4a', stroke: '#3a0000' },
|
||||||
|
crit: { color: '#ffd23a', stroke: '#5a3a00' },
|
||||||
|
heal: { color: '#46e06a', stroke: '#063a14' },
|
||||||
|
mana: { color: '#4aa8ff', stroke: '#001a3a' },
|
||||||
|
miss: { color: '#b8b8b8', stroke: '#222222' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function easeOutQuad(t) { return 1 - (1 - t) * (1 - t); }
|
||||||
|
|
||||||
|
export class FloaterManager {
|
||||||
|
constructor(scene3d) {
|
||||||
|
this.s = scene3d;
|
||||||
|
this.scene = scene3d.scene;
|
||||||
|
this.pool = [];
|
||||||
|
this._initialized = false;
|
||||||
|
this._stacks = new Map(); // stackKey → slot (для накопления ×N)
|
||||||
|
}
|
||||||
|
|
||||||
|
_init() {
|
||||||
|
if (this._initialized) return;
|
||||||
|
this._initialized = true;
|
||||||
|
for (let i = 0; i < POOL_SIZE; i++) {
|
||||||
|
const tex = new DynamicTexture(`floaterTex_${i}`, { width: TEX_W, height: TEX_H }, this.scene, true);
|
||||||
|
tex.hasAlpha = true;
|
||||||
|
const plane = MeshBuilder.CreatePlane(`floater_${i}`, { width: 2.4, height: 1.2, sideOrientation: Mesh.DOUBLESIDE }, this.scene);
|
||||||
|
const mat = new StandardMaterial(`floaterMat_${i}`, this.scene);
|
||||||
|
mat.diffuseTexture = tex;
|
||||||
|
mat.diffuseTexture.hasAlpha = true;
|
||||||
|
mat.emissiveColor = new Color3(1, 1, 1);
|
||||||
|
mat.diffuseColor = new Color3(0, 0, 0);
|
||||||
|
mat.disableLighting = true;
|
||||||
|
mat.backFaceCulling = false;
|
||||||
|
mat.disableDepthWrite = true;
|
||||||
|
mat.useAlphaFromDiffuseTexture = true;
|
||||||
|
plane.material = mat;
|
||||||
|
plane.billboardMode = 7;
|
||||||
|
plane.renderingGroupId = 1;
|
||||||
|
plane.isPickable = false;
|
||||||
|
plane.setEnabled(false);
|
||||||
|
this.pool.push({ plane, tex, mat, active: false, age: 0, lifetime: 0.8 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_acquire() {
|
||||||
|
for (const slot of this.pool) if (!slot.active) return slot;
|
||||||
|
return null; // все заняты — пропускаем новый floater (норма)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Главный API. position: {x,y,z}; value: число|строка; opts — см. задачу 40.
|
||||||
|
*/
|
||||||
|
spawn(position, value, opts = {}) {
|
||||||
|
this._init();
|
||||||
|
if (!position) return;
|
||||||
|
opts = opts || {};
|
||||||
|
|
||||||
|
// Стек: одинаковый stackKey за время жизни накапливает счётчик.
|
||||||
|
if (opts.stackKey && this._stacks.has(opts.stackKey)) {
|
||||||
|
const slot = this._stacks.get(opts.stackKey);
|
||||||
|
if (slot.active) {
|
||||||
|
slot.stackCount = (slot.stackCount || 1) + 1;
|
||||||
|
slot.age = Math.min(slot.age, slot.lifetime * 0.3); // продлеваем
|
||||||
|
this._draw(slot, slot.baseText, slot.preset, slot.fontSize, slot.comic, slot.stackCount);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const slot = this._acquire();
|
||||||
|
if (!slot) return;
|
||||||
|
|
||||||
|
// Тип floater'а.
|
||||||
|
let kind = 'damage';
|
||||||
|
if (opts.isCrit) kind = 'crit';
|
||||||
|
else if (opts.isHeal) kind = 'heal';
|
||||||
|
else if (opts.isMana) kind = 'mana';
|
||||||
|
else if (opts.isMiss) kind = 'miss';
|
||||||
|
const preset = PRESETS[kind];
|
||||||
|
const color = opts.color || preset.color;
|
||||||
|
const stroke = opts.strokeColor || preset.stroke;
|
||||||
|
|
||||||
|
let fontSize = Number.isFinite(opts.fontSize) ? opts.fontSize : 60;
|
||||||
|
let floatHeight = Number.isFinite(opts.floatHeight) ? opts.floatHeight : 2;
|
||||||
|
let lifetime = Number.isFinite(opts.lifetime) ? opts.lifetime : 0.9;
|
||||||
|
const randomOffset = Number.isFinite(opts.randomOffset) ? opts.randomOffset : (opts.isCrit ? 0.5 : 0.25);
|
||||||
|
|
||||||
|
// Текст: число с минусом (урон) или как есть (строка / heal с плюсом).
|
||||||
|
let baseText;
|
||||||
|
if (typeof value === 'string') baseText = value;
|
||||||
|
else if (opts.isHeal) baseText = '+' + value;
|
||||||
|
else if (opts.isMiss) baseText = String(value);
|
||||||
|
else baseText = '-' + Math.abs(value);
|
||||||
|
|
||||||
|
if (opts.isCrit) { fontSize = Math.round(fontSize * 1.4); floatHeight *= 1.2; }
|
||||||
|
|
||||||
|
slot.active = true;
|
||||||
|
slot.age = 0;
|
||||||
|
slot.lifetime = lifetime;
|
||||||
|
slot.floatHeight = floatHeight;
|
||||||
|
slot.isCrit = !!opts.isCrit;
|
||||||
|
slot.color = color; slot.stroke = stroke;
|
||||||
|
slot.preset = { color, stroke };
|
||||||
|
slot.fontSize = fontSize;
|
||||||
|
slot.comic = !!opts.comicStyle;
|
||||||
|
slot.baseText = baseText;
|
||||||
|
slot.stackCount = 1;
|
||||||
|
slot.stackKey = opts.stackKey || null;
|
||||||
|
|
||||||
|
const rx = (Math.random() - 0.5) * 2 * randomOffset;
|
||||||
|
const rz = (Math.random() - 0.5) * 2 * randomOffset;
|
||||||
|
slot.startX = position.x + rx;
|
||||||
|
slot.startY = position.y + (Number.isFinite(opts.yOffset) ? opts.yOffset : 1.5);
|
||||||
|
slot.startZ = position.z + rz;
|
||||||
|
slot.plane.position.set(slot.startX, slot.startY, slot.startZ);
|
||||||
|
slot.plane.scaling.set(1, 1, 1);
|
||||||
|
slot.plane.setEnabled(true);
|
||||||
|
|
||||||
|
this._draw(slot, baseText, slot.preset, fontSize, slot.comic, 1);
|
||||||
|
|
||||||
|
if (opts.stackKey) this._stacks.set(opts.stackKey, slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
_draw(slot, baseText, preset, fontSize, comic, stackCount) {
|
||||||
|
const ctx = slot.tex.getContext();
|
||||||
|
ctx.clearRect(0, 0, TEX_W, TEX_H);
|
||||||
|
|
||||||
|
let text = baseText;
|
||||||
|
if (comic) {
|
||||||
|
const num = parseInt(String(baseText).replace(/[^0-9]/g, ''), 10) || 0;
|
||||||
|
if (slot.isCrit) text = 'POW!';
|
||||||
|
else if (num > 100) text = 'KAPOW!';
|
||||||
|
else if (num > 50) text = 'BAM!';
|
||||||
|
}
|
||||||
|
if (stackCount > 1) text = baseText + ' ×' + stackCount;
|
||||||
|
|
||||||
|
const fs = comic ? Math.round(fontSize * 1.1) : fontSize;
|
||||||
|
ctx.font = `900 ${fs}px ${comic ? 'Bangers, Impact, sans-serif' : 'Inter, Arial, sans-serif'}`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
|
||||||
|
// Комикс-фон: жёлтая звезда-вспышка.
|
||||||
|
if (comic) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(TEX_W / 2, TEX_H / 2);
|
||||||
|
ctx.fillStyle = 'rgba(255,210,60,0.9)';
|
||||||
|
ctx.beginPath();
|
||||||
|
const spikes = 10, outer = 130, inner = 70;
|
||||||
|
for (let i = 0; i < spikes * 2; i++) {
|
||||||
|
const r = i % 2 === 0 ? outer : inner;
|
||||||
|
const a = (i / (spikes * 2)) * Math.PI * 2 - Math.PI / 2;
|
||||||
|
const px = Math.cos(a) * r, py = Math.sin(a) * r * 0.55;
|
||||||
|
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
|
||||||
|
}
|
||||||
|
ctx.closePath(); ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обводка + текст.
|
||||||
|
ctx.strokeStyle = comic ? '#000' : preset.stroke;
|
||||||
|
ctx.lineWidth = Math.max(6, fs * 0.16);
|
||||||
|
ctx.strokeText(text, TEX_W / 2, TEX_H / 2);
|
||||||
|
ctx.fillStyle = comic ? '#d22' : preset.color;
|
||||||
|
ctx.fillText(text, TEX_W / 2, TEX_H / 2);
|
||||||
|
|
||||||
|
slot.tex.update(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Вызывать каждый кадр (анимация подъёма + fade + покачивание + crit-pop). */
|
||||||
|
tick(dt) {
|
||||||
|
if (!this._initialized) return;
|
||||||
|
for (const slot of this.pool) {
|
||||||
|
if (!slot.active) continue;
|
||||||
|
slot.age += dt;
|
||||||
|
const t = slot.age / slot.lifetime;
|
||||||
|
if (t >= 1) {
|
||||||
|
slot.active = false;
|
||||||
|
slot.plane.setEnabled(false);
|
||||||
|
if (slot.stackKey && this._stacks.get(slot.stackKey) === slot) this._stacks.delete(slot.stackKey);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const ease = easeOutQuad(t);
|
||||||
|
slot.plane.position.y = slot.startY + slot.floatHeight * ease;
|
||||||
|
slot.plane.position.x = slot.startX + Math.sin(slot.age * 5) * 0.12;
|
||||||
|
|
||||||
|
// fade-in 0.12 / hold / fade-out 0.25
|
||||||
|
let alpha = 1;
|
||||||
|
if (t < 0.12) alpha = t / 0.12;
|
||||||
|
else if (t > 0.75) alpha = 1 - (t - 0.75) / 0.25;
|
||||||
|
slot.mat.alpha = Math.max(0, Math.min(1, alpha));
|
||||||
|
|
||||||
|
// crit pop: scale 1 → 1.3 → 1 в первые 0.4 жизни
|
||||||
|
if (slot.isCrit) {
|
||||||
|
let s = 1;
|
||||||
|
if (t < 0.2) s = 1 + (t / 0.2) * 0.3;
|
||||||
|
else if (t < 0.4) s = 1.3 - ((t - 0.2) / 0.2) * 0.3;
|
||||||
|
slot.plane.scaling.set(s, s, s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
for (const slot of this.pool) {
|
||||||
|
try { slot.plane.dispose(); slot.tex.dispose(); slot.mat.dispose(); } catch (e) {}
|
||||||
|
}
|
||||||
|
this.pool = []; this._stacks.clear(); this._initialized = false;
|
||||||
|
}
|
||||||
|
resetRuntime() {
|
||||||
|
for (const slot of this.pool) { slot.active = false; slot.plane?.setEnabled(false); }
|
||||||
|
this._stacks.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -215,22 +215,21 @@ export class FolderManager {
|
|||||||
* Возвращает количество повёрнутых примитивов.
|
* Возвращает количество повёрнутых примитивов.
|
||||||
*/
|
*/
|
||||||
rotateFolderY(folderId, angle, pivot) {
|
rotateFolderY(folderId, angle, pivot) {
|
||||||
if (!this.primitiveManager || !pivot) return 0;
|
if (!pivot) return 0;
|
||||||
const cosA = Math.cos(angle);
|
const cosA = Math.cos(angle);
|
||||||
const sinA = Math.sin(angle);
|
const sinA = Math.sin(angle);
|
||||||
let count = 0;
|
let count = 0;
|
||||||
|
// Примитивы папки.
|
||||||
|
if (this.primitiveManager) {
|
||||||
for (const data of this.primitiveManager.instances.values()) {
|
for (const data of this.primitiveManager.instances.values()) {
|
||||||
if (data.folderId !== folderId) continue;
|
if (data.folderId !== folderId) continue;
|
||||||
// Поворачиваем позицию вокруг pivot.y axis (XZ-плоскость)
|
|
||||||
const dx = data.x - pivot.x;
|
const dx = data.x - pivot.x;
|
||||||
const dz = data.z - pivot.z;
|
const dz = data.z - pivot.z;
|
||||||
const newX = pivot.x + dx * cosA - dz * sinA;
|
data.x = pivot.x + dx * cosA - dz * sinA;
|
||||||
const newZ = pivot.z + dx * sinA + dz * cosA;
|
data.z = pivot.z + dx * sinA + dz * cosA;
|
||||||
data.x = newX;
|
|
||||||
data.z = newZ;
|
|
||||||
data.rotationY = (data.rotationY || 0) + angle;
|
data.rotationY = (data.rotationY || 0) + angle;
|
||||||
if (data.mesh) {
|
if (data.mesh) {
|
||||||
data.mesh.position.set(newX, data.y, newZ);
|
data.mesh.position.set(data.x, data.y, data.z);
|
||||||
data.mesh.rotation.y = data.rotationY;
|
data.mesh.rotation.y = data.rotationY;
|
||||||
if (data._worldMatrixFrozen) {
|
if (data._worldMatrixFrozen) {
|
||||||
try { data.mesh.unfreezeWorldMatrix?.(); } catch (e) {}
|
try { data.mesh.unfreezeWorldMatrix?.(); } catch (e) {}
|
||||||
@ -239,6 +238,26 @@ export class FolderManager {
|
|||||||
}
|
}
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// Модели папки (позиция вокруг pivot + собственный поворот).
|
||||||
|
if (this.modelManager) {
|
||||||
|
const Vec = this.modelManager._Vector3 || null;
|
||||||
|
for (const data of this.modelManager.instances.values()) {
|
||||||
|
if (data.folderId !== folderId) continue;
|
||||||
|
const dx = data.x - pivot.x;
|
||||||
|
const dz = data.z - pivot.z;
|
||||||
|
data.x = pivot.x + dx * cosA - dz * sinA;
|
||||||
|
data.z = pivot.z + dx * sinA + dz * cosA;
|
||||||
|
data.rotationY = (data.rotationY || 0) + angle;
|
||||||
|
const root = data.rootMesh || data.rootNode;
|
||||||
|
if (root) {
|
||||||
|
if (data._worldMatrixFrozen) { try { root.unfreezeWorldMatrix?.(); } catch (e) {} data._worldMatrixFrozen = false; }
|
||||||
|
root.position.set(data.x, data.y, data.z);
|
||||||
|
if (root.rotation) root.rotation.y = data.rotationY;
|
||||||
|
}
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
this._notifyChange();
|
this._notifyChange();
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
@ -259,6 +278,99 @@ export class FolderManager {
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Собрать все объекты папки (рекурсивно по подпапкам) с их мешами.
|
||||||
|
* Возвращает { models:[{data}], primitives:[{data}], blocks:[mesh],
|
||||||
|
* meshes:[meshes для подсветки], center:{x,y,z}, count }.
|
||||||
|
*/
|
||||||
|
getFolderObjects(folderId) {
|
||||||
|
const out = { models: [], primitives: [], blocks: [], meshes: [] };
|
||||||
|
const ids = new Set([folderId]);
|
||||||
|
// Собираем id всех вложенных подпапок.
|
||||||
|
let added = true;
|
||||||
|
while (added) {
|
||||||
|
added = false;
|
||||||
|
for (const f of this.getAll()) {
|
||||||
|
if (f.parentId != null && ids.has(f.parentId) && !ids.has(f.id)) {
|
||||||
|
ids.add(f.id); added = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.modelManager) {
|
||||||
|
for (const d of this.modelManager.instances.values()) {
|
||||||
|
if (ids.has(d.folderId)) {
|
||||||
|
out.models.push(d);
|
||||||
|
const root = d.rootMesh || d.rootNode;
|
||||||
|
if (root) out.meshes.push(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.primitiveManager) {
|
||||||
|
for (const d of this.primitiveManager.instances.values()) {
|
||||||
|
if (ids.has(d.folderId)) {
|
||||||
|
out.primitives.push(d);
|
||||||
|
if (d.mesh) out.meshes.push(d.mesh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.blockManager) {
|
||||||
|
for (const mesh of this.blockManager.blocks.values()) {
|
||||||
|
if (ids.has(mesh.metadata?.folderId)) out.blocks.push(mesh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Центр группы (по позициям моделей/примитивов).
|
||||||
|
let sx = 0, sy = 0, sz = 0, n = 0;
|
||||||
|
for (const d of out.models) { sx += d.x || 0; sy += d.y || 0; sz += d.z || 0; n++; }
|
||||||
|
for (const d of out.primitives) { sx += d.x || 0; sy += d.y || 0; sz += d.z || 0; n++; }
|
||||||
|
out.center = n > 0 ? { x: sx / n, y: sy / n, z: sz / n } : { x: 0, y: 0, z: 0 };
|
||||||
|
out.count = out.models.length + out.primitives.length + out.blocks.length;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сдвинуть все объекты папки на (dx,dy,dz). */
|
||||||
|
moveFolderBy(folderId, dx, dy, dz) {
|
||||||
|
const g = this.getFolderObjects(folderId);
|
||||||
|
const apply = (d, mesh) => {
|
||||||
|
d.x = (d.x || 0) + dx; d.y = (d.y || 0) + dy; d.z = (d.z || 0) + dz;
|
||||||
|
if (mesh) {
|
||||||
|
if (d._worldMatrixFrozen) { try { mesh.unfreezeWorldMatrix?.(); } catch (e) {} d._worldMatrixFrozen = false; }
|
||||||
|
mesh.position.set(d.x, d.y, d.z);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const d of g.models) apply(d, d.rootMesh || d.rootNode);
|
||||||
|
for (const d of g.primitives) apply(d, d.mesh);
|
||||||
|
this._notifyChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Масштабировать папку относительно центра pivot на коэффициент factor.
|
||||||
|
* Позиции расходятся/сходятся от центра + размеры примитивов меняются.
|
||||||
|
*/
|
||||||
|
scaleFolder(folderId, factor, pivot) {
|
||||||
|
if (!Number.isFinite(factor) || factor <= 0) return;
|
||||||
|
const g = this.getFolderObjects(folderId);
|
||||||
|
const p = pivot || g.center;
|
||||||
|
const sc = (d, mesh, isPrim) => {
|
||||||
|
d.x = p.x + ((d.x || 0) - p.x) * factor;
|
||||||
|
d.y = p.y + ((d.y || 0) - p.y) * factor;
|
||||||
|
d.z = p.z + ((d.z || 0) - p.z) * factor;
|
||||||
|
if (isPrim) {
|
||||||
|
d.sx = (d.sx || 1) * factor; d.sy = (d.sy || 1) * factor; d.sz = (d.sz || 1) * factor;
|
||||||
|
} else {
|
||||||
|
d.scale = (d.scale || 1) * factor;
|
||||||
|
}
|
||||||
|
if (mesh) {
|
||||||
|
if (d._worldMatrixFrozen) { try { mesh.unfreezeWorldMatrix?.(); } catch (e) {} d._worldMatrixFrozen = false; }
|
||||||
|
mesh.position.set(d.x, d.y, d.z);
|
||||||
|
if (isPrim && mesh.scaling) mesh.scaling.set(d.sx, d.sy, d.sz);
|
||||||
|
else if (mesh.scaling) mesh.scaling.scaleInPlace(factor);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const d of g.models) sc(d, d.rootMesh || d.rootNode, false);
|
||||||
|
for (const d of g.primitives) sc(d, d.mesh, true);
|
||||||
|
this._notifyChange();
|
||||||
|
}
|
||||||
|
|
||||||
/** Найти папку по имени (regex/exact). */
|
/** Найти папку по имени (regex/exact). */
|
||||||
findByName(name) {
|
findByName(name) {
|
||||||
const n = String(name || '').toLowerCase();
|
const n = String(name || '').toLowerCase();
|
||||||
|
|||||||
@ -68,6 +68,22 @@ export class GameRuntime {
|
|||||||
start(scripts) {
|
start(scripts) {
|
||||||
this.stop();
|
this.stop();
|
||||||
this._isRunning = true;
|
this._isRunning = true;
|
||||||
|
this.scripts = scripts || []; // для привязки логов/ошибок к скрипту
|
||||||
|
// Задача 20: мост leaderstats.onChange (main) → globalEvent в worker'ы,
|
||||||
|
// чтобы скриптовые game.leaderstats.onChange и bindToStat срабатывали.
|
||||||
|
try {
|
||||||
|
const ls = this.scene3d?.leaderstats;
|
||||||
|
if (ls && !ls._bridgeBound) {
|
||||||
|
ls._bridgeBound = true;
|
||||||
|
const meId = ls._resolveMe?.();
|
||||||
|
ls.onChange((pid, name, nv, ov) => {
|
||||||
|
for (const sb of this.sandboxes) sb.sendGlobalEvent({
|
||||||
|
type: 'leaderstatsChange', playerId: pid, name, newValue: nv, oldValue: ov,
|
||||||
|
isMe: String(pid) === String(meId),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log('[GameRuntime] start called with scripts:', scripts);
|
console.log('[GameRuntime] start called with scripts:', scripts);
|
||||||
// Phase 6.5: lazy-init физ-мира. Wasm-инициализация асинхронная (~50мс),
|
// Phase 6.5: lazy-init физ-мира. Wasm-инициализация асинхронная (~50мс),
|
||||||
@ -1310,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;
|
||||||
}
|
}
|
||||||
@ -1548,7 +1566,13 @@ export class GameRuntime {
|
|||||||
/** Команда от Worker'а пришла — применяем на сцене. */
|
/** Команда от Worker'а пришла — применяем на сцене. */
|
||||||
_handleCommand(scriptId, cmd, payload) {
|
_handleCommand(scriptId, cmd, payload) {
|
||||||
if (cmd === 'log') {
|
if (cmd === 'log') {
|
||||||
this._log(payload?.level || 'info', payload?.text || '');
|
// Привязываем запись к скрипту-источнику (для ссылки в консоли).
|
||||||
|
let scriptName = null;
|
||||||
|
try {
|
||||||
|
const meta = (this.scripts || []).find(s => s.id === scriptId);
|
||||||
|
scriptName = meta?.name || scriptId;
|
||||||
|
} catch (e) { scriptName = scriptId; }
|
||||||
|
this._log(payload?.level || 'info', payload?.text || '', scriptId, scriptName);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (cmd === 'player.teleport') {
|
if (cmd === 'player.teleport') {
|
||||||
@ -1724,6 +1748,11 @@ export class GameRuntime {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (cmd === 'npc.setAttacking') {
|
||||||
|
this._npcCmd(payload?.ref, (nid) =>
|
||||||
|
this.scene3d?.npcManager?.setAttacking?.(nid, !!payload?.on));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (cmd === 'npc.stop') {
|
if (cmd === 'npc.stop') {
|
||||||
this._npcCmd(payload?.ref, (nid) =>
|
this._npcCmd(payload?.ref, (nid) =>
|
||||||
this.scene3d?.npcManager?.stopNpc(nid));
|
this.scene3d?.npcManager?.stopNpc(nid));
|
||||||
@ -1852,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;
|
||||||
@ -1862,8 +1891,38 @@ 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) — всплывающие цифры урона ===
|
||||||
|
if (cmd === 'fx.damageFloater') {
|
||||||
|
try {
|
||||||
|
let pos = payload?.position;
|
||||||
|
// ref-строка ('player'|'primitive:N'|'model:N') → координаты объекта.
|
||||||
|
if (typeof pos === 'string') {
|
||||||
|
if (pos === 'player') {
|
||||||
|
const pl = this.scene3d?.player;
|
||||||
|
const p = pl ? (pl._pos || pl.position || pl.mesh?.position) : null;
|
||||||
|
pos = p ? { x: p.x, y: p.y, z: p.z } : null;
|
||||||
|
} else {
|
||||||
|
const tgt = this._resolveTweenTarget(pos);
|
||||||
|
pos = tgt ? { x: tgt.data.x || 0, y: tgt.data.y || 0, z: tgt.data.z || 0 } : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pos) this.scene3d?.floaters?.spawn(pos, payload?.value, payload?.opts || {});
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cmd === 'fx.autoMobFloaters') {
|
||||||
|
try {
|
||||||
|
if (this.scene3d?.npcManager) {
|
||||||
|
this.scene3d.npcManager._autoFloater = payload?.enabled
|
||||||
|
? { opts: payload?.opts || {} } : null;
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// === Beam / Trail — лучи и следы (Фаза 5.2) ===
|
// === Beam / Trail — лучи и следы (Фаза 5.2) ===
|
||||||
if (cmd === 'fx.create') {
|
if (cmd === 'fx.create') {
|
||||||
// payload: { kind: 'beam'|'trail', localRef, ... }
|
// payload: { kind: 'beam'|'trail', localRef, ... }
|
||||||
@ -2031,6 +2090,19 @@ export class GameRuntime {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// === Задача 44: drag-drop инвентарь (invUI) ===
|
||||||
|
if (cmd === 'items.define') { try { this.scene3d?.invUI?.defineItem(payload.def); } catch (e) {} return; }
|
||||||
|
if (cmd === 'inv2.add') {
|
||||||
|
try { this.scene3d?.invUI?.add(payload.itemId, payload.count); this.scene3d?.invUI?.mountHotbar(); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cmd === 'inv2.remove') { try { this.scene3d?.invUI?.remove(payload.itemId, payload.count); } catch (e) {} return; }
|
||||||
|
if (cmd === 'inv2.open') { try { this.scene3d?.invUI?.open(); } catch (e) {} return; }
|
||||||
|
if (cmd === 'inv2.close') { try { this.scene3d?.invUI?.close(); } catch (e) {} return; }
|
||||||
|
if (cmd === 'inv2.toggle') { try { this.scene3d?.invUI?.toggle(); } catch (e) {} return; }
|
||||||
|
if (cmd === 'inv2.sort') { try { this.scene3d?.invUI?.sort(payload.by); } catch (e) {} return; }
|
||||||
|
if (cmd === 'inv2.setActive') { try { this.scene3d?.invUI?.setActiveHotbar(payload.i); } catch (e) {} return; }
|
||||||
|
|
||||||
if (cmd === 'inventory.remove') {
|
if (cmd === 'inventory.remove') {
|
||||||
// payload: { modelTypeId? , name? } — убрать первый совпавший слот.
|
// payload: { modelTypeId? , name? } — убрать первый совпавший слот.
|
||||||
const inv = this.scene3d?.inventory;
|
const inv = this.scene3d?.inventory;
|
||||||
@ -2882,13 +2954,85 @@ export class GameRuntime {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// === Небо и атмосфера (задача 16) ===
|
||||||
|
// === Лидерборды и достижения (задача 20) ===
|
||||||
|
if (cmd === 'leaderstats.define') {
|
||||||
|
try { this.scene3d?.leaderstats?.define(payload.name, payload.opts || {}); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cmd === 'leaderstats.set') {
|
||||||
|
try { this.scene3d?.leaderstats?.set(payload.playerId, payload.name, payload.value); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cmd === 'leaderstats.add') {
|
||||||
|
try { this.scene3d?.leaderstats?.add(payload.playerId, payload.name, payload.delta); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cmd === 'achievements.define') {
|
||||||
|
try { this.scene3d?.achievements?.define(payload.list); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cmd === 'achievements.unlock') {
|
||||||
|
try { this.scene3d?.achievements?.unlock(payload.id, payload.playerId); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cmd === 'achievements.bindToStat') {
|
||||||
|
try { this.scene3d?.achievements?.bindToStat(payload.id, payload.statName, payload.cond || {}); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cmd === 'achievements.setButtonVisible') {
|
||||||
|
try { this.scene3d?.achievements?.setButtonVisible(!!payload.visible); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cmd === 'achievements.openPage') {
|
||||||
|
try { this.scene3d?.achievements?.openPage(); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cmd === 'scene.setSkybox') {
|
||||||
|
try { this.scene3d?.skybox?.setSkybox(payload?.opts || {}); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cmd === 'scene.setClouds') {
|
||||||
|
try { this.scene3d?.skybox?.setClouds(payload?.opts || {}); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cmd === 'scene.setFog') {
|
||||||
|
try { this.scene3d?.skybox?.setFog(payload?.opts || {}); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cmd === 'scene.skyboxFadeTo') {
|
||||||
|
try { this.scene3d?.skybox?.fadeTo(payload?.opts || {}, payload?.duration || 2); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cmd === 'scene.skyboxSunDir') {
|
||||||
|
try { this.scene3d?.skybox?.setSunDirection(payload?.dir || {}); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cmd === 'scene.setScale') {
|
||||||
|
try {
|
||||||
|
const k = Number(payload?.scale);
|
||||||
|
if (!Number.isFinite(k) || k < 0) return;
|
||||||
|
const pm = this.scene3d?.primitiveManager;
|
||||||
|
const rid = this._resolvePrimitiveId(payload?.id ?? payload?.ref);
|
||||||
|
const data = (pm && rid != null) ? pm.instances.get(rid) : null;
|
||||||
|
if (data?.mesh) {
|
||||||
|
if (data._worldMatrixFrozen) { try { data.mesh.unfreezeWorldMatrix?.(); } catch (e) {} data._worldMatrixFrozen = false; }
|
||||||
|
data.mesh.scaling.set(k, k, k); // визуальный масштаб от исходного размера
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (cmd === 'scene.setColor') {
|
if (cmd === 'scene.setColor') {
|
||||||
try {
|
try {
|
||||||
const color = payload?.color;
|
const color = payload?.color;
|
||||||
if (typeof color !== 'string') return;
|
if (typeof color !== 'string') return;
|
||||||
// Окрашиваемый блок (studs-block): ref вида 'block:x,y,z' →
|
// Окрашиваемый блок (studs-block): ref вида 'block:x,y,z' →
|
||||||
// меняем per-instance цвет через BlockManager.setBlockColor.
|
// меняем per-instance цвет через BlockManager.setBlockColor.
|
||||||
const ref = payload?.id;
|
// ВАЖНО: obj.color=hex шлёт {ref}, а self.setColor — {id}. Берём оба.
|
||||||
|
const ref = payload?.id ?? payload?.ref;
|
||||||
if (typeof ref === 'string' && ref.startsWith('block:')) {
|
if (typeof ref === 'string' && ref.startsWith('block:')) {
|
||||||
const parts = ref.slice(6).split(',').map(Number);
|
const parts = ref.slice(6).split(',').map(Number);
|
||||||
if (parts.length === 3 && parts.every(Number.isFinite)) {
|
if (parts.length === 3 && parts.every(Number.isFinite)) {
|
||||||
@ -2898,7 +3042,7 @@ export class GameRuntime {
|
|||||||
}
|
}
|
||||||
const pm = this.scene3d?.primitiveManager;
|
const pm = this.scene3d?.primitiveManager;
|
||||||
if (!pm) return;
|
if (!pm) return;
|
||||||
const rid = this._resolvePrimitiveId(payload?.id);
|
const rid = this._resolvePrimitiveId(payload?.id ?? payload?.ref);
|
||||||
const data = rid != null ? pm.instances.get(rid) : null;
|
const data = rid != null ? pm.instances.get(rid) : null;
|
||||||
if (data) {
|
if (data) {
|
||||||
data.color = color;
|
data.color = color;
|
||||||
@ -3532,8 +3676,13 @@ export class GameRuntime {
|
|||||||
}
|
}
|
||||||
if (cmd === 'scene.setVisible') {
|
if (cmd === 'scene.setVisible') {
|
||||||
try {
|
try {
|
||||||
const kind = payload?.kind;
|
let kind = payload?.kind;
|
||||||
const id = payload?.id;
|
let id = payload?.id;
|
||||||
|
// obj.visible=false шлёт {ref:'primitive:N'} без kind/id — парсим ref.
|
||||||
|
if ((kind == null || id == null) && typeof payload?.ref === 'string') {
|
||||||
|
const colon = payload.ref.indexOf(':');
|
||||||
|
if (colon > 0) { kind = payload.ref.slice(0, colon); id = payload.ref.slice(colon + 1); }
|
||||||
|
}
|
||||||
const visible = !!payload?.visible;
|
const visible = !!payload?.visible;
|
||||||
if (id == null) return;
|
if (id == null) return;
|
||||||
if (kind === 'primitive') {
|
if (kind === 'primitive') {
|
||||||
@ -4168,6 +4317,11 @@ export class GameRuntime {
|
|||||||
const id = t.id ?? t.ref;
|
const id = t.id ?? t.ref;
|
||||||
this.scene3d?.primitiveManager?.removeInstance(id);
|
this.scene3d?.primitiveManager?.removeInstance(id);
|
||||||
}
|
}
|
||||||
|
// Снять interact-подсказку удалённого объекта (иначе «E» висит на пустоте).
|
||||||
|
if (t.kind && (t.ref ?? t.id) != null && Array.isArray(this._interactables)) {
|
||||||
|
const ref = t.kind + ':' + (t.ref ?? t.id);
|
||||||
|
this._interactables = this._interactables.filter(it => it.ref !== ref);
|
||||||
|
}
|
||||||
this.scheduleSceneSnapshot();
|
this.scheduleSceneSnapshot();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
@ -4175,9 +4329,9 @@ export class GameRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_log(level, text) {
|
_log(level, text, scriptId = null, scriptName = null) {
|
||||||
if (this._onLog) {
|
if (this._onLog) {
|
||||||
try { this._onLog({ level, text, ts: Date.now() }); } catch (e) { /* ignore */ }
|
try { this._onLog({ level, text, ts: Date.now(), scriptId, scriptName }); } catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -4331,6 +4485,29 @@ export class GameRuntime {
|
|||||||
.then(j => this._saveReply(scriptId, reqId, j.namespaces || {}))
|
.then(j => this._saveReply(scriptId, reqId, j.namespaces || {}))
|
||||||
.catch(() => this._saveReply(scriptId, reqId, {}));
|
.catch(() => this._saveReply(scriptId, reqId, {}));
|
||||||
}
|
}
|
||||||
|
/** Публичный helper для движковых менеджеров (leaderstats/achievements):
|
||||||
|
* сохранить прогресс текущего игрока в БД (storys savegame). */
|
||||||
|
saveProgress(namespace, data) {
|
||||||
|
const url = this._saveBaseUrl(namespace);
|
||||||
|
if (!url) return;
|
||||||
|
try {
|
||||||
|
fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this._economyAuthHeaders(), // JWT игрока (иначе 401)
|
||||||
|
body: JSON.stringify({ data }),
|
||||||
|
}).catch(() => {});
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
/** Загрузить прогресс из БД (cb(data|null)). */
|
||||||
|
loadProgress(namespace, cb) {
|
||||||
|
const url = this._saveBaseUrl(namespace);
|
||||||
|
if (!url) { cb && cb(null); return; }
|
||||||
|
fetch(url, { headers: this._economyAuthHeaders() })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(j => cb && cb(j.data ?? null))
|
||||||
|
.catch(() => cb && cb(null));
|
||||||
|
}
|
||||||
|
|
||||||
_saveSet(payload) {
|
_saveSet(payload) {
|
||||||
const url = this._saveBaseUrl(payload?.namespace);
|
const url = this._saveBaseUrl(payload?.namespace);
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
|
|||||||
953
src/editor/engine/GameplayKits.js
Normal file
953
src/editor/engine/GameplayKits.js
Normal file
@ -0,0 +1,953 @@
|
|||||||
|
/**
|
||||||
|
* GameplayKits — каталог готовых механик для Toolbox (задача 17, фаза T2).
|
||||||
|
*
|
||||||
|
* Каждый kit — это готовый кусок поведения, который автор вставляет одним кликом
|
||||||
|
* из Тулбокса (вкладка «Готовые механики»). При вставке:
|
||||||
|
* - scripts с attachTo:'global' → добавляются как глобальный скрипт игры;
|
||||||
|
* - scripts с attachTo:'on-target' → создаётся примитив-маркер + скрипт на нём;
|
||||||
|
* - prims[] → создаются примитивы на сцене (визуал кита).
|
||||||
|
*
|
||||||
|
* Все киты написаны НАМИ на белом-листе game-API (ScriptSandboxWorker) →
|
||||||
|
* заведомо безопасны, исполняются в существующем sandbox (нет доступа к DOM/fetch).
|
||||||
|
*
|
||||||
|
* Фича-парность: тот же файл копируется в rublox-player/src/engine/ (киты — это
|
||||||
|
* данные-скрипты, исполняются движком плеера так же).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const KIT_CATEGORIES = [
|
||||||
|
{ id: 'all', label: 'Все' },
|
||||||
|
{ id: 'movement', label: 'Движение' },
|
||||||
|
{ id: 'world', label: 'Мир' },
|
||||||
|
{ id: 'ui', label: 'Интерфейс' },
|
||||||
|
{ id: 'fx', label: 'Эффекты' },
|
||||||
|
{ id: 'npc', label: 'NPC и бой' },
|
||||||
|
{ id: 'economy', label: 'Экономика' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const GAMEPLAY_KITS = [
|
||||||
|
{
|
||||||
|
id: 'shift-to-run',
|
||||||
|
name: 'Бег на Shift',
|
||||||
|
desc: 'Игрок ускоряется в 1.8× при удержании Shift и возвращается к обычной скорости при отпускании.',
|
||||||
|
icon: 'zap', category: 'movement',
|
||||||
|
scripts: [{ attachTo: 'global', code:
|
||||||
|
`// Бег на Shift
|
||||||
|
game.onKey('shift', () => game.player.setSpeed(1.8));
|
||||||
|
game.onKeyUp('shift', () => game.player.setSpeed(1.0));` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'double-jump',
|
||||||
|
name: 'Двойной прыжок',
|
||||||
|
desc: 'Разрешает второй прыжок прямо в воздухе. Нажми Space ещё раз во время прыжка.',
|
||||||
|
icon: 'arrow-up', category: 'movement',
|
||||||
|
scripts: [{ attachTo: 'global', code:
|
||||||
|
`// Двойной прыжок: второй прыжок в воздухе по Space
|
||||||
|
game.player.setDoubleJump(true);
|
||||||
|
game.ui.set('dj', 'Двойной прыжок включён! Жми Space в воздухе.', { x: 50, y: 90, anchor: 'bottom', color: '#fff', size: 16 });
|
||||||
|
game.after(5, () => game.ui.set('dj', ''));` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'day-night-cycle',
|
||||||
|
name: 'Смена дня и ночи',
|
||||||
|
desc: 'Небо плавно переключается день → закат → ночь → день по кругу (использует Skybox задачи 16).',
|
||||||
|
icon: 'cloud', category: 'world',
|
||||||
|
scripts: [{ attachTo: 'global', code:
|
||||||
|
`// Авто-цикл дня и ночи
|
||||||
|
const phases = ['clear-summer-day', 'sunset', 'starry-night', 'clear-summer-day'];
|
||||||
|
let i = 0;
|
||||||
|
game.scene.setSkybox({ preset: phases[0] });
|
||||||
|
game.every(8, () => {
|
||||||
|
i = (i + 1) % phases.length;
|
||||||
|
game.scene.skybox.fadeTo({ preset: phases[i] }, 3);
|
||||||
|
});` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'currency-counter',
|
||||||
|
name: 'Счётчик монет',
|
||||||
|
desc: 'Счётчик монет в углу HUD. Другие механики шлют game.broadcast("coins", {add: N}) — счётчик обновляется.',
|
||||||
|
icon: 'circle', category: 'ui',
|
||||||
|
scripts: [{ attachTo: 'global', code:
|
||||||
|
`// Счётчик монет в HUD. Прибавить монеты из любого скрипта:
|
||||||
|
// game.broadcast('coins', { add: 100 });
|
||||||
|
let coins = 0;
|
||||||
|
function show() { game.ui.set('coins', '🪙 ' + coins, { x: 92, y: 6, anchor: 'top', color: '#ffd23a', size: 22 }); }
|
||||||
|
show();
|
||||||
|
game.onMessage('coins', (m) => { coins += (m && m.add) ? m.add : 1; show(); });` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'start-pad',
|
||||||
|
name: 'Стартовая площадка',
|
||||||
|
desc: 'Светящаяся платформа — игрок появляется НА ней в начале игры (задаёт точку старта).',
|
||||||
|
icon: 'flag', category: 'world',
|
||||||
|
prims: [{ type: 'cylinder', x: 0, y: 0.15, z: 0, sx: 3, sy: 0.3, sz: 3, color: '#36d57a', material: 'neon', name: 'Стартовая площадка' }],
|
||||||
|
scripts: [{ attachTo: 'on-target', code:
|
||||||
|
`// Игрок появляется на этой площадке в начале игры.
|
||||||
|
// Небольшая задержка — чтобы позиция объекта и игрок успели проинициализироваться.
|
||||||
|
game.after(0.1, () => {
|
||||||
|
const p = game.self.position;
|
||||||
|
game.player.teleport(p.x, p.y + 1.5, p.z);
|
||||||
|
});` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'checkpoint',
|
||||||
|
name: 'Чекпоинт',
|
||||||
|
desc: 'Светящийся столб-чекпоинт. При касании сохраняет прогресс и показывает уведомление.',
|
||||||
|
icon: 'flag', category: 'world',
|
||||||
|
prims: [{ type: 'cylinder', x: 0, y: 1.5, z: 0, sx: 0.6, sy: 3, sz: 0.6, color: '#4d6bff', material: 'neon', name: 'Чекпоинт' }],
|
||||||
|
scripts: [{ attachTo: 'on-target', code:
|
||||||
|
`// Чекпоинт: касание → сообщение
|
||||||
|
game.self.onInteract(() => {
|
||||||
|
game.ui.set('cp', '✓ Чекпоинт сохранён!', { x: 50, y: 85, anchor: 'bottom', color: '#36d57a', size: 18 });
|
||||||
|
game.after(2, () => game.ui.set('cp', ''));
|
||||||
|
}, { text: 'Активировать', key: 'f', distance: 4 });` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'confetti',
|
||||||
|
name: 'Конфетти',
|
||||||
|
desc: 'Праздничный фонтан конфетти из этого объекта. Кубики разлетаются и падают. Запускается периодически.',
|
||||||
|
icon: 'sparkles', category: 'fx',
|
||||||
|
prims: [{ type: 'sphere', x: 0, y: 1, z: 0, sx: 0.5, sy: 0.5, sz: 0.5, color: '#ff5ab0', material: 'neon', name: 'Конфетти-источник', canCollide: false }],
|
||||||
|
scripts: [{ attachTo: 'on-target', code:
|
||||||
|
`// Конфетти вылетает из ПОЗИЦИИ этого объекта (не из центра сцены).
|
||||||
|
function burst() {
|
||||||
|
const p = game.self.position; // где стоит конфетти-источник
|
||||||
|
for (let k = 0; k < 16; k++) {
|
||||||
|
const col = ['#ff5ab0','#ffd23a','#4d6bff','#36d57a','#ff7a3a'][k % 5];
|
||||||
|
game.scene.spawn('primitive:cube', {
|
||||||
|
x: p.x + (Math.random()-0.5)*0.6,
|
||||||
|
y: p.y + 0.5,
|
||||||
|
z: p.z + (Math.random()-0.5)*0.6,
|
||||||
|
sx: 0.22, sy: 0.22, sz: 0.22, color: col,
|
||||||
|
anchored: false, canCollide: false, lifetime: 2.5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
burst();
|
||||||
|
game.every(3, burst);` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'floating-platform',
|
||||||
|
name: 'Парящая платформа',
|
||||||
|
desc: 'Платформа, которая плавно качается вверх-вниз — для паркура.',
|
||||||
|
icon: 'square', category: 'world',
|
||||||
|
prims: [{ type: 'cube', x: 0, y: 2, z: 0, sx: 4, sy: 0.5, sz: 4, color: '#c8a86a', material: 'matte', name: 'Парящая платформа' }],
|
||||||
|
scripts: [{ attachTo: 'on-target', code:
|
||||||
|
`// Качание платформы вверх-вниз
|
||||||
|
let t = 0; const baseY = 2;
|
||||||
|
game.onTick((dt) => {
|
||||||
|
t += dt;
|
||||||
|
game.self.move(game.self.position.x, baseY + Math.sin(t * 1.5) * 1.2, game.self.position.z);
|
||||||
|
});` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rotating-trap',
|
||||||
|
name: 'Вращающийся объект',
|
||||||
|
desc: 'Объект, который постоянно вращается — препятствие или декор.',
|
||||||
|
icon: 'refresh', category: 'world',
|
||||||
|
prims: [{ type: 'cube', x: 0, y: 1.5, z: 0, sx: 5, sy: 0.4, sz: 0.6, color: '#e0483c', material: 'matte', name: 'Вертушка' }],
|
||||||
|
scripts: [{ attachTo: 'on-target', code:
|
||||||
|
`// Постоянное вращение
|
||||||
|
let a = 0;
|
||||||
|
game.onTick((dt) => { a += dt * 1.5; game.self.rotate(a); });` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'timer-hud',
|
||||||
|
name: 'Таймер забега',
|
||||||
|
desc: 'Секундомер в HUD — считает время с начала игры. Основа для гонок на время.',
|
||||||
|
icon: 'clock', category: 'ui',
|
||||||
|
scripts: [{ attachTo: 'global', code:
|
||||||
|
`// Таймер забега
|
||||||
|
let t = 0;
|
||||||
|
game.every(0.1, () => {
|
||||||
|
t += 0.1;
|
||||||
|
game.ui.set('timer', '⏱ ' + t.toFixed(1) + ' c', { x: 50, y: 6, anchor: 'top', color: '#ffffff', size: 22 });
|
||||||
|
});` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'welcome-message',
|
||||||
|
name: 'Приветствие',
|
||||||
|
desc: 'Показывает приветственное сообщение при входе в игру и убирает через 5 секунд.',
|
||||||
|
icon: 'message', category: 'ui',
|
||||||
|
scripts: [{ attachTo: 'global', code:
|
||||||
|
`// Приветствие
|
||||||
|
game.ui.set('welcome', '👋 Добро пожаловать в игру!', { x: 50, y: 40, anchor: 'center', color: '#ffffff', size: 30 });
|
||||||
|
game.after(5, () => game.ui.set('welcome', ''));` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'loot-crate',
|
||||||
|
name: 'Сундук с лутом',
|
||||||
|
desc: 'Золотой сундук. При взаимодействии «открывается» — даёт награду и сообщение.',
|
||||||
|
icon: 'box', category: 'world',
|
||||||
|
prims: [
|
||||||
|
{ type: 'cube', x: 0, y: 0.6, z: 0, sx: 1.6, sy: 1.2, sz: 1.2, color: '#b5862e', material: 'metal', name: 'Сундук' },
|
||||||
|
{ type: 'cube', x: 0, y: 1.35, z: 0, sx: 1.7, sy: 0.4, sz: 1.3, color: '#d4a843', material: 'metal', name: 'Крышка сундука', canCollide: false },
|
||||||
|
],
|
||||||
|
scripts: [{ attachTo: 'on-target', code:
|
||||||
|
`// Сундук с лутом — даёт 100 монет (через счётчик монет, если он добавлен).
|
||||||
|
let opened = false;
|
||||||
|
game.self.onInteract(() => {
|
||||||
|
if (opened) { game.ui.set('loot', 'Сундук уже открыт.', { x: 50, y: 85, anchor: 'bottom', color: '#bbb', size: 16 }); return; }
|
||||||
|
opened = true;
|
||||||
|
game.broadcast('coins', { add: 100 }); // обновит «Счётчик монет», если он есть
|
||||||
|
game.ui.set('loot', '🎁 Ты получил награду: +100 монет!', { x: 50, y: 85, anchor: 'bottom', color: '#ffd23a', size: 18 });
|
||||||
|
game.after(3, () => game.ui.set('loot', ''));
|
||||||
|
}, { text: 'Открыть сундук', key: 'f', distance: 4 });` }],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== Партия 1 из Вики (киты 13-17) =====
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'trampoline',
|
||||||
|
name: 'Батут (пружина)',
|
||||||
|
desc: 'Яркая платформа-батут — наступи на неё, и игрока подбросит высоко вверх. (Вики: «Прыжок-пружина»)',
|
||||||
|
icon: 'arrow-up', category: 'movement',
|
||||||
|
prims: [{ type: 'cylinder', x: 0, y: 0.3, z: 0, sx: 3, sy: 0.6, sz: 3, color: '#ff3c8e', material: 'neon', name: 'Батут' }],
|
||||||
|
scripts: [{ attachTo: 'on-target', code:
|
||||||
|
`// Батут: касание → подброс игрока вверх.
|
||||||
|
game.self.onTouch(() => {
|
||||||
|
game.player.setVy(20); // вертикальный импульс (как трамплин)
|
||||||
|
});` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'speed-pad',
|
||||||
|
name: 'Лента ускорения',
|
||||||
|
desc: 'Жёлтая плита-ускоритель — наступи, и игрок бежит быстрее несколько секунд. (Вики: «бусты скорости»)',
|
||||||
|
icon: 'zap', category: 'movement',
|
||||||
|
prims: [{ type: 'cube', x: 0, y: 0.1, z: 0, sx: 3, sy: 0.2, sz: 5, color: '#ffd23a', material: 'neon', name: 'Лента ускорения', canCollide: false }],
|
||||||
|
scripts: [{ attachTo: 'on-target', code:
|
||||||
|
`// Лента ускорения: касание → x2 скорости на 3 секунды.
|
||||||
|
let boosting = false;
|
||||||
|
game.self.onTouch(() => {
|
||||||
|
if (boosting) return;
|
||||||
|
boosting = true;
|
||||||
|
game.player.setSpeed(2.0);
|
||||||
|
game.ui.set('boost', '⚡ Ускорение!', { x: 50, y: 80, anchor: 'bottom', color: '#ffd23a', size: 18 });
|
||||||
|
game.after(3, () => { game.player.setSpeed(1.0); game.ui.set('boost', ''); boosting = false; });
|
||||||
|
});` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'teleport-portal',
|
||||||
|
name: 'Портал-телепорт',
|
||||||
|
desc: 'Два синих портала. Вошёл в портал — мгновенно переносишься ко второму. Поставь второй портал где нужно. (Вики: «секретный путь»)',
|
||||||
|
icon: 'sparkles', category: 'movement',
|
||||||
|
prims: [
|
||||||
|
{ type: 'cylinder', x: 0, y: 1.5, z: 0, sx: 0.4, sy: 3, sz: 3, color: '#4d6bff', material: 'neon', name: 'Портал A' },
|
||||||
|
{ type: 'cylinder', x: 8, y: 1.5, z: 0, sx: 0.4, sy: 3, sz: 3, color: '#4dffd6', material: 'neon', name: 'Портал B', canCollide: false },
|
||||||
|
],
|
||||||
|
scripts: [{ attachTo: 'on-target', code:
|
||||||
|
`// Портал A: касание → телепорт к «Портал B» (ищем его по имени в момент входа).
|
||||||
|
// Передвигай «Портал B» куда угодно — телепорт всегда попадёт к нему.
|
||||||
|
let cd = false;
|
||||||
|
game.self.onTouch(() => {
|
||||||
|
if (cd) return;
|
||||||
|
const b = game.scene.findOne('Портал B'); // ищем второй портал
|
||||||
|
if (!b || !b.position) return;
|
||||||
|
cd = true;
|
||||||
|
game.player.teleport(b.position.x, b.position.y + 1, b.position.z);
|
||||||
|
game.after(1.2, () => { cd = false; }); // защита от повторного входа
|
||||||
|
});` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'disappearing-platform',
|
||||||
|
name: 'Исчезающая платформа',
|
||||||
|
desc: 'Платформа пропадает под ногами через секунду после касания и возвращается через 3с. (Вики: «Не упади», «Падающий мост»)',
|
||||||
|
icon: 'square', category: 'world',
|
||||||
|
prims: [{ type: 'cube', x: 0, y: 0.25, z: 0, sx: 4, sy: 0.5, sz: 4, color: '#e06a3c', material: 'matte', name: 'Исчезающая платформа' }],
|
||||||
|
scripts: [{ attachTo: 'on-target', code:
|
||||||
|
`// Исчезающая платформа: наступил → через 1с пропадает, через 3с возвращается.
|
||||||
|
let busy = false;
|
||||||
|
game.self.onTouch(() => {
|
||||||
|
if (busy) return; busy = true;
|
||||||
|
game.after(1, () => { game.self.setVisible(false); game.self.setCollide(false); });
|
||||||
|
game.after(3, () => { game.self.setVisible(true); game.self.setCollide(true); busy = false; });
|
||||||
|
});` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'door-button',
|
||||||
|
name: 'Дверь по кнопке E',
|
||||||
|
desc: 'Красивая дверь с рамкой, филёнками и ручкой. Нажми E — плавно распахивается вокруг петли. (Вики: «Кнопка-открывашка»)',
|
||||||
|
icon: 'door', category: 'world',
|
||||||
|
// Полотно двери — ПЕРВЫЙ prim (на нём скрипт). Остальные части — рамка
|
||||||
|
// (неподвижный косяк) + декор полотна. Всё уходит в одну папку.
|
||||||
|
prims: [
|
||||||
|
// 0) Полотно двери (тёмное дерево).
|
||||||
|
{ type: 'cube', x: 0, y: 2, z: 0, sx: 0.25, sy: 3.8, sz: 2.6, color: '#6b4423', material: 'matte', name: 'Полотно двери' },
|
||||||
|
// Филёнки (светлее, чуть выступают) — верхняя и нижняя.
|
||||||
|
{ type: 'cube', x: 0.16, y: 2.9, z: 0, sx: 0.08, sy: 1.2, sz: 1.9, color: '#8a5a2e', material: 'matte', canCollide: false, name: 'Филёнка верх' },
|
||||||
|
{ type: 'cube', x: 0.16, y: 1.2, z: 0, sx: 0.08, sy: 1.4, sz: 1.9, color: '#8a5a2e', material: 'matte', canCollide: false, name: 'Филёнка низ' },
|
||||||
|
// Ручка (золотая).
|
||||||
|
{ type: 'sphere', x: 0.28, y: 2, z: 0.95, sx: 0.3, sy: 0.3, sz: 0.3, color: '#e0b030', material: 'metal', canCollide: false, name: 'Ручка' },
|
||||||
|
// Косяк-рамка (неподвижная) — две стойки + перемычка.
|
||||||
|
{ type: 'cube', x: 0, y: 2, z: -1.55, sx: 0.4, sy: 4.2, sz: 0.4, color: '#4a3018', material: 'matte', name: 'Косяк левый' },
|
||||||
|
{ type: 'cube', x: 0, y: 2, z: 1.55, sx: 0.4, sy: 4.2, sz: 0.4, color: '#4a3018', material: 'matte', name: 'Косяк правый' },
|
||||||
|
{ type: 'cube', x: 0, y: 4.1, z: 0, sx: 0.4, sy: 0.4, sz: 3.5, color: '#4a3018', material: 'matte', name: 'Перемычка' },
|
||||||
|
],
|
||||||
|
scripts: [{ attachTo: 'on-target', code:
|
||||||
|
`// Дверь по E: ПЛАВНО поворачивается вокруг петли (левой грани).
|
||||||
|
// Скрипт на полотне двери. Филёнки/ручка двигаются вместе как часть полотна?
|
||||||
|
// Нет — они отдельные примитивы, поэтому анимируем только полотно (game.self),
|
||||||
|
// а декор оставляем — на низкой толщине двери смотрится цельно.
|
||||||
|
const p0 = game.self.position; // центр полотна (закрытое положение)
|
||||||
|
const halfW = 1.3; // половина ширины полотна по Z (sz=2.6)
|
||||||
|
const hingeX = p0.x;
|
||||||
|
const hingeZ = p0.z - halfW; // петля у левого края
|
||||||
|
let open = false;
|
||||||
|
let cur = 0, target = 0; // текущий и целевой угол (радианы)
|
||||||
|
const SPEED = Math.PI; // рад/сек → ~0.5с на 90°
|
||||||
|
|
||||||
|
// Декор полотна — двигаем вместе с дверью. Запоминаем их СМЕЩЕНИЕ относительно
|
||||||
|
// центра полотна (в закрытом виде), чтобы вращать вокруг той же петли.
|
||||||
|
const decorNames = ['Филёнка верх', 'Филёнка низ', 'Ручка'];
|
||||||
|
const decor = [];
|
||||||
|
for (const nm of decorNames) {
|
||||||
|
const o = game.scene.findOne(nm);
|
||||||
|
if (o && o.position) {
|
||||||
|
decor.push({ obj: o, dx: o.position.x - p0.x, dy: o.position.y - p0.y, dz: o.position.z - p0.z });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поворот локального вектора (lx,lz) вокруг оси Y на angle — согласованно с
|
||||||
|
// тем, как Babylon поворачивает меш при rotation.y=angle (левосторонняя СК).
|
||||||
|
function rotY(lx, lz, a) {
|
||||||
|
const s = Math.sin(a), c = Math.cos(a);
|
||||||
|
return { x: lx * c + lz * s, z: -lx * s + lz * c };
|
||||||
|
}
|
||||||
|
function place(angle) {
|
||||||
|
// Полотно: центр = петля + повёрнутый локальный вектор (0, +halfW).
|
||||||
|
const pc = rotY(0, halfW, angle);
|
||||||
|
const cx = hingeX + pc.x;
|
||||||
|
const cz = hingeZ + pc.z;
|
||||||
|
game.self.move(cx, p0.y, cz);
|
||||||
|
game.self.rotate(angle);
|
||||||
|
// Декор: центр полотна + повёрнутое локальное смещение (той же формулой).
|
||||||
|
for (const d of decor) {
|
||||||
|
const r = rotY(d.dx, d.dz, angle);
|
||||||
|
d.obj.move(cx + r.x, p0.y + d.dy, cz + r.z);
|
||||||
|
if (d.obj.rotate) d.obj.rotate(angle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Один постоянный тик плавно ведёт cur → target.
|
||||||
|
game.onTick((dt) => {
|
||||||
|
if (cur === target) return;
|
||||||
|
const step = SPEED * dt;
|
||||||
|
if (Math.abs(target - cur) <= step) cur = target;
|
||||||
|
else cur += Math.sign(target - cur) * step;
|
||||||
|
place(cur);
|
||||||
|
});
|
||||||
|
game.self.onInteract(() => {
|
||||||
|
open = !open;
|
||||||
|
target = open ? Math.PI / 2 : 0; // 90° открыта / 0° закрыта
|
||||||
|
}, { text: 'Открыть / закрыть', key: 'e', distance: 5 });` }],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== Партия 2 из Вики (киты 18-22) =====
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'color-tiles',
|
||||||
|
name: 'Цветная плитка',
|
||||||
|
desc: 'Наступи на плитку — она меняет цвет на случайный. (Вики: «Цветные плитки»)',
|
||||||
|
icon: 'palette', category: 'world',
|
||||||
|
prims: [{ type: 'cube', x: 0, y: 0.1, z: 0, sx: 2.5, sy: 0.2, sz: 2.5, color: '#cfd8dc', material: 'matte', name: 'Цветная плитка' }],
|
||||||
|
scripts: [{ attachTo: 'on-target', code:
|
||||||
|
`// Плитка меняет цвет при касании.
|
||||||
|
const colors = ['#ff5ab0','#ffd23a','#4d6bff','#36d57a','#ff7a3a','#a05aff'];
|
||||||
|
let i = 0;
|
||||||
|
game.self.onTouch(() => {
|
||||||
|
i = (i + 1) % colors.length;
|
||||||
|
game.self.setColor(colors[i]);
|
||||||
|
});` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lava-floor',
|
||||||
|
name: 'Лава (урон по касанию)',
|
||||||
|
desc: 'Раскалённая плита: наступишь — теряешь здоровье каждую секунду, пока стоишь. (Вики: «Лава-пол»)',
|
||||||
|
icon: 'lava', category: 'world',
|
||||||
|
prims: [{ type: 'cube', x: 0, y: 0.1, z: 0, sx: 5, sy: 0.2, sz: 5, color: '#ff4422', material: 'neon', name: 'Лава' }],
|
||||||
|
scripts: [{ attachTo: 'on-target', code:
|
||||||
|
`// Лава: пока игрок на плите — урон каждую секунду.
|
||||||
|
let onLava = false, timer = null;
|
||||||
|
game.self.onTouch(() => {
|
||||||
|
if (onLava) return; onLava = true;
|
||||||
|
const tick = () => { if (!onLava) return; game.player.damage(15);
|
||||||
|
game.ui.set('lava', '🔥 Горячо! -15 HP', { x: 50, y: 80, anchor: 'bottom', color: '#ff6644', size: 18 });
|
||||||
|
timer = game.after(1, tick); };
|
||||||
|
tick();
|
||||||
|
});
|
||||||
|
game.self.onUntouch(() => { onLava = false; if (timer) game.cancel(timer); game.ui.set('lava', ''); });` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'elevator',
|
||||||
|
name: 'Лифт',
|
||||||
|
desc: 'Платформа-лифт сама ездит вверх-вниз между двумя этажами. Встань на неё и катайся. (Вики: «Лифт»)',
|
||||||
|
icon: 'elevator', category: 'world',
|
||||||
|
prims: [{ type: 'cube', x: 0, y: 0.5, z: 0, sx: 4, sy: 0.5, sz: 4, color: '#7a8a9a', material: 'metal', name: 'Лифт' }],
|
||||||
|
scripts: [{ attachTo: 'on-target', code:
|
||||||
|
`// Лифт: плавно ездит между нижней и верхней высотой.
|
||||||
|
const p0 = game.self.position;
|
||||||
|
const lowY = p0.y, highY = p0.y + 8;
|
||||||
|
let t = 0;
|
||||||
|
game.onTick((dt) => {
|
||||||
|
t += dt;
|
||||||
|
// Синусоида 0..1 с паузами на концах (период ~8с).
|
||||||
|
const k = (Math.sin(t * 0.5 - Math.PI/2) + 1) / 2;
|
||||||
|
game.self.move(p0.x, lowY + (highY - lowY) * k, p0.z);
|
||||||
|
});` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'finish-line',
|
||||||
|
name: 'Финиш (победа)',
|
||||||
|
desc: 'Финишная плита: дойди до неё — на экране «ПОБЕДА!» и управление блокируется. (Вики: «Беги к финишу»)',
|
||||||
|
icon: 'flag', category: 'ui',
|
||||||
|
prims: [{ type: 'cube', x: 0, y: 0.15, z: 0, sx: 4, sy: 0.3, sz: 2, color: '#ffd23a', material: 'neon', name: 'Финиш', canCollide: false }],
|
||||||
|
scripts: [{ attachTo: 'on-target', code:
|
||||||
|
`// Финиш: касание → экран победы.
|
||||||
|
let done = false;
|
||||||
|
game.self.onTouch(() => {
|
||||||
|
if (done) return; done = true;
|
||||||
|
game.ui.set('win', '🏆 ПОБЕДА!', { x: 50, y: 42, anchor: 'center', color: '#ffd23a', size: 48 });
|
||||||
|
game.ui.set('winsub', 'Ты дошёл до финиша!', { x: 50, y: 54, anchor: 'center', color: '#fff', size: 22 });
|
||||||
|
game.player.setInputBlocked(true);
|
||||||
|
});` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sound-tile',
|
||||||
|
name: 'Звуковая плитка',
|
||||||
|
desc: 'Наступи на плитку — играет звук. Из таких можно собрать мелодию. (Вики: «Эхо-комната»)',
|
||||||
|
icon: 'sound', category: 'fx',
|
||||||
|
prims: [{ type: 'cube', x: 0, y: 0.1, z: 0, sx: 2, sy: 0.2, sz: 2, color: '#6f8bff', material: 'neon', name: 'Звуковая плитка' }],
|
||||||
|
scripts: [{ attachTo: 'on-target', code:
|
||||||
|
`// Плитка играет звук при касании + подсвечивается.
|
||||||
|
let cd = false;
|
||||||
|
game.self.onTouch(() => {
|
||||||
|
if (cd) return; cd = true;
|
||||||
|
game.sound.play('coin');
|
||||||
|
game.self.setColor('#ffffff');
|
||||||
|
game.after(0.25, () => { game.self.setColor('#6f8bff'); cd = false; });
|
||||||
|
});` }],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== Партия 3 из Вики (остальные механики) =====
|
||||||
|
|
||||||
|
// --- Мир ---
|
||||||
|
{
|
||||||
|
id: 'damage-zone',
|
||||||
|
name: 'Зона опасности',
|
||||||
|
desc: 'Невидимая зона: пока игрок внутри — теряет здоровье. (Вики: «Зона опасности»)',
|
||||||
|
icon: 'warning', category: 'world',
|
||||||
|
prims: [{ type: 'cube', x: 0, y: 2, z: 0, sx: 6, sy: 4, sz: 6, color: '#ff3344', material: 'glass', canCollide: false, name: 'Зона опасности' }],
|
||||||
|
scripts: [{ attachTo: 'on-target', code:
|
||||||
|
`// Зона урона: внутри — 10 HP/сек.
|
||||||
|
let inside = false, t = null;
|
||||||
|
game.self.onTouch(() => { if (inside) return; inside = true;
|
||||||
|
const tick = () => { if (!inside) return; game.player.damage(10);
|
||||||
|
game.ui.set('dz', '☠ Опасно! -10 HP', { x:50, y:78, anchor:'bottom', color:'#ff5555', size:18 });
|
||||||
|
t = game.after(1, tick); }; tick(); });
|
||||||
|
game.self.onUntouch(() => { inside = false; if (t) game.cancel(t); game.ui.set('dz',''); });` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'spikes-trap',
|
||||||
|
name: 'Шипы-ловушка',
|
||||||
|
desc: 'Ряд острых шипов: наступишь — мгновенный урон. (Вики: «Полоса препятствий»)',
|
||||||
|
icon: 'warning', category: 'world',
|
||||||
|
prims: [
|
||||||
|
{ type: 'cube', x: 0, y: 0.1, z: 0, sx: 4, sy: 0.2, sz: 1.5, color: '#555', material: 'metal', name: 'Основание шипов' },
|
||||||
|
{ type: 'cone', x: -1.2, y: 0.7, z: 0, sx: 0.6, sy: 1.2, sz: 0.6, color: '#cccccc', material: 'metal', canCollide: false, name: 'Шип 1' },
|
||||||
|
{ type: 'cone', x: 0, y: 0.7, z: 0, sx: 0.6, sy: 1.2, sz: 0.6, color: '#cccccc', material: 'metal', canCollide: false, name: 'Шип 2' },
|
||||||
|
{ type: 'cone', x: 1.2, y: 0.7, z: 0, sx: 0.6, sy: 1.2, sz: 0.6, color: '#cccccc', material: 'metal', canCollide: false, name: 'Шип 3' },
|
||||||
|
],
|
||||||
|
scripts: [{ attachTo: 'on-target', code:
|
||||||
|
`// Шипы: касание → урон + отброс.
|
||||||
|
let cd = false;
|
||||||
|
game.self.onTouch(() => { if (cd) return; cd = true;
|
||||||
|
game.player.damage(34);
|
||||||
|
game.ui.set('sp', '🗡 Ой! -34 HP', { x:50, y:78, anchor:'bottom', color:'#ff5555', size:18 });
|
||||||
|
game.after(1.2, () => { game.ui.set('sp',''); cd = false; }); });` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'traffic-light',
|
||||||
|
name: 'Светофор',
|
||||||
|
desc: 'Светофор переключает красный/жёлтый/зелёный по таймеру. (Вики: «Светофор»)',
|
||||||
|
icon: 'light', category: 'world',
|
||||||
|
prims: [
|
||||||
|
{ type: 'cube', x: 0, y: 3, z: 0, sx: 1.2, sy: 4, sz: 1.2, color: '#2a2a2a', material: 'matte', name: 'Корпус светофора' },
|
||||||
|
{ type: 'sphere', x: 0, y: 4.2, z: 0.6, sx: 0.8, sy: 0.8, sz: 0.4, color: '#5a0000', material: 'neon', canCollide: false, name: 'Красный' },
|
||||||
|
{ type: 'sphere', x: 0, y: 3.3, z: 0.6, sx: 0.8, sy: 0.8, sz: 0.4, color: '#5a5a00', material: 'neon', canCollide: false, name: 'Жёлтый' },
|
||||||
|
{ type: 'sphere', x: 0, y: 2.4, z: 0.6, sx: 0.8, sy: 0.8, sz: 0.4, color: '#005a00', material: 'neon', canCollide: false, name: 'Зелёный' },
|
||||||
|
],
|
||||||
|
scripts: [{ attachTo: 'on-target', code:
|
||||||
|
`// Светофор: переключение фаз каждые 2 сек.
|
||||||
|
const R = game.scene.findOne('Красный'), Y = game.scene.findOne('Жёлтый'), G = game.scene.findOne('Зелёный');
|
||||||
|
const phases = [['#ff0000','#5a5a00','#005a00'], ['#5a0000','#ffcc00','#005a00'], ['#5a0000','#5a5a00','#00ff00']];
|
||||||
|
let p = 0;
|
||||||
|
function show(){ const c = phases[p]; if(R) R.color = c[0]; if(Y) Y.color = c[1]; if(G) G.color = c[2]; }
|
||||||
|
show();
|
||||||
|
game.every(2, () => { p = (p+1) % phases.length; show(); });` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'harvest-plant',
|
||||||
|
name: 'Грядка с урожаем',
|
||||||
|
desc: 'Растение растёт, по клику собираешь урожай (+10 монет) и оно вырастает заново. (Вики: «Сбор урожая»)',
|
||||||
|
icon: 'plant', category: 'world',
|
||||||
|
prims: [
|
||||||
|
{ type: 'cube', x: 0, y: 0.2, z: 0, sx: 2, sy: 0.4, sz: 2, color: '#6b4a2e', material: 'matte', name: 'Грядка' },
|
||||||
|
{ type: 'sphere', x: 0, y: 0.8, z: 0, sx: 1, sy: 1, sz: 1, color: '#3f9a48', material: 'matte', name: 'Урожай' },
|
||||||
|
],
|
||||||
|
scripts: [{ attachTo: 'on-target', code:
|
||||||
|
`// Грядка: собрал урожай → он исчезает, растёт заново (цвет красный→зелёный),
|
||||||
|
// при полном размере снова созрел и его можно собрать.
|
||||||
|
const plant = game.scene.findOne('Урожай');
|
||||||
|
let ripe = true; // созрел ли (можно собирать)
|
||||||
|
let growth = 1; // 0..1 — степень роста
|
||||||
|
// Плавная интерполяция цвета красный(незрелый)→зелёный(спелый).
|
||||||
|
function colorAt(g) {
|
||||||
|
const r = Math.round(0xb0 + (0x3f - 0xb0) * g);
|
||||||
|
const gr = Math.round(0x40 + (0x9a - 0x40) * g);
|
||||||
|
const b = Math.round(0x2e + (0x48 - 0x2e) * g);
|
||||||
|
return '#' + [r,gr,b].map(v => v.toString(16).padStart(2,'0')).join('');
|
||||||
|
}
|
||||||
|
game.self.onInteract(() => {
|
||||||
|
if (!ripe) { game.ui.set('h','Ещё не созрело...', {x:50,y:80,anchor:'bottom',color:'#bbb',size:16}); return; }
|
||||||
|
ripe = false;
|
||||||
|
growth = 0;
|
||||||
|
game.broadcast('coins', { add: 10 });
|
||||||
|
if (plant) { plant.scale = 0.01; plant.color = colorAt(0); }
|
||||||
|
game.ui.set('h','🌾 Собрано! +10 монет', {x:50,y:80,anchor:'bottom',color:'#ffd23a',size:18});
|
||||||
|
game.after(2, () => game.ui.set('h',''));
|
||||||
|
}, { text: 'Собрать урожай', key: 'e', distance: 4 });
|
||||||
|
// Рост: за ~5 секунд от 0 до 1.
|
||||||
|
game.onTick((dt) => {
|
||||||
|
if (ripe || !plant) return;
|
||||||
|
growth = Math.min(1, growth + dt / 5);
|
||||||
|
plant.scale = Math.max(0.05, growth);
|
||||||
|
plant.color = colorAt(growth);
|
||||||
|
if (growth >= 1) ripe = true;
|
||||||
|
});` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'falling-objects',
|
||||||
|
name: 'Падающие предметы',
|
||||||
|
desc: 'Из этой точки с неба сыплются кубики — лови их или уворачивайся. (Вики: «Поймай падающее»)',
|
||||||
|
icon: 'box', category: 'world',
|
||||||
|
prims: [{ type: 'sphere', x: 0, y: 8, z: 0, sx: 0.6, sy: 0.6, sz: 0.6, color: '#4d6bff', material: 'neon', canCollide: false, name: 'Тучка-источник' }],
|
||||||
|
scripts: [{ attachTo: 'on-target', code:
|
||||||
|
`// Каждые 1.5с роняет куб из позиции источника.
|
||||||
|
const p = game.self.position;
|
||||||
|
game.every(1.5, () => {
|
||||||
|
game.scene.spawn('primitive:cube', {
|
||||||
|
x: p.x + (Math.random()-0.5)*6, y: p.y, z: p.z + (Math.random()-0.5)*6,
|
||||||
|
sx: 0.6, sy: 0.6, sz: 0.6, color: '#ffaa33', anchored: false, canCollide: true, lifetime: 6 });
|
||||||
|
});` }],
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Интерфейс ---
|
||||||
|
{
|
||||||
|
id: 'score-counter',
|
||||||
|
name: 'Счётчик очков',
|
||||||
|
desc: 'Счёт очков в HUD. Другие механики шлют game.broadcast("score",{add:N}). (Вики: «Собери монетки»)',
|
||||||
|
icon: 'star', category: 'ui',
|
||||||
|
scripts: [{ attachTo: 'global', code:
|
||||||
|
`// Счётчик очков. Прибавить: game.broadcast('score', { add: 1 });
|
||||||
|
let score = 0;
|
||||||
|
function show(){ game.ui.set('score', '⭐ ' + score, { x:8, y:6, anchor:'top', color:'#ffd23a', size:22 }); }
|
||||||
|
show();
|
||||||
|
game.onMessage('score', (m) => { score += (m && m.add) ? m.add : 1; show(); });` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'leaderboard',
|
||||||
|
name: 'Таблица лидеров',
|
||||||
|
desc: 'Лидерборд справа-сверху (Очки/Время). Растёт от монет и очков других механик. Сохраняется в БД между сессиями. (Задача 20)',
|
||||||
|
icon: 'trophy', category: 'ui',
|
||||||
|
scripts: [{ attachTo: 'global', code:
|
||||||
|
`// Таблица лидеров: столбцы «Очки» (primary) и «Время».
|
||||||
|
game.leaderstats.define('Очки', { initial: 0, format: 'number', icon: '⭐', color: '#ffd23a', primary: true });
|
||||||
|
game.leaderstats.define('Время', { initial: 0, format: 'time', icon: '⏱', color: '#7fd0ff' });
|
||||||
|
// Время идёт само.
|
||||||
|
game.every(1, () => game.leaderstats.me.add('Время', 1));
|
||||||
|
// Любая механика, шлющая broadcast('score',{add}) или ('coins',{add}),
|
||||||
|
// автоматически добавляет очки в таблицу.
|
||||||
|
game.onMessage('score', (m) => game.leaderstats.me.add('Очки', (m && m.add) ? m.add : 1));
|
||||||
|
game.onMessage('coins', (m) => game.leaderstats.me.add('Очки', (m && m.add) ? m.add : 1));` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hp-bar',
|
||||||
|
name: 'Полоска здоровья',
|
||||||
|
desc: 'Показывает HP игрока в углу экрана, обновляется при уроне/лечении. (Вики: «Лава-пол»)',
|
||||||
|
icon: 'warning', category: 'ui',
|
||||||
|
scripts: [{ attachTo: 'global', code:
|
||||||
|
`// Своя полоска HP. Сначала прячем стандартную, чтобы не дублировалась.
|
||||||
|
game.hud.setHpVisible(false);
|
||||||
|
function show(){ const hp = Math.max(0, Math.round(game.player.hp));
|
||||||
|
game.ui.set('hp', '❤ ' + hp + ' / 100', { x:50, y:94, anchor:'bottom', color: hp>30?'#36d57a':'#ff4444', size:22 }); }
|
||||||
|
show();
|
||||||
|
game.every(0.2, show);` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hide-default-hp',
|
||||||
|
name: 'Скрыть стандартный HUD HP',
|
||||||
|
desc: 'Прячет стандартную полосу здоровья сверху — чтобы показать свою. (Свойство игрока game.hud.setHpVisible)',
|
||||||
|
icon: 'warning', category: 'ui',
|
||||||
|
scripts: [{ attachTo: 'global', code:
|
||||||
|
`// Скрыть стандартную полосу здоровья игрока.
|
||||||
|
game.hud.setHpVisible(false);` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'code-door',
|
||||||
|
name: 'Дверь по коду',
|
||||||
|
desc: 'Красивая дверь с кодовой панелью: подойди — появится поле ввода, введи код (1234) — откроется. (Вики: «Дверь по коду»)',
|
||||||
|
icon: 'keypad', category: 'world',
|
||||||
|
// Красивая дверь (полотно + рамка) + кодовая панель на стене.
|
||||||
|
prims: [
|
||||||
|
{ type: 'cube', x: 0, y: 2, z: 0, sx: 0.25, sy: 3.8, sz: 2.6, color: '#5a6478', material: 'metal', name: 'Полотно двери-код' },
|
||||||
|
{ type: 'cube', x: 0.16, y: 2.6, z: 0, sx: 0.06, sy: 0.9, sz: 1.8, color: '#79879a', material: 'metal', canCollide: false, name: 'Панель двери' },
|
||||||
|
{ type: 'cube', x: 0.16, y: 1.3, z: 0, sx: 0.06, sy: 0.9, sz: 1.8, color: '#79879a', material: 'metal', canCollide: false, name: 'Панель двери низ' },
|
||||||
|
{ type: 'cube', x: 0.3, y: 2, z: 0.95, sx: 0.15, sy: 0.6, sz: 0.5, color: '#ffd23a', material: 'neon', canCollide: false, name: 'Кодовая панель' },
|
||||||
|
{ type: 'cube', x: 0, y: 2, z: -1.55, sx: 0.4, sy: 4.2, sz: 0.4, color: '#3a4250', material: 'metal', name: 'Косяк левый' },
|
||||||
|
{ type: 'cube', x: 0, y: 2, z: 1.55, sx: 0.4, sy: 4.2, sz: 0.4, color: '#3a4250', material: 'metal', name: 'Косяк правый' },
|
||||||
|
{ type: 'cube', x: 0, y: 4.1, z: 0, sx: 0.4, sy: 0.4, sz: 3.5, color: '#3a4250', material: 'metal', name: 'Перемычка' },
|
||||||
|
],
|
||||||
|
scripts: [{ attachTo: 'on-target', code:
|
||||||
|
`// Дверь по коду 1234. Поле ввода появляется ТОЛЬКО рядом. Верный код →
|
||||||
|
// дверь ПЛАВНО распахивается вокруг петли (вместе с панелями).
|
||||||
|
const CODE = '1234';
|
||||||
|
const p0 = game.self.position;
|
||||||
|
const halfW = 1.3; // полуширина полотна (sz=2.6)
|
||||||
|
const hingeX = p0.x, hingeZ = p0.z - halfW;
|
||||||
|
const RADIUS = 6;
|
||||||
|
let opened = false, near = false, cur = 0, target = 0;
|
||||||
|
const SPEED = Math.PI; // ~0.5с на 90°
|
||||||
|
|
||||||
|
// Декор полотна — двигается вместе с дверью.
|
||||||
|
const decorNames = ['Панель двери', 'Панель двери низ', 'Кодовая панель'];
|
||||||
|
const decor = [];
|
||||||
|
for (const nm of decorNames) {
|
||||||
|
const o = game.scene.findOne(nm);
|
||||||
|
if (o && o.position) decor.push({ obj:o, dx:o.position.x-p0.x, dy:o.position.y-p0.y, dz:o.position.z-p0.z });
|
||||||
|
}
|
||||||
|
function rotY(lx, lz, a){ const s=Math.sin(a),c=Math.cos(a); return { x: lx*c+lz*s, z: -lx*s+lz*c }; }
|
||||||
|
function place(a){
|
||||||
|
const pc = rotY(0, halfW, a);
|
||||||
|
const cx = hingeX + pc.x, cz = hingeZ + pc.z;
|
||||||
|
game.self.move(cx, p0.y, cz); game.self.rotate(a);
|
||||||
|
for (const d of decor){ const r = rotY(d.dx, d.dz, a); d.obj.move(cx+r.x, p0.y+d.dy, cz+r.z); if (d.obj.rotate) d.obj.rotate(a); }
|
||||||
|
}
|
||||||
|
game.onTick((dt) => {
|
||||||
|
if (cur !== target){ const st=SPEED*dt; cur = Math.abs(target-cur)<=st ? target : cur+Math.sign(target-cur)*st; place(cur); }
|
||||||
|
const pl = game.player.position;
|
||||||
|
const d = Math.sqrt((pl.x-p0.x)**2 + (pl.z-p0.z)**2);
|
||||||
|
if (opened){
|
||||||
|
// Дверь открыта: подсказка «E закрыть» только когда игрок рядом.
|
||||||
|
if (d < RADIUS && !near){ near = true; game.ui.set('codehint','Нажми E чтобы закрыть дверь', {x:50,y:80,anchor:'bottom',color:'#fff',size:16}); }
|
||||||
|
else if (d >= RADIUS && near){ near = false; game.ui.set('codehint','',{}); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Дверь закрыта: поле ввода кода по дистанции.
|
||||||
|
if (d < RADIUS && !near){ near = true;
|
||||||
|
game.gui.create('textbox', { id:'codein', x:50, y:86, w:24, h:8, anchor:'center', placeholder:'Код...', textSize:20 });
|
||||||
|
game.ui.set('codehint', '🔢 Введи код двери (1234) и нажми Enter', {x:50,y:78,anchor:'bottom',color:'#fff',size:16}); }
|
||||||
|
else if (d >= RADIUS && near){ near = false; game.gui.remove('codein'); game.ui.set('codehint','',{}); }
|
||||||
|
});
|
||||||
|
game.gui.onSubmit('codein', (text) => {
|
||||||
|
if (opened) return;
|
||||||
|
if (String(text).trim() === CODE){
|
||||||
|
opened = true; near = false; target = Math.PI/2; // плавно распахнуть
|
||||||
|
game.gui.remove('codein');
|
||||||
|
game.ui.set('codehint','✓ Открыто!', {x:50,y:78,anchor:'bottom',color:'#36d57a',size:18});
|
||||||
|
game.after(2, () => game.ui.set('codehint','',{}));
|
||||||
|
} else game.ui.set('codehint','✗ Неверный код', {x:50,y:78,anchor:'bottom',color:'#ff5555',size:18});
|
||||||
|
});
|
||||||
|
// Закрыть дверь по E (только если открыта и игрок рядом).
|
||||||
|
game.onKey('e', () => {
|
||||||
|
if (!opened) return;
|
||||||
|
const pl = game.player.position;
|
||||||
|
if (Math.sqrt((pl.x-p0.x)**2 + (pl.z-p0.z)**2) >= RADIUS) return;
|
||||||
|
opened = false; near = false; target = 0;
|
||||||
|
game.ui.set('codehint','🔒 Дверь закрыта.', {x:50,y:80,anchor:'bottom',color:'#fff',size:16});
|
||||||
|
game.after(1.5, () => game.ui.set('codehint','',{}));
|
||||||
|
});` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'name-label',
|
||||||
|
name: 'Метка с именем',
|
||||||
|
desc: 'Над объектом висит табличка с текстом (имя/HP). (Вики: «Имена над врагами»)',
|
||||||
|
icon: 'tag', category: 'ui',
|
||||||
|
prims: [{ type: 'cube', x: 0, y: 1, z: 0, sx: 1.5, sy: 2, sz: 1.5, color: '#c83030', material: 'matte', name: 'Объект с меткой' }],
|
||||||
|
scripts: [{ attachTo: 'on-target', code:
|
||||||
|
`// Метка-табличка над объектом.
|
||||||
|
game.self.setLabel('Враг ❤ 100', { color: '#ffffff', bg: '#c83030' });` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'countdown',
|
||||||
|
name: 'Обратный отсчёт',
|
||||||
|
desc: 'Таймер обратного отсчёта в HUD. По нулю — событие (тут просто сообщение). (Вики: «продержись N секунд»)',
|
||||||
|
icon: 'clock', category: 'ui',
|
||||||
|
scripts: [{ attachTo: 'global', code:
|
||||||
|
`// Обратный отсчёт 30 секунд.
|
||||||
|
let left = 30;
|
||||||
|
game.ui.set('cd', '⏳ ' + left, { x:50, y:6, anchor:'top', color:'#fff', size:26 });
|
||||||
|
const id = game.every(1, () => {
|
||||||
|
left--; game.ui.set('cd', '⏳ ' + left, { x:50, y:6, anchor:'top', color: left<=5?'#ff4444':'#fff', size:26 });
|
||||||
|
if (left <= 0) { game.cancel(id); game.ui.set('cd', '⏰ Время вышло!', { x:50, y:42, anchor:'center', color:'#ff4444', size:40 }); }
|
||||||
|
});` }],
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Эффекты ---
|
||||||
|
{
|
||||||
|
id: 'fire-emitter',
|
||||||
|
name: 'Костёр (огонь)',
|
||||||
|
desc: 'Источник частиц огня — горит постоянно. (Палитра эффектов)',
|
||||||
|
icon: 'sparkles', category: 'fx',
|
||||||
|
prims: [{ type: 'cylinder', x: 0, y: 0.2, z: 0, sx: 1.2, sy: 0.4, sz: 1.2, color: '#3a2a1a', material: 'matte', name: 'Костёр' }],
|
||||||
|
scripts: [{ attachTo: 'on-target', code:
|
||||||
|
`// Постоянный огонь из точки костра.
|
||||||
|
const p = game.self.position;
|
||||||
|
function fire(){ game.scene.spawnParticles('fire', { x:p.x, y:p.y+0.5, z:p.z }, { duration: 1.5, count: 40 }); }
|
||||||
|
fire(); game.every(1.2, fire);` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'magnet-coins',
|
||||||
|
name: 'Магнит монет',
|
||||||
|
desc: 'Монета сама летит к игроку, когда он подходит близко. (Вики: «Магнит монет»)',
|
||||||
|
icon: 'circle', category: 'fx',
|
||||||
|
prims: [{ type: 'cylinder', x: 0, y: 1, z: 0, sx: 0.8, sy: 0.2, sz: 0.8, color: '#ffd23a', material: 'metal', name: 'Монета' }],
|
||||||
|
scripts: [{ attachTo: 'on-target', code:
|
||||||
|
`// Монета летит к игроку, если он ближе 6 м; коснулся — +1 монета.
|
||||||
|
let taken = false;
|
||||||
|
game.onTick(() => {
|
||||||
|
if (taken) return;
|
||||||
|
const me = game.self.position, pl = game.player.position;
|
||||||
|
const dx = pl.x-me.x, dy = pl.y-me.y, dz = pl.z-me.z;
|
||||||
|
const d = Math.sqrt(dx*dx+dy*dy+dz*dz);
|
||||||
|
if (d < 1.2) { taken = true; game.broadcast('coins', { add: 1 }); game.self.setVisible(false); return; }
|
||||||
|
if (d < 6) { game.self.move(me.x+dx*0.12, me.y+dy*0.12, me.z+dz*0.12); }
|
||||||
|
});` }],
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- NPC и бой ---
|
||||||
|
{
|
||||||
|
id: 'npc-chaser',
|
||||||
|
name: 'NPC-преследователь',
|
||||||
|
desc: 'Враг бежит за игроком по всему уровню. (Вики: «Преследователь»)',
|
||||||
|
icon: 'chase', category: 'npc',
|
||||||
|
scripts: [{ attachTo: 'global', code:
|
||||||
|
`// Спавним NPC, который преследует игрока.
|
||||||
|
const enemy = game.scene.spawnNpc('skin_roblox-noob', { x: 8, z: 8, name: 'Охотник', speed: 4 });
|
||||||
|
if (enemy && enemy.follow) enemy.follow('player');` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'npc-trader',
|
||||||
|
name: 'Торговец (NPC)',
|
||||||
|
desc: 'NPC-персонаж торговец: подойди, нажми E — открывается диалог. (Вики: «Торговец»)',
|
||||||
|
icon: 'trader', category: 'npc',
|
||||||
|
// Невидимый prim-триггер держит onInteract; рядом спавнится NPC-персонаж.
|
||||||
|
prims: [{ type: 'cube', x: 0, y: 1.5, z: 0, sx: 2, sy: 3, sz: 2, color: '#3a6ea5', material: 'matte', visible: false, canCollide: false, name: 'Зона торговца' }],
|
||||||
|
scripts: [{ attachTo: 'on-target', code:
|
||||||
|
`// Торговец — настоящий NPC-персонаж. Триггер (этот объект) держит диалог по E.
|
||||||
|
const p = game.self.position;
|
||||||
|
const npc = game.scene.spawnNpc('skin_roblox-noob', { x: p.x, z: p.z, name: 'Торговец Боб' });
|
||||||
|
game.self.onInteract(() => {
|
||||||
|
game.modal.dialog('Торговец Боб', [
|
||||||
|
'Привет, путник! Заходи за товарами.',
|
||||||
|
'У меня лучшие мечи во всём королевстве!',
|
||||||
|
'Возвращайся, когда накопишь монет.',
|
||||||
|
]);
|
||||||
|
}, { text: 'Поговорить', key: 'e', distance: 4 });` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'shooting-target',
|
||||||
|
name: 'Мишень для стрельбы',
|
||||||
|
desc: 'Кликни по мишени — +10 очков, мишень исчезает и появляется снова. (Вики: «Тир»)',
|
||||||
|
icon: 'crosshair', category: 'npc',
|
||||||
|
prims: [
|
||||||
|
{ type: 'cylinder', x: 0, y: 2, z: 0, sx: 0.3, sy: 2, sz: 2, color: '#ffffff', material: 'matte', name: 'Мишень' },
|
||||||
|
{ type: 'cylinder', x: 0.2, y: 2, z: 0, sx: 0.1, sy: 1.2, sz: 1.2, color: '#ff3333', material: 'matte', canCollide: false, name: 'Кольцо мишени' },
|
||||||
|
],
|
||||||
|
scripts: [{ attachTo: 'on-target', code:
|
||||||
|
`// Мишень: клик → +10 очков, прячется на 1.5с.
|
||||||
|
let active = true;
|
||||||
|
game.self.onClick(() => {
|
||||||
|
if (!active) return; active = false;
|
||||||
|
game.broadcast('score', { add: 10 });
|
||||||
|
game.self.setVisible(false);
|
||||||
|
game.ui.set('hit', '🎯 +10!', { x:50, y:75, anchor:'bottom', color:'#36d57a', size:20 });
|
||||||
|
game.after(1.5, () => { game.self.setVisible(true); active = true; game.ui.set('hit',''); });
|
||||||
|
});` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'enemy-hp',
|
||||||
|
name: 'Враг с HP',
|
||||||
|
desc: 'Враг-персонаж: преследует игрока, бьёт при касании. Над головой — полоска здоровья. (Вики: «босс», «имена над врагами»)',
|
||||||
|
icon: 'boss', category: 'npc',
|
||||||
|
// Невидимый триггер-якорь; рядом спавнится NPC-враг.
|
||||||
|
prims: [{ type: 'cube', x: 0, y: 1, z: 0, sx: 1, sy: 2, sz: 1, color: '#7a2030', material: 'matte', visible: false, canCollide: false, name: 'Якорь врага' }],
|
||||||
|
scripts: [{ attachTo: 'on-target', code:
|
||||||
|
`// Враг-персонаж: преследует игрока, бьёт с анимацией удара при сближении.
|
||||||
|
const p = game.self.position;
|
||||||
|
const enemy = game.scene.spawnNpc('skin_retro-zombie', { x: p.x, z: p.z, name: 'Враг', hp: 100, speed: 3.5 });
|
||||||
|
if (enemy && enemy.follow) enemy.follow('player');
|
||||||
|
let cd = 0, atk = false;
|
||||||
|
game.onTick((dt) => {
|
||||||
|
if (!enemy || !enemy.position) return;
|
||||||
|
cd -= dt;
|
||||||
|
const pl = game.player.position, e = enemy.position;
|
||||||
|
const d = Math.sqrt((pl.x-e.x)**2 + (pl.z-e.z)**2);
|
||||||
|
const inRange = d < 3.5;
|
||||||
|
if (inRange !== atk) { atk = inRange; enemy.setAttacking && enemy.setAttacking(inRange); }
|
||||||
|
if (inRange && cd <= 0) { game.player.damage(10); cd = 1; } // удар раз в секунду
|
||||||
|
});` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'enemy-wave',
|
||||||
|
name: 'Волна врагов',
|
||||||
|
desc: 'Спавнер: выпускает врагов волнами по таймеру. (Вики: «Выживание от волн», tower defense)',
|
||||||
|
icon: 'zombie', category: 'npc',
|
||||||
|
prims: [{ type: 'cylinder', x: 0, y: 0.15, z: 0, sx: 2, sy: 0.3, sz: 2, color: '#7a2030', material: 'neon', name: 'Портал врагов' }],
|
||||||
|
scripts: [{ attachTo: 'on-target', code:
|
||||||
|
`// Каждые 5с спавнит 2 врагов из портала, они идут к игроку И бьют при касании.
|
||||||
|
const p = game.self.position;
|
||||||
|
const enemies = [];
|
||||||
|
function wave(){
|
||||||
|
for (let i=0;i<2;i++){ const e = game.scene.spawnNpc('skin_retro-zombie', { x:p.x+(Math.random()-0.5)*2, z:p.z+(Math.random()-0.5)*2, name:'Враг', hp:60, speed:3 });
|
||||||
|
if (e){ if (e.follow) e.follow('player'); enemies.push({ npc:e, cd:0 }); } }
|
||||||
|
}
|
||||||
|
game.after(2, wave); game.every(5, wave);
|
||||||
|
// Урон + анимация удара при сближении (у каждого врага свой кулдаун).
|
||||||
|
game.onTick((dt) => {
|
||||||
|
const pl = game.player.position;
|
||||||
|
for (const en of enemies){
|
||||||
|
if (!en.npc || !en.npc.position) continue;
|
||||||
|
en.cd -= dt;
|
||||||
|
const e = en.npc.position;
|
||||||
|
const d = Math.sqrt((pl.x-e.x)**2 + (pl.z-e.z)**2);
|
||||||
|
const inRange = d < 3.5;
|
||||||
|
if (inRange !== en.atk){ en.atk = inRange; en.npc.setAttacking && en.npc.setAttacking(inRange); }
|
||||||
|
if (inRange && en.cd <= 0){ game.player.damage(8); en.cd = 1; }
|
||||||
|
}
|
||||||
|
});` }],
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Экономика ---
|
||||||
|
{
|
||||||
|
id: 'shop-button',
|
||||||
|
name: 'Магазин (кнопка покупки)',
|
||||||
|
desc: 'GUI-кнопка магазина: покупка предмета за 50 монет (если хватает). (Вики: «Магазин»)',
|
||||||
|
icon: 'cart', category: 'economy',
|
||||||
|
scripts: [{ attachTo: 'global', code:
|
||||||
|
`// Кнопка магазина: купить за 50 монет.
|
||||||
|
let coins = 100; // локальный баланс кита (для демо)
|
||||||
|
game.ui.set('bal', '🪙 ' + coins, { x:92, y:6, anchor:'top', color:'#ffd23a', size:22 });
|
||||||
|
game.onMessage('coins', (m) => { coins += (m&&m.add)?m.add:0; game.ui.set('bal','🪙 '+coins,{x:92,y:6,anchor:'top',color:'#ffd23a',size:22}); });
|
||||||
|
game.gui.create('button', { id:'buybtn', x:50, y:90, w:26, h:9, anchor:'center', text:'Купить меч — 50 🪙',
|
||||||
|
bgGradient:{ stops:['#ffe066','#e0a000'], angle:90 }, textColor:'#3a2a00', textSize:18, fontWeight:800, borderRadius:12 });
|
||||||
|
game.gui.onClick('buybtn', () => {
|
||||||
|
if (coins >= 50) { game.broadcast('coins', { add: -50 }); game.ui.set('shopmsg','✓ Куплено!',{x:50,y:80,anchor:'bottom',color:'#36d57a',size:18}); }
|
||||||
|
else game.ui.set('shopmsg','✗ Мало монет',{x:50,y:80,anchor:'bottom',color:'#ff5555',size:18});
|
||||||
|
game.after(2, () => game.ui.set('shopmsg',''));
|
||||||
|
});` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'clicker-button',
|
||||||
|
name: 'Кликер',
|
||||||
|
desc: 'GUI-кнопка: кликай и копи очки. (Вики: «Кликер»)',
|
||||||
|
icon: 'click', category: 'economy',
|
||||||
|
scripts: [{ attachTo: 'global', code:
|
||||||
|
`// Кликер: кнопка по центру, клик → +1 очко.
|
||||||
|
let n = 0;
|
||||||
|
function show(){ game.ui.set('clk', '👆 ' + n, { x:50, y:20, anchor:'center', color:'#fff', size:36 }); }
|
||||||
|
show();
|
||||||
|
game.gui.create('button', { id:'clickbtn', x:50, y:55, w:30, h:14, anchor:'center', text:'КЛИК!',
|
||||||
|
bgGradient:{ stops:['#6f8bff','#3a4ed0'], angle:90 }, textColor:'#fff', textSize:32, fontWeight:900, borderRadius:18,
|
||||||
|
hover:{ scale:1.05 }, active:{ scale:0.94 } });
|
||||||
|
game.gui.onClick('clickbtn', () => { n++; show(); });` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'key-lock',
|
||||||
|
name: 'Ключ и замок',
|
||||||
|
desc: 'Найди золотой ключ, подбери — и дверь рядом плавно откроется по E. Без ключа заперта. (Вики: «Ключ и сундук»)',
|
||||||
|
icon: 'key', category: 'economy',
|
||||||
|
prims: [
|
||||||
|
// Ключ из примитивов: стержень + бородка + кольцо (torus). ПЕРВЫЙ — скрипт на нём.
|
||||||
|
{ type: 'cylinder', x: 0, y: 1, z: 0, sx: 0.12, sy: 1.0, sz: 0.12, color: '#ffd23a', material: 'metal', name: 'Ключ' },
|
||||||
|
{ type: 'torus', x: 0, y: 1.6, z: 0, sx: 0.5, sy: 0.5, sz: 0.5, color: '#ffd23a', material: 'metal', canCollide: false, name: 'Кольцо ключа' },
|
||||||
|
{ type: 'cube', x: 0.18, y: 0.6, z: 0, sx: 0.3, sy: 0.12, sz: 0.12, color: '#ffd23a', material: 'metal', canCollide: false, name: 'Бородка ключа' },
|
||||||
|
{ type: 'cube', x: 0.18, y: 0.4, z: 0, sx: 0.2, sy: 0.12, sz: 0.12, color: '#ffd23a', material: 'metal', canCollide: false, name: 'Бородка ключа 2' },
|
||||||
|
// Красивая дверь (полотно + рамка) на расстоянии.
|
||||||
|
{ type: 'cube', x: 6, y: 2, z: 0, sx: 0.25, sy: 3.8, sz: 2.6, color: '#6b4423', material: 'matte', name: 'Запертая дверь' },
|
||||||
|
{ type: 'cube', x: 6, y: 2, z: -1.55, sx: 0.4, sy: 4.2, sz: 0.4, color: '#4a3018', material: 'matte', name: 'Косяк левый замка' },
|
||||||
|
{ type: 'cube', x: 6, y: 2, z: 1.55, sx: 0.4, sy: 4.2, sz: 0.4, color: '#4a3018', material: 'matte', name: 'Косяк правый замка' },
|
||||||
|
{ type: 'cube', x: 6, y: 4.1, z: 0, sx: 0.4, sy: 0.4, sz: 3.5, color: '#4a3018', material: 'matte', name: 'Перемычка замка' },
|
||||||
|
],
|
||||||
|
scripts: [{ attachTo: 'on-target', code:
|
||||||
|
`// Ключ подбирается касанием. Дверь рядом открывается по E ТОЛЬКО с ключом —
|
||||||
|
// плавный поворот вокруг петли (как дверь по кнопке E).
|
||||||
|
let hasKey = false;
|
||||||
|
const keyParts = ['Ключ','Кольцо ключа','Бородка ключа','Бородка ключа 2'];
|
||||||
|
game.self.onTouch(() => {
|
||||||
|
if (hasKey) return; hasKey = true;
|
||||||
|
for (const nm of keyParts){ const o = game.scene.findOne(nm); if (o) o.visible = false; }
|
||||||
|
game.ui.set('key', '🔑 Ключ найден! Иди к двери (E).', {x:50,y:80,anchor:'bottom',color:'#ffd23a',size:18});
|
||||||
|
});
|
||||||
|
const door = game.scene.findOne('Запертая дверь');
|
||||||
|
if (door && door.onInteract){
|
||||||
|
const dp0 = door.position;
|
||||||
|
const halfW = 1.3, hingeZ = dp0.z - halfW;
|
||||||
|
let open = false, cur = 0, target = 0;
|
||||||
|
function rotY(lx,lz,a){ const s=Math.sin(a),c=Math.cos(a); return {x:lx*c+lz*s, z:-lx*s+lz*c}; }
|
||||||
|
game.onTick((dt) => {
|
||||||
|
if (cur===target) return;
|
||||||
|
const st = Math.PI*dt; cur = Math.abs(target-cur)<=st ? target : cur+Math.sign(target-cur)*st;
|
||||||
|
const pc = rotY(0, halfW, cur);
|
||||||
|
door.move(dp0.x+pc.x, dp0.y, hingeZ+pc.z); if (door.rotate) door.rotate(cur);
|
||||||
|
});
|
||||||
|
door.onInteract(() => {
|
||||||
|
if (!hasKey){ game.ui.set('key','🔒 Заперто. Нужен ключ.',{x:50,y:80,anchor:'bottom',color:'#ff5555',size:18}); return; }
|
||||||
|
open = !open; target = open ? Math.PI/2 : 0;
|
||||||
|
game.ui.set('key', open ? '✓ Дверь открыта!' : '🔒 Дверь закрыта.', {x:50,y:80,anchor:'bottom',color:'#36d57a',size:18});
|
||||||
|
game.after(2, () => game.ui.set('key','',{}));
|
||||||
|
}, { text:'Открыть / закрыть', key:'e', distance:4 });
|
||||||
|
}` }],
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Из готовых игр (g5) ---
|
||||||
|
{
|
||||||
|
id: 'spawn-car',
|
||||||
|
name: 'Машина (сядь за руль)',
|
||||||
|
desc: 'Готовый автомобиль: подойди, держи F — садись за руль, WASD — едь. (Вики: «Такси-симулятор»)',
|
||||||
|
icon: 'car', category: 'npc',
|
||||||
|
scripts: [{ attachTo: 'global', code:
|
||||||
|
`// Спавн машины, на которой можно ездить.
|
||||||
|
game.scene.spawn('vehicle:car', { x: 0, y: 0.5, z: 5, model: 'car-sedan', color: '#c83030',
|
||||||
|
name: 'Авто', params: { maxSpeed: 18, turnSpeed: 1.7, enginePower: 20 } });
|
||||||
|
game.ui.set('carhint', 'Подойди к машине и держи F — за руль!', {x:50,y:90,anchor:'bottom',color:'#fff',size:16});` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cutscene-dialog',
|
||||||
|
name: 'Диалог (кат-сцена)',
|
||||||
|
desc: 'Объект, по взаимодействию показывает диалог по строкам. (Вики: «Тайна старого сундука»)',
|
||||||
|
icon: 'scroll', category: 'npc',
|
||||||
|
prims: [{ type: 'cube', x: 0, y: 0.6, z: 0, sx: 1.4, sy: 1.2, sz: 1, color: '#8a6a3a', material: 'matte', name: 'Рассказчик' }],
|
||||||
|
scripts: [{ attachTo: 'on-target', code:
|
||||||
|
`// Диалог по строкам через готовый game.modal.dialog.
|
||||||
|
const lines = ['Давным-давно здесь стоял замок...', 'Его охранял древний страж.', 'Найди три ключа, чтобы войти!'];
|
||||||
|
game.self.onInteract(() => {
|
||||||
|
game.modal.dialog('Рассказчик', lines);
|
||||||
|
}, { text:'Поговорить', key:'e', distance:4 });` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'guide-arrow',
|
||||||
|
name: '3D-стрелка-указатель',
|
||||||
|
desc: 'Стрелка-подсказка «иди сюда» ведёт игрока к цели. (Вики: «Туториал — собери монетки»)',
|
||||||
|
icon: 'flag', category: 'ui',
|
||||||
|
prims: [{ type: 'cylinder', x: 0, y: 1, z: 0, sx: 1, sy: 2, sz: 1, color: '#ffd23a', material: 'neon', name: 'Цель-указатель' }],
|
||||||
|
scripts: [{ attachTo: 'on-target', code:
|
||||||
|
`// Стрелка от игрока к этому объекту-цели.
|
||||||
|
const arrow = game.fx.pointer({ from: 'player', to: game.self, preset: 'guide' });
|
||||||
|
game.self.onTouch(() => { if (arrow && arrow.remove) arrow.remove();
|
||||||
|
game.ui.set('arr','✓ Дошёл!', {x:50,y:80,anchor:'bottom',color:'#36d57a',size:18}); });` }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Найти кит по id. */
|
||||||
|
export function getKit(id) {
|
||||||
|
return GAMEPLAY_KITS.find(k => k.id === id) || null;
|
||||||
|
}
|
||||||
370
src/editor/engine/InventoryUI.js
Normal file
370
src/editor/engine/InventoryUI.js
Normal file
@ -0,0 +1,370 @@
|
|||||||
|
/**
|
||||||
|
* InventoryUI — drag-drop инвентарь (задача 44): сетка 8×5 + hotbar 9 + стаки +
|
||||||
|
* редкости + ПКМ-меню + tooltip. Самодостаточный DOM-оверлей (как
|
||||||
|
* LoadingScreenOverlay) — крепится к canvas.parentElement, работает в студии и
|
||||||
|
* плеере одинаково.
|
||||||
|
*
|
||||||
|
* Хранит: item-defs (game.items.define), слоты основного инвентаря (GRID),
|
||||||
|
* слоты hotbar (HOTBAR), активный hotbar-слот. Постоянный hotbar внизу HUD;
|
||||||
|
* окно инвентаря по клавише I (toggle).
|
||||||
|
*
|
||||||
|
* API (через game.inventory.* / game.items.*):
|
||||||
|
* game.items.define({id,name,icon,rarity,maxStack,description,value,onUse,tags})
|
||||||
|
* game.inventory.add(itemId, count) / remove / has / count
|
||||||
|
* game.inventory.open() / close() / toggle() / isOpen()
|
||||||
|
* game.inventory.move(from, to) / split(slot, n) / sort(by) / use(slot)
|
||||||
|
* game.inventory.setActiveHotbar(i) / getActiveItem()
|
||||||
|
*
|
||||||
|
* Фича-парность: тот же модуль в rublox-player/src/engine/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const GRID = 40; // 8×5 основной инвентарь
|
||||||
|
const COLS = 8;
|
||||||
|
const HOTBAR = 9;
|
||||||
|
|
||||||
|
const RARITY = {
|
||||||
|
common: { color: '#bbbbbb', label: 'Обычное' },
|
||||||
|
uncommon: { color: '#5cb85c', label: 'Необычное' },
|
||||||
|
rare: { color: '#5bc0de', label: 'Редкое' },
|
||||||
|
epic: { color: '#9b59b6', label: 'Эпическое' },
|
||||||
|
legendary: { color: '#f0ad4e', label: 'Легендарное' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export class InventoryUI {
|
||||||
|
constructor(scene3d) {
|
||||||
|
this.s = scene3d;
|
||||||
|
this.defs = new Map(); // itemId → def
|
||||||
|
this.grid = new Array(GRID).fill(null); // {itemId,count}|null
|
||||||
|
this.hotbar = new Array(HOTBAR).fill(null);
|
||||||
|
this.active = 0;
|
||||||
|
this._open = false;
|
||||||
|
this.root = null; this.hotbarRoot = null; this.tooltip = null; this.ctxMenu = null;
|
||||||
|
this._drag = null; // {from:'grid'|'hotbar', idx}
|
||||||
|
this._onChange = [];
|
||||||
|
this._events = { added: [], removed: [], used: [], slot: [] };
|
||||||
|
this._opts = { allowDrop: true, allowSplit: true, showRarity: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Определения предметов ───────────────────────────────────────────────
|
||||||
|
defineItem(def) {
|
||||||
|
if (!def || typeof def.id !== 'string') return;
|
||||||
|
this.defs.set(def.id, {
|
||||||
|
id: def.id, name: def.name || def.id,
|
||||||
|
icon: def.icon || null, emoji: def.emoji || null,
|
||||||
|
rarity: RARITY[def.rarity] ? def.rarity : 'common',
|
||||||
|
maxStack: Number(def.maxStack) > 0 ? Number(def.maxStack) : 1,
|
||||||
|
description: def.description || '', value: Number(def.value) || 0,
|
||||||
|
tags: Array.isArray(def.tags) ? def.tags : [],
|
||||||
|
onUseEffect: def.onUseEffect || null, // 'heal:50' | 'speed:1.5:5' | null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_def(id) { return this.defs.get(id) || { id, name: id, rarity: 'common', maxStack: 99, emoji: '📦', icon: null, description: '', value: 0, tags: [] }; }
|
||||||
|
|
||||||
|
// ── Операции ────────────────────────────────────────────────────────────
|
||||||
|
add(itemId, count = 1) {
|
||||||
|
const def = this._def(itemId);
|
||||||
|
let left = count;
|
||||||
|
// 1) долить в существующие стаки (сначала hotbar — он на виду, потом grid)
|
||||||
|
const fill = (arr) => {
|
||||||
|
for (let i = 0; i < arr.length && left > 0; i++) {
|
||||||
|
const s = arr[i];
|
||||||
|
if (s && s.itemId === itemId && s.count < def.maxStack) {
|
||||||
|
const room = def.maxStack - s.count;
|
||||||
|
const take = Math.min(room, left);
|
||||||
|
s.count += take; left -= take;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fill(this.hotbar); fill(this.grid);
|
||||||
|
// 2) в пустые слоты (сначала hotbar — собранное видно сразу, потом grid)
|
||||||
|
const place = (arr) => {
|
||||||
|
for (let i = 0; i < arr.length && left > 0; i++) {
|
||||||
|
if (!arr[i]) { const take = Math.min(def.maxStack, left); arr[i] = { itemId, count: take }; left -= take; }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
place(this.hotbar); place(this.grid);
|
||||||
|
const added = count - left;
|
||||||
|
if (added > 0) { this._emit('added', { itemId, count: added }); this._changed(); }
|
||||||
|
return { added, overflow: left };
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(itemId, count = 1) {
|
||||||
|
let left = count;
|
||||||
|
const drain = (arr) => {
|
||||||
|
for (let i = arr.length - 1; i >= 0 && left > 0; i--) {
|
||||||
|
const s = arr[i];
|
||||||
|
if (s && s.itemId === itemId) {
|
||||||
|
const take = Math.min(s.count, left);
|
||||||
|
s.count -= take; left -= take;
|
||||||
|
if (s.count <= 0) arr[i] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
drain(this.hotbar); drain(this.grid);
|
||||||
|
const removed = count - left;
|
||||||
|
if (removed > 0) { this._emit('removed', { itemId, count: removed }); this._changed(); }
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
count(itemId) {
|
||||||
|
let n = 0;
|
||||||
|
for (const s of this.grid) if (s && s.itemId === itemId) n += s.count;
|
||||||
|
for (const s of this.hotbar) if (s && s.itemId === itemId) n += s.count;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
has(itemId, n = 1) { return this.count(itemId) >= n; }
|
||||||
|
|
||||||
|
/** slot-ref: число 0..39 = grid; строка 'h0'..'h8' = hotbar. */
|
||||||
|
_arrIdx(ref) {
|
||||||
|
if (typeof ref === 'string' && ref[0] === 'h') return { arr: this.hotbar, idx: parseInt(ref.slice(1), 10) };
|
||||||
|
return { arr: this.grid, idx: Number(ref) };
|
||||||
|
}
|
||||||
|
move(from, to) {
|
||||||
|
const a = this._arrIdx(from), b = this._arrIdx(to);
|
||||||
|
if (!a.arr || !b.arr || a.idx == null || b.idx == null) return;
|
||||||
|
if (a.arr === b.arr && a.idx === b.idx) return;
|
||||||
|
const src = a.arr[a.idx], dst = b.arr[b.idx];
|
||||||
|
// merge одинаковых стаков
|
||||||
|
if (src && dst && src.itemId === dst.itemId) {
|
||||||
|
const def = this._def(src.itemId);
|
||||||
|
const room = def.maxStack - dst.count;
|
||||||
|
if (room > 0) {
|
||||||
|
const take = Math.min(room, src.count);
|
||||||
|
dst.count += take; src.count -= take;
|
||||||
|
if (src.count <= 0) a.arr[a.idx] = null;
|
||||||
|
this._changed(); return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// swap
|
||||||
|
a.arr[a.idx] = dst; b.arr[b.idx] = src;
|
||||||
|
this._changed();
|
||||||
|
}
|
||||||
|
split(ref, n) {
|
||||||
|
if (!this._opts.allowSplit) return;
|
||||||
|
const { arr, idx } = this._arrIdx(ref);
|
||||||
|
const s = arr[idx]; if (!s || s.count <= 1) return;
|
||||||
|
const take = Math.max(1, Math.min(s.count - 1, n || Math.floor(s.count / 2)));
|
||||||
|
const empty = this.grid.indexOf(null);
|
||||||
|
if (empty < 0) return;
|
||||||
|
s.count -= take; this.grid[empty] = { itemId: s.itemId, count: take };
|
||||||
|
this._changed();
|
||||||
|
}
|
||||||
|
sort(by = 'rarity') {
|
||||||
|
const order = { legendary: 0, epic: 1, rare: 2, uncommon: 3, common: 4 };
|
||||||
|
const all = this.grid.filter(Boolean);
|
||||||
|
all.sort((x, y) => {
|
||||||
|
const dx = this._def(x.itemId), dy = this._def(y.itemId);
|
||||||
|
if (by === 'rarity') return (order[dx.rarity] - order[dy.rarity]) || dx.name.localeCompare(dy.name);
|
||||||
|
if (by === 'name') return dx.name.localeCompare(dy.name);
|
||||||
|
return dx.id.localeCompare(dy.id);
|
||||||
|
});
|
||||||
|
this.grid = all.concat(new Array(GRID - all.length).fill(null));
|
||||||
|
this._changed();
|
||||||
|
}
|
||||||
|
use(ref) {
|
||||||
|
const { arr, idx } = this._arrIdx(ref);
|
||||||
|
const s = arr[idx]; if (!s) return;
|
||||||
|
const def = this._def(s.itemId);
|
||||||
|
let consume = false;
|
||||||
|
if (def.onUseEffect) {
|
||||||
|
const [eff, a, b] = String(def.onUseEffect).split(':');
|
||||||
|
try {
|
||||||
|
if (eff === 'heal') { this.s?.player?.heal?.(Number(a) || 25); consume = true; }
|
||||||
|
else if (eff === 'speed') { this.s?.player?.setSpeed?.(Number(a) || 1.5); consume = true; }
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
this._emit('used', { itemId: s.itemId });
|
||||||
|
if (consume) { s.count -= 1; if (s.count <= 0) arr[idx] = null; this._changed(); }
|
||||||
|
}
|
||||||
|
setActiveHotbar(i) { this.active = Math.max(0, Math.min(HOTBAR - 1, i | 0)); this._renderHotbar(); }
|
||||||
|
getActiveItem() { const s = this.hotbar[this.active]; return s ? { ...s, def: this._def(s.itemId) } : null; }
|
||||||
|
|
||||||
|
onChange(fn) { if (typeof fn === 'function') this._onChange.push(fn); }
|
||||||
|
on(evt, fn) { if (this._events[evt] && typeof fn === 'function') this._events[evt].push(fn); }
|
||||||
|
_emit(evt, data) { for (const fn of (this._events[evt] || [])) { try { fn(data); } catch (e) {} } }
|
||||||
|
_changed() {
|
||||||
|
for (const fn of this._onChange) { try { fn(); } catch (e) {} }
|
||||||
|
this._emit('slot', {});
|
||||||
|
if (this._open) this._renderGrid();
|
||||||
|
this._renderHotbar();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DOM: hotbar (постоянный) ───────────────────────────────────────────
|
||||||
|
_parent() { return (this.s && this.s.canvas && this.s.canvas.parentElement) || document.body; }
|
||||||
|
mountHotbar() {
|
||||||
|
if (this.hotbarRoot) return;
|
||||||
|
const r = document.createElement('div');
|
||||||
|
r.style.cssText = 'position:absolute;left:50%;bottom:64px;transform:translateX(-50%);z-index:48;display:flex;gap:6px;pointer-events:auto;font-family:Inter,system-ui,sans-serif';
|
||||||
|
this._parent().appendChild(r); this.hotbarRoot = r;
|
||||||
|
this._renderHotbar();
|
||||||
|
}
|
||||||
|
_slotInner(s) {
|
||||||
|
if (!s) return '';
|
||||||
|
const def = this._def(s.itemId);
|
||||||
|
const icon = def.icon ? `<img src="${def.icon}" style="width:80%;height:80%;object-fit:contain;pointer-events:none">`
|
||||||
|
: `<span style="font-size:26px;pointer-events:none">${def.emoji || '📦'}</span>`;
|
||||||
|
const cnt = s.count > 1 ? `<span style="position:absolute;right:3px;bottom:1px;font-size:13px;font-weight:900;color:#fff;text-shadow:0 1px 2px #000">${s.count}</span>` : '';
|
||||||
|
return icon + cnt;
|
||||||
|
}
|
||||||
|
_slotStyle(s, activeBorder) {
|
||||||
|
const rc = (s && this._opts.showRarity) ? RARITY[this._def(s.itemId).rarity].color : 'rgba(255,255,255,0.15)';
|
||||||
|
const border = activeBorder ? '#ffd23a' : rc;
|
||||||
|
return `position:relative;width:52px;height:52px;border-radius:10px;border:2px solid ${border};background:rgba(20,26,40,0.7);display:flex;align-items:center;justify-content:center;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,0.3)` + (activeBorder ? ';box-shadow:0 0 12px #ffd23a' : '');
|
||||||
|
}
|
||||||
|
_renderHotbar() {
|
||||||
|
if (!this.hotbarRoot) return;
|
||||||
|
this.hotbarRoot.innerHTML = '';
|
||||||
|
for (let i = 0; i < HOTBAR; i++) {
|
||||||
|
const s = this.hotbar[i];
|
||||||
|
const cell = document.createElement('div');
|
||||||
|
cell.style.cssText = this._slotStyle(s, i === this.active);
|
||||||
|
cell.innerHTML = `<span style="position:absolute;left:3px;top:1px;font-size:11px;color:#9aa3b2;font-weight:700">${i + 1}</span>` + this._slotInner(s);
|
||||||
|
cell.onmouseenter = (e) => this._showTooltip(s, e);
|
||||||
|
cell.onmouseleave = () => this._hideTooltip();
|
||||||
|
cell.onclick = () => { this.setActiveHotbar(i); };
|
||||||
|
cell.oncontextmenu = (e) => { e.preventDefault(); this._openCtx('h' + i, e); };
|
||||||
|
this._wireDrag(cell, 'h' + i);
|
||||||
|
this.hotbarRoot.appendChild(cell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DOM: окно инвентаря ─────────────────────────────────────────────────
|
||||||
|
open() { if (this._open) return; this._open = true; this._mountWindow(); }
|
||||||
|
close() { this._open = false; if (this.root) { try { this.root.remove(); } catch (e) {} this.root = null; } this._hideTooltip(); this._closeCtx(); }
|
||||||
|
toggle() { this._open ? this.close() : this.open(); }
|
||||||
|
isOpen() { return this._open; }
|
||||||
|
|
||||||
|
_mountWindow() {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.style.cssText = 'position:absolute;inset:0;z-index:70;background:rgba(8,10,16,0.6);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;font-family:Inter,system-ui,sans-serif;pointer-events:auto';
|
||||||
|
overlay.onclick = (e) => { if (e.target === overlay) this.close(); };
|
||||||
|
const panel = document.createElement('div');
|
||||||
|
panel.style.cssText = 'width:min(640px,94%);background:#141826;border:1px solid rgba(255,255,255,0.12);border-radius:18px;padding:20px;color:#e8ecf2;box-shadow:0 20px 60px rgba(0,0,0,0.5)';
|
||||||
|
panel.onclick = (e) => e.stopPropagation();
|
||||||
|
panel.innerHTML =
|
||||||
|
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:14px">' +
|
||||||
|
'<div style="font-size:22px;font-weight:800">🎒 Инвентарь</div>' +
|
||||||
|
'<div style="display:flex;gap:8px">' +
|
||||||
|
'<button id="_inv_sort" style="height:34px;padding:0 14px;border-radius:9px;background:#2a3550;border:1px solid rgba(255,255,255,0.15);color:#fff;cursor:pointer;font-weight:700">Сорт.</button>' +
|
||||||
|
'<button id="_inv_close" style="width:34px;height:34px;border-radius:9px;background:rgba(255,255,255,0.1);border:1px solid rgba(255,255,255,0.15);color:#fff;font-size:18px;cursor:pointer">✕</button>' +
|
||||||
|
'</div></div>' +
|
||||||
|
'<div id="_inv_grid" style="display:grid;grid-template-columns:repeat(' + COLS + ',1fr);gap:6px"></div>' +
|
||||||
|
'<div style="margin:16px 0 6px;font-size:13px;color:#9aa3b2;font-weight:700">Панель быстрого доступа (1-9)</div>' +
|
||||||
|
'<div id="_inv_hb" style="display:grid;grid-template-columns:repeat(' + HOTBAR + ',1fr);gap:6px"></div>';
|
||||||
|
overlay.appendChild(panel); this._parent().appendChild(overlay); this.root = overlay;
|
||||||
|
panel.querySelector('#_inv_close').onclick = () => this.close();
|
||||||
|
panel.querySelector('#_inv_sort').onclick = () => this.sort('rarity');
|
||||||
|
this._gridEl = panel.querySelector('#_inv_grid');
|
||||||
|
this._hbEl = panel.querySelector('#_inv_hb');
|
||||||
|
this._renderGrid();
|
||||||
|
}
|
||||||
|
_renderGrid() {
|
||||||
|
if (!this._gridEl) return;
|
||||||
|
const build = (el, arr, prefix) => {
|
||||||
|
el.innerHTML = '';
|
||||||
|
for (let i = 0; i < arr.length; i++) {
|
||||||
|
const ref = prefix === 'h' ? 'h' + i : i;
|
||||||
|
const s = arr[i];
|
||||||
|
const cell = document.createElement('div');
|
||||||
|
cell.style.cssText = this._slotStyle(s, prefix === 'h' && i === this.active).replace('52px', '56px');
|
||||||
|
cell.innerHTML = (prefix === 'h' ? `<span style="position:absolute;left:3px;top:1px;font-size:11px;color:#9aa3b2;font-weight:700">${i + 1}</span>` : '') + this._slotInner(s);
|
||||||
|
cell.onmouseenter = (e) => this._showTooltip(s, e);
|
||||||
|
cell.onmouseleave = () => this._hideTooltip();
|
||||||
|
cell.oncontextmenu = (e) => { e.preventDefault(); this._openCtx(ref, e); };
|
||||||
|
if (prefix === 'h') cell.onclick = () => this.setActiveHotbar(i);
|
||||||
|
this._wireDrag(cell, ref);
|
||||||
|
el.appendChild(cell);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
build(this._gridEl, this.grid, 'g');
|
||||||
|
if (this._hbEl) build(this._hbEl, this.hotbar, 'h');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Drag-drop (HTML5 native) ────────────────────────────────────────────
|
||||||
|
_wireDrag(cell, ref) {
|
||||||
|
cell.draggable = true;
|
||||||
|
cell.addEventListener('dragstart', (e) => {
|
||||||
|
this._drag = ref; cell.style.opacity = '0.4';
|
||||||
|
try { e.dataTransfer.setData('text/plain', String(ref)); e.dataTransfer.effectAllowed = 'move'; } catch (er) {}
|
||||||
|
});
|
||||||
|
cell.addEventListener('dragend', () => { cell.style.opacity = '1'; this._drag = null; });
|
||||||
|
cell.addEventListener('dragover', (e) => { e.preventDefault(); });
|
||||||
|
cell.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const from = this._drag;
|
||||||
|
if (from != null && String(from) !== String(ref)) this.move(from, ref);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tooltip ──────────────────────────────────────────────────────────────
|
||||||
|
_showTooltip(s, e) {
|
||||||
|
if (!s) return;
|
||||||
|
this._hideTooltip();
|
||||||
|
const def = this._def(s.itemId), rc = RARITY[def.rarity];
|
||||||
|
const t = document.createElement('div');
|
||||||
|
t.style.cssText = 'position:absolute;z-index:90;max-width:240px;padding:10px 12px;background:rgba(12,16,26,0.96);border:1px solid ' + rc.color + ';border-radius:10px;color:#e8ecf2;font-family:Inter,system-ui,sans-serif;font-size:13px;pointer-events:none;box-shadow:0 6px 20px rgba(0,0,0,0.5)';
|
||||||
|
t.innerHTML =
|
||||||
|
'<div style="font-weight:800;color:' + rc.color + '">' + this._esc(def.name) + '</div>' +
|
||||||
|
'<div style="font-size:11px;color:#9aa3b2;margin:2px 0">' + rc.label + (def.tags.length ? ' · ' + def.tags.join(', ') : '') + '</div>' +
|
||||||
|
(def.description ? '<div style="margin-top:4px">' + this._esc(def.description) + '</div>' : '') +
|
||||||
|
(def.value ? '<div style="margin-top:4px;color:#ffd23a">💰 ' + def.value + '</div>' : '');
|
||||||
|
document.body.appendChild(t);
|
||||||
|
const x = (e && e.clientX) || 0, y = (e && e.clientY) || 0;
|
||||||
|
t.style.left = Math.min(x + 14, window.innerWidth - 250) + 'px';
|
||||||
|
t.style.top = (y + 14) + 'px';
|
||||||
|
this.tooltip = t;
|
||||||
|
}
|
||||||
|
_hideTooltip() { if (this.tooltip) { try { this.tooltip.remove(); } catch (e) {} this.tooltip = null; } }
|
||||||
|
|
||||||
|
// ── ПКМ-меню (Use/Split/Drop) ─────────────────────────────────────────────
|
||||||
|
_openCtx(ref, e) {
|
||||||
|
this._closeCtx();
|
||||||
|
const { arr, idx } = this._arrIdx(ref);
|
||||||
|
const s = arr[idx]; if (!s) return;
|
||||||
|
const m = document.createElement('div');
|
||||||
|
m.style.cssText = 'position:absolute;z-index:95;background:#1a2030;border:1px solid rgba(255,255,255,0.15);border-radius:10px;padding:5px;min-width:140px;font-family:Inter,system-ui,sans-serif;box-shadow:0 8px 24px rgba(0,0,0,0.5)';
|
||||||
|
const item = (label, fn) => {
|
||||||
|
const b = document.createElement('div');
|
||||||
|
b.textContent = label;
|
||||||
|
b.style.cssText = 'padding:8px 12px;border-radius:7px;cursor:pointer;color:#e8ecf2;font-size:14px';
|
||||||
|
b.onmouseenter = () => b.style.background = 'rgba(255,255,255,0.08)';
|
||||||
|
b.onmouseleave = () => b.style.background = 'transparent';
|
||||||
|
b.onclick = () => { fn(); this._closeCtx(); };
|
||||||
|
m.appendChild(b);
|
||||||
|
};
|
||||||
|
item('Использовать', () => this.use(ref));
|
||||||
|
if (this._opts.allowSplit && s.count > 1) item('Разделить', () => this.split(ref, Math.floor(s.count / 2)));
|
||||||
|
if (this._opts.allowDrop && !this._def(s.itemId).tags.includes('quest')) item('Выбросить', () => { arr[idx] = null; this._changed(); });
|
||||||
|
item('Отмена', () => {});
|
||||||
|
document.body.appendChild(m);
|
||||||
|
m.style.left = Math.min((e.clientX || 0), window.innerWidth - 150) + 'px';
|
||||||
|
m.style.top = (e.clientY || 0) + 'px';
|
||||||
|
this.ctxMenu = m;
|
||||||
|
setTimeout(() => { this._ctxCloser = () => this._closeCtx(); window.addEventListener('click', this._ctxCloser, { once: true }); }, 0);
|
||||||
|
}
|
||||||
|
_closeCtx() { if (this.ctxMenu) { try { this.ctxMenu.remove(); } catch (e) {} this.ctxMenu = null; } }
|
||||||
|
|
||||||
|
_esc(str) { return String(str).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c])); }
|
||||||
|
|
||||||
|
// ── Сериализация ──────────────────────────────────────────────────────────
|
||||||
|
serialize() {
|
||||||
|
return { defs: [...this.defs.values()], grid: this.grid, hotbar: this.hotbar, active: this.active, opts: this._opts };
|
||||||
|
}
|
||||||
|
load(data) {
|
||||||
|
if (!data) return;
|
||||||
|
if (Array.isArray(data.defs)) for (const d of data.defs) this.defineItem(d);
|
||||||
|
if (Array.isArray(data.grid)) this.grid = data.grid.slice(0, GRID).concat(new Array(Math.max(0, GRID - data.grid.length)).fill(null));
|
||||||
|
if (Array.isArray(data.hotbar)) this.hotbar = data.hotbar.slice(0, HOTBAR).concat(new Array(Math.max(0, HOTBAR - data.hotbar.length)).fill(null));
|
||||||
|
if (typeof data.active === 'number') this.active = data.active;
|
||||||
|
if (data.opts) this._opts = { ...this._opts, ...data.opts };
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.close();
|
||||||
|
if (this.hotbarRoot) { try { this.hotbarRoot.remove(); } catch (e) {} this.hotbarRoot = null; }
|
||||||
|
}
|
||||||
|
resetRuntime() {
|
||||||
|
this.close();
|
||||||
|
if (this.hotbarRoot) { try { this.hotbarRoot.remove(); } catch (e) {} this.hotbarRoot = null; }
|
||||||
|
}
|
||||||
|
}
|
||||||
255
src/editor/engine/LeaderstatsManager.js
Normal file
255
src/editor/engine/LeaderstatsManager.js
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
/**
|
||||||
|
* LeaderstatsManager — лидерборды (leaderstats) как в Roblox (задача 20).
|
||||||
|
*
|
||||||
|
* Хранит статы игроков и рендерит HUD-таблицу в правом-верхнем углу.
|
||||||
|
* В одиночной игре — один игрок ('me'). Поля сортируются по primary-стату.
|
||||||
|
*
|
||||||
|
* API (через game.leaderstats.*):
|
||||||
|
* define(name, opts) — зарегистрировать стат (initial/format/icon/color/primary)
|
||||||
|
* set(playerId, name, value) / add — изменить стат игрока
|
||||||
|
* get(playerId, name) — прочитать
|
||||||
|
* me.set/add(name, value) — для текущего игрока
|
||||||
|
* onChange(fn) — подписка (для bindToStat достижений)
|
||||||
|
*
|
||||||
|
* format: 'number' (42) | 'time' (mm:ss) | 'short' (1.2K).
|
||||||
|
* DOM-оверлей крепится к canvas.parentElement (как LoadingScreenOverlay).
|
||||||
|
*
|
||||||
|
* Фича-парность: тот же модуль в rublox-player/src/engine/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function fmt(value, format) {
|
||||||
|
const v = Number(value) || 0;
|
||||||
|
if (format === 'time') {
|
||||||
|
const m = Math.floor(v / 60), s = Math.floor(v % 60);
|
||||||
|
return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
|
||||||
|
}
|
||||||
|
if (format === 'short') {
|
||||||
|
if (v >= 1e9) return (v / 1e9).toFixed(1).replace(/\.0$/, '') + 'B';
|
||||||
|
if (v >= 1e6) return (v / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
|
||||||
|
if (v >= 1e3) return (v / 1e3).toFixed(1).replace(/\.0$/, '') + 'K';
|
||||||
|
return String(Math.round(v));
|
||||||
|
}
|
||||||
|
return String(Math.round(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LeaderstatsManager {
|
||||||
|
constructor(scene3d) {
|
||||||
|
this.s = scene3d;
|
||||||
|
this._defs = []; // [{name, initial, format, icon, color, primary}]
|
||||||
|
this._stats = new Map(); // playerId → Map(name → value)
|
||||||
|
this._players = new Map(); // playerId → displayName
|
||||||
|
this._onChange = [];
|
||||||
|
this.root = null;
|
||||||
|
this._dirty = false;
|
||||||
|
this._meId = 'me';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** id текущего игрока (одиночка = 'me'). */
|
||||||
|
_resolveMe() {
|
||||||
|
try {
|
||||||
|
const p = this.s?.gameRuntime?._players?.me;
|
||||||
|
if (p && p.id != null) return String(p.id);
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
return 'me';
|
||||||
|
}
|
||||||
|
|
||||||
|
define(name, opts = {}) {
|
||||||
|
if (typeof name !== 'string' || !name) return;
|
||||||
|
if (this._defs.some(d => d.name === name)) return; // уже есть
|
||||||
|
this._defs.push({
|
||||||
|
name,
|
||||||
|
initial: Number(opts.initial) || 0,
|
||||||
|
format: opts.format || 'number',
|
||||||
|
icon: opts.icon || '',
|
||||||
|
color: opts.color || '#e8ecf2',
|
||||||
|
primary: !!opts.primary,
|
||||||
|
});
|
||||||
|
// Если ни один не primary — первый становится primary.
|
||||||
|
if (!this._defs.some(d => d.primary)) this._defs[0].primary = true;
|
||||||
|
// Инициализируем стат у уже известных игроков.
|
||||||
|
for (const [pid] of this._players) this._ensure(pid, name);
|
||||||
|
this._ensureMe();
|
||||||
|
if (this.s?._isPlaying) this._mount(); // HUD только в Play
|
||||||
|
this._dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ensureMe() {
|
||||||
|
const me = this._resolveMe();
|
||||||
|
this._meId = me;
|
||||||
|
if (!this._players.has(me)) {
|
||||||
|
let nm = 'Ты';
|
||||||
|
try { nm = this.s?.gameRuntime?._players?.me?.name || 'Ты'; } catch (e) {}
|
||||||
|
this._players.set(me, nm);
|
||||||
|
}
|
||||||
|
for (const d of this._defs) this._ensure(me, d.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
_ensure(pid, name) {
|
||||||
|
if (!this._stats.has(pid)) this._stats.set(pid, new Map());
|
||||||
|
const m = this._stats.get(pid);
|
||||||
|
if (!m.has(name)) {
|
||||||
|
const def = this._defs.find(d => d.name === name);
|
||||||
|
m.set(name, def ? def.initial : 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set(playerId, name, value) {
|
||||||
|
const pid = playerId == null ? this._resolveMe() : String(playerId);
|
||||||
|
if (!this._players.has(pid)) this._players.set(pid, pid === this._resolveMe() ? 'Ты' : ('Игрок ' + pid));
|
||||||
|
this._ensure(pid, name);
|
||||||
|
const m = this._stats.get(pid);
|
||||||
|
const old = m.get(name);
|
||||||
|
const nv = Number(value) || 0;
|
||||||
|
if (old === nv) return;
|
||||||
|
m.set(name, nv);
|
||||||
|
this._dirty = true;
|
||||||
|
this._flash = this._flash || {};
|
||||||
|
this._flash[pid + '|' + name] = performance.now ? performance.now() : Date.now();
|
||||||
|
for (const fn of this._onChange) {
|
||||||
|
try { fn(pid, name, nv, old); } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
// Сохраняем статы текущего игрока в БД (дебаунс 1с) — между сессиями.
|
||||||
|
if (pid === this._resolveMe()) this._scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
_scheduleSave() {
|
||||||
|
if (this._saveTimer) clearTimeout(this._saveTimer);
|
||||||
|
this._saveTimer = setTimeout(() => {
|
||||||
|
this._saveTimer = null;
|
||||||
|
try {
|
||||||
|
const me = this._resolveMe();
|
||||||
|
const m = this._stats.get(me);
|
||||||
|
if (!m) return;
|
||||||
|
const obj = {}; for (const [k, v] of m) obj[k] = v;
|
||||||
|
this.s?.gameRuntime?.saveProgress?.('_leaderstats', obj);
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Загрузить статы текущего игрока из БД (вызывать при Play, после define). */
|
||||||
|
loadFromDB() {
|
||||||
|
const rt = this.s?.gameRuntime;
|
||||||
|
if (!rt || !rt.loadProgress) return;
|
||||||
|
rt.loadProgress('_leaderstats', (data) => {
|
||||||
|
if (data && typeof data === 'object') {
|
||||||
|
const me = this._resolveMe();
|
||||||
|
for (const name of Object.keys(data)) {
|
||||||
|
// Применяем только к зарегистрированным статам, без повторного сейва.
|
||||||
|
if (this._defs.some(d => d.name === name)) {
|
||||||
|
this._ensure(me, name);
|
||||||
|
this._stats.get(me).set(name, Number(data[name]) || 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._dirty = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
add(playerId, name, delta) {
|
||||||
|
const pid = playerId == null ? this._resolveMe() : String(playerId);
|
||||||
|
this._ensure(pid, name);
|
||||||
|
const cur = this._stats.get(pid).get(name) || 0;
|
||||||
|
this.set(pid, name, cur + (Number(delta) || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
get(playerId, name) {
|
||||||
|
const pid = playerId == null ? this._resolveMe() : String(playerId);
|
||||||
|
const m = this._stats.get(pid);
|
||||||
|
return m ? (m.get(name) || 0) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange(fn) { if (typeof fn === 'function') this._onChange.push(fn); }
|
||||||
|
|
||||||
|
/** Активны ли leaderstats (хотя бы один define). */
|
||||||
|
get active() { return this._defs.length > 0; }
|
||||||
|
|
||||||
|
// ── HUD ──────────────────────────────────────────────────────────────
|
||||||
|
_mount() {
|
||||||
|
if (this.root) return;
|
||||||
|
const parent = (this.s && this.s.canvas && this.s.canvas.parentElement) || document.body;
|
||||||
|
const root = document.createElement('div');
|
||||||
|
root.style.cssText = [
|
||||||
|
'position:absolute', 'top:14px', 'right:14px', 'z-index:50',
|
||||||
|
'min-width:230px', 'max-width:300px',
|
||||||
|
'background:rgba(18,22,33,0.55)', 'backdrop-filter:blur(8px)',
|
||||||
|
'-webkit-backdrop-filter:blur(8px)',
|
||||||
|
'border:1px solid rgba(255,255,255,0.12)', 'border-radius:12px',
|
||||||
|
'padding:10px 12px', 'font-family:Inter,system-ui,sans-serif',
|
||||||
|
'color:#e8ecf2', 'pointer-events:none', 'user-select:none',
|
||||||
|
'box-shadow:0 6px 24px rgba(0,0,0,0.35)',
|
||||||
|
].join(';');
|
||||||
|
parent.appendChild(root);
|
||||||
|
this.root = root;
|
||||||
|
this._sortBy = null; // имя стата для сортировки (null = primary)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Вызывать каждый кадр (рендер при изменениях + затухание flash). */
|
||||||
|
tick() {
|
||||||
|
if (!this.active) return;
|
||||||
|
if (!this.root) { this._mount(); this._dirty = true; }
|
||||||
|
if (this._dirty) { this._render(); this._dirty = false; }
|
||||||
|
// flash затухает ~600мс — перерисуем пока активен.
|
||||||
|
if (this._flash && Object.keys(this._flash).length) {
|
||||||
|
const now = performance.now ? performance.now() : Date.now();
|
||||||
|
let any = false;
|
||||||
|
for (const k of Object.keys(this._flash)) {
|
||||||
|
if (now - this._flash[k] < 600) any = true; else delete this._flash[k];
|
||||||
|
}
|
||||||
|
if (any) this._render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_render() {
|
||||||
|
const defs = this._defs;
|
||||||
|
if (!defs.length) { this.root.innerHTML = ''; return; }
|
||||||
|
const sortStat = this._sortBy || (defs.find(d => d.primary) || defs[0]).name;
|
||||||
|
const me = this._resolveMe();
|
||||||
|
// Строки игроков, сортировка по убыванию sortStat, топ-10.
|
||||||
|
const rows = [...this._players.keys()]
|
||||||
|
.map(pid => ({ pid, name: this._players.get(pid) }))
|
||||||
|
.sort((a, b) => (this.get(b.pid, sortStat) - this.get(a.pid, sortStat)))
|
||||||
|
.slice(0, 10);
|
||||||
|
const now = performance.now ? performance.now() : Date.now();
|
||||||
|
|
||||||
|
let html = '<div style="display:flex;align-items:center;gap:6px;font-weight:800;font-size:13px;margin-bottom:8px;color:#ffd23a">🏆 Таблица лидеров</div>';
|
||||||
|
// Шапка столбцов.
|
||||||
|
html += '<div style="display:grid;grid-template-columns:1fr ' + defs.map(() => 'auto').join(' ') + ';gap:8px;font-size:11px;color:#9aa3b2;font-weight:700;padding-bottom:4px;border-bottom:1px solid rgba(255,255,255,0.1)">';
|
||||||
|
html += '<span>Игрок</span>';
|
||||||
|
for (const d of defs) html += '<span style="text-align:right;color:' + d.color + '">' + (d.icon ? d.icon + ' ' : '') + d.name + '</span>';
|
||||||
|
html += '</div>';
|
||||||
|
// Строки.
|
||||||
|
for (const r of rows) {
|
||||||
|
const mine = r.pid === me;
|
||||||
|
html += '<div style="display:grid;grid-template-columns:1fr ' + defs.map(() => 'auto').join(' ') + ';gap:8px;font-size:13px;padding:4px 2px;border-radius:6px;' + (mine ? 'background:rgba(51,87,255,0.22);' : '') + '">';
|
||||||
|
html += '<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:' + (mine ? '800' : '600') + '">' + this._esc(r.name) + '</span>';
|
||||||
|
for (const d of defs) {
|
||||||
|
const flashed = this._flash && (now - (this._flash[r.pid + '|' + d.name] || 0) < 600);
|
||||||
|
const col = flashed ? '#ffe066' : d.color;
|
||||||
|
html += '<span style="text-align:right;font-weight:700;color:' + col + ';transition:color .2s">' + fmt(this.get(r.pid, d.name), d.format) + '</span>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
this.root.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
_esc(s) { return String(s).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c])); }
|
||||||
|
|
||||||
|
/** Сериализация определений в project_data. */
|
||||||
|
serialize() {
|
||||||
|
return this._defs.map(d => ({ ...d }));
|
||||||
|
}
|
||||||
|
load(arr) {
|
||||||
|
if (!Array.isArray(arr)) return;
|
||||||
|
for (const d of arr) this.define(d.name, d);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
if (this.root) { try { this.root.remove(); } catch (e) {} this.root = null; }
|
||||||
|
this._stats.clear(); this._players.clear(); this._onChange = [];
|
||||||
|
}
|
||||||
|
/** Сброс рантайм-значений при exitPlayMode (определения остаются). */
|
||||||
|
resetRuntime() {
|
||||||
|
this._stats.clear(); this._players.clear(); this._flash = {};
|
||||||
|
if (this.root) this.root.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
@ -526,6 +526,9 @@ export class ModelManager {
|
|||||||
opacity: typeof data.opacity === 'number' ? data.opacity : 1,
|
opacity: typeof data.opacity === 'number' ? data.opacity : 1,
|
||||||
tint: data.tint || null,
|
tint: data.tint || null,
|
||||||
name: data.name || null,
|
name: data.name || null,
|
||||||
|
// folderId — принадлежность к папке (иначе модели вываливаются
|
||||||
|
// из папки после Play/Stop). Баг 2026-06-05.
|
||||||
|
...(data.folderId != null ? { folderId: data.folderId } : {}),
|
||||||
// Параметры геймплея (HP, скорость врага, лимит спавнера и т.п.)
|
// Параметры геймплея (HP, скорость врага, лимит спавнера и т.п.)
|
||||||
gameplayParams: data.gameplayParams || null,
|
gameplayParams: data.gameplayParams || null,
|
||||||
});
|
});
|
||||||
@ -768,6 +771,7 @@ export class ModelManager {
|
|||||||
if (m.tint) data.tint = m.tint;
|
if (m.tint) data.tint = m.tint;
|
||||||
if (m.name) data.name = m.name;
|
if (m.name) data.name = m.name;
|
||||||
if (m.gameplayParams) data.gameplayParams = m.gameplayParams;
|
if (m.gameplayParams) data.gameplayParams = m.gameplayParams;
|
||||||
|
if (m.folderId != null) data.folderId = m.folderId; // восстановить папку
|
||||||
if (data.opacity != null || data.tint) this._applyMaterialOverrides(data);
|
if (data.opacity != null || data.tint) this._applyMaterialOverrides(data);
|
||||||
}
|
}
|
||||||
// Гарантируем что _nextInstanceId стоит ПОСЛЕ максимального восстановленного id —
|
// Гарантируем что _nextInstanceId стоит ПОСЛЕ максимального восстановленного id —
|
||||||
|
|||||||
@ -161,6 +161,20 @@ export class NpcManager {
|
|||||||
r15Animator,
|
r15Animator,
|
||||||
};
|
};
|
||||||
this.npcs.set(id, npc);
|
this.npcs.set(id, npc);
|
||||||
|
// Пометить меши NPC для попаданий оружия (бластер/меч): pickable + npcId
|
||||||
|
// в metadata. Без pickable raycast оружия проходит сквозь NPC и урон/
|
||||||
|
// floater'ы не срабатывают (задача 40).
|
||||||
|
try {
|
||||||
|
const root = npc.data && npc.data.rootMesh;
|
||||||
|
if (root) {
|
||||||
|
root.isPickable = true;
|
||||||
|
root.metadata = Object.assign({}, root.metadata, { npcId: id });
|
||||||
|
for (const m of root.getChildMeshes(false)) {
|
||||||
|
m.isPickable = true;
|
||||||
|
m.metadata = Object.assign({}, m.metadata, { npcId: id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -274,6 +288,12 @@ export class NpcManager {
|
|||||||
npc.isMoving = false;
|
npc.isMoving = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Включить/выключить анимацию атаки (R15-NPC машет руками). */
|
||||||
|
setAttacking(id, on) {
|
||||||
|
const npc = this.npcs.get(Number(id));
|
||||||
|
if (npc) npc.attacking = !!on;
|
||||||
|
}
|
||||||
|
|
||||||
/** Реплика над головой NPC на duration секунд. */
|
/** Реплика над головой NPC на duration секунд. */
|
||||||
say(id, text, duration = 3) {
|
say(id, text, duration = 3) {
|
||||||
const npc = this.npcs.get(Number(id));
|
const npc = this.npcs.get(Number(id));
|
||||||
@ -286,10 +306,43 @@ export class NpcManager {
|
|||||||
damage(id, amount) {
|
damage(id, amount) {
|
||||||
const npc = this.npcs.get(Number(id));
|
const npc = this.npcs.get(Number(id));
|
||||||
if (!npc || npc.dead) return;
|
if (!npc || npc.dead) return;
|
||||||
npc.hp = Math.max(0, npc.hp - (Number(amount) || 0));
|
const amt = Number(amount) || 0;
|
||||||
|
npc.hp = Math.max(0, npc.hp - amt);
|
||||||
|
// Авто-floater над мобом (задача 40 доп): game.fx.autoMobFloaters(true).
|
||||||
|
if (this._autoFloater && amt > 0 && this.scene3d?.floaters) {
|
||||||
|
try {
|
||||||
|
this.scene3d.floaters.spawn(
|
||||||
|
{ x: npc.x, y: (npc.y || 0) + 2.2, z: npc.z }, amt, this._autoFloater.opts || {});
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
if (npc.hp <= 0) this._killNpc(npc);
|
if (npc.hp <= 0) this._killNpc(npc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Нанести урон NPC по мешу-попаданию (бластер/оружие). Ищет NPC, чьи меши
|
||||||
|
* содержат hit-меш (или предка). Вызывает damage() → авто-floater. */
|
||||||
|
damageByMesh(mesh, amount) {
|
||||||
|
if (!mesh) return false;
|
||||||
|
// 1) Быстрый путь: npcId в metadata меша (или предка).
|
||||||
|
let m = mesh;
|
||||||
|
for (let i = 0; i < 8 && m; i++) {
|
||||||
|
const nid = m.metadata && m.metadata.npcId;
|
||||||
|
if (nid != null && this.npcs.has(nid)) { this.damage(nid, amount); return true; }
|
||||||
|
m = m.parent;
|
||||||
|
}
|
||||||
|
// 2) Fallback: сравнение с rootMesh по иерархии.
|
||||||
|
for (const npc of this.npcs.values()) {
|
||||||
|
if (npc.dead) continue;
|
||||||
|
const root = npc.data && npc.data.rootMesh;
|
||||||
|
if (!root) continue;
|
||||||
|
let mm = mesh;
|
||||||
|
for (let i = 0; i < 8 && mm; i++) {
|
||||||
|
if (mm === root) { this.damage(npc.id, amount); return true; }
|
||||||
|
mm = mm.parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/** Удалить NPC по id (без эффекта смерти — просто убрать). */
|
/** Удалить NPC по id (без эффекта смерти — просто убрать). */
|
||||||
removeNpc(id) {
|
removeNpc(id) {
|
||||||
const npc = this.npcs.get(Number(id));
|
const npc = this.npcs.get(Number(id));
|
||||||
@ -390,17 +443,23 @@ export class NpcManager {
|
|||||||
if (root._isWorldMatrixFrozen) {
|
if (root._isWorldMatrixFrozen) {
|
||||||
try { root.unfreezeWorldMatrix(); } catch (e) {}
|
try { root.unfreezeWorldMatrix(); } catch (e) {}
|
||||||
}
|
}
|
||||||
root.position.set(npc.x, npc.y, npc.z);
|
// Анимация ходьбы — процедурное покачивание (у Kenney-моделей нет
|
||||||
|
// скелета). Подпрыгивание по Y + лёгкое раскачивание корпуса.
|
||||||
|
if (moving) npc.walkPhase += dt * 10;
|
||||||
|
let bobY = 0, lean = 0;
|
||||||
|
if (moving && !npc.r15Animator) {
|
||||||
|
bobY = Math.abs(Math.sin(npc.walkPhase)) * 0.12; // шаги вверх-вниз
|
||||||
|
lean = Math.sin(npc.walkPhase) * 0.08; // покачивание
|
||||||
|
}
|
||||||
|
root.position.set(npc.x, npc.y + bobY, npc.z);
|
||||||
root.rotation.y = npc.yaw;
|
root.rotation.y = npc.yaw;
|
||||||
|
root.rotation.z = lean;
|
||||||
// data.x/y/z — чтобы scene.find/getPosition видели NPC.
|
// data.x/y/z — чтобы scene.find/getPosition видели NPC.
|
||||||
data.x = npc.x; data.y = npc.y; data.z = npc.z;
|
data.x = npc.x; data.y = npc.y; data.z = npc.z;
|
||||||
|
// R15-NPC (skin_*): процедурная анимация бега/покоя/атаки через R15Animator.
|
||||||
// Анимация ходьбы — простое покачивание (без R15-скелета у Kenney).
|
|
||||||
if (moving) npc.walkPhase += dt * 6;
|
|
||||||
// R15-NPC (skin_*): процедурная анимация бега/покоя через R15Animator.
|
|
||||||
if (npc.r15Animator) {
|
if (npc.r15Animator) {
|
||||||
try {
|
try {
|
||||||
npc.r15Animator.setState(moving ? 'run' : 'idle');
|
npc.r15Animator.setState(npc.attacking ? 'attack' : (moving ? 'run' : 'idle'));
|
||||||
npc.r15Animator.update(dt);
|
npc.r15Animator.update(dt);
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -186,6 +186,10 @@ export class PrimitiveManager {
|
|||||||
primitiveId: id,
|
primitiveId: id,
|
||||||
primitiveType: type,
|
primitiveType: type,
|
||||||
primitiveKind: typeDef.kind,
|
primitiveKind: typeDef.kind,
|
||||||
|
// canCollide в metadata нужен camera-clamp (PlayerController):
|
||||||
|
// без него камера 3-го лица цепляется за проходимые зоны/триггеры
|
||||||
|
// (canCollide:false) и прыгает к игроку внутри зоны. Баг 2026-06-05.
|
||||||
|
canCollide,
|
||||||
};
|
};
|
||||||
|
|
||||||
// textureAsset — id картинки из AssetManager (пользовательская
|
// textureAsset — id картинки из AssetManager (пользовательская
|
||||||
@ -754,7 +758,10 @@ export class PrimitiveManager {
|
|||||||
this._applyMaterial(data.mesh, typeDef, data.color, data.material);
|
this._applyMaterial(data.mesh, typeDef, data.color, data.material);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (patch.canCollide !== undefined) data.canCollide = patch.canCollide;
|
if (patch.canCollide !== undefined) {
|
||||||
|
data.canCollide = patch.canCollide;
|
||||||
|
if (data.mesh?.metadata) data.mesh.metadata.canCollide = patch.canCollide;
|
||||||
|
}
|
||||||
if (patch.locked !== undefined) data.locked = !!patch.locked;
|
if (patch.locked !== undefined) data.locked = !!patch.locked;
|
||||||
if (patch.visible !== undefined) {
|
if (patch.visible !== undefined) {
|
||||||
data.visible = patch.visible;
|
data.visible = patch.visible;
|
||||||
@ -938,6 +945,9 @@ export class PrimitiveManager {
|
|||||||
anchored: d.anchored,
|
anchored: d.anchored,
|
||||||
mass: d.mass,
|
mass: d.mass,
|
||||||
name: d.name || null,
|
name: d.name || null,
|
||||||
|
// folderId — принадлежность к папке. БЕЗ него примитивы вываливались
|
||||||
|
// из папки после Play/Stop (снапшот терял группировку). Баг 2026-06-05.
|
||||||
|
...(d.folderId != null ? { folderId: d.folderId } : {}),
|
||||||
// locked — защита от выделения/перемещения (Фаза 5.11).
|
// locked — защита от выделения/перемещения (Фаза 5.11).
|
||||||
...(d.locked ? { locked: true } : {}),
|
...(d.locked ? { locked: true } : {}),
|
||||||
// id пользовательской текстуры (картинка из AssetManager).
|
// id пользовательской текстуры (картинка из AssetManager).
|
||||||
|
|||||||
@ -131,6 +131,23 @@ const ANIMS_STD = {
|
|||||||
{ bone: B.SPINE, axis: AXIS_RIGHT, angleDeg: 10,
|
{ bone: B.SPINE, axis: AXIS_RIGHT, angleDeg: 10,
|
||||||
times: [0.0, 0.4, 0.8], values: [0.0, 1.0, 0.0] },
|
times: [0.0, 0.4, 0.8], values: [0.0, 1.0, 0.0] },
|
||||||
]),
|
]),
|
||||||
|
// Удар правой рукой вперёд (для враждебных NPC). loop=true — постоянно
|
||||||
|
// машет, пока NPC в режиме атаки.
|
||||||
|
attack: makeAnim(0.5, true, [
|
||||||
|
// Правая рука выбрасывается вперёд (замах назад → удар).
|
||||||
|
{ bone: B.RIGHT_ARM, axis: AXIS_RIGHT, angleDeg: -95,
|
||||||
|
times: [0.0, 0.15, 0.3, 0.5], values: [0.3, 1.0, 0.2, 0.3] },
|
||||||
|
{ bone: B.RIGHT_FOREARM, axis: AXIS_RIGHT, angleDeg: -50,
|
||||||
|
times: [0.0, 0.15, 0.3, 0.5], values: [1.0, 0.2, 1.0, 1.0] },
|
||||||
|
// Левая рука тоже в боевой стойке.
|
||||||
|
{ bone: B.LEFT_ARM, axis: AXIS_RIGHT, angleDeg: -45,
|
||||||
|
times: [0.0, 0.5], values: [1.0, 1.0] },
|
||||||
|
{ bone: B.LEFT_FOREARM, axis: AXIS_RIGHT, angleDeg: -70,
|
||||||
|
times: [0.0, 0.5], values: [1.0, 1.0] },
|
||||||
|
// Корпус подаётся в удар.
|
||||||
|
{ bone: B.SPINE, axis: AXIS_RIGHT, angleDeg: -12,
|
||||||
|
times: [0.0, 0.15, 0.3, 0.5], values: [0.0, 1.0, 0.3, 0.0] },
|
||||||
|
]),
|
||||||
|
|
||||||
// === ЭМОЦИИ (game.player.playAnimation) ===
|
// === ЭМОЦИИ (game.player.playAnimation) ===
|
||||||
// Разовые анимации поверх авто-состояния. loop=false — играют один раз,
|
// Разовые анимации поверх авто-состояния. loop=false — играют один раз,
|
||||||
|
|||||||
@ -70,10 +70,16 @@ 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.
|
||||||
let _roomState = {};
|
let _roomState = {};
|
||||||
|
// Задача 20: зеркала лидербордов/достижений для синхронного get/has в скриптах.
|
||||||
|
let _lsMirror = {}; // { playerId: { statName: value } }
|
||||||
|
let _achUnlocked = {}; // { id: true }
|
||||||
|
let _lsChangeHandlers = []; // game.leaderstats.onChange подписки
|
||||||
// Подписки game.onPlayerJoin / onPlayerLeave / game.onMessage(name).
|
// Подписки game.onPlayerJoin / onPlayerLeave / game.onMessage(name).
|
||||||
let _playerJoinHandlers = [];
|
let _playerJoinHandlers = [];
|
||||||
let _playerLeaveHandlers = [];
|
let _playerLeaveHandlers = [];
|
||||||
@ -229,6 +235,10 @@ function _makeNpcProxy(ref) {
|
|||||||
damage(amount) {
|
damage(amount) {
|
||||||
_send('npc.damage', { ref, amount: Number(amount) || 0 });
|
_send('npc.damage', { ref, amount: Number(amount) || 0 });
|
||||||
},
|
},
|
||||||
|
/** Включить/выключить анимацию атаки (удары руками). */
|
||||||
|
setAttacking(on) {
|
||||||
|
_send('npc.setAttacking', { ref, on: !!on });
|
||||||
|
},
|
||||||
/** Убрать NPC со сцены. */
|
/** Убрать NPC со сцены. */
|
||||||
remove() {
|
remove() {
|
||||||
_send('npc.remove', { ref });
|
_send('npc.remove', { ref });
|
||||||
@ -554,6 +564,12 @@ function _getOrCreateInstance(ref, kindHint) {
|
|||||||
_send('scene.setColor', { ref, color: String(value) });
|
_send('scene.setColor', { ref, color: String(value) });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (prop === 'scale') {
|
||||||
|
// Равномерный визуальный масштаб объекта (1 = исходный размер).
|
||||||
|
const k = Number(value);
|
||||||
|
if (Number.isFinite(k) && k >= 0) _send('scene.setScale', { ref, scale: k });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (prop === 'transparency' || prop === 'opacity') {
|
if (prop === 'transparency' || prop === 'opacity') {
|
||||||
const v = Number(value);
|
const v = Number(value);
|
||||||
if (Number.isFinite(v)) {
|
if (Number.isFinite(v)) {
|
||||||
@ -742,6 +758,51 @@ function _buildSelfApi() {
|
|||||||
_send('self.move', { target: _target, x: nx, y: ny, z: nz });
|
_send('self.move', { target: _target, x: nx, y: ny, z: nz });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Повернуть объект-носитель вокруг вертикальной оси Y на угол ry (радианы).
|
||||||
|
* game.onTick((dt) => { a += dt; game.self.rotate(a); });
|
||||||
|
*/
|
||||||
|
rotate(ry) {
|
||||||
|
const r = Number(ry);
|
||||||
|
if (!Number.isFinite(r)) return;
|
||||||
|
const k = _target.kind;
|
||||||
|
const id = _target.id ?? _target.ref;
|
||||||
|
_send('scene.rotate', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, rotationY: r });
|
||||||
|
},
|
||||||
|
rotateY(ry) { this.rotate(ry); },
|
||||||
|
/** Показать/скрыть объект-носитель. */
|
||||||
|
setVisible(vis) {
|
||||||
|
const k = _target.kind;
|
||||||
|
const id = _target.id ?? _target.ref;
|
||||||
|
_send('scene.setVisible', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, visible: !!vis });
|
||||||
|
},
|
||||||
|
/** Включить/выключить столкновения объекта-носителя (проходимость). */
|
||||||
|
setCollide(can) {
|
||||||
|
const k = _target.kind;
|
||||||
|
const id = _target.id ?? _target.ref;
|
||||||
|
_send('scene.setCollide', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, canCollide: !!can });
|
||||||
|
},
|
||||||
|
/** Перекрасить объект-носитель (только примитив). */
|
||||||
|
setColor(hex) {
|
||||||
|
if (typeof hex !== 'string') return;
|
||||||
|
const k = _target.kind;
|
||||||
|
const id = _target.id ?? _target.ref;
|
||||||
|
_send('scene.setColor', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, color: hex });
|
||||||
|
},
|
||||||
|
/** Повесить текст-метку над объектом-носителем (имя/HP). */
|
||||||
|
setLabel(text, opts) {
|
||||||
|
const k = _target.kind;
|
||||||
|
const id = _target.id ?? _target.ref;
|
||||||
|
const ref = (k && id != null) ? (k + ':' + id) : undefined;
|
||||||
|
_send('scene.setLabel', { ref, text: String(text == null ? '' : text), opts: opts || {} });
|
||||||
|
},
|
||||||
|
/** Убрать метку с объекта-носителя. */
|
||||||
|
clearLabel() {
|
||||||
|
const k = _target.kind;
|
||||||
|
const id = _target.id ?? _target.ref;
|
||||||
|
const ref = (k && id != null) ? (k + ':' + id) : undefined;
|
||||||
|
_send('scene.clearLabel', { ref });
|
||||||
|
},
|
||||||
delete() {
|
delete() {
|
||||||
_send('self.delete', { target: _target });
|
_send('self.delete', { target: _target });
|
||||||
},
|
},
|
||||||
@ -1966,6 +2027,32 @@ const game = {
|
|||||||
const bag = _dataIndex[r];
|
const bag = _dataIndex[r];
|
||||||
return bag ? bag[key] : undefined;
|
return bag ? bag[key] : undefined;
|
||||||
},
|
},
|
||||||
|
// === Небо и атмосфера (задача 16) ===
|
||||||
|
/**
|
||||||
|
* Установить небо. Либо пресет, либо ручной gradient:
|
||||||
|
* game.scene.setSkybox({ preset: 'lowpoly-roblox' });
|
||||||
|
* game.scene.setSkybox({ mode:'gradient', topColor:'#4a90e2', bottomColor:'#cfd8dc' });
|
||||||
|
* Пресеты: clear-summer-day / lowpoly-roblox / cloudy / sunset / starry-night / space.
|
||||||
|
*/
|
||||||
|
setSkybox(opts) { _send('scene.setSkybox', { opts: opts || {} }); },
|
||||||
|
/**
|
||||||
|
* Облака поверх неба:
|
||||||
|
* game.scene.setClouds({ enabled:true, cover:0.5, speed:0.02, color:'#ffffff' });
|
||||||
|
*/
|
||||||
|
setClouds(opts) { _send('scene.setClouds', { opts: opts || {} }); },
|
||||||
|
/**
|
||||||
|
* Атмосферный туман:
|
||||||
|
* game.scene.setFog({ color:'#dddddd', density:0.005 });
|
||||||
|
* game.scene.setFog({ enabled:false });
|
||||||
|
*/
|
||||||
|
setFog(opts) { _send('scene.setFog', { opts: opts || {} }); },
|
||||||
|
/** Объект управления небом: плавный переход + солнце. */
|
||||||
|
skybox: {
|
||||||
|
/** Плавный переход к пресету за N секунд: skybox.fadeTo({preset:'sunset'}, 2). */
|
||||||
|
fadeTo(opts, durationSec) { _send('scene.skyboxFadeTo', { opts: opts || {}, duration: Number(durationSec) || 2 }); },
|
||||||
|
/** Направление солнца (для анимации дуги): setSunDirection({x,y,z}). */
|
||||||
|
setSunDirection(dir) { _send('scene.skyboxSunDir', { dir: dir || {} }); },
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* Теги объектов (Фаза 5.6) — как CollectionService в Roblox.
|
* Теги объектов (Фаза 5.6) — как CollectionService в Roblox.
|
||||||
* Помечаешь объекты тегом, потом находишь все объекты с тегом.
|
* Помечаешь объекты тегом, потом находишь все объекты с тегом.
|
||||||
@ -2508,6 +2595,10 @@ const game = {
|
|||||||
opts = opts && typeof opts === 'object' ? opts : {};
|
opts = opts && typeof opts === 'object' ? opts : {};
|
||||||
this._opts = opts;
|
this._opts = opts;
|
||||||
this._active = true;
|
this._active = true;
|
||||||
|
// Колбэки можно передавать прямо в опциях show({ onPlay, onShow, onHide }).
|
||||||
|
if (typeof opts.onPlay === 'function') this._onPlay.push(opts.onPlay);
|
||||||
|
if (typeof opts.onShow === 'function') this._onShow.push(opts.onShow);
|
||||||
|
if (typeof opts.onHide === 'function') this._onHide.push(opts.onHide);
|
||||||
// 1) Заблокировать управление игроком (наблюдатель).
|
// 1) Заблокировать управление игроком (наблюдатель).
|
||||||
_send('player.setInputBlocked', { blocked: true });
|
_send('player.setInputBlocked', { blocked: true });
|
||||||
game.hud.setVisible(false);
|
game.hud.setVisible(false);
|
||||||
@ -2943,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;
|
||||||
@ -2961,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 } : {};
|
||||||
@ -3083,6 +3191,28 @@ const game = {
|
|||||||
clear() {
|
clear() {
|
||||||
_send('inventory.clear', {});
|
_send('inventory.clear', {});
|
||||||
},
|
},
|
||||||
|
// === Задача 44: drag-drop инвентарь (сетка 8×5 + hotbar 9 + стаки) ===
|
||||||
|
/** Добавить предмет по itemId со стаком. game.inventory.give('berry', 5). */
|
||||||
|
give(itemId, count) { _send('inv2.add', { itemId, count: Number(count) || 1 }); },
|
||||||
|
/** Убрать N предметов по itemId. */
|
||||||
|
take(itemId, count) { _send('inv2.remove', { itemId, count: Number(count) || 1 }); },
|
||||||
|
/** Открыть/закрыть/тоггл окна инвентаря. */
|
||||||
|
open() { _send('inv2.open', {}); },
|
||||||
|
closeUi() { _send('inv2.close', {}); },
|
||||||
|
toggle() { _send('inv2.toggle', {}); },
|
||||||
|
/** Сортировать (by: 'rarity'|'name'). */
|
||||||
|
sort(by) { _send('inv2.sort', { by: by || 'rarity' }); },
|
||||||
|
/** Активный слот хотбара (0..8). */
|
||||||
|
setActiveHotbar(i) { _send('inv2.setActive', { i: Number(i) || 0 }); },
|
||||||
|
},
|
||||||
|
|
||||||
|
// === Определения предметов (задача 44) ===
|
||||||
|
items: {
|
||||||
|
/** Зарегистрировать предмет: {id,name,emoji|icon,rarity,maxStack,description,value,tags,onUseEffect}. */
|
||||||
|
define(def) {
|
||||||
|
if (Array.isArray(def)) { for (const d of def) _send('items.define', { def: d }); return; }
|
||||||
|
_send('items.define', { def: def || {} });
|
||||||
|
},
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Игроки комнаты (Фаза 4.3 — мультиплеер).
|
* Игроки комнаты (Фаза 4.3 — мультиплеер).
|
||||||
@ -3110,6 +3240,62 @@ const game = {
|
|||||||
return p ? { ...p } : null;
|
return p ? { ...p } : null;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// === Лидерборды (leaderstats) — задача 20 ===
|
||||||
|
leaderstats: {
|
||||||
|
/** Зарегистрировать стат: define('Монеты', {initial,format,icon,color,primary}). */
|
||||||
|
define(name, opts) {
|
||||||
|
if (typeof name !== 'string' || !name) return;
|
||||||
|
_send('leaderstats.define', { name, opts: opts || {} });
|
||||||
|
},
|
||||||
|
/** Установить стат игрока (playerId=null → текущий). */
|
||||||
|
set(playerId, name, value) {
|
||||||
|
_send('leaderstats.set', { playerId: playerId == null ? null : String(playerId), name, value: Number(value) || 0 });
|
||||||
|
const pid = playerId == null ? '@me' : String(playerId);
|
||||||
|
if (!_lsMirror[pid]) _lsMirror[pid] = {};
|
||||||
|
_lsMirror[pid][name] = Number(value) || 0;
|
||||||
|
},
|
||||||
|
/** Прибавить к стату. */
|
||||||
|
add(playerId, name, delta) {
|
||||||
|
_send('leaderstats.add', { playerId: playerId == null ? null : String(playerId), name, delta: Number(delta) || 0 });
|
||||||
|
const pid = playerId == null ? '@me' : String(playerId);
|
||||||
|
if (!_lsMirror[pid]) _lsMirror[pid] = {};
|
||||||
|
_lsMirror[pid][name] = (_lsMirror[pid][name] || 0) + (Number(delta) || 0);
|
||||||
|
},
|
||||||
|
/** Прочитать стат (из локального зеркала). */
|
||||||
|
get(playerId, name) {
|
||||||
|
const pid = playerId == null ? '@me' : String(playerId);
|
||||||
|
return (_lsMirror[pid] && _lsMirror[pid][name]) || 0;
|
||||||
|
},
|
||||||
|
/** Подписка на изменение: onChange((playerId, name, newVal, oldVal) => {}). */
|
||||||
|
onChange(fn) { if (typeof fn === 'function') _lsChangeHandlers.push(fn); },
|
||||||
|
/** Шорткат для текущего игрока. */
|
||||||
|
me: {
|
||||||
|
set(name, value) { game.leaderstats.set(null, name, value); },
|
||||||
|
add(name, delta) { game.leaderstats.add(null, name, delta); },
|
||||||
|
get(name) { return game.leaderstats.get(null, name); },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// === Достижения — задача 20 ===
|
||||||
|
achievements: {
|
||||||
|
/** Объявить достижения: define([{id,name,description,icon,rarity,points,hidden}]). */
|
||||||
|
define(list) { _send('achievements.define', { list: Array.isArray(list) ? list : [list] }); },
|
||||||
|
/** Разблокировать достижение. */
|
||||||
|
unlock(id, playerId) {
|
||||||
|
if (typeof id !== 'string') return;
|
||||||
|
_achUnlocked[id] = true;
|
||||||
|
_send('achievements.unlock', { id, playerId: playerId == null ? null : String(playerId) });
|
||||||
|
},
|
||||||
|
/** Разблокировано ли (из зеркала). */
|
||||||
|
has(id) { return !!_achUnlocked[id]; },
|
||||||
|
/** Авто-unlock по достижению значения leaderstat. */
|
||||||
|
bindToStat(id, statName, cond) { _send('achievements.bindToStat', { id, statName, cond: cond || {} }); },
|
||||||
|
/** Показать/скрыть кнопку-кубок. */
|
||||||
|
setButtonVisible(v) { _send('achievements.setButtonVisible', { visible: !!v }); },
|
||||||
|
/** Открыть страницу достижений. */
|
||||||
|
openPage() { _send('achievements.openPage', {}); },
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* Общее состояние комнаты (Фаза 4.3) — данные, видимые всем игрокам.
|
* Общее состояние комнаты (Фаза 4.3) — данные, видимые всем игрокам.
|
||||||
* В одиночной игре работает как локальное хранилище.
|
* В одиночной игре работает как локальное хранилище.
|
||||||
@ -3298,6 +3484,25 @@ const game = {
|
|||||||
* trail — шлейф за движущимся объектом.
|
* trail — шлейф за движущимся объектом.
|
||||||
*/
|
*/
|
||||||
fx: {
|
fx: {
|
||||||
|
/**
|
||||||
|
* Всплывающая цифра урона (задача 40). position — {x,y,z} или ref
|
||||||
|
* объекта; value — число или строка; opts — color/isCrit/isHeal/isMana/
|
||||||
|
* isMiss/fontSize/floatHeight/lifetime/randomOffset/stackKey/comicStyle.
|
||||||
|
* game.fx.damageFloater(enemy.position, 25);
|
||||||
|
* game.fx.damageFloater(pos, 100, { isCrit: true });
|
||||||
|
* game.fx.damageFloater(pos, 30, { isHeal: true });
|
||||||
|
*/
|
||||||
|
damageFloater(position, value, opts) {
|
||||||
|
const pos = _normFxPoint(position);
|
||||||
|
_send('fx.damageFloater', { position: pos, value, opts: opts || {} });
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Авто-floater'ы над мобами (NPC) при потере HP. Включил один раз — любой
|
||||||
|
* урон по NPC сам показывает облачко «-N». game.fx.autoMobFloaters(true).
|
||||||
|
*/
|
||||||
|
autoMobFloaters(enabled, opts) {
|
||||||
|
_send('fx.autoMobFloaters', { enabled: enabled !== false, opts: opts || {} });
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* Луч между двумя точками. opts: { from, to — {x,y,z} или ref
|
* Луч между двумя точками. opts: { from, to — {x,y,z} или ref
|
||||||
* объекта (тогда луч следит за ним); color: '#hex', width }.
|
* объекта (тогда луч следит за ним); color: '#hex', width }.
|
||||||
@ -4197,6 +4402,15 @@ self.onmessage = (e) => {
|
|||||||
const t = payload?.type;
|
const t = payload?.type;
|
||||||
if (t === 'click') {
|
if (t === 'click') {
|
||||||
for (const fn of _globalClickHandlers) _safeCall(fn, payload, 'onClick');
|
for (const fn of _globalClickHandlers) _safeCall(fn, payload, 'onClick');
|
||||||
|
} else if (t === 'leaderstatsChange') {
|
||||||
|
// Задача 20: стат изменился на сервере/main — обновляем зеркало + onChange.
|
||||||
|
const pid = payload.playerId == null ? '@me' : String(payload.playerId);
|
||||||
|
if (!_lsMirror[pid]) _lsMirror[pid] = {};
|
||||||
|
_lsMirror[pid][payload.name] = payload.newValue;
|
||||||
|
if (payload.isMe) { if (!_lsMirror['@me']) _lsMirror['@me'] = {}; _lsMirror['@me'][payload.name] = payload.newValue; }
|
||||||
|
for (const fn of _lsChangeHandlers) { try { fn(payload.playerId, payload.name, payload.newValue, payload.oldValue); } catch (err) { _send('log', { level: 'error', text: 'leaderstats.onChange: ' + (err && err.message ? err.message : err) }); } }
|
||||||
|
} else if (t === 'achievementUnlocked') {
|
||||||
|
_achUnlocked[payload.id] = true;
|
||||||
} else if (t === 'mouseMove') {
|
} else if (t === 'mouseMove') {
|
||||||
for (const fn of _mouseMoveHandlers) {
|
for (const fn of _mouseMoveHandlers) {
|
||||||
try { fn(payload.x, payload.y); }
|
try { fn(payload.x, payload.y); }
|
||||||
@ -4402,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) {
|
||||||
@ -4411,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 и зовём соответствующие подписчики.
|
||||||
|
|||||||
@ -76,24 +76,37 @@ export class SelectionManager {
|
|||||||
selectByMesh(mesh) {
|
selectByMesh(mesh) {
|
||||||
if (!mesh) return this.clear();
|
if (!mesh) return this.clear();
|
||||||
const m = mesh.metadata;
|
const m = mesh.metadata;
|
||||||
|
// Если объект лежит в папке — клик по СЦЕНЕ выделяет ВСЮ папку целиком
|
||||||
|
// (отдельную часть можно выбрать через дерево). folderId берём из data.
|
||||||
|
const folderIdOf = (kind, id) => {
|
||||||
|
let d = null;
|
||||||
|
if (kind === 'model') d = this.modelManager?.instances?.get(id);
|
||||||
|
else if (kind === 'userModel') d = this.userModelManager?.instances?.get(id);
|
||||||
|
else if (kind === 'primitive') d = this.primitiveManager?.instances?.get(id);
|
||||||
|
return d ? (d.folderId ?? null) : null;
|
||||||
|
};
|
||||||
if (m?.isBlock) {
|
if (m?.isBlock) {
|
||||||
return this.selectBlockAt(m.gridX, m.gridY, m.gridZ);
|
return this.selectBlockAt(m.gridX, m.gridY, m.gridZ);
|
||||||
}
|
}
|
||||||
if (m?.isModel) {
|
if (m?.isModel) {
|
||||||
// Заблокированный объект (Фаза 5.11) не выделяется кликом по
|
|
||||||
// сцене — только через иерархию (чтобы можно было снять lock).
|
|
||||||
const md = this.modelManager?.instances?.get(m.instanceId);
|
const md = this.modelManager?.instances?.get(m.instanceId);
|
||||||
if (md && md.locked) return this.clear();
|
if (md && md.locked) return this.clear();
|
||||||
|
const fid = folderIdOf('model', m.instanceId);
|
||||||
|
if (fid != null) return this.selectFolder(fid);
|
||||||
return this.selectModelByInstanceId(m.instanceId);
|
return this.selectModelByInstanceId(m.instanceId);
|
||||||
}
|
}
|
||||||
if (m?.isUserModel) {
|
if (m?.isUserModel) {
|
||||||
const ud = this.userModelManager?.instances?.get(m.instanceId);
|
const ud = this.userModelManager?.instances?.get(m.instanceId);
|
||||||
if (ud && ud.locked) return this.clear();
|
if (ud && ud.locked) return this.clear();
|
||||||
|
const fid = folderIdOf('userModel', m.instanceId);
|
||||||
|
if (fid != null) return this.selectFolder(fid);
|
||||||
return this.selectUserModelByInstanceId(m.instanceId);
|
return this.selectUserModelByInstanceId(m.instanceId);
|
||||||
}
|
}
|
||||||
if (m?.isPrimitive) {
|
if (m?.isPrimitive) {
|
||||||
const pd = this.primitiveManager?.instances?.get(m.primitiveId);
|
const pd = this.primitiveManager?.instances?.get(m.primitiveId);
|
||||||
if (pd && pd.locked) return this.clear();
|
if (pd && pd.locked) return this.clear();
|
||||||
|
const fid = folderIdOf('primitive', m.primitiveId);
|
||||||
|
if (fid != null) return this.selectFolder(fid);
|
||||||
return this.selectPrimitiveById(m.primitiveId);
|
return this.selectPrimitiveById(m.primitiveId);
|
||||||
}
|
}
|
||||||
if (m?.isSpawn) {
|
if (m?.isSpawn) {
|
||||||
@ -116,6 +129,11 @@ export class SelectionManager {
|
|||||||
primitiveType: data.type,
|
primitiveType: data.type,
|
||||||
x: data.x, y: data.y, z: data.z,
|
x: data.x, y: data.y, z: data.z,
|
||||||
sx: data.sx, sy: data.sy, sz: data.sz,
|
sx: data.sx, sy: data.sy, sz: data.sz,
|
||||||
|
// Вращение — нужно для корректного копирования/дублирования
|
||||||
|
// (без него копия теряла поворот, баг 2026-06-04).
|
||||||
|
rotationX: data.rotationX || 0,
|
||||||
|
rotationY: data.rotationY || 0,
|
||||||
|
rotationZ: data.rotationZ || 0,
|
||||||
color: data.color,
|
color: data.color,
|
||||||
material: data.material,
|
material: data.material,
|
||||||
canCollide: data.canCollide,
|
canCollide: data.canCollide,
|
||||||
@ -194,6 +212,29 @@ export class SelectionManager {
|
|||||||
this._notifyChange();
|
this._notifyChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выделить ПАПКУ целиком: подсветить все объекты внутри + поставить
|
||||||
|
* selection.type='folder'. Групповой gizmo привязывается в BabylonScene.
|
||||||
|
*/
|
||||||
|
selectFolder(folderId) {
|
||||||
|
const fm = this._scene3d?.folderManager;
|
||||||
|
if (!fm) return;
|
||||||
|
const g = fm.getFolderObjects(folderId);
|
||||||
|
this._removeHighlight();
|
||||||
|
this._multi = [];
|
||||||
|
for (const m of g.meshes) this._highlightMesh(m);
|
||||||
|
this._selection = {
|
||||||
|
type: 'folder',
|
||||||
|
folderId,
|
||||||
|
center: g.center,
|
||||||
|
count: g.count,
|
||||||
|
meshes: g.meshes,
|
||||||
|
};
|
||||||
|
this._notifyChange();
|
||||||
|
// Поставить групповой gizmo на пивот папки.
|
||||||
|
this._scene3d?._attachFolderGizmo?.(folderId, g.center);
|
||||||
|
}
|
||||||
|
|
||||||
/** Выделить псевдо-объект «Пол» — настройки grid'а пола. */
|
/** Выделить псевдо-объект «Пол» — настройки grid'а пола. */
|
||||||
selectFloor() {
|
selectFloor() {
|
||||||
if (!this._scene3d) return;
|
if (!this._scene3d) return;
|
||||||
@ -676,10 +717,27 @@ export class SelectionManager {
|
|||||||
this.blockManager.removeBlock(this._selection.gridX, this._selection.gridY, this._selection.gridZ);
|
this.blockManager.removeBlock(this._selection.gridX, this._selection.gridY, this._selection.gridZ);
|
||||||
} else if (this._selection.type === 'model') {
|
} else if (this._selection.type === 'model') {
|
||||||
this.modelManager.removeInstance(this._selection.instanceId);
|
this.modelManager.removeInstance(this._selection.instanceId);
|
||||||
|
this.clear(); this._scene3d?._cleanupOrphanScripts?.(); return;
|
||||||
} else if (this._selection.type === 'userModel') {
|
} else if (this._selection.type === 'userModel') {
|
||||||
this.userModelManager.removeInstance(this._selection.instanceId);
|
this.userModelManager.removeInstance(this._selection.instanceId);
|
||||||
|
this.clear(); this._scene3d?._cleanupOrphanScripts?.(); return;
|
||||||
} else if (this._selection.type === 'primitive') {
|
} else if (this._selection.type === 'primitive') {
|
||||||
this.primitiveManager.removeInstance(this._selection.id);
|
this.primitiveManager.removeInstance(this._selection.id);
|
||||||
|
this.clear(); this._scene3d?._cleanupOrphanScripts?.(); return;
|
||||||
|
} else if (this._selection.type === 'spawn') {
|
||||||
|
// Удаление точки спавна → игрок будет появляться в (0, высота, 0).
|
||||||
|
this._scene3d?.deleteSpawn?.();
|
||||||
|
} else if (this._selection.type === 'folder') {
|
||||||
|
// Папка целиком — удаляем со ВСЕМ содержимым (рекурсивно).
|
||||||
|
const fid = this._selection.folderId;
|
||||||
|
this.clear();
|
||||||
|
this._scene3d?.folderManager?.removeFolder?.(fid, true);
|
||||||
|
// Удаляем скрипты, привязанные к объектам этой папки? Они привязаны
|
||||||
|
// к примитивам, которые removeFolder удалит; скрипты на них чистятся
|
||||||
|
// через _onSceneChange / при сохранении. Дополнительно пусть движок
|
||||||
|
// подчистит «осиротевшие» скрипты.
|
||||||
|
this._scene3d?._cleanupOrphanScripts?.();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
this.clear();
|
this.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
571
src/editor/engine/SkyboxManager.js
Normal file
571
src/editor/engine/SkyboxManager.js
Normal file
@ -0,0 +1,571 @@
|
|||||||
|
/**
|
||||||
|
* SkyboxManager — кастомное небо для сцены (задача 16).
|
||||||
|
*
|
||||||
|
* Реализует процедурный gradient-skybox без внешних текстур (работает offline):
|
||||||
|
* - купол-сфера (BACKSIDE) с ShaderMaterial: плавный градиент верх→низ,
|
||||||
|
* солнечный диск, лёгкая дымка у горизонта;
|
||||||
|
* - low-poly горы на горизонте (как в Roblox-эталоне);
|
||||||
|
* - billboard-облака (плоскости, медленный дрейф);
|
||||||
|
* - атмосферный туман (scene.fog).
|
||||||
|
*
|
||||||
|
* Пресеты: clear-summer-day / cloudy / sunset / starry-night / space /
|
||||||
|
* lowpoly-roblox (+ gradient вручную). Плавный fadeTo между ними.
|
||||||
|
*
|
||||||
|
* API (через game.scene.*):
|
||||||
|
* setSkybox({ preset } | { mode:'gradient', topColor, bottomColor, ... })
|
||||||
|
* setClouds({ enabled, cover, density, speed, color })
|
||||||
|
* setFog({ color, density, near, far } | enabled:false)
|
||||||
|
* skybox.fadeTo(opts, durationSec)
|
||||||
|
* skybox.setSunDirection({x,y,z})
|
||||||
|
*
|
||||||
|
* Фича-парность: при портировании в плеер — тот же модуль в rublox-player/src/engine/.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
Color3, Color4, Vector3,
|
||||||
|
MeshBuilder, ShaderMaterial, Effect, StandardMaterial, Texture,
|
||||||
|
DynamicTexture, VertexData, Mesh,
|
||||||
|
} from '@babylonjs/core';
|
||||||
|
|
||||||
|
// ── Шейдер градиентного неба ──────────────────────────────────────────────
|
||||||
|
// Купол изнутри: цвет интерполируется по высоте (y от -1 низ до +1 верх),
|
||||||
|
// плюс солнечный диск и осветление у горизонта (дымка).
|
||||||
|
const SKY_VERT = `
|
||||||
|
precision highp float;
|
||||||
|
attribute vec3 position;
|
||||||
|
uniform mat4 worldViewProjection;
|
||||||
|
varying vec3 vDir;
|
||||||
|
void main(void){
|
||||||
|
vDir = normalize(position);
|
||||||
|
gl_Position = worldViewProjection * vec4(position, 1.0);
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const SKY_FRAG = `
|
||||||
|
precision highp float;
|
||||||
|
varying vec3 vDir;
|
||||||
|
uniform vec3 topColor;
|
||||||
|
uniform vec3 bottomColor;
|
||||||
|
uniform vec3 horizonColor;
|
||||||
|
uniform vec3 sunDir;
|
||||||
|
uniform vec3 sunColor;
|
||||||
|
uniform float sunSize; // 0..1 угловой радиус
|
||||||
|
uniform float horizonHaze; // 0..1 сила дымки у горизонта
|
||||||
|
void main(void){
|
||||||
|
float h = clamp(vDir.y * 0.5 + 0.5, 0.0, 1.0); // 0 низ .. 1 верх
|
||||||
|
// Градиент: низ→горизонт→верх (двойная интерполяция через горизонт у h~0.5)
|
||||||
|
vec3 col;
|
||||||
|
if (h < 0.5) {
|
||||||
|
col = mix(bottomColor, horizonColor, h * 2.0);
|
||||||
|
} else {
|
||||||
|
col = mix(horizonColor, topColor, (h - 0.5) * 2.0);
|
||||||
|
}
|
||||||
|
// Дымка у горизонта — осветление узкой полосы около h=0.5
|
||||||
|
float haze = smoothstep(0.5, 0.0, abs(h - 0.5)) * horizonHaze;
|
||||||
|
col = mix(col, horizonColor + vec3(0.08), haze * 0.5);
|
||||||
|
// Солнечный диск + гало
|
||||||
|
float d = distance(normalize(vDir), normalize(sunDir));
|
||||||
|
float disk = smoothstep(sunSize, sunSize * 0.4, d);
|
||||||
|
float glow = smoothstep(sunSize * 6.0, 0.0, d) * 0.35;
|
||||||
|
col += sunColor * disk;
|
||||||
|
col += sunColor * glow;
|
||||||
|
gl_FragColor = vec4(col, 1.0);
|
||||||
|
}`;
|
||||||
|
|
||||||
|
let _shaderRegistered = false;
|
||||||
|
function registerSkyShader() {
|
||||||
|
if (_shaderRegistered) return;
|
||||||
|
Effect.ShadersStore['kubikonSkyVertexShader'] = SKY_VERT;
|
||||||
|
Effect.ShadersStore['kubikonSkyFragmentShader'] = SKY_FRAG;
|
||||||
|
_shaderRegistered = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hexToRgb = (hex) => {
|
||||||
|
if (Array.isArray(hex)) return hex;
|
||||||
|
let h = String(hex || '#ffffff').replace('#', '').trim();
|
||||||
|
// Короткая форма #fff → #ffffff.
|
||||||
|
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
|
||||||
|
if (h.length < 6) h = (h + 'ffffff').slice(0, 6);
|
||||||
|
const r = parseInt(h.substring(0, 2), 16);
|
||||||
|
const g = parseInt(h.substring(2, 4), 16);
|
||||||
|
const b = parseInt(h.substring(4, 6), 16);
|
||||||
|
return [
|
||||||
|
(Number.isFinite(r) ? r : 255) / 255,
|
||||||
|
(Number.isFinite(g) ? g : 255) / 255,
|
||||||
|
(Number.isFinite(b) ? b : 255) / 255,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Пресеты неба ──────────────────────────────────────────────────────────
|
||||||
|
// top/bottom/horizon — цвета купола; sun — направление/цвет/размер солнца;
|
||||||
|
// mountains — рисовать ли горы; clouds — параметры облаков; fog — туман;
|
||||||
|
// stars — звёздное небо (для ночи/космоса).
|
||||||
|
const PRESETS = {
|
||||||
|
'clear-summer-day': {
|
||||||
|
top: '#3d7fe0', horizon: '#bcd9f2', bottom: '#dcebf7',
|
||||||
|
sunDir: [0.3, 0.85, 0.4], sunColor: '#fff6d8', sunSize: 0.035, haze: 0.6,
|
||||||
|
mountains: false,
|
||||||
|
clouds: { enabled: true, cover: 0.25, color: '#ffffff', speed: 0.015 },
|
||||||
|
fog: { color: '#cfe2f2', density: 0.0035 },
|
||||||
|
light: { sunIntensity: 1.0, sunColor: '#fff6e0', hemiIntensity: 0.7, ambient: '#b9d4ee' },
|
||||||
|
},
|
||||||
|
'lowpoly-roblox': {
|
||||||
|
top: '#4a93e6', horizon: '#cfe6f5', bottom: '#e6f1fa',
|
||||||
|
sunDir: [0.4, 0.8, 0.45], sunColor: '#fffbe6', sunSize: 0.03, haze: 0.85,
|
||||||
|
mountains: true,
|
||||||
|
clouds: { enabled: true, cover: 0.45, color: '#ffffff', speed: 0.012 },
|
||||||
|
fog: { color: '#e2eef7', density: 0.005 },
|
||||||
|
light: { sunIntensity: 0.95, sunColor: '#fff7e0', hemiIntensity: 0.75, ambient: '#cfe6f5' },
|
||||||
|
},
|
||||||
|
'cloudy': {
|
||||||
|
top: '#8fa6bd', horizon: '#c2ccd6', bottom: '#d8dde2',
|
||||||
|
sunDir: [0.2, 0.7, 0.3], sunColor: '#e8e8e8', sunSize: 0.0, haze: 0.4,
|
||||||
|
mountains: false,
|
||||||
|
clouds: { enabled: true, cover: 0.8, color: '#e8ebef', speed: 0.02 },
|
||||||
|
fog: { color: '#cfd6dd', density: 0.008 },
|
||||||
|
light: { sunIntensity: 0.5, sunColor: '#dfe4ea', hemiIntensity: 0.8, ambient: '#c2ccd6' },
|
||||||
|
},
|
||||||
|
'sunset': {
|
||||||
|
top: '#2a3a6b', horizon: '#f5915a', bottom: '#f7c98a',
|
||||||
|
sunDir: [0.7, 0.12, -0.2], sunColor: '#ffd28a', sunSize: 0.05, haze: 1.0,
|
||||||
|
mountains: true,
|
||||||
|
clouds: { enabled: true, cover: 0.35, color: '#ffd9b3', speed: 0.01 },
|
||||||
|
fog: { color: '#f0b483', density: 0.006 },
|
||||||
|
light: { sunIntensity: 0.7, sunColor: '#ff9a52', hemiIntensity: 0.5, ambient: '#9a6a78' },
|
||||||
|
},
|
||||||
|
'starry-night': {
|
||||||
|
top: '#070b1f', horizon: '#1b2547', bottom: '#243056',
|
||||||
|
sunDir: [-0.4, 0.75, 0.3], sunColor: '#cdd6ff', sunSize: 0.02, haze: 0.3,
|
||||||
|
mountains: true, stars: true,
|
||||||
|
clouds: { enabled: false },
|
||||||
|
fog: { color: '#141c38', density: 0.004 },
|
||||||
|
light: { sunIntensity: 0.18, sunColor: '#9aa8d8', hemiIntensity: 0.25, ambient: '#1b2547' },
|
||||||
|
},
|
||||||
|
'space': {
|
||||||
|
top: '#02030a', horizon: '#06070f', bottom: '#0a0c18',
|
||||||
|
sunDir: [0.5, 0.6, 0.4], sunColor: '#ffffff', sunSize: 0.015, haze: 0.0,
|
||||||
|
mountains: false, stars: true,
|
||||||
|
clouds: { enabled: false },
|
||||||
|
fog: { enabled: false },
|
||||||
|
light: { sunIntensity: 0.6, sunColor: '#ffffff', hemiIntensity: 0.2, ambient: '#10121c' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export class SkyboxManager {
|
||||||
|
constructor(scene, hemiLight, sunLight) {
|
||||||
|
this.scene = scene;
|
||||||
|
this.hemiLight = hemiLight || null; // ambient
|
||||||
|
this.sunLight = sunLight || null; // directional (тени)
|
||||||
|
this._dome = null;
|
||||||
|
this._mat = null;
|
||||||
|
this._mountains = null;
|
||||||
|
this._clouds = []; // [{mesh, baseX, speed}]
|
||||||
|
this._cloudRoot = null;
|
||||||
|
this._stars = null;
|
||||||
|
this._fade = null; // активный fadeTo {from,to,t,dur}
|
||||||
|
this._state = this._defaultState();
|
||||||
|
registerSkyShader();
|
||||||
|
this._buildDome();
|
||||||
|
}
|
||||||
|
|
||||||
|
_defaultState() {
|
||||||
|
return {
|
||||||
|
mode: 'gradient',
|
||||||
|
top: '#4a93e6', horizon: '#cfe6f5', bottom: '#e6f1fa',
|
||||||
|
sunDir: [0.4, 0.8, 0.45], sunColor: '#fffbe6', sunSize: 0.03, haze: 0.8,
|
||||||
|
mountains: false, stars: false,
|
||||||
|
clouds: { enabled: false, cover: 0.4, color: '#ffffff', speed: 0.012 },
|
||||||
|
fog: { enabled: false, color: '#dde8f2', density: 0.005 },
|
||||||
|
light: { sunIntensity: 0.95, sunColor: '#fff7e0', hemiIntensity: 0.75, ambient: '#cfe6f5' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Купол ──────────────────────────────────────────────────────────────
|
||||||
|
_buildDome() {
|
||||||
|
const dome = MeshBuilder.CreateSphere('kubikonSkyDome', {
|
||||||
|
diameter: 1000, segments: 24, sideOrientation: Mesh.BACKSIDE,
|
||||||
|
}, this.scene);
|
||||||
|
dome.isPickable = false;
|
||||||
|
dome.infiniteDistance = true; // не двигается с камерой
|
||||||
|
dome.renderingGroupId = 0;
|
||||||
|
dome.applyFog = false;
|
||||||
|
|
||||||
|
const mat = new ShaderMaterial('kubikonSkyMat', this.scene, {
|
||||||
|
vertex: 'kubikonSky', fragment: 'kubikonSky',
|
||||||
|
}, {
|
||||||
|
attributes: ['position'],
|
||||||
|
uniforms: ['worldViewProjection', 'topColor', 'bottomColor',
|
||||||
|
'horizonColor', 'sunDir', 'sunColor', 'sunSize', 'horizonHaze'],
|
||||||
|
});
|
||||||
|
mat.backFaceCulling = false;
|
||||||
|
mat.disableDepthWrite = true; // небо всегда позади
|
||||||
|
dome.material = mat;
|
||||||
|
this._dome = dome;
|
||||||
|
this._mat = mat;
|
||||||
|
this._applyShaderUniforms();
|
||||||
|
}
|
||||||
|
|
||||||
|
_applyShaderUniforms() {
|
||||||
|
const s = this._state;
|
||||||
|
const m = this._mat;
|
||||||
|
if (!m) return;
|
||||||
|
m.setColor3('topColor', Color3.FromArray(hexToRgb(s.top)));
|
||||||
|
m.setColor3('bottomColor', Color3.FromArray(hexToRgb(s.bottom)));
|
||||||
|
m.setColor3('horizonColor', Color3.FromArray(hexToRgb(s.horizon)));
|
||||||
|
const sd = Array.isArray(s.sunDir) ? s.sunDir : [0.4, 0.8, 0.45];
|
||||||
|
m.setVector3('sunDir', new Vector3(sd[0], sd[1], sd[2]).normalize());
|
||||||
|
m.setColor3('sunColor', Color3.FromArray(hexToRgb(s.sunColor)));
|
||||||
|
m.setFloat('sunSize', s.sunSize || 0.03);
|
||||||
|
m.setFloat('horizonHaze', s.haze != null ? s.haze : 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Горы (low-poly на горизонте) ────────────────────────────────────────
|
||||||
|
_buildMountains(colorHex) {
|
||||||
|
this._disposeMountains();
|
||||||
|
const positions = [], indices = [];
|
||||||
|
const ringR = 420, baseY = -10, segs = 64;
|
||||||
|
// Кольцо из треугольных пиков переменной высоты — стилизованный силуэт.
|
||||||
|
let vi = 0;
|
||||||
|
for (let i = 0; i < segs; i++) {
|
||||||
|
const a0 = (i / segs) * Math.PI * 2;
|
||||||
|
const a1 = ((i + 1) / segs) * Math.PI * 2;
|
||||||
|
const am = (a0 + a1) / 2;
|
||||||
|
// Псевдослучайная высота пика (детерминированно от индекса).
|
||||||
|
const hh = 40 + Math.abs(Math.sin(i * 1.7) * Math.cos(i * 0.6)) * 130;
|
||||||
|
const x0 = Math.cos(a0) * ringR, z0 = Math.sin(a0) * ringR;
|
||||||
|
const x1 = Math.cos(a1) * ringR, z1 = Math.sin(a1) * ringR;
|
||||||
|
const xm = Math.cos(am) * ringR, zm = Math.sin(am) * ringR;
|
||||||
|
positions.push(x0, baseY, z0, x1, baseY, z1, xm, baseY + hh, zm);
|
||||||
|
indices.push(vi, vi + 1, vi + 2);
|
||||||
|
vi += 3;
|
||||||
|
}
|
||||||
|
const vd = new VertexData();
|
||||||
|
vd.positions = positions; vd.indices = indices;
|
||||||
|
const normals = [];
|
||||||
|
VertexData.ComputeNormals(positions, indices, normals);
|
||||||
|
vd.normals = normals;
|
||||||
|
const mesh = new Mesh('kubikonSkyMountains', this.scene);
|
||||||
|
vd.applyToMesh(mesh);
|
||||||
|
mesh.isPickable = false;
|
||||||
|
mesh.applyFog = true; // горы выцветают в туман (атмосфера)
|
||||||
|
mesh.renderingGroupId = 0;
|
||||||
|
const mat = new StandardMaterial('kubikonSkyMountainsMat', this.scene);
|
||||||
|
const c = hexToRgb(colorHex || '#8fa98a');
|
||||||
|
mat.diffuseColor = new Color3(c[0], c[1], c[2]);
|
||||||
|
mat.specularColor = new Color3(0, 0, 0);
|
||||||
|
mat.emissiveColor = new Color3(c[0] * 0.25, c[1] * 0.25, c[2] * 0.25);
|
||||||
|
mesh.material = mat;
|
||||||
|
this._mountains = mesh;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposeMountains() {
|
||||||
|
if (this._mountains) { this._mountains.material?.dispose(); this._mountains.dispose(); this._mountains = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Облака (billboard-плоскости) ────────────────────────────────────────
|
||||||
|
_buildClouds(opts) {
|
||||||
|
this._disposeClouds();
|
||||||
|
const o = opts || {};
|
||||||
|
if (!o.enabled) return;
|
||||||
|
const cover = o.cover != null ? o.cover : 0.4;
|
||||||
|
const count = Math.round(4 + cover * 16); // 4..20 облаков
|
||||||
|
const tex = this._makeCloudTexture(o.color || '#ffffff');
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const w = 60 + Math.random() * 90;
|
||||||
|
const plane = MeshBuilder.CreatePlane(`kubikonCloud_${i}`, { width: w, height: w * 0.55 }, this.scene);
|
||||||
|
plane.billboardMode = Mesh.BILLBOARDMODE_ALL;
|
||||||
|
plane.isPickable = false;
|
||||||
|
plane.applyFog = false;
|
||||||
|
plane.renderingGroupId = 0;
|
||||||
|
const mat = new StandardMaterial(`kubikonCloudMat_${i}`, this.scene);
|
||||||
|
mat.diffuseTexture = tex;
|
||||||
|
mat.opacityTexture = tex;
|
||||||
|
mat.emissiveColor = new Color3(1, 1, 1);
|
||||||
|
mat.disableLighting = true;
|
||||||
|
mat.backFaceCulling = false;
|
||||||
|
plane.material = mat;
|
||||||
|
const ang = Math.random() * Math.PI * 2;
|
||||||
|
const rad = 150 + Math.random() * 200;
|
||||||
|
const x = Math.cos(ang) * rad;
|
||||||
|
const z = Math.sin(ang) * rad;
|
||||||
|
const y = 90 + Math.random() * 70;
|
||||||
|
plane.position.set(x, y, z);
|
||||||
|
this._clouds.push({ mesh: plane, speed: (o.speed || 0.012) * (0.6 + Math.random() * 0.8) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Процедурная мягкая текстура-облако (radial-gradient на DynamicTexture). */
|
||||||
|
_makeCloudTexture(colorHex) {
|
||||||
|
const size = 256;
|
||||||
|
const dt = new DynamicTexture('kubikonCloudTex', { width: size, height: size }, this.scene, false);
|
||||||
|
const ctx = dt.getContext();
|
||||||
|
ctx.clearRect(0, 0, size, size);
|
||||||
|
const c = hexToRgb(colorHex);
|
||||||
|
const rgb = `${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)}`;
|
||||||
|
// Несколько перекрывающихся мягких кругов → пухлое облако.
|
||||||
|
const blobs = [[128, 140, 70], [90, 150, 50], [170, 150, 55], [128, 110, 55], [110, 130, 45], [150, 130, 45]];
|
||||||
|
for (const [bx, by, br] of blobs) {
|
||||||
|
const g = ctx.createRadialGradient(bx, by, 0, bx, by, br);
|
||||||
|
g.addColorStop(0, `rgba(${rgb},0.9)`);
|
||||||
|
g.addColorStop(0.6, `rgba(${rgb},0.5)`);
|
||||||
|
g.addColorStop(1, `rgba(${rgb},0)`);
|
||||||
|
ctx.fillStyle = g;
|
||||||
|
ctx.beginPath(); ctx.arc(bx, by, br, 0, Math.PI * 2); ctx.fill();
|
||||||
|
}
|
||||||
|
dt.hasAlpha = true;
|
||||||
|
dt.update();
|
||||||
|
return dt;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposeClouds() {
|
||||||
|
for (const c of this._clouds) { c.mesh.material?.diffuseTexture?.dispose?.(); c.mesh.material?.dispose(); c.mesh.dispose(); }
|
||||||
|
this._clouds = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Звёзды (точки на куполе) ─────────────────────────────────────────────
|
||||||
|
_buildStars(enabled) {
|
||||||
|
this._disposeStars();
|
||||||
|
if (!enabled) return;
|
||||||
|
const size = 1024;
|
||||||
|
const dt = new DynamicTexture('kubikonStarsTex', { width: size, height: size }, this.scene, false);
|
||||||
|
const ctx = dt.getContext();
|
||||||
|
ctx.fillStyle = 'rgba(0,0,0,0)'; ctx.clearRect(0, 0, size, size);
|
||||||
|
for (let i = 0; i < 600; i++) {
|
||||||
|
const x = Math.random() * size, y = Math.random() * size;
|
||||||
|
const r = Math.random() * 1.4 + 0.3;
|
||||||
|
const a = 0.4 + Math.random() * 0.6;
|
||||||
|
ctx.fillStyle = `rgba(255,255,255,${a})`;
|
||||||
|
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
|
||||||
|
}
|
||||||
|
dt.hasAlpha = true; dt.update();
|
||||||
|
const dome = MeshBuilder.CreateSphere('kubikonStarsDome', {
|
||||||
|
diameter: 980, segments: 16, sideOrientation: Mesh.BACKSIDE,
|
||||||
|
}, this.scene);
|
||||||
|
dome.isPickable = false; dome.infiniteDistance = true;
|
||||||
|
dome.applyFog = false; dome.renderingGroupId = 0;
|
||||||
|
const mat = new StandardMaterial('kubikonStarsMat', this.scene);
|
||||||
|
mat.diffuseTexture = dt; mat.opacityTexture = dt;
|
||||||
|
mat.emissiveColor = new Color3(1, 1, 1);
|
||||||
|
mat.disableLighting = true; mat.backFaceCulling = false;
|
||||||
|
mat.disableDepthWrite = true;
|
||||||
|
dome.material = mat;
|
||||||
|
this._stars = dome;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposeStars() {
|
||||||
|
if (this._stars) { this._stars.material?.diffuseTexture?.dispose?.(); this._stars.material?.dispose(); this._stars.dispose(); this._stars = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Туман ────────────────────────────────────────────────────────────────
|
||||||
|
_applyFog(fog) {
|
||||||
|
if (!this.scene) return;
|
||||||
|
if (fog && fog.enabled !== false && (fog.density != null || fog.color)) {
|
||||||
|
this.scene.fogMode = 2; // EXP
|
||||||
|
const c = hexToRgb(fog.color || '#dde8f2');
|
||||||
|
this.scene.fogColor = new Color3(c[0], c[1], c[2]);
|
||||||
|
this.scene.fogDensity = fog.density != null ? fog.density : 0.005;
|
||||||
|
} else if (fog && fog.enabled === false) {
|
||||||
|
this.scene.fogMode = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Освещение (единый источник: небо управляет светом сцены) ─────────────
|
||||||
|
/** Выставить направление/яркость солнца и ambient под текущее небо. */
|
||||||
|
_applyLighting(light, sunDir) {
|
||||||
|
if (this.sunLight && sunDir) {
|
||||||
|
// DirectionalLight.direction указывает КУДА падает свет → от солнца вниз.
|
||||||
|
const d = new Vector3(-sunDir[0], -sunDir[1], -sunDir[2]);
|
||||||
|
if (d.lengthSquared() > 0) { d.normalize(); this.sunLight.direction = d; }
|
||||||
|
}
|
||||||
|
if (!light) return;
|
||||||
|
if (this.sunLight) {
|
||||||
|
if (typeof light.sunIntensity === 'number') this.sunLight.intensity = light.sunIntensity;
|
||||||
|
if (light.sunColor) this.sunLight.diffuse = Color3.FromArray(hexToRgb(light.sunColor));
|
||||||
|
}
|
||||||
|
if (this.hemiLight) {
|
||||||
|
if (typeof light.hemiIntensity === 'number') this.hemiLight.intensity = light.hemiIntensity;
|
||||||
|
if (light.ambient) this.hemiLight.groundColor = Color3.FromArray(hexToRgb(light.ambient));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Применить пресет или ручные опции gradient. */
|
||||||
|
setSkybox(opts) {
|
||||||
|
if (!opts) return;
|
||||||
|
const preset = opts.preset && PRESETS[opts.preset] ? { ...PRESETS[opts.preset] } : null;
|
||||||
|
const s = this._state;
|
||||||
|
if (preset) {
|
||||||
|
s.top = preset.top; s.horizon = preset.horizon; s.bottom = preset.bottom;
|
||||||
|
s.sunDir = preset.sunDir; s.sunColor = preset.sunColor; s.sunSize = preset.sunSize;
|
||||||
|
s.haze = preset.haze; s.mountains = !!preset.mountains; s.stars = !!preset.stars;
|
||||||
|
s.clouds = { ...(preset.clouds || { enabled: false }) };
|
||||||
|
s.fog = { enabled: preset.fog?.enabled !== false, ...(preset.fog || {}) };
|
||||||
|
s.light = preset.light || null;
|
||||||
|
this._applyLighting(preset.light, preset.sunDir);
|
||||||
|
} else {
|
||||||
|
// Ручной gradient: { topColor, bottomColor, horizonColor, sunDirection, sunColor, sunSize }
|
||||||
|
if (opts.topColor) s.top = opts.topColor;
|
||||||
|
if (opts.bottomColor) s.bottom = opts.bottomColor;
|
||||||
|
if (opts.horizonColor) s.horizon = opts.horizonColor;
|
||||||
|
if (opts.sunDirection) s.sunDir = [opts.sunDirection.x, opts.sunDirection.y, opts.sunDirection.z];
|
||||||
|
if (opts.sunColor) s.sunColor = opts.sunColor;
|
||||||
|
if (typeof opts.sunSize === 'number') s.sunSize = opts.sunSize;
|
||||||
|
if (typeof opts.haze === 'number') s.haze = opts.haze;
|
||||||
|
if (typeof opts.mountains === 'boolean') s.mountains = opts.mountains;
|
||||||
|
if (typeof opts.stars === 'boolean') s.stars = opts.stars;
|
||||||
|
}
|
||||||
|
this._rebuildAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Облака поверх любого режима. */
|
||||||
|
setClouds(opts) {
|
||||||
|
if (!opts) return;
|
||||||
|
this._state.clouds = { ...this._state.clouds, ...opts };
|
||||||
|
if (this._state.clouds.enabled == null) this._state.clouds.enabled = true;
|
||||||
|
this._buildClouds(this._state.clouds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Атмосферный туман. */
|
||||||
|
setFog(opts) {
|
||||||
|
if (!opts) { return; }
|
||||||
|
this._state.fog = { ...this._state.fog, ...opts };
|
||||||
|
if (opts.enabled == null) this._state.fog.enabled = true;
|
||||||
|
this._applyFog(this._state.fog);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Установить направление солнца (для программной анимации). */
|
||||||
|
setSunDirection(dir) {
|
||||||
|
if (!dir) return;
|
||||||
|
this._state.sunDir = [dir.x, dir.y, dir.z];
|
||||||
|
this._applyShaderUniforms();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Плавный переход к пресету/опциям за durationSec секунд (цвета купола + туман). */
|
||||||
|
fadeTo(opts, durationSec = 2) {
|
||||||
|
const target = opts?.preset && PRESETS[opts.preset] ? { ...PRESETS[opts.preset] } : null;
|
||||||
|
if (!target) { this.setSkybox(opts); return; }
|
||||||
|
// Запоминаем стартовые цвета и целевые — анимируем в tick().
|
||||||
|
this._fade = {
|
||||||
|
t: 0, dur: Math.max(0.1, durationSec),
|
||||||
|
from: {
|
||||||
|
top: hexToRgb(this._state.top), horizon: hexToRgb(this._state.horizon),
|
||||||
|
bottom: hexToRgb(this._state.bottom), sunColor: hexToRgb(this._state.sunColor),
|
||||||
|
sunDir: this._state.sunDir.slice(), sunSize: this._state.sunSize, haze: this._state.haze,
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
top: hexToRgb(target.top), horizon: hexToRgb(target.horizon),
|
||||||
|
bottom: hexToRgb(target.bottom), sunColor: hexToRgb(target.sunColor),
|
||||||
|
sunDir: target.sunDir.slice(), sunSize: target.sunSize, haze: target.haze,
|
||||||
|
},
|
||||||
|
target,
|
||||||
|
};
|
||||||
|
// Немедленно перестраиваем «дискретные» части (горы/звёзды/облака/туман
|
||||||
|
// целевого пресета появляются сразу, цвета купола — плавно).
|
||||||
|
const s = this._state;
|
||||||
|
s.mountains = !!target.mountains; s.stars = !!target.stars;
|
||||||
|
s.clouds = { ...(target.clouds || { enabled: false }) };
|
||||||
|
s.fog = { enabled: target.fog?.enabled !== false, ...(target.fog || {}) };
|
||||||
|
s.light = target.light || null;
|
||||||
|
this._rebuildExtras();
|
||||||
|
// Запоминаем стартовые/целевые значения света для плавной анимации.
|
||||||
|
if (target.light) {
|
||||||
|
this._fade.lightFrom = {
|
||||||
|
sunInt: this.sunLight?.intensity ?? 1,
|
||||||
|
hemiInt: this.hemiLight?.intensity ?? 0.7,
|
||||||
|
};
|
||||||
|
this._fade.lightTo = {
|
||||||
|
sunInt: target.light.sunIntensity ?? 1,
|
||||||
|
hemiInt: target.light.hemiIntensity ?? 0.7,
|
||||||
|
sunColor: target.light.sunColor, ambient: target.light.ambient,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Пересобрать всё (купол-uniforms + горы + звёзды + облака + туман + свет). */
|
||||||
|
_rebuildAll() {
|
||||||
|
this._applyShaderUniforms();
|
||||||
|
this._rebuildExtras();
|
||||||
|
this._applyLighting(this._state.light, this._state.sunDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
_rebuildExtras() {
|
||||||
|
const s = this._state;
|
||||||
|
if (s.mountains) {
|
||||||
|
// Цвет гор подбираем под пресет (днём зеленовато-серый, ночью тёмный).
|
||||||
|
const mc = s.stars ? '#2a3550' : '#8fa98a';
|
||||||
|
this._buildMountains(mc);
|
||||||
|
} else this._disposeMountains();
|
||||||
|
this._buildStars(!!s.stars);
|
||||||
|
this._buildClouds(s.clouds);
|
||||||
|
this._applyFog(s.fog);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Вызывать каждый кадр: дрейф облаков + анимация fadeTo. */
|
||||||
|
tick(dt) {
|
||||||
|
// Дрейф облаков по кругу.
|
||||||
|
for (const c of this._clouds) {
|
||||||
|
c.mesh.position.x += c.speed * dt * 60;
|
||||||
|
if (c.mesh.position.x > 380) c.mesh.position.x = -380;
|
||||||
|
}
|
||||||
|
// Анимация перехода неба.
|
||||||
|
if (this._fade) {
|
||||||
|
this._fade.t += dt;
|
||||||
|
const k = Math.min(1, this._fade.t / this._fade.dur);
|
||||||
|
const f = this._fade.from, t = this._fade.to, m = this._mat;
|
||||||
|
const mix = (a, b) => [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k];
|
||||||
|
if (m) {
|
||||||
|
m.setColor3('topColor', Color3.FromArray(mix(f.top, t.top)));
|
||||||
|
m.setColor3('bottomColor', Color3.FromArray(mix(f.bottom, t.bottom)));
|
||||||
|
m.setColor3('horizonColor', Color3.FromArray(mix(f.horizon, t.horizon)));
|
||||||
|
m.setColor3('sunColor', Color3.FromArray(mix(f.sunColor, t.sunColor)));
|
||||||
|
const sd = mix(f.sunDir, t.sunDir);
|
||||||
|
m.setVector3('sunDir', new Vector3(sd[0], sd[1], sd[2]).normalize());
|
||||||
|
m.setFloat('sunSize', f.sunSize + (t.sunSize - f.sunSize) * k);
|
||||||
|
m.setFloat('horizonHaze', f.haze + (t.haze - f.haze) * k);
|
||||||
|
// Плавно ведём направление солнца (свет) к целевому (используем sd выше).
|
||||||
|
if (this.sunLight) {
|
||||||
|
const d = new Vector3(-sd[0], -sd[1], -sd[2]);
|
||||||
|
if (d.lengthSquared() > 0) { d.normalize(); this.sunLight.direction = d; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Плавно ведём яркость/ambient света.
|
||||||
|
if (this._fade.lightFrom && this._fade.lightTo) {
|
||||||
|
const lf = this._fade.lightFrom, lt = this._fade.lightTo;
|
||||||
|
if (this.sunLight) {
|
||||||
|
this.sunLight.intensity = lf.sunInt + (lt.sunInt - lf.sunInt) * k;
|
||||||
|
if (lt.sunColor) this.sunLight.diffuse = Color3.FromArray(hexToRgb(lt.sunColor));
|
||||||
|
}
|
||||||
|
if (this.hemiLight) {
|
||||||
|
this.hemiLight.intensity = lf.hemiInt + (lt.hemiInt - lf.hemiInt) * k;
|
||||||
|
if (lt.ambient) this.hemiLight.groundColor = Color3.FromArray(hexToRgb(lt.ambient));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (k >= 1) {
|
||||||
|
// Зафиксировать целевое состояние в _state (как hex).
|
||||||
|
const tp = this._fade.target;
|
||||||
|
Object.assign(this._state, {
|
||||||
|
top: tp.top, horizon: tp.horizon, bottom: tp.bottom,
|
||||||
|
sunColor: tp.sunColor, sunDir: tp.sunDir.slice(),
|
||||||
|
sunSize: tp.sunSize, haze: tp.haze,
|
||||||
|
});
|
||||||
|
this._fade = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
serialize() {
|
||||||
|
return { ...this._state, _active: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
load(data) {
|
||||||
|
if (!data) return;
|
||||||
|
this._state = { ...this._defaultState(), ...data };
|
||||||
|
this._rebuildAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this._disposeMountains();
|
||||||
|
this._disposeClouds();
|
||||||
|
this._disposeStars();
|
||||||
|
if (this._dome) { this._mat?.dispose(); this._dome.dispose(); this._dome = null; }
|
||||||
|
}
|
||||||
|
}
|
||||||
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 };
|
||||||
|
}
|
||||||
@ -599,6 +599,8 @@ export class UserModelManager {
|
|||||||
// instanceId — чтобы target-скрипты могли стабильно ссылаться
|
// instanceId — чтобы target-скрипты могли стабильно ссылаться
|
||||||
// на конкретный инстанс после перезагрузки.
|
// на конкретный инстанс после перезагрузки.
|
||||||
instanceId: inst.instanceId,
|
instanceId: inst.instanceId,
|
||||||
|
// folderId — принадлежность к папке (иначе вываливается после Play/Stop).
|
||||||
|
...(inst.folderId != null ? { folderId: inst.folderId } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return arr;
|
return arr;
|
||||||
@ -663,7 +665,13 @@ export class UserModelManager {
|
|||||||
forceInstanceId: item.instanceId,
|
forceInstanceId: item.instanceId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (id != null) loaded++;
|
if (id != null) {
|
||||||
|
loaded++;
|
||||||
|
if (item.folderId != null) { // восстановить папку
|
||||||
|
const inst = this.instances.get(id);
|
||||||
|
if (inst) inst.folderId = item.folderId;
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[UserModelManager] failed to load instance', item, e);
|
console.warn('[UserModelManager] failed to load instance', item, e);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -90,6 +90,18 @@ export class WeaponSystem {
|
|||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
// Если UI-режим курсора — не стреляем (мышь работает по GUI)
|
// Если UI-режим курсора — не стреляем (мышь работает по GUI)
|
||||||
if (this.scene3d?.player?.isUiCursorMode?.()) return;
|
if (this.scene3d?.player?.isUiCursorMode?.()) return;
|
||||||
|
// Если курсор СВОБОДЕН (нет pointer-lock — обычно 3-е лицо) — стреляем
|
||||||
|
// ТУДА, КУДА КЛИКНУЛИ, а не в центр камеры. При pointer-lock курсор в
|
||||||
|
// центре экрана → используем прицел камеры (aim не задаём).
|
||||||
|
if (document.pointerLockElement !== canvas) {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const cx = (e.clientX != null ? e.clientX : 0) - rect.left;
|
||||||
|
const cy = (e.clientY != null ? e.clientY : 0) - rect.top;
|
||||||
|
if (cx >= 0 && cy >= 0 && cx <= rect.width && cy <= rect.height) {
|
||||||
|
this.setAimScreenPoint(cx * (canvas.width / rect.width),
|
||||||
|
cy * (canvas.height / rect.height));
|
||||||
|
}
|
||||||
|
}
|
||||||
this._mouseDown = true;
|
this._mouseDown = true;
|
||||||
this._tryFire();
|
this._tryFire();
|
||||||
};
|
};
|
||||||
@ -97,14 +109,28 @@ export class WeaponSystem {
|
|||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
this._mouseDown = false;
|
this._mouseDown = false;
|
||||||
};
|
};
|
||||||
|
// При свободном курсоре (3-е лицо) запоминаем позицию мыши — чтобы
|
||||||
|
// авто-огонь при удержании ЛКМ продолжал стрелять в точку курсора.
|
||||||
|
const onMove = (e) => {
|
||||||
|
if (!this._mouseDown) return;
|
||||||
|
if (document.pointerLockElement === canvas) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const cx = (e.clientX != null ? e.clientX : 0) - rect.left;
|
||||||
|
const cy = (e.clientY != null ? e.clientY : 0) - rect.top;
|
||||||
|
if (cx >= 0 && cy >= 0 && cx <= rect.width && cy <= rect.height) {
|
||||||
|
this._holdAim = { x: cx * (canvas.width / rect.width), y: cy * (canvas.height / rect.height) };
|
||||||
|
}
|
||||||
|
};
|
||||||
const onKey = (e) => {
|
const onKey = (e) => {
|
||||||
if (e.code === 'KeyR') this.reload();
|
if (e.code === 'KeyR') this.reload();
|
||||||
};
|
};
|
||||||
canvas.addEventListener('mousedown', onDown);
|
canvas.addEventListener('mousedown', onDown);
|
||||||
window.addEventListener('mouseup', onUp);
|
window.addEventListener('mouseup', onUp);
|
||||||
|
window.addEventListener('mousemove', onMove);
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
this._listeners.push({ target: canvas, type: 'mousedown', fn: onDown });
|
this._listeners.push({ target: canvas, type: 'mousedown', fn: onDown });
|
||||||
this._listeners.push({ target: window, type: 'mouseup', fn: onUp });
|
this._listeners.push({ target: window, type: 'mouseup', fn: onUp });
|
||||||
|
this._listeners.push({ target: window, type: 'mousemove', fn: onMove });
|
||||||
this._listeners.push({ target: window, type: 'keydown', fn: onKey });
|
this._listeners.push({ target: window, type: 'keydown', fn: onKey });
|
||||||
|
|
||||||
// Регистрируем перед-кадровый хук для авто-стрельбы (если auto=true)
|
// Регистрируем перед-кадровый хук для авто-стрельбы (если auto=true)
|
||||||
@ -583,7 +609,12 @@ export class WeaponSystem {
|
|||||||
// (для tap-to-shoot на мобиле). Точка применяется один раз.
|
// (для tap-to-shoot на мобиле). Точка применяется один раз.
|
||||||
let hit = null;
|
let hit = null;
|
||||||
let ray;
|
let ray;
|
||||||
const aim = this._aimScreenPoint;
|
// aim: разовый клик (_aimScreenPoint) или удержание по курсору (_holdAim,
|
||||||
|
// только когда курсор свободен — нет pointer-lock).
|
||||||
|
let aim = this._aimScreenPoint;
|
||||||
|
if (!aim && this._holdAim && document.pointerLockElement !== this.scene3d?.canvas) {
|
||||||
|
aim = this._holdAim;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (aim) {
|
if (aim) {
|
||||||
ray = this.scene.createPickingRay(aim.x, aim.y, null, camera);
|
ray = this.scene.createPickingRay(aim.x, aim.y, null, camera);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user