feat: 50 игр на Lua + импорт Roblox для всех + поддержка Lua в плеере #39

Merged
min merged 215 commits from feat/lua-50-games-bundle into main 2026-06-09 21:59:25 +00:00
5 changed files with 494 additions and 7 deletions
Showing only changes of commit 913283ffa6 - Show all commits

View File

@ -264,8 +264,32 @@ class Converter:
'sounds': [],
'glbModels': [],
'scripts': [],
# Команды PvP (Roblox Battle): {id, name, color_hex, auto_assignable}
'teams': [],
# Spawn-точки команд (для SpawnLocation.TeamColor)
'team_spawns': [], # {team_color_hex, x, y, z}
}
# Эвристика для Roblox Battle: Model с именем "TeamBeacon X" →
# команда X. PvP-карты часто используют этот паттерн вместо Team-инстансов.
TEAM_BEACON_COLORS = {
'Black': '#1f1f1f', 'Blue': '#0d69ac', 'Red': '#c4281c',
'Green': '#4b9740', 'White': '#f2f3f3', 'Yellow': '#f5cd30',
'Orange': '#d97e29', 'Purple': '#6b327a',
}
for inst in self.model.instances:
name = inst.properties.get('Name', '')
if (inst.class_name == 'Model' and isinstance(name, str)
and name.startswith('TeamBeacon ')):
team_name = name.replace('TeamBeacon ', '').strip()
color = TEAM_BEACON_COLORS.get(team_name, '#cccccc')
scene['teams'].append({
'id': f'team_{len(scene["teams"]) + 1}',
'name': team_name,
'color_hex': color,
'auto_assignable': True,
})
# Обходим все instances и конвертим
for inst in self.model.instances:
self._convert_one(inst, scene)
@ -331,8 +355,12 @@ class Converter:
elif cls == 'Workspace':
# Workspace = root, его свойства мапим на scene.worldSize и т.п.
pass
elif cls == 'Team':
# PvP-команда: имя + цвет в scene.teams[].
self._convert_team(inst, scene)
elif cls in ('Camera', 'Terrain', 'StarterPlayer', 'StarterGui',
'StarterPack', 'StarterCharacterScripts', 'Players',
'Teams',
'ReplicatedStorage', 'ServerScriptService', 'ServerStorage',
'SoundService', 'TweenService', 'RunService',
'UserInputService', 'HttpService', 'DataStoreService',
@ -631,10 +659,37 @@ class Converter:
# ─── Spawn ───
def _convert_team(self, inst: Instance, scene: Dict) -> None:
"""Roblox Team → scene.teams[]."""
props = inst.properties
name = str(props.get('Name', 'Team'))
# TeamColor — BrickColor код, мапим в hex через существующую таблицу
team_color = props.get('TeamColor')
color_hex = '#ffffff'
if isinstance(team_color, BrickColor):
color_hex = BRICKCOLOR_TO_HEX.get(team_color.code, '#cccccc')
scene['teams'].append({
'id': f'team_{len(scene["teams"]) + 1}',
'name': name,
'color_hex': color_hex,
'auto_assignable': bool(props.get('AutoAssignable', True)),
})
def _convert_spawn(self, inst: Instance, scene: Dict) -> None:
props = inst.properties
cf = props.get('CFrame')
pos, _ = cframe_to_pos_rot(cf, self.scale)
# TeamColor (если есть) → spawn для команды.
team_color = props.get('TeamColor')
if isinstance(team_color, BrickColor):
color_hex = BRICKCOLOR_TO_HEX.get(team_color.code, '#cccccc')
scene['team_spawns'].append({
'team_color_hex': color_hex,
'x': pos['x'], 'y': pos['y'] + 1.5, 'z': pos['z'],
'neutral': not bool(props.get('Neutral', True)) and team_color.code != 0,
})
# Spawn должен быть чуть выше — Roblox SpawnLocation это плоская плита,
# юзер появляется на её верхней грани.
scene['spawnPoint'] = {

View File

@ -21,6 +21,7 @@ import { PhysicsWorld } from './PhysicsWorld';
import { LabelManager } from './LabelManager';
import { handleLuaCommand, unpackRobloxLuaCode, parseRobloxLuaMeta } from './rbxl-lua-integration.js';
import { LuaSharedSandbox } from './lua/LuaSharedSandbox.js';
import { RbxlHudOverlay } from './RbxlHudOverlay.js';
export class GameRuntime {
constructor(scene3d) {
@ -247,6 +248,45 @@ export class GameRuntime {
// partlcle-эффекты есть у Tool. При equip покажем у руки.
this._rbxlPendingParticles = this._rbxlPendingParticles || [];
this._rbxlPendingParticles.push(payload);
} else if (cmd === 'mouseIconChanged') {
// Roblox Mouse.Icon → CSS cursor на canvas
try {
const canvas = this.scene3d?.engine?.getRenderingCanvas?.();
if (canvas) canvas.style.cursor = payload.cssCursor || 'default';
} catch (_) {}
} else if (cmd === 'hudMessage') {
// Roblox Message/Hint в верхней трети экрана
try {
this._ensureRbxlHud();
if (payload.visible && payload.text) {
this._rbxlHud.showMessage(payload.text);
} else {
this._rbxlHud.hideMessage();
}
} catch (_) {}
} else if (cmd === 'killFeed') {
// Кастомное событие от нашего creator-tag tracker'а
try {
this._ensureRbxlHud();
this._rbxlHud.addKillFeed(payload.killer, payload.victim, payload.weapon);
} catch (_) {}
} else if (cmd === 'winShow') {
try {
this._ensureRbxlHud();
this._rbxlHud.showWin(payload.text || 'WIN!');
} catch (_) {}
} else if (cmd === 'leaderstatSet') {
// Roblox leaderstats: IntValue.Value меняется → HUD.
try {
const lm = this.scene3d?.leaderstats;
if (lm) {
const statName = String(payload.statName || 'Stat');
if (!lm._defs.some(d => d.name === statName)) {
lm.define(statName, { initial: 0 });
}
lm.set(lm._meId || 'me', statName, Number(payload.value) || 0);
}
} catch (_) {}
} else {
this._handleCommand(null, cmd, payload);
}
@ -576,6 +616,15 @@ export class GameRuntime {
return null;
}
/** Создаёт DOM-overlay для импортированных Roblox-карт (KillFeed,
* Message, WinGui). Лениво только при первом hudMessage/killFeed. */
_ensureRbxlHud() {
if (this._rbxlHud) return;
const canvas = this.scene3d?.engine?.getRenderingCanvas?.();
const parent = canvas?.parentElement || document.body;
this._rbxlHud = new RbxlHudOverlay(parent);
}
/** Регистрирует Roblox-Tool в InventoryUI как item в hotbar.
* Слушает смену активного слота шлёт equipTool/unequipTool в Lua-shim.
* Слушает клики ЛКМ шлёт mouseButton1Down (Tool.Activated fires там). */
@ -719,6 +768,9 @@ export class GameRuntime {
this._rbxlToolHooks = false;
this._rbxlActiveSlot = -1;
this._rbxlPendingParticles = null;
// Очищаем Roblox HUD overlay (KillFeed/Message/WinGui)
try { this._rbxlHud?.dispose(); } catch (_) {}
this._rbxlHud = null;
// Удаляем все объекты, которые скрипты наспавнили через
// game.scene.spawn/clone — иначе после Stop они остаются на сцене
// и накапливаются при повторных запусках.
@ -4196,6 +4248,16 @@ export class GameRuntime {
} else {
player.hp = target;
}
} else if (payload.prop === 'jumpVelocity') {
// Bouncer (батут): Lua-скрипт даёт игроку Y-velocity = N
try {
if (player._vy !== undefined) player._vy = Number(payload.value) || 0;
else if (player.velocity) player.velocity.y = Number(payload.value) || 0;
} catch (_) {}
} else if (payload.prop === 'walkSpeed') {
try { player.walkSpeed = Number(payload.value) || player.walkSpeed; } catch (_) {}
} else if (payload.prop === 'jumpPower') {
try { player.jumpPower = Number(payload.value) || player.jumpPower; } catch (_) {}
}
return;
}
@ -4495,7 +4557,10 @@ export class GameRuntime {
});
}
}
return { blocks, models, primitives };
// Teams и team_spawns из projectData (импортированные из .rbxl)
const teams = this.projectData?.scene?.teams || [];
const teamSpawns = this.projectData?.scene?.team_spawns || [];
return { blocks, models, primitives, teams, teamSpawns };
}
// Разобрать ref-строку ('primitive:N' / 'model:N' / 'block:x,y,z') в target

