diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index d9677f6..38a441c 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -11,6 +11,7 @@ mod net_cmd; mod pack; mod qmenu; mod touch; +mod ui_overlay; mod weapon; use clap::{Parser, Subcommand}; diff --git a/crates/client/src/map.rs b/crates/client/src/map.rs index f259eef..bcc44c5 100644 --- a/crates/client/src/map.rs +++ b/crates/client/src/map.rs @@ -3,6 +3,7 @@ use crate::doctor; use crate::qmenu::{self, MenuAction, SpawnItem, SpawnMenu}; use crate::touch::TouchControls; +use crate::ui_overlay; use anyhow::Context; use engine::FlyCamera; use fs::VirtualFs; @@ -471,6 +472,10 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { None } }; + // Настоящее Q-меню Sandbox собирается из Lua-ассетов. Если загрузка не + // дошла до `g_SpawnMenu`, клиент остаётся на встроенном `qmenu.rs`. + let game_dir = layout.game.clone(); + let mut lua_ui_ready = false; if let Some(lua) = &lua { // Хост отдаёт Lua доступ к живому миру: `Entity()` и `ents.*` // начинают видеть настоящие сущности, а не заглушку. @@ -482,11 +487,9 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { // `file.*` с песочницой на каталог garrysmod: скрипты GMod читают и // пишут внутри него, а выход наружу должен быть отказом, а не // предупреждением (правило #4 AGENTS.md про ассеты). - if let Some(root) = config.assets.as_deref().map(|p| p.join("garrysmod")) { - match lua.set_file_root(&root) { - Ok(()) => info!(root = %root.display(), "file.* подключён"), - Err(err) => warn!("file.* недоступен: {err}"), - } + match lua.set_file_root(&game_dir) { + Ok(()) => info!(root = %game_dir.display(), "file.* подключён"), + Err(err) => warn!("file.* недоступен: {err}"), } let _ = lua.set_map(&args.map); let mut loaded = 0usize; @@ -512,12 +515,49 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { // Скрипты GMod: оружие (`lua/weapons`) и сущности (`lua/entities`). // Они пополняют `list.Get("Weapon")` и `scripted_ents` — из этих // реестров строятся меню и выбор оружия. - if let Some(root) = config.assets.as_deref().map(|path| path.join("garrysmod")) { - let (weapons, entities, failed) = lua.load_scripts(&root); - info!(weapons, entities, failed, "Lua: скрипты GMod"); - if weapons > 0 { - log.push_back(format!("lua: оружия {weapons}, энтити {entities}")); + let (weapons, entities, failed) = lua.load_scripts(&game_dir); + info!(weapons, entities, failed, "Lua: скрипты GMod"); + if weapons > 0 { + log.push_back(format!("lua: оружия {weapons}, энтити {entities}")); + } + + // Настоящий клиентский UI: Derma, VGUI, `spawnmenu` и геймод + // Sandbox. `OnGamemodeLoaded` создаёт `g_SpawnMenu`; его наличие — + // единственный надёжный признак, что меню можно рисовать. + // `SOURCE_RS_NO_LUA_UI=1` отключает его для проверки fallback. + if std::env::var_os("SOURCE_RS_NO_LUA_UI").is_some() { + warn!("Lua UI отключён (SOURCE_RS_NO_LUA_UI): встроенное Q-меню"); + log.push_back("ui: отключено".to_string()); + } else { + let ui = lua.load_ui(&game_dir); + info!( + loaded = ui.loaded.len(), + errors = ui.errors.len(), + "Lua: UI Sandbox загружен" + ); + for error in ui.errors.iter().take(4) { + warn!(error = %error, "Lua UI: ошибка загрузки"); + log.push_back(format!("ui err: {error}")); } + if ui.errors.len() > 4 { + log.push_back(format!("ui err: ещё {}", ui.errors.len() - 4)); + } + lua_ui_ready = lua + .probe("return tostring(IsValid(g_SpawnMenu))") + .ok() + .flatten() + .as_deref() + == Some("true"); + if lua_ui_ready { + log.push_back("ui: Lua Sandbox".to_string()); + info!("Q-меню: настоящий Sandbox из Lua"); + } else { + log.push_back("ui: fallback Q-меню".to_string()); + warn!("Q-меню: g_SpawnMenu не создан, остаётся встроенное меню"); + } + } + while log.len() > 8 { + log.pop_front(); } } @@ -602,6 +642,12 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { let mut gfx = Gfx::new(&window)?; info!(gpu = %gfx.adapter_info().name, "GPU готов"); + // Раскладка меню (ScrW/ScrH) должна совпадать с пикселями окна, иначе + // мышь попадает мимо панелей. + if let Some(lua) = &lua { + let (width, height) = gfx.size(); + lua.ui_set_screen(width, height); + } let mut renderer = WorldRenderer::new(&gfx, &world.borrow(), args.debug_materials)?; // Цветокор карты из игры: Source хранит его в // `materials/colorcorrection/<карта>.raw` (32³ RGB). Для gm_construct файл @@ -709,24 +755,59 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { .unwrap_or(26.0); // Q-меню спавна (A24): предметы из реестров Lua и spawnlist GMod. // Собирается один раз на старте: списки меняются только загрузкой карты. + // Если Lua-меню Sandbox загрузилось, этот список — только fallback и для + // отладочных флагов; рисовать его не будем. let mut spawn_menu = SpawnMenu::collect(lua.as_ref(), &vfs); - if let Ok(needle) = std::env::var("SOURCE_RS_QMENU_ITEM") + if !lua_ui_ready + && let Ok(needle) = std::env::var("SOURCE_RS_QMENU_ITEM") && !spawn_menu.select_matching(&needle) { warn!(needle = %needle, "Q-меню: предмет для выбора не найден"); } // `SOURCE_RS_QMENU_SPAWN=1` — заспавнить выбранный предмет сразу: - // проверка спавна кадром без ручного клика. + // проверка спавна кадром без ручного клика (встроенное меню). let mut pending_spawns: Vec = Vec::new(); - if std::env::var_os("SOURCE_RS_QMENU_SPAWN").is_some() + if !lua_ui_ready + && std::env::var_os("SOURCE_RS_QMENU_SPAWN").is_some() && let Some(item) = spawn_menu.selected_item() { pending_spawns.push(menu_spawn_request(item, &collision, &player, camera.yaw)); } + // `SOURCE_RS_UI_CLICK` — отладочный клик по Lua-меню: «x,y» или текст + // вкладки/модели. Нужен, потому что скрытое окно не получает xdotool. + let ui_click = std::env::var("SOURCE_RS_UI_CLICK") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let mut ui_click_done = false; + // `SOURCE_RS_UI_CLOSE=<кадр>` — закрыть меню после отладочного клика, + // чтобы на скриншоте был виден заспавненный проп, а не только панели. + let ui_close_frame = std::env::var("SOURCE_RS_UI_CLOSE") + .ok() + .and_then(|value| value.trim().parse::().ok()); + let mut ui_close_done = false; + // Снимок Lua-меню переиспользует один буфер: каждый кадр новый Vec не + // нужен, а строки освобождаются при закрытии меню. + let mut ui_commands: Vec = Vec::new(); + let mut lua_ui_open = false; + // Палец, который сейчас нажимает в Lua-панель (остальные — камера/стик). + let mut ui_touch_finger: Option = None; if std::env::var_os("SOURCE_RS_QMENU").is_some() { - spawn_menu.set_open(true); + lua_ui_open = set_menu_open( + true, + lua_ui_ready, + lua.as_ref(), + &mut spawn_menu, + &sdl, + &window, + relative_mouse, + ); player.velocity = math::Vec3::ZERO; - apply_menu_mouse(true, &sdl, &window, relative_mouse); + if lua_ui_ready { + info!("Q-меню: Lua Sandbox открыто (SOURCE_RS_QMENU)"); + } else { + info!(kind = ?spawn_menu.kind(), "Q-меню открыто (SOURCE_RS_QMENU)"); + } } // Время для процедурной пробы скиннинга вьюмодели (A26). @@ -765,8 +846,17 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { .. } => { // Esc в открытом меню закрывает его, а не клиент. - if spawn_menu.is_open() { - close_menu(&mut spawn_menu, &sdl, &window, relative_mouse); + if lua_ui_open || spawn_menu.is_open() { + lua_ui_open = set_menu_open( + false, + lua_ui_ready, + lua.as_ref(), + &mut spawn_menu, + &sdl, + &window, + relative_mouse, + ); + ui_touch_finger = None; } else { break 'running; } @@ -776,34 +866,86 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { repeat: false, .. } => { - if spawn_menu.is_open() { - close_menu(&mut spawn_menu, &sdl, &window, relative_mouse); + let open = if lua_ui_ready { + !lua_ui_open } else { - spawn_menu.set_open(true); - info!(kind = ?spawn_menu.kind(), "Q-меню открыто"); + !spawn_menu.is_open() + }; + lua_ui_open = set_menu_open( + open, + lua_ui_ready, + lua.as_ref(), + &mut spawn_menu, + &sdl, + &window, + relative_mouse, + ); + if open { + info!( + lua = lua_ui_ready, + kind = ?spawn_menu.kind(), + "Q-меню открыто" + ); // Меню — модальное: взятый физганом проп отпускаем, // иначе он останется висеть на луче взгляда. grabbed = None; player.velocity = math::Vec3::ZERO; - apply_menu_mouse(true, &sdl, &window, relative_mouse); - log.push_back("Q: spawn menu".to_string()); + ui_touch_finger = None; + log.push_back(if lua_ui_ready { + "Q: Lua spawn menu".to_string() + } else { + "Q: spawn menu".to_string() + }); while log.len() > 8 { log.pop_front(); } } } + // Пока открыто Lua-меню, клавиатура принадлежит ему: GMod + // панели ловят клавиши сами. Клиентские горячие клавиши + // (слоты, G, V) не должны срабатывать за спиной меню. + Event::KeyDown { + keycode: Some(_), + repeat: false, + .. + } if lua_ui_open => {} Event::KeyDown { keycode: Some(code), repeat: false, .. } if spawn_menu.is_open() => match spawn_menu.key(code) { - MenuAction::Close => close_menu(&mut spawn_menu, &sdl, &window, relative_mouse), + MenuAction::Close => { + lua_ui_open = set_menu_open( + false, + lua_ui_ready, + lua.as_ref(), + &mut spawn_menu, + &sdl, + &window, + relative_mouse, + ); + } MenuAction::Spawn(item) => { pending_spawns .push(menu_spawn_request(&item, &collision, &player, camera.yaw)); } MenuAction::None => {} }, + Event::MouseButtonDown { + mouse_btn, x, y, .. + } if lua_ui_open => { + if let Some(lua) = &lua { + lua.ui_mouse("move", x, y, 0); + lua.ui_mouse("down", x, y, mouse_button_code(mouse_btn)); + } + } + Event::MouseButtonUp { + mouse_btn, x, y, .. + } if lua_ui_open => { + if let Some(lua) = &lua { + lua.ui_mouse("up", x, y, mouse_button_code(mouse_btn)); + } + } Event::MouseButtonDown { mouse_btn: MouseButton::Left, x, @@ -990,8 +1132,22 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { } => { pressed.remove(&code); } - Event::MouseWheel { y, .. } => { - if spawn_menu.is_open() { + Event::MouseWheel { + y, + mouse_x, + mouse_y, + .. + } => { + if lua_ui_open { + // В меню колесо листает содержимое панели, а не крутит + // взятый физганом проп. + if y != 0.0 + && let Some(lua) = &lua + { + let step = if y > 0.0 { 1 } else { -1 }; + lua.ui_mouse("wheel", mouse_x, mouse_y, step); + } + } else if spawn_menu.is_open() { // Колесо листает страницы списка (он длинный). spawn_menu.scroll(if y > 0.0 { -1 } else { 1 }); } else if let Some((id, along, angles)) = grabbed { @@ -1000,6 +1156,12 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { grabbed = Some((id, along, angles)); } } + Event::MouseMotion { x, y, .. } if lua_ui_open => { + // Настоящее меню само решает, какая панель под курсором. + if let Some(lua) = &lua { + lua.ui_mouse("move", x, y, 0); + } + } Event::MouseMotion { x, y, .. } if spawn_menu.is_open() => { // В открытом меню мышь управляет выбором, а не камерой. let (width, height) = gfx.size(); @@ -1012,7 +1174,41 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { Event::FingerDown { finger_id, x, y, .. } => { - touch.finger_down(finger_id, x, y); + if touch.menu_button(x, y) { + let open = if lua_ui_ready { + !lua_ui_open + } else { + !spawn_menu.is_open() + }; + lua_ui_open = set_menu_open( + open, + lua_ui_ready, + lua.as_ref(), + &mut spawn_menu, + &sdl, + &window, + relative_mouse, + ); + if open { + grabbed = None; + player.velocity = math::Vec3::ZERO; + } + ui_touch_finger = None; + } else if lua_ui_open { + // Палец нажимает в настоящие панели: касание SDL + // нормализовано, а Lua-меню ждёт пиксели экрана. + if ui_touch_finger.is_none() { + ui_touch_finger = Some(finger_id); + let (width, height) = gfx.size(); + let (px, py) = (x * width as f32, y * height as f32); + if let Some(lua) = &lua { + lua.ui_mouse("move", px, py, 0); + lua.ui_mouse("down", px, py, 1); + } + } + } else { + touch.finger_down(finger_id, x, y); + } } Event::FingerMotion { finger_id, @@ -1022,13 +1218,34 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { dy, .. } => { - if !spawn_menu.is_open() + if lua_ui_open { + if ui_touch_finger == Some(finger_id) { + let (width, height) = gfx.size(); + let (px, py) = (x * width as f32, y * height as f32); + if let Some(lua) = &lua { + lua.ui_mouse("move", px, py, 0); + } + } + } else if !spawn_menu.is_open() && let Some((yaw, pitch)) = touch.finger_motion(finger_id, x, y, dx, dy) { camera.rotate(yaw, pitch); } } - Event::FingerUp { finger_id, .. } | Event::FingerCanceled { finger_id, .. } => { + Event::FingerUp { + finger_id, x, y, .. + } + | Event::FingerCanceled { + finger_id, x, y, .. + } => { + if ui_touch_finger == Some(finger_id) { + ui_touch_finger = None; + let (width, height) = gfx.size(); + let (px, py) = (x * width as f32, y * height as f32); + if let Some(lua) = &lua { + lua.ui_mouse("up", px, py, 1); + } + } touch.finger_up(finger_id); } Event::Window { @@ -1036,7 +1253,15 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { .. } => { // При сворачивании Android приходит cancel не на все пальцы: - // иначе игрок «уезжает» с зажатым стиком. + // иначе игрок «уезжает» с зажатым стиком. Нажатая панель + // Lua-меню должна получить отпускание, иначе останется + // «залипшей». + if ui_touch_finger.take().is_some() + && let Some(lua) = &lua + { + let (width, height) = gfx.size(); + lua.ui_mouse("up", width as f32 * 0.5, height as f32 * 0.5, 1); + } touch.cancel(); } Event::Window { @@ -1046,11 +1271,69 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { let (w, h) = (w.max(1) as u32, h.max(1) as u32); gfx.resize(w, h); renderer.resize(&gfx, w, h); + // Раскладка меню и попадания мыши живут в пикселях окна. + if let Some(lua) = &lua { + lua.ui_set_screen(w, h); + } } _ => {} } } + // Lua-меню может закрыть себя само (кнопка Close в панели) — тогда + // возвращаем захват мыши, даже если Q не нажимали. + if lua_ui_ready && let Some(lua) = &lua { + let visible = lua.ui_visible(); + if visible != lua_ui_open { + lua_ui_open = visible; + apply_menu_mouse(visible, &sdl, &window, relative_mouse); + ui_touch_finger = None; + } + } + + // Отладочный клик по снимку прошлого кадра: вкладка, категория или + // модель. Скрытое окно не получает внешних кликов, поэтому проверка + // спавна идёт тем же `ui_mouse`, что и настоящая мышь. + if lua_ui_open + && !ui_click_done + && frames_drawn >= 8 + && let Some(spec) = ui_click.as_deref() + { + ui_click_done = true; + match ui_overlay::click_point(&ui_commands, spec) { + Some((x, y)) => { + info!(spec, x, y, "Lua UI: отладочный клик"); + if let Some(lua) = &lua { + lua.ui_mouse("move", x, y, 0); + lua.ui_mouse("down", x, y, 1); + lua.ui_mouse("up", x, y, 1); + } + } + None => warn!(spec, "Lua UI: цель отладочного клика не найдена"), + } + } + if ui_click.is_none() { + ui_click_done = true; + } + // Отладочное закрытие: нужно для кадра со спавном (меню закрыто). + if lua_ui_open + && !ui_close_done + && ui_close_frame.is_some_and(|frame| frames_drawn >= frame) + { + ui_close_done = true; + lua_ui_open = set_menu_open( + false, + lua_ui_ready, + lua.as_ref(), + &mut spawn_menu, + &sdl, + &window, + relative_mouse, + ); + ui_touch_finger = None; + info!("Lua UI: меню закрыто (SOURCE_RS_UI_CLOSE)"); + } + // Спавн из меню или по G — один путь: физика, коллизия и пересборка // рендерера одинаковы, поэтому запрос копится в событиях и // обрабатывается здесь, до шага физики. @@ -1183,7 +1466,7 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { w.step_physics(dt); w.recover_fallen_props(-8192.0); // Физган: держим тело на луче взгляда, на взятом расстоянии. - if auto_grab && grabbed.is_none() && !spawn_menu.is_open() { + if auto_grab && grabbed.is_none() && !spawn_menu.is_open() && !lua_ui_open { let origin = player.eye_position(); let dir = camera.forward(); let mut best: Option<(f32, engine::physics::BodyId)> = None; @@ -1251,7 +1534,7 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { let touch_jump = touch.take_jump(); // В открытом меню игрок стоит: мышь и клавиши принадлежат меню, // гравитация/ходьба не должны утащить его с места за время выбора. - let menu_open = spawn_menu.is_open(); + let menu_open = spawn_menu.is_open() || lua_ui_open; if menu_open { player.velocity = math::Vec3::ZERO; player.sprint = false; @@ -1498,11 +1781,60 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { if let Some(state) = swep.as_mut() { state.update(lua); } + // Спавн из настоящего меню: `SpawnIcon.DoClick` зовёт + // `RunConsoleCommand("gm_spawn", model, ...)`. Клиент забирает + // очередь раз в кадр и делает то же, что G-спавн: проп на полу + // перед игроком, без телепорта и без сервера. + for command in lua.take_console_commands() { + let Some(name) = command.first().map(String::as_str) else { + continue; + }; + let (model, label) = match name.to_ascii_lowercase().as_str() { + "gm_spawn" => ( + command.get(1).and_then(|raw| qmenu::clean_model_path(raw)), + "gm_spawn", + ), + "gm_spawnsent" => ( + command.get(1).and_then(|class| entity_model(lua, class)), + "gm_spawnsent", + ), + "gm_spawnswep" => ( + command.get(1).and_then(|class| weapon_model(lua, class)), + "gm_spawnswep", + ), + other => { + debug!(command = %other, "Lua: команда не поддержана клиентом"); + continue; + } + }; + let Some(model) = model else { + warn!( + command = label, + args = ?command, + "Lua: команда спавна без известной модели" + ); + log.push_back(format!("{label}: нет модели")); + continue; + }; + if !vfs.exists(&model) { + warn!(command = label, model = %model, "Lua: модели нет в VFS"); + log.push_back(format!("{label}: нет {model}")); + continue; + } + let origin = qmenu::spawn_origin(&collision, &player, camera.yaw); + info!(command = label, model = %model, "Lua: спавн из меню"); + log.push_back(format!("{label}: {model}")); + pending_spawns.push(SpawnRequest { + model, + origin, + angles: math::Vec3::new(0.0, camera.yaw, 0.0), + }); + } for line in lua.take_output() { log.push_back(line); - while log.len() > 8 { - log.pop_front(); - } + } + while log.len() > 8 { + log.pop_front(); } } @@ -1570,14 +1902,14 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { }; hud(8.0, hud_top + 40.0, 1.4, [1.0, 0.9, 0.65, 1.0], text); } - if !spawn_menu.is_open() { + if !menu_open { hud( 8.0, hud_bottom, 1.5, [0.95, 0.95, 0.8, 1.0], if touch_enabled { - "touch: left stick move | right swipe look | buttons jump/duck".to_string() + "touch: left stick move | right swipe look | MENU/jump/duck".to_string() } else { "WASD move | Space jump | Ctrl duck | V noclip | G spawn | Q menu | 0-9 weapon | ` log" .to_string() @@ -1594,6 +1926,14 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { [0.75, 0.9, 1.0, 0.75], "MOVE".to_string(), ); + // Четвёртая кнопка — меню (аналог Q), над прыжком. + hud( + width as f32 * 0.74, + height as f32 * 0.30, + 1.6, + [0.9, 0.85, 1.0, 0.85], + "MENU".to_string(), + ); hud( width as f32 * 0.74, height as f32 * 0.60, @@ -1620,6 +1960,35 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { // Q-меню поверх HUD: выбор предмета и подсказки по управлению. spawn_menu.draw(&mut renderer, width as f32, height as f32); + // Настоящее меню Sandbox: снимок панелей из Lua, примитивами клиента. + if lua_ui_open { + if let Some(lua) = &lua { + lua.ui_snapshot_into(&mut ui_commands); + } + let stats = ui_overlay::draw(&mut renderer, &ui_commands, width as f32, height as f32); + if frames_drawn % 120 == 0 { + debug!( + commands = ui_commands.len(), + rects = stats.rects, + outlines = stats.outlines, + texts = stats.texts, + skipped = stats.skipped, + glyphs = stats.glyphs, + "Lua UI: кадр" + ); + // Ошибки Paint/PerformLayout не фатальны, но не должны + // теряться: показываем их в логе, а не только в памяти Lua. + if let Some(lua) = &lua { + for error in lua.ui_take_errors().iter().take(4) { + warn!(error = %error, "Lua UI: ошибка панели"); + } + } + } + } else if !ui_commands.is_empty() { + // Строки снимка больше не нужны, память буфера остаётся. + ui_commands.clear(); + } + if frames_drawn % 60 == 0 { tracing::debug!( z = player.position.z, @@ -1818,15 +2187,62 @@ fn apply_menu_mouse( } } -/// Закрывает Q-меню и возвращает захват мыши. -fn close_menu( +/// Открывает/закрывает меню: настоящее Lua-меню, если оно загрузилось, +/// иначе встроенное. Возвращает новое состояние Lua-меню. +/// +/// Одна точка входа нужна, чтобы Q, Esc и сенсорная кнопка одинаково +/// возвращали захват мыши и не оставляли два меню открытыми сразу. +#[allow(clippy::too_many_arguments)] +fn set_menu_open( + open: bool, + lua_ui_ready: bool, + lua: Option<&glua::LuaRuntime>, menu: &mut SpawnMenu, sdl: &sdl3::Sdl, window: &sdl3::video::Window, relative_mouse: bool, -) { - menu.set_open(false); - apply_menu_mouse(false, sdl, window, relative_mouse); +) -> bool { + if lua_ui_ready && let Some(lua) = lua { + lua.ui_toggle(open); + // Реальное состояние важнее запрошенного: если корень уже удалён, + // меню не открылось, и захват мыши снимать нельзя. + let visible = lua.ui_visible(); + apply_menu_mouse(visible, sdl, window, relative_mouse); + return visible; + } + menu.set_open(open); + apply_menu_mouse(open, sdl, window, relative_mouse); + false +} + +/// Номер кнопки мыши в терминах GMod (`MOUSE_LEFT`/`MOUSE_RIGHT`/...). +fn mouse_button_code(button: MouseButton) -> i32 { + match button { + MouseButton::Left => 1, + MouseButton::Right => 2, + MouseButton::Middle => 3, + MouseButton::X1 => 4, + MouseButton::X2 => 5, + _ => 1, + } +} + +/// Модель ENT-класса: те же поля, что у списка `qmenu`, только по запросу +/// из `gm_spawnsent`. +fn entity_model(lua: &glua::LuaRuntime, class: &str) -> Option { + ["Model", "WorldModel", "__sr_preview_model"] + .into_iter() + .filter_map(|field| lua.entity_string(class, field)) + .find_map(|value| qmenu::clean_model_path(&value)) +} + +/// Мировая модель SWEP-класса для `gm_spawnswep`. +fn weapon_model(lua: &glua::LuaRuntime, class: &str) -> Option { + ["WorldModel", "ViewModel"] + .into_iter() + .filter_map(|field| lua.weapon_string(class, field)) + .filter(|value| !value.trim().is_empty()) + .find_map(|value| qmenu::clean_model_path(&value)) } fn save_png(path: &Path, width: u32, height: u32, rgba: &[u8]) -> anyhow::Result<()> { diff --git a/crates/client/src/qmenu.rs b/crates/client/src/qmenu.rs index 22f5c26..8bebdb5 100644 --- a/crates/client/src/qmenu.rs +++ b/crates/client/src/qmenu.rs @@ -603,7 +603,7 @@ fn truncate(value: &str, max_chars: usize) -> String { } /// Нормализует путь модели: `\` → `/`, без пустых компонентов и `..`. -fn clean_model_path(raw: &str) -> Option { +pub(crate) fn clean_model_path(raw: &str) -> Option { let path = raw.trim().replace('\\', "/"); let mut parts: Vec<&str> = Vec::new(); for part in path.split('/') { diff --git a/crates/client/src/touch.rs b/crates/client/src/touch.rs index dde9d0b..3f4d8b5 100644 --- a/crates/client/src/touch.rs +++ b/crates/client/src/touch.rs @@ -1,7 +1,7 @@ //! Виртуальные органы управления для сенсорного экрана. //! //! Левый стик — ходьба (те же `wish_forward`/`wish_right`, что у WASD), -//! правая половина — обзор (свайп = мышь), кнопки справа снизу — прыжок и +//! правая половина — обзор (свайп = мышь), кнопки справа — меню, прыжок и //! присед. Касания обрабатываются в общем цикле `map`, поэтому на телефоне //! работают те же правила движения, коллизий и физики, что и с клавиатуры. @@ -42,6 +42,14 @@ const DUCK_BUTTON: ButtonRect = ButtonRect { max_y: 0.97, }; +/// Меню (аналог Q): над прыжком, чтобы не задевать стик и обзор. +const MENU_BUTTON: ButtonRect = ButtonRect { + min_x: 0.70, + max_x: 0.93, + min_y: 0.22, + max_y: 0.47, +}; + /// Состояние сенсорного управления. #[derive(Debug, Default)] pub struct TouchControls { @@ -66,12 +74,24 @@ impl TouchControls { } } + /// Возвращает true, если касание пришлось на кнопку меню. + /// + /// Кнопка проверяется и при открытом меню: ею же меню закрывается + /// (на Android нет Esc), а остальные касания в этот момент уходят в + /// Lua-панели. + pub fn menu_button(&self, x: f32, y: f32) -> bool { + self.enabled && MENU_BUTTON.contains(x, y) + } + /// Возвращает true, если касание было поглощено сенсорным управлением. pub fn finger_down(&mut self, finger_id: u64, x: f32, y: f32) -> bool { if !self.enabled { return false; } + if MENU_BUTTON.contains(x, y) { + return true; + } if JUMP_BUTTON.contains(x, y) { self.jump_finger = Some(finger_id); self.jump_pending = true; @@ -210,8 +230,8 @@ mod tests { #[test] fn look_rotates_and_buttons_hold() { let mut touch = TouchControls::new(true); - assert!(touch.finger_down(2, 0.8, 0.3)); - let (yaw, pitch) = touch.finger_motion(2, 0.9, 0.3, 0.1, 0.0).expect("look"); + assert!(touch.finger_down(2, 0.5, 0.3)); + let (yaw, pitch) = touch.finger_motion(2, 0.6, 0.3, 0.1, 0.0).expect("look"); assert!(yaw < 0.0); assert_eq!(pitch, 0.0); assert!(touch.finger_up(2)); @@ -230,4 +250,21 @@ mod tests { assert_eq!(touch.movement(), (0.0, 0.0)); assert!(!touch.take_jump()); } + + #[test] + fn menu_button_is_above_jump() { + let touch = TouchControls::new(true); + assert!(touch.menu_button(0.8, 0.3)); + assert!(!touch.menu_button(0.8, 0.6)); + assert!(!touch.menu_button(0.2, 0.3)); + let disabled = TouchControls::new(false); + assert!(!disabled.menu_button(0.8, 0.3)); + } + + #[test] + fn menu_button_does_not_start_look() { + let mut touch = TouchControls::new(true); + assert!(touch.finger_down(5, 0.8, 0.3)); + assert!(touch.finger_motion(5, 0.9, 0.3, 0.1, 0.0).is_none()); + } } diff --git a/crates/client/src/ui_overlay.rs b/crates/client/src/ui_overlay.rs new file mode 100644 index 0000000..92f9388 --- /dev/null +++ b/crates/client/src/ui_overlay.rs @@ -0,0 +1,351 @@ +//! Отрисовка настоящего Lua-меню (`UiCommand`) поверх кадра. +//! +//! Снимок строит `glua::LuaRuntime`: это уже готовые прямоугольники, контуры +//! и строки в пикселях экрана. Рендерер клиента умеет только текст и заливку, +//! поэтому контур рисуется четырьмя тонкими полосами, а текст — bitmap-шрифтом. +//! +//! Лимиты здесь не декоративные: у текстового оверлея фиксированный буфер +//! вершин (64k). Снимок ограничен 4096 командами, но одна широкая полоса +//! превращается в сотни глифов, поэтому бюджет глифов считается заранее и +//! лишние команды пропускаются, а не роняют кадр. + +use glua::{UiCommand, UiKind}; +use render::WorldRenderer; + +/// Сколько глифов разрешено на кадр. Буфер текста — 65536 вершин, один глиф +/// занимает 6 вершин; 10000 глифов (60000 вершин) оставляют запас HUD. +const MAX_GLYPHS: usize = 10_000; + +/// Толщина контура в пикселях. У заливки минимальный глиф — 8×8, поэтому +/// один пиксель всё равно станет восемью; меньшее значение только соврёт. +const OUTLINE: f32 = 1.0; + +/// Минимальный масштаб bitmap-шрифта: мельче глиф нечитаем. +const MIN_TEXT_SCALE: f32 = 0.8; +/// Максимальный масштаб: DermaLarge — 32 px, при scale=8 строка не влезает. +const MAX_TEXT_SCALE: f32 = 3.0; + +/// Что попало в кадр, а что отброшено (для диагностики). +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct OverlayStats { + pub rects: usize, + pub outlines: usize, + pub texts: usize, + pub skipped: usize, + pub glyphs: usize, +} + +/// Рисует снимок меню. Вызывать после HUD: меню — модальное, оно сверху. +pub fn draw( + renderer: &mut WorldRenderer, + commands: &[UiCommand], + width: f32, + height: f32, +) -> OverlayStats { + let mut stats = OverlayStats::default(); + for command in commands { + if !visible(command, width, height) { + stats.skipped += 1; + continue; + } + match command.kind { + UiKind::Rect => { + if command.w <= 0.0 || command.h <= 0.0 || invisible(command.color) { + stats.skipped += 1; + continue; + } + let cost = rect_glyphs(command.w, command.h); + if stats.glyphs + cost > MAX_GLYPHS { + stats.skipped += 1; + continue; + } + renderer.push_rect( + command.x, + command.y, + command.w, + command.h, + linear_color(command.color), + ); + stats.rects += 1; + stats.glyphs += cost; + } + UiKind::Outline => { + if command.w <= 0.0 || command.h <= 0.0 || invisible(command.color) { + stats.skipped += 1; + continue; + } + let edges = outline_rects(command); + let cost: usize = edges.iter().map(|(_, _, w, h)| rect_glyphs(*w, *h)).sum(); + if stats.glyphs + cost > MAX_GLYPHS { + stats.skipped += 1; + continue; + } + let color = linear_color(command.color); + for (x, y, w, h) in edges { + renderer.push_rect(x, y, w, h, color); + } + stats.outlines += 1; + stats.glyphs += cost; + } + UiKind::Text => { + let Some(text) = command.text.as_deref() else { + stats.skipped += 1; + continue; + }; + if text.is_empty() || !command.x.is_finite() || !command.y.is_finite() { + stats.skipped += 1; + continue; + } + let scale = text_scale(command.h); + // Каждый байт строки — отдельный глиф: не-ASCII в bitmap- + // атласе всё равно превращается в квадрат, но бюджет считаем + // по фактической длине, которую обойдёт build_vertices. + let cost = text.len(); + if stats.glyphs + cost > MAX_GLYPHS { + stats.skipped += 1; + continue; + } + renderer.push_text( + command.x, + command.y, + scale, + linear_color(command.color), + text, + ); + stats.texts += 1; + stats.glyphs += cost; + } + } + } + stats +} + +/// Точка клика из отладочного флага: «x,y» или подстрока текста. +/// +/// По тексту ищется самая ранняя команда (панели рисуются сверху вниз, так +/// что вкладки и подписи находятся раньше содержимого). Для скриншот-теста +/// этого достаточно и не нужен xdotool по скрытому окну. +pub fn click_point(commands: &[UiCommand], spec: &str) -> Option<(f32, f32)> { + let spec = spec.trim(); + if let Some(point) = parse_point(spec) { + return Some(point); + } + if spec.is_empty() { + return None; + } + let lower = spec.to_ascii_lowercase(); + let exact = commands.iter().find(|command| { + command.kind == UiKind::Text + && command + .text + .as_deref() + .is_some_and(|text| text.eq_ignore_ascii_case(spec)) + }); + let command = exact.or_else(|| { + commands.iter().find(|command| { + command.kind == UiKind::Text + && command + .text + .as_deref() + .is_some_and(|text| text.to_ascii_lowercase().contains(&lower)) + }) + })?; + if !command.x.is_finite() || !command.y.is_finite() { + return None; + } + let width = if command.w.is_finite() { + command.w.max(8.0) + } else { + 8.0 + }; + let height = if command.h.is_finite() { + command.h.max(8.0) + } else { + 8.0 + }; + // Чуть выше середины: подпись вкладки может лежать на границе с + // содержимым, и клик ровно по центру попадал бы в панель под вкладкой. + Some((command.x + width * 0.5, command.y + height * 0.25)) +} + +/// Полностью ли команда в кадре (частично видимую рисует GPU-обрезка). +fn visible(command: &UiCommand, width: f32, height: f32) -> bool { + if !command.x.is_finite() || !command.y.is_finite() { + return false; + } + let w = if command.w.is_finite() { + command.w.max(0.0) + } else { + 0.0 + }; + let h = if command.h.is_finite() { + command.h.max(0.0) + } else { + 0.0 + }; + match command.kind { + UiKind::Text => command.y < height && command.y + h.max(8.0) > 0.0 && command.x < width, + UiKind::Rect | UiKind::Outline => { + command.x < width && command.y < height && command.x + w > 0.0 && command.y + h > 0.0 + } + } +} + +/// Нулевая альфа — команда невидима (Lua-скины часто рисуют прозрачное). +fn invisible(color: [f32; 4]) -> bool { + !color[3].is_finite() || color[3] <= 0.01 +} + +/// Цвета VGUI заданы в sRGB (Color(128,...)), а цель рендера — sRGB-текстура: +/// шейдер обязан получить линейные компоненты, иначе серый 128 на экране +/// станет 188 и меню выглядит выцветшим. +fn linear_color(color: [f32; 4]) -> [f32; 4] { + let channel = |value: f32| { + let value = if value.is_finite() { + value.clamp(0.0, 1.0) + } else { + 0.0 + }; + if value <= 0.04045 { + value / 12.92 + } else { + ((value + 0.055) / 1.055).powf(2.4) + } + }; + [ + channel(color[0]), + channel(color[1]), + channel(color[2]), + color[3].clamp(0.0, 1.0), + ] +} + +/// Сколько «глифов» (квадов по 6 вершин) займёт прямоугольник. +/// +/// У `push_rect` теперь отдельный квад: тонкая линия не растягивается в +/// квадрат, а стоит столько же, сколько один глиф текста. +fn rect_glyphs(width: f32, height: f32) -> usize { + if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 { + return 0; + } + 1 +} + +/// Четыре полосы контура: верх, низ, лево, право. +fn outline_rects(command: &UiCommand) -> [(f32, f32, f32, f32); 4] { + let (x, y, w, h) = (command.x, command.y, command.w, command.h); + let t = OUTLINE.min(w * 0.5).min(h * 0.5).max(0.0); + [ + (x, y, w, t), + (x, y + h - t, w, t), + (x, y + t, t, (h - 2.0 * t).max(0.0)), + (x + w - t, y + t, t, (h - 2.0 * t).max(0.0)), + ] +} + +/// Масштаб bitmap-шрифта по высоте шрифта Derma. +/// +/// Глиф 8×8, а строка DermaDefault — 13 px. `h / 12` даёт ширину около +/// 8,7 px на символ: чуть шире, чем считает `surface.GetTextSize`, зато +/// буквы читаются (при `h / 8` строка вылезает из панелей). +fn text_scale(height: f32) -> f32 { + if !height.is_finite() { + return 1.0; + } + (height / 12.0).clamp(MIN_TEXT_SCALE, MAX_TEXT_SCALE) +} + +/// Разбирает «x,y» или «x y». +fn parse_point(text: &str) -> Option<(f32, f32)> { + let mut parts = text + .split(|c: char| c == ',' || c.is_whitespace()) + .filter(|part| !part.is_empty()); + let x: f32 = parts.next()?.parse().ok()?; + let y: f32 = parts.next()?.parse().ok()?; + if parts.next().is_some() || !x.is_finite() || !y.is_finite() { + return None; + } + Some((x, y)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn text_command(text: &str, x: f32, y: f32, w: f32, h: f32) -> UiCommand { + UiCommand { + kind: UiKind::Text, + x, + y, + w, + h, + color: [1.0, 1.0, 1.0, 1.0], + text: Some(text.to_string()), + } + } + + #[test] + fn click_finds_exact_text_before_substring() { + let commands = vec![ + text_command("Weapons", 10.0, 4.0, 50.0, 13.0), + text_command("Weapons of mass", 100.0, 100.0, 80.0, 13.0), + ]; + assert_eq!(click_point(&commands, "weapons"), Some((35.0, 7.25))); + assert_eq!(click_point(&commands, "mass"), Some((140.0, 103.25))); + assert_eq!(click_point(&commands, "10,20"), Some((10.0, 20.0))); + assert_eq!(click_point(&commands, "нет такого"), None); + } + + #[test] + fn offscreen_commands_are_skipped() { + let rect = UiCommand { + kind: UiKind::Rect, + x: 2000.0, + y: 10.0, + w: 100.0, + h: 20.0, + color: [1.0, 0.0, 0.0, 1.0], + text: None, + }; + assert!(!visible(&rect, 1280.0, 720.0)); + // Частично видимый прямоугольник рисуем: GPU обрежет по окну. + let mut partial = rect.clone(); + partial.x = 1200.0; + assert!(visible(&partial, 1280.0, 720.0)); + } + + #[test] + fn text_scale_is_readable_and_bounded() { + assert!((text_scale(13.0) - 1.0833).abs() < 0.001); + assert_eq!(text_scale(1.0), MIN_TEXT_SCALE); + assert_eq!(text_scale(500.0), MAX_TEXT_SCALE); + } + + #[test] + fn srgb_colors_become_linear_before_the_shader() { + let white = linear_color([1.0, 1.0, 1.0, 1.0]); + assert!((white[0] - 1.0).abs() < 1e-6); + // sRGB 128 → линейный ~0.2158: на экране снова будет 128, а не 188. + let gray = linear_color([128.0 / 255.0, 0.0, 0.0, 0.5]); + assert!((gray[0] - 0.2158).abs() < 0.001, "gray={}", gray[0]); + assert_eq!(gray[3], 0.5); + } + + #[test] + fn outline_stays_inside_the_rect() { + let outline = UiCommand { + kind: UiKind::Outline, + x: 10.0, + y: 20.0, + w: 100.0, + h: 40.0, + color: [1.0, 1.0, 1.0, 1.0], + text: None, + }; + let edges = outline_rects(&outline); + assert_eq!(edges[0], (10.0, 20.0, 100.0, 1.0)); + assert_eq!(edges[1], (10.0, 59.0, 100.0, 1.0)); + assert_eq!(edges[2], (10.0, 21.0, 1.0, 38.0)); + assert_eq!(edges[3], (109.0, 21.0, 1.0, 38.0)); + } +} diff --git a/crates/lua/src/lib.rs b/crates/lua/src/lib.rs index a4b581b..5d44f8f 100644 --- a/crates/lua/src/lib.rs +++ b/crates/lua/src/lib.rs @@ -27,6 +27,9 @@ pub enum LuaError { type TimerQueue = Rc>>; type Output = Rc>>; +/// Очередь `RunConsoleCommand`: клиент забирает её раз в кадр и решает, +/// какие команды он умеет исполнять локально (например, `gm_spawn`). +type ConsoleQueue = Rc>>>; /// Итог загрузки геймода. #[derive(Debug, Default, Clone, PartialEq, Eq)] @@ -104,6 +107,7 @@ pub struct LuaRuntime { lua: Lua, timers: TimerQueue, output: Output, + console: ConsoleQueue, include: Rc>, } @@ -113,9 +117,10 @@ impl LuaRuntime { let lua = Lua::new(); let timers: TimerQueue = Rc::new(RefCell::new(Vec::new())); let output: Output = Rc::new(RefCell::new(Vec::new())); + let console: ConsoleQueue = Rc::new(RefCell::new(Vec::new())); let include = Rc::new(RefCell::new(include::IncludeState::default())); - install_api(&lua, &timers, &output)?; + install_api(&lua, &timers, &output, &console)?; gmod::install(&lua)?; stdlib::install(&lua, "unknown")?; include::install(&lua, include.clone())?; @@ -125,6 +130,7 @@ impl LuaRuntime { lua, timers, output, + console, include, }) } @@ -695,6 +701,7 @@ impl LuaRuntime { lua, &Rc::new(RefCell::new(Vec::new())), &Rc::new(RefCell::new(Vec::new())), + &Rc::new(RefCell::new(Vec::new())), ) } @@ -782,6 +789,16 @@ impl LuaRuntime { std::mem::take(&mut *self.output.borrow_mut()) } + /// Забирает вызовы `RunConsoleCommand` с прошлого кадра. + /// + /// Скрипты меню (например, `SpawnIcon.DoClick`) не зовут concommand + /// напрямую: в GMod команда уходит в консоль, а исполняет её сервер. + /// Клиент забирает очередь и обрабатывает те команды, которые умеет + /// (спавн пропа), остальные игнорирует. + pub fn take_console_commands(&self) -> Vec> { + std::mem::take(&mut *self.console.borrow_mut()) + } + /// Имена зарегистрированных concommand'ов. pub fn commands(&self) -> Vec { let table: Table = match self.lua.globals().get("__sr_commands") { @@ -812,7 +829,12 @@ impl LuaRuntime { } /// Устанавливает GMod-подобный API в глобальную таблицу. -fn install_api(lua: &Lua, timers: &TimerQueue, output: &Output) -> Result<(), mlua::Error> { +fn install_api( + lua: &Lua, + timers: &TimerQueue, + output: &Output, + console: &ConsoleQueue, +) -> Result<(), mlua::Error> { let globals = lua.globals(); // print / Msg: накопление вывода. @@ -1142,10 +1164,21 @@ fn install_api(lua: &Lua, timers: &TimerQueue, output: &Output) -> Result<(), ml })?, )?; - // RunConsoleCommand — заглушка. + // RunConsoleCommand: в GMod уходит в консоль, здесь — в очередь для + // клиента. `gm_spawn` из Q-меню доходит до `map` и спавнит настоящий проп. + let console_queue = console.clone(); globals.set( "RunConsoleCommand", - lua.create_function(|_, _: MultiValue| Ok(()))?, + lua.create_function(move |_, args: MultiValue| { + let mut command = Vec::with_capacity(args.len()); + for value in args.iter() { + command.push(lua_value_to_string(value)); + } + if !command.is_empty() { + console_queue.borrow_mut().push(command); + } + Ok(()) + })?, )?; Ok(()) @@ -2056,6 +2089,22 @@ result = out ); } + #[test] + fn run_console_command_is_queued_for_the_client() { + let rt = LuaRuntime::new().expect("рантайм"); + rt.run( + "console.lua", + br#"RunConsoleCommand("gm_spawn", "models/props_junk/watermelon01.mdl", 0, "")"#, + ) + .expect("скрипт"); + let commands = rt.take_console_commands(); + assert_eq!(commands.len(), 1); + assert_eq!(commands[0][0], "gm_spawn"); + assert_eq!(commands[0][1], "models/props_junk/watermelon01.mdl"); + // Очередь забирается один раз. + assert!(rt.take_console_commands().is_empty()); + } + #[test] fn concommands_register() { let rt = LuaRuntime::new().expect("рантайм"); diff --git a/crates/lua/src/ui.rs b/crates/lua/src/ui.rs index 679779a..07cb7c6 100644 --- a/crates/lua/src/ui.rs +++ b/crates/lua/src/ui.rs @@ -104,28 +104,70 @@ impl crate::LuaRuntime { .unwrap_or(false) } + /// Забирает ошибки Paint/PerformLayout/ввода, накопленные Lua-слоем. + /// + /// Ошибка одной панели не должна ронять меню и кадр: клиент показывает + /// их в консоли и продолжает рисовать остальные. + pub fn ui_take_errors(&self) -> Vec { + catch_unwind(AssertUnwindSafe(|| -> Option> { + let ui: Table = self.lua.globals().get("__sr_ui").ok()?; + let take: Function = ui.get("take_errors").ok()?; + let Value::Table(list) = take.call::(()).ok()? else { + return None; + }; + let mut errors = Vec::new(); + for pair in list.sequence_values::() { + if errors.len() >= 64 { + break; + } + if let Ok(Value::String(text)) = pair + && let Ok(text) = text.to_str() + { + errors.push(text.to_string()); + } + } + Some(errors) + })) + .ok() + .flatten() + .unwrap_or_default() + } + /// Снимок видимого дерева панелей на текущий кадр. /// /// Пустой список, если меню закрыто или скрипты ещё не создали панели. /// Число команд ограничено [`MAX_COMMANDS`], глубина — [`MAX_UI_DEPTH`] /// (проверяется в Lua, здесь — вторая линия защиты). pub fn ui_snapshot(&self) -> Vec { - catch_unwind(AssertUnwindSafe(|| self.ui_snapshot_inner())) - .ok() - .flatten() - .unwrap_or_default() + let mut commands = Vec::new(); + self.ui_snapshot_into(&mut commands); + commands } - fn ui_snapshot_inner(&self) -> Option> { + /// То же, что [`Self::ui_snapshot`], но переиспользует буфер вызывающего: + /// рендер снимает меню каждый кадр, и лишние аллокации ему не нужны. + /// Буфер очищается даже при ошибке Lua. + pub fn ui_snapshot_into(&self, commands: &mut Vec) { + commands.clear(); + let result = catch_unwind(AssertUnwindSafe(|| self.ui_snapshot_inner(commands))); + if result.is_err() { + commands.clear(); + } + } + + fn ui_snapshot_inner(&self, commands: &mut Vec) { // Отложенные `timer.Simple(0, ...)` создают вкладки и контент // spawnmenu; без этого шага снимок был бы пустым деревом. let _ = self.tick(); - let ui: Table = self.lua.globals().get("__sr_ui").ok()?; - let snapshot: Function = ui.get("snapshot").ok()?; - let Value::Table(list) = snapshot.call::(()).ok()? else { - return None; + let Ok(ui) = self.lua.globals().get::("__sr_ui") else { + return; + }; + let Ok(snapshot) = ui.get::("snapshot") else { + return; + }; + let Ok(Value::Table(list)) = snapshot.call::(()) else { + return; }; - let mut commands = Vec::new(); for pair in list.sequence_values::() { if commands.len() >= MAX_COMMANDS { break; @@ -190,7 +232,6 @@ impl crate::LuaRuntime { text, }); } - Some(commands) } /// Передаёт мышь в Lua-дерево панелей. @@ -282,6 +323,40 @@ mod tests { assert!((text.y - 25.0).abs() < 0.01, "y={}", text.y); } + #[test] + fn snapshot_into_reuses_the_buffer() { + let rt = runtime(); + rt.run( + "ui_reuse.lua", + br#" + local p = vgui.Create( "Panel" ) + p:SetSize( 100, 50 ) + function p:Paint( w, h ) + surface.DrawRect( 0, 0, w, h ) + draw.SimpleText( "Hi", "DermaDefault", 0, 0, color_white ) + end + __sr_ui.root = p + __sr_ui.visible = true + "#, + ) + .expect("панель создана"); + let mut commands = Vec::new(); + rt.ui_snapshot_into(&mut commands); + let first = commands.len(); + assert!(first >= 2, "команды: {commands:?}"); + rt.ui_snapshot_into(&mut commands); + assert_eq!(commands.len(), first, "буфер не должен накапливать команды"); + } + + #[test] + fn ui_errors_are_taken_once() { + let rt = runtime(); + rt.run("ui_error.lua", br#"__sr_ui.log_error( "boom" )"#) + .expect("ошибка записана"); + assert_eq!(rt.ui_take_errors(), vec!["boom".to_string()]); + assert!(rt.ui_take_errors().is_empty()); + } + #[test] fn snapshot_respects_command_limit() { let rt = runtime(); diff --git a/crates/lua/src/ui/bootstrap.lua b/crates/lua/src/ui/bootstrap.lua index a43d1c1..4cd7aa8 100644 --- a/crates/lua/src/ui/bootstrap.lua +++ b/crates/lua/src/ui/bootstrap.lua @@ -278,17 +278,39 @@ function surface.DrawLine(x1, y1, x2, y2) emit("rect", x, y, w, h, nil, nil) end +-- Текстуры скина у нас не загружаются: DrawTexturedRect рисует заливку +-- текущим цветом. У Derma это часто белая плитка-фон — на экране она давала +-- бы сплошную белую панель с белым текстом. Крупные непрозрачные белые +-- текстуры подменяем тёмным фоном; мелкие (иконки, подсветка наведения) +-- оставляем, чтобы они оставались заметными. +local function textured_color(width, height) + local r, g, b, a = current_draw_color[1], current_draw_color[2], current_draw_color[3], current_draw_color[4] + local large = number(width, 0) > 24 or number(height, 0) > 24 + if a >= 230 and r >= 235 and g >= 235 and b >= 235 then + if large then + return { r = 38, g = 41, b = 47, a = a } + end + -- Мелкие белые плитки — иконки без текстуры: серый квадрат читается + -- как значок, белый — как дыра в тёмном меню. + return { r = 105, g = 110, b = 120, a = a } + end + return nil +end + +local function emit_textured_rect(x, y, w, h) + emit("rect", x, y, w, h, textured_color(w, h), nil) +end + function surface.DrawTexturedRect(x, y, w, h) - -- Текстур у нас нет; заливка текущим цветом сохраняет геометрию панели. - emit("rect", x, y, w, h, nil, nil) + emit_textured_rect(x, y, w, h) end function surface.DrawTexturedRectUV(x, y, w, h) - emit("rect", x, y, w, h, nil, nil) + emit_textured_rect(x, y, w, h) end function surface.DrawTexturedRectRotated(x, y, w, h) - emit("rect", x, y, w, h, nil, nil) + emit_textured_rect(x, y, w, h) end function surface.DrawCircle(x, y, radius) @@ -1323,6 +1345,20 @@ function panel_meta:InsertAtTop(child) end function panel_meta:GetChildCount() return #(self.__children or {}) end function panel_meta:ChildCount() return #(self.__children or {}) end +function panel_meta:HasChildren() return #(self.__children or {}) > 0 end +function panel_meta:ChildrenSize() + -- Размер контейнера по детям: DTileLayout через него выставляет высоту + -- сетки иконок. Без этого высота оставалась 0, и попадания мыши в + -- иконки (find_target проверяет границы) не срабатывали. + local max_x, max_y = 0, 0 + for _, child in ipairs(self.__children or {}) do + if child:IsVisible() then + max_x = math.max(max_x, (child.x or 0) + (child.w or 0)) + max_y = math.max(max_y, (child.y or 0) + (child.h or 0)) + end + end + return max_x, max_y +end function panel_meta:GetChild(index) return (self.__children or {})[index] end function panel_meta:Add(value) if isstring(value) then @@ -1565,6 +1601,9 @@ function panel_meta:IsEnabled() return self.__enabled ~= false end function panel_meta:SetEnabled(value) self.__enabled = value and true or false end function panel_meta:SetDisabled(value) self.__enabled = not (value and true or false) end function panel_meta:GetDisabled() return self.__enabled == false end +function panel_meta:HasFocus() return self.__has_focus == true end +function panel_meta:SetOpenSize(value) self.__open_size = number(value, 0) end +function panel_meta:GetOpenSize() return self.__open_size or 0 end function panel_meta:LocalToScreen(x, y) local screen_x = number(x, 0) @@ -1614,6 +1653,20 @@ function panel_meta:SizeToChildren(wide, tall) if tall then self.h = max_y end end function panel_meta:SizeToContents() + -- Для контейнера «содержимое» — это дети, а не текст: DListLayout и + -- DScrollPanel зовут SizeToContents до раскладки, и если вернуть размер + -- подписи (обычно 0), дерево спавн-меню схлопывается и ветки наезжают + -- друг на друга. У пустой панели остаётся текстовый размер. + local children = self.__children or {} + if #children > 0 then + local max_x, max_y = 0, 0 + for _, child in ipairs(children) do + max_x = math.max(max_x, (child.x or 0) + (child.w or 0)) + max_y = math.max(max_y, (child.y or 0) + (child.h or 0)) + end + self:SetSize(max_x, max_y) + return + end local width, height = self:GetContentSize() self:SetSize(width, height) end @@ -1713,6 +1766,31 @@ function panel_meta:GetContentSize() local inset_x, inset_y = self:GetTextInset() return width + inset_x * 2, height + inset_y * 2 end +function panel_meta:GetTextSize() + surface.SetFont(self.__font or "DermaDefault") + local value = self.__text or "" + if value:sub(1, 1) == "#" then value = language.GetPhrase(value) end + return surface.GetTextSize(value) +end + +-- Движковый метод DTextEntry, который скин вызывает из Paint. В GMod это +-- часть движка, а не Lua-класса; без него поле поиска рисуется с ошибкой. +function panel_meta:DrawTextEntryText(text_color) + local value = self:GetText() + if value == "" and self.__placeholder ~= nil then + value = language.GetPhrase(self.__placeholder) + end + if value == "" then return end + surface.SetFont(self:GetFont()) + surface.SetTextColor(text_color or color_black) + local _, text_height = surface.GetTextSize(value) + local inset_x, inset_y = self:GetTextInset() + surface.SetTextPos( + inset_x, + inset_y + math.max((self:GetTall() - text_height) * 0.5, 0) + ) + surface.DrawText(value) +end function panel_meta:SetAutoStretchVertical(value) self.__auto_stretch = value and true or false end function panel_meta:SetDoubleClickingEnabled(value) self.__double_click = value and true or false end function panel_meta:SetIsToggle(value) self.__is_toggle = value and true or false end @@ -2931,11 +3009,31 @@ function __sr_ui_install_fallbacks() surface.DrawRect(0, 0, w, h) local name = self.__model or "" name = name:match("([^/\\]+)$") or name - if name ~= "" then - surface.SetFont("DermaDefault") - surface.SetTextColor(220, 220, 220, 255) - surface.SetTextPos(2, math.max(h - 15, 2)) - surface.DrawText(name) + if name == "" then return end + surface.SetFont("DermaDefaultSmall") + surface.SetTextColor(220, 220, 220, 255) + -- Имя модели в две строки по 7 символов: bitmap-шрифт клиента шире + -- метрики surface.GetTextSize, и полное имя одного строкой наезжает + -- на соседние иконки. Если имя длиннее 14 символов, сохраняем начало + -- и конец — по ним модели одного набора различимы. + local per_line = 7 + local first = name + local second = nil + if #name > per_line then + if #name <= per_line * 2 then + first = name:sub(1, per_line) + second = name:sub(per_line + 1) + else + first = name:sub(1, per_line) + second = name:sub(-per_line) + end + end + local y = math.max(h - 18, 2) + surface.SetTextPos(2, y) + surface.DrawText(first) + if second ~= nil and second ~= "" then + surface.SetTextPos(2, y + 9) + surface.DrawText(second) end end register("ModelImage", ModelImage, "DPanel") diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index dce01ee..853feba 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -10,7 +10,11 @@ pub struct TextVertex { pub color: [f32; 4], } -/// Строка текста, подготовленная к отрисовке. +/// Строка текста или залитый прямоугольник, подготовленный к отрисовке. +/// +/// `size = Some((w, h))` — это прямоугольник: глиф 127 растягивается ровно +/// на w×h. Такой режим нужен меню и HUD: масштабировать квадратный глиф по +/// высоте нельзя, иначе тонкая вертикальная линия становится широким блоком. #[derive(Debug, Clone)] pub struct TextItem { pub x: f32, @@ -18,6 +22,7 @@ pub struct TextItem { pub scale: f32, pub color: [f32; 4], pub text: String, + pub size: Option<[f32; 2]>, } /// Размер атласа глифов. @@ -89,10 +94,32 @@ pub fn glyph_uv(code: u8) -> ([f32; 2], [f32; 2]) { ) } -/// Собирает вершины для списка строк. +/// Собирает вершины для списка строк и прямоугольников. pub fn build_vertices(items: &[TextItem]) -> Vec { let mut out = Vec::new(); for item in items { + if let Some([width, height]) = item.size { + if width <= 0.0 || height <= 0.0 { + continue; + } + // Тот же служебный сплошной глиф, но квад ровно по размеру. + let (uv0, uv1) = glyph_uv(127); + let corners = [ + ([item.x, item.y], [uv0[0], uv0[1]]), + ([item.x + width, item.y], [uv1[0], uv0[1]]), + ([item.x + width, item.y + height], [uv1[0], uv1[1]]), + ([item.x, item.y + height], [uv0[0], uv1[1]]), + ]; + let indices = [0usize, 1, 2, 0, 2, 3]; + for i in indices { + out.push(TextVertex { + pos: corners[i].0, + uv: corners[i].1, + color: item.color, + }); + } + continue; + } let mut x = item.x; for byte in item.text.bytes() { if byte == b'\n' { @@ -159,8 +186,26 @@ mod tests { scale: 1.0, color: [1.0, 1.0, 1.0, 1.0], text: "Hi".to_string(), + size: None, }]; let v = build_vertices(&items); assert_eq!(v.len(), 12); } + + /// Прямоугольник — один квад, независимо от соотношения сторон. + #[test] + fn rectangle_is_one_quad() { + let items = vec![TextItem { + x: 10.0, + y: 20.0, + scale: 1.0, + color: [1.0, 0.5, 0.25, 1.0], + text: String::new(), + size: Some([1.0, 400.0]), + }]; + let v = build_vertices(&items); + assert_eq!(v.len(), 6); + assert_eq!(v[0].pos, [10.0, 20.0]); + assert_eq!(v[2].pos, [11.0, 420.0]); + } } diff --git a/crates/render/src/world_renderer.rs b/crates/render/src/world_renderer.rs index 52d45a5..24ee4c5 100644 --- a/crates/render/src/world_renderer.rs +++ b/crates/render/src/world_renderer.rs @@ -1457,6 +1457,7 @@ impl WorldRenderer { scale, color, text: text.into(), + size: None, }); } @@ -1465,23 +1466,23 @@ impl WorldRenderer { self.text.items.clear(); } - /// Заливает прямоугольник цветом (затемнение под меню). + /// Заливает прямоугольник цветом (фон меню/панели, контуры). /// - /// Панелей у HUD пока нет, поэтому берём служебный сплошной глиф 127 из - /// атласа и растягиваем его сеткой. Годится только для прямоугольников, - /// залитых одним цветом, — ровно это нужно модальному оверлею. + /// Это отдельный квад, а не растянутый квадратный глиф: у тонких + /// вертикальных линий (дерево категорий, разделители) иначе ширина + /// становилась бы равна высоте. pub fn push_rect(&mut self, x: f32, y: f32, width: f32, height: f32, color: [f32; 4]) { if width <= 0.0 || height <= 0.0 { return; } - let scale = (height / 8.0).max(1.0); - let step = 8.0 * scale; - let cols = (width / step).ceil().max(1.0) as usize; - let rows = (height / step).ceil().max(1.0) as usize; - let line: String = std::iter::repeat_n('\u{7f}', cols).collect(); - for row in 0..rows { - self.push_text(x, y + row as f32 * step, scale, color, line.clone()); - } + self.text.items.push(TextItem { + x, + y, + scale: 1.0, + color, + text: String::new(), + size: Some([width, height]), + }); } /// Загружает оверлей на GPU (размер экрана + вершины). diff --git a/docs/DIARY.md b/docs/DIARY.md index ce22685..2d680d3 100644 --- a/docs/DIARY.md +++ b/docs/DIARY.md @@ -8,6 +8,42 @@ --- +## 0.8. Q-меню Sandbox в клиенте (2026-10-24, ветка `feat/ui-render`) + +Настоящее меню из `feat/derma` подключено к клиенту; самодельный `qmenu.rs` +остался только fallback'ом. + +- `client/src/map.rs`: после `load_scripts` вызывается + `lua.load_ui(/garrysmod)`. Признак успеха — живой `g_SpawnMenu`; + иначе Q открывает встроенное меню (проверка: `SOURCE_RS_NO_LUA_UI=1`). + `ui_set_screen` вызывается на старте и на resize — мышь не уезжает. +- Рендер: `client/src/ui_overlay.rs` переводит `UiCommand` в `push_rect` + (теперь это точный квад, а не растянутый глиф — тонкие линии дерева больше + не превращаются в квадраты) и `push_text` (scale = h/12). Цвета VGUI + sRGB→linear, иначе серый 128 на экране становился 188. Бюджет — 10k глифов + на кадр; команды за пределами экрана отбрасываются. +- Ввод: Q/Esc управляют меню, Esc больше не выходит из клиента при открытом + меню. Пока меню открыто, игрок заморожен, ЛКМ не стреляет, мышь уходит в + `ui_mouse` (`move/down/up/wheel`), колесо не крутит физган. На Android + четвёртая кнопка MENU (над JUMP) открывает/закрывает меню; касания в меню + переводятся из нормализованных координат SDL в пиксели и идут в `ui_mouse`. +- Спавн: `RunConsoleCommand` в Lua теперь кладёт вызовы в очередь + (`take_console_commands`). Клиент исполняет `gm_spawn` (модель из + `SpawnIcon.DoClick`), а также `gm_spawnsent`/`gm_spawnswep` через + `entity_string`/`weapon_string`. Проп появляется на полу перед игроком + (`qmenu::spawn_origin`) и проходит обычный путь спавна/рендера. +- Снимок: `ui_snapshot_into` переиспользует буфер, `ui_take_errors` отдаёт + ошибки Paint/PerformLayout в лог клиента. +- Отладка кадров: `SOURCE_RS_QMENU=1`, `SOURCE_RS_UI_CLICK=<текст|x,y>`, + `SOURCE_RS_UI_CLOSE=<кадр>`. +- Грабли: реальный `DTileLayout` зовёт `ChildrenSize`, `DTree_Node` — + `HasChildren`, скин — `HasFocus`/`GetTextSize`/`DrawTextEntryText`; без них + сетка иконок имела высоту 0 и клики не попадали, а дерево схлопывалось. + Модели по-прежнему без превью (две строки имени), HTML-вкладки — заглушки, + длинные подписи дерева обрезаются панелью содержимого. + +--- + ## 0.9. Настоящее Q-меню: Derma + Sandbox spawnmenu (2026-10-24, ветка `feat/derma`) Q-меню больше не рисуется сеткой на Rust: `crates/lua` получил слой VGUI/Derma diff --git a/docs/LUA.md b/docs/LUA.md index 370e07f..19b7bd3 100644 --- a/docs/LUA.md +++ b/docs/LUA.md @@ -47,6 +47,18 @@ - Ввод: поиск верхней видимой панели под курсором, вызовы `OnMousePressed/OnMouseReleased/OnCursorMoved/OnMouseWheeled`; клик по вкладке меняет активную панель. +- Клиент (`crates/client/src/map.rs`, `ui_overlay.rs`) рисует снимок поверх + кадра: прямоугольники — точными квадами, текст — bitmap-шрифтом (scale = + h/12), цвета переводятся из sRGB в линейные. Меню модальное: Q/Esc, мышь и + колесо уходят в Lua, игрок и камера стоят; на Android есть кнопка MENU и + касания переводятся в пиксельные координаты. Если `g_SpawnMenu` не создан, + клиент откатывается на встроенное `qmenu.rs`. +- Спавн из меню: `RunConsoleCommand` складывает вызовы в очередь + (`LuaRuntime::take_console_commands`), клиент исполняет `gm_spawn` (и + `gm_spawnsent`/`gm_spawnswep` через реестры ENT/SWEP) обычным спавном пропа. +- Ограничения реализации: нет превью моделей (только две строки имени), нет + HTML-вкладок Dupes/Saves, приблизительные шрифтовые метрики, нет scissor- + клиппинга панелей (переполнение подписей гасится порядком отрисовки). ## 3. Sandbox (защита от крашедла)