studio/src/editor/engine/snippets.js
min 34060c90c3
All checks were successful
CI / Lint (pull_request) Successful in 1m13s
CI / Build (pull_request) Successful in 2m3s
CI / Secret scan (pull_request) Successful in 2m35s
CI / PR size check (pull_request) Successful in 8s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
feat(12): внутриигровой Loading Screen (game.loading)
Программный экран загрузки для перехода между мирами:
- game.loading.show(opts) → хэндл (setProgress/setText/setCover/close/onSkip/onComplete)
- game.loading.transition(opts) → Promise (фейковый прогресс за duration)
- cover sceneSnapshot, прогресс-бар+процент, спиннер, кнопка Пропустить, логотип
- blockInput + пауза симуляции, fadeIn/Out; tick независим от paused
- настройки проекта «Экран загрузки» (логотип/акцент/дефолты) + 3 сниппета
- LoadingScreenOverlay.js (новый DOM-оверлей), worker namespace loading,
  cmd loading.* + _ensureLoadingScreen, serialize/load конфига в scene
- вики g5 #59 guide-taxi (карточка + урок), тест-игра «Такси-босс» id 2427

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 22:00:26 +03:00

514 lines
22 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.

/**
* Сниппеты для Monaco-редактора скриптов (Фаза 6.1.3).
*
* Каждый сниппет — короткое имя (триггер) + готовый код с табстопами
* (`${1:placeholder}`, `${2:...}`), который раскрывается по Tab.
*
* Регистрируется один раз через monaco.languages.registerCompletionItemProvider
* для языка 'javascript' в ScriptEditor.jsx.
*
* Цель Фазы 6.1: новый юзер пишет первый скрипт и видит готовые шаблоны
* на типичные задачи, не изобретая велосипед.
*/
export const SCRIPT_SNIPPETS = [
{
label: 'on touch',
detail: 'Игрок коснулся объекта (для скрипта на блоке/примитиве/модели)',
body: [
"game.self.onTouch(() => {",
"\t${1:// твой код когда игрок коснулся}",
"\tgame.ui.showText('Привет!', 2);",
"});",
],
},
{
label: 'on click',
detail: 'Игрок кликнул по объекту (луч от прицела)',
body: [
"game.self.onClick(() => {",
"\t${1:// действие на клик}",
"});",
],
},
{
label: 'on key',
detail: 'Нажатие клавиши',
body: [
"game.onKey('${1:e}', () => {",
"\t${2:// действие на клавишу}",
"});",
],
},
{
label: 'on tick',
detail: 'Каждый кадр (для постоянной логики)',
body: [
"game.onTick((dt) => {",
"\t${1:// логика каждый кадр; dt — секунды с прошлого кадра}",
"});",
],
},
{
label: 'tween',
detail: 'Плавный переход свойств (движение, поворот, цвет)',
body: [
"game.tween(${1:ref}, { ${2:y: 5} }, {",
"\tduration: ${3:1},",
"\teasing: '${4|ease,linear,bounce,elastic,back|}',",
"\tonDone: () => { ${5:// после завершения} },",
"});",
],
},
{
label: 'after',
detail: 'Выполнить через N секунд',
body: [
"game.after(${1:3}, () => {",
"\t${2:game.ui.showText('Прошло 3 секунды', 2);}",
"});",
],
},
{
label: 'every',
detail: 'Повторять каждые N секунд',
body: [
"const ${1:timerId} = game.every(${2:1}, () => {",
"\t${3:// тик}",
"});",
"// game.cancel(${1:timerId}); — остановить",
],
},
{
label: 'door',
detail: 'Дверь, открывающаяся по нажатию E',
body: [
"// Скрипт привязать к двери. Клавиша E открывает.",
"let isOpen = false;",
"game.self.onInteract(() => {",
"\tconst targetY = isOpen ? 0 : ${1:Math.PI / 2};",
"\tgame.tween(game.self.ref, { rotationY: targetY }, { duration: 0.5, easing: 'ease' });",
"\tisOpen = !isOpen;",
"}, { text: isOpen ? 'Закрыть' : 'Открыть', distance: 4, key: 'e' });",
],
},
{
label: 'chest',
detail: 'Сундук с наградой',
body: [
"let opened = false;",
"game.self.onInteract(() => {",
"\tif (opened) return;",
"\topened = true;",
"\tgame.ui.showText('Ты получил ${1:сокровище}!', 2);",
"\tgame.sound.play('coin');",
"\tgame.economy.reward('${2:found_chest_1}');",
"}, { text: 'Открыть сундук', key: 'e' });",
],
},
{
label: 'coin',
detail: 'Монета — собирается и исчезает при касании',
body: [
"game.self.onTouch(() => {",
"\tgame.sound.play('coin');",
"\tgame.ui.score = (game.ui.score || 0) + ${1:1};",
"\tgame.self.delete();",
"});",
],
},
{
label: 'spike',
detail: 'Шип — наносит урон при касании',
body: [
"game.self.onTouch(() => {",
"\tgame.player.damage(${1:25});",
"});",
],
},
{
label: 'portal',
detail: 'Телепорт игрока в точку',
body: [
"game.self.onTouch(() => {",
"\tgame.player.teleport(${1:0}, ${2:5}, ${3:0});",
"\tgame.sound.play('pickup');",
"});",
],
},
{
label: 'npc',
detail: 'Дружественный NPC с патрулём',
body: [
"const ${1:trader} = game.scene.spawnNpc('${2:character-a}', {",
"\tx: ${3:0}, y: ${4:1}, z: ${5:5},",
"\tname: '${6:Торговец}',",
"});",
"${1:trader}.say('Привет, путник!', 3);",
"// каждые 5 секунд идёт в случайную точку",
"game.every(5, () => {",
"\tconst x = game.random(-10, 10);",
"\tconst z = game.random(-10, 10);",
"\t${1:trader}.moveTo(x, z);",
"});",
],
},
{
label: 'enemy',
detail: 'Враг с патрулём вокруг точки',
body: [
"const ${1:enemy} = game.scene.spawnNpc('${2:zombie}', {",
"\tx: ${3:0}, y: ${4:1}, z: ${5:0},",
"\thp: 50,",
"});",
"${1:enemy}.follow('player');",
"${1:enemy}.onDeath(() => {",
"\tgame.ui.showText('Враг повержен!', 1.5);",
"\tgame.ui.score = (game.ui.score || 0) + 10;",
"});",
],
},
{
label: 'quest',
detail: 'Простой квест с шагами',
body: [
"let step = 1;",
"function showQuest() {",
"\tconst steps = {",
"\t\t1: 'Найди ${1:сундук}',",
"\t\t2: 'Принеси ${2:торговцу}',",
"\t\t3: 'Готово!',",
"\t};",
"\tgame.ui.set('quest', `Квест: ${steps[step] || ''}`, { x: 50, y: 10, color: '#ffd700' });",
"}",
"showQuest();",
"// продвинуть шаг — game.broadcast('quest_step', { step: 2 });",
"game.onMessage('quest_step', (data) => {",
"\tstep = data.step;",
"\tshowQuest();",
"});",
],
},
{
label: 'save',
detail: 'Сохранить прогресс игрока',
body: [
"// Прочитать сохранение",
"game.save.get('progress', (data) => {",
"\tconst level = (data && data.level) || 1;",
"\tgame.ui.showText(`Уровень: ${level}`, 2);",
"});",
"// Записать (после успеха)",
"game.save.merge('progress', {",
"\tincrement: { level: 1, coins: ${1:10} },",
"\tmax: { bestTime: ${2:47.3} },",
"});",
],
},
{
label: 'spawn',
detail: 'Создать объект на сцене',
body: [
"const ${1:obj} = game.scene.spawn('primitive:${2|cube,sphere,cylinder,cone|}', {",
"\tx: ${3:0}, y: ${4:5}, z: ${5:0},",
"\tcolor: '${6:#ff0000}',",
"\tname: '${7:MyObject}',",
"});",
],
},
// === UI-шаблоны (Phase 6.3.5) ===
{
label: 'ui-mainmenu',
detail: 'Главное меню: заголовок + 3 кнопки по центру',
body: [
"// Главное меню (Phase 6.3 шаблон)",
"const menu = game.gui.create('frame', {",
"\tname: 'MainMenu',",
"\tx: 50, y: 50, w: 30, h: 50,",
"\tanchor: 'center', anchorPoint: { x: 0.5, y: 0.5 },",
"\tbgColor: '#1a1a2e', bgOpacity: 0.9,",
"\tborderColor: '#4f74ff', borderWidth: 2, borderRadius: 12,",
"\tlayout: 'vertical', layoutGap: 4, layoutPad: 5,",
"});",
"game.gui.create('text', {",
"\tparentId: menu, text: '${1:Игра}',",
"\ttextColor: '#ffd166', textSize: 28, fontWeight: 800, h: 15,",
"});",
"const playBtn = game.gui.create('button', { parentId: menu, text: 'Играть', h: 12 });",
"const settBtn = game.gui.create('button', { parentId: menu, text: 'Настройки', h: 12 });",
"const exitBtn = game.gui.create('button', { parentId: menu, text: 'Выйти', h: 12 });",
"game.gui.onClick(playBtn, () => { game.gui.hide(menu); });",
],
},
{
label: 'ui-hp-bar',
detail: 'HP-бар + патроны в углу (HUD)',
body: [
"// HP-бар (Phase 6.3 шаблон)",
"const hpBg = game.gui.create('frame', {",
"\tname: 'HpBg', x: 2, y: 2, w: 22, h: 4, anchor: 'top-left',",
"\tbgColor: '#222', bgOpacity: 0.8, borderRadius: 6,",
"});",
"const hpFill = game.gui.create('frame', {",
"\tparentId: hpBg, x: 0, y: 0, w: 100, h: 100, anchor: 'top-left',",
"\tanchorPoint: { x: 0, y: 0 },",
"\tbgColor: '#22d97a', borderRadius: 6,",
"});",
"game.onHpChange(() => {",
"\tconst pct = (game.player.hp / game.player.maxHp) * 100;",
"\tgame.tween(hpFill, { w: pct }, { duration: 0.3, easing: 'ease' });",
"});",
],
},
{
label: 'ui-shop',
detail: 'Магазин со списком предметов (Grid)',
body: [
"// Магазин со списком (Phase 6.3 шаблон)",
"const shop = game.gui.create('frame', {",
"\tname: 'Shop', x: 50, y: 50, w: 60, h: 70, anchor: 'center',",
"\tbgColor: '#222', bgOpacity: 0.95, borderRadius: 12,",
"\tlayout: 'grid', layoutCellW: 18, layoutCellH: 22, layoutCols: 4,",
"\tlayoutGap: 2, layoutPad: 3,",
"});",
"const items = ${1:['меч', 'лук', 'щит', 'зелье', 'кольцо', 'плащ', 'броня', 'еда']};",
"items.forEach((name) => {",
"\tconst slot = game.gui.create('button', { parentId: shop, text: name, bgColor: '#3a3a5a' });",
"\tgame.gui.onClick(slot, () => game.log('купил:', name));",
"});",
],
},
{
label: 'ui-dialog',
detail: 'Диалог NPC: рамка + текст + 2 кнопки',
body: [
"// Диалог NPC (Phase 6.3 шаблон)",
"const dlg = game.gui.create('frame', {",
"\tname: 'Dialog', x: 50, y: 80, w: 60, h: 25, anchor: 'center',",
"\tbgColor: '#1a1a2e', bgOpacity: 0.95, borderColor: '#ffd166', borderWidth: 2, borderRadius: 12,",
"});",
"game.gui.create('text', {",
"\tparentId: dlg, x: 50, y: 25, w: 90, h: 40, anchor: 'center',",
"\ttext: '${1:Привет, путник!}', textColor: '#fff', textSize: 18,",
"});",
"const yes = game.gui.create('button', {",
"\tparentId: dlg, x: 30, y: 75, w: 30, h: 20, anchor: 'center', text: '${2:Да}',",
"});",
"const no = game.gui.create('button', {",
"\tparentId: dlg, x: 70, y: 75, w: 30, h: 20, anchor: 'center', text: '${3:Нет}',",
"});",
"game.gui.onClick(yes, () => game.gui.remove(dlg));",
"game.gui.onClick(no, () => game.gui.remove(dlg));",
],
},
{
label: 'ui-victory',
detail: 'Экран победы с tween-появлением',
body: [
"// Победа (Phase 6.3 шаблон)",
"function showVictory(score) {",
"\tconst panel = game.gui.create('frame', {",
"\t\tname: 'Victory', x: 50, y: 50, w: 40, h: 30, anchor: 'center',",
"\t\tbgColor: '#22d97a', bgOpacity: 0, borderRadius: 16,",
"\t});",
"\tgame.gui.create('text', {",
"\t\tparentId: panel, x: 50, y: 30, w: 90, h: 30, anchor: 'center',",
"\t\ttext: '\\u041f\\u041e\\u0411\\u0415\\u0414\\u0410!', textColor: '#fff', textSize: 36, fontWeight: 800,",
"\t});",
"\tgame.gui.create('text', {",
"\t\tparentId: panel, x: 50, y: 70, w: 90, h: 20, anchor: 'center',",
"\t\ttext: 'Счёт: ' + score, textColor: '#fff', textSize: 18,",
"\t});",
"\t// Tween-появление",
"\tgame.tween(panel, { bgOpacity: 0.95 }, { duration: 0.5, easing: 'ease' });",
"}",
"showVictory(${1:100});",
],
},
{
label: 'ui-inventory',
detail: 'Инвентарь 4×3 grid с tooltip',
body: [
"// Инвентарь (Phase 6.3 шаблон)",
"const inv = game.gui.create('frame', {",
"\tname: 'Inventory', x: 50, y: 50, w: 50, h: 60, anchor: 'center',",
"\tbgColor: '#222', bgOpacity: 0.95, borderRadius: 12,",
"\tlayout: 'grid', layoutCellW: 22, layoutCellH: 30, layoutCols: 4,",
"});",
"for (let i = 0; i < ${1:12}; i++) {",
"\tgame.gui.create('button', { parentId: inv, text: '[пусто]', bgColor: '#3a3a5a' });",
"}",
],
},
{
label: 'ui-leaderboard',
detail: 'Лидерборд: топ-N игроков',
body: [
"// Лидерборд (Phase 6.3 шаблон)",
"const lb = game.gui.create('frame', {",
"\tname: 'Leaderboard', x: 98, y: 50, w: 22, h: 60, anchor: 'top-right',",
"\tanchorPoint: { x: 1, y: 0.5 },",
"\tbgColor: '#1a1a2e', bgOpacity: 0.9, borderRadius: 10,",
"\tlayout: 'vertical', layoutGap: 1, layoutPad: 3,",
"});",
"game.gui.create('text', {",
"\tparentId: lb, text: 'ТОП', textColor: '#ffd166', textSize: 18, h: 8, fontWeight: 800,",
"});",
"game.save.leaderboard.top('${1:score}', 10, (rows) => {",
"\trows.forEach((r, i) => {",
"\t\tgame.gui.create('text', {",
"\t\t\tparentId: lb, h: 8,",
"\t\t\ttext: `${i+1}. ${r.username}: ${r.value}`,",
"\t\t\ttextColor: '#fff', textSize: 13, textAlign: 'left',",
"\t\t});",
"\t});",
"});",
],
},
{
label: 'ui-loading',
detail: 'Загрузочный экран с прогресс-баром',
body: [
"// Загрузочный экран (Phase 6.3 шаблон)",
"const overlay = game.gui.create('frame', {",
"\tname: 'Loading', x: 50, y: 50, w: 100, h: 100, anchor: 'center',",
"\tbgColor: '#000', bgOpacity: 0.85,",
"});",
"game.gui.create('text', {",
"\tparentId: overlay, x: 50, y: 40, w: 60, h: 10, anchor: 'center',",
"\ttext: '${1:Загрузка...}', textColor: '#ffd166', textSize: 24,",
"});",
"const barBg = game.gui.create('frame', {",
"\tparentId: overlay, x: 50, y: 55, w: 40, h: 4, anchor: 'center',",
"\tbgColor: '#222', borderRadius: 4,",
"});",
"const bar = game.gui.create('frame', {",
"\tparentId: barBg, x: 0, y: 0, w: 0, h: 100, anchor: 'top-left',",
"\tanchorPoint: { x: 0, y: 0 },",
"\tbgColor: '#22d97a', borderRadius: 4,",
"});",
"// Tween-заполнение",
"game.tween(bar, { w: 100 }, { duration: ${2:3}, onDone: () => game.gui.remove(overlay) });",
],
},
{
label: 'ui-settings',
detail: 'Настройки: громкость + графика',
body: [
"// Настройки (Phase 6.3 шаблон)",
"const set = game.gui.create('frame', {",
"\tname: 'Settings', x: 50, y: 50, w: 40, h: 50, anchor: 'center',",
"\tbgColor: '#1a1a2e', bgOpacity: 0.95, borderRadius: 12,",
"\tlayout: 'vertical', layoutGap: 3, layoutPad: 5,",
"});",
"game.gui.create('text', { parentId: set, text: 'Настройки', textColor: '#ffd166', textSize: 22, h: 12 });",
"const volBtn = game.gui.create('button', { parentId: set, text: 'Звук: ВКЛ', h: 10 });",
"const muted = { value: false };",
"game.gui.onClick(volBtn, () => {",
"\tmuted.value = !muted.value;",
"\tgame.audio.setMuted(muted.value);",
"\tgame.gui.update(volBtn, { text: muted.value ? 'Звук: ВЫКЛ' : 'Звук: ВКЛ' });",
"});",
"const closeBtn = game.gui.create('button', { parentId: set, text: 'Закрыть', h: 10 });",
"game.gui.onClick(closeBtn, () => game.gui.remove(set));",
],
},
{
label: 'ui-card',
detail: 'Карточка персонажа: аватар + ник + статы',
body: [
"// Карточка персонажа (Phase 6.3 шаблон)",
"const card = game.gui.create('frame', {",
"\tname: 'Card', x: 2, y: 50, w: 18, h: 30, anchor: 'left',",
"\tanchorPoint: { x: 0, y: 0.5 },",
"\tbgColor: '#1a1a2e', bgOpacity: 0.9, borderRadius: 10,",
"});",
"game.gui.create('image', {",
"\tparentId: card, x: 50, y: 25, w: 50, h: 40, anchor: 'center',",
"\timageUrl: '${1:}', bgColor: '#3a3a5a',",
"});",
"game.gui.create('text', {",
"\tparentId: card, x: 50, y: 60, w: 90, h: 12, anchor: 'center',",
"\ttext: '${2:Стив}', textColor: '#fff', textSize: 16, fontWeight: 800,",
"});",
"game.gui.create('text', {",
"\tparentId: card, x: 50, y: 80, w: 90, h: 12, anchor: 'center',",
"\ttext: 'HP: ' + game.player.hp + ' / ' + game.player.maxHp,",
"\ttextColor: '#22d97a', textSize: 13,",
"});",
],
},
{
label: 'loading.transition',
detail: 'Экран загрузки: переход на новый уровень (фейковый прогресс)',
body: [
"await game.loading.transition({",
"\tcover: { sceneSnapshot: true }, // снимок текущей сцены как превью",
"\tduration: ${1:4}, // секунд заполняется бар",
"\tskipButton: true,",
"\tspinner: true,",
"});",
"${2:game.player.teleport(100, 1, 100);} // после загрузки",
],
},
{
label: 'loading.realProgress',
detail: 'Экран загрузки: реальный прогресс подгрузки (ручной setProgress)',
body: [
"const lo = game.loading.show({ progressBar: true, spinner: true });",
"const total = ${1:10};",
"let i = 0;",
"const step = () => {",
"\ti++;",
"\t${2:// подгрузить i-й ресурс}",
"\tlo.setProgress(i / total);",
"\tif (i < total) game.after(${3:0.2}, step); else lo.close();",
"};",
"step();",
],
},
{
label: 'loading.minigame',
detail: 'Экран загрузки: короткая пауза перед мини-игрой',
body: [
"await game.loading.transition({",
"\ttext: '${1:Загружаем мини-игру...}',",
"\tduration: ${2:1.5},",
"\tskipButton: false,",
"});",
"${3:// запустить мини-игру}",
],
},
];
/**
* Регистрируем сниппеты в Monaco. Вызывается ОДИН раз для всего приложения
* при первом монтировании редактора.
*/
export function registerSnippets(monaco) {
if (monaco.__kubikonSnippetsRegistered) return;
monaco.__kubikonSnippetsRegistered = true;
monaco.languages.registerCompletionItemProvider('javascript', {
triggerCharacters: [],
provideCompletionItems(model, position) {
const word = model.getWordUntilPosition(position);
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn,
};
return {
suggestions: SCRIPT_SNIPPETS.map((s) => ({
label: s.label,
kind: monaco.languages.CompletionItemKind.Snippet,
detail: s.detail,
documentation: { value: '```js\n' + s.body.join('\n') + '\n```' },
insertText: s.body.join('\n'),
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
range,
})),
};
},
});
}