feat: перенос Lua-стека из студии (Фаза 1: shim + sandbox + LabelManager + rbxl-integration + HudOverlay)
This commit is contained in:
parent
f34320db91
commit
3478ffafd1
@ -1,80 +1,385 @@
|
||||
/**
|
||||
* LabelManager — billboard-метки (текст-плашки) над 3D-объектами.
|
||||
* LabelManager — billboard-плашки (текст-надписи) над 3D-объектами.
|
||||
*
|
||||
* Используется для game.scene.setLabel(ref, text) — имена/HP над
|
||||
* персонажами, врагами, предметами. Метка всегда повёрнута лицом к камере
|
||||
* (billboardMode=7), всегда поверх геометрии (renderingGroupId=1).
|
||||
* game.scene.setLabel(ref, text, opts) — имена/HP/таймеры/счётчики над
|
||||
* персонажами, врагами, предметами. По умолчанию плашка повёрнута лицом к
|
||||
* камере (billboardMode=7), всегда поверх геометрии (renderingGroupId=1).
|
||||
*
|
||||
* Метка привязывается к мешу объекта (parent) и висит над ним.
|
||||
* Задача 10 — расширенные стили: фон/обводка/скругление (пресеты gameui/
|
||||
* warning/reward/boss-hp/plain), обводка текста, richText (<color>/<b>/<size>),
|
||||
* faceMode billboard|fixed, attachPoint, maxDistance.
|
||||
*
|
||||
* Плашка привязывается к мешу объекта (parent) и висит над ним.
|
||||
*/
|
||||
import { DynamicTexture } from '@babylonjs/core/Materials/Textures/dynamicTexture';
|
||||
import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial';
|
||||
import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
|
||||
import { Color3 } from '@babylonjs/core/Maths/math.color';
|
||||
import { Mesh } from '@babylonjs/core/Meshes/mesh';
|
||||
|
||||
// === Пресеты стилей плашки (фон/обводка/текст) ===
|
||||
// gameui — жёлтая обводка + тёмно-синий фон + белый текст (как в Roblox-UI).
|
||||
export const LABEL_PRESETS = {
|
||||
plain: {
|
||||
background: null, borderColor: null, borderWidth: 0, cornerRadius: 0,
|
||||
color: '#ffffff', textStroke: { color: '#000', width: 8 },
|
||||
},
|
||||
gameui: {
|
||||
background: '#1a3a8a', borderColor: '#f5c020', borderWidth: 10, cornerRadius: 28,
|
||||
color: '#ffffff', textStroke: { color: '#0a1430', width: 6 },
|
||||
},
|
||||
warning: {
|
||||
background: '#b01e1e', borderColor: '#ffce3a', borderWidth: 10, cornerRadius: 28,
|
||||
color: '#ffffff', textStroke: { color: '#000', width: 6 },
|
||||
},
|
||||
reward: {
|
||||
background: '#caa018', borderColor: '#fff0a0', borderWidth: 10, cornerRadius: 28,
|
||||
color: '#fff8e0', textStroke: { color: '#6b4e00', width: 6 },
|
||||
gradient: ['#f7d34a', '#c98a18'], // золотой градиент фона
|
||||
},
|
||||
'boss-hp': {
|
||||
background: '#3a0a0a', borderColor: '#ff4040', borderWidth: 8, cornerRadius: 20,
|
||||
color: '#ffd0d0', textStroke: { color: '#000', width: 6 },
|
||||
gradient: ['#8a1414', '#3a0a0a'],
|
||||
},
|
||||
};
|
||||
|
||||
export class LabelManager {
|
||||
constructor(scene) {
|
||||
this.scene = scene;
|
||||
// ref-строка объекта → { plane, tex, mat }
|
||||
// ref-строка объекта → { plane, tex, mat, lastKey, opts }
|
||||
this.labels = new Map();
|
||||
this._playerMesh = null; // для maxDistance — задаётся из BabylonScene
|
||||
}
|
||||
|
||||
/** Дать ссылку на меш игрока (для maxDistance-скрытия). */
|
||||
setPlayerMesh(mesh) { this._playerMesh = mesh; }
|
||||
|
||||
/**
|
||||
* Установить/обновить метку над объектом.
|
||||
* ref — ref-строка объекта (от scene.spawn / scene.find).
|
||||
* anchorMesh — Babylon-меш объекта (метка крепится к нему).
|
||||
* text — текст метки.
|
||||
* opts — { color: '#fff', height: 2.5 (м над объектом), size: 1 }
|
||||
* Установить/обновить плашку над объектом.
|
||||
* ref — ref-строка объекта.
|
||||
* anchorMesh — Babylon-меш объекта (плашка крепится к нему).
|
||||
* text — текст (может содержать richText-теги если opts.richText).
|
||||
* opts — см. LABEL_PRESETS + { color, height, size, background,
|
||||
* borderColor, borderWidth, cornerRadius, padding, textStroke,
|
||||
* fontWeight, faceMode, rotationY, attachPoint, preset,
|
||||
* richText, maxDistance }
|
||||
*/
|
||||
setLabel(ref, anchorMesh, text, opts = {}) {
|
||||
if (!anchorMesh) return;
|
||||
const color = opts.color || '#ffffff';
|
||||
text = String(text == null ? '' : text);
|
||||
|
||||
// Пресет → база, поверх — явные opts.
|
||||
const preset = opts.preset && LABEL_PRESETS[opts.preset] ? LABEL_PRESETS[opts.preset] : null;
|
||||
const st = { ...(preset || {}), ...opts };
|
||||
const color = st.color || '#ffffff';
|
||||
const heightAbove = Number.isFinite(opts.height) ? opts.height : 2.5;
|
||||
const sizeMul = Number.isFinite(opts.size) ? opts.size : 1;
|
||||
const richText = !!opts.richText;
|
||||
|
||||
// Диф-чек: если ничего не поменялось — не пересоздаём (важно для bindLabel).
|
||||
const styleKey = JSON.stringify({ text, color, preset: opts.preset, bg: st.background,
|
||||
bc: st.borderColor, bw: st.borderWidth, cr: st.cornerRadius, rich: richText,
|
||||
fw: st.fontWeight, h: heightAbove, sz: sizeMul, fm: st.faceMode, ry: st.rotationY,
|
||||
af: st.attachFace, tl: st.tilt, ap: typeof st.attachPoint === 'string' ? st.attachPoint : null });
|
||||
const existing = this.labels.get(ref);
|
||||
if (existing && existing.lastKey === styleKey && existing.plane.parent === anchorMesh) {
|
||||
return; // ничего не изменилось
|
||||
}
|
||||
|
||||
// Меняется только текст (тот же стиль/размер) → перерисуем canvas без
|
||||
// пересоздания меша (дешевле). Иначе — полное пересоздание.
|
||||
const sameStruct = existing && existing.styleStruct === this._structKey(st, richText, heightAbove, sizeMul);
|
||||
if (sameStruct) {
|
||||
this._drawCanvas(existing.tex, text, color, st, richText);
|
||||
existing.tex.update(true);
|
||||
existing.lastKey = styleKey;
|
||||
existing.lastText = text;
|
||||
return;
|
||||
}
|
||||
|
||||
// Если метка уже есть — пересоздаём (текст/цвет могли измениться).
|
||||
this.clearLabel(ref);
|
||||
|
||||
// Размер текстуры: чем больше текста — тем шире, чтобы не растягивать.
|
||||
const fontPx = 120;
|
||||
const W = 1024, H = 256;
|
||||
const tex = new DynamicTexture(`lblTex_${ref}_${Date.now()}`,
|
||||
const tex = new DynamicTexture(`lblTex_${ref}_${this._uid()}`,
|
||||
{ width: W, height: H }, this.scene, true);
|
||||
tex.updateSamplingMode?.(3); // TRILINEAR
|
||||
tex.anisotropicFilteringLevel = 8;
|
||||
const ctx = tex.getContext();
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
ctx.font = 'bold 120px "Roboto Condensed", "Segoe UI", sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.lineWidth = 16;
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.strokeStyle = '#000';
|
||||
ctx.strokeText(String(text), W / 2, H / 2);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillText(String(text), W / 2, H / 2);
|
||||
tex.update(true);
|
||||
tex.hasAlpha = true;
|
||||
this._drawCanvas(tex, text, color, st, richText);
|
||||
tex.update(true);
|
||||
|
||||
// Соотношение плашки — 4:1 (1024×256). Крупная и читаемая (как в Roblox).
|
||||
// ОДНОСТОРОННЯЯ плоскость (FRONTSIDE): лицо = нормаль +Z. Мы всегда
|
||||
// разворачиваем плашку лицом к наблюдателю снаружи грани, поэтому текст
|
||||
// читается прямо. DOUBLESIDE НЕ используем — задняя грань зеркалит UV
|
||||
// (баг: «Ключ от тайника» наоборот). backFaceCulling выключен только
|
||||
// чтобы плашка не пропадала при взгляде сзади (без отражённого текста).
|
||||
const plane = MeshBuilder.CreatePlane(`lbl_${ref}`,
|
||||
{ width: 2.2 * sizeMul, height: 0.55 * sizeMul }, this.scene);
|
||||
{ width: 3.4 * sizeMul, height: 0.85 * sizeMul,
|
||||
sideOrientation: Mesh.FRONTSIDE }, this.scene);
|
||||
const mat = new StandardMaterial(`lblMat_${ref}`, this.scene);
|
||||
mat.diffuseTexture = tex;
|
||||
mat.diffuseTexture.hasAlpha = true;
|
||||
mat.emissiveColor = new Color3(1, 1, 1);
|
||||
mat.diffuseColor = new Color3(0, 0, 0);
|
||||
mat.disableLighting = true;
|
||||
// Геометрия двусторонняя (DOUBLESIDE) с прямыми backUVs — culling можно
|
||||
// включить, дублей нет; текст читается с обеих сторон без зеркала.
|
||||
mat.backFaceCulling = false;
|
||||
mat.disableDepthWrite = true;
|
||||
mat.useAlphaFromDiffuseTexture = true;
|
||||
plane.material = mat;
|
||||
plane.billboardMode = 7; // всегда лицом к камере
|
||||
plane.renderingGroupId = 1; // поверх геометрии
|
||||
plane.renderingGroupId = 1;
|
||||
plane.isPickable = false;
|
||||
// Крепим к объекту: метка висит над ним и двигается вместе с ним.
|
||||
plane.parent = anchorMesh;
|
||||
plane.position.set(0, heightAbove, 0);
|
||||
|
||||
this.labels.set(ref, { plane, tex, mat });
|
||||
// Полуразмеры объекта по каждой оси (для крепления над верхом ИЛИ на
|
||||
// грань). Берём bounding box в ЛОКАЛЬНЫХ координатах меша (min/max), чтобы
|
||||
// позиция плашки-ребёнка была верной при любом масштабе/вращении родителя.
|
||||
let halfX = 0.5, halfY = 0.5, halfZ = 0.5;
|
||||
try {
|
||||
const bb = anchorMesh.getBoundingInfo?.().boundingBox;
|
||||
if (bb && bb.minimum && bb.maximum) {
|
||||
halfX = (bb.maximum.x - bb.minimum.x) / 2;
|
||||
halfY = (bb.maximum.y - bb.minimum.y) / 2;
|
||||
halfZ = (bb.maximum.z - bb.minimum.z) / 2;
|
||||
} else if (anchorMesh.scaling) {
|
||||
halfX = Math.abs(anchorMesh.scaling.x) / 2;
|
||||
halfY = Math.abs(anchorMesh.scaling.y) / 2;
|
||||
halfZ = Math.abs(anchorMesh.scaling.z) / 2;
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
const halfH = halfY;
|
||||
const halfPlane = 0.45 * sizeMul; // полувысота самой плашки (3.4×0.85)
|
||||
|
||||
// attachFace — ПРИКРЕПИТЬ плашку НА ГРАНЬ объекта (как табличка на
|
||||
// стене/полу): плашка лежит В ПЛОСКОСТИ грани, фиксированной ориентации,
|
||||
// и перемещается/вращается ВМЕСТЕ с объектом (она его ребёнок). Это
|
||||
// Roblox-style «надпись = часть постройки» (в отличие от billboard над
|
||||
// верхом). Значения: 'top'|'bottom'|'front'|'back'|'left'|'right'
|
||||
// (или сырые '+y'/'-y'/'+z'/'-z'/'+x'/'-x').
|
||||
const FACE = { top: '+y', bottom: '-y', front: '+z', back: '-z',
|
||||
right: '+x', left: '-x' };
|
||||
let face = st.attachFace;
|
||||
if (face && FACE[face]) face = FACE[face];
|
||||
|
||||
if (face) {
|
||||
// На грань — всегда фиксированная ориентация (не billboard), иначе
|
||||
// «связки с примитивом» не будет (плашка крутилась бы к камере).
|
||||
plane.billboardMode = 0;
|
||||
const gap = Number.isFinite(opts.height) ? opts.height : 0.05;
|
||||
// ВАЖНО: Babylon CreatePlane (FRONTSIDE) лицевой стороной (где UV/текст
|
||||
// не зеркалятся) смотрит в −Z. Поэтому чтобы ЛИЦО таблички смотрело
|
||||
// НАРУЖУ выбранной грани, поворачиваем плоскость так, чтобы её −Z
|
||||
// совпал с внешней нормалью грани. tiltSign — знак наклона tilt с
|
||||
// учётом того, что для грани +z плоскость развёрнута на π.
|
||||
let tiltSign = 1;
|
||||
if (face === '+z') { plane.position.set(0, 0, halfZ + gap); plane.rotation.set(0, Math.PI, 0); tiltSign = -1; }
|
||||
else if (face === '-z') { plane.position.set(0, 0, -(halfZ + gap)); plane.rotation.set(0, 0, 0); }
|
||||
else if (face === '+x') { plane.position.set( halfX + gap, 0, 0); plane.rotation.set(0, -Math.PI / 2, 0); }
|
||||
else if (face === '-x') { plane.position.set(-(halfX + gap), 0, 0); plane.rotation.set(0, Math.PI / 2, 0); }
|
||||
else if (face === '+y') { plane.position.set(0, halfY + gap, 0); plane.rotation.set( Math.PI / 2, 0, 0); }
|
||||
else if (face === '-y') { plane.position.set(0, -(halfY + gap), 0); plane.rotation.set(-Math.PI / 2, 0, 0); }
|
||||
if (Number.isFinite(st.rotationY)) plane.rotation.y += st.rotationY;
|
||||
// tilt — наклон таблички вокруг локальной X (ценник под ~45°, как на
|
||||
// витрине Roblox). Знак нормализуем (tiltSign), чтобы «верх назад» был
|
||||
// одинаковым для всех граней. Отрицательный tilt = верх отклоняется
|
||||
// назад (от наблюдателя), как пюпитр.
|
||||
if (Number.isFinite(st.tilt)) plane.rotation.x += st.tilt * tiltSign;
|
||||
} else {
|
||||
// faceMode: 'fixed' — фиксированная ориентация (вращается с объектом),
|
||||
// но позиционируется как обычная плашка (над верхом/центром/низом).
|
||||
if (st.faceMode === 'fixed') {
|
||||
plane.billboardMode = 0;
|
||||
if (Number.isFinite(st.rotationY)) plane.rotation.y = st.rotationY;
|
||||
} else {
|
||||
plane.billboardMode = 7; // всегда лицом к камере
|
||||
}
|
||||
// attachPoint: 'top'(default) — над верхом + небольшой зазор (height);
|
||||
// 'center' — по центру; 'bottom' — у низа; {x,y,z} — явно.
|
||||
const gap = Number.isFinite(opts.height) ? opts.height : 0.6;
|
||||
let py = halfH + gap + halfPlane; // верх объекта + зазор + полувысота плашки
|
||||
if (st.attachPoint === 'center') py = 0;
|
||||
else if (st.attachPoint === 'bottom') py = -(halfH + gap);
|
||||
else if (st.attachPoint && typeof st.attachPoint === 'object') {
|
||||
plane.position.set(st.attachPoint.x || 0, st.attachPoint.y || 0, st.attachPoint.z || 0);
|
||||
py = null;
|
||||
}
|
||||
if (py !== null) plane.position.set(0, py, 0);
|
||||
}
|
||||
|
||||
this.labels.set(ref, {
|
||||
plane, tex, mat,
|
||||
lastKey: styleKey,
|
||||
lastText: text,
|
||||
styleStruct: this._structKey(st, richText, heightAbove, sizeMul),
|
||||
maxDistance: Number.isFinite(opts.maxDistance) ? opts.maxDistance : null,
|
||||
});
|
||||
}
|
||||
|
||||
/** Убрать метку с объекта. */
|
||||
/** Ключ «структуры» (всё кроме текста) — для решения перерисовать ли canvas. */
|
||||
_structKey(st, richText, h, sz) {
|
||||
return JSON.stringify({ p: st.preset, bg: st.background, bc: st.borderColor,
|
||||
bw: st.borderWidth, cr: st.cornerRadius, rich: richText, fw: st.fontWeight,
|
||||
grad: st.gradient, ts: st.textStroke, h, sz, fm: st.faceMode,
|
||||
af: st.attachFace, tl: st.tilt, ap: typeof st.attachPoint === 'string' ? st.attachPoint : null });
|
||||
}
|
||||
|
||||
_uid() { this._seq = (this._seq || 0) + 1; return this._seq; }
|
||||
|
||||
/**
|
||||
* Нарисовать плашку на canvas DynamicTexture.
|
||||
* Фон (roundRect + gradient/fill) → обводка border → текст (с обводкой).
|
||||
*/
|
||||
_drawCanvas(tex, text, color, st, richText) {
|
||||
const W = 1024, H = 256;
|
||||
const ctx = tex.getContext();
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
const hasBg = !!st.background || (Array.isArray(st.gradient) && st.gradient.length === 2);
|
||||
const pad = Number.isFinite(st.padding) ? st.padding : 28;
|
||||
const cr = Number.isFinite(st.cornerRadius) ? st.cornerRadius : 0;
|
||||
const bw = Number.isFinite(st.borderWidth) ? st.borderWidth : 0;
|
||||
|
||||
const weight = st.fontWeight || 700;
|
||||
const innerPad = (hasBg ? (bw + pad) : 24); // отступ от краёв (под рамку)
|
||||
const maxTextW = W - innerPad * 2;
|
||||
// Auto-fit: подбираем размер шрифта, чтобы текст влез по ширине (не обрезался).
|
||||
let fontPx = 120;
|
||||
if (!richText) {
|
||||
ctx.font = `${weight} ${fontPx}px "Roboto Condensed", "Segoe UI", sans-serif`;
|
||||
const tw = ctx.measureText(text).width;
|
||||
if (tw > maxTextW) fontPx = Math.max(40, Math.floor(fontPx * maxTextW / tw));
|
||||
}
|
||||
ctx.font = `${weight} ${fontPx}px "Roboto Condensed", "Segoe UI", sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
// === Фон-плашка ===
|
||||
if (hasBg) {
|
||||
const m = bw / 2 + 4; // отступ рамки от края текстуры
|
||||
const x = m, y = m, w = W - m * 2, h = H - m * 2;
|
||||
this._roundRectPath(ctx, x, y, w, h, cr);
|
||||
if (Array.isArray(st.gradient) && st.gradient.length === 2) {
|
||||
const g = ctx.createLinearGradient(0, y, 0, y + h);
|
||||
g.addColorStop(0, st.gradient[0]);
|
||||
g.addColorStop(1, st.gradient[1]);
|
||||
ctx.fillStyle = g;
|
||||
} else {
|
||||
ctx.fillStyle = st.background;
|
||||
}
|
||||
ctx.globalAlpha = Number.isFinite(st.backgroundOpacity) ? st.backgroundOpacity : 0.92;
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 1;
|
||||
if (bw > 0 && st.borderColor) {
|
||||
ctx.lineWidth = bw;
|
||||
ctx.strokeStyle = st.borderColor;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// === Текст ===
|
||||
const ts = st.textStroke || { color: '#000', width: hasBg ? 6 : 10 };
|
||||
if (richText) {
|
||||
this._drawRichText(ctx, text, color, ts, W, H);
|
||||
} else {
|
||||
if (ts && ts.width > 0) {
|
||||
ctx.lineWidth = ts.width;
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.strokeStyle = ts.color || '#000';
|
||||
ctx.strokeText(text, W / 2, H / 2 + 4);
|
||||
}
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillText(text, W / 2, H / 2 + 4);
|
||||
}
|
||||
}
|
||||
|
||||
/** Путь скруглённого прямоугольника (roundRect не везде есть). */
|
||||
_roundRectPath(ctx, x, y, w, h, r) {
|
||||
r = Math.min(r, w / 2, h / 2);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||||
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||||
ctx.arcTo(x, y + h, x, y, r);
|
||||
ctx.arcTo(x, y, x + w, y, r);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
/**
|
||||
* RichText: парсим теги <color=#hex>...</color>, <b>...</b>, <size=N>...</size>.
|
||||
* Рисуем сегментами по горизонтали, центрируя всю строку. Вложенность не
|
||||
* поддерживается (на MVP) — берём последний открытый тег каждого типа.
|
||||
*/
|
||||
_drawRichText(ctx, text, baseColor, ts, W, H) {
|
||||
const segs = this._parseRich(text, baseColor);
|
||||
const fontPx = 120;
|
||||
// Замер ширины каждого сегмента в его размере.
|
||||
let total = 0;
|
||||
for (const s of segs) {
|
||||
ctx.font = `${s.bold ? 800 : 700} ${Math.round(fontPx * s.sizeMul)}px "Roboto Condensed","Segoe UI",sans-serif`;
|
||||
s.w = ctx.measureText(s.text).width;
|
||||
total += s.w;
|
||||
}
|
||||
let x = (W - total) / 2;
|
||||
for (const s of segs) {
|
||||
ctx.font = `${s.bold ? 800 : 700} ${Math.round(fontPx * s.sizeMul)}px "Roboto Condensed","Segoe UI",sans-serif`;
|
||||
ctx.textAlign = 'left';
|
||||
if (ts && ts.width > 0) {
|
||||
ctx.lineWidth = ts.width;
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.strokeStyle = ts.color || '#000';
|
||||
ctx.strokeText(s.text, x, H / 2 + 4);
|
||||
}
|
||||
ctx.fillStyle = s.color;
|
||||
ctx.fillText(s.text, x, H / 2 + 4);
|
||||
x += s.w;
|
||||
}
|
||||
ctx.textAlign = 'center';
|
||||
}
|
||||
|
||||
/** Простой парсер richText → [{text, color, bold, sizeMul}]. */
|
||||
_parseRich(text, baseColor) {
|
||||
const segs = [];
|
||||
let color = baseColor, bold = false, sizeMul = 1;
|
||||
// Разбиваем по тегам (открывающим/закрывающим).
|
||||
const re = /<(\/?)(?:(color)=([#0-9a-fA-F]+)|(b)|(i)|(size)=(\d+))>|([^<]+)/g;
|
||||
let m;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
const closing = m[1] === '/';
|
||||
if (m[8] != null) {
|
||||
// текстовый кусок
|
||||
if (m[8]) segs.push({ text: m[8], color, bold, sizeMul });
|
||||
} else if (m[2]) { // <color=...>
|
||||
color = closing ? baseColor : m[3];
|
||||
} else if (m[4]) { // <b>
|
||||
bold = !closing;
|
||||
} else if (m[6]) { // <size=N>
|
||||
sizeMul = closing ? 1 : Math.max(0.4, Math.min(2, Number(m[7]) / 100));
|
||||
}
|
||||
// <i> игнорим визуально (italic в canvas через font-style — опускаем на MVP)
|
||||
}
|
||||
if (segs.length === 0) segs.push({ text: '', color: baseColor, bold: false, sizeMul: 1 });
|
||||
return segs;
|
||||
}
|
||||
|
||||
/** Каждый кадр: maxDistance-скрытие плашек дальше порога от игрока. */
|
||||
update() {
|
||||
if (!this._playerMesh) return;
|
||||
const pp = this._playerMesh.position;
|
||||
for (const rec of this.labels.values()) {
|
||||
if (rec.maxDistance == null) continue;
|
||||
const ap = rec.plane.getAbsolutePosition();
|
||||
const dx = ap.x - pp.x, dy = ap.y - pp.y, dz = ap.z - pp.z;
|
||||
const far = (dx * dx + dy * dy + dz * dz) > rec.maxDistance * rec.maxDistance;
|
||||
rec.plane.setEnabled(!far);
|
||||
}
|
||||
}
|
||||
|
||||
/** Убрать плашку с объекта. */
|
||||
clearLabel(ref) {
|
||||
const rec = this.labels.get(ref);
|
||||
if (!rec) return;
|
||||
@ -84,7 +389,7 @@ export class LabelManager {
|
||||
this.labels.delete(ref);
|
||||
}
|
||||
|
||||
/** Удалить все метки (при выходе из Play). */
|
||||
/** Удалить все плашки (при выходе из Play). */
|
||||
clearAll() {
|
||||
for (const ref of [...this.labels.keys()]) this.clearLabel(ref);
|
||||
}
|
||||
|
||||
177
src/engine/RbxlHudOverlay.js
Normal file
177
src/engine/RbxlHudOverlay.js
Normal file
@ -0,0 +1,177 @@
|
||||
/**
|
||||
* RbxlHudOverlay — DOM-оверлей с HUD-элементами для импортированных
|
||||
* Roblox-карт: KillFeed (правый верх), Message/Hint (центр), WinGui.
|
||||
*
|
||||
* Один контейнер `.rbxl-hud-overlay` на canvas.parentElement, в нём дочерние
|
||||
* блоки по типу. Стили inline, ничего не зависит от CSS приложения.
|
||||
*
|
||||
* API:
|
||||
* const hud = new RbxlHudOverlay(canvasParent);
|
||||
* hud.addKillFeed(killer, victim, weapon)
|
||||
* hud.showMessage(text, opts)
|
||||
* hud.hideMessage()
|
||||
* hud.showWin(text)
|
||||
* hud.dispose()
|
||||
*/
|
||||
|
||||
export class RbxlHudOverlay {
|
||||
constructor(parent) {
|
||||
this._parent = parent || document.body;
|
||||
this._root = null;
|
||||
this._killFeed = null;
|
||||
this._message = null;
|
||||
this._winBox = null;
|
||||
this._killEntries = []; // [{el, expireAt}]
|
||||
this._mount();
|
||||
}
|
||||
|
||||
_mount() {
|
||||
if (this._root) return;
|
||||
const root = document.createElement('div');
|
||||
root.className = 'rbxl-hud-overlay';
|
||||
Object.assign(root.style, {
|
||||
position: 'absolute',
|
||||
inset: '0',
|
||||
pointerEvents: 'none',
|
||||
zIndex: '999',
|
||||
fontFamily: 'system-ui, -apple-system, "Segoe UI", sans-serif',
|
||||
});
|
||||
this._parent.appendChild(root);
|
||||
this._root = root;
|
||||
|
||||
// KillFeed — правый верхний угол
|
||||
const kf = document.createElement('div');
|
||||
Object.assign(kf.style, {
|
||||
position: 'absolute',
|
||||
top: '60px',
|
||||
right: '12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '6px',
|
||||
maxWidth: '320px',
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
root.appendChild(kf);
|
||||
this._killFeed = kf;
|
||||
|
||||
// Message — центр сверху (Roblox Message по центру экрана,
|
||||
// но в верхней трети чтобы не мешать игре)
|
||||
const msg = document.createElement('div');
|
||||
Object.assign(msg.style, {
|
||||
position: 'absolute',
|
||||
top: '15%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
padding: '10px 24px',
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
color: '#fff',
|
||||
fontSize: '22px',
|
||||
fontWeight: '600',
|
||||
borderRadius: '6px',
|
||||
textShadow: '0 2px 4px rgba(0,0,0,0.8)',
|
||||
display: 'none',
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
root.appendChild(msg);
|
||||
this._message = msg;
|
||||
|
||||
// WinGui — большая надпись по центру
|
||||
const win = document.createElement('div');
|
||||
Object.assign(win.style, {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
padding: '24px 48px',
|
||||
background: 'rgba(0,0,0,0.75)',
|
||||
color: '#ffd86b',
|
||||
fontSize: '48px',
|
||||
fontWeight: '800',
|
||||
borderRadius: '12px',
|
||||
textShadow: '0 4px 8px rgba(0,0,0,0.8)',
|
||||
display: 'none',
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
root.appendChild(win);
|
||||
this._winBox = win;
|
||||
|
||||
// Тик для авто-исчезновения KillFeed entries (через 5с)
|
||||
this._tickInterval = setInterval(() => this._cleanupKills(), 500);
|
||||
}
|
||||
|
||||
addKillFeed(killer, victim, weapon) {
|
||||
if (!this._killFeed) return;
|
||||
const entry = document.createElement('div');
|
||||
Object.assign(entry.style, {
|
||||
background: 'rgba(0,0,0,0.55)',
|
||||
color: '#fff',
|
||||
padding: '6px 10px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '13px',
|
||||
display: 'flex',
|
||||
gap: '6px',
|
||||
alignItems: 'center',
|
||||
animation: 'rbxlHudFadeIn 0.3s',
|
||||
});
|
||||
const killerEl = document.createElement('span');
|
||||
killerEl.textContent = String(killer || '?');
|
||||
killerEl.style.color = '#5bd1e8';
|
||||
const arrow = document.createElement('span');
|
||||
arrow.textContent = weapon ? `→ [${weapon}] →` : '→';
|
||||
arrow.style.color = '#ff9a52';
|
||||
const victimEl = document.createElement('span');
|
||||
victimEl.textContent = String(victim || '?');
|
||||
victimEl.style.color = '#f87a7a';
|
||||
entry.appendChild(killerEl);
|
||||
entry.appendChild(arrow);
|
||||
entry.appendChild(victimEl);
|
||||
this._killFeed.appendChild(entry);
|
||||
this._killEntries.push({ el: entry, expireAt: performance.now() + 5000 });
|
||||
// Keep only last 8
|
||||
while (this._killEntries.length > 8) {
|
||||
const old = this._killEntries.shift();
|
||||
try { old.el.remove(); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
_cleanupKills() {
|
||||
const now = performance.now();
|
||||
const keep = [];
|
||||
for (const e of this._killEntries) {
|
||||
if (e.expireAt < now) { try { e.el.remove(); } catch (_) {} }
|
||||
else keep.push(e);
|
||||
}
|
||||
this._killEntries = keep;
|
||||
}
|
||||
|
||||
showMessage(text, opts = {}) {
|
||||
if (!this._message) return;
|
||||
this._message.textContent = String(text || '');
|
||||
this._message.style.display = text ? 'block' : 'none';
|
||||
if (opts.duration) {
|
||||
clearTimeout(this._msgTimer);
|
||||
this._msgTimer = setTimeout(() => this.hideMessage(), opts.duration);
|
||||
}
|
||||
}
|
||||
|
||||
hideMessage() {
|
||||
if (this._message) this._message.style.display = 'none';
|
||||
}
|
||||
|
||||
showWin(text) {
|
||||
if (!this._winBox) return;
|
||||
this._winBox.textContent = String(text || '');
|
||||
this._winBox.style.display = 'block';
|
||||
// Auto-hide через 6с
|
||||
clearTimeout(this._winTimer);
|
||||
this._winTimer = setTimeout(() => { if (this._winBox) this._winBox.style.display = 'none'; }, 6000);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
try { this._root?.remove(); } catch (_) {}
|
||||
clearInterval(this._tickInterval);
|
||||
clearTimeout(this._msgTimer);
|
||||
clearTimeout(this._winTimer);
|
||||
this._root = null;
|
||||
}
|
||||
}
|
||||
337
src/engine/lua/LuaSharedSandbox.js
Normal file
337
src/engine/lua/LuaSharedSandbox.js
Normal file
@ -0,0 +1,337 @@
|
||||
/**
|
||||
* LuaSharedSandbox (v3, main-thread) — wasmoon-VM работает в MAIN потоке,
|
||||
* без Web Worker. Это позволяет:
|
||||
* - Видеть точные Lua-ошибки в DevTools (через console.error)
|
||||
* - Использовать debugger / breakpoints прямо в RobloxShim.js
|
||||
* - Не возиться с молчаливыми Worker-падениями
|
||||
*
|
||||
* Цена: тяжёлые Lua-скрипты могут блокировать UI. Для KillBrick-style
|
||||
* скриптов это нестрашно — они быстрые.
|
||||
*
|
||||
* API совместим с ScriptSandbox: setOnCommand / sendEvent / sendGlobalEvent /
|
||||
* sendSceneSnapshot / sendGuiSnapshot / sendDataSnapshot / sendSkinsSnapshot /
|
||||
* sendTerrainHeightmap / stop / tick / target.
|
||||
*
|
||||
* Что добавлено сверх ScriptSandbox:
|
||||
* - addScript(id, code, target) — добавить скрипт в общий VM. Можно
|
||||
* до или после start().
|
||||
* - start() — асинхронен (createEngine), но возвращает сразу. После init
|
||||
* стартует main loop (Heartbeat + scheduler).
|
||||
*/
|
||||
|
||||
import { LuaFactory } from 'wasmoon';
|
||||
import { registerRobloxShim } from './RobloxShim.js';
|
||||
|
||||
export class LuaSharedSandbox {
|
||||
constructor() {
|
||||
this.vm = null;
|
||||
this.api = null;
|
||||
this._onCommand = null;
|
||||
this._isReady = false;
|
||||
this._isStopped = false;
|
||||
this._isKickedOff = false;
|
||||
this._pendingScripts = []; // [{id, code, target, name}]
|
||||
this._scriptsById = new Map();
|
||||
this._scenes = null;
|
||||
this._guiTree = null;
|
||||
this._loopHandle = null;
|
||||
this._lastTickAt = 0;
|
||||
// Маркер для GameRuntime.routeEvent — этот sandbox принимает все
|
||||
// события и сам маршрутизирует через shim.fireTargetEvent.
|
||||
this._luaShared = true;
|
||||
}
|
||||
|
||||
setOnCommand(cb) { this._onCommand = cb; }
|
||||
|
||||
get target() { return null; }
|
||||
tick(_dt, _state) { /* no-op: main-loop запускается изнутри (setInterval) */ }
|
||||
|
||||
addScript(id, code, target, name, extra) {
|
||||
const entry = {
|
||||
id: String(id || `lua_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`),
|
||||
code: String(code || ''),
|
||||
target: target == null ? null : target,
|
||||
name: name || null,
|
||||
toolName: extra?.toolName || null,
|
||||
};
|
||||
this._scriptsById.set(entry.id, entry);
|
||||
if (!this._isKickedOff) {
|
||||
this._pendingScripts.push(entry);
|
||||
} else {
|
||||
this._startSingleScript(entry);
|
||||
}
|
||||
}
|
||||
|
||||
removeScript(id) {
|
||||
this._scriptsById.delete(String(id));
|
||||
}
|
||||
|
||||
/** Стартует VM, регистрирует shim, запускает main-loop. */
|
||||
start() {
|
||||
if (this.vm || this._isStopped) return;
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[LuaSharedSandbox v3] starting Lua VM in main thread...');
|
||||
this._initAsync().catch((err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[LuaSharedSandbox] FATAL init error:', err);
|
||||
this._emit('log', { level: 'error', text: `Lua-runtime init: ${err?.message || err}` });
|
||||
});
|
||||
}
|
||||
|
||||
async _initAsync() {
|
||||
const factory = new LuaFactory();
|
||||
this.vm = await factory.createEngine({ openStandardLibs: true });
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[LuaSharedSandbox] VM created. Registering Roblox shim...');
|
||||
|
||||
// Адаптер для shim'а: он ожидает send(cmd, payload), getSceneSnapshot, getGuiTree, scheduleWait.
|
||||
const send = (cmd, payload) => this._emit(cmd, payload);
|
||||
|
||||
this.api = registerRobloxShim(this.vm, {
|
||||
send,
|
||||
getSceneSnapshot: () => this._scenes,
|
||||
getGuiTree: () => this._guiTree,
|
||||
scheduleWait: () => null,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[LuaSharedSandbox] Shim registered. api keys:', Object.keys(this.api || {}));
|
||||
|
||||
// Применим snapshot если он есть
|
||||
if (this._scenes && this.api?.onSceneSnapshot) {
|
||||
try { this.api.onSceneSnapshot(this._scenes); } catch (e) {
|
||||
console.error('[LuaSharedSandbox] onSceneSnapshot:', e);
|
||||
}
|
||||
}
|
||||
|
||||
this._isReady = true;
|
||||
this._kickoff();
|
||||
}
|
||||
|
||||
_kickoff() {
|
||||
if (this._isKickedOff || this._isStopped) return;
|
||||
this._isKickedOff = true;
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[LuaSharedSandbox] kickoff: starting ${this._pendingScripts.length} scripts`);
|
||||
const pending = this._pendingScripts;
|
||||
this._pendingScripts = [];
|
||||
// Запускаем main-loop сразу — он начнёт tick'ать как только будут coroutines.
|
||||
this._lastTickAt = performance.now();
|
||||
this._startMainLoop();
|
||||
// Init батчами по 5 с задержкой 20мс между ними, чтобы UI отзывался.
|
||||
const BATCH_SIZE = 5;
|
||||
let idx = 0;
|
||||
const initBatch = () => {
|
||||
if (this._isStopped) return;
|
||||
const end = Math.min(idx + BATCH_SIZE, pending.length);
|
||||
for (let i = idx; i < end; i++) {
|
||||
try { this._startSingleScript(pending[i]); }
|
||||
catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[LuaSharedSandbox] init batch err:', e);
|
||||
}
|
||||
}
|
||||
idx = end;
|
||||
if (idx < pending.length) {
|
||||
setTimeout(initBatch, 20);
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[LuaSharedSandbox] all ${pending.length} scripts kicked off`);
|
||||
// После того как все скрипты подключили хендлеры — фейрим
|
||||
// events для уже существующих сущностей. Roblox-конвенция:
|
||||
// если игрок уже на сервере когда скрипт подключается,
|
||||
// Players.PlayerAdded не сработает повторно. Юзеру нужно
|
||||
// делать ручной обход GetPlayers() — но это редко кто помнит.
|
||||
// Мы дублируем событие через короткую задержку.
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (this.api?.fireExistingPlayers) {
|
||||
this.api.fireExistingPlayers();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[LuaSharedSandbox] fireExistingPlayers failed:', e);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
setTimeout(initBatch, 0);
|
||||
}
|
||||
|
||||
_startSingleScript(entry) {
|
||||
if (!this.vm || !entry || typeof entry.code !== 'string') return;
|
||||
let primId = null;
|
||||
if (typeof entry.target === 'number') primId = entry.target;
|
||||
else if (entry.target && typeof entry.target === 'object') {
|
||||
if (entry.target.kind === 'primitive') primId = entry.target.id ?? entry.target.ref;
|
||||
}
|
||||
const safeId = entry.id.replace(/[^a-zA-Z0-9_]/g, '_');
|
||||
const scriptName = entry.name || `Script_${safeId}`;
|
||||
// Скрипт оборачиваем в coroutine — это позволяет task.wait через yield.
|
||||
// Резюмим coroutine из main-loop когда наступило время.
|
||||
// Регистрируем coroutine в __rbxl_coroutines с id для возобновления.
|
||||
// Скрипт оборачиваем в coroutine. task.wait()→coroutine.yield(sec) возвращает
|
||||
// delay из resume → планируем следующий resume через scheduleResume.
|
||||
// Fallback Parent: если скрипт связан с Tool (по имени Tool из metadata) —
|
||||
// подсовываем виртуальный Tool как script.Parent. Иначе primitive по id,
|
||||
// иначе workspace.
|
||||
let parentExpr;
|
||||
if (entry.toolName) {
|
||||
// Tool создаётся в shim как Instance.new('Tool'). По имени достаём.
|
||||
// Если не нашли — fallback на новый Tool того же имени.
|
||||
const safeName = JSON.stringify(entry.toolName);
|
||||
parentExpr = `(function()
|
||||
local existing = __rbxl_get_tool_by_name(${safeName})
|
||||
if existing then return existing end
|
||||
local t = Instance.new("Tool")
|
||||
t.Name = ${safeName}
|
||||
return t
|
||||
end)()`;
|
||||
} else if (primId != null) {
|
||||
parentExpr = `(__rbxl_get_part_by_id(${Number(primId)}) or workspace)`;
|
||||
} else {
|
||||
parentExpr = 'workspace';
|
||||
}
|
||||
const wrapped = `
|
||||
do
|
||||
-- Если parentExpr вернул primitive — у него уже есть :FindFirstChild и пр.
|
||||
-- Если ничего не вернёт — workspace (всегда валидный).
|
||||
-- script.Parent.Parent (Tool.Parent = StarterPack / Backpack / workspace).
|
||||
local _scriptParent = ${parentExpr}
|
||||
if _scriptParent == nil then _scriptParent = workspace end
|
||||
if _scriptParent.Parent == nil then _scriptParent.Parent = workspace end
|
||||
local script = setmetatable({
|
||||
Name = ${JSON.stringify(scriptName)},
|
||||
Parent = _scriptParent,
|
||||
ClassName = "Script",
|
||||
Disabled = false,
|
||||
Source = nil,
|
||||
}, {
|
||||
-- Любой доступ к несуществующему полю → workspace
|
||||
-- (на случай script.Foo:Bar() в старом коде)
|
||||
__index = function(t, k)
|
||||
if k == "FindFirstChild" or k == "WaitForChild" or k == "GetChildren" then
|
||||
return function() return nil end
|
||||
end
|
||||
return workspace[k]
|
||||
end,
|
||||
})
|
||||
local co = coroutine.create(function()
|
||||
-- WATCHDOG: каждые 100000 инструкций — yield 1 кадр.
|
||||
-- НЕ оборачиваем в pcall — внутри C-call boundary yield
|
||||
-- упадёт ошибкой, что прервёт скрипт. Это лучше чем виснуть.
|
||||
debug.sethook(function()
|
||||
coroutine.yield(0.016)
|
||||
end, "", 20000)
|
||||
-- pcall защищает от runtime-ошибок которые иначе крашат
|
||||
-- coroutine и могут повредить WASM-стейт. Возвраты
|
||||
-- handler'а намеренно поглощаются.
|
||||
local ok_, err_ = pcall(function()
|
||||
${entry.code}
|
||||
end)
|
||||
if not ok_ then
|
||||
__rbxl_send_error(${JSON.stringify(entry.id)}, tostring(err_))
|
||||
end
|
||||
end)
|
||||
__rbxl_register_coroutine(${JSON.stringify(entry.id)}, co)
|
||||
local ok, ret = coroutine.resume(co)
|
||||
if not ok then
|
||||
__rbxl_send_error(${JSON.stringify(entry.id)}, tostring(ret))
|
||||
__rbxl_unregister_coroutine(${JSON.stringify(entry.id)})
|
||||
elseif type(ret) == 'number' then
|
||||
-- скрипт yield'нул с delay (через task.wait) — планируем resume
|
||||
__rbxl_schedule_resume(${JSON.stringify(entry.id)}, ret)
|
||||
elseif coroutine.status(co) == 'dead' then
|
||||
__rbxl_unregister_coroutine(${JSON.stringify(entry.id)})
|
||||
end
|
||||
end
|
||||
`;
|
||||
try {
|
||||
this.vm.doStringSync(wrapped);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[LuaSharedSandbox] script ${entry.id} initialized OK`);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[LuaSharedSandbox] script ${entry.id} init FAILED:`, err);
|
||||
this._emit('log', { level: 'error', text: `[Lua ${entry.id}] ${err?.message || err}` });
|
||||
}
|
||||
}
|
||||
|
||||
_startMainLoop() {
|
||||
const tick = () => {
|
||||
if (this._isStopped) return;
|
||||
try {
|
||||
const now = performance.now();
|
||||
const dt = Math.min(0.1, (now - this._lastTickAt) / 1000);
|
||||
this._lastTickAt = now;
|
||||
if (this.api?.tickScheduler) this.api.tickScheduler(dt);
|
||||
if (this.api?.fireHeartbeat) this.api.fireHeartbeat(dt);
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[LuaSharedSandbox tick]', e);
|
||||
}
|
||||
this._loopHandle = setTimeout(tick, 16);
|
||||
};
|
||||
this._loopHandle = setTimeout(tick, 16);
|
||||
}
|
||||
|
||||
_emit(cmd, payload) {
|
||||
if (typeof this._onCommand === 'function') {
|
||||
try { this._onCommand({ cmd, payload }); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
// ----- API совместимый с ScriptSandbox -----
|
||||
sendEvent(payload) {
|
||||
if (!this.api?.fireTargetEvent || !this._isReady) return;
|
||||
try { this.api.fireTargetEvent(payload); } catch (e) {
|
||||
console.error('[LuaSharedSandbox] sendEvent:', e);
|
||||
}
|
||||
}
|
||||
|
||||
sendGlobalEvent(payload) {
|
||||
if (!this.api?.fireGlobalEvent || !this._isReady) return;
|
||||
try { this.api.fireGlobalEvent(payload); } catch (e) {
|
||||
console.error('[LuaSharedSandbox] sendGlobalEvent:', e);
|
||||
}
|
||||
}
|
||||
|
||||
sendSceneSnapshot(snapshot) {
|
||||
this._scenes = snapshot;
|
||||
if (this.api?.onSceneSnapshot && this._isReady) {
|
||||
try { this.api.onSceneSnapshot(snapshot); } catch (e) {
|
||||
console.error('[LuaSharedSandbox] onSceneSnapshot:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sendGuiSnapshot(snapshot) {
|
||||
this._guiTree = snapshot;
|
||||
if (this.api?.onGuiSnapshot && this._isReady) {
|
||||
try { this.api.onGuiSnapshot(snapshot); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
sendDataSnapshot(snapshot) {
|
||||
if (this.api?.onDataSnapshot && this._isReady) {
|
||||
try { this.api.onDataSnapshot(snapshot); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
sendSkinsSnapshot(_) { /* no-op для Этапа 3 */ }
|
||||
sendTerrainHeightmap(_) { /* no-op */ }
|
||||
|
||||
stop() {
|
||||
this._isStopped = true;
|
||||
if (this._loopHandle) {
|
||||
clearTimeout(this._loopHandle);
|
||||
this._loopHandle = null;
|
||||
}
|
||||
if (this.vm) {
|
||||
try { this.vm.global.close(); } catch (_) {}
|
||||
this.vm = null;
|
||||
}
|
||||
this.api = null;
|
||||
}
|
||||
}
|
||||
|
||||
export default LuaSharedSandbox;
|
||||
2500
src/engine/lua/RobloxShim.js
Normal file
2500
src/engine/lua/RobloxShim.js
Normal file
File diff suppressed because it is too large
Load Diff
210
src/engine/rbxl-lua-integration.js
Normal file
210
src/engine/rbxl-lua-integration.js
Normal file
@ -0,0 +1,210 @@
|
||||
/**
|
||||
* rbxl-lua-integration.js — вспомогательные функции для импорта .rbxl-карт.
|
||||
*
|
||||
* Старый отдельный Worker-based runtime удалён (2026-06-08): импортированные
|
||||
* Lua-скрипты теперь идут через тот же LuaSharedSandbox что и user-Lua
|
||||
* (см. GameRuntime.start()). Этот файл оставлен только для:
|
||||
* - unpackRobloxLuaCode() — распаковка Lua из JS-комментария-обёртки;
|
||||
* - handleLuaCommand() — обработка partSet/sceneCreate/sceneDelete/playerCmd
|
||||
* команд от Lua-VM в BabylonScene.
|
||||
*/
|
||||
|
||||
/** Распаковка lua_source из packed-кода. */
|
||||
export function unpackRobloxLuaCode(code) {
|
||||
const openTag = '/[*] lua_source:\n'.replace('[*]', '*');
|
||||
const i = code.indexOf(openTag);
|
||||
if (i < 0) return null;
|
||||
const start = i + openTag.length;
|
||||
const closeIdx = code.lastIndexOf('\n*' + '/');
|
||||
if (closeIdx < start) return null;
|
||||
return code.slice(start, closeIdx);
|
||||
}
|
||||
|
||||
/** Парсит JSON-метадату из 2-й строки packed-кода (`// {"roblox_class":..., "enabled": true}`). */
|
||||
export function parseRobloxLuaMeta(code) {
|
||||
if (typeof code !== 'string') return null;
|
||||
const lines = code.split('\n');
|
||||
if (lines.length < 2) return null;
|
||||
const metaLine = lines[1];
|
||||
if (!metaLine.startsWith('// ')) return null;
|
||||
try {
|
||||
return JSON.parse(metaLine.slice(3));
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Сцена → snap для shim'а (workspace:GetChildren). */
|
||||
export function buildLuaSceneSnap(primitives) {
|
||||
const out = { primitives: {} };
|
||||
if (!Array.isArray(primitives)) return out;
|
||||
for (const p of primitives) {
|
||||
out.primitives[p.id] = {
|
||||
id: p.id, type: p.type, name: p.name,
|
||||
x: p.x, y: p.y, z: p.z,
|
||||
sx: p.sx, sy: p.sy, sz: p.sz,
|
||||
color: p.color, material: p.material,
|
||||
anchored: !!p.anchored, canCollide: p.canCollide !== false,
|
||||
opacity: typeof p.opacity === 'number' ? p.opacity : 1,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* GUI-tree для shim'а. Mapping origin → __roblox_class.
|
||||
* scene.gui — массив элементов с {id, type, name, parentId, ...origin}.
|
||||
* Возвращаем массив сохраняя порядок parent → child (важно для tree-сборки).
|
||||
*/
|
||||
export function buildLuaGuiTree(guiElements) {
|
||||
if (!Array.isArray(guiElements)) return [];
|
||||
const out = [];
|
||||
for (const el of guiElements) {
|
||||
// origin = 'roblox-textbutton' → 'TextButton'
|
||||
let rblClass = 'Frame';
|
||||
const origin = el.origin || '';
|
||||
if (origin.startsWith('roblox-')) {
|
||||
const tail = origin.slice(7);
|
||||
rblClass = tail.charAt(0).toUpperCase() + tail.slice(1);
|
||||
// Camel-case "textbutton" → "TextButton"
|
||||
if (rblClass.toLowerCase() === 'textbutton') rblClass = 'TextButton';
|
||||
else if (rblClass.toLowerCase() === 'textlabel') rblClass = 'TextLabel';
|
||||
else if (rblClass.toLowerCase() === 'imagebutton') rblClass = 'ImageButton';
|
||||
else if (rblClass.toLowerCase() === 'imagelabel') rblClass = 'ImageLabel';
|
||||
else if (rblClass.toLowerCase() === 'textbox') rblClass = 'TextBox';
|
||||
else if (rblClass.toLowerCase() === 'scrollingframe') rblClass = 'ScrollingFrame';
|
||||
else if (rblClass.toLowerCase() === 'frame') rblClass = 'Frame';
|
||||
} else {
|
||||
// Если origin не задан — гадаем по type
|
||||
const t = el.type;
|
||||
if (t === 'button') rblClass = 'TextButton';
|
||||
else if (t === 'text') rblClass = 'TextLabel';
|
||||
else if (t === 'image') rblClass = 'ImageLabel';
|
||||
else if (t === 'textbox') rblClass = 'TextBox';
|
||||
}
|
||||
out.push({
|
||||
id: el.id,
|
||||
name: el.name || rblClass,
|
||||
parentId: el.parentId || null,
|
||||
visible: el.visible !== false,
|
||||
text: el.text || '',
|
||||
__roblox_class: rblClass,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка IPC команд от worker'а — мапим на действия в Babylon-сцене.
|
||||
*/
|
||||
export function handleLuaCommand(_scriptId, cmd, payload, runtime) {
|
||||
if (cmd === 'log') {
|
||||
const fn = payload?.level === 'error' ? console.error
|
||||
: payload?.level === 'warn' ? console.warn : console.log;
|
||||
fn('[rbxl-lua]', payload?.text || '');
|
||||
return;
|
||||
}
|
||||
if (cmd === 'partSet') {
|
||||
const pm = runtime.scene3d?.primitiveManager;
|
||||
if (!pm) {
|
||||
console.warn('[partSet] no primitiveManager. scene3d=', !!runtime.scene3d);
|
||||
return;
|
||||
}
|
||||
const primId = payload?.primId;
|
||||
const prop = payload?.prop;
|
||||
const value = payload?.value;
|
||||
const patch = {};
|
||||
if (prop === 'position' && value) {
|
||||
patch.x = value.x; patch.y = value.y; patch.z = value.z;
|
||||
} else if (prop === 'cframe' && value) {
|
||||
patch.x = value.x; patch.y = value.y; patch.z = value.z;
|
||||
patch.rotationX = value.rx; patch.rotationY = value.ry; patch.rotationZ = value.rz;
|
||||
} else if (prop === 'size' && value) {
|
||||
patch.sx = value.sx; patch.sy = value.sy; patch.sz = value.sz;
|
||||
} else if (prop === 'color') patch.color = value;
|
||||
else if (prop === 'material') patch.material = value;
|
||||
else if (prop === 'anchored') patch.anchored = value;
|
||||
else if (prop === 'canCollide') patch.canCollide = value;
|
||||
else if (prop === 'opacity') patch.opacity = value;
|
||||
try {
|
||||
if (typeof pm.updateInstance === 'function') pm.updateInstance(primId, patch);
|
||||
else if (typeof pm.applyPatch === 'function') pm.applyPatch(primId, patch);
|
||||
else if (typeof pm.update === 'function') pm.update(primId, patch);
|
||||
} catch (e) {
|
||||
console.error('[partSet] updateInstance failed:', e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'sceneCreate') {
|
||||
// Lua: Instance.new("Part") + part.Parent = workspace → создание примитива.
|
||||
// payload: { primId (предложенный id), type, x, y, z, sx, sy, sz, color, anchored }
|
||||
try {
|
||||
const pm = runtime.scene3d?.primitiveManager;
|
||||
if (!pm || typeof pm.addInstance !== 'function') return;
|
||||
const opts = {
|
||||
id: payload?.primId,
|
||||
x: payload?.x || 0, y: payload?.y || 0, z: payload?.z || 0,
|
||||
sx: payload?.sx || 1, sy: payload?.sy || 1, sz: payload?.sz || 1,
|
||||
color: payload?.color,
|
||||
anchored: payload?.anchored !== false,
|
||||
canCollide: payload?.canCollide !== false,
|
||||
};
|
||||
pm.addInstance(payload?.type || 'cube', opts);
|
||||
// Если unanchored — регистрируем в физике на лету, иначе он не падает.
|
||||
if (opts.anchored === false) {
|
||||
try {
|
||||
const dm = runtime.scene3d?.dynamics;
|
||||
const data = pm.instances?.get?.(opts.id);
|
||||
if (dm && data && typeof dm.registerPrimitive === 'function') {
|
||||
dm.registerPrimitive(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[sceneCreate] registerPrimitive failed', e);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[sceneCreate]', e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'sceneDelete') {
|
||||
// Lua: part:Destroy() → удаление примитива.
|
||||
try {
|
||||
const pm = runtime.scene3d?.primitiveManager;
|
||||
if (!pm || typeof pm.removeInstance !== 'function') return;
|
||||
const id = payload?.primId;
|
||||
if (id != null) pm.removeInstance(Number(id));
|
||||
} catch (e) {
|
||||
console.error('[sceneDelete]', e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'partVel') {
|
||||
try {
|
||||
const pm = runtime.scene3d?.primitiveManager;
|
||||
if (pm && typeof pm.setVelocity === 'function') {
|
||||
pm.setVelocity(payload.primId, payload.vx, payload.vy, payload.vz);
|
||||
}
|
||||
} catch (e) {}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'playerCmd') {
|
||||
try {
|
||||
const p = runtime.game?.player;
|
||||
if (!p) return;
|
||||
const method = payload?.method;
|
||||
const args = payload?.args || [];
|
||||
if (method === 'teleport') p.teleport && p.teleport(args[0], args[1], args[2]);
|
||||
else if (method === 'setWalkSpeed') p.setWalkSpeed && p.setWalkSpeed(args[0]);
|
||||
else if (method === 'setJumpPower') p.setJumpPower && p.setJumpPower(args[0]);
|
||||
else if (method === 'setHealth') p.setHealth && p.setHealth(args[0]);
|
||||
else if (method === 'die') p.die && p.die();
|
||||
else if (method === 'damage' || method === 'takeDamage') p.damage && p.damage(args[0]);
|
||||
} catch (e) {}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'guiUpdate') {
|
||||
// TODO: scripts setting Visible/Text/Color on GUI → передать в GuiManager
|
||||
return;
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user