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:
parent
8a7ab9aadf
commit
d62739d709
@ -6,6 +6,7 @@ import { useSanctions } from '../auth/SanctionsContext.jsx';
|
||||
import { BabylonScene } from './engine/BabylonScene';
|
||||
import { BLOCK_TYPES, BLOCK_CATEGORIES, blockPreview, registerCustomBlockType } from './engine/BlockTypes';
|
||||
import { MODEL_TYPES, MODEL_CATEGORIES, getModelType } from './engine/ModelTypes';
|
||||
import { getKit } from './engine/GameplayKits';
|
||||
import { PRIMITIVE_TYPES, PALETTE_PRIMITIVE_TYPES } from './engine/PrimitiveTypes';
|
||||
import { getModelThumbnail } from './engine/ModelThumbnails';
|
||||
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 [blockCount, setBlockCount] = useState(0);
|
||||
const [modelCount, setModelCount] = useState(0);
|
||||
@ -3842,6 +3894,12 @@ const KubikonEditor = () => {
|
||||
}}
|
||||
onClose={() => setToolboxOpen(false)}
|
||||
onPick={(id, userModelObj = null) => {
|
||||
// Задача 17: готовая механика из Тулбокса (kit:<id>).
|
||||
// Вставляем её скрипты/примитивы в проект одним кликом.
|
||||
if (typeof id === 'string' && id.startsWith('kit:')) {
|
||||
insertGameplayKit(id.slice(4));
|
||||
return;
|
||||
}
|
||||
// Пользовательские модели имеют префикс 'user:<id>' и
|
||||
// обрабатываются в BabylonScene через UserModelManager
|
||||
// (Этап 5). Активный тип модели работает одинаково.
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react';
|
||||
import { MODEL_TYPES, MODEL_CATEGORIES } from './engine/ModelTypes';
|
||||
import { GAMEPLAY_KITS, KIT_CATEGORIES } from './engine/GameplayKits';
|
||||
import { getModelThumbnail, cancelThumbnailRequest } from './engine/ModelThumbnails';
|
||||
import {
|
||||
getMyUserModels, getPublicUserModels, likeUserModel,
|
||||
@ -285,6 +286,7 @@ const ToolboxModal = ({
|
||||
const [search, setSearch] = useState('');
|
||||
const [category, setCategory] = useState('all'); // для 'standard'
|
||||
const [userKind, setUserKind] = useState('all'); // для 'mine'/'community': all|voxel|smooth
|
||||
const [kitCat, setKitCat] = useState('all'); // для 'gameplay': категория кита
|
||||
|
||||
// Загруженные модели для 'mine' и 'community'
|
||||
const [myModels, setMyModels] = useState(null); // null = ещё не загружено
|
||||
@ -298,6 +300,7 @@ const ToolboxModal = ({
|
||||
setSection(initialSection || 'standard');
|
||||
setCategory('all');
|
||||
setUserKind('all');
|
||||
setKitCat('all');
|
||||
setMyModels(null);
|
||||
setCommunityModels(null);
|
||||
setLoadError('');
|
||||
@ -413,6 +416,16 @@ const ToolboxModal = ({
|
||||
[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'
|
||||
? standardFiltered.length
|
||||
@ -497,9 +510,11 @@ const ToolboxModal = ({
|
||||
<div className={cl.headerInfo}>
|
||||
{section === 'standard'
|
||||
? `Показано ${visibleCount} из ${totalForSection}`
|
||||
: (myModels === null && section === 'mine') || (communityModels === null && section === 'community')
|
||||
? '...'
|
||||
: `${visibleCount} из ${totalForSection}`}
|
||||
: section === 'gameplay'
|
||||
? `${kitsFiltered.length} готовых механик`
|
||||
: (myModels === null && section === 'mine') || (communityModels === null && section === 'community')
|
||||
? '...'
|
||||
: `${visibleCount} из ${totalForSection}`}
|
||||
</div>
|
||||
<button className={cl.closeBtn} onClick={onClose} title="Закрыть (Esc)">
|
||||
<Icon name="close" size={14} />
|
||||
@ -526,6 +541,12 @@ const ToolboxModal = ({
|
||||
>
|
||||
<Icon name="globe" size={15} /> Сообщество
|
||||
</button>
|
||||
<button
|
||||
className={`${cl.sectionTab} ${section === 'gameplay' ? cl.sectionTabActive : ''}`}
|
||||
onClick={() => setSection('gameplay')}
|
||||
>
|
||||
<Icon name="zap" size={15} /> Готовые механики
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={cl.searchBar}>
|
||||
@ -558,6 +579,20 @@ const ToolboxModal = ({
|
||||
</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') && (
|
||||
<div className={cl.categoryTabs}>
|
||||
<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>
|
||||
|
||||
188
src/editor/engine/GameplayKits.js
Normal file
188
src/editor/engine/GameplayKits.js
Normal 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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user