View 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;
}
}

View File

@ -239,7 +239,29 @@ function makeInstanceMethods() {
this.Parent = undefined;
}
},
Clone: function () { return undefined; },
Clone: function () {
// Поверхностный клон — достаточно для большинства Roblox-паттернов
// (Tool/Pellet/Bomb клонируются и parent'ятся в Workspace).
// Глубокий клон не делаем — Children копируются по ссылке (как в Roblox
// Clone() это deep copy, но у нас нет полной physical model).
try {
const copy = Object.assign({}, this);
copy.Children = (this.Children || []).slice();
copy.Parent = undefined;
return copy;
} catch (_) {
return undefined;
}
},
// Старый Roblox API: lowercase :clone()
clone: function () { return this.Clone && this.Clone(); },
// model:makeJoints() — заглушка (Welds мы не делаем)
MakeJoints: function () {},
makeJoints: function () {},
BreakJoints: function () {},
breakJoints: function () {},
Remove: function () { this.Parent = undefined; },
remove: function () { this.Parent = undefined; },
GetAttribute: function (n) { return (this.Attributes || {})[n]; },
SetAttribute: function (n, v) {
if (!this.Attributes) this.Attributes = {};
@ -742,6 +764,13 @@ export function registerRobloxShim(lua, opts) {
localPlayer.Children.push(playerGui);
localPlayer.PlayerGui = playerGui;
localPlayer.DisplayName = 'Player';
localPlayer.Name = 'Player';
localPlayer.Neutral = true; // не в команде по умолчанию
localPlayer.Team = undefined;
localPlayer.TeamColor = { Name: 'White', Color: new RbxColor3(1, 1, 1) };
localPlayer.Kick = function () {};
localPlayer.LoadCharacter = function () {};
localPlayer.HasAppearanceLoaded = function () { return true; };
// Backpack — инвентарь, содержит Tools. В Roblox StarterPack автоматически
// клонируется в Backpack каждого спавнящегося игрока.
const backpack = newInstance('Backpack', 'Backpack');
@ -762,7 +791,18 @@ export function registerRobloxShim(lua, opts) {
m.WheelForward = makeSignal();
m.WheelBackward = makeSignal();
m.Idle = makeSignal();
m.Icon = '';
// m.Icon reactive — меняет CSS cursor на canvas
let _icon = '';
Object.defineProperty(m, 'Icon', {
get() { return _icon; },
set(v) {
_icon = String(v || '');
// rbxassetid → стрелочный курсор-прицел (наш дефолт)
const cssCursor = _icon && _icon.includes('rbxasset')
? 'crosshair' : (_icon ? 'crosshair' : 'default');
send('mouseIconChanged', { icon: _icon, cssCursor });
},
});
m.X = 0; m.Y = 0;
m.ViewSizeX = 1920; m.ViewSizeY = 1080;
m.Hit = { Position: new RbxVector3(0, 0, 0), p: new RbxVector3(0, 0, 0),
@ -803,7 +843,26 @@ export function registerRobloxShim(lua, opts) {
const v = Math.max(0, (this.Health || 100) - (Number(n) || 0));
this.Health = v;
this.HealthChanged.Fire(v);
if (v === 0) this.Died.Fire();
if (v === 0) {
// Creator-tag: ищем creator-ObjectValue в Humanoid.Children для kill feed
let killerName = null;
for (const c of (this.Children || [])) {
if (c && c.Name === 'creator' && c.Value) {
killerName = String(c.Value.Name || c.Value.DisplayName || '?');
break;
}
}
if (killerName) {
send('killFeed', { killer: killerName, victim: localPlayer.Name || 'Player', weapon: '' });
}
this.Died.Fire();
// В Roblox после Died игрок респавнится — у нас через playerSet=respawn
setTimeout(() => {
this.Health = this.MaxHealth || 100;
this.HealthChanged.Fire(this.Health);
send('playerSet', { prop: 'health', value: this.Health });
}, 2000);
}
send('playerSet', { prop: 'health', value: v });
};
humanoid.MoveTo = function () {};
@ -836,6 +895,12 @@ export function registerRobloxShim(lua, opts) {
makeService('StarterPack');
makeService('StarterPlayer');
// Teams сервис — PvP-команды (TeamBeacon Black/Blue/Red/Green в Roblox Battle)
const teams = makeService('Teams');
teams.Children = [];
teams.GetTeams = function () { return teams.Children.slice(); };
teams.GetChildren = function () { return teams.Children.slice(); };
const uis = makeService('UserInputService');
uis.InputBegan = makeSignal();
uis.InputChanged = makeSignal();
@ -1165,6 +1230,51 @@ export function registerRobloxShim(lua, opts) {
inst = newInstance('BindableEvent', 'BindableEvent');
inst.Event = makeSignal();
inst.Fire = function (...a) { this.Event.Fire(...a); };
} else if (className === 'BindableFunction') {
// BindableFunction — синхронный RPC внутри клиента.
// OnInvoke = single-callback; Invoke вызывает его и возвращает значение.
inst = newInstance('BindableFunction', 'BindableFunction');
inst.OnInvoke = undefined; // юзер ставит function
inst.Invoke = function (...args) {
if (typeof this.OnInvoke === 'function') {
try { return this.OnInvoke(...args); } catch (_) { return undefined; }
}
return undefined;
};
} else if (className === 'RemoteFunction') {
inst = newInstance('RemoteFunction', 'RemoteFunction');
inst.OnServerInvoke = undefined;
inst.OnClientInvoke = undefined;
inst.InvokeServer = function (...args) {
if (typeof this.OnServerInvoke === 'function') {
try { return this.OnServerInvoke(localPlayer, ...args); } catch (_) {}
}
return undefined;
};
inst.InvokeClient = function (_p, ...args) {
if (typeof this.OnClientInvoke === 'function') {
try { return this.OnClientInvoke(...args); } catch (_) {}
}
return undefined;
};
} else if (className === 'Message' || className === 'Hint') {
// Roblox Message — текстовая надпись по центру экрана,
// когда .Parent = workspace или nil. Hint — то же но мельче.
inst = newInstance(className, className);
let _txt = '';
Object.defineProperty(inst, 'Text', {
get() { return _txt; },
set(v) { _txt = String(v || ''); send('hudMessage', { kind: className, text: _txt, visible: !!inst.Parent }); },
});
// При смене Parent: nil → скрываем, workspace → показываем
const _origParent = Object.getOwnPropertyDescriptor(inst, 'Parent');
Object.defineProperty(inst, 'Parent', {
get() { return this._parent; },
set(v) {
this._parent = v;
send('hudMessage', { kind: className, text: _txt, visible: !!v });
},
});
} else if (className === 'Humanoid') {
inst = newInstance('Humanoid', 'Humanoid');
inst.Health = 100; inst.MaxHealth = 100;
@ -1229,6 +1339,26 @@ export function registerRobloxShim(lua, opts) {
|| className === 'ScrollingFrame') {
inst = newGuiInstance(className);
guiByLocalRef.set(inst.__guiLocalRef, inst);
} else if (className === 'Team') {
inst = newInstance('Team', 'Team');
inst.TeamColor = { Name: 'Bright red', Color: new RbxColor3(0.77, 0.2, 0.2) };
inst.Score = 0;
inst.AutoAssignable = true;
inst.PlayerAdded = makeSignal();
inst.PlayerRemoved = makeSignal();
inst.GetPlayers = function () {
return (players?.Children || []).filter(p => p.Team === this);
};
// Регистрация в teams сервисе при Parent = teams
Object.defineProperty(inst, 'Parent', {
get() { return this._parent; },
set(v) {
this._parent = v;
if (v === teams && !teams.Children.includes(this)) {
teams.Children.push(this);
}
},
});
} else if (className === 'Tool' || className === 'HopperBin') {
inst = newInstance(className, 'Tool');
inst.Equipped = makeSignal();
@ -1265,18 +1395,54 @@ export function registerRobloxShim(lua, opts) {
|| className === 'Vector3Value' || className === 'Color3Value'
|| className === 'BrickColorValue' || className === 'RayValue') {
inst = newInstance(className, className);
inst.Value = className === 'BoolValue' ? false
let _val = className === 'BoolValue' ? false
: className === 'StringValue' ? ''
: (className === 'IntValue' || className === 'NumberValue') ? 0
: undefined;
inst.Changed = makeSignal();
// Реактивное поле Value — фейерим Changed + обновляем leaderstats
// если этот *Value лежит внутри leaderstats-родителя (Roblox-pattern).
Object.defineProperty(inst, 'Value', {
get() { return _val; },
set(v) {
_val = v;
try { inst.Changed.Fire(v); } catch (_) {}
// Если этот IntValue — leaderstat (родитель Name=leaderstats):
if (inst.Parent && inst.Parent.Name === 'leaderstats') {
send('leaderstatSet', {
playerName: inst.Parent.Parent?.Name || 'Player',
statName: inst.Name || 'Stat',
value: Number(v) || 0,
});
}
},
});
} else if (className === 'BodyForce' || className === 'BodyVelocity'
|| className === 'BodyPosition' || className === 'BodyGyro'
|| className === 'BodyAngularVelocity' || className === 'BodyThrust') {
inst = newInstance(className, className);
let _vel = new RbxVector3(0, 0, 0);
Object.defineProperty(inst, 'velocity', {
get() { return _vel; },
set(v) {
_vel = v;
// Эвристика батута: BodyVelocity с +Y и Parent=Torso/HRP
// = "толкаем игрока вверх". Если это игрок — шлём jumpVelocity.
if (className === 'BodyVelocity' && v && v.Y > 10) {
const p = inst.Parent;
if (p && (p.Name === 'Torso' || p.Name === 'HumanoidRootPart' ||
p.Name === 'UpperTorso')) {
send('playerSet', { prop: 'jumpVelocity', value: v.Y * 0.3 });
}
}
},
});
Object.defineProperty(inst, 'Velocity', {
get() { return _vel; },
set(v) { inst.velocity = v; },
});
inst.force = new RbxVector3(0, 0, 0);
inst.Force = inst.force;
inst.Velocity = new RbxVector3(0, 0, 0);
inst.MaxForce = new RbxVector3(0, 0, 0);
inst.P = 1000; inst.D = 100;
} else if (className === 'Weld' || className === 'WeldConstraint'
@ -1478,8 +1644,32 @@ export function registerRobloxShim(lua, opts) {
workspace.Children.push(part);
partById.set(Number(p.id), part);
}
// Teams из импорта .rbxl — создаём Team-инстансы в teams сервисе
const teamsList = snap?.teams || [];
if (teamsList.length > 0 && teams.Children.length === 0) {
for (const t of teamsList) {
const team = newInstance('Team', String(t.name || 'Team'));
team.TeamColor = {
Name: String(t.name || 'White'),
Color: RbxColor3.fromHex(t.color_hex || '#ffffff'),
};
team.Score = 0;
team.AutoAssignable = !!t.auto_assignable;
team.PlayerAdded = makeSignal();
team.PlayerRemoved = makeSignal();
team._parent = teams;
teams.Children.push(team);
}
// Авто-назначение игрока в первую auto_assignable команду
const first = teams.Children.find(t => t.AutoAssignable);
if (first) {
localPlayer.Team = first;
localPlayer.TeamColor = first.TeamColor;
localPlayer.Neutral = false;
}
}
// eslint-disable-next-line no-console
console.log(`[shim] DataModel: workspace has ${workspace.Children.length} children, ${partById.size} parts`);
console.log(`[shim] DataModel: workspace has ${workspace.Children.length} children, ${partById.size} parts, ${teams.Children.length} teams`);
} catch (e) {
send('log', { level: 'error', text: `[shim onSceneSnapshot] ${e?.message || e}` });
}