diff --git a/src/editor/InspectorPanel.jsx b/src/editor/InspectorPanel.jsx
index 28423b7..6c91732 100644
--- a/src/editor/InspectorPanel.jsx
+++ b/src/editor/InspectorPanel.jsx
@@ -1880,6 +1880,100 @@ const InspectorPanel = ({
)}
+ {/* Стрелка-указатель (задача 08) — пресет, источник, цель, дуга */}
+ {primitiveType?.kind === 'pointer' && (
+
+
Стрелка-указатель
+ {/* Пресет внешнего вида */}
+
Стиль
+
+ {[
+ { id: 'guide', name: 'Красная' },
+ { id: 'quest', name: 'Жёлтая' },
+ { id: 'danger', name: 'Молнии' },
+ { id: 'gift', name: 'Радуга' },
+ ].map(pr => (
+
+ ))}
+
+ {/* Откуда */}
+
+ Откуда
+
+
+ {/* Куда — id объекта-цели или эта точка */}
+
+ Куда (id)
+ onSetPrimitiveProps?.({ pointerTo: e.target.value })}
+ style={{ flex: 1.4, fontSize: 12, padding: '4px 6px' }}
+ />
+
+
+ Пусто = стрелка указывает вперёд (+4 по Z). Можно вписать id примитива/модели или «player».
+
+ {/* Скорость анимации текстуры */}
+
+
+ Скорость бега
+ {(selection.textureSpeed ?? 3)}
+
+
onSetPrimitiveProps?.({ textureSpeed: parseFloat(e.target.value) })}
+ style={{ width: '100%' }}
+ />
+
+ {/* Дугой */}
+
+ {selection.curved && (
+
+
+ Высота дуги
+ {(selection.curveHeight ?? 2)}
+
+
onSetPrimitiveProps?.({ curveHeight: parseFloat(e.target.value) })}
+ style={{ width: '100%' }}
+ />
+
+ )}
+
+ Стрелка появляется при запуске игры (▶). В редакторе показан маркер позиции.
+
+
+ )}
+
{/* Свойства — для всех примитивов */}
Свойства
diff --git a/src/editor/KubikonEditor.jsx b/src/editor/KubikonEditor.jsx
index 65e4994..8c23b1e 100644
--- a/src/editor/KubikonEditor.jsx
+++ b/src/editor/KubikonEditor.jsx
@@ -1042,6 +1042,7 @@ const KubikonEditor = () => {
is_public: !!meta.is_public,
multiplayer: !!meta.multiplayer,
max_players: Math.max(2, Math.min(50, Number(meta.max_players) || 10)),
+ is_test: !!meta.is_test,
project_data: jsonStr,
};
@@ -1365,6 +1366,7 @@ const KubikonEditor = () => {
multiplayer: !!data.multiplayer,
max_players: typeof data.max_players === 'number'
? data.max_players : 10,
+ is_test: !!data.is_test,
};
// Состояние публикации (этап 3)
setProjectStatus({
diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js
index 350b76f..561296e 100644
--- a/src/editor/engine/BabylonScene.js
+++ b/src/editor/engine/BabylonScene.js
@@ -2404,18 +2404,24 @@ export class BabylonScene {
* Иначе пробел/буквы из ввода в модалке двигают камеру и блокируют ввод.
* Также игнорируем когда открыта модалка (z-index overlay).
*/
- const isTypingTarget = (target) => {
- if (!target) return false;
- const tag = (target.tagName || '').toLowerCase();
+ const _isInEditableEl = (el) => {
+ if (!el) return false;
+ const tag = (el.tagName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
- if (target.isContentEditable) return true;
- // Monaco-редактор — у его внутренних элементов tagName бывает 'div',
- // фокус живёт на скрытой textarea, но в зависимости от роутинга
- // событий e.target может оказаться родительским div. Проверяем
- // принадлежность дереву Monaco — там точно идёт набор текста.
- if (typeof target.closest === 'function' && target.closest('.monaco-editor')) return true;
+ if (el.isContentEditable) return true;
+ // Monaco-редактор скриптов — ввод идёт через скрытую textarea.inputarea
+ // внутри .monaco-editor. Принадлежность дереву Monaco = идёт набор.
+ if (typeof el.closest === 'function' && el.closest('.monaco-editor')) return true;
return false;
};
+ const isTypingTarget = (target) => {
+ // Проверяем И e.target, И document.activeElement. Ctrl+V в Monaco
+ // иногда всплывает до window с target == document.body (фокус живёт
+ // на скрытой inputarea), поэтому одной проверки target мало — без
+ // activeElement глобальный Ctrl+V (pasteFromClipboard) перехватывал
+ // вставку кода в редактор скриптов.
+ return _isInEditableEl(target) || _isInEditableEl(document.activeElement);
+ };
const onKeyDown = (e) => {
if (isTypingTarget(e.target)) return;
@@ -5506,6 +5512,8 @@ export class BabylonScene {
// === Лучи и следы (Фаза 5.2 — Beam/Trail) ===
if (!this.beamManager) this.beamManager = new BeamManager(this);
this.beamManager.start();
+ // Задача 08: активируем pointer-примитивы из палитры в реальные стрелки.
+ this._activatePointers();
// === 3D-звук (Фаза 5.5 — позиционный звук) ===
if (!this.soundManager) this.soundManager = new SoundManager(this);
@@ -7337,6 +7345,50 @@ export class BabylonScene {
}
}
+ /**
+ * Задача 08: активировать pointer-примитивы из палитры в реальные стрелки.
+ * Маркер-сфера прячется, через BeamManager создаётся анимированная стрелка
+ * от источника (игрок/объект) к цели. from/to берутся из инспектора.
+ */
+ _activatePointers() {
+ const pm = this.primitiveManager;
+ const bm = this.beamManager;
+ if (!pm || !bm) return;
+ let cnt = 0;
+ for (const inst of pm.instances.values()) {
+ if (inst.type !== 'pointer') continue;
+ cnt++;
+ try { if (inst.mesh) inst.mesh.setEnabled(false); } catch (e) {}
+ const at = { x: inst.x, y: inst.y, z: inst.z };
+ const from = this._pointerRefOrPoint(inst.pointerFrom, at);
+ const to = this._pointerRefOrPoint(inst.pointerTo, { x: at.x, y: at.y, z: at.z + 4 });
+ try {
+ const pid = bm.addPointer({
+ from, to,
+ preset: inst.pointerPreset || 'guide',
+ color: inst.color, textureSpeed: inst.textureSpeed,
+ curved: inst.curved, curveHeight: inst.curveHeight,
+ });
+ } catch (e) {
+ }
+ }
+ }
+
+ /** from/to стрелки: 'player' | id примитива/модели → ref | точка-fallback. */
+ _pointerRefOrPoint(val, fallbackPoint) {
+ if (val === 'player') return 'player';
+ if (val != null && val !== '') {
+ const n = Number(val);
+ if (Number.isFinite(n)) {
+ if (this.primitiveManager?.instances?.has(n)) return 'primitive:' + n;
+ if (this.modelManager?.instances?.has(n)) return 'model:' + n;
+ }
+ if (typeof val === 'string'
+ && (val.startsWith('primitive:') || val.startsWith('model:'))) return val;
+ }
+ return fallbackPoint;
+ }
+
/** Выйти из режима игры — восстановить редактор-камеру. */
exitPlayMode() {
if (!this._isPlaying) return;
diff --git a/src/editor/engine/BeamManager.js b/src/editor/engine/BeamManager.js
index 75e28fb..3ba7359 100644
--- a/src/editor/engine/BeamManager.js
+++ b/src/editor/engine/BeamManager.js
@@ -1,30 +1,50 @@
/**
- * BeamManager — лучи (Beam) и следы (Trail) как объекты сцены (Фаза 5.2).
+ * BeamManager — лучи (Beam), следы (Trail) и стрелки-указатели (Pointer)
+ * как объекты сцены.
*
- * Beam — светящаяся линия между двумя точками. Точки могут быть
- * фиксированными координатами или ref объектов — тогда луч
- * следует за объектами каждый кадр (лазеры, мосты света,
- * соединения, цепи).
- * Trail — шлейф, тянущийся за движущимся объектом (Babylon TrailMesh).
+ * Beam — светящаяся линия/лента между двумя точками. Точки могут быть
+ * фиксированными координатами, ref объектов ИЛИ 'player' — тогда
+ * конец следует за объектом/игроком каждый кадр.
+ * Trail — шлейф за движущимся объектом (Babylon TrailMesh).
+ * Pointer — высокоуровневая «стрелка иди сюда»: текстурированная лента
+ * (бегущие шевроны/стрелки), с пресетами, curved-дугой, градиентом.
*
- * Живут только в Play-режиме. Управляются скриптом через game.fx.* —
- * каждый вызов возвращает прокси-объект.
+ * Задача 08: расширены опции addBeam (texture/textureSpeed/curved/colorSequence/
+ * faceMode/strokeColor/...) + game.fx.pointer. Живут только в Play-режиме
+ * (и в превью редактора со скоростью анимации 0).
*/
import {
- MeshBuilder, StandardMaterial, Color3, Vector3,
+ MeshBuilder, StandardMaterial, Color3, Color4, Vector3,
+ DynamicTexture, Texture, VertexBuffer, TransformNode,
} from '@babylonjs/core';
import { TrailMesh } from '@babylonjs/core/Meshes/trailMesh';
let _fxIdSeq = 1;
+// Кэш сгенерированных текстур по ключу (форма+обводка) — одна на сцену.
+// Текстуры белые (рисуются белым), реальный цвет даёт mat.emissiveColor/diffuse.
+const _texCache = new Map();
+
+// Встроенные пресеты для game.fx.pointer — разворачиваются в опции beam.
+const POINTER_PRESETS = {
+ guide: { texture: 'chevron', color: '#ff3a3a', strokeColor: '#000000', strokeWidth: 4, textureSpeed: 3, width: 0.9, textureScale: 1 },
+ quest: { texture: 'chevron', color: '#ffd23a', strokeColor: '#5a3a00', strokeWidth: 3, textureSpeed: 3.5, width: 0.9, textureScale: 1 },
+ danger: { texture: 'lightning', color: '#ff2a2a', strokeColor: '#3a0000', strokeWidth: 2, textureSpeed: 5, width: 1.0, textureScale: 1.2 },
+ gift: { texture: 'sparkle', color: '#ffffff', strokeColor: null, strokeWidth: 0, textureSpeed: 2, width: 1.0, textureScale: 1.4,
+ colorSequence: [{ p: 0, c: '#ff5a5a' }, { p: 0.25, c: '#ffd23a' }, { p: 0.5, c: '#5aff7a' }, { p: 0.75, c: '#3a9aff' }, { p: 1, c: '#c45aff' }] },
+};
+
export class BeamManager {
constructor(scene3d) {
this.scene3d = scene3d;
this.scene = scene3d.scene;
- /** @type {Map
} id → fx state (beam | trail) */
+ /** @type {Map} id → fx state (beam | trail | pointer) */
this.items = new Map();
this._renderHook = null;
+ this._lastTime = 0;
+ // В превью редактора (не Play) анимацию текстур замораживаем.
+ this.animationEnabled = true;
}
start() {
@@ -46,59 +66,304 @@ export class BeamManager {
try {
if (it.mesh) it.mesh.dispose();
if (it.mat) it.mat.dispose();
+ // Парящий quest-marker: root (TransformNode) с дочерним конусом —
+ // dispose с потомками убирает и конус, и его outline.
+ if (it._markerMesh) it._markerMesh.dispose();
+ if (it._marker) it._marker.dispose();
+ if (it._markerMat) it._markerMat.dispose();
} catch (e) { /* ignore */ }
}
+ // ===================================================================
+ // ТЕКСТУРЫ ЛУЧА — генерируются на лету через Canvas2D (без PNG-файлов).
+ // Все белые с альфа-каналом; цвет даёт материал. strokeColor рисуется
+ // под основной формой.
+ // ===================================================================
+
+ /** Получить (с кэшем) DynamicTexture для формы. */
+ _getBeamTexture(shape, strokeColor, strokeWidth) {
+ const key = shape + '|' + (strokeColor || '') + '|' + (strokeWidth || 0);
+ if (_texCache.has(key)) return _texCache.get(key);
+ const S = 128;
+ const dyn = new DynamicTexture('beamTex_' + key, { width: S, height: S }, this.scene, true);
+ dyn.hasAlpha = true;
+ dyn.wrapU = Texture.WRAP_ADDRESSMODE;
+ dyn.wrapV = Texture.CLAMP_ADDRESSMODE;
+ const ctx = dyn.getContext();
+ ctx.clearRect(0, 0, S, S);
+ this._drawShape(ctx, shape, S, strokeColor, strokeWidth);
+ dyn.update();
+ _texCache.set(key, dyn);
+ return dyn;
+ }
+
/**
- * Создать луч между двумя точками.
- * opts: { from, to — {x,y,z} или ref-строка объекта;
- * color: '#hex', width: толщина (м) }.
+ * Рисует форму на canvas. Ориентация: текстура натягивается вдоль ленты
+ * так, что U идёт ПО длине (from→to). Шеврон рисуем «>» указывающим в
+ * сторону +U (к цели).
+ */
+ _drawShape(ctx, shape, S, strokeColor, strokeWidth) {
+ const sw = Number(strokeWidth) || 0;
+ ctx.lineJoin = 'round';
+ ctx.lineCap = 'round';
+ const drawPath = (pathFn, fill) => {
+ // Обводка (под формой) — рисуем толще тем же путём.
+ if (strokeColor && sw > 0) {
+ ctx.beginPath(); pathFn();
+ ctx.strokeStyle = strokeColor;
+ ctx.lineWidth = sw * 2 + 14;
+ ctx.stroke();
+ }
+ ctx.beginPath(); pathFn();
+ if (fill) {
+ ctx.fillStyle = '#ffffff';
+ ctx.fill();
+ } else {
+ ctx.strokeStyle = '#ffffff';
+ ctx.lineWidth = 16;
+ ctx.stroke();
+ }
+ };
+ const m = S * 0.18; // отступ
+ const cx = S / 2, cy = S / 2;
+ switch (shape) {
+ case 'chevron': {
+ // «>» указывающий вправо (к цели вдоль +U)
+ drawPath(() => {
+ ctx.moveTo(m, m);
+ ctx.lineTo(S - m, cy);
+ ctx.lineTo(m, S - m);
+ }, false);
+ break;
+ }
+ case 'arrow': {
+ // наконечник-треугольник вправо
+ drawPath(() => {
+ ctx.moveTo(m, m + 6);
+ ctx.lineTo(S - m, cy);
+ ctx.lineTo(m, S - m - 6);
+ ctx.closePath();
+ }, true);
+ break;
+ }
+ case 'dot': {
+ drawPath(() => { ctx.arc(cx, cy, S * 0.22, 0, Math.PI * 2); }, true);
+ break;
+ }
+ case 'line': {
+ ctx.fillStyle = '#ffffff';
+ if (strokeColor && sw > 0) {
+ ctx.fillStyle = strokeColor;
+ ctx.fillRect(0, cy - (S * 0.28) / 2 - sw, S, S * 0.28 + sw * 2);
+ ctx.fillStyle = '#ffffff';
+ }
+ ctx.fillRect(0, cy - (S * 0.28) / 2, S, S * 0.28);
+ break;
+ }
+ case 'dash': {
+ ctx.fillStyle = '#ffffff';
+ ctx.fillRect(S * 0.12, cy - (S * 0.22) / 2, S * 0.76, S * 0.22);
+ break;
+ }
+ case 'wave': {
+ drawPath(() => {
+ ctx.moveTo(0, cy);
+ for (let x = 0; x <= S; x += 4) {
+ ctx.lineTo(x, cy + Math.sin((x / S) * Math.PI * 2) * (S * 0.28));
+ }
+ }, false);
+ break;
+ }
+ case 'lightning': {
+ drawPath(() => {
+ ctx.moveTo(m, m);
+ ctx.lineTo(cx + 6, cy - 6);
+ ctx.lineTo(cx - 6, cy + 6);
+ ctx.lineTo(S - m, S - m);
+ }, false);
+ break;
+ }
+ case 'sparkle': {
+ // 4-лучевая звёздочка
+ drawPath(() => {
+ const r = S * 0.30, ri = S * 0.10;
+ for (let i = 0; i < 8; i++) {
+ const a = (i / 8) * Math.PI * 2 - Math.PI / 2;
+ const rad = (i % 2 === 0) ? r : ri;
+ const x = cx + Math.cos(a) * rad, y = cy + Math.sin(a) * rad;
+ if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
+ }
+ ctx.closePath();
+ }, true);
+ break;
+ }
+ default: { // fallback — сплошная линия
+ ctx.fillStyle = '#ffffff';
+ ctx.fillRect(0, cy - (S * 0.28) / 2, S, S * 0.28);
+ }
+ }
+ }
+
+ // ===================================================================
+ // BEAM (расширенный — задача 08)
+ // ===================================================================
+
+ /**
+ * Создать луч/ленту между двумя точками.
+ * opts: {
+ * from, to — {x,y,z} | ref-строка | 'player';
+ * color, width;
+ * texture: 'chevron'|'arrow'|'dot'|'line'|'wave'|'lightning'|'dash'|'sparkle'|'custom'|null;
+ * customTextureUrl, textureMode, textureSpeed, textureScale,
+ * strokeColor, strokeWidth,
+ * colorSequence:[{p,c}], transparencySequence:[{p,t}], widthSequence:[{p,w}],
+ * faceMode: 'billboard'|'flat-x'|'flat-y'|'flat-z',
+ * segments, curved, curveHeight, attachOffset:{fromY,toY},
+ * ignoreDepth,
+ * }
* Возвращает id.
*/
addBeam(opts = {}) {
const id = _fxIdSeq++;
- const width = Number.isFinite(opts.width) ? opts.width : 0.15;
- const mat = new StandardMaterial('beamMat_' + id, this.scene);
- const col = Color3.FromHexString(opts.color || '#66ccff');
+ const hasTexture = !!opts.texture && opts.texture !== 'none';
+ const it = {
+ id, type: 'beam',
+ from: opts.from, to: opts.to,
+ width: Number.isFinite(opts.width) ? opts.width : (hasTexture ? 0.8 : 0.15),
+ color: opts.color || '#66ccff',
+ texture: hasTexture ? opts.texture : null,
+ customTextureUrl: opts.customTextureUrl || null,
+ textureMode: opts.textureMode || 'wrap',
+ textureSpeed: Number.isFinite(opts.textureSpeed) ? opts.textureSpeed : 0,
+ textureScale: Number.isFinite(opts.textureScale) ? opts.textureScale : 1,
+ strokeColor: opts.strokeColor || null,
+ strokeWidth: Number.isFinite(opts.strokeWidth) ? opts.strokeWidth : 3,
+ colorSequence: Array.isArray(opts.colorSequence) ? opts.colorSequence : null,
+ transparencySequence: Array.isArray(opts.transparencySequence) ? opts.transparencySequence : null,
+ widthSequence: Array.isArray(opts.widthSequence) ? opts.widthSequence : null,
+ faceMode: opts.faceMode || 'billboard',
+ segments: Math.max(2, Number.isFinite(opts.segments) ? opts.segments : 24),
+ curved: !!opts.curved,
+ curveHeight: Number.isFinite(opts.curveHeight) ? opts.curveHeight : 2,
+ attachOffset: opts.attachOffset || { fromY: 0, toY: 0 },
+ ignoreDepth: opts.ignoreDepth !== false, // по умолчанию рисуем поверх
+ uOffset: 0,
+ mesh: null, mat: null,
+ };
+ this._buildBeamMaterial(it);
+ this.items.set(id, it);
+ this._updateBeam(it);
+ return id;
+ }
+
+ _buildBeamMaterial(it) {
+ if (it.mat) { try { it.mat.dispose(); } catch (e) {} }
+ const mat = new StandardMaterial('beamMat_' + it.id, this.scene);
+ const col = Color3.FromHexString(it.color);
mat.diffuseColor = col;
mat.emissiveColor = col;
mat.disableLighting = true;
- // Цилиндр-заготовка единичной высоты — масштабируем под длину луча.
- const mesh = MeshBuilder.CreateCylinder('beam_' + id,
- { height: 1, diameter: width, tessellation: 8 }, this.scene);
- mesh.material = mat;
- mesh.isPickable = false;
- mesh.renderingGroupId = 1;
- const it = {
- id, type: 'beam', mesh, mat,
- from: opts.from, to: opts.to,
- };
- this.items.set(id, it);
- this._updateBeam(it); // сразу позиционируем
- return id;
+ mat.backFaceCulling = false;
+ if (it.texture) {
+ let tex;
+ if (it.texture === 'custom' && it.customTextureUrl) {
+ tex = new Texture(it.customTextureUrl, this.scene);
+ tex.hasAlpha = true;
+ tex.wrapU = Texture.WRAP_ADDRESSMODE;
+ tex.wrapV = Texture.CLAMP_ADDRESSMODE;
+ } else {
+ tex = this._getBeamTexture(it.texture, it.strokeColor, it.strokeWidth);
+ }
+ mat.diffuseTexture = tex;
+ mat.emissiveTexture = tex;
+ mat.opacityTexture = tex; // альфа формы
+ mat.useAlphaFromDiffuseTexture = true;
+ }
+ mat.alpha = it.texture ? 1 : 0.9;
+ if (it.ignoreDepth) {
+ mat.disableDepthWrite = true;
+ // рисуем поверх геометрии
+ }
+ it.mat = mat;
}
/** Сменить цвет луча. */
setBeamColor(id, color) {
const it = this.items.get(Number(id));
- if (!it || it.type !== 'beam' || !it.mat) return;
- const col = Color3.FromHexString(color || '#66ccff');
+ if (!it || !it.mat || !color) return;
+ it.color = color;
+ const col = Color3.FromHexString(color);
it.mat.diffuseColor = col;
it.mat.emissiveColor = col;
+ this._recolorMarker(it, col);
+ // если есть градиент — он перекрывает; иначе vertexColors обновим в _updateBeam
+ it.colorSequence = null;
}
- /** Сменить концы луча (координаты или ref). */
+ /** Перекрасить quest-marker в цвет луча (при смене пресета). */
+ _recolorMarker(it, col) {
+ if (it && it._markerMat && col) {
+ it._markerMat.diffuseColor = col;
+ it._markerMat.emissiveColor = col;
+ }
+ }
+
+ /** Сменить концы луча (координаты | ref | 'player'). */
setBeamEndpoints(id, from, to) {
const it = this.items.get(Number(id));
- if (!it || it.type !== 'beam') return;
+ if (!it) return;
if (from !== undefined) it.from = from;
if (to !== undefined) it.to = to;
}
+ /** Обновить произвольные опции луча на лету (для pointer.update). */
+ updateBeam(id, opts = {}) {
+ const it = this.items.get(Number(id));
+ if (!it) return;
+ let rebuild = false;
+ for (const k of ['texture', 'strokeColor', 'strokeWidth', 'customTextureUrl', 'ignoreDepth']) {
+ if (opts[k] !== undefined && opts[k] !== it[k]) { it[k] = opts[k]; rebuild = true; }
+ }
+ for (const k of ['color', 'width', 'textureMode', 'textureSpeed', 'textureScale',
+ 'faceMode', 'segments', 'curved', 'curveHeight', 'attachOffset',
+ 'colorSequence', 'transparencySequence', 'widthSequence']) {
+ if (opts[k] !== undefined) it[k] = opts[k];
+ }
+ if (opts.from !== undefined) it.from = opts.from;
+ if (opts.to !== undefined) it.to = opts.to;
+ if (rebuild) {
+ this._buildBeamMaterial(it);
+ } else if (opts.color !== undefined && !it.colorSequence) {
+ const col = Color3.FromHexString(it.color);
+ it.mat.diffuseColor = col; it.mat.emissiveColor = col;
+ }
+ // Перекрасить quest-marker под новый пресет. Для градиента (gift) —
+ // средний цвет последовательности.
+ if (it._markerMat) {
+ let mc = it.color;
+ if (it.colorSequence && it.colorSequence.length) {
+ mc = this._sampleSeqColor(it.colorSequence, 0.5) || it.color;
+ }
+ if (mc) this._recolorMarker(it, Color3.FromHexString(mc));
+ }
+ // геометрия (curved/segments/width) пересоберётся в _updateBeam — сбросим mesh
+ if (opts.curved !== undefined || opts.segments !== undefined
+ || opts.width !== undefined || opts.widthSequence !== undefined
+ || opts.colorSequence !== undefined) {
+ if (it.mesh) { try { it.mesh.dispose(); } catch (e) {} it.mesh = null; }
+ }
+ this._updateBeam(it);
+ }
+
+ setVisible(id, vis) {
+ const it = this.items.get(Number(id));
+ if (it && it.mesh) it.mesh.setEnabled(!!vis);
+ if (it && it._marker) it._marker.setEnabled(!!vis); // и маркер
+ if (it) it._hidden = !vis;
+ }
+
/**
- * Создать шлейф за объектом.
- * ref — ref-строка объекта. opts: { color, width, lifetime (сек) }.
- * Возвращает id.
+ * Создать шлейф за объектом. (без изменений — Фаза 5.2)
*/
addTrail(ref, opts = {}) {
const data = this._resolve(ref);
@@ -107,11 +372,8 @@ export class BeamManager {
const id = _fxIdSeq++;
const width = Number.isFinite(opts.width) ? opts.width : 0.4;
const lifetime = Number.isFinite(opts.lifetime) ? opts.lifetime : 1.5;
- // TrailMesh(name, generator, scene, diameter, length, autoStart).
- // length — сколько сегментов хранить; считаем из lifetime (≈60 fps).
const segments = Math.max(10, Math.round(lifetime * 60));
- const trail = new TrailMesh('trail_' + id, mesh, this.scene,
- width, segments, true);
+ const trail = new TrailMesh('trail_' + id, mesh, this.scene, width, segments, true);
const mat = new StandardMaterial('trailMat_' + id, this.scene);
const col = Color3.FromHexString(opts.color || '#ffcc44');
mat.diffuseColor = col;
@@ -126,7 +388,7 @@ export class BeamManager {
return id;
}
- /** Убрать луч/след по id. */
+ /** Убрать луч/след/указатель по id. */
remove(id) {
const it = this.items.get(Number(id));
if (!it) return;
@@ -138,41 +400,380 @@ export class BeamManager {
_tick() {
if (this.items.size === 0) return;
+ const now = performance.now() / 1000;
+ let dt = this._lastTime ? (now - this._lastTime) : 0.016;
+ this._lastTime = now;
+ if (dt > 0.1) dt = 0.016; // защита от больших скачков (вкладка спала)
for (const it of this.items.values()) {
- // Trail обновляется самим Babylon (autoStart). Beam — мы.
- if (it.type === 'beam') this._updateBeam(it);
+ if (it.type === 'trail') continue; // Babylon сам
+ if (it._hidden) continue;
+ this._updateBeam(it);
+ // Анимация бегущей текстуры.
+ if (this.animationEnabled && it.texture && it.textureSpeed
+ && it.mat && it.mat.diffuseTexture && it.mat.diffuseTexture.uOffset !== undefined) {
+ it.uOffset -= it.textureSpeed * dt * 0.4;
+ it.mat.diffuseTexture.uOffset = it.uOffset;
+ }
+ // Парящий 3D-маркер над целью: позиция + подпрыгивание + вращение.
+ if (it._marker) this._tickMarker(it, now);
}
}
+ /** Анимировать quest-marker над целью (bob + spin), держать над to. */
+ _tickMarker(it, now) {
+ const root = it._marker;
+ if (!root) return;
+ // Цель: точка `to` (без attachOffset — нам нужна верхушка объекта).
+ const tgt = this._point(it.to, 0);
+ if (!tgt) { root.setEnabled(false); return; }
+ root.setEnabled(true);
+ const ph = it._markerPhase || 0;
+ // bob: плавный синус ±0.22м; высота над целью ~2.2м.
+ const bob = Math.sin(now * 3 + ph) * 0.22;
+ root.position.set(tgt.x, tgt.y + 2.2 + bob, tgt.z);
+ // Вращение всего маркера вокруг Y (дочерний конус остаётся остриём вниз).
+ root.rotation.y = now * 1.6 + ph;
+ }
+
+ /**
+ * Построить/обновить геометрию луча. Для текстурированных/curved —
+ * ribbon из сегментов с UV вдоль длины. Для простого — цилиндр (легаси).
+ */
_updateBeam(it) {
- const a = this._point(it.from);
- const b = this._point(it.to);
- if (!a || !b || !it.mesh) return;
+ const a = this._point(it.from, it.attachOffset && it.attachOffset.fromY);
+ const b = this._point(it.to, it.attachOffset && it.attachOffset.toY);
+ if (!a || !b) {
+ if (it.mesh) it.mesh.setEnabled(false);
+ return;
+ }
const dx = b.x - a.x, dy = b.y - a.y, dz = b.z - a.z;
const len = Math.hypot(dx, dy, dz);
- if (len < 0.001) { it.mesh.setEnabled(false); return; }
+ if (len < 0.01) { if (it.mesh) it.mesh.setEnabled(false); return; }
+
+ // Текстурированный/curved/градиент — лента (ribbon).
+ if (it.texture || it.curved || it.colorSequence || it.widthSequence) {
+ this._updateRibbon(it, a, b, len);
+ return;
+ }
+
+ // Простой луч — легаси цилиндр.
+ if (!it.mesh || it._meshKind !== 'cyl') {
+ if (it.mesh) { try { it.mesh.dispose(); } catch (e) {} }
+ it.mesh = MeshBuilder.CreateCylinder('beam_' + it.id,
+ { height: 1, diameter: it.width, tessellation: 8 }, this.scene);
+ it.mesh.material = it.mat;
+ it.mesh.isPickable = false;
+ it.mesh.renderingGroupId = 1;
+ it._meshKind = 'cyl';
+ }
it.mesh.setEnabled(true);
- // Цилиндр единичной высоты вдоль локальной оси Y. Растягиваем по длине.
it.mesh.scaling.y = len;
- // Центр луча.
it.mesh.position.set((a.x + b.x) / 2, (a.y + b.y) / 2, (a.z + b.z) / 2);
- // Ориентируем ось Y цилиндра вдоль вектора a→b.
const dir = new Vector3(dx, dy, dz).normalize();
- // yaw + pitch так, чтобы +Y смотрел вдоль dir.
const yaw = Math.atan2(dir.x, dir.z);
const pitch = Math.acos(Math.max(-1, Math.min(1, dir.y)));
it.mesh.rotation.set(pitch, yaw, 0);
}
- /** Точка из {x,y,z} или ref-строки объекта. */
- _point(p) {
+ /**
+ * Построить ленту (ribbon): две линии вершин вдоль направления from→to,
+ * смещённые перпендикулярно (billboard — к камере). UV: u вдоль длины
+ * (количество тайлов = длина/ширина × textureScale), v поперёк.
+ */
+ _updateRibbon(it, a, b, len) {
+ const seg = it.curved ? it.segments : 1;
+ const A = new Vector3(a.x, a.y, a.z);
+ const B = new Vector3(b.x, b.y, b.z);
+ // центральная линия (curved = квадратичная Безье через приподнятый midpoint)
+ const center = [];
+ if (it.curved) {
+ const mid = A.add(B).scale(0.5);
+ mid.y += it.curveHeight;
+ for (let i = 0; i <= seg; i++) {
+ const t = i / seg;
+ const omt = 1 - t;
+ // B(t) = (1-t)^2 A + 2(1-t)t M + t^2 B
+ const x = omt * omt * A.x + 2 * omt * t * mid.x + t * t * B.x;
+ const y = omt * omt * A.y + 2 * omt * t * mid.y + t * t * B.y;
+ const z = omt * omt * A.z + 2 * omt * t * mid.z + t * t * B.z;
+ center.push(new Vector3(x, y, z));
+ }
+ } else {
+ center.push(A, B);
+ }
+
+ // направление «вбок» для ширины: billboard → перпендикуляр к лучу и к камере.
+ const cam = this.scene.activeCamera;
+ const camPos = cam ? cam.position : new Vector3(0, 50, 0);
+ const widthAt = (t) => {
+ if (it.widthSequence && it.widthSequence.length) {
+ return this._sampleSeq(it.widthSequence, t, 'w', it.width);
+ }
+ return it.width;
+ };
+
+ const left = [], right = [];
+ for (let i = 0; i < center.length; i++) {
+ const p = center[i];
+ const t = center.length > 1 ? i / (center.length - 1) : 0;
+ // касательная к линии
+ const prev = center[Math.max(0, i - 1)];
+ const next = center[Math.min(center.length - 1, i + 1)];
+ const tangent = next.subtract(prev);
+ if (tangent.lengthSquared() < 1e-6) tangent.copyFrom(B.subtract(A));
+ tangent.normalize();
+ let side;
+ if (it.faceMode === 'flat-y') {
+ side = new Vector3(0, 1, 0).cross(tangent); // лежит горизонтально
+ } else if (it.faceMode === 'flat-x') {
+ side = new Vector3(1, 0, 0);
+ } else if (it.faceMode === 'flat-z') {
+ side = new Vector3(0, 0, 1);
+ } else { // billboard — перпендикуляр к лучу, в плоскости к камере
+ const toCam = camPos.subtract(p);
+ side = tangent.cross(toCam);
+ }
+ if (side.lengthSquared() < 1e-6) side = new Vector3(1, 0, 0);
+ side.normalize().scaleInPlace(widthAt(t) / 2);
+ left.push(p.add(side));
+ right.push(p.subtract(side));
+ }
+
+ const pathArray = [left, right];
+ // UV вдоль длины: тайлов = длина / ширина × scale
+ const tiles = Math.max(1, Math.round((len / Math.max(0.1, it.width)) * 0.6 * it.textureScale));
+
+ // (пере)создание ribbon. updatable, чтобы менять каждый кадр дёшево.
+ const needNew = !it.mesh || it._meshKind !== 'ribbon' || it._segCount !== center.length;
+ if (needNew) {
+ if (it.mesh) { try { it.mesh.dispose(); } catch (e) {} }
+ // sideOrientation НЕ DOUBLESIDE: при DOUBLESIDE Babylon удваивает
+ // вершины (фронт+бэк), а наши UV/vertexColors считаются на 2n вершин
+ // → setVerticesData выходит за vertex buffer → GL_INVALID_OPERATION
+ // «Vertex buffer is not big enough» (стрелка не рисовалась, WebGL
+ // отрубал контекст). Двусторонность даёт материал (backFaceCulling
+ // = false), DOUBLESIDE-геометрия не нужна.
+ it.mesh = MeshBuilder.CreateRibbon('beam_' + it.id, {
+ pathArray, updatable: true,
+ }, this.scene);
+ it.mesh.material = it.mat;
+ it.mesh.isPickable = false;
+ it.mesh.renderingGroupId = it.ignoreDepth ? 3 : 1;
+ it._meshKind = 'ribbon';
+ it._segCount = center.length;
+ this._applyRibbonUV(it, center.length, tiles);
+ this._applyRibbonColors(it, center.length);
+ } else {
+ it.mesh = MeshBuilder.CreateRibbon('beam_' + it.id, {
+ pathArray, instance: it.mesh,
+ });
+ // UV/colors при изменении длины тайлов
+ if (it._tiles !== tiles) { this._applyRibbonUV(it, center.length, tiles); }
+ }
+ it._tiles = tiles;
+ it.mesh.setEnabled(true);
+ }
+
+ /** UV: u = тайлы вдоль длины (для wrap-повтора), v = 0..1 поперёк. */
+ _applyRibbonUV(it, n, tiles) {
+ if (!it.mesh) return;
+ const uv = [];
+ // pathArray=[left,right] → вершины идут: left[0..n-1], right[0..n-1]
+ for (let i = 0; i < n; i++) {
+ const u = (i / (n - 1 || 1)) * tiles;
+ uv.push(u, 0);
+ }
+ for (let i = 0; i < n; i++) {
+ const u = (i / (n - 1 || 1)) * tiles;
+ uv.push(u, 1);
+ }
+ try { it.mesh.setVerticesData(VertexBuffer.UVKind, uv, true); } catch (e) {}
+ }
+
+ /** Градиент цвета/прозрачности вдоль длины через vertexColors. */
+ _applyRibbonColors(it, n) {
+ if (!it.mesh) return;
+ if (!it.colorSequence && !it.transparencySequence) {
+ // равномерный цвет — vertexColors не нужны (материал даёт цвет)
+ return;
+ }
+ const colors = [];
+ const base = Color3.FromHexString(it.color);
+ const sample = (t) => {
+ let c = base;
+ if (it.colorSequence) {
+ const hex = this._sampleSeqColor(it.colorSequence, t);
+ if (hex) c = Color3.FromHexString(hex);
+ }
+ const alpha = it.transparencySequence
+ ? 1 - this._sampleSeq(it.transparencySequence, t, 't', 0)
+ : 1;
+ return new Color4(c.r, c.g, c.b, alpha);
+ };
+ for (let i = 0; i < n; i++) {
+ const t = i / (n - 1 || 1);
+ const c = sample(t);
+ colors.push(c.r, c.g, c.b, c.a);
+ }
+ for (let i = 0; i < n; i++) {
+ const t = i / (n - 1 || 1);
+ const c = sample(t);
+ colors.push(c.r, c.g, c.b, c.a);
+ }
+ try {
+ it.mesh.setVerticesData(VertexBuffer.ColorKind, colors, true);
+ it.mat.useVertexColor = true;
+ if (it.transparencySequence) it.mat.alpha = 1; // альфа из vertexColor
+ } catch (e) {}
+ }
+
+ _sampleSeq(seq, t, key, def) {
+ if (!seq || !seq.length) return def;
+ let prev = seq[0], next = seq[seq.length - 1];
+ for (let i = 0; i < seq.length; i++) {
+ if (seq[i].p <= t) prev = seq[i];
+ if (seq[i].p >= t) { next = seq[i]; break; }
+ }
+ if (prev === next || next.p === prev.p) return prev[key];
+ const f = (t - prev.p) / (next.p - prev.p);
+ return prev[key] + (next[key] - prev[key]) * f;
+ }
+
+ _sampleSeqColor(seq, t) {
+ if (!seq || !seq.length) return null;
+ let prev = seq[0], next = seq[seq.length - 1];
+ for (let i = 0; i < seq.length; i++) {
+ if (seq[i].p <= t) prev = seq[i];
+ if (seq[i].p >= t) { next = seq[i]; break; }
+ }
+ const ca = Color3.FromHexString(prev.c), cb = Color3.FromHexString(next.c);
+ if (prev === next || next.p === prev.p) return prev.c;
+ const f = (t - prev.p) / (next.p - prev.p);
+ const r = Math.round((ca.r + (cb.r - ca.r) * f) * 255);
+ const g = Math.round((ca.g + (cb.g - ca.g) * f) * 255);
+ const b = Math.round((ca.b + (cb.b - ca.b) * f) * 255);
+ return '#' + [r, g, b].map((x) => x.toString(16).padStart(2, '0')).join('');
+ }
+
+ // ===================================================================
+ // POINTER (game.fx.pointer) — высокоуровневая стрелка-указатель
+ // ===================================================================
+
+ /**
+ * Создать стрелку-указатель. opts: { from, to, preset, ...override }.
+ * from/to: {x,y,z} | ref | 'player'. preset: guide|quest|danger|gift|custom.
+ * Возвращает id (это beam с pointer-флагом).
+ */
+ addPointer(opts = {}) {
+ const preset = POINTER_PRESETS[opts.preset] || (opts.preset === 'custom' ? {} : POINTER_PRESETS.guide);
+ const merged = {
+ from: opts.from,
+ to: opts.to,
+ ...preset,
+ // явные override из opts перебивают пресет:
+ ...(opts.color !== undefined ? { color: opts.color } : {}),
+ ...(opts.texture !== undefined ? { texture: opts.texture } : {}),
+ ...(opts.textureSpeed !== undefined ? { textureSpeed: opts.textureSpeed } : {}),
+ ...(opts.strokeColor !== undefined ? { strokeColor: opts.strokeColor } : {}),
+ ...(opts.colorSequence !== undefined ? { colorSequence: opts.colorSequence } : {}),
+ ...(opts.curved !== undefined ? { curved: opts.curved } : {}),
+ ...(opts.curveHeight !== undefined ? { curveHeight: opts.curveHeight } : {}),
+ ...(opts.faceMode !== undefined ? { faceMode: opts.faceMode } : {}),
+ ...(opts.customTextureUrl !== undefined ? { texture: 'custom', customTextureUrl: opts.customTextureUrl } : {}),
+ // Указатель — горизонтальная лента (flat-y), лежит над землёй и
+ // всегда видна сверху. billboard на curved-ленте вырождался (side-
+ // вектор tangent×toCam схлопывался на сегментах смотрящих на камеру)
+ // → стрелка пропадала. flat-y держит ширину стабильно.
+ faceMode: opts.faceMode || 'flat-y',
+ curved: opts.curved !== undefined ? opts.curved : true,
+ curveHeight: opts.curveHeight !== undefined ? opts.curveHeight : 0.8,
+ // Лента над землёй, цель — у её центра. Хорошо видно, не в земле.
+ width: opts.width !== undefined ? opts.width : 1.4,
+ attachOffset: opts.attachOffset || { fromY: 1.6, toY: 1.2 },
+ };
+ const id = this.addBeam(merged);
+ const it = this.items.get(id);
+ if (it) {
+ it._isPointer = true;
+ // Парящая 3D-стрелка над целью (как quest-marker в Roblox):
+ // объёмный конус вершиной ВНИЗ, подпрыгивает + вращается + светится.
+ this._makePointerMarker(it);
+ }
+ return id;
+ }
+
+ /**
+ * 3D-маркер над целью (Roblox quest-pin): гладкий конус остриём ВНИЗ,
+ * светится, с чёрной обводкой. Анимация (bob + spin) — на РОДИТЕЛЕ
+ * (TransformNode), сам конус ориентирован вниз статично, поэтому spin
+ * не ломает «вниз» направление и обводка не съезжает.
+ */
+ _makePointerMarker(it) {
+ try {
+ const col = Color3.FromHexString(it.color || '#ff3a3a');
+ // Родитель: только позиция/bob/spin. Дочерний конус — геометрия.
+ const root = new TransformNode('ptrMarkerRoot_' + it.id, this.scene);
+
+ // Гладкий конус (tessellation 18 — не пирамида). Остриё вниз:
+ // CreateCylinder остриём ВВЕРХ (diameterTop=0), поворот X=PI → вниз.
+ const m = MeshBuilder.CreateCylinder('ptrMarker_' + it.id, {
+ height: 1.0, diameterTop: 0, diameterBottom: 0.55, tessellation: 18,
+ }, this.scene);
+ m.parent = root;
+ m.rotation.x = Math.PI; // остриём вниз (на цель)
+ m.isPickable = false;
+ m.renderingGroupId = 3; // поверх геометрии (как beam)
+ const mat = new StandardMaterial('ptrMarkerMat_' + it.id, this.scene);
+ mat.diffuseColor = col;
+ mat.emissiveColor = col; // светится
+ mat.disableLighting = true;
+ mat.disableDepthWrite = true; // рисуем поверх
+ m.material = mat;
+ // Мультяшная обводка — встроенный Babylon outline (не второй меш,
+ // потому не съезжает). Толщину даём заметную.
+ m.renderOutline = true;
+ m.outlineColor = new Color3(0, 0, 0);
+ m.outlineWidth = 0.06;
+
+ it._marker = root;
+ it._markerMesh = m;
+ it._markerMat = mat;
+ it._markerPhase = (it.id % 7) * 0.5; // разный фазовый сдвиг bob
+ } catch (e) {
+ console.warn('[BeamManager] marker create failed:', e);
+ }
+ }
+
+ /** Сменить цель указателя. */
+ setPointerTarget(id, to) {
+ const it = this.items.get(Number(id));
+ if (it) it.to = to;
+ }
+
+ /** Применить пресет к существующему указателю (для pointer.update({preset})). */
+ applyPointerPreset(id, preset) {
+ const p = POINTER_PRESETS[preset];
+ if (!p) return;
+ this.updateBeam(id, p);
+ }
+
+ // ===================================================================
+
+ /** Точка из {x,y,z} | ref | 'player'. yOff — доп. смещение по Y. */
+ _point(p, yOff) {
+ const off = Number.isFinite(yOff) ? yOff : 0;
if (!p) return null;
+ if (p === 'player') {
+ const pl = this.scene3d && this.scene3d.player;
+ if (pl && pl._pos) return { x: pl._pos.x, y: pl._pos.y + off, z: pl._pos.z };
+ return null;
+ }
if (typeof p === 'object' && Number.isFinite(p.x)) {
- return { x: p.x, y: p.y, z: p.z };
+ return { x: p.x, y: p.y + off, z: p.z };
}
if (typeof p === 'string') {
const d = this._resolve(p);
- if (d) return { x: d.x, y: d.y, z: d.z };
+ if (d) return { x: d.x, y: d.y + off, z: d.z };
}
return null;
}
diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js
index f065393..2aaae16 100644
--- a/src/editor/engine/GameRuntime.js
+++ b/src/editor/engine/GameRuntime.js
@@ -1577,6 +1577,17 @@ export class GameRuntime {
id = bm.addBeam({
from: payload.from, to: payload.to,
color: payload.color, width: payload.width,
+ // Задача 08: расширенные опции луча.
+ texture: payload.texture, customTextureUrl: payload.customTextureUrl,
+ textureMode: payload.textureMode, textureSpeed: payload.textureSpeed,
+ textureScale: payload.textureScale,
+ strokeColor: payload.strokeColor, strokeWidth: payload.strokeWidth,
+ colorSequence: payload.colorSequence,
+ transparencySequence: payload.transparencySequence,
+ widthSequence: payload.widthSequence,
+ faceMode: payload.faceMode, segments: payload.segments,
+ curved: payload.curved, curveHeight: payload.curveHeight,
+ attachOffset: payload.attachOffset, ignoreDepth: payload.ignoreDepth,
});
} else if (payload.kind === 'trail') {
id = bm.addTrail(payload.ref, {
@@ -1598,6 +1609,53 @@ export class GameRuntime {
if (fid != null) this.scene3d?.beamManager?.setBeamColor(fid, payload?.color);
return;
}
+ // === Задача 08: стрелка-указатель + расширенное управление лучом ===
+ if (cmd === 'fx.createPointer') {
+ const bm = this.scene3d?.beamManager;
+ if (bm && payload) {
+ const id = bm.addPointer({
+ from: payload.from, to: payload.to, preset: payload.preset,
+ color: payload.color, texture: payload.texture,
+ customTextureUrl: payload.customTextureUrl,
+ textureSpeed: payload.textureSpeed, width: payload.width,
+ strokeColor: payload.strokeColor, colorSequence: payload.colorSequence,
+ curved: payload.curved, curveHeight: payload.curveHeight,
+ faceMode: payload.faceMode, attachOffset: payload.attachOffset,
+ });
+ if (id == null) {
+ this._log('error', 'не удалось создать стрелку-указатель');
+ } else if (payload.localRef) {
+ if (!this._fxLocalToReal) this._fxLocalToReal = new Map();
+ this._fxLocalToReal.set(payload.localRef, id);
+ }
+ }
+ return;
+ }
+ if (cmd === 'fx.pointerTarget') {
+ const fid = this._resolveFxId(payload?.ref);
+ if (fid != null) this.scene3d?.beamManager?.setPointerTarget(fid, payload?.to);
+ return;
+ }
+ if (cmd === 'fx.pointerUpdate') {
+ const fid = this._resolveFxId(payload?.ref);
+ const bm = this.scene3d?.beamManager;
+ if (fid != null && bm) {
+ const o = payload?.opts || {};
+ if (o.preset) bm.applyPointerPreset(fid, o.preset);
+ bm.updateBeam(fid, o);
+ }
+ return;
+ }
+ if (cmd === 'fx.beamUpdate') {
+ const fid = this._resolveFxId(payload?.ref);
+ if (fid != null) this.scene3d?.beamManager?.updateBeam(fid, payload?.opts || {});
+ return;
+ }
+ if (cmd === 'fx.beamVisible') {
+ const fid = this._resolveFxId(payload?.ref);
+ if (fid != null) this.scene3d?.beamManager?.setVisible(fid, payload?.visible !== false);
+ return;
+ }
if (cmd === 'fx.beamEndpoints') {
const fid = this._resolveFxId(payload?.ref);
if (fid != null) {
diff --git a/src/editor/engine/PrimitiveManager.js b/src/editor/engine/PrimitiveManager.js
index 43cdffb..a9f0c3b 100644
--- a/src/editor/engine/PrimitiveManager.js
+++ b/src/editor/engine/PrimitiveManager.js
@@ -145,6 +145,21 @@ export class PrimitiveManager {
}
}
+ // === Стрелка-указатель (задача 08): сохраняем настройки в data ===
+ // В редакторе это маркер-сфера; реальный beam создаётся при Play через
+ // activatePointers (см. ниже) / при превью.
+ if (typeDef.kind === 'pointer') {
+ data.pointerPreset = opts.pointerPreset || 'guide';
+ data.pointerFrom = opts.pointerFrom || 'player'; // 'player' | id | ''
+ data.pointerTo = opts.pointerTo || ''; // id цели | ''
+ data.textureSpeed = Number.isFinite(opts.textureSpeed) ? opts.textureSpeed : 3;
+ data.curved = !!opts.curved;
+ data.curveHeight = Number.isFinite(opts.curveHeight) ? opts.curveHeight : 2;
+ if (mesh.material) {
+ mesh.material.emissiveColor = Color3.FromHexString(color || '#ff3a3a');
+ }
+ }
+
// === 3D-табличка (billboard): натягиваем DynamicTexture с GUI ===
if (typeDef.kind === 'billboard' && this.billboardUiManager) {
// Сохраняем настройки билборда в data.billboardOpts чтобы
@@ -208,8 +223,10 @@ export class PrimitiveManager {
{ diameterX: sx, diameterY: sy, diameterZ: sz, segments: 24 }, this.scene);
case 'light':
case 'emitter':
- // Лампа / эмиттер = маленькая сфера-маркер. Свет/частицы
- // создаются отдельно в addInstance.
+ case 'pointer':
+ // Лампа / эмиттер / стрелка = маленькая сфера-маркер.
+ // Свет/частицы/beam создаются отдельно (light — в addPrimitive,
+ // pointer — при enterPlayMode через activatePointers).
return MeshBuilder.CreateSphere(name,
{ diameterX: sx, diameterY: sy, diameterZ: sz, segments: 12 }, this.scene);
case 'billboard': {
@@ -676,6 +693,24 @@ export class PrimitiveManager {
if (Number.isFinite(m) && m > 0) data.mass = m;
}
+ // === Стрелка-указатель (задача 08): настройки из инспектора ===
+ // Сохраняем в data, чтобы serialize записал их в project_data, а при
+ // Play _activatePointers() построил реальную стрелку с этими параметрами.
+ if (data.type === 'pointer') {
+ if (patch.pointerPreset !== undefined) data.pointerPreset = patch.pointerPreset;
+ if (patch.pointerFrom !== undefined) data.pointerFrom = patch.pointerFrom;
+ if (patch.pointerTo !== undefined) data.pointerTo = patch.pointerTo;
+ if (patch.textureSpeed !== undefined) {
+ const s = Number(patch.textureSpeed);
+ if (Number.isFinite(s)) data.textureSpeed = s;
+ }
+ if (patch.curved !== undefined) data.curved = !!patch.curved;
+ if (patch.curveHeight !== undefined) {
+ const h = Number(patch.curveHeight);
+ if (Number.isFinite(h)) data.curveHeight = h;
+ }
+ }
+
this._notifyChange();
}
diff --git a/src/editor/engine/PrimitiveTypes.js b/src/editor/engine/PrimitiveTypes.js
index cdbafe8..06a0046 100644
--- a/src/editor/engine/PrimitiveTypes.js
+++ b/src/editor/engine/PrimitiveTypes.js
@@ -66,6 +66,13 @@ export const PRIMITIVE_TYPES = [
{ id: 'billboard', name: '3D-табличка', icon: 'prim-billboard', kind: 'billboard',
defaultScale: { x: 2, y: 1.2, z: 0.05 }, defaultColor: '#1a1a2e' },
+ // === Стрелка-указатель «иди сюда» (задача 08) ===
+ // Маркер-сфера в редакторе + бегущие шевроны (beam) в Play. Источник/цель
+ // и пресет задаются в инспекторе. В Play превращается в реальную стрелку
+ // через BeamManager (см. PrimitiveManager.activatePointer).
+ { id: 'pointer', name: 'Стрелка-указатель', icon: 'arrow-right', kind: 'pointer',
+ defaultScale: { x: 0.4, y: 0.4, z: 0.4 }, defaultColor: '#ff3a3a' },
+
// === GD-порталы (Geometry Dash 2.0) — переключают гейммод игрока ===
// Все цилиндры-«столбы» с neon-материалом. Цвета и gdMode задают режим.
{ id: 'gd_cube', name: 'Портал: Куб', icon: 'prim-portal', kind: 'gd_portal', gdMode: 'cube',
@@ -96,7 +103,7 @@ export const PRIMITIVE_TYPES = [
/** Категории для группировки в палитре. */
export const PRIMITIVE_CATEGORIES = [
{ id: 'geometry', name: 'Фигуры', ids: ['cube','sphere','cylinder','cone','plane','torus','wedge','cornerwedge'] },
- { id: 'gameplay', name: 'Геймплей', ids: ['trigger', 'checkpoint', 'light', 'emitter', 'billboard'] },
+ { id: 'gameplay', name: 'Геймплей', ids: ['trigger', 'checkpoint', 'light', 'emitter', 'billboard', 'pointer'] },
{ id: 'gd-portals', name: 'GD-порталы', ids: ['gd_cube','gd_ship','gd_ball','gd_ufo','gd_wave','gd_robot'] },
{ id: 'gd-objects', name: 'GD-объекты', ids: ['gd_spike','gd_finish','gd_coin'] },
];
diff --git a/src/editor/engine/ScriptSandboxWorker.js b/src/editor/engine/ScriptSandboxWorker.js
index 3f6cd1d..eb4469f 100644
--- a/src/editor/engine/ScriptSandboxWorker.js
+++ b/src/editor/engine/ScriptSandboxWorker.js
@@ -120,6 +120,27 @@ let _billboardClickHandlers = {};
// Для GUI-события с реальным id вернуть набор ключей, под которыми
// могли быть зарегистрированы handlers: сам id + имя элемента (скрипт
// часто подписывается через game.gui.onClick('ИмяКнопки', fn)).
+// Нормализовать точку для fx.beam/fx.pointer перед postMessage.
+// game.scene.findOne() возвращает Instance-PROXY — его НЕЛЬЗЯ передать через
+// postMessage (structured clone бросает DataCloneError → весь скрипт молча
+// падает в воркере, стрелка/луч не создаётся). Конвертируем proxy/объект-с-ref
+// в ref-строку ('primitive:NN'); 'player' и {x,y,z} пропускаем как есть.
+function _normFxPoint(p) {
+ if (p == null) return p;
+ if (typeof p === 'string') return p; // 'player' | 'primitive:NN'
+ if (typeof p === 'object') {
+ // Instance-proxy: есть .ref (через toString/valueOf даёт ref-строку).
+ if (typeof p.ref === 'string') return p.ref;
+ // Чистая точка {x,y,z} — безопасна для clone, отдаём копию.
+ if (Number.isFinite(p.x) && Number.isFinite(p.y) && Number.isFinite(p.z)) {
+ return { x: p.x, y: p.y, z: p.z };
+ }
+ // Любой другой объект (вдруг proxy без .ref) — пробуем String().
+ try { const s = String(p); if (s && s !== '[object Object]') return s; } catch (e) {}
+ }
+ return p;
+}
+
function _guiHandlerKeys(id, localId) {
const keys = [id];
// localId — ref который вернул gui.create() ('_gui_local_N'); скрипт мог
@@ -2914,11 +2935,22 @@ const game = {
opts = opts || {};
_fxRefSeq++;
const localRef = 'fx:_local_' + _fxRefSeq;
+ // Задача 08: расширенные опции (текстура/curved/градиент/billboard).
_send('fx.create', {
kind: 'beam', localRef,
- from: opts.from, to: opts.to,
+ from: _normFxPoint(opts.from), to: _normFxPoint(opts.to),
color: typeof opts.color === 'string' ? opts.color : undefined,
width: Number.isFinite(Number(opts.width)) ? Number(opts.width) : undefined,
+ texture: opts.texture, customTextureUrl: opts.customTextureUrl,
+ textureMode: opts.textureMode, textureSpeed: opts.textureSpeed,
+ textureScale: opts.textureScale,
+ strokeColor: opts.strokeColor, strokeWidth: opts.strokeWidth,
+ colorSequence: opts.colorSequence,
+ transparencySequence: opts.transparencySequence,
+ widthSequence: opts.widthSequence,
+ faceMode: opts.faceMode, segments: opts.segments,
+ curved: opts.curved, curveHeight: opts.curveHeight,
+ attachOffset: opts.attachOffset, ignoreDepth: opts.ignoreDepth,
});
return {
get ref() { return localRef; },
@@ -2930,6 +2962,44 @@ const game = {
setEndpoints(from, to) {
_send('fx.beamEndpoints', { ref: localRef, from, to });
},
+ /** Изменить любые опции луча на лету. */
+ update(o) {
+ _send('fx.beamUpdate', { ref: localRef, opts: o || {} });
+ },
+ hide() { _send('fx.beamVisible', { ref: localRef, visible: false }); },
+ show() { _send('fx.beamVisible', { ref: localRef, visible: true }); },
+ remove() { _send('fx.remove', { ref: localRef }); },
+ };
+ },
+ /**
+ * Стрелка-указатель «иди сюда» (бегущие шевроны). Задача 08.
+ * const arrow = game.fx.pointer({ from: 'player', to: cubeRef, preset: 'guide' });
+ * arrow.setTarget(otherRef); arrow.update({ preset: 'quest' }); arrow.remove();
+ * preset: 'guide'|'quest'|'danger'|'gift'|'custom'.
+ * from/to: 'player' | ref-объекта | {x,y,z}.
+ */
+ pointer(opts) {
+ opts = opts || {};
+ _fxRefSeq++;
+ const localRef = 'fx:_local_' + _fxRefSeq;
+ _send('fx.createPointer', {
+ localRef,
+ from: _normFxPoint(opts.from !== undefined ? opts.from : 'player'),
+ to: _normFxPoint(opts.to),
+ preset: opts.preset || 'guide',
+ color: opts.color, texture: opts.texture,
+ customTextureUrl: opts.customTextureUrl,
+ textureSpeed: opts.textureSpeed, width: opts.width,
+ strokeColor: opts.strokeColor, colorSequence: opts.colorSequence,
+ curved: opts.curved, curveHeight: opts.curveHeight,
+ faceMode: opts.faceMode, attachOffset: opts.attachOffset,
+ });
+ return {
+ get ref() { return localRef; },
+ setTarget(to) { _send('fx.pointerTarget', { ref: localRef, to: _normFxPoint(to) }); },
+ update(o) { _send('fx.pointerUpdate', { ref: localRef, opts: o || {} }); },
+ hide() { _send('fx.beamVisible', { ref: localRef, visible: false }); },
+ show() { _send('fx.beamVisible', { ref: localRef, visible: true }); },
remove() { _send('fx.remove', { ref: localRef }); },
};
},