Клиент: настоящее Q-меню Sandbox из Lua
Some checks are pending
CI / checks (push) Waiting to run

В crates/client подключён UI-слой из glua: после Lua-инициализации
вызывается load_ui, и если OnGamemodeLoaded создал g_SpawnMenu, Q
открывает настоящее меню Sandbox, иначе остаётся qmenu.rs.

Рендер: ui_overlay переводит UiCommand в push_rect/push_text, цвета
sRGB→linear, бюджет глифов и отсечение за экраном. push_rect теперь
точный квад, а не растянутый глиф: тонкие линии дерева больше не
превращаются в квадраты.

Ввод: Q/Esc, мышь и колесо уходят в ui_mouse, игрок и камера стоят,
ЛКМ не стреляет; ui_set_screen на старте и resize. На Android добавлена
кнопка MENU и перевод касаний в пиксельные координаты.

Спавн: RunConsoleCommand складывает вызовы в очередь; клиент исполняет
gm_spawn (и gm_spawnsent/gm_spawnswep) обычным спавном пропа на полу
перед игроком.

Попутно: ui_snapshot_into переиспользует буфер, ui_take_errors отдаёт
ошибки панелей в лог; в VGUI-слое добавлены HasChildren/ChildrenSize/
HasFocus/GetTextSize/DrawTextEntryText, без которых сетка иконок имела
высоту 0 и клики не попадали.
This commit is contained in:
Niko Marmeladkov 2026-10-24 16:02:44 +03:00
parent ffd9e736bc
commit 30fe9dfec1
12 changed files with 1205 additions and 84 deletions

View file

@ -11,6 +11,7 @@ mod net_cmd;
mod pack;
mod qmenu;
mod touch;
mod ui_overlay;
mod weapon;
use clap::{Parser, Subcommand};

View file

