Q-меню: dirty-снимок, слайдеры, колесо и выравнивание текста

Снимок Derma пересобирается по dirty-флагу (ввод, таймеры, сеттеры), а не раз в 6 кадров: в покое 0.26-0.5 мс вместо 4-6 мс, без пиков 47-84 мс. Невидимые поддеревья и обрезанные команды не попадают в снимок; сортировка детей без ZPos не аллоцирует; hover не перезапускает раскладку. Меню прогревается на загрузке карты.

Слайдеры: загружается dnumberscratch.lua, добавлены NoClipping/GetCaretPos/SetCaretPos/input.SetCursorPos, OnCursorMoved получает локальные координаты, MouseCapture удерживает перетаскивание; есть fallback DSlider/DNumSlider/DNumberScratch.

Колесо всплывает от иконки/кнопки до DScrollPanel/DTree; починен корень DTree (высота ChildNodes до VBar). Клик/колесо проверяются тестами и отладочными SOURCE_RS_UI_CLICK/WHEEL.

Текст: метрики surface.GetTextSize совпадают с bitmap-рендером, вертикаль по высоте глифа, ContentAlignment как в GMod, вкладки центрируются, дерево слева с отступом, заглушки иконок - две строки по центру с обрезкой.

Иконки: очередь видимых моделей по площади, до 8/~8 мс за кадр, LRU-кэш переиспользуется.
This commit is contained in:
Niko Marmeladkov 2026-10-24 20:35:56 +03:00
parent 58daab03be
commit 1b24f280d3
8 changed files with 1070 additions and 147 deletions

View file

