studio/src/editor/engine/GameplayKits.js
min 32cbb7bbe9 fix(studio): дверь поворачивается вокруг петли (как настоящая), а не отскакивает
Было: дверь сдвигалась вбок. Стало: вращение вокруг левой грани (петли) на
90°. Центр двери пересчитывается по дуге вокруг hinge (p0.z - halfW), плюс
self.rotate(angle) — дверь распахивается, как в реальности.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 08:51:36 +03:00

296 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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;
}