@ -3,6 +3,7 @@
use crate::doctor;
use crate::qmenu::{self, MenuAction, SpawnItem, SpawnMenu};
use crate::touch::TouchControls;
use crate::ui_overlay;
use anyhow::Context;
use engine::FlyCamera;
use fs::VirtualFs;
@ -471,6 +472,10 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
None
}
};
// Настоящее Q-меню Sandbox собирается из Lua-ассетов. Если загрузка не
// дошла до `g_SpawnMenu`, клиент остаётся на встроенном `qmenu.rs`.
let game_dir = layout.game.clone();
let mut lua_ui_ready = false;
if let Some(lua) = &lua {
// Хост отдаёт Lua доступ к живому миру: `Entity()` и `ents.*`
// начинают видеть настоящие сущности, а не заглушку.
@ -482,11 +487,9 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
// `file.*` с песочницой на каталог garrysmod: скрипты GMod читают и
// пишут внутри него, а выход наружу должен быть отказом, а не
// предупреждением (правило #4 AGENTS.md про ассеты).
if let Some(root) = config.assets.as_deref().map(|p| p.join("garrysmod")) {
match lua.set_file_root(&root) {
Ok(()) => info!(root = %root.display(), "file.* подключён"),
Err(err) => warn!("file.* недоступен: {err}"),
}
match lua.set_file_root(&game_dir) {
Ok(()) => info!(root = %game_dir.display(), "file.* подключён"),
Err(err) => warn!("file.* недоступен: {err}"),
}
let _ = lua.set_map(&args.map);
let mut loaded = 0usize;
@ -512,12 +515,49 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
// Скрипты GMod: оружие (`lua/weapons`) и сущности (`lua/entities`).
// Они пополняют `list.Get("Weapon")` и `scripted_ents` — из этих
// реестров строятся меню и выбор оружия.
if let Some(root) = config.assets.as_deref().map(|path| path.join("garrysmod")) {
let (weapons, entities, failed) = lua.load_scripts(&root);
info!(weapons, entities, failed, "Lua: скрипты GMod");
if weapons > 0 {
log.push_back(format!("lua: оружия {weapons}, энтити {entities}"));
let (weapons, entities, failed) = lua.load_scripts(&game_dir);
info!(weapons, entities, failed, "Lua: скрипты GMod");
if weapons > 0 {
log.push_back(format!("lua: оружия {weapons}, энтити {entities}"));
}
// Настоящий клиентский UI: Derma, VGUI, `spawnmenu` и геймод
// Sandbox. `OnGamemodeLoaded` создаёт `g_SpawnMenu`; его наличие —
// единственный надёжный признак, что меню можно рисовать.
// `SOURCE_RS_NO_LUA_UI=1` отключает его для проверки fallback.
if std::env::var_os("SOURCE_RS_NO_LUA_UI").is_some() {
warn!("Lua UI отключён (SOURCE_RS_NO_LUA_UI): встроенное Q-меню");
log.push_back("ui: отключено".to_string());
} else {
let ui = lua.load_ui(&game_dir);
info!(
loaded = ui.loaded.len(),
errors = ui.errors.len(),
"Lua: UI Sandbox загружен"
);
for error in ui.errors.iter().take(4) {
warn!(error = %error, "Lua UI: ошибка загрузки");
log.push_back(format!("ui err: {error}"));
}
if ui.errors.len() > 4 {
log.push_back(format!("ui err: ещё {}", ui.errors.len() - 4));
}
lua_ui_ready = lua
.probe("return tostring(IsValid(g_SpawnMenu))")
.ok()
.flatten()
.as_deref()
== Some("true");
if lua_ui_ready {
log.push_back("ui: Lua Sandbox".to_string());
info!("Q-меню: настоящий Sandbox из Lua");
} else {
log.push_back("ui: fallback Q-меню".to_string());
warn!("Q-меню: g_SpawnMenu не создан, остаётся встроенное меню");
}
}
while log.len() > 8 {
log.pop_front();
}
}
@ -602,6 +642,12 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
let mut gfx = Gfx::new(&window)?;
info!(gpu = %gfx.adapter_info().name, "GPU готов");
// Раскладка меню (ScrW/ScrH) должна совпадать с пикселями окна, иначе
// мышь попадает мимо панелей.
if let Some(lua) = &lua {
let (width, height) = gfx.size();
lua.ui_set_screen(width, height);
}
let mut renderer = WorldRenderer::new(&gfx, &world.borrow(), args.debug_materials)?;
// Цветокор карты из игры: Source хранит его в
// `materials/colorcorrection/<карта>.raw` (32³ RGB). Для gm_construct файл
@ -709,24 +755,59 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
.unwrap_or(26.0);
// Q-меню спавна (A24): предметы из реестров Lua и spawnlist GMod.
// Собирается один раз на старте: списки меняются только загрузкой карты.
// Если Lua-меню Sandbox загрузилось, этот список — только fallback и для
// отладочных флагов; рисовать его не будем.
let mut spawn_menu = SpawnMenu::collect(lua.as_ref(), &vfs);
if let Ok(needle) = std::env::var("SOURCE_RS_QMENU_ITEM")
if !lua_ui_ready
&& let Ok(needle) = std::env::var("SOURCE_RS_QMENU_ITEM")
&& !spawn_menu.select_matching(&needle)
{
warn!(needle = %needle, "Q-меню: предмет для выбора не найден");
}
// `SOURCE_RS_QMENU_SPAWN=1` — заспавнить выбранный предмет сразу:
// проверка спавна кадром без ручного клика.
// проверка спавна кадром без ручного клика (встроенное меню).
let mut pending_spawns: Vec<SpawnRequest> = Vec::new();
if std::env::var_os("SOURCE_RS_QMENU_SPAWN").is_some()
if !lua_ui_ready
&& std::env::var_os("SOURCE_RS_QMENU_SPAWN").is_some()
&& let Some(item) = spawn_menu.selected_item()
{
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")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let mut ui_click_done = false;
// `SOURCE_RS_UI_CLOSE=<кадр>` — закрыть меню после отладочного клика,
// чтобы на скриншоте был виден заспавненный проп, а не только панели.
let ui_close_frame = std::env::var("SOURCE_RS_UI_CLOSE")
.ok()
.and_then(|value| value.trim().parse::<u64>().ok());
let mut ui_close_done = false;
// Снимок Lua-меню переиспользует один буфер: каждый кадр новый Vec не
// нужен, а строки освобождаются при закрытии меню.
let mut ui_commands: Vec<glua::UiCommand> = Vec::new();
let mut lua_ui_open = false;
// Палец, который сейчас нажимает в Lua-панель (остальные — камера/стик).
let mut ui_touch_finger: Option<u64> = None;
if std::env::var_os("SOURCE_RS_QMENU").is_some() {
spawn_menu.set_open(true);
lua_ui_open = set_menu_open(
true,
lua_ui_ready,
lua.as_ref(),
&mut spawn_menu,
&sdl,
&window,
relative_mouse,
);
player.velocity = math::Vec3::ZERO;
apply_menu_mouse(true, &sdl, &window, relative_mouse);
if lua_ui_ready {
info!("Q-меню: Lua Sandbox открыто (SOURCE_RS_QMENU)");
} else {
info!(kind = ?spawn_menu.kind(), "Q-меню открыто (SOURCE_RS_QMENU)");
}
}
// Время для процедурной пробы скиннинга вьюмодели (A26).
@ -765,8 +846,17 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
..
} => {
// Esc в открытом меню закрывает его, а не клиент.
if spawn_menu.is_open() {
close_menu(&mut spawn_menu, &sdl, &window, relative_mouse);
if lua_ui_open || spawn_menu.is_open() {
lua_ui_open = set_menu_open(
false,
lua_ui_ready,
lua.as_ref(),
&mut spawn_menu,
&sdl,
&window,
relative_mouse,
);
ui_touch_finger = None;
} else {
break 'running;
}
@ -776,34 +866,86 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
repeat: false,
..
} => {
if spawn_menu.is_open() {
close_menu(&mut spawn_menu, &sdl, &window, relative_mouse);
let open = if lua_ui_ready {
!lua_ui_open
} else {
spawn_menu.set_open(true);
info!(kind = ?spawn_menu.kind(), "Q-меню открыто");
!spawn_menu.is_open()
};
lua_ui_open = set_menu_open(
open,
lua_ui_ready,
lua.as_ref(),
&mut spawn_menu,
&sdl,
&window,
relative_mouse,
);
if open {
info!(
lua = lua_ui_ready,
kind = ?spawn_menu.kind(),
"Q-меню открыто"
);
// Меню — модальное: взятый физганом проп отпускаем,
// иначе он останется висеть на луче взгляда.
grabbed = None;
player.velocity = math::Vec3::ZERO;
apply_menu_mouse(true, &sdl, &window, relative_mouse);
log.push_back("Q: spawn menu".to_string());
ui_touch_finger = None;
log.push_back(if lua_ui_ready {
"Q: Lua spawn menu".to_string()
} else {
"Q: spawn menu".to_string()
});
while log.len() > 8 {
log.pop_front();
}
}
}
// Пока открыто Lua-меню, клавиатура принадлежит ему: GMod
// панели ловят клавиши сами. Клиентские горячие клавиши
// (слоты, G, V) не должны срабатывать за спиной меню.
Event::KeyDown {
keycode: Some(_),
repeat: false,
..
} if lua_ui_open => {}
Event::KeyDown {
keycode: Some(code),
repeat: false,
..
} if spawn_menu.is_open() => match spawn_menu.key(code) {
MenuAction::Close => close_menu(&mut spawn_menu, &sdl, &window, relative_mouse),
MenuAction::Close => {
lua_ui_open = set_menu_open(
false,
lua_ui_ready,
lua.as_ref(),
&mut spawn_menu,
&sdl,
&window,
relative_mouse,
);
}
MenuAction::Spawn(item) => {
pending_spawns
.push(menu_spawn_request(&item, &collision, &player, camera.yaw));
}
MenuAction::None => {}
},
Event::MouseButtonDown {
mouse_btn, x, y, ..
} if lua_ui_open => {
if let Some(lua) = &lua {
lua.ui_mouse("move", x, y, 0);
lua.ui_mouse("down", x, y, mouse_button_code(mouse_btn));
}
}
Event::MouseButtonUp {
mouse_btn, x, y, ..
} if lua_ui_open => {
if let Some(lua) = &lua {
lua.ui_mouse("up", x, y, mouse_button_code(mouse_btn));
}
}
Event::MouseButtonDown {
mouse_btn: MouseButton::Left,
x,
@ -990,8 +1132,22 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
} => {
pressed.remove(&code);
}
Event::MouseWheel { y, .. } => {
if spawn_menu.is_open() {
Event::MouseWheel {
y,
mouse_x,
mouse_y,
..
} => {
if lua_ui_open {
// В меню колесо листает содержимое панели, а не крутит
// взятый физганом проп.
if y != 0.0
&& let Some(lua) = &lua
{
let step = if y > 0.0 { 1 } else { -1 };
lua.ui_mouse("wheel", mouse_x, mouse_y, step);
}
} else if spawn_menu.is_open() {
// Колесо листает страницы списка (он длинный).
spawn_menu.scroll(if y > 0.0 { -1 } else { 1 });
} else if let Some((id, along, angles)) = grabbed {
@ -1000,6 +1156,12 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
grabbed = Some((id, along, angles));
}
}
Event::MouseMotion { x, y, .. } if lua_ui_open => {
// Настоящее меню само решает, какая панель под курсором.
if let Some(lua) = &lua {
lua.ui_mouse("move", x, y, 0);
}
}
Event::MouseMotion { x, y, .. } if spawn_menu.is_open() => {
// В открытом меню мышь управляет выбором, а не камерой.
let (width, height) = gfx.size();
@ -1012,7 +1174,41 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
Event::FingerDown {
finger_id, x, y, ..
} => {
touch.finger_down(finger_id, x, y);
if touch.menu_button(x, y) {
let open = if lua_ui_ready {
!lua_ui_open
} else {
!spawn_menu.is_open()
};
lua_ui_open = set_menu_open(
open,
lua_ui_ready,
lua.as_ref(),
&mut spawn_menu,
&sdl,
&window,
relative_mouse,
);
if open {
grabbed = None;
player.velocity = math::Vec3::ZERO;
}
ui_touch_finger = None;
} else if lua_ui_open {
// Палец нажимает в настоящие панели: касание SDL
// нормализовано, а Lua-меню ждёт пиксели экрана.
if ui_touch_finger.is_none() {
ui_touch_finger = Some(finger_id);
let (width, height) = gfx.size();
let (px, py) = (x * width as f32, y * height as f32);
if let Some(lua) = &lua {
lua.ui_mouse("move", px, py, 0);
lua.ui_mouse("down", px, py, 1);
}
}
} else {
touch.finger_down(finger_id, x, y);
}
}
Event::FingerMotion {
finger_id,
@ -1022,13 +1218,34 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
dy,
..
} => {
if !spawn_menu.is_open()
if lua_ui_open {
if ui_touch_finger == Some(finger_id) {
let (width, height) = gfx.size();
let (px, py) = (x * width as f32, y * height as f32);
if let Some(lua) = &lua {
lua.ui_mouse("move", px, py, 0);
}
}
} else if !spawn_menu.is_open()
&& let Some((yaw, pitch)) = touch.finger_motion(finger_id, x, y, dx, dy)
{
camera.rotate(yaw, pitch);
}
}
Event::FingerUp { finger_id, .. } | Event::FingerCanceled { finger_id, .. } => {
Event::FingerUp {
finger_id, x, y, ..
}
| Event::FingerCanceled {
finger_id, x, y, ..
} => {
if ui_touch_finger == Some(finger_id) {
ui_touch_finger = None;
let (width, height) = gfx.size();
let (px, py) = (x * width as f32, y * height as f32);
if let Some(lua) = &lua {
lua.ui_mouse("up", px, py, 1);
}
}
touch.finger_up(finger_id);
}
Event::Window {
@ -1036,7 +1253,15 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
..
} => {
// При сворачивании Android приходит cancel не на все пальцы:
// иначе игрок «уезжает» с зажатым стиком.
// иначе игрок «уезжает» с зажатым стиком. Нажатая панель
// Lua-меню должна получить отпускание, иначе останется
// «залипшей».
if ui_touch_finger.take().is_some()
&& let Some(lua) = &lua
{
let (width, height) = gfx.size();
lua.ui_mouse("up", width as f32 * 0.5, height as f32 * 0.5, 1);
}
touch.cancel();
}
Event::Window {
@ -1046,11 +1271,69 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
let (w, h) = (w.max(1) as u32, h.max(1) as u32);
gfx.resize(w, h);
renderer.resize(&gfx, w, h);
// Раскладка меню и попадания мыши живут в пикселях окна.
if let Some(lua) = &lua {
lua.ui_set_screen(w, h);
}
}
_ => {}
}
}
// Lua-меню может закрыть себя само (кнопка Close в панели) — тогда
// возвращаем захват мыши, даже если Q не нажимали.
if lua_ui_ready && let Some(lua) = &lua {
let visible = lua.ui_visible();
if visible != lua_ui_open {
lua_ui_open = visible;
apply_menu_mouse(visible, &sdl, &window, relative_mouse);
ui_touch_finger = None;
}
}
// Отладочный клик по снимку прошлого кадра: вкладка, категория или
// модель. Скрытое окно не получает внешних кликов, поэтому проверка
// спавна идёт тем же `ui_mouse`, что и настоящая мышь.
if lua_ui_open
&& !ui_click_done
&& frames_drawn >= 8
&& let Some(spec) = ui_click.as_deref()
{
ui_click_done = true;
match ui_overlay::click_point(&ui_commands, spec) {
Some((x, y)) => {
info!(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: цель отладочного клика не найдена"),
}
}
if ui_click.is_none() {
ui_click_done = true;
}
// Отладочное закрытие: нужно для кадра со спавном (меню закрыто).
if lua_ui_open
&& !ui_close_done
&& ui_close_frame.is_some_and(|frame| frames_drawn >= frame)
{
ui_close_done = true;
lua_ui_open = set_menu_open(
false,
lua_ui_ready,
lua.as_ref(),
&mut spawn_menu,
&sdl,
&window,
relative_mouse,
);
ui_touch_finger = None;
info!("Lua UI: меню закрыто (SOURCE_RS_UI_CLOSE)");
}
// Спавн из меню или по G — один путь: физика, коллизия и пересборка
// рендерера одинаковы, поэтому запрос копится в событиях и
// обрабатывается здесь, до шага физики.
@ -1183,7 +1466,7 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
w.step_physics(dt);
w.recover_fallen_props(-8192.0);
// Физган: держим тело на луче взгляда, на взятом расстоянии.
if auto_grab && grabbed.is_none() && !spawn_menu.is_open() {
if auto_grab && grabbed.is_none() && !spawn_menu.is_open() && !lua_ui_open {
let origin = player.eye_position();
let dir = camera.forward();
let mut best: Option<(f32, engine::physics::BodyId)> = None;
@ -1251,7 +1534,7 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
let touch_jump = touch.take_jump();
// В открытом меню игрок стоит: мышь и клавиши принадлежат меню,
// гравитация/ходьба не должны утащить его с места за время выбора.
let menu_open = spawn_menu.is_open();
let menu_open = spawn_menu.is_open() || lua_ui_open;
if menu_open {
player.velocity = math::Vec3::ZERO;
player.sprint = false;
@ -1498,11 +1781,60 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
if let Some(state) = swep.as_mut() {
state.update(lua);
}
// Спавн из настоящего меню: `SpawnIcon.DoClick` зовёт
// `RunConsoleCommand("gm_spawn", model, ...)`. Клиент забирает
// очередь раз в кадр и делает то же, что G-спавн: проп на полу
// перед игроком, без телепорта и без сервера.
for command in lua.take_console_commands() {
let Some(name) = command.first().map(String::as_str) else {
continue;
};
let (model, label) = match name.to_ascii_lowercase().as_str() {
"gm_spawn" => (
command.get(1).and_then(|raw| qmenu::clean_model_path(raw)),
"gm_spawn",
),
"gm_spawnsent" => (
command.get(1).and_then(|class| entity_model(lua, class)),
"gm_spawnsent",
),
"gm_spawnswep" => (
command.get(1).and_then(|class| weapon_model(lua, class)),
"gm_spawnswep",
),
other => {
debug!(command = %other, "Lua: команда не поддержана клиентом");
continue;
}
};
let Some(model) = model else {
warn!(
command = label,
args = ?command,
"Lua: команда спавна без известной модели"
);
log.push_back(format!("{label}: нет модели"));
continue;
};
if !vfs.exists(&model) {
warn!(command = label, model = %model, "Lua: модели нет в VFS");
log.push_back(format!("{label}: нет {model}"));
continue;
}
let origin = qmenu::spawn_origin(&collision, &player, camera.yaw);
info!(command = label, model = %model, "Lua: спавн из меню");
log.push_back(format!("{label}: {model}"));
pending_spawns.push(SpawnRequest {
model,
origin,
angles: math::Vec3::new(0.0, camera.yaw, 0.0),
});
}
for line in lua.take_output() {
log.push_back(line);
while log.len() > 8 {
log.pop_front();
}
}
while log.len() > 8 {
log.pop_front();
}
}
@ -1570,14 +1902,14 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
};
hud(8.0, hud_top + 40.0, 1.4, [1.0, 0.9, 0.65, 1.0], text);
}
if !spawn_menu.is_open() {
if !menu_open {
hud(
8.0,
hud_bottom,
1.5,
[0.95, 0.95, 0.8, 1.0],
if touch_enabled {
"touch: left stick move | right swipe look | buttons jump/duck".to_string()
"touch: left stick move | right swipe look | MENU/jump/duck".to_string()
} else {
"WASD move | Space jump | Ctrl duck | V noclip | G spawn | Q menu | 0-9 weapon | ` log"
.to_string()
@ -1594,6 +1926,14 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
[0.75, 0.9, 1.0, 0.75],
"MOVE".to_string(),
);
// Четвёртая кнопка — меню (аналог Q), над прыжком.
hud(
width as f32 * 0.74,
height as f32 * 0.30,
1.6,
[0.9, 0.85, 1.0, 0.85],
"MENU".to_string(),
);
hud(
width as f32 * 0.74,
height as f32 * 0.60,
@ -1620,6 +1960,35 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
// Q-меню поверх HUD: выбор предмета и подсказки по управлению.
spawn_menu.draw(&mut renderer, width as f32, height as f32);
// Настоящее меню Sandbox: снимок панелей из Lua, примитивами клиента.
if lua_ui_open {
if let Some(lua) = &lua {
lua.ui_snapshot_into(&mut ui_commands);
}
let stats = ui_overlay::draw(&mut renderer, &ui_commands, width as f32, height as f32);
if frames_drawn % 120 == 0 {
debug!(
commands = ui_commands.len(),
rects = stats.rects,
outlines = stats.outlines,
texts = stats.texts,
skipped = stats.skipped,
glyphs = stats.glyphs,
"Lua UI: кадр"
);
// Ошибки Paint/PerformLayout не фатальны, но не должны
// теряться: показываем их в логе, а не только в памяти Lua.
if let Some(lua) = &lua {
for error in lua.ui_take_errors().iter().take(4) {
warn!(error = %error, "Lua UI: ошибка панели");
}
}
}
} else if !ui_commands.is_empty() {
// Строки снимка больше не нужны, память буфера остаётся.
ui_commands.clear();
}
if frames_drawn % 60 == 0 {
tracing::debug!(
z = player.position.z,
@ -1818,15 +2187,62 @@ fn apply_menu_mouse(
}
}
/// Закрывает Q-меню и возвращает захват мыши.
fn close_menu(
/// Открывает/закрывает меню: настоящее Lua-меню, если оно загрузилось,
/// иначе встроенное. Возвращает новое состояние Lua-меню.
///
/// Одна точка входа нужна, чтобы Q, Esc и сенсорная кнопка одинаково
/// возвращали захват мыши и не оставляли два меню открытыми сразу.
#[allow(clippy::too_many_arguments)]
fn set_menu_open(
open: bool,
lua_ui_ready: bool,
lua: Option<&glua::LuaRuntime>,
menu: &mut SpawnMenu,
sdl: &sdl3::Sdl,
window: &sdl3::video::Window,
relative_mouse: bool,
) {
menu.set_open(false);
apply_menu_mouse(false, sdl, window, relative_mouse);
) -> bool {
if lua_ui_ready && let Some(lua) = lua {
lua.ui_toggle(open);
// Реальное состояние важнее запрошенного: если корень уже удалён,
// меню не открылось, и захват мыши снимать нельзя.
let visible = lua.ui_visible();
apply_menu_mouse(visible, sdl, window, relative_mouse);
return visible;
}
menu.set_open(open);
apply_menu_mouse(open, sdl, window, relative_mouse);
false
}
/// Номер кнопки мыши в терминах GMod (`MOUSE_LEFT`/`MOUSE_RIGHT`/...).
fn mouse_button_code(button: MouseButton) -> i32 {
match button {
MouseButton::Left => 1,
MouseButton::Right => 2,
MouseButton::Middle => 3,
MouseButton::X1 => 4,
MouseButton::X2 => 5,
_ => 1,
}
}
/// Модель ENT-класса: те же поля, что у списка `qmenu`, только по запросу
/// из `gm_spawnsent`.
fn entity_model(lua: &glua::LuaRuntime, class: &str) -> Option<String> {
["Model", "WorldModel", "__sr_preview_model"]
.into_iter()
.filter_map(|field| lua.entity_string(class, field))
.find_map(|value| qmenu::clean_model_path(&value))
}
/// Мировая модель SWEP-класса для `gm_spawnswep`.
fn weapon_model(lua: &glua::LuaRuntime, class: &str) -> Option<String> {
["WorldModel", "ViewModel"]
.into_iter()
.filter_map(|field| lua.weapon_string(class, field))
.filter(|value| !value.trim().is_empty())
.find_map(|value| qmenu::clean_model_path(&value))
}
fn save_png(path: &Path, width: u32, height: u32, rgba: &[u8]) -> anyhow::Result<()> {

View file

@ -603,7 +603,7 @@ fn truncate(value: &str, max_chars: usize) -> String {
}
/// Нормализует путь модели: `\` → `/`, без пустых компонентов и `..`.
fn clean_model_path(raw: &str) -> Option<String> {
pub(crate) fn clean_model_path(raw: &str) -> Option<String> {
let path = raw.trim().replace('\\', "/");
let mut parts: Vec<&str> = Vec::new();
for part in path.split('/') {

View file

@ -1,7 +1,7 @@
//! Виртуальные органы управления для сенсорного экрана.
//!
//! Левый стик — ходьба (те же `wish_forward`/`wish_right`, что у WASD),
//! правая половина — обзор (свайп = мышь), кнопки справа снизу — прыжок и
//! правая половина — обзор (свайп = мышь), кнопки справа — меню, прыжок и
//! присед. Касания обрабатываются в общем цикле `map`, поэтому на телефоне
//! работают те же правила движения, коллизий и физики, что и с клавиатуры.
@ -42,6 +42,14 @@ const DUCK_BUTTON: ButtonRect = ButtonRect {
max_y: 0.97,
};
/// Меню (аналог Q): над прыжком, чтобы не задевать стик и обзор.
const MENU_BUTTON: ButtonRect = ButtonRect {
min_x: 0.70,
max_x: 0.93,
min_y: 0.22,
max_y: 0.47,
};
/// Состояние сенсорного управления.
#[derive(Debug, Default)]
pub struct TouchControls {
@ -66,12 +74,24 @@ impl TouchControls {
}
}
/// Возвращает true, если касание пришлось на кнопку меню.
///
/// Кнопка проверяется и при открытом меню: ею же меню закрывается
/// (на Android нет Esc), а остальные касания в этот момент уходят в
/// Lua-панели.
pub fn menu_button(&self, x: f32, y: f32) -> bool {
self.enabled && MENU_BUTTON.contains(x, y)
}
/// Возвращает true, если касание было поглощено сенсорным управлением.
pub fn finger_down(&mut self, finger_id: u64, x: f32, y: f32) -> bool {
if !self.enabled {
return false;
}
if MENU_BUTTON.contains(x, y) {
return true;
}
if JUMP_BUTTON.contains(x, y) {
self.jump_finger = Some(finger_id);
self.jump_pending = true;
@ -210,8 +230,8 @@ mod tests {
#[test]
fn look_rotates_and_buttons_hold() {
let mut touch = TouchControls::new(true);
assert!(touch.finger_down(2, 0.8, 0.3));
let (yaw, pitch) = touch.finger_motion(2, 0.9, 0.3, 0.1, 0.0).expect("look");
assert!(touch.finger_down(2, 0.5, 0.3));
let (yaw, pitch) = touch.finger_motion(2, 0.6, 0.3, 0.1, 0.0).expect("look");
assert!(yaw < 0.0);
assert_eq!(pitch, 0.0);
assert!(touch.finger_up(2));
@ -230,4 +250,21 @@ mod tests {
assert_eq!(touch.movement(), (0.0, 0.0));
assert!(!touch.take_jump());
}
#[test]
fn menu_button_is_above_jump() {
let touch = TouchControls::new(true);
assert!(touch.menu_button(0.8, 0.3));
assert!(!touch.menu_button(0.8, 0.6));
assert!(!touch.menu_button(0.2, 0.3));
let disabled = TouchControls::new(false);
assert!(!disabled.menu_button(0.8, 0.3));
}
#[test]
fn menu_button_does_not_start_look() {
let mut touch = TouchControls::new(true);
assert!(touch.finger_down(5, 0.8, 0.3));
assert!(touch.finger_motion(5, 0.9, 0.3, 0.1, 0.0).is_none());
}
}

View file

@ -0,0 +1,351 @@
//! Отрисовка настоящего Lua-меню (`UiCommand`) поверх кадра.
//!
//! Снимок строит `glua::LuaRuntime`: это уже готовые прямоугольники, контуры
//! и строки в пикселях экрана. Рендерер клиента умеет только текст и заливку,
//! поэтому контур рисуется четырьмя тонкими полосами, а текст — bitmap-шрифтом.
//!
//! Лимиты здесь не декоративные: у текстового оверлея фиксированный буфер
//! вершин (64k). Снимок ограничен 4096 командами, но одна широкая полоса
//! превращается в сотни глифов, поэтому бюджет глифов считается заранее и
//! лишние команды пропускаются, а не роняют кадр.
use glua::{UiCommand, UiKind};
use render::WorldRenderer;
/// Сколько глифов разрешено на кадр. Буфер текста — 65536 вершин, один глиф
/// занимает 6 вершин; 10000 глифов (60000 вершин) оставляют запас HUD.
const MAX_GLYPHS: usize = 10_000;
/// Толщина контура в пикселях. У заливки минимальный глиф — 8×8, поэтому
/// один пиксель всё равно станет восемью; меньшее значение только соврёт.
const OUTLINE: f32 = 1.0;
/// Минимальный масштаб bitmap-шрифта: мельче глиф нечитаем.
const MIN_TEXT_SCALE: f32 = 0.8;
/// Максимальный масштаб: DermaLarge — 32 px, при scale=8 строка не влезает.
const MAX_TEXT_SCALE: f32 = 3.0;
/// Что попало в кадр, а что отброшено (для диагностики).
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct OverlayStats {
pub rects: usize,
pub outlines: usize,
pub texts: usize,
pub skipped: usize,
pub glyphs: usize,
}
/// Рисует снимок меню. Вызывать после HUD: меню — модальное, оно сверху.
pub fn draw(
renderer: &mut WorldRenderer,
commands: &[UiCommand],
width: f32,
height: f32,
) -> OverlayStats {
let mut stats = OverlayStats::default();
for command in commands {
if !visible(command, width, height) {
stats.skipped += 1;
continue;
}
match command.kind {
UiKind::Rect => {
if command.w <= 0.0 || command.h <= 0.0 || invisible(command.color) {
stats.skipped += 1;
continue;
}
let cost = rect_glyphs(command.w, command.h);
if stats.glyphs + cost > MAX_GLYPHS {
stats.skipped += 1;
continue;
}
renderer.push_rect(
command.x,
command.y,
command.w,
command.h,
linear_color(command.color),
);
stats.rects += 1;
stats.glyphs += cost;
}
UiKind::Outline => {
if command.w <= 0.0 || command.h <= 0.0 || invisible(command.color) {
stats.skipped += 1;
continue;
}
let edges = outline_rects(command);
let cost: usize = edges.iter().map(|(_, _, w, h)| rect_glyphs(*w, *h)).sum();
if stats.glyphs + cost > MAX_GLYPHS {
stats.skipped += 1;
continue;
}
let color = linear_color(command.color);
for (x, y, w, h) in edges {
renderer.push_rect(x, y, w, h, color);
}
stats.outlines += 1;
stats.glyphs += cost;
}
UiKind::Text => {
let Some(text) = command.text.as_deref() else {
stats.skipped += 1;
continue;
};
if text.is_empty() || !command.x.is_finite() || !command.y.is_finite() {
stats.skipped += 1;
continue;
}
let scale = text_scale(command.h);
// Каждый байт строки — отдельный глиф: не-ASCII в bitmap-
// атласе всё равно превращается в квадрат, но бюджет считаем
// по фактической длине, которую обойдёт build_vertices.
let cost = text.len();
if stats.glyphs + cost > MAX_GLYPHS {
stats.skipped += 1;
continue;
}
renderer.push_text(
command.x,
command.y,
scale,
linear_color(command.color),
text,
);
stats.texts += 1;
stats.glyphs += cost;
}
}
}
stats
}
/// Точка клика из отладочного флага: «x,y» или подстрока текста.
///
/// По тексту ищется самая ранняя команда (панели рисуются сверху вниз, так
/// что вкладки и подписи находятся раньше содержимого). Для скриншот-теста
/// этого достаточно и не нужен xdotool по скрытому окну.
pub fn click_point(commands: &[UiCommand], spec: &str) -> Option<(f32, f32)> {
let spec = spec.trim();
if let Some(point) = parse_point(spec) {
return Some(point);
}
if spec.is_empty() {
return None;
}
let lower = spec.to_ascii_lowercase();
let exact = commands.iter().find(|command| {
command.kind == UiKind::Text
&& command
.text
.as_deref()
.is_some_and(|text| text.eq_ignore_ascii_case(spec))
});
let command = exact.or_else(|| {
commands.iter().find(|command| {
command.kind == UiKind::Text
&& command
.text
.as_deref()
.is_some_and(|text| text.to_ascii_lowercase().contains(&lower))
})
})?;
if !command.x.is_finite() || !command.y.is_finite() {
return None;
}
let width = if command.w.is_finite() {
command.w.max(8.0)
} else {
8.0
};
let height = if command.h.is_finite() {
command.h.max(8.0)
} else {
8.0
};
// Чуть выше середины: подпись вкладки может лежать на границе с
// содержимым, и клик ровно по центру попадал бы в панель под вкладкой.
Some((command.x + width * 0.5, command.y + height * 0.25))
}
/// Полностью ли команда в кадре (частично видимую рисует GPU-обрезка).
fn visible(command: &UiCommand, width: f32, height: f32) -> bool {
if !command.x.is_finite() || !command.y.is_finite() {
return false;
}
let w = if command.w.is_finite() {
command.w.max(0.0)
} else {
0.0
};
let h = if command.h.is_finite() {
command.h.max(0.0)
} else {
0.0
};
match command.kind {
UiKind::Text => command.y < height && command.y + h.max(8.0) > 0.0 && command.x < width,
UiKind::Rect | UiKind::Outline => {
command.x < width && command.y < height && command.x + w > 0.0 && command.y + h > 0.0
}
}
}
/// Нулевая альфа — команда невидима (Lua-скины часто рисуют прозрачное).
fn invisible(color: [f32; 4]) -> bool {
!color[3].is_finite() || color[3] <= 0.01
}
/// Цвета VGUI заданы в sRGB (Color(128,...)), а цель рендера — sRGB-текстура:
/// шейдер обязан получить линейные компоненты, иначе серый 128 на экране
/// станет 188 и меню выглядит выцветшим.
fn linear_color(color: [f32; 4]) -> [f32; 4] {
let channel = |value: f32| {
let value = if value.is_finite() {
value.clamp(0.0, 1.0)
} else {
0.0
};
if value <= 0.04045 {
value / 12.92
} else {
((value + 0.055) / 1.055).powf(2.4)
}
};
[
channel(color[0]),
channel(color[1]),
channel(color[2]),
color[3].clamp(0.0, 1.0),
]
}
/// Сколько «глифов» (квадов по 6 вершин) займёт прямоугольник.
///
/// У `push_rect` теперь отдельный квад: тонкая линия не растягивается в
/// квадрат, а стоит столько же, сколько один глиф текста.
fn rect_glyphs(width: f32, height: f32) -> usize {
if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 {
return 0;
}
1
}
/// Четыре полосы контура: верх, низ, лево, право.
fn outline_rects(command: &UiCommand) -> [(f32, f32, f32, f32); 4] {
let (x, y, w, h) = (command.x, command.y, command.w, command.h);
let t = OUTLINE.min(w * 0.5).min(h * 0.5).max(0.0);
[
(x, y, w, t),
(x, y + h - t, w, t),
(x, y + t, t, (h - 2.0 * t).max(0.0)),
(x + w - t, y + t, t, (h - 2.0 * t).max(0.0)),
]
}
/// Масштаб bitmap-шрифта по высоте шрифта Derma.
///
/// Глиф 8×8, а строка DermaDefault — 13 px. `h / 12` даёт ширину около
/// 8,7 px на символ: чуть шире, чем считает `surface.GetTextSize`, зато
/// буквы читаются (при `h / 8` строка вылезает из панелей).
fn text_scale(height: f32) -> f32 {
if !height.is_finite() {
return 1.0;
}
(height / 12.0).clamp(MIN_TEXT_SCALE, MAX_TEXT_SCALE)
}
/// Разбирает «x,y» или «x y».
fn parse_point(text: &str) -> Option<(f32, f32)> {
let mut parts = text
.split(|c: char| c == ',' || c.is_whitespace())
.filter(|part| !part.is_empty());
let x: f32 = parts.next()?.parse().ok()?;
let y: f32 = parts.next()?.parse().ok()?;
if parts.next().is_some() || !x.is_finite() || !y.is_finite() {
return None;
}
Some((x, y))
}
#[cfg(test)]
mod tests {
use super::*;
fn text_command(text: &str, x: f32, y: f32, w: f32, h: f32) -> UiCommand {
UiCommand {
kind: UiKind::Text,
x,
y,
w,
h,
color: [1.0, 1.0, 1.0, 1.0],
text: Some(text.to_string()),
}
}
#[test]
fn click_finds_exact_text_before_substring() {
let commands = vec![
text_command("Weapons", 10.0, 4.0, 50.0, 13.0),
text_command("Weapons of mass", 100.0, 100.0, 80.0, 13.0),
];
assert_eq!(click_point(&commands, "weapons"), Some((35.0, 7.25)));
assert_eq!(click_point(&commands, "mass"), Some((140.0, 103.25)));
assert_eq!(click_point(&commands, "10,20"), Some((10.0, 20.0)));
assert_eq!(click_point(&commands, "нет такого"), None);
}
#[test]
fn offscreen_commands_are_skipped() {
let rect = UiCommand {
kind: UiKind::Rect,
x: 2000.0,
y: 10.0,
w: 100.0,
h: 20.0,
color: [1.0, 0.0, 0.0, 1.0],
text: None,
};
assert!(!visible(&rect, 1280.0, 720.0));
// Частично видимый прямоугольник рисуем: GPU обрежет по окну.
let mut partial = rect.clone();
partial.x = 1200.0;
assert!(visible(&partial, 1280.0, 720.0));
}
#[test]
fn text_scale_is_readable_and_bounded() {
assert!((text_scale(13.0) - 1.0833).abs() < 0.001);
assert_eq!(text_scale(1.0), MIN_TEXT_SCALE);
assert_eq!(text_scale(500.0), MAX_TEXT_SCALE);
}
#[test]
fn srgb_colors_become_linear_before_the_shader() {
let white = linear_color([1.0, 1.0, 1.0, 1.0]);
assert!((white[0] - 1.0).abs() < 1e-6);
// sRGB 128 → линейный ~0.2158: на экране снова будет 128, а не 188.
let gray = linear_color([128.0 / 255.0, 0.0, 0.0, 0.5]);
assert!((gray[0] - 0.2158).abs() < 0.001, "gray={}", gray[0]);
assert_eq!(gray[3], 0.5);
}
#[test]
fn outline_stays_inside_the_rect() {
let outline = UiCommand {
kind: UiKind::Outline,
x: 10.0,
y: 20.0,
w: 100.0,
h: 40.0,
color: [1.0, 1.0, 1.0, 1.0],
text: None,
};
let edges = outline_rects(&outline);
assert_eq!(edges[0], (10.0, 20.0, 100.0, 1.0));
assert_eq!(edges[1], (10.0, 59.0, 100.0, 1.0));
assert_eq!(edges[2], (10.0, 21.0, 1.0, 38.0));
assert_eq!(edges[3], (109.0, 21.0, 1.0, 38.0));
}
}

View file

@ -27,6 +27,9 @@ pub enum LuaError {
type TimerQueue = Rc<RefCell<Vec<(Instant, RegistryKey)>>>;
type Output = Rc<RefCell<Vec<String>>>;
/// Очередь `RunConsoleCommand`: клиент забирает её раз в кадр и решает,
/// какие команды он умеет исполнять локально (например, `gm_spawn`).
type ConsoleQueue = Rc<RefCell<Vec<Vec<String>>>>;
/// Итог загрузки геймода.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
@ -104,6 +107,7 @@ pub struct LuaRuntime {
lua: Lua,
timers: TimerQueue,
output: Output,
console: ConsoleQueue,
include: Rc<RefCell<include::IncludeState>>,
}
@ -113,9 +117,10 @@ impl LuaRuntime {
let lua = Lua::new();
let timers: TimerQueue = Rc::new(RefCell::new(Vec::new()));
let output: Output = Rc::new(RefCell::new(Vec::new()));
let console: ConsoleQueue = Rc::new(RefCell::new(Vec::new()));
let include = Rc::new(RefCell::new(include::IncludeState::default()));
install_api(&lua, &timers, &output)?;
install_api(&lua, &timers, &output, &console)?;
gmod::install(&lua)?;
stdlib::install(&lua, "unknown")?;
include::install(&lua, include.clone())?;
@ -125,6 +130,7 @@ impl LuaRuntime {
lua,
timers,
output,
console,
include,
})
}
@ -695,6 +701,7 @@ impl LuaRuntime {
lua,
&Rc::new(RefCell::new(Vec::new())),
&Rc::new(RefCell::new(Vec::new())),
&Rc::new(RefCell::new(Vec::new())),
)
}
@ -782,6 +789,16 @@ impl LuaRuntime {
std::mem::take(&mut *self.output.borrow_mut())
}
/// Забирает вызовы `RunConsoleCommand` с прошлого кадра.
///
/// Скрипты меню (например, `SpawnIcon.DoClick`) не зовут concommand
/// напрямую: в GMod команда уходит в консоль, а исполняет её сервер.
/// Клиент забирает очередь и обрабатывает те команды, которые умеет
/// (спавн пропа), остальные игнорирует.
pub fn take_console_commands(&self) -> Vec<Vec<String>> {
std::mem::take(&mut *self.console.borrow_mut())
}
/// Имена зарегистрированных concommand'ов.
pub fn commands(&self) -> Vec<String> {
let table: Table = match self.lua.globals().get("__sr_commands") {
@ -812,7 +829,12 @@ impl LuaRuntime {
}
/// Устанавливает GMod-подобный API в глобальную таблицу.
fn install_api(lua: &Lua, timers: &TimerQueue, output: &Output) -> Result<(), mlua::Error> {
fn install_api(
lua: &Lua,
timers: &TimerQueue,
output: &Output,
console: &ConsoleQueue,
) -> Result<(), mlua::Error> {
let globals = lua.globals();
// print / Msg: накопление вывода.
@ -1142,10 +1164,21 @@ fn install_api(lua: &Lua, timers: &TimerQueue, output: &Output) -> Result<(), ml
})?,
)?;
// RunConsoleCommand — заглушка.
// RunConsoleCommand: в GMod уходит в консоль, здесь — в очередь для
// клиента. `gm_spawn` из Q-меню доходит до `map` и спавнит настоящий проп.
let console_queue = console.clone();
globals.set(
"RunConsoleCommand",
lua.create_function(|_, _: MultiValue| Ok(()))?,
lua.create_function(move |_, args: MultiValue| {
let mut command = Vec::with_capacity(args.len());
for value in args.iter() {
command.push(lua_value_to_string(value));
}
if !command.is_empty() {
console_queue.borrow_mut().push(command);
}
Ok(())
})?,
)?;
Ok(())
@ -2056,6 +2089,22 @@ result = out
);
}
#[test]
fn run_console_command_is_queued_for_the_client() {
let rt = LuaRuntime::new().expect("рантайм");
rt.run(
"console.lua",
br#"RunConsoleCommand("gm_spawn", "models/props_junk/watermelon01.mdl", 0, "")"#,
)
.expect("скрипт");
let commands = rt.take_console_commands();
assert_eq!(commands.len(), 1);
assert_eq!(commands[0][0], "gm_spawn");
assert_eq!(commands[0][1], "models/props_junk/watermelon01.mdl");
// Очередь забирается один раз.
assert!(rt.take_console_commands().is_empty());
}
#[test]
fn concommands_register() {
let rt = LuaRuntime::new().expect("рантайм");

View file

@ -104,28 +104,70 @@ impl crate::LuaRuntime {
.unwrap_or(false)
}
/// Забирает ошибки Paint/PerformLayout/ввода, накопленные Lua-слоем.
///
/// Ошибка одной панели не должна ронять меню и кадр: клиент показывает
/// их в консоли и продолжает рисовать остальные.
pub fn ui_take_errors(&self) -> Vec<String> {
catch_unwind(AssertUnwindSafe(|| -> Option<Vec<String>> {
let ui: Table = self.lua.globals().get("__sr_ui").ok()?;
let take: Function = ui.get("take_errors").ok()?;
let Value::Table(list) = take.call::<Value>(()).ok()? else {
return None;
};
let mut errors = Vec::new();
for pair in list.sequence_values::<Value>() {
if errors.len() >= 64 {
break;
}
if let Ok(Value::String(text)) = pair
&& let Ok(text) = text.to_str()
{
errors.push(text.to_string());
}
}
Some(errors)
}))
.ok()
.flatten()
.unwrap_or_default()
}
/// Снимок видимого дерева панелей на текущий кадр.
///
/// Пустой список, если меню закрыто или скрипты ещё не создали панели.
/// Число команд ограничено [`MAX_COMMANDS`], глубина — [`MAX_UI_DEPTH`]
/// (проверяется в Lua, здесь — вторая линия защиты).
pub fn ui_snapshot(&self) -> Vec<UiCommand> {
catch_unwind(AssertUnwindSafe(|| self.ui_snapshot_inner()))
.ok()
.flatten()
.unwrap_or_default()
let mut commands = Vec::new();
self.ui_snapshot_into(&mut commands);
commands
}
fn ui_snapshot_inner(&self) -> Option<Vec<UiCommand>> {
/// То же, что [`Self::ui_snapshot`], но переиспользует буфер вызывающего:
/// рендер снимает меню каждый кадр, и лишние аллокации ему не нужны.
/// Буфер очищается даже при ошибке Lua.
pub fn ui_snapshot_into(&self, commands: &mut Vec<UiCommand>) {
commands.clear();
let result = catch_unwind(AssertUnwindSafe(|| self.ui_snapshot_inner(commands)));
if result.is_err() {
commands.clear();
}
}
fn ui_snapshot_inner(&self, commands: &mut Vec<UiCommand>) {
// Отложенные `timer.Simple(0, ...)` создают вкладки и контент
// spawnmenu; без этого шага снимок был бы пустым деревом.
let _ = self.tick();
let ui: Table = self.lua.globals().get("__sr_ui").ok()?;
let snapshot: Function = ui.get("snapshot").ok()?;
let Value::Table(list) = snapshot.call::<Value>(()).ok()? else {
return None;
let Ok(ui) = self.lua.globals().get::<Table>("__sr_ui") else {
return;
};
let Ok(snapshot) = ui.get::<Function>("snapshot") else {
return;
};
let Ok(Value::Table(list)) = snapshot.call::<Value>(()) else {
return;
};
let mut commands = Vec::new();
for pair in list.sequence_values::<Value>() {
if commands.len() >= MAX_COMMANDS {
break;
@ -190,7 +232,6 @@ impl crate::LuaRuntime {
text,
});
}
Some(commands)
}
/// Передаёт мышь в Lua-дерево панелей.
@ -282,6 +323,40 @@ mod tests {
assert!((text.y - 25.0).abs() < 0.01, "y={}", text.y);
}
#[test]
fn snapshot_into_reuses_the_buffer() {
let rt = runtime();
rt.run(
"ui_reuse.lua",
br#"
local p = vgui.Create( "Panel" )
p:SetSize( 100, 50 )
function p:Paint( w, h )
surface.DrawRect( 0, 0, w, h )
draw.SimpleText( "Hi", "DermaDefault", 0, 0, color_white )
end
__sr_ui.root = p
__sr_ui.visible = true
"#,
)
.expect("панель создана");
let mut commands = Vec::new();
rt.ui_snapshot_into(&mut commands);
let first = commands.len();
assert!(first >= 2, "команды: {commands:?}");
rt.ui_snapshot_into(&mut commands);
assert_eq!(commands.len(), first, "буфер не должен накапливать команды");
}
#[test]
fn ui_errors_are_taken_once() {
let rt = runtime();
rt.run("ui_error.lua", br#"__sr_ui.log_error( "boom" )"#)
.expect("ошибка записана");
assert_eq!(rt.ui_take_errors(), vec!["boom".to_string()]);
assert!(rt.ui_take_errors().is_empty());
}
#[test]
fn snapshot_respects_command_limit() {
let rt = runtime();

View file

@ -278,17 +278,39 @@ function surface.DrawLine(x1, y1, x2, y2)
emit("rect", x, y, w, h, nil, nil)
end
-- Текстуры скина у нас не загружаются: DrawTexturedRect рисует заливку
-- текущим цветом. У Derma это часто белая плитка-фон — на экране она давала
-- бы сплошную белую панель с белым текстом. Крупные непрозрачные белые
-- текстуры подменяем тёмным фоном; мелкие (иконки, подсветка наведения)
-- оставляем, чтобы они оставались заметными.
local function textured_color(width, height)
local r, g, b, a = current_draw_color[1], current_draw_color[2], current_draw_color[3], current_draw_color[4]
local large = number(width, 0) > 24 or number(height, 0) > 24
if a >= 230 and r >= 235 and g >= 235 and b >= 235 then
if large then
return { r = 38, g = 41, b = 47, a = a }
end
-- Мелкие белые плитки — иконки без текстуры: серый квадрат читается
-- как значок, белый — как дыра в тёмном меню.
return { r = 105, g = 110, b = 120, a = a }
end
return nil
end
local function emit_textured_rect(x, y, w, h)
emit("rect", x, y, w, h, textured_color(w, h), nil)
end
function surface.DrawTexturedRect(x, y, w, h)
-- Текстур у нас нет; заливка текущим цветом сохраняет геометрию панели.
emit("rect", x, y, w, h, nil, nil)
emit_textured_rect(x, y, w, h)
end
function surface.DrawTexturedRectUV(x, y, w, h)
emit("rect", x, y, w, h, nil, nil)
emit_textured_rect(x, y, w, h)
end
function surface.DrawTexturedRectRotated(x, y, w, h)
emit("rect", x, y, w, h, nil, nil)
emit_textured_rect(x, y, w, h)
end
function surface.DrawCircle(x, y, radius)
@ -1323,6 +1345,20 @@ function panel_meta:InsertAtTop(child)
end
function panel_meta:GetChildCount() return #(self.__children or {}) end
function panel_meta:ChildCount() return #(self.__children or {}) end
function panel_meta:HasChildren() return #(self.__children or {}) > 0 end
function panel_meta:ChildrenSize()
-- Размер контейнера по детям: DTileLayout через него выставляет высоту
-- сетки иконок. Без этого высота оставалась 0, и попадания мыши в
-- иконки (find_target проверяет границы) не срабатывали.
local max_x, max_y = 0, 0
for _, child in ipairs(self.__children or {}) do
if child:IsVisible() then
max_x = math.max(max_x, (child.x or 0) + (child.w or 0))
max_y = math.max(max_y, (child.y or 0) + (child.h or 0))
end
end
return max_x, max_y
end
function panel_meta:GetChild(index) return (self.__children or {})[index] end
function panel_meta:Add(value)
if isstring(value) then
@ -1565,6 +1601,9 @@ 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:HasFocus() return self.__has_focus == true end
function panel_meta:SetOpenSize(value) self.__open_size = number(value, 0) end
function panel_meta:GetOpenSize() return self.__open_size or 0 end
function panel_meta:LocalToScreen(x, y)
local screen_x = number(x, 0)
@ -1614,6 +1653,20 @@ function panel_meta:SizeToChildren(wide, tall)
if tall then self.h = max_y end
end
function panel_meta:SizeToContents()
-- Для контейнера «содержимое» — это дети, а не текст: DListLayout и
-- DScrollPanel зовут SizeToContents до раскладки, и если вернуть размер
-- подписи (обычно 0), дерево спавн-меню схлопывается и ветки наезжают
-- друг на друга. У пустой панели остаётся текстовый размер.
local children = self.__children or {}
if #children > 0 then
local max_x, max_y = 0, 0
for _, child in ipairs(children) do
max_x = math.max(max_x, (child.x or 0) + (child.w or 0))
max_y = math.max(max_y, (child.y or 0) + (child.h or 0))
end
self:SetSize(max_x, max_y)
return
end
local width, height = self:GetContentSize()
self:SetSize(width, height)
end
@ -1713,6 +1766,31 @@ function panel_meta:GetContentSize()
local inset_x, inset_y = self:GetTextInset()
return width + inset_x * 2, height + inset_y * 2
end
function panel_meta:GetTextSize()
surface.SetFont(self.__font or "DermaDefault")
local value = self.__text or ""
if value:sub(1, 1) == "#" then value = language.GetPhrase(value) end
return surface.GetTextSize(value)
end
-- Движковый метод DTextEntry, который скин вызывает из Paint. В GMod это
-- часть движка, а не Lua-класса; без него поле поиска рисуется с ошибкой.
function panel_meta:DrawTextEntryText(text_color)
local value = self:GetText()
if value == "" and self.__placeholder ~= nil then
value = language.GetPhrase(self.__placeholder)
end
if value == "" then return end
surface.SetFont(self:GetFont())
surface.SetTextColor(text_color or color_black)
local _, text_height = surface.GetTextSize(value)
local inset_x, inset_y = self:GetTextInset()
surface.SetTextPos(
inset_x,
inset_y + math.max((self:GetTall() - text_height) * 0.5, 0)
)
surface.DrawText(value)
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
@ -2931,11 +3009,31 @@ function __sr_ui_install_fallbacks()
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)
if name == "" then return end
surface.SetFont("DermaDefaultSmall")
surface.SetTextColor(220, 220, 220, 255)
-- Имя модели в две строки по 7 символов: bitmap-шрифт клиента шире
-- метрики surface.GetTextSize, и полное имя одного строкой наезжает
-- на соседние иконки. Если имя длиннее 14 символов, сохраняем начало
-- и конец — по ним модели одного набора различимы.
local per_line = 7
local first = name
local second = nil
if #name > per_line then
if #name <= per_line * 2 then
first = name:sub(1, per_line)
second = name:sub(per_line + 1)
else
first = name:sub(1, per_line)
second = name:sub(-per_line)
end
end
local y = math.max(h - 18, 2)
surface.SetTextPos(2, y)
surface.DrawText(first)
if second ~= nil and second ~= "" then
surface.SetTextPos(2, y + 9)
surface.DrawText(second)
end
end
register("ModelImage", ModelImage, "DPanel")

View file

@ -10,7 +10,11 @@ pub struct TextVertex {
pub color: [f32; 4],
}
/// Строка текста, подготовленная к отрисовке.
/// Строка текста или залитый прямоугольник, подготовленный к отрисовке.
///
/// `size = Some((w, h))` — это прямоугольник: глиф 127 растягивается ровно
/// на w×h. Такой режим нужен меню и HUD: масштабировать квадратный глиф по
/// высоте нельзя, иначе тонкая вертикальная линия становится широким блоком.
#[derive(Debug, Clone)]
pub struct TextItem {
pub x: f32,
@ -18,6 +22,7 @@ pub struct TextItem {
pub scale: f32,
pub color: [f32; 4],
pub text: String,
pub size: Option<[f32; 2]>,
}
/// Размер атласа глифов.
@ -89,10 +94,32 @@ pub fn glyph_uv(code: u8) -> ([f32; 2], [f32; 2]) {
)
}
/// Собирает вершины для списка строк.
/// Собирает вершины для списка строк и прямоугольников.
pub fn build_vertices(items: &[TextItem]) -> Vec<TextVertex> {
let mut out = Vec::new();
for item in items {
if let Some([width, height]) = item.size {
if width <= 0.0 || height <= 0.0 {
continue;
}
// Тот же служебный сплошной глиф, но квад ровно по размеру.
let (uv0, uv1) = glyph_uv(127);
let corners = [
([item.x, item.y], [uv0[0], uv0[1]]),
([item.x + width, item.y], [uv1[0], uv0[1]]),
([item.x + width, item.y + height], [uv1[0], uv1[1]]),
([item.x, item.y + height], [uv0[0], uv1[1]]),
];
let indices = [0usize, 1, 2, 0, 2, 3];
for i in indices {
out.push(TextVertex {
pos: corners[i].0,
uv: corners[i].1,
color: item.color,
});
}
continue;
}
let mut x = item.x;
for byte in item.text.bytes() {
if byte == b'\n' {
@ -159,8 +186,26 @@ mod tests {
scale: 1.0,
color: [1.0, 1.0, 1.0, 1.0],
text: "Hi".to_string(),
size: None,
}];
let v = build_vertices(&items);
assert_eq!(v.len(), 12);
}
/// Прямоугольник — один квад, независимо от соотношения сторон.
#[test]
fn rectangle_is_one_quad() {
let items = vec![TextItem {
x: 10.0,
y: 20.0,
scale: 1.0,
color: [1.0, 0.5, 0.25, 1.0],
text: String::new(),
size: Some([1.0, 400.0]),
}];
let v = build_vertices(&items);
assert_eq!(v.len(), 6);
assert_eq!(v[0].pos, [10.0, 20.0]);
assert_eq!(v[2].pos, [11.0, 420.0]);
}
}

View file

@ -1457,6 +1457,7 @@ impl WorldRenderer {
scale,
color,
text: text.into(),
size: None,
});
}
@ -1465,23 +1466,23 @@ impl WorldRenderer {
self.text.items.clear();
}
/// Заливает прямоугольник цветом (затемнение под меню).
/// Заливает прямоугольник цветом (фон меню/панели, контуры).
///
/// Панелей у HUD пока нет, поэтому берём служебный сплошной глиф 127 из
/// атласа и растягиваем его сеткой. Годится только для прямоугольников,
/// залитых одним цветом, — ровно это нужно модальному оверлею.
/// Это отдельный квад, а не растянутый квадратный глиф: у тонких
/// вертикальных линий (дерево категорий, разделители) иначе ширина
/// становилась бы равна высоте.
pub fn push_rect(&mut self, x: f32, y: f32, width: f32, height: f32, color: [f32; 4]) {
if width <= 0.0 || height <= 0.0 {
return;
}
let scale = (height / 8.0).max(1.0);
let step = 8.0 * scale;
let cols = (width / step).ceil().max(1.0) as usize;
let rows = (height / step).ceil().max(1.0) as usize;
let line: String = std::iter::repeat_n('\u{7f}', cols).collect();
for row in 0..rows {
self.push_text(x, y + row as f32 * step, scale, color, line.clone());
}
self.text.items.push(TextItem {
x,
y,
scale: 1.0,
color,
text: String::new(),
size: Some([width, height]),
});
}
/// Загружает оверлей на GPU (размер экрана + вершины).

View file

@ -8,6 +8,42 @@
---
## 0.8. Q-меню Sandbox в клиенте (2026-10-24, ветка `feat/ui-render`)
Настоящее меню из `feat/derma` подключено к клиенту; самодельный `qmenu.rs`
остался только fallback'ом.
- `client/src/map.rs`: после `load_scripts` вызывается
`lua.load_ui(<assets>/garrysmod)`. Признак успеха — живой `g_SpawnMenu`;
иначе Q открывает встроенное меню (проверка: `SOURCE_RS_NO_LUA_UI=1`).
`ui_set_screen` вызывается на старте и на resize — мышь не уезжает.
- Рендер: `client/src/ui_overlay.rs` переводит `UiCommand` в `push_rect`
(теперь это точный квад, а не растянутый глиф — тонкие линии дерева больше
не превращаются в квадраты) и `push_text` (scale = h/12). Цвета VGUI
sRGB→linear, иначе серый 128 на экране становился 188. Бюджет — 10k глифов
на кадр; команды за пределами экрана отбрасываются.
- Ввод: Q/Esc управляют меню, Esc больше не выходит из клиента при открытом
меню. Пока меню открыто, игрок заморожен, ЛКМ не стреляет, мышь уходит в
`ui_mouse` (`move/down/up/wheel`), колесо не крутит физган. На Android
четвёртая кнопка MENU (над JUMP) открывает/закрывает меню; касания в меню
переводятся из нормализованных координат SDL в пиксели и идут в `ui_mouse`.
- Спавн: `RunConsoleCommand` в Lua теперь кладёт вызовы в очередь
(`take_console_commands`). Клиент исполняет `gm_spawn` (модель из
`SpawnIcon.DoClick`), а также `gm_spawnsent`/`gm_spawnswep` через
`entity_string`/`weapon_string`. Проп появляется на полу перед игроком
(`qmenu::spawn_origin`) и проходит обычный путь спавна/рендера.
- Снимок: `ui_snapshot_into` переиспользует буфер, `ui_take_errors` отдаёт
ошибки Paint/PerformLayout в лог клиента.
- Отладка кадров: `SOURCE_RS_QMENU=1`, `SOURCE_RS_UI_CLICK=<текст|x,y>`,
`SOURCE_RS_UI_CLOSE=<кадр>`.
- Грабли: реальный `DTileLayout` зовёт `ChildrenSize`, `DTree_Node` —
`HasChildren`, скин — `HasFocus`/`GetTextSize`/`DrawTextEntryText`; без них
сетка иконок имела высоту 0 и клики не попадали, а дерево схлопывалось.
Модели по-прежнему без превью (две строки имени), HTML-вкладки — заглушки,
длинные подписи дерева обрезаются панелью содержимого.
---
## 0.9. Настоящее Q-меню: Derma + Sandbox spawnmenu (2026-10-24, ветка `feat/derma`)
Q-меню больше не рисуется сеткой на Rust: `crates/lua` получил слой VGUI/Derma

View file

@ -47,6 +47,18 @@
- Ввод: поиск верхней видимой панели под курсором, вызовы
`OnMousePressed/OnMouseReleased/OnCursorMoved/OnMouseWheeled`; клик по вкладке
меняет активную панель.
- Клиент (`crates/client/src/map.rs`, `ui_overlay.rs`) рисует снимок поверх
кадра: прямоугольники — точными квадами, текст — bitmap-шрифтом (scale =
h/12), цвета переводятся из sRGB в линейные. Меню модальное: Q/Esc, мышь и
колесо уходят в Lua, игрок и камера стоят; на Android есть кнопка MENU и
касания переводятся в пиксельные координаты. Если `g_SpawnMenu` не создан,
клиент откатывается на встроенное `qmenu.rs`.
- Спавн из меню: `RunConsoleCommand` складывает вызовы в очередь
(`LuaRuntime::take_console_commands`), клиент исполняет `gm_spawn` (и
`gm_spawnsent`/`gm_spawnswep` через реестры ENT/SWEP) обычным спавном пропа.
- Ограничения реализации: нет превью моделей (только две строки имени), нет
HTML-вкладок Dupes/Saves, приблизительные шрифтовые метрики, нет scissor-
клиппинга панелей (переполнение подписей гасится порядком отрисовки).
## 3. Sandbox (защита от крашедла)