Derma: настоящее Q-меню Sandbox на Lua поверх VGUI-слоя
Some checks are pending
CI / checks (push) Waiting to run
Some checks are pending
CI / checks (push) Waiting to run
В crates/lua добавлен минимальный слой VGUI/Derma: панели-таблицы, vgui.Register/Create, surface/draw с записью команд, ввод и снимок кадра (до 4096 команд, глубина 32). На нём грузятся настоящие lua/derma, lua/skins/default.lua, lua/vgui/* и lua/includes/modules/spawnmenu.lua, затем Sandbox и OnGamemodeLoaded строят g_SpawnMenu. Публичный контракт: UiKind/UiCommand, LuaRuntime::ui_toggle/ui_visible/ ui_snapshot/ui_mouse/ui_set_screen, load_ui -> UiLoad. Попутно: file.Find в GMod-семантике (файлы и каталоги, маски *.txt), DEFINE_BASECLASS -> local BaseClass, //-комментарии, регистронезависимый include, SpawnableEntities хранит таблицу ENT. Тесты на реальных ассетах: ~991 команда, вкладки/категории/модели, клик по вкладке меняет снимок; без ассетов пропуск.
This commit is contained in:
parent
8aff0caf12
commit
ffd9e736bc
10 changed files with 4200 additions and 39 deletions
|
|
@ -259,6 +259,36 @@ impl UserData for LuaColor {
|
|||
table.set(4, c.a)?;
|
||||
Ok(table)
|
||||
});
|
||||
// `skin.Colours.*:Lerp(gray, frac)` — на этом держится вся окраска
|
||||
// текста Derma. Компоненты считаются в f32, результат обрезается.
|
||||
m.add_function(
|
||||
"Lerp",
|
||||
|lua, (color, target, fraction): (AnyUserData, Value, f64)| {
|
||||
let a = *color.borrow::<Self>()?;
|
||||
let b = borrow_color(&target).unwrap_or(a);
|
||||
let t = (fraction as f32).clamp(0.0, 1.0);
|
||||
let mix = |left: u8, right: u8| -> u8 {
|
||||
(left as f32 + (right as f32 - left as f32) * t)
|
||||
.clamp(0.0, 255.0)
|
||||
.round() as u8
|
||||
};
|
||||
lua.create_userdata(LuaColor::new(
|
||||
mix(a.r, b.r),
|
||||
mix(a.g, b.g),
|
||||
mix(a.b, b.b),
|
||||
mix(a.a, b.a),
|
||||
))
|
||||
},
|
||||
);
|
||||
m.add_function("ToTable", |lua, c: AnyUserData| {
|
||||
let c = *c.borrow::<Self>()?;
|
||||
let table = lua.create_table()?;
|
||||
table.set("r", c.r)?;
|
||||
table.set("g", c.g)?;
|
||||
table.set("b", c.b)?;
|
||||
table.set("a", c.a)?;
|
||||
Ok(table)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -109,9 +109,16 @@ impl IncludeState {
|
|||
if !candidate.starts_with(&canonical_root) && !candidate.starts_with(root) {
|
||||
continue;
|
||||
}
|
||||
if !candidate.is_file() {
|
||||
continue;
|
||||
}
|
||||
let candidate = if candidate.is_file() {
|
||||
candidate
|
||||
} else {
|
||||
// Windows-скрипты ссылаются на `gui/IconEditor.lua`, а на
|
||||
// Linux файл лежит как `gui/iconeditor.lua`.
|
||||
match case_insensitive_file(&candidate) {
|
||||
Some(found) => found,
|
||||
None => continue,
|
||||
}
|
||||
};
|
||||
// Канонизация ловит симлинк, ведущий наружу.
|
||||
let Ok(canonical) = candidate.canonicalize() else {
|
||||
continue;
|
||||
|
|
@ -124,6 +131,19 @@ impl IncludeState {
|
|||
}
|
||||
}
|
||||
|
||||
/// Ищет файл без учёта регистра имени (GMod на Windows регистр не различает).
|
||||
fn case_insensitive_file(path: &Path) -> Option<PathBuf> {
|
||||
let parent = path.parent()?;
|
||||
let name = path.file_name()?.to_str()?.to_lowercase();
|
||||
let entries = std::fs::read_dir(parent).ok()?;
|
||||
for entry in entries.flatten() {
|
||||
if entry.file_name().to_str()?.to_lowercase() == name {
|
||||
return Some(entry.path());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Ставит глобальную функцию `include`, связанную с состоянием.
|
||||
pub(crate) fn install(lua: &Lua, state: Rc<RefCell<IncludeState>>) -> mlua::Result<()> {
|
||||
let include = lua.create_function(move |lua, path: String| {
|
||||
|
|
|
|||
|
|
@ -8,8 +8,11 @@ pub mod gmod;
|
|||
mod include;
|
||||
pub mod stdlib;
|
||||
pub mod swep;
|
||||
pub mod ui;
|
||||
|
||||
use mlua::{Function, Lua, MultiValue, RegistryKey, Table, Value};
|
||||
pub use ui::{UiCommand, UiKind};
|
||||
|
||||
use mlua::{Function, Lua, MultiValue, RegistryKey, Table, Value, Variadic};
|
||||
use std::cell::RefCell;
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
use std::rc::Rc;
|
||||
|
|
@ -34,6 +37,68 @@ pub struct GamemodeLoad {
|
|||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
/// Итог загрузки UI: Derma, Sandbox spawnmenu и геймода.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct UiLoad {
|
||||
/// Выполненные файлы (пути как их видит `include`).
|
||||
pub loaded: Vec<String>,
|
||||
/// Ошибки: загрузка продолжается со следующего файла.
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
/// Максимум команд в одном кадре UI (см. [`ui::MAX_COMMANDS`]).
|
||||
pub const UI_COMMAND_LIMIT: usize = ui::MAX_COMMANDS;
|
||||
|
||||
/// VGUI-файлы, которые нужны Sandbox spawnmenu. Часть контролов может
|
||||
/// отсутствовать в установке игры — тогда для них ставится безопасная
|
||||
/// заглушка; настоящие файлы не перезаписываются.
|
||||
const VGUI_FILES: &[&str] = &[
|
||||
"dpanel.lua",
|
||||
"dlabel.lua",
|
||||
"dbutton.lua",
|
||||
"dframe.lua",
|
||||
"dscrollpanel.lua",
|
||||
"dhorizontalscroller.lua",
|
||||
"dexpandbutton.lua",
|
||||
"dvscrollbar.lua",
|
||||
"dhscrollbar.lua",
|
||||
"dhorizontaldivider.lua",
|
||||
"dverticaldivider.lua",
|
||||
"dpropertysheet.lua",
|
||||
"dimage.lua",
|
||||
"dimagebutton.lua",
|
||||
"dtextentry.lua",
|
||||
"dcategorycollapse.lua",
|
||||
"dcategorylist.lua",
|
||||
"dcombobox.lua",
|
||||
"dcheckbox.lua",
|
||||
"dlistbox.lua",
|
||||
"dlistview.lua",
|
||||
"dmenu.lua",
|
||||
"dmenubar.lua",
|
||||
"ddragbase.lua",
|
||||
"dtilelayout.lua",
|
||||
"dlistlayout.lua",
|
||||
"diconlayout.lua",
|
||||
"diconbrowser.lua",
|
||||
"dtree.lua",
|
||||
"dtree_node.lua",
|
||||
"dtree_node_button.lua",
|
||||
"dproperties.lua",
|
||||
"dform.lua",
|
||||
"dlabeleditable.lua",
|
||||
"dslider.lua",
|
||||
"dnumberwang.lua",
|
||||
"dnumslider.lua",
|
||||
"dcolorbutton.lua",
|
||||
"dcolormixer.lua",
|
||||
"dcolorpalette.lua",
|
||||
"dcolorcombo.lua",
|
||||
"dmodelpanel.lua",
|
||||
"dmodelselect.lua",
|
||||
"spawnicon.lua",
|
||||
];
|
||||
|
||||
/// Lua-рантайм клиента.
|
||||
pub struct LuaRuntime {
|
||||
lua: Lua,
|
||||
|
|
@ -54,6 +119,7 @@ impl LuaRuntime {
|
|||
gmod::install(&lua)?;
|
||||
stdlib::install(&lua, "unknown")?;
|
||||
include::install(&lua, include.clone())?;
|
||||
ui::install(&lua)?;
|
||||
|
||||
Ok(Self {
|
||||
lua,
|
||||
|
|
@ -377,7 +443,7 @@ impl LuaRuntime {
|
|||
entries
|
||||
}
|
||||
};
|
||||
entries.set(name, true)?;
|
||||
entries.set(name, &table)?;
|
||||
Ok(())
|
||||
})();
|
||||
if registered.is_ok() {
|
||||
|
|
@ -466,6 +532,146 @@ impl LuaRuntime {
|
|||
report
|
||||
}
|
||||
|
||||
/// Загружает настоящий клиентский UI Sandbox: Derma, базовые VGUI-классы,
|
||||
/// модуль `spawnmenu` и сам геймод.
|
||||
///
|
||||
/// Порядок повторяет GMod: сначала `lua/includes` (расширения строк/таблиц,
|
||||
/// `scriptedpanels`, настоящие `lua/vgui/*`), затем `lua/derma/init.lua` и
|
||||
/// скин, затем `gamemodes/sandbox` и хук `OnGamemodeLoaded`, который строит
|
||||
/// `g_SpawnMenu` через `vgui.Create`. Ошибки отдельных файлов собираются в
|
||||
/// [`UiLoad::errors`], но не мешают остальным: меню должно пережить
|
||||
/// отсутствующий контрол.
|
||||
///
|
||||
/// Вызывать после [`Self::set_file_root`] (метод ставит корень сам, если
|
||||
/// его ещё нет) и после загрузки скриптов оружия/энтити, если они нужны в
|
||||
/// спавн-меню.
|
||||
pub fn load_ui(&self, game_dir: &std::path::Path) -> UiLoad {
|
||||
let mut report = UiLoad::default();
|
||||
if let Err(err) = self.set_file_root(game_dir) {
|
||||
report.errors.push(format!("file.*: {err}"));
|
||||
return report;
|
||||
}
|
||||
|
||||
let include_file = |relative: &str| -> Result<(), LuaError> {
|
||||
self.run(relative, format!("include( {:?} )", relative).as_bytes())
|
||||
};
|
||||
|
||||
// Настоящие расширения GMod. Без `scriptedpanels.lua` vgui.Register и
|
||||
// vgui.Create остаются заглушками, а без `draw.lua` Derma рисует одним
|
||||
// примитивом.
|
||||
for relative in [
|
||||
"includes/modules/baseclass.lua",
|
||||
"includes/extensions/string.lua",
|
||||
"includes/extensions/table.lua",
|
||||
"includes/modules/draw.lua",
|
||||
// scriptedpanels — ядро vgui.Register/Create. Грузим до
|
||||
// panel.lua: ошибка в расширениях ввода не должна оставить
|
||||
// Derma без реестра классов.
|
||||
"includes/extensions/client/panel/scriptedpanels.lua",
|
||||
"includes/extensions/client/panel.lua",
|
||||
] {
|
||||
match include_file(relative) {
|
||||
Ok(()) => report.loaded.push(relative.to_string()),
|
||||
Err(err) => report.errors.push(format!("{relative}: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
// Ограниченный table.Merge и Panel:Add для табличных панелей:
|
||||
// настоящие версии рекурсивны/не различают панель и класс.
|
||||
if let Err(err) = self.run("ui_patch.lua", b"__sr_ui_patch_extensions()") {
|
||||
report.errors.push(format!("ui patch: {err}"));
|
||||
}
|
||||
|
||||
// Модуль spawnmenu — это `lua/includes/modules/spawnmenu.lua`; sandbox
|
||||
// лишь дополняет его. Без него `spawnmenu.AddCreationTab` не существует.
|
||||
for relative in [
|
||||
"includes/modules/spawnmenu.lua",
|
||||
"includes/modules/controlpanel.lua",
|
||||
] {
|
||||
match include_file(relative) {
|
||||
Ok(()) => report.loaded.push(relative.to_string()),
|
||||
Err(err) => report.errors.push(format!("{relative}: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
match include_file("derma/init.lua") {
|
||||
Ok(()) => report.loaded.push("derma/init.lua".to_string()),
|
||||
Err(err) => report.errors.push(format!("derma/init.lua: {err}")),
|
||||
}
|
||||
match include_file("skins/default.lua") {
|
||||
Ok(()) => report.loaded.push("skins/default.lua".to_string()),
|
||||
Err(err) => report.errors.push(format!("skins/default.lua: {err}")),
|
||||
}
|
||||
|
||||
for relative in VGUI_FILES {
|
||||
match include_file(&format!("vgui/{relative}")) {
|
||||
Ok(()) => report.loaded.push(format!("vgui/{relative}")),
|
||||
Err(err) => report.errors.push(format!("vgui/{relative}: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
// Заглушки только для того, что не загрузилось. Настоящий класс не
|
||||
// перезаписывается: `__sr_ui_register_fallback` проверяет реестр.
|
||||
if let Err(err) = self.run("ui_fallbacks.lua", b"__sr_ui_install_fallbacks()") {
|
||||
report.errors.push(format!("ui fallbacks: {err}"));
|
||||
}
|
||||
if let Err(err) = self.run("ui_finalize.lua", b"__sr_ui_finalize()") {
|
||||
report.errors.push(format!("ui finalize: {err}"));
|
||||
}
|
||||
|
||||
// Словарь `#spawnmenu.*` из настоящих файлов локализации.
|
||||
if let Err(err) = self.run("ui_language.lua", b"__sr_ui.load_language('en')") {
|
||||
report.errors.push(format!("language: {err}"));
|
||||
}
|
||||
|
||||
// Геймод Sandbox грузим, только если его ещё нет: повторный
|
||||
// load_gamemode выполнил бы init.lua заново (include-кэш его не
|
||||
// защищает, потому что загрузчик читает файлы напрямую).
|
||||
let already = self
|
||||
.probe("return GAMEMODE and GAMEMODE.Name or ''")
|
||||
.ok()
|
||||
.flatten()
|
||||
.as_deref()
|
||||
== Some("Sandbox");
|
||||
if !already {
|
||||
let gamemode = self.load_gamemode(game_dir, "sandbox");
|
||||
report.loaded.extend(
|
||||
gamemode
|
||||
.loaded
|
||||
.iter()
|
||||
.map(|name| format!("gamemode/sandbox/{name}")),
|
||||
);
|
||||
report.errors.extend(gamemode.errors);
|
||||
}
|
||||
|
||||
// `OnGamemodeLoaded` создаёт g_SpawnMenu: к этому моменту все классы
|
||||
// Derma и vgui уже зарегистрированы.
|
||||
match self.call_hook("OnGamemodeLoaded", MultiValue::new()) {
|
||||
Ok(()) => report.loaded.push("hook:OnGamemodeLoaded".to_string()),
|
||||
Err(err) => report.errors.push(format!("OnGamemodeLoaded: {err}")),
|
||||
}
|
||||
if let Err(err) = self.run(
|
||||
"ui_root.lua",
|
||||
b"__sr_ui.attach_root( rawget( _G, 'g_SpawnMenu' ) )",
|
||||
) {
|
||||
report.errors.push(format!("ui root: {err}"));
|
||||
}
|
||||
|
||||
// Ошибки Init/Paint, собранные Lua-слоем (а не загрузчиком файлов).
|
||||
// Забираем один раз, чтобы клиент показал их в консоли, а поле не
|
||||
// росло на каждом кадре.
|
||||
if let Ok(Some(errors)) = self.probe(
|
||||
"local text = table.concat(__sr_ui.errors, '\\n'); __sr_ui.errors = {}; return text",
|
||||
) {
|
||||
for line in errors.lines() {
|
||||
if !line.is_empty() {
|
||||
report.errors.push(format!("ui: {line}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
/// Ставит `file.*` с песочницей на каталог игры.
|
||||
pub fn set_file_root(&self, root: &std::path::Path) -> Result<(), LuaError> {
|
||||
stdlib::install_file(&self.lua, root)?;
|
||||
|
|
@ -548,10 +754,25 @@ impl LuaRuntime {
|
|||
}
|
||||
}
|
||||
let count = due.len();
|
||||
let mut first_error = None;
|
||||
for key in due {
|
||||
let func: Function = self.lua.registry_value(&key)?;
|
||||
func.call::<()>(())?;
|
||||
self.lua.remove_registry_value(key)?;
|
||||
// Один упавший таймер не должен отменять остальные: на
|
||||
// `timer.Simple(0, ...)` держится наполнение spawnmenu.
|
||||
let result = (|| -> mlua::Result<()> {
|
||||
let func: Function = self.lua.registry_value(&key)?;
|
||||
func.call::<()>(())?;
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(err) = result {
|
||||
eprintln!("таймер: {err}");
|
||||
if first_error.is_none() {
|
||||
first_error = Some(err);
|
||||
}
|
||||
}
|
||||
let _ = self.lua.remove_registry_value(key);
|
||||
}
|
||||
if let Some(err) = first_error {
|
||||
return Err(err.into());
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
|
@ -676,6 +897,16 @@ fn install_api(lua: &Lua, timers: &TimerQueue, output: &Output) -> Result<(), ml
|
|||
let name = name.to_str()?.to_string();
|
||||
let _gm = iter.next();
|
||||
let rest: Vec<Value> = iter.cloned().collect();
|
||||
// Сначала метод геймода: `GM:SpawnMenuEnabled` и подобные хуки не
|
||||
// регистрируются через hook.Add, но hook.Run их вызывает.
|
||||
if let Ok(Value::Table(gamemode)) = lua.globals().get::<Value>("GAMEMODE") {
|
||||
if let Ok(Value::Function(func)) = gamemode.get::<Value>(name.as_str()) {
|
||||
let args: Vec<Value> = std::iter::once(Value::Table(gamemode.clone()))
|
||||
.chain(rest.iter().cloned())
|
||||
.collect();
|
||||
let _ = func.call::<Value>(Variadic::from(args))?;
|
||||
}
|
||||
}
|
||||
let hooks: Table = lua.globals().get("__sr_hooks")?;
|
||||
if let Value::Table(list) = hooks.get(name)? {
|
||||
// Снимок значений, чтобы вызовы не сбивали итерацию.
|
||||
|
|
@ -929,9 +1160,69 @@ fn install_api(lua: &Lua, timers: &TimerQueue, output: &Output) -> Result<(), ml
|
|||
/// Оба прохода лексические: строки и комментарии остаются байт-в-байт.
|
||||
pub fn preprocess(source: &str) -> String {
|
||||
let converted = preprocess_operators(source);
|
||||
let converted = preprocess_define_baseclass(&converted);
|
||||
preprocess_continue(&converted)
|
||||
}
|
||||
|
||||
/// `DEFINE_BASECLASS( "X" )` → `local BaseClass = baseclass.Get( "X" )`.
|
||||
///
|
||||
/// В GMod это макрос движка, а не функция: `BaseClass` обязан быть локальной
|
||||
/// переменной файла, иначе последний загруженный класс подменил бы базу всем
|
||||
/// предыдущим.
|
||||
fn preprocess_define_baseclass(source: &str) -> String {
|
||||
const MACRO: &str = "DEFINE_BASECLASS";
|
||||
let bytes = source.as_bytes();
|
||||
let mut out = String::with_capacity(source.len() + 64);
|
||||
let mut i = 0usize;
|
||||
while i < bytes.len() {
|
||||
// Комментарии и строки копируются без изменений.
|
||||
if bytes[i] == b'-' && bytes.get(i + 1) == Some(&b'-') {
|
||||
let start = i;
|
||||
while i < bytes.len() && bytes[i] != b'\n' {
|
||||
i += 1;
|
||||
}
|
||||
out.push_str(&source[start..i]);
|
||||
continue;
|
||||
}
|
||||
if bytes[i] == b'"' || bytes[i] == b'\'' {
|
||||
let quote = bytes[i];
|
||||
let start = i;
|
||||
i += 1;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'\\' {
|
||||
i = (i + 2).min(bytes.len());
|
||||
continue;
|
||||
}
|
||||
if bytes[i] == quote {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
out.push_str(&source[start..i.min(source.len())]);
|
||||
continue;
|
||||
}
|
||||
if source[i..].starts_with(MACRO) {
|
||||
let before_ok = i == 0 || !is_name_continue_byte(bytes[i - 1]);
|
||||
let after = i + MACRO.len();
|
||||
let after_ok = after >= bytes.len() || !is_name_continue_byte(bytes[after]);
|
||||
if before_ok && after_ok {
|
||||
out.push_str("local BaseClass = baseclass.Get");
|
||||
i = after;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let ch = source[i..].chars().next().map_or(1, char::len_utf8);
|
||||
out.push_str(&source[i..i + ch]);
|
||||
i += ch;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn is_name_continue_byte(byte: u8) -> bool {
|
||||
byte.is_ascii_alphanumeric() || byte == b'_'
|
||||
}
|
||||
|
||||
/// `!`, `!=`, `&&`, `||` вне строк и комментариев.
|
||||
fn preprocess_operators(source: &str) -> String {
|
||||
let bytes = source.as_bytes();
|
||||
|
|
@ -964,6 +1255,14 @@ fn preprocess_operators(source: &str) -> String {
|
|||
continue;
|
||||
}
|
||||
}
|
||||
// `//` в скриптах GMod — комментарий (полноценного `//` в LuaJIT
|
||||
// нет; движок срезает его тем же препроцессором).
|
||||
if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
|
||||
while i < bytes.len() && bytes[i] != b'\n' {
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Обычные строки.
|
||||
if b == b'"' || b == b'\'' {
|
||||
let quote = b;
|
||||
|
|
@ -1587,6 +1886,21 @@ local t = [[raw ! text]]
|
|||
assert!(out2.contains("a and b or c ~= d"), "{out2}");
|
||||
}
|
||||
|
||||
/// `//` — комментарий GMod, а `DEFINE_BASECLASS` — макрос движка,
|
||||
/// превращающий базу в локальную переменную файла.
|
||||
#[test]
|
||||
fn preprocess_gmod_comments_and_baseclass() {
|
||||
let out = preprocess(
|
||||
"// comment with ! &&\nDEFINE_BASECLASS( \"DScrollPanel\" )\nlocal x = 1 // tail\n",
|
||||
);
|
||||
assert!(!out.contains("comment with"), "{out}");
|
||||
assert!(!out.contains("// tail"), "{out}");
|
||||
assert!(
|
||||
out.contains("local BaseClass = baseclass.Get( \"DScrollPanel\" )"),
|
||||
"{out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preprocess_continue_becomes_goto() {
|
||||
let src = r#"
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
//! `hook.Run` — 63 вызова, `math.Clamp` — 61, `game.SinglePlayer` — 36,
|
||||
//! `math.Rand` — 27. Это первые по частоте пробелы, и все они дешёвые.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::rc::Rc;
|
||||
|
||||
|
|
@ -39,6 +40,22 @@ pub fn install(lua: &Lua, map: &str) -> Result<(), mlua::Error> {
|
|||
/// на первом же `list.Set("Weapon", ...)`.
|
||||
fn install_list(lua: &Lua) -> Result<(), mlua::Error> {
|
||||
lua.globals().set("__sr_lists", lua.create_table()?)?;
|
||||
// Списки, которые в GMod заполняет движок. Пустая таблица — честный
|
||||
// ответ: `for k, v in pairs(list.Get("NPC"))` не должен падать.
|
||||
{
|
||||
let lists: Table = lua.globals().get("__sr_lists")?;
|
||||
for kind in [
|
||||
"NPC",
|
||||
"Vehicles",
|
||||
"Effects",
|
||||
"PostProcess",
|
||||
"ContentCategoryIcons",
|
||||
"SpawnableEntities",
|
||||
"Weapon",
|
||||
] {
|
||||
lists.set(kind, lua.create_table()?)?;
|
||||
}
|
||||
}
|
||||
let list = lua.create_table()?;
|
||||
let set = lua.create_function(|lua, (kind, name, value): (String, String, Value)| {
|
||||
let lists: Table = lua.globals().get("__sr_lists")?;
|
||||
|
|
@ -158,7 +175,7 @@ fn install_scripted_ents(lua: &Lua) -> Result<(), mlua::Error> {
|
|||
};
|
||||
table.set("Type", class.clone())?;
|
||||
let store: Table = lua.globals().get("__sr_ents")?;
|
||||
store.set(class.clone(), table)?;
|
||||
store.set(class.clone(), &table)?;
|
||||
let lists: Table = lua.globals().get("__sr_lists")?;
|
||||
let entries: Table = match lists.get::<Value>("SpawnableEntities")? {
|
||||
Value::Table(entries) => entries,
|
||||
|
|
@ -168,7 +185,7 @@ fn install_scripted_ents(lua: &Lua) -> Result<(), mlua::Error> {
|
|||
entries
|
||||
}
|
||||
};
|
||||
entries.set(class, true)?;
|
||||
entries.set(class, table)?;
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
|
@ -544,6 +561,22 @@ fn install_hook_run(lua: &Lua) -> Result<(), mlua::Error> {
|
|||
};
|
||||
// Первый аргумент — имя хука, его передаём обратно первым.
|
||||
let rest: Vec<Value> = args.iter().skip(1).cloned().collect();
|
||||
// Метод геймода вызывается до hook.Add-хуков и может отменить
|
||||
// цепочку (`return false`), как в настоящем GMod.
|
||||
if let Ok(Value::Table(gamemode)) = lua.globals().get::<Value>("GAMEMODE") {
|
||||
if let Ok(Value::Function(func)) = gamemode.get::<Value>(name.as_str()) {
|
||||
let call_args: Vec<Value> = std::iter::once(Value::Table(gamemode.clone()))
|
||||
.chain(rest.iter().cloned())
|
||||
.collect();
|
||||
let value = func.call::<MultiValue>(Variadic::from(call_args))?;
|
||||
if !value.is_empty() {
|
||||
let first = value.front().cloned().unwrap_or(Value::Nil);
|
||||
if !first.is_nil() {
|
||||
return Ok(first);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let list: Table = match lua.globals().get::<Value>("__sr_hooks")? {
|
||||
Value::Table(registry) => match registry.get::<Value>(name.clone())? {
|
||||
Value::Table(list) => list,
|
||||
|
|
@ -900,18 +933,75 @@ pub fn install_file(lua: &Lua, root: &Path) -> Result<(), mlua::Error> {
|
|||
file.set(
|
||||
"Find",
|
||||
lua.create_function(move |lua, (pattern, _kind): (String, Option<String>)| {
|
||||
let found = glob_lite(&find_root, &pattern);
|
||||
let mut list: Vec<Value> = Vec::new();
|
||||
for path in found {
|
||||
if let Ok(relative) = path.strip_prefix(find_root.as_path()) {
|
||||
let text = relative.to_string_lossy().replace('\\', "/");
|
||||
list.push(Value::String(lua.create_string(&text)?));
|
||||
}
|
||||
let (files, folders) = glob_find(&find_root, &pattern);
|
||||
// GMod возвращает ровно две таблицы: файлы и каталоги.
|
||||
let file_table = lua.create_table()?;
|
||||
for (index, name) in files.into_iter().enumerate() {
|
||||
file_table.set(index + 1, name)?;
|
||||
}
|
||||
Ok(Variadic::from(list))
|
||||
let folder_table = lua.create_table()?;
|
||||
for (index, name) in folders.into_iter().enumerate() {
|
||||
folder_table.set(index + 1, name)?;
|
||||
}
|
||||
Ok((file_table, folder_table))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `file.Open` нужен экрану сохранений; чтение целого файла уже есть в
|
||||
// `file.Read`, поэтому объект отдаёт строки из памяти и не держит дескриптор.
|
||||
let open_root = root.clone();
|
||||
file.set(
|
||||
"Open",
|
||||
lua.create_function(
|
||||
move |lua, (relative, mode, _kind): (String, Option<String>, Option<String>)| {
|
||||
let _ = mode;
|
||||
let Some(path) = resolve_inside(&open_root, &relative) else {
|
||||
return Ok(Value::Nil);
|
||||
};
|
||||
const MAX_OPEN: u64 = 8 * 1024 * 1024;
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
return Ok(Value::Nil);
|
||||
};
|
||||
if bytes.len() as u64 > MAX_OPEN {
|
||||
return Ok(Value::Nil);
|
||||
}
|
||||
let text = String::from_utf8_lossy(&bytes).to_string();
|
||||
let lines = Rc::new(RefCell::new(
|
||||
text.split('\n')
|
||||
.map(|line| line.to_string())
|
||||
.collect::<Vec<_>>(),
|
||||
));
|
||||
let handle = lua.create_table()?;
|
||||
let read_lines = lines.clone();
|
||||
handle.set(
|
||||
"ReadLine",
|
||||
lua.create_function(move |lua, ()| {
|
||||
let mut lines = read_lines.borrow_mut();
|
||||
if lines.is_empty() {
|
||||
return Ok(Value::Nil);
|
||||
}
|
||||
let line = lines.remove(0);
|
||||
Ok(Value::String(lua.create_string(&line)?))
|
||||
})?,
|
||||
)?;
|
||||
let read_all = lines.clone();
|
||||
handle.set(
|
||||
"Read",
|
||||
lua.create_function(move |lua, ()| {
|
||||
let text = read_all.borrow().join("\n");
|
||||
lua.create_string(text).map(Value::String)
|
||||
})?,
|
||||
)?;
|
||||
handle.set("Close", lua.create_function(|_, ()| Ok(()))?)?;
|
||||
handle.set(
|
||||
"Size",
|
||||
lua.create_function(move |_, ()| Ok(text.len() as i64))?,
|
||||
)?;
|
||||
Ok(Value::Table(handle))
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
|
||||
globals.set("file", &file)?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -949,33 +1039,70 @@ fn normalize(path: PathBuf) -> PathBuf {
|
|||
out
|
||||
}
|
||||
|
||||
/// Минимальный поиск по шаблону: поддерживает `*` в имени файла и `**`.
|
||||
/// Поиск файлов и каталогов по шаблону в стиле GMod `file.Find`.
|
||||
///
|
||||
/// Полноценный glob здесь не нужен и был бы лишней зависимостью: скрипты
|
||||
/// используют `file.Find("lua/autorun/*.lua", "LUA")`, то есть ровно один
|
||||
/// уровень подстановки.
|
||||
fn glob_lite(root: &Path, pattern: &str) -> Vec<PathBuf> {
|
||||
let mut result = Vec::new();
|
||||
let Some(relative) = pattern.strip_suffix("/*") else {
|
||||
// Без `*` — это конкретный файл или каталог.
|
||||
let path = normalize(root.join(pattern));
|
||||
if path.starts_with(root) && path.exists() {
|
||||
result.push(path);
|
||||
}
|
||||
return result;
|
||||
/// Поддерживается `*` в последнем компоненте пути (`*.txt`, `*`, `*.mdl`) и
|
||||
/// разделение результата на файлы и каталоги. Имена возвращаются без каталога
|
||||
/// поиска — именно этого ждут скрипты (`folder .. "/" .. file`).
|
||||
fn glob_find(root: &Path, pattern: &str) -> (Vec<String>, Vec<String>) {
|
||||
let mut files = Vec::new();
|
||||
let mut folders = Vec::new();
|
||||
let pattern = pattern.replace('\\', "/");
|
||||
let pattern = pattern.trim_start_matches('/');
|
||||
let (directory_part, last) = match pattern.rsplit_once('/') {
|
||||
Some((directory, name)) => (directory, name),
|
||||
None => ("", pattern),
|
||||
};
|
||||
let directory = normalize(root.join(relative));
|
||||
let directory = normalize(root.join(directory_part));
|
||||
if !directory.starts_with(root) || !directory.is_dir() {
|
||||
return result;
|
||||
return (files, folders);
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(&directory) else {
|
||||
return result;
|
||||
return (files, folders);
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
result.push(entry.path());
|
||||
let mut names: Vec<(String, bool)> = entries
|
||||
.flatten()
|
||||
.filter_map(|entry| {
|
||||
let name = entry.file_name().to_str()?.to_string();
|
||||
Some((name, entry.path().is_dir()))
|
||||
})
|
||||
.collect();
|
||||
names.sort_by(|a, b| {
|
||||
a.0.to_lowercase()
|
||||
.cmp(&b.0.to_lowercase())
|
||||
.then(a.0.cmp(&b.0))
|
||||
});
|
||||
for (name, is_dir) in names {
|
||||
if !wildcard_match(last, &name) {
|
||||
continue;
|
||||
}
|
||||
if is_dir {
|
||||
folders.push(name);
|
||||
} else {
|
||||
files.push(name);
|
||||
}
|
||||
}
|
||||
result.sort();
|
||||
result
|
||||
(files, folders)
|
||||
}
|
||||
|
||||
/// Простое сопоставление с `*` (без `?` и классов символов).
|
||||
fn wildcard_match(pattern: &str, name: &str) -> bool {
|
||||
if pattern == "*" {
|
||||
return true;
|
||||
}
|
||||
let Some(first_star) = pattern.find('*') else {
|
||||
return pattern == name;
|
||||
};
|
||||
let prefix = &pattern[..first_star];
|
||||
let suffix = &pattern[first_star + 1..];
|
||||
if suffix.contains('*') {
|
||||
// Несколько `*` в именах файлов игры не встречаются; честно
|
||||
// обрабатываем только хвост, чтобы не пропустить файл.
|
||||
return name.len() >= prefix.len() + suffix.len()
|
||||
&& name.starts_with(prefix)
|
||||
&& name.ends_with(suffix);
|
||||
}
|
||||
name.len() >= prefix.len() + suffix.len() && name.starts_with(prefix) && name.ends_with(suffix)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -1251,7 +1378,27 @@ mod tests {
|
|||
);
|
||||
|
||||
let found = eval(&lua, "return file.Find('lua/*', 'LUA')");
|
||||
assert_eq!(found.len(), 1, "find должен найти autorun.lua");
|
||||
assert_eq!(found.len(), 2, "file.Find отдаёт файлы и каталоги");
|
||||
assert_eq!(
|
||||
string(&lua, "local f = file.Find('lua/*', 'LUA') return f[1]"),
|
||||
"autorun.lua",
|
||||
"имя без каталога поиска"
|
||||
);
|
||||
assert_eq!(
|
||||
number(&lua, "local f = file.Find('lua/*', 'LUA') return #f"),
|
||||
1.0
|
||||
);
|
||||
assert_eq!(
|
||||
number(&lua, "local f, d = file.Find('lua/*', 'LUA') return #d"),
|
||||
0.0
|
||||
);
|
||||
|
||||
// Шаблон с расширением: именно так читаются spawnlist_default/*.txt.
|
||||
std::fs::write(dir.join("lua").join("a.txt"), b"x").expect("файл");
|
||||
std::fs::write(dir.join("lua").join("b.mdl"), b"x").expect("файл");
|
||||
let (files, folders) = glob_find(&dir, "lua/*.txt");
|
||||
assert_eq!(files, vec!["a.txt".to_string()]);
|
||||
assert!(folders.is_empty());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let _ = std::fs::remove_file(dir.parent().unwrap().join("secret_outside.txt"));
|
||||
}
|
||||
|
|
|
|||
304
crates/lua/src/ui.rs
Normal file
304
crates/lua/src/ui.rs
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
//! Слой VGUI/Derma для Lua-скриптов Sandbox.
|
||||
//!
|
||||
//! Rust-сторона этого модуля намеренно тонкая: панели, раскладка и `Paint`
|
||||
//! живут в Lua (`bootstrap.lua`), потому что скрипты игры создают и рисуют их
|
||||
//! сами. Здесь только:
|
||||
//!
|
||||
//! - снимок кадра [`UiCommand`] для рендера (не больше [`MAX_COMMANDS`]);
|
||||
//! - открытие/закрытие корневого меню и состояние видимости;
|
||||
//! - передача мыши в дерево панелей.
|
||||
//!
|
||||
//! `__sr_ui.snapshot()` — настоящий обход панелей из Lua: он вызывает
|
||||
//! `Panel:Paint(w, h)`, а `surface.DrawRect`/`draw.SimpleText` складывают
|
||||
//! команды в список. Rust лишь читает этот список и превращает его в
|
||||
//! [`UiCommand`], отбрасывая всё, что выходит за лимиты.
|
||||
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
|
||||
use mlua::{Function, Lua, Table, Value};
|
||||
|
||||
use crate::LuaError;
|
||||
|
||||
/// Максимум команд в одном кадре. Скрипт-аддон не должен уметь залить
|
||||
/// рендер миллионами прямоугольников (правило #1 AGENTS.md).
|
||||
pub const MAX_COMMANDS: usize = 4096;
|
||||
|
||||
/// Максимум аргументов одного снимка — тот же предел, но отдельной
|
||||
/// константой, чтобы рендер мог заранее выделить буфер.
|
||||
pub const MAX_UI_DEPTH: usize = 32;
|
||||
|
||||
/// Что рисует команда.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum UiKind {
|
||||
/// Залитый прямоугольник (`surface.DrawRect`).
|
||||
Rect,
|
||||
/// Контур (`surface.DrawOutlinedRect`).
|
||||
Outline,
|
||||
/// Текст (`draw.SimpleText`/`draw.DrawText`).
|
||||
Text,
|
||||
}
|
||||
|
||||
/// Одна команда кадра в пикселях экрана.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UiCommand {
|
||||
pub kind: UiKind,
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub w: f32,
|
||||
pub h: f32,
|
||||
/// RGBA, компоненты 0..1.
|
||||
pub color: [f32; 4],
|
||||
pub text: Option<String>,
|
||||
}
|
||||
|
||||
/// Устанавливает `__sr_ui` и базовый Lua-слой (панели, surface, draw).
|
||||
pub(crate) fn install(lua: &Lua) -> Result<(), mlua::Error> {
|
||||
let ui = lua.create_table()?;
|
||||
ui.set("visible", false)?;
|
||||
ui.set("recording", false)?;
|
||||
ui.set("commands", Value::Nil)?;
|
||||
ui.set("command_limit", MAX_COMMANDS)?;
|
||||
ui.set("depth_limit", MAX_UI_DEPTH)?;
|
||||
ui.set("screen_w", 1280.0)?;
|
||||
ui.set("screen_h", 720.0)?;
|
||||
ui.set("mouse_x", 0.0)?;
|
||||
ui.set("mouse_y", 0.0)?;
|
||||
ui.set("pressed", Value::Nil)?;
|
||||
ui.set("root", Value::Nil)?;
|
||||
ui.set("paint_errors", 0)?;
|
||||
lua.globals().set("__sr_ui", &ui)?;
|
||||
|
||||
// Lua-половина слоя: базовые панели, `surface`/`draw`, поиск под курсором.
|
||||
let chunk = lua
|
||||
.load(include_str!("ui/bootstrap.lua"))
|
||||
.set_name("ui/bootstrap.lua");
|
||||
chunk.exec()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl crate::LuaRuntime {
|
||||
/// Открыть или закрыть корневое меню (Sandbox spawnmenu).
|
||||
///
|
||||
/// Если меню ещё не создано скриптами, состояние всё равно запоминается:
|
||||
/// когда `OnGamemodeLoaded` построит `g_SpawnMenu`, он откроется.
|
||||
pub fn ui_toggle(&self, open: bool) {
|
||||
let _ = catch_unwind(AssertUnwindSafe(|| {
|
||||
let Ok(ui) = self.lua.globals().get::<Table>("__sr_ui") else {
|
||||
return;
|
||||
};
|
||||
let Ok(toggle) = ui.get::<Function>("toggle") else {
|
||||
return;
|
||||
};
|
||||
let _ = toggle.call::<()>(open);
|
||||
}));
|
||||
}
|
||||
|
||||
/// Видимо ли корневое меню на текущий кадр.
|
||||
pub fn ui_visible(&self) -> bool {
|
||||
catch_unwind(AssertUnwindSafe(|| {
|
||||
let ui: Table = self.lua.globals().get("__sr_ui")?;
|
||||
ui.get::<bool>("visible")
|
||||
}))
|
||||
.ok()
|
||||
.and_then(Result::ok)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Снимок видимого дерева панелей на текущий кадр.
|
||||
///
|
||||
/// Пустой список, если меню закрыто или скрипты ещё не создали панели.
|
||||
/// Число команд ограничено [`MAX_COMMANDS`], глубина — [`MAX_UI_DEPTH`]
|
||||
/// (проверяется в Lua, здесь — вторая линия защиты).
|
||||
pub fn ui_snapshot(&self) -> Vec<UiCommand> {
|
||||
catch_unwind(AssertUnwindSafe(|| self.ui_snapshot_inner()))
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn ui_snapshot_inner(&self) -> Option<Vec<UiCommand>> {
|
||||
// Отложенные `timer.Simple(0, ...)` создают вкладки и контент
|
||||
// spawnmenu; без этого шага снимок был бы пустым деревом.
|
||||
let _ = self.tick();
|
||||
let ui: Table = self.lua.globals().get("__sr_ui").ok()?;
|
||||
let snapshot: Function = ui.get("snapshot").ok()?;
|
||||
let Value::Table(list) = snapshot.call::<Value>(()).ok()? else {
|
||||
return None;
|
||||
};
|
||||
let mut commands = Vec::new();
|
||||
for pair in list.sequence_values::<Value>() {
|
||||
if commands.len() >= MAX_COMMANDS {
|
||||
break;
|
||||
}
|
||||
let Ok(Value::Table(entry)) = pair else {
|
||||
continue;
|
||||
};
|
||||
let Ok(kind) = entry.get::<String>("kind") else {
|
||||
continue;
|
||||
};
|
||||
let kind = match kind.as_str() {
|
||||
"rect" => UiKind::Rect,
|
||||
"outline" => UiKind::Outline,
|
||||
"text" => UiKind::Text,
|
||||
_ => continue,
|
||||
};
|
||||
let number = |key: &str| -> f32 {
|
||||
entry
|
||||
.get::<Value>(key)
|
||||
.ok()
|
||||
.and_then(|value| crate::gmod::value_number(&self.lua, &value))
|
||||
.filter(|value| value.is_finite())
|
||||
.unwrap_or(0.0) as f32
|
||||
};
|
||||
let color = entry
|
||||
.get::<Value>("color")
|
||||
.ok()
|
||||
.and_then(|value| value.as_table().cloned())
|
||||
.map(|color| {
|
||||
let component = |index: usize| -> f32 {
|
||||
color
|
||||
.get::<Value>(index)
|
||||
.ok()
|
||||
.and_then(|value| crate::gmod::value_number(&self.lua, &value))
|
||||
.filter(|value| value.is_finite())
|
||||
.unwrap_or(0.0)
|
||||
.clamp(0.0, 1.0) as f32
|
||||
};
|
||||
[component(1), component(2), component(3), component(4)]
|
||||
})
|
||||
.unwrap_or([1.0, 1.0, 1.0, 1.0]);
|
||||
let text = entry
|
||||
.get::<Option<String>>("text")
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|text| {
|
||||
// Текст рисуется на экране: длиннее пары тысяч символов
|
||||
// он всё равно не влезет, а память съест.
|
||||
if text.chars().count() > 2048 {
|
||||
text.chars().take(2048).collect()
|
||||
} else {
|
||||
text
|
||||
}
|
||||
});
|
||||
commands.push(UiCommand {
|
||||
kind,
|
||||
x: number("x"),
|
||||
y: number("y"),
|
||||
w: number("w"),
|
||||
h: number("h"),
|
||||
color,
|
||||
text,
|
||||
});
|
||||
}
|
||||
Some(commands)
|
||||
}
|
||||
|
||||
/// Передаёт мышь в Lua-дерево панелей.
|
||||
///
|
||||
/// `kind`: `"move"`, `"down"`, `"up"`, `"wheel"`; `x`/`y` — пиксели от
|
||||
/// левого верхнего угла экрана; `button` — 1 (ЛКМ) или 2 (ПКМ), для
|
||||
/// колеса — направление (+1 вверх, -1 вниз).
|
||||
pub fn ui_mouse(&self, kind: &str, x: f32, y: f32, button: i32) {
|
||||
let _ = catch_unwind(AssertUnwindSafe(|| -> Result<(), LuaError> {
|
||||
let ui: Table = self.lua.globals().get("__sr_ui")?;
|
||||
let mouse: Function = ui.get("mouse")?;
|
||||
mouse.call::<()>((kind.to_string(), x, y, button))?;
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Задаёт размер экрана для `ScrW()`/`ScrH()` и раскладки.
|
||||
///
|
||||
/// Рендер-агент вызывает это при изменении окна; по умолчанию 1280×720.
|
||||
pub fn ui_set_screen(&self, width: u32, height: u32) {
|
||||
let _ = catch_unwind(AssertUnwindSafe(|| -> mlua::Result<()> {
|
||||
let ui: Table = self.lua.globals().get("__sr_ui")?;
|
||||
ui.set("screen_w", width.clamp(1, 16384) as f64)?;
|
||||
ui.set("screen_h", height.clamp(1, 16384) as f64)?;
|
||||
// Корневое меню в GMod занимает весь экран.
|
||||
if let Ok(Value::Table(root)) = ui.get::<Value>("root")
|
||||
&& let Ok(resize) = root.get::<Function>("__sr_resize_to_screen")
|
||||
{
|
||||
let _ = resize.call::<()>(());
|
||||
}
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::LuaRuntime;
|
||||
|
||||
fn runtime() -> LuaRuntime {
|
||||
LuaRuntime::new().expect("рантайм")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_starts_closed_and_empty() {
|
||||
let rt = runtime();
|
||||
assert!(!rt.ui_visible());
|
||||
assert!(rt.ui_snapshot().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_records_panels_and_text() {
|
||||
let rt = runtime();
|
||||
// Тестовая панель: рисует фон и текст, как настоящий DLabel.
|
||||
rt.run(
|
||||
"ui_test.lua",
|
||||
br#"
|
||||
local p = vgui.Create( "Panel" )
|
||||
p:SetPos( 10, 20 )
|
||||
p:SetSize( 200, 50 )
|
||||
function p:Paint( w, h )
|
||||
surface.SetDrawColor( 255, 0, 0, 255 )
|
||||
surface.DrawRect( 0, 0, w, h )
|
||||
draw.SimpleText( "Hello", "DermaDefault", 5, 5, Color( 255, 255, 255, 255 ) )
|
||||
end
|
||||
__sr_ui.root = p
|
||||
__sr_ui.visible = true
|
||||
"#,
|
||||
)
|
||||
.expect("панель создана");
|
||||
let commands = rt.ui_snapshot();
|
||||
assert!(commands.len() >= 2, "команды: {commands:?}");
|
||||
assert!(
|
||||
commands
|
||||
.iter()
|
||||
.any(|command| command.kind == crate::ui::UiKind::Rect)
|
||||
);
|
||||
assert!(
|
||||
commands
|
||||
.iter()
|
||||
.any(|command| command.text.as_deref() == Some("Hello"))
|
||||
);
|
||||
// Координаты детей складываются с позицией родителя.
|
||||
let text = commands
|
||||
.iter()
|
||||
.find(|command| command.text.is_some())
|
||||
.expect("текст");
|
||||
assert!((text.x - 15.0).abs() < 0.01, "x={}", text.x);
|
||||
assert!((text.y - 25.0).abs() < 0.01, "y={}", text.y);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_respects_command_limit() {
|
||||
let rt = runtime();
|
||||
rt.run(
|
||||
"ui_limit.lua",
|
||||
br#"
|
||||
local p = vgui.Create( "Panel" )
|
||||
p:SetSize( 100, 100 )
|
||||
function p:Paint( w, h )
|
||||
for i = 1, 100000 do surface.DrawRect( 0, 0, 1, 1 ) end
|
||||
end
|
||||
__sr_ui.root = p
|
||||
__sr_ui.visible = true
|
||||
"#,
|
||||
)
|
||||
.expect("панель создана");
|
||||
let commands = rt.ui_snapshot();
|
||||
assert_eq!(commands.len(), crate::ui::MAX_COMMANDS);
|
||||
}
|
||||
}
|
||||
3099
crates/lua/src/ui/bootstrap.lua
Normal file
3099
crates/lua/src/ui/bootstrap.lua
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -170,6 +170,20 @@ fn load_gamemode_collects_errors_and_continues() {
|
|||
assert!(!bad.errors.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn include_finds_windows_case_on_linux() {
|
||||
let game = TempGame::new("case");
|
||||
game.write("lua/gui/IconEditor.lua", "case_loaded = true\n");
|
||||
let rt = LuaRuntime::new().expect("рантайм");
|
||||
rt.set_file_root(game.path()).expect("корень");
|
||||
rt.run("case.lua", br#"include( "gui/IconEditor.lua" )"#)
|
||||
.expect("include без учёта регистра");
|
||||
assert_eq!(
|
||||
rt.probe("return tostring(case_loaded)").expect("probe"),
|
||||
Some("true".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn include_limits_depth() {
|
||||
let game = TempGame::new("depth");
|
||||
|
|
|
|||
190
crates/lua/tests/ui.rs
Normal file
190
crates/lua/tests/ui.rs
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
//! Настоящий UI Sandbox: Derma, VGUI и spawnmenu из установки Garry's Mod.
|
||||
//!
|
||||
//! Без ассетов тесты пропускаются: путь задаётся `SOURCE_RS_ASSETS` или
|
||||
//! `~/gmod_server/server/garrysmod`.
|
||||
|
||||
use glua::{LuaRuntime, UiCommand, UiKind};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn game_dir() -> Option<PathBuf> {
|
||||
let root = std::env::var_os("SOURCE_RS_ASSETS")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| {
|
||||
std::env::var_os("HOME").map(|home| PathBuf::from(home).join("gmod_server/server"))
|
||||
})?;
|
||||
let game = root.join("garrysmod");
|
||||
game.is_dir().then_some(game)
|
||||
}
|
||||
|
||||
fn loaded_ui() -> Option<LuaRuntime> {
|
||||
let Some(game) = game_dir() else {
|
||||
eprintln!("пропуск: ассеты Garry's Mod не найдены");
|
||||
return None;
|
||||
};
|
||||
let runtime = LuaRuntime::new().expect("рантайм");
|
||||
runtime.set_file_root(&game).expect("корень игры");
|
||||
let (_, _, failed) = runtime.load_scripts(&game);
|
||||
assert_eq!(failed, 0, "оружие и энтити должны грузиться");
|
||||
let report = runtime.load_ui(&game);
|
||||
eprintln!("UI: загружено {} файлов", report.loaded.len());
|
||||
for error in &report.errors {
|
||||
eprintln!("UI: ошибка: {error}");
|
||||
}
|
||||
// Меню обязано пережить отсутствующие контролы: ошибки отдельных
|
||||
// файлов допустимы, но SpawnMenu и модуль spawnmenu должны создаться.
|
||||
assert_eq!(
|
||||
runtime
|
||||
.probe("return tostring(IsValid(g_SpawnMenu))")
|
||||
.expect("probe")
|
||||
.as_deref(),
|
||||
Some("true"),
|
||||
"g_SpawnMenu не создан"
|
||||
);
|
||||
assert_eq!(
|
||||
runtime
|
||||
.probe("return tostring(spawnmenu.GetCreationTabs() ~= nil)")
|
||||
.expect("probe")
|
||||
.as_deref(),
|
||||
Some("true"),
|
||||
"модуль spawnmenu не загружен"
|
||||
);
|
||||
Some(runtime)
|
||||
}
|
||||
|
||||
fn texts(commands: &[UiCommand]) -> Vec<String> {
|
||||
let mut texts: Vec<String> = commands
|
||||
.iter()
|
||||
.filter_map(|command| command.text.clone())
|
||||
.collect();
|
||||
texts.sort();
|
||||
texts.dedup();
|
||||
texts
|
||||
}
|
||||
|
||||
fn dump(label: &str, commands: &[UiCommand]) {
|
||||
eprintln!("[{label}] команд: {}", commands.len());
|
||||
eprintln!(
|
||||
"[{label}] rect: {}",
|
||||
commands
|
||||
.iter()
|
||||
.filter(|command| command.kind == UiKind::Rect)
|
||||
.count()
|
||||
);
|
||||
for text in texts(commands).iter().take(30) {
|
||||
eprintln!("[{label}] текст: {text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_spawnmenu_snapshot_and_click() {
|
||||
let Some(runtime) = loaded_ui() else {
|
||||
return;
|
||||
};
|
||||
runtime.ui_set_screen(1280, 720);
|
||||
assert!(!runtime.ui_visible());
|
||||
assert!(runtime.ui_snapshot().is_empty());
|
||||
|
||||
runtime.ui_toggle(true);
|
||||
assert!(runtime.ui_visible());
|
||||
// Первый снимок прокачивает `timer.Simple(0, ...)`, которые создают
|
||||
// вкладки и содержимое спавн-меню; второй — уже для проверки.
|
||||
let _ = runtime.ui_snapshot();
|
||||
let first = runtime.ui_snapshot();
|
||||
dump("первый", &first);
|
||||
assert!(
|
||||
first.len() > 20,
|
||||
"меню должно нарисовать больше 20 команд, получили {}",
|
||||
first.len()
|
||||
);
|
||||
let first_texts = texts(&first);
|
||||
for expected in ["Weapons", "Entities", "Spawnlists", "Browse"] {
|
||||
assert!(
|
||||
first_texts.iter().any(|text| text == expected),
|
||||
"в снимке нет «{expected}»: {first_texts:?}"
|
||||
);
|
||||
}
|
||||
// Категории из settings/spawnlist_default/*.txt доходят до Lua и до
|
||||
// дерева спавн-меню.
|
||||
assert!(
|
||||
first_texts.iter().any(|text| text == "Construction Props"),
|
||||
"нет категории Construction Props: {first_texts:?}"
|
||||
);
|
||||
assert!(
|
||||
first_texts.iter().any(|text| text.ends_with(".mdl")),
|
||||
"нет ни одной модели пропа: {first_texts:?}"
|
||||
);
|
||||
|
||||
// Клик по вкладке Weapons переключает активную панель: снимок обязан
|
||||
// измениться.
|
||||
let tab = first
|
||||
.iter()
|
||||
.find(|command| command.text.as_deref() == Some("Weapons"))
|
||||
.expect("вкладка Weapons");
|
||||
let x = tab.x + tab.w * 0.5;
|
||||
let y = tab.y + tab.h * 0.5;
|
||||
eprintln!("клик по Weapons в ({x}, {y})");
|
||||
runtime.ui_mouse("move", x, y, 1);
|
||||
runtime.ui_mouse("down", x, y, 1);
|
||||
runtime.ui_mouse("up", x, y, 1);
|
||||
let second = runtime.ui_snapshot();
|
||||
dump("после клика", &second);
|
||||
assert_ne!(first, second, "клик по вкладке должен менять снимок");
|
||||
assert_eq!(
|
||||
runtime
|
||||
.probe("return tostring(g_SpawnMenu.CreateMenu:GetActiveTab():GetText())")
|
||||
.expect("probe")
|
||||
.as_deref(),
|
||||
Some("#spawnmenu.category.weapons"),
|
||||
"активная вкладка не переключилась"
|
||||
);
|
||||
|
||||
runtime.ui_toggle(false);
|
||||
assert!(!runtime.ui_visible());
|
||||
assert!(runtime.ui_snapshot().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_snapshot_has_a_bounded_command_count() {
|
||||
let Some(runtime) = loaded_ui() else {
|
||||
return;
|
||||
};
|
||||
runtime.ui_toggle(true);
|
||||
let commands = runtime.ui_snapshot();
|
||||
assert!(
|
||||
commands.len() <= glua::UI_COMMAND_LIMIT,
|
||||
"лимит команд: {}",
|
||||
commands.len()
|
||||
);
|
||||
assert!(commands.len() > 20, "меню пустое: {}", commands.len());
|
||||
}
|
||||
|
||||
/// Категории спавн-меню (settings/spawnlist_default/*.txt) реально читаются
|
||||
/// Lua-скриптом через `file.Find`; иначе вкладка Spawnlists осталась бы пустой.
|
||||
#[test]
|
||||
fn ui_spawnlist_files_are_visible_to_lua() {
|
||||
let Some(game) = game_dir() else {
|
||||
return;
|
||||
};
|
||||
let runtime = LuaRuntime::new().expect("рантайм");
|
||||
runtime.set_file_root(&game).expect("корень");
|
||||
let files = runtime
|
||||
.probe(
|
||||
r#"
|
||||
local files = file.Find("settings/spawnlist_default/*.txt", "GAME")
|
||||
return tostring(#files) .. ":" .. tostring(files[1])
|
||||
"#,
|
||||
)
|
||||
.expect("probe")
|
||||
.unwrap_or_default();
|
||||
eprintln!("spawnlist: {files}");
|
||||
let count: usize = files
|
||||
.split(':')
|
||||
.next()
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or(0);
|
||||
assert!(count >= 5, "не найдены spawnlist-файлы: {files}");
|
||||
assert!(
|
||||
files.contains(".txt"),
|
||||
"file.Find вернул не имена файлов: {files}"
|
||||
);
|
||||
}
|
||||
|
|
@ -8,6 +8,28 @@
|
|||
|
||||
---
|
||||
|
||||
## 0.9. Настоящее Q-меню: Derma + Sandbox spawnmenu (2026-10-24, ветка `feat/derma`)
|
||||
|
||||
Q-меню больше не рисуется сеткой на Rust: `crates/lua` получил слой VGUI/Derma
|
||||
(`ui.rs` + `ui/bootstrap.lua`), поверх которого грузятся настоящие
|
||||
`lua/derma/init.lua`, `lua/skins/default.lua`, `lua/vgui/*` и
|
||||
`lua/includes/modules/spawnmenu.lua`.
|
||||
|
||||
- `LuaRuntime::load_ui(game_dir)` собирает UI и геймод, `OnGamemodeLoaded`
|
||||
создаёт `g_SpawnMenu`; `ui_toggle/ui_visible/ui_snapshot/ui_mouse` — контракт
|
||||
для рендера (`UiKind`, `UiCommand`, лимит 4096 команд/32 уровня).
|
||||
- `surface`/`draw` в снимке пишут команды; клик по вкладке/дереву реально
|
||||
меняет состояние (`OnMousePressed/Released`).
|
||||
- `file.Find` научен GMod-семантике (файлы+каталоги, `*.txt`), добавлен
|
||||
`DEFINE_BASECLASS` -> `local BaseClass = baseclass.Get` и `//`-комментарии;
|
||||
песочница `include` ищет файлы без учёта регистра (Windows-скрипты на Linux).
|
||||
- Снимок на реальных ассетах: ~900 команд, вкладки/категории/модели
|
||||
(`Construction Props`, `*.mdl`), клик по Weapons меняет снимок.
|
||||
- Ограничения: HTML-вкладки (Dupes/Saves) — заглушки, модели не рендерятся
|
||||
(только имена), шрифты приблизительные; `crates/client` не тронут.
|
||||
|
||||
---
|
||||
|
||||
## 0. SWEP: оружие из Lua в клиенте (2026-10-24, ветка `feat/swep`)
|
||||
|
||||
Скрипты `lua/weapons/*.lua` (на реальных ассетах — `weapon_fists`,
|
||||
|
|
|
|||
21
docs/LUA.md
21
docs/LUA.md
|
|
@ -27,6 +27,27 @@
|
|||
Правило: API строится «рельсами» — каждый модуль регистрируется декларативно
|
||||
(функции, сигнатуры, проверки аргументов), чтобы добавлять покрытие быстро и единообразно.
|
||||
|
||||
### 2.1. UI-слой VGUI/Derma (2026-10-24)
|
||||
|
||||
Спавн-меню Sandbox строится **настоящими** скриптами `gamemodes/sandbox` и
|
||||
`lua/vgui/*` через минимальный слой на Rust+Lua в `crates/lua/src/ui*`:
|
||||
|
||||
- `load_ui(game_dir)` грузит `lua/includes` (baseclass, расширения строк/таблиц,
|
||||
`scriptedpanels`, `panel.lua`), `lua/includes/modules/spawnmenu.lua`, настоящие
|
||||
`lua/derma/init.lua`, `lua/skins/default.lua`, набор `lua/vgui/*`, затем геймод и
|
||||
вызывает `OnGamemodeLoaded`.
|
||||
- Панель — Lua-таблица с методами; `vgui.Register/Create` работают по-настоящему
|
||||
(класс + базовый класс), `Init` каждого класса обёрнут в `pcall`, поэтому сбой
|
||||
одного контрола не убивает `SpawnMenu`.
|
||||
- `surface.*`/`draw.*` во время снимка пишут команды `rect/outline/text`;
|
||||
`__sr_ui.snapshot()` обходит видимое дерево, вызывает `Panel:Paint(w,h)` и
|
||||
соблюдает лимиты 4096 команд/32 уровня.
|
||||
- Контракт для рендера: `LuaRuntime::ui_toggle/ui_visible/ui_snapshot/ui_mouse`
|
||||
(+`ui_set_screen`), типы `UiKind`/`UiCommand`. Координаты — пиксели экрана.
|
||||
- Ввод: поиск верхней видимой панели под курсором, вызовы
|
||||
`OnMousePressed/OnMouseReleased/OnCursorMoved/OnMouseWheeled`; клик по вкладке
|
||||
меняет активную панель.
|
||||
|
||||
## 3. Sandbox (защита от крашедла)
|
||||
|
||||
Цель: аддон/сервер не может уронить клиент.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue