Было: дверь сдвигалась вбок. Стало: вращение вокруг левой грани (петли) на 90°. Центр двери пересчитывается по дуге вокруг hinge (p0.z - halfW), плюс self.rotate(angle) — дверь распахивается, как в реальности. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
296 lines
16 KiB
JavaScript
296 lines
16 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|