/** * 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: 'Разрешает второй прыжок прямо в воздухе. Нажми Space ещё раз во время прыжка.', icon: 'arrow-up', category: 'movement', scripts: [{ attachTo: 'global', code: `// Двойной прыжок: второй прыжок в воздухе по Space game.player.setDoubleJump(true); game.ui.set('dj', 'Двойной прыжок включён! Жми Space в воздухе.', { x: 50, y: 90, anchor: 'bottom', color: '#fff', size: 16 }); game.after(5, () => 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.broadcast("coins", {add: N}) — счётчик обновляется.', icon: 'circle', category: 'ui', scripts: [{ attachTo: 'global', code: `// Счётчик монет в HUD. Прибавить монеты из любого скрипта: // game.broadcast('coins', { add: 100 }); let coins = 0; function show() { game.ui.set('coins', '🪙 ' + coins, { x: 92, y: 6, anchor: 'top', color: '#ffd23a', size: 22 }); } show(); game.onMessage('coins', (m) => { coins += (m && m.add) ? m.add : 1; show(); });` }], }, { id: 'start-pad', 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: 'Стартовая площадка' }], scripts: [{ attachTo: 'on-target', code: `// Игрок появляется на этой площадке в начале игры. // Небольшая задержка — чтобы позиция объекта и игрок успели проинициализироваться. game.after(0.1, () => { const p = game.self.position; game.player.teleport(p.x, p.y + 1.5, p.z); });` }], }, { 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: 1, 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() { const p = game.self.position; // где стоит конфетти-источник for (let k = 0; k < 16; k++) { const col = ['#ff5ab0','#ffd23a','#4d6bff','#36d57a','#ff7a3a'][k % 5]; game.scene.spawn('primitive:cube', { x: p.x + (Math.random()-0.5)*0.6, y: p.y + 0.5, z: p.z + (Math.random()-0.5)*0.6, sx: 0.22, sy: 0.22, sz: 0.22, 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: `// Сундук с лутом — даёт 100 монет (через счётчик монет, если он добавлен). 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.broadcast('coins', { add: 100 }); // обновит «Счётчик монет», если он есть 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 });` }], }, // ===== Партия 1 из Вики (киты 13-17) ===== { id: 'trampoline', name: 'Батут (пружина)', desc: 'Яркая платформа-батут — наступи на неё, и игрока подбросит высоко вверх. (Вики: «Прыжок-пружина»)', icon: 'arrow-up', category: 'movement', prims: [{ type: 'cylinder', x: 0, y: 0.3, z: 0, sx: 3, sy: 0.6, sz: 3, color: '#ff3c8e', material: 'neon', name: 'Батут' }], scripts: [{ attachTo: 'on-target', code: `// Батут: касание → подброс игрока вверх. game.self.onTouch(() => { game.player.setVy(20); // вертикальный импульс (как трамплин) });` }], }, { id: 'speed-pad', name: 'Лента ускорения', desc: 'Жёлтая плита-ускоритель — наступи, и игрок бежит быстрее несколько секунд. (Вики: «бусты скорости»)', icon: 'zap', category: 'movement', prims: [{ type: 'cube', x: 0, y: 0.1, z: 0, sx: 3, sy: 0.2, sz: 5, color: '#ffd23a', material: 'neon', name: 'Лента ускорения', canCollide: false }], scripts: [{ attachTo: 'on-target', code: `// Лента ускорения: касание → x2 скорости на 3 секунды. let boosting = false; game.self.onTouch(() => { if (boosting) return; boosting = true; game.player.setSpeed(2.0); game.ui.set('boost', '⚡ Ускорение!', { x: 50, y: 80, anchor: 'bottom', color: '#ffd23a', size: 18 }); game.after(3, () => { game.player.setSpeed(1.0); game.ui.set('boost', ''); boosting = false; }); });` }], }, { id: 'teleport-portal', name: 'Портал-телепорт', desc: 'Два синих портала. Вошёл в портал — мгновенно переносишься ко второму. Поставь второй портал где нужно. (Вики: «секретный путь»)', icon: 'sparkles', category: 'movement', prims: [ { type: 'cylinder', x: 0, y: 1.5, z: 0, sx: 0.4, sy: 3, sz: 3, color: '#4d6bff', material: 'neon', name: 'Портал A' }, { type: 'cylinder', x: 8, y: 1.5, z: 0, sx: 0.4, sy: 3, sz: 3, color: '#4dffd6', material: 'neon', name: 'Портал B', canCollide: false }, ], scripts: [{ attachTo: 'on-target', code: `// Портал A: касание → телепорт к «Портал B» (ищем его по имени в момент входа). // Передвигай «Портал B» куда угодно — телепорт всегда попадёт к нему. let cd = false; game.self.onTouch(() => { if (cd) return; const b = game.scene.findOne('Портал B'); // ищем второй портал if (!b || !b.position) return; cd = true; game.player.teleport(b.position.x, b.position.y + 1, b.position.z); game.after(1.2, () => { cd = false; }); // защита от повторного входа });` }], }, { id: 'disappearing-platform', name: 'Исчезающая платформа', desc: 'Платформа пропадает под ногами через секунду после касания и возвращается через 3с. (Вики: «Не упади», «Падающий мост»)', icon: 'square', category: 'world', prims: [{ type: 'cube', x: 0, y: 0.25, z: 0, sx: 4, sy: 0.5, sz: 4, color: '#e06a3c', material: 'matte', name: 'Исчезающая платформа' }], scripts: [{ attachTo: 'on-target', code: `// Исчезающая платформа: наступил → через 1с пропадает, через 3с возвращается. let busy = false; game.self.onTouch(() => { if (busy) return; busy = true; game.after(1, () => { game.self.setVisible(false); game.self.setCollide(false); }); game.after(3, () => { game.self.setVisible(true); game.self.setCollide(true); busy = false; }); });` }], }, { id: 'door-button', name: 'Дверь по кнопке E', desc: 'Дверь + кнопка: подойди, нажми E — дверь уезжает в сторону и открывает проход. (Вики: «Кнопка-открывашка»)', icon: 'door', category: 'world', prims: [{ type: 'cube', x: 0, y: 2, z: 0, sx: 1, sy: 4, sz: 3, color: '#8a5a3c', material: 'matte', name: 'Дверь' }], scripts: [{ attachTo: 'on-target', code: `// Дверь по E: ПОВОРАЧИВАЕТСЯ вокруг петли (левой грани), как настоящая дверь. // Толщина двери по X (sx=1) → полуширина 0.5. Петля у грани z = центр - halfZ. let open = false; const p0 = game.self.position; const halfW = 1.5; // половина ширины двери по Z (sz=3 → 1.5) // Петля — у левого края двери (по Z): hinge = p0.z - halfW. const hingeX = p0.x; const hingeZ = p0.z - halfW; function placeDoor(angle) { // Центр двери на расстоянии halfW от петли, повёрнут на angle вокруг петли. const cx = hingeX + Math.sin(angle) * halfW; const cz = hingeZ + Math.cos(angle) * halfW; game.self.move(cx, p0.y, cz); game.self.rotate(angle); } game.self.onInteract(() => { open = !open; placeDoor(open ? Math.PI / 2 : 0); // 90° открыта / 0° закрыта }, { text: 'Открыть / закрыть', key: 'e', distance: 5 });` }], }, ]; /** Найти кит по id. */ export function getKit(id) { return GAMEPLAY_KITS.find(k => k.id === id) || null; }