From a3b884b7c616ef653eca6a80c0502caa3c0e8338 Mon Sep 17 00:00:00 2001 From: Niko Marmeladkov Date: Sat, 24 Oct 2026 22:56:26 +0300 Subject: [PATCH] =?UTF-8?q?Q-=D0=BC=D0=B5=D0=BD=D1=8E:=20hold/pin,=20?= =?UTF-8?q?=D0=BA=D1=8D=D1=88=20=D0=B8=D0=BA=D0=BE=D0=BD=D0=BE=D0=BA,=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B7=D0=BC=D0=B5=D1=80=20=D0=BE=D0=BA=D0=BD=D0=B0?= =?UTF-8?q?=20=D0=B8=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D1=81=D0=BA=D0=BE=D1=80=D0=BE=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Иконки: видимая сетка оставалась белыми заглушками, потому что при бюджете 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 до/после. --- crates/client/src/map.rs | 338 ++++++++++++++++++++++++---- crates/lua/src/stdlib.rs | 20 +- crates/lua/src/ui.rs | 152 ++++++++++++- crates/lua/src/ui/bootstrap.lua | 219 +++++++++++++++++- crates/render/src/icons.rs | 42 +++- crates/render/src/world_renderer.rs | 5 + docs/DIARY.md | 38 ++++ docs/LUA.md | 32 ++- docs/PARITY.md | 9 + 9 files changed, 770 insertions(+), 85 deletions(-) diff --git a/crates/client/src/map.rs b/crates/client/src/map.rs index 6f9ccae..2760cf7 100644 --- a/crates/client/src/map.rs +++ b/crates/client/src/map.rs @@ -699,6 +699,10 @@ pub fn map(assets: Option, 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, args: MapArgs) -> anyhow::Result<()> { // Снимок Lua-меню переиспользует один буфер: каждый кадр новый Vec не // нужен, а строки освобождаются при закрытии меню. let mut ui_commands: Vec = 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, 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 = None; if std::env::var_os("SOURCE_RS_QMENU").is_some() { @@ -892,7 +908,15 @@ pub fn map(assets: Option, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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 { diff --git a/crates/lua/src/stdlib.rs b/crates/lua/src/stdlib.rs index b65fc58..88f5afe 100644 --- a/crates/lua/src/stdlib.rs +++ b/crates/lua/src/stdlib.rs @@ -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 = 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 = - 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` реализует `IntoLua` как // таблицу, и `Function::call` передал бы хуку **один** - // аргумент вместо трёх — молча, без всякой ошибки. - let call_args = Variadic::from(call_args); + // аргумент вместо нескольких — молча, без всякой ошибки. let value = hook.call::(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", + "хук обязан получить ровно два аргумента вызова (без имени хука)" ); } diff --git a/crates/lua/src/ui.rs b/crates/lua/src/ui.rs index fc03c5d..d343882 100644 --- a/crates/lua/src/ui.rs +++ b/crates/lua/src/ui.rs @@ -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::("root") - && let Ok(resize) = root.get::("__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::("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::(()) + })) + .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::(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::((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::("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(); diff --git a/crates/lua/src/ui/bootstrap.lua b/crates/lua/src/ui/bootstrap.lua index bdbde32..351b8e7 100644 --- a/crates/lua/src/ui/bootstrap.lua +++ b/crates/lua/src/ui/bootstrap.lua @@ -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 diff --git a/crates/render/src/icons.rs b/crates/render/src/icons.rs index b278c4b..99b8976 100644 --- a/crates/render/src/icons.rs +++ b/crates/render/src/icons.rs @@ -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, failed: HashSet, 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 { let x1 = item.x + item.w; let y1 = item.y + item.h; diff --git a/crates/render/src/world_renderer.rs b/crates/render/src/world_renderer.rs index 69c414b..2b97178 100644 --- a/crates/render/src/world_renderer.rs +++ b/crates/render/src/world_renderer.rs @@ -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(); diff --git a/docs/DIARY.md b/docs/DIARY.md index c9e696a..3d25f4e 100644 --- a/docs/DIARY.md +++ b/docs/DIARY.md @@ -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 — локальный индекс внутри diff --git a/docs/LUA.md b/docs/LUA.md index 6db8792..4786faf 100644 --- a/docs/LUA.md +++ b/docs/LUA.md @@ -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) обычным спавном пропа. diff --git a/docs/PARITY.md b/docs/PARITY.md index cd9f14b..202afe0 100644 --- a/docs/PARITY.md +++ b/docs/PARITY.md @@ -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) ### Что сделано