feat: 50 игр на Lua + импорт Roblox для всех + поддержка Lua в плеере #39
Binary file not shown.
@ -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'] = {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
177
src/editor/engine/RbxlHudOverlay.js
Normal file
177
src/editor/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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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}` });
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user