feat(lua): Этап 5 — GUI (Frame, TextLabel, TextButton, ImageLabel, TextBox, ScrollingFrame)

В Lua теперь работает Roblox-style GUI:
  local sg = Instance.new('ScreenGui')
  local label = Instance.new('TextLabel', sg)
  label.Text = 'Hello'
  label.TextColor3 = Color3.fromRGB(255, 255, 0)
  label.Position = UDim2.new(0.5, 0, 0.5, 0)
  label.Size = UDim2.new(0.2, 0, 0.05, 0)

  local btn = Instance.new('TextButton', sg)
  btn.Text = 'Click me'
  btn.MouseButton1Click:Connect(function()
    print('clicked!')
  end)

Реализация: GUI-обёртка newGuiInstance создаёт элемент через gui.create
команду → GameRuntime.scene3d.guiManager. Setter'ы Text/Visible/
BackgroundColor3/TextColor3/TextSize/Position/Size шлют gui.update.
Destroy шлёт gui.remove. Клики через guiClick → guiByLocalRef →
inst.MouseButton1Click.Fire().

Добавлен localPlayer.PlayerGui для совместимости с Roblox-скриптами.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
min 2026-06-08 12:40:49 +03:00
parent 936f93a42c
commit 371ddaaae8

View File

@ -480,6 +480,12 @@ export function registerRobloxShim(lua, opts) {
const localPlayer = newInstance('Player', 'Player');
localPlayer.Parent = players;
localPlayer.UserId = 1;
// PlayerGui — контейнер для GUI принадлежащих игроку. В Rublox это no-op
// (overlay глобальный), но Roblox-скрипты часто делают gui.Parent = playerGui.
const playerGui = newInstance('PlayerGui', 'PlayerGui');
playerGui.Parent = localPlayer;
localPlayer.Children.push(playerGui);
localPlayer.PlayerGui = playerGui;
localPlayer.DisplayName = 'Player';
players.Children.push(localPlayer);
players.LocalPlayer = localPlayer;
@ -643,6 +649,136 @@ export function registerRobloxShim(lua, opts) {
global.set('Workspace', workspace);
// === Instance.new ===
// === Helper: создание GUI-элемента через game.gui.create ===
// Roblox: Frame/TextLabel/TextButton/ImageLabel/TextBox/ScrollingFrame.
// Шлём gui.create команду в main thread → GuiManager создаёт элемент.
// Возвращаем Lua-объект с setter'ами для основных свойств.
let _nextGuiLocalRef = 0;
function newGuiInstance(robloxClass) {
const localRef = `_gui_lua_${_nextGuiLocalRef++}`;
const inst = newInstance(robloxClass, robloxClass);
inst.__guiLocalRef = localRef;
inst.__guiClass = robloxClass;
// Маппим Roblox-класс на тип в GuiManager
const guiType = ({
Frame: 'frame',
TextLabel: 'text',
TextButton: 'button',
ImageLabel: 'image',
ImageButton: 'button',
TextBox: 'textbox',
ScrollingFrame: 'scroll',
})[robloxClass] || 'frame';
// Внутренние стейты
inst._gui = {
type: guiType,
text: '',
bgColor: '#3a2820',
bgOpacity: 1,
textColor: '#f0e6d8',
textSize: 16,
x: 50, y: 50, w: 20, h: 10,
visible: true,
};
// Шлём create при первом обращении (lazy) или сейчас — лучше сейчас, чтобы
// не было гонок при моментальной правке свойств после Instance.new.
send('gui.create', {
type: guiType,
opts: { ...inst._gui, _scriptCreated: true },
localRef,
});
// Сигналы (для кнопок)
if (robloxClass === 'TextButton' || robloxClass === 'ImageButton') {
inst.MouseButton1Click = makeSignal();
inst.MouseEnter = makeSignal();
inst.MouseLeave = makeSignal();
inst.Activated = inst.MouseButton1Click;
}
// Setters
const updateField = (field, value) => {
inst._gui[field] = value;
send('gui.update', { id: localRef, patch: { [field]: value } });
};
Object.defineProperty(inst, 'Text', {
get() { return inst._gui.text; },
set(v) { updateField('text', String(v ?? '')); },
enumerable: true,
});
Object.defineProperty(inst, 'Visible', {
get() { return inst._gui.visible; },
set(v) { updateField('visible', !!v); },
enumerable: true,
});
Object.defineProperty(inst, 'BackgroundColor3', {
get() { return RbxColor3.fromHex(inst._gui.bgColor); },
set(v) {
if (!v) return;
const hex = v.toHex ? v.toHex() : (v instanceof RbxColor3 ? v.toHex() : '#3a2820');
updateField('bgColor', hex);
},
enumerable: true,
});
Object.defineProperty(inst, 'BackgroundTransparency', {
get() { return 1 - (inst._gui.bgOpacity ?? 1); },
set(v) { updateField('bgOpacity', 1 - Math.max(0, Math.min(1, +v || 0))); },
enumerable: true,
});
Object.defineProperty(inst, 'TextColor3', {
get() { return RbxColor3.fromHex(inst._gui.textColor); },
set(v) {
if (!v) return;
const hex = v.toHex ? v.toHex() : '#f0e6d8';
updateField('textColor', hex);
},
enumerable: true,
});
Object.defineProperty(inst, 'TextSize', {
get() { return inst._gui.textSize; },
set(v) { updateField('textSize', Math.max(8, Math.min(72, +v || 16))); },
enumerable: true,
});
// Position: UDim2 → x,y проценты (Roblox-style: scale=%, offset=px)
// Упрощённо берём scale*100 как x/y; offset игнорируем.
Object.defineProperty(inst, 'Position', {
get() {
return new RbxUDim2(inst._gui.x / 100, 0, inst._gui.y / 100, 0);
},
set(v) {
if (!v) return;
const xPct = (v.X?.Scale || 0) * 100 + (v.X?.Offset || 0) / 10;
const yPct = (v.Y?.Scale || 0) * 100 + (v.Y?.Offset || 0) / 10;
inst._gui.x = xPct;
inst._gui.y = yPct;
send('gui.update', { id: localRef, patch: { x: xPct, y: yPct } });
},
enumerable: true,
});
Object.defineProperty(inst, 'Size', {
get() {
return new RbxUDim2(inst._gui.w / 100, 0, inst._gui.h / 100, 0);
},
set(v) {
if (!v) return;
const wPct = (v.X?.Scale || 0) * 100 + (v.X?.Offset || 0) / 10;
const hPct = (v.Y?.Scale || 0) * 100 + (v.Y?.Offset || 0) / 10;
inst._gui.w = wPct;
inst._gui.h = hPct;
send('gui.update', { id: localRef, patch: { w: wPct, h: hPct } });
},
enumerable: true,
});
// Destroy — удаление GUI
const origDestroy = inst.Destroy;
inst.Destroy = function () {
try { send('gui.remove', { id: localRef }); } catch (_) {}
origDestroy.call(inst);
};
return inst;
}
// Регистрация в guiByLocalRef для дальнейшей маршрутизации событий клика
const guiByLocalRef = new Map();
// Счётчик для новых Part'ов, создаваемых через Instance.new("Part"):
// primitiveManager.addInstance даст уникальный id, мы используем временный
// отрицательный id пока не пришёл sceneCreate ACK. Но проще: даём свой
@ -693,6 +829,18 @@ export function registerRobloxShim(lua, opts) {
inst.Health = 100; inst.MaxHealth = 100;
inst.Died = makeSignal(); inst.HealthChanged = makeSignal();
inst.TakeDamage = function (n) { this.Health = Math.max(0, this.Health - (n || 0)); };
} else if (className === 'ScreenGui') {
// ScreenGui — логический корень GUI. В Rublox overlay глобальный,
// поэтому ScreenGui это просто контейнер-no-op (без gui.create).
inst = newInstance('ScreenGui', 'ScreenGui');
inst.__isScreenGui = true;
inst.Enabled = true;
} else if (className === 'Frame' || className === 'TextLabel'
|| className === 'TextButton' || className === 'ImageLabel'
|| className === 'ImageButton' || className === 'TextBox'
|| className === 'ScrollingFrame') {
inst = newGuiInstance(className);
guiByLocalRef.set(inst.__guiLocalRef, inst);
} else {
inst = newInstance(className, className);
}
@ -885,6 +1033,14 @@ export function registerRobloxShim(lua, opts) {
if (humanoid.Touched) humanoid.Touched.Fire(part);
}
}
// GUI-клик: GameRuntime шлёт {type:'guiClick', localId, id}
if (p.type === 'guiClick') {
const ref = p.localId || p.id;
const guiEl = guiByLocalRef.get(ref);
if (guiEl?.MouseButton1Click) {
guiEl.MouseButton1Click.Fire();
}
}
},
// Доступ к ключевым объектам (для тестов и отладки)
partById, localPlayer, humanoid, character, workspace, players, game,