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:
parent
3b715dcd65
commit
a3b884b7c6
9 changed files with 770 additions and 85 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
"хук обязан получить ровно два аргумента вызова (без имени хука)"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 — локальный индекс внутри
|
||||
|
|
|
|||
32
docs/LUA.md
32
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) обычным спавном пропа.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
### Что сделано
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue