feat(wiki): инфраструктура JS/Lua вкладок в статьях

Что сделано:

1. docsLang.jsx (НОВЫЙ):
   - DocsLangProvider — Context для выбранного языка (localStorage).
   - DocsLangPicker — большой переключатель JS/Lua над разделом.
   - <LangTabs js={...} lua={...} /> — локальные вкладки внутри
     статьи: показывает контент текущего языка.
   - useDocsLang() хук.
   - Стили для picker / tabs / langChoiceModal / docTable.

2. docsData.jsx:
   - Новая статья D0 "Скриптинг: JS или Lua — что выбрать?"
     в самом верху раздела D. Сравнение, примеры одного и того же
     кода на двух языках, советы новичкам.
   - Импорт LangTabs.

3. KubikonDocs.jsx:
   - ChapterPage обёрнут в DocsLangProvider + DocsLangPicker сверху.
     Юзер может одним кликом переключить весь раздел JS↔Lua.
   - LessonPage: при «Открыть мою копию» теперь показывается модалка
     LangChoiceModal (JS / Lua). Создаём копию с нужными скриптами.
   - convertProjectScriptsToLua() конвертит project_data:
     если в скрипте есть code_lua слот — активируем. Иначе ставим
     stub с подсказкой.

4. docsGamesBuilders.js:
   - buildGameProject(id, opts) принимает opts.lang='lua'.
     Та же логика — code_lua или stub.

ОСТАЛОСЬ (постепенно):
- Lua-эквиваленты в существующих 78 статьях. Сейчас Picker уже
  показывается, но если в статье нет <LangTabs> — контент одинаковый.
  Будем добавлять <LangTabs> в ключевые места по очереди.
- Lua-версии в GAME_BUILDERS для уроков 1-50 (code_lua слот).
This commit is contained in:
min 2026-06-09 02:25:24 +03:00
parent 0805da0708
commit d019da0ab6
4 changed files with 577 additions and 19 deletions

View File

