studio/src/community/KubikonDocs.jsx
МИН 42be04def9
Some checks failed
CI / Lint (pull_request) Failing after 1m10s
CI / Build (pull_request) Successful in 2m2s
CI / Secret scan (pull_request) Successful in 2m36s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
feat(week4): модальные сцены, кастомные скины игрока и вики-гайды по 4 играм
Задача 04 — модальные сцены (затемнение + GUI/3D-анимация + блок ввода):
- engine/ModalManager.js (state-машина, fade, spotlight-проекция, HighlightLayer)
- editor/ModalOverlay.jsx (CSS-overlay + mask-image для spotlight)
- PlayerController.setInputBlocked/setCameraFrozen/captureCameraState/focusOnTarget
- game.modal.open/close/update/isOpen/onClose + пресеты bossIntro/lootbox/dialog/confirmation
- Фиксы ядра: клик GUI-кнопок (realId↔localId), modalOpened через globalEvent,
  guard от двойного срабатывания колбэков, кнопка ✓ на последней строке диалога

Задача 07 — кастомные скины игрока + магазин:
- non-humanoid скины (любая 3D-модель): загрузчик, процедурная анимация,
  настраиваемый AABB, центрирование через pivot-node
- PlayerController.reloadSkin (смена скина в Play)
- game.player.setSkin/unlockSkin/getAvailableSkins/onSkinChange/openSkinShop + локальная валюта
- editor/SkinShopOverlay.jsx (встроенный магазин, клавиша B) + SkinManagerModal.jsx (вкладка «Скины»)
- сериализация scene.skins, кнопка «Скины» в тулбаре

Вики — категория «Разбор готовых игр»:
- docsGames.js: группа g5 + 4 карточки (Двор/Витрина GUI/Сундук/Парк)
- docsLessons.jsx: 4 подробные статьи-урока для детей
- «Открыть мою копию» создаёт копию проекта (оригинал не трогается)

Адаптивная ширина кнопки billboard под длинный текст.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 00:50:56 +03:00

1150 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { jwtDecode } from 'jwt-decode';
import { useAuth } from '../auth/AuthContext.jsx';
import * as Kubikon3DApi from '../api/Kubikon3DService';
import cl from './KubikonStudio.module.css';
import RublocsLogo from '../components/RublocsLogo/RublocsLogo';
import useDeviceType from '../hooks/useDeviceType';
import KubikonDesktopOnlyStub from './KubikonDesktopOnlyStub';
import Icon from '../editor/Icon';
import { DOCS } from './docsData';
import { GAMES, GAME_GROUPS } from './docsGames';
import { LESSONS, hasLesson } from './docsLessons';
import { buildGameProject } from './docsGamesBuilders';
import DocIcon from './docsIcons';
/**
* KubikonDocs — вика редактора Рублокс.
* URL: /docs
*
* Навигация (внутренний state activeChapter, без роутинга):
* - null → главная вики: разделы A-J + сетка 50 игр-уроков
* - 'lesson:<id>' → страница урока конкретной игры
* - <id раздела> → отдельная страница раздела A-J
*
* Все карточки обучалок (разделы вики и игры-уроки) — на одной
* главной странице; отдельной страницы «раздел K» больше нет.
*
* Контент разделов A-J — docsData.jsx, каталог игр — docsGames.js,
* тексты уроков — docsLessons.jsx, билдеры играбельных версий —
* docsGamesBuilders.js. Иконки — самописные SVG (docsIcons.jsx).
*/
function getCurrentUserId() {
try {
const token = localStorage.getItem('Authorization');
return token ? jwtDecode(token).id : null;
} catch {
return null;
}
}
// Звёзды сложности — ряд SVG-иконок
const Stars = ({ n }) => (
<span className="docStars">
{Array.from({ length: n }).map((_, i) => (
<DocIcon key={i} name="star" size={13} />
))}
</span>
);
const KubikonDocs = () => {
const navigate = useNavigate();
const { isLoading } = useAuth();
const { isDesktop } = useDeviceType();
const mainRef = useRef(null);
// null = главная вики, 'lesson:<id>' = урок, иначе id раздела из DOCS
const [activeChapter, setActiveChapter] = useState(null);
// при смене раздела — прокрутка вверх
useEffect(() => {
if (mainRef.current) mainRef.current.scrollTo({ top: 0 });
}, [activeChapter]);
if (!isDesktop) {
return <KubikonDesktopOnlyStub feature="Вики редактора Рублокс" />;
}
const chapter = DOCS.find((c) => c.id === activeChapter) || null;
// если открыт урок — activeChapter имеет вид 'lesson:<id игры>'
const lessonId = typeof activeChapter === 'string' && activeChapter.startsWith('lesson:')
? activeChapter.slice(7) : null;
const lessonGame = lessonId ? GAMES.find((g) => g.id === lessonId) : null;
return (
<div className={cl.studio}>
<style>{INLINE_STYLES}</style>
{/* === Левая боковая панель === */}
<aside className={cl.sidebar}>
<div className={cl.sidebarHeader}>
<div className={cl.sidebarLogo}>
<RublocsLogo size={44} bg="#16181d" />
</div>
<div className={cl.sidebarTitle}>
<div className={cl.brandName}>Рублокс</div>
<div className={cl.brandSub}>Студия</div>
</div>
</div>
{/* Меню — единое со страницей Studio (7 пунктов). Пункты,
которые в Studio переключают вкладки, здесь навигируют
на /?tab=… (у вики своих вкладок нет). */}
<nav className={cl.sidebarNav}>
<button
className={cl.navNewGame}
onClick={() => navigate('/edit/new')}
>
<span className={cl.navNewGameIco}><Icon name="plus" size={15} /></span>
<span>Новая игра</span>
</button>
<button className={cl.navItem} onClick={() => navigate('/?tab=recent')}>
<span className={cl.navIcon}><Icon name="clock" size={15} /></span>
<span>История</span>
</button>
<button className={cl.navItem} onClick={() => navigate('/?tab=home')}>
<span className={cl.navIcon}><Icon name="home" size={15} /></span>
<span>Главная</span>
</button>
<button className={cl.navItem} onClick={() => navigate('/?tab=my')}>
<span className={cl.navIcon}><Icon name="folder" size={15} /></span>
<span>Мои игры</span>
</button>
<button className={cl.navItem} onClick={() => navigate('/?tab=templates')}>
<span className={cl.navIcon}><Icon name="grid" size={15} /></span>
<span>Шаблоны</span>
</button>
<button className={cl.navItem} onClick={() => navigate('/?tab=archive')}>
<span className={cl.navIcon}><Icon name="archive" size={15} /></span>
<span>Архив</span>
</button>
<button
className={`${cl.navItem} ${cl.navActive}`}
onClick={() => setActiveChapter(null)}
>
<span className={cl.navIcon}><Icon name="book-open" size={15} /></span>
<span>ВИКИ</span>
</button>
</nav>
<div className={cl.sidebarFooter}>
<button
className={cl.docsBtn}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}
onClick={() => navigate('/rules')}
>
<Icon name="shield" size={15} /> Правила создания игр
</button>
</div>
</aside>
{/* === Основной контент === */}
<main className={cl.main} ref={mainRef}>
<header className={cl.topBar}>
<div>
<h1 className={cl.pageTitle}>
<Icon name="book-open" size={13} />{' '}
{lessonGame ? `Урок ${lessonGame.num}. ${lessonGame.title}`
: chapter ? chapter.title
: 'Вики редактора'}
</h1>
<p className={cl.pageSub}>
{lessonGame ? 'Урок-игра — собери по шагам'
: chapter ? `Раздел вики · ${chapter.sections.length} тем`
: 'Учебник по созданию 3D-игр в Рублоксе'}
</p>
</div>
<div className={cl.topBarActions}>
{activeChapter !== null ? (
<button className={cl.searchBox} onClick={() => setActiveChapter(null)}>
К разделам вики
</button>
) : (
<button className={cl.searchBox} onClick={() => navigate('/')}>
В Studio
</button>
)}
</div>
</header>
{/* ── ГЛАВНАЯ ВИКИ — разделы A-J + 50 игр-уроков ── */}
{activeChapter === null && (
<WikiHome
onOpen={setActiveChapter}
onOpenLesson={(id) => setActiveChapter('lesson:' + id)}
/>
)}
{/* ── СТРАНИЦА УРОКА ── */}
{lessonGame && (
<LessonPage game={lessonGame} navigate={navigate} />
)}
{/* ── ОТДЕЛЬНАЯ СТРАНИЦА РАЗДЕЛА A-J ── */}
{chapter && (
<ChapterPage chapter={chapter} mainRef={mainRef} />
)}
</main>
</div>
);
};
// ══════════════════════════════════════════════════════════════════
// Сетка 50 игр-уроков, сгруппированная по GAME_GROUPS.
// Общий блок: используется и на главной вики, и в разделе K.
// ══════════════════════════════════════════════════════════════════
const GamesGrid = ({ onOpenLesson }) => (
<>
{GAME_GROUPS.map((grp) => {
const games = GAMES.filter((g) => g.group === grp.id);
return (
<div key={grp.id} className="gamesGroup">
<div className="gamesGroup__head">
<h3 className="gamesGroup__title">
{grp.title} <Stars n={grp.stars} />
</h3>
<p className="gamesGroup__hint">{grp.hint}</p>
</div>
<div className="gamesGrid">
{games.map((g) => {
const ready = hasLesson(g.id);
return (
<button
key={g.id}
type="button"
className={'gameCard' + (ready ? ' gameCard--ready' : '')}
disabled={!ready}
onClick={() => ready && onOpenLesson(g.id)}
>
{/* Превью-картинка игры — скриншот из урока.
По умолчанию lessonN-result.png, но игра
может задать свой кадр через previewShot.
Если урока ещё нет — иконка-плейсхолдер. */}
<div className="gameCard__preview">
{ready ? (
<img
className="gameCard__img"
src={`/wiki/${g.previewShot || `lesson${g.num}-result.png`}`}
alt={g.title}
loading="lazy"
/>
) : (
<div className="gameCard__noimg">
<DocIcon name={g.icon} size={40} />
</div>
)}
<span className="gameCard__num">#{g.num}</span>
<span className="gameCard__stars">
<Stars n={g.stars} />
</span>
</div>
<div className="gameCard__body">
<div className="gameCard__title">{g.title}</div>
<div className="gameCard__desc">{g.desc}</div>
<div className="gameCard__mechanics">
{g.mechanics.slice(0, 3).map((m, i) => (
<span key={i} className="gameCard__tag">{m}</span>
))}
</div>
<div className="gameCard__foot">
{ready ? (
<span className="gameCard__ready">
Открыть урок
</span>
) : (
<span className="gameCard__soon">Урок скоро</span>
)}
</div>
</div>
</button>
);
})}
</div>
</div>
);
})}
</>
);
// ══════════════════════════════════════════════════════════════════
// Главная вики — разделы A-J + сетка 50 игр-уроков на одной странице
// ══════════════════════════════════════════════════════════════════
const WikiHome = ({ onOpen, onOpenLesson }) => {
const totalSections = DOCS.reduce((s, c) => s + c.sections.length, 0);
return (
<>
{/* Hero */}
<section className="docsHero">
<div className="docsHeroContent">
<div className="docsHeroBadge">
<DocIcon name="wiki" size={13} /> Вики
</div>
<h2 className="docsHeroTitle">Научись создавать игры</h2>
<p className="docsHeroDesc">
Полный учебник по редактору Рублокс. {DOCS.length} разделов,
{' '}{totalSections} тем и 50 игр-уроков. Каждый пример можно
скопировать к себе в игру и он заработает.
</p>
</div>
<div className="docsHeroEmoji"><DocIcon name="rocket" size={88} /></div>
</section>
{/* Сетка разделов A-J */}
<div className="wikiSectionLabel">Разделы вики</div>
<div className="wikiGrid">
{DOCS.map((c, i) => (
<button key={c.id} className="wikiCard" onClick={() => onOpen(c.id)}>
<div className="wikiCard__icon">
<DocIcon name={c.icon} size={32} />
</div>
<div className="wikiCard__letter">
{String.fromCharCode(65 + i)}
</div>
<div className="wikiCard__title">{c.title}</div>
<div className="wikiCard__desc">{c.summary}</div>
<div className="wikiCard__meta">{c.sections.length} тем </div>
</button>
))}
</div>
{/* Раздел K — 50 игр-уроков прямо на этой же странице */}
<div className="wikiSectionLabel">
Практика · K. 50 игр-уроков
</div>
<p className="wikiGamesIntro">
Лучший способ научиться собирать настоящие игры. Игры идут от
простого к сложному. Уроки с готовыми играми отмечены зелёным
их можно открыть прямо сейчас.
</p>
<GamesGrid onOpenLesson={onOpenLesson} />
</>
);
};
// ══════════════════════════════════════════════════════════════════
// Отдельная страница раздела A-J
// ══════════════════════════════════════════════════════════════════
const ChapterPage = ({ chapter, mainRef }) => {
const [activeSec, setActiveSec] = useState(chapter.sections[0]?.id);
// подсветка активной темы при скролле
useEffect(() => {
setActiveSec(chapter.sections[0]?.id);
const main = mainRef.current;
if (!main) return;
const onScroll = () => {
const top = main.scrollTop + 120;
let cur = chapter.sections[0]?.id;
for (const s of chapter.sections) {
const el = main.querySelector(`#sec-${s.id}`);
if (el && el.offsetTop <= top) cur = s.id;
}
setActiveSec(cur);
};
main.addEventListener('scroll', onScroll);
return () => main.removeEventListener('scroll', onScroll);
}, [chapter, mainRef]);
const scrollToSec = (id) => {
const main = mainRef.current;
const el = main && main.querySelector(`#sec-${id}`);
if (el) main.scrollTo({ top: el.offsetTop - 16, behavior: 'smooth' });
};
return (
<section className="docsBody">
{/* Оглавление раздела */}
<aside className="docsToc">
<div className="docsTocTitle">
<DocIcon name={chapter.icon} size={16} /> {chapter.title}
</div>
{chapter.sections.map((s) => (
<button
key={s.id}
className={`docsTocItem ${activeSec === s.id ? 'docsTocItemActive' : ''}`}
onClick={() => scrollToSec(s.id)}
>
{s.title}
</button>
))}
</aside>
{/* Контент раздела */}
<div className="docsContent">
{chapter.sections.map((s) => (
<article key={s.id} id={`sec-${s.id}`} className="docsChapter">
<h3 className="docsSectionTitle">{s.title}</h3>
<div className="docsSectionBody">{s.body}</div>
</article>
))}
</div>
</section>
);
};
// ══════════════════════════════════════════════════════════════════
// Страница урока — текст инструкции + кнопка «Открыть игру в редакторе»
// ══════════════════════════════════════════════════════════════════
const LessonPage = ({ game, navigate }) => {
const lesson = LESSONS[game.id];
// 'idle' | 'creating' | 'error'
const [state, setState] = useState('idle');
// Создаёт НОВУЮ копию игры-урока на текущем пользователе и
// открывает её в редакторе. Оригинал при этом ВСЕГДА цел.
const openInEditor = async () => {
const userId = getCurrentUserId();
if (!userId) {
setState('error');
return;
}
setState('creating');
try {
// project_data копии берём двумя способами:
// - у обычных уроков (1-50) — собираем из билдера;
// - у разбора готовых игр (g5) — ЗАГРУЖАЕМ project_data
// оригинала из БД и копируем его (оригинал не трогаем!).
let projectDataStr;
if (game.openProjectId) {
const orig = await Kubikon3DApi.getProjectWithRetry(game.openProjectId, userId);
const pd = orig && orig.data && orig.data.project_data;
if (!pd) { setState('error'); return; }
// project_data может прийти строкой или объектом — нормализуем в строку.
projectDataStr = typeof pd === 'string' ? pd : JSON.stringify(pd);
} else {
const project = buildGameProject(game.id);
if (!project) { setState('error'); return; }
projectDataStr = JSON.stringify(project);
}
const res = await Kubikon3DApi.createProject(userId, {
user_id: userId,
title: 'Моя копия: ' + game.title,
description: 'Игра-урок из вики Рублокса. Это твоя копия — меняй как хочешь, оригинал не пострадает.',
genre: 'other',
thumbnail: '',
is_public: false,
project_data: projectDataStr,
});
const newId = res.data && res.data.id;
if (newId) navigate('/edit/' + newId);
else setState('error');
} catch (e) {
console.error('[LessonPage] openInEditor error:', e);
setState('error');
}
};
return (
<section className="lessonWrap">
{/* Hero урока */}
<div className="lessonHero">
<div className="lessonHero__icon"><DocIcon name={game.icon} size={40} /></div>
<div className="lessonHero__body">
<div className="lessonHero__num">
Урок {game.num} <Stars n={game.stars} />
</div>
<h2 className="lessonHero__title">{game.title}</h2>
<p className="lessonHero__desc">{game.desc}</p>
</div>
</div>
{/* Кнопка открыть игру */}
<div className="lessonOpen">
<div className="lessonOpen__text">
<b>Хочешь сразу посмотреть готовую игру?</b><br />
Открой её в редакторе создастся <b>твоя личная копия</b>.
Меняй её как хочешь, нажимай «Играть» <b>оригинал не пострадает</b>.
</div>
<button
className="lessonOpen__btn"
onClick={openInEditor}
disabled={state === 'creating'}
>
{state === 'creating'
? 'Создаём копию…'
: <><Icon name="play" size={15} /> Открыть мою копию в редакторе</>}
</button>
</div>
{state === 'error' && (
<div className="lessonErr">
Не получилось открыть игру. Проверь, что ты вошёл в аккаунт,
и попробуй ещё раз.
</div>
)}
{/* Тело урока */}
<article className="docsChapter lessonBody">
<div className="docsSectionBody">{lesson.body}</div>
</article>
</section>
);
};
// ══════════════════════════════════════════════════════════════════
// Инлайн-стили
// ══════════════════════════════════════════════════════════════════
const INLINE_STYLES = `
.docsHero {
background: linear-gradient(135deg, #3357ff 0%, #6d28d9 50%, #ec4899 100%);
background-size: 200% 200%;
animation: docsGradient 16s ease-in-out infinite;
border-radius: 28px;
padding: 36px 40px;
margin-bottom: 28px;
color: #fff;
display: flex;
align-items: center;
gap: 24px;
box-shadow: 0 24px 56px rgba(51, 87, 255, 0.28);
}
@keyframes docsGradient {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
.docsHeroContent { flex: 1; min-width: 0; }
.docsHeroBadge {
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(255, 255, 255, 0.20);
border: 1px solid rgba(255, 255, 255, 0.30);
color: #fff;
padding: 5px 14px;
border-radius: 999px;
font-size: 11px;
font-weight: 800;
letter-spacing: 1.4px;
text-transform: uppercase;
margin-bottom: 12px;
}
.docsHeroTitle {
margin: 0 0 10px;
font-size: 34px;
font-weight: 900;
color: #fff;
letter-spacing: -1px;
line-height: 1.08;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.18);
}
.docsHeroDesc {
margin: 0;
font-size: 15px;
color: rgba(255, 255, 255, 0.94);
line-height: 1.55;
max-width: 660px;
}
.docsHeroEmoji {
flex-shrink: 0;
color: #fff;
opacity: 0.92;
animation: docsFloat 6s ease-in-out infinite;
filter: drop-shadow(0 12px 24px rgba(0, 0, 0, 0.20));
}
@keyframes docsFloat {
0%, 100% { transform: translateY(0) rotate(-2deg); }
50% { transform: translateY(-10px) rotate(3deg); }
}
/* === Метка-заголовок секции на главной вики ===
ВАЖНО: прямые потомки .main центрируются правилом
.main > * { max-width:1240px; margin-left/right:auto }.
Для display:block-элемента margin:auto НЕ центрирует, пока у него
нет ширины МЕНЬШЕ доступной — блок просто тянется во всю ширину .main
(весь экран), а margin:auto схлопывается в 0. Поэтому метка уезжала
левее hero/сетки. display:block здесь оставляем, но центрирование
делаем явно: width:100% от бокса с max-width:1240px (наследуется
от .main > *) + margin:auto центрирует сам бокс. Так левый край
метки совпадает с левым краем .docsHero и .wikiGrid. */
.wikiSectionLabel {
display: block;
width: 100%;
max-width: 1240px;
margin: 8px auto 14px;
font-size: 12px;
font-weight: 800;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 1.4px;
}
/* === Сетка карточек разделов === */
.wikiGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
margin-bottom: 30px;
}
.wikiCard {
position: relative;
text-align: left;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 18px;
padding: 22px 20px 18px;
cursor: pointer;
transition: transform 200ms cubic-bezier(0.34,1.56,0.64,1), box-shadow 200ms ease, border-color 200ms ease;
font-family: inherit;
}
.wikiCard:hover {
transform: translateY(-4px);
box-shadow: 0 16px 36px rgba(15, 23, 42, 0.12);
border-color: #3357ff;
}
.wikiCard__icon {
color: #3357ff;
margin-bottom: 10px;
line-height: 0;
}
.wikiCard__letter {
position: absolute;
top: 18px; right: 18px;
width: 30px; height: 30px;
border-radius: 9px;
background: linear-gradient(135deg, #3357ff 0%, #1e2da5 100%);
color: #fff;
font-size: 15px;
font-weight: 900;
display: flex; align-items: center; justify-content: center;
}
.wikiCard__title {
font-size: 17px;
font-weight: 900;
color: #0f172a;
margin-bottom: 6px;
letter-spacing: -0.3px;
}
.wikiCard__desc {
font-size: 13px;
color: #64748b;
line-height: 1.5;
margin-bottom: 12px;
}
.wikiCard__meta {
font-size: 12.5px;
font-weight: 800;
color: #3357ff;
}
/* === Вводный текст к блоку 50 игр-уроков ===
центрируется так же, как .wikiSectionLabel (см. комментарий выше). */
.wikiGamesIntro {
display: block;
width: 100%;
max-width: 1240px;
margin: -6px auto 18px;
font-size: 14px;
line-height: 1.6;
color: #64748b;
}
/* === Body раздела: TOC + контент === */
.docsBody {
display: grid;
grid-template-columns: 250px minmax(0, 1fr);
gap: 28px;
align-items: flex-start;
}
.docsToc {
position: sticky;
top: 8px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 16px;
padding: 12px 8px;
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.04);
}
.docsTocTitle {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px 10px;
font-size: 13px;
font-weight: 900;
color: #0f172a;
border-bottom: 1px solid #eef2f7;
margin-bottom: 6px;
}
.docsTocTitle svg { color: #3357ff; flex-shrink: 0; }
.docsTocItem {
display: block;
width: 100%;
padding: 8px 12px;
background: transparent;
border: none;
border-radius: 9px;
color: #475569;
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
text-align: left;
transition: all 160ms ease;
font-family: inherit;
line-height: 1.4;
}
.docsTocItem:hover { background: #f1f5f9; color: #0f172a; }
.docsTocItemActive, .docsTocItemActive:hover {
background: linear-gradient(135deg, #3357ff 0%, #1e2da5 100%);
color: #fff;
}
/* === Контент раздела === */
.docsContent {
min-width: 0;
display: flex;
flex-direction: column;
gap: 18px;
}
.docsChapter {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 18px;
padding: 24px 28px;
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.04);
scroll-margin-top: 16px;
}
.docsSectionTitle {
margin: 0 0 14px;
font-size: 20px;
font-weight: 900;
color: #0f172a;
letter-spacing: -0.4px;
padding-bottom: 12px;
border-bottom: 1px solid #eef2f7;
}
.docsSectionBody { color: #334155; font-size: 14.5px; line-height: 1.65; }
.docsSectionBody p { margin: 0 0 12px; }
.docsSectionBody p:last-child { margin-bottom: 0; }
.docsSectionBody ul, .docsSectionBody ol { margin: 0 0 12px; padding-left: 22px; }
.docsSectionBody li { margin-bottom: 8px; line-height: 1.55; }
.docsSectionBody li:last-child { margin-bottom: 0; }
.docsSectionBody b { color: #0f172a; font-weight: 800; }
.docsSectionBody h4 { font-family: inherit; }
.docsSectionBody code {
background: #e0e8ff;
color: #3357ff;
padding: 2px 7px;
border-radius: 6px;
font-family: Consolas, Menlo, "Courier New", monospace;
font-size: 13px;
font-weight: 700;
}
/* kbd */
.docsSectionBody .kbd, .kbd {
display: inline-block;
background: #f1f5f9;
border: 1px solid #cbd5e1;
border-radius: 5px;
padding: 1px 8px;
font-family: "Roboto Condensed", system-ui, sans-serif;
font-size: 12px;
font-weight: 800;
color: #475569;
margin: 0 1px;
}
/* Код-блок */
.docCode {
background: #0f172a;
color: #e2e8f0;
border-radius: 12px;
padding: 14px 16px;
margin: 12px 0 14px;
overflow-x: auto;
font-family: Consolas, Menlo, "Courier New", monospace;
font-size: 13px;
line-height: 1.6;
}
.docCode code {
background: none; color: inherit; padding: 0;
font-weight: 500; font-size: 13px; white-space: pre;
}
/* Скриншот интерфейса с подписью.
Картинка вписывается в рамку: не шире контейнера и не выше 360px,
узкие вертикальные скрины (иерархия) не растягиваются на всю высоту. */
.docShot {
margin: 14px auto;
border: 1px solid #e5e7eb;
border-radius: 12px;
overflow: hidden;
background: #f8fafc;
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.06);
width: fit-content;
max-width: 100%;
}
.docShot img {
display: block;
max-width: 100%;
max-height: 360px;
width: auto;
height: auto;
}
.docShot figcaption {
padding: 8px 14px;
font-size: 12.5px;
font-weight: 600;
color: #64748b;
background: #fff;
border-top: 1px solid #eef2f7;
text-align: center;
}
/* широкоформатные скрины (обзор, лента) — во всю ширину */
.docShot--wide { width: 100%; }
.docShot--wide img { width: 100%; max-height: none; }
/* Плашка «куда писать скрипт» */
.docScriptKind {
display: flex;
gap: 10px;
align-items: flex-start;
border-radius: 12px;
padding: 12px 14px;
margin: 12px 0;
font-size: 13px;
line-height: 1.55;
}
.docScriptKind__ico { flex-shrink: 0; line-height: 0; margin-top: 1px; }
.docScriptKind--global {
background: #eef2ff;
border: 1px solid #c7d2fe;
color: #3730a3;
}
.docScriptKind--object {
background: #fef3c7;
border: 1px solid #fde68a;
color: #92400e;
}
.docScriptKind code { background: rgba(255,255,255,0.6); }
/* Шаг инструкции */
.docStep {
display: flex;
gap: 12px;
align-items: flex-start;
margin: 0 0 10px;
}
.docStep__num {
flex-shrink: 0;
width: 26px; height: 26px;
border-radius: 50%;
background: linear-gradient(135deg, #3357ff 0%, #1e2da5 100%);
color: #fff;
font-size: 13px;
font-weight: 900;
display: flex; align-items: center; justify-content: center;
}
.docStep__body { flex: 1; padding-top: 2px; }
/* Жёлтая плашка-подсказка */
.docNote {
display: flex;
gap: 10px;
background: #fffbeb;
border: 1px solid #fde68a;
border-left: 4px solid #f59e0b;
border-radius: 10px;
padding: 12px 14px;
margin: 12px 0;
font-size: 13.5px;
line-height: 1.55;
color: #78350f;
}
.docNote__ico { flex-shrink: 0; line-height: 0; margin-top: 1px; color: #b45309; }
.docNote code { background: #fef3c7; color: #92400e; }
/* Зелёная плашка «попробуй сам» */
.docTry {
display: flex;
gap: 10px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-left: 4px solid #16a34a;
border-radius: 10px;
padding: 12px 14px;
margin: 12px 0;
font-size: 13.5px;
line-height: 1.55;
color: #14532d;
}
.docTry__ico { flex-shrink: 0; line-height: 0; margin-top: 1px; color: #16a34a; }
/* Подзаголовок в справочнике */
.docRefHead {
display: flex;
align-items: center;
gap: 8px;
margin: 22px 0 10px;
font-size: 15px;
font-weight: 800;
color: #0f172a;
padding-bottom: 6px;
border-bottom: 2px solid #e0e8ff;
}
.docRefHead svg { color: #3357ff; flex-shrink: 0; }
.docRefHead:first-child { margin-top: 0; }
/* Таблицы */
.docTable {
width: 100%;
border-collapse: separate;
border-spacing: 0;
background: #fafbfd;
border: 1px solid #e5e7eb;
border-radius: 12px;
overflow: hidden;
margin: 10px 0 12px;
}
.docTable td {
padding: 9px 14px;
border-bottom: 1px solid #eef2f7;
font-size: 13px;
color: #334155;
vertical-align: middle;
line-height: 1.5;
}
.docTable tr:last-child td { border-bottom: none; }
.docTable td:first-child {
width: 220px;
font-weight: 700;
background: #fff;
border-right: 1px solid #eef2f7;
}
/* === Раздел K — игры === */
.gamesGroup { margin-bottom: 28px; }
.gamesGroup__head { margin-bottom: 14px; }
.gamesGroup__title {
margin: 0 0 4px;
font-size: 20px;
font-weight: 900;
color: #e8edf5;
letter-spacing: -0.4px;
}
.gamesGroup__hint { margin: 0; font-size: 13px; color: #9aa3b8; }
/* Звёзды сложности — ряд SVG */
.docStars {
display: inline-flex;
align-items: center;
gap: 1px;
color: #f59e0b;
vertical-align: middle;
}
.docStars svg { fill: #f59e0b; stroke: #f59e0b; }
.gamesGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
gap: 14px;
}
.gameCard {
background: #1c2231;
border: none;
border-radius: 16px;
padding: 0;
overflow: hidden;
display: flex;
flex-direction: column;
transition: transform 200ms ease, box-shadow 200ms ease;
font-family: inherit;
text-align: left;
}
/* карточка с готовым уроком — кликабельна */
.gameCard--ready { cursor: pointer; }
.gameCard--ready:hover { box-shadow: 0 0 0 2px #16a34a; }
/* карточка без урока — приглушена, клик отключён */
.gameCard:disabled { opacity: 0.62; cursor: default; }
.gameCard:disabled:hover { transform: none; box-shadow: none; }
.gameCard:hover {
transform: translateY(-3px);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.35);
}
/* Превью игры — картинка 16:9 с номером и звёздами поверх,
впритык к краям карточки сверху и по бокам. */
.gameCard__preview {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
margin: 0;
background: #0f1320;
overflow: hidden;
}
.gameCard__img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.gameCard--ready:hover .gameCard__img { transform: scale(1.04); }
.gameCard__img { transition: transform 300ms ease; }
/* плейсхолдер, если урока ещё нет */
.gameCard__noimg {
display: flex;
align-items: center;
justify-content: center;
width: 100%; height: 100%;
color: #3357ff;
}
/* плашки поверх превью */
.gameCard__num {
position: absolute;
top: 8px; left: 8px;
background: rgba(15, 23, 42, 0.72);
color: #fff;
font-size: 11.5px;
font-weight: 900;
padding: 3px 8px;
border-radius: 7px;
}
.gameCard__stars {
position: absolute;
top: 8px; right: 8px;
background: rgba(15, 23, 42, 0.72);
padding: 3px 7px;
border-radius: 7px;
line-height: 0;
}
.gameCard__stars .docStars svg { fill: #fbbf24; stroke: #fbbf24; }
.gameCard__body {
display: flex;
flex-direction: column;
flex: 1;
padding: 14px 16px 16px;
}
.gameCard__title {
font-size: 15px;
font-weight: 900;
color: #e8edf5;
margin-bottom: 5px;
letter-spacing: -0.2px;
}
.gameCard__desc {
font-size: 12.5px;
color: #9aa3b8;
line-height: 1.5;
margin-bottom: 10px;
flex: 1;
}
.gameCard__mechanics {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 10px;
}
.gameCard__tag {
font-size: 10.5px;
font-weight: 700;
background: #262d3d;
color: #b6becc;
padding: 2px 7px;
border-radius: 5px;
}
.gameCard__foot {
border-top: 1px solid #262d3d;
padding-top: 8px;
}
.gameCard__soon { font-size: 11.5px; font-weight: 800; color: #6c7691; }
.gameCard__ready { font-size: 11.5px; font-weight: 800; color: #4ade80; }
/* === Страница урока === */
.lessonWrap { display: flex; flex-direction: column; gap: 16px; }
.lessonHero {
display: flex;
align-items: center;
gap: 20px;
background: linear-gradient(135deg, #16a34a 0%, #0d9488 100%);
border-radius: 22px;
padding: 26px 30px;
color: #fff;
box-shadow: 0 18px 44px rgba(13, 148, 136, 0.26);
}
.lessonHero__icon {
flex-shrink: 0;
width: 76px; height: 76px;
border-radius: 18px;
background: rgba(255,255,255,0.16);
color: #fff;
display: flex; align-items: center; justify-content: center;
}
.lessonHero__body { flex: 1; min-width: 0; }
.lessonHero__num {
display: flex; align-items: center; gap: 8px;
font-size: 12px; font-weight: 800;
letter-spacing: 1px; text-transform: uppercase;
opacity: 0.9; margin-bottom: 6px;
}
.lessonHero__num .docStars svg { fill: #fde68a; stroke: #fde68a; }
.lessonHero__title {
margin: 0 0 6px;
font-size: 28px; font-weight: 900;
letter-spacing: -0.6px;
}
.lessonHero__desc { margin: 0; font-size: 14px; line-height: 1.5; opacity: 0.94; }
/* блок «открыть игру» */
.lessonOpen {
display: flex;
align-items: center;
gap: 18px;
background: #fff;
border: 1px solid #e5e7eb;
border-left: 4px solid #16a34a;
border-radius: 14px;
padding: 18px 20px;
}
.lessonOpen__text { flex: 1; font-size: 13.5px; line-height: 1.55; color: #334155; }
.lessonOpen__btn {
flex-shrink: 0;
display: inline-flex; align-items: center; gap: 8px;
background: linear-gradient(135deg, #16a34a 0%, #0d9488 100%);
color: #fff;
font-size: 14px; font-weight: 900;
border: none; border-radius: 10px;
padding: 13px 22px;
cursor: pointer;
font-family: inherit;
}
.lessonOpen__btn:hover { transform: translateY(-2px); }
.lessonOpen__btn:disabled { opacity: 0.6; cursor: default; transform: none; }
.lessonErr {
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 10px;
padding: 12px 16px;
font-size: 13px;
color: #991b1b;
}
/* тело урока */
.lessonBody { padding: 26px 30px; }
.lessonH {
margin: 24px 0 12px;
font-size: 19px;
font-weight: 900;
color: #0f172a;
letter-spacing: -0.3px;
padding-bottom: 8px;
border-bottom: 2px solid #d1fae5;
}
.lessonBody .docsSectionBody > :first-child { margin-top: 0; }
.lessonH:first-child { margin-top: 0; }
/* Адаптив */
@media (max-width: 980px) {
.docsBody { grid-template-columns: 1fr; }
.docsToc { position: static; }
.docsHero { flex-direction: column; padding: 28px 24px; text-align: center; }
.docsHeroEmoji { font-size: 64px; }
.docsChapter { padding: 20px; }
}
`;
export default KubikonDocs;