studio/src/editor/engine/GameplayKits.js
min ba90bf5c7d feat(studio): кит «Таблица лидеров» в Toolbox → Готовые механики
Новый кит (категория ui): определяет лидерборд (Очки primary + Время),
время идёт само, очки растут от broadcast('score'|'coins'). Сохраняется в БД.
Работает вместе со счётчиком монет/очков. Всего 47 китов.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 10:11:18 +03:00

954 lines
56 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: 'Эффекты' },
{ 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:
`// Грядка: собрал урожай → он исчезает, растёт заново (цвет красный→зелёный),
// при полном размере снова созрел и его можно собрать.
const plant = game.scene.findOne('Урожай');
let ripe = true; // созрел ли (можно собирать)
let growth = 1; // 0..1 — степень роста
// Плавная интерполяция цвета красный(незрелый)→зелёный(спелый).
function colorAt(g) {
const r = Math.round(0xb0 + (0x3f - 0xb0) * g);
const gr = Math.round(0x40 + (0x9a - 0x40) * g);
const b = Math.round(0x2e + (0x48 - 0x2e) * g);
return '#' + [r,gr,b].map(v => v.toString(16).padStart(2,'0')).join('');
}
game.self.onInteract(() => {
if (!ripe) { game.ui.set('h','Ещё не созрело...', {x:50,y:80,anchor:'bottom',color:'#bbb',size:16}); return; }
ripe = false;
growth = 0;
game.broadcast('coins', { add: 10 });
if (plant) { plant.scale = 0.01; plant.color = colorAt(0); }
game.ui.set('h','🌾 Собрано! +10 монет', {x:50,y:80,anchor:'bottom',color:'#ffd23a',size:18});
game.after(2, () => game.ui.set('h',''));
}, { text: 'Собрать урожай', key: 'e', distance: 4 });
// Рост: за ~5 секунд от 0 до 1.
game.onTick((dt) => {
if (ripe || !plant) return;
growth = Math.min(1, growth + dt / 5);
plant.scale = Math.max(0.05, growth);
plant.color = colorAt(growth);
if (growth >= 1) ripe = true;
});` }],
},
{
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: 'leaderboard',
name: 'Таблица лидеров',
desc: 'Лидерборд справа-сверху (Очки/Время). Растёт от монет и очков других механик. Сохраняется в БД между сессиями. (Задача 20)',
icon: 'trophy', category: 'ui',
scripts: [{ attachTo: 'global', code:
`// Таблица лидеров: столбцы «Очки» (primary) и «Время».
game.leaderstats.define('Очки', { initial: 0, format: 'number', icon: '⭐', color: '#ffd23a', primary: true });
game.leaderstats.define('Время', { initial: 0, format: 'time', icon: '⏱', color: '#7fd0ff' });
// Время идёт само.
game.every(1, () => game.leaderstats.me.add('Время', 1));
// Любая механика, шлющая broadcast('score',{add}) или ('coins',{add}),
// автоматически добавляет очки в таблицу.
game.onMessage('score', (m) => game.leaderstats.me.add('Очки', (m && m.add) ? m.add : 1));
game.onMessage('coins', (m) => game.leaderstats.me.add('Очки', (m && m.add) ? m.add : 1));` }],
},
{
id: 'hp-bar',
name: 'Полоска здоровья',
desc: 'Показывает HP игрока в углу экрана, обновляется при уроне/лечении. (Вики: «Лава-пол»)',
icon: 'warning', category: 'ui',
scripts: [{ attachTo: 'global', code:
`// Своя полоска HP. Сначала прячем стандартную, чтобы не дублировалась.
game.hud.setHpVisible(false);
function show(){ const hp = Math.max(0, Math.round(game.player.hp));
game.ui.set('hp', '❤ ' + hp + ' / 100', { x:50, y:94, anchor:'bottom', color: hp>30?'#36d57a':'#ff4444', size:22 }); }
show();
game.every(0.2, show);` }],
},
{
id: 'hide-default-hp',
name: 'Скрыть стандартный HUD HP',
desc: 'Прячет стандартную полосу здоровья сверху — чтобы показать свою. (Свойство игрока game.hud.setHpVisible)',
icon: 'warning', category: 'ui',
scripts: [{ attachTo: 'global', code:
`// Скрыть стандартную полосу здоровья игрока.
game.hud.setHpVisible(false);` }],
},
{
id: 'code-door',
name: 'Дверь по коду',
desc: 'Красивая дверь с кодовой панелью: подойди — появится поле ввода, введи код (1234) — откроется. (Вики: «Дверь по коду»)',
icon: 'keypad', category: 'world',
// Красивая дверь (полотно + рамка) + кодовая панель на стене.
prims: [
{ type: 'cube', x: 0, y: 2, z: 0, sx: 0.25, sy: 3.8, sz: 2.6, color: '#5a6478', material: 'metal', name: 'Полотно двери-код' },
{ type: 'cube', x: 0.16, y: 2.6, z: 0, sx: 0.06, sy: 0.9, sz: 1.8, color: '#79879a', material: 'metal', canCollide: false, name: 'Панель двери' },
{ type: 'cube', x: 0.16, y: 1.3, z: 0, sx: 0.06, sy: 0.9, sz: 1.8, color: '#79879a', material: 'metal', canCollide: false, name: 'Панель двери низ' },
{ type: 'cube', x: 0.3, y: 2, z: 0.95, sx: 0.15, sy: 0.6, sz: 0.5, color: '#ffd23a', material: 'neon', canCollide: false, name: 'Кодовая панель' },
{ type: 'cube', x: 0, y: 2, z: -1.55, sx: 0.4, sy: 4.2, sz: 0.4, color: '#3a4250', material: 'metal', name: 'Косяк левый' },
{ type: 'cube', x: 0, y: 2, z: 1.55, sx: 0.4, sy: 4.2, sz: 0.4, color: '#3a4250', material: 'metal', name: 'Косяк правый' },
{ type: 'cube', x: 0, y: 4.1, z: 0, sx: 0.4, sy: 0.4, sz: 3.5, color: '#3a4250', material: 'metal', name: 'Перемычка' },
],
scripts: [{ attachTo: 'on-target', code:
`// Дверь по коду 1234. Поле ввода появляется ТОЛЬКО рядом. Верный код →
// дверь ПЛАВНО распахивается вокруг петли (вместе с панелями).
const CODE = '1234';
const p0 = game.self.position;
const halfW = 1.3; // полуширина полотна (sz=2.6)
const hingeX = p0.x, hingeZ = p0.z - halfW;
const RADIUS = 6;
let opened = false, near = false, 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 });
}
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(a){
const pc = rotY(0, halfW, a);
const cx = hingeX + pc.x, cz = hingeZ + pc.z;
game.self.move(cx, p0.y, cz); game.self.rotate(a);
for (const d of decor){ const r = rotY(d.dx, d.dz, a); d.obj.move(cx+r.x, p0.y+d.dy, cz+r.z); if (d.obj.rotate) d.obj.rotate(a); }
}
game.onTick((dt) => {
if (cur !== target){ const st=SPEED*dt; cur = Math.abs(target-cur)<=st ? target : cur+Math.sign(target-cur)*st; place(cur); }
const pl = game.player.position;
const d = Math.sqrt((pl.x-p0.x)**2 + (pl.z-p0.z)**2);
if (opened){
// Дверь открыта: подсказка «E закрыть» только когда игрок рядом.
if (d < RADIUS && !near){ near = true; game.ui.set('codehint','Нажми E чтобы закрыть дверь', {x:50,y:80,anchor:'bottom',color:'#fff',size:16}); }
else if (d >= RADIUS && near){ near = false; game.ui.set('codehint','',{}); }
return;
}
// Дверь закрыта: поле ввода кода по дистанции.
if (d < RADIUS && !near){ near = true;
game.gui.create('textbox', { id:'codein', x:50, y:86, w:24, h:8, anchor:'center', placeholder:'Код...', textSize:20 });
game.ui.set('codehint', '🔢 Введи код двери (1234) и нажми Enter', {x:50,y:78,anchor:'bottom',color:'#fff',size:16}); }
else if (d >= RADIUS && near){ near = false; game.gui.remove('codein'); game.ui.set('codehint','',{}); }
});
game.gui.onSubmit('codein', (text) => {
if (opened) return;
if (String(text).trim() === CODE){
opened = true; near = false; target = Math.PI/2; // плавно распахнуть
game.gui.remove('codein');
game.ui.set('codehint','✓ Открыто!', {x:50,y:78,anchor:'bottom',color:'#36d57a',size:18});
game.after(2, () => game.ui.set('codehint','',{}));
} else game.ui.set('codehint','✗ Неверный код', {x:50,y:78,anchor:'bottom',color:'#ff5555',size:18});
});
// Закрыть дверь по E (только если открыта и игрок рядом).
game.onKey('e', () => {
if (!opened) return;
const pl = game.player.position;
if (Math.sqrt((pl.x-p0.x)**2 + (pl.z-p0.z)**2) >= RADIUS) return;
opened = false; near = false; target = 0;
game.ui.set('codehint','🔒 Дверь закрыта.', {x:50,y:80,anchor:'bottom',color:'#fff',size:16});
game.after(1.5, () => game.ui.set('codehint','',{}));
});` }],
},
{
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('skin_roblox-noob', { x: 8, z: 8, name: 'Охотник', speed: 4 });
if (enemy && enemy.follow) enemy.follow('player');` }],
},
{
id: 'npc-trader',
name: 'Торговец (NPC)',
desc: 'NPC-персонаж торговец: подойди, нажми E — открывается диалог. (Вики: «Торговец»)',
icon: 'trader', category: 'npc',
// Невидимый prim-триггер держит onInteract; рядом спавнится NPC-персонаж.
prims: [{ type: 'cube', x: 0, y: 1.5, z: 0, sx: 2, sy: 3, sz: 2, color: '#3a6ea5', material: 'matte', visible: false, canCollide: false, name: 'Зона торговца' }],
scripts: [{ attachTo: 'on-target', code:
`// Торговец — настоящий NPC-персонаж. Триггер (этот объект) держит диалог по E.
const p = game.self.position;
const npc = game.scene.spawnNpc('skin_roblox-noob', { x: p.x, z: p.z, name: 'Торговец Боб' });
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: 'Враг-персонаж: преследует игрока, бьёт при касании. Над головой — полоска здоровья. (Вики: «босс», «имена над врагами»)',
icon: 'boss', category: 'npc',
// Невидимый триггер-якорь; рядом спавнится NPC-враг.
prims: [{ type: 'cube', x: 0, y: 1, z: 0, sx: 1, sy: 2, sz: 1, color: '#7a2030', material: 'matte', visible: false, canCollide: false, name: 'Якорь врага' }],
scripts: [{ attachTo: 'on-target', code:
`// Враг-персонаж: преследует игрока, бьёт с анимацией удара при сближении.
const p = game.self.position;
const enemy = game.scene.spawnNpc('skin_retro-zombie', { x: p.x, z: p.z, name: 'Враг', hp: 100, speed: 3.5 });
if (enemy && enemy.follow) enemy.follow('player');
let cd = 0, atk = false;
game.onTick((dt) => {
if (!enemy || !enemy.position) return;
cd -= dt;
const pl = game.player.position, e = enemy.position;
const d = Math.sqrt((pl.x-e.x)**2 + (pl.z-e.z)**2);
const inRange = d < 3.5;
if (inRange !== atk) { atk = inRange; enemy.setAttacking && enemy.setAttacking(inRange); }
if (inRange && cd <= 0) { game.player.damage(10); cd = 1; } // удар раз в секунду
});` }],
},
{
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;
const enemies = [];
function wave(){
for (let i=0;i<2;i++){ const e = game.scene.spawnNpc('skin_retro-zombie', { x:p.x+(Math.random()-0.5)*2, z:p.z+(Math.random()-0.5)*2, name:'Враг', hp:60, speed:3 });
if (e){ if (e.follow) e.follow('player'); enemies.push({ npc:e, cd:0 }); } }
}
game.after(2, wave); game.every(5, wave);
// Урон + анимация удара при сближении (у каждого врага свой кулдаун).
game.onTick((dt) => {
const pl = game.player.position;
for (const en of enemies){
if (!en.npc || !en.npc.position) continue;
en.cd -= dt;
const e = en.npc.position;
const d = Math.sqrt((pl.x-e.x)**2 + (pl.z-e.z)**2);
const inRange = d < 3.5;
if (inRange !== en.atk){ en.atk = inRange; en.npc.setAttacking && en.npc.setAttacking(inRange); }
if (inRange && en.cd <= 0){ game.player.damage(8); en.cd = 1; }
}
});` }],
},
// --- Экономика ---
{
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: 'Найди золотой ключ, подбери — и дверь рядом плавно откроется по E. Без ключа заперта. (Вики: «Ключ и сундук»)',
icon: 'key', category: 'economy',
prims: [
// Ключ из примитивов: стержень + бородка + кольцо (torus). ПЕРВЫЙ — скрипт на нём.
{ type: 'cylinder', x: 0, y: 1, z: 0, sx: 0.12, sy: 1.0, sz: 0.12, color: '#ffd23a', material: 'metal', name: 'Ключ' },
{ type: 'torus', x: 0, y: 1.6, z: 0, sx: 0.5, sy: 0.5, sz: 0.5, color: '#ffd23a', material: 'metal', canCollide: false, name: 'Кольцо ключа' },
{ type: 'cube', x: 0.18, y: 0.6, z: 0, sx: 0.3, sy: 0.12, sz: 0.12, color: '#ffd23a', material: 'metal', canCollide: false, name: 'Бородка ключа' },
{ type: 'cube', x: 0.18, y: 0.4, z: 0, sx: 0.2, sy: 0.12, sz: 0.12, color: '#ffd23a', material: 'metal', canCollide: false, name: 'Бородка ключа 2' },
// Красивая дверь (полотно + рамка) на расстоянии.
{ type: 'cube', x: 6, y: 2, z: 0, sx: 0.25, sy: 3.8, sz: 2.6, color: '#6b4423', material: 'matte', name: 'Запертая дверь' },
{ type: 'cube', x: 6, y: 2, z: -1.55, sx: 0.4, sy: 4.2, sz: 0.4, color: '#4a3018', material: 'matte', name: 'Косяк левый замка' },
{ type: 'cube', x: 6, y: 2, z: 1.55, sx: 0.4, sy: 4.2, sz: 0.4, color: '#4a3018', material: 'matte', name: 'Косяк правый замка' },
{ type: 'cube', x: 6, 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 ТОЛЬКО с ключом —
// плавный поворот вокруг петли (как дверь по кнопке E).
let hasKey = false;
const keyParts = ['Ключ','Кольцо ключа','Бородка ключа','Бородка ключа 2'];
game.self.onTouch(() => {
if (hasKey) return; hasKey = true;
for (const nm of keyParts){ const o = game.scene.findOne(nm); if (o) o.visible = 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){
const dp0 = door.position;
const halfW = 1.3, hingeZ = dp0.z - halfW;
let open = false, cur = 0, target = 0;
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}; }
game.onTick((dt) => {
if (cur===target) return;
const st = Math.PI*dt; cur = Math.abs(target-cur)<=st ? target : cur+Math.sign(target-cur)*st;
const pc = rotY(0, halfW, cur);
door.move(dp0.x+pc.x, dp0.y, hingeZ+pc.z); if (door.rotate) door.rotate(cur);
});
door.onInteract(() => {
if (!hasKey){ game.ui.set('key','🔒 Заперто. Нужен ключ.',{x:50,y:80,anchor:'bottom',color:'#ff5555',size:18}); return; }
open = !open; target = open ? Math.PI/2 : 0;
game.ui.set('key', open ? '✓ Дверь открыта!' : '🔒 Дверь закрыта.', {x:50,y:80,anchor:'bottom',color:'#36d57a',size:18});
game.after(2, () => game.ui.set('key','',{}));
}, { 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;
}