@ -13,6 +13,7 @@ import { GAMES, GAME_GROUPS } from './docsGames';
import { LESSONS, hasLesson } from './docsLessons'; import { LESSONS, hasLesson } from './docsLessons';
import { buildGameProject } from './docsGamesBuilders'; import { buildGameProject } from './docsGamesBuilders';
import DocIcon from './docsIcons'; import DocIcon from './docsIcons';
import { DocsLangProvider, DocsLangPicker, DOCS_LANG_STYLES, useDocsLang } from './docsLang';
/** /**
* KubikonDocs вика редактора Рублокс. * KubikonDocs вика редактора Рублокс.
@ -76,6 +77,7 @@ const KubikonDocs = () => {
return ( return (
<div className={cl.studio}> <div className={cl.studio}>
<style>{INLINE_STYLES}</style> <style>{INLINE_STYLES}</style>
<style>{DOCS_LANG_STYLES}</style>
{/* === Левая боковая панель === */} {/* === Левая боковая панель === */}
<aside className={cl.sidebar}> <aside className={cl.sidebar}>
@ -383,12 +385,15 @@ const ChapterPage = ({ chapter, mainRef }) => {
{/* Контент раздела */} {/* Контент раздела */}
<div className="docsContent"> <div className="docsContent">
{chapter.sections.map((s) => ( <DocsLangProvider>
<article key={s.id} id={`sec-${s.id}`} className="docsChapter"> <DocsLangPicker />
<h3 className="docsSectionTitle">{s.title}</h3> {chapter.sections.map((s) => (
<div className="docsSectionBody">{s.body}</div> <article key={s.id} id={`sec-${s.id}`} className="docsChapter">
</article> <h3 className="docsSectionTitle">{s.title}</h3>
))} <div className="docsSectionBody">{s.body}</div>
</article>
))}
</DocsLangProvider>
</div> </div>
</section> </section>
); );
@ -399,17 +404,20 @@ const ChapterPage = ({ chapter, mainRef }) => {
// //
const LessonPage = ({ game, navigate }) => { const LessonPage = ({ game, navigate }) => {
const lesson = LESSONS[game.id]; const lesson = LESSONS[game.id];
// 'idle' | 'creating' | 'error' // 'idle' | 'choosing' | 'creating' | 'error'
const [state, setState] = useState('idle'); const [state, setState] = useState('idle');
// Создаёт НОВУЮ копию игры-урока на текущем пользователе и // Шаг 1: юзер нажал «Открыть копию» показываем модалку выбора языка.
// открывает её в редакторе. Оригинал при этом ВСЕГДА цел. const openInEditor = () => {
const openInEditor = async () => {
const userId = getCurrentUserId(); const userId = getCurrentUserId();
if (!userId) { if (!userId) { setState('error'); return; }
setState('error'); setState('choosing');
return; };
}
// Шаг 2: язык выбран создаём копию с нужными скриптами и открываем.
const createCopyWithLang = async (lang) => {
const userId = getCurrentUserId();
if (!userId) { setState('error'); return; }
setState('creating'); setState('creating');
try { try {
// project_data копии берём двумя способами: // project_data копии берём двумя способами:
@ -422,9 +430,11 @@ const LessonPage = ({ game, navigate }) => {
const pd = orig && orig.data && orig.data.project_data; const pd = orig && orig.data && orig.data.project_data;
if (!pd) { setState('error'); return; } if (!pd) { setState('error'); return; }
// project_data может прийти строкой или объектом нормализуем в строку. // project_data может прийти строкой или объектом нормализуем в строку.
projectDataStr = typeof pd === 'string' ? pd : JSON.stringify(pd); let pdObj = typeof pd === 'string' ? JSON.parse(pd) : pd;
if (lang === 'lua') pdObj = convertProjectScriptsToLua(pdObj);
projectDataStr = JSON.stringify(pdObj);
} else { } else {
const project = buildGameProject(game.id); const project = buildGameProject(game.id, { lang });
if (!project) { setState('error'); return; } if (!project) { setState('error'); return; }
projectDataStr = JSON.stringify(project); projectDataStr = JSON.stringify(project);
} }
@ -477,6 +487,12 @@ const LessonPage = ({ game, navigate }) => {
: <><Icon name="play" size={15} /> Открыть мою копию в редакторе</>} : <><Icon name="play" size={15} /> Открыть мою копию в редакторе</>}
</button> </button>
</div> </div>
{state === 'choosing' && (
<LangChoiceModal
onPick={(lang) => createCopyWithLang(lang)}
onCancel={() => setState('idle')}
/>
)}
{state === 'error' && ( {state === 'error' && (
<div className="lessonErr"> <div className="lessonErr">
Не получилось открыть игру. Проверь, что ты вошёл в аккаунт, Не получилось открыть игру. Проверь, что ты вошёл в аккаунт,
@ -492,6 +508,81 @@ const LessonPage = ({ game, navigate }) => {
); );
}; };
//
// Модалка выбора языка скриптов при «Открыть копию»
//
const LangChoiceModal = ({ onPick, onCancel }) => {
return (
<div className="langChoiceOverlay" onClick={onCancel}>
<div className="langChoiceDialog" onClick={(e) => e.stopPropagation()}>
<h3 className="langChoiceTitle">На каком языке открыть копию?</h3>
<p className="langChoiceSub">
Скрипты в твоей копии будут написаны на выбранном языке.
Логика игры одинаковая отличается только запись кода.
</p>
<div className="langChoiceBtns">
<button className="langChoiceBtn langChoiceBtn--js"
onClick={() => onPick('js')}>
<div className="langChoiceBtn__name">JavaScript</div>
<div className="langChoiceBtn__hint">
Если ты новичок этот выбор проще.
</div>
</button>
<button className="langChoiceBtn langChoiceBtn--lua"
onClick={() => onPick('lua')}>
<div className="langChoiceBtn__name">Lua</div>
<div className="langChoiceBtn__hint">
Если играл в Roblox узнаешь команды.
</div>
</button>
</div>
<button className="langChoiceCancel" onClick={onCancel}>Отмена</button>
</div>
</div>
);
};
/**
* Конвертирует все JS-скрипты в project_data в Lua-эквивалент.
* Сейчас простая стратегия: если в скрипте есть code_lua слот, делает его
* активным. Иначе ставит флаг language='lua' и пустой Lua-шаблон с TODO.
* Полноценная транспиляция JSLua невозможна без AST-анализа.
*/
function convertProjectScriptsToLua(projectData) {
const scene = projectData?.scene;
if (!scene || !Array.isArray(scene.scripts)) return projectData;
scene.scripts = scene.scripts.map(s => {
if (s.language === 'lua') return s;
// Если уже есть готовый Lua-слот используем его
if (s.code_lua && s.code_lua.trim()) {
return {
...s,
language: 'lua',
code: s.code_lua,
code_js: s.code_js || s.code,
code_lua: s.code_lua,
};
}
// Иначе ставим заглушку с подсказкой
const luaStub = `-- TODO: версия этого скрипта на Lua пока не готова.
-- Оригинальный JS-код сохранён ниже (переключи язык назад на JS в редакторе).
-- Доступные API: game:GetService("Players"), game.Workspace, script.Parent
--
-- Например, простой пример:
local Players = game:GetService("Players")
print("Привет от Lua-скрипта")
`;
return {
...s,
language: 'lua',
code: luaStub,
code_js: s.code_js || s.code,
code_lua: luaStub,
};
});
return projectData;
}
// //
// Инлайн-стили // Инлайн-стили
// //

View File

@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import DocIcon from './docsIcons'; import DocIcon from './docsIcons';
import { LangTabs } from './docsLang';
/** /**
* docsData.jsx контент вики редактора Рублокс (разделы A-J). * docsData.jsx контент вики редактора Рублокс (разделы A-J).
@ -1122,6 +1123,119 @@ game.gui.onSubmit(boxId, (text) => {
title: 'Скрипты — основы', title: 'Скрипты — основы',
summary: 'Самый важный раздел: что такое скрипт, переменные, события, таймеры.', summary: 'Самый важный раздел: что такое скрипт, переменные, события, таймеры.',
sections: [ sections: [
{
id: 'js-or-lua',
title: 'D0. Скриптинг: JS или Lua — что выбрать?',
body: (
<>
<p>
В Рублоксе можно писать скрипты <b>на двух языках</b>:
<b> JavaScript</b> и <b>Lua</b>. Оба работают одинаково
хорошо. Игра не отличает их между собой внутри одного
проекта одни скрипты могут быть на JS, другие на Lua,
и они общаются между собой как будто это один язык.
</p>
<p><b>Чем они отличаются?</b></p>
<table className="docTable">
<thead>
<tr><th></th><th>JavaScript</th><th>Lua</th></tr>
</thead>
<tbody>
<tr>
<td><b>Где ещё используется</b></td>
<td>Сайты, мобильные приложения, серверы.
Самый популярный язык в мире.</td>
<td>Roblox, World of Warcraft (моды),
многие игры. Простой и быстрый.</td>
</tr>
<tr>
<td><b>Главный API</b></td>
<td><code>game.*</code> (game.player,
game.log, game.scene)</td>
<td><code>game.*</code> в Roblox-стиле
(game:GetService("Players"), workspace)</td>
</tr>
<tr>
<td><b>Похож на</b></td>
<td>Roblox-LUA если знаешь Roblox</td>
<td>Roblox-Studio те же команды</td>
</tr>
<tr>
<td><b>Когда выбрать</b></td>
<td>Если планируешь делать сайты и приложения
JS пригодится везде.</td>
<td>Если играешь в Roblox и видел там скрипты
Lua тебе знаком.</td>
</tr>
</tbody>
</table>
<p><b>Один и тот же пример на двух языках:</b></p>
<p>Когда игрок касается синего блока печатаем «Привет».</p>
<LangTabs
js={<Code>{`// JS — глобальный скрипт
game.onTouch('синий-блок', (player) => {
game.log('Привет, ' + player.name + '!');
});`}</Code>}
lua={<Code>{`-- Lua — скрипт на самом блоке
local part = script.Parent
part.Touched:Connect(function(hit)
local player = game.Players:GetPlayerFromCharacter(hit.Parent)
if player then
print("Привет, " .. player.Name .. "!")
end
end)`}</Code>}
/>
<p>
Видишь оба варианта делают <b>одно и то же</b>. Но
запись отличается. JS короче для простых вещей через
<code>game.onTouch</code>, Lua даёт точный Roblox-стиль
через <code>:Connect</code> и события.
</p>
<p><b>Что выбирать новичку?</b></p>
<Note>
Если ты <b>совсем новичок</b> бери <b>JavaScript</b>.
Команды <code>game.*</code> в JS короче и проще читать.
Большая часть уроков в этой вике написана с примерами
на JS все они работают, просто копируй.
</Note>
<Note>
Если ты <b>уже играл в Roblox</b> и видел там скрипты
бери <b>Lua</b>. Команды почти один в один как
в Roblox Studio: <code>game:GetService</code>,
<code>:Connect</code>, <code>workspace</code>,
<code>script.Parent</code>.
</Note>
<p><b>Можно ли менять язык в одном скрипте?</b></p>
<p>
Да. В редакторе скрипта вверху есть две кнопки
<b>JS</b> и <b>Lua</b>. Просто нажми на нужную.
Твой код на текущем языке <b>сохранится</b>, а
на другом языке откроется пустой шаблон или то, что
ты писал там раньше. Никогда ничего не теряется.
</p>
<Try>
Создай новый скрипт. Напиши пару строк на JS. Нажми
кнопку <b>Lua</b> JS-код спрячется, появится
Lua-шаблон. Напиши там что-то. Нажми <b>JS</b>
обратно твой JS-код вернётся. Магия!
</Try>
<p><b>А что под капотом?</b></p>
<p>
JS-скрипты исполняются в <code>WebWorker</code>
это отдельный поток в браузере, чтобы скрипт не
тормозил саму игру. Lua-скрипты исполняются через
<b> wasmoon</b> Lua-интерпретатор, скомпилированный
в WebAssembly. Оба варианта работают на любом
устройстве без установки.
</p>
</>
),
},
{ {
id: 'what-is-script', id: 'what-is-script',
title: 'D1. Что такое скрипт и как его создать', title: 'D1. Что такое скрипт и как его создать',

View File

@ -6158,8 +6158,37 @@ export function hasGameBuilder(id) {
return typeof GAME_BUILDERS[id] === 'function'; return typeof GAME_BUILDERS[id] === 'function';
} }
/** Построить project_data для игры-урока. Возвращает объект или null. */ /** Построить project_data для игры-урока. Возвращает объект или null.
export function buildGameProject(id) { * opts.lang: 'js' (default) | 'lua' на каком языке скрипты в копии.
*/
export function buildGameProject(id, opts = {}) {
const fn = GAME_BUILDERS[id]; const fn = GAME_BUILDERS[id];
return fn ? fn() : null; if (!fn) return null;
const project = fn();
if (opts.lang === 'lua' && project) {
// Если в скрипте есть code_lua слот — делаем его активным.
// Иначе ставим stub с заметкой что Lua-версия в работе.
const scene = project.scene || {};
if (Array.isArray(scene.scripts)) {
scene.scripts = scene.scripts.map(s => {
if (s.language === 'lua') return s;
if (s.code_lua && s.code_lua.trim()) {
return { ...s, language: 'lua', code: s.code_lua, code_js: s.code_js || s.code };
}
const luaStub = `-- TODO: Lua-версия этого скрипта пока не готова.
-- Переключи язык на JS в редакторе (кнопка JS вверху), чтобы увидеть рабочий код.
-- Lua-API: game:GetService("Players"), workspace, script.Parent
print("Lua-скрипт запущен (заглушка)")
`;
return {
...s,
language: 'lua',
code: luaStub,
code_js: s.code_js || s.code,
code_lua: luaStub,
};
});
}
}
return project;
} }

324
src/community/docsLang.jsx Normal file
View File

@ -0,0 +1,324 @@
/**
* docsLang.jsx поддержка вкладок JS/Lua в статьях вики.
*
* Компоненты:
* <DocsLangProvider> оборачивает страницу статьи, хранит выбранный язык
* в localStorage 'rublox.docs.lang' ('js' | 'lua').
* <DocsLangPicker /> большой переключатель JS/Lua над статьёй.
* <LangTabs js lua /> вкладка-переключатель внутри статьи. Показывает
* либо js, либо lua, согласно текущему языку.
* useDocsLang() хук: возвращает {lang, setLang}.
*
* Если в статье нет ни одного <LangTabs> она одинаково выглядит на обоих
* языках (общая теория, не зависящая от языка скриптов).
*/
import React, { createContext, useContext, useEffect, useState } from 'react';
const LS_KEY = 'rublox.docs.lang';
const DEFAULT_LANG = 'js';
const DocsLangContext = createContext({
lang: DEFAULT_LANG,
setLang: () => {},
});
export function DocsLangProvider({ children }) {
const [lang, setLangState] = useState(() => {
try {
const v = localStorage.getItem(LS_KEY);
return v === 'lua' ? 'lua' : 'js';
} catch (_) {
return DEFAULT_LANG;
}
});
const setLang = (next) => {
const v = next === 'lua' ? 'lua' : 'js';
setLangState(v);
try { localStorage.setItem(LS_KEY, v); } catch (_) {}
};
useEffect(() => {
// Слушаем смену из других вкладок
const onStorage = (e) => {
if (e.key === LS_KEY && (e.newValue === 'js' || e.newValue === 'lua')) {
setLangState(e.newValue);
}
};
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, []);
return (
<DocsLangContext.Provider value={{ lang, setLang }}>
{children}
</DocsLangContext.Provider>
);
}
export function useDocsLang() {
return useContext(DocsLangContext);
}
/** Большой переключатель над статьёй: «На каком языке смотреть код?» */
export function DocsLangPicker() {
const { lang, setLang } = useDocsLang();
return (
<div className="docsLangPicker">
<div className="docsLangPicker__label">
Язык скриптов в этой статье:
</div>
<div className="docsLangPicker__tabs">
<button
type="button"
className={
'docsLangPicker__tab docsLangPicker__tab--js' +
(lang === 'js' ? ' is-active' : '')
}
onClick={() => setLang('js')}
>
JavaScript
</button>
<button
type="button"
className={
'docsLangPicker__tab docsLangPicker__tab--lua' +
(lang === 'lua' ? ' is-active' : '')
}
onClick={() => setLang('lua')}
>
Lua
</button>
</div>
<div className="docsLangPicker__hint">
Не знаешь что выбрать? Смотри статью <b>D0. Скриптинг: JS или Lua?</b>
</div>
</div>
);
}
/**
* Локальный переключатель вкладок внутри статьи. Если js/lua
* прямой контент (children), если на странице нет <DocsLangProvider>
* показываем оба заголовками.
*
* Использование:
* <LangTabs
* js={<Code>game.log('Привет')</Code>}
* lua={<Code>print('Привет')</Code>}
* />
*/
export function LangTabs({ js, lua }) {
const { lang, setLang } = useDocsLang();
const hasJs = js !== undefined && js !== null;
const hasLua = lua !== undefined && lua !== null;
if (!hasJs && !hasLua) return null;
// Если есть только один язык показываем без переключателя
if (hasJs && !hasLua) return <>{js}</>;
if (!hasJs && hasLua) return <>{lua}</>;
return (
<div className="docsLangTabs">
<div className="docsLangTabs__head">
<button
type="button"
className={'docsLangTabs__tab' + (lang === 'js' ? ' is-active' : '')}
onClick={() => setLang('js')}
>
JS
</button>
<button
type="button"
className={'docsLangTabs__tab' + (lang === 'lua' ? ' is-active' : '')}
onClick={() => setLang('lua')}
>
Lua
</button>
</div>
<div className="docsLangTabs__body">
{lang === 'lua' ? lua : js}
</div>
</div>
);
}
export const DOCS_LANG_STYLES = `
.docsLangPicker {
background: linear-gradient(135deg, #1a1d2e 0%, #14172b 100%);
border: 1px solid #2a3050;
border-radius: 10px;
padding: 14px 18px;
margin: 16px 0 24px;
display: flex;
flex-direction: column;
gap: 10px;
}
.docsLangPicker__label {
font-size: 13px;
font-weight: 600;
color: #c8cce0;
}
.docsLangPicker__tabs {
display: flex;
gap: 8px;
}
.docsLangPicker__tab {
flex: 1;
padding: 10px 16px;
border-radius: 6px;
border: 1px solid transparent;
background: #232842;
color: #aab0c8;
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: all 0.15s;
}
.docsLangPicker__tab:hover { background: #2a304f; color: #fff; }
.docsLangPicker__tab--js.is-active {
background: linear-gradient(135deg, #f7df1e 0%, #d4b500 100%);
color: #1a1a1c;
border-color: #d4b500;
}
.docsLangPicker__tab--lua.is-active {
background: linear-gradient(135deg, #2196f3 0%, #1565c0 100%);
color: #fff;
border-color: #1565c0;
}
.docsLangPicker__hint {
font-size: 12px;
color: #8a90a8;
font-style: italic;
}
.docsLangTabs {
margin: 12px 0;
border-radius: 8px;
overflow: hidden;
border: 1px solid #2a3050;
}
.docsLangTabs__head {
display: flex;
background: #181b2c;
border-bottom: 1px solid #2a3050;
}
.docsLangTabs__tab {
padding: 8px 18px;
border: none;
background: transparent;
color: #888da6;
font-size: 12px;
font-weight: 700;
cursor: pointer;
letter-spacing: 0.5px;
border-bottom: 2px solid transparent;
}
.docsLangTabs__tab:hover { color: #c8cce0; }
.docsLangTabs__tab.is-active {
color: #fff;
border-bottom-color: #4a8bff;
background: #1f2338;
}
.docsLangTabs__body {
padding: 0;
}
.docsLangTabs__body > pre,
.docsLangTabs__body > .docCode { margin: 0; border-radius: 0; }
.docTable {
width: 100%;
border-collapse: collapse;
margin: 12px 0;
font-size: 13px;
}
.docTable th, .docTable td {
border: 1px solid #2a3050;
padding: 8px 10px;
text-align: left;
vertical-align: top;
}
.docTable th {
background: #1a1d2e;
color: #c8cce0;
font-weight: 600;
}
.docTable td:first-child {
background: #181b2c;
width: 25%;
color: #aab0c8;
}
.docTable code {
background: #0e1020;
padding: 1px 5px;
border-radius: 3px;
font-size: 12px;
}
.langChoiceOverlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.75);
display: flex; align-items: center; justify-content: center;
z-index: 10000;
}
.langChoiceDialog {
background: #1a1d2e;
border: 1px solid #2a3050;
border-radius: 14px;
padding: 28px;
width: 100%;
max-width: 520px;
box-shadow: 0 20px 60px rgba(0,0,0,0.6);
}
.langChoiceTitle {
font-size: 20px;
margin: 0 0 8px;
color: #fff;
}
.langChoiceSub {
margin: 0 0 20px;
font-size: 13px;
color: #aab0c8;
line-height: 1.5;
}
.langChoiceBtns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 14px;
}
.langChoiceBtn {
padding: 18px 16px;
border-radius: 10px;
border: 2px solid transparent;
text-align: left;
cursor: pointer;
transition: all 0.15s;
color: #fff;
}
.langChoiceBtn--js {
background: linear-gradient(135deg, #f7df1e 0%, #d4b500 100%);
color: #1a1a1c;
}
.langChoiceBtn--js:hover { transform: translateY(-2px); box-shadow: 0 10px 24px rgba(247,223,30,0.3); }
.langChoiceBtn--lua {
background: linear-gradient(135deg, #2196f3 0%, #1565c0 100%);
}
.langChoiceBtn--lua:hover { transform: translateY(-2px); box-shadow: 0 10px 24px rgba(33,150,243,0.4); }
.langChoiceBtn__name {
font-size: 18px;
font-weight: 800;
margin-bottom: 4px;
}
.langChoiceBtn__hint {
font-size: 12px;
font-weight: 400;
opacity: 0.85;
}
.langChoiceCancel {
width: 100%;
padding: 10px;
background: transparent;
border: 1px solid #2a3050;
color: #aab0c8;
border-radius: 8px;
font-size: 13px;
cursor: pointer;
}
.langChoiceCancel:hover { background: #232842; color: #fff; }
`;