/** * 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: 'Эффекты' }, { id: 'npc', label: 'NPC и бой' }, { id: 'economy', 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', // Полотно двери — ПЕРВЫЙ prim (на нём скрипт). Остальные части — рамка // (неподвижный косяк) + декор полотна. Всё уходит в одну папку. prims: [ // 0) Полотно двери (тёмное дерево). { type: 'cube', x: 0, y: 2, z: 0, sx: 0.25, sy: 3.8, sz: 2.6, color: '#6b4423', material: 'matte', name: 'Полотно двери' }, // Филёнки (светлее, чуть выступают) — верхняя и нижняя. { type: 'cube', x: 0.16, y: 2.9, z: 0, sx: 0.08, sy: 1.2, sz: 1.9, color: '#8a5a2e', material: 'matte', canCollide: false, name: 'Филёнка верх' }, { type: 'cube', x: 0.16, y: 1.2, z: 0, sx: 0.08, sy: 1.4, sz: 1.9, color: '#8a5a2e', material: 'matte', canCollide: false, name: 'Филёнка низ' }, // Ручка (золотая). { type: 'sphere', x: 0.28, y: 2, z: 0.95, sx: 0.3, sy: 0.3, sz: 0.3, color: '#e0b030', material: 'metal', canCollide: false, name: 'Ручка' }, // Косяк-рамка (неподвижная) — две стойки + перемычка. { type: 'cube', x: 0, y: 2, z: -1.55, sx: 0.4, sy: 4.2, sz: 0.4, color: '#4a3018', material: 'matte', name: 'Косяк левый' }, { type: 'cube', x: 0, y: 2, z: 1.55, sx: 0.4, sy: 4.2, sz: 0.4, color: '#4a3018', material: 'matte', name: 'Косяк правый' }, { type: 'cube', x: 0, y: 4.1, z: 0, sx: 0.4, sy: 0.4, sz: 3.5, color: '#4a3018', material: 'matte', name: 'Перемычка' }, ], scripts: [{ attachTo: 'on-target', code: `// Дверь по E: ПЛАВНО поворачивается вокруг петли (левой грани). // Скрипт на полотне двери. Филёнки/ручка двигаются вместе как часть полотна? // Нет — они отдельные примитивы, поэтому анимируем только полотно (game.self), // а декор оставляем — на низкой толщине двери смотрится цельно. const p0 = game.self.position; // центр полотна (закрытое положение) const halfW = 1.3; // половина ширины полотна по Z (sz=2.6) const hingeX = p0.x; const hingeZ = p0.z - halfW; // петля у левого края let open = false; let cur = 0, target = 0; // текущий и целевой угол (радианы) const SPEED = Math.PI; // рад/сек → ~0.5с на 90° // Декор полотна — двигаем вместе с дверью. Запоминаем их СМЕЩЕНИЕ относительно // центра полотна (в закрытом виде), чтобы вращать вокруг той же петли. const decorNames = ['Филёнка верх', 'Филёнка низ', 'Ручка']; const decor = []; for (const nm of decorNames) { const o = game.scene.findOne(nm); if (o && o.position) { decor.push({ obj: o, dx: o.position.x - p0.x, dy: o.position.y - p0.y, dz: o.position.z - p0.z }); } } // Поворот локального вектора (lx,lz) вокруг оси Y на angle — согласованно с // тем, как Babylon поворачивает меш при rotation.y=angle (левосторонняя СК). function rotY(lx, lz, a) { const s = Math.sin(a), c = Math.cos(a); return { x: lx * c + lz * s, z: -lx * s + lz * c }; } function place(angle) { // Полотно: центр = петля + повёрнутый локальный вектор (0, +halfW). const pc = rotY(0, halfW, angle); const cx = hingeX + pc.x; const cz = hingeZ + pc.z; game.self.move(cx, p0.y, cz); game.self.rotate(angle); // Декор: центр полотна + повёрнутое локальное смещение (той же формулой). for (const d of decor) { const r = rotY(d.dx, d.dz, angle); d.obj.move(cx + r.x, p0.y + d.dy, cz + r.z); if (d.obj.rotate) d.obj.rotate(angle); } } // Один постоянный тик плавно ведёт cur → target. game.onTick((dt) => { if (cur === target) return; const step = SPEED * dt; if (Math.abs(target - cur) <= step) cur = target; else cur += Math.sign(target - cur) * step; place(cur); }); game.self.onInteract(() => { open = !open; target = open ? Math.PI / 2 : 0; // 90° открыта / 0° закрыта }, { text: 'Открыть / закрыть', key: 'e', distance: 5 });` }], }, // ===== Партия 2 из Вики (киты 18-22) ===== { id: 'color-tiles', name: 'Цветная плитка', desc: 'Наступи на плитку — она меняет цвет на случайный. (Вики: «Цветные плитки»)', icon: 'palette', category: 'world', prims: [{ type: 'cube', x: 0, y: 0.1, z: 0, sx: 2.5, sy: 0.2, sz: 2.5, color: '#cfd8dc', material: 'matte', name: 'Цветная плитка' }], scripts: [{ attachTo: 'on-target', code: `// Плитка меняет цвет при касании. const colors = ['#ff5ab0','#ffd23a','#4d6bff','#36d57a','#ff7a3a','#a05aff']; let i = 0; game.self.onTouch(() => { i = (i + 1) % colors.length; game.self.setColor(colors[i]); });` }], }, { id: 'lava-floor', name: 'Лава (урон по касанию)', desc: 'Раскалённая плита: наступишь — теряешь здоровье каждую секунду, пока стоишь. (Вики: «Лава-пол»)', icon: 'lava', category: 'world', prims: [{ type: 'cube', x: 0, y: 0.1, z: 0, sx: 5, sy: 0.2, sz: 5, color: '#ff4422', material: 'neon', name: 'Лава' }], scripts: [{ attachTo: 'on-target', code: `// Лава: пока игрок на плите — урон каждую секунду. let onLava = false, timer = null; game.self.onTouch(() => { if (onLava) return; onLava = true; const tick = () => { if (!onLava) return; game.player.damage(15); game.ui.set('lava', '🔥 Горячо! -15 HP', { x: 50, y: 80, anchor: 'bottom', color: '#ff6644', size: 18 }); timer = game.after(1, tick); }; tick(); }); game.self.onUntouch(() => { onLava = false; if (timer) game.cancel(timer); game.ui.set('lava', ''); });` }], }, { id: 'elevator', name: 'Лифт', desc: 'Платформа-лифт сама ездит вверх-вниз между двумя этажами. Встань на неё и катайся. (Вики: «Лифт»)', icon: 'elevator', category: 'world', prims: [{ type: 'cube', x: 0, y: 0.5, z: 0, sx: 4, sy: 0.5, sz: 4, color: '#7a8a9a', material: 'metal', name: 'Лифт' }], scripts: [{ attachTo: 'on-target', code: `// Лифт: плавно ездит между нижней и верхней высотой. const p0 = game.self.position; const lowY = p0.y, highY = p0.y + 8; let t = 0; game.onTick((dt) => { t += dt; // Синусоида 0..1 с паузами на концах (период ~8с). const k = (Math.sin(t * 0.5 - Math.PI/2) + 1) / 2; game.self.move(p0.x, lowY + (highY - lowY) * k, p0.z); });` }], }, { id: 'finish-line', name: 'Финиш (победа)', desc: 'Финишная плита: дойди до неё — на экране «ПОБЕДА!» и управление блокируется. (Вики: «Беги к финишу»)', icon: 'flag', category: 'ui', prims: [{ type: 'cube', x: 0, y: 0.15, z: 0, sx: 4, sy: 0.3, sz: 2, color: '#ffd23a', material: 'neon', name: 'Финиш', canCollide: false }], scripts: [{ attachTo: 'on-target', code: `// Финиш: касание → экран победы. let done = false; game.self.onTouch(() => { if (done) return; done = true; game.ui.set('win', '🏆 ПОБЕДА!', { x: 50, y: 42, anchor: 'center', color: '#ffd23a', size: 48 }); game.ui.set('winsub', 'Ты дошёл до финиша!', { x: 50, y: 54, anchor: 'center', color: '#fff', size: 22 }); game.player.setInputBlocked(true); });` }], }, { id: 'sound-tile', name: 'Звуковая плитка', desc: 'Наступи на плитку — играет звук. Из таких можно собрать мелодию. (Вики: «Эхо-комната»)', icon: 'sound', category: 'fx', prims: [{ type: 'cube', x: 0, y: 0.1, z: 0, sx: 2, sy: 0.2, sz: 2, color: '#6f8bff', material: 'neon', name: 'Звуковая плитка' }], scripts: [{ attachTo: 'on-target', code: `// Плитка играет звук при касании + подсвечивается. let cd = false; game.self.onTouch(() => { if (cd) return; cd = true; game.sound.play('coin'); game.self.setColor('#ffffff'); game.after(0.25, () => { game.self.setColor('#6f8bff'); cd = false; }); });` }], }, // ===== Партия 3 из Вики (остальные механики) ===== // --- Мир --- { id: 'damage-zone', name: 'Зона опасности', desc: 'Невидимая зона: пока игрок внутри — теряет здоровье. (Вики: «Зона опасности»)', icon: 'warning', category: 'world', prims: [{ type: 'cube', x: 0, y: 2, z: 0, sx: 6, sy: 4, sz: 6, color: '#ff3344', material: 'glass', canCollide: false, name: 'Зона опасности' }], scripts: [{ attachTo: 'on-target', code: `// Зона урона: внутри — 10 HP/сек. let inside = false, t = null; game.self.onTouch(() => { if (inside) return; inside = true; const tick = () => { if (!inside) return; game.player.damage(10); game.ui.set('dz', '☠ Опасно! -10 HP', { x:50, y:78, anchor:'bottom', color:'#ff5555', size:18 }); t = game.after(1, tick); }; tick(); }); game.self.onUntouch(() => { inside = false; if (t) game.cancel(t); game.ui.set('dz',''); });` }], }, { id: 'spikes-trap', name: 'Шипы-ловушка', desc: 'Ряд острых шипов: наступишь — мгновенный урон. (Вики: «Полоса препятствий»)', icon: 'warning', category: 'world', prims: [ { type: 'cube', x: 0, y: 0.1, z: 0, sx: 4, sy: 0.2, sz: 1.5, color: '#555', material: 'metal', name: 'Основание шипов' }, { type: 'cone', x: -1.2, y: 0.7, z: 0, sx: 0.6, sy: 1.2, sz: 0.6, color: '#cccccc', material: 'metal', canCollide: false, name: 'Шип 1' }, { type: 'cone', x: 0, y: 0.7, z: 0, sx: 0.6, sy: 1.2, sz: 0.6, color: '#cccccc', material: 'metal', canCollide: false, name: 'Шип 2' }, { type: 'cone', x: 1.2, y: 0.7, z: 0, sx: 0.6, sy: 1.2, sz: 0.6, color: '#cccccc', material: 'metal', canCollide: false, name: 'Шип 3' }, ], scripts: [{ attachTo: 'on-target', code: `// Шипы: касание → урон + отброс. let cd = false; game.self.onTouch(() => { if (cd) return; cd = true; game.player.damage(34); game.ui.set('sp', '🗡 Ой! -34 HP', { x:50, y:78, anchor:'bottom', color:'#ff5555', size:18 }); game.after(1.2, () => { game.ui.set('sp',''); cd = false; }); });` }], }, { id: 'traffic-light', name: 'Светофор', desc: 'Светофор переключает красный/жёлтый/зелёный по таймеру. (Вики: «Светофор»)', icon: 'light', category: 'world', prims: [ { type: 'cube', x: 0, y: 3, z: 0, sx: 1.2, sy: 4, sz: 1.2, color: '#2a2a2a', material: 'matte', name: 'Корпус светофора' }, { type: 'sphere', x: 0, y: 4.2, z: 0.6, sx: 0.8, sy: 0.8, sz: 0.4, color: '#5a0000', material: 'neon', canCollide: false, name: 'Красный' }, { type: 'sphere', x: 0, y: 3.3, z: 0.6, sx: 0.8, sy: 0.8, sz: 0.4, color: '#5a5a00', material: 'neon', canCollide: false, name: 'Жёлтый' }, { type: 'sphere', x: 0, y: 2.4, z: 0.6, sx: 0.8, sy: 0.8, sz: 0.4, color: '#005a00', material: 'neon', canCollide: false, name: 'Зелёный' }, ], scripts: [{ attachTo: 'on-target', code: `// Светофор: переключение фаз каждые 2 сек. const R = game.scene.findOne('Красный'), Y = game.scene.findOne('Жёлтый'), G = game.scene.findOne('Зелёный'); const phases = [['#ff0000','#5a5a00','#005a00'], ['#5a0000','#ffcc00','#005a00'], ['#5a0000','#5a5a00','#00ff00']]; let p = 0; function show(){ const c = phases[p]; if(R) R.color = c[0]; if(Y) Y.color = c[1]; if(G) G.color = c[2]; } show(); game.every(2, () => { p = (p+1) % phases.length; show(); });` }], }, { id: 'harvest-plant', name: 'Грядка с урожаем', desc: 'Растение растёт, по клику собираешь урожай (+10 монет) и оно вырастает заново. (Вики: «Сбор урожая»)', icon: 'plant', category: 'world', prims: [ { type: 'cube', x: 0, y: 0.2, z: 0, sx: 2, sy: 0.4, sz: 2, color: '#6b4a2e', material: 'matte', name: 'Грядка' }, { type: 'sphere', x: 0, y: 0.8, z: 0, sx: 1, sy: 1, sz: 1, color: '#3f9a48', material: 'matte', name: 'Урожай' }, ], scripts: [{ attachTo: 'on-target', code: `// Грядка: урожай растёт, по взаимодействию — сбор +10 монет. const plant = game.scene.findOne('Урожай'); let ripe = true; game.self.onInteract(() => { if (!ripe) { game.ui.set('h','Ещё не созрело...', {x:50,y:80,anchor:'bottom',color:'#bbb',size:16}); return; } ripe = false; game.broadcast('coins', { add: 10 }); if (plant) plant.visible = false; game.ui.set('h','🌾 Собрано! +10 монет', {x:50,y:80,anchor:'bottom',color:'#ffd23a',size:18}); game.after(3, () => { if (plant) plant.visible = true; ripe = true; game.ui.set('h',''); }); }, { text: 'Собрать урожай', key: 'e', distance: 4 });` }], }, { id: 'falling-objects', name: 'Падающие предметы', desc: 'Из этой точки с неба сыплются кубики — лови их или уворачивайся. (Вики: «Поймай падающее»)', icon: 'box', category: 'world', prims: [{ type: 'sphere', x: 0, y: 8, z: 0, sx: 0.6, sy: 0.6, sz: 0.6, color: '#4d6bff', material: 'neon', canCollide: false, name: 'Тучка-источник' }], scripts: [{ attachTo: 'on-target', code: `// Каждые 1.5с роняет куб из позиции источника. const p = game.self.position; game.every(1.5, () => { game.scene.spawn('primitive:cube', { x: p.x + (Math.random()-0.5)*6, y: p.y, z: p.z + (Math.random()-0.5)*6, sx: 0.6, sy: 0.6, sz: 0.6, color: '#ffaa33', anchored: false, canCollide: true, lifetime: 6 }); });` }], }, // --- Интерфейс --- { id: 'score-counter', name: 'Счётчик очков', desc: 'Счёт очков в HUD. Другие механики шлют game.broadcast("score",{add:N}). (Вики: «Собери монетки»)', icon: 'star', category: 'ui', scripts: [{ attachTo: 'global', code: `// Счётчик очков. Прибавить: game.broadcast('score', { add: 1 }); let score = 0; function show(){ game.ui.set('score', '⭐ ' + score, { x:8, y:6, anchor:'top', color:'#ffd23a', size:22 }); } show(); game.onMessage('score', (m) => { score += (m && m.add) ? m.add : 1; show(); });` }], }, { id: 'hp-bar', name: 'Полоска здоровья', desc: 'Показывает HP игрока в углу экрана, обновляется при уроне/лечении. (Вики: «Лава-пол»)', icon: 'warning', category: 'ui', scripts: [{ attachTo: 'global', code: `// HP-индикатор игрока в HUD. function show(){ const hp = Math.max(0, Math.round(game.player.hp)); game.ui.set('hp', '❤ ' + hp, { x:8, y:12, anchor:'top', color: hp>30?'#36d57a':'#ff4444', size:22 }); } show(); game.every(0.3, show);` }], }, { id: 'code-door', name: 'Дверь по коду', desc: 'Поле ввода: введи правильный код (1234) — дверь открывается. (Вики: «Дверь по коду»)', icon: 'keypad', category: 'ui', prims: [{ type: 'cube', x: 0, y: 2, z: 0, sx: 0.5, sy: 4, sz: 3, color: '#5a6478', material: 'metal', name: 'Дверь-код' }], scripts: [{ attachTo: 'on-target', code: `// Дверь по коду 1234. Поле ввода снизу. const CODE = '1234'; const inp = game.gui.create('textbox', { id:'codein', x:50, y:88, w:24, h:8, anchor:'center', placeholder:'Код...', textSize:20 }); game.ui.set('codehint', 'Введи код двери (1234) и нажми Enter', {x:50,y:80,anchor:'bottom',color:'#fff',size:16}); let opened = false; const p0 = game.self.position; game.gui.onSubmit('codein', (text) => { if (opened) return; if (String(text).trim() === CODE) { opened = true; game.self.move(p0.x, p0.y-4.2, p0.z); game.ui.set('codehint', '✓ Открыто!', {x:50,y:80,anchor:'bottom',color:'#36d57a',size:18}); } else game.ui.set('codehint', '✗ Неверный код', {x:50,y:80,anchor:'bottom',color:'#ff5555',size:18}); });` }], }, { id: 'name-label', name: 'Метка с именем', desc: 'Над объектом висит табличка с текстом (имя/HP). (Вики: «Имена над врагами»)', icon: 'tag', category: 'ui', prims: [{ type: 'cube', x: 0, y: 1, z: 0, sx: 1.5, sy: 2, sz: 1.5, color: '#c83030', material: 'matte', name: 'Объект с меткой' }], scripts: [{ attachTo: 'on-target', code: `// Метка-табличка над объектом. game.self.setLabel('Враг ❤ 100', { color: '#ffffff', bg: '#c83030' });` }], }, { id: 'countdown', name: 'Обратный отсчёт', desc: 'Таймер обратного отсчёта в HUD. По нулю — событие (тут просто сообщение). (Вики: «продержись N секунд»)', icon: 'clock', category: 'ui', scripts: [{ attachTo: 'global', code: `// Обратный отсчёт 30 секунд. let left = 30; game.ui.set('cd', '⏳ ' + left, { x:50, y:6, anchor:'top', color:'#fff', size:26 }); const id = game.every(1, () => { left--; game.ui.set('cd', '⏳ ' + left, { x:50, y:6, anchor:'top', color: left<=5?'#ff4444':'#fff', size:26 }); if (left <= 0) { game.cancel(id); game.ui.set('cd', '⏰ Время вышло!', { x:50, y:42, anchor:'center', color:'#ff4444', size:40 }); } });` }], }, // --- Эффекты --- { id: 'fire-emitter', name: 'Костёр (огонь)', desc: 'Источник частиц огня — горит постоянно. (Палитра эффектов)', icon: 'sparkles', category: 'fx', prims: [{ type: 'cylinder', x: 0, y: 0.2, z: 0, sx: 1.2, sy: 0.4, sz: 1.2, color: '#3a2a1a', material: 'matte', name: 'Костёр' }], scripts: [{ attachTo: 'on-target', code: `// Постоянный огонь из точки костра. const p = game.self.position; function fire(){ game.scene.spawnParticles('fire', { x:p.x, y:p.y+0.5, z:p.z }, { duration: 1.5, count: 40 }); } fire(); game.every(1.2, fire);` }], }, { id: 'magnet-coins', name: 'Магнит монет', desc: 'Монета сама летит к игроку, когда он подходит близко. (Вики: «Магнит монет»)', icon: 'circle', category: 'fx', prims: [{ type: 'cylinder', x: 0, y: 1, z: 0, sx: 0.8, sy: 0.2, sz: 0.8, color: '#ffd23a', material: 'metal', name: 'Монета' }], scripts: [{ attachTo: 'on-target', code: `// Монета летит к игроку, если он ближе 6 м; коснулся — +1 монета. let taken = false; game.onTick(() => { if (taken) return; const me = game.self.position, pl = game.player.position; const dx = pl.x-me.x, dy = pl.y-me.y, dz = pl.z-me.z; const d = Math.sqrt(dx*dx+dy*dy+dz*dz); if (d < 1.2) { taken = true; game.broadcast('coins', { add: 1 }); game.self.setVisible(false); return; } if (d < 6) { game.self.move(me.x+dx*0.12, me.y+dy*0.12, me.z+dz*0.12); } });` }], }, // --- NPC и бой --- { id: 'npc-chaser', name: 'NPC-преследователь', desc: 'Враг бежит за игроком по всему уровню. (Вики: «Преследователь»)', icon: 'chase', category: 'npc', scripts: [{ attachTo: 'global', code: `// Спавним NPC, который преследует игрока. const enemy = game.scene.spawnNpc('robot', { x: 8, z: 8, name: 'Охотник', speed: 4 }); if (enemy && enemy.follow) enemy.follow('player');` }], }, { id: 'npc-trader', name: 'Торговец (диалог)', desc: 'Фигура торговца: подойди, нажми E — открывается диалог. (Вики: «Торговец»)', icon: 'trader', category: 'npc', prims: [ { type: 'cylinder', x: 0, y: 1, z: 0, sx: 1.2, sy: 2, sz: 1.2, color: '#3a6ea5', material: 'matte', name: 'Торговец' }, { type: 'sphere', x: 0, y: 2.3, z: 0, sx: 0.9, sy: 0.9, sz: 0.9, color: '#e8c8a0', material: 'matte', canCollide: false, name: 'Голова торговца' }, ], scripts: [{ attachTo: 'on-target', code: `// Торговец с диалогом по E. game.self.setLabel('Торговец Боб', { color:'#fff', bg:'#3a6ea5' }); game.self.onInteract(() => { game.modal.dialog('Торговец Боб', [ 'Привет, путник! Заходи за товарами.', 'У меня лучшие мечи во всём королевстве!', 'Возвращайся, когда накопишь монет.', ]); }, { text: 'Поговорить', key: 'e', distance: 4 });` }], }, { id: 'shooting-target', name: 'Мишень для стрельбы', desc: 'Кликни по мишени — +10 очков, мишень исчезает и появляется снова. (Вики: «Тир»)', icon: 'crosshair', category: 'npc', prims: [ { type: 'cylinder', x: 0, y: 2, z: 0, sx: 0.3, sy: 2, sz: 2, color: '#ffffff', material: 'matte', name: 'Мишень' }, { type: 'cylinder', x: 0.2, y: 2, z: 0, sx: 0.1, sy: 1.2, sz: 1.2, color: '#ff3333', material: 'matte', canCollide: false, name: 'Кольцо мишени' }, ], scripts: [{ attachTo: 'on-target', code: `// Мишень: клик → +10 очков, прячется на 1.5с. let active = true; game.self.onClick(() => { if (!active) return; active = false; game.broadcast('score', { add: 10 }); game.self.setVisible(false); game.ui.set('hit', '🎯 +10!', { x:50, y:75, anchor:'bottom', color:'#36d57a', size:20 }); game.after(1.5, () => { game.self.setVisible(true); active = true; game.ui.set('hit',''); }); });` }], }, { id: 'enemy-hp', name: 'Враг с HP', desc: 'Враг с полоской здоровья над головой. Кликай — урон, при нуле HP погибает. (Вики: «Имена над врагами», «босс»)', icon: 'boss', category: 'npc', prims: [{ type: 'cube', x: 0, y: 1.5, z: 0, sx: 1.5, sy: 3, sz: 1.5, color: '#7a2030', material: 'matte', name: 'Враг' }], scripts: [{ attachTo: 'on-target', code: `// Враг с HP: клик → -20 HP, метка обновляется, при 0 — исчезает. let hp = 100; function lbl(){ game.self.setLabel('Враг ❤ ' + hp, { color:'#fff', bg:'#7a2030' }); } lbl(); game.self.onClick(() => { hp -= 20; if (hp <= 0) { game.self.setVisible(false); game.broadcast('score', { add: 50 }); game.ui.set('kill', '💀 Враг повержен! +50', { x:50, y:75, anchor:'bottom', color:'#ffd23a', size:18 }); return; } lbl(); });` }], }, { id: 'enemy-wave', name: 'Волна врагов', desc: 'Спавнер: выпускает врагов волнами по таймеру. (Вики: «Выживание от волн», tower defense)', icon: 'zombie', category: 'npc', prims: [{ type: 'cylinder', x: 0, y: 0.15, z: 0, sx: 2, sy: 0.3, sz: 2, color: '#7a2030', material: 'neon', name: 'Портал врагов' }], scripts: [{ attachTo: 'on-target', code: `// Каждые 5с спавнит 2 врагов из точки портала, они идут к игроку. const p = game.self.position; function wave(){ for (let i=0;i<2;i++){ const e = game.scene.spawnNpc('robot', { x:p.x+(Math.random()-0.5)*2, z:p.z+(Math.random()-0.5)*2, name:'Враг', speed:3 }); if (e && e.follow) e.follow('player'); } } game.after(2, wave); game.every(5, wave);` }], }, // --- Экономика --- { id: 'shop-button', name: 'Магазин (кнопка покупки)', desc: 'GUI-кнопка магазина: покупка предмета за 50 монет (если хватает). (Вики: «Магазин»)', icon: 'cart', category: 'economy', scripts: [{ attachTo: 'global', code: `// Кнопка магазина: купить за 50 монет. let coins = 100; // локальный баланс кита (для демо) game.ui.set('bal', '🪙 ' + coins, { x:92, y:6, anchor:'top', color:'#ffd23a', size:22 }); game.onMessage('coins', (m) => { coins += (m&&m.add)?m.add:0; game.ui.set('bal','🪙 '+coins,{x:92,y:6,anchor:'top',color:'#ffd23a',size:22}); }); game.gui.create('button', { id:'buybtn', x:50, y:90, w:26, h:9, anchor:'center', text:'Купить меч — 50 🪙', bgGradient:{ stops:['#ffe066','#e0a000'], angle:90 }, textColor:'#3a2a00', textSize:18, fontWeight:800, borderRadius:12 }); game.gui.onClick('buybtn', () => { if (coins >= 50) { game.broadcast('coins', { add: -50 }); game.ui.set('shopmsg','✓ Куплено!',{x:50,y:80,anchor:'bottom',color:'#36d57a',size:18}); } else game.ui.set('shopmsg','✗ Мало монет',{x:50,y:80,anchor:'bottom',color:'#ff5555',size:18}); game.after(2, () => game.ui.set('shopmsg','')); });` }], }, { id: 'clicker-button', name: 'Кликер', desc: 'GUI-кнопка: кликай и копи очки. (Вики: «Кликер»)', icon: 'click', category: 'economy', scripts: [{ attachTo: 'global', code: `// Кликер: кнопка по центру, клик → +1 очко. let n = 0; function show(){ game.ui.set('clk', '👆 ' + n, { x:50, y:20, anchor:'center', color:'#fff', size:36 }); } show(); game.gui.create('button', { id:'clickbtn', x:50, y:55, w:30, h:14, anchor:'center', text:'КЛИК!', bgGradient:{ stops:['#6f8bff','#3a4ed0'], angle:90 }, textColor:'#fff', textSize:32, fontWeight:900, borderRadius:18, hover:{ scale:1.05 }, active:{ scale:0.94 } }); game.gui.onClick('clickbtn', () => { n++; show(); });` }], }, { id: 'key-lock', name: 'Ключ и замок', desc: 'Подбери ключ, затем открой запертую дверь. Без ключа дверь не открывается. (Вики: «Ключ и сундук»)', icon: 'key', category: 'economy', prims: [ { type: 'cube', x: 0, y: 1, z: 0, sx: 0.4, sy: 0.8, sz: 0.4, color: '#ffd23a', material: 'metal', name: 'Ключ' }, { type: 'cube', x: 6, y: 2, z: 0, sx: 0.5, sy: 4, sz: 3, color: '#6b4423', material: 'matte', name: 'Запертая дверь' }, ], scripts: [{ attachTo: 'on-target', code: `// Ключ (этот объект) подбирается касанием → открывает «Запертую дверь». let hasKey = false; game.self.onTouch(() => { if (hasKey) return; hasKey = true; game.self.setVisible(false); game.ui.set('key', '🔑 Ключ найден! Иди к двери (E).', {x:50,y:80,anchor:'bottom',color:'#ffd23a',size:18}); }); const door = game.scene.findOne('Запертая дверь'); if (door && door.onInteract) { let opened = false; door.onInteract(() => { if (!hasKey) { game.ui.set('key','🔒 Заперто. Нужен ключ.',{x:50,y:80,anchor:'bottom',color:'#ff5555',size:18}); return; } if (opened) return; opened = true; const dp = door.position; door.move(dp.x, dp.y-4.2, dp.z); game.ui.set('key','✓ Дверь открыта!',{x:50,y:80,anchor:'bottom',color:'#36d57a',size:18}); }, { text:'Открыть дверь', key:'e', distance:4 }); }` }], }, // --- Из готовых игр (g5) --- { id: 'spawn-car', name: 'Машина (сядь за руль)', desc: 'Готовый автомобиль: подойди, держи F — садись за руль, WASD — едь. (Вики: «Такси-симулятор»)', icon: 'car', category: 'npc', scripts: [{ attachTo: 'global', code: `// Спавн машины, на которой можно ездить. game.scene.spawn('vehicle:car', { x: 0, y: 0.5, z: 5, model: 'car-sedan', color: '#c83030', name: 'Авто', params: { maxSpeed: 18, turnSpeed: 1.7, enginePower: 20 } }); game.ui.set('carhint', 'Подойди к машине и держи F — за руль!', {x:50,y:90,anchor:'bottom',color:'#fff',size:16});` }], }, { id: 'cutscene-dialog', name: 'Диалог (кат-сцена)', desc: 'Объект, по взаимодействию показывает диалог по строкам. (Вики: «Тайна старого сундука»)', icon: 'scroll', category: 'npc', prims: [{ type: 'cube', x: 0, y: 0.6, z: 0, sx: 1.4, sy: 1.2, sz: 1, color: '#8a6a3a', material: 'matte', name: 'Рассказчик' }], scripts: [{ attachTo: 'on-target', code: `// Диалог по строкам через готовый game.modal.dialog. const lines = ['Давным-давно здесь стоял замок...', 'Его охранял древний страж.', 'Найди три ключа, чтобы войти!']; game.self.onInteract(() => { game.modal.dialog('Рассказчик', lines); }, { text:'Поговорить', key:'e', distance:4 });` }], }, { id: 'guide-arrow', name: '3D-стрелка-указатель', desc: 'Стрелка-подсказка «иди сюда» ведёт игрока к цели. (Вики: «Туториал — собери монетки»)', icon: 'flag', category: 'ui', prims: [{ type: 'cylinder', x: 0, y: 1, z: 0, sx: 1, sy: 2, sz: 1, color: '#ffd23a', material: 'neon', name: 'Цель-указатель' }], scripts: [{ attachTo: 'on-target', code: `// Стрелка от игрока к этому объекту-цели. const arrow = game.fx.pointer({ from: 'player', to: game.self, preset: 'guide' }); game.self.onTouch(() => { if (arrow && arrow.remove) arrow.remove(); game.ui.set('arr','✓ Дошёл!', {x:50,y:80,anchor:'bottom',color:'#36d57a',size:18}); });` }], }, ]; /** Найти кит по id. */ export function getKit(id) { return GAMEPLAY_KITS.find(k => k.id === id) || null; }