В crates/client подключён UI-слой из glua: после Lua-инициализации вызывается load_ui, и если OnGamemodeLoaded создал g_SpawnMenu, Q открывает настоящее меню Sandbox, иначе остаётся qmenu.rs. Рендер: ui_overlay переводит UiCommand в push_rect/push_text, цвета sRGB→linear, бюджет глифов и отсечение за экраном. push_rect теперь точный квад, а не растянутый глиф: тонкие линии дерева больше не превращаются в квадраты. Ввод: Q/Esc, мышь и колесо уходят в ui_mouse, игрок и камера стоят, ЛКМ не стреляет; ui_set_screen на старте и resize. На Android добавлена кнопка MENU и перевод касаний в пиксельные координаты. Спавн: RunConsoleCommand складывает вызовы в очередь; клиент исполняет gm_spawn (и gm_spawnsent/gm_spawnswep) обычным спавном пропа на полу перед игроком. Попутно: ui_snapshot_into переиспользует буфер, ui_take_errors отдаёт ошибки панелей в лог; в VGUI-слое добавлены HasChildren/ChildrenSize/ HasFocus/GetTextSize/DrawTextEntryText, без которых сетка иконок имела высоту 0 и клики не попадали.
This commit is contained in:
parent
ffd9e736bc
commit
30fe9dfec1
12 changed files with 1205 additions and 84 deletions
|
|
@ -11,6 +11,7 @@ mod net_cmd;
|
|||
mod pack;
|
||||
mod qmenu;
|
||||
mod touch;
|
||||
mod ui_overlay;
|
||||
mod weapon;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
use crate::doctor;
|
||||
use crate::qmenu::{self, MenuAction, SpawnItem, SpawnMenu};
|
||||
use crate::touch::TouchControls;
|
||||
use crate::ui_overlay;
|
||||
use anyhow::Context;
|
||||
use engine::FlyCamera;
|
||||
use fs::VirtualFs;
|
||||
|
|
@ -471,6 +472,10 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
None
|
||||
}
|
||||
};
|
||||
// Настоящее Q-меню Sandbox собирается из Lua-ассетов. Если загрузка не
|
||||
// дошла до `g_SpawnMenu`, клиент остаётся на встроенном `qmenu.rs`.
|
||||
let game_dir = layout.game.clone();
|
||||
let mut lua_ui_ready = false;
|
||||
if let Some(lua) = &lua {
|
||||
// Хост отдаёт Lua доступ к живому миру: `Entity()` и `ents.*`
|
||||
// начинают видеть настоящие сущности, а не заглушку.
|
||||
|
|
@ -482,11 +487,9 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
// `file.*` с песочницой на каталог garrysmod: скрипты GMod читают и
|
||||
// пишут внутри него, а выход наружу должен быть отказом, а не
|
||||
// предупреждением (правило #4 AGENTS.md про ассеты).
|
||||
if let Some(root) = config.assets.as_deref().map(|p| p.join("garrysmod")) {
|
||||
match lua.set_file_root(&root) {
|
||||
Ok(()) => info!(root = %root.display(), "file.* подключён"),
|
||||
Err(err) => warn!("file.* недоступен: {err}"),
|
||||
}
|
||||
match lua.set_file_root(&game_dir) {
|
||||
Ok(()) => info!(root = %game_dir.display(), "file.* подключён"),
|
||||
Err(err) => warn!("file.* недоступен: {err}"),
|
||||
}
|
||||
let _ = lua.set_map(&args.map);
|
||||
let mut loaded = 0usize;
|
||||
|
|
@ -512,12 +515,49 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
// Скрипты GMod: оружие (`lua/weapons`) и сущности (`lua/entities`).
|
||||
// Они пополняют `list.Get("Weapon")` и `scripted_ents` — из этих
|
||||
// реестров строятся меню и выбор оружия.
|
||||
if let Some(root) = config.assets.as_deref().map(|path| path.join("garrysmod")) {
|
||||
let (weapons, entities, failed) = lua.load_scripts(&root);
|
||||
info!(weapons, entities, failed, "Lua: скрипты GMod");
|
||||
if weapons > 0 {
|
||||
log.push_back(format!("lua: оружия {weapons}, энтити {entities}"));
|
||||
let (weapons, entities, failed) = lua.load_scripts(&game_dir);
|
||||
info!(weapons, entities, failed, "Lua: скрипты GMod");
|
||||
if weapons > 0 {
|
||||
log.push_back(format!("lua: оружия {weapons}, энтити {entities}"));
|
||||
}
|
||||
|
||||
// Настоящий клиентский UI: Derma, VGUI, `spawnmenu` и геймод
|
||||
// Sandbox. `OnGamemodeLoaded` создаёт `g_SpawnMenu`; его наличие —
|
||||
// единственный надёжный признак, что меню можно рисовать.
|
||||
// `SOURCE_RS_NO_LUA_UI=1` отключает его для проверки fallback.
|
||||
if std::env::var_os("SOURCE_RS_NO_LUA_UI").is_some() {
|
||||
warn!("Lua UI отключён (SOURCE_RS_NO_LUA_UI): встроенное Q-меню");
|
||||
log.push_back("ui: отключено".to_string());
|
||||
} else {
|
||||
let ui = lua.load_ui(&game_dir);
|
||||
info!(
|
||||
loaded = ui.loaded.len(),
|
||||
errors = ui.errors.len(),
|
||||
"Lua: UI Sandbox загружен"
|
||||
);
|
||||
for error in ui.errors.iter().take(4) {
|
||||
warn!(error = %error, "Lua UI: ошибка загрузки");
|
||||
log.push_back(format!("ui err: {error}"));
|
||||
}
|
||||
if ui.errors.len() > 4 {
|
||||
log.push_back(format!("ui err: ещё {}", ui.errors.len() - 4));
|
||||
}
|
||||
lua_ui_ready = lua
|
||||
.probe("return tostring(IsValid(g_SpawnMenu))")
|
||||
.ok()
|
||||
.flatten()
|
||||
.as_deref()
|
||||
== Some("true");
|
||||
if lua_ui_ready {
|
||||
log.push_back("ui: Lua Sandbox".to_string());
|
||||
info!("Q-меню: настоящий Sandbox из Lua");
|
||||
} else {
|
||||
log.push_back("ui: fallback Q-меню".to_string());
|
||||
warn!("Q-меню: g_SpawnMenu не создан, остаётся встроенное меню");
|
||||
}
|
||||
}
|
||||
while log.len() > 8 {
|
||||
log.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -602,6 +642,12 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
|
||||
let mut gfx = Gfx::new(&window)?;
|
||||
info!(gpu = %gfx.adapter_info().name, "GPU готов");
|
||||
// Раскладка меню (ScrW/ScrH) должна совпадать с пикселями окна, иначе
|
||||
// мышь попадает мимо панелей.
|
||||
if let Some(lua) = &lua {
|
||||
let (width, height) = gfx.size();
|
||||
lua.ui_set_screen(width, height);
|
||||
}
|
||||
let mut renderer = WorldRenderer::new(&gfx, &world.borrow(), args.debug_materials)?;
|
||||
// Цветокор карты из игры: Source хранит его в
|
||||
// `materials/colorcorrection/<карта>.raw` (32³ RGB). Для gm_construct файл
|
||||
|
|
@ -709,24 +755,59 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
.unwrap_or(26.0);
|
||||
// Q-меню спавна (A24): предметы из реестров Lua и spawnlist GMod.
|
||||
// Собирается один раз на старте: списки меняются только загрузкой карты.
|
||||
// Если Lua-меню Sandbox загрузилось, этот список — только fallback и для
|
||||
// отладочных флагов; рисовать его не будем.
|
||||
let mut spawn_menu = SpawnMenu::collect(lua.as_ref(), &vfs);
|
||||
if let Ok(needle) = std::env::var("SOURCE_RS_QMENU_ITEM")
|
||||
if !lua_ui_ready
|
||||
&& let Ok(needle) = std::env::var("SOURCE_RS_QMENU_ITEM")
|
||||
&& !spawn_menu.select_matching(&needle)
|
||||
{
|
||||
warn!(needle = %needle, "Q-меню: предмет для выбора не найден");
|
||||
}
|
||||
// `SOURCE_RS_QMENU_SPAWN=1` — заспавнить выбранный предмет сразу:
|
||||
// проверка спавна кадром без ручного клика.
|
||||
// проверка спавна кадром без ручного клика (встроенное меню).
|
||||
let mut pending_spawns: Vec<SpawnRequest> = Vec::new();
|
||||
if std::env::var_os("SOURCE_RS_QMENU_SPAWN").is_some()
|
||||
if !lua_ui_ready
|
||||
&& std::env::var_os("SOURCE_RS_QMENU_SPAWN").is_some()
|
||||
&& let Some(item) = spawn_menu.selected_item()
|
||||
{
|
||||
pending_spawns.push(menu_spawn_request(item, &collision, &player, camera.yaw));
|
||||
}
|
||||
// `SOURCE_RS_UI_CLICK` — отладочный клик по Lua-меню: «x,y» или текст
|
||||
// вкладки/модели. Нужен, потому что скрытое окно не получает xdotool.
|
||||
let ui_click = std::env::var("SOURCE_RS_UI_CLICK")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
let mut ui_click_done = false;
|
||||
// `SOURCE_RS_UI_CLOSE=<кадр>` — закрыть меню после отладочного клика,
|
||||
// чтобы на скриншоте был виден заспавненный проп, а не только панели.
|
||||
let ui_close_frame = std::env::var("SOURCE_RS_UI_CLOSE")
|
||||
.ok()
|
||||
.and_then(|value| value.trim().parse::<u64>().ok());
|
||||
let mut ui_close_done = false;
|
||||
// Снимок Lua-меню переиспользует один буфер: каждый кадр новый Vec не
|
||||
// нужен, а строки освобождаются при закрытии меню.
|
||||
let mut ui_commands: Vec<glua::UiCommand> = Vec::new();
|
||||
let mut lua_ui_open = false;
|
||||
// Палец, который сейчас нажимает в Lua-панель (остальные — камера/стик).
|
||||
let mut ui_touch_finger: Option<u64> = None;
|
||||
if std::env::var_os("SOURCE_RS_QMENU").is_some() {
|
||||
spawn_menu.set_open(true);
|
||||
lua_ui_open = set_menu_open(
|
||||
true,
|
||||
lua_ui_ready,
|
||||
lua.as_ref(),
|
||||
&mut spawn_menu,
|
||||
&sdl,
|
||||
&window,
|
||||
relative_mouse,
|
||||
);
|
||||
player.velocity = math::Vec3::ZERO;
|
||||
apply_menu_mouse(true, &sdl, &window, relative_mouse);
|
||||
if lua_ui_ready {
|
||||
info!("Q-меню: Lua Sandbox открыто (SOURCE_RS_QMENU)");
|
||||
} else {
|
||||
info!(kind = ?spawn_menu.kind(), "Q-меню открыто (SOURCE_RS_QMENU)");
|
||||
}
|
||||
}
|
||||
|
||||
// Время для процедурной пробы скиннинга вьюмодели (A26).
|
||||
|
|
@ -765,8 +846,17 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
..
|
||||
} => {
|
||||
// Esc в открытом меню закрывает его, а не клиент.
|
||||
if spawn_menu.is_open() {
|
||||
close_menu(&mut spawn_menu, &sdl, &window, relative_mouse);
|
||||
if lua_ui_open || spawn_menu.is_open() {
|
||||
lua_ui_open = set_menu_open(
|
||||
false,
|
||||
lua_ui_ready,
|
||||
lua.as_ref(),
|
||||
&mut spawn_menu,
|
||||
&sdl,
|
||||
&window,
|
||||
relative_mouse,
|
||||
);
|
||||
ui_touch_finger = None;
|
||||
} else {
|
||||
break 'running;
|
||||
}
|
||||
|
|
@ -776,34 +866,86 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
repeat: false,
|
||||
..
|
||||
} => {
|
||||
if spawn_menu.is_open() {
|
||||
close_menu(&mut spawn_menu, &sdl, &window, relative_mouse);
|
||||
let open = if lua_ui_ready {
|
||||
!lua_ui_open
|
||||
} else {
|
||||
spawn_menu.set_open(true);
|
||||
info!(kind = ?spawn_menu.kind(), "Q-меню открыто");
|
||||
!spawn_menu.is_open()
|
||||
};
|
||||
lua_ui_open = set_menu_open(
|
||||
open,
|
||||
lua_ui_ready,
|
||||
lua.as_ref(),
|
||||
&mut spawn_menu,
|
||||
&sdl,
|
||||
&window,
|
||||
relative_mouse,
|
||||
);
|
||||
if open {
|
||||
info!(
|
||||
lua = lua_ui_ready,
|
||||
kind = ?spawn_menu.kind(),
|
||||
"Q-меню открыто"
|
||||
);
|
||||
// Меню — модальное: взятый физганом проп отпускаем,
|
||||
// иначе он останется висеть на луче взгляда.
|
||||
grabbed = None;
|
||||
player.velocity = math::Vec3::ZERO;
|
||||
apply_menu_mouse(true, &sdl, &window, relative_mouse);
|
||||
log.push_back("Q: spawn menu".to_string());
|
||||
ui_touch_finger = None;
|
||||
log.push_back(if lua_ui_ready {
|
||||
"Q: Lua spawn menu".to_string()
|
||||
} else {
|
||||
"Q: spawn menu".to_string()
|
||||
});
|
||||
while log.len() > 8 {
|
||||
log.pop_front();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Пока открыто Lua-меню, клавиатура принадлежит ему: GMod
|
||||
// панели ловят клавиши сами. Клиентские горячие клавиши
|
||||
// (слоты, G, V) не должны срабатывать за спиной меню.
|
||||
Event::KeyDown {
|
||||
keycode: Some(_),
|
||||
repeat: false,
|
||||
..
|
||||
} if lua_ui_open => {}
|
||||
Event::KeyDown {
|
||||
keycode: Some(code),
|
||||
repeat: false,
|
||||
..
|
||||
} if spawn_menu.is_open() => match spawn_menu.key(code) {
|
||||
MenuAction::Close => close_menu(&mut spawn_menu, &sdl, &window, relative_mouse),
|
||||
MenuAction::Close => {
|
||||
lua_ui_open = set_menu_open(
|
||||
false,
|
||||
lua_ui_ready,
|
||||
lua.as_ref(),
|
||||
&mut spawn_menu,
|
||||
&sdl,
|
||||
&window,
|
||||
relative_mouse,
|
||||
);
|
||||
}
|
||||
MenuAction::Spawn(item) => {
|
||||
pending_spawns
|
||||
.push(menu_spawn_request(&item, &collision, &player, camera.yaw));
|
||||
}
|
||||
MenuAction::None => {}
|
||||
},
|
||||
Event::MouseButtonDown {
|
||||
mouse_btn, x, y, ..
|
||||
} if lua_ui_open => {
|
||||
if let Some(lua) = &lua {
|
||||
lua.ui_mouse("move", x, y, 0);
|
||||
lua.ui_mouse("down", x, y, mouse_button_code(mouse_btn));
|
||||
}
|
||||
}
|
||||
Event::MouseButtonUp {
|
||||
mouse_btn, x, y, ..
|
||||
} if lua_ui_open => {
|
||||
if let Some(lua) = &lua {
|
||||
lua.ui_mouse("up", x, y, mouse_button_code(mouse_btn));
|
||||
}
|
||||
}
|
||||
Event::MouseButtonDown {
|
||||
mouse_btn: MouseButton::Left,
|
||||
x,
|
||||
|
|
@ -990,8 +1132,22 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
} => {
|
||||
pressed.remove(&code);
|
||||
}
|
||||
Event::MouseWheel { y, .. } => {
|
||||
if spawn_menu.is_open() {
|
||||
Event::MouseWheel {
|
||||
y,
|
||||
mouse_x,
|
||||
mouse_y,
|
||||
..
|
||||
} => {
|
||||
if lua_ui_open {
|
||||
// В меню колесо листает содержимое панели, а не крутит
|
||||
// взятый физганом проп.
|
||||
if y != 0.0
|
||||
&& let Some(lua) = &lua
|
||||
{
|
||||
let step = if y > 0.0 { 1 } else { -1 };
|
||||
lua.ui_mouse("wheel", mouse_x, mouse_y, step);
|
||||
}
|
||||
} else if spawn_menu.is_open() {
|
||||
// Колесо листает страницы списка (он длинный).
|
||||
spawn_menu.scroll(if y > 0.0 { -1 } else { 1 });
|
||||
} else if let Some((id, along, angles)) = grabbed {
|
||||
|
|
@ -1000,6 +1156,12 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
grabbed = Some((id, along, angles));
|
||||
}
|
||||
}
|
||||
Event::MouseMotion { x, y, .. } if lua_ui_open => {
|
||||
// Настоящее меню само решает, какая панель под курсором.
|
||||
if let Some(lua) = &lua {
|
||||
lua.ui_mouse("move", x, y, 0);
|
||||
}
|
||||
}
|
||||
Event::MouseMotion { x, y, .. } if spawn_menu.is_open() => {
|
||||
// В открытом меню мышь управляет выбором, а не камерой.
|
||||
let (width, height) = gfx.size();
|
||||
|
|
@ -1012,7 +1174,41 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
Event::FingerDown {
|
||||
finger_id, x, y, ..
|
||||
} => {
|
||||
touch.finger_down(finger_id, x, y);
|
||||
if touch.menu_button(x, y) {
|
||||
let open = if lua_ui_ready {
|
||||
!lua_ui_open
|
||||
} else {
|
||||
!spawn_menu.is_open()
|
||||
};
|
||||
lua_ui_open = set_menu_open(
|
||||
open,
|
||||
lua_ui_ready,
|
||||
lua.as_ref(),
|
||||
&mut spawn_menu,
|
||||
&sdl,
|
||||
&window,
|
||||
relative_mouse,
|
||||
);
|
||||
if open {
|
||||
grabbed = None;
|
||||
player.velocity = math::Vec3::ZERO;
|
||||
}
|
||||
ui_touch_finger = None;
|
||||
} else if lua_ui_open {
|
||||
// Палец нажимает в настоящие панели: касание SDL
|
||||
// нормализовано, а Lua-меню ждёт пиксели экрана.
|
||||
if ui_touch_finger.is_none() {
|
||||
ui_touch_finger = Some(finger_id);
|
||||
let (width, height) = gfx.size();
|
||||
let (px, py) = (x * width as f32, y * height as f32);
|
||||
if let Some(lua) = &lua {
|
||||
lua.ui_mouse("move", px, py, 0);
|
||||
lua.ui_mouse("down", px, py, 1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
touch.finger_down(finger_id, x, y);
|
||||
}
|
||||
}
|
||||
Event::FingerMotion {
|
||||
finger_id,
|
||||
|
|
@ -1022,13 +1218,34 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
dy,
|
||||
..
|
||||
} => {
|
||||
if !spawn_menu.is_open()
|
||||
if lua_ui_open {
|
||||
if ui_touch_finger == Some(finger_id) {
|
||||
let (width, height) = gfx.size();
|
||||
let (px, py) = (x * width as f32, y * height as f32);
|
||||
if let Some(lua) = &lua {
|
||||
lua.ui_mouse("move", px, py, 0);
|
||||
}
|
||||
}
|
||||
} else if !spawn_menu.is_open()
|
||||
&& let Some((yaw, pitch)) = touch.finger_motion(finger_id, x, y, dx, dy)
|
||||
{
|
||||
camera.rotate(yaw, pitch);
|
||||
}
|
||||
}
|
||||
Event::FingerUp { finger_id, .. } | Event::FingerCanceled { finger_id, .. } => {
|
||||
Event::FingerUp {
|
||||
finger_id, x, y, ..
|
||||
}
|
||||
| Event::FingerCanceled {
|
||||
finger_id, x, y, ..
|
||||
} => {
|
||||
if ui_touch_finger == Some(finger_id) {
|
||||
ui_touch_finger = None;
|
||||
let (width, height) = gfx.size();
|
||||
let (px, py) = (x * width as f32, y * height as f32);
|
||||
if let Some(lua) = &lua {
|
||||
lua.ui_mouse("up", px, py, 1);
|
||||
}
|
||||
}
|
||||
touch.finger_up(finger_id);
|
||||
}
|
||||
Event::Window {
|
||||
|
|
@ -1036,7 +1253,15 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
..
|
||||
} => {
|
||||
// При сворачивании Android приходит cancel не на все пальцы:
|
||||
// иначе игрок «уезжает» с зажатым стиком.
|
||||
// иначе игрок «уезжает» с зажатым стиком. Нажатая панель
|
||||
// Lua-меню должна получить отпускание, иначе останется
|
||||
// «залипшей».
|
||||
if ui_touch_finger.take().is_some()
|
||||
&& let Some(lua) = &lua
|
||||
{
|
||||
let (width, height) = gfx.size();
|
||||
lua.ui_mouse("up", width as f32 * 0.5, height as f32 * 0.5, 1);
|
||||
}
|
||||
touch.cancel();
|
||||
}
|
||||
Event::Window {
|
||||
|
|
@ -1046,11 +1271,69 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
let (w, h) = (w.max(1) as u32, h.max(1) as u32);
|
||||
gfx.resize(w, h);
|
||||
renderer.resize(&gfx, w, h);
|
||||
// Раскладка меню и попадания мыши живут в пикселях окна.
|
||||
if let Some(lua) = &lua {
|
||||
lua.ui_set_screen(w, h);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Lua-меню может закрыть себя само (кнопка Close в панели) — тогда
|
||||
// возвращаем захват мыши, даже если Q не нажимали.
|
||||
if lua_ui_ready && let Some(lua) = &lua {
|
||||
let visible = lua.ui_visible();
|
||||
if visible != lua_ui_open {
|
||||
lua_ui_open = visible;
|
||||
apply_menu_mouse(visible, &sdl, &window, relative_mouse);
|
||||
ui_touch_finger = None;
|
||||
}
|
||||
}
|
||||
|
||||
// Отладочный клик по снимку прошлого кадра: вкладка, категория или
|
||||
// модель. Скрытое окно не получает внешних кликов, поэтому проверка
|
||||
// спавна идёт тем же `ui_mouse`, что и настоящая мышь.
|
||||
if lua_ui_open
|
||||
&& !ui_click_done
|
||||
&& frames_drawn >= 8
|
||||
&& let Some(spec) = ui_click.as_deref()
|
||||
{
|
||||
ui_click_done = true;
|
||||
match ui_overlay::click_point(&ui_commands, spec) {
|
||||
Some((x, y)) => {
|
||||
info!(spec, x, y, "Lua UI: отладочный клик");
|
||||
if let Some(lua) = &lua {
|
||||
lua.ui_mouse("move", x, y, 0);
|
||||
lua.ui_mouse("down", x, y, 1);
|
||||
lua.ui_mouse("up", x, y, 1);
|
||||
}
|
||||
}
|
||||
None => warn!(spec, "Lua UI: цель отладочного клика не найдена"),
|
||||
}
|
||||
}
|
||||
if ui_click.is_none() {
|
||||
ui_click_done = true;
|
||||
}
|
||||
// Отладочное закрытие: нужно для кадра со спавном (меню закрыто).
|
||||
if lua_ui_open
|
||||
&& !ui_close_done
|
||||
&& ui_close_frame.is_some_and(|frame| frames_drawn >= frame)
|
||||
{
|
||||
ui_close_done = true;
|
||||
lua_ui_open = set_menu_open(
|
||||
false,
|
||||
lua_ui_ready,
|
||||
lua.as_ref(),
|
||||
&mut spawn_menu,
|
||||
&sdl,
|
||||
&window,
|
||||
relative_mouse,
|
||||
);
|
||||
ui_touch_finger = None;
|
||||
info!("Lua UI: меню закрыто (SOURCE_RS_UI_CLOSE)");
|
||||
}
|
||||
|
||||
// Спавн из меню или по G — один путь: физика, коллизия и пересборка
|
||||
// рендерера одинаковы, поэтому запрос копится в событиях и
|
||||
// обрабатывается здесь, до шага физики.
|
||||
|
|
@ -1183,7 +1466,7 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
w.step_physics(dt);
|
||||
w.recover_fallen_props(-8192.0);
|
||||
// Физган: держим тело на луче взгляда, на взятом расстоянии.
|
||||
if auto_grab && grabbed.is_none() && !spawn_menu.is_open() {
|
||||
if auto_grab && grabbed.is_none() && !spawn_menu.is_open() && !lua_ui_open {
|
||||
let origin = player.eye_position();
|
||||
let dir = camera.forward();
|
||||
let mut best: Option<(f32, engine::physics::BodyId)> = None;
|
||||
|
|
@ -1251,7 +1534,7 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
let touch_jump = touch.take_jump();
|
||||
// В открытом меню игрок стоит: мышь и клавиши принадлежат меню,
|
||||
// гравитация/ходьба не должны утащить его с места за время выбора.
|
||||
let menu_open = spawn_menu.is_open();
|
||||
let menu_open = spawn_menu.is_open() || lua_ui_open;
|
||||
if menu_open {
|
||||
player.velocity = math::Vec3::ZERO;
|
||||
player.sprint = false;
|
||||
|
|
@ -1498,11 +1781,60 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
if let Some(state) = swep.as_mut() {
|
||||
state.update(lua);
|
||||
}
|
||||
// Спавн из настоящего меню: `SpawnIcon.DoClick` зовёт
|
||||
// `RunConsoleCommand("gm_spawn", model, ...)`. Клиент забирает
|
||||
// очередь раз в кадр и делает то же, что G-спавн: проп на полу
|
||||
// перед игроком, без телепорта и без сервера.
|
||||
for command in lua.take_console_commands() {
|
||||
let Some(name) = command.first().map(String::as_str) else {
|
||||
continue;
|
||||
};
|
||||
let (model, label) = match name.to_ascii_lowercase().as_str() {
|
||||
"gm_spawn" => (
|
||||
command.get(1).and_then(|raw| qmenu::clean_model_path(raw)),
|
||||
"gm_spawn",
|
||||
),
|
||||
"gm_spawnsent" => (
|
||||
command.get(1).and_then(|class| entity_model(lua, class)),
|
||||
"gm_spawnsent",
|
||||
),
|
||||
"gm_spawnswep" => (
|
||||
command.get(1).and_then(|class| weapon_model(lua, class)),
|
||||
"gm_spawnswep",
|
||||
),
|
||||
other => {
|
||||
debug!(command = %other, "Lua: команда не поддержана клиентом");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let Some(model) = model else {
|
||||
warn!(
|
||||
command = label,
|
||||
args = ?command,
|
||||
"Lua: команда спавна без известной модели"
|
||||
);
|
||||
log.push_back(format!("{label}: нет модели"));
|
||||
continue;
|
||||
};
|
||||
if !vfs.exists(&model) {
|
||||
warn!(command = label, model = %model, "Lua: модели нет в VFS");
|
||||
log.push_back(format!("{label}: нет {model}"));
|
||||
continue;
|
||||
}
|
||||
let origin = qmenu::spawn_origin(&collision, &player, camera.yaw);
|
||||
info!(command = label, model = %model, "Lua: спавн из меню");
|
||||
log.push_back(format!("{label}: {model}"));
|
||||
pending_spawns.push(SpawnRequest {
|
||||
model,
|
||||
origin,
|
||||
angles: math::Vec3::new(0.0, camera.yaw, 0.0),
|
||||
});
|
||||
}
|
||||
for line in lua.take_output() {
|
||||
log.push_back(line);
|
||||
while log.len() > 8 {
|
||||
log.pop_front();
|
||||
}
|
||||
}
|
||||
while log.len() > 8 {
|
||||
log.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1570,14 +1902,14 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
};
|
||||
hud(8.0, hud_top + 40.0, 1.4, [1.0, 0.9, 0.65, 1.0], text);
|
||||
}
|
||||
if !spawn_menu.is_open() {
|
||||
if !menu_open {
|
||||
hud(
|
||||
8.0,
|
||||
hud_bottom,
|
||||
1.5,
|
||||
[0.95, 0.95, 0.8, 1.0],
|
||||
if touch_enabled {
|
||||
"touch: left stick move | right swipe look | buttons jump/duck".to_string()
|
||||
"touch: left stick move | right swipe look | MENU/jump/duck".to_string()
|
||||
} else {
|
||||
"WASD move | Space jump | Ctrl duck | V noclip | G spawn | Q menu | 0-9 weapon | ` log"
|
||||
.to_string()
|
||||
|
|
@ -1594,6 +1926,14 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
[0.75, 0.9, 1.0, 0.75],
|
||||
"MOVE".to_string(),
|
||||
);
|
||||
// Четвёртая кнопка — меню (аналог Q), над прыжком.
|
||||
hud(
|
||||
width as f32 * 0.74,
|
||||
height as f32 * 0.30,
|
||||
1.6,
|
||||
[0.9, 0.85, 1.0, 0.85],
|
||||
"MENU".to_string(),
|
||||
);
|
||||
hud(
|
||||
width as f32 * 0.74,
|
||||
height as f32 * 0.60,
|
||||
|
|
@ -1620,6 +1960,35 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
// Q-меню поверх HUD: выбор предмета и подсказки по управлению.
|
||||
spawn_menu.draw(&mut renderer, width as f32, height as f32);
|
||||
|
||||
// Настоящее меню Sandbox: снимок панелей из Lua, примитивами клиента.
|
||||
if lua_ui_open {
|
||||
if let Some(lua) = &lua {
|
||||
lua.ui_snapshot_into(&mut ui_commands);
|
||||
}
|
||||
let stats = ui_overlay::draw(&mut renderer, &ui_commands, width as f32, height as f32);
|
||||
if frames_drawn % 120 == 0 {
|
||||
debug!(
|
||||
commands = ui_commands.len(),
|
||||
rects = stats.rects,
|
||||
outlines = stats.outlines,
|
||||
texts = stats.texts,
|
||||
skipped = stats.skipped,
|
||||
glyphs = stats.glyphs,
|
||||
"Lua UI: кадр"
|
||||
);
|
||||
// Ошибки Paint/PerformLayout не фатальны, но не должны
|
||||
// теряться: показываем их в логе, а не только в памяти Lua.
|
||||
if let Some(lua) = &lua {
|
||||
for error in lua.ui_take_errors().iter().take(4) {
|
||||
warn!(error = %error, "Lua UI: ошибка панели");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if !ui_commands.is_empty() {
|
||||
// Строки снимка больше не нужны, память буфера остаётся.
|
||||
ui_commands.clear();
|
||||
}
|
||||
|
||||
if frames_drawn % 60 == 0 {
|
||||
tracing::debug!(
|
||||
z = player.position.z,
|
||||
|
|
@ -1818,15 +2187,62 @@ fn apply_menu_mouse(
|
|||
}
|
||||
}
|
||||
|
||||
/// Закрывает Q-меню и возвращает захват мыши.
|
||||
fn close_menu(
|
||||
/// Открывает/закрывает меню: настоящее Lua-меню, если оно загрузилось,
|
||||
/// иначе встроенное. Возвращает новое состояние Lua-меню.
|
||||
///
|
||||
/// Одна точка входа нужна, чтобы Q, Esc и сенсорная кнопка одинаково
|
||||
/// возвращали захват мыши и не оставляли два меню открытыми сразу.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn set_menu_open(
|
||||
open: bool,
|
||||
lua_ui_ready: bool,
|
||||
lua: Option<&glua::LuaRuntime>,
|
||||
menu: &mut SpawnMenu,
|
||||
sdl: &sdl3::Sdl,
|
||||
window: &sdl3::video::Window,
|
||||
relative_mouse: bool,
|
||||
) {
|
||||
menu.set_open(false);
|
||||
apply_menu_mouse(false, sdl, window, relative_mouse);
|
||||
) -> bool {
|
||||
if lua_ui_ready && let Some(lua) = lua {
|
||||
lua.ui_toggle(open);
|
||||
// Реальное состояние важнее запрошенного: если корень уже удалён,
|
||||
// меню не открылось, и захват мыши снимать нельзя.
|
||||
let visible = lua.ui_visible();
|
||||
apply_menu_mouse(visible, sdl, window, relative_mouse);
|
||||
return visible;
|
||||
}
|
||||
menu.set_open(open);
|
||||
apply_menu_mouse(open, sdl, window, relative_mouse);
|
||||
false
|
||||
}
|
||||
|
||||
/// Номер кнопки мыши в терминах GMod (`MOUSE_LEFT`/`MOUSE_RIGHT`/...).
|
||||
fn mouse_button_code(button: MouseButton) -> i32 {
|
||||
match button {
|
||||
MouseButton::Left => 1,
|
||||
MouseButton::Right => 2,
|
||||
MouseButton::Middle => 3,
|
||||
MouseButton::X1 => 4,
|
||||
MouseButton::X2 => 5,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Модель ENT-класса: те же поля, что у списка `qmenu`, только по запросу
|
||||
/// из `gm_spawnsent`.
|
||||
fn entity_model(lua: &glua::LuaRuntime, class: &str) -> Option<String> {
|
||||
["Model", "WorldModel", "__sr_preview_model"]
|
||||
.into_iter()
|
||||
.filter_map(|field| lua.entity_string(class, field))
|
||||
.find_map(|value| qmenu::clean_model_path(&value))
|
||||
}
|
||||
|
||||
/// Мировая модель SWEP-класса для `gm_spawnswep`.
|
||||
fn weapon_model(lua: &glua::LuaRuntime, class: &str) -> Option<String> {
|
||||
["WorldModel", "ViewModel"]
|
||||
.into_iter()
|
||||
.filter_map(|field| lua.weapon_string(class, field))
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.find_map(|value| qmenu::clean_model_path(&value))
|
||||
}
|
||||
|
||||
fn save_png(path: &Path, width: u32, height: u32, rgba: &[u8]) -> anyhow::Result<()> {
|
||||
|
|
|
|||
|
|
@ -603,7 +603,7 @@ fn truncate(value: &str, max_chars: usize) -> String {
|
|||
}
|
||||
|
||||
/// Нормализует путь модели: `\` → `/`, без пустых компонентов и `..`.
|
||||
fn clean_model_path(raw: &str) -> Option<String> {
|
||||
pub(crate) fn clean_model_path(raw: &str) -> Option<String> {
|
||||
let path = raw.trim().replace('\\', "/");
|
||||
let mut parts: Vec<&str> = Vec::new();
|
||||
for part in path.split('/') {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
351
crates/client/src/ui_overlay.rs
Normal file
351
crates/client/src/ui_overlay.rs
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
//! Отрисовка настоящего Lua-меню (`UiCommand`) поверх кадра.
|
||||
//!
|
||||
//! Снимок строит `glua::LuaRuntime`: это уже готовые прямоугольники, контуры
|
||||
//! и строки в пикселях экрана. Рендерер клиента умеет только текст и заливку,
|
||||
//! поэтому контур рисуется четырьмя тонкими полосами, а текст — bitmap-шрифтом.
|
||||
//!
|
||||
//! Лимиты здесь не декоративные: у текстового оверлея фиксированный буфер
|
||||
//! вершин (64k). Снимок ограничен 4096 командами, но одна широкая полоса
|
||||
//! превращается в сотни глифов, поэтому бюджет глифов считается заранее и
|
||||
//! лишние команды пропускаются, а не роняют кадр.
|
||||
|
||||
use glua::{UiCommand, UiKind};
|
||||
use render::WorldRenderer;
|
||||
|
||||
/// Сколько глифов разрешено на кадр. Буфер текста — 65536 вершин, один глиф
|
||||
/// занимает 6 вершин; 10000 глифов (60000 вершин) оставляют запас HUD.
|
||||
const MAX_GLYPHS: usize = 10_000;
|
||||
|
||||
/// Толщина контура в пикселях. У заливки минимальный глиф — 8×8, поэтому
|
||||
/// один пиксель всё равно станет восемью; меньшее значение только соврёт.
|
||||
const OUTLINE: f32 = 1.0;
|
||||
|
||||
/// Минимальный масштаб bitmap-шрифта: мельче глиф нечитаем.
|
||||
const MIN_TEXT_SCALE: f32 = 0.8;
|
||||
/// Максимальный масштаб: DermaLarge — 32 px, при scale=8 строка не влезает.
|
||||
const MAX_TEXT_SCALE: f32 = 3.0;
|
||||
|
||||
/// Что попало в кадр, а что отброшено (для диагностики).
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct OverlayStats {
|
||||
pub rects: usize,
|
||||
pub outlines: usize,
|
||||
pub texts: usize,
|
||||
pub skipped: usize,
|
||||
pub glyphs: usize,
|
||||
}
|
||||
|
||||
/// Рисует снимок меню. Вызывать после HUD: меню — модальное, оно сверху.
|
||||
pub fn draw(
|
||||
renderer: &mut WorldRenderer,
|
||||
commands: &[UiCommand],
|
||||
width: f32,
|
||||
height: f32,
|
||||
) -> OverlayStats {
|
||||
let mut stats = OverlayStats::default();
|
||||
for command in commands {
|
||||
if !visible(command, width, height) {
|
||||
stats.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
match command.kind {
|
||||
UiKind::Rect => {
|
||||
if command.w <= 0.0 || command.h <= 0.0 || invisible(command.color) {
|
||||
stats.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
let cost = rect_glyphs(command.w, command.h);
|
||||
if stats.glyphs + cost > MAX_GLYPHS {
|
||||
stats.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
renderer.push_rect(
|
||||
command.x,
|
||||
command.y,
|
||||
command.w,
|
||||
command.h,
|
||||
linear_color(command.color),
|
||||
);
|
||||
stats.rects += 1;
|
||||
stats.glyphs += cost;
|
||||
}
|
||||
UiKind::Outline => {
|
||||
if command.w <= 0.0 || command.h <= 0.0 || invisible(command.color) {
|
||||
stats.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
let edges = outline_rects(command);
|
||||
let cost: usize = edges.iter().map(|(_, _, w, h)| rect_glyphs(*w, *h)).sum();
|
||||
if stats.glyphs + cost > MAX_GLYPHS {
|
||||
stats.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
let color = linear_color(command.color);
|
||||
for (x, y, w, h) in edges {
|
||||
renderer.push_rect(x, y, w, h, color);
|
||||
}
|
||||
stats.outlines += 1;
|
||||
stats.glyphs += cost;
|
||||
}
|
||||
UiKind::Text => {
|
||||
let Some(text) = command.text.as_deref() else {
|
||||
stats.skipped += 1;
|
||||
continue;
|
||||
};
|
||||
if text.is_empty() || !command.x.is_finite() || !command.y.is_finite() {
|
||||
stats.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
let scale = text_scale(command.h);
|
||||
// Каждый байт строки — отдельный глиф: не-ASCII в bitmap-
|
||||
// атласе всё равно превращается в квадрат, но бюджет считаем
|
||||
// по фактической длине, которую обойдёт build_vertices.
|
||||
let cost = text.len();
|
||||
if stats.glyphs + cost > MAX_GLYPHS {
|
||||
stats.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
renderer.push_text(
|
||||
command.x,
|
||||
command.y,
|
||||
scale,
|
||||
linear_color(command.color),
|
||||
text,
|
||||
);
|
||||
stats.texts += 1;
|
||||
stats.glyphs += cost;
|
||||
}
|
||||
}
|
||||
}
|
||||
stats
|
||||
}
|
||||
|
||||
/// Точка клика из отладочного флага: «x,y» или подстрока текста.
|
||||
///
|
||||
/// По тексту ищется самая ранняя команда (панели рисуются сверху вниз, так
|
||||
/// что вкладки и подписи находятся раньше содержимого). Для скриншот-теста
|
||||
/// этого достаточно и не нужен xdotool по скрытому окну.
|
||||
pub fn click_point(commands: &[UiCommand], spec: &str) -> Option<(f32, f32)> {
|
||||
let spec = spec.trim();
|
||||
if let Some(point) = parse_point(spec) {
|
||||
return Some(point);
|
||||
}
|
||||
if spec.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let lower = spec.to_ascii_lowercase();
|
||||
let exact = commands.iter().find(|command| {
|
||||
command.kind == UiKind::Text
|
||||
&& command
|
||||
.text
|
||||
.as_deref()
|
||||
.is_some_and(|text| text.eq_ignore_ascii_case(spec))
|
||||
});
|
||||
let command = exact.or_else(|| {
|
||||
commands.iter().find(|command| {
|
||||
command.kind == UiKind::Text
|
||||
&& command
|
||||
.text
|
||||
.as_deref()
|
||||
.is_some_and(|text| text.to_ascii_lowercase().contains(&lower))
|
||||
})
|
||||
})?;
|
||||
if !command.x.is_finite() || !command.y.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let width = if command.w.is_finite() {
|
||||
command.w.max(8.0)
|
||||
} else {
|
||||
8.0
|
||||
};
|
||||
let height = if command.h.is_finite() {
|
||||
command.h.max(8.0)
|
||||
} else {
|
||||
8.0
|
||||
};
|
||||
// Чуть выше середины: подпись вкладки может лежать на границе с
|
||||
// содержимым, и клик ровно по центру попадал бы в панель под вкладкой.
|
||||
Some((command.x + width * 0.5, command.y + height * 0.25))
|
||||
}
|
||||
|
||||
/// Полностью ли команда в кадре (частично видимую рисует GPU-обрезка).
|
||||
fn visible(command: &UiCommand, width: f32, height: f32) -> bool {
|
||||
if !command.x.is_finite() || !command.y.is_finite() {
|
||||
return false;
|
||||
}
|
||||
let w = if command.w.is_finite() {
|
||||
command.w.max(0.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let h = if command.h.is_finite() {
|
||||
command.h.max(0.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
match command.kind {
|
||||
UiKind::Text => command.y < height && command.y + h.max(8.0) > 0.0 && command.x < width,
|
||||
UiKind::Rect | UiKind::Outline => {
|
||||
command.x < width && command.y < height && command.x + w > 0.0 && command.y + h > 0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Нулевая альфа — команда невидима (Lua-скины часто рисуют прозрачное).
|
||||
fn invisible(color: [f32; 4]) -> bool {
|
||||
!color[3].is_finite() || color[3] <= 0.01
|
||||
}
|
||||
|
||||
/// Цвета VGUI заданы в sRGB (Color(128,...)), а цель рендера — sRGB-текстура:
|
||||
/// шейдер обязан получить линейные компоненты, иначе серый 128 на экране
|
||||
/// станет 188 и меню выглядит выцветшим.
|
||||
fn linear_color(color: [f32; 4]) -> [f32; 4] {
|
||||
let channel = |value: f32| {
|
||||
let value = if value.is_finite() {
|
||||
value.clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
if value <= 0.04045 {
|
||||
value / 12.92
|
||||
} else {
|
||||
((value + 0.055) / 1.055).powf(2.4)
|
||||
}
|
||||
};
|
||||
[
|
||||
channel(color[0]),
|
||||
channel(color[1]),
|
||||
channel(color[2]),
|
||||
color[3].clamp(0.0, 1.0),
|
||||
]
|
||||
}
|
||||
|
||||
/// Сколько «глифов» (квадов по 6 вершин) займёт прямоугольник.
|
||||
///
|
||||
/// У `push_rect` теперь отдельный квад: тонкая линия не растягивается в
|
||||
/// квадрат, а стоит столько же, сколько один глиф текста.
|
||||
fn rect_glyphs(width: f32, height: f32) -> usize {
|
||||
if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 {
|
||||
return 0;
|
||||
}
|
||||
1
|
||||
}
|
||||
|
||||
/// Четыре полосы контура: верх, низ, лево, право.
|
||||
fn outline_rects(command: &UiCommand) -> [(f32, f32, f32, f32); 4] {
|
||||
let (x, y, w, h) = (command.x, command.y, command.w, command.h);
|
||||
let t = OUTLINE.min(w * 0.5).min(h * 0.5).max(0.0);
|
||||
[
|
||||
(x, y, w, t),
|
||||
(x, y + h - t, w, t),
|
||||
(x, y + t, t, (h - 2.0 * t).max(0.0)),
|
||||
(x + w - t, y + t, t, (h - 2.0 * t).max(0.0)),
|
||||
]
|
||||
}
|
||||
|
||||
/// Масштаб bitmap-шрифта по высоте шрифта Derma.
|
||||
///
|
||||
/// Глиф 8×8, а строка DermaDefault — 13 px. `h / 12` даёт ширину около
|
||||
/// 8,7 px на символ: чуть шире, чем считает `surface.GetTextSize`, зато
|
||||
/// буквы читаются (при `h / 8` строка вылезает из панелей).
|
||||
fn text_scale(height: f32) -> f32 {
|
||||
if !height.is_finite() {
|
||||
return 1.0;
|
||||
}
|
||||
(height / 12.0).clamp(MIN_TEXT_SCALE, MAX_TEXT_SCALE)
|
||||
}
|
||||
|
||||
/// Разбирает «x,y» или «x y».
|
||||
fn parse_point(text: &str) -> Option<(f32, f32)> {
|
||||
let mut parts = text
|
||||
.split(|c: char| c == ',' || c.is_whitespace())
|
||||
.filter(|part| !part.is_empty());
|
||||
let x: f32 = parts.next()?.parse().ok()?;
|
||||
let y: f32 = parts.next()?.parse().ok()?;
|
||||
if parts.next().is_some() || !x.is_finite() || !y.is_finite() {
|
||||
return None;
|
||||
}
|
||||
Some((x, y))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn text_command(text: &str, x: f32, y: f32, w: f32, h: f32) -> UiCommand {
|
||||
UiCommand {
|
||||
kind: UiKind::Text,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
color: [1.0, 1.0, 1.0, 1.0],
|
||||
text: Some(text.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn click_finds_exact_text_before_substring() {
|
||||
let commands = vec![
|
||||
text_command("Weapons", 10.0, 4.0, 50.0, 13.0),
|
||||
text_command("Weapons of mass", 100.0, 100.0, 80.0, 13.0),
|
||||
];
|
||||
assert_eq!(click_point(&commands, "weapons"), Some((35.0, 7.25)));
|
||||
assert_eq!(click_point(&commands, "mass"), Some((140.0, 103.25)));
|
||||
assert_eq!(click_point(&commands, "10,20"), Some((10.0, 20.0)));
|
||||
assert_eq!(click_point(&commands, "нет такого"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offscreen_commands_are_skipped() {
|
||||
let rect = UiCommand {
|
||||
kind: UiKind::Rect,
|
||||
x: 2000.0,
|
||||
y: 10.0,
|
||||
w: 100.0,
|
||||
h: 20.0,
|
||||
color: [1.0, 0.0, 0.0, 1.0],
|
||||
text: None,
|
||||
};
|
||||
assert!(!visible(&rect, 1280.0, 720.0));
|
||||
// Частично видимый прямоугольник рисуем: GPU обрежет по окну.
|
||||
let mut partial = rect.clone();
|
||||
partial.x = 1200.0;
|
||||
assert!(visible(&partial, 1280.0, 720.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_scale_is_readable_and_bounded() {
|
||||
assert!((text_scale(13.0) - 1.0833).abs() < 0.001);
|
||||
assert_eq!(text_scale(1.0), MIN_TEXT_SCALE);
|
||||
assert_eq!(text_scale(500.0), MAX_TEXT_SCALE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn srgb_colors_become_linear_before_the_shader() {
|
||||
let white = linear_color([1.0, 1.0, 1.0, 1.0]);
|
||||
assert!((white[0] - 1.0).abs() < 1e-6);
|
||||
// sRGB 128 → линейный ~0.2158: на экране снова будет 128, а не 188.
|
||||
let gray = linear_color([128.0 / 255.0, 0.0, 0.0, 0.5]);
|
||||
assert!((gray[0] - 0.2158).abs() < 0.001, "gray={}", gray[0]);
|
||||
assert_eq!(gray[3], 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_stays_inside_the_rect() {
|
||||
let outline = UiCommand {
|
||||
kind: UiKind::Outline,
|
||||
x: 10.0,
|
||||
y: 20.0,
|
||||
w: 100.0,
|
||||
h: 40.0,
|
||||
color: [1.0, 1.0, 1.0, 1.0],
|
||||
text: None,
|
||||
};
|
||||
let edges = outline_rects(&outline);
|
||||
assert_eq!(edges[0], (10.0, 20.0, 100.0, 1.0));
|
||||
assert_eq!(edges[1], (10.0, 59.0, 100.0, 1.0));
|
||||
assert_eq!(edges[2], (10.0, 21.0, 1.0, 38.0));
|
||||
assert_eq!(edges[3], (109.0, 21.0, 1.0, 38.0));
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,9 @@ pub enum LuaError {
|
|||
|
||||
type TimerQueue = Rc<RefCell<Vec<(Instant, RegistryKey)>>>;
|
||||
type Output = Rc<RefCell<Vec<String>>>;
|
||||
/// Очередь `RunConsoleCommand`: клиент забирает её раз в кадр и решает,
|
||||
/// какие команды он умеет исполнять локально (например, `gm_spawn`).
|
||||
type ConsoleQueue = Rc<RefCell<Vec<Vec<String>>>>;
|
||||
|
||||
/// Итог загрузки геймода.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
|
|
@ -104,6 +107,7 @@ pub struct LuaRuntime {
|
|||
lua: Lua,
|
||||
timers: TimerQueue,
|
||||
output: Output,
|
||||
console: ConsoleQueue,
|
||||
include: Rc<RefCell<include::IncludeState>>,
|
||||
}
|
||||
|
||||
|
|
@ -113,9 +117,10 @@ impl LuaRuntime {
|
|||
let lua = Lua::new();
|
||||
let timers: TimerQueue = Rc::new(RefCell::new(Vec::new()));
|
||||
let output: Output = Rc::new(RefCell::new(Vec::new()));
|
||||
let console: ConsoleQueue = Rc::new(RefCell::new(Vec::new()));
|
||||
let include = Rc::new(RefCell::new(include::IncludeState::default()));
|
||||
|
||||
install_api(&lua, &timers, &output)?;
|
||||
install_api(&lua, &timers, &output, &console)?;
|
||||
gmod::install(&lua)?;
|
||||
stdlib::install(&lua, "unknown")?;
|
||||
include::install(&lua, include.clone())?;
|
||||
|
|
@ -125,6 +130,7 @@ impl LuaRuntime {
|
|||
lua,
|
||||
timers,
|
||||
output,
|
||||
console,
|
||||
include,
|
||||
})
|
||||
}
|
||||
|
|
@ -695,6 +701,7 @@ impl LuaRuntime {
|
|||
lua,
|
||||
&Rc::new(RefCell::new(Vec::new())),
|
||||
&Rc::new(RefCell::new(Vec::new())),
|
||||
&Rc::new(RefCell::new(Vec::new())),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -782,6 +789,16 @@ impl LuaRuntime {
|
|||
std::mem::take(&mut *self.output.borrow_mut())
|
||||
}
|
||||
|
||||
/// Забирает вызовы `RunConsoleCommand` с прошлого кадра.
|
||||
///
|
||||
/// Скрипты меню (например, `SpawnIcon.DoClick`) не зовут concommand
|
||||
/// напрямую: в GMod команда уходит в консоль, а исполняет её сервер.
|
||||
/// Клиент забирает очередь и обрабатывает те команды, которые умеет
|
||||
/// (спавн пропа), остальные игнорирует.
|
||||
pub fn take_console_commands(&self) -> Vec<Vec<String>> {
|
||||
std::mem::take(&mut *self.console.borrow_mut())
|
||||
}
|
||||
|
||||
/// Имена зарегистрированных concommand'ов.
|
||||
pub fn commands(&self) -> Vec<String> {
|
||||
let table: Table = match self.lua.globals().get("__sr_commands") {
|
||||
|
|
@ -812,7 +829,12 @@ impl LuaRuntime {
|
|||
}
|
||||
|
||||
/// Устанавливает GMod-подобный API в глобальную таблицу.
|
||||
fn install_api(lua: &Lua, timers: &TimerQueue, output: &Output) -> Result<(), mlua::Error> {
|
||||
fn install_api(
|
||||
lua: &Lua,
|
||||
timers: &TimerQueue,
|
||||
output: &Output,
|
||||
console: &ConsoleQueue,
|
||||
) -> Result<(), mlua::Error> {
|
||||
let globals = lua.globals();
|
||||
|
||||
// print / Msg: накопление вывода.
|
||||
|
|
@ -1142,10 +1164,21 @@ fn install_api(lua: &Lua, timers: &TimerQueue, output: &Output) -> Result<(), ml
|
|||
})?,
|
||||
)?;
|
||||
|
||||
// RunConsoleCommand — заглушка.
|
||||
// RunConsoleCommand: в GMod уходит в консоль, здесь — в очередь для
|
||||
// клиента. `gm_spawn` из Q-меню доходит до `map` и спавнит настоящий проп.
|
||||
let console_queue = console.clone();
|
||||
globals.set(
|
||||
"RunConsoleCommand",
|
||||
lua.create_function(|_, _: MultiValue| Ok(()))?,
|
||||
lua.create_function(move |_, args: MultiValue| {
|
||||
let mut command = Vec::with_capacity(args.len());
|
||||
for value in args.iter() {
|
||||
command.push(lua_value_to_string(value));
|
||||
}
|
||||
if !command.is_empty() {
|
||||
console_queue.borrow_mut().push(command);
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
|
|
@ -2056,6 +2089,22 @@ result = out
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_console_command_is_queued_for_the_client() {
|
||||
let rt = LuaRuntime::new().expect("рантайм");
|
||||
rt.run(
|
||||
"console.lua",
|
||||
br#"RunConsoleCommand("gm_spawn", "models/props_junk/watermelon01.mdl", 0, "")"#,
|
||||
)
|
||||
.expect("скрипт");
|
||||
let commands = rt.take_console_commands();
|
||||
assert_eq!(commands.len(), 1);
|
||||
assert_eq!(commands[0][0], "gm_spawn");
|
||||
assert_eq!(commands[0][1], "models/props_junk/watermelon01.mdl");
|
||||
// Очередь забирается один раз.
|
||||
assert!(rt.take_console_commands().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concommands_register() {
|
||||
let rt = LuaRuntime::new().expect("рантайм");
|
||||
|
|
|
|||
|
|
@ -104,28 +104,70 @@ impl crate::LuaRuntime {
|
|||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Забирает ошибки Paint/PerformLayout/ввода, накопленные Lua-слоем.
|
||||
///
|
||||
/// Ошибка одной панели не должна ронять меню и кадр: клиент показывает
|
||||
/// их в консоли и продолжает рисовать остальные.
|
||||
pub fn ui_take_errors(&self) -> Vec<String> {
|
||||
catch_unwind(AssertUnwindSafe(|| -> Option<Vec<String>> {
|
||||
let ui: Table = self.lua.globals().get("__sr_ui").ok()?;
|
||||
let take: Function = ui.get("take_errors").ok()?;
|
||||
let Value::Table(list) = take.call::<Value>(()).ok()? else {
|
||||
return None;
|
||||
};
|
||||
let mut errors = Vec::new();
|
||||
for pair in list.sequence_values::<Value>() {
|
||||
if errors.len() >= 64 {
|
||||
break;
|
||||
}
|
||||
if let Ok(Value::String(text)) = pair
|
||||
&& let Ok(text) = text.to_str()
|
||||
{
|
||||
errors.push(text.to_string());
|
||||
}
|
||||
}
|
||||
Some(errors)
|
||||
}))
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Снимок видимого дерева панелей на текущий кадр.
|
||||
///
|
||||
/// Пустой список, если меню закрыто или скрипты ещё не создали панели.
|
||||
/// Число команд ограничено [`MAX_COMMANDS`], глубина — [`MAX_UI_DEPTH`]
|
||||
/// (проверяется в Lua, здесь — вторая линия защиты).
|
||||
pub fn ui_snapshot(&self) -> Vec<UiCommand> {
|
||||
catch_unwind(AssertUnwindSafe(|| self.ui_snapshot_inner()))
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default()
|
||||
let mut commands = Vec::new();
|
||||
self.ui_snapshot_into(&mut commands);
|
||||
commands
|
||||
}
|
||||
|
||||
fn ui_snapshot_inner(&self) -> Option<Vec<UiCommand>> {
|
||||
/// То же, что [`Self::ui_snapshot`], но переиспользует буфер вызывающего:
|
||||
/// рендер снимает меню каждый кадр, и лишние аллокации ему не нужны.
|
||||
/// Буфер очищается даже при ошибке Lua.
|
||||
pub fn ui_snapshot_into(&self, commands: &mut Vec<UiCommand>) {
|
||||
commands.clear();
|
||||
let result = catch_unwind(AssertUnwindSafe(|| self.ui_snapshot_inner(commands)));
|
||||
if result.is_err() {
|
||||
commands.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn ui_snapshot_inner(&self, commands: &mut Vec<UiCommand>) {
|
||||
// Отложенные `timer.Simple(0, ...)` создают вкладки и контент
|
||||
// spawnmenu; без этого шага снимок был бы пустым деревом.
|
||||
let _ = self.tick();
|
||||
let ui: Table = self.lua.globals().get("__sr_ui").ok()?;
|
||||
let snapshot: Function = ui.get("snapshot").ok()?;
|
||||
let Value::Table(list) = snapshot.call::<Value>(()).ok()? else {
|
||||
return None;
|
||||
let Ok(ui) = self.lua.globals().get::<Table>("__sr_ui") else {
|
||||
return;
|
||||
};
|
||||
let Ok(snapshot) = ui.get::<Function>("snapshot") else {
|
||||
return;
|
||||
};
|
||||
let Ok(Value::Table(list)) = snapshot.call::<Value>(()) else {
|
||||
return;
|
||||
};
|
||||
let mut commands = Vec::new();
|
||||
for pair in list.sequence_values::<Value>() {
|
||||
if commands.len() >= MAX_COMMANDS {
|
||||
break;
|
||||
|
|
@ -190,7 +232,6 @@ impl crate::LuaRuntime {
|
|||
text,
|
||||
});
|
||||
}
|
||||
Some(commands)
|
||||
}
|
||||
|
||||
/// Передаёт мышь в Lua-дерево панелей.
|
||||
|
|
@ -282,6 +323,40 @@ mod tests {
|
|||
assert!((text.y - 25.0).abs() < 0.01, "y={}", text.y);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_into_reuses_the_buffer() {
|
||||
let rt = runtime();
|
||||
rt.run(
|
||||
"ui_reuse.lua",
|
||||
br#"
|
||||
local p = vgui.Create( "Panel" )
|
||||
p:SetSize( 100, 50 )
|
||||
function p:Paint( w, h )
|
||||
surface.DrawRect( 0, 0, w, h )
|
||||
draw.SimpleText( "Hi", "DermaDefault", 0, 0, color_white )
|
||||
end
|
||||
__sr_ui.root = p
|
||||
__sr_ui.visible = true
|
||||
"#,
|
||||
)
|
||||
.expect("панель создана");
|
||||
let mut commands = Vec::new();
|
||||
rt.ui_snapshot_into(&mut commands);
|
||||
let first = commands.len();
|
||||
assert!(first >= 2, "команды: {commands:?}");
|
||||
rt.ui_snapshot_into(&mut commands);
|
||||
assert_eq!(commands.len(), first, "буфер не должен накапливать команды");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_errors_are_taken_once() {
|
||||
let rt = runtime();
|
||||
rt.run("ui_error.lua", br#"__sr_ui.log_error( "boom" )"#)
|
||||
.expect("ошибка записана");
|
||||
assert_eq!(rt.ui_take_errors(), vec!["boom".to_string()]);
|
||||
assert!(rt.ui_take_errors().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_respects_command_limit() {
|
||||
let rt = runtime();
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@ pub struct TextVertex {
|
|||
pub color: [f32; 4],
|
||||
}
|
||||
|
||||
/// Строка текста, подготовленная к отрисовке.
|
||||
/// Строка текста или залитый прямоугольник, подготовленный к отрисовке.
|
||||
///
|
||||
/// `size = Some((w, h))` — это прямоугольник: глиф 127 растягивается ровно
|
||||
/// на w×h. Такой режим нужен меню и HUD: масштабировать квадратный глиф по
|
||||
/// высоте нельзя, иначе тонкая вертикальная линия становится широким блоком.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TextItem {
|
||||
pub x: f32,
|
||||
|
|
@ -18,6 +22,7 @@ pub struct TextItem {
|
|||
pub scale: f32,
|
||||
pub color: [f32; 4],
|
||||
pub text: String,
|
||||
pub size: Option<[f32; 2]>,
|
||||
}
|
||||
|
||||
/// Размер атласа глифов.
|
||||
|
|
@ -89,10 +94,32 @@ pub fn glyph_uv(code: u8) -> ([f32; 2], [f32; 2]) {
|
|||
)
|
||||
}
|
||||
|
||||
/// Собирает вершины для списка строк.
|
||||
/// Собирает вершины для списка строк и прямоугольников.
|
||||
pub fn build_vertices(items: &[TextItem]) -> Vec<TextVertex> {
|
||||
let mut out = Vec::new();
|
||||
for item in items {
|
||||
if let Some([width, height]) = item.size {
|
||||
if width <= 0.0 || height <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
// Тот же служебный сплошной глиф, но квад ровно по размеру.
|
||||
let (uv0, uv1) = glyph_uv(127);
|
||||
let corners = [
|
||||
([item.x, item.y], [uv0[0], uv0[1]]),
|
||||
([item.x + width, item.y], [uv1[0], uv0[1]]),
|
||||
([item.x + width, item.y + height], [uv1[0], uv1[1]]),
|
||||
([item.x, item.y + height], [uv0[0], uv1[1]]),
|
||||
];
|
||||
let indices = [0usize, 1, 2, 0, 2, 3];
|
||||
for i in indices {
|
||||
out.push(TextVertex {
|
||||
pos: corners[i].0,
|
||||
uv: corners[i].1,
|
||||
color: item.color,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let mut x = item.x;
|
||||
for byte in item.text.bytes() {
|
||||
if byte == b'\n' {
|
||||
|
|
@ -159,8 +186,26 @@ mod tests {
|
|||
scale: 1.0,
|
||||
color: [1.0, 1.0, 1.0, 1.0],
|
||||
text: "Hi".to_string(),
|
||||
size: None,
|
||||
}];
|
||||
let v = build_vertices(&items);
|
||||
assert_eq!(v.len(), 12);
|
||||
}
|
||||
|
||||
/// Прямоугольник — один квад, независимо от соотношения сторон.
|
||||
#[test]
|
||||
fn rectangle_is_one_quad() {
|
||||
let items = vec![TextItem {
|
||||
x: 10.0,
|
||||
y: 20.0,
|
||||
scale: 1.0,
|
||||
color: [1.0, 0.5, 0.25, 1.0],
|
||||
text: String::new(),
|
||||
size: Some([1.0, 400.0]),
|
||||
}];
|
||||
let v = build_vertices(&items);
|
||||
assert_eq!(v.len(), 6);
|
||||
assert_eq!(v[0].pos, [10.0, 20.0]);
|
||||
assert_eq!(v[2].pos, [11.0, 420.0]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (размер экрана + вершины).
|
||||
|
|
|
|||
|
|
@ -8,6 +8,42 @@
|
|||
|
||||
---
|
||||
|
||||
## 0.8. Q-меню Sandbox в клиенте (2026-10-24, ветка `feat/ui-render`)
|
||||
|
||||
Настоящее меню из `feat/derma` подключено к клиенту; самодельный `qmenu.rs`
|
||||
остался только fallback'ом.
|
||||
|
||||
- `client/src/map.rs`: после `load_scripts` вызывается
|
||||
`lua.load_ui(<assets>/garrysmod)`. Признак успеха — живой `g_SpawnMenu`;
|
||||
иначе Q открывает встроенное меню (проверка: `SOURCE_RS_NO_LUA_UI=1`).
|
||||
`ui_set_screen` вызывается на старте и на resize — мышь не уезжает.
|
||||
- Рендер: `client/src/ui_overlay.rs` переводит `UiCommand` в `push_rect`
|
||||
(теперь это точный квад, а не растянутый глиф — тонкие линии дерева больше
|
||||
не превращаются в квадраты) и `push_text` (scale = h/12). Цвета VGUI
|
||||
sRGB→linear, иначе серый 128 на экране становился 188. Бюджет — 10k глифов
|
||||
на кадр; команды за пределами экрана отбрасываются.
|
||||
- Ввод: Q/Esc управляют меню, Esc больше не выходит из клиента при открытом
|
||||
меню. Пока меню открыто, игрок заморожен, ЛКМ не стреляет, мышь уходит в
|
||||
`ui_mouse` (`move/down/up/wheel`), колесо не крутит физган. На Android
|
||||
четвёртая кнопка MENU (над JUMP) открывает/закрывает меню; касания в меню
|
||||
переводятся из нормализованных координат SDL в пиксели и идут в `ui_mouse`.
|
||||
- Спавн: `RunConsoleCommand` в Lua теперь кладёт вызовы в очередь
|
||||
(`take_console_commands`). Клиент исполняет `gm_spawn` (модель из
|
||||
`SpawnIcon.DoClick`), а также `gm_spawnsent`/`gm_spawnswep` через
|
||||
`entity_string`/`weapon_string`. Проп появляется на полу перед игроком
|
||||
(`qmenu::spawn_origin`) и проходит обычный путь спавна/рендера.
|
||||
- Снимок: `ui_snapshot_into` переиспользует буфер, `ui_take_errors` отдаёт
|
||||
ошибки Paint/PerformLayout в лог клиента.
|
||||
- Отладка кадров: `SOURCE_RS_QMENU=1`, `SOURCE_RS_UI_CLICK=<текст|x,y>`,
|
||||
`SOURCE_RS_UI_CLOSE=<кадр>`.
|
||||
- Грабли: реальный `DTileLayout` зовёт `ChildrenSize`, `DTree_Node` —
|
||||
`HasChildren`, скин — `HasFocus`/`GetTextSize`/`DrawTextEntryText`; без них
|
||||
сетка иконок имела высоту 0 и клики не попадали, а дерево схлопывалось.
|
||||
Модели по-прежнему без превью (две строки имени), HTML-вкладки — заглушки,
|
||||
длинные подписи дерева обрезаются панелью содержимого.
|
||||
|
||||
---
|
||||
|
||||
## 0.9. Настоящее Q-меню: Derma + Sandbox spawnmenu (2026-10-24, ветка `feat/derma`)
|
||||
|
||||
Q-меню больше не рисуется сеткой на Rust: `crates/lua` получил слой VGUI/Derma
|
||||
|
|
|
|||
12
docs/LUA.md
12
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 (защита от крашедла)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue