Q-меню: hold/pin, кэш иконок, размер окна и сохранение скорости

Иконки: видимая сетка оставалась белыми заглушками, потому что при
бюджете 8 мс на кадр успевало загрузиться всего 1-2 превью. Бюджет поднят
до 12 штук / 40 мс, кэш увеличен с 512 до 1024 текстур и живёт весь сеанс
(LRU, ключ — нормализованный путь модели). Добавлены IconStats
(cached/rendered/reused/evicted/failed) и логи при открытии, закрытии и
выходе: повторное открытие показывает rendered_since_open=0.

Размер меню: прогрев до создания окна задавал корню 1280×720, а
ui_set_screen не обновлял размер (Rust звал несуществующий
__sr_resize_to_screen). ui.set_screen/ui.sync_root_size теперь растягивают
g_SpawnMenu на ScrW×ScrH при открытии, resize и раскладке.

Скорость: при открытии меню больше не обнуляются velocity/sprint. Игрок
заморожен (update не вызывается), но импульс сохраняется: до открытия и
после закрытия speed одинаковый, включая noclip.

Hold/pin Q: Q down открывает, Q up закрывает только без взаимодействий.
Клик, колесо, касание и ввод закрепляют меню (pinned), после чего Q
работает как toggle. Esc закрывает всегда. Для текстовых полей добавлены
ui.focus/RequestFocus/GetKeyboardFocus, SDL3 TextInput, Backspace/стрелки/
Enter и input.IsKeyTrapping: в фокусе Q печатается, а не закрывает меню.

Попутно исправлена передача аргументов hook.Run: колбэки hook.Add больше
не получают имя хука первым аргументом, как в GMod. Из-за этого
OnTextEntryGetFocus падал на pnl:HasParent, и StartKeyFocus не вызывался.

Проверки: cargo fmt, clippy -D warnings, cargo test --workspace (28 сьютов)
зелёные. Кадры 1280×720 и 1600×900: images=88/104, placeholders=0; xdotool:
hold open/close, клик+keyup, ввод q, повторный Q, скорость 250.45 до/после.
This commit is contained in:
Niko Marmeladkov 2026-10-24 22:56:26 +03:00
parent 3b715dcd65
commit a3b884b7c6
9 changed files with 770 additions and 85 deletions

View file

@ -699,6 +699,10 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
let window = builder
.build()
.map_err(|e| anyhow::anyhow!("SDL3 window: {e}"))?;
// SDL3 не присылает TextInput, пока ввод явно не включён. Включаем на всё
// время работы: события используются только когда фокус в DTextEntry
// (Q-меню), в остальное время клиент их игнорирует.
video.text_input().start(&window);
let mut gfx = Gfx::new(&window)?;
info!(gpu = %gfx.adapter_info().name, "GPU готов");
@ -866,6 +870,9 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
// Снимок Lua-меню переиспользует один буфер: каждый кадр новый Vec не
// нужен, а строки освобождаются при закрытии меню.
let mut ui_commands: Vec<glua::UiCommand> = Vec::new();
// Итоговая статистика последнего кадра меню: сколько иконок нарисовано
// и сколько осталось заглушками (для проверки скриншотов).
let mut last_ui_stats = ui_overlay::OverlayStats::default();
// Снимок панелей Derma — дорогой (Lua + строки), поэтому обновляем его
// не каждый кадр: по событиям ввода и раз в несколько кадров для анимаций.
let mut ui_snapshot_age = u32::MAX;
@ -880,6 +887,15 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
let mut profile_frames: u32 = 0;
let mut profile_icon_loads: usize = 0;
let mut lua_ui_open = false;
// Hold/pin Q-меню как в GMod: пока Q удерживается — меню открыто, но
// после любого взаимодействия (клик, ввод, колесо) оно закрепляется и
// ждёт повторного Q. `menu_q_hold` — меню открыто именно удержанием Q,
// чтобы отпускание не закрывало меню, открытое из отладки или Lua.
let mut menu_pinned = false;
let mut menu_q_hold = false;
// Сколько offscreen-рендеров иконок было к моменту открытия меню: при
// закрытии логируем дельту. На повторном открытии из кэша она равна нулю.
let mut icon_renders_at_open: u64 = 0;
// Палец, который сейчас нажимает в Lua-панель (остальные — камера/стик).
let mut ui_touch_finger: Option<u64> = None;
if std::env::var_os("SOURCE_RS_QMENU").is_some() {
@ -892,7 +908,15 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
&window,
relative_mouse,
);
player.velocity = math::Vec3::ZERO;
// Скорость при открытии меню не трогаем: игрок замирает, но импульс
// сохраняется до закрытия.
let icons = renderer.model_icon_stats();
icon_renders_at_open = icons.rendered;
info!(
cached = icons.cached,
rendered = icons.rendered,
"Q-меню: кэш иконок на открытии"
);
if lua_ui_ready {
info!("Q-меню: Lua Sandbox открыто (SOURCE_RS_QMENU)");
} else {
@ -947,61 +971,166 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
&window,
relative_mouse,
);
menu_pinned = false;
menu_q_hold = false;
ui_touch_finger = None;
debug!(
speed = player.velocity.length(),
noclip = player.noclip,
"Q-меню: скорость после закрытия (Esc)"
);
log_icons_closed(&renderer, icon_renders_at_open, "esc");
}
}
// Q в GMod — hold: нажал — открылось, отпустил — закрылось.
// Но после взаимодействия с меню оно закрепляется (pinned) и
// ждёт повторного нажатия Q, как `spawnmenu_toggle`/HangOpen.
Event::KeyDown {
keycode: Some(Keycode::Q),
repeat: false,
..
} => {
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 {
info!(
lua = lua_ui_ready,
kind = ?spawn_menu.kind(),
"Q-меню открыто"
);
// Меню — модальное: взятый физганом проп отпускаем,
// иначе он останется висеть на луче взгляда.
if let Some(current) = grab.take() {
let message =
sandbox::release_grab(&mut world.borrow_mut(), current, &mut undo);
log.push_back(message);
}
player.velocity = math::Vec3::ZERO;
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();
// Пока фокус в текстовом поле, Q — это символ «q»:
// `input.IsKeyTrapping()` в GMod не даёт `-menu` закрыть
// меню. Текст придёт отдельным `Event::TextInput`.
let text_focus =
lua_ui_open && lua.as_ref().is_some_and(|lua| lua.ui_text_focus());
if !text_focus {
let menu_open = lua_ui_open || spawn_menu.is_open();
let close = menu_open && (menu_pinned || !menu_q_hold);
if close {
lua_ui_open = set_menu_open(
false,
lua_ui_ready,
lua.as_ref(),
&mut spawn_menu,
&sdl,
&window,
relative_mouse,
);
menu_pinned = false;
menu_q_hold = false;
ui_touch_finger = None;
debug!(
speed = player.velocity.length(),
noclip = player.noclip,
"Q-меню: скорость после закрытия (Q)"
);
log_icons_closed(&renderer, icon_renders_at_open, "q");
} else if !menu_open {
lua_ui_open = set_menu_open(
true,
lua_ui_ready,
lua.as_ref(),
&mut spawn_menu,
&sdl,
&window,
relative_mouse,
);
// Открытие именно Q: отпускание закроет, пока
// пользователь не тронул меню.
menu_pinned = false;
menu_q_hold = true;
info!(
lua = lua_ui_ready,
kind = ?spawn_menu.kind(),
"Q-меню открыто"
);
// Меню — модальное: взятый физганом проп отпускаем,
// иначе он останется висеть на луче взгляда.
if let Some(current) = grab.take() {
let message = sandbox::release_grab(
&mut world.borrow_mut(),
current,
&mut undo,
);
log.push_back(message);
}
// Скорость/импульс НЕ обнуляем: игрок замирает на
// время меню, а после закрытия продолжает движение.
debug!(
speed = player.velocity.length(),
noclip = player.noclip,
"Q-меню: скорость сохранена"
);
icon_renders_at_open = log_icons_opened(&renderer);
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();
}
}
}
}
// Отпускание Q закрывает только меню, открытое удержанием и
// ещё не закреплённое. Если фокус в текстовом поле — не
// закрываем (Q там печатается).
Event::KeyUp {
keycode: Some(Keycode::Q),
..
} => {
pressed.remove(&Keycode::Q);
let text_focus =
lua_ui_open && lua.as_ref().is_some_and(|lua| lua.ui_text_focus());
if !text_focus
&& menu_q_hold
&& !menu_pinned
&& (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;
debug!(
speed = player.velocity.length(),
noclip = player.noclip,
"Q-меню: скорость после отпускания Q"
);
log_icons_closed(&renderer, icon_renders_at_open, "q_up");
}
menu_q_hold = false;
}
// Пока открыто Lua-меню, клавиатура принадлежит ему: GMod
// панели ловят клавиши сами. Клиентские горячие клавиши
// (слоты, G, V) не должны срабатывать за спиной меню.
Event::KeyDown {
keycode: Some(_),
keycode: Some(code),
repeat: false,
..
} if lua_ui_open => {}
} if lua_ui_open => {
// Служебные клавиши текстового поля (Backspace, стрелки,
// Enter) уходят в панель с фокусом. Любой ввод закрепляет
// меню: отпускание Q его больше не закроет.
if let Some(name) = ui_key_name(code)
&& let Some(lua) = &lua
&& lua.ui_key(name)
{
ui_snapshot_age = u32::MAX;
menu_pinned = true;
menu_q_hold = false;
}
}
// SDL TextInput приходит отдельно от KeyDown: только он даёт
// раскладку и регистр. Пока фокус в DTextEntry — вставляем.
Event::TextInput { text, .. } if lua_ui_open => {
if let Some(lua) = &lua
&& lua.ui_text_input(&text)
{
ui_snapshot_age = u32::MAX;
menu_pinned = true;
menu_q_hold = false;
}
}
Event::KeyDown {
keycode: Some(code),
repeat: false,
@ -1017,6 +1146,8 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
&window,
relative_mouse,
);
menu_pinned = false;
menu_q_hold = false;
}
MenuAction::Spawn(item) => {
pending_spawns
@ -1027,7 +1158,14 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
Event::MouseButtonDown {
mouse_btn, x, y, ..
} if lua_ui_open => {
// Любой клик по меню закрепляет его: отпускание Q больше
// не закроет (hold превращается в toggle после действия).
menu_pinned = true;
menu_q_hold = false;
ui_snapshot_age = u32::MAX;
// Клик мог дать фокус текстовому полю: включаем ввод ещё
// раз (SDL сбрасывает его при смене фокуса окна).
video.text_input().start(&window);
if let Some(lua) = &lua {
lua.ui_mouse("move", x, y, 0);
lua.ui_mouse("down", x, y, mouse_button_code(mouse_btn));
@ -1036,6 +1174,8 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
Event::MouseButtonUp {
mouse_btn, x, y, ..
} if lua_ui_open => {
menu_pinned = true;
menu_q_hold = false;
ui_snapshot_age = u32::MAX;
if let Some(lua) = &lua {
lua.ui_mouse("up", x, y, mouse_button_code(mouse_btn));
@ -1048,6 +1188,9 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
..
} => {
if spawn_menu.is_open() {
// Клик по встроенному меню — тоже взаимодействие.
menu_pinned = true;
menu_q_hold = false;
// Меню модальное: ЛКМ выбирает предмет, а не стреляет
// и не хватает проп физганом.
let (width, height) = gfx.size();
@ -1356,6 +1499,9 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
..
} => {
if lua_ui_open {
// Колесо в меню — тоже взаимодействие: закрепляем.
menu_pinned = true;
menu_q_hold = false;
// В меню колесо листает содержимое панели, а не крутит
// взятый физганом проп.
if y != 0.0
@ -1365,6 +1511,8 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
lua.ui_mouse("wheel", mouse_x, mouse_y, step);
}
} else if spawn_menu.is_open() {
menu_pinned = true;
menu_q_hold = false;
// Колесо листает страницы списка (он длинный).
spawn_menu.scroll(if y > 0.0 { -1 } else { 1 });
} else if let Some(current) = grab.as_mut() {
@ -1418,12 +1566,18 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
);
log.push_back(message);
}
player.velocity = math::Vec3::ZERO;
icon_renders_at_open = log_icons_opened(&renderer);
} else {
log_icons_closed(&renderer, icon_renders_at_open, "touch");
}
menu_pinned = false;
menu_q_hold = false;
ui_touch_finger = None;
} else if lua_ui_open {
// Палец нажимает в настоящие панели: касание SDL
// нормализовано, а Lua-меню ждёт пиксели экрана.
menu_pinned = true;
menu_q_hold = false;
if ui_touch_finger.is_none() {
ui_touch_finger = Some(finger_id);
let (width, height) = gfx.size();
@ -1467,6 +1621,8 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
} => {
if ui_touch_finger == Some(finger_id) {
ui_touch_finger = None;
menu_pinned = true;
menu_q_hold = false;
let (width, height) = gfx.size();
let (px, py) = (x * width as f32, y * height as f32);
if let Some(lua) = &lua {
@ -1515,6 +1671,11 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
lua_ui_open = visible;
apply_menu_mouse(visible, &sdl, &window, relative_mouse);
ui_touch_finger = None;
if !visible {
menu_pinned = false;
menu_q_hold = false;
log_icons_closed(&renderer, icon_renders_at_open, "lua");
}
}
}
@ -1832,10 +1993,11 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
let touch_jump = touch.take_jump();
// В открытом меню игрок стоит: мышь и клавиши принадлежат меню,
// гравитация/ходьба не должны утащить его с места за время выбора.
// Скорость при этом НЕ обнуляем: она хранит импульс, и после закрытия
// движение продолжается с той же скоростью (жалоба «Q сбрасывает
// разгон»). Ноклип тоже просто замирает, без обнуления velocity.
let menu_open = spawn_menu.is_open() || lua_ui_open;
if menu_open {
player.velocity = math::Vec3::ZERO;
player.sprint = false;
} else if player.noclip {
// Как в GMod: Shift ускоряет, Ctrl замедляет (а не опускает).
let speed = if pressed.contains(&Keycode::LShift) {
@ -1885,7 +2047,7 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
// плавно догоняет (как viewmodel sway в Source).
let horizontal_speed =
(player.velocity.x * player.velocity.x + player.velocity.y * player.velocity.y).sqrt();
let bob_amplitude = if player.on_ground && !player.noclip {
let bob_amplitude = if !menu_open && player.on_ground && !player.noclip {
(horizontal_speed / engine::player::RUN_SPEED).clamp(0.0, 1.0)
} else {
0.0
@ -1912,7 +2074,7 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
player_animation.update(
dt,
engine::AnimationContext {
velocity: if player.noclip {
velocity: if player.noclip || menu_open {
math::Vec3::ZERO
} else {
player.velocity
@ -2309,10 +2471,13 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
ui_snapshot_age = 0;
}
// 3D-иконки: грузим видимые модели по убыванию площади на экране,
// не больше 8 за кадр и не дольше ~8 мс. Так очередь доходит до
// всех иконок, а не только до первых трёх, и не даёт рывка.
const MAX_ICON_LOADS_PER_FRAME: usize = 8;
const ICON_LOAD_BUDGET_US: u128 = 8_000;
// не больше 12 за кадр и не дольше ~40 мс. Бюджет был 8 мс, и при
// кадре ~50 мс это давало всего две иконки за кадр: сетка меню
// секундами оставалась белыми заглушками. Заполнение видимой
// области — разовая работа (дальше всё из кэша), поэтому лучше
// короткий рывок, чем полупустое меню.
const MAX_ICON_LOADS_PER_FRAME: usize = 12;
const ICON_LOAD_BUDGET_US: u128 = 40_000;
let icons_started = Instant::now();
let mut candidates: Vec<(f32, &str)> = Vec::new();
for command in &ui_commands {
@ -2359,7 +2524,9 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
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);
last_ui_stats = stats;
let draw_elapsed = draw_started.elapsed().as_micros();
let icon_stats = renderer.model_icon_stats();
profile_draw_us += draw_elapsed;
profile_draw_max_us = profile_draw_max_us.max(draw_elapsed);
profile_frames += 1;
@ -2371,6 +2538,10 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
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,
icons_cached = icon_stats.cached,
icons_rendered = icon_stats.rendered,
icons_reused = icon_stats.reused,
icons_evicted = icon_stats.evicted,
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(),
@ -2396,6 +2567,11 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
placeholders = stats.placeholders,
skipped = stats.skipped,
glyphs = stats.glyphs,
icons_cached = icon_stats.cached,
icons_rendered = icon_stats.rendered,
icons_reused = icon_stats.reused,
icons_evicted = icon_stats.evicted,
icons_failed = icon_stats.failed,
"Lua UI: кадр"
);
// Ошибки Paint/PerformLayout не фатальны, но не должны
@ -2507,6 +2683,23 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
}
}
// Итог по иконкам: сколько готово к моменту выхода. Полезно для проверки
// скриншотов: `cached` должно покрывать видимую сетку.
{
let icons = renderer.model_icon_stats();
info!(
cached = icons.cached,
rendered = icons.rendered,
reused = icons.reused,
evicted = icons.evicted,
failed = icons.failed,
menu_open = lua_ui_open,
images = last_ui_stats.images,
placeholders = last_ui_stats.placeholders,
"Q-меню: кэш иконок при выходе"
);
}
// Диагностика физики: где пропы оказались после прогона кадров.
// Без неё «пропы падают» — утверждение, а не измерение.
let world_snapshot = world.borrow();
@ -2661,6 +2854,37 @@ fn apply_menu_mouse(
}
}
/// Логирует состояние кэша иконок при открытии и запоминает счётчик
/// offscreen-рендеров: при закрытии дельта покажет, сколько иконок реально
/// отрисовано за это открытие (для повторного открытия — ноль).
fn log_icons_opened(renderer: &WorldRenderer) -> u64 {
let icons = renderer.model_icon_stats();
info!(
cached = icons.cached,
rendered = icons.rendered,
reused = icons.reused,
evicted = icons.evicted,
failed = icons.failed,
"Q-меню: кэш иконок на открытии"
);
icons.rendered
}
/// Логирует, сколько иконок дорисовано за время открытия меню. Ноль на
/// повторном открытии — доказательство, что offscreen-рендер не повторяется.
fn log_icons_closed(renderer: &WorldRenderer, rendered_at_open: u64, reason: &str) {
let icons = renderer.model_icon_stats();
info!(
reason,
cached = icons.cached,
rendered_since_open = icons.rendered.saturating_sub(rendered_at_open),
rendered_total = icons.rendered,
reused = icons.reused,
evicted = icons.evicted,
"Q-меню: иконки при закрытии"
);
}
/// Открывает/закрывает меню: настоящее Lua-меню, если оно загрузилось,
/// иначе встроенное. Возвращает новое состояние Lua-меню.
///
@ -2689,6 +2913,24 @@ fn set_menu_open(
false
}
/// Имя служебной клавиши для Lua-текстового поля. Клиент передаёт только
/// редактирование, раскладку и символы даёт SDL TextInput.
fn ui_key_name(code: Keycode) -> Option<&'static str> {
match code {
Keycode::Backspace => Some("backspace"),
Keycode::Delete => Some("delete"),
Keycode::Left => Some("left"),
Keycode::Right => Some("right"),
Keycode::Home => Some("home"),
Keycode::End => Some("end"),
Keycode::Up => Some("up"),
Keycode::Down => Some("down"),
Keycode::Return => Some("enter"),
Keycode::Tab => Some("tab"),
_ => None,
}
}
/// Номер кнопки мыши в терминах GMod (`MOUSE_LEFT`/`MOUSE_RIGHT`/...).
fn mouse_button_code(button: MouseButton) -> i32 {
match button {

View file

@ -559,7 +559,9 @@ fn install_hook_run(lua: &Lua) -> Result<(), mlua::Error> {
Some(name) => name.to_string(),
None => return Ok(Value::Nil),
};
// Первый аргумент — имя хука, его передаём обратно первым.
// GMod передаёт в hook.Add-колбэки только аргументы вызова после
// имени хука. Имя первым аргументом ломало `OnTextEntryGetFocus`:
// spawnmenu получал строку вместо панели и падал на HasParent.
let rest: Vec<Value> = args.iter().skip(1).cloned().collect();
// Метод геймода вызывается до hook.Add-хуков и может отменить
// цепочку (`return false`), как в настоящем GMod.
@ -600,14 +602,12 @@ fn install_hook_run(lua: &Lua) -> Result<(), mlua::Error> {
let mut result = Value::Nil;
for (_, hook) in entries {
// `GM:HookName` тоже должен выполняться — так вызывается
// обработчик метода сущности.
let mut call_args: Vec<Value> =
vec![Value::String(lua.create_string(name.as_str())?)];
call_args.extend(rest.iter().cloned());
// обработчик метода сущности. Аргументы — без имени хука, как
// в GMod.
let call_args = Variadic::from(rest.clone());
// Именно `Variadic`: `Vec<Value>` реализует `IntoLua` как
// таблицу, и `Function::call` передал бы хуку **один**
// аргумент вместо трёх — молча, без всякой ошибки.
let call_args = Variadic::from(call_args);
// аргумент вместо нескольких — молча, без всякой ошибки.
let value = hook.call::<MultiValue>(call_args)?;
// Не-nil отменяет цепочку: так работает `return false`.
if !value.is_empty() {
@ -1236,7 +1236,7 @@ mod tests {
let lua = ready("gm_test");
lua.load(
r##"
hook.Add("Args", "a", function(name, value) captured = tostring(value) end)
hook.Add("Args", "a", function(value) captured = tostring(value) end)
hook.Run("Args", "pass")
"##,
)
@ -1263,8 +1263,8 @@ mod tests {
.expect("вызов");
assert_eq!(
string(&lua, "return tostring(count)"),
"3",
"хук обязан получить имя хука и два аргумента"
"2",
"хук обязан получить ровно два аргумента вызова (без имени хука)"
);
}

View file

@ -324,18 +324,73 @@ impl crate::LuaRuntime {
/// Задаёт размер экрана для `ScrW()`/`ScrH()` и раскладки.
///
/// Рендер-агент вызывает это при изменении окна; по умолчанию 1280×720.
/// Размер корня меню синхронизирует Lua-половина (`ui.set_screen`): иначе
/// после прогрева до реального размера окна меню оставалось маленьким.
pub fn ui_set_screen(&self, width: u32, height: u32) {
let _ = catch_unwind(AssertUnwindSafe(|| -> mlua::Result<()> {
let ui: Table = self.lua.globals().get("__sr_ui")?;
ui.set("screen_w", width.clamp(1, 16384) as f64)?;
ui.set("screen_h", height.clamp(1, 16384) as f64)?;
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")
{
let _ = resize.call::<()>(());
let width = width.clamp(1, 16384);
let height = height.clamp(1, 16384);
ui.set("screen_w", width as f64)?;
ui.set("screen_h", height as f64)?;
if let Ok(set_screen) = ui.get::<Function>("set_screen") {
set_screen.call::<()>((width as f64, height as f64))?;
} else {
ui.set("dirty", true)?;
ui.set("layout_dirty", true)?;
}
Ok(())
}));
}
/// Есть ли сейчас фокус в текстовом поле (`DTextEntry`).
///
/// Клиент использует это, чтобы Q печатался в поле, а не закрывал меню,
/// как `input.IsKeyTrapping()` в GMod.
pub fn ui_text_focus(&self) -> bool {
catch_unwind(AssertUnwindSafe(|| {
let ui: Table = self.lua.globals().get("__sr_ui")?;
let focus: Function = ui.get("text_focus")?;
focus.call::<bool>(())
}))
.ok()
.and_then(Result::ok)
.unwrap_or(false)
}
/// Передаёт набранный текст в панель с клавиатурным фокусом.
///
/// Возвращает true, если текст принят (значит, пользователь взаимо-
/// действует с меню и его нужно закрепить).
pub fn ui_text_input(&self, text: &str) -> bool {
catch_unwind(AssertUnwindSafe(|| {
let ui: Table = self.lua.globals().get("__sr_ui")?;
let input: Function = ui.get("text_input")?;
input.call::<bool>(text.to_string())
}))
.ok()
.and_then(Result::ok)
.unwrap_or(false)
}
/// Передаёт служебную клавишу (Backspace/Delete/стрелки/Enter) в поле.
pub fn ui_key(&self, name: &str) -> bool {
catch_unwind(AssertUnwindSafe(|| {
let ui: Table = self.lua.globals().get("__sr_ui")?;
let key: Function = ui.get("key")?;
key.call::<bool>((name.to_string(), true))
}))
.ok()
.and_then(Result::ok)
.unwrap_or(false)
}
/// Снимает клавиатурный фокус (закрытие меню).
pub fn ui_clear_focus(&self) {
let _ = catch_unwind(AssertUnwindSafe(|| -> mlua::Result<()> {
let ui: Table = self.lua.globals().get("__sr_ui")?;
if let Ok(clear) = ui.get::<Function>("clear_focus") {
clear.call::<()>(())?;
}
Ok(())
}));
@ -481,6 +536,85 @@ mod tests {
assert_eq!(rect.clip, None);
}
#[test]
fn screen_size_resizes_root_to_full_window() {
let rt = runtime();
rt.run(
"ui_root_size.lua",
br#"
local p = vgui.Create( "Panel" )
p.__sr_fullscreen_root = true
__sr_ui.root = p
__sr_ui.visible = true
"#,
)
.expect("корень создан");
rt.ui_set_screen(1600, 900);
let size = rt
.probe("return tostring(__sr_ui.root:GetWide()) .. 'x' .. tostring(__sr_ui.root:GetTall())")
.expect("probe")
.unwrap_or_default();
assert_eq!(size, "1600x900", "корень меню обязан занять всё окно");
// Смена размера окна тоже двигает корень: раньше он застревал в
// размере прогрева (1280×720) и меню выглядело маленьким.
rt.ui_set_screen(1024, 768);
let size = rt
.probe("return tostring(__sr_ui.root:GetWide()) .. 'x' .. tostring(__sr_ui.root:GetTall())")
.expect("probe")
.unwrap_or_default();
assert_eq!(size, "1024x768");
}
#[test]
fn text_entry_focus_takes_input_and_blocks_menu_key() {
let rt = runtime();
rt.run(
"ui_text_input.lua",
br#"
rawset( _G, "g_SpawnMenu", nil )
local p = vgui.Create( "Panel" )
p:SetSize( 300, 100 )
local entry = vgui.Create( "TextEntry", p )
entry:SetPos( 10, 10 )
entry:SetSize( 200, 20 )
_G.__test_entry = entry
__sr_ui.root = p
__sr_ui.visible = true
__sr_ui.mark_dirty( true )
"#,
)
.expect("поле создано");
let _ = rt.ui_snapshot();
assert!(!rt.ui_text_focus(), "без клика фокуса быть не должно");
rt.ui_mouse("move", 20.0, 20.0, 0);
rt.ui_mouse("down", 20.0, 20.0, 1);
rt.ui_mouse("up", 20.0, 20.0, 1);
assert!(rt.ui_text_focus(), "клик по полю не дал фокус");
assert!(
rt.probe("return tostring(input.IsKeyTrapping())")
.expect("probe")
.as_deref()
== Some("true"),
"пока поле в фокусе, Q должен печататься, а не закрывать меню"
);
assert!(rt.ui_text_input("q"));
assert_eq!(
rt.probe("return __test_entry:GetText()")
.expect("probe")
.as_deref(),
Some("q")
);
assert!(rt.ui_key("backspace"));
assert_eq!(
rt.probe("return __test_entry:GetText()")
.expect("probe")
.as_deref(),
Some("")
);
rt.ui_clear_focus();
assert!(!rt.ui_text_focus());
}
#[test]
fn snapshot_respects_command_limit() {
let rt = runtime();

View file

@ -52,6 +52,10 @@ function ui.is_dirty()
return ui.dirty == true
end
-- Клавиатурный фокус: текстовое поле получает символы поверх меню.
-- В GMod этим занимается движок, здесь — явная ссылка на панель.
ui.focus = ui.focus or nil
-- Константы GMod. Значения совпадают с движком и нужны скриптам как данные.
NODOCK = 0
FILL = 1
@ -74,6 +78,22 @@ TEXT_ALIGN_RIGHT = 2
TEXT_ALIGN_TOP = 3
TEXT_ALIGN_BOTTOM = 4
-- Виртуальные коды клавиш, которые движок передаёт в `OnKeyCodeTyped`.
-- В GMod они уже есть в глобальной таблице; здесь — минимальный набор для
-- текстовых полей, чтобы `dtextentry.lua` не сравнивал с nil.
KEY_BACKSPACE = KEY_BACKSPACE or 8
KEY_TAB = KEY_TAB or 9
KEY_ENTER = KEY_ENTER or 13
KEY_ESCAPE = KEY_ESCAPE or 27
KEY_SPACE = KEY_SPACE or 32
KEY_LEFT = KEY_LEFT or 37
KEY_UP = KEY_UP or 38
KEY_RIGHT = KEY_RIGHT or 39
KEY_DOWN = KEY_DOWN or 40
KEY_DELETE = KEY_DELETE or 46
KEY_HOME = KEY_HOME or 36
KEY_END = KEY_END or 35
TEXFILTER = { NONE = 0, POINT = 1, LINEAR = 2, ANISOTROPIC = 3, NOMIP = 4, MIPMAP = 5 }
BLEND = { NONE = 0, ALPHA = 1, ADDITIVE = 2, MULTIPLY = 3 }
SCALE = { NONE = 0, NORMAL = 1, FRACTION = 2 }
@ -598,7 +618,7 @@ end
function gui.InternalMouseReleased()
end
function gui.FocusedHasParent()
return false
return ui.focus ~= nil and ui.focus.__parent ~= nil
end
input = input or {}
@ -614,7 +634,9 @@ function input.SetCursorPos(x, y)
end
function input.LookupBinding() return nil end
function input.GetKeyName(code) return tostring(code) end
function input.IsKeyTrapping() return false end
-- Как `input.IsKeyTrapping()` в GMod: пока фокус в текстовом поле, `-menu`
-- не должен закрывать меню, а Q — печататься в поле.
function input.IsKeyTrapping() return ui.text_focus() ~= nil end
system = system or {}
function system.IsLinux() return true end
@ -1385,13 +1407,13 @@ function vgui.GetHoveredPanel()
return ui.hovered
end
function vgui.GetKeyboardFocus()
return nil
return ui.focus
end
function vgui.GetMouseFocus()
return ui.hovered
end
function vgui.FocusedHasParent()
return false
return ui.focus ~= nil and ui.focus.__parent ~= nil
end
-- Методы панели. Всё, что нужно и Derma, и движковым базовым классам.
@ -1755,7 +1777,16 @@ 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:HasFocus() return ui.focus == self end
function panel_meta:RequestFocus()
if self.__removed then return end
ui.set_focus(self)
end
function panel_meta:Focus() self:RequestFocus() end
function panel_meta:KillFocus()
if ui.focus == self then ui.set_focus(nil) end
end
function panel_meta:FocusNext() end
function panel_meta:SetOpenSize(value) self.__open_size = number(value, 0) end
function panel_meta:GetOpenSize() return self.__open_size or 0 end
@ -2193,10 +2224,31 @@ function ui.layout_tree(panel, depth, seen)
end
end
-- Корень меню (SpawnMenu) в GMod — панель с Dock(FILL) на весь экран. У нас
-- он создаётся без родителя, поэтому размер окна ему нужно выставлять самим:
-- иначе меню остаётся в размере, который был при прогреве до `ui_set_screen`
-- (на 1920×1080 это выглядело как «меню стало маленьким»). Размер корня
-- синхронизируется при открытии, смене размера окна и каждой раскладке.
function ui.sync_root_size(root)
if root == nil or root.__parent ~= nil or root.__sr_fullscreen_root ~= true then
return false
end
local width = number(ScrW(), 1280)
local height = number(ScrH(), 720)
if root.x == 0 and root.y == 0 and root.w == width and root.h == height then
return false
end
root:SetPos(0, 0)
root:SetSize(width, height)
ui.layout_dirty = true
return true
end
-- Раскладка только когда она действительно устарела. Снимок после движения
-- мыши перерисовывает панели, но не гоняет DTileLayout по тысячам иконок.
function ui.ensure_layout(root)
if root == nil then return end
ui.sync_root_size(root)
if root.w <= 0 or root.h <= 0 then
root:SetPos(0, 0)
root:SetSize(ScrW(), ScrH())
@ -2210,6 +2262,9 @@ 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
-- SpawnMenu в GMod — панель с Dock(FILL) на весь экран. Отмечаем
-- корень, чтобы `ui.sync_root_size` не трогал тестовые панели.
root.__sr_fullscreen_root = true
ui.root = root
return root
end
@ -2371,11 +2426,12 @@ function ui.toggle(open)
if not ok then ui.log_error("SpawnMenu:Open: " .. tostring(err)) end
end
root:SetVisible(true)
if root.w <= 0 or root.h <= 0 then
root:SetPos(0, 0)
root:SetSize(ScrW(), ScrH())
end
-- Меню всегда на весь экран с полями, как SpawnMenu с Dock(FILL).
ui.sync_root_size(root)
else
-- Фокус не должен пережить закрытие: иначе Q печатался бы в скрытое
-- поле, а `input.IsKeyTrapping` навсегда остался бы true.
ui.set_focus(nil)
if root.Close ~= nil then
local ok, err = pcall(root.Close, root)
if not ok then ui.log_error("SpawnMenu:Close: " .. tostring(err)) end
@ -2384,6 +2440,140 @@ function ui.toggle(open)
end
end
-- ---------------------------------------------------------------------------
-- Клавиатурный фокус и ввод текста
-- ---------------------------------------------------------------------------
-- Передаёт фокус панели. Хуки OnGetFocus/OnLoseFocus — как в GMod: через них
-- SpawnMenu узнаёт о StartKeyFocus/EndKeyFocus (HangOpen).
function ui.set_focus(panel)
if panel ~= nil and (panel.__removed or not panel:IsVisibleChain()) then
panel = nil
end
local old = ui.focus
if old == panel then return end
if old ~= nil and not old.__removed then
old.__has_focus = false
if old.OnLoseFocus ~= nil then
local ok, err = pcall(old.OnLoseFocus, old)
if not ok then ui.log_error("OnLoseFocus: " .. tostring(err)) end
end
end
ui.focus = panel
if panel ~= nil then
panel.__has_focus = true
if panel.OnGetFocus ~= nil then
local ok, err = pcall(panel.OnGetFocus, panel)
if not ok then ui.log_error("OnGetFocus: " .. tostring(err)) end
end
end
ui.mark_dirty(false)
end
function ui.clear_focus()
ui.set_focus(nil)
end
-- Текстовое поле в фокусе: Q должен печататься, а не закрывать меню.
function ui.text_focus()
local panel = ui.focus
if panel == nil or panel.__removed then return nil end
if not panel.__is_textentry then return nil end
if not panel:IsVisibleChain() then return nil end
return panel
end
-- Текст, набранный на клавиатуре (SDL TextInput). Возвращает true, если
-- панель его приняла: клиент по этому признаку закрепляет меню (pinned).
function ui.text_input(value)
local panel = ui.text_focus()
if panel == nil then return false end
local text = text_of(value) or ""
if text == "" then return false end
local ok, accepted = pcall(function()
if panel:IsEnabled() == false then return false end
-- `AllowInput` в GMod возвращает true, чтобы запретить ввод (например,
-- цифровое поле отбрасывает буквы).
if panel.AllowInput ~= nil then
local allowed, blocked = pcall(panel.AllowInput, panel, text)
if allowed and blocked then return true end
end
local current = panel:GetText()
local caret = math.Clamp(number(panel:GetCaretPos(), #current), 0, #current)
panel:SetText(current:sub(1, caret) .. text .. current:sub(caret + 1))
panel:SetCaretPos(caret + #text)
if panel.OnTextChanged ~= nil then
local changed, err = pcall(panel.OnTextChanged, panel)
if not changed then ui.log_error("OnTextChanged: " .. tostring(err)) end
end
ui.mark_dirty(true)
return true
end)
if not ok then
ui.log_error("text_input: " .. tostring(accepted))
return false
end
return accepted == true
end
-- Служебные клавиши текстового поля: Backspace/Delete/стрелки/Enter.
-- `pressed = false` — повтор после авто-повтора, обрабатывается как обычный.
function ui.key(name, pressed)
if pressed == false then return false end
local panel = ui.text_focus()
if panel == nil then return false end
local text = panel:GetText()
local caret = math.Clamp(number(panel:GetCaretPos(), #text), 0, #text)
if name == "backspace" then
if caret > 0 then
text = text:sub(1, caret - 1) .. text:sub(caret + 1)
caret = caret - 1
else
return true
end
elseif name == "delete" then
if caret < #text then
text = text:sub(1, caret) .. text:sub(caret + 2)
else
return true
end
elseif name == "left" then
panel:SetCaretPos(math.max(caret - 1, 0))
ui.mark_dirty(false)
return true
elseif name == "right" then
panel:SetCaretPos(math.min(caret + 1, #text))
ui.mark_dirty(false)
return true
elseif name == "home" then
panel:SetCaretPos(0)
ui.mark_dirty(false)
return true
elseif name == "end" then
panel:SetCaretPos(#text)
ui.mark_dirty(false)
return true
elseif name == "enter" or name == "tab" then
local code = name == "enter" and KEY_ENTER or KEY_TAB
if panel.OnKeyCodeTyped ~= nil then
local ok, err = pcall(panel.OnKeyCodeTyped, panel, code)
if not ok then ui.log_error("OnKeyCodeTyped: " .. tostring(err)) end
end
ui.mark_dirty(false)
return true
else
return false
end
panel:SetText(text)
panel:SetCaretPos(caret)
if panel.OnTextChanged ~= nil then
local ok, err = pcall(panel.OnTextChanged, panel)
if not ok then ui.log_error("OnTextChanged: " .. tostring(err)) end
end
ui.mark_dirty(true)
return true
end
local function point_inside(panel, px, py)
local x, y = absolute_pos(panel)
return px >= x and py >= y and px < x + math.max(panel.w, 0) and py < y + math.max(panel.h, 0)
@ -2495,6 +2685,14 @@ function ui.mouse(kind, x, y, button)
end
elseif kind == "down" then
set_hovered(target)
-- Клик по текстовому полю забирает клавиатурный фокус (в GMod это
-- делает движок), клик мимо — снимает. Иначе Q уходил бы в меню,
-- а не в поле поиска.
if target ~= nil and target.__is_textentry then
ui.set_focus(target)
elseif ui.focus ~= nil and (target == nil or not target:HasParent(ui.focus)) then
ui.set_focus(nil)
end
ui.pressed = target
if target ~= nil and target.OnMousePressed ~= nil then
local ok, err = pcall(target.OnMousePressed, target, button)
@ -3816,5 +4014,8 @@ end
function ui.set_screen(width, height)
ui.screen_w = number(width, ui.screen_w)
ui.screen_h = number(height, ui.screen_h)
-- Окно изменилось — корень на весь экран, иначе меню остаётся в старом
-- размере (жалоба «меню стало меньше после прогрева»).
ui.sync_root_size(ui.resolve_root())
ui.mark_dirty(true)
end

View file

@ -24,8 +24,10 @@ use crate::text::TextVertex;
pub const ICON_SIZE: u32 = 128;
/// Максимум иконок в кэше. Одна текстура 128×128 RGBA8 — 64 КиБ, то есть
/// 512 иконок занимают около 32 МиБ видеопамяти (плюс накладные расходы).
pub const MAX_ICONS: usize = 512;
/// 1024 иконки занимают около 64 МиБ видеопамяти (плюс накладные расходы).
/// Кэш живёт весь сеанс: при повторном открытии меню уже готовые превью не
/// рендерятся заново, вытесняются только самые давние (LRU).
pub const MAX_ICONS: usize = 1024;
/// Максимум неудачных путей, которые мы запомнили, чтобы не долбить VFS.
const MAX_FAILED: usize = 2048;
@ -84,6 +86,9 @@ pub struct ModelIcons {
cache: HashMap<String, IconEntry>,
failed: HashSet<String>,
tick: u64,
rendered: u64,
reused: u64,
evicted: u64,
}
impl ModelIcons {
@ -358,6 +363,20 @@ impl ModelIcons {
cache: HashMap::new(),
failed: HashSet::new(),
tick: 0,
rendered: 0,
reused: 0,
evicted: 0,
}
}
/// Снимок счётчиков кэша для логов и проверок.
pub fn stats(&self) -> IconStats {
IconStats {
cached: self.cache.len(),
rendered: self.rendered,
reused: self.reused,
evicted: self.evicted,
failed: self.failed.len(),
}
}
@ -367,6 +386,7 @@ impl ModelIcons {
if let Some(entry) = self.cache.get_mut(&key) {
self.tick = self.tick.wrapping_add(1);
entry.last_used = self.tick;
self.reused = self.reused.saturating_add(1);
return true;
}
false
@ -565,6 +585,7 @@ impl ModelIcons {
break;
};
self.cache.remove(&oldest);
self.evicted = self.evicted.saturating_add(1);
}
self.cache.insert(
key,
@ -575,6 +596,7 @@ impl ModelIcons {
last_used: tick,
},
);
self.rendered = self.rendered.saturating_add(1);
true
}
@ -645,6 +667,22 @@ impl ModelIcons {
/// Прямоугольник квада после обрезки: `(x0, y0, x1, y1, u0, v0, u1, v1)`.
type ClippedQuad = (f32, f32, f32, f32, f32, f32, f32, f32);
/// Счётчики кэша иконок: по ним видно, что повторное открытие меню не
/// запускает offscreen-рендер заново (`rendered` не растёт, `reused` растёт).
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct IconStats {
/// Сколько готовых текстур сейчас в кэше.
pub cached: usize,
/// Сколько иконок отрисовано за сеанс (offscreen-проходов).
pub rendered: u64,
/// Сколько раз запрос попал в готовую текстуру.
pub reused: u64,
/// Сколько записей вытеснено по лимиту LRU.
pub evicted: u64,
/// Сколько путей запомнено как битые/отсутствующие.
pub failed: usize,
}
fn clip_quad(item: &UiImageItem) -> Option<ClippedQuad> {
let x1 = item.x + item.w;
let y1 = item.y + item.h;

View file

@ -1538,6 +1538,11 @@ impl WorldRenderer {
self.icons.ensure(&gfx.device, &gfx.queue, vfs, model)
}
/// Счётчики кэша иконок: сколько готово, сколько отрисовано за сеанс.
pub fn model_icon_stats(&self) -> crate::icons::IconStats {
self.icons.stats()
}
/// Очищает оверлей (вызывать в начале кадра).
pub fn clear_text(&mut self) {
self.text.items.clear();

View file

@ -8,6 +8,44 @@
---
## 0.12. Q-меню: hold/pin, кэш иконок и размер окна (2026-10-24, ветка `feat/qmenu2`)
- **Белые квадраты.** Снимок и `image`-команды были целы; видимая сетка
просто не успевала заполниться: бюджет загрузки был 8 мс на кадр, а одна
иконка (MDL+текстуры+offscreen) занимает десятки мс. За 90 кадров грузилось
~60 иконок, остальные оставались заглушками. Бюджет поднят до 40 мс/12 штук
за кадр; на 1280×720 и 1600×900 к 90-му кадру `placeholders=0`
(`images` = 88/104), при этом failed=0.
- **Кэш.** `MAX_ICONS` 512 → 1024, ключ — нормализованный путь модели, живёт
весь сеанс. Добавлены счётчики `IconStats` (cached/rendered/reused/evicted/
failed) и логи при открытии/закрытии/выходе. Проверка xdotool: первое
открытие `rendered=88`, повторное открытие после закрытия —
`rendered_since_open=0`, `reused` растёт.
- **Размер меню.** Причина: прогрев `ui_toggle(true)` до создания окна
задавал корню 1280×720, а `ui_set_screen(1600,900)` размер не обновлял
(Rust звал несуществующий `__sr_resize_to_screen`). Теперь `ui.set_screen`
и `ui.sync_root_size` растягивают настоящий `g_SpawnMenu` на `ScrW×ScrH`
при открытии, resize и раскладке. Кадр 1600×900 — меню на весь экран с
полями, как SpawnMenu с `Dock(FILL)`.
- **Скорость.** Убраны `player.velocity = ZERO` и `sprint = false` при
открытии/в меню. Игрок заморожен (update не вызывается), но импульс цел:
лог `speed=250.45` до открытия и после закрытия, позиция в меню не меняется
(`pos 370 132` → `370 132` → движение после Q), noclip тоже без обнуления.
Bob/анимация в меню не проигрываются.
- **Hold/pin.** Q down открывает (`q_hold`), Q up закрывает только без
взаимодействий. Клик/колесо/касание/ввод ставят `pinned`; тогда Q down
закрывает (toggle), а Q up ничего не делает. Esc закрывает всегда.
В фокусе `DTextEntry` Q печатается: клиент проверяет `ui.text_focus()`,
SDL3 TextInput включается явно; `OnTextEntryGetFocus` теперь получает
панель (исправлена передача имени хука), поэтому `StartKeyFocus` работает.
- **Проверки:** `cargo fmt`, `cargo clippy --workspace --all-targets -D
warnings`, `cargo test --workspace` (28 сьютов) — зелёные. Кадры:
`/tmp/qmenu2-final-1280f.png`, `/tmp/qmenu2-final-1600e.png`; xdotool:
hold open/close, клик+keyup оставляет меню, ввод `q` не закрывает,
повторный Q закрывает, скорость сохраняется.
---
## 0.11. Модели: локальные индексы вершин и пол под пропами (2026-10-24, ветка `feat/models`)
- Причина «взрывов» мешей: `origMeshVertID` в VTX — локальный индекс внутри

View file

@ -57,8 +57,13 @@
только картинку, колесо/перетаскивание — геометрию. Это убрало пересборку
Lua-дерева раз в 6 кадров (было 30–80 мс на кадр, стало ~0,3 мс в покое).
- Контракт для рендера: `LuaRuntime::ui_toggle/ui_visible/ui_snapshot/ui_mouse/
ui_dirty/ui_tick` (+`ui_set_screen`), типы `UiKind`/`UiCommand`. Координаты —
пиксели экрана.
ui_dirty/ui_tick` (+`ui_set_screen`), а для клавиатуры —
`ui_text_focus/ui_text_input/ui_key/ui_clear_focus`; типы `UiKind`/`UiCommand`.
Координаты — пиксели экрана.
- Корень `SpawnMenu` создаётся без родителя, поэтому его размер выставляет
`ui.sync_root_size`: на открытии, при `ui_set_screen` и каждой раскладке он
занимает `ScrW()×ScrH()`. Без этого после прогрева до создания окна меню
оставалось 1280×720 на большем экране (жалоба «меню стало меньше»).
- Скин: после `skins/default.lua` вызывается `ui.apply_light_skin()` —
светло-серые панели, тёмный текст, голубые выделения. Настоящая текстура
`gwenskin` не загружается; `surface.DrawTexturedRectUV` подбирает цвет по
@ -73,6 +78,12 @@
вернёт `true`; клик по вкладке меняет активную панель. Hover не зовёт
`OnCursorEntered` (его `InvalidateLayout` раскладывал всё дерево на каждом
движении мыши) — скины читают `Hovered`.
- Клавиатура: клик по `TextEntry` ставит `ui.focus` и включает SDL TextInput
(`SDL_StartTextInput`; в SDL3 ввод по умолчанию выключен). `Event::TextInput`
вставляет символы в поле, `ui.key` обрабатывает Backspace/Delete/стрелки/
Enter. Пока поле в фокусе, `ui.text_focus()`/`input.IsKeyTrapping()` истинны,
поэтому Q печатается в поле и не закрывает меню. Фокус снимается кликом мимо
и при закрытии меню.
- Слайдеры: настоящие `DSlider`/`DNumSlider`/`DNumberScratch` загружаются
(`dnumberscratch.lua` добавлен в `VGUI_FILES`); добавлены недостающие
движковые методы `NoClipping`, `GetCaretPos/SetCaretPos`, `input.SetCursorPos`.
@ -93,15 +104,22 @@
в Lua, игрок и камера стоят; на Android есть кнопка MENU и касания
переводятся в пиксельные координаты. Если `g_SpawnMenu` не создан, клиент
откатывается на встроенное `qmenu.rs`.
- Q работает как в GMod: нажал — открылось, отпустил без действий — закрылось.
Клик, колесо или ввод в поле ставят `pinned`: после этого меню ждёт
повторного Q (toggle). Пока меню открыто, позиция игрока заморожена, но
`velocity`/импульс не обнуляются — после закрытия движение продолжается с
той же скоростью (включая noclip).
- 3D-иконки (`render::ModelIcons`): модель грузится `engine::load_model`,
рисуется в offscreen-текстуру 128×128 камерой «три четверти» на светлом
фоне, в UI кэш LRU на 512 путей. Клиент каждый кадр собирает отсутствующие
видимые иконки, сортирует по площади на экране и грузит до 8 штук или ~8 мс
за кадр — очередь доходит до всей категории, а не только до первых трёх.
фоне, в UI кэш LRU на 1024 пути, живущий весь сеанс. Клиент каждый кадр
собирает отсутствующие видимые иконки, сортирует по площади на экране и
грузит до 12 штук или ~40 мс за кадр: видимая сетка заполняется за первые
секунды, дальше всё берётся из кэша. При повторном открытии меню
offscreen-рендера нет. `ModelIcons::stats()` отдаёт `cached/rendered/reused/
evicted/failed`; клиент логирует их при открытии/закрытии и в профиле.
Отсутствующие/битые пути запоминаются, чтобы не грузить их повторно. Пока
текстуры нет, `ui_overlay` рисует светлую заглушку: имя файла в две строки
по центру, обрезанное прямоугольником иконки. При повторном открытии меню
кэш переиспользуется.
по центру, обрезанное прямоугольником иконки.
- Спавн из меню: `RunConsoleCommand` складывает вызовы в очередь
(`LuaRuntime::take_console_commands`), клиент исполняет `gm_spawn` (и
`gm_spawnsent`/`gm_spawnswep` через реестры ENT/SWEP) обычным спавном пропа.

View file

@ -860,6 +860,15 @@ API. Частоты по числу вхождений:
- `Cam`, `LocalPlayer`, `vgui`/`derma` — для Q-меню.
- `util.TraceLine`, `util.Compress`, `surface.*`.
### D3.8. Дефект: хук получал имя события первым аргументом
`hook.Add`-колбэки вызывались как `v("EventName", ...)`, хотя в GMod имя
хука не передаётся. Из-за этого `OnTextEntryGetFocus` из spawnmenu получал
строку вместо панели и падал на `pnl:HasParent(...)`: `StartKeyFocus` не
вызывался, а ошибка висела в логе при каждом клике по полю поиска. Теперь
колбэки получают только аргументы вызова (`GM:`-методы по-прежнему первым
аргументом получают таблицу геймода).
## Часть B2.6. Констрейны ( Rapier joints)
### Что сделано