Open-source веб-студия для создания игр Рублокса, двойная лицензия AGPL-3.0 + Коммерческая. Главное: - Vite 5 + React 18 + Babylon 7.54.3 + Monaco Editor + Colyseus 0.16 - Самодостаточный движок ~28к строк (66 файлов): BlockManager, TerrainVoxelBuilder, ModelManager, DecoManager, PlayerController, ScriptSandboxWorker, MultiplayerSync, 30+ GD-гейммодов - Главный редактор KubikonEditor (~37к строк) + панели, ScriptEditor (Monaco) - Витрина игр (KubikonFeed, KubikonStudio, KubikonDocs, KubikonLearn) - Geometry Dash sub-app (GdMenu, GdShop, GdRules, GdCoverArt) - 10 admin-preview каталогов для дизайнеров (скины, музыка, порталы и т.д.) - Конфигурируемый бэкенд через VITE_API_BASE — работает со staging (dev-api.rublox.pro) без настройки - Standalone-режим (VITE_STANDALONE=true) — открыть пустой редактор без бэка - Полная документация (на русском): README, ARCHITECTURE, CONTRIBUTING, SECURITY, CHANGELOG - ESLint + Prettier + EditorConfig - Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md - Issue templates: bug_report, feature_request, security_disclosure Перед публикацией: - Все импорты из minecraftia заменены на локальные - Все хардкоды URL (minecraftia-school.ru) и внутренних IP убраны → env - Admin-эндпоинты Kubikon3DService вырезаны (остаются в приватном репо) - AdminKubikonModeration не публикуется (модерация — в team.rublox.pro) - 93 МБ ассетов public/kubikon-assets вынесены в .gitignore (раздаются через release artifact)
258 lines
14 KiB
JavaScript
258 lines
14 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
||
import cl from './GameSettingsModal.module.css';
|
||
import Icon from './Icon';
|
||
|
||
/**
|
||
* PublishModal — публикация проекта в умную ленту Рублокса.
|
||
*
|
||
* Премодерации больше нет (RUBLOX_SMART_FEED_PLAN.md). Игра публикуется
|
||
* сразу: проходит автопроверку скриптов и попадает в ленту «Новое».
|
||
* Дальше её судьбу решает алгоритм — лайки, просмотры, время в игре.
|
||
*
|
||
* Поля:
|
||
* - Возрастной рейтинг: 6 / 12 / 16 / 18
|
||
* - Подтверждение «Я ознакомился с правилами публикации»
|
||
*
|
||
* Props:
|
||
* open
|
||
* project — { title, description, status, age_rating }
|
||
* onClose
|
||
* onSubmit({ age_rating }) → должен вернуть результат публикации
|
||
* (например { review: bool }), чтобы показать нужное сообщение.
|
||
*/
|
||
const AGE_OPTIONS = [
|
||
{ id: 6, label: '6+', desc: 'Для самых маленьких. Никакого насилия.' },
|
||
{ id: 12, label: '12+', desc: 'Лёгкая фантастика, без жестокости.' },
|
||
{ id: 16, label: '16+', desc: 'Подростковая. Лёгкое насилие допустимо.' },
|
||
{ id: 18, label: '18+', desc: 'Для взрослых.' },
|
||
];
|
||
|
||
// Самописная иконка «как работает алгоритм» — ступеньки роста.
|
||
const AlgoIcon = ({ size = 16 }) => (
|
||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
|
||
stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"
|
||
strokeLinejoin="round">
|
||
<path d="M3 17l5-5 4 4 8-8" />
|
||
<path d="M16 8h5v5" />
|
||
</svg>
|
||
);
|
||
|
||
const PublishModal = ({ open, project, onClose, onSubmit }) => {
|
||
const [ageRating, setAgeRating] = useState(12);
|
||
const [confirmed, setConfirmed] = useState(false);
|
||
const [error, setError] = useState('');
|
||
const [submitting, setSubmitting] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
setAgeRating(project?.age_rating || 12);
|
||
setConfirmed(false);
|
||
setError('');
|
||
setSubmitting(false);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [open]);
|
||
|
||
if (!open) return null;
|
||
|
||
const handleSubmit = async () => {
|
||
if (!confirmed) {
|
||
setError('Подтвердите что вы ознакомились с правилами');
|
||
return;
|
||
}
|
||
setSubmitting(true);
|
||
try {
|
||
await onSubmit?.({ age_rating: ageRating });
|
||
onClose?.();
|
||
} catch (e) {
|
||
// Приоритет — понятное message от бэка (фильтр названия/описания
|
||
// отдаёт его), затем код ошибки, затем общий текст.
|
||
setError(e?.response?.data?.message
|
||
|| e?.response?.data?.error
|
||
|| e?.message || 'Не удалось опубликовать');
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
// Игра уже опубликована (новый статус 'published') — это переиздание.
|
||
const isAlreadyPublished = project?.status === 'published';
|
||
|
||
return (
|
||
<div className={cl.overlay} onClick={onClose}>
|
||
<div className={cl.modal} onClick={(e) => e.stopPropagation()}>
|
||
<div className={cl.header}>
|
||
<h2 className={cl.title}>
|
||
<span><Icon name="upload" size={14} /></span>
|
||
<span>{isAlreadyPublished ? 'Обновить игру' : 'Опубликовать игру'}</span>
|
||
</h2>
|
||
<button className={cl.closeBtn} onClick={onClose}>×</button>
|
||
</div>
|
||
|
||
<div className={cl.body}>
|
||
{/* === Как работает лента === */}
|
||
<div style={{
|
||
padding: '14px 16px', marginBottom: 4,
|
||
background: 'linear-gradient(135deg, rgba(79,116,255,0.14) 0%, rgba(109,40,217,0.10) 100%)',
|
||
border: '1px solid rgba(79,116,255,0.32)',
|
||
borderRadius: 12, fontSize: 13, color: '#e8e8ea',
|
||
lineHeight: 1.55,
|
||
}}>
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', gap: 8,
|
||
fontWeight: 800, marginBottom: 8, color: '#6d8aff',
|
||
}}>
|
||
<AlgoIcon size={16} />
|
||
<span>Как игра попадает в ленту</span>
|
||
</div>
|
||
<ul style={{ margin: 0, paddingLeft: 18, fontWeight: 600 }}>
|
||
<li style={{ marginBottom: 4 }}>
|
||
После публикации игра <b>сразу появляется</b> во
|
||
вкладке «Новое» — ждать одобрения не нужно.
|
||
</li>
|
||
<li style={{ marginBottom: 4 }}>
|
||
Первые дни ей дают <b>пробный показ</b>. Если
|
||
игроки ставят лайки и долго играют — она
|
||
поднимается во вкладку «Рекомендуем».
|
||
</li>
|
||
<li style={{ marginBottom: 4 }}>
|
||
Если игру мало кто запускает или ставят много
|
||
дизлайков — она тихо опускается в ленте. Но её
|
||
всё равно можно найти <b>через поиск</b>.
|
||
</li>
|
||
<li>
|
||
Хочешь в топ — делай игру интересной: чтобы в неё
|
||
хотелось играть и возвращаться.
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
{isAlreadyPublished && (
|
||
<div style={{
|
||
padding: '12px 14px',
|
||
background: 'linear-gradient(135deg, rgba(16,185,129,0.16) 0%, rgba(16,185,129,0.08) 100%)',
|
||
border: '1px solid rgba(16,185,129,0.40)',
|
||
borderRadius: 12, fontSize: 13, color: '#7ee0b8',
|
||
display: 'flex', alignItems: 'flex-start', gap: 10,
|
||
fontWeight: 600, lineHeight: 1.5,
|
||
}}>
|
||
<span style={{ fontSize: 16 }}><Icon name="check" size={14} /></span>
|
||
<span>
|
||
Игра уже опубликована. Обновление сохранит
|
||
изменения — игра <b>останется в ленте</b>,
|
||
из неё не пропадёт.
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Возрастной рейтинг */}
|
||
<div className={cl.field}>
|
||
<div className={cl.fieldLabel}>
|
||
<span>Возрастной рейтинг <span className={cl.required}>*</span></span>
|
||
</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 8 }}>
|
||
{AGE_OPTIONS.map(opt => {
|
||
const active = ageRating === opt.id;
|
||
return (
|
||
<button
|
||
key={opt.id}
|
||
onClick={() => setAgeRating(opt.id)}
|
||
style={{
|
||
padding: '12px 14px',
|
||
background: active
|
||
? 'linear-gradient(135deg, #4f74ff 0%, #3a57d8 100%)'
|
||
: '#1b1b1b',
|
||
border: active
|
||
? '1px solid transparent'
|
||
: '1px solid #3a3a3a',
|
||
borderRadius: 12,
|
||
color: active ? '#fff' : '#e8e8ea',
|
||
cursor: 'pointer',
|
||
textAlign: 'left',
|
||
transition: 'all 200ms cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||
fontFamily: 'inherit',
|
||
boxShadow: active
|
||
? '0 8px 20px rgba(79, 116, 255, 0.4)'
|
||
: 'none',
|
||
transform: active ? 'translateY(-2px)' : 'translateY(0)',
|
||
}}
|
||
>
|
||
<div style={{
|
||
fontSize: 20, fontWeight: 900, letterSpacing: -0.3,
|
||
}}>{opt.label}</div>
|
||
<div style={{
|
||
fontSize: 12, fontWeight: 600, marginTop: 2,
|
||
color: active ? 'rgba(255,255,255,0.86)' : '#9a9a9e',
|
||
lineHeight: 1.35,
|
||
}}>{opt.desc}</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Подтверждение */}
|
||
<div className={cl.field}>
|
||
<label style={{
|
||
display: 'flex', gap: 12, alignItems: 'flex-start',
|
||
cursor: 'pointer', fontSize: 13,
|
||
padding: 14,
|
||
background: confirmed ? 'rgba(79,116,255,0.18)' : '#1b1b1b',
|
||
border: `1.5px solid ${confirmed ? '#4f74ff' : '#3a3a3a'}`,
|
||
borderRadius: 12,
|
||
transition: 'all 150ms ease',
|
||
color: '#e8e8ea',
|
||
}}>
|
||
<input
|
||
type="checkbox"
|
||
checked={confirmed}
|
||
onChange={(e) => setConfirmed(e.target.checked)}
|
||
style={{
|
||
marginTop: 2, width: 18, height: 18,
|
||
accentColor: '#4f74ff', cursor: 'pointer',
|
||
}}
|
||
/>
|
||
<span style={{ flex: 1, lineHeight: 1.5, fontWeight: 600 }}>
|
||
Я подтверждаю что игра не нарушает правила платформы:
|
||
нет матов, насилия не по возрасту, рекламы и контента,
|
||
нарушающего авторские права.
|
||
<br />
|
||
<span style={{
|
||
color: '#9a9a9e', fontWeight: 600, fontSize: 12,
|
||
display: 'inline-block', marginTop: 4,
|
||
}}>
|
||
Скрипты игры проходят быструю автопроверку.
|
||
Если всё в порядке — игра появится в ленте
|
||
<b style={{ color: '#6d8aff' }}> сразу же</b>.
|
||
</span>
|
||
</span>
|
||
</label>
|
||
</div>
|
||
|
||
{error && (
|
||
<div className={cl.error}>
|
||
<span style={{ fontSize: 16 }}><Icon name="warning" size={14} /></span>
|
||
{error}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className={cl.footer}>
|
||
<button className={cl.secondaryBtn} onClick={onClose} disabled={submitting}>
|
||
Отмена
|
||
</button>
|
||
<button
|
||
className={cl.primaryBtn}
|
||
onClick={handleSubmit}
|
||
disabled={submitting || !confirmed}
|
||
>
|
||
{submitting
|
||
? 'Публикация…'
|
||
: <><Icon name="upload" size={13} /> {isAlreadyPublished ? 'Обновить' : 'Опубликовать'}</>}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default PublishModal;
|