Some checks failed
Задача 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>
1150 lines
42 KiB
JavaScript
1150 lines
42 KiB
JavaScript
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;
|