From ffd9e736bc98d71b6ae6639156b0f6f49c5a5dcc Mon Sep 17 00:00:00 2001 From: Niko Marmeladkov Date: Sat, 24 Oct 2026 15:03:32 +0300 Subject: [PATCH] =?UTF-8?q?Derma:=20=D0=BD=D0=B0=D1=81=D1=82=D0=BE=D1=8F?= =?UTF-8?q?=D1=89=D0=B5=D0=B5=20Q-=D0=BC=D0=B5=D0=BD=D1=8E=20Sandbox=20?= =?UTF-8?q?=D0=BD=D0=B0=20Lua=20=D0=BF=D0=BE=D0=B2=D0=B5=D1=80=D1=85=20VGU?= =?UTF-8?q?I-=D1=81=D0=BB=D0=BE=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В 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 команда, вкладки/категории/модели, клик по вкладке меняет снимок; без ассетов пропуск. --- crates/lua/src/gmod.rs | 30 + crates/lua/src/include.rs | 26 +- crates/lua/src/lib.rs | 324 +++- crates/lua/src/stdlib.rs | 209 ++- crates/lua/src/ui.rs | 304 +++ crates/lua/src/ui/bootstrap.lua | 3099 +++++++++++++++++++++++++++++++ crates/lua/tests/include.rs | 14 + crates/lua/tests/ui.rs | 190 ++ docs/DIARY.md | 22 + docs/LUA.md | 21 + 10 files changed, 4200 insertions(+), 39 deletions(-) create mode 100644 crates/lua/src/ui.rs create mode 100644 crates/lua/src/ui/bootstrap.lua create mode 100644 crates/lua/tests/ui.rs diff --git a/crates/lua/src/gmod.rs b/crates/lua/src/gmod.rs index 27fa44f..9e2c60a 100644 --- a/crates/lua/src/gmod.rs +++ b/crates/lua/src/gmod.rs @@ -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::()?; + 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::()?; + 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) + }); } } diff --git a/crates/lua/src/include.rs b/crates/lua/src/include.rs index 923a658..9938384 100644 --- a/crates/lua/src/include.rs +++ b/crates/lua/src/include.rs @@ -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 { + 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>) -> mlua::Result<()> { let include = lua.create_function(move |lua, path: String| { diff --git a/crates/lua/src/lib.rs b/crates/lua/src/lib.rs index 4546e21..a4b581b 100644 --- a/crates/lua/src/lib.rs +++ b/crates/lua/src/lib.rs @@ -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, } +/// Итог загрузки UI: Derma, Sandbox spawnmenu и геймода. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct UiLoad { + /// Выполненные файлы (пути как их видит `include`). + pub loaded: Vec, + /// Ошибки: загрузка продолжается со следующего файла. + pub errors: Vec, +} + +/// Максимум команд в одном кадре 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 = iter.cloned().collect(); + // Сначала метод геймода: `GM:SpawnMenuEnabled` и подобные хуки не + // регистрируются через hook.Add, но hook.Run их вызывает. + if let Ok(Value::Table(gamemode)) = lua.globals().get::("GAMEMODE") { + if let Ok(Value::Function(func)) = gamemode.get::(name.as_str()) { + let args: Vec = std::iter::once(Value::Table(gamemode.clone())) + .chain(rest.iter().cloned()) + .collect(); + let _ = func.call::(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#" diff --git a/crates/lua/src/stdlib.rs b/crates/lua/src/stdlib.rs index dd90536..b65fc58 100644 --- a/crates/lua/src/stdlib.rs +++ b/crates/lua/src/stdlib.rs @@ -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::("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 = args.iter().skip(1).cloned().collect(); + // Метод геймода вызывается до hook.Add-хуков и может отменить + // цепочку (`return false`), как в настоящем GMod. + if let Ok(Value::Table(gamemode)) = lua.globals().get::("GAMEMODE") { + if let Ok(Value::Function(func)) = gamemode.get::(name.as_str()) { + let call_args: Vec = std::iter::once(Value::Table(gamemode.clone())) + .chain(rest.iter().cloned()) + .collect(); + let value = func.call::(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::("__sr_hooks")? { Value::Table(registry) => match registry.get::(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)| { - let found = glob_lite(&find_root, &pattern); - let mut list: Vec = 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, Option)| { + 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::>(), + )); + 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 { - 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, Vec) { + 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")); } diff --git a/crates/lua/src/ui.rs b/crates/lua/src/ui.rs new file mode 100644 index 0000000..679779a --- /dev/null +++ b/crates/lua/src/ui.rs @@ -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, +} + +/// Устанавливает `__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::("__sr_ui") else { + return; + }; + let Ok(toggle) = ui.get::("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::("visible") + })) + .ok() + .and_then(Result::ok) + .unwrap_or(false) + } + + /// Снимок видимого дерева панелей на текущий кадр. + /// + /// Пустой список, если меню закрыто или скрипты ещё не создали панели. + /// Число команд ограничено [`MAX_COMMANDS`], глубина — [`MAX_UI_DEPTH`] + /// (проверяется в Lua, здесь — вторая линия защиты). + pub fn ui_snapshot(&self) -> Vec { + catch_unwind(AssertUnwindSafe(|| self.ui_snapshot_inner())) + .ok() + .flatten() + .unwrap_or_default() + } + + fn ui_snapshot_inner(&self) -> Option> { + // Отложенные `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::(()).ok()? else { + return None; + }; + let mut commands = Vec::new(); + for pair in list.sequence_values::() { + if commands.len() >= MAX_COMMANDS { + break; + } + let Ok(Value::Table(entry)) = pair else { + continue; + }; + let Ok(kind) = entry.get::("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::(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::("color") + .ok() + .and_then(|value| value.as_table().cloned()) + .map(|color| { + let component = |index: usize| -> f32 { + color + .get::(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::>("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::("root") + && let Ok(resize) = root.get::("__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); + } +} diff --git a/crates/lua/src/ui/bootstrap.lua b/crates/lua/src/ui/bootstrap.lua new file mode 100644 index 0000000..a43d1c1 --- /dev/null +++ b/crates/lua/src/ui/bootstrap.lua @@ -0,0 +1,3099 @@ +-- Минимальный слой VGUI/Derma для настоящих скриптов Sandbox. +-- +-- Здесь нет ни одной копии игровых скриптов: панели, `surface`, `draw`, ввод +-- и снимок кадра реализованы заново. Реальные `lua/derma/*`, `lua/vgui/*`, +-- `lua/includes/*` и `gamemodes/sandbox` грузятся поверх этого слоя через +-- `include()` и переопределяют заглушки своими классами. +-- +-- Файл выполняется обычным `lua.load` (без GMod-препроцессора), поэтому +-- синтаксис — чистый Lua 5.1. + +local G = _G +local ui = __sr_ui + +local function number(value, default) + value = tonumber(value) + if value == nil or value ~= value or value == math.huge or value == -math.huge then + return default + end + return value +end + +local function text_of(value) + if value == nil then return nil end + return tostring(value) +end + +ui.number = number +ui.text_of = text_of + +-- Ошибки скриптов не должны исчезать: их забирает клиент для консоли. +ui.errors = ui.errors or {} +function ui.log_error(message) + if #ui.errors >= 200 then return end + ui.errors[#ui.errors + 1] = text_of(message) +end + +function ui.take_errors() + local list = ui.errors or {} + ui.errors = {} + return list +end + +-- Константы GMod. Значения совпадают с движком и нужны скриптам как данные. +NODOCK = 0 +FILL = 1 +LEFT = 2 +RIGHT = 3 +TOP = 4 +BOTTOM = 5 + +MOUSE_LEFT = 1 +MOUSE_RIGHT = 2 +MOUSE_MIDDLE = 3 +MOUSE_4 = 4 +MOUSE_5 = 5 +MOUSE_WHEEL_UP = 4 +MOUSE_WHEEL_DOWN = 5 + +TEXT_ALIGN_LEFT = 0 +TEXT_ALIGN_CENTER = 1 +TEXT_ALIGN_RIGHT = 2 +TEXT_ALIGN_TOP = 3 +TEXT_ALIGN_BOTTOM = 4 + +TEXFILTER = { NONE = 0, POINT = 1, LINEAR = 2, ANISOTROPIC = 3, NOMIP = 4, MIPMAP = 5 } +BLEND = { NONE = 0, ALPHA = 1, ADDITIVE = 2, MULTIPLY = 3 } +SCALE = { NONE = 0, NORMAL = 1, FRACTION = 2 } + +FORCE_BOOL = true + +-- Скриншот-контракт: размеры экрана задаёт рендер-агент. +function ScrW() return ui.screen_w end +function ScrH() return ui.screen_h end + +function SysTime() return CurTime() end +local last_clock = os.clock() +function RealFrameTime() + local now = os.clock() + local delta = now - last_clock + last_clock = now + if delta <= 0 then return 1 / 60 end + return delta +end +function FrameTime() return RealFrameTime() end + +-- --------------------------------------------------------------------------- +-- Цвета +-- --------------------------------------------------------------------------- + +local function color_parts(color, default_alpha) + default_alpha = default_alpha or 255 + if color == nil then return 255, 255, 255, default_alpha end + local kind = type(color) + if kind == "table" or kind == "userdata" then + local r = color.r + local g = color.g + local b = color.b + local a = color.a + if r == nil then r = color[1] end + if g == nil then g = color[2] end + if b == nil then b = color[3] end + if a == nil then a = color[4] end + return number(r, 255), number(g, 255), number(b, 255), number(a, default_alpha) + end + return 255, 255, 255, default_alpha +end +ui.color_parts = color_parts + +local function to_color_table(color) + local r, g, b, a = color_parts(color, 255) + return { r = r, g = g, b = b, a = a } +end + +color_white = Color(255, 255, 255, 255) +color_black = Color(0, 0, 0, 255) +color_transparent = Color(0, 0, 0, 0) +color_red = Color(255, 0, 0, 255) +color_green = Color(0, 255, 0, 255) +color_blue = Color(0, 0, 255, 255) +color_yellow = Color(255, 255, 0, 255) + +-- `Material` движка: объект с текстурой. Рендер моделей и текстур не наш +-- слой, но Derma-скин обращается к `GetTexture`/`GetColor`, и без них +-- загрузка скина падает. +local function texture_size(name) + if name:find("gwenskin", 1, true) ~= nil then return 512 end + return 16 +end + +function Material(path, flags) + local name = text_of(path) or "" + local size = texture_size(name) + local texture = { + Width = function() return size end, + Height = function() return size end, + GetColor = function() return Color(128, 128, 128, 255) end, + GetName = function() return name end, + } + local material = { + path = name, + __sr_material = true, + GetName = function() return name end, + GetTexture = function() return texture end, + GetShader = function() return "UnlitGeneric" end, + GetString = function(key) if key == "$basetexture" then return name end return nil end, + SetString = function() end, + IsError = function() return false end, + GetColor = function() return Color(128, 128, 128, 255) end, + SetInt = function() end, + SetFloat = function() end, + SetString = function() end, + SetVector = function() end, + SetMatrix = function() end, + Recompute = function() end, + } + local _ = flags + return material +end + +function CreateMaterial(name, shader, parameters) + local _ = shader + local _ = parameters + return Material(name) +end + +-- --------------------------------------------------------------------------- +-- surface: записывающие примитивы +-- --------------------------------------------------------------------------- + +local fonts = { + DermaDefault = 13, + DermaDefaultBold = 13, + DermaDefaultSmall = 11, + DermaLarge = 32, + Default = 13, + TabLarge = 14, +} + +local current_draw_color = { 255, 255, 255, 255 } +local current_text_color = { 255, 255, 255, 255 } +local current_font = "DermaDefault" +local text_x, text_y = 0, 0 +local draw_origin_x, draw_origin_y = 0, 0 +local in_snapshot = false + +local function font_size(name) + return number(fonts[name], 13) +end + +local function emit(kind, x, y, width, height, color, text) + if not in_snapshot or ui.commands == nil then return end + local commands = ui.commands + if #commands >= ui.command_limit then return end + local r, g, b, a = current_draw_color[1], current_draw_color[2], current_draw_color[3], current_draw_color[4] + if color ~= nil then + r, g, b, a = color_parts(color, 255) + end + commands[#commands + 1] = { + kind = kind, + x = number(x, 0) + draw_origin_x, + y = number(y, 0) + draw_origin_y, + w = math.max(number(width, 0), 0), + h = math.max(number(height, 0), 0), + color = { r / 255, g / 255, b / 255, a / 255 }, + text = text, + } +end + +surface = surface or {} + +function surface.CreateFont(name, data) + if type(name) ~= "string" then return end + local size = 13 + if istable(data) then + size = number(data.size, 13) + elseif isnumber(data) then + size = number(data, 13) + end + fonts[name] = math.max(size, 1) +end + +function surface.SetFont(name) + current_font = name or "DermaDefault" +end + +function surface.GetFontName() + return current_font +end + +-- Приблизительная метрика: рендер-агент не обязан совпадать пиксель в +-- пиксель, но раскладка и центрирование должны быть правдоподобными. +function surface.GetTextSize(value) + local text = text_of(value) or "" + local size = font_size(current_font) + local width = 0 + for index = 1, #text do + local byte = text:byte(index) + if byte >= 128 then + width = width + size * 0.6 + elseif text:sub(index, index) == " " then + width = width + size * 0.28 + else + width = width + size * 0.52 + end + end + return width, size +end + +function surface.SetDrawColor(r, g, b, a) + if type(r) == "table" or type(r) == "userdata" then + local cr, cg, cb, ca = color_parts(r, 255) + current_draw_color = { cr, cg, cb, ca } + else + current_draw_color = { + number(r, 255), number(g, 255), number(b, 255), number(a, 255), + } + end +end + +function surface.GetDrawColor() + return Color(current_draw_color[1], current_draw_color[2], current_draw_color[3], current_draw_color[4]) +end + +function surface.DrawRect(x, y, w, h) + emit("rect", x, y, w, h, nil, nil) +end + +function surface.DrawOutlinedRect(x, y, w, h, thickness) + local _ = thickness + emit("outline", x, y, w, h, nil, nil) +end + +function surface.DrawLine(x1, y1, x2, y2) + local x = math.min(number(x1, 0), number(x2, 0)) + local y = math.min(number(y1, 0), number(y2, 0)) + local w = math.max(math.abs(number(x2, 0) - number(x1, 0)), 1) + local h = math.max(math.abs(number(y2, 0) - number(y1, 0)), 1) + emit("rect", x, y, w, h, nil, nil) +end + +function surface.DrawTexturedRect(x, y, w, h) + -- Текстур у нас нет; заливка текущим цветом сохраняет геометрию панели. + emit("rect", x, y, w, h, nil, nil) +end + +function surface.DrawTexturedRectUV(x, y, w, h) + emit("rect", x, y, w, h, nil, nil) +end + +function surface.DrawTexturedRectRotated(x, y, w, h) + emit("rect", x, y, w, h, nil, nil) +end + +function surface.DrawCircle(x, y, radius) + emit("rect", number(x, 0) - number(radius, 0), number(y, 0) - number(radius, 0), + number(radius, 0) * 2, number(radius, 0) * 2, nil, nil) +end + +function surface.DrawPoly() +end + +function surface.DrawScreenText(value, font, x, y, color) + surface.SetFont(font) + surface.SetTextColor(color or color_white) + surface.SetTextPos(x, y) + surface.DrawText(value) +end + +function surface.SetTextColor(r, g, b, a) + if type(r) == "table" or type(r) == "userdata" then + local cr, cg, cb, ca = color_parts(r, 255) + current_text_color = { cr, cg, cb, ca } + else + current_text_color = { + number(r, 255), number(g, 255), number(b, 255), number(a, 255), + } + end +end + +function surface.GetTextColor() + return Color(current_text_color[1], current_text_color[2], current_text_color[3], current_text_color[4]) +end + +function surface.SetTextPos(x, y) + text_x = number(x, 0) + text_y = number(y, 0) +end + +function surface.DrawText(value) + local text = text_of(value) or "" + if text == "" then return end + local w, h = surface.GetTextSize(text) + emit("text", text_x, text_y, w, h, current_text_color, text) +end + +function surface.SetTexture() +end +function surface.SetMaterial() +end +function surface.GetTextureID() + return 0 +end +function surface.SetAlphaMultiplier() +end +function surface.GetAlphaMultiplier() + return 1 +end +function surface.PlaySound() +end +function surface.DisableClipping() +end +function surface.EnableClipping() +end +function surface.SetAllowClipping() +end +function surface.SetDrawColorRaw() +end +function surface.GetTextureSize() + return 512, 512 +end +function surface.GetMaterial() + return nil +end + +-- Настоящий `lua/includes/modules/draw.lua` переопределит эти функции, но +-- базовые должны существовать уже при загрузке derma/init.lua. +draw = draw or {} +function draw.SimpleText(value, font, x, y, color, align_x, align_y) + surface.SetFont(font) + local w, h = surface.GetTextSize(value) + local px = number(x, 0) + local py = number(y, 0) + if align_x == TEXT_ALIGN_CENTER then + px = px - w * 0.5 + elseif align_x == TEXT_ALIGN_RIGHT then + px = px - w + end + if align_y == TEXT_ALIGN_CENTER then + py = py - h * 0.5 + elseif align_y == TEXT_ALIGN_BOTTOM then + py = py - h + end + emit("text", px, py, w, h, color, text_of(value)) +end +function draw.DrawText(value, font, x, y, color, align_x) + draw.SimpleText(value, font, x, y, color, align_x or 0, TEXT_ALIGN_TOP) +end +draw.Text = draw.DrawText +function draw.SimpleTextOutlined(value, font, x, y, color, align_x, align_y, outline) + local _ = outline + draw.SimpleText(value, font, x, y, color, align_x, align_y) +end +function draw.RoundedBox(radius, x, y, w, h, color) + local _ = radius + emit("rect", x, y, w, h, color, nil) +end +function draw.OutlinedBox(x, y, w, h, thickness, color) + local _ = thickness + emit("outline", x, y, w, h, color, nil) +end +function draw.NoTexture() +end +function draw.SetTexture() +end +function draw.GetFontHeight(font) + return font_size(font) +end +function draw.GetTextSize(value, font) + surface.SetFont(font) + return surface.GetTextSize(value) +end + +-- --------------------------------------------------------------------------- +-- Заглушки движка +-- --------------------------------------------------------------------------- + +gui = gui or {} +local screen_clicker = false +local game_ui_visible = false +function gui.EnableScreenClicker(value) + screen_clicker = value and true or false +end +function gui.IsScreenClickerActive() + return screen_clicker +end +function gui.IsGameUIVisible() + return game_ui_visible +end +function gui.MouseX() + return number(ui.mouse_x, 0) +end +function gui.MouseY() + return number(ui.mouse_y, 0) +end +function gui.GetWorldPanel() + return nil +end +function gui.ScreenToVector(x, y) + local _ = x + local _ = y + return Vector(0, 0, 0) +end +function gui.GetHoveredPanel() + return ui.hovered +end +function gui.InternalCursorMoved() +end +function gui.InternalMousePressed() +end +function gui.InternalMouseReleased() +end +function gui.FocusedHasParent() + return false +end + +input = input or {} +function input.IsShiftDown() return false end +function input.IsControlDown() return false end +function input.IsAltDown() return false end +function input.IsMouseDown() return false end +function input.IsKeyDown() return false end +function input.GetCursorPos() return number(ui.mouse_x, 0), number(ui.mouse_y, 0) end +function input.LookupBinding() return nil end +function input.GetKeyName(code) return tostring(code) end +function input.IsKeyTrapping() return false end + +system = system or {} +function system.IsLinux() return true end +function system.IsWindows() return false end +function system.IsOSX() return false end +function system.IsConsole() return false end +function system.HasFocus() return true end +function system.GetCountry() return "us" end +function system.AppTime() return CurTime() end +function system.GetClipboardText() return "" end +function system.SetClipboardText() end +function system.FlashWindow() end +function system.OpenURL() end +function system.Is64Bit() return true end + +render = render or {} +function render.PushFilterMag() end +function render.PushFilterMin() end +function render.PopFilterMag() end +function render.PopFilterMin() end +function render.PushRenderTarget() end +function render.PopRenderTarget() return nil end +function render.SetColorMaterial() end +function render.SetColorMaterialIgnoreZ() end +function render.SetModelLighting() end +function render.SetBlend() end +function render.SetScissorRect() end +function render.SetStencilEnable() end +function render.DrawModel() end +function render.Capture() return "" end +function render.GetScreenEffectTexture() return 0 end +function render.UpdateScreenEffectTexture() end +function render.OverrideBlend() end +function render.OverrideDepthEnable() end +function render.EnableClipping() end +function render.DisableClipping() end +function render.SetViewPort() end + +cam = cam or {} +function cam.Start2D() end +function cam.End2D() end +function cam.Start3D() end +function cam.End3D() end +function cam.Start3D2D() end +function cam.End3D2D() end +function cam.IgnoreZ() end +function cam.PushModelMatrix() end +function cam.PopModelMatrix() end +function cam.GetViewPort() return 0, 0, ScrW(), ScrH() end + +effects = effects or {} +function effects.Create() return {} end +function effects.Suppress() end + +achievements = achievements or {} +function achievements.SpawnMenuOpen() end +function achievements.ContextMenuOpen() end +function achievements.SpawnMenuClosed() end +function achievements.Init() end + +menubar = menubar or {} +function menubar.ParentTo() end +function menubar.IsValid() return false end +function menubar.Add() end +function menubar.Get() return nil end + +chat = chat or {} +function chat.AddText() end +function chat.GetChatBoxPos() return 0, 0 end +function chat.GetChatBoxSize() return 640, 480 end + +notification = notification or {} +function notification.AddLegacy() end +function notification.AddProgress() end +function notification.Kill() end + +search = search or {} +search.__providers = search.__providers or {} +function search.AddProvider(func) + if type(func) == "function" then + search.__providers[#search.__providers + 1] = func + end +end +function search.GetResults(text, kind, maximum) + local results = {} + for _, provider in ipairs(search.__providers) do + local ok, provided = pcall(provider, tostring(text or "")) + if ok and istable(provided) then + for _, entry in ipairs(provided) do + results[#results + 1] = entry + if maximum ~= nil and #results >= maximum then return results end + end + end + end + local _ = kind + return results +end + +function GetConVarNumber(name) return GetConVar(name):GetFloat() end +function GetConVarString(name) return GetConVar(name):GetString() end + +SetClipboardText = SetClipboardText or function() end +GetClipboardText = GetClipboardText or function() return "" end +RestoreCursorPosition = RestoreCursorPosition or function() end +RememberCursorPosition = RememberCursorPosition or function() end +CloseDermaMenus = CloseDermaMenus or function() end +DermaMenu = DermaMenu or function() return vgui.Create("DMenu") end + +-- Вкладки Dupes/Saves строят HTML-браузер. HTML-рендера у нас нет, но +-- заглушки позволяют остальным вкладкам создаться без ошибок таймера. +function JS_Language() end +function JS_Workshop() end +function WorkshopFileBase() + return { + FetchLocal = function() end, + FetchWorkshop = function() end, + OnRemove = function() end, + } +end + +function game.GetWorld() return nil end +function game.GetMap() return "unknown" end + +engine = engine or {} +function engine.GetGames() + return { + { + title = "Garry's Mod", + folder = "garrysmod", + icon = "garrysmod", + mounted = true, + }, + } +end +function engine.GetAddons() return {} end +function engine.GetAddonInfo() return nil end +function engine.GetAppID() return 4000 end +function IsMounted() return true end + +util = util or {} +function util.TableToKeyValues() return "" end +function util.TableToJSON() return "{}" end +function util.JSONToTable() return nil end +function util.GetModelInfo() return nil end +function util.IsValidModel() return false end +function util.IsValidProp() return false end + +debug = debug or {} +debug.Trace = debug.Trace or function() end +-- `debug.getmetatable` нужен настоящему table.Copy; в безопасном рантайме +-- библиотеки debug нет, поэтому отдаём базовые функции Lua. +debug.getmetatable = debug.getmetatable or function(value) return getmetatable(value) end +debug.setmetatable = debug.setmetatable or function(value, meta) return setmetatable(value, meta) end +debug.getinfo = debug.getinfo or function() return { short_src = "lua", currentline = 0 } end + +-- markup: ToolPanel рисует предупреждение через markup.Parse. Полноценная +-- вёрстка не нужна — достаточно вырезать теги и нарисовать строку. +markup = markup or {} +function markup.Parse(value, width) + local _ = width + local text = tostring(value or ""):gsub("<[^>]->", "") + return { + Draw = function(self, x, y, align_x, align_y) + draw.SimpleText(text, "DermaLarge", x, y, color_white, + align_x or TEXT_ALIGN_LEFT, align_y or TEXT_ALIGN_TOP) + end, + } +end + +-- --------------------------------------------------------------------------- +-- Локализация +-- --------------------------------------------------------------------------- + +language = language or {} +language.__phrases = language.__phrases or {} +language.__loaded = language.__loaded or false + +function language.Add(key, value) + if key == nil then return end + language.__phrases[text_of(key)] = text_of(value) or text_of(key) +end + +function language.AddToPhrase(key, value) + key = text_of(key) + if key == nil then return end + local current = language.__phrases[key] + if current == nil then + language.__phrases[key] = text_of(value) or "" + else + language.__phrases[key] = current .. (text_of(value) or "") + end +end + +function language.GetPhrase(key) + local text = text_of(key) or "" + if text:sub(1, 1) == "#" then + local name = text:sub(2) + local phrase = language.__phrases[name] + if phrase ~= nil then return phrase end + -- Неизвестный ключ в GMod показывается как есть (без `#`). + return name + end + local phrase = language.__phrases[text] + if phrase ~= nil then return phrase end + return text +end + +local function parse_properties(text, into) + for line in (text or ""):gmatch("[^\r\n]+") do + line = line:gsub("^%s+", ""):gsub("%s+$", "") + if line ~= "" and line:sub(1, 1) ~= "#" and line:sub(1, 1) ~= "[" then + local key, value = line:match("^([^=]+)=(.*)$") + if key ~= nil then + key = key:gsub("^%s+", ""):gsub("%s+$", "") + value = value:gsub("\\n", "\n"):gsub("\\t", "\t") + into[key] = value + end + end + end +end + +-- Читает `resource/localization//*.properties`. Вызывается из Rust +-- после `set_file_root`; без файлов просто остаётся пустой словарь. +function ui.load_language(lang) + if language.__loaded then return end + language.__loaded = true + if type(file) ~= "table" or type(file.Find) ~= "function" then return end + lang = lang or "en" + local folder = "resource/localization/" .. lang + local ok, files = pcall(file.Find, folder .. "/*.properties", "GAME") + if not ok or not istable(files) then return end + for _, name in ipairs(files) do + if tostring(name):EndsWith(".properties") then + local source = file.Read(folder .. "/" .. name) + if isstring(source) then + parse_properties(source, language.__phrases) + end + end + end +end + +-- Русские подписи не обязательны для теста, но меню Sandbox на русском +-- приятнее; ключи совпадают с `spawnmenu.properties`. +local builtin_phrases = { + ["spawnmenu.content_tab"] = "Spawnlists", + ["spawnmenu.tools_tab"] = "Tools", + ["spawnmenu.utilities_tab"] = "Utilities", + ["spawnmenu.category.browse"] = "Browse", + ["spawnmenu.category.games"] = "Games", + ["spawnmenu.category.entities"] = "Entities", + ["spawnmenu.category.weapons"] = "Weapons", + ["spawnmenu.category.npcs"] = "NPCs", + ["spawnmenu.category.postprocess"] = "Post Process", + ["spawnmenu.category.your_spawnlists"] = "Your Spawnlists", + ["spawnmenu.category.addon_spawnlists"] = "Addon Spawnlists", + ["spawnmenu.tools.constraints"] = "Constraints", + ["spawnmenu.tools.construction"] = "Construction", + ["spawnmenu.tools.posing"] = "Poser", + ["spawnmenu.tools.render"] = "Render", +} +for key, value in pairs(builtin_phrases) do + language.__phrases[key] = value +end + +-- --------------------------------------------------------------------------- +-- Строки и таблицы: настоящие расширения загрузятся поверх, но базовый +-- набор нужен уже derma/init.lua. +-- --------------------------------------------------------------------------- + +-- AccessorFunc: на нём держатся почти все Derma-контролы. +function AccessorFunc(tab, varname, name, force) + if tab == nil or name == nil then return end + tab["Get" .. name] = function(self) + local value = self[varname] + if force then return value and true or false end + return value + end + tab["Set" .. name] = function(self, value) + self[varname] = value + end +end + +function string.EndsWith(value, suffix) + return value:sub(-#suffix) == suffix +end +function string.StartsWith(value, prefix) + return value:sub(1, #prefix) == prefix +end +function string.Replace(value, from, to) + local result, count = value:gsub(from, to:gsub("%%", "%%%%")) + local _ = count + return result +end +function string.Split(value, separator) + local out = {} + for part in value:gmatch("([^" .. separator .. "]+)") do + out[#out + 1] = part + end + return out +end +function string.Trim(value, chars) + if chars == nil then + return value:match("^%s*(.-)%s*$") + end + local escaped = chars:gsub("(%W)", "%%%1") + return value:gsub("^[" .. escaped .. "]+", ""):gsub("[" .. escaped .. "]+$", "") +end +function string.Left(value, count) return value:sub(1, count) end +function string.Right(value, count) return value:sub(-count) end +function string.ToTable(value) + local out = {} + for index = 1, #value do out[index] = value:sub(index, index) end + return out +end +function string.Comma(value) + local text = tostring(math.floor(tonumber(value) or 0)) + local out = text:reverse():gsub("(%d%d%d)", "%1,"):reverse() + return out:gsub("^,", "") +end +function string.Explode(separator, value) + local out = {} + local pattern = "([^" .. separator:gsub("(%W)", "%%%1") .. "]+)" + for part in tostring(value):gmatch(pattern) do + out[#out + 1] = part + end + return out +end +function string.Implode(separator, values) + return table.concat(values, separator) +end +function string.GetExtensionFromFilename(path) + return path:match("%.([^./\\]+)$") or "" +end +function string.StripExtension(path) + return path:gsub("%.[^./\\]+$", "") +end +function string.GetPathFromFilename(path) + return path:match("^(.*)[/\\][^/\\]*$") or "" +end +function string.GetFileFromFilename(path) + return path:match("([^/\\]+)$") or path +end +function string.NiceName(value) + return value:gsub("_", " ") +end +function string.NiceSize(bytes) + bytes = tonumber(bytes) or 0 + if bytes > 1024 * 1024 then return string.format("%.1f MB", bytes / 1024 / 1024) end + if bytes > 1024 then return string.format("%.1f KB", bytes / 1024) end + return tostring(bytes) .. " B" +end + +table.Copy = table.Copy or function(source, lookup) + if type(source) ~= "table" then return source end + lookup = lookup or {} + if lookup[source] then return lookup[source] end + local copy = {} + lookup[source] = copy + for key, value in pairs(source) do + copy[table.Copy(key, lookup)] = table.Copy(value, lookup) + end + return setmetatable(copy, getmetatable(source)) +end + +table.Merge = table.Merge or function(destination, source) + for key, value in pairs(source or {}) do + destination[key] = value + end + return destination +end + +table.GetKeys = table.GetKeys or function(source) + local out = {} + for key in pairs(source) do out[#out + 1] = key end + return out +end + +table.HasValue = table.HasValue or function(source, needle) + for _, value in pairs(source) do + if value == needle then return true end + end + return false +end + +table.IsEmpty = table.IsEmpty or function(source) + return next(source) == nil +end + +table.SortByMember = table.SortByMember or function(source, member, ascending) + table.sort(source, function(a, b) + local left = type(a) == "table" and a[member] or a + local right = type(b) == "table" and b[member] or b + if ascending then return left < right end + return left > right + end) +end + +function SortedPairs(source, descending) + local keys = {} + for key in pairs(source) do keys[#keys + 1] = key end + table.sort(keys, function(a, b) + if descending then return a > b end + return a < b + end) + local index = 0 + return function() + index = index + 1 + local key = keys[index] + if key == nil then return nil end + return key, source[key] + end +end + +function SortedPairsByMemberValue(source, member, descending) + local values = {} + for _, value in pairs(source) do values[#values + 1] = value end + table.sort(values, function(a, b) + local left = type(a) == "table" and a[member] or a + local right = type(b) == "table" and b[member] or b + if descending then return left > right end + return left < right + end) + return ipairs(values) +end + +Format = Format or string.format +function tobool(value) return value and true or false end +function Lerp(fraction, from, to) + return from + (to - from) * (number(fraction, 0)) +end +function LerpVector(fraction, from, to) return from end +function LerpAngle(fraction, from, to) return from end + +-- `Rainbow`/`HSVToColor` встречаются в скинах; безопасные приближения. +function HSVToColor(hue, saturation, value) + local h = (number(hue, 0) % 1) * 6 + local s = number(saturation, 0) + local v = number(value, 0) * 255 + local index = math.floor(h) + local f = h - index + local p = v * (1 - s) + local q = v * (1 - f * s) + local t = v * (1 - (1 - f) * s) + local r, g, b + if index == 0 then r, g, b = v, t, p + elseif index == 1 then r, g, b = q, v, p + elseif index == 2 then r, g, b = p, v, t + elseif index == 3 then r, g, b = p, q, v + elseif index == 4 then r, g, b = t, p, v + else r, g, b = v, p, q end + return Color(math.floor(r), math.floor(g), math.floor(b), 255) +end + +-- --------------------------------------------------------------------------- +-- gamemode: GMod-обёртка над GAMEMODE. Derma зовёт `gamemode.Call`. +-- --------------------------------------------------------------------------- + +gamemode = gamemode or {} +function gamemode.Call(name, ...) + local gm = rawget(G, "GAMEMODE") + if gm == nil then + local handle = rawget(G, "GM") + gm = handle + end + if gm == nil then return nil end + local func = gm[name] + if type(func) ~= "function" then return nil end + return func(gm, ...) +end +function gamemode.Get(name) + local gm = rawget(G, "GAMEMODE") + if gm == nil then return nil end + return gm[name] +end +function gamemode.GetName() + local gm = rawget(G, "GAMEMODE") + return gm and gm.Name or "" +end + +-- --------------------------------------------------------------------------- +-- spawnmenu: движковая часть. `lua/includes/modules/spawnmenu.lua` сохраняет +-- эту таблицу как `spawnmenu_engine` и вызывает её при PopulateFromTextFiles. +-- --------------------------------------------------------------------------- + +spawnmenu = spawnmenu or {} + +-- Очень маленький парсер KeyValues: только то, что есть в +-- settings/spawnlist_default/*.txt (вложенные таблицы и строки). +local function kv_tokens(source) + local tokens = {} + local index = 1 + local length = #source + while index <= length do + local char = source:sub(index, index) + if char:match("%s") then + index = index + 1 + elseif char == "{" or char == "}" then + tokens[#tokens + 1] = char + index = index + 1 + elseif char == '"' then + local out = {} + local cursor = index + 1 + while cursor <= length do + local current = source:sub(cursor, cursor) + if current == "\\" then + local escaped = source:sub(cursor + 1, cursor + 1) + if escaped == "n" then out[#out + 1] = "\n" + elseif escaped == "t" then out[#out + 1] = "\t" + else out[#out + 1] = escaped end + cursor = cursor + 2 + elseif current == '"' then + break + else + out[#out + 1] = current + cursor = cursor + 1 + end + end + tokens[#tokens + 1] = table.concat(out) + index = cursor + 1 + else + local cursor = index + while cursor <= length do + local current = source:sub(cursor, cursor) + if current:match("[%s{}]") then break end + cursor = cursor + 1 + end + tokens[#tokens + 1] = source:sub(index, cursor - 1) + index = cursor + end + end + return tokens +end + +local function kv_block(tokens, index, into) + while index <= #tokens do + local key = tokens[index] + if key == "}" then return index + 1 end + local value = tokens[index + 1] + if value == "{" then + local child = {} + index = kv_block(tokens, index + 2, child) + into[key] = child + elseif value ~= nil then + into[key] = value + index = index + 2 + else + break + end + end + return index +end + +function spawnmenu.ParseKeyValues(source) + if not isstring(source) then return nil end + local tokens = kv_tokens(source) + local index = 1 + while index <= #tokens and tokens[index] ~= "{" do index = index + 1 end + if index > #tokens then return nil end + local root = {} + kv_block(tokens, index + 1, root) + if root.TableToKeyValues ~= nil then return root.TableToKeyValues end + return root +end + +function spawnmenu.PopulateFromTextFiles(callback) + if type(callback) ~= "function" then return end + if type(file) ~= "table" or type(file.Find) ~= "function" then return end + local function convert_numbers(tab) + for key, value in pairs(tab) do + if istable(value) then + convert_numbers(value) + elseif isstring(value) and value:match("^-?%d+%.?%d*$") ~= nil then + tab[key] = tonumber(value) + end + end + end + local ok, files = pcall(file.Find, "settings/spawnlist_default/*.txt", "GAME") + if not ok or not istable(files) then return end + for _, filename in ipairs(files) do + local source = file.Read("settings/spawnlist_default/" .. filename) + local tab = spawnmenu.ParseKeyValues(source) + if tab ~= nil then + convert_numbers(tab) + local nice = tostring(filename):gsub("%.txt$", ""):gsub("^%d+%-", "") + callback(filename, tab.name or nice, tab.contents or {}, tab.icon, + tonumber(tab.id), tonumber(tab.parentid), tab.needsapp) + end + end +end + +function spawnmenu.SaveToTextFiles() + -- Сохранение списков пишет в установку игры; в клиенте это не нужно. +end + +-- baseclass нужен уже при загрузке оружия/энтити (`scripted_ents`), то есть +-- до `load_ui`; настоящий lua/includes/modules/baseclass.lua загрузится позже +-- и заменит эту таблицу собой. +baseclass = baseclass or {} +baseclass.__tables = baseclass.__tables or {} +function baseclass.Get(name) + if rawget(G, "ENT") ~= nil then rawget(G, "ENT").Base = name end + if rawget(G, "SWEP") ~= nil then rawget(G, "SWEP").Base = name end + baseclass.__tables[name] = baseclass.__tables[name] or {} + return baseclass.__tables[name] +end +function baseclass.Set(name, tab) + local target = baseclass.__tables[name] or {} + for key, value in pairs(tab or {}) do target[key] = value end + baseclass.__tables[name] = target + target.ThisClass = name +end + +-- --------------------------------------------------------------------------- +-- Cookie и SQL: настоящий cookie-модуль требует SQLite, а нам достаточно +-- детерминированного хранилища в памяти. +-- --------------------------------------------------------------------------- + +cookie = cookie or {} +cookie.__values = cookie.__values or {} +function cookie.Set(key, value) + cookie.__values[text_of(key)] = text_of(value) +end +function cookie.GetString(key, default) + local value = cookie.__values[text_of(key)] + if value == nil then return default end + return value +end +function cookie.GetNumber(key, default) + local value = tonumber(cookie.__values[text_of(key)]) + if value == nil then return number(default, 0) end + return value +end +function cookie.Delete(key) + cookie.__values[text_of(key)] = nil +end + +sql = sql or {} +function sql.TableExists() return false end +function sql.Query() return nil end +function sql.QueryValue() return nil end +function sql.Begin() end +function sql.Commit() end +function SQLStr(value) return "'" .. tostring(value):gsub("'", "''") .. "'" end + +-- --------------------------------------------------------------------------- +-- Время (timer.Create нужен cookie/animation; настоящий timer.Simple — Rust) +-- --------------------------------------------------------------------------- + +local lua_timers = {} +function timer.Create(name, delay, repetitions, func) + if type(name) ~= "string" or type(func) ~= "function" then return end + lua_timers[name] = { + at = CurTime() + math.max(number(delay, 0), 0), + delay = math.max(number(delay, 0), 0), + reps = number(repetitions, 0), + func = func, + } +end +function timer.Remove(name) + lua_timers[text_of(name)] = nil +end +function timer.Exists(name) + return lua_timers[text_of(name)] ~= nil +end +function timer.Adjust(name, delay) + local entry = lua_timers[text_of(name)] + if entry == nil then return end + entry.delay = math.max(number(delay, 0), 0) + entry.at = CurTime() + entry.delay +end + +function ui.pump() + if next(lua_timers) == nil then return end + local now = CurTime() + local due = {} + for name, entry in pairs(lua_timers) do + if now >= entry.at then due[#due + 1] = name end + end + table.sort(due) + for _, name in ipairs(due) do + local entry = lua_timers[name] + if entry ~= nil then + if entry.reps == 1 then + lua_timers[name] = nil + elseif entry.reps > 1 then + entry.reps = entry.reps - 1 + entry.at = now + entry.delay + else + entry.at = now + entry.delay + end + local ok, err = pcall(entry.func) + if not ok then + ui.log_error("timer " .. name .. ": " .. tostring(err)) + end + end + end +end + +-- --------------------------------------------------------------------------- +-- vgui: движковый слой +-- --------------------------------------------------------------------------- + +vgui = vgui or {} + +-- Инстанс панели — обычная Lua-таблица. `Panel:GetTable()` возвращает её же, +-- поэтому настоящий `scriptedpanels.lua` может копировать в неё методы +-- классов ровно как в GMod. +local panel_meta = {} +_G.__sr_panel_meta = panel_meta + +local function new_panel(engine, parent, name) + local panel = { + __sr_ui_panel = true, + __children = {}, + __engine = engine or "Panel", + __is_label = engine == "Label" or engine == "EditablePanel" and false, + __is_textentry = engine == "TextEntry", + __is_button = engine == "Button", + __visible = true, + __alpha = 255, + __mouse_input = true, + __keyboard_input = false, + __paint_background = false, + __paint_border = false, + __dock = NODOCK, + __dock_margin = { 0, 0, 0, 0 }, + __dock_padding = { 0, 0, 0, 0 }, + __zpos = 0, + __font = "DermaDefault", + __content_alignment = 0, + __text_inset = { 0, 0 }, + __character = engine, + } + panel.x = 0 + panel.y = 0 + panel.w = 0 + panel.h = 0 + panel.__name = name or engine or "Panel" + setmetatable(panel, { __index = panel_meta }) + ui.panels[panel] = true + if parent ~= nil then + panel:SetParent(parent) + end + return panel +end + +function vgui.CreateX(engine, parent, name) + return new_panel(engine, parent, name) +end +ui.new_panel = new_panel +function vgui.Create(engine, parent, name) + return new_panel(engine, parent, name) +end +ui.engine_create = vgui.Create + +-- Все живые панели: `derma.lua` ищет по ним экземпляры при перезагрузке. +ui.panels = setmetatable({}, { __mode = "k" }) +function vgui.GetAll() + local out = {} + for panel in pairs(ui.panels) do + if panel.__removed ~= true then out[#out + 1] = panel end + end + return out +end + +function vgui.GetHoveredPanel() + return ui.hovered +end +function vgui.GetKeyboardFocus() + return nil +end +function vgui.GetMouseFocus() + return ui.hovered +end +function vgui.FocusedHasParent() + return false +end + +-- Методы панели. Всё, что нужно и Derma, и движковым базовым классам. +function panel_meta:GetTable() return self end +function panel_meta:Prepare() end +function panel_meta:IsValid() return not self.__removed end +function panel_meta:IsMarkedForDeletion() return self.__removed == true end +function panel_meta:GetClassName() return self.ClassName or self.__engine or "Panel" end +function panel_meta:GetName() return self.__name end +function panel_meta:SetName(name) self.__name = name end +function panel_meta:GetCharacter() return self.__character or "" end + +function panel_meta:GetParent() return self.__parent end +function panel_meta:HasParent(target) + local current = self.__parent + local depth = 0 + while current ~= nil and depth < 64 do + if current == target then return true end + current = current.__parent + depth = depth + 1 + end + return false +end +function panel_meta:IsOurChild(target) + return target ~= nil and target:HasParent(self) +end +function panel_meta:SetParent(parent) + if parent == self then return end + if self.__parent == parent then return end + local old = self.__parent + if old ~= nil and istable(old) and istable(old.__children) then + for index = #old.__children, 1, -1 do + if old.__children[index] == self then + table.remove(old.__children, index) + end + end + end + self.__parent = parent + if parent ~= nil and istable(parent.__children) then + local found = false + for _, child in ipairs(parent.__children) do + if child == self then found = true break end + end + if not found then parent.__children[#parent.__children + 1] = self end + end + if parent ~= nil and parent.OnChildAdded ~= nil then + local ok, err = pcall(parent.OnChildAdded, parent, self) + if not ok then ui.log_error("OnChildAdded: " .. tostring(err)) end + end +end +function panel_meta:GetChildren() + local out = {} + for index, child in ipairs(self.__children or {}) do out[index] = child end + return out +end +function panel_meta:MoveToFront() + local parent = self.__parent + if parent == nil then return end + local list = parent.__children or {} + for index = #list, 1, -1 do + if list[index] == self then table.remove(list, index) end + end + list[#list + 1] = self +end +function panel_meta:MoveToBack() + local parent = self.__parent + if parent == nil then return end + local list = parent.__children or {} + for index = #list, 1, -1 do + if list[index] == self then table.remove(list, index) end + end + table.insert(list, 1, self) +end +function panel_meta:InsertAtTop(child) + if child == nil then return end + child:SetParent(self) + local list = self.__children or {} + for index = #list, 1, -1 do + if list[index] == child then table.remove(list, index) end + end + table.insert(list, 1, child) +end +function panel_meta:GetChildCount() return #(self.__children or {}) end +function panel_meta:ChildCount() return #(self.__children or {}) end +function panel_meta:GetChild(index) return (self.__children or {})[index] end +function panel_meta:Add(value) + if isstring(value) then + return vgui.Create(value, self) + end + if istable(value) then + if value.__sr_ui_panel then + value:SetParent(self) + return value + end + if vgui.CreateFromTable ~= nil then + return vgui.CreateFromTable(value, self) + end + end + if value ~= nil then value:SetParent(self) end + return value +end +function panel_meta:Insert(panel) + if panel ~= nil then panel:SetParent(self) end + return panel +end +function panel_meta:AddItem(panel) + if panel ~= nil then panel:SetParent(self) end + return panel +end +function panel_meta:Remove() + if self.__removed then return end + self.__removed = true + local parent = self.__parent + if parent ~= nil and istable(parent.__children) then + for index = #parent.__children, 1, -1 do + if parent.__children[index] == self then + table.remove(parent.__children, index) + end + end + end + for _, child in ipairs(self.__children or {}) do + child.__removed = true + child.__parent = nil + end + self.__children = {} +end + +function panel_meta:GetPos() return self.x, self.y end +function panel_meta:GetX() return self.x end +function panel_meta:GetY() return self.y end +function panel_meta:GetSize() return self.w, self.h end +function panel_meta:GetWide() return self.w end +function panel_meta:GetTall() return self.h end +function panel_meta:SetPos(x, y) + self.x = number(x, self.x) + self.y = number(y, self.y) +end +function panel_meta:SetX(x) self.x = number(x, self.x) end +function panel_meta:SetY(y) self.y = number(y, self.y) end +function panel_meta:SetSize(w, h) + self.w = math.max(number(w, self.w), 0) + self.h = math.max(number(h, self.h), 0) +end +function panel_meta:SetWide(w) self:SetSize(w, self.h) end +function panel_meta:SetTall(h) self:SetSize(self.w, h) end +function panel_meta:SetWidth(w) self:SetSize(w, self.h) end +function panel_meta:SetHeight(h) self:SetSize(self.w, h) end +function panel_meta:GetBounds() + return { x = self.x, y = self.y, w = self.w, h = self.h } +end +function panel_meta:MoveTo(x, y) self:SetPos(x, y) end +function panel_meta:MoveToX(x) self:SetPos(x, self.y) end +function panel_meta:MoveToY(y) self:SetPos(self.x, y) end +function panel_meta:AlignTop(offset) self.y = number(offset, 0) end +function panel_meta:AlignLeft(offset) self.x = number(offset, 0) end +function panel_meta:AlignRight(offset) + local parent = self.__parent + if parent ~= nil then self.x = parent.w - self.w - number(offset, 0) end +end +function panel_meta:AlignBottom(offset) + local parent = self.__parent + if parent ~= nil then self.y = parent.h - self.h - number(offset, 0) end +end +function panel_meta:CenterHorizontal() + local parent = self.__parent + if parent ~= nil then self.x = (parent.w - self.w) * 0.5 end +end +function panel_meta:CenterVertical() + local parent = self.__parent + if parent ~= nil then self.y = (parent.h - self.h) * 0.5 end +end +function panel_meta:Center() + self:CenterHorizontal() + self:CenterVertical() +end + +function panel_meta:SetVisible(visible) + self.__visible = visible and true or false +end +function panel_meta:IsVisible() + return self.__visible ~= false and not self.__removed +end +function panel_meta:IsVisibleChain() + local current = self + local depth = 0 + while current ~= nil and depth < 64 do + if current.__removed or current.__visible == false then return false end + current = current.__parent + depth = depth + 1 + end + return true +end +function panel_meta:SetAlpha(alpha) self.__alpha = math.Clamp(number(alpha, 255), 0, 255) end +function panel_meta:GetAlpha() return self.__alpha end +function panel_meta:SetZPos(z) self.__zpos = number(z, 0) end +function panel_meta:GetZPos() return self.__zpos end +function panel_meta:SetMouseInputEnabled(value) self.__mouse_input = value and true or false end +function panel_meta:IsMouseInputEnabled() return self.__mouse_input ~= false end +function panel_meta:SetKeyboardInputEnabled(value) self.__keyboard_input = value and true or false end +function panel_meta:IsKeyboardInputEnabled() return self.__keyboard_input == true end +function panel_meta:MakePopup() + self.__popup = true + self.__mouse_input = true + self.__keyboard_input = true + if self.__parent == nil and (self.w <= 0 or self.h <= 0) then + self:SetPos(0, 0) + self:SetSize(ScrW(), ScrH()) + end +end +function panel_meta:IsPopup() return self.__popup == true end +function panel_meta:SetPopupStayAtBack() end +function panel_meta:SetWorldClicker(value) self.__world_clicker = value and true or false end +function panel_meta:IsWorldClicker() return self.__world_clicker == true end +function panel_meta:SetExpensiveShadow() end + +function panel_meta:Dock(mode) + self.__dock = number(mode, NODOCK) +end +function panel_meta:GetDock() return self.__dock end +function panel_meta:DockMargin(top, right, bottom, left) + self.__dock_margin = { + number(top, 0), number(right, 0), number(bottom, 0), number(left, 0), + } +end +function panel_meta:GetDockMargin() + local m = self.__dock_margin + return m[1], m[2], m[3], m[4] +end +function panel_meta:DockPadding(top, right, bottom, left) + self.__dock_padding = { + number(top, 0), number(right, 0), number(bottom, 0), number(left, 0), + } +end +function panel_meta:GetDockPadding() + local p = self.__dock_padding + return p[1], p[2], p[3], p[4] +end + +function panel_meta:SetCookieName(name) self.__cookie_name = name end +function panel_meta:GetCookieName() return self.__cookie_name end +function panel_meta:SetBackgroundColor(color) self.__background_color = to_color_table(color) end +function panel_meta:GetBackgroundColor() + local c = self.__background_color + if c == nil then return nil end + return Color(c.r, c.g, c.b, c.a) +end +function panel_meta:SetClickOnDragHover(value) self.__click_drag = value and true or false end +function panel_meta:GetClickOnDragHover() return self.__click_drag == true end +function panel_meta:GetCookieNumber(key, default) + local name = self.__cookie_name + if name == nil then return default end + local value = cookie.GetNumber(name .. key) + if value == nil then return default end + return value +end +function panel_meta:SetCookie(key, value) + local name = self.__cookie_name + if name == nil then return end + cookie.Set(name .. key, value) +end +function panel_meta:GetCookie(key) + local name = self.__cookie_name + if name == nil then return nil end + return cookie.GetString(name .. key) +end +function panel_meta:LoadCookies() end +function panel_meta:SaveCookies() end + +function panel_meta:InvalidateLayout() end +function panel_meta:InvalidateParent() + local parent = self.__parent + if parent ~= nil then parent.__layout_dirty = true end +end +function panel_meta:InvalidateChildren() end +function panel_meta:PerformLayout() end + +function panel_meta:SetPaintBackground(value) self.__paint_background = value and true or false end +function panel_meta:GetPaintBackground() return self.__paint_background end +function panel_meta:SetPaintBackgroundEnabled(value) self.__paint_background = value and true or false end +function panel_meta:SetPaintBorderEnabled(value) self.__paint_border = value and true or false end +function panel_meta:SetPaintBorder() end + +function panel_meta:SetCursor(value) self.__cursor = value end +function panel_meta:GetCursor() return self.__cursor or "arrow" end +function panel_meta:SetTooltip(value) self.__tooltip = value end +function panel_meta:GetTooltip() return self.__tooltip end +function panel_meta:SetTooltipPanel() end + +function panel_meta:SetMouseInputEnabledDefault() end +function panel_meta:MouseCapture(value) self.__mouse_capture = value and true or false end +function panel_meta:IsMouseCaptured() return self.__mouse_capture == true end +function panel_meta:DragMousePress() end +function panel_meta:DragMouseRelease() return false end +function panel_meta:IsDraggable() return self.__draggable == true end +function panel_meta:SetDraggable(value) self.__draggable = value and true or false end +function panel_meta:SetDraggableName(value) self.__drag_name = value end +function panel_meta:GetDraggableName() return self.__drag_name end +function panel_meta:SetScreenLock() end +function panel_meta:SetSelectionCanvas(value) self.__selection_canvas = value and true or false end +function panel_meta:IsSelectionCanvas() return self.__selection_canvas == true end +function panel_meta:StartBoxSelection() end +function panel_meta:EndBoxSelection() return false end +function panel_meta:GetSelectionCanvas() + local current = self + local depth = 0 + while current ~= nil and depth < 64 do + if current.__selection_canvas then return current end + current = current.__parent + depth = depth + 1 + end + return nil +end +function panel_meta:NumSelectedChildren() return 0 end +function panel_meta:GetSelectedChildren() return {} end +function panel_meta:UnselectAll() end +function panel_meta:IsSelected() return self.__selected == true end +function panel_meta:SetSelected(value) self.__selected = value and true or false end +function panel_meta:IsSelectable() return false end +function panel_meta:DrawSelections() end + +function panel_meta:IsHovered() return self.__hovered == true end +function panel_meta:IsDown() return self.Depressed == true end +function panel_meta:IsEnabled() return self.__enabled ~= false end +function panel_meta:SetEnabled(value) self.__enabled = value and true or false end +function panel_meta:SetDisabled(value) self.__enabled = not (value and true or false) end +function panel_meta:GetDisabled() return self.__enabled == false end + +function panel_meta:LocalToScreen(x, y) + local screen_x = number(x, 0) + local screen_y = number(y, 0) + local current = self + local depth = 0 + while current ~= nil and depth < 64 do + screen_x = screen_x + current.x + screen_y = screen_y + current.y + current = current.__parent + depth = depth + 1 + end + return screen_x, screen_y +end +function panel_meta:ScreenToLocal(x, y) + local screen_x, screen_y = self:LocalToScreen(0, 0) + return number(x, 0) - screen_x, number(y, 0) - screen_y +end +function panel_meta:LocalCursorPos() + return self:ScreenToLocal(ui.mouse_x, ui.mouse_y) +end +function panel_meta:CursorPos() + return self:ScreenToLocal(ui.mouse_x, ui.mouse_y) +end +function panel_meta:GetChildPosition(panel) + return panel.x, panel.y +end + +function panel_meta:StretchToParent(left, top, right, bottom) + local parent = self.__parent + if parent == nil then return end + if left ~= nil then self.x = number(left, 0) end + if top ~= nil then self.y = number(top, 0) end + local width = parent.w - self.x - number(right, 0) + local height = parent.h - self.y - number(bottom, 0) + if left ~= nil or right ~= nil then self.w = math.max(width, 0) end + if top ~= nil or bottom ~= nil then self.h = math.max(height, 0) end +end + +function panel_meta:SizeToChildren(wide, tall) + local max_x, max_y = 0, 0 + for _, child in ipairs(self.__children or {}) do + max_x = math.max(max_x, child.x + child.w) + max_y = math.max(max_y, child.y + child.h) + end + if wide then self.w = max_x end + if tall then self.h = max_y end +end +function panel_meta:SizeToContents() + local width, height = self:GetContentSize() + self:SetSize(width, height) +end +function panel_meta:SizeToContentsX(add) + local width = self:GetContentSize() + self:SetWide(width + number(add, 0)) +end +function panel_meta:SizeToContentsY(add) + local _, height = self:GetContentSize() + self:SetTall(height + number(add, 0)) +end + +function panel_meta:GetCanvas() + if self.__canvas == nil then + local canvas = new_panel("Panel", self, "__canvas") + canvas:SetPos(0, 0) + canvas:SetSize(self.w, self.h) + canvas.__is_canvas = true + self.__canvas = canvas + end + return self.__canvas +end +function panel_meta:GetVBar() return nil end +function panel_meta:GetHBar() return nil end +function panel_meta:InnerWidth() return self.w end + +function panel_meta:GetSkin() + if derma ~= nil and derma.GetDefaultSkin ~= nil then + local ok, skin = pcall(derma.GetDefaultSkin) + if ok then return skin end + end + return nil +end +function panel_meta:ApplySchemeSettings() end +function panel_meta:OnSizeChanged() end +function panel_meta:OnChildAdded() end +function panel_meta:OnCursorMoved() end +function panel_meta:OnMousePressed() end +function panel_meta:OnMouseReleased() end +function panel_meta:OnMouseWheel() end +function panel_meta:OnMouseWheeled() end +function panel_meta:OnKeyCodePressed() end +function panel_meta:OnKeyCodeTyped() end +function panel_meta:OnTextChanged() end +function panel_meta:OnEnter() end +function panel_meta:OnValueChanged() end +function panel_meta:Think() end +function panel_meta:DoClick() end +function panel_meta:DoRightClick() end +function panel_meta:DoMiddleClick() end +function panel_meta:DoDoubleClick() end +function panel_meta:PaintOver() end +function panel_meta:PaintAt() end + +-- Базовый Label: текст рисуем сами, потому что движкового рендера у нас нет. +function panel_meta:SetText(value) self.__text = text_of(value) or "" end +function panel_meta:GetText() return self.__text or "" end +function panel_meta:SetFont(value) self.__font = value or "DermaDefault" self:SetFontInternal(self.__font) end +function panel_meta:SetFontInternal(value) self.__font = value or self.__font end +function panel_meta:GetFont() return self.__font end +function panel_meta:SetFGColor(r, g, b, a) + if type(r) == "table" or type(r) == "userdata" then + local cr, cg, cb, ca = color_parts(r, 255) + self.__fg = { cr, cg, cb, ca } + else + self.__fg = { number(r, 255), number(g, 255), number(b, 255), number(a, 255) } + end +end +function panel_meta:GetFGColor() + local c = self.__fg or { 255, 255, 255, 255 } + return Color(c[1], c[2], c[3], c[4]) +end +function panel_meta:SetTextColor(color) self.__text_color = to_color_table(color) end +function panel_meta:GetTextColor() + local c = self.__text_color or self.__fg + if c == nil then return nil end + if type(c) == "table" and c.r ~= nil then return c end + return Color(c[1], c[2], c[3], c[4]) +end +function panel_meta:SetTextStyleColor(color) self.__text_style_color = to_color_table(color) end +function panel_meta:GetTextStyleColor() + local c = self.__text_style_color + if c == nil then return nil end + return Color(c.r, c.g, c.b, c.a) +end +function panel_meta:SetContentAlignment(value) self.__content_alignment = number(value, 0) end +function panel_meta:GetContentAlignment() return self.__content_alignment end +function panel_meta:SetTextInset(x, y) self.__text_inset = { number(x, 0), number(y, 0) } end +function panel_meta:GetTextInset() + return self.__text_inset[1], self.__text_inset[2] +end +function panel_meta:GetContentSize() + surface.SetFont(self.__font or "DermaDefault") + local value = self.__text or "" + if value:sub(1, 1) == "#" then value = language.GetPhrase(value) end + local width, height = surface.GetTextSize(value) + local inset_x, inset_y = self:GetTextInset() + return width + inset_x * 2, height + inset_y * 2 +end +function panel_meta:SetAutoStretchVertical(value) self.__auto_stretch = value and true or false end +function panel_meta:SetDoubleClickingEnabled(value) self.__double_click = value and true or false end +function panel_meta:SetIsToggle(value) self.__is_toggle = value and true or false end +function panel_meta:GetIsToggle() return self.__is_toggle == true end +function panel_meta:SetToggle(value) self.__toggle = value and true or false end +function panel_meta:GetToggle() return self.__toggle == true end +function panel_meta:Toggle() + if not self.__is_toggle then return end + self.__toggle = not self.__toggle + self:OnToggled(self.__toggle) +end + +-- Заглушки свойств контролов, которые Derma вызывает через AccessorFunc. +function panel_meta:SetImage() end +function panel_meta:GetImage() return self.__image end +function panel_meta:SetMaterial() end +function panel_meta:SetImageColor() end +function panel_meta:SetIcon() end +function panel_meta:GetIcon() return self.__icon end +function panel_meta:SetAllowNonAsciiCharacters() end +function panel_meta:SetEnterAllowed() end +function panel_meta:SetHistoryEnabled() end +function panel_meta:SetUpdateOnType() end +function panel_meta:SetDragParent(parent) self.__drag_parent = parent end +function panel_meta:GetDragParent() return self.__drag_parent end + +-- Кнопки: нажатие/отпускание. Настоящий DLabel переопределяет это, но +-- движковый класс "Button" должен работать сам. +function panel_meta:OnMousePressedDefault(code) + if code == MOUSE_LEFT then self.Depressed = true end +end +function panel_meta:OnMouseReleasedDefault(code) + if code == MOUSE_LEFT and self.Depressed then + self.Depressed = nil + if self:IsHovered() and self.DoClick ~= nil then + local ok, err = pcall(self.DoClick, self) + if not ok then ui.log_error("DoClick: " .. tostring(err)) end + end + end +end + +function panel_meta:Paint(width, height) + local _ = width + local _ = height + if self.__is_label and (self.__text or "") ~= "" then + ui.draw_label_text(self, width, height) + end +end + +-- Движковые метатаблицы: `FindMetaTable("Panel")` в настоящем +-- `lua/includes/extensions/client/panel.lua` обязан получить нашу таблицу, +-- иначе Derma-расширения уедут в пустую заглушку. +do + local original_find = FindMetaTable + function FindMetaTable(name) + if name == "Panel" or name == "Label" or name == "EditablePanel" + or name == "Button" or name == "TextEntry" then + return panel_meta + end + return original_find(name) + end +end + +-- GMod-овский IsValid: `nil`/`false` невалидны, у таблицы/сущности может +-- быть свой IsValid, остальные значения (числа, строки, панели) валидны. +function IsValid(value) + if value == nil or value == false then return false end + local kind = type(value) + if kind == "table" or kind == "userdata" then + local method = value.IsValid + if type(method) == "function" then + local ok, result = pcall(method, value) + if ok then return result == true end + end + if kind == "table" then + if value.__sr_ui_panel then return value.__removed ~= true end + return true + end + return true + end + return true +end + +-- Рисование текста Label/DLabel. В GMod это делает движок поверх Lua-Paint. +function ui.draw_label_text(panel, width, height) + local value = panel:GetText() + if value == nil or value == "" then return end + if value:sub(1, 1) == "#" then value = language.GetPhrase(value) end + local font = panel.__font or "DermaDefault" + surface.SetFont(font) + local text_width, text_height = surface.GetTextSize(value) + local inset_x, inset_y = panel:GetTextInset() + local align = panel:GetContentAlignment() + local x + local y + if align == 1 or align == 4 or align == 5 then + x = math.max((width - text_width) * 0.5, inset_x) + elseif align == 2 or align == 6 or align == 8 then + x = math.max(width - text_width - inset_x, inset_x) + else + x = inset_x + end + if align >= 6 then + y = height - text_height - inset_y + elseif align == 4 or align == 5 or align == 1 then + y = (height - text_height) * 0.5 + else + y = inset_y + end + local color = panel:GetTextColor() or panel:GetFGColor() or color_white + draw.SimpleText(value, font, x, y, color) +end + +-- --------------------------------------------------------------------------- +-- Ввод и раскладка +-- --------------------------------------------------------------------------- + +local function absolute_pos(panel) + local x, y = 0, 0 + local current = panel + local depth = 0 + while current ~= nil and depth < 64 do + x = x + (current.x or 0) + y = y + (current.y or 0) + current = current.__parent + depth = depth + 1 + end + return x, y +end + +local function sort_children(panel) + local children = {} + for index, child in ipairs(panel.__children or {}) do + children[index] = { child = child, index = index } + end + table.sort(children, function(a, b) + local za = number(a.child.__zpos, 0) + local zb = number(b.child.__zpos, 0) + if za ~= zb then return za < zb end + return a.index < b.index + end) + local out = {} + for index, entry in ipairs(children) do out[index] = entry.child end + return out +end + +local function arrange_children(panel) + local padding_top, padding_right, padding_bottom, padding_left = panel:GetDockPadding() + local left = padding_left + local top = padding_top + local right = panel.w - padding_right + local bottom = panel.h - padding_bottom + for _, child in ipairs(sort_children(panel)) do + if child.__dock ~= NODOCK then + local margin_top, margin_right, margin_bottom, margin_left = child:GetDockMargin() + local dock = child.__dock + if dock == FILL then + child.x = left + margin_left + child.y = top + margin_top + child.w = math.max(right - left - margin_left - margin_right, 0) + child.h = math.max(bottom - top - margin_top - margin_bottom, 0) + elseif dock == TOP then + child.x = left + margin_left + child.y = top + margin_top + child.w = math.max(right - left - margin_left - margin_right, 0) + top = child.y + child.h + margin_bottom + elseif dock == BOTTOM then + child.x = left + margin_left + child.h = child.h + child.y = bottom - child.h - margin_bottom + child.w = math.max(right - left - margin_left - margin_right, 0) + bottom = child.y - margin_top + elseif dock == LEFT then + child.x = left + margin_left + child.y = top + margin_top + child.h = math.max(bottom - top - margin_top - margin_bottom, 0) + left = child.x + child.w + margin_right + elseif dock == RIGHT then + child.w = child.w + child.x = right - child.w - margin_right + child.y = top + margin_top + child.h = math.max(bottom - top - margin_top - margin_bottom, 0) + right = child.x - margin_left + end + end + end +end + +function ui.layout_tree(panel, depth, seen) + depth = depth or 0 + if depth > ui.depth_limit then return end + if panel == nil or not panel:IsVisible() then return end + seen = seen or {} + if seen[panel] then return end + seen[panel] = true + -- Некоторые контролы создают canvas до того, как панель получила + -- родителя (DScrollPanel). Без этого дерево canvas остаётся сиротой и + -- не попадает в снимок; прикрепляем его перед раскладкой. + local canvas = panel.pnlCanvas or panel.__canvas + if canvas ~= nil and canvas.__parent ~= panel then + canvas:SetParent(panel) + end + if panel.PerformLayout ~= nil then + local ok, err = pcall(panel.PerformLayout, panel, panel.w, panel.h) + if not ok then + ui.log_error("PerformLayout " .. tostring(panel.ClassName or panel.__engine) .. ": " .. tostring(err)) + end + end + arrange_children(panel) + for _, child in ipairs(sort_children(panel)) do + ui.layout_tree(child, depth + 1, seen) + end +end + +function ui.resolve_root() + local root = rawget(G, "g_SpawnMenu") + if root ~= nil and istable(root) and root.__sr_ui_panel and not root.__removed then + ui.root = root + return root + end + if ui.root ~= nil and istable(ui.root) and not ui.root.__removed then + return ui.root + end + return nil +end + +local function paint_panel(panel, origin_x, origin_y, depth, count) + if depth > ui.depth_limit or count >= ui.command_limit then return count end + if not panel:IsVisible() then return count end + local x = origin_x + (panel.x or 0) + local y = origin_y + (panel.y or 0) + if panel.Paint ~= nil then + draw_origin_x, draw_origin_y = x, y + local ok, err = pcall(panel.Paint, panel, panel.w, panel.h) + if not ok then + ui.log_error("Paint " .. tostring(panel.ClassName or panel.__engine) .. ": " .. tostring(err)) + end + -- Движковый Label в GMod рисует текст даже когда Lua-Paint свой. + if panel.__is_label and panel.Paint ~= panel_meta.Paint then + ui.draw_label_text(panel, panel.w, panel.h) + end + end + for _, child in ipairs(sort_children(panel)) do + count = paint_panel(child, x, y, depth + 1, count) + if count >= ui.command_limit then break end + end + return count +end + +local function think_panel(panel, depth, seen) + if depth > ui.depth_limit or not panel:IsVisible() then return end + seen = seen or {} + if seen[panel] then return end + seen[panel] = true + if panel.Think ~= nil then + local ok, err = pcall(panel.Think, panel) + if not ok then + ui.log_error("Think " .. tostring(panel.ClassName or panel.__engine) .. ": " .. tostring(err)) + end + end + for _, child in ipairs(sort_children(panel)) do + think_panel(child, depth + 1, seen) + end +end + +function ui.snapshot() + local out = {} + ui.pump() + local root = ui.resolve_root() + if not ui.visible or root == nil or not root:IsVisible() then + return out + end + if root.w <= 0 or root.h <= 0 then + root:SetPos(0, 0) + root:SetSize(ScrW(), ScrH()) + end + ui.layout_tree(root, 0, {}) + think_panel(root, 0, {}) + ui.commands = out + in_snapshot = true + draw_origin_x, draw_origin_y = 0, 0 + paint_panel(root, 0, 0, 0, 0) + in_snapshot = false + ui.commands = nil + return out +end + +function ui.toggle(open) + open = open and true or false + ui.visible = open + local root = ui.resolve_root() + if root == nil then return end + if open then + if not root:IsVisible() and root.Open ~= nil then + local ok, err = pcall(root.Open, root) + if not ok then ui.log_error("SpawnMenu:Open: " .. tostring(err)) end + end + root:SetVisible(true) + if root.w <= 0 or root.h <= 0 then + root:SetPos(0, 0) + root:SetSize(ScrW(), ScrH()) + end + else + if root.Close ~= nil then + local ok, err = pcall(root.Close, root) + if not ok then ui.log_error("SpawnMenu:Close: " .. tostring(err)) end + end + root:SetVisible(false) + end +end + +local function point_inside(panel, px, py) + local x, y = absolute_pos(panel) + return px >= x and py >= y and px < x + math.max(panel.w, 0) and py < y + math.max(panel.h, 0) +end + +local function find_target(panel, px, py, depth) + if depth > ui.depth_limit or not panel:IsVisible() then return nil end + local children = sort_children(panel) + for index = #children, 1, -1 do + local child = children[index] + if point_inside(child, px, py) then + local target = find_target(child, px, py, depth + 1) + if target ~= nil then return target end + end + end + if panel:IsMouseInputEnabled() and point_inside(panel, px, py) then + return panel + end + return nil +end + +local function clear_hovered(panel, depth) + if depth > ui.depth_limit then return end + panel.__hovered = false + panel.Hovered = false + for _, child in ipairs(panel.__children or {}) do + clear_hovered(child, depth + 1) + end +end + +function ui.mouse(kind, x, y, button) + ui.pump() + -- Новые панели могли появиться после прошлого кадра: перед попаданием + -- нужна та же раскладка, что и при рисовании. + local root = ui.resolve_root() + if not ui.visible or root == nil or not root:IsVisible() then return end + ui.layout_tree(root, 0, {}) + ui.mouse_x = number(x, 0) + ui.mouse_y = number(y, 0) + button = number(button, 1) + clear_hovered(root, 0) + local target = find_target(root, ui.mouse_x, ui.mouse_y, 0) + ui.hovered = target + if target ~= nil then + target.__hovered = true + target.Hovered = true + local ancestor = target.__parent + local depth = 0 + while ancestor ~= nil and depth < 64 do + ancestor.__hovered = true + ancestor.Hovered = true + ancestor = ancestor.__parent + depth = depth + 1 + end + end + if kind == "move" then + if target ~= nil and target.OnCursorMoved ~= nil then + local ok, err = pcall(target.OnCursorMoved, target, ui.mouse_x, ui.mouse_y) + if not ok then ui.log_error("OnCursorMoved: " .. tostring(err)) end + end + elseif kind == "down" then + ui.pressed = target + if target ~= nil then + if target.OnMousePressed ~= nil then + local ok, err = pcall(target.OnMousePressed, target, button) + if not ok then ui.log_error("OnMousePressed: " .. tostring(err)) end + end + end + elseif kind == "up" then + local pressed = ui.pressed + ui.pressed = nil + if pressed ~= nil and not pressed.__removed then + -- Под курсором может быть другой элемент, но GMod отпускает + -- кнопку тому, кто её нажал. + local ok, err = pcall(function() + if pressed.OnMouseReleased ~= nil then + pressed.OnMouseReleased(pressed, button) + end + end) + if not ok then ui.log_error("OnMouseReleased: " .. tostring(err)) end + elseif target ~= nil and target.OnMouseReleased ~= nil then + local ok, err = pcall(target.OnMouseReleased, target, button) + if not ok then ui.log_error("OnMouseReleased: " .. tostring(err)) end + end + elseif kind == "wheel" then + local delta = button + if delta == 0 then delta = 1 end + if delta ~= 1 and delta ~= -1 then + if delta > 1 then delta = 1 else delta = -1 end + end + if target ~= nil then + local handler = target.OnMouseWheeled or target.OnMouseWheel + if handler ~= nil then + local ok, err = pcall(handler, target, delta) + if not ok then ui.log_error("OnMouseWheel: " .. tostring(err)) end + end + end + end +end + +-- Совместимость с рендер-агентом: кнопка колеса может прийти как 4/5. +function ui.mouse_wheel(x, y, delta) + ui.mouse("wheel", x, y, delta) +end + +-- --------------------------------------------------------------------------- +-- Fallback-классы. Ставятся ПОСЛЕ настоящих vgui/*; если настоящий файл +-- загрузился, он уже зарегистрирован и fallback не перезаписывает его. +-- --------------------------------------------------------------------------- +function __sr_ui_register_fallback(name, table_value, base) + if vgui.GetControlTable(name) ~= nil then return end + vgui.Register(name, table_value, base or "Panel") +end + +-- Настоящий table.Merge рекурсивно сливает вложенные таблицы и на +-- самоссылающихся данных уходит в stack overflow. Для UI-слоя нужен +-- **поверхностный** merge: экземпляр панели получает методы класса, а +-- вложенные таблицы (например, `Derma`) остаются ссылками на класс, иначе +-- создание DButton перезаписывало бы описание DLabel. +function __sr_ui_patch_extensions() + table.Merge = function(destination, source, force) + if type(destination) ~= "table" or type(source) ~= "table" then + return destination + end + local _ = force + for key, value in pairs(source) do + destination[key] = value + end + return destination + end + + -- Настоящий Panel:Add проверяет `istable(pnl)` и считает любую таблицу + -- классом. Наши панели — таблицы, поэтому их нужно отличать по флагу, + -- иначе DTree_Node:AddNode создаёт копию вместо добавления самой панели. + function panel_meta:Add(value) + if isstring(value) then + return vgui.Create(value, self) + end + if istable(value) then + if value.__sr_ui_panel then + value:SetParent(self) + return value + end + return vgui.CreateFromTable(value, self) + end + if value ~= nil then value:SetParent(self) end + return value + end +end + +function __sr_ui_install_fallbacks() + local function register(name, table_value, base) + __sr_ui_register_fallback(name, table_value, base) + end + + -- DPanel + local DPanel = {} + function DPanel:Init() + self:SetPaintBackground(true) + self:SetPaintBackgroundEnabled(false) + self:SetPaintBorderEnabled(false) + end + function DPanel:Paint(w, h) + local skin = self:GetSkin() + if skin ~= nil then + local ok = pcall(derma.SkinHook, "Paint", "Panel", self, w, h) + if ok then return end + end + if self.m_bBackground ~= false then + surface.SetDrawColor(101, 100, 105, 255) + surface.DrawRect(0, 0, w, h) + end + end + function DPanel:OnMousePressed(code) + local _ = code + end + function DPanel:OnMouseReleased(code) + local _ = code + end + register("DPanel", DPanel, "Panel") + + -- DLabel + local DLabel = {} + function DLabel:Init() + self:SetMouseInputEnabled(false) + self:SetKeyboardInputEnabled(false) + self:SetTall(20) + self:SetFont("DermaDefault") + self:SetPaintBackgroundEnabled(false) + self:SetPaintBorderEnabled(false) + end + function DLabel:ApplySchemeSettings() + local skin = self:GetSkin() + if skin ~= nil then + local ok, err = pcall(derma.SkinHook, "Scheme", "Label", self) + if not ok and err ~= nil then ui.log_error("Label scheme: " .. tostring(err)) end + end + end + register("DLabel", DLabel, "Label") + + -- DButton + local DButton = {} + function DButton:Init() + self:SetTall(22) + self:SetMouseInputEnabled(true) + self:SetKeyboardInputEnabled(true) + self:SetFont("DermaDefault") + self:SetPaintBackground(true) + self:SetPaintBackgroundEnabled(false) + end + function DButton:DoClick() + end + function DButton:Paint(w, h) + local skin = self:GetSkin() + if skin ~= nil then + local ok = pcall(derma.SkinHook, "Paint", "Button", self, w, h) + if ok then return end + end + surface.SetDrawColor(120, 120, 120, 255) + surface.DrawRect(0, 0, w, h) + end + register("DButton", DButton, "DLabel") + + -- DFrame + local DFrame = {} + function DFrame:Init() + self:SetSize(400, 300) + self:SetMouseInputEnabled(true) + self:SetKeyboardInputEnabled(true) + self:SetDraggable(true) + self:SetPaintBackground(true) + self:SetVisible(false) + self:MakePopup() + end + function DFrame:Paint(w, h) + surface.SetDrawColor(55, 57, 61, 240) + surface.DrawRect(0, 0, w, h) + surface.SetDrawColor(20, 20, 20, 255) + surface.DrawOutlinedRect(0, 0, w, h) + if (self.__text or "") ~= "" then + surface.SetDrawColor(101, 100, 105, 255) + surface.DrawRect(4, 4, w - 8, 22) + surface.SetFont("DermaDefault") + surface.SetTextColor(255, 255, 255, 255) + surface.SetTextPos(8, 8) + surface.DrawText(self.__text) + end + end + function DFrame:SetTitle(value) self:SetText(value) end + function DFrame:GetTitle() return self:GetText() end + function DFrame:Close() self:Remove() end + function DFrame:ShowCloseButton() end + function DFrame:SetIcon() end + register("DFrame", DFrame, "EditablePanel") + + -- DScrollPanel + local DScrollPanel = {} + function DScrollPanel:Init() + self.pnlCanvas = vgui.Create("Panel", self) + self.pnlCanvas.__is_canvas = true + self.pnlCanvas:SetPos(0, 0) + self.pnlCanvas:SetSize(self:GetWide(), self:GetTall()) + self.VBar = vgui.Create("DVScrollBar", self) + self.VBar:Dock(RIGHT) + self:SetPaintBackground(false) + end + function DScrollPanel:GetCanvas() + if self.pnlCanvas == nil then + self.pnlCanvas = panel_meta.GetCanvas(self) + self.pnlCanvas.__is_canvas = true + end + return self.pnlCanvas + end + function DScrollPanel:AddItem(panel) panel:SetParent(self:GetCanvas()) end + function DScrollPanel:OnChildAdded(child) self:AddItem(child) end + function DScrollPanel:Clear() self:GetCanvas():Clear() end + function DScrollPanel:PerformLayout() + local canvas = self:GetCanvas() + canvas:SetWide(self:GetWide() - (self.VBar ~= nil and self.VBar:GetWide() or 0)) + canvas:SizeToChildren(false, true) + end + function DScrollPanel:Rebuild() self:GetCanvas():SizeToChildren(false, true) end + function DScrollPanel:ScrollToChild() end + register("DScrollPanel", DScrollPanel, "DPanel") + + -- DScrollBar + local DScrollBar = {} + function DScrollBar:Init() + self:SetSize(15, 100) + self.Enabled = false + self.__offset = 0 + self.__scroll = 0 + end + function DScrollBar:SetUp() end + function DScrollBar:GetOffset() return self.__offset end + function DScrollBar:GetScroll() return self.__scroll end + function DScrollBar:SetScroll(value) self.__scroll = number(value, 0) end + function DScrollBar:AnimateTo(value) self.__scroll = number(value, 0) end + function DScrollBar:OnMouseWheeled(delta) self.__scroll = self.__scroll + number(delta, 0) * 64 return true end + function DScrollBar:Paint(w, h) + surface.SetDrawColor(50, 50, 50, 255) + surface.DrawRect(0, 0, w, h) + end + register("DVScrollBar", DScrollBar, "Panel") + register("DHScrollBar", DScrollBar, "Panel") + + -- DPropertySheet + local DTab = {} + function DTab:Init() + self:SetTall(20) + self:SetFont("DermaDefault") + self:SetTextInset(10, 4) + self:SetContentAlignment(7) + end + function DTab:Setup(label, sheet, panel) + self:SetText(label) + self.__sheet = sheet + self.__panel = panel + end + function DTab:GetPanel() return self.__panel end + function DTab:GetPropertySheet() return self.__sheet end + function DTab:IsActive() return self.__sheet ~= nil and self.__sheet.__active_tab == self end + function DTab:DoClick() if self.__sheet ~= nil then self.__sheet:SetActiveTab(self) end end + function DTab:Paint(w, h) + if self:IsActive() then + surface.SetDrawColor(170, 170, 170, 255) + elseif self:IsHovered() then + surface.SetDrawColor(155, 155, 155, 255) + else + surface.SetDrawColor(140, 140, 140, 255) + end + surface.DrawRect(0, 0, w, h) + surface.SetDrawColor(30, 30, 30, 255) + surface.DrawOutlinedRect(0, 0, w, h) + end + register("DTab", DTab, "DButton") + + local DPropertySheet = {} + function DPropertySheet:Init() + self.Items = {} + self.tabScroller = vgui.Create("DHorizontalScroller", self) + self.tabScroller:SetOverlap(5) + self.tabScroller:Dock(TOP) + self.tabScroller:DockMargin(3, 0, 3, 0) + self:SetPadding(8) + end + function DPropertySheet:GetPadding() return self.__padding or 8 end + function DPropertySheet:SetPadding(value) self.__padding = number(value, 8) end + function DPropertySheet:AddSheet(label, panel, material, no_stretch_x, no_stretch_y) + local _ = material + if not IsValid(panel) then return nil end + local tab = vgui.Create("DTab", self) + tab:Setup(label, self, panel) + tab:Dock(TOP) + tab:SetZPos(100) + local sheet = { Name = label, Tab = tab, Panel = panel } + self.Items[#self.Items + 1] = sheet + panel:SetParent(self) + panel:SetPos(self:GetPadding(), 24) + panel:SetVisible(false) + panel.NoStretchX = no_stretch_x + panel.NoStretchY = no_stretch_y + self.tabScroller:AddPanel(tab) + if self.__active_tab == nil then self:SetActiveTab(tab) end + return sheet + end + function DPropertySheet:GetActiveTab() return self.__active_tab end + function DPropertySheet:SetActiveTab(tab) + if not IsValid(tab) then return end + if self.__active_tab == tab then return end + if self.__active_tab ~= nil then + local old = self.__active_tab:GetPanel() + if IsValid(old) then old:SetVisible(false) end + end + self.__active_tab = tab + local panel = tab:GetPanel() + if IsValid(panel) then panel:SetVisible(true) end + tab:InvalidateLayout(true) + end + function DPropertySheet:SwitchToName(name) + for _, sheet in ipairs(self.Items) do + if sheet.Name == name then sheet.Tab:DoClick() return true end + end + return false + end + function DPropertySheet:GetItems() return self.Items end + function DPropertySheet:Clear() self.Items = {} self.__active_tab = nil if self.tabScroller ~= nil then self.tabScroller:Clear() end end + function DPropertySheet:PerformLayout() + local tab = self.__active_tab + local panel = tab ~= nil and tab:GetPanel() or nil + if self.tabScroller ~= nil then self.tabScroller:SetTall(tab ~= nil and tab:GetTall() or 20) end + if IsValid(panel) then + panel:SetPos(self:GetPadding(), (tab ~= nil and tab:GetTall() or 20) + self:GetPadding()) + if not panel.NoStretchX then panel:SetWide(self:GetWide() - self:GetPadding() * 2) end + if not panel.NoStretchY then panel:SetTall(self:GetTall() - panel.y - self:GetPadding()) end + end + end + function DPropertySheet:Paint(w, h) + surface.SetDrawColor(101, 100, 105, 255) + surface.DrawRect(0, 0, w, h) + end + register("DPropertySheet", DPropertySheet, "Panel") + + -- DHorizontalScroller + local DHorizontalScroller = {} + function DHorizontalScroller:Init() + self.Panels = {} + self.__offset = 0 + self:SetTall(20) + end + function DHorizontalScroller:AddPanel(panel) + self.Panels[#self.Panels + 1] = panel + panel:SetParent(self) + panel:SetPos(self.__offset, 0) + self.__offset = self.__offset + panel:GetWide() + panel:Dock(NODOCK) + end + function DHorizontalScroller:Add(classname) + local panel = vgui.Create(classname, self) + self:AddPanel(panel) + return panel + end + function DHorizontalScroller:SetOverlap(value) self.__overlap = number(value, 0) end + function DHorizontalScroller:GetOverlap() return self.__overlap or 0 end + function DHorizontalScroller:ScrollToChild() end + function DHorizontalScroller:PerformLayout() + local x = 0 + for _, panel in ipairs(self.Panels) do + panel:SetPos(x, 0) + panel:SetTall(self:GetTall() - 1) + x = x + panel:GetWide() - self:GetOverlap() + end + end + function DHorizontalScroller:Paint(w, h) + surface.SetDrawColor(140, 140, 140, 255) + surface.DrawRect(0, 0, w, h) + end + register("DHorizontalScroller", DHorizontalScroller, "Panel") + + -- DImage / DImageButton + local DImage = {} + function DImage:Init() + self:SetSize(64, 64) + self:SetPaintBackground(false) + self:SetMouseInputEnabled(false) + end + function DImage:Paint(w, h) + surface.SetDrawColor(70, 70, 70, 255) + surface.DrawRect(0, 0, w, h) + end + function DImage:SetImage(image) self.__image = image end + function DImage:GetImage() return self.__image end + function DImage:SetMaterial(material) self.__material = material end + function DImage:GetMaterial() return self.__material end + function DImage:SetImageColor(color) self.__image_color = to_color_table(color) end + function DImage:SizeToContents() end + register("DImage", DImage, "DPanel") + + local DImageButton = {} + function DImageButton:Init() + self:SetSize(16, 16) + self:SetMouseInputEnabled(true) + self:SetCursor("hand") + end + function DImageButton:SetImage(image) self.__image = image end + function DImageButton:GetImage() return self.__image end + function DImageButton:Paint(w, h) + surface.SetDrawColor(120, 120, 120, self:IsHovered() and 255 or 180) + surface.DrawRect(0, 0, w, h) + if self.__text ~= nil and self.__text ~= "" then + surface.SetFont("DermaDefault") + surface.SetTextColor(255, 255, 255, 255) + surface.SetTextPos(2, math.max((h - 13) * 0.5, 0)) + surface.DrawText(self.__text) + end + end + register("DImageButton", DImageButton, "DButton") + + -- DTextEntry + local DTextEntry = {} + function DTextEntry:Init() + self:SetTall(20) + self:SetMouseInputEnabled(true) + self:SetKeyboardInputEnabled(true) + self.__value = "" + end + function DTextEntry:SetText(value) self.__text = text_of(value) or "" self.__value = self.__text end + function DTextEntry:GetText() return self.__text or self.__value or "" end + function DTextEntry:SetValue(value) self:SetText(value) end + function DTextEntry:GetValue() return self:GetText() end + function DTextEntry:SetPlaceholderText(value) self.__placeholder = value end + function DTextEntry:GetPlaceholderText() return self.__placeholder end + function DTextEntry:SetNumeric(value) self.__numeric = value and true or false end + function DTextEntry:SetMultiline(value) self.__multiline = value and true or false end + function DTextEntry:AllowInput(value) self.__allow_input = value and true or false end + function DTextEntry:SetEditable(value) self.__editable = value end + function DTextEntry:Paint(w, h) + surface.SetDrawColor(240, 240, 240, 255) + surface.DrawRect(0, 0, w, h) + surface.SetDrawColor(20, 20, 20, 255) + surface.DrawOutlinedRect(0, 0, w, h) + local value = self:GetText() + local color = { 20, 20, 20, 255 } + if value == "" and self.__placeholder ~= nil then + value = language.GetPhrase(self.__placeholder) + color = { 128, 128, 128, 255 } + end + if value ~= "" then + surface.SetFont("DermaDefault") + surface.SetTextColor(color[1], color[2], color[3], color[4]) + surface.SetTextPos(5, math.max((h - 13) * 0.5, 0)) + surface.DrawText(value) + end + end + function DTextEntry:OnMousePressed(code) + if code == MOUSE_LEFT then self.__focused = true end + end + register("DTextEntry", DTextEntry, "TextEntry") + + -- DForm / DProperties: минимальные, чтобы controlpanel мог строиться. + local DForm = {} + function DForm:Init() + self:SetPaintBackground(false) + self.Items = {} + self.__y = 0 + end + function DForm:AddItem(panel) + panel:SetParent(self) + panel:SetPos(0, self.__y) + panel:SetWide(self:GetWide()) + self.__y = self.__y + panel:GetTall() + 4 + self.Items[#self.Items + 1] = panel + end + function DForm:PerformLayout() + local y = 0 + for _, panel in ipairs(self.Items) do + panel:SetPos(0, y) + panel:SetWide(self:GetWide()) + y = y + panel:GetTall() + 4 + end + end + register("DForm", DForm, "DPanel") + + -- DMenu + local DMenu = {} + function DMenu:Init() + self:SetVisible(false) + self:SetSize(160, 24) + self.Options = {} + self:SetPaintBackground(true) + end + function DMenu:AddOption(label, callback) + local option = vgui.Create("DButton", self) + option:SetText(label) + option:SetSize(self:GetWide(), 22) + option.DoClick = function(button) + self:SetVisible(false) + if callback ~= nil then pcall(callback, button) end + end + self.Options[#self.Options + 1] = option + self:SetTall(math.max(24, #self.Options * 22 + 4)) + local index = #self.Options + option:SetPos(2, 2 + (index - 1) * 22) + return option + end + function DMenu:AddSpacer() end + function DMenu:AddSubMenu(label) + local option = self:AddOption(label, nil) + return self, option + end + function DMenu:Open(x, y) + if x ~= nil then self:SetPos(x, y) end + self:SetVisible(true) + end + function DMenu:Close() self:SetVisible(false) end + register("DMenu", DMenu, "Panel") + register("DMenuOption", DButton, "DButton") + + -- DTree/DTree_Node: без них боковая панель spawnmenu пуста. + local DTree = {} + function DTree:Init() + self:SetPaintBackground(true) + self:SetMouseInputEnabled(true) + self.RootNode = vgui.Create("DTree_Node", self) + self.RootNode:SetRoot(self) + self.RootNode:SetParentNode(self) + self.RootNode:SetText("") + self.RootNode:SetExpanded(true, true) + self.RootNode:Dock(TOP) + end + function DTree:Root() return self.RootNode end + function DTree:AddNode(name, icon) return self.RootNode:AddNode(name, icon) end + function DTree:Clear() self.RootNode:Clear() end + function DTree:SetClickOnDragHover() end + function DTree:SetShowIcons(value) self.__show_icons = value end + function DTree:ShowIcons() return self.__show_icons ~= false end + function DTree:LayoutTree() end + register("DTree", DTree, "DScrollPanel") + + local DTree_Node = {} + function DTree_Node:Init() + self.ChildNodes = vgui.Create("Panel", self) + self.ChildNodes:Dock(TOP) + self.ChildNodes:SetTall(0) + self.__expanded = false + self.__icon = "" + self.__text = "" + self:SetTall(17) + self:SetMouseInputEnabled(true) + end + function DTree_Node:SetRoot(root) self.__root = root end + function DTree_Node:GetRoot() return self.__root end + function DTree_Node:SetParentNode(node) self.__parent_node = node end + function DTree_Node:GetParentNode() return self.__parent_node end + function DTree_Node:AddNode(name, icon) + local node = vgui.Create("DTree_Node", self.ChildNodes) + node:SetParentNode(self) + node:SetRoot(self.__root) + node:SetText(name) + node:SetIcon(icon) + node:Dock(TOP) + self:SetExpanded(true) + self.ChildNodes:SetTall(#self.ChildNodes:GetChildren() * 17) + return node + end + function DTree_Node:AddFolder(name, path, path_id, search) + local _ = path_id + local _ = search + local node = self:AddNode(name, "icon16/folder.png") + node.__folder = path + return node + end + function DTree_Node:GetFolder() return self.__folder end + function DTree_Node:GetPathID() return "GAME" end + function DTree_Node:SetText(value) self.__text = language.GetPhrase(value) end + function DTree_Node:GetText() return self.__text or "" end + function DTree_Node:SetIcon(value) self.__icon = value or "" end + function DTree_Node:GetIcon() return self.__icon or "" end + function DTree_Node:SetExpanded(value) + self.__expanded = value and true or false + if self.ChildNodes ~= nil then self.ChildNodes:SetVisible(self.__expanded) end + end + function DTree_Node:GetExpanded() return self.__expanded end + function DTree_Node:SetExpandedEx() end + function DTree_Node:GetChildNodes() return self.ChildNodes ~= nil and self.ChildNodes:GetChildren() or {} end + function DTree_Node:GetChildNodeCount() return self.ChildNodes ~= nil and #self.ChildNodes:GetChildren() or 0 end + function DTree_Node:Clear() if self.ChildNodes ~= nil then self.ChildNodes:Clear() end end + function DTree_Node:MoveToFront() end + function DTree_Node:MoveToBack() end + function DTree_Node:DoPopulate() end + function DTree_Node:InternalDoClick() + if self.__root ~= nil and self.__root.SetSelectedItem ~= nil then + self.__root:SetSelectedItem(self) + end + if self.DoClick ~= nil then pcall(self.DoClick, self) end + end + function DTree_Node:Paint(w, h) + if self:IsHovered() then + surface.SetDrawColor(255, 255, 255, 30) + surface.DrawRect(0, 0, w, h) + end + if self.__expanded then + surface.SetDrawColor(220, 220, 220, 255) + surface.DrawRect(4, 4, 9, 9) + end + surface.SetFont("DermaDefault") + surface.SetTextColor(30, 30, 30, 255) + surface.SetTextPos(18, math.max((h - 13) * 0.5, 0)) + surface.DrawText(self.__text or "") + end + function DTree_Node:OnMousePressed(code) + if code == MOUSE_LEFT then + self:InternalDoClick() + self:SetExpanded(not self:GetExpanded()) + end + end + register("DTree_Node", DTree_Node, "DPanel") + register("DTree_Node_Button", DButton, "DButton") + function DTree:SetSelectedItem(node) + self.__selected = node + if node ~= nil and node.OnNodeSelected ~= nil then pcall(node.OnNodeSelected, node) end + end + + -- DCategoryList / DCollapsibleCategory: ими пользуется ToolMenu. + local DCollapsibleCategory = {} + function DCollapsibleCategory:Init() + self:SetTall(24) + self:SetPaintBackground(false) + self.Items = {} + end + function DCollapsibleCategory:SetLabel(value) self.__label = value end + function DCollapsibleCategory:GetLabel() return self.__label end + function DCollapsibleCategory:Add(classname) + local panel = vgui.Create(classname, self) + panel:Dock(TOP) + self.Items[#self.Items + 1] = panel + return panel + end + function DCollapsibleCategory:SetContents(panel) self.__contents = panel end + function DCollapsibleCategory:GetContents() return self.__contents end + function DCollapsibleCategory:Paint(w, h) + surface.SetDrawColor(80, 80, 80, 255) + surface.DrawRect(0, 0, w, 20) + surface.SetFont("DermaDefault") + surface.SetTextColor(255, 255, 255, 255) + surface.SetTextPos(4, 3) + surface.DrawText(self.__label or "") + end + register("DCollapsibleCategory", DCollapsibleCategory, "Panel") + + local DCategoryList = {} + function DCategoryList:Init() + self:SetPaintBackground(false) + self:SetMouseInputEnabled(true) + self.Items = {} + end + function DCategoryList:Add(name) + local category = vgui.Create("DCollapsibleCategory", self) + category:SetLabel(name) + category:Dock(TOP) + self.Items[#self.Items + 1] = category + return category + end + function DCategoryList:Clear() self.Items = {} self:ClearDefault() end + function DCategoryList:ClearDefault() for _, child in ipairs(self:GetChildren()) do child:Remove() end end + register("DCategoryList", DCategoryList, "DScrollPanel") + + -- DComboBox + local DComboBox = {} + function DComboBox:Init() + self:SetTall(20) + self:SetMouseInputEnabled(true) + self.Choices = {} + self.__value = "" + end + function DComboBox:AddChoice(text, data, selected) + self.Choices[#self.Choices + 1] = { text = text, data = data } + if selected then self:SetValue(text, data) end + end + function DComboBox:SetValue(value, data) + self.__value = value + self.__data = data + if self.OnSelect ~= nil then pcall(self.OnSelect, self, #self.Choices, value, data) end + end + function DComboBox:GetValue() return self.__value, self.__data end + function DComboBox:ChooseOption(value) + for index, choice in ipairs(self.Choices) do + if choice.text == value then + self:SetValue(choice.text, choice.data) + if self.OnSelect ~= nil then pcall(self.OnSelect, self, index, choice.text, choice.data) end + return + end + end + end + function DComboBox:Clear() self.Choices = {} end + function DComboBox:Paint(w, h) + surface.SetDrawColor(240, 240, 240, 255) + surface.DrawRect(0, 0, w, h) + surface.SetDrawColor(20, 20, 20, 255) + surface.DrawOutlinedRect(0, 0, w, h) + surface.SetFont("DermaDefault") + surface.SetTextColor(20, 20, 20, 255) + surface.SetTextPos(4, math.max((h - 13) * 0.5, 0)) + surface.DrawText(tostring(self.__value or "")) + end + register("DComboBox", DComboBox, "DPanel") + + -- DCheckBox + local DCheckBox = {} + function DCheckBox:Init() + self:SetSize(16, 16) + self:SetMouseInputEnabled(true) + self.__value = 0 + end + function DCheckBox:SetValue(value) + self.__value = number(value, 0) + if self.OnChange ~= nil then pcall(self.OnChange, self, self.__value) end + end + function DCheckBox:GetValue() return self.__value end + function DCheckBox:SetChecked(value) self:SetValue(value and 1 or 0) end + function DCheckBox:GetChecked() return self.__value ~= 0 end + function DCheckBox:Paint(w, h) + surface.SetDrawColor(240, 240, 240, 255) + surface.DrawRect(0, 0, w, h) + surface.SetDrawColor(20, 20, 20, 255) + surface.DrawOutlinedRect(0, 0, w, h) + if self.__value ~= 0 then + surface.DrawRect(3, 3, math.max(w - 6, 1), math.max(h - 6, 1)) + end + end + function DCheckBox:OnMousePressed(code) + if code == MOUSE_LEFT then self:SetValue(self.__value == 0 and 1 or 0) end + end + register("DCheckBox", DCheckBox, "Panel") + register("DCheckBoxLabel", DCheckBox, "DCheckBox") + + -- DListBox / DListView + local DListBox = {} + function DListBox:Init() + self:SetPaintBackground(true) + self.Items = {} + self.__selected = 0 + end + function DListBox:AddItem(value) + self.Items[#self.Items + 1] = value + end + function DListBox:Clear() self.Items = {} self.__selected = 0 end + function DListBox:GetSelectedItem() return self.Items[self.__selected] end + function DListBox:GetSelected() return self.__selected end + function DListBox:SelectItem(index) self.__selected = number(index, 0) end + function DListBox:Paint(w, h) + surface.SetDrawColor(240, 240, 240, 255) + surface.DrawRect(0, 0, w, h) + surface.SetFont("DermaDefault") + for index, value in ipairs(self.Items) do + if index > 64 then break end + surface.SetTextColor(20, 20, 20, 255) + surface.SetTextPos(4, (index - 1) * 16 + 2) + surface.DrawText(tostring(value)) + end + end + register("DListBox", DListBox, "DPanel") + + -- DTileLayout / DDragBase: сетка иконок; настоящий файл обычно грузится, + -- это запас на случай его ошибки. + local DDragBase = {} + function DDragBase:Init() + self:SetPaintBackground(false) + self:SetMouseInputEnabled(true) + self.__read_only = false + end + function DDragBase:SetReadOnly(value) self.__read_only = value and true or false end + function DDragBase:GetReadOnly() return self.__read_only end + function DDragBase:MakeDroppable() end + function DDragBase:SetDropPos() end + function DDragBase:SetDropTarget() end + function DDragBase:UpdateDropTarget() end + function DDragBase:OnModified() end + function DDragBase:SetSelectionCanvas(value) self.__selection_canvas = value end + function DDragBase:IsSelectionCanvas() return self.__selection_canvas == true end + function DDragBase:StartBoxSelection() end + function DDragBase:EndBoxSelection() return false end + function DDragBase:DrawSelections() end + function DDragBase:NumSelectedChildren() return 0 end + function DDragBase:GetSelectedChildren() return {} end + function DDragBase:UnselectAll() end + register("DDragBase", DDragBase, "DPanel") + + local DTileLayout = {} + function DTileLayout:Init() + self:SetPaintBackground(false) + self:SetMouseInputEnabled(true) + self.__base_size = 64 + self.__space_x = 0 + self.__space_y = 0 + self.__border = 0 + end + function DTileLayout:SetBaseSize(value) self.__base_size = math.max(number(value, 64), 1) end + function DTileLayout:GetBaseSize() return self.__base_size end + function DTileLayout:SetSpaceX(value) self.__space_x = number(value, 0) end + function DTileLayout:GetSpaceX() return self.__space_x end + function DTileLayout:SetSpaceY(value) self.__space_y = number(value, 0) end + function DTileLayout:GetSpaceY() return self.__space_y end + function DTileLayout:SetBorder(value) self.__border = number(value, 0) end + function DTileLayout:GetBorder() return self.__border end + function DTileLayout:SetMinHeight(value) self.__min_height = number(value, 0) end + function DTileLayout:GetMinHeight() return self.__min_height or 0 end + function DTileLayout:Layout() end + function DTileLayout:PerformLayout() + local size = self.__base_size + self.__space_x + local columns = math.max(math.floor((self:GetWide() - self.__border * 2 + self.__space_x) / size), 1) + local x, y = self.__border, self.__border + local column = 0 + for _, child in ipairs(self:GetChildren()) do + if child:IsVisible() then + child:SetPos(x + column * size, y) + x = x + column = column + 1 + if column >= columns then + column = 0 + y = y + self.__base_size + self.__space_y + end + end + end + local rows = math.ceil(#self:GetChildren() / columns) + self:SetTall(math.max(rows * (self.__base_size + self.__space_y) + self.__border * 2, self:GetMinHeight())) + end + register("DTileLayout", DTileLayout, "DDragBase") + register("DIconLayout", DTileLayout, "DTileLayout") + + -- ModelImage: рендер модели не наш слой, но SpawnIcon обращается к его + -- методам. Текст модели делает иконки различимыми в снимке. + local ModelImage = {} + function ModelImage:Init() + self:SetPaintBackground(false) + self.__model = "" + end + function ModelImage:SetModel(model) self.__model = tostring(model or "") end + function ModelImage:GetModel() return self.__model end + function ModelImage:SetSpawnIcon(name) self.__model = tostring(name or "") end + function ModelImage:RebuildSpawnIcon() end + function ModelImage:RebuildSpawnIconEx() end + function ModelImage:Paint(w, h) + surface.SetDrawColor(45, 45, 45, 255) + surface.DrawRect(0, 0, w, h) + local name = self.__model or "" + name = name:match("([^/\\]+)$") or name + if name ~= "" then + surface.SetFont("DermaDefault") + surface.SetTextColor(220, 220, 220, 255) + surface.SetTextPos(2, math.max(h - 15, 2)) + surface.DrawText(name) + end + end + register("ModelImage", ModelImage, "DPanel") + register("DModelPanel", ModelImage, "ModelImage") + + -- Заглушки оставшихся контролов, которые встречаются в панелях инструментов. + local function simple_panel(name, base) + local panel = {} + function panel:Init() + self:SetPaintBackground(false) + self:SetMouseInputEnabled(true) + end + function panel:SetValue(value) self.__value = value end + function panel:GetValue() return self.__value end + function panel:SetConVar(name_) self.__convar = name_ end + function panel:GetConVar() return self.__convar end + function panel:SetExpanded(value) self.__expanded = value and true or false end + function panel:GetExpanded() return self.__expanded == true end + function panel:SetSelected(value) self.__selected = value and true or false end + function panel:IsSelected() return self.__selected == true end + function panel:SetScroll(value) self.__scroll = value end + function panel:GetScroll() return self.__scroll or 0 end + function panel:Paint(w, h) + surface.SetDrawColor(100, 100, 100, 255) + surface.DrawRect(0, 0, w, h) + end + register(name, panel, base or "DPanel") + return panel + end + simple_panel("DNumSlider") + simple_panel("DColorMixer") + simple_panel("DColorButton", "DButton") + simple_panel("DGrid") + simple_panel("DHTML") + simple_panel("DBinder") + simple_panel("DPanelList", "DScrollPanel") + simple_panel("DExpandButton", "DButton") + simple_panel("DShape") + simple_panel("DProgress") + simple_panel("DNotify", "DFrame") + simple_panel("DAlphaBar", "DPanel") + simple_panel("DColorPalette") + simple_panel("DColorCube") + simple_panel("DColorCombo", "DPanel") + simple_panel("DSlider") + simple_panel("DNumberWang") + simple_panel("DSprite") + simple_panel("DModelSelect") + simple_panel("DModelSelectMulti") + simple_panel("DFileBrowser") +end + +-- --------------------------------------------------------------------------- +-- Финализация: pcall-граница вокруг Init классов (правило #2 AGENTS.md). +-- Без неё один отсутствующий метод в ToolMenu убивает создание SpawnMenu. +-- --------------------------------------------------------------------------- + +function __sr_ui_finalize() + if ui.__finalized then return end + ui.__finalized = true + local depth = 0 + local active = {} + function vgui.Create(classname, parent, name) + if depth >= 32 then + ui.log_error("vgui.Create(" .. tostring(classname) .. "): цикл базовых классов: " + .. table.concat(active, " -> ")) + return ui.new_panel("Panel", parent, name or classname) + end + depth = depth + 1 + active[#active + 1] = tostring(classname) + local ok, panel = pcall(function() + local class = vgui.GetControlTable(classname) + if class == nil then + -- Движковый класс (Panel, Label, Button, TextEntry...). + return ui.new_panel(tostring(classname), parent, name or classname) + end + local base = class.Base or "Panel" + local instance = vgui.Create(base, parent, name or classname) + if instance == nil then + return ui.new_panel("Panel", parent, name or classname) + end + table.Merge(instance:GetTable(), class) + instance.ClassName = classname + instance.BaseClass = vgui.GetControlTable(base) + -- Init каждого класса оборачиваем отдельно: если он упал после + -- создания дочерних панелей, они остаются в дереве. + if type(instance.Init) == "function" then + local init_ok, init_err = pcall(instance.Init, instance) + if not init_ok then + ui.log_error("Init " .. tostring(classname) .. ": " .. tostring(init_err)) + end + end + if type(instance.Prepare) == "function" then + pcall(instance.Prepare, instance) + end + return instance + end) + active[#active] = nil + depth = depth - 1 + if ok and panel ~= nil then return panel end + ui.log_error("vgui.Create(" .. tostring(classname) .. "): " .. tostring(panel)) + -- Безопасный запас: пустая панель вместо nil, чтобы вызывающий код + -- не падал на SetParent/SetPos. + local fallback = ui.new_panel("Panel", parent, name or classname) + fallback.ClassName = classname + return fallback + end + + -- Некоторые контролы создают canvas до того, как панель получила + -- родителя (DScrollPanel). Чиним связь сразу: иначе `GetParent():GetParent()` + -- в DCollapsibleCategory:LoadCookies видит nil и тулменю не строится. + local scroll_panel = vgui.GetControlTable("DScrollPanel") + if scroll_panel ~= nil and type(scroll_panel.GetCanvas) == "function" then + local original_get_canvas = scroll_panel.GetCanvas + scroll_panel.GetCanvas = function(self) + local canvas = original_get_canvas(self) + if canvas ~= nil and canvas.__parent ~= self then + canvas:SetParent(self) + end + return canvas + end + end + + -- Копия настоящего CreateFromTable, но с pcall вокруг Init и без + -- зависимости от устройства scriptedpanels. + function vgui.CreateFromTable(class_table, parent, name) + if type(class_table) ~= "table" then return nil end + local base = class_table.Base or "Panel" + local panel = vgui.Create(base, parent, name) + if panel == nil then return nil end + table.Merge(panel:GetTable(), class_table) + panel.BaseClass = vgui.GetControlTable(base) + if type(panel.Init) == "function" then + local init_ok, init_err = pcall(panel.Init, panel) + if not init_ok then + ui.log_error("Init CreateFromTable: " .. tostring(init_err)) + end + end + return panel + end + + -- Глобальные `Panel`, `Label`, `EditablePanel` в GMod — таблицы методов. + -- Настоящий scriptedpanels уже выставил baseclass, но глобальные имена + -- нужны скриптам вроде `DLabel.ApplySchemeSettings(self)`. + if rawget(G, "DLabel") == nil then G.DLabel = vgui.GetControlTable("DLabel") or panel_meta end + if rawget(G, "DPanel") == nil then G.DPanel = vgui.GetControlTable("DPanel") or panel_meta end +end + +-- Первый кадр: корень меню может появиться позже (OnGamemodeLoaded). +function ui.attach_root(root) + if root ~= nil and istable(root) then + ui.root = root + if ui.visible then ui.toggle(true) end + end +end + +-- Совместимость: рендер-агент может спросить размеры экрана. +function ui.set_screen(width, height) + ui.screen_w = number(width, ui.screen_w) + ui.screen_h = number(height, ui.screen_h) +end diff --git a/crates/lua/tests/include.rs b/crates/lua/tests/include.rs index e1efa76..2495a25 100644 --- a/crates/lua/tests/include.rs +++ b/crates/lua/tests/include.rs @@ -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"); diff --git a/crates/lua/tests/ui.rs b/crates/lua/tests/ui.rs new file mode 100644 index 0000000..1c87945 --- /dev/null +++ b/crates/lua/tests/ui.rs @@ -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 { + 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 { + 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 { + let mut texts: Vec = 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}" + ); +} diff --git a/docs/DIARY.md b/docs/DIARY.md index 464739d..ce22685 100644 --- a/docs/DIARY.md +++ b/docs/DIARY.md @@ -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`, diff --git a/docs/LUA.md b/docs/LUA.md index 0fff131..370e07f 100644 --- a/docs/LUA.md +++ b/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 (защита от крашедла) Цель: аддон/сервер не может уронить клиент.