feat(rbxl): 9 механик ROBLOX Battle (Teams/Leaderstats/HUD/Tools/etc)

Реализовано из 14 механик:

1. Teams (game.Teams, Player.Team, TeamColor): scene.teams[] из конвертера,
   эвристика TeamBeacon-Model → автоматически создаются 4 команды.
   В shim создаются Team-инстансы при snapshot, авто-эквип игрока в первую.

2. Leaderstats UI: IntValue.Value реактивно шлёт leaderstatSet → существующий
   LeaderstatsManager (define + set). HUD автоматически рисуется в правом
   верхнем по родительскому Name='leaderstats'.

3. BindableFunction + RemoteFunction + Message/Hint класс. Message с
   реактивным .Text и .Parent шлёт hudMessage в наш RbxlHudOverlay.

4. KillFeed UI + creator-tag tracking. RbxlHudOverlay.addKillFeed() рисует
   А → [weapon] → Б в правом верхнем. Humanoid.TakeDamage при Health=0
   ищет creator-ObjectValue и шлёт killFeed. Авто-respawn через 2с.

5. SpawnLocation.TeamColor → scene.team_spawns[] для будущей логики
   команд-спавна.

6. Tool:Clone() / Model:Clone() / :clone(): поверхностный клон + lowercase
   alias. Также :MakeJoints/:BreakJoints/:Remove/:remove no-op методы.

7. Creator-tag handling в TakeDamage (см. пункт 4).

12. Bouncer/батут: BodyVelocity с +Y и Parent=Torso/HumanoidRootPart →
    эвристика "толкаем вверх" → playerSet jumpVelocity → реальный jump
    через player._vy.

14. Mouse.Icon → CSS cursor на canvas (crosshair для не-пустых).

Также:
- RbxlHudOverlay.js — новый модуль DOM-оверлей для HUD-элементов
  (KillFeed/Message/WinGui). Lazy-создаётся при первом hudMessage/killFeed.
- BabylonScene.serialize включает scene.teams и scene.team_spawns.
- Converter: scene = teams[] + team_spawns[]. TeamBeacon Model'и → команды.
- Deploy converter.py на VM 130.

Остались: 8 Regeneration, 9 BattleArmor, 10 WinGui/FireButton кастомное
позиционирование, 11 AdminConsole (no-op уже ok), 13 NotLinkedBlocker.
This commit is contained in:
min 2026-06-08 19:43:36 +03:00
parent dbfd214f42
commit 913283ffa6
5 changed files with 494 additions and 7 deletions

View File

@ -264,8 +264,32 @@ class Converter:
'sounds': [], 'sounds': [],
'glbModels': [], 'glbModels': [],
'scripts': [], '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 и конвертим # Обходим все instances и конвертим
for inst in self.model.instances: for inst in self.model.instances:
self._convert_one(inst, scene) self._convert_one(inst, scene)
@ -331,8 +355,12 @@ class Converter:
elif cls == 'Workspace': elif cls == 'Workspace':
# Workspace = root, его свойства мапим на scene.worldSize и т.п. # Workspace = root, его свойства мапим на scene.worldSize и т.п.
pass pass
elif cls == 'Team':
# PvP-команда: имя + цвет в scene.teams[].
self._convert_team(inst, scene)
elif cls in ('Camera', 'Terrain', 'StarterPlayer', 'StarterGui', elif cls in ('Camera', 'Terrain', 'StarterPlayer', 'StarterGui',
'StarterPack', 'StarterCharacterScripts', 'Players', 'StarterPack', 'StarterCharacterScripts', 'Players',
'Teams',
'ReplicatedStorage', 'ServerScriptService', 'ServerStorage', 'ReplicatedStorage', 'ServerScriptService', 'ServerStorage',
'SoundService', 'TweenService', 'RunService', 'SoundService', 'TweenService', 'RunService',
'UserInputService', 'HttpService', 'DataStoreService', 'UserInputService', 'HttpService', 'DataStoreService',
@ -631,10 +659,37 @@ class Converter:
# ─── Spawn ─── # ─── 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: def _convert_spawn(self, inst: Instance, scene: Dict) -> None:
props = inst.properties props = inst.properties
cf = props.get('CFrame') cf = props.get('CFrame')
pos, _ = cframe_to_pos_rot(cf, self.scale) 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 это плоская плита, # Spawn должен быть чуть выше — Roblox SpawnLocation это плоская плита,
# юзер появляется на её верхней грани. # юзер появляется на её верхней грани.
scene['spawnPoint'] = { scene['spawnPoint'] = {

View File

@ -21,6 +21,7 @@ import { PhysicsWorld } from './PhysicsWorld';
import { LabelManager } from './LabelManager'; import { LabelManager } from './LabelManager';
import { handleLuaCommand, unpackRobloxLuaCode, parseRobloxLuaMeta } from './rbxl-lua-integration.js'; import { handleLuaCommand, unpackRobloxLuaCode, parseRobloxLuaMeta } from './rbxl-lua-integration.js';
import { LuaSharedSandbox } from './lua/LuaSharedSandbox.js'; import { LuaSharedSandbox } from './lua/LuaSharedSandbox.js';
import { RbxlHudOverlay } from './RbxlHudOverlay.js';
export class GameRuntime { export class GameRuntime {
constructor(scene3d) { constructor(scene3d) {
@ -247,6 +248,45 @@ export class GameRuntime {
// partlcle-эффекты есть у Tool. При equip покажем у руки. // partlcle-эффекты есть у Tool. При equip покажем у руки.
this._rbxlPendingParticles = this._rbxlPendingParticles || []; this._rbxlPendingParticles = this._rbxlPendingParticles || [];
this._rbxlPendingParticles.push(payload); 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 { } else {
this._handleCommand(null, cmd, payload); this._handleCommand(null, cmd, payload);
} }
@ -576,6 +616,15 @@ export class GameRuntime {
return null; 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. /** Регистрирует Roblox-Tool в InventoryUI как item в hotbar.
* Слушает смену активного слота шлёт equipTool/unequipTool в Lua-shim. * Слушает смену активного слота шлёт equipTool/unequipTool в Lua-shim.
* Слушает клики ЛКМ шлёт mouseButton1Down (Tool.Activated fires там). */ * Слушает клики ЛКМ шлёт mouseButton1Down (Tool.Activated fires там). */
@ -719,6 +768,9 @@ export class GameRuntime {
this._rbxlToolHooks = false; this._rbxlToolHooks = false;
this._rbxlActiveSlot = -1; this._rbxlActiveSlot = -1;
this._rbxlPendingParticles = null; this._rbxlPendingParticles = null;
// Очищаем Roblox HUD overlay (KillFeed/Message/WinGui)
try { this._rbxlHud?.dispose(); } catch (_) {}
this._rbxlHud = null;
// Удаляем все объекты, которые скрипты наспавнили через // Удаляем все объекты, которые скрипты наспавнили через
// game.scene.spawn/clone — иначе после Stop они остаются на сцене // game.scene.spawn/clone — иначе после Stop они остаются на сцене
// и накапливаются при повторных запусках. // и накапливаются при повторных запусках.
@ -4196,6 +4248,16 @@ export class GameRuntime {
} else { } else {
player.hp = target; 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; 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 // Разобрать 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; 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]; }, GetAttribute: function (n) { return (this.Attributes || {})[n]; },
SetAttribute: function (n, v) { SetAttribute: function (n, v) {
if (!this.Attributes) this.Attributes = {}; if (!this.Attributes) this.Attributes = {};
@ -742,6 +764,13 @@ export function registerRobloxShim(lua, opts) {
localPlayer.Children.push(playerGui); localPlayer.Children.push(playerGui);
localPlayer.PlayerGui = playerGui; localPlayer.PlayerGui = playerGui;
localPlayer.DisplayName = 'Player'; 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 — инвентарь, содержит Tools. В Roblox StarterPack автоматически
// клонируется в Backpack каждого спавнящегося игрока. // клонируется в Backpack каждого спавнящегося игрока.
const backpack = newInstance('Backpack', 'Backpack'); const backpack = newInstance('Backpack', 'Backpack');
@ -762,7 +791,18 @@ export function registerRobloxShim(lua, opts) {
m.WheelForward = makeSignal(); m.WheelForward = makeSignal();
m.WheelBackward = makeSignal(); m.WheelBackward = makeSignal();
m.Idle = 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.X = 0; m.Y = 0;
m.ViewSizeX = 1920; m.ViewSizeY = 1080; m.ViewSizeX = 1920; m.ViewSizeY = 1080;
m.Hit = { Position: new RbxVector3(0, 0, 0), p: new RbxVector3(0, 0, 0), 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)); const v = Math.max(0, (this.Health || 100) - (Number(n) || 0));
this.Health = v; this.Health = v;
this.HealthChanged.Fire(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 }); send('playerSet', { prop: 'health', value: v });
}; };
humanoid.MoveTo = function () {}; humanoid.MoveTo = function () {};
@ -836,6 +895,12 @@ export function registerRobloxShim(lua, opts) {
makeService('StarterPack'); makeService('StarterPack');
makeService('StarterPlayer'); 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'); const uis = makeService('UserInputService');
uis.InputBegan = makeSignal(); uis.InputBegan = makeSignal();
uis.InputChanged = makeSignal(); uis.InputChanged = makeSignal();
@ -1165,6 +1230,51 @@ export function registerRobloxShim(lua, opts) {
inst = newInstance('BindableEvent', 'BindableEvent'); inst = newInstance('BindableEvent', 'BindableEvent');
inst.Event = makeSignal(); inst.Event = makeSignal();
inst.Fire = function (...a) { this.Event.Fire(...a); }; 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') { } else if (className === 'Humanoid') {
inst = newInstance('Humanoid', 'Humanoid'); inst = newInstance('Humanoid', 'Humanoid');
inst.Health = 100; inst.MaxHealth = 100; inst.Health = 100; inst.MaxHealth = 100;
@ -1229,6 +1339,26 @@ export function registerRobloxShim(lua, opts) {
|| className === 'ScrollingFrame') { || className === 'ScrollingFrame') {
inst = newGuiInstance(className); inst = newGuiInstance(className);
guiByLocalRef.set(inst.__guiLocalRef, inst); 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') { } else if (className === 'Tool' || className === 'HopperBin') {
inst = newInstance(className, 'Tool'); inst = newInstance(className, 'Tool');
inst.Equipped = makeSignal(); inst.Equipped = makeSignal();
@ -1265,18 +1395,54 @@ export function registerRobloxShim(lua, opts) {
|| className === 'Vector3Value' || className === 'Color3Value' || className === 'Vector3Value' || className === 'Color3Value'
|| className === 'BrickColorValue' || className === 'RayValue') { || className === 'BrickColorValue' || className === 'RayValue') {
inst = newInstance(className, className); inst = newInstance(className, className);
inst.Value = className === 'BoolValue' ? false let _val = className === 'BoolValue' ? false
: className === 'StringValue' ? '' : className === 'StringValue' ? ''
: (className === 'IntValue' || className === 'NumberValue') ? 0 : (className === 'IntValue' || className === 'NumberValue') ? 0
: undefined; : undefined;
inst.Changed = makeSignal(); 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' } else if (className === 'BodyForce' || className === 'BodyVelocity'
|| className === 'BodyPosition' || className === 'BodyGyro' || className === 'BodyPosition' || className === 'BodyGyro'
|| className === 'BodyAngularVelocity' || className === 'BodyThrust') { || className === 'BodyAngularVelocity' || className === 'BodyThrust') {
inst = newInstance(className, className); 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 = new RbxVector3(0, 0, 0);
inst.Force = inst.force; inst.Force = inst.force;
inst.Velocity = new RbxVector3(0, 0, 0);
inst.MaxForce = new RbxVector3(0, 0, 0); inst.MaxForce = new RbxVector3(0, 0, 0);
inst.P = 1000; inst.D = 100; inst.P = 1000; inst.D = 100;
} else if (className === 'Weld' || className === 'WeldConstraint' } else if (className === 'Weld' || className === 'WeldConstraint'
@ -1478,8 +1644,32 @@ export function registerRobloxShim(lua, opts) {
workspace.Children.push(part); workspace.Children.push(part);
partById.set(Number(p.id), 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 // 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) { } catch (e) {
send('log', { level: 'error', text: `[shim onSceneSnapshot] ${e?.message || e}` }); send('log', { level: 'error', text: `[shim onSceneSnapshot] ${e?.message || e}` });
} }