diff --git a/rbxl-importer/src/__pycache__/converter.cpython-314.pyc b/rbxl-importer/src/__pycache__/converter.cpython-314.pyc index 6a97014..4e9d3db 100644 Binary files a/rbxl-importer/src/__pycache__/converter.cpython-314.pyc and b/rbxl-importer/src/__pycache__/converter.cpython-314.pyc differ diff --git a/rbxl-importer/src/converter.py b/rbxl-importer/src/converter.py index 7509a40..c86bbf8 100644 --- a/rbxl-importer/src/converter.py +++ b/rbxl-importer/src/converter.py @@ -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'] = { diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 6a0ae5a..47acba5 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -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 diff --git a/src/editor/engine/RbxlHudOverlay.js b/src/editor/engine/RbxlHudOverlay.js new file mode 100644 index 0000000..ae17084 --- /dev/null +++ b/src/editor/engine/RbxlHudOverlay.js @@ -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; + } +} diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 59db590..d4c96aa 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -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}` }); }