Compare commits

..

No commits in common. "8a66ce03f9b2358b5782d6fec41fa6790eb7f235" and "dc669a51f4727bca89eac4ba46c0b04d73dcec76" have entirely different histories.

8 changed files with 85 additions and 1070 deletions

View File

@ -10,7 +10,7 @@ export const USER_addres = BASE + '/api-user';
export const ACHIVES_addres = BASE + '/api-achievs';
export const COMMENTS_addres = BASE + '/api-comments';
export const STORYS_addres = BASE + '/api-storys';
// rbxl-importer: импорт .rbxl карт Roblox (см. вики «Импорт из Roblox»)
// rbxl-importer: только для МИНа (тест-фича импорта .rbxl карт Roblox)
export const RBXL_addres = BASE + '/api-rbxl';
export const NOTICES_addres = BASE + '/api-notices';
export const HELP_addres = BASE + '/api-help';

View File

@ -390,16 +390,18 @@ const KubikonStudio = () => {
<span className={cl.navIcon}><Icon name="book-open" size={15} /></span>
<span>ВИКИ</span>
</button>
{/* Импорт Roblox .rbxl — доступно всем */}
<button
className={cl.navItem}
onClick={() => setRbxlImportOpen(true)}
title="Импортировать игру из Roblox (.rbxl файл)"
style={{ marginTop: 8, borderTop: '1px solid #333', paddingTop: 12 }}
>
<span className={cl.navIcon}>📦</span>
<span>Импорт Roblox</span>
</button>
{/* Импорт Roblox .rbxl — только для МИНа (user_id=1) */}
{getCurrentUserId() === 1 && (
<button
className={cl.navItem}
onClick={() => setRbxlImportOpen(true)}
title="Импортировать игру из Roblox (.rbxl файл) — тест-фича"
style={{ marginTop: 8, borderTop: '1px solid #333', paddingTop: 12 }}
>
<span className={cl.navIcon}>📦</span>
<span>Импорт Roblox</span>
</button>
)}
</nav>
<RbxlImportModal

View File

@ -5073,350 +5073,6 @@ end)`}</Code>}
],
},
//
// РАЗДЕЛ ИМПОРТ ИЗ ROBLOX (.rbxl)
//
{
id: 'rbxl-import',
icon: 'package',
title: 'Импорт из Roblox',
summary: 'Загрузи .rbxl-файл готовой Roblox-карты в Рублокс: геометрия, цвета и материалы переносятся хорошо, скрипты лучше отключить при первом импорте и включать постепенно.',
sections: [
{
id: 'rbxl-overview',
title: 'I1. Что это и зачем',
body: (
<>
<p>
<b>Импорт из Roblox</b> это возможность загрузить
в Рублокс <b>готовую карту из Roblox Studio</b> в формате
<b> .rbxl</b> или <b>.rbxlx</b>. Часть карты геометрия,
цвета, материалы, GUI переносится в Рублокс
автоматически. Импорт превращает её в обычный проект
Рублокса, который можно редактировать, как любой
свой проект.
</p>
<p>
Кнопка <b>«📦 Импорт Roblox»</b> находится в левой
панели студии внизу под кнопкой «ВИКИ». Откроется
модалка, куда можно перетащить .rbxl-файл.
</p>
<h3 className="lessonH">Зачем это нужно</h3>
<ul>
<li>Перенести свою старую Roblox-карту в Рублокс,
чтобы продолжить работу здесь;</li>
<li>Изучить, как устроены большие карты опытных
разработчиков (классические <i>Crossroads</i>,
<i> ROBLOX Battle</i> и др.);</li>
<li>Использовать готовую сцену как «костяк» для
своей игры добавить свои скрипты и механики.</li>
</ul>
<Note>
Импорт работает с файлами до <b>50 МБ</b>. Этого
хватает для большинства карт. Очень большие карты
с тысячами объектов могут импортироваться долго
(2060 секунд).
</Note>
</>
),
},
{
id: 'rbxl-how-to-use',
title: 'I2. Как импортировать карту',
body: (
<>
<h3 className="lessonH">Шаг 1. Получи .rbxl-файл</h3>
<p>
В Roblox Studio открой свою карту и сохрани её
через меню <b>File Save to File</b>. Получится файл
с расширением <b>.rbxl</b> (бинарный) или
<b> .rbxlx</b> (текстовый XML). Оба формата подходят.
</p>
<Note>
Если у тебя нет своей карты, можно скачать
любую <b>opensource Roblox-карту</b> с GitHub
их там много (поиск «roblox places .rbxl»).
Классика карта <i>Crossroads</i>.
</Note>
<h3 className="lessonH">Шаг 2. Загрузи в студию</h3>
<Step n="1">
Открой студию Рублокса и нажми кнопку
<b> «📦 Импорт Roblox»</b> в левой панели.
</Step>
<Step n="2">
Перетащи .rbxl-файл в окно модалки или нажми
<b> «Выбрать файл»</b>. Кликни <b>«Анализировать»</b>.
Это займёт 530 секунд: сервер читает структуру карты,
считает объекты, скрипты, ассеты и показывает отчёт.
</Step>
<Step n="3">
В отчёте увидишь, сколько в карте Part-ов, моделей,
скриптов, текстур и какие будут <b>предупреждения</b>.
Например: «найдены неподдерживаемые сетки», «много
BillboardGui может тормозить» и т.п.
</Step>
<h3 className="lessonH">Шаг 3. Настрой режим импорта</h3>
<p>
Перед созданием проекта выбери, как обращаться
со скриптами и GUI карты:
</p>
<ul>
<li>
<b>Скрипты:</b>
<ul>
<li><b>Отключены</b> (рекомендуется) все
скрипты импортируются, но <b>не запускаются</b>.
Карта оживает как декорация: можно ходить
и смотреть, но без логики.</li>
<li><b>Включены</b> Lua-скрипты карты пытаются
запуститься. Часть API не поддерживается
(Roblox-only), некоторые карты могут зависать
или давать ошибки в консоли.</li>
<li><b>Удалить</b> скрипты вообще не сохраняются.
Останется только геометрия.</li>
</ul>
</li>
<li>
<b>GUI:</b>
<ul>
<li><b>Все</b> импортируется и HUD (ScreenGui),
и подписи над объектами (BillboardGui).</li>
<li><b>Только ScreenGui</b> переносится
только HUD. Хорошо если в карте было
200+ табличек-вывесок города.</li>
<li><b>Пропустить</b> не импортировать GUI.</li>
</ul>
</li>
</ul>
<h3 className="lessonH">Шаг 4. Создай проект</h3>
<Step n="1">
Введи название игры. Под этим именем карта появится
в твоих проектах.
</Step>
<Step n="2">
Нажми <b>«Создать игру»</b>. Сервер скачает текстуры
и сетки с Roblox CDN, соберёт проект и переведёт тебя
в редактор.
</Step>
<Step n="3">
Нажми <kbd className="kbd">Запустить</kbd> и осмотри
карту вживую.
</Step>
</>
),
},
{
id: 'rbxl-graphics-vs-scripts',
title: 'I3. Графика хорошо, скрипты — осторожно',
body: (
<>
<p>
Главное, что нужно знать про импорт: <b>графика
переносится хорошо, скрипты нет</b>. Это связано
с тем, что движок Рублокса (Babylon.js) и движок
Roblox разные. Геометрия и материалы это
«стандартный 3D», он одинаков везде. А скрипты
опираются на сотни Roblox-API, которые в Рублоксе
реализованы лишь частично.
</p>
<h3 className="lessonH">Что переносится хорошо</h3>
<ul>
<li><b>Все Part-ы</b> (Brick, Cube, Sphere, Cylinder,
Wedge, CornerWedge) позиция, размер, поворот,
цвет, материал, прозрачность;</li>
<li><b>Модели (Models)</b> собранные группы Part-ов,
включая <i>Welds</i> (склейки);</li>
<li><b>Текстуры</b> и <b>Decals</b> скачиваются
с Roblox CDN и применяются на Part-ы;</li>
<li><b>MeshPart-ы</b> пользовательские 3D-сетки;</li>
<li><b>SpawnLocation</b> точка появления игрока;</li>
<li><b>Lighting / Sky</b> время суток, цвет неба;</li>
<li><b>GUI</b> (ScreenGui, TextLabel, TextButton,
ImageLabel, Frame) простая разметка интерфейса
переносится верно.</li>
</ul>
<h3 className="lessonH">Что переносится так себе</h3>
<ul>
<li><b>Lua-скрипты</b> выполняются через нашу
реализацию Roblox API. <b>Базовые вещи работают</b>:
Touched, ClickDetector, TweenService, BindableEvent,
Vector3, CFrame, Workspace, Players. Но <b>десятки
специальных сервисов нет</b>: DataStoreService,
HttpService, MarketplaceService, MessagingService,
TeleportService и т.д.;</li>
<li><b>Анимации</b> простые работают, сложные
rig-анимации (R15, custom) могут вести себя
не так;</li>
<li><b>Физика</b> общая работает, но <i>Constraint-ы</i>,
<i> Motors</i>, <i>BodyMover-ы</i> (BodyVelocity,
BodyPosition и др.) имеют упрощённую реализацию;</li>
<li><b>Звуки</b> переносятся, но Roblox SoundId
(<i>rbxassetid://</i>) у нас не играет напрямую
лучше заменить на наши звуки (<i>'click'</i>,
<i> 'win'</i>, <i>'coin'</i> и т.д.).</li>
</ul>
<h3 className="lessonH">Что точно не переносится</h3>
<ul>
<li>Мультиплеерная логика на Roblox-Remote-Event-ах
у нас своя система мультиплеера;</li>
<li>Покупки, бонусы, премиум Roblox-only;</li>
<li>Облачное сохранение (DataStore) у нас другое;</li>
<li>Чат, аватары пользователей Roblox.</li>
</ul>
</>
),
},
{
id: 'rbxl-recommended-flow',
title: 'I4. Правильный порядок: сначала графика, потом скрипты',
body: (
<>
<p>
Импорт <b>хорошо работает в два прохода</b>. Не
пытайся всё запустить сразу карта может встать
колом из-за ошибок в чужих скриптах. Делай так:
</p>
<h3 className="lessonH">Проход 1. Импорт без скриптов</h3>
<Step n="1">
Перед созданием проекта выбери в режиме скриптов
<b> «Отключены»</b>.
</Step>
<Step n="2">
Создай проект. Запусти игру и пройдись по карте.
Смотри только на <b>графику</b>:
<ul>
<li>как лежат блоки и модели;</li>
<li>правильные ли материалы и цвета;</li>
<li>работают ли текстуры;</li>
<li>как выглядит освещение и небо;</li>
<li>нет ли провалов / летающих в воздухе кусков.</li>
</ul>
</Step>
<Step n="3">
Если что-то выглядит криво это <b>хорошо</b>:
твой проект не сломан скриптами, можно спокойно
поправить геометрию руками в редакторе.
</Step>
<h3 className="lessonH">Проход 2. Аккуратно включай скрипты</h3>
<p>
После того как графика тебя устраивает, можно
<b> по одному</b> включать скрипты карты. Это
делается уже в редакторе проекта, в панели
<b> «Скрипты»</b>:
</p>
<Step n="1">
Открой панель скриптов. У каждого скрипта рядом
с названием есть тумблер <b>«Включён»</b>.
По умолчанию после импорта все они выключены.
</Step>
<Step n="2">
Включай скрипты <b>по одному</b>, начиная с самых
простых те, что висят на Touched-частях
(кнопки, телепорты, ловушки). Запускай игру,
смотри, всё ли работает.
</Step>
<Step n="3">
Если включил скрипт и игра <b>зависла или замусорила
консоль ошибками</b> выключи его обратно. Это
нормально. У этого скрипта, скорее всего, есть
Roblox-API, которого у нас нет.
</Step>
<Step n="4">
Для важных скриптов, которые не работают, можно
либо <b>переписать</b> их под Рублокс (у нас простой
JS-API через <code>game.*</code>), либо
<b> заменить</b> на свой скрипт с похожей логикой.
</Step>
<Note>
Не расстраивайся, если из 200 скриптов сразу
заработает только 50. Это нормально большие
Roblox-карты опираются на сложные сервисы. Зато
графика уже на месте, и тебе остаётся написать
несколько коротких скриптов на привычном JS.
</Note>
</>
),
},
{
id: 'rbxl-tips-and-tricks',
title: 'I5. Советы и частые проблемы',
body: (
<>
<h3 className="lessonH">Частые ошибки</h3>
<ul>
<li>
<b>«Карта пустая после импорта»</b> спавн-точка
могла оказаться под полом или в стене. В редакторе
переставь её в Game-вкладке: <b>точка спавна</b>
{' '} кликни в нужное место.
</li>
<li>
<b>«Текстуры серые»</b> изредка Roblox CDN не
отдаёт текстуру (она удалена). Поставь свой цвет
или текстуру на этих Part-ах в редакторе.
</li>
<li>
<b>«Игра тормозит»</b> обычно дело в большом
числе BillboardGui (вывески города) или unanchored
Part-ов. Импортируй заново с режимом GUI
<i> «Только ScreenGui»</i>.
</li>
<li>
<b>«Скрипт даёт ошибку DataStoreService»</b>
это Roblox-only сервис. Выключи скрипт или
замени логику сохранения на нашу.
</li>
<li>
<b>«Игрок проваливается под пол»</b> иногда
у Part-а пола стоит <i>CanCollide=false</i>.
Выдели пол в редакторе и включи коллизию.
</li>
</ul>
<h3 className="lessonH">Что делать после импорта</h3>
<ul>
<li>Переименуй важные объекты на русский так
удобнее писать новые скрипты;</li>
<li>Сгруппируй похожие Part-ы в модели (можно через
инспектор);</li>
<li>Поставь свои точки спавна и финиша на местах,
которые тебе нужны;</li>
<li>Сохрани <b>копию проекта</b> до того, как начнёшь
включать скрипты на случай, если карта сломается.</li>
</ul>
<Note>
Импорт это <b>стартовая площадка</b>, а не готовый
продукт. Лучше всего он работает, когда ты берёшь
из Roblox-карты только <b>геометрию</b>, а механики
и интерактив пишешь сам на нашем JS-API
(через <code>game.*</code>). Так получится игра,
которая <b>стабильно работает</b> у тебя и у игроков.
</Note>
<h3 className="lessonH">Что почитать дальше</h3>
<p>
Если хочешь узнать, как переписать импортированный
Roblox-скрипт под наш движок посмотри раздел
<b> «Скрипты»</b> в вики (D-G) и сравни Roblox-API
с нашим <code>game.*</code>. Многое делается похоже,
только короче.
</p>
</>
),
},
],
},
//
// РАЗДЕЛ СОВМЕСТНОЕ РЕДАКТИРОВАНИЕ (Team Create)
//

View File

@ -4228,502 +4228,14 @@ end)`;
})(),
// ═══════════════════════════════════════════════════════════════
// ИГРА 45 — «Стрелялка-арена»
// ═══════════════════════════════════════════════════════════════
'arena-shooter': {
g45_main: `-- === ИГРА «СТРЕЛЯЛКА-АРЕНА» — главный скрипт (Lua) ===
${SNIPPET_BROADCAST}
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local player = Players.LocalPlayer
local GOAL = 15
local score = 0
local over = false
-- Враги: { ref { ref, alive, lastDmg } }
local enemies = {}
__rbxl_score_set(0)
__rbxl_show_text("Перебей 15 врагов! Кликай по ним", 3)
local hitSound = Instance.new("Sound", workspace)
hitSound.SoundId = "hit"; hitSound.Volume = 0.6
local winSound = Instance.new("Sound", workspace)
winSound.SoundId = "win"; winSound.Volume = 1
-- Подписка на смерть игрока
task.delay(0.5, function()
local char = player.Character or player.CharacterAdded:Wait()
local h = char:WaitForChild("Humanoid", 2)
if h then
h.Died:Connect(function()
if over then return end
over = true
__rbxl_show_text("Поражение! Тебя одолели враги.", 5)
end)
end
end)
-- Клик по врагу: EnemyClicked:Fire(ref)
local clickEvent = getEvent("EnemyClicked")
clickEvent.Event:Connect(function(localRef)
if over then return end
local e = enemies[localRef]
if not e or not e.alive then return end
local px = __rbxl_player_x()
local pz = __rbxl_player_z()
local ex = __rbxl_npc_x(localRef)
local ez = __rbxl_npc_z(localRef)
if ex == 0 and ez == 0 then return end
local dx = px - ex
local dz = pz - ez
local dist = math.sqrt(dx*dx + dz*dz)
if dist < 6 then
e.alive = false
__rbxl_npc_remove(localRef)
__rbxl_spawn_particles("explosion", ex, 2, ez, 0.4, 1)
hitSound:Play()
score = score + 1
__rbxl_score_set(score)
if score >= GOAL and not over then
over = true
__rbxl_show_text("Победа! Арена зачищена!", 5)
winSound:Play()
__rbxl_spawn_particles("confetti", px, 3, pz, 3, 3)
end
end
end)
-- Спавн каждые 1.8с
local spawnTimer = 0
RunService.Heartbeat:Connect(function(dt)
if over or score >= GOAL then return end
spawnTimer = spawnTimer + dt
if spawnTimer < 1.8 then return end
spawnTimer = 0
local angle = math.random() * math.pi * 2
local ex = math.cos(angle) * 11
local ez = math.sin(angle) * 11
local ref = __rbxl_spawn_npc("character-b", ex, 1, ez, "Враг", 30, 2.2)
enemies[ref] = { ref = ref, alive = true, lastDmg = 0 }
task.delay(0.3, function()
__rbxl_npc_follow(ref, "player")
end)
__rbxl_npc_on_click(ref, function()
local ev = game:GetService("ReplicatedStorage"):FindFirstChild("EnemyClicked")
if ev then ev:Fire(ref) end
end)
end)
-- Враги бьют игрока вблизи (каждые 0.7с)
RunService.Heartbeat:Connect(function()
if over then return end
local px = __rbxl_player_x()
local pz = __rbxl_player_z()
local now = tick()
for _, e in pairs(enemies) do
if e.alive then
local ex = __rbxl_npc_x(e.ref)
local ez = __rbxl_npc_z(e.ref)
if not (ex == 0 and ez == 0) then
local dx = px - ex
local dz = pz - ez
local dist = math.sqrt(dx*dx + dz*dz)
if dist < 1.8 and now - e.lastDmg > 0.7 then
e.lastDmg = now
__rbxl_damage_player(10)
hitSound:Play()
end
end
end
end
end)`,
},
// ═══════════════════════════════════════════════════════════════
// ИГРА 47 — «Квест-побег»
// ═══════════════════════════════════════════════════════════════
'escape-quest': (function() {
const BTN_COUNT = 3;
const overrides = {
g47_main: `-- === ИГРА «КВЕСТ-ПОБЕГ» — главный скрипт (Lua) ===
${SNIPPET_BROADCAST}
local TweenService = game:GetService("TweenService")
local TOTAL = ${BTN_COUNT}
local pressed = 0
local escaped = false
__rbxl_show_text("Найди и нажми 3 кнопки, чтобы выйти!", 4)
local clickSound = Instance.new("Sound", workspace)
clickSound.SoundId = "click"; clickSound.Volume = 0.6
local winSound = Instance.new("Sound", workspace)
winSound.SoundId = "win"; winSound.Volume = 1
local btnEvent = getEvent("ButtonPressed")
btnEvent.Event:Connect(function()
pressed = pressed + 1
clickSound:Play()
__rbxl_show_text("Кнопка " .. pressed .. " из " .. TOTAL, 1.5)
if pressed >= TOTAL then
local door = workspace:FindFirstChild("Дверь")
if door then
local dp = door.Position
local goal = { Position = Vector3.new(dp.X, dp.Y + 6, dp.Z) }
TweenService:Create(door, TweenInfo.new(1.2), goal):Play()
door.CanCollide = false
end
__rbxl_show_text("Все кнопки нажаты! Дверь открыта!", 3)
winSound:Play()
end
end)
local escEvent = getEvent("Escape")
escEvent.Event:Connect(function()
if escaped then return end
escaped = true
__rbxl_show_text("Победа! Ты сбежал из комнаты!", 5)
winSound:Play()
local px = __rbxl_player_x()
local py = __rbxl_player_y()
local pz = __rbxl_player_z()
__rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3)
end)`,
g47_finish: `-- === Скрипт финиша (Lua) ===
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local part = script.Parent
local fired = false
part.Touched:Connect(function(hit)
if fired then return end
local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid")
if not h then return end
fired = true
local ev = ReplicatedStorage:FindFirstChild("Escape")
if ev then ev:Fire() end
end)`,
};
for (let i = 1; i <= BTN_COUNT; i++) {
overrides['g47_btn_' + i] = `-- === Скрипт кнопки ${i} (Lua) ===
local UserInputService = game:GetService("UserInputService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local part = script.Parent
local used = false
local hintVisible = false
RunService.Heartbeat:Connect(function()
if used then return end
local px = __rbxl_player_x()
local pz = __rbxl_player_z()
local dx = part.Position.X - px
local dz = part.Position.Z - pz
local dist = math.sqrt(dx*dx + dz*dz)
local near = dist <= 3
if near ~= hintVisible then
hintVisible = near
if near then
__rbxl_hud_set("g47_btn_${i}_hint", "[E] Нажать кнопку", 50, 75, "#ffe44a", 20)
else
__rbxl_hud_set("g47_btn_${i}_hint", nil)
end
end
end)
UserInputService.InputBegan:Connect(function(input, gp)
if gp then return end
if not hintVisible then return end
if used then return end
if input.KeyCode ~= Enum.KeyCode.E then return end
used = true
__rbxl_hud_set("g47_btn_${i}_hint", nil)
part.Color = Color3.fromRGB(34, 221, 85)
local ev = ReplicatedStorage:FindFirstChild("ButtonPressed")
if ev then ev:Fire() end
end)`;
}
return overrides;
})(),
// ═══════════════════════════════════════════════════════════════
// ИГРА 48 — «Мультиплеер: Салки»
// ═══════════════════════════════════════════════════════════════
'mp-tag': {
g48_main: `-- === ИГРА «МУЛЬТИПЛЕЕР: САЛКИ» — главный скрипт (Lua) ===
-- Это МУЛЬТИПЛЕЕРНАЯ игра. Чтобы играть с друзьями, опубликуй её
-- с галочкой «Мультиплеер» тогда в комнату смогут зайти несколько
-- игроков. В одиночку игра показывает только правила.
local Players = game:GetService("Players")
__rbxl_show_text("Салки! Опубликуй игру для игры с друзьями", 4)
-- Показываем сколько игроков в комнате (постоянная плашка вверху)
local function refresh()
local n = #Players:GetPlayers()
__rbxl_hud_set("info", "Игроков в комнате: " .. n, 50, 8, "#ffe066", 22)
end
refresh()
-- Подписки на вход/выход
Players.PlayerAdded:Connect(function(p)
__rbxl_show_text(p.Name .. " присоединился к салкам!", 2)
refresh()
end)
Players.PlayerRemoving:Connect(function()
refresh()
end)
-- В одиночке роли не назначаются показываем правила
task.delay(2, function()
__rbxl_show_text("Водящий — первый зашедший. Он догоняет остальных.", 4)
end)`,
},
// ═══════════════════════════════════════════════════════════════
// ИГРА 49 — «Мультиплеер: Гонка»
// ═══════════════════════════════════════════════════════════════
'mp-race': {
g49_main: `-- === ИГРА «МУЛЬТИПЛЕЕР: ГОНКА» — главный скрипт (Lua) ===
-- Мультиплеерная гонка. Чтобы соревноваться с друзьями опубликуй
-- игру с галочкой «Мультиплеер».
${SNIPPET_BROADCAST}
local Players = game:GetService("Players")
local winnerName = nil
local won = false
__rbxl_show_text("Гонка! Беги к финишу первым", 3)
local winSound = Instance.new("Sound", workspace)
winSound.SoundId = "win"; winSound.Volume = 1
local function refresh()
local n = #Players:GetPlayers()
local txt = "Игроков: " .. n
if winnerName then txt = txt .. " | Победил: " .. winnerName end
__rbxl_hud_set("info", txt, 50, 8, "#ffe066", 22)
end
refresh()
Players.PlayerAdded:Connect(refresh)
Players.PlayerRemoving:Connect(refresh)
-- Финиш
local finEvent = getEvent("FinishReached")
finEvent.Event:Connect(function()
if won then return end
won = true
-- В одиночке мы и есть первый
local me = Players.LocalPlayer
winnerName = me and me.Name or "Игрок"
refresh()
__rbxl_show_text("Ты пришёл первым! Победа!", 5)
winSound:Play()
local px = __rbxl_player_x()
local py = __rbxl_player_y()
local pz = __rbxl_player_z()
__rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3)
end)`,
g49_finish: `-- === Скрипт финиша (Lua) ===
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local part = script.Parent
local fired = false
part.Touched:Connect(function(hit)
if fired then return end
local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid")
if not h then return end
fired = true
local ev = ReplicatedStorage:FindFirstChild("FinishReached")
if ev then ev:Fire() end
end)`,
},
// ═══════════════════════════════════════════════════════════════
// ИГРА 50 — «Своя игра» (песочница)
// ═══════════════════════════════════════════════════════════════
'make-your-own': {
g50_main: `-- === «СВОЯ ИГРА» — твоя песочница (Lua) ===
--
-- Это пустая площадка. Здесь ты придумываешь и собираешь
-- СВОЮ игру с нуля. Удали этот текст и пиши свой код.
--
-- С чего начать:
-- 1. Реши, КАКАЯ это игра (паркур / гонка / стрелялка / квест).
-- 2. Построй сцену из блоков и примитивов.
-- 3. Поставь точку спавна.
-- 4. Добавь цель финиш, счёт или врагов.
-- 5. Напиши скрипты, оживляющие игру.
--
-- Всё, что нужно, ты уже знаешь из уроков 1-49. Удачи!
__rbxl_show_text("Твоя песочница! Создай свою игру", 4)`,
},
'clicker': {
g46_main: `-- === ИГРА «КЛИКЕР» — главный скрипт (Lua) ===
${SNIPPET_BROADCAST}
local RunService = game:GetService("RunService")
local GOAL = 200
local points = 0
local perClick = 1
local autoIncome = 0
local won = false
__rbxl_score_set(0)
__rbxl_show_text("Кликай по жёлтому кубу! Цель: 200 очков", 4)
local clickSound = Instance.new("Sound", workspace)
clickSound.SoundId = "click"; clickSound.Volume = 0.6
local pickupSound = Instance.new("Sound", workspace)
pickupSound.SoundId = "pickup"; pickupSound.Volume = 0.7
local winSound = Instance.new("Sound", workspace)
winSound.SoundId = "win"; winSound.Volume = 1
local function checkWin()
if not won and points >= GOAL then
won = true
__rbxl_show_text("Победа! Накоплено 200 очков!", 5)
winSound:Play()
local px = __rbxl_player_x()
local py = __rbxl_player_y()
local pz = __rbxl_player_z()
__rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3)
end
end
-- Авто-доход каждую секунду
local autoTimer = 0
RunService.Heartbeat:Connect(function(dt)
if won then return end
autoTimer = autoTimer + dt
if autoTimer < 1 then return end
autoTimer = 0
if autoIncome > 0 then
points = points + autoIncome
__rbxl_score_set(points)
checkWin()
end
end)
-- Клик по кубу
local clickEvent = getEvent("CubeClicked")
clickEvent.Event:Connect(function()
if won then return end
points = points + perClick
__rbxl_score_set(points)
clickSound:Play()
checkWin()
end)
-- Покупка силы клика (20)
local powerEvent = getEvent("BuyPower")
powerEvent.Event:Connect(function()
if points < 20 then
__rbxl_show_text("Нужно 20 очков для улучшения!", 1.5)
return
end
points = points - 20
perClick = perClick + 2
__rbxl_score_set(points)
pickupSound:Play()
__rbxl_show_text("Сила клика: +" .. perClick .. " за клик", 2)
end)
-- Покупка авто-дохода (40)
local autoEvent = getEvent("BuyAuto")
autoEvent.Event:Connect(function()
if points < 40 then
__rbxl_show_text("Нужно 40 очков для авто-дохода!", 1.5)
return
end
points = points - 40
autoIncome = autoIncome + 3
__rbxl_score_set(points)
pickupSound:Play()
__rbxl_show_text("Авто-доход: +" .. autoIncome .. " в секунду", 2)
end)`,
g46_cube: `-- === Скрипт куба-кликера (Lua) ===
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local part = script.Parent
-- ClickDetector делает куб кликабельным
local cd = Instance.new("ClickDetector")
cd.Parent = part
cd.MouseClick:Connect(function()
__rbxl_spawn_particles("sparks", part.Position.X, part.Position.Y + 1, part.Position.Z, 0.3, 1)
local ev = ReplicatedStorage:FindFirstChild("CubeClicked")
if ev then ev:Fire() end
end)`,
g46_up1: `-- === Скрипт улучшения «сила клика» (20 очков) (Lua) ===
local UserInputService = game:GetService("UserInputService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local part = script.Parent
local hintVisible = false
RunService.Heartbeat:Connect(function()
local px = __rbxl_player_x()
local pz = __rbxl_player_z()
local dx = part.Position.X - px
local dz = part.Position.Z - pz
local dist = math.sqrt(dx*dx + dz*dz)
local near = dist <= 3
if near ~= hintVisible then
hintVisible = near
if near then
__rbxl_hud_set("g46_up1_hint", "[E] Купить +силу клика (20)", 50, 75, "#ffe44a", 20)
else
__rbxl_hud_set("g46_up1_hint", nil)
end
end
end)
UserInputService.InputBegan:Connect(function(input, gp)
if gp then return end
if not hintVisible then return end
if input.KeyCode ~= Enum.KeyCode.E then return end
local ev = ReplicatedStorage:FindFirstChild("BuyPower")
if ev then ev:Fire() end
end)`,
g46_up2: `-- === Скрипт улучшения «авто-доход» (40 очков) (Lua) ===
local UserInputService = game:GetService("UserInputService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local part = script.Parent
local hintVisible = false
RunService.Heartbeat:Connect(function()
local px = __rbxl_player_x()
local pz = __rbxl_player_z()
local dx = part.Position.X - px
local dz = part.Position.Z - pz
local dist = math.sqrt(dx*dx + dz*dz)
local near = dist <= 3
if near ~= hintVisible then
hintVisible = near
if near then
__rbxl_hud_set("g46_up2_hint", "[E] Купить авто-доход (40)", 50, 75, "#ffe44a", 20)
else
__rbxl_hud_set("g46_up2_hint", nil)
end
end
end)
UserInputService.InputBegan:Connect(function(input, gp)
if gp then return end
if not hintVisible then return end
if input.KeyCode ~= Enum.KeyCode.E then return end
local ev = ReplicatedStorage:FindFirstChild("BuyAuto")
if ev then ev:Fire() end
end)`,
},
// ИГРЫ 45-50: явных Lua-версий пока нет.
// buildGameProject в docsGamesBuilders.js использует generateFallbackLua
// (главный скрипт → показ подсказки + слушает FinishReached →
// победа+конфетти; скрипт на финиш-примитиве → шлёт FinishReached;
// остальные target-скрипты → красят примитив на касание).
// Это даёт «хоть что-то рабочее» в любой игре до того как напишем
// полноценный Lua-скрипт. Когда дописываем игру — добавляем сюда явный override.
'clicker': { g46_main: simpleClicker() },
};
// ══════════════════════════════════════════════════════════════════

View File

@ -113,11 +113,7 @@ export function highlightCode(text, lang) {
}
// v2 раньше при первом включении lua-режима сохранялся в LS и юзер
// потом всегда видел Lua-таб по умолчанию. Бамп ключа = сброс на JS
// у всех уже-открытых вкладок.
const LS_KEY = 'rublox.docs.lang.v2';
const LS_KEY_OLD = 'rublox.docs.lang';
const LS_KEY = 'rublox.docs.lang';
const DEFAULT_LANG = 'js';
const DocsLangContext = createContext({
@ -128,8 +124,6 @@ const DocsLangContext = createContext({
export function DocsLangProvider({ children }) {
const [lang, setLangState] = useState(() => {
try {
// Очищаем старый ключ у части юзеров там залип 'lua'
localStorage.removeItem(LS_KEY_OLD);
const v = localStorage.getItem(LS_KEY);
return v === 'lua' ? 'lua' : 'js';
} catch (_) {

View File

@ -1,6 +1,6 @@
import React from 'react';
import { Code, ScriptKind, Step, Note, Try, Shot } from './docsData';
import { LangTabs, useDocsLang } from './docsLang';
import { LangTabs } from './docsLang';
import { LUA_OVERRIDES } from './docsGamesBuildersLua';
/**
@ -20,17 +20,6 @@ function CodeBoth({ game, script, children }) {
);
}
/**
* Инлайн-API-имена в тексте уроков, меняющиеся в зависимости от JS/Lua вкладки.
* <Api js="game.ui.showText('Победа!', 5)" lua={'__rbxl_show_text("Победа!", 5)'} />
* Если lua не задан показывает js в обоих режимах.
*/
function Api({ js, lua }) {
const { lang } = useDocsLang();
const txt = lang === 'lua' && lua ? lua : js;
return <code>{txt}</code>;
}
/**
* docsLessons.jsx тексты уроков для 50 мини-игр (раздел K вики).
*
@ -6302,7 +6291,7 @@ game.self.onTouch(() => {
стрелять и проверяет, кто победил.
</p>
<ScriptKind kind="global" />
<CodeBoth game="tower-defense" script="g44_main">{`// === ИГРА «TOWER DEFENSE» — главный скрипт ===
<Code>{`// === ИГРА «TOWER DEFENSE» — главный скрипт ===
let leaked = 0; // врагов прошло до базы
const MAX_LEAK = 8;
@ -6385,7 +6374,7 @@ game.every(0.5, () => {
}
}
}
});`}</CodeBoth>
});`}</Code>
<p>Разберём:</p>
<ul>
<li><code>towers</code> и <code>enemies</code> два списка:
@ -6406,7 +6395,7 @@ game.every(0.5, () => {
<h3 className="lessonH">Шаг 3. Скрипт площадки под башню</h3>
<ScriptKind kind="object" on="каждую площадку" />
<CodeBoth game="tower-defense" script="g44_slot_1">{`// === Скрипт площадки под башню ===
<Code>{`// === Скрипт площадки под башню ===
let built = false;
game.self.onInteract(() => {
if (built) return;
@ -6419,7 +6408,7 @@ game.self.onInteract(() => {
color: '#ffcc33',
});
game.broadcast('addTower', { x: pos.x, z: pos.z });
}, { text: 'Построить башню', distance: 4 });`}</CodeBoth>
}, { text: 'Построить башню', distance: 4 });`}</Code>
<p>
При нажатии <kbd className="kbd">E</kbd> скрипт создаёт
жёлтый цилиндр-башню над площадкой и шлёт сообщение
@ -6526,7 +6515,7 @@ game.scene.spawn('user:3', {
<h3 className="lessonH">Шаг 2. Главный скрипт</h3>
<ScriptKind kind="global" />
<CodeBoth game="arena-shooter" script="g45_main">{`// === ИГРА «СТРЕЛЯЛКА-АРЕНА» — главный скрипт ===
<Code>{`// === ИГРА «СТРЕЛЯЛКА-АРЕНА» — главный скрипт ===
let score = 0;
const GOAL = 15;
@ -6588,7 +6577,7 @@ game.every(1.8, () => {
}
}
});
});`}</CodeBoth>
});`}</Code>
<p>Разберём:</p>
<ul>
<li><code>game.onHpChange((e) ={'>'} ...)</code>
@ -6671,7 +6660,7 @@ game.every(1.8, () => {
<h3 className="lessonH">Шаг 2. Главный скрипт</h3>
<ScriptKind kind="global" />
<CodeBoth game="clicker" script="g46_main">{`// === ИГРА «КЛИКЕР» — главный скрипт ===
<Code>{`// === ИГРА «КЛИКЕР» — главный скрипт ===
let points = 0; // очки
let perClick = 1; // очков за клик
@ -6736,7 +6725,7 @@ game.onMessage('buyAuto', () => {
game.ui.score = points;
game.sound.play('pickup');
game.ui.showText('Авто-доход: +' + autoIncome + ' в секунду', 2);
});`}</CodeBoth>
});`}</Code>
<p>Разберём:</p>
<ul>
<li><code>points</code> очки, <code>perClick</code>
@ -6756,22 +6745,22 @@ game.onMessage('buyAuto', () => {
<h3 className="lessonH">Шаг 3. Скрипты куба и кнопок</h3>
<ScriptKind kind="object" on="жёлтый куб-кликер" />
<CodeBoth game="clicker" script="g46_cube">{`// === Скрипт куба-кликера ===
<Code>{`// === Скрипт куба-кликера ===
game.self.onClick(() => {
game.broadcast('click');
// куб слегка вспыхивает
game.scene.spawnParticles('sparks', game.self.position, { duration: 0.3 });
});`}</CodeBoth>
});`}</Code>
<ScriptKind kind="object" on="красную кнопку" />
<CodeBoth game="clicker" script="g46_up1">{`// === Скрипт улучшения «сила клика» (20 очков) ===
<Code>{`// === Скрипт улучшения «сила клика» (20 очков) ===
game.self.onInteract(() => {
game.broadcast('buyPower');
}, { text: 'Купить +силу клика (20)', distance: 3 });`}</CodeBoth>
}, { text: 'Купить +силу клика (20)', distance: 3 });`}</Code>
<ScriptKind kind="object" on="синюю кнопку" />
<CodeBoth game="clicker" script="g46_up2">{`// === Скрипт улучшения «авто-доход» (40 очков) ===
<Code>{`// === Скрипт улучшения «авто-доход» (40 очков) ===
game.self.onInteract(() => {
game.broadcast('buyAuto');
}, { text: 'Купить авто-доход (40)', distance: 3 });`}</CodeBoth>
}, { text: 'Купить авто-доход (40)', distance: 3 });`}</Code>
<Note>
Главная идея кликера: сначала кликаешь руками, потом
покупаешь улучшения и игра «играет сама». Это экономика:
@ -6841,7 +6830,7 @@ game.self.onInteract(() => {
<h3 className="lessonH">Шаг 2. Главный скрипт</h3>
<ScriptKind kind="global" />
<CodeBoth game="escape-quest" script="g47_main">{`// === ИГРА «КВЕСТ-ПОБЕГ» — главный скрипт ===
<Code>{`// === ИГРА «КВЕСТ-ПОБЕГ» — главный скрипт ===
let pressed = 0; // сколько кнопок нажато
const TOTAL = 3;
@ -6873,7 +6862,7 @@ game.onMessage('escape', () => {
const p = game.player.position;
game.scene.spawnParticles('confetti',
{ x: p.x, y: p.y + 3, z: p.z }, { duration: 3, count: 3 });
});`}</CodeBoth>
});`}</Code>
<p>Разберём:</p>
<ul>
<li><code>pressed</code> счётчик нажатых кнопок,
@ -6889,19 +6878,19 @@ game.onMessage('escape', () => {
<h3 className="lessonH">Шаг 3. Скрипты кнопки и финиша</h3>
<ScriptKind kind="object" on="каждую красную кнопку" />
<CodeBoth game="escape-quest" script="g47_btn_1">{`// === Скрипт кнопки 1 ===
<Code>{`// === Скрипт кнопки 1 ===
let used = false;
game.self.onInteract(() => {
if (used) return;
used = true;
game.scene.setColor(game.self.ref, '#22dd55'); // нажата зелёная
game.broadcast('pressButton');
}, { text: 'Нажать кнопку', distance: 3 });`}</CodeBoth>
}, { text: 'Нажать кнопку', distance: 3 });`}</Code>
<ScriptKind kind="object" on="зелёный финиш" />
<CodeBoth game="escape-quest" script="g47_finish">{`// === Скрипт финиша ===
<Code>{`// === Скрипт финиша ===
game.self.onTouch(() => {
game.broadcast('escape');
});`}</CodeBoth>
});`}</Code>
<p>
Кнопка при нажатии становится зелёной (видно, что нажата),
шлёт <code>game.broadcast('pressButton')</code> и больше
@ -6980,7 +6969,7 @@ game.self.onTouch(() => {
<h3 className="lessonH">Шаг 2. Главный скрипт</h3>
<ScriptKind kind="global" />
<CodeBoth game="mp-tag" script="g48_main">{`// === ИГРА «МУЛЬТИПЛЕЕР: САЛКИ» — главный скрипт ===
<Code>{`// === ИГРА «МУЛЬТИПЛЕЕР: САЛКИ» — главный скрипт ===
//
// Это МУЛЬТИПЛЕЕРНАЯ игра. Чтобы играть с друзьями, опубликуй её
// с галочкой «Мультиплеер» тогда в комнату смогут зайти несколько
@ -7017,7 +7006,7 @@ game.room.onChange('tagger', (taggerId) => {
} else {
game.ui.showText('Убегай от водящего!', 3);
}
});`}</CodeBoth>
});`}</Code>
<p>Разберём:</p>
<ul>
<li><code>game.players.count()</code> сколько игроков
@ -7108,7 +7097,7 @@ game.room.onChange('tagger', (taggerId) => {
<h3 className="lessonH">Шаг 2. Главный скрипт</h3>
<ScriptKind kind="global" />
<CodeBoth game="mp-race" script="g49_main">{`// === ИГРА «МУЛЬТИПЛЕЕР: ГОНКА» — главный скрипт ===
<Code>{`// === ИГРА «МУЛЬТИПЛЕЕР: ГОНКА» — главный скрипт ===
//
// Мультиплеерная гонка. Чтобы соревноваться с друзьями опубликуй
// игру с галочкой «Мультиплеер».
@ -7148,7 +7137,7 @@ game.onMessage('finish', () => {
} else {
game.ui.showText('Финиш! Но кто-то был быстрее.', 4);
}
});`}</CodeBoth>
});`}</Code>
<p>Разберём:</p>
<ul>
<li><code>game.room.get('winner')</code> читаем общую
@ -7168,10 +7157,10 @@ game.onMessage('finish', () => {
<h3 className="lessonH">Шаг 3. Скрипт финиша</h3>
<ScriptKind kind="object" on="зелёный финиш" />
<CodeBoth game="mp-race" script="g49_finish">{`// === Скрипт финиша ===
<Code>{`// === Скрипт финиша ===
game.self.onTouch(() => {
game.broadcast('finish');
});`}</CodeBoth>
});`}</Code>
<p>
Когда любой игрок касается финиша, скрипт шлёт сообщение
<code> game.broadcast('finish')</code> а главный скрипт
@ -7281,10 +7270,8 @@ game.self.onTouch(() => {
</ul>
<p>
И обязательно покажи игроку, когда он <b>победил</b>
надписью <Api js="game.ui.showText('Победа!', 5)"
lua={'__rbxl_show_text("Победа!", 5)'} />,
звуком <Api js="game.sound.play('win')"
lua={'winSound:Play()'} /> и конфетти.
надписью <code>game.ui.showText('Победа!', 5)</code>,
звуком <code>game.sound.play('win')</code> и конфетти.
</p>
<h3 className="lessonH">Шаг 4. Напиши скрипты</h3>
@ -7292,48 +7279,34 @@ game.self.onTouch(() => {
Сцена сама по себе не «живая» её оживляют скрипты.
Начинай с <b>главного скрипта</b>: в нём заводи переменные
(счёт, флажок победы) и <b>лови сообщения</b> через
<> </><Api js="game.onMessage('имя', fn)"
lua={'getEvent("Имя").Event:Connect(fn)'} />.
На объекты вешай небольшие скрипты они шлют сообщения
главному через <Api js="game.broadcast('имя')"
lua={'getEvent("Имя"):Fire()'} />.
Так главный скрипт узнаёт, что монетку собрали или кнопку
нажали. Ты делал так в каждом уроке.
<code> game.onMessage('имя', fn)</code>. На объекты вешай
небольшие скрипты они шлют сообщения главному через
<code> game.broadcast('имя')</code>. Так главный скрипт
узнаёт, что монетку собрали или кнопку нажали. Ты делал
так в каждом уроке.
</p>
<Note>
Каждый скрипт работает в своей «песочнице» переменные
одного скрипта не видны другому. Поэтому скрипты общаются
сообщениями: один шлёт <Api js="game.broadcast('имя')"
lua={'getEvent("Имя"):Fire()'} />,
другой ловит <Api js="game.onMessage('имя', fn)"
lua={'getEvent("Имя").Event:Connect(fn)'} />.
Можно передать данные: <Api
js="game.broadcast('имя', { ... })"
lua={'getEvent("Имя"):Fire(data)'} />.
сообщениями: один шлёт <code>game.broadcast('имя')</code>,
другой ловит <code>game.onMessage('имя', fn)</code>. Можно
передать данные: <code>game.broadcast('имя', {'{'} ... {'}'})</code>.
</Note>
<p>Базовый набор инструментов, который ты знаешь:</p>
<ul>
<li><Api js="game.self.onTouch"
lua={'part.Touched:Connect(fn)'} /> реакция на касание;</li>
<li><Api js="game.self.onInteract"
lua={'UserInputService.InputBegan + Heartbeat (дистанция)'} /> реакция на
<li><code>game.self.onTouch</code> реакция на касание;</li>
<li><code>game.self.onInteract</code> реакция на
<kbd className="kbd">E</kbd>;</li>
<li><Api js="game.self.onClick"
lua={'ClickDetector + MouseClick:Connect'} /> реакция на клик;</li>
<li><Api js="game.broadcast" lua={'BindableEvent:Fire'} /> и
<> </><Api js="game.onMessage" lua={'BindableEvent.Event:Connect'} />
<li><code>game.self.onClick</code> реакция на клик;</li>
<li><code>game.broadcast</code> и <code>game.onMessage</code>
связь между скриптами;</li>
<li><Api js="game.onTick"
lua={'RunService.Heartbeat:Connect'} /> каждый кадр;</li>
<li><Api js="game.after / game.every"
lua={'task.delay / task.spawn'} /> таймеры;</li>
<li><Api js="game.tween"
lua={'TweenService:Create(part, TweenInfo.new(t), goal):Play()'} /> плавное движение;</li>
<li><Api js="game.scene.spawnNpc"
lua={'__rbxl_spawn_npc("character-b", x, y, z, name, hp, speed)'} /> враги и NPC;</li>
<li><Api js="game.ui.score" lua={'__rbxl_score_set(N)'} /> и
<> </><Api js="game.ui.showText"
lua={'__rbxl_show_text(text, duration)'} /> счёт и подсказки.</li>
<li><code>game.onTick</code> каждый кадр;</li>
<li><code>game.after</code> и <code>game.every</code>
таймеры;</li>
<li><code>game.tween</code> плавное движение;</li>
<li><code>game.scene.spawnNpc</code> враги и NPC;</li>
<li><code>game.ui.score</code> и
<code> game.ui.showText</code> счёт и подсказки.</li>
</ul>
<h3 className="lessonH">Шаг 5. Проверяй и улучшай</h3>

View File

@ -1,7 +1,7 @@
/**
* RbxlImportModal модалка импорта .rbxl Roblox-карт в Rublox.
*
* Доступна всем пользователям (см. вики «Импорт из Roblox» о нюансах).
* Доступна ТОЛЬКО МИНу (user_id === 1) это тест-фича.
*
* Поток:
* 1. Юзер дропает или выбирает .rbxl файл.
@ -13,6 +13,8 @@
import React, { useState, useRef } from 'react';
import { analyzeRbxl, createRbxlProject } from '../api/rbxlImporterApi.js';
const ALLOWED_USER_ID = 1; // МИН
const MAX_SIZE = 50 * 1024 * 1024; // 50 MB
export default function RbxlImportModal({ open, onClose, currentUserId, onCreated }) {
@ -35,6 +37,18 @@ export default function RbxlImportModal({ open, onClose, currentUserId, onCreate
if (!open) return null;
if (currentUserId !== ALLOWED_USER_ID) {
return (
<div style={overlayStyle} onClick={onClose}>
<div style={dialogStyle} onClick={(e) => e.stopPropagation()}>
<h2 style={{ marginTop: 0 }}>Импорт из Roblox</h2>
<p>Эта тест-функция доступна только администратору.</p>
<button style={btnStyle} onClick={onClose}>Закрыть</button>
</div>
</div>
);
}
const reset = () => {
setFile(null); setReport(null); setPreviewHash(null);
setTitle(''); setError(null); setAnalyzing(false); setCreating(false);

View File

@ -204,96 +204,6 @@ export class StudioCollab {
};
}
}
// GUI-элементы (GuiManager: create/update/remove/setParent).
// Передаём полный набор полей с id, чтобы у соавтора создался ТОТ ЖЕ id.
const gm = this.scene.guiManager;
if (gm && !gm.__collabPatched) {
gm.__collabPatched = true;
const origGCreate = gm.create.bind(gm);
gm.create = function (type, opts = {}) {
const id = origGCreate(type, opts);
if (id != null && !self._applyingRemote) {
const el = gm.get?.(id);
self.sendOp({ op: 'guiCreate', id, type, def: el ? { ...el } : { ...opts, id } });
}
return id;
};
const origGUpd = gm.update.bind(gm);
gm.update = function (id, patch) {
const r = origGUpd(id, patch);
if (!self._applyingRemote && patch && typeof patch === 'object') {
self.sendOp({ op: 'guiUpdate', id, patch: { ...patch } });
}
return r;
};
const origGRem = gm.remove.bind(gm);
gm.remove = function (id) {
const r = origGRem(id);
if (!self._applyingRemote) self.sendOp({ op: 'guiRemove', id });
return r;
};
}
// Папки (FolderManager: createFolder/renameFolder/removeFolder).
const fm = this.scene.folderManager;
if (fm && !fm.__collabPatched) {
fm.__collabPatched = true;
if (typeof fm.createFolder === 'function') {
const origFC = fm.createFolder.bind(fm);
fm.createFolder = function (name, parentId = null) {
const r = origFC(name, parentId);
if (!self._applyingRemote) {
// r может быть id или объектом папки — нормализуем.
const id = (r && typeof r === 'object') ? (r.id ?? r) : r;
self.sendOp({ op: 'folderCreate', id, name, parentId: parentId ?? null });
}
return r;
};
}
if (typeof fm.renameFolder === 'function') {
const origFR = fm.renameFolder.bind(fm);
fm.renameFolder = function (id, name) {
const r = origFR(id, name);
if (!self._applyingRemote) self.sendOp({ op: 'folderRename', id, name });
return r;
};
}
if (typeof fm.removeFolder === 'function') {
const origFRem = fm.removeFolder.bind(fm);
fm.removeFolder = function (id, deleteContent = false) {
const r = origFRem(id, deleteContent);
if (!self._applyingRemote) self.sendOp({ op: 'folderRemove', id, deleteContent: !!deleteContent });
return r;
};
}
}
// Пользовательские модели (UserModelManager: addInstance async / removeInstance).
const um = this.scene.userModelManager;
if (um && !um.__collabPatched) {
um.__collabPatched = true;
if (typeof um.addInstance === 'function') {
const origUMAdd = um.addInstance.bind(um);
um.addInstance = function (userModelTypeId, x, y, z, rotationY = 0, options = {}) {
const ret = origUMAdd(userModelTypeId, x, y, z, rotationY, options);
Promise.resolve(ret).then((id) => {
if (id != null && !self._applyingRemote) {
self.sendOp({ op: 'userModelAdd', id, userModelTypeId, x, y, z, rotationY });
}
}).catch(() => {});
return ret;
};
}
if (typeof um.removeInstance === 'function') {
const origUMRem = um.removeInstance.bind(um);
um.removeInstance = function (id) {
const r = origUMRem(id);
if (!self._applyingRemote) self.sendOp({ op: 'userModelRemove', id });
return r;
};
}
}
}
/** Снять обёртки (при выходе из коллаба восстановить оригиналы — простой флаг). */
@ -531,52 +441,6 @@ export function applyRemoteOp(scene, op) {
scene.removeScript?.(op.id);
scene._onCollabScriptsChange?.();
return;
// GUI-элементы
case 'guiCreate':
scene.guiManager?.create?.(op.type, op.def || { id: op.id });
return;
case 'guiUpdate':
scene.guiManager?.update?.(op.id, op.patch || {});
return;
case 'guiRemove':
scene.guiManager?.remove?.(op.id);
return;
// Папки
case 'folderCreate':
// createFolder(name, parentId) генерит свой автоинкремент-id, но нам
// нужен ТОТ ЖЕ id что у автора (иначе rename/remove и folderId
// объектов разойдутся). folders — Map<id, data>; перевешиваем запись.
{
const fm2 = scene.folderManager;
if (fm2?.createFolder) {
const madeId = fm2.createFolder(op.name, op.parentId ?? null);
if (op.id != null && madeId !== op.id && fm2.folders?.has?.(madeId)) {
const data = fm2.folders.get(madeId);
fm2.folders.delete(madeId);
data.id = op.id;
fm2.folders.set(op.id, data);
if (fm2._nextId != null && op.id >= fm2._nextId) fm2._nextId = op.id + 1;
fm2._notifyChange?.();
}
}
}
return;
case 'folderRename':
scene.folderManager?.renameFolder?.(op.id, op.name);
return;
case 'folderRemove':
scene.folderManager?.removeFolder?.(op.id, !!op.deleteContent);
return;
// Пользовательские модели — forceInstanceId, чтобы id совпал у всех
// (иначе move/remove по id автора не найдут инстанс у соавтора).
case 'userModelAdd':
scene.userModelManager?.addInstance?.(
op.userModelTypeId, op.x, op.y, op.z, op.rotationY || 0,
{ forceInstanceId: op.id });
return;
case 'userModelRemove':
scene.userModelManager?.removeInstance?.(op.id);
return;
}
switch (t) {
case 'add': {