feat: 3D-�������-��������� + is_test + Ctrl+V + ����-�������� #16

Merged
min merged 3 commits from feat/arrow-pointer into main 2026-05-30 19:41:38 +00:00
10 changed files with 1015 additions and 68 deletions
Showing only changes of commit e3e5960241 - Show all commits

View File

@ -318,4 +318,9 @@ export const GAMES = [
desc: 'Кастомные скины: герой превращается в пончик, машину, пришельца. Магазин скинов на B.',
mechanics: ['game.player.setSkin', 'non-humanoid скины', 'магазин скинов', 'таблички'],
previewShot: 'guide-zoo-scene.png', openProjectId: 2046, ready: true },
{ id: 'guide-strelka', num: 55, group: 'g5', stars: 1, icon: 'gamepad',
title: 'Туториал — собери монетки',
desc: '3D-стрелка-указатель «иди сюда»: дорожка из бегущих шевронов + парящий маркер над целью. Дошёл — стрелка прыгает на следующую.',
mechanics: ['game.fx.pointer', 'preset стрелки', 'setTarget/update', 'onTouch цели'],
previewShot: 'guide-strelka-scene.png', openProjectId: 333, ready: true },
];

View File

@ -44,6 +44,7 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
const [isPublic, setIsPublic] = useState(false);
const [multiplayer, setMultiplayer] = useState(false);
const [maxPlayers, setMaxPlayers] = useState(10);
const [isTest, setIsTest] = useState(false);
const [error, setError] = useState('');
const fileInputRef = useRef(null);
@ -58,6 +59,7 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
setThumbnail(initial?.thumbnail || '');
setIsPublic(!!initial?.is_public);
setMultiplayer(!!initial?.multiplayer);
setIsTest(!!initial?.is_test);
setMaxPlayers(
typeof initial?.max_players === 'number'
? Math.max(2, Math.min(50, initial.max_players))
@ -117,6 +119,7 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
is_public: isPublic,
multiplayer,
max_players: Math.max(2, Math.min(50, Number(maxPlayers) || 10)),
is_test: isTest,
});
};
@ -252,6 +255,26 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
</label>
</div>
{/* Тестовая игра для разработки/проверки в плеере.
Не попадает в каталог (лента/поиск/профиль). */}
<label className={cl.toggleRow} style={{ marginTop: 8 }}>
<input
type="checkbox"
className={cl.toggle}
checked={isTest}
onChange={(e) => setIsTest(e.target.checked)}
/>
<div className={cl.toggleText}>
<div className={cl.toggleTitle}>Тестовая игра</div>
<div className={cl.toggleHint}>
<Icon name={isTest ? 'flask' : 'eye'} size={12} />
<span>{isTest
? 'Только для тебя: можно тестить в плеере, но НЕ видна в каталоге'
: 'Обычная игра — попадает в каталог при публикации'}</span>
</div>
</div>
</label>
{multiplayer && (
<label className={cl.field}>
<div className={cl.fieldLabel}>

View File

@ -1880,6 +1880,100 @@ const InspectorPanel = ({
</div>
)}
{/* Стрелка-указатель (задача 08) — пресет, источник, цель, дуга */}
{primitiveType?.kind === 'pointer' && (
<div className={cl.section}>
<div className={cl.sectionTitle}><Icon name="arrow-right" size={12} /> Стрелка-указатель</div>
{/* Пресет внешнего вида */}
<div style={{ fontSize: 12, marginBottom: 4 }}>Стиль</div>
<div className={cl.row3}>
{[
{ id: 'guide', name: 'Красная' },
{ id: 'quest', name: 'Жёлтая' },
{ id: 'danger', name: 'Молнии' },
{ id: 'gift', name: 'Радуга' },
].map(pr => (
<button
key={pr.id}
type="button"
className={cl.smallBtn}
onClick={() => onSetPrimitiveProps?.({ pointerPreset: pr.id })}
style={{
fontWeight: (selection.pointerPreset || 'guide') === pr.id ? 700 : 400,
background: (selection.pointerPreset || 'guide') === pr.id ? 'var(--accent)' : undefined,
color: (selection.pointerPreset || 'guide') === pr.id ? '#fff' : undefined,
}}
>
{pr.name}
</button>
))}
</div>
{/* Откуда */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 0' }}>
<span style={{ fontSize: 12, flex: 1 }}>Откуда</span>
<select
value={selection.pointerFrom ?? 'player'}
onChange={(e) => onSetPrimitiveProps?.({ pointerFrom: e.target.value })}
style={{ flex: 1.4 }}
>
<option value="player">Игрок</option>
<option value="">Эта точка</option>
</select>
</div>
{/* Куда — id объекта-цели или эта точка */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 0' }}>
<span style={{ fontSize: 12, flex: 1 }}>Куда (id)</span>
<input
type="text"
placeholder="напр. 42 или player"
value={selection.pointerTo ?? ''}
onChange={(e) => onSetPrimitiveProps?.({ pointerTo: e.target.value })}
style={{ flex: 1.4, fontSize: 12, padding: '4px 6px' }}
/>
</div>
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 2, lineHeight: 1.4 }}>
Пусто = стрелка указывает вперёд (+4 по Z). Можно вписать id примитива/модели или «player».
</div>
{/* Скорость анимации текстуры */}
<div style={{ padding: '6px 0' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 }}>
<span>Скорость бега</span>
<span style={{ opacity: 0.6 }}>{(selection.textureSpeed ?? 3)}</span>
</div>
<input
type="range" min="0" max="8" step="0.5"
value={selection.textureSpeed ?? 3}
onChange={(e) => onSetPrimitiveProps?.({ textureSpeed: parseFloat(e.target.value) })}
style={{ width: '100%' }}
/>
</div>
{/* Дугой */}
<label style={{ display: 'flex', gap: 8, alignItems: 'center', cursor: 'pointer', padding: '4px 0' }}>
<input type="checkbox"
checked={!!selection.curved}
onChange={(e) => onSetPrimitiveProps?.({ curved: e.target.checked })} />
<span style={{ fontSize: 12 }}>Изогнуть дугой</span>
</label>
{selection.curved && (
<div style={{ padding: '4px 0' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 }}>
<span>Высота дуги</span>
<span style={{ opacity: 0.6 }}>{(selection.curveHeight ?? 2)}</span>
</div>
<input
type="range" min="1" max="10" step="0.5"
value={selection.curveHeight ?? 2}
onChange={(e) => onSetPrimitiveProps?.({ curveHeight: parseFloat(e.target.value) })}
style={{ width: '100%' }}
/>
</div>
)}
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 6, fontStyle: 'italic', lineHeight: 1.4 }}>
Стрелка появляется при запуске игры (). В редакторе показан маркер позиции.
</div>
</div>
)}
{/* Свойства — для всех примитивов */}
<div className={cl.section}>
<div className={cl.sectionTitle}><Icon name="settings" size={12} /> Свойства</div>

View File

@ -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({

View File

@ -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;

View File

@ -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<number, object>} id → fx state (beam | trail) */
/** @type {Map<number, object>} 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 идёт ПО длине (fromto). Шеврон рисуем «>» указывающим в
* сторону +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): две линии вершин вдоль направления fromto,
* смещённые перпендикулярно (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;
}

View File

@ -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) {

View File

@ -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();
}

View File

@ -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'] },
];

View File

@ -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 }); },
};
},