Compare commits

..

13 Commits

Author SHA1 Message Date
min
8a66ce03f9 Merge remote-tracking branch 'origin/main' into feat/lua-50-games-bundle
Some checks failed
CI / Lint (pull_request) Failing after 1m11s
CI / Build (pull_request) Successful in 1m57s
CI / Secret scan (pull_request) Successful in 23s
CI / PR size check (pull_request) Successful in 7s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
2026-06-10 00:20:52 +03:00
min
09a927bbfc fix(docs): дефолт языка в статьях — JS, не Lua
Дефолт в DEFAULT_LANG уже был 'js', но у части юзеров в localStorage
залип 'lua' с прошлого посещения (rublox.docs.lang).

Фикс: бамп ключа на 'rublox.docs.lang.v2' + удаление старого ключа
при инициализации. У всех теперь старт с JS, переключение на Lua
сохраняется по новому ключу как раньше.
2026-06-09 23:43:12 +03:00
min
eedac4379d feat: импорт Roblox открыт для всех + раздел вики
KubikonStudio.jsx: убрана проверка getCurrentUserId()===1 на кнопке
'📦 Импорт Roblox'. Теперь доступно всем юзерам.

RbxlImportModal.jsx: убран гейт ALLOWED_USER_ID и заглушка
'доступна только администратору'.

API.js: убран комментарий 'только для МИНа' у RBXL_addres.

docsData.jsx: новый раздел вики 'rbxl-import' (icon: package,
title: 'Импорт из Roblox') с 5 статьями:
- I1. Что это и зачем
- I2. Как импортировать карту (шаги 1-4)
- I3. Графика хорошо, скрипты — осторожно
  (что переносится хорошо/так себе/не переносится)
- I4. Правильный порядок: сначала графика, потом скрипты
  (Проход 1: импорт со скриптами 'Отключены' → смотрим графику,
   Проход 2: включаем скрипты по одному в редакторе)
- I5. Советы и частые проблемы
  (пустая карта, серые текстуры, тормоза, DataStoreService,
   провал под пол + что делать после импорта)

Раздел вставлен перед 'collab' (Совместное редактирование).
2026-06-09 23:39:08 +03:00
min
61026a1df0 feat(docs): компонент <Api> переключает JS/Lua инлайны
В уроке 50 (и можно дальше) в тексте было много <code>game.ui.showText</code>,
<code>game.broadcast</code> и т.д. — это JS-API. В Lua-вкладке вики они
оставались JS — путало юзера.

Фикс:
- Новый компонент <Api js="..." lua="..." /> использует useDocsLang().
  Если lang='lua' и lua задан — показывает Lua-эквивалент.
- В уроке 50 заменил все инлайны game.* на <Api> с Lua-параллелями:
  - game.ui.showText → __rbxl_show_text
  - game.sound.play → winSound:Play()
  - game.onMessage/broadcast → BindableEvent:Connect/Fire
  - game.self.onTouch/onInteract/onClick → Touched/UIS+Heartbeat/ClickDetector
  - game.onTick → RunService.Heartbeat
  - game.after/every → task.delay/spawn
  - game.tween → TweenService:Create
  - game.scene.spawnNpc → __rbxl_spawn_npc
  - game.ui.score → __rbxl_score_set
2026-06-09 23:29:41 +03:00
min
ddeb8ff93f docs(49) + feat(g50): «Своя игра» (песочница)
g49 docs: CodeBoth main+finish.

g50 паритет:
- Комментарии-инструкция как начать свою игру (5 шагов)
- __rbxl_show_text 'Твоя песочница! Создай свою игру'
2026-06-09 23:21:39 +03:00
min
3a95cd148a docs(48) + feat(g49): «Мультиплеер: Гонка»
g48 docs: CodeBoth g48_main.

g49 паритет (упрощённый без MP-API):
- showText 'Гонка! Беги к финишу первым'
- hud_set 'info' 'Игроков: N | Победил: X' (Players:GetPlayers count + LocalPlayer name)
- Players.PlayerAdded/Removing → refresh
- BindableEvent FinishReached
- g49_finish: Touched → FinishReached:Fire (fired-флаг)
- При победе: winnerName = LocalPlayer.Name + refresh + 'Победа!' + confetti
2026-06-09 23:18:39 +03:00
min
e78f585fd4 docs(47) + feat(g48): «Мультиплеер: Салки»
g47 docs: CodeBoth main+btn_1+finish.

g48 паритет (упрощённый — без MP-API):
- __rbxl_show_text 'Опубликуй игру для игры с друзьями'
- __rbxl_hud_set 'info' 50,8 'Игроков в комнате: N' (Players:GetPlayers)
- Players.PlayerAdded → '<имя> присоединился!' + refresh
- Players.PlayerRemoving → refresh
- task.delay 2 показывает правила
- В одиночке роли не назначаются (нет game.room API в shim)
2026-06-09 23:15:11 +03:00
min
57c5779644 docs(46) + feat(g47): «Квест-побег»
g46 docs: CodeBoth 4 скрипта (main+cube+up1+up2).

g47 паритет:
- TOTAL=3, pressed/escaped counters
- BindableEvents ButtonPressed/Escape
- 3 g47_btn_N: Heartbeat distance(3) + '[E] Нажать кнопку'
  E → used=true + Color зелёный + ButtonPressed:Fire
- При pressed>=3: tween двери Position.Y+6 + CanCollide=false + win sound
- g47_finish: Touched → Escape:Fire → 'Победа! Сбежал!' + confetti
2026-06-09 23:12:28 +03:00
min
d758fdfbe6 docs(45) + feat(g46): «Кликер»
g45 docs: CodeBoth g45_main.

g46 паритет (заменил simpleClicker fallback):
- GOAL=200, points/perClick/autoIncome
- Heartbeat: каждую секунду points += autoIncome → checkWin
- BindableEvents CubeClicked/BuyPower/BuyAuto
- g46_cube: ClickDetector → CubeClicked:Fire + sparks
- g46_up1/up2: Heartbeat distance(3) + '[E] Купить ...' + InputBegan E
- buyPower: -20 + perClick+=2
- buyAuto: -40 + autoIncome+=3
2026-06-09 23:07:25 +03:00
min
1c5e5fe5bb docs(44) + feat(g45): «Стрелялка-арена»
g44 docs: CodeBoth main+slot_1.

g45 паритет:
- GOAL=15, score/over
- Humanoid.Died → 'Поражение!'
- BindableEvent EnemyClicked(ref)
- Heartbeat spawn 1.8с: радиус=11 cos/sin → npc 'character-b'
  hp=30 speed=2.2 follow('player')
- npc_on_click → EnemyClicked:Fire(ref)
- Главный: dist<6 → npc.remove + explosion + score++
- 15 → 'Победа!' + confetti
- Heartbeat damage: каждый враг dist<1.8 + last>0.7 → damage_player(10) + hit
2026-06-09 23:04:21 +03:00
min
9e89fb1936 Merge pull request 'Team Create: ������� GUI, ����� � userModels' (#37) from restore/all-tasks into main
All checks were successful
CI / Lint (push) Successful in 1m11s
CI / Build (push) Successful in 1m58s
CI / Secret scan (push) Successful in 21s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Successful in 3m27s
2026-06-08 04:05:13 +00:00
min
b95e5c7401 merge main (синхрон перед PR GUI/папок/моделей)
All checks were successful
CI / Lint (pull_request) Successful in 1m7s
CI / Build (pull_request) Successful in 1m59s
CI / Secret scan (pull_request) Successful in 23s
CI / PR size check (pull_request) Successful in 7s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
2026-06-08 06:56:30 +03:00
min
4757bf95a0 feat(studio): Team Create — синхрон GUI, папок и userModels
Дополнил коллаб-синхрон: GUI-элементы (guiManager create/update/remove),
папки (folderManager createFolder/renameFolder/removeFolder, id выравнивается
у соавторов через Map), пользовательские модели (userModelManager addInstance
с forceInstanceId / removeInstance). Списки в Hierarchy обновляются штатным
setInterval. Теперь синхронятся: примитивы, модели, блоки, скрипты, GUI,
папки, userModels.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 06:56:19 +03:00
8 changed files with 1070 additions and 85 deletions

View File

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

View File

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

View File

@ -5073,6 +5073,350 @@ 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) // РАЗДЕЛ СОВМЕСТНОЕ РЕДАКТИРОВАНИЕ (Team Create)
// //

View File

@ -4228,14 +4228,502 @@ end)`;
})(), })(),
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
// ИГРЫ 45-50: явных Lua-версий пока нет. // ИГРА 45 — «Стрелялка-арена»
// buildGameProject в docsGamesBuilders.js использует generateFallbackLua // ═══════════════════════════════════════════════════════════════
// (главный скрипт → показ подсказки + слушает FinishReached → 'arena-shooter': {
// победа+конфетти; скрипт на финиш-примитиве → шлёт FinishReached; g45_main: `-- === ИГРА «СТРЕЛЯЛКА-АРЕНА» — главный скрипт (Lua) ===
// остальные target-скрипты → красят примитив на касание). ${SNIPPET_BROADCAST}
// Это даёт «хоть что-то рабочее» в любой игре до того как напишем
// полноценный Lua-скрипт. Когда дописываем игру — добавляем сюда явный override. local Players = game:GetService("Players")
'clicker': { g46_main: simpleClicker() }, 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)`,
},
}; };
// ══════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════

View File

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

View File

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

View File

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

View File

@ -204,6 +204,96 @@ 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;
};
}
}
} }
/** Снять обёртки (при выходе из коллаба восстановить оригиналы — простой флаг). */ /** Снять обёртки (при выходе из коллаба восстановить оригиналы — простой флаг). */
@ -441,6 +531,52 @@ export function applyRemoteOp(scene, op) {
scene.removeScript?.(op.id); scene.removeScript?.(op.id);
scene._onCollabScriptsChange?.(); scene._onCollabScriptsChange?.();
return; 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) { switch (t) {
case 'add': { case 'add': {