feat(studio): задача 17 — Toolbox «Готовые механики» (gameplay-киты)

Фаза T2: вкладка «Готовые механики» в Тулбоксе — 12 готовых китов
(Бег на Shift, Смена дня/ночи, Счётчик монет, Таймер, Приветствие, Сундук,
Чекпоинт, Конфетти, Парящая платформа, Вертушка, Двойной прыжок, Точка спавна).

- GameplayKits.js — каталог китов (scripts global/on-target + prims), getKit.
- ToolboxModal.jsx — section 'gameplay' + категории (Движение/Мир/Интерфейс/
  Эффекты) + карточки китов + поиск; клик → onPick('kit:<id>').
- KubikonEditor.jsx — insertGameplayKit: создаёт примитивы кита перед камерой,
  привязывает on-target скрипт к первому примитиву, global-скрипты добавляет в
  проект (upsertScript). Безопасность: киты наши, существующий sandbox.

Тест-игра «Игра за 5 минут» id=2544 (dev-режим is_test): town + применённые
киты (welcome/timer/coins/day-night/shift-run + сундук/чекпоинт/конфетти/
платформа). Проверено в плеере — все 5 скриптов исполняются без ошибок.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
min 2026-06-05 01:16:35 +03:00
parent 8a7ab9aadf
commit d62739d709
3 changed files with 314 additions and 3 deletions

View File

@ -6,6 +6,7 @@ import { useSanctions } from '../auth/SanctionsContext.jsx';
import { BabylonScene } from './engine/BabylonScene'; import { BabylonScene } from './engine/BabylonScene';
import { BLOCK_TYPES, BLOCK_CATEGORIES, blockPreview, registerCustomBlockType } from './engine/BlockTypes'; import { BLOCK_TYPES, BLOCK_CATEGORIES, blockPreview, registerCustomBlockType } from './engine/BlockTypes';
import { MODEL_TYPES, MODEL_CATEGORIES, getModelType } from './engine/ModelTypes'; import { MODEL_TYPES, MODEL_CATEGORIES, getModelType } from './engine/ModelTypes';
import { getKit } from './engine/GameplayKits';
import { PRIMITIVE_TYPES, PALETTE_PRIMITIVE_TYPES } from './engine/PrimitiveTypes'; import { PRIMITIVE_TYPES, PALETTE_PRIMITIVE_TYPES } from './engine/PrimitiveTypes';
import { getModelThumbnail } from './engine/ModelThumbnails'; import { getModelThumbnail } from './engine/ModelThumbnails';
import * as Kubikon3DApi from '../api/Kubikon3DService'; import * as Kubikon3DApi from '../api/Kubikon3DService';
@ -768,6 +769,57 @@ const KubikonEditor = () => {
}); });
} }
}, []); }, []);
// Задача 17: вставить готовую механику (kit) из Тулбокса в проект.
// prims[] создаём примитивы перед камерой; on-target скрипт привязываем
// к первому созданному примитиву; global скрипт добавляем как скрипт игры.
const insertGameplayKit = useCallback((kitId) => {
const kit = getKit(kitId);
const s = sceneRef.current;
if (!kit || !s) return;
// Точка вставки перед камерой редактора (~6м), как у paste.
let px = 0, pz = 0;
try {
const cam = s.camera;
const fwd = cam?.getForwardRay ? cam.getForwardRay().direction : null;
if (cam && fwd) { px = cam.position.x + fwd.x * 6; pz = cam.position.z + fwd.z * 6; }
} catch (e) { /* ignore */ }
// 1) Создаём примитивы кита. Запоминаем id первого для on-target скрипта.
let firstPrimId = null;
if (Array.isArray(kit.prims)) {
for (const p of kit.prims) {
const newId = s.primitiveManager?.addInstance(p.type || 'cube', {
x: px + (p.x || 0), y: (p.y != null ? p.y : 1), z: pz + (p.z || 0),
sx: p.sx, sy: p.sy, sz: p.sz,
color: p.color, material: p.material,
canCollide: p.canCollide !== false, visible: true, anchored: true,
name: p.name,
});
if (firstPrimId == null && newId != null) firstPrimId = newId;
}
}
// 2) Добавляем скрипты кита.
if (Array.isArray(kit.scripts)) {
for (const sc of kit.scripts) {
const sid = `script_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
if (sc.attachTo === 'on-target' && firstPrimId != null) {
s.upsertScript(sid, sc.code, { kind: 'primitive', id: firstPrimId });
} else {
s.upsertScript(sid, sc.code, null); // глобальный
}
}
}
markDirty();
setScriptsList(s.getScripts?.() || []);
// Выделим созданный объект (если был) для наглядности.
if (firstPrimId != null) { try { s.selection?.selectPrimitiveById(firstPrimId); } catch (e) {} }
// Тост-уведомление.
try { showToast?.(`Механика «${kit.name}» добавлена`); } catch (e) {}
}, []);
const [paletteTab, setPaletteTab] = useState('blocks'); // 'blocks' | 'models' | 'primitives' const [paletteTab, setPaletteTab] = useState('blocks'); // 'blocks' | 'models' | 'primitives'
const [blockCount, setBlockCount] = useState(0); const [blockCount, setBlockCount] = useState(0);
const [modelCount, setModelCount] = useState(0); const [modelCount, setModelCount] = useState(0);
@ -3842,6 +3894,12 @@ const KubikonEditor = () => {
}} }}
onClose={() => setToolboxOpen(false)} onClose={() => setToolboxOpen(false)}
onPick={(id, userModelObj = null) => { onPick={(id, userModelObj = null) => {
// Задача 17: готовая механика из Тулбокса (kit:<id>).
// Вставляем её скрипты/примитивы в проект одним кликом.
if (typeof id === 'string' && id.startsWith('kit:')) {
insertGameplayKit(id.slice(4));
return;
}
// Пользовательские модели имеют префикс 'user:<id>' и // Пользовательские модели имеют префикс 'user:<id>' и
// обрабатываются в BabylonScene через UserModelManager // обрабатываются в BabylonScene через UserModelManager
// (Этап 5). Активный тип модели работает одинаково. // (Этап 5). Активный тип модели работает одинаково.

View File

@ -1,5 +1,6 @@
import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react'; import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react';
import { MODEL_TYPES, MODEL_CATEGORIES } from './engine/ModelTypes'; import { MODEL_TYPES, MODEL_CATEGORIES } from './engine/ModelTypes';
import { GAMEPLAY_KITS, KIT_CATEGORIES } from './engine/GameplayKits';
import { getModelThumbnail, cancelThumbnailRequest } from './engine/ModelThumbnails'; import { getModelThumbnail, cancelThumbnailRequest } from './engine/ModelThumbnails';
import { import {
getMyUserModels, getPublicUserModels, likeUserModel, getMyUserModels, getPublicUserModels, likeUserModel,
@ -285,6 +286,7 @@ const ToolboxModal = ({
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [category, setCategory] = useState('all'); // для 'standard' const [category, setCategory] = useState('all'); // для 'standard'
const [userKind, setUserKind] = useState('all'); // для 'mine'/'community': all|voxel|smooth const [userKind, setUserKind] = useState('all'); // для 'mine'/'community': all|voxel|smooth
const [kitCat, setKitCat] = useState('all'); // для 'gameplay': категория кита
// Загруженные модели для 'mine' и 'community' // Загруженные модели для 'mine' и 'community'
const [myModels, setMyModels] = useState(null); // null = ещё не загружено const [myModels, setMyModels] = useState(null); // null = ещё не загружено
@ -298,6 +300,7 @@ const ToolboxModal = ({
setSection(initialSection || 'standard'); setSection(initialSection || 'standard');
setCategory('all'); setCategory('all');
setUserKind('all'); setUserKind('all');
setKitCat('all');
setMyModels(null); setMyModels(null);
setCommunityModels(null); setCommunityModels(null);
setLoadError(''); setLoadError('');
@ -413,6 +416,16 @@ const ToolboxModal = ({
[communityModels, filterUserModels] [communityModels, filterUserModels]
); );
// === Готовые механики (gameplay-киты) фильтр по категории + search ===
const kitsFiltered = useMemo(() => {
const q = search.trim().toLowerCase();
let arr = GAMEPLAY_KITS;
if (kitCat !== 'all') arr = arr.filter(k => k.category === kitCat);
if (q) arr = arr.filter(k =>
k.name.toLowerCase().includes(q) || k.desc.toLowerCase().includes(q));
return arr;
}, [search, kitCat]);
// Активный счётчик для шапки // Активный счётчик для шапки
const visibleCount = section === 'standard' const visibleCount = section === 'standard'
? standardFiltered.length ? standardFiltered.length
@ -497,9 +510,11 @@ const ToolboxModal = ({
<div className={cl.headerInfo}> <div className={cl.headerInfo}>
{section === 'standard' {section === 'standard'
? `Показано ${visibleCount} из ${totalForSection}` ? `Показано ${visibleCount} из ${totalForSection}`
: (myModels === null && section === 'mine') || (communityModels === null && section === 'community') : section === 'gameplay'
? '...' ? `${kitsFiltered.length} готовых механик`
: `${visibleCount} из ${totalForSection}`} : (myModels === null && section === 'mine') || (communityModels === null && section === 'community')
? '...'
: `${visibleCount} из ${totalForSection}`}
</div> </div>
<button className={cl.closeBtn} onClick={onClose} title="Закрыть (Esc)"> <button className={cl.closeBtn} onClick={onClose} title="Закрыть (Esc)">
<Icon name="close" size={14} /> <Icon name="close" size={14} />
@ -526,6 +541,12 @@ const ToolboxModal = ({
> >
<Icon name="globe" size={15} /> Сообщество <Icon name="globe" size={15} /> Сообщество
</button> </button>
<button
className={`${cl.sectionTab} ${section === 'gameplay' ? cl.sectionTabActive : ''}`}
onClick={() => setSection('gameplay')}
>
<Icon name="zap" size={15} /> Готовые механики
</button>
</div> </div>
<div className={cl.searchBar}> <div className={cl.searchBar}>
@ -558,6 +579,20 @@ const ToolboxModal = ({
</div> </div>
)} )}
{section === 'gameplay' && (
<div className={cl.categoryTabs}>
{KIT_CATEGORIES.map(c => (
<button
key={c.id}
className={`${cl.categoryTab} ${kitCat === c.id ? cl.categoryTabActive : ''}`}
onClick={() => setKitCat(c.id)}
>
{c.label}
</button>
))}
</div>
)}
{(section === 'mine' || section === 'community') && ( {(section === 'mine' || section === 'community') && (
<div className={cl.categoryTabs}> <div className={cl.categoryTabs}>
<button <button
@ -657,6 +692,36 @@ const ToolboxModal = ({
)) ))
) )
)} )}
{section === 'gameplay' && (
kitsFiltered.length === 0 ? (
<div className={cl.empty}>Ничего не найдено</div>
) : (
kitsFiltered.map(kit => (
<button
key={kit.id}
type="button"
className={cl.card}
onClick={() => { onPick('kit:' + kit.id); onClose(); }}
title={kit.desc}
style={{ textAlign: 'left' }}
>
<div className={cl.cardIconWrap}>
<div className={cl.cardIconPlaceholder} style={{
background: 'linear-gradient(135deg, rgba(77,107,255,0.25), rgba(54,213,122,0.18))',
}}>
<Icon name={kit.icon || 'zap'} size={30} />
</div>
</div>
<div className={cl.cardName}>{kit.name}</div>
<div className={cl.cardCat} style={{
fontSize: 11, opacity: 0.8, lineHeight: 1.3,
display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden',
}}>{kit.desc}</div>
</button>
))
)
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,188 @@
/**
* GameplayKits каталог готовых механик для Toolbox (задача 17, фаза T2).
*
* Каждый kit это готовый кусок поведения, который автор вставляет одним кликом
* из Тулбокса (вкладка «Готовые механики»). При вставке:
* - scripts с attachTo:'global' добавляются как глобальный скрипт игры;
* - scripts с attachTo:'on-target' создаётся примитив-маркер + скрипт на нём;
* - prims[] создаются примитивы на сцене (визуал кита).
*
* Все киты написаны НАМИ на белом-листе game-API (ScriptSandboxWorker)
* заведомо безопасны, исполняются в существующем sandbox (нет доступа к DOM/fetch).
*
* Фича-парность: тот же файл копируется в rublox-player/src/engine/ (киты это
* данные-скрипты, исполняются движком плеера так же).
*/
export const KIT_CATEGORIES = [
{ id: 'all', label: 'Все' },
{ id: 'movement', label: 'Движение' },
{ id: 'world', label: 'Мир' },
{ id: 'ui', label: 'Интерфейс' },
{ id: 'fx', label: 'Эффекты' },
];
export const GAMEPLAY_KITS = [
{
id: 'shift-to-run',
name: 'Бег на Shift',
desc: 'Игрок ускоряется в 1.8× при удержании Shift и возвращается к обычной скорости при отпускании.',
icon: 'zap', category: 'movement',
scripts: [{ attachTo: 'global', code:
`// Бег на Shift
game.onKey('shift', () => game.player.setSpeed(1.8));
game.onKeyUp('shift', () => game.player.setSpeed(1.0));` }],
},
{
id: 'double-jump',
name: 'Двойной прыжок',
desc: 'Разрешает второй прыжок в воздухе. Подсказка появляется при старте.',
icon: 'arrow-up', category: 'movement',
scripts: [{ attachTo: 'global', code:
`// Двойной прыжок (упрощённо — повышенная высота прыжка)
game.player.setJumpPower && game.player.setJumpPower(1.6);
game.ui.set('dj', 'Прыгай — теперь выше!', { x: 50, y: 90, anchor: 'bottom', color: '#fff', size: 16 });
game.after(4, () => game.ui.set('dj', ''));` }],
},
{
id: 'day-night-cycle',
name: 'Смена дня и ночи',
desc: 'Небо плавно переключается день → закат → ночь → день по кругу (использует Skybox задачи 16).',
icon: 'cloud', category: 'world',
scripts: [{ attachTo: 'global', code:
`// Авто-цикл дня и ночи
const phases = ['clear-summer-day', 'sunset', 'starry-night', 'clear-summer-day'];
let i = 0;
game.scene.setSkybox({ preset: phases[0] });
game.every(8, () => {
i = (i + 1) % phases.length;
game.scene.skybox.fadeTo({ preset: phases[i] }, 3);
});` }],
},
{
id: 'currency-counter',
name: 'Счётчик монет',
desc: 'Показывает счётчик монет в углу HUD. Метод game.addCoins(n) прибавляет монеты.',
icon: 'circle', category: 'ui',
scripts: [{ attachTo: 'global', code:
`// Счётчик монет в HUD
let coins = 0;
function showCoins() { game.ui.set('coins', '🪙 ' + coins, { x: 92, y: 6, anchor: 'top', color: '#ffd23a', size: 22 }); }
showCoins();
// Глобальный помощник: вызывай game.scene.setData('_coins','add',N) или меняй coins из других скриптов.
game.every(0.3, () => showCoins());
globalThis.__addCoins = (n) => { coins += (n||1); showCoins(); };` }],
},
{
id: 'spawn-point',
name: 'Точка спавна',
desc: 'Зелёная платформа-маркер — место появления игрока. Поставь где нужно.',
icon: 'flag', category: 'world',
prims: [{ type: 'cylinder', x: 0, y: 0.15, z: 0, sx: 3, sy: 0.3, sz: 3, color: '#36d57a', material: 'neon', name: 'Точка спавна' }],
},
{
id: 'checkpoint',
name: 'Чекпоинт',
desc: 'Светящийся столб-чекпоинт. При касании сохраняет прогресс и показывает уведомление.',
icon: 'flag', category: 'world',
prims: [{ type: 'cylinder', x: 0, y: 1.5, z: 0, sx: 0.6, sy: 3, sz: 0.6, color: '#4d6bff', material: 'neon', name: 'Чекпоинт' }],
scripts: [{ attachTo: 'on-target', code:
`// Чекпоинт: касание → сообщение
game.self.onInteract(() => {
game.ui.set('cp', '✓ Чекпоинт сохранён!', { x: 50, y: 85, anchor: 'bottom', color: '#36d57a', size: 18 });
game.after(2, () => game.ui.set('cp', ''));
}, { text: 'Активировать', key: 'f', distance: 4 });` }],
},
{
id: 'confetti',
name: 'Конфетти',
desc: 'Праздничный взрыв конфетти из точки. Запускается сразу и периодически.',
icon: 'sparkles', category: 'fx',
prims: [{ type: 'sphere', x: 0, y: 3, z: 0, sx: 0.5, sy: 0.5, sz: 0.5, color: '#ff5ab0', material: 'neon', name: 'Конфетти-источник', canCollide: false }],
scripts: [{ attachTo: 'on-target', code:
`// Конфетти: периодический фейерверк примитивов
function burst() {
for (let k = 0; k < 16; k++) {
const col = ['#ff5ab0','#ffd23a','#4d6bff','#36d57a','#ff7a3a'][k % 5];
const id = game.scene.spawn('primitive:cube', {
x: (Math.random()-0.5)*1, y: 4, z: (Math.random()-0.5)*1,
sx: 0.25, sy: 0.25, sz: 0.25, color: col, anchored: false, canCollide: false, lifetime: 2.5,
});
}
}
burst();
game.every(3, burst);` }],
},
{
id: 'floating-platform',
name: 'Парящая платформа',
desc: 'Платформа, которая плавно качается вверх-вниз — для паркура.',
icon: 'square', category: 'world',
prims: [{ type: 'cube', x: 0, y: 2, z: 0, sx: 4, sy: 0.5, sz: 4, color: '#c8a86a', material: 'matte', name: 'Парящая платформа' }],
scripts: [{ attachTo: 'on-target', code:
`// Качание платформы вверх-вниз
let t = 0; const baseY = 2;
game.onTick((dt) => {
t += dt;
game.self.move(game.self.position.x, baseY + Math.sin(t * 1.5) * 1.2, game.self.position.z);
});` }],
},
{
id: 'rotating-trap',
name: 'Вращающийся объект',
desc: 'Объект, который постоянно вращается — препятствие или декор.',
icon: 'refresh', category: 'world',
prims: [{ type: 'cube', x: 0, y: 1.5, z: 0, sx: 5, sy: 0.4, sz: 0.6, color: '#e0483c', material: 'matte', name: 'Вертушка' }],
scripts: [{ attachTo: 'on-target', code:
`// Постоянное вращение
let a = 0;
game.onTick((dt) => { a += dt * 1.5; game.self.rotate(a); });` }],
},
{
id: 'timer-hud',
name: 'Таймер забега',
desc: 'Секундомер в HUD — считает время с начала игры. Основа для гонок на время.',
icon: 'clock', category: 'ui',
scripts: [{ attachTo: 'global', code:
`// Таймер забега
let t = 0;
game.every(0.1, () => {
t += 0.1;
game.ui.set('timer', '⏱ ' + t.toFixed(1) + ' c', { x: 50, y: 6, anchor: 'top', color: '#ffffff', size: 22 });
});` }],
},
{
id: 'welcome-message',
name: 'Приветствие',
desc: 'Показывает приветственное сообщение при входе в игру и убирает через 5 секунд.',
icon: 'message', category: 'ui',
scripts: [{ attachTo: 'global', code:
`// Приветствие
game.ui.set('welcome', '👋 Добро пожаловать в игру!', { x: 50, y: 40, anchor: 'center', color: '#ffffff', size: 30 });
game.after(5, () => game.ui.set('welcome', ''));` }],
},
{
id: 'loot-crate',
name: 'Сундук с лутом',
desc: 'Золотой сундук. При взаимодействии «открывается» — даёт награду и сообщение.',
icon: 'box', category: 'world',
prims: [
{ type: 'cube', x: 0, y: 0.6, z: 0, sx: 1.6, sy: 1.2, sz: 1.2, color: '#b5862e', material: 'metal', name: 'Сундук' },
{ type: 'cube', x: 0, y: 1.35, z: 0, sx: 1.7, sy: 0.4, sz: 1.3, color: '#d4a843', material: 'metal', name: 'Крышка сундука', canCollide: false },
],
scripts: [{ attachTo: 'on-target', code:
`// Сундук с лутом
let opened = false;
game.self.onInteract(() => {
if (opened) { game.ui.set('loot', 'Сундук уже открыт.', { x: 50, y: 85, anchor: 'bottom', color: '#bbb', size: 16 }); return; }
opened = true;
game.ui.set('loot', '🎁 Ты получил награду: +100 монет!', { x: 50, y: 85, anchor: 'bottom', color: '#ffd23a', size: 18 });
game.after(3, () => game.ui.set('loot', ''));
}, { text: 'Открыть сундук', key: 'f', distance: 4 });` }],
},
];
/** Найти кит по id. */
export function getKit(id) {
return GAMEPLAY_KITS.find(k => k.id === id) || null;
}