@ -551,6 +551,21 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
if lua_ui_ready {
log.push_back("ui: Lua Sandbox".to_string());
info!("Q-меню: настоящий Sandbox из Lua");
// Прогрев: таймеры spawnmenu создают вкладки и содержимое.
// Делаем это на загрузке карты, а не на первом Q, иначе
// первый кадр меню стоит ~100 мс.
if std::env::var_os("SOURCE_RS_UI_NO_PREWARM").is_none() {
let started = Instant::now();
lua.ui_toggle(true);
let mut warm = Vec::new();
lua.ui_snapshot_into(&mut warm);
lua.ui_toggle(false);
info!(
elapsed_ms = started.elapsed().as_secs_f64() * 1000.0,
commands = warm.len(),
"Lua UI: меню прогрето"
);
}
} else {
log.push_back("ui: fallback Q-меню".to_string());
warn!("Q-меню: g_SpawnMenu не создан, остаётся встроенное меню");
@ -774,12 +789,29 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
pending_spawns.push(menu_spawn_request(item, &collision, &player, camera.yaw));
}
// `SOURCE_RS_UI_CLICK` — отладочный клик по Lua-меню: «x,y» или текст
// вкладки/модели. Нужен, потому что скрытое окно не получает xdotool.
let ui_click = std::env::var("SOURCE_RS_UI_CLICK")
// вкладки/модели. Несколько целей разделяются `;` и кликаются по очереди
// (например, «Weapons;barrel»). Нужен, потому что скрытое окно не
// получает xdotool.
let ui_clicks: Vec<String> = std::env::var("SOURCE_RS_UI_CLICK")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let mut ui_click_done = false;
.map(|value| {
value
.split(';')
.map(|part| part.trim().to_string())
.filter(|part| !part.is_empty())
.collect()
})
.unwrap_or_default();
let mut ui_click_index = 0usize;
// `SOURCE_RS_UI_WHEEL=x,y,delta` — колесо после отладочных кликов.
let ui_wheel = std::env::var("SOURCE_RS_UI_WHEEL").ok().and_then(|value| {
let mut parts = value.split(',').map(|part| part.trim().parse::<f32>());
let x = parts.next()?.ok()?;
let y = parts.next()?.ok()?;
let delta = parts.next()?.ok()? as i32;
Some((x, y, delta))
});
let mut ui_wheel_done = false;
// `SOURCE_RS_UI_CLOSE=<кадр>` — закрыть меню после отладочного клика,
// чтобы на скриншоте был виден заспавненный проп, а не только панели.
let ui_close_frame = std::env::var("SOURCE_RS_UI_CLOSE")
@ -792,6 +824,16 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
// Снимок панелей Derma — дорогой (Lua + строки), поэтому обновляем его
// не каждый кадр: по событиям ввода и раз в несколько кадров для анимаций.
let mut ui_snapshot_age = u32::MAX;
// `SOURCE_RS_UI_PROFILE=1` — раз в 120 кадров печатать стоимость снимка,
// загрузки иконок и отрисовки. Без цифр «меню лагает» — только ощущение.
let ui_profile = std::env::var_os("SOURCE_RS_UI_PROFILE").is_some();
let mut profile_snapshot_us: u128 = 0;
let mut profile_icons_us: u128 = 0;
let mut profile_draw_us: u128 = 0;
let mut profile_snapshot_max_us: u128 = 0;
let mut profile_draw_max_us: u128 = 0;
let mut profile_frames: u32 = 0;
let mut profile_icon_loads: usize = 0;
let mut lua_ui_open = false;
// Палец, который сейчас нажимает в Lua-панель (остальные — камера/стик).
let mut ui_touch_finger: Option<u64> = None;
@ -1161,8 +1203,8 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
}
}
Event::MouseMotion { x, y, .. } if lua_ui_open => {
ui_snapshot_age = u32::MAX;
// Настоящее меню само решает, какая панель под курсором.
// Настоящее меню само решает, какая панель под курсором;
// снимок закажет только смена hover или перетаскивание.
if let Some(lua) = &lua {
lua.ui_mouse("move", x, y, 0);
}
@ -1296,29 +1338,37 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
}
}
// Отладочный клик по снимку прошлого кадра: вкладка, категория или
// Отладочные клики по снимку прошлого кадра: вкладка, категория или
// модель. Скрытое окно не получает внешних кликов, поэтому проверка
// спавна идёт тем же `ui_mouse`, что и настоящая мышь.
// идёт тем же `ui_mouse`, что и настоящая мышь.
if lua_ui_open
&& !ui_click_done
&& frames_drawn >= 8
&& let Some(spec) = ui_click.as_deref()
&& ui_click_index < ui_clicks.len()
&& frames_drawn >= 8 + ui_click_index as u64 * 8
{
ui_click_done = true;
match ui_overlay::click_point(&ui_commands, spec) {
let spec = ui_clicks[ui_click_index].clone();
ui_click_index += 1;
match ui_overlay::click_point(&ui_commands, &spec) {
Some((x, y)) => {
info!(spec, x, y, "Lua UI: отладочный клик");
info!(spec = %spec, x, y, "Lua UI: отладочный клик");
if let Some(lua) = &lua {
lua.ui_mouse("move", x, y, 0);
lua.ui_mouse("down", x, y, 1);
lua.ui_mouse("up", x, y, 1);
}
}
None => warn!(spec, "Lua UI: цель отладочного клика не найдена"),
None => warn!(spec = %spec, "Lua UI: цель отладочного клика не найдена"),
}
}
if ui_click.is_none() {
ui_click_done = true;
if lua_ui_open
&& !ui_wheel_done
&& let Some((x, y, delta)) = ui_wheel
&& frames_drawn >= 8 + ui_clicks.len() as u64 * 8
{
ui_wheel_done = true;
if let Some(lua) = &lua {
info!(x, y, delta, "Lua UI: отладочное колесо");
lua.ui_mouse("wheel", x, y, delta);
}
}
// Отладочное закрытие: нужно для кадра со спавном (меню закрыто).
if lua_ui_open
@ -1968,43 +2018,101 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
// Настоящее меню Sandbox: снимок панелей из Lua, примитивами клиента.
if lua_ui_open {
ui_snapshot_age = ui_snapshot_age.saturating_add(1);
// 6 кадров (~0.1 с) — компромисс: меню отзывчиво, но Lua не
// пересобирает дерево на каждый кадр (это и давало лаги).
if (ui_snapshot_age >= 6 || ui_commands.is_empty())
// Снимок пересобирается только когда Lua пометил состояние
// грязным (ввод, таймер, сеттер панели, анимация). Раз в 60
// кадров — страховка от забытого флага, не рабочий режим.
if let Some(lua) = &lua {
lua.ui_tick();
}
let dirty = lua.as_ref().is_some_and(|lua| lua.ui_dirty());
if (dirty || ui_commands.is_empty() || ui_snapshot_age >= 60)
&& let Some(lua) = &lua
{
let started = Instant::now();
lua.ui_snapshot_into(&mut ui_commands);
let elapsed = started.elapsed().as_micros();
profile_snapshot_us += elapsed;
profile_snapshot_max_us = profile_snapshot_max_us.max(elapsed);
ui_snapshot_age = 0;
}
// 3D-иконки: не больше трёх новых моделей за кадр. Загрузка MDL
// и распаковка текстур дорогие, а на экране одновременно видно
// несколько десятков иконок — кэш наполняется за пару секунд без
// рывков. Невидимые (за краем содержимого) не грузим вовсе.
const MAX_ICON_LOADS_PER_FRAME: usize = 3;
let mut icon_loads = 0usize;
// 3D-иконки: грузим видимые модели по убыванию площади на экране,
// не больше 8 за кадр и не дольше ~8 мс. Так очередь доходит до
// всех иконок, а не только до первых трёх, и не даёт рывка.
const MAX_ICON_LOADS_PER_FRAME: usize = 8;
const ICON_LOAD_BUDGET_US: u128 = 8_000;
let icons_started = Instant::now();
let mut candidates: Vec<(f32, &str)> = Vec::new();
for command in &ui_commands {
if command.kind != glua::UiKind::Image {
continue;
}
if icon_loads >= MAX_ICON_LOADS_PER_FRAME {
break;
}
let Some(model) = command.model.as_deref() else {
continue;
};
let visible = command.x < width as f32
&& command.y < height as f32
&& command.x + command.w > 0.0
&& command.y + command.h > 0.0;
if !visible || renderer.has_model_icon(model) {
if renderer.has_model_icon(model) {
continue;
}
let mut x0 = command.x.max(0.0);
let mut y0 = command.y.max(0.0);
let mut x1 = (command.x + command.w).min(width as f32);
let mut y1 = (command.y + command.h).min(height as f32);
if let Some([cx0, cy0, cx1, cy1]) = command.clip {
x0 = x0.max(cx0);
y0 = y0.max(cy0);
x1 = x1.min(cx1);
y1 = y1.min(cy1);
}
let area = (x1 - x0).max(0.0) * (y1 - y0).max(0.0);
if area > 0.0 && !candidates.iter().any(|(_, path)| *path == model) {
candidates.push((area, model));
}
}
candidates.sort_by(|a, b| b.0.total_cmp(&a.0));
let mut icon_loads = 0usize;
for (_, model) in candidates {
if icon_loads >= MAX_ICON_LOADS_PER_FRAME {
break;
}
if icon_loads > 0 && icons_started.elapsed().as_micros() >= ICON_LOAD_BUDGET_US {
break;
}
icon_loads += 1;
if !renderer.ensure_model_icon(&gfx, &vfs, model) {
debug!(model, "Lua UI: иконка недоступна");
}
}
let icons_elapsed = icons_started.elapsed().as_micros();
profile_icons_us += icons_elapsed;
profile_icon_loads += icon_loads;
let draw_started = Instant::now();
let stats = ui_overlay::draw(&mut renderer, &ui_commands, width as f32, height as f32);
let draw_elapsed = draw_started.elapsed().as_micros();
profile_draw_us += draw_elapsed;
profile_draw_max_us = profile_draw_max_us.max(draw_elapsed);
profile_frames += 1;
if ui_profile && profile_frames >= 120 {
let frames = profile_frames.max(1) as u128;
info!(
frames = profile_frames,
snapshot_avg_ms = profile_snapshot_us as f32 / frames as f32 / 1000.0,
snapshot_max_ms = profile_snapshot_max_us as f32 / 1000.0,
icons_avg_ms = profile_icons_us as f32 / frames as f32 / 1000.0,
icons_loaded = profile_icon_loads,
draw_avg_ms = profile_draw_us as f32 / frames as f32 / 1000.0,
draw_max_ms = profile_draw_max_us as f32 / 1000.0,
commands = ui_commands.len(),
glyphs = stats.glyphs,
placeholders = stats.placeholders,
"Lua UI: профиль"
);
profile_snapshot_us = 0;
profile_icons_us = 0;
profile_draw_us = 0;
profile_snapshot_max_us = 0;
profile_draw_max_us = 0;
profile_frames = 0;
profile_icon_loads = 0;
}
if frames_drawn % 120 == 0 {
debug!(
commands = ui_commands.len(),

View file

@ -149,28 +149,61 @@ pub fn draw(
stats.skipped += 1;
continue;
}
// Заглушка не должна вылезать за свою иконку: иначе имена
// соседних моделей сливаются в одну строку поверх сетки.
let icon_clip = [
command.x,
command.y,
command.x + command.w,
command.y + command.h,
];
let clip = Some(match command.clip {
Some([cx0, cy0, cx1, cy1]) => [
icon_clip[0].max(cx0),
icon_clip[1].max(cy0),
icon_clip[2].min(cx1),
icon_clip[3].min(cy1),
],
None => icon_clip,
});
renderer.push_rect_clipped(
command.x,
command.y,
command.w,
command.h,
linear_color([0.93, 0.93, 0.93, 1.0]),
command.clip,
clip,
);
let name = model
.rsplit(['/', '\\'])
.next()
.unwrap_or(model)
.trim_end_matches(".mdl");
let name: String = name.chars().take(24).collect();
renderer.push_text_clipped(
command.x + 3.0,
command.y + (command.h - 10.0).max(0.0),
1.0,
linear_color([0.12, 0.12, 0.12, 1.0]),
name,
command.clip,
);
let scale = 0.8f32;
let glyph = 8.0 * scale;
let max_chars = ((command.w / glyph).floor() as usize).max(1);
let mut chars = name.chars();
let first: String = chars.by_ref().take(max_chars).collect();
let second: String = chars.take(max_chars).collect();
let lines: Vec<&str> = if second.is_empty() {
vec![first.as_str()]
} else {
vec![first.as_str(), second.as_str()]
};
let line_height = glyph + 2.0;
let total_height = lines.len() as f32 * line_height - 2.0;
let top = command.y + (command.h - total_height).max(0.0) * 0.5;
for (index, line) in lines.iter().enumerate() {
let line_width = line.chars().count() as f32 * glyph;
renderer.push_text_clipped(
command.x + (command.w - line_width).max(0.0) * 0.5,
top + index as f32 * line_height,
scale,
linear_color([0.12, 0.12, 0.12, 1.0]),
*line,
clip,
);
}
stats.placeholders += 1;
stats.glyphs += cost;
}

View file

@ -92,6 +92,7 @@ const VGUI_FILES: &[&str] = &[
"dlabeleditable.lua",
"dslider.lua",
"dnumberwang.lua",
"dnumberscratch.lua",
"dnumslider.lua",
"dcolorbutton.lua",
"dcolormixer.lua",

View file

@ -79,6 +79,15 @@ pub(crate) fn install(lua: &Lua) -> Result<(), mlua::Error> {
ui.set("pressed", Value::Nil)?;
ui.set("root", Value::Nil)?;
ui.set("paint_errors", 0)?;
// Грязный флаг: снимок пересобирается только после реального изменения
// состояния (ввод, таймер, сеттеры панелей), а не по таймеру кадров.
ui.set("dirty", true)?;
ui.set("layout_dirty", true)?;
ui.set("animating", false)?;
ui.set("animated_frame", false)?;
ui.set("hovered", Value::Nil)?;
ui.set("capture", Value::Nil)?;
ui.set("hover_chain", Value::Nil)?;
lua.globals().set("__sr_ui", &ui)?;
// Lua-половина слоя: базовые панели, `surface`/`draw`, поиск под курсором.
@ -117,6 +126,30 @@ impl crate::LuaRuntime {
.unwrap_or(false)
}
/// Нужен ли новый снимок панелей: Lua выставляет флаг при вводе,
/// срабатывании таймера и изменении свойств панели.
pub fn ui_dirty(&self) -> bool {
catch_unwind(AssertUnwindSafe(|| {
let ui: Table = self.lua.globals().get("__sr_ui")?;
ui.get::<bool>("dirty")
}))
.ok()
.and_then(Result::ok)
.unwrap_or(true)
}
/// Прокачивает таймеры Lua вне снимка: тогда их изменения поднимают
/// dirty-флаг заранее, а не сваливаются в один дорогой снимок по
/// страховочному таймеру.
pub fn ui_tick(&self) {
let _ = catch_unwind(AssertUnwindSafe(|| -> Result<(), LuaError> {
let ui: Table = self.lua.globals().get("__sr_ui")?;
let pump: Function = ui.get("pump")?;
pump.call::<()>(())?;
Ok(())
}));
}
/// Забирает ошибки Paint/PerformLayout/ввода, накопленные Lua-слоем.
///
/// Ошибка одной панели не должна ронять меню и кадр: клиент показывает
@ -296,6 +329,8 @@ impl crate::LuaRuntime {
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)?;
ui.set("dirty", true)?;
ui.set("layout_dirty", true)?;
// Корневое меню в GMod занимает весь экран.
if let Ok(Value::Table(root)) = ui.get::<Value>("root")
&& let Ok(resize) = root.get::<Function>("__sr_resize_to_screen")

View file

@ -40,6 +40,18 @@ function ui.take_errors()
return list
end
-- Грязный флаг снимка. `layout = false` — изменилась только картинка
-- (hover), геометрию пересчитывать не нужно. Сеттеры панелей и ввод зовут
-- это сами, поэтому Rust пересобирает снимок только по факту изменения.
function ui.mark_dirty(layout)
ui.dirty = true
if layout ~= false then ui.layout_dirty = true end
end
function ui.is_dirty()
return ui.dirty == true
end
-- Константы GMod. Значения совпадают с движком и нужны скриптам как данные.
NODOCK = 0
FILL = 1
@ -196,6 +208,20 @@ local function font_size(name)
return number(fonts[name], 13)
end
-- Масштаб bitmap-глифа. Должен совпадать с `ui_overlay::text_scale` в
-- клиенте: иначе Lua центрует строку по одной ширине, а рендер рисует
-- другую, и текст «пляшет» относительно кнопок и иконок.
local function glyph_scale(size)
return math.Clamp(number(size, 13) / 12, 0.8, 3.0)
end
local function glyph_height(size)
return 8 * glyph_scale(size)
end
ui.glyph_scale = glyph_scale
ui.glyph_height = glyph_height
local function emit(kind, x, y, width, height, color, text, model)
if not in_snapshot or ui.commands == nil then return end
local commands = ui.commands
@ -204,16 +230,26 @@ local function emit(kind, x, y, width, height, color, text, model)
if color ~= nil then
r, g, b, a = color_parts(color, 255)
end
local px = number(x, 0) + draw_origin_x
local py = number(y, 0) + draw_origin_y
local pw = math.max(number(width, 0), 0)
local ph = math.max(number(height, 0), 0)
local clip = nil
if current_clip ~= nil and clip_disabled <= 0 then
-- Полностью обрезанную команду не отправляем в Rust: иначе длинные
-- подписи дерева и иконки за краем канваса съедают лимит команд.
if px + pw <= current_clip[1] or py + ph <= current_clip[2]
or px >= current_clip[3] or py >= current_clip[4] then
return
end
clip = { current_clip[1], current_clip[2], current_clip[3], current_clip[4] }
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),
x = px,
y = py,
w = pw,
h = ph,
color = { r / 255, g / 255, b / 255, a / 255 },
text = text,
model = model,
@ -244,20 +280,12 @@ end
-- Приблизительная метрика: рендер-агент не обязан совпадать пиксель в
-- пиксель, но раскладка и центрирование должны быть правдоподобными.
-- Ширина — ровно столько, сколько займёт bitmap-строка в рендере: один
-- байт = один глиф 8×8 с тем же масштабом, что и в `ui_overlay`.
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
local width = #text * 8 * glyph_scale(size)
return width, size
end
@ -492,11 +520,14 @@ function draw.SimpleText(value, font, x, y, color, align_x, align_y)
elseif align_x == TEXT_ALIGN_RIGHT then
px = px - w
end
local rendered_h = glyph_height(h)
if align_y == TEXT_ALIGN_CENTER then
py = py - h * 0.5
py = py - rendered_h * 0.5
elseif align_y == TEXT_ALIGN_BOTTOM then
py = py - h
py = py - rendered_h
end
-- Высота команды — размер шрифта: по ней `ui_overlay` выбирает масштаб
-- глифа. Верх строки уже сдвинут по фактической высоте глифа.
emit("text", px, py, w, h, color, text_of(value))
end
function draw.DrawText(value, font, x, y, color, align_x)
@ -577,6 +608,10 @@ 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.SetCursorPos(x, y)
ui.mouse_x = number(x, ui.mouse_x)
ui.mouse_y = number(y, ui.mouse_y)
end
function input.LookupBinding() return nil end
function input.GetKeyName(code) return tostring(code) end
function input.IsKeyTrapping() return false end
@ -1259,6 +1294,7 @@ function ui.pump()
if now >= entry.at then due[#due + 1] = name end
end
table.sort(due)
if #due > 0 then ui.mark_dirty(true) end
for _, name in ipairs(due) do
local entry = lua_timers[name]
if entry ~= nil then
@ -1405,6 +1441,7 @@ function panel_meta:SetParent(parent)
local ok, err = pcall(parent.OnChildAdded, parent, self)
if not ok then ui.log_error("OnChildAdded: " .. tostring(err)) end
end
ui.mark_dirty(true)
end
function panel_meta:GetChildren()
local out = {}
@ -1414,20 +1451,22 @@ 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
local top = 0
for _, child in ipairs(parent.__children or {}) do
if child ~= self then top = math.max(top, number(child.__zpos, 0)) end
end
list[#list + 1] = self
self.__zpos = top + 1
ui.mark_dirty(true)
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
local bottom = 0
for _, child in ipairs(parent.__children or {}) do
if child ~= self then bottom = math.min(bottom, number(child.__zpos, 0)) end
end
table.insert(list, 1, self)
self.__zpos = bottom - 1
ui.mark_dirty(true)
end
function panel_meta:InsertAtTop(child)
if child == nil then return end
@ -1506,12 +1545,14 @@ 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)
ui.mark_dirty(true)
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:SetX(x) self.x = number(x, self.x) ui.mark_dirty(true) end
function panel_meta:SetY(y) self.y = number(y, self.y) ui.mark_dirty(true) 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)
ui.mark_dirty(true)
end
function panel_meta:SetWide(w) self:SetSize(w, self.h) end
function panel_meta:SetTall(h) self:SetSize(self.w, h) end
@ -1548,6 +1589,9 @@ end
function panel_meta:SetVisible(visible)
self.__visible = visible and true or false
-- Видимость не меняет геометрию: раскладку не заказываем, иначе первое
-- открытие меню снова гоняет DTileLayout по тысячам иконок.
ui.mark_dirty(false)
end
function panel_meta:IsVisible()
return self.__visible ~= false and not self.__removed
@ -1562,9 +1606,9 @@ function panel_meta:IsVisibleChain()
end
return true
end
function panel_meta:SetAlpha(alpha) self.__alpha = math.Clamp(number(alpha, 255), 0, 255) end
function panel_meta:SetAlpha(alpha) self.__alpha = math.Clamp(number(alpha, 255), 0, 255) ui.mark_dirty(false) end
function panel_meta:GetAlpha() return self.__alpha end
function panel_meta:SetZPos(z) self.__zpos = number(z, 0) end
function panel_meta:SetZPos(z) self.__zpos = number(z, 0) ui.mark_dirty(true) 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
@ -1587,12 +1631,14 @@ function panel_meta:SetExpensiveShadow() end
function panel_meta:Dock(mode)
self.__dock = number(mode, NODOCK)
ui.mark_dirty(true)
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),
}
ui.mark_dirty(true)
end
function panel_meta:GetDockMargin()
local m = self.__dock_margin
@ -1602,6 +1648,7 @@ function panel_meta:DockPadding(top, right, bottom, left)
self.__dock_padding = {
number(top, 0), number(right, 0), number(bottom, 0), number(left, 0),
}
ui.mark_dirty(true)
end
function panel_meta:GetDockPadding()
local p = self.__dock_padding
@ -1638,10 +1685,13 @@ end
function panel_meta:LoadCookies() end
function panel_meta:SaveCookies() end
function panel_meta:InvalidateLayout() end
function panel_meta:InvalidateLayout()
ui.mark_dirty(true)
end
function panel_meta:InvalidateParent()
local parent = self.__parent
if parent ~= nil then parent.__layout_dirty = true end
ui.mark_dirty(true)
end
function panel_meta:InvalidateChildren() end
function panel_meta:PerformLayout() end
@ -1659,8 +1709,17 @@ 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:MouseCapture(value)
self.__mouse_capture = value and true or false
if self.__mouse_capture then
ui.capture = self
elseif ui.capture == self then
ui.capture = nil
end
end
function panel_meta:IsMouseCaptured() return self.__mouse_capture == true end
function panel_meta:NoClipping(value) self.__no_clipping = value and true or false end
function panel_meta:GetNoClipping() return self.__no_clipping == true end
function panel_meta:DragMousePress() end
function panel_meta:DragMouseRelease() return false end
function panel_meta:IsDraggable() return self.__draggable == true end
@ -1817,10 +1876,14 @@ 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:SetText(value) self.__text = text_of(value) or "" ui.mark_dirty(true) end
function panel_meta:GetText() return self.__text or "" end
function panel_meta:GetCaretPos() return number(self.__caret, 0) end
function panel_meta:SetCaretPos(value) self.__caret = math.max(number(value, 0), 0) ui.mark_dirty(false) end
function panel_meta:SetValue(value) self.__value = value ui.mark_dirty(true) end
function panel_meta:GetValue() return self.__value 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:SetFontInternal(value) self.__font = value or self.__font ui.mark_dirty(true) 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
@ -1829,27 +1892,28 @@ function panel_meta:SetFGColor(r, g, b, a)
else
self.__fg = { number(r, 255), number(g, 255), number(b, 255), number(a, 255) }
end
ui.mark_dirty(false)
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:SetTextColor(color) self.__text_color = to_color_table(color) ui.mark_dirty(false) 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:SetTextStyleColor(color) self.__text_style_color = to_color_table(color) ui.mark_dirty(false) 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:SetContentAlignment(value) self.__content_alignment = number(value, 0) ui.mark_dirty(false) 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:SetTextInset(x, y) self.__text_inset = { number(x, 0), number(y, 0) } ui.mark_dirty(true) end
function panel_meta:GetTextInset()
return self.__text_inset[1], self.__text_inset[2]
end
@ -1880,9 +1944,10 @@ function panel_meta:DrawTextEntryText(text_color)
surface.SetTextColor(text_color or color_black)
local _, text_height = surface.GetTextSize(value)
local inset_x, inset_y = self:GetTextInset()
local rendered_h = ui.glyph_height(text_height)
surface.SetTextPos(
inset_x,
inset_y + math.max((self:GetTall() - text_height) * 0.5, 0)
inset_y + math.max((self:GetTall() - rendered_h) * 0.5, 0)
)
surface.DrawText(value)
end
@ -1970,6 +2035,8 @@ function IsValid(value)
end
-- Рисование текста Label/DLabel. В GMod это делает движок поверх Lua-Paint.
-- `ContentAlignment` — «цифровая клавиатура» GMod: 1..3 низ, 4..6 середина,
-- 7..9 верх; внутри ряда 1/4/7 — лево, 2/5/8 — центр, 3/6/9 — право.
function ui.draw_label_text(panel, width, height)
local value = panel:GetText()
if value == nil or value == "" then return end
@ -1979,19 +2046,30 @@ function ui.draw_label_text(panel, width, height)
local text_width, text_height = surface.GetTextSize(value)
local inset_x, inset_y = panel:GetTextInset()
local align = panel:GetContentAlignment()
-- Вкладки DPropertySheet в оригинале позиционируют текст по левому краю,
-- но в меню Sandbox он должен стоять по центру вкладки.
if panel.ClassName == "DTab" then align = 5 end
align = number(align, 0)
local horizontal = 0
local vertical = 2
if align > 0 then
horizontal = (align - 1) % 3
vertical = math.floor((align - 1) / 3)
end
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
if horizontal == 1 then
x = inset_x + math.max((width - inset_x * 2 - text_width) * 0.5, 0)
elseif horizontal == 2 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
local rendered_h = ui.glyph_height(text_height)
local y
if vertical == 1 then
y = math.max((height - rendered_h) * 0.5, 0)
elseif vertical == 0 then
y = math.max(height - rendered_h - inset_y, 0)
else
y = inset_y
end
@ -2017,8 +2095,19 @@ local function absolute_pos(panel)
end
local function sort_children(panel)
local list = panel.__children or {}
-- У большинства панелей ZPos нулевой: не сортируем и не копируем
-- тысячи иконок сетки на каждый снимок, если порядок и так стабилен.
local has_z = false
for index = 1, #list do
if number(list[index].__zpos, 0) ~= 0 then
has_z = true
break
end
end
if not has_z then return list end
local children = {}
for index, child in ipairs(panel.__children or {}) do
for index, child in ipairs(list) do
children[index] = { child = child, index = index }
end
table.sort(children, function(a, b)
@ -2038,7 +2127,7 @@ local function arrange_children(panel)
local top = padding_top
local right = panel.w - padding_right
local bottom = panel.h - padding_bottom
for _, child in ipairs(sort_children(panel)) do
for _, child in ipairs(panel.__children or {}) do
if child.__dock ~= NODOCK then
local margin_top, margin_right, margin_bottom, margin_left = child:GetDockMargin()
local dock = child.__dock
@ -2073,6 +2162,7 @@ local function arrange_children(panel)
end
end
end
ui.arrange_children = arrange_children
function ui.layout_tree(panel, depth, seen)
depth = depth or 0
@ -2088,6 +2178,9 @@ function ui.layout_tree(panel, depth, seen)
if canvas ~= nil and canvas.__parent ~= panel then
canvas:SetParent(panel)
end
-- Dock-раскладка должна быть применена до PerformLayout: DScrollPanel
-- вызывает VBar:SetUp с высотой скроллбара, а её выставляет Dock(RIGHT).
arrange_children(panel)
if panel.PerformLayout ~= nil then
local ok, err = pcall(panel.PerformLayout, panel, panel.w, panel.h)
if not ok then
@ -2100,6 +2193,20 @@ function ui.layout_tree(panel, depth, seen)
end
end
-- Раскладка только когда она действительно устарела. Снимок после движения
-- мыши перерисовывает панели, но не гоняет DTileLayout по тысячам иконок.
function ui.ensure_layout(root)
if root == nil then return end
if root.w <= 0 or root.h <= 0 then
root:SetPos(0, 0)
root:SetSize(ScrW(), ScrH())
ui.layout_dirty = true
end
if not ui.layout_dirty then return end
ui.layout_tree(root, 0, {})
ui.layout_dirty = false
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
@ -2112,15 +2219,27 @@ function ui.resolve_root()
return nil
end
local function intersects_clip(x, y, w, h)
local clip = current_clip
if clip == nil then return true end
return x < clip[3] and y < clip[4] and x + w > clip[1] and y + h > clip[2]
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)
local w, h = number(panel.w, 0), number(panel.h, 0)
-- Панель целиком за границей родителя: её Paint и всё поддерево не
-- могут попасть в кадр. Раньше снимок честно рисовал тысячи иконок за
-- краем канваса и упирался в лимит команд.
if w > 0 and h > 0 and not intersects_clip(x, y, w, h) then
return count
end
-- Дети обрезаются по границам панели, как в GMod. Панели без размера
-- (служебные контейнеры) не сужают область: иначе их содержимое исчезло бы.
local previous_clip = current_clip
local w, h = number(panel.w, 0), number(panel.h, 0)
if w > 0 and h > 0 then
local x0 = previous_clip ~= nil and math.max(previous_clip[1], x) or x
local y0 = previous_clip ~= nil and math.max(previous_clip[2], y) or y
@ -2162,6 +2281,42 @@ local function think_panel(panel, depth, seen)
ui.log_error("Think " .. tostring(panel.ClassName or panel.__engine) .. ": " .. tostring(err))
end
end
-- Анимации панели (MoveTo/SizeTo/DTree) двигают геометрию между кадрами.
-- Пока они активны, снимок обязан пересобираться каждый кадр.
if panel.AnimationThink ~= nil then
local ok, err = pcall(panel.AnimationThink, panel)
if not ok then
ui.log_error("AnimationThink " .. tostring(panel.ClassName or panel.__engine) .. ": " .. tostring(err))
end
if (panel.m_AnimList ~= nil and next(panel.m_AnimList) ~= nil) or panel.Term ~= nil then
ui.animating = true
end
end
-- Derma_Anim (DTree_Node.animSlide, DPropertySheet.animFade) сам не
-- тикает: в GMod его зовёт Think владельца. Без этого анимация ветки
-- дерева навсегда оставалась активной и PerformLayout не раскладывал
-- подписи (ширина Label = 0).
local function run_derma_anim(anim)
if type(anim) ~= "table" or type(anim.Active) ~= "function" then return end
-- Анимация завершилась в прошлом кадре: её Func уже выставил новые
-- размеры, но раскладка была до Think. Просим пересчитать геометрию.
if anim.Finished then
anim.Finished = nil
ui.animated_frame = true
ui.layout_dirty = true
return
end
if not anim:Active() then return end
local ok, err = pcall(anim.Run, anim)
if not ok then
ui.log_error("Derma_Anim: " .. tostring(err))
end
ui.animated_frame = true
ui.layout_dirty = true
if anim:Active() then ui.animating = true end
end
run_derma_anim(panel.animSlide)
run_derma_anim(panel.animFade)
for _, child in ipairs(sort_children(panel)) do
think_panel(child, depth + 1, seen)
end
@ -2172,14 +2327,19 @@ function ui.snapshot()
ui.pump()
local root = ui.resolve_root()
if not ui.visible or root == nil or not root:IsVisible() then
ui.dirty = false
ui.animating = false
ui.animated_frame = false
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, {})
ui.animating = false
ui.animated_frame = false
ui.ensure_layout(root)
think_panel(root, 0, {})
-- Think/анимации могли изменить размеры (раскрытие ветки дерева,
-- переключение вкладки): раскладываем ещё раз до Paint, иначе подпись
-- останется с шириной 0 на один кадр.
if ui.layout_dirty then ui.ensure_layout(root) end
ui.commands = out
in_snapshot = true
draw_origin_x, draw_origin_y = 0, 0
@ -2189,12 +2349,20 @@ function ui.snapshot()
current_clip = nil
in_snapshot = false
ui.commands = nil
-- Сеттеры, которые зовёт раскладка/рисование, не должны сами по себе
-- заказывать следующий снимок. Грязным остаётся только кадр, в котором
-- реально идёт анимация.
ui.dirty = ui.animating
if not ui.animated_frame then
ui.layout_dirty = false
end
return out
end
function ui.toggle(open)
open = open and true or false
ui.visible = open
ui.mark_dirty(false)
local root = ui.resolve_root()
if root == nil then return end
if open then
@ -2222,7 +2390,7 @@ local function point_inside(panel, px, py)
end
local function find_target(panel, px, py, depth)
if depth > ui.depth_limit or not panel:IsVisible() then return nil end
if depth > ui.depth_limit or not panel:IsVisibleChain() then return nil end
local children = sort_children(panel)
for index = #children, 1, -1 do
local child = children[index]
@ -2237,54 +2405,104 @@ local function find_target(panel, px, py, depth)
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)
local function clear_hover_chain()
local chain = ui.hover_chain
if chain ~= nil then
for _, panel in ipairs(chain) do
panel.__hovered = false
panel.Hovered = false
end
end
ui.hover_chain = nil
end
local function set_hovered(target)
if ui.hovered == target then return false end
clear_hover_chain()
local chain = {}
local current = target
local depth = 0
while current ~= nil and depth < 64 do
current.__hovered = true
current.Hovered = true
chain[#chain + 1] = current
current = current.__parent
depth = depth + 1
end
ui.hover_chain = chain
ui.hovered = target
-- OnCursorEntered/Exited в DLabel зовут InvalidateLayout: на каждом
-- переходе мыши это заставляло заново раскладывать всё дерево. Скины
-- и так читают `Hovered`, поэтому достаточно dirty-флага отрисовки.
ui.mark_dirty(false)
return true
end
local function wheel_target(target, delta)
local current = target
local depth = 0
while current ~= nil and depth < 64 do
local handler = current.OnMouseWheeled
if handler ~= nil then
local ok, handled = pcall(handler, current, delta)
if not ok then
ui.log_error("OnMouseWheeled: " .. tostring(handled))
elseif handled then
return true
end
end
handler = current.OnMouseWheel
if handler ~= nil then
local ok, handled = pcall(handler, current, delta)
if not ok then
ui.log_error("OnMouseWheel: " .. tostring(handled))
elseif handled then
return true
end
end
current = current.__parent
depth = depth + 1
end
return false
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.ensure_layout(root)
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)
set_hovered(target)
local receiver = ui.capture
if receiver ~= nil and (receiver.__removed or not receiver:IsVisible()) then
ui.capture = nil
receiver = nil
end
if receiver == nil then receiver = target end
if receiver ~= nil and receiver.OnCursorMoved ~= nil then
-- GMod передаёт OnCursorMoved координаты относительно панели, а
-- не экрана: DSlider внутри делает LocalToScreen/ScreenToLocal.
local local_x, local_y = receiver:ScreenToLocal(ui.mouse_x, ui.mouse_y)
local ok, err = pcall(receiver.OnCursorMoved, receiver, local_x, local_y)
if not ok then ui.log_error("OnCursorMoved: " .. tostring(err)) end
end
elseif kind == "down" then
set_hovered(target)
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
if target ~= nil and target.OnMousePressed ~= nil then
local ok, err = pcall(target.OnMousePressed, target, button)
if not ok then ui.log_error("OnMousePressed: " .. tostring(err)) end
end
ui.mark_dirty(false)
elseif kind == "up" then
set_hovered(target)
local pressed = ui.pressed
ui.pressed = nil
if pressed ~= nil and not pressed.__removed then
@ -2300,19 +2518,17 @@ function ui.mouse(kind, x, y, button)
local ok, err = pcall(target.OnMouseReleased, target, button)
if not ok then ui.log_error("OnMouseReleased: " .. tostring(err)) end
end
ui.capture = nil
ui.mark_dirty(false)
elseif kind == "wheel" then
set_hovered(target)
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
wheel_target(target, delta)
ui.mark_dirty(true)
end
end
@ -3140,6 +3356,163 @@ function __sr_ui_install_fallbacks()
register("ModelImage", ModelImage, "DPanel")
register("DModelPanel", ModelImage, "ModelImage")
-- DSlider / DNumSlider: минимальная рабочая реализация на случай, если
-- настоящий dslider.lua не загрузился. Полоса, ползунок и перетаскивание.
local SliderFallback = {}
function SliderFallback:Init()
self:SetPaintBackground(false)
self:SetMouseInputEnabled(true)
self.__value = 0.5
self.__notches = nil
self.__notch_color = Color(160, 160, 160, 180)
end
function SliderFallback:SetValue(value) self:SetSlideX(value) end
function SliderFallback:GetValue() return self.__value end
function SliderFallback:SetSlideX(value)
local next_value = math.Clamp(number(value, 0.5), 0, 1)
if next_value == self.__value then return end
self.__value = next_value
self:OnValueChanged(self.__value)
self:InvalidateLayout()
end
function SliderFallback:GetSlideX() return self.__value end
function SliderFallback:SetSlideY() end
function SliderFallback:GetSlideY() return 0.5 end
function SliderFallback:SetNotches(value) self.__notches = value end
function SliderFallback:GetNotches() return self.__notches end
function SliderFallback:SetNotchColor(color) self.__notch_color = color end
function SliderFallback:GetNotchColor() return self.__notch_color end
function SliderFallback:IsEditing() return self.Dragging == true end
function SliderFallback:OnValueChanged() end
function SliderFallback:TranslateValues(x, y) return x, y end
function SliderFallback:ResetToDefaultValue() self:SetSlideX(0.5) end
function SliderFallback:UpdateFromCursor()
local w = math.max(self:GetWide(), 1)
local x = select(1, self:CursorPos())
self:SetSlideX(x / w)
end
function SliderFallback:OnMousePressed(code)
if code ~= MOUSE_LEFT then return end
self.Dragging = true
self:MouseCapture(true)
self:UpdateFromCursor()
end
function SliderFallback:OnCursorMoved()
if self.Dragging then self:UpdateFromCursor() end
end
function SliderFallback:OnMouseReleased()
self.Dragging = false
self:MouseCapture(false)
end
function SliderFallback:Paint(w, h)
local y = math.max(h * 0.5 - 2, 0)
surface.SetDrawColor(205, 205, 205, 255)
surface.DrawRect(0, y, w, 4)
surface.SetDrawColor(100, 170, 220, 255)
surface.DrawRect(0, y, w * self.__value, 4)
local knob = 12
surface.SetDrawColor(245, 245, 245, 255)
surface.DrawRect(math.Clamp(w * self.__value - knob * 0.5, 0, math.max(w - knob, 0)), math.max((h - knob) * 0.5, 0), knob, knob)
surface.SetDrawColor(120, 120, 120, 255)
surface.DrawOutlinedRect(math.Clamp(w * self.__value - knob * 0.5, 0, math.max(w - knob, 0)), math.max((h - knob) * 0.5, 0), knob, knob)
end
register("DSlider", SliderFallback, "Panel")
local NumSliderFallback = {}
function NumSliderFallback:Init()
self.__min = 0
self.__max = 1
self.__decimals = 2
self.__value = 0
self.Slider = vgui.Create("DSlider", self)
self.Slider:Dock(FILL)
self.Label = vgui.Create("DLabel", self)
self.Label:Dock(RIGHT)
self.Label:SetWide(52)
self.Label:SetContentAlignment(5)
self.Slider.OnValueChanged = function(slider, fraction)
if self.__updating then return end
self:SetValue(self.__min + fraction * (self.__max - self.__min))
end
self:SetTall(24)
end
function NumSliderFallback:SetMin(value) self.__min = number(value, 0) self:SetValue(self.__value) end
function NumSliderFallback:GetMin() return self.__min end
function NumSliderFallback:SetMax(value) self.__max = number(value, 1) self:SetValue(self.__value) end
function NumSliderFallback:GetMax() return self.__max end
function NumSliderFallback:GetRange() return self.__max - self.__min end
function NumSliderFallback:SetMinMax(min, max) self.__min = number(min, 0) self.__max = number(max, 1) self:SetValue(self.__value) end
function NumSliderFallback:SetDecimals(value) self.__decimals = math.max(math.floor(number(value, 2)), 0) self:SetValue(self.__value) end
function NumSliderFallback:GetDecimals() return self.__decimals end
function NumSliderFallback:SetText(value) self.__text = text_of(value) or "" self:SetValue(self.__value) end
function NumSliderFallback:GetText() return self.__text or "" end
function NumSliderFallback:SetValue(value)
local next_value = math.Clamp(number(value, self.__min), self.__min, self.__max)
self.__value = next_value
local range = self:GetRange()
local fraction = range ~= 0 and (next_value - self.__min) / range or 0
self.__updating = true
if self.Slider ~= nil then self.Slider:SetSlideX(fraction) end
self.__updating = false
local pattern = "%." .. tostring(self.__decimals) .. "f"
if self.Label ~= nil then
local text = string.format(pattern, next_value)
if (self.__text or "") ~= "" then text = self.__text .. " " .. text end
self.Label:SetText(text)
end
self:OnValueChanged(next_value)
end
function NumSliderFallback:GetValue() return self.__value end
function NumSliderFallback:SetConVar() end
function NumSliderFallback:GetConVar() return nil end
function NumSliderFallback:IsEditing() return self.Slider ~= nil and self.Slider:IsEditing() end
function NumSliderFallback:PerformLayout()
if self.Label ~= nil then self.Label:SetWide(52) end
end
function NumSliderFallback:OnValueChanged() end
register("DNumSlider", NumSliderFallback, "Panel")
register("Slider", NumSliderFallback, "Panel")
-- DNumberScratch: только данные и текст, без интерактивного «скретча».
local NumberScratchFallback = {}
function NumberScratchFallback:Init()
self.__min = 0
self.__max = 1
self.__decimals = 2
self.__value = 0
self:SetMouseInputEnabled(false)
self:SetPaintBackground(false)
end
function NumberScratchFallback:SetMin(value) self.__min = number(value, 0) end
function NumberScratchFallback:GetMin() return self.__min end
function NumberScratchFallback:SetMax(value) self.__max = number(value, 1) end
function NumberScratchFallback:GetMax() return self.__max end
function NumberScratchFallback:GetRange() return self.__max - self.__min end
function NumberScratchFallback:SetDecimals(value) self.__decimals = math.max(math.floor(number(value, 2)), 0) end
function NumberScratchFallback:GetDecimals() return self.__decimals end
function NumberScratchFallback:SetValue(value)
local next_value = math.Clamp(number(value, 0), self.__min, self.__max)
if next_value == self.__value then return end
self.__value = next_value
self:OnValueChanged(next_value)
end
function NumberScratchFallback:GetValue() return self.__value end
function NumberScratchFallback:GetFloatValue() return self.__value end
function NumberScratchFallback:SetFloatValue(value) self.__value = number(value, 0) end
function NumberScratchFallback:GetFraction()
local range = self:GetRange()
return range ~= 0 and (self.__value - self.__min) / range or 0
end
function NumberScratchFallback:SetFraction(fraction) self:SetValue(self.__min + number(fraction, 0) * self:GetRange()) end
function NumberScratchFallback:GetTextValue()
local pattern = "%." .. tostring(self.__decimals) .. "f"
return string.format(pattern, self.__value)
end
function NumberScratchFallback:SetConVar() end
function NumberScratchFallback:OnValueChanged() end
function NumberScratchFallback:IsEditing() return false end
register("DNumberScratch", NumberScratchFallback, "DImageButton")
-- Заглушки оставшихся контролов, которые встречаются в панелях инструментов.
local function simple_panel(name, base)
local panel = {}
@ -3377,6 +3750,35 @@ function __sr_ui_finalize()
end
end
-- DTree_Node:PerformRootNodeLayout берёт высоту ChildNodes до того, как
-- DListLayout успел посчитать размер по детям. В результате корень
-- дерева оставался высотой 0, а VBar — выключенным, и колесо не листало
-- список. Считаем размер детей до родителя.
local tree_node = vgui.GetControlTable("DTree_Node")
if tree_node ~= nil and type(tree_node.PerformRootNodeLayout) == "function" then
local original_root_layout = tree_node.PerformRootNodeLayout
tree_node.PerformRootNodeLayout = function(self)
if self.ChildNodes ~= nil then
ui.arrange_children(self.ChildNodes)
self.ChildNodes:SizeToChildren(false, true)
end
local result = original_root_layout(self)
-- Родитель-канвас и скроллбар уже прошли PerformLayout до детей.
-- Обновляем их сразу, иначе высота канваса остаётся старой, VBar
-- выключен, и колесо не листает дерево до следующего изменения.
local canvas = self.__parent
if canvas ~= nil then
canvas:SizeToChildren(false, true)
local scroll = canvas.__parent
if scroll ~= nil and scroll.VBar ~= nil then
scroll.VBar:SetUp(scroll:GetTall(), canvas:GetTall())
canvas:SetPos(0, scroll.VBar:GetOffset())
end
end
return result
end
end
-- Копия настоящего CreateFromTable, но с pcall вокруг Init и без
-- зависимости от устройства scriptedpanels.
function vgui.CreateFromTable(class_table, parent, name)
@ -3414,4 +3816,5 @@ end
function ui.set_screen(width, height)
ui.screen_w = number(width, ui.screen_w)
ui.screen_h = number(height, ui.screen_h)
ui.mark_dirty(true)
end

View file

@ -89,6 +89,11 @@ fn ui_spawnmenu_snapshot_and_click() {
// Первый снимок прокачивает `timer.Simple(0, ...)`, которые создают
// вкладки и содержимое спавн-меню; второй — уже для проверки.
let _ = runtime.ui_snapshot();
// Ветки DTree раскрываются анимацией 0.3 с (как в GMod). Даём ей
// завершиться и делаем ещё два снимка: первый завершает анимацию и
// заказывает раскладку, второй уже видит подписи веток.
std::thread::sleep(std::time::Duration::from_millis(400));
let _ = runtime.ui_snapshot();
let first = runtime.ui_snapshot();
dump("первый", &first);
assert!(
@ -172,6 +177,259 @@ fn ui_snapshot_has_a_bounded_command_count() {
assert!(commands.len() > 20, "меню пустое: {}", commands.len());
}
/// Настоящие DSlider/DNumSlider/DNumberScratch из `lua/vgui` загружены и
/// работают: значение меняется от перетаскивания, текст числа виден.
#[test]
fn ui_num_slider_drags_and_updates_value() {
let Some(runtime) = loaded_ui() else {
return;
};
runtime.ui_set_screen(1280, 720);
runtime
.run(
"slider_test.lua",
br#"
rawset( _G, "g_SpawnMenu", nil )
local p = vgui.Create( "Panel" )
p:SetPos( 100, 100 )
p:SetSize( 300, 60 )
local slider = vgui.Create( "DNumSlider", p )
slider:SetPos( 10, 10 )
slider:SetSize( 220, 32 )
slider:SetMin( 0 )
slider:SetMax( 10 )
slider:SetDecimals( 1 )
slider:SetValue( 5 )
_G.__test_slider = slider
__sr_ui.root = p
__sr_ui.visible = true
__sr_ui.mark_dirty( true )
"#,
)
.expect("слайдер создан");
let commands = runtime.ui_snapshot();
assert!(
commands
.iter()
.any(|command| command.text.as_deref() == Some("5.0")),
"DNumSlider не нарисовал число: {:?}",
texts(&commands)
);
let before = runtime
.probe("return tostring(__test_slider:GetValue())")
.expect("probe")
.unwrap_or_default();
assert_eq!(before, "5", "начальное значение");
// Тянем ползунок вправо: значение должно вырасти.
let y = 126.0;
runtime.ui_mouse("move", 230.0, y, 0);
runtime.ui_mouse("down", 230.0, y, 1);
runtime.ui_mouse("move", 270.0, y, 0);
runtime.ui_mouse("up", 270.0, y, 1);
let _ = runtime.ui_snapshot();
let after: f32 = runtime
.probe("return tostring(__test_slider:GetValue())")
.expect("probe")
.unwrap_or_default()
.parse()
.unwrap_or(0.0);
assert!(after > 6.0, "перетаскивание не изменило значение: {after}");
}
/// Колесо над содержимым DScrollPanel прокручивает канвас (событие
/// всплывает от иконки/кнопки до панели со скроллбаром).
#[test]
fn ui_wheel_scrolls_scroll_panel() {
let Some(runtime) = loaded_ui() else {
return;
};
runtime.ui_set_screen(1280, 720);
runtime
.run(
"scroll_test.lua",
br#"
rawset( _G, "g_SpawnMenu", nil )
local p = vgui.Create( "DScrollPanel" )
p:SetPos( 50, 50 )
p:SetSize( 300, 200 )
local canvas = p:GetCanvas()
local item = vgui.Create( "DButton", canvas )
item:SetPos( 0, 0 )
item:SetSize( 120, 800 )
item:SetText( "long" )
_G.__test_scroll = p
__sr_ui.root = p
__sr_ui.visible = true
__sr_ui.mark_dirty( true )
"#,
)
.expect("скролл-панель создана");
let _ = runtime.ui_snapshot();
let enabled = runtime
.probe("return tostring(__test_scroll.VBar.Enabled)")
.expect("probe")
.unwrap_or_default();
assert_eq!(enabled, "true", "скроллбар не включился");
runtime.ui_mouse("wheel", 100.0, 100.0, -1);
let _ = runtime.ui_snapshot();
let offset = runtime
.probe("return tostring(__test_scroll:GetCanvas().y)")
.expect("probe")
.unwrap_or_default();
let offset: f32 = offset.parse().unwrap_or(0.0);
assert!(offset < -10.0, "колесо не прокрутило канвас: y={offset}");
}
/// Колесо над узлом DTree прокручивает дерево: событие всплывает от кнопки
/// узла до DScrollPanel, а не теряется в базовой заглушке.
#[test]
fn ui_wheel_scrolls_tree() {
let Some(runtime) = loaded_ui() else {
return;
};
runtime.ui_set_screen(1280, 720);
runtime
.run(
"tree_scroll_test.lua",
br#"
rawset( _G, "g_SpawnMenu", nil )
local tree = vgui.Create( "DTree" )
tree:SetPos( 20, 20 )
tree:SetSize( 200, 160 )
for i = 1, 40 do
tree:AddNode( "Node " .. i )
end
tree:Root():SetExpanded( true, true )
_G.__test_tree = tree
__sr_ui.root = tree
__sr_ui.visible = true
__sr_ui.mark_dirty( true )
"#,
)
.expect("дерево создано");
let _ = runtime.ui_snapshot();
let _ = runtime.ui_snapshot();
runtime.ui_mouse("wheel", 100.0, 60.0, -1);
let _ = runtime.ui_snapshot();
let offset = runtime
.probe("return tostring(__test_tree:GetCanvas().y)")
.expect("probe")
.unwrap_or_default();
let offset: f32 = offset.parse().unwrap_or(0.0);
assert!(offset < -10.0, "колесо не прокрутило дерево: y={offset}");
}
/// Клик по заголовку DCollapsibleCategory сворачивает/разворачивает
/// категорию: по этому пути открываются панели инструментов со слайдерами.
#[test]
fn ui_collapsible_category_toggles() {
let Some(runtime) = loaded_ui() else {
return;
};
runtime.ui_set_screen(1280, 720);
runtime
.run(
"category_test.lua",
br#"
rawset( _G, "g_SpawnMenu", nil )
local list = vgui.Create( "DCategoryList" )
list:SetPos( 20, 20 )
list:SetSize( 220, 300 )
local category = list:Add( "Constraints" )
category:Add( "DButton" ):SetText( "Slider" )
_G.__test_category = category
__sr_ui.root = list
__sr_ui.visible = true
__sr_ui.mark_dirty( true )
"#,
)
.expect("категория создана");
let _ = runtime.ui_snapshot();
let expanded = runtime
.probe("return tostring(__test_category:GetExpanded())")
.expect("probe")
.unwrap_or_default();
assert_eq!(expanded, "true", "категория должна быть раскрыта");
runtime.ui_mouse("move", 60.0, 28.0, 0);
runtime.ui_mouse("down", 60.0, 28.0, 1);
runtime.ui_mouse("up", 60.0, 28.0, 1);
let _ = runtime.ui_snapshot();
let expanded = runtime
.probe("return tostring(__test_category:GetExpanded())")
.expect("probe")
.unwrap_or_default();
assert_eq!(expanded, "false", "клик по заголовку не свернул категорию");
}
/// Текст с ContentAlignment 5 (центр) действительно центрируется, а не
/// уезжает по старой таблице выравниваний.
#[test]
fn ui_label_alignment_centers_text() {
let Some(runtime) = loaded_ui() else {
return;
};
runtime.ui_set_screen(1280, 720);
runtime
.run(
"align_test.lua",
br#"
rawset( _G, "g_SpawnMenu", nil )
local label = vgui.Create( "DLabel" )
label:SetPos( 0, 0 )
label:SetSize( 200, 20 )
label:SetContentAlignment( 5 )
label:SetText( "center" )
__sr_ui.root = label
__sr_ui.visible = true
__sr_ui.mark_dirty( true )
"#,
)
.expect("метка создана");
let commands = runtime.ui_snapshot();
let text = commands
.iter()
.find(|command| command.text.as_deref() == Some("center"))
.expect("текст метки");
let expected = (200.0 - text.w) * 0.5;
assert!(
(text.x - expected).abs() < 0.6,
"x={} ожидали {} (w={})",
text.x,
expected,
text.w
);
}
/// Снимок заказывается по dirty-флагу: после снимка флаг снят, сеттер
/// панели снова его поднимает.
#[test]
fn ui_snapshot_dirty_flag_round_trip() {
let Some(runtime) = loaded_ui() else {
return;
};
runtime
.run(
"dirty_test.lua",
br#"
rawset( _G, "g_SpawnMenu", nil )
local p = vgui.Create( "Panel" )
p:SetSize( 100, 100 )
__sr_ui.root = p
__sr_ui.visible = true
__sr_ui.mark_dirty( true )
"#,
)
.expect("панель создана");
assert!(runtime.ui_dirty(), "новое меню должно быть грязным");
let _ = runtime.ui_snapshot();
assert!(!runtime.ui_dirty(), "после снимка флаг должен сняться");
runtime
.run("dirty_test_set.lua", br#"__sr_ui.root:SetSize( 120, 100 )"#)
.expect("сеттер");
assert!(runtime.ui_dirty(), "SetSize должен поднять dirty-флаг");
}
/// Категории спавн-меню (settings/spawnlist_default/*.txt) реально читаются
/// Lua-скриптом через `file.Find`; иначе вкладка Spawnlists осталась бы пустой.
#[test]

View file

@ -8,6 +8,57 @@
---
## 0.11. Q-меню: отзывчивость, слайдеры, колесо и выравнивание (2026-10-24, ветка `feat/menu`)
Жалобы пользователя: лаги Q-меню на release, не работают слайдеры, колесо не
листает списки/дерево, текст «пляшет», часть иконок — заглушки.
- **Профиль до.** `SOURCE_RS_UI_PROFILE=1`: снимок Lua пересобирался раз в 6
кадров, avg 6,4/4,1 мс, max 84/47 мс; 1445 команд; отрисовка 0,17 мс;
FPS с меню 16,8–17,3 против 20,7–20,9 без меню (Xvfb, замеры шли
параллельно и мешали друг другу).
- **Причина.** Дорогой обход всего дерева и `DTileLayout` по тысячам иконок
каждые 6 кадров; `emit` создавал команды для полностью обрезанных панелей;
`sort_children` сортировал всех детей сетки; hover на каждом движении мыши
звал `OnCursorEntered` → `InvalidateLayout`; снимок не знал, изменилось ли
что-то на самом деле.
- **Снимок по dirty-флагу.** `ui.mark_dirty(layout)` зовут сеттеры, ввод и
таймеры; Rust каждый кадр делает `ui_tick()` (таймеры) и `ui_dirty()`, а
`ui_snapshot_into` — только при изменении. `layout_dirty` отделён от
отрисовки: hover не пересчитывает геометрию, колесо/перетаскивание — да.
`paint_panel` не заходит в поддеревья вне clip, `emit` отбрасывает полностью
невидимые команды, `sort_children` не сортирует панели без ZPos.
- **Профиль после.** В покое снимок avg 0,26–0,5 мс, max 18–27 мс (редкая
страховочная пересборка), 677 команд; отрисовка 0,13–0,17 мс. FPS с меню
18,9/20,5, без меню 17,6/16,9 — разница в пределах разброса. Первый кадр
после открытия — до ~95 мс (раскладка + первая порция иконок); для этого
меню прогревается на загрузке карты (`SOURCE_RS_UI_NO_PREWARM=1` отключает).
- **Слайдеры.** В `VGUI_FILES` добавлен `dnumberscratch.lua`; добавлены
`NoClipping`, `GetCaretPos/SetCaretPos`, `input.SetCursorPos`. Реальные
`DSlider`/`DNumSlider` теперь создаются и тянутся: `OnCursorMoved` получает
локальные координаты, `MouseCapture` удерживает ползунок вне панели. Тест
`ui_num_slider_drags_and_updates_value` тянет настоящий DNumSlider.
- **Колесо.** Обработчик всплывает от кнопки/иконки к родителям до
`DScrollPanel`/`DTree`, пока не вернёт `true`. Починен корень `DTree`:
`PerformRootNodeLayout` брал высоту `ChildNodes` до `DListLayout`, из-за чего
canvas был 15 px и VBar выключен; теперь размеры детей считаются раньше.
Тесты `ui_wheel_scrolls_scroll_panel` и `ui_wheel_scrolls_tree` проверяют
сдвиг канваса.
- **Выравнивание.** `surface.GetTextSize` теперь совпадает с bitmap-метрикой
рендера (8 px × масштаб на байт), вертикаль считается по высоте глифа.
`ContentAlignment` разобран как в GMod (1–3 низ, 4–6 середина, 7–9 верх;
внутри ряда лево/центр/право), вкладки центрируются, дерево — слева с
отступом, заглушка иконки — две строки по центру с обрезкой по иконке.
- **Иконки.** Очередь видимых моделей сортируется по площади на экране и
грузится до 8 штук/~8 мс за кадр; кэш LRU переиспользуется при повторном
открытии. Заглушки остаются только для реально отсутствующих в VFS моделей.
- **Проверка.** `cargo test -p glua --test ui` (9 тестов, включая перетаскивание
DNumSlider и колесо над DTree), кадры
`SOURCE_RS_QMENU=1 ... --frames 90 --screenshot`, клики
`SOURCE_RS_UI_CLICK='Entities'`, колесо `SOURCE_RS_UI_WHEEL=x,y,d`.
---
## 0.8. Q-меню Sandbox в клиенте (2026-10-24, ветка `feat/ui-render`)
Настоящее меню из `feat/derma` подключено к клиенту; самодельный `qmenu.rs`

View file

@ -47,16 +47,45 @@
всех родителей; `surface.DisableClipping(true)` временно его снимает
(тени рамок рисуются за границей, как в GMod). Обрезка выполняется на CPU
по вершинам, поэтому длинные подписи дерева не вылезают на содержимое.
- Контракт для рендера: `LuaRuntime::ui_toggle/ui_visible/ui_snapshot/ui_mouse`
(+`ui_set_screen`), типы `UiKind`/`UiCommand`. Координаты — пиксели экрана.
`paint_panel` не заходит в поддеревья за пределами clip, а `emit` не
создаёт полностью обрезанные команды: снимок большой сетки не упирается
в лимит 4096 и не тратит время на невидимые иконки.
- Снимок заказывается по dirty-флагу: сеттеры панелей, ввод и таймеры зовут
`ui.mark_dirty(layout)`. Rust каждый кадр прокачивает `ui_tick()` (таймеры),
проверяет `ui_dirty()` и пересобирает снимок только при изменении; раз в
60 кадров — страховка. `layout_dirty` отдельно от отрисовки: hover меняет
только картинку, колесо/перетаскивание — геометрию. Это убрало пересборку
Lua-дерева раз в 6 кадров (было 30–80 мс на кадр, стало ~0,3 мс в покое).
- Контракт для рендера: `LuaRuntime::ui_toggle/ui_visible/ui_snapshot/ui_mouse/
ui_dirty/ui_tick` (+`ui_set_screen`), типы `UiKind`/`UiCommand`. Координаты —
пиксели экрана.
- Скин: после `skins/default.lua` вызывается `ui.apply_light_skin()` —
светло-серые панели, тёмный текст, голубые выделения. Настоящая текстура
`gwenskin` не загружается; `surface.DrawTexturedRectUV` подбирает цвет по
UV-области (панель/кнопка/вкладка/список), поэтому меню светлое, а не
тёмное, как было при подмене крупных белых плиток тёмным фоном.
- Ввод: поиск верхней видимой панели под курсором, вызовы
`OnMousePressed/OnMouseReleased/OnCursorMoved/OnMouseWheeled`; клик по вкладке
меняет активную панель.
- Ввод: поиск верхней видимой панели под курсором (учитывается вся цепочка
видимости, а не только сам флаг панели), вызовы `OnMousePressed/
OnMouseReleased/OnCursorMoved/OnMouseWheeled`; `OnCursorMoved` получает
координаты относительно панели, как в GMod. `MouseCapture` удерживает
перетаскивание (ползунок слайдера) вне границ панели. Колесо всплывает от
кнопки/иконки к родителю до `DScrollPanel`/`DTree`, пока обработчик не
вернёт `true`; клик по вкладке меняет активную панель. Hover не зовёт
`OnCursorEntered` (его `InvalidateLayout` раскладывал всё дерево на каждом
движении мыши) — скины читают `Hovered`.
- Слайдеры: настоящие `DSlider`/`DNumSlider`/`DNumberScratch` загружаются
(`dnumberscratch.lua` добавлен в `VGUI_FILES`); добавлены недостающие
движковые методы `NoClipping`, `GetCaretPos/SetCaretPos`, `input.SetCursorPos`.
Перетаскивание меняет `SlideX` → `SetValue` → `OnValueChanged`, число
показывается в `DTextEntry`. Если файл контрола отсутствует, в fallback
есть минимальные `DSlider`/`DNumSlider`/`DNumberScratch`.
- Текст: `surface.GetTextSize` считает ширину по тем же метрикам, что
`ui_overlay::text_scale` (8 px на байт × масштаб), а вертикальное
выравнивание — по фактической высоте глифа. `ContentAlignment` разобран по
«цифровой клавиатуре» GMod (4/5/6 — лево/центр/право, 7/8/9 — верх);
вкладки `DTab` принудительно центрируются, дерево остаётся слева с
отступом. Многострочная заглушка иконки рисуется двумя строками по центру
и обрезается прямоугольником самой иконки.
- Клиент (`crates/client/src/map.rs`, `ui_overlay.rs`) рисует снимок поверх
кадра: прямоугольники — точными квадами, текст — bitmap-шрифтом (scale =
h/12), иконки — текстурированными квадами между командами снимка, цвета
@ -66,16 +95,21 @@
откатывается на встроенное `qmenu.rs`.
- 3D-иконки (`render::ModelIcons`): модель грузится `engine::load_model`,
рисуется в offscreen-текстуру 128×128 камерой «три четверти» на светлом
фоне, в UI кэш LRU на 512 путей. Клиент просит не больше 3 новых иконок за
кадр; отсутствующие/битые пути запоминаются, чтобы не грузить их повторно.
Пока текстуры нет, `ui_overlay` рисует светлую заглушку с именем файла.
фоне, в UI кэш LRU на 512 путей. Клиент каждый кадр собирает отсутствующие
видимые иконки, сортирует по площади на экране и грузит до 8 штук или ~8 мс
за кадр — очередь доходит до всей категории, а не только до первых трёх.
Отсутствующие/битые пути запоминаются, чтобы не грузить их повторно. Пока
текстуры нет, `ui_overlay` рисует светлую заглушку: имя файла в две строки
по центру, обрезанное прямоугольником иконки. При повторном открытии меню
кэш переиспользуется.
- Спавн из меню: `RunConsoleCommand` складывает вызовы в очередь
(`LuaRuntime::take_console_commands`), клиент исполняет `gm_spawn` (и
`gm_spawnsent`/`gm_spawnswep` через реестры ENT/SWEP) обычным спавном пропа.
- Ограничения реализации: нет HTML-вкладок Dupes/Saves, приблизительные
шрифтовые метрики, текстура скина `gwenskin` заменена палитрой по UV (не
пиксель-в-пиксель с оригиналом), иконки грузятся синхронно по 3 за кадр
(первый показ большой категории может дать просадку FPS).
шрифтовые метрики (bitmap 8×8, не TTF), текстура скина `gwenskin` заменена
палитрой по UV (не пиксель-в-пиксель с оригиналом). Иконки грузятся
синхронно в главном потоке, но порциями по времени; первый показ большой
категории может дать несколько кадров с заглушками.
## 3. Sandbox (защита от крашедла)