From d62739d7096566940134746d063f3abef9c2d8d0 Mon Sep 17 00:00:00 2001 From: min Date: Fri, 5 Jun 2026 01:16:35 +0300 Subject: [PATCH] =?UTF-8?q?feat(studio):=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87?= =?UTF-8?q?=D0=B0=2017=20=E2=80=94=20Toolbox=20=C2=AB=D0=93=D0=BE=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=D1=8B=D0=B5=20=D0=BC=D0=B5=D1=85=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=BA=D0=B8=C2=BB=20(gameplay-=D0=BA=D0=B8=D1=82=D1=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фаза T2: вкладка «Готовые механики» в Тулбоксе — 12 готовых китов (Бег на Shift, Смена дня/ночи, Счётчик монет, Таймер, Приветствие, Сундук, Чекпоинт, Конфетти, Парящая платформа, Вертушка, Двойной прыжок, Точка спавна). - GameplayKits.js — каталог китов (scripts global/on-target + prims), getKit. - ToolboxModal.jsx — section 'gameplay' + категории (Движение/Мир/Интерфейс/ Эффекты) + карточки китов + поиск; клик → onPick('kit:'). - 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 --- src/editor/KubikonEditor.jsx | 58 +++++++++ src/editor/ToolboxModal.jsx | 71 ++++++++++- src/editor/engine/GameplayKits.js | 188 ++++++++++++++++++++++++++++++ 3 files changed, 314 insertions(+), 3 deletions(-) create mode 100644 src/editor/engine/GameplayKits.js diff --git a/src/editor/KubikonEditor.jsx b/src/editor/KubikonEditor.jsx index bb5b6a4..03b7a79 100644 --- a/src/editor/KubikonEditor.jsx +++ b/src/editor/KubikonEditor.jsx @@ -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:). + // Вставляем её скрипты/примитивы в проект одним кликом. + if (typeof id === 'string' && id.startsWith('kit:')) { + insertGameplayKit(id.slice(4)); + return; + } // Пользовательские модели имеют префикс 'user:' и // обрабатываются в BabylonScene через UserModelManager // (Этап 5). Активный тип модели работает одинаково. diff --git a/src/editor/ToolboxModal.jsx b/src/editor/ToolboxModal.jsx index 0a11057..b1e655f 100644 --- a/src/editor/ToolboxModal.jsx +++ b/src/editor/ToolboxModal.jsx @@ -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 = ({
{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}`}
+
@@ -558,6 +579,20 @@ const ToolboxModal = ({
)} + {section === 'gameplay' && ( +
+ {KIT_CATEGORIES.map(c => ( + + ))} +
+ )} + {(section === 'mine' || section === 'community') && (
+ ) : ( + kitsFiltered.map(kit => ( + + )) + ) + )} diff --git a/src/editor/engine/GameplayKits.js b/src/editor/engine/GameplayKits.js new file mode 100644 index 0000000..dc3f79d --- /dev/null +++ b/src/editor/engine/GameplayKits.js @@ -